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

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

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

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

255 lines
12 KiB
C++

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