sots-engine/tests/game_combat/test_retreat.cpp
alex ae170ecdfe B5: the post-battle retreat rules as a pure planner (game/combat)
The strategic half of a battle: where beaten fleets go, which fleets split,
which are left empty, and who learns the system they were beaten at. Draw-free
end to end -- the whole sub-tree contains no random draw -- so this is a pure
function of its inputs and needs no generator.

What is modelled:
  * the destination search: three independent nearest-system passes (owned /
    no hostile presence / anything), each with its own best-so-far, over
    squared float32 distances with a strict comparison. The independence is
    load-bearing: a nearer system rejected by one predicate must not spoil
    that pass's best, and a single-loop version gets it wrong.
  * the hostility mask, including the rule that a system captured on the
    current turn loses its owner's ceasefire cover.
  * per-ship eligibility: already-departed, encounter-faction exclusions (one
    hard-coded id plus a data-driven bitmask), and the dead-drive gate, which
    tests against a single-precision epsilon rather than zero and which the
    gate species skips because it does not fly out.
  * grouping on all four key words (owner, destination, mode, variant).
  * whole-versus-partial: a fleet runs whole only when every one of its ships
    is in the group; otherwise the group gets one new fleet and the leftover
    ships move into it, while ships of a wholly-retreating fleet stay put.
  * the emptied-fleet list, which matters because destroying a fleet aborts
    every intercept aimed at it.

Deliberately NOT modelled: applying the plan. Creating a fleet mints an object
id from a monotonic counter and appends to the master fleet list, and both of
those are saved state; that belongs above this layer, where the object store
lives. Keeping the decision separate is what makes it host-testable.

53 hand-computed checks. ctest 42/42, clean-room check OK.
2026-09-08 11:52:57 -04:00

348 lines
14 KiB
C++

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