sots-engine/src/app/phase_catalog.cpp
alex 39c01422f7 src/app: the standalone -- load a save, run a turn, write a save
`sots_turn` loads a save through the engine's own reader, walks the published
phase order of all three turn drivers, runs what we hold, prints what we do
not, and writes the result back through the engine's own writer.

The phase catalog carries all 32 + 12 + 37 phases whether or not they are
implemented, so an unimplemented phase is a named no-op that appears in the run
log rather than a silent absence. 14 of the 44 turn-driver phases are modelled,
7 commit anything, 2 of the 37 tail phases are modelled.

Modelled but NOT committed is a first-class state. A phase whose formula we hold
and whose inputs we do not is evaluated, reported, and left unwritten unless
--commit-blocked is passed. That distinction was earned: committing phase 31's
player-status restore regressed two leaves that had agreed with the oracle
before the turn, because the phase writes 1 and the file carries 4.

Measured against the game's own post-turn saves, leaves localised by
state_checksum.py with coverage proved by re-serialisation:

  turn1-state -> turn2-state   209 -> 204 diverging, closed 5, regressed 0
  turn2-state -> turn3-state   108 -> 103 diverging, closed 5, regressed 0

Two tests: app_catalog (the tables stay complete and nothing claims to be
verified against a live game) and app_turn (11 saves driven; an untouched load
re-serialises byte-identically, a turn leaves the file re-readable, and no
blocked or stub phase writes anything). Skips cleanly without SOTS_SAVES_DIR.

ctest 38/38, clean-room OK. src/shim untouched. docs/S-standalone.md has the
full gap list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 10:35:45 -04:00

270 lines
16 KiB
C++

#include "app/phase_catalog.h"
namespace sots::app {
const char* DriverName(Driver d) {
switch (d) {
case Driver::Host: return "host turn sequence (outside the two turn drivers)";
case Driver::Strategic: return "StrategyServer::ProcessTurn";
case Driver::Player: return "ServerPlayer::ProcessTurn";
case Driver::Tail: return "StrategyServer::OnAllCombatDone_Tail";
}
return "?";
}
const char* StatusName(PhaseStatus s) {
switch (s) {
case PhaseStatus::Verified: return "verified";
case PhaseStatus::Implemented: return "implemented";
case PhaseStatus::Partial: return "partial";
case PhaseStatus::Blocked: return "blocked";
case PhaseStatus::Stub: return "stub";
}
return "?";
}
char StatusGlyph(PhaseStatus s) {
switch (s) {
case PhaseStatus::Verified: return 'V';
case PhaseStatus::Implemented: return 'I';
case PhaseStatus::Partial: return 'P';
case PhaseStatus::Blocked: return 'B';
case PhaseStatus::Stub: return '.';
}
return '?';
}
// ---------------------------------------------------------------------------------------
// StrategyServer::ProcessTurn -- 32 phases, 0..31
// ---------------------------------------------------------------------------------------
namespace {
// The host steps around the drivers. Two turn counters live here, not in either driver.
constexpr PhaseDesc kHost[] = {
{Driver::Host, 0, "H00", "BeginProcessTurn", PhaseStatus::Implemented,
"advances the frame counter, which is the turn number the whole game displays; runs "
"before the spine and is where the 'Begin processing turn N' line comes from"},
{Driver::Host, 1, "H01", "SaveWriterInvariants", PhaseStatus::Implemented,
"the summary's turn number is the simulation's frame counter -- an identity that holds "
"across every save in the corpus and belongs to the writer, not to a turn phase"},
};
constexpr PhaseDesc kStrategic[] = {
{Driver::Strategic, 0, "S00", "SnapshotPreviousTurn", PhaseStatus::Partial,
"bumps the modification counter; the previous-turn shadow-word snapshot is not modelled "
"(the shadow words are not all identified on the wire)"},
{Driver::Strategic, 1, "S01", "SystemPrePassMoraleAndAbandon", PhaseStatus::Stub,
"per-system morale event + the abandon/chaos check below the minimum chaos population"},
{Driver::Strategic, 2, "S02", "TradeManagerTurn", PhaseStatus::Stub,
"ServerTradeManager::ProcessTurn -- feeds the trade slot of the budget"},
{Driver::Strategic, 3, "S03", "RegisterTradeSystems", PhaseStatus::Stub, ""},
{Driver::Strategic, 4, "S04", "RebuildAllianceMasks", PhaseStatus::Stub,
"per-player shared-vision / alliance mask, rebuilt from scratch each turn"},
{Driver::Strategic, 5, "S05", "BuildShipActionTypeSets", PhaseStatus::Stub,
"builds the two action-type id sets the dispatcher runs over"},
{Driver::Strategic, 6, "S06", "ShipActionsExceptType2", PhaseStatus::Stub,
"the ship-action dispatcher over every action type but type 2 (colonise, build, "
"terraform, mine, scrap)"},
{Driver::Strategic, 7, "S07", "NodeSpaceTravel", PhaseStatus::Stub, ""},
{Driver::Strategic, 8, "S08", "FleetMovement", PhaseStatus::Stub,
"game::sim movement primitives exist and are mechanism-verified, but the fleet/waypoint "
"adapter over the save's flight plans is not written"},
{Driver::Strategic, 9, "S09", "ShipActionsType2", PhaseStatus::Stub,
"the action type that needs the fleet to have arrived first"},
{Driver::Strategic, 10, "S10", "ShipUpkeep", PhaseStatus::Stub,
"upkeep of population carried aboard colony/slaver hulls in transit"},
{Driver::Strategic, 11, "S11", "SystemTurn", PhaseStatus::Partial,
"runs game::sim ProcessColonyTurn per system and commits the parts that need neither the "
"tuning table nor a carrying capacity; plague, growth, resources, slaves, rebellion and "
"the build queue are the sub-passes the model already declares as its input boundary"},
{Driver::Strategic, 12, "S12", "TradeSliderFinalisation", PhaseStatus::Stub,
"re-normalises the per-system output rates"},
{Driver::Strategic, 13, "S13", "PlayerTurn", PhaseStatus::Partial,
"runs the 12-phase player driver once per player, in save order"},
{Driver::Strategic, 14, "S14", "ProcessMissions", PhaseStatus::Stub, ""},
{Driver::Strategic, 15, "S15", "ProcessStations", PhaseStatus::Stub, ""},
{Driver::Strategic, 16, "S16", "ProcessDefenceSats", PhaseStatus::Stub, ""},
{Driver::Strategic, 17, "S17", "ShipStatCacheRefresh", PhaseStatus::Stub,
"re-syncs cached per-ship stat words from the design record"},
{Driver::Strategic, 18, "S18", "ShipActionsForceValidate", PhaseStatus::Stub,
"the dispatcher a third time over all action types with the force flag: validate and "
"cancel whatever is left"},
{Driver::Strategic, 19, "S19", "ProcessAid", PhaseStatus::Stub,
"writes savings and research points of OTHER players; an input to the budget"},
{Driver::Strategic, 20, "S20", "ProcessSpecialProjectsServer", PhaseStatus::Stub, ""},
{Driver::Strategic, 21, "S21", "ProcessSurrenders", PhaseStatus::Stub, ""},
{Driver::Strategic, 22, "S22", "AdvanceAIRebellion", PhaseStatus::Stub,
"steps an in-progress AI rebellion; the rebellion object is opaque on the wire"},
{Driver::Strategic, 23, "S23", "ScriptHookTurnStart", PhaseStatus::Stub,
"scripted-scenario callback pair; dead in a normal game but not proven so"},
{Driver::Strategic, 24, "S24", "SensorUpdate", PhaseStatus::Stub,
"packs 2-bit per-player visibility into every system and fleet"},
{Driver::Strategic, 25, "S25", "ScriptHookPostSensor", PhaseStatus::Stub, ""},
{Driver::Strategic, 26, "S26", "RefreshPlayerViews", PhaseStatus::Stub, ""},
{Driver::Strategic, 27, "S27", "RecomputePlayerReports", PhaseStatus::Stub, ""},
{Driver::Strategic, 28, "S28", "GrantMetSpeciesTechs", PhaseStatus::Stub,
"the 'you have met this race, its racial tech appears in your tree' rule -- small, pure, "
"draw-free, and the cheapest unimplemented phase in this table"},
{Driver::Strategic, 29, "S29", "SystemTailFixup", PhaseStatus::Stub, ""},
{Driver::Strategic, 30, "S30", "BuildTeamPartition", PhaseStatus::Stub, ""},
{Driver::Strategic, 31, "S31", "EncounterDetectionAndStatusRestore", PhaseStatus::Blocked,
"encounter detection is not modelled. The player-status restore that follows it IS -- it "
"writes 1 -- but the value the file carries is 4, so a further writer between this phase "
"and the autosave is unaccounted. Committing the 1 turned two agreeing leaves into "
"disagreeing ones on the turn2->turn3 pair, so it is evaluated and reported instead"},
};
// ---------------------------------------------------------------------------------------
// ServerPlayer::ProcessTurn -- 12 phases, 1..12
// ---------------------------------------------------------------------------------------
constexpr PhaseDesc kPlayer[] = {
{Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Blocked,
"the formula is verified (0 divergences over 4,284 live calls) but one input is not "
"modelled: the money output of each owned system. That needs the population -> base-output "
"term, which the colony model declares unresolved. Evaluated and reported, not committed"},
{Driver::Player, 2, "P02", "ApplyNetToSavings", PhaseStatus::Blocked,
"saturating add of the budget net into savings; blocked behind P01's missing input"},
{Driver::Player, 3, "P03", "RecordBudgetDerivedFields", PhaseStatus::Blocked,
"trade income, savings-given-away and research-points-given-away land on the turn record "
"and on two player words that are not identified on the wire"},
{Driver::Player, 4, "P04", "ProcessSpecialProjectsSpend", PhaseStatus::Stub,
"special-project spend; the project bodies are opaque on the wire"},
{Driver::Player, 5, "P05", "ProcessResearch", PhaseStatus::Blocked,
"the research slice is verified end to end (35 live calls, 0 divergences) but its "
"allocation comes from P01's budget, so it cannot be driven yet"},
{Driver::Player, 6, "P06", "ResearchRefund", PhaseStatus::Blocked,
"unspent research points converted back to money at the turn's own rate; needs P01 and P05"},
{Driver::Player, 7, "P07", "ClearTimedResearchAccumulators", PhaseStatus::Partial,
"zeroes the three timed-research accumulators that are on the wire; two further words the "
"phase also zeroes are not identified"},
{Driver::Player, 8, "P08", "DecayRebellionOutputModifier", PhaseStatus::Implemented,
"rebel AI only: the output modifier walks down by 0.04f per turn, clamped to [1, 2]"},
{Driver::Player, 9, "P09", "AccumulateTimedResearchBonuses", PhaseStatus::Implemented,
"the timed research-bonus vector, iterated from the LAST element down to index 0 -- the "
"descending order is load-bearing because float addition is not associative"},
{Driver::Player, 10, "P10", "ConsumeResearchRollPending", PhaseStatus::Partial,
"the flag/threshold test and the flag clear are implemented and committed; the draw it "
"fires is counted into the RNG ledger but the generator state is only written back under "
"--commit-rng, because the turn's other draws are not yet attributed"},
{Driver::Player, 11, "P11", "PostNoResearchEvent", PhaseStatus::Blocked,
"the condition is implemented and reported; posting needs the localised event text table "
"and the event-id sequence, neither of which the standalone has"},
{Driver::Player, 12, "P12", "PruneRaidTargets", PhaseStatus::Stub,
"20-turn ageing of the raid-target list; the records are opaque on the wire"},
};
// ---------------------------------------------------------------------------------------
// StrategyServer::OnAllCombatDone_Tail -- 37 phases, 0..36.
//
// This is the driver the autosave is written from: everything below runs AFTER combat and
// BEFORE the file hits disk. Nothing here is implemented. It is listed in full because a
// reimplementation that reproduces the spine exactly and stops will still diverge -- two of
// these phases draw from the same generator.
// ---------------------------------------------------------------------------------------
constexpr PhaseDesc kTail[] = {
{Driver::Tail, 0, "T00", "IncrementModCount", PhaseStatus::Implemented,
"the same modification counter the spine's phase 0 bumps"},
{Driver::Tail, 1, "T01", "ValidateEncounterResultArity", PhaseStatus::Stub, ""},
{Driver::Tail, 2, "T02", "ResolveFirstContact", PhaseStatus::Stub, ""},
{Driver::Tail, 3, "T03", "AnnounceEncounterSightings", PhaseStatus::Stub, ""},
{Driver::Tail, 4, "T04", "TallyBattlesFought", PhaseStatus::Stub, ""},
{Driver::Tail, 5, "T05", "UpdateDiplomacyStatsFromCombat", PhaseStatus::Stub, ""},
{Driver::Tail, 6, "T06", "ApplyEncounterResults", PhaseStatus::Stub,
"DRAWS RNG -- node-cannon and salvage paths; the word count is combat-dependent"},
{Driver::Tail, 7, "T07", "ClearEncounters", PhaseStatus::Stub, ""},
{Driver::Tail, 8, "T08", "ScriptHookCombatDone", PhaseStatus::Stub, ""},
{Driver::Tail, 9, "T09", "AdvanceAIRebellionPostCombat", PhaseStatus::Stub, ""},
{Driver::Tail, 10, "T10", "NodeSpaceTravelSecondPass", PhaseStatus::Stub,
"node-space travel runs a SECOND time this turn"},
{Driver::Tail, 11, "T11", "DecayNodeLines", PhaseStatus::Stub,
"DRAWS RNG -- exactly one unit draw per expired node line per turn"},
{Driver::Tail, 12, "T12", "DrainColonyLossQueue", PhaseStatus::Stub, ""},
{Driver::Tail, 13, "T13", "UpdateTreasuryMorale", PhaseStatus::Stub, ""},
{Driver::Tail, 14, "T14", "UpdateForeignFleetMorale", PhaseStatus::Stub, ""},
{Driver::Tail, 15, "T15", "ProcessBankruptcy", PhaseStatus::Stub,
"the decision function is modelled in game::sim; the per-player state it reads is not "
"assembled here"},
{Driver::Tail, 16, "T16", "ResolveArrivedColonizers", PhaseStatus::Stub, ""},
{Driver::Tail, 17, "T17", "RebuildPlayerViewTree", PhaseStatus::Stub, ""},
{Driver::Tail, 18, "T18", "PostFleetWarnings", PhaseStatus::Stub, ""},
{Driver::Tail, 19, "T19", "DrainInfraTerraformQueue", PhaseStatus::Stub, ""},
{Driver::Tail, 20, "T20", "ScriptHooksTurnEnd", PhaseStatus::Stub, ""},
{Driver::Tail, 21, "T21", "UpdateSurveyAndSystemStats", PhaseStatus::Stub, ""},
{Driver::Tail, 22, "T22", "TradeSliderFinalisationSecondPass", PhaseStatus::Stub, ""},
{Driver::Tail, 23, "T23", "TradeManagerEndOfTurnHooks", PhaseStatus::Stub,
"eight vtable calls, wholly unidentified"},
{Driver::Tail, 24, "T24", "RecomputeMaintenanceAndResearchBonus", PhaseStatus::Stub,
"recomputes per-player ship maintenance and research bonus and rebuilds the ship records"},
{Driver::Tail, 25, "T25", "SensorUpdateSecondPass", PhaseStatus::Stub, ""},
{Driver::Tail, 26, "T26", "ScriptHookPostSensorTail", PhaseStatus::Stub, ""},
{Driver::Tail, 27, "T27", "RefreshPlayerViewsSecondPass", PhaseStatus::Stub, ""},
{Driver::Tail, 28, "T28", "UpdateNodeLineSightingMasks", PhaseStatus::Stub, ""},
{Driver::Tail, 29, "T29", "AbortInvisibleInterceptOrders", PhaseStatus::Stub, ""},
{Driver::Tail, 30, "T30", "RebuildCommunicationMasks", PhaseStatus::Stub, ""},
{Driver::Tail, 31, "T31", "UpdateBankruptcyLimits", PhaseStatus::Blocked,
"the limits formula is modelled in game::sim; its input is the sum of every owned "
"system's MAXIMUM money output, which needs the same unresolved population->output term "
"as P01. Evaluated and reported, not committed"},
{Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""},
{Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""},
{Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""},
{Driver::Tail, 35, "T35", "RebuildPlayerReports", PhaseStatus::Stub, ""},
{Driver::Tail, 36, "T36", "FinalizeTurnRecords", PhaseStatus::Stub,
"fills every player's turn record and archives it by turn; must stay last"},
};
PhaseTally Tally(const PhaseDesc* p, std::size_t n) {
PhaseTally t;
t.total = static_cast<int>(n);
for (std::size_t i = 0; i < n; ++i) {
switch (p[i].status) {
case PhaseStatus::Verified: ++t.verified; break;
case PhaseStatus::Implemented: ++t.implemented; break;
case PhaseStatus::Partial: ++t.partial; break;
case PhaseStatus::Blocked: ++t.blocked; break;
case PhaseStatus::Stub: ++t.stub; break;
}
}
return t;
}
} // namespace
const PhaseDesc* HostPhases(std::size_t& count) {
count = sizeof(kHost) / sizeof(kHost[0]);
return kHost;
}
const PhaseDesc* StrategicPhases(std::size_t& count) {
count = sizeof(kStrategic) / sizeof(kStrategic[0]);
return kStrategic;
}
const PhaseDesc* PlayerPhases(std::size_t& count) {
count = sizeof(kPlayer) / sizeof(kPlayer[0]);
return kPlayer;
}
const PhaseDesc* TailPhases(std::size_t& count) {
count = sizeof(kTail) / sizeof(kTail[0]);
return kTail;
}
PhaseTally TallySpine() {
std::size_t ns = 0, np = 0;
const PhaseDesc* s = StrategicPhases(ns);
const PhaseDesc* p = PlayerPhases(np);
PhaseTally a = Tally(s, ns), b = Tally(p, np);
PhaseTally t;
t.total = a.total + b.total;
t.verified = a.verified + b.verified;
t.implemented = a.implemented + b.implemented;
t.partial = a.partial + b.partial;
t.blocked = a.blocked + b.blocked;
t.stub = a.stub + b.stub;
return t;
}
PhaseTally TallyTail() {
std::size_t n = 0;
const PhaseDesc* p = TailPhases(n);
return Tally(p, n);
}
} // namespace sots::app