merge lane S2: src/app standalone turn runner; phase catalog across all three drivers; divergence + completion metric
This commit is contained in:
commit
ce12a5a0b2
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