merge lane B5: retreat pipeline (not a combat pipeline); game/combat/retreat planner; OID allocation closed

This commit is contained in:
alex 2026-09-08 12:01:30 -04:00
commit 419cbca824
6 changed files with 948 additions and 1 deletions

View file

@ -29,6 +29,7 @@ add_subdirectory(src/game/data) # typed catalogs on mars/parse+text (lib gam
add_subdirectory(src/game/effects) # tech effects (TechId table + apply) (lib game_effects)
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/app) # the standalone turn driver (lib sots_app, sots_turn)
# ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ----
@ -118,7 +119,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 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 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()

View file

@ -0,0 +1,10 @@
# Combat's strategic consequences. Not the tactical battle -- the part that runs on the
# strategic server once a battle has already been decided, and whose output lands in the
# save file. Pure; no state, no I/O, no random draws.
add_library(sots_game_combat STATIC
retreat.cpp)
target_include_directories(sots_game_combat PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..)
target_compile_features(sots_game_combat PUBLIC cxx_std_17)
if(NOT MSVC)
target_compile_options(sots_game_combat PRIVATE -Wall -Wextra)
endif()

327
src/game/combat/retreat.cpp Normal file
View file

@ -0,0 +1,327 @@
#include "game/combat/retreat.h"
#include <algorithm>
namespace sots::combat {
namespace {
// The original's per-ship "is the drive gone" test compares against the single-precision
// epsilon, not against zero.
constexpr float kDriveHealthEpsilon = 1.1920928955078125e-07f; // FLT_EPSILON
// Highest encounter-type id the blocked-type bitmask is defined over. Ids above this are
// never blocked -- the original range-checks before shifting, which matters because a shift
// count of 32 or more is undefined.
constexpr int kMaxEncounterType = 0x17;
// Squared distance between two positions, reproducing the original's arithmetic exactly.
//
// The three component differences are each narrowed to float32 before use; the products and
// their sum are then accumulated at higher precision and the total is narrowed to float32
// once, at the end. Doing the middle in `double` is not an approximation of the original's
// 80-bit registers: a float32 delta carries 24 significand bits, so its square is exact in
// double, and the sum of three such squares needs at most ~50 bits, also exact. The single
// rounding is the final narrowing, and it happens in the same place.
float SquaredDistance(const Vec3f& a, const Vec3f& b) {
const float dx = a.x - b.x;
const float dy = a.y - b.y;
const float dz = a.z - b.z;
const double sum = (static_cast<double>(dx) * dx + static_cast<double>(dy) * dy) +
static_cast<double>(dz) * dz;
return static_cast<float>(sum);
}
std::uint32_t BitOf(int playerIndex) {
if (playerIndex < 0 || playerIndex >= 32) return 0;
return std::uint32_t{1} << playerIndex;
}
} // namespace
// ---------------------------------------------------------------------------------------
// Destination search
// ---------------------------------------------------------------------------------------
bool IsUsableRetreatTarget(const SystemView& s) {
if (s.destroyed) return false;
if (s.hiveHost && !s.hiveExhausted) return false;
return true;
}
std::uint32_t HostileMask(const PlayerView& p, const SystemView& s, int currentTurn) {
// A system whose owner took it THIS turn does not shelter that owner behind a ceasefire.
std::uint32_t ownerBit = 0;
if (s.ownerPlayer >= 0 && s.turnAcquired == currentTurn) ownerBit = BitOf(s.ownerPlayer);
const std::uint32_t friendly = BitOf(p.index) | (p.diplomacy.ceaseFire & ~ownerBit) |
p.diplomacy.nonAggression | p.diplomacy.alliance;
return ~friendly;
}
bool HasHostilePresence(const PlayerView& p, const SystemView& s, int currentTurn) {
return (HostileMask(p, s, currentTurn) & s.presenceMask) != 0;
}
int ChooseRetreatDestination(const std::vector<SystemView>& systems,
int battleSystemIndex,
const Vec3f& battlePos,
const PlayerView& player,
int currentTurn) {
// Three independent nearest-searches, each with its own best-so-far seeded to FLT_MAX.
// A candidate that fails a predicate leaves that search's best-so-far alone, which is
// the whole reason the three cannot be collapsed into one loop with early exits.
float bestOwned = 3.4028234663852886e+38f; // FLT_MAX
float bestQuiet = 3.4028234663852886e+38f;
float bestAny = 3.4028234663852886e+38f;
int owned = -1, quiet = -1, any = -1;
for (const SystemView& s : systems) {
if (s.index == battleSystemIndex) continue;
if (!IsUsableRetreatTarget(s)) continue;
const float d = SquaredDistance(s.pos, battlePos);
if (d < bestOwned && s.ownerPlayer == player.index) {
bestOwned = d;
owned = s.index;
}
if (d < bestQuiet && !HasHostilePresence(player, s, currentTurn)) {
bestQuiet = d;
quiet = s.index;
}
if (d < bestAny) {
bestAny = d;
any = s.index;
}
}
if (owned >= 0) return owned;
if (quiet >= 0) return quiet;
return any;
}
// ---------------------------------------------------------------------------------------
// Per-ship eligibility
// ---------------------------------------------------------------------------------------
bool IsGroundedByDamage(const ShipView& ship) {
if (!ship.design.hasDriveSectionA && !ship.design.hasDriveSectionB) return false;
if (ship.design.mobilityClass >= 2) return false;
return kDriveHealthEpsilon > ship.driveHealth;
}
bool ShipMayRetreat(const ShipView& ship,
const PlayerView& owner,
std::uint32_t blockedEncounterTypes,
int localPlayerIndex) {
if (ship.departed) return false;
if (ship.encounterType == kNeverRetreatsEncounterType) return false;
if (ship.encounterType >= 0 && ship.encounterType <= kMaxEncounterType &&
(blockedEncounterTypes & (std::uint32_t{1} << ship.encounterType)) != 0) {
return false;
}
// The locally-controlled player's own ships are handled elsewhere and are skipped here.
if (ship.ownerPlayer >= 0 && ship.ownerPlayer == localPlayerIndex) return false;
// The gate species does not fly out, so a dead drive does not ground it.
const bool gating = ship.mode == kGateRetreatMode && owner.species == kGateSpecies;
if (!gating && IsGroundedByDamage(ship)) return false;
return true;
}
// ---------------------------------------------------------------------------------------
// Grouping
// ---------------------------------------------------------------------------------------
std::vector<RetreatGroup> GroupRetreats(const std::vector<ShipView>& ships,
const std::vector<int>& destinationPerShip) {
std::vector<RetreatGroup> groups;
for (std::size_t i = 0; i < ships.size(); ++i) {
const ShipView& sh = ships[i];
const int dest = i < destinationPerShip.size() ? destinationPerShip[i] : -1;
RetreatGroup* found = nullptr;
for (RetreatGroup& g : groups) {
if (g.ownerPlayer == sh.ownerPlayer && g.destinationSystem == dest &&
g.mode == sh.mode && g.variant == sh.variant) {
found = &g;
break;
}
}
if (found == nullptr) {
RetreatGroup g;
g.ownerPlayer = sh.ownerPlayer;
g.destinationSystem = dest;
g.mode = sh.mode;
g.variant = sh.variant;
groups.push_back(g);
found = &groups.back();
}
found->ships.push_back(sh.id);
}
return groups;
}
void ClassifyFleets(RetreatGroup& group,
const std::vector<ShipView>& ships,
const std::vector<FleetView>& fleets) {
// Count this group's ships per source fleet, in first-seen order.
std::vector<std::pair<int, int>> counts; // (fleetId, count)
for (int shipId : group.ships) {
auto sh = std::find_if(ships.begin(), ships.end(),
[shipId](const ShipView& s) { return s.id == shipId; });
if (sh == ships.end()) continue;
auto c = std::find_if(counts.begin(), counts.end(),
[&](const std::pair<int, int>& p) { return p.first == sh->fleetId; });
if (c == counts.end()) {
counts.emplace_back(sh->fleetId, 1);
} else {
++c->second;
}
}
for (const auto& [fleetId, n] : counts) {
auto f = std::find_if(fleets.begin(), fleets.end(),
[id = fleetId](const FleetView& v) { return v.id == id; });
const int total = f == fleets.end() ? -1 : f->shipCount;
if (total == n) {
group.wholeFleets.push_back(fleetId);
} else {
group.partial = true;
}
}
}
bool UsesGateRetreat(const RetreatGroup& group, const PlayerView& owner) {
if (group.mode != kGateRetreatMode) return false;
if (owner.species != kGateSpecies) return false;
return group.destinationSystem >= 0;
}
// ---------------------------------------------------------------------------------------
// The plan
// ---------------------------------------------------------------------------------------
RetreatPlan BuildRetreatPlan(const std::vector<SystemView>& systems,
const std::vector<FleetView>& fleets,
const std::vector<ShipView>& ships,
const std::vector<PlayerView>& players,
int battleSystemIndex,
const Vec3f& battlePos,
int currentTurn,
std::uint32_t blockedEncounterTypes,
int localPlayerIndex,
const std::vector<std::uint32_t>& exploredMaskPerSystem) {
RetreatPlan plan;
auto playerOf = [&players](int idx) -> const PlayerView* {
for (const PlayerView& p : players) {
if (p.index == idx) return &p;
}
return nullptr;
};
// Phase 1: one default destination per player, computed once and shared.
std::vector<std::pair<int, int>> defaultDest; // (playerIndex, systemIndex)
for (const PlayerView& p : players) {
defaultDest.emplace_back(
p.index, ChooseRetreatDestination(systems, battleSystemIndex, battlePos, p, currentTurn));
}
auto defaultFor = [&defaultDest](int idx) {
for (const auto& [pi, si] : defaultDest) {
if (pi == idx) return si;
}
return -1;
};
// Phase 2: filter, resolve each ship's destination, group.
std::vector<ShipView> eligible;
std::vector<int> dests;
for (const ShipView& sh : ships) {
const PlayerView* owner = playerOf(sh.ownerPlayer);
if (owner == nullptr) continue;
if (!ShipMayRetreat(sh, *owner, blockedEncounterTypes, localPlayerIndex)) continue;
// A named destination wins; otherwise the owner's default. A named destination that
// does not resolve falls back to the default too (and logs, in the original).
int d = sh.requestedDestination;
if (d < 0) d = defaultFor(sh.ownerPlayer);
eligible.push_back(sh);
dests.push_back(d);
}
plan.groups = GroupRetreats(eligible, dests);
// Phase 3 / 4: whole versus partial, and the split.
std::vector<std::pair<int, int>> movedOut; // (fleetId, ships taken away)
for (RetreatGroup& g : plan.groups) {
ClassifyFleets(g, eligible, fleets);
if (g.partial) {
g.needsNewFleet = true;
for (int shipId : g.ships) {
auto sh = std::find_if(eligible.begin(), eligible.end(),
[shipId](const ShipView& s) { return s.id == shipId; });
if (sh == eligible.end()) continue;
// A ship whose own fleet is retreating whole stays exactly where it is.
const bool wholeFleetRuns =
std::find(g.wholeFleets.begin(), g.wholeFleets.end(), sh->fleetId) !=
g.wholeFleets.end();
if (wholeFleetRuns) continue;
auto sp = std::find_if(g.splits.begin(), g.splits.end(),
[&](const FleetSplit& s) { return s.sourceFleet == sh->fleetId; });
if (sp == g.splits.end()) {
FleetSplit s;
s.sourceFleet = sh->fleetId;
s.ships.push_back(shipId);
g.splits.push_back(s);
} else {
sp->ships.push_back(shipId);
}
auto m = std::find_if(movedOut.begin(), movedOut.end(),
[&](const std::pair<int, int>& p) { return p.first == sh->fleetId; });
if (m == movedOut.end()) {
movedOut.emplace_back(sh->fleetId, 1);
} else {
++m->second;
}
}
}
const PlayerView* owner = playerOf(g.ownerPlayer);
if (owner == nullptr) {
g.move = RetreatMove::None;
} else if (UsesGateRetreat(g, *owner)) {
g.move = RetreatMove::Gate;
} else if (g.destinationSystem >= 0) {
g.move = RetreatMove::FlightPlan;
} else {
g.move = RetreatMove::None;
}
// Phase 5's side effect: leaving a system you had not explored reveals it.
if (battleSystemIndex >= 0 && g.ownerPlayer >= 0 &&
static_cast<std::size_t>(battleSystemIndex) < exploredMaskPerSystem.size()) {
const std::uint32_t explored = exploredMaskPerSystem[battleSystemIndex];
if ((explored & BitOf(g.ownerPlayer)) == 0 &&
std::find(plan.exploredGrants.begin(), plan.exploredGrants.end(), g.ownerPlayer) ==
plan.exploredGrants.end()) {
plan.exploredGrants.push_back(g.ownerPlayer);
}
}
}
// A source fleet that gives up every ship it had is destroyed. Fleets that ran whole are
// not affected -- they keep their ships and simply move.
for (const auto& [fleetId, taken] : movedOut) {
auto f = std::find_if(fleets.begin(), fleets.end(),
[id = fleetId](const FleetView& v) { return v.id == id; });
if (f != fleets.end() && f->shipCount == taken) plan.emptiedFleets.push_back(fleetId);
}
return plan;
}
} // namespace sots::combat

255
src/game/combat/retreat.h Normal file
View file

@ -0,0 +1,255 @@
// Retreat: what happens to the losers' ships after a battle resolves.
//
// This is the first of the post-battle sub-phases and it is the one with strategic
// consequences: it decides where beaten fleets go, splits fleets whose ships did not all
// run, deletes fleets that end up empty, and grants the retreating side knowledge of the
// system it was beaten at. All of that lands in saved state.
//
// It is DRAW-FREE. The whole sub-tree contains no random draw of any kind, so this module
// needs no generator and its output is a pure function of its input.
//
// Everything here is a PLAN, not a mutation. The functions below decide *what* should
// happen; applying it -- creating a fleet, reparenting a ship, deleting an emptied fleet --
// needs an object store with id allocation, which lives above this layer. Keeping the two
// apart is what makes the decision testable on the host.
//
// CONFIDENCE: the destination search, the eligibility gates, the grouping key and the
// whole-versus-partial rule are all read from the instruction stream. See the campaign note
// on the retreat pipeline for the per-claim breakdown.
#pragma once
#include <cstdint>
#include <vector>
namespace sots::combat {
// ------------------------------------------------------------------------------------
// Inputs
// ------------------------------------------------------------------------------------
// Positions are float32 in the original and the destination search compares SQUARED
// distances accumulated in float32. Keeping the type narrow here is deliberate: widening it
// changes which system wins a near-tie.
struct Vec3f {
float x = 0.0f, y = 0.0f, z = 0.0f;
};
// A system as the retreat search sees it. Every field is a saved one.
struct SystemView {
int index = -1; // the system's own index
Vec3f pos{}; // system position
bool destroyed = false; // system was destroyed
bool hiveHost = false; // a self-replicator hive is present
bool hiveExhausted = false; // ...and it has been cleared out
int ownerPlayer = -1; // -1 when unowned
int turnAcquired = -1; // the turn the current owner took it
std::uint32_t presenceMask = 0; // bit per player judged "present" here
};
// The three per-player diplomacy bitmasks. `self` is excluded from hostility implicitly.
struct Diplomacy {
std::uint32_t alliance = 0;
std::uint32_t nonAggression = 0;
std::uint32_t ceaseFire = 0;
};
// A player, only as much of one as the retreat rules read.
struct PlayerView {
int index = -1; // player index, the bit position in every mask above
int species = -1; // species id; species 1 retreats by gate
Diplomacy diplomacy{};
};
// Species that retreats by opening a gate at the destination instead of flying home.
constexpr int kGateSpecies = 1;
// The retreat "mode" word that selects the gate path. Any other value flies.
constexpr int kGateRetreatMode = 1;
// A ship's design, only the fields the eligibility gate reads.
struct DesignView {
bool hasDriveSectionA = false; // one of two design slots that must be populated
bool hasDriveSectionB = false;
int mobilityClass = 0; // >= 2 means the design is never grounded this way
};
// One ship the battle reported as trying to leave.
struct ShipView {
int id = -1;
int fleetId = -1; // the fleet it is in right now
int ownerPlayer = -1;
bool departed = false; // already left the system under a previous order
int encounterType = -1; // which encounter faction it belongs to, -1 for a normal ship
DesignView design{};
float driveHealth = 0.0f;
int requestedDestination = -1; // system index the battle asked for, -1 for none
int mode = 0; // retreat mode word
int variant = 0; // second grouping word, carried through untouched
};
// A fleet, only its size (the whole-versus-partial rule needs nothing else).
struct FleetView {
int id = -1;
int shipCount = 0;
int ownerPlayer = -1;
};
// ------------------------------------------------------------------------------------
// Destination search
// ------------------------------------------------------------------------------------
// Systems that can never be retreated to at all: a destroyed system, or one hosting a
// self-replicator hive that has not been cleared. CONFIDENCE: high.
bool IsUsableRetreatTarget(const SystemView& s);
// The mask of players `p` counts as hostile to, evaluated against one system.
//
// hostile = ~( selfBit | (ceaseFire & ~ownerBit) | nonAggression | alliance )
//
// where `ownerBit` is the system owner's bit, but ONLY when that owner took the system on
// the current turn -- a system captured this turn loses its ceasefire cover. Unowned
// systems, and systems held since an earlier turn, contribute no ownerBit.
//
// The complement is taken over the full 32 bits, so bits above the player count are set.
// That is harmless because it is only ever ANDed with a presence mask, which never has
// them, but a reimplementation that narrows the mask must narrow both sides together.
// CONFIDENCE: high.
std::uint32_t HostileMask(const PlayerView& p, const SystemView& s, int currentTurn);
// True when any player hostile to `p` is present at `s`. CONFIDENCE: high.
bool HasHostilePresence(const PlayerView& p, const SystemView& s, int currentTurn);
// The default retreat destination for one player, given where the battle happened.
//
// Three independent nearest-system searches run over the whole system list in one pass,
// each keeping its own best-so-far, all skipping the battle's own system and any system
// `IsUsableRetreatTarget` rejects:
//
// 1. nearest system the player owns
// 2. nearest system with no hostile presence
// 3. nearest system, unconditionally
//
// The answer is the first of those that found anything. Note that a candidate failing a
// predicate does NOT spoil that search's best-so-far -- the three are genuinely independent,
// which is why (2) can name a system further away than one (1) rejected.
//
// Distance is the SQUARED euclidean distance, accumulated in float32, and the comparison is
// a strict `<`, so on an exact tie the earlier system in the list wins. Returns -1 when the
// list yields nothing. CONFIDENCE: high.
int ChooseRetreatDestination(const std::vector<SystemView>& systems,
int battleSystemIndex,
const Vec3f& battlePos,
const PlayerView& player,
int currentTurn);
// ------------------------------------------------------------------------------------
// Per-ship eligibility
// ------------------------------------------------------------------------------------
// True when a ship is too badly hurt to run on its own: its design has a drive section, its
// mobility class is below 2, and its drive health has fallen to (effectively) zero -- the
// comparison is against a single-precision epsilon, so a health of exactly 0 is grounded
// and anything at or above the epsilon is not.
//
// The gate species skips this test entirely; it does not fly out, so a dead drive does not
// stop it. CONFIDENCE: high.
bool IsGroundedByDamage(const ShipView& ship);
// Encounter-faction ids that may never retreat. The original reads a data-driven bitmask
// here plus one hard-coded id; `blockedEncounterTypes` is that bitmask, and the hard-coded
// exclusion is folded in by the caller supplying it. DB-SOURCED -- do not hard-code the
// mask in a reimplementation, load it.
constexpr int kNeverRetreatsEncounterType = 0x15;
// Whether a reported ship is actually allowed to retreat. `blockedEncounterTypes` is the
// data-driven bitmask above; bits are indexed by encounter type and only types 0..23 are
// tested. CONFIDENCE: high.
bool ShipMayRetreat(const ShipView& ship,
const PlayerView& owner,
std::uint32_t blockedEncounterTypes,
int localPlayerIndex);
// ------------------------------------------------------------------------------------
// Grouping and the plan
// ------------------------------------------------------------------------------------
// How a group leaves.
enum class RetreatMove {
None, // no destination was resolved; nothing moves and a warning is logged
Gate, // relocated to the destination at once, and an event is posted
FlightPlan, // given a move order and detached from the system
};
// Ships pulled out of one fleet because only part of that fleet ran.
struct FleetSplit {
int sourceFleet = -1;
std::vector<int> ships{};
};
// One retreat group. Groups are keyed by (owner, destination, mode, variant) -- four words,
// all four compared -- so one player retreating to two places in one battle produces two
// groups, and so does one player retreating to one place by two different modes.
struct RetreatGroup {
int ownerPlayer = -1;
int destinationSystem = -1;
int mode = 0;
int variant = 0;
std::vector<int> ships{}; // every eligible ship, in report order
std::vector<int> wholeFleets{}; // fleets every one of whose ships is in `ships`
bool partial = false; // at least one fleet ran only in part
// Filled by the planner:
bool needsNewFleet = false; // a fleet must be created for the split-off ships
std::vector<FleetSplit> splits{};
RetreatMove move = RetreatMove::None;
};
// A fleet that ends up with no ships left and is therefore destroyed. Destroying a fleet
// also aborts every intercept order aimed at it, which posts an event to each interceptor's
// owner -- that is why this list matters outside this module.
struct RetreatPlan {
std::vector<RetreatGroup> groups{};
std::vector<int> emptiedFleets{}; // fleets left with zero ships, to be destroyed
std::vector<int> exploredGrants{}; // players who learn the battle system by leaving it
};
// Assign each eligible ship to its group. `destinationFor` is consulted per ship: a ship
// that named a destination gets it, otherwise the owner's default from
// `ChooseRetreatDestination`. Groups appear in first-seen order. CONFIDENCE: high.
std::vector<RetreatGroup> GroupRetreats(const std::vector<ShipView>& ships,
const std::vector<int>& destinationPerShip);
// Decide, per group, which source fleets ran whole and which ran only in part.
// A fleet runs whole when the number of its ships in the group equals its total ship count.
// CONFIDENCE: high.
void ClassifyFleets(RetreatGroup& group,
const std::vector<ShipView>& ships,
const std::vector<FleetView>& fleets);
// Whether a group leaves by gate: mode word equal to the gate mode, owner of the gate
// species, and a destination actually resolved. CONFIDENCE: high.
bool UsesGateRetreat(const RetreatGroup& group, const PlayerView& owner);
// The whole thing. `battleSystemIndex` is -1 when the battle was not at a system, which
// suppresses both the explore grant and the "put the new fleet here" step.
//
// The split rule, stated exactly because it is easy to get subtly wrong: a group that has
// `partial` set gets ONE new fleet, appended to `wholeFleets` before any ship is moved. A
// ship is then moved into it only if its own source fleet is not already in that list --
// which is what keeps ships belonging to a wholly-retreating fleet where they are. A source
// fleet that loses all of its group's ships and had none left over is, by construction,
// a whole fleet and is never split.
// CONFIDENCE: high on the rule; the emptied-fleet list is a consequence of it.
RetreatPlan BuildRetreatPlan(const std::vector<SystemView>& systems,
const std::vector<FleetView>& fleets,
const std::vector<ShipView>& ships,
const std::vector<PlayerView>& players,
int battleSystemIndex,
const Vec3f& battlePos,
int currentTurn,
std::uint32_t blockedEncounterTypes,
int localPlayerIndex,
const std::vector<std::uint32_t>& exploredMaskPerSystem);
} // namespace sots::combat

View file

@ -0,0 +1,6 @@
# game/combat tests: hand-computed cases for the post-battle retreat rules.
add_executable(game_combat_test_retreat test_retreat.cpp)
target_link_libraries(game_combat_test_retreat PRIVATE sots_game_combat)
target_include_directories(game_combat_test_retreat PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(game_combat_test_retreat PRIVATE -Wall -Wextra -pedantic)
add_test(NAME game_combat_retreat COMMAND game_combat_test_retreat)

View file

@ -0,0 +1,348 @@
// Hand-computed cases for the post-battle retreat rules.
//
// Every expected value here was worked out from the rule, not from running the code, and
// each case is annotated with which rule it pins. The three that are most worth keeping are
// the independent-best-so-far case (a nearest-search that a naive single-loop version gets
// wrong), the whole-versus-partial split, and the ceasefire-lapses-on-capture case.
#include "game/combat/retreat.h"
#include <algorithm>
#include <cstdio>
#include <string>
using namespace sots::combat;
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);
}
}
void check_eq(int got, int expected, const char* what) {
++g_checks;
if (got != expected) {
++g_fails;
std::fprintf(stderr, "FAIL: %s -- got %d, expected %d\n", what, got, expected);
}
}
SystemView Sys(int index, float x, int owner = -1) {
SystemView s;
s.index = index;
s.pos = Vec3f{x, 0.0f, 0.0f};
s.ownerPlayer = owner;
return s;
}
PlayerView Plr(int index, int species = 0) {
PlayerView p;
p.index = index;
p.species = species;
return p;
}
// ---------------------------------------------------------------------------------------
void TestUsableTarget() {
SystemView s = Sys(0, 0.0f);
check(IsUsableRetreatTarget(s), "a plain system is a usable retreat target");
s.destroyed = true;
check(!IsUsableRetreatTarget(s), "a destroyed system is not");
s = Sys(0, 0.0f);
s.hiveHost = true;
check(!IsUsableRetreatTarget(s), "a system with a live hive is not");
s.hiveExhausted = true;
check(IsUsableRetreatTarget(s), "...but a cleared hive lifts the exclusion");
}
void TestHostileMask() {
PlayerView p = Plr(0);
p.diplomacy.alliance = 0b0010u; // player 1 allied
p.diplomacy.nonAggression = 0b0100u; // player 2 under NAP
p.diplomacy.ceaseFire = 0b1000u; // player 3 under ceasefire
SystemView s = Sys(0, 0.0f, /*owner=*/-1);
const std::uint32_t m = HostileMask(p, s, /*currentTurn=*/10);
check((m & 0b0001u) == 0, "self is never hostile");
check((m & 0b0010u) == 0, "an ally is not hostile");
check((m & 0b0100u) == 0, "a NAP partner is not hostile");
check((m & 0b1000u) == 0, "a ceasefire partner is not hostile");
check((m & 0b10000u) != 0, "an unrelated player is hostile");
// The rule this case exists for: a system its owner took THIS turn loses ceasefire
// cover, so the owner counts as hostile there and nowhere else.
SystemView fresh = Sys(1, 1.0f, /*owner=*/3);
fresh.turnAcquired = 10;
check((HostileMask(p, fresh, 10) & 0b1000u) != 0,
"a ceasefire partner IS hostile at a system it captured this turn");
SystemView old = Sys(2, 2.0f, /*owner=*/3);
old.turnAcquired = 4;
check((HostileMask(p, old, 10) & 0b1000u) == 0,
"...but not at one it has held since an earlier turn");
}
void TestDestinationPrefersOwned() {
// Battle at x = 0. Systems at 1 (neutral), 5 (ours), 2 (neutral). Owned wins even
// though it is furthest.
std::vector<SystemView> sys{Sys(10, 1.0f), Sys(11, 5.0f, /*owner=*/0), Sys(12, 2.0f)};
check_eq(ChooseRetreatDestination(sys, /*battleSystemIndex=*/99, Vec3f{0, 0, 0}, Plr(0), 1),
11, "the nearest OWNED system wins over nearer neutral ones");
}
void TestDestinationIndependentBests() {
// This is the case a single-loop implementation gets wrong.
//
// Battle at x = 0, player 0 owns nothing. Hostile player 1 is present at x = 1.
// x = 1 hostile present, unowned -> only search 3 accepts it
// x = 3 quiet, unowned -> searches 2 and 3
// Search 2's best-so-far must NOT have been spoiled by the nearer hostile system, so
// the answer is 3.0's system. A naive "keep one best, then filter" gives -1 or the
// wrong id.
std::vector<SystemView> sys{Sys(20, 1.0f), Sys(21, 3.0f)};
sys[0].presenceMask = 0b0010u; // player 1 present
check_eq(ChooseRetreatDestination(sys, 99, Vec3f{0, 0, 0}, Plr(0), 1), 21,
"a nearer hostile system does not spoil the quiet-system search");
// And with every system hostile, search 3 still answers with the nearest of them.
sys[1].presenceMask = 0b0010u;
check_eq(ChooseRetreatDestination(sys, 99, Vec3f{0, 0, 0}, Plr(0), 1), 20,
"with nowhere quiet, the unconditional nearest wins");
}
void TestDestinationSkipsAndTies() {
std::vector<SystemView> sys{Sys(30, 1.0f), Sys(31, 1.0f)};
check_eq(ChooseRetreatDestination(sys, 99, Vec3f{0, 0, 0}, Plr(0), 1), 30,
"an exact tie goes to the earlier system (the comparison is strict <)");
// The battle's own system is never a destination.
std::vector<SystemView> two{Sys(40, 1.0f), Sys(41, 9.0f)};
check_eq(ChooseRetreatDestination(two, /*battleSystemIndex=*/40, Vec3f{0, 0, 0}, Plr(0), 1),
41, "the battle's own system is excluded");
check_eq(ChooseRetreatDestination({}, 99, Vec3f{0, 0, 0}, Plr(0), 1), -1,
"an empty galaxy yields no destination");
}
void TestShipEligibility() {
ShipView sh;
sh.id = 1;
sh.ownerPlayer = 0;
sh.design.hasDriveSectionA = true;
sh.design.mobilityClass = 0;
sh.driveHealth = 1.0f;
const PlayerView owner = Plr(0);
check(ShipMayRetreat(sh, owner, 0, /*localPlayerIndex=*/7), "a healthy ship may retreat");
ShipView dead = sh;
dead.driveHealth = 0.0f;
check(IsGroundedByDamage(dead), "zero drive health grounds a ship");
check(!ShipMayRetreat(dead, owner, 0, 7), "...and it therefore may not retreat");
// Exactly at the epsilon is NOT grounded: the test is `epsilon > health`.
ShipView edge = sh;
edge.driveHealth = 1.1920928955078125e-07f;
check(!IsGroundedByDamage(edge), "drive health exactly at the epsilon is not grounded");
// The gate species does not fly out, so a dead drive does not stop it.
ShipView hiver = dead;
hiver.mode = kGateRetreatMode;
check(ShipMayRetreat(hiver, Plr(0, kGateSpecies), 0, 7),
"the gate species retreats with a dead drive");
check(!ShipMayRetreat(hiver, Plr(0, /*species=*/2), 0, 7),
"...but only the gate species does");
ShipView gone = sh;
gone.departed = true;
check(!ShipMayRetreat(gone, owner, 0, 7), "a ship that already left is skipped");
ShipView monster = sh;
monster.encounterType = kNeverRetreatsEncounterType;
check(!ShipMayRetreat(monster, owner, 0, 7), "the hard-excluded encounter type never retreats");
ShipView blocked = sh;
blocked.encounterType = 6;
check(!ShipMayRetreat(blocked, owner, /*blockedEncounterTypes=*/1u << 6, 7),
"a data-blocked encounter type never retreats");
check(ShipMayRetreat(blocked, owner, /*blockedEncounterTypes=*/1u << 5, 7),
"...and an unrelated bit does not block it");
ShipView mine = sh;
mine.ownerPlayer = 7;
check(!ShipMayRetreat(mine, Plr(7), 0, /*localPlayerIndex=*/7),
"the locally-controlled player's ships are skipped here");
}
void TestGrouping() {
std::vector<ShipView> ships(4);
for (int i = 0; i < 4; ++i) ships[i].id = i;
ships[0].ownerPlayer = 0; ships[0].mode = 0; ships[0].variant = 0;
ships[1].ownerPlayer = 0; ships[1].mode = 0; ships[1].variant = 0;
ships[2].ownerPlayer = 0; ships[2].mode = 1; ships[2].variant = 0; // different mode
ships[3].ownerPlayer = 1; ships[3].mode = 0; ships[3].variant = 0; // different owner
auto groups = GroupRetreats(ships, {5, 5, 5, 5});
check_eq(static_cast<int>(groups.size()), 3,
"groups split on owner and on mode, not just on destination");
check_eq(static_cast<int>(groups[0].ships.size()), 2, "the first group holds both same-key ships");
// Same owner, same mode, different destination -> two groups.
auto split = GroupRetreats({ships[0], ships[1]}, {5, 6});
check_eq(static_cast<int>(split.size()), 2, "groups split on destination too");
}
void TestWholeVersusPartial() {
// Fleet 100 has 2 ships and both run; fleet 200 has 3 ships and only 1 runs.
std::vector<FleetView> fleets{{100, 2, 0}, {200, 3, 0}};
std::vector<ShipView> ships(3);
for (std::size_t i = 0; i < ships.size(); ++i) {
ships[i].id = static_cast<int>(i);
ships[i].ownerPlayer = 0;
ships[i].design.hasDriveSectionA = true;
ships[i].driveHealth = 1.0f;
}
ships[0].fleetId = 100;
ships[1].fleetId = 100;
ships[2].fleetId = 200;
RetreatGroup g;
g.ownerPlayer = 0;
g.ships = {0, 1, 2};
ClassifyFleets(g, ships, fleets);
check_eq(static_cast<int>(g.wholeFleets.size()), 1, "one fleet ran whole");
check_eq(g.wholeFleets[0], 100, "...and it is the fleet that lost every ship");
check(g.partial, "the other fleet ran only in part");
}
void TestPlanSplitsAndEmpties() {
std::vector<SystemView> sys{Sys(1, 10.0f, /*owner=*/0)};
std::vector<FleetView> fleets{{100, 2, 0}, {200, 3, 0}};
std::vector<ShipView> ships(3);
for (std::size_t i = 0; i < ships.size(); ++i) {
ships[i].id = static_cast<int>(i);
ships[i].ownerPlayer = 0;
ships[i].design.hasDriveSectionA = true;
ships[i].driveHealth = 1.0f;
ships[i].requestedDestination = -1;
}
ships[0].fleetId = 100;
ships[1].fleetId = 100;
ships[2].fleetId = 200;
const RetreatPlan plan =
BuildRetreatPlan(sys, fleets, ships, {Plr(0)}, /*battleSystemIndex=*/0,
Vec3f{0, 0, 0}, /*currentTurn=*/3, /*blockedEncounterTypes=*/0,
/*localPlayerIndex=*/7, /*exploredMaskPerSystem=*/{});
check_eq(static_cast<int>(plan.groups.size()), 1, "all three ships share one group");
const RetreatGroup& g = plan.groups[0];
check_eq(g.destinationSystem, 1, "they retreat to the player's own system");
check(g.needsNewFleet, "a partial retreat needs a new fleet");
check_eq(static_cast<int>(g.splits.size()), 1, "exactly one fleet is split");
check_eq(g.splits[0].sourceFleet, 200, "and it is the partially-retreating one");
check_eq(static_cast<int>(g.splits[0].ships.size()), 1, "one ship moves out of it");
check(std::find(g.wholeFleets.begin(), g.wholeFleets.end(), 100) != g.wholeFleets.end(),
"the wholly-retreating fleet moves intact and is NOT split");
check(plan.emptiedFleets.empty(), "no fleet was emptied -- fleet 200 keeps two ships");
check(g.move == RetreatMove::FlightPlan, "a non-gate species is given a move order");
}
void TestPlanEmptiesAFleet() {
// Fleet 300 has 1 ship; fleet 400 has 2, and one of 400's ships retreats to a DIFFERENT
// destination, which puts the two ships in different groups -- so neither group sees
// 400 as whole, both split it, and it ends up empty.
std::vector<SystemView> sys{Sys(1, 10.0f, 0), Sys(2, 20.0f, 0)};
std::vector<FleetView> fleets{{400, 2, 0}};
std::vector<ShipView> ships(2);
for (std::size_t i = 0; i < ships.size(); ++i) {
ships[i].id = static_cast<int>(i);
ships[i].ownerPlayer = 0;
ships[i].fleetId = 400;
ships[i].design.hasDriveSectionA = true;
ships[i].driveHealth = 1.0f;
}
ships[0].requestedDestination = 1;
ships[1].requestedDestination = 2;
const RetreatPlan plan = BuildRetreatPlan(sys, fleets, ships, {Plr(0)}, 0, Vec3f{0, 0, 0},
3, 0, 7, {});
check_eq(static_cast<int>(plan.groups.size()), 2, "two destinations make two groups");
check_eq(static_cast<int>(plan.emptiedFleets.size()), 1,
"a fleet that gives up every ship across both groups is emptied");
check_eq(plan.emptiedFleets[0], 400, "...and it is fleet 400");
}
void TestExploreGrant() {
std::vector<SystemView> sys{Sys(0, 0.0f), Sys(1, 10.0f, 0)};
std::vector<FleetView> fleets{{100, 1, 0}};
std::vector<ShipView> ships(1);
ships[0].id = 0;
ships[0].fleetId = 100;
ships[0].ownerPlayer = 0;
ships[0].design.hasDriveSectionA = true;
ships[0].driveHealth = 1.0f;
const RetreatPlan unexplored =
BuildRetreatPlan(sys, fleets, ships, {Plr(0)}, /*battleSystemIndex=*/0, Vec3f{0, 0, 0},
3, 0, 7, /*exploredMaskPerSystem=*/{0u, 0u});
check_eq(static_cast<int>(unexplored.exploredGrants.size()), 1,
"retreating from an unexplored system reveals it");
const RetreatPlan explored =
BuildRetreatPlan(sys, fleets, ships, {Plr(0)}, 0, Vec3f{0, 0, 0}, 3, 0, 7, {0b1u, 0u});
check(explored.exploredGrants.empty(), "...and does nothing when it was already explored");
const RetreatPlan deepSpace =
BuildRetreatPlan(sys, fleets, ships, {Plr(0)}, /*battleSystemIndex=*/-1, Vec3f{0, 0, 0},
3, 0, 7, {0u, 0u});
check(deepSpace.exploredGrants.empty(), "a battle away from any system grants nothing");
}
void TestGateRetreat() {
RetreatGroup g;
g.mode = kGateRetreatMode;
g.destinationSystem = 5;
check(UsesGateRetreat(g, Plr(0, kGateSpecies)), "gate species + gate mode + a destination");
check(!UsesGateRetreat(g, Plr(0, /*species=*/3)), "a different species flies instead");
g.mode = 0;
check(!UsesGateRetreat(g, Plr(0, kGateSpecies)), "a different mode flies instead");
g.mode = kGateRetreatMode;
g.destinationSystem = -1;
check(!UsesGateRetreat(g, Plr(0, kGateSpecies)), "no destination means no gate");
}
} // namespace
int main() {
TestUsableTarget();
TestHostileMask();
TestDestinationPrefersOwned();
TestDestinationIndependentBests();
TestDestinationSkipsAndTies();
TestShipEligibility();
TestGrouping();
TestWholeVersusPartial();
TestPlanSplitsAndEmpties();
TestPlanEmptiesAFleet();
TestExploreGrant();
TestGateRetreat();
std::printf("game_combat/retreat: %d checks, %d failures\n", g_checks, g_fails);
return g_fails == 0 ? 0 : 1;
}