2 Commits

Author SHA1 Message Date
sindoring 02d382c0f8 encounter journal base logic 2026-06-06 19:51:34 +04:00
sindoring 5dcb8be3aa wip 2026-05-06 23:26:10 +04:00
49 changed files with 239209 additions and 3563 deletions
+19 -35
View File
@@ -1,42 +1,26 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(grep -rn \"CREATE TABLE.*\\\\`game_tele\\\\`\" /home/sindo/moonwell-core/data/sql/base/db_world/)", "Bash(command -v lua5.1)",
"Bash(grep -rn \"CREATE TABLE.*\\\\`waypoint_data\\\\`\\\\|CREATE TABLE.*\\\\`creature_addon\\\\`\" /home/sindo/moonwell-core/data/sql/base/db_world/)", "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(bash import-custom-sql.sh)",
"Bash(grep -rn \"WorldDatabase\\\\.Execute\\(\\\\\".*{}.*\\\\\"\\\\|WorldDatabase\\\\.DirectExecute\\(\\\\\".*{}\\\\|WorldDatabase\\\\.Execute\\(\\\\\".*\\\\\".*,\" /home/sindo/moonwell-core/src/server/)", "Bash(awk '/^ ac-worldserver:/,/^ [a-z]/' /home/sindo/moonwell-core/docker-compose.yml)",
"Bash(grep -n \"enum LocaleConstant\\\\|LocaleConstant\\\\s*$\\\\|LocaleConstant{\" /home/sindo/moonwell-core/src/server/shared/SharedDefines.h /home/sindo/moonwell-core/src/common/Common.h)", "Bash(awk '/ac-worldserver:/{flag=1} flag{print; if\\(/depends_on:/\\){flag=0}}' /home/sindo/moonwell-core/docker-compose.yml)",
"Bash(chmod +x /home/sindo/moonwell-core/tools/moonwell-taxi/bin/moonwell-taxi)", "Bash(grep -n \"LoadAdditionalFile\\\\|\\\\.conf.dist\\\\|conf$\" /home/sindo/moonwell-core/src/common/Configuration/Config.cpp)",
"Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py dump-vanilla /home/sindo/moonwell-core/TaxiNodes.dbc --limit 3)", "Bash(grep -n \"^$\\\\|ENCOUNTER\" /home/sindo/moonwell-core/conf/dist/env.ac)",
"Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py dump-vanilla /home/sindo/moonwell-core/TaxiPath.dbc --limit 3)", "Bash(grep -v \"^information_schema\\\\|^mysql$\\\\|^performance_schema\\\\|^sys$\")",
"Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py dump-vanilla /home/sindo/moonwell-core/TaxiPathNode.dbc --limit 3)", "Bash(python3 /home/sindo/moonwell-core/modules/mod-encounter-journal/tools/build_item_cache.py -j /home/sindo/moonwell-core/env/dist/logs/EncounterJournalData.json -d /home/sindo/moonwell-core/Generated_ItemsCache1.lua /home/sindo/moonwell-core/Generated_ItemsCache2.lua /home/sindo/moonwell-core/Generated_ItemsCache3.lua -o /tmp/EncounterJournalItemCache.lua)",
"Bash(python3 -c \"import pymysql; print\\(pymysql.__version__\\)\")", "Bash(awk 'NR>1 && !/^\\\\s*\\\\[[0-9]+\\\\]\\\\s*=\\\\s*\\\\{.*\\\\},?\\\\s*$/ && !/^\\\\}$/' /home/sindo/moonwell-core/Generated_ItemsCache3.lua)",
"Bash(python3 -m pip show pymysql)", "Bash(awk '/^\\\\s*\\\\[[0-9]+\\\\]\\\\s*=\\\\s*\\\\{/' /home/sindo/moonwell-core/Generated_ItemsCache3.lua)",
"Bash(python3 -m venv /home/sindo/moonwell-core/tools/moonwell-taxi/.venv)", "Bash(grep -n \"^[A-Z_]* = {$\\\\|^[A-Z_]* = {\" /home/sindo/moonwell-core/Generated_EncounterJournal.lua)",
"Bash(/home/sindo/moonwell-core/tools/moonwell-taxi/.venv/bin/python -m pip install --quiet -r /home/sindo/moonwell-core/tools/moonwell-taxi/requirements.txt)", "Bash(python3 /home/sindo/moonwell-core/modules/mod-encounter-journal/tools/import_donor_journal.py -i /home/sindo/moonwell-core/Generated_EncounterJournal.lua -o /tmp/donor_import.sql)",
"Bash(/home/sindo/moonwell-core/tools/moonwell-taxi/.venv/bin/python -c \"import pymysql; print\\('ok', pymysql.__version__\\)\")", "Bash(python3 /home/sindo/moonwell-core/modules/mod-encounter-journal/tools/import_donor_journal.py -i /home/sindo/moonwell-core/Generated_EncounterJournal.lua -o /tmp/donor_import_filtered.sql --known-maps /tmp/known_maps.txt --known-creatures /tmp/known_creatures.txt --known-items /tmp/known_items.txt)",
"Bash(apt-cache search *)", "Bash(sudo grep -c \"Encounter Journal loaded\\\\|mod-encounter-journal\" /var/lib/docker/containers/8a3d7a23f170597b5a8eae754ef3259ac6f2af352d78958707ef5cf3010133e3/8a3d7a23f170597b5a8eae754ef3259ac6f2af352d78958707ef5cf3010133e3-json.log)",
"Read(//usr/lib/python3/dist-packages/**)", "Bash(xargs -I {} stat -c '%y %n' {})"
"Bash(sudo apt install -y python3-pymysql)",
"Bash(python3 -c \"import pymysql; print\\('OK', pymysql.__version__\\)\")",
"Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py export-dbc)",
"Bash(BUILDKIT_PROGRESS=plain docker compose build ac-db-import)",
"Bash(tee /tmp/dbimport-build.log)",
"Bash(docker volume *)",
"Bash(chmod +x /home/sindo/moonwell-core/tools/moonwell-taxi/redeploy.sh)",
"Bash(grep -rn 'PSendSysMessage\\(\"[^\"]*{[^\"]*\",' /home/sindo/moonwell-core/src/server/scripts/Commands/)",
"Bash(grep -n \"class taxi_builder_commandscript\\\\|^[[:space:]]*}\\\\?[[:space:]]*$\\\\|namespace\" /home/sindo/moonwell-core/modules/mod-taxi-builder/src/cs_taxi_builder.cpp)",
"Bash(python3 tools/moonwell-taxi/moonwell_taxi.py export-dbc)",
"Bash(python3 tools/moonwell-taxi/moonwell_taxi.py thin --path 90000 --min-yd 50 --dry-run)",
"Bash(python3 -)",
"Bash(cp var/client-data-vanilla/Taxi*.dbc var/client-data-live/dbc/)",
"Bash(bash tools/moonwell-taxi/redeploy.sh)",
"Bash(cp /home/sindo/moonwell-core/var/client-data-vanilla/Taxi*.dbc /home/sindo/moonwell-core/var/client-data-live/dbc/ *)",
"Bash(cp /home/sindo/moonwell-core/build/taxi/export/server/dbc/Taxi*.dbc /home/sindo/moonwell-core/var/client-data-live/dbc/ *)"
],
"additionalDirectories": [
"/home/sindo/moonwell-core/var/client-data-vanilla",
"/home/sindo/moonwell-core/build/taxi/export/server/dbc"
] ]
} }
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10
View File
@@ -19,3 +19,13 @@ AC_CCACHE=true
AC_RESTARTER_BINPATH= AC_RESTARTER_BINPATH=
AC_RESTARTER_BINFILE= AC_RESTARTER_BINFILE=
AC_RESTARTER_WITHGDB= 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
@@ -1,61 +0,0 @@
--
-- Taxi Route Builder: storage for custom flight nodes, paths and waypoints.
-- DBC files (TaxiNodes / TaxiPath / TaxiPathNode) are GENERATED from these
-- tables by the moonwell-taxi CLI. These tables are the source of truth.
--
-- ID space (enforced by the in-game module, not by this schema):
-- * custom_taxi_nodes.id >= 1000 (vanilla WotLK uses up to ~313)
-- * custom_taxi_paths.id >= 90000 (vanilla uses up to ~700)
-- from_taxi_node_id / to_taxi_node_id may reference EITHER a row in
-- custom_taxi_nodes OR a vanilla TaxiNodes.dbc id, so no FK is declared.
--
CREATE TABLE IF NOT EXISTS `custom_taxi_nodes` (
`id` int unsigned NOT NULL,
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`map_id` smallint unsigned NOT NULL DEFAULT '0',
`position_x` float NOT NULL DEFAULT '0',
`position_y` float NOT NULL DEFAULT '0',
`position_z` float NOT NULL DEFAULT '0',
`mount_alliance` int unsigned NOT NULL DEFAULT '0',
`mount_horde` int unsigned NOT NULL DEFAULT '0',
`created_by` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'character GUID of GM who created the node',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Taxi Builder: custom flight masters (source for TaxiNodes.dbc)';
CREATE TABLE IF NOT EXISTS `custom_taxi_paths` (
`id` int unsigned NOT NULL,
`from_taxi_node_id` int unsigned NOT NULL,
`to_taxi_node_id` int unsigned NOT NULL,
`cost` int unsigned NOT NULL DEFAULT '0' COMMENT 'flight cost in copper',
`is_enabled` tinyint(1) unsigned NOT NULL DEFAULT '1',
`created_by` bigint unsigned NOT NULL DEFAULT '0',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_from_to` (`from_taxi_node_id`, `to_taxi_node_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Taxi Builder: custom directed paths (source for TaxiPath.dbc)';
CREATE TABLE IF NOT EXISTS `custom_taxi_path_nodes` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`path_id` int unsigned NOT NULL,
`node_index` int unsigned NOT NULL COMMENT '0-based, contiguous within a path',
`map_id` smallint unsigned NOT NULL DEFAULT '0',
`position_x` float NOT NULL DEFAULT '0',
`position_y` float NOT NULL DEFAULT '0',
`position_z` float NOT NULL DEFAULT '0',
`flags` int unsigned NOT NULL DEFAULT '0' COMMENT 'TaxiPathNode flags (teleport=1, stop=2, ...)',
`delay` int unsigned NOT NULL DEFAULT '0',
`arrival_event_id` int unsigned NOT NULL DEFAULT '0',
`departure_event_id` int unsigned NOT NULL DEFAULT '0',
`comment` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_path_index` (`path_id`, `node_index`),
KEY `idx_path` (`path_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Taxi Builder: waypoints of custom paths (source for TaxiPathNode.dbc)';
+37 -14
View File
@@ -14,6 +14,7 @@ fi
IMPORT_MYTHICPLUS_SQL="${ACORE_IMPORT_MYTHICPLUS_SQL:-1}" IMPORT_MYTHICPLUS_SQL="${ACORE_IMPORT_MYTHICPLUS_SQL:-1}"
IMPORT_STORE_SQL="${ACORE_IMPORT_STORE_SQL:-1}" IMPORT_STORE_SQL="${ACORE_IMPORT_STORE_SQL:-1}"
IMPORT_ENCOUNTER_JOURNAL_SQL="${ACORE_IMPORT_ENCOUNTER_JOURNAL_SQL:-1}"
usage() { usage() {
cat <<EOF cat <<EOF
@@ -234,26 +235,47 @@ import_aoe_loot_sql() {
import_sql_file "acore_world" "$aoe_loot_sql_file" import_sql_file "acore_world" "$aoe_loot_sql_file"
} }
import_taxi_builder_sql() { import_encounter_journal_sql() {
# Source-of-truth tables for the Taxi Route Builder. The migration also local schema_file="$ROOT_DIR/modules/mod-encounter-journal/data/sql/db-world/base/01_encounter_journal_schema.sql"
# lives in data/sql/updates/pending_db_world/ for AzerothCore DBUpdater, local sample_file="$ROOT_DIR/modules/mod-encounter-journal/data/sql/db-world/base/02_encounter_journal_sample.sql"
# but we apply it eagerly here so deploy flows that don't rely on the
# worldserver-side updater still bootstrap correctly.
local taxi_builder_sql_file="$ROOT_DIR/data/sql/updates/pending_db_world/rev_1778279831177904414.sql"
if [[ ! -f "$taxi_builder_sql_file" ]]; then if [[ "$IMPORT_ENCOUNTER_JOURNAL_SQL" == "0" ]]; then
log "taxi-builder migration not found, skipped" log "Encounter Journal SQL import disabled, skipped"
return 0 return 0
fi fi
if table_exists "acore_world" "custom_taxi_nodes" \ if [[ ! -f "$schema_file" ]]; then
&& table_exists "acore_world" "custom_taxi_paths" \ log "mod-encounter-journal schema SQL not found, skipped"
&& table_exists "acore_world" "custom_taxi_path_nodes"; then
log "taxi-builder tables already exist, skipping"
return 0 return 0
fi fi
import_sql_file "acore_world" "$taxi_builder_sql_file" # 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 if (($# > 0)); then
@@ -272,6 +294,7 @@ fi
require_integer_flag "ACORE_IMPORT_MYTHICPLUS_SQL" "$IMPORT_MYTHICPLUS_SQL" 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_STORE_SQL" "$IMPORT_STORE_SQL"
require_integer_flag "ACORE_IMPORT_ENCOUNTER_JOURNAL_SQL" "$IMPORT_ENCOUNTER_JOURNAL_SQL"
cd "$ROOT_DIR" cd "$ROOT_DIR"
@@ -282,7 +305,7 @@ fi
import_mythicplus_sql import_mythicplus_sql
import_aoe_loot_sql import_aoe_loot_sql
import_taxi_builder_sql import_encounter_journal_sql
import_store_sql import_store_sql
log "custom SQL bootstrap completed" 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
+195
View File
@@ -0,0 +1,195 @@
# 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 |
| ---------------- | ---------------------------------------------------------------------------- |
| 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 importskeleton` | Generate instance/encounter/creature/section rows from `instance_template` + `instance_encounters`. |
| `.ej importloot` | Auto-populate `custom_ej_loot` from `creature_loot_template` (skips dupes). |
| `.ej importall` | Run `importskeleton` then `importloot` back-to-back. |
| `.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,164 @@
#!/usr/bin/env python3
"""
Build a trimmed item cache for the MoonWellClient EncounterJournal addon.
Reads one or more donor cache files (Sirus-style Generated_ItemsCache*.lua,
each holding rows like `[id] = { "name_en", "name_ru", quality, ..., "icon",
... }`), intersects them with the item ids referenced by
EncounterJournalData.json (the file produced by .ej export) and emits a
Lua file containing only the matching rows.
Output schema (single global, Lua):
MoonWellEncounterJournalItemCache = {
[<item_id>] = { name_en, name_ru, quality, icon },
...
}
Multiple donor files are merged left-to-right; later files override earlier
ones for duplicate ids. Items missing from every donor are reported on
stderr and skipped (the client falls back to GetItemInfo for those).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, Iterable, Set, Tuple
# Row layout per Sirus convention:
# [id] = { "name_en", "name_ru", quality, classId, subClass,
# inventoryType, slot, stackable, idx, "icon", spellId },
ROW_RE = re.compile(
r"""^\s*\[(?P<id>\d+)\]\s*=\s*\{\s*
"(?P<en>(?:[^"\\]|\\.)*)"\s*,\s*
"(?P<ru>(?:[^"\\]|\\.)*)"\s*,\s*
(?P<quality>-?\d+)\s*,\s*
-?\d+\s*,\s* # field 4
-?\d+\s*,\s* # field 5
-?\d+\s*,\s* # field 6
-?\d+\s*,\s* # field 7
-?\d+\s*,\s* # field 8
-?\d+\s*,\s* # field 9
"(?P<icon>(?:[^"\\]|\\.)*)"\s*,\s*
-?\d+\s*
\}\s*,?\s*$""",
re.VERBOSE,
)
def parse_donor(path: Path) -> Dict[int, Tuple[str, str, int, str]]:
"""Returns {id: (name_en, name_ru, quality, icon)} from one donor file."""
out: Dict[int, Tuple[str, str, int, str]] = {}
bad = 0
with path.open("r", encoding="utf-8") as f:
for line in f:
m = ROW_RE.match(line)
if not m:
stripped = line.strip()
if stripped and not stripped.startswith(
("--", "Items", "ItemsCache", "}", "{")
):
bad += 1
continue
out[int(m.group("id"))] = (
m.group("en"),
m.group("ru"),
int(m.group("quality")),
m.group("icon"),
)
if bad:
print(f"[{path.name}] skipped {bad} unparseable lines", file=sys.stderr)
return out
def collect_needed_ids(journal_json: Path) -> Set[int]:
"""Returns the set of item ids referenced in EncounterJournalData.json."""
with journal_json.open("r", encoding="utf-8") as f:
data = json.load(f)
needed: Set[int] = set()
for rows in data.get("items", {}).values():
for row in rows:
# items[encounterID] row layout per docs/encounter_journal_format.md:
# [item_id, encounter_id, difficulty_mask, faction_mask, flags,
# id, class_mask]
if row:
needed.add(int(row[0]))
return needed
def lua_str(s: str) -> str:
s = s.replace("\\", "\\\\").replace("'", "\\'")
s = s.replace("\n", "\\n").replace("\r", "\\r")
return f"'{s}'"
def write_lua(out_path: Path, global_name: str,
cache: Dict[int, Tuple[str, str, int, str]]) -> None:
with out_path.open("w", encoding="utf-8") as f:
f.write("-- Generated by mod-encounter-journal/tools/"
"build_item_cache.py.\n")
f.write("-- Trimmed donor item cache for items referenced by "
"EncounterJournalData.\n\n")
f.write(f"{global_name} = {{\n")
for item_id in sorted(cache.keys()):
en, ru, quality, icon = cache[item_id]
f.write(
f" [{item_id}] = {{ {lua_str(en)}, {lua_str(ru)}, "
f"{quality}, {lua_str(icon)} }},\n"
)
f.write("}\n")
def merge_donors(donor_paths: Iterable[Path]
) -> Dict[int, Tuple[str, str, int, str]]:
merged: Dict[int, Tuple[str, str, int, str]] = {}
for p in donor_paths:
rows = parse_donor(p)
print(f"[{p.name}] parsed {len(rows)} rows", file=sys.stderr)
merged.update(rows)
return merged
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--journal", "-j", required=True, type=Path,
help="EncounterJournalData.json (from .ej export)")
parser.add_argument("--donor", "-d", required=True, type=Path, nargs="+",
help="One or more Generated_ItemsCache*.lua files")
parser.add_argument("--output", "-o", required=True, type=Path,
help="Where to write EncounterJournalItemCache.lua")
parser.add_argument("--global", dest="global_name",
default="MoonWellEncounterJournalItemCache",
help="Lua global name (default: %(default)s)")
args = parser.parse_args()
needed = collect_needed_ids(args.journal)
print(f"EJ references {len(needed)} unique item ids", file=sys.stderr)
donors = merge_donors(args.donor)
print(f"donor cache holds {len(donors)} unique item ids", file=sys.stderr)
trimmed = {i: donors[i] for i in needed if i in donors}
missing = sorted(needed - donors.keys())
write_lua(args.output, args.global_name, trimmed)
print(
f"wrote {len(trimmed)} rows to {args.output} "
f"(missing in donor: {len(missing)})",
file=sys.stderr,
)
if missing:
sample = ", ".join(str(i) for i in missing[:15])
more = "" if len(missing) <= 15 else f" ... and {len(missing) - 15} more"
print(f"missing item ids (client will fall back to GetItemInfo): "
f"{sample}{more}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,577 @@
#!/usr/bin/env python3
"""
Import a Sirus-style Generated_EncounterJournal.lua dump into the
mod-encounter-journal custom_ej_* tables.
The dump holds seven top-level Lua tables that map 1:1 to our schema:
JOURNALINSTANCE -> custom_ej_instance
JOURNALENCOUNTER -> custom_ej_encounter
JOURNALENCOUNTERCREATURE -> custom_ej_creature
JOURNALENCOUNTERSECTION -> custom_ej_section
JOURNALENCOUNTERITEM -> custom_ej_loot
JOURNALTIER -> custom_ej_tier
JOURNALTIERXINSTANCE -> custom_ej_tier_instance
The script emits a single .sql file (or writes to stdout) that uses
REPLACE INTO so re-running is safe. Pipe it into your MySQL container:
python3 import_donor_journal.py -i Generated_EncounterJournal.lua \
-o donor_import.sql
docker exec -i ac-database mysql -uroot -p$PASS acore_world \
< donor_import.sql
Each row from the dump goes through a small tokenizer that understands
quoted strings ("..."), long-bracket strings ([[...]]), numbers, and
nested braces. The dump only uses single-line strings, so no multi-line
handling is needed.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from typing import Any, Iterable, List, Optional, TextIO, Tuple
# ---------------------------------------------------------------------------
# Tokeniser / value parser
# ---------------------------------------------------------------------------
class LuaParseError(RuntimeError):
pass
def _skip_ws(src: str, pos: int) -> int:
while pos < len(src) and src[pos] in " \t\r\n,":
pos += 1
return pos
def _parse_quoted_string(src: str, pos: int) -> Tuple[str, int]:
assert src[pos] == '"'
pos += 1
out: List[str] = []
while pos < len(src):
c = src[pos]
if c == "\\" and pos + 1 < len(src):
nxt = src[pos + 1]
# Lua escapes: \", \', \\, \n, \r, \t — keep verbatim semantics.
out.append({
'"': '"', "'": "'", "\\": "\\",
"n": "\n", "r": "\r", "t": "\t",
}.get(nxt, nxt))
pos += 2
continue
if c == '"':
return "".join(out), pos + 1
out.append(c)
pos += 1
raise LuaParseError("unterminated quoted string")
def _parse_long_bracket_string(src: str, pos: int) -> Tuple[str, int]:
assert src.startswith("[[", pos)
pos += 2
end = src.find("]]", pos)
if end < 0:
raise LuaParseError("unterminated long-bracket string")
return src[pos:end], end + 2
_NUM_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?")
def _parse_number(src: str, pos: int) -> Tuple[float, int]:
m = _NUM_RE.match(src, pos)
if not m:
raise LuaParseError(f"expected number at {pos}: {src[pos:pos+20]!r}")
txt = m.group(0)
val: Any = float(txt) if ("." in txt or "e" in txt or "E" in txt) else int(txt)
return val, m.end()
def _parse_table(src: str, pos: int) -> Tuple[list, int]:
"""Parses `{ v1, v2, ... }` and returns the list of values."""
if src[pos] != "{":
raise LuaParseError(f"expected '{{' at {pos}")
pos += 1
out: list = []
while True:
pos = _skip_ws(src, pos)
if pos >= len(src):
raise LuaParseError("unterminated table literal")
if src[pos] == "}":
return out, pos + 1
value, pos = _parse_value(src, pos)
out.append(value)
def _parse_value(src: str, pos: int) -> Tuple[Any, int]:
pos = _skip_ws(src, pos)
c = src[pos]
if c == '"':
return _parse_quoted_string(src, pos)
if src.startswith("[[", pos):
return _parse_long_bracket_string(src, pos)
if c == "{":
return _parse_table(src, pos)
return _parse_number(src, pos)
# ---------------------------------------------------------------------------
# Top-level table extractor
# ---------------------------------------------------------------------------
def _find_top_level_tables(text: str) -> dict:
"""
Returns {table_name: dict_or_list}. Iterates the source once, splitting
at top-level `NAME = {` declarations and parsing the brace-balanced body.
"""
tables: dict = {}
pos = 0
pat = re.compile(r"^([A-Z_]+)\s*=\s*\{", re.MULTILINE)
while True:
m = pat.search(text, pos)
if not m:
break
name = m.group(1)
# Find brace-balanced end.
body_start = m.end() - 1 # position of '{'
depth = 0
i = body_start
in_str = None # '"' or '[' for long bracket
while i < len(text):
ch = text[i]
if in_str == '"':
if ch == "\\":
i += 2
continue
if ch == '"':
in_str = None
i += 1
continue
if in_str == "[":
if text.startswith("]]", i):
in_str = None
i += 2
continue
i += 1
continue
if ch == '"':
in_str = '"'
i += 1
continue
if text.startswith("[[", i):
in_str = "["
i += 2
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
body = text[body_start:i + 1]
tables[name] = _parse_top_level_body(name, body)
pos = i + 1
break
i += 1
else:
raise LuaParseError(f"unterminated table {name}")
return tables
def _parse_top_level_body(name: str, body: str) -> Any:
"""
Parses the contents of one top-level table, which can have either
keyed entries `[id] = { ... }` or sequential entries `{ ... }`.
Returns dict for keyed entries, list for sequential.
"""
assert body.startswith("{") and body.endswith("}")
inner = body[1:-1]
i = 0
keyed: dict = {}
seq: list = []
while True:
i = _skip_ws(inner, i)
if i >= len(inner):
break
if inner[i] == "[":
# `[key] = value`
j = inner.index("]", i)
key_val, _ = _parse_value(inner, i + 1)
i = j + 1
i = _skip_ws(inner, i)
if inner[i] != "=":
raise LuaParseError(f"{name}: expected '=' at {i}")
i = _skip_ws(inner, i + 1)
value, i = _parse_value(inner, i)
keyed[key_val] = value
else:
value, i = _parse_value(inner, i)
seq.append(value)
if keyed and seq:
raise LuaParseError(f"{name}: mix of keyed/sequential entries")
return keyed if keyed else seq
# ---------------------------------------------------------------------------
# SQL emission
# ---------------------------------------------------------------------------
def _sql_str(s: Any) -> str:
s = str(s)
return "'" + s.replace("\\", "\\\\").replace("'", "''") + "'"
def _to_int(v: Any, default: int = 0) -> int:
if v is None:
return default
if isinstance(v, bool):
return int(v)
return int(v)
def _to_uint(v: Any) -> int:
"""Sirus uses -1 as 'all difficulties'; our schema is UNSIGNED → store 0."""
n = _to_int(v)
return n if n >= 0 else 0
def _to_signed(v: Any) -> int:
return _to_int(v)
def _to_float(v: Any) -> float:
if v is None:
return 0.0
return float(v)
def _load_id_set(path: Optional[Path]) -> Optional[set]:
"""Reads one id per line, returns a set of ints. None when path is None."""
if path is None:
return None
out: set = set()
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
try:
out.add(int(line))
except ValueError:
pass
return out
def emit(tables: dict, out: TextIO,
known_maps: Optional[set] = None,
known_creatures: Optional[set] = None,
known_items: Optional[set] = None,
dropped: Optional[dict] = None) -> dict:
"""
Writes REPLACE INTO statements for every donor table. Returns per-table
insert counts for the run summary.
When @known_maps/@known_creatures/@known_items are provided, rows that
reference an id missing from the corresponding set are skipped:
- instance with map_id not in known_maps is dropped (and all of its
encounters/creatures/sections/loot/tier_instance go with it);
- creature whose creature_id != 0 and not in known_creatures dropped;
- loot whose item_id not in known_items dropped;
- tier with no surviving tier_instance entries dropped.
"""
counts = {
"tier": 0, "instance": 0, "tier_instance": 0,
"encounter": 0, "creature": 0, "section": 0, "loot": 0,
}
if dropped is None:
dropped = {}
for k in ("instance", "creature", "loot", "tier"):
dropped.setdefault(k, 0)
out.write("-- Generated by mod-encounter-journal/tools/"
"import_donor_journal.py.\n")
out.write("-- Source: Sirus-style Generated_EncounterJournal.lua dump.\n")
out.write("-- Safe to re-run: every INSERT uses REPLACE semantics on PK.\n")
out.write("SET NAMES utf8mb4;\n")
out.write("START TRANSACTION;\n\n")
# First pass: figure out which instance_ids survive the map filter so we
# can transitively drop their encounters/creatures/sections/loot.
surviving_instances: set = set()
for inst_id, row in tables.get("JOURNALINSTANCE", {}).items():
if len(row) < 12:
row = row + [0] * (12 - len(row))
map_id = _to_int(row[6])
if known_maps is not None and map_id not in known_maps:
dropped["instance"] += 1
continue
surviving_instances.add(_to_int(inst_id))
# encounter_id -> instance_id (used by creature/section/loot filters)
encounter_to_instance: dict = {}
for inst_id, rows in tables.get("JOURNALENCOUNTER", {}).items():
for row in rows:
if len(row) < 12:
continue
encounter_to_instance[_to_int(row[0])] = _to_int(inst_id)
def _enc_survives(enc_id: int) -> bool:
inst = encounter_to_instance.get(enc_id)
return inst is not None and inst in surviving_instances
# ---- tiers ----
# Build the set of tiers that retain at least one surviving instance
# via JOURNALTIERXINSTANCE so we don't write orphan tiers.
surviving_tiers: set = set()
for inst_id, tirow in tables.get("JOURNALTIERXINSTANCE", {}).items():
if _to_int(inst_id) in surviving_instances and len(tirow) >= 1:
surviving_tiers.add(_to_int(tirow[0]))
for row in tables.get("JOURNALTIER", []):
# {id, name, flags}
tid, name, flags = row[0], row[1], row[2] if len(row) > 2 else 0
if surviving_tiers and _to_int(tid) not in surviving_tiers:
dropped["tier"] += 1
continue
out.write(
"REPLACE INTO `custom_ej_tier` "
"(`id`, `name`, `flags`, `sort_order`) VALUES "
f"({_to_int(tid)}, {_sql_str(name)}, {_to_int(flags)}, "
f"{_to_int(tid)});\n"
)
counts["tier"] += 1
out.write("\n")
# ---- instances ----
for inst_id, row in tables.get("JOURNALINSTANCE", {}).items():
if _to_int(inst_id) not in surviving_instances:
continue
if len(row) < 12:
row = row + [0] * (12 - len(row))
(name, desc, btn, sbtn, bg, lbg, map_id, area, sort_o,
flags, src_id, wmaa) = row[:12]
out.write(
"REPLACE 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 ("
f"{_to_int(inst_id)}, {_sql_str(name)}, {_sql_str(desc)}, "
f"{_sql_str(btn)}, {_sql_str(sbtn)}, {_sql_str(bg)}, "
f"{_sql_str(lbg)}, {_to_int(map_id)}, {_to_int(area)}, "
f"{_to_int(wmaa)}, {_to_int(flags)}, {_to_int(sort_o)}"
");\n"
)
counts["instance"] += 1
out.write("\n")
# ---- tier_instance ----
# JOURNALTIERXINSTANCE[instance_id] = {tier_id, sort_order}
for inst_id, row in tables.get("JOURNALTIERXINSTANCE", {}).items():
if _to_int(inst_id) not in surviving_instances:
continue
tier_id = row[0]
sort_o = row[1] if len(row) > 1 else 0
out.write(
"REPLACE INTO `custom_ej_tier_instance` "
"(`tier_id`, `instance_id`, `sort_order`) VALUES "
f"({_to_int(tier_id)}, {_to_int(inst_id)}, {_to_int(sort_o)});\n"
)
counts["tier_instance"] += 1
out.write("\n")
# ---- encounters ----
# JOURNALENCOUNTER[instance_id] = list of rows
# {id, name, desc, map_x, map_y, floor, world_map_area, first_section,
# instance_id, difficulty_mask, flags, sort_order}
for inst_id, rows in tables.get("JOURNALENCOUNTER", {}).items():
if _to_int(inst_id) not in surviving_instances:
continue
for row in rows:
if len(row) < 12:
row = row + [0] * (12 - len(row))
(eid, name, desc, mx, my, floor, wmaa, first_sec, src_inst,
diff, flags, sort_o) = row[:12]
out.write(
"REPLACE 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 ("
f"{_to_int(eid)}, {_to_int(inst_id)}, {_sql_str(name)}, "
f"{_sql_str(desc)}, {_to_float(mx):.6f}, {_to_float(my):.6f}, "
f"{_to_uint(floor)}, {_to_uint(wmaa)}, {_to_int(first_sec)}, "
f"{_to_uint(diff)}, {_to_uint(flags)}, {_to_int(sort_o)}"
");\n"
)
counts["encounter"] += 1
out.write("\n")
# ---- creatures ----
# JOURNALENCOUNTERCREATURE[encounter_id] = list of rows
# {name, desc, display_id, icon, encounter_id, sort, id, creature_id,
# difficulty_mask}
for enc_id, rows in tables.get("JOURNALENCOUNTERCREATURE", {}).items():
if not _enc_survives(_to_int(enc_id)):
continue
for row in rows:
if len(row) < 9:
row = row + [0] * (9 - len(row))
(name, desc, display_id, icon, src_enc, sort_o, cid, cr_id,
diff) = row[:9]
cr_id_i = _to_int(cr_id)
if (known_creatures is not None and cr_id_i != 0
and cr_id_i not in known_creatures):
dropped["creature"] += 1
continue
out.write(
"REPLACE INTO `custom_ej_creature` "
"(`id`, `encounter_id`, `creature_id`, `name`, `description`, "
"`creature_display_id`, `icon`, `difficulty_mask`, "
"`sort_order`) VALUES ("
f"{_to_int(cid)}, {_to_int(enc_id)}, {_to_int(cr_id)}, "
f"{_sql_str(name)}, {_sql_str(desc)}, {_to_uint(display_id)}, "
f"{_sql_str(icon)}, {_to_uint(diff)}, {_to_int(sort_o)}"
");\n"
)
counts["creature"] += 1
out.write("\n")
# ---- sections ----
# JOURNALENCOUNTERSECTION[section_id] = single row
# {id, name, desc, display_id, desc_spell, icon_spell, encounter_id,
# next, sub, parent, flags, icon_flags, sort, type, difficulty,
# creature_id}
for sid, row in tables.get("JOURNALENCOUNTERSECTION", {}).items():
if len(row) < 16:
row = row + [0] * (16 - len(row))
(src_sid, name, desc, display_id, desc_spell, icon_spell, enc_id,
next_sid, sub_sid, parent_sid, flags, icon_flags, sort_o, stype,
diff, cr_id) = row[:16]
if not _enc_survives(_to_int(enc_id)):
continue
out.write(
"REPLACE 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 ("
f"{_to_int(sid)}, {_to_int(enc_id)}, {_to_int(parent_sid)}, "
f"{_to_int(next_sid)}, {_to_int(sub_sid)}, {_sql_str(name)}, "
f"{_sql_str(desc)}, {_to_uint(display_id)}, "
f"{_to_uint(desc_spell)}, {_to_uint(icon_spell)}, NULL, "
f"{_to_uint(flags)}, {_to_uint(icon_flags)}, {_to_int(sort_o)}, "
f"{_to_uint(stype)}, {_to_uint(diff)}, {_to_uint(cr_id)}"
");\n"
)
counts["section"] += 1
out.write("\n")
# ---- loot ----
# JOURNALENCOUNTERITEM[encounter_id] = list of rows
# {item_id, encounter_id, difficulty_mask, faction_mask, flags, id,
# class_mask}
for enc_id, rows in tables.get("JOURNALENCOUNTERITEM", {}).items():
if not _enc_survives(_to_int(enc_id)):
continue
for row in rows:
if len(row) < 7:
row = row + [0] * (7 - len(row))
(item_id, src_enc, diff, faction, flags, lid, class_mask) = row[:7]
item_i = _to_int(item_id)
if known_items is not None and item_i not in known_items:
dropped["loot"] += 1
continue
out.write(
"REPLACE INTO `custom_ej_loot` "
"(`id`, `encounter_id`, `item_id`, `difficulty_mask`, "
"`faction_mask`, `class_mask`, `flags`, `sort_order`) "
"VALUES ("
f"{_to_int(lid)}, {_to_int(enc_id)}, {_to_int(item_id)}, "
f"{_to_uint(diff)}, {_to_signed(faction)}, "
f"{_to_signed(class_mask)}, {_to_uint(flags)}, "
f"{_to_int(lid)}"
");\n"
)
counts["loot"] += 1
out.write("\n")
out.write("COMMIT;\n")
return counts
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--input", "-i", required=True, type=Path,
help="Generated_EncounterJournal.lua")
p.add_argument("--output", "-o", type=Path,
help="Where to write the .sql file (default: stdout)")
p.add_argument("--known-maps", type=Path,
help="File with allowed map ids (one per line). Donor "
"instances whose map_id is not listed are dropped, "
"together with their encounters/creatures/sections/"
"loot.")
p.add_argument("--known-creatures", type=Path,
help="File with allowed creature_template entries. Donor "
"creature cards with creature_id not in the list are "
"dropped.")
p.add_argument("--known-items", type=Path,
help="File with allowed item_template entries. Donor loot "
"rows referencing missing items are dropped.")
args = p.parse_args()
text = args.input.read_text(encoding="utf-8")
tables = _find_top_level_tables(text)
print("parsed tables:", file=sys.stderr)
for name, body in tables.items():
size = len(body) if isinstance(body, (dict, list)) else "?"
print(f" {name:30s} {size}", file=sys.stderr)
known_maps = _load_id_set(args.known_maps)
known_creatures = _load_id_set(args.known_creatures)
known_items = _load_id_set(args.known_items)
if known_maps is not None:
print(f"map whitelist: {len(known_maps)} ids", file=sys.stderr)
if known_creatures is not None:
print(f"creature whitelist: {len(known_creatures)} ids",
file=sys.stderr)
if known_items is not None:
print(f"item whitelist: {len(known_items)} ids", file=sys.stderr)
dropped: dict = {}
if args.output:
with args.output.open("w", encoding="utf-8") as f:
counts = emit(tables, f, known_maps, known_creatures,
known_items, dropped)
else:
counts = emit(tables, sys.stdout, known_maps, known_creatures,
known_items, dropped)
print("emitted rows:", file=sys.stderr)
for k, v in counts.items():
print(f" {k:14s} {v}", file=sys.stderr)
if any(dropped.values()):
print("dropped (custom Sirus entries):", file=sys.stderr)
for k, v in dropped.items():
if v:
print(f" {k:14s} {v}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -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())
@@ -336,3 +336,40 @@ IndividualProgression.ExcludeAccounts = 1
# Default: "^RNDBOT.*" # Default: "^RNDBOT.*"
# #
IndividualProgression.ExcludedAccountsRegex = "^RNDBOT.*" IndividualProgression.ExcludedAccountsRegex = "^RNDBOT.*"
########################################
# Player Guide (Путеводитель)
########################################
# IndividualProgression.PlayerGuide.Enable
#
# Description: Enable server-side Player Guide payload generation
# and addon-channel delivery. When disabled the
# module does not push any data to clients and
# ignores client commands.
#
# Default: 1 - Enabled
# 0 - Disabled
#
IndividualProgression.PlayerGuide.Enable = 1
# IndividualProgression.PlayerGuide.ChunkSize
#
# Description: Maximum size, in bytes, of a single addon-channel
# chunk used to deliver guide payloads. Lower values
# produce more chunks; higher values risk hitting
# the 255-byte client limit. Clamped to [64, 240].
#
# Default: 230
#
IndividualProgression.PlayerGuide.ChunkSize = 230
# IndividualProgression.PlayerGuide.LogPayloads
#
# Description: Emit a debug log line for each payload pushed to
# a client. Useful when developing the client UI.
#
# Default: 0 - Disabled
# 1 - Enabled
#
IndividualProgression.PlayerGuide.LogPayloads = 0
@@ -42,6 +42,7 @@ void AddSC_ipp_spell_scripts();
void AddSC_individualProgression_commandscript(); void AddSC_individualProgression_commandscript();
void AddSC_mod_individual_progression_awareness(); void AddSC_mod_individual_progression_awareness();
void AddSC_mod_individual_progression_player(); void AddSC_mod_individual_progression_player();
void AddSC_mod_individual_progression_player_guide();
void AddSC_npc_archmage_timear(); void AddSC_npc_archmage_timear();
void AddSC_karazhan_70(); void AddSC_karazhan_70();
void AddSC_the_eye_70(); void AddSC_the_eye_70();
@@ -88,6 +89,7 @@ void Addmod_individual_progressionScripts()
AddSC_individualProgression_commandscript(); AddSC_individualProgression_commandscript();
AddSC_mod_individual_progression_awareness(); AddSC_mod_individual_progression_awareness();
AddSC_mod_individual_progression_player(); AddSC_mod_individual_progression_player();
AddSC_mod_individual_progression_player_guide();
AddSC_npc_archmage_timear(); AddSC_npc_archmage_timear();
AddSC_karazhan_70(); AddSC_karazhan_70();
AddSC_the_eye_70(); AddSC_the_eye_70();
@@ -0,0 +1,584 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#include "PlayerGuideMgr.h"
#include "../IndividualProgression.h"
#include "Config.h"
#include "Log.h"
#include "Player.h"
#include "QuestDef.h"
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <set>
#include <sstream>
#include <unordered_set>
namespace MoonWell::PlayerGuide
{
namespace
{
std::string EscapeJson(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<char>(c);
break;
}
}
return out;
}
// Writes "key":number when value > 0.
void WriteOptUInt(std::ostringstream& s, char const* key, uint32 v,
bool& first)
{
if (!v)
return;
if (!first) s << ',';
s << '"' << key << "\":" << v;
first = false;
}
void WriteString(std::ostringstream& s, char const* key,
std::string const& v, bool& first)
{
if (!first) s << ',';
s << '"' << key << "\":\"" << EscapeJson(v) << '"';
first = false;
}
void WriteUInt(std::ostringstream& s, char const* key, uint32 v,
bool& first)
{
if (!first) s << ',';
s << '"' << key << "\":" << v;
first = false;
}
void WriteBool(std::ostringstream& s, char const* key, bool v,
bool& first)
{
if (!first) s << ',';
s << '"' << key << "\":" << (v ? "true" : "false");
first = false;
}
// Maps an IPP ProgressionState into a target stage (1..N).
// The stage ids are kept stable so the client can key
// localisations off of them.
struct StageInfo
{
uint32 id;
char const* name;
char const* description;
};
StageInfo const STAGES[] =
{
{ 1, "Начало пути",
"Завершите низкоуровневое содержимое родного континента." },
{ 2, "Огненные Недра",
"Победите Рагнароса в Огненных Недрах." },
{ 3, "Логово Ониксии",
"Победите Ониксию." },
{ 4, "Логово Крыла Тьмы",
"Победите Нефариана в Логове Крыла Тьмы." },
{ 5, "Подготовка к АQ",
"Завершите подготовку к открытию ворот Ан'Киража." },
{ 6, "Военный поход АQ",
"Окажите помощь военным усилиям в Силитусе." },
{ 7, "Храм Ан'Киража",
"Победите Ц'Туна в Храме Ан'Киража." },
{ 8, "Наксрамас (40)",
"Победите КелТузада в Наксрамасе." },
{ 9, "Подготовка к Запределью",
"Откройте Тёмный портал и подготовьтесь к TBC." },
{ 10, "Каражан и Гру'ул",
"Завершите первый ярус рейдов TBC." },
{ 11, "Пещера Змеиных Недр / Око Бури",
"Завершите рейды второго яруса TBC." },
{ 12, "Вершина Хиджала и Чёрный храм",
"Победите Иллидана в Чёрном храме." },
{ 13, "Зул'Аман",
"Получите доступ к Зул'Аману и завершите его." },
{ 14, "Плато Солнечного Колодца",
"Победите Кил'джедена на Плато Солнечного Колодца." },
{ 15, "Наксрамас (Нордскол)",
"Завершите рейды первого яруса WotLK." },
{ 16, "Ульдуар",
"Победите Йогг-Сарона в Ульдуаре." },
{ 17, "Испытание крестоносца",
"Завершите Испытание крестоносца." },
{ 18, "Ледяная Корона",
"Победите Короля-лича." },
{ 19, "Рубиновое святилище",
"Победите Халиона в Рубиновом святилище." }
};
StageInfo const* FindStage(uint32 id)
{
for (StageInfo const& s : STAGES)
if (s.id == id)
return &s;
return nullptr;
}
// Tiered dungeon recommendations matched to the player level/stage.
struct DungeonRec
{
uint32 stage; // recommended starting from this stage
uint32 maxStage; // hidden after this stage (inclusive)
uint8 minLevel;
uint8 maxLevel;
uint32 mapId;
char const* title;
};
DungeonRec const DUNGEONS[] =
{
{ 1, 1, 15, 21, 36, "Мёртвые копи" },
{ 1, 1, 17, 24, 33, "Курганы Иглошкурых" },
{ 1, 2, 22, 30, 47, "Шахта Каражан" },
{ 1, 2, 24, 32, 70, "Ульдаман" },
{ 1, 3, 27, 38, 109, "Затонувший храм" },
{ 1, 3, 30, 42, 209, "Зул'Фаррак" },
{ 1, 4, 46, 54, 229, "Верхняя часть Чёрной горы" },
{ 1, 4, 55, 60, 230, "Глубины Чёрной горы" },
{ 1, 4, 58, 60, 533, "Наксрамас" },
{ 9, 12, 58, 70, 540, "Кузня Крови" },
{ 9, 12, 60, 70, 542, "Цитадель Адского пламени" },
{ 9, 12, 65, 70, 553, "Ботаника" },
{ 9, 12, 67, 70, 555, "Гробница Ткача Смерти" },
{14, 19, 68, 80, 574, "Утгард" },
{14, 19, 70, 80, 600, "Драк'Тарон" },
{14, 19, 72, 80, 619, "Ан'Кахет: Старое Королевство" },
{14, 19, 74, 80, 595, "Чертоги Камней" },
{14, 19, 76, 80, 658, "Очищение Стратхольма" }
};
}
Mgr* Mgr::instance()
{
static Mgr inst;
return &inst;
}
void Mgr::LoadConfig()
{
_enabled = sConfigMgr->GetOption<bool>(
"IndividualProgression.PlayerGuide.Enable", true);
_logPayloads = sConfigMgr->GetOption<bool>(
"IndividualProgression.PlayerGuide.LogPayloads", false);
_chunkSize = sConfigMgr->GetOption<uint32>(
"IndividualProgression.PlayerGuide.ChunkSize", 230);
// The addon channel hard-caps a single message body to 255
// characters. We leave room for the framing header.
if (_chunkSize < 64) _chunkSize = 64;
if (_chunkSize > 240) _chunkSize = 240;
}
std::string Mgr::StageName(uint32 stage) const
{
StageInfo const* s = FindStage(stage);
return s ? s->name : "Этап";
}
std::string Mgr::StageDescription(uint32 stage) const
{
StageInfo const* s = FindStage(stage);
return s ? s->description : "";
}
void Mgr::AppendLevelObjective(Payload& out, Player* player,
uint32 requiredLevel, uint32 order) const
{
Objective o;
o.id = order;
o.type = "level";
char buf[64];
std::snprintf(buf, sizeof(buf), "Достигнуть %u уровня",
requiredLevel);
o.title = buf;
o.progress = player->GetLevel();
o.required = requiredLevel;
o.completed = o.progress >= o.required;
o.targetId = requiredLevel;
o.order = order;
out.objectives.push_back(std::move(o));
}
void Mgr::AppendQuestObjective(Payload& out, Player* player,
uint32 questId,
std::string const& title,
uint32 order) const
{
QuestStatus st = player->GetQuestStatus(questId);
Objective o;
o.id = order;
o.type = "quest";
o.title = title;
o.required = 1;
o.progress = (st == QUEST_STATUS_REWARDED ||
st == QUEST_STATUS_COMPLETE) ? 1 : 0;
o.completed = (st == QUEST_STATUS_REWARDED);
o.targetId = questId;
o.questId = questId;
o.order = order;
out.objectives.push_back(std::move(o));
}
void Mgr::AppendDungeonObjective(Payload& out, uint32 mapId,
std::string const& title,
uint32 order) const
{
Objective o;
o.id = order;
o.type = "dungeon";
o.title = title;
o.required = 1;
o.targetId = mapId;
o.instanceId = mapId;
o.order = order;
out.objectives.push_back(std::move(o));
}
void Mgr::AppendRaidObjective(Payload& out, uint32 mapId,
std::string const& title,
uint32 order) const
{
Objective o;
o.id = order;
o.type = "raid";
o.title = title;
o.required = 1;
o.targetId = mapId;
o.instanceId = mapId;
o.order = order;
out.objectives.push_back(std::move(o));
}
void Mgr::AppendRecommendations(Payload& out, Player* player) const
{
uint8 level = player->GetLevel();
uint32 stage = out.stageId;
std::unordered_set<uint32> seen;
uint32 recId = 1;
for (DungeonRec const& d : DUNGEONS)
{
if (stage < d.stage || stage > d.maxStage)
continue;
if (level + 2 < d.minLevel || level > d.maxLevel + 1)
continue;
if (!seen.insert(d.mapId).second)
continue;
Recommendation r;
r.id = recId++;
r.type = "dungeon";
r.title = d.title;
r.instanceId = d.mapId;
r.priority = 100 -
static_cast<uint32>(std::abs(int32(level) -
int32((d.minLevel + d.maxLevel) / 2)));
r.description = "Подходит для вашего текущего этапа.";
out.recommendations.push_back(std::move(r));
if (out.recommendations.size() >= 6)
break;
}
}
static uint32 ProgressFor(uint32 stage, uint8 level)
{
// Rough %: scaled across the three expansion bands.
uint32 p = 0;
if (stage < PROGRESSION_PRE_TBC)
p = std::min<uint32>(60u, (level * 60u) / 60u);
else if (stage < PROGRESSION_WOTLK_TIER_1)
p = 60u + std::min<uint32>(20u,
((level > 60 ? level - 60 : 0) * 20u) / 10u);
else
p = 80u + std::min<uint32>(20u,
((level > 70 ? level - 70 : 0) * 20u) / 10u);
// Add a small bump per cleared major raid stage above 1.
if (stage > 1)
p = std::min<uint32>(100u, p + (stage - 1) * 2u);
return p;
}
Payload Mgr::BuildFor(Player* player) const
{
Payload out;
out.version = PAYLOAD_VERSION;
out.characterGuid = player->GetGUID().GetRawValue();
// ProgressionState as set by IPP. Default is 0 (start of vanilla).
uint32 ipStage = player->GetPlayerSetting(
"mod-individual-progression", SETTING_PROGRESSION_STATE).value;
// Player's *next* stage is what the guide should be helping with.
// STAGES use 1-based ids where stage id = ProgressionState+1.
out.stageId = std::min<uint32>(ipStage + 1, 19);
out.stageName = StageName(out.stageId);
out.stageDescription = StageDescription(out.stageId);
out.progress = ProgressFor(ipStage, player->GetLevel());
uint32 order = 1;
switch (ipStage)
{
case PROGRESSION_START:
AppendLevelObjective(out, player, 60, order++);
AppendRaidObjective(out, 409,
"Победить Рагнароса в Огненных Недрах", order++);
break;
case PROGRESSION_MOLTEN_CORE:
AppendLevelObjective(out, player, 60, order++);
AppendRaidObjective(out, 249,
"Победить Ониксию", order++);
AppendRaidObjective(out, 469,
"Победить Нефариана в Логове Крыла Тьмы", order++);
break;
case PROGRESSION_ONYXIA:
AppendRaidObjective(out, 469,
"Победить Нефариана в Логове Крыла Тьмы", order++);
AppendRaidObjective(out, 309,
"Зул'Гуруб (опционально)", order++);
break;
case PROGRESSION_BLACKWING_LAIR:
AppendQuestObjective(out, player, MIGHT_OF_KALIMDOR,
"Сдать ресурсы для военного похода", order++);
AppendQuestObjective(out, player, BANG_A_GONG,
"Открыть ворота Ан'Киража", order++);
break;
case PROGRESSION_PRE_AQ:
AppendQuestObjective(out, player, CHAOS_AND_DESTRUCTION,
"Завершить полевые задания в Силитусе", order++);
AppendRaidObjective(out, 509,
"Руины Ан'Киража", order++);
break;
case PROGRESSION_AQ_WAR:
AppendRaidObjective(out, 531,
"Победить Ц'Туна в Храме Ан'Киража", order++);
break;
case PROGRESSION_AQ:
AppendRaidObjective(out, 533,
"Победить КелТузада в Наксрамасе", order++);
break;
case PROGRESSION_NAXX40:
AppendQuestObjective(out, player, INTO_THE_BREACH,
"Открыть Тёмный портал", order++);
AppendLevelObjective(out, player, 70, order++);
break;
case PROGRESSION_PRE_TBC:
AppendLevelObjective(out, player, 70, order++);
AppendRaidObjective(out, 532,
"Каражан", order++);
AppendRaidObjective(out, 565,
"Логово Груула", order++);
AppendRaidObjective(out, 544,
"Логово Магтеридона", order++);
break;
case PROGRESSION_TBC_TIER_1:
AppendRaidObjective(out, 548,
"Пещера Змеиных Недр", order++);
AppendRaidObjective(out, 550,
"Око Бури — Крепость Бурь", order++);
break;
case PROGRESSION_TBC_TIER_2:
AppendRaidObjective(out, 534,
"Битва за гору Хиджал", order++);
AppendRaidObjective(out, 564,
"Чёрный храм", order++);
break;
case PROGRESSION_TBC_TIER_3:
AppendRaidObjective(out, 568,
"Зул'Аман", order++);
break;
case PROGRESSION_TBC_TIER_4:
AppendRaidObjective(out, 580,
"Плато Солнечного Колодца", order++);
break;
case PROGRESSION_TBC_TIER_5:
AppendLevelObjective(out, player, 80, order++);
AppendRaidObjective(out, 533,
"Наксрамас (Нордскол)", order++);
AppendRaidObjective(out, 616,
"Око Вечности", order++);
AppendRaidObjective(out, 615,
"Камера Сокровищ Обсидиановой драконьей стаи",
order++);
break;
case PROGRESSION_WOTLK_TIER_1:
AppendRaidObjective(out, 603,
"Победить Йогг-Сарона в Ульдуаре", order++);
break;
case PROGRESSION_WOTLK_TIER_2:
AppendRaidObjective(out, 649,
"Испытание крестоносца", order++);
break;
case PROGRESSION_WOTLK_TIER_3:
AppendRaidObjective(out, 631,
"Цитадель Ледяной Короны", order++);
break;
case PROGRESSION_WOTLK_TIER_4:
AppendRaidObjective(out, 724,
"Рубиновое святилище", order++);
break;
default:
break;
}
AppendRecommendations(out, player);
return out;
}
std::string Mgr::SerializeJson(Objective const& o) const
{
std::ostringstream s;
s << '{';
bool first = true;
WriteUInt(s, "id", o.id, first);
WriteString(s, "type", o.type, first);
WriteString(s, "title", o.title, first);
if (!o.description.empty())
WriteString(s, "description", o.description, first);
WriteBool(s, "completed", o.completed, first);
WriteUInt(s, "progress", o.progress, first);
WriteUInt(s, "required", o.required, first);
WriteUInt(s, "targetID", o.targetId, first);
WriteUInt(s, "order", o.order, first);
WriteOptUInt(s, "questID", o.questId, first);
WriteOptUInt(s, "questChainID", o.questChainId, first);
WriteOptUInt(s, "instanceID", o.instanceId, first);
WriteOptUInt(s, "encounterID", o.encounterId, first);
WriteOptUInt(s, "achievementID", o.achievementId, first);
WriteOptUInt(s, "factionID", o.factionId, first);
WriteOptUInt(s, "professionID", o.professionId, first);
WriteOptUInt(s, "itemID", o.itemId, first);
WriteOptUInt(s, "currencyID", o.currencyId, first);
s << '}';
return s.str();
}
std::string Mgr::SerializeJson(Payload const& p) const
{
std::ostringstream s;
s << '{';
s << "\"version\":" << p.version
<< ",\"characterGuid\":" << p.characterGuid
<< ",\"stageID\":" << p.stageId
<< ",\"stageName\":\"" << EscapeJson(p.stageName) << '"'
<< ",\"stageDescription\":\""
<< EscapeJson(p.stageDescription) << '"'
<< ",\"progress\":" << std::min<uint32>(p.progress, 100u);
// Objectives: collapse duplicate ids and clamp progress.
s << ",\"objectives\":[";
std::set<uint32> seenIds;
bool firstObj = true;
for (Objective const& src : p.objectives)
{
if (!seenIds.insert(src.id).second)
continue;
Objective o = src;
if (o.required && o.progress > o.required)
o.progress = o.required;
if (!firstObj) s << ',';
s << SerializeJson(o);
firstObj = false;
}
s << "]";
// Recommendations.
s << ",\"recommendations\":[";
bool firstRec = true;
for (Recommendation const& r : p.recommendations)
{
if (!firstRec) s << ',';
s << '{';
bool f = true;
WriteUInt(s, "id", r.id, f);
WriteString(s, "type", r.type, f);
WriteString(s, "title", r.title, f);
if (!r.description.empty())
WriteString(s, "description", r.description, f);
WriteOptUInt(s, "instanceID", r.instanceId, f);
WriteOptUInt(s, "encounterID", r.encounterId, f);
WriteOptUInt(s, "questID", r.questId, f);
WriteOptUInt(s, "itemID", r.itemId, f);
WriteUInt(s, "priority", r.priority, f);
s << '}';
firstRec = false;
}
s << "]";
// Rewards.
s << ",\"rewards\":[";
bool firstRew = true;
for (Reward const& r : p.rewards)
{
if (!firstRew) s << ',';
s << '{';
bool f = true;
WriteString(s, "type", r.type, f);
WriteOptUInt(s, "itemID", r.itemId, f);
WriteOptUInt(s, "currencyID", r.currencyId, f);
WriteUInt(s, "count", std::max<uint32>(r.count, 1), f);
WriteOptUInt(s, "amount", r.amount, f);
s << '}';
firstRew = false;
}
s << "]";
s << '}';
return s.str();
}
}
@@ -0,0 +1,138 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#ifndef MOONWELL_PLAYER_GUIDE_MGR_H
#define MOONWELL_PLAYER_GUIDE_MGR_H
#include "Define.h"
#include <cstdint>
#include <string>
#include <vector>
class Player;
namespace MoonWell::PlayerGuide
{
enum : uint32 { PAYLOAD_VERSION = 1 };
struct Objective
{
uint32 id = 0;
std::string type; // level | quest | quest_chain | dungeon |
// raid | boss | achievement |
// reputation | profession | item |
// currency | custom
std::string title;
std::string description;
bool completed = false;
uint32 progress = 0;
uint32 required = 0;
uint32 targetId = 0;
uint32 order = 0;
// Optional type-specific helpers. Zero means "not set" and
// serialiser omits it.
uint32 questId = 0;
uint32 questChainId = 0;
uint32 instanceId = 0;
uint32 encounterId = 0;
uint32 achievementId = 0;
uint32 factionId = 0;
uint32 professionId = 0;
uint32 itemId = 0;
uint32 currencyId = 0;
};
struct Recommendation
{
uint32 id = 0;
std::string type;
std::string title;
std::string description;
uint32 instanceId = 0;
uint32 encounterId = 0;
uint32 questId = 0;
uint32 itemId = 0;
uint32 priority = 0;
};
struct Reward
{
std::string type;
uint32 itemId = 0;
uint32 count = 1;
uint32 currencyId = 0;
uint32 amount = 0;
};
struct Payload
{
uint32 version = PAYLOAD_VERSION;
uint64 characterGuid = 0;
uint32 stageId = 0;
std::string stageName;
std::string stageDescription;
uint32 progress = 0; // 0..100
std::vector<Objective> objectives;
std::vector<Recommendation> recommendations;
std::vector<Reward> rewards;
};
class Mgr
{
public:
static Mgr* instance();
// Reads configuration. Safe to call multiple times.
void LoadConfig();
// Builds the per-character guide payload from IPP state.
// The function is read-only with respect to the player.
Payload BuildFor(Player* player) const;
// Serialises a payload to a single JSON string.
// Validates uniqueness of objective ids and clamps progress.
std::string SerializeJson(Payload const& payload) const;
std::string SerializeJson(Objective const& objective) const;
bool IsEnabled() const { return _enabled; }
bool LogPayloads() const { return _logPayloads; }
uint32 ChunkSize() const { return _chunkSize; }
private:
Mgr() = default;
// Stage helpers based on ProgressionState.
std::string StageName(uint32 stage) const;
std::string StageDescription(uint32 stage) const;
// Filling helpers.
void AppendLevelObjective(Payload& out, Player* player,
uint32 requiredLevel, uint32 order) const;
void AppendQuestObjective(Payload& out, Player* player,
uint32 questId, std::string const& title,
uint32 order) const;
void AppendDungeonObjective(Payload& out, uint32 mapId,
std::string const& title,
uint32 order) const;
void AppendRaidObjective(Payload& out, uint32 mapId,
std::string const& title,
uint32 order) const;
void AppendRecommendations(Payload& out, Player* player) const;
bool _enabled = true;
bool _logPayloads = false;
uint32 _chunkSize = 230; // per-frame body capacity (addon channel)
};
}
#define sPlayerGuideMgr ::MoonWell::PlayerGuide::Mgr::instance()
#endif // MOONWELL_PLAYER_GUIDE_MGR_H
@@ -0,0 +1,183 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#include "PlayerGuideMgr.h"
#include "PlayerGuideTransport.h"
#include "../IndividualProgression.h"
#include "Chat.h"
#include "Config.h"
#include "Creature.h"
#include "Log.h"
#include "Player.h"
#include "QuestDef.h"
#include "ScriptMgr.h"
#include "SharedDefines.h"
#include <cstdlib>
#include <string>
namespace MoonWell::PlayerGuide
{
namespace
{
void Push(Player* player)
{
if (!sPlayerGuideMgr->IsEnabled())
return;
if (!player || !player->GetSession() || player->IsBeingTeleported())
return;
Payload p = sPlayerGuideMgr->BuildFor(player);
std::string json = sPlayerGuideMgr->SerializeJson(p);
if (sPlayerGuideMgr->LogPayloads())
LOG_DEBUG("module", "[PlayerGuide] push {} bytes -> {}",
json.size(), player->GetName());
Transport::SendSetData(player, json);
}
// Splits "MWPG\t<cmd>\t<arg>" -> (cmd, arg). Returns false if
// the message does not match the expected addon prefix.
bool ParseClientMessage(std::string const& in, std::string& cmd,
std::string& arg)
{
// Expected prefix: "MWPG\t".
if (in.size() < 6)
return false;
if (in.compare(0, 4, ADDON_PREFIX) != 0)
return false;
if (in[4] != '\t')
return false;
std::size_t end = in.find('\t', 5);
if (end == std::string::npos)
{
cmd.assign(in, 5, in.size() - 5);
arg.clear();
return true;
}
cmd.assign(in, 5, end - 5);
arg.assign(in, end + 1, in.size() - (end + 1));
return true;
}
}
class PlayerGuide_PlayerScript : public PlayerScript
{
public:
PlayerGuide_PlayerScript()
: PlayerScript("PlayerGuide_PlayerScript",
{
PLAYERHOOK_ON_LOGIN,
PLAYERHOOK_ON_LEVEL_CHANGED,
PLAYERHOOK_ON_PLAYER_COMPLETE_QUEST,
PLAYERHOOK_ON_CREATURE_KILL,
PLAYERHOOK_ON_BEFORE_SEND_CHAT_MESSAGE
}) { }
void OnPlayerLogin(Player* player) override
{
Push(player);
}
void OnPlayerLevelChanged(Player* player, uint8 /*oldLevel*/) override
{
Push(player);
}
void OnPlayerCompleteQuest(Player* player,
Quest const* /*quest*/) override
{
Push(player);
}
void OnPlayerCreatureKill(Player* killer, Creature* killed) override
{
if (!killer || !killed)
return;
if (killed->GetCreatureTemplate()->rank <= CREATURE_ELITE_NORMAL)
return;
Push(killer);
}
void OnPlayerBeforeSendChatMessage(Player* player, uint32& type,
uint32& lang, std::string& msg) override
{
if (!sPlayerGuideMgr->IsEnabled())
return;
if (type != CHAT_MSG_WHISPER || lang != LANG_ADDON)
return;
if (sPlayerGuideMgr->LogPayloads())
LOG_DEBUG("module",
"[PlayerGuide] raw addon <- {} ({} bytes): '{}'",
player->GetName(), msg.size(), msg);
std::string cmd, arg;
if (!ParseClientMessage(msg, cmd, arg))
return;
LOG_INFO("module",
"[PlayerGuide] <- {} cmd='{}' arg='{}' ({} bytes)",
player->GetName(), cmd, arg, msg.size());
if (cmd == ClientCmd::REQUEST_REFRESH)
{
Push(player);
}
else if (cmd == ClientCmd::REQUEST_TRACK_OBJECTIVE)
{
// Tracking is purely client-side state for now; we just
// re-send the payload so the client can update its
// tracked id atomically.
Push(player);
}
else if (cmd == ClientCmd::REQUEST_OPEN_TARGET)
{
// Reserved for future: server-driven open-target action
// (e.g. queue LFG). For MVP a refresh is enough.
Push(player);
}
else
{
LOG_WARN("module",
"[PlayerGuide] unknown cmd='{}' from {}",
cmd, player->GetName());
return;
}
// Swallow the message so it does not get relayed as a real
// whisper. Clearing the body is enough — ChatHandler
// returns early on empty addon messages.
msg.clear();
}
};
class PlayerGuide_WorldScript : public WorldScript
{
public:
PlayerGuide_WorldScript()
: WorldScript("PlayerGuide_WorldScript",
{ WORLDHOOK_ON_AFTER_CONFIG_LOAD }) { }
void OnAfterConfigLoad(bool /*reload*/) override
{
sPlayerGuideMgr->LoadConfig();
LOG_INFO("server.loading",
">> PlayerGuide module: {}.",
sPlayerGuideMgr->IsEnabled() ? "enabled" : "disabled");
}
};
}
void AddSC_mod_individual_progression_player_guide()
{
new MoonWell::PlayerGuide::PlayerGuide_PlayerScript();
new MoonWell::PlayerGuide::PlayerGuide_WorldScript();
}
@@ -0,0 +1,96 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#include "PlayerGuideTransport.h"
#include "PlayerGuideMgr.h"
#include "Chat.h"
#include "Player.h"
#include "WorldPacket.h"
#include "WorldSession.h"
#include <algorithm>
#include <cstdio>
#include <vector>
namespace MoonWell::PlayerGuide
{
namespace
{
// The client-visible chat body must stay strictly below the
// 255-byte addon limit. We reserve room for the framing header.
// The mgr clamps its configured chunk size into a safe range.
constexpr std::size_t HARD_LIMIT = 250;
void SendOneChunk(Player* player, std::string_view command,
uint32 seq, uint32 total, std::string_view body)
{
std::string msg;
msg.reserve(ADDON_PREFIX[0] ? body.size() + 32 : body.size());
msg.append(ADDON_PREFIX);
msg.push_back('\t');
msg.append(command.data(), command.size());
msg.push_back('\t');
char buf[24];
std::snprintf(buf, sizeof(buf), "%u\t%u\t", seq, total);
msg.append(buf);
msg.append(body.data(), body.size());
if (msg.size() > HARD_LIMIT)
msg.resize(HARD_LIMIT);
WorldPacket data;
ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER,
LANG_ADDON, player, player, msg);
player->GetSession()->SendPacket(&data);
}
}
void Transport::Send(Player* player, std::string_view command,
std::string_view body)
{
if (!player || !player->GetSession())
return;
// How much body fits into a chunk after framing.
// Framing = prefix(4) + '\t' + cmd(N) + '\t' + "65535\t65535\t".
// Use Mgr's configured chunk size when sensible.
std::size_t framing = 4 + 1 + command.size() + 1 + 16;
std::size_t budget = sPlayerGuideMgr->ChunkSize() > framing
? (sPlayerGuideMgr->ChunkSize() - framing)
: 64;
if (body.size() <= budget)
{
SendOneChunk(player, command, 1, 1, body);
return;
}
// Multi-chunk: split blindly by byte budget. JSON tolerates
// concatenation on the client.
uint32 total = static_cast<uint32>(
(body.size() + budget - 1) / budget);
uint32 seq = 1;
for (std::size_t off = 0; off < body.size(); off += budget, ++seq)
{
std::size_t take = std::min(budget, body.size() - off);
SendOneChunk(player, command, seq, total,
std::string_view(body.data() + off, take));
}
}
void Transport::SendSetData(Player* player, std::string const& json)
{
Send(player, ServerCmd::SET_DATA, json);
}
void Transport::SendNotify(Player* player, std::string const& text)
{
Send(player, ServerCmd::NOTIFY, text);
}
}
@@ -0,0 +1,57 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#ifndef MOONWELL_PLAYER_GUIDE_TRANSPORT_H
#define MOONWELL_PLAYER_GUIDE_TRANSPORT_H
#include "Define.h"
#include <string>
#include <string_view>
class Player;
namespace MoonWell::PlayerGuide
{
// Addon channel prefix used on both ends. The client AIO bridge
// routes messages with this prefix to the MoonWellPlayerGuide
// handler.
constexpr char const* ADDON_PREFIX = "MWPG";
// Commands sent server -> client.
namespace ServerCmd
{
constexpr char const* SET_DATA = "SetData";
constexpr char const* SET_STAGE = "SetStage";
constexpr char const* UPDATE_OBJECTIVE = "UpdateObjective";
constexpr char const* NOTIFY = "Notify";
}
// Commands sent client -> server.
namespace ClientCmd
{
constexpr char const* REQUEST_REFRESH = "RequestRefresh";
constexpr char const* REQUEST_TRACK_OBJECTIVE = "RequestTrackObjective";
constexpr char const* REQUEST_OPEN_TARGET = "RequestOpenTarget";
}
class Transport
{
public:
// Sends a single logical message split into addon-channel
// chunks. Wire format per chunk:
// "MWPG\t<cmd>\t<seq>\t<total>\t<body>"
// Single-chunk messages still use seq=1 total=1.
static void Send(Player* player, std::string_view command,
std::string_view body);
// Convenience wrappers.
static void SendSetData(Player* player, std::string const& json);
static void SendNotify(Player* player, std::string const& text);
};
}
#endif // MOONWELL_PLAYER_GUIDE_TRANSPORT_H
-4
View File
@@ -1,4 +0,0 @@
SPDX-License-Identifier: AGPL-3.0-or-later
This module is licensed under the GNU Affero General Public License v3 or
later. See https://www.gnu.org/licenses/agpl-3.0.html for the full text.
-753
View File
@@ -1,753 +0,0 @@
# mod-taxi-builder
GM-инструмент для создания собственных точек полёта и маршрутов
прямо в игре на сервере MoonWell (AzerothCore 3.3.5a). Не требует
ручного редактирования DBC: все данные складываются в три таблицы
`acore_world`, а внешний CLI [moonwell-taxi](../../tools/moonwell-taxi/)
собирает из них `TaxiNodes.dbc`, `TaxiPath.dbc` и `TaxiPathNode.dbc`
для серверной `dbc/`-папки и клиентского патча.
Все команды требуют права `SEC_ADMINISTRATOR` (стандартный GM-уровень
3+).
---
## Содержание
- [Установка](#установка)
- [Полный сценарий: новый маршрут с нуля](#полный-сценарий-новый-маршрут-с-нуля)
- [Команды](#команды)
- [`.taxi node`](#taxi-node)
- [`.taxi path`](#taxi-path)
- [`.taxi record`](#taxi-record)
- [`.taxi markers`](#taxi-markers)
- [`.taxi export dbc`](#taxi-export-dbc)
- [Лимит TaxiMask и распределение id](#лимит-taximask-и-распределение-id)
- [Правила валидации](#правила-валидации)
- [Прореживание точек: `moonwell-taxi thin`](#прореживание-точек-moonwell-taxi-thin)
- [Структура БД](#структура-бд)
- [Связь с CLI moonwell-taxi](#связь-с-cli-moonwell-taxi)
- [Известные ограничения](#известные-ограничения)
- [FAQ / типичные ошибки](#faq--типичные-ошибки)
---
## Установка
1. Клонируйте репозиторий ядра и убедитесь, что папка
`modules/mod-taxi-builder/` присутствует.
2. Примените миграцию схемы. Самый простой способ — через
[import-custom-sql.sh](../../import-custom-sql.sh):
```bash
docker compose up -d ac-database
bash import-custom-sql.sh
```
В `acore_world` появятся три таблицы: `custom_taxi_nodes`,
`custom_taxi_paths`, `custom_taxi_path_nodes`. Скрипт идемпотентен —
повторный запуск пропустит уже созданные таблицы.
3. Соберите ядро со включенными модулями (значение по умолчанию):
```bash
mkdir -p build && cd build
cmake .. -DCMAKE_INSTALL_PREFIX=$HOME/azeroth-server \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DSCRIPTS=static -DMODULES=static
make -j$(nproc) && make install
```
4. Перезапустите worldserver. На старте появится строка о регистрации
`taxi_builder_commandscript`.
5. (Один раз) поставьте `PyMySQL` для CLI:
```bash
sudo apt install -y python3-pymysql
```
---
## Полный сценарий: новый маршрут с нуля
Допустим, GM хочет добавить кастомный маршрут «Златоземье → Штормград»
(вернее, «новая точка → существующий узел Штормграда `id=2`»).
```
# 1. Встаём ровно туда, где должна быть новая точка полёта.
.taxi node create "Златоземье"
# -> [taxi-builder] node created: id=441 name="Златоземье" map=0 ...
# 2. Создаём маршрут от новой точки к Штормграду (vanilla node id=2),
# стоимость — 10 медяков.
.taxi path create 441 2 10
# -> [taxi-builder] path created: id=1979 441 (Златоземье) -> 2 (Stormwind) ...
# 3. Запускаем запись маршрута.
.taxi record start 1979
# -> [taxi-builder] recording started for path 1979 (existing points: 0)
# 4. Идём/летим по будущему маршруту. На каждой ключевой точке:
.taxi record point
# (или с комментарием — пригодится для последующего отчёта)
.taxi record point "взлёт над таверной"
.taxi record point
.taxi record point
# ...записываем 15-25 точек...
# Если оступились — откат последней:
.taxi record undo
# 5. Завершаем запись. Точки сохраняются.
.taxi record finish
# -> path 1979 now has 18 waypoints.
# 6. Проверяем маршрут на типовые проблемы.
.taxi path validate 1979
# -> Validation: 0 errors, 2 warnings — OK
# (Опционально) Расставить визуальные маркеры на всех точках, обойти их пешком:
.taxi markers spawn 1979
# ... осмотр ...
.taxi markers clear 1979
# 7. Из консоли (или из чата) запускаем экспорт DBC.
moonwell-taxi export-dbc
# 8. Кладём серверные DBC в папку сервера и перезапускаем worldserver.
# 9. Кладём клиентские DBC в патч-MPQ и перезапускаем клиент (или удаляем Cache).
# 10. На месте мастера полётов в Златоземье спавним NPC с npcflag=flight master
# и фракцией Альянса/Орды (для MVP — стандартные команды AzerothCore .npc add).
# 11. Подходим к нему, выбираем путь, летим.
```
После шага 9 точка появится у мастеров полётов и игроки смогут летать
по записанному маршруту.
---
## Команды
### `.taxi node`
Управление точками полёта (записи в `custom_taxi_nodes`).
#### `.taxi node create "Name"`
Создаёт новую такситочку из текущей позиции GM. Работает только из
игры (нужны координаты).
- `id` подбирается автоматически из безопасного диапазона (`>= 441`,
избегая конфликтов с vanilla TaxiNodes ~313).
- `mount_alliance` и `mount_horde` определяются автоматически: модуль
смотрит, какие mount-creature чаще всего используются у vanilla точек
на текущей карте, и подставляет их. Если карта неизвестна — fallback
`541/541` (как в примере ТЗ).
- Имя сохраняется как одна строка и при экспорте в DBC дублируется во
все 16 локалей (отображается на любом клиенте).
```
.taxi node create "Златоземье"
[taxi-builder] node created: id=441 name="Златоземье" map=0
xyz=(-9445.77, 79.40, 57.38) mounts=A:1572/H:295
```
#### `.taxi node list [filter]`
Печатает список точек: сначала custom (`custom_taxi_nodes`), затем
vanilla (`sTaxiNodesStore`). Без аргумента дамп ограничен 50 строками
(в чате не должно быть спама). С `filter` — подстрочный поиск без
учёта регистра. Доступна и из консоли.
```
.taxi node list Storm
[vanilla] 2 Stormwind, Elwynn @ map 0 (-9446.5, 64.4, 56.0)
[vanilla] 38 Stonewatch Tower, Redridge @ map 0 (-9241.3, -2280.7, 65.0)
Shown 2 (cap 50). Custom: 1, Vanilla: 365.
```
#### `.taxi node free`
Печатает все свободные TaxiNode id в диапазоне `1..448`, сгруппированные
в непрерывные подряд-идущие отрезки. Полезно перед `.taxi node create`
посмотреть, сколько слотов осталось, и до создания убедиться, что
auto-allocator не упрётся. Подробности — в разделе
[Лимит TaxiMask и распределение id](#лимит-taximask-и-распределение-id).
```text
.taxi node free
Free TaxiNode ids in 1..448: 83 total (7 above-vanilla 441..448, 76 in vanilla gaps).
338..339 (2)
...
441..448 (8)
```
#### `.taxi node goto <nodeId>`
Телепортирует GM к такситочке (custom или vanilla — что найдётся
первым). Удобно для быстрой проверки, что точка стоит, где надо.
```text
.taxi node goto 441
[taxi-builder] teleported to custom node 441 (Златоземье)
```
---
### `.taxi path`
Управление маршрутами (записи в `custom_taxi_paths`).
#### `.taxi path create <fromNodeId> <toNodeId> <cost>`
Создаёт направленный маршрут между двумя taxi-нодами. Любой конец
может быть как custom-нодом, так и vanilla — модуль ищет по обоим
источникам и валит, если ID нигде нет. Если уже есть включённый путь
с тем же `from→to` — отказ (нельзя случайно создать дубль).
- `id` подбирается автоматически из диапазона `>= 1979`.
- `cost` хранится в медяках.
- `is_enabled` ставится в `1`.
```
.taxi path create 441 2 10
[taxi-builder] path created: id=1979 441 (Златоземье) -> 2 (Stormwind) cost=10 copper
Now record waypoints with: .taxi record start 1979 -> .taxi record point ... -> .taxi record finish
```
#### `.taxi path reverse <pathId>`
Создаёт обратный путь из существующего:
1. Делает новый `TaxiPath` `to → from` с тем же `cost`.
2. Копирует все `TaxiPathNode` исходного пути в обратном порядке,
пересчитывая `node_index` от `0`.
3. Печатает предупреждение из ТЗ: ручную проверку посадки никто не
отменял.
```
.taxi path reverse 1979
[taxi-builder] reversed path 1979 (441->2) into new path 1980 (2->441) with 18 waypoints.
WARNING: reverse paths are auto-generated. Check landing waypoints by hand
(`.taxi path goto 1980 <lastIndex>`)...
```
#### `.taxi path list`
Все custom-маршруты с резолвом имён узлов и счётчиком точек.
```
.taxi path list
1979 441 (Златоземье) -> 2 (Stormwind, Elwynn) cost=10 pts=18 enabled
1980 2 (Stormwind, Elwynn) -> 441 (Златоземье) cost=10 pts=18 enabled
Total: 2 custom paths.
```
#### `.taxi path report <pathId>`
Подробный отчёт по маршруту в формате секции 5.3 ТЗ.
```
.taxi path report 1979
PathID: 1979 (enabled)
From: 441 Златоземье [custom]
To: 2 Stormwind, Elwynn [vanilla]
Cost: 10 copper
Points: 18
Total distance: 1842.3 yd
Min segment: 41.7 yd Max segment: 95.4 yd
Last point distance to ToTaxiNode: 4.8 yd
```
#### `.taxi path validate <pathId>`
Применяет правила секции 4 ТЗ. См. подробности в разделе
[Правила валидации](#правила-валидации).
```
.taxi path validate 1979
[warn] segment 7->8 is 38.2 yd (< 40 yd, may be filtered by IsNodeIncludedInShortenedPath)
[warn] sharp turn at NodeIndex 12: 89 deg (> 75). Consider adding intermediate points.
Validation: 0 errors, 2 warnings — OK
```
При наличии errors статус — `FAIL`. Маршрут с errors не пропустит
`.taxi path test`.
#### `.taxi path goto <pathId> <nodeIndex>`
Телепорт к конкретной точке маршрута. Удобно для шага «обход маркеров».
```
.taxi path goto 1979 7
[taxi-builder] teleported to path 1979, NodeIndex 7 map=0 (-9095.00, 230.00, 180.00)
```
#### `.taxi path test <pathId>`
«Сухой запуск»: запускает валидацию (отказ при errors), затем
телепортирует GM на первую точку маршрута, развернув лицом ко второй.
Реальный полёт по таксиспрайнам в этой версии возможен только после
прогона `moonwell-taxi export-dbc` и перезапуска worldserver+клиента.
```
.taxi path test 1979
[warn] segment 7->8 is 38.2 yd ...
[taxi-builder] dropped at first waypoint of path 1979 (18 total).
Step through with `.taxi path goto 1979 <index>`.
Real taxi flight needs `moonwell-taxi export-dbc` + worldserver/client restart.
```
---
### `.taxi record`
Запись точек маршрута. Запись — *сессия в памяти*, привязанная к GM-у:
рестарт worldserver очистит сессию, но уже записанные waypoint-ы в БД
останутся, и сессию можно продолжить через `.taxi record start <pathId>`.
#### `.taxi record start <pathId>`
Начинает запись для существующего custom-пути. Запоминает baseline —
число точек, которые были у пути до старта (для корректного `cancel`).
```
.taxi record start 1979
[taxi-builder] recording started for path 1979 (existing points: 0).
Use `.taxi record point ["comment"]` and `.taxi record finish` when done.
```
#### `.taxi record point [comment]`
Записывает следующую точку с координатами и map_id GM-а. `node_index`
автоматически = `MAX(node_index) + 1` (или 0 для первой).
Опциональный комментарий пишется в `comment` колонку (до 255 символов).
```
.taxi record point
[taxi-builder] path=1979 point #0 map=0 (-9445.77, 79.40, 57.38)
.taxi record point "разворот над озером"
[taxi-builder] path=1979 point #1 map=0 (-9395.00, 80.00, 70.00) // разворот над озером
```
#### `.taxi record undo`
Удаляет последнюю записанную в этой сессии точку. Не даёт уйти ниже
baseline (точки, существовавшие до `start`, защищены).
```
.taxi record undo
[taxi-builder] removed point #17 from path 1979.
```
#### `.taxi record finish`
Закрывает сессию. Точки остаются в БД. Печатает счётчики.
```
.taxi record finish
[taxi-builder] recording finished. Path 1979 now has 18 waypoints (added 18 this session).
Run `.taxi path report 1979` or `.taxi path validate 1979` to review.
```
#### `.taxi record cancel`
Откатывает текущую сессию: удаляет ровно те точки, которые добавил
этот `start ... point ...`. Существовавшие до сессии waypoint-ы не
трогаются — это полезно при правке уже готового маршрута.
```
.taxi record cancel
[taxi-builder] recording cancelled for path 1979; rolled back to 5 waypoints.
```
---
### `.taxi markers`
Временные визуальные маркеры на waypoint-ах. По умолчанию используется
GO `178559` (Banner Stand of Argent Crusade) — он мелкий, виден всем
и есть в стоковой `gameobject_template`. При желании смените
`MARKER_GO_ENTRY` в [src/cs_taxi_builder.cpp](src/cs_taxi_builder.cpp).
#### `.taxi markers spawn <pathId>`
Спавнит маркеры на всех точках маршрута, лежащих на текущей карте
GM-а. Точки на других континентах пропускаются (грид всё равно не
загружен — было бы бесполезно).
Маркеры держатся 24 часа или до явного `clear`. Повторный `spawn`
сначала чистит предыдущие, чтобы не плодить дубли.
```
.taxi markers spawn 1979
[taxi-builder] spawned 18 markers for path 1979 on map 0 (0 skipped on other maps).
Auto-despawn in 86400 s. Use `.taxi markers clear 1979` to remove now.
```
#### `.taxi markers clear <pathId>`
Удаляет все маркеры данного пути.
```
.taxi markers clear 1979
[taxi-builder] cleared 18 markers for path 1979.
```
---
### `.taxi export dbc`
Внутри игры команда не делает экспорт сама — она лишь напоминает
запустить внешний CLI. Это сделано специально: генерация DBC, sha256
и копирование файлов — задача внешнего инструмента, чтобы не нагружать
runtime worldserver-а.
```
.taxi export dbc
[taxi-builder] export dbc stub: run `moonwell-taxi export-dbc` from CLI
```
См. раздел [Связь с CLI moonwell-taxi](#связь-с-cli-moonwell-taxi).
---
## Лимит TaxiMask и распределение id
`TaxiNode.id` ограничен сверху значением **448**. Это hardcoded в
`src/server/shared/DataStores/DBCStructure.h:2284`:
```cpp
static constexpr std::size_t TaxiMaskSize = 14;
typedef std::array<uint32, TaxiMaskSize> TaxiMask;
```
14 × 32 = 448 бит. По одному биту на каждую такситочку: «доступна
ли в taxi-сети» и «открыл ли её игрок». Сервер в
`ObjectMgr::GetNearestTaxiNode` пропускает любой узел с
`(id - 1) / 32 >= 14`, то есть `id > 448`.
Симптом нарушения: GM создал точку `id = 441`, custom NPC появился,
но при выборе destination сервер берёт ближайший **vanilla** узел
вместо нашего, строит другую трассу — игрок «крутится по дороге»
и в конце сваливается. Видимых ошибок в логе нет.
Поднять потолок без патча клиента нельзя: пакет `SMSG_SHOWTAXINODES`
шлёт ровно 14 uint32, клиент 3.3.5a читает фиксированный размер.
### Что внутри 1..448
В реальной vanilla-DBC проекта используется до `id = 440`, и часть
номеров в этом диапазоне пустует. Вместе с диапазоном выше vanilla
(`441..448`) даёт ~84 свободных слота. Команда `.taxi node free`
показывает текущую картину:
```text
.taxi node free
Free TaxiNode ids in 1..448: 84 total (8 above-vanilla 441..448, 76 in vanilla gaps).
338..339 (2)
343..347 (5)
349..351 (3)
354..356 (3)
360..374 (15)
378
385..391 (7)
396..403 (8)
406..437 (32)
441..448 (8)
```
`.taxi node create` использует двухзонный авто-выбор:
1. Сначала ищет первый свободный id в `441..448` (выше vanilla,
всегда безопасно).
2. Если эта зона исчерпана — берёт первый свободный id в дырах
`1..440`.
«Свободный» означает: id не загружен в `sTaxiNodesStore`
(merged DBC на сервере) **и** не присутствует в `custom_taxi_nodes`
(может быть ещё не задеплоен в DBC).
### Что делать при исчерпании всех 84 слотов
- Освободить слот: удалить лишнюю строку из `custom_taxi_nodes`
(плюс зависимости в `custom_taxi_paths`).
- Перейти на бинарный патч `Wow.exe` под расширенный `TaxiMaskSize`
(Cataclysm-эра приватки делают так — увеличивают до 32 uint32 =
1024 ноды). Это вне scope модуля: нужен патч exe + изменение
`TaxiMaskSize` в ядре + раскатка патча игрокам.
- Использовать «фейковые» полёты через скриптового NPC и `MoveSplinePath`,
без записи в TaxiMask. Не показывается на карте как обычная точка
полёта, но id неограничены. Тоже отдельный модуль.
---
## Правила валидации
`.taxi path validate <pathId>` применяет следующие правила
(пороги — константы в [src/cs_taxi_builder.cpp](src/cs_taxi_builder.cpp),
секция «Thresholds per spec section 4»):
| # | Правило | Уровень |
| --- | --- | --- |
| 4.1 | `from`/`to`-нод существует (custom или vanilla) | error |
| 4.1 | у пути есть хотя бы одна waypoint | error |
| 4.1 | `node_index` идёт подряд от 0 | error |
| 4.2 | минимум 3 точки | error |
| 4.2 | рекомендация: 12+ точек | warning |
| 4.3 | сегмент `> 200 yd` (XY) | error |
| 4.3 | сегмент `> 120 yd` (XY) | warning |
| 4.3 | сегмент `< 40 yd` (XY) | warning (`IsNodeIncludedInShortenedPath` режет) |
| 4.3 | сегмент пересекает границу карты | error (полёт между континентами невозможен) |
| 4.4 | первая точка `> 20 yd` от `FromTaxiNode` | warning |
| 4.4 | последняя точка `> 20 yd` от `ToTaxiNode` | warning |
| 4.4 | первая/последняя точка на другой карте, чем нод | error |
| 4.5 | модуль абсолютной разницы Z последней точки и `ToTaxiNode > 30 yd` | warning (риск падения) |
| 4.6 | поворот `> 75°` в плоскости XY между сегментами | warning |
`.taxi path test` отказывается работать, если есть хотя бы один error.
Warnings — индикативны, не блокируют.
---
## Прореживание точек: `moonwell-taxi thin`
Самая частая поломка свежезаписанного маршрута: в полёте таксИ
«крутится по дороге» и сбрасывает игрока на кладбище. Причина —
большинство сегментов короче 40 yd. AzerothCore при построении
spline-а вызывает `IsNodeIncludedInShortenedPath` (`SKIP_SPLINE_POINT_DISTANCE_SQ = 40 * 40`)
и выкидывает waypoint-ы, которые ближе ~40 yd друг к другу. Если у
вас 68 точек, среднее расстояние 12 yd — после фильтрации остаётся
3-5 точек, по которым spline крутится как угодно.
Симптом виден прямо в `.taxi path validate <pathId>`:
```
[warn] segment 19->20 is 12.1 yd (< 40 yd, may be filtered by IsNodeIncludedInShortenedPath)
[warn] segment 20->21 is 12.7 yd (< 40 yd, ...)
...
Validation: 0 errors, 67 warnings — OK
```
Чинить руками удалением точек по одной — больно. Внешний CLI имеет
подкоманду `thin`, которая делает это безопасно: пишет бэкап,
удаляет старые waypoint-ы и записывает прореженные с пересчитанным
`node_index` от 0.
```bash
# 1. посмотреть, что получится, без записи в БД
python3 tools/moonwell-taxi/moonwell_taxi.py thin --path 1979 --min-yd 50 --dry-run
# 2. реально применить
python3 tools/moonwell-taxi/moonwell_taxi.py thin --path 1979 --min-yd 50
```
Параметры:
- `--path <id>` — обязательный, какой `custom_taxi_paths.id` чистить.
- `--min-yd <float>` — минимальное XY-расстояние между соседними
оставленными точками. Дефолт `50`. Для длинных прямых смело
ставьте `60-80`, для извилистых — `40-50`.
- `--dry-run` — показать план без модификации БД.
Алгоритм:
1. Первая waypoint всегда сохраняется.
2. Каждая последующая сравнивается с последней оставленной по XY.
Если ≥ `--min-yd` — сохраняется, иначе пропускается.
3. Последняя waypoint всегда сохраняется (анкер посадки).
4. Если предпоследняя получилась слишком близко к последней — её
тоже выкидывают. Так не возникает «маленький хвост» на финише.
5. Все ID `node_index` пересчитываются от `0` без пропусков.
6. Перед удалением исходные строки копируются в
`custom_taxi_path_nodes_backup_<pathId>_<unix_ts>` — откатить
можно прямым SQL (команда сама печатает rollback в конце).
Пример вывода на боевом маршруте:
```
Path 1979: 68 -> 13 waypoints (min_yd=50.0, removed 55)
Backup table: `custom_taxi_path_nodes_backup_1979_1778283612`
To rollback:
DELETE FROM custom_taxi_path_nodes WHERE path_id = 1979;
INSERT INTO custom_taxi_path_nodes
SELECT * FROM `custom_taxi_path_nodes_backup_1979_1778283612`;
```
Что делать после `thin`:
```
.taxi path validate 1979 # должно быть 0 errors, 0 warnings про <40 yd
.taxi path report 1979 # Min/Max segment в коридоре 40-120 yd
```
Затем `bash tools/moonwell-taxi/redeploy.sh` — экспорт DBC, заливка
в `var/client-data-live/dbc/`, рестарт worldserver-а.
**Важно:** `thin` работает только с записями в `custom_taxi_path_nodes`.
DBC сами по себе не меняются — для актуализации сервера нужен
`redeploy.sh` (или ручной `export-dbc` + `cp` + `restart`). Без этого
in-game проверка увидит старый, плотный маршрут.
---
## Структура БД
Все три таблицы в `acore_world`. См.
[data/sql/updates/pending_db_world/rev_*.sql](../../data/sql/updates/pending_db_world/).
```
custom_taxi_nodes
id int unsigned PRIMARY KEY (модуль выдаёт >= 441)
name varchar(100) (utf8mb4, утаивается во все 16 локалей DBC)
map_id smallint unsigned
position_x float
position_y float
position_z float
mount_alliance int unsigned
mount_horde int unsigned
created_by bigint unsigned (GUID GM-а на момент создания)
created_at timestamp
updated_at timestamp
custom_taxi_paths
id int unsigned PRIMARY KEY (модуль выдаёт >= 1979)
from_taxi_node_id int unsigned (custom ИЛИ vanilla)
to_taxi_node_id int unsigned (custom ИЛИ vanilla)
cost int unsigned (медяки)
is_enabled tinyint(1)
created_by, created_at, updated_at
KEY idx_from_to (from_taxi_node_id, to_taxi_node_id)
custom_taxi_path_nodes
id int unsigned AUTO_INCREMENT (внутренний; DBC ID — на экспорте)
path_id int unsigned
node_index int unsigned (UNIQUE с path_id, 0-based)
map_id smallint unsigned
position_x, _y, _z float
flags int unsigned (TaxiPathNode flags: teleport=1, stop=2 ...)
delay int unsigned
arrival_event_id int unsigned
departure_event_id int unsigned
comment varchar(255)
created_at timestamp
```
ID-диапазоны: модуль предпочитает `441..448` для новых нод, а когда
этот диапазон занят, использует свободные дырки внутри `1..440`.
Для путей модуль начинает с `1979`, сразу после vanilla max `1978`.
Схема БД эти правила не enforce-ит, поэтому в теории можно вставить ID
руками, но дальше CLI откажется писать DBC при коллизии с vanilla.
`from`/`to` в `custom_taxi_paths` намеренно без FK: маршрут регулярно
ведёт от custom-нода к vanilla (как в исходном кейсе ТЗ —
«Златоземье → Штормград»).
---
## Связь с CLI moonwell-taxi
Модуль *только* пишет в БД. DBC генерирует внешний инструмент
[tools/moonwell-taxi/](../../tools/moonwell-taxi/):
```bash
# Прочесть custom_taxi_*, смерджить с vanilla DBC из корня репозитория,
# записать оба комплекта (server + client) и проверить sha256.
python3 tools/moonwell-taxi/moonwell_taxi.py export-dbc
# Output:
# build/taxi/export/server/dbc/TaxiNodes.dbc
# build/taxi/export/server/dbc/TaxiPath.dbc
# build/taxi/export/server/dbc/TaxiPathNode.dbc
# build/taxi/export/client/DBFilesClient/TaxiNodes.dbc
# build/taxi/export/client/DBFilesClient/TaxiPath.dbc
# build/taxi/export/client/DBFilesClient/TaxiPathNode.dbc
# + sha256.txt в каждом каталоге
```
Если sha256 серверной и клиентской копий не совпадают — CLI выходит с
`exit code 1`, не считая экспорт валидным.
**Важно:** модуль и CLI общаются *только через схему БД*. Никаких
общих хедеров, FIFO или RPC. Меняется схема — обновляется и CLI.
---
## Известные ограничения
- **Только custom_taxi_*, не модифицирует vanilla.** Чтобы поправить
существующую такситочку или путь vanilla, нужны другие средства.
- **Имя пишется в locale slot текущего vanilla DBC.** Exporter
авто-инферит slot и `Name_lang_mask` из `TaxiNodes.dbc` (для ruRU это
slot `8`). Per-locale override таблицы пока нет.
- **Mount creature ID — авто-подбор по большинству на карте.** Для
Outland/Northrend и нестандартных карт fallback `541/541` может
выглядеть как Альянсный грифон у Орды. Меняется UPDATE-ом
`custom_taxi_nodes.mount_horde`.
- **`.taxi markers spawn` спавнит только на текущей карте.** Точки на
других континентах пропускаются — таково ограничение grid-load.
- **`.taxi path test` не запускает реальный flight.** Реальный полёт
работает только через DBC; у `path test` MVP-уровень — телепорт на
первую точку. Делать `Player::ActivateTaxiPathTo` для путей не из
DBC требует более глубокой интеграции (out of scope MVP).
- **Сессия записи не персистится.** Рестарт worldserver сбрасывает
состояние `.taxi record`. Workaround: `.taxi record start <pathId>`
заново — точки в БД остаются.
- **Конвенция Alliance/Horde в DBC обратна тексту ТЗ.** В реальном
DBC `MountCreatureID[0]` — Horde, `[1]` — Alliance
(см. `ObjectMgr.cpp:7229`). Колонки `mount_alliance` /
`mount_horde` в БД названы корректно — конверсия делается при записи
в DBC.
---
## FAQ / типичные ошибки
### «Path 1979 has no waypoints — record some first»
Создан путь, но не записано ни одной точки. Запусти
`.taxi record start 1979`, пройди маршрут, нажимая
`.taxi record point` в нужных местах, заверши `.taxi record finish`.
### «custom_taxi_paths id=N not found»
Путь не существует или `is_enabled=0`. Список — `.taxi path list`.
### «from-node N does not exist (custom or vanilla)»
ID не найден ни в `custom_taxi_nodes`, ни в vanilla `TaxiNodes.dbc`.
Список — `.taxi node list <substring>`.
### «An enabled path X -> Y already exists with id Z»
Активный маршрут `from→to` уже существует. Либо
`UPDATE custom_taxi_paths SET is_enabled=0 WHERE id=Z;`, либо
используй существующий маршрут.
### `.taxi path validate` показывает много `< 40 yd` warnings
Сегменты слишком короткие — `IsNodeIncludedInShortenedPath` их режет
и реальный полёт пройдёт «через» эти точки. Удали лишние через
`.taxi record undo` или прямым `DELETE` по `node_index`.
### Marker GO entry 178559 is missing from gameobject_template
На сервере зачищен banner GO. Поменяй `MARKER_GO_ENTRY` в
[src/cs_taxi_builder.cpp](src/cs_taxi_builder.cpp) на любой видимый
GO из `gameobject_template` и пересобери модуль.
### После `export-dbc` сервер видит точку, а клиент — нет
Клиент читает свои `DBFilesClient/*.dbc` из MPQ-патчей. Проверь, что
файлы из `build/taxi/export/client/DBFilesClient/` попали в
подключённый патч (например, `patch-X.MPQ` с приоритетом выше
базовых) и удалён клиентский `Cache/`.
### `Server/client DBC match: FAIL` после экспорта
Не должно случаться при штатной работе CLI (server и client пишутся
из одного буфера). Если всё-таки — баг в инструменте, открывайте
issue с `sha256.txt` обоих комплектов.
@@ -1 +0,0 @@
#!/usr/bin/env bash
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
MOD_TAXI_BUILDER_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/" && pwd )"
source $MOD_TAXI_BUILDER_ROOT"/conf/conf.sh.dist"
if [ -f $MOD_TAXI_BUILDER_ROOT"/conf/conf.sh" ]; then
source $MOD_TAXI_BUILDER_ROOT"/conf/conf.sh"
fi
File diff suppressed because it is too large Load Diff
@@ -1,163 +0,0 @@
/*
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* mod-taxi-builder: verbose runtime logging of taxi-related events.
* Hooks creature gossip interactions (catches the moment a player clicks a
* flight master — useful when the client crashes on dialog open) and polls
* Player::IsInFlight() to log start/mid/end of taxi flights with positions
* and current path. Output goes to Server.log via the standard logger; the
* channel is `server.loading` so it always passes the default Appender
* thresholds, but the `[taxi-log]` prefix lets you grep cleanly.
*/
#include "Chat.h"
#include "Creature.h"
#include "Log.h"
#include "ObjectGuid.h"
#include "Player.h"
#include "ScriptMgr.h"
#include <unordered_map>
namespace
{
struct TaxiLogState
{
bool inFlight = false;
uint32 lastPath = 0;
uint32 nextLogMs = 0;
float lastX = 0.f;
float lastY = 0.f;
float lastZ = 0.f;
uint32 lastMapId = 0;
};
std::unordered_map<ObjectGuid, TaxiLogState> g_taxiLog;
constexpr uint32 PROGRESS_LOG_INTERVAL_MS = 1000;
}
class taxi_builder_gossip_log : public AllCreatureScript
{
public:
taxi_builder_gossip_log() : AllCreatureScript("taxi_builder_gossip_log") { }
bool CanCreatureGossipHello(Player* player, Creature* creature) override
{
if (!player || !creature)
return false;
bool const isFlightMaster = creature->IsTaxi();
LOG_INFO("server.loading",
"[taxi-log] GossipHello: player={} (lvl {}) creature={} entry={} guid={} "
"isFlightMaster={} pos=({:.2f},{:.2f},{:.2f}) map={} npcflag=0x{:X}",
player->GetName(), uint32(player->GetLevel()),
creature->GetName(), creature->GetEntry(),
creature->GetGUID().ToString(),
isFlightMaster ? "YES" : "no",
creature->GetPositionX(), creature->GetPositionY(), creature->GetPositionZ(),
creature->GetMapId(),
creature->GetUInt32Value(UNIT_NPC_FLAGS));
// returning false leaves default handling in place (we just observed)
return false;
}
};
class taxi_builder_flight_log : public PlayerScript
{
public:
taxi_builder_flight_log() : PlayerScript("taxi_builder_flight_log") { }
void OnPlayerLogout(Player* player) override
{
if (player)
g_taxiLog.erase(player->GetGUID());
}
void OnPlayerUpdate(Player* player, uint32 diff) override
{
if (!player)
return;
bool const nowFlying = player->IsInFlight();
auto it = g_taxiLog.find(player->GetGUID());
// Fast path: not flying and never started — skip allocation.
if (!nowFlying && it == g_taxiLog.end())
return;
TaxiLogState& state = (it != g_taxiLog.end()) ? it->second : g_taxiLog[player->GetGUID()];
// START of flight
if (nowFlying && !state.inFlight)
{
uint32 path = player->m_taxi.GetCurrentTaxiPath();
auto const& nodes = player->m_taxi.GetPath();
std::string nodeList;
for (uint32 n : nodes)
{
if (!nodeList.empty()) nodeList += ",";
nodeList += std::to_string(n);
}
LOG_INFO("server.loading",
"[taxi-log] START player={} path={} nodes=[{}] start=({:.1f},{:.1f},{:.1f}) map={}",
player->GetName(), path, nodeList,
player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
player->GetMapId());
state.inFlight = true;
state.lastPath = path;
state.lastX = player->GetPositionX();
state.lastY = player->GetPositionY();
state.lastZ = player->GetPositionZ();
state.lastMapId = player->GetMapId();
state.nextLogMs = PROGRESS_LOG_INTERVAL_MS;
return;
}
// END of flight
if (!nowFlying && state.inFlight)
{
LOG_INFO("server.loading",
"[taxi-log] END player={} lastPath={} end=({:.1f},{:.1f},{:.1f}) map={}",
player->GetName(), state.lastPath,
player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
player->GetMapId());
state.inFlight = false;
return;
}
// Mid-flight throttled progress log (~1s)
if (!nowFlying)
return;
if (state.nextLogMs > diff)
{
state.nextLogMs -= diff;
return;
}
state.nextLogMs = PROGRESS_LOG_INTERVAL_MS;
float dx = player->GetPositionX() - state.lastX;
float dy = player->GetPositionY() - state.lastY;
float dz = player->GetPositionZ() - state.lastZ;
float moved = std::sqrt(dx*dx + dy*dy + dz*dz);
LOG_INFO("server.loading",
"[taxi-log] MID player={} path={} pos=({:.1f},{:.1f},{:.1f}) map={} dMoved={:.1f}yd",
player->GetName(), state.lastPath,
player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
player->GetMapId(), moved);
state.lastX = player->GetPositionX();
state.lastY = player->GetPositionY();
state.lastZ = player->GetPositionZ();
state.lastMapId = player->GetMapId();
}
};
void AddSC_taxi_builder_logger()
{
new taxi_builder_gossip_log();
new taxi_builder_flight_log();
}
@@ -1,16 +0,0 @@
/*
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* mod-taxi-builder entry point. AzerothCore's module loader calls
* Addmod_taxi_builderScripts() during startup; this function registers
* every script the module owns.
*/
void AddSC_taxi_builder_commandscript();
void AddSC_taxi_builder_logger();
void Addmod_taxi_builderScripts()
{
AddSC_taxi_builder_commandscript();
AddSC_taxi_builder_logger();
}
-1
View File
@@ -1 +0,0 @@
./__pycache__
-1
View File
@@ -1 +0,0 @@
python3
-1
View File
@@ -1 +0,0 @@
/usr/bin/python3
-1
View File
@@ -1 +0,0 @@
python3
-1
View File
@@ -1 +0,0 @@
lib
-5
View File
@@ -1,5 +0,0 @@
home = /usr/bin
include-system-site-packages = false
version = 3.12.3
executable = /usr/bin/python3.12
command = /usr/bin/python3 -m venv /home/sindo/moonwell-core/tools/moonwell-taxi/.venv
-87
View File
@@ -1,87 +0,0 @@
# moonwell-taxi
External CLI that reads custom flight data from `acore_world` and the
three vanilla taxi DBCs, then generates merged DBCs ready to drop into
both the server's `dbc/` directory and the client patch.
## Quick start
```bash
# 1. install deps (PyMySQL only)
pip install -r requirements.txt
# 2. point at your vanilla DBC source and run the export
./bin/moonwell-taxi export-dbc \
--vanilla-dir /home/sindo/moonwell-core \
--output-dir /home/sindo/moonwell-core/build/taxi/export
# Output:
# <output-dir>/server/dbc/TaxiNodes.dbc
# <output-dir>/server/dbc/TaxiPath.dbc
# <output-dir>/server/dbc/TaxiPathNode.dbc
# <output-dir>/client/DBFilesClient/TaxiNodes.dbc
# <output-dir>/client/DBFilesClient/TaxiPath.dbc
# <output-dir>/client/DBFilesClient/TaxiPathNode.dbc
#
# A sha256 manifest is written next to each half; the tool fails with a
# non-zero exit if server and client copies do not match.
# 3. pack the client half into an MPQ patch for native taxi UI
./bin/moonwell-taxi build-client-patch
# Output:
# <output-dir>/client/patch-Z.MPQ
#
# Copy that MPQ to the WoW 3.3.5a client's Data directory, delete the
# client's Cache directory, then restart Wow.exe. Loose DBFilesClient/*.dbc
# files are not enough for the stock 3.3.5a client taxi UI.
```
## DB connection
Reads connection params from environment variables (which `acore.sh` and
`import-custom-sql.sh` already populate from `.env`):
- `DOCKER_DB_EXTERNAL_PORT` (default 3306)
- `DOCKER_DB_ROOT_PASSWORD` (default "password")
- `MOONWELL_TAXI_DB_HOST` (default 127.0.0.1)
- `MOONWELL_TAXI_DB_USER` (default root)
- `MOONWELL_TAXI_DB_NAME` (default acore_world)
CLI flags override env: `--db-host`, `--db-port`, `--db-user`,
`--db-password`, `--db-name`.
## Subcommands
- `export-dbc` — fully implemented: merges vanilla + custom into new DBCs,
writes server and client copies, prints sha256 for both. Refuses to run
if `--vanilla-dir` already contains records in the custom-id space
(TaxiNode id ≥ 441, TaxiPath id ≥ 1979) — that means the directory
was already merged once and re-running would compound rows.
- `build-client-patch` — packs the exported client DBCs into a minimal MPQ
patch with internal paths `DBFilesClient\TaxiNodes.dbc`,
`DBFilesClient\TaxiPath.dbc`, and `DBFilesClient\TaxiPathNode.dbc`.
This is required for the native flight-master map UI to see custom
taxi nodes; otherwise the client can crash when the server sends a
custom current-node id.
- `thin --path <id> [--min-yd 50] [--dry-run]` — drops waypoints that sit
within `min-yd` (XY) of the previously kept point, preserves first and
last, renumbers `node_index` from 0. Use it when an in-game recording
produced too-dense waypoints (chat shows lots of
`segment N->M is X.X yd (< 40 yd ...)` warnings) — server filters those
via `IsNodeIncludedInShortenedPath` and the spline goes haywire.
Backs up to `custom_taxi_path_nodes_backup_<pathId>_<ts>` before
deleting; the rollback SQL is printed at the end.
- `dump-vanilla <path> [--limit N]` — debug helper: parses a `.dbc` file
and prints header + first N records as raw uint32. Useful when adding
new schemas.
The other subcommands listed in the spec (build-mpq, deploy-server,
backup, rollback, diff) are not part of the MVP and return a
not-implemented error today.
## Default paths
`--vanilla-dir` defaults to `<repo>/var/client-data-vanilla`. Keep that
directory as a clean snapshot. `redeploy.sh` writes merged files into
`<repo>/var/client-data-live/dbc`, not back into the vanilla snapshot.
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
exec python3 "$HERE/moonwell_taxi.py" "$@"
-958
View File
@@ -1,958 +0,0 @@
#!/usr/bin/env python3
"""
moonwell-taxi: merge custom_taxi_* DB rows with vanilla TaxiNodes /
TaxiPath / TaxiPathNode DBCs, write server and client copies, and
sha256-verify they match.
The DBC binary format used by 3.3.5a is:
20-byte header:
magic char[4] = b'WDBC'
record_count uint32
field_count uint32
record_size uint32 (bytes per record)
sb_size uint32 (string block size)
record_count * record_size bytes of records
sb_size bytes of NUL-terminated strings (the string block)
Field types are not encoded in the file; the schema is implicit.
For our three tables the layouts are:
TaxiNodes (24 fields, 96 B/record):
ID uint32
ContinentID uint32
Pos_X float
Pos_Y float
Pos_Z float
Name_lang[16] uint32 string offsets
Name_lang_mask uint32
MountCreatureID[2] uint32 (Horde, Alliance)
TaxiPath (4 fields, 16 B/record):
ID, FromTaxiNode, ToTaxiNode, Cost (all uint32)
TaxiPathNode (11 fields, 44 B/record):
ID, PathID, NodeIndex, ContinentID uint32
Loc_X, Loc_Y, Loc_Z float
Flags, Delay, ArrivalEventID, DepartureEventID uint32
"""
import argparse
import hashlib
import os
import struct
import sys
from dataclasses import dataclass
from pathlib import Path
DBC_MAGIC = b"WDBC"
DBC_HEADER_FMT = "<4sIIII"
DBC_HEADER_SIZE = 20
TAXI_NODES_FIELDS = 24
TAXI_NODES_RECSIZE = TAXI_NODES_FIELDS * 4 # 96
TAXI_PATH_FIELDS = 4
TAXI_PATH_RECSIZE = TAXI_PATH_FIELDS * 4 # 16
TAXI_PATH_NODE_FIELDS = 11
TAXI_PATH_NODE_RECSIZE = TAXI_PATH_NODE_FIELDS * 4 # 44
# Vanilla DBC files we merge into. Filenames must match WoW's case.
DBC_FILES = ("TaxiNodes.dbc", "TaxiPath.dbc", "TaxiPathNode.dbc")
CLIENT_MPQ_PATHS = tuple(f"DBFilesClient\\{name}" for name in DBC_FILES)
MPQ_MAGIC = b"MPQ\x1A"
MPQ_HEADER_FMT = "<4sIIHHIIII"
MPQ_HEADER_SIZE = 32
MPQ_HASH_ENTRY_DELETED = 0xFFFFFFFE
MPQ_HASH_ENTRY_EMPTY = 0xFFFFFFFF
MPQ_FILE_EXISTS = 0x80000000
MPQ_FILE_SINGLE_UNIT = 0x01000000
def _build_mpq_crypt_table() -> list:
crypt_table = [0] * 0x500
seed = 0x00100001
for index1 in range(0x100):
index2 = index1
for _ in range(5):
seed = (seed * 125 + 3) % 0x2AAAAB
temp1 = (seed & 0xFFFF) << 16
seed = (seed * 125 + 3) % 0x2AAAAB
temp2 = seed & 0xFFFF
crypt_table[index2] = (temp1 | temp2) & 0xFFFFFFFF
index2 += 0x100
return crypt_table
MPQ_CRYPT_TABLE = _build_mpq_crypt_table()
def mpq_hash_string(name: str, hash_type: int) -> int:
seed1 = 0x7FED7FED
seed2 = 0xEEEEEEEE
normalized = name.replace("/", "\\").upper()
for ch in normalized.encode("ascii"):
value = MPQ_CRYPT_TABLE[(hash_type << 8) + ch]
seed1 = (value ^ (seed1 + seed2)) & 0xFFFFFFFF
seed2 = (ch + seed1 + seed2 + ((seed2 << 5) & 0xFFFFFFFF) + 3) & 0xFFFFFFFF
return seed1
def mpq_encrypt_words(words: list, key: int) -> bytes:
seed1 = key
seed2 = 0xEEEEEEEE
out = []
for word in words:
word &= 0xFFFFFFFF
seed2 = (seed2 + MPQ_CRYPT_TABLE[0x400 + (seed1 & 0xFF)]) & 0xFFFFFFFF
encrypted = (word ^ (seed1 + seed2)) & 0xFFFFFFFF
seed1 = (((~seed1 << 21) & 0xFFFFFFFF) + 0x11111111 + (seed1 >> 11)) & 0xFFFFFFFF
seed2 = (encrypted + seed2 + ((seed2 << 5) & 0xFFFFFFFF) + 3) & 0xFFFFFFFF
out.append(encrypted)
return struct.pack(f"<{len(out)}I", *out)
def mpq_decrypt_words(data: bytes, key: int) -> list:
if len(data) % 4:
raise ValueError("encrypted MPQ table size is not uint32-aligned")
seed1 = key
seed2 = 0xEEEEEEEE
words = struct.unpack(f"<{len(data) // 4}I", data)
out = []
for encrypted in words:
seed2 = (seed2 + MPQ_CRYPT_TABLE[0x400 + (seed1 & 0xFF)]) & 0xFFFFFFFF
plain = (encrypted ^ (seed1 + seed2)) & 0xFFFFFFFF
seed1 = (((~seed1 << 21) & 0xFFFFFFFF) + 0x11111111 + (seed1 >> 11)) & 0xFFFFFFFF
seed2 = (encrypted + seed2 + ((seed2 << 5) & 0xFFFFFFFF) + 3) & 0xFFFFFFFF
out.append(plain)
return out
def next_power_of_two(value: int) -> int:
n = 1
while n < value:
n <<= 1
return n
def build_mpq(files: dict, output_file: Path) -> None:
"""Write a minimal MPQ v1 archive with uncompressed single-unit files."""
names = list(files)
hash_table_size = max(8, next_power_of_two(len(names) * 2))
hash_entries = [[0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF] for _ in range(hash_table_size)]
block_entries = []
cursor = MPQ_HEADER_SIZE
payload = bytearray()
for block_index, name in enumerate(names):
data = files[name]
payload.extend(data)
block_entries.append([
cursor,
len(data),
len(data),
MPQ_FILE_EXISTS | MPQ_FILE_SINGLE_UNIT,
])
slot = mpq_hash_string(name, 0) & (hash_table_size - 1)
while hash_entries[slot][3] not in (MPQ_HASH_ENTRY_EMPTY, MPQ_HASH_ENTRY_DELETED):
slot = (slot + 1) & (hash_table_size - 1)
hash_entries[slot] = [
mpq_hash_string(name, 1),
mpq_hash_string(name, 2),
0,
block_index,
]
cursor += len(data)
hash_table_pos = cursor
hash_words = [word for entry in hash_entries for word in entry]
hash_table = mpq_encrypt_words(hash_words, mpq_hash_string("(hash table)", 3))
cursor += len(hash_table)
block_table_pos = cursor
block_words = [word for entry in block_entries for word in entry]
block_table = mpq_encrypt_words(block_words, mpq_hash_string("(block table)", 3))
cursor += len(block_table)
header = struct.pack(
MPQ_HEADER_FMT,
MPQ_MAGIC,
MPQ_HEADER_SIZE,
cursor,
0,
3,
hash_table_pos,
block_table_pos,
hash_table_size,
len(block_entries),
)
output_file.parent.mkdir(parents=True, exist_ok=True)
with output_file.open("wb") as f:
f.write(header)
f.write(payload)
f.write(hash_table)
f.write(block_table)
def verify_mpq(files: dict, mpq_file: Path) -> None:
data = mpq_file.read_bytes()
if len(data) < MPQ_HEADER_SIZE:
raise ValueError(f"{mpq_file} too short to be an MPQ")
(
magic,
header_size,
archive_size,
fmt_version,
_block_size,
hash_table_pos,
block_table_pos,
hash_table_size,
block_table_size,
) = struct.unpack(MPQ_HEADER_FMT, data[:MPQ_HEADER_SIZE])
if magic != MPQ_MAGIC or header_size != MPQ_HEADER_SIZE or fmt_version != 0:
raise ValueError(f"{mpq_file} has an unsupported MPQ header")
if archive_size != len(data):
raise ValueError(f"{mpq_file} size mismatch: header={archive_size}, actual={len(data)}")
hash_bytes = data[hash_table_pos : hash_table_pos + hash_table_size * 16]
block_bytes = data[block_table_pos : block_table_pos + block_table_size * 16]
hash_words = mpq_decrypt_words(hash_bytes, mpq_hash_string("(hash table)", 3))
block_words = mpq_decrypt_words(block_bytes, mpq_hash_string("(block table)", 3))
hash_entries = [hash_words[i:i + 4] for i in range(0, len(hash_words), 4)]
block_entries = [block_words[i:i + 4] for i in range(0, len(block_words), 4)]
for name, expected in files.items():
slot = mpq_hash_string(name, 0) & (hash_table_size - 1)
name1 = mpq_hash_string(name, 1)
name2 = mpq_hash_string(name, 2)
for _ in range(hash_table_size):
entry = hash_entries[slot]
if entry[3] == MPQ_HASH_ENTRY_EMPTY:
break
if entry[0] == name1 and entry[1] == name2:
block_index = entry[3]
file_pos, compressed_size, file_size, flags = block_entries[block_index]
actual = data[file_pos:file_pos + compressed_size]
if flags != (MPQ_FILE_EXISTS | MPQ_FILE_SINGLE_UNIT):
raise ValueError(f"{mpq_file}: unexpected flags for {name}: 0x{flags:08X}")
if file_size != len(expected) or actual != expected:
raise ValueError(f"{mpq_file}: payload mismatch for {name}")
break
slot = (slot + 1) & (hash_table_size - 1)
else:
raise ValueError(f"{mpq_file}: hash table probe exhausted for {name}")
if hash_entries[slot][0] != name1 or hash_entries[slot][1] != name2:
raise ValueError(f"{mpq_file}: missing {name}")
def sort_records_by_id(dbc: "DBC") -> None:
"""Keep DBC records in ascending ID order.
AzerothCore's server loader builds an explicit ID index and tolerates
records appended out of order. The 3.3.5a client is stricter around taxi
UI lookups, so custom IDs that fill holes below the vanilla max must be
inserted into sorted order instead of left at EOF.
"""
dbc.records.sort(key=lambda r: struct.unpack_from("<I", r, 0)[0])
# --------------------------------------------------------------------------- #
# .env loader (avoids adding python-dotenv as a dep) #
# --------------------------------------------------------------------------- #
def load_env_file(path: Path) -> None:
if not path.exists():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
# --------------------------------------------------------------------------- #
# DBC binary I/O #
# --------------------------------------------------------------------------- #
@dataclass
class DBC:
"""Mutable in-memory DBC: a list of record bytes + the string block."""
field_count: int
record_size: int
records: list # list[bytes], each exactly record_size bytes
string_block: bytearray
@classmethod
def read(cls, path: Path) -> "DBC":
data = path.read_bytes()
if len(data) < DBC_HEADER_SIZE:
raise ValueError(f"{path} too short to be a DBC")
magic, n_rec, n_field, rec_size, sb_size = struct.unpack(
DBC_HEADER_FMT, data[:DBC_HEADER_SIZE]
)
if magic != DBC_MAGIC:
raise ValueError(f"{path} bad magic {magic!r}, expected {DBC_MAGIC!r}")
records_blob_size = n_rec * rec_size
records_end = DBC_HEADER_SIZE + records_blob_size
sb_end = records_end + sb_size
if sb_end != len(data):
raise ValueError(
f"{path} size mismatch: header says {sb_end} bytes, file is {len(data)}"
)
records = [
data[DBC_HEADER_SIZE + i * rec_size : DBC_HEADER_SIZE + (i + 1) * rec_size]
for i in range(n_rec)
]
return cls(
field_count=n_field,
record_size=rec_size,
records=records,
string_block=bytearray(data[records_end:sb_end]),
)
def write(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("wb") as f:
f.write(struct.pack(
DBC_HEADER_FMT,
DBC_MAGIC,
len(self.records),
self.field_count,
self.record_size,
len(self.string_block),
))
for r in self.records:
if len(r) != self.record_size:
raise ValueError(
f"record size {len(r)} != schema {self.record_size}"
)
f.write(r)
f.write(self.string_block)
def append_string(self, s: str) -> int:
"""Append a UTF-8 string to the block and return its offset."""
# An empty string block always starts with a NUL byte at offset 0
# (so any record's "no string" can point to offset 0). Real DBCs
# already have this from vanilla, but we guard for safety.
if not self.string_block:
self.string_block.append(0)
offset = len(self.string_block)
self.string_block.extend(s.encode("utf-8"))
self.string_block.append(0)
return offset
# --------------------------------------------------------------------------- #
# Schema-specific record helpers #
# --------------------------------------------------------------------------- #
def pack_taxinode_record(
*,
node_id: int,
map_id: int,
x: float,
y: float,
z: float,
name_offset: int,
mount_alliance: int,
mount_horde: int,
locale_slot: int,
name_lang_mask: int,
) -> bytes:
"""Build a 96-byte TaxiNodes record.
The vanilla 3.3.5a client expects locale-aware DBCs: the string lives
only in the slot that matches the file's locale (e.g. slot 8 for ruRU
DBCs), the other 15 slots stay 0, and `name_lang_mask` carries a
Blizzard-internal bitmask. Writing the string into every slot or
using a different mask trips strict clients (DragonUI / modded
Wow.exe builds crash on `LookupEntry`-style lookups with a NULL
deref at TaxiNodesEntry+12).
Caller computes the right `locale_slot` and `name_lang_mask` by
sampling a vanilla record (see infer_taxinodes_locale_layout).
Note: in the DBC binary, MountCreatureID[0] is the *Horde* mount and
MountCreatureID[1] is the *Alliance* mount (see ObjectMgr.cpp:7229
`teamId == TEAM_ALLIANCE ? 1 : 0`). The taxi-builder spec text is
inverted; trust the runtime usage."""
if not (0 <= locale_slot < 16):
raise ValueError(f"locale_slot {locale_slot} out of range 0..15")
parts = [
struct.pack("<II", node_id, map_id),
struct.pack("<fff", x, y, z),
]
for i in range(16):
parts.append(struct.pack("<I", name_offset if i == locale_slot else 0))
parts.append(struct.pack("<III", name_lang_mask, mount_horde, mount_alliance))
rec = b"".join(parts)
assert len(rec) == TAXI_NODES_RECSIZE, len(rec)
return rec
def infer_taxinodes_locale_layout(dbc: "DBC") -> tuple:
"""Walk vanilla TaxiNodes records and figure out (a) which locale slot
Blizzard's localized DBC uses (the one with non-zero offsets), and
(b) the typical `name_lang_mask` value. Returns (locale_slot, mask).
Falls back to (8, 0xFF00FE) — ruRU + observed vanilla mask — if the
file is empty."""
mask_counts = {}
slot_counts = [0] * 16
for r in dbc.records:
for i in range(16):
off = struct.unpack_from("<I", r, 8 + 12 + i * 4)[0]
if off:
slot_counts[i] += 1
m = struct.unpack_from("<I", r, 8 + 12 + 16 * 4)[0]
mask_counts[m] = mask_counts.get(m, 0) + 1
best_slot = max(range(16), key=lambda i: slot_counts[i]) if any(slot_counts) else 8
best_mask = max(mask_counts, key=mask_counts.get) if mask_counts else 0xFF00FE
return best_slot, best_mask
def pack_taxipath_record(*, path_id: int, from_node: int, to_node: int, cost: int) -> bytes:
rec = struct.pack("<IIII", path_id, from_node, to_node, cost)
assert len(rec) == TAXI_PATH_RECSIZE, len(rec)
return rec
def pack_taxipathnode_record(
*,
record_id: int,
path_id: int,
node_index: int,
map_id: int,
x: float,
y: float,
z: float,
flags: int,
delay: int,
arrival_event: int,
departure_event: int,
) -> bytes:
rec = struct.pack(
"<IIIIfffIIII",
record_id, path_id, node_index, map_id,
x, y, z,
flags, delay, arrival_event, departure_event,
)
assert len(rec) == TAXI_PATH_NODE_RECSIZE, len(rec)
return rec
def vanilla_max_id_uint32_at_offset(records: list, byte_offset: int) -> int:
"""Scan `records` and return the max uint32 read at byte_offset."""
best = 0
for r in records:
v = struct.unpack_from("<I", r, byte_offset)[0]
if v > best:
best = v
return best
# --------------------------------------------------------------------------- #
# DB access #
# --------------------------------------------------------------------------- #
def db_connect(args):
import pymysql
return pymysql.connect(
host=args.db_host,
port=args.db_port,
user=args.db_user,
password=args.db_password,
database=args.db_name,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
)
def fetch_custom_taxi_nodes(conn):
with conn.cursor() as c:
c.execute(
"SELECT id, name, map_id, position_x, position_y, position_z, "
" mount_alliance, mount_horde "
"FROM custom_taxi_nodes ORDER BY id"
)
return list(c.fetchall())
def fetch_custom_taxi_paths(conn):
with conn.cursor() as c:
c.execute(
"SELECT id, from_taxi_node_id, to_taxi_node_id, cost "
"FROM custom_taxi_paths WHERE is_enabled = 1 ORDER BY id"
)
return list(c.fetchall())
def fetch_custom_taxi_path_nodes(conn):
with conn.cursor() as c:
c.execute(
"SELECT path_id, node_index, map_id, position_x, position_y, position_z, "
" flags, delay, arrival_event_id, departure_event_id "
"FROM custom_taxi_path_nodes ORDER BY path_id, node_index"
)
return list(c.fetchall())
# --------------------------------------------------------------------------- #
# Export pipeline #
# --------------------------------------------------------------------------- #
def merge_taxinodes(dbc: DBC, custom_rows: list) -> int:
"""Append custom rows to the loaded TaxiNodes DBC. Returns the count
of appended records. Picks the locale_slot and name_lang_mask from
the vanilla file so our records are byte-shaped exactly like the
surrounding Blizzard data — strict clients reject anything else."""
if dbc.record_size != TAXI_NODES_RECSIZE:
raise ValueError(
f"TaxiNodes.dbc record size {dbc.record_size} != expected {TAXI_NODES_RECSIZE}"
)
locale_slot, lang_mask = infer_taxinodes_locale_layout(dbc)
print(f"TaxiNodes locale layout: slot={locale_slot}, lang_mask=0x{lang_mask:08X}")
vanilla_ids = {struct.unpack_from("<I", r, 0)[0] for r in dbc.records}
appended = 0
for row in custom_rows:
if row["id"] in vanilla_ids:
raise ValueError(
f"custom TaxiNode id {row['id']} collides with a vanilla TaxiNode id"
)
offset = dbc.append_string(row["name"])
dbc.records.append(pack_taxinode_record(
node_id=row["id"],
map_id=row["map_id"],
x=row["position_x"],
y=row["position_y"],
z=row["position_z"],
name_offset=offset,
mount_alliance=row["mount_alliance"],
mount_horde=row["mount_horde"],
locale_slot=locale_slot,
name_lang_mask=lang_mask,
))
appended += 1
sort_records_by_id(dbc)
return appended
def merge_taxipath(dbc: DBC, custom_rows: list) -> int:
if dbc.record_size != TAXI_PATH_RECSIZE:
raise ValueError(
f"TaxiPath.dbc record size {dbc.record_size} != expected {TAXI_PATH_RECSIZE}"
)
vanilla_ids = {struct.unpack_from("<I", r, 0)[0] for r in dbc.records}
appended = 0
for row in custom_rows:
if row["id"] in vanilla_ids:
raise ValueError(
f"custom TaxiPath id {row['id']} collides with a vanilla TaxiPath id"
)
dbc.records.append(pack_taxipath_record(
path_id=row["id"],
from_node=row["from_taxi_node_id"],
to_node=row["to_taxi_node_id"],
cost=row["cost"],
))
appended += 1
sort_records_by_id(dbc)
return appended
def merge_taxipathnode(dbc: DBC, custom_rows: list) -> int:
if dbc.record_size != TAXI_PATH_NODE_RECSIZE:
raise ValueError(
f"TaxiPathNode.dbc record size {dbc.record_size} != expected {TAXI_PATH_NODE_RECSIZE}"
)
next_id = vanilla_max_id_uint32_at_offset(dbc.records, 0) + 1
appended = 0
for row in custom_rows:
dbc.records.append(pack_taxipathnode_record(
record_id=next_id,
path_id=row["path_id"],
node_index=row["node_index"],
map_id=row["map_id"],
x=row["position_x"],
y=row["position_y"],
z=row["position_z"],
flags=row["flags"],
delay=row["delay"],
arrival_event=row["arrival_event_id"],
departure_event=row["departure_event_id"],
))
next_id += 1
appended += 1
sort_records_by_id(dbc)
return appended
# --------------------------------------------------------------------------- #
# sha256 helpers #
# --------------------------------------------------------------------------- #
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def write_manifest(folder: Path, hashes: dict) -> None:
folder.mkdir(parents=True, exist_ok=True)
lines = [f"{h} {name}" for name, h in sorted(hashes.items())]
(folder / "sha256.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
# --------------------------------------------------------------------------- #
# Commands #
# --------------------------------------------------------------------------- #
def cmd_export_dbc(args) -> int:
vanilla_dir = Path(args.vanilla_dir).expanduser().resolve()
output_dir = Path(args.output_dir).expanduser().resolve()
server_dir = output_dir / "server" / "dbc"
client_dir = output_dir / "client" / "DBFilesClient"
print(f"Vanilla source: {vanilla_dir}")
print(f"Output: {output_dir}")
# Validate vanilla files exist before touching the DB.
for fname in DBC_FILES:
p = vanilla_dir / fname
if not p.is_file():
print(f"ERROR: vanilla DBC missing: {p}", file=sys.stderr)
return 2
print("Connecting to MySQL...")
conn = db_connect(args)
try:
nodes = fetch_custom_taxi_nodes(conn)
paths = fetch_custom_taxi_paths(conn)
wps = fetch_custom_taxi_path_nodes(conn)
finally:
conn.close()
print(f"Custom nodes: {len(nodes)}, paths: {len(paths)}, waypoints: {len(wps)}")
# Sanity checks on the data before we burn DBC bytes.
if paths and not wps:
print("WARNING: paths defined but no waypoints — generated DBC will be useless.",
file=sys.stderr)
enabled_path_ids = {p["id"] for p in paths}
waypoints_by_path = {}
for wp in wps:
waypoints_by_path.setdefault(wp["path_id"], []).append(wp)
for pid in waypoints_by_path:
if pid not in enabled_path_ids:
print(f"WARNING: waypoints reference disabled/unknown path_id={pid}; skipping.",
file=sys.stderr)
wps_filtered = [wp for wp in wps if wp["path_id"] in enabled_path_ids]
# Read vanilla, merge, write.
nodes_dbc = DBC.read(vanilla_dir / "TaxiNodes.dbc")
paths_dbc = DBC.read(vanilla_dir / "TaxiPath.dbc")
pn_dbc = DBC.read(vanilla_dir / "TaxiPathNode.dbc")
# Compounding guard: if the source already contains records in the
# taxi-builder custom-id space, the user is pointing --vanilla-dir at
# an already-merged tree. Continuing would duplicate entries.
# NB: TaxiMaskSize=14 caps node ids at 448 — vanilla 3.3.5a uses up to
# ~440, so 441 is the practical floor for custom node ids. Path ids are
# not bound by the mask and live in their own large range.
CUSTOM_NODE_FLOOR = 441
CUSTOM_PATH_FLOOR = 1979
seen_nodes = sum(
1 for r in nodes_dbc.records
if struct.unpack_from("<I", r, 0)[0] >= CUSTOM_NODE_FLOOR
)
seen_paths = sum(
1 for r in paths_dbc.records
if struct.unpack_from("<I", r, 0)[0] >= CUSTOM_PATH_FLOOR
)
if seen_nodes or seen_paths:
print(
f"ERROR: vanilla source at {vanilla_dir} already contains "
f"{seen_nodes} TaxiNodes id>={CUSTOM_NODE_FLOOR} and "
f"{seen_paths} TaxiPath id>={CUSTOM_PATH_FLOOR} — looks already-merged.\n"
f" Point --vanilla-dir at a clean snapshot, or restore "
f"vanilla DBCs into {vanilla_dir} before re-running.",
file=sys.stderr)
return 3
n_appended_nodes = merge_taxinodes(nodes_dbc, nodes)
n_appended_paths = merge_taxipath(paths_dbc, paths)
n_appended_pn = merge_taxipathnode(pn_dbc, wps_filtered)
print(f"Appended: TaxiNodes +{n_appended_nodes}, "
f"TaxiPath +{n_appended_paths}, "
f"TaxiPathNode +{n_appended_pn}")
print(f"Final: TaxiNodes {len(nodes_dbc.records)} records, "
f"TaxiPath {len(paths_dbc.records)} records, "
f"TaxiPathNode {len(pn_dbc.records)} records")
# Write to server/dbc and client/DBFilesClient. Same bytes both sides.
targets = {
"TaxiNodes.dbc": nodes_dbc,
"TaxiPath.dbc": paths_dbc,
"TaxiPathNode.dbc": pn_dbc,
}
server_hashes = {}
client_hashes = {}
for fname, dbc in targets.items():
spath = server_dir / fname
cpath = client_dir / fname
dbc.write(spath)
dbc.write(cpath)
server_hashes[fname] = sha256_file(spath)
client_hashes[fname] = sha256_file(cpath)
write_manifest(server_dir, server_hashes)
write_manifest(client_dir, client_hashes)
print()
print("sha256 (server):")
for fname, h in sorted(server_hashes.items()):
print(f" {h} {fname}")
print("sha256 (client):")
for fname, h in sorted(client_hashes.items()):
print(f" {h} {fname}")
if server_hashes != client_hashes:
print("Server/client DBC match: FAIL", file=sys.stderr)
return 1
print("Server/client DBC match: OK")
return 0
def cmd_build_client_patch(args) -> int:
input_dir = Path(args.input_dir).expanduser().resolve()
output_file = Path(args.output_file).expanduser().resolve()
print(f"Client DBC input: {input_dir}")
print(f"MPQ output: {output_file}")
files = {}
listfile_lines = []
for internal_path, fname in zip(CLIENT_MPQ_PATHS, DBC_FILES):
source = input_dir / fname
if not source.is_file():
print(f"ERROR: client DBC missing: {source}", file=sys.stderr)
return 2
files[internal_path] = source.read_bytes()
listfile_lines.append(internal_path)
files["(listfile)"] = ("\r\n".join(listfile_lines) + "\r\n").encode("ascii")
build_mpq(files, output_file)
verify_mpq(files, output_file)
print()
print(f"MPQ sha256: {sha256_file(output_file)} {output_file.name}")
print("Client install:")
print(f" Copy to: <WoW 3.3.5a>\\Data\\{output_file.name}")
print(" Then delete the client Cache directory and restart Wow.exe.")
return 0
def cmd_dump_vanilla(args) -> int:
"""Read a vanilla DBC and dump header + first few raw records."""
p = Path(args.dbc).expanduser().resolve()
dbc = DBC.read(p)
print(f"{p}")
print(f" field_count={dbc.field_count} record_size={dbc.record_size} "
f"records={len(dbc.records)} string_block={len(dbc.string_block)}")
n = min(args.limit, len(dbc.records))
for i in range(n):
r = dbc.records[i]
ints = struct.unpack(f"<{dbc.record_size // 4}I", r)
print(f" [{i}] {ints}")
return 0
def cmd_thin(args) -> int:
"""Drop intermediate waypoints that sit within args.min_yd (XY) of the
previously kept point. Always keeps the first and the last waypoint;
re-numbers node_index from 0 in place. Original rows are saved to a
backup table so the operation is reversible."""
import time
if args.min_yd < 1.0:
print("--min-yd must be >= 1", file=sys.stderr)
return 2
conn = db_connect(args)
try:
with conn.cursor() as c:
c.execute(
"SELECT id, path_id, node_index, map_id, position_x, position_y, position_z, "
" flags, delay, arrival_event_id, departure_event_id, comment "
"FROM custom_taxi_path_nodes WHERE path_id = %s ORDER BY node_index",
(args.path,),
)
rows = list(c.fetchall())
if not rows:
print(f"path {args.path} has no waypoints", file=sys.stderr)
return 1
if len(rows) < 3:
print(f"path {args.path} has only {len(rows)} waypoints — nothing to thin")
return 0
def dist_xy(a, b):
dx = a["position_x"] - b["position_x"]
dy = a["position_y"] - b["position_y"]
return (dx * dx + dy * dy) ** 0.5
kept = [rows[0]]
for wp in rows[1:-1]:
if wp["map_id"] != kept[-1]["map_id"]:
kept.append(wp)
continue
if dist_xy(wp, kept[-1]) >= args.min_yd:
kept.append(wp)
kept.append(rows[-1])
# If the second-to-last we kept is now inside the spacing window
# of the final point, drop it — we want the last point unique.
while (
len(kept) >= 3
and kept[-2]["map_id"] == kept[-1]["map_id"]
and dist_xy(kept[-2], kept[-1]) < args.min_yd
):
kept.pop(-2)
print(f"Path {args.path}: {len(rows)} -> {len(kept)} waypoints "
f"(min_yd={args.min_yd}, removed {len(rows) - len(kept)})")
if args.dry_run:
print("(dry run — DB not modified). New sequence:")
for i, wp in enumerate(kept):
seg = ""
if i > 0 and kept[i - 1]["map_id"] == wp["map_id"]:
seg = f" seg={dist_xy(kept[i - 1], wp):.1f}yd"
print(f" [{i:>3}] (was {wp['node_index']:>3}) map={wp['map_id']} "
f"({wp['position_x']:.1f}, {wp['position_y']:.1f}, {wp['position_z']:.1f}){seg}")
return 0
ts = int(time.time())
backup_table = f"custom_taxi_path_nodes_backup_{args.path}_{ts}"
with conn.cursor() as c:
c.execute(
f"CREATE TABLE `{backup_table}` AS "
"SELECT * FROM custom_taxi_path_nodes WHERE path_id = %s",
(args.path,),
)
c.execute(
"DELETE FROM custom_taxi_path_nodes WHERE path_id = %s",
(args.path,),
)
for new_idx, wp in enumerate(kept):
c.execute(
"INSERT INTO custom_taxi_path_nodes "
"(path_id, node_index, map_id, position_x, position_y, position_z, "
" flags, delay, arrival_event_id, departure_event_id, comment) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
(args.path, new_idx, wp["map_id"],
wp["position_x"], wp["position_y"], wp["position_z"],
wp["flags"], wp["delay"],
wp["arrival_event_id"], wp["departure_event_id"], wp["comment"]),
)
conn.commit()
print(f"Backup table: `{backup_table}`")
print("To rollback:")
print(f" DELETE FROM custom_taxi_path_nodes WHERE path_id = {args.path};")
print(f" INSERT INTO custom_taxi_path_nodes "
f"SELECT * FROM `{backup_table}`;")
finally:
conn.close()
return 0
def cmd_not_implemented(args) -> int:
print(f"`{args.subcommand}` is not implemented in the MVP.", file=sys.stderr)
return 2
# --------------------------------------------------------------------------- #
# CLI wiring #
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
repo_root = Path(__file__).resolve().parent.parent.parent
default_vanilla = repo_root / "var" / "client-data-vanilla"
default_output = repo_root / "build" / "taxi" / "export"
default_patch = default_output / "client" / "patch-Z.MPQ"
p = argparse.ArgumentParser(prog="moonwell-taxi", description=__doc__)
sub = p.add_subparsers(dest="subcommand", required=True)
# Shared DB connection flags.
def add_db_flags(sp):
sp.add_argument("--db-host", default=os.environ.get("MOONWELL_TAXI_DB_HOST", "127.0.0.1"))
sp.add_argument("--db-port", type=int,
default=int(os.environ.get("DOCKER_DB_EXTERNAL_PORT", "3306")))
sp.add_argument("--db-user", default=os.environ.get("MOONWELL_TAXI_DB_USER", "root"))
sp.add_argument("--db-password", default=os.environ.get("DOCKER_DB_ROOT_PASSWORD", "password"))
sp.add_argument("--db-name", default=os.environ.get("MOONWELL_TAXI_DB_NAME", "acore_world"))
sp_export = sub.add_parser("export-dbc", help="merge custom + vanilla into new DBCs")
sp_export.add_argument("--vanilla-dir", default=str(default_vanilla),
help="directory containing TaxiNodes.dbc / TaxiPath.dbc / TaxiPathNode.dbc "
"(default: repo root)")
sp_export.add_argument("--output-dir", default=str(default_output),
help="output base; server/ and client/ subtrees are created here")
add_db_flags(sp_export)
sp_export.set_defaults(func=cmd_export_dbc)
sp_patch = sub.add_parser("build-client-patch", help="pack exported client DBCs into a WoW MPQ patch")
sp_patch.add_argument("--input-dir", default=str(default_output / "client" / "DBFilesClient"),
help="directory containing exported client Taxi*.dbc files")
sp_patch.add_argument("--output-file", default=str(default_patch),
help="MPQ file to write")
sp_patch.set_defaults(func=cmd_build_client_patch)
sp_dump = sub.add_parser("dump-vanilla", help="dump header + records for a DBC")
sp_dump.add_argument("dbc", help="path to a .dbc file")
sp_dump.add_argument("--limit", type=int, default=5)
sp_dump.set_defaults(func=cmd_dump_vanilla)
sp_thin = sub.add_parser("thin", help="drop too-close waypoints in a path")
sp_thin.add_argument("--path", type=int, required=True, help="custom_taxi_paths.id to thin")
sp_thin.add_argument("--min-yd", type=float, default=50.0,
help="minimum XY segment distance to keep (default 50)")
sp_thin.add_argument("--dry-run", action="store_true",
help="show what would change, do not touch the DB")
add_db_flags(sp_thin)
sp_thin.set_defaults(func=cmd_thin)
for stub in ("build-mpq", "deploy-server", "backup", "rollback", "diff"):
sp = sub.add_parser(stub, help=f"(not implemented in MVP) {stub}")
sp.set_defaults(func=cmd_not_implemented)
return p
def main(argv=None) -> int:
repo_root = Path(__file__).resolve().parent.parent.parent
load_env_file(repo_root / ".env")
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
-53
View File
@@ -1,53 +0,0 @@
#!/usr/bin/env bash
# Re-export TaxiNodes / TaxiPath / TaxiPathNode DBCs from custom_taxi_*
# tables, copy them into the worldserver's bind-mounted data directory,
# and restart the worldserver. Idempotent.
#
# Vanilla baseline lives in var/client-data-vanilla/ and is treated as
# read-only: the export tool reads from there but writes to
# build/taxi/export/. Never point --vanilla-dir at the live tree, or
# subsequent exports will compound their own custom rows.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
VANILLA_DIR="${VANILLA_DIR:-$ROOT_DIR/var/client-data-vanilla}"
LIVE_DIR="${LIVE_DIR:-$ROOT_DIR/var/client-data-live/dbc}"
EXPORT_DIR="${EXPORT_DIR:-$ROOT_DIR/build/taxi/export}"
SERVER_OUT="$EXPORT_DIR/server/dbc"
log() { printf '[redeploy] %s\n' "$*"; }
if [[ ! -d "$VANILLA_DIR" ]]; then
printf 'vanilla snapshot not found: %s\n' "$VANILLA_DIR" >&2
printf 'Create it once from a clean live tree:\n' >&2
printf ' mkdir -p %s && cp %s/Taxi*.dbc %s/\n' \
"$VANILLA_DIR" "$LIVE_DIR" "$VANILLA_DIR" >&2
exit 1
fi
if [[ ! -d "$LIVE_DIR" ]]; then
printf 'live dbc dir not found: %s\n' "$LIVE_DIR" >&2
exit 1
fi
cd "$ROOT_DIR"
log "exporting DBCs from $VANILLA_DIR"
python3 "$ROOT_DIR/tools/moonwell-taxi/moonwell_taxi.py" export-dbc \
--vanilla-dir "$VANILLA_DIR" \
--output-dir "$EXPORT_DIR"
log "copying server DBCs into $LIVE_DIR"
cp "$SERVER_OUT/TaxiNodes.dbc" "$LIVE_DIR/"
cp "$SERVER_OUT/TaxiPath.dbc" "$LIVE_DIR/"
cp "$SERVER_OUT/TaxiPathNode.dbc" "$LIVE_DIR/"
if docker compose ps --status running --services 2>/dev/null | grep -qx 'ac-worldserver'; then
log "restarting ac-worldserver"
docker compose restart ac-worldserver
else
log "ac-worldserver is not running; new DBCs will be picked up on next start"
fi
log "done"
-1
View File
@@ -1 +0,0 @@
PyMySQL>=1.1