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.
This commit is contained in:
parent
b2bad30f6c
commit
89f5d2f34f
6 changed files with 1216 additions and 1 deletions
|
|
@ -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()
|
||||
|
|
|
|||
11
src/game/nav/CMakeLists.txt
Normal file
11
src/game/nav/CMakeLists.txt
Normal 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
287
src/game/nav/pathplan.cpp
Normal 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
294
src/game/nav/pathplan.h
Normal 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
|
||||
6
tests/game_nav/CMakeLists.txt
Normal file
6
tests/game_nav/CMakeLists.txt
Normal 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)
|
||||
616
tests/game_nav/test_pathplan.cpp
Normal file
616
tests/game_nav/test_pathplan.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue