sots-engine/tests/app/test_command_replay.cpp
alex 98257b9e82 RB: sots_turn --turn-commands replays a recorded command stream, and ModCount closes
The milestone needs a second input. The AI is a client, not part of the sim: it decides once,
on one machine, and its decisions reach the server as commands. A save carries the board and
half the input, which is why our turn wrote ModCount 14 where the original writes 24 -- the
missing ten ARE the turn's command stream.

  * `game/ai/apply_order` -- the thirty-step schedule the original drains a batch in: twenty-
    seven per-LIST steps (every player's elements of one list before the next list starts) with
    three per-PLAYER gate loops spliced in at step 10, 29 and 30. Neither list order nor member
    order, and both facts are asserted so a port that sorted cannot pass.
  * `game/ai/command_capture` -- a `.tcb` recorded turn: gates, list lengths, elements in wire
    order, per-client seeds, and `?` for a field the instrument could not read. An element count
    that disagrees with its declaration is REJECTED, because a counter quietly one short is
    indistinguishable from a turn that issued one fewer command.
  * `app/command_replay` -- applies it before the drivers, where the End-Turn dispatcher does.
    Every command is CHARGED; only the ones whose subsystem we hold are APPLIED; the rest are
    declined with the named gap, or marked incomplete when the capture itself lacks the payload.
  * `--turn-commands`, `--replay-count-only`, `--replay-recorded-names`, `--ai-seed`.

Measured on a fresh build directory, canonical pair turn2-state -> turn3-state:
  108 -> 62, closed 46, regressed 0  (was 108 -> 63, closed 45) -- /Sim/ModCount now reads the
  original's 24, decomposed as 2 drivers + 4 research-rate gates + build + rates + list 10 +
  two list-14 + fleet move, with the list-23 population element free.

turn1-state replayed against the SAME run's autosave closes 7 (ModCount and all six research
leaves); against the historical turn2-state it closes 6 and leaves player 512's research pick
diverging -- which is correct, because that recording is from a process that picked differently.

One prediction was falsified and it paid for itself: the first run regressed two leaves because
the rates element's MEMORY field order is not its wire order. The converter no longer claims a
mapping it cannot support.

Two new addresses (the second and third gate-loop heads) via ghidra/addresses.d/lane-rb.json;
header regenerated, never hand-resolved.
2026-09-08 18:57:46 -04:00

233 lines
9.3 KiB
C++

// Replaying a recorded command stream against a save.
//
// Every check here runs on a save built in this file, so the test always runs and no .sav enters
// the repo. What it pins:
//
// 1. THE COUNT IS CHARGED FOR EVERY COMMAND, whether or not we can apply it. That is the
// point of the whole module: the modification counter is a property of the turn's command
// stream, not of our ability to model what the commands did, and it is the one leaf of the
// save that a save alone can never supply.
// 2. A COMMAND WE CANNOT APPLY IS COUNTED AND DECLINED, WITH A NAMED REASON -- never applied
// with a guessed payload, and never dropped.
// 3. AN INCOMPLETE ELEMENT IS NOT A ZERO ELEMENT. A route whose hops the instrument could not
// read must leave the fleet alone.
// 4. THE COUNT-ONLY CONTROL. With writing suppressed the state is untouched and the count is
// unchanged. That is what separates "our handlers happen to agree with the oracle" from
// "our handlers wrote to the right place", which an outcome cannot tell apart when the
// command re-issues a value the save already holds -- and on the reference turn EVERY
// modelled handler does exactly that.
#include <cstdio>
#include <string>
#include "app/command_replay.h"
#include "game/ai/command_capture.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 mars::stream::shapes::SaveGame;
namespace {
// Two players and one system, which is all any command below names.
SaveGame Board() {
SaveGame g;
g.sim.modCount = 100;
mars::stream::shapes::PlayerEntry a, b;
a.playerID = 16;
a.player.resRate = 0.25f;
b.playerID = 32;
b.player.resRate = 0.25f;
g.sim.players = {a, b};
mars::stream::shapes::SysEntry s;
s.sysID = 288;
s.sys.rts.srsc = 1.0f;
g.sim.systems = {s};
return g;
}
sots::ai::Capture Parse(const char* text) {
sots::ai::Capture c;
sots::ai::CaptureDiagnostics d;
if (!sots::ai::ParseCapture(text, c, d)) {
for (const auto& e : d.errors) std::printf(" parse error: %s\n", e.c_str());
++failures;
}
return c;
}
// The reference turn's shape: four submitting blocks, one with orders, four empty slots.
const char* kStream = R"(tcb 1
block 0 16
gate 0 rate 0.25
block 1 32
gate 1 rate 0.8
list 1 3 1
elem 1 3 0 i2 i18 i288 i0
list 1 5 1
elem 1 5 0 i288 ? ? ? ? ? ? ?
list 1 8 1
elem 1 8 0 i34 v1
list 1 10 1
elem 1 10 0 i288 i34 v1
list 1 14 2
elem 1 14 0 i34 i0 b1
elem 1 14 1 i34 i1 b1
list 1 23 1
elem 1 23 0 i288 ?
block 2 0
block 3 0
)";
void TestTheStreamIsCountedInFull() {
SaveGame g = Board();
const sots::ai::Capture c = Parse(kStream);
sots::app::ReplayOptions opt;
const sots::app::ReplayReport r = sots::app::ReplayTurnCommands(g, c, opt);
// Two rate gates + build + rates + list 8 + list 10 + two list-14 = 8. List 23 is in the
// free half and costs nothing, which is the half no save has ever exercised.
CHECK(r.bumps == 8);
CHECK(r.bumpsExact);
CHECK(r.commands == 9); // the eight paying ones plus the free list-23 element
CHECK(r.blocks == 4);
CHECK(r.submittingBlocks == 2);
// Every command has a disposition and the four sum to the total.
CHECK(r.applied + r.transcribed + r.declined + r.incomplete == r.commands);
// Nothing may be silently dropped: every logged command carries a `what`, and every command
// we did not apply carries a reason.
for (const auto& e : r.log) {
CHECK(!e.what.empty());
if (e.disposition != sots::app::ReplayDisposition::Applied) CHECK(!e.reason.empty());
}
}
void TestDeclinedCommandsChangeNothing() {
SaveGame g = Board();
const sots::ai::Capture c = Parse(kStream);
sots::app::ReplayOptions opt;
sots::app::ReplayTurnCommands(g, c, opt);
// The build order names design 18 and system 288; the fleet commands name fleet 34, which
// this board does not contain. None of it may have leaked into the save.
CHECK(g.sim.fleets.empty());
CHECK(g.sim.systems.size() == 1);
CHECK(g.sim.systems[0].sys.rts.srsc == 1.0f); // the rates frame is unread, so untouched
CHECK(g.sim.systems[0].sys.rts.srt == 0.0f);
// The one thing that IS modelled did run: the AI's rate reached its player and only its
// player.
CHECK(g.sim.players[1].player.resRate == 0.8f);
CHECK(g.sim.players[0].player.resRate == 0.25f);
}
void TestCountOnlyIsTheControl() {
const sots::ai::Capture c = Parse(kStream);
SaveGame wrote = Board(), counted = Board();
sots::app::ReplayOptions on, off;
off.countOnly = true;
const sots::app::ReplayReport a = sots::app::ReplayTurnCommands(wrote, c, on);
const sots::app::ReplayReport b = sots::app::ReplayTurnCommands(counted, c, off);
CHECK(a.bumps == b.bumps); // suppressing the writes may not change the count
CHECK(b.leafWrites == 0);
CHECK(a.leafWrites == 1); // the AI's rate, 0.25 -> 0.8
CHECK(counted.sim.players[1].player.resRate == 0.25f);
CHECK(wrote.sim.players[1].player.resRate == 0.8f);
}
void TestAnEmptyBlockStillCostsOne() {
SaveGame g = Board();
const sots::ai::Capture c = Parse("tcb 1\nblock 0 16\ngate 0 rate 0.25\n");
sots::app::ReplayOptions opt;
const sots::app::ReplayReport r = sots::app::ReplayTurnCommands(g, c, opt);
// The send-buffer build sets the rate gate unconditionally, so a player who ordered nothing
// at all still costs one -- and on the reference turn four of the ten bumps are exactly this.
CHECK(r.bumps == 1);
CHECK(r.commands == 1);
CHECK(r.leafWrites == 0); // 0.25 was already the value
}
void TestAGateWithNoApplierMakesTheCountInexact() {
SaveGame g = Board();
const sots::ai::Capture c = Parse("tcb 1\nblock 0 16\ngate 0 civilian\n");
sots::app::ReplayOptions opt;
const sots::app::ReplayReport r = sots::app::ReplayTurnCommands(g, c, opt);
CHECK(!r.bumpsExact);
CHECK(r.bumps == 0); // a lower bound, and the report says so rather than guessing
CHECK(!r.warnings.empty());
}
void TestTheTargetGateIsATranscription() {
const char* stream = "tcb 1\nblock 0 32\ngate 0 target 144 name IND_Waldo\n";
// Without the flag the command is counted and declined: the wire carries an id, the save
// carries a name, and the map between them is unread.
{
SaveGame g = Board();
const sots::ai::Capture c = Parse(stream);
sots::app::ReplayOptions opt;
const sots::app::ReplayReport r = sots::app::ReplayTurnCommands(g, c, opt);
CHECK(r.bumps == 1);
CHECK(r.declined == 1 && r.transcribed == 0);
CHECK(g.sim.players[1].player.resTNm.empty());
}
// With it, the leaf is written -- and reported in its own column, because writing a value an
// instrument observed is not the same as computing one.
{
SaveGame g = Board();
const sots::ai::Capture c = Parse(stream);
sots::app::ReplayOptions opt;
opt.useRecordedNames = true;
const sots::app::ReplayReport r = sots::app::ReplayTurnCommands(g, c, opt);
CHECK(r.bumps == 1);
CHECK(r.transcribed == 1 && r.applied == 0);
CHECK(g.sim.players[1].player.resTNm == "IND_Waldo");
}
// A capture with no recorded name declines even when the flag is given.
{
SaveGame g = Board();
const sots::ai::Capture c = Parse("tcb 1\nblock 0 32\ngate 0 target 144\n");
sots::app::ReplayOptions opt;
opt.useRecordedNames = true;
const sots::app::ReplayReport r = sots::app::ReplayTurnCommands(g, c, opt);
CHECK(r.declined == 1 && r.transcribed == 0);
CHECK(g.sim.players[1].player.resTNm.empty());
}
}
void TestCommandsAreAppliedInTheOriginalsOrder() {
SaveGame g = Board();
const sots::ai::Capture c = Parse(kStream);
sots::app::ReplayOptions opt;
const sots::app::ReplayReport r = sots::app::ReplayTurnCommands(g, c, opt);
// The log is in schedule order, and the schedule is not list order: list 5 and list 23 come
// before the gates, and the gates come before list 3, list 10, list 14 and list 8. A port
// that applied lists 1..27 in order would produce a log whose step numbers were sorted by
// list number instead.
int prev = -1;
for (const auto& e : r.log) {
CHECK(e.step >= prev);
prev = e.step;
}
CHECK(r.log.front().what.rfind("list 5", 0) == 0);
CHECK(r.log.back().what.rfind("list 8", 0) == 0);
// A player id reaches every logged command, so a multi-player turn can be attributed.
for (const auto& e : r.log) CHECK(e.playerId == 16 || e.playerId == 32);
}
} // namespace
int main() {
TestTheStreamIsCountedInFull();
TestDeclinedCommandsChangeNothing();
TestCountOnlyIsTheControl();
TestAnEmptyBlockStillCostsOne();
TestAGateWithNoApplierMakesTheCountInexact();
TestTheTargetGateIsATranscription();
TestCommandsAreAppliedInTheOriginalsOrder();
std::printf("app_test_command_replay: %s\n", failures ? "FAILURES" : "ok");
return failures ? 1 : 0;
}