diff --git a/CMakeLists.txt b/CMakeLists.txt index 3f925b7..a0ae66c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,6 +30,7 @@ add_subdirectory(src/game/effects) # tech effects (TechId table + apply) (lib add_subdirectory(src/game/design) # ship-design rules + derived stats (lib game_design) add_subdirectory(src/game/events) # player event log + research events (lib sots_game_events) add_subdirectory(src/game/combat) # post-battle strategic consequences (lib sots_game_combat) +add_subdirectory(src/game/nav) # fleet path planning, pure (lib sots_game_nav) add_subdirectory(src/app) # the standalone turn driver (lib sots_app, sots_turn) # ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ---- @@ -120,7 +121,7 @@ else() add_executable(addr_smoke tests/addr_smoke.cpp) target_link_libraries(addr_smoke PRIVATE sots_addresses) add_test(NAME addr_smoke COMMAND addr_smoke) - foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects game_events game_combat shim_budget shim_techfx shim_colony shim_movement shim_events shim_player_turn shim_rng_ledger app) + foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects game_events game_combat game_nav shim_budget shim_techfx shim_colony shim_movement shim_events shim_player_turn shim_rng_ledger app) if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt) add_subdirectory(tests/${_t}) endif() diff --git a/src/game/nav/CMakeLists.txt b/src/game/nav/CMakeLists.txt new file mode 100644 index 0000000..31c4c79 --- /dev/null +++ b/src/game/nav/CMakeLists.txt @@ -0,0 +1,11 @@ +# Fleet path planning: destinations in, waypoint kinds and route records out. Pure; no state, +# no I/O, no random draws. Deliberately NOT part of game/sim -- the movement step (how far a +# fleet gets this turn) and the path plan (what kind of crossing each leg is) are different +# subsystems that happen to share a vocabulary. +add_library(sots_game_nav STATIC + pathplan.cpp) +target_include_directories(sots_game_nav PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..) +target_compile_features(sots_game_nav PUBLIC cxx_std_17) +if(NOT MSVC) + target_compile_options(sots_game_nav PRIVATE -Wall -Wextra) +endif() diff --git a/src/game/nav/pathplan.cpp b/src/game/nav/pathplan.cpp new file mode 100644 index 0000000..8c04d52 --- /dev/null +++ b/src/game/nav/pathplan.cpp @@ -0,0 +1,287 @@ +#include "game/nav/pathplan.h" + +#include + +namespace sots::nav { +namespace { + +// Narrow to float32 and widen back. Every scalar the original keeps in a 4-byte field rounds +// on the way in; a formula that skips the rounding drifts in the last bit and, at a +// comparison boundary, flips the answer. +inline double F32(double v) { return static_cast(static_cast(v)); } + +// The squared distance a range check compares against: three float32 differences, squared and +// summed at full precision, and the SUM rounded once. +inline double SquaredDistanceF32(const Vec3& a, const Vec3& b) { + const double dx = F32(a.x - b.x); + const double dy = F32(a.y - b.y); + const double dz = F32(a.z - b.z); + return F32(dx * dx + dy * dy + dz * dz); +} + +inline bool IsSystem(const MapObject* o) { return o && o->kind == ObjectKind::System; } +inline bool IsPoint(const MapObject* o) { return o && o->kind == ObjectKind::Point; } +inline bool IsFleet(const MapObject* o) { return o && o->kind == ObjectKind::Fleet; } + +} // namespace + +// --------------------------------------------------------------------------------------- +// Kinds +// --------------------------------------------------------------------------------------- + +WaypointKind DriveOf(Species s) { + switch (s) { + case Species::Human: return WaypointKind::NodeRoute; // 3 + case Species::Hiver: return WaypointKind::None; // 0 -- crosses by gate + case Species::Tarkas: return WaypointKind::StraightA; // 1 + case Species::Liir: return WaypointKind::Stutter; // 2 -- the only producer of 2 + case Species::NPC: return WaypointKind::None; // 0 + case Species::Zuul: return WaypointKind::NodeRoute; // 3 + case Species::Morrigi: return WaypointKind::StraightB; // 6 + } + return WaypointKind::None; // anything outside the enum +} + +bool IsGateTransitKind(int kind) { return kind == 4 || kind == 5; } +bool IsNodeKind(int kind) { return kind == 3; } + +// --------------------------------------------------------------------------------------- +// Geometry and fuel +// --------------------------------------------------------------------------------------- + +double LegLength(const Vec3& a, const Vec3& b) { + return F32(std::sqrt(SquaredDistanceF32(a, b))); +} + +bool LegInRange(double squaredDistance, double rangeAvailable, double tankCapacity) { + // min(available, capacity), with both candidates having been through a float32 field. + const double avail = F32(rangeAvailable); + const double cap = F32(tankCapacity); + const double r = (avail > cap) ? cap : avail; + // The square is NOT narrowed -- it stays in the register. This asymmetry is the whole + // point of the function; see the header. + return squaredDistance <= r * r; +} + +bool LegInRange(const Vec3& a, const Vec3& b, double rangeAvailable, double tankCapacity) { + return LegInRange(SquaredDistanceF32(a, b), rangeAvailable, tankCapacity); +} + +bool GateProjectionReaches(const FleetState& f, const MapObject& from, const MapObject& to) { + if (!(0.0 < F32(f.gateProjectionRadius))) return false; + if (!from.weHaveGateHere) return false; + if (to.weHaveGateHere) return false; // with a gate at both ends it is the ordinary kind + return LegLength(from.pos, to.pos) <= F32(f.gateProjectionRadius); +} + +// --------------------------------------------------------------------------------------- +// One leg +// --------------------------------------------------------------------------------------- + +LegResult ClassifyLeg(const FleetState& f, + const MapObject& from, + const MapObject& to, + double rangeIn, + const NodeGraph* graph) { + LegResult r; + r.rangeAfter = rangeIn; + + const WaypointKind drive = f.hasShips ? DriveOf(f.ownerSpecies) : WaypointKind::None; + + // Resolve each endpoint into at most one of the three shapes. A fleet endpoint stands in + // for the system it is parked at, when it is parked at one. + const MapObject* fromSystem = nullptr; + const MapObject* fromPoint = nullptr; + if (from.kind == ObjectKind::Point) { + fromPoint = &from; + } else if (from.kind == ObjectKind::System) { + fromSystem = &from; + } else if (from.interceptSystem != nullptr) { + // A fleet endpoint resolves through whatever it is sitting on. + if (IsSystem(from.interceptSystem)) fromSystem = from.interceptSystem; + else if (IsPoint(from.interceptSystem)) fromPoint = from.interceptSystem; + } + + const MapObject* toSystem = IsSystem(&to) ? &to : nullptr; + const MapObject* toFleet = IsFleet(&to) ? &to : nullptr; + const MapObject* toPoint = IsPoint(&to) ? &to : nullptr; + + // 1. A moving target. We can only meet a node-travelling fleet at one end of the line it + // is on; if it is not node-travelling at all there is nothing to solve and no + // complaint to make. + bool intercepted = false; + if (toFleet) { + intercepted = toFleet->interceptSolved; + if (intercepted && IsSystem(toFleet->interceptSystem)) toSystem = toFleet->interceptSystem; + } + + // 2. The advisory bits, and the one hard bit that a gate can later waive. + if (toFleet && !intercepted && toFleet->fleetOnNodeLeg) r.flags |= kCannotInterceptFleet; + if (f.anyShipGrounded) r.flags |= kFleetGrounded; + if (f.anyShipActionEight) r.flags |= kShipActionEight; + if (f.anyShipActingOther) r.flags |= kShipActionsWillCancel; + + // 3. A deep-space destination we are not allowed to use. Refused here, before any drive + // or gate consideration -- and note the kind that comes back: a gate or node drive + // yields nothing, while a straight-line drive still reports its own kind even though + // the order will be refused. + if (toPoint && !toPoint->pointVisibleToUs && !toPoint->pointKnownToUs) { + r.flags |= kDestPointNotPermitted; + const int d = static_cast(drive); + r.kind = (IsGateTransitKind(d) || IsNodeKind(d)) ? WaypointKind::None : drive; + return r; + } + + // 4. A gate transit. Reachable whenever we hold a gate at one end -- which for every + // species but the gate-builder means never. + const bool fromHasGate = fromSystem && fromSystem->weHaveGateHere; + const bool toHasGate = toSystem && toSystem->weHaveGateHere; + const bool canProject = + (fromSystem && toSystem) ? GateProjectionReaches(f, *fromSystem, *toSystem) : false; + const bool pointUsable = toPoint && (toPoint->pointVisibleToUs || toPoint->pointKnownToUs); + + if ((fromHasGate && (toHasGate || canProject || pointUsable)) || (toHasGate && fromPoint)) { + const int cost = f.alreadyOnGateLeg ? 0 : f.gateTrafficCost; + if (f.gateTrafficUsed + cost > f.gateTrafficCapacity) { + r.flags |= kGateTrafficExceeded; + r.kind = WaypointKind::None; + return r; + } + // A gate does not care whether the fleet's own drives work. + r.flags &= ~static_cast(kFleetGrounded); + r.kind = canProject ? WaypointKind::GateProjected : WaypointKind::GateToGate; + return r; + } + + // 5. Every species that does not fly the node drive stops here. No range check, no route + // record: the leg is simply that species' drive. + if (drive != WaypointKind::NodeRoute) { + r.kind = drive; + return r; + } + + // 6. The node-route hop. Exactly one hop -- there is no search over intermediate systems. + unsigned errBits = kNodeLegOutOfRange; + int pathIndex = -1; + const MapObject* origin = nullptr; + const MapObject* dest = nullptr; + + if (fromPoint && toSystem) { + if (!(toSystem->ownedByUs || toSystem->ownerIsFriendly)) { + r.flags |= kDestSystemNotFriendly; + r.kind = WaypointKind::None; + return r; + } + origin = fromPoint; + dest = toSystem; + } else if (toPoint) { + if (!fromSystem) { r.kind = WaypointKind::None; return r; } + if (!(fromSystem->ownedByUs || fromSystem->ownerIsFriendly)) { + r.flags |= kSourceSystemNotFriend; + r.kind = WaypointKind::None; + return r; + } + if (!pointUsable) { + r.flags |= kDestPointNotPermitted; + r.kind = WaypointKind::None; + return r; + } + origin = fromSystem; + dest = toPoint; + } else { + if (!fromSystem || !toSystem || fromSystem == toSystem) { + r.kind = WaypointKind::None; + return r; + } + const int found = + graph ? graph->FindLine(fromSystem->systemIndex, toSystem->systemIndex) : -1; + if (found != -1) { + pathIndex = found; + } else { + if (!f.canBoreNodeLines) { + r.flags |= kNoLineAndCannotBore; + r.kind = WaypointKind::None; + return r; + } + errBits = kBoredLineOutOfRange; + if (!(graph && graph->BoreLine(fromSystem->systemIndex, toSystem->systemIndex))) { + r.flags |= kBoreFailed; + r.kind = WaypointKind::None; + return r; + } + pathIndex = -1; // a line we just made carries no index + } + origin = fromSystem; + dest = toSystem; + } + + if (origin && dest && !LegInRange(origin->pos, dest->pos, rangeIn, f.tankCapacity)) { + r.flags |= errBits; + r.kind = WaypointKind::None; + return r; + } + + r.kind = WaypointKind::NodeRoute; + r.route.pathIndex = pathIndex; + r.route.fromId = origin ? origin->id : 0; + r.route.toId = dest ? dest->id : 0; + return r; +} + +// --------------------------------------------------------------------------------------- +// The whole order +// --------------------------------------------------------------------------------------- + +PathPlan SolvePath(const FleetState& f, + const MapObject& start, + const std::vector& dests, + const NodeGraph* graph) { + PathPlan plan; + plan.kinds.assign(dests.size(), WaypointKind::None); + plan.routes.assign(dests.size(), NodeRoute{}); + + std::size_t first = 0; + if (!dests.empty() && dests[0] != nullptr) { + // The leading destination is dropped when it is where we already are: either the + // start object itself, or the SYSTEM the fleet is parked at. Only a system counts -- + // a fleet parked at a deep-space point is not "already there" for this purpose. + const bool sameObject = (dests[0] == &start); + const bool sameSystem = IsSystem(f.currentSystem) && dests[0] == f.currentSystem; + if (sameObject || sameSystem) { + first = 1; + plan.droppedLeadingDestination = true; + } + } + + double range = f.rangeRemaining; + const MapObject* prev = &start; + std::size_t out = 0; + for (std::size_t i = first; i < dests.size(); ++i, ++out) { + const MapObject* cur = dests[i]; + if (cur == nullptr) continue; + + if (!(range >= 0.0)) range = 0.0; // NaN clamps to zero, as the original's compare does + + const LegResult leg = ClassifyLeg(f, *prev, *cur, range, graph); + + // The kinds and routes land at the OUTPUT index, which after a drop is one behind the + // destination index. Reproduced deliberately -- see the header. + plan.kinds[out] = leg.kind; + plan.routes[out] = leg.route; + + if (leg.flags != 0u) { + if (plan.firstFailingLeg == -1) plan.firstFailingLeg = static_cast(out); + plan.flags |= leg.flags; + } + + range = F32(range - LegLength(prev->pos, cur->pos)); + if (IsSystem(cur) && cur->weCanRefuelHere) range = f.rangeFull; + + prev = cur; + } + + plan.refused = OrderRefused(plan.flags); + return plan; +} + +} // namespace sots::nav diff --git a/src/game/nav/pathplan.h b/src/game/nav/pathplan.h new file mode 100644 index 0000000..74b44a0 --- /dev/null +++ b/src/game/nav/pathplan.h @@ -0,0 +1,294 @@ +// Fleet path planning: turning an ordered list of destinations into a flight plan. +// +// The strategic layer does NOT search for a route. The player (or the AI) picks the +// destinations; this code walks the resulting chain one leg at a time and decides, for each +// consecutive pair, *how* the fleet crosses that leg -- which is the waypoint kind the save +// file records -- plus whether the order is legal at all. +// +// The whole module is pure. Installing a flight plan mints no ids but does mutate a fleet, +// a player's gate-traffic total and every ship's pending action, so the split is the same +// one game/combat uses: this returns a plan, and applying it belongs to whatever owns the +// object store. +// +// CONFIDENCE: high on the decision order, the kind table and the flag meanings; see the +// per-item notes for the parts that are weaker. +#pragma once + +#include +#include + +namespace sots::nav { + +// --------------------------------------------------------------------------------------- +// Waypoint kinds +// --------------------------------------------------------------------------------------- + +// The kind stored on each waypoint. It is not a property of the leg's geometry -- for every +// leg the gate rule and the node rule decline, it is simply the drive the owning species +// flies, so a fleet's kind is decided by who owns it and not by where it is going. +// +// CONFIDENCE: high -- the mapping is a dense jump table with one entry per species. +enum class WaypointKind : int { + None = 0, // no crossing is possible; also the drive of the gate-building and + // the non-player races, which cross in normal space + StraightA = 1, // a plain straight-line drive + Stutter = 2, // the Liir drive, whose speed rises with distance from a star. It has + // been called "node line" across earlier work; it has nothing to do + // with node lines, which is why kind 3 and only kind 3 counts as a + // node waypoint + NodeRoute = 3, // travel along a discovered node line; the ONLY kind that carries a + // route record + GateToGate = 4, // instantaneous transit between two of the player's own gates + GateProjected = 5, // a gate throw at a system with NO receiving gate; arrival is a roll + StraightB = 6, // a second straight-line drive, distinct only by its number +}; + +// The seven playable/NPC species, in the order every per-species table uses. Mirrors +// game/sim's Species so this module can stand alone; keep them in step. +enum class Species : int { + Human = 0, Hiver = 1, Tarkas = 2, Liir = 3, NPC = 4, Zuul = 5, Morrigi = 6, +}; + +// The drive a species flies, and therefore the waypoint kind of any leg that is neither a +// gate transit nor a node route. +// +// THIS IS THE WHOLE OF THE "why is kind 2 never seen" QUESTION. Kind 2 is the Liir drive and +// nothing else produces it; the two node-drive races are Human and Zuul, both mapped to 3. So +// a Human or Zuul fleet can never carry a kind-2 waypoint and a Liir fleet can never carry a +// kind-3 one. There is no unreachable branch and nothing to repair -- the observations that +// found only kind 3 were taken on the two races the table forces to 3. +// +// CONFIDENCE: high -- one jump-table entry per species, resolved individually. +WaypointKind DriveOf(Species s); + +// The two predicates the movement step branches on. Both accept only the values below and +// are false for everything else, including values outside the enum. +// gate transit: 4 or 5 -- what a player's gate-traffic total counts +// node: 3 only -- NOT 2, which is a common and costly mistake to make because the +// stutter drive was long mis-named "node line" +// CONFIDENCE: high. +bool IsGateTransitKind(int kind); +bool IsNodeKind(int kind); + +// --------------------------------------------------------------------------------------- +// Why an order was refused, or merely questioned +// --------------------------------------------------------------------------------------- + +// One bit per problem, accumulated across every leg of the order. Only three of them refuse +// the order; the rest are either advisory (the player is asked to confirm) or a complaint +// about route quality that the server commits anyway. +// +// CONFIDENCE: high -- the refusal mask is a single literal in the order path, and the UI's +// dry run tests the whole word, which is what separates the two groups. +enum PathFlag : unsigned { + kShipActionsWillCancel = 0x001u, // advisory: giving this order cancels ship actions + kNodeLegOutOfRange = 0x002u, // the existing node line is beyond remaining fuel + kGateTrafficExceeded = 0x004u, // over the player's gate capacity; the leg becomes None + kCannotInterceptFleet = 0x008u, // REFUSES: target fleet is on a node line we cannot meet + kFleetGrounded = 0x010u, // REFUSES: a ship's drive is destroyed + kNoLineAndCannotBore = 0x020u, // no node line, and the fleet cannot make one + kBoredLineOutOfRange = 0x040u, // a line was made, but the leg is still out of fuel + kBoreFailed = 0x080u, // making the line failed + kDestSystemNotFriendly = 0x100u, // point -> a system owned by no friend of ours + kSourceSystemNotFriend = 0x200u, // a system owned by no friend of ours -> point + kDestPointNotPermitted = 0x400u, // REFUSES: we may not move to that deep-space point + kShipActionEight = 0x800u, // advisory: one particular ship action, called out alone +}; + +// The three bits that make the order fail. Every other bit is shown to the player but does +// not stop the plan being installed -- including the gate-capacity bit, which means an order +// CAN be accepted over capacity, with a None-kind first waypoint. CONFIDENCE: high. +constexpr unsigned kOrderRefusalMask = + kCannotInterceptFleet | kFleetGrounded | kDestPointNotPermitted; + +constexpr bool OrderRefused(unsigned flags) { return (flags & kOrderRefusalMask) != 0u; } + +// --------------------------------------------------------------------------------------- +// Map objects, reduced to what the leg rule reads +// --------------------------------------------------------------------------------------- + +struct Vec3 { double x = 0, y = 0, z = 0; }; + +enum class ObjectKind : int { System = 0, Fleet = 1, Point = 2 }; + +// A destination, source, or intermediate stop. The three shapes share a tag and a position; +// each of the three adds the fields the rule reads for that shape only. +struct MapObject { + ObjectKind kind = ObjectKind::System; + int id = 0; // the network handle; this is what a saved waypoint stores + Vec3 pos{}; + + // --- kind == System --- + int systemIndex = -1; // dense index, used to key the node-line adjacency + bool ownedByUs = false; // the moving player owns it + bool ownerIsFriendly = false;// its owner has a positive relation with the moving player + bool weHaveGateHere = false; // the moving player's gate mask covers it + bool weCanRefuelHere = false;// a tanker of ours is parked here, or the owner permits it + + // --- kind == Point --- + // The two per-player masks a point carries. Either one permits the move, and a + // non-player-race fleet is permitted regardless -- fold that bypass into the first flag. + bool pointVisibleToUs = false; + bool pointKnownToUs = false; + + // --- kind == Fleet --- + bool fleetOnNodeLeg = false; // its current waypoint is a node route + // The system to aim at when we can meet it; unset means the meeting could not be solved. + bool interceptSolved = false; + const MapObject* interceptSystem = nullptr; +}; + +// The moving fleet, reduced likewise. +struct FleetState { + Species ownerSpecies = Species::Human; + bool hasShips = true; + + // Fuel. `rangeRemaining` is drawn down leg by leg and reset to `rangeFull` on reaching a + // system where the fleet may refuel; `tankCapacity` caps whatever range a single leg is + // allowed to claim. + double rangeRemaining = 0.0; + double rangeFull = 0.0; + double tankCapacity = 0.0; + + bool anyShipGrounded = false; // some ship's drive is destroyed + // The two advisory bits come from ONE bitmask of the actions in progress, with the + // single action that gets its own bit taken out first -- so a fleet whose only busy ship + // is on that action raises the second bit and NOT the first. + bool anyShipActingOther = false;// some ship is mid-action, on an action other than "eight" + bool anyShipActionEight = false;// that one action, called out on its own + bool canBoreNodeLines = false; // the fleet carries the capability to make a node line + + // Gate transit accounting. + int gateTrafficCost = 0; // this fleet's own cost, a signed 16-bit field + bool alreadyOnGateLeg = false; // its current waypoint is a gate transit, so it is + // already counted and must not be counted twice + int gateTrafficUsed = 0; // the owner's running total + int gateTrafficCapacity = 0; // gate count times per-gate traffic + double gateProjectionRadius = 0.0; // how far past a gate a fleet can be thrown + + // Where the fleet is parked, when that is a system. Used only by the leading-destination + // drop: ordering a fleet to the system it is already at drops that destination. + const struct MapObject* currentSystem = nullptr; +}; + +// --------------------------------------------------------------------------------------- +// Geometry and fuel +// --------------------------------------------------------------------------------------- + +// The distance a leg costs the fuel budget. +// +// Each component difference is rounded to float32, the three squares are summed at full +// precision and the SUM is rounded once, then the square root is rounded again. Because a +// float32 difference has 24 significand bits, every square and their sum are exact in double, +// so accumulating in double and narrowing once is bit-identical -- but the narrowing of the +// differences is not optional and neither is the one on the root. +// CONFIDENCE: high. +double LegLength(const Vec3& a, const Vec3& b); + +// Whether a leg is within a fleet's fuel. +// +// THE ONE PLACE A FLOATING-POINT DETAIL DECIDES AN OUTCOME. The squared distance is rounded +// to float32; the range is `min(available, tank capacity)` with both candidates read back +// from float32 fields; and then the range is SQUARED AT FULL PRECISION and compared against +// the rounded squared distance. Squaring the range in float32 as well -- the natural mirror +// of every other rounding here -- disagrees exactly at the boundary, which is where a fuel +// check lives. The comparison is inclusive. +// CONFIDENCE: high. +bool LegInRange(double squaredDistanceRange, double rangeAvailable, double tankCapacity); +bool LegInRange(const Vec3& a, const Vec3& b, double rangeAvailable, double tankCapacity); + +// Whether a gate at `from` can throw a fleet as far as `to`. +// +// Requires a positive projection radius, a gate at the source, NO gate at the destination +// (with one at both ends the transit is the ordinary gate-to-gate kind), and a distance +// within the radius, inclusive. The distance is the same two-rounding length as everywhere +// else. CONFIDENCE: high. +bool GateProjectionReaches(const FleetState& f, const MapObject& from, const MapObject& to); + +// --------------------------------------------------------------------------------------- +// One leg +// --------------------------------------------------------------------------------------- + +// What a node-route leg records on its waypoint. Written ONLY for kind 3; every other kind +// leaves the default, and that is a save-visible invariant. +struct NodeRoute { + int pathIndex = -1; // the node line's index, or -1 when the line was just made or an + // endpoint is a deep-space point + int fromId = 0; // network handle of where the leg starts + int toId = 0; // network handle of where it ends +}; + +struct LegResult { + WaypointKind kind = WaypointKind::None; + unsigned flags = 0u; + NodeRoute route{}; // default unless kind == NodeRoute + double rangeAfter = 0.0;// the fuel budget the next leg inherits +}; + +// A caller-supplied view of the node-line graph, because the graph itself is a hash of +// discovered lines that this module does not model. +struct NodeGraph { + virtual ~NodeGraph() = default; + // The path index of a line joining the two systems that the moving player has + // discovered, or -1. The original returns the FIRST match in hash-bucket order, because + // its ranking term turned out not to depend on the candidate -- so an implementation is + // free to return any single match, but must not pretend to rank them. + virtual int FindLine(int systemIndexA, int systemIndexB) const = 0; + // Attempt to make a line. Only reached when the fleet can bore and no line exists. + virtual bool BoreLine(int systemIndexA, int systemIndexB) const { (void)systemIndexA; (void)systemIndexB; return false; } +}; + +// Classify one leg. `rangeIn` is the fuel budget entering the leg; the result carries the +// budget leaving it (this function does not subtract the leg's own length -- the walk does, +// so that the refuel reset lands in the right order). +// +// The decision order, and it matters: +// 1. if the destination is a fleet, try to meet it +// 2. raise the advisory and grounded bits +// 3. if the destination is a point we may not use, refuse it here +// 4. if we have a gate at one end, this is a gate transit -- check capacity, and note that +// a successful gate transit CLEARS the grounded bit, because a gate does not care +// whether the fleet's own drives work +// 5. if the species does not fly the node drive, the leg is simply that species' drive, +// with no range check and no route record +// 6. otherwise solve the single node-line hop +// CONFIDENCE: high. +LegResult ClassifyLeg(const FleetState& f, + const MapObject& from, + const MapObject& to, + double rangeIn, + const NodeGraph* graph); + +// --------------------------------------------------------------------------------------- +// The whole order +// --------------------------------------------------------------------------------------- + +struct PathPlan { + std::vector kinds; // one per destination + std::vector routes; // one per destination + unsigned flags = 0u; + int firstFailingLeg = -1; // index of the first leg that raised anything + bool refused = false; // flags & kOrderRefusalMask + bool droppedLeadingDestination = false; // see the note on SolvePath +}; + +// Walk the destination chain. +// +// `start` is where the first leg begins -- the order path passes the fleet's own position +// object. If the first destination IS the start (the fleet itself, or the system the fleet is +// already parked at) it is DROPPED, and `droppedLeadingDestination` says so. +// +// KNOWN DEFECT IN THE ORIGINAL, reproduced here behind that flag rather than silently fixed: +// the original drops the destination from its own copy of the list only. Its caller still +// builds one waypoint per destination in the UNDROPPED list and pairs waypoint i with kind i, +// so after a drop every kind is shifted by one and the last waypoint gets the kind the output +// array was initialised with, which is None. `kinds` here has one entry per destination in the +// list as passed, filled the way the original fills it -- so a caller that reproduces the +// original's waypoint construction reproduces the bug, and a caller that wants the sane +// behaviour can drop the leading destination itself before calling. +PathPlan SolvePath(const FleetState& f, + const MapObject& start, + const std::vector& dests, + const NodeGraph* graph); + +} // namespace sots::nav diff --git a/tests/game_nav/CMakeLists.txt b/tests/game_nav/CMakeLists.txt new file mode 100644 index 0000000..1a02d54 --- /dev/null +++ b/tests/game_nav/CMakeLists.txt @@ -0,0 +1,6 @@ +# game/nav tests: hand-computed cases for the fleet path-planning rules. +add_executable(game_nav_test_pathplan test_pathplan.cpp) +target_link_libraries(game_nav_test_pathplan PRIVATE sots_game_nav) +target_include_directories(game_nav_test_pathplan PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_compile_options(game_nav_test_pathplan PRIVATE -Wall -Wextra -pedantic) +add_test(NAME game_nav_pathplan COMMAND game_nav_test_pathplan) diff --git a/tests/game_nav/test_pathplan.cpp b/tests/game_nav/test_pathplan.cpp new file mode 100644 index 0000000..76f0812 --- /dev/null +++ b/tests/game_nav/test_pathplan.cpp @@ -0,0 +1,616 @@ +// 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 +#include +#include + +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(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(100.00000762939453f); + const double exactSquare = r * r; + const double narrowedSquare = static_cast(static_cast(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(static_cast(std::sqrt(static_cast( + static_cast(static_cast(static_cast(big + tiny - 0.0)) * + static_cast(static_cast(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 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::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; +}