Add local deck navigation for AdventureBot transport passengers
This commit is contained in:
1 parent
49712aadfe
commit
2841ee973b
19 files changed
+693
-5
No files matched your search
@@ -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
|
# Umsetzung 2026-10-29: Navigation und Verhaltensgrenzen
|
||||||
|
|
||||||
Die unten festgehaltene Liste ist implementiert; lokaler Commit vom Nutzer angefordert.
|
Die unten festgehaltene Liste ist implementiert; lokaler Commit vom Nutzer angefordert.
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Flight handoff before spline end, terminal landing, return corners, collision stop and actual distance passed.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Moving-target flight: bounded phased corridors, obstacle hold, ceiling rejection, aerial formation hold, safe landing and return fallback passed
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Transport dock, frame, recorder, cross-map copy and ride regressions passed
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Production deck controller: halt budgets, departure interruption, return-before-exit, blocked paths and reservations passed
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -96,3 +96,6 @@ install(
|
|||||||
DESTINATION "bin/scripts/clientless"
|
DESTINATION "bin/scripts/clientless"
|
||||||
FILES_MATCHING PATTERN "*.lua"
|
FILES_MATCHING PATTERN "*.lua"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Local transport deck meshes are built once per supported collision model.
|
||||||
|
target_link_libraries(game PRIVATE Recast)
|
||||||
@@ -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 <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
using Point=TransportDeckMesh::Point;
|
||||||
|
struct Model {bool Tried=false;TransportDeckMesh Mesh;std::string Error;};
|
||||||
|
std::unordered_map<uint32,Model> Models;
|
||||||
|
struct Passenger
|
||||||
|
{
|
||||||
|
ObjectGuid Transport;
|
||||||
|
bool Tried=false,Moving=false,Reserved=false;
|
||||||
|
Point Target{};
|
||||||
|
time_t Deadline=0;
|
||||||
|
};
|
||||||
|
std::unordered_map<uint32,Passenger> 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<Point> const& path)
|
||||||
|
{float length=0;for(size_t i=1;i<path.size();++i)length+=Distance(path[i-1],path[i]);return length;}
|
||||||
|
Model* Get(uint32 entry)
|
||||||
|
{
|
||||||
|
bool tram=entry>=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<VMAP::GroupModel> groups;geometry.getGroupModels(groups);
|
||||||
|
std::vector<float> xyz;std::vector<int> triangles;
|
||||||
|
for(auto& group:groups)
|
||||||
|
{
|
||||||
|
std::vector<G3D::Vector3> vertices;std::vector<VMAP::MeshTriangle> 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<Point> 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<Point> 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.RemainingPause<budget)return false;
|
||||||
|
Launch(player,state,path);return false;
|
||||||
|
}
|
||||||
|
if(state.Tried)return false;
|
||||||
|
state.Tried=true;
|
||||||
|
if(player->IsSplineEnabled())return false;
|
||||||
|
std::vector<Point> 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<Point> 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<budget)continue;
|
||||||
|
float clearance=10;
|
||||||
|
for(auto const& pair:Passengers)if(pair.first!=guid && pair.second.Transport==state.Transport && pair.second.Reserved)
|
||||||
|
clearance=std::min(clearance,Distance(path.back(),pair.second.Target));
|
||||||
|
// Include real nearby players/NPCs aboard the same vehicle, in local coordinates.
|
||||||
|
std::list<Player*> 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<Creature*> 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<Point> 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());
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
#include "ClientlessPositioning.h"
|
#include "ClientlessPositioning.h"
|
||||||
#include "ClientlessTravelGraphMgr.h"
|
#include "ClientlessTravelGraphMgr.h"
|
||||||
#include "ClientlessTransportMgr.h"
|
#include "ClientlessTransportMgr.h"
|
||||||
|
#include "ClientlessTransportDeckMgr.h"
|
||||||
#include "Chat.h"
|
#include "Chat.h"
|
||||||
#include "ClientlessPlayerMgr.h"
|
#include "ClientlessPlayerMgr.h"
|
||||||
#include "ClientlessRouteRecorderMgr.h"
|
#include "ClientlessRouteRecorderMgr.h"
|
||||||
@@ -397,6 +398,7 @@ bool ClientlessTransportMgr::Start(Player* player, std::string const& from, std:
|
|||||||
auto const& source = _stops.at(from);
|
auto const& source = _stops.at(from);
|
||||||
if (source.Map != player->GetMapId() || Distance(*player, source.Points.front().Pos) > 3.0f)
|
if (source.Map != player->GetMapId() || Distance(*player, source.Points.front().Pos) > 3.0f)
|
||||||
{ error = "Player has not reached the shared departure node."; return false; }
|
{ error = "Player has not reached the shared departure node."; return false; }
|
||||||
|
ClientlessTransportDeckMgr::Clear(player->GetGUID().GetCounter());
|
||||||
Ride ride;
|
Ride ride;
|
||||||
ride.From = source; ride.To = _stops.at(to);
|
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;
|
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)
|
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;
|
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'",
|
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);
|
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())
|
if(!player->GetTransport())
|
||||||
{
|
{
|
||||||
|
ClientlessTransportDeckMgr::Clear(player->GetGUID().GetCounter());
|
||||||
player->StopMoving();ride.Status.Recovering=true;ride.Status.Detail="lost passenger; returning to recorded safe departure";
|
player->StopMoving();ride.Status.Recovering=true;ride.Status.Detail="lost passenger; returning to recorded safe departure";
|
||||||
ride.RecoveryMs=0;ride.RecoveryMoving=false;++ride.Recoveries;
|
ride.RecoveryMs=0;ride.RecoveryMoving=false;++ride.Recoveries;
|
||||||
RecoverPassenger(player,ride,diff);return;
|
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;
|
fail("transport passenger identity changed; attachment retained");return;
|
||||||
}
|
}
|
||||||
ClientlessTransportStop const* arrival = nullptr;
|
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;
|
if (AtDock(player, object, ride.To, 0)) arrival = &ride.To;
|
||||||
else if (ride.Abort && AtDock(player, object, ride.From, ride.RequiredPause)) arrival = &ride.From;
|
else if (ride.Abort && AtDock(player, object, ride.From, 0)) arrival = &ride.From;
|
||||||
if (!arrival) return;
|
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;
|
if (arrival == &ride.From) ride.To = ride.From;
|
||||||
ride.Point = ride.To.Points.size();
|
ride.Point = ride.To.Points.size();
|
||||||
SetPhase(player, ride, Phase::Leaving, "leaving via reversed recorded transition");
|
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);
|
WorldSession* session = sWorld->FindClientlessSession(itr->first);
|
||||||
if (!session || sWorld->GetClientlessPlayerState(itr->first) != ClientlessPlayerState::Online)
|
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();
|
Player* player = session->GetPlayer();
|
||||||
if (player) UpdateRide(player, itr->second, diff);
|
if (player) UpdateRide(player, itr->second, diff);
|
||||||
++itr;
|
++itr;
|
||||||
@@ -644,6 +652,10 @@ bool ClientlessTransportMgr::Command(ChatHandler* handler, char const* args)
|
|||||||
std::istringstream input(args ? args : "");
|
std::istringstream input(args ? args : "");
|
||||||
std::string command; input >> command;
|
std::string command; input >> command;
|
||||||
Player* gm = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
|
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 (command == "reload") Reload();
|
||||||
if (!EnsureLoaded())
|
if (!EnsureLoaded())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#include "TransportDeckMesh.h"
|
||||||
|
#include "Recast.h"
|
||||||
|
#include "DetourNavMesh.h"
|
||||||
|
#include "DetourNavMeshBuilder.h"
|
||||||
|
#include "DetourNavMeshQuery.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
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<float> const& xyz,std::vector<int> 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<float> vertices(xyz.size());
|
||||||
|
for(size_t i=0;i<xyz.size();i+=3)
|
||||||
|
{
|
||||||
|
for(size_t j=0;j<3;++j)if(!std::isfinite(xyz[i+j]) || std::fabs(xyz[i+j])>500)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<rcHeightfield,decltype(&rcFreeHeightField)> solid(rcAllocHeightfield(),rcFreeHeightField);
|
||||||
|
std::unique_ptr<rcCompactHeightfield,decltype(&rcFreeCompactHeightfield)> compact(rcAllocCompactHeightfield(),rcFreeCompactHeightfield);
|
||||||
|
std::unique_ptr<rcContourSet,decltype(&rcFreeContourSet)> contours(rcAllocContourSet(),rcFreeContourSet);
|
||||||
|
std::unique_ptr<rcPolyMesh,decltype(&rcFreePolyMesh)> poly(rcAllocPolyMesh(),rcFreePolyMesh);
|
||||||
|
std::unique_ptr<rcPolyMeshDetail,decltype(&rcFreePolyMeshDetail)> detail(rcAllocPolyMeshDetail(),rcFreePolyMeshDetail);
|
||||||
|
if(!solid || !compact || !contours || !poly || !detail)return fail("deck builder allocation failed");
|
||||||
|
int count=int(triangles.size()/3);std::vector<unsigned char> 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;i<poly->npolys;++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>();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<Point>& 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;i<n;++i)
|
||||||
|
{
|
||||||
|
Point p{points[3*i+2],points[3*i],points[3*i+1]};
|
||||||
|
if(!path.empty())
|
||||||
|
{
|
||||||
|
auto const& last=path.back();float d=std::sqrt(std::pow(p[0]-last[0],2)+std::pow(p[1]-last[1],2)+std::pow(p[2]-last[2],2));
|
||||||
|
length+=d;if(length>100){path.clear();return false;}
|
||||||
|
}
|
||||||
|
path.push_back(p);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#ifndef VCORE_TRANSPORT_DECK_MESH_H
|
||||||
|
#define VCORE_TRANSPORT_DECK_MESH_H
|
||||||
|
#include <array>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// Model-local XYZ. No map grids, world transforms or passenger ownership here.
|
||||||
|
class TransportDeckMesh
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
using Point = std::array<float,3>;
|
||||||
|
TransportDeckMesh();
|
||||||
|
~TransportDeckMesh();
|
||||||
|
TransportDeckMesh(TransportDeckMesh const&) = delete;
|
||||||
|
TransportDeckMesh& operator=(TransportDeckMesh const&) = delete;
|
||||||
|
bool Build(std::vector<float> const& xyz, std::vector<int> const& triangles, std::string& error);
|
||||||
|
bool Path(Point const& from, Point const& to, std::vector<Point>& path) const;
|
||||||
|
unsigned Polygons() const;
|
||||||
|
private:
|
||||||
|
struct Data;
|
||||||
|
std::unique_ptr<Data> _data;
|
||||||
|
};
|
||||||
|
#endif
|
||||||
@@ -100,6 +100,7 @@ public:
|
|||||||
struct Node{} node;
|
struct Node{} node;
|
||||||
struct Graph{Node* FindNode(std::string const&){return &node;}}graph;auto sClientlessTravelGraphMgr=&graph;
|
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;
|
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<Position>&path){if(!available)return false;spot=*p;spot.x+=2;path={*p,spot};return true;}}
|
namespace ClientlessPositioning{bool available=false;bool Nearby(Player*p,Position const&,float,Position&spot,std::vector<Position>&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::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);}
|
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);
|
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.
|
// 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=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.
|
// 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);
|
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.
|
// Midride cancellation retains the Passenger until a recorded safe dock.
|
||||||
|
|||||||
@@ -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 <array>
|
||||||
|
#include <vector>
|
||||||
|
#include <list>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <ctime>
|
||||||
|
#include <cassert>
|
||||||
|
#include <iostream>
|
||||||
|
using uint32=unsigned;using uint64=unsigned long long;using Point=std::array<float,3>;
|
||||||
|
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<Player*>&,int){} void GetCreatureListWithEntryInGrid(std::list<Creature*>&,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<unsigned,Passenger> 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::vector<Point>const&path){float n=0;for(unsigned i=1;i<path.size();++i)n+=Distance(path[i-1],path[i]);return n;}
|
||||||
|
struct Mesh{bool blocked=false;unsigned Polygons(){return 50;}bool Path(Point const&a,Point const&b,std::vector<Point>&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::vector<Point>const&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)
|
||||||
@@ -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 <cassert>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <cmath>
|
||||||
|
using P=TransportDeckMesh::Point;
|
||||||
|
void quad(std::vector<float>& v,std::vector<int>& 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<float>& v,std::vector<int>& 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<float> v;std::vector<int> t;std::vector<P> 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<argc;++a){std::ifstream f(argv[a],std::ios::binary);unsigned nv,nt;f.read((char*)&nv,4);f.read((char*)&nt,4);
|
||||||
|
v.resize(nv);t.resize(nt);f.read((char*)v.data(),nv*4);f.read((char*)t.data(),nt*4);P seed;f.read((char*)seed.data(),12);
|
||||||
|
assert(mesh.Build(v,t,error));assert(mesh.Path(seed,seed,path));int reachable=0,returns=0;float longest=0;
|
||||||
|
for(float x=-10;x<=10;x+=1)for(float y=-10;y<=10;y+=1){P p{seed[0]+x,seed[1]+y,seed[2]};
|
||||||
|
if(mesh.Path(seed,p,path)){++reachable;std::vector<P> back;if(mesh.Path(path.back(),seed,back))++returns;}}
|
||||||
|
std::cout<<argv[a]<<" polygons="<<mesh.Polygons()<<" reachable="<<reachable<<" returns="<<returns<<"\n";
|
||||||
|
assert(reachable>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('<II',data,q+4)
|
||||||
|
if size!=4+n*12:start=q+4;continue
|
||||||
|
end=q+8+size
|
||||||
|
if data[end:end+4]!=b'TRIM':start=end;continue
|
||||||
|
ts,nt=struct.unpack_from('<II',data,end+4);offset=len(verts)//3
|
||||||
|
verts.extend(struct.unpack_from('<'+str(n*3)+'f',data,q+12))
|
||||||
|
for ids in struct.iter_unpack('<3I',data[end+12:end+12+nt*12]):
|
||||||
|
a,b,c=ids;tris.extend((offset+a,offset+c,offset+b) if m2 else (offset+a,offset+b,offset+c))
|
||||||
|
start=end+8+ts
|
||||||
|
f=p/(name+'.fixture');f.write_bytes(struct.pack('<II',len(verts),len(tris))+struct.pack('<'+str(len(verts))+'f',*verts)+struct.pack('<'+str(len(tris))+'i',*tris)+struct.pack('<3f',*seed));fixtures.append(str(f))
|
||||||
|
(p/'test.cpp').write_text(code)
|
||||||
|
subprocess.run(['c++','-std=c++17','-O1','-I'+str(root/'src/server/game/World'),'-I'+str(root/'dep/recastnavigation/Recast/Include'),'-I'+str(root/'dep/recastnavigation/Detour/Include'),str(p/'test.cpp'),str(root/'src/server/game/World/TransportDeckMesh.cpp'),str(Path(args.build)/'dep/recastnavigation/Recast/libRecast.a'),str(Path(args.build)/'dep/recastnavigation/Detour/libDetour.a'),'-o',str(p/'test')],check=True)
|
||||||
|
subprocess.run([str(p/'test'),*fixtures],check=True)
|
||||||
Reference in new issue
Block a user