sots-engine/tests/game_ai/test_orders.cpp
lane-l4 4a5c218a35 L4: read the AI's command block out of the running game
New shim module src/shim/hooks/ai_orders.{h,cpp}: one register-transparent entry
stub on StrategySim::ApplyTurnCommandBatch dumps every submitted TurnCommands block
(six gates, 27 list lengths, element bytes) at the point where all of them are
complete in memory; sixteen entry probes, with RunTaskList's stub recording the
pass so every later hit is pass-attributed.

Two workloads on VM145, one End Turn each. The rule-19 control passed with all
seventeen detours installed: both autosaves byte-identical to the published oracle.

What the AI actually emits, and three things no reading had produced:
  - a list-23 element on EVERY turn, the first element ever observed in the free
    half of the cost table -- and both turns still cost the measured 12;
  - the ids in AI commands are client-allocated and travel in the command (design
    18, fleet 34; neither exists in the input save);
  - pass 0 emits nothing, measured from element counts rather than inferred.

tests/game_ai/test_live_blocks.cpp rebuilds both captured blocks through the public
OrderClient API and asserts the list profile, element values, gate counts and
ModCount total: 44 checks. Kept separate from test_orders.cpp, which stays the
record of what static reading predicted.

Gates: clean_room_check OK, host ctest 55/55, CT111 shim cross-build exit 0.
2026-09-08 18:01:19 -04:00

318 lines
15 KiB
C++

// 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 <cstdio>
#include <string>
#include <vector>
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<CommandList>(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<CommandList>(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<TurnCommandBlock> 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<TurnCommandBlock> 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<TurnCommandBlock> 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;
}