// 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 #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, const void* h = nullptr) { RankedTask r; r.type = t; r.handle = h; return r; } bool Contains(const std::vector& 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(e.t))); check(std::string(TaskTypeName(e.t)) == e.name, "name of id " + std::to_string(static_cast(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(0x21)) == 0, "id 0x21 is out of range"); check(TablePriority(static_cast(-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 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 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 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 mixed = {T(TaskType::DeployGateAt), uncommitted}; Rank(mixed, hot); check(mixed[0].type == TaskType::Invade, "a hot uncommitted Invade outranks DeployGateAt"); std::vector 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 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 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 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 v; for (TaskType t : CreationOrder(Species::Zuul, true)) v.push_back(T(t)); const std::vector 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; }