sots-engine/tests/game_ai/test_agent.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

166 lines
7.2 KiB
C++

// The turn's phase list and the two-pass task walk.
//
// The cases that matter:
// * the dead tail. Five phases run after the turn is submitted and one of them issues a colonize
// order; a port that lets them through is over on the modification counter every turn the AI
// holds a colony ship. This is tested through the client, not by asserting a flag, because the
// flag is the claim and the refusal is the behaviour.
// * the species restriction on the one phase that has one, because it is the reason a whole
// prologue gate has never been observed set;
// * the first pass writing nothing even when the task action does not check the pass. The
// original gates the writers, not the callers; a model that relied on every task being
// well-behaved would be a different model.
// * the second pass visiting the same tasks in the same order as the first -- the list is ranked
// once and not re-sorted or pruned between passes.
#include "game/ai/agent.h"
#include <cstdio>
#include <string>
#include <vector>
using namespace sots::ai;
using sots::sim::Species;
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());
}
}
RankedTask T(TaskType t) {
RankedTask r;
r.type = t;
return r;
}
void TestPhaseTable() {
const auto& phases = ProcessTurnPhases();
check(phases.size() == static_cast<std::size_t>(kProcessTurnPhaseCount), "thirty-four phases");
for (int i = 0; i < kProcessTurnPhaseCount; ++i) {
check(phases[static_cast<std::size_t>(i)].index == i, "phase " + std::to_string(i) + " is in position");
}
check(phases[kSubmitPhase].emits == PhaseEmission::Submit, "phase 28 submits");
check(!phases[kSubmitPhase].dead, "the submit phase is not itself dead");
for (int i = 0; i < kProcessTurnPhaseCount; ++i) {
check(phases[static_cast<std::size_t>(i)].dead == (i > kSubmitPhase),
"phase " + std::to_string(i) + " deadness");
}
check(phases[32].emits == PhaseEmission::Colonize, "phase 32 would issue a colonize order");
check(phases[32].dead, "but it runs after the submit and is refused");
}
void TestPhaseCanEmit() {
const auto& phases = ProcessTurnPhases();
// The species-restricted phase, from both sides.
check(PhaseCanEmit(phases[2], Species::Hiver), "phase 2 emits for the one species that runs it");
check(!PhaseCanEmit(phases[2], Species::Human), "and for nobody else");
check(!PhaseCanEmit(phases[2], Species::Tarkas), "including the reference game's AI species");
// The dead tail is dead for every species.
check(!PhaseCanEmit(phases[32], Species::Hiver), "the colonize phase is dead even for Hiver");
// The live emitters.
check(PhaseCanEmit(phases[kTaskListPhase], Species::Tarkas), "the task phase emits");
check(PhaseCanEmit(phases[21], Species::Tarkas), "so does the system-rates phase");
check(PhaseCanEmit(phases[18], Species::Tarkas), "so does the research-target phase");
// A phase with no traced emission never claims one.
check(!PhaseCanEmit(phases[1], Species::Tarkas), "an untraced phase emits nothing");
// The submit phase is not an emitter.
check(!PhaseCanEmit(phases[kSubmitPhase], Species::Tarkas), "the submit phase is not an emitter");
}
void TestDeadTailThroughTheClient() {
OrderClient c(32);
const auto& phases = ProcessTurnPhases();
int refused = 0;
for (const auto& p : phases) {
if (p.emits == PhaseEmission::Submit) {
c.EndTurn(0.8f);
continue;
}
if (p.emits == PhaseEmission::Colonize) {
if (!c.OrderColonize(ColonizeOrder{})) ++refused;
}
}
check(refused == 1, "walking the whole phase list refuses exactly the one late colonize");
check(c.block().ElementCount(CommandList::Colonize) == 0, "and the block carries no colonize order");
}
void TestTwoPassSilence() {
std::vector<RankedTask> tasks = {T(TaskType::Colonize), T(TaskType::Explore),
T(TaskType::AdvanceIdleShips)};
OrderClient client(32);
TaskPriorityPolicy policy;
std::vector<int> passesSeen;
std::vector<std::vector<TaskType>> orderPerPass(2);
int accepted = 0;
// Deliberately a task action that does NOT check the pass: the original gates the writers.
RunTaskList(tasks, policy, client, [&](const RankedTask& t, int pass, OrderClient& c) {
if (orderPerPass[static_cast<std::size_t>(pass)].empty()) passesSeen.push_back(pass);
orderPerPass[static_cast<std::size_t>(pass)].push_back(t.type);
if (c.OrderBuild(BuildOrder{})) ++accepted;
});
check(passesSeen.size() == 2 && passesSeen[0] == 0 && passesSeen[1] == 1, "two passes, 0 then 1");
check(accepted == 3, "only the second pass's three attempts were accepted");
check(client.block().ElementCount(CommandList::Build) == 3, "and only three builds reached the block");
check(orderPerPass[0] == orderPerPass[1], "the second pass visits the same tasks in the same order");
check(orderPerPass[0].size() == 3, "every task is visited in every pass");
// Ranked descending: Colonize 900, Explore 600, AdvanceIdleShips 0.
check(orderPerPass[0][0] == TaskType::Colonize, "highest priority first");
check(orderPerPass[0][2] == TaskType::AdvanceIdleShips, "the sweep-up task last");
check(tasks[0].type == TaskType::Colonize, "the caller's list is left ranked");
}
void TestTaskWalkAfterSubmit() {
// A task walk that somehow ran after the submit still writes nothing: the latch outranks the
// pass gate.
std::vector<RankedTask> tasks = {T(TaskType::Colonize)};
OrderClient client(32);
client.EndTurn(0.8f);
int accepted = 0;
RunTaskList(tasks, TaskPriorityPolicy{}, client, [&](const RankedTask&, int, OrderClient& c) {
if (c.OrderBuild(BuildOrder{})) ++accepted;
});
check(accepted == 0, "the submit latch outranks the pass gate");
}
void TestEmptyTaskList() {
std::vector<RankedTask> tasks;
OrderClient client(32);
int calls = 0;
RunTaskList(tasks, TaskPriorityPolicy{}, client, [&](const RankedTask&, int, OrderClient&) { ++calls; });
check(calls == 0, "an empty task list runs no actions");
check(client.OrdersAccepted(), "and leaves the pass gate disengaged");
check(TaskWalkPasses().size() == 2, "there are exactly two passes");
}
void TestNpcSpeciesBuildsNothing() {
// The species that creates no tasks reaches the task phase and finds nothing to do. The phase
// is still live -- it is the list that is empty, which is a different statement.
check(CreationOrder(Species::NPC, true).empty(), "the NPC species builds no tasks");
check(PhaseCanEmit(ProcessTurnPhases()[kTaskListPhase], Species::NPC),
"the task phase is still live for it");
}
} // namespace
int main() {
TestPhaseTable();
TestPhaseCanEmit();
TestDeadTailThroughTheClient();
TestTwoPassSilence();
TestTaskWalkAfterSubmit();
TestEmptyTaskList();
TestNpcSpeciesBuildsNothing();
std::printf("game_ai/agent: %d checks, %d failures\n", g_checks, g_fails);
return g_fails == 0 ? 0 : 1;
}