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; +}