// The two AI command blocks that were read out of the running game, element by element. // // Every number in this file was DUMPED, not derived. Lane L4 put a register-transparent entry stub // on the batch applier -- the point at which every player's submitted block is complete and in // memory at a fixed stride -- and printed the six gates, the twenty-seven list lengths and the // element bytes for all eight block slots, on two workloads, on a guest whose autosaves reproduced // the campaign's published oracle byte for byte with the instrument installed. // // WHY THIS FILE IS SEPARATE FROM test_orders.cpp. That file's expectations were written from the // instruction stream and the corpus saves before any of this code existed, and they must stay that // way: it is the record of what static reading predicted. This file is the record of what the game // did. Where they agree -- and on the arithmetic they agree exactly, twelve and twelve -- the // agreement means something precisely because the two were produced by different instruments and // neither was fitted to the other. // // The three things the live capture added that no amount of reading had produced: // // * A LIST-23 ELEMENT ON BOTH TURNS. Nothing in eleven corpus saves had ever populated a list in // the free half of the table, so the whole 17..27 row of the cost model was a hypothesis in // the rule-6 sense. It is now exercised: the AI submits one every turn, and the counter still // lands on the measured twelve, so the element really is free. // * THE IDS ARE CLIENT-ALLOCATED AND TRAVEL IN THE COMMAND. The build order names design 18 -- // an id the server has not yet issued when the block is submitted -- and the fleet order names // fleet 34, an object that does not exist in the input save at all. A reimplementation cannot // assign these on apply; it has to allocate them where the original does or every id in the // resulting save is wrong. // * THE SAME SYSTEM IN THREE COMMANDS. Build, system rates and the population command all name // system 288, the AI's home. They are one decision expressed three times. #include "game/ai/orders.h" #include #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::printf("FAIL: %s\n", what.c_str()); } } // The AI's home system, named by three different commands in the same block. constexpr int kHomeSystem = 288; // The design the AI created on turn 1 and built from on both turns. Not a multiple of sixteen and // not from the save's master id counter -- the other new design that turn, a monster faction's, // took 1712 from that counter while this one took 18 from somewhere the save does not show. constexpr int kHonorLance = 18; // The fleet the turn-2 order names. It is NOT the fleet that exists in the input save (1744); it // is the one that exists in the OUTPUT save, so the client had already allocated it. constexpr int kBetaFleet = 34; // --------------------------------------------------------------------------------------------- // Turn 1 -> 2, captured from `turn1-state.sav` + one End Turn // --------------------------------------------------------------------------------------------- std::vector CaptureTurnOneToTwo() { std::vector blocks; // pid 16, the human. Ordered nothing all turn and still submits a block, because the send // buffer sets the rate gate from live player state whatever the player did -- and the value it // carried was 0.25, the rate the human never touched. OrderClient human(16); human.EndTurn(0.25f); blocks.push_back(human.block()); // pid 32, the only AI with an empire. OrderClient ai(32); ai.SetResearchTarget(144); ai.OrderUnmodelled(CommandList::NewDesigns, 1); // list 1: the "Honor Lance" hull itself ai.OrderBuild(BuildOrder{1, kHonorLance, kHomeSystem, 0}); ai.OrderSystemRates(SystemRatesOrder{kHomeSystem, 0, 1.0f, 0, 0, 0, 0, 0}); ai.OrderUnmodelled(CommandList::PopulationCmds, 1); // list 23, free half ai.EndTurn(0.8f); blocks.push_back(ai.block()); // pid 496 and 512: the two AI players that own nothing. They still run a full two-pass task // sweep and they still pick a research target. OrderClient dormantA(496); dormantA.SetResearchTarget(90); dormantA.EndTurn(0.8f); blocks.push_back(dormantA.block()); OrderClient dormantB(512); // The one field in the whole turn that is not reproducible: three runs of this exact workload // produced three different targets for this player. 288 is what the instrumented run recorded. dormantB.SetResearchTarget(288); dormantB.EndTurn(0.8f); blocks.push_back(dormantB.block()); // The four monster factions occupy block slots and never touch them: player id zero, every // gate clear, all twenty-seven lists empty. They are not modelled as blocks here because a // block with no gate set is indistinguishable from an absent one for every purpose this // module has -- which is itself the finding. return blocks; } void TestTurnOneToTwo() { const std::vector blocks = CaptureTurnOneToTwo(); const TurnCommandBlock& ai = blocks[1]; check(blocks.size() == 4, "four players submitted a block"); check(ai.ElementCount(CommandList::NewDesigns) == 1, "1->2: one design command"); check(ai.ElementCount(CommandList::Build) == 1, "1->2: one build order"); check(ai.ElementCount(CommandList::SystemRates) == 1, "1->2: one system-rates command"); check(ai.ElementCount(CommandList::PopulationCmds) == 1, "1->2: one population command"); // The whole fleet-order group is absent on the turn the fleet is created. check(ai.ElementCount(CommandList::FleetMove) == 0, "1->2: no fleet move"); check(ai.ElementCount(CommandList::FleetTask) == 0, "1->2: no fleet task"); check(ai.ElementCount(CommandList::List10) == 0, "1->2: no list-10 command"); // Nothing else at all: four lists out of twenty-seven. int nonEmpty = 0; for (int n = 1; n <= kCommandListCount; ++n) nonEmpty += ai.ElementCount(static_cast(n)) > 0 ? 1 : 0; check(nonEmpty == 4, "1->2: exactly four of the twenty-seven lists are non-empty"); check(ai.build[0].designId == kHonorLance, "the build order names the design the block creates"); check(ai.build[0].systemId == kHomeSystem, "and builds it at the home system"); check(ai.build[0].ordinal == 1, "the ordinal is 1 on the AI's first build"); check(ai.systemRates[0].systemId == kHomeSystem, "system rates name the same system"); // Three targets, not one: every AI player picks one on the first turn, the human does not. int targets = 0, rates = 0; for (const auto& b : blocks) { targets += b.hasResearchTarget ? 1 : 0; rates += b.hasResearchRate ? 1 : 0; } check(targets == 3, "three research-target gates, one per AI player"); check(rates == 4, "four research-rate gates, one per submitted block"); check(!blocks[0].hasResearchTarget, "the human set no research target"); const ModCountCost cost = TurnModCountDelta(blocks); check(cost.exact, "1->2 cost is exact"); check(cost.bumps == 12, "1->2 costs the measured 12"); check(BlockModCountCost(blocks[2]).bumps == 2, "a dormant AI costs two: rate and target"); } // --------------------------------------------------------------------------------------------- // Turn 2 -> 3, captured from `ref-turn2.sav` + one End Turn // --------------------------------------------------------------------------------------------- std::vector CaptureTurnTwoToThree() { std::vector blocks; OrderClient human(16); human.EndTurn(0.25f); blocks.push_back(human.block()); OrderClient ai(32); // No research target this turn: the gate is clear on all four blocks, and no player's saved // target changes across the turn. ai.OrderBuild(BuildOrder{2, kHonorLance, kHomeSystem, 0}); ai.OrderSystemRates(SystemRatesOrder{kHomeSystem, 0, 1.0f, 0, 0, 0, 0, 0}); ai.OrderUnmodelled(CommandList::List10, 1); ai.IssueAiFleetOrder(kBetaFleet, {272}); ai.OrderUnmodelled(CommandList::PopulationCmds, 1); ai.EndTurn(0.8f); blocks.push_back(ai.block()); OrderClient dormantA(496); dormantA.EndTurn(0.8f); blocks.push_back(dormantA.block()); OrderClient dormantB(512); dormantB.EndTurn(0.8f); blocks.push_back(dormantB.block()); return blocks; } void TestTurnTwoToThree() { const std::vector blocks = CaptureTurnTwoToThree(); const TurnCommandBlock& ai = blocks[1]; check(ai.ElementCount(CommandList::Build) == 1, "2->3: one build order"); check(ai.ElementCount(CommandList::SystemRates) == 1, "2->3: one system-rates command"); check(ai.ElementCount(CommandList::FleetMove) == 1, "2->3: one fleet move"); check(ai.ElementCount(CommandList::List10) == 1, "2->3: one list-10 command"); check(ai.ElementCount(CommandList::FleetTask) == 2, "2->3: TWO fleet-task elements"); check(ai.ElementCount(CommandList::PopulationCmds) == 1, "2->3: one population command"); check(ai.ElementCount(CommandList::NewDesigns) == 0, "2->3: no new design -- it reuses design 18"); int nonEmpty = 0; for (int n = 1; n <= kCommandListCount; ++n) nonEmpty += ai.ElementCount(static_cast(n)) > 0 ? 1 : 0; check(nonEmpty == 6, "2->3: exactly six of the twenty-seven lists are non-empty"); // The pair that AI2 predicted from the call site and that this capture read off the values: // one fleet, two elements, modes 0 and 1, and the same fleet id the route names. check(ai.fleetTasks.size() == 2, "two fleet-task elements"); check(ai.fleetTasks[0].fleetId == kBetaFleet && ai.fleetTasks[1].fleetId == kBetaFleet, "both name the SAME fleet"); check(ai.fleetTasks[0].mode == 0 && ai.fleetTasks[1].mode == 1, "modes 0 and 1, in that order"); check(ai.fleetMoves[0].fleetId == kBetaFleet, "and the route names that fleet too"); check(ai.fleetMoves[0].route.size() == 1, "the route is one hop"); check(ai.build[0].ordinal == 2, "the ordinal is 2 on the AI's second build"); int targets = 0; for (const auto& b : blocks) targets += b.hasResearchTarget ? 1 : 0; check(targets == 0, "no research target is set on this turn by anyone"); const ModCountCost cost = TurnModCountDelta(blocks); check(cost.exact, "2->3 cost is exact"); check(cost.bumps == 12, "2->3 costs the measured 12"); check(BlockModCountCost(ai).bumps == 7, "seven of them are the AI's block"); } // --------------------------------------------------------------------------------------------- // What the capture proves about the cost table itself // --------------------------------------------------------------------------------------------- void TestFreeHalfExercisedLive() { // Both turns carry a list-23 element and both turns cost exactly twelve. Remove the element // and the total does not move: that is the free half of the table being exercised by a real // workload for the first time, from both directions. for (int turn = 0; turn < 2; ++turn) { std::vector with = turn == 0 ? CaptureTurnOneToTwo() : CaptureTurnTwoToThree(); std::vector without = with; without[1].unmodelled[static_cast(CommandList::PopulationCmds) - 1] = 0; check(with[1].ElementCount(CommandList::PopulationCmds) == 1, "the capture carries list 23"); check(TurnModCountDelta(with).bumps == TurnModCountDelta(without).bumps, "removing the list-23 element changes nothing -- list 23 is free, measured"); check(TurnModCountDelta(with).bumps == 12, "and the total is the measured 12 either way"); } // The counter-check, so the previous one is not vacuous: an element in the paying half does // move the total. std::vector b = CaptureTurnTwoToThree(); const int before = TurnModCountDelta(b).bumps; b[1].AddUnmodelled(CommandList::List16, 1); check(TurnModCountDelta(b).bumps == before + 1, "a list-16 element does cost one"); } } // namespace int main() { TestTurnOneToTwo(); TestTurnTwoToThree(); TestFreeHalfExercisedLive(); std::printf("%s: %d checks, %d failures\n", g_fails ? "FAILED" : "ok", g_checks, g_fails); return g_fails ? 1 : 0; }