#include "game/sim/movement.h" #include #include #include "check.h" using namespace sots::sim; static void test_vectors() { CHECK_NEAR(Distance({0, 0, 0}, {3, 4, 0}), 5.0, 1e-12); Vec3 p = AdvanceToward({0, 0, 0}, {10, 0, 0}, 4); CHECK_NEAR(p.x, 4.0, 1e-12); CHECK_NEAR(p.y, 0.0, 0.0); p = AdvanceToward({0, 0, 0}, {10, 0, 0}, 20); // snaps to the destination CHECK_NEAR(p.x, 10.0, 0.0); p = AdvanceToward({1, 2, 3}, {1, 2, 3}, 5); // already there CHECK_NEAR(p.z, 3.0, 0.0); p = AdvanceToward({0, 0, 0}, {3, 4, 0}, 2.5); // half way along a 3-4-5 CHECK_NEAR(p.x, 1.5, 1e-12); CHECK_NEAR(p.y, 2.0, 1e-12); } static void test_steps() { CHECK_NEAR(StraightStep(10, 0.5), 5.0, 0.0); CHECK_NEAR(StraightStep(10, kFullStep), 10.0, 0.0); CHECK_NEAR(StraightStep(0, kHalfStep), 0.0, 0.0); TuningTable t; t.STUTTER_SYSTEM_INFLUENCE_RADIUS = 100; t.STUTTER_MIN_SPEED = 0.2; t.STUTTER_MAX_SPEED = 1.0; CHECK_NEAR(NodeLineSpeed(10, 50, t), 6.0, 1e-12); // 10 x (0.8 x 0.5 + 0.2) CHECK_NEAR(NodeLineSpeed(10, 0, t), 2.0, 1e-12); // at a system: min profile CHECK_NEAR(NodeLineSpeed(10, 100, t), 10.0, 1e-12); // at the radius: max profile CHECK_NEAR(NodeLineSpeed(10, 200, t), 18.0, 1e-12); // no clamp (never reached in practice) TuningTable zero; CHECK_NEAR(NodeLineSpeed(10, 50, zero), 0.0, 0.0); // no tuning -> no speed } static void test_stutter_segments() { CHECK_NEAR(DistPointToSegment({5, 3, 0}, {0, 0, 0}, {10, 0, 0}), 3.0, 1e-12); CHECK_NEAR(DistPointToSegment({-5, 3, 0}, {0, 0, 0}, {10, 0, 0}), std::sqrt(34.0), 1e-12); CHECK_NEAR(DistPointToSegment({15, 0, 0}, {0, 0, 0}, {10, 0, 0}), 5.0, 1e-12); CHECK_NEAR(DistPointToSegment({3, 4, 0}, {1, 1, 1}, {1, 1, 1}), std::sqrt(4 + 9 + 1), 1e-12); TuningTable t; t.STUTTER_SYSTEM_INFLUENCE_RADIUS = 50; t.STUTTER_MIN_SPEED = 0.2; t.STUTTER_MAX_SPEED = 1.0; const Vec3 from{0, 0, 0}, to{100, 0, 0}; // One system 30 off the line: chord where (x-50)^2 + 900 <= 2500 -> x in [10, 90] std::vector s = BuildStutterSegments(from, to, {{50, 30, 0}}, t); CHECK_EQ(s.size(), std::size_t{1}); CHECK_NEAR(s[0].start, 10.0, 1e-4); CHECK_NEAR(s[0].end, 90.0, 1e-4); CHECK_EQ(s[0].systemIndex, 0); CHECK_NEAR(s[0].speedFactor, 0.8 * 30 / 50 + 0.2, 1e-6); // 0.68 for the whole chord // Out of reach, and exactly tangent (zero-length chord): no segments CHECK(BuildStutterSegments(from, to, {{50, 200, 0}}, t).empty()); CHECK(BuildStutterSegments(from, to, {{50, 50, 0}}, t).empty()); // B4: the overlap rule is NOT a midpoint. Second system near the end gives [35, 100]; // the earlier chord ends at 90, so both boundaries become 90 + 0.5 x (90 - 35) = 117.5 -- // pushed forward past both chords, which leaves the second segment inverted. s = BuildStutterSegments(from, to, {{50, 30, 0}, {85, 0, 0}}, t); CHECK_EQ(s.size(), std::size_t{2}); CHECK_NEAR(s[0].start, 10.0, 1e-4); CHECK_NEAR(s[0].end, 117.5, 1e-3); CHECK_EQ(s[0].systemIndex, 0); CHECK_NEAR(s[1].start, 117.5, 1e-3); CHECK_NEAR(s[1].end, 100.0, 1e-4); CHECK(s[1].start > s[1].end); // inverted, and left that way CHECK_EQ(s[1].systemIndex, 1); // Input order does not matter: segments come back sorted by start std::vector r = BuildStutterSegments(from, to, {{85, 0, 0}, {50, 30, 0}}, t); CHECK_EQ(r.size(), std::size_t{2}); CHECK_EQ(r[0].systemIndex, 1); CHECK_EQ(r[1].systemIndex, 0); // A chord swallowed by an earlier one is not dropped either: [0,100] and [20,80] // become [0, 140] and [140, 80]. s = BuildStutterSegments(from, to, {{50, 0, 0}, {50, 40, 0}}, t); CHECK_EQ(s.size(), std::size_t{2}); CHECK_NEAR(s[0].end, 140.0, 1e-3); CHECK_NEAR(s[1].start, 140.0, 1e-3); CHECK_NEAR(s[1].end, 80.0, 1e-4); TuningTable zero; CHECK(BuildStutterSegments(from, to, {{50, 0, 0}}, zero).empty()); // no radius: plain line // Walk the [10, 90] x0.68 profile at node speed 20. s = BuildStutterSegments(from, to, {{50, 30, 0}}, t); NodeLineStepResult n = NodeLineStep(20, 1.0, 100, s); CHECK_NEAR(n.along, 16.8, 1e-4); // half a turn to reach 10, then 0.5 x 13.6 CHECK(!n.arrived); n = NodeLineStep(20, 10.0, 100, s); // plenty of time: reaches the far end CHECK_NEAR(n.along, 100.0, 1e-6); CHECK(n.arrived); n = NodeLineStep(20, 1.0, 100, {}); // no spheres: plain speed the whole way CHECK_NEAR(n.along, 20.0, 1e-9); CHECK(!n.arrived); n = NodeLineStep(20, 0.0, 100, s); // no time at all CHECK_NEAR(n.along, 0.0, 0.0); CHECK(!n.arrived); n = NodeLineStep(0, 1.0, 100, s); // no speed: the fleet does not move CHECK_NEAR(n.along, 0.0, 0.0); } static void test_resolve() { // range = minShipRange + 0.05 (B4: the grace margin is ADDED, not subtracted) MoveStepResult r = ResolveMoveStep(5, 10, 20); CHECK_NEAR(r.moved, 5.0, 0.0); CHECK(!r.arrived); CHECK(!r.stranded); CHECK_NEAR(PassFraction(0, 20, 5), 1.0, 0.0); // a straight leg reports a full pass r = ResolveMoveStep(5, 3, 20); // 3.05 of range limits the step CHECK_NEAR(r.moved, 3.05, 1e-6); CHECK_NEAR(r.range, 3.05, 1e-6); r = ResolveMoveStep(5, 0, 20); // no fuel at all: the RANGE is zeroed CHECK_NEAR(r.moved, 0.0, 0.0); CHECK_NEAR(r.range, 0.0, 0.0); CHECK(r.stranded); CHECK(!r.arrived); // ... and the step survives, so it is still the divisor of the pass fraction CHECK_NEAR(BlockedPassFraction(r.moved, 5), 0.0, 0.0); r = ResolveMoveStep(5, 0, 0.01); // within the grace margin: it moves CHECK_NEAR(r.moved, 0.01, 1e-9); CHECK(!r.stranded); CHECK(r.arrived); r = ResolveMoveStep(50, 100, 20); // arrives with step to spare CHECK_NEAR(r.moved, 20.0, 0.0); CHECK(r.arrived); r = ResolveMoveStep(5, -1, 20); // a negative range moves it BACKWARDS CHECK(r.moved < 0); // there is no floor at zero r = ResolveMoveStep(0, 10, 20); // zero step CHECK_NEAR(r.moved, 0.0, 0.0); r = ResolveMoveStep(5, 10, 0); // already at the destination CHECK(r.arrived); // an empty fleet is unconstrained rather than stranded CHECK(FleetMinShipRange({}, 0.0) > 1e30); CHECK_NEAR(FleetMinShipRange({10, 3, 7}, 0.05), 3.05, 1e-6); CHECK_NEAR(FleetMinShipRange({10, 3, 7}, 0.0), 3.0, 1e-6); CHECK_NEAR(ConsumeShipRange(10, 3, false), 7.0, 0.0); CHECK_NEAR(ConsumeShipRange(2, 3, false), 0.0, 0.0); CHECK_NEAR(ConsumeShipRange(10, 3, true), 10.0, 0.0); // range-exempt: pays nothing CHECK_NEAR(RemainingPassTime(0.4, 1.0), 0.6, 1e-7); CHECK_NEAR(RemainingPassTime(0.4, 0.5), 0.3, 1e-7); CHECK_NEAR(RemainingPassTime(0.99995, 1.0), 0.0, 0.0); CHECK_NEAR(RemainingPassTime(1.0, 1.0), 0.0, 0.0); // the threshold is the widened float literal and the test is strict CHECK_NEAR(RemainingPassTime(kPassCompleteFraction, 1.0), 0.0, 0.0); CHECK(RemainingPassTime(0.99989, 1.0) > 0); // only a node waypoint (type 3) reports a partial fraction CHECK_NEAR(PassFraction(3, 4, 10), 0.4, 1e-7); CHECK_NEAR(PassFraction(3, 40, 10), 1.0, 0.0); // clamped CHECK_NEAR(PassFraction(2, 4, 10), 1.0, 0.0); // node LINE is type 2: full pass CHECK(IsNodeWaypoint(3)); CHECK(!IsNodeWaypoint(2)); CHECK(IsGateTransitWaypoint(4) && IsGateTransitWaypoint(5)); CHECK(!IsGateTransitWaypoint(3) && !IsGateTransitWaypoint(0) && !IsGateTransitWaypoint(9)); } static void test_multi_waypoint_turn() { // A fleet with speed 10 and plenty of range covers a 4-unit node leg, then continues // with the remaining 0.6 of the turn onto the next leg. double dt = kFullStep; MoveStepResult a = ResolveMoveStep(StraightStep(10, dt), 100, 4); CHECK(a.arrived); dt = RemainingPassTime(PassFraction(3, 4, StraightStep(10, dt)), dt); CHECK_NEAR(dt, 0.6, 1e-6); MoveStepResult b = ResolveMoveStep(StraightStep(10, dt), 96, 20); CHECK_NEAR(b.moved, 6.0, 1e-6); CHECK(!b.arrived); CHECK_NEAR(RemainingPassTime(PassFraction(0, 20, StraightStep(10, dt)), dt), 0.0, 0.0); } static void test_jump() { { // 0.7 x 1.0 > 0.5: the jump MISSES and scatters by 0.7 in a random direction, // which costs a second draw simtest::ScriptedRng rng({0.7f}, {0x1234u}); JumpResult j = RollProbabilisticJump(1.0, 0.5, rng); CHECK(!j.arrived); CHECK_NEAR(j.scatter, 0.7, 1e-7); CHECK_EQ(j.draws, 2); CHECK_EQ(rng.floatDraws(), std::size_t{1}); CHECK_EQ(rng.intDraws(), std::size_t{1}); } { simtest::ScriptedRng rng({0.3f}); JumpResult j = RollProbabilisticJump(1.0, 0.5, rng); CHECK(j.arrived); CHECK_NEAR(j.scatter, 0.0, 0.0); CHECK_EQ(j.draws, 1); // an arrival costs exactly one word CHECK_EQ(rng.intDraws(), std::size_t{0}); } { // efficiency scales the roll: 0.9 x 0.5 = 0.45 <= 0.5 arrives simtest::ScriptedRng rng({0.9f}); JumpResult j = RollProbabilisticJump(0.5, 0.5, rng); CHECK(j.arrived); } { // a product exactly equal to the threshold is not "greater": it arrives simtest::ScriptedRng rng({0.5f}); JumpResult j = RollProbabilisticJump(1.0, 0.5, rng); CHECK(j.arrived); } { // determinism: the same script gives the same outcome auto run = [] { simtest::ScriptedRng rng({0.6f, 0.2f, 0.95f}, {1u, 2u, 3u}); std::vector out; for (int i = 0; i < 3; ++i) out.push_back(RollProbabilisticJump(1.0, 0.5, rng).scatter); return out; }; CHECK(run() == run()); } } static void test_pass_schedule() { // fleet 1 chases fleet 2 (no relation); fleet 3 follows allied fleet 4; fleet 5 idles. std::vector f = { {1, 2, 0, false}, {2, 0, 0, false}, {3, 4, 1, false}, {4, 0, 0, false}, {5, 0, 0, false}, }; std::vector p = PlanFleetMovement(f); // pass 1 prey, pass 2 pursuer, pass 3 prey again, pass 4 the rest, pass 5 followers CHECK_EQ(p.size(), std::size_t{7}); CHECK_EQ(p[0].fleetId, 2); CHECK_EQ(p[0].pass, 1); CHECK_NEAR(p[0].dt, 0.5, 0.0); CHECK_EQ(p[1].fleetId, 1); CHECK_EQ(p[1].pass, 2); CHECK_NEAR(p[1].dt, 0.5, 0.0); CHECK_EQ(p[2].fleetId, 2); CHECK_EQ(p[2].pass, 3); CHECK_NEAR(p[2].dt, 0.5, 0.0); CHECK_EQ(p[3].fleetId, 1); CHECK_EQ(p[3].pass, 4); CHECK_NEAR(p[3].dt, 0.5, 0.0); CHECK_EQ(p[4].fleetId, 4); CHECK_EQ(p[4].pass, 4); CHECK_NEAR(p[4].dt, 1.0, 0.0); CHECK_EQ(p[5].fleetId, 5); CHECK_EQ(p[5].pass, 4); CHECK_NEAR(p[5].dt, 1.0, 0.0); CHECK_EQ(p[6].fleetId, 3); CHECK_EQ(p[6].pass, 5); CHECK_NEAR(p[6].dt, 1.0, 0.0); // when the pursuer catches its prey, both retire after pass 2 f[0].caught = true; p = PlanFleetMovement(f); CHECK_EQ(p.size(), std::size_t{5}); CHECK_EQ(p[0].fleetId, 2); CHECK_EQ(p[0].pass, 1); CHECK_EQ(p[1].fleetId, 1); CHECK_EQ(p[1].pass, 2); CHECK_EQ(p[2].fleetId, 4); CHECK_EQ(p[2].pass, 4); CHECK_EQ(p[3].fleetId, 5); CHECK_EQ(p[3].pass, 4); CHECK_EQ(p[4].fleetId, 3); CHECK_EQ(p[4].pass, 5); } // --------------------------------------------------------------------------------------- // The straight-run normalise, pinned to the bit. // // Every one of these expectations is a float32 BIT PATTERN, because the whole point of the // module is which precision each intermediate is held in. A `CHECK_NEAR` here would pass // against the wrong arithmetic -- the error this pins down is one ULP. // // Regression: the behavioural compare caught 8 of 45 live `MoveFleet` calls diverging by // exactly one ULP on a position component, and the cause was this file computing the // direction in double where the original narrows to float32 four separate times. // --------------------------------------------------------------------------------------- static std::uint32_t f32bits(double v) { const float f = static_cast(v); std::uint32_t u = 0; std::memcpy(&u, &f, sizeof u); return u; } static void check_pos(const Vec3& got, std::uint32_t bx, std::uint32_t by, std::uint32_t bz, int line) { ::simtest::report(f32bits(got.x) == bx, "pos.x bits", __FILE__, line, "got 0x" + std::to_string(f32bits(got.x)) + ", expected 0x" + std::to_string(bx)); ::simtest::report(f32bits(got.y) == by, "pos.y bits", __FILE__, line, "got 0x" + std::to_string(f32bits(got.y)) + ", expected 0x" + std::to_string(by)); ::simtest::report(f32bits(got.z) == bz, "pos.z bits", __FILE__, line, "got 0x" + std::to_string(f32bits(got.z)) + ", expected 0x" + std::to_string(bz)); } static void test_normalise_precision() { // 1. The sum of squares is narrowed to float32 BEFORE the square root. 1e-4 squares to // 1e-8, which is representable, but the sum 1 + 1e-8 is not: it rounds to 1.0f, so // the length comes out exactly 1 rather than 1.000000005. { const NormalizeResult n = NormalizeVec3({1.0, 1e-4, 0.0}); CHECK_EQ(f32bits(n.length), f32bits(1.0f)); CHECK(n.length == 1.0); } // 2. The square root is narrowed to float32 too. sqrt(2) as a double is // 1.4142135623730951; as a float32 it is 1.41421356201171875. { const NormalizeResult n = NormalizeVec3({1.0, 1.0, 0.0}); CHECK_EQ(f32bits(n.length), std::uint32_t{0x3FB504F3u}); CHECK(n.length == static_cast(1.41421353816986083984375f)); } // 3. The direction is a RECIPROCAL multiply through a float32 slot, not three divides. // For this vector float32(1/len) x c and c / len land on different float32s. { const NormalizeResult n = NormalizeVec3({-12.7324999f, 11.0829000f, -16.4794998f}); // float32(1 / float32(sqrt(float32(sumsq)))) const double inv = static_cast(1.0f / 23.590700149536133f); CHECK_EQ(f32bits(n.dir.x), f32bits(static_cast(-12.7324999f) * inv)); CHECK_EQ(f32bits(n.length), f32bits(23.590700149536133f)); } // 4. Below the epsilon the direction is zeroed and the length reported as 0. { const NormalizeResult n = NormalizeVec3({1e-9, 0, 0}); CHECK(n.length == 0.0); CHECK(n.dir.x == 0.0 && n.dir.y == 0.0 && n.dir.z == 0.0); } // 5. The leg delta is stored back to float32 before the normalise. Here the exact // difference and its float32 rounding are different numbers, and the original uses // the rounded one. { const Vec3 pos{1.8504999876022339, -2.4797000885009766, 11.430100440979004}; const Vec3 dest{-10.881999969482422, 8.60319995880127, -5.0493998527526855}; CHECK_EQ(f32bits(StraightLegDistance(pos, dest)), std::uint32_t{0x41BCB9C1u}); } } static void test_advance_bit_exact() { // Four independent straight-run legs at step 2.0. Each expectation is the float32 the // original produces; the previous double-precision direction got at least one of the // three components one ULP wrong on every one of them. struct Case { Vec3 pos, dest; std::uint32_t bx, by, bz; }; const Case cases[] = { {{1.8504999876022339, -2.4797000885009766, 11.430100440979004}, {-10.881999969482422, 8.60319995880127, -5.0493998527526855}, 0x3F45637Au, 0xBFC52208u, 0x41208718u}, {{3.333899974822998, -3.0625, 1.145900011062622}, {-10.4931001663208, -10.569600105285645, -7.057000160217285}, 0x3FE33EC5u, 0xC07A27DCu, 0x3E62996Cu}, {{4.329599857330322, -1.7378000020980835, -4.4604997634887695}, {2.053499937057495, -1.1236000061035156, -4.805600166320801}, 0x401AD160u, 0xBF9C7244u, 0xC0980177u}, {{-11.458499908447266, -0.9193000197410583, -7.966800212860107}, {-9.18970012664795, -10.585100173950195, 6.437600135803223}, 0xC1332FA2u, 0xC0018E2Bu, 0xC0CA3E13u}, }; for (const Case& c : cases) { check_pos(AdvanceAlongDirection(c.pos, c.dest, 2.0), c.bx, c.by, c.bz, __LINE__); // The two-step form the hook uses must agree with the one-shot form exactly. const NormalizeResult n = StraightLeg(c.pos, c.dest); check_pos(AdvanceAlongUnitDirection(c.pos, n.dir, 2.0), c.bx, c.by, c.bz, __LINE__); } // A leg shorter than the step arrives, and an arrival copies the destination words // verbatim rather than stepping onto them. { const Vec3 pos{4.329599857330322, -1.7378000020980835, -4.4604997634887695}; const Vec3 dest{3.0, -1.5, -4.5999999046325684}; const double dist = StraightLegDistance(pos, dest); CHECK(dist < 2.0); const MoveStepResult m = ResolveMoveStep(2.0, 100.0, dist); CHECK(m.arrived); // `arrived` is an EXACT float comparison, so the distance the step is capped at has // to be the same float32 the normalise produced -- a double-precision distance here // would make `move == distance` a coincidence rather than an identity. CHECK(m.moved == dist); } } static void test_gate_traffic() { std::vector f = { {0, 4, 10}, // gate transit {0, 5, 5}, // probabilistic jump also counts {0, 2, 100}, // node line does not {1, 4, -3}, // the traffic word is SIGNED {2, -1, 99}, // no waypoints at all }; std::vector g = GateTrafficTotals(f, 3); CHECK_EQ(g.size(), std::size_t{3}); CHECK_EQ(g[0], 15); CHECK_EQ(g[1], -3); CHECK_EQ(g[2], 0); // an owner index past the player count is dropped rather than corrupting memory CHECK_EQ(GateTrafficTotals({{9, 4, 10}}, 3)[0], 0); } int main() { test_vectors(); test_steps(); test_stutter_segments(); test_resolve(); test_multi_waypoint_turn(); test_jump(); test_pass_schedule(); test_gate_traffic(); test_normalise_precision(); test_advance_bit_exact(); return simtest::finish("test_movement"); }