First module of game/ai, and the first piece of Rung B that is not scaffolding. It is the two
halves of the AI's task selection that are pure: the 33-value task type enumeration and the
ranking that decides which goal the AI acts on first.
* the priority table, verbatim -- higher runs first, and it is the entire default policy;
* the five overrides, kept out of the table on purpose. The two artifact tasks ignore their
table entries (1 and 2) and return 1260/1261; a port that only copied the table would rank
them last instead of fourth and fifth. The two tuned invade priorities are INPUTS
(TaskPriorityPolicy), not constants, because their loader is not yet identified;
* Rank() as a stable descending sort. The original sorts a std::list, so stability is the
behaviour, not a choice -- ties keep creation order;
* CreationOrder(species, policyNonZero), because that is what breaks the ties. Four arms: the
NPC species builds nothing, Hiver is the only arm with the gate families, Zuul the only one
with NodeBore, everyone else shares a fourth. Both defensive families are gated on the
player's policy value and DefendGateIncoming is Hiver-only on top of that.
193 checks in tests/game_ai, every expected value read off the original's tables rather than
produced by running this code. ctest 46/46 -> 47/47; clean-room check OK.
Derivation: sots-re findings/subsystems/ai-task-system.md (lane AI2), sections 1-3.
318 lines
15 KiB
C++
318 lines
15 KiB
C++
// Task vocabulary and ordering-policy cases.
|
|
//
|
|
// Every expected value here was read off the original's own tables, not produced by running
|
|
// this code. The cases worth keeping are:
|
|
// * the full priority table as a golden list, because it IS the ordering policy;
|
|
// * the two artifact tasks, whose real priority is nothing like their table entry -- a
|
|
// port that only copied the table would rank them last instead of fourth and fifth;
|
|
// * the tie order, because the original sorts a std::list (a stable merge sort) and the
|
|
// per-species creation order is therefore load-bearing;
|
|
// * the NPC species building nothing, which is a whole arm of the original's switch;
|
|
// * DefendGateIncoming being Hiver-only even when the policy gate is open.
|
|
#include "game/ai/tasks.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, const void* h = nullptr) {
|
|
RankedTask r;
|
|
r.type = t;
|
|
r.handle = h;
|
|
return r;
|
|
}
|
|
|
|
bool Contains(const std::vector<TaskType>& v, TaskType t) {
|
|
for (TaskType x : v)
|
|
if (x == t) return true;
|
|
return false;
|
|
}
|
|
|
|
// ---- the priority table, verbatim ----------------------------------------------------------
|
|
void TestTable() {
|
|
struct {
|
|
TaskType t;
|
|
int prio;
|
|
const char* name;
|
|
} expect[] = {
|
|
{TaskType::Steamroll, 1250, "AITSteamroll"},
|
|
{TaskType::Explore, 600, "AITExplore"},
|
|
{TaskType::ExploreInForce, 550, "AITExploreInForce"},
|
|
{TaskType::EscortGate, 700, "AITEscortGate"},
|
|
{TaskType::EscortGateInvade, 400, "AITEscortGateInvade"},
|
|
{TaskType::EscortGateInvadeGoal, 950, "AITEscortGateInvadeGoal"},
|
|
{TaskType::DeployGateAt, 1400, "AITDeployGateAt"},
|
|
{TaskType::Colonize, 900, "AITColonize"},
|
|
{TaskType::ColonizeGoal, 970, "AITColonizeGoal"},
|
|
{TaskType::ColonizeAt, 1300, "AITColonizeAt"},
|
|
{TaskType::Invade, 500, "AITInvade"},
|
|
{TaskType::InvadeGate, 1000, "AITInvadeGate"},
|
|
{TaskType::InvadeGoal, 930, "AITInvadeGoal"},
|
|
{TaskType::Retired0d, 200, ""},
|
|
{TaskType::DefendColonyIncoming, 1100, "AITDefendColonyIncoming"},
|
|
{TaskType::Retired0f, 300, ""},
|
|
{TaskType::DefendGateIncoming, 1200, "AITDefendGateIncoming"},
|
|
{TaskType::KillEasterEgg, 800, "AITKillEasterEgg"},
|
|
{TaskType::InterceptEnemy, 850, "AITInterceptEnemy"},
|
|
{TaskType::Mining, 350, "AITMining"},
|
|
{TaskType::MiningReturn, 375, "AITMiningReturn"},
|
|
{TaskType::AttackBlockade, 100, "AITAttackBlockade"},
|
|
{TaskType::AdvanceIdleShips, 0, "AITAdvanceIdleShips"},
|
|
{TaskType::StockFreighters, 50, "AITStockFreighters"},
|
|
{TaskType::RespondAttackSystem, 980, "AITRespondAttackSystem"},
|
|
{TaskType::RespondDefendSystem, 990, "AITRespondDefendSystem"},
|
|
{TaskType::NodeBore, 1275, "AITNodeBore"},
|
|
{TaskType::BuildStations, 910, "AITBuildStations"},
|
|
{TaskType::BuildPoliceShips, 75, "AITBuildPoliceShips"},
|
|
{TaskType::BuildDeepScanShips, 60, "AITBuildDeepScanShips"},
|
|
{TaskType::Raid, 399, "AITRaid"},
|
|
{TaskType::RetrieveArtifact, 1, "AITRetrieveArtifact"},
|
|
{TaskType::ReturnArtifact, 2, "AITReturnArtifact"},
|
|
};
|
|
for (const auto& e : expect) {
|
|
check(TablePriority(e.t) == e.prio,
|
|
"table priority of id " + std::to_string(static_cast<int>(e.t)));
|
|
check(std::string(TaskTypeName(e.t)) == e.name,
|
|
"name of id " + std::to_string(static_cast<int>(e.t)));
|
|
}
|
|
check(sizeof(expect) / sizeof(expect[0]) == kTaskTypeCount, "table covers every id");
|
|
|
|
// Out of range yields 0, as the original's bounds check does.
|
|
check(TablePriority(static_cast<TaskType>(0x21)) == 0, "id 0x21 is out of range");
|
|
check(TablePriority(static_cast<TaskType>(-1)) == 0, "negative id is out of range");
|
|
|
|
check(IsRetiredTaskType(TaskType::Retired0d), "0x0d is retired");
|
|
check(IsRetiredTaskType(TaskType::Retired0f), "0x0f is retired");
|
|
check(!IsRetiredTaskType(TaskType::Raid), "Raid is not retired");
|
|
}
|
|
|
|
// ---- the five overrides --------------------------------------------------------------------
|
|
void TestOverrides() {
|
|
TaskPriorityPolicy pol;
|
|
pol.uncommittedInvade = 4242;
|
|
pol.uncommittedEscortGateInvade = 777;
|
|
|
|
// The artifact tasks ignore their table entries entirely.
|
|
check(PriorityOf(T(TaskType::RetrieveArtifact), pol) == 1260, "RetrieveArtifact overrides to 1260");
|
|
check(PriorityOf(T(TaskType::ReturnArtifact), pol) == 1261, "ReturnArtifact overrides to 1261");
|
|
check(TablePriority(TaskType::RetrieveArtifact) == 1, "and its dead table entry is still 1");
|
|
|
|
// A port that only copied the table would put them last; they are actually fourth and fifth.
|
|
check(PriorityOf(T(TaskType::ReturnArtifact), pol) < TablePriority(TaskType::ColonizeAt),
|
|
"artifacts rank below ColonizeAt");
|
|
check(PriorityOf(T(TaskType::RetrieveArtifact), pol) > TablePriority(TaskType::Steamroll),
|
|
"artifacts rank above Steamroll");
|
|
|
|
// Invade / EscortGateInvade take the tuned value only while uncommitted.
|
|
RankedTask uncommitted = T(TaskType::Invade);
|
|
uncommitted.committed = false;
|
|
check(PriorityOf(uncommitted, pol) == 4242, "uncommitted Invade takes the tunable");
|
|
check(PriorityOf(T(TaskType::Invade), pol) == 500, "committed Invade takes the table");
|
|
|
|
RankedTask ug = T(TaskType::EscortGateInvade);
|
|
ug.committed = false;
|
|
check(PriorityOf(ug, pol) == 777, "uncommitted EscortGateInvade takes the tunable");
|
|
check(PriorityOf(T(TaskType::EscortGateInvade), pol) == 400, "committed takes the table");
|
|
|
|
// The committed flag is meaningless for every other type.
|
|
RankedTask other = T(TaskType::Raid);
|
|
other.committed = false;
|
|
check(PriorityOf(other, pol) == 399, "the committed flag does not affect Raid");
|
|
|
|
// AttackBlockade is the one whose priority the caller supplies.
|
|
RankedTask ab = T(TaskType::AttackBlockade);
|
|
check(PriorityOf(ab, pol) == 100, "AttackBlockade defaults to the table");
|
|
ab.overridePriority = true;
|
|
ab.priority = 1234;
|
|
check(PriorityOf(ab, pol) == 1234, "AttackBlockade takes a supplied priority");
|
|
|
|
// ...and only AttackBlockade does.
|
|
RankedTask notAb = T(TaskType::Mining);
|
|
notAb.overridePriority = true;
|
|
notAb.priority = 1234;
|
|
check(PriorityOf(notAb, pol) == 350, "a supplied priority is ignored for other types");
|
|
}
|
|
|
|
// ---- ranking --------------------------------------------------------------------------------
|
|
void TestRank() {
|
|
TaskPriorityPolicy pol;
|
|
|
|
std::vector<RankedTask> v = {
|
|
T(TaskType::AdvanceIdleShips), // 0
|
|
T(TaskType::DeployGateAt), // 1400
|
|
T(TaskType::Mining), // 350
|
|
T(TaskType::RetrieveArtifact), // 1260 by override
|
|
T(TaskType::Raid), // 399
|
|
T(TaskType::ColonizeAt), // 1300
|
|
};
|
|
Rank(v, pol);
|
|
check(v[0].type == TaskType::DeployGateAt, "rank[0] DeployGateAt 1400");
|
|
check(v[1].type == TaskType::ColonizeAt, "rank[1] ColonizeAt 1300");
|
|
check(v[2].type == TaskType::RetrieveArtifact, "rank[2] RetrieveArtifact 1260 (override)");
|
|
check(v[3].type == TaskType::Raid, "rank[3] Raid 399");
|
|
check(v[4].type == TaskType::Mining, "rank[4] Mining 350");
|
|
check(v[5].type == TaskType::AdvanceIdleShips, "rank[5] AdvanceIdleShips 0");
|
|
|
|
// Stability: three tasks of one type keep their input order. The original sorts a
|
|
// std::list, so this is not an implementation choice -- it is the behaviour.
|
|
const int a = 1, b = 2, c = 3;
|
|
std::vector<RankedTask> ties = {
|
|
T(TaskType::Raid, &a),
|
|
T(TaskType::DeployGateAt),
|
|
T(TaskType::Raid, &b),
|
|
T(TaskType::Raid, &c),
|
|
};
|
|
Rank(ties, pol);
|
|
check(ties[0].type == TaskType::DeployGateAt, "the higher priority still leads");
|
|
check(ties[1].handle == &a && ties[2].handle == &b && ties[3].handle == &c,
|
|
"ties keep creation order");
|
|
|
|
// Ranking is idempotent -- a second pass must not reshuffle the ties.
|
|
std::vector<RankedTask> again = ties;
|
|
Rank(again, pol);
|
|
for (std::size_t i = 0; i < ties.size(); ++i)
|
|
check(again[i].handle == ties[i].handle, "re-ranking is stable at index " + std::to_string(i));
|
|
|
|
// The tunables participate in the ordering, which is why they are inputs and not constants.
|
|
TaskPriorityPolicy hot;
|
|
hot.uncommittedInvade = 9999;
|
|
RankedTask uncommitted = T(TaskType::Invade);
|
|
uncommitted.committed = false;
|
|
std::vector<RankedTask> mixed = {T(TaskType::DeployGateAt), uncommitted};
|
|
Rank(mixed, hot);
|
|
check(mixed[0].type == TaskType::Invade, "a hot uncommitted Invade outranks DeployGateAt");
|
|
|
|
std::vector<RankedTask> empty;
|
|
Rank(empty, pol);
|
|
check(empty.empty(), "ranking an empty list is a no-op");
|
|
}
|
|
|
|
// ---- per-species creation order --------------------------------------------------------------
|
|
void TestCreationOrder() {
|
|
check(BuildsNoTasks(Species::NPC), "the NPC species builds no tasks");
|
|
check(CreationOrder(Species::NPC, true).empty(), "...and its creation order is empty");
|
|
check(CreationOrder(Species::NPC, false).empty(), "...with the policy gate shut too");
|
|
|
|
for (Species s : {Species::Human, Species::Hiver, Species::Tarkas, Species::Liir,
|
|
Species::Zuul, Species::Morrigi}) {
|
|
check(!CreationOrder(s, true).empty(), "a playable species builds tasks");
|
|
// Steamroll is created first in every arm.
|
|
check(CreationOrder(s, true).front() == TaskType::Steamroll, "Steamroll leads every arm");
|
|
}
|
|
|
|
// Only the Hiver arm builds the gate families.
|
|
for (TaskType gate : {TaskType::DeployGateAt, TaskType::EscortGate, TaskType::EscortGateInvade,
|
|
TaskType::InvadeGate}) {
|
|
check(Contains(CreationOrder(Species::Hiver, true), gate), "Hiver builds a gate family");
|
|
check(!Contains(CreationOrder(Species::Human, true), gate), "Human does not");
|
|
check(!Contains(CreationOrder(Species::Zuul, true), gate), "Zuul does not");
|
|
}
|
|
|
|
// Only the Zuul arm builds NodeBore, and it is second, right after Steamroll.
|
|
check(CreationOrder(Species::Zuul, true)[1] == TaskType::NodeBore, "Zuul builds NodeBore second");
|
|
check(!Contains(CreationOrder(Species::Human, true), TaskType::NodeBore), "Human does not");
|
|
check(!Contains(CreationOrder(Species::Hiver, true), TaskType::NodeBore), "Hiver does not");
|
|
|
|
// The Zuul arm is also the one that skips BuildPoliceShips and plain Explore.
|
|
check(!Contains(CreationOrder(Species::Zuul, true), TaskType::BuildPoliceShips),
|
|
"Zuul builds no police ships");
|
|
check(!Contains(CreationOrder(Species::Zuul, true), TaskType::Explore),
|
|
"Zuul builds ExploreInForce but not Explore");
|
|
check(Contains(CreationOrder(Species::Zuul, true), TaskType::ExploreInForce), "...it does build that");
|
|
|
|
// The Hiver arm skips KillEasterEgg and the plain explore pair.
|
|
check(!Contains(CreationOrder(Species::Hiver, true), TaskType::KillEasterEgg),
|
|
"Hiver skips KillEasterEgg");
|
|
check(!Contains(CreationOrder(Species::Hiver, true), TaskType::Explore), "Hiver skips Explore");
|
|
|
|
// The four species that share the default arm produce identical orders.
|
|
const std::vector<TaskType> human = CreationOrder(Species::Human, true);
|
|
for (Species s : {Species::Tarkas, Species::Liir, Species::Morrigi})
|
|
check(CreationOrder(s, true) == human, "the default arm is shared");
|
|
|
|
// The policy gate suppresses both defensive families, in every species.
|
|
for (Species s : {Species::Human, Species::Hiver, Species::Zuul}) {
|
|
const std::vector<TaskType> off = CreationOrder(s, false);
|
|
check(!Contains(off, TaskType::DefendColonyIncoming), "policy 0 suppresses DefendColony");
|
|
check(!Contains(off, TaskType::DefendGateIncoming), "policy 0 suppresses DefendGate");
|
|
check(Contains(CreationOrder(s, true), TaskType::DefendColonyIncoming),
|
|
"policy non-zero restores DefendColony");
|
|
}
|
|
|
|
// DefendGateIncoming is Hiver-only even with the gate open.
|
|
check(Contains(CreationOrder(Species::Hiver, true), TaskType::DefendGateIncoming),
|
|
"Hiver gets DefendGateIncoming");
|
|
for (Species s : {Species::Human, Species::Tarkas, Species::Liir, Species::Zuul, Species::Morrigi})
|
|
check(!Contains(CreationOrder(s, true), TaskType::DefendGateIncoming),
|
|
"no one else gets DefendGateIncoming");
|
|
|
|
// Neither retired id is ever created.
|
|
for (Species s : {Species::Human, Species::Hiver, Species::Zuul}) {
|
|
check(!Contains(CreationOrder(s, true), TaskType::Retired0d), "0x0d is never created");
|
|
check(!Contains(CreationOrder(s, true), TaskType::Retired0f), "0x0f is never created");
|
|
}
|
|
|
|
// The goal group is created as a block, in order, in every arm that has it.
|
|
for (Species s : {Species::Human, Species::Hiver, Species::Zuul}) {
|
|
const std::vector<TaskType> v = CreationOrder(s, true);
|
|
std::size_t i = 0;
|
|
while (i < v.size() && v[i] != TaskType::ColonizeGoal) ++i;
|
|
check(i + 3 < v.size(), "the goal group is present");
|
|
if (i + 3 < v.size()) {
|
|
check(v[i + 1] == TaskType::EscortGateInvadeGoal, "goal group order 1");
|
|
check(v[i + 2] == TaskType::Invade, "goal group order 2");
|
|
check(v[i + 3] == TaskType::InvadeGoal, "goal group order 3");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- the two together ------------------------------------------------------------------------
|
|
void TestCreationOrderBreaksTies() {
|
|
// A Zuul AI holding one of everything it can create: the ranking is fully determined by the
|
|
// table, and where the table ties, by the creation order. Nothing else is consulted.
|
|
TaskPriorityPolicy pol;
|
|
std::vector<RankedTask> v;
|
|
for (TaskType t : CreationOrder(Species::Zuul, true)) v.push_back(T(t));
|
|
const std::vector<RankedTask> before = v;
|
|
Rank(v, pol);
|
|
|
|
// ColonizeAt (1300) leads, not NodeBore (1275) -- the Zuul arm creates NodeBore second but
|
|
// it does not rank first, and no gate task (DeployGateAt 1400) exists in a Zuul list at all.
|
|
check(v.front().type == TaskType::ColonizeAt, "ColonizeAt (1300) leads a Zuul list");
|
|
check(v[1].type == TaskType::NodeBore, "NodeBore (1275) is second");
|
|
check(v.back().type == TaskType::AdvanceIdleShips, "AdvanceIdleShips (0) trails it");
|
|
|
|
for (std::size_t i = 1; i < v.size(); ++i)
|
|
check(PriorityOf(v[i - 1], pol) >= PriorityOf(v[i], pol), "the result is non-increasing");
|
|
|
|
// Same multiset in, same multiset out.
|
|
check(v.size() == before.size(), "ranking preserves the count");
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
TestTable();
|
|
TestOverrides();
|
|
TestRank();
|
|
TestCreationOrder();
|
|
TestCreationOrderBreaksTies();
|
|
std::printf("game/ai tasks: %d checks, %d failures\n", g_checks, g_fails);
|
|
return g_fails == 0 ? 0 : 1;
|
|
}
|