Compare commits

...

3 commits

Author SHA1 Message Date
alex
910b697392 sync generated header (1056 entries) after AI task merge 2026-09-08 14:48:57 -04:00
alex
9ddd6a2f7e merge lane AI2: game/ai/tasks - task type enum, priority table, species creation order; selection loop is a stable sort not a search 2026-09-08 14:45:32 -04:00
alex
29ea2b65fa game/ai: the strategic AI's task vocabulary and its ordering policy
First module of game/ai, and the first piece of Rung B that is not scaffolding. It is the two
halves of the AI's task selection that are pure: the 33-value task type enumeration and the
ranking that decides which goal the AI acts on first.

  * the priority table, verbatim -- higher runs first, and it is the entire default policy;
  * the five overrides, kept out of the table on purpose. The two artifact tasks ignore their
    table entries (1 and 2) and return 1260/1261; a port that only copied the table would rank
    them last instead of fourth and fifth. The two tuned invade priorities are INPUTS
    (TaskPriorityPolicy), not constants, because their loader is not yet identified;
  * Rank() as a stable descending sort. The original sorts a std::list, so stability is the
    behaviour, not a choice -- ties keep creation order;
  * CreationOrder(species, policyNonZero), because that is what breaks the ties. Four arms: the
    NPC species builds nothing, Hiver is the only arm with the gate families, Zuul the only one
    with NodeBore, everyone else shares a fourth. Both defensive families are gated on the
    player's policy value and DefendGateIncoming is Hiver-only on top of that.

193 checks in tests/game_ai, every expected value read off the original's tables rather than
produced by running this code. ctest 46/46 -> 47/47; clean-room check OK.

Derivation: sots-re findings/subsystems/ai-task-system.md (lane AI2), sections 1-3.
2026-09-08 14:43:51 -04:00
7 changed files with 693 additions and 4 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

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ bebdee1, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ 232397a, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
@ -1327,7 +1327,7 @@ constexpr uint32_t StrategyApp_RaiseAIPrepareTurn = 0x00415f20;
constexpr uint32_t StrategyAIAgent_OnEvent = 0x002d0ad0;
// thiscall void __thiscall Game::StrategyAIContext::OnStrategyEvent(int clientEventType, Game::StrategyEvent** ev, void (*cb)(void*, void*), Game::StrategyAIAgent* agent) -- RET 0x10. Registers {cb, agent, seq} on the pending-callback deque at this+0x58 (ring deque, buf@+0x5c cap@+0x60 head@+0x64 size@+0x68, 0x0c-byte nodes, push helper 0x0069e470 under a critical section). Then `switch (type - 6)` over 0..0x20 through the byte index table at 0x006c33a4 and jump table at 0x006c3360 (17 distinct cases), updating the AI world model and emitting INTERNAL AI packets {int code; ...} through StrategyAIContext::Broadcast 0x006b3840. Client event 9 (SEAIPrepareTurn) emits codes 1 then 2; client event 0x26 (SEResumePlaying) emits code 3. Tail: if the pending deque size is 1 it drains a second, separate queue at this+0x38 via 0x006a8690 [verified]
constexpr uint32_t StrategyAIContext_OnStrategyEvent = 0x002c2b90;
// thiscall void __thiscall Game::StrategyAIContext::Broadcast(const AIPacket* pkt) -- walks the listener red-black tree at this+0xc (std::set/map nodes; `_Isnil` at node+0x15) and calls listener->vt[3](pkt) on each -- that is the Game::AIObject event slot, implemented by AIPlayer (0x00723ed0), AISystem (0x006b3ae0), AIFleet (0x006b3970), AIBuildOrder and StrategyAIAgent (0x0069de50). Then, if the pending-callback deque at this+0x68 is non-empty, iterates it (0x0069e510 / 0x006a4ee0) and delivers the same packet to the queued {cb, this} pairs -- the hop that reaches StrategyAIAgent::OnAIPacket 0x006cf8a0. 19 call sites, all inside OnStrategyEvent 0x006c2b90 and 0x006c29c0 [mapped]
// thiscall void __thiscall Game::StrategyAIContext::Broadcast(const AIPacket* pkt) -- walks the listener red-black tree at this+0xc (std::set/map nodes; `_Isnil` at node+0x15) and calls listener->vt[3](pkt) on each -- that is the Game::AIObject event slot, implemented by AIPlayer (0x00723ed0), AISystem (0x006b3ae0), AIFleet (0x006b3970), AIBuildOrder and StrategyAIAgent (0x0069de50). Then, if the pending-callback deque at this+0x68 is non-empty, iterates it (0x0069e510 / 0x006a4ee0) and delivers the same packet to the queued {cb, this} pairs -- the hop that reaches StrategyAIAgent::OnAIPacket 0x006cf8a0. 19 call sites, all inside OnStrategyEvent 0x006c2b90 and 0x006c29c0 [verified]
constexpr uint32_t StrategyAIContext_Broadcast = 0x002b3840;
// thiscall void __thiscall Game::StrategyAIAgent::OnAIPacket(const AIPacket* pkt) -- 2008 bytes. `eax = pkt->code - 2; if (eax > 0xf) return; jmp [eax*4 + 0x006d0078]` -- a 16-entry jump table over internal packet codes 2..17. Code 2 = the PREPARE TURN body (0x006cf958, logs "====== AI Prepare Turn (%s) ======"); code 3 = the PROCESS TURN body (0x006cfabf, logs "====== AI Process Turn (%s) ======", ~30 phases, ends by calling cl_EndTurn 0x00579310). Codes 4/6/7/8/10/11/13 fall through to the no-op at 0x006d0058. this->+0x10 = the owning StrategyClient, this->+0x14 = the ClientPlayer (name std::string at +0x40), this->+0x94 = the StrategyAIContext. Reached only through the thunk at 0x006d0ab0 [verified]
constexpr uint32_t StrategyAIAgent_OnAIPacket = 0x002cf8a0;
@ -1353,7 +1353,7 @@ constexpr uint32_t cl_Chance = 0x00178cf0;
constexpr uint32_t cl_RandRange = 0x001798e0;
// offset Game::StrategyClient* g_StrategyClients[] -- the client table the whole 0x00578cf0..0x005793xx façade family indexes with g_CurrentClientIndex (0x00ae4808). 40 functions reference it. The AI runs as the current client: everything it does goes through this indirection, which is how one process hosts the human client and N AI clients over the same API [mapped]
constexpr uint32_t g_StrategyClients = 0x006e47e4;
// offset int -- index into g_StrategyClients (0x00ae47e4). Selects which client the cl_* façade acts on. Not instrumented; who sets it, and when relative to the AI's turn, is open [mapped]
// offset int -- index into g_StrategyClients (0x00ae47e4). Selects which client the cl_* façade acts on. CORRECTED by lane AI2 2026-09-08: it is a STACK POINTER, not a plain index. The only two instructions in the image that write it are PushCurrentClient 0x00578020 (`g_StrategyClients[idx+1] = c; ++idx;`) and PopCurrentClient 0x00578040 (`--idx;`); the other 40 referencing functions only read `[idx*4 + 0x00ae47e4]`. StrategyAIAgent::OnEvent 0x006d0ad0 brackets the whole AI turn in Push(agent->+0x10)/Pop [verified]
constexpr uint32_t g_CurrentClientIndex = 0x006e4808;
// offset Mars::RNG -- a STATIC generator in .data, 0x9cc bytes. Its only static initialiser (0x009dc6e0) writes the Mars::IStreamable vftable 0x009e22bc, NOT the Mars::RNG vftable 0x009e9aec that RNG_Seed installs: none of the six RNG_Seed call sites in the image targets it, so its mt[624] is the zero-initialised BSS array and `left` is 0. An all-zero MT19937 state is a fixed point of the twist, so EVERY draw from it returns 0. Five consumers: SNMRunAI (the AI client seed, OnMessage+0x955), RunCombatRound 0x007cbe80+0x60f, 0x007c2fa0+0xc84, 0x0079ea90+0x73 (an RNG_Chance) and 0x005b9f00+0xc0 [verified]
constexpr uint32_t g_GlobalRNG = 0x006f6e58;
@ -1365,6 +1365,56 @@ constexpr uint32_t AIPersonaDB_LoadStockTables = 0x002c6250;
constexpr uint32_t AIRulesDB_LoadAffinityTables = 0x002c63c0;
// thiscall void __thiscall -- loads Data/Strategy/AI/weapon_replacements.csv through Game::StrategyAIContext::WeaponReplacementsRowParser (vftable 0x00a1a62c). Consumed by 0x00694f80 ("StrategyAIContext::GetWeaponReplacement: maxReplacements (%i)") [mapped]
constexpr uint32_t StrategyAIContext_LoadWeaponReplacements = 0x002b4dc0;
// thiscall void __thiscall Game::StrategyAIAgent::RebuildAndRunTasks() -- THE TASK SELECTION LOOP. Phase 20 of the AI Process Turn body (called from 0x006cfc94). Order: (1) 0x006b34f0(this, &this->+0x2f8) refreshes the per-fleet world model over client->+0x60..+0x64; (2) PruneTasks 0x006b3640(this); (3) `switch (client->+0x150->+0x5c)` over 0..6 through the 7-entry jump table at 0x006cf880 -- four distinct arms (case 0/2/3/6 -> 0x006cf665, case 1 -> 0x006cf6f8, case 5 -> 0x006cf75d, case 4 -> NOTHING) each calling a fixed, source-ordered list of per-task-family creators in 0x006ab6c0..0x006c0e60; (4) if this->+0x8, that object's vt[1](this); (5) PruneTasks again; (6) TaskList_SortByPriority 0x006bf9c0(&this->+0x31c, player->+0x5c) -- std::list::sort, STABLE, DESCENDING by IAITask::vt[10](); (7) RunTaskList 0x006b3320(this, &this->+0x31c, 0, &this->+0x2e8) then again with pass=1; (8) PruneTasks again; (9) if player->+0x2d8 in {1,2}, 0x006cf4c0 then 0x006cf590; (10) 0x006a8eb0(this) -- reaches client order method 0x00763a20; (11) if this->+0x124, cl_SetResearchRate(*(float*)0x009e2ea0) and clear the flag; (12) this->+0x128 = 0 [verified]
constexpr uint32_t StrategyAIAgent_RebuildAndRunTasks = 0x002cf630;
// thiscall void __thiscall std::list<Game::IAITask*>::sort(Pred) on the agent's task list -- the MSVC 7.1 binlist sort: eh_vector_constructor_iterator over 26 (0x1a) 0x0c-byte std::list bins, the `_Bin == 25` overflow branch, merge helper 0x006a9850. The Pred is a 4-byte functor carrying player->+0x5c, and the inlined comparison IGNORES it: the whole ordering key is IAITask::vt[10]() (see AITask_slot10_GetPriority). std::list::sort is STABLE, so ties keep creation order -- which makes the per-arm creator call order in RebuildAndRunTasks part of the answer, not an implementation detail [verified]
constexpr uint32_t StrategyAIAgent_TaskListSortByPriority = 0x002bf9c0;
// thiscall void __thiscall std::list<Game::IAITask*>::merge(list& right, Pred) -- RET 8. THE COMPARISON, inlined at 0x006a9879..0x006a9895: `a = A->vt[10](); b = B->vt[10](); if (a > b) splice A before B;` (`cmp [ebp-0x10],eax / jle` -- so a strictly-greater test, descending order, ties left alone). Both calls are __thiscall with no stack args, which pins IAITask::vt[10] as `int GetPriority(void)` [verified]
constexpr uint32_t StrategyAIAgent_TaskListMerge = 0x002a9850;
// cdecl void (Game::StrategyAIAgent* agent, std::list<IAITask*>* tasks, int pass, std::vector<IAITask*>* pending) -- THE TASK EXECUTION LOOP, run twice per turn with pass = 0 then 1. For each node of `tasks` in list order (i.e. priority order after the sort): task = node->value; erase task from `pending` (std::find 0x0069af70 + memmove compaction); push_back task onto the agent's active-task stack at agent->+0x12c/+0x130/+0x134 (growth helper 0x00483410, "vector<T> too long"); call `task->vt[5](agent, pass)`; then if back() is still that task, pop_back. The push/pop bracket makes agent->+0x12c a task CALL STACK, which is how goal tasks (AITColonizeGoal, AITInvadeGoal, AITEscortGateInvadeGoal) nest sub-tasks [verified]
constexpr uint32_t StrategyAIAgent_RunTaskList = 0x002b3320;
// cdecl void (Game::StrategyAIAgent* agent) -- `for each node of agent->+0x31c: task = node->value; if (task->vt[6](agent)) { erase task from the vector agent->+0x2e8..+0x2ec; agent->RemoveTask(task) 0x006af900; }`. Called three times inside RebuildAndRunTasks: before creation, after creation, and after execution. This is what pins IAITask::vt[6] as `bool IsFinished(StrategyAIAgent*)` -- returning true destroys the task [verified]
constexpr uint32_t StrategyAIAgent_PruneTasks = 0x002b3640;
// thiscall void __thiscall Game::StrategyAIAgent::RemoveTask(Game::IAITask* task) -- unlinks the task from four containers: the master list at this+0x31c and the vector at this+0x2e8 (via 0x006ae930), the 0x0c-stride vector at this+0x1cc (via 0x006a95e0), and the 0x20-stride vector at this+0x208..+0x20c (find 0x0069b0e0 then a rep-movsd compaction of 0x20-byte records) [verified]
constexpr uint32_t StrategyAIAgent_RemoveTask = 0x002af900;
// cdecl int (int taskTypeId) -- the AI's whole task-ordering policy as one switch: `if ((unsigned)id > 0x20) return 0; jmp [id*4 + 0x00691ffc]`, 33 arms each a single `mov eax,imm32; ret`. Values (id -> priority): 0 AITSteamroll 1250, 1 AITExplore 600, 2 AITExploreInForce 550, 3 AITEscortGate 700, 4 AITEscortGateInvade 400, 5 AITEscortGateInvadeGoal 950, 6 AITDeployGateAt 1400, 7 AITColonize 900, 8 AITColonizeGoal 970, 9 AITColonizeAt 1300, 0xa AITInvade 500, 0xb AITInvadeGate 1000, 0xc AITInvadeGoal 930, 0xd (no class) 200, 0xe AITDefendColonyIncoming 1100, 0xf (no class) 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 last two table entries are DEAD: both artifact classes override vt[10] with fixed 0x4ec/0x4ed (1260/1261). Ids 0xd and 0xf have priorities but no surviving class [verified]
constexpr uint32_t AITask_PriorityForType = 0x00291f00;
// thiscall int __thiscall Game::IAITask::GetPriority() -- vtable slot 10, the DEFAULT implementation, used by 21 of the 31 concrete tasks (5 more reach it through the thunk 0x00682650): `return AITask_PriorityForType(this->vt[1]());`. Overrides: AITInvade 0x00683670 and AITEscortGateInvade 0x006835e0 return the globals at 0x00a1795c / 0x00a17960 when `this->+0x4 & 1` is clear, else default; AITAttackBlockade 0x00685600 scans a 0xc-stride vector at this->+0x8->+0x1cc for a related task and filters on its type id (1, 2, 7, 0x11, ...); AITRetrieveArtifact 0x005465a0 returns 0x4ec and AITReturnArtifact 0x00546800 returns 0x4ed unconditionally [verified]
constexpr uint32_t AITask_slot10_GetPriority = 0x00294220;
// offset Game::IAITask vtable slot 1 (byte offset 4) -- `int GetTypeId(void)`, PURE in the interface (vftable 0x009fa354), and in all 31 concrete classes a single 16-byte `mov eax,imm32; ret` returning a value in 0..0x20. It is the key into AITask_PriorityForType and the discriminator every cross-task filter uses [verified]
constexpr uint32_t AITask_vt_slot1_GetTypeId = 0x00000004;
// offset Game::IAITask vtable slot 5 (byte offset 0x14) -- `void Execute(Game::StrategyAIAgent* agent, int pass)`, RET 8, PURE in the interface. THE task body: 27 distinct implementations across the 31 classes, 48..288+ bytes each, dispatched from StrategyAIAgent_RunTaskList 0x006b3320+0x167 (`mov edx,[task_vt+0x14]; push pass; push agent; mov ecx,task; call edx`). This is the only slot from which a client order method is ever reached [verified]
constexpr uint32_t AITask_vt_slot5_Execute = 0x00000014;
// offset Game::IAITask vtable slot 6 (byte offset 0x18) -- `bool IsFinished(Game::StrategyAIAgent* agent)`, RET 4, PURE in the interface. Called ONLY from StrategyAIAgent_PruneTasks 0x006b3640+0x2b; true means unlink and destroy. 27 distinct implementations; the two shared trivials are 0x005eda80 `return false` (AITAdvanceIdleShips, AITSteamroll -- never retire) and, e.g., AITBuildDeepScanShips 0x00682fe0 `return !0x0069a7f0(agent, 0x20, 0)` [verified]
constexpr uint32_t AITask_vt_slot6_IsFinished = 0x00000018;
// offset Game::IAITask vtable slot 8 (byte offset 0x20) -- `const char* GetTypeName(void)`, PURE in the interface; in all 31 classes a 16-byte `mov eax,<rdata ptr>; ret` returning the class's own unmangled name ("AITRaid", "AITColonizeGoal", ...). Pairing slot 1 with slot 8 across the 31 vtables yields the complete task-type enum with no gaps except ids 0x0d and 0x0f [verified]
constexpr uint32_t AITask_vt_slot8_GetTypeName = 0x00000020;
// offset Game::IAITask vtable slot 9 (byte offset 0x24) -- `void Describe(void)`, no args, PURE in the interface. Every implementation is a single log call of the form `Log("<TypeName>: %s -> %s\n", NameOf(vt2()), NameOf(vt3()))` (AITAdvanceIdleShips prints the literal "AITAdvanceIdleShips: n/a -> n/a\n"). It is what pins slots 2 and 3 as the task's source and destination target getters [verified]
constexpr uint32_t AITask_vt_slot9_Describe = 0x00000024;
// offset Game::IAITask vtable slot 7 (byte offset 0x1c) -- `void OnObjectDestroyed(void* obj)`, RET 4, NOT pure: the interface default 0x005f8ac0 is a bare `ret 4`. The dominant override 0x00682540 (17 of 31 classes) nulls whichever of this->+0xc and this->+0x8 holds an object whose +0x4 equals the argument -- i.e. it drops dangling target references [verified]
constexpr uint32_t AITask_vt_slot7_OnObjectDestroyed = 0x0000001c;
// cdecl void (Game::StrategyClient* c) -- `g_StrategyClients[g_CurrentClientIndex + 1] = c; ++g_CurrentClientIndex;` (written as `mov [eax*4+0x00ae47e8],ecx` with eax = the old index, then `inc [0x00ae4808]`). So 0x00ae47e4 is a STACK of client scopes and 0x00ae4808 is its stack pointer, not a plain index -- the whole cl_* family reads `[idx*4 + 0x00ae47e4]`, i.e. the top of stack. 18 callers; the AI-relevant one is StrategyAIAgent::OnEvent 0x006d0ad0, which brackets the ENTIRE AI turn in Push(agent->+0x10) / Pop. That is the mechanism by which every cl_* call the AI makes -- cl_Chance, cl_RandRange, cl_SetResearchRate, cl_EndTurn -- lands on that AI's own client and its own RNG at client+0x134 [verified]
constexpr uint32_t PushCurrentClient = 0x00178020;
// cdecl void () -- `--g_CurrentClientIndex;`, the two-instruction pop matching PushCurrentClient 0x00578020. 18 callers, the same set. No other instruction in the image writes 0x00ae4808: an image-wide absolute-reference scan finds 42 referencing functions and every one of the other 40 only READS it [verified]
constexpr uint32_t PopCurrentClient = 0x00178040;
// thiscall void __thiscall Game::StrategyApp::RunPendingAITurns() -- called EVERY FRAME from StrategyNetworkClient::Update 0x007842b0+0xf7 with ECX = the StrategyApp singleton 0x00b29f98. `if (this->+0x1c == this->+0x20) return;` (empty pending-AI-player-id vector). Otherwise: t0 = clock 0x008d0b70; show the Game::AIProcessingDialog at 0x00b1149c if it exists; then FOR EVERY entry of +0x1c..+0x20 IN INDEX ORDER, find the client in +0xc..+0x10 whose client->+0x148 matches, update the dialog with client->+0x150, and StrategyClient::RaiseEvent 0x00783ee0(client, 0x26 /*SEResumePlaying*/, &ev) -- which is what runs that AI player's whole turn. The loop has no early exit and no frame yield. After it, the pending vector is emptied, and only THEN: `remaining = this->+0x2c - (clock() - t0); if (remaining > 0) Sleep((int)(remaining * 1000));` before hiding the dialog. THE THROTTLE IS A TRAILING SLEEP, NOT A COMPUTE BUDGET: AIProcessMinTime cannot change a decision and cannot defer an AI turn across frames [verified]
constexpr uint32_t StrategyApp_RunPendingAITurns = 0x00438c60;
// offset float -- Game::StrategyApp+0x2c, in SECONDS. Set once in StrategyApp::CreateGame 0x00888e80+0x90: the GameOptions key "AIProcessMinTime" (string at 0x00a32e30) is read through 0x00898bc0, converted with the CRT string-to-long at 0x009dd320, `fild`ed and divided by the double 1000.0 at 0x009e22f8, then clamped at 0 before `fst [esi+0x2c]`. Its only consumer is the trailing Sleep in StrategyApp_RunPendingAITurns 0x00838c60+0x10e [verified]
constexpr uint32_t StrategyApp_off_AIProcessMinTime = 0x0000002c;
// offset std::vector<int> -- Game::StrategyApp+0x1c.._+0x20, the queue of player net ids whose AI turn is due. Drained in index order by StrategyApp_RunPendingAITurns 0x00838c60, which is therefore the ONLY thing that decides in what order the AI players are stepped and hence the order their TurnCommands blocks reach the host. WHO PUSHES TO IT WAS NOT FOUND by this lane -- neither an absolute-reference scan for the singleton nor an enumeration of the methods called on it located the enqueue site [mapped]
constexpr uint32_t StrategyApp_off_PendingAIPlayers = 0x0000001c;
// thiscall bool __thiscall Game::StrategyClient::<fleet task order>(void* fleetObj, int mode, bool flag) -- RET 0xc. `if (this->+0x15c) return false;` then builds the 12-byte record {i32 fleetId = fleetObj->+4, i32 mode, bool flag}, calls the local-apply/validate 0x00821cf0(this->+0x148 /*playerId*/, &rec), and on true appends it to the accumulating TurnCommands at this+0x160 via the LIST 14 adder 0x00842a00. THIS IS LANE Q'S UNEXPLAINED LIST 14. The AI reaches it through 0x006987e0, which calls it TWICE per fleet -- (fleet, 0, true) then (fleet, 1, true) -- and 0x00842a00 keys its insert-or-update on BOTH fleetId (node+0x8) and mode (node+0xc), so an AI fleet order deposits TWO list-14 elements. The UI path (0x005e6fa0) and OnResumePlaying 0x00777480 also call it [verified]
constexpr uint32_t ClientOrder_FleetTask = 0x003634d0;
// thiscall void __thiscall Game::TurnCommands::<add list-14 entry>(const rec* r) -- operates on the std::list at this+0x10c, which is lane Q's LIST 14 (member 14 of 27, +0x70 + 14*0x0c - 0x0c = +0x10c). Scans for a node with node->+0x8 == r->fleetId AND node->+0xc == r->mode; if found, overwrites node->+0x8/+0xc/+0x10 in place; otherwise push_back via 0x00766c20. The node payload is exactly lane Q's observed element record {i32, i32, bool} [verified]
constexpr uint32_t TurnCommands_AddList14 = 0x00442a00;
// cdecl void* (Game::StrategyAIAgent* agent, std::vector<void*>* route, void* dest) -- the AI's single fleet-order bridge and the busiest AI->TurnCommands edge in the module. Pushes `dest` through cl_* helper 0x00578cd0, opens a route build with 0x0057b4a0, appends each element of `route` with 0x0057aa50, closes with 0x0057b4d0, resolves the resulting handle through 0x008f4b30, and if non-null calls ClientOrder_FleetTask 0x007634d0 twice: (obj, 0, true) then (obj, 1, true). Three callers -- 0x006b76a0, 0x006c15e0, 0x006c16c0 -- which between them are reached from the Execute (slot 5) body of 24 of the 31 task classes [verified]
constexpr uint32_t AI_IssueFleetTask = 0x002987e0;
// thiscall Game::StrategyClient order method appending to TurnCommands LIST 5 (+0xa0, the planetary-budget/system-rates list lane O observed in zuul-turn17-orders2.sav) through helper 0x008490b0, which is `add ecx,0xa0; call 0x00843fa0`. Called from the AI at 0x0069dd80 (AI Prepare Turn's one-shot NextInt scheduler) and from seven non-AI sites including the cl_* façade at 0x00579110. It is one of the five order methods lane AI1's 21-row table missed [verified]
constexpr uint32_t ClientOrder_SetSystemRates = 0x00363270;
// thiscall Game::StrategyClient order method appending to TurnCommands LIST 7 (+0xb8, lane O's `{i32 shipId, i32 w}` colonize list) through helper 0x00842890. Called from the AI at 0x006af790 -- phase 32 of the AI Process Turn body, i.e. AFTER cl_EndTurn -- and from ten non-AI sites, seven of which are the cl_* façade family 0x00578fc0..0x005790e0. Also missing from lane AI1's table [verified]
constexpr uint32_t ClientOrder_Colonize = 0x00369640;
// offset Game::TurnCommands -- the first of the 27 std::list members lane Q enumerated, stride 0x0c, so list N (1-based, as lane Q numbers them) is at +0x70 + (N-1)*0x0c and the last, list 27, is at +0x1a8. Recorded here because the order-method -> list mapping in ai-task-system.md#4 is expressed entirely in these offsets: the adder for list N is the function whose first `this`-relative access is +0x70 + (N-1)*0x0c [verified]
constexpr uint32_t TurnCommands_off_ListBase = 0x00000070;
// thiscall void (CombatResolveContext* this) // THE POST-BATTLE RETREAT PIPELINE. Exactly one caller: CombatResolver_Run 0x007d5af0, unconditionally, at 0x007d5be2. Real body 0x007d5a00..0x007d5abb; the only jcc in it is the operator-new null test whose false arm is a _CxxThrowException. It builds a ~0x2c-byte RetreatContext stack local from the resolver's ctx (rc->+0x00 = ctx->+0x00 = S; rc->+0x04 = ctx->+0x08 = enc; rc->+0x08 = ctx->+0x0c = res; a std::map<int,ServerSystem*> at rc->+0x0c with an operator_new(0x18) head node at rc->+0x10 and _Mysize rc->+0x14; a std::vector<RetreatGroup*> at rc->+0x1c/+0x20/+0x24) and runs SIX unconditional this-calls in a straight line: FUN_0079bb90 (per-player destinations), FUN_0079bcd0 (build groups), FUN_007b0320 (whole vs partial), FUN_00790790 (split partial fleets), FUN_007d5650 (execute; EVENT_FLEET_RETREATED_VIA_TELEPORT), FUN_007a7cd0 (destructor). CORRECTS combat-resolver.md's characterisation of this as 'the per-phase combat pipeline': it is ONE subsystem, retreat, not six combat phases. DRAW-FREE: a 327-function closure (E8 calls plus E9 tail-call thunks) contains zero calls to the four RNG primitives and zero inlined MT tempering immediates [verified]
constexpr uint32_t CombatResolve_Retreat = 0x003d5a00;
// thiscall void (RetreatContext* this) // RETREAT PHASE 1. One loop over enc->members (stride 0x44, magic 0x78787879 / sar 5). Per member: FUN_00787210(&enc->+0x1c, enc->+0x0c, member->+0x00 /*ServerPlayer*/, &r1, &r2, &r3), then this->dest[player->PlyrIdx(+0x28)] = the FIRST NON-NULL of (r1, r2, r3) via std::map<int,T*>::operator[] 0x0076bce0. So the per-player retreat destination is: nearest system you own, else nearest system with no hostile presence, else nearest system at all [verified]

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