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:
commit
9ddd6a2f7e
6 changed files with 640 additions and 1 deletions
|
|
@ -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/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/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/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)
|
add_subdirectory(src/app) # the standalone turn driver (lib sots_app, sots_turn)
|
||||||
|
|
||||||
# ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ----
|
# ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ----
|
||||||
|
|
@ -121,7 +122,7 @@ else()
|
||||||
add_executable(addr_smoke tests/addr_smoke.cpp)
|
add_executable(addr_smoke tests/addr_smoke.cpp)
|
||||||
target_link_libraries(addr_smoke PRIVATE sots_addresses)
|
target_link_libraries(addr_smoke PRIVATE sots_addresses)
|
||||||
add_test(NAME addr_smoke COMMAND addr_smoke)
|
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)
|
if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt)
|
||||||
add_subdirectory(tests/${_t})
|
add_subdirectory(tests/${_t})
|
||||||
endif()
|
endif()
|
||||||
|
|
|
||||||
10
src/game/ai/CMakeLists.txt
Normal file
10
src/game/ai/CMakeLists.txt
Normal 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
169
src/game/ai/tasks.cpp
Normal 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
135
src/game/ai/tasks.h
Normal 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
|
||||||
6
tests/game_ai/CMakeLists.txt
Normal file
6
tests/game_ai/CMakeLists.txt
Normal 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)
|
||||||
318
tests/game_ai/test_tasks.cpp
Normal file
318
tests/game_ai/test_tasks.cpp
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue