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.
158 lines
7.9 KiB
C++
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;
|
|
}
|