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
This commit is contained in:
parent
bcf429740c
commit
39c01422f7
14 changed files with 1638 additions and 1 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
213
docs/S-standalone.md
Normal file
213
docs/S-standalone.md
Normal file
|
|
@ -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.
|
||||
21
src/app/CMakeLists.txt
Normal file
21
src/app/CMakeLists.txt
Normal file
|
|
@ -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()
|
||||
140
src/app/main.cpp
Normal file
140
src/app/main.cpp
Normal file
|
|
@ -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 <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
#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<const char*>(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;
|
||||
}
|
||||
270
src/app/phase_catalog.cpp
Normal file
270
src/app/phase_catalog.cpp
Normal file
|
|
@ -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<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
|
||||
90
src/app/phase_catalog.h
Normal file
90
src/app/phase_catalog.h
Normal file
|
|
@ -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 <cstddef>
|
||||
|
||||
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
|
||||
115
src/app/report.cpp
Normal file
115
src/app/report.cpp
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#include "app/report.h"
|
||||
|
||||
#include <fstream>
|
||||
|
||||
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<unsigned char>(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<Driver>(-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<bool>(f);
|
||||
}
|
||||
|
||||
} // namespace sots::app
|
||||
24
src/app/report.h
Normal file
24
src/app/report.h
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Run reporting: the phase log a human reads, and the machine-readable completion metric.
|
||||
#pragma once
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#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
|
||||
487
src/app/turn.cpp
Normal file
487
src/app/turn.cpp
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
#include "app/turn.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#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<float>(static_cast<double>(p.trm) + static_cast<double>(p.pr[i].prm));
|
||||
if (--p.pr[i].prbt <= 0) p.pr.erase(p.pr.begin() + static_cast<long>(i));
|
||||
}
|
||||
if (p.trm != trmBefore) ++t.writes[9];
|
||||
t.writes[9] += static_cast<int>(before - p.pr.size());
|
||||
t.writes[9] += static_cast<int>(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<float>(static_cast<double>(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<float>(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<int>(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<int>(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<int>(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
|
||||
66
src/app/turn.h
Normal file
66
src/app/turn.h
Normal file
|
|
@ -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 <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<std::string> notes; // measured facts, one short line each
|
||||
};
|
||||
|
||||
struct TurnResult {
|
||||
std::vector<PhaseRecord> records; // spine order, with player phases nested after S13
|
||||
int leafWrites = 0;
|
||||
int wouldWrite = 0;
|
||||
int rngWords = 0;
|
||||
bool rngLoaded = false;
|
||||
std::vector<std::string> 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
|
||||
17
tests/app/CMakeLists.txt
Normal file
17
tests/app/CMakeLists.txt
Normal file
|
|
@ -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()
|
||||
79
tests/app/test_catalog.cpp
Normal file
79
tests/app/test_catalog.cpp
Normal file
|
|
@ -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 <cstdio>
|
||||
#include <cstring>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
#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<std::string>& ids, std::set<std::string>& 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<int>(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<std::string> 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<int>(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;
|
||||
}
|
||||
112
tests/app/test_turn.cpp
Normal file
112
tests/app/test_turn.cpp
Normal file
|
|
@ -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 <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <dirent.h>
|
||||
|
||||
#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<std::string> 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue