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).
128 lines
6.5 KiB
C++
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
|