sots-engine/src/game/nav/pathplan.h
alex 89f5d2f34f lane P2: fleet path planning (game/nav)
The strategic layer does not search for a route: the player or the AI picks the
destinations and the engine classifies each consecutive pair, deciding the waypoint
kind and whether the order is legal. This models that classifier as pure functions.

The waypoint kind of any leg that is neither a gate transit nor a node route is a
pure function of the owning species -- which is the whole answer to why kind 2 has
never been observed. Kind 2 is the Liir drive; the two node-drive races are Human
and Zuul, both of which map to kind 3, and every observation so far was taken on
one of those two.

Also modelled: the three refusal bits versus the nine advisory ones, the gate
transit that waives the grounded-fleet refusal, the projection radius that splits
gate-to-gate from gate-to-gateless, the single-hop node line lookup and bore, and
the fuel check whose range is squared at full precision while the distance is
narrowed -- the one floating-point asymmetry here that flips a decision.

The leading-destination drop is reproduced with its original off-by-one behind an
explicit flag rather than silently fixed.

120 hand-computed checks. Host ctest 43/43; clean-room check OK.
2026-09-08 12:43:11 -04:00

294 lines
16 KiB
C++

// 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