Two AIs, built by different people at different times, sharing nothing but the name-based factory that constructs them. Nullkiller decides what a hero should do next by decomposing desires into tasks, depth-first, with its own loop detector. BattleAI decides what a stack should do next by fighting the battle against a body double first — a shadow object that answers every question the real battle would, except the dice always land on average.
Nullkiller::makeTurn() doesn't plan the whole turn once. It runs a bounded number of short passes, each one asking the same question again: what's the single best thing to do right now?
wiped every pass — only nullkiller->memory survives to the next turn, on purpose, to keep savegames small and forward-compatible
CaptureObjectsBehavior, ClusterBehavior, DefenceBehavior, EscapeBehavior, GatherArmyBehavior, and (if the map isn't fully explored) ExplorationBehavior
every task lands in one of seven PriorityTier buckets — INSTAKILL, INSTADEFEND, KILL, ESCAPE, EXPLORE_AND_GATHER, DEFEND, BUILDINGS — and only the best non-empty tier is even considered this pass
run each selected task; a failure doesn't stop the pass — chooseTaskFailureAction() decides to try the next task, replan, or stop the turn
scanDepth escalates from MAIN_FULL to ALL_FULL and the whole thing runs again — the AI widens its own search radius only once cheaper passes come up empty, rather than always paying for the expensive scan.A Behavior doesn't hand back a plan. It hands back a wish — DeepDecomposer::decompose() is what turns a wish into something a hero can actually do, or gives up on it.
It's an explicit-stack depth-first search, one level of the search per element of a std::vector<TGoalVec> sized to the depth limit. At each step, the goal on top of the current level's stack gets decomposed (goal->decompose(aiNk), memoized per level so the same goal is never expanded twice at the same depth); an isElementar() result becomes a task — wrapped in a Composition that aggregates every goal from the root down to it, so a deeply nested plan still remembers the whole chain that justified it — while a still-abstract result gets pushed one level deeper and decomposed again next iteration.
The loop check, isCompositionLoop(), doesn't keep a global visited set. It walks back up the current DFS stack, from the current depth to the root, comparing the candidate against every ancestor already on the path (CAPTURE_OBJECT goals for the same shipyard count as equivalent even if they're not the same object). A goal that matches an ancestor is discarded on the spot — “capture the shipyard to build a boat to reach the shipyard” dies here instead of recursing until the depth limit does it for you.
BattleAI doesn't reason about the real battle. It reasons about HypotheticBattle, an object that answers every question Battle System's real interfaces would — except the two things that matter most for planning are quietly substituted.
HypotheticBattle is constructed around a real Subject (std::shared_ptr<CBattleInfoCallback>) and implements the identical interface, so every class in AI/BattleAI/ queries it exactly as it would the real battle. What actually changes: unit state comes from a local stackStates map instead of the real gamestate; every roll goes through RNGStub, which always returns the arithmetic middle of its range instead of a real outcome, so “what happens if I attack” means “what happens on average,” never a lucky or unlucky one; and its nested HypotheticServerCallback::apply() catches every state-changing pack (BattleStackMoved, StacksInjured, SetStackEffect…) the real server would otherwise process, and simply updates the shadow instead. Nothing hypothetical can leak into the real fight.Neither AI is discovered at runtime. Both are compiled in and picked by name.
The Systems Map already names the mechanism: AIFactory::createAdventureAI(name) returns a shared_ptr<CGlobalAI> — AIGateway, Nullkiller's entry point, is one such subclass — and AIFactory::createBattleAI(name) returns a shared_ptr<CBattleGameInterface>, which CBattleAI subclasses. No dynamic loading, no plugin scan: the string that selects an AI (in a player's settings, or a scripted opponent) is matched against a fixed set of names baked into libFacade at build time, the same way every other AI on that region of the Systems Map is wired in.