merge lane P2: nav path classifier (not a finder); waypoint type 2 is the Liir drive; three failure bits

This commit is contained in:
alex 2026-09-08 12:44:40 -04:00
commit 0f9400a238
6 changed files with 1216 additions and 1 deletions

View file

@ -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/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/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/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) add_subdirectory(src/app) # the standalone turn driver (lib sots_app, sots_turn)
# ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ---- # ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ----
@ -120,7 +121,7 @@ else()
add_executable(addr_smoke tests/addr_smoke.cpp) add_executable(addr_smoke tests/addr_smoke.cpp)
target_link_libraries(addr_smoke PRIVATE sots_addresses) target_link_libraries(addr_smoke PRIVATE sots_addresses)
add_test(NAME addr_smoke COMMAND addr_smoke) 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) if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt)
add_subdirectory(tests/${_t}) add_subdirectory(tests/${_t})
endif() endif()

View file

@ -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()

287
src/game/nav/pathplan.cpp Normal file
View file

@ -0,0 +1,287 @@
#include "game/nav/pathplan.h"
#include <cmath>
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<double>(static_cast<float>(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<int>(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<unsigned>(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<const MapObject*>& 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<int>(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

294
src/game/nav/pathplan.h Normal file
View file

@ -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 <cstddef>
#include <vector>
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<WaypointKind> kinds; // one per destination
std::vector<NodeRoute> 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<const MapObject*>& dests,
const NodeGraph* graph);
} // namespace sots::nav

View file

@ -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)

View file

@ -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 <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;
}