merge lane P2: nav path classifier (not a finder); waypoint type 2 is the Liir drive; three failure bits
This commit is contained in:
commit
0f9400a238
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