diff --git a/src/game/ai/CMakeLists.txt b/src/game/ai/CMakeLists.txt index dc47404..43c81ed 100644 --- a/src/game/ai/CMakeLists.txt +++ b/src/game/ai/CMakeLists.txt @@ -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) diff --git a/src/game/ai/agent.cpp b/src/game/ai/agent.cpp new file mode 100644 index 0000000..9e9b875 --- /dev/null +++ b/src/game/ai/agent.cpp @@ -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 BuildPhases() { + std::vector p(kProcessTurnPhaseCount); + for (int i = 0; i < kProcessTurnPhaseCount; ++i) { + p[static_cast(i)].index = i; + p[static_cast(i)].dead = PhaseRunsAfterSubmit(i); + } + auto at = [&p](int i) -> ProcessTurnPhase& { return p[static_cast(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& ProcessTurnPhases() { + static const std::vector 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& TaskWalkPasses() { + static const std::vector kPasses = {static_cast(TaskPass::Reserve), + static_cast(TaskPass::Fill)}; + return kPasses; +} + +void RunTaskList(std::vector& 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 diff --git a/src/game/ai/agent.h b/src/game/ai/agent.h new file mode 100644 index 0000000..3050cbe --- /dev/null +++ b/src/game/ai/agent.h @@ -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 +#include + +#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& 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; + +// 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& 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& TaskWalkPasses(); + +} // namespace sots::ai diff --git a/src/game/ai/orders.cpp b/src/game/ai/orders.cpp new file mode 100644 index 0000000..23e8719 --- /dev/null +++ b/src/game/ai/orders.cpp @@ -0,0 +1,221 @@ +#include "game/ai/orders.h" + +#include + +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(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(build.size()); + case CommandList::SystemRates: + return static_cast(systemRates.size()); + case CommandList::Colonize: + return static_cast(colonize.size()); + case CommandList::FleetMove: + return static_cast(fleetMoves.size()); + case CommandList::FleetTask: + return static_cast(fleetTasks.size()); + default: + break; + } + const int i = IndexOf(list); + if (i < 0 || i >= kCommandListCount) return 0; + return unmodelled[static_cast(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(i)] += n; +} + +ModCountCost BlockModCountCost(const TurnCommandBlock& block) { + ModCountCost cost; + for (int g = 0; g < kPrologueGateCount; ++g) { + const auto gate = static_cast(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(n); + if (!ListAdvancesModCount(list)) continue; + cost.bumps += block.ElementCount(list); + } + return cost; +} + +ModCountCost TurnModCountDelta(const std::vector& 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 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 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 diff --git a/src/game/ai/orders.h b/src/game/ai/orders.h new file mode 100644 index 0000000..215c973 --- /dev/null +++ b/src/game/ai/orders.h @@ -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 +#include +#include +#include + +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(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 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 build; // list 3 + std::vector systemRates; // list 5 + std::vector colonize; // list 7 + std::vector fleetMoves; // list 8 + std::vector 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 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& 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 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 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 pendingRoutes_; + bool turnEnded_ = false; + int taskPass_ = kNoTaskPass; +}; + +} // namespace sots::ai diff --git a/tests/game_ai/CMakeLists.txt b/tests/game_ai/CMakeLists.txt index 97a142d..4d02a51 100644 --- a/tests/game_ai/CMakeLists.txt +++ b/tests/game_ai/CMakeLists.txt @@ -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) diff --git a/tests/game_ai/test_agent.cpp b/tests/game_ai/test_agent.cpp new file mode 100644 index 0000000..fe6346a --- /dev/null +++ b/tests/game_ai/test_agent.cpp @@ -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 +#include +#include + +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(kProcessTurnPhaseCount), "thirty-four phases"); + for (int i = 0; i < kProcessTurnPhaseCount; ++i) { + check(phases[static_cast(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(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 tasks = {T(TaskType::Colonize), T(TaskType::Explore), + T(TaskType::AdvanceIdleShips)}; + OrderClient client(32); + TaskPriorityPolicy policy; + + std::vector passesSeen; + std::vector> 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(pass)].empty()) passesSeen.push_back(pass); + orderPerPass[static_cast(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 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 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; +} diff --git a/tests/game_ai/test_orders.cpp b/tests/game_ai/test_orders.cpp new file mode 100644 index 0000000..0d0f578 --- /dev/null +++ b/tests/game_ai/test_orders.cpp @@ -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 +#include +#include + +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(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(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 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 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 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; +}