sots-engine/src/app/turn_record.h
alex 5b93a4f959 lane E2: wire the ship census into the turn record; T36 still blocked, now on two named things
The tail's last phase archives a per-player record whose 13 modelled fields were 7.
The six ship counts join them: every player's designs (normal and legacy, one id space)
are classified against the section catalog, and the global fleet list is walked keyed by
Flt.PID against each player's OBJECT id, not its vector position.

The census cannot come from a save. A design's hull size and its defence-platform flag
are recomputed from the section catalog whenever the design changes and are never written
down, so the standalone grows a data root -- `--data DIR`, or $SOTS_DATA_DIR. No game data
is embedded, and without a root the six counters report themselves unmodelled instead of
being written as six zeros that a wrong model would also produce.

`--commit-blocked=IDS` and `--commit-blocked-except=IDS` narrow the commit switch to named
phase ids. All-or-nothing across every blocked phase reports one closed count and one
regressed count for all of them at once, which is the netting the campaign does not do.

MEASURED, closed and regressed never netted, state_checksum leaves:

  turn1 -> turn2  default              209 -> 204  closed 5   regressed 0   (unchanged)
  turn1 -> turn2  --commit-blocked=T36  no data    closed 29  regressed 9
  turn1 -> turn2  --commit-blocked=T36  with data  closed 29  regressed 7
  turn2 -> turn3  default              108 -> 103  closed 5   regressed 0   (unchanged)
  turn2 -> turn3  --commit-blocked=T36  with data  closed 13  regressed 7

The prediction written before the code said the regressed list would fall to 6 and 8 under
a full --commit-blocked. It fell to 7 and 9. The prediction's first falsification case is
what happened: two census leaves closed and the third did not, because the census is of the
fleet list as it stands and no phase the standalone runs creates a ship. The archived count
is higher than ours by exactly one destroyer on BOTH pairs for the one player whose build
queue completes that turn, while the self-check on the input turn is exact. That leaf is
short by the turn's construction, not wrong about classification.

app_test_turn_record now compares the six counters against the record the game archived:
11 saves, 80 player-records, 1040 fields, 480 of them census leaves, 0 mismatches. That is
lane D2's 480/480 reproduced through this code path, which visits a design's slots in the
original's in-memory order (mission, command, engine) rather than the wire's.

T36 stays Blocked, and on two named things, neither of them in this phase:
sav and inc come from P01/P02, blocked on the per-system money output; and shpt[0] is short
by the ships the turn builds. The archived record is one struct on the wire, so those words
cannot be left out while the rest is written -- committing is all-or-nothing at the record,
and there is no field-granular knob that could change that. Seven confidently-wrong leaves
are not worth 29 that later lanes close for free.

COVERAGE, as loudly as the verdict: only 32 of the 480 archived census leaves are nonzero
anywhere in the corpus -- per leaf (cls0 shpt/satt, cls1 shpt/satt, cls2 shpt/satt) =
18/3, 0/0, 11/0. cls1 entirely and satt for cls2 have never been observed nonzero: three of
the six counters are unexercised hypotheses. The four loss/kill words of each group are zero
throughout and are written as zeros with no model behind them. Hull size is an assignment in
slot order, and design rule A6 means no save can tell the memory order from the wire order.
verified stays 0: nothing here was compared against a running game.

Gates, separately: clean_room_check OK; host ctest 45/45 without SOTS_SAVES_DIR and 45/45
with it. No src/shim file touched; the shim cross-build was NOT run (no i686 mingw here).
2026-09-08 13:25:17 -04:00

128 lines
6.5 KiB
C++

// The per-player turn record -- the last phase of the post-combat tail.
//
// Each player carries a small "what my empire looked like this turn" summary that the final
// tail phase fills and then copies into a history archive keyed by the turn number. The
// archive IS on the wire: it is the per-player turn-statistics history, one element per turn,
// and every save carries an element for its own frame. That makes this phase testable without
// a running game: build the record from a save's own state and compare it with the element the
// save already holds for that turn.
//
// Seven fields are recoverable from the wire and are reproduced here. Six more -- the
// per-hull-class ship census -- are recoverable only with the game's section catalog, because
// neither the hull size nor the defence-platform flag is anywhere on the wire; they are filled
// when the operator supplies a data root and are reported as unmodelled when they are not.
// The rest are not recoverable at all, and each is named with the reason -- they belong to
// phases or inputs the standalone does not hold.
#pragma once
#include <array>
#include <cstdint>
#include <string>
#include <vector>
#include "game/data/catalog.h"
#include "game/design/hull.h"
#include "mars/stream/shapes.h"
namespace sots::app {
// The per-player ship census, built once for the whole simulation.
//
// The counts cannot come from a save alone: a ship names a design, and a design's hull size
// and defence-platform flag are recomputed from the section catalog whenever it changes and
// are never written down. So this needs a data root, and says so when it does not have one.
//
// Built once rather than per player because design ids are unique across the whole game and
// the fleet list is global: classifying every design once and then walking the fleets is the
// same shape the original uses, and it keeps the catalog lookups off the per-player path.
class ShipCensusIndex {
public:
// Without a catalog: an index that reports itself unmodelled and counts nothing.
ShipCensusIndex() = default;
ShipCensusIndex(const mars::stream::shapes::SaveGame& game, const game::data::Catalog& cat);
bool modelled() const { return modelled_; }
// Null when the census is unmodelled or the index is out of range.
const game::design::ShipCensus* ForPlayer(std::size_t vectorIndex) const;
int designsSeen = 0;
// A design none of whose sections resolve against the catalog. NOT counted as class 0:
// an unclassifiable design is a gap, and a silent class-0 count would hide it.
int designsUnclassified = 0;
// A ship naming a design id no player's design list carries, or an unclassifiable one.
int shipsWithoutDesign = 0;
// Fleets whose owner id names no player in the player vector -- the NPC pools. Skipped,
// and counted so "skipped" is a measurement rather than an omission.
int fleetsWithoutOwner = 0;
private:
bool modelled_ = false;
std::vector<game::design::ShipCensus> byPlayer_;
};
// The part of a turn record this model can produce.
struct TurnRecord {
std::int32_t turn = 0; // the frame counter
std::int64_t population = 0; // summed over owned systems
std::int32_t colonies = 0; // owned-system count
std::int32_t savings = 0;
std::int32_t income = 0; // savings minus previous-turn savings
std::int32_t completedTech = 0; // tech-tree entries in the completed state
std::int32_t allianceMask = 0; // the shared-vision mask the spine's phase 4 rebuilds
// The census, by hull size 0/1/2. Filled only when `censusModelled`; six zeros otherwise,
// which is exactly the value a wrong model would produce, hence the flag rather than a
// sentinel.
bool censusModelled = false;
std::array<int, 3> ships{};
std::array<int, 3> platforms{};
// Fields the archive element also carries that this model does NOT fill, kept as a
// published list rather than as silence. Each is blocked on something named.
static const char* const* Unmodelled(std::size_t& count);
};
// The completed state's value in the tech-tree state word. Named rather than spelled inline
// because it is the one magic number in this file.
constexpr std::int32_t kTechStateCompleted = 4;
// Build one player's record from the simulation state. `systemsById` is the save's system
// table indexed the way the player's owned-system ids index it.
//
// `vectorIndex` is the player's position in the player vector -- the alliance mask's bit
// index, and NOT the player's own index field. See app/alliance.h.
//
// `spineRan` says whether the turn whose record this is actually ran the strategic spine.
// The archiving phase runs in two places -- at the end of a turn, and on load -- and the
// alliance mask is written only by the spine. So a record archived by the load path carries
// a zero mask, and that is a positive prediction of this model, not an exclusion: the
// earliest turn every save carries has `almem == 0` on every player, in all eleven saves.
//
// `census` may be null, and is null whenever no data root was supplied: the six census
// fields then stay zero and `censusModelled` stays false.
TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p,
const std::vector<mars::stream::shapes::SysEntry>& systems,
std::int32_t frame, std::size_t vectorIndex, bool spineRan,
// set when an owned-system id is not present in the table, which
// would silently drop a term from the population sum
int* danglingOwnedSystems = nullptr,
const ShipCensusIndex* census = nullptr);
// The earliest turn the archive carries for a player. The record for that turn was written
// by the new-game / load path, not by a turn.
std::int32_t EarliestArchivedTurn(const mars::stream::shapes::PlayerTurnHistory& hist);
// What the archive element for `turn` holds, if the save carries one for that turn.
const mars::stream::shapes::PlayerTurnStats* FindArchivedRecord(
const mars::stream::shapes::PlayerTurnHistory& hist, std::int32_t turn);
// A comparison of a built record against a stored one, over the six modelled fields only.
struct TurnRecordDiff {
int compared = 0;
std::vector<std::string> mismatches; // "field: built != stored"
bool ok() const { return mismatches.empty(); }
};
TurnRecordDiff CompareTurnRecord(const TurnRecord& built,
const mars::stream::shapes::PlayerTurnStats& stored);
} // namespace sots::app