diff --git a/CMakeLists.txt b/CMakeLists.txt index bbe6168..a5b0ee9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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/app) # the standalone turn driver (lib sots_app, sots_turn) # ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ---- add_library(shim_trace STATIC @@ -108,7 +109,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) + 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 app) if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt) add_subdirectory(tests/${_t}) endif() diff --git a/README.md b/README.md index b959948..5073c9a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ loads) builds, deploys, hooks, and logs from a real game launch. Engine code acc - `mars/rng` — MT19937 with save-state load/store; layout confirmed against real saves, draw mappings read off the binary (`docs/mars-rng.md`) - `game/data` — typed catalogs (weapons, ship sections, turrets, id registries, tech tree, strings) with cross-reference checks; 229k values agree with the reference - `game/design` — ship-design assembly/fit/tech-gating rules and derived stats; validates all 127 stock designs from real saves +- `app` — **the standalone**: `sots_turn` loads a save, runs one strategic turn over the published phase order of all three turn drivers, and writes a save. 14 of the 44 turn-driver phases are modelled; every phase that is not appears in the run log as a named no-op. How far it is from the byte-match, and what stands in the way, is in `docs/S-standalone.md` Build: `cmake --preset host && cmake --build --preset host && ctest --preset host` (Linux); `tools/sync-build.sh` cross-builds the shim on the lab box and stages it for deployment. @@ -27,6 +28,7 @@ Build: `cmake --preset host && cmake --build --preset host && ctest --preset hos ## Layout (grows with the work) - `src/shim/` — binkw32 proxy + hooks + old-vs-new compare harness (frontend #1) - `src/mars/`, `src/game/` — the engine and game reimplementation (accruing) +- `src/app/` — the standalone turn driver (frontend #2): load a save, run a turn, write a save - `include/generated/sots_addresses.h` — binary facts (RVAs/prototypes), generated from the RE repo - `tests/` — host tests; real-data tests skip unless `$SOTS_DATA_DIR` is set - `tools/` — build (MinGW i686 cross) and deploy scripts diff --git a/docs/S-standalone.md b/docs/S-standalone.md new file mode 100644 index 0000000..8dc5956 --- /dev/null +++ b/docs/S-standalone.md @@ -0,0 +1,213 @@ +# `src/app` — the standalone, and exactly how far it is from the byte-match + +Lane S2, 2026-09-08. Branch `wip/standalone`. + +The campaign's north star is *a functional reimplementation*, and the first milestone that +makes it falsifiable is: + +> the standalone loads a save, runs one strategic turn, and writes an autosave that +> **byte-matches** what the original produces from the same state. + +This document says what `sots_turn` does today, what it deliberately does **not** do, and what +stands between it and that byte-match. The short answer is on the first line of the metric: +**14 of the 44 turn-driver phases are modelled, 7 of them commit anything, and the reference +turn closes 5 of 209 diverging leaves.** Everything below is the detail behind those numbers. + +--- + +## 1. What it does end to end, today + +``` +$ sots_turn SAVE --roundtrip --phases --out POST.sav --metric metric.json +``` + +1. **Loads** the save through `mars::stream::read_save_file` — the engine's own reader, the one + with 100 % named coverage on all eleven saves in the corpus. +2. **Proves the foundation** with `--roundtrip`: re-serialises the *untouched* parse and + compares byte for byte against the inflated stream. If that fails, nothing after it means + anything, so the run stops. +3. **Walks the whole published phase order of all three drivers** — the host steps, then + `StrategyServer::ProcessTurn`'s 32 phases, with `ServerPlayer::ProcessTurn`'s 12 nested in + at phase 13, then `OnAllCombatDone_Tail`'s 37 — running what we hold and printing what we + do not. +4. **Writes** the post-turn state back through `mars::stream::write_save` + gzip. +5. **Emits a completion metric** as JSON for the campaign dashboard. + +It reads no game data. The save path is an argument; the test reads `$SOTS_SAVES_DIR` and +skips cleanly when it is unset. No `.sav` is in this repo and none is written into it. + +## 2. The design decision that matters: blocked phases do not commit + +Several phases are ones whose **formula is verified and whose inputs are not modelled**. The +budget roll-up is the type case: `ComputeBudget` compared clean over 4,284 live calls, but one +of its inputs is the money output of each owned system, and that needs the +population → base-output term the colony model explicitly declares unresolved. + +Running such a phase and writing its result would be worse than not running it. It replaces a +leaf that may currently agree with the oracle *by construction* with one that is confidently +wrong, and the divergence count then measures how much code we ran rather than how much we +know. So a `blocked` phase is **evaluated, reported, and not written** unless the operator +passes `--commit-blocked`. + +This is not theoretical. `S31`'s player-status restore was implemented and committed in the +first version of this lane; the comparison tool immediately reported **two regressed leaves** +on the `turn2 → turn3` pair — two `Player.Status` words that agreed with the oracle before the +turn and disagreed after it. The phase writes `1`; the file carries `4`; a writer between the +phase and the autosave is unaccounted. `S31` is now `blocked`, and the measured fact is +recorded in its catalog note rather than papered over. + +The same reasoning governs the generator. The turn consumes roughly 18–20 words that nothing +here models, so the advanced state would be wrong in a *different* way from the untouched +state. The untouched state at least tells the truth. `--commit-rng` exists for the day the +ledger closes. + +## 3. The phase catalog + +`src/app/phase_catalog.{h,cpp}` is the roadmap and the ledger at once. Every phase of every +driver is in it whether or not it is implemented, with one of five statuses: + +| status | meaning | commits? | +|---|---|---| +| `verified` | implemented here **and** compared against the live game | yes | +| `implemented` | implemented from an instruction-verified reading, not yet compared live | yes | +| `partial` | part implemented and committed, part stubbed; the note says which | yes, the part | +| `blocked` | formula held, an input is not modelled | **no** (unless forced) | +| `stub` | a named no-op | no | + +Nothing is `verified`. That is deliberate: in this table `verified` means "compared against +the live game", and lane S2 holds no VM. `app_test_catalog` asserts `verified == 0` so the +claim cannot drift upward by accident. + +### 3.1 Current state + +| driver | phases | modelled | committed | +|---|---:|---:|---:| +| host steps (outside the milestone's denominator) | 2 | 2 | 2 | +| `StrategyServer::ProcessTurn` | 32 | 4 | 3 | +| `ServerPlayer::ProcessTurn` | 12 | 10 | 4 | +| **the milestone's denominator** | **44** | **14** | **7** | +| `OnAllCombatDone_Tail` | 37 | 2 | 1 | + +### 3.2 What actually runs + +**Committed** + +| phase | what it does | +|---|---| +| `H00 BeginProcessTurn` | advances the frame counter — the turn number the whole game displays | +| `H01 SaveWriterInvariants` | the summary's turn number is the frame counter; an identity that holds across the whole corpus | +| `S00 SnapshotPreviousTurn` | the modification-counter bump (the shadow-word snapshot is not modelled) | +| `S11 SystemTurn` | `game::sim::ProcessColonyTurn`, committing the parts that need neither the tuning table nor a carrying capacity | +| `P07 ClearTimedResearchAccumulators` | zeroes the three timed-research accumulators that are on the wire | +| `P08 DecayRebellionOutputModifier` | rebel AI only: −0.04f per turn, clamped to [1, 2] | +| `P09 AccumulateTimedResearchBonuses` | the timed research-bonus vector, iterated **last → first**, which is load-bearing because float addition is not associative | +| `P10 ConsumeResearchRollPending` | the strict `0.5f < progress/cost` test and the in-branch flag clear | +| `T00 IncrementModCount` | the tail's own bump of the same counter | + +**Evaluated and reported, not committed:** `P01` `P02` `P03` `P05` `P06` `P11` `S31` `T31`. + +Everything else is a named no-op that prints itself in `--phases`. + +### 3.3 One hypothesis, and how it is being tested + +`ProcessColonyTurn` takes `stable` as an **input** — in the original it is a callee's verdict. +The standalone's stand-in is `owned && !abandoned && !destroyed`, and it is labelled a +hypothesis in the code and in the run log, because rule 6 says a path no evidence exercises is +a hypothesis. + +It is a *testable* one: `stable` drives the turns-developing counter, which is a named leaf. +On the reference pair it judged 3 of 28 systems stable and closed exactly the 3 `ntdev` leaves +the oracle moved. It then closed the same 3 on the `turn2 → turn3` pair — **a different +workload**, which is what makes it evidence about the model rather than about the recording. +Six independent agreements, zero disagreements, is not proof, and it is written down as such. + +## 4. The divergence report + +Measured by `sots-re/tools/standalone_report.py`, which drives `sots_turn` over each +consecutive-turn pair in the corpus and diffs the result against the game's own post-turn save +using `verify/state-checksum/state_checksum.py`. That tool localises to named leaves and +**proves its own coverage** by re-serialising the parse back to bytes, so nothing can hide +from it. + +Reference pair `turn1-state.sav → turn2-state.sav` (a real End Turn): + +``` +baseline (a standalone that does nothing) 209 leaves diverge +after one standalone turn 204 leaves diverge +closed 5, regressed 0 +``` + +Closed: `/Summary/Turn`, `/Sim/Frame`, and `ntdev` on Gamma Cephei, Ke'Dolarra and Koa'Vo. +The second pair `turn2 → turn3` closes the same five out of a baseline of 108, with zero +regressions. + +`regressed` is reported next to `closed` on purpose. A leaf that agreed before the turn and +disagrees after it is a phase doing damage, and netting it off against the closures would hide +exactly the failure this scaffold is built to catch. + +### 4.1 Where the remaining 204 live + +| subsystem | leaves | what it is | +|---:|---:|---| +| `/Sim/players` | 82 | savings, bankruptcy limits, research state, the turn's event buckets, per-player AI bookkeeping | +| `/Sim/systems` | 80 | see below | +| `/Sim/turnstats` | 24 | the per-turn statistics archive — written by the tail's last phase | +| `/Sim/SvSctOb` | 8 | the script-object encounter tree | +| singletons | 10 | `ModCount`, `RNG`, `Checksum`, `NMnx`, `cmbtid`, the four id lists, `NumFlts` | + +The system side is **not** 80 unrelated facts. It is a handful of mechanisms: + +* **32 leaves — one new `nve` record on each of 8 systems** (`NVE` + `EPid`/`ETS`/`Eid`). One + mechanism, eight repetitions: the per-player system-visibility record. Spine phase 24 or + tail phase 21. +* **18 leaves — `TShn` and `ltis` moving 1 → 2 on 8–10 systems.** Both look like per-turn + counters and both are unattributed; nothing in the campaign names their writer. They are the + **cheapest measured candidates on the board** and they are deliberately *not* implemented, + because "it went up by one across one turn" is a hypothesis, not a reading. +* the rest: population growth, reputation, the output-rate re-normalisation, one new fleet. + +The player side is dominated by three things: `Sav` (blocked behind the system-income term), +the bankruptcy limits (blocked behind the same term), and the event buckets (blocked behind +the localised event-text table). + +## 5. What stands between this and the byte-match + +In the order they must be solved, not in order of size. + +1. **The RNG ledger.** 18–20 words are consumed per turn and none are modelled. The generator + is saved state, so a byte-match is *arithmetically impossible* until every draw is + attributed — including the two draw sites in the post-combat tail, which happen after the + spine has finished and before the file is written. Lane I proved the search space closes + (exactly 22 draw sites in the spine's 1,426-function closure, plus an image-wide scan for + inlined draws); lane Z is measuring the attribution. **Nothing in `src/app` can close this.** +2. **The population → output term.** One unresolved formula blocks `P01`, `P02`, `P05`, `P06` + and `T31` — that is 5 of the 44 phases and the single largest cluster of player-side leaves. + It is the highest-value unresolved formula in the campaign for this milestone. +3. **The post-combat tail.** 37 phases, none implemented, and it is the driver the autosave is + written from. `turnstats`, the bankruptcy limits, the observed-design records and the + player reports all live there. +4. **`Summary.Checksum`.** Its algorithm is unknown. It is one leaf, and it is the *last* leaf: + whatever it hashes, it cannot be right until everything it hashes is right. +5. **The `Player.Status` writer.** The phase writes 1, the file carries 4, a load resets to 0. + Four leaves, and a small, self-contained question. + +There is also a floating-point requirement that is already **measured** rather than assumed: +intermediates must be held at 53 bits and narrowed to float32 only on store, with +round-to-nearest. 24-bit precision and round-up each move named leaves on this very corpus. +`game::sim` already follows that rule; anything added to `src/app` must too. + +## 6. Tests + +| test | what it holds | +|---|---| +| `app_catalog` | the three tables are complete, contiguous, uniquely named, and 32 + 12 = 44; every non-stub carries a note; nothing claims to be `verified` | +| `app_turn` | over every save in `$SOTS_SAVES_DIR`: an untouched load re-serialises byte-identically; a turn leaves the file re-readable and re-serialisable; the modelled counters moved; **no blocked or stub phase wrote anything**; the generator is untouched by default. Skips cleanly when the variable is unset | + +## 7. What this lane did not do + +* It did not run the game. Lane S2 holds no VM; every comparison here is against saves the + game already wrote. +* It did not touch `src/shim/`, so no cross-build was required. +* It did not implement a phase on a guess. Two obvious "+1 per turn" system counters were left + alone for exactly that reason, and they are named in §4.1 so the next lane can close them + properly. diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt new file mode 100644 index 0000000..439622f --- /dev/null +++ b/src/app/CMakeLists.txt @@ -0,0 +1,21 @@ +# src/app — the standalone: load a save, run one strategic turn, write a save. +# Included from the root CMakeLists.txt via add_subdirectory(src/app). +# +# The turn logic is a library so the test can drive it in process; `sots_turn` is the CLI. + +add_library(sots_app STATIC + phase_catalog.cpp + turn.cpp + report.cpp) +target_include_directories(sots_app PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..) +target_link_libraries(sots_app PUBLIC mars_stream mars_rng sots_game_sim) +target_compile_features(sots_app PUBLIC cxx_std_17) +if(NOT MSVC) + target_compile_options(sots_app PRIVATE -Wall -Wextra -Werror) +endif() + +add_executable(sots_turn main.cpp) +target_link_libraries(sots_turn PRIVATE sots_app) +if(NOT MSVC) + target_compile_options(sots_turn PRIVATE -Wall -Wextra -Werror) +endif() diff --git a/src/app/main.cpp b/src/app/main.cpp new file mode 100644 index 0000000..227abd6 --- /dev/null +++ b/src/app/main.cpp @@ -0,0 +1,140 @@ +// sots_turn -- the standalone: load a save, run one strategic turn, write a save. +// +// This is the campaign's north star made into an executable, and it is deliberately honest +// about how far it gets. It reads a save through the engine's own reader, walks the whole +// published phase order of all three turn drivers, runs the phases we hold, prints every +// phase it did NOT run, and writes the result back through the engine's own writer. +// +// It reads no game data of its own. The save path comes from the command line or, for the +// test, from $SOTS_SAVES_DIR; nothing is embedded. +#include +#include +#include +#include + +#include "app/report.h" +#include "app/turn.h" +#include "mars/stream/gzip.h" +#include "mars/stream/save.h" + +namespace { + +int Usage() { + std::fprintf(stderr, + "usage: sots_turn SAVE [--out FILE] [--metric FILE] [--phases] [--verbose]\n" + " [--commit-blocked] [--commit-rng] [--roundtrip]\n" + "\n" + " SAVE a .sav to load (gzip or already inflated)\n" + " --out FILE write the post-turn save here\n" + " --metric FILE write the completion metric as JSON here\n" + " --phases print the whole phase table with this run's numbers\n" + " --verbose add each phase's standing note to the listing\n" + " --commit-blocked write results of phases whose inputs are unmodelled\n" + " --commit-rng write the advanced generator state back\n" + " --roundtrip re-serialise the UNTOUCHED save and check byte identity\n"); + return 2; +} + +} // namespace + +int main(int argc, char** argv) { + std::string in, out, metric; + bool phases = false, verbose = false, roundtrip = false; + sots::app::TurnOptions opt; + + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + auto next = [&](std::string& dst) { + if (i + 1 >= argc) return false; + dst = argv[++i]; + return true; + }; + if (a == "--out") { + if (!next(out)) return Usage(); + } else if (a == "--metric") { + if (!next(metric)) return Usage(); + } else if (a == "--phases") { + phases = true; + } else if (a == "--verbose") { + phases = verbose = true; + } else if (a == "--commit-blocked") { + opt.commitBlocked = true; + } else if (a == "--commit-rng") { + opt.commitRng = true; + } else if (a == "--roundtrip") { + roundtrip = true; + } else if (a == "-h" || a == "--help") { + return Usage(); + } else if (!a.empty() && a[0] == '-') { + std::fprintf(stderr, "unknown option: %s\n", a.c_str()); + return Usage(); + } else if (in.empty()) { + in = a; + } else { + return Usage(); + } + } + if (in.empty()) return Usage(); + + mars::stream::SaveDocument doc; + try { + doc = mars::stream::read_save_file(in); + } catch (const std::exception& e) { + std::fprintf(stderr, "cannot read %s: %s\n", in.c_str(), e.what()); + return 2; + } + + const size_t errors = doc.count(mars::stream::Issue::Error); + std::printf("load: %s\n", in.c_str()); + std::printf(" %zu inflated bytes, %zu error(s), %zu warning(s)\n", doc.inflated.size(), + errors, doc.count(mars::stream::Issue::Warn)); + std::printf(" turn %d, frame %d, modCount %d, %zu player(s), %zu system(s), %zu fleet(s)\n", + doc.game.summary.turn, doc.game.sim.frame, doc.game.sim.modCount, + doc.game.sim.players.size(), doc.game.sim.systems.size(), + doc.game.sim.fleets.size()); + if (errors) { + std::fprintf(stderr, "refusing to run a turn on a save that did not parse cleanly\n"); + return 2; + } + + if (roundtrip) { + // The reader/writer pair is the standalone's foundation; prove it before trusting a + // diff of anything the turn wrote. + mars::stream::Bytes again = mars::stream::write_save(doc.game); + const bool ok = again == doc.inflated; + std::printf("roundtrip (untouched): %s (%zu bytes)\n", ok ? "byte-identical" : "DIFFERS", + again.size()); + if (!ok) return 1; + } + + const sots::app::TurnResult r = sots::app::RunStrategicTurn(doc.game, opt); + + if (phases) sots::app::PrintPhaseLog(stdout, r, verbose); + sots::app::PrintSummary(stdout, r); + + if (!out.empty()) { + mars::stream::Bytes body = mars::stream::write_save(doc.game); + mars::stream::Bytes gz = mars::stream::gzip(body.data(), body.size()); + std::ofstream f(out, std::ios::binary); + if (!f) { + std::fprintf(stderr, "cannot write %s\n", out.c_str()); + return 2; + } + f.write(reinterpret_cast(gz.data()), std::streamsize(gz.size())); + if (!f) { + std::fprintf(stderr, "write failed: %s\n", out.c_str()); + return 2; + } + std::printf("\nwrote %s (%zu bytes gzipped, %zu inflated)\n", out.c_str(), gz.size(), + body.size()); + } + + if (!metric.empty()) { + if (!sots::app::WriteMetricJson(metric, r, in, out)) { + std::fprintf(stderr, "cannot write %s\n", metric.c_str()); + return 2; + } + std::printf("metric -> %s\n", metric.c_str()); + } + return 0; +} diff --git a/src/app/phase_catalog.cpp b/src/app/phase_catalog.cpp new file mode 100644 index 0000000..8354336 --- /dev/null +++ b/src/app/phase_catalog.cpp @@ -0,0 +1,270 @@ +#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(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 diff --git a/src/app/phase_catalog.h b/src/app/phase_catalog.h new file mode 100644 index 0000000..43fa624 --- /dev/null +++ b/src/app/phase_catalog.h @@ -0,0 +1,90 @@ +// The strategic turn, as a catalog of named phases. +// +// The turn is not one function. It is three drivers running in a fixed order, and the +// campaign has read all three out of the instruction stream: +// +// * `StrategyServer::ProcessTurn` -- 32 phases, the spine +// * `ServerPlayer::ProcessTurn` -- 12 phases, run once per player from spine phase 13 +// * `StrategyServer::OnAllCombatDone_Tail` -- 37 phases, a SEPARATE driver reached from a +// different message after combat, and the one the autosave is written from +// +// This header is the roadmap and the ledger at once. Every phase of every driver appears +// here whether or not it is implemented, so a run of the standalone prints the whole turn +// and marks each phase with what we actually have. A phase that is not implemented is a +// named no-op that shows up in the output; it is never silently skipped. +// +// Nothing in this file describes the original's memory layout. Phase indices are ordinals +// in a published phase table, not offsets, and no address appears anywhere in src/app. +#pragma once + +#include + +namespace sots::app { + +// Which driver a phase belongs to. +enum class Driver : int { + // The host steps that bracket the drivers: BeginProcessTurn before the spine, and the + // save writer's own invariants after everything. Not part of the milestone's 44 -- they + // are not phases of either turn driver -- but they are part of a turn and are listed so + // the log is the whole sequence. + Host = 0, + Strategic = 1, // StrategyServer::ProcessTurn + Player = 2, // ServerPlayer::ProcessTurn (once per player, from Strategic phase 13) + Tail = 3, // StrategyServer::OnAllCombatDone_Tail (post-combat, pre-autosave) +}; + +const char* DriverName(Driver d); + +// How much of a phase we have, and -- the load-bearing part -- whether the standalone is +// allowed to COMMIT its result to the save. +// +// The distinction between `Blocked` and `Stub` is the whole point of this scaffold. A +// blocked phase is one whose formula we hold and whose inputs we do not: running it and +// writing the result would replace a leaf that currently happens to match with a leaf that +// is confidently wrong. So a blocked phase is evaluated, its result is reported, and it is +// not written unless the operator asks for it. That keeps the divergence count an honest +// measure of what we know rather than a measure of how much code we ran. +enum class PhaseStatus : int { + Verified = 0, // implemented here AND compared against the live game by an earlier lane + Implemented = 1, // implemented from an instruction-verified reading; committed + Partial = 2, // part implemented and committed, part stubbed; the note says which + Blocked = 3, // formula held, an input is not modelled -> evaluated, reported, NOT committed + Stub = 4, // named no-op +}; + +const char* StatusName(PhaseStatus s); +// One-character glyph for the compact phase listing. +char StatusGlyph(PhaseStatus s); + +struct PhaseDesc { + Driver driver; + int index; // ordinal in that driver's published phase table + const char* id; // stable id, e.g. "S11" / "P07" / "T31" + const char* name; // short identifier-shaped name + PhaseStatus status; + const char* note; // what it does, or what is missing -- printed next to the phase +}; + +// The tables, in execution order. +const PhaseDesc* HostPhases(std::size_t& count); +const PhaseDesc* StrategicPhases(std::size_t& count); +const PhaseDesc* PlayerPhases(std::size_t& count); +const PhaseDesc* TailPhases(std::size_t& count); + +// The completion metric's denominator. The campaign's milestone counts the two turn-driver +// tables -- 32 + 12 = 44 -- and tracks the tail's 37 separately, because the tail is a +// second driver that has never been executed by anything we own. +constexpr int kSpinePhaseCount = 44; + +struct PhaseTally { + int total = 0, verified = 0, implemented = 0, partial = 0, blocked = 0, stub = 0; + // "modelled" = anything we run at all, blocked included. "committed" = anything whose + // result reaches the save. + int modelled() const { return verified + implemented + partial + blocked; } + int committed() const { return verified + implemented + partial; } +}; + +PhaseTally TallySpine(); +PhaseTally TallyTail(); + +} // namespace sots::app diff --git a/src/app/report.cpp b/src/app/report.cpp new file mode 100644 index 0000000..5d8030f --- /dev/null +++ b/src/app/report.cpp @@ -0,0 +1,115 @@ +#include "app/report.h" + +#include + +namespace sots::app { +namespace { + +std::string JsonEscape(const std::string& s) { + std::string o; + for (char c : s) { + switch (c) { + case '"': o += "\\\""; break; + case '\\': o += "\\\\"; break; + case '\n': o += "\\n"; break; + case '\r': o += "\\r"; break; + case '\t': o += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + char b[8]; + std::snprintf(b, sizeof b, "\\u%04x", c); + o += b; + } else { + o += c; + } + } + } + return o; +} + +void TallyJson(std::ofstream& f, const char* key, const PhaseTally& t) { + f << " \"" << key << "\": {\"total\": " << t.total << ", \"verified\": " << t.verified + << ", \"implemented\": " << t.implemented << ", \"partial\": " << t.partial + << ", \"blocked\": " << t.blocked << ", \"stub\": " << t.stub + << ", \"modelled\": " << t.modelled() << ", \"committed\": " << t.committed() << "}"; +} + +} // namespace + +void PrintPhaseLog(std::FILE* out, const TurnResult& r, bool verbose) { + Driver current = static_cast(-1); + for (const auto& rec : r.records) { + if (rec.desc->driver != current) { + current = rec.desc->driver; + std::fprintf(out, "\n%s\n", DriverName(current)); + } + const char* indent = current == Driver::Player ? " " : " "; + std::fprintf(out, "%s%c %-4s %-38s %-12s", indent, StatusGlyph(rec.desc->status), + rec.desc->id, rec.desc->name, StatusName(rec.desc->status)); + if (rec.invocations || rec.leafWrites || rec.wouldWrite || rec.rngWords) { + std::fprintf(out, " ran=%-5d writes=%-5d", rec.invocations, rec.leafWrites); + if (rec.wouldWrite) std::fprintf(out, " would=%-4d", rec.wouldWrite); + if (rec.rngWords) std::fprintf(out, " rng=%d", rec.rngWords); + } + std::fprintf(out, "\n"); + for (const auto& n : rec.notes) std::fprintf(out, "%s - %s\n", indent, n.c_str()); + if (verbose && rec.desc->note[0]) + std::fprintf(out, "%s # %s\n", indent, rec.desc->note); + } +} + +void PrintSummary(std::FILE* out, const TurnResult& r) { + const PhaseTally s = TallySpine(); + const PhaseTally t = TallyTail(); + std::fprintf(out, "\nphases\n"); + std::fprintf(out, + " turn drivers (the milestone's denominator): %d of %d modelled, %d committed\n" + " verified %d implemented %d partial %d blocked %d stub %d\n", + s.modelled(), s.total, s.committed(), s.verified, s.implemented, s.partial, + s.blocked, s.stub); + std::fprintf(out, + " post-combat tail (written to the autosave, tracked separately): %d of %d " + "modelled\n" + " verified %d implemented %d partial %d blocked %d stub %d\n", + t.modelled(), t.total, t.verified, t.implemented, t.partial, t.blocked, t.stub); + std::fprintf(out, "\nthis run\n"); + std::fprintf(out, " leaves written %d\n", r.leafWrites); + std::fprintf(out, " leaves NOT written by a blocked phase %d\n", r.wouldWrite); + std::fprintf(out, " generator words consumed %d (state %s)\n", r.rngWords, + r.rngLoaded ? "loaded" : "UNREADABLE"); + for (const auto& w : r.warnings) std::fprintf(out, " ! %s\n", w.c_str()); +} + +bool WriteMetricJson(const std::string& path, const TurnResult& r, const std::string& inputName, + const std::string& outputName) { + std::ofstream f(path); + if (!f) return false; + const PhaseTally s = TallySpine(); + const PhaseTally t = TallyTail(); + f << "{\n"; + f << " \"schema\": \"sots-standalone-metric/1\",\n"; + f << " \"input\": \"" << JsonEscape(inputName) << "\",\n"; + f << " \"output\": \"" << JsonEscape(outputName) << "\",\n"; + TallyJson(f, "spine", s); + f << ",\n"; + TallyJson(f, "tail", t); + f << ",\n"; + f << " \"run\": {\"leafWrites\": " << r.leafWrites << ", \"blockedLeafWrites\": " + << r.wouldWrite << ", \"rngWords\": " << r.rngWords << ", \"rngLoaded\": " + << (r.rngLoaded ? "true" : "false") << "},\n"; + f << " \"phases\": [\n"; + bool first = true; + for (const auto& rec : r.records) { + if (!first) f << ",\n"; + first = false; + f << " {\"driver\": \"" << DriverName(rec.desc->driver) << "\", \"id\": \"" + << rec.desc->id << "\", \"name\": \"" << rec.desc->name << "\", \"status\": \"" + << StatusName(rec.desc->status) << "\", \"ran\": " << rec.invocations + << ", \"writes\": " << rec.leafWrites << ", \"blockedWrites\": " << rec.wouldWrite + << ", \"rng\": " << rec.rngWords << "}"; + } + f << "\n ]\n}\n"; + return static_cast(f); +} + +} // namespace sots::app diff --git a/src/app/report.h b/src/app/report.h new file mode 100644 index 0000000..8adb351 --- /dev/null +++ b/src/app/report.h @@ -0,0 +1,24 @@ +// Run reporting: the phase log a human reads, and the machine-readable completion metric. +#pragma once + +#include +#include + +#include "app/turn.h" + +namespace sots::app { + +// The phase table with this run's numbers, in execution order. Every phase appears -- +// including the ones that did nothing -- because the point of the listing is the gap. +void PrintPhaseLog(std::FILE* out, const TurnResult& r, bool verbose); + +// A one-screen summary: how many phases of each driver we hold, and how much this run moved. +void PrintSummary(std::FILE* out, const TurnResult& r); + +// The completion metric, as JSON, for the campaign dashboard. Written to `path`; the +// divergence numbers are filled in by the comparison tool that runs the checksum diff, so +// this file carries only what the standalone itself knows. +bool WriteMetricJson(const std::string& path, const TurnResult& r, const std::string& inputName, + const std::string& outputName); + +} // namespace sots::app diff --git a/src/app/turn.cpp b/src/app/turn.cpp new file mode 100644 index 0000000..94efd0a --- /dev/null +++ b/src/app/turn.cpp @@ -0,0 +1,487 @@ +#include "app/turn.h" + +#include +#include +#include +#include +#include +#include + +#include "game/sim/colony.h" +#include "game/sim/economy.h" +#include "game/sim/numeric.h" +#include "game/sim/rng.h" +#include "game/sim/tuning.h" + +namespace sots::app { +namespace { + +using mars::stream::Node; +using mars::stream::shapes::Player; +using mars::stream::shapes::SaveGame; +using mars::stream::shapes::Sys; + +std::string fmt(const char* f, ...) { + char buf[512]; + va_list ap; + va_start(ap, f); + std::vsnprintf(buf, sizeof buf, f, ap); + va_end(ap); + return std::string(buf); +} + +// A generator wrapper for the strategic-sim interface. Every draw is counted, so a phase +// can report its word cost even when the state is not committed. +class CountingRandom final : public sim::IRandom { +public: + explicit CountingRandom(mars::rng::MT19937& g) : g_(g) {} + float NextFloat() override { + ++words_; + return g_.next_float(); + } + std::uint32_t NextIntInclusive(std::uint32_t n) override { + // The rejection loop can spend more than one word; count what the generator moved. + const int before = consumed(); + const std::uint32_t v = g_.next_int_inclusive(n); + words_ += consumed() - before; + return v; + } + std::uint32_t NextUInt32() override { + ++words_; + return g_.next_u32(); + } + int words() const { return words_; } + +private: + // Words handed out since the block was twisted, monotone within a block; used only to + // count a rejection loop, which never spans more than one twist here. + int consumed() const { return mars::rng::MT19937::N - g_.left(); } + mars::rng::MT19937& g_; + int words_ = 0; +}; + +// --------------------------------------------------------------------------------------- +// The player driver +// --------------------------------------------------------------------------------------- + +// Read a player's current research target out of the tech-state section. The target is +// named on the wire, so the lookup is by name; a player with no target has an empty name. +const mars::stream::shapes::TechState* FindTarget(const Player& p) { + if (p.resTNm.empty()) return nullptr; + for (const auto& st : p.techTree.state) + if (st.tNm == p.resTNm) return &st; + return nullptr; +} + +struct PlayerPhaseTotals { + int writes[13] = {}; // indexed by phase number 1..12 + int wouldWrite[13] = {}; + int rng[13] = {}; + int fired[13] = {}; // how many players the phase actually did something for +}; + +void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng, + PlayerPhaseTotals& t) { + // --- P01 ComputeBudget -- blocked on the per-system money output ----------------- + // The formula is here and is verified; what is missing is `systemIncome`. We build the + // inputs we do hold so the shape of the gap is visible, then stop. + { + sim::BudgetInputs in; + in.savings = p.sav; + in.ownsSystems = !p.owners.empty(); + in.maintenance = p.maint; + in.isAI = p.npc; + in.researchRate = p.resRate; + in.resMod = p.resMod; + in.shrm = p.shrm; + in.trm = p.trm; + in.resScl = p.resScl; + in.tra = p.tra; + in.trp = p.trp; + in.hasResearchTarget = !p.resTNm.empty(); + for (const auto& e : p.nexp) { + sim::ExpenseSlider s; + s.minimum = e.xmin; + s.maximum = e.xmax; + s.fraction = e.xper; + in.expenses.push_back(s); + } + // in.systemIncome stays empty: unmodelled input. + const sim::Budget b = sim::ComputeBudget(in, /*projected=*/false); + const int wouldBe = sim::SaturatingAdd(p.sav, b.net); + ++t.fired[1]; + if (wouldBe != p.sav) ++t.wouldWrite[2]; + if (opt.commitBlocked) { + p.sav = wouldBe; + ++t.writes[2]; + } + } + + // --- P07 ClearTimedResearchAccumulators ----------------------------------------- + if (p.trm != 0.f || p.tra != 0 || p.trp != 0) { + if (p.trm != 0.f) ++t.writes[7]; + if (p.tra != 0) ++t.writes[7]; + if (p.trp != 0) ++t.writes[7]; + p.trm = 0.f; + p.tra = 0; + p.trp = 0; + ++t.fired[7]; + } + + // --- P08 DecayRebellionOutputModifier ------------------------------------------- + if (p.rebAI) { + const float before = p.rebOutMod; + float v = p.rebOutMod - 0.04f; + if (v < 1.0f) v = 1.0f; + if (v > 2.0f) v = 2.0f; + p.rebOutMod = v; + ++t.fired[8]; + if (before != v) ++t.writes[8]; + } + + // --- P09 AccumulateTimedResearchBonuses ----------------------------------------- + // Iterated from the LAST element down to index 0; the order is part of the result + // because float addition is not associative. + if (!p.pr.empty()) { + ++t.fired[9]; + const float trmBefore = p.trm; + const std::size_t before = p.pr.size(); + for (std::size_t i = p.pr.size(); i-- > 0;) { + p.trm = static_cast(static_cast(p.trm) + static_cast(p.pr[i].prm)); + if (--p.pr[i].prbt <= 0) p.pr.erase(p.pr.begin() + static_cast(i)); + } + if (p.trm != trmBefore) ++t.writes[9]; + t.writes[9] += static_cast(before - p.pr.size()); + t.writes[9] += static_cast(p.pr.size()); // every surviving entry's counter moved + } + + // --- P10 ConsumeResearchRollPending --------------------------------------------- + // Threshold is a STRICT `0.5f < progress/cost`, and the flag clear is INSIDE the + // branch: a target below half cost keeps the flag into the next turn. + if (const mars::stream::shapes::TechState* target = FindTarget(p)) { + if (p.resErrRoll) { + ++t.fired[10]; + const double cost = target->tResCost; + const float ratio = + cost > 0 ? static_cast(static_cast(target->tResDone) / cost) : 0.f; + if (0.5f < ratio) { + if (rng) { + const int before = rng->words(); + (void)rng->NextFloat(); // the research-event roll + t.rng[10] += rng->words() - before; + } + p.resErrRoll = false; + ++t.writes[10]; + } + } + } + + // --- P11 PostNoResearchEvent -- condition only ---------------------------------- + if (p.resTNm.empty()) { + bool anyAvailable = false; + for (const auto& st : p.techTree.state) + if (st.st == 2) { + anyAvailable = true; + break; + } + if (anyAvailable) { + ++t.fired[11]; + ++t.wouldWrite[11]; + } + } +} + +// --------------------------------------------------------------------------------------- +// The per-system pass +// --------------------------------------------------------------------------------------- + +struct SystemTotals { + int fired = 0, writes = 0, skippedBonus = 0, skippedCountdown = 0, stable = 0; +}; + +void RunSystemTurn(Sys& s, int playerCount, SystemTotals& t) { + ++t.fired; + + // `IsStable()` is a callee's verdict in the original and an input to the model. The + // standalone's stand-in -- owned, not abandoned, not destroyed -- is a HYPOTHESIS, and + // it is measurable: it drives the turns-developing counter, which is a named leaf. + const bool owned = s.pid != 0; + const bool stable = owned && !s.abdn && !s.dstyd; + if (stable) ++t.stable; + + // 1. An unowned system's infrastructure rots. + if (!owned) { + const float before = s.infra; + const float after = static_cast(sim::DecayUnownedInfrastructure(s.infra)); + if (after != before) { + s.infra = after; + ++t.writes; + } + } + + // 2. The two pending bonus pools. Draining them needs the imperial carrying capacity, + // which needs the population->capacity chain; when either pool is non-empty we do + // not touch it and say so. + if (s.pbon != 0 || s.ibon != 0.f) ++t.skippedBonus; + + // 3. Turns-developing. + { + const int before = s.ntdev; + s.ntdev = stable ? s.ntdev + 1 : 0; + if (s.ntdev != before) ++t.writes; + } + + // 4. The long-stability accrual reads the tuning table; with no tuning table loaded its + // increments and targets are all zero, so it is a no-op and is left out rather than + // run with fabricated constants. + + // 5. The turn's resource total is consumed and reset. + if (s.tRes != 0) { + s.tRes = 0; + ++t.writes; + } + + // 6. Growth halts expire every turn. The halt records are a counted list on the wire + // whose element meaning is not settled, so this is reported, not written. + + // 7. The two per-player countdown words. The sweep is skipped entirely when the counter + // word is zero, which is the case throughout the corpus; a non-zero word needs the + // companion "someone is counting" mask, which is not identified on the wire. + if (s.bats2 != 0 || s.rcex != 0) { + ++t.skippedCountdown; + } + (void)playerCount; +} + +} // namespace + +// --------------------------------------------------------------------------------------- +// Generator plumbing +// --------------------------------------------------------------------------------------- + +bool LoadGenerator(const SaveGame& game, mars::rng::MT19937& out) { + const Node& f = game.sim.rng; + if (!f.is_complex() || f.children.size() != 1) return false; + const Node& blob = f.children[0]; + if (blob.kind != mars::stream::Kind::Raw) return false; + return out.load_state(blob.raw.data(), blob.raw.size()); +} + +bool StoreGenerator(SaveGame& game, const mars::rng::MT19937& gen) { + Node& f = game.sim.rng; + if (!f.is_complex() || f.children.size() != 1) return false; + Node& blob = f.children[0]; + if (blob.kind != mars::stream::Kind::Raw || blob.raw.size() < mars::rng::MT19937::kStateBytes) + return false; + // The blob carries three trailing pad bytes past the state; they are preserved. + std::uint8_t tmp[mars::rng::MT19937::kStateBytes]; + gen.save_state(tmp); + std::memcpy(blob.raw.data(), tmp, sizeof tmp); + return true; +} + +void ApplySaveWriterInvariants(SaveGame& game, TurnResult& r) { + // Observed on every save in the corpus: the summary's turn number equals the + // simulation's frame counter. The summary is rebuilt by the writer, not by a turn + // phase, so it belongs here rather than in the phase catalog. + if (game.summary.turn != game.sim.frame) { + game.summary.turn = game.sim.frame; + ++r.leafWrites; + } +} + +// --------------------------------------------------------------------------------------- +// The runner +// --------------------------------------------------------------------------------------- + +TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { + TurnResult r; + + mars::rng::MT19937 gen(1u); + r.rngLoaded = LoadGenerator(game, gen); + if (!r.rngLoaded) + r.warnings.push_back("generator state could not be read from the save; drawing phases " + "will report themselves as unable to draw"); + CountingRandom rng(gen); + + std::size_t nh = 0, ns = 0, np = 0; + const PhaseDesc* hp = HostPhases(nh); + const PhaseDesc* sp = StrategicPhases(ns); + const PhaseDesc* pp = PlayerPhases(np); + + // H00 BeginProcessTurn: the frame counter, which is the turn number the game displays. + { + PhaseRecord rec; + rec.desc = &hp[0]; + ++game.sim.frame; + rec.invocations = 1; + rec.leafWrites = 1; + rec.committed = true; + rec.notes.push_back(fmt("Frame -> %d", game.sim.frame)); + r.records.push_back(rec); + } + + PlayerPhaseTotals pt; + SystemTotals st; + bool playerDriverRan = false; + + for (std::size_t i = 0; i < ns; ++i) { + PhaseRecord rec; + rec.desc = &sp[i]; + + switch (sp[i].index) { + case 0: { // S00 SnapshotPreviousTurn + ++game.sim.modCount; + rec.invocations = 1; + rec.leafWrites = 1; + rec.committed = true; + rec.notes.push_back(fmt("ModCount -> %d", game.sim.modCount)); + rec.notes.push_back("the real turn advances this counter 12-44 times, from " + "writers spread across both drivers; only this one is " + "modelled, so the leaf will not match yet"); + break; + } + case 11: { // S11 SystemTurn + for (auto& e : game.sim.systems) + RunSystemTurn(e.sys, static_cast(game.sim.players.size()), st); + rec.invocations = st.fired; + rec.leafWrites = st.writes; + rec.committed = st.writes > 0; + rec.notes.push_back(fmt("%d systems, %d judged stable by the owned/not-abandoned " + "stand-in (HYPOTHESIS -- the original asks a callee)", + st.fired, st.stable)); + if (st.skippedBonus) + rec.notes.push_back(fmt("%d system(s) left their pending bonus pool alone: " + "draining it needs the imperial carrying capacity", + st.skippedBonus)); + if (st.skippedCountdown) + rec.notes.push_back(fmt("%d system(s) left their countdown words alone: the " + "companion active-player mask is not identified", + st.skippedCountdown)); + break; + } + case 13: { // S13 PlayerTurn -- the nested driver + for (auto& e : game.sim.players) + RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt); + rec.invocations = static_cast(game.sim.players.size()); + for (int k = 1; k <= 12; ++k) { + rec.leafWrites += pt.writes[k]; + rec.wouldWrite += pt.wouldWrite[k]; + rec.rngWords += pt.rng[k]; + } + rec.committed = rec.leafWrites > 0; + playerDriverRan = true; + break; + } + case 31: { // S31 EncounterDetectionAndStatusRestore + // The status restore: every player that is not an AI, or whose secondary AI + // flag is set, goes back to status 1. The secondary flag is not on the wire, + // so the AI test alone is used and the difference is reported. + int n = 0; + for (auto& e : game.sim.players) { + if (e.player.npc) continue; + if (e.player.status == 1) continue; + ++n; + if (opt.commitBlocked) e.player.status = 1; + } + rec.invocations = static_cast(game.sim.players.size()); + rec.leafWrites = opt.commitBlocked ? n : 0; + rec.wouldWrite = opt.commitBlocked ? 0 : n; + rec.committed = rec.leafWrites > 0; + rec.notes.push_back(fmt("%d player status word(s) would be restored to 1", n)); + rec.notes.push_back("MEASURED: the phase writes 1, the post-turn file carries " + "4, and a load resets it to 0. Writing the 1 REGRESSED two " + "agreeing leaves on the turn2->turn3 pair, so the write is " + "held back until the writer that produces the 4 is found"); + break; + } + default: + break; // named no-op + } + + r.records.push_back(rec); + + // The player driver's own phases are listed immediately after the phase that runs + // them, so the printed log is the turn in execution order. + if (sp[i].index == 13 && playerDriverRan) { + for (std::size_t k = 0; k < np; ++k) { + PhaseRecord pr; + pr.desc = &pp[k]; + const int idx = pp[k].index; + pr.invocations = pt.fired[idx]; + pr.leafWrites = pt.writes[idx]; + pr.wouldWrite = pt.wouldWrite[idx]; + pr.rngWords = pt.rng[idx]; + pr.committed = pr.leafWrites > 0; + if (idx == 1) + pr.notes.push_back(fmt("budget computed for %d player(s) with an EMPTY " + "system-income vector; nothing committed", + pt.fired[1])); + if (idx == 2 && pt.wouldWrite[2]) + pr.notes.push_back(fmt("%d player(s) would have had savings rewritten", + pt.wouldWrite[2])); + if (idx == 10) + pr.notes.push_back(fmt("%d player(s) held both a target and the pending " + "flag; %d roll(s) fired", + pt.fired[10], pt.writes[10])); + if (idx == 11 && pt.fired[11]) + pr.notes.push_back(fmt("%d player(s) meet the no-research condition; the " + "event is not posted", + pt.fired[11])); + r.records.push_back(pr); + } + } + } + + // The tail is a separate driver reached from a different message. Nothing in it is + // implemented except the counter bump, so it is listed rather than run -- but it is + // listed, because the autosave is written after it and two of its phases draw. + std::size_t nt = 0; + const PhaseDesc* tp = TailPhases(nt); + for (std::size_t i = 0; i < nt; ++i) { + PhaseRecord rec; + rec.desc = &tp[i]; + if (tp[i].index == 0) { + ++game.sim.modCount; + rec.invocations = 1; + rec.leafWrites = 1; + rec.committed = true; + rec.notes.push_back(fmt("ModCount -> %d", game.sim.modCount)); + } + r.records.push_back(rec); + } + + // H01 SaveWriterInvariants. + { + PhaseRecord rec; + rec.desc = &hp[1]; + const int before = game.summary.turn; + ApplySaveWriterInvariants(game, r); + rec.invocations = 1; + rec.leafWrites = game.summary.turn != before ? 1 : 0; + rec.committed = rec.leafWrites > 0; + rec.notes.push_back(fmt("Summary.Turn -> %d", game.summary.turn)); + r.records.push_back(rec); + } + // ApplySaveWriterInvariants counts its own write; zero the accumulator before the fold so + // the per-phase records are the single source of the total. + r.leafWrites = 0; + for (const auto& rec : r.records) { + r.leafWrites += rec.leafWrites; + r.wouldWrite += rec.wouldWrite; + r.rngWords += rec.rngWords; + } + + if (opt.commitRng && r.rngLoaded) { + if (!StoreGenerator(game, gen)) + r.warnings.push_back("generator state could not be written back"); + } else if (r.rngWords > 0) { + r.warnings.push_back( + "the generator advanced during this run but the save keeps its original state " + "(--commit-rng to write it); the turn's full draw count is not yet attributed"); + } + + return r; +} + +} // namespace sots::app diff --git a/src/app/turn.h b/src/app/turn.h new file mode 100644 index 0000000..6cefe7d --- /dev/null +++ b/src/app/turn.h @@ -0,0 +1,66 @@ +// One strategic turn, run over a loaded save. +// +// The runner walks the phase catalog in the original's order and calls whatever we have for +// each phase. Every phase produces a record -- including the ones that do nothing -- so the +// run log is the phase table with this run's numbers filled in. +#pragma once + +#include +#include +#include + +#include "app/phase_catalog.h" +#include "mars/rng/mt19937.h" +#include "mars/stream/shapes.h" + +namespace sots::app { + +struct TurnOptions { + // Commit the results of phases whose inputs are not fully modelled. Off by default: + // a blocked phase writing a confidently-wrong value is worse than a phase that does not + // write at all, because it replaces a leaf that may currently agree by construction. + bool commitBlocked = false; + // Write the advanced generator state back into the save. Off by default: the turn + // consumes roughly eighteen to twenty words that nothing here models, so an advanced + // state is wrong in a different way than an untouched one, and an untouched one at + // least tells the truth about how far the model has got. + bool commitRng = false; + // Tuning constants were loaded, so formulas that read them may run. + bool haveTuning = false; +}; + +// One line of the run log. +struct PhaseRecord { + const PhaseDesc* desc = nullptr; + int invocations = 0; // how many times the phase body ran (per player / per system) + int leafWrites = 0; // save leaves this phase actually changed + int wouldWrite = 0; // leaves a blocked phase WOULD have changed had it committed + int rngWords = 0; // generator words this phase consumed + bool committed = false; + std::vector notes; // measured facts, one short line each +}; + +struct TurnResult { + std::vector records; // spine order, with player phases nested after S13 + int leafWrites = 0; + int wouldWrite = 0; + int rngWords = 0; + bool rngLoaded = false; + std::vector warnings; +}; + +// Run one strategic turn in place. `game` is mutated; the caller re-serialises it. +TurnResult RunStrategicTurn(mars::stream::shapes::SaveGame& game, const TurnOptions& opt); + +// The identity the save writer imposes on every file in the corpus: the summary's turn +// number is the simulation's frame counter. Applied by the runner after the drivers. +void ApplySaveWriterInvariants(mars::stream::shapes::SaveGame& game, TurnResult& r); + +// Pull the generator out of the save's opaque RNG frame. Returns false when the frame is +// not the expected blob, which is not an error: the run continues with no generator and +// every phase that would draw reports itself as unable to. +bool LoadGenerator(const mars::stream::shapes::SaveGame& game, mars::rng::MT19937& out); +// Write a generator's state back into the save's RNG frame. +bool StoreGenerator(mars::stream::shapes::SaveGame& game, const mars::rng::MT19937& gen); + +} // namespace sots::app diff --git a/tests/app/CMakeLists.txt b/tests/app/CMakeLists.txt new file mode 100644 index 0000000..cb2eeca --- /dev/null +++ b/tests/app/CMakeLists.txt @@ -0,0 +1,17 @@ +# Standalone turn-driver tests. +# +# test_catalog needs nothing but the catalog and always runs. +# test_turn reads the owner's saves via SOTS_SAVES_DIR at run time and skips cleanly when it +# is unset -- no .sav ever enters this repo. +add_executable(app_test_catalog test_catalog.cpp) +target_link_libraries(app_test_catalog PRIVATE sots_app) +add_test(NAME app_catalog COMMAND app_test_catalog) + +add_executable(app_test_turn test_turn.cpp) +target_link_libraries(app_test_turn PRIVATE sots_app) +add_test(NAME app_turn COMMAND app_test_turn) + +foreach(_t app_test_catalog app_test_turn) + target_include_directories(${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic) +endforeach() diff --git a/tests/app/test_catalog.cpp b/tests/app/test_catalog.cpp new file mode 100644 index 0000000..1fd1aaa --- /dev/null +++ b/tests/app/test_catalog.cpp @@ -0,0 +1,79 @@ +// The phase catalog is a published claim about the turn: it must stay complete, ordered and +// self-consistent, because the completion metric is computed from it. +#include +#include +#include +#include + +#include "app/phase_catalog.h" + +static int failures = 0; +#define CHECK(c) \ + do { \ + if (!(c)) { \ + std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); \ + ++failures; \ + } \ + } while (0) + +using namespace sots::app; + +static void CheckTable(const PhaseDesc* p, size_t n, Driver d, int firstIndex, + std::set& ids, std::set& names) { + for (size_t i = 0; i < n; ++i) { + CHECK(p[i].driver == d); + // Indices are contiguous and in execution order -- a gap means a phase was dropped. + CHECK(p[i].index == firstIndex + static_cast(i)); + CHECK(p[i].id && std::strlen(p[i].id) == 3); + CHECK(p[i].name && p[i].name[0] != '\0'); + CHECK(ids.insert(p[i].id).second); + CHECK(names.insert(p[i].name).second); + // Anything not a stub must carry a note saying what it does or what is missing. + if (p[i].status != PhaseStatus::Stub) CHECK(p[i].note && p[i].note[0] != '\0'); + } +} + +int main() { + std::set ids, names; + size_t nh = 0, ns = 0, np = 0, nt = 0; + const PhaseDesc* h = HostPhases(nh); + const PhaseDesc* s = StrategicPhases(ns); + const PhaseDesc* p = PlayerPhases(np); + const PhaseDesc* t = TailPhases(nt); + + // The three published phase counts. These are load-bearing: the milestone is stated as + // "N of 44", and 44 is 32 + 12. + CHECK(ns == 32); + CHECK(np == 12); + CHECK(nt == 37); + CHECK(static_cast(ns + np) == kSpinePhaseCount); + + // The host steps are deliberately NOT part of the milestone's denominator. + CHECK(nh == 2); + CheckTable(h, nh, Driver::Host, 0, ids, names); + CheckTable(s, ns, Driver::Strategic, 0, ids, names); + CheckTable(p, np, Driver::Player, 1, ids, names); + CheckTable(t, nt, Driver::Tail, 0, ids, names); + + const PhaseTally spine = TallySpine(); + CHECK(spine.total == kSpinePhaseCount); + CHECK(spine.verified + spine.implemented + spine.partial + spine.blocked + spine.stub == + spine.total); + CHECK(spine.modelled() <= spine.total); + CHECK(spine.committed() <= spine.modelled()); + + const PhaseTally tail = TallyTail(); + CHECK(tail.total == 37); + CHECK(tail.verified + tail.implemented + tail.partial + tail.blocked + tail.stub == + tail.total); + + // A phase that does nothing must not claim to be verified: `Verified` in this table means + // "compared against the live game", and nothing here has been. + CHECK(spine.verified == 0); + CHECK(tail.verified == 0); + + std::printf("catalog: spine %d/%d modelled (%d committed), tail %d/%d modelled\n", + spine.modelled(), spine.total, spine.committed(), tail.modelled(), tail.total); + std::printf(failures ? "FAILED (%d)\n" : "ok (%d failures)\n", failures); + return failures ? 1 : 0; +} diff --git a/tests/app/test_turn.cpp b/tests/app/test_turn.cpp new file mode 100644 index 0000000..2bafe15 --- /dev/null +++ b/tests/app/test_turn.cpp @@ -0,0 +1,112 @@ +// Drive one strategic turn over every save in $SOTS_SAVES_DIR and assert the properties +// the standalone must hold whatever the phase coverage is: +// +// 1. an untouched load re-serialises byte-identically (the foundation the diff rests on); +// 2. a turn never breaks the file -- the post-turn state still round-trips; +// 3. the counters that ARE modelled moved, and in the right direction; +// 4. no phase committed a write while its inputs were declared missing; +// 5. the generator is left alone unless asked for. +// +// Skips cleanly (exit 0) when the variable is unset. No .sav ever enters this repo. +#include +#include +#include +#include +#include + +#include + +#include "app/report.h" +#include "app/turn.h" +#include "mars/stream/save.h" + +static int failures = 0; +#define CHECK(c) \ + do { \ + if (!(c)) { \ + std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); \ + ++failures; \ + } \ + } while (0) + +int main() { + const char* dir = std::getenv("SOTS_SAVES_DIR"); + if (!dir || !*dir) { + std::printf("app_test_turn: SOTS_SAVES_DIR unset, skipped\n"); + return 0; + } + DIR* d = opendir(dir); + if (!d) { + std::fprintf(stderr, "app_test_turn: cannot open %s\n", dir); + return 1; + } + std::vector saves; + while (struct dirent* e = readdir(d)) { + const std::string n = e->d_name; + if (n.size() > 4 && n.compare(n.size() - 4, 4, ".sav") == 0) + saves.push_back(std::string(dir) + "/" + n); + } + closedir(d); + if (saves.empty()) { + std::printf("app_test_turn: no .sav in %s, skipped\n", dir); + return 0; + } + + int ran = 0; + for (const std::string& path : saves) { + mars::stream::SaveDocument doc; + try { + doc = mars::stream::read_save_file(path); + } catch (const std::exception& ex) { + std::printf(" %s: unreadable (%s), skipped\n", path.c_str(), ex.what()); + continue; + } + if (doc.count(mars::stream::Issue::Error)) { + std::printf(" %s: parse errors, skipped\n", path.c_str()); + continue; + } + ++ran; + + // 1. the foundation + CHECK(mars::stream::write_save(doc.game) == doc.inflated); + + const int frame0 = doc.game.sim.frame; + const int mod0 = doc.game.sim.modCount; + const mars::stream::Node rng0 = doc.game.sim.rng; + + sots::app::TurnOptions opt; // defaults: commit nothing that is not fully modelled + const sots::app::TurnResult r = sots::app::RunStrategicTurn(doc.game, opt); + + // 2. the turn did not break the file + mars::stream::Bytes after = mars::stream::write_save(doc.game); + CHECK(!after.empty()); + mars::stream::SaveDocument again = mars::stream::read_save_bytes(after.data(), after.size()); + CHECK(again.count(mars::stream::Issue::Error) == 0); + CHECK(mars::stream::write_save(again.game) == after); + + // 3. modelled counters moved + CHECK(doc.game.sim.modCount > mod0); + CHECK(doc.game.summary.turn == doc.game.sim.frame); + CHECK(doc.game.sim.frame == frame0 + 1); + + // 4. blocked phases stayed blocked + for (const auto& rec : r.records) { + if (rec.desc->status == sots::app::PhaseStatus::Blocked) CHECK(rec.leafWrites == 0); + if (rec.desc->status == sots::app::PhaseStatus::Stub) { + CHECK(rec.leafWrites == 0); + CHECK(rec.rngWords == 0); + } + } + + // 5. the generator is untouched by default + CHECK(doc.game.sim.rng.children.size() == rng0.children.size()); + if (!doc.game.sim.rng.children.empty() && !rng0.children.empty()) + CHECK(doc.game.sim.rng.children[0].raw == rng0.children[0].raw); + + std::printf(" %s: %d leaf write(s), %d blocked, %d rng word(s)\n", path.c_str(), + r.leafWrites, r.wouldWrite, r.rngWords); + } + + std::printf("app_test_turn: %d save(s) driven, %d failure(s)\n", ran, failures); + return failures ? 1 : 0; +}