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.
This commit is contained in:
parent
8c23f6bd74
commit
ae170ecdfe
6 changed files with 948 additions and 1 deletions
|
|
@ -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()
|
||||
|
|
|
|||
10
src/game/combat/CMakeLists.txt
Normal file
10
src/game/combat/CMakeLists.txt
Normal 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
327
src/game/combat/retreat.cpp
Normal 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
255
src/game/combat/retreat.h
Normal 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
|
||||
6
tests/game_combat/CMakeLists.txt
Normal file
6
tests/game_combat/CMakeLists.txt
Normal 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)
|
||||
348
tests/game_combat/test_retreat.cpp
Normal file
348
tests/game_combat/test_retreat.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue