sots-engine/tests/game_ai/test_command_capture.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

280 lines
12 KiB
C++

// The recorded-turn capture format, and the two real turns that exist in it.
//
// The two captures below are transcriptions of live dumps taken at the original's own
// command-application routine on two consecutive End Turns of the reference game. They are here
// as VALUES, the way an address is a value: they are what the game submitted, and nothing in
// this engine produced them.
//
// What the checks are for, in order of what they would catch:
//
// * THE COUNT. Each capture must cost exactly the ten command bumps the reference turns were
// measured at, and they must be two DIFFERENT tens -- one is four rate gates plus a build,
// rates, a fleet group and a population command; the other is four rate gates plus three
// research targets, a design, a build and rates. Ten twice is not a constant. It is the
// single most valuable thing in this file, because the counter is the one leaf of the save
// that cannot be reached from a save at all.
// * THE FOUR EMPTY SLOTS. The batch is sized to the player count, so the non-playing factions
// occupy slots with a player id of zero, every gate clear and every list empty. They must
// cost nothing. A parser that read their uninitialised gate payloads as SET gates would come
// out four bumps high and look plausible.
// * THE LOST ELEMENT. A capture whose declared list length disagrees with the elements it
// carries must be REJECTED, not silently short. A counter quietly one under is
// indistinguishable from a turn that issued one fewer command.
// * `?` IS NOT ZERO. An element the instrument could not fully read must come out incomplete,
// so the replayer counts it and declines to apply it. A parser that defaulted the unknown
// fields to zero would produce a confident wrong route and a confident wrong build order.
#include "game/ai/command_capture.h"
#include <cstdio>
#include <string>
using namespace sots::ai;
namespace {
int g_checks = 0;
int g_fails = 0;
void check(bool ok, const std::string& what) {
++g_checks;
if (!ok) {
++g_fails;
std::fprintf(stderr, "FAIL: %s\n", what.c_str());
}
}
// The reference turn: turn 2 -> turn 3 of the recorded game. Four clients submit; one of them
// has orders. The rates element's slider values are `?` on purpose -- the dump reads the frame
// in MEMORY order and the memory order of that frame is not its wire order, a fact this campaign
// learned by writing the one non-zero slider into the wrong member and regressing two leaves.
const char* kTurn2to3 = R"(tcb 1
meta source l4-turn2to3-aiorders.txt
meta batch seq=2 n=8
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 496
gate 2 rate 0.8
block 3 512
gate 3 rate 0.8
block 4 0
block 5 0
block 6 0
block 7 0
)";
// The turn before it. A different ten: three research targets and a new ship design, and NOT one
// element in the fleet group.
const char* kTurn1to2 = R"(tcb 1
meta source l4-turn1to2-aiorders.txt
seed client0 0x11223344
block 0 16
gate 0 rate 0.25
block 1 32
gate 1 rate 0.8
gate 1 target 144 name IND_Waldo
list 1 1 1
elem 1 1 0 ?
list 1 3 1
elem 1 3 0 i1 i18 i288 i0
list 1 5 1
elem 1 5 0 i288 ? ? ? ? ? ? ?
list 1 23 1
elem 1 23 0 i288 ?
block 2 496
gate 2 rate 0.8
gate 2 target 90 name DRV_PlsFiss
block 3 512
gate 3 rate 0.8
gate 3 target 288 name XNC_TrnsMorr2
block 4 0
block 5 0
block 6 0
block 7 0
)";
Capture Parse(const char* text, const std::string& what) {
Capture c;
CaptureDiagnostics d;
const bool ok = ParseCapture(text, c, d);
check(ok, what + " parses");
for (const auto& e : d.errors) std::fprintf(stderr, " parse error: %s\n", e.c_str());
return c;
}
ModCountCost CostOf(const Capture& c) {
std::vector<TurnCommandBlock> blocks;
for (const auto& b : c.blocks) blocks.push_back(ToTurnCommandBlock(b));
return TurnModCountDelta(blocks);
}
void TestReferenceTurnTwoToThree() {
const Capture c = Parse(kTurn2to3, "turn 2->3");
check(c.blocks.size() == 8, "the batch is sized to the player count, not the submitter count");
int submitting = 0;
for (const auto& b : c.blocks) submitting += b.playerId != 0 ? 1 : 0;
check(submitting == 4, "four clients submit");
const CapturedBlock& ai = c.blocks[1];
check(ai.playerId == 32, "block 1 is the AI empire");
check(ai.List(3).size() == 1 && ai.List(5).size() == 1 && ai.List(8).size() == 1 &&
ai.List(10).size() == 1 && ai.List(14).size() == 2 && ai.List(23).size() == 1,
"lists 3, 5, 8, 10, 14x2 and 23");
for (int n = 1; n <= kCommandListCount; ++n) {
const bool expected = n == 3 || n == 5 || n == 8 || n == 10 || n == 14 || n == 23;
check(ai.List(n).empty() != expected, "list " + std::to_string(n) + " emptiness");
}
// The build order, in WIRE order: ordinal, design, system, trailing. The ordinal is the
// running build-queue index and it is 2 on this turn and 1 on the one before, which is the
// cross-check that the descending memory order was undone the right way round.
const TurnCommandBlock t = ToTurnCommandBlock(ai);
check(t.build.size() == 1 && t.build[0].ordinal == 2 && t.build[0].designId == 18 &&
t.build[0].systemId == 288,
"build order {ordinal 2, design 18, system 288}");
// The AI's fleet order is TWO list-14 elements against one fleet, modes 0 then 1. That is
// the prediction the whole cost model turned on, and it is here as element values.
check(t.fleetTasks.size() == 2 && t.fleetTasks[0].fleetId == 34 &&
t.fleetTasks[0].mode == 0 && t.fleetTasks[1].fleetId == 34 &&
t.fleetTasks[1].mode == 1,
"one fleet, modes 0 and 1");
// The route's length is known and its hops are not, and the element must say so.
check(t.fleetMoves.size() == 1 && t.fleetMoves[0].fleetId == 34 &&
t.fleetMoves[0].route.empty(),
"the fleet move names fleet 34 with an unread route");
check(!ai.List(8)[0].complete, "an unread route makes the element incomplete");
check(!ai.List(5)[0].complete, "an unread rates frame makes the element incomplete");
check(ai.List(3)[0].complete, "the build order is fully read");
const ModCountCost cost = CostOf(c);
check(cost.exact, "the cost is exact -- no gate with an unlocated applier is set");
check(cost.bumps == 12, "the reference turn costs 12: two drivers plus ten commands");
check(cost.bumps - kTurnDriverBumps == 10, "ten command bumps");
}
void TestTurnOneToTwo() {
const Capture c = Parse(kTurn1to2, "turn 1->2");
int targets = 0;
for (const auto& b : c.blocks) targets += b.GateSet(PrologueGate::ResearchTarget) ? 1 : 0;
check(targets == 3, "three AI clients set a research target; the human does not");
check(c.blocks[1].researchTarget == 144 && c.blocks[1].researchTargetName == "IND_Waldo",
"the target gate carries an id AND the name the instrument saw it resolve to");
check(c.blocks[0].researchTargetName.empty(), "the human's target gate is clear");
check(c.blocks[1].List(1).size() == 1, "a new ship design");
check(c.blocks[1].List(8).empty() && c.blocks[1].List(10).empty() &&
c.blocks[1].List(14).empty(),
"and NOT one element of the fleet group");
check(c.seeds.size() == 1 && c.Seed("client0") && c.Seed("client0")->seed == 0x11223344u,
"an AI client's construction seed is carried");
check(c.Seed("nobody") == nullptr, "an unknown client has no seed");
const ModCountCost cost = CostOf(c);
check(cost.bumps == 12, "this turn also costs 12");
// Same total, different composition. That is the point of having both.
const Capture ref = Parse(kTurn2to3, "turn 2->3 (again)");
check(CostOf(ref).bumps == cost.bumps, "the two turns agree on the total");
check(c.blocks[1].List(3).size() == ref.blocks[1].List(3).size(), "both build once");
check(!ref.blocks[1].GateSet(PrologueGate::ResearchTarget) &&
c.blocks[1].GateSet(PrologueGate::ResearchTarget),
"and disagree on every other term: three targets here, none there");
check(ref.blocks[1].List(14).size() == 2 && c.blocks[1].List(14).empty(),
"a fleet group there, none here");
}
void TestEmptySlotsCostNothing() {
const Capture c = Parse(kTurn2to3, "turn 2->3 (slots)");
for (std::size_t i = 4; i < c.blocks.size(); ++i) {
const TurnCommandBlock t = ToTurnCommandBlock(c.blocks[i]);
check(BlockModCountCost(t).bumps == 0, "an unwritten batch slot costs nothing");
}
// A block whose only content is the always-set rate gate still costs one. Four of this
// turn's ten are exactly that, and one of the four is the human's.
const TurnCommandBlock human = ToTurnCommandBlock(c.blocks[0]);
check(BlockModCountCost(human).bumps == 1, "a player who ordered nothing still costs one");
}
void TestRejections() {
struct Case {
const char* text;
const char* what;
};
const Case bad[] = {
{"block 0 16\n", "no magic line"},
{"tcb 2\nblock 0 16\n", "a version this reader does not speak"},
{"tcb 1\nblock 1 16\nblock 0 32\n", "blocks out of order"},
{"tcb 1\nblock 0 16\nblock 0 32\n", "a block declared twice"},
{"tcb 1\nblock 0 16\nlist 0 3 2\nelem 0 3 0 i1 i2 i3 i4\n", "a lost element"},
{"tcb 1\nblock 0 16\nlist 0 3 0\nelem 0 3 0 i1\n", "an element too many"},
{"tcb 1\nblock 0 16\nelem 0 3 0 i1\n", "an element with no declared count"},
{"tcb 1\nblock 0 16\nlist 0 28 1\nelem 0 28 0 i1\n", "a list number out of range"},
{"tcb 1\nblock 0 16\nlist 0 3 1\nelem 0 3 0 q9\n", "an unreadable field"},
{"tcb 1\nblock 0 16\nlist 0 8 1\nelem 0 8 0 i1 v2:1\n", "a vector shorter than it claims"},
{"tcb 1\nblock 0 16\ngate 0 rate\n", "a gate with no payload"},
{"tcb 1\nlist 0 3 0\n", "a record for a block that was never opened"},
{"tcb 1\nblock 0 16\nseed a 1\nseed a 2\n", "a client seeded twice"},
};
for (const auto& b : bad) {
Capture c;
CaptureDiagnostics d;
const bool ok = ParseCapture(b.text, c, d);
check(!ok && !d.errors.empty(), std::string("rejected: ") + b.what);
}
// And the civilian-ratios gate is accepted but must WARN, because its applier has never been
// located: the counter for such a turn is a lower bound and silence would hide that.
Capture c;
CaptureDiagnostics d;
check(ParseCapture("tcb 1\nblock 0 16\ngate 0 civilian\n", c, d), "the civilian gate parses");
check(!d.warnings.empty(), "and warns that the cost is a lower bound");
const TurnCommandBlock t = ToTurnCommandBlock(c.blocks[0]);
check(!BlockModCountCost(t).exact, "and makes the block's cost inexact");
}
void TestFieldKinds() {
Capture c;
CaptureDiagnostics d;
check(ParseCapture("tcb 1\nblock 0 16\nlist 0 9 1\nelem 0 9 0 i-7 f0.5 b1 s:Alpha v3:1,2,3 ?\n",
c, d),
"every field kind parses");
const CapturedElement& e = c.blocks[0].List(9)[0];
check(e.fields.size() == 6, "six fields");
check(e.fields[0].kind == CaptureField::Kind::Int && e.fields[0].i == -7, "a negative int");
check(e.fields[1].kind == CaptureField::Kind::Float && e.fields[1].f == 0.5f, "a float");
check(e.fields[2].kind == CaptureField::Kind::Bool && e.fields[2].b, "a bool");
check(e.fields[3].kind == CaptureField::Kind::Str && e.fields[3].s == "Alpha", "a string");
check(e.fields[4].vecValues && e.fields[4].vec.size() == 3, "a vector with values");
check(!e.complete, "one `?` makes the whole element incomplete");
// A vector whose length is known and whose contents are not is ALSO incomplete -- that is the
// route case, and it is the difference between counting a command and applying it.
Capture c2;
CaptureDiagnostics d2;
ParseCapture("tcb 1\nblock 0 16\nlist 0 8 1\nelem 0 8 0 i34 v1\n", c2, d2);
const CapturedElement& route = c2.blocks[0].List(8)[0];
check(route.fields[1].vecCount == 1 && !route.fields[1].vecValues, "a length without values");
check(!route.complete, "and that makes the element incomplete");
}
} // namespace
int main() {
TestReferenceTurnTwoToThree();
TestTurnOneToTwo();
TestEmptySlotsCostNothing();
TestRejections();
TestFieldKinds();
std::printf("game_ai/command_capture: %d checks, %d failures\n", g_checks, g_fails);
return g_fails == 0 ? 0 : 1;
}