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

158 lines
7.9 KiB
C++

// The order the server applies a turn's commands in.
//
// This test cannot be written against an outcome, and saying so is half of its value. Both
// captures the campaign holds put every non-empty list on ONE player and every command on ONE
// system, so any permutation of the thirty steps produces the same save and the same counter.
// The order is implemented because a later workload will need it, and the checks below are
// therefore checks against the ORIGINAL'S INSTRUCTION STREAM, not against a result:
//
// * the six appliers the batch inlines each write the counter in place, so a watchpoint run
// recovered their addresses; those addresses must increase in the same order as the steps
// they belong to. That is nine of thirty positions independently pinned, and the test says
// nine rather than pretending to thirty;
// * the permutation must be a bijection of 1..27 and the gates must appear exactly once each,
// which catches a transcription slip and nothing else;
// * NEITHER the list sequence NOR the member-offset sequence may be ascending. A port that
// loops `for (list = 1..27)`, or walks the block's members in memory order, is the obvious
// wrong implementation and is the one thing an outcome-free test CAN rule out.
#include "game/ai/apply_order.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());
}
}
// Every list exactly once, every gate exactly once, and nothing else in the schedule.
void TestSchedulePartitionsTheBlock() {
int listSeen[kCommandListCount + 1] = {};
int gateSeen[kPrologueGateCount] = {};
int listSteps = 0, gateSteps = 0;
for (int i = 0; i < kApplyStepCount; ++i) {
const ApplyStep& s = kApplySchedule[i];
if (s.kind == ApplyStepKind::List) {
++listSteps;
check(s.list >= 1 && s.list <= kCommandListCount, "list number in range");
if (s.list >= 1 && s.list <= kCommandListCount) ++listSeen[s.list];
} else {
++gateSteps;
check(s.gateCount >= 1 && s.gateCount <= kMaxGatesPerStep, "gate count in range");
for (int g = 0; g < s.gateCount; ++g) ++gateSeen[static_cast<int>(s.gates[g])];
}
}
check(listSteps == kCommandListCount, "twenty-seven list steps");
check(gateSteps == 3, "three gate loops");
for (int n = 1; n <= kCommandListCount; ++n)
check(listSeen[n] == 1, "list " + std::to_string(n) + " appears exactly once");
// Five of the six gates have a located applier in this routine; the civilian-ratios gate has
// none at all, and its ABSENCE from the schedule is the honest representation of that. A
// schedule that quietly included it would be claiming a cost of zero for something whose cost
// is unknown.
for (int g = 0; g < kPrologueGateCount; ++g) {
const bool located = static_cast<PrologueGate>(g) != PrologueGate::CivilianRatios;
check(gateSeen[g] == (located ? 1 : 0),
"gate " + std::to_string(g) + (located ? " applied once" : " has no applier here"));
}
}
// The two orderings a wrong implementation would produce.
void TestScheduleIsNeitherSortedOrder() {
bool listAscending = true, offsetAscending = true;
int prevList = 0, prevOffset = 0;
for (int i = 0; i < kApplyStepCount; ++i) {
const ApplyStep& s = kApplySchedule[i];
if (s.kind != ApplyStepKind::List) continue;
if (s.list < prevList) listAscending = false;
if (s.memberOffset < prevOffset) offsetAscending = false;
prevList = s.list;
prevOffset = s.memberOffset;
}
check(!listAscending, "the list sequence is NOT 1..27");
check(!offsetAscending, "the member-offset sequence is NOT ascending either");
// The two anchors a reader can check by eye against the published table.
check(kApplySchedule[0].kind == ApplyStepKind::List && kApplySchedule[0].list == 6,
"the batch starts at list 6");
check(kApplySchedule[kApplyStepCount - 1].kind == ApplyStepKind::GateLoop,
"the batch ends on a gate loop");
check(StepIndexOfList(14) == 20, "list 14 is the twenty-first step");
check(StepIndexOfList(8) == 24, "list 8 is the twenty-fifth step");
check(StepIndexOfList(-1) == -1 && StepIndexOfList(28) == -1, "a bad list number has no step");
}
// The one part of the sequence this module can re-derive rather than inherit.
void TestInlinedBumpAddressesIncreaseWithTheSchedule() {
unsigned prevAddr = 0;
int prevStep = -1;
for (int i = 0; i < kScheduleAnchorCount; ++i) {
const ScheduleAnchor& a = kScheduleAnchors[i];
check(a.address > prevAddr, std::string("address increases at ") + a.what);
check(a.step >= prevStep, std::string("step does not go backwards at ") + a.what);
check(a.step >= 0 && a.step < kApplyStepCount, std::string("step in range at ") + a.what);
prevAddr = a.address;
prevStep = a.step;
}
// Every anchored step must be the kind the anchor claims: the four inlined LIST appliers sit
// on list steps and the gate-loop anchors sit on gate steps. This is what would catch an
// anchor that had been renumbered against a reordered schedule.
check(kApplySchedule[18].kind == ApplyStepKind::List && kApplySchedule[18].list == 12,
"anchor 0x008902fe is list 12");
check(kApplySchedule[19].kind == ApplyStepKind::List && kApplySchedule[19].list == 13,
"anchor 0x008903b9 is list 13");
check(kApplySchedule[20].kind == ApplyStepKind::List && kApplySchedule[20].list == 14,
"anchor 0x0089046c is list 14");
check(kApplySchedule[23].kind == ApplyStepKind::List && kApplySchedule[23].list == 7,
"anchor 0x008905c8 is list 7");
check(kApplySchedule[9].kind == ApplyStepKind::GateLoop, "anchor 0x0088fdb0 is gate loop A");
check(kApplySchedule[28].kind == ApplyStepKind::GateLoop, "anchor 0x008907b1 is gate loop B");
check(kApplySchedule[29].kind == ApplyStepKind::GateLoop, "anchor 0x0089080a is gate loop C");
}
// The gates are not one group, and the two that sit in the middle are the two research ones.
void TestGatesAreSplitAcrossTheRun() {
const ApplyStep& a = kApplySchedule[9];
check(a.kind == ApplyStepKind::GateLoop && a.gateCount == 3, "gate loop A tests three gates");
check(a.gates[0] == PrologueGate::Group5 && a.gates[1] == PrologueGate::ResearchTarget &&
a.gates[2] == PrologueGate::ResearchRate,
"gate loop A order: group5, target, rate");
check(kApplySchedule[28].gates[0] == PrologueGate::ResearchBoost, "gate loop B is the boost");
check(kApplySchedule[29].gates[0] == PrologueGate::Group4, "gate loop C is group 4");
// Nine list steps run before any gate is tested, and eighteen run after.
int before = 0;
for (int i = 0; i < 9; ++i) before += kApplySchedule[i].kind == ApplyStepKind::List ? 1 : 0;
check(before == 9, "nine lists are applied before the first gate is even read");
}
// The cost half of the schedule, restated from the step rather than from the list number, so a
// step whose `bumps` flag disagreed with the 1..16 rule would be caught here.
void TestStepCostMatchesTheListCostRule() {
for (int i = 0; i < kApplyStepCount; ++i) {
const ApplyStep& s = kApplySchedule[i];
if (s.kind != ApplyStepKind::List) continue;
check(s.bumps == ListAdvancesModCount(static_cast<CommandList>(s.list)),
"step cost agrees with the list cost rule for list " + std::to_string(s.list));
}
}
} // namespace
int main() {
TestSchedulePartitionsTheBlock();
TestScheduleIsNeitherSortedOrder();
TestInlinedBumpAddressesIncreaseWithTheSchedule();
TestGatesAreSplitAcrossTheRun();
TestStepCostMatchesTheListCostRule();
std::printf("game_ai/apply_order: %d checks, %d failures\n", g_checks, g_fails);
return g_fails == 0 ? 0 : 1;
}