// The command block, the order API, and the modification counter's arithmetic. // // Every expectation here was written from the original's instruction stream and from the corpus // saves BEFORE this code was built, not produced by running it. The cases that carry the weight: // // * the 1..16 / 17..27 boundary, tested from BOTH sides at the boundary itself -- a list-16 // element pays and a list-17 element does not. A cost table is exactly the kind of thing that // compares clean on twenty ordinary states and is wrong on the edge; // * the block that costs one while containing no order at all, which is four of the ten command // bumps on the reference turn; // * an AI fleet order costing three where the interface's costs two, which is the one prediction // this whole area turned on; // * the reference turn reconstructed to the exact measured 12, and the turn before it // reconstructed to the same 12 out of a DIFFERENT set of commands. That second one is the // point: 12 twice is not a constant, it is two compositions that happen to agree. #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::fprintf(stderr, "FAIL: %s\n", what.c_str()); } } // --------------------------------------------------------------------------------------------- void TestListCostBoundary() { // The whole table, both halves, every entry -- it is 27 values and there is no reason to // sample it. for (int n = 1; n <= kCommandListCount; ++n) { const auto list = static_cast(n); const bool pays = ListAdvancesModCount(list); check(pays == (n <= 16), "list " + std::to_string(n) + " cost"); } // The boundary itself, from both sides, through the cost function rather than the predicate. TurnCommandBlock at16; at16.AddUnmodelled(CommandList::List16, 1); check(BlockModCountCost(at16).bumps == 1, "one list-16 element costs one"); TurnCommandBlock at17; at17.AddUnmodelled(CommandList::List17, 1); check(BlockModCountCost(at17).bumps == 0, "one list-17 element costs nothing"); // And a block stuffed with free commands still costs nothing. TurnCommandBlock freeOnly; for (int n = 17; n <= kCommandListCount; ++n) freeOnly.AddUnmodelled(static_cast(n), 5); check(BlockModCountCost(freeOnly).bumps == 0, "55 elements across the free lists cost nothing"); check(BlockModCountCost(freeOnly).exact, "and the answer is exact"); // AddUnmodelled must not shadow a modelled list, or a caller could double-count. TurnCommandBlock modelled; modelled.build.push_back(BuildOrder{}); modelled.AddUnmodelled(CommandList::Build, 7); check(modelled.ElementCount(CommandList::Build) == 1, "unmodelled counts cannot shadow a modelled list"); } void TestGateCosts() { check(GateModCountCost(PrologueGate::ResearchRate) == GateCost::OneBump, "rate gate pays"); check(GateModCountCost(PrologueGate::ResearchTarget) == GateCost::OneBump, "target gate pays"); check(GateModCountCost(PrologueGate::ResearchBoost) == GateCost::OneBump, "boost gate pays"); check(GateModCountCost(PrologueGate::Group4) == GateCost::OneBump, "group-4 gate pays"); check(GateModCountCost(PrologueGate::Group5) == GateCost::Free, "group-5 gate is free"); check(GateModCountCost(PrologueGate::CivilianRatios) == GateCost::Unknown, "the civilian-ratios gate has no located applier"); // The free gate really is free, and setting it does not make the answer inexact. TurnCommandBlock g5; g5.hasGroup5 = true; check(BlockModCountCost(g5).bumps == 0 && BlockModCountCost(g5).exact, "group 5 costs nothing, exactly"); // The unknown gate makes the answer a lower bound rather than a number. TurnCommandBlock civ; civ.hasResearchRate = true; civ.hasCivilianRatios = true; const ModCountCost c = BlockModCountCost(civ); check(c.bumps == 1, "the unknown gate contributes a lower bound of zero"); check(!c.exact, "and marks the answer inexact rather than guessing"); } void TestEmptyBlockStillCosts() { // The load-bearing boundary case: a player who issues nothing still submits a block, and the // block still carries the research-rate gate, because the send-buffer build sets it whatever // the player did. Four of the ten command bumps on the reference turn are exactly this. OrderClient c(16); check(BlockModCountCost(c.block()).bumps == 0, "before End Turn an untouched block costs nothing"); c.EndTurn(0.25f); check(c.block().hasResearchRate, "End Turn sets the research-rate gate unconditionally"); check(BlockModCountCost(c.block()).bumps == 1, "a do-nothing player still costs one"); } void TestFleetOrderAsymmetry() { // The interface: one route, one fleet-task element -> two bumps. OrderClient ui(16); ui.QueueFleetRoute(1456, {432}); ui.OrderFleetTask(1456, 0, true); ui.EndTurn(0.25f); const auto& u = ui.block(); check(u.ElementCount(CommandList::FleetMove) == 1, "interface: one fleet move"); check(u.ElementCount(CommandList::FleetTask) == 1, "interface: one fleet-task element"); check(BlockModCountCost(u).bumps == 3, "interface fleet order: rate + move + task = 3"); // The AI: same route, two fleet-task elements -> three bumps for the order. OrderClient ai(32); ai.IssueAiFleetOrder(1456, {432}); ai.EndTurn(0.8f); const auto& a = ai.block(); check(a.ElementCount(CommandList::FleetMove) == 1, "AI: one fleet move"); check(a.ElementCount(CommandList::FleetTask) == 2, "AI: TWO fleet-task elements"); check(a.fleetTasks[0].mode == 0 && a.fleetTasks[1].mode == 1, "modes 0 then 1, in that order"); check(a.fleetTasks[0].fleetId == 1456 && a.fleetTasks[1].fleetId == 1456, "both name the same fleet"); check(a.fleetTasks[0].flag && a.fleetTasks[1].flag, "both carry the flag set"); check(BlockModCountCost(a).bumps == 4, "AI fleet order: rate + move + two tasks = 4"); } void TestFleetTaskDedup() { // The adder keys on (fleet, mode). Same pair twice is an update, not an append. OrderClient c(32); c.OrderFleetTask(700, 0, true); c.OrderFleetTask(700, 0, false); check(c.block().ElementCount(CommandList::FleetTask) == 1, "same (fleet, mode) updates in place"); check(c.block().fleetTasks[0].flag == false, "and takes the later value"); c.OrderFleetTask(700, 1, true); check(c.block().ElementCount(CommandList::FleetTask) == 2, "a different mode appends"); c.OrderFleetTask(701, 0, true); check(c.block().ElementCount(CommandList::FleetTask) == 3, "a different fleet appends"); // Re-issuing an AI fleet order for a fleet already ordered adds no fleet-task element: both // (fleet, 0) and (fleet, 1) already exist and are updated in place. What the pending-route // vector does on a repeat is NOT established -- see the note on QueueFleetRoute -- so this // case asserts only the half that is. OrderClient once(32); once.IssueAiFleetOrder(700, {1, 2}); OrderClient twice(32); twice.IssueAiFleetOrder(700, {1, 2}); twice.IssueAiFleetOrder(700, {1, 2}); check(once.block().ElementCount(CommandList::FleetTask) == 2, "one AI order, two task elements"); check(twice.block().ElementCount(CommandList::FleetTask) == 2, "two AI orders for one fleet, still two"); } void TestSubmitLatch() { OrderClient c(32); check(c.OrdersAccepted(), "orders are accepted before the submit"); c.EndTurn(0.8f); check(c.TurnEnded(), "the turn latches closed"); check(!c.OrdersAccepted(), "and every order is refused after it"); check(!c.OrderColonize(ColonizeOrder{}), "the colonize order the last phases would issue is refused"); check(!c.SetResearchTarget(191), "so is a research target"); check(!c.IssueAiFleetOrder(700, {1}), "so is a fleet order"); check(c.block().ElementCount(CommandList::Colonize) == 0, "and nothing reached the block"); check(BlockModCountCost(c.block()).bumps == 1, "the block still costs exactly its rate gate"); // A second submit is a no-op, not a second flush. c.QueueFleetRoute(1, {2}); c.EndTurn(0.5f); check(c.block().researchRate == 0.8f, "a second End Turn does not rewrite the rate"); check(c.block().ElementCount(CommandList::FleetMove) == 0, "and flushes nothing"); } void TestPassGate() { OrderClient c(32); c.EnterTaskPass(0); check(!c.OrdersAccepted(), "the first task pass accepts no orders"); check(!c.OrderBuild(BuildOrder{}), "a build issued in the first pass is refused"); check(!c.IssueAiFleetOrder(1, {2}), "so is a fleet order"); check(c.pendingRouteCount() == 0, "and it does not even queue a route"); c.EnterTaskPass(1); check(c.OrdersAccepted(), "the second task pass accepts orders"); check(c.OrderBuild(BuildOrder{}), "and a build lands"); c.LeaveTaskPass(); check(c.OrdersAccepted(), "outside the task walk the pass gate does not apply"); check(BlockModCountCost(c.block()).bumps == 1, "one build, one bump"); } void TestRouteLengthDoesNotChangeCost() { // A multi-hop route is longer on the wire but is still ONE element and therefore one bump. // This is the rule-23 shape: the thing that varies is not the thing that counts. OrderClient one(32); one.QueueFleetRoute(700, {1}); one.EndTurn(0.25f); OrderClient many(32); many.QueueFleetRoute(700, {1, 2, 3, 4, 5, 6, 7}); many.EndTurn(0.25f); check(BlockModCountCost(one.block()).bumps == BlockModCountCost(many.block()).bumps, "a seven-hop route costs the same as a one-hop route"); check(many.block().fleetMoves[0].route.size() == 7, "and the route survives intact"); } // --------------------------------------------------------------------------------------------- // The reference game // --------------------------------------------------------------------------------------------- // The board these two cases describe: eight players, of which four end their turn -- one human and // three AI. The other four are the monster factions, which submit no block at all. void TestReferenceTurnTwoToThree() { std::vector blocks; OrderClient human(16); // ended the turn, ordered nothing human.EndTurn(0.25f); blocks.push_back(human.block()); OrderClient ai(32); // the one AI with an empire ai.OrderSystemRates(SystemRatesOrder{}); ai.OrderBuild(BuildOrder{}); ai.OrderUnmodelled(CommandList::List10, 1); // The ids here were placeholders when this case was written from the saves. The live capture // (tests/game_ai/test_live_blocks.cpp) read the real ones: the order names fleet 34 -- an // object the INPUT save does not contain -- and one hop to system 272. Corrected in place // rather than left as an illustration, because a wrong id in a test is how a wrong id spreads. ai.IssueAiFleetOrder(34, {272}); ai.EndTurn(0.8f); blocks.push_back(ai.block()); OrderClient dormantA(496); // no colonies, no fleets: nothing to command dormantA.EndTurn(0.8f); blocks.push_back(dormantA.block()); OrderClient dormantB(512); dormantB.EndTurn(0.8f); blocks.push_back(dormantB.block()); const ModCountCost cost = TurnModCountDelta(blocks); check(cost.exact, "the reference turn's cost is exact"); check(cost.bumps == 12, "reference turn 2 -> 3: the measured 12"); check(BlockModCountCost(blocks[1]).bumps == 7, "and seven of them are the one real AI's block"); // The four rate gates are the largest single term and they come from four different players. int rateBumps = 0; for (const auto& b : blocks) rateBumps += b.hasResearchRate ? 1 : 0; check(rateBumps == 4, "four submitted blocks, four research-rate bumps"); } void TestReferenceTurnOneToTwo() { // The prediction: the same total out of a different set of commands. All three AI players pick // a research target on the first turn -- the saves show all three going from no target to a // named one -- and the one with an empire designs a hull and queues it instead of moving a // fleet. std::vector blocks; OrderClient human(16); human.EndTurn(0.25f); blocks.push_back(human.block()); OrderClient ai(32); ai.SetResearchRate(0.8f); ai.SetResearchTarget(144); // IND_Waldo -- tech id read live, not a placeholder ai.OrderUnmodelled(CommandList::NewDesigns, 1); // the new hull ai.OrderBuild(BuildOrder{}); // and the order to build it ai.OrderSystemRates(SystemRatesOrder{}); ai.EndTurn(0.8f); blocks.push_back(ai.block()); OrderClient dormantA(496); dormantA.SetResearchRate(0.8f); dormantA.SetResearchTarget(90); // DRV_PlsFiss, read live dormantA.EndTurn(0.8f); blocks.push_back(dormantA.block()); OrderClient dormantB(512); dormantB.SetResearchRate(0.8f); dormantB.SetResearchTarget(288); // read live; and see test_live_blocks.cpp -- this // one player's target is NOT reproducible run to run dormantB.EndTurn(0.8f); blocks.push_back(dormantB.block()); const ModCountCost cost = TurnModCountDelta(blocks); check(cost.exact, "the predicted turn's cost is exact"); check(cost.bumps == 12, "predicted turn 1 -> 2: also 12"); check(BlockModCountCost(blocks[2]).bumps == 2, "a dormant AI costs two: its rate and its target"); check(blocks[1].ElementCount(CommandList::FleetTask) == 0, "the prediction is that turn 1 moves no fleet"); } void TestOrdersSaveArithmetic() { // A save the campaign actually holds, from the interface side: one turn on which the player // set a research target, spent savings on a boost, queued five builds and moved a fleet. OrderClient p(16); p.SetResearchTarget(191); p.BoostResearch(216383, 0.9992f); for (int i = 0; i < 5; ++i) p.OrderBuild(BuildOrder{}); p.QueueFleetRoute(688, {432}); p.EndTurn(0.97f); const ModCountCost c = BlockModCountCost(p.block()); check(c.bumps == 9, "rate + target + boost + 5 builds + 1 move = 9"); check(c.exact, "and nothing in it is unknown"); } void TestAbandonedSystemsTerm() { std::vector none; check(TurnModCountDelta(none).bumps == 2, "a turn with no blocks at all still costs the two drivers"); check(TurnModCountDelta(none, 3).bumps == 5, "each abandoned system adds one"); check(TurnModCountDelta(none, -4).bumps == 2, "a negative count cannot subtract"); } } // namespace int main() { TestListCostBoundary(); TestGateCosts(); TestEmptyBlockStillCosts(); TestFleetOrderAsymmetry(); TestFleetTaskDedup(); TestSubmitLatch(); TestPassGate(); TestRouteLengthDoesNotChangeCost(); TestReferenceTurnTwoToThree(); TestReferenceTurnOneToTwo(); TestOrdersSaveArithmetic(); TestAbandonedSystemsTerm(); std::printf("game_ai/orders: %d checks, %d failures\n", g_checks, g_fails); return g_fails == 0 ? 0 : 1; }