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
This commit is contained in:
parent
aabd8a3506
commit
3ca010978c
8 changed files with 1244 additions and 1 deletions
|
|
@ -3,7 +3,9 @@
|
|||
# sim answers "what happens", this answers "what does an AI player decide to try".
|
||||
add_library(sots_game_ai STATIC
|
||||
tasks.cpp
|
||||
turn_order.cpp)
|
||||
turn_order.cpp
|
||||
orders.cpp
|
||||
agent.cpp)
|
||||
target_include_directories(sots_game_ai PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..)
|
||||
target_compile_features(sots_game_ai PUBLIC cxx_std_17)
|
||||
if(NOT MSVC)
|
||||
|
|
|
|||
76
src/game/ai/agent.cpp
Normal file
76
src/game/ai/agent.cpp
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
#include "game/ai/agent.h"
|
||||
|
||||
namespace sots::ai {
|
||||
namespace {
|
||||
|
||||
// Phases are numbered by their position in the original's body. Names are given only where a log
|
||||
// string or an already-named callee pins one; the rest stay empty on purpose, because a plausible
|
||||
// name for an unread phase is how a guess becomes a fact.
|
||||
std::vector<ProcessTurnPhase> BuildPhases() {
|
||||
std::vector<ProcessTurnPhase> p(kProcessTurnPhaseCount);
|
||||
for (int i = 0; i < kProcessTurnPhaseCount; ++i) {
|
||||
p[static_cast<std::size_t>(i)].index = i;
|
||||
p[static_cast<std::size_t>(i)].dead = PhaseRunsAfterSubmit(i);
|
||||
}
|
||||
auto at = [&p](int i) -> ProcessTurnPhase& { return p[static_cast<std::size_t>(i)]; };
|
||||
|
||||
at(0).name = "log banner";
|
||||
at(2).emits = PhaseEmission::Group5Gate;
|
||||
at(2).speciesRestricted = true;
|
||||
at(10).name = "survival outlook";
|
||||
at(11).name = "survival outlook log";
|
||||
at(12).name = "surrender roll"; // the AI's only chance draw in the turn body
|
||||
at(14).emits = PhaseEmission::Group4Gate;
|
||||
at(15).name = "set research rate";
|
||||
at(15).emits = PhaseEmission::ResearchRate;
|
||||
at(18).name = "set research rate and target";
|
||||
at(18).emits = PhaseEmission::ResearchTarget;
|
||||
at(kTaskListPhase).name = "task list";
|
||||
at(kTaskListPhase).emits = PhaseEmission::TaskList;
|
||||
at(21).name = "system rates";
|
||||
at(21).emits = PhaseEmission::SystemRates;
|
||||
at(23).emits = PhaseEmission::PopulationCmd;
|
||||
at(24).emits = PhaseEmission::FleetLayout;
|
||||
at(26).name = "new design";
|
||||
at(26).emits = PhaseEmission::NewDesign;
|
||||
at(kSubmitPhase).name = "end turn";
|
||||
at(kSubmitPhase).emits = PhaseEmission::Submit;
|
||||
at(32).name = "colonize";
|
||||
at(32).emits = PhaseEmission::Colonize;
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const std::vector<ProcessTurnPhase>& ProcessTurnPhases() {
|
||||
static const std::vector<ProcessTurnPhase> kPhases = BuildPhases();
|
||||
return kPhases;
|
||||
}
|
||||
|
||||
bool PhaseCanEmit(const ProcessTurnPhase& phase, sim::Species species) {
|
||||
if (phase.emits == PhaseEmission::None) return false;
|
||||
if (phase.emits == PhaseEmission::Submit) return false;
|
||||
if (phase.dead) return false;
|
||||
if (phase.speciesRestricted && species != kGroup5GatePhaseSpecies) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const std::vector<int>& TaskWalkPasses() {
|
||||
static const std::vector<int> kPasses = {static_cast<int>(TaskPass::Reserve),
|
||||
static_cast<int>(TaskPass::Fill)};
|
||||
return kPasses;
|
||||
}
|
||||
|
||||
void RunTaskList(std::vector<RankedTask>& tasks, const TaskPriorityPolicy& policy,
|
||||
OrderClient& client, const TaskAction& action) {
|
||||
Rank(tasks, policy);
|
||||
for (int pass : TaskWalkPasses()) {
|
||||
client.EnterTaskPass(pass);
|
||||
for (const RankedTask& t : tasks) {
|
||||
if (action) action(t, pass, client);
|
||||
}
|
||||
client.LeaveTaskPass();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace sots::ai
|
||||
117
src/game/ai/agent.h
Normal file
117
src/game/ai/agent.h
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// The strategic AI's turn, as a sequence of phases and a two-pass walk over its task list.
|
||||
//
|
||||
// An AI player's whole turn runs synchronously in one go, on the main thread, after the pre-turn
|
||||
// autosave has already been written. That ordering is why no pre-turn save can ever contain an AI
|
||||
// order: the orders do not exist yet when the file is written. What the turn IS, is a fixed list
|
||||
// of thirty-four phases, always in the same order, of which a handful reach the order API.
|
||||
//
|
||||
// Two structural facts here are worth more than the phase names:
|
||||
//
|
||||
// * PHASE 28 SUBMITS, AND EVERYTHING AFTER IT IS DEAD. The submit path latches the client closed
|
||||
// before it builds the send buffer, and every order method refuses once that latch is down.
|
||||
// Five phases run after the submit, and one of them would otherwise issue a colonize order. A
|
||||
// port that walks the phase list and lets the tail through emits commands the original never
|
||||
// sends -- and would be over on the modification counter by exactly one per AI per turn with a
|
||||
// colony ship in hand.
|
||||
// * THE TASK WALK IS TWO PASSES AND THE FIRST WRITES NOTHING. The pass argument is a tier index,
|
||||
// not a plan/act switch: each request for force carries two quotas, a smaller and a larger,
|
||||
// and the pass picks which is in force. The second pass redoes the first tier and then tops up
|
||||
// to the second. Separately -- and this is the part that matters for the counter -- every path
|
||||
// that can write a command is gated on being in the second pass.
|
||||
//
|
||||
// This header does NOT decide anything. Which tasks exist, what each one wants, and whether it
|
||||
// finds a target are all questions about the board, and nothing in this module models the board.
|
||||
// What it provides is the skeleton the decisions hang in: the phase order, which phases can emit,
|
||||
// and a task walk that reproduces the original's ordering and its first-pass silence exactly.
|
||||
//
|
||||
// Pure: no state, no I/O, no random draws.
|
||||
// CONFIDENCE: high on the phase count and order, on the submit latch and the dead tail, and on the
|
||||
// two-pass structure -- all read from the original's instruction stream. The phase NAMES are only
|
||||
// as good as the order method each phase was traced to; phases with no traced emission are left
|
||||
// unnamed on purpose rather than guessed at.
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
#include "game/ai/orders.h"
|
||||
#include "game/ai/tasks.h"
|
||||
#include "game/ai/turn_order.h"
|
||||
|
||||
namespace sots::ai {
|
||||
|
||||
// The AI's turn is exactly this many phases, numbered in execution order.
|
||||
constexpr int kProcessTurnPhaseCount = 34;
|
||||
|
||||
// The phase that submits the turn. Everything after it runs, and everything after it is refused.
|
||||
constexpr int kSubmitPhase = 28;
|
||||
|
||||
// The phase that walks the task list.
|
||||
constexpr int kTaskListPhase = 20;
|
||||
|
||||
constexpr bool PhaseRunsAfterSubmit(int phase) { return phase > kSubmitPhase; }
|
||||
|
||||
// What a phase can put into the command block. Phases that reach no order method carry None; that
|
||||
// is a statement about what was traced, not a claim that they do nothing.
|
||||
enum class PhaseEmission {
|
||||
None,
|
||||
Group5Gate, // phase 2 -- and only for one species; see speciesRestricted
|
||||
Group4Gate, // phase 14
|
||||
ResearchRate, // phases 15 and 18
|
||||
ResearchTarget, // phase 18
|
||||
TaskList, // phase 20 -- whatever the tasks issue
|
||||
SystemRates, // phase 21 -- list 5
|
||||
PopulationCmd, // phase 23 -- list 23
|
||||
FleetLayout, // phase 24 -- list 12
|
||||
NewDesign, // phase 26 -- list 1
|
||||
Submit, // phase 28
|
||||
Colonize, // phase 32 -- list 7, and it is dead
|
||||
};
|
||||
|
||||
struct ProcessTurnPhase {
|
||||
int index = 0;
|
||||
const char* name = ""; // "" where nothing pins a name
|
||||
PhaseEmission emits = PhaseEmission::None;
|
||||
// True when the phase's body returns immediately for every species but one. Only phase 2 is,
|
||||
// and it is the reason the group-5 gate has never been seen set in any save: the corpus has no
|
||||
// player of that species.
|
||||
bool speciesRestricted = false;
|
||||
// True when the phase runs but every order it issues is refused, because the turn is already
|
||||
// submitted. Derived, kept explicit so a reader sees it in the table.
|
||||
bool dead = false;
|
||||
};
|
||||
|
||||
// The thirty-four phases, in execution order.
|
||||
const std::vector<ProcessTurnPhase>& ProcessTurnPhases();
|
||||
|
||||
// The one phase whose emission is species-restricted, and the species it is restricted to.
|
||||
constexpr sim::Species kGroup5GatePhaseSpecies = sim::Species::Hiver;
|
||||
|
||||
// Whether a phase can actually deposit a command in the block, given the species and accounting
|
||||
// for the dead tail. This is the predicate a turn model should consult -- not `emits != None`.
|
||||
bool PhaseCanEmit(const ProcessTurnPhase& phase, sim::Species species);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The task walk
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
// What one task does when it is run. `pass` is the tier index the walk is in. The action may issue
|
||||
// orders through the client; in the first pass the client refuses them, exactly as the original
|
||||
// does, so an action that does not check the pass itself still cannot write.
|
||||
using TaskAction = std::function<void(const RankedTask& task, int pass, OrderClient& client)>;
|
||||
|
||||
// Walk a task list the way the original does.
|
||||
//
|
||||
// Ranks the list -- a stable descending sort by priority, so ties keep creation order -- and then
|
||||
// runs the whole list twice, once per pass, in that ranked order. The list is ranked ONCE, before
|
||||
// the first pass, and the second pass visits the same tasks in the same order: the original does
|
||||
// not re-sort and does not prune between the passes.
|
||||
//
|
||||
// `tasks` is ranked in place, so a caller can inspect the order afterwards.
|
||||
void RunTaskList(std::vector<RankedTask>& tasks, const TaskPriorityPolicy& policy,
|
||||
OrderClient& client, const TaskAction& action);
|
||||
|
||||
// The passes a task walk runs, in order, as plain ints. Two, always.
|
||||
const std::vector<int>& TaskWalkPasses();
|
||||
|
||||
} // namespace sots::ai
|
||||
221
src/game/ai/orders.cpp
Normal file
221
src/game/ai/orders.cpp
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
#include "game/ai/orders.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace sots::ai {
|
||||
namespace {
|
||||
|
||||
bool Modelled(CommandList list) {
|
||||
switch (list) {
|
||||
case CommandList::Build:
|
||||
case CommandList::SystemRates:
|
||||
case CommandList::Colonize:
|
||||
case CommandList::FleetMove:
|
||||
case CommandList::FleetTask:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int IndexOf(CommandList list) { return static_cast<int>(list) - 1; }
|
||||
|
||||
} // namespace
|
||||
|
||||
bool TurnCommandBlock::GateSet(PrologueGate gate) const {
|
||||
switch (gate) {
|
||||
case PrologueGate::ResearchRate:
|
||||
return hasResearchRate;
|
||||
case PrologueGate::ResearchTarget:
|
||||
return hasResearchTarget;
|
||||
case PrologueGate::ResearchBoost:
|
||||
return hasResearchBoost;
|
||||
case PrologueGate::Group4:
|
||||
return hasGroup4;
|
||||
case PrologueGate::Group5:
|
||||
return hasGroup5;
|
||||
case PrologueGate::CivilianRatios:
|
||||
return hasCivilianRatios;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int TurnCommandBlock::ElementCount(CommandList list) const {
|
||||
switch (list) {
|
||||
case CommandList::Build:
|
||||
return static_cast<int>(build.size());
|
||||
case CommandList::SystemRates:
|
||||
return static_cast<int>(systemRates.size());
|
||||
case CommandList::Colonize:
|
||||
return static_cast<int>(colonize.size());
|
||||
case CommandList::FleetMove:
|
||||
return static_cast<int>(fleetMoves.size());
|
||||
case CommandList::FleetTask:
|
||||
return static_cast<int>(fleetTasks.size());
|
||||
default:
|
||||
break;
|
||||
}
|
||||
const int i = IndexOf(list);
|
||||
if (i < 0 || i >= kCommandListCount) return 0;
|
||||
return unmodelled[static_cast<std::size_t>(i)];
|
||||
}
|
||||
|
||||
void TurnCommandBlock::AddUnmodelled(CommandList list, int n) {
|
||||
if (n <= 0 || Modelled(list)) return;
|
||||
const int i = IndexOf(list);
|
||||
if (i < 0 || i >= kCommandListCount) return;
|
||||
unmodelled[static_cast<std::size_t>(i)] += n;
|
||||
}
|
||||
|
||||
ModCountCost BlockModCountCost(const TurnCommandBlock& block) {
|
||||
ModCountCost cost;
|
||||
for (int g = 0; g < kPrologueGateCount; ++g) {
|
||||
const auto gate = static_cast<PrologueGate>(g);
|
||||
if (!block.GateSet(gate)) continue;
|
||||
switch (GateModCountCost(gate)) {
|
||||
case GateCost::OneBump:
|
||||
++cost.bumps;
|
||||
break;
|
||||
case GateCost::Free:
|
||||
break;
|
||||
case GateCost::Unknown:
|
||||
cost.exact = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (int n = 1; n <= kCommandListCount; ++n) {
|
||||
const auto list = static_cast<CommandList>(n);
|
||||
if (!ListAdvancesModCount(list)) continue;
|
||||
cost.bumps += block.ElementCount(list);
|
||||
}
|
||||
return cost;
|
||||
}
|
||||
|
||||
ModCountCost TurnModCountDelta(const std::vector<TurnCommandBlock>& blocks, int abandonedSystems) {
|
||||
ModCountCost total;
|
||||
total.bumps = kTurnDriverBumps + std::max(0, abandonedSystems);
|
||||
for (const auto& b : blocks) {
|
||||
const ModCountCost c = BlockModCountCost(b);
|
||||
total.bumps += c.bumps;
|
||||
total.exact = total.exact && c.exact;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// --- OrderClient -------------------------------------------------------------------------------
|
||||
|
||||
bool OrderClient::SetResearchRate(float rate) {
|
||||
if (!OrdersAccepted()) return false;
|
||||
block_.hasResearchRate = true;
|
||||
block_.researchRate = rate;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OrderClient::SetResearchTarget(int techId) {
|
||||
if (!OrdersAccepted()) return false;
|
||||
block_.hasResearchTarget = true;
|
||||
block_.researchTarget = techId;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OrderClient::BoostResearch(int spend, float fraction) {
|
||||
if (!OrdersAccepted()) return false;
|
||||
block_.hasResearchBoost = true;
|
||||
block_.researchBoostSpend = spend;
|
||||
block_.researchBoostFraction = fraction;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OrderClient::SetGroup4(bool flag, int value) {
|
||||
if (!OrdersAccepted()) return false;
|
||||
block_.hasGroup4 = true;
|
||||
block_.group4Flag = flag;
|
||||
block_.group4Value = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OrderClient::SetGroup5(float a, float b, float c) {
|
||||
if (!OrdersAccepted()) return false;
|
||||
block_.hasGroup5 = true;
|
||||
block_.group5a = a;
|
||||
block_.group5b = b;
|
||||
block_.group5c = c;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OrderClient::OrderBuild(const BuildOrder& order) {
|
||||
if (!OrdersAccepted()) return false;
|
||||
block_.build.push_back(order);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OrderClient::OrderSystemRates(const SystemRatesOrder& order) {
|
||||
if (!OrdersAccepted()) return false;
|
||||
block_.systemRates.push_back(order);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OrderClient::OrderColonize(const ColonizeOrder& order) {
|
||||
if (!OrdersAccepted()) return false;
|
||||
block_.colonize.push_back(order);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OrderClient::OrderFleetTask(int fleetId, int mode, bool flag) {
|
||||
if (!OrdersAccepted()) return false;
|
||||
// The adder scans for a node with the same fleet AND the same mode, updates it in place when
|
||||
// it finds one, and appends otherwise. That key is why the AI's two calls cost two elements
|
||||
// and why re-issuing the same one costs none.
|
||||
for (auto& e : block_.fleetTasks) {
|
||||
if (e.fleetId == fleetId && e.mode == mode) {
|
||||
e.flag = flag;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
FleetTaskOrder e;
|
||||
e.fleetId = fleetId;
|
||||
e.mode = mode;
|
||||
e.flag = flag;
|
||||
block_.fleetTasks.push_back(e);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OrderClient::QueueFleetRoute(int fleetId, std::vector<int> route) {
|
||||
if (!OrdersAccepted()) return false;
|
||||
FleetMoveOrder m;
|
||||
m.fleetId = fleetId;
|
||||
m.route = std::move(route);
|
||||
pendingRoutes_.push_back(std::move(m));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OrderClient::OrderUnmodelled(CommandList list, int n) {
|
||||
if (!OrdersAccepted()) return false;
|
||||
block_.AddUnmodelled(list, n);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OrderClient::IssueAiFleetOrder(int fleetId, std::vector<int> route) {
|
||||
if (!OrdersAccepted()) return false;
|
||||
QueueFleetRoute(fleetId, std::move(route));
|
||||
// Both calls, in this order. The second is the whole difference between an AI fleet order and
|
||||
// an interface one.
|
||||
OrderFleetTask(fleetId, 0, true);
|
||||
OrderFleetTask(fleetId, 1, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
void OrderClient::EndTurn(float playerResearchRate) {
|
||||
if (turnEnded_) return;
|
||||
// The latch goes down before the send buffer is built, which is what makes every later phase
|
||||
// of a turn dead. Setting it here, before the two writes below, would be wrong only if those
|
||||
// writes went through the order API -- they do not; they are direct copies out of live player
|
||||
// state, and they happen whatever the latch says.
|
||||
turnEnded_ = true;
|
||||
block_.hasResearchRate = true;
|
||||
block_.researchRate = playerResearchRate;
|
||||
for (auto& m : pendingRoutes_) block_.fleetMoves.push_back(std::move(m));
|
||||
pendingRoutes_.clear();
|
||||
}
|
||||
|
||||
} // namespace sots::ai
|
||||
332
src/game/ai/orders.h
Normal file
332
src/game/ai/orders.h
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
// The orders a client submits for a turn, and what each one costs the save's modification counter.
|
||||
//
|
||||
// Every order any player issues -- the AI's included, through exactly the same API the interface
|
||||
// uses -- lands in one accumulating command block on that player's client. At End Turn the block
|
||||
// is copied to a send buffer and shipped; the server later applies every submitted block, one
|
||||
// command at a time, and the counter in the save advances as it goes. So the counter is not a
|
||||
// property of the board: it is the length of the turn's command stream, and it is computable from
|
||||
// the blocks alone.
|
||||
//
|
||||
// The block has two halves, and this header models both:
|
||||
//
|
||||
// * six flag-gated single commands (research rate, research target, research boost, and three
|
||||
// more), each a bool plus its payload;
|
||||
// * twenty-seven counted command lists, all twenty-seven always present, most of them always
|
||||
// empty.
|
||||
//
|
||||
// Three things about the cost are easy to get wrong and are the reason this is code and not a
|
||||
// comment:
|
||||
//
|
||||
// * NOT EVERY COMMAND COSTS. Applying an element of lists 1..16 advances the counter; applying
|
||||
// an element of lists 17..27 does not, and neither does one of the six gates. The command is
|
||||
// still applied -- it just leaves no trace in the counter. A cost model that charges per
|
||||
// element uniformly is wrong on any turn that touches the free half.
|
||||
// * THE RESEARCH-RATE GATE IS ALWAYS SET. Building the send buffer sets it unconditionally from
|
||||
// live player state, whatever the player did, so *every* submitted block costs at least one.
|
||||
// A player who does nothing at all still costs one. That single fact is the largest term in
|
||||
// the counter's per-turn delta on a quiet board.
|
||||
// * A FLEET ORDER FROM THE AI IS NOT A FLEET ORDER FROM THE INTERFACE. The interface deposits
|
||||
// one fleet-task element; the AI's bridge calls the same method twice, with mode 0 and mode 1,
|
||||
// and the adder keys on (fleet, mode), so the AI deposits two. Same order, twice the cost.
|
||||
//
|
||||
// Pure: no state outside the block, no I/O, no random draws.
|
||||
// CONFIDENCE: high on the gate/list cost table and on the two-elements-per-AI-fleet-order rule --
|
||||
// both read from the original's instruction stream and both corroborated by a live watchpoint run
|
||||
// that trapped every counter write of two consecutive turns. The free half of the list table is
|
||||
// read from the instruction stream ONLY: no save has ever carried an element in lists 17..27, so
|
||||
// nothing has exercised it. One gate (the civilian-ratios one) has no located applier at all and
|
||||
// its cost is unknown rather than zero -- ModCountCost reports that rather than guessing.
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace sots::ai {
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The command lists
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
// The block carries exactly this many command lists, always, even when every one is empty.
|
||||
constexpr int kCommandListCount = 27;
|
||||
|
||||
// Lists are numbered 1..27 in the order they are written. Only the ones a workload has ever
|
||||
// populated get a name; the rest are deliberately left as numbers, because naming a list from its
|
||||
// element's scalar shape alone is how a hypothesis becomes a fact by accident.
|
||||
enum class CommandList : int {
|
||||
NewDesigns = 1, // a ship design plus an int
|
||||
List02 = 2,
|
||||
Build = 3, // build-queue order: ordinal, design, system
|
||||
List04 = 4,
|
||||
SystemRates = 5, // a system's planetary budget sliders
|
||||
PlayerNotes = 6,
|
||||
Colonize = 7, // a colony ship told to settle
|
||||
FleetMove = 8, // a fleet plus the route it was given
|
||||
List09 = 9,
|
||||
List10 = 10,
|
||||
List11 = 11,
|
||||
FleetLayouts = 12,
|
||||
List13 = 13,
|
||||
FleetTask = 14, // the AI's fleet order; see AiFleetOrder below
|
||||
List15 = 15,
|
||||
List16 = 16,
|
||||
List17 = 17,
|
||||
List18 = 18,
|
||||
List19 = 19,
|
||||
List20 = 20,
|
||||
List21 = 21,
|
||||
WeaponGroups = 22,
|
||||
PopulationCmds = 23,
|
||||
List24 = 24,
|
||||
DefenceLayouts = 25,
|
||||
RaidTargets = 26,
|
||||
List27 = 27,
|
||||
};
|
||||
|
||||
// The last list whose elements advance the modification counter. The boundary is sharp: 1..16 all
|
||||
// pay, 17..27 all do not, with no exception in either direction. It reads like a real division in
|
||||
// the original -- the paying half is the half whose elements name a game object to act on -- but
|
||||
// this module only claims the arithmetic, not the reason.
|
||||
constexpr int kLastCountedList = 16;
|
||||
|
||||
// Whether applying one element of this list advances the modification counter.
|
||||
constexpr bool ListAdvancesModCount(CommandList list) {
|
||||
const int n = static_cast<int>(list);
|
||||
return n >= 1 && n <= kLastCountedList;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The six flag-gated single commands
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
enum class PrologueGate : int {
|
||||
ResearchRate = 0, // set unconditionally when the send buffer is built
|
||||
ResearchTarget = 1, // the tech the player is aiming at
|
||||
ResearchBoost = 2, // savings spent to accelerate research, plus a fraction
|
||||
Group4 = 3, // a bool and an int; never observed set in any save
|
||||
Group5 = 4, // three floats; AI-only AND species-restricted, so no save carries it
|
||||
CivilianRatios = 5, // the empire civilian-settings command; interface-only
|
||||
};
|
||||
|
||||
constexpr int kPrologueGateCount = 6;
|
||||
|
||||
// How much applying a set gate costs. Five of the six are settled; the sixth has no applier
|
||||
// anywhere in the command-application path, so its cost is genuinely unknown and is reported as
|
||||
// such instead of being assumed free.
|
||||
enum class GateCost { Free, OneBump, Unknown };
|
||||
|
||||
constexpr GateCost GateModCountCost(PrologueGate gate) {
|
||||
switch (gate) {
|
||||
case PrologueGate::ResearchRate:
|
||||
case PrologueGate::ResearchTarget:
|
||||
case PrologueGate::ResearchBoost:
|
||||
case PrologueGate::Group4:
|
||||
return GateCost::OneBump;
|
||||
case PrologueGate::Group5:
|
||||
return GateCost::Free;
|
||||
case PrologueGate::CivilianRatios:
|
||||
return GateCost::Unknown;
|
||||
}
|
||||
return GateCost::Unknown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The element records this module models
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
// List 3. `ordinal` is the running build-queue index on the target system, not a per-turn counter.
|
||||
struct BuildOrder {
|
||||
int ordinal = 0;
|
||||
int designId = 0;
|
||||
int systemId = 0;
|
||||
int trailing = 0;
|
||||
};
|
||||
|
||||
// List 5. The planetary budget sliders for one system, in the order they are written.
|
||||
struct SystemRatesOrder {
|
||||
int systemId = 0;
|
||||
float ship = 0;
|
||||
float terraform = 0;
|
||||
float sciences = 0;
|
||||
float trade = 0;
|
||||
float infrastructure = 0;
|
||||
float overharvest = 0;
|
||||
int noRate = 0;
|
||||
};
|
||||
|
||||
// List 7.
|
||||
struct ColonizeOrder {
|
||||
int shipId = 0;
|
||||
int trailing = 0;
|
||||
};
|
||||
|
||||
// List 8. The route is a counted vector of system ids, so a multi-hop order is longer on the wire
|
||||
// than a single-hop one -- but it is still ONE element and therefore ONE counter bump.
|
||||
struct FleetMoveOrder {
|
||||
int fleetId = 0;
|
||||
std::vector<int> route;
|
||||
};
|
||||
|
||||
// List 14. The adder keys on (fleetId, mode): a second call with the same pair updates in place
|
||||
// rather than appending, which is what makes the AI's two calls two elements and a repeat of one
|
||||
// of them zero.
|
||||
struct FleetTaskOrder {
|
||||
int fleetId = 0;
|
||||
int mode = 0;
|
||||
bool flag = false;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The block
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
// One player's submitted commands for one turn.
|
||||
//
|
||||
// Lists this module has a typed record for are held as such; the rest are held as a count, because
|
||||
// their element bodies are unmodelled and a wrong body is worse than an honest number. The counter
|
||||
// arithmetic only needs the count, so nothing is lost for the purpose this exists for.
|
||||
struct TurnCommandBlock {
|
||||
int playerId = 0;
|
||||
|
||||
bool hasResearchRate = false;
|
||||
float researchRate = 0;
|
||||
bool hasResearchTarget = false;
|
||||
int researchTarget = 0;
|
||||
bool hasResearchBoost = false;
|
||||
int researchBoostSpend = 0;
|
||||
float researchBoostFraction = 0;
|
||||
bool hasGroup4 = false;
|
||||
bool group4Flag = false;
|
||||
int group4Value = 0;
|
||||
bool hasGroup5 = false;
|
||||
float group5a = 0, group5b = 0, group5c = 0;
|
||||
bool hasCivilianRatios = false;
|
||||
|
||||
std::vector<BuildOrder> build; // list 3
|
||||
std::vector<SystemRatesOrder> systemRates; // list 5
|
||||
std::vector<ColonizeOrder> colonize; // list 7
|
||||
std::vector<FleetMoveOrder> fleetMoves; // list 8
|
||||
std::vector<FleetTaskOrder> fleetTasks; // list 14
|
||||
|
||||
// Element counts for every list this module does not model. Indexed by list number - 1;
|
||||
// entries for the five modelled lists stay zero and are never read.
|
||||
std::array<int, kCommandListCount> unmodelled{};
|
||||
|
||||
// Whether a gate is set.
|
||||
bool GateSet(PrologueGate gate) const;
|
||||
// How many elements a list holds, modelled or not.
|
||||
int ElementCount(CommandList list) const;
|
||||
// Record `n` elements of a list whose payload this module does not model. Lets a caller that
|
||||
// knows a command was issued keep the counter arithmetic honest without inventing a record.
|
||||
void AddUnmodelled(CommandList list, int n = 1);
|
||||
};
|
||||
|
||||
// What applying one block costs the modification counter.
|
||||
//
|
||||
// `exact` is false when the block sets a gate whose cost is not established, in which case `bumps`
|
||||
// is a lower bound. Everything else is exact.
|
||||
struct ModCountCost {
|
||||
int bumps = 0;
|
||||
bool exact = true;
|
||||
};
|
||||
|
||||
ModCountCost BlockModCountCost(const TurnCommandBlock& block);
|
||||
|
||||
// The modification counter's delta across one whole end-of-turn, given every submitted block.
|
||||
//
|
||||
// Two bumps come from the turn drivers themselves and are unconditional. `abandonedSystems` is the
|
||||
// one other writer: a per-system check inside the turn bumps once for each system flagged as
|
||||
// abandoned. It is zero on every save the campaign holds, so that term has never been exercised --
|
||||
// it is here so a caller cannot silently omit it, not because it has been seen.
|
||||
ModCountCost TurnModCountDelta(const std::vector<TurnCommandBlock>& blocks, int abandonedSystems = 0);
|
||||
|
||||
// The two bumps the turn drivers contribute regardless of what anyone ordered.
|
||||
constexpr int kTurnDriverBumps = 2;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The order API
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
// A client accumulating one turn's orders.
|
||||
//
|
||||
// Two behaviours here are not conveniences, they are the original's and they change the output:
|
||||
//
|
||||
// * Once the turn is ended, EVERY order method refuses. The end-turn path sets that latch BEFORE
|
||||
// it builds the send buffer, so any phase of the AI's turn that runs after it submits is dead
|
||||
// code -- it calls the order method and is turned away. A port that runs the whole phase list
|
||||
// and lets the late phases through emits commands the original never sends.
|
||||
// * While the task list is being walked, orders are refused unless the walk is in its second
|
||||
// pass. The first pass is a claim pass: it reserves each task's minimum force in priority
|
||||
// order and writes nothing at all.
|
||||
class OrderClient {
|
||||
public:
|
||||
explicit OrderClient(int playerId) { block_.playerId = playerId; }
|
||||
|
||||
// --- the pass gate -------------------------------------------------------------------------
|
||||
// Engaged while the task list is being walked. Disengaged (the default) for the fixed phases
|
||||
// of the turn, which are outside the task loop and are not pass-gated.
|
||||
void EnterTaskPass(int pass) { taskPass_ = pass; }
|
||||
void LeaveTaskPass() { taskPass_ = kNoTaskPass; }
|
||||
// The pass in which the emitting paths are open. The first pass claims and writes nothing.
|
||||
static constexpr int kEmittingPass = 1;
|
||||
static constexpr int kNoTaskPass = -1;
|
||||
bool OrdersAccepted() const {
|
||||
return !turnEnded_ && (taskPass_ == kNoTaskPass || taskPass_ == kEmittingPass);
|
||||
}
|
||||
|
||||
// --- the gated single commands -------------------------------------------------------------
|
||||
bool SetResearchRate(float rate);
|
||||
bool SetResearchTarget(int techId);
|
||||
bool BoostResearch(int spend, float fraction);
|
||||
bool SetGroup4(bool flag, int value);
|
||||
bool SetGroup5(float a, float b, float c);
|
||||
|
||||
// --- the list commands ---------------------------------------------------------------------
|
||||
bool OrderBuild(const BuildOrder& order);
|
||||
bool OrderSystemRates(const SystemRatesOrder& order);
|
||||
bool OrderColonize(const ColonizeOrder& order);
|
||||
// One fleet-task element. Returns true when the command was accepted, whether it appended a
|
||||
// new element or updated an existing one -- the (fleetId, mode) key decides which, and only an
|
||||
// append changes what the turn costs.
|
||||
bool OrderFleetTask(int fleetId, int mode, bool flag);
|
||||
// Queue a route for a fleet. Routes do not enter the block when they are issued; they sit on
|
||||
// the client and are flushed into list 8 by EndTurn.
|
||||
//
|
||||
// UNVERIFIED: whether re-routing a fleet that already has a queued route replaces that entry
|
||||
// or appends a second one. The original holds these in a plain vector of pairs and nothing was
|
||||
// read that would rule either way, so this appends -- the simpler reading -- and no test
|
||||
// depends on the choice. It matters only for a turn in which one fleet is ordered twice, and
|
||||
// it is worth one hook if that ever turns up.
|
||||
bool QueueFleetRoute(int fleetId, std::vector<int> route);
|
||||
// Record a command in a list this module does not model, so the cost stays right.
|
||||
bool OrderUnmodelled(CommandList list, int n = 1);
|
||||
|
||||
// --- the AI's fleet order ------------------------------------------------------------------
|
||||
// The bridge the strategic AI issues every fleet order through. It resolves a route and then
|
||||
// calls the fleet-task method TWICE, with mode 0 and then mode 1. The interface's own fleet
|
||||
// order calls it once, with mode 0. So an AI fleet order is three counter bumps -- one for the
|
||||
// route in list 8 and two for the pair in list 14 -- where the interface's is two.
|
||||
bool IssueAiFleetOrder(int fleetId, std::vector<int> route);
|
||||
|
||||
// --- ending the turn -----------------------------------------------------------------------
|
||||
// Copies live player state into the block the way the original's send-buffer build does: it
|
||||
// sets the research-rate gate UNCONDITIONALLY, whatever the player did, and flushes every
|
||||
// queued route into list 8. Then it latches the turn closed. Calling it twice does nothing the
|
||||
// second time.
|
||||
void EndTurn(float playerResearchRate);
|
||||
bool TurnEnded() const { return turnEnded_; }
|
||||
|
||||
const TurnCommandBlock& block() const { return block_; }
|
||||
// Routes queued but not yet flushed.
|
||||
std::size_t pendingRouteCount() const { return pendingRoutes_.size(); }
|
||||
|
||||
private:
|
||||
TurnCommandBlock block_;
|
||||
std::vector<FleetMoveOrder> pendingRoutes_;
|
||||
bool turnEnded_ = false;
|
||||
int taskPass_ = kNoTaskPass;
|
||||
};
|
||||
|
||||
} // namespace sots::ai
|
||||
|
|
@ -12,3 +12,19 @@ target_link_libraries(game_ai_test_turn_order PRIVATE sots_game_ai)
|
|||
target_include_directories(game_ai_test_turn_order PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_compile_options(game_ai_test_turn_order PRIVATE -Wall -Wextra -pedantic)
|
||||
add_test(NAME game_ai_turn_order COMMAND game_ai_test_turn_order)
|
||||
|
||||
# The command block, the order API and the modification counter's arithmetic: what an order costs,
|
||||
# which lists cost nothing, and the two reconstructions of the reference turn.
|
||||
add_executable(game_ai_test_orders test_orders.cpp)
|
||||
target_link_libraries(game_ai_test_orders PRIVATE sots_game_ai)
|
||||
target_include_directories(game_ai_test_orders PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_compile_options(game_ai_test_orders PRIVATE -Wall -Wextra -pedantic)
|
||||
add_test(NAME game_ai_orders COMMAND game_ai_test_orders)
|
||||
|
||||
# The turn's phase list and the two-pass task walk: the dead tail after the submit, the one
|
||||
# species-restricted phase, and the first pass writing nothing.
|
||||
add_executable(game_ai_test_agent test_agent.cpp)
|
||||
target_link_libraries(game_ai_test_agent PRIVATE sots_game_ai)
|
||||
target_include_directories(game_ai_test_agent PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_compile_options(game_ai_test_agent PRIVATE -Wall -Wextra -pedantic)
|
||||
add_test(NAME game_ai_agent COMMAND game_ai_test_agent)
|
||||
|
|
|
|||
166
tests/game_ai/test_agent.cpp
Normal file
166
tests/game_ai/test_agent.cpp
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// 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;
|
||||
}
|
||||
313
tests/game_ai/test_orders.cpp
Normal file
313
tests/game_ai/test_orders.cpp
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
// 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue