diff --git a/.gitignore b/.gitignore index b7be1fdd9..7017dfb09 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,13 @@ imgui.ini config.ini config.json .env +client/ +tools/.env +tools/headless_client/settings.json +tools/bot_fleet_manager/fleet.settings.json +tools/bot_fleet_manager/runtime/ +tools/provisioning/*.local.json +tools/provisioning/roster.json # Runtime cache (floor heights, etc.) cache/ @@ -106,9 +113,11 @@ asset_pipeline/ # Local texture dumps / extracted art should never be committed assets/textures/ node_modules/ +vcpkg_installed/ # Python cache artifacts tools/__pycache__/ +**/__pycache__/ *.pyc # World editor output: scaffolded/exported zones and per-user ImGui layout diff --git a/BOT_PLAN.md b/BOT_PLAN.md new file mode 100644 index 000000000..6995f937b --- /dev/null +++ b/BOT_PLAN.md @@ -0,0 +1,256 @@ +# Bot Fleet Manager Plan + +## Goal + +Turn `wowee_headless` into a bot automation foundation that can supervise multiple player-character party leaders. Each leader can log in, form a CMaNGOS bot party, receive automation commands, and report world/chat/party state. The first practical scale target is 10 leader sessions, each with up to 4 server-side follower bots. + +## Current Strategy + +- [x] Use client-side orchestration first; do not require CMaNGOS server changes for v1. +- [x] Treat each logged-in headless character as a leader agent. +- [x] Use CMaNGOS/playerbot follower behavior for party members. +- [x] Keep Python/Lua automation external through HTTP APIs for the first implementation. +- [x] Pull current upstream `master` into the headless branch before continuing. +- [ ] Add WebSocket or Server-Sent Events after the request/response APIs stabilize. +- [ ] Revisit optional CMaNGOS server hooks after the client-side fleet model proves useful. + +## Phase 1: Headless Leader Agent + +- [x] Log in, select a character, enter world, and expose localhost chat APIs. +- [x] Add startup bot commands through `bots.names` and `automation.onEnterWorldCommands`. +- [x] Preserve local `settings.json` outside git. +- [x] Expose `GET /world/self` with map, position, orientation, movement state, and character identity. +- [x] Expose `GET /party` with parsed party members and leader GUID. +- [x] Expose `POST /commands` for raw queued chat/GM/playerbot commands. +- [x] Expose `POST /movement/goto` for direct leader travel. +- [x] Expose `POST /movement/goto/waypoints` for explicit waypoint-list travel. +- [x] Expose `POST /movement/stop` to cancel leader travel. +- [x] Add movement task status to `GET /status`. +- [x] Add death/combat detection fields to HTTP API (`combat.inCombat`, `health.current/max/isDead/isPlayerDead`). + +## Phase 2: Leader Travel MVP + +- [x] Implement direct-line steering to a coordinate: `mapId`, `x`, `y`, `z`, optional `arrivalRadius`. +- [x] Face destination, send movement start/stop/facing/heartbeat packets, and update local position based on server run speed. +- [x] Stop when within arrival radius. +- [x] Fail cleanly on map mismatch, not-in-world, or invalid coordinates. +- [x] Detect and report server movement lock as a distinct travel failure. +- [x] Add waypoint-list travel for hand-authored route segments. +- [ ] Validate direct travel against live CMaNGOS after the upstream merge. +- [ ] Add runtime collision checks during movement. +- [x] Decide whether pathfinding should come from CMaNGOS/Detour data, MiniManager-style map assets, or a separate extracted navmesh. +- [x] Add a standalone pathfinding service boundary with `/health` and `/path`. +- [x] Add an MVP direct-line backend for transport and waypoint handoff testing. +- [x] Add fleet `pathfind-goto` command that asks the path service for waypoints and posts them to leaders. +- [x] Add a native `mmap_path_query` helper that loads CMaNGOS mmap/Detour data and emits JSON waypoints. +- [x] Wire the path service `external` backend to pass `DataDir` and preserve helper JSON errors. +- [x] Validate `mmap_path_query` against real extracted `mmaps/` data on the CMaNGOS host. +- [x] Deploy the pathfinding service on the CMaNGOS host and validate live fleet `pathfind-goto` against it. +- [ ] Validate the helper output against CMaNGOS `.mmap path` on the same start/end points. +- [x] Optimize `mmap_path_query` with bbox tile loading for short route queries. +- [x] Add navmesh-projected waypoint densification so stairs, ramps, and bridges are followed more closely by the headless mover. +- [ ] Add route smoothing and height stabilization pass to reduce visual jitter from tiny navmesh Z oscillations while preserving stairs/ramps. +- [ ] Broaden bbox validation across continents, instances, and longer cross-zone routes. +- [x] Add an iterative `/route` planner endpoint that can expose partial/stalled/max-leg progress without moving leaders. +- [x] Add a read-only fleet `plan-route` command for inspecting route legs from a live leader position. +- [x] Add guarded route execution that can move one leg, replan, and stop safely on stalled/partial routes. +- [x] Live-test guarded `route-goto` on a short complete path, then on a known stalled long path. +- [x] Harden the headless HTTP API server against intermittent 10053/10054/reset/400 responses during frequent polling. +- [x] Add named landmarks for common route/demo targets. +- [x] Add a route demo command for repeatable named route tests. +- [x] Add route stop/resume state for guarded route targets. +- [x] Add named travel nodes for cross-map and long-range routes: tram, boats, portals, and flight masters. + +## Phase 2b: Grand Expedition Travel Planner + +The aspirational route for the next pathfinding phase is a low-level Alliance "Grand Expedition" from Goldshire to Darnassus. It is intentionally bigger than one navmesh query: it forces the automation stack to handle city navigation, transports, continent transitions, and route resumption. + +**Definition of done:** create a brand-new Human character, issue one command ("go to Darnassus"), and the automation layer plans and executes the entire route unattended - city navigation, transports, and continent transitions all self-detected and self-resumed, no manual coordinate tuning, no watching-and-reacting to catch a timing window. If a step needs a human to babysit it in real time to work, that step isn't done yet, no matter how many times it's been made to work manually. + +Target route: + +- [ ] Northshire -> Goldshire -> Stormwind City -> Dwarven District tram entrance. +- [ ] Deeprun Tram: enter tram instance/area, board tram car, wait for transit, exit at Ironforge side. +- [ ] Ironforge -> Dun Morogh -> Loch Modan -> Wetlands -> Menethil Harbor, favoring roads for low-level safety. +- [ ] Menethil Harbor -> Auberdine boat: navigate onto boat, detect departure/transit/arrival, navigate off boat. +- [ ] Auberdine -> Rut'theran Village transport: navigate to the correct dock/boat or validated alternate travel method. +- [ ] Rut'theran Village -> Darnassus: navigate off transport and through the portal. +- [ ] Include at least one griffon/flight-path leg once flight-master gossip and known-route availability can be detected. Candidate: Auberdine/Darkshore <-> Rut'theran Village if available in the active TBC server data. + +Implementation roadmap: + +- [x] Add a travel node registry with typed nodes: `walk`, `tram_entrance`, `tram_platform`, `boat_dock`, `boat_deck`, `portal`, `flight_master`, `zone_handoff`. +- [x] Store each node in WoWee canonical coordinates with map id, radius, faction/expansion notes, and optional linked destination node. +- [x] Add a route planner mode that chains navmesh legs and travel-node transitions. +- [x] Add planner output that distinguishes `walk`, `board_transport`, `wait_transport`, `disembark`, `use_portal`, and `use_flight` steps. +- [x] Add survey tooling to record live map/position/orientation/movement flags while manually walking transport areas. +- [x] Add a capture command that writes surveyed leader positions back into the travel node registry. +- [ ] Add execution state for travel-node steps so `resume-route` can continue after a crash, disconnect, death, or manual intervention. Resume must re-verify progress against the specific target step, not just any plausible-looking signal that a step completed - live-confirmed this matters: an early tram dwell-detector treated "the car stopped" as arrival without checking *which* platform it stopped at, and falsely reported success ~2400 yards from the actual destination. Every arrival/completion check needs to know what it's actually trying to reach. +- [ ] Prototype with Deeprun Tram first because it directly addresses the current Goldshire/Stormwind -> Ironforge stall. + - [x] Verify Stormwind tram entrance AreaTrigger `2173` and target landing on map `369`. + - [x] Add an explicit headless `/area-trigger` API and fleet `area-trigger` command for validated transition prototyping. + - [x] Identify Deeprun Tram cars as CMaNGOS `GAMEOBJECT_TYPE_TRANSPORT` / ElevatorTransport objects using `gameobject` spawn rows plus `TransportAnimation.dbc`, not boat-style `transports` rows. + - [x] Add fleet `tram-state` predictor/calibration tooling that estimates Deeprun car positions from DBC animation data and live leader position. + - [x] Split broad tram station detection from tighter platform boarding candidates after live Ironforge-side testing showed the far tram can still be inside the coarse station window. + - [ ] Discover/implement actual tram boarding/wait/disembark behavior instead of walking the Deeprun tunnel. + - [ ] Wire calibrated tram state into `ride_tram` execution: wait for car, board at platform, ride, disembark, then trigger the destination exit. + - [ ] Move the Deeprun ElevatorTransport math into shared C++ client code so the regular WoWee client and headless automation agree on tram car placement. +- [ ] Add same-continent landmark routing polish after tram prototype: clearer map-mismatch messages, route feasibility labels, and better named demos. +- [x] Make route execution death-aware: check `health.isDead`/`isPlayerDead` (already exposed by `/world/self`) during `route-goto`/`travel-node-execute` and stop cleanly with a clear reported reason instead of continuing to issue movement commands for a dead character until it times out confusingly. Prompted by the low-level Wetlands road crossing on the Grand Expedition route, where mobs stray close enough to the road to be a real risk. Live-validated on an unforced real death during the Ironforge -> Menethil dry run (Dun Morogh/Loch Modan, not even Wetlands yet) - `LeaderDiedError` fired cleanly instead of the route hanging/timing out confusingly. +- [ ] Add death recovery: release spirit, walk back to the corpse as a ghost, reclaim it, and resume the interrupted route. Most of the protocol-level work already exists in the GUI client's release/reclaim flow (`GameHandler::releaseSpirit()`, corpse position tracking, `canReclaimCorpse()` incl. the reclaim-delay timer, the `CMSG_RECLAIM_CORPSE` send) - this is mostly headless API exposure plus Python orchestration, not new protocol work. + - [ ] Expose corpse/ghost state over the headless API: corpse position + map, `canReclaimCorpse`, remaining reclaim delay, released-spirit flag. + - [ ] Add `POST /release-spirit` and `POST /reclaim-corpse` endpoints wrapping the existing GameHandler methods. + - [ ] Add a Python death-recovery routine: detect death -> release spirit -> wait out the reclaim delay -> walk to the corpse via the existing pathfinding-backed route-goto -> reclaim -> confirm alive. + - [ ] Verify ghost-form movement behaves normally through the existing movement system - untested assumption, not yet confirmed live. + - [ ] Wire death-recovery into route resume so the interrupted route continues from a sensible point after reclaiming, rather than starting over. +- [ ] Fix headless single-target `/movement/goto`'s Z interpolation: `updateMovementTask()` in `tools/headless_client/main.cpp` (`newZ = move.z + dz * t`) linearly interpolates Z straight toward the *final* target's z over the whole remaining distance, instead of sampling real ground/floor height along the way. Over a long walk this produces a smooth, continuous z ramp with no flat segments or stair step-changes, which doesn't match the real (mostly-flat, stairs-only) floor - live-observed as the leader visibly "jumping into the air and floating back down" when viewed from another client. `setPosition()` in `movement_handler.cpp` is a dumb setter with no ground-height correction, so nothing catches this downstream. `route-goto`/`travel-node-execute` are largely insulated since they walk short (~4y) pathfinding-service-derived hops with real per-point z from the navmesh, so the error self-corrects at every waypoint; the raw single-shot `goto` CLI/API (used for quick ad-hoc walks, e.g. most of this session's live debugging) is what exposes it badly over long distances. Needs either real client-side ground-height sampling or a guard that chunks/refuses long single-target goto calls. Documented, not yet fixed. +- [x] Add hostile-proximity avoidance - ended up building actual rerouting rather than just a pause, since a real trogg camp sat right on the road (pausing at its edge would never clear). `route-goto --avoid-hostiles` scans `/world/entities` for hostile units (real faction-reaction data, `Unit::isHostile()`, now exposed over the API - was already fully computed client-side, just never surfaced), computes a level-gap-scaled danger radius per threat (higher mob level relative to the player pushes it out, matching real aggro-range scaling), and pushes any waypoint inside a threat's radius directly away from it - a repulsion field bending the existing pathfinding-service waypoints around danger without a fresh planning query. Rescans periodically *during* a leg (chunks of ~15 waypoints), not just once at the start - a single leg can span 1000+ yards, and scanning only at the start missed a second, more dangerous mob cluster entirely. Live-validated: successfully detoured around a 12-mob camp and, on retest, 6 more mid-leg reroutes around real level 20-24 threats. Smart routing (this) is done; full combat/engage-and-fight fallback is still Phase 6 territory. + - Even with working avoidance, the Loch Modan/Wetlands stretch past Thelsamar turned out to be a *sustained* high-level corridor (level 20-24 mobs, 4-14 in nearly every scan), not a single camp - likely just not survivable for a level 6 character no matter how good the detour math is. Character died repeatedly (4+ times) attempting it even with full HP and avoidance active. +- [ ] Add surveyed road/handoff waypoints for Ironforge -> Dun Morogh -> Loch Modan -> Wetlands -> Menethil; the first `grand-expedition-v0` dry run stalls when asking for Ironforge -> Menethil as one giant leg. + - [x] Split the leg at `thelsamar` (DBC-verified coordinate), then found a second stall right at the Ironforge gate itself - not a missing-tile issue (all 513 mmap tiles for map 0 checked out contiguous once a filename-parsing mistake on our end was corrected: the `.mmtile` filename encodes `mapId+Y+X`, not `mapId+X+Y`), but a narrow navmesh connectivity gap at the indoor-WMO-to-outdoor-terrain seam (`polyCount` collapsed to 5 right at the gate, jumped to 124+ about 300 yards out). Added `ironforge-gate-south`, a live-surveyed waypoint just past the gap, as a workaround rather than chasing a navmesh-generation fix. + - [ ] Continue the survey past Thelsamar through Wetlands to Menethil Harbor. First attempt (2026-07-11) got roughly 1140y past Thelsamar via `route-goto` before stalling again around canonical `(-1240.7, -4332.1)` in Dun Morogh/Loch Modan (`polyCount` collapsed to 5, same signature as the Ironforge gate). Unlike the gate, this one is not a simple point fix: nudging forward found a spot ~24y away where `polyCount` ticked up to only 9 (vs. 124+ at the gate), and a multi-leg `/route` query from there made 23.9y of real progress before stalling again at `polyCount=1`. A compass-direction probe around that point found the connected navmesh pocket is small in most directions, with several queries even 502'ing (path-helper failure, not just a low-poly response) - inconclusive on which way the real road actually goes. All 36 tiles covering the area are loaded per the service's own accounting, so this looks like either a genuine navmesh generation gap over complex terrain (not a missing-file issue) or the straight-line probing direction simply isn't the real road's path. + - Turned out the automated straight-line probing was leading up into unreachable mountain terrain the whole time - the user manually walked the real road (through an actual gate structure) and repositioned the leader there; from that spot the pathfinder found a real, well-connected road (`polyCount` 185/183/93) for ~1140y before it turned out to just loop back into Ironforge's own navmesh rather than continue toward Wetlands. **Separately, the user found a real hazard while manually exploring past Thelsamar**: a tunnel mouth around canonical `(-2483.9, -6039.2)` where walking forward sends the character up into the mountain and eventually falling through world geometry into the void - confirmed server-side (not just a client render glitch) since relogging afterward reset the character to the Ironforge bind point. Do not route bots through that tunnel; treat it as a known map-data hazard, not a navmesh gap to fix. + - Lesson for next attempt: don't trust automated point-to-point pathfinding to find the real road in this stretch - it happily routes back through well-connected areas (like a city interior) that are geometrically "on the way" but wrong. Better to have a human walk the real route once (or drive the walk via the new `follow_player.py` trailing script) and capture waypoints from that, rather than blind coordinate probing. + - [x] Have a human walk Ironforge -> Menethil live while `follow_player.py` trails and catalogs it. Executed 2026-07-11 evening: leader followed the user on foot from the Dun Algaz tunnel entrance through Wetlands to Menethil Harbor, producing a 119-waypoint human-verified catalog at `tools/bot_fleet_manager/road_surveys/wetlands/ironforge-to-menethil.json`. Survived two real leader deaths mid-session (recovered via `recover-death`/manual revive + restarting `follow_player.py`, which now resumes from the existing catalog instead of overwriting it - see below). Replayable via `bot_fleet_manager.py replay-survey tools/bot_fleet_manager/road_surveys/wetlands/ironforge-to-menethil.json`. + - [x] Build a reusable survey-catalog system: `follow_player.py ` saves the walked path to `tools/bot_fleet_manager/road_surveys//.json`; `bot_fleet_manager.py replay-survey ` walks a leader through a saved catalog directly (optionally with `--avoid-hostiles`), skipping the pathfinding service entirely. Designed to extend to other maps/routes, not just this one leg - live-exercised end-to-end on the Wetlands survey above. + - Fixed a data-loss bug found during that live run: `follow_player.py` always started from an empty waypoint list, so restarting after a leader death silently overwrote everything captured so far (lost 71 waypoints once before the fix). Now loads the existing catalog file on startup and resumes appending to it. + - [x] Exercise the catalog both directions: `replay-survey --reverse` walks tail-to-head. Live-validated 2026-07-11 by replaying the Wetlands survey backwards (Menethil -> Ironforge) with `--avoid-hostiles --resume-through-death`, surviving 5 real deaths with zero manual intervention and reaching the Dun Algaz tunnel entrance. `--resume-through-death` recovers via the same ghost-walk/reclaim primitive `recover-death` uses, then resumes from whichever waypoint is nearest the post-recovery position. Found and fixed one bug live: `wait_for_movement` raises as soon as it sees death, before movement reaches a terminal state, which bypassed the death-recovery loop entirely until caught explicitly. + - [x] Add an efficiency calculation for death recovery: `_recover_from_death` now compares ghost-walk-to-corpse-and-reclaim (free, but gated by a real server-side reclaim delay - observed 72-100s per death, scaling with repeated deaths) against spirit-healer resurrection (near-instant, but costs 25% durability + resurrection sickness, and still requires walking alive from the graveyard to the nearest resume target). Only switches to the spirit healer when it's 30%+ faster, not on a marginal edge. Wired into both `recover-death` (route-goto, via the saved route state's target) and `replay-survey --resume-through-death` (via the remaining catalog waypoints) through the same shared primitive. Live-validated both branches: chose the spirit healer correctly when it was clearly faster, and chose the corpse walk correctly when it wasn't - but the first live spirit-healer attempt exposed a real bug (see below). + - [x] Extended `--avoid-hostiles`/`--resume-through-death` to `route-goto` and `travel-node-execute` (previously only `replay-survey`), plus a new `--auto-learn-flight-paths` flag on all three. Found and fixed two interaction-range bugs live: both `_recover_from_death`'s spirit-healer branch and the new flight-path auto-learn fired their NPC interaction from wherever the search radius (30-60y) happened to find the NPC, not the real ~3-5y CMaNGOS requires to actually accept it - silently doing nothing from just outside that range even though the NPC shows up in the entity scan. Both now walk in close first. + - [x] Made `/follow` actually walk toward the target instead of just tracking the camera - it already existed (`FollowCommand`/`GameHandler::followTarget()`) but only moved `followRenderPos_` for the camera controller; the character never moved, which is exactly the gap `follow_player.py` was built to paper over externally (and lagged behind the target's real position doing so). Added `MovementHandler::updateFollowMovement()`, called from the same per-frame block that already refreshes `followRenderPos_`, so it works in both clients with no extra wiring. Exposed to headless as `POST /follow {name}` / `POST /follow/stop`. Live-validated: followed a real Ironforge NPC, confirmed genuine per-frame position updates (not just a camera pointer), re-chased when the NPC moved, stopped cleanly at a 4-yard threshold. + - [x] Reversed the remaining Ironforge <-> Menethil route (Wetlands tunnel entrance -> Thelsamar -> Ironforge gate -> Ironforge City) via `travel-node-execute` with avoidance/death-resume/auto-learn-flight-paths all enabled. Survived a Rockjaw Trogg gauntlet and several more deaths with automatic recovery. Hit two more local navmesh dead-ends where the planner reported near-zero remaining distance without the character actually being close (one right at Thelsamar, one approaching the Ironforge gate from the south this time) - same failure signature as the original `ironforge-gate-south` stall, worked around the same way (a short direct `/movement/goto` past the stuck point rather than chasing the pathfinder). Learned the Ironforge flight path on arrival. +- [ ] Survey and capture `stormwind-dwarven-district`, `stormwind-tram-entrance`, `deeprun-tram-stormwind-platform`, `deeprun-tram-ironforge-platform`, and `ironforge-tram-exit`. + - [x] Seed Stormwind entrance and Deeprun/Ironforge platform nodes from `AreaTrigger.dbc` plus live CMaNGOS `areatrigger_teleport`. + - [x] Adjust Stormwind tram approach nodes to the floor-level navmesh endpoint reached by the headless client. + +Known unknowns: + +- [ ] Confirm exact TBC coordinates and map/area transitions for both Deeprun Tram entrances, platforms, and exit points. + - [x] Confirm Stormwind entrance `2173` -> Deeprun Stormwind platform on map `369`. + - [ ] Confirm Ironforge exit `2166` from the Deeprun Ironforge platform once the tram-side leg is reachable. +- [ ] Determine whether the headless client can detect being on a tram/boat through movement flags, transport GUID, map id changes, position deltas, or server packets. + - [x] Confirm the headless entity stream does not reliably expose Deeprun type-11 tram gameobjects while stationary on the platform; use DBC/DB prediction as the current Deeprun prototype. + - [x] Confirm simply moving onto the floor-projected predicted tram position does not attach the headless client to a Deeprun transport; `onTransport` remains false and the character stays at the static floor position. +- [ ] Determine how to interact with portals and flight masters through available packets/Gossip APIs. +- [ ] Confirm whether Darkshore <-> Rut'theran flight is available to the test characters or if boat/portal flow should be the canonical Darnassus route. +- [ ] Decide how aggressive low-level road-following should be around Wetlands deaths: pure road route, spirit-resume support, or escort/guard behavior. Near-term plan is death-awareness + hostile-proximity pause (see implementation roadmap above) to fail loud and clean rather than avoid death entirely - actual death recovery (spirit walk back, corpse run, or just re-resurrect and resume-route) is a distinct, harder problem to solve later, not blocking the travel planner work now. + +## Phase 3: Bot Fleet Manager + +- [x] Add `tools/bot_fleet_manager/`. +- [x] Add `fleet.settings.example.json`. +- [x] Launch multiple `wowee_headless` child processes with distinct settings and API ports. +- [x] Stagger logins/startup commands to avoid server spikes. +- [x] Track leader process state and restart failures with backoff. +- [x] Poll and report leader API status. +- [x] Expose or document aggregate fleet commands: start, stop, status, command, goto. +- [x] Expose or document aggregate pathfinding command: pathfind-goto. +- [x] Add explicit fleet assignment semantics. +- [x] Support multiple fleet managers by making ports, settings paths, and process labels explicit. + +## Phase 3b: Textual Fleet Visibility + +- [x] Add a web dashboard that lists configured teams without a map dependency. +- [x] Show leader online/offline state, API base, textual map/position, activity, party members, and recent chat. +- [x] Add `dashboard` subcommand for already-running leaders. +- [x] Add `supervise --dashboard` for running the supervisor and dashboard together. +- [ ] Add richer activity inference from recent chat, movement transitions, combat, death, and command queue state. +- [ ] Add team filtering and a compact JSON endpoint suitable for external automation. +- [ ] Add stale-data timestamps per endpoint so partial API failures are obvious. + +## Phase 3c: Map Viewer + +- [x] Review MiniManager's map/data approach and adapt the useful pieces into Python. +- [x] Build on top of the textual dashboard once leader status data is stable. +- [x] Add an abstract dashboard map that plots online leaders from `/world/self` without requiring map assets. +- [x] Add a high-level MiniManager/POMM-style continental overview map. +- [x] Augment the abstract map with MiniManager-style zone bounds and optional zone art. +- [x] Add a scripted asset refresh command for pulling MiniManager zone PNGs into the ignored runtime asset directory. +- [x] Avoid committing the experimental static WHM/Leaflet map server until its coordinate and asset assumptions are validated. + +## Phase 4: External Automation + +- [x] Add Python examples that call the manager/headless HTTP APIs. +- [ ] Add Lua examples that call the HTTP APIs externally. +- [x] Provide examples for fleet startup, party formation, and party chat. +- [x] Provide examples for direct leader travel and explicit waypoint travel. +- [ ] Keep durable state in the manager, not in scripts. + +## Phase 5: Provisioning + +- [x] Add a direct SOAP account creation script. +- [x] Add an SSH-based account creation script that reads CMaNGOS host credentials from `tools/.env` and runs SOAP locally on the server host. +- [x] Add `wowee_headless` provisioning mode for creating a character from settings JSON. +- [x] Add a character creation wrapper script that generates temporary headless settings and exits after creation. +- [x] Document the account and character provisioning flow. +- [x] Add a batch/fleet provisioning script that creates many accounts and characters from one roster file. +- [ ] Add live integration tests against a local CMaNGOS instance. + +## Asset Extraction Follow-Up + +- [x] Identify the restored asset extraction fixes in `tools/asset_extract/open_format_emitter.cpp`. +- [x] Validate that the changes are isolated from bot automation work. +- [x] Build `asset_extract` after the upstream merge. +- [ ] Split the asset extraction fix into a standalone branch/PR. +- [ ] Validate on real extracted ADT inputs before treating WOC/WHM output as pathfinding-grade data. + +## Future Work + +- [ ] Event streaming for lower-latency automation. +- [ ] Optional server-side bot takeover hooks. +- [ ] Lua automation examples. +- [ ] Runtime tests for `/movement/goto` against a live local CMaNGOS server. + +## Phase 6: Combat, Loot, and Progression Automation + +The prerequisite for most of the fun stuff: leader characters can navigate but can't fight, loot, or run quests yet. Follower bots (CMaNGOS playerbots) already have their own combat AI, so the real gap is getting the *leader* characters (the actual headless client) to target, attack, loot, and handle quests. + +- [ ] Add hostile target selection for leader characters (nearest hostile in range, or explicit target-by-guid via the API). +- [ ] Detect when a follower bot enters combat (party/bot status) and have the leader assist instead of continuing to navigate away and leaving the bot to fight alone. +- [ ] Add auto-attack / basic ability usage triggering for leader characters. +- [ ] Add loot handling: auto-loot on kill, or a queued loot command. +- [ ] Add quest automation: detect quest giver, accept, track objectives, turn in. +- [ ] Add a leveling-assembly-line mode: chain quest-accept -> navigate -> kill/loot -> turn-in cycles to level a character (target 1->60 or 1->70). +- [ ] Add a repeatable gold-farming circuit mode: fixed route, kill + loot + vendor + repeat, report gold/hour. +- [ ] Add gathering-profession automation: detect nearby herb/mining/skinning nodes and run a farming route. +- [ ] Add crafting-profession automation: buy mats from the AH, craft for skill-ups. + +## Phase 7: Fleet-Scale Group Content Demo + +A flagship stress test once Phase 6 lands: exercises fleet scale, travel automation, quest automation, and coordinated combat together in one scenario. 8 leaders x 5 characters (1 leader + 4 playerbot followers each) = 40 total, which fits inside the existing "10 leaders x 4 followers" scale target. + +- [ ] Provision 8 accounts x 5 Human characters using the existing batch provisioning tooling. +- [ ] Configure the fleet manager to operate all 8 party leaders simultaneously. +- [ ] Navigate all parties to Westbrook Garrison. +- [ ] Automate accepting "Wanted: Hogger" for each party. +- [ ] Navigate to Hogger and coordinate group engagement (depends on Phase 6 combat automation). +- [ ] Return to Westbrook Garrison and turn in the quest. + +## Phase 8: Social and Observability Automation + +Doesn't depend on Phase 6 - these only need chat, `/who`, and existing slash commands, so they can ship independently and probably sooner. + +- [ ] Server census: log in on each faction/realm, poll `/who` periodically, and build a population timeline (peak hours, faction balance). +- [ ] Guild management bot: automate invites, MOTD updates, event postings, and guild bank deposit/withdrawal tracking. +- [ ] Achievement/level-up celebrations: parse chat for level-up/achievement events, then trigger a synchronized fleet-wide emote chain, mount parade, or fireworks display. + +## Notes + +- First implementation target is CMaNGOS TBC, matching the active test environment. +- Scale should be measured in tiers: 1 leader, 2 leaders, 5 leaders, 10 leaders, then beyond. +- Coordinate conventions from `include/core/coordinates.hpp`: + - Canonical: `X=north, Y=west, Z=up`. + - Server/wire: `X=canonical.Y (west), Y=canonical.X (north)`. + - Engine render: `X=west, Y=north`. + - Headless `/world/self` API returns canonical `position.x = north`, `position.y = west`. +- First demo path: import `tools/headless_client/settings.json` into a one-leader fleet config, run fleet `supervise --dashboard`, open `http://127.0.0.1:8780`, then use `tools/automation_examples/party_demo.py`. + +## Upstream Sync (2026-07-12) + +Fast-forwarded `master` (local + `origin`) onto `upstream/master` (28 commits, clean - no local-only commits on master, `feat/headless-bot-fleet` dry-run-merges against the new master with zero conflicts across all 10 overlapping files). Peruse of the 28 commits for anything relevant to our own TBC/CMaNGOS bug-hunting, since several were framed as "restore WotLK X" fixes touching shared packet-parsing code: + +- [x] **Auction list enchant-slot count was wrong for us too**: `AuctionListResultParser` read `numEnchantSlots=3` for TBC/WotLK; real value is 6 (`MAX_INSPECTED_ENCHANTMENT_SLOT`), a 36-byte-per-entry desync that corrupted every subsequent item ID/price. Comment explicitly says "WotLK/TBC send 6" - not emulator-specific, so this affected (or would have affected, if the fleet ever used the Auction House) our CMaNGOS bots too. Already fixed via the sync, no separate action needed. +- [x] **`SMSG_SPELLLOGEXECUTE` effectType was read as `uint8` instead of `uint32`**: "All expansions send effectType as uint32" per the fix commit - a universal bug, not WotLK-specific. Corrupted CREATE_ITEM effect item IDs (e.g. crafted item shows as item #3808428032). Already fixed via the sync. +- [x] **Deeprun Tram CMaNGOS-workaround gating** (`entity_controller.cpp`'s DESTROY_OBJECT transport-preservation, `transport_manager.cpp`'s position-echo handling) is now behind `isPreWotlk()` so it doesn't misfire on AzerothCore/WotLK. Verified this is a no-op for us: `isPreWotlk()` is true for our CMaNGOS/TBC setup, so both gated blocks behave exactly as before - no conflict with, or changes needed to, our own tram-boarding work. +- [ ] **Possible latent desync in `parseMonsterMoveSplineBody`/`parseMonsterMoveSplineBodyVanilla`** (`src/game/spline_packet.cpp:34,112`, used by `SMSG_MONSTER_MOVE` - reachable on TBC): both have `if (pointCount == 0) return true;`, structurally the same shape as the WotLK bug just fixed upstream (`c83db01b`, `parseWotlkMoveUpdateSpline`), where AzerothCore was found to still write trailer bytes (splineMode + endPoint) on a zero-point spline that the old code assumed had none, desyncing the next read. Not confirmed broken - `SMSG_MONSTER_MOVE` is a different wire format from the UPDATE_OBJECT-embedded spline block the WotLK bug was in (destination is read as part of the point block here, so a genuinely-empty case may be correct to skip entirely) - but worth a live CMaNGOS packet capture to rule out an equivalent trailer-byte assumption, especially given we already found two other packed-vs-raw-field desync bugs this session (`MSG_MOVE_TELEPORT_ACK`, other-player movement relay) in the same movement-parsing neighborhood. +- Methodological note, not a bug: `1b1831a2` (Wrath quest-POI axis-order fix) is another instance of the same "packet-embedded coordinate field uses the opposite axis order from the canonical world convention" class of bug we hit repeatedly tonight (WorldMapArea.dbc, MSG_MOVE_TELEPORT_ACK). Doesn't affect us directly (quest POI parsing isn't part of the automation flow), but worth checking axis order explicitly, not assuming, whenever a new packet-derived coordinate field gets consumed by the bot fleet. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..2eb4e4875 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# WoWee repo notes for Claude + +## Two remotes, two audiences + +- `origin` (`paleophyte/WoWee`) - this fork. Headless bot-fleet work + (`tools/headless_client/`, `tools/bot_fleet_manager/`, `tools/world_db_query/`, + `tools/provisioning/`, `BOT_PLAN.md`, the `WOWEE_HEADLESS`/`WOWEE_HEADLESS_DEFAULT` + build machinery) lives here only - none of it exists upstream, so it can't + meaningfully be split out or PR'd there. +- `upstream` (`Kelsidavis/WoWee`) - the original project. Only bug fixes and + improvements to code that exists in *both* trees (`src/game/`, `include/game/`, + `src/core/`, `src/ui/`, `src/rendering/`, etc. - the GUI/shared client) belong here. + +This file itself is fork-specific context. It originally lived only on +`feat/headless-bot-fleet` on the assumption `master` would stay a clean +upstream mirror - as of 2026-07-13, `origin/master` (this fork's master, not +upstream's) had the entire `feat/headless-bot-fleet` branch merged into it +directly (PR #4), so it now permanently carries the full headless-bot-fleet +content, this file included. That's fine on its own terms (headless work +living only in this fork was always the intent - see above), but it means +**`origin/master`/local `master` can no longer be assumed clean for cutting +shared-code fix branches**. Use `upstream/master` (fetched fresh via +`git fetch upstream`) as the base for those instead - see below. + +## Don't build the `wowee` GUI target on this machine + +This is a VM with no GUI testing use - the user pulls this repo to their Mac +or Windows laptop (whichever matches the platform they want to test) and +builds/runs `wowee` there. `wowee`'s compile time is significant, so treat +that cycle as expensive and avoid spending it here by default. + +- Default to building only `wowee_headless` to verify shared-code changes + compile - that's almost always sufficient, since `wowee` and + `wowee_headless` share the same source for anything outside + `tools/headless_client/` and `WOWEE_HEADLESS`-gated branches. +- CI (GitHub Actions, `Build (windows-x86-64)`/`(x86-64)`/`(macOS arm64)`/etc.) + already builds and validates `wowee` for every PR - that's the normal way + a `wowee`-side compile error gets caught, not a local build here. +- If something seems like it could *specifically* require compiling `wowee` + locally to prove (e.g. a GUI-only code path CI can't exercise, or CI itself + is failing in a way that needs local repro) - stop and ask the user for + confirmation first, explaining exactly why the headless-only build or CI + isn't enough for this specific case. Don't just build it "to be thorough." + +## When fixing a bug in shared code + +If a fix touches a file that also exists upstream (not just something under +`tools/headless_client/` or gated behind `WOWEE_HEADLESS`), cut a small, +focused branch for it off **`upstream/master`** (not local/`origin` `master` +- see the note above about why that's no longer a clean base) *in addition +to* whatever work is happening on this feature branch - don't let it get +stuck waiting for a future retroactive extraction. Precedent: +`fix/area-trigger-dbc-load-race` was done this way and is already merged +upstream as PR #91. + +1. `git fetch upstream` and diff the target function/file against + `upstream/master` to confirm the bug is still actually there (not already + fixed independently) - don't assume the local history summary is still + accurate. +2. `git checkout -b fix/ upstream/master` (or `feat/` + for a genuine improvement, not a bug fix). +3. Apply *only* the shared-code hunks - if the same commit on this branch + also touched a headless-only call site (e.g. a new HTTP endpoint in + `tools/headless_client/main.cpp` that exercises the fix), leave that part + out. Check `git diff upstream/master feat/headless-bot-fleet -- ` + first; several shared files have accumulated unrelated headless-only + hunks (env-var gating, new headless-only methods) mixed in from other + work, so don't blindly `git checkout -- ` a whole file + without checking what's actually in its diff. +4. Build `wowee_headless` against the new branch before committing (see + "Don't build the `wowee` GUI target on this machine" above for why not + both - CI covers `wowee` once pushed). +5. Push and open a PR against `paleophyte/WoWee` `master` first - never + open the upstream PR before this one exists and its CI is clean. +6. Once that PR's local CI is green, `git fetch upstream` again and check + `git log --oneline ..upstream/master`. This confirms the fix + branch is still current *at the moment of forwarding*, not just when it + was cut - upstream moves fast enough that new commits can land during + the CI run itself. + - **Not behind**: open the PR directly against `Kelsidavis/WoWee` + `master` - this is pre-authorized, no need to check back first. + - **Behind, and the new upstream commits don't touch this fix's files**: + open the PR anyway: unrelated upstream churn isn't a reason to hold it. + - **Behind, and a new upstream commit touches the same function/file**: + do not open the PR yet. Re-run the full sync cycle first - merge fresh + `upstream/master` into the shared-code fix branch (and into + `feat/headless-bot-fleet` separately, if it also carries this fix), + resolve conflicts (favor whichever side has real, currently-working + logic over a duplicate/dead code path - see the `updateTaxiAndMountState()` + precedent from 2026-07-12 for the reasoning to apply), rebuild + `wowee_headless`, push, and wait for CI to go green again before + re-checking upstream and opening the PR. + +Keep each branch to one logical fix - small and reviewable, matching the +PR #91 precedent, not one giant "everything shared" branch. diff --git a/CMakeLists.txt b/CMakeLists.txt index 70b5637e8..c98839822 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,7 @@ option(WOWEE_ENABLE_TRACY "Enable Tracy profiler instrumentation" OFF) option(WOWEE_ENABLE_AMD_FSR2 "Enable AMD FidelityFX FSR2 backend when SDK is present" ON) option(WOWEE_ENABLE_AMD_FSR3_FRAMEGEN "Enable AMD FidelityFX SDK FSR3 frame generation interface probe when SDK is present" ON) option(WOWEE_BUILD_AMD_FSR3_RUNTIME "Build native AMD FidelityFX VK runtime (Path A) from extern/FidelityFX-SDK/Kits" ON) +set(WOWEE_MANGOS_TBC_DIR "C:/Users/admin/code/mangos-tbc" CACHE PATH "Optional CMaNGOS TBC checkout used to build mmap_path_query") # AMD FidelityFX FSR2 SDK detection (drop-in under extern/FidelityFX-FSR2) set(WOWEE_AMD_FSR2_DIR ${CMAKE_SOURCE_DIR}/extern/FidelityFX-FSR2) @@ -507,6 +508,7 @@ set(WOWEE_SOURCES src/core/window.cpp src/core/input.cpp src/core/logger.cpp + src/core/crash_diagnostics.cpp src/core/memory_monitor.cpp # Math @@ -1463,6 +1465,106 @@ set_target_properties(auth_login_probe PROPERTIES ) install(TARGETS auth_login_probe RUNTIME DESTINATION bin) +set(WOWEE_HEADLESS_SOURCES ${WOWEE_SOURCES}) +list(REMOVE_ITEM WOWEE_HEADLESS_SOURCES src/main.cpp) +add_executable(wowee_headless + tools/headless_client/main.cpp + ${WOWEE_HEADLESS_SOURCES} +) +target_compile_definitions(wowee_headless PRIVATE WOWEE_HEADLESS_DEFAULT=1) +if(TARGET opcodes-generate) + add_dependencies(wowee_headless opcodes-generate) +endif() +add_dependencies(wowee_headless wowee_version) +target_include_directories(wowee_headless PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_BINARY_DIR}/generated +) +target_include_directories(wowee_headless SYSTEM PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/extern + ${CMAKE_CURRENT_SOURCE_DIR}/extern/vk-bootstrap/src +) +if(HAVE_FFMPEG) + target_compile_definitions(wowee_headless PRIVATE HAVE_FFMPEG) + target_include_directories(wowee_headless PRIVATE ${FFMPEG_INCLUDE_DIRS}) + target_link_libraries(wowee_headless PRIVATE ${FFMPEG_LIBRARIES}) + if(FFMPEG_LIBRARY_DIRS) + target_link_directories(wowee_headless PRIVATE ${FFMPEG_LIBRARY_DIRS}) + endif() +endif() +target_link_libraries(wowee_headless PRIVATE + SDL2::SDL2 + Vulkan::Vulkan + OpenSSL::SSL + OpenSSL::Crypto + Threads::Threads + ZLIB::ZLIB + lua51 + ${CMAKE_DL_LIBS} +) +if(UNIX AND NOT APPLE) + target_link_libraries(wowee_headless PRIVATE X11) +endif() +if(WIN32) + target_link_libraries(wowee_headless PRIVATE ws2_32) +endif() +if(TARGET imgui) + target_link_libraries(wowee_headless PRIVATE imgui) +endif() +if(TARGET vk-bootstrap) + target_link_libraries(wowee_headless PRIVATE vk-bootstrap) +endif() +if(TARGET wowee_fsr2_amd_vk) + target_link_libraries(wowee_headless PRIVATE wowee_fsr2_amd_vk) +endif() +if(TARGET wowee_fsr3_framegen_amd_vk_probe) + target_link_libraries(wowee_headless PRIVATE wowee_fsr3_framegen_amd_vk_probe) +endif() +if(HAVE_UNICORN) + target_link_libraries(wowee_headless PRIVATE ${UNICORN_LIBRARY}) + target_include_directories(wowee_headless PRIVATE ${UNICORN_INCLUDE_DIR}) + target_compile_definitions(wowee_headless PRIVATE HAVE_UNICORN) +endif() +if(TARGET glm::glm) + target_link_libraries(wowee_headless PRIVATE glm::glm) +elseif(glm_FOUND) + target_include_directories(wowee_headless PRIVATE ${GLM_INCLUDE_DIRS}) +endif() +set_target_properties(wowee_headless PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin +) +install(TARGETS wowee_headless RUNTIME DESTINATION bin) + +# ---- Tool: mmap_path_query (CMaNGOS mmap/Detour JSON path helper) ---- +set(WOWEE_DETOUR_DIR "${WOWEE_MANGOS_TBC_DIR}/dep/recastnavigation/Detour") +if(EXISTS "${WOWEE_DETOUR_DIR}/Include/DetourNavMesh.h") + add_executable(mmap_path_query + tools/pathfinding_service/mmap_path_query.cpp + ${WOWEE_DETOUR_DIR}/Source/DetourAlloc.cpp + ${WOWEE_DETOUR_DIR}/Source/DetourAssert.cpp + ${WOWEE_DETOUR_DIR}/Source/DetourCommon.cpp + ${WOWEE_DETOUR_DIR}/Source/DetourNavMesh.cpp + ${WOWEE_DETOUR_DIR}/Source/DetourNavMeshBuilder.cpp + ${WOWEE_DETOUR_DIR}/Source/DetourNavMeshQuery.cpp + ${WOWEE_DETOUR_DIR}/Source/DetourNode.cpp + ) + target_include_directories(mmap_path_query PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/extern + ${WOWEE_DETOUR_DIR}/Include + ) + target_compile_definitions(mmap_path_query PRIVATE DT_POLYREF64) + set_target_properties(mmap_path_query PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin + ) + install(TARGETS mmap_path_query RUNTIME DESTINATION bin) + message(STATUS " mmap_path_query tool: ENABLED (${WOWEE_MANGOS_TBC_DIR})") +else() + message(STATUS " mmap_path_query tool: DISABLED (set WOWEE_MANGOS_TBC_DIR to a CMaNGOS TBC checkout)") +endif() + # ---- Tool: blp_convert (BLP ↔ PNG) ---- add_executable(blp_convert tools/blp_convert/main.cpp diff --git a/include/core/crash_diagnostics.hpp b/include/core/crash_diagnostics.hpp new file mode 100644 index 000000000..98612cde0 --- /dev/null +++ b/include/core/crash_diagnostics.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +namespace wowee::core { + +void installCrashDiagnostics(const char* processLabel); +void setCrashBreadcrumb(const char* phase, + uint16_t wireOpcode, + const char* logicalOpcode, + std::size_t packetSize, + std::size_t readPos, + int worldState); +void setCrashNote(const char* note); + +} // namespace wowee::core diff --git a/include/game/entity_controller.hpp b/include/game/entity_controller.hpp index cac4291a6..6c4745363 100644 --- a/include/game/entity_controller.hpp +++ b/include/game/entity_controller.hpp @@ -35,6 +35,9 @@ class EntityController { void queryPlayerName(uint64_t guid); void queryCreatureInfo(uint32_t entry, uint64_t guid); void queryGameObjectInfo(uint32_t entry, uint64_t guid); + void cachePlayerName(uint64_t guid, const std::string& name) { + if (guid != 0 && !name.empty()) playerNameCache[guid] = name; + } std::string getCachedPlayerName(uint64_t guid) const; std::string getCachedCreatureName(uint32_t entry) const; void invalidatePlayerName(uint64_t guid) { playerNameCache.erase(guid); } diff --git a/include/game/faction_hostility.hpp b/include/game/faction_hostility.hpp index df0a0e386..e48dfd078 100644 --- a/include/game/faction_hostility.hpp +++ b/include/game/faction_hostility.hpp @@ -16,9 +16,11 @@ class GameHandler; // GameHandler::isHostileFaction() defaults an unrecognized faction template // ID to hostile=true (a reasonable default once the map is actually // populated, since anything left out by the DBC-driven loop below is -// genuinely unusual). Call this once per session, as soon as playerRace is -// known and the asset manager is initialized, or every faction template -// will fall through to that hostile default. +// genuinely unusual). The headless client never called this, so the map +// stayed permanently empty and *everything* read as hostile - live-observed +// at Menethil Harbor as ~20 false-positive "threats" including town guards, +// friendly party bots, and a critter. Call this once per session, as soon +// as playerRace is known and the asset manager is initialized. void buildFactionHostilityMap(pipeline::AssetManager& assetManager, GameHandler& gameHandler, uint8_t playerRace); } // namespace game diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index d1b797937..cc8580441 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -495,6 +495,7 @@ class GameHandler : public IConnectionState, // Instance difficulty void sendSetDifficulty(uint32_t difficulty); bool isLoggingOut() const; + bool isLogoutComplete() const; float getLogoutCountdown() const; // Stand state @@ -648,6 +649,9 @@ class GameHandler : public IConnectionState, void queryPlayerName(uint64_t guid); void queryCreatureInfo(uint32_t entry, uint64_t guid); void queryGameObjectInfo(uint32_t entry, uint64_t guid); + void cachePlayerName(uint64_t guid, const std::string& name) { + entityController_->cachePlayerName(guid, name); + } const GameObjectQueryResponseData* getCachedGameObjectInfo(uint32_t entry) const { return entityController_->getCachedGameObjectInfo(entry); } @@ -1192,6 +1196,7 @@ class GameHandler : public IConnectionState, bool hasServerTransportUpdate(uint64_t guid) const { return entityController_->hasServerTransportUpdate(guid); } glm::vec3 getComposedWorldPosition(); // Compose transport transform * local offset TransportManager* getTransportManager() { return transportManager_.get(); } + const TransportManager* getTransportManager() const { return transportManager_.get(); } // Client-side M2 transport (trams, lifts) board/disembark check by proximity to the // transport's live position. Call once per tick with the player's current canonical // world position; safe to call whether or not any M2 transports are registered. @@ -1307,6 +1312,10 @@ class GameHandler : public IConnectionState, outY = corpseX_; // server X = canonical Y (west) return true; } + /** Corpse Z (up); only meaningful when getCorpseCanonicalPos() returns true. */ + float getCorpseZ() const { return corpseZ_; } + /** Map the corpse is on, or 0 if no corpse data yet. */ + uint32_t getCorpseMapId() const { return corpseMapId_; } /** Send CMSG_RECLAIM_CORPSE; noop if not a ghost or not near corpse. */ void reclaimCorpse(); void releaseSpirit(); @@ -1515,6 +1524,10 @@ class GameHandler : public IConnectionState, // NPC Gossip void interactWithNpc(uint64_t guid); void interactWithGameObject(uint64_t guid); + // Sends CMSG_TAXIQUERYAVAILABLENODES directly (what a real client sends + // when clicking a flight master, instead of CMSG_GOSSIP_HELLO) - see + // queryTaxiNodes() usage in headless_client/main.cpp for why. + void queryTaxiNodes(uint64_t guid); void selectGossipOption(uint32_t optionId); void selectGossipQuest(uint32_t questId); void acceptQuest(); @@ -2023,6 +2036,7 @@ class GameHandler : public IConnectionState, using TaxiPathEdge = MovementHandler::TaxiPathEdge; using TaxiPathNode = MovementHandler::TaxiPathNode; const std::unordered_map& getTaxiNodes() const; + const std::vector& getTaxiPathEdges() const; bool isKnownTaxiNode(uint32_t nodeId) const; uint32_t getTaxiCostTo(uint32_t destNodeId) const; bool taxiNpcHasRoutes(uint64_t guid) const { diff --git a/include/game/game_utils.hpp b/include/game/game_utils.hpp index e0f0ac740..f92cbf60e 100644 --- a/include/game/game_utils.hpp +++ b/include/game/game_utils.hpp @@ -2,11 +2,16 @@ #include "game/expansion_profile.hpp" #include "core/application.hpp" +#include +#include namespace wowee { namespace game { inline bool isActiveExpansion(const char* expansionId) { + if (const char* env = std::getenv("WOWEE_ACTIVE_EXPANSION")) { + if (*env) return std::string(env) == expansionId; + } auto& app = core::Application::getInstance(); auto* registry = app.getExpansionRegistry(); if (!registry) return false; diff --git a/include/game/movement_handler.hpp b/include/game/movement_handler.hpp index 20472593f..f0b93cf39 100644 --- a/include/game/movement_handler.hpp +++ b/include/game/movement_handler.hpp @@ -130,6 +130,7 @@ class MovementHandler { }; const std::unordered_map& getTaxiNodes() const { return taxiNodes_; } + const std::vector& getTaxiPathEdges() const { return taxiPathEdges_; } // WotLK 3.3.5a TaxiNodes.dbc has 384 entries; the known-taxi bitmask // is 12 × uint32 = 384 bits. Node IDs outside this range are invalid. static constexpr uint32_t kMaxTaxiNodeId = 384; diff --git a/include/game/protocol_constants.hpp b/include/game/protocol_constants.hpp index 0be5dcd6b..1e6b5ef13 100644 --- a/include/game/protocol_constants.hpp +++ b/include/game/protocol_constants.hpp @@ -88,7 +88,8 @@ constexpr uint32_t SPELL_SHADOWFORM = 15473; constexpr uint32_t RX_SILENCE_WARNING_MS = 10000; // 10 s constexpr uint32_t RX_SILENCE_CRITICAL_MS = 15000; // 15 s constexpr float WARDEN_GATE_LOG_INTERVAL_SEC = 30.0f; -constexpr float CLASSIC_PING_INTERVAL_SEC = 10.0f; +// CMaNGOS classic/TBC treats pings under ~27s apart as overspeed pings. +constexpr float CLASSIC_PING_INTERVAL_SEC = 30.0f; // --------------------------------------------------------------------------- // Heartbeat / area-trigger intervals (seconds) diff --git a/include/game/social_handler.hpp b/include/game/social_handler.hpp index 149245b4c..5dcbd06d3 100644 --- a/include/game/social_handler.hpp +++ b/include/game/social_handler.hpp @@ -109,6 +109,10 @@ class SocialHandler { void cancelLogout(); bool isLoggingOut() const { return loggingOut_; } float getLogoutCountdown() const { return logoutCountdown_; } + // Distinct from isLoggingOut(), which goes back to false once the + // logout finishes - this stays true so callers (e.g. the headless + // client's shutdown loop) can tell "never logged out" from "done". + bool isLogoutComplete() const { return logoutComplete_; } bool isLogoutToExit() const { return exitAfterLogout_; } // Guild @@ -368,6 +372,7 @@ class SocialHandler { // Logout bool loggingOut_ = false; + bool logoutComplete_ = false; bool exitAfterLogout_ = false; float logoutCountdown_ = 0.0f; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 8c15d3b8b..7bbad982e 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1157,8 +1157,11 @@ namespace GuildEvent { constexpr uint8_t LEADER_IS = 6; constexpr uint8_t LEADER_CHANGED = 7; constexpr uint8_t DISBANDED = 8; - constexpr uint8_t SIGNED_ON = 14; - constexpr uint8_t SIGNED_OFF = 15; + constexpr uint8_t TABARD_CHANGED = 9; + constexpr uint8_t SIGNED_ON = 12; + constexpr uint8_t SIGNED_OFF = 13; + constexpr uint8_t GUILD_BANK_BAG_SLOTS_CHANGED = 14; + constexpr uint8_t BANK_TAB_PURCHASED = 15; } /** SMSG_GUILD_QUERY_RESPONSE data */ @@ -2202,6 +2205,12 @@ class QuestgiverHelloPacket { static network::Packet build(uint64_t npcGuid); }; +/** CMSG_TAXIQUERYAVAILABLENODES packet builder */ +class TaxiQueryAvailableNodesPacket { +public: + static network::Packet build(uint64_t npcGuid); +}; + /** CMSG_GOSSIP_SELECT_OPTION packet builder */ class GossipSelectOptionPacket { public: diff --git a/include/pipeline/asset_manager.hpp b/include/pipeline/asset_manager.hpp index 15dcdb9c8..5838fcfbe 100644 --- a/include/pipeline/asset_manager.hpp +++ b/include/pipeline/asset_manager.hpp @@ -44,6 +44,15 @@ class AssetManager { */ bool switchDataPath(const std::string& newDataPath); + /** + * Initialize only enough of the asset manager to load loose DBC files. + * This is intended for headless/tooling paths that do not need a full + * manifest-backed asset extraction. + * @param dataPath Path checked for db/ and DBFilesClient/ DBC files + * @return true if initialization succeeded + */ + bool initializeDbcOnly(const std::string& dataPath); + /** * Shutdown and cleanup */ @@ -64,9 +73,9 @@ class AssetManager { BLPImage loadTexture(const std::string& path); /** - * Set expansion-specific data path for CSV DBC lookup. - * When set, loadDBC() checks expansionDataPath/db/Name.csv before - * falling back to the manifest (binary DBC from extracted MPQs). + * Set expansion-specific data path for DBC lookup. + * When set, loadDBC() checks expansionDataPath/DBFilesClient and + * expansionDataPath/db before falling back to the default Data dataset. */ void setExpansionDataPath(const std::string& path); diff --git a/include/rendering/animation/emote_registry.hpp b/include/rendering/animation/emote_registry.hpp index fac30a46d..050810fc5 100644 --- a/include/rendering/animation/emote_registry.hpp +++ b/include/rendering/animation/emote_registry.hpp @@ -7,6 +7,7 @@ #include namespace wowee { +namespace pipeline { class AssetManager; } namespace rendering { // ============================================================================ @@ -33,6 +34,7 @@ class EmoteRegistry { /// Load emotes from DBC files (called once on first use). void loadFromDbc(); + void loadFromDbc(pipeline::AssetManager* assetManager); struct EmoteResult { uint32_t animId; bool loop; }; diff --git a/mangos-helper.sudoers b/mangos-helper.sudoers new file mode 100644 index 000000000..bd094f972 --- /dev/null +++ b/mangos-helper.sudoers @@ -0,0 +1,146 @@ +# Project sudo rules for the Codex MaNGOS helper account. +# Install with: +# sudo visudo -f /etc/sudoers.d/mangos-helper +# +# Scope: CMaNGOS services, LLM bridge service, project config files, +# MiniManager PHP files, project logs, and MaNGOS/MySQL maintenance. + +Defaults:mangos-helper !requiretty +Defaults:www-data !requiretty + +Cmnd_Alias MINIMANAGER_IRC_USER = /usr/local/sbin/minimanager-irc-user * + +Cmnd_Alias PROJECT_SYSTEMCTL = \ + /bin/systemctl daemon-reload, \ + /bin/systemctl status cmangos-mangosd, \ + /bin/systemctl start cmangos-mangosd, \ + /bin/systemctl stop cmangos-mangosd, \ + /bin/systemctl restart cmangos-mangosd, \ + /bin/systemctl status cmangos-realmd, \ + /bin/systemctl start cmangos-realmd, \ + /bin/systemctl stop cmangos-realmd, \ + /bin/systemctl restart cmangos-realmd, \ + /bin/systemctl status mysql, \ + /bin/systemctl restart mysql, \ + /bin/systemctl status llm-bridge.service, \ + /bin/systemctl start llm-bridge.service, \ + /bin/systemctl stop llm-bridge.service, \ + /bin/systemctl restart llm-bridge.service, \ + /bin/systemctl enable llm-bridge.service, \ + /bin/systemctl disable llm-bridge.service, \ + /bin/systemctl status thelounge.service, \ + /bin/systemctl start thelounge.service, \ + /bin/systemctl stop thelounge.service, \ + /bin/systemctl restart thelounge.service, \ + /bin/systemctl enable thelounge.service, \ + /bin/systemctl disable thelounge.service, \ + /bin/systemctl status ergo.service, \ + /bin/systemctl start ergo.service, \ + /bin/systemctl stop ergo.service, \ + /bin/systemctl restart ergo.service, \ + /bin/systemctl enable ergo.service, \ + /bin/systemctl disable ergo.service, \ + /bin/systemctl status nginx.service, \ + /bin/systemctl reload nginx.service, \ + /bin/systemctl restart nginx.service + +Cmnd_Alias PROJECT_JOURNAL = \ + /usr/bin/journalctl + +Cmnd_Alias PROJECT_DB = \ + /usr/bin/mysql, \ + /usr/bin/mysqladmin, \ + /usr/bin/mysqlcheck, \ + /usr/bin/myisamchk + +Cmnd_Alias PROJECT_EDIT = \ + sudoedit /home/josh/mangos-tbc/run/etc/*.conf, \ + sudoedit /etc/systemd/system/llm-bridge.service, \ + sudoedit /etc/systemd/system/ergo.service, \ + sudoedit /etc/systemd/system/thelounge.service.d/*.conf, \ + sudoedit /var/www/minimanager/*.php, \ + sudoedit /var/www/minimanager/*/*.php, \ + sudoedit /var/www/minimanager/config/*.php, \ + sudoedit /var/www/minimanager/config/*.dist, \ + sudoedit /var/www/minimanager/lang/*.php, \ + sudoedit /var/www/minimanager/core/themes/*/*.css, \ + sudoedit /etc/nginx/sites-available/blizzcms.conf, \ + sudoedit /var/www/blizzcms/application/config/*.php, \ + sudoedit /var/www/blizzcms/application/controllers/*.php, \ + sudoedit /var/www/blizzcms/application/views/*.php, \ + sudoedit /var/www/blizzcms/application/views/*/*.php + +Cmnd_Alias PROJECT_INSTALL = \ + /usr/bin/install -m 0644 -o josh -g josh /tmp/*.conf /home/josh/mangos-tbc/run/etc/*.conf, \ + /usr/bin/install -m 0644 -o mangos-helper -g mangos-helper /tmp/llm_bridge.py /home/mangos-helper/llm-bridge/llm_bridge.py, \ + /usr/bin/install -m 0644 -o root -g root /tmp/llm-bridge.service /etc/systemd/system/llm-bridge.service, \ + /usr/bin/install -m 0644 -o root -g root /tmp/ergo.service /etc/systemd/system/ergo.service, \ + /usr/bin/install -m 0755 -o root -g root /tmp/minimanager-irc-user /usr/local/sbin/minimanager-irc-user, \ + /usr/bin/install -d -m 0755 -o root -g root /etc/systemd/system/thelounge.service.d, \ + /usr/bin/install -m 0644 -o root -g root /tmp/thelounge-override.conf /etc/systemd/system/thelounge.service.d/override.conf, \ + /usr/bin/install -d -m 0755 -o www-data -g www-data /var/www/minimanager/img/zone, \ + /usr/bin/install -m 0644 -o www-data -g www-data /tmp/*.png /var/www/minimanager/img/zone/*.png, \ + /usr/bin/install -m 0644 -o www-data -g www-data /tmp/*.php /var/www/minimanager/*.php, \ + /usr/bin/install -m 0644 -o www-data -g www-data /tmp/*.php /var/www/minimanager/*/*.php, \ + /usr/bin/install -m 0644 -o www-data -g www-data /tmp/*.dist /var/www/minimanager/config/*.dist, \ + /usr/bin/install -m 0644 -o www-data -g www-data /tmp/*.css /var/www/minimanager/core/themes/*/*.css, \ + /usr/bin/install -m 0644 -o root -g root /tmp/blizzcms.conf /etc/nginx/sites-available/blizzcms.conf, \ + /usr/bin/install -m 0644 -o www-data -g www-data /tmp/*.php /var/www/blizzcms/application/config/*.php, \ + /usr/bin/install -m 0644 -o www-data -g www-data /tmp/*.php /var/www/blizzcms/application/controllers/*.php, \ + /usr/bin/install -m 0644 -o www-data -g www-data /tmp/*.php /var/www/blizzcms/application/views/*.php, \ + /usr/bin/install -m 0644 -o www-data -g www-data /tmp/*.php /var/www/blizzcms/application/views/*/*.php, \ + /usr/bin/install -m 0750 -o root -g root /tmp/query_world_db.sh /home/josh/mangos-tbc/tools/query_world_db.sh + +Cmnd_Alias PROJECT_BACKUP = \ + /usr/bin/cp /home/josh/mangos-tbc/run/etc/*.conf /home/josh/mangos-tbc/run/etc/*.codex-backup, \ + /usr/bin/cp /home/mangos-helper/llm-bridge/llm_bridge.py /home/mangos-helper/llm-bridge/llm_bridge.py.codex-backup, \ + /usr/bin/cp /etc/systemd/system/llm-bridge.service /etc/systemd/system/llm-bridge.service.codex-backup, \ + /usr/bin/cp /etc/nginx/sites-available/blizzcms.conf /etc/nginx/sites-available/blizzcms.conf.codex-backup, \ + /usr/bin/cp /var/www/blizzcms/application/config/*.php /var/www/blizzcms/application/config/*.codex-backup, \ + /usr/bin/cp /var/www/blizzcms/application/controllers/*.php /var/www/blizzcms/application/controllers/*.codex-backup, \ + /usr/bin/cp /var/www/blizzcms/application/views/*.php /var/www/blizzcms/application/views/*.codex-backup, \ + /usr/bin/cp /var/www/blizzcms/application/views/*/*.php /var/www/blizzcms/application/views/*/*.codex-backup, \ + /usr/bin/cp /var/www/minimanager/core/themes/*/*.css /var/www/minimanager/core/themes/*/*.codex-backup, \ + /usr/bin/cp /var/www/minimanager/*.php /var/www/minimanager/*.codex-backup, \ + /usr/bin/cp /var/www/minimanager/*/*.php /var/www/minimanager/*/*.codex-backup, \ + /usr/sbin/nginx -t, \ + /usr/bin/sha256sum /home/josh/mangos-tbc/run/etc/*.conf, \ + /usr/bin/sha256sum /home/mangos-helper/llm-bridge/llm_bridge.py, \ + /usr/bin/sha256sum /etc/systemd/system/llm-bridge.service, \ + /usr/bin/sha256sum /etc/systemd/system/ergo.service, \ + /usr/bin/sha256sum /etc/systemd/system/thelounge.service.d/override.conf, \ + /usr/bin/sha256sum /usr/local/sbin/minimanager-irc-user, \ + /usr/bin/sha256sum /etc/nginx/sites-available/blizzcms.conf, \ + /usr/bin/sha256sum /var/www/blizzcms/application/config/*.php, \ + /usr/bin/sha256sum /var/www/blizzcms/application/controllers/*.php, \ + /usr/bin/sha256sum /var/www/blizzcms/application/views/*.php, \ + /usr/bin/sha256sum /var/www/blizzcms/application/views/*/*.php, \ + /usr/bin/sha256sum /var/www/minimanager/core/themes/*/*.css, \ + /usr/bin/sha256sum /var/www/minimanager/*.php, \ + /usr/bin/sha256sum /var/www/minimanager/*/*.php + +Cmnd_Alias PROJECT_PROCESS = \ + /usr/bin/fuser -k 5001/tcp, \ + /usr/bin/fuser -k 8765/tcp, \ + /usr/bin/fuser -k 6667/tcp, \ + /usr/bin/fuser -k 6697/tcp, \ + /usr/bin/fuser -k 8097/tcp + +# MoveMapGen writes into /home/josh/mangos-tbc/run/{mmaps,vmaps}, which are +# owned by josh:josh and not group-writable by mangos-helper. Runs as root so +# ownership doesn't matter. Scoped to this one purpose-built binary only - +# not a general file-write grant. +Cmnd_Alias PROJECT_MMAP_GEN = \ + /home/josh/mangos-tbc/build/contrib/mmap/MoveMapGen * + +# Lets mangos-helper run a single read-only SELECT against the world DB +# (see tools/world_db_query/ in the WoWee repo) without ever being handed +# the DB credentials in mangosd.conf directly - the wrapper script reads +# them internally (it must run as root since mangosd.conf isn't readable +# by mangos-helper) and enforces SELECT-only, single-statement queries. +# Scoped to this one script path only - not a general mysql/root grant. +Cmnd_Alias PROJECT_WORLD_DB_QUERY = \ + /home/josh/mangos-tbc/tools/query_world_db.sh * + +mangos-helper ALL=(root) NOPASSWD: PROJECT_SYSTEMCTL, PROJECT_JOURNAL, PROJECT_DB, PROJECT_EDIT, PROJECT_INSTALL, PROJECT_BACKUP, PROJECT_PROCESS, PROJECT_MMAP_GEN, PROJECT_WORLD_DB_QUERY, MINIMANAGER_IRC_USER +www-data ALL=(root) NOPASSWD: MINIMANAGER_IRC_USER diff --git a/src/core/crash_diagnostics.cpp b/src/core/crash_diagnostics.cpp new file mode 100644 index 000000000..831fb1f1c --- /dev/null +++ b/src/core/crash_diagnostics.cpp @@ -0,0 +1,161 @@ +#include "core/crash_diagnostics.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +namespace wowee::core { +namespace { + +struct CrashBreadcrumb { + char label[128] = {}; + char phase[96] = {}; + char logicalOpcode[96] = {}; + char note[192] = {}; + uint16_t wireOpcode = 0; + std::size_t packetSize = 0; + std::size_t readPos = 0; + int worldState = 0; + uint64_t sequence = 0; +}; + +std::mutex g_crashMutex; +CrashBreadcrumb g_crashBreadcrumb; +std::atomic g_sequence{0}; +std::atomic g_installed{false}; + +void copyString(char* dest, std::size_t destSize, const char* src) { + if (!dest || destSize == 0) return; + if (!src) src = ""; + std::snprintf(dest, destSize, "%s", src); +} + +CrashBreadcrumb snapshotBreadcrumb() { + std::lock_guard lock(g_crashMutex); + return g_crashBreadcrumb; +} + +std::string timestampUtc() { + std::time_t now = std::time(nullptr); + std::tm tm{}; +#ifdef _WIN32 + gmtime_s(&tm, &now); +#else + gmtime_r(&now, &tm); +#endif + char buffer[32] = {}; + std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &tm); + return buffer; +} + +void writeReport(std::ostream& os, const char* reason, const CrashBreadcrumb& crumb, void* faultAddress) { + os << "\n=== wowee_headless crash report ===\n"; + os << "time=" << timestampUtc() << "\n"; + os << "process=" << crumb.label << "\n"; + os << "reason=" << (reason ? reason : "unknown") << "\n"; + if (faultAddress) os << "faultAddress=" << faultAddress << "\n"; + os << "breadcrumb.sequence=" << crumb.sequence << "\n"; + os << "breadcrumb.phase=" << crumb.phase << "\n"; + os << "breadcrumb.logicalOpcode=" << crumb.logicalOpcode << "\n"; + os << "breadcrumb.wireOpcode=0x" << std::hex << crumb.wireOpcode << std::dec << "\n"; + os << "breadcrumb.packetSize=" << crumb.packetSize << "\n"; + os << "breadcrumb.readPos=" << crumb.readPos << "\n"; + os << "breadcrumb.worldState=" << crumb.worldState << "\n"; + os << "breadcrumb.note=" << crumb.note << "\n"; + +#ifdef _WIN32 + void* frames[48] = {}; + USHORT frameCount = CaptureStackBackTrace(0, 48, frames, nullptr); + os << "stack.frames=" << frameCount << "\n"; + for (USHORT i = 0; i < frameCount; ++i) { + os << "stack[" << i << "]=" << frames[i] << "\n"; + } +#endif + os << "=== end crash report ===\n"; + os.flush(); +} + +void writeCrashReport(const char* reason, void* faultAddress) { + CrashBreadcrumb crumb = snapshotBreadcrumb(); + writeReport(std::cerr, reason, crumb, faultAddress); + + const char* crashLog = std::getenv("WOWEE_CRASH_LOG"); + if (crashLog && *crashLog) { + std::ofstream file(crashLog, std::ios::app); + if (file) writeReport(file, reason, crumb, faultAddress); + } +} + +void signalCrashHandler(int sig) { + writeCrashReport(sig == SIGABRT ? "signal SIGABRT" : "signal SIGSEGV", nullptr); + std::_Exit(128 + sig); +} + +#ifdef _WIN32 +LONG WINAPI unhandledExceptionFilter(EXCEPTION_POINTERS* info) { + void* faultAddress = nullptr; + DWORD code = 0; + if (info && info->ExceptionRecord) { + faultAddress = info->ExceptionRecord->ExceptionAddress; + code = info->ExceptionRecord->ExceptionCode; + } + char reason[64] = {}; + std::snprintf(reason, sizeof(reason), "unhandled exception 0x%08lx", static_cast(code)); + writeCrashReport(reason, faultAddress); + return EXCEPTION_EXECUTE_HANDLER; +} +#endif + +} // namespace + +void installCrashDiagnostics(const char* processLabel) { + { + std::lock_guard lock(g_crashMutex); + copyString(g_crashBreadcrumb.label, sizeof(g_crashBreadcrumb.label), processLabel); + copyString(g_crashBreadcrumb.phase, sizeof(g_crashBreadcrumb.phase), "startup"); + } + + bool expected = false; + if (!g_installed.compare_exchange_strong(expected, true)) return; + + std::signal(SIGSEGV, signalCrashHandler); + std::signal(SIGABRT, signalCrashHandler); +#ifdef _WIN32 + SetUnhandledExceptionFilter(unhandledExceptionFilter); +#endif +} + +void setCrashBreadcrumb(const char* phase, + uint16_t wireOpcode, + const char* logicalOpcode, + std::size_t packetSize, + std::size_t readPos, + int worldState) { + std::lock_guard lock(g_crashMutex); + g_crashBreadcrumb.sequence = ++g_sequence; + copyString(g_crashBreadcrumb.phase, sizeof(g_crashBreadcrumb.phase), phase); + copyString(g_crashBreadcrumb.logicalOpcode, sizeof(g_crashBreadcrumb.logicalOpcode), logicalOpcode); + g_crashBreadcrumb.wireOpcode = wireOpcode; + g_crashBreadcrumb.packetSize = packetSize; + g_crashBreadcrumb.readPos = readPos; + g_crashBreadcrumb.worldState = worldState; +} + +void setCrashNote(const char* note) { + std::lock_guard lock(g_crashMutex); + copyString(g_crashBreadcrumb.note, sizeof(g_crashBreadcrumb.note), note); +} + +} // namespace wowee::core diff --git a/src/core/logger.cpp b/src/core/logger.cpp index 27162dfd1..e39692b19 100644 --- a/src/core/logger.cpp +++ b/src/core/logger.cpp @@ -95,6 +95,7 @@ void Logger::emitLineLocked(LogLevel level, const std::string& message) { if (echoToStdout_) { std::cout << line.str() << '\n'; + std::cout.flush(); } if (fileStream.is_open()) { fileStream << line.str() << '\n'; diff --git a/src/game/chat_handler.cpp b/src/game/chat_handler.cpp index 4f1c50e86..588716c86 100644 --- a/src/game/chat_handler.cpp +++ b/src/game/chat_handler.cpp @@ -7,6 +7,7 @@ #include "network/world_socket.hpp" #include "rendering/renderer.hpp" #include "rendering/animation_controller.hpp" +#include "core/crash_diagnostics.hpp" #include "core/logger.hpp" #include #include @@ -21,6 +22,41 @@ namespace wowee { namespace game { +namespace { + +bool equalNameIgnoreCase(const std::string& a, const std::string& b) { + return a.size() == b.size() && + std::equal(a.begin(), a.end(), b.begin(), [](unsigned char ca, unsigned char cb) { + return std::tolower(ca) == std::tolower(cb); + }); +} + +uint64_t findGuidByKnownName(GameHandler& owner, const std::string& name) { + if (name.empty()) return 0; + + if (const auto* active = owner.getActiveCharacter()) { + if (equalNameIgnoreCase(active->name, name)) { + return owner.getPlayerGuid(); + } + } + + for (const auto& [guid, cachedName] : owner.getPlayerNameCache()) { + if (equalNameIgnoreCase(cachedName, name)) { + return guid; + } + } + + for (const auto& member : owner.getPartyData().members) { + if (equalNameIgnoreCase(member.name, name)) { + return member.guid; + } + } + + return 0; +} + +} // namespace + ChatHandler::ChatHandler(GameHandler& owner) : owner_(owner) { initializeChatLog(); @@ -97,6 +133,18 @@ bool chatPacketDiagEnabled() { return enabled; } +bool headlessModeEnabled() { +#ifdef WOWEE_HEADLESS_DEFAULT + return true; +#else + static const bool enabled = [] { + const char* raw = std::getenv("WOWEE_HEADLESS"); + return raw && *raw && !isFalsyEnvValue(raw); + }(); + return enabled; +#endif +} + } // namespace void ChatHandler::initializeChatLog() { @@ -302,7 +350,12 @@ void ChatHandler::sendChatMessage(ChatType type, const std::string& message, con " target='", target, "' bytes=", raw.size(), " data=[", raw.empty() ? std::string{} : core::toHexString(raw.data(), raw.size(), true), "]"); } - owner_.getSocket()->send(packet); + auto* sock = owner_.getSocket(); + if (!sock) { + LOG_WARNING("Cannot send chat: socket is null"); + return; + } + sock->send(packet); // Add local echo so the player sees their own message immediately MessageChatData echo; @@ -619,15 +672,23 @@ void ChatHandler::sendTextEmote(uint32_t textEmoteId, uint64_t targetGuid) { void ChatHandler::handleTextEmote(network::Packet& packet) { const bool legacyFormat = isClassicLikeExpansion(); TextEmoteData data; + wowee::core::setCrashNote("SMSG_TEXT_EMOTE: parse"); if (!TextEmoteParser::parse(packet, data, legacyFormat)) { LOG_WARNING("Failed to parse SMSG_TEXT_EMOTE"); return; } + wowee::core::setCrashBreadcrumb("SMSG_TEXT_EMOTE:parsed", + packet.getOpcode(), + "SMSG_TEXT_EMOTE", + packet.getSize(), + packet.getReadPos(), + static_cast(owner_.getState())); if (data.senderGuid == owner_.getPlayerGuid() && data.senderGuid != 0) { return; } + wowee::core::setCrashNote("SMSG_TEXT_EMOTE: sender lookup"); std::string senderName = owner_.lookupName(data.senderGuid); if (senderName.empty()) { const auto& partyData = owner_.getPartyData(); @@ -639,8 +700,13 @@ void ChatHandler::handleTextEmote(network::Packet& packet) { } } - uint32_t animId = rendering::AnimationController::getEmoteAnimByDbcId(data.textEmoteId); - if (animId != 0 && owner_.emoteAnimCallbackRef()) { + uint32_t animId = 0; + if (!headlessModeEnabled()) { + wowee::core::setCrashNote("SMSG_TEXT_EMOTE: animation lookup"); + animId = rendering::AnimationController::getEmoteAnimByDbcId(data.textEmoteId); + } + if (!headlessModeEnabled() && animId != 0 && owner_.emoteAnimCallbackRef()) { + wowee::core::setCrashNote("SMSG_TEXT_EMOTE: animation callback"); owner_.emoteAnimCallbackRef()(data.senderGuid, animId); } @@ -653,11 +719,15 @@ void ChatHandler::handleTextEmote(network::Packet& packet) { } const std::string* targetPtr = data.targetName.empty() ? nullptr : &data.targetName; - std::string emoteText = rendering::AnimationController::getEmoteTextByDbcId(data.textEmoteId, senderName, targetPtr); + std::string emoteText; + if (!headlessModeEnabled()) { + wowee::core::setCrashNote("SMSG_TEXT_EMOTE: text lookup"); + emoteText = rendering::AnimationController::getEmoteTextByDbcId(data.textEmoteId, senderName, targetPtr); + } if (emoteText.empty()) { emoteText = data.targetName.empty() - ? senderName + " performs an emote." - : senderName + " performs an emote at " + data.targetName + "."; + ? senderName + " performs text emote " + std::to_string(data.textEmoteId) + "." + : senderName + " performs text emote " + std::to_string(data.textEmoteId) + " at " + data.targetName + "."; } MessageChatData chatMsg; @@ -665,11 +735,16 @@ void ChatHandler::handleTextEmote(network::Packet& packet) { chatMsg.language = ChatLanguage::COMMON; chatMsg.senderGuid = data.senderGuid; chatMsg.senderName = senderName; + chatMsg.receiverGuid = findGuidByKnownName(owner_, data.targetName); + chatMsg.receiverName = data.targetName; + chatMsg.channelName = "TEXT_EMOTE:" + std::to_string(data.textEmoteId); chatMsg.message = emoteText; + wowee::core::setCrashNote("SMSG_TEXT_EMOTE: add chat"); addLocalChatMessage(chatMsg); LOG_INFO("TEXT_EMOTE from ", senderName, " (emoteId=", data.textEmoteId, ", anim=", animId, ")"); + wowee::core::setCrashNote("SMSG_TEXT_EMOTE: done"); } void ChatHandler::joinChannel(const std::string& channelName, const std::string& password) { diff --git a/src/game/entity_controller.cpp b/src/game/entity_controller.cpp index 4a66569b8..604d2192d 100644 --- a/src/game/entity_controller.cpp +++ b/src/game/entity_controller.cpp @@ -46,6 +46,29 @@ bool envFlagEnabled(const char* key, bool defaultValue = false) { raw[0] == 'n' || raw[0] == 'N'); } +bool headlessModeEnabled() { + // WOWEE_HEADLESS must win over the compile-time default when set, same + // as headlessMode() in game_handler_packets.cpp - this copy previously + // checked WOWEE_HEADLESS_DEFAULT first and returned unconditionally, + // so fullWorldSimulation's WOWEE_HEADLESS=0 override never took effect + // here specifically. Live-observed: other players never got created as + // entities (skipped at CREATE_OBJECT), even standing right next to one, + // while regular units/NPCs worked fine (a different, correctly-gated + // code path). + static const bool enabled = []() { + const char* raw = std::getenv("WOWEE_HEADLESS"); + if (raw && *raw) { + return envFlagEnabled("WOWEE_HEADLESS", false); + } +#ifdef WOWEE_HEADLESS_DEFAULT + return true; +#else + return false; +#endif + }(); + return enabled; +} + int parseEnvIntClamped(const char* key, int defaultValue, int minValue, int maxValue) { const char* raw = std::getenv(key); if (!raw || !*raw) return defaultValue; @@ -69,6 +92,27 @@ float slowUpdateObjectBlockLogThresholdMs() { return static_cast(thresholdMs); } +bool transportDebugEnabled() { + static const bool enabled = envFlagEnabled("WOWEE_TRANSPORT_DEBUG", false); + return enabled; +} + +const char* updateTypeDebugName(UpdateType type) { + switch (type) { + case UpdateType::VALUES: return "VALUES"; + case UpdateType::MOVEMENT: return "MOVEMENT"; + case UpdateType::CREATE_OBJECT: return "CREATE_OBJECT"; + case UpdateType::CREATE_OBJECT2: return "CREATE_OBJECT2"; + case UpdateType::OUT_OF_RANGE_OBJECTS: return "OUT_OF_RANGE_OBJECTS"; + case UpdateType::NEAR_OBJECTS: return "NEAR_OBJECTS"; + } + return "UNKNOWN"; +} + +bool isDeeprunSubwayEntry(uint32_t entry) { + return entry >= 176080 && entry <= 176086; +} + } // anonymous namespace EntityController::EntityController(GameHandler& owner) @@ -213,6 +257,55 @@ void EntityController::handleUpdateObject(network::Packet& packet) { // Fall through: process any blocks that were successfully parsed before the failure. } + if (transportDebugEnabled()) { + size_t creates = 0; + size_t movements = 0; + size_t gameObjects = 0; + size_t transportFlagged = 0; + size_t subwayCandidates = 0; + const uint16_t entryIdx = fieldIndex(UF::OBJECT_FIELD_ENTRY); + const uint16_t displayIdx = fieldIndex(UF::GAMEOBJECT_DISPLAYID); + for (const auto& block : data.blocks) { + const bool isCreate = block.updateType == UpdateType::CREATE_OBJECT || + block.updateType == UpdateType::CREATE_OBJECT2; + if (isCreate) ++creates; + if (block.updateType == UpdateType::MOVEMENT) ++movements; + if (block.objectType == ObjectType::GAMEOBJECT) ++gameObjects; + if ((block.updateFlags & 0x0002) != 0) ++transportFlagged; + + uint32_t entry = 0; + uint32_t displayId = 0; + auto entryIt = block.fields.find(entryIdx); + if (entryIt != block.fields.end()) entry = entryIt->second; + auto displayIt = block.fields.find(displayIdx); + if (displayIt != block.fields.end()) displayId = displayIt->second; + + if (isDeeprunSubwayEntry(entry) || displayId == 3831 || (block.objectType == ObjectType::GAMEOBJECT && (block.updateFlags & 0x0002) != 0)) { + ++subwayCandidates; + LOG_INFO("TRANSPORT DEBUG block type=", updateTypeDebugName(block.updateType), + " guid=0x", std::hex, block.guid, std::dec, + " objectType=", static_cast(block.objectType), + " updateFlags=0x", std::hex, block.updateFlags, std::dec, + " entry=", entry, + " displayId=", displayId, + " hasMovement=", block.hasMovement, + " pos=(", block.x, ", ", block.y, ", ", block.z, ")", + " onTransport=", block.onTransport, + " transportGuid=0x", std::hex, block.transportGuid, std::dec); + } + } + if (creates || movements || gameObjects || transportFlagged || !data.outOfRangeGuids.empty()) { + LOG_INFO("TRANSPORT DEBUG update packet blocks=", data.blocks.size(), + " creates=", creates, + " movements=", movements, + " gameObjects=", gameObjects, + " transportFlagged=", transportFlagged, + " subwayCandidates=", subwayCandidates, + " outOfRange=", data.outOfRangeGuids.size(), + " entitiesNow=", entityManager.getEntityCount()); + } + } + enqueueUpdateObjectWork(std::move(data)); } @@ -398,6 +491,15 @@ void EntityController::maybeDetectCoinageIndex(const FlatFieldMap& oldFields, // ============================================================ void EntityController::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated) { + if (headlessModeEnabled() && + block.guid != owner_.getPlayerGuid() && + block.objectType == ObjectType::PLAYER && + (block.updateType == UpdateType::CREATE_OBJECT || + block.updateType == UpdateType::CREATE_OBJECT2)) { + LOG_INFO("Headless skipped remote player create guid=0x", std::hex, block.guid, std::dec); + return; + } + switch (block.updateType) { case UpdateType::CREATE_OBJECT: case UpdateType::CREATE_OBJECT2: @@ -2105,6 +2207,21 @@ void EntityController::handleDestroyObject(network::Packet& packet) { // ============================================================ void EntityController::queryPlayerName(uint64_t guid) { + static const bool headlessAllowRemoteNameQueries = + envFlagEnabled("WOWEE_HEADLESS_QUERY_REMOTE_NAMES", false); + if (headlessModeEnabled() && !headlessAllowRemoteNameQueries && + guid != owner_.getPlayerGuid()) { + LOG_INFO("queryPlayerName: headless skipped remote guid=0x", std::hex, guid, std::dec); + return; + } + + static const bool headlessAllowHighGuidNameQueries = + envFlagEnabled("WOWEE_HEADLESS_QUERY_HIGH_GUIDS", false); + if (headlessModeEnabled() && !headlessAllowHighGuidNameQueries && (guid >> 32) != 0) { + LOG_INFO("queryPlayerName: headless skipped high guid=0x", std::hex, guid, std::dec); + return; + } + // If already cached, apply the name to the entity (handles entity recreation after // moving out/in range — the entity object is new but the cached name is valid). auto cacheIt = playerNameCache.find(guid); @@ -2132,6 +2249,7 @@ void EntityController::queryPlayerName(uint64_t guid) { } void EntityController::queryCreatureInfo(uint32_t entry, uint64_t guid) { + if (headlessModeEnabled()) return; if (creatureInfoCache.count(entry) || pendingCreatureQueries.count(entry)) return; if (!owner_.isInWorld()) return; @@ -2141,6 +2259,7 @@ void EntityController::queryCreatureInfo(uint32_t entry, uint64_t guid) { } void EntityController::queryGameObjectInfo(uint32_t entry, uint64_t guid) { + if (headlessModeEnabled()) return; if (gameObjectInfoCache_.count(entry) || pendingGameObjectQueries_.count(entry)) return; if (!owner_.isInWorld()) return; @@ -2229,6 +2348,11 @@ void EntityController::handleNameQueryResponse(network::Packet& packet) { } void EntityController::handleCreatureQueryResponse(network::Packet& packet) { + if (headlessModeEnabled()) { + packet.skipAll(); + return; + } + CreatureQueryResponseData data; if (!owner_.getPacketParsers()->parseCreatureQueryResponse(packet, data)) return; @@ -2253,6 +2377,11 @@ void EntityController::handleCreatureQueryResponse(network::Packet& packet) { // ============================================================ void EntityController::handleGameObjectQueryResponse(network::Packet& packet) { + if (headlessModeEnabled()) { + packet.skipAll(); + return; + } + GameObjectQueryResponseData data; bool ok = owner_.getPacketParsers() ? owner_.getPacketParsers()->parseGameObjectQueryResponse(packet, data) : GameObjectQueryResponseParser::parse(packet, data); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e833d5fd0..0bbe6ca87 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -662,7 +662,11 @@ void GameHandler::updateTimers(float deltaTime) { // Periodically re-query names for players whose initial CMSG_NAME_QUERY was // lost (server didn't respond) or whose entity was recreated while the query // was still pending. Runs every 5 seconds to keep overhead minimal. - if (isInWorld()) { + static const bool headlessMode = []() { + const char* raw = std::getenv("WOWEE_HEADLESS"); + return raw && *raw && raw[0] != '0'; + }(); + if (!headlessMode && isInWorld()) { static float nameResyncTimer = 0.0f; nameResyncTimer += deltaTime; if (nameResyncTimer >= 5.0f) { @@ -2295,6 +2299,10 @@ bool GameHandler::isLoggingOut() const { return socialHandler_ ? socialHandler_->isLoggingOut() : false; } +bool GameHandler::isLogoutComplete() const { + return socialHandler_ ? socialHandler_->isLogoutComplete() : false; +} + float GameHandler::getLogoutCountdown() const { return socialHandler_ ? socialHandler_->getLogoutCountdown() : 0.0f; } @@ -2697,6 +2705,11 @@ const std::unordered_map& GameHandler::getTaxiN static const std::unordered_map empty; return empty; } +const std::vector& GameHandler::getTaxiPathEdges() const { + if (movementHandler_) return movementHandler_->getTaxiPathEdges(); + static const std::vector empty; + return empty; +} bool GameHandler::isKnownTaxiNode(uint32_t nodeId) const { // Was reading a GameHandler-local knownTaxiMask_ that nothing ever wrote // to (handleShowTaxiNodes only updates MovementHandler's own copy), so diff --git a/src/game/game_handler_callbacks.cpp b/src/game/game_handler_callbacks.cpp index 4df95180a..33f9ed860 100644 --- a/src/game/game_handler_callbacks.cpp +++ b/src/game/game_handler_callbacks.cpp @@ -548,9 +548,12 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { LOG_INFO("========================================"); LOG_INFO("Character GUID: 0x", std::hex, characterGuid, std::dec); + std::string selectedCharacterName; + // Find character name for logging for (const auto& character : characters) { if (character.guid == characterGuid) { + selectedCharacterName = character.name; LOG_INFO("Character: ", character.name); LOG_INFO("Level ", static_cast(character.level), " ", getRaceName(character.race), " ", @@ -628,6 +631,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { lastTargetGuid = 0; tabCycleStale = true; entityController_->clearAll(); + cachePlayerName(characterGuid, selectedCharacterName); // Build CMSG_PLAYER_LOGIN packet auto packet = PlayerLoginPacket::build(characterGuid); @@ -814,8 +818,19 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { // correctly sets the active spec (static locals don't reset across logins). if (spellHandler_) spellHandler_->resetTalentState(); - // Auto-join default chat channels only on first world entry. - autoJoinDefaultChannels(); + // Auto-join default chat channels only on first world entry. The + // headless client opts into channels from its own settings instead. + const bool headlessMode = []() { +#ifdef WOWEE_HEADLESS_DEFAULT + return true; +#else + const char* raw = std::getenv("WOWEE_HEADLESS"); + return raw && *raw && raw[0] != '0'; +#endif + }(); + if (!headlessMode) { + autoJoinDefaultChannels(); + } // Auto-query guild info on login. const Character* activeChar = getActiveCharacter(); @@ -940,8 +955,8 @@ void GameHandler::sendPing() { // Increment sequence number pingSequence++; - LOG_DEBUG("Sending CMSG_PING: sequence=", pingSequence, - " latencyHintMs=", lastLatency); + LOG_INFO("Sending CMSG_PING: sequence=", pingSequence, + " latencyHintMs=", lastLatency); // Record send time for RTT measurement pingTimestamp_ = std::chrono::steady_clock::now(); @@ -1254,8 +1269,8 @@ void GameHandler::handlePong(network::Packet& packet) { lastLatency = static_cast( std::chrono::duration_cast(rtt).count()); - LOG_DEBUG("SMSG_PONG acknowledged: sequence=", data.sequence, - " latencyMs=", lastLatency); + LOG_INFO("SMSG_PONG acknowledged: sequence=", data.sequence, + " latencyMs=", lastLatency); } bool GameHandler::isServerMovementAllowed() const { @@ -2180,6 +2195,12 @@ void GameHandler::interactWithNpc(uint64_t guid) { socket->send(packet); } +void GameHandler::queryTaxiNodes(uint64_t guid) { + if (!isInWorld()) return; + auto packet = TaxiQueryAvailableNodesPacket::build(guid); + socket->send(packet); +} + void GameHandler::interactWithGameObject(uint64_t guid) { LOG_DEBUG("[GO-DIAG] interactWithGameObject called: guid=0x", std::hex, guid, std::dec); if (guid == 0) { LOG_DEBUG("[GO-DIAG] BLOCKED: guid==0"); return; } diff --git a/src/game/game_handler_packets.cpp b/src/game/game_handler_packets.cpp index 2f296eef1..132a40c09 100644 --- a/src/game/game_handler_packets.cpp +++ b/src/game/game_handler_packets.cpp @@ -32,6 +32,7 @@ #include "core/application.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/dbc_loader.hpp" +#include "core/crash_diagnostics.hpp" #include "core/logger.hpp" #include "rendering/animation/animation_ids.hpp" #include @@ -126,6 +127,77 @@ float slowPacketLogThresholdMs() { return static_cast(thresholdMs); } +bool headlessTracePackets() { + static const bool enabled = []() { + const char* raw = std::getenv("WOWEE_HEADLESS_TRACE_PACKETS"); + return raw && *raw && raw[0] != '0'; + }(); + return enabled; +} + +bool headlessMode() { + static const bool enabled = []() { + // WOWEE_HEADLESS lets an individual leader opt back into full world-object + // simulation (SMSG_UPDATE_OBJECT et al, see shouldSkipHeadlessWorldSimulationPacket + // below) even in a WOWEE_HEADLESS_DEFAULT build. Without this, entities + // (other players, NPCs, GameObjects like transports) are never created + // client-side, so anything that needs to see/board them - e.g. the Deeprun + // Tram - is impossible no matter what automation code sits on top. + const char* raw = std::getenv("WOWEE_HEADLESS"); + if (raw && *raw) { + return raw[0] != '0'; + } +#ifdef WOWEE_HEADLESS_DEFAULT + return true; +#else + return false; +#endif + }(); + return enabled; +} + +// Cosmetic/animation packets whose handlers assume a fully initialized rendering +// pipeline (AnimationController, spell visual system, etc). These are skipped +// unconditionally in every headless build, even when WOWEE_HEADLESS=0 opts a +// leader back into object tracking: SMSG_EMOTE's handler was observed to SIGSEGV +// the first time it actually ran under a headless leader, since nothing in this +// build's startup path had wired up whatever it dereferences. None of them are +// needed to track entities/transports, so there's no upside to risking it. +bool shouldAlwaysSkipHeadlessPacket(LogicalOpcode op) { + switch (op) { + case Opcode::MSG_MOVE_HEARTBEAT: + case Opcode::SMSG_EMOTE: + case Opcode::SMSG_SPELL_START: + case Opcode::SMSG_SPELL_GO: + case Opcode::SMSG_SET_EXTRA_AURA_INFO_OBSOLETE: + case Opcode::SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE: + case Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE_OBSOLETE: + return true; + default: + return false; + } +} + +// Object-lifecycle packets that populate EntityManager (see entity.hpp). Skipped +// by default for lightweight bot fleets; WOWEE_HEADLESS=0 (see headlessMode() +// above) re-enables these for a leader that needs to see nearby entities, e.g. +// to board a transport like the Deeprun Tram. +bool shouldSkipHeadlessWorldSimulationPacket(LogicalOpcode op) { + switch (op) { + case Opcode::SMSG_UPDATE_OBJECT: + case Opcode::SMSG_COMPRESSED_UPDATE_OBJECT: + case Opcode::SMSG_DESTROY_OBJECT: + case Opcode::SMSG_MONSTER_MOVE: + case Opcode::SMSG_CREATURE_QUERY_RESPONSE: + case Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE: + case Opcode::SMSG_ITEM_QUERY_SINGLE_RESPONSE: + case Opcode::SMSG_ITEM_QUERY_MULTIPLE_RESPONSE: + return true; + default: + return false; + } +} + constexpr size_t kMaxQueuedInboundPackets = 4096; } // end anonymous namespace @@ -2830,9 +2902,60 @@ void GameHandler::handlePacket(network::Packet& packet) { } // Dispatch via the opcode handler table + if (state == WorldState::IN_WORLD && shouldAlwaysSkipHeadlessPacket(*logicalOp)) { + const std::string logicalName = OpcodeTable::logicalToName(*logicalOp); + wowee::core::setCrashBreadcrumb("world_packet:headless_skip", + opcode, + logicalName.c_str(), + packet.getSize(), + packet.getReadPos(), + static_cast(state)); + packet.skipAll(); + return; + } + if (headlessMode() && state == WorldState::IN_WORLD && + shouldSkipHeadlessWorldSimulationPacket(*logicalOp)) { + const std::string logicalName = OpcodeTable::logicalToName(*logicalOp); + wowee::core::setCrashBreadcrumb("world_packet:headless_skip", + opcode, + logicalName.c_str(), + packet.getSize(), + packet.getReadPos(), + static_cast(state)); + packet.skipAll(); + return; + } + + const std::string logicalName = OpcodeTable::logicalToName(*logicalOp); + wowee::core::setCrashBreadcrumb("world_packet:dispatch_begin", + opcode, + logicalName.c_str(), + packet.getSize(), + packet.getReadPos(), + static_cast(state)); + auto it = dispatchTable_.find(*logicalOp); if (it != dispatchTable_.end()) { + if (headlessTracePackets()) { + LOG_INFO("HEADLESS DISPATCH begin wire=0x", std::hex, opcode, std::dec, + " logical=", logicalName, + " size=", packet.getSize(), + " readPos=", packet.getReadPos(), + " state=", worldStateName(state)); + } it->second(packet); + wowee::core::setCrashBreadcrumb("world_packet:dispatch_end", + opcode, + logicalName.c_str(), + packet.getSize(), + packet.getReadPos(), + static_cast(state)); + if (headlessTracePackets()) { + LOG_INFO("HEADLESS DISPATCH end wire=0x", std::hex, opcode, std::dec, + " logical=", logicalName, + " readPos=", packet.getReadPos(), "/", packet.getSize(), + " state=", worldStateName(state)); + } } else { // In pre-world states we need full visibility (char create/login handshakes). // In-world we keep de-duplication to avoid heavy log I/O in busy areas. diff --git a/src/game/inventory_handler.cpp b/src/game/inventory_handler.cpp index e92ff538f..a076c8cd6 100644 --- a/src/game/inventory_handler.cpp +++ b/src/game/inventory_handler.cpp @@ -23,6 +23,22 @@ namespace wowee { namespace game { +namespace { + +bool headlessMode() { + static const bool enabled = []() { +#ifdef WOWEE_HEADLESS_DEFAULT + return true; +#else + const char* raw = std::getenv("WOWEE_HEADLESS"); + return raw && *raw && raw[0] != '0'; +#endif + }(); + return enabled; +} + +} // namespace + std::string formatCopperAmount(uint32_t amount); InventoryHandler::InventoryHandler(GameHandler& owner) @@ -2760,6 +2776,7 @@ void InventoryHandler::handleEquipmentSetList(network::Packet& packet) { // ============================================================ void InventoryHandler::queryItemInfo(uint32_t entry, uint64_t guid) { + if (headlessMode()) return; if (owner_.itemInfoCacheRef().count(entry) || owner_.pendingItemQueriesRef().count(entry)) return; if (!owner_.isInWorld()) return; @@ -2777,6 +2794,11 @@ void InventoryHandler::queryItemInfo(uint32_t entry, uint64_t guid) { } void InventoryHandler::handleItemQueryResponse(network::Packet& packet) { + if (headlessMode()) { + packet.skipAll(); + return; + } + ItemQueryResponseData data; bool parsed = owner_.getPacketParsers() ? owner_.getPacketParsers()->parseItemQueryResponse(packet, data) diff --git a/src/game/movement_handler.cpp b/src/game/movement_handler.cpp index ca498ddbb..f3650610e 100644 --- a/src/game/movement_handler.cpp +++ b/src/game/movement_handler.cpp @@ -1760,12 +1760,20 @@ void MovementHandler::handleTeleportAck(network::Packet& packet) { uint32_t counter = packet.readUInt32(); const bool taNoFlags2 = isPreWotlk(); - // Pre-WotLK sends one extra byte here that isn't part of the documented - // MovementInfo layout - confirmed by live capture (byte value 0x04, - // constant across samples) and verified against a known ground-truth - // teleport target (.go xyz to ironforge-city decoded to the exact - // expected x/y/z only once this byte was accounted for). - const size_t minMoveSz = taNoFlags2 ? (4 + 4 + 1 + 4 * 4) : (4 + 2 + 4 + 4 * 4); + // TBC sends one extra byte here that isn't part of the documented + // MovementInfo layout - confirmed by live capture against CMaNGOS TBC + // (byte value 0x04, constant across samples) and verified against a + // known ground-truth teleport target (.go xyz to ironforge-city decoded + // to the exact expected x/y/z only once this byte was accounted for). + // True Vanilla (VMangos) does NOT send it - confirmed by live capture + // against VMangos 1.12.1: assuming this byte for "classic" produced + // garbage x/y/z (denormalized near-zero floats), and removing it for + // classic decoded the exact expected .go xyz target with no remainder. + // So this is a TBC-only quirk, not a general pre-WotLK one - narrower + // than the moveFlags2 check below, which WotLK genuinely added and + // every earlier expansion (classic/tbc/turtle) genuinely lacks. + const bool taExtraByte = isActiveExpansion("tbc"); + const size_t minMoveSz = (4 + (taNoFlags2 ? 0 : 2) + 4 + (taExtraByte ? 1 : 0) + 4 * 4); if (packet.getRemainingSize() < minMoveSz) { LOG_WARNING("MSG_MOVE_TELEPORT_ACK: not enough data for movement info"); return; @@ -1775,8 +1783,8 @@ void MovementHandler::handleTeleportAck(network::Packet& packet) { if (!taNoFlags2) packet.readUInt16(); // moveFlags2 (WotLK only) uint32_t moveTime = packet.readUInt32(); - if (taNoFlags2) - packet.readUInt8(); // unknown byte, pre-WotLK only (see comment above) + if (taExtraByte) + packet.readUInt8(); // unknown byte, TBC only (see comment above) float serverX = packet.readFloat(); float serverY = packet.readFloat(); float serverZ = packet.readFloat(); diff --git a/src/game/social_handler.cpp b/src/game/social_handler.cpp index 3e87dc557..73db7248d 100644 --- a/src/game/social_handler.cpp +++ b/src/game/social_handler.cpp @@ -37,9 +37,39 @@ std::filesystem::path guildNameCachePath() { return std::filesystem::path("guild_names.tsv"); } -} // namespace - +bool headlessMode() { + static const bool enabled = []() { +#ifdef WOWEE_HEADLESS_DEFAULT + return true; +#else + const char* raw = std::getenv("WOWEE_HEADLESS"); + return raw && *raw && raw[0] != '0'; +#endif + }(); + return enabled; +} + +std::string guildEventName(uint8_t eventType) { + switch (eventType) { + case GuildEvent::PROMOTION: return "PROMOTION"; + case GuildEvent::DEMOTION: return "DEMOTION"; + case GuildEvent::MOTD: return "MOTD"; + case GuildEvent::JOINED: return "JOINED"; + case GuildEvent::LEFT: return "LEFT"; + case GuildEvent::REMOVED: return "REMOVED"; + case GuildEvent::LEADER_IS: return "LEADER_IS"; + case GuildEvent::LEADER_CHANGED: return "LEADER_CHANGED"; + case GuildEvent::DISBANDED: return "DISBANDED"; + case GuildEvent::TABARD_CHANGED: return "TABARD_CHANGED"; + case GuildEvent::SIGNED_ON: return "SIGNED_ON"; + case GuildEvent::SIGNED_OFF: return "SIGNED_OFF"; + case GuildEvent::GUILD_BANK_BAG_SLOTS_CHANGED: return "GUILD_BANK_BAG_SLOTS_CHANGED"; + case GuildEvent::BANK_TAB_PURCHASED: return "BANK_TAB_PURCHASED"; + default: return "EVENT_" + std::to_string(eventType); + } +} +} // namespace // LFG join result codes from LFGJoinResult enum (WotLK 3.3.5a). @@ -1276,8 +1306,9 @@ void SocialHandler::requestLogout(bool exitAfterLogout) { auto packet = LogoutRequestPacket::build(); owner_.getSocket()->send(packet); loggingOut_ = true; + logoutComplete_ = false; exitAfterLogout_ = exitAfterLogout; - LOG_WARNING("Sent logout request (exitAfterLogout=", exitAfterLogout ? "yes" : "no", ")"); // temporary + LOG_INFO("Sent logout request (exitAfterLogout=", exitAfterLogout ? "yes" : "no", ")"); } void SocialHandler::cancelLogout() { @@ -1713,9 +1744,13 @@ void SocialHandler::handleGroupList(network::Packet& packet) { partyData = GroupListData{}; if (!GroupListParser::parse(packet, partyData, hasRoles, hasBattleGroupFlag)) return; + for (const auto& member : partyData.members) { + owner_.cachePlayerName(member.guid, member.name); + } + const bool nowInGroup = !partyData.isEmpty(); if (!nowInGroup && wasInGroup) { - owner_.addSystemChatMessage("You are no longer in a group."); + if (!headlessMode()) owner_.addSystemChatMessage("You are no longer in a group."); LOG_INFO("Left group"); } else if (nowInGroup && !wasInGroup) { std::string members; @@ -1723,8 +1758,8 @@ void SocialHandler::handleGroupList(network::Packet& packet) { if (!members.empty()) members += ", "; members += m.name.empty() ? "?" : m.name; } - owner_.addSystemChatMessage("You joined a group with: " + members); - LOG_INFO("Joined group with ", partyData.memberCount, " members: ", members); + if (!headlessMode()) owner_.addSystemChatMessage("You joined a group with: " + members); + LOG_INFO("Joined group with ", partyData.members.size(), " parsed members: ", members); } // Loot method change notification if (wasInGroup && nowInGroup && partyData.lootMethod != prevLootMethod) { @@ -1732,9 +1767,9 @@ void SocialHandler::handleGroupList(network::Packet& packet) { "Free for All", "Round Robin", "Master Looter", "Group Loot", "Need Before Greed" }; const char* methodName = (partyData.lootMethod < 5) ? kLootMethods[partyData.lootMethod] : "Unknown"; - owner_.addSystemChatMessage(std::string("Loot method changed to ") + methodName + "."); + if (!headlessMode()) owner_.addSystemChatMessage(std::string("Loot method changed to ") + methodName + "."); } - if (owner_.addonEventCallbackRef()) { + if (!headlessMode() && owner_.addonEventCallbackRef()) { owner_.addonEventCallbackRef()("GROUP_ROSTER_UPDATE", {}); owner_.addonEventCallbackRef()("PARTY_MEMBERS_CHANGED", {}); if (partyData.groupType == 1) @@ -1996,16 +2031,29 @@ void SocialHandler::handleGuildEvent(network::Packet& packet) { hasGuildRoster_ = false; if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("PLAYER_GUILD_UPDATE", {}); break; + case GuildEvent::TABARD_CHANGED: + msg = "Guild tabard changed."; + break; case GuildEvent::SIGNED_ON: if (data.numStrings >= 1) msg = "[Guild] " + data.strings[0] + " has come online."; break; case GuildEvent::SIGNED_OFF: if (data.numStrings >= 1) msg = "[Guild] " + data.strings[0] + " has gone offline."; break; + case GuildEvent::GUILD_BANK_BAG_SLOTS_CHANGED: + msg = "Guild bank bag slots changed."; + break; + case GuildEvent::BANK_TAB_PURCHASED: + msg = "Guild bank tab purchased."; + break; default: msg = "Guild event " + std::to_string(data.eventType); // Was `!numStrings && numStrings >= 1` — always false (0 can't be ≥1). - if (data.numStrings >= 1) msg += ": " + data.strings[0]; + for (uint8_t i = 0; i < data.numStrings && i < 3; ++i) { + if (i == 0) msg += ": "; + else msg += " | "; + msg += data.strings[i]; + } break; } @@ -2014,6 +2062,7 @@ void SocialHandler::handleGuildEvent(network::Packet& packet) { chatMsg.type = ChatType::GUILD; chatMsg.language = ChatLanguage::UNIVERSAL; chatMsg.message = msg; + chatMsg.channelName = guildEventName(data.eventType); owner_.addLocalChatMessage(chatMsg); } @@ -2398,7 +2447,8 @@ void SocialHandler::handleLogoutComplete(network::Packet& /*packet*/) { const bool exiting = exitAfterLogout_; owner_.addSystemChatMessage("Logout complete."); loggingOut_ = false; exitAfterLogout_ = false; logoutCountdown_ = 0.0f; - LOG_WARNING("Logout complete (exiting=", exiting ? "yes" : "no", ")"); // temporary: visible at default log level + logoutComplete_ = true; + LOG_INFO("Logout complete (exiting=", exiting ? "yes" : "no", ")"); if (owner_.logoutCompleteCallbackRef()) owner_.logoutCompleteCallbackRef()(exiting); } diff --git a/src/game/spell_handler.cpp b/src/game/spell_handler.cpp index a454e249a..11fd1f582 100644 --- a/src/game/spell_handler.cpp +++ b/src/game/spell_handler.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include namespace wowee { @@ -244,6 +245,14 @@ void SpellHandler::playSpellImpactSound(uint32_t spellId) { // ---- Spell visual effect helpers ---- +static bool headlessMode() { + static const bool enabled = []() { + const char* raw = std::getenv("WOWEE_HEADLESS"); + return raw && *raw && raw[0] != '0'; + }(); + return enabled; +} + uint32_t SpellHandler::resolveSpellVisualId(uint32_t spellId) { owner_.loadSpellNameCache(); auto it = owner_.spellNameCacheRef().find(spellId); @@ -265,6 +274,7 @@ bool SpellHandler::resolveUnitPosition(uint64_t guid, glm::vec3& outPos) { } void SpellHandler::triggerCastVisual(uint32_t spellId, uint64_t casterGuid, uint32_t castTimeMs) { + if (headlessMode()) return; LOG_INFO("SpellVisual: triggerCastVisual spellId=", spellId, " casterGuid=0x", std::hex, casterGuid, std::dec); auto* renderer = owner_.services().renderer; if (!renderer) { LOG_WARNING("SpellVisual: triggerCastVisual — no renderer"); return; } @@ -279,6 +289,7 @@ void SpellHandler::triggerCastVisual(uint32_t spellId, uint64_t casterGuid, uint } void SpellHandler::triggerImpactVisual(uint32_t spellId, uint64_t targetGuid) { + if (headlessMode()) return; LOG_INFO("SpellVisual: triggerImpactVisual spellId=", spellId, " targetGuid=0x", std::hex, targetGuid, std::dec); auto* renderer = owner_.services().renderer; if (!renderer) return; diff --git a/src/game/world_packets_social.cpp b/src/game/world_packets_social.cpp index bb9c3cc6c..8fa12b549 100644 --- a/src/game/world_packets_social.cpp +++ b/src/game/world_packets_social.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include namespace wowee { @@ -338,7 +339,19 @@ bool TextEmoteParser::parse(network::Packet& packet, TextEmoteData& data, bool l uint32_t nameLen = packet.readUInt32(); if (nameLen > 0 && nameLen <= 256) { - data.targetName = packet.readString(); + if (!packet.hasRemaining(nameLen)) { + LOG_WARNING("SMSG_TEXT_EMOTE target name truncated: len=", nameLen, + " remaining=", packet.getRemainingSize()); + return false; + } + std::string targetName; + targetName.reserve(nameLen); + for (uint32_t i = 0; i < nameLen; ++i) { + const uint8_t c = packet.readUInt8(); + if (c == 0) break; + targetName.push_back(static_cast(c)); + } + data.targetName = std::move(targetName); } else if (nameLen > 0) { // Implausible name length — misaligned read return false; diff --git a/src/game/world_packets_world.cpp b/src/game/world_packets_world.cpp index e39e3e059..74c14215c 100644 --- a/src/game/world_packets_world.cpp +++ b/src/game/world_packets_world.cpp @@ -668,6 +668,12 @@ network::Packet QuestgiverHelloPacket::build(uint64_t npcGuid) { return packet; } +network::Packet TaxiQueryAvailableNodesPacket::build(uint64_t npcGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_TAXIQUERYAVAILABLENODES)); + packet.writeUInt64(npcGuid); + return packet; +} + network::Packet GossipSelectOptionPacket::build(uint64_t npcGuid, uint32_t menuId, uint32_t optionId, const std::string& code) { network::Packet packet(wireOpcode(Opcode::CMSG_GOSSIP_SELECT_OPTION)); packet.writeUInt64(npcGuid); diff --git a/src/network/world_socket.cpp b/src/network/world_socket.cpp index 2670c4ac5..ef92b6385 100644 --- a/src/network/world_socket.cpp +++ b/src/network/world_socket.cpp @@ -32,6 +32,19 @@ constexpr int kAsyncPumpSleepMs = 2; constexpr size_t kRecentPacketHistoryLimit = 96; constexpr auto kRecentPacketHistoryWindow = std::chrono::seconds(15); +inline int headerTracePacketCount() { + static int count = []() { + const char* raw = std::getenv("WOWEE_NET_HEADER_TRACE_PACKETS"); + if (!raw || !*raw) return 0; + char* end = nullptr; + long parsed = std::strtol(raw, &end, 10); + if (end == raw || parsed <= 0) return 0; + if (parsed > 256) return 256; + return static_cast(parsed); + }(); + return count; +} + inline int parsedPacketsBudgetPerUpdate() { static int budget = []() { const char* raw = std::getenv("WOWEE_NET_MAX_PARSED_PACKETS"); @@ -891,7 +904,7 @@ void WorldSocket::initEncryption(const std::vector& sessionKey, uint32_ } encryptionEnabled = true; - headerTracePacketsLeft = 24; + headerTracePacketsLeft = headerTracePacketCount(); LOG_INFO("World server encryption initialized successfully"); } diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp index 8515d06c7..4951fa6b4 100644 --- a/src/pipeline/asset_manager.cpp +++ b/src/pipeline/asset_manager.cpp @@ -44,6 +44,55 @@ size_t parseEnvCount(const char* name, size_t defValue) { } return static_cast(n); } + +std::vector readLooseFileExactOrCaseInsensitive( + const std::filesystem::path& dir, + const std::string& filename, + std::string* resolvedPath = nullptr) { + if (!std::filesystem::is_directory(dir)) { + return {}; + } + + std::filesystem::path resolved = dir / filename; + if (!std::filesystem::exists(resolved)) { + std::string wantLower = filename; + std::transform(wantLower.begin(), wantLower.end(), wantLower.begin(), + [](unsigned char c) { return std::tolower(c); }); + + resolved.clear(); + for (const auto& entry : std::filesystem::directory_iterator(dir)) { + if (!entry.is_regular_file()) continue; + std::string gotLower = entry.path().filename().string(); + std::transform(gotLower.begin(), gotLower.end(), gotLower.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (gotLower == wantLower) { + resolved = entry.path(); + break; + } + } + } + + if (resolved.empty() || !std::filesystem::exists(resolved)) { + return {}; + } + + std::ifstream f(resolved, std::ios::binary | std::ios::ate); + if (!f) { + return {}; + } + auto size = f.tellg(); + if (size <= 0) { + return {}; + } + + std::vector data(static_cast(size)); + f.seekg(0); + f.read(reinterpret_cast(data.data()), size); + if (resolvedPath) { + *resolvedPath = resolved.string(); + } + return data; +} } // namespace AssetManager::AssetManager() = default; @@ -108,6 +157,22 @@ bool AssetManager::switchDataPath(const std::string& newDataPath) { return true; } +bool AssetManager::initializeDbcOnly(const std::string& dataPath_) { + if (initialized) { + LOG_WARNING("AssetManager already initialized"); + return true; + } + + dataPath = dataPath_; + overridePath_ = dataPath + "/override"; + LOG_WARNING("Initializing asset manager in DBC-only mode with data path: ", dataPath); + + setupFileCacheBudget(); + + initialized = true; + return true; +} + void AssetManager::setupFileCacheBudget() { auto& memMonitor = core::MemoryMonitor::getInstance(); size_t recommendedBudget = memMonitor.getRecommendedCacheBudget(); @@ -178,9 +243,15 @@ std::string AssetManager::resolveFile(const std::string& normalizedPath) const { // extraction produced manifest.json. Try the loose file directly at its expected // location before giving up, so a missing manifest entry doesn't silently mean // "file doesn't exist" when it plainly does. - std::string looseCandidate = normalizedPath; - std::replace(looseCandidate.begin(), looseCandidate.end(), '\\', '/'); - looseCandidate = dataPath + "/" + looseCandidate; + std::string looseRelativePath = normalizedPath; + std::replace(looseRelativePath.begin(), looseRelativePath.end(), '\\', '/'); + if (!expansionDataPath_.empty()) { + std::string expansionCandidate = expansionDataPath_ + "/" + looseRelativePath; + if (LooseFileReader::fileExists(expansionCandidate)) { + return expansionCandidate; + } + } + std::string looseCandidate = dataPath + "/" + looseRelativePath; if (LooseFileReader::fileExists(looseCandidate)) { return looseCandidate; } @@ -340,9 +411,26 @@ std::shared_ptr AssetManager::loadDBC(const std::string& name) { std::vector dbcData; - // Try binary DBC from extracted MPQs first (preferred source). + // Prefer expansion-scoped loose DBCs when present, but keep the root Data + // dataset as the default/fallback so existing upstream setups still work. + if (!expansionDataPath_.empty()) { + std::string resolved; + for (const auto& dir : { + std::filesystem::path(expansionDataPath_) / "DBFilesClient", + std::filesystem::path(expansionDataPath_) / "dbfilesclient", + }) { + dbcData = readLooseFileExactOrCaseInsensitive(dir, name, &resolved); + if (!dbcData.empty()) { + LOG_INFO("Loaded expansion-scoped DBC from: ", resolved, + " (", dbcData.size(), " bytes)"); + break; + } + } + } + + // Try binary DBC from the default extracted MPQs next. std::string dbcPath = "DBFilesClient\\" + name; - { + if (dbcData.empty()) { dbcData = readFile(dbcPath); } @@ -374,10 +462,12 @@ std::shared_ptr AssetManager::loadDBC(const std::string& name) { // Try Data/db/ directory (pre-extracted binary DBCs shared across expansions) if (dbcData.empty()) { // Expansion overlay first (e.g. Data/expansions/tbc/overlay/db/), then - // expansion db/, then shared Data/db/. + // expansion db/, then shared/default Data/db/. std::vector dbDirs; - if (!expansionDataPath_.empty()) + if (!expansionDataPath_.empty()) { dbDirs.push_back(expansionDataPath_ + "/overlay/db"); + dbDirs.push_back(expansionDataPath_ + "/db"); + } dbDirs.push_back(dataPath + "/db"); dbDirs.push_back(dataPath + "/../../db"); dbDirs.push_back("Data/db"); @@ -493,13 +583,48 @@ std::shared_ptr AssetManager::loadDBCOptional(const std::string& name) auto it = dbcCache.find(name); if (it != dbcCache.end()) return it->second; - // Try binary DBC + // Try expansion-scoped loose DBCs first, then default/root Data. std::vector dbcData; - { + if (!expansionDataPath_.empty()) { + std::string resolved; + for (const auto& dir : { + std::filesystem::path(expansionDataPath_) / "DBFilesClient", + std::filesystem::path(expansionDataPath_) / "dbfilesclient", + }) { + dbcData = readLooseFileExactOrCaseInsensitive(dir, name, &resolved); + if (!dbcData.empty()) { + LOG_INFO("Loaded expansion-scoped optional DBC from: ", resolved, + " (", dbcData.size(), " bytes)"); + break; + } + } + } + if (dbcData.empty()) { std::string dbcPath = "DBFilesClient\\" + name; dbcData = readFile(dbcPath); } + if (dbcData.empty()) { + std::vector dbDirs; + if (!expansionDataPath_.empty()) { + dbDirs.push_back(expansionDataPath_ + "/overlay/db"); + dbDirs.push_back(expansionDataPath_ + "/db"); + } + dbDirs.push_back(dataPath + "/db"); + dbDirs.push_back(dataPath + "/../../db"); + dbDirs.push_back("Data/db"); + + for (const auto& dir : dbDirs) { + std::string resolved; + dbcData = readLooseFileExactOrCaseInsensitive(dir, name, &resolved); + if (!dbcData.empty()) { + LOG_INFO("Loaded optional binary DBC from: ", resolved, + " (", dbcData.size(), " bytes)"); + break; + } + } + } + // Fall back to expansion-specific CSV if (dbcData.empty() && !expansionDataPath_.empty()) { std::string baseName = name; @@ -550,7 +675,16 @@ bool AssetManager::fileExists(const std::string& path) const { return false; } std::string normalized = normalizePath(path); - return manifest_.hasEntry(normalized); + if (manifest_.hasEntry(normalized)) { + return true; + } + std::string looseRelativePath = normalized; + std::replace(looseRelativePath.begin(), looseRelativePath.end(), '\\', '/'); + if (!expansionDataPath_.empty() && + LooseFileReader::fileExists(expansionDataPath_ + "/" + looseRelativePath)) { + return true; + } + return LooseFileReader::fileExists(dataPath + "/" + looseRelativePath); } std::vector AssetManager::readFile(const std::string& path) const { diff --git a/src/rendering/animation/emote_registry.cpp b/src/rendering/animation/emote_registry.cpp index 50681d432..560ff79fd 100644 --- a/src/rendering/animation/emote_registry.cpp +++ b/src/rendering/animation/emote_registry.cpp @@ -137,10 +137,13 @@ EmoteRegistry& EmoteRegistry::instance() { } void EmoteRegistry::loadFromDbc() { + loadFromDbc(core::Application::getInstance().getAssetManager()); +} + +void EmoteRegistry::loadFromDbc(pipeline::AssetManager* assetManager) { if (loaded_) return; loaded_ = true; - auto* assetManager = core::Application::getInstance().getAssetManager(); if (!assetManager) { LOG_WARNING("Emotes: no AssetManager"); loadFallbackEmotes(); diff --git a/src/ui/window_manager.cpp b/src/ui/window_manager.cpp index a1a983ddc..f01ebb7d5 100644 --- a/src/ui/window_manager.cpp +++ b/src/ui/window_manager.cpp @@ -2215,6 +2215,8 @@ void WindowManager::renderTaxiWindow(game::GameHandler& gameHandler) { static uint32_t selectedNodeId = 0; int destCount = 0; + uint64_t playerMoney = gameHandler.getMoneyCopper(); + uint32_t selectedCostCopper = 0; if (ImGui::BeginTable("TaxiNodes", 3, ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { ImGui::TableSetupColumn("Destination", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Cost", ImGuiTableColumnFlags_WidthFixed, 120.0f); @@ -2230,10 +2232,14 @@ void WindowManager::renderTaxiWindow(game::GameHandler& gameHandler) { uint32_t gold = costCopper / 10000; uint32_t silver = (costCopper / 100) % 100; uint32_t copper = costCopper % 100; + bool affordable = costCopper <= playerMoney; + if (nodeId == selectedNodeId) selectedCostCopper = costCopper; ImGui::PushID(static_cast(nodeId)); ImGui::TableNextRow(); + ImGui::BeginDisabled(!affordable); + ImGui::TableSetColumnIndex(0); bool isSelected = (selectedNodeId == nodeId); if (ImGui::Selectable(node.name.c_str(), isSelected, @@ -2241,7 +2247,7 @@ void WindowManager::renderTaxiWindow(game::GameHandler& gameHandler) { ImGuiSelectableFlags_AllowDoubleClick)) { selectedNodeId = nodeId; LOG_INFO("Taxi UI: Selected dest=", nodeId); - if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + if (affordable && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { LOG_INFO("Taxi UI: Double-click activate dest=", nodeId); gameHandler.activateTaxi(nodeId); } @@ -2257,6 +2263,8 @@ void WindowManager::renderTaxiWindow(game::GameHandler& gameHandler) { gameHandler.activateTaxi(nodeId); } + ImGui::EndDisabled(); + ImGui::PopID(); destCount++; } @@ -2269,10 +2277,16 @@ void WindowManager::renderTaxiWindow(game::GameHandler& gameHandler) { ImGui::Spacing(); ImGui::Separator(); - if (selectedNodeId != 0 && ImGui::Button("Fly Selected", ImVec2(-1, 0))) { + bool selectedAffordable = selectedCostCopper <= playerMoney; + if (selectedNodeId != 0 && !selectedAffordable) { + ImGui::TextColored(colors::kLowHealthRed, "Not enough money for this destination."); + } + ImGui::BeginDisabled(selectedNodeId == 0 || !selectedAffordable); + if (ImGui::Button("Fly Selected", ImVec2(-1, 0))) { LOG_INFO("Taxi UI: Fly Selected dest=", selectedNodeId); gameHandler.activateTaxi(selectedNodeId); } + ImGui::EndDisabled(); if (ImGui::Button("Close", ImVec2(-1, 0))) { gameHandler.closeTaxi(); } diff --git a/tools/automation_examples/README.md b/tools/automation_examples/README.md new file mode 100644 index 000000000..084cecf04 --- /dev/null +++ b/tools/automation_examples/README.md @@ -0,0 +1,92 @@ +# Automation Examples + +These scripts exercise the `wowee_headless` HTTP APIs from outside the client. + +## First Party Demo + +Create a local one-leader fleet config from your already-working headless settings: + +```bash +python tools/bot_fleet_manager/import_headless_settings.py +``` + +Start the supervised leader: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json supervise +``` + +To start the textual team dashboard at the same time: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json supervise --dashboard +``` + +Then open `http://127.0.0.1:8780`. + +In another terminal, inspect the leader, party, and chat APIs: + +```bash +python tools/automation_examples/party_demo.py +``` + +Send a party message and then read recent chat after a short wait: + +```bash +python tools/automation_examples/party_demo.py --message "hello from the first automation demo" +``` + +The default API URL is `http://127.0.0.1:8787`. Use `--base-url` if your fleet config uses a different port. + +## Leader Travel Demo + +Send the leader to a named landmark, exact coordinate, or multi-waypoint route, polling status until arrival: + +```bash +# Go to Goldshire (direct line) +python tools/automation_examples/travel_demo.py --landmark goldshire + +# Go to an exact coordinate +python tools/automation_examples/travel_demo.py --coord -9465 62 56 + +# Follow a named waypoint route (Stormwind -> Goldshire, 7 waypoints) +python tools/automation_examples/travel_demo.py --route stormwind_to_goldshire + +# Check current position without moving +python tools/automation_examples/travel_demo.py --here + +# Stop the current movement task +python tools/automation_examples/travel_demo.py --stop +``` + +Available landmarks: `goldshire`, `stormwind`, `elwynn_forest`. + +Available named routes: `stormwind_to_goldshire` (7 waypoints). + +The script polls `GET /status` every second and prints state transitions (`moving` to `arrived`, `failed`, or `movement_locked`) and waypoint progress. Press Ctrl-C to interrupt and stop the leader. + +## Taxi Flight Demo + +Take a flight and trace position + `taxi.onFlight` over time until landing: + +```bash +# Learn the nearest flight master's node first (must be standing near one), then fly +python tools/automation_examples/taxi_flight_demo.py --learn-first --dest-name Ironforge + +# Already know the node is learned - just activate and trace +python tools/automation_examples/taxi_flight_demo.py --dest-name Ironforge + +# By exact node id, and save the full trace for later comparison +python tools/automation_examples/taxi_flight_demo.py --dest-node-id 2 --csv ironforge_flight.csv +``` + +Polls `GET /world/self` every second (`--poll-interval` to change), printing position and distance moved since the last sample, and stops as soon as `taxi.onFlight` flips back to `false` (landing). Useful for getting ground-truth position data for a flight without any GUI rendering/animation noise in the way - e.g. confirming exactly where a flight actually lands. + +For fleet-level pathfinding, start the path service and use the manager's `pathfind-goto` command: + +```bash +python tools/pathfinding_service/pathfinding_service.py --host 127.0.0.1 --port 8790 +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json pathfind-goto 0 -9465 62 56 +``` + +The current path service backend returns straight-line waypoints for integration testing. It is the API boundary where the CMaNGOS mmap/Detour backend will plug in. diff --git a/tools/automation_examples/party_demo.py b/tools/automation_examples/party_demo.py new file mode 100644 index 000000000..7d09769f8 --- /dev/null +++ b/tools/automation_examples/party_demo.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Small demo client for a running wowee_headless leader API.""" + +from __future__ import annotations + +import argparse +import json +import time +import urllib.request +from typing import Any + + +def request_json(method: str, url: str, payload: dict[str, Any] | None = None, timeout: float = 5.0) -> dict[str, Any]: + if not url.startswith(("http://", "https://")): + raise ValueError(f"refusing non-http(s) URL: {url}") + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + # url is validated to http(s) above; this calls our own local fleet API, not attacker-controlled input. + with urllib.request.urlopen(req, timeout=timeout) as response: # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + return json.loads(response.read().decode("utf-8")) + + +def print_json(label: str, doc: dict[str, Any]) -> None: + print(f"\n== {label} ==") + print(json.dumps(doc, indent=2, sort_keys=True)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", default="http://127.0.0.1:8787") + parser.add_argument("--message", default="") + parser.add_argument("--after", type=int, default=0) + parser.add_argument("--wait-seconds", type=float, default=4.0) + args = parser.parse_args() + + base = args.base_url.rstrip("/") + print_json("status", request_json("GET", base + "/status")) + print_json("self", request_json("GET", base + "/world/self")) + print_json("party", request_json("GET", base + "/party")) + + if args.message: + print_json("send party chat", request_json("POST", base + "/chat", { + "type": "party", + "message": args.message, + })) + time.sleep(max(0.0, args.wait_seconds)) + + print_json("chat", request_json("GET", f"{base}/chat?after={args.after}&limit=50")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/automation_examples/taxi_flight_demo.py b/tools/automation_examples/taxi_flight_demo.py new file mode 100644 index 000000000..50dfc6ca1 --- /dev/null +++ b/tools/automation_examples/taxi_flight_demo.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Take a taxi flight and trace position/onFlight over time via the headless HTTP API. + +Written to exercise POST /taxi/activate end to end and, in particular, to get +ground-truth position data for a real flight without any of the GUI client's +rendering/animation noise - useful for diagnosing landing-position bugs (see +TODO.md: flight from Menethil to Ironforge landing on the mountain surface +instead of the underground flight point). +""" + +from __future__ import annotations + +import argparse +import csv +import json +import sys +import time +import urllib.error +import urllib.request +from typing import Any + + +def request_json(method: str, url: str, payload: dict[str, Any] | None = None, timeout: float = 5.0) -> dict[str, Any]: + if not url.startswith(("http://", "https://")): + raise ValueError(f"refusing non-http(s) URL: {url}") + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + # url is validated to http(s) above; this calls our own local fleet API, not attacker-controlled input. + with urllib.request.urlopen(req, timeout=timeout) as response: # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + return json.loads(response.read().decode("utf-8")) + + +def print_json(label: str, doc: dict[str, Any]) -> None: + print(f"\n== {label} ==") + print(json.dumps(doc, indent=2, sort_keys=True)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", default="http://127.0.0.1:8787") + parser.add_argument("--poll-interval", type=float, default=1.0, help="Seconds between /world/self polls") + parser.add_argument("--timeout", type=float, default=300.0, help="Max seconds to wait for landing") + parser.add_argument("--learn-first", action="store_true", + help="POST /learn-flight-path before activating (must be near a flight master)") + parser.add_argument("--search-radius", type=float, default=15.0, help="Flight master search radius for --learn-first") + parser.add_argument("--csv", help="Optional path to write the full position trace as CSV") + + dest = parser.add_mutually_exclusive_group(required=True) + dest.add_argument("--dest-name", help="Destination taxi node, matched case-insensitively (e.g. 'Ironforge')") + dest.add_argument("--dest-node-id", type=int, help="Exact destination taxi node id") + + args = parser.parse_args() + base = args.base_url.rstrip("/") + + status = request_json("GET", base + "/status") + print_json("initial status", status) + + if args.learn_first: + learn = request_json("POST", base + "/learn-flight-path", {"searchRadius": args.search_radius}) + print_json("learn-flight-path result", learn) + if not learn.get("ok", False): + print(f"Error: {learn.get('error', 'unknown')}", file=sys.stderr) + return 1 + + payload: dict[str, Any] = {} + if args.dest_name: + payload["destName"] = args.dest_name + else: + payload["destNodeId"] = args.dest_node_id + + activate = request_json("POST", base + "/taxi/activate", payload) + print_json("taxi/activate result", activate) + if not activate.get("ok", False): + print(f"Error: {activate.get('error', 'unknown')}", file=sys.stderr) + return 1 + + trace: list[dict[str, Any]] = [] + deadline = time.monotonic() + args.timeout + last_pos: tuple[float, float, float] | None = None + was_on_flight = False + start_time = time.monotonic() + + print(f"\nPolling /world/self every {args.poll_interval}s (timeout {args.timeout}s)...") + try: + while time.monotonic() < deadline: + self_info = request_json("GET", base + "/world/self", timeout=args.poll_interval + 1.0) + pos = self_info.get("position", {}) + x, y, z = pos.get("x", 0.0), pos.get("y", 0.0), pos.get("z", 0.0) + on_flight = bool(self_info.get("taxi", {}).get("onFlight", False)) + t = time.monotonic() - start_time + + moved = 0.0 + if last_pos is not None: + moved = ((x - last_pos[0]) ** 2 + (y - last_pos[1]) ** 2 + (z - last_pos[2]) ** 2) ** 0.5 + last_pos = (x, y, z) + + trace.append({"t": round(t, 2), "x": x, "y": y, "z": z, "onFlight": on_flight, "moved": round(moved, 3)}) + print(f" [{t:6.1f}s] pos=({x:.1f}, {y:.1f}, {z:.1f}) onFlight={on_flight} moved={moved:.2f}") + + if was_on_flight and not on_flight: + print(f"\nLanded after {t:.1f}s at ({x:.2f}, {y:.2f}, {z:.2f}), mapId={self_info.get('mapId')}") + break + was_on_flight = on_flight + + time.sleep(args.poll_interval) + else: + print(f"\nTimed out after {args.timeout}s still on flight.", file=sys.stderr) + if args.csv: + write_csv(args.csv, trace) + return 1 + except KeyboardInterrupt: + print("\nInterrupted; leaving flight in progress (no cancel API for an active taxi flight).") + if args.csv: + write_csv(args.csv, trace) + return 130 + + if not any(sample["onFlight"] for sample in trace): + print("\nWarning: onFlight was never true during polling - activation may not have taken.", file=sys.stderr) + + if args.csv: + write_csv(args.csv, trace) + + print(f"\n{len(trace)} samples over {trace[-1]['t']:.1f}s. Total straight-line samples logged above; " + "compare the final landing position against the destination's known flight-point coordinates.") + return 0 + + +def write_csv(path: str, trace: list[dict[str, Any]]) -> None: + with open(path, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=["t", "x", "y", "z", "onFlight", "moved"]) + writer.writeheader() + writer.writerows(trace) + print(f"\nWrote {len(trace)} samples to {path}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/automation_examples/travel_demo.py b/tools/automation_examples/travel_demo.py new file mode 100644 index 000000000..f9eebe8c1 --- /dev/null +++ b/tools/automation_examples/travel_demo.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Demo leader travel: goto a coordinate, follow waypoints, poll status until arrival.""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +import urllib.error +import urllib.request +from typing import Any + + +def request_json(method: str, url: str, payload: dict[str, Any] | None = None, timeout: float = 5.0) -> dict[str, Any]: + if not url.startswith(("http://", "https://")): + raise ValueError(f"refusing non-http(s) URL: {url}") + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + # url is validated to http(s) above; this calls our own local fleet API, not attacker-controlled input. + with urllib.request.urlopen(req, timeout=timeout) as response: # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + return json.loads(response.read().decode("utf-8")) + + +def print_json(label: str, doc: dict[str, Any]) -> None: + print(f"\n== {label} ==") + print(json.dumps(doc, indent=2, sort_keys=True)) + + +def resolve_destination(name: str) -> dict[str, float]: + """Return {mapId, x, y, z} for a named landmark on the Eastern Kingdoms map.""" + landmarks: dict[str, dict[str, float]] = { + "goldshire": {"mapId": 0, "x": -9465.0, "y": 62.0, "z": 56.0}, + "stormwind": {"mapId": 0, "x": -8833.0, "y": 626.0, "z": 94.0}, + "elwynn_forest": {"mapId": 0, "x": -9200.0, "y": -50.0, "z": 73.0}, + } + key = name.lower().replace(" ", "_") + if key in landmarks: + return landmarks[key] + raise ValueError(f"Unknown landmark: {name}. Known: {', '.join(sorted(landmarks.keys()))}") + + +def resolve_route(name: str) -> list[dict[str, float]]: + """Return a list of {x, y, z} waypoints for a named route.""" + routes: dict[str, list[dict[str, float]]] = { + "stormwind_to_goldshire": [ + {"x": -8833.0, "y": 626.0, "z": 94.0}, + {"x": -8970.0, "y": 610.0, "z": 95.0}, + {"x": -9050.0, "y": 520.0, "z": 92.0}, + {"x": -9150.0, "y": 350.0, "z": 82.0}, + {"x": -9260.0, "y": 220.0, "z": 75.0}, + {"x": -9360.0, "y": 130.0, "z": 65.0}, + {"x": -9465.0, "y": 62.0, "z": 56.0}, + ], + } + key = name.lower().replace(" ", "_") + if key in routes: + return routes[key] + raise ValueError(f"Unknown route: {name}. Known: {', '.join(sorted(routes.keys()))}") + + +STATE_ARRIVED = "arrived" +STATE_FAILED = "failed" +STATE_MOVING = "moving" +STATE_IDLE = "idle" +STATE_LOCKED = "movement_locked" + +TERMINAL_STATES = {STATE_ARRIVED, STATE_FAILED, STATE_LOCKED} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", default="http://127.0.0.1:8787") + parser.add_argument("--poll-interval", type=float, default=1.0, help="Seconds between status polls") + parser.add_argument("--timeout", type=float, default=120.0, help="Max seconds to wait for arrival") + parser.add_argument("--arrival-radius", type=float, default=5.0) + + dest = parser.add_mutually_exclusive_group(required=True) + dest.add_argument("--landmark", help="Named destination (goldshire, stormwind, elwynn_forest)") + dest.add_argument("--coord", nargs=3, metavar=("X", "Y", "Z"), type=float, help="Exact coordinates") + dest.add_argument("--route", help="Named multi-waypoint route (stormwind_to_goldshire)") + dest.add_argument("--here", action="store_true", help="Read current position and use as destination (no-op, just reports)") + + parser.add_argument("--stop", action="store_true", help="Send stop command and exit") + parser.add_argument("--stop-reason", default="travel demo stop", help="Reason for stop") + + args = parser.parse_args() + base = args.base_url.rstrip("/") + + if args.stop: + result = request_json("POST", base + "/movement/stop", {"reason": args.stop_reason}) + print_json("stop result", result) + return 0 + + status = request_json("GET", base + "/status") + print_json("initial status", status) + + if args.here: + self_info = request_json("GET", base + "/world/self") + pos = self_info.get("position", {}) + print(f"\nCurrent position: x={pos.get('x')}, y={pos.get('y')}, z={pos.get('z')}") + print(f"Map ID: {self_info.get('mapId')}") + return 0 + + if args.route: + waypoints = resolve_route(args.route) + print(f"\nSending waypoint route '{args.route}' with {len(waypoints)} waypoints") + goto = request_json("POST", base + "/movement/goto/waypoints", { + "mapId": 0, + "waypoints": waypoints, + "arrivalRadius": args.arrival_radius, + }) + elif args.landmark: + coords = resolve_destination(args.landmark) + print(f"\nSending goto: mapId={coords['mapId']}, " + f"x={coords['x']}, y={coords['y']}, z={coords['z']}") + goto = request_json("POST", base + "/movement/goto", { + "mapId": coords["mapId"], + "x": coords["x"], + "y": coords["y"], + "z": coords["z"], + "arrivalRadius": args.arrival_radius, + }) + else: + print(f"\nSending goto: x={args.coord[0]}, y={args.coord[1]}, z={args.coord[2]}") + goto = request_json("POST", base + "/movement/goto", { + "mapId": 0, + "x": args.coord[0], + "y": args.coord[1], + "z": args.coord[2], + "arrivalRadius": args.arrival_radius, + }) + print_json("goto result", goto) + + if not goto.get("ok", False): + print(f"Error: {goto.get('error', 'unknown')}", file=sys.stderr) + return 1 + + deadline = time.monotonic() + args.timeout + last_state = "" + last_waypoint = 0 + + print(f"\nPolling status every {args.poll_interval}s (timeout {args.timeout}s)...") + try: + while time.monotonic() < deadline: + status = request_json("GET", base + "/status", timeout=args.poll_interval + 1.0) + movement = status.get("movement", {}) + state = movement.get("state", STATE_IDLE) + wp_index = movement.get("waypointIndex", 0) + wp_count = movement.get("waypointCount", 0) + + if wp_index != last_waypoint: + print(f" [{time.strftime('%H:%M:%S')}] waypoint {wp_index}/{wp_count}") + last_waypoint = wp_index + + if state != last_state: + print(f" [{time.strftime('%H:%M:%S')}] movement state: {state}") + last_state = state + + if state in TERMINAL_STATES: + print_json("final status", status) + if state == STATE_ARRIVED: + print("\nArrived at destination!") + return 0 + else: + error = movement.get("error", "unknown") + print(f"\nMovement failed: {error}", file=sys.stderr) + return 1 + + time.sleep(args.poll_interval) + except KeyboardInterrupt: + print("\nInterrupted; stopping movement...") + request_json("POST", base + "/movement/stop", {"reason": "interrupt"}) + print_json("final status", request_json("GET", base + "/status")) + return 130 + + print(f"\nTimed out after {args.timeout}s.", file=sys.stderr) + print_json("timeout status", request_json("GET", base + "/status")) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bot_fleet_manager/README.md b/tools/bot_fleet_manager/README.md new file mode 100644 index 000000000..1b88f9b3f --- /dev/null +++ b/tools/bot_fleet_manager/README.md @@ -0,0 +1,132 @@ +# Bot Fleet Manager + +`bot_fleet_manager.py` supervises multiple `wowee_headless` leader clients. Each leader logs in as one character, can add CMaNGOS follower bots through startup commands, and exposes its own localhost automation API. + +## Run + +Copy `fleet.settings.example.json` to a local config and fill in accounts, passwords, characters, and party bot names. + +If you already have a working `tools/headless_client/settings.json`, generate a one-leader demo fleet config from it: + +```bash +python tools/bot_fleet_manager/import_headless_settings.py +``` + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json start +``` + +`start` launches the leaders and exits. For longer runs, use `supervise` so the manager keeps running, writes per-leader logs, and restarts crashed clients with backoff: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json supervise +``` + +Start the textual team dashboard while supervising: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json supervise --dashboard +``` + +Stream each leader's log output to the same console while supervising: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json supervise --dashboard --debug +``` + +The same flag is also available as `--verbose`, `--v`, or `-v`. Logs are still written under `tools/bot_fleet_manager/runtime/logs/`. +Each leader log line is timestamped by the supervisor as it is written, including plain `wowee_headless` console output that does not include its own timestamp. + +Or run only the dashboard against already-running leaders: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json dashboard +``` + +The dashboard listens on `http://127.0.0.1:8780` by default and shows a leader-position map, leader status, textual position, current activity, party members, and recent chat. Use `--dashboard-port` with `supervise` or `--port` with `dashboard` to change the port. + +The map uses MiniManager's continental projection for the high-level overview, and bundled MiniManager zone bounds for zone-level fallback views. It will draw client art when assets are available under `tools/bot_fleet_manager/runtime/map_assets/continent/` and `tools/bot_fleet_manager/runtime/map_assets/zone/`. That runtime directory is ignored by git. Set `WOWEE_MINIMANAGER_ZONE_DIR` to another `img/zone` folder if you want to serve the zone art from a different local MiniManager checkout. + +Refresh MiniManager map art from the CMaNGOS host described by the external `wow_server` `.env`: + +```bash +python tools/bot_fleet_manager/fetch_minimanager_assets.py --env C:\Users\admin\code\wow_server\.env +``` + +Common commands: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json status +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json landmarks +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json travel-nodes --routes +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json travel-plan deeprun-prototype +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json travel-plan --static grand-expedition-v0 --max-legs 3 +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json travel-survey --leader demo-leader-1 --label stormwind-tram --duration 120 +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json travel-capture --leader demo-leader-1 --type tram_entrance --name "Stormwind Deeprun Tram Entrance" --verified stormwind-tram-entrance +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json tram-state --leader demo-leader-1 --calibrate +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json tram-state --leader demo-leader-1 --time-offset-ms 122400 --watch 30 +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json board-tram --leader demo-leader-1 --from deeprun-tram-stormwind-platform --to deeprun-tram-ironforge-platform +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json command ".bot follow" +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json goto 530 -248.0 951.0 84.0 +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json plan-route 530 -248.0 951.0 84.0 +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json route-goto 530 -248.0 951.0 84.0 +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json route-demo goldshire +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json route-demo ironforge-probe --max-legs 2 +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json resume-route +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json pathfind-goto 530 -248.0 951.0 84.0 +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json stop +``` + +Target one group or one leader: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json status --fleet alpha +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json command --leader leader-1 ".bot follow" +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json goto --fleet beta 530 -248.0 951.0 84.0 +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json plan-route --leader leader-1 530 -248.0 951.0 84.0 +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json route-goto --leader leader-1 530 -248.0 951.0 84.0 +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json pathfind-goto --leader leader-1 530 -248.0 951.0 84.0 +``` + +Set each leader's `fleet` field in `fleet.settings.json` to create assignment groups. + +`goto` sends one direct destination to each leader. `landmarks` lists built-in named targets such as `goldshire`, `stormwind`, `ironforge`, `crossroads`, `ratchet`, `exodar`, and `azure-watch`. `plan-route` asks the configured pathfinding service for iterative route legs and prints the result without moving anyone. `route-goto` executes cautiously by moving one planned leg, waiting for arrival, then replanning from the new live position. `route-demo` resolves a named landmark/demo and runs the guarded route flow; add `--plan-only` to inspect without moving. `route-goto` and `route-demo` save each leader's last route target under `tools/bot_fleet_manager/runtime/route_state/`; use `resume-route` to continue toward that saved target after a stop, crash, or interruption. `pathfind-goto` first calls the configured pathfinding service, then posts the returned waypoint list to each leader's `/movement/goto/waypoints` endpoint. + +`travel-nodes` lists the long-range route registry in `tools/bot_fleet_manager/travel_nodes.json`. `travel-plan` is read-only: it chains ordinary pathfinding legs with registered travel transitions such as the Deeprun Tram, boats, portals, and future flight paths. It does not move the party. Nodes marked `survey` are placeholders that need in-game coordinates or packet/state detection before execution can be enabled. Use `--static` to plan only between registry nodes without querying a live leader position. + +`travel-survey` records append-only JSONL samples under `tools/bot_fleet_manager/runtime/travel_surveys/` by default. It samples `/world/self` and `/status`, including map id, position, orientation, movement flags, movement state, combat, and health. Use `--raw` if you want full API responses in each line. `travel-capture` updates one named node in `travel_nodes.json` from the selected leader's current position. A good tram survey loop is: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json travel-survey --leader demo-leader-1 --label deeprun-tram --interval 0.5 --min-distance 0.5 +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json travel-capture --leader demo-leader-1 --type tram_entrance --name "Stormwind Deeprun Tram Entrance" --description "Surveyed Stormwind-side tram entrance" --verified stormwind-tram-entrance +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json travel-capture --leader demo-leader-1 --type tram_platform --name "Deeprun Tram Stormwind Platform" --description "Surveyed Stormwind-side tram platform" --verified deeprun-tram-stormwind-platform +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json travel-capture --leader demo-leader-1 --type tram_platform --name "Deeprun Tram Ironforge Platform" --description "Surveyed Ironforge-side tram platform" --verified deeprun-tram-ironforge-platform +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json travel-capture --leader demo-leader-1 --type tram_entrance --name "Ironforge Deeprun Tram Exit" --description "Surveyed Ironforge-side tram exit" --verified ironforge-tram-exit +``` + +`tram-state` predicts Deeprun Tram car positions from `TransportAnimation.dbc`, preferring `Data/expansions//DBFilesClient/` for the selected fleet's configured client expansion and falling back to the default root `Data` DBC paths. CMaNGOS models Deeprun cars as `GAMEOBJECT_TYPE_TRANSPORT`/ElevatorTransport objects, not as boat-style rows in the `transports` table. Because the server clock is map-local, use `--calibrate` while the leader is near a known platform to find a useful `--time-offset-ms` for the current server run. The command prints platform distance (`stationXY`), a coarse platform-window flag (`atStation`), a tighter boarding candidate flag (`boardable`/`boardableAtStation`), leader horizontal distance (`xy`), and true 3D leader distance (`dist`) because tram model origins are vertically offset from the platform floor. + +`board-tram` actually boards a Deeprun car and rides it, instead of just predicting where one is. Deeprun cars are M2 transports, so `wowee_headless` boards the player client-side: `src/core/application.cpp` auto-attaches the player to any M2 transport within ~12 horizontal / ~15 vertical yards of its live tracked position (see `/world/self`'s `transport.onTransport`/`transport.guid`), the same way a real WoW client boards a moving tram or elevator without a scripted "get on" packet. So `board-tram` doesn't need the offline DBC cycle math at all: it polls `/world/entities?transports=1` for a live Deeprun car (entries 176080-176085), walks the leader onto its reported position every tick with `/movement/goto`, and watches `transport.onTransport` flip true. Once boarded it waits for the leader's (now tram-composed) position to reach the `--to` platform, then walks off toward that node's surveyed `exitSource` coordinates — standing still on a docked tram never disembarks you, since the boarding/disembark check only measures distance from the tram's *current* position, which stays constant relative to a stationary rider. Pass `--from`/`--to` as `tools/bot_fleet_manager/travel_nodes.json` node ids (e.g. `deeprun-tram-stormwind-platform` / `deeprun-tram-ironforge-platform`); omit `--from` if the leader is already standing at the platform, and omit `--to` to just board and stop there. `travel-node-execute` calls the same logic for any `ride_tram` link in a registered route. + +Start the MVP pathfinding service in another terminal: + +```bash +python tools/pathfinding_service/pathfinding_service.py --host 127.0.0.1 --port 8790 +``` + +Enable it in `fleet.settings.json`: + +```json +"pathfinding": { + "enabled": true, + "baseUrl": "http://127.0.0.1:8790", + "coordinateSpace": "wowee-canonical", + "timeoutSeconds": 45.0 +} +``` + +The service supports a direct-line backend for simple contract testing and an external CMaNGOS mmap helper backend for terrain-aware waypoint generation. See `tools/pathfinding_service/README.md` for helper build and deployment details. + +The manager writes generated per-leader settings under `tools/bot_fleet_manager/runtime/`, which is ignored by git. + +Supervisor logs are written under `tools/bot_fleet_manager/runtime/logs/`. diff --git a/tools/bot_fleet_manager/bot_fleet_manager.py b/tools/bot_fleet_manager/bot_fleet_manager.py new file mode 100644 index 000000000..183681851 --- /dev/null +++ b/tools/bot_fleet_manager/bot_fleet_manager.py @@ -0,0 +1,3726 @@ +#!/usr/bin/env python3 +"""Supervise multiple wowee_headless leader clients.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +TERMINAL_MOVEMENT_STATES = {"arrived", "failed", "movement_locked"} +TRAVEL_NODES_PATH = ROOT / "tools" / "bot_fleet_manager" / "travel_nodes.json" +LANDMARKS: dict[str, dict[str, Any]] = { + "goldshire": { + "mapId": 0, + "x": 63.52180099487305, + "y": -9480.08984375, + "z": 56.17570114135742, + "description": "Goldshire, Elwynn Forest", + }, + "stormwind": { + "mapId": 0, + "x": 364.0570068359375, + "y": -9153.76953125, + "z": 90.48290252685547, + "description": "Stormwind City", + }, + "ironforge": { + "mapId": 0, + "x": -834.0590209960938, + "y": -5021.0, + "z": 495.3190002441406, + "description": "Ironforge", + }, + "ironforge-probe": { + "mapId": 0, + "x": -834.0590209960938, + "y": -5021.0, + "z": 495.3190002441406, + "description": "Ironforge guarded partial-route probe", + }, + "kharanos": { + "mapId": 0, + "x": -482.12701416015625, + "y": -5585.9501953125, + "z": 397.0169982910156, + "description": "Kharanos, Dun Morogh", + }, + "crossroads": { + "mapId": 1, + "x": -2652.14990234375, + "y": -455.8999938964844, + "z": 95.58650207519531, + "description": "The Crossroads, Barrens", + }, + "ratchet": { + "mapId": 1, + "x": -3680.070068359375, + "y": -951.364013671875, + "z": 8.040180206298828, + "description": "Ratchet, Barrens", + }, + "exodar": { + "mapId": 530, + "x": -11873.599609375, + "y": -4007.31005859375, + "z": -0.5502870082855225, + "description": "The Exodar", + }, + "azure-watch": { + "mapId": 530, + "x": -12501.5, + "y": -4179.47021484375, + "z": 50.063499450683594, + "description": "Azure Watch, Azuremyst Isle", + }, +} + + +def load_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def write_json(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + + +def utc_timestamp() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def resolve_path(raw: str) -> Path: + path = Path(raw) + return path if path.is_absolute() else ROOT / path + + +def find_client_dbc_path(config: "FleetConfig", filename: str, leader: dict[str, Any] | None = None) -> Path | None: + client_defaults = config.defaults.get("client", {}) + client = {**client_defaults, **(leader.get("client", {}) if leader else {})} + expansion = str(client.get("expansion", "")).strip() + candidates: list[Path] = [] + if expansion: + expansion_root = ROOT / "Data" / "expansions" / expansion + candidates.extend([ + expansion_root / "DBFilesClient" / filename, + expansion_root / "dbfilesclient" / filename, + expansion_root / "db" / filename, + ]) + data_root = ROOT / "Data" + candidates.extend([ + data_root / "DBFilesClient" / filename, + data_root / "dbfilesclient" / filename, + data_root / "db" / filename, + ]) + for path in candidates: + if path.exists(): + return path + return None + + +def request_json(method: str, url: str, payload: dict[str, Any] | None = None, timeout: float = 3.0) -> dict[str, Any]: + if not url.startswith(("http://", "https://")): + raise ValueError(f"refusing non-http(s) URL: {url}") + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + # url is validated to http(s) above; this calls our own local fleet API, not attacker-controlled input. + with urllib.request.urlopen(req, timeout=timeout) as response: # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + # The headless client's HTTP API consistently encodes {"ok": false, "error": "..."} + # bodies for legitimate "request was fine, action just didn't succeed" outcomes + # (e.g. "no Flight Master found nearby") using a 400 status - not a genuinely + # malformed request. Letting HTTPError propagate here discarded that body + # entirely, so every caller's `result.get("error")` saw nothing and every + # failure surfaced as an opaque "HTTP Error 400: Bad Request" - live-hit this + # exact loss debugging why auto-learn-flight-paths kept silently giving up. + # Parse the body through like a normal response when possible; only re-raise + # if there genuinely isn't a JSON body to fall back on. + try: + return json.loads(exc.read().decode("utf-8")) + except Exception: + raise exc + + +class LeaderDiedError(TimeoutError): + """Raised mid-wait when the leader's health reports isDead/isPlayerDead, + so route execution stops cleanly with a clear reason instead of + continuing to issue movement commands for a dead character until + something else times out confusingly. Subclasses TimeoutError (not + RuntimeError) so it's automatically caught by every existing "this leg + failed" handler in this file without having to touch each one.""" + + +def _raise_if_dead(self_info: dict[str, Any]) -> None: + health = self_info.get("health", {}) + if health.get("isDead") or health.get("isPlayerDead"): + raise LeaderDiedError("leader died") + + +def point_distance(a: dict[str, Any], b: dict[str, Any]) -> float: + dx = float(b["x"]) - float(a["x"]) + dy = float(b["y"]) - float(a["y"]) + dz = float(b["z"]) - float(a["z"]) + return (dx * dx + dy * dy + dz * dz) ** 0.5 + + +def landmark_key(name: str) -> str: + return name.strip().lower().replace(" ", "-").replace("_", "-") + + +# Rough approximation of live aggro-radius scaling by level gap (creature +# level minus player level): higher-level mobs notice from farther away, +# mobs well below the player's level barely register at all. Not exact - +# real aggro radius also depends on creature type/rank - but good enough to +# stay well clear of anything that could plausibly aggro, which is the goal. +HOSTILE_BASE_RADIUS_YARDS = 20.0 +HOSTILE_LEVEL_GAP_YARDS_PER_LEVEL = 2.5 +HOSTILE_MIN_RADIUS_YARDS = 8.0 +HOSTILE_MAX_RADIUS_YARDS = 60.0 +HOSTILE_IGNORE_BELOW_LEVEL_GAP = -8 # mobs this far below player level: not a threat + + +def hostile_danger_radius(mob_level: int, player_level: int, distance_adjuster: float = 1.0) -> float | None: + """Returns the effective aggro-avoidance radius for a hostile at mob_level + against a player_level character, or None if the level gap makes it a + non-threat (safely gray/trivial).""" + level_gap = mob_level - player_level + if level_gap <= HOSTILE_IGNORE_BELOW_LEVEL_GAP: + return None + radius = HOSTILE_BASE_RADIUS_YARDS + HOSTILE_LEVEL_GAP_YARDS_PER_LEVEL * level_gap + radius = max(HOSTILE_MIN_RADIUS_YARDS, min(HOSTILE_MAX_RADIUS_YARDS, radius)) + return radius * distance_adjuster + + +def scan_hostile_threats( + config: FleetConfig, + leader: dict[str, Any], + index: int, + player_level: int, + scan_radius: float, + distance_adjuster: float = 1.0, +) -> list[dict[str, Any]]: + """Poll /world/entities and return hostile units (excluding the leader's + own entity) as threat circles: {x, y, z, dangerRadius, level, name}.""" + api_base = config.api_base_for(leader, index) + result = request_json("GET", f"{api_base}/world/entities?radius={scan_radius}") + self_guid = result.get("selfGuid") + threats: list[dict[str, Any]] = [] + for entity in result.get("entities", []): + if entity.get("type") != "PLAYER" and entity.get("type") != "UNIT": + continue + if not entity.get("hostile"): + continue + if self_guid is not None and entity.get("guid") == self_guid: + continue + # Our own character has shown up hostile=true against itself (a + # faction-reaction self-reference quirk) - exclude by name as a + # backstop even if selfGuid isn't present in the response. + if entity.get("name") == leader.get("character"): + continue + mob_level = entity.get("level") + if not isinstance(mob_level, int): + continue + radius = hostile_danger_radius(mob_level, player_level, distance_adjuster) + if radius is None: + continue + pos = entity.get("position", {}) + if "x" not in pos or "y" not in pos: + continue + threats.append({ + "x": float(pos["x"]), "y": float(pos["y"]), "z": float(pos.get("z", 0.0)), + "dangerRadius": radius, "level": mob_level, "name": entity.get("name", "?"), + }) + return threats + + +def nudge_waypoints_around_threats( + waypoints: list[dict[str, Any]], + threats: list[dict[str, Any]], + clearance_margin: float = 5.0, + max_passes: int = 6, +) -> tuple[list[dict[str, Any]], int]: + """Push any waypoint that falls inside a threat's danger radius directly + away from that threat until it clears the radius plus a margin. Threats + act like repulsive circles - the path bends around them without needing + a fresh pathfinding query. Runs multiple passes since pushing clear of + one threat can land inside another's radius when threats cluster (a + dense mob camp) - single-pass nudging left waypoints still inside + overlapping circles. Returns (adjusted_waypoints, nudged_count).""" + if not threats: + return waypoints, 0 + adjusted: list[dict[str, Any]] = [] + nudged_count = 0 + for wp in waypoints: + wx, wy = float(wp["x"]), float(wp["y"]) + wp_nudged = False + for _ in range(max_passes): + still_inside = False + for threat in threats: + dx = wx - threat["x"] + dy = wy - threat["y"] + dist = (dx * dx + dy * dy) ** 0.5 + target_clear = threat["dangerRadius"] + clearance_margin + if dist < target_clear: + if dist < 0.01: + dx, dy, dist = 1.0, 0.0, 1.0 + push = target_clear - dist + wx += (dx / dist) * push + wy += (dy / dist) * push + still_inside = True + wp_nudged = True + if not still_inside: + break + if wp_nudged: + nudged_count += 1 + new_wp = dict(wp) + new_wp["x"] = wx + new_wp["y"] = wy + adjusted.append(new_wp) + return adjusted, nudged_count + + +def resolve_landmark(name: str) -> dict[str, Any]: + key = landmark_key(name) + if key not in LANDMARKS: + known = ", ".join(sorted(LANDMARKS)) + raise ValueError(f"unknown landmark '{name}'. Known landmarks: {known}") + return LANDMARKS[key] + + +def load_travel_registry(path: Path | None = None) -> dict[str, Any]: + registry_path = path or TRAVEL_NODES_PATH + doc = load_json(registry_path) + nodes = doc.get("nodes", {}) + routes = doc.get("routes", {}) + if not isinstance(nodes, dict) or not nodes: + raise ValueError(f"travel registry has no nodes: {registry_path}") + if not isinstance(routes, dict): + raise ValueError(f"travel registry routes must be an object: {registry_path}") + return doc + + +def travel_node_key(name: str) -> str: + return name.strip().lower().replace(" ", "-").replace("_", "-") + + +def resolve_travel_node(registry: dict[str, Any], name: str) -> tuple[str, dict[str, Any]]: + key = travel_node_key(name) + nodes = registry.get("nodes", {}) + if key not in nodes: + known = ", ".join(sorted(nodes)) + raise ValueError(f"unknown travel node '{name}'. Known nodes: {known}") + node = nodes[key] + if not isinstance(node, dict): + raise ValueError(f"travel node '{key}' must be an object") + return key, node + + +def travel_node_position(node: dict[str, Any]) -> dict[str, Any] | None: + required = ("mapId", "x", "y", "z") + if not all(name in node for name in required): + return None + return { + "mapId": int(node["mapId"]), + "x": float(node["x"]), + "y": float(node["y"]), + "z": float(node["z"]), + } + + +def travel_node_label(node_id: str, node: dict[str, Any]) -> str: + name = str(node.get("name", node_id)) + node_type = str(node.get("type", "unknown")) + return f"{node_id} ({name}, {node_type})" + + +def find_travel_link(from_node: dict[str, Any], to_node_id: str) -> dict[str, Any] | None: + links = from_node.get("links", []) + if not isinstance(links, list): + return None + for link in links: + if isinstance(link, dict) and travel_node_key(str(link.get("to", ""))) == to_node_id: + return link + return None + + +def transition_step_name(mode: str) -> str: + if mode == "enter_instance": + return "enter_instance" + if mode == "exit_instance": + return "exit_instance" + if mode == "ride_tram": + return "board_transport + wait_transport + disembark" + if mode == "ride_boat": + return "board_transport + wait_transport + disembark" + if mode == "use_portal": + return "use_portal" + if mode == "use_flight": + return "use_flight" + return mode or "transition" + + +def route_node_ids(registry: dict[str, Any], route_or_nodes: list[str]) -> tuple[str, list[str]]: + routes = registry.get("routes", {}) + if len(route_or_nodes) == 1: + route_key = travel_node_key(route_or_nodes[0]) + if route_key in routes: + route = routes[route_key] + nodes = route.get("nodes", []) if isinstance(route, dict) else [] + if not isinstance(nodes, list) or not nodes: + raise ValueError(f"travel route '{route_key}' has no nodes") + return route_key, [travel_node_key(str(node_id)) for node_id in nodes] + return "custom", [travel_node_key(node_id) for node_id in route_or_nodes] + + +class FleetConfig: + def __init__(self, path: Path): + self.path = path + self.doc = load_json(path) + self.defaults = self.doc.get("defaults", {}) + self.leaders = self.doc.get("leaders", []) + self.runtime_dir = resolve_path(self.doc.get("runtimeDir", "tools/bot_fleet_manager/runtime")) + self.headless = resolve_path(self.doc.get("woweeHeadless", "build/bin/wowee_headless.exe")) + self.launch_delay = float(self.doc.get("launchDelaySeconds", 2.0)) + self.supervision = self.doc.get("supervision", {}) + self.pathfinding = self.doc.get("pathfinding", {}) + self.integrity_dir = self.doc.get("integrityDir", "") + if not self.leaders: + raise ValueError("fleet config must contain at least one leader") + + @property + def log_dir(self) -> Path: + return self.runtime_dir / "logs" + + @property + def route_state_dir(self) -> Path: + return self.runtime_dir / "route_state" + + @property + def initial_restart_backoff(self) -> float: + return float(self.supervision.get("initialRestartBackoffSeconds", 5.0)) + + @property + def max_restart_backoff(self) -> float: + return float(self.supervision.get("maxRestartBackoffSeconds", 60.0)) + + @property + def max_restarts(self) -> int: + return int(self.supervision.get("maxRestarts", 0)) + + @property + def pathfinding_base_url(self) -> str: + return str(self.pathfinding.get("baseUrl", "http://127.0.0.1:8790")).rstrip("/") + + @property + def pathfinding_enabled(self) -> bool: + return bool(self.pathfinding.get("enabled", False)) + + @property + def pathfinding_timeout(self) -> float: + return float(self.pathfinding.get("timeoutSeconds", 45.0)) + + def api_port_for(self, leader: dict[str, Any], index: int) -> int: + api_defaults = self.defaults.get("api", {}) + return int(leader.get("apiPort", int(api_defaults.get("basePort", 8787)) + index)) + + def api_base_for(self, leader: dict[str, Any], index: int) -> str: + bind = self.defaults.get("api", {}).get("bind", "127.0.0.1") + return f"http://{bind}:{self.api_port_for(leader, index)}" + + def leader_settings(self, leader: dict[str, Any], index: int) -> dict[str, Any]: + api_defaults = self.defaults.get("api", {}) + automation_defaults = self.defaults.get("automation", {}) + startup_commands = list(automation_defaults.get("onEnterWorldCommands", [])) + startup_commands.extend(leader.get("startupCommands", [])) + auth_settings = { + **self.defaults.get("auth", {}), + "account": leader["account"], + "password": leader["password"], + } + for optional_auth_key in ("pin", "totpSecret"): + if leader.get(optional_auth_key): + auth_settings[optional_auth_key] = leader[optional_auth_key] + + return { + "auth": auth_settings, + "client": self.defaults.get("client", {}), + "realm": leader.get("realm", self.defaults.get("realm", {})), + "character": {"name": leader["character"]}, + "chat": {"autoJoinDefaultChannels": bool(leader.get("autoJoinDefaultChannels", False))}, + "bots": { + "enabled": bool(leader.get("partyBots")), + "names": leader.get("partyBots", []), + }, + "automation": { + "commandDelaySeconds": float(leader.get( + "commandDelaySeconds", + automation_defaults.get("commandDelaySeconds", 0.25), + )), + "onEnterWorldCommands": startup_commands, + }, + "api": { + "enabled": True, + "bind": api_defaults.get("bind", "127.0.0.1"), + "port": self.api_port_for(leader, index), + "maxMessages": int(api_defaults.get("maxMessages", 500)), + }, + } + + def write_runtime_settings(self) -> list[tuple[dict[str, Any], Path]]: + written: list[tuple[dict[str, Any], Path]] = [] + for index, leader in enumerate(self.leaders): + leader_id = leader.get("id", f"leader-{index + 1}") + path = self.runtime_dir / f"{leader_id}.settings.json" + write_json(path, self.leader_settings(leader, index)) + written.append((leader, path)) + return written + + def selected_leaders(self, fleet: str = "", leader_ids: list[str] | None = None) -> list[tuple[int, dict[str, Any]]]: + want_ids = set(leader_ids or []) + selected: list[tuple[int, dict[str, Any]]] = [] + for index, leader in enumerate(self.leaders): + leader_id = leader.get("id", f"leader-{index + 1}") + if fleet and leader.get("fleet", "") != fleet: + continue + if want_ids and leader_id not in want_ids: + continue + selected.append((index, leader)) + return selected + + def route_state_path(self, leader_id: str) -> Path: + return self.route_state_dir / f"{leader_id}.json" + + +def runtime_env(config: FleetConfig | None = None, leader: dict[str, Any] | None = None) -> dict[str, str]: + env = os.environ.copy() + path_entries: list[str] = [] + msys_ucrt = Path("C:/msys64/ucrt64/bin") + if sys.platform == "win32" and msys_ucrt.exists(): + path_entries.append(str(msys_ucrt)) + if path_entries: + env["PATH"] = os.pathsep.join(path_entries + [env.get("PATH", "")]) + if config and config.integrity_dir: + env["WOWEE_INTEGRITY_DIR"] = str(resolve_path(config.integrity_dir)) + if config: + # wowee_headless is built with WOWEE_HEADLESS_DEFAULT=1, which makes the + # client skip SMSG_UPDATE_OBJECT/SMSG_COMPRESSED_UPDATE_OBJECT (and other + # world-simulation packets) entirely, so it never learns about nearby + # entities - other players, NPCs, or GameObjects like the Deeprun Tram or a + # Flight Master. Full simulation is the sane default: a leader that can't + # see anything around it is the "special", CPU-optimized case, not the + # normal one, and this exact default silently defeated an entire debugging + # session (learn-flight-path failing with "no Flight Master found nearby" + # while standing right in front of one, since entity tracking was off with + # no explicit signal that it was). Set fullWorldSimulation: false on a + # leader (or in defaults.automation) to opt back into the lightweight, + # entity-blind mode for large swarms that only need to walk fixed routes. + automation_defaults = config.defaults.get("automation", {}) + full_sim_default = bool(automation_defaults.get("fullWorldSimulation", True)) + full_sim = bool(leader.get("fullWorldSimulation", full_sim_default)) if leader else full_sim_default + env["WOWEE_HEADLESS"] = "0" if full_sim else "1" + return env + + +def creation_flags() -> int: + return subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0 + + +def describe_exit_code(return_code: int) -> str: + if return_code == 0: + return "clean exit" + if return_code == 139: + return "SIGSEGV caught by crash handler" + if return_code == 134: + return "SIGABRT caught by crash handler" + if sys.platform == "win32" and return_code < 0: + return f"terminated by signal {-return_code}" + if sys.platform == "win32": + unsigned = return_code & 0xFFFFFFFF + if unsigned == 0xC0000005: + return "access violation (0xC0000005)" + if unsigned == 0xC0000409: + return "stack buffer overrun / fast fail (0xC0000409)" + if unsigned == 0xC0000374: + return "heap corruption (0xC0000374)" + return f"Windows exception 0x{unsigned:08X}" + if return_code < 0: + return f"terminated by signal {-return_code}" + return f"exit code {return_code}" + + +def log_timestamp() -> str: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + + +class ManagedLeader: + def __init__(self, config: FleetConfig, leader: dict[str, Any], index: int, settings_path: Path, verbose: bool = False): + self.config = config + self.leader = leader + self.index = index + self.settings_path = settings_path + self.leader_id = leader.get("id", f"leader-{index + 1}") + self.process: subprocess.Popen[Any] | None = None + self.log_handle: Any = None + self.stream_thread: threading.Thread | None = None + self.restart_count = 0 + self.next_start_at = 0.0 + self.backoff = config.initial_restart_backoff + self.disabled = False + self.verbose = verbose + self.log_path = self.config.log_dir / f"{self.leader_id}.log" + self.crash_log_path = self.config.log_dir / f"{self.leader_id}.crash.log" + + def stream_output(self) -> None: + if not self.process or not self.process.stdout: + return + try: + for line in self.process.stdout: + stamped_line = f"[{log_timestamp()}] {line}" + if self.log_handle: + self.log_handle.write(stamped_line) + self.log_handle.flush() + if self.verbose: + print(f"[{self.leader_id}] {line}", end="", flush=True) + except ValueError: + return + + def start(self) -> None: + self.config.log_dir.mkdir(parents=True, exist_ok=True) + if self.log_handle: + self.log_handle.close() + self.log_handle = self.log_path.open("a", encoding="utf-8", errors="replace") + run_stamp = log_timestamp() + separator = f"\n[{run_stamp}] --- {self.leader_id} run start restart={self.restart_count} ---\n" + self.log_handle.write(separator) + self.log_handle.flush() + child_env = runtime_env(self.config, self.leader) + child_env["WOWEE_CRASH_LOG"] = str(self.crash_log_path) + print(f"Starting {self.leader_id} with {self.settings_path}; log={self.log_path}; crashLog={self.crash_log_path}") + self.process = subprocess.Popen( + [str(self.config.headless), str(self.settings_path)], + cwd=str(ROOT), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + creationflags=creation_flags(), + env=child_env, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + self.stream_thread = threading.Thread(target=self.stream_output, daemon=True) + self.stream_thread.start() + + def poll(self) -> None: + if self.disabled: + return + now = time.monotonic() + if self.process is None: + if now >= self.next_start_at: + self.start() + return + + rc = self.process.poll() + if rc is None: + return + + exit_description = describe_exit_code(rc) + print(f"{self.leader_id}: exited with code {rc} ({exit_description})") + self.process = None + if self.stream_thread: + self.stream_thread.join(timeout=2.0) + self.stream_thread = None + if self.log_handle: + self.log_handle.write(f"[{log_timestamp()}] --- {self.leader_id} exited with code {rc} ({exit_description}) ---\n") + self.log_handle.flush() + if self.log_handle: + self.log_handle.close() + self.log_handle = None + + # Exit code 0 = clean logout/shutdown; don't restart + if rc == 0: + print(f"{self.leader_id}: clean exit; disabling auto-restart") + self.disabled = True + return + + self.restart_count += 1 + if self.config.max_restarts > 0 and self.restart_count > self.config.max_restarts: + print(f"{self.leader_id}: restart limit reached; disabled") + self.disabled = True + return + + delay = self.backoff + self.backoff = min(self.backoff * 2.0, self.config.max_restart_backoff) + self.next_start_at = now + delay + print(f"{self.leader_id}: restart {self.restart_count} scheduled in {delay:.1f}s") + + def stop(self) -> None: + self.disabled = True + if self.process and self.process.poll() is None: + print(f"Stopping {self.leader_id}") + self.process.terminate() + try: + self.process.wait(timeout=8) + except subprocess.TimeoutExpired: + print(f"{self.leader_id}: terminate timed out; killing") + self.process.kill() + if self.stream_thread: + self.stream_thread.join(timeout=2.0) + self.stream_thread = None + if self.log_handle: + self.log_handle.close() + self.log_handle = None + + +def cmd_start(config: FleetConfig) -> int: + if not config.headless.exists(): + print(f"Missing wowee_headless executable: {config.headless}", file=sys.stderr) + return 2 + + for index, (leader, settings_path) in enumerate(config.write_runtime_settings()): + leader_id = leader.get("id", f"leader-{index + 1}") + print(f"Starting {leader_id} with {settings_path}") + subprocess.Popen( + [str(config.headless), str(settings_path)], + cwd=str(ROOT), + creationflags=creation_flags(), + env=runtime_env(config, leader), + ) + time.sleep(config.launch_delay) + return 0 + + +def dashboard_targets(config: FleetConfig) -> list[dict[str, str]]: + return [ + { + "id": leader.get("id", f"leader-{index + 1}"), + "fleet": leader.get("fleet", ""), + "base": config.api_base_for(leader, index), + } + for index, leader in enumerate(config.leaders) + ] + + +def start_dashboard_thread(config: FleetConfig, host: str, port: int) -> threading.Thread: + try: + from team_status_server import run_team_status_server + except ImportError: + from bot_fleet_manager.team_status_server import run_team_status_server + + thread = threading.Thread( + target=run_team_status_server, + args=(dashboard_targets(config), host, port), + daemon=True, + ) + thread.start() + return thread + + +def cmd_supervise( + config: FleetConfig, + dashboard: bool = False, + dashboard_host: str = "127.0.0.1", + dashboard_port: int = 8780, + verbose: bool = False, +) -> int: + if not config.headless.exists(): + print(f"Missing wowee_headless executable: {config.headless}", file=sys.stderr) + return 2 + + managed = [ + ManagedLeader(config, leader, index, settings_path, verbose=verbose) + for index, (leader, settings_path) in enumerate(config.write_runtime_settings()) + ] + + if dashboard: + start_dashboard_thread(config, dashboard_host, dashboard_port) + + try: + for leader in managed: + leader.poll() + time.sleep(config.launch_delay) + + print("Supervisor running. Press Ctrl-C to stop leader processes.") + while True: + for leader in managed: + leader.poll() + time.sleep(1.0) + except KeyboardInterrupt: + print("\nSupervisor stopping") + for leader in managed: + leader.stop() + return 0 + + +def cmd_status(config: FleetConfig, fleet: str = "", leader_ids: list[str] | None = None) -> int: + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + url = config.api_base_for(leader, index) + "/status" + try: + status = request_json("GET", url) + print(f"{leader_id}: {json.dumps(status, sort_keys=True)}") + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + print(f"{leader_id}: unavailable ({exc})") + return 0 + + +def post_all( + config: FleetConfig, + path: str, + payload: dict[str, Any], + fleet: str = "", + leader_ids: list[str] | None = None, +) -> int: + ok = True + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + url = config.api_base_for(leader, index) + path + try: + result = request_json("POST", url, payload) + print(f"{leader_id}: {json.dumps(result, sort_keys=True)}") + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + ok = False + print(f"{leader_id}: failed ({exc})") + return 0 if ok else 1 + + +def cmd_logout( + config: FleetConfig, + timeout_seconds: float, + fleet: str = "", + leader_ids: list[str] | None = None, +) -> int: + """Request a clean CMSG_LOGOUT_REQUEST for each leader and wait for its + process to exit on its own (see updateLogoutExit() in main.cpp), instead + of force-killing it - avoids the unclean-disconnect position/save issues + force-kills can cause.""" + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + ok = True + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + api_base = config.api_base_for(leader, index) + try: + request_json("POST", api_base + "/logout", {}) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + print(f"{leader_id}: logout request failed ({exc})") + ok = False + continue + print(f"{leader_id}: logout requested, waiting for process to exit") + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + try: + request_json("GET", api_base + "/status", timeout=2.0) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError): + print(f"{leader_id}: exited cleanly") + break + time.sleep(1.0) + else: + print(f"{leader_id}: still responding after {timeout_seconds:.0f}s; " + f"process may need a manual stop") + ok = False + return 0 if ok else 1 + + +def leader_position(config: FleetConfig, leader: dict[str, Any], index: int) -> dict[str, Any]: + return request_json("GET", config.api_base_for(leader, index) + "/world/self") + + +def leader_status(config: FleetConfig, leader: dict[str, Any], index: int) -> dict[str, Any]: + return request_json("GET", config.api_base_for(leader, index) + "/status") + + +def current_position_node(config: FleetConfig, leader: dict[str, Any], index: int) -> dict[str, Any]: + self_info = leader_position(config, leader, index) + current = self_info.get("position", {}) + return { + "mapId": int(self_info.get("mapId", current.get("mapId", 0))), + "x": float(current["x"]), + "y": float(current["y"]), + "z": float(current["z"]), + "orientation": float(self_info.get("orientation", 0.0)), + "movementFlags": int(self_info.get("movementFlags", 0) or 0), + "movementFlags2": int(self_info.get("movementFlags2", 0) or 0), + "status": str(self_info.get("status", "")), + "character": self_info.get("character", {}), + } + + +def default_survey_path(config: FleetConfig, label: str) -> Path: + safe_label = travel_node_key(label or "survey") + stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime()) + return config.runtime_dir / "travel_surveys" / f"{stamp}-{safe_label}.jsonl" + + +def survey_sample_summary( + config: FleetConfig, + leader: dict[str, Any], + index: int, + label: str, + include_raw: bool, +) -> dict[str, Any]: + leader_id = leader.get("id", f"leader-{index + 1}") + self_info = leader_position(config, leader, index) + status_info = leader_status(config, leader, index) + position = self_info.get("position", {}) + sample = { + "sampledAt": utc_timestamp(), + "label": label, + "leaderId": leader_id, + "character": self_info.get("character", {}), + "status": self_info.get("status", ""), + "inWorld": bool(self_info.get("inWorld", False)), + "mapId": int(self_info.get("mapId", 0) or 0), + "position": { + "x": float(position.get("x", 0.0)), + "y": float(position.get("y", 0.0)), + "z": float(position.get("z", 0.0)), + }, + "orientation": float(self_info.get("orientation", 0.0) or 0.0), + "movementFlags": int(self_info.get("movementFlags", 0) or 0), + "movementFlags2": int(self_info.get("movementFlags2", 0) or 0), + "runSpeed": float(self_info.get("runSpeed", 0.0) or 0.0), + "combat": self_info.get("combat", {}), + "health": self_info.get("health", {}), + "movement": status_info.get("movement", self_info.get("movement", {})), + } + if include_raw: + sample["rawWorldSelf"] = self_info + sample["rawStatus"] = status_info + return sample + + +def sample_changed(previous: dict[str, Any] | None, current: dict[str, Any], min_distance: float) -> bool: + if previous is None: + return True + if int(previous.get("mapId", -1)) != int(current.get("mapId", -2)): + return True + previous_pos = previous.get("position", {}) + current_pos = current.get("position", {}) + distance = point_distance(previous_pos, current_pos) + if distance >= min_distance: + return True + keys = ("status", "movementFlags", "movementFlags2") + if any(previous.get(key) != current.get(key) for key in keys): + return True + previous_movement = previous.get("movement", {}) + current_movement = current.get("movement", {}) + return previous_movement.get("state") != current_movement.get("state") + + +def save_route_state( + config: FleetConfig, + leader_id: str, + state: str, + target: dict[str, Any], + detail: dict[str, Any] | None = None, +) -> None: + payload = { + "leaderId": leader_id, + "state": state, + "updatedAt": utc_timestamp(), + "target": target, + } + if detail: + payload.update(detail) + write_json(config.route_state_path(leader_id), payload) + + +def load_route_state(config: FleetConfig, leader_id: str) -> dict[str, Any]: + path = config.route_state_path(leader_id) + if not path.exists(): + raise FileNotFoundError(f"no route state for {leader_id}: {path}") + return load_json(path) + + +def cmd_pathfind_goto( + config: FleetConfig, + map_id: int, + x: float, + y: float, + z: float, + arrival_radius: float, + fleet: str = "", + leader_ids: list[str] | None = None, +) -> int: + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + if not config.pathfinding_enabled: + print("Warning: pathfinding.enabled is false; using configured service anyway") + + ok = True + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + try: + self_info = leader_position(config, leader, index) + position = self_info.get("position", {}) + current_map = int(self_info.get("mapId", map_id)) + if current_map != map_id: + raise ValueError(f"leader is on map {current_map}, destination is on map {map_id}") + + path_request = { + "mapId": map_id, + "start": { + "x": float(position["x"]), + "y": float(position["y"]), + "z": float(position["z"]), + }, + "end": {"x": x, "y": y, "z": z}, + "coordinateSpace": "wowee-canonical", + "outputSpace": "wowee-canonical", + } + path_result = request_json( + "POST", + config.pathfinding_base_url + "/path", + path_request, + timeout=config.pathfinding_timeout, + ) + if not path_result.get("ok", False): + raise RuntimeError(path_result.get("error", "pathfinding failed")) + waypoints = path_result.get("waypoints", []) + if not isinstance(waypoints, list) or not waypoints: + raise RuntimeError("pathfinding returned no waypoints") + + movement = request_json("POST", config.api_base_for(leader, index) + "/movement/goto/waypoints", { + "mapId": map_id, + "waypoints": waypoints, + "arrivalRadius": arrival_radius, + }) + backend = path_result.get("backend", "unknown") + path_type = path_result.get("pathType", "unknown") + print(f"{leader_id}: path {backend}/{path_type} waypoints={len(waypoints)} movement={json.dumps(movement, sort_keys=True)}") + for warning in path_result.get("warnings", []): + print(f"{leader_id}: path warning: {warning}") + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, ValueError, RuntimeError) as exc: + ok = False + print(f"{leader_id}: pathfind-goto failed ({exc})") + return 0 if ok else 1 + + +def cmd_plan_route( + config: FleetConfig, + map_id: int, + x: float, + y: float, + z: float, + arrival_radius: float, + max_legs: int, + fleet: str = "", + leader_ids: list[str] | None = None, +) -> int: + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + if not config.pathfinding_enabled: + print("Warning: pathfinding.enabled is false; using configured service anyway") + + ok = True + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + try: + self_info = leader_position(config, leader, index) + position = self_info.get("position", {}) + current_map = int(self_info.get("mapId", map_id)) + if current_map != map_id: + raise ValueError(f"leader is on map {current_map}, destination is on map {map_id}") + + route_request = { + "mapId": map_id, + "start": { + "x": float(position["x"]), + "y": float(position["y"]), + "z": float(position["z"]), + }, + "end": {"x": x, "y": y, "z": z}, + "coordinateSpace": "wowee-canonical", + "outputSpace": "wowee-canonical", + "arrivalRadius": arrival_radius, + "maxLegs": max_legs, + } + route_result = request_json( + "POST", + config.pathfinding_base_url + "/route", + route_request, + timeout=config.pathfinding_timeout, + ) + if not route_result.get("ok", False): + raise RuntimeError(route_result.get("error", "route planning failed")) + + status = route_result.get("routeStatus", "unknown") + legs = route_result.get("legs", []) + waypoints = route_result.get("waypoints", []) + print(f"{leader_id}: route status={status} legs={len(legs)} waypoints={len(waypoints)}") + for leg in legs: + before = float(leg.get("distanceToGoalBefore", 0.0)) + after = float(leg.get("distanceToGoalAfter", 0.0)) + progress = float(leg.get("progressYards", 0.0)) + print( + f" leg {leg.get('index')}: {leg.get('backend')}/{leg.get('pathType')} " + f"waypoints={leg.get('waypointCount')} progress={progress:.1f}y " + f"remaining {before:.1f}y -> {after:.1f}y" + ) + for warning in route_result.get("warnings", []): + print(f"{leader_id}: route warning: {warning}") + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, ValueError, RuntimeError) as exc: + ok = False + print(f"{leader_id}: plan-route failed ({exc})") + return 0 if ok else 1 + + +def wait_for_movement( + api_base: str, + timeout_seconds: float, + poll_interval: float, +) -> dict[str, Any]: + deadline = time.monotonic() + timeout_seconds + last_state = "" + last_waypoint = 0 + transient_errors = 0 + while time.monotonic() < deadline: + try: + status = request_json("GET", api_base + "/status", timeout=max(2.0, poll_interval + 1.0)) + transient_errors = 0 + except (urllib.error.URLError, TimeoutError, OSError) as exc: + transient_errors += 1 + if transient_errors == 1 or transient_errors % 5 == 0: + print(f" transient status poll failure #{transient_errors}: {exc}") + time.sleep(poll_interval) + continue + if status.get("health", {}).get("isDead") or status.get("health", {}).get("isPlayerDead"): + print(" leader died mid-movement") + try: + request_json("POST", api_base + "/movement/stop", {"reason": "leader died"}) + except Exception: + pass + raise LeaderDiedError("leader died") + movement = status.get("movement", {}) + state = str(movement.get("state", "idle")) + waypoint = int(movement.get("waypointIndex", 0) or 0) + waypoint_count = int(movement.get("waypointCount", 0) or 0) + if state != last_state or waypoint != last_waypoint: + print(f" movement state={state} waypoint={waypoint}/{waypoint_count}") + last_state = state + last_waypoint = waypoint + if state in TERMINAL_MOVEMENT_STATES or not bool(movement.get("active", False)): + return status + time.sleep(poll_interval) + request_json("POST", api_base + "/movement/stop", {"reason": "route-goto leg timeout"}) + raise TimeoutError(f"movement did not finish within {timeout_seconds:.1f}s") + + +def post_waypoint_movement( + api_base: str, + map_id: int, + waypoints: list[Any], + arrival_radius: float, +) -> dict[str, Any]: + payload = { + "mapId": map_id, + "waypoints": waypoints, + "arrivalRadius": arrival_radius, + } + last_error: Exception | None = None + for attempt in range(1, 4): + try: + return request_json("POST", api_base + "/movement/goto/waypoints", payload) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + last_error = exc + print(f" movement POST transient failure #{attempt}: {exc}") + time.sleep(0.5) + try: + status = request_json("GET", api_base + "/status", timeout=3.0) + movement = status.get("movement", {}) + if movement.get("active") and movement.get("state") == "moving": + if int(movement.get("waypointCount", 0) or 0) == len(waypoints): + print(" movement command appears active after dropped POST response") + return {"ok": True, "movement": movement, "recoveredAfterPostError": True} + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError): + pass + raise RuntimeError(f"movement command failed after retries: {last_error}") + + +def execute_waypoints_with_avoidance( + config: FleetConfig, + leader: dict[str, Any], + index: int, + api_base: str, + map_id: int, + waypoints: list[dict[str, Any]], + arrival_radius: float, + leg_timeout: float, + poll_interval: float, + player_level: int, + hostile_distance_adjuster: float, + hostile_scan_radius: float, + leader_id: str, + chunk_size: int = 15, +) -> dict[str, Any]: + """Walk a leg's waypoints in small chunks, rescanning for hostile + threats and nudging the upcoming (not-yet-walked) waypoints between each + chunk. A single leg can span a thousand-plus yards - scanning only once + at the start missed threats far down the path entirely (live-observed: + walked straight into a second, more dangerous mob cluster ~350y past + where the leg began, since it was never within the initial scan + radius). Chunking makes the scan follow the leader instead of only + firing once at the leg's starting point.""" + remaining = list(waypoints) + final_status: dict[str, Any] = {} + while remaining: + chunk = remaining[:chunk_size] + remaining = remaining[chunk_size:] + movement = post_waypoint_movement(api_base, map_id, chunk, arrival_radius) + if not movement.get("ok", False): + raise RuntimeError(movement.get("error", "movement command failed")) + final_status = wait_for_movement(api_base, leg_timeout, poll_interval) + movement_status = final_status.get("movement", {}) + state = movement_status.get("state", "unknown") + if state != "arrived": + raise RuntimeError(f"movement ended as {state}: {movement_status.get('error', '')}") + if remaining: + threats = scan_hostile_threats( + config, leader, index, player_level, hostile_scan_radius, hostile_distance_adjuster, + ) + if threats: + remaining, nudged_count = nudge_waypoints_around_threats(remaining, threats) + if nudged_count: + names = ", ".join(f"{t['name']}(lvl{t['level']})" for t in threats) + print(f" mid-leg rescan: rerouting around {len(threats)} threat(s) [{names}], " + f"nudged {nudged_count}/{len(remaining)} upcoming waypoints") + return final_status + + +FLIGHT_MASTER_NPC_FLAG = 0x2000 + + +def _find_flight_master_position(api_base: str, search_radius: float) -> dict[str, Any] | None: + try: + result = request_json("GET", f"{api_base}/world/entities?radius={search_radius}") + except Exception: + return None + map_id = result.get("mapId", 0) + for entity in result.get("entities", []): + if entity.get("type") != "UNIT" or (entity.get("npcFlags", 0) & FLIGHT_MASTER_NPC_FLAG) == 0: + continue + pos = entity.get("position", {}) + if "x" not in pos or "y" not in pos: + continue + return {"mapId": map_id, "x": float(pos["x"]), "y": float(pos["y"]), "z": float(pos.get("z", 0.0))} + return None + + +def _try_auto_learn_flight_path(api_base: str, prefix: str, search_radius: float = 30.0) -> None: + """Best-effort: checks for a Flight Master within search_radius and + learns it if found. Every give-up path prints why - this used to be + entirely silent on failure (a bare `return` at each step), which made a + genuine "no Flight Master here" indistinguishable in the field from a + network hiccup, a timing race, or an unverified/inaccurate travel-node + coordinate placing the real NPC outside search_radius. Live-hit this + exact ambiguity: a leader "arrived" at a travel node 7.9y from its + recorded (unverified) coordinate and knownNodes stayed empty with zero + output anywhere in the chain. + + /learn-flight-path's own search radius is wider than the real + interaction range CMaNGOS requires to actually accept + CMSG_TAXIQUERYAVAILABLENODES (~3-5y, same class of gotcha as the spirit + healer) - firing from further away silently does nothing even though the + NPC shows up in the entity scan. Walk in close first when needed.""" + try: + result = request_json("POST", api_base + "/learn-flight-path", {"searchRadius": search_radius}) + except Exception as exc: + print(f"{prefix}auto-learn-flight-paths: /learn-flight-path request failed ({exc})") + return + if not result.get("ok", False): + print(f"{prefix}auto-learn-flight-paths: {result.get('error', 'unknown error')} " + f"(searchRadius={search_radius})") + return + taxi = result.get("taxi", {}) + if taxi.get("windowOpen"): + print(f"{prefix}auto-learn-flight-paths: near '{taxi.get('nearestNodeName')}' " + f"(known={taxi.get('nearestNodeKnown')})") + return + + # Wasn't close enough for the server to actually accept it - find the + # NPC's real position and walk within interaction range, then retry once. + print(f"{prefix}auto-learn-flight-paths: Flight Master found but too far for the " + f"server to accept the query - walking closer") + fm_pos = _find_flight_master_position(api_base, search_radius) + if fm_pos is None: + print(f"{prefix}auto-learn-flight-paths: no Flight Master within {search_radius}y " + f"on retry scan - likely an inaccurate/unverified travel-node coordinate") + return + try: + request_json("POST", api_base + "/movement/goto", { + "mapId": fm_pos["mapId"], "x": fm_pos["x"], "y": fm_pos["y"], "z": fm_pos["z"], + "arrivalRadius": 3.0, + }) + except Exception as exc: + print(f"{prefix}auto-learn-flight-paths: /movement/goto to Flight Master failed ({exc})") + return + deadline = time.monotonic() + 30.0 + reached = False + while time.monotonic() < deadline: + try: + here = request_json("GET", f"{api_base}/world/self").get("position", {}) + except Exception: + time.sleep(1.0) + continue + if point_distance(here, fm_pos) <= 5.0: + reached = True + break + time.sleep(1.0) + if not reached: + print(f"{prefix}auto-learn-flight-paths: did not reach the Flight Master within 30s, " + f"retrying /learn-flight-path anyway") + try: + result = request_json("POST", api_base + "/learn-flight-path", {"searchRadius": 10.0}) + except Exception as exc: + print(f"{prefix}auto-learn-flight-paths: retry /learn-flight-path request failed ({exc})") + return + if result.get("ok", False): + taxi = result.get("taxi", {}) + print(f"{prefix}auto-learn-flight-paths: near '{taxi.get('nearestNodeName')}' " + f"(known={taxi.get('nearestNodeKnown')})") + else: + print(f"{prefix}auto-learn-flight-paths: retry still failed - " + f"{result.get('error', 'unknown error')}") + + +def cmd_route_goto( + config: FleetConfig, + map_id: int, + x: float, + y: float, + z: float, + arrival_radius: float, + max_legs: int, + leg_timeout: float, + poll_interval: float, + min_progress_yards: float, + fleet: str = "", + leader_ids: list[str] | None = None, + avoid_hostiles: bool = False, + hostile_distance_adjuster: float = 1.0, + hostile_scan_radius: float = 100.0, + resume_through_death: bool = False, + max_death_recoveries: int = 25, + death_recovery_timeout: float = 180.0, + auto_learn_flight_paths: bool = False, +) -> int: + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + if not config.pathfinding_enabled: + print("Warning: pathfinding.enabled is false; using configured service anyway") + + destination = {"x": x, "y": y, "z": z} + ok = True + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + api_base = config.api_base_for(leader, index) + print(f"{leader_id}: guarded route-goto map={map_id} destination={destination}") + save_route_state(config, leader_id, "running", { + "mapId": map_id, + **destination, + "arrivalRadius": arrival_radius, + }, {"startedAt": utc_timestamp(), "lastLeg": 0}) + try: + leg_number = 0 + deaths = 0 + arrived = False + while leg_number < max_legs: + self_info = leader_position(config, leader, index) + is_dead = self_info.get("health", {}).get("isPlayerDead") or self_info.get("death", {}).get("isGhost") + if is_dead: + if not resume_through_death: + raise LeaderDiedError("leader died") + deaths += 1 + if deaths > max_death_recoveries: + raise RuntimeError(f"exceeded {max_death_recoveries} death recoveries") + print(f"{leader_id}: died (death #{deaths}), recovering") + if not _recover_from_death(config, leader, index, poll_interval, death_recovery_timeout, + prefix=f"{leader_id}: ", resume_targets=[destination]): + raise RuntimeError("death recovery did not complete") + continue # death recoveries don't count against max_legs + + position = self_info.get("position", {}) + current_map = int(self_info.get("mapId", map_id)) + if current_map != map_id: + raise ValueError(f"leader is on map {current_map}, destination is on map {map_id}") + + remaining = point_distance(position, destination) + if remaining <= arrival_radius: + print(f"{leader_id}: arrived within {remaining:.1f} yards") + if auto_learn_flight_paths: + _try_auto_learn_flight_path(api_base, f"{leader_id}: ") + arrived = True + break + + leg_number += 1 + route_request = { + "mapId": map_id, + "start": { + "x": float(position["x"]), + "y": float(position["y"]), + "z": float(position["z"]), + }, + "end": destination, + "coordinateSpace": "wowee-canonical", + "outputSpace": "wowee-canonical", + "arrivalRadius": arrival_radius, + "maxLegs": 1, + "minProgressYards": min_progress_yards, + } + route_result = request_json( + "POST", + config.pathfinding_base_url + "/route", + route_request, + timeout=config.pathfinding_timeout, + ) + if not route_result.get("ok", False): + raise RuntimeError(route_result.get("error", "route planning failed")) + legs = route_result.get("legs", []) + waypoints = route_result.get("waypoints", []) + route_status = route_result.get("routeStatus", "unknown") + if not isinstance(legs, list) or not legs: + if route_status == "complete": + print(f"{leader_id}: planner reports complete") + break + raise RuntimeError(f"planner returned no executable legs; status={route_status}") + if not isinstance(waypoints, list) or not waypoints: + raise RuntimeError(f"planner returned no executable waypoints; status={route_status}") + + if avoid_hostiles: + player_level = self_info.get("character", {}).get("level") + if isinstance(player_level, int): + threats = scan_hostile_threats( + config, leader, index, player_level, + hostile_scan_radius, hostile_distance_adjuster, + ) + if threats: + waypoints, nudged_count = nudge_waypoints_around_threats(waypoints, threats) + if nudged_count: + names = ", ".join(f"{t['name']}(lvl{t['level']})" for t in threats) + print(f"{leader_id}: rerouting around {len(threats)} threat(s) [{names}], " + f"nudged {nudged_count}/{len(waypoints)} waypoints") + + leg = legs[0] + progress = float(leg.get("progressYards", 0.0)) + after = float(leg.get("distanceToGoalAfter", remaining)) + print( + f"{leader_id}: leg {leg_number}/{max_legs} {leg.get('backend')}/{leg.get('pathType')} " + f"waypoints={len(waypoints)} progress={progress:.1f}y remaining={after:.1f}y " + f"plannerStatus={route_status}" + ) + save_route_state(config, leader_id, "running", { + "mapId": map_id, + **destination, + "arrivalRadius": arrival_radius, + }, { + "lastLeg": leg_number, + "lastPlannerStatus": route_status, + "remainingYards": after, + "progressYards": progress, + "waypointCount": len(waypoints), + }) + if progress < min_progress_yards and after > arrival_radius: + raise RuntimeError( + f"planner stalled: progress {progress:.1f}y below {min_progress_yards:.1f}y" + ) + + try: + if avoid_hostiles: + player_level = self_info.get("character", {}).get("level") + if not isinstance(player_level, int): + player_level = 1 + execute_waypoints_with_avoidance( + config, leader, index, api_base, map_id, waypoints, arrival_radius, + leg_timeout, poll_interval, player_level, + hostile_distance_adjuster, hostile_scan_radius, leader_id, + ) + else: + movement = post_waypoint_movement(api_base, map_id, waypoints, arrival_radius) + if not movement.get("ok", False): + raise RuntimeError(movement.get("error", "movement command failed")) + + final_status = wait_for_movement(api_base, leg_timeout, poll_interval) + movement_status = final_status.get("movement", {}) + state = movement_status.get("state", "unknown") + if state != "arrived": + raise RuntimeError(f"movement ended as {state}: {movement_status.get('error', '')}") + except LeaderDiedError: + if not resume_through_death: + raise + # Death mid-leg raises immediately, before movement ever + # reaches a terminal state - let the death check at the + # top of the loop handle recovery instead of propagating. + print(f"{leader_id}: leg interrupted by death, rechecking status") + continue + if not arrived: + raise RuntimeError(f"route-goto used all {max_legs} legs without arriving") + save_route_state(config, leader_id, "complete", { + "mapId": map_id, + **destination, + "arrivalRadius": arrival_radius, + }, {"completedAt": utc_timestamp()}) + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, KeyError, ValueError, RuntimeError) as exc: + ok = False + print(f"{leader_id}: route-goto failed ({exc})") + save_route_state(config, leader_id, "failed", { + "mapId": map_id, + **destination, + "arrivalRadius": arrival_radius, + }, {"failedAt": utc_timestamp(), "error": str(exc)}) + try: + request_json("POST", api_base + "/movement/stop", {"reason": "route-goto failed"}) + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError): + pass + return 0 if ok else 1 + + +def load_survey_catalog(path: Path) -> dict[str, Any]: + catalog = load_json(path) + if not isinstance(catalog.get("waypoints"), list) or not catalog["waypoints"]: + raise ValueError(f"{path}: no waypoints in catalog") + if catalog.get("mapId") is None: + raise ValueError(f"{path}: catalog has no mapId") + return catalog + + +def cmd_replay_survey( + config: FleetConfig, + catalog_path_str: str, + arrival_radius: float, + leg_timeout: float, + poll_interval: float, + chunk_size: int, + start_index: int, + avoid_hostiles: bool, + hostile_distance_adjuster: float, + hostile_scan_radius: float, + fleet: str = "", + leader_ids: list[str] | None = None, + reverse: bool = False, + resume_through_death: bool = False, + max_death_recoveries: int = 25, + death_recovery_timeout: float = 180.0, + auto_learn_flight_paths: bool = False, +) -> int: + """Walk a leader through a human-captured route catalog (see + follow_player.py) instead of the automated pathfinding service - a + human-verified path sidesteps whatever the pathfinder struggles with. + + reverse walks the catalog tail-to-head (e.g. replaying a one-way + Ironforge->Menethil survey to get back to Ironforge). resume_through_death + treats death as expected rather than fatal: it recovers the leader (ghost + walk to corpse + reclaim, via the same primitive recover-death uses), then + resumes from whichever waypoint is nearest the post-recovery position - + reclaiming lands you back near where you died, so this is normally just + the next handful of waypoints, not a big jump.""" + catalog = load_survey_catalog(Path(catalog_path_str)) + map_id = int(catalog["mapId"]) + all_waypoints = list(reversed(catalog["waypoints"])) if reverse else list(catalog["waypoints"]) + waypoints = all_waypoints[start_index:] + if not waypoints: + print(f"start_index {start_index} is past the end of the catalog " + f"({len(all_waypoints)} waypoints)") + return 1 + print(f"replaying '{catalog.get('routeName')}' ({catalog.get('mapName')}) " + f"{'reversed' if reverse else 'forward'}: {len(waypoints)} waypoints " + f"starting at index {start_index}, captured from {catalog.get('playerName')}") + + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + ok = True + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + prefix = f"{leader_id}: " + api_base = config.api_base_for(leader, index) + deaths = 0 + idx = 0 + try: + self_info = leader_position(config, leader, index) + already_dead = self_info.get("health", {}).get("isPlayerDead") or self_info.get("death", {}).get("isGhost") + if not already_dead: + current_map = int(self_info.get("mapId", map_id)) + if current_map != map_id: + raise ValueError(f"leader is on map {current_map}, survey is for map {map_id}") + + while idx < len(waypoints): + self_info = leader_position(config, leader, index) + is_dead = self_info.get("health", {}).get("isPlayerDead") or self_info.get("death", {}).get("isGhost") + if is_dead: + if not resume_through_death: + raise LeaderDiedError("leader died") + deaths += 1 + if deaths > max_death_recoveries: + raise RuntimeError(f"exceeded {max_death_recoveries} death recoveries") + print(f"{prefix}died (death #{deaths}), recovering") + if not _recover_from_death(config, leader, index, poll_interval, death_recovery_timeout, + prefix=prefix, resume_targets=waypoints): + raise RuntimeError("death recovery did not complete") + self_info = leader_position(config, leader, index) + pos = self_info.get("position", {}) + idx, nearest_d = min( + ((i, point_distance(pos, wp)) for i, wp in enumerate(waypoints)), + key=lambda t: t[1], + ) + print(f"{prefix}resuming survey at waypoint {start_index + idx}/{len(all_waypoints)} " + f"({nearest_d:.1f}y from recovery position)") + continue + + player_level = self_info.get("character", {}).get("level") + if not isinstance(player_level, int): + player_level = 1 + + chunk = waypoints[idx:idx + chunk_size] + wp_payload = [{"x": w["x"], "y": w["y"], "z": w["z"]} for w in chunk] + if avoid_hostiles: + threats = scan_hostile_threats( + config, leader, index, player_level, hostile_scan_radius, hostile_distance_adjuster, + ) + if threats: + wp_payload, nudged_count = nudge_waypoints_around_threats(wp_payload, threats) + if nudged_count: + names = ", ".join(f"{t['name']}(lvl{t['level']})" for t in threats) + print(f"{prefix}rerouting around {len(threats)} threat(s) [{names}], " + f"nudged {nudged_count}/{len(wp_payload)} waypoints") + + movement = post_waypoint_movement(api_base, map_id, wp_payload, arrival_radius) + if not movement.get("ok", False): + raise RuntimeError(movement.get("error", "movement command failed")) + try: + final_status = wait_for_movement(api_base, leg_timeout, poll_interval) + except LeaderDiedError: + if not resume_through_death: + raise + # wait_for_movement raises as soon as it sees death, + # before movement ever reaches a terminal state - catch + # it here (not just the state!="arrived" case below) so + # the death check at the top of the loop gets a chance to + # recover instead of this propagating straight out. + print(f"{prefix}chunk interrupted by death, rechecking status") + continue + state = final_status.get("movement", {}).get("state", "unknown") + if state == "arrived": + idx += len(chunk) + print(f"{prefix}reached waypoint {start_index + idx}/{len(all_waypoints)}") + if auto_learn_flight_paths: + _try_auto_learn_flight_path(api_base, prefix) + elif resume_through_death: + # Most likely died mid-chunk; loop back around and let + # the death check above handle it (or surface a clearer + # error on the next pass if it wasn't death-related). + print(f"{prefix}chunk ended as '{state}', rechecking status") + else: + raise RuntimeError(f"movement ended as {state}") + print(f"{prefix}survey replay complete" + (f" ({deaths} death(s) recovered)" if deaths else "")) + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, KeyError, ValueError, RuntimeError) as exc: + ok = False + print(f"{prefix}survey replay failed ({exc})") + try: + request_json("POST", api_base + "/movement/stop", {"reason": "survey replay failed"}) + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError): + pass + return 0 if ok else 1 + + +def cmd_landmarks() -> int: + for name in sorted(LANDMARKS): + target = LANDMARKS[name] + print( + f"{name}: map={target['mapId']} " + f"x={target['x']} y={target['y']} z={target['z']} - {target['description']}" + ) + return 0 + + +def cmd_travel_nodes(registry_path: Path | None, show_routes: bool) -> int: + registry = load_travel_registry(registry_path) + nodes = registry.get("nodes", {}) + for node_id in sorted(nodes): + node = nodes[node_id] + if not isinstance(node, dict): + continue + pos = travel_node_position(node) + flags: list[str] = [] + if node.get("verified"): + flags.append("verified") + if node.get("requiresSurvey"): + flags.append("survey") + suffix = f" [{' '.join(flags)}]" if flags else "" + if pos: + print( + f"{node_id}: type={node.get('type', 'unknown')} map={pos['mapId']} " + f"x={pos['x']} y={pos['y']} z={pos['z']} - {node.get('description', node.get('name', ''))}{suffix}" + ) + else: + print( + f"{node_id}: type={node.get('type', 'unknown')} " + f"- {node.get('description', node.get('name', ''))}{suffix}" + ) + + if show_routes: + routes = registry.get("routes", {}) + print("\nRoutes:") + for route_id in sorted(routes): + route = routes[route_id] + if not isinstance(route, dict): + continue + node_count = len(route.get("nodes", [])) if isinstance(route.get("nodes", []), list) else 0 + print(f"{route_id}: nodes={node_count} - {route.get('description', route.get('name', ''))}") + return 0 + + +def print_walk_plan( + config: FleetConfig, + leader_id: str, + step_index: int, + from_label: str, + from_pos: dict[str, Any], + to_label: str, + to_pos: dict[str, Any], + arrival_radius: float, + max_legs: int, +) -> bool: + if int(from_pos["mapId"]) != int(to_pos["mapId"]): + print( + f" {step_index}. map_transition_needed {from_label} -> {to_label}: " + f"map {from_pos['mapId']} -> {to_pos['mapId']} has no registered transport link" + ) + return True + + route_request = { + "mapId": int(to_pos["mapId"]), + "start": {"x": from_pos["x"], "y": from_pos["y"], "z": from_pos["z"]}, + "end": {"x": to_pos["x"], "y": to_pos["y"], "z": to_pos["z"]}, + "coordinateSpace": "wowee-canonical", + "outputSpace": "wowee-canonical", + "arrivalRadius": arrival_radius, + "maxLegs": max_legs, + } + try: + route_result = request_json( + "POST", + config.pathfinding_base_url + "/route", + route_request, + timeout=config.pathfinding_timeout, + ) + if not route_result.get("ok", False): + raise RuntimeError(route_result.get("error", "route planning failed")) + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, RuntimeError) as exc: + print(f" {step_index}. walk {from_label} -> {to_label}: path query failed for {leader_id}: {exc}") + return False + + status = route_result.get("routeStatus", "unknown") + legs = route_result.get("legs", []) + waypoints = route_result.get("waypoints", []) + print( + f" {step_index}. walk {from_label} -> {to_label}: " + f"status={status} legs={len(legs)} waypoints={len(waypoints)}" + ) + for leg in legs: + before = float(leg.get("distanceToGoalBefore", 0.0)) + after = float(leg.get("distanceToGoalAfter", 0.0)) + progress = float(leg.get("progressYards", 0.0)) + print( + f" leg {leg.get('index')}: {leg.get('backend')}/{leg.get('pathType')} " + f"waypoints={leg.get('waypointCount')} progress={progress:.1f}y " + f"remaining {before:.1f}y -> {after:.1f}y" + ) + for warning in route_result.get("warnings", []): + print(f" warning: {warning}") + return True + + +def cmd_travel_plan( + config: FleetConfig, + registry_path: Path | None, + route_or_nodes: list[str], + static: bool, + arrival_radius: float, + max_legs: int, + fleet: str = "", + leader_ids: list[str] | None = None, +) -> int: + registry = load_travel_registry(registry_path) + plan_name, node_ids = route_node_ids(registry, route_or_nodes) + nodes_by_id: list[tuple[str, dict[str, Any]]] = [ + resolve_travel_node(registry, node_id) for node_id in node_ids + ] + routes = registry.get("routes", {}) + route = routes.get(plan_name, {}) if isinstance(routes, dict) else {} + if isinstance(route, dict) and plan_name != "custom": + print(f"travel-plan {plan_name}: {route.get('description', route.get('name', ''))}") + else: + print(f"travel-plan custom: {' -> '.join(node_id for node_id, _node in nodes_by_id)}") + + if static: + selected: list[tuple[int, dict[str, Any] | None]] = [(-1, None)] + else: + selected = [(index, leader) for index, leader in config.selected_leaders(fleet, leader_ids)] + if not selected: + print("No matching leaders") + return 1 + if not config.pathfinding_enabled: + print("Warning: pathfinding.enabled is false; using configured service anyway") + + ok = True + for index, leader in selected: + if leader is None: + leader_id = "static" + pairs: list[tuple[str, dict[str, Any], str, dict[str, Any]]] = [] + else: + leader_id = leader.get("id", f"leader-{index + 1}") + try: + self_info = leader_position(config, leader, index) + current = self_info.get("position", {}) + current_node = { + "type": "current_position", + "mapId": int(self_info.get("mapId", current.get("mapId", 0))), + "x": float(current["x"]), + "y": float(current["y"]), + "z": float(current["z"]), + "name": "Current leader position", + } + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, KeyError, ValueError) as exc: + ok = False + print(f"{leader_id}: travel-plan failed to read leader position ({exc})") + continue + pairs = [("__current__", current_node, nodes_by_id[0][0], nodes_by_id[0][1])] + + pairs.extend( + (from_id, from_node, to_id, to_node) + for (from_id, from_node), (to_id, to_node) in zip(nodes_by_id, nodes_by_id[1:]) + ) + + print(f"{leader_id}: {len(pairs)} step(s)") + for step_index, (from_id, from_node, to_id, to_node) in enumerate(pairs, start=1): + from_label = travel_node_label(from_id, from_node) + to_label = travel_node_label(to_id, to_node) + link = None if from_id == "__current__" else find_travel_link(from_node, to_id) + if link: + mode = str(link.get("mode", "transition")) + transport = f" transport={link['transportMap']}" if "transportMap" in link else "" + print( + f" {step_index}. {transition_step_name(mode)} {from_label} -> {to_label}: " + f"{link.get('description', '')}{transport}" + ) + if to_node.get("requiresSurvey") or from_node.get("requiresSurvey"): + print(" survey required before this transition can be executed") + continue + + from_pos = travel_node_position(from_node) + to_pos = travel_node_position(to_node) + if not from_pos or not to_pos: + missing: list[str] = [] + if not from_pos: + missing.append(from_id) + if not to_pos: + missing.append(to_id) + print( + f" {step_index}. survey_needed {from_label} -> {to_label}: " + f"missing coordinates for {', '.join(missing)}" + ) + continue + + if not print_walk_plan( + config, + leader_id, + step_index, + from_label, + from_pos, + to_label, + to_pos, + arrival_radius, + max_legs, + ): + ok = False + return 0 if ok else 1 + + +def cmd_travel_survey( + config: FleetConfig, + label: str, + duration: float, + interval: float, + min_distance: float, + output: Path | None, + include_raw: bool, + fleet: str = "", + leader_ids: list[str] | None = None, +) -> int: + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + + output_path = resolve_path(str(output)) if output else default_survey_path(config, label) + output_path.parent.mkdir(parents=True, exist_ok=True) + deadline = time.monotonic() + duration if duration > 0 else None + last_samples: dict[str, dict[str, Any]] = {} + written = 0 + print(f"Writing travel survey samples to {output_path}") + print("Press Ctrl-C to stop." if deadline is None else f"Sampling for {duration:.1f}s.") + try: + with output_path.open("a", encoding="utf-8") as handle: + while deadline is None or time.monotonic() < deadline: + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + try: + sample = survey_sample_summary(config, leader, index, label, include_raw) + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, KeyError, ValueError) as exc: + print(f"{leader_id}: survey sample failed ({exc})") + continue + if not sample_changed(last_samples.get(leader_id), sample, min_distance): + continue + last_samples[leader_id] = sample + handle.write(json.dumps(sample, sort_keys=True) + "\n") + handle.flush() + written += 1 + pos = sample["position"] + movement = sample.get("movement", {}) + print( + f"{leader_id}: map={sample['mapId']} " + f"x={pos['x']:.3f} y={pos['y']:.3f} z={pos['z']:.3f} " + f"o={sample['orientation']:.3f} flags=0x{sample['movementFlags']:x} " + f"move={movement.get('state', '')}" + ) + time.sleep(max(0.1, interval)) + except KeyboardInterrupt: + print("Survey stopped.") + print(f"Wrote {written} sample(s)") + return 0 + + +def cmd_travel_capture( + config: FleetConfig, + registry_path: Path | None, + node_id: str, + node_type: str, + name: str, + description: str, + radius: float, + verified: bool, + fleet: str = "", + leader_ids: list[str] | None = None, +) -> int: + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + if len(selected) != 1: + print("travel-capture requires exactly one selected leader") + return 2 + + registry_path_resolved = resolve_path(str(registry_path)) if registry_path else TRAVEL_NODES_PATH + registry = load_travel_registry(registry_path_resolved) + nodes = registry.setdefault("nodes", {}) + if not isinstance(nodes, dict): + print("travel registry nodes must be an object") + return 2 + + index, leader = selected[0] + leader_id = leader.get("id", f"leader-{index + 1}") + key = travel_node_key(node_id) + try: + current = current_position_node(config, leader, index) + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, KeyError, ValueError) as exc: + print(f"{leader_id}: travel-capture failed ({exc})") + return 1 + + existing = nodes.get(key, {}) + if not isinstance(existing, dict): + existing = {} + character = current.get("character", {}) + updated = { + **existing, + "type": node_type, + "mapId": current["mapId"], + "x": current["x"], + "y": current["y"], + "z": current["z"], + "radius": radius, + "name": name or existing.get("name", key), + "description": description or existing.get("description", f"Captured from {leader_id}"), + "verified": verified, + "requiresSurvey": False, + "capturedAt": utc_timestamp(), + "source": f"travel-capture:{leader_id}", + "capture": { + "orientation": current["orientation"], + "movementFlags": current["movementFlags"], + "movementFlags2": current["movementFlags2"], + "character": character, + }, + } + if not verified: + updated["requiresSurvey"] = True + nodes[key] = updated + write_json(registry_path_resolved, registry) + print( + f"Captured {key}: map={updated['mapId']} " + f"x={updated['x']} y={updated['y']} z={updated['z']} " + f"verified={updated['verified']} registry={registry_path_resolved}" + ) + return 0 + + +def cmd_route_demo( + config: FleetConfig, + name: str, + plan_only: bool, + arrival_radius: float, + max_legs: int, + leg_timeout: float, + poll_interval: float, + min_progress_yards: float, + fleet: str = "", + leader_ids: list[str] | None = None, +) -> int: + target = resolve_landmark(name) + map_id = int(target["mapId"]) + x = float(target["x"]) + y = float(target["y"]) + z = float(target["z"]) + print(f"route-demo {landmark_key(name)}: {target['description']} map={map_id} x={x} y={y} z={z}") + if plan_only: + return cmd_plan_route( + config, + map_id, + x, + y, + z, + arrival_radius, + max_legs, + fleet, + leader_ids, + ) + return cmd_route_goto( + config, + map_id, + x, + y, + z, + arrival_radius, + max_legs, + leg_timeout, + poll_interval, + min_progress_yards, + fleet, + leader_ids, + ) + + +def _transition_step_label(mode: str, to_label: str) -> str: + return f"{transition_step_name(mode)} -> {to_label}" + + +def cmd_travel_node_execute( + config: FleetConfig, + registry_path: Path | None, + route_or_nodes: list[str], + arrival_radius: float, + max_legs: int, + leg_timeout: float, + poll_interval: float, + min_progress_yards: float, + fleet: str = "", + leader_ids: list[str] | None = None, + avoid_hostiles: bool = False, + hostile_distance_adjuster: float = 1.0, + hostile_scan_radius: float = 100.0, + resume_through_death: bool = False, + max_death_recoveries: int = 25, + death_recovery_timeout: float = 180.0, + auto_learn_flight_paths: bool = False, +) -> int: + registry = load_travel_registry(registry_path) + plan_name, node_ids = route_node_ids(registry, route_or_nodes) + nodes_by_id: list[tuple[str, dict[str, Any]]] = [ + resolve_travel_node(registry, node_id) for node_id in node_ids + ] + + selected = [(index, leader) for index, leader in config.selected_leaders(fleet, leader_ids)] + if not selected: + print("No matching leaders") + return 1 + + ok = True + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + api_base = config.api_base_for(leader, index) + + try: + self_info = leader_position(config, leader, index) + except Exception as exc: + print(f"{leader_id}: travel-node-execute failed to read leader position ({exc})") + ok = False + continue + + current = self_info.get("position", {}) + pairs: list[tuple[str, dict[str, Any], str, dict[str, Any]]] = [ + ("__current__", { + "type": "current_position", + "mapId": int(self_info.get("mapId", current.get("mapId", 0))), + "x": float(current["x"]), + "y": float(current["y"]), + "z": float(current["z"]), + "name": "Current leader position", + }, node_ids[0], nodes_by_id[0][1]) + ] + pairs.extend( + (from_id, from_node, to_id, to_node) + for (from_id, from_node), (to_id, to_node) in zip(nodes_by_id, nodes_by_id[1:]) + ) + + plan_state = { + "planName": plan_name, + "nodeIds": node_ids, + "stepCount": len(pairs), + } + + complete = True + for step_index, (from_id, from_node, to_id, to_node) in enumerate(pairs, start=1): + to_label = travel_node_label(to_id, to_node) + link = None if from_id == "__current__" else find_travel_link(from_node, to_id) + + detail = { + "planName": plan_name, + "nodeIds": node_ids, + "stepIndex": step_index, + "stepCount": len(pairs), + "fromNodeId": from_id, + "toNodeId": to_id, + } + + if link: + mode = str(link.get("mode", "transition")) + step_name = _transition_step_label(mode, to_label) + print(f" {step_index}. {step_name}") + + if mode == "enter_instance" or mode == "exit_instance" or mode == "use_portal": + area_trigger_id = link.get("areaTriggerId") or ( + from_node.get("areaTrigger", {}).get("id") if mode == "enter_instance" + else to_node.get("areaTrigger", {}).get("exitId") if mode == "exit_instance" + else None + ) + if not area_trigger_id: + area_trigger_id = (from_node.get("areaTrigger", {}) if mode == "enter_instance" else to_node.get("areaTrigger", {})).get("id") + + if not area_trigger_id: + # fallback: use from_node areaTrigger.id or to_node areaTrigger entryId/exitId + at = from_node.get("areaTrigger", {}) or to_node.get("areaTrigger", {}) + area_trigger_id = at.get("id") or at.get("entryId") or at.get("exitId") + + if not area_trigger_id: + print(f" no area trigger id for {step_name}; skipping") + continue + + print(f" sending area trigger {area_trigger_id}") + try: + request_json("POST", api_base + "/area-trigger", {"id": int(area_trigger_id)}) + except Exception as exc: + print(f" area trigger failed: {exc}") + ok = False + complete = False + save_route_state(config, leader_id, "failed", {}, { + **detail, + "failedAt": utc_timestamp(), + "error": f"area-trigger {area_trigger_id} failed: {exc}", + }) + break + + target_pos = travel_node_position(to_node) + try: + if target_pos and int(target_pos["mapId"]) == int(from_node.get("mapId", 0)): + print(f" waiting for position arrival at {to_label}") + try: + _wait_node_arrival(config, leader, index, target_pos, arrival_radius, leg_timeout, poll_interval) + except LeaderDiedError: + raise + except TimeoutError as exc: + print(f" {exc}") + else: + print(f" waiting for map change to {target_pos.get('mapId', '?')} (up to {leg_timeout}s)") + try: + _wait_map_change(config, leader, index, target_pos.get("mapId"), leg_timeout, poll_interval) + except LeaderDiedError: + raise + except TimeoutError as exc: + print(f" {exc}") + except LeaderDiedError as exc: + print(f" {exc}") + ok = False + complete = False + save_route_state(config, leader_id, "failed", {}, { + **detail, + "failedAt": utc_timestamp(), + "error": str(exc), + }) + break + + save_route_state(config, leader_id, "running", {}, { + **detail, + "completedNodeId": to_id, + "completedAt": utc_timestamp(), + }) + continue + + if mode == "ride_tram": + print(f" waiting for a Deeprun tram car (timeout {leg_timeout}s)") + try: + _board_and_ride_tram( + config, leader, index, api_base, to_node, + poll_interval=poll_interval, + board_timeout=leg_timeout, + ride_timeout=leg_timeout, + prefix=" ", + from_node=from_node, + ) + except TimeoutError as exc: + print(f" tram transit timeout: {exc}") + ok = False + complete = False + save_route_state(config, leader_id, "failed", {}, { + **detail, + "failedAt": utc_timestamp(), + "error": str(exc), + }) + break + + save_route_state(config, leader_id, "running", {}, { + **detail, + "completedAt": utc_timestamp(), + }) + continue + + if mode == "ride_boat": + print(f" waiting for transit (map change to target map, timeout {leg_timeout}s)") + target_pos = travel_node_position(to_node) + target_map = int(target_pos.get("mapId", 369)) if target_pos else 369 + try: + request_json("POST", api_base + "/area-trigger", {"id": int(to_node.get("areaTrigger", {}).get("entryId", 0))}) + except Exception: + pass + try: + _wait_map_change(config, leader, index, target_map, leg_timeout, poll_interval) + except TimeoutError as exc: + print(f" transit timeout: {exc}") + ok = False + complete = False + save_route_state(config, leader_id, "failed", {}, { + **detail, + "failedAt": utc_timestamp(), + "error": str(exc), + }) + break + + save_route_state(config, leader_id, "running", {}, { + **detail, + "completedAt": utc_timestamp(), + }) + continue + + print(f" unknown link mode {mode}; skipping") + continue + + from_pos = travel_node_position(from_node) + to_pos = travel_node_position(to_node) + if not from_pos or not to_pos: + print(f" {step_index}. survey_needed -> {to_label}: missing coordinates") + continue + + print(f" {step_index}. walk -> {to_label}") + step_result = cmd_route_goto( + config, + int(to_pos["mapId"]), + float(to_pos["x"]), + float(to_pos["y"]), + float(to_pos["z"]), + arrival_radius, + max_legs, + leg_timeout, + poll_interval, + min_progress_yards, + leader_ids=[leader_id], + avoid_hostiles=avoid_hostiles, + hostile_distance_adjuster=hostile_distance_adjuster, + hostile_scan_radius=hostile_scan_radius, + resume_through_death=resume_through_death, + max_death_recoveries=max_death_recoveries, + death_recovery_timeout=death_recovery_timeout, + auto_learn_flight_paths=auto_learn_flight_paths, + ) + if step_result != 0: + ok = False + complete = False + save_route_state(config, leader_id, "failed", {}, { + **detail, + "failedAt": utc_timestamp(), + "error": "walk leg failed", + }) + break + + save_route_state(config, leader_id, "running", {}, { + **detail, + "completedAt": utc_timestamp(), + }) + + if complete: + save_route_state(config, leader_id, "complete", {}, { + "planName": plan_name, + "completedAt": utc_timestamp(), + }) + print(f"{leader_id}: travel plan {plan_name} complete") + + return 0 if ok else 1 + + +def _wait_node_arrival( + config: FleetConfig, + leader: dict[str, Any], + index: int, + target_pos: dict[str, Any], + arrival_radius: float, + timeout_seconds: float, + poll_interval: float, +) -> None: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + try: + self_info = leader_position(config, leader, index) + except Exception: + time.sleep(poll_interval) + continue + _raise_if_dead(self_info) + position = self_info.get("position", {}) + remaining = point_distance(position, target_pos) + print(f" distance to {target_pos.get('name', 'target')}: {remaining:.1f}y") + if remaining <= arrival_radius: + return + time.sleep(poll_interval) + raise TimeoutError(f"did not arrive at target within {timeout_seconds:.1f}s") + + +def _wait_map_change( + config: FleetConfig, + leader: dict[str, Any], + index: int, + expected_map_id: str | int | None, + timeout_seconds: float, + poll_interval: float, +) -> int: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + try: + self_info = leader_position(config, leader, index) + except Exception: + time.sleep(poll_interval) + continue + _raise_if_dead(self_info) + current_map = int(self_info.get("mapId", 0)) + print(f" current map: {current_map}") + if expected_map_id is not None and current_map == int(expected_map_id): + return current_map + time.sleep(poll_interval) + raise TimeoutError(f"map did not change to {expected_map_id} within {timeout_seconds:.1f}s") + + +def _wait_tram_dwell( + config: FleetConfig, + leader: dict[str, Any], + index: int, + timeout_seconds: float, + poll_interval: float, + destination_hint: dict[str, Any] | None = None, + destination_radius_yards: float = 400.0, + min_travel_yards: float = 150.0, + dwell_epsilon_yards: float = 1.0, + dwell_polls_required: int = 3, + prefix: str = "", +) -> None: + """Wait for the tram the leader just boarded to stop at the destination end. + + "The car stopped" alone isn't enough: a Deeprun car dwells at *both* + ends of its loop, and if this wait starts mid-cycle (e.g. resuming an + in-progress ride), the first stop it observes can be back at the + origin, not the destination - confirmed live, where this function + without the destination check happily reported "arrived" 2400+ yards + from the actual target. destination_hint doesn't need to be precise + (an AreaTrigger-derived survey coordinate is fine): the two ends of the + tunnel are ~2500 yards apart, so even a loose few-hundred-yard radius + reliably tells the platforms apart without needing an exact dwell spot. + + Requires the leader to have moved at least min_travel_yards from where + this wait started before any stop counts, so the pre-departure dwell at + the boarding platform can't be mistaken for arrival either. Raises + TimeoutError if the leader is no longer on a transport (unexpected + early disembark) or the car never stops at the destination end within + timeout_seconds. + """ + deadline = time.monotonic() + timeout_seconds + start_pos: dict[str, Any] | None = None + last_pos: dict[str, Any] | None = None + moved_away = False + dwell_count = 0 + + while time.monotonic() < deadline: + try: + self_info = leader_position(config, leader, index) + except Exception: + time.sleep(poll_interval) + continue + + _raise_if_dead(self_info) + if not self_info.get("transport", {}).get("onTransport"): + raise TimeoutError("disembarked unexpectedly before the tram stopped") + + pos = self_info.get("position", {}) + if start_pos is None: + start_pos = pos + + if not moved_away: + if point_distance(pos, start_pos) >= min_travel_yards: + moved_away = True + print(f"{prefix}under way, watching for the car to stop") + elif last_pos is not None and point_distance(pos, last_pos) <= dwell_epsilon_yards: + dwell_count += 1 + if dwell_count >= dwell_polls_required: + if destination_hint is None or point_distance(pos, destination_hint) <= destination_radius_yards: + print(f"{prefix}car has stopped at the destination") + return + print(f"{prefix}car stopped, but not at the destination end - waiting for it to depart and come back") + dwell_count = 0 + else: + dwell_count = 0 + + last_pos = pos + time.sleep(poll_interval) + + raise TimeoutError(f"tram never stopped at the destination within {timeout_seconds:.1f}s") + + +def _nearby_deeprun_trams(entities_response: dict[str, Any], entries: "range") -> list[dict[str, Any]]: + trams = [ + item for item in entities_response.get("entities", []) + if item.get("isTransport") and int(item.get("entry", -1)) in entries + ] + trams.sort(key=lambda item: float(item.get("distance", 1e9))) + return trams + + +def _tram_exit_walk_target(node: dict[str, Any]) -> dict[str, Any] | None: + # The verified platform surveys record a walk-off point just past the boarding + # radius (the spot used to approach the exit AreaTrigger). Standing still on a + # docked tram never disembarks you client-side, so we need somewhere else to walk. + at = node.get("areaTrigger", {}) + required = ("exitSourceX", "exitSourceY", "exitSourceZ") + if not isinstance(at, dict) or not all(key in at for key in required): + return None + return { + "mapId": int(node.get("mapId", 0)), + "x": float(at["exitSourceX"]), + "y": float(at["exitSourceY"]), + "z": float(at["exitSourceZ"]), + } + + +def _recover_stranded_in_deeprun_tunnel( + config: FleetConfig, + leader: dict[str, Any], + index: int, + to_node: dict[str, Any], + leg_timeout: float, + poll_interval: float, + prefix: str = "", +) -> bool: + """Best-effort recovery when a tram ride/disembark doesn't resolve cleanly. + + Headless has no local terrain collision, so there's no way to "snap to + ground" a leader left stranded mid-tunnel (e.g. an unexpected disembark + with no floor underneath - see the wrong-end-dwell bug this was written + for). But the Deeprun Tram is a real instance with real navmesh data, so + the mundane fix works: just walk the rest of the way to the destination + platform like any other leg, using the same pathfinding already used + everywhere else. Returns True if it got there. + """ + try: + self_info = leader_position(config, leader, index) + except Exception: + return False + if int(self_info.get("mapId", -1)) != 369: + return False # not in the tunnel (or somewhere worse) - this can't help + if self_info.get("transport", {}).get("onTransport"): + return False # still riding, not actually stranded + + to_pos = travel_node_position(to_node) + if not to_pos: + return False + dist = point_distance(self_info.get("position", {}), to_pos) + print(f"{prefix}stranded in the tunnel, {dist:.0f}y from {to_node.get('name', 'the platform')} — walking the rest of the way") + leader_id = leader.get("id", f"leader-{index + 1}") + result = cmd_route_goto( + config, int(to_pos["mapId"]), float(to_pos["x"]), float(to_pos["y"]), float(to_pos["z"]), + arrival_radius=10.0, max_legs=8, leg_timeout=leg_timeout, poll_interval=poll_interval, + min_progress_yards=5.0, leader_ids=[leader_id], + ) + return result == 0 + + +def _board_and_ride_tram( + config: FleetConfig, + leader: dict[str, Any], + index: int, + api_base: str, + to_node: dict[str, Any], + poll_interval: float, + board_timeout: float, + ride_timeout: float, + scan_radius: float = 80.0, + prefix: str = "", + from_node: dict[str, Any] | None = None, +) -> None: + """Watch for a live Deeprun tram car, walk the leader onto it, ride to + to_node, then walk off. Raises TimeoutError if boarding or transit stalls. + + Boarding on this client is proximity-triggered (see application.cpp's M2 + transport boarding check): once the leader is within ~12 horizontal / + ~15 vertical yards of the tram's live position, /world/self reports + transport.onTransport without any extra command. So instead of relying on + the offline DBC cycle prediction, we just keep walking the leader onto + whatever tram car /world/entities reports nearby until that flag flips. + """ + try: + from deeprun_tram import DEEPRUN_TRAM_ENTRIES + except ImportError: + from bot_fleet_manager.deeprun_tram import DEEPRUN_TRAM_ENTRIES + + # The AreaTrigger landing spot (a platform node's own x/y/z) can be tens of + # yards from where the tram actually runs - live-observed on both ends of + # the Stormwind<->Ironforge crossing, where the trigger lands you 30-63 + # yards from the real dwell point on the track. If the boarding node + # records a separate trackPosition, stage there first so the scan-and- + # chase loop below starts close enough to actually catch a car. + track_position = (from_node or {}).get("trackPosition") + if track_position: + api = config.api_base_for(leader, index) + try: + self_info = leader_position(config, leader, index) + request_json("POST", api + "/movement/goto", { + "mapId": int(self_info.get("mapId", 0)), + "x": float(track_position["x"]), "y": float(track_position["y"]), "z": float(track_position["z"]), + "arrivalRadius": 3.0, + }) + _wait_node_arrival(config, leader, index, track_position, 3.0, board_timeout, poll_interval) + except TimeoutError as exc: + print(f"{prefix}failed to reach tram boarding position: {exc}") + + boarded = False + deadline = time.monotonic() + board_timeout + while time.monotonic() < deadline: + try: + self_info = leader_position(config, leader, index) + except Exception: + time.sleep(poll_interval) + continue + _raise_if_dead(self_info) + if self_info.get("transport", {}).get("onTransport"): + boarded = True + break + try: + entities = request_json("GET", api_base + f"/world/entities?radius={scan_radius}&transports=1") + except Exception: + entities = {} + trams = _nearby_deeprun_trams(entities, DEEPRUN_TRAM_ENTRIES) + if trams: + nearest = trams[0] + # position is the static GameObject spawn/presence-echo field, which + # never moves - livePosition (when the server has sent us an update + # for this transport) is where the car actually is right now. + pos = nearest.get("livePosition") or nearest["position"] + print(f"{prefix}tram entry={nearest.get('entry')} {float(nearest.get('distance', 0.0)):.1f}y away — walking onto it") + try: + request_json("POST", api_base + "/movement/goto", { + "mapId": int(self_info.get("mapId", 0)), + "x": pos["x"], "y": pos["y"], "z": pos["z"], + "arrivalRadius": 2.0, + }) + except Exception as exc: + print(f"{prefix}goto onto tram failed: {exc}") + else: + print(f"{prefix}no tram car in range yet") + time.sleep(poll_interval) + + if not boarded: + raise TimeoutError(f"did not board a tram car within {board_timeout:.1f}s") + print(f"{prefix}boarded tram") + + # A precise fixed target coordinate + tight radius turned out to be + # fragile: the real dwell point where a car actually stops varies by + # tens of yards run to run (even for the same physical platform), so a + # tight arrival check either times out on a wide pass or requires a + # radius so loose it can trigger mid-transit. The car genuinely + # stopping is a directly observable fact - the leader's own reported + # position holds still while onTransport - so wait for that instead of + # guessing an exact coordinate. Still pass the node's approximate + # position as a loose destination check: the two ends of the tunnel are + # ~2500 yards apart, so this reliably tells them apart without needing + # precision, and without it a dwell at the *wrong* end (e.g. this wait + # starting mid-cycle, already heading back toward the origin) gets + # mistaken for arrival. + to_pos = travel_node_position(to_node) + try: + _wait_tram_dwell(config, leader, index, ride_timeout, poll_interval, destination_hint=to_pos, prefix=prefix) + + walk_off = _tram_exit_walk_target(to_node) or to_pos + print(f"{prefix}walking off the tram") + try: + request_json("POST", api_base + "/movement/goto", { + "mapId": walk_off["mapId"], "x": walk_off["x"], "y": walk_off["y"], "z": walk_off["z"], + "arrivalRadius": 2.0, + }) + except Exception as exc: + print(f"{prefix}disembark goto failed: {exc}") + + disembark_deadline = time.monotonic() + min(ride_timeout, 20.0) + while time.monotonic() < disembark_deadline: + try: + self_info = leader_position(config, leader, index) + except Exception: + time.sleep(poll_interval) + continue + _raise_if_dead(self_info) + if not self_info.get("transport", {}).get("onTransport"): + print(f"{prefix}disembarked") + return + time.sleep(poll_interval) + raise TimeoutError("did not disembark after walking off the tram") + except TimeoutError as exc: + # Covers both "disembarked unexpectedly before the tram stopped" (the + # wrong-end-dwell/no-floor bug) and "did not disembark" - in either + # case, if the leader ended up off the transport somewhere in the + # tunnel, walking the rest of the way beats leaving it stranded. + print(f"{prefix}ride did not complete cleanly ({exc}); attempting recovery") + if _recover_stranded_in_deeprun_tunnel(config, leader, index, to_node, ride_timeout, poll_interval, prefix=prefix): + print(f"{prefix}recovered on foot") + return + raise + + +# Below this factor, spirit-healer resurrection isn't worth its cost +# (25% durability loss + resurrection sickness) even if it's nominally +# faster - only take it when it's clearly, not marginally, better. +SPIRIT_HEALER_PREFERENCE_MARGIN = 0.7 + + +def _estimate_ghost_reclaim_seconds(ghost_pos: dict, corpse: dict, reclaim_delay: float, run_speed: float) -> float: + """Estimated wall-clock seconds until alive again via ghost-walk + + reclaim. Ghost movement speed equals normal run speed in TBC (no + penalty), so run_speed serves for both. The reclaim delay counts down in + parallel with the walk, not after it, so the total is whichever is + longer, not their sum.""" + if run_speed <= 0: + return float("inf") + travel = point_distance(ghost_pos, corpse) / run_speed + return max(travel, reclaim_delay) + + +def _estimate_spirit_healer_seconds(ghost_pos: dict, resume_targets: list[dict], run_speed: float) -> float: + """Estimated wall-clock seconds until alive again via spirit-healer + resurrection (treated as ~instant) plus walking from the graveyard to + the nearest resume target (remaining survey waypoints, or the + route-goto destination).""" + if not resume_targets or run_speed <= 0: + return float("inf") + nearest = min(point_distance(ghost_pos, t) for t in resume_targets) + return nearest / run_speed + + +SPIRIT_HEALER_NPC_FLAG = 0x4000 + + +def _find_spirit_healer_position(api_base: str, search_radius: float) -> dict[str, Any] | None: + try: + result = request_json("GET", f"{api_base}/world/entities?radius={search_radius}") + except Exception: + return None + map_id = result.get("mapId", 0) + for entity in result.get("entities", []): + if entity.get("type") != "UNIT" or (entity.get("npcFlags", 0) & SPIRIT_HEALER_NPC_FLAG) == 0: + continue + pos = entity.get("position", {}) + if "x" not in pos or "y" not in pos: + continue + return {"mapId": map_id, "x": float(pos["x"]), "y": float(pos["y"]), "z": float(pos.get("z", 0.0))} + return None + + +def _wait_until_alive(config: FleetConfig, leader: dict[str, Any], index: int, + poll_interval: float, deadline: float, prefix: str) -> bool: + while time.monotonic() < deadline: + try: + self_info = leader_position(config, leader, index) + except Exception: + time.sleep(poll_interval) + continue + # isPlayerDead alone isn't enough here - it flips false as soon as you + # release spirit (a ghost isn't "dead", just not resurrected yet). + # Live-observed this falsely reporting "resurrected" while still a + # ghost, well before the corpse was ever reached. + health = self_info.get("health", {}) + death = self_info.get("death", {}) + if not health.get("isPlayerDead") and not death.get("isGhost"): + print(f"{prefix}resurrected") + return True + time.sleep(poll_interval) + print(f"{prefix}did not confirm resurrection within timeout") + return False + + +def _recover_from_death( + config: FleetConfig, + leader: dict[str, Any], + index: int, + poll_interval: float, + timeout_seconds: float, + prefix: str = "", + resume_targets: list[dict] | None = None, +) -> bool: + """If the leader is dead, release spirit (if needed) and get back to + alive by whichever of two paths is more efficient: + + 1. Ghost-walk to the corpse and reclaim it - free, but gated by a real + server-side reclaim delay (observed 72-100s per death, scaling with + repeated deaths) that runs regardless of how close the ghost gets. + 2. Resurrect at the nearest Spirit Healer - effectively instant, but + costs 25% durability and resurrection sickness, and still requires + walking (alive, exposed to mobs) from the graveyard to wherever we + actually need to be. + + resume_targets (remaining survey waypoints, or a single route-goto + destination) lets the comparison account for where we're trying to go, + not just the corpse - both paths start from the same graveyard spawn + point, so the real question is which finish line is closer. Falls back + to ghost-walk-only (the original behavior) when resume_targets is None. + + Deliberately does not use _wait_node_arrival/cmd_route_goto or their + LeaderDiedError check: a ghost is isPlayerDead=true for the entire + recovery, which every other wait loop in this file correctly treats as + fatal. Polls death.canReclaimCorpse directly instead of waiting for a + movement task to reach "arrived", since that's the actual distance/ + readiness signal we need. + """ + api_base = config.api_base_for(leader, index) + deadline = time.monotonic() + timeout_seconds + + try: + self_info = leader_position(config, leader, index) + except Exception as exc: + print(f"{prefix}could not read leader state: {exc}") + return False + + if not self_info.get("health", {}).get("isPlayerDead") and not self_info.get("death", {}).get("isGhost"): + return True + + if not self_info.get("death", {}).get("isGhost"): + print(f"{prefix}releasing spirit") + try: + request_json("POST", api_base + "/release-spirit", {}) + except Exception as exc: + print(f"{prefix}release-spirit failed: {exc}") + return False + while time.monotonic() < deadline: + try: + self_info = leader_position(config, leader, index) + except Exception: + time.sleep(poll_interval) + continue + if self_info.get("death", {}).get("isGhost"): + break + time.sleep(poll_interval) + else: + print(f"{prefix}never became a ghost after releasing spirit") + return False + + corpse = self_info.get("death", {}).get("corpse") + while not corpse and time.monotonic() < deadline: + time.sleep(poll_interval) + try: + self_info = leader_position(config, leader, index) + except Exception: + continue + corpse = self_info.get("death", {}).get("corpse") + if not corpse: + print(f"{prefix}no corpse position known; cannot recover") + return False + + if resume_targets: + ghost_pos = self_info.get("position", {}) + run_speed = float(self_info.get("runSpeed", 7.0) or 7.0) + reclaim_delay = float(self_info.get("death", {}).get("reclaimDelaySec", 0.0) or 0.0) + corpse_time = _estimate_ghost_reclaim_seconds(ghost_pos, corpse, reclaim_delay, run_speed) + healer_time = _estimate_spirit_healer_seconds(ghost_pos, resume_targets, run_speed) + print(f"{prefix}recovery estimate: corpse-walk ~{corpse_time:.0f}s vs spirit-healer+walk ~{healer_time:.0f}s") + if healer_time < corpse_time * SPIRIT_HEALER_PREFERENCE_MARGIN: + healer_pos = _find_spirit_healer_position(api_base, 60.0) + healer_ok = False + if healer_pos is None: + print(f"{prefix}no Spirit Healer found nearby, falling back to corpse walk") + else: + # /resurrect-at-graveyard's search radius (60y) is much wider + # than the real interaction range CMaNGOS actually enforces + # for CMSG_SPIRIT_HEALER_ACTIVATE (~3-5y) - firing it from + # outside that range is silently ignored server-side even + # though the NPC shows up in the entity scan. Live-observed: + # fired from 10.5y, resurrection confirmation timed out. + # Ghosts are safe from mobs, so walk in close first. + try: + request_json("POST", api_base + "/movement/goto", { + "mapId": healer_pos["mapId"], "x": healer_pos["x"], "y": healer_pos["y"], + "z": healer_pos["z"], "arrivalRadius": 3.0, + }) + except Exception as exc: + print(f"{prefix}goto spirit healer failed: {exc}") + else: + while time.monotonic() < deadline: + try: + here = leader_position(config, leader, index).get("position", {}) + except Exception: + time.sleep(poll_interval) + continue + if point_distance(here, healer_pos) <= 5.0: + break + time.sleep(poll_interval) + else: + print(f"{prefix}never got close enough to the spirit healer") + try: + result = request_json("POST", api_base + "/resurrect-at-graveyard", {"searchRadius": 15.0}) + except Exception as exc: + print(f"{prefix}resurrect-at-graveyard failed: {exc}") + result = {"ok": False} + healer_ok = result.get("ok", False) + if healer_ok: + print(f"{prefix}using spirit healer (clearly closer to the destination than the corpse)") + return _wait_until_alive(config, leader, index, poll_interval, deadline, prefix) + print(f"{prefix}spirit-healer resurrection unavailable, falling back to corpse walk") + + print(f"{prefix}walking to corpse at ({corpse['x']:.1f}, {corpse['y']:.1f}), {corpse.get('distance', 0):.0f}y away") + try: + request_json("POST", api_base + "/movement/goto", { + "mapId": corpse["mapId"], "x": corpse["x"], "y": corpse["y"], "z": corpse["z"], + "arrivalRadius": 15.0, + }) + except Exception as exc: + print(f"{prefix}goto corpse failed: {exc}") + return False + + while time.monotonic() < deadline: + try: + self_info = leader_position(config, leader, index) + except Exception: + time.sleep(poll_interval) + continue + death = self_info.get("death", {}) + if death.get("canReclaimCorpse"): + break + time.sleep(poll_interval) + else: + print(f"{prefix}never got close enough to reclaim the corpse") + return False + + delay = float(death.get("reclaimDelaySec", 0.0) or 0.0) + if delay > 0: + print(f"{prefix}waiting out reclaim delay ({delay:.1f}s)") + time.sleep(delay + 0.5) + + print(f"{prefix}reclaiming corpse") + try: + result = request_json("POST", api_base + "/reclaim-corpse", {}) + except Exception as exc: + print(f"{prefix}reclaim-corpse failed: {exc}") + return False + if not result.get("ok", False): + print(f"{prefix}reclaim-corpse rejected: {result.get('error')}") + return False + + return _wait_until_alive(config, leader, index, poll_interval, deadline, prefix) + + +def cmd_recover_death( + config: FleetConfig, + poll_interval: float, + timeout_seconds: float, + fleet: str = "", + leader_ids: list[str] | None = None, + resume_route: bool = True, + resume_arrival_radius: float = 5.0, + resume_max_legs: int = 8, + resume_leg_timeout: float = 180.0, + resume_min_progress_yards: float = 15.0, +) -> int: + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + ok = True + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + prefix = f"{leader_id}: " + # Load the saved route-goto target up front (if any) so the recovery + # efficiency comparison can weigh the spirit-healer's walk-back + # distance against it, not just guess blind. + target: dict[str, Any] = {} + if resume_route: + try: + target = load_route_state(config, leader_id).get("target") or {} + except FileNotFoundError: + pass + resume_targets = [target] if all(k in target for k in ("mapId", "x", "y", "z")) else None + if not _recover_from_death(config, leader, index, poll_interval, timeout_seconds, + prefix=prefix, resume_targets=resume_targets): + ok = False + continue + if not resume_route: + continue + if not all(k in target for k in ("mapId", "x", "y", "z")): + print(f"{prefix}no resumable route-goto target in saved state; not resuming") + continue + print(f"{prefix}resuming route-goto toward {target}") + resume_ok = cmd_route_goto( + config, + int(target["mapId"]), float(target["x"]), float(target["y"]), float(target["z"]), + float(target.get("arrivalRadius", resume_arrival_radius)), + resume_max_legs, + resume_leg_timeout, + poll_interval, + resume_min_progress_yards, + fleet="", + leader_ids=[leader_id], + ) + if resume_ok != 0: + ok = False + return 0 if ok else 1 + + +def cmd_board_tram( + config: FleetConfig, + registry_path: Path | None, + from_node_id: str, + to_node_id: str, + scan_radius: float, + arrival_radius: float, + poll_interval: float, + board_timeout: float, + ride_timeout: float, + fleet: str = "", + leader_ids: list[str] | None = None, +) -> int: + registry = load_travel_registry(registry_path) if (from_node_id or to_node_id) else None + from_node = None + from_pos = None + to_node = None + to_pos = None + to_label = "" + if from_node_id: + _, from_node = resolve_travel_node(registry, from_node_id) + from_pos = travel_node_position(from_node) + if not from_pos: + print(f"travel node '{from_node_id}' has no coordinates") + return 1 + if to_node_id: + to_key, to_node = resolve_travel_node(registry, to_node_id) + to_pos = travel_node_position(to_node) + to_label = travel_node_label(to_key, to_node) + if not to_pos: + print(f"travel node '{to_node_id}' has no coordinates") + return 1 + + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + + ok = True + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + api_base = config.api_base_for(leader, index) + + if from_pos: + print(f"{leader_id}: walking to boarding platform") + try: + request_json("POST", api_base + "/movement/goto", { + "mapId": from_pos["mapId"], "x": from_pos["x"], "y": from_pos["y"], "z": from_pos["z"], + "arrivalRadius": arrival_radius, + }) + _wait_node_arrival(config, leader, index, from_pos, arrival_radius, board_timeout, poll_interval) + except Exception as exc: + print(f"{leader_id}: failed to reach boarding platform ({exc})") + ok = False + continue + + print(f"{leader_id}: watching for a Deeprun tram car") + try: + _board_and_ride_tram( + config, leader, index, api_base, + to_node or {}, + poll_interval=poll_interval, + board_timeout=board_timeout, + ride_timeout=ride_timeout, + from_node=from_node, + scan_radius=scan_radius, + prefix=f"{leader_id}: ", + ) + except TimeoutError as exc: + print(f"{leader_id}: {exc}") + ok = False + continue + + if to_pos: + print(f"{leader_id}: arrived at {to_label}") + + return 0 if ok else 1 + + +def cmd_resume_travel_plan( + config: FleetConfig, + registry_path: Path | None, + arrival_radius: float, + max_legs: int, + leg_timeout: float, + poll_interval: float, + min_progress_yards: float, + fleet: str = "", + leader_ids: list[str] | None = None, +) -> int: + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + + ok = True + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + try: + state = load_route_state(config, leader_id) + except FileNotFoundError: + print(f"{leader_id}: no saved travel plan state") + ok = False + continue + + detail = {} + for k in ("planName", "nodeIds", "stepIndex", "stepCount", "fromNodeId", "toNodeId"): + v = state.get(k) + if v is not None: + detail[k] = v + + plan_name = detail.get("planName") or (state.get("detail") or {}).get("planName") + node_ids = detail.get("nodeIds") or (state.get("detail") or {}).get("nodeIds") + step_index = detail.get("stepIndex") or (state.get("detail") or {}).get("stepIndex") + + if not plan_name or not node_ids: + print(f"{leader_id}: saved state does not contain a travel plan; use resume-route instead") + ok = False + continue + + if step_index is not None and isinstance(step_index, int): + remaining = node_ids[step_index:] + else: + remaining = node_ids + + print( + f"{leader_id}: resuming travel plan {plan_name} " + f"step {step_index}/{len(node_ids) if node_ids else '?'} " + f"remaining nodes: {' -> '.join(remaining)}" + ) + + result = cmd_travel_node_execute( + config, + registry_path or TRAVEL_NODES_PATH, + remaining, + arrival_radius, + max_legs, + leg_timeout, + poll_interval, + min_progress_yards, + leader_ids=[leader_id], + ) + ok = ok and result == 0 + return 0 if ok else 1 + + +def cmd_resume_route( + config: FleetConfig, + arrival_radius: float | None, + max_legs: int, + leg_timeout: float, + poll_interval: float, + min_progress_yards: float, + fleet: str = "", + leader_ids: list[str] | None = None, +) -> int: + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + + ok = True + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + try: + state = load_route_state(config, leader_id) + target = state.get("target", {}) + map_id = int(target["mapId"]) + radius = float(arrival_radius if arrival_radius is not None else target.get("arrivalRadius", 5.0)) + print( + f"{leader_id}: resuming route state={state.get('state')} " + f"target map={map_id} x={target['x']} y={target['y']} z={target['z']}" + ) + result = cmd_route_goto( + config, + map_id, + float(target["x"]), + float(target["y"]), + float(target["z"]), + radius, + max_legs, + leg_timeout, + poll_interval, + min_progress_yards, + leader_ids=[leader_id], + ) + ok = ok and result == 0 + except (FileNotFoundError, KeyError, TypeError, ValueError, RuntimeError) as exc: + ok = False + print(f"{leader_id}: resume-route failed ({exc})") + return 0 if ok else 1 + + +def cmd_dashboard(config: FleetConfig, host: str, port: int) -> int: + try: + from team_status_server import run_team_status_server + except ImportError: + from bot_fleet_manager.team_status_server import run_team_status_server + + run_team_status_server(dashboard_targets(config), host=host, port=port) + return 0 + + +def cmd_tram_state( + config: FleetConfig, + time_offset_ms: int, + calibrate: bool, + calibrate_step_ms: int, + watch_seconds: float, + interval: float, + json_output: bool, + fleet: str = "", + leader_ids: list[str] | None = None, +) -> int: + try: + from deeprun_tram import find_best_time_offset, predict_trams, prediction_to_dict + except ImportError: + from bot_fleet_manager.deeprun_tram import find_best_time_offset, predict_trams, prediction_to_dict + + selected = config.selected_leaders(fleet, leader_ids) + leader_info: dict[str, Any] | None = None + leader_label = "" + leader_for_data: dict[str, Any] | None = selected[0][1] if selected else None + dbc_path = find_client_dbc_path(config, "TransportAnimation.dbc", leader_for_data) + if not dbc_path: + print("Missing TransportAnimation.dbc in expansion-scoped or default Data paths", file=sys.stderr) + return 2 + + if selected: + index, leader = selected[0] + leader_label = leader.get("id", f"leader-{index + 1}") + try: + leader_info = leader_position(config, leader, index) + except Exception as exc: + print(f"{leader_label}: unable to read leader position ({exc})", file=sys.stderr) + + if calibrate: + if not leader_info: + print("--calibrate requires a reachable leader position", file=sys.stderr) + return 1 + best = find_best_time_offset(dbc_path, leader_info, step_ms=calibrate_step_ms) + if not best: + print("unable to calibrate Deeprun tram offset", file=sys.stderr) + return 1 + time_offset_ms, prediction = best + print( + f"Best Deeprun tram offset: {time_offset_ms}ms " + f"(entry={prediction.entry} guid={prediction.guid} " + f"xyDistance={prediction.xy_distance_to_leader:.1f}y " + f"distance={prediction.distance_to_leader:.1f}y " + f"pos=({prediction.x:.1f}, {prediction.y:.1f}, {prediction.z:.1f}))" + ) + + deadline = time.monotonic() + watch_seconds if watch_seconds > 0 else None + while True: + if selected: + index, leader = selected[0] + try: + leader_info = leader_position(config, leader, index) + except Exception: + pass + + predictions = predict_trams(dbc_path, time_offset_ms=time_offset_ms, leader=leader_info) + if json_output: + print(json.dumps({ + "collectedAt": utc_timestamp(), + "timeOffsetMs": time_offset_ms, + "leader": leader_info, + "trams": [prediction_to_dict(prediction) for prediction in predictions], + }, indent=2)) + else: + header = f"Deeprun tram prediction offset={time_offset_ms}ms" + if leader_label and leader_info: + pos = leader_info.get("position", {}) + header += ( + f" leader={leader_label} map={leader_info.get('mapId')} " + f"pos=({float(pos.get('x', 0.0)):.1f}, {float(pos.get('y', 0.0)):.1f}, {float(pos.get('z', 0.0)):.1f})" + ) + print(header) + for prediction in predictions: + dist = ( + f" xy={prediction.xy_distance_to_leader:.1f}y dist={prediction.distance_to_leader:.1f}y" + if prediction.xy_distance_to_leader is not None and prediction.distance_to_leader is not None + else "" + ) + print( + f" entry={prediction.entry} guid={prediction.guid} " + f"t={prediction.time_ms}/{prediction.cycle_ms}ms " + f"station={prediction.nearest_station} stationXY={prediction.station_xy_distance:.1f}y " + f"atStation={str(prediction.at_station).lower()} " + f"boardable={str(prediction.boardable_at_station).lower()} " + f"pos=({prediction.x:.1f}, {prediction.y:.1f}, {prediction.z:.1f})" + f"{dist}" + ) + + if deadline is None or time.monotonic() >= deadline: + break + time.sleep(max(0.1, interval)) + + return 0 + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("config", type=Path) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("start") + sub.add_parser("landmarks", help="List built-in named route landmarks") + travel_nodes_parser = sub.add_parser("travel-nodes", help="List long-range travel nodes and routes") + travel_nodes_parser.add_argument("--registry", type=Path, default=None, help="Travel node registry path") + travel_nodes_parser.add_argument("--routes", action="store_true", help="Also list named travel routes") + supervise_parser = sub.add_parser("supervise") + supervise_parser.add_argument("--dashboard", action="store_true", help="Also start the team status dashboard") + supervise_parser.add_argument("--dashboard-port", type=int, default=8780, help="Dashboard port") + supervise_parser.add_argument("--dashboard-host", default="127.0.0.1", help="Dashboard bind address") + supervise_parser.add_argument( + "-v", + "--verbose", + "--debug", + "--v", + action="store_true", + help="Stream each leader's log output to the supervisor console", + ) + status_parser = sub.add_parser("status") + status_parser.add_argument("--fleet", default="") + status_parser.add_argument("--leader", action="append", default=[]) + stop_parser = sub.add_parser("stop") + stop_parser.add_argument("--fleet", default="") + stop_parser.add_argument("--leader", action="append", default=[]) + logout_parser = sub.add_parser( + "logout", + help="Request a clean logout and wait for the leader process to exit on its own, instead of force-killing it", + ) + logout_parser.add_argument("--fleet", default="") + logout_parser.add_argument("--leader", action="append", default=[]) + logout_parser.add_argument("--timeout", type=float, default=20.0, + help="Max seconds to wait for the process to exit before giving up") + command_parser = sub.add_parser("command") + command_parser.add_argument("--fleet", default="") + command_parser.add_argument("--leader", action="append", default=[]) + command_parser.add_argument("text") + area_trigger_parser = sub.add_parser("area-trigger", help="Send CMSG_AREATRIGGER through selected leader APIs") + area_trigger_parser.add_argument("--fleet", default="") + area_trigger_parser.add_argument("--leader", action="append", default=[]) + area_trigger_parser.add_argument("trigger_id", type=int) + goto_parser = sub.add_parser("goto") + goto_parser.add_argument("--fleet", default="") + goto_parser.add_argument("--leader", action="append", default=[]) + goto_parser.add_argument("mapId", type=int) + goto_parser.add_argument("x", type=float) + goto_parser.add_argument("y", type=float) + goto_parser.add_argument("z", type=float) + goto_parser.add_argument("--arrival-radius", type=float, default=3.0) + pathfind_goto_parser = sub.add_parser("pathfind-goto", help="Pathfind through the configured path service, then send waypoints") + pathfind_goto_parser.add_argument("--fleet", default="") + pathfind_goto_parser.add_argument("--leader", action="append", default=[]) + pathfind_goto_parser.add_argument("mapId", type=int) + pathfind_goto_parser.add_argument("x", type=float) + pathfind_goto_parser.add_argument("y", type=float) + pathfind_goto_parser.add_argument("z", type=float) + pathfind_goto_parser.add_argument("--arrival-radius", type=float, default=3.0) + plan_route_parser = sub.add_parser("plan-route", help="Ask the path service for an iterative route without moving leaders") + plan_route_parser.add_argument("--fleet", default="") + plan_route_parser.add_argument("--leader", action="append", default=[]) + plan_route_parser.add_argument("mapId", type=int) + plan_route_parser.add_argument("x", type=float) + plan_route_parser.add_argument("y", type=float) + plan_route_parser.add_argument("z", type=float) + plan_route_parser.add_argument("--arrival-radius", type=float, default=5.0) + plan_route_parser.add_argument("--max-legs", type=int, default=8) + route_goto_parser = sub.add_parser("route-goto", help="Move leaders one planned route leg at a time, replanning between legs") + route_goto_parser.add_argument("--fleet", default="") + route_goto_parser.add_argument("--leader", action="append", default=[]) + route_goto_parser.add_argument("mapId", type=int) + route_goto_parser.add_argument("x", type=float) + route_goto_parser.add_argument("y", type=float) + route_goto_parser.add_argument("z", type=float) + route_goto_parser.add_argument("--arrival-radius", type=float, default=5.0) + route_goto_parser.add_argument("--max-legs", type=int, default=8) + route_goto_parser.add_argument("--leg-timeout", type=float, default=180.0) + route_goto_parser.add_argument("--poll-interval", type=float, default=1.0) + route_goto_parser.add_argument("--min-progress-yards", type=float, default=15.0) + route_goto_parser.add_argument("--avoid-hostiles", action="store_true", + help="Reroute around hostile creatures using a level-gap-scaled danger radius") + route_goto_parser.add_argument("--hostile-distance-adjuster", type=float, default=1.0, + help="Multiplier on the computed danger radius (>1 = more cautious)") + route_goto_parser.add_argument("--hostile-scan-radius", type=float, default=100.0, + help="How far out to scan for hostiles each leg") + route_goto_parser.add_argument("--resume-through-death", action="store_true", + help="Treat death as expected: recover (ghost walk or spirit healer, " + "whichever is more efficient) and keep going toward the destination") + route_goto_parser.add_argument("--max-death-recoveries", type=int, default=25) + route_goto_parser.add_argument("--death-recovery-timeout", type=float, default=180.0) + route_goto_parser.add_argument("--auto-learn-flight-paths", action="store_true", + help="On arrival, check for a nearby Flight Master and learn it") + replay_survey_parser = sub.add_parser( + "replay-survey", + help="Walk a leader through a human-captured route catalog (see follow_player.py) instead of the automated pathfinder", + ) + replay_survey_parser.add_argument("--fleet", default="") + replay_survey_parser.add_argument("--leader", action="append", default=[]) + replay_survey_parser.add_argument("catalog_path") + replay_survey_parser.add_argument("--arrival-radius", type=float, default=8.0) + replay_survey_parser.add_argument("--leg-timeout", type=float, default=180.0) + replay_survey_parser.add_argument("--poll-interval", type=float, default=1.0) + replay_survey_parser.add_argument("--chunk-size", type=int, default=15) + replay_survey_parser.add_argument("--start-index", type=int, default=0, + help="Resume from this waypoint index instead of the start") + replay_survey_parser.add_argument("--avoid-hostiles", action="store_true") + replay_survey_parser.add_argument("--hostile-distance-adjuster", type=float, default=1.0) + replay_survey_parser.add_argument("--hostile-scan-radius", type=float, default=100.0) + replay_survey_parser.add_argument("--reverse", action="store_true", + help="Walk the catalog tail-to-head instead of head-to-tail") + replay_survey_parser.add_argument("--resume-through-death", action="store_true", + help="Treat death as expected: recover (ghost walk + reclaim) and " + "resume from the nearest waypoint instead of failing") + replay_survey_parser.add_argument("--max-death-recoveries", type=int, default=25) + replay_survey_parser.add_argument("--death-recovery-timeout", type=float, default=180.0) + replay_survey_parser.add_argument("--auto-learn-flight-paths", action="store_true", + help="On arrival at each waypoint chunk, check for a nearby Flight Master and learn it") + route_demo_parser = sub.add_parser("route-demo", help="Run a named route demo using built-in landmarks") + route_demo_parser.add_argument("--fleet", default="") + route_demo_parser.add_argument("--leader", action="append", default=[]) + route_demo_parser.add_argument("--plan-only", action="store_true", help="Only print the route plan; do not move") + route_demo_parser.add_argument("--arrival-radius", type=float, default=5.0) + route_demo_parser.add_argument("--max-legs", type=int, default=8) + route_demo_parser.add_argument("--leg-timeout", type=float, default=180.0) + route_demo_parser.add_argument("--poll-interval", type=float, default=1.0) + route_demo_parser.add_argument("--min-progress-yards", type=float, default=15.0) + route_demo_parser.add_argument("name", help="Landmark/demo name, e.g. goldshire or ironforge-probe") + travel_plan_parser = sub.add_parser("travel-plan", help="Plan a read-only chained route through travel nodes") + travel_plan_parser.add_argument("--fleet", default="") + travel_plan_parser.add_argument("--leader", action="append", default=[]) + travel_plan_parser.add_argument("--registry", type=Path, default=None, help="Travel node registry path") + travel_plan_parser.add_argument("--static", action="store_true", help="Plan only between listed nodes; do not query live leaders") + travel_plan_parser.add_argument("--arrival-radius", type=float, default=8.0) + travel_plan_parser.add_argument("--max-legs", type=int, default=8) + travel_plan_parser.add_argument( + "route_or_nodes", + nargs="+", + help="Named route such as deeprun-prototype, or an explicit sequence of travel node ids", + ) + travel_survey_parser = sub.add_parser("travel-survey", help="Record live leader position/state samples for route-node surveying") + travel_survey_parser.add_argument("--fleet", default="") + travel_survey_parser.add_argument("--leader", action="append", default=[]) + travel_survey_parser.add_argument("--label", default="survey", help="Survey label stored in each sample and default output filename") + travel_survey_parser.add_argument("--duration", type=float, default=0.0, help="Seconds to sample; 0 means until Ctrl-C") + travel_survey_parser.add_argument("--interval", type=float, default=1.0, help="Seconds between polls") + travel_survey_parser.add_argument("--min-distance", type=float, default=1.0, help="Only write samples after this many yards of movement") + travel_survey_parser.add_argument("--output", type=Path, default=None, help="JSONL output path") + travel_survey_parser.add_argument("--raw", action="store_true", help="Include full /world/self and /status responses in each sample") + travel_capture_parser = sub.add_parser("travel-capture", help="Capture the selected leader's current position into the travel registry") + travel_capture_parser.add_argument("--fleet", default="") + travel_capture_parser.add_argument("--leader", action="append", default=[]) + travel_capture_parser.add_argument("--registry", type=Path, default=None, help="Travel node registry path") + travel_capture_parser.add_argument("--type", default="walk", help="Travel node type, e.g. walk, tram_entrance, tram_platform, boat_dock") + travel_capture_parser.add_argument("--name", default="", help="Human-readable node name") + travel_capture_parser.add_argument("--description", default="", help="Human-readable node description") + travel_capture_parser.add_argument("--radius", type=float, default=8.0) + travel_capture_parser.add_argument("--verified", action="store_true", help="Mark captured node as verified") + travel_capture_parser.add_argument("node_id") + travel_node_execute_parser = sub.add_parser( + "travel-node-execute", + help="Execute a travel-node plan: walk legs and trigger transitions (tram/boat/portal) step by step", + ) + travel_node_execute_parser.add_argument("--fleet", default="") + travel_node_execute_parser.add_argument("--leader", action="append", default=[]) + travel_node_execute_parser.add_argument("--registry", type=Path, default=None, help="Travel node registry path") + travel_node_execute_parser.add_argument("--arrival-radius", type=float, default=8.0) + travel_node_execute_parser.add_argument("--max-legs", type=int, default=8) + travel_node_execute_parser.add_argument("--leg-timeout", type=float, default=180.0) + travel_node_execute_parser.add_argument("--poll-interval", type=float, default=1.0) + travel_node_execute_parser.add_argument("--min-progress-yards", type=float, default=15.0) + travel_node_execute_parser.add_argument("--avoid-hostiles", action="store_true", + help="Reroute around hostile creatures on plain walk legs") + travel_node_execute_parser.add_argument("--hostile-distance-adjuster", type=float, default=1.0) + travel_node_execute_parser.add_argument("--hostile-scan-radius", type=float, default=100.0) + travel_node_execute_parser.add_argument("--resume-through-death", action="store_true", + help="Treat death as expected: recover and keep going on walk legs") + travel_node_execute_parser.add_argument("--max-death-recoveries", type=int, default=25) + travel_node_execute_parser.add_argument("--death-recovery-timeout", type=float, default=180.0) + travel_node_execute_parser.add_argument("--auto-learn-flight-paths", action="store_true", + help="On arrival at each walk leg, check for a nearby Flight Master and learn it") + travel_node_execute_parser.add_argument( + "route_or_nodes", + nargs="+", + help="Named route such as deeprun-prototype, or an explicit sequence of travel node ids", + ) + resume_travel_parser = sub.add_parser( + "resume-travel-plan", help="Resume a travel-node-execute plan from its last saved step" + ) + resume_travel_parser.add_argument("--fleet", default="") + resume_travel_parser.add_argument("--leader", action="append", default=[]) + resume_travel_parser.add_argument("--registry", type=Path, default=None, help="Travel node registry path") + resume_travel_parser.add_argument("--arrival-radius", type=float, default=8.0) + resume_travel_parser.add_argument("--max-legs", type=int, default=8) + resume_travel_parser.add_argument("--leg-timeout", type=float, default=180.0) + resume_travel_parser.add_argument("--poll-interval", type=float, default=1.0) + resume_travel_parser.add_argument("--min-progress-yards", type=float, default=15.0) + resume_route_parser = sub.add_parser("resume-route", help="Resume each leader's last saved guarded route target") + resume_route_parser.add_argument("--fleet", default="") + resume_route_parser.add_argument("--leader", action="append", default=[]) + resume_route_parser.add_argument("--arrival-radius", type=float, default=None) + resume_route_parser.add_argument("--max-legs", type=int, default=8) + resume_route_parser.add_argument("--leg-timeout", type=float, default=180.0) + resume_route_parser.add_argument("--poll-interval", type=float, default=1.0) + resume_route_parser.add_argument("--min-progress-yards", type=float, default=15.0) + dashboard_parser = sub.add_parser("dashboard", help="Start textual team status dashboard") + dashboard_parser.add_argument("--port", type=int, default=8780, help="Dashboard port") + dashboard_parser.add_argument("--host", default="127.0.0.1", help="Dashboard bind address") + tram_state_parser = sub.add_parser("tram-state", help="Predict Deeprun Tram car positions from DBC + spawn data") + tram_state_parser.add_argument("--fleet", default="") + tram_state_parser.add_argument("--leader", action="append", default=[]) + tram_state_parser.add_argument("--time-offset-ms", type=int, default=0, help="Calibration offset added to local wall clock") + tram_state_parser.add_argument("--calibrate", action="store_true", help="Find the offset that puts a predicted tram nearest the selected leader") + tram_state_parser.add_argument("--calibrate-step-ms", type=int, default=250, help="Calibration search step size") + tram_state_parser.add_argument("--watch", type=float, default=0.0, help="Seconds to continuously print predictions") + tram_state_parser.add_argument("--interval", type=float, default=1.0, help="Seconds between watch samples") + tram_state_parser.add_argument("--json", action="store_true", help="Print JSON instead of text") + board_tram_parser = sub.add_parser( + "board-tram", + help="Wait for a live Deeprun tram car, walk the leader onto it, ride, and disembark", + ) + board_tram_parser.add_argument("--fleet", default="") + board_tram_parser.add_argument("--leader", action="append", default=[]) + board_tram_parser.add_argument("--registry", type=Path, default=None, help="Travel node registry path") + board_tram_parser.add_argument("--from", dest="from_node", default="", help="Travel node id to walk to before boarding, e.g. deeprun-tram-stormwind-platform") + board_tram_parser.add_argument("--to", dest="to_node", default="", help="Travel node id to ride to and disembark at, e.g. deeprun-tram-ironforge-platform") + board_tram_parser.add_argument("--scan-radius", type=float, default=80.0, help="Radius to search for live tram gameobjects") + board_tram_parser.add_argument("--arrival-radius", type=float, default=8.0, help="Arrival radius for the --from walk") + board_tram_parser.add_argument("--poll-interval", type=float, default=1.0) + board_tram_parser.add_argument("--board-timeout", type=float, default=180.0, help="Max seconds to wait for a boardable tram car") + board_tram_parser.add_argument("--ride-timeout", type=float, default=90.0, help="Max seconds to wait for arrival + disembark after boarding") + + recover_death_parser = sub.add_parser( + "recover-death", + help="If the leader is dead, release spirit, walk to the corpse, and reclaim it", + ) + recover_death_parser.add_argument("--fleet", default="") + recover_death_parser.add_argument("--leader", action="append", default=[]) + recover_death_parser.add_argument("--poll-interval", type=float, default=1.5) + recover_death_parser.add_argument("--timeout", type=float, default=300.0, help="Max seconds for the whole release/walk/reclaim sequence") + recover_death_parser.add_argument("--no-resume-route", action="store_true", + help="Don't automatically resume the interrupted route-goto after resurrecting") + recover_death_parser.add_argument("--resume-arrival-radius", type=float, default=5.0) + recover_death_parser.add_argument("--resume-max-legs", type=int, default=8) + recover_death_parser.add_argument("--resume-leg-timeout", type=float, default=180.0) + recover_death_parser.add_argument("--resume-min-progress-yards", type=float, default=15.0) + + args = parser.parse_args(argv) + config = FleetConfig(resolve_path(str(args.config))) + + if args.command == "start": + return cmd_start(config) + if args.command == "landmarks": + return cmd_landmarks() + if args.command == "travel-nodes": + return cmd_travel_nodes(args.registry, args.routes) + if args.command == "supervise": + return cmd_supervise( + config, + dashboard=args.dashboard, + dashboard_host=args.dashboard_host, + dashboard_port=args.dashboard_port, + verbose=args.verbose, + ) + if args.command == "status": + return cmd_status(config, args.fleet, args.leader) + if args.command == "stop": + return post_all(config, "/movement/stop", {"reason": "fleet stop"}, args.fleet, args.leader) + if args.command == "logout": + return cmd_logout(config, args.timeout, args.fleet, args.leader) + if args.command == "command": + return post_all(config, "/commands", {"command": args.text}, args.fleet, args.leader) + if args.command == "area-trigger": + return post_all(config, "/area-trigger", {"id": args.trigger_id}, args.fleet, args.leader) + if args.command == "goto": + return post_all(config, "/movement/goto", { + "mapId": args.mapId, + "x": args.x, + "y": args.y, + "z": args.z, + "arrivalRadius": args.arrival_radius, + }, args.fleet, args.leader) + if args.command == "pathfind-goto": + return cmd_pathfind_goto( + config, + args.mapId, + args.x, + args.y, + args.z, + args.arrival_radius, + args.fleet, + args.leader, + ) + if args.command == "plan-route": + return cmd_plan_route( + config, + args.mapId, + args.x, + args.y, + args.z, + args.arrival_radius, + args.max_legs, + args.fleet, + args.leader, + ) + if args.command == "route-goto": + return cmd_route_goto( + config, + args.mapId, + args.x, + args.y, + args.z, + args.arrival_radius, + args.max_legs, + args.leg_timeout, + args.poll_interval, + args.min_progress_yards, + args.fleet, + args.leader, + args.avoid_hostiles, + args.hostile_distance_adjuster, + args.hostile_scan_radius, + args.resume_through_death, + args.max_death_recoveries, + args.death_recovery_timeout, + args.auto_learn_flight_paths, + ) + if args.command == "replay-survey": + return cmd_replay_survey( + config, + args.catalog_path, + args.arrival_radius, + args.leg_timeout, + args.poll_interval, + args.chunk_size, + args.start_index, + args.avoid_hostiles, + args.hostile_distance_adjuster, + args.hostile_scan_radius, + args.fleet, + args.leader, + args.reverse, + args.resume_through_death, + args.max_death_recoveries, + args.death_recovery_timeout, + args.auto_learn_flight_paths, + ) + if args.command == "route-demo": + return cmd_route_demo( + config, + args.name, + args.plan_only, + args.arrival_radius, + args.max_legs, + args.leg_timeout, + args.poll_interval, + args.min_progress_yards, + args.fleet, + args.leader, + ) + if args.command == "travel-plan": + return cmd_travel_plan( + config, + args.registry, + args.route_or_nodes, + args.static, + args.arrival_radius, + args.max_legs, + args.fleet, + args.leader, + ) + if args.command == "travel-survey": + return cmd_travel_survey( + config, + args.label, + args.duration, + args.interval, + args.min_distance, + args.output, + args.raw, + args.fleet, + args.leader, + ) + if args.command == "travel-capture": + return cmd_travel_capture( + config, + args.registry, + args.node_id, + args.type, + args.name, + args.description, + args.radius, + args.verified, + args.fleet, + args.leader, +) + if args.command == "travel-node-execute": + return cmd_travel_node_execute( + config, + args.registry, + args.route_or_nodes, + args.arrival_radius, + args.max_legs, + args.leg_timeout, + args.poll_interval, + args.min_progress_yards, + args.fleet, + args.leader, + args.avoid_hostiles, + args.hostile_distance_adjuster, + args.hostile_scan_radius, + args.resume_through_death, + args.max_death_recoveries, + args.death_recovery_timeout, + args.auto_learn_flight_paths, + ) + if args.command == "resume-travel-plan": + return cmd_resume_travel_plan( + config, + args.registry, + args.arrival_radius, + args.max_legs, + args.leg_timeout, + args.poll_interval, + args.min_progress_yards, + args.fleet, + args.leader, + ) + if args.command == "resume-route": + return cmd_resume_route( + config, + args.arrival_radius, + args.max_legs, + args.leg_timeout, + args.poll_interval, + args.min_progress_yards, + args.fleet, + args.leader, + ) + if args.command == "dashboard": + return cmd_dashboard(config, args.host, args.port) + if args.command == "tram-state": + return cmd_tram_state( + config, + args.time_offset_ms, + args.calibrate, + args.calibrate_step_ms, + args.watch, + args.interval, + args.json, + args.fleet, + args.leader, + ) + if args.command == "board-tram": + return cmd_board_tram( + config, + args.registry, + args.from_node, + args.to_node, + args.scan_radius, + args.arrival_radius, + args.poll_interval, + args.board_timeout, + args.ride_timeout, + args.fleet, + args.leader, + ) + if args.command == "recover-death": + return cmd_recover_death( + config, + args.poll_interval, + args.timeout, + args.fleet, + args.leader, + not args.no_resume_route, + args.resume_arrival_radius, + args.resume_max_legs, + args.resume_leg_timeout, + args.resume_min_progress_yards, + ) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/bot_fleet_manager/deeprun_tram.py b/tools/bot_fleet_manager/deeprun_tram.py new file mode 100644 index 000000000..3fb74f146 --- /dev/null +++ b/tools/bot_fleet_manager/deeprun_tram.py @@ -0,0 +1,283 @@ +"""Predict Deeprun Tram car positions from CMaNGOS TBC data. + +CMaNGOS models Deeprun cars as GAMEOBJECT_TYPE_TRANSPORT (type 11) +ElevatorTransport objects. Their movement is derived from static DB spawns +plus TransportAnimation.dbc offsets, not the `transports` table used by +boats and zeppelins. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +import struct +import time +from pathlib import Path +from typing import Any + + +DEEPRUN_MAP_ID = 369 +DEEPRUN_TRAM_ENTRIES = range(176080, 176087) +STATION_WINDOW_YARDS = 60.0 +BOARDING_WINDOW_YARDS = 22.0 + + +@dataclass(frozen=True) +class TramSpawn: + guid: int + entry: int + map_id: int + x: float + y: float + z: float + orientation: float + rotation0: float + rotation1: float + rotation2: float + rotation3: float + + +@dataclass(frozen=True) +class TramFrame: + record_id: int + entry: int + time_ms: int + x: float + y: float + z: float + sequence_id: int + + +@dataclass(frozen=True) +class PredictedTram: + guid: int + entry: int + map_id: int + cycle_ms: int + time_ms: int + x: float + y: float + z: float + local_x: float + local_y: float + local_z: float + nearest_station: str + station_xy_distance: float + at_station: bool + boardable_at_station: bool + xy_distance_to_leader: float | None = None + distance_to_leader: float | None = None + + +# Snapshot from tbcmangos.gameobject for entries 176080-176085. These are +# stable client-data spawns for TBC Deeprun; if a server customizes them, +# regenerate this list from the DB. +DEEPRUN_TBC_SPAWNS: tuple[TramSpawn, ...] = ( + TramSpawn(18802, 176080, 369, 4.580639839172363, 28.209699630737305, 7.011069774627686, 1.5707999467849731, 0.0, 0.0, 0.7071070075035095, 0.7071070075035095), + TramSpawn(18803, 176081, 369, 4.528069972991943, 8.435290336608887, 7.011069774627686, 1.5707999467849731, 0.0, 0.0, 0.7071070075035095, 0.7071070075035095), + TramSpawn(18804, 176082, 369, -45.4005012512207, 2492.7900390625, 6.98859977722168, 1.5707999467849731, 0.0, 0.0, 0.7071070075035095, 0.7071070075035095), + TramSpawn(18805, 176083, 369, -45.400699615478516, 2512.14990234375, 6.98859977722168, 1.5707999467849731, 0.0, 0.0, 0.7071070075035095, 0.7071070075035095), + TramSpawn(18806, 176084, 369, -45.39339828491211, 2472.929931640625, 6.98859977722168, 1.5707999467849731, 0.0, 0.0, 0.7071070075035095, 0.7071070075035095), + TramSpawn(18807, 176085, 369, 4.49882984161377, -11.34749984741211, 7.011069774627686, -1.5707999467849731, 0.0, 0.0, -0.7071070075035095, 0.7071070075035095), +) + + +TRACK_STATIONS: tuple[tuple[str, float, float], ...] = ( + ("ironforge", 8.11925983428955, 11.050399780273438), + ("stormwind", -20.4, 2492.8), +) + + +def load_transport_animation(path: Path) -> dict[int, list[TramFrame]]: + with path.open("rb") as handle: + magic = handle.read(4) + if magic != b"WDBC": + raise ValueError(f"{path} is not a WDBC file") + record_count, field_count, record_size, _string_size = struct.unpack("<4I", handle.read(16)) + if field_count != 7 or record_size != 28: + raise ValueError(f"unexpected TransportAnimation.dbc layout: fields={field_count} recordSize={record_size}") + + by_entry: dict[int, list[TramFrame]] = {} + for _ in range(record_count): + record = handle.read(record_size) + record_id, entry, time_ms, x, y, z, sequence_id = struct.unpack(" tuple[float, float, float]: + # CMaNGOS/G3D uses vector * quaternion in ElevatorTransport. That is the + # inverse of the more common q * vector convention, so conjugate first. + qx, qy, qz = -qx, -qy, -qz + tx = 2.0 * (qy * z - qz * y) + ty = 2.0 * (qz * x - qx * z) + tz = 2.0 * (qx * y - qy * x) + rx = x + qw * tx + (qy * tz - qz * ty) + ry = y + qw * ty + (qz * tx - qx * tz) + rz = z + qw * tz + (qx * ty - qy * tx) + return rx, ry, rz + + +def interpolate_frame(frames: list[TramFrame], path_time_ms: int) -> tuple[float, float, float]: + if not frames: + return (0.0, 0.0, 0.0) + if path_time_ms <= frames[0].time_ms: + first = frames[0] + return (first.x, first.y, first.z) + + prev = frames[0] + for frame in frames[1:]: + if frame.time_ms >= path_time_ms: + span = max(1, frame.time_ms - prev.time_ms) + t = (path_time_ms - prev.time_ms) / span + return ( + prev.x + (frame.x - prev.x) * t, + prev.y + (frame.y - prev.y) * t, + prev.z + (frame.z - prev.z) * t, + ) + prev = frame + + last = frames[-1] + return (last.x, last.y, last.z) + + +def nearest_station(x: float, y: float) -> tuple[str, float]: + best_name = "unknown" + best_dist = float("inf") + for name, sx, sy in TRACK_STATIONS: + dist = math.hypot(x - sx, y - sy) + if dist < best_dist: + best_name = name + best_dist = dist + return best_name, best_dist + + +def _predict_trams_from_frames( + frames_by_entry: dict[int, list[TramFrame]], + now_ms: int | None = None, + time_offset_ms: int = 0, + leader: dict[str, Any] | None = None, +) -> list[PredictedTram]: + clock_ms = int(time.time() * 1000) if now_ms is None else now_ms + predictions: list[PredictedTram] = [] + for spawn in DEEPRUN_TBC_SPAWNS: + frames = frames_by_entry.get(spawn.entry, []) + if not frames: + continue + cycle_ms = max(frame.time_ms for frame in frames) + path_time_ms = (clock_ms + time_offset_ms) % cycle_ms if cycle_ms > 0 else 0 + lx, ly, lz = interpolate_frame(frames, path_time_ms) + rx, ry, rz = rotate_by_quaternion( + lx, ly, lz, + spawn.rotation0, spawn.rotation1, spawn.rotation2, spawn.rotation3, + ) + # CMaNGOS ElevatorTransport applies this sign flip after local rotation. + ry = -ry + x = spawn.x + rx + y = spawn.y + ry + z = spawn.z + rz + station_name, station_xy_distance = nearest_station(x, y) + xy_distance_to_leader = None + distance_to_leader = None + if leader and int(leader.get("mapId", -1)) == spawn.map_id: + pos = leader.get("position", {}) + dx = x - float(pos.get("x", 0.0)) + dy = y - float(pos.get("y", 0.0)) + dz = z - float(pos.get("z", 0.0)) + xy_distance_to_leader = math.hypot(dx, dy) + distance_to_leader = math.sqrt( + dx ** 2 + + dy ** 2 + + dz ** 2 + ) + predictions.append(PredictedTram( + guid=spawn.guid, + entry=spawn.entry, + map_id=spawn.map_id, + cycle_ms=cycle_ms, + time_ms=path_time_ms, + x=x, + y=y, + z=z, + local_x=lx, + local_y=ly, + local_z=lz, + nearest_station=station_name, + station_xy_distance=station_xy_distance, + at_station=station_xy_distance <= STATION_WINDOW_YARDS, + boardable_at_station=station_xy_distance <= BOARDING_WINDOW_YARDS, + xy_distance_to_leader=xy_distance_to_leader, + distance_to_leader=distance_to_leader, + )) + + predictions.sort(key=lambda pred: pred.xy_distance_to_leader if pred.xy_distance_to_leader is not None else 999999.0) + return predictions + + +def predict_trams( + dbc_path: Path, + now_ms: int | None = None, + time_offset_ms: int = 0, + leader: dict[str, Any] | None = None, +) -> list[PredictedTram]: + return _predict_trams_from_frames( + load_transport_animation(dbc_path), + now_ms=now_ms, + time_offset_ms=time_offset_ms, + leader=leader, + ) + + +def find_best_time_offset( + dbc_path: Path, + leader: dict[str, Any], + now_ms: int | None = None, + step_ms: int = 250, +) -> tuple[int, PredictedTram] | None: + frames_by_entry = load_transport_animation(dbc_path) + cycle_ms = max( + (frame.time_ms for frames in frames_by_entry.values() for frame in frames), + default=0, + ) + if cycle_ms <= 0: + return None + clock_ms = int(time.time() * 1000) if now_ms is None else now_ms + best: tuple[int, PredictedTram] | None = None + best_dist = float("inf") + step = max(25, step_ms) + for offset in range(0, cycle_ms, step): + predictions = _predict_trams_from_frames( + frames_by_entry, + now_ms=clock_ms, + time_offset_ms=offset, + leader=leader, + ) + if not predictions or predictions[0].xy_distance_to_leader is None: + continue + dist = predictions[0].xy_distance_to_leader + if dist < best_dist: + best_dist = dist + best = (offset, predictions[0]) + return best + + +def prediction_to_dict(prediction: PredictedTram) -> dict[str, Any]: + return { + "guid": prediction.guid, + "entry": prediction.entry, + "mapId": prediction.map_id, + "cycleMs": prediction.cycle_ms, + "timeMs": prediction.time_ms, + "position": {"x": prediction.x, "y": prediction.y, "z": prediction.z}, + "localOffset": {"x": prediction.local_x, "y": prediction.local_y, "z": prediction.local_z}, + "nearestStation": prediction.nearest_station, + "stationXyDistance": prediction.station_xy_distance, + "atStation": prediction.at_station, + "boardableAtStation": prediction.boardable_at_station, + "xyDistanceToLeader": prediction.xy_distance_to_leader, + "distanceToLeader": prediction.distance_to_leader, + } diff --git a/tools/bot_fleet_manager/fetch_minimanager_assets.py b/tools/bot_fleet_manager/fetch_minimanager_assets.py new file mode 100644 index 000000000..a185b4631 --- /dev/null +++ b/tools/bot_fleet_manager/fetch_minimanager_assets.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Fetch MiniManager zone map art into the ignored fleet-manager runtime dir.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_DEST = ROOT / "tools" / "bot_fleet_manager" / "runtime" / "map_assets" / "zone" +DEFAULT_CONTINENT_DEST = ROOT / "tools" / "bot_fleet_manager" / "runtime" / "map_assets" / "continent" +DEFAULT_REMOTE_DIR = "/var/www/minimanager/img/zone" +DEFAULT_REMOTE_MAP_DIR = "/var/www/minimanager/img/map" +DEFAULT_EXTERNAL_ENV = Path(r"C:\Users\admin\code\wow_server\.env") +CONTINENT_ASSETS = ["azeroth.jpg", "outland.jpg", "northrend.jpg"] + + +def read_env(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + with path.open("r", encoding="utf-8") as handle: + for raw in handle: + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"').strip("'") + return values + + +def require(values: dict[str, str], key: str, env_path: Path) -> str: + value = values.get(key, "").strip() + if not value: + raise SystemExit(f"{key} must be set in {env_path}") + return value + + +def main() -> int: + parser = argparse.ArgumentParser(description="Fetch MiniManager zone PNGs for the fleet dashboard") + parser.add_argument("--env", type=Path, default=DEFAULT_EXTERNAL_ENV, help="Path to the wow_server .env file") + parser.add_argument("--remote-dir", default=DEFAULT_REMOTE_DIR, help="Remote MiniManager img/zone directory") + parser.add_argument("--remote-map-dir", default=DEFAULT_REMOTE_MAP_DIR, help="Remote MiniManager img/map directory") + parser.add_argument("--dest", type=Path, default=DEFAULT_DEST, help="Local destination directory") + parser.add_argument("--continent-dest", type=Path, default=DEFAULT_CONTINENT_DEST, help="Local continent map destination directory") + parser.add_argument("--skip-continents", action="store_true", help="Only fetch zone PNGs") + args = parser.parse_args() + + env_path = args.env.expanduser() + if not env_path.exists(): + raise SystemExit(f"Missing env file: {env_path}") + + values = read_env(env_path) + host = require(values, "MANGOS_HOST", env_path) + user = require(values, "MANGOS_USER", env_path) + key_path = require(values, "MANGOS_SSH_KEY_PATH", env_path) + port = values.get("MANGOS_PORT", "22").strip() or "22" + + dest = args.dest.expanduser() + dest.mkdir(parents=True, exist_ok=True) + remote_glob = args.remote_dir.rstrip("/") + "/*.png" + command = [ + "scp", + "-i", + key_path, + "-P", + port, + "-o", + "StrictHostKeyChecking=accept-new", + f"{user}@{host}:{remote_glob}", + str(dest) + os.sep, + ] + + completed = subprocess.run(command, text=True) + if completed.returncode != 0: + return completed.returncode + + files = list(dest.glob("*.png")) + total_bytes = sum(path.stat().st_size for path in files) + print(f"Fetched {len(files)} zone map PNGs to {dest} ({total_bytes} bytes)") + + if not args.skip_continents: + continent_dest = args.continent_dest.expanduser() + continent_dest.mkdir(parents=True, exist_ok=True) + for asset in CONTINENT_ASSETS: + remote_asset = args.remote_map_dir.rstrip("/") + "/" + asset + command = [ + "scp", + "-i", + key_path, + "-P", + port, + "-o", + "StrictHostKeyChecking=accept-new", + f"{user}@{host}:{remote_asset}", + str(continent_dest) + os.sep, + ] + completed = subprocess.run(command, text=True) + if completed.returncode != 0: + return completed.returncode + continent_files = list(continent_dest.glob("*.*")) + continent_bytes = sum(path.stat().st_size for path in continent_files) + print(f"Fetched {len(continent_files)} continent map assets to {continent_dest} ({continent_bytes} bytes)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bot_fleet_manager/fleet.settings.example.json b/tools/bot_fleet_manager/fleet.settings.example.json new file mode 100644 index 000000000..a025f0a0f --- /dev/null +++ b/tools/bot_fleet_manager/fleet.settings.example.json @@ -0,0 +1,67 @@ +{ + "woweeHeadless": "build/bin/wowee_headless.exe", + "runtimeDir": "tools/bot_fleet_manager/runtime", + "launchDelaySeconds": 2.0, + "supervision": { + "initialRestartBackoffSeconds": 5.0, + "maxRestartBackoffSeconds": 60.0, + "maxRestarts": 0 + }, + "pathfinding": { + "enabled": false, + "baseUrl": "http://127.0.0.1:8790", + "coordinateSpace": "wowee-canonical", + "timeoutSeconds": 45.0 + }, + "defaults": { + "auth": { + "host": "127.0.0.1", + "port": 3724 + }, + "client": { + "major": 2, + "minor": 4, + "patch": 3, + "build": 8606, + "protocol": 8, + "locale": "enUS", + "platform": "x86", + "os": "Win", + "expansion": "tbc" + }, + "realm": { + "name": "", + "index": 0, + "id": 0 + }, + "api": { + "bind": "127.0.0.1", + "basePort": 8787, + "maxMessages": 500 + }, + "automation": { + "commandDelaySeconds": 0.25, + "onEnterWorldCommands": [] + } + }, + "leaders": [ + { + "id": "leader-1", + "fleet": "alpha", + "account": "ACCOUNT_1", + "password": "PASSWORD_1", + "character": "Leaderone", + "apiPort": 8787, + "partyBots": ["Botone", "Bottwo", "Botthree", "Botfour"] + }, + { + "id": "leader-2", + "fleet": "beta", + "account": "ACCOUNT_2", + "password": "PASSWORD_2", + "character": "Leadertwo", + "apiPort": 8788, + "partyBots": [] + } + ] +} diff --git a/tools/bot_fleet_manager/follow_player.py b/tools/bot_fleet_manager/follow_player.py new file mode 100644 index 000000000..b34b3865f --- /dev/null +++ b/tools/bot_fleet_manager/follow_player.py @@ -0,0 +1,170 @@ +"""Have a headless leader trail a live player around, cataloging the walked +path as a reusable survey file. + +Polls the leader's own /world/entities for a PLAYER by name, and re-issues +/movement/goto toward that player's live position whenever they've moved far +enough away. Each commanded position is appended to a catalog file at +//.json, which `bot_fleet_manager.py +replay-survey` can walk a leader through later - a human-verified path +sidesteps whatever the automated pathfinder struggles with (navmesh gaps, +mob-dense corridors), at the cost of needing a live human to walk it once. + +Usage: + python follow_player.py + [--follow-distance 12] [--repath-threshold 15] + [--poll-interval 2.0] [--catalog-dir ...] +""" + +import argparse +import json +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + + +def http_json(url: str, method: str = "GET", payload: dict | None = None, timeout: float = 10.0): + if not url.startswith(("http://", "https://")): + raise ValueError(f"refusing non-http(s) URL: {url}") + data = json.dumps(payload).encode() if payload is not None else None + req = urllib.request.Request(url, data=data, method=method, + headers={"Content-Type": "application/json"} if data else {}) + # url is validated to http(s) above; this calls our own local fleet API, not attacker-controlled input. + with urllib.request.urlopen(req, timeout=timeout) as resp: # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + return json.loads(resp.read()) + + +def find_player(api_base: str, name: str, radius: float = 150.0): + result = http_json(f"{api_base}/world/entities?radius={radius}") + for entity in result.get("entities", []): + if entity.get("type") == "PLAYER" and entity.get("name", "").lower() == name.lower(): + return entity + return None + + +def point_distance(a: dict, b: dict) -> float: + return ((a["x"] - b["x"]) ** 2 + (a["y"] - b["y"]) ** 2 + (a["z"] - b["z"]) ** 2) ** 0.5 + + +def catalog_path(catalog_dir: str, map_name: str, route_name: str) -> Path: + return Path(catalog_dir) / map_name / f"{route_name}.json" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("api_base") + parser.add_argument("player_name") + parser.add_argument("map_name", help="Catalog subdirectory, e.g. 'dun-morogh', 'wetlands'") + parser.add_argument("route_name", help="Catalog file name, e.g. 'ironforge-to-menethil'") + parser.add_argument("--follow-distance", type=float, default=12.0, + help="arrivalRadius for each goto - how close the leader stops behind the target") + parser.add_argument("--repath-threshold", type=float, default=15.0, + help="minimum player movement (yards) before issuing a new goto " + "(also the effective waypoint spacing in the saved catalog)") + parser.add_argument("--poll-interval", type=float, default=2.0) + parser.add_argument("--catalog-dir", default="tools/bot_fleet_manager/road_surveys") + parser.add_argument("--description", default="", help="Free-text note saved with the catalog entry") + parser.add_argument("--search-radius", type=float, default=150.0) + args = parser.parse_args() + + log_path = catalog_path(args.catalog_dir, args.map_name, args.route_name) + log_path.parent.mkdir(parents=True, exist_ok=True) + # Resume from an existing catalog instead of starting fresh - a restart + # (e.g. after the leader dies) used to silently overwrite everything + # captured so far, since the in-memory list always started empty. + # Live-observed losing 71 already-logged waypoints this way. + waypoints: list[dict] = [] + started_at = time.time() + if log_path.exists(): + try: + existing = json.loads(log_path.read_text()) + existing_wps = existing.get("waypoints") or [] + waypoints = [{"mapId": existing.get("mapId"), **w} for w in existing_wps] + if existing.get("capturedAt"): + started_at = existing["capturedAt"] + if waypoints: + print(f"resuming existing catalog with {len(waypoints)} waypoints already logged") + except (json.JSONDecodeError, OSError) as exc: + print(f"could not read existing catalog ({exc}), starting fresh") + + def save(): + catalog = { + "mapName": args.map_name, + "routeName": args.route_name, + "playerName": args.player_name, + "description": args.description, + "capturedAt": started_at, + "mapId": waypoints[0]["mapId"] if waypoints else None, + "waypoints": [{"x": w["x"], "y": w["y"], "z": w["z"]} for w in waypoints], + } + log_path.write_text(json.dumps(catalog, indent=2)) + + print(f"waiting to spot {args.player_name} within {args.search_radius}y...") + print(f"catalog: {log_path}") + last_target: dict | None = waypoints[-1] if waypoints else None + last_map_id = waypoints[-1]["mapId"] if waypoints else None + + while True: + try: + self_info = http_json(f"{args.api_base}/world/self") + except Exception as exc: + print(f"leader status check failed: {exc}") + time.sleep(args.poll_interval) + continue + + if not self_info.get("inWorld"): + print("leader not in world, waiting...") + time.sleep(args.poll_interval) + continue + + health = self_info.get("health", {}) + if health.get("isPlayerDead") or health.get("isDead"): + print("leader is dead, stopping follow") + break + + try: + target = find_player(args.api_base, args.player_name, args.search_radius) + except Exception as exc: + print(f"entity scan failed: {exc}") + time.sleep(args.poll_interval) + continue + + if target is None: + time.sleep(args.poll_interval) + continue + + map_id = self_info.get("mapId") + pos = target["position"] + if last_target is None: + print(f"spotted {args.player_name} at ({pos['x']:.1f},{pos['y']:.1f},{pos['z']:.1f}) " + f"distance={target.get('distance', 0):.1f}y, following") + + moved_enough = last_target is None or point_distance(pos, last_target) >= args.repath_threshold + map_changed = map_id != last_map_id + + if moved_enough or map_changed: + try: + http_json(f"{args.api_base}/movement/goto", "POST", { + "mapId": map_id, "x": pos["x"], "y": pos["y"], "z": pos["z"], + "arrivalRadius": args.follow_distance, + }) + except Exception as exc: + print(f"goto failed: {exc}") + time.sleep(args.poll_interval) + continue + waypoints.append({"mapId": map_id, "x": pos["x"], "y": pos["y"], "z": pos["z"]}) + save() + print(f"-> following to ({pos['x']:.1f},{pos['y']:.1f},{pos['z']:.1f}) mapId={map_id} " + f"[{len(waypoints)} waypoints logged]") + last_target = pos + last_map_id = map_id + + time.sleep(args.poll_interval) + + print(f"stopped with {len(waypoints)} waypoints saved to {log_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/bot_fleet_manager/import_headless_settings.py b/tools/bot_fleet_manager/import_headless_settings.py new file mode 100644 index 000000000..1e50814f4 --- /dev/null +++ b/tools/bot_fleet_manager/import_headless_settings.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Create a one-leader fleet config from an existing wowee_headless settings file.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] + + +def load_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + doc = json.load(handle) + if not isinstance(doc, dict): + raise ValueError("settings root must be a JSON object") + return doc + + +def write_json(path: Path, doc: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(doc, handle, indent=2) + handle.write("\n") + + +def resolve_path(raw: str) -> Path: + path = Path(raw) + return path if path.is_absolute() else ROOT / path + + +def bot_name_from_command(command: str) -> str: + lowered = command.strip().lower() + if not lowered.startswith(".bot add "): + return "" + return command.strip()[len(".bot add "):].strip() + + +def build_fleet_config(settings: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]: + auth = settings.get("auth", {}) + client = settings.get("client", {}) + realm = settings.get("realm", {}) + character = settings.get("character", {}) + api = settings.get("api", {}) + bots = settings.get("bots", {}) + automation = settings.get("automation", {}) + + party_bots: list[str] = [] + if isinstance(bots, dict) and bots.get("enabled", False): + for name in bots.get("names", []): + if isinstance(name, str) and name.strip(): + party_bots.append(name.strip()) + + startup_commands: list[str] = [] + for command in automation.get("onEnterWorldCommands", []) if isinstance(automation, dict) else []: + if not isinstance(command, str) or not command.strip(): + continue + bot_name = bot_name_from_command(command) + if bot_name: + party_bots.append(bot_name) + else: + startup_commands.append(command.strip()) + + seen: set[str] = set() + unique_party_bots = [] + for name in party_bots: + key = name.lower() + if key in seen: + continue + seen.add(key) + unique_party_bots.append(name) + + return { + "woweeHeadless": args.wowee_headless, + "runtimeDir": "tools/bot_fleet_manager/runtime", + "launchDelaySeconds": 2.0, + "supervision": { + "initialRestartBackoffSeconds": 5.0, + "maxRestartBackoffSeconds": 60.0, + "maxRestarts": 0, + }, + "defaults": { + "auth": { + "host": auth.get("host", "127.0.0.1"), + "port": int(auth.get("port", 3724)), + }, + "client": client, + "realm": realm, + "api": { + "bind": api.get("bind", "127.0.0.1"), + "basePort": int(api.get("port", 8787)), + "maxMessages": int(api.get("maxMessages", 500)), + }, + "automation": { + "commandDelaySeconds": float(automation.get("commandDelaySeconds", 0.25)) if isinstance(automation, dict) else 0.25, + "onEnterWorldCommands": [], + }, + }, + "leaders": [ + { + "id": args.leader_id, + "fleet": args.fleet, + "account": auth.get("account", ""), + "password": auth.get("password", ""), + "character": character.get("name", ""), + "apiPort": int(api.get("port", 8787)), + "partyBots": unique_party_bots, + "startupCommands": startup_commands, + } + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", default="tools/headless_client/settings.json", help="Existing headless settings JSON") + parser.add_argument("--output", default="tools/bot_fleet_manager/fleet.settings.json", help="Local fleet config to write") + parser.add_argument("--wowee-headless", default="build/bin/wowee_headless.exe") + parser.add_argument("--leader-id", default="demo-leader-1") + parser.add_argument("--fleet", default="demo") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + fleet = build_fleet_config(load_json(resolve_path(args.input)), args) + if args.dry_run: + redacted = json.loads(json.dumps(fleet)) + for leader in redacted.get("leaders", []): + if "password" in leader: + leader["password"] = "" + if "account" in leader: + leader["account"] = "" + print(json.dumps(redacted, indent=2)) + return 0 + + output = resolve_path(args.output) + write_json(output, fleet) + print(f"Wrote {output}") + print("This file may contain account credentials and is ignored by git.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bot_fleet_manager/map_data/zone_map_bounds.json b/tools/bot_fleet_manager/map_data/zone_map_bounds.json new file mode 100644 index 000000000..fc422ec1c --- /dev/null +++ b/tools/bot_fleet_manager/map_data/zone_map_bounds.json @@ -0,0 +1,642 @@ +{ + "1": { + "asset": "DunMorogh.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -7160.41650391, + "left": 1802.08325195, + "map_id": 0, + "right": -3122.91650391, + "top": -3877.08325195 + }, + "10": { + "asset": "Duskwood.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -11516.66601562, + "left": 833.33331299, + "map_id": 0, + "right": -1866.66662598, + "top": -9716.66601562 + }, + "11": { + "asset": "Wetlands.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -4904.16650391, + "left": -389.58331299, + "map_id": 0, + "right": -4525.0, + "top": -2147.91650391 + }, + "12": { + "asset": "Elwynn.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -10254.16601562, + "left": 1535.41662598, + "map_id": 0, + "right": -1935.41662598, + "top": -7939.58300781 + }, + "130": { + "asset": "Silverpine.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1133.33325195, + "left": 3449.99975586, + "map_id": 0, + "right": -750.0, + "top": 1666.66662598 + }, + "1377": { + "asset": "Silithus.png", + "asset_height": 669, + "asset_width": 1002, + "bottom": -8281.25, + "left": 2537.5, + "map_id": 1, + "right": -945.83398438, + "top": -5958.33398438 + }, + "139": { + "asset": "EasternPlaguelands.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 1218.75, + "left": -2185.41650391, + "map_id": 0, + "right": -6056.25, + "top": 3799.99975586 + }, + "14": { + "asset": "Durotar.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1716.66662598, + "left": -1962.49987793, + "map_id": 1, + "right": -7249.99951172, + "top": 1808.33325195 + }, + "141": { + "asset": "Teldrassil.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 8437.5, + "left": 3814.58325195, + "map_id": 1, + "right": -1277.08325195, + "top": 11831.25 + }, + "148": { + "asset": "Darkshore.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 3966.66650391, + "left": 2941.66650391, + "map_id": 1, + "right": -3608.33325195, + "top": 8333.33300781 + }, + "1497": { + "asset": "Undercity.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 1237.84118652, + "left": 873.19262695, + "map_id": 0, + "right": -86.18240356, + "top": 1877.9453125 + }, + "15": { + "asset": "Dustwallow.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -5533.33300781, + "left": -974.99993896, + "map_id": 1, + "right": -6225.0, + "top": -2033.33325195 + }, + "1519": { + "asset": "Stormwind.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -9175.20507812, + "left": 1380.97143555, + "map_id": 0, + "right": 36.70063019, + "top": -8278.85058594 + }, + "1537": { + "asset": "Ironforge.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -5096.84570312, + "left": -713.59136963, + "map_id": 0, + "right": -1504.21643066, + "top": -4569.24121094 + }, + "16": { + "asset": "Aszhara.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 1960.41662598, + "left": -3277.08325195, + "map_id": 1, + "right": -8347.91601562, + "top": 5341.66650391 + }, + "1637": { + "asset": "Ogrimmar.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 1338.46057129, + "left": -3680.60107422, + "map_id": 1, + "right": -5083.20556641, + "top": 2273.87719727 + }, + "1638": { + "asset": "ThunderBluff.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1545.83325195, + "left": 516.66662598, + "map_id": 1, + "right": -527.08331299, + "top": -849.99993896 + }, + "1657": { + "asset": "Darnassis.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 9532.58691406, + "left": 2938.36279297, + "map_id": 1, + "right": 1880.02954102, + "top": 10238.31640625 + }, + "17": { + "asset": "Barrens.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -5143.75, + "left": 2622.91650391, + "map_id": 1, + "right": -7510.41650391, + "top": 1612.49987793 + }, + "215": { + "asset": "Mulgore.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -3697.91650391, + "left": 2047.91662598, + "map_id": 1, + "right": -3089.58325195, + "top": -272.91665649 + }, + "2597": { + "asset": "AlteracValley.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1739.58325195, + "left": 1781.24987793, + "map_id": 30, + "right": -2456.25, + "top": 1085.41662598 + }, + "267": { + "asset": "Hilsbrad.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1733.33325195, + "left": 1066.66662598, + "map_id": 0, + "right": -2133.33325195, + "top": 400.0 + }, + "28": { + "asset": "WesternPlaguelands.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 499.99996948, + "left": 416.66665649, + "map_id": 0, + "right": -3883.33325195, + "top": 3366.66650391 + }, + "3": { + "asset": "Badlands.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -7547.91650391, + "left": -2079.16650391, + "map_id": 0, + "right": -4566.66650391, + "top": -5889.58300781 + }, + "3277": { + "asset": "WarsongGulch.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 862.49993896, + "left": 2041.66662598, + "map_id": 489, + "right": 895.83331299, + "top": 1627.08325195 + }, + "33": { + "asset": "Stranglethorn.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -15422.91601562, + "left": 2220.83325195, + "map_id": 0, + "right": -4160.41650391, + "top": -11168.75 + }, + "331": { + "asset": "Ashenvale.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 829.16662598, + "left": 1699.99987793, + "map_id": 1, + "right": -4066.66650391, + "top": 4672.91650391 + }, + "3358": { + "asset": "ArathiBasin.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 337.5, + "left": 1858.33325195, + "map_id": 529, + "right": 102.08332825, + "top": 1508.33325195 + }, + "3430": { + "asset": "EversongWoods.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 7758.33300781, + "left": -4487.5, + "map_id": 530, + "right": -9412.5, + "top": 11041.66601562 + }, + "3433": { + "asset": "Ghostlands.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 6066.66650391, + "left": -5283.33300781, + "map_id": 530, + "right": -8583.33300781, + "top": 8266.66601562 + }, + "3483": { + "asset": "Hellfire.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1962.49987793, + "left": 5539.58300781, + "map_id": 530, + "right": 375.0, + "top": 1481.25 + }, + "3487": { + "asset": "SilvermoonCity.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 9346.93847656, + "left": -6400.75, + "map_id": 530, + "right": -7612.20849609, + "top": 10153.70898438 + }, + "3518": { + "asset": "Nagrand.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -3641.66650391, + "left": 10295.83300781, + "map_id": 530, + "right": 4770.83300781, + "top": 41.66666412 + }, + "3519": { + "asset": "TerokkarForest.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -4600.0, + "left": 7083.33300781, + "map_id": 530, + "right": 1683.33325195, + "top": -999.99993896 + }, + "3520": { + "asset": "ShadowmoonValley.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -5614.58300781, + "left": 4225.0, + "map_id": 530, + "right": -1275.0, + "top": -1947.91662598 + }, + "3521": { + "asset": "Zangarmarsh.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1416.66662598, + "left": 9475.0, + "map_id": 530, + "right": 4447.91650391, + "top": 1935.41662598 + }, + "3522": { + "asset": "BladesEdgeMountains.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 791.66662598, + "left": 8845.83300781, + "map_id": 530, + "right": 3420.83325195, + "top": 4408.33300781 + }, + "3523": { + "asset": "Netherstorm.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 1739.58325195, + "left": 5483.33300781, + "map_id": 530, + "right": -91.66666412, + "top": 5456.25 + }, + "3524": { + "asset": "AzuremystIsle.png", + "asset_height": 668, + "asset_width": 1020, + "bottom": -5508.33300781, + "left": -10500.0, + "map_id": 530, + "right": -14570.83300781, + "top": -2793.75 + }, + "3525": { + "asset": "BloodmystIsle.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -2933.33325195, + "left": -10075.0, + "map_id": 530, + "right": -13337.49902344, + "top": -758.33331299 + }, + "3557": { + "asset": "TheExodar.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -4314.37109375, + "left": -11066.3671875, + "map_id": 530, + "right": -12123.13769531, + "top": -3609.68334961 + }, + "357": { + "asset": "Feralas.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -6999.99951172, + "left": 5441.66650391, + "map_id": 1, + "right": -1508.33325195, + "top": -2366.66650391 + }, + "36": { + "asset": "Alterac.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -366.66665649, + "left": 783.33331299, + "map_id": 0, + "right": -2016.66662598, + "top": 1500.0 + }, + "361": { + "asset": "Felwood.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 3299.99975586, + "left": 1641.66662598, + "map_id": 1, + "right": -4108.33300781, + "top": 7133.33300781 + }, + "3703": { + "asset": "ShattrathCity.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -2344.7878418, + "left": 6135.25878906, + "map_id": 530, + "right": 4829.00878906, + "top": -1473.95446777 + }, + "38": { + "asset": "LochModan.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -6327.08300781, + "left": -1993.74987793, + "map_id": 0, + "right": -4752.08300781, + "top": -4487.5 + }, + "3820": { + "asset": "NetherstormArena.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 1404.16662598, + "left": 2660.41650391, + "map_id": 566, + "right": 389.58331299, + "top": 2918.75 + }, + "4": { + "asset": "BlastedLands.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -12800.0, + "left": -1241.66662598, + "map_id": 0, + "right": -4591.66650391, + "top": -10566.66601562 + }, + "40": { + "asset": "Westfall.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -11733.33300781, + "left": 3016.66650391, + "map_id": 0, + "right": -483.33331299, + "top": -9400.0 + }, + "400": { + "asset": "ThousandNeedles.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -6899.99951172, + "left": -433.33331299, + "map_id": 1, + "right": -4833.33300781, + "top": -3966.66650391 + }, + "405": { + "asset": "Desolace.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -2545.83325195, + "left": 4233.33300781, + "map_id": 1, + "right": -262.5, + "top": 452.08331299 + }, + "406": { + "asset": "StonetalonMountains.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -339.58331299, + "left": 3245.83325195, + "map_id": 1, + "right": -1637.49987793, + "top": 2916.66650391 + }, + "41": { + "asset": "DeadwindPass.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -11533.33300781, + "left": -833.33331299, + "map_id": 0, + "right": -3333.33325195, + "top": -9866.66601562 + }, + "44": { + "asset": "Redridge.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -10022.91601562, + "left": -1570.83325195, + "map_id": 0, + "right": -3741.66650391, + "top": -8575.0 + }, + "440": { + "asset": "Tanaris.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -10475.0, + "left": -218.74998474, + "map_id": 1, + "right": -7118.74951172, + "top": -5875.0 + }, + "45": { + "asset": "Arathi.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -2533.33325195, + "left": -866.66662598, + "map_id": 0, + "right": -4466.66650391, + "top": -133.33332825 + }, + "46": { + "asset": "BurningSteppes.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -8983.33300781, + "left": -266.66665649, + "map_id": 0, + "right": -3195.83325195, + "top": -7031.24951172 + }, + "47": { + "asset": "Hinterlands.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1100.0, + "left": -1575.0, + "map_id": 0, + "right": -5425.0, + "top": 1466.66662598 + }, + "490": { + "asset": "UngoroCrater.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -8433.33300781, + "left": 533.33331299, + "map_id": 1, + "right": -3166.66650391, + "top": -5966.66650391 + }, + "493": { + "asset": "Moonglade.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 6952.08300781, + "left": -1381.25, + "map_id": 1, + "right": -3689.58325195, + "top": 8491.66601562 + }, + "51": { + "asset": "SearingGorge.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -7587.49951172, + "left": -322.91665649, + "map_id": 0, + "right": -2554.16650391, + "top": -6100.0 + }, + "618": { + "asset": "Winterspring.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 3799.99975586, + "left": -316.66665649, + "map_id": 1, + "right": -7416.66650391, + "top": 8533.33300781 + }, + "8": { + "asset": "SwampOfSorrows.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -11150.0, + "left": -2222.91650391, + "map_id": 0, + "right": -4516.66650391, + "top": -9620.83300781 + }, + "85": { + "asset": "Tirisfal.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 824.99993896, + "left": 3033.33325195, + "map_id": 0, + "right": -1485.41662598, + "top": 3837.49975586 + } +} diff --git a/tools/bot_fleet_manager/road_surveys/wetlands/ironforge-to-menethil.json b/tools/bot_fleet_manager/road_surveys/wetlands/ironforge-to-menethil.json new file mode 100644 index 000000000..e148e1ffa --- /dev/null +++ b/tools/bot_fleet_manager/road_surveys/wetlands/ironforge-to-menethil.json @@ -0,0 +1,705 @@ +{ + "mapName": "wetlands", + "routeName": "ironforge-to-menethil", + "playerName": "Joshio", + "description": "Human-led survey: Dun Algaz tunnel entrance to Wetlands -> Menethil Harbor road", + "capturedAt": 1783817193.9320073, + "mapId": 0, + "waypoints": [ + { + "x": -2783.1, + "y": -4047.0, + "z": 17.8 + }, + { + "x": -2810.4, + "y": -4008.6, + "z": 17.9 + }, + { + "x": -2814.6, + "y": -4005.2, + "z": 17.9 + }, + { + "x": -2817.9, + "y": -3965.0, + "z": 17.9 + }, + { + "x": -2818.5, + "y": -3958.8, + "z": 18.0 + }, + { + "x": -2822.8, + "y": -3924.7, + "z": 18.0 + }, + { + "x": -2821.9, + "y": -3905.2, + "z": 18.0 + }, + { + "x": -2818.8, + "y": -3896.9, + "z": 18.0 + }, + { + "x": -2814.0, + "y": -3891.6, + "z": 18.0 + }, + { + "x": -2808.5, + "y": -3885.4, + "z": 18.0 + }, + { + "x": -2803.1, + "y": -3879.7, + "z": 18.0 + }, + { + "x": -2779.9, + "y": -3850.5, + "z": 18.2 + }, + { + "x": -2780.7, + "y": -3844.4, + "z": 18.1 + }, + { + "x": -2784.8, + "y": -3809.8, + "z": 17.8 + }, + { + "x": -2787.5, + "y": -3765.4, + "z": 17.8 + }, + { + "x": -2788.9, + "y": -3757.4, + "z": 17.8 + }, + { + "x": -2788.8, + "y": -3752.1, + "z": 17.8 + }, + { + "x": -2787.6, + "y": -3743.3, + "z": 17.8 + }, + { + "x": -2781.0, + "y": -3730.6, + "z": 17.8 + }, + { + "x": -2776.8, + "y": -3726.3, + "z": 17.8 + }, + { + "x": -2755.2, + "y": -3703.6, + "z": 18.5 + }, + { + "x": -2756.1, + "y": -3697.5, + "z": 18.7 + }, + { + "x": -2754.5, + "y": -3682.6, + "z": 19.1 + }, + { + "x": -2753.9, + "y": -3669.0, + "z": 19.1 + }, + { + "x": -2751.3, + "y": -3664.1, + "z": 19.1 + }, + { + "x": -2747.0, + "y": -3658.7, + "z": 19.1 + }, + { + "x": -2722.1, + "y": -3634.8, + "z": 18.5 + }, + { + "x": -2715.9, + "y": -3606.5, + "z": 19.1 + }, + { + "x": -2712.7, + "y": -3600.5, + "z": 19.1 + }, + { + "x": -2658.7, + "y": -3548.2, + "z": 16.0 + }, + { + "x": -2640.6, + "y": -3484.5, + "z": 16.0 + }, + { + "x": -2617.8, + "y": -3428.3, + "z": 15.9 + }, + { + "x": -2612.8, + "y": -3417.3, + "z": 16.0 + }, + { + "x": -2597.1, + "y": -3387.3, + "z": 16.0 + }, + { + "x": -2583.7, + "y": -3361.9, + "z": 16.0 + }, + { + "x": -2582.5, + "y": -3356.3, + "z": 16.0 + }, + { + "x": -2581.8, + "y": -3348.5, + "z": 16.0 + }, + { + "x": -2575.0, + "y": -3332.1, + "z": 16.0 + }, + { + "x": -2570.9, + "y": -3325.5, + "z": 16.0 + }, + { + "x": -2565.9, + "y": -3319.5, + "z": 16.2 + }, + { + "x": -2561.6, + "y": -3315.5, + "z": 16.2 + }, + { + "x": -2550.8, + "y": -3306.8, + "z": 16.0 + }, + { + "x": -2544.4, + "y": -3300.4, + "z": 16.0 + }, + { + "x": -2523.2, + "y": -3280.4, + "z": 16.3 + }, + { + "x": -2518.2, + "y": -3275.8, + "z": 16.5 + }, + { + "x": -2511.8, + "y": -3268.1, + "z": 16.7 + }, + { + "x": -2502.4, + "y": -3258.9, + "z": 16.7 + }, + { + "x": -2494.4, + "y": -3253.1, + "z": 16.1 + }, + { + "x": -2488.5, + "y": -3250.3, + "z": 16.0 + }, + { + "x": -2460.8, + "y": -3241.6, + "z": 15.8 + }, + { + "x": -2435.2, + "y": -3230.7, + "z": 15.9 + }, + { + "x": -2423.3, + "y": -3225.4, + "z": 15.8 + }, + { + "x": -2416.7, + "y": -3223.8, + "z": 15.8 + }, + { + "x": -2352.6, + "y": -3229.4, + "z": 15.8 + }, + { + "x": -2344.2, + "y": -3229.1, + "z": 15.8 + }, + { + "x": -2330.2, + "y": -3226.5, + "z": 15.8 + }, + { + "x": -2273.3, + "y": -3223.3, + "z": 15.8 + }, + { + "x": -2266.9, + "y": -3222.9, + "z": 15.8 + }, + { + "x": -2258.6, + "y": -3220.5, + "z": 15.8 + }, + { + "x": -2246.4, + "y": -3217.8, + "z": 15.8 + }, + { + "x": -2193.2, + "y": -3197.5, + "z": 15.8 + }, + { + "x": -2172.3, + "y": -3196.4, + "z": 15.8 + }, + { + "x": -2167.4, + "y": -3198.1, + "z": 16.1 + }, + { + "x": -2153.6, + "y": -3197.0, + "z": 16.0 + }, + { + "x": -2147.9, + "y": -3197.5, + "z": 16.3 + }, + { + "x": -2130.4, + "y": -3201.0, + "z": 17.1 + }, + { + "x": -2123.5, + "y": -3198.2, + "z": 16.6 + }, + { + "x": -2105.5, + "y": -3195.3, + "z": 16.1 + }, + { + "x": -2020.7, + "y": -3182.4, + "z": 15.7 + }, + { + "x": -1976.5, + "y": -3176.0, + "z": 13.8 + }, + { + "x": -1967.8, + "y": -3178.3, + "z": 13.8 + }, + { + "x": -1996.4, + "y": -3182.7, + "z": 16.0 + }, + { + "x": -1991.0, + "y": -3181.7, + "z": 15.8 + }, + { + "x": -1986.6, + "y": -3184.2, + "z": 16.4 + }, + { + "x": -1971.2, + "y": -3183.0, + "z": 14.8 + }, + { + "x": -1954.6, + "y": -3183.4, + "z": 14.5 + }, + { + "x": -1948.2, + "y": -3181.4, + "z": 13.9 + }, + { + "x": -1941.4, + "y": -3180.5, + "z": 13.6 + }, + { + "x": -1933.7, + "y": -3183.2, + "z": 13.4 + }, + { + "x": -1849.8, + "y": -3198.3, + "z": 9.9 + }, + { + "x": -1819.1, + "y": -3205.3, + "z": 9.1 + }, + { + "x": -1809.1, + "y": -3207.6, + "z": 9.1 + }, + { + "x": -1801.0, + "y": -3207.4, + "z": 9.1 + }, + { + "x": -1742.3, + "y": -3223.3, + "z": 9.2 + }, + { + "x": -1721.5, + "y": -3231.8, + "z": 9.1 + }, + { + "x": -1682.0, + "y": -3252.2, + "z": 9.2 + }, + { + "x": -1604.1, + "y": -3271.6, + "z": 9.1 + }, + { + "x": -1565.0, + "y": -3272.8, + "z": 9.1 + }, + { + "x": -1553.5, + "y": -3276.9, + "z": 9.2 + }, + { + "x": -1506.2, + "y": -3311.8, + "z": 9.2 + }, + { + "x": -1501.1, + "y": -3313.4, + "z": 9.2 + }, + { + "x": -1482.4, + "y": -3320.5, + "z": 9.7 + }, + { + "x": -1495.6, + "y": -3314.8, + "z": 9.3 + }, + { + "x": -1502.2, + "y": -3311.0, + "z": 9.2 + }, + { + "x": -1490.1, + "y": -3317.3, + "z": 9.4 + }, + { + "x": -1410.6553955078125, + "y": -3325.85546875, + "z": 9.146307945251465 + }, + { + "x": -1382.806396484375, + "y": -3335.220458984375, + "z": 9.14661693572998 + }, + { + "x": -1373.014892578125, + "y": -3341.009765625, + "z": 9.14661693572998 + }, + { + "x": -1363.5474853515625, + "y": -3347.521484375, + "z": 9.14661693572998 + }, + { + "x": -1359.418212890625, + "y": -3351.11962890625, + "z": 9.14661693572998 + }, + { + "x": -1351.846435546875, + "y": -3352.712158203125, + "z": 9.14661693572998 + }, + { + "x": -1345.406494140625, + "y": -3354.956298828125, + "z": 9.14661693572998 + }, + { + "x": -1316.9586181640625, + "y": -3349.483154296875, + "z": 8.053306579589844 + }, + { + "x": -1243.0838623046875, + "y": -3344.170654296875, + "z": 9.145211219787598 + }, + { + "x": -1210.777099609375, + "y": -3343.74560546875, + "z": 9.158116340637207 + }, + { + "x": -1205.888427734375, + "y": -3345.311767578125, + "z": 9.146568298339844 + }, + { + "x": -1181.384521484375, + "y": -3347.53662109375, + "z": 9.042654037475586 + }, + { + "x": -1083.8687744140625, + "y": -3358.826416015625, + "z": 9.1463041305542 + }, + { + "x": -1038.9549560546875, + "y": -3381.460693359375, + "z": 9.7781343460083 + }, + { + "x": -980.4271240234375, + "y": -3424.406005859375, + "z": 8.865910530090332 + }, + { + "x": -969.7933959960938, + "y": -3433.80810546875, + "z": 8.865910530090332 + }, + { + "x": -963.1966552734375, + "y": -3437.874267578125, + "z": 8.865910530090332 + }, + { + "x": -956.1812133789062, + "y": -3442.52783203125, + "z": 9.134137153625488 + }, + { + "x": -951.145751953125, + "y": -3446.20166015625, + "z": 9.30504322052002 + }, + { + "x": -946.4677734375, + "y": -3451.239990234375, + "z": 9.611787796020508 + }, + { + "x": -924.3131713867188, + "y": -3490.04833984375, + "z": 8.865880966186523 + }, + { + "x": -891.8536376953125, + "y": -3552.006103515625, + "z": 11.081778526306152 + }, + { + "x": -865.6510009765625, + "y": -3602.722412109375, + "z": 12.865551948547363 + }, + { + "x": -854.7683715820312, + "y": -3623.943603515625, + "z": 11.355806350708008 + }, + { + "x": -833.0233154296875, + "y": -3667.388916015625, + "z": 9.899097442626953 + }, + { + "x": -848.1666870117188, + "y": -3689.489501953125, + "z": 10.056407928466797 + }, + { + "x": -857.388671875, + "y": -3702.166748046875, + "z": 10.082600593566895 + }, + { + "x": -861.6632080078125, + "y": -3706.965576171875, + "z": 9.899327278137207 + }, + { + "x": -865.7645874023438, + "y": -3713.707763671875, + "z": 9.899104118347168 + }, + { + "x": -868.6167602539062, + "y": -3721.51513671875, + "z": 9.899104118347168 + }, + { + "x": -870.1215209960938, + "y": -3727.424560546875, + "z": 9.899104118347168 + }, + { + "x": -870.076904296875, + "y": -3733.15869140625, + "z": 9.899104118347168 + }, + { + "x": -870.4998779296875, + "y": -3739.353271484375, + "z": 9.899104118347168 + }, + { + "x": -870.4746704101562, + "y": -3745.68896484375, + "z": 9.899104118347168 + }, + { + "x": -868.8655395507812, + "y": -3752.271728515625, + "z": 9.899104118347168 + }, + { + "x": -866.3003540039062, + "y": -3757.608642578125, + "z": 9.899104118347168 + }, + { + "x": -860.0194702148438, + "y": -3765.040283203125, + "z": 9.958391189575195 + }, + { + "x": -857.337646484375, + "y": -3769.750244140625, + "z": 9.915430068969727 + }, + { + "x": -854.3422241210938, + "y": -3773.935546875, + "z": 9.900158882141113 + }, + { + "x": -848.5653686523438, + "y": -3779.75244140625, + "z": 9.90272045135498 + }, + { + "x": -844.3300170898438, + "y": -3782.625244140625, + "z": 10.016761779785156 + }, + { + "x": -797.1155395507812, + "y": -3790.042236328125, + "z": 9.433711051940918 + }, + { + "x": -788.3897094726562, + "y": -3789.79443359375, + "z": 9.515586853027344 + }, + { + "x": -781.1129760742188, + "y": -3792.7021484375, + "z": 8.990615844726562 + } + ] +} \ No newline at end of file diff --git a/tools/bot_fleet_manager/team_status_server.py b/tools/bot_fleet_manager/team_status_server.py new file mode 100644 index 000000000..e85b552cd --- /dev/null +++ b/tools/bot_fleet_manager/team_status_server.py @@ -0,0 +1,956 @@ +#!/usr/bin/env python3 +"""Small web dashboard for textual WoWee fleet visibility.""" + +from __future__ import annotations + +import html +import json +import os +import posixpath +import time +import urllib.error +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parent +MAP_DATA_PATH = ROOT / "map_data" / "zone_map_bounds.json" +RUNTIME_ZONE_ASSET_DIR = ROOT / "runtime" / "map_assets" / "zone" +RUNTIME_CONTINENT_ASSET_DIR = ROOT / "runtime" / "map_assets" / "continent" +STALE_TEAM_GRACE_SECONDS = 20.0 +ACTIVITY_HISTORY: dict[str, dict[str, Any]] = {} +DEFAULT_ZONE_ASSET_DIRS = [ + RUNTIME_ZONE_ASSET_DIR, + Path(os.environ.get("WOWEE_MINIMANAGER_ZONE_DIR", "")) if os.environ.get("WOWEE_MINIMANAGER_ZONE_DIR") else None, + Path(r"C:\Users\admin\code\wow_server\minimanager_remote\img\zone"), +] +CONTINENT_ASSETS = { + "azeroth": {"label": "Azeroth", "asset": "azeroth.jpg", "width": 966, "height": 732}, + "outland": {"label": "Outland", "asset": "outland.jpg", "width": 966, "height": 695}, + "northrend": {"label": "Northrend", "asset": "northrend.jpg", "width": 966, "height": 732}, +} +TEAM_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} + + +def _load_zone_bounds() -> dict[str, dict[str, Any]]: + try: + with MAP_DATA_PATH.open("r", encoding="utf-8") as handle: + return json.load(handle) + except (OSError, json.JSONDecodeError): + return {} + + +ZONE_BOUNDS = _load_zone_bounds() + + +def _request_json(url: str, timeout: float = 3.0) -> dict[str, Any]: + if not url.startswith(("http://", "https://")): + raise ValueError(f"refusing non-http(s) URL: {url}") + # url is validated to http(s) above; this calls our own local fleet API, not attacker-controlled input. + with urllib.request.urlopen(url, timeout=timeout) as response: # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + return json.loads(response.read().decode("utf-8")) + + +def _try_request_json(url: str, attempts: int = 2) -> tuple[dict[str, Any] | None, str]: + last_error = "" + try: + for attempt in range(max(1, attempts)): + try: + return _request_json(url, timeout=1.8), "" + except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + last_error = str(exc) + if attempt + 1 < attempts: + time.sleep(0.08) + return None, last_error + except Exception as exc: + return None, str(exc) + + +def _safe_get(doc: dict[str, Any], *path: str, default: Any = "") -> Any: + value: Any = doc + for key in path: + if not isinstance(value, dict): + return default + value = value.get(key, default) + return value + + +def _summarize_activity( + leader_id: str, + status: dict[str, Any], + recent_chat: list[dict[str, Any]], +) -> str: + movement = status.get("movement", {}) + movement_state = str(movement.get("state") or "idle") + session_state = str(status.get("status") or "unknown") + combat = status.get("combat", {}) + health = status.get("health", {}) + is_dead = bool(health.get("isDead") or health.get("isPlayerDead")) + + last = ACTIVITY_HISTORY.setdefault(leader_id, {}) + prev_movement_state = last.get("movementState") + prev_in_combat = last.get("inCombat") + prev_dead = last.get("dead") + last.update({ + "movementState": movement_state, + "inCombat": combat.get("inCombat"), + "dead": is_dead, + "sessionState": session_state, + "updatedAt": time.monotonic(), + }) + + if is_dead: + return "dead" if not prev_dead else "dead (continuing)" + + if combat.get("inCombat"): + return "in combat" if not prev_in_combat else "in combat (continuing)" + + if movement_state == "moving": + index = movement.get("waypointIndex", 0) + count = movement.get("waypointCount", 0) + if prev_movement_state != "moving": + return f"started moving ({index}/{count})" + return f"moving ({index}/{count})" + + if movement.get("error"): + return f"{movement_state}: {movement.get('error')}" + + if prev_movement_state in ("moving", "arrived"): + return "arrived (settling)" + + if prev_in_combat and not combat.get("inCombat"): + return "exited combat" + + if prev_dead and not is_dead: + return "resurrected" + + command_queue = status.get("commandQueue", {}) + pending = int(command_queue.get("pending", 0) or 0) + if pending > 0: + return f"command pending ({pending})" + + if recent_chat: + last_msg = recent_chat[-1] + msg_type = str(last_msg.get("type", "")) + from_name = str(last_msg.get("from", "")) + if msg_type in ("SYSTEM", "WHISPER"): + return f"whisper/system: {last_msg.get('message', '')[:50]}" + + return session_state + + +def _team_state(status: dict[str, Any] | None) -> str: + if not status: + return "offline" + if status.get("inWorld"): + return "in_world" + return str(status.get("status") or "connecting") + + +def _zone_score(x: float, y: float, bounds: dict[str, Any]) -> tuple[float, float]: + min_x = min(float(bounds["top"]), float(bounds["bottom"])) + max_x = max(float(bounds["top"]), float(bounds["bottom"])) + min_y = min(float(bounds["left"]), float(bounds["right"])) + max_y = max(float(bounds["left"]), float(bounds["right"])) + area = max(1.0, (max_x - min_x) * (max_y - min_y)) + center_x = (min_x + max_x) / 2.0 + center_y = (min_y + max_y) / 2.0 + half_height = max(1.0, (max_x - min_x) / 2.0) + half_width = max(1.0, (max_y - min_y) / 2.0) + score = ((x - center_x) / half_height) ** 2 + ((y - center_y) / half_width) ** 2 + return score, area + + +def _zone_contains(map_id: int, x: float, y: float, bounds: dict[str, Any]) -> bool: + if int(bounds.get("map_id", -1)) != map_id: + return False + min_x = min(float(bounds["top"]), float(bounds["bottom"])) + max_x = max(float(bounds["top"]), float(bounds["bottom"])) + min_y = min(float(bounds["left"]), float(bounds["right"])) + max_y = max(float(bounds["left"]), float(bounds["right"])) + return min_x <= x <= max_x and min_y <= y <= max_y + + +def _zone_for_position(map_id: int, x: float, y: float) -> tuple[str, dict[str, Any]] | None: + best: tuple[str, dict[str, Any], float, float] | None = None + for zone_id, bounds in ZONE_BOUNDS.items(): + if not _zone_contains(map_id, x, y, bounds): + continue + score, area = _zone_score(x, y, bounds) + if best is None or score < best[2] or (score <= best[2] + 0.10 and area < best[3]): + best = (zone_id, bounds, score, area) + if best is None: + return None + return best[0], best[1] + + +def _map_zone_for_world(world: dict[str, Any]) -> dict[str, Any] | None: + position = world.get("position", {}) + try: + map_id = int(world.get("mapId", -1)) + x = float(position.get("x")) + y = float(position.get("y")) + except (TypeError, ValueError): + return None + + explicit_zone = world.get("zoneId") + if explicit_zone is not None: + bounds = ZONE_BOUNDS.get(str(explicit_zone)) + if bounds and int(bounds.get("map_id", -1)) == map_id: + return {"zoneId": int(explicit_zone), **bounds} + + found = _zone_for_position(map_id, x, y) + if not found: + return None + zone_id, bounds = found + return {"zoneId": int(zone_id), **bounds} + + +def _zone_asset_dirs() -> list[Path]: + dirs: list[Path] = [] + for item in DEFAULT_ZONE_ASSET_DIRS: + if item is None: + continue + try: + path = item.expanduser().resolve() + except OSError: + continue + if path.exists() and path.is_dir() and path not in dirs: + dirs.append(path) + return dirs + + +def _zone_asset_path(asset_name: str) -> Path | None: + clean_name = posixpath.basename(asset_name) + if clean_name != asset_name or not clean_name.lower().endswith(".png"): + return None + for directory in _zone_asset_dirs(): + candidate = directory / clean_name + if candidate.exists() and candidate.is_file(): + return candidate + return None + + +def _continent_asset_path(asset_name: str) -> Path | None: + clean_name = posixpath.basename(asset_name) + if clean_name != asset_name or not clean_name.lower().endswith((".jpg", ".jpeg", ".png", ".gif")): + return None + candidate = RUNTIME_CONTINENT_ASSET_DIR / clean_name + if candidate.exists() and candidate.is_file(): + return candidate + fallback = Path(r"C:\Users\admin\code\wow_server\minimanager_remote\img\map") / clean_name + if fallback.exists() and fallback.is_file(): + return fallback + return None + + +def _continent_for_world(world: dict[str, Any]) -> dict[str, Any] | None: + position = world.get("position", {}) + try: + map_id = int(world.get("mapId", -1)) + x = float(position.get("x")) + y = float(position.get("y")) + except (TypeError, ValueError): + return None + + continent_key = "azeroth" + # WoWee APIs expose canonical coordinates: x=north, y=west. + # MiniManager's continent formulas use server coordinates: x=west, y=north. + working_x = round(y) + working_y = round(x) + if map_id == 530: + if working_y < -1000 and working_y > -10000 and working_x > 5000: + working_x -= 10349 + working_y += 6357 + where_530 = 1 + elif working_y < -7000 and working_x < 0: + working_x += 3961 + working_y += 13931 + where_530 = 2 + else: + working_x -= 3070 + working_y -= 1265 + where_530 = 3 + elif map_id == 609: + working_x -= 2355 + working_y += 5662 + where_530 = 0 + else: + where_530 = 0 + + if where_530 == 3: + x_pos = round(working_x * 0.051446) + y_pos = round(working_y * 0.051446) + elif map_id == 571: + x_pos = round(working_x * 0.047055) + y_pos = round(working_y * 0.047055) + else: + x_pos = round(working_x * 0.025140) + y_pos = round(working_y * 0.025140) + + if map_id == 530 and where_530 == 1: + pixel_x = 858 - y_pos + pixel_y = 84 - x_pos + elif map_id == 530 and where_530 == 2: + pixel_x = 103 - y_pos + pixel_y = 261 - x_pos + elif map_id == 530 and where_530 == 3: + continent_key = "outland" + pixel_x = 684 - y_pos + pixel_y = 229 - x_pos + elif map_id == 571: + continent_key = "northrend" + pixel_x = 515 - y_pos + pixel_y = 644 - x_pos + elif map_id == 609: + pixel_x = 896 - y_pos + pixel_y = 232 - x_pos + elif map_id == 1: + pixel_x = 194 - y_pos + pixel_y = 398 - x_pos + elif map_id == 0: + pixel_x = 752 - y_pos + pixel_y = 291 - x_pos + else: + return None + + meta = CONTINENT_ASSETS[continent_key] + asset = str(meta["asset"]) + return { + "id": continent_key, + "label": meta["label"], + "asset": asset, + "assetUrl": f"/map-assets/continent/{urllib.parse.quote(asset)}" if _continent_asset_path(asset) else "", + "x": max(0, min(int(meta["width"]), int(pixel_x))), + "y": max(0, min(int(meta["height"]), int(pixel_y))), + "width": int(meta["width"]), + "height": int(meta["height"]), + } + + +def _cached_team(leader_id: str, error: str) -> dict[str, Any] | None: + cached = TEAM_CACHE.get(leader_id) + if cached is None: + return None + cached_at, cached_team = cached + age = time.monotonic() - cached_at + if age > STALE_TEAM_GRACE_SECONDS: + return None + team = json.loads(json.dumps(cached_team)) + team["stale"] = True + team["staleSeconds"] = round(age, 1) + team.setdefault("endpointErrors", {})["status"] = error + team["error"] = error + return team + + +def _maybe_stale_fallback(leader_id, key, team, endpoint_errors): + if key in team: + return + cached = TEAM_CACHE.get(leader_id) + if cached is not None and time.monotonic() - cached[0] <= STALE_TEAM_GRACE_SECONDS: + cached_team = cached[1] + if key in cached_team: + team[key] = cached_team[key] + + +def collect_team_status( + api_bases: list[dict[str, str]], + fleet_filter: str = "", + compact: bool = False, +) -> dict[str, Any]: + teams: list[dict[str, Any]] = [] + collected_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + for item in api_bases: + leader_id = item["id"] + item_fleet = item.get("fleet", "") + if fleet_filter and item_fleet != fleet_filter: + continue + + base = item["base"].rstrip("/") + team: dict[str, Any] = { + "id": leader_id, + "fleet": item_fleet, + "apiBase": base, + "apiReachable": False, + "state": "offline", + "activity": "unavailable", + "endpointErrors": {}, + } + + status, status_error = _try_request_json(base + "/status") + now = time.monotonic() + if status is None: + cached_team = _cached_team(leader_id, status_error) + if cached_team is not None: + teams.append(cached_team) + continue + team["error"] = status_error + teams.append(team) + continue + + team.update({ + "apiReachable": True, + "state": _team_state(status), + "status": status, + }) + + endpoint_errors: dict[str, str] = {} + world, world_error = _try_request_json(base + "/world/self") + world_ts = time.monotonic() + if world is not None: + team["world"] = world + team["worldCollectedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + continent = _continent_for_world(world) + if continent is not None: + team["continentMap"] = continent + map_zone = _map_zone_for_world(world) + if map_zone is not None: + asset = str(map_zone.get("asset", "")) + team["mapZone"] = { + "zoneId": map_zone["zoneId"], + "mapId": int(map_zone["map_id"]), + "asset": asset, + "assetUrl": f"/map-assets/zone/{urllib.parse.quote(asset)}" if _zone_asset_path(asset) else "", + "left": float(map_zone["left"]), + "right": float(map_zone["right"]), + "top": float(map_zone["top"]), + "bottom": float(map_zone["bottom"]), + "assetWidth": int(map_zone["asset_width"]), + "assetHeight": int(map_zone["asset_height"]), + } + elif world_error: + _maybe_stale_fallback(leader_id, "world", team, endpoint_errors) + _maybe_stale_fallback(leader_id, "continentMap", team, endpoint_errors) + _maybe_stale_fallback(leader_id, "mapZone", team, endpoint_errors) + endpoint_errors["world"] = world_error + team["worldCollectedAt"] = "stale" + + party, party_error = _try_request_json(base + "/party") + if party is not None: + team["party"] = party + team["partyCollectedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + elif party_error: + _maybe_stale_fallback(leader_id, "party", team, endpoint_errors) + endpoint_errors["party"] = party_error + team["partyCollectedAt"] = "stale" + + chat, chat_error = _try_request_json(base + "/chat?after=0&limit=8") + if chat is not None: + team["recentChat"] = chat.get("messages", [])[-8:] + team["chatCollectedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + elif chat_error: + _maybe_stale_fallback(leader_id, "recentChat", team, endpoint_errors) + endpoint_errors["chat"] = chat_error + team["chatCollectedAt"] = "stale" + + team["activity"] = _summarize_activity( + leader_id, status, team.get("recentChat", []) + ) + + if endpoint_errors: + team["endpointErrors"] = endpoint_errors + if any(key in endpoint_errors for key in ("world", "party", "chat")): + team["partialStale"] = True + else: + TEAM_CACHE[leader_id] = (now, json.loads(json.dumps(team))) + teams.append(team) + + result: dict[str, Any] = {"collectedAt": collected_at, "teams": teams} + + if compact: + result["teams"] = [ + { + "id": t.get("id", ""), + "fleet": t.get("fleet", ""), + "state": t.get("state", "offline"), + "apiReachable": t.get("apiReachable", False), + "activity": t.get("activity", ""), + "mapId": ( + int(t.get("world", {}).get("mapId", -1)) + if t.get("world") else -1 + ), + "position": { + "x": float(t.get("world", {}).get("position", {}).get("x", 0)), + "y": float(t.get("world", {}).get("position", {}).get("y", 0)), + "z": float(t.get("world", {}).get("position", {}).get("z", 0)), + } if t.get("world") else None, + "partyMembers": [ + m.get("name", "") for m in + (t.get("party", {}).get("members", [])) + ] if t.get("party") else None, + "collectedAt": t.get("worldCollectedAt", ""), + "stale": t.get("partialStale", False) or t.get("stale", False), + } + for t in teams + ] + + return result + + +def render_dashboard(title: str) -> bytes: + escaped_title = html.escape(title) + page = f""" + + + + + {escaped_title} + + + +
+

{escaped_title}

+
loading...
+
+
+
+
+

Leader Map

+
+
+
+ +
+
+
+ + + + + + + + + + + + +
TeamStatusLocationActivityPartyRecent Chat
+
+ + +""" + return page.encode("utf-8") + + +def run_team_status_server(api_bases: list[dict[str, str]], host: str = "127.0.0.1", port: int = 8780) -> None: + class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt: str, *args: Any) -> None: + return + + def _send(self, status: int, body: bytes, content_type: str) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + if self.path == "/" or self.path.startswith("/?"): + self._send(200, render_dashboard("WoWee Team Status"), "text/html; charset=utf-8") + return + if self.path.startswith("/api/teams"): + parsed = urllib.parse.urlparse(self.path) + params = urllib.parse.parse_qs(parsed.query) + fleet_filter = (params.get("fleet") or [""])[0] + compact = (params.get("compact") or [""])[0].lower() in ("1", "true", "yes") + body = json.dumps( + collect_team_status(api_bases, fleet_filter=fleet_filter, compact=compact) + ).encode("utf-8") + self._send(200, body, "application/json") + return + if self.path.startswith("/map-assets/zone/"): + asset_name = urllib.parse.unquote(self.path.removeprefix("/map-assets/zone/").split("?", 1)[0]) + asset_path = _zone_asset_path(asset_name) + if asset_path is not None: + self._send(200, asset_path.read_bytes(), "image/png") + return + if self.path.startswith("/map-assets/continent/"): + asset_name = urllib.parse.unquote(self.path.removeprefix("/map-assets/continent/").split("?", 1)[0]) + asset_path = _continent_asset_path(asset_name) + if asset_path is not None: + self._send(200, asset_path.read_bytes(), "image/jpeg") + return + self._send(404, b"not found", "text/plain; charset=utf-8") + + server = ThreadingHTTPServer((host, port), Handler) + print(f"Team status dashboard listening on http://{host}:{port}") + server.serve_forever() diff --git a/tools/bot_fleet_manager/travel_nodes.json b/tools/bot_fleet_manager/travel_nodes.json new file mode 100644 index 000000000..b23301c85 --- /dev/null +++ b/tools/bot_fleet_manager/travel_nodes.json @@ -0,0 +1,463 @@ +{ + "schemaVersion": 1, + "coordinateSpace": "wowee-canonical", + "notes": [ + "WoWee canonical coordinates use x=north/south and y=west/east. CMaNGOS/DBC server coordinates often appear swapped relative to this.", + "Nodes marked requiresSurvey=true are intentional placeholders for in-game observation before execution is enabled." + ], + "nodes": { + "goldshire": { + "type": "walk", + "mapId": 0, + "x": 63.52180099487305, + "y": -9480.08984375, + "z": 56.17570114135742, + "radius": 8.0, + "name": "Goldshire", + "description": "Goldshire, Elwynn Forest", + "verified": true + }, + "stormwind-city": { + "type": "walk", + "mapId": 0, + "x": 364.0570068359375, + "y": -9153.76953125, + "z": 90.48290252685547, + "radius": 10.0, + "name": "Stormwind City", + "description": "Known Stormwind waypoint used by current route demos", + "verified": true + }, + "stormwind-flight-master": { + "type": "flight_master", + "mapId": 0, + "x": 489.70001220703125, + "y": -8840.5595703125, + "z": 109.61000061035156, + "radius": 12.0, + "name": "Stormwind, Elwynn", + "description": "Taxi node from TBC TaxiNodes DBC", + "verified": false, + "source": "Data/dbfilesclient/taxinodes.json" + }, + "stormwind-dwarven-district": { + "type": "walk", + "mapId": 0, + "x": 514.031005859375, + "y": -8346.4599609375, + "z": 92.02064514160156, + "radius": 18.0, + "name": "Stormwind Dwarven District", + "description": "Dwarven District tram approach; floor-level endpoint near Stormwind Deeprun AreaTrigger 2173", + "verified": true, + "source": "Data/dbfilesclient/areatrigger.json + live tbcmangos.areatrigger_teleport" + }, + "stormwind-tram-entrance": { + "type": "tram_entrance", + "mapId": 0, + "x": 514.031005859375, + "y": -8346.4599609375, + "z": 92.02064514160156, + "radius": 10.0, + "name": "Stormwind Deeprun Tram Entrance", + "description": "AreaTrigger 2173: Stormwind to Deeprun Tram", + "verified": true, + "source": "Data/dbfilesclient/areatrigger.json + live tbcmangos.areatrigger_teleport", + "areaTrigger": { + "id": 2173, + "targetMapId": 369, + "targetX": 2490.97998046875, + "targetY": 67.76069641113281, + "targetZ": -4.29649019241333, + "targetOrientation": 3.14159 + }, + "links": [ + { + "to": "deeprun-tram-stormwind-platform", + "mode": "enter_instance", + "description": "Enter Deeprun Tram from Stormwind" + } + ] + }, + "deeprun-tram-stormwind-platform": { + "type": "tram_platform", + "mapId": 369, + "x": 2490.97998046875, + "y": 67.76069641113281, + "z": -4.29649019241333, + "radius": 12.0, + "name": "Deeprun Tram Stormwind Platform", + "description": "Deeprun arrival from Stormwind AreaTrigger 2173; exit trigger 2171 is nearby", + "trackPosition": { + "x": 2460.06, + "y": 4.58, + "z": 5.9, + "note": "The AreaTrigger 2173 landing spot (this node's own x/y/z) is ~63 yards from the actual tram dwell point on the track - live-observed during headless testing on 2026-07-11. Used both to stage before boarding and as the ride arrival target." + }, + "verified": true, + "source": "Data/dbfilesclient/areatrigger.json + live tbcmangos.areatrigger_teleport", + "areaTrigger": { + "entryId": 2173, + "exitId": 2171, + "exitSourceX": 2490.8798828125, + "exitSourceY": 80.16329956054688, + "exitSourceZ": -1.8569300174713135, + "exitTargetMapId": 0, + "exitTargetX": 524.0679931640625, + "exitTargetY": -8354.23046875, + "exitTargetZ": 91.7970962524414, + "exitTargetOrientation": 2.35619 + }, + "links": [ + { + "to": "deeprun-tram-ironforge-platform", + "mode": "ride_tram", + "description": "Board tram, wait for transit, disembark on the Ironforge side" + }, + { + "to": "stormwind-tram-exit", + "mode": "exit_instance", + "description": "Exit Deeprun Tram into Stormwind (return leg)" + } + ] + }, + "stormwind-tram-exit": { + "type": "tram_entrance", + "mapId": 0, + "x": 524.0679931640625, + "y": -8354.23046875, + "z": 91.7970962524414, + "radius": 12.0, + "name": "Stormwind Deeprun Tram Exit", + "description": "AreaTrigger 2171 target: Deeprun Tram to Stormwind (return leg landing spot, mirrors ironforge-tram-exit)", + "verified": true, + "source": "Data/dbfilesclient/areatrigger.json + live tbcmangos.areatrigger_teleport", + "areaTrigger": { + "id": 2171, + "sourceMapId": 369, + "sourceX": 2490.8798828125, + "sourceY": 80.16329956054688, + "sourceZ": -1.8569300174713135, + "targetMapId": 0, + "targetX": 524.0679931640625, + "targetY": -8354.23046875, + "targetZ": 91.7970962524414, + "targetOrientation": 2.35619 + } + }, + "deeprun-tram-ironforge-platform": { + "type": "tram_platform", + "mapId": 369, + "x": 8.11925983428955, + "y": 11.050399780273438, + "z": -4.021659851074219, + "radius": 12.0, + "name": "Deeprun Tram Ironforge Platform", + "description": "Surveyed Ironforge-side Deeprun Tram platform near the portal instance; AreaTrigger 2175 lands nearby but not on this platform", + "trackPosition": { + "x": -21.59, + "y": 4.58, + "z": 6.84, + "note": "This node's own x/y/z (from AreaTrigger survey) is ~32 yards from the actual tram dwell point on the track - live-observed during headless testing on 2026-07-11. Used both to stage before boarding and as the ride arrival target." + }, + "verified": true, + "source": "live Codexvale survey after real-client placement + Data/dbfilesclient/areatrigger.json + live tbcmangos.areatrigger_teleport", + "areaTrigger": { + "entryId": 2175, + "entryTargetX": 10.393199920654297, + "entryTargetY": 69.22769927978516, + "entryTargetZ": -4.296649932861328, + "exitId": 2166, + "exitSourceX": 10.504300117492676, + "exitSourceY": 76.0270004272461, + "exitSourceZ": -4.2965898513793945, + "exitTargetMapId": 0, + "exitTargetX": -1317.739990234375, + "exitTargetY": -4839.509765625, + "exitTargetZ": 501.8680114746094, + "exitTargetOrientation": 1.48353 + }, + "links": [ + { + "to": "ironforge-tram-exit", + "mode": "exit_instance", + "description": "Exit Deeprun Tram into Ironforge" + }, + { + "to": "deeprun-tram-stormwind-platform", + "mode": "ride_tram", + "description": "Board tram, wait for transit, disembark on the Stormwind side (return leg)" + } + ] + }, + "ironforge-tram-exit": { + "type": "tram_entrance", + "mapId": 0, + "x": -1317.739990234375, + "y": -4839.509765625, + "z": 501.8680114746094, + "radius": 12.0, + "name": "Ironforge Deeprun Tram Exit", + "description": "AreaTrigger 2166 target: Deeprun Tram to Ironforge", + "verified": true, + "source": "Data/dbfilesclient/areatrigger.json + live tbcmangos.areatrigger_teleport", + "areaTrigger": { + "id": 2166, + "sourceMapId": 369, + "sourceX": 10.504300117492676, + "sourceY": 76.0270004272461, + "sourceZ": -4.2965898513793945, + "targetMapId": 0, + "targetX": -1317.739990234375, + "targetY": -4839.509765625, + "targetZ": 501.8680114746094, + "targetOrientation": 1.48353 + } + }, + "ironforge-tram-entrance": { + "type": "tram_entrance", + "mapId": 0, + "x": -1330.4599609375, + "y": -4840.259765625, + "z": 508.1719970703125, + "radius": 10.0, + "name": "Ironforge Deeprun Tram Entrance", + "description": "AreaTrigger 2175: Ironforge to Deeprun Tram (return leg boarding approach, mirrors stormwind-tram-entrance)", + "verified": true, + "source": "Data/dbfilesclient/areatrigger.json + live tbcmangos.areatrigger_teleport", + "areaTrigger": { + "id": 2175, + "targetMapId": 369, + "targetX": 10.393199920654297, + "targetY": 69.22769927978516, + "targetZ": -4.296649932861328, + "targetOrientation": 3.14159 + }, + "links": [ + { + "to": "deeprun-tram-ironforge-platform", + "mode": "enter_instance", + "description": "Enter Deeprun Tram from Ironforge" + } + ] + }, + "ironforge-city": { + "type": "walk", + "mapId": 0, + "x": -834.0590209960938, + "y": -5021.0, + "z": 495.3190002441406, + "radius": 12.0, + "name": "Ironforge", + "description": "Ironforge city waypoint used by current route demos", + "verified": true + }, + "ironforge-flight-master": { + "type": "flight_master", + "mapId": 0, + "x": -1155.43994140625, + "y": -4821.77978515625, + "z": 502.2099914550781, + "radius": 12.0, + "name": "Ironforge, Dun Morogh", + "description": "Taxi node from TBC TaxiNodes DBC", + "verified": false, + "source": "Data/dbfilesclient/taxinodes.json" + }, + "ironforge-gate-south": { + "type": "walk", + "mapId": 0, + "x": -1004.7667236328125, + "y": -5148.51806640625, + "z": 500.18768310546875, + "radius": 15.0, + "name": "Dun Morogh, south of the Ironforge gate", + "description": "Live-surveyed waypoint. The navmesh has a narrow connectivity gap right at the Ironforge gate threshold (indoor-WMO-to-outdoor-terrain seam) - polyCount collapses to 5 and the pathfinder can't make progress from inside/right at the gate, but works fine (polyCount 124+) from here onward. Direct-walked here successfully; not a tile data gap, just a local navmesh stitching issue at the threshold.", + "verified": true, + "source": "Live survey, 2026-07-11" + }, + "thelsamar": { + "type": "walk", + "mapId": 0, + "x": -2930.010009765625, + "y": -5421.91015625, + "z": 347.25, + "radius": 20.0, + "name": "Thelsamar, Loch Modan", + "description": "Midpoint waypoint for Ironforge -> Menethil Harbor: splits a route the pathfinding service couldn't handle as one giant leg (spans Dun Morogh + Loch Modan) into two shorter, more tractable legs.", + "verified": false, + "source": "Data/dbfilesclient/taxinodes.json" + }, + "menethil-harbor": { + "type": "walk", + "mapId": 0, + "x": -783.2899780273438, + "y": -3792.260009765625, + "z": 9.0600004196167, + "radius": 20.0, + "name": "Menethil Harbor, Wetlands", + "description": "Taxi-node position near Menethil Harbor; dock path requires survey", + "verified": false, + "source": "Data/dbfilesclient/taxinodes.json" + }, + "menethil-auberdine-dock": { + "type": "boat_dock", + "name": "Menethil to Auberdine Dock", + "description": "Dock/boarding point for Transport176310: Menethil to Auberdine", + "requiresSurvey": true, + "links": [ + { + "to": "auberdine-menethil-dock", + "mode": "ride_boat", + "transportMap": "Transport176310", + "description": "Board Menethil -> Auberdine boat, wait for arrival, disembark" + } + ] + }, + "auberdine-menethil-dock": { + "type": "boat_dock", + "name": "Auberdine Menethil Dock", + "description": "Arrival dock for Transport176310: Menethil to Auberdine", + "requiresSurvey": true + }, + "auberdine": { + "type": "walk", + "mapId": 1, + "x": 557.6799926757812, + "y": 6341.3798828125, + "z": 16.290000915527344, + "radius": 20.0, + "name": "Auberdine, Darkshore", + "description": "Taxi-node position near Auberdine", + "verified": false, + "source": "Data/dbfilesclient/taxinodes.json" + }, + "auberdine-ruttheran-dock": { + "type": "boat_dock", + "name": "Auberdine to Rut'theran Dock", + "description": "Dock/boarding point for Transport176244: Rut'theran to Auberdine; direction and boarding behavior need survey", + "requiresSurvey": true, + "links": [ + { + "to": "ruttheran-dock", + "mode": "ride_boat", + "transportMap": "Transport176244", + "description": "Ride between Auberdine and Rut'theran Village" + } + ] + }, + "ruttheran-dock": { + "type": "boat_dock", + "mapId": 1, + "x": 841.0499877929688, + "y": 8643.58984375, + "z": 23.299999237060547, + "radius": 20.0, + "name": "Rut'theran Village, Teldrassil", + "description": "Taxi-node position near Rut'theran Village; exact boat/portal points need survey", + "verified": false, + "source": "Data/dbfilesclient/taxinodes.json" + }, + "ruttheran-darnassus-portal": { + "type": "portal", + "name": "Rut'theran to Darnassus Portal", + "description": "Portal into Darnassus from Rut'theran Village", + "requiresSurvey": true + }, + "darnassus": { + "type": "walk", + "name": "Darnassus", + "description": "Grand Expedition destination; city coordinate needs survey", + "requiresSurvey": true + } + }, + "routes": { + "ironforge-to-menethil": { + "name": "Ironforge -> Dun Morogh -> Loch Modan -> Wetlands -> Menethil Harbor", + "description": "Overland road leg of the Grand Expedition route. Split at Thelsamar since Ironforge -> Menethil as one giant leg stalled the pathfinding service, and routed through ironforge-gate-south to hop past a narrow navmesh connectivity gap right at the Ironforge gate threshold.", + "nodes": [ + "ironforge-city", + "ironforge-gate-south", + "thelsamar", + "menethil-harbor" + ] + }, + "deeprun-prototype": { + "name": "Deeprun Tram Prototype", + "description": "Goldshire to Ironforge via Stormwind and the Deeprun Tram. Read-only until tram nodes are surveyed.", + "nodes": [ + "goldshire", + "stormwind-city", + "stormwind-dwarven-district", + "stormwind-tram-entrance", + "deeprun-tram-stormwind-platform", + "deeprun-tram-ironforge-platform", + "ironforge-tram-exit", + "ironforge-city" + ] + }, + "grand-expedition-v0": { + "name": "Grand Expedition v0", + "description": "Goldshire -> Stormwind -> Deeprun Tram -> Ironforge -> Menethil -> Auberdine -> Rut'theran -> Darnassus.", + "nodes": [ + "goldshire", + "stormwind-city", + "stormwind-dwarven-district", + "stormwind-tram-entrance", + "deeprun-tram-stormwind-platform", + "deeprun-tram-ironforge-platform", + "ironforge-tram-exit", + "ironforge-city", + "menethil-harbor", + "menethil-auberdine-dock", + "auberdine-menethil-dock", + "auberdine", + "auberdine-ruttheran-dock", + "ruttheran-dock", + "ruttheran-darnassus-portal", + "darnassus" + ] + }, + "tram-stormwind-to-ironforge": { + "name": "Deeprun Tram: Stormwind FM -> Ironforge FM", + "description": "Round-trip test cycle leg. Stormwind Flight Master to Ironforge Flight Master via the Deeprun Tram.", + "nodes": [ + "stormwind-flight-master", + "stormwind-city", + "stormwind-dwarven-district", + "stormwind-tram-entrance", + "deeprun-tram-stormwind-platform", + "deeprun-tram-ironforge-platform", + "ironforge-tram-exit", + "ironforge-city", + "ironforge-flight-master" + ] + }, + "tram-ironforge-to-stormwind": { + "name": "Deeprun Tram: Ironforge FM -> Stormwind FM", + "description": "Round-trip test cycle leg. Ironforge Flight Master to Stormwind Flight Master via the Deeprun Tram.", + "nodes": [ + "ironforge-flight-master", + "ironforge-city", + "ironforge-tram-entrance", + "deeprun-tram-ironforge-platform", + "deeprun-tram-stormwind-platform", + "stormwind-tram-exit", + "stormwind-dwarven-district", + "stormwind-city", + "stormwind-flight-master" + ] + }, + "alliance-flight-nodes-v0": { + "name": "Alliance Flight Node Survey", + "description": "Known taxi-node positions useful for future flight-master automation.", + "nodes": [ + "stormwind-flight-master", + "ironforge-flight-master", + "menethil-harbor", + "auberdine", + "ruttheran-dock" + ] + } + } +} diff --git a/tools/headless_client/README.md b/tools/headless_client/README.md new file mode 100644 index 000000000..17ac060d2 --- /dev/null +++ b/tools/headless_client/README.md @@ -0,0 +1,190 @@ +# WoWee Headless Client + +`wowee_headless` is a minimal terminal/world client for automation. It logs into a WoW-compatible auth server, selects a realm and character, enters the world, then exposes a small localhost HTTP API for chat. + +## Settings + +Copy `settings.example.json` to `settings.json` and fill in: + +- `auth.host`, `auth.port`, `auth.account`, `auth.password` +- `client.*` for the client version/build. WotLK 3.3.5a defaults are already set. +- `client.expansion`: `classic`, `tbc`, `wotlk`, or `turtle` +- `realm.name` or `realm.index` +- `character.name` +- `bots.names` for automatic `.bot add ` commands after world entry +- `automation.onEnterWorldCommands` for arbitrary chat/GM commands after world entry +- `api.bind` and `api.port` + +Run: + +```bash +wowee_headless tools/headless_client/settings.json +``` + +While running, press `Esc` to request an in-game logout and exit after the server responds. Press `Ctrl-C` to quit the process immediately. + +Example bot startup: + +```json +{ + "bots": { + "enabled": true, + "names": ["Soulweaver", "Leatherfang"] + }, + "automation": { + "commandDelaySeconds": 0.25, + "onEnterWorldCommands": [".bot add Wildbrew"] + } +} +``` + +Both sections are optional. `bots.names` expands to `.bot add `, while `automation.onEnterWorldCommands` sends each command exactly as written. + +## Emote Text + +For friendlier `TEXT_EMOTE` messages, the headless client can load emote strings from extracted client DBC files. Provide these files in either `Data/DBFilesClient/` or `Data/db/`: + +- `EmotesText.dbc` +- `EmotesTextData.dbc` +- `Emotes.dbc` + +If `Data/manifest.json` exists from `asset_extract`, the normal asset manifest is used. If not, `wowee_headless` falls back to a DBC-only mode and still checks the loose DBC paths above. Without these files, it uses a small built-in fallback table. + +## API + +Status: + +```bash +curl http://127.0.0.1:8787/status +``` + +World self: + +```bash +curl http://127.0.0.1:8787/world/self +``` + +Party: + +```bash +curl http://127.0.0.1:8787/party +``` + +Queue a raw command: + +```bash +curl -X POST http://127.0.0.1:8787/commands \ + -H "Content-Type: application/json" \ + -d "{\"command\":\".bot follow\"}" +``` + +Move the leader toward coordinates on the current map: + +```bash +curl -X POST http://127.0.0.1:8787/movement/goto \ + -H "Content-Type: application/json" \ + -d "{\"mapId\":530,\"x\":-248.0,\"y\":951.0,\"z\":84.0,\"arrivalRadius\":3.0}" +``` + +Move through a sequence of waypoints: + +```bash +curl -X POST http://127.0.0.1:8787/movement/goto/waypoints \ + -H "Content-Type: application/json" \ + -d '{ + "mapId": 0, + "arrivalRadius": 5.0, + "waypoints": [ + {"x": -8970, "y": 610, "z": 95}, + {"x": -9050, "y": 520, "z": 92}, + {"x": -9465, "y": 62, "z": 56} + ] + }' +``` + +Learn the flight point at the nearest flight master (must be within range): + +```bash +curl -X POST http://127.0.0.1:8787/learn-flight-path \ + -H "Content-Type: application/json" \ + -d "{\"searchRadius\":15.0}" +``` + +Take a flight to a known taxi node (by id, or by a case-insensitive substring of its name - `/world/self`'s `taxi.knownNodes` lists both). Poll `/world/self`'s `position` and `taxi.onFlight` to track progress; `onFlight` flips back to `false` on landing: + +```bash +curl -X POST http://127.0.0.1:8787/taxi/activate \ + -H "Content-Type: application/json" \ + -d "{\"destName\":\"Ironforge\"}" +``` + +### Coordinate Convention + +The headless client's `/world/self` and movement APIs use WoWee canonical world coordinates: +- `x` = north/south axis +- `y` = west/east axis +- `z` = up/down axis + +CMaNGOS server/wire coordinates use the opposite X/Y order at the native pathfinding boundary: +- CMaNGOS `x` = WoWee canonical `y` +- CMaNGOS `y` = WoWee canonical `x` +- CMaNGOS `z` = WoWee canonical `z` + +The fleet pathfinding service accepts and returns WoWee canonical coordinates, so callers can post returned waypoints directly to `/movement/goto/waypoints`. + +Each waypoint can optionally override the arrival radius: + +```json +{"x": -8970, "y": 610, "z": 95, "arrivalRadius": 8.0} +``` + +Stop leader movement: + +```bash +curl -X POST http://127.0.0.1:8787/movement/stop \ + -H "Content-Type: application/json" \ + -d "{\"reason\":\"manual stop\"}" +``` + +The `GET /status` endpoint reports waypoint progress: + +```json +"movement": { + "state": "moving", + "waypointIndex": 2, + "waypointCount": 3, + ... +} +``` + +Read chat: + +```bash +curl "http://127.0.0.1:8787/chat?after=0&limit=50" +``` + +Send say: + +```bash +curl -X POST http://127.0.0.1:8787/chat \ + -H "Content-Type: application/json" \ + -d "{\"type\":\"say\",\"message\":\"hello from automation\"}" +``` + +Send whisper: + +```bash +curl -X POST http://127.0.0.1:8787/chat \ + -H "Content-Type: application/json" \ + -d "{\"type\":\"whisper\",\"target\":\"Playername\",\"message\":\"hello\"}" +``` + +Send channel: + +```bash +curl -X POST http://127.0.0.1:8787/chat \ + -H "Content-Type: application/json" \ + -d "{\"type\":\"channel\",\"target\":\"world\",\"message\":\"hello\"}" +``` + +Supported send types: `say`, `yell`, `whisper`, `channel`, `party`, `guild`, `raid`, `officer`. diff --git a/tools/headless_client/main.cpp b/tools/headless_client/main.cpp new file mode 100644 index 000000000..cf41c6342 --- /dev/null +++ b/tools/headless_client/main.cpp @@ -0,0 +1,2202 @@ +#include "auth/auth_handler.hpp" +#include "auth/auth_packets.hpp" +#include "auth/crypto.hpp" +#include "game/game_handler.hpp" +#include "game/faction_hostility.hpp" +#include "game/game_services.hpp" +#include "game/packet_parsers.hpp" +#include "game/transport_manager.hpp" +#include "game/world_packets.hpp" +#include "network/net_platform.hpp" +#include "network/packet.hpp" +#include "network/world_socket.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_layout.hpp" +#include "rendering/animation/emote_registry.hpp" +#include "core/crash_diagnostics.hpp" +#include "core/logger.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#endif + +using json = nlohmann::json; +using namespace wowee; + +namespace { + +std::atomic g_running{true}; + +constexpr int kEscKey = 27; +constexpr auto kLogoutSilentExitTimeout = std::chrono::seconds(12); + +const char* objectTypeName(game::ObjectType type) { + switch (type) { + case game::ObjectType::OBJECT: return "OBJECT"; + case game::ObjectType::ITEM: return "ITEM"; + case game::ObjectType::CONTAINER: return "CONTAINER"; + case game::ObjectType::UNIT: return "UNIT"; + case game::ObjectType::PLAYER: return "PLAYER"; + case game::ObjectType::GAMEOBJECT: return "GAMEOBJECT"; + case game::ObjectType::DYNAMICOBJECT: return "DYNAMICOBJECT"; + case game::ObjectType::CORPSE: return "CORPSE"; + } + return "UNKNOWN"; +} + +bool consumeEscKeypress() { +#ifdef _WIN32 + while (_kbhit()) { + const int ch = _getch(); + if (ch == kEscKey) return true; + if (ch == 0 || ch == 0xE0) { + if (_kbhit()) (void)_getch(); + } + } +#endif + return false; +} + +void setDefaultEnv(const char* key, const char* value) { + if (std::getenv(key)) return; +#ifdef _WIN32 + _putenv_s(key, value); +#else + setenv(key, value, 0); +#endif +} + +struct Settings { + std::string authHost = "127.0.0.1"; + uint16_t authPort = 3724; + std::string account; + std::string password; + std::string pin; + std::string totpSecret; + + auth::ClientInfo clientInfo; + uint16_t worldBuild = 0; + std::string expansion = "wotlk"; + + std::string realmName; + size_t realmIndex = 0; + uint32_t realmId = 0; + std::string characterName; + + bool autoJoinDefaultChannels = false; + bool apiEnabled = true; + std::string apiBind = "127.0.0.1"; + uint16_t apiPort = 8787; + size_t apiMaxMessages = 200; + + std::vector onEnterWorldCommands; + float onEnterCommandDelaySeconds = 0.25f; + + std::optional createCharacter; + bool exitAfterCreateCharacter = false; +}; + +struct PendingChat { + game::ChatType type = game::ChatType::SAY; + std::string message; + std::string target; +}; + +struct Waypoint { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + float arrivalRadius = 0.0f; +}; + +struct MovementTask { + bool active = false; + bool started = false; + uint32_t mapId = 0; + std::vector waypoints; + size_t currentWaypoint = 0; + float arrivalRadius = 3.0f; + std::string status = "idle"; + std::string error; + float lastSetX = 0.0f; + float lastSetY = 0.0f; + float lastSetZ = 0.0f; + bool lastSetValid = false; + + float targetX() const { + return currentWaypoint < waypoints.size() ? waypoints[currentWaypoint].x : 0.0f; + } + float targetY() const { + return currentWaypoint < waypoints.size() ? waypoints[currentWaypoint].y : 0.0f; + } + float targetZ() const { + return currentWaypoint < waypoints.size() ? waypoints[currentWaypoint].z : 0.0f; + } + float targetRadius() const { + if (currentWaypoint < waypoints.size() && waypoints[currentWaypoint].arrivalRadius > 0.0f) + return waypoints[currentWaypoint].arrivalRadius; + return arrivalRadius; + } +}; + +std::string lowerAscii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; +} + +std::string trim(std::string value) { + auto isSpace = [](unsigned char c) { return std::isspace(c) != 0; }; + value.erase(value.begin(), std::find_if(value.begin(), value.end(), [&](char c) { return !isSpace(c); })); + value.erase(std::find_if(value.rbegin(), value.rend(), [&](char c) { return !isSpace(c); }).base(), value.end()); + return value; +} + +std::string detectExpansion(uint32_t build) { + if (build <= 5875) return "classic"; + if (build <= 8606) return "tbc"; + return "wotlk"; +} + +std::string normalizeBase32(std::string value) { + std::string out; + out.reserve(value.size()); + for (unsigned char c : value) { + if (std::isspace(c) || c == '=') continue; + out.push_back(static_cast(std::toupper(c))); + } + return out; +} + +std::optional> decodeBase32(const std::string& secret) { + const std::string normalized = normalizeBase32(secret); + std::vector bytes; + int buffer = 0; + int bitsLeft = 0; + for (char c : normalized) { + int value = -1; + if (c >= 'A' && c <= 'Z') value = c - 'A'; + else if (c >= '2' && c <= '7') value = c - '2' + 26; + else return std::nullopt; + + buffer = (buffer << 5) | value; + bitsLeft += 5; + if (bitsLeft >= 8) { + bitsLeft -= 8; + bytes.push_back(static_cast((buffer >> bitsLeft) & 0xFF)); + } + } + if (bytes.empty()) return std::nullopt; + return bytes; +} + +std::optional generateTotpCode(const std::string& secret) { + auto key = decodeBase32(secret); + if (!key) return std::nullopt; + + const uint64_t counter = static_cast(std::time(nullptr) / 30); + std::vector challenge(8); + uint64_t value = counter; + for (int i = 7; i >= 0; --i) { + challenge[static_cast(i)] = static_cast(value & 0xFFu); + value >>= 8; + } + + const auto hmac = auth::Crypto::hmacSHA1(*key, challenge); + if (hmac.size() < 20) return std::nullopt; + const uint8_t offset = hmac[19] & 0x0Fu; + if (offset + 3 >= hmac.size()) return std::nullopt; + uint32_t truncated = + (static_cast(hmac[offset]) << 24) | + (static_cast(hmac[offset + 1]) << 16) | + (static_cast(hmac[offset + 2]) << 8) | + static_cast(hmac[offset + 3]); + truncated &= 0x7FFFFFFFu; + + std::ostringstream out; + out << std::setw(6) << std::setfill('0') << (truncated % 1000000u); + return out.str(); +} + +std::optional raceFromString(std::string value); +std::optional classFromString(std::string value); +std::optional genderFromString(std::string value); + +template +T jsonValue(const json& obj, const char* key, T fallback) { + if (!obj.is_object() || !obj.contains(key) || obj.at(key).is_null()) return fallback; + return obj.at(key).get(); +} + +Settings loadSettings(const std::string& path) { + std::ifstream in(path); + if (!in) { + throw std::runtime_error("Cannot open settings file: " + path); + } + + json doc = json::parse(in); + Settings s; + + const auto authObj = doc.value("auth", json::object()); + s.authHost = jsonValue(authObj, "host", s.authHost); + s.authPort = static_cast(jsonValue(authObj, "port", s.authPort)); + s.account = jsonValue(authObj, "account", s.account); + s.password = jsonValue(authObj, "password", s.password); + s.pin = jsonValue(authObj, "pin", s.pin); + s.totpSecret = jsonValue(authObj, "totpSecret", s.totpSecret); + + const auto clientObj = doc.value("client", json::object()); + s.clientInfo.majorVersion = static_cast(jsonValue(clientObj, "major", 3)); + s.clientInfo.minorVersion = static_cast(jsonValue(clientObj, "minor", 3)); + s.clientInfo.patchVersion = static_cast(jsonValue(clientObj, "patch", 5)); + s.clientInfo.build = static_cast(jsonValue(clientObj, "build", 12340)); + s.worldBuild = static_cast(jsonValue(clientObj, "worldBuild", s.clientInfo.build)); + s.clientInfo.protocolVersion = static_cast(jsonValue(clientObj, "protocol", 8)); + s.clientInfo.locale = jsonValue(clientObj, "locale", "enUS"); + s.clientInfo.platform = jsonValue(clientObj, "platform", "x86"); + s.clientInfo.os = jsonValue(clientObj, "os", "Win"); + s.expansion = jsonValue(clientObj, "expansion", detectExpansion(s.clientInfo.build)); + s.clientInfo.legacyVanillaRealmList = (s.expansion == "classic" || s.expansion == "turtle"); + + const auto realmObj = doc.value("realm", json::object()); + s.realmName = jsonValue(realmObj, "name", ""); + s.realmIndex = static_cast(std::max(0, jsonValue(realmObj, "index", 0))); + s.realmId = static_cast(std::max(0, jsonValue(realmObj, "id", 0))); + + const auto characterObj = doc.value("character", json::object()); + s.characterName = jsonValue(characterObj, "name", ""); + + const auto chatObj = doc.value("chat", json::object()); + s.autoJoinDefaultChannels = jsonValue(chatObj, "autoJoinDefaultChannels", false); + + const auto apiObj = doc.value("api", json::object()); + s.apiEnabled = jsonValue(apiObj, "enabled", true); + s.apiBind = jsonValue(apiObj, "bind", "127.0.0.1"); + s.apiPort = static_cast(jsonValue(apiObj, "port", 8787)); + s.apiMaxMessages = static_cast(std::max(1, jsonValue(apiObj, "maxMessages", 200))); + + if (s.account.empty()) throw std::runtime_error("settings auth.account is required"); + if (s.password.empty()) throw std::runtime_error("settings auth.password is required"); + + const auto botsObj = doc.value("bots", json::object()); + if (botsObj.is_object() && botsObj.value("enabled", false)) { + const auto names = botsObj.value("names", json::array()); + if (!names.is_array()) { + throw std::runtime_error("settings bots.names must be an array"); + } + for (const auto& nameValue : names) { + if (!nameValue.is_string()) continue; + const std::string name = trim(nameValue.get()); + if (!name.empty()) { + s.onEnterWorldCommands.push_back(".bot add " + name); + } + } + } + + const auto automationObj = doc.value("automation", json::object()); + if (automationObj.is_object()) { + s.onEnterCommandDelaySeconds = std::max(0.0f, + jsonValue(automationObj, "commandDelaySeconds", s.onEnterCommandDelaySeconds)); + const auto commands = automationObj.value("onEnterWorldCommands", json::array()); + if (!commands.is_array()) { + throw std::runtime_error("settings automation.onEnterWorldCommands must be an array"); + } + for (const auto& commandValue : commands) { + if (!commandValue.is_string()) continue; + const std::string command = trim(commandValue.get()); + if (!command.empty()) { + s.onEnterWorldCommands.push_back(command); + } + } + } + + const auto provisionObj = doc.value("provision", json::object()); + if (provisionObj.is_object()) { + const auto createObj = provisionObj.value("createCharacter", json::object()); + if (createObj.is_object() && jsonValue(createObj, "enabled", false)) { + game::CharCreateData data; + data.name = trim(jsonValue(createObj, "name", "")); + const auto race = raceFromString(jsonValue(createObj, "race", "")); + const auto cls = classFromString(jsonValue(createObj, "class", "")); + const auto gender = genderFromString(jsonValue(createObj, "gender", "male")); + if (data.name.empty()) throw std::runtime_error("provision.createCharacter.name is required"); + if (!race) throw std::runtime_error("provision.createCharacter.race is invalid"); + if (!cls) throw std::runtime_error("provision.createCharacter.class is invalid"); + if (!gender) throw std::runtime_error("provision.createCharacter.gender is invalid"); + data.race = *race; + data.characterClass = *cls; + data.gender = *gender; + data.skin = static_cast(std::clamp(jsonValue(createObj, "skin", 0), 0, 255)); + data.face = static_cast(std::clamp(jsonValue(createObj, "face", 0), 0, 255)); + data.hairStyle = static_cast(std::clamp(jsonValue(createObj, "hairStyle", 0), 0, 255)); + data.hairColor = static_cast(std::clamp(jsonValue(createObj, "hairColor", 0), 0, 255)); + data.facialHair = static_cast(std::clamp(jsonValue(createObj, "facialHair", 0), 0, 255)); + s.createCharacter = data; + s.exitAfterCreateCharacter = jsonValue(createObj, "exitAfterCreate", true); + } + } + + return s; +} + +bool splitHostPort(const std::string& address, std::string& host, uint16_t& port) { + const size_t pos = address.rfind(':'); + if (pos == std::string::npos) return false; + host = address.substr(0, pos); + port = static_cast(std::stoi(address.substr(pos + 1))); + return !host.empty() && port != 0; +} + +std::optional chatTypeFromString(const std::string& value) { + const std::string t = lowerAscii(value); + if (t == "say") return game::ChatType::SAY; + if (t == "yell") return game::ChatType::YELL; + if (t == "whisper" || t == "w") return game::ChatType::WHISPER; + if (t == "channel") return game::ChatType::CHANNEL; + if (t == "party") return game::ChatType::PARTY; + if (t == "guild") return game::ChatType::GUILD; + if (t == "raid") return game::ChatType::RAID; + if (t == "officer") return game::ChatType::OFFICER; + return std::nullopt; +} + +std::string chatTypeName(game::ChatType type) { + return game::getChatTypeString(type); +} + +std::optional raceFromString(std::string value) { + value = lowerAscii(trim(std::move(value))); + if (value == "human" || value == "1") return game::Race::HUMAN; + if (value == "orc" || value == "2") return game::Race::ORC; + if (value == "dwarf" || value == "3") return game::Race::DWARF; + if (value == "night_elf" || value == "nightelf" || value == "night elf" || value == "4") return game::Race::NIGHT_ELF; + if (value == "undead" || value == "5") return game::Race::UNDEAD; + if (value == "tauren" || value == "6") return game::Race::TAUREN; + if (value == "gnome" || value == "7") return game::Race::GNOME; + if (value == "troll" || value == "8") return game::Race::TROLL; + if (value == "blood_elf" || value == "bloodelf" || value == "blood elf" || value == "10") return game::Race::BLOOD_ELF; + if (value == "draenei" || value == "11") return game::Race::DRAENEI; + return std::nullopt; +} + +std::optional classFromString(std::string value) { + value = lowerAscii(trim(std::move(value))); + if (value == "warrior" || value == "1") return game::Class::WARRIOR; + if (value == "paladin" || value == "2") return game::Class::PALADIN; + if (value == "hunter" || value == "3") return game::Class::HUNTER; + if (value == "rogue" || value == "4") return game::Class::ROGUE; + if (value == "priest" || value == "5") return game::Class::PRIEST; + if (value == "shaman" || value == "7") return game::Class::SHAMAN; + if (value == "mage" || value == "8") return game::Class::MAGE; + if (value == "warlock" || value == "9") return game::Class::WARLOCK; + if (value == "druid" || value == "11") return game::Class::DRUID; + return std::nullopt; +} + +std::optional genderFromString(std::string value) { + value = lowerAscii(trim(std::move(value))); + if (value == "male" || value == "m" || value == "0") return game::Gender::MALE; + if (value == "female" || value == "f" || value == "1") return game::Gender::FEMALE; + return std::nullopt; +} + +std::string movementTaskStateName(const MovementTask& task) { + if (task.active) return "moving"; + return task.status.empty() ? "idle" : task.status; +} + +std::string timeToIso(std::chrono::system_clock::time_point tp) { + auto time = std::chrono::system_clock::to_time_t(tp); + std::tm tm{}; +#ifdef _WIN32 + gmtime_s(&tm, &time); +#else + gmtime_r(&time, &tm); +#endif + char buf[32]{}; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm); + return buf; +} + +json chatToJson(const game::MessageChatData& msg, size_t id) { + return { + {"id", id}, + {"type", chatTypeName(msg.type)}, + {"from", msg.senderName}, + {"fromGuid", msg.senderGuid}, + {"to", msg.receiverName}, + {"toGuid", msg.receiverGuid}, + {"channel", msg.channelName}, + {"message", msg.message}, + {"timestamp", timeToIso(msg.timestamp)} + }; +} + +class HeadlessSession { +public: + explicit HeadlessSession(Settings settings) + : settings_(std::move(settings)), game_(services_) { + configureProtocol(); + } + + bool start() { + auth_.setClientInfo(settings_.clientInfo); + auth_.setOnSuccess([this](const std::vector&) { + std::cout << "Auth succeeded; requesting realms\n"; + auth_.requestRealmList(); + }); + auth_.setOnFailure([this](const std::string& reason) { + fail("Auth failed: " + reason); + }); + auth_.setOnRealmList([this](const std::vector& realms) { + onRealmList(realms); + }); + + game_.setOnFailure([this](const std::string& reason) { + fail("World failed: " + reason); + }); + game_.setCharCreateCallback([this](bool success, const std::string& message) { + std::cout << "Character create result: " << message << "\n"; + if (success) { + charCreateDone_ = true; + if (settings_.exitAfterCreateCharacter) { + g_running = false; + } + } else { + fail("Character create failed: " + message); + } + }); + + // Headless has no renderer, so it never learns whether a transport's model + // resolved to a WMO or M2 asset (see EntitySpawner::spawnOnlineGameObject). + // Known M2 transports (Deeprun Tram cars, Thunder Bluff lifts) are hardcoded + // here since that's the whole population this harness needs to track; anything + // else defaults to isM2=false (WMO), matching ships/zeppelins. + auto registerOrUpdateTransport = [this](uint64_t guid, uint32_t entry, uint32_t displayId, + float x, float y, float z, float orientation) { + auto* tm = game_.getTransportManager(); + if (!tm) return; + const bool isM2 = + displayId == 3831u || + (entry >= 176080u && entry <= 176085u) || + (entry >= 20649u && entry <= 20657u); + const glm::vec3 canonicalPos(x, y, z); + if (!tm->getTransport(guid)) { + tm->resolveAndRegisterSpawn(guid, entry, displayId, canonicalPos, + /*wmoInstanceId=*/0, isM2, + game_.hasServerTransportUpdate(guid)); + } + tm->updateServerTransport(guid, canonicalPos, orientation); + }; + game_.setGameObjectSpawnCallback([this, registerOrUpdateTransport]( + uint64_t guid, uint32_t entry, uint32_t displayId, + float x, float y, float z, float orientation, float /*scale*/) { + if (!game_.isTransportGuid(guid)) return; + registerOrUpdateTransport(guid, entry, displayId, x, y, z, orientation); + }); + game_.setTransportMoveCallback([this, registerOrUpdateTransport]( + uint64_t guid, float x, float y, float z, float orientation) { + uint32_t entry = 0, displayId = 0; + auto entity = game_.getEntityManager().getEntity(guid); + if (entity && entity->getType() == game::ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + entry = go->getEntry(); + displayId = go->getDisplayId(); + } + registerOrUpdateTransport(guid, entry, displayId, x, y, z, orientation); + }); + + if (!auth_.connect(settings_.authHost, settings_.authPort)) { + fail("Could not connect to auth server"); + return false; + } + std::string authPin = settings_.pin; + if (authPin.empty() && !settings_.totpSecret.empty()) { + auto generated = generateTotpCode(settings_.totpSecret); + if (generated) { + authPin = *generated; + } else { + fail("Invalid auth.totpSecret; cannot generate PIN"); + return false; + } + } + auth_.authenticate(settings_.account, settings_.password, authPin); + return true; + } + + void update(float deltaSeconds) { + wowee::core::setCrashNote("headless update: auth"); + auth_.update(deltaSeconds); + wowee::core::setCrashNote("headless update: game"); + const auto beforeWorldState = game_.getState(); + game_.update(deltaSeconds); + wowee::core::setCrashNote("headless update: state transition"); + const auto afterWorldState = game_.getState(); + if (afterWorldState != beforeWorldState) { + std::cout << "World state changed: " + << static_cast(beforeWorldState) << " -> " + << static_cast(afterWorldState) << "\n"; + } + inWorldForApi_ = (game_.getState() == game::WorldState::IN_WORLD); + + if (!selectedCharacter_ && game_.getState() == game::WorldState::CHAR_LIST_RECEIVED) { + wowee::core::setCrashNote("headless update: character selection"); + if (settings_.createCharacter) { + provisionConfiguredCharacter(); + return; + } + selectConfiguredCharacter(); + } + + if (!enteredWorld_ && game_.getState() == game::WorldState::IN_WORLD) { + wowee::core::setCrashNote("headless update: enter world"); + enteredWorld_ = true; + inWorldForApi_ = true; + std::cout << "Entered world"; + if (!settings_.characterName.empty()) std::cout << " as " << settings_.characterName; + std::cout << "\n"; + if (settings_.autoJoinDefaultChannels) { + game_.autoJoinDefaultChannels(); + } + // The GUI client builds this via WorldLoader (world_loader.cpp) as + // part of its render-setup sequence; the headless client has no + // equivalent call anywhere, so isHostileFaction() was permanently + // falling through to its "unknown -> hostile" default for every + // faction template - live-observed as ~20 false-positive threats + // at Menethil Harbor (town guards, our own party bots, a critter). + if (const auto* activeChar = game_.getActiveCharacter()) { + game::buildFactionHostilityMap(assetManager_, game_, static_cast(activeChar->race)); + } + queueOnEnterWorldCommands(); + } + + if (!logoutRequested_) { + wowee::core::setCrashNote("headless update: scheduled commands"); + drainScheduledCommands(deltaSeconds); + wowee::core::setCrashNote("headless update: pending chat"); + drainPendingChat(); + wowee::core::setCrashNote("headless update: movement"); + updateMovementTask(deltaSeconds); + wowee::core::setCrashNote("headless update: transport boarding"); + if (inWorldForApi_) { + // While riding with no active goto, movementInfo doesn't move on its own here + // (unlike the GUI client's per-frame render loop, which relocks the player to + // the deck each frame) - keep it following the transport so /world/self and + // the disembark distance check both see the real, moving position instead of + // wherever the player happened to be standing at the moment they boarded. If a + // goto is active (e.g. walking off at the destination), let + // updateMovementTask() drive movementInfo instead so that walk can actually + // carry the player off the deck rather than being overwritten back onto it. + glm::vec3 playerCanonical; + if (game_.isOnTransport() && !movementTask_.active) { + playerCanonical = game_.getComposedWorldPosition(); + game_.setPosition(playerCanonical.x, playerCanonical.y, playerCanonical.z); + } else { + const auto& move = game_.getMovementInfo(); + playerCanonical = glm::vec3(move.x, move.y, move.z); + } + game_.updateM2TransportBoarding(playerCanonical); + } + } + wowee::core::setCrashNote("headless update: chat snapshot"); + syncChatSnapshot(); + wowee::core::setCrashNote("headless update: logout"); + updateLogoutExit(); + wowee::core::setCrashNote("headless update: idle"); + } + + bool isFailed() const { return failed_.load(); } + bool isInWorld() const { return inWorldForApi_.load(); } + bool isLogoutRequested() const { return logoutRequested_.load(); } + json statusJson() const { + std::lock_guard lock(stateMutex_); + bool inCombat = false, isDead = false, isPlayerDead = false; + uint32_t hp = 0, maxHp = 0; + if (isInWorld()) { + auto playerEntity = game_.getEntityManager().getEntity(game_.getPlayerGuid()); + if (playerEntity) { + if (auto* unit = dynamic_cast(playerEntity.get())) { + hp = unit->getHealth(); + maxHp = unit->getMaxHealth(); + } + } + inCombat = game_.isInCombat(); + isDead = game_.isDead(); + isPlayerDead = game_.isPlayerDead(); + } + return { + {"status", statusUnlocked()}, + {"inWorld", isInWorld()}, + {"movement", movementTaskToJsonLocked()}, + {"combat", {{"inCombat", inCombat}}}, + {"health", {{"current", hp}, {"max", maxHp}, {"isDead", isDead}, {"isPlayerDead", isPlayerDead}}} + }; + } + std::string status() const { + std::lock_guard lock(stateMutex_); + return statusUnlocked(); + } + + json worldSelf() const { + const auto& move = game_.getMovementInfo(); + json character = json::object(); + if (const auto* ch = game_.getActiveCharacter()) { + character = { + {"name", ch->name}, + {"guid", ch->guid}, + {"race", static_cast(ch->race)}, + {"class", static_cast(ch->characterClass)}, + {"level", ch->level} + }; + } + +uint32_t hp = 0, maxHp = 0; + { + auto playerEntity = game_.getEntityManager().getEntity(game_.getPlayerGuid()); + if (playerEntity) { + if (auto* unit = dynamic_cast(playerEntity.get())) { + hp = unit->getHealth(); + maxHp = unit->getMaxHealth(); + } + } + } + + std::lock_guard lock(stateMutex_); + return { + {"status", statusUnlocked()}, + {"inWorld", isInWorld()}, + {"character", character}, + {"playerGuid", game_.getPlayerGuid()}, + {"mapId", game_.getCurrentMapId()}, + {"position", {{"x", move.x}, {"y", move.y}, {"z", move.z}}}, + {"orientation", move.orientation}, + {"movementFlags", move.flags}, + {"movementFlags2", move.flags2}, + {"runSpeed", game_.getServerRunSpeed()}, + {"moneyCopper", game_.getMoneyCopper()}, + {"transport", { + {"onTransport", game_.isOnTransport()}, + {"guid", game_.getPlayerTransportGuid()}, + {"offset", { + {"x", game_.getPlayerTransportOffset().x}, + {"y", game_.getPlayerTransportOffset().y}, + {"z", game_.getPlayerTransportOffset().z} + }} + }}, + {"movement", movementTaskToJsonLocked()}, + {"combat", {{"inCombat", game_.isInCombat()}}}, + {"health", {{"current", hp}, {"max", maxHp}, {"isDead", game_.isDead()}, {"isPlayerDead", game_.isPlayerDead()}}}, + {"death", deathJsonLocked()}, + {"taxi", taxiJson()} + }; + } + + // Surfaces enough taxi state to confirm a learn-flight-path call actually + // stuck server-side (SMSG_SHOWTAXINODES is fire-and-forget from the + // client's point of view, and the discovery chat message only fires on + // *repeat* visits - see handleShowTaxiNodes's taxiMaskInitialized_ guard + // - so on a character's very first flight master this is the only signal). + json taxiJson() const { + const uint32_t nearest = game_.getTaxiCurrentNode(); + json nearestNodeName = nullptr; + const auto& nodes = game_.getTaxiNodes(); + auto it = nodes.find(nearest); + if (it != nodes.end()) nearestNodeName = it->second.name; + // Only meaningful once at least one SMSG_SHOWTAXINODES has been + // received (loadTaxiDbc() is lazy, triggered from that handler) - + // empty before the first flight master visit this session/process. + json known = json::array(); + for (const auto& [id, node] : nodes) { + if (game_.isKnownTaxiNode(id)) { + known.push_back({{"id", id}, {"name", node.name}}); + } + } + return { + {"windowOpen", game_.isTaxiWindowOpen()}, + {"nearestNode", nearest}, + {"nearestNodeName", nearestNodeName}, + {"nearestNodeKnown", nearest != 0 && game_.isKnownTaxiNode(nearest)}, + {"knownNodes", known}, + {"onFlight", game_.isOnTaxiFlight()} + }; + } + + // Full DBC-loaded taxi node list (id/name/map/position for every node, + // not just ones this character knows) - only populated once + // loadTaxiDbc() has fired via at least one SMSG_SHOWTAXINODES response + // this session (see taxiJson()'s comment). Useful for resolving a named + // flight point's exact coordinates (e.g. for a GM teleport) without + // hardcoding them. + json taxiNodesListAction() const { + json list = json::array(); + for (const auto& [id, node] : game_.getTaxiNodes()) { + list.push_back({ + {"id", id}, + {"name", node.name}, + {"mapId", node.mapId}, + {"x", node.x}, + {"y", node.y}, + {"z", node.z}, + {"known", game_.isKnownTaxiNode(id)} + }); + } + return {{"ok", true}, {"nodes", list}}; + } + + // Full DBC-loaded TaxiPath.dbc edge list (pathId/fromNode/toNode/cost) - + // same lazy-load timing as taxiNodesListAction(). Lets you check whether + // a route between two known node IDs actually exists in the data before + // assuming a silent flight-master query failure is a bug rather than a + // genuinely disconnected/unreachable node (real WoW clients just show an + // error in that case rather than opening the flight map at all). + json taxiEdgesListAction() const { + json list = json::array(); + for (const auto& edge : game_.getTaxiPathEdges()) { + list.push_back({ + {"pathId", edge.pathId}, + {"fromNode", edge.fromNode}, + {"toNode", edge.toNode}, + {"cost", edge.cost} + }); + } + return {{"ok", true}, {"edges", list}}; + } + + // Requires stateMutex_ already held (called from worldSelf() while locked). + json deathJsonLocked() const { + float corpseX = 0.0f, corpseY = 0.0f; + const bool hasCorpse = game_.getCorpseCanonicalPos(corpseX, corpseY); + json corpse = nullptr; + if (hasCorpse) { + corpse = { + {"mapId", game_.getCorpseMapId()}, + {"x", corpseX}, + {"y", corpseY}, + {"z", game_.getCorpseZ()}, + {"distance", game_.getCorpseDistance()} + }; + } + return { + {"isPlayerDead", game_.isPlayerDead()}, + {"isGhost", game_.isPlayerGhost()}, + {"canReclaimCorpse", game_.canReclaimCorpse()}, + {"reclaimDelaySec", game_.getCorpseReclaimDelaySec()}, + {"corpse", corpse} + }; + } + + json worldEntities(float radius = 120.0f, bool onlyTransports = false) const { + const auto& move = game_.getMovementInfo(); + json entities = json::array(); + size_t total = 0; + size_t included = 0; + + // entity->getX/Y/Z() below is the raw GameObject spawn/presence-echo field - + // for transports it never moves, since CMaNGOS only sends a static echo and all + // actual motion is simulated client-side by TransportManager. Snapshot the live + // simulated state once (thread-safe, mutex-guarded copy) so transport entities + // can additionally report where they actually are, without disturbing the + // existing GameObject-sourced fields other callers may depend on. + std::unordered_map liveTransports; + if (const auto* tm = game_.getTransportManager()) { + for (auto& t : tm->snapshotTransports()) { + const uint64_t guid = t.guid; + liveTransports.emplace(guid, std::move(t)); + } + } + + for (const auto& entity : game_.getEntityManager().snapshotEntities()) { + if (!entity) continue; + const uint64_t guid = entity->getGuid(); + ++total; + + const bool isTransport = game_.isTransportGuid(guid); + if (onlyTransports && !isTransport) continue; + + // Transports report a static presence-echo position (see the class + // comment above) - the live simulated position, when available, is + // what callers actually need for proximity/boarding checks. Using + // the stale static field here made every distance/radius decision + // for a moving tram car wrong regardless of where it actually was. + const auto liveIt = isTransport ? liveTransports.find(guid) : liveTransports.end(); + const bool hasLive = liveIt != liveTransports.end(); + const float refX = hasLive ? liveIt->second.position.x : entity->getX(); + const float refY = hasLive ? liveIt->second.position.y : entity->getY(); + const float refZ = hasLive ? liveIt->second.position.z : entity->getZ(); + const float dx = refX - move.x; + const float dy = refY - move.y; + const float dz = refZ - move.z; + const float distance = std::sqrt(dx * dx + dy * dy + dz * dz); + if (radius >= 0.0f && distance > radius) continue; + + json item = { + {"guid", guid}, + {"type", objectTypeName(entity->getType())}, + {"isTransport", isTransport}, + {"hasServerTransportUpdate", isTransport ? game_.hasServerTransportUpdate(guid) : false}, + {"position", {{"x", entity->getX()}, {"y", entity->getY()}, {"z", entity->getZ()}}}, + {"orientation", entity->getOrientation()}, + {"distance", distance} + }; + + if (isTransport && hasLive) { + const auto& t = liveIt->second; + item["livePosition"] = {{"x", t.position.x}, {"y", t.position.y}, {"z", t.position.z}}; + item["localPathTimeMs"] = t.localClockMs; + item["playerOnBoard"] = t.playerOnBoard; + } + + if (entity->isUnit()) { + const auto* unit = static_cast(entity.get()); + item["name"] = unit->getName(); + item["entry"] = unit->getEntry(); + item["displayId"] = unit->getDisplayId(); + item["level"] = unit->getLevel(); + item["hostile"] = unit->isHostile(); + item["factionTemplate"] = unit->getFactionTemplate(); + item["npcFlags"] = unit->getNpcFlags(); + } else if (entity->getType() == game::ObjectType::GAMEOBJECT) { + const auto* go = static_cast(entity.get()); + item["name"] = go->getName(); + item["entry"] = go->getEntry(); + item["displayId"] = go->getDisplayId(); + if (const auto* info = game_.getCachedGameObjectInfo(go->getEntry())) { + item["template"] = { + {"entry", info->entry}, + {"name", info->name}, + {"type", info->type}, + {"displayId", info->displayId} + }; + } + } + + entities.push_back(std::move(item)); + ++included; + } + + return { + {"status", status()}, + {"inWorld", isInWorld()}, + {"mapId", game_.getCurrentMapId()}, + {"position", {{"x", move.x}, {"y", move.y}, {"z", move.z}}}, + {"radius", radius}, + {"onlyTransports", onlyTransports}, + {"entityCount", total}, + {"included", included}, + {"selfGuid", game_.getPlayerGuid()}, + {"entities", entities} + }; + } + + json partyJson() const { + const auto& party = game_.getPartyData(); + json members = json::array(); + for (const auto& member : party.members) { + members.push_back({ + {"name", member.name}, + {"guid", member.guid}, + {"isOnline", member.isOnline != 0}, + {"subGroup", member.subGroup}, + {"flags", member.flags}, + {"roles", member.roles}, + {"level", member.level}, + {"zoneId", member.zoneId}, + {"hasPartyStats", member.hasPartyStats} + }); + } + return { + {"status", status()}, + {"inGroup", !party.isEmpty()}, + {"groupType", party.groupType}, + {"memberCount", party.memberCount}, + {"leaderGuid", party.leaderGuid}, + {"members", members} + }; + } + + json queueCommand(const std::string& command) { + const std::string trimmed = trim(command); + if (trimmed.empty()) { + return {{"ok", false}, {"error", "command is required"}}; + } + PendingChat msg; + msg.type = game::ChatType::SAY; + msg.message = trimmed; + enqueueChat(std::move(msg)); + return {{"ok", true}}; + } + + json startGoto(uint32_t mapId, float x, float y, float z, float arrivalRadius) { + std::lock_guard lock(stateMutex_); + if (!isInWorld()) { + return {{"ok", false}, {"error", "not in world"}}; + } + if (mapId != 0 && mapId != game_.getCurrentMapId()) { + return {{"ok", false}, {"error", "map mismatch"}, {"currentMapId", game_.getCurrentMapId()}}; + } + if (!std::isfinite(x) || !std::isfinite(y) || !std::isfinite(z)) { + return {{"ok", false}, {"error", "coordinates must be finite"}}; + } + if (!std::isfinite(arrivalRadius) || arrivalRadius <= 0.0f) { + arrivalRadius = 3.0f; + } + if (game_.isPlayerRooted()) { + return {{"ok", false}, {"error", "movement_locked: player is rooted"}}; + } + if (!game_.isServerMovementAllowed()) { + return {{"ok", false}, {"error", "movement_locked: server movement not allowed"}}; + } + if (game_.isOnTaxiFlight()) { + return {{"ok", false}, {"error", "movement_locked: on taxi flight"}}; + } + + Waypoint wp; + wp.x = x; + wp.y = y; + wp.z = z; + wp.arrivalRadius = std::max(0.5f, arrivalRadius); + + movementTask_ = MovementTask{}; + movementTask_.active = true; + movementTask_.mapId = mapId == 0 ? game_.getCurrentMapId() : mapId; + movementTask_.arrivalRadius = std::max(0.5f, arrivalRadius); + movementTask_.waypoints.push_back(wp); + movementTask_.status = "moving"; + return {{"ok", true}, {"movement", movementTaskToJsonLocked()}}; + } + + json startGotoWaypoints(uint32_t mapId, const std::vector& waypoints, float defaultRadius) { + std::lock_guard lock(stateMutex_); + if (!isInWorld()) { + return {{"ok", false}, {"error", "not in world"}}; + } + if (waypoints.empty()) { + return {{"ok", false}, {"error", "waypoints list is empty"}}; + } + if (!std::isfinite(defaultRadius) || defaultRadius <= 0.0f) { + defaultRadius = 3.0f; + } + if (game_.isPlayerRooted()) { + return {{"ok", false}, {"error", "movement_locked: player is rooted"}}; + } + if (!game_.isServerMovementAllowed()) { + return {{"ok", false}, {"error", "movement_locked: server movement not allowed"}}; + } + if (game_.isOnTaxiFlight()) { + return {{"ok", false}, {"error", "movement_locked: on taxi flight"}}; + } + + movementTask_ = MovementTask{}; + movementTask_.active = true; + movementTask_.mapId = mapId == 0 ? game_.getCurrentMapId() : mapId; + movementTask_.arrivalRadius = std::max(0.5f, defaultRadius); + movementTask_.waypoints = waypoints; + movementTask_.status = "moving"; + return {{"ok", true}, {"movement", movementTaskToJsonLocked()}}; + } + + json stopMovementTask(const std::string& reason = "stopped") { + std::lock_guard lock(stateMutex_); + stopMovementTaskLocked(reason, ""); + return {{"ok", true}, {"movement", movementTaskToJsonLocked()}}; + } + + json fireAreaTrigger(uint32_t triggerId) { + if (!isInWorld()) { + return {{"ok", false}, {"error", "not in world"}}; + } + if (triggerId == 0) { + return {{"ok", false}, {"error", "trigger id is required"}}; + } + auto* socket = game_.getSocket(); + if (!socket || !socket->isConnected()) { + return {{"ok", false}, {"error", "world socket is not connected"}}; + } + + network::Packet pkt(game::wireOpcode(game::Opcode::CMSG_AREATRIGGER)); + pkt.writeUInt32(triggerId); + socket->send(pkt); + std::cout << "Sent CMSG_AREATRIGGER id=" << triggerId << "\n"; + return { + {"ok", true}, + {"triggerId", triggerId}, + {"mapId", game_.getCurrentMapId()}, + {"position", {{"x", game_.getMovementInfo().x}, {"y", game_.getMovementInfo().y}, {"z", game_.getMovementInfo().z}}} + }; + } + + json releaseSpiritAction() { + if (!isInWorld()) { + return {{"ok", false}, {"error", "not in world"}}; + } + if (!game_.isPlayerDead()) { + return {{"ok", false}, {"error", "not dead"}}; + } + if (game_.isPlayerGhost()) { + return {{"ok", true}, {"alreadyGhost", true}}; + } + game_.releaseSpirit(); + return {{"ok", true}}; + } + + json reclaimCorpseAction() { + if (!isInWorld()) { + return {{"ok", false}, {"error", "not in world"}}; + } + if (!game_.isPlayerGhost()) { + return {{"ok", false}, {"error", "not a ghost"}}; + } + if (!game_.canReclaimCorpse()) { + const float delay = game_.getCorpseReclaimDelaySec(); + return { + {"ok", false}, + {"error", delay > 0.0f ? "reclaim delay not yet elapsed" : "not close enough to corpse"}, + {"reclaimDelaySec", delay}, + {"corpseDistance", game_.getCorpseDistance()} + }; + } + game_.reclaimCorpse(); + return {{"ok", true}}; + } + + // Resurrects a ghost at the nearest Spirit Healer instead of walking back + // to the corpse - CMSG_SPIRIT_HEALER_ACTIVATE/activateSpiritHealer() + // already existed for the GUI client's manual interaction; this just + // finds the NPC and triggers it headlessly. Identified by + // UNIT_NPC_FLAG_SPIRITHEALER (0x4000, see CMaNGOS Unit.h) rather than + // name - live-observed that this NPC's name field often isn't populated + // client-side (no completed name query), so name matching missed it + // entirely even standing right next to it. Comes with the usual WoW + // tradeoff: resurrection sickness and gear durability loss, but skips + // the ghost-walk-to-corpse entirely. + json resurrectAtGraveyardAction(float searchRadius) { + if (!isInWorld()) { + return {{"ok", false}, {"error", "not in world"}}; + } + if (!game_.isPlayerGhost()) { + return {{"ok", false}, {"error", "not a ghost"}}; + } + constexpr uint32_t kNpcFlagSpiritHealer = 0x4000; + const auto& move = game_.getMovementInfo(); + uint64_t bestGuid = 0; + float bestDist = -1.0f; + for (const auto& entity : game_.getEntityManager().snapshotEntities()) { + if (!entity || !entity->isUnit()) continue; + const auto* unit = static_cast(entity.get()); + if ((unit->getNpcFlags() & kNpcFlagSpiritHealer) == 0) continue; + const float dx = entity->getX() - move.x; + const float dy = entity->getY() - move.y; + const float dz = entity->getZ() - move.z; + const float dist = std::sqrt(dx * dx + dy * dy + dz * dz); + if (dist > searchRadius) continue; + if (bestDist < 0.0f || dist < bestDist) { + bestDist = dist; + bestGuid = entity->getGuid(); + } + } + if (bestGuid == 0) { + return {{"ok", false}, {"error", "no Spirit Healer found nearby"}, {"searchRadius", searchRadius}}; + } + game_.activateSpiritHealer(bestGuid); + return {{"ok", true}, {"npcGuid", bestGuid}, {"distance", bestDist}}; + } + + // Discovers (learns) the flight point at the nearest Flight Master + // without actually taking a flight. A real client sends + // CMSG_TAXIQUERYAVAILABLENODES (not CMSG_GOSSIP_HELLO) when clicking a + // flight master; CMaNGOS's handler (TaxiHandler.cpp) learns the node as + // a side effect on the *first* query and returns early without sending + // SMSG_SHOWTAXINODES - only a *second* query (now-known node) falls + // through to SendTaxiMenu() and actually sends it. So we query twice: + // the first call is the real discovery, the second is purely to force + // SMSG_SHOWTAXINODES back so handleShowTaxiNodes() populates + // currentTaxiData_/knownTaxiMask_ and /world/self's "taxi" block can + // confirm success (nearestNodeKnown=true) instead of just hoping. + json learnFlightPathAction(float searchRadius) { + if (!isInWorld()) { + return {{"ok", false}, {"error", "not in world"}}; + } + // TBC/WotLK (and Turtle WoW, which follows the same Blizzard-derived + // enum) use bit 0x2000 for UNIT_NPC_FLAG_FLIGHTMASTER. This specific + // VMangos fork uses its own compacted NPCFlags enum instead (no gaps + // reserved for later-expansion features) with FLIGHTMASTER at bit + // 0x8 - confirmed against /home/josh/vmangos/core/src/game/Objects/ + // UnitDefines.h on the live dev server and cross-checked against + // creature_template.npc_flags for the real Stormwind flight master + // (Dungar Longdrink, npc_flags=11 = GOSSIP|QUESTGIVER|FLIGHTMASTER + // under this enum, which made no sense under the 0x2000 assumption). + // Scoped to "classic" only, not "turtle" - no evidence turtle shares + // this fork's specific reordering. + const uint32_t kNpcFlagFlightMaster = (settings_.expansion == "classic") ? 0x8u : 0x2000u; + const auto& move = game_.getMovementInfo(); + uint64_t bestGuid = 0; + float bestDist = -1.0f; + for (const auto& entity : game_.getEntityManager().snapshotEntities()) { + if (!entity || !entity->isUnit()) continue; + const auto* unit = static_cast(entity.get()); + if ((unit->getNpcFlags() & kNpcFlagFlightMaster) == 0) continue; + const float dx = entity->getX() - move.x; + const float dy = entity->getY() - move.y; + const float dz = entity->getZ() - move.z; + const float dist = std::sqrt(dx * dx + dy * dy + dz * dz); + if (dist > searchRadius) continue; + if (bestDist < 0.0f || dist < bestDist) { + bestDist = dist; + bestGuid = entity->getGuid(); + } + } + if (bestGuid == 0) { + return {{"ok", false}, {"error", "no Flight Master found nearby"}, {"searchRadius", searchRadius}}; + } + game_.queryTaxiNodes(bestGuid); + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + game_.queryTaxiNodes(bestGuid); + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + return {{"ok", true}, {"npcGuid", bestGuid}, {"distance", bestDist}, {"taxi", taxiJson()}}; + } + + // Activates a taxi flight to destNodeId (or, if 0, the first known node + // whose name contains destName, case-insensitive) via the same + // GameHandler::activateTaxi() path the GUI's flight-map click uses. The + // node must already be known (learnFlightPathAction / a real flight-master + // visit populates knownNodes) since activateTaxi() needs both a resolved + // start node (currentTaxiData_.nearestNode) and taxiNpcGuid_ from that. + // + // Cost is checked client-side against known money and surfaced as a + // "warning" field by default (some servers - confirmed live against a + // CMaNGOS instance - don't enforce CMSG_ACTIVATETAXI cost at all, so a + // hard client-side block by default would make this endpoint unable to + // exercise that exact server behavior). Pass requireAffordable=true to + // turn the warning into a hard pre-check that blocks the request instead. + json activateTaxiAction(uint32_t destNodeId, const std::string& destName, bool requireAffordable = false) { + if (!isInWorld()) { + return {{"ok", false}, {"error", "not in world"}}; + } + if (destNodeId == 0 && !destName.empty()) { + std::string needle = destName; + std::transform(needle.begin(), needle.end(), needle.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + for (const auto& [id, node] : game_.getTaxiNodes()) { + if (!game_.isKnownTaxiNode(id)) continue; + std::string lowerName = node.name; + std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lowerName.find(needle) != std::string::npos) { + destNodeId = id; + break; + } + } + if (destNodeId == 0) { + return {{"ok", false}, {"error", "no known taxi node matches destName"}, {"destName", destName}}; + } + } + if (destNodeId == 0) { + return {{"ok", false}, {"error", "destNodeId or destName is required"}}; + } + if (game_.isOnTaxiFlight()) { + return {{"ok", false}, {"error", "movement_locked: already on taxi flight"}}; + } + const uint32_t costCopper = game_.getTaxiCostTo(destNodeId); + const uint64_t moneyCopper = game_.getMoneyCopper(); + const bool affordable = costCopper <= moneyCopper; + if (!affordable) { + LOG_WARNING("Taxi activate: dest=", destNodeId, " costCopper=", costCopper, + " moneyCopper=", moneyCopper, " - insufficient funds", + requireAffordable ? " (blocking, requireAffordable=true)" : " (proceeding anyway)"); + if (requireAffordable) { + return {{"ok", false}, {"error", "insufficient funds"}, {"destNodeId", destNodeId}, + {"costCopper", costCopper}, {"moneyCopper", moneyCopper}}; + } + } + game_.activateTaxi(destNodeId); + json result = {{"ok", true}, {"destNodeId", destNodeId}, {"costCopper", costCopper}, + {"moneyCopper", moneyCopper}, {"taxi", taxiJson()}}; + if (!affordable) { + result["warning"] = "insufficient funds - server may reject or silently accept depending on its taxi cost enforcement"; + } + return result; + } + + // Resolves a name (case-insensitive prefix match, closest within + // searchRadius wins) to an entity and starts the same real GameHandler + // follow (camera + walking-toward-live-position, via + // MovementHandler::updateFollowMovement) the GUI client's /follow chat + // command uses - this was previously only approximated externally by + // follow_player.py polling /world/self and reissuing /movement/goto, + // which lagged behind the target's actual live position. + json followByNameAction(const std::string& targetName, float searchRadius) { + if (!isInWorld()) { + return {{"ok", false}, {"error", "not in world"}}; + } + std::string needle = targetName; + std::transform(needle.begin(), needle.end(), needle.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + const auto& move = game_.getMovementInfo(); + uint64_t bestGuid = 0; + float bestDist = -1.0f; + std::string bestName; + for (const auto& entity : game_.getEntityManager().snapshotEntities()) { + if (!entity || !entity->isUnit()) continue; + const auto* unit = static_cast(entity.get()); + std::string name = unit->getName(); + std::string lowerName = name; + std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lowerName.rfind(needle, 0) != 0) continue; + const float dx = entity->getX() - move.x; + const float dy = entity->getY() - move.y; + const float dz = entity->getZ() - move.z; + const float dist = std::sqrt(dx * dx + dy * dy + dz * dz); + if (dist > searchRadius) continue; + if (bestDist < 0.0f || dist < bestDist) { + bestDist = dist; + bestGuid = entity->getGuid(); + bestName = name; + } + } + if (bestGuid == 0) { + return {{"ok", false}, {"error", "no matching entity found nearby"}, {"searchRadius", searchRadius}}; + } + game_.setTarget(bestGuid); + game_.followTarget(); + return {{"ok", true}, {"targetGuid", bestGuid}, {"targetName", bestName}, {"distance", bestDist}}; + } + + json stopFollowAction() { + game_.cancelFollow(); + return {{"ok", true}}; + } + + void requestGracefulLogout() { + if (logoutRequested_.exchange(true)) return; + if (game_.getState() == game::WorldState::IN_WORLD) { + std::cout << "Esc pressed; requesting logout. Press Ctrl-C to quit immediately.\n"; + game_.requestLogout(); + logoutRequestedAt_ = std::chrono::steady_clock::now(); + } else { + std::cout << "Esc pressed before entering world; exiting.\n"; + g_running = false; + } + } + + void enqueueChat(PendingChat msg) { + std::lock_guard lock(pendingMutex_); + pendingChat_.push_back(std::move(msg)); + } + + json readChat(size_t afterId, size_t limit) { + std::lock_guard lock(chatMutex_); + json arr = json::array(); + for (size_t i = 0; i < chatMirror_.size(); ++i) { + const size_t id = chatBaseId_ + i; + if (id <= afterId) continue; + arr.push_back(chatToJson(resolveChatSender(chatMirror_[i]), id)); + if (arr.size() >= limit) break; + } + return { + {"status", status()}, + {"inWorld", isInWorld()}, + {"nextId", chatBaseId_ + chatMirror_.size()}, + {"messages", arr} + }; + } + +private: + std::string statusUnlocked() const { + if (failed_) return "failed: " + failureReason_; + if (logoutRequested_) return "logging_out"; + if (isInWorld()) return "in_world"; + return "connecting"; + } + + json movementTaskToJsonLocked() const { + const size_t total = movementTask_.waypoints.size(); + const size_t index = movementTask_.currentWaypoint; + return { + {"state", movementTaskStateName(movementTask_)}, + {"active", movementTask_.active}, + {"mapId", movementTask_.mapId}, + {"x", movementTask_.targetX()}, + {"y", movementTask_.targetY()}, + {"z", movementTask_.targetZ()}, + {"arrivalRadius", movementTask_.targetRadius()}, + {"error", movementTask_.error}, + {"waypointIndex", total > 0 ? static_cast(index + 1) : 0ull}, + {"waypointCount", static_cast(total)} + }; + } + + game::MessageChatData resolveChatSender(const game::MessageChatData& msg) const { + if (!msg.senderName.empty() || msg.senderGuid == 0) return msg; + + game::MessageChatData resolved = msg; + const auto& cache = game_.getPlayerNameCache(); + auto it = cache.find(msg.senderGuid); + if (it != cache.end() && !it->second.empty()) { + resolved.senderName = it->second; + } + return resolved; + } + + void configureProtocol() { + const std::filesystem::path dataPath = std::getenv("WOW_DATA_PATH") ? std::getenv("WOW_DATA_PATH") : "Data"; + const auto expansionPath = dataPath / "expansions" / settings_.expansion; + + if (!game_.getOpcodeTable().loadFromJson((expansionPath / "opcodes.json").string())) { + std::cerr << "Warning: failed to load opcode table from " << (expansionPath / "opcodes.json").string() << "\n"; + } + game::setActiveOpcodeTable(&game_.getOpcodeTable()); + + if (!game_.getUpdateFieldTable().loadFromJson((expansionPath / "update_fields.json").string())) { + std::cerr << "Warning: failed to load update field table from " << (expansionPath / "update_fields.json").string() << "\n"; + } + game::setActiveUpdateFieldTable(&game_.getUpdateFieldTable()); + game_.setPacketParsers(game::createPacketParsers(settings_.expansion)); + + if (dbcLayout_.loadFromJson((expansionPath / "dbc_layouts.json").string())) { + pipeline::setActiveDBCLayout(&dbcLayout_); + } + + if (assetManager_.initialize(dataPath.string()) || assetManager_.initializeDbcOnly(dataPath.string())) { + assetManager_.setExpansionDataPath(expansionPath.string()); + // Without this, GameHandler::services().assetManager stays null for the + // whole process, and anything gated on it (e.g. MovementHandler's + // AreaTrigger.dbc load) silently never runs - headless has its own + // assetManager_ member but nothing previously wired it into the shared + // GameServices struct GameHandler was constructed with. + services_.assetManager = &assetManager_; + rendering::EmoteRegistry::instance().loadFromDbc(&assetManager_); + // Without this, TransportManager::hasPathForEntry() never finds a real + // moving path for any transport (Deeprun Tram, boats, elevators) - they + // register but sit stationary at their spawn point forever, since nothing + // else in this harness ever loads the animation curves. + if (auto* tm = game_.getTransportManager()) { + tm->loadTransportAnimationDBC(&assetManager_); + tm->loadTaxiPathNodeDBC(&assetManager_); + } + } else { + std::cerr << "Warning: asset manager not initialized; headless emotes will use fallback text\n"; + } + } + + void onRealmList(const std::vector& realms) { + if (realms.empty()) { + fail("Realm list is empty"); + return; + } + + size_t index = settings_.realmIndex; + if (!settings_.realmName.empty()) { + const std::string want = lowerAscii(settings_.realmName); + auto it = std::find_if(realms.begin(), realms.end(), [&](const auth::Realm& realm) { + return lowerAscii(realm.name) == want; + }); + if (it == realms.end()) { + fail("Configured realm not found: " + settings_.realmName); + return; + } + index = static_cast(std::distance(realms.begin(), it)); + } + if (index >= realms.size()) { + fail("Configured realm index is out of range"); + return; + } + + const auto& realm = realms[index]; + std::string worldHost; + uint16_t worldPort = 0; + if (!splitHostPort(realm.address, worldHost, worldPort)) { + fail("Realm address is not host:port: " + realm.address); + return; + } + + const uint32_t realmId = settings_.realmId != 0 ? settings_.realmId : realm.id; + std::cout << "Connecting to realm " << realm.name << " at " << worldHost << ":" << worldPort << "\n"; + game_.connect(worldHost, worldPort, auth_.getSessionKey(), settings_.account, + settings_.worldBuild, realmId); + } + + void selectConfiguredCharacter() { + const auto& characters = game_.getCharacters(); + if (characters.empty()) { + fail("No characters found on selected realm"); + return; + } + + const game::Character* chosen = nullptr; + if (!settings_.characterName.empty()) { + const std::string want = lowerAscii(settings_.characterName); + for (const auto& ch : characters) { + if (lowerAscii(ch.name) == want) { + chosen = &ch; + break; + } + } + if (!chosen) { + fail("Configured character not found: " + settings_.characterName); + return; + } + } else { + chosen = &characters.front(); + settings_.characterName = chosen->name; + } + + selectedCharacter_ = true; + std::cout << "Selecting character " << chosen->name << "\n"; + game_.selectCharacter(chosen->guid); + } + + void provisionConfiguredCharacter() { + if (charCreateRequested_ || charCreateDone_) return; + const auto& data = *settings_.createCharacter; + const std::string want = lowerAscii(data.name); + for (const auto& ch : game_.getCharacters()) { + if (lowerAscii(ch.name) == want) { + std::cout << "Character already exists: " << data.name << "\n"; + charCreateDone_ = true; + if (settings_.exitAfterCreateCharacter) { + g_running = false; + } + return; + } + } + + charCreateRequested_ = true; + std::cout << "Creating character " << data.name << "\n"; + game_.createCharacter(data); + } + + void drainPendingChat() { + if (!isInWorld()) return; + + std::deque local; + { + std::lock_guard lock(pendingMutex_); + local.swap(pendingChat_); + } + + for (const auto& msg : local) { + game_.sendChatMessage(msg.type, msg.message, msg.target); + } + } + + void queueOnEnterWorldCommands() { + for (const auto& command : settings_.onEnterWorldCommands) { + scheduledCommands_.push_back(command); + } + if (!scheduledCommands_.empty()) { + nextScheduledCommandDelay_ = 0.0f; + std::cout << "Queued " << scheduledCommands_.size() << " on-enter-world command(s)\n"; + } + } + + void drainScheduledCommands(float deltaSeconds) { + if (!isInWorld() || scheduledCommands_.empty()) return; + + if (nextScheduledCommandDelay_ > 0.0f) { + nextScheduledCommandDelay_ -= deltaSeconds; + if (nextScheduledCommandDelay_ > 0.0f) return; + } + + PendingChat msg; + msg.type = game::ChatType::SAY; + msg.message = scheduledCommands_.front(); + scheduledCommands_.pop_front(); + enqueueChat(std::move(msg)); + nextScheduledCommandDelay_ = settings_.onEnterCommandDelaySeconds; + } + + void stopMovementTaskLocked(const std::string& status, const std::string& error) { + if (movementTask_.active || movementTask_.started) { + game_.sendMovement(game::Opcode::MSG_MOVE_STOP); + game_.sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT); + } + movementTask_.active = false; + movementTask_.started = false; + movementTask_.status = status; + movementTask_.error = error; + movementTask_.lastSetValid = false; + } + + void advanceToNextWaypointLocked() { + if (movementTask_.currentWaypoint + 1 < movementTask_.waypoints.size()) { + ++movementTask_.currentWaypoint; + movementTask_.lastSetValid = false; + if (movementTask_.currentWaypoint + 1 == movementTask_.waypoints.size()) { + std::cout << "Waypoint " << (movementTask_.currentWaypoint + 1) + << "/" << movementTask_.waypoints.size() << " (final)\n"; + } else { + std::cout << "Waypoint " << (movementTask_.currentWaypoint + 1) + << "/" << movementTask_.waypoints.size() << "\n"; + } + } else { + stopMovementTaskLocked("arrived", ""); + } + } + + void updateMovementTask(float deltaSeconds) { + std::lock_guard lock(stateMutex_); + if (!movementTask_.active) return; + + if (!isInWorld()) { + stopMovementTaskLocked("failed", "not in world"); + return; + } + if (movementTask_.mapId != 0 && movementTask_.mapId != game_.getCurrentMapId()) { + stopMovementTaskLocked("failed", "map changed"); + return; + } + if (game_.isPlayerRooted()) { + stopMovementTaskLocked("movement_locked", "player is rooted"); + return; + } + if (!game_.isServerMovementAllowed()) { + stopMovementTaskLocked("movement_locked", "server movement not allowed"); + return; + } + if (game_.isOnTaxiFlight()) { + stopMovementTaskLocked("movement_locked", "on taxi flight"); + return; + } + + if (movementTask_.waypoints.empty()) { + stopMovementTaskLocked("failed", "no waypoints"); + return; + } + + const auto& move = game_.getMovementInfo(); + + if (movementTask_.lastSetValid) { + const float resetDx = move.x - movementTask_.lastSetX; + const float resetDy = move.y - movementTask_.lastSetY; + const float resetDz = move.z - movementTask_.lastSetZ; + const float resetDist = std::sqrt(resetDx * resetDx + resetDy * resetDy + resetDz * resetDz); + if (resetDist > 1.0f) { + stopMovementTaskLocked("movement_locked", "server reset position"); + return; + } + } + + const float tx = movementTask_.targetX(); + const float ty = movementTask_.targetY(); + const float tz = movementTask_.targetZ(); + const float radius = movementTask_.targetRadius(); + + const float dx = tx - move.x; + const float dy = ty - move.y; + const float dz = tz - move.z; + const float horizontalDist = std::sqrt(dx * dx + dy * dy); + const float distance = std::sqrt(dx * dx + dy * dy + dz * dz); + + if (distance <= radius) { + // Treat waypoint radius as "close enough", not as permission to + // snap forward. Snapping at every intermediate waypoint makes the + // headless client visibly outrun a normal WoW client on dense paths. + if (movementTask_.currentWaypoint + 1 >= movementTask_.waypoints.size() && distance <= 0.5f) { + game_.setPosition(tx, ty, tz); + } + advanceToNextWaypointLocked(); + return; + } + + if (horizontalDist > 0.001f) { + game_.setOrientation(std::atan2(-dy, dx)); + game_.sendMovement(game::Opcode::MSG_MOVE_SET_FACING); + } + if (!movementTask_.started) { + game_.sendMovement(game::Opcode::MSG_MOVE_START_FORWARD); + movementTask_.started = true; + } + + const float speed = std::max(0.1f, game_.getServerRunSpeed()); + const float step = std::min(distance, speed * std::max(0.0f, deltaSeconds)); + const float t = distance > 0.001f ? (step / distance) : 1.0f; + const float newX = move.x + dx * t; + const float newY = move.y + dy * t; + const float newZ = move.z + dz * t; + game_.setPosition(newX, newY, newZ); + movementTask_.lastSetX = newX; + movementTask_.lastSetY = newY; + movementTask_.lastSetZ = newZ; + movementTask_.lastSetValid = true; + game_.sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT); + } + + void syncChatSnapshot() { + std::lock_guard lock(chatMutex_); + syncChatSnapshotLocked(); + } + + void syncChatSnapshotLocked() { + const auto& history = game_.getChatHistory(); + if (history.size() < lastGameHistorySize_) { + chatMirror_.clear(); + chatBaseId_ = nextChatId_; + lastGameHistorySize_ = 0; + } + + for (size_t i = lastGameHistorySize_; i < history.size(); ++i) { + if (history[i].message.empty()) continue; + chatMirror_.push_back(history[i]); + ++nextChatId_; + while (chatMirror_.size() > settings_.apiMaxMessages) { + chatMirror_.pop_front(); + ++chatBaseId_; + } + } + lastGameHistorySize_ = history.size(); + } + + void updateLogoutExit() { + if (!logoutRequested_) return; + const auto elapsed = std::chrono::steady_clock::now() - logoutRequestedAt_; + const bool inWorld = game_.getState() == game::WorldState::IN_WORLD; + if (!inWorld && !game_.isLoggingOut()) { + g_running = false; + return; + } + + if (!game_.isLoggingOut() && elapsed >= std::chrono::seconds(2)) { + std::cout << "Logout complete; exiting process.\n"; + g_running = false; + return; + } + + if (elapsed >= kLogoutSilentExitTimeout) { + std::cout << "Logout wait reached " + << std::chrono::duration_cast(kLogoutSilentExitTimeout).count() + << "s; exiting process.\n"; + g_running = false; + } + } + + void fail(const std::string& reason) { + if (!failed_) { + std::lock_guard lock(stateMutex_); + failed_ = true; + failureReason_ = reason; + std::cerr << reason << "\n"; + } + } + + Settings settings_; + game::GameServices services_; + auth::AuthHandler auth_; + game::GameHandler game_; + pipeline::DBCLayout dbcLayout_; + pipeline::AssetManager assetManager_; + + std::atomic failed_{false}; + std::atomic inWorldForApi_{false}; + mutable std::mutex stateMutex_; + std::string failureReason_; + bool selectedCharacter_ = false; + bool enteredWorld_ = false; + bool charCreateRequested_ = false; + bool charCreateDone_ = false; + + std::mutex pendingMutex_; + std::deque pendingChat_; + std::deque scheduledCommands_; + float nextScheduledCommandDelay_ = 0.0f; + + std::mutex chatMutex_; + std::deque chatMirror_; + size_t chatBaseId_ = 1; + size_t nextChatId_ = 1; + size_t lastGameHistorySize_ = 0; + MovementTask movementTask_; + std::atomic logoutRequested_{false}; + std::chrono::steady_clock::time_point logoutRequestedAt_{}; +}; + +std::string urlDecode(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (size_t i = 0; i < s.size(); ++i) { + if (s[i] == '%' && i + 2 < s.size()) { + const std::string hex = s.substr(i + 1, 2); + out.push_back(static_cast(std::strtol(hex.c_str(), nullptr, 16))); + i += 2; + } else if (s[i] == '+') { + out.push_back(' '); + } else { + out.push_back(s[i]); + } + } + return out; +} + +std::string queryParam(const std::string& query, const std::string& key) { + size_t pos = 0; + while (pos <= query.size()) { + size_t amp = query.find('&', pos); + std::string part = query.substr(pos, amp == std::string::npos ? std::string::npos : amp - pos); + size_t eq = part.find('='); + std::string k = urlDecode(eq == std::string::npos ? part : part.substr(0, eq)); + if (k == key) return urlDecode(eq == std::string::npos ? "" : part.substr(eq + 1)); + if (amp == std::string::npos) break; + pos = amp + 1; + } + return ""; +} + +bool apiDebugEnabled() { + static const bool enabled = [] { + const char* value = std::getenv("WOWEE_API_DEBUG"); + return value && value[0] != '\0' && std::string(value) != "0"; + }(); + return enabled; +} + +const char* httpReason(int status) { + switch (status) { + case 200: return "OK"; + case 400: return "Bad Request"; + case 404: return "Not Found"; + case 408: return "Request Timeout"; + case 413: return "Payload Too Large"; + case 500: return "Internal Server Error"; + default: return "Error"; + } +} + +void configureApiClientSocket(socket_t client) { +// Accepted sockets can inherit non-blocking mode from the listening socket on +// Windows. The per-client handler expects blocking reads with a short timeout. +#ifdef _WIN32 + u_long blockingMode = 0; + ioctlsocket(client, FIONBIO, &blockingMode); +#else + int flags = fcntl(client, F_GETFL, 0); + if (flags >= 0) fcntl(client, F_SETFL, flags & ~O_NONBLOCK); +#endif + + int one = 1; + setsockopt(client, IPPROTO_TCP, TCP_NODELAY, reinterpret_cast(&one), sizeof(one)); +#ifdef _WIN32 + DWORD timeoutMs = 5000; + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&timeoutMs), sizeof(timeoutMs)); + setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&timeoutMs), sizeof(timeoutMs)); +#else + timeval tv{}; + tv.tv_sec = 5; + tv.tv_usec = 0; + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); +#endif +} + +bool sendAll(socket_t client, const std::string& response) { + size_t offset = 0; + while (offset < response.size()) { + const size_t remaining = response.size() - offset; + ssize_t sent = wowee::net::portableSend( + client, + reinterpret_cast(response.data() + offset), + remaining + ); + if (sent <= 0) { + if (apiDebugEnabled()) { + std::cerr << "API send failed: " + << wowee::net::errorString(wowee::net::lastError()) << "\n"; + } + return false; + } + offset += static_cast(sent); + } + return true; +} + +bool parseContentLengthLine(const std::string& line, size_t& contentLength) { + const std::string lower = lowerAscii(line); + if (lower.rfind("content-length:", 0) != 0) return true; + const std::string raw = trim(line.substr(15)); + if (raw.empty() || raw.size() > 10) return false; + size_t value = 0; + for (char ch : raw) { + if (!std::isdigit(static_cast(ch))) return false; + value = value * 10 + static_cast(ch - '0'); + if (value > 1024 * 1024) return false; + } + contentLength = value; + return true; +} + +bool recvAppend(socket_t client, std::string& out, size_t maxBytes) { + std::array buf{}; + ssize_t n = wowee::net::portableRecv(client, buf.data(), buf.size()); + if (n <= 0) { + if (n < 0 && apiDebugEnabled()) { + const int err = wowee::net::lastError(); + if (!wowee::net::isConnectionClosed(err)) { + std::cerr << "API recv failed: " << wowee::net::errorString(err) << "\n"; + } + } + return false; + } + const size_t available = maxBytes > out.size() ? maxBytes - out.size() : 0; + const size_t toCopy = std::min(available, static_cast(n)); + out.append(reinterpret_cast(buf.data()), toCopy); + return toCopy == static_cast(n); +} + +void sendHttp(socket_t client, int status, const json& body) { + const std::string payload = body.dump(); + std::ostringstream out; + out << "HTTP/1.1 " << status << " " << httpReason(status) << "\r\n" + << "Content-Type: application/json\r\n" + << "Access-Control-Allow-Origin: *\r\n" + << "Content-Length: " << payload.size() << "\r\n" + << "Connection: close\r\n\r\n" + << payload; + const std::string response = out.str(); + bool sent = sendAll(client, response); + (void)sent; // client may have disconnected — ignore +} + +void handleHttpClient(socket_t client, HeadlessSession& session) { + configureApiClientSocket(client); + std::string req; + while (req.find("\r\n\r\n") == std::string::npos && req.size() < 65536) { + if (!recvAppend(client, req, 65536)) break; + } + + const size_t headerEnd = req.find("\r\n\r\n"); + if (headerEnd == std::string::npos) { + sendHttp(client, req.empty() ? 408 : 400, {{"ok", false}, {"error", req.empty() ? "request timeout" : "bad request: missing header terminator"}}); + wowee::net::closeSocket(client); + return; + } + + const std::string headers = req.substr(0, headerEnd); + std::istringstream hs(headers); + std::string method; + std::string target; + std::string version; + hs >> method >> target >> version; + if (method.empty() || target.empty() || version.rfind("HTTP/", 0) != 0) { + sendHttp(client, 400, {{"ok", false}, {"error", "bad request line"}}); + wowee::net::closeSocket(client); + return; + } + + size_t contentLength = 0; + std::string line; + std::getline(hs, line); + while (std::getline(hs, line)) { + line = trim(line); + if (!parseContentLengthLine(line, contentLength)) { + sendHttp(client, 400, {{"ok", false}, {"error", "invalid Content-Length"}}); + wowee::net::closeSocket(client); + return; + } + } + if (contentLength > 1024 * 1024) { + sendHttp(client, 413, {{"ok", false}, {"error", "request body too large"}}); + wowee::net::closeSocket(client); + return; + } + + std::string body = req.substr(headerEnd + 4); + while (body.size() < contentLength) { + if (!recvAppend(client, body, contentLength)) break; + } + if (body.size() < contentLength) { + sendHttp(client, 400, {{"ok", false}, {"error", "incomplete request body"}}); + wowee::net::closeSocket(client); + return; + } + + const size_t qpos = target.find('?'); + const std::string path = qpos == std::string::npos ? target : target.substr(0, qpos); + const std::string query = qpos == std::string::npos ? "" : target.substr(qpos + 1); + if (apiDebugEnabled()) { + std::cerr << "API " << method << " " << path << " body=" << body.size() << "\n"; + } + + try { + if (method == "OPTIONS") { + sendHttp(client, 200, {{"ok", true}}); + } else if (method == "GET" && path == "/status") { + sendHttp(client, 200, session.statusJson()); + } else if (method == "GET" && path == "/world/self") { + sendHttp(client, 200, session.worldSelf()); + } else if (method == "GET" && path == "/world/entities") { + const std::string radiusParam = queryParam(query, "radius"); + const float radius = radiusParam.empty() ? 120.0f : std::stof(radiusParam); + const std::string transportsParam = queryParam(query, "transports"); + const bool onlyTransports = transportsParam == "1" || transportsParam == "true" || transportsParam == "yes"; + sendHttp(client, 200, session.worldEntities(radius, onlyTransports)); + } else if (method == "GET" && path == "/party") { + sendHttp(client, 200, session.partyJson()); + } else if (method == "GET" && path == "/chat") { + const size_t after = queryParam(query, "after").empty() ? 0 : static_cast(std::stoull(queryParam(query, "after"))); + const size_t limit = queryParam(query, "limit").empty() ? 100 : static_cast(std::stoull(queryParam(query, "limit"))); + sendHttp(client, 200, session.readChat(after, limit)); + } else if (method == "POST" && path == "/chat") { + json payload = json::parse(body.empty() ? "{}" : body); + auto type = chatTypeFromString(payload.value("type", "say")); + if (!type) { + sendHttp(client, 400, {{"error", "unsupported chat type"}}); + } else { + PendingChat msg; + msg.type = *type; + msg.message = payload.value("message", ""); + msg.target = payload.value("target", payload.value("channel", "")); + if (msg.message.empty()) { + sendHttp(client, 400, {{"error", "message is required"}}); + } else if ((msg.type == game::ChatType::WHISPER || msg.type == game::ChatType::CHANNEL) && msg.target.empty()) { + sendHttp(client, 400, {{"error", "target/channel is required for this chat type"}}); + } else { + session.enqueueChat(std::move(msg)); + sendHttp(client, 200, {{"ok", true}}); + } + } + } else if (method == "POST" && path == "/commands") { + json payload = json::parse(body.empty() ? "{}" : body); + if (payload.contains("commands") && payload["commands"].is_array()) { + json results = json::array(); + for (const auto& item : payload["commands"]) { + results.push_back(session.queueCommand(item.is_string() ? item.get() : "")); + } + sendHttp(client, 200, {{"ok", true}, {"results", results}}); + } else { + const auto result = session.queueCommand(payload.value("command", payload.value("message", ""))); + sendHttp(client, result.value("ok", false) ? 200 : 400, result); + } + } else if (method == "POST" && path == "/movement/goto/waypoints") { + json payload = json::parse(body.empty() ? "{}" : body); + const uint32_t mapId = static_cast(std::max(0, payload.value("mapId", 0))); + const float defaultRadius = payload.value("arrivalRadius", 3.0f); + std::vector waypoints; + for (const auto& wpJson : payload.value("waypoints", json::array())) { + if (!wpJson.is_object()) continue; + Waypoint wp; + wp.x = wpJson.value("x", 0.0f); + wp.y = wpJson.value("y", 0.0f); + wp.z = wpJson.value("z", 0.0f); + wp.arrivalRadius = wpJson.value("arrivalRadius", 0.0f); + if (std::isfinite(wp.x) && std::isfinite(wp.y) && std::isfinite(wp.z)) { + waypoints.push_back(wp); + } + } + const auto result = session.startGotoWaypoints(mapId, waypoints, defaultRadius); + sendHttp(client, result.value("ok", false) ? 200 : 400, result); + } else if (method == "POST" && path == "/movement/goto") { + json payload = json::parse(body.empty() ? "{}" : body); + const uint32_t mapId = static_cast(std::max(0, payload.value("mapId", 0))); + const float x = payload.value("x", 0.0f); + const float y = payload.value("y", 0.0f); + const float z = payload.value("z", 0.0f); + const float arrivalRadius = payload.value("arrivalRadius", 3.0f); + const auto result = session.startGoto(mapId, x, y, z, arrivalRadius); + sendHttp(client, result.value("ok", false) ? 200 : 400, result); + } else if (method == "POST" && path == "/movement/stop") { + json payload = json::parse(body.empty() ? "{}" : body); + sendHttp(client, 200, session.stopMovementTask(payload.value("reason", "stopped"))); + } else if (method == "POST" && path == "/area-trigger") { + json payload = json::parse(body.empty() ? "{}" : body); + const uint32_t triggerId = static_cast(std::max(0, payload.value("id", payload.value("triggerId", 0)))); + const auto result = session.fireAreaTrigger(triggerId); + sendHttp(client, result.value("ok", false) ? 200 : 400, result); + } else if (method == "POST" && path == "/release-spirit") { + const auto result = session.releaseSpiritAction(); + sendHttp(client, result.value("ok", false) ? 200 : 400, result); + } else if (method == "POST" && path == "/reclaim-corpse") { + const auto result = session.reclaimCorpseAction(); + sendHttp(client, result.value("ok", false) ? 200 : 400, result); + } else if (method == "POST" && path == "/resurrect-at-graveyard") { + json payload = json::parse(body.empty() ? "{}" : body); + const float searchRadius = payload.value("searchRadius", 40.0f); + const auto result = session.resurrectAtGraveyardAction(searchRadius); + sendHttp(client, result.value("ok", false) ? 200 : 400, result); + } else if (method == "POST" && path == "/learn-flight-path") { + json payload = json::parse(body.empty() ? "{}" : body); + const float searchRadius = payload.value("searchRadius", 15.0f); + const auto result = session.learnFlightPathAction(searchRadius); + sendHttp(client, result.value("ok", false) ? 200 : 400, result); + } else if (method == "GET" && path == "/taxi/nodes") { + const auto result = session.taxiNodesListAction(); + sendHttp(client, 200, result); + } else if (method == "GET" && path == "/taxi/edges") { + const auto result = session.taxiEdgesListAction(); + sendHttp(client, 200, result); + } else if (method == "POST" && path == "/taxi/activate") { + json payload = json::parse(body.empty() ? "{}" : body); + const uint32_t destNodeId = payload.value("destNodeId", 0u); + const std::string destName = payload.value("destName", ""); + const bool requireAffordable = payload.value("requireAffordable", false); + const auto result = session.activateTaxiAction(destNodeId, destName, requireAffordable); + sendHttp(client, result.value("ok", false) ? 200 : 400, result); + } else if (method == "POST" && path == "/follow") { + json payload = json::parse(body.empty() ? "{}" : body); + const std::string name = payload.value("name", ""); + const float searchRadius = payload.value("searchRadius", 150.0f); + if (name.empty()) { + sendHttp(client, 400, {{"error", "name is required"}}); + } else { + const auto result = session.followByNameAction(name, searchRadius); + sendHttp(client, result.value("ok", false) ? 200 : 400, result); + } + } else if (method == "POST" && path == "/follow/stop") { + sendHttp(client, 200, session.stopFollowAction()); + } else if (method == "POST" && path == "/logout") { + // Requests a clean CMSG_LOGOUT_REQUEST and lets the process exit + // on its own once the server confirms logout (or a timeout + // elapses) - see updateLogoutExit(). Same mechanism the ESC key + // already used interactively, just reachable over the API so + // fleet tooling doesn't have to force-kill the process to stop + // a leader. + session.requestGracefulLogout(); + sendHttp(client, 200, {{"ok", true}, {"status", "logout requested"}}); + } else { + sendHttp(client, 404, {{"ok", false}, {"error", "not found"}}); + } + } catch (const std::exception& ex) { + std::cerr << "API handler exception for " << method << " " << path << ": " << ex.what() << "\n"; + sendHttp(client, 500, {{"ok", false}, {"error", ex.what()}, {"path", path}}); + } catch (...) { + std::cerr << "API handler unknown exception for " << method << " " << path << "\n"; + sendHttp(client, 500, {{"ok", false}, {"error", "unknown API handler exception"}, {"path", path}}); + } + + wowee::net::closeSocket(client); +} + +void runHttpServer(const Settings& settings, HeadlessSession& session) { + wowee::net::ensureInit(); + socket_t server = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (server == INVALID_SOCK) { + std::cerr << "API socket() failed\n"; + return; + } + + int yes = 1; + setsockopt(server, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&yes), sizeof(yes)); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(settings.apiPort); + if (inet_pton(AF_INET, settings.apiBind.c_str(), &addr.sin_addr) != 1) { + std::cerr << "API bind address must be an IPv4 address: " << settings.apiBind << "\n"; + wowee::net::closeSocket(server); + return; + } + + if (::bind(server, reinterpret_cast(&addr), sizeof(addr)) != 0) { + std::cerr << "API bind failed on " << settings.apiBind << ":" << settings.apiPort << "\n"; + wowee::net::closeSocket(server); + return; + } + if (::listen(server, 16) != 0) { + std::cerr << "API listen failed\n"; + wowee::net::closeSocket(server); + return; + } + + wowee::net::setNonBlocking(server); + std::cout << "Chat API listening on http://" << settings.apiBind << ":" << settings.apiPort << "\n"; + + while (g_running) { + sockaddr_in clientAddr{}; +#ifdef _WIN32 + int len = sizeof(clientAddr); +#else + socklen_t len = sizeof(clientAddr); +#endif + socket_t client = ::accept(server, reinterpret_cast(&clientAddr), &len); + if (client == INVALID_SOCK) { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + continue; + } + handleHttpClient(client, session); + } + + wowee::net::closeSocket(server); +} + +void signalHandler(int) { + g_running = false; +} + +void usage() { + std::cerr << "Usage: wowee_headless [settings.json]\n"; +} + +} // namespace + +int main(int argc, char** argv) { + wowee::core::installCrashDiagnostics("wowee_headless"); + std::signal(SIGINT, signalHandler); + std::signal(SIGTERM, signalHandler); + setDefaultEnv("WOWEE_HEADLESS", "1"); + setDefaultEnv("WOWEE_NET_ASYNC_PUMP", "0"); + wowee::net::ensureInit(); + + const std::string settingsPath = argc >= 2 ? argv[1] : "tools/headless_client/settings.json"; + if (argc > 2) { + usage(); + return 2; + } + + try { + Settings settings = loadSettings(settingsPath); + setDefaultEnv("WOWEE_ACTIVE_EXPANSION", settings.expansion.c_str()); + std::cout << "WoWee headless client using " << settingsPath << "\n"; + std::cout << "Expansion profile: " << settings.expansion << "\n"; + + HeadlessSession session(settings); + if (!session.start()) return 1; + + std::thread apiThread; + if (settings.apiEnabled) { + apiThread = std::thread([&]() { runHttpServer(settings, session); }); + } + + auto last = std::chrono::steady_clock::now(); + while (g_running && !session.isFailed()) { + if (consumeEscKeypress()) { + session.requestGracefulLogout(); + } + + const auto now = std::chrono::steady_clock::now(); + const float dt = std::chrono::duration(now - last).count(); + last = now; + session.update(dt); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + g_running = false; + if (apiThread.joinable()) apiThread.join(); + return session.isFailed() ? 1 : 0; + } catch (const std::exception& ex) { + std::cerr << ex.what() << "\n"; + return 1; + } +} diff --git a/tools/headless_client/settings.example.json b/tools/headless_client/settings.example.json new file mode 100644 index 000000000..c3e3a33c0 --- /dev/null +++ b/tools/headless_client/settings.example.json @@ -0,0 +1,44 @@ +{ + "auth": { + "host": "127.0.0.1", + "port": 3724, + "account": "ACCOUNT", + "password": "PASSWORD" + }, + "client": { + "major": 3, + "minor": 3, + "patch": 5, + "build": 12340, + "protocol": 8, + "locale": "enUS", + "platform": "x86", + "os": "Win", + "expansion": "wotlk" + }, + "realm": { + "name": "", + "index": 0, + "id": 0 + }, + "character": { + "name": "" + }, + "chat": { + "autoJoinDefaultChannels": false + }, + "bots": { + "enabled": false, + "names": [] + }, + "automation": { + "commandDelaySeconds": 0.25, + "onEnterWorldCommands": [] + }, + "api": { + "enabled": true, + "bind": "127.0.0.1", + "port": 8787, + "maxMessages": 200 + } +} diff --git a/tools/integration_test.py b/tools/integration_test.py new file mode 100644 index 000000000..e015b4f26 --- /dev/null +++ b/tools/integration_test.py @@ -0,0 +1,409 @@ +#!/usr/bin/env python3 +#!/usr/bin/env python3 +"""Live integration tests against a local CMaNGOS server and headless client. + +Usage: + python tools/integration_test.py # provision, test APIs, cleanup + python tools/integration_test.py --cleanup # remove test account only + +Environment: + Uses tools/.env for SSH/SOAP server credentials. + Requires a built wowee_headless at build/bin/wowee_headless.exe. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +ENV_PATH = ROOT / "tools" / ".env" +SETTINGS_TEMPLATE = ROOT / "tools" / "headless_client" / "settings.example.json" +HEADLESS_BUILD = ROOT / "build" / "bin" / "wowee_headless.exe" + +TEST_ACCOUNT = "WOWEE_TEST_BOT" +TEST_PASSWORD = "TestPass123!" +TEST_CHAR = "Weetest" + +SSH_HOST = "10.102.172.4" +SSH_PORT = 22 +SSH_USER = "mangos-helper" +SSH_KEY = "C:/Users/admin/.ssh/mangos_codex" +SOAP_USER = "SERVERADMIN" +SOAP_PASS = "server" + +if ENV_PATH.exists(): + for line in ENV_PATH.read_text().splitlines(): + if "=" in line and not line.strip().startswith("#"): + k, v = line.strip().split("=", 1) + if k == "MANGOS_HOST": + SSH_HOST = v + elif k == "MANGOS_PORT": + SSH_PORT = int(v) + elif k == "MANGOS_USER": + SSH_USER = v + elif k == "MANGOS_SSH_KEY_PATH": + SSH_KEY = v + elif k == "MANGOS_SOAP_USERNAME": + SOAP_USER = v + elif k == "MANGOS_SOAP_PASSWORD": + SOAP_PASS = v + + +def log(msg: str) -> None: + print(f" [{test_step}] {msg}", flush=True) + + +test_step = 0 + + +def step(name: str) -> None: + global test_step + test_step += 1 + print(f"\n{'='*60}") + print(f"Step {test_step}: {name}") + print(f"{'='*60}") + + +def fail(msg: str) -> None: + print(f"FAIL: {msg}", file=sys.stderr) + sys.exit(1) + + +def http_get(url: str, timeout: float = 5.0) -> dict: + if not url.startswith(("http://", "https://")): + raise ValueError(f"refusing non-http(s) URL: {url}") + try: + # url is validated to http(s) above; this calls our own local test targets, not attacker-controlled input. + with urllib.request.urlopen(url, timeout=timeout) as resp: # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + return json.loads(resp.read().decode()) + except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + fail(f"GET {url} failed: {exc}") + + +def http_post(url: str, data: dict, timeout: float = 5.0) -> dict: + if not url.startswith(("http://", "https://")): + raise ValueError(f"refusing non-http(s) URL: {url}") + try: + body = json.dumps(data).encode() + req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}) + # url is validated to http(s) above; this calls our own local test targets, not attacker-controlled input. + with urllib.request.urlopen(req, timeout=timeout) as resp: # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as exc: + return json.loads(exc.read().decode()) if exc.code == 400 else {"error": str(exc)} + except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + fail(f"POST {url} failed: {exc}") + + +SSH_SCRIPT = """ +import base64, http.client, json, sys, urllib.parse, xml.etree.ElementTree as ET +payload = json.loads(sys.stdin.read()) +soap_user, soap_password = payload["soap_user"], payload["soap_password"] +parsed = urllib.parse.urlparse(payload.get("soap_url", payload.get("soapUrl", "http://127.0.0.1:7878/"))) +host = parsed.hostname or "127.0.0.1" +port = parsed.port or 7878 +path = parsed.path or "/" +def call(cmd): + body = '''{cmd}''' + auth = base64.b64encode((soap_user + ":" + soap_password).encode()).decode() + conn = http.client.HTTPConnection(host, port, timeout=10) + conn.request("POST", path, body.replace("{cmd}", cmd.replace("&","&").replace("<","<")), {"Authorization":"Basic "+auth,"Content-Type":"text/xml","SOAPAction":"executeCommand"}) + return conn.getresponse().read().decode("utf-8", errors="replace") +out = [call(cmd) for cmd in payload["commands"]] +print(json.dumps(out)) +""" + + +def ssh_soap(commands: list[str]) -> list[str]: + key_path = SSH_KEY.replace("\\", "/") + payload = json.dumps({"soapUser": SOAP_USER, "soapPassword": SOAP_PASS, "commands": commands}) + ssh_cmd = [ + "ssh", "-i", key_path, + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=8", + "-p", str(SSH_PORT), + f"{SSH_USER}@{SSH_HOST}", + "--", "python3", "-c", SSH_SCRIPT, + ] + try: + proc = subprocess.run( + ssh_cmd, + input=payload, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=20, + ) + if proc.returncode != 0: + raise RuntimeError(f"SSH exit {proc.returncode}: {proc.stderr.strip()}") + results = json.loads(proc.stdout) + return [r.strip() for r in results] + except subprocess.TimeoutExpired: + return ["SSH timed out"] + except (OSError, json.JSONDecodeError, RuntimeError) as exc: + return [f"SSH error: {exc}"] + + +def soap_cmd(command: str) -> str: + results = ssh_soap([command]) + return results[0] if results else "no output" + + +# --------------------------------------------------------------------------- +# Step 1: Cleanup previous test artifacts +# --------------------------------------------------------------------------- +step("Clean up any prior test account and runtime files") + +soap_cmd(f"account delete {TEST_ACCOUNT}") +runtime_dir = ROOT / "tools" / "bot_fleet_manager" / "runtime" +if runtime_dir.exists(): + import stat as stat_mod + def safe_unlink(p: Path) -> None: + try: + p.chmod(stat_mod.S_IWRITE) + p.unlink() + except Exception: + pass + for f in runtime_dir.glob("*test*"): + if f.is_dir(): + safe_unlink(f) + else: + safe_unlink(f) + route_state_dir = runtime_dir / "route_state" + if route_state_dir.exists(): + for f in route_state_dir.glob("*test*"): + safe_unlink(f) +print(" Cleanup done") + + +# --------------------------------------------------------------------------- +# Step 2: Create test account and character +# --------------------------------------------------------------------------- +step("Create test account via SOAP") + +result = soap_cmd(f"account create {TEST_ACCOUNT} {TEST_PASSWORD}") +print(f" Account create: {result}") +result = soap_cmd(f"account set addon {TEST_ACCOUNT} 1") +print(f" Set addon: {result}") + +step("Create test character via headless provisioning") + +tmp_settings = ROOT / "build" / "test_char_settings.json" +if not SETTINGS_TEMPLATE.exists(): + fail(f"Settings template not found: {SETTINGS_TEMPLATE}") + +settings = json.loads(SETTINGS_TEMPLATE.read_text()) +settings["auth"]["account"] = TEST_ACCOUNT +settings["auth"]["password"] = TEST_PASSWORD +settings["character"]["name"] = TEST_CHAR +settings["provision"] = { + "createCharacter": { + "enabled": True, + "name": TEST_CHAR, + "race": "human", + "class": "warrior", + "gender": 0, + "exitAfterCreate": True, + } +} +tmp_settings.write_text(json.dumps(settings, indent=2)) + +if not HEADLESS_BUILD.exists(): + fail(f"Headless build not found: {HEADLESS_BUILD}") + +env = os.environ.copy() +env["WOWEE_HEADLESS"] = "1" +proc = subprocess.Popen( + [str(HEADLESS_BUILD), str(tmp_settings)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + env=env, +) +try: + stdout, _ = proc.communicate(timeout=60) + print(f" Provisioning exit code: {proc.returncode}") + for line in stdout.splitlines()[-5:]: + print(f" {line}") +except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + fail("Provisioning timed out (60s)") + +# Clean up tmp settings +tmp_settings.unlink(missing_ok=True) + +# --------------------------------------------------------------------------- +# Step 3: Launch headless client in normal mode +# --------------------------------------------------------------------------- +step("Launch headless client with test character") + +settings.pop("provision", None) +settings["api"]["port"] = 8789 +settings["bots"]["enabled"] = True # test that bot fields parse but don't require bots +settings["bots"]["names"] = ["TestBotOne"] + +normal_settings = ROOT / "build" / "test-normal-settings.json" +settings.pop("provision", None) +normal_settings.write_text(json.dumps(settings, indent=2)) + +proc = subprocess.Popen( + [str(HEADLESS_BUILD), str(normal_settings)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + env=env, +) +API_BASE = "http://127.0.0.1:8787" + +# Wait for in-world (up to 30s) +deadline = time.monotonic() + 30 +entered = False +while time.monotonic() < deadline: + try: + status = http_get(f"{API_BASE}/status", timeout=3) + if status.get("inWorld") and status.get("status") == "in_world": + print(f" Entered world after {30 - (deadline - time.monotonic()):.0f}s") + entered = True + break + except SystemExit: + raise + except Exception: + time.sleep(0.5) +if not entered: + proc.kill() + proc.wait() + fail("Headless client did not enter world within 30s") + + +# --------------------------------------------------------------------------- +# Step 4: Test /status endpoint +# --------------------------------------------------------------------------- +step("Test GET /status") + +status = http_get(f"{API_BASE}/status") +required_status_fields = ["inWorld", "status", "account", "character"] +for field in required_status_fields: + assert field in status, f"/status missing field {field}" +assert status["inWorld"] is True, f"Expected inWorld=true, got {status['inWorld']}" +assert status["status"] == "in_world", f"Expected status=in_world, got {status['status']}" +assert status["character"]["name"] == TEST_CHAR, f"Expected character name {TEST_CHAR}" +movement = status.get("movement", {}) +assert "active" in movement, "/status missing movement.active" +combat = status.get("combat", {}) +assert "inCombat" in combat, "/status missing combat.inCombat" +health = status.get("health", {}) +assert "isDead" in health, "/status missing health.isDead" +print(" OK") + + +# --------------------------------------------------------------------------- +# Step 5: Test /world/self endpoint +# --------------------------------------------------------------------------- +step("Test /world/self") + +world = http_get(f"{API_BASE}/world/self") +for field in ("position", "mapId", "orientation", "movementFlags", "movementFlags2", "character"): + assert field in world, f"/world/self missing field {field}" +pos = world["position"] +for axis in ("x", "y", "z"): + assert axis in pos, f"/world/self.position missing {axis}" +assert isinstance(world["mapId"], int) or isinstance(world["mapId"], float), "mapId must be numeric" +assert isinstance(world["orientation"], (int, float)), "orientation must be numeric" +print(f" OK: map={world['mapId']} pos=({pos['x']:.1f}, {pos['y']:.1f}, {pos['z']:.1f})") + + +# --------------------------------------------------------------------------- +# Step 6: Test /party endpoint +# --------------------------------------------------------------------------- +step("Test /party") + +party = http_get(f"{API_BASE}/party") +assert "leader" in party, "/party missing leader GUID" +assert "members" in party, "/party missing members list" +assert isinstance(party["members"], list), "/party.members must be a list" +print(f" OK: {len(party['members'])} party member(s)") + + +# --------------------------------------------------------------------------- +# Step 7: Test /chat endpoint +# --------------------------------------------------------------------------- +step("Test /chat") + +chat = http_get(f"{API_BASE}/chat?after=0&limit=5") +assert "messages" in chat, "/chat missing messages" +assert isinstance(chat["messages"], list), "/chat.messages must be a list" +print(f" OK: {len(chat['messages'])} message(s)") + + +# --------------------------------------------------------------------------- +if len(sys.argv) > 1 and sys.argv[1] == "--cleanup": + step("Cleanup test account") + print(f" {soap_cmd(f'account delete {TEST_ACCOUNT}')}") + sys.exit(0) + +# Step 8: Test /commands endpoint +# --------------------------------------------------------------------------- +step("Test /commands") + +cmd_result = http_post(f"{API_BASE}/commands", {"command": "/say Integration test!"}) +assert isinstance(cmd_result, dict), "POST /commands must return a dict" +print(f" OK: {cmd_result}") + + +# --------------------------------------------------------------------------- +# Step 9: Test /movement/goto and /movement/stop +# --------------------------------------------------------------------------- +step("Test /movement/goto and /movement/stop") + +goto_result = http_post(f"{API_BASE}/movement/goto", { + "mapId": 0, "x": 100.0, "y": -9500.0, "z": 50.0, + "arrivalRadius": 5.0, +}) +assert goto_result.get("ok"), f"/movement/goto failed: {goto_result}" +print(" Move command accepted") + +# Let it run a moment, then stop +time.sleep(1) +stop_result = http_post(f"{API_BASE}/movement/stop", {"reason": "integration test"}) +assert stop_result.get("ok") or stop_result.get("movement", {}).get("state") in ("idle", "stopped"), \ + f"/movement/stop failed: {stop_result}" +print(" Stop command accepted") + + +# --------------------------------------------------------------------------- +# Step 10: Verify movement state in /status after stop +# --------------------------------------------------------------------------- +step("Verify movement state after stop") + +status2 = http_get(f"{API_BASE}/status") +mv = status2.get("movement", {}) +assert not mv.get("active", True), "Movement should be inactive after stop" +assert mv.get("state") in ("idle", "stopped", "arrived", "failed"), \ + f"Unexpected movement state: {mv.get('state')}" +print(f" OK: movement state={mv.get('state')}") + + +# --------------------------------------------------------------------------- +# Done +# --------------------------------------------------------------------------- +print(f"\n{'='*60}") +print(f"All {test_step} steps passed!") + +# Graceful shutdown +print("\nShutting down headless client...") +proc.terminate() +try: + proc.wait(timeout=8) +except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + +normal_settings.unlink(missing_ok=True) +print("Cleanup done.") \ No newline at end of file diff --git a/tools/pathfinding_service/README.md b/tools/pathfinding_service/README.md new file mode 100644 index 000000000..22deb814a --- /dev/null +++ b/tools/pathfinding_service/README.md @@ -0,0 +1,157 @@ +# WoWee Pathfinding Service + +This is the first pathfinding service boundary for fleet automation. + +The MVP service provides the same HTTP contract that the fleet manager will use once a real CMaNGOS mmap/Detour backend exists. For now, the built-in `direct` backend returns a subdivided straight-line route. It is useful for testing transport, waypoint handoff, dashboard status, and automation scripts, but it does not avoid terrain, buildings, water, cliffs, or zone boundaries. + +## Run + +```bash +python tools/pathfinding_service/pathfinding_service.py --host 127.0.0.1 --port 8790 +``` + +Check health: + +```bash +curl http://127.0.0.1:8790/health +``` + +Ask for a path in WoWee canonical coordinates: + +```bash +curl -X POST http://127.0.0.1:8790/path ^ + -H "Content-Type: application/json" ^ + -d "{\"mapId\":0,\"start\":{\"x\":-8833,\"y\":626,\"z\":94},\"end\":{\"x\":-9465,\"y\":62,\"z\":56}}" +``` + +The response includes `waypoints` that can be posted directly to a headless client's `/movement/goto/waypoints` endpoint. + +Ask for an iterative route plan without moving anything: + +```bash +curl -X POST http://127.0.0.1:8790/route ^ + -H "Content-Type: application/json" ^ + -d "{\"mapId\":0,\"start\":{\"x\":620.3,\"y\":-8885.53,\"z\":95.49},\"end\":{\"x\":-834.06,\"y\":-5021.0,\"z\":495.32},\"maxLegs\":8,\"arrivalRadius\":5}" +``` + +`/route` repeatedly calls the configured path backend from the last reachable waypoint toward the destination. It returns `routeStatus`: + +- `complete`: the final waypoint is within `arrivalRadius`. +- `partial`: more work is needed but the planner has not declared a failure. +- `stalled`: a leg did not make enough progress. +- `max_legs`: the planner used all allowed legs before arriving. + +This is intentionally a planner contract, not a transport system. Cross-map travel, boats, zeppelins, trams, portals, and flight paths still need named travel nodes above this layer. + +## Fleet Manager Use + +Add a pathfinding block to `tools/bot_fleet_manager/fleet.settings.json`: + +```json +{ + "pathfinding": { + "enabled": true, + "baseUrl": "http://127.0.0.1:8790", + "coordinateSpace": "wowee-canonical" + } +} +``` + +Then run: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json pathfind-goto 0 -9465 62 56 +``` + +To inspect the same route planner without moving the leader: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json plan-route 0 -9465 62 56 +``` + +## Real CMaNGOS Backend Plan + +CMaNGOS already pathfinds through Recast/Detour mmaps. The durable backend should reuse that rather than inventing new map math in Python. + +Build the native helper from WoWee. By default CMake looks for CMaNGOS at `C:/Users/admin/code/mangos-tbc`; override that path with `-DWOWEE_MANGOS_TBC_DIR=...` if needed. + +```bash +cmake -S . -B build -G Ninja -DWOWEE_MANGOS_TBC_DIR=C:/Users/admin/code/mangos-tbc +cmake --build build --target mmap_path_query +``` + +The helper reads one JSON request on stdin and writes one JSON response on stdout. It needs a CMaNGOS-style `DataDir` containing `mmaps/`. + +```bash +echo "{\"mapId\":0,\"dataDir\":\"/opt/cmangos/data\",\"start\":{\"x\":-8833,\"y\":626,\"z\":94},\"end\":{\"x\":-9465,\"y\":62,\"z\":56}}" \ + | build/bin/mmap_path_query +``` + +The service can wrap that helper through the `external` backend: + +```bash +python tools/pathfinding_service/pathfinding_service.py \ + --backend external \ + --helper /opt/wowee/bin/mmap_path_query \ + --helper-timeout 30 \ + --cmangos-data-dir /opt/cmangos/data \ + --tile-mode bbox \ + --tile-margin 2 \ + --waypoint-step-yards 4 \ + --waypoint-arrival-radius 1.5 +``` + +Use `--tile-mode bbox --tile-margin 2` for normal service use. It loads only the tiles around the requested route and is much faster than `all`. Keep `--tile-mode all` as a diagnostic fallback if a route crosses a long distance or returns a suspicious partial/no-path result. + +Use `--waypoint-step-yards` to control how densely the mmap helper samples Detour's straight path. Smaller values produce more waypoints and better stair/bridge surface following, at the cost of more movement updates. The helper projects intermediate samples back onto the navmesh height where possible. `--waypoint-arrival-radius` is written into each returned waypoint so the headless mover does not skip dense samples when a fleet command uses a broader route arrival radius. + +This improves visual movement, but it is still only as good as the CMaNGOS mmap data. If the mmap considers a tree, building corner, or decorative bridge edge walkable, the service cannot infer a better path from navmesh data alone. + +When running the service on the CMaNGOS host, point the fleet manager at it: + +```json +{ + "pathfinding": { + "enabled": true, + "baseUrl": "http://CMANGOS_HOST:8790", + "coordinateSpace": "wowee-canonical", + "timeoutSeconds": 45.0 + } +} +``` + +The bbox helper backend is fast for nearby and short routes. Keep the service `--helper-timeout` and fleet `timeoutSeconds` comfortably above the observed query time so longer diagnostic `all` calls can still complete. + +The helper contract is JSON on stdin/stdout: + +Input: + +```json +{ + "mapId": 0, + "start": {"x": -8833.0, "y": 626.0, "z": 94.0}, + "end": {"x": -9465.0, "y": 62.0, "z": 56.0}, + "coordinateSpace": "wowee-canonical", + "outputSpace": "wowee-canonical", + "waypointStepYards": 4, + "waypointArrivalRadius": 1.5 +} +``` + +Output: + +```json +{ + "ok": true, + "backend": "cmangos-mmap", + "pathType": "navmesh", + "coordinateSpace": "wowee-canonical", + "mapId": 0, + "waypoints": [ + {"x": -9000.0, "y": 500.0, "z": 90.0}, + {"x": -9465.0, "y": 62.0, "z": 56.0} + ] +} +``` + +Coordinate reminder: WoWee APIs expose canonical coordinates where `x = north`, `y = west`, `z = up`. CMaNGOS server/wire coordinates use `x = west`, `y = north`, `z = up`, so the native helper should swap X/Y at the CMaNGOS boundary and return canonical waypoints to this service. diff --git a/tools/pathfinding_service/mmap_path_query.cpp b/tools/pathfinding_service/mmap_path_query.cpp new file mode 100644 index 000000000..df20268fb --- /dev/null +++ b/tools/pathfinding_service/mmap_path_query.cpp @@ -0,0 +1,615 @@ +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace { + +constexpr std::uint32_t MMAP_MAGIC = 0x4d4d4150; +constexpr std::uint32_t MMAP_VERSION = 8; +constexpr float SIZE_OF_GRIDS = 533.33333f; +constexpr int CENTER_GRID_ID = 32; +constexpr float CENTER_GRID_OFFSET = SIZE_OF_GRIDS / 2.0f; +constexpr int MAX_NUMBER_OF_GRIDS = 64; +constexpr int MAX_POLYS = 4096; +constexpr int MAX_POINTS = 4096; +constexpr unsigned short NAV_GROUND = 0x1; +constexpr unsigned short NAV_GROUND_STEEP = 0x2; +constexpr unsigned short NAV_WATER = 0x4; +constexpr unsigned short NAV_MAGMA_SLIME = 0x8; + +struct MmapTileHeader { + std::uint32_t mmapMagic; + std::uint32_t dtVersion; + std::uint32_t mmapVersion; + std::uint32_t size; + std::uint32_t usesLiquids; +}; + +struct Point { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; +}; + +struct LoadedTile { + dtTileRef ref = 0; +}; + +struct NavMeshDeleter { + void operator()(dtNavMesh* mesh) const { dtFreeNavMesh(mesh); } +}; + +struct QueryDeleter { + void operator()(dtNavMeshQuery* query) const { dtFreeNavMeshQuery(query); } +}; + +std::string readStdin() +{ + return std::string(std::istreambuf_iterator(std::cin), std::istreambuf_iterator()); +} + +void writeJson(json const& doc) +{ + std::cout << doc.dump() << "\n"; +} + +void fail(std::string const& message, json extra = {}) +{ + json out = { + {"ok", false}, + {"error", message}, + {"backend", "cmangos-mmap"} + }; + for (auto& item : extra.items()) + out[item.key()] = item.value(); + writeJson(out); + std::exit(1); +} + +float number(json const& doc, char const* key) +{ + if (!doc.contains(key) || !doc[key].is_number()) + fail(std::string("missing numeric field: ") + key); + return doc[key].get(); +} + +Point point(json const& doc, char const* key) +{ + if (!doc.contains(key) || !doc[key].is_object()) + fail(std::string("missing object field: ") + key); + auto const& p = doc[key]; + return Point{number(p, "x"), number(p, "y"), number(p, "z")}; +} + +float distance(Point const& a, Point const& b) +{ + float dx = b.x - a.x; + float dy = b.y - a.y; + float dz = b.z - a.z; + return std::sqrt(dx * dx + dy * dy + dz * dz); +} + +Point lerp(Point const& a, Point const& b, float t) +{ + return Point{ + a.x + (b.x - a.x) * t, + a.y + (b.y - a.y) * t, + a.z + (b.z - a.z) * t, + }; +} + +std::filesystem::path dataDir(json const& request) +{ + if (request.contains("dataDir") && request["dataDir"].is_string()) + { + std::string value = request["dataDir"].get(); + if (!value.empty()) + return value; + } + + if (char const* env = std::getenv("WOWEE_CMANGOS_DATA_DIR")) + return env; + + fail("missing dataDir; provide request.dataDir or WOWEE_CMANGOS_DATA_DIR"); + return {}; +} + +int generatorTileCoord(float detourAxisValue) +{ + // MapBuilder::getTileBounds writes tiles where: + // bmax = (32 - tile) * SIZE_OF_GRIDS + // bmin = bmax - SIZE_OF_GRIDS + // So invert that interval test for a Detour X/Z axis value. + int coord = int(std::floor(32.0f - (detourAxisValue / SIZE_OF_GRIDS))); + return std::clamp(coord, 0, MAX_NUMBER_OF_GRIDS - 1); +} + +std::filesystem::path mapPath(std::filesystem::path const& base, int mapId) +{ + char name[32]; + std::snprintf(name, sizeof(name), "%03d.mmap", mapId); + return base / "mmaps" / name; +} + +std::filesystem::path tilePath(std::filesystem::path const& base, int mapId, int x, int y) +{ + char name[32]; + std::snprintf(name, sizeof(name), "%03d%02d%02d.mmtile", mapId, x, y); + return base / "mmaps" / name; +} + +std::unique_ptr loadNavMesh(std::filesystem::path const& base, int mapId) +{ + auto path = mapPath(base, mapId); + std::ifstream file(path, std::ios::binary); + if (!file) + fail("could not open mmap file", {{"path", path.string()}}); + + dtNavMeshParams params{}; + file.read(reinterpret_cast(¶ms), sizeof(params)); + if (!file) + fail("could not read mmap params", {{"path", path.string()}}); + + std::unique_ptr mesh(dtAllocNavMesh()); + if (!mesh) + fail("dtAllocNavMesh failed"); + + dtStatus status = mesh->init(¶ms); + if (dtStatusFailed(status)) + fail("dtNavMesh init failed", {{"status", status}}); + + return mesh; +} + +bool loadTile(std::filesystem::path const& path, dtNavMesh& mesh, std::vector& loaded) +{ + std::ifstream file(path, std::ios::binary); + if (!file) + return false; + + MmapTileHeader header{}; + file.read(reinterpret_cast(&header), sizeof(header)); + if (!file) + fail("could not read mmtile header", {{"path", path.string()}}); + if (header.mmapMagic != MMAP_MAGIC) + fail("bad mmtile magic", {{"path", path.string()}}); + if (header.dtVersion != DT_NAVMESH_VERSION) + fail("mmtile Detour version mismatch", {{"path", path.string()}, {"fileVersion", header.dtVersion}, {"expectedVersion", DT_NAVMESH_VERSION}}); + if (header.mmapVersion != MMAP_VERSION) + fail("mmtile mmap version mismatch", {{"path", path.string()}, {"fileVersion", header.mmapVersion}, {"expectedVersion", MMAP_VERSION}}); + + unsigned char* data = static_cast(dtAlloc(header.size, DT_ALLOC_PERM)); + if (!data) + fail("dtAlloc tile data failed", {{"path", path.string()}, {"size", header.size}}); + + file.read(reinterpret_cast(data), header.size); + if (!file) + { + dtFree(data); + fail("could not read mmtile data", {{"path", path.string()}}); + } + + dtTileRef tileRef = 0; + dtStatus status = mesh.addTile(data, header.size, DT_TILE_FREE_DATA, 0, &tileRef); + if (dtStatusFailed(status)) + { + dtFree(data); + fail("could not add mmtile to navmesh", {{"path", path.string()}, {"status", status}}); + } + loaded.push_back(LoadedTile{tileRef}); + return true; +} + +int loadAllTiles(std::filesystem::path const& base, int mapId, dtNavMesh& mesh, std::vector& loaded) +{ + auto dir = base / "mmaps"; + if (!std::filesystem::is_directory(dir)) + fail("mmaps directory does not exist", {{"path", dir.string()}}); + + char prefix[8]; + std::snprintf(prefix, sizeof(prefix), "%03d", mapId); + + int count = 0; + for (auto const& entry : std::filesystem::directory_iterator(dir)) + { + if (!entry.is_regular_file() || entry.path().extension() != ".mmtile") + continue; + std::string name = entry.path().filename().string(); + if (name.rfind(prefix, 0) != 0) + continue; + if (loadTile(entry.path(), mesh, loaded)) + ++count; + } + return count; +} + +int loadBboxTiles(std::filesystem::path const& base, int mapId, Point start, Point end, int margin, dtNavMesh& mesh, std::vector& loaded) +{ + // Filename order follows MapBuilder output: + // %03u%02i%02i.mmtile => mapId, tileY, tileX + // The tile bounds themselves are on Detour's X/Z plane, where WoWee + // canonical x maps to Detour X and canonical y maps to Detour Z. + int sx = generatorTileCoord(start.x); + int sy = generatorTileCoord(start.y); + int ex = generatorTileCoord(end.x); + int ey = generatorTileCoord(end.y); + + int minX = std::clamp(std::min(sx, ex) - margin, 0, MAX_NUMBER_OF_GRIDS - 1); + int maxX = std::clamp(std::max(sx, ex) + margin, 0, MAX_NUMBER_OF_GRIDS - 1); + int minY = std::clamp(std::min(sy, ey) - margin, 0, MAX_NUMBER_OF_GRIDS - 1); + int maxY = std::clamp(std::max(sy, ey) + margin, 0, MAX_NUMBER_OF_GRIDS - 1); + + int count = 0; + for (int tileX = minX; tileX <= maxX; ++tileX) + for (int tileY = minY; tileY <= maxY; ++tileY) + if (loadTile(tilePath(base, mapId, tileY, tileX), mesh, loaded)) + ++count; + return count; +} + +void toDetour(Point p, float out[3]) +{ + // WoWee canonical: x=north, y=west, z=up. + // CMaNGOS server: x=west, y=north, z=up. + // Detour in CMaNGOS PathFinder: {serverY, z, serverX}. + out[0] = p.x; + out[1] = p.z; + out[2] = p.y; +} + +Point fromDetour(float const* p) +{ + return Point{p[0], p[2], p[1]}; +} + +bool projectToNavmeshHeight(dtNavMeshQuery& query, dtQueryFilter const& filter, float const extents[3], Point const& input, Point& output) +{ + float detour[3]; + toDetour(input, detour); + + dtPolyRef ref = 0; + float nearest[3]; + dtStatus nearestStatus = query.findNearestPoly(detour, extents, &filter, &ref, nearest); + if (dtStatusFailed(nearestStatus) || !ref) + { + output = input; + return false; + } + + float height = 0.0f; + dtStatus heightStatus = query.getPolyHeight(ref, nearest, &height); + if (dtStatusFailed(heightStatus)) + { + output = fromDetour(nearest); + return false; + } + + nearest[1] = height; + output = fromDetour(nearest); + return true; +} + +std::vector densifyWaypoints(dtNavMeshQuery& query, dtQueryFilter const& filter, float const extents[3], + std::vector const& points, float stepYards, int& projectedCount) +{ + std::vector dense; + projectedCount = 0; + if (points.empty()) + return dense; + + stepYards = std::max(1.0f, stepYards); + dense.reserve(points.size() * 4); + dense.push_back(points.front()); + + for (std::size_t i = 1; i < points.size(); ++i) + { + Point const& a = points[i - 1]; + Point const& b = points[i]; + int segments = std::max(1, int(std::ceil(distance(a, b) / stepYards))); + for (int s = 1; s <= segments; ++s) + { + Point sample = lerp(a, b, float(s) / float(segments)); + Point projected; + if (projectToNavmeshHeight(query, filter, extents, sample, projected)) + ++projectedCount; + else + projected = sample; + + if (!dense.empty() && distance(dense.back(), projected) < 0.05f) + continue; + dense.push_back(projected); + } + } + + return dense; +} + +json pathTypeName(dtStatus pathStatus, int polyCount, dtPolyRef endRef, dtPolyRef lastRef) +{ + if (polyCount == 0) + return "nopollies"; + if (dtStatusFailed(pathStatus)) + return "failed"; + if (lastRef != endRef) + return "partial"; + return "navmesh"; +} + +json navMeshBounds(dtNavMesh const& mesh) +{ + json tiles = json::array(); + float globalMin[3] = { + std::numeric_limits::max(), + std::numeric_limits::max(), + std::numeric_limits::max(), + }; + float globalMax[3] = { + -std::numeric_limits::max(), + -std::numeric_limits::max(), + -std::numeric_limits::max(), + }; + + for (int i = 0; i < mesh.getMaxTiles(); ++i) + { + dtMeshTile const* tile = mesh.getTile(i); + if (!tile || !tile->header) + continue; + for (int axis = 0; axis < 3; ++axis) + { + globalMin[axis] = std::min(globalMin[axis], tile->header->bmin[axis]); + globalMax[axis] = std::max(globalMax[axis], tile->header->bmax[axis]); + } + if (tiles.size() < 12) + { + tiles.push_back({ + {"x", tile->header->x}, + {"y", tile->header->y}, + {"bmin", {tile->header->bmin[0], tile->header->bmin[1], tile->header->bmin[2]}}, + {"bmax", {tile->header->bmax[0], tile->header->bmax[1], tile->header->bmax[2]}}, + }); + } + } + + float const* orig = mesh.getParams()->orig; + return { + {"orig", {orig[0], orig[1], orig[2]}}, + {"detourMin", {globalMin[0], globalMin[1], globalMin[2]}}, + {"detourMax", {globalMax[0], globalMax[1], globalMax[2]}}, + {"sampleTiles", tiles}, + }; +} + +std::vector smoothWaypoints(std::vector const& points, float zWeight, float zMinChange) +{ + if (points.size() <= 2) + return points; + + std::vector result; + result.reserve(points.size()); + result.push_back(points.front()); + + float prevSmoothedZ = points[0].z; + + for (std::size_t i = 1; i + 1 < points.size(); ++i) + { + Point const& prev = points[i - 1]; + Point const& curr = points[i]; + Point const& next = points[i + 1]; + + Point s = curr; + + float hDistNext = std::sqrt( + (next.x - prev.x) * (next.x - prev.x) + + (next.y - prev.y) * (next.y - prev.y) + ); + + float rawZ = curr.z; + + if (hDistNext > 0.5f) + { + float zChangePerYard = std::abs(rawZ - prev.z) / hDistNext; + if (zChangePerYard < zMinChange) + { + prevSmoothedZ = prevSmoothedZ + zWeight * (rawZ - prevSmoothedZ); + } + else + { + prevSmoothedZ = rawZ; + } + } + else + { + prevSmoothedZ = prevSmoothedZ + zWeight * (rawZ - prevSmoothedZ); + } + + s.z = prevSmoothedZ; + + float smoothX = 0.333f * prev.x + 0.334f * curr.x + 0.333f * next.x; + float smoothY = 0.333f * prev.y + 0.334f * curr.y + 0.333f * next.y; + + if (i == 1 || distance(result.back(), s) >= 0.05f) + result.push_back(s); + } + + result.push_back(points.back()); + return result; +} + +} // namespace + +int main() +{ + try + { + json request = json::parse(readStdin()); + int mapId = int(number(request, "mapId")); + Point start = point(request, "start"); + Point end = point(request, "end"); + std::filesystem::path base = dataDir(request); + std::string tileMode = request.value("tileMode", "all"); + int tileMargin = request.value("tileMargin", 2); + + auto mesh = loadNavMesh(base, mapId); + std::vector loadedTiles; + int loadedCount = tileMode == "bbox" + ? loadBboxTiles(base, mapId, start, end, tileMargin, *mesh, loadedTiles) + : loadAllTiles(base, mapId, *mesh, loadedTiles); + if (loadedCount == 0) + fail("no mmtile files loaded", {{"mapId", mapId}, {"dataDir", base.string()}, {"tileMode", tileMode}}); + + if (request.value("debugMode", std::string()) == "bounds") + { + json bounds = navMeshBounds(*mesh); + bounds["ok"] = true; + bounds["backend"] = "cmangos-mmap"; + bounds["pathType"] = "debug-bounds"; + bounds["mapId"] = mapId; + bounds["loadedTiles"] = loadedCount; + writeJson(bounds); + return 0; + } + + std::unique_ptr query(dtAllocNavMeshQuery()); + if (!query) + fail("dtAllocNavMeshQuery failed"); + dtStatus initStatus = query->init(mesh.get(), 8192); + if (dtStatusFailed(initStatus)) + fail("dtNavMeshQuery init failed", {{"status", initStatus}}); + + float startPt[3]; + float endPt[3]; + toDetour(start, startPt); + toDetour(end, endPt); + + float extents[3] = { + request.value("polySearchX", 10.0f), + request.value("polySearchZ", 10.0f), + request.value("polySearchY", 10.0f), + }; + dtQueryFilter filter; + filter.setIncludeFlags(NAV_GROUND | NAV_WATER); + filter.setExcludeFlags(NAV_MAGMA_SLIME | NAV_GROUND_STEEP); + filter.setAreaCost(9, 20.0f); + filter.setAreaCost(12, 5.0f); + filter.setAreaCost(13, 20.0f); + + dtPolyRef startRef = 0; + dtPolyRef endRef = 0; + float nearestStart[3]; + float nearestEnd[3]; + dtStatus startStatus = query->findNearestPoly(startPt, extents, &filter, &startRef, nearestStart); + dtStatus endStatus = query->findNearestPoly(endPt, extents, &filter, &endRef, nearestEnd); + if (dtStatusFailed(startStatus) || !startRef) + fail("could not find nearest start polygon", { + {"status", startStatus}, + {"loadedTiles", loadedCount}, + {"detourStart", {startPt[0], startPt[1], startPt[2]}}, + {"searchExtents", {extents[0], extents[1], extents[2]}}, + }); + if (dtStatusFailed(endStatus) || !endRef) + fail("could not find nearest end polygon", { + {"status", endStatus}, + {"loadedTiles", loadedCount}, + {"detourEnd", {endPt[0], endPt[1], endPt[2]}}, + {"searchExtents", {extents[0], extents[1], extents[2]}}, + }); + + std::vector polys(MAX_POLYS); + int polyCount = 0; + dtStatus pathStatus = query->findPath(startRef, endRef, nearestStart, nearestEnd, &filter, polys.data(), &polyCount, MAX_POLYS); + if (dtStatusFailed(pathStatus) || polyCount <= 0) + fail("findPath failed", {{"status", pathStatus}, {"loadedTiles", loadedCount}}); + + std::vector straight(MAX_POINTS * 3); + std::vector straightFlags(MAX_POINTS); + std::vector straightRefs(MAX_POINTS); + int pointCount = 0; + dtStatus straightStatus = query->findStraightPath( + nearestStart, + nearestEnd, + polys.data(), + polyCount, + straight.data(), + straightFlags.data(), + straightRefs.data(), + &pointCount, + MAX_POINTS + ); + if (dtStatusFailed(straightStatus) || pointCount <= 0) + fail("findStraightPath failed", {{"status", straightStatus}, {"polyCount", polyCount}, {"loadedTiles", loadedCount}}); + + float waypointStepYards = request.value("waypointStepYards", 4.0f); + if (!std::isfinite(waypointStepYards) || waypointStepYards <= 0.0f) + waypointStepYards = 4.0f; + waypointStepYards = std::clamp(waypointStepYards, 1.0f, 40.0f); + + std::vector straightPoints; + straightPoints.reserve(pointCount); + for (int i = 0; i < pointCount; ++i) + { + Point p = fromDetour(&straight[i * 3]); + straightPoints.push_back(p); + } + + int projectedSamples = 0; + std::vector densePoints = densifyWaypoints(*query, filter, extents, straightPoints, waypointStepYards, projectedSamples); + + float smoothZWeight = request.value("smoothZWeight", 0.15f); + float smoothZMinChange = request.value("smoothZMinChange", 0.6f); + if (std::isfinite(smoothZWeight) && smoothZWeight > 0.0f && densePoints.size() > 2) + { + densePoints = smoothWaypoints(densePoints, smoothZWeight, smoothZMinChange); + } + + json waypoints = json::array(); + float waypointArrivalRadius = request.value("waypointArrivalRadius", 1.5f); + if (!std::isfinite(waypointArrivalRadius) || waypointArrivalRadius <= 0.0f) + waypointArrivalRadius = 1.5f; + waypointArrivalRadius = std::clamp(waypointArrivalRadius, 0.5f, 5.0f); + + for (Point const& p : densePoints) + { + waypoints.push_back({{"x", p.x}, {"y", p.y}, {"z", p.z}, {"arrivalRadius", waypointArrivalRadius}}); + } + + json out = { + {"ok", true}, + {"backend", "cmangos-mmap"}, + {"pathType", pathTypeName(pathStatus, polyCount, endRef, polys[polyCount - 1])}, + {"coordinateSpace", "wowee-canonical"}, + {"mapId", mapId}, + {"loadedTiles", loadedCount}, + {"polyCount", polyCount}, + {"straightPointCount", pointCount}, + {"waypointStepYards", waypointStepYards}, + {"waypointArrivalRadius", waypointArrivalRadius}, + {"projectedSamples", projectedSamples}, + {"smoothZWeight", smoothZWeight}, + {"smoothZMinChange", smoothZMinChange}, + {"waypoints", waypoints}, + }; + writeJson(out); + return 0; + } + catch (std::exception const& exc) + { + fail(std::string("unhandled exception: ") + exc.what()); + } +} diff --git a/tools/pathfinding_service/pathfinding_service.py b/tools/pathfinding_service/pathfinding_service.py new file mode 100644 index 000000000..d4d790521 --- /dev/null +++ b/tools/pathfinding_service/pathfinding_service.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +"""Small HTTP pathfinding service boundary for WoWee fleet automation.""" + +from __future__ import annotations + +import argparse +import json +import math +import subprocess +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any +from urllib.parse import urlparse + + +Point = dict[str, float] + + +def point_distance(a: Point, b: Point) -> float: + dx = b["x"] - a["x"] + dy = b["y"] - a["y"] + dz = b["z"] - a["z"] + return math.sqrt(dx * dx + dy * dy + dz * dz) + + +def json_response(handler: BaseHTTPRequestHandler, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) + handler.send_header("Access-Control-Allow-Origin", "*") + handler.end_headers() + handler.wfile.write(body) + + +def read_json(handler: BaseHTTPRequestHandler) -> dict[str, Any]: + length = int(handler.headers.get("Content-Length", "0")) + if length <= 0: + return {} + raw = handler.rfile.read(length) + return json.loads(raw.decode("utf-8")) + + +def require_number(doc: dict[str, Any], key: str) -> float: + value = doc.get(key) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"missing numeric field: {key}") + if not math.isfinite(float(value)): + raise ValueError(f"non-finite numeric field: {key}") + return float(value) + + +def parse_point(doc: Any, name: str) -> Point: + if not isinstance(doc, dict): + raise ValueError(f"{name} must be an object") + return { + "x": require_number(doc, "x"), + "y": require_number(doc, "y"), + "z": require_number(doc, "z"), + } + + +def lerp(a: float, b: float, t: float) -> float: + return a + (b - a) * t + + +def direct_path(start: Point, end: Point, step_yards: float) -> list[Point]: + dx = end["x"] - start["x"] + dy = end["y"] - start["y"] + dz = end["z"] - start["z"] + distance = math.sqrt(dx * dx + dy * dy + dz * dz) + if distance <= 0.001: + return [dict(end)] + segments = max(1, int(math.ceil(distance / max(1.0, step_yards)))) + return [ + { + "x": lerp(start["x"], end["x"], i / segments), + "y": lerp(start["y"], end["y"], i / segments), + "z": lerp(start["z"], end["z"], i / segments), + } + for i in range(1, segments + 1) + ] + + +def densify_path(waypoints: list[Point], step_yards: float) -> list[Point]: + if not waypoints: + return [] + step_yards = max(1.0, step_yards) + dense: list[Point] = [] + previous: Point | None = None + for waypoint in waypoints: + current = dict(waypoint) + if previous is None: + dense.append(current) + previous = current + continue + distance = point_distance(previous, current) + segments = max(1, int(math.ceil(distance / step_yards))) + for i in range(1, segments + 1): + t = i / segments + dense.append({ + "x": lerp(previous["x"], current["x"], t), + "y": lerp(previous["y"], current["y"], t), + "z": lerp(previous["z"], current["z"], t), + }) + previous = current + return dense + + +class PathService: + def __init__( + self, + backend: str, + helper: str | None, + helper_timeout: float, + direct_step_yards: float, + waypoint_step_yards: float, + waypoint_arrival_radius: float, + cmangos_data_dir: str | None, + tile_mode: str, + tile_margin: int, + smooth_z_weight: float = 0.15, + smooth_z_min_change: float = 0.6, + ): + self.backend = backend + self.helper = helper + self.helper_timeout = helper_timeout + self.direct_step_yards = direct_step_yards + self.waypoint_step_yards = waypoint_step_yards + self.waypoint_arrival_radius = waypoint_arrival_radius + self.cmangos_data_dir = cmangos_data_dir + self.tile_mode = tile_mode + self.tile_margin = tile_margin + self.smooth_z_weight = smooth_z_weight + self.smooth_z_min_change = smooth_z_min_change + + def health(self) -> dict[str, Any]: + return { + "ok": True, + "backend": self.backend, + "helperConfigured": bool(self.helper), + "cmangosDataDirConfigured": bool(self.cmangos_data_dir), + "tileMode": self.tile_mode, + "tileMargin": self.tile_margin, + "directStepYards": self.direct_step_yards, + "waypointStepYards": self.waypoint_step_yards, + "waypointArrivalRadius": self.waypoint_arrival_radius, + "smoothZWeight": self.smooth_z_weight, + "smoothZMinChange": self.smooth_z_min_change, + } + + def path(self, request: dict[str, Any]) -> tuple[int, dict[str, Any]]: + try: + map_id, start, end = self.parse_path_request(request) + except ValueError as exc: + return 400, {"ok": False, "error": str(exc)} + + return self.find_path(map_id, start, end) + + def route(self, request: dict[str, Any]) -> tuple[int, dict[str, Any]]: + try: + map_id, start, end = self.parse_path_request(request) + except ValueError as exc: + return 400, {"ok": False, "error": str(exc)} + try: + max_legs = int(request.get("maxLegs", 8)) + arrival_radius = float(request.get("arrivalRadius", 5.0)) + min_progress_yards = float(request.get("minProgressYards", 15.0)) + if max_legs < 1 or max_legs > 64: + raise ValueError("maxLegs must be between 1 and 64") + if arrival_radius < 0.1: + raise ValueError("arrivalRadius must be at least 0.1") + if min_progress_yards < 0.1: + raise ValueError("minProgressYards must be at least 0.1") + except (TypeError, ValueError) as exc: + return 400, {"ok": False, "error": str(exc)} + + legs: list[dict[str, Any]] = [] + combined: list[Point] = [] + current = dict(start) + status = "partial" + warnings: list[str] = [] + + for leg_index in range(max_legs): + before_goal = point_distance(current, end) + if before_goal <= arrival_radius: + status = "complete" + break + + code, result = self.find_path(map_id, current, end) + if code != 200 or not result.get("ok", False): + result.setdefault("ok", False) + result.setdefault("legIndex", leg_index) + return code, result + + waypoints = result.get("waypoints", []) + if not isinstance(waypoints, list) or not waypoints: + warnings.append(f"leg {leg_index + 1}: pathfinding returned no waypoints") + status = "stalled" + break + + last = parse_point(waypoints[-1], "last waypoint") + after_goal = point_distance(last, end) + progress = before_goal - after_goal + leg = { + "index": leg_index + 1, + "start": current, + "end": last, + "requestedEnd": end, + "pathType": result.get("pathType", "unknown"), + "backend": result.get("backend", "unknown"), + "waypointCount": len(waypoints), + "loadedTiles": result.get("loadedTiles"), + "polyCount": result.get("polyCount"), + "distanceToGoalBefore": before_goal, + "distanceToGoalAfter": after_goal, + "progressYards": progress, + } + legs.append(leg) + + for waypoint in waypoints: + point = parse_point(waypoint, "waypoint") + if combined and point_distance(combined[-1], point) < 0.01: + continue + combined.append(point) + + current = last + if after_goal <= arrival_radius: + status = "complete" + break + if progress < min_progress_yards: + warnings.append( + f"leg {leg_index + 1}: progress {progress:.2f} yards is below minProgressYards" + ) + status = "stalled" + break + + if status != "complete" and len(legs) >= max_legs: + status = "max_legs" + + return 200, { + "ok": True, + "backend": self.backend if self.backend != "external" else "cmangos-mmap", + "routeStatus": status, + "coordinateSpace": "wowee-canonical", + "mapId": map_id, + "start": start, + "end": end, + "arrivalRadius": arrival_radius, + "legs": legs, + "waypoints": combined, + "warnings": warnings, + } + + def parse_path_request(self, request: dict[str, Any]) -> tuple[int, Point, Point]: + map_id = int(require_number(request, "mapId")) + start = parse_point(request.get("start"), "start") + end = parse_point(request.get("end"), "end") + coordinate_space = str(request.get("coordinateSpace", "wowee-canonical")) + output_space = str(request.get("outputSpace", coordinate_space)) + if coordinate_space != "wowee-canonical" or output_space != "wowee-canonical": + raise ValueError("only wowee-canonical coordinates are supported by this service boundary") + return map_id, start, end + + def find_path(self, map_id: int, start: Point, end: Point) -> tuple[int, dict[str, Any]]: + if self.backend == "direct": + waypoints = direct_path(start, end, self.direct_step_yards) + return 200, { + "ok": True, + "backend": "direct", + "pathType": "direct", + "coordinateSpace": "wowee-canonical", + "mapId": map_id, + "waypoints": waypoints, + "warnings": ["direct backend does not avoid terrain or collision"], + } + + if self.backend == "external": + return self.external_path({ + "mapId": map_id, + "start": start, + "end": end, + "coordinateSpace": "wowee-canonical", + "outputSpace": "wowee-canonical", + "dataDir": self.cmangos_data_dir, + "tileMode": self.tile_mode, + "tileMargin": self.tile_margin, + "waypointStepYards": self.waypoint_step_yards, + "waypointArrivalRadius": self.waypoint_arrival_radius, + "smoothZWeight": self.smooth_z_weight, + "smoothZMinChange": self.smooth_z_min_change, + }) + + return 501, { + "ok": False, + "error": f"backend is not implemented: {self.backend}", + "backend": self.backend, + } + + def external_path(self, request: dict[str, Any]) -> tuple[int, dict[str, Any]]: + if not self.helper: + return 500, {"ok": False, "error": "external backend requires --helper"} + try: + proc = subprocess.run( + [self.helper], + input=json.dumps(request), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=self.helper_timeout, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return 502, {"ok": False, "error": f"path helper failed: {exc}"} + try: + payload = json.loads(proc.stdout) + except json.JSONDecodeError as exc: + if proc.returncode != 0: + return 502, { + "ok": False, + "error": f"path helper exited {proc.returncode}", + "stderr": proc.stderr[-2000:], + } + return 502, {"ok": False, "error": f"path helper returned invalid JSON: {exc}"} + if proc.returncode != 0: + payload.setdefault("ok", False) + payload.setdefault("error", f"path helper exited {proc.returncode}") + payload.setdefault("stderr", proc.stderr[-2000:]) + return 502, payload + status = 200 if payload.get("ok", False) else 502 + return status, payload + + +def make_handler(service: PathService) -> type[BaseHTTPRequestHandler]: + class Handler(BaseHTTPRequestHandler): + server_version = "WoWeePathService/0.1" + + def log_message(self, fmt: str, *args: Any) -> None: + print(f"{self.address_string()} - {fmt % args}", file=sys.stderr) + + def do_OPTIONS(self) -> None: + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") + self.end_headers() + + def do_GET(self) -> None: + path = urlparse(self.path).path + if path == "/health": + json_response(self, 200, service.health()) + return + json_response(self, 404, {"ok": False, "error": "not found"}) + + def do_POST(self) -> None: + path = urlparse(self.path).path + if path not in ("/path", "/route"): + json_response(self, 404, {"ok": False, "error": "not found"}) + return + try: + request = read_json(self) + except json.JSONDecodeError as exc: + json_response(self, 400, {"ok": False, "error": f"invalid JSON: {exc}"}) + return + if path == "/route": + status, payload = service.route(request) + else: + status, payload = service.path(request) + json_response(self, status, payload) + + return Handler + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8790) + parser.add_argument("--backend", choices=("direct", "external", "cmangos-mmap"), default="direct") + parser.add_argument("--helper", default="", help="External path helper executable for --backend external") + parser.add_argument("--helper-timeout", type=float, default=5.0) + parser.add_argument("--direct-step-yards", type=float, default=35.0) + parser.add_argument("--waypoint-step-yards", type=float, default=4.0, help="Maximum spacing for mmap helper output waypoints") + parser.add_argument("--waypoint-arrival-radius", type=float, default=1.5, help="Per-waypoint arrival radius for mmap helper output") + parser.add_argument("--cmangos-data-dir", default="", help="CMaNGOS DataDir containing mmaps/ for the external mmap helper") + parser.add_argument("--tile-mode", choices=("all", "bbox"), default="all", help="Tile loading mode passed to the external mmap helper") + parser.add_argument("--tile-margin", type=int, default=2, help="Extra grid margin when --tile-mode=bbox") + parser.add_argument("--smooth-z-weight", type=float, default=0.15, help="Z low-pass filter weight (0=no smoothing, 1=no filter)") + parser.add_argument("--smooth-z-min-change", type=float, default=0.6, help="Minimum Z change per horizontal yard to preserve (stairs/ramps)") + args = parser.parse_args(argv) + + service = PathService( + args.backend, + args.helper, + args.helper_timeout, + args.direct_step_yards, + args.waypoint_step_yards, + args.waypoint_arrival_radius, + args.cmangos_data_dir, + args.tile_mode, + args.tile_margin, + smooth_z_weight=args.smooth_z_weight, + smooth_z_min_change=args.smooth_z_min_change, + ) + server = ThreadingHTTPServer((args.host, args.port), make_handler(service)) + print(f"Pathfinding service listening on http://{args.host}:{args.port} backend={args.backend}") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nPathfinding service stopping") + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/pathfinding_service/test_route_coverage.py b/tools/pathfinding_service/test_route_coverage.py new file mode 100644 index 000000000..196c1259d --- /dev/null +++ b/tools/pathfinding_service/test_route_coverage.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Test bbox route coverage across landmarks, continents, and terrain types. + +Usage: + python tools/pathfinding_service/test_route_coverage.py +""" + +import json +import sys +import urllib.error +import urllib.request +import time + +LANDMARKS: dict[str, dict] = { + "goldshire": {"mapId": 0, "x": 63.52, "y": -9480.09, "z": 56.18, "zone": "Elwynn"}, + "stormwind": {"mapId": 0, "x": 364.06, "y": -9153.77, "z": 90.48, "zone": "Stormwind"}, + "dwarven-district": {"mapId": 0, "x": 514.03, "y": -8346.46, "z": 92.02, "zone": "Stormwind/Dwarven"}, + "kharanos": {"mapId": 0, "x": -482.13, "y": -5585.95, "z": 397.02, "zone": "Dun Morogh"}, + "ironforge": {"mapId": 0, "x": -834.06, "y": -5021.00, "z": 495.32, "zone": "Ironforge"}, + "thelsamar": {"mapId": 0, "x": -1704.45, "y": -5379.21, "z": 368.82, "zone": "Loch Modan"}, + "menethil": {"mapId": 0, "x": -3703.86, "y": -4875.27, "z": 37.82, "zone": "Wetlands"}, + "southshore": {"mapId": 0, "x": -770.09, "y": -3581.72, "z": 43.60, "zone": "Hillsbrad"}, + "tarren-mill": {"mapId": 0, "x": -20.43, "y": -3282.22, "z": 96.25, "zone": "Hillsbrad"}, + "undercity": {"mapId": 0, "x": 1584.07, "y": -2209.64, "z": 90.73, "zone": "Tirisfal"}, + "brill": {"mapId": 0, "x": 1110.07, "y": -2868.17, "z": 91.42, "zone": "Tirisfal"}, + "lakeshire": {"mapId": 0, "x": -2277.58, "y": -11338.74, "z": 31.47, "zone": "Redridge"}, + "darkshire": {"mapId": 0, "x": -10229.62, "y": -10601.33, "z": 31.33, "zone": "Duskwood"}, + "westfall": {"mapId": 0, "x": -11481.84, "y": -8777.82, "z": 9.72, "zone": "Westfall"}, + "sentinel-hill": {"mapId": 0, "x": -10455.47, "y": -8323.00, "z": 33.54, "zone": "Westfall"}, + "booty-bay": {"mapId": 0, "x": -14421.82, "y": -1409.11, "z": 2.90, "zone": "Stranglethorn"}, + "orggrimmar": {"mapId": 1, "x": -3313.42, "y": -4409.50, "z": 97.82, "zone": "Orgrimmar/Durotar"}, + "crossroads": {"mapId": 1, "x": -2652.15, "y": -455.90, "z": 95.59, "zone": "Barrens"}, + "ratchet": {"mapId": 1, "x": -3680.07, "y": -951.36, "z": 8.04, "zone": "Barrens"}, + "thunder-bluff": {"mapId": 1, "x": -4225.87, "y": 729.20, "z": 134.49, "zone": "Mulgore"}, + "exodar": {"mapId": 530, "x": -11873.60, "y": -4007.31, "z": -0.55, "zone": "Azuremyst"}, + "azure-watch": {"mapId": 530, "x": -12501.5, "y": -4179.47, "z": 50.06, "zone": "Azuremyst"}, + "hellfire-pen": {"mapId": 530, "x": -1262.26, "y": -1777.55, "z": 69.79, "zone": "Hellfire"}, + "zangarmarsh": {"mapId": 530, "x": 3221.43, "y": -1065.20, "z": 5.06, "zone": "Zangarmarsh"}, + "nagrand": {"mapId": 530, "x": 5236.64, "y": -757.88, "z": 177.65, "zone": "Nagrand"}, + "terokkar": {"mapId": 530, "x": -3965.57, "y": 727.16, "z": 33.56, "zone": "Terokkar"}, + "shadowmoon": {"mapId": 530, "x": -4526.35, "y": 3359.72, "z": 10.52, "zone": "Shadowmoon"}, +} + + +def test_route(base: str, start_name: str, end_name: str) -> dict: + start = LANDMARKS[start_name] + end = LANDMARKS[end_name] + if start["mapId"] != end["mapId"]: + return {"status": "SKIP", "reason": "different maps"} + + payload = { + "mapId": start["mapId"], + "start": {"x": start["x"], "y": start["y"], "z": start["z"]}, + "end": {"x": end["x"], "y": end["y"], "z": end["z"]}, + "coordinateSpace": "wowee-canonical", + "outputSpace": "wowee-canonical", + "arrivalRadius": 5.0, + "maxLegs": 4, + "minProgressYards": 15.0, + "tileMode": "bbox", + "tileMargin": 2, + } + url = base.rstrip("/") + "/route" + if not url.startswith(("http://", "https://")): + raise ValueError(f"refusing non-http(s) URL: {url}") + t0 = time.monotonic() + try: + req = urllib.request.Request(url, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}) + # url is validated to http(s) above; this calls our own local pathfinding service, not attacker-controlled input. + with urllib.request.urlopen(req, timeout=30) as resp: # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + result = json.loads(resp.read().decode()) + elapsed = time.monotonic() - t0 + if not result.get("ok"): + return {"status": "FAIL", "error": result.get("error", "unknown"), "elapsed": round(elapsed, 2)} + status = result.get("routeStatus", "unknown") + legs = result.get("legs", []) + total_waypoints = sum(leg.get("waypointCount", 0) for leg in legs) + total_progress = sum(float(leg.get("progressYards", 0)) for leg in legs) + return { + "status": "OK" if status == "complete" else "PARTIAL", + "routeStatus": status, + "legs": len(legs), + "waypoints": total_waypoints, + "progressYards": round(total_progress, 1), + "loadedTiles": result.get("loadedTiles", 0), + "elapsed": round(elapsed, 1), + } + except urllib.error.HTTPError as exc: + return {"status": "HTTP", "code": exc.code, "elapsed": round(time.monotonic() - t0, 1)} + except (OSError, urllib.error.URLError, TimeoutError) as exc: + return {"status": "NET", "error": str(exc), "elapsed": round(time.monotonic() - t0, 1)} + except json.JSONDecodeError as exc: + return {"status": "JSON", "error": str(exc), "elapsed": round(time.monotonic() - t0, 1)} + + +def main(): + if len(sys.argv) < 2: + print("Usage: test_route_coverage.py ") + return 1 + base = sys.argv[1] + + scenarios = [ + ("short city", "stormwind", "goldshire"), + ("short city", "stormwind", "dwarven-district"), + ("medium zone", "goldshire", "lakeshire"), + ("medium zone", "goldshire", "darkshire"), + ("medium zone", "goldshire", "westfall"), + ("long cross-zone", "stormwind", "kharanos"), + ("long cross-zone", "stormwind", "ironforge"), + ("long cross-zone", "ironforge", "thelsamar"), + ("long cross-zone", "thelsamar", "menethil"), + ("long cross-zone", "stormwind", "menethil"), + ("long cross-zone", "goldshire", "menethil"), + ("long cross-zone", "menethil", "southshore"), + ("long cross-zone", "southshore", "tarren-mill"), + ("long cross-zone", "tarren-mill", "undercity"), + ("long cross-zone", "brill", "undercity"), + ("long cross-zone", "stormwind", "southshore"), + ("long inland", "booty-bay", "undercity"), + ("kalimdor", "orggrimmar", "crossroads"), + ("kalimdor", "crossroads", "ratchet"), + ("kalimdor", "crossroads", "thunder-bluff"), + ("kalimdor", "orggrimmar", "thunder-bluff"), + ("kalimdor", "orggrimmar", "ratchet"), + ("outland short", "exodar", "azure-watch"), + ("outland cross", "hellfire-pen", "zangarmarsh"), + ("outland cross", "hellfire-pen", "nagrand"), + ("outland cross", "hellfire-pen", "terokkar"), + ("outland long", "hellfire-pen", "shadowmoon"), + ("outland long", "exodar", "hellfire-pen"), + ("outland long", "exodar", "shadowmoon"), + ] + + print(f"Testing {len(scenarios)} route pairs against {base}") + print(f"{'Category':<22} {'From':<18} {'To':<18} {'Result':<8} {'Legs':<5} {'WPs':<5} {'Tiles':<5} {'Time':<6} Detail") + print("-" * 110) + + results = {"OK": 0, "PARTIAL": 0, "FAIL": 0, "SKIP": 0, "NET": 0, "HTTP": 0} + for cat, start_name, end_name in scenarios: + if start_name not in LANDMARKS or end_name not in LANDMARKS: + print(f"{'MISSING':<22} {start_name:<18} {end_name:<18}") + results["SKIP"] = results.get("SKIP", 0) + 1 + continue + r = test_route(base, start_name, end_name) + s = r["status"] + elapsed = r.get("elapsed", "") + detail = "" + if s == "OK": + detail = f"progress={r.get('progressYards')}y" + elif s == "PARTIAL": + detail = f"routeStatus={r.get('routeStatus')} progress={r.get('progressYards')}y" + elif s == "FAIL": + detail = r.get("error", "") + elif s == "NET": + detail = r.get("error", "") + elif s == "HTTP": + detail = str(r.get("code", "")) + elif s == "SKIP": + detail = r.get("reason", "") + print( + f"{cat:<22} {start_name:<18} {end_name:<18} " + f"{s:<10} {str(r.get('legs', '')):<5} {str(r.get('waypoints', '')):<5} " + f"{str(r.get('loadedTiles', '')):<5} {str(elapsed):<6} {detail}" + ) + results[s] = results.get(s, 0) + 1 + + total = sum(results.values()) + print(f"\n{'='*110}") + print(f"Total: {total} OK={results.get('OK', 0)} PARTIAL={results.get('PARTIAL', 0)} " + f"FAIL={results.get('FAIL', 0)} SKIP={results.get('SKIP', 0)} " + f"NET={results.get('NET', 0)} HTTP={results.get('HTTP', 0)}") + return 0 if results.get("FAIL", 0) == 0 and results.get("NET", 0) == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tools/provisioning/README.md b/tools/provisioning/README.md new file mode 100644 index 000000000..3b0e6ffde --- /dev/null +++ b/tools/provisioning/README.md @@ -0,0 +1,315 @@ +# WoWee Provisioning Tools + +These scripts create CMaNGOS, AzerothCore, VMangos, or Turtle WoW test accounts and characters for headless automation. + +## Account Creation + +Use SOAP/server commands instead of direct SQL inserts. Modern CMaNGOS and AzerothCore account rows include SRP verifier/salt fields, and the server already knows how to generate them correctly. + +There are two supported account creation paths: + +- Preferred for a private Linux CMaNGOS host: SSH to the server and call the server-local SOAP endpoint from there. +- Useful for local/dev setups: call SOAP directly from this machine or run the older tiny HTTP provisioning service. + +### SSH + Server-Local SOAP + +`create_account_ssh.py` reads server connection details from `tools/.env`, opens SSH, and runs the SOAP request locally on the CMaNGOS host. This lets `SOAP.IP` stay bound to `127.0.0.1` on the Linux box. + +Create `tools/.env` locally: + +```dotenv +MANGOS_HOST=10.0.0.10 +MANGOS_PORT=22 +MANGOS_USER=mangos +MANGOS_SSH_KEY_PATH=C:\Users\admin\.ssh\mangos_codex +MANGOS_SOAP_URL=http://127.0.0.1:7878/ +MANGOS_SOAP_USERNAME=GMACCOUNT +MANGOS_SOAP_PASSWORD=GMPASSWORD +``` + +`tools/.env` is ignored by git. + +For AzerothCore, you can either add AC-specific values or rely on the same SSH host/key values as `MANGOS_*`. AzerothCore SOAP uses the `urn:AC` namespace and is normally kept bound to `127.0.0.1`: + +```dotenv +AC_HOST=10.0.0.10 +AC_PORT=22 +AC_USER=mangos +AC_SSH_KEY_PATH=C:\Users\admin\.ssh\mangos_codex +AC_SOAP_URL=http://127.0.0.1:7879/ +AC_SOAP_USERNAME=SERVERADMIN +AC_SOAP_PASSWORD=GMPASSWORD +``` + +For VMangos, the helper can reuse the same SSH host/key and SOAP admin values as `MANGOS_*`, but defaults SOAP to the VMangos world server port: + +```dotenv +VMANGOS_SOAP_URL=http://127.0.0.1:7880/ +``` + +If `VMANGOS_SOAP_USERNAME` is not set, the helper uses `SERVERADMIN` for VMangos and can still reuse `MANGOS_SOAP_PASSWORD`. VMangos enforces the older 16-character password limit; when the VMangos profile reads a shared env SOAP password, it uses the first 16 characters. + +VMangos' administrator level is `6` in this build. The SOAP admin account must be level `6` or higher for SOAP commands; normal bot roster accounts still default to moderator level `1`. + +For Turtle WoW, the helper uses SSH plus server-local MySQL instead of SOAP. Turtle's account table stores the old MaNGOS `sha_pass_hash`; the realmd process fills `v`/`s` on first login. The profile can reuse the same SSH host/key values as `MANGOS_*`, or you can provide Turtle-specific values: + +```dotenv +TURTLE_HOST=10.0.0.10 +TURTLE_PORT=22 +TURTLE_USER=mangos +TURTLE_SSH_KEY_PATH=C:\Users\admin\.ssh\mangos_codex +TURTLE_LOGIN_DB=tw_logon +TURTLE_MYSQL_COMMAND=sudo mysql +``` + +Turtle account rank is stored in `account.rank`, so roster `gmlevel` maps to that column. Turtle can force PIN/2FA for staff ranks, so the default Turtle roster uses `gmlevel: 0` for character provisioning. The default Turtle profile uses auth port `3730`, realm `Turtle WoW Local`, client expansion `turtle`, auth version `1.17.2` build `7199`, and world build `5875`. + +For staff-ranked Turtle accounts, add a local `totpSecret` to ignored `tools/provisioning/roster.json`. The headless client can generate the current PIN from that secret during provisioning. To print a PIN for logging in with the real client: + +```bash +python tools/provisioning/turtle_pin.py +``` + +Create or update an account: + +```bash +python tools/provisioning/create_account_ssh.py BOT001 \ + --password bot-password \ + --expansion 1 +``` + +Create or update an AzerothCore account: + +```bash +python tools/provisioning/create_account_ssh.py BOT001 \ + --server-type azerothcore \ + --password bot-password \ + --expansion 2 +``` + +New accounts default to GM level 1 (Moderator) so automation can run moderator-only bot commands. Pass `--gmlevel 0` when you need a regular player account: + +```bash +python tools/provisioning/create_account_ssh.py BOT001 \ + --password bot-password \ + --expansion 1 \ + --gmlevel 1 +``` + +The script runs the server's account commands: + +```text +account create BOT001 ******** +account set addon BOT001 1 +account set gmlevel BOT001 1 -1 +``` + +Use `--dry-run` to confirm the SSH target and commands without connecting: + +```bash +python tools/provisioning/create_account_ssh.py BOT001 \ + --password bot-password \ + --expansion 1 \ + --gmlevel 1 \ + --dry-run +``` + +Useful options: + +```bash +python tools/provisioning/create_account_ssh.py BOT001 \ + --password bot-password \ + --expansion 1 \ + --gmlevel 1 \ + --env tools/.env \ + --soap-url http://127.0.0.1:7878/ \ + --verbose +``` + +If the account already exists and you only need to set the expansion: + +```bash +python tools/provisioning/create_account_ssh.py BOT001 \ + --password existing-password \ + --expansion 1 \ + --skip-create +``` + +For TBC on CMaNGOS, `--expansion 1` is normally the useful account expansion value. For AzerothCore WotLK, use `--server-type azerothcore`; it defaults account expansion to `2`. For VMangos Vanilla, use `--server-type vmangos`; it defaults account expansion to `0`. +For Turtle WoW, use `--server-type turtle`; it also defaults account expansion to `0`. + +### GM Level for Bot Accounts + +Bot fleet accounts need GM level 1+ to run moderator-only bot commands. The account helpers default to `--gmlevel 1`. The `--realm-id` argument (default `-1`, all realms) controls which realm gets the GM assignment. Pass `--gmlevel 0` to keep the account as a regular player. + +### Direct SOAP / Provisioning Service + +`create_account_direct_soap.py` is kept for cases where the SOAP endpoint is reachable from the machine running WoWee, or where you want to run a small HTTP provisioning service that forwards to SOAP. + +Requires `defusedxml` (`pip install defusedxml`) to parse SOAP responses safely. + +Enable SOAP in your CMaNGOS world server config, create or choose a GM account that is allowed to run SOAP commands, then run: + +```bash +python tools/provisioning/create_account_direct_soap.py create \ + --server-type cmangos \ + --soap-url http://127.0.0.1:7878/ \ + --admin-user GMACCOUNT \ + --admin-pass GMPASSWORD \ + --account BOT001 \ + --password bot-password \ + --expansion 1 +``` + +For AzerothCore direct SOAP, pass `--server-type azerothcore`. For VMangos direct SOAP, pass `--server-type vmangos` and the VMangos SOAP URL, normally `http://127.0.0.1:7880/` from the server host. Turtle can be selected for direct SOAP only if you explicitly enable a MaNGOS-compatible SOAP endpoint; the default Turtle provisioning path is SSH plus server-local MySQL. + +If the CMaNGOS host should expose a tiny provisioning endpoint, run this on the Linux box: + +```bash +python tools/provisioning/create_account_direct_soap.py serve \ + --bind 127.0.0.1 \ + --port 8790 \ + --soap-url http://127.0.0.1:7878/ \ + --admin-user GMACCOUNT \ + --admin-pass GMPASSWORD \ + --token choose-a-long-random-token +``` + +Then create an account with: + +```bash +curl -X POST http://127.0.0.1:8790/accounts \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer choose-a-long-random-token" \ + -d '{"account":"BOT001","password":"bot-password","expansion":1}' +``` + +Keep this service bound to localhost or behind your own trusted network boundary. + +## Character Creation + +Character creation uses `wowee_headless` against the normal auth/world servers. Start from a working headless settings file so realm, client build, expansion profile, and auth host already match your server: + +```bash +python tools/provisioning/create_character.py \ + --settings tools/headless_client/settings.json \ + --wowee-headless build/bin/wowee_headless.exe \ + --account BOT001 \ + --password bot-password \ + --name Codexbotone \ + --race human \ + --class warrior \ + --gender male +``` + +On Windows, the wrapper automatically adds `C:/msys64/ucrt64/bin` to `PATH` when it exists so MSYS2-built runtime DLLs such as `libgcc_s_seh-1.dll`, `libstdc++-6.dll`, and OpenSSL DLLs can be found. If your MSYS2 install is somewhere else, pass: + +```bash +python tools/provisioning/create_character.py \ + --settings tools/headless_client/settings.json \ + --wowee-headless build/bin/wowee_headless.exe \ + --msys-ucrt-bin C:/path/to/msys64/ucrt64/bin \ + --account BOT001 \ + --password bot-password \ + --name Codexbotone \ + --race human \ + --class warrior +``` + +The script writes a temporary settings file with: + +```json +{ + "provision": { + "createCharacter": { + "enabled": true, + "exitAfterCreate": true + } + } +} +``` + +`wowee_headless` logs in, reaches the character list, creates the character if it does not already exist, and exits. + +Supported race names: `human`, `orc`, `dwarf`, `night_elf`, `undead`, `tauren`, `gnome`, `troll`, `blood_elf`, `draenei`. + +Supported TBC class names: `warrior`, `paladin`, `hunter`, `rogue`, `priest`, `shaman`, `mage`, `warlock`, `druid`. + +## Batch Roster Provisioning + +Use `provision_roster.py` when you are ready to create more than one account/character pair. Start by copying the example roster: + +```bash +copy tools\provisioning\roster.example.json tools\provisioning\roster.json +``` + +`tools/provisioning/roster.json` is ignored by git because it may contain account passwords. + +Example roster shape: + +```json +{ + "defaults": { + "settings": "tools/headless_client/settings.json", + "woweeHeadless": "build/bin/wowee_headless.exe", + "accountPassword": "change-me", + "serverType": "cmangos", + "gmlevel": 1, + "race": "human", + "class": "warrior", + "gender": "male" + }, + "accounts": [ + { + "account": "BOT001", + "password": "change-me", + "gmlevel": 1, + "characters": [ + { "name": "Botone" } + ] + } + ] +} +``` + +Roster provisioning defaults to `gmlevel: 1`. Set `gmlevel` in `defaults` for all accounts, or override per-account. Set `realmId` (default `-1`, all realms) to target a specific realm for the GM assignment. Set `gmlevel` to `0` to keep an account as a regular player. + +Dry-run the roster first: + +```bash +python tools/provisioning/provision_roster.py tools/provisioning/roster.json --dry-run +``` + +Then run it: + +```bash +python tools/provisioning/provision_roster.py tools/provisioning/roster.json +``` + +Useful switches: + +```bash +python tools/provisioning/provision_roster.py tools/provisioning/roster.json --skip-accounts +python tools/provisioning/provision_roster.py tools/provisioning/roster.json --skip-characters +python tools/provisioning/provision_roster.py tools/provisioning/roster.json --continue-on-error +``` + +By default, roster provisioning uses `create_account_ssh.py` for account creation and `create_character.py` for character creation. + +To target AzerothCore from the command line: + +```bash +python tools/provisioning/provision_roster.py tools/provisioning/roster.json \ + --server-type azerothcore \ + --auth-host 10.102.172.4 +``` + +To target VMangos from the command line: + +```bash +python tools/provisioning/provision_roster.py tools/provisioning/roster.json \ + --server-type vmangos \ + --auth-host 10.102.172.4 +``` + +When `serverType` is `azerothcore`, or when `--server-type azerothcore` is passed, roster provisioning defaults to account expansion `2`, GM level `1`, auth port `3725`, realm `AzerothCore`, client expansion `wotlk`, and client version `3.3.5.12340`. When `serverType` is `vmangos`, roster provisioning defaults to account expansion `0`, GM level `1`, auth port `3726`, realm `VMangos`, client expansion `classic`, and client version `1.12.1.5875`. CMaNGOS defaults remain expansion `1`, GM level `1`, auth port `3724`, realm `MaNGOS`, client expansion `tbc`, and client version `2.4.3.8606`. The CLI flag overrides the roster default for quick retargeting. diff --git a/tools/provisioning/create_account_direct_soap.py b/tools/provisioning/create_account_direct_soap.py new file mode 100644 index 000000000..b376d6eb1 --- /dev/null +++ b/tools/provisioning/create_account_direct_soap.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Create WoW accounts through the built-in SOAP command endpoint.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import sys +import urllib.error +import urllib.request +import defusedxml.ElementTree as ET # SOAP response is server-controlled, but parse it safely regardless +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + + +SOAP_ENV = "http://schemas.xmlsoap.org/soap/envelope/" +SERVER_SOAP_NAMESPACES = { + "cmangos": "urn:MaNGOS", + "azerothcore": "urn:AC", + "vmangos": "urn:MaNGOS", + "turtle": "urn:MaNGOS", +} + + +def soap_execute_command( + soap_url: str, + admin_user: str, + admin_pass: str, + command: str, + timeout: float = 15.0, + soap_namespace: str = "urn:MaNGOS", +) -> str: + if not soap_url.startswith(("http://", "https://")): + raise ValueError(f"refusing non-http(s) URL: {soap_url}") + envelope = f""" + + + + {xml_escape(command)} + + + +""" + auth = base64.b64encode(f"{admin_user}:{admin_pass}".encode("utf-8")).decode("ascii") + req = urllib.request.Request( + soap_url, + data=envelope.encode("utf-8"), + headers={ + "Authorization": f"Basic {auth}", + "Content-Type": 'text/xml; charset="utf-8"', + "SOAPAction": "executeCommand", + }, + method="POST", + ) + + try: + # soap_url is validated to http(s) above and comes from an operator-supplied --soap-url flag, not attacker-controlled input. + with urllib.request.urlopen(req, timeout=timeout) as resp: # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + body = resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"SOAP HTTP {exc.code}: {detail}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"SOAP request failed: {exc}") from exc + + return parse_soap_result(body) + + +def xml_escape(value: str) -> str: + return ( + value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'") + ) + + +def parse_soap_result(body: str) -> str: + try: + root = ET.fromstring(body) + except ET.ParseError: + return body.strip() + + for elem in root.iter(): + if elem.tag.endswith("result"): + return (elem.text or "").strip() + return body.strip() + + +def create_account(args: argparse.Namespace) -> int: + validate_command_arg(args.account, "account") + validate_command_arg(args.password, "password") + command = f"account create {args.account} {args.password}" + try: + result = soap_execute_command(args.soap_url, args.admin_user, args.admin_pass, command, args.timeout, args.soap_namespace) + print(result or "Account created.") + except RuntimeError as exc: + lowered = str(exc).lower() + if "already exist" in lowered or "already exists" in lowered: + print("Account already exists; continuing.") + else: + raise + + if args.expansion is not None: + expansion_cmd = f"account set addon {args.account} {args.expansion}" + expansion_result = soap_execute_command( + args.soap_url, args.admin_user, args.admin_pass, expansion_cmd, args.timeout, args.soap_namespace + ) + print(expansion_result or f"Expansion set to {args.expansion}.") + + if args.gmlevel > 0: + gm_cmd = f"account set gmlevel {args.account} {args.gmlevel} {args.realm_id}" + gm_result = soap_execute_command(args.soap_url, args.admin_user, args.admin_pass, gm_cmd, args.timeout, args.soap_namespace) + print(gm_result or f"GM level set to {args.gmlevel}.") + + return 0 + + +class AccountService(BaseHTTPRequestHandler): + server_version = "WoWeeProvisioning/1.0" + + def do_POST(self) -> None: + if self.path != "/accounts": + self.send_json(404, {"ok": False, "error": "unknown endpoint"}) + return + + try: + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length).decode("utf-8")) + token = self.server.provision_token # type: ignore[attr-defined] + if payload.get("token") != token and self.headers.get("Authorization") != f"Bearer {token}": + self.send_json(401, {"ok": False, "error": "invalid token"}) + return + + account = require_string(payload, "account") + password = require_string(payload, "password") + validate_command_arg(account, "account") + validate_command_arg(password, "password") + expansion = payload.get("expansion") + command = f"account create {account} {password}" + + try: + result = soap_execute_command( + self.server.soap_url, # type: ignore[attr-defined] + self.server.admin_user, # type: ignore[attr-defined] + self.server.admin_pass, # type: ignore[attr-defined] + command, + self.server.soap_timeout, # type: ignore[attr-defined] + self.server.soap_namespace, # type: ignore[attr-defined] + ) + except RuntimeError as exc: + lowered = str(exc).lower() + if "already exist" in lowered or "already exists" in lowered: + result = "Account already exists." + else: + raise + if expansion is not None: + soap_execute_command( + self.server.soap_url, # type: ignore[attr-defined] + self.server.admin_user, # type: ignore[attr-defined] + self.server.admin_pass, # type: ignore[attr-defined] + f"account set addon {account} {int(expansion)}", + self.server.soap_timeout, # type: ignore[attr-defined] + self.server.soap_namespace, # type: ignore[attr-defined] + ) + self.send_json(200, {"ok": True, "result": result}) + except Exception as exc: + self.send_json(400, {"ok": False, "error": str(exc)}) + + def log_message(self, fmt: str, *args: Any) -> None: + print(f"{self.address_string()} - {fmt % args}", file=sys.stderr) + + def send_json(self, status: int, doc: dict[str, Any]) -> None: + data = json.dumps(doc).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +def require_string(doc: dict[str, Any], key: str) -> str: + value = doc.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{key} is required") + return value.strip() + + +def validate_command_arg(value: str, label: str) -> None: + if any(ch.isspace() or ord(ch) < 32 for ch in value): + raise ValueError(f"{label} must not contain whitespace or control characters") + + +def serve(args: argparse.Namespace) -> int: + if not args.token: + raise SystemExit("--token is required for service mode") + + httpd = ThreadingHTTPServer((args.bind, args.port), AccountService) + httpd.soap_url = args.soap_url # type: ignore[attr-defined] + httpd.admin_user = args.admin_user # type: ignore[attr-defined] + httpd.admin_pass = args.admin_pass # type: ignore[attr-defined] + httpd.soap_timeout = args.timeout # type: ignore[attr-defined] + httpd.soap_namespace = args.soap_namespace # type: ignore[attr-defined] + httpd.provision_token = args.token # type: ignore[attr-defined] + + print(f"Account provisioning service listening on http://{args.bind}:{args.port}") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nStopping account provisioning service") + return 0 + + +def add_common_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--server-type", choices=sorted(SERVER_SOAP_NAMESPACES), default="cmangos") + parser.add_argument("--soap-namespace", default="", help="Override SOAP XML namespace; defaults from --server-type") + parser.add_argument("--soap-url", required=True, help="SOAP URL, for example http://127.0.0.1:7878/") + parser.add_argument("--admin-user", required=True, help="GM account with SOAP command access") + parser.add_argument("--admin-pass", required=True, help="GM account password") + parser.add_argument("--timeout", type=float, default=15.0, help="SOAP timeout in seconds") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="cmd", required=True) + + create = sub.add_parser("create", help="Create one account through server SOAP") + add_common_args(create) + create.add_argument("--account", required=True) + create.add_argument("--password", required=True) + create.add_argument("--expansion", type=int, help="Optional account expansion value, such as 0 for Vanilla or 1 for TBC") + create.add_argument("--gmlevel", type=int, default=1, choices=range(0, 8), help="GM security level; use 0 for a regular player account") + create.add_argument("--realm-id", type=int, default=-1, help="Realm ID for gmlevel assignment (-1 = all realms)") + create.set_defaults(func=create_account) + + service = sub.add_parser("serve", help="Expose POST /accounts as a small provisioning service") + add_common_args(service) + service.add_argument("--bind", default="127.0.0.1") + service.add_argument("--port", type=int, default=8790) + service.add_argument("--token", required=True) + service.set_defaults(func=serve) + + args = parser.parse_args(argv) + args.soap_namespace = args.soap_namespace or SERVER_SOAP_NAMESPACES[args.server_type] + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/provisioning/create_account_ssh.py b/tools/provisioning/create_account_ssh.py new file mode 100644 index 000000000..8c1b3f5d8 --- /dev/null +++ b/tools/provisioning/create_account_ssh.py @@ -0,0 +1,561 @@ +#!/usr/bin/env python3 +"""Create a WoW account over SSH using the server-local SOAP endpoint.""" + +from __future__ import annotations + +import argparse +import getpass +import hashlib +import json +import pathlib +import re +import secrets +import shlex +import subprocess +import sys +from typing import Any + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DEFAULT_ENV = ROOT / ".env" + + +USERNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{2,31}$") + +SERVER_PROFILES = { + "cmangos": { + "label": "CMaNGOS", + "env_prefixes": ("MANGOS",), + "soap_namespace": "urn:MaNGOS", + "default_expansion": 1, + "default_soap_url": "http://127.0.0.1:7878/", + }, + "azerothcore": { + "label": "AzerothCore", + "env_prefixes": ("AC", "AZEROTHCORE", "MANGOS"), + "soap_namespace": "urn:AC", + "default_expansion": 2, + "default_soap_url": "http://127.0.0.1:7879/", + }, + "vmangos": { + "label": "VMangos", + "env_prefixes": ("VMANGOS", "MANGOS"), + "soap_namespace": "urn:MaNGOS", + "default_expansion": 0, + "default_soap_url": "http://127.0.0.1:7880/", + "default_soap_username": "SERVERADMIN", + "max_soap_password_len": 16, + }, + "turtle": { + "label": "Turtle WoW", + "env_prefixes": ("TURTLE", "MANGOS"), + "backend": "turtle_sql", + "default_expansion": 0, + "default_login_db": "tw_logon", + "default_character_db": "tw_chars", + "default_mysql_command": "sudo mysql", + }, +} + + +REMOTE_SCRIPT = r""" +import base64 +import http.client +import json +import sys +import urllib.parse +import xml.etree.ElementTree as ET + +payload = json.loads(sys.stdin.read()) +soap_url = payload.get("soap_url") or "http://127.0.0.1:7878/" +soap_user = payload["soap_user"] +soap_password = payload["soap_password"] +soap_namespace = payload.get("soap_namespace") or "urn:MaNGOS" +commands = payload["commands"] +verbose = bool(payload.get("verbose")) + +parsed = urllib.parse.urlparse(soap_url) +if parsed.scheme != "http": + raise SystemExit("Only plain HTTP SOAP endpoints are supported by this helper.") +host = parsed.hostname or "127.0.0.1" +port = parsed.port or 7878 +path = parsed.path or "/" + + +def call_soap(command): + body = ''' + + + + {command} + + +'''.format( + soap_namespace=soap_namespace.replace("&", "&").replace('"', """), + command=command.replace("&", "&").replace("<", "<").replace(">", ">") + ) + auth = base64.b64encode((soap_user + ":" + soap_password).encode("utf-8")).decode("ascii") + headers = { + "Authorization": "Basic " + auth, + "Content-Type": "text/xml; charset=utf-8", + "SOAPAction": "executeCommand", + } + conn = http.client.HTTPConnection(host, port, timeout=10) + try: + conn.request("POST", path, body.encode("utf-8"), headers) + response = conn.getresponse() + raw = response.read().decode("utf-8", errors="replace") + finally: + conn.close() + + if response.status >= 400: + raise RuntimeError("SOAP HTTP {0}: {1}".format(response.status, raw[:500])) + + try: + root = ET.fromstring(raw) + except ET.ParseError: + raise RuntimeError("SOAP returned non-XML response: " + raw[:500]) + + fault = root.find(".//{http://schemas.xmlsoap.org/soap/envelope/}Fault") + if fault is not None: + text = "".join(fault.itertext()).strip() + raise RuntimeError("SOAP fault: " + text) + + result = "" + for element in root.iter(): + if element.tag.endswith("result") or element.tag.endswith("return"): + result = element.text or "" + break + return result.strip() + + +for command in commands: + if verbose: + shown = command + if command.lower().startswith("account create "): + parts = command.split(" ", 3) + if len(parts) == 4: + shown = " ".join(parts[:3] + ["********"]) + print("SOAP:", shown) + try: + result = call_soap(command) + except RuntimeError as exc: + lowered_error = str(exc).lower() + if command.lower().startswith("account create ") and ( + "already exist" in lowered_error or "already exists" in lowered_error + ): + print("Account already exists; continuing.") + continue + raise + lowered = result.lower() + failed_markers = (" syntax", "error", "not exist", "not found", "incorrect", "already exist", "already exists") + if any(marker in lowered for marker in failed_markers): + raise RuntimeError("Command failed: " + result) + if result: + print(result) +""" + + +REMOTE_TURTLE_SQL_SCRIPT = r""" +import json +import shlex +import subprocess +import sys + +payload = json.loads(sys.stdin.read()) +mysql_command = payload.get("mysql_command") or "sudo mysql" +login_db = payload.get("login_db") or "tw_logon" +character_db = payload.get("character_db") or "tw_chars" +account = payload["account"] +password_hash = payload["password_hash"] +verifier = payload.get("verifier", "") +salt = payload.get("salt", "") +expansion = int(payload["expansion"]) +gmlevel = int(payload["gmlevel"]) +realm_id = int(payload["realm_id"]) +pin = str(payload.get("pin") or "").strip() +totp_secret = str(payload.get("totp_secret") or "").strip() +skip_create = bool(payload.get("skip_create")) +verbose = bool(payload.get("verbose")) + + +def sql_string(value): + return "'" + str(value).replace("\\", "\\\\").replace("'", "''") + "'" + + +def sql_identifier(value): + return "`" + str(value).replace("`", "``") + "`" + + +statements = [ + "USE {}".format(sql_identifier(login_db)), +] + +if not skip_create: + statements.append( + "INSERT INTO account(username, sha_pass_hash, v, s, `rank`, expansion, joindate) " + "SELECT {account}, {password_hash}, {verifier}, {salt}, {gmlevel}, {expansion}, NOW() " + "WHERE NOT EXISTS (SELECT 1 FROM account WHERE username = {account})".format( + account=sql_string(account), + password_hash=sql_string(password_hash), + verifier=sql_string(verifier), + salt=sql_string(salt), + gmlevel=gmlevel, + expansion=expansion, + ) + ) + statements.append( + "UPDATE account SET sha_pass_hash = {password_hash}, v = {verifier}, s = {salt} " + "WHERE username = {account}".format( + account=sql_string(account), + password_hash=sql_string(password_hash), + verifier=sql_string(verifier), + salt=sql_string(salt), + ) + ) + +statements.extend( + [ + "UPDATE account SET expansion = {expansion} WHERE username = {account}".format( + expansion=expansion, + account=sql_string(account), + ), + "UPDATE account SET `rank` = {gmlevel} WHERE username = {account}".format( + gmlevel=gmlevel, + account=sql_string(account), + ), + "UPDATE account SET geolock_pin = {pin} WHERE username = {account}".format( + pin=int(pin) if pin else 0, + account=sql_string(account), + ), + "UPDATE account SET security = {totp_secret} WHERE username = {account}".format( + totp_secret=sql_string(totp_secret) if totp_secret else "security", + account=sql_string(account), + ), + "REPLACE INTO realmcharacters (realmid, acctid, numchars) " + "SELECT realmlist.id, account.id, 0 FROM realmlist, account " + "LEFT JOIN realmcharacters ON acctid = account.id " + "WHERE account.username = {account} AND acctid IS NULL".format(account=sql_string(account)), + "INSERT IGNORE INTO {character_db}.character_tutorial " + "(account, tut0, tut1, tut2, tut3, tut4, tut5, tut6, tut7) " + "SELECT id, 0, 0, 0, 0, 0, 0, 0, 0 FROM account WHERE username = {account}".format( + character_db=sql_identifier(character_db), + account=sql_string(account), + ), + "SELECT id, username, `rank`, expansion FROM account WHERE username = {}".format(sql_string(account)), + ] +) + +sql = ";\n".join(statements) + ";\n" +if verbose: + print(sql.replace(password_hash, "********")) + +cmd = shlex.split(mysql_command) +completed = subprocess.run(cmd, input=sql, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) +if completed.stdout: + print(completed.stdout.strip()) +if completed.returncode != 0: + if completed.stderr: + print(completed.stderr.strip(), file=sys.stderr) + raise SystemExit(completed.returncode) +""" + + +def load_env(path: pathlib.Path) -> dict[str, str]: + values: dict[str, str] = {} + if not path.exists(): + raise SystemExit(f"Missing env file: {path}") + + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"').strip("'") + return values + + +def require_env(env: dict[str, str], key: str) -> str: + value = env.get(key, "").strip() + if not value: + raise SystemExit(f"{key} must be set in .env") + return value + + +def env_value(env: dict[str, str], prefixes: tuple[str, ...], suffix: str, fallback: str = "") -> str: + for prefix in prefixes: + value = env.get(f"{prefix}_{suffix}", "").strip() + if value: + return value + return fallback + + +def validate_account_username(username: str) -> str: + username = username.strip() + if not USERNAME_RE.match(username): + raise SystemExit( + "Account username must be 3-32 characters and use only letters, numbers, dot, underscore, or dash." + ) + return username.upper() + + +def validate_account_password(password: str) -> str: + if not password: + raise SystemExit("Account password cannot be empty.") + if any(ch.isspace() for ch in password): + raise SystemExit("Account password cannot contain whitespace because account commands are space-delimited.") + if len(password) > 64: + raise SystemExit("Account password is too long for this helper; use 64 characters or fewer.") + return password + + +def validate_pin(pin: str) -> str: + pin = pin.strip() + if not pin: + return "" + if not pin.isdigit() or len(pin) < 4 or len(pin) > 10: + raise SystemExit("PIN must be 4-10 digits.") + return pin + + +def validate_totp_secret(secret: str) -> str: + secret = secret.strip().replace(" ", "").upper() + if not secret: + return "" + allowed = set("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") + if any(ch not in allowed for ch in secret): + raise SystemExit("TOTP secret must be base32 characters A-Z and 2-7.") + return secret + + +def calculate_turtle_sha_pass_hash(account_name: str, password: str) -> str: + normalized_account = account_name.upper() + normalized_password = password.upper() + # Turtle/MaNGOS auth stores SHA1(USER:PASSWORD); changing this breaks SRP login compatibility. + # nosemgrep: python.lang.security.insecure-hash-algorithms.insecure-hash-algorithm-sha1 + return hashlib.sha1(f"{normalized_account}:{normalized_password}".encode("utf-8")).hexdigest().upper() + + +def calculate_turtle_srp_fields(password_hash: str) -> tuple[str, str]: + modulus = int("894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7", 16) + generator = 7 + salt_bytes_le = secrets.token_bytes(32) + password_hash_bytes = bytes.fromhex(password_hash) + # Turtle/MaNGOS SRP verifier generation requires SHA1(salt || password_hash). + # nosemgrep: python.lang.security.insecure-hash-algorithms.insecure-hash-algorithm-sha1 + x_digest = hashlib.sha1(salt_bytes_le + password_hash_bytes).digest() + x = int.from_bytes(x_digest, "little") + verifier = pow(generator, x, modulus) + salt = int.from_bytes(salt_bytes_le, "little") + return f"{verifier:064X}", f"{salt:064X}" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Create a WoW account through SSH + server-local SOAP.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument("username", help="WoW account username to create") + parser.add_argument("--password", help="WoW account password. If omitted, prompt securely.") + parser.add_argument("--server-type", choices=sorted(SERVER_PROFILES), default="cmangos") + parser.add_argument("--expansion", type=int, choices=(0, 1, 2), help="0=Classic, 1=TBC, 2=WotLK") + parser.add_argument("--gmlevel", type=int, default=1, choices=range(0, 8), help="GM security level; use 0 for a regular player account") + parser.add_argument("--realm-id", type=int, default=-1, help="Realm ID for gmlevel assignment (-1 = all realms)") + parser.add_argument("--env", type=pathlib.Path, default=DEFAULT_ENV, help="Path to .env") + parser.add_argument("--soap-url", default="", help="Override SOAP URL on the server") + parser.add_argument("--soap-user", default="", help="SOAP admin username. Defaults to MANGOS_SOAP_USERNAME.") + parser.add_argument("--soap-password", default="", help="SOAP admin password. Defaults to MANGOS_SOAP_PASSWORD or prompt.") + parser.add_argument("--pin", default="", help="Optional 4-10 digit PIN/current token for Turtle PIN challenge.") + parser.add_argument("--totp-secret", default="", help="Optional Turtle staff TOTP/base32 secret stored in account.security.") + parser.add_argument("--skip-create", action="store_true", help="Only run the expansion command for an existing account.") + parser.add_argument("--dry-run", action="store_true", help="Print the remote commands without connecting.") + parser.add_argument("--verbose", action="store_true", help="Print SOAP commands before executing them on the server.") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + env = load_env(args.env) + profile = SERVER_PROFILES[args.server_type] + env_prefixes = profile["env_prefixes"] + expansion = args.expansion if args.expansion is not None else int(profile["default_expansion"]) + + account_name = validate_account_username(args.username) + account_password = validate_account_password(args.password or getpass.getpass("New WoW account password: ")) + pin = validate_pin(args.pin) + totp_secret = validate_totp_secret(args.totp_secret) + + ssh_host = env_value(env, env_prefixes, "HOST") + if not ssh_host: + raise SystemExit(f"{env_prefixes[0]}_HOST must be set in .env") + ssh_port = env_value(env, env_prefixes, "PORT", "22") or "22" + ssh_user = env_value(env, env_prefixes, "USER") + if not ssh_user: + raise SystemExit(f"{env_prefixes[0]}_USER must be set in .env") + ssh_key = env_value(env, env_prefixes, "SSH_KEY_PATH") + if not ssh_key: + raise SystemExit(f"{env_prefixes[0]}_SSH_KEY_PATH must be set in .env") + + if profile.get("backend") == "turtle_sql": + login_db = env_value(env, env_prefixes, "LOGIN_DB", str(profile["default_login_db"])) + character_db = env_value(env, env_prefixes, "CHARACTER_DB", str(profile["default_character_db"])) + mysql_command = env_value(env, env_prefixes, "MYSQL_COMMAND", str(profile["default_mysql_command"])) + password_hash = calculate_turtle_sha_pass_hash(account_name, account_password) + verifier, salt = calculate_turtle_srp_fields(password_hash) + payload: dict[str, Any] = { + "login_db": login_db, + "character_db": character_db, + "mysql_command": mysql_command, + "account": account_name, + "password_hash": password_hash, + "verifier": verifier, + "salt": salt, + "expansion": expansion, + "gmlevel": args.gmlevel, + "realm_id": args.realm_id, + "pin": pin, + "totp_secret": totp_secret, + "skip_create": args.skip_create, + "verbose": args.verbose, + } + + if args.dry_run: + print(f"SSH target: {ssh_user}@{ssh_host}:{ssh_port}") + print(f"Server type: {profile['label']}") + print(f"MySQL command on server: {mysql_command}") + print(f"Login DB: {login_db}") + print(f"Character DB: {character_db}") + if not args.skip_create: + print(f"Would create account: {account_name} with password hash ********") + print(f"Would set expansion to {expansion}") + print(f"Would set rank to {args.gmlevel} (realm {args.realm_id})") + if pin: + print("Would set geolock PIN to ********") + if totp_secret: + print("Would set Turtle staff TOTP secret to ********") + return 0 + + ssh_command = [ + "ssh", + "-i", + ssh_key, + "-p", + ssh_port, + "-o", + "StrictHostKeyChecking=accept-new", + f"{ssh_user}@{ssh_host}", + "python3 -c " + shlex.quote(REMOTE_TURTLE_SQL_SCRIPT), + ] + + completed = subprocess.run( + ssh_command, + input=json.dumps(payload), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + if completed.stdout: + print(completed.stdout.strip()) + if completed.returncode != 0: + if completed.stderr: + print(completed.stderr.strip(), file=sys.stderr) + return completed.returncode + + parts = [f"Created/updated {profile['label']} account {account_name} with expansion {expansion}"] + if args.gmlevel > 0: + parts.append(f"rank set to {args.gmlevel}") + if pin: + parts.append("geolock PIN set") + if totp_secret: + parts.append("TOTP secret set") + print(". ".join(parts) + ".") + return 0 + + soap_user_prefixes = env_prefixes if args.server_type == "cmangos" else tuple(prefix for prefix in env_prefixes if prefix != "MANGOS") + soap_user = args.soap_user or env_value(env, soap_user_prefixes, "SOAP_USERNAME") or str(profile.get("default_soap_username", "")) + if not soap_user: + soap_user = input("SOAP admin username: ").strip() + if not soap_user: + raise SystemExit("SOAP admin username is required.") + + soap_password = args.soap_password or env_value(env, env_prefixes, "SOAP_PASSWORD") + if not soap_password: + soap_password = getpass.getpass("SOAP admin password: ") + if not soap_password: + raise SystemExit("SOAP admin password is required.") + max_soap_password_len = int(profile.get("max_soap_password_len", 0) or 0) + if max_soap_password_len > 0 and not args.soap_password: + soap_password = soap_password[:max_soap_password_len] + + soap_url_prefixes = env_prefixes if args.server_type == "cmangos" else tuple(prefix for prefix in env_prefixes if prefix != "MANGOS") + soap_url = args.soap_url or env_value(env, soap_url_prefixes, "SOAP_URL", str(profile["default_soap_url"])) + + commands = [] + if not args.skip_create: + commands.append(f"account create {account_name} {account_password}") + commands.append(f"account set addon {account_name} {expansion}") + if args.gmlevel > 0: + commands.append(f"account set gmlevel {account_name} {args.gmlevel} {args.realm_id}") + + payload: dict[str, Any] = { + "soap_url": soap_url, + "soap_user": soap_user, + "soap_password": soap_password, + "soap_namespace": profile["soap_namespace"], + "commands": commands, + "verbose": args.verbose, + } + + if args.dry_run: + print(f"SSH target: {ssh_user}@{ssh_host}:{ssh_port}") + print(f"Server type: {profile['label']}") + print(f"SOAP URL on server: {soap_url}") + print(f"SOAP namespace: {profile['soap_namespace']}") + for command in commands: + scrubbed = command.replace(account_password, "********") + print(f"Would run: {scrubbed}") + return 0 + + ssh_command = [ + "ssh", + "-i", + ssh_key, + "-p", + ssh_port, + "-o", + "StrictHostKeyChecking=accept-new", + f"{ssh_user}@{ssh_host}", + "python3 -c " + shlex.quote(REMOTE_SCRIPT), + ] + + completed = subprocess.run( + ssh_command, + input=json.dumps(payload), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + if completed.stdout: + print(completed.stdout.strip()) + if completed.returncode != 0: + if completed.stderr: + print(completed.stderr.strip(), file=sys.stderr) + return completed.returncode + + parts = [f"Created/updated {profile['label']} account {account_name} with expansion {expansion}"] + if args.gmlevel > 0: + parts.append(f"GM level set to {args.gmlevel} (realm {args.realm_id})") + print(".".join(parts) + ".") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/provisioning/create_character.py b/tools/provisioning/create_character.py new file mode 100644 index 000000000..79039b7c0 --- /dev/null +++ b/tools/provisioning/create_character.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Create a WoW character by driving wowee_headless in provisioning mode.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + + +def load_settings(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as f: + doc = json.load(f) + if not isinstance(doc, dict): + raise ValueError("settings root must be a JSON object") + return doc + + +def set_nested(doc: dict[str, Any], section: str, key: str, value: Any) -> None: + obj = doc.setdefault(section, {}) + if not isinstance(obj, dict): + raise ValueError(f"{section} must be a JSON object") + obj[key] = value + + +def build_settings(args: argparse.Namespace) -> dict[str, Any]: + doc = load_settings(args.settings) + + if args.account: + set_nested(doc, "auth", "account", args.account) + if args.password: + set_nested(doc, "auth", "password", args.password) + if args.pin: + set_nested(doc, "auth", "pin", args.pin) + if args.totp_secret: + set_nested(doc, "auth", "totpSecret", args.totp_secret) + if args.auth_host: + set_nested(doc, "auth", "host", args.auth_host) + if args.auth_port: + set_nested(doc, "auth", "port", args.auth_port) + if args.realm: + set_nested(doc, "realm", "name", args.realm) + if args.client_expansion: + set_nested(doc, "client", "expansion", args.client_expansion) + for attr, key in ( + ("client_major", "major"), + ("client_minor", "minor"), + ("client_patch", "patch"), + ("client_build", "build"), + ("client_world_build", "worldBuild"), + ("client_protocol", "protocol"), + ): + value = getattr(args, attr) + if value is not None: + set_nested(doc, "client", key, value) + + set_nested(doc, "character", "name", args.name) + doc["provision"] = { + "createCharacter": { + "enabled": True, + "name": args.name, + "race": args.race, + "class": args.character_class, + "gender": args.gender, + "skin": args.skin, + "face": args.face, + "hairStyle": args.hair_style, + "hairColor": args.hair_color, + "facialHair": args.facial_hair, + "exitAfterCreate": True, + } + } + return doc + + +def build_runtime_env(args: argparse.Namespace) -> dict[str, str]: + env = os.environ.copy() + path_parts: list[str] = [] + + exe_dir = args.wowee_headless.resolve().parent + if exe_dir.exists(): + path_parts.append(str(exe_dir)) + + if args.msys_ucrt_bin: + msys_bin = args.msys_ucrt_bin + if msys_bin.exists(): + path_parts.append(str(msys_bin.resolve())) + else: + print(f"Warning: --msys-ucrt-bin does not exist: {msys_bin}", file=sys.stderr) + elif os.name == "nt": + default_msys_bin = Path("C:/msys64/ucrt64/bin") + if default_msys_bin.exists(): + path_parts.append(str(default_msys_bin)) + + if path_parts: + env["PATH"] = os.pathsep.join(path_parts + [env.get("PATH", "")]) + print("Added runtime PATH entries:", os.pathsep.join(path_parts)) + return env + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--settings", required=True, type=Path, help="Base wowee_headless settings JSON") + parser.add_argument("--wowee-headless", default="build/bin/wowee_headless.exe", type=Path) + parser.add_argument("--msys-ucrt-bin", type=Path, help="MSYS2 UCRT runtime bin directory; defaults to C:/msys64/ucrt64/bin on Windows if present") + parser.add_argument("--account", help="Override auth.account") + parser.add_argument("--password", help="Override auth.password") + parser.add_argument("--pin", help="Override auth.pin for PIN/TOTP challenge login") + parser.add_argument("--totp-secret", help="Override auth.totpSecret for runtime TOTP generation") + parser.add_argument("--auth-host", help="Override auth.host") + parser.add_argument("--auth-port", type=int, help="Override auth.port") + parser.add_argument("--realm", help="Override realm.name") + parser.add_argument("--client-expansion", choices=("classic", "tbc", "wotlk", "turtle"), help="Override client.expansion") + parser.add_argument("--client-major", type=int, help="Override client.major") + parser.add_argument("--client-minor", type=int, help="Override client.minor") + parser.add_argument("--client-patch", type=int, help="Override client.patch") + parser.add_argument("--client-build", type=int, help="Override client.build") + parser.add_argument("--client-world-build", type=int, help="Override client.worldBuild") + parser.add_argument("--client-protocol", type=int, help="Override client.protocol") + parser.add_argument("--name", required=True, help="Character name to create") + parser.add_argument("--race", required=True, help="Race name or numeric race id") + parser.add_argument("--class", required=True, dest="character_class", help="Class name or numeric class id") + parser.add_argument("--gender", default="male", help="male/female or 0/1") + parser.add_argument("--skin", type=int, default=0) + parser.add_argument("--face", type=int, default=0) + parser.add_argument("--hair-style", type=int, default=0) + parser.add_argument("--hair-color", type=int, default=0) + parser.add_argument("--facial-hair", type=int, default=0) + parser.add_argument("--dry-run", action="store_true", help="Print generated settings and do not run the client") + args = parser.parse_args(argv) + + generated = build_settings(args) + if args.dry_run: + print(json.dumps(generated, indent=2)) + return 0 + + with tempfile.TemporaryDirectory(prefix="wowee-create-character-") as tmp: + settings_path = Path(tmp) / "settings.json" + settings_path.write_text(json.dumps(generated, indent=2), encoding="utf-8") + cmd = [str(args.wowee_headless), str(settings_path)] + print("Running:", " ".join(cmd)) + return subprocess.call(cmd, env=build_runtime_env(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/provisioning/provision_roster.py b/tools/provisioning/provision_roster.py new file mode 100644 index 000000000..db3077804 --- /dev/null +++ b/tools/provisioning/provision_roster.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +"""Provision many CMaNGOS accounts and characters from one roster JSON file.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +TOOLS = ROOT / "tools" +PROVISIONING = TOOLS / "provisioning" + +SERVER_DEFAULTS = { + "cmangos": { + "accountExpansion": 1, + "authPort": 3724, + "realm": "MaNGOS", + "clientExpansion": "tbc", + "clientMajor": 2, + "clientMinor": 4, + "clientPatch": 3, + "clientBuild": 8606, + "clientProtocol": 8, + "gmlevel": 1, + }, + "azerothcore": { + "accountExpansion": 2, + "authPort": 3725, + "realm": "AzerothCore", + "clientExpansion": "wotlk", + "clientMajor": 3, + "clientMinor": 3, + "clientPatch": 5, + "clientBuild": 12340, + "clientProtocol": 8, + "gmlevel": 1, + }, + "vmangos": { + "accountExpansion": 0, + "authPort": 3726, + "realm": "VMangos", + "clientExpansion": "classic", + "clientMajor": 1, + "clientMinor": 12, + "clientPatch": 1, + "clientBuild": 5875, + "clientProtocol": 8, + "gmlevel": 1, + }, + "turtle": { + "accountExpansion": 0, + "authPort": 3730, + "realm": "Turtle WoW Local", + "clientExpansion": "turtle", + "clientMajor": 1, + "clientMinor": 17, + "clientPatch": 2, + "clientBuild": 7199, + "clientWorldBuild": 5875, + "clientProtocol": 8, + "gmlevel": 0, + }, +} + + +def load_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8-sig") as f: + doc = json.load(f) + if not isinstance(doc, dict): + raise ValueError("roster root must be a JSON object") + return doc + + +def bool_value(value: Any, fallback: bool) -> bool: + if value is None: + return fallback + return bool(value) + + +def string_value(value: Any, fallback: str = "") -> str: + if value is None: + return fallback + if not isinstance(value, str): + raise ValueError(f"expected string, got {type(value).__name__}") + return value + + +def path_from(value: str) -> Path: + path = Path(value) + return path if path.is_absolute() else ROOT / path + + +def scrub_command(cmd: list[str]) -> str: + scrubbed: list[str] = [] + hide_next = False + for item in cmd: + if hide_next: + scrubbed.append("********") + hide_next = False + continue + scrubbed.append(item) + if item in {"--password", "--soap-password", "--admin-pass", "--pin", "--totp-secret"}: + hide_next = True + return " ".join(scrubbed) + + +def run_step(cmd: list[str], dry_run: bool) -> int: + print(("Would run: " if dry_run else "Running: ") + scrub_command(cmd)) + if dry_run: + return 0 + return subprocess.call(cmd, cwd=ROOT) + + +def account_command( + args: argparse.Namespace, + server_type: str, + account: str, + password: str, + expansion: int, + gmlevel: int = 0, + realm_id: int = -1, + pin: str = "", + totp_secret: str = "", +) -> list[str]: + if args.account_mode == "ssh": + cmd = [ + sys.executable, + str(args.account_ssh_script), + account, + "--server-type", + server_type, + "--password", + password, + "--expansion", + str(expansion), + "--gmlevel", + str(gmlevel), + "--realm-id", + str(realm_id), + ] + if args.env: + cmd.extend(["--env", str(args.env)]) + if pin: + cmd.extend(["--pin", pin]) + if totp_secret: + cmd.extend(["--totp-secret", totp_secret]) + return cmd + + cmd = [ + sys.executable, + str(args.account_direct_script), + "create", + "--server-type", + server_type, + "--soap-url", + args.soap_url, + "--admin-user", + args.admin_user, + "--admin-pass", + args.admin_pass, + "--account", + account, + "--password", + password, + "--expansion", + str(expansion), + ] + if gmlevel > 0: + cmd.extend(["--gmlevel", str(gmlevel), "--realm-id", str(realm_id)]) + return cmd + + +def character_command( + args: argparse.Namespace, + server_type: str, + defaults: dict[str, Any], + account_doc: dict[str, Any], + character_doc: dict[str, Any], + account: str, + password: str, + pin: str = "", + totp_secret: str = "", +) -> list[str]: + server_defaults = SERVER_DEFAULTS[server_type] + settings = string_value(character_doc.get("settings"), string_value(account_doc.get("settings"), string_value(defaults.get("settings")))) + wowee = string_value( + character_doc.get("woweeHeadless"), + string_value(account_doc.get("woweeHeadless"), string_value(defaults.get("woweeHeadless"), "build/bin/wowee_headless.exe")), + ) + name = string_value(character_doc.get("name")) + race = string_value(character_doc.get("race"), string_value(account_doc.get("race"), string_value(defaults.get("race"), "human"))) + cls = string_value(character_doc.get("class"), string_value(account_doc.get("class"), string_value(defaults.get("class"), "warrior"))) + gender = string_value(character_doc.get("gender"), string_value(account_doc.get("gender"), string_value(defaults.get("gender"), "male"))) + auth_host = string_value( + character_doc.get("authHost"), + string_value(account_doc.get("authHost"), string_value(defaults.get("authHost"), string_value(args.auth_host))), + ) + auth_port = character_doc.get("authPort", account_doc.get("authPort", defaults.get("authPort", args.auth_port))) + auth_port = int(auth_port) if auth_port is not None else int(server_defaults["authPort"]) + realm = string_value( + character_doc.get("realm"), + string_value(account_doc.get("realm"), string_value(defaults.get("realm"), str(server_defaults["realm"]))), + ) + client_expansion = string_value( + character_doc.get("clientExpansion"), + string_value(account_doc.get("clientExpansion"), string_value(defaults.get("clientExpansion"), str(server_defaults["clientExpansion"]))), + ) + client_major = int(character_doc.get("clientMajor", account_doc.get("clientMajor", defaults.get("clientMajor", server_defaults["clientMajor"])))) + client_minor = int(character_doc.get("clientMinor", account_doc.get("clientMinor", defaults.get("clientMinor", server_defaults["clientMinor"])))) + client_patch = int(character_doc.get("clientPatch", account_doc.get("clientPatch", defaults.get("clientPatch", server_defaults["clientPatch"])))) + client_build = int(character_doc.get("clientBuild", account_doc.get("clientBuild", defaults.get("clientBuild", server_defaults["clientBuild"])))) + client_world_build = int( + character_doc.get( + "clientWorldBuild", + account_doc.get("clientWorldBuild", defaults.get("clientWorldBuild", server_defaults.get("clientWorldBuild", client_build))), + ) + ) + client_protocol = int( + character_doc.get("clientProtocol", account_doc.get("clientProtocol", defaults.get("clientProtocol", server_defaults["clientProtocol"]))) + ) + + if not settings: + raise ValueError(f"{account}/{name}: settings is required") + if not name: + raise ValueError(f"{account}: character name is required") + + cmd = [ + sys.executable, + str(args.character_script), + "--settings", + str(path_from(settings)), + "--wowee-headless", + str(path_from(wowee)), + "--account", + account, + "--password", + password, + "--auth-port", + str(auth_port), + "--realm", + realm, + "--client-expansion", + client_expansion, + "--client-major", + str(client_major), + "--client-minor", + str(client_minor), + "--client-patch", + str(client_patch), + "--client-build", + str(client_build), + "--client-world-build", + str(client_world_build), + "--client-protocol", + str(client_protocol), + "--name", + name, + "--race", + race, + "--class", + cls, + "--gender", + gender, + ] + if pin: + cmd.extend(["--pin", pin]) + if totp_secret: + cmd.extend(["--totp-secret", totp_secret]) + if auth_host: + cmd.extend(["--auth-host", auth_host]) + + for option_name, cli_name in ( + ("skin", "--skin"), + ("face", "--face"), + ("hairStyle", "--hair-style"), + ("hairColor", "--hair-color"), + ("facialHair", "--facial-hair"), + ): + value = character_doc.get(option_name, account_doc.get(option_name, defaults.get(option_name))) + if value is not None: + cmd.extend([cli_name, str(int(value))]) + + return cmd + + +def iter_characters(account_doc: dict[str, Any]) -> list[dict[str, Any]]: + if "characters" in account_doc: + characters = account_doc["characters"] + if not isinstance(characters, list): + raise ValueError("account.characters must be an array") + return [ch for ch in characters if isinstance(ch, dict)] + if "character" in account_doc: + character = account_doc["character"] + if isinstance(character, str): + return [{"name": character}] + if isinstance(character, dict): + return [character] + return [] + + +def provision(args: argparse.Namespace) -> int: + roster = load_json(args.roster) + defaults = roster.get("defaults", {}) + if not isinstance(defaults, dict): + raise ValueError("defaults must be an object") + accounts = roster.get("accounts", []) + if not isinstance(accounts, list): + raise ValueError("accounts must be an array") + + failures = 0 + for raw_account in accounts: + if not isinstance(raw_account, dict): + continue + account = string_value(raw_account.get("account")).strip() + password = string_value(raw_account.get("password"), string_value(defaults.get("accountPassword"))).strip() + pin = string_value(raw_account.get("pin"), string_value(defaults.get("pin"))).strip() + totp_secret = string_value(raw_account.get("totpSecret"), string_value(defaults.get("totpSecret"))).strip() + server_type = args.server_type or string_value(raw_account.get("serverType"), string_value(defaults.get("serverType"), "cmangos")) + if server_type not in SERVER_DEFAULTS: + raise ValueError(f"{account or ''}: unknown serverType {server_type!r}") + expansion = int(raw_account.get("expansion", defaults.get("expansion", SERVER_DEFAULTS[server_type]["accountExpansion"]))) + gmlevel = int(raw_account.get("gmlevel", defaults.get("gmlevel", SERVER_DEFAULTS[server_type]["gmlevel"]))) + realm_id = int(raw_account.get("realmId", defaults.get("realmId", -1))) + if not account: + raise ValueError("account is required") + if not password: + raise ValueError(f"{account}: password is required") + + create_account = bool_value(raw_account.get("createAccount"), bool_value(defaults.get("createAccount"), True)) + create_character = bool_value(raw_account.get("createCharacter"), bool_value(defaults.get("createCharacter"), True)) + + if create_account and not args.skip_accounts: + rc = run_step(account_command(args, server_type, account, password, expansion, gmlevel, realm_id, pin, totp_secret), args.dry_run) + if rc != 0: + failures += 1 + print(f"Account step failed for {account} with exit code {rc}", file=sys.stderr) + if not args.continue_on_error: + return rc + + if create_character and not args.skip_characters: + for character in iter_characters(raw_account): + try: + character_pin = string_value(character.get("pin"), pin).strip() + character_totp_secret = string_value(character.get("totpSecret"), totp_secret).strip() + cmd = character_command( + args, + server_type, + defaults, + raw_account, + character, + account, + password, + character_pin, + character_totp_secret, + ) + except Exception as exc: + failures += 1 + print(f"Character step setup failed for {account}: {exc}", file=sys.stderr) + if not args.continue_on_error: + return 2 + continue + rc = run_step(cmd, args.dry_run) + if rc != 0: + failures += 1 + print(f"Character step failed for {account} with exit code {rc}", file=sys.stderr) + if not args.continue_on_error: + return rc + + if failures: + print(f"Provisioning finished with {failures} failure(s).") + return 1 + print("Provisioning finished successfully.") + return 0 + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument("roster", type=Path, help="Roster JSON file") + parser.add_argument("--server-type", choices=sorted(SERVER_DEFAULTS), help="Default server type when roster omits defaults.serverType") + parser.add_argument("--account-mode", choices=("ssh", "direct-soap"), default="ssh") + parser.add_argument("--env", type=Path, default=TOOLS / ".env", help="Env file for SSH account mode") + parser.add_argument("--account-ssh-script", type=Path, default=PROVISIONING / "create_account_ssh.py") + parser.add_argument("--account-direct-script", type=Path, default=PROVISIONING / "create_account_direct_soap.py") + parser.add_argument("--character-script", type=Path, default=PROVISIONING / "create_character.py") + parser.add_argument("--soap-url", default="", help="Direct SOAP mode URL") + parser.add_argument("--admin-user", default="", help="Direct SOAP mode admin user") + parser.add_argument("--admin-pass", default="", help="Direct SOAP mode admin password") + parser.add_argument("--auth-host", default="", help="Character creation auth host override") + parser.add_argument("--auth-port", type=int, help="Character creation auth port override") + parser.add_argument("--skip-accounts", action="store_true") + parser.add_argument("--skip-characters", action="store_true") + parser.add_argument("--continue-on-error", action="store_true") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args(argv) + + if args.account_mode == "direct-soap" and not args.skip_accounts: + missing = [name for name in ("soap_url", "admin_user", "admin_pass") if not getattr(args, name)] + if missing: + parser.error("--account-mode direct-soap requires --soap-url, --admin-user, and --admin-pass") + return args + + +def main(argv: list[str] | None = None) -> int: + try: + return provision(parse_args(argv)) + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/provisioning/roster.example.json b/tools/provisioning/roster.example.json new file mode 100644 index 000000000..3e7e837a4 --- /dev/null +++ b/tools/provisioning/roster.example.json @@ -0,0 +1,34 @@ +{ + "defaults": { + "settings": "tools/headless_client/settings.json", + "woweeHeadless": "build/bin/wowee_headless.exe", + "accountPassword": "change-me", + "serverType": "cmangos", + "race": "human", + "class": "warrior", + "gender": "male", + "createAccount": true, + "createCharacter": true + }, + "accounts": [ + { + "account": "BOT001", + "password": "change-me", + "characters": [ + { + "name": "Botone" + } + ] + }, + { + "account": "BOT002", + "password": "change-me", + "characters": [ + { + "name": "Bottwo", + "class": "paladin" + } + ] + } + ] +} diff --git a/tools/provisioning/turtle_pin.py b/tools/provisioning/turtle_pin.py new file mode 100644 index 000000000..b22ece59b --- /dev/null +++ b/tools/provisioning/turtle_pin.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Print the current Turtle WoW PIN from a local roster totpSecret.""" + +from __future__ import annotations + +import argparse +import base64 +import hmac +import hashlib +import json +import struct +import time +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_ROSTER = ROOT / "tools" / "provisioning" / "roster.json" + + +def load_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8-sig") as handle: + doc = json.load(handle) + if not isinstance(doc, dict): + raise ValueError("roster root must be an object") + return doc + + +def normalize_secret(secret: str) -> str: + return "".join(ch for ch in secret.upper() if not ch.isspace()).rstrip("=") + + +def decode_base32_secret(secret: str) -> bytes: + normalized = normalize_secret(secret) + if not normalized: + raise ValueError("totpSecret is empty") + padding = "=" * ((8 - (len(normalized) % 8)) % 8) + try: + return base64.b32decode(normalized + padding, casefold=True) + except Exception as exc: + raise ValueError("totpSecret is not valid base32") from exc + + +def totp(secret: str, when: int | None = None) -> int: + timestamp = int(time.time() if when is None else when) + counter = timestamp // 30 + key = decode_base32_secret(secret) + digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest() + offset = digest[-1] & 0x0F + value = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF + return value % 1_000_000 + + +def find_account(doc: dict[str, Any], account_name: str) -> dict[str, Any]: + accounts = doc.get("accounts", []) + if not isinstance(accounts, list): + raise ValueError("roster accounts must be an array") + if not accounts: + raise ValueError("roster has no accounts") + + if account_name: + for account in accounts: + if not isinstance(account, dict): + continue + if str(account.get("account", "")).lower() == account_name.lower(): + return account + raise ValueError(f"account not found in roster: {account_name}") + + first = accounts[0] + if not isinstance(first, dict): + raise ValueError("first roster account is not an object") + return first + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--roster", type=Path, default=DEFAULT_ROSTER, help="Local roster JSON with totpSecret") + parser.add_argument("--account", default="", help="Account name to use when roster has multiple accounts") + parser.add_argument("--at", type=int, help="Unix timestamp for testing; defaults to now") + args = parser.parse_args() + + doc = load_json(args.roster) + defaults = doc.get("defaults", {}) + if not isinstance(defaults, dict): + defaults = {} + account = find_account(doc, args.account) + secret = str(account.get("totpSecret") or defaults.get("totpSecret") or "").strip() + pin = totp(secret, args.at) + now = int(time.time() if args.at is None else args.at) + remaining = 30 - (now % 30) + account_name = str(account.get("account", "")) + + print(f"Account: {account_name}") + print(f"PIN: {pin:06d}") + print(f"Valid for: {remaining}s") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/start_supervisor.py b/tools/start_supervisor.py new file mode 100644 index 000000000..7cd227221 --- /dev/null +++ b/tools/start_supervisor.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Start supervisor in background, detach so the shell tool won't kill children.""" +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +cmd = [ + sys.executable, + str(ROOT / "tools/bot_fleet_manager/bot_fleet_manager.py"), + str(ROOT / "tools/bot_fleet_manager/fleet.settings.json"), + "supervise", +] + +log = open(ROOT / "build/supervisor_out.log", "w") +proc = subprocess.Popen( + cmd, + cwd=str(ROOT), + stdout=log, + stderr=log, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS, +) +print(f"Supervisor PID: {proc.pid}") +sys.exit(0) \ No newline at end of file diff --git a/tools/world_db_query/README.md b/tools/world_db_query/README.md new file mode 100644 index 000000000..9b2a125d3 --- /dev/null +++ b/tools/world_db_query/README.md @@ -0,0 +1,48 @@ +# World DB Query + +A narrowly-scoped wrapper letting `mangos-helper` run read-only `SELECT` +queries against the CMaNGOS world database, without ever being handed the +DB credentials directly (they live in `mangosd.conf`, which `mangos-helper` +can't read on its own). + +Originally built to scrape road signpost `gameobject`/`gameobject_template` +rows for road-network mapping (see BOT_PLAN.md), but usable for any +read-only investigative query against the world DB. + +## Deploy + +Initial setup (manual, on the server - not done by the agent): + +1. Copy `query_world_db.sh` to the server, e.g.: + ``` + scp query_world_db.sh you@server:/home/josh/mangos-tbc/tools/query_world_db.sh + ``` +2. Make it root-owned and not writable by `mangos-helper`: + ``` + sudo chown root:root /home/josh/mangos-tbc/tools/query_world_db.sh + sudo chmod 750 /home/josh/mangos-tbc/tools/query_world_db.sh + ``` +3. Install the sudoers grant (see `mangos-helper.sudoers` in the repo root, + `PROJECT_WORLD_DB_QUERY` and `PROJECT_INSTALL` aliases): + ``` + sudo visudo -f /etc/sudoers.d/mangos-helper + ``` + +After that initial setup, `mangos-helper` can redeploy this one script on +its own (fixes, tweaks) via the scoped `PROJECT_INSTALL` grant: +``` +scp query_world_db.sh mangos-helper@server:/tmp/query_world_db.sh +ssh mangos-helper@server "sudo install -m 0750 -o root -g root /tmp/query_world_db.sh /home/josh/mangos-tbc/tools/query_world_db.sh" +``` +Only this exact source/destination pair is permitted - not a general +file-install grant. + +## Usage + +``` +sudo /home/josh/mangos-tbc/tools/query_world_db.sh "SELECT gt.name, go.map, go.position_x, go.position_y, go.position_z FROM gameobject go JOIN gameobject_template gt ON go.id = gt.entry WHERE go.map = 0 AND gt.name LIKE '%Sign%' LIMIT 100" +``` + +Only single `SELECT` statements are allowed - the script rejects anything +containing write keywords (`INSERT`/`UPDATE`/`DELETE`/`DROP`/etc.), +`LOAD_FILE`/`INTO OUTFILE`, or a stacked second statement. diff --git a/tools/world_db_query/query_world_db.sh b/tools/world_db_query/query_world_db.sh new file mode 100644 index 000000000..1605e9fcb --- /dev/null +++ b/tools/world_db_query/query_world_db.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Runs a single read-only SELECT against the CMaNGOS world database. +# +# Exists so mangos-helper can query the world DB (e.g. gameobject/ +# gameobject_template for road signposts, per BOT_PLAN's road-survey work) +# without ever being handed the DB credentials directly - this script reads +# them out of mangosd.conf itself (which mangos-helper cannot read - it's +# owned by josh/root) and connects internally. The sudoers grant should +# only permit executing this exact script path, as root, matching the +# scoping already used for MoveMapGen. +# +# Deploy: place this file somewhere root-owned and not writable by +# mangos-helper (e.g. /home/josh/mangos-tbc/tools/query_world_db.sh), +# chmod 750 root:root, then grant mangos-helper sudo NOPASSWD execution of +# that exact path (see mangos-helper.sudoers in the WoWee repo). +# +# Usage: sudo /path/to/query_world_db.sh "SELECT ... FROM gameobject ..." +set -euo pipefail + +CONF=/home/josh/mangos-tbc/run/etc/mangosd.conf + +if [ "$#" -ne 1 ]; then + echo "usage: $0 \"SELECT ...\"" >&2 + exit 2 +fi +QUERY="$1" + +# Only a single, plain SELECT - no writes, no stacked statements, no +# filesystem access via LOAD_FILE/INTO OUTFILE. +NORMALIZED=$(echo "$QUERY" | tr '[:upper:]' '[:lower:]') +if [[ "$NORMALIZED" != select* ]]; then + echo "ERROR: only SELECT statements are allowed" >&2 + exit 1 +fi +if echo "$NORMALIZED" | grep -qE '\b(insert|update|delete|drop|alter|create|grant|revoke|truncate|replace|call|into[[:space:]]+outfile|into[[:space:]]+dumpfile|load_file)\b'; then + echo "ERROR: query contains a disallowed keyword" >&2 + exit 1 +fi +# tr, not grep -o|wc -l, so a query with zero semicolons (the common case) +# doesn't trip grep's "no match" exit 1 and silently kill the script under +# set -e before this check ever runs. +SEMI_COUNT=$(tr -dc ';' <<< "$QUERY" | wc -c) +if [ "$SEMI_COUNT" -gt 1 ]; then + echo "ERROR: only a single statement is allowed" >&2 + exit 1 +fi + +if [ ! -r "$CONF" ]; then + echo "ERROR: cannot read $CONF" >&2 + exit 1 +fi + +LINE=$(grep -E '^\s*WorldDatabaseInfo\s*=' "$CONF" | head -1 || true) +if [ -z "$LINE" ]; then + echo "ERROR: WorldDatabaseInfo not found in $CONF" >&2 + exit 1 +fi +# Format: WorldDatabaseInfo = "host;port;user;pass;dbname" +INFO=$(echo "$LINE" | sed -E 's/^[^"]*"([^"]*)".*/\1/') +DBHOST=$(echo "$INFO" | cut -d';' -f1) +DBPORT=$(echo "$INFO" | cut -d';' -f2) +DBUSER=$(echo "$INFO" | cut -d';' -f3) +DBPASS=$(echo "$INFO" | cut -d';' -f4) +DBNAME=$(echo "$INFO" | cut -d';' -f5) + +mysql -h "$DBHOST" -P "$DBPORT" -u "$DBUSER" -p"$DBPASS" "$DBNAME" -e "$QUERY"