From 29ea2b65fa6cc1c7d41cc29f4ae65f2d7dafb002 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 14:43:51 -0400 Subject: [PATCH] game/ai: the strategic AI's task vocabulary and its ordering policy 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. --- CMakeLists.txt | 3 +- src/game/ai/CMakeLists.txt | 10 ++ src/game/ai/tasks.cpp | 169 +++++++++++++++++++ src/game/ai/tasks.h | 135 +++++++++++++++ tests/game_ai/CMakeLists.txt | 6 + tests/game_ai/test_tasks.cpp | 318 +++++++++++++++++++++++++++++++++++ 6 files changed, 640 insertions(+), 1 deletion(-) create mode 100644 src/game/ai/CMakeLists.txt create mode 100644 src/game/ai/tasks.cpp create mode 100644 src/game/ai/tasks.h create mode 100644 tests/game_ai/CMakeLists.txt create mode 100644 tests/game_ai/test_tasks.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a0ae66c..9f7800b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,7 @@ add_subdirectory(src/game/design) # ship-design rules + derived stats (lib gam add_subdirectory(src/game/events) # player event log + research events (lib sots_game_events) add_subdirectory(src/game/combat) # post-battle strategic consequences (lib sots_game_combat) add_subdirectory(src/game/nav) # fleet path planning, pure (lib sots_game_nav) +add_subdirectory(src/game/ai) # strategic AI task vocabulary + ranking (lib sots_game_ai) add_subdirectory(src/app) # the standalone turn driver (lib sots_app, sots_turn) # ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ---- @@ -121,7 +122,7 @@ else() add_executable(addr_smoke tests/addr_smoke.cpp) target_link_libraries(addr_smoke PRIVATE sots_addresses) add_test(NAME addr_smoke COMMAND addr_smoke) - foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects game_events game_combat game_nav shim_budget shim_techfx shim_colony shim_movement shim_events shim_player_turn shim_rng_ledger app) + foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects game_events game_combat game_nav game_ai shim_budget shim_techfx shim_colony shim_movement shim_events shim_player_turn shim_rng_ledger app) if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt) add_subdirectory(tests/${_t}) endif() diff --git a/src/game/ai/CMakeLists.txt b/src/game/ai/CMakeLists.txt new file mode 100644 index 0000000..e427136 --- /dev/null +++ b/src/game/ai/CMakeLists.txt @@ -0,0 +1,10 @@ +# Strategic AI: the task vocabulary and the ordering policy that decides which goal the AI acts +# on first. Pure -- no state, no I/O, no random draws. Deliberately separate from game/sim: the +# sim answers "what happens", this answers "what does an AI player decide to try". +add_library(sots_game_ai STATIC + tasks.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) + target_compile_options(sots_game_ai PRIVATE -Wall -Wextra) +endif() diff --git a/src/game/ai/tasks.cpp b/src/game/ai/tasks.cpp new file mode 100644 index 0000000..5ae697d --- /dev/null +++ b/src/game/ai/tasks.cpp @@ -0,0 +1,169 @@ +#include "game/ai/tasks.h" + +#include + +namespace sots::ai { +namespace { + +struct Row { + const char* name; + int priority; +}; + +// Indexed by task type id. Both retired ids keep their priority and carry no name. +constexpr Row kTable[kTaskTypeCount] = { + /* 0x00 */ {"AITSteamroll", 1250}, + /* 0x01 */ {"AITExplore", 600}, + /* 0x02 */ {"AITExploreInForce", 550}, + /* 0x03 */ {"AITEscortGate", 700}, + /* 0x04 */ {"AITEscortGateInvade", 400}, + /* 0x05 */ {"AITEscortGateInvadeGoal", 950}, + /* 0x06 */ {"AITDeployGateAt", 1400}, + /* 0x07 */ {"AITColonize", 900}, + /* 0x08 */ {"AITColonizeGoal", 970}, + /* 0x09 */ {"AITColonizeAt", 1300}, + /* 0x0a */ {"AITInvade", 500}, + /* 0x0b */ {"AITInvadeGate", 1000}, + /* 0x0c */ {"AITInvadeGoal", 930}, + /* 0x0d */ {"", 200}, + /* 0x0e */ {"AITDefendColonyIncoming", 1100}, + /* 0x0f */ {"", 300}, + /* 0x10 */ {"AITDefendGateIncoming", 1200}, + /* 0x11 */ {"AITKillEasterEgg", 800}, + /* 0x12 */ {"AITInterceptEnemy", 850}, + /* 0x13 */ {"AITMining", 350}, + /* 0x14 */ {"AITMiningReturn", 375}, + /* 0x15 */ {"AITAttackBlockade", 100}, + /* 0x16 */ {"AITAdvanceIdleShips", 0}, + /* 0x17 */ {"AITStockFreighters", 50}, + /* 0x18 */ {"AITRespondAttackSystem", 980}, + /* 0x19 */ {"AITRespondDefendSystem", 990}, + /* 0x1a */ {"AITNodeBore", 1275}, + /* 0x1b */ {"AITBuildStations", 910}, + /* 0x1c */ {"AITBuildPoliceShips", 75}, + /* 0x1d */ {"AITBuildDeepScanShips", 60}, + /* 0x1e */ {"AITRaid", 399}, + /* 0x1f */ {"AITRetrieveArtifact", 1}, + /* 0x20 */ {"AITReturnArtifact", 2}, +}; + +// The two artifact tasks ignore their table entries entirely and return these instead. Keeping +// them here rather than in kTable is deliberate: the table values are real, reachable through +// nothing, and a future reader who "fixes" the table would be wrong. +constexpr int kRetrieveArtifactPriority = 1260; +constexpr int kReturnArtifactPriority = 1261; + +constexpr bool InRange(TaskType t) { + const int i = static_cast(t); + return i >= 0 && i < kTaskTypeCount; +} + +// The shared tail every arm ends with, in call order. +void AppendCommonTail(std::vector& out, bool policeShips, bool deepScanShips) { + out.push_back(TaskType::InterceptEnemy); + out.push_back(TaskType::Mining); + out.push_back(TaskType::MiningReturn); + out.push_back(TaskType::AdvanceIdleShips); + out.push_back(TaskType::Raid); + out.push_back(TaskType::StockFreighters); + out.push_back(TaskType::AttackBlockade); + out.push_back(TaskType::BuildStations); + if (policeShips) out.push_back(TaskType::BuildPoliceShips); + if (deepScanShips) out.push_back(TaskType::BuildDeepScanShips); +} + +// The goal group: one creator that builds four families. +void AppendGoalGroup(std::vector& out) { + out.push_back(TaskType::ColonizeGoal); + out.push_back(TaskType::EscortGateInvadeGoal); + out.push_back(TaskType::Invade); + out.push_back(TaskType::InvadeGoal); +} + +// The two defensive families, behind the policy gate. DefendGateIncoming is Hiver-only. +void AppendDefensive(std::vector& out, bool policyNonZero, sim::Species species) { + if (!policyNonZero) return; + if (species == sim::Species::Hiver) out.push_back(TaskType::DefendGateIncoming); + out.push_back(TaskType::DefendColonyIncoming); +} + +} // namespace + +const char* TaskTypeName(TaskType t) { return InRange(t) ? kTable[static_cast(t)].name : ""; } + +int TablePriority(TaskType t) { return InRange(t) ? kTable[static_cast(t)].priority : 0; } + +int PriorityOf(const RankedTask& t, const TaskPriorityPolicy& policy) { + switch (t.type) { + case TaskType::RetrieveArtifact: + return kRetrieveArtifactPriority; + case TaskType::ReturnArtifact: + return kReturnArtifactPriority; + case TaskType::Invade: + return t.committed ? TablePriority(t.type) : policy.uncommittedInvade; + case TaskType::EscortGateInvade: + return t.committed ? TablePriority(t.type) : policy.uncommittedEscortGateInvade; + case TaskType::AttackBlockade: + return t.overridePriority ? t.priority : TablePriority(t.type); + default: + return TablePriority(t.type); + } +} + +void Rank(std::vector& tasks, const TaskPriorityPolicy& policy) { + // std::stable_sort, not the introsort in game/config/msvc_sort.h: the original sorts a + // std::list, and list::sort is a merge sort -- stable by construction, in every library. + // The comparison is a strict greater-than on the priority, so equal keys never move. + std::stable_sort(tasks.begin(), tasks.end(), + [&policy](const RankedTask& a, const RankedTask& b) { + return PriorityOf(a, policy) > PriorityOf(b, policy); + }); +} + +std::vector CreationOrder(sim::Species species, bool policyNonZero) { + std::vector out; + if (BuildsNoTasks(species)) return out; + + if (species == sim::Species::Hiver) { + out.push_back(TaskType::Steamroll); + out.push_back(TaskType::Colonize); + out.push_back(TaskType::ColonizeAt); + AppendDefensive(out, policyNonZero, species); + out.push_back(TaskType::EscortGateInvade); + out.push_back(TaskType::InvadeGate); + out.push_back(TaskType::DeployGateAt); + out.push_back(TaskType::EscortGate); + AppendGoalGroup(out); + AppendCommonTail(out, /*policeShips=*/true, /*deepScanShips=*/true); + return out; + } + + if (species == sim::Species::Zuul) { + out.push_back(TaskType::Steamroll); + out.push_back(TaskType::NodeBore); + out.push_back(TaskType::Colonize); + out.push_back(TaskType::ColonizeAt); + AppendDefensive(out, policyNonZero, species); + out.push_back(TaskType::KillEasterEgg); + out.push_back(TaskType::Invade); + out.push_back(TaskType::ExploreInForce); + AppendGoalGroup(out); + AppendCommonTail(out, /*policeShips=*/false, /*deepScanShips=*/true); + return out; + } + + // Human, Tarkas, Liir, Morrigi. + out.push_back(TaskType::Steamroll); + out.push_back(TaskType::Colonize); + out.push_back(TaskType::ColonizeAt); + AppendDefensive(out, policyNonZero, species); + out.push_back(TaskType::KillEasterEgg); + out.push_back(TaskType::Invade); + out.push_back(TaskType::Explore); + out.push_back(TaskType::ExploreInForce); + AppendGoalGroup(out); + AppendCommonTail(out, /*policeShips=*/true, /*deepScanShips=*/true); + return out; +} + +} // namespace sots::ai diff --git a/src/game/ai/tasks.h b/src/game/ai/tasks.h new file mode 100644 index 0000000..f690f5d --- /dev/null +++ b/src/game/ai/tasks.h @@ -0,0 +1,135 @@ +// The strategic AI's task vocabulary and its ordering policy. +// +// The AI does not search and it does not score. Once a turn it rebuilds a list of candidate +// tasks -- which task families it builds at all depends on the player's species -- sorts that +// list by a per-task-type priority, and then walks it twice, calling each task's Execute with +// pass 0 and then pass 1. This header is the two halves of that which are pure data: the task +// type enumeration and the priority function, plus a stable ranking that reproduces the +// original's sort exactly. +// +// Three details are easy to get wrong and are the reason this is a module rather than a table: +// +// * The priority function is a lookup on the task's type id, but five task types override it. +// Two of them (the artifact tasks) override with a CONSTANT that is nothing like their table +// entry -- port only the table and they rank last instead of near the top. +// * The sort is a stable list sort, descending. Ties therefore keep the order the tasks were +// created in, which makes the per-species creation order part of the answer, not an +// implementation detail. CreationOrder() carries it. +// * The NPC species creates no strategic tasks at all. +// +// Pure: no state, no I/O, no random draws. +// CONFIDENCE: high on the enumeration, the priority table and the sort; the two tuned +// priorities (see TaskPriorityPolicy) are inputs this module does not own, and the creation +// order is the call order of the per-family creators, not a claim about what each creates. +#pragma once + +#include +#include + +#include "game/sim/species.h" + +namespace sots::ai { + +// The complete task type space. Values are the ids the tasks report for themselves; they index +// the priority table directly, so the two ids with no surviving task type are kept as holes +// rather than closed up. +enum class TaskType : int { + Steamroll = 0x00, + Explore = 0x01, + ExploreInForce = 0x02, + EscortGate = 0x03, + EscortGateInvade = 0x04, + EscortGateInvadeGoal = 0x05, + DeployGateAt = 0x06, + Colonize = 0x07, + ColonizeGoal = 0x08, + ColonizeAt = 0x09, + Invade = 0x0a, + InvadeGate = 0x0b, + InvadeGoal = 0x0c, + Retired0d = 0x0d, // no task type survives with this id; the priority entry does + DefendColonyIncoming = 0x0e, + Retired0f = 0x0f, // likewise + DefendGateIncoming = 0x10, + KillEasterEgg = 0x11, + InterceptEnemy = 0x12, + Mining = 0x13, + MiningReturn = 0x14, + AttackBlockade = 0x15, + AdvanceIdleShips = 0x16, + StockFreighters = 0x17, + RespondAttackSystem = 0x18, + RespondDefendSystem = 0x19, + NodeBore = 0x1a, + BuildStations = 0x1b, + BuildPoliceShips = 0x1c, + BuildDeepScanShips = 0x1d, + Raid = 0x1e, + RetrieveArtifact = 0x1f, + ReturnArtifact = 0x20, +}; + +constexpr int kTaskTypeCount = 0x21; + +// The name each task type reports for itself. Empty for the two retired ids. +const char* TaskTypeName(TaskType t); + +// True for the two ids that have a priority but no task type. +constexpr bool IsRetiredTaskType(TaskType t) { + return t == TaskType::Retired0d || t == TaskType::Retired0f; +} + +// The priority every task type gets from the shared table. This is the whole default ranking +// policy; higher runs first. Out-of-range ids yield 0, as the original's bounds check does. +// +// NOTE: for RetrieveArtifact and ReturnArtifact this is NOT the priority those tasks actually +// use -- they override it. Prefer PriorityOf(), which applies the overrides. +int TablePriority(TaskType t); + +// The four inputs the priority function needs that are not this module's to know. Two are +// tunables held outside the task code; the other two are per-instance state. +struct TaskPriorityPolicy { + // The priority an Invade / EscortGateInvade task takes while its "committed" flag is clear. + // Held as two separate tunables in the original rather than in the shared table. + int uncommittedInvade = 0; + int uncommittedEscortGateInvade = 0; +}; + +// One task, as far as ranking is concerned. +struct RankedTask { + TaskType type = TaskType::Steamroll; + // Set for Invade / EscortGateInvade once the task has committed. When clear, those two + // types take the tuned priority from TaskPriorityPolicy instead of the table's. + bool committed = true; + // AttackBlockade is the one task whose priority depends on what other tasks exist; the + // caller supplies the result. Ignored for every other type. + bool overridePriority = false; + int priority = 0; + // Opaque to this module. Carried through the ranking so callers can recover their own task. + const void* handle = nullptr; +}; + +// The priority a task actually ranks by: the table, with the five overrides applied. +int PriorityOf(const RankedTask& t, const TaskPriorityPolicy& policy); + +// Rank a candidate list the way the original does: a STABLE sort, descending by PriorityOf. +// Equal priorities keep their input order, which is why the input order matters -- see +// CreationOrder(). +void Rank(std::vector& tasks, const TaskPriorityPolicy& policy); + +// Which task families a species builds candidates for, in the order they are built. Ties in +// Rank() are broken by this order, so it is part of the ordering policy. +// +// Four distinct arms: the NPC species builds nothing; the Hiver arm is the only one that builds +// the gate families; the Zuul arm is the only one that builds NodeBore; everyone else shares a +// fourth. Two families -- DefendColonyIncoming and DefendGateIncoming -- are additionally gated +// on the player's policy value being non-zero, and DefendGateIncoming is Hiver-only even inside +// the Hiver arm's own gate. +// +// `policyNonZero` is the player's policy field; pass false to suppress the defensive families. +std::vector CreationOrder(sim::Species species, bool policyNonZero); + +// True when this species builds no strategic tasks at all. +constexpr bool BuildsNoTasks(sim::Species species) { return species == sim::Species::NPC; } + +} // namespace sots::ai diff --git a/tests/game_ai/CMakeLists.txt b/tests/game_ai/CMakeLists.txt new file mode 100644 index 0000000..414d71f --- /dev/null +++ b/tests/game_ai/CMakeLists.txt @@ -0,0 +1,6 @@ +# game/ai tests: the task vocabulary, the priority table read off the original, and the ranking. +add_executable(game_ai_test_tasks test_tasks.cpp) +target_link_libraries(game_ai_test_tasks PRIVATE sots_game_ai) +target_include_directories(game_ai_test_tasks PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_compile_options(game_ai_test_tasks PRIVATE -Wall -Wextra -pedantic) +add_test(NAME game_ai_tasks COMMAND game_ai_test_tasks) diff --git a/tests/game_ai/test_tasks.cpp b/tests/game_ai/test_tasks.cpp new file mode 100644 index 0000000..30aff6a --- /dev/null +++ b/tests/game_ai/test_tasks.cpp @@ -0,0 +1,318 @@ +// 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; +}