diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index 1f0d048a38..e4939ed0fe 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,47 @@ +# Aktuelle Aufgabe: freie Bordnavigation (2026-10-29) + +Vorheriger Schritt lokal committed als49712aadfe. Bordnavigation implementiert +und getestet; lokaler Commit vom Nutzer angefordert. +Nutzer hat lokale Navigationsnetze für Tiefenbahnwagen und The Bravery beauftragt. +TransportDeckMesh baut Recast/Detour-Netze aus vorhandenen VMAP-Modellen; +ClientlessTransportDeckMgr steuert Platzwahl bei Halt und Rückkehr vor Ausstieg. +Modelle werden pro Display einmal erzeugt, nur Bravery176310 und Tram176080-86. +Recast ist jetzt als private game-Abhängigkeit eingebunden, daher großer Build. +Build erfolgreich: /tmp/vcore-deck-build.log; installiert und neu gestartet. +Build3 mit korrigiertem Abfahrtsbudget erfolgreich und installiert. +Finaler Testserver PID270857, tmux vcore-worldserver, Konsole: +/tmp/vcore-deck-final-20261029.log. +Frischer ready-Start und TCP8085 verifiziert. +Echte Geometrietests: Schiff1678Polygone/259 erreichbare Rasterziele mit Rückweg, +Tram105/59. Alle sechs gezielten Tests erfolgreich, auch Controller-/Transport-/Gruppenflugtests. +Native Diagnose bestätigt beide Netze. Kontrollierte Fahrt Renaisia70 von +ship_sw_br nach ship_ruttheran_br: abgeschlossen; Schedule70 ist wieder ON +(confirmed Schedule mode updated). Sie hat einen +2m-Netzweg an Bord zurückgelegt; gespeicherte lokale Position bestätigt Ziel +(-1.74993,7.03908,6.14418) während fahrendem Schiff. Rückweg0.79m am Ziel, +Leaving und Arrived/passenger0 bestätigt. Noralin80 wurde per nativem tele name +(offline) an DeeprunTram gesetzt und eingeloggt; normaler Tagesplan führt sie +von tram_if nach tram_sw. Erste Tramfahrt abgeschlossen:11:40:47Riding;11:41:55Rückweg1.07m; +11:41:57Arrived/passenger0. Keine manuelle Scheduleänderung bei80. +Das anfängliche Bordverteilen war zu konservativ zeitlich reserviert; Build3 +reserviert an Abfahrt nur Hinweg+2s, Rückweg/Ausstieg am Ziel. Controltest passt. +Finaler Start erfolgreich. Native Diagnose für weiteres Tram-Entry176080 +bestätigt ebenfalls105Polygone/59Ziele+Rückwege. Noralin80 offline erneut per +tele name an DeeprunTram gesetzt und eingeloggt; ihr normaler Tagesplan nimmt +wieder tram_if->tram_sw. KEIN manueller Scheduleoverride80 (daher nichts +zurückzustellen). Finale Tram-Bordverteilung bestätigt11:48:02,2m, lokales Ziel per saveall +bestätigt. Rückweg11:49:13,Leaving11:49:19; unter massiven Worldtick-Stalls +(50.8s/8ticks) Abfahrt11:49:26 während Ausstieg: bestehende Sicherung bleibt +angehängt/Riding+Abort bis sicherem Dock. KEIN vollständiger finaler Ausstieg +behaupten. Frühere vollständige Schiff-/Tramfahrten waren erfolgreich. +Recovery80 abgeschlossen11:51:36 Failed/passenger0='cancelled at safe dock'; +sicher an Land ausgestiegen, normaler Tagesplan wieder aktiv. Kein manueller +Override bleibt; Schiff70ScheduleON,80war nie überschrieben. +Siehe transport-deck-navigation.md. Keine Schema-/Config-Änderung; native Testpositionierung80 dokumentiert. +Änderungen werden lokal committed; kein Push. + +--- + # Umsetzung 2026-10-29: Navigation und Verhaltensgrenzen Die unten festgehaltene Liste ist implementiert; lokaler Commit vom Nutzer angefordert. diff --git a/doc/clientless/checkpoints/2026-10-29-deck-navigation/live-journeys.log b/doc/clientless/checkpoints/2026-10-29-deck-navigation/live-journeys.log new file mode 100644 index 0000000000..b43467c849 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-deck-navigation/live-journeys.log @@ -0,0 +1,21 @@ +2026-10-29_11:36:29 INFO [server.adventurebot.navigation] DECK_MESH entry=176310 polygons=1678 error='' +2026-10-29_11:36:29 INFO [server.adventurebot.navigation] DECK_MESH entry=176081 polygons=105 error='' +2026-10-29_11:36:57 INFO [server.adventurebot.navigation] TRANSPORT_STATE guid=70 phase='Boarding' from='ship_sw_br' to='ship_ruttheran_br' passenger=0 detail='boarding recorded land-to-deck transition' +2026-10-29_11:37:04 INFO [server.adventurebot.navigation] TRANSPORT_STATE guid=70 phase='Riding' from='ship_sw_br' to='ship_ruttheran_br' passenger=1 detail='aboard; waiting for destination dock' +2026-10-29_11:37:04 INFO [server.adventurebot.navigation] DECK_WALK guid=70 points=3 length=2.00 target=(-1.750 7.039 6.144) +2026-10-29_11:38:42 INFO [server.adventurebot.navigation] DECK_WALK guid=70 points=2 length=0.79 target=(-1.878 7.822 6.144) +2026-10-29_11:38:42 INFO [server.adventurebot.navigation] TRANSPORT_STATE guid=70 phase='Leaving' from='ship_sw_br' to='ship_ruttheran_br' passenger=1 detail='leaving via reversed recorded transition' +2026-10-29_11:38:44 INFO [server.adventurebot.navigation] TRANSPORT_STATE guid=70 phase='Arrived' from='ship_sw_br' to='ship_ruttheran_br' passenger=0 detail='arrived at shared destination node' +2026-10-29_11:40:44 INFO [server.adventurebot.navigation] TRANSPORT_STATE guid=80 phase='Boarding' from='tram_if' to='tram_sw' passenger=0 detail='boarding recorded land-to-deck transition' +2026-10-29_11:40:47 INFO [server.adventurebot.navigation] TRANSPORT_STATE guid=80 phase='Riding' from='tram_if' to='tram_sw' passenger=1 detail='aboard; waiting for destination dock' +2026-10-29_11:41:55 INFO [server.adventurebot.navigation] DECK_WALK guid=80 points=2 length=1.07 target=(-0.265 -1.246 -10.363) +2026-10-29_11:41:55 INFO [server.adventurebot.navigation] TRANSPORT_STATE guid=80 phase='Leaving' from='tram_if' to='tram_sw' passenger=1 detail='leaving via reversed recorded transition' +2026-10-29_11:41:57 INFO [server.adventurebot.navigation] TRANSPORT_STATE guid=80 phase='Arrived' from='tram_if' to='tram_sw' passenger=0 detail='arrived at shared destination node' +2026-10-29_11:48:00 INFO [server.adventurebot.navigation] TRANSPORT_STATE guid=80 phase='Boarding' from='tram_if' to='tram_sw' passenger=0 detail='boarding recorded land-to-deck transition' +2026-10-29_11:48:02 INFO [server.adventurebot.navigation] TRANSPORT_STATE guid=80 phase='Riding' from='tram_if' to='tram_sw' passenger=1 detail='aboard; waiting for destination dock' +2026-10-29_11:48:02 INFO [server.adventurebot.navigation] DECK_WALK guid=80 points=2 length=2.00 target=(-2.101 -0.396 -10.349) +2026-10-29_11:49:13 INFO [server.adventurebot.navigation] DECK_WALK guid=80 points=2 length=2.02 target=(-0.265 -1.246 -10.363) +2026-10-29_11:49:19 INFO [server.adventurebot.navigation] TRANSPORT_STATE guid=80 phase='Leaving' from='tram_if' to='tram_sw' passenger=1 detail='leaving via reversed recorded transition' +2026-10-29_11:49:26 INFO [server.adventurebot.navigation] TRANSPORT_STATE guid=80 phase='Riding' from='tram_if' to='tram_sw' passenger=1 detail='departure during transition; retaining passenger until safe dock' +2026-10-29_11:51:35 INFO [server.adventurebot.navigation] TRANSPORT_STATE guid=80 phase='Leaving' from='tram_if' to='tram_sw' passenger=1 detail='leaving via reversed recorded transition' +2026-10-29_11:51:36 INFO [server.adventurebot.navigation] TRANSPORT_STATE guid=80 phase='Failed' from='tram_if' to='tram_sw' passenger=0 detail='cancelled at safe dock' diff --git a/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_adventure_group_travel.log b/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_adventure_group_travel.log new file mode 100644 index 0000000000..3eb62f5522 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_adventure_group_travel.log @@ -0,0 +1 @@ +Production group travel: distinct slots, phase169 cliff safety, width, mounts, flight/kick landing, taxi grace, portal queue, bounded separation and recorded-road validation passed diff --git a/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_flight_handoff.log b/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_flight_handoff.log new file mode 100644 index 0000000000..12538ff1e3 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_flight_handoff.log @@ -0,0 +1 @@ +Flight handoff before spline end, terminal landing, return corners, collision stop and actual distance passed. diff --git a/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_group_flight_follow.log b/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_group_flight_follow.log new file mode 100644 index 0000000000..f69a883e14 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_group_flight_follow.log @@ -0,0 +1 @@ +Moving-target flight: bounded phased corridors, obstacle hold, ceiling rejection, aerial formation hold, safe landing and return fallback passed diff --git a/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_transport.log b/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_transport.log new file mode 100644 index 0000000000..9a3cbb74be --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_transport.log @@ -0,0 +1 @@ +Transport dock, frame, recorder, cross-map copy and ride regressions passed diff --git a/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_transport_deck_control.log b/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_transport_deck_control.log new file mode 100644 index 0000000000..81599178a4 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_transport_deck_control.log @@ -0,0 +1 @@ +Production deck controller: halt budgets, departure interruption, return-before-exit, blocked paths and reservations passed diff --git a/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_transport_deck_mesh.log b/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_transport_deck_mesh.log new file mode 100644 index 0000000000..11c5f7f28e --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-deck-navigation/test_transport_deck_mesh.log @@ -0,0 +1,3 @@ +/tmp/tmp2fpgi1re/Transportship.wmo.fixture polygons=1678 reachable=259 returns=259 +/tmp/tmp2fpgi1re/Subwaycar.m2.fixture polygons=105 reachable=59 returns=59 +Real deck meshes: connectivity, floor isolation, edge clearance and return paths passed diff --git a/doc/clientless/checkpoints/2026-10-29-deck-navigation/validation.md b/doc/clientless/checkpoints/2026-10-29-deck-navigation/validation.md new file mode 100644 index 0000000000..1207dcc818 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-deck-navigation/validation.md @@ -0,0 +1,65 @@ +# Deck navigation validation + +Build and installation succeeded with the existing Debug configuration. Added +private Recast dependency; no external data extraction/reinstallation needed. +Fresh worldserver PID270232 reached ready and listens on TCP8085. Console: +/tmp/vcore-deck-start-20261029.log. Six targeted test programs passed. + +Native diagnostics: + +- Bravery176310:1678 polygons,259 reachable sample targets,259 return paths. +- Subway176081:105 polygons,59 reachable sample targets,59 return paths. + +Controlled live ship journey, Renaisia70: + +- 11:36:57 Boarding at ship_sw_br. +- 11:37:04 Riding/passenger1; DECK_WALK2m,3 points. +- Native saveall during transit persisted local position + (-1.74993,7.03908,6.14418), matching the mesh walk target. +- Native inspect simultaneously reported stopped0 on The Bravery. +- 11:38:42 DECK_WALK0.79m back to the recorded exit anchor. +- 11:38:42 Leaving;11:38:44 Arrived/passenger0 at ship_ruttheran_br. +- Temporary manual test schedule override restored to ON and acknowledged. + +Noralin80 was positioned offline using native tele name at DeeprunTram and +logged in for a second live check. Her normal schedule takes tram_if to tram_sw; +no manual schedule override. Native journey completed: +11:40:44 Boarding,11:40:47 Riding/passenger1;11:41:55 mesh return1.07m to +Stormwind exit anchor, followed by11:41:57 Arrived/passenger0. Initial spreading +was skipped because of an overly conservative departure budget; adjusted the +budget to outward duration+2s, reserving return/exit time at the destination. +The updated production scheduling regression passes. + +No visual acceptance claimed; native geometry, controller tests and logs are +separate evidence. Remaining user acceptance: multiple visible passengers, +clothing/body sizes, interruption close to departure and crowded boarding. + +Final build3 was installed after another verified clean shutdown. Fresh console +/tmp/vcore-deck-final-20261029.log reached ready; PID270857, TCP8085 listening. +The diagnostic for subway entry176080 also confirms105 polygons/59 return paths, +sharing the same model cache as176081. + +Final live subway check, Noralin80 (normal schedule, no manual override): + +- Native offline tele name to DeeprunTram, followed by clientless login80. +- 11:48:00 Boarding,11:48:02 Riding/passenger1 and outward DECK_WALK2.00m. +- Native saveall persisted local(-2.1012,-0.396121,-10.3489), matching the target. + Native inspect reported stopped0 and distance10.57 from the wagon origin, + consistent with that local offset while the vehicle was travelling. +- 11:49:13 return mesh walk2.02m;11:49:19 Leaving. +- 11:49:26 vehicle departed during the recorded exit; existing safety retained + the passenger and entered Riding/abort pending a safe recorded dock. +- This final journey does NOT count as a successful full exit. Prior ship and + subway journeys completed, but the final stress run exposed exit timing under + heavy world-update stalls (CPC_PERF reported50830ms with8 ticks). Normal travel + duration reserves cannot guarantee completing a multi-step exit across such + stalls. No vehicle schedule was altered to hide this limitation. + +Final source diff check passed. No schema/config changes, no commit/push. +Ship70's manual schedule override is restored; Noralin80 remains under her normal +schedule and the existing safe transport cancellation/recovery logic. + +Recovery completed at11:51:36: native Leaving followed by Failed/passenger0 +with 'cancelled at safe dock'. The bot was safely detached on land and normal +day-plan travel resumed. This is successful safety recovery, not an uninterrupted +journey. No manual override remains. diff --git a/doc/clientless/transport-deck-navigation.md b/doc/clientless/transport-deck-navigation.md new file mode 100644 index 0000000000..1e56157ffa --- /dev/null +++ b/doc/clientless/transport-deck-navigation.md @@ -0,0 +1,74 @@ +# Local transport deck navigation + +Supported passengers: The Bravery (entry176310, display3015), Tiefenbahn subway +entries176080 through176086 (display3831). No new routes or boarding recordings +are created. Existing recorded dock transitions and native passenger ownership +remain authoritative. + +The controller lazily builds and caches one model-local Recast/Detour mesh per +supported model from vmaps/Transportship.wmo.vmo and vmaps/Subwaycar.m2.vmo. +Existing collision extraction is sufficient; no world MMAP regeneration or new +external data format is required. Missing/unsupported models retain the recorded +shared stand behaviour. Cached meshes are rebuilt on process restart. + +Raster cells are0.15m with0.10m vertical resolution, minimum headroom2m, +climb0.4m, slope45 degrees and erosion radius0.6m. Model-local XYZ is converted +to Detour YZX; extracted M2 triangle winding is reversed, like the map generator. +Queries reject incomplete corridors, distant floor projections and truncated +results. Polygon crossings preserve obstacle corners and floor geometry. +Only reachable points near the current deck level are selected; this does not +intentionally send bots on tours of other floors. + +After boarding, bots may select a nearby reachable point with a validated return +path. Targets prefer spacing from reserved destinations and other nearby native +passengers. Crowding can leave bots at the original stand. Meshes are shared; +reservations and movement state are per passenger and vehicle identity. + +Native splines use local transport coordinates, disable a second world-to-local +transformation, retain passenger ownership and walk at2m/s. A walk is scheduled +only during an actual pause with enough time for the outward path and a2s +reserve. The return path is validated then, but its time is reserved at the +destination, not at the departure dock. Departure, combat, death, timeout or the +last1.5s of a pause stop an active deck spline without detaching the passenger. +The current safe local position remains fixed during transit. + +At a recorded destination dock, the bot finds a complete local path to the +recorded exit anchor. Return duration, exit duration and a2s reserve must fit the +remaining pause. The recorded disembarkation begins only after reaching that +anchor. If no safe path/time exists, the bot remains aboard rather than moving +through geometry. Existing ride timeout, cancellation and lost-passenger recovery +still apply. Group formation resumes only after complete disembarkation. + +Read-only diagnostics (may populate the mesh cache): + +``` +clientless transport deck 176310 +clientless transport deck 176081 +``` + +Reports polygon counts and a bounded grid of reachable test targets/return paths. +Navigation log events: DECK_MESH and DECK_WALK. A DECK_WALK event records a spline +launch, not proof that the destination was reached. Normal TRANSPORT_STATE events +and passenger position traces are needed for live acceptance. + +Regression tests: + +- test_transport_deck_mesh.py: actual production mesh builder against synthetic + walls, low ceilings, disconnected floors, edges, invalid inputs, and both real + collision models. Data/build directory arguments are configurable. +- test_transport_deck_control.py: production scheduling/arrival method with + deterministic passenger/docking doubles. +- test_transport.py: existing native transition regressions plus the deck-return + gate that prevents premature disembarkation. + +Visual acceptance should cover multiple passengers spreading at a dock, stopping +when departure begins, retaining their positions in transit and returning before +normal disembarkation. Geometry/controller tests do not replace this acceptance. + +Live validation: both meshes were generated natively and a ship journey and a +first subway journey completed. After reducing the departure-only time budget, +a second subway journey exercised initial spreading too. That final journey hit +severe world-update stalls during disembarkation: the wagon departed and native +safety kept the passenger aboard. Exit timing under that load remains a known +limitation; see the dated checkpoint for exact evidence. Fixed travel-time +reserves do not compensate for entire skipped/late transport update windows. diff --git a/src/server/game/CMakeLists.txt b/src/server/game/CMakeLists.txt index cb899b022f..7062bb0690 100644 --- a/src/server/game/CMakeLists.txt +++ b/src/server/game/CMakeLists.txt @@ -96,3 +96,6 @@ install( DESTINATION "bin/scripts/clientless" FILES_MATCHING PATTERN "*.lua" ) + +# Local transport deck meshes are built once per supported collision model. +target_link_libraries(game PRIVATE Recast) diff --git a/src/server/game/World/ClientlessTransportDeckMgr.cpp b/src/server/game/World/ClientlessTransportDeckMgr.cpp new file mode 100644 index 0000000000..994da87a24 --- /dev/null +++ b/src/server/game/World/ClientlessTransportDeckMgr.cpp @@ -0,0 +1,162 @@ +#include "ClientlessTransportDeckMgr.h" +#include "TransportDeckMesh.h" +#include "WorldModel.h" +#include "World.h" +#include "Player.h" +#include "GameObject.h" +#include "Creature.h" +#include "GameTime.h" +#include "MotionMaster.h" +#include "MoveSplineInit.h" +#include "Chat.h" +#include "Log.h" +#include +#include +#include + +namespace +{ +using Point=TransportDeckMesh::Point; +struct Model {bool Tried=false;TransportDeckMesh Mesh;std::string Error;}; +std::unordered_map Models; +struct Passenger +{ + ObjectGuid Transport; + bool Tried=false,Moving=false,Reserved=false; + Point Target{}; + time_t Deadline=0; +}; +std::unordered_map Passengers; +float Distance(Point const& a,Point const& b) +{return std::sqrt(std::pow(a[0]-b[0],2)+std::pow(a[1]-b[1],2)+std::pow(a[2]-b[2],2));} +Point Local(Unit const* player) +{auto const& p=player->m_movementInfo.transport.pos;return {p.GetPositionX(),p.GetPositionY(),p.GetPositionZ()};} +float Length(std::vector const& path) +{float length=0;for(size_t i=1;i=176080 && entry<=176086; + char const* filename=entry==176310?"Transportship.wmo.vmo":tram?"Subwaycar.m2.vmo":nullptr; + if(!filename)return nullptr; + auto& model=Models[tram?3831:3015];if(model.Tried)return &model;model.Tried=true; + VMAP::WorldModel geometry; + if(!geometry.readFile(sWorld->GetDataPath()+"vmaps/"+filename))model.Error="collision model unavailable"; + else + { + std::vector groups;geometry.getGroupModels(groups); + std::vector xyz;std::vector triangles; + for(auto& group:groups) + { + std::vector vertices;std::vector indices;VMAP::WmoLiquid* liquid=nullptr; + group.getMeshData(vertices,indices,liquid);int offset=int(xyz.size()/3); + for(auto const& v:vertices)xyz.insert(xyz.end(),{v.x,v.y,v.z}); + for(auto const& t:indices) + { + // Extracted M2 collision winding is reversed by the map generator too. + triangles.insert(triangles.end(),{offset+int(t.idx0),offset+int(tram?t.idx2:t.idx1),offset+int(tram?t.idx1:t.idx2)}); + } + } + model.Mesh.Build(xyz,triangles,model.Error); + } + TC_LOG_INFO("server.adventurebot.navigation","DECK_MESH entry=%u polygons=%u error='%s'",entry,model.Mesh.Polygons(),model.Error.c_str()); + return &model; +} +void Launch(Player* player,Passenger& state,std::vector const& path) +{ + Movement::PointsArray points; + // Already in transport coordinates; never transform these a second time. + for(auto const& p:path)points.emplace_back(p[0],p[1],p[2]); + Movement::MoveSplineInit init(player);init.DisableTransportPathTransformations(); + init.MovebyPath(points);init.SetWalk(true);init.SetVelocity(2.0f); + player->GetMotionMaster()->LaunchMoveSpline(std::move(init)); + state.Moving=true;state.Target=path.back(); + state.Deadline=GameTime::GetGameTime()+time_t(std::ceil(Length(path)/2.0f))+5; + TC_LOG_INFO("server.adventurebot.navigation","DECK_WALK guid=%u points=%u length=%.2f target=(%.3f %.3f %.3f)", + player->GetGUID().GetCounter(),uint32(path.size()),Length(path),state.Target[0],state.Target[1],state.Target[2]); +} +} +void ClientlessTransportDeckMgr::Clear(uint32 guid){Passengers.erase(guid);} + +bool ClientlessTransportDeckMgr::Update(Player* player,GameObject* object,Position const& exitAnchor,bool arriving,uint32 exitBudget) +{ + auto model=Get(object->GetEntry()); + if(!model || !model->Mesh.Polygons() || std::fabs(object->GetObjectScale()-1.0f)>0.001f)return true; + uint32 guid=player->GetGUID().GetCounter();auto& state=Passengers[guid]; + if(state.Transport!=object->GetGUID()){state=Passenger{};state.Transport=object->GetGUID();} + TransportDockState dock;bool stopped=object->GetTransportDockState(dock) && dock.Stopped; + Point current=Local(player); + if(!stopped || !player->IsAlive() || player->IsInCombat()) + { + if(state.Moving){player->StopMoving();state.Moving=false;state.Reserved=false;} + return false; + } + if(state.Moving) + { + // Every active walk retains a reserve before departure, even after a lag spike. + if(dock.RemainingPause<1500 || GameTime::GetGameTime()>state.Deadline) + {player->StopMoving();state.Moving=false;state.Reserved=false;return false;} + if(player->IsSplineEnabled())return false; + state.Moving=false; + if(Distance(current,state.Target)>0.8f){state.Reserved=false;return false;} + } + Point anchor{exitAnchor.GetPositionX(),exitAnchor.GetPositionY(),exitAnchor.GetPositionZ()}; + if(arriving) + { + state.Reserved=false; + if(Distance(current,anchor)<=0.65f)return dock.RemainingPause>=exitBudget; + std::vector path; + if(!model->Mesh.Path(current,anchor,path))return false; // remain attached, never cut across the deck + uint64 budget=uint64(std::ceil(Length(path)/2.0f)*1000)+exitBudget+2000; + if(path.size()<2 || dock.RemainingPauseIsSplineEnabled())return false; + std::vector best;float bestScore=-1; + unsigned seed=guid*2654435761u; + for(unsigned i=0;i<32;++i) + { + float angle=float((seed%360)+i*137)*0.01745329252f; + float radius=2.0f+float(i%5)*1.5f; + Point candidate{current[0]+std::cos(angle)*radius,current[1]+std::sin(angle)*radius,current[2]}; + std::vector path,back; + if(!model->Mesh.Path(current,candidate,path) || path.size()<2 || + !model->Mesh.Path(path.back(),anchor,back))continue; + // This is the departure dock: stay at the reached spot during transit. + // Validate the return now, but reserve its duration at the destination. + uint64 budget=uint64(std::ceil(Length(path)/2.0f)*1000)+2000; + if(dock.RemainingPause players;player->GetPlayerListInGrid(players,25); + for(auto other:players)if(other!=player && other->GetTransport()==player->GetTransport()) + clearance=std::min(clearance,Distance(path.back(),Local(other))); + std::list creatures;player->GetCreatureListWithEntryInGrid(creatures,0,25); + for(auto other:creatures)if(other->GetTransport()==player->GetTransport()) + clearance=std::min(clearance,Distance(path.back(),Local(other))); + float score=std::min(clearance,3.0f)-Length(path)*0.02f; + if(clearance>=1.5f && score>bestScore){bestScore=score;best=std::move(path);} + } + if(!best.empty()) + {Launch(player,state,best);state.Reserved=true;} + return false; +} + +void ClientlessTransportDeckMgr::Diagnose(ChatHandler* handler,uint32 entry) +{ + auto model=Get(entry); + if(!model){handler->SendSysMessage("Supported deck entries: 176310 (The Bravery), 176080-176086 (Subway).");return;} + Point seed=entry==176310?Point{-1.87779f,7.8222f,6.21784f}:Point{-0.112158f,-0.187065f,-10.468f}; + unsigned reachable=0,returns=0;std::vector path,back; + for(int x=-10;x<=10;++x)for(int y=-10;y<=10;++y) + { + Point target{seed[0]+x,seed[1]+y,seed[2]}; + if(model->Mesh.Path(seed,target,path)) + {++reachable;if(model->Mesh.Path(path.back(),seed,back))++returns;} + } + handler->PSendSysMessage("Deck mesh entry=%u polygons=%u reachable=%u returnPaths=%u error='%s'",entry, + model->Mesh.Polygons(),reachable,returns,model->Error.c_str()); +} diff --git a/src/server/game/World/ClientlessTransportDeckMgr.h b/src/server/game/World/ClientlessTransportDeckMgr.h new file mode 100644 index 0000000000..9ec84b6856 --- /dev/null +++ b/src/server/game/World/ClientlessTransportDeckMgr.h @@ -0,0 +1,15 @@ +#ifndef VCORE_CLIENTLESS_TRANSPORT_DECK_MGR_H +#define VCORE_CLIENTLESS_TRANSPORT_DECK_MGR_H +#include "Define.h" +class Player; +class GameObject; +class Position; +class ChatHandler; +namespace ClientlessTransportDeckMgr +{ + // Return true only when the passenger is ready for the recorded exit. + bool Update(Player* player,GameObject* object,Position const& exitAnchor,bool arriving,uint32 exitBudget); + void Clear(uint32 guid); + void Diagnose(ChatHandler* handler,uint32 entry); +} +#endif diff --git a/src/server/game/World/ClientlessTransportMgr.cpp b/src/server/game/World/ClientlessTransportMgr.cpp index 904c7f453c..341b2e2274 100644 --- a/src/server/game/World/ClientlessTransportMgr.cpp +++ b/src/server/game/World/ClientlessTransportMgr.cpp @@ -2,6 +2,7 @@ #include "ClientlessPositioning.h" #include "ClientlessTravelGraphMgr.h" #include "ClientlessTransportMgr.h" +#include "ClientlessTransportDeckMgr.h" #include "Chat.h" #include "ClientlessPlayerMgr.h" #include "ClientlessRouteRecorderMgr.h" @@ -397,6 +398,7 @@ bool ClientlessTransportMgr::Start(Player* player, std::string const& from, std: auto const& source = _stops.at(from); if (source.Map != player->GetMapId() || Distance(*player, source.Points.front().Pos) > 3.0f) { error = "Player has not reached the shared departure node."; return false; } + ClientlessTransportDeckMgr::Clear(player->GetGUID().GetCounter()); Ride ride; ride.From = source; ride.To = _stops.at(to); ride.RequiredPause = uint32(PathLength(source) / std::max(1.0f, player->GetSpeed(MOVE_RUN)) * 1000.0f) + uint32(source.Points.size()) * 100 + 1500; @@ -421,6 +423,8 @@ ClientlessTransportStatus const* ClientlessTransportMgr::GetStatus(uint32 guid) void ClientlessTransportMgr::SetPhase(Player* player, Ride& ride, ClientlessTransportPhase phase, char const* detail) { + if(phase==ClientlessTransportPhase::Arrived || phase==ClientlessTransportPhase::Failed) + ClientlessTransportDeckMgr::Clear(player->GetGUID().GetCounter()); ride.Status = { phase, detail, ride.Abort }; ride.PhaseElapsed = 0; ride.Moving = false; TC_LOG_INFO("server.adventurebot.navigation", "TRANSPORT_STATE guid=%u phase='%s' from='%s' to='%s' passenger=%u detail='%s'", player->GetGUID().GetCounter(), PhaseName(phase), ride.From.Node.c_str(), ride.To.Node.c_str(), player->GetTransport() ? 1u : 0u, detail); @@ -513,6 +517,7 @@ void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff) { if(!player->GetTransport()) { + ClientlessTransportDeckMgr::Clear(player->GetGUID().GetCounter()); player->StopMoving();ride.Status.Recovering=true;ride.Status.Detail="lost passenger; returning to recorded safe departure"; ride.RecoveryMs=0;ride.RecoveryMoving=false;++ride.Recoveries; RecoverPassenger(player,ride,diff);return; @@ -520,9 +525,12 @@ void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff) fail("transport passenger identity changed; attachment retained");return; } ClientlessTransportStop const* arrival = nullptr; - if (AtDock(player, object, ride.To, uint32(PathLength(ride.To) / std::max(1.0f, player->GetSpeed(MOVE_RUN)) * 1000) + uint32(ride.To.Points.size()) * 100 + 1500)) arrival = &ride.To; - else if (ride.Abort && AtDock(player, object, ride.From, ride.RequiredPause)) arrival = &ride.From; - if (!arrival) return; + if (AtDock(player, object, ride.To, 0)) arrival = &ride.To; + else if (ride.Abort && AtDock(player, object, ride.From, 0)) arrival = &ride.From; + auto const& exit = arrival ? *arrival : ride.From; + uint32 exitBudget=uint32(PathLength(exit)/std::max(1.0f,player->GetSpeed(MOVE_RUN))*1000)+uint32(exit.Points.size())*100+1500; + bool ready=ClientlessTransportDeckMgr::Update(player,object,exit.Points.back().Pos,arrival!=nullptr,exitBudget); + if (!arrival || !ready || !AtDock(player,object,*arrival,exitBudget)) return; if (arrival == &ride.From) ride.To = ride.From; ride.Point = ride.To.Points.size(); SetPhase(player, ride, Phase::Leaving, "leaving via reversed recorded transition"); @@ -632,7 +640,7 @@ void ClientlessTransportMgr::Update(uint32 diff) { WorldSession* session = sWorld->FindClientlessSession(itr->first); if (!session || sWorld->GetClientlessPlayerState(itr->first) != ClientlessPlayerState::Online) - { itr = _rides.erase(itr); continue; } + { ClientlessTransportDeckMgr::Clear(itr->first);itr = _rides.erase(itr); continue; } Player* player = session->GetPlayer(); if (player) UpdateRide(player, itr->second, diff); ++itr; @@ -644,6 +652,10 @@ bool ClientlessTransportMgr::Command(ChatHandler* handler, char const* args) std::istringstream input(args ? args : ""); std::string command; input >> command; Player* gm = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr; + if (command == "deck") + { + uint32 entry=0;input>>entry;ClientlessTransportDeckMgr::Diagnose(handler,entry);return true; + } if (command == "reload") Reload(); if (!EnsureLoaded()) { diff --git a/src/server/game/World/TransportDeckMesh.cpp b/src/server/game/World/TransportDeckMesh.cpp new file mode 100644 index 0000000000..04c937e8e9 --- /dev/null +++ b/src/server/game/World/TransportDeckMesh.cpp @@ -0,0 +1,118 @@ +#include "TransportDeckMesh.h" +#include "Recast.h" +#include "DetourNavMesh.h" +#include "DetourNavMeshBuilder.h" +#include "DetourNavMeshQuery.h" +#include +#include +#include + +struct TransportDeckMesh::Data +{ + dtNavMesh* Mesh = nullptr; + dtNavMeshQuery* Query = nullptr; + unsigned Polygons = 0; + ~Data(){dtFreeNavMeshQuery(Query);dtFreeNavMesh(Mesh);} +}; +TransportDeckMesh::TransportDeckMesh() = default; +TransportDeckMesh::~TransportDeckMesh() = default; +unsigned TransportDeckMesh::Polygons() const{return _data?_data->Polygons:0;} + +bool TransportDeckMesh::Build(std::vector const& xyz,std::vector const& triangles,std::string& error) +{ + _data.reset();error.clear(); + auto fail=[&](char const* why){error=why;return false;}; + if(xyz.empty() || xyz.size()%3 || triangles.empty() || triangles.size()%3 || xyz.size()>300000 || triangles.size()>300000) + return fail("invalid or oversized deck geometry"); + std::vector vertices(xyz.size()); + for(size_t i=0;i500)return fail("invalid model coordinate"); + vertices[i]=xyz[i+1];vertices[i+1]=xyz[i+2];vertices[i+2]=xyz[i]; + } + for(int index:triangles)if(index<0 || size_t(index)>=xyz.size()/3)return fail("invalid deck triangle index"); + rcContext context(false);rcConfig config{}; + config.cs=0.15f;config.ch=0.10f;config.walkableSlopeAngle=45; + config.walkableHeight=20;config.walkableClimb=4;config.walkableRadius=4; + config.maxEdgeLen=80;config.maxSimplificationError=0.5f; + config.minRegionArea=8;config.mergeRegionArea=20;config.maxVertsPerPoly=6; + config.detailSampleDist=0.9f;config.detailSampleMaxError=0.10f; + rcCalcBounds(vertices.data(),int(vertices.size()/3),config.bmin,config.bmax); + for(int i:{0,2}){config.bmin[i]-=1;config.bmax[i]+=1;} + config.bmin[1]-=1;config.bmax[1]+=3; + rcCalcGridSize(config.bmin,config.bmax,config.cs,&config.width,&config.height); + if(int64_t(config.width)*config.height>2000000)return fail("deck raster exceeds budget"); + std::unique_ptr solid(rcAllocHeightfield(),rcFreeHeightField); + std::unique_ptr compact(rcAllocCompactHeightfield(),rcFreeCompactHeightfield); + std::unique_ptr contours(rcAllocContourSet(),rcFreeContourSet); + std::unique_ptr poly(rcAllocPolyMesh(),rcFreePolyMesh); + std::unique_ptr detail(rcAllocPolyMeshDetail(),rcFreePolyMeshDetail); + if(!solid || !compact || !contours || !poly || !detail)return fail("deck builder allocation failed"); + int count=int(triangles.size()/3);std::vector areas(count,RC_NULL_AREA); + rcMarkWalkableTriangles(&context,config.walkableSlopeAngle,vertices.data(),int(vertices.size()/3),triangles.data(),count,areas.data()); + if(!rcCreateHeightfield(&context,*solid,config.width,config.height,config.bmin,config.bmax,config.cs,config.ch) || + !rcRasterizeTriangles(&context,vertices.data(),int(vertices.size()/3),triangles.data(),areas.data(),count,*solid,config.walkableClimb)) + return fail("deck rasterization failed"); + rcFilterLowHangingWalkableObstacles(&context,config.walkableClimb,*solid); + rcFilterLedgeSpans(&context,config.walkableHeight,config.walkableClimb,*solid); + rcFilterWalkableLowHeightSpans(&context,config.walkableHeight,*solid); + if(!rcBuildCompactHeightfield(&context,config.walkableHeight,config.walkableClimb,*solid,*compact) || + !rcErodeWalkableArea(&context,config.walkableRadius,*compact) || + !rcBuildDistanceField(&context,*compact) || + !rcBuildRegions(&context,*compact,0,config.minRegionArea,config.mergeRegionArea) || + !rcBuildContours(&context,*compact,config.maxSimplificationError,config.maxEdgeLen,*contours) || + !rcBuildPolyMesh(&context,*contours,config.maxVertsPerPoly,*poly) || !poly->npolys || + !rcBuildPolyMeshDetail(&context,*poly,*compact,config.detailSampleDist,config.detailSampleMaxError,*detail)) + return fail("no usable deck navigation mesh"); + for(int i=0;inpolys;++i){poly->areas[i]=0;poly->flags[i]=1;} + dtNavMeshCreateParams params{}; + params.verts=poly->verts;params.vertCount=poly->nverts;params.polys=poly->polys; + params.polyAreas=poly->areas;params.polyFlags=poly->flags;params.polyCount=poly->npolys;params.nvp=poly->nvp; + params.detailMeshes=detail->meshes;params.detailVerts=detail->verts;params.detailVertsCount=detail->nverts; + params.detailTris=detail->tris;params.detailTriCount=detail->ntris; + std::copy(poly->bmin,poly->bmin+3,params.bmin);std::copy(poly->bmax,poly->bmax+3,params.bmax); + params.walkableHeight=2;params.walkableRadius=0.6f;params.walkableClimb=0.4f; + params.cs=config.cs;params.ch=config.ch;params.buildBvTree=true; + unsigned char* bytes=nullptr;int size=0; + if(!dtCreateNavMeshData(¶ms,&bytes,&size))return fail("deck Detour conversion failed"); + auto data=std::make_unique();data->Mesh=dtAllocNavMesh();data->Query=dtAllocNavMeshQuery(); + if(!data->Mesh || !data->Query){dtFree(bytes);return fail("deck query allocation failed");} + if(dtStatusFailed(data->Mesh->init(bytes,size,DT_TILE_FREE_DATA))) + {dtFree(bytes);return fail("deck mesh initialization failed");} + if(dtStatusFailed(data->Query->init(data->Mesh,4096)))return fail("deck query initialization failed"); + data->Polygons=unsigned(poly->npolys);_data=std::move(data);return true; +} + +bool TransportDeckMesh::Path(Point const& from,Point const& to,std::vector& path) const +{ + path.clear();if(!_data)return false; + for(auto const& p:{from,to})for(float c:p)if(!std::isfinite(c))return false; + float a[]{from[1],from[2],from[0]},b[]{to[1],to[2],to[0]},na[3],nb[3]; + float extents[]{0.65f,0.5f,0.65f};dtPolyRef start=0,end=0;dtQueryFilter filter; + filter.setIncludeFlags(1);filter.setExcludeFlags(0); + auto query=_data->Query; + if(dtStatusFailed(query->findNearestPoly(a,extents,&filter,&start,na)) || !start || + dtStatusFailed(query->findNearestPoly(b,extents,&filter,&end,nb)) || !end)return false; + for(auto pair:{std::make_pair(a,na),std::make_pair(b,nb)}) + if(std::hypot(pair.first[0]-pair.second[0],pair.first[2]-pair.second[2])>0.65f || + std::fabs(pair.first[1]-pair.second[1])>0.5f)return false; + dtPolyRef corridor[512];int count=0; + dtStatus status=query->findPath(start,end,na,nb,&filter,corridor,&count,512); + if(dtStatusFailed(status) || dtStatusDetail(status,DT_OUT_OF_NODES) || dtStatusDetail(status,DT_BUFFER_TOO_SMALL) || + !count || corridor[count-1]!=end)return false; + float points[1536];int n=0; + status=query->findStraightPath(na,nb,corridor,count,points,nullptr,nullptr,&n,512,DT_STRAIGHTPATH_ALL_CROSSINGS); + if(dtStatusFailed(status) || dtStatusDetail(status,DT_BUFFER_TOO_SMALL) || n<1)return false; + float length=0; + for(int i=0;i100){path.clear();return false;} + } + path.push_back(p); + } + return true; +} diff --git a/src/server/game/World/TransportDeckMesh.h b/src/server/game/World/TransportDeckMesh.h new file mode 100644 index 0000000000..232cc04654 --- /dev/null +++ b/src/server/game/World/TransportDeckMesh.h @@ -0,0 +1,24 @@ +#ifndef VCORE_TRANSPORT_DECK_MESH_H +#define VCORE_TRANSPORT_DECK_MESH_H +#include +#include +#include +#include + +// Model-local XYZ. No map grids, world transforms or passenger ownership here. +class TransportDeckMesh +{ +public: + using Point = std::array; + TransportDeckMesh(); + ~TransportDeckMesh(); + TransportDeckMesh(TransportDeckMesh const&) = delete; + TransportDeckMesh& operator=(TransportDeckMesh const&) = delete; + bool Build(std::vector const& xyz, std::vector const& triangles, std::string& error); + bool Path(Point const& from, Point const& to, std::vector& path) const; + unsigned Polygons() const; +private: + struct Data; + std::unique_ptr _data; +}; +#endif diff --git a/tests/clientless/test_transport.py b/tests/clientless/test_transport.py index 031a5aff60..0998ae7835 100644 --- a/tests/clientless/test_transport.py +++ b/tests/clientless/test_transport.py @@ -100,6 +100,7 @@ public: struct Node{} node; struct Graph{Node* FindNode(std::string const&){return &node;}}graph;auto sClientlessTravelGraphMgr=&graph; struct AreaAccess{bool allowed=true;bool Applies(Player*){return true;}bool AllowsNode(Player*,Node const&){return allowed;}}areas;auto sAdventureAreaMgr=&areas; +namespace ClientlessTransportDeckMgr {bool ready=true;int calls=0;void Clear(uint32){} bool Update(Player*,GameObject*,Position const&,bool,uint32){++calls;return ready;}} namespace ClientlessPositioning{bool available=false;bool Nearby(Player*p,Position const&,float,Position&spot,std::vector&path){if(!available)return false;spot=*p;spot.x+=2;path={*p,spot};return true;}} void TransportBase::CalculatePassengerPosition(float&x,float&y,float&z,float*o)const{CalculatePassengerPosition(x,y,z,o,go->x,go->y,go->z,go->o);} void TransportBase::CalculatePassengerOffset(float&x,float&y,float&z,float*o)const{CalculatePassengerOffset(x,y,z,o,go->x,go->y,go->z,go->o);} @@ -177,7 +178,9 @@ int main(){ go.state.RemainingPause=10000;run(mgr,p,r);assert(r.Status.Phase==Phase::Riding&&p.transport==&go.base);assert(std::fabs(p.m_movementInfo.transport.pos.x-1)<0.01); // Wrong location despite a stopped object must not allow disembarkation. go.x=50;p.x=51;run(mgr,p,r);assert(r.Status.Phase==Phase::Riding&&p.transport); - go.x=100;p.x=101;run(mgr,p,r);assert(r.Status.Phase==Phase::Arrived&&!p.transport&&p.x==104); + go.x=100;p.x=101;ClientlessTransportDeckMgr::ready=false; + run(mgr,p,r);assert(r.Status.Phase==Phase::Riding&&p.transport); + ClientlessTransportDeckMgr::ready=true;run(mgr,p,r);assert(r.Status.Phase==Phase::Arrived&&!p.transport&&p.x==104); // Cancellation on land does not attach a passenger. p.x=0;go.x=5;assert(mgr.Start(&p,"A","B",error));mgr.Cancel(26);auto&cancel=mgr._rides.at(26);run(mgr,p,cancel);assert(cancel.Status.Phase==Phase::Failed&&cancel.Status.Cancelled&&!p.transport); // Midride cancellation retains the Passenger until a recorded safe dock. diff --git a/tests/clientless/test_transport_deck_control.py b/tests/clientless/test_transport_deck_control.py new file mode 100644 index 0000000000..1dc1060b80 --- /dev/null +++ b/tests/clientless/test_transport_deck_control.py @@ -0,0 +1,74 @@ +"""Run the actual deck controller against deterministic docking/movement doubles.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/ClientlessTransportDeckMgr.cpp').read_text() +a=s.index('bool ClientlessTransportDeckMgr::Update(');b=s.index('\nvoid ClientlessTransportDeckMgr::Diagnose(',a) +code=r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include +using uint32=unsigned;using uint64=unsigned long long;using Point=std::array; +using ObjectGuid=unsigned; +struct Creature; +struct Position{float x=0,y=0,z=0;float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}}; +struct TransportDockState{bool Stopped=true;uint32 RemainingPause=60000;}; +struct GameObject{TransportDockState dock;unsigned entry=176310;unsigned GetEntry(){return entry;}unsigned GetGUID(){return 123;}float GetObjectScale(){return 1;}bool GetTransportDockState(TransportDockState& d){d=dock;return true;}}object; +struct Guid{unsigned id;unsigned GetCounter(){return id;}}; +struct Player{Point current{0,0,0};unsigned id=1;bool spline=false,alive=true,combat=false;int stops=0;void* transport=&object; + Guid GetGUID(){return {id};}bool IsAlive(){return alive;}bool IsInCombat(){return combat;}bool IsSplineEnabled(){return spline;} + void StopMoving(){++stops;spline=false;}void* GetTransport(){return transport;}void GetPlayerListInGrid(std::list&,int){} void GetCreatureListWithEntryInGrid(std::list&,int,int){}}; +struct Creature:Player{}; +struct Passenger{unsigned Transport=0;bool Tried=false,Moving=false,Reserved=false;Point Target{};time_t Deadline=0;}; +std::unordered_map Passengers; +float Distance(Point const&a,Point const&b){return std::sqrt(std::pow(a[0]-b[0],2)+std::pow(a[1]-b[1],2)+std::pow(a[2]-b[2],2));} +Point Local(Player*p){return p->current;}float Length(std::vectorconst&path){float n=0;for(unsigned i=1;i&p){p={a,b};return !blocked;}}; +struct Model{Mesh Mesh;}model; +Model* Get(unsigned entry){return entry==176310?&model:nullptr;} +namespace GameTime{time_t now=100;time_t GetGameTime(){return now;}} +int launches=0; +void Launch(Player*p,Passenger&s,std::vectorconst&path){++launches;p->spline=true;s.Moving=true;s.Target=path.back();s.Deadline=GameTime::now+20;} +namespace ClientlessTransportDeckMgr{bool Update(Player*,GameObject*,Position const&,bool,uint32);} +'''+s[a:b]+r''' +int main(){Player p;Position anchor; + // Insufficient halt time never starts a walk. + object.dock.RemainingPause=1000;assert(!ClientlessTransportDeckMgr::Update(&p,&object,anchor,false,5000));assert(!launches); + // Departure does not need time for disembarkation at this station. + Passengers.clear();object.dock.RemainingPause=4500; + assert(!ClientlessTransportDeckMgr::Update(&p,&object,anchor,false,5000));assert(launches==1); + Passengers.clear();p.spline=false;launches=0;object.dock.RemainingPause=60000; + assert(!ClientlessTransportDeckMgr::Update(&p,&object,anchor,false,5000));assert(launches==1&&Passengers[1].Moving); + // Departure stops the spline but keeps ownership. + object.dock.Stopped=false;assert(!ClientlessTransportDeckMgr::Update(&p,&object,anchor,false,5000));assert(p.stops==1&&!p.spline&&p.transport==&object); + // Arriving at the next dock starts a real return, provided it fits the halt. + p.current={4,0,0};object.dock.Stopped=true;object.dock.RemainingPause=6000; + assert(!ClientlessTransportDeckMgr::Update(&p,&object,anchor,true,5000));assert(launches==1); + object.dock.RemainingPause=60000;assert(!ClientlessTransportDeckMgr::Update(&p,&object,anchor,true,5000));assert(launches==2); + assert(!ClientlessTransportDeckMgr::Update(&p,&object,anchor,true,5000)); // still moving + p.spline=false;p.current={0,0,0};assert(ClientlessTransportDeckMgr::Update(&p,&object,anchor,true,5000)); + // Interrupted arrival never claims the exit anchor was reached. + p.current={4,0,0};model.Mesh.blocked=true;assert(!ClientlessTransportDeckMgr::Update(&p,&object,anchor,true,5000));assert(launches==2); + model.Mesh.blocked=false;p.combat=true;assert(!ClientlessTransportDeckMgr::Update(&p,&object,anchor,true,5000));p.combat=false; + Passengers.clear();p.current={0,0,0};p.spline=false;launches=0; + ClientlessTransportDeckMgr::Update(&p,&object,anchor,false,5000);Point first=Passengers[1].Target; + Player other;other.id=2;ClientlessTransportDeckMgr::Update(&other,&object,anchor,false,5000); + assert(launches==2&&Distance(first,Passengers[2].Target)>=1.5f); + // Near departure, a running walk is stopped on the deck. + object.dock.RemainingPause=1000;assert(!ClientlessTransportDeckMgr::Update(&p,&object,anchor,false,5000));assert(!p.spline); + object.entry=999;assert(ClientlessTransportDeckMgr::Update(&p,&object,anchor,true,5000)); + std::cout<<"Production deck controller: halt budgets, departure interruption, return-before-exit, blocked paths and reservations passed\n"; +} +''' +# Avoid a type/member name collision in the standalone double. +code=code.replace('struct Mesh{','struct FakeMesh{').replace('struct Model{Mesh Mesh;}','struct Model{FakeMesh Mesh;}') +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) diff --git a/tests/clientless/test_transport_deck_mesh.py b/tests/clientless/test_transport_deck_mesh.py new file mode 100644 index 0000000000..9984f0b2b1 --- /dev/null +++ b/tests/clientless/test_transport_deck_mesh.py @@ -0,0 +1,65 @@ +"""Compile the real local mesh builder and test synthetic and extracted vehicle geometry.""" +from pathlib import Path +import subprocess, tempfile, struct, argparse +root=Path(__file__).resolve().parents[2] +parser=argparse.ArgumentParser();parser.add_argument('--data',default='/home/wow_admin/wow_cata_server/data/vmaps');parser.add_argument('--build',default='/home/wow_admin/wow_cata_server/build/debug');args=parser.parse_args() +code=r''' +#include "TransportDeckMesh.h" +#include +#include +#include +#include +using P=TransportDeckMesh::Point; +void quad(std::vector& v,std::vector& t,float x,float y,float z,float w,float h){int i=v.size()/3; + for(auto p:{P{x,y,z},P{x+w,y,z},P{x+w,y+h,z},P{x,y+h,z}})v.insert(v.end(),p.begin(),p.end()); + t.insert(t.end(),{i,i+1,i+2,i,i+2,i+3});} +void wall(std::vector& v,std::vector& t,float x,float y0,float y1){int i=v.size()/3; + for(auto p:{P{x,y0,-1},P{x,y1,-1},P{x,y1,4},P{x,y0,4}})v.insert(v.end(),p.begin(),p.end()); + t.insert(t.end(),{i,i+1,i+2,i,i+2,i+3});} +int main(int argc,char**argv){TransportDeckMesh mesh;std::string error;std::vector v;std::vector t;std::vector

path; + quad(v,t,-5,-5,0,10,10);quad(v,t,12,-5,0,10,10);quad(v,t,-5,-5,5,10,10); + assert(mesh.Build(v,t,error));assert(mesh.Path({0,0,0},{3,3,0},path)); + assert(!mesh.Path({0,0,0},{15,0,0},path)); // disconnected deck + assert(!mesh.Path({0,0,0},{0,0,5},path)); // cannot jump floors + assert(mesh.Path({0,0,0},{5,0,0},path) && path.back()[0]<4.5f); // snap stays inside eroded edge + assert(!mesh.Path({0,0,0},{6,0,0},path)); + assert(!mesh.Path({0,0,0},{0,0,2},path)); // no projection to another floor + assert(!mesh.Path({NAN,0,0},{0,0,0},path)); + v.clear();t.clear();quad(v,t,-8,-8,0,16,16);wall(v,t,0,-8,8); + assert(mesh.Build(v,t,error));assert(!mesh.Path({-3,0,0},{3,0,0},path)); // solid wall + v.clear();t.clear();quad(v,t,-8,-8,0,16,16);wall(v,t,0,-8,4); + assert(mesh.Build(v,t,error));assert(mesh.Path({-3,0,0},{3,0,0},path)); + bool around=false;for(auto const& point:path)if(point[1]>4)around=true;assert(around); // routed around obstruction + v.clear();t.clear();quad(v,t,-5,-5,0,10,10);quad(v,t,-1,-5,1.3f,2,10); + assert(mesh.Build(v,t,error));assert(!mesh.Path({-3,0,0},{3,0,0},path)); // insufficient headroom + for(int a=1;a back;if(mesh.Path(path.back(),seed,back))++returns;}} + std::cout<8 && returns==reachable); + } + assert(!mesh.Build({},t,error));std::cout<<"Real deck meshes: connectivity, floor isolation, edge clearance and return paths passed\n"; +} +''' +with tempfile.TemporaryDirectory() as d: + p=Path(d);fixtures=[] + for name,seed,m2 in [('Transportship.wmo',(-1.87779,7.8222,6.21784),False),('Subwaycar.m2',(-.112158,-.187065,-10.468),True)]: + data=(Path(args.data)/(name+'.vmo')).read_bytes();start=0;verts=[];tris=[] + while True: + q=data.find(b'VERT',start) + if q<0:break + size,n=struct.unpack_from('