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.
318 lines
15 KiB
C++
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;
|
|
}
|