Files
WoWee/tests/test_transport_components.cpp
Kelsi 462defc76d refactor: consolidate transport hull orientation into single bow-offset rule
Funnel every ship-facing path through TransportManager::transportModelBowOffset
(facing = direction of travel + fixed per-model bow offset), replacing the
scattered per-entry 180-degree hull corrections. berthRunsParallel() names the
side-on dock routes, and buildTaxiSegmentSpline is extracted static/testable so
the cyclic-wrap behavior is unit-covered without a DBC.
2026-07-20 19:36:21 -07:00

413 lines
16 KiB
C++

// tests/test_transport_components.cpp
// Unit tests for TransportClockSync and TransportAnimator (Phase 3 extractions).
#include <catch_amalgamated.hpp>
#include "game/transport_clock_sync.hpp"
#include "game/transport_animator.hpp"
#include "game/transport_manager.hpp"
#include "game/transport_path_repository.hpp"
#include "math/spline.hpp"
#include <glm/gtc/constants.hpp>
#include <cmath>
using namespace wowee::game;
using namespace wowee::math;
// ── Helper: build a simple circular path ──────────────────────────
static PathEntry makeCirclePath() {
// Circle-ish path with 4 points, 4000ms duration
std::vector<SplineKey> keys = {
{0, glm::vec3(0.0f, 0.0f, 0.0f)},
{1000, glm::vec3(10.0f, 0.0f, 0.0f)},
{2000, glm::vec3(10.0f, 10.0f, 0.0f)},
{3000, glm::vec3(0.0f, 10.0f, 0.0f)},
{4000, glm::vec3(0.0f, 0.0f, 0.0f)},
};
CatmullRomSpline spline(std::move(keys), /*timeClosed=*/true);
return PathEntry(std::move(spline), /*pathId=*/100, /*zOnly=*/false, /*fromDBC=*/true, /*worldCoords=*/false);
}
// ── Helper: create a fresh ActiveTransport ────────────────────────
static ActiveTransport makeTransport(uint64_t guid = 1, uint32_t pathId = 100) {
ActiveTransport t{};
t.guid = guid;
t.pathId = pathId;
t.basePosition = glm::vec3(100.0f, 200.0f, 0.0f);
t.position = glm::vec3(100.0f, 200.0f, 0.0f);
t.rotation = glm::quat(1.0f, 0.0f, 0.0f, 0.0f);
t.playerOnBoard = false;
t.playerLocalOffset = glm::vec3(0);
t.hasDeckBounds = false;
t.localClockMs = 0;
t.hasServerClock = false;
t.serverClockOffsetMs = 0;
t.useClientAnimation = true;
t.clientAnimationReverse = false;
t.serverYaw = 0.0f;
t.hasServerYaw = false;
t.dockYaw = 0.0f;
t.hasDockYaw = false;
t.serverYawFlipped180 = false;
t.serverYawAlignmentScore = 0;
t.lastServerUpdate = 0.0;
t.serverUpdateCount = 0;
t.serverLinearVelocity = glm::vec3(0);
t.serverAngularVelocity = 0.0f;
t.hasServerVelocity = false;
t.allowBootstrapVelocity = true;
t.isM2 = false;
return t;
}
// ══════════════════════════════════════════════════════════════════
// TransportClockSync tests
// ══════════════════════════════════════════════════════════════════
TEST_CASE("ClockSync: client animation advances localClockMs", "[transport_clock_sync]") {
TransportClockSync sync;
auto path = makeCirclePath();
auto t = makeTransport();
t.useClientAnimation = true;
t.hasServerClock = false;
uint32_t pathTimeMs = 0;
bool result = sync.computePathTime(t, path.spline, 1.0, 0.016f, pathTimeMs);
REQUIRE(result);
REQUIRE(t.localClockMs > 0); // Should have advanced
REQUIRE(pathTimeMs == t.localClockMs % path.spline.durationMs());
}
TEST_CASE("ClockSync: server clock mode wraps correctly", "[transport_clock_sync]") {
TransportClockSync sync;
auto path = makeCirclePath();
auto t = makeTransport();
t.hasServerClock = true;
t.serverClockOffsetMs = 500; // Server is 500ms ahead
uint32_t pathTimeMs = 0;
double elapsedTime = 3.7; // 3700ms local → 4200ms server → 200ms wrapped (dur=4000)
bool result = sync.computePathTime(t, path.spline, elapsedTime, 0.016f, pathTimeMs);
REQUIRE(result);
REQUIRE(pathTimeMs == 200);
}
TEST_CASE("ClockSync: strict server mode returns false", "[transport_clock_sync]") {
TransportClockSync sync;
auto path = makeCirclePath();
auto t = makeTransport();
t.useClientAnimation = false;
t.hasServerClock = false;
uint32_t pathTimeMs = 0;
bool result = sync.computePathTime(t, path.spline, 1.0, 0.016f, pathTimeMs);
REQUIRE_FALSE(result);
}
TEST_CASE("ClockSync: reverse client animation decrements", "[transport_clock_sync]") {
TransportClockSync sync;
auto path = makeCirclePath();
auto t = makeTransport();
t.useClientAnimation = true;
t.clientAnimationReverse = true;
t.localClockMs = 2000;
uint32_t pathTimeMs = 0;
bool result = sync.computePathTime(t, path.spline, 1.0, 0.5f, pathTimeMs);
REQUIRE(result);
// localClockMs should have decreased by ~500ms
REQUIRE(t.localClockMs < 2000);
}
TEST_CASE("ClockSync: processServerUpdate sets yaw and rotation", "[transport_clock_sync]") {
TransportClockSync sync;
auto path = makeCirclePath();
auto t = makeTransport();
glm::vec3 pos(105.0f, 205.0f, 1.0f);
float yaw = 1.5f;
sync.processServerUpdate(t, &path, pos, yaw, 10.0);
REQUIRE(t.serverUpdateCount == 1);
REQUIRE(t.hasServerYaw);
REQUIRE(t.serverYaw == Catch::Approx(1.5f));
REQUIRE(t.position == pos);
}
TEST_CASE("ClockSync: yaw flip detection after repeated misaligned updates", "[transport_clock_sync]") {
TransportClockSync sync;
auto path = makeCirclePath();
auto t = makeTransport();
t.useClientAnimation = false;
// Simulate transport moving east (+X) but reporting yaw pointing west (pi)
float westYaw = glm::pi<float>();
glm::vec3 pos(100.0f, 200.0f, 0.0f);
sync.processServerUpdate(t, &path, pos, westYaw, 1.0);
// Send several updates moving east with west-facing yaw
for (int i = 1; i <= 8; i++) {
pos.x += 5.0f;
sync.processServerUpdate(t, &path, pos, westYaw, 1.0 + i * 0.5);
}
// After enough misaligned updates, should have flipped
REQUIRE(t.serverYawFlipped180);
}
// ══════════════════════════════════════════════════════════════════
// TransportAnimator tests
// ══════════════════════════════════════════════════════════════════
TEST_CASE("Animator: evaluateAndApply updates position from spline", "[transport_animator]") {
TransportAnimator animator;
auto path = makeCirclePath();
auto t = makeTransport();
t.hasServerYaw = false;
animator.evaluateAndApply(t, path, 0);
// At t=0, path offset is (0,0,0), so pos = base + (0,0,0) = (100,200,0)
REQUIRE(t.position.x == Catch::Approx(100.0f));
REQUIRE(t.position.y == Catch::Approx(200.0f));
animator.evaluateAndApply(t, path, 1000);
// At t=1000, path offset is (10,0,0), so pos = base + (10,0,0) = (110,200,0)
REQUIRE(t.position.x == Catch::Approx(110.0f));
}
TEST_CASE("Animator: uses server yaw when available", "[transport_animator]") {
TransportAnimator animator;
auto path = makeCirclePath();
auto t = makeTransport();
t.hasServerYaw = true;
t.serverYaw = 1.0f;
t.serverYawFlipped180 = false;
animator.evaluateAndApply(t, path, 500);
// Rotation should be based on serverYaw=1.0, not spline tangent
float expectedYaw = 1.0f;
glm::quat expected = glm::angleAxis(expectedYaw, glm::vec3(0.0f, 0.0f, 1.0f));
REQUIRE(t.rotation.w == Catch::Approx(expected.w).margin(0.01f));
REQUIRE(t.rotation.z == Catch::Approx(expected.z).margin(0.01f));
}
TEST_CASE("Animator: world-coordinate WMO faces along the server-space route", "[transport_animator][transport]") {
TransportAnimator animator;
CatmullRomSpline spline({
{0, glm::vec3(0.0f, 0.0f, 0.0f)},
{1000, glm::vec3(10.0f, 0.0f, 0.0f)},
{2000, glm::vec3(20.0f, 0.0f, 0.0f)},
});
PathEntry path(std::move(spline), 300, false, true, true);
auto t = makeTransport();
t.basePosition = glm::vec3(0.0f);
t.isM2 = false;
animator.evaluateAndApply(t, path, 500);
// canonical +X is server +Y; model-local +X is the ship's forward axis.
const glm::quat expected = glm::angleAxis(
glm::half_pi<float>(), glm::vec3(0.0f, 0.0f, 1.0f));
REQUIRE(t.rotation.w == Catch::Approx(expected.w).margin(0.01f));
REQUIRE(t.rotation.z == Catch::Approx(expected.z).margin(0.01f));
}
TEST_CASE("Animator: exact ship dwell restores authored dock facing", "[transport_animator][transport]") {
TransportAnimator animator;
CatmullRomSpline spline({
{0, glm::vec3(-10.0f, 0.0f, 0.0f)},
{1000, glm::vec3(0.0f, 0.0f, 0.0f)},
{61000, glm::vec3(0.0f, 0.0f, 0.0f)},
{62000, glm::vec3(10.0f, 0.0f, 0.0f)},
});
PathEntry path(std::move(spline), 301, false, true, true);
auto t = makeTransport();
t.basePosition = glm::vec3(0.0f);
t.rotation = glm::angleAxis(0.75f, glm::vec3(0.0f, 0.0f, 1.0f));
t.dockYaw = -0.4f;
t.hasDockYaw = true;
animator.evaluateAndApply(t, path, 30000);
REQUIRE(t.position == glm::vec3(0.0f));
const glm::quat expected = glm::angleAxis(
t.dockYaw, glm::vec3(0.0f, 0.0f, 1.0f));
REQUIRE(t.rotation.w == Catch::Approx(expected.w));
REQUIRE(t.rotation.z == Catch::Approx(expected.z));
}
TEST_CASE("Animator: Bravery holds side-on at its dock dwell", "[transport_animator][transport]") {
TransportAnimator animator;
CatmullRomSpline spline({
{0, glm::vec3(0.0f, 0.0f, 0.0f)},
{10000, glm::vec3(100.0f, 0.0f, 0.0f)},
{70000, glm::vec3(100.0f, 0.0f, 0.0f)},
{80000, glm::vec3(200.0f, 0.0f, 0.0f)},
});
PathEntry path(std::move(spline), 176310u, false, true, true);
auto t = makeTransport(1, 176310u);
t.entry = 176310u;
t.displayId = 3015u; // Bravery-class hull: authored bow-forward, no +PI correction
t.basePosition = glm::vec3(0.0f);
t.isM2 = false;
// Deliberately unrelated live server yaw. Docking must not depend on the
// orientation snapshot received when this transport happened to load.
t.dockYaw = glm::pi<float>();
t.hasDockYaw = true;
animator.evaluateAndApply(t, path, 30000);
// Keep the authored pier node so the gangway reaches the dock.
REQUIRE(t.position.x == Catch::Approx(100.0f));
REQUIRE(t.position.y == Catch::Approx(0.0f));
// Model 3015 is bow-forward, so route yaw is PI/2 (no hull correction). Bravery's
// TaxiPath runs parallel to the pier, so retaining that heading stops it broadside.
const glm::quat expectedDock = glm::angleAxis(
glm::half_pi<float>(),
glm::vec3(0.0f, 0.0f, 1.0f));
REQUIRE(t.rotation.w == Catch::Approx(expectedDock.w).margin(0.001f));
REQUIRE(t.rotation.z == Catch::Approx(expectedDock.z).margin(0.001f));
t.dockYaw = -0.35f;
t.hasDockYaw = false;
animator.evaluateAndApply(t, path, 30000);
REQUIRE(t.rotation.w == Catch::Approx(expectedDock.w).margin(0.001f));
REQUIRE(t.rotation.z == Catch::Approx(expectedDock.z).margin(0.001f));
}
TEST_CASE("Animator: affected ship hulls face their direction of travel",
"[transport_animator][transport]") {
// The hull bow correction is keyed by MODEL (displayId), not entry. Models 7087 and
// 7446 are bow-reversed (+PI); model 3015 (Bravery) is bow-forward (no correction).
struct ShipCase { uint32_t entry; uint32_t displayId; bool bowReversed; };
for (const ShipCase& sc : {
ShipCase{176310u, 3015u, false}, // The Bravery
ShipCase{176244u, 7087u, true}, // The Moonspray
ShipCase{181646u, 7087u, true}, // Elune's Blessing
ShipCase{190536u, 7446u, true}, // Kraken-class icebreaker
}) {
TransportAnimator animator;
CatmullRomSpline spline({
{0, glm::vec3(0.0f, 0.0f, 0.0f)},
{1000, glm::vec3(10.0f, 0.0f, 0.0f)},
{2000, glm::vec3(20.0f, 0.0f, 0.0f)},
});
PathEntry path(std::move(spline), sc.entry, false, false, true);
auto t = makeTransport(1, sc.entry);
t.entry = sc.entry;
t.displayId = sc.displayId;
t.basePosition = glm::vec3(0.0f);
t.isM2 = false;
animator.evaluateAndApply(t, path, 500);
const float expectedYaw = glm::half_pi<float>() +
(sc.bowReversed ? glm::pi<float>() : 0.0f);
const glm::quat expected = glm::angleAxis(
expectedYaw, glm::vec3(0.0f, 0.0f, 1.0f));
INFO("entry=" << sc.entry << " displayId=" << sc.displayId);
REQUIRE(t.rotation.w == Catch::Approx(expected.w).margin(0.001f));
REQUIRE(t.rotation.z == Catch::Approx(expected.z).margin(0.001f));
}
}
TEST_CASE("Animator: Kraken retains arrival heading throughout dock dwell",
"[transport_animator][transport]") {
TransportAnimator animator;
CatmullRomSpline spline({
{0, glm::vec3(0.0f, 0.0f, 0.0f)},
{10000, glm::vec3(100.0f, 0.0f, 0.0f)},
{70000, glm::vec3(100.0f, 0.0f, 0.0f)},
{80000, glm::vec3(200.0f, 0.0f, 0.0f)},
});
PathEntry path(std::move(spline), 190536u, false, true, true);
auto t = makeTransport(1, 190536u);
t.entry = 190536u;
t.displayId = 7446u; // Icebreaker hull: excluded from spawn-yaw restore at dwell
t.basePosition = glm::vec3(0.0f);
t.isM2 = false;
t.dockYaw = glm::half_pi<float>();
t.hasDockYaw = true;
animator.evaluateAndApply(t, path, 5000);
const glm::quat arrivalRotation = t.rotation;
animator.evaluateAndApply(t, path, 30000);
REQUIRE(t.rotation.w == Catch::Approx(arrivalRotation.w).margin(0.001f));
REQUIRE(t.rotation.z == Catch::Approx(arrivalRotation.z).margin(0.001f));
}
TEST_CASE("Animator: Moonspray holds side-on at its dock dwell", "[transport_animator][transport]") {
TransportAnimator animator;
CatmullRomSpline spline({
{0, glm::vec3(0.0f, 0.0f, 0.0f)},
{10000, glm::vec3(100.0f, 0.0f, 0.0f)},
{70000, glm::vec3(100.0f, 0.0f, 0.0f)},
{80000, glm::vec3(200.0f, 0.0f, 0.0f)},
});
PathEntry path(std::move(spline), 176244u, false, true, true);
auto t = makeTransport(1, 176244u);
t.entry = 176244u;
t.displayId = 7087u;
t.basePosition = glm::vec3(0.0f);
t.isM2 = false;
// Deliberately wrong bow-first server yaw: Moonspray keeps the berth-parallel route yaw.
t.dockYaw = glm::pi<float>() - 0.1f;
t.hasDockYaw = true;
animator.evaluateAndApply(t, path, 30000);
REQUIRE(t.position.x == Catch::Approx(100.0f));
REQUIRE(t.position.y == Catch::Approx(0.0f));
const glm::quat expectedDock = glm::angleAxis(
glm::half_pi<float>() + glm::pi<float>(),
glm::vec3(0.0f, 0.0f, 1.0f));
REQUIRE(t.rotation.w == Catch::Approx(expectedDock.w).margin(0.001f));
REQUIRE(t.rotation.z == Catch::Approx(expectedDock.z).margin(0.001f));
}
TEST_CASE("Animator: Elune's Blessing holds side-on at its dock dwell", "[transport_animator][transport]") {
TransportAnimator animator;
CatmullRomSpline spline({
{0, glm::vec3(0.0f, 0.0f, 0.0f)},
{10000, glm::vec3(100.0f, 0.0f, 0.0f)},
{70000, glm::vec3(100.0f, 0.0f, 0.0f)},
{80000, glm::vec3(200.0f, 0.0f, 0.0f)},
});
PathEntry path(std::move(spline), 181646u, false, true, true);
auto t = makeTransport(1, 181646u);
t.entry = 181646u; // Elune's Blessing
t.displayId = 7087u; // Same model family as Moonspray
t.basePosition = glm::vec3(0.0f);
t.isM2 = false;
t.dockYaw = glm::pi<float>();
t.hasDockYaw = true;
animator.evaluateAndApply(t, path, 30000);
const glm::quat expectedDock = glm::angleAxis(
glm::half_pi<float>() + glm::pi<float>(),
glm::vec3(0.0f, 0.0f, 1.0f));
REQUIRE(t.rotation.w == Catch::Approx(expectedDock.w).margin(0.001f));
REQUIRE(t.rotation.z == Catch::Approx(expectedDock.z).margin(0.001f));
}
TEST_CASE("Animator: Z clamping on non-world-coord client anim", "[transport_animator]") {
TransportAnimator animator;
// Build a path with a deep negative Z offset
std::vector<SplineKey> keys = {
{0, glm::vec3(0.0f, 0.0f, 0.0f)},
{1000, glm::vec3(5.0f, 0.0f, -50.0f)}, // Deep negative Z
{2000, glm::vec3(10.0f, 0.0f, 0.0f)},
};
CatmullRomSpline spline(std::move(keys), false);
PathEntry path(std::move(spline), 200, false, true, false);
auto t = makeTransport();
t.useClientAnimation = true;
t.serverUpdateCount = 0; // <= 1, so Z clamping applies
animator.evaluateAndApply(t, path, 1000);
// Z should be clamped to >= -2.0 (kMinFallbackZOffset)
REQUIRE(t.position.z >= (t.basePosition.z - 2.0f));
}