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