Support interactive object portals and document travel route recording
GCC PCH / build (push) Canceled after 0s
clang PCH / build (push) Canceled after 0s

Bind directed portal_use edges to native GameObject spawns and spell destinations. Execute normal clicks and verify completed clientless teleports, with regression coverage. Document live portal tests, mage tower calibration, Badlands portals and transport recording workflows.
This commit is contained in:
Nico committed 2026-10-26 14:01:42 +01:00
1 parent 1164a72ea3
commit 4474714c00
18 files changed
+1374 -5

No files matched your search

@@ -6,6 +6,9 @@
#include "Player.h"
#include "ClientlessTravelMgr.h"
#include "PathGenerator.h" // VCORE_C818_PREDICTIVE_GRAPH
#include "ObjectMgr.h"
#include "SpellMgr.h"
#include "SpellInfo.h"
#include <algorithm>
#include <cmath>
@@ -27,11 +30,80 @@ bool ClientlessTravelGraphMgr::EnsureLoaded()
return Reload();
}
bool ClientlessTravelGraphMgr::ResolvePortalUse(uint32 spawnId,
ClientlessPortalUse& portal, std::string& error)
{
portal = {};
auto const* spawn = sObjectMgr->GetGameObjectData(spawnId);
auto const* info = spawn ? sObjectMgr->GetGameObjectTemplate(spawn->id) : nullptr;
if (!info || info->type != GAMEOBJECT_TYPE_SPELLCASTER)
{
error = "spawn must be a static SPELLCASTER GameObject (type 22)";
return false;
}
SpellInfo const* spell = sSpellMgr->GetSpellInfo(info->spellcaster.spell);
SpellTargetPosition const* destination = nullptr;
if (spell)
for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i)
{
auto const& effect = spell->Effects[i];
if (effect.Effect != SPELL_EFFECT_TELEPORT_UNITS)
continue;
// Only explicit database destinations are unambiguous. Homebind,
// script-selected destinations and triggered spell chains need
// their own interaction definitions, not guessed target nodes.
if (destination || (effect.TargetA.GetTarget() != TARGET_DEST_DB &&
effect.TargetB.GetTarget() != TARGET_DEST_DB))
{
error = "portal spell has an ambiguous or non-DB destination";
return false;
}
destination = sSpellMgr->GetSpellTargetPosition(spell->Id, SpellEffIndex(i));
if (!destination)
{
error = "portal teleport effect has no native spell_target_position";
return false;
}
}
if (!destination)
{
error = "portal requires a direct teleport spell with a fixed DB destination";
return false;
}
portal.SpawnId = spawnId;
portal.Entry = spawn->id;
portal.SpellId = spell->Id;
portal.Source.Key = "portal_go_" + std::to_string(spawnId) + "_source";
portal.Source.Type = "portal";
portal.Source.WorldPointKey = portal.Source.Key;
portal.Source.MapId = spawn->mapId;
portal.Source.X = spawn->spawnPoint.GetPositionX();
portal.Source.Y = spawn->spawnPoint.GetPositionY();
portal.Source.Z = spawn->spawnPoint.GetPositionZ();
portal.Source.O = spawn->spawnPoint.GetOrientation();
portal.Target.Key = "portal_go_" + std::to_string(spawnId) + "_target";
portal.Target.Type = "portal";
portal.Target.WorldPointKey = portal.Target.Key;
portal.Target.MapId = destination->target_mapId;
portal.Target.X = destination->target_X;
portal.Target.Y = destination->target_Y;
portal.Target.Z = destination->target_Z;
portal.Target.O = destination->target_Orientation;
return true;
}
ClientlessPortalUse const* ClientlessTravelGraphMgr::GetPortalUse(uint32 edgeDatabaseId) const
{
auto itr = _portalUses.find(edgeDatabaseId);
return itr != _portalUses.end() ? &itr->second : nullptr;
}
bool ClientlessTravelGraphMgr::Reload()
{
_nodes.clear();
_edges.clear();
_edgePoints.clear();
_portalUses.clear();
_edgeCount = 0;
QueryResult nodeResult = WorldDatabase.Query(
@@ -66,6 +138,27 @@ bool ClientlessTravelGraphMgr::Reload()
while (nodeResult->NextRow());
}
std::unordered_map<uint32, ClientlessPortalUse> portalBindings;
// The schema is opt-in, so existing installations retain their graph even
// before applying the interactive-portal migration.
if (WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_portal_use'"))
if (QueryResult bindings = WorldDatabase.Query(
"SELECT edge_id,gameobject_spawn_id,gameobject_entry,spell_id FROM vcore_clientless_portal_use"))
do
{
Field* fields = bindings->Fetch();
ClientlessPortalUse portal;
std::string error;
if (!ResolvePortalUse(fields[1].GetUInt32(), portal, error) ||
portal.Entry != fields[2].GetUInt32() || portal.SpellId != fields[3].GetUInt32())
{
TC_LOG_WARN("server.adventurebot.navigation", "PORTAL_USE_BINDING_INVALID edge=%u reason='%s'",
fields[0].GetUInt32(), error.c_str());
continue;
}
portalBindings.emplace(fields[0].GetUInt32(), std::move(portal));
} while (bindings->NextRow());
QueryResult edgeResult = WorldDatabase.Query(
"SELECT id, from_node_key, to_node_key, edge_type, cost, bidirectional "
"FROM vcore_clientless_travel_edge "
@@ -101,6 +194,24 @@ bool ClientlessTravelGraphMgr::Reload()
if (edge.Cost <= 0.0f)
edge.Cost = 1.0f;
if (edge.Type == "portal_use")
{
auto binding = portalBindings.find(edge.DatabaseId);
auto const* portal = binding != portalBindings.end() ? &binding->second : nullptr;
auto const& target = _nodes.at(edge.ToNode);
if (!portal || bidirectional || edge.FromNode != portal->Source.Key ||
edge.ToNode != portal->Target.Key ||
_nodes.at(edge.FromNode).MapId != portal->Source.MapId ||
target.MapId != portal->Target.MapId ||
std::hypot(std::hypot(target.X - portal->Target.X, target.Y - portal->Target.Y),
target.Z - portal->Target.Z) > 1.0f)
{
TC_LOG_WARN("server.adventurebot.navigation", "PORTAL_USE_EDGE_UNAVAILABLE edge=%u", edge.DatabaseId);
continue;
}
_portalUses.emplace(edge.DatabaseId, *portal);
}
if (ClientlessTransportMgr::IsTransportEdge(edge.Type) &&
!sClientlessTransportMgr->HasConnection(edge.FromNode, edge.ToNode))
{