merge lane AI2: game/ai/tasks - task type enum, priority table, species creation order; selection loop is a stable sort not a search

This commit is contained in:
alex 2026-09-08 14:45:32 -04:00
commit 9ddd6a2f7e
6 changed files with 640 additions and 1 deletions

View file

@ -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()

View file

@ -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()

169
src/game/ai/tasks.cpp Normal file
View file

@ -0,0 +1,169 @@
#include "game/ai/tasks.h"
#include <algorithm>
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<int>(t);
return i >= 0 && i < kTaskTypeCount;
}
// The shared tail every arm ends with, in call order.
void AppendCommonTail(std::vector<TaskType>& 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<TaskType>& 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<TaskType>& 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<int>(t)].name : ""; }
int TablePriority(TaskType t) { return InRange(t) ? kTable[static_cast<int>(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<RankedTask>& 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<TaskType> CreationOrder(sim::Species species, bool policyNonZero) {
std::vector<TaskType> 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

135
src/game/ai/tasks.h Normal file
View file

@ -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 <cstddef>
#include <vector>
#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<RankedTask>& 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<TaskType> 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

View file

@ -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)

View file

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