From 5dcb8be3aa54c1defba5594eb8c2752e0f14f5a9 Mon Sep 17 00:00:00 2001 From: sindoring Date: Wed, 6 May 2026 23:26:10 +0400 Subject: [PATCH] wip --- .claude/settings.json | 18 + conf/dist/env.ac | 10 + import-custom-sql.sh | 46 + modules/mod-encounter-journal/LICENSE | 5 + modules/mod-encounter-journal/README.md | 191 +++ .../conf/mod_encounter_journal.conf.dist | 83 ++ .../base/01_encounter_journal_schema.sql | 115 ++ .../base/02_encounter_journal_sample.sql | 179 +++ .../data/sql/db-world/updates/.gitkeep | 0 .../docs/encounter_journal_format.md | 125 ++ modules/mod-encounter-journal/include.sh | 2 + .../src/EncounterJournalCommands.cpp | 182 +++ .../src/EncounterJournalLoader.cpp | 83 ++ .../src/EncounterJournalMgr.cpp | 1261 +++++++++++++++++ .../src/EncounterJournalMgr.h | 197 +++ .../tools/json_to_lua.py | 125 ++ 16 files changed, 2622 insertions(+) create mode 100644 .claude/settings.json create mode 100644 modules/mod-encounter-journal/LICENSE create mode 100644 modules/mod-encounter-journal/README.md create mode 100644 modules/mod-encounter-journal/conf/mod_encounter_journal.conf.dist create mode 100644 modules/mod-encounter-journal/data/sql/db-world/base/01_encounter_journal_schema.sql create mode 100644 modules/mod-encounter-journal/data/sql/db-world/base/02_encounter_journal_sample.sql create mode 100644 modules/mod-encounter-journal/data/sql/db-world/updates/.gitkeep create mode 100644 modules/mod-encounter-journal/docs/encounter_journal_format.md create mode 100644 modules/mod-encounter-journal/include.sh create mode 100644 modules/mod-encounter-journal/src/EncounterJournalCommands.cpp create mode 100644 modules/mod-encounter-journal/src/EncounterJournalLoader.cpp create mode 100644 modules/mod-encounter-journal/src/EncounterJournalMgr.cpp create mode 100644 modules/mod-encounter-journal/src/EncounterJournalMgr.h create mode 100644 modules/mod-encounter-journal/tools/json_to_lua.py diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..1ea40c3 --- /dev/null +++ b/.claude/settings.json @@ -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$\")" + ] + } +} diff --git a/conf/dist/env.ac b/conf/dist/env.ac index 3f17c4d..f286d59 100644 --- a/conf/dist/env.ac +++ b/conf/dist/env.ac @@ -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 diff --git a/import-custom-sql.sh b/import-custom-sql.sh index ed58559..75d3bb0 100644 --- a/import-custom-sql.sh +++ b/import-custom-sql.sh @@ -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 < 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" diff --git a/modules/mod-encounter-journal/LICENSE b/modules/mod-encounter-journal/LICENSE new file mode 100644 index 0000000..c05524f --- /dev/null +++ b/modules/mod-encounter-journal/LICENSE @@ -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 diff --git a/modules/mod-encounter-journal/README.md b/modules/mod-encounter-journal/README.md new file mode 100644 index 0000000..efc5c74 --- /dev/null +++ b/modules/mod-encounter-journal/README.md @@ -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. diff --git a/modules/mod-encounter-journal/conf/mod_encounter_journal.conf.dist b/modules/mod-encounter-journal/conf/mod_encounter_journal.conf.dist new file mode 100644 index 0000000..a5bfdb3 --- /dev/null +++ b/modules/mod-encounter-journal/conf/mod_encounter_journal.conf.dist @@ -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 diff --git a/modules/mod-encounter-journal/data/sql/db-world/base/01_encounter_journal_schema.sql b/modules/mod-encounter-journal/data/sql/db-world/base/01_encounter_journal_schema.sql new file mode 100644 index 0000000..538facb --- /dev/null +++ b/modules/mod-encounter-journal/data/sql/db-world/base/01_encounter_journal_schema.sql @@ -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.'; diff --git a/modules/mod-encounter-journal/data/sql/db-world/base/02_encounter_journal_sample.sql b/modules/mod-encounter-journal/data/sql/db-world/base/02_encounter_journal_sample.sql new file mode 100644 index 0000000..c7042d9 --- /dev/null +++ b/modules/mod-encounter-journal/data/sql/db-world/base/02_encounter_journal_sample.sql @@ -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; diff --git a/modules/mod-encounter-journal/data/sql/db-world/updates/.gitkeep b/modules/mod-encounter-journal/data/sql/db-world/updates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/modules/mod-encounter-journal/docs/encounter_journal_format.md b/modules/mod-encounter-journal/docs/encounter_journal_format.md new file mode 100644 index 0000000..aa636b7 --- /dev/null +++ b/modules/mod-encounter-journal/docs/encounter_journal_format.md @@ -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`. diff --git a/modules/mod-encounter-journal/include.sh b/modules/mod-encounter-journal/include.sh new file mode 100644 index 0000000..7d4654a --- /dev/null +++ b/modules/mod-encounter-journal/include.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +MOD_ENCOUNTER_JOURNAL_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/" && pwd )" diff --git a/modules/mod-encounter-journal/src/EncounterJournalCommands.cpp b/modules/mod-encounter-journal/src/EncounterJournalCommands.cpp new file mode 100644 index 0000000..683b382 --- /dev/null +++ b/modules/mod-encounter-journal/src/EncounterJournalCommands.cpp @@ -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 +#include + +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 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 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 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 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(); +} diff --git a/modules/mod-encounter-journal/src/EncounterJournalLoader.cpp b/modules/mod-encounter-journal/src/EncounterJournalLoader.cpp new file mode 100644 index 0000000..39a9e4f --- /dev/null +++ b/modules/mod-encounter-journal/src/EncounterJournalLoader.cpp @@ -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("EncounterJournal.Enable", true)) + { + LOG_INFO("server.loading", ">> mod-encounter-journal disabled " + "via EncounterJournal.Enable=0."); + return; + } + + sEncounterJournalMgr->Load(); + + if (sConfigMgr->GetOption("EncounterJournal.ImportOnStart", false)) + { + MoonWell::EncounterJournal::Mgr::SkeletonCounts sc; + std::vector 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 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("EncounterJournal.ExportOnStart", false)) + return; + + std::vector 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("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)."); +} diff --git a/modules/mod-encounter-journal/src/EncounterJournalMgr.cpp b/modules/mod-encounter-journal/src/EncounterJournalMgr.cpp new file mode 100644 index 0000000..e84b745 --- /dev/null +++ b/modules/mod-encounter-journal/src/EncounterJournalMgr.cpp @@ -0,0 +1,1261 @@ +/* + * 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 "Config.h" +#include "Common.h" +#include "DatabaseEnv.h" +#include "DBCStores.h" +#include "Field.h" +#include "Log.h" +#include "ObjectMgr.h" +#include "QueryResult.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace MoonWell::EncounterJournal +{ + namespace + { + std::string EscapeJsonString(std::string const& in) + { + std::string out; + out.reserve(in.size() + 2); + for (unsigned char c : in) + { + switch (c) + { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (c < 0x20) + { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", c); + out += buf; + } + else + out += static_cast(c); + break; + } + } + return out; + } + + // Lua single-quoted string literal escaper. + std::string EscapeLuaString(std::string const& in) + { + std::string out; + out.reserve(in.size() + 2); + for (unsigned char c : in) + { + switch (c) + { + case '\'': out += "\\'"; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + default: out += static_cast(c); break; + } + } + return out; + } + + std::string FormatFloat(float v) + { + char buf[32]; + // %.6g gives stable output without trailing zeros and matches Lua's + // default number formatting closely. + std::snprintf(buf, sizeof(buf), "%.6g", v); + return buf; + } + + // Stable sort key (sort_order asc, id asc). + template + bool BySortOrderThenId(T const& a, T const& b) + { + return std::tie(a.sortOrder, a.id) < std::tie(b.sortOrder, b.id); + } + } + + Mgr* Mgr::instance() + { + static Mgr inst; + return &inst; + } + + void Mgr::Unload() + { + _tiers.clear(); + _tierInstances.clear(); + _instances.clear(); + _encounters.clear(); + _creatures.clear(); + _sections.clear(); + _loot.clear(); + + _instancesSorted.clear(); + _encountersByInstance.clear(); + _creaturesByEncounter.clear(); + _lootByEncounter.clear(); + _instancesByTier.clear(); + } + + bool Mgr::Load() + { + Unload(); + + // ----- tiers ----- + if (QueryResult result = WorldDatabase.Query( + "SELECT `id`, `name`, `flags`, `sort_order` FROM `custom_ej_tier`")) + { + do + { + Field* f = result->Fetch(); + Tier t; + t.id = f[0].Get(); + t.name = f[1].Get(); + t.flags = f[2].Get(); + t.sortOrder = f[3].Get(); + _tiers.push_back(std::move(t)); + } while (result->NextRow()); + } + std::sort(_tiers.begin(), _tiers.end(), BySortOrderThenId); + + // ----- instances ----- + if (QueryResult result = WorldDatabase.Query( + "SELECT `id`, `name`, `description`, `button_icon`, `small_button_icon`, " + "`background`, `lore_background`, `map_id`, `area_id`, " + "`world_map_area_id`, `flags`, `sort_order` FROM `custom_ej_instance`")) + { + do + { + Field* f = result->Fetch(); + Instance i; + i.id = f[0].Get(); + i.name = f[1].Get(); + i.description = f[2].Get(); + i.buttonIcon = f[3].Get(); + i.smallButtonIcon = f[4].Get(); + i.background = f[5].Get(); + i.loreBackground = f[6].Get(); + i.mapId = f[7].Get(); + i.areaId = f[8].Get(); + i.worldMapAreaId = f[9].Get(); + i.flags = f[10].Get(); + i.sortOrder = f[11].Get(); + _instances.emplace(i.id, std::move(i)); + } while (result->NextRow()); + } + + // ----- tier_instance ----- + if (QueryResult result = WorldDatabase.Query( + "SELECT `tier_id`, `instance_id`, `sort_order` FROM `custom_ej_tier_instance`")) + { + do + { + Field* f = result->Fetch(); + TierInstance ti; + ti.tierId = f[0].Get(); + ti.instanceId = f[1].Get(); + ti.sortOrder = f[2].Get(); + _tierInstances.push_back(ti); + } while (result->NextRow()); + } + + // ----- encounters ----- + if (QueryResult result = WorldDatabase.Query( + "SELECT `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` FROM `custom_ej_encounter`")) + { + do + { + Field* f = result->Fetch(); + Encounter e; + e.id = f[0].Get(); + e.instanceId = f[1].Get(); + e.name = f[2].Get(); + e.description = f[3].Get(); + e.mapPositionX = f[4].Get(); + e.mapPositionY = f[5].Get(); + e.floorIndex = f[6].Get(); + e.worldMapAreaId = f[7].Get(); + e.firstSectionId = f[8].Get(); + e.difficultyMask = f[9].Get(); + e.flags = f[10].Get(); + e.sortOrder = f[11].Get(); + _encounters.emplace(e.id, std::move(e)); + } while (result->NextRow()); + } + + // ----- creatures ----- + if (QueryResult result = WorldDatabase.Query( + "SELECT `id`, `encounter_id`, `creature_id`, `name`, `description`, " + "`creature_display_id`, `icon`, `difficulty_mask`, `sort_order` " + "FROM `custom_ej_creature`")) + { + do + { + Field* f = result->Fetch(); + Creature c; + c.id = f[0].Get(); + c.encounterId = f[1].Get(); + c.creatureId = f[2].Get(); + c.name = f[3].Get(); + c.description = f[4].Get(); + c.creatureDisplayId = f[5].Get(); + c.icon = f[6].Get(); + c.difficultyMask = f[7].Get(); + c.sortOrder = f[8].Get(); + _creatures.emplace(c.id, std::move(c)); + } while (result->NextRow()); + } + + // ----- sections ----- + if (QueryResult result = WorldDatabase.Query( + "SELECT `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` " + "FROM `custom_ej_section`")) + { + do + { + Field* f = result->Fetch(); + Section s; + s.id = f[0].Get(); + s.encounterId = f[1].Get(); + s.parentSectionId = f[2].Get(); + s.nextSectionId = f[3].Get(); + s.subSectionId = f[4].Get(); + s.name = f[5].Get(); + s.description = f[6].Get(); + s.creatureDisplayId = f[7].Get(); + s.descriptionSpellId = f[8].Get(); + s.iconSpellId = f[9].Get(); + s.icon = f[10].IsNull() ? "" : f[10].Get(); + s.flags = f[11].Get(); + s.iconFlags = f[12].Get(); + s.sortOrder = f[13].Get(); + s.type = f[14].Get(); + s.difficultyMask = f[15].Get(); + s.creatureId = f[16].Get(); + _sections.emplace(s.id, std::move(s)); + } while (result->NextRow()); + } + + // ----- loot ----- + if (QueryResult result = WorldDatabase.Query( + "SELECT `id`, `encounter_id`, `item_id`, `difficulty_mask`, " + "`faction_mask`, `class_mask`, `flags`, `sort_order` " + "FROM `custom_ej_loot`")) + { + do + { + Field* f = result->Fetch(); + Loot l; + l.id = f[0].Get(); + l.encounterId = f[1].Get(); + l.itemId = f[2].Get(); + l.difficultyMask = f[3].Get(); + l.factionMask = f[4].Get(); + l.classMask = f[5].Get(); + l.flags = f[6].Get(); + l.sortOrder = f[7].Get(); + _loot.push_back(l); + } while (result->NextRow()); + } + + // ---- build sorted access caches ---- + _instancesSorted.reserve(_instances.size()); + for (auto const& [id, _] : _instances) + _instancesSorted.push_back(id); + std::sort(_instancesSorted.begin(), _instancesSorted.end(), + [this](uint32 a, uint32 b) + { + Instance const& ia = _instances.at(a); + Instance const& ib = _instances.at(b); + return std::tie(ia.sortOrder, ia.id) < std::tie(ib.sortOrder, ib.id); + }); + + for (auto const& [id, e] : _encounters) + _encountersByInstance[e.instanceId].push_back(id); + for (auto& [_, ids] : _encountersByInstance) + std::sort(ids.begin(), ids.end(), + [this](uint32 a, uint32 b) + { + Encounter const& ea = _encounters.at(a); + Encounter const& eb = _encounters.at(b); + return std::tie(ea.sortOrder, ea.id) < std::tie(eb.sortOrder, eb.id); + }); + + for (auto const& [id, c] : _creatures) + _creaturesByEncounter[c.encounterId].push_back(id); + for (auto& [_, ids] : _creaturesByEncounter) + std::sort(ids.begin(), ids.end(), + [this](uint32 a, uint32 b) + { + Creature const& ca = _creatures.at(a); + Creature const& cb = _creatures.at(b); + return std::tie(ca.sortOrder, ca.id) < std::tie(cb.sortOrder, cb.id); + }); + + for (std::size_t i = 0; i < _loot.size(); ++i) + _lootByEncounter[_loot[i].encounterId].push_back(i); + for (auto& [_, idxs] : _lootByEncounter) + std::sort(idxs.begin(), idxs.end(), + [this](std::size_t a, std::size_t b) + { + Loot const& la = _loot[a]; + Loot const& lb = _loot[b]; + return std::tie(la.sortOrder, la.id) < std::tie(lb.sortOrder, lb.id); + }); + + // tier -> instance, sorted by tier_instance.sort_order then instance_id + { + std::vector sortedTI = _tierInstances; + std::sort(sortedTI.begin(), sortedTI.end(), + [](TierInstance const& a, TierInstance const& b) + { + return std::tie(a.sortOrder, a.instanceId) + < std::tie(b.sortOrder, b.instanceId); + }); + for (auto const& ti : sortedTI) + _instancesByTier[ti.tierId].push_back(ti.instanceId); + } + + LOG_INFO("server.loading", + ">> Encounter Journal loaded: {} tiers, {} instances, " + "{} encounters, {} creatures, {} sections, {} loot rows.", + _tiers.size(), _instances.size(), _encounters.size(), + _creatures.size(), _sections.size(), _loot.size()); + + return true; + } + + std::string Mgr::ResolveExportPath(std::string const& filename) const + { + std::string base = sConfigMgr->GetOption( + "EncounterJournal.Export.Path", ""); + + std::filesystem::path p; + if (base.empty()) + p = std::filesystem::current_path(); + else + p = base; + p /= filename; + return p.lexically_normal().string(); + } + + bool Mgr::Export(std::vector& writtenPaths, std::string& error) + { + std::string jsonName = sConfigMgr->GetOption( + "EncounterJournal.Export.JsonFile", "EncounterJournalData.json"); + std::string luaName = sConfigMgr->GetOption( + "EncounterJournal.Export.LuaFile", "EncounterJournalData.lua"); + std::string luaGlobal = sConfigMgr->GetOption( + "EncounterJournal.Export.LuaGlobal", "MoonWellEncounterJournalData"); + + if (jsonName.empty() && luaName.empty()) + { + error = "both EncounterJournal.Export.JsonFile and " + "EncounterJournal.Export.LuaFile are empty"; + return false; + } + + if (!jsonName.empty()) + { + std::string path = ResolveExportPath(jsonName); + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out) + { + error = "failed to open " + path + " for writing"; + return false; + } + out << BuildJson(); + out.close(); + writtenPaths.push_back(std::move(path)); + } + + if (!luaName.empty()) + { + std::string path = ResolveExportPath(luaName); + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out) + { + error = "failed to open " + path + " for writing"; + return false; + } + out << BuildLua(luaGlobal); + out.close(); + writtenPaths.push_back(std::move(path)); + } + + return true; + } + + // ----------------------------------------------------------------------- + // JSON writer + // ----------------------------------------------------------------------- + std::string Mgr::BuildJson() const + { + std::ostringstream o; + auto const Q = [](std::string const& s) { return "\"" + EscapeJsonString(s) + "\""; }; + + o << "{\n"; + + // tiers : [[id, name, flags, sort_order], ...] + o << " \"tiers\": ["; + for (std::size_t i = 0; i < _tiers.size(); ++i) + { + Tier const& t = _tiers[i]; + if (i) o << ", "; + o << "[" << t.id << ", " << Q(t.name) << ", " << t.flags + << ", " << t.sortOrder << "]"; + } + o << "],\n"; + + // instances + o << " \"instances\": {"; + bool first = true; + for (uint32 instanceId : _instancesSorted) + { + Instance const& i = _instances.at(instanceId); + if (!first) o << ","; + first = false; + o << "\n \"" << instanceId << "\": [" + << Q(i.name) << ", " + << Q(i.description) << ", " + << Q(i.buttonIcon) << ", " + << Q(i.smallButtonIcon) << ", " + << Q(i.background) << ", " + << Q(i.loreBackground) << ", " + << i.mapId << ", " + << i.areaId << ", " + << i.sortOrder << ", " + << i.flags << ", " + << i.id << ", " + << i.worldMapAreaId + << "]"; + } + o << (first ? "" : "\n ") << "},\n"; + + // tierInstances + o << " \"tierInstances\": {"; + first = true; + std::vector tierKeys; + tierKeys.reserve(_instancesByTier.size()); + for (auto const& [tierId, _] : _instancesByTier) + tierKeys.push_back(tierId); + std::sort(tierKeys.begin(), tierKeys.end()); + for (uint32 tierId : tierKeys) + { + auto const& ids = _instancesByTier.at(tierId); + if (!first) o << ","; + first = false; + o << "\n \"" << tierId << "\": ["; + for (std::size_t k = 0; k < ids.size(); ++k) + { + if (k) o << ", "; + o << ids[k]; + } + o << "]"; + } + o << (first ? "" : "\n ") << "},\n"; + + // encounters: keyed by instance_id + o << " \"encounters\": {"; + first = true; + for (uint32 instanceId : _instancesSorted) + { + auto it = _encountersByInstance.find(instanceId); + if (it == _encountersByInstance.end()) + continue; + if (!first) o << ","; + first = false; + o << "\n \"" << instanceId << "\": ["; + for (std::size_t k = 0; k < it->second.size(); ++k) + { + Encounter const& e = _encounters.at(it->second[k]); + if (k) o << ", "; + o << "[" << e.id << ", " + << Q(e.name) << ", " + << Q(e.description) << ", " + << FormatFloat(e.mapPositionX) << ", " + << FormatFloat(e.mapPositionY) << ", " + << e.floorIndex << ", " + << e.worldMapAreaId << ", " + << e.firstSectionId << ", " + << e.instanceId << ", " + << e.difficultyMask << ", " + << e.flags << ", " + << e.sortOrder << "]"; + } + o << "]"; + } + o << (first ? "" : "\n ") << "},\n"; + + // creatures: keyed by encounter_id + o << " \"creatures\": {"; + first = true; + std::vector encounterKeys; + encounterKeys.reserve(_creaturesByEncounter.size()); + for (auto const& [encId, _] : _creaturesByEncounter) + encounterKeys.push_back(encId); + std::sort(encounterKeys.begin(), encounterKeys.end()); + for (uint32 encounterId : encounterKeys) + { + auto const& ids = _creaturesByEncounter.at(encounterId); + if (!first) o << ","; + first = false; + o << "\n \"" << encounterId << "\": ["; + for (std::size_t k = 0; k < ids.size(); ++k) + { + Creature const& c = _creatures.at(ids[k]); + if (k) o << ", "; + o << "[" << Q(c.name) << ", " + << Q(c.description) << ", " + << c.creatureDisplayId << ", " + << Q(c.icon) << ", " + << c.encounterId << ", " + << c.sortOrder << ", " + << c.id << ", " + << c.creatureId << ", " + << c.difficultyMask << "]"; + } + o << "]"; + } + o << (first ? "" : "\n ") << "},\n"; + + // sections: keyed by section_id (single row) + o << " \"sections\": {"; + first = true; + // stable order: by id + std::vector sectionIds; + sectionIds.reserve(_sections.size()); + for (auto const& [id, _] : _sections) + sectionIds.push_back(id); + std::sort(sectionIds.begin(), sectionIds.end()); + for (uint32 sid : sectionIds) + { + Section const& s = _sections.at(sid); + if (!first) o << ","; + first = false; + o << "\n \"" << sid << "\": [" + << s.id << ", " + << Q(s.name) << ", " + << Q(s.description) << ", " + << s.creatureDisplayId << ", " + << s.descriptionSpellId << ", " + << s.iconSpellId << ", " + << s.encounterId << ", " + << s.nextSectionId << ", " + << s.subSectionId << ", " + << s.parentSectionId << ", " + << s.flags << ", " + << s.iconFlags << ", " + << s.sortOrder << ", " + << s.type << ", " + << s.difficultyMask << ", " + << s.creatureId + << "]"; + } + o << (first ? "" : "\n ") << "},\n"; + + // items: keyed by encounter_id + o << " \"items\": {"; + first = true; + std::vector lootKeys; + lootKeys.reserve(_lootByEncounter.size()); + for (auto const& [encId, _] : _lootByEncounter) + lootKeys.push_back(encId); + std::sort(lootKeys.begin(), lootKeys.end()); + for (uint32 encounterId : lootKeys) + { + auto const& idxs = _lootByEncounter.at(encounterId); + if (!first) o << ","; + first = false; + o << "\n \"" << encounterId << "\": ["; + for (std::size_t k = 0; k < idxs.size(); ++k) + { + Loot const& l = _loot[idxs[k]]; + if (k) o << ", "; + o << "[" << l.itemId << ", " + << l.encounterId << ", " + << l.difficultyMask << ", " + << l.factionMask << ", " + << l.flags << ", " + << l.id << ", " + << l.classMask << "]"; + } + o << "]"; + } + o << (first ? "" : "\n ") << "}\n"; + + o << "}\n"; + return o.str(); + } + + // ----------------------------------------------------------------------- + // Lua writer (mirrors the JSON layout 1:1; addons can index the same way) + // ----------------------------------------------------------------------- + std::string Mgr::BuildLua(std::string const& globalName) const + { + std::ostringstream o; + auto const Q = [](std::string const& s) { return "'" + EscapeLuaString(s) + "'"; }; + + o << "-- Generated by mod-encounter-journal. Do not edit by hand.\n"; + o << "-- Source of truth: custom_ej_* tables in acore_world.\n\n"; + o << globalName << " = {\n"; + + // tiers + o << " tiers = {\n"; + for (Tier const& t : _tiers) + { + o << " { " << t.id << ", " << Q(t.name) << ", " + << t.flags << ", " << t.sortOrder << " },\n"; + } + o << " },\n"; + + // instances + o << " instances = {\n"; + for (uint32 id : _instancesSorted) + { + Instance const& i = _instances.at(id); + o << " [" << id << "] = { " + << Q(i.name) << ", " + << Q(i.description) << ", " + << Q(i.buttonIcon) << ", " + << Q(i.smallButtonIcon) << ", " + << Q(i.background) << ", " + << Q(i.loreBackground) << ", " + << i.mapId << ", " + << i.areaId << ", " + << i.sortOrder << ", " + << i.flags << ", " + << i.id << ", " + << i.worldMapAreaId + << " },\n"; + } + o << " },\n"; + + // tierInstances + o << " tierInstances = {\n"; + std::vector tierKeys; + tierKeys.reserve(_instancesByTier.size()); + for (auto const& [tierId, _] : _instancesByTier) + tierKeys.push_back(tierId); + std::sort(tierKeys.begin(), tierKeys.end()); + for (uint32 tierId : tierKeys) + { + auto const& ids = _instancesByTier.at(tierId); + o << " [" << tierId << "] = { "; + for (std::size_t k = 0; k < ids.size(); ++k) + { + if (k) o << ", "; + o << ids[k]; + } + o << " },\n"; + } + o << " },\n"; + + // encounters + o << " encounters = {\n"; + for (uint32 instanceId : _instancesSorted) + { + auto it = _encountersByInstance.find(instanceId); + if (it == _encountersByInstance.end()) + continue; + o << " [" << instanceId << "] = {\n"; + for (uint32 encId : it->second) + { + Encounter const& e = _encounters.at(encId); + o << " { " << e.id << ", " + << Q(e.name) << ", " + << Q(e.description) << ", " + << FormatFloat(e.mapPositionX) << ", " + << FormatFloat(e.mapPositionY) << ", " + << e.floorIndex << ", " + << e.worldMapAreaId << ", " + << e.firstSectionId << ", " + << e.instanceId << ", " + << e.difficultyMask << ", " + << e.flags << ", " + << e.sortOrder << " },\n"; + } + o << " },\n"; + } + o << " },\n"; + + // creatures + o << " creatures = {\n"; + std::vector creatureEncIds; + creatureEncIds.reserve(_creaturesByEncounter.size()); + for (auto const& [encId, _] : _creaturesByEncounter) + creatureEncIds.push_back(encId); + std::sort(creatureEncIds.begin(), creatureEncIds.end()); + for (uint32 encounterId : creatureEncIds) + { + auto const& ids = _creaturesByEncounter.at(encounterId); + o << " [" << encounterId << "] = {\n"; + for (uint32 cid : ids) + { + Creature const& c = _creatures.at(cid); + o << " { " << Q(c.name) << ", " + << Q(c.description) << ", " + << c.creatureDisplayId << ", " + << Q(c.icon) << ", " + << c.encounterId << ", " + << c.sortOrder << ", " + << c.id << ", " + << c.creatureId << ", " + << c.difficultyMask << " },\n"; + } + o << " },\n"; + } + o << " },\n"; + + // sections + o << " sections = {\n"; + std::vector sectionIds; + sectionIds.reserve(_sections.size()); + for (auto const& [id, _] : _sections) + sectionIds.push_back(id); + std::sort(sectionIds.begin(), sectionIds.end()); + for (uint32 sid : sectionIds) + { + Section const& s = _sections.at(sid); + o << " [" << sid << "] = { " + << s.id << ", " + << Q(s.name) << ", " + << Q(s.description) << ", " + << s.creatureDisplayId << ", " + << s.descriptionSpellId << ", " + << s.iconSpellId << ", " + << s.encounterId << ", " + << s.nextSectionId << ", " + << s.subSectionId << ", " + << s.parentSectionId << ", " + << s.flags << ", " + << s.iconFlags << ", " + << s.sortOrder << ", " + << s.type << ", " + << s.difficultyMask << ", " + << s.creatureId + << " },\n"; + } + o << " },\n"; + + // items + o << " items = {\n"; + std::vector lootEncIds; + lootEncIds.reserve(_lootByEncounter.size()); + for (auto const& [encId, _] : _lootByEncounter) + lootEncIds.push_back(encId); + std::sort(lootEncIds.begin(), lootEncIds.end()); + for (uint32 encounterId : lootEncIds) + { + auto const& idxs = _lootByEncounter.at(encounterId); + o << " [" << encounterId << "] = {\n"; + for (std::size_t idx : idxs) + { + Loot const& l = _loot[idx]; + o << " { " << l.itemId << ", " + << l.encounterId << ", " + << l.difficultyMask << ", " + << l.factionMask << ", " + << l.flags << ", " + << l.id << ", " + << l.classMask << " },\n"; + } + o << " },\n"; + } + o << " },\n"; + + o << "}\n"; + return o.str(); + } + + // ----------------------------------------------------------------------- + // Validator + // ----------------------------------------------------------------------- + std::size_t Mgr::Validate(std::vector& errors) + { + std::size_t const startSize = errors.size(); + auto err = [&errors](std::string msg) { errors.push_back(std::move(msg)); }; + + // build helper sets + std::unordered_set tierIds; + for (Tier const& t : _tiers) tierIds.insert(t.id); + + std::unordered_set instanceIds; + for (auto const& [id, _] : _instances) instanceIds.insert(id); + + std::unordered_set encounterIds; + for (auto const& [id, _] : _encounters) encounterIds.insert(id); + + std::unordered_set sectionIds; + for (auto const& [id, _] : _sections) sectionIds.insert(id); + + // tier_instance references + for (TierInstance const& ti : _tierInstances) + { + if (!tierIds.count(ti.tierId)) + err("custom_ej_tier_instance: tier_id=" + std::to_string(ti.tierId) + + " does not exist (instance_id=" + std::to_string(ti.instanceId) + ")"); + if (!instanceIds.count(ti.instanceId)) + err("custom_ej_tier_instance: instance_id=" + std::to_string(ti.instanceId) + + " does not exist (tier_id=" + std::to_string(ti.tierId) + ")"); + } + + // encounter -> instance + difficulty mask vs instance type + constexpr uint32 INSTANCE_FLAG_RAID = 0x10; + constexpr uint32 RAID_DIFFICULTY_BITS = 0xF; // 10n|25n|10h|25h + constexpr uint32 DUNGEON_DIFFICULTY_BITS = 0x7; // normal|heroic|mythic + + for (auto const& [id, e] : _encounters) + { + auto instIt = _instances.find(e.instanceId); + if (instIt == _instances.end()) + { + err("custom_ej_encounter id=" + std::to_string(e.id) + + ": instance_id=" + std::to_string(e.instanceId) + + " does not exist"); + continue; + } + if (e.firstSectionId != 0 && !sectionIds.count(e.firstSectionId)) + err("custom_ej_encounter id=" + std::to_string(e.id) + + ": first_section_id=" + std::to_string(e.firstSectionId) + + " does not exist"); + + bool isRaid = (instIt->second.flags & INSTANCE_FLAG_RAID) != 0; + uint32 allowed = isRaid ? RAID_DIFFICULTY_BITS : DUNGEON_DIFFICULTY_BITS; + if (e.difficultyMask != 0 && (e.difficultyMask & ~allowed) != 0) + { + char hex[16]; + std::snprintf(hex, sizeof(hex), "%X", e.difficultyMask); + err("custom_ej_encounter id=" + std::to_string(e.id) + + ": difficulty_mask=0x" + hex + + " has bits outside the " + + (isRaid ? "raid (0xF)" : "dungeon (0x7)") + " range"); + } + } + + // creature.encounter_id + creature.creature_id + for (auto const& [id, c] : _creatures) + { + if (!encounterIds.count(c.encounterId)) + err("custom_ej_creature id=" + std::to_string(c.id) + + ": encounter_id=" + std::to_string(c.encounterId) + + " does not exist"); + if (c.creatureId != 0 && !sObjectMgr->GetCreatureTemplate(c.creatureId)) + err("custom_ej_creature id=" + std::to_string(c.id) + + ": creature_id=" + std::to_string(c.creatureId) + + " does not exist in creature_template"); + } + + // section.encounter_id + parent/next/sub references + for (auto const& [id, s] : _sections) + { + if (!encounterIds.count(s.encounterId)) + err("custom_ej_section id=" + std::to_string(s.id) + + ": encounter_id=" + std::to_string(s.encounterId) + + " does not exist"); + for (auto const& [field, value] : std::initializer_list>{ + {"parent_section_id", s.parentSectionId}, + {"next_section_id", s.nextSectionId}, + {"sub_section_id", s.subSectionId}}) + { + if (value != 0 && !sectionIds.count(value)) + err(std::string("custom_ej_section id=") + std::to_string(s.id) + + ": " + field + "=" + std::to_string(value) + + " does not exist"); + } + if (s.creatureId != 0 && !sObjectMgr->GetCreatureTemplate(s.creatureId)) + err("custom_ej_section id=" + std::to_string(s.id) + + ": creature_id=" + std::to_string(s.creatureId) + + " does not exist in creature_template"); + } + + // loot.encounter_id + loot.item_id + duplicate detection + std::set> seen; + for (Loot const& l : _loot) + { + if (!encounterIds.count(l.encounterId)) + err("custom_ej_loot id=" + std::to_string(l.id) + + ": encounter_id=" + std::to_string(l.encounterId) + + " does not exist"); + if (!sObjectMgr->GetItemTemplate(l.itemId)) + err("custom_ej_loot id=" + std::to_string(l.id) + + ": item_id=" + std::to_string(l.itemId) + + " does not exist in item_template"); + + auto key = std::make_tuple( + l.encounterId, l.itemId, l.difficultyMask, + l.factionMask, l.classMask); + if (!seen.insert(key).second) + err("custom_ej_loot id=" + std::to_string(l.id) + + ": duplicate (encounter_id, item_id, difficulty_mask, " + "faction_mask, class_mask)"); + } + + return errors.size() - startSize; + } + + // ----------------------------------------------------------------------- + // Loot importer (creature_template.lootid -> creature_loot_template, + // following one level of reference_loot_template). + // ----------------------------------------------------------------------- + std::size_t Mgr::ImportLoot(std::vector& warnings) + { + std::size_t inserted = 0; + + // Group: encounter_id -> (set of unique creature template lootids, + // first creature card's difficulty mask) + struct Bucket { uint32 lootId; uint32 difficultyMask; }; + std::vector> work; + + for (auto const& [id, c] : _creatures) + { + if (c.creatureId == 0) + continue; + CreatureTemplate const* ct = sObjectMgr->GetCreatureTemplate(c.creatureId); + if (!ct) + { + warnings.push_back("creature_id=" + std::to_string(c.creatureId) + + " (custom_ej_creature.id=" + std::to_string(c.id) + + ") missing in creature_template"); + continue; + } + if (ct->lootid == 0) + continue; + + work.push_back({ c.encounterId, { ct->lootid, c.difficultyMask } }); + } + + // Already-present loot rows so we can skip without round-tripping + // (the unique key would catch it anyway, but this keeps the count clean). + std::set> already; + for (Loot const& l : _loot) + already.emplace(l.encounterId, l.itemId, l.difficultyMask); + + for (auto const& [encounterId, b] : work) + { + // Pull primary loot rows (Reference < 0 represents a reference id when + // mode + chance use the reference convention; AzerothCore's actual + // creature_loot_template stores Reference > 0 to point at + // reference_loot_template.Entry, with Item == 0). + QueryResult res = WorldDatabase.Query( + "SELECT `Item`, `Reference` FROM `creature_loot_template` " + "WHERE `Entry` = {}", b.lootId); + if (!res) + continue; + + std::set items; + std::set refs; + do + { + Field* f = res->Fetch(); + uint32 item = f[0].Get(); + uint32 ref = f[1].Get(); + if (item != 0) + items.insert(item); + if (ref != 0) + refs.insert(ref); + } while (res->NextRow()); + + for (uint32 ref : refs) + { + QueryResult r2 = WorldDatabase.Query( + "SELECT `Item` FROM `reference_loot_template` " + "WHERE `Entry` = {} AND `Item` <> 0", ref); + if (!r2) continue; + do + { + items.insert(r2->Fetch()[0].Get()); + } while (r2->NextRow()); + } + + int32 sortOrder = 0; + for (uint32 itemId : items) + { + if (already.count({ encounterId, itemId, b.difficultyMask })) + continue; + if (!sObjectMgr->GetItemTemplate(itemId)) + { + warnings.push_back("skipping unknown item_id=" + std::to_string(itemId) + + " (encounter_id=" + std::to_string(encounterId) + ")"); + continue; + } + + WorldDatabase.DirectExecute( + "INSERT IGNORE INTO `custom_ej_loot` " + "(`encounter_id`, `item_id`, `difficulty_mask`, " + " `faction_mask`, `class_mask`, `flags`, `sort_order`) " + "VALUES ({}, {}, {}, -1, -1, 0, {})", + encounterId, itemId, b.difficultyMask, sortOrder); + already.emplace(encounterId, itemId, b.difficultyMask); + sortOrder += 10; + ++inserted; + } + } + + return inserted; + } + + // ----------------------------------------------------------------------- + // Skeleton importer: builds instance/encounter/creature/section rows from + // standard AzerothCore tables (instance_template, instance_encounters, + // creature_template) and the loaded Map.dbc store. Descriptions stay + // empty — they are not derivable from server-side data on WotLK. + // + // ID assignment (chosen to never collide with the sample data range + // 9000-9999 and to be re-runnable via INSERT IGNORE): + // tier : 100 + expansionID (100/101/102) + // instance : map_id (1..max ~700) + // encounter : 100000 + instance_encounters.entry (1..2000ish) + // creature : 100000 + instance_encounters.entry (1:1 to enc) + // section : (encounter_id * 10) + 1 (Overview) + // ----------------------------------------------------------------------- + std::size_t Mgr::ImportSkeleton(SkeletonCounts& counts, + std::vector& warnings) + { + constexpr uint32 TIER_BASE = 100; + constexpr uint32 ENCOUNTER_BASE = 100000; + constexpr uint32 INSTANCE_FLAG_RAID = 0x10; + + auto bestLocaleString = [](char const* const* names) -> std::string + { + // Prefer ruRU, fall back to enUS, then anything non-empty. + if (names[LOCALE_ruRU] && *names[LOCALE_ruRU]) + return names[LOCALE_ruRU]; + if (names[LOCALE_enUS] && *names[LOCALE_enUS]) + return names[LOCALE_enUS]; + for (int i = 0; i < TOTAL_LOCALES; ++i) + if (names[i] && *names[i]) + return names[i]; + return std::string(); + }; + + // Helper that injects a single row via DirectExecute and returns + // whether the insert produced a new row (rows-affected > 0). We use + // the pool's DirectExecute for synchronous semantics so the counter + // stays accurate; the unique key / PK guarantees idempotence. + auto execIgnore = [](std::string const& sql) -> bool + { + // INSERT IGNORE silently drops dup-key conflicts; we trust it. + WorldDatabase.DirectExecute(sql); + return true; + }; + + // ---- 1. Collect map -> set from spawn data -- + // We need to know which map each instance_encounters row belongs to. + // The DBC has no direct map column, so we look up where the credit + // creature spawns and pick the most common map id. + std::unordered_map> creaturesByMap; + if (QueryResult q = WorldDatabase.Query( + "SELECT DISTINCT id1, map FROM creature WHERE id1 <> 0 " + "UNION SELECT DISTINCT id2, map FROM creature WHERE id2 <> 0 " + "UNION SELECT DISTINCT id3, map FROM creature WHERE id3 <> 0")) + { + do + { + Field* f = q->Fetch(); + creaturesByMap[f[1].Get()].push_back(f[0].Get()); + } while (q->NextRow()); + } + std::unordered_map creatureToMap; + for (auto const& [mapId, creatures] : creaturesByMap) + for (uint32 entry : creatures) + if (!creatureToMap.count(entry)) + creatureToMap[entry] = mapId; + + // ---- 2. tiers (one per expansion that has at least one instance) --- + std::set expansionsUsed; + std::map instanceMaps; // map_id -> MapEntry + if (QueryResult q = WorldDatabase.Query( + "SELECT DISTINCT map FROM instance_template")) + { + do + { + uint32 mapId = q->Fetch()[0].Get(); + MapEntry const* me = sMapStore.LookupEntry(mapId); + if (!me) + { + warnings.push_back("instance_template.map=" + + std::to_string(mapId) + " missing in Map.dbc; skipped"); + continue; + } + if (!me->IsDungeon() && !me->IsRaid()) + continue; + instanceMaps[mapId] = me; + expansionsUsed.insert(me->expansionID); + } while (q->NextRow()); + } + + char const* expansionName[] = + { "Classic", "The Burning Crusade", "Wrath of the Lich King" }; + for (uint32 exp : expansionsUsed) + { + uint32 tierId = TIER_BASE + exp; + char const* name = (exp < std::size(expansionName)) + ? expansionName[exp] : "Unknown"; + WorldDatabase.DirectExecute( + "INSERT IGNORE INTO `custom_ej_tier` " + "(`id`, `name`, `flags`, `sort_order`) VALUES " + "({}, '{}', 0, {})", + tierId, name, exp * 10); + ++counts.tiers; + } + + // ---- 3. instances ---- + for (auto const& [mapId, me] : instanceMaps) + { + std::string name = bestLocaleString(me->name); + if (name.empty()) + name = "Map " + std::to_string(mapId); + + // SQL escape: replace single quotes (rare in map names but safe). + std::string esc; + esc.reserve(name.size()); + for (char c : name) { if (c == '\'') esc += "''"; else esc += c; } + + uint32 flags = me->IsRaid() ? INSTANCE_FLAG_RAID : 0u; + + WorldDatabase.DirectExecute( + "INSERT IGNORE 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 ({}, '{}', '', '', '', '', '', {}, 0, 0, {}, {})", + mapId, esc, mapId, flags, mapId); + ++counts.instances; + + uint32 tierId = TIER_BASE + me->expansionID; + WorldDatabase.DirectExecute( + "INSERT IGNORE INTO `custom_ej_tier_instance` " + "(`tier_id`, `instance_id`, `sort_order`) VALUES ({}, {}, {})", + tierId, mapId, mapId); + ++counts.tierInstances; + } + + // ---- 4. encounters + creatures + sections from instance_encounters + QueryResult ienc = WorldDatabase.Query( + "SELECT `entry`, `creditType`, `creditEntry`, `comment` " + "FROM `instance_encounters` " + "WHERE `creditType` = 0 ORDER BY `entry`"); + if (!ienc) + { + warnings.push_back("instance_encounters table empty or missing"); + return counts.tiers + counts.instances + counts.tierInstances; + } + + int32 sortPerInstance = 10; + uint32 lastInstanceId = 0; + do + { + Field* f = ienc->Fetch(); + uint32 entry = f[0].Get(); + uint32 creature = f[2].Get(); + std::string cmt = f[3].Get(); + + auto mIt = creatureToMap.find(creature); + if (mIt == creatureToMap.end()) + { + warnings.push_back("instance_encounters.entry=" + + std::to_string(entry) + " creature=" + + std::to_string(creature) + + " has no spawn — cannot infer map; skipped"); + continue; + } + uint32 mapId = mIt->second; + auto mapIt = instanceMaps.find(mapId); + if (mapIt == instanceMaps.end()) + continue; // creature spawns outside known instance + + MapEntry const* me = mapIt->second; + uint32 difficultyMask = me->IsRaid() ? 0xFu : 0x3u; + + CreatureTemplate const* ct = sObjectMgr->GetCreatureTemplate(creature); + std::string crName = (ct && !ct->Name.empty()) ? ct->Name : cmt; + uint32 displayId = ct ? ct->Modelid1 : 0; + + std::string crEsc; + crEsc.reserve(crName.size()); + for (char c : crName) { if (c == '\'') crEsc += "''"; else crEsc += c; } + std::string cmtEsc; + cmtEsc.reserve(cmt.size()); + for (char c : cmt) { if (c == '\'') cmtEsc += "''"; else cmtEsc += c; } + + uint32 encounterId = ENCOUNTER_BASE + entry; + uint32 firstSection = encounterId * 10 + 1; + + // reset sort within an instance whenever map changes + if (mapId != lastInstanceId) + { + lastInstanceId = mapId; + sortPerInstance = 10; + } + + WorldDatabase.DirectExecute( + "INSERT IGNORE 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 ({}, {}, '{}', '', 0, 0, 1, 0, {}, {}, 0, {})", + encounterId, mapId, cmtEsc, firstSection, + difficultyMask, sortPerInstance); + ++counts.encounters; + + // 1:1 creature card + WorldDatabase.DirectExecute( + "INSERT IGNORE INTO `custom_ej_creature` " + "(`id`, `encounter_id`, `creature_id`, `name`, `description`, " + " `creature_display_id`, `icon`, `difficulty_mask`, `sort_order`) " + "VALUES ({}, {}, {}, '{}', '', {}, '', {}, 10)", + encounterId, encounterId, creature, crEsc, displayId, + difficultyMask); + ++counts.creatures; + + // Overview section placeholder + WorldDatabase.DirectExecute( + "INSERT IGNORE 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 ({}, {}, 0, 0, 0, 'Overview', '', 0, 0, 0, NULL, 1, " + "0, 10, 0, {}, {})", + firstSection, encounterId, difficultyMask, creature); + ++counts.sections; + + sortPerInstance += 10; + } while (ienc->NextRow()); + + return counts.tiers + counts.instances + counts.tierInstances + + counts.encounters + counts.creatures + counts.sections; + } +} diff --git a/modules/mod-encounter-journal/src/EncounterJournalMgr.h b/modules/mod-encounter-journal/src/EncounterJournalMgr.h new file mode 100644 index 0000000..edeb9de --- /dev/null +++ b/modules/mod-encounter-journal/src/EncounterJournalMgr.h @@ -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 +#include +#include +#include +#include + +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& 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& 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& 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& 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 _tiers; + std::vector _tierInstances; + std::unordered_map _instances; + std::unordered_map _encounters; + std::unordered_map _creatures; + std::unordered_map _sections; + std::vector _loot; + + // Sorted access caches (by sort_order, id). Filled at end of Load(). + std::vector _instancesSorted; + std::unordered_map> _encountersByInstance; + std::unordered_map> _creaturesByEncounter; + std::unordered_map> _lootByEncounter; + std::unordered_map> _instancesByTier; + }; +} + +#define sEncounterJournalMgr ::MoonWell::EncounterJournal::Mgr::instance() + +#endif // MOONWELL_ENCOUNTER_JOURNAL_MGR_H diff --git a/modules/mod-encounter-journal/tools/json_to_lua.py b/modules/mod-encounter-journal/tools/json_to_lua.py new file mode 100644 index 0000000..21d851f --- /dev/null +++ b/modules/mod-encounter-journal/tools/json_to_lua.py @@ -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())