diff --git a/CMakeLists.txt b/CMakeLists.txt index cf29fae..927ac9a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/src/game/combat/CMakeLists.txt b/src/game/combat/CMakeLists.txt new file mode 100644 index 0000000..1ac8ee6 --- /dev/null +++ b/src/game/combat/CMakeLists.txt @@ -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() diff --git a/src/game/combat/retreat.cpp b/src/game/combat/retreat.cpp new file mode 100644 index 0000000..f075df9 --- /dev/null +++ b/src/game/combat/retreat.cpp @@ -0,0 +1,327 @@ +#include "game/combat/retreat.h" + +#include + +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(dx) * dx + static_cast(dy) * dy) + + static_cast(dz) * dz; + return static_cast(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& 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 GroupRetreats(const std::vector& ships, + const std::vector& destinationPerShip) { + std::vector 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& ships, + const std::vector& fleets) { + // Count this group's ships per source fleet, in first-seen order. + std::vector> 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& 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& systems, + const std::vector& fleets, + const std::vector& ships, + const std::vector& players, + int battleSystemIndex, + const Vec3f& battlePos, + int currentTurn, + std::uint32_t blockedEncounterTypes, + int localPlayerIndex, + const std::vector& 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> 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 eligible; + std::vector 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> 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& 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(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 diff --git a/src/game/combat/retreat.h b/src/game/combat/retreat.h new file mode 100644 index 0000000..ca9603d --- /dev/null +++ b/src/game/combat/retreat.h @@ -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 +#include + +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& 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 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 ships{}; // every eligible ship, in report order + std::vector 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 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 groups{}; + std::vector emptiedFleets{}; // fleets left with zero ships, to be destroyed + std::vector 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 GroupRetreats(const std::vector& ships, + const std::vector& 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& ships, + const std::vector& 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& systems, + const std::vector& fleets, + const std::vector& ships, + const std::vector& players, + int battleSystemIndex, + const Vec3f& battlePos, + int currentTurn, + std::uint32_t blockedEncounterTypes, + int localPlayerIndex, + const std::vector& exploredMaskPerSystem); + +} // namespace sots::combat diff --git a/tests/game_combat/CMakeLists.txt b/tests/game_combat/CMakeLists.txt new file mode 100644 index 0000000..e5e202a --- /dev/null +++ b/tests/game_combat/CMakeLists.txt @@ -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) diff --git a/tests/game_combat/test_retreat.cpp b/tests/game_combat/test_retreat.cpp new file mode 100644 index 0000000..76c7fc9 --- /dev/null +++ b/tests/game_combat/test_retreat.cpp @@ -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 +#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; +}