// 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 #include #include 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 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 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 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 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 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(groups.size()), 3, "groups split on owner and on mode, not just on destination"); check_eq(static_cast(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(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 fleets{{100, 2, 0}, {200, 3, 0}}; std::vector ships(3); for (std::size_t i = 0; i < ships.size(); ++i) { ships[i].id = static_cast(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(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 sys{Sys(1, 10.0f, /*owner=*/0)}; std::vector fleets{{100, 2, 0}, {200, 3, 0}}; std::vector ships(3); for (std::size_t i = 0; i < ships.size(); ++i) { ships[i].id = static_cast(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(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(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(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 sys{Sys(1, 10.0f, 0), Sys(2, 20.0f, 0)}; std::vector fleets{{400, 2, 0}}; std::vector ships(2); for (std::size_t i = 0; i < ships.size(); ++i) { ships[i].id = static_cast(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(plan.groups.size()), 2, "two destinations make two groups"); check_eq(static_cast(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 sys{Sys(0, 0.0f), Sys(1, 10.0f, 0)}; std::vector fleets{{100, 1, 0}}; std::vector 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(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; }