Improve AdventureBot instance and flight navigation
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s

This commit is contained in:
Nico committed 2026-11-01 17:00:59 +01:00
1 parent be049baacc
commit 3c361e435a
14 files changed
+525 -63

No files matched your search

+40
View File
@@ -1,3 +1,43 @@
# Aktuell: Quest-unabhängige Portalnutzung für AdventureBots ab Stufe 80
AdventureBots können keinen Questfortschritt erwerben. Graphgebundene öffentliche
`portal_use`-Verbindungen gelten deshalb für AdventureBots ab Stufe80 als
freigeschaltet. Normale Spieler, Testbots und niedrigere AdventureBots behalten
die nativen Spell-/Questbedingungen. Fraktion, Gebietskatalog, Node/Edge-Zugang,
sichtbares/erreichbares Portalobjekt und bestätigte Zielankunft bleiben zwingend.
Sind native Bedingungen erfüllt, läuft weiterhin der echte CMSG_GAMEOBJ_USE-Pfad.
Andernfalls teleportiert ausschließlich der qualifizierte AdventureBot zum beim
Import validierten festen Portalziel; der Executor verlangt danach neue Teleport-
Sequenz, richtige Karte und maximal5Yards Zielfehler. Portal-, Prerequisite-,
Flug- und Ground-Exit-Tests PASS, Build-j10 PASS, installiert. Frischer PID174775
ready, Ports8085/8087. Elf Restbots nach Restart ohne alte Flug/Taxi/Transport-
Zustände geprüft. Temporäres Elanora-Override entfernt; Loader/Testbetrieb
wiederhergestellt (`enabled=1`, `schema=1`, `busy=0`, Pools `50/5/50/5`). Details unter Checkpoint
`doc/clientless/checkpoints/2026-11-01-adventure-portal-unlock/`. Kein Commit.
---
# Aktuell: generischer Flug-Wiedereinstieg gebaut, installiert und live geprüft
PlanAdaptiveRoute kann nun jeden erlaubten Graphknoten als begrenzten freien
Flug-Wiedereinstieg verwenden, ohne daraus einen vollständigen Luftgraphen zu
materialisieren. Makroanschlüsse werden priorisiert, maximal16 Flugschätzungen
pro Plan und6 pro Ausgangsknoten; der konkrete Zielknoten wird in jedem lokalen
Fenster zuerst geprüft. Portalquellen werden über ihre tatsächlichen ausgehenden
`portal`/`portal_use`-Kanten erkannt statt über Namenskonventionen. Am erreichten
Portal sind nur diese Kanten zulässig. Regression deckt Flug->beliebig benannter
Portalquellknoten->portal_use->Flug ab. Drei gezielte Tests, diff-check und zwei
abschließende Builds ausschließlich-j10 bestanden. Installiert; frischer PID
172446 ready, Ports8085/8087. Restbots77/153 nach Restart ohne alte Flug- oder
Transportzustände. Elanora erreichte live eine gültige freie Flugroute zu einem
regulären Zielknoten. Das SW-Twilight-Portal wurde wegen nativer Spell-/Quest-
Voraussetzungen für Elanora nicht gewählt, nicht wegen des Plannerbudgets.
Temporäres Override entfernt, Schedule wieder an, Loader/Testbetrieb restauriert;
Status enabled1/schema1/busy0, Pools50/5/50/5. Details im Checkpoint
`doc/clientless/checkpoints/2026-11-01-generic-flight-entry/`. Kein Commit/Push.
---
# Aktuell: finaler Gruppenbildungsmanager implementiert und installiert
Nutzerauftrag: Ab zehn Minuten vor Ende von `groupformation` sollen Botgruppen
@@ -0,0 +1,50 @@
# Adventure portal unlock build recovery
Safe logout was requested while the `-j10` build started in parallel. The build
completed successfully. At the five-real-minute installation boundary eleven
managed sessions remained (`combat=4`, `transport=0`, `flight/taxi=7`). `saveall`
completed before shutdown.
| GUID | Name | Faction | Saved position | Observed transition/action |
|---:|---|---|---|---|
| 26 | Arthoran | Alliance | map0 `-11080.00,-483.45,54.49` | failed bounded free flight; mounted hold |
| 60 | Ashani | Alliance | map530 `-3013.51,-13204.10,59.63` | native taxi 94 -> 27 |
| 77 | Drakogzug | Horde | map1 `1852.91,-4570.06,24.98` | no flight/transport; failed departure recovery |
| 80 | Noralin | Alliance | map530 `-3013.51,-13204.10,59.63` | native taxi 94 -> 27 |
| 90 | Dorndin | Alliance | map1 `7990.23,1296.75,59.63` | native taxi 27 -> 94 |
| 93 | Gargar | Horde | map571 `2277.05,5202.07,12.49` | flight arrived; zeppelin state arrived |
| 96 | Darden | Alliance | map0 `-9381.78,-59.96,93.02` | failed bounded free flight; mounted hold |
| 97 | Talzul | Horde | map1 `2035.92,-3467.29,171.71` | failed bounded free flight; mounted hold |
| 132 | Braneron | Horde | map0 `2602.82,-112.11,34.02` | idle; no flight/transport |
| 135 | Ragnhild | Alliance | map1 `7990.23,1296.75,59.63` | native taxi 27 -> 94 |
| 153 | Ragnli | Alliance | map0 `-11787.06,-3180.12,-29.71` | idle; no flight/transport |
Loader configuration and test admission remained disabled/paused through the
build, installation and restart window. Post-start recovery outcomes are added
below after verification.
## Post-start verification
- Fresh worldserver PID 174775 reached ready state; TCP 8085 and 8087 listen.
- All eleven recorded characters were loaded. Every prior free-flight, native
taxi and transport state was absent after login; ordinary schedules then
produced new behavior independently of the saved transition state.
- Targeted production-method tests exercise both paths: native GameObject use
when prerequisites pass, and the importer-validated fixed teleport when a
level-80+ AdventureBot lacks quest prerequisites. Arrival still requires a
changed clientless teleport sequence, correct map and <=5-yard target error.
- A level-85 Elanora live probe planned and traversed graph-bound capital portal
travel. The explicit Twilight Highlands acceptance and its actual native
condition state are documented below.
## Explicit Twilight Highlands acceptance
Elanora (level 85) was placed within native interaction range of Stormwind spawn
200627 and given only target `portal_go_200627_target`. Production navigation
log at 2026-11-01 16:57:17 records edge 1801 `portal_use`, spawn 200627, spell
85545, followed in the same second by `EDGE_COMPLETE` on map 0 with
`arrivalError=0.00`. Runtime area query at the destination resolved area 5140,
allowed, level band 84-85. The World DB has no spell-condition rows for 85545,
so this particular portal correctly used the unchanged native click path rather
than `PORTAL_ADVENTURE_UNLOCK`. The questless unlock path remains covered by the
production-method regression. Temporary Elanora profile and override removed.
@@ -0,0 +1,103 @@
# Generic flight-entry build recovery
- Safe logout requested at approximately 2026-11-01 12:02 CET after pausing
`Clientless.Adventure.Loader` and TestBot admission.
- The `-j10` build ran in parallel with the safe drain, per the corrected build
policy in `AGENTS.md`, and completed successfully.
- At 12:08 CET, more than five real minutes later, 13 managed sessions remained:
3 combat flags, 0 active transports and 9 native flight flags. The database
positions below are from the immediately preceding online snapshot; native
action/flight/transport diagnostics were also captured in the tmux console.
| GUID | Name | faction | saved map / position | observed state |
|---:|---|---|---|---|
| 40 | Drakagmok | Horde | 1 / -4777.22 -1264.89 15.78 | native taxi 22->42; no free-flight/transport record |
| 48 | Elaniel | Horde | 1 / -4647.84 -1609.87 23.93 | native taxi 22->42; no free-flight/transport record |
| 54 | Falaeriel | Horde | 0 / 1875.88 -5018.08 166.81 | native taxi chain; prior free flight arrived |
| 77 | Drakogzug | Horde | 1 / 1857.05 -4568.03 24.98 | travel completed; no active flight/transport |
| 80 | Noralin | Alliance | 1 / 7990.23 1296.75 59.63 | native taxi; old ship record already Arrived |
| 83 | Mahuwa | Horde | 571 / 4234.13 3346.45 391.19 | failed free flight/permission timeout |
| 87 | Moggra | Horde | 1 / -3230.20 -1945.24 156.47 | native taxi to node55 |
| 91 | Norara | Horde | 571 / 3588.17 6692.83 208.30 | failed bounded flight corridor |
| 104 | Lillini | Alliance | 1 / 2669.46 1667.13 393.02 | native taxi; old flight/ship records Arrived |
| 118 | Lillina | Alliance | 0 / -8768.04 583.35 133.27 | failed bounded flight corridor |
| 132 | Braneron | Horde | 0 / 2539.96 -53.78 28.11 | idle; no flight/transport state |
| 146 | Loraria | Horde | 1 / -4215.50 -1913.36 131.73 | native taxi 22->42 |
| 153 | Ragnli | Alliance | 0 / -11787.10 -3180.12 -29.71 | idle; no flight/transport state |
No bot was individually force-logged-out, teleported, detached, or removed to
enable the build. Installation/restart uses the documented five-minute clean
shutdown exception. Post-start recovery results must be appended below.
## Restart outcome
Pending.
## Second build drain
- A second safe drain was requested at approximately 12:15 CET for the
flight-budget correction. The incremental `-j10` build again ran in parallel
and succeeded.
- After the five-minute gate, only two sessions remained online; neither had an
active free-flight or transport record:
| GUID | Name | faction | saved map / position | observed state |
|---:|---|---|---|---|
| 77 | Drakogzug | Horde | 1 / 1852.91 -4570.06 24.98 | completed location travel; outdoors=0 |
| 153 | Ragnli | Alliance | 0 / -11787.10 -3180.12 -29.71 | idle; no flight/transport state |
The other three members of the earlier five-bot snapshot logged out safely
before this final snapshot. No individual force logout was used.
## Third build drain
- Final macro-connector prioritization build started in parallel with a safe
drain at approximately 15:47 CET and completed successfully with `-j10`.
- At the 15:52 CET installation gate, GUID 77 Drakogzug and GUID 153 Ragnli
remained in combat at the same positions recorded above. Both had zero active
free-flight and transport records. They were not interrupted or individually
logged out; the documented clean-shutdown exception was used.
## Fourth build drain (portal-continuation budget fix)
- Safe logout requested at approximately 2026-11-01 15:54:30 CET. The `-j10`
build started immediately in parallel and completed successfully.
- At the five-minute installation boundary, two clientless sessions remained;
neither was in free flight, taxi travel or on a transport:
- GUID 77, Drakogzug, Horde, map 1 at `(1852.91, -4570.06, 24.98)`, area
1637. Action had failed at `flight_departure`; no free-flight or transport
state was present.
- GUID 153, Ragnli, Alliance, map 0 at `(-11787.06, -3180.12, -29.71)`, area
72. Action executor was idle; no free-flight or transport state was present.
- GUID 168 Elanora completed her active movement state and logged out safely
before the boundary. The temporary route-test Lua override was removed from
the source tree before this build.
- Testbot admission remained paused. AdventureBot loader remained disabled in
runtime configuration throughout the build and restart window.
## Fifth build drain (destination-first probe fix)
- Safe logout requested at approximately 2026-11-01 16:04:15 CET. Build
`-j10` again ran immediately in parallel and succeeded.
- GUID 168 Elanora completed her active free-flight transition and logged out
safely. At the five-minute installation boundary GUID 77 Drakogzug and GUID
153 Ragnli remained online in the same saved positions recorded above.
- `saveall` completed before shutdown. Both remaining bots again reported no
free-flight or transport state. Drakogzug had a failed travel action at
`flight_departure`; Ragnli was idle. Test admission and the AdventureBot
loader remained paused/disabled.
## Final runtime result
- Fresh worldserver PID 172446 reached `(worldserver-daemon) ready`; TCP 8085
and 8087 are listening.
- GUIDs 77 and 153 were loaded after restart. Both had no stale free-flight or
transport state and resumed ordinary behavior.
- The controlled Elanora plan produced a valid direct free-flight route from a
regular graph node to `portal_at_6354_source`; no MMAP attachment was needed.
The Stormwind Twilight Highlands portal did not compete because edge 1801
correctly failed `PortalPrerequisites` for this character's native spell/
quest state. The relevant portal is otherwise the first geometric connector,
so this was not a probe-budget failure.
- Temporary Elanora override removed. Schedule restored. Loader configuration
restored to enabled and test admission resumed. Loader status confirmed
`enabled=1 schema=1 busy=0`, pools `50/5/50/5`.
@@ -0,0 +1,36 @@
# Instance/navigation first-fix build recovery
- Safe logout requested approximately 2026-11-01 11:26 CET after pausing
`Clientless.Adventure.Loader` and TestBot admission.
- Build was allowed to run in parallel by the user's explicit clarification;
installation/restart remained gated by zero bots or the five-minute limit.
- At 11:31 CET, nine managed bots remained after more than five minutes.
The server was therefore eligible for a clean shutdown under the agreed rule.
- The separately logged-in human `Allitester` (GUID 21) is not a managed bot.
| GUID | Name | faction | saved map/position | observed state before shutdown |
|---:|---|---|---|---|
| 40 | Drakagmok | Horde | 1 / -4009.801 -2003.480 121.296 | groupformation; native taxi chain; no free-flight/transport record |
| 43 | Fizzira | Alliance | 0 / -8830.011 635.672 119.259 | social; failed held free flight, 94 segments |
| 60 | Ashani | Alliance | 1 / 7990.230 1296.750 59.628 | leisure; no flight capability/state |
| 77 | Drakogzug | Horde | 1 / 1852.910 -4570.060 24.981 | economy; flight and local travel arrived |
| 83 | Mahuwa | Horde | 571 / 2906.229 6057.960 80.671 | profession; zeppelin arrived; free flight failed touchdown confirmation |
| 96 | Darden | Alliance | 0 / -9337.900 12.744 105.163 | profession; failed held free flight, 162 segments |
| 121 | Braneven | Horde | 0 / 1964.780 -1567.223 123.819 | rest; failed held free flight, 462 segments |
| 132 | Braneron | Horde | 0 / 2539.960 -53.784 28.107 | no intent/travel/flight/transport state |
| 153 | Ragnli | Alliance | 0 / -11787.057 -3180.120 -29.714 | no intent/travel/flight/transport state |
Aggregate immediately before diagnostics: nine sessions, three combat and six
flight flags. No bot was individually force-logged-out, teleported, detached or
removed to enable the build.
## Restart outcome
- Build `-j10`, installation and fresh startup succeeded.
- Fresh worldserver PID 153100 reached `(worldserver-daemon) ready...`; TCP
listeners 8085 and 8087 were verified.
- All nine bots were loaded explicitly before restoring the population loader.
- None retained an old free-flight or transport controller record. Normal new
intent travel/combat resumed, so no recovery teleport was necessary.
- Loader and TestBot admission were restored. Population status reported
`enabled=1 schema=1 busy=0`, targets `50/5/50/5`; staggered loading resumed.
+34 -5
View File
@@ -510,7 +510,11 @@ void AdventureBehaviorMgr::Begin(Player * player, Record & r)
if ((r.Intent=="instance" || r.Intent=="raid"))
{
if (!ResolveInstance(player,r)) {Fallback(player,r);return;}
Queue(player,r,"travel_to_location","id",l->BaseKey.empty()?r.Location:l->BaseKey,true);
// The entrance is a first-class TravelGraph transition. Route to its
// source node instead of an approximate catalog point; the native
// AreaTrigger radius remains the final arrival contract.
Queue(player,r,"travel_to_node","node",
"portal_at_"+std::to_string(r.Instance.Entry)+"_source",true);
}
else if (l->IsArea)
{
@@ -1619,11 +1623,36 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff)
if (Busy(guid)) return;
auto status = sClientlessActionExecutorMgr->GetStatus(guid);
auto location = sAdventureLocationMgr->Find(r.Location);
bool arrived = status && status->State == ClientlessActionExecutionState::Completed && location && player->GetMapId() == location->Point.MapId;
if (arrived && location->IsArea) arrived = location->AreaId &&(player->GetAreaId() == location->AreaId || player->GetZoneId() == location->AreaId) && status->Detail != "best-effort travel completed at nearest useful reachable point";
else if (arrived) arrived = player->GetExactDist(location->Point.X, location->Point.Y, location->Point.Z) <= 6;
bool const completed=status && status->State==ClientlessActionExecutionState::Completed;
bool arrived=completed && location && player->GetMapId()==location->Point.MapId;
char const* arrivalContract="catalog-radius";
if (arrived && (r.Intent=="instance" || r.Intent=="raid") && r.Instance.Active)
{
arrivalContract="native-area-trigger";
auto entrance=sAreaTriggerStore.LookupEntry(r.Instance.Entry);
arrived=entrance && player->GetMapId()==uint32(entrance->ContinentID) &&
player->IsInAreaTriggerRadius(entrance);
}
else if (arrived && location->IsArea)
{
arrivalContract="area-or-zone";
arrived=location->AreaId && (player->GetAreaId()==location->AreaId ||
player->GetZoneId()==location->AreaId) &&
status->Detail!="best-effort travel completed at nearest useful reachable point";
}
else if (arrived)
arrived=player->GetExactDist(location->Point.X,location->Point.Y,location->Point.Z)<=6;
if (! arrived)
{
TC_LOG_WARN("server.adventurebot.navigation",
"INTENT_ARRIVAL_REJECT guid=%u intent='%s' location='%s' contract='%s' completed=%u status='%s' map=%u expectedMap=%u distance=%.2f entry=%u inTrigger=%u",
guid,r.Intent.c_str(),r.Location.c_str(),arrivalContract,completed?1u:0u,
status?status->Detail.c_str():"missing",player->GetMapId(),
location?location->Point.MapId:0u,
location?player->GetExactDist(location->Point.X,location->Point.Y,location->Point.Z):-1.0f,
r.Instance.Active?r.Instance.Entry:0u,
(r.Instance.Active && sAreaTriggerStore.LookupEntry(r.Instance.Entry) &&
player->IsInAreaTriggerRadius(sAreaTriggerStore.LookupEntry(r.Instance.Entry)))?1u:0u);
Fallback(player, r);
return;
}
@@ -1632,7 +1661,7 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff)
r.State = Phase::OnSite;
r.Detail = "performing intent at destination";
sClientlessLuaEngine->UpdateWorldPresence(player,r.Intent,true);
TC_LOG_INFO("server.adventurebot", "INTENT_ARRIVED guid=%u intent='%s' location='%s'", guid, r.Intent.c_str(), r.Location.c_str());
TC_LOG_INFO("server.adventurebot", "INTENT_ARRIVED guid=%u intent='%s' location='%s' contract='%s'", guid, r.Intent.c_str(), r.Location.c_str(),arrivalContract);
}
if (r.State == Phase::OnSite)
{
@@ -990,6 +990,12 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
}
if(!access)
{
TC_LOG_WARN("server.adventurebot.navigation",
"ROUTE_ACCESS_DENIED guid=%u name='%s' step=%u/%u node='%s' edgeType='%s' "
"edgeDbId=%u nodeAllowed=%u",
characterGuid, player->GetName().c_str(), uint32(record.RouteStep + 1),
uint32(record.Route.size()), step.NodeKey.c_str(), step.EdgeType.c_str(),
step.EdgeDatabaseId, accessNode && sAdventureAreaMgr->AllowsNode(player,*accessNode) ? 1u : 0u);
record.Status.State=ClientlessActionExecutionState::Failed;
record.Status.Detail="route denied by area/node catalog";record.Active=false;
return false;
@@ -1583,7 +1589,11 @@ bool ClientlessActionExecutorMgr::StartPortalUse(Player* player,
record.Status.Detail = reason;
return false;
};
if(portal && !sClientlessTravelGraphMgr->PortalPrerequisites(player,step.EdgeDatabaseId))
bool const nativePrerequisites = portal &&
sClientlessTravelGraphMgr->PortalNativePrerequisites(player,step.EdgeDatabaseId);
bool const adventureUnlock = portal && !nativePrerequisites &&
sClientlessTravelGraphMgr->AdventurePortalUnlockAllowed(player,step.EdgeDatabaseId);
if(portal && !nativePrerequisites && !adventureUnlock)
return fail("native portal prerequisites not satisfied");
if (!portal || !record.RouteStep || step.EdgeReverse ||
player->GetMapId() != portal->Source.MapId || !player->IsAlive() ||
@@ -1626,11 +1636,27 @@ bool ClientlessActionExecutorMgr::StartPortalUse(Player* player,
TC_LOG_INFO("server.adventurebot.navigation", "PORTAL_USE_START guid=%u edge=%u spawn=%u entry=%u spell=%u sourceMap=%u targetMap=%u",
characterGuid, step.EdgeDatabaseId, portal->SpawnId, portal->Entry, portal->SpellId,
portal->Source.MapId, step.MapId);
// Same packet handler as a player's click. Native Use/AI, party checks,
// spell cast restrictions and scripted conditions remain authoritative.
WorldPacket usePacket(CMSG_GAMEOBJ_USE, 8);
usePacket << object->GetGUID();
player->GetSession()->HandleGameObjectUseOpcode(usePacket);
if (adventureUnlock)
{
// These performative bots never complete quests. Reproduce the fixed,
// importer-validated destination of the bound public portal while the
// normal executor still verifies source object, range, faction/area
// policy and the resulting teleport sequence/arrival coordinates.
TC_LOG_INFO("server.adventurebot.navigation",
"PORTAL_ADVENTURE_UNLOCK guid=%u level=%u edge=%u spawn=%u",
characterGuid, player->getLevel(), step.EdgeDatabaseId, portal->SpawnId);
if (!player->TeleportTo(portal->Target.MapId, portal->Target.X,
portal->Target.Y, portal->Target.Z, portal->Target.O))
return fail("adventure portal unlock teleport failed");
}
else
{
// Same packet handler as a player's click. Native Use/AI, party checks,
// spell cast restrictions and scripted conditions remain authoritative.
WorldPacket usePacket(CMSG_GAMEOBJ_USE, 8);
usePacket << object->GetGUID();
player->GetSession()->HandleGameObjectUseOpcode(usePacket);
}
return true;
}
@@ -2664,7 +2690,10 @@ void ClientlessActionExecutorMgr::UpdateTravelToNode(
float dz = player->GetPositionZ() - destination.Z;
float remaining = std::sqrt(dx * dx + dy * dy + dz * dz);
if (remaining > 2.0f)
bool const portalApproach = record.RouteStep + 1 < record.Route.size() &&
record.Route[record.RouteStep + 1].EdgeType == "portal_use";
float const arrivalTolerance = portalApproach ? 5.0f : 2.0f;
if (remaining > arrivalTolerance)
{
uint32 const failedEdgeId =
record.RouteStep < record.Route.size()
+32 -3
View File
@@ -66,6 +66,18 @@ namespace
if(!ClearRay(player,{x+offset.X,y+offset.Y,surface.Ground+0.8f},{x+offset.X,y+offset.Y,top}))return false;
return true;
}
bool OpenColumnFrom(Player* player,float x,float y,float z,float height)
{
// A height query from MAX_HEIGHT can legitimately return a bridge,
// tree canopy or other collision surface far above the player. Such a
// surface is useful as an obstacle, but it is not the player's support
// floor. Test the actual take-off column from the player's feet.
if (!std::isfinite(z) || height < 5.0f) return false;
for (auto offset : std::array<Point,5>{{{0,0,0},{2,0,0},{-2,0,0},{0,2,0},{0,-2,0}}})
if (!ClearRay(player,{x+offset.X,y+offset.Y,z+0.8f},
{x+offset.X,y+offset.Y,z+height})) return false;
return true;
}
char const * StateName(ClientlessFlightState state)
{
switch (state)
@@ -398,13 +410,30 @@ bool ClientlessFlightMgr::Begin(Player * player, Position const & target, std::s
bool const airborne=existingMount && origin.Valid && player->GetPositionZ()>=origin.Ground+0.7f &&
player->GetPositionZ()+5<=origin.Ceiling && ClearRay(player,PointOf(*player),{player->GetPositionX(),player->GetPositionY(),player->GetPositionZ()+5});
float const feet=player->GetMap()->GetHeight(player->GetPhaseShift(),player->GetPositionX(),player->GetPositionY(),player->GetPositionZ()+2,true,6);
if (!origin.Valid || (!airborne && (!std::isfinite(feet) || feet<=INVALID_HEIGHT+1 || std::fabs(origin.Ground-player->GetPositionZ())>3 || std::fabs(feet-player->GetPositionZ())>3)))
bool const localSupport=std::isfinite(feet) && feet>INVALID_HEIGHT+1 &&
std::fabs(feet-player->GetPositionZ())<=3;
bool const overheadSurface=origin.Valid && origin.Ground>player->GetPositionZ()+3;
bool const openFromFeet=overheadSurface && OpenColumnFrom(player,player->GetPositionX(),
player->GetPositionY(),player->GetPositionZ(),
std::min(record.Settings.Clearance,origin.Ground-player->GetPositionZ()-1.0f));
// Not falling/in water was verified above. If the global height sample
// selected a remote surface above us, a clear local vertical column is a
// valid departure even though that remote surface cannot be our floor.
bool const supportedDeparture=localSupport || openFromFeet;
if (!origin.Valid || (!airborne && !supportedDeparture))
{
if(std::isfinite(feet) && feet>INVALID_HEIGHT+1 && std::fabs(feet-player->GetPositionZ())<=3 && ApproachDeparture(player,record,following))return true;
if(localSupport && ApproachDeparture(player,record,following))return true;
error = "departure-unavailable: no supported open takeoff point";
TC_LOG_WARN("server.adventurebot.navigation","FLIGHT_DEPARTURE_REJECT guid=%u z=%.3f surface=%.3f feet=%.3f valid=%u mounted=%u canFly=%u",player->GetGUID().GetCounter(),player->GetPositionZ(),origin.Ground,feet,origin.Valid?1:0,player->IsMounted()?1:0,player->CanFly()?1:0);
TC_LOG_WARN("server.adventurebot.navigation","FLIGHT_DEPARTURE_REJECT guid=%u z=%.3f surface=%.3f feet=%.3f valid=%u localSupport=%u overhead=%u openFromFeet=%u mounted=%u canFly=%u",
player->GetGUID().GetCounter(),player->GetPositionZ(),origin.Ground,feet,
origin.Valid?1:0,localSupport?1:0,overheadSurface?1:0,openFromFeet?1:0,
player->IsMounted()?1:0,player->CanFly()?1:0);
return false;
}
if(openFromFeet)
ClientlessTravelTelemetry::Note(player,"flight-departure-overhead-surface",
"z="+std::to_string(player->GetPositionZ())+" surface="+std::to_string(origin.Ground)+
" feet="+std::to_string(feet));
bool foundLanding = false;
std::array<uint32,5> landingRejected{}; // surface, water, column, MMAP, area
auto landingSummary=[&]()
@@ -3,6 +3,7 @@
#include "ClientlessTravelGraphMgr.h"
#include "ClientlessTransportMgr.h"
#include "ClientlessFlightMgr.h"
#include "ClientlessLuaEngine.h"
#include "DatabaseEnv.h"
#include "Log.h"
@@ -125,7 +126,7 @@ ClientlessPortalUse const* ClientlessTravelGraphMgr::GetPortalUse(uint32 edgeDat
return itr != _portalUses.end() ? &itr->second : nullptr;
}
bool ClientlessTravelGraphMgr::PortalPrerequisites(Player const* player,uint32 edgeDatabaseId) const
bool ClientlessTravelGraphMgr::PortalNativePrerequisites(Player const* player,uint32 edgeDatabaseId) const
{
auto portal=GetPortalUse(edgeDatabaseId);
auto info=portal?sObjectMgr->GetGameObjectTemplate(portal->Entry):nullptr;
@@ -136,6 +137,23 @@ bool ClientlessTravelGraphMgr::PortalPrerequisites(Player const* player,uint32 e
return !portal->Requirements.empty();
}
bool ClientlessTravelGraphMgr::AdventurePortalUnlockAllowed(Player const* player,
uint32 edgeDatabaseId) const
{
// AdventureBots cannot acquire quest progress. Once they have reached the
// Cataclysm level range, graph-bound public travel portals are therefore
// considered unlocked. This does not bypass faction/area/node policy or
// make arbitrary, unbound GameObjects usable.
return player && player->getLevel() >= 80 && GetPortalUse(edgeDatabaseId) &&
sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter());
}
bool ClientlessTravelGraphMgr::PortalPrerequisites(Player const* player,uint32 edgeDatabaseId) const
{
return PortalNativePrerequisites(player,edgeDatabaseId) ||
AdventurePortalUnlockAllowed(player,edgeDatabaseId);
}
bool ClientlessTravelGraphMgr::Reload()
{
_positionProbes.Clear();
@@ -623,6 +641,10 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
}
bool const flightEnabled = sClientlessFlightMgr->Enabled(player);
constexpr std::uint64_t MaxFlightEstimatesPerPlan = 16;
// Preserve part of the global budget for nodes reached later in the
// search. In particular, a flight to a portal source must still leave
// enough probes for the flight from its destination map to the goal.
constexpr std::uint64_t MaxFlightEstimatesPerOrigin = 6;
auto flightCost = [&](ClientlessTravelNode const& from, uint32 map, float x, float y, float z, bool access = false)
{
if (!flightEnabled || from.MapId != map ||
@@ -815,9 +837,15 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
ClientlessTravelNode departure;
departure.Key="flight_departure";
departure.MapId=player->GetMapId();departure.X=player->GetPositionX();departure.Y=player->GetPositionY();departure.Z=player->GetPositionZ();
float air=blockedBridgeKeys && blockedBridgeKeys->count("flight:flight_departure->"+node.Key)?
std::numeric_limits<float>::infinity():flightCost(departure,node.MapId,node.X,node.Y,node.Z,!reachable);
if(std::isfinite(air) && (!reachable || air<entryNav.PathLength))
// A valid local MMAP entry is the normal, organic graph access and
// must not spend the bounded flight-estimate budget. Free flight is
// the recovery/re-entry mode for nodes that local navigation cannot
// reach, not a shortcut racing every nearby pavement node.
float air = std::numeric_limits<float>::infinity();
if (!reachable && !(blockedBridgeKeys &&
blockedBridgeKeys->count("flight:flight_departure->"+node.Key)))
air=flightCost(departure,node.MapId,node.X,node.Y,node.Z,true);
if(std::isfinite(air) && !reachable)
{flightRoots.insert(node.Key);startCost=air;}
else
{
@@ -1220,29 +1248,37 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
float bestGoalCost = std::numeric_limits<float>::infinity();
std::string bestGoalState;
bool bestFinalFlight = false;
// Free flight is a macro connector, not a reason to turn every graph map
// into a complete directed graph. A flyer only needs direct links to the
// requested destination and to nodes that enter a map-changing or physical
// transport edge (portal, ship, zeppelin or tram). Taxi nodes are excluded:
// a free flyer can already reach their same-map destination directly, and
// adding every taxi source recreates an almost-complete graph. Ordinary
// same-map ground
// nodes remain reachable through their recorded graph, while a location
// goal is evaluated directly from every expanded node below.
std::unordered_set<std::string> flightConnectorNodes;
flightConnectorNodes.insert(destinationNode);
for (auto const& edgeGroup : _edges)
for (ClientlessTravelEdge const& edge : edgeGroup.second)
// Every permitted node is a potential way back into the persistent graph.
// Do not materialize an all-to-all air graph, though: candidates are ranked
// on demand below and the shared per-plan Estimate cap remains authoritative.
// This is important for isolated anchors such as the capital Cataclysm
// portals, but is deliberately not portal-specific.
auto flightRemainderLowerBound = [&](ClientlessTravelNode const& candidate)
{
ClientlessTravelNode const& destination = destinationItr->second;
auto lowerBoundToGoal = [&](ClientlessTravelNode const& node)
{
auto fromItr = _nodes.find(edge.FromNode);
auto toItr = _nodes.find(edge.ToNode);
bool const changesMap = fromItr != _nodes.end() && toItr != _nodes.end() &&
fromItr->second.MapId != toItr->second.MapId;
bool const physicalTransport = edge.Type == "portal" || edge.Type == "ship" ||
edge.Type == "zeppelin" || edge.Type == "tram" || edge.Type == "transport";
if (changesMap || physicalTransport)
flightConnectorNodes.insert(edge.FromNode);
}
if (node.MapId != destination.MapId)
return std::numeric_limits<float>::infinity();
float dx = destination.X-node.X, dy = destination.Y-node.Y, dz = destination.Z-node.Z;
return 40.0f + std::sqrt(dx*dx+dy*dy+dz*dz)*0.4f;
};
float best = lowerBoundToGoal(candidate);
auto edges = _edges.find(candidate.Key);
if (edges != _edges.end())
for (ClientlessTravelEdge const& edge : edges->second)
{
if ((!RescueEdgeAllowed(edge) || !sAdventureAreaMgr->AllowsEdge(player,edge)))
continue;
if (blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId))
continue;
auto next = _nodes.find(edge.ToNode);
if (next == _nodes.end())
continue;
best = std::min(best, edge.Cost + lowerBoundToGoal(next->second));
}
return best;
};
// C8.20: a virtual entry is represented in the queue by its FAR
// endpoint. Check exits BEFORE that endpoint independently: otherwise
// an exit halfway along the road would pay for the entire remaining edge.
@@ -1379,10 +1415,11 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
// ground transit. Once reached during a planned route, its only legal
// continuation is the portal edge itself. Physical AreaTriggers may
// fire even before the route executor advances to that edge.
bool const isPortalSource =
current.Value.Node.rfind("portal_at_", 0) == 0 &&
current.Value.Node.size() >= 7 &&
current.Value.Node.compare(current.Value.Node.size() - 7, 7, "_source") == 0;
auto const sourceEdges = _edges.find(current.Value.Node);
bool const isPortalSource = sourceEdges != _edges.end() &&
std::any_of(sourceEdges->second.begin(), sourceEdges->second.end(),
[](ClientlessTravelEdge const& edge)
{ return edge.Type == "portal" || edge.Type == "portal_use"; });
bool const portalArrival = isPortalSource && previous.find(currentKey) != previous.end();
// Persistent TravelGraph edges always reset the bridge guard.
@@ -1393,7 +1430,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
for (ClientlessTravelEdge const& edge : edgeItr->second)
{
if ((!RescueEdgeAllowed(edge) || !sAdventureAreaMgr->AllowsEdge(player,edge))) continue;
if (portalArrival && edge.Type != "portal")
if (portalArrival && edge.Type != "portal" && edge.Type != "portal_use")
continue;
if (blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId))
continue;
@@ -1421,15 +1458,15 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
// Dynamic flights connect real nodes; never the synthetic entry waypoints.
// Include them before the local-bridge guard: a flight is a separate
// macro travel mode with its own native departure and landing checks.
if (flightEnabled && !portalArrival)
if (flightEnabled && !portalArrival && trace.FlightCalls < MaxFlightEstimatesPerPlan)
{
auto const& origin=_nodes.at(current.Value.Node);
for (std::string const& targetKey : flightConnectorNodes)
std::vector<std::pair<float, std::string>> macroFlightCandidates;
std::vector<std::pair<float, std::string>> flightCandidates;
flightCandidates.reserve(_nodes.size());
for (auto const& candidate : _nodes)
{
auto targetItr = _nodes.find(targetKey);
if (targetItr == _nodes.end())
continue;
auto const& target=targetItr->second;
auto const& target=candidate.second;
if (target.Key==origin.Key || target.MapId!=origin.MapId) continue;
State next{target.Key,false};std::string key=stateKey(next);
auto old=distance.find(key);
@@ -1441,6 +1478,58 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
if(old!=distance.end() && old->second<=current.Cost+lowerBound)continue;
if ((!RescueNodeAllowed(target) || !sAdventureAreaMgr->AllowsNode(player,target))) continue;
if (blockedBridgeKeys && blockedBridgeKeys->count("flight:"+origin.Key+"->"+target.Key)) continue;
float const remainder = flightRemainderLowerBound(target);
if (!std::isfinite(remainder))
continue;
bool macroConnector = target.Key == destinationNode;
auto outgoing = _edges.find(target.Key);
if (outgoing != _edges.end())
for (ClientlessTravelEdge const& edge : outgoing->second)
{
auto next = _nodes.find(edge.ToNode);
bool const changesMap = next != _nodes.end() &&
next->second.MapId != target.MapId;
bool const physical = edge.Type == "portal" ||
edge.Type == "portal_use" || edge.Type == "ship" ||
edge.Type == "zeppelin" || edge.Type == "tram" ||
edge.Type == "transport";
if (changesMap || physical)
{
macroConnector = true;
break;
}
}
auto& candidates = macroConnector ? macroFlightCandidates : flightCandidates;
candidates.emplace_back(lowerBound + remainder, target.Key);
}
std::sort(macroFlightCandidates.begin(), macroFlightCandidates.end(),
[&](auto const& left, auto const& right)
{
// Every newly reached portal/transport endpoint must get
// one chance to finish the requested journey before the
// bounded probe window is spent on further connectors.
bool const leftIsDestination = left.second == destinationNode;
bool const rightIsDestination = right.second == destinationNode;
if (leftIsDestination != rightIsDestination)
return leftIsDestination;
return left < right;
});
std::sort(flightCandidates.begin(), flightCandidates.end());
macroFlightCandidates.insert(macroFlightCandidates.end(),
flightCandidates.begin(), flightCandidates.end());
std::uint64_t const callsAtOrigin = trace.FlightCalls;
for (auto const& ranked : macroFlightCandidates)
{
if (trace.FlightCalls >= MaxFlightEstimatesPerPlan ||
trace.FlightCalls - callsAtOrigin >= MaxFlightEstimatesPerOrigin)
break;
auto const& target = _nodes.at(ranked.second);
State next{target.Key,false};std::string key=stateKey(next);
auto old=distance.find(key);
auto const blockedFlight = blockedBridgeKeys &&
blockedBridgeKeys->count("flight:"+origin.Key+"->"+target.Key);
if (blockedFlight)
continue;
float cost=flightCost(origin,target.MapId,target.X,target.Y,target.Z);
if (!std::isfinite(cost)) continue;
float score=current.Cost+cost;
@@ -1464,10 +1553,11 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
// road at an MMAP-reachable waypoint. Only remaining portions of
// stored, directed ground edges are admitted; no off-graph shortcut.
// A portal source may not be treated as an ordinary intermediate node.
bool const futurePortalSource =
from.Key.rfind("portal_at_", 0) == 0 &&
from.Key.size() >= 7 &&
from.Key.compare(from.Key.size() - 7, 7, "_source") == 0;
auto const futureEdges = _edges.find(from.Key);
bool const futurePortalSource = futureEdges != _edges.end() &&
std::any_of(futureEdges->second.begin(), futureEdges->second.end(),
[](ClientlessTravelEdge const& edge)
{ return edge.Type == "portal" || edge.Type == "portal_use"; });
if (!futurePortalSource && !hasPersistentContinuation && persistentHops[currentKey] > 0 &&
!(blockedBridgeKeys &&
blockedBridgeKeys->count(MakeLocalBridgeKey(from.Key, "virtual_ground_entry"))))
@@ -177,6 +177,8 @@ public:
static bool ResolvePortalUse(uint32 spawnId, ClientlessPortalUse& portal, std::string& error);
ClientlessPortalUse const* GetPortalUse(uint32 edgeDatabaseId) const;
bool PortalNativePrerequisites(Player const* player,uint32 edgeDatabaseId) const;
bool AdventurePortalUnlockAllowed(Player const* player,uint32 edgeDatabaseId) const;
bool PortalPrerequisites(Player const* player,uint32 edgeDatabaseId) const;
private:
@@ -3980,6 +3980,7 @@ Appender.Console=1,3,0
Appender.Server=2,2,0,Server.log,w
Appender.GM=2,2,15,gm/gm_%s.log
Appender.DBErrors=2,2,0,DBErrors.log
Appender.AdventureBotNavigation=2,2,7,adventurebot-navigation.log,a,67108864
# Logger config values: Given a logger "name"
# Logger.name
@@ -4005,6 +4006,7 @@ Logger.commands.gm=3,Console GM
Logger.scripts.hotswap=3,Console Server
Logger.sql.sql=5,Console DBErrors
Logger.sql.updates=3,Console Server
Logger.server.adventurebot.navigation=2,AdventureBotNavigation
#Logger.mmaps=3,Server
#Logger.achievement=3,Console Server
@@ -22,6 +22,19 @@ assert "r.GroupOverride" in follower_hold
assert "r.Dirty=false" in follower_hold
assert "GROUP_MEMBER_FALLBACK_HOLD" in follower_hold
begin_start = source.index("void AdventureBehaviorMgr::Begin(")
begin_end = source.index("void AdventureBehaviorMgr::Fallback(", begin_start)
begin = source[begin_start:begin_end]
assert '"portal_at_"+std::to_string(r.Instance.Entry)+"_source"' in begin
assert 'Queue(player,r,"travel_to_node","node"' in begin
update_start = source.index("void AdventureBehaviorMgr::Update(Player * player")
update_end = source.index("void AdventureBehaviorMgr::FinishInstance(", update_start)
update = source[update_start:update_end]
assert 'arrivalContract="native-area-trigger"' in update
assert "player->IsInAreaTriggerRadius(entrance)" in update
assert "INTENT_ARRIVAL_REJECT" in update
protect_start = source.index("bool AdventureBehaviorMgr::ProtectGroupLeave(")
protect_end = source.index("void AdventureBehaviorMgr::InterruptForInteraction(", protect_start)
protection = source[protect_start:protect_end]
@@ -35,4 +48,9 @@ for invariant in (
):
assert invariant in protection
print("Instance visit retention, inherited group-target hold and formation leave protection passed.")
flight = (root / "src/server/game/World/ClientlessFlightMgr.cpp").read_text()
assert "OpenColumnFrom" in flight
assert "overheadSurface" in flight
assert "flight-departure-overhead-surface" in flight
print("Instance graph entrance/arrival contract, visit retention, flight overhead handling, group-target hold and formation leave protection passed.")
+27 -2
View File
@@ -34,12 +34,14 @@ struct ClientlessPathDiagnostics {bool Calculated=true, Complete=true,Suspicious
struct Travel {bool complete=true;void AnalyzePath(Player* p,Position b,ClientlessPathDiagnostics& n){Position a;a.Relocate(p->x,p->y,p->z);n.PathLength=n.DirectDistance=dist(a,b);n.Complete=complete;}} travel;
auto sClientlessTravelMgr=&travel;
struct Flight {
bool enabled=false;float minimumX=-10000;
bool enabled=false;float minimumX=-10000;float forbiddenTargetX=1e9;
float forbiddenPairFromX=1e9,forbiddenPairTargetX=1e9;
void BeginPlanning(Player const* = nullptr){}
bool Enabled(Player const*){return enabled;}
bool Preferred(Player const*,uint32,Position const&){return false;}
float Estimate(Player const*,uint32 map,Position a,Position b,bool recovery=false){
float d=dist(a,b);return enabled&&map==0&&a.x>=minimumX&&(recovery||d>=60)?(recovery?d:180+d*1.5f):std::numeric_limits<float>::infinity();
bool forbiddenPair=std::fabs(a.x-forbiddenPairFromX)<0.1f&&std::fabs(b.x-forbiddenPairTargetX)<0.1f;
float d=dist(a,b);return enabled&&map==0&&a.x>=minimumX&&std::fabs(b.x-forbiddenTargetX)>0.1f&&!forbiddenPair&&(recovery||d>=60)?(recovery?d:180+d*1.5f):std::numeric_limits<float>::infinity();
}
} flight;
auto sClientlessFlightMgr=&flight;
@@ -162,6 +164,29 @@ travel.complete=true;route.clear();
assert(accessFlight.PlanAdaptiveRoute(&start,"B",400,200,route,done,endKey,why,nullptr,&deniedFlightRoots));
assert(route.front().NodeKey!="flight_departure");
travel.complete=true;
// Any allowed graph node can be a bounded, on-demand flight re-entry. The
// planner must not require a portal/transport node type or an MMAP bridge.
flight.forbiddenTargetX=2000;
ClientlessTravelGraphMgr genericEntry;node(genericEntry,"A",0);node(genericEntry,"R",500);node(genericEntry,"B",2000);
edge(genericEntry,"R","B",88,10,false,"ground");
travel.complete=false;route.clear();
assert(genericEntry.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why)&&done);
assert(route.size()==3&&route[0].NodeKey=="A"&&route[1].NodeKey=="R");
assert(route[1].EdgeType=="free_flight"&&route[2].EdgeDatabaseId==88);
flight.forbiddenTargetX=1e9;travel.complete=true;
// Keep probe budget for a later graph state: fly to a generic portal source,
// cross it, and then fly to the destination. A rejected direct flight must
// not consume all estimates at the initial node.
flight.forbiddenPairFromX=0;flight.forbiddenPairTargetX=2000;
ClientlessTravelGraphMgr portalEntry;node(portalEntry,"A",0);node(portalEntry,"P",500);
node(portalEntry,"Q",1500);node(portalEntry,"B",2000);
edge(portalEntry,"P","Q",89,1,false,"portal_use");
travel.complete=false;route.clear();
assert(portalEntry.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why)&&done);
assert(route.size()==4&&route[1].NodeKey=="P"&&route[1].EdgeType=="free_flight");
assert(route[2].NodeKey=="Q"&&route[2].EdgeType=="portal_use");
assert(route[3].NodeKey=="B"&&route[3].EdgeType=="free_flight");
flight.forbiddenPairFromX=flight.forbiddenPairTargetX=1e9;travel.complete=true;
std::cout<<"ground planner regressions and dynamic flight/node/location/virtual-exit integration passed\n";
}
'''
@@ -77,7 +77,7 @@ struct Flight {
bool enabled=false,starts=false,recovery=false;int launches=0;FlightStatus status;
void BeginPlanning(){}
float Estimate(Player*,uint32,Position const&,Position const&,bool fallback=false){recovery=fallback;return enabled?100:std::numeric_limits<float>::infinity();}
bool Start(Player*,Position const&,std::string&){++launches;return starts;}
bool Start(Player*,Position const&,std::string&,bool=false){++launches;return starts;}
FlightStatus const* GetStatus(uint32){return &status;}
} flight;
auto sClientlessFlightMgr=&flight;
+12 -3
View File
@@ -68,6 +68,8 @@ struct Player {
bool IsInPhase(GameObject*){return phase;}bool CanSeeOrDetect(GameObject*){return visible;}
GameObject* GetGameObjectIfCanInteractWith(int guid){return inRange&&guid==go.guid?&go:nullptr;}
float GetPositionX(){return x;}float GetPositionY(){return y;}float GetPositionZ(){return z;}
unsigned getLevel(){return 85;}
bool TeleportTo(uint32 map,float tx,float ty,float tz,float){pending=true;world=false;mapId=map;x=tx;y=ty;z=tz;return true;}
std::string GetName(){return "Arthoran";}void StopMoving(){}
};
void Session::HandleGameObjectUseOpcode(WorldPacket& packet) {
@@ -80,8 +82,11 @@ void Session::CompleteClientlessTeleport(){
player->mapId=wrongTarget?1:0;player->x=-8998;player->y=861;player->z=29;
}
struct Node {std::string Key;uint32 MapId=530;};
struct Portal {uint32 SpawnId=83,Entry=183325,SpellId=17334;Node Source;};
struct Graph {bool prerequisites=true;bool PortalPrerequisites(Player*,uint32){return prerequisites;}bool available=true;Portal portal;auto GetPortalUse(uint32){return available?&portal:nullptr;}} graph;
struct Portal {uint32 SpawnId=83,Entry=183325,SpellId=17334;Node Source;struct{uint32 MapId=0;float X=-8998,Y=861,Z=29,O=0;}Target;};
struct Graph {bool nativePrerequisites=true,adventureUnlock=false;
bool PortalNativePrerequisites(Player*,uint32){return nativePrerequisites;}
bool AdventurePortalUnlockAllowed(Player*,uint32){return adventureUnlock;}
bool available=true;Portal portal;auto GetPortalUse(uint32){return available?&portal:nullptr;}} graph;
struct Travel {void CancelTravel(uint32){}} travel;
struct Lua {void DismountGroundMount(Player*,const char*){}} lua;
auto sClientlessTravelGraphMgr=&graph;auto sClientlessTravelMgr=&travel;auto sClientlessLuaEngine=&lua;
@@ -105,7 +110,11 @@ harness += methods
harness += r'''
int main(){
ClientlessActionExecutorMgr mgr;
{Player p;Record r;graph.prerequisites=false;assert(!mgr.StartPortalUse(&p,26,r)&&p.session.clicks==0);graph.prerequisites=true;}
{Player p;Record r;graph.nativePrerequisites=false;assert(!mgr.StartPortalUse(&p,26,r)&&p.session.clicks==0);graph.nativePrerequisites=true;}
{Player p;Record r;graph.nativePrerequisites=false;graph.adventureUnlock=true;
assert(mgr.StartPortalUse(&p,26,r)&&p.session.clicks==0&&p.pending);
mgr.UpdatePortalUse(&p,26,r);assert(r.RouteStep==2&&p.session.sequence==1);
graph.nativePrerequisites=true;graph.adventureUnlock=false;}
{Player p;Record r;assert(mgr.StartPortalUse(&p,26,r));assert(p.session.clicks==1&&!p.world);
mgr.UpdatePortalUse(&p,26,r);assert(!r.PortalUseActive&&r.RouteStep==2&&p.mapId==0&&p.session.sequence==1);}
// The global lifecycle may have ACKed a delayed cast before our next update.