sots-engine/tests/game_ai/test_orders.cpp
alex 3ca010978c game/ai: the order block, the turn's phase spine, and what a command costs
Adds the half of the AI's turn that is arithmetic rather than judgement: what an
order looks like in the command block, which orders advance the save's
modification counter and by how much, and the phase/pass skeleton the decisions
hang in.

The counter's per-command cost turns out to have a sharp boundary. Applying an
element of command lists 1..16 advances it; applying an element of lists 17..27
does not, and one of the six flag-gated single commands is free as well. So a
uniform per-element cost model is wrong on any turn that touches the free half.

Two behaviours here are not conveniences and change the output:

  * every submitted block costs at least one, because the send-buffer build sets
    the research-rate gate unconditionally whatever the player did. On a quiet
    board that is the largest term in the turn's delta -- four of the ten command
    bumps on the reference turn are exactly this, and one of the four is the
    human's;
  * an AI fleet order costs three where the interface's costs two, because the
    AI's bridge issues the fleet-task command twice, mode 0 then mode 1, and the
    adder keys on (fleet, mode).

The phase spine records the one thing a literal port gets wrong: the turn submits
at phase 28 of 34, the submit latches the client closed before it builds the send
buffer, and every order the last five phases issue -- one of which is a colonize
order -- is refused. Tested through the client rather than by asserting a flag.

The task walk reproduces the two passes: rank once, walk twice, and refuse every
write in the first pass at the client rather than trusting the caller to check.

Nothing here decides anything. Which tasks exist and what each one wants are
questions about the board, and no part of this models the board; the module
supplies the order API, the pass gate and the cost function, and a caller
supplies the decisions.

game/ai tests 233 -> 423 checks; ctest 51/51 -> 53/53. Not linked into the
standalone driver, whose divergence on the reference pair is unchanged at 128.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 16:27:38 -04:00

313 lines
14 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);
ai.IssueAiFleetOrder(1744, {288});
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(1); // IND_Waldo
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(2); // DRV_PlsFiss
dormantA.EndTurn(0.8f);
blocks.push_back(dormantA.block());
OrderClient dormantB(512);
dormantB.SetResearchRate(0.8f);
dormantB.SetResearchTarget(3); // BIO_GnMod
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;
}