The strategic layer does not search for a route: the player or the AI picks the destinations and the engine classifies each consecutive pair, deciding the waypoint kind and whether the order is legal. This models that classifier as pure functions. The waypoint kind of any leg that is neither a gate transit nor a node route is a pure function of the owning species -- which is the whole answer to why kind 2 has never been observed. Kind 2 is the Liir drive; the two node-drive races are Human and Zuul, both of which map to kind 3, and every observation so far was taken on one of those two. Also modelled: the three refusal bits versus the nine advisory ones, the gate transit that waives the grounded-fleet refusal, the projection radius that splits gate-to-gate from gate-to-gateless, the single-hop node line lookup and bore, and the fuel check whose range is squared at full precision while the distance is narrowed -- the one floating-point asymmetry here that flips a decision. The leading-destination drop is reproduced with its original off-by-one behind an explicit flag rather than silently fixed. 120 hand-computed checks. Host ctest 43/43; clean-room check OK.
616 lines
27 KiB
C++
616 lines
27 KiB
C++
// Hand-computed cases for the fleet path-planning rules.
|
|
//
|
|
// Every expected value was worked out from the rule, not by running the code. The cases most
|
|
// worth keeping are:
|
|
// * the species drive table, because it is the entire answer to "why is kind 2 never seen";
|
|
// * the fuel check's asymmetric squaring, which is the one floating-point detail in this
|
|
// subsystem that flips a decision;
|
|
// * the gate transit clearing the grounded flag, which is a rule two separate subsystems
|
|
// reach independently;
|
|
// * the leading-destination drop shifting the output array, which is a defect in the
|
|
// original that this module reproduces on purpose.
|
|
#include "game/nav/pathplan.h"
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <limits>
|
|
|
|
using namespace sots::nav;
|
|
|
|
namespace {
|
|
|
|
int g_checks = 0;
|
|
int g_fails = 0;
|
|
|
|
void check(bool ok, const char* what) {
|
|
++g_checks;
|
|
if (!ok) {
|
|
++g_fails;
|
|
std::fprintf(stderr, "FAIL: %s\n", what);
|
|
}
|
|
}
|
|
|
|
MapObject System(int id, int idx, double x = 0, double y = 0, double z = 0) {
|
|
MapObject o;
|
|
o.kind = ObjectKind::System;
|
|
o.id = id;
|
|
o.systemIndex = idx;
|
|
o.pos = {x, y, z};
|
|
o.ownedByUs = true;
|
|
o.ownerIsFriendly = true;
|
|
return o;
|
|
}
|
|
|
|
MapObject Point(int id, double x = 0, double y = 0, double z = 0) {
|
|
MapObject o;
|
|
o.kind = ObjectKind::Point;
|
|
o.id = id;
|
|
o.pos = {x, y, z};
|
|
return o;
|
|
}
|
|
|
|
MapObject Fleet(int id) {
|
|
MapObject o;
|
|
o.kind = ObjectKind::Fleet;
|
|
o.id = id;
|
|
return o;
|
|
}
|
|
|
|
FleetState NodeFleet() {
|
|
FleetState f;
|
|
f.ownerSpecies = Species::Human;
|
|
f.rangeRemaining = 1000.0;
|
|
f.rangeFull = 1000.0;
|
|
f.tankCapacity = 1000.0;
|
|
return f;
|
|
}
|
|
|
|
// A graph with a single line between two named system indices.
|
|
struct OneLineGraph : NodeGraph {
|
|
int a, b, index;
|
|
bool boreSucceeds = false;
|
|
mutable int boreCalls = 0;
|
|
OneLineGraph(int a_, int b_, int i) : a(a_), b(b_), index(i) {}
|
|
int FindLine(int x, int y) const override {
|
|
if ((x == a && y == b) || (x == b && y == a)) return index;
|
|
return -1;
|
|
}
|
|
bool BoreLine(int, int) const override {
|
|
++boreCalls;
|
|
return boreSucceeds;
|
|
}
|
|
};
|
|
|
|
// -----------------------------------------------------------------------------------------
|
|
// The drive table -- the type-2 question
|
|
// -----------------------------------------------------------------------------------------
|
|
|
|
void TestDriveTable() {
|
|
check(DriveOf(Species::Human) == WaypointKind::NodeRoute, "Human flies the node drive");
|
|
check(DriveOf(Species::Hiver) == WaypointKind::None, "Hiver has no straight-line kind");
|
|
check(DriveOf(Species::Tarkas) == WaypointKind::StraightA, "Tarkas kind 1");
|
|
check(DriveOf(Species::Liir) == WaypointKind::Stutter, "Liir kind 2");
|
|
check(DriveOf(Species::NPC) == WaypointKind::None, "NPC kind 0");
|
|
check(DriveOf(Species::Zuul) == WaypointKind::NodeRoute, "Zuul flies the node drive");
|
|
check(DriveOf(Species::Morrigi) == WaypointKind::StraightB, "Morrigi kind 6");
|
|
check(DriveOf(static_cast<Species>(9)) == WaypointKind::None, "out-of-range species is 0");
|
|
|
|
// The reachability argument, stated as a test: no node-drive race can produce kind 2 and
|
|
// the kind-2 race cannot produce kind 3, for ANY leg, because the drive is a pure function
|
|
// of species and the node branch is entered only for the node drive.
|
|
check(DriveOf(Species::Human) != WaypointKind::Stutter &&
|
|
DriveOf(Species::Zuul) != WaypointKind::Stutter,
|
|
"neither node race can produce kind 2");
|
|
check(DriveOf(Species::Liir) != WaypointKind::NodeRoute, "the kind-2 race is not a node race");
|
|
|
|
check(IsNodeKind(3) && !IsNodeKind(2) && !IsNodeKind(4), "only kind 3 is a node waypoint");
|
|
check(IsGateTransitKind(4) && IsGateTransitKind(5) && !IsGateTransitKind(3) &&
|
|
!IsGateTransitKind(2) && !IsGateTransitKind(6),
|
|
"only kinds 4 and 5 are gate transits");
|
|
check(!IsNodeKind(-1) && !IsGateTransitKind(99), "out-of-range kinds are neither");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------------------
|
|
// A non-node species short-circuits
|
|
// -----------------------------------------------------------------------------------------
|
|
|
|
void TestNonNodeSpeciesShortCircuit() {
|
|
FleetState f = NodeFleet();
|
|
f.ownerSpecies = Species::Liir;
|
|
f.rangeRemaining = 0.0; // no fuel at all
|
|
f.tankCapacity = 0.0;
|
|
const MapObject a = System(16, 0, 0, 0, 0);
|
|
const MapObject b = System(32, 1, 1000, 0, 0); // absurdly far
|
|
|
|
OneLineGraph g(0, 1, 7);
|
|
const LegResult r = ClassifyLeg(f, a, b, f.rangeRemaining, &g);
|
|
check(r.kind == WaypointKind::Stutter, "a stutter leg is kind 2 regardless of distance");
|
|
check(r.flags == 0u, "and raises no range complaint -- no range check is performed");
|
|
check(r.route.pathIndex == -1 && r.route.fromId == 0 && r.route.toId == 0,
|
|
"a non-node leg records an empty route");
|
|
|
|
// The same geometry for a node race does complain.
|
|
FleetState h = NodeFleet();
|
|
h.rangeRemaining = 0.0;
|
|
h.tankCapacity = 0.0;
|
|
const LegResult rh = ClassifyLeg(h, a, b, h.rangeRemaining, &g);
|
|
check(rh.kind == WaypointKind::None, "the node race cannot make the same leg");
|
|
check((rh.flags & kNodeLegOutOfRange) != 0u, "and says why");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------------------
|
|
// The route record, and the save-visible invariant
|
|
// -----------------------------------------------------------------------------------------
|
|
|
|
void TestRouteRecord() {
|
|
FleetState f = NodeFleet();
|
|
const MapObject a = System(80, 0, 0, 0, 0);
|
|
const MapObject b = System(272, 1, 3, 4, 0); // distance 5
|
|
OneLineGraph g(0, 1, 53);
|
|
|
|
const LegResult r = ClassifyLeg(f, a, b, f.rangeRemaining, &g);
|
|
check(r.kind == WaypointKind::NodeRoute, "an existing line gives a node route");
|
|
check(r.route.pathIndex == 53, "the line's index is recorded");
|
|
check(r.route.fromId == 80 && r.route.toId == 272, "the endpoints' handles are recorded");
|
|
|
|
// The invariant every save must satisfy: a non-node kind records nothing.
|
|
FleetState t = NodeFleet();
|
|
t.ownerSpecies = Species::Tarkas;
|
|
const LegResult rt = ClassifyLeg(t, a, b, t.rangeRemaining, &g);
|
|
check(rt.kind == WaypointKind::StraightA, "a straight-drive leg");
|
|
check(rt.route.pathIndex == -1 && rt.route.fromId == 0 && rt.route.toId == 0,
|
|
"kind != 3 implies an empty route record");
|
|
}
|
|
|
|
void TestBoredLineHasNoIndex() {
|
|
FleetState f = NodeFleet();
|
|
f.ownerSpecies = Species::Zuul;
|
|
f.canBoreNodeLines = true;
|
|
const MapObject a = System(80, 0, 0, 0, 0);
|
|
const MapObject b = System(272, 1, 3, 4, 0);
|
|
|
|
OneLineGraph g(5, 6, 99); // no line between 0 and 1
|
|
g.boreSucceeds = true;
|
|
const LegResult r = ClassifyLeg(f, a, b, f.rangeRemaining, &g);
|
|
check(r.kind == WaypointKind::NodeRoute, "a bored line still gives a node route");
|
|
check(g.boreCalls == 1, "and it was actually bored");
|
|
check(r.route.pathIndex == -1,
|
|
"a freshly bored line records index -1, not a real index");
|
|
check(r.route.fromId == 80 && r.route.toId == 272, "with the endpoints still recorded");
|
|
|
|
g.boreSucceeds = false;
|
|
const LegResult rf = ClassifyLeg(f, a, b, f.rangeRemaining, &g);
|
|
check(rf.kind == WaypointKind::None && (rf.flags & kBoreFailed) != 0u, "a failed bore says so");
|
|
|
|
FleetState nb = f;
|
|
nb.canBoreNodeLines = false;
|
|
const LegResult rn = ClassifyLeg(nb, a, b, nb.rangeRemaining, &g);
|
|
check((rn.flags & kNoLineAndCannotBore) != 0u, "a fleet that cannot bore says so instead");
|
|
check((rn.flags & kBoreFailed) == 0u, "and does not also claim the bore failed");
|
|
}
|
|
|
|
void TestBoredLineOutOfRangeIsADistinctFlag() {
|
|
FleetState f = NodeFleet();
|
|
f.ownerSpecies = Species::Zuul;
|
|
f.canBoreNodeLines = true;
|
|
f.rangeRemaining = 1.0;
|
|
f.tankCapacity = 1.0;
|
|
const MapObject a = System(80, 0, 0, 0, 0);
|
|
const MapObject b = System(272, 1, 3, 4, 0); // distance 5, well beyond 1
|
|
|
|
OneLineGraph missing(5, 6, 99);
|
|
missing.boreSucceeds = true;
|
|
const LegResult r = ClassifyLeg(f, a, b, f.rangeRemaining, &missing);
|
|
check((r.flags & kBoredLineOutOfRange) != 0u, "out of range AFTER boring is its own flag");
|
|
check((r.flags & kNodeLegOutOfRange) == 0u, "and is not the existing-line flag");
|
|
|
|
OneLineGraph present(0, 1, 12);
|
|
const LegResult r2 = ClassifyLeg(f, a, b, f.rangeRemaining, &present);
|
|
check((r2.flags & kNodeLegOutOfRange) != 0u, "out of range on an EXISTING line is the other");
|
|
check((r2.flags & kBoredLineOutOfRange) == 0u, "and not the bored one");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------------------
|
|
// The fuel check -- the one float that decides an outcome
|
|
// -----------------------------------------------------------------------------------------
|
|
|
|
void TestRangeCheckSquaringAsymmetry() {
|
|
// Exact case first: a 3-4-5 triangle is exact in binary, so range 5 must just reach.
|
|
check(LegInRange(Vec3{0, 0, 0}, Vec3{3, 4, 0}, 5.0, 1e9), "distance exactly equal is in range");
|
|
check(!LegInRange(Vec3{0, 0, 0}, Vec3{3, 4, 0}, 4.999, 1e9), "a hair short is out of range");
|
|
|
|
// The capacity cap bites even when plenty of fuel remains.
|
|
check(!LegInRange(Vec3{0, 0, 0}, Vec3{3, 4, 0}, 1e9, 4.0), "the tank capacity caps the range");
|
|
check(LegInRange(Vec3{0, 0, 0}, Vec3{3, 4, 0}, 1e9, 5.0), "and permits it when large enough");
|
|
|
|
// The asymmetry itself, and it must actually be exercised -- a case that silently skips
|
|
// is a check that compared nothing. `100.00000762939453` is the float32 just above 100;
|
|
// its exact square is 10000.001525878964 and the float32 rounding of that square is
|
|
// 10000.001953125, i.e. it rounds UP by ~4.3e-4. Any squared distance strictly between the
|
|
// two is IN range under a "narrow the square too" rule and OUT of range under the real one.
|
|
const double r = static_cast<double>(100.00000762939453f);
|
|
const double exactSquare = r * r;
|
|
const double narrowedSquare = static_cast<double>(static_cast<float>(exactSquare));
|
|
check(narrowedSquare > exactSquare,
|
|
"the chosen range's float32 square really does round up (else the next two checks "
|
|
"would be vacuous)");
|
|
const double between = 0.5 * (exactSquare + narrowedSquare);
|
|
check(between > exactSquare && between < narrowedSquare, "and the probe sits between them");
|
|
check(!LegInRange(between, r, 1e9),
|
|
"a squared distance above the EXACT square is out of range");
|
|
check(LegInRange(exactSquare, r, 1e9), "while the exact square itself is inclusive");
|
|
|
|
// Stated the other way round: a float32 squaring would have accepted the probe, so this
|
|
// pins the direction of the disagreement, not merely its existence.
|
|
check(between <= narrowedSquare,
|
|
"a float32 squaring would have called the same probe in range");
|
|
|
|
// A degenerate fleet: zero capacity means only a zero-length leg is in range.
|
|
check(LegInRange(0.0, 0.0, 0.0), "a zero leg is in range with no fuel");
|
|
check(!LegInRange(1e-12, 0.0, 0.0), "any leg at all is not");
|
|
}
|
|
|
|
void TestLegLengthNarrowsTheDeltas() {
|
|
// A pair whose exact difference is not representable in float32. The rule narrows each
|
|
// difference before squaring, so the answer is the length of the NARROWED delta.
|
|
const double big = 1.0;
|
|
const double tiny = 1.0e-9; // lost when 1.0 + tiny is stored as a float
|
|
const double got = LegLength(Vec3{big + tiny, 0, 0}, Vec3{0, 0, 0});
|
|
const double expected =
|
|
static_cast<double>(static_cast<float>(std::sqrt(static_cast<double>(
|
|
static_cast<float>(static_cast<double>(static_cast<float>(big + tiny - 0.0)) *
|
|
static_cast<double>(static_cast<float>(big + tiny - 0.0)))))));
|
|
check(got == expected, "the leg length narrows the delta, the sum and the root");
|
|
check(LegLength(Vec3{0, 0, 0}, Vec3{3, 4, 0}) == 5.0, "and is exact on an exact triangle");
|
|
check(LegLength(Vec3{1, 2, 3}, Vec3{1, 2, 3}) == 0.0, "a zero leg has zero length");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------------------
|
|
// Gate transits
|
|
// -----------------------------------------------------------------------------------------
|
|
|
|
FleetState GateFleet() {
|
|
FleetState f;
|
|
f.ownerSpecies = Species::Hiver;
|
|
f.rangeRemaining = 1000.0;
|
|
f.rangeFull = 1000.0;
|
|
f.tankCapacity = 1000.0;
|
|
f.gateProjectionRadius = 10.0;
|
|
f.gateTrafficCapacity = 100;
|
|
f.gateTrafficCost = 5;
|
|
return f;
|
|
}
|
|
|
|
void TestGateKinds() {
|
|
FleetState f = GateFleet();
|
|
MapObject a = System(16, 0, 0, 0, 0);
|
|
MapObject b = System(32, 1, 3, 4, 0); // distance 5, inside the radius
|
|
a.weHaveGateHere = true;
|
|
|
|
b.weHaveGateHere = true;
|
|
check(ClassifyLeg(f, a, b, f.rangeRemaining, nullptr).kind == WaypointKind::GateToGate,
|
|
"gate at both ends is kind 4");
|
|
|
|
b.weHaveGateHere = false;
|
|
check(ClassifyLeg(f, a, b, f.rangeRemaining, nullptr).kind == WaypointKind::GateProjected,
|
|
"gate at one end, within the radius, is kind 5");
|
|
|
|
// Outside the radius there is no gate transit at all, so the leg falls back to the drive.
|
|
MapObject far = System(48, 2, 100, 0, 0);
|
|
const LegResult rf = ClassifyLeg(f, a, far, f.rangeRemaining, nullptr);
|
|
check(rf.kind == WaypointKind::None, "beyond the radius the gate race falls back to kind 0");
|
|
|
|
// The radius is inclusive, and a zero radius disables projection entirely.
|
|
f.gateProjectionRadius = 5.0;
|
|
check(ClassifyLeg(f, a, b, f.rangeRemaining, nullptr).kind == WaypointKind::GateProjected,
|
|
"the projection radius is inclusive");
|
|
f.gateProjectionRadius = 0.0;
|
|
check(ClassifyLeg(f, a, b, f.rangeRemaining, nullptr).kind == WaypointKind::None,
|
|
"a zero projection radius disables the throw");
|
|
}
|
|
|
|
void TestGateTrafficCapacity() {
|
|
FleetState f = GateFleet();
|
|
MapObject a = System(16, 0, 0, 0, 0);
|
|
MapObject b = System(32, 1, 3, 4, 0);
|
|
a.weHaveGateHere = true;
|
|
b.weHaveGateHere = true;
|
|
|
|
f.gateTrafficUsed = 96; // 96 + 5 > 100
|
|
const LegResult over = ClassifyLeg(f, a, b, f.rangeRemaining, nullptr);
|
|
check((over.flags & kGateTrafficExceeded) != 0u, "over capacity raises the traffic flag");
|
|
check(over.kind == WaypointKind::None, "and the leg becomes kind 0");
|
|
check(!OrderRefused(over.flags),
|
|
"but the order is NOT refused -- a plan can be installed over gate capacity");
|
|
|
|
f.gateTrafficUsed = 95; // 95 + 5 == 100, not over
|
|
check(ClassifyLeg(f, a, b, f.rangeRemaining, nullptr).kind == WaypointKind::GateToGate,
|
|
"exactly at capacity is allowed");
|
|
|
|
// A fleet already on a gate leg is already counted and must not be counted twice.
|
|
f.gateTrafficUsed = 100;
|
|
f.alreadyOnGateLeg = true;
|
|
check(ClassifyLeg(f, a, b, f.rangeRemaining, nullptr).kind == WaypointKind::GateToGate,
|
|
"a fleet already on a gate leg does not pay again");
|
|
}
|
|
|
|
void TestGateWaivesTheGroundedRefusal() {
|
|
FleetState f = GateFleet();
|
|
f.anyShipGrounded = true;
|
|
MapObject a = System(16, 0, 0, 0, 0);
|
|
MapObject b = System(32, 1, 3, 4, 0);
|
|
a.weHaveGateHere = true;
|
|
b.weHaveGateHere = true;
|
|
|
|
const LegResult gate = ClassifyLeg(f, a, b, f.rangeRemaining, nullptr);
|
|
check((gate.flags & kFleetGrounded) == 0u, "a gate transit clears the grounded flag");
|
|
check(!OrderRefused(gate.flags), "so a dead-drive fleet may still be thrown through a gate");
|
|
|
|
// Without a gate, the same fleet is refused.
|
|
a.weHaveGateHere = false;
|
|
b.weHaveGateHere = false;
|
|
const LegResult walk = ClassifyLeg(f, a, b, f.rangeRemaining, nullptr);
|
|
check((walk.flags & kFleetGrounded) != 0u, "without a gate the flag stands");
|
|
check(OrderRefused(walk.flags), "and the order is refused");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------------------
|
|
// The three refusal bits, and the warnings that are not refusals
|
|
// -----------------------------------------------------------------------------------------
|
|
|
|
void TestRefusalMask() {
|
|
check(kOrderRefusalMask == 0x418u, "the refusal mask is exactly three bits");
|
|
check(OrderRefused(kCannotInterceptFleet), "cannot-intercept refuses");
|
|
check(OrderRefused(kFleetGrounded), "grounded refuses");
|
|
check(OrderRefused(kDestPointNotPermitted), "forbidden point refuses");
|
|
for (unsigned bit : {kShipActionsWillCancel, kNodeLegOutOfRange, kGateTrafficExceeded,
|
|
kNoLineAndCannotBore, kBoredLineOutOfRange, kBoreFailed,
|
|
kDestSystemNotFriendly, kSourceSystemNotFriend, kShipActionEight}) {
|
|
check(!OrderRefused(bit), "every other bit is advisory");
|
|
}
|
|
}
|
|
|
|
void TestInterceptFlagOnlyForNodeTravellingTargets() {
|
|
FleetState f = NodeFleet();
|
|
const MapObject a = System(16, 0, 0, 0, 0);
|
|
|
|
MapObject target = Fleet(64);
|
|
target.fleetOnNodeLeg = true;
|
|
target.interceptSolved = false;
|
|
const LegResult miss = ClassifyLeg(f, a, target, f.rangeRemaining, nullptr);
|
|
check((miss.flags & kCannotInterceptFleet) != 0u,
|
|
"an unmeetable node-travelling target refuses the order");
|
|
|
|
target.fleetOnNodeLeg = false;
|
|
const LegResult idle = ClassifyLeg(f, a, target, f.rangeRemaining, nullptr);
|
|
check((idle.flags & kCannotInterceptFleet) == 0u,
|
|
"a target that is not node-travelling raises nothing -- there was nothing to solve");
|
|
|
|
MapObject dest = System(32, 1, 3, 4, 0);
|
|
target.fleetOnNodeLeg = true;
|
|
target.interceptSolved = true;
|
|
target.interceptSystem = &dest;
|
|
OneLineGraph g(0, 1, 21);
|
|
const LegResult hit = ClassifyLeg(f, a, target, f.rangeRemaining, &g);
|
|
check((hit.flags & kCannotInterceptFleet) == 0u, "a solved intercept raises nothing");
|
|
check(hit.kind == WaypointKind::NodeRoute && hit.route.pathIndex == 21,
|
|
"and the leg is planned to the system the target was met at");
|
|
}
|
|
|
|
void TestPointPermission() {
|
|
FleetState f = NodeFleet();
|
|
const MapObject a = System(16, 0, 0, 0, 0);
|
|
|
|
MapObject p = Point(200, 3, 4, 0);
|
|
const LegResult denied = ClassifyLeg(f, a, p, f.rangeRemaining, nullptr);
|
|
check((denied.flags & kDestPointNotPermitted) != 0u, "an unknown point is refused");
|
|
check(denied.kind == WaypointKind::None, "and a node race gets no kind for it");
|
|
|
|
// A straight-line race still reports its own kind even though the order will be refused.
|
|
FleetState t = NodeFleet();
|
|
t.ownerSpecies = Species::Tarkas;
|
|
const LegResult straight = ClassifyLeg(t, a, p, t.rangeRemaining, nullptr);
|
|
check((straight.flags & kDestPointNotPermitted) != 0u, "same refusal");
|
|
check(straight.kind == WaypointKind::StraightA,
|
|
"but a straight drive still reports its kind alongside the refusal");
|
|
|
|
p.pointKnownToUs = true;
|
|
const LegResult allowed = ClassifyLeg(f, a, p, f.rangeRemaining, nullptr);
|
|
check((allowed.flags & kDestPointNotPermitted) == 0u, "either mask permits it");
|
|
check(allowed.kind == WaypointKind::NodeRoute, "and a node race plans a leg to it");
|
|
check(allowed.route.pathIndex == -1, "a point endpoint records no line index");
|
|
check(allowed.route.fromId == 16 && allowed.route.toId == 200, "with both handles recorded");
|
|
}
|
|
|
|
void TestFriendlinessFlags() {
|
|
FleetState f = NodeFleet();
|
|
MapObject a = System(16, 0, 0, 0, 0);
|
|
MapObject p = Point(200, 3, 4, 0);
|
|
p.pointKnownToUs = true;
|
|
|
|
a.ownedByUs = false;
|
|
a.ownerIsFriendly = false;
|
|
const LegResult r = ClassifyLeg(f, a, p, f.rangeRemaining, nullptr);
|
|
check((r.flags & kSourceSystemNotFriend) != 0u, "an unfriendly source system, going to a point");
|
|
check(r.kind == WaypointKind::None, "and no leg");
|
|
|
|
MapObject dest = System(32, 1, 3, 4, 0);
|
|
dest.ownedByUs = false;
|
|
dest.ownerIsFriendly = false;
|
|
MapObject fromPoint = Point(200, 0, 0, 0);
|
|
fromPoint.pointKnownToUs = true;
|
|
const LegResult r2 = ClassifyLeg(f, fromPoint, dest, f.rangeRemaining, nullptr);
|
|
check((r2.flags & kDestSystemNotFriendly) != 0u, "an unfriendly destination, coming from a point");
|
|
}
|
|
|
|
void TestAdvisoryBitsAreSeparate() {
|
|
FleetState f = NodeFleet();
|
|
f.anyShipActionEight = true;
|
|
const MapObject a = System(16, 0, 0, 0, 0);
|
|
const MapObject b = System(32, 1, 3, 4, 0);
|
|
OneLineGraph g(0, 1, 5);
|
|
|
|
const LegResult only8 = ClassifyLeg(f, a, b, f.rangeRemaining, &g);
|
|
check((only8.flags & kShipActionEight) != 0u, "the singled-out action raises its own bit");
|
|
check((only8.flags & kShipActionsWillCancel) == 0u,
|
|
"and NOT the general one -- it is taken out of the mask first");
|
|
check(!OrderRefused(only8.flags), "neither refuses");
|
|
check(only8.kind == WaypointKind::NodeRoute, "and the leg is still planned");
|
|
|
|
f.anyShipActingOther = true;
|
|
const LegResult both = ClassifyLeg(f, a, b, f.rangeRemaining, &g);
|
|
check((both.flags & (kShipActionEight | kShipActionsWillCancel)) ==
|
|
(kShipActionEight | kShipActionsWillCancel),
|
|
"both can be raised together");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------------------
|
|
// The walk
|
|
// -----------------------------------------------------------------------------------------
|
|
|
|
struct FullGraph : NodeGraph {
|
|
int FindLine(int, int) const override { return 1; }
|
|
};
|
|
|
|
void TestWalkAccumulates() {
|
|
FleetState f = NodeFleet();
|
|
const MapObject start = System(16, 0, 0, 0, 0);
|
|
const MapObject b = System(32, 1, 3, 4, 0);
|
|
const MapObject c = System(48, 2, 6, 8, 0);
|
|
FullGraph g;
|
|
|
|
const std::vector<const MapObject*> dests{&b, &c};
|
|
const PathPlan plan = SolvePath(f, start, dests, &g);
|
|
check(plan.kinds.size() == 2, "one kind per destination");
|
|
check(plan.kinds[0] == WaypointKind::NodeRoute && plan.kinds[1] == WaypointKind::NodeRoute,
|
|
"both legs are node routes");
|
|
check(!plan.droppedLeadingDestination, "nothing was dropped");
|
|
check(plan.flags == 0u && plan.firstFailingLeg == -1 && !plan.refused, "and nothing complained");
|
|
|
|
// The route chain: each leg's origin is the previous leg's destination, and the first
|
|
// leg's origin is where the fleet started. This is the shape a save file shows.
|
|
check(plan.routes[0].fromId == 16 && plan.routes[0].toId == 32, "leg 0 runs start -> b");
|
|
check(plan.routes[1].fromId == 32 && plan.routes[1].toId == 48, "leg 1 runs b -> c");
|
|
check(plan.routes[1].fromId == plan.routes[0].toId, "so the chain closes");
|
|
}
|
|
|
|
void TestWalkRecordsTheFirstFailureOnly() {
|
|
FleetState f = NodeFleet();
|
|
f.rangeRemaining = 4.0; // not enough for a distance-5 leg
|
|
f.rangeFull = 4.0;
|
|
f.tankCapacity = 4.0;
|
|
const MapObject start = System(16, 0, 0, 0, 0);
|
|
const MapObject b = System(32, 1, 3, 4, 0);
|
|
const MapObject c = System(48, 2, 6, 8, 0);
|
|
FullGraph g;
|
|
|
|
const PathPlan plan = SolvePath(f, start, {&b, &c}, &g);
|
|
check(plan.firstFailingLeg == 0, "the FIRST failing leg is recorded, not the last");
|
|
check((plan.flags & kNodeLegOutOfRange) != 0u, "and the flags are the OR across all legs");
|
|
check(!plan.refused, "running out of fuel does not refuse the order");
|
|
}
|
|
|
|
void TestRefuellingResetsTheBudget() {
|
|
FleetState f = NodeFleet();
|
|
f.rangeRemaining = 6.0;
|
|
f.rangeFull = 6.0;
|
|
f.tankCapacity = 6.0;
|
|
const MapObject start = System(16, 0, 0, 0, 0);
|
|
MapObject b = System(32, 1, 3, 4, 0); // 5 from start
|
|
const MapObject c = System(48, 2, 6, 8, 0); // 5 further
|
|
FullGraph g;
|
|
|
|
// Without a refuel at b, the second leg has only 1.0 left and fails.
|
|
const PathPlan dry = SolvePath(f, start, {&b, &c}, &g);
|
|
check((dry.flags & kNodeLegOutOfRange) != 0u, "the second leg runs dry");
|
|
check(dry.firstFailingLeg == 1, "and it is the second leg that failed, not the first");
|
|
|
|
// With one, the budget goes back to full and both legs pass.
|
|
b.weCanRefuelHere = true;
|
|
const PathPlan wet = SolvePath(f, start, {&b, &c}, &g);
|
|
check(wet.flags == 0u, "refuelling at the intermediate system carries the fleet through");
|
|
}
|
|
|
|
void TestLeadingDestinationDropShiftsTheOutput() {
|
|
FleetState f = NodeFleet();
|
|
const MapObject start = System(16, 0, 0, 0, 0);
|
|
f.currentSystem = &start;
|
|
const MapObject b = System(32, 1, 3, 4, 0);
|
|
const MapObject c = System(48, 2, 6, 8, 0);
|
|
FullGraph g;
|
|
|
|
// Ordering the fleet to the system it is already at, then onward.
|
|
const PathPlan plan = SolvePath(f, start, {&start, &b, &c}, &g);
|
|
check(plan.droppedLeadingDestination, "the leading destination was dropped");
|
|
check(plan.kinds.size() == 3, "but the output still has one slot per destination");
|
|
check(plan.kinds[0] == WaypointKind::NodeRoute && plan.kinds[1] == WaypointKind::NodeRoute,
|
|
"the two real legs land in slots 0 and 1");
|
|
check(plan.kinds[2] == WaypointKind::None,
|
|
"and the LAST slot is never written -- this is the original's off-by-one, reproduced");
|
|
check(plan.routes[2].fromId == 0 && plan.routes[2].toId == 0,
|
|
"the trailing route record is likewise untouched");
|
|
check(plan.routes[0].fromId == 16 && plan.routes[0].toId == 32,
|
|
"slot 0 describes the leg to the SECOND destination, not the first");
|
|
|
|
// Without the drop, everything lines up.
|
|
FleetState g2 = f;
|
|
g2.currentSystem = nullptr;
|
|
const PathPlan ok = SolvePath(g2, start, {&b, &c}, &g);
|
|
check(!ok.droppedLeadingDestination && ok.kinds.size() == 2, "no drop, no shift");
|
|
}
|
|
|
|
void TestEmptyAndDegenerateInputs() {
|
|
FleetState f = NodeFleet();
|
|
const MapObject start = System(16, 0, 0, 0, 0);
|
|
FullGraph g;
|
|
|
|
const PathPlan none = SolvePath(f, start, {}, &g);
|
|
check(none.kinds.empty() && none.flags == 0u && !none.refused, "an empty order plans nothing");
|
|
check(none.firstFailingLeg == -1, "and reports no failing leg");
|
|
|
|
// A leg to the system we are standing on is not a leg at all.
|
|
const LegResult self = ClassifyLeg(f, start, start, f.rangeRemaining, &g);
|
|
check(self.kind == WaypointKind::None, "a node leg from a system to itself is not a crossing");
|
|
|
|
// A shipless fleet has no drive.
|
|
FleetState empty = NodeFleet();
|
|
empty.hasShips = false;
|
|
const MapObject b = System(32, 1, 3, 4, 0);
|
|
check(ClassifyLeg(empty, start, b, empty.rangeRemaining, &g).kind == WaypointKind::None,
|
|
"a fleet with no ships has no drive");
|
|
|
|
// A NaN budget clamps to zero rather than propagating.
|
|
FleetState nan = NodeFleet();
|
|
nan.rangeRemaining = std::numeric_limits<double>::quiet_NaN();
|
|
nan.tankCapacity = 10.0;
|
|
const PathPlan p = SolvePath(nan, start, {&b}, &g);
|
|
check((p.flags & kNodeLegOutOfRange) != 0u, "a NaN fuel budget clamps to zero, not to infinity");
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
TestDriveTable();
|
|
TestNonNodeSpeciesShortCircuit();
|
|
TestRouteRecord();
|
|
TestBoredLineHasNoIndex();
|
|
TestBoredLineOutOfRangeIsADistinctFlag();
|
|
TestRangeCheckSquaringAsymmetry();
|
|
TestLegLengthNarrowsTheDeltas();
|
|
TestGateKinds();
|
|
TestGateTrafficCapacity();
|
|
TestGateWaivesTheGroundedRefusal();
|
|
TestRefusalMask();
|
|
TestInterceptFlagOnlyForNodeTravellingTargets();
|
|
TestPointPermission();
|
|
TestFriendlinessFlags();
|
|
TestAdvisoryBitsAreSeparate();
|
|
TestWalkAccumulates();
|
|
TestWalkRecordsTheFirstFailureOnly();
|
|
TestRefuellingResetsTheBudget();
|
|
TestLeadingDestinationDropShiftsTheOutput();
|
|
TestEmptyAndDegenerateInputs();
|
|
|
|
std::printf("game_nav pathplan: %d checks, %d failures\n", g_checks, g_fails);
|
|
return g_fails == 0 ? 0 : 1;
|
|
}
|