// 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 #include #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; }