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.
233 lines
9.3 KiB
C++
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;
|
|
}
|