12 Commits

Author SHA1 Message Date
sindoring 1401e8dfb7 gitignore for tools 2026-06-06 19:53:37 +04:00
sindoring 13747727cf taxi builder 2026-05-11 18:35:47 +04:00
sindoring 0a3d3b6afa доработка db-export 2026-04-03 00:10:19 +04:00
sindoring c9b1100e3a фикс импорта бд 2026-04-02 22:52:48 +04:00
sindoring b608754d36 фикс настройки прод базы 2026-04-02 22:48:40 +04:00
sindoring 119b2a500b фикс импорта 2026-04-02 22:46:02 +04:00
sindoring 83262368ac backups 2026-04-02 22:28:07 +04:00
sindoring 0f2ec7c1b3 feat(Core/Docker): production deploy configuration
- docker-compose.prod.yml: MySQL tuning (innodb_buffer_pool=4G),
  resource limits, log rotation
- scripts/prod-deploy.sh: production startup script mirroring
  start-server.sh sequence with pre-flight checks and sysctl tuning
- conf/dist/env.production: production environment variable template

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 21:47:39 +04:00
Maxim Kalistratov b0d62064ba Merge pull request #1 from sindoring/mod-progressive-quest-xp-config
множители опыта от квестов с шагом в 10 уровней
2026-03-21 17:47:54 +04:00
sindoring ba63751269 множители опыта от квестов с шагом в 10 уровней 2026-03-21 16:29:01 +04:00
sindoring dc835fa9f8 эльфы крови и дренеи создаются без учета individual progress 2026-03-21 15:53:01 +04:00
sindoring d03e96cc9b AGENTS.md 2026-03-21 11:21:14 +04:00
34 changed files with 4715 additions and 1 deletions
+42
View File
@@ -0,0 +1,42 @@
{
"permissions": {
"allow": [
"Bash(grep -rn \"CREATE TABLE.*\\\\`game_tele\\\\`\" /home/sindo/moonwell-core/data/sql/base/db_world/)",
"Bash(grep -rn \"CREATE TABLE.*\\\\`waypoint_data\\\\`\\\\|CREATE TABLE.*\\\\`creature_addon\\\\`\" /home/sindo/moonwell-core/data/sql/base/db_world/)",
"Bash(bash import-custom-sql.sh)",
"Bash(grep -rn \"WorldDatabase\\\\.Execute\\(\\\\\".*{}.*\\\\\"\\\\|WorldDatabase\\\\.DirectExecute\\(\\\\\".*{}\\\\|WorldDatabase\\\\.Execute\\(\\\\\".*\\\\\".*,\" /home/sindo/moonwell-core/src/server/)",
"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(chmod +x /home/sindo/moonwell-core/tools/moonwell-taxi/bin/moonwell-taxi)",
"Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py dump-vanilla /home/sindo/moonwell-core/TaxiNodes.dbc --limit 3)",
"Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py dump-vanilla /home/sindo/moonwell-core/TaxiPath.dbc --limit 3)",
"Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py dump-vanilla /home/sindo/moonwell-core/TaxiPathNode.dbc --limit 3)",
"Bash(python3 -c \"import pymysql; print\\(pymysql.__version__\\)\")",
"Bash(python3 -m pip show pymysql)",
"Bash(python3 -m venv /home/sindo/moonwell-core/tools/moonwell-taxi/.venv)",
"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(/home/sindo/moonwell-core/tools/moonwell-taxi/.venv/bin/python -c \"import pymysql; print\\('ok', pymysql.__version__\\)\")",
"Bash(apt-cache search *)",
"Read(//usr/lib/python3/dist-packages/**)",
"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"
]
}
}
+29 -1
View File
@@ -261,7 +261,7 @@ ACORE_PROGRESSION_LIMIT=0
ACORE_PROGRESSION_ENFORCE_GROUP_RULES=1 ACORE_PROGRESSION_ENFORCE_GROUP_RULES=1
# На какой стадии открываются расы TBC. # На какой стадии открываются расы TBC.
ACORE_PROGRESSION_TBC_RACES_UNLOCK=8 ACORE_PROGRESSION_TBC_RACES_UNLOCK=0
# На какой стадии открывается создание Death Knight. # На какой стадии открывается создание Death Knight.
ACORE_PROGRESSION_DK_UNLOCK=13 ACORE_PROGRESSION_DK_UNLOCK=13
@@ -276,6 +276,34 @@ ACORE_PROGRESSION_MC_AQUAL_COOLDOWN_REDUCTION=60
# 0 = отключены, 1 = включены. # 0 = отключены, 1 = включены.
ACORE_PROGRESSION_PVP_GEAR_REQUIREMENTS=0 ACORE_PROGRESSION_PVP_GEAR_REQUIREMENTS=0
# ------------------------------------------------------------------------------
# mod-progressive-quest-xp
# ------------------------------------------------------------------------------
# 1 = включает прогрессивный множитель XP за квесты.
ACORE_DYNAMIC_QUEST_XP_ENABLE=1
# Множитель квестового XP для уровней 1-20.
ACORE_DYNAMIC_QUEST_XP_LEVEL_1_20=1.5
# Множитель квестового XP для уровней 21-30.
ACORE_DYNAMIC_QUEST_XP_LEVEL_21_30=1.3
# Множитель квестового XP для уровней 31-40.
ACORE_DYNAMIC_QUEST_XP_LEVEL_31_40=1.1
# Множитель квестового XP для уровней 41-50.
ACORE_DYNAMIC_QUEST_XP_LEVEL_41_50=1.0
# Множитель квестового XP для уровней 51-60.
ACORE_DYNAMIC_QUEST_XP_LEVEL_51_60=1.0
# Множитель квестового XP для уровней 61-70.
ACORE_DYNAMIC_QUEST_XP_LEVEL_61_70=1.0
# Множитель квестового XP для уровней 70-80.
ACORE_DYNAMIC_QUEST_XP_LEVEL_70_PLUS=1.0
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# mod-aoe-loot # mod-aoe-loot
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
+401
View File
@@ -0,0 +1,401 @@
# AGENTS.md
Project-specific instructions for coding agents working in this repository.
## Scope
These instructions apply to the whole repository rooted at this directory.
## Project Shape
- This repo is an AzerothCore 3.3.5a server with local custom deployment glue.
- It is run primarily through Docker Compose, not through direct host binaries.
- The important local customizations live in:
- `.env` and `.env.example`
- `setup-modules.sh`
- `start-server.sh`
- `create-account.sh`
- `sync-realmlist.sh`
- `import-custom-sql.sh`
- `lua_scripts/`
- `sql/`
- vendored modules in `modules/`
## Source Of Truth
- Treat `.env` plus the helper scripts as the source of truth for deployment behavior.
- Do not hand-edit `env/dist/etc/*` as a permanent fix. Those files are generated runtime config.
- If a change must persist, make it in `.env`, `.env.example`, or the generating shell script.
- Keep `.env.example` aligned with `.env` defaults when changing deployment settings.
## Generated And Ignored Paths
- `env/dist/*` is generated and gitignored.
- `var/*` is generated and gitignored.
- `.env` is local and gitignored.
- `src/server/scripts/Custom/*` is ignored except for:
- `src/server/scripts/Custom/README.md`
- `src/server/scripts/Custom/custom_script_loader.cpp`
- Do not rely on generated files being committed.
## Docker Workflow
Prefer the local wrapper scripts over raw compose commands when they already encode repo-specific logic.
### Primary commands
- Full startup:
- `./start-server.sh`
- `./start-server.sh --no-build`
- `./start-server.sh --logs`
- Regenerate module and world configs:
- `./setup-modules.sh`
- `./setup-modules.sh --dry-run`
- Sync realm address and gamebuild into auth DB:
- `./sync-realmlist.sh`
- Create or update an account:
- `./create-account.sh <username> <password> --gmlevel 0`
- Import custom SQL that is outside the normal module update path:
- `./import-custom-sql.sh`
### Useful raw compose commands
- `docker compose ps`
- `docker compose logs -f ac-worldserver ac-authserver`
- `docker compose stop`
- `docker compose down`
## Compose And Runtime Conventions
- `docker-compose.yml` is upstream-style base config. Prefer local changes in `docker-compose.override.yml`.
- Service names used by local scripts:
- `ac-database`
- `ac-db-import`
- `ac-client-data-init`
- `ac-authserver`
- `ac-worldserver`
- The deployment expects Docker Compose v2 (`docker compose`, not `docker-compose`).
## Config Layering Rules
### World config
- `setup-modules.sh` manages selected keys in `env/dist/etc/worldserver.conf`.
- If you need a persistent world config change, add it to `setup-modules.sh` and usually expose it through `.env`.
### Module config
- This repo must write effective module overrides to `env/dist/etc/modules/*.conf.dist`.
- Do not introduce a parallel `*.conf` override layer for modules here.
- AzerothCore loads module config files from the compiled module config list, and in this repo that list resolves to `*.conf.dist`.
- If you change module defaults, update the corresponding writer function in `setup-modules.sh`.
### Playerbots
- `mod-playerbots` is supported here, but it is a fragile area.
- Keep the repo default conservative:
- no random bot autologin
- no random bot startup population
- small addclass pool
- Do not accidentally reintroduce the old broken pattern where only `playerbots.conf` was written.
- Be careful with Docker environment overrides that map to `AiPlayerbot.*`; they can silently override file config.
## Active Module Map
Use this as the fast path before opening upstream module READMEs.
### `mod-autobalance`
- Purpose:
- scales dungeon and raid mobs for solo, duo, and undersized groups
- Source config:
- `modules/mod-autobalance/conf/AutoBalance.conf.dist`
- Effective generated config:
- `env/dist/etc/modules/AutoBalance.conf.dist`
- Managed by:
- `setup-modules.sh`
- Current repo intent:
- solo-friendly PvE tuning
- `MinPlayers = 1` for dungeons and raids
- softened raid boss health and damage for incomplete groups
- Useful diagnostics:
- `.ab mapstat`
- `.ab creaturestat`
### `mod-individual-progression`
- Purpose:
- per-character expansion and tier progression on a WotLK client
- Source config:
- `modules/mod-individual-progression/conf/individualProgression.conf.dist`
- Effective generated config:
- `env/dist/etc/modules/individualProgression.conf.dist`
- Managed by:
- `setup-modules.sh`
- Hard prerequisites already encoded in this repo:
- `EnablePlayerSettings = 1`
- `DBC.EnforceItemAttributes = 0`
- Important behavior:
- touches visibility, quest flow, content gating, item stats, restored legacy content, and progression unlocks
- Caveat:
- optional SQL under this module is not automatically imported unless explicitly wired in
### `mod-playerbots`
- Purpose:
- addclass bots, alt bots, and optional random world bots
- Source config:
- `modules/mod-playerbots/conf/playerbots.conf.dist`
- Effective generated config:
- `env/dist/etc/modules/playerbots.conf.dist`
- Managed by:
- `setup-modules.sh`
- Separate database:
- `acore_playerbots`
- Repo-specific caveat:
- upstream documents a custom `Playerbot` AzerothCore branch, but this repo carries local compatibility work on top of the current tree
- treat changes here as high risk
- Current repo intent:
- playerbots available for solo/small-group support
- random bot population disabled by default
- small addclass pool
- Fragile points:
- Docker environment overrides can silently beat file config
- avoid reintroducing large random bot defaults
- if startup crashes occur around bot creation, inspect effective `playerbots.conf.dist` first
### `mod-eluna`
- Purpose:
- Lua scripting engine used by local custom packages in `lua_scripts/`
- Source config:
- `modules/mod-eluna/conf/mod_eluna.conf.dist`
- Effective generated config:
- `env/dist/etc/modules/mod_eluna.conf.dist`
- Managed by:
- `setup-modules.sh`
- Important note:
- this repo uses AzerothCore `mod-eluna`, not original Eluna compatibility mode
- do not assume third-party Eluna scripts are portable
### `mod-aoe-loot`
- Purpose:
- area looting QoL
- Source config:
- `modules/mod-aoe-loot/conf/mod_aoe_loot.conf.dist`
- Effective generated config:
- `env/dist/etc/modules/mod_aoe_loot.conf.dist`
- Managed by:
- `setup-modules.sh`
- Extra SQL:
- module strings are imported by `import-custom-sql.sh`
- Caveat:
- if `module_string` rows are missing, world logs show `Module string module mod-aoe-loot id 1 not found in DB`
### `mod-quest-loot-party`
- Purpose:
- grouped quest-loot quality of life
- Source config:
- `modules/mod-quest-loot-party/conf/mod-quest-loot-party.conf.dist`
- Effective generated config:
- `env/dist/etc/modules/mod-quest-loot-party.conf.dist`
- Managed by:
- `setup-modules.sh`
### `mod-learnspells`
- Purpose:
- auto-grants class spells, talents, proficiencies, and optional riding/quest spells
- Source config:
- `modules/mod-learnspells/conf/mod_learnspells.conf.dist`
- Effective generated config:
- `env/dist/etc/modules/mod_learnspells.conf.dist`
- Managed by:
- `setup-modules.sh`
- Current repo intent:
- class/proficiency/quest spell automation enabled
- riding automation deliberately conservative in the local `.env`
## Local Lua Package Map
These packages are part of the active customization layer and are more important than generic upstream documentation.
### `lua_scripts/AIO_Server`
- Purpose:
- shared AIO transport/UI layer for Lua client-server addons
- Consumers in this repo:
- `MythicPlus`
- `Store_System`
- If AIO breaks, those packages effectively break too
### `lua_scripts/MythicPlus`
- Purpose:
- Mythic+ gameplay, scoring, keys, affixes, vault, AIO UI
- Data dependencies:
- character tables:
- `character_mythic_history`
- `character_mythic_keys`
- `character_mythic_rating`
- `character_mythic_vault`
- `character_mythic_weekly_affixes`
- world tables:
- `world_mythic_loot`
- `world_vault_loot`
- Bootstrap:
- handled by `import-custom-sql.sh`
- Extra world bootstrap:
- repairs creature/item/gameobject entries around `900001`, `900100`, `900000`
- Caveat:
- missing bootstrap often shows up as `Couldn't find a creature with (ID: 900001)!`
- empty `world_vault_loot` is non-fatal but means no vault rewards
### `lua_scripts/Store_System`
- Purpose:
- AIO-based in-game store UI and purchase handling
- Data dependencies:
- separate MySQL schema `store`
- Bootstrap:
- handled by `import-custom-sql.sh`
- imports `sql/Store.sql`
- Caveat:
- if `store` database is missing, Lua throws SQL errors and the store becomes unusable
### `lua_scripts/StartZoneRaceTeleporters`
- Purpose:
- race-specific start-zone teleporter NPCs
- Files:
- Lua logic:
- `lua_scripts/StartZoneRaceTeleporters/start_zone_race_teleporters.lua`
- base SQL:
- `lua_scripts/StartZoneRaceTeleporters/sql/world/start_zone_race_teleporters.sql`
- Bootstrap:
- imported automatically by `setup-modules.sh` when `ac-database` is already running
- Caveat:
- base SQL does not create all world spawns automatically
- example/manual spawn SQL still needs to be added per start zone
### `lua_scripts/Battlepass`
- Current state:
- present in tree but effectively empty
- Guidance:
- do not assume it is an active, wired gameplay system until code and bootstrap are actually added
### `lua_scripts/hello_world.lua`
- Current state:
- simple local script/example
- Guidance:
- low-risk when testing Eluna loading, not a core gameplay system
## Operational Feature Map
This repo is not just “AzerothCore plus random modules”. The intended gameplay stack is:
- solo/small-group PvE:
- `mod-autobalance`
- softened elite HP/damage in `worldserver.conf`
- progression playthrough:
- `mod-individual-progression`
- optional bot-assisted party filling:
- `mod-playerbots`
- QoL:
- `mod-aoe-loot`
- `mod-quest-loot-party`
- `mod-learnspells`
- custom Lua systems:
- `MythicPlus`
- `Store_System`
- `StartZoneRaceTeleporters`
When making a change, identify which layer you are actually modifying:
- core C++ behavior
- module config generation
- custom SQL bootstrap
- Eluna Lua logic
- Docker/deployment behavior
## Custom SQL Rules
- Normal AzerothCore/module SQL is handled by `ac-db-import`.
- This repo also has custom SQL outside standard module paths. That logic lives in `import-custom-sql.sh`.
- At the time of writing, this includes bootstrap/import logic for:
- MythicPlus SQL under `sql/Mythic+/`
- Store SQL under `sql/Store.sql`
- `mod-aoe-loot` module strings
- If you add a new Lua package or custom SQL tree outside the standard module import layout, extend `import-custom-sql.sh`.
## Local Lua And Custom Content
- `lua_scripts/` contains active server-side custom Lua packages.
- Some Lua features depend on SQL bootstrap outside the normal module importer.
- If you add or rename a Lua package that requires DB state, update both:
- the file tree
- `import-custom-sql.sh`
## Build And Edit Guidance
- Do not rebuild the whole core unless the change actually requires it.
- For shell-script changes, validate with:
- `bash -n <script>`
- For compose changes, validate with:
- `docker compose config`
- For config-generation changes, validate by running:
- `./setup-modules.sh --dry-run`
- then `./setup-modules.sh`
- If you change realm-related settings, run:
- `./sync-realmlist.sh`
- If you change custom SQL bootstrap, run:
- `./import-custom-sql.sh`
- with `ac-database` already running
## C++ And Core Notes
- This is still a normal AzerothCore source tree under `src/`.
- `authserver` and `worldserver` are the two runtime executables.
- `modules/` is vendored into this repo. Do not treat it as git submodules.
- Keep `src/server/scripts/Custom/custom_script_loader.cpp` present and tracked; removing it breaks linking of `AddCustomScripts()`.
## What To Change For Common Tasks
### Change deployment defaults
- Edit `.env.example`
- If the repo default should also change for the current working copy, edit `.env`
- Update any helper script that derives behavior from those variables
### Change module behavior
- Edit `setup-modules.sh`
- Regenerate configs with `./setup-modules.sh`
### Change auth/world exposure or realm address
- Edit `.env`
- Run `./sync-realmlist.sh`
### Change account bootstrap behavior
- Edit `create-account.sh`
- Keep SRP/account logic self-contained there
### Change first-run database bootstrap
- Edit `import-custom-sql.sh`
- Keep imports idempotent
## Practical Safety Notes
- Prefer additive, idempotent changes in shell scripts.
- Preserve current local server assumptions unless the task explicitly changes them:
- solo/small-group friendly tuning
- generated config flow
- Docker-first operation
- If a fix only works by editing generated files under `env/dist`, it is incomplete.
+29
View File
@@ -0,0 +1,29 @@
# Production environment — copy to .env before deploying
# cp .env.production .env
# Docker image tag (master = latest stable build)
DOCKER_IMAGE_TAG=master
# Database root password — CHANGE THIS
DOCKER_DB_ROOT_PASSWORD=CHANGE_ME_STRONG_PASSWORD
# External ports
DOCKER_WORLD_EXTERNAL_PORT=8085
DOCKER_AUTH_EXTERNAL_PORT=3724
DOCKER_SOAP_EXTERNAL_PORT=7878
DOCKER_DB_EXTERNAL_PORT=3306
# Run server as this user (match host UID/GID to avoid volume permission issues)
# Run: id -u && id -g to get your values
DOCKER_USER=acore
DOCKER_USER_ID=1000
DOCKER_GROUP_ID=1000
# Volume paths (use absolute paths in production)
DOCKER_VOL_ETC=./env/dist/etc
DOCKER_VOL_LOGS=./env/dist/logs
DOCKER_VOL_DATA=ac-client-data
# Game rates (can also be set in worldserver.conf)
AC_RATE_XP_KILL=1
ACORE_PLAYERBOTS_RANDOM_BOT_AUTOLOGIN=0
@@ -0,0 +1,61 @@
--
-- 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)';
+43
View File
@@ -0,0 +1,43 @@
services:
ac-database:
command: >
--innodb_buffer_pool_size=4G
--innodb_redo_log_capacity=1G
--innodb_flush_log_at_trx_commit=2
--innodb_flush_method=O_DIRECT
--max_connections=300
--slow_query_log=1
--long_query_time=2
deploy:
resources:
limits:
cpus: '2'
memory: 5G
ac-worldserver:
deploy:
resources:
limits:
cpus: '6'
memory: 6G
ulimits:
nofile:
soft: 65536
hard: 65536
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
ac-authserver:
deploy:
resources:
limits:
cpus: '1'
memory: 512M
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
+23
View File
@@ -234,6 +234,28 @@ 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() {
# Source-of-truth tables for the Taxi Route Builder. The migration also
# lives in data/sql/updates/pending_db_world/ for AzerothCore DBUpdater,
# 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
log "taxi-builder migration not found, skipped"
return 0
fi
if table_exists "acore_world" "custom_taxi_nodes" \
&& table_exists "acore_world" "custom_taxi_paths" \
&& table_exists "acore_world" "custom_taxi_path_nodes"; then
log "taxi-builder tables already exist, skipping"
return 0
fi
import_sql_file "acore_world" "$taxi_builder_sql_file"
}
if (($# > 0)); then if (($# > 0)); then
case "$1" in case "$1" in
-h|--help) -h|--help)
@@ -260,6 +282,7 @@ fi
import_mythicplus_sql import_mythicplus_sql
import_aoe_loot_sql import_aoe_loot_sql
import_taxi_builder_sql
import_store_sql import_store_sql
log "custom SQL bootstrap completed" log "custom SQL bootstrap completed"
@@ -0,0 +1,72 @@
#
# Progressive Quest XP module configuration
#
########################################
# DynamicQuestXP settings
########################################
#
# DynamicQuestXP.Enable
# Description: Enable progressive quest XP multiplier
# Default: 1 - (Enabled)
# 0 - (Disabled)
#
DynamicQuestXP.Enable = 1
#
# DynamicQuestXP.Level_1_20
# Description: XP multiplier for quest rewards at levels 1-20
# Default: 1.5
#
DynamicQuestXP.Level_1_20 = 1.5
#
# DynamicQuestXP.Level_21_30
# Description: XP multiplier for quest rewards at levels 21-30
# Default: 1.3
#
DynamicQuestXP.Level_21_30 = 1.3
#
# DynamicQuestXP.Level_31_40
# Description: XP multiplier for quest rewards at levels 31-40
# Default: 1.1
#
DynamicQuestXP.Level_31_40 = 1.1
#
# DynamicQuestXP.Level_41_50
# Description: XP multiplier for quest rewards at levels 41-50
# Default: 1.0
#
DynamicQuestXP.Level_41_50 = 1.0
#
# DynamicQuestXP.Level_51_60
# Description: XP multiplier for quest rewards at levels 51-60
# Default: 1.0
#
DynamicQuestXP.Level_51_60 = 1.0
#
# DynamicQuestXP.Level_61_70
# Description: XP multiplier for quest rewards at levels 61-70
# Default: 1.0
#
DynamicQuestXP.Level_61_70 = 1.0
#
# DynamicQuestXP.Level_70_plus
# Description: XP multiplier for quest rewards at levels 70-80
# Default: 1.0
#
DynamicQuestXP.Level_70_plus = 1.0
@@ -0,0 +1 @@
@@ -0,0 +1,59 @@
/*
* Progressive Quest XP Module for AzerothCore
*
* Applies a level-based XP multiplier to quest rewards only.
* Does not affect kill XP, exploration XP, or PvP XP.
*/
#include "Config.h"
#include "Player.h"
#include "ScriptMgr.h"
class ProgressiveQuestXPPlayer : public PlayerScript
{
public:
ProgressiveQuestXPPlayer() : PlayerScript("ProgressiveQuestXPPlayer",
{PLAYERHOOK_ON_QUEST_COMPUTE_EXP}) { }
void OnPlayerQuestComputeXP(Player* player, Quest const* /*quest*/,
uint32& xpValue) override
{
if (!sConfigMgr->GetOption<bool>("DynamicQuestXP.Enable", true))
return;
if (!xpValue)
return;
uint8 level = player->GetLevel();
float multiplier;
if (level <= 20)
multiplier = sConfigMgr->GetOption<float>(
"DynamicQuestXP.Level_1_20", 1.5f);
else if (level <= 30)
multiplier = sConfigMgr->GetOption<float>(
"DynamicQuestXP.Level_21_30", 1.3f);
else if (level <= 40)
multiplier = sConfigMgr->GetOption<float>(
"DynamicQuestXP.Level_31_40", 1.1f);
else if (level <= 50)
multiplier = sConfigMgr->GetOption<float>(
"DynamicQuestXP.Level_41_50", 1.0f);
else if (level <= 60)
multiplier = sConfigMgr->GetOption<float>(
"DynamicQuestXP.Level_51_60", 1.0f);
else if (level <= 70)
multiplier = sConfigMgr->GetOption<float>(
"DynamicQuestXP.Level_61_70", 1.0f);
else
multiplier = sConfigMgr->GetOption<float>(
"DynamicQuestXP.Level_70_plus", 1.0f);
xpValue = static_cast<uint32>(xpValue * multiplier);
}
};
void AddSC_ProgressiveQuestXP()
{
new ProgressiveQuestXPPlayer();
}
@@ -0,0 +1,6 @@
void AddSC_ProgressiveQuestXP();
void Addmod_progressive_quest_xpScripts()
{
AddSC_ProgressiveQuestXP();
}
+4
View File
@@ -0,0 +1,4 @@
SPDX-License-Identifier: AGPL-3.0-or-later
This module is licensed under the GNU Affero General Public License v3 or
later. See https://www.gnu.org/licenses/agpl-3.0.html for the full text.
+753
View File
@@ -0,0 +1,753 @@
# 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` обоих комплектов.
@@ -0,0 +1 @@
#!/usr/bin/env bash
+8
View File
@@ -0,0 +1,8 @@
#!/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
@@ -0,0 +1,163 @@
/*
* 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();
}
@@ -0,0 +1,16 @@
/*
* 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();
}
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env}"
OUTPUT_FILE=""
COMPRESS=1
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
usage() {
cat <<EOF
Usage: $(basename "$0") [--output FILE] [--no-compress]
Exports all AzerothCore databases from the running ac-database container
into a single dump file suitable for migration to another server.
Options:
--output FILE Output file path
(default: ./backups/YYYY-MM-DD_HH-MM-SS.sql.gz)
--no-compress Write plain .sql instead of .sql.gz
-h, --help Show this help
Examples:
$(basename "$0")
$(basename "$0") --output /mnt/backup/moonwell.sql.gz
$(basename "$0") --no-compress --output /tmp/dump.sql
EOF
}
log() { printf '[db-export] %s\n' "$*"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
require_running_db() {
if ! docker compose ps --status running --services 2>/dev/null | grep -qx 'ac-database'; then
die "ac-database is not running. Start it with: docker compose up -d ac-database"
fi
}
# Returns space-separated list of user databases (excludes MySQL system schemas)
get_user_databases() {
docker compose exec -T ac-database bash -lc \
"mysql -h 127.0.0.1 -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -s information_schema" \
<<<"SELECT schema_name FROM schemata
WHERE schema_name NOT IN ('mysql','information_schema','performance_schema','sys')
ORDER BY schema_name;"
}
# ── argument parsing ──────────────────────────────────────────────────────────
while (($#)); do
case "$1" in
--output) OUTPUT_FILE="$2"; shift ;;
--no-compress) COMPRESS=0 ;;
-h|--help) usage; exit 0 ;;
*) printf 'Unknown option: %s\n\n' "$1" >&2; usage >&2; exit 1 ;;
esac
shift
done
# ── main ──────────────────────────────────────────────────────────────────────
cd "$ROOT_DIR"
require_running_db
if [[ -z "$OUTPUT_FILE" ]]; then
mkdir -p "$ROOT_DIR/backups"
timestamp="$(date '+%Y-%m-%d_%H-%M-%S')"
if [[ "$COMPRESS" -eq 1 ]]; then
OUTPUT_FILE="$ROOT_DIR/backups/${timestamp}.sql.gz"
else
OUTPUT_FILE="$ROOT_DIR/backups/${timestamp}.sql"
fi
fi
mkdir -p "$(dirname "$OUTPUT_FILE")"
log "discovering user databases..."
databases="$(get_user_databases | tr '\n' ' ' | xargs)"
[[ -z "$databases" ]] && die "no user databases found"
log "databases: $databases"
log "exporting → $OUTPUT_FILE"
log "this may take a few minutes for large worlds..."
if [[ "$COMPRESS" -eq 1 ]]; then
docker compose exec -T ac-database bash -lc \
"mysqldump -h 127.0.0.1 -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \
--databases $databases \
--add-drop-database \
--single-transaction \
--quick \
--routines \
--triggers \
--events" \
| gzip -6 > "$OUTPUT_FILE"
else
docker compose exec -T ac-database bash -lc \
"mysqldump -h 127.0.0.1 -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \
--databases $databases \
--add-drop-database \
--single-transaction \
--quick \
--routines \
--triggers \
--events" \
> "$OUTPUT_FILE"
fi
log "done: $OUTPUT_FILE ($(du -sh "$OUTPUT_FILE" | cut -f1))"
log ""
log "to restore on the new server:"
if [[ "$OUTPUT_FILE" == *.gz ]]; then
log " bash scripts/db-import.sh --from $OUTPUT_FILE"
else
log " bash scripts/db-import.sh --from $OUTPUT_FILE"
fi
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env}"
DUMP_FILE=""
DRY_RUN=0
FORCE=0
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
usage() {
cat <<EOF
Usage: $(basename "$0") --from FILE [--dry-run] [--force]
Restores a full database dump (produced by db-export.sh) into the
ac-database Docker container. The container must be running.
Options:
--from FILE Dump file to restore (.sql or .sql.gz) (required)
--dry-run Show what would happen without executing
--force Skip the confirmation prompt
-h, --help Show this help
Examples:
$(basename "$0") --from backups/2025-01-01_12-00-00.sql.gz
$(basename "$0") --from /mnt/backup/dump.sql.gz --force
EOF
}
log() { printf '[db-import] %s\n' "$*"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
require_ready_db() {
if ! docker compose ps --status running --services 2>/dev/null | grep -qx 'ac-database'; then
die "ac-database is not running. Start it with: docker compose up -d ac-database"
fi
log "waiting for MySQL to be ready..."
local attempts=0
until docker compose exec -T ac-database bash -lc \
"mysql -h 127.0.0.1 -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -e 'SELECT 1;'" \
>/dev/null 2>&1; do
attempts=$((attempts + 1))
if [[ $attempts -ge 30 ]]; then
die "MySQL did not become ready in time. Check: docker logs ac-database"
fi
sleep 2
done
log "MySQL is ready"
}
mysql_exec() {
docker compose exec -T ac-database bash -lc \
"mysql -h 127.0.0.1 -uroot -p\"\$MYSQL_ROOT_PASSWORD\""
}
# ── argument parsing ──────────────────────────────────────────────────────────
while (($#)); do
case "$1" in
--from) DUMP_FILE="$2"; shift ;;
--dry-run) DRY_RUN=1 ;;
--force) FORCE=1 ;;
-h|--help) usage; exit 0 ;;
*) printf 'Unknown option: %s\n\n' "$1" >&2; usage >&2; exit 1 ;;
esac
shift
done
# ── validation ────────────────────────────────────────────────────────────────
[[ -z "$DUMP_FILE" ]] && { usage >&2; die "--from FILE is required"; }
[[ -f "$DUMP_FILE" ]] || die "file not found: $DUMP_FILE"
cd "$ROOT_DIR"
# ── preview ───────────────────────────────────────────────────────────────────
file_size="$(du -sh "$DUMP_FILE" | cut -f1)"
printf '\nMigration plan:\n'
printf ' Source : %s (%s)\n' "$DUMP_FILE" "$file_size"
printf ' Target : ac-database container\n\n'
if [[ "$DRY_RUN" -eq 1 ]]; then
log "dry-run mode — nothing imported"
exit 0
fi
if [[ "$FORCE" -eq 0 ]]; then
printf 'WARNING: this will DROP and recreate all databases in the dump.\n'
read -r -p "Continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { log "aborted"; exit 0; }
fi
# ── import ────────────────────────────────────────────────────────────────────
require_ready_db
log "restoring $DUMP_FILE into ac-database..."
log "this may take several minutes..."
if [[ "$DUMP_FILE" == *.gz ]]; then
zcat "$DUMP_FILE" | mysql_exec
else
mysql_exec < "$DUMP_FILE"
fi
log "import complete"
log ""
log "next steps:"
log " 1. Update realmlist: bash sync-realmlist.sh"
log " 2. Start the server: bash scripts/prod-deploy.sh --no-build"
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env}"
BUILD_FLAG="--build"
# Production compose stack — layered on top of the standard override
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.prod.yml"
# ── helpers ──────────────────────────────────────────────────────────────────
log() { printf '[prod-deploy] %s\n' "$*"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
print_service_logs() {
local service="$1"
printf '\n[%s logs]\n' "$service" >&2
$COMPOSE logs --tail=120 "$service" >&2 || true
printf '\n' >&2
}
wait_for_healthy() {
local container_name="$1"
local timeout_seconds="${2:-180}"
local start_ts now status
start_ts="$(date +%s)"
while :; do
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)"
case "$status" in
healthy|running) return 0 ;;
unhealthy|exited|dead) return 1 ;;
esac
now="$(date +%s)"
if (( now - start_ts >= timeout_seconds )); then return 1; fi
sleep 2
done
}
run_one_shot_service() {
local service="$1"
local container_name="$2"
if [[ -n "$BUILD_FLAG" ]]; then
if ! $COMPOSE up --build --no-deps "$service"; then
print_service_logs "$service"
printf '%s failed during startup\n' "$service" >&2
exit 1
fi
else
if ! $COMPOSE up --no-deps "$service"; then
print_service_logs "$service"
printf '%s failed during startup\n' "$service" >&2
exit 1
fi
fi
local exit_code
exit_code="$(docker inspect -f '{{.State.ExitCode}}' "$container_name" 2>/dev/null || printf '1')"
if [[ "$exit_code" != "0" ]]; then
print_service_logs "$service"
printf '%s failed with exit code %s\n' "$service" "$exit_code" >&2
exit 1
fi
}
require_docker() {
if ! command -v docker >/dev/null 2>&1; then
die "docker is not installed or not in PATH"
fi
if ! docker compose version >/dev/null 2>&1; then
die "docker compose plugin is not available. On Ubuntu: apt install docker-compose-plugin"
fi
if ! docker info >/dev/null 2>&1; then
die "docker daemon is not reachable. Run: sudo systemctl enable --now docker"
fi
}
ensure_host_permissions() {
local target_uid="${DOCKER_USER_ID:-1000}"
local target_gid="${DOCKER_GROUP_ID:-1000}"
local dirs=(
"$ROOT_DIR/env/dist/etc"
"$ROOT_DIR/env/dist/logs"
"$ROOT_DIR/env/dist/temp"
)
mkdir -p "${dirs[@]}"
if [[ "$(id -u)" -eq 0 ]]; then
chown -R "${target_uid}:${target_gid}" "${dirs[@]}"
return 0
fi
local dir
for dir in "${dirs[@]}"; do
if [[ ! -w "$dir" ]]; then
printf 'host directory is not writable: %s\n' "$dir" >&2
printf 'Run: sudo chown -R %s:%s %q %q %q\n' \
"$target_uid" "$target_gid" \
"${dirs[0]}" "${dirs[1]}" "${dirs[2]}" >&2
exit 1
fi
done
}
apply_sysctl() {
log "applying sysctl settings..."
sysctl -w net.core.somaxconn=4096 2>/dev/null || true
sysctl -w net.ipv4.tcp_max_syn_backlog=4096 2>/dev/null || true
sysctl -w fs.file-max=100000 2>/dev/null || true
# Prevent Docker conntrack table overflow (causes sudden disconnects)
sysctl -w net.netfilter.nf_conntrack_max=131072 2>/dev/null || true
sysctl -w net.ipv4.tcp_keepalive_time=120 2>/dev/null || true
sysctl -w net.ipv4.tcp_keepalive_intvl=30 2>/dev/null || true
sysctl -w net.ipv4.tcp_keepalive_probes=5 2>/dev/null || true
}
# ── argument parsing ──────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $(basename "$0") [--no-build]
Starts the AzerothCore production docker stack.
Options:
--no-build Start containers without rebuilding images
-h, --help Show this help
EOF
}
while (($#)); do
case "$1" in
--no-build) BUILD_FLAG="" ;;
-h|--help) usage; exit 0 ;;
*) printf 'Unknown option: %s\n\n' "$1" >&2; usage >&2; exit 1 ;;
esac
shift
done
# ── pre-flight ────────────────────────────────────────────────────────────────
if [[ ! -f "$ENV_FILE" ]]; then
if [[ -f "$ROOT_DIR/.env.production" ]]; then
log ".env not found — copying from .env.production"
cp "$ROOT_DIR/.env.production" "$ENV_FILE"
die "Fill in $ENV_FILE (at minimum DOCKER_DB_ROOT_PASSWORD) and re-run."
else
die "$ENV_FILE not found. Create it based on .env.production."
fi
fi
if grep -q "CHANGE_ME" "$ENV_FILE"; then
die "Change DOCKER_DB_ROOT_PASSWORD in $ENV_FILE before deploying to production."
fi
# Source env so ensure_host_permissions picks up DOCKER_USER_ID / DOCKER_GROUP_ID
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
cd "$ROOT_DIR"
require_docker
if [[ $EUID -eq 0 ]]; then
apply_sysctl
else
log "not root — skipping sysctl (run with sudo to apply kernel settings)"
fi
# ── startup sequence (mirrors start-server.sh) ───────────────────────────────
log "applying module configuration"
bash "$ROOT_DIR/setup-modules.sh"
log "ensuring host directory permissions"
ensure_host_permissions
log "starting database"
if [[ -n "$BUILD_FLAG" ]]; then
$COMPOSE up -d --build ac-database
else
$COMPOSE up -d ac-database
fi
if ! wait_for_healthy "ac-database" 180; then
print_service_logs "ac-database"
die "ac-database did not become healthy"
fi
log "running database import"
run_one_shot_service "ac-db-import" "ac-db-import"
log "importing custom SQL"
bash "$ROOT_DIR/import-custom-sql.sh"
log "applying post-database module setup"
bash "$ROOT_DIR/setup-modules.sh"
log "initializing client data"
run_one_shot_service "ac-client-data-init" "ac-client-data-init"
log "starting authserver and worldserver"
if [[ -n "$BUILD_FLAG" ]]; then
$COMPOSE up -d --build ac-authserver ac-worldserver
else
$COMPOSE up -d ac-authserver ac-worldserver
fi
log "synchronizing realmlist"
bash "$ROOT_DIR/sync-realmlist.sh"
log "current service status"
$COMPOSE ps
printf '\n'
printf 'Realm: %s:%s\n' "${ACORE_REALMLIST_PUBLIC_ADDRESS:-127.0.0.1}" "${ACORE_REALMLIST_PORT:-${DOCKER_WORLD_EXTERNAL_PORT:-8085}}"
printf 'World: 127.0.0.1:%s\n' "${DOCKER_WORLD_EXTERNAL_PORT:-8085}"
printf 'Auth: 127.0.0.1:%s\n' "${DOCKER_AUTH_EXTERNAL_PORT:-3724}"
printf 'SOAP: 127.0.0.1:%s\n' "${DOCKER_SOAP_EXTERNAL_PORT:-7878}"
printf 'Account: ./create-account.sh admin your_password\n'
printf 'Logs: %s logs -f ac-worldserver ac-authserver\n' "$COMPOSE"
+27
View File
@@ -65,6 +65,15 @@ FORCE_PLAYERBOTS="${FORCE_PLAYERBOTS:-${ACORE_PLAYERBOTS_FORCE:-0}}"
: "${ACORE_PROGRESSION_MC_AQUAL_COOLDOWN_REDUCTION:=60}" : "${ACORE_PROGRESSION_MC_AQUAL_COOLDOWN_REDUCTION:=60}"
: "${ACORE_PROGRESSION_PVP_GEAR_REQUIREMENTS:=0}" : "${ACORE_PROGRESSION_PVP_GEAR_REQUIREMENTS:=0}"
: "${ACORE_DYNAMIC_QUEST_XP_ENABLE:=1}"
: "${ACORE_DYNAMIC_QUEST_XP_LEVEL_1_20:=1.5}"
: "${ACORE_DYNAMIC_QUEST_XP_LEVEL_21_30:=1.3}"
: "${ACORE_DYNAMIC_QUEST_XP_LEVEL_31_40:=1.1}"
: "${ACORE_DYNAMIC_QUEST_XP_LEVEL_41_50:=1.0}"
: "${ACORE_DYNAMIC_QUEST_XP_LEVEL_51_60:=1.0}"
: "${ACORE_DYNAMIC_QUEST_XP_LEVEL_61_70:=1.0}"
: "${ACORE_DYNAMIC_QUEST_XP_LEVEL_70_PLUS:=1.0}"
: "${ACORE_AOE_LOOT_ENABLE:=1}" : "${ACORE_AOE_LOOT_ENABLE:=1}"
: "${ACORE_AOE_LOOT_MESSAGE:=0}" : "${ACORE_AOE_LOOT_MESSAGE:=0}"
: "${ACORE_AOE_LOOT_RANGE:=55.0}" : "${ACORE_AOE_LOOT_RANGE:=55.0}"
@@ -316,6 +325,8 @@ ensure_world_conf() {
} }
sync_module_dists() { sync_module_dists() {
sync_file "$ROOT_DIR/modules/mod-progressive-quest-xp/conf/mod_progressive_quest_xp.conf.dist" \
"$MODULES_ETC_DIR/mod_progressive_quest_xp.conf.dist"
sync_file "$ROOT_DIR/modules/mod-aoe-loot/conf/mod_aoe_loot.conf.dist" \ sync_file "$ROOT_DIR/modules/mod-aoe-loot/conf/mod_aoe_loot.conf.dist" \
"$MODULES_ETC_DIR/mod_aoe_loot.conf.dist" "$MODULES_ETC_DIR/mod_aoe_loot.conf.dist"
sync_file "$ROOT_DIR/modules/mod-autobalance/conf/AutoBalance.conf.dist" \ sync_file "$ROOT_DIR/modules/mod-autobalance/conf/AutoBalance.conf.dist" \
@@ -393,6 +404,20 @@ IndividualProgression.PvPGearRequirements = ${ACORE_PROGRESSION_PVP_GEAR_REQUIRE
EOF EOF
} }
write_progressive_quest_xp_conf() {
write_file "$MODULES_ETC_DIR/mod_progressive_quest_xp.conf.dist" <<EOF
# Managed by ./setup-modules.sh
DynamicQuestXP.Enable = ${ACORE_DYNAMIC_QUEST_XP_ENABLE}
DynamicQuestXP.Level_1_20 = ${ACORE_DYNAMIC_QUEST_XP_LEVEL_1_20}
DynamicQuestXP.Level_21_30 = ${ACORE_DYNAMIC_QUEST_XP_LEVEL_21_30}
DynamicQuestXP.Level_31_40 = ${ACORE_DYNAMIC_QUEST_XP_LEVEL_31_40}
DynamicQuestXP.Level_41_50 = ${ACORE_DYNAMIC_QUEST_XP_LEVEL_41_50}
DynamicQuestXP.Level_51_60 = ${ACORE_DYNAMIC_QUEST_XP_LEVEL_51_60}
DynamicQuestXP.Level_61_70 = ${ACORE_DYNAMIC_QUEST_XP_LEVEL_61_70}
DynamicQuestXP.Level_70_plus = ${ACORE_DYNAMIC_QUEST_XP_LEVEL_70_PLUS}
EOF
}
write_aoe_loot_conf() { write_aoe_loot_conf() {
write_file "$MODULES_ETC_DIR/mod_aoe_loot.conf.dist" <<EOF write_file "$MODULES_ETC_DIR/mod_aoe_loot.conf.dist" <<EOF
# Managed by ./setup-modules.sh # Managed by ./setup-modules.sh
@@ -538,6 +563,7 @@ ensure_runtime_dirs() {
cleanup_legacy_module_overrides() { cleanup_legacy_module_overrides() {
local legacy_files=( local legacy_files=(
"$MODULES_ETC_DIR/mod_progressive_quest_xp.conf"
"$MODULES_ETC_DIR/AutoBalance.conf" "$MODULES_ETC_DIR/AutoBalance.conf"
"$MODULES_ETC_DIR/individualProgression.conf" "$MODULES_ETC_DIR/individualProgression.conf"
"$MODULES_ETC_DIR/mod_aoe_loot.conf" "$MODULES_ETC_DIR/mod_aoe_loot.conf"
@@ -595,6 +621,7 @@ main() {
configure_worldserver configure_worldserver
write_autobalance_conf write_autobalance_conf
write_individual_progression_conf write_individual_progression_conf
write_progressive_quest_xp_conf
write_aoe_loot_conf write_aoe_loot_conf
write_quest_loot_party_conf write_quest_loot_party_conf
write_eluna_conf write_eluna_conf
+1
View File
@@ -0,0 +1 @@
./__pycache__
+1
View File
@@ -0,0 +1 @@
python3
+1
View File
@@ -0,0 +1 @@
/usr/bin/python3
+1
View File
@@ -0,0 +1 @@
python3
+1
View File
@@ -0,0 +1 @@
lib
+5
View File
@@ -0,0 +1,5 @@
home = /usr/bin
include-system-site-packages = false
version = 3.12.3
executable = /usr/bin/python3.12
command = /usr/bin/python3 -m venv /home/sindo/moonwell-core/tools/moonwell-taxi/.venv
+87
View File
@@ -0,0 +1,87 @@
# moonwell-taxi
External CLI that reads custom flight data from `acore_world` and the
three vanilla taxi DBCs, then generates merged DBCs ready to drop into
both the server's `dbc/` directory and the client patch.
## Quick start
```bash
# 1. install deps (PyMySQL only)
pip install -r requirements.txt
# 2. point at your vanilla DBC source and run the export
./bin/moonwell-taxi export-dbc \
--vanilla-dir /home/sindo/moonwell-core \
--output-dir /home/sindo/moonwell-core/build/taxi/export
# Output:
# <output-dir>/server/dbc/TaxiNodes.dbc
# <output-dir>/server/dbc/TaxiPath.dbc
# <output-dir>/server/dbc/TaxiPathNode.dbc
# <output-dir>/client/DBFilesClient/TaxiNodes.dbc
# <output-dir>/client/DBFilesClient/TaxiPath.dbc
# <output-dir>/client/DBFilesClient/TaxiPathNode.dbc
#
# A sha256 manifest is written next to each half; the tool fails with a
# non-zero exit if server and client copies do not match.
# 3. pack the client half into an MPQ patch for native taxi UI
./bin/moonwell-taxi build-client-patch
# Output:
# <output-dir>/client/patch-Z.MPQ
#
# Copy that MPQ to the WoW 3.3.5a client's Data directory, delete the
# client's Cache directory, then restart Wow.exe. Loose DBFilesClient/*.dbc
# files are not enough for the stock 3.3.5a client taxi UI.
```
## DB connection
Reads connection params from environment variables (which `acore.sh` and
`import-custom-sql.sh` already populate from `.env`):
- `DOCKER_DB_EXTERNAL_PORT` (default 3306)
- `DOCKER_DB_ROOT_PASSWORD` (default "password")
- `MOONWELL_TAXI_DB_HOST` (default 127.0.0.1)
- `MOONWELL_TAXI_DB_USER` (default root)
- `MOONWELL_TAXI_DB_NAME` (default acore_world)
CLI flags override env: `--db-host`, `--db-port`, `--db-user`,
`--db-password`, `--db-name`.
## Subcommands
- `export-dbc` — fully implemented: merges vanilla + custom into new DBCs,
writes server and client copies, prints sha256 for both. Refuses to run
if `--vanilla-dir` already contains records in the custom-id space
(TaxiNode id ≥ 441, TaxiPath id ≥ 1979) — that means the directory
was already merged once and re-running would compound rows.
- `build-client-patch` — packs the exported client DBCs into a minimal MPQ
patch with internal paths `DBFilesClient\TaxiNodes.dbc`,
`DBFilesClient\TaxiPath.dbc`, and `DBFilesClient\TaxiPathNode.dbc`.
This is required for the native flight-master map UI to see custom
taxi nodes; otherwise the client can crash when the server sends a
custom current-node id.
- `thin --path <id> [--min-yd 50] [--dry-run]` — drops waypoints that sit
within `min-yd` (XY) of the previously kept point, preserves first and
last, renumbers `node_index` from 0. Use it when an in-game recording
produced too-dense waypoints (chat shows lots of
`segment N->M is X.X yd (< 40 yd ...)` warnings) — server filters those
via `IsNodeIncludedInShortenedPath` and the spline goes haywire.
Backs up to `custom_taxi_path_nodes_backup_<pathId>_<ts>` before
deleting; the rollback SQL is printed at the end.
- `dump-vanilla <path> [--limit N]` — debug helper: parses a `.dbc` file
and prints header + first N records as raw uint32. Useful when adding
new schemas.
The other subcommands listed in the spec (build-mpq, deploy-server,
backup, rollback, diff) are not part of the MVP and return a
not-implemented error today.
## Default paths
`--vanilla-dir` defaults to `<repo>/var/client-data-vanilla`. Keep that
directory as a clean snapshot. `redeploy.sh` writes merged files into
`<repo>/var/client-data-live/dbc`, not back into the vanilla snapshot.
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
exec python3 "$HERE/moonwell_taxi.py" "$@"
+958
View File
@@ -0,0 +1,958 @@
#!/usr/bin/env python3
"""
moonwell-taxi: merge custom_taxi_* DB rows with vanilla TaxiNodes /
TaxiPath / TaxiPathNode DBCs, write server and client copies, and
sha256-verify they match.
The DBC binary format used by 3.3.5a is:
20-byte header:
magic char[4] = b'WDBC'
record_count uint32
field_count uint32
record_size uint32 (bytes per record)
sb_size uint32 (string block size)
record_count * record_size bytes of records
sb_size bytes of NUL-terminated strings (the string block)
Field types are not encoded in the file; the schema is implicit.
For our three tables the layouts are:
TaxiNodes (24 fields, 96 B/record):
ID uint32
ContinentID uint32
Pos_X float
Pos_Y float
Pos_Z float
Name_lang[16] uint32 string offsets
Name_lang_mask uint32
MountCreatureID[2] uint32 (Horde, Alliance)
TaxiPath (4 fields, 16 B/record):
ID, FromTaxiNode, ToTaxiNode, Cost (all uint32)
TaxiPathNode (11 fields, 44 B/record):
ID, PathID, NodeIndex, ContinentID uint32
Loc_X, Loc_Y, Loc_Z float
Flags, Delay, ArrivalEventID, DepartureEventID uint32
"""
import argparse
import hashlib
import os
import struct
import sys
from dataclasses import dataclass
from pathlib import Path
DBC_MAGIC = b"WDBC"
DBC_HEADER_FMT = "<4sIIII"
DBC_HEADER_SIZE = 20
TAXI_NODES_FIELDS = 24
TAXI_NODES_RECSIZE = TAXI_NODES_FIELDS * 4 # 96
TAXI_PATH_FIELDS = 4
TAXI_PATH_RECSIZE = TAXI_PATH_FIELDS * 4 # 16
TAXI_PATH_NODE_FIELDS = 11
TAXI_PATH_NODE_RECSIZE = TAXI_PATH_NODE_FIELDS * 4 # 44
# Vanilla DBC files we merge into. Filenames must match WoW's case.
DBC_FILES = ("TaxiNodes.dbc", "TaxiPath.dbc", "TaxiPathNode.dbc")
CLIENT_MPQ_PATHS = tuple(f"DBFilesClient\\{name}" for name in DBC_FILES)
MPQ_MAGIC = b"MPQ\x1A"
MPQ_HEADER_FMT = "<4sIIHHIIII"
MPQ_HEADER_SIZE = 32
MPQ_HASH_ENTRY_DELETED = 0xFFFFFFFE
MPQ_HASH_ENTRY_EMPTY = 0xFFFFFFFF
MPQ_FILE_EXISTS = 0x80000000
MPQ_FILE_SINGLE_UNIT = 0x01000000
def _build_mpq_crypt_table() -> list:
crypt_table = [0] * 0x500
seed = 0x00100001
for index1 in range(0x100):
index2 = index1
for _ in range(5):
seed = (seed * 125 + 3) % 0x2AAAAB
temp1 = (seed & 0xFFFF) << 16
seed = (seed * 125 + 3) % 0x2AAAAB
temp2 = seed & 0xFFFF
crypt_table[index2] = (temp1 | temp2) & 0xFFFFFFFF
index2 += 0x100
return crypt_table
MPQ_CRYPT_TABLE = _build_mpq_crypt_table()
def mpq_hash_string(name: str, hash_type: int) -> int:
seed1 = 0x7FED7FED
seed2 = 0xEEEEEEEE
normalized = name.replace("/", "\\").upper()
for ch in normalized.encode("ascii"):
value = MPQ_CRYPT_TABLE[(hash_type << 8) + ch]
seed1 = (value ^ (seed1 + seed2)) & 0xFFFFFFFF
seed2 = (ch + seed1 + seed2 + ((seed2 << 5) & 0xFFFFFFFF) + 3) & 0xFFFFFFFF
return seed1
def mpq_encrypt_words(words: list, key: int) -> bytes:
seed1 = key
seed2 = 0xEEEEEEEE
out = []
for word in words:
word &= 0xFFFFFFFF
seed2 = (seed2 + MPQ_CRYPT_TABLE[0x400 + (seed1 & 0xFF)]) & 0xFFFFFFFF
encrypted = (word ^ (seed1 + seed2)) & 0xFFFFFFFF
seed1 = (((~seed1 << 21) & 0xFFFFFFFF) + 0x11111111 + (seed1 >> 11)) & 0xFFFFFFFF
seed2 = (encrypted + seed2 + ((seed2 << 5) & 0xFFFFFFFF) + 3) & 0xFFFFFFFF
out.append(encrypted)
return struct.pack(f"<{len(out)}I", *out)
def mpq_decrypt_words(data: bytes, key: int) -> list:
if len(data) % 4:
raise ValueError("encrypted MPQ table size is not uint32-aligned")
seed1 = key
seed2 = 0xEEEEEEEE
words = struct.unpack(f"<{len(data) // 4}I", data)
out = []
for encrypted in words:
seed2 = (seed2 + MPQ_CRYPT_TABLE[0x400 + (seed1 & 0xFF)]) & 0xFFFFFFFF
plain = (encrypted ^ (seed1 + seed2)) & 0xFFFFFFFF
seed1 = (((~seed1 << 21) & 0xFFFFFFFF) + 0x11111111 + (seed1 >> 11)) & 0xFFFFFFFF
seed2 = (encrypted + seed2 + ((seed2 << 5) & 0xFFFFFFFF) + 3) & 0xFFFFFFFF
out.append(plain)
return out
def next_power_of_two(value: int) -> int:
n = 1
while n < value:
n <<= 1
return n
def build_mpq(files: dict, output_file: Path) -> None:
"""Write a minimal MPQ v1 archive with uncompressed single-unit files."""
names = list(files)
hash_table_size = max(8, next_power_of_two(len(names) * 2))
hash_entries = [[0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF] for _ in range(hash_table_size)]
block_entries = []
cursor = MPQ_HEADER_SIZE
payload = bytearray()
for block_index, name in enumerate(names):
data = files[name]
payload.extend(data)
block_entries.append([
cursor,
len(data),
len(data),
MPQ_FILE_EXISTS | MPQ_FILE_SINGLE_UNIT,
])
slot = mpq_hash_string(name, 0) & (hash_table_size - 1)
while hash_entries[slot][3] not in (MPQ_HASH_ENTRY_EMPTY, MPQ_HASH_ENTRY_DELETED):
slot = (slot + 1) & (hash_table_size - 1)
hash_entries[slot] = [
mpq_hash_string(name, 1),
mpq_hash_string(name, 2),
0,
block_index,
]
cursor += len(data)
hash_table_pos = cursor
hash_words = [word for entry in hash_entries for word in entry]
hash_table = mpq_encrypt_words(hash_words, mpq_hash_string("(hash table)", 3))
cursor += len(hash_table)
block_table_pos = cursor
block_words = [word for entry in block_entries for word in entry]
block_table = mpq_encrypt_words(block_words, mpq_hash_string("(block table)", 3))
cursor += len(block_table)
header = struct.pack(
MPQ_HEADER_FMT,
MPQ_MAGIC,
MPQ_HEADER_SIZE,
cursor,
0,
3,
hash_table_pos,
block_table_pos,
hash_table_size,
len(block_entries),
)
output_file.parent.mkdir(parents=True, exist_ok=True)
with output_file.open("wb") as f:
f.write(header)
f.write(payload)
f.write(hash_table)
f.write(block_table)
def verify_mpq(files: dict, mpq_file: Path) -> None:
data = mpq_file.read_bytes()
if len(data) < MPQ_HEADER_SIZE:
raise ValueError(f"{mpq_file} too short to be an MPQ")
(
magic,
header_size,
archive_size,
fmt_version,
_block_size,
hash_table_pos,
block_table_pos,
hash_table_size,
block_table_size,
) = struct.unpack(MPQ_HEADER_FMT, data[:MPQ_HEADER_SIZE])
if magic != MPQ_MAGIC or header_size != MPQ_HEADER_SIZE or fmt_version != 0:
raise ValueError(f"{mpq_file} has an unsupported MPQ header")
if archive_size != len(data):
raise ValueError(f"{mpq_file} size mismatch: header={archive_size}, actual={len(data)}")
hash_bytes = data[hash_table_pos : hash_table_pos + hash_table_size * 16]
block_bytes = data[block_table_pos : block_table_pos + block_table_size * 16]
hash_words = mpq_decrypt_words(hash_bytes, mpq_hash_string("(hash table)", 3))
block_words = mpq_decrypt_words(block_bytes, mpq_hash_string("(block table)", 3))
hash_entries = [hash_words[i:i + 4] for i in range(0, len(hash_words), 4)]
block_entries = [block_words[i:i + 4] for i in range(0, len(block_words), 4)]
for name, expected in files.items():
slot = mpq_hash_string(name, 0) & (hash_table_size - 1)
name1 = mpq_hash_string(name, 1)
name2 = mpq_hash_string(name, 2)
for _ in range(hash_table_size):
entry = hash_entries[slot]
if entry[3] == MPQ_HASH_ENTRY_EMPTY:
break
if entry[0] == name1 and entry[1] == name2:
block_index = entry[3]
file_pos, compressed_size, file_size, flags = block_entries[block_index]
actual = data[file_pos:file_pos + compressed_size]
if flags != (MPQ_FILE_EXISTS | MPQ_FILE_SINGLE_UNIT):
raise ValueError(f"{mpq_file}: unexpected flags for {name}: 0x{flags:08X}")
if file_size != len(expected) or actual != expected:
raise ValueError(f"{mpq_file}: payload mismatch for {name}")
break
slot = (slot + 1) & (hash_table_size - 1)
else:
raise ValueError(f"{mpq_file}: hash table probe exhausted for {name}")
if hash_entries[slot][0] != name1 or hash_entries[slot][1] != name2:
raise ValueError(f"{mpq_file}: missing {name}")
def sort_records_by_id(dbc: "DBC") -> None:
"""Keep DBC records in ascending ID order.
AzerothCore's server loader builds an explicit ID index and tolerates
records appended out of order. The 3.3.5a client is stricter around taxi
UI lookups, so custom IDs that fill holes below the vanilla max must be
inserted into sorted order instead of left at EOF.
"""
dbc.records.sort(key=lambda r: struct.unpack_from("<I", r, 0)[0])
# --------------------------------------------------------------------------- #
# .env loader (avoids adding python-dotenv as a dep) #
# --------------------------------------------------------------------------- #
def load_env_file(path: Path) -> None:
if not path.exists():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
# --------------------------------------------------------------------------- #
# DBC binary I/O #
# --------------------------------------------------------------------------- #
@dataclass
class DBC:
"""Mutable in-memory DBC: a list of record bytes + the string block."""
field_count: int
record_size: int
records: list # list[bytes], each exactly record_size bytes
string_block: bytearray
@classmethod
def read(cls, path: Path) -> "DBC":
data = path.read_bytes()
if len(data) < DBC_HEADER_SIZE:
raise ValueError(f"{path} too short to be a DBC")
magic, n_rec, n_field, rec_size, sb_size = struct.unpack(
DBC_HEADER_FMT, data[:DBC_HEADER_SIZE]
)
if magic != DBC_MAGIC:
raise ValueError(f"{path} bad magic {magic!r}, expected {DBC_MAGIC!r}")
records_blob_size = n_rec * rec_size
records_end = DBC_HEADER_SIZE + records_blob_size
sb_end = records_end + sb_size
if sb_end != len(data):
raise ValueError(
f"{path} size mismatch: header says {sb_end} bytes, file is {len(data)}"
)
records = [
data[DBC_HEADER_SIZE + i * rec_size : DBC_HEADER_SIZE + (i + 1) * rec_size]
for i in range(n_rec)
]
return cls(
field_count=n_field,
record_size=rec_size,
records=records,
string_block=bytearray(data[records_end:sb_end]),
)
def write(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("wb") as f:
f.write(struct.pack(
DBC_HEADER_FMT,
DBC_MAGIC,
len(self.records),
self.field_count,
self.record_size,
len(self.string_block),
))
for r in self.records:
if len(r) != self.record_size:
raise ValueError(
f"record size {len(r)} != schema {self.record_size}"
)
f.write(r)
f.write(self.string_block)
def append_string(self, s: str) -> int:
"""Append a UTF-8 string to the block and return its offset."""
# An empty string block always starts with a NUL byte at offset 0
# (so any record's "no string" can point to offset 0). Real DBCs
# already have this from vanilla, but we guard for safety.
if not self.string_block:
self.string_block.append(0)
offset = len(self.string_block)
self.string_block.extend(s.encode("utf-8"))
self.string_block.append(0)
return offset
# --------------------------------------------------------------------------- #
# Schema-specific record helpers #
# --------------------------------------------------------------------------- #
def pack_taxinode_record(
*,
node_id: int,
map_id: int,
x: float,
y: float,
z: float,
name_offset: int,
mount_alliance: int,
mount_horde: int,
locale_slot: int,
name_lang_mask: int,
) -> bytes:
"""Build a 96-byte TaxiNodes record.
The vanilla 3.3.5a client expects locale-aware DBCs: the string lives
only in the slot that matches the file's locale (e.g. slot 8 for ruRU
DBCs), the other 15 slots stay 0, and `name_lang_mask` carries a
Blizzard-internal bitmask. Writing the string into every slot or
using a different mask trips strict clients (DragonUI / modded
Wow.exe builds crash on `LookupEntry`-style lookups with a NULL
deref at TaxiNodesEntry+12).
Caller computes the right `locale_slot` and `name_lang_mask` by
sampling a vanilla record (see infer_taxinodes_locale_layout).
Note: in the DBC binary, MountCreatureID[0] is the *Horde* mount and
MountCreatureID[1] is the *Alliance* mount (see ObjectMgr.cpp:7229
`teamId == TEAM_ALLIANCE ? 1 : 0`). The taxi-builder spec text is
inverted; trust the runtime usage."""
if not (0 <= locale_slot < 16):
raise ValueError(f"locale_slot {locale_slot} out of range 0..15")
parts = [
struct.pack("<II", node_id, map_id),
struct.pack("<fff", x, y, z),
]
for i in range(16):
parts.append(struct.pack("<I", name_offset if i == locale_slot else 0))
parts.append(struct.pack("<III", name_lang_mask, mount_horde, mount_alliance))
rec = b"".join(parts)
assert len(rec) == TAXI_NODES_RECSIZE, len(rec)
return rec
def infer_taxinodes_locale_layout(dbc: "DBC") -> tuple:
"""Walk vanilla TaxiNodes records and figure out (a) which locale slot
Blizzard's localized DBC uses (the one with non-zero offsets), and
(b) the typical `name_lang_mask` value. Returns (locale_slot, mask).
Falls back to (8, 0xFF00FE) ruRU + observed vanilla mask if the
file is empty."""
mask_counts = {}
slot_counts = [0] * 16
for r in dbc.records:
for i in range(16):
off = struct.unpack_from("<I", r, 8 + 12 + i * 4)[0]
if off:
slot_counts[i] += 1
m = struct.unpack_from("<I", r, 8 + 12 + 16 * 4)[0]
mask_counts[m] = mask_counts.get(m, 0) + 1
best_slot = max(range(16), key=lambda i: slot_counts[i]) if any(slot_counts) else 8
best_mask = max(mask_counts, key=mask_counts.get) if mask_counts else 0xFF00FE
return best_slot, best_mask
def pack_taxipath_record(*, path_id: int, from_node: int, to_node: int, cost: int) -> bytes:
rec = struct.pack("<IIII", path_id, from_node, to_node, cost)
assert len(rec) == TAXI_PATH_RECSIZE, len(rec)
return rec
def pack_taxipathnode_record(
*,
record_id: int,
path_id: int,
node_index: int,
map_id: int,
x: float,
y: float,
z: float,
flags: int,
delay: int,
arrival_event: int,
departure_event: int,
) -> bytes:
rec = struct.pack(
"<IIIIfffIIII",
record_id, path_id, node_index, map_id,
x, y, z,
flags, delay, arrival_event, departure_event,
)
assert len(rec) == TAXI_PATH_NODE_RECSIZE, len(rec)
return rec
def vanilla_max_id_uint32_at_offset(records: list, byte_offset: int) -> int:
"""Scan `records` and return the max uint32 read at byte_offset."""
best = 0
for r in records:
v = struct.unpack_from("<I", r, byte_offset)[0]
if v > best:
best = v
return best
# --------------------------------------------------------------------------- #
# DB access #
# --------------------------------------------------------------------------- #
def db_connect(args):
import pymysql
return pymysql.connect(
host=args.db_host,
port=args.db_port,
user=args.db_user,
password=args.db_password,
database=args.db_name,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
)
def fetch_custom_taxi_nodes(conn):
with conn.cursor() as c:
c.execute(
"SELECT id, name, map_id, position_x, position_y, position_z, "
" mount_alliance, mount_horde "
"FROM custom_taxi_nodes ORDER BY id"
)
return list(c.fetchall())
def fetch_custom_taxi_paths(conn):
with conn.cursor() as c:
c.execute(
"SELECT id, from_taxi_node_id, to_taxi_node_id, cost "
"FROM custom_taxi_paths WHERE is_enabled = 1 ORDER BY id"
)
return list(c.fetchall())
def fetch_custom_taxi_path_nodes(conn):
with conn.cursor() as c:
c.execute(
"SELECT path_id, node_index, map_id, position_x, position_y, position_z, "
" flags, delay, arrival_event_id, departure_event_id "
"FROM custom_taxi_path_nodes ORDER BY path_id, node_index"
)
return list(c.fetchall())
# --------------------------------------------------------------------------- #
# Export pipeline #
# --------------------------------------------------------------------------- #
def merge_taxinodes(dbc: DBC, custom_rows: list) -> int:
"""Append custom rows to the loaded TaxiNodes DBC. Returns the count
of appended records. Picks the locale_slot and name_lang_mask from
the vanilla file so our records are byte-shaped exactly like the
surrounding Blizzard data strict clients reject anything else."""
if dbc.record_size != TAXI_NODES_RECSIZE:
raise ValueError(
f"TaxiNodes.dbc record size {dbc.record_size} != expected {TAXI_NODES_RECSIZE}"
)
locale_slot, lang_mask = infer_taxinodes_locale_layout(dbc)
print(f"TaxiNodes locale layout: slot={locale_slot}, lang_mask=0x{lang_mask:08X}")
vanilla_ids = {struct.unpack_from("<I", r, 0)[0] for r in dbc.records}
appended = 0
for row in custom_rows:
if row["id"] in vanilla_ids:
raise ValueError(
f"custom TaxiNode id {row['id']} collides with a vanilla TaxiNode id"
)
offset = dbc.append_string(row["name"])
dbc.records.append(pack_taxinode_record(
node_id=row["id"],
map_id=row["map_id"],
x=row["position_x"],
y=row["position_y"],
z=row["position_z"],
name_offset=offset,
mount_alliance=row["mount_alliance"],
mount_horde=row["mount_horde"],
locale_slot=locale_slot,
name_lang_mask=lang_mask,
))
appended += 1
sort_records_by_id(dbc)
return appended
def merge_taxipath(dbc: DBC, custom_rows: list) -> int:
if dbc.record_size != TAXI_PATH_RECSIZE:
raise ValueError(
f"TaxiPath.dbc record size {dbc.record_size} != expected {TAXI_PATH_RECSIZE}"
)
vanilla_ids = {struct.unpack_from("<I", r, 0)[0] for r in dbc.records}
appended = 0
for row in custom_rows:
if row["id"] in vanilla_ids:
raise ValueError(
f"custom TaxiPath id {row['id']} collides with a vanilla TaxiPath id"
)
dbc.records.append(pack_taxipath_record(
path_id=row["id"],
from_node=row["from_taxi_node_id"],
to_node=row["to_taxi_node_id"],
cost=row["cost"],
))
appended += 1
sort_records_by_id(dbc)
return appended
def merge_taxipathnode(dbc: DBC, custom_rows: list) -> int:
if dbc.record_size != TAXI_PATH_NODE_RECSIZE:
raise ValueError(
f"TaxiPathNode.dbc record size {dbc.record_size} != expected {TAXI_PATH_NODE_RECSIZE}"
)
next_id = vanilla_max_id_uint32_at_offset(dbc.records, 0) + 1
appended = 0
for row in custom_rows:
dbc.records.append(pack_taxipathnode_record(
record_id=next_id,
path_id=row["path_id"],
node_index=row["node_index"],
map_id=row["map_id"],
x=row["position_x"],
y=row["position_y"],
z=row["position_z"],
flags=row["flags"],
delay=row["delay"],
arrival_event=row["arrival_event_id"],
departure_event=row["departure_event_id"],
))
next_id += 1
appended += 1
sort_records_by_id(dbc)
return appended
# --------------------------------------------------------------------------- #
# sha256 helpers #
# --------------------------------------------------------------------------- #
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def write_manifest(folder: Path, hashes: dict) -> None:
folder.mkdir(parents=True, exist_ok=True)
lines = [f"{h} {name}" for name, h in sorted(hashes.items())]
(folder / "sha256.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
# --------------------------------------------------------------------------- #
# Commands #
# --------------------------------------------------------------------------- #
def cmd_export_dbc(args) -> int:
vanilla_dir = Path(args.vanilla_dir).expanduser().resolve()
output_dir = Path(args.output_dir).expanduser().resolve()
server_dir = output_dir / "server" / "dbc"
client_dir = output_dir / "client" / "DBFilesClient"
print(f"Vanilla source: {vanilla_dir}")
print(f"Output: {output_dir}")
# Validate vanilla files exist before touching the DB.
for fname in DBC_FILES:
p = vanilla_dir / fname
if not p.is_file():
print(f"ERROR: vanilla DBC missing: {p}", file=sys.stderr)
return 2
print("Connecting to MySQL...")
conn = db_connect(args)
try:
nodes = fetch_custom_taxi_nodes(conn)
paths = fetch_custom_taxi_paths(conn)
wps = fetch_custom_taxi_path_nodes(conn)
finally:
conn.close()
print(f"Custom nodes: {len(nodes)}, paths: {len(paths)}, waypoints: {len(wps)}")
# Sanity checks on the data before we burn DBC bytes.
if paths and not wps:
print("WARNING: paths defined but no waypoints — generated DBC will be useless.",
file=sys.stderr)
enabled_path_ids = {p["id"] for p in paths}
waypoints_by_path = {}
for wp in wps:
waypoints_by_path.setdefault(wp["path_id"], []).append(wp)
for pid in waypoints_by_path:
if pid not in enabled_path_ids:
print(f"WARNING: waypoints reference disabled/unknown path_id={pid}; skipping.",
file=sys.stderr)
wps_filtered = [wp for wp in wps if wp["path_id"] in enabled_path_ids]
# Read vanilla, merge, write.
nodes_dbc = DBC.read(vanilla_dir / "TaxiNodes.dbc")
paths_dbc = DBC.read(vanilla_dir / "TaxiPath.dbc")
pn_dbc = DBC.read(vanilla_dir / "TaxiPathNode.dbc")
# Compounding guard: if the source already contains records in the
# taxi-builder custom-id space, the user is pointing --vanilla-dir at
# an already-merged tree. Continuing would duplicate entries.
# NB: TaxiMaskSize=14 caps node ids at 448 — vanilla 3.3.5a uses up to
# ~440, so 441 is the practical floor for custom node ids. Path ids are
# not bound by the mask and live in their own large range.
CUSTOM_NODE_FLOOR = 441
CUSTOM_PATH_FLOOR = 1979
seen_nodes = sum(
1 for r in nodes_dbc.records
if struct.unpack_from("<I", r, 0)[0] >= CUSTOM_NODE_FLOOR
)
seen_paths = sum(
1 for r in paths_dbc.records
if struct.unpack_from("<I", r, 0)[0] >= CUSTOM_PATH_FLOOR
)
if seen_nodes or seen_paths:
print(
f"ERROR: vanilla source at {vanilla_dir} already contains "
f"{seen_nodes} TaxiNodes id>={CUSTOM_NODE_FLOOR} and "
f"{seen_paths} TaxiPath id>={CUSTOM_PATH_FLOOR} — looks already-merged.\n"
f" Point --vanilla-dir at a clean snapshot, or restore "
f"vanilla DBCs into {vanilla_dir} before re-running.",
file=sys.stderr)
return 3
n_appended_nodes = merge_taxinodes(nodes_dbc, nodes)
n_appended_paths = merge_taxipath(paths_dbc, paths)
n_appended_pn = merge_taxipathnode(pn_dbc, wps_filtered)
print(f"Appended: TaxiNodes +{n_appended_nodes}, "
f"TaxiPath +{n_appended_paths}, "
f"TaxiPathNode +{n_appended_pn}")
print(f"Final: TaxiNodes {len(nodes_dbc.records)} records, "
f"TaxiPath {len(paths_dbc.records)} records, "
f"TaxiPathNode {len(pn_dbc.records)} records")
# Write to server/dbc and client/DBFilesClient. Same bytes both sides.
targets = {
"TaxiNodes.dbc": nodes_dbc,
"TaxiPath.dbc": paths_dbc,
"TaxiPathNode.dbc": pn_dbc,
}
server_hashes = {}
client_hashes = {}
for fname, dbc in targets.items():
spath = server_dir / fname
cpath = client_dir / fname
dbc.write(spath)
dbc.write(cpath)
server_hashes[fname] = sha256_file(spath)
client_hashes[fname] = sha256_file(cpath)
write_manifest(server_dir, server_hashes)
write_manifest(client_dir, client_hashes)
print()
print("sha256 (server):")
for fname, h in sorted(server_hashes.items()):
print(f" {h} {fname}")
print("sha256 (client):")
for fname, h in sorted(client_hashes.items()):
print(f" {h} {fname}")
if server_hashes != client_hashes:
print("Server/client DBC match: FAIL", file=sys.stderr)
return 1
print("Server/client DBC match: OK")
return 0
def cmd_build_client_patch(args) -> int:
input_dir = Path(args.input_dir).expanduser().resolve()
output_file = Path(args.output_file).expanduser().resolve()
print(f"Client DBC input: {input_dir}")
print(f"MPQ output: {output_file}")
files = {}
listfile_lines = []
for internal_path, fname in zip(CLIENT_MPQ_PATHS, DBC_FILES):
source = input_dir / fname
if not source.is_file():
print(f"ERROR: client DBC missing: {source}", file=sys.stderr)
return 2
files[internal_path] = source.read_bytes()
listfile_lines.append(internal_path)
files["(listfile)"] = ("\r\n".join(listfile_lines) + "\r\n").encode("ascii")
build_mpq(files, output_file)
verify_mpq(files, output_file)
print()
print(f"MPQ sha256: {sha256_file(output_file)} {output_file.name}")
print("Client install:")
print(f" Copy to: <WoW 3.3.5a>\\Data\\{output_file.name}")
print(" Then delete the client Cache directory and restart Wow.exe.")
return 0
def cmd_dump_vanilla(args) -> int:
"""Read a vanilla DBC and dump header + first few raw records."""
p = Path(args.dbc).expanduser().resolve()
dbc = DBC.read(p)
print(f"{p}")
print(f" field_count={dbc.field_count} record_size={dbc.record_size} "
f"records={len(dbc.records)} string_block={len(dbc.string_block)}")
n = min(args.limit, len(dbc.records))
for i in range(n):
r = dbc.records[i]
ints = struct.unpack(f"<{dbc.record_size // 4}I", r)
print(f" [{i}] {ints}")
return 0
def cmd_thin(args) -> int:
"""Drop intermediate waypoints that sit within args.min_yd (XY) of the
previously kept point. Always keeps the first and the last waypoint;
re-numbers node_index from 0 in place. Original rows are saved to a
backup table so the operation is reversible."""
import time
if args.min_yd < 1.0:
print("--min-yd must be >= 1", file=sys.stderr)
return 2
conn = db_connect(args)
try:
with conn.cursor() as c:
c.execute(
"SELECT id, path_id, node_index, map_id, position_x, position_y, position_z, "
" flags, delay, arrival_event_id, departure_event_id, comment "
"FROM custom_taxi_path_nodes WHERE path_id = %s ORDER BY node_index",
(args.path,),
)
rows = list(c.fetchall())
if not rows:
print(f"path {args.path} has no waypoints", file=sys.stderr)
return 1
if len(rows) < 3:
print(f"path {args.path} has only {len(rows)} waypoints — nothing to thin")
return 0
def dist_xy(a, b):
dx = a["position_x"] - b["position_x"]
dy = a["position_y"] - b["position_y"]
return (dx * dx + dy * dy) ** 0.5
kept = [rows[0]]
for wp in rows[1:-1]:
if wp["map_id"] != kept[-1]["map_id"]:
kept.append(wp)
continue
if dist_xy(wp, kept[-1]) >= args.min_yd:
kept.append(wp)
kept.append(rows[-1])
# If the second-to-last we kept is now inside the spacing window
# of the final point, drop it — we want the last point unique.
while (
len(kept) >= 3
and kept[-2]["map_id"] == kept[-1]["map_id"]
and dist_xy(kept[-2], kept[-1]) < args.min_yd
):
kept.pop(-2)
print(f"Path {args.path}: {len(rows)} -> {len(kept)} waypoints "
f"(min_yd={args.min_yd}, removed {len(rows) - len(kept)})")
if args.dry_run:
print("(dry run — DB not modified). New sequence:")
for i, wp in enumerate(kept):
seg = ""
if i > 0 and kept[i - 1]["map_id"] == wp["map_id"]:
seg = f" seg={dist_xy(kept[i - 1], wp):.1f}yd"
print(f" [{i:>3}] (was {wp['node_index']:>3}) map={wp['map_id']} "
f"({wp['position_x']:.1f}, {wp['position_y']:.1f}, {wp['position_z']:.1f}){seg}")
return 0
ts = int(time.time())
backup_table = f"custom_taxi_path_nodes_backup_{args.path}_{ts}"
with conn.cursor() as c:
c.execute(
f"CREATE TABLE `{backup_table}` AS "
"SELECT * FROM custom_taxi_path_nodes WHERE path_id = %s",
(args.path,),
)
c.execute(
"DELETE FROM custom_taxi_path_nodes WHERE path_id = %s",
(args.path,),
)
for new_idx, wp in enumerate(kept):
c.execute(
"INSERT INTO custom_taxi_path_nodes "
"(path_id, node_index, map_id, position_x, position_y, position_z, "
" flags, delay, arrival_event_id, departure_event_id, comment) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
(args.path, new_idx, wp["map_id"],
wp["position_x"], wp["position_y"], wp["position_z"],
wp["flags"], wp["delay"],
wp["arrival_event_id"], wp["departure_event_id"], wp["comment"]),
)
conn.commit()
print(f"Backup table: `{backup_table}`")
print("To rollback:")
print(f" DELETE FROM custom_taxi_path_nodes WHERE path_id = {args.path};")
print(f" INSERT INTO custom_taxi_path_nodes "
f"SELECT * FROM `{backup_table}`;")
finally:
conn.close()
return 0
def cmd_not_implemented(args) -> int:
print(f"`{args.subcommand}` is not implemented in the MVP.", file=sys.stderr)
return 2
# --------------------------------------------------------------------------- #
# CLI wiring #
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
repo_root = Path(__file__).resolve().parent.parent.parent
default_vanilla = repo_root / "var" / "client-data-vanilla"
default_output = repo_root / "build" / "taxi" / "export"
default_patch = default_output / "client" / "patch-Z.MPQ"
p = argparse.ArgumentParser(prog="moonwell-taxi", description=__doc__)
sub = p.add_subparsers(dest="subcommand", required=True)
# Shared DB connection flags.
def add_db_flags(sp):
sp.add_argument("--db-host", default=os.environ.get("MOONWELL_TAXI_DB_HOST", "127.0.0.1"))
sp.add_argument("--db-port", type=int,
default=int(os.environ.get("DOCKER_DB_EXTERNAL_PORT", "3306")))
sp.add_argument("--db-user", default=os.environ.get("MOONWELL_TAXI_DB_USER", "root"))
sp.add_argument("--db-password", default=os.environ.get("DOCKER_DB_ROOT_PASSWORD", "password"))
sp.add_argument("--db-name", default=os.environ.get("MOONWELL_TAXI_DB_NAME", "acore_world"))
sp_export = sub.add_parser("export-dbc", help="merge custom + vanilla into new DBCs")
sp_export.add_argument("--vanilla-dir", default=str(default_vanilla),
help="directory containing TaxiNodes.dbc / TaxiPath.dbc / TaxiPathNode.dbc "
"(default: repo root)")
sp_export.add_argument("--output-dir", default=str(default_output),
help="output base; server/ and client/ subtrees are created here")
add_db_flags(sp_export)
sp_export.set_defaults(func=cmd_export_dbc)
sp_patch = sub.add_parser("build-client-patch", help="pack exported client DBCs into a WoW MPQ patch")
sp_patch.add_argument("--input-dir", default=str(default_output / "client" / "DBFilesClient"),
help="directory containing exported client Taxi*.dbc files")
sp_patch.add_argument("--output-file", default=str(default_patch),
help="MPQ file to write")
sp_patch.set_defaults(func=cmd_build_client_patch)
sp_dump = sub.add_parser("dump-vanilla", help="dump header + records for a DBC")
sp_dump.add_argument("dbc", help="path to a .dbc file")
sp_dump.add_argument("--limit", type=int, default=5)
sp_dump.set_defaults(func=cmd_dump_vanilla)
sp_thin = sub.add_parser("thin", help="drop too-close waypoints in a path")
sp_thin.add_argument("--path", type=int, required=True, help="custom_taxi_paths.id to thin")
sp_thin.add_argument("--min-yd", type=float, default=50.0,
help="minimum XY segment distance to keep (default 50)")
sp_thin.add_argument("--dry-run", action="store_true",
help="show what would change, do not touch the DB")
add_db_flags(sp_thin)
sp_thin.set_defaults(func=cmd_thin)
for stub in ("build-mpq", "deploy-server", "backup", "rollback", "diff"):
sp = sub.add_parser(stub, help=f"(not implemented in MVP) {stub}")
sp.set_defaults(func=cmd_not_implemented)
return p
def main(argv=None) -> int:
repo_root = Path(__file__).resolve().parent.parent.parent
load_env_file(repo_root / ".env")
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Re-export TaxiNodes / TaxiPath / TaxiPathNode DBCs from custom_taxi_*
# tables, copy them into the worldserver's bind-mounted data directory,
# and restart the worldserver. Idempotent.
#
# Vanilla baseline lives in var/client-data-vanilla/ and is treated as
# read-only: the export tool reads from there but writes to
# build/taxi/export/. Never point --vanilla-dir at the live tree, or
# subsequent exports will compound their own custom rows.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
VANILLA_DIR="${VANILLA_DIR:-$ROOT_DIR/var/client-data-vanilla}"
LIVE_DIR="${LIVE_DIR:-$ROOT_DIR/var/client-data-live/dbc}"
EXPORT_DIR="${EXPORT_DIR:-$ROOT_DIR/build/taxi/export}"
SERVER_OUT="$EXPORT_DIR/server/dbc"
log() { printf '[redeploy] %s\n' "$*"; }
if [[ ! -d "$VANILLA_DIR" ]]; then
printf 'vanilla snapshot not found: %s\n' "$VANILLA_DIR" >&2
printf 'Create it once from a clean live tree:\n' >&2
printf ' mkdir -p %s && cp %s/Taxi*.dbc %s/\n' \
"$VANILLA_DIR" "$LIVE_DIR" "$VANILLA_DIR" >&2
exit 1
fi
if [[ ! -d "$LIVE_DIR" ]]; then
printf 'live dbc dir not found: %s\n' "$LIVE_DIR" >&2
exit 1
fi
cd "$ROOT_DIR"
log "exporting DBCs from $VANILLA_DIR"
python3 "$ROOT_DIR/tools/moonwell-taxi/moonwell_taxi.py" export-dbc \
--vanilla-dir "$VANILLA_DIR" \
--output-dir "$EXPORT_DIR"
log "copying server DBCs into $LIVE_DIR"
cp "$SERVER_OUT/TaxiNodes.dbc" "$LIVE_DIR/"
cp "$SERVER_OUT/TaxiPath.dbc" "$LIVE_DIR/"
cp "$SERVER_OUT/TaxiPathNode.dbc" "$LIVE_DIR/"
if docker compose ps --status running --services 2>/dev/null | grep -qx 'ac-worldserver'; then
log "restarting ac-worldserver"
docker compose restart ac-worldserver
else
log "ac-worldserver is not running; new DBCs will be picked up on next start"
fi
log "done"
+1
View File
@@ -0,0 +1 @@
PyMySQL>=1.1