merge lane AI4: AI order emission scaffold; ModCount decomposed exactly, all handler EIPs named
This commit is contained in:
commit
e0f80a1bf6
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