Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02d382c0f8 | |||
| 5dcb8be3aa |
+19
-35
@@ -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
+119789
File diff suppressed because it is too large
Load Diff
Vendored
+10
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
./__pycache__
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
python3
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/usr/bin/python3
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
python3
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
lib
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
Binary file not shown.
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
HERE="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
|
|
||||||
exec python3 "$HERE/moonwell_taxi.py" "$@"
|
|
||||||
@@ -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())
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
PyMySQL>=1.1
|
|
||||||
Reference in New Issue
Block a user