Merge branch 'main' into wip/tail

This commit is contained in:
alex 2026-09-08 14:59:08 -04:00
commit c5d4b45fe7
21 changed files with 2212 additions and 48 deletions

View file

@ -31,6 +31,7 @@ add_subdirectory(src/game/design) # ship-design rules + derived stats (lib gam
add_subdirectory(src/game/events) # player event log + research events (lib sots_game_events)
add_subdirectory(src/game/combat) # post-battle strategic consequences (lib sots_game_combat)
add_subdirectory(src/game/nav) # fleet path planning, pure (lib sots_game_nav)
add_subdirectory(src/game/ai) # strategic AI task vocabulary + ranking (lib sots_game_ai)
add_subdirectory(src/app) # the standalone turn driver (lib sots_app, sots_turn)
# ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ----
@ -121,7 +122,7 @@ else()
add_executable(addr_smoke tests/addr_smoke.cpp)
target_link_libraries(addr_smoke PRIVATE sots_addresses)
add_test(NAME addr_smoke COMMAND addr_smoke)
foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects game_events game_combat game_nav shim_budget shim_techfx shim_colony shim_movement shim_events shim_player_turn shim_rng_ledger app)
foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects game_events game_combat game_nav game_ai shim_budget shim_techfx shim_colony shim_movement shim_events shim_player_turn shim_rng_ledger app)
if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt)
add_subdirectory(tests/${_t})
endif()

View file

@ -0,0 +1,162 @@
# B6 — ship construction: the build queue, and where the missing destroyer actually comes from
Lane B6. Branch `wip/build` off `main` `0f1c007`.
This file was written and committed **before** the code (earned rule 2). Section 1 is the
prediction as first written; section 2 is the falsification list; sections 3 onward are filled
in after the measurement and correct section 1 in place where it was wrong.
---
## 1. Prediction, written before the build
The brief's target is `hist[*]/stats[*]/shpt[0]` on both reference pairs — the archived ship
census is one destroyer higher than ours, and lane E2 concluded that no phase we run builds a
ship. That conclusion is right. The inference drawn from it — that `S11`'s build-queue
sub-pass is what closes it — is **wrong**, and this lane predicts the pass closes **nothing**:
> **Both reference pairs: closed 0, regressed 0.**
>
> `turn1-state.sav` and `turn2-state.sav` each carry exactly **three** `BQ` frames (one per
> owned system: players 16, 32 and 576), and **every one of them holds zero orders**. Every
> `hbq` in both files is `false`, so there is no ship-borne `BQ2` queue either. The only
> `TurnCommands_v5` block belongs to player 16 and is the byte-identical empty one. So at the
> moment the standalone loads either save, **the whole game contains no build order at all**,
> and a faithful build-queue pass has nothing to advance.
>
> The destroyer the archive counts is created *inside* the turn: on the reference pair the
> owner is player 32 ("Fane Lao"), an AI, and the same turn shows `NumDes` 5 → 6 (a design the
> AI designed during the turn), `FNG/FNGNum` 0 → 1 (a fleet named), `Maint` 0 → 500,
> `ShipRecs/srb[0]` 0 → 1 and a new `ShipRecs` design record, with `NMnx` 106 → 109 — three
> ids issued in the order design(107), ship(108), fleet(109). The order that fed the queue was
> issued by the AI and consumed in the same turn.
>
> **`shpt[0]` is blocked on AI order generation (`game/ai`, Rung B), not on S11.** This lane
> moves the blocker; it does not close the leaf.
What this lane does deliver, and what it is worth:
* the build-queue advance read byte for byte from `Game::BuildQueue::ProcessTurn`
(`0x00890d50`, real end `0x00891240` — Ghidra says 1230 bytes, the body runs 1264), which
**independently confirms** lane B4's `sim::ProcessBuildQueue` and finds **one divergence**
in it (§4, "What changed in the engine");
* the `ShipRecords` update the pass performs, whose per-class counter has **exactly one
writer in the whole image**;
* the pass wired into the standalone's S11 as its own reported sub-pass;
* a real oracle for the pass that the campaign already owns and had not noticed:
`zuul-turn16-noderoute.sav` → `zuul-turn17-rollpending.sav` is a genuine consecutive-turn
pair (frames 16 and 17) in which **six orders complete and one is partially advanced**
across two systems (§3, "The corpus oracle").
## 2. How this prediction could be wrong, and the symptom of each way
1. **A build order exists somewhere I did not look.** The reader types unknown regions as
`raw`; a queue hidden inside one would not show up in a tag scan. *Symptom:* the standalone
reports a non-zero order count on a reference pair, and `closed` is not 0.
2. **The order is created before the spine rather than during it.** If the AI's orders were
applied at load time by something the standalone also runs, the queue would be non-empty by
S11 and the pass would build. *Symptom:* same as (1). (The measured `PvSav` says otherwise:
Fane Lao's savings are 50,000 in the input file and the previous-turn snapshot in the output
file is 38,100, so 11,900 left the treasury *between the save and phase 0* — the queue-time
deduction of a build order the file does not contain.)
3. **The FIFO + stop-at-the-first-short-order model is wrong.** *Symptom:* on the
`zuul-turn16 → zuul-turn17` pair there is **no** single non-negative integer point total per
system that turns the observed before-queue into the observed after-queue. This is a real
test: a per-order point budget, a non-FIFO order, or a "skip and continue" rule instead of
"stop" each fail it.
4. **The `points <= 0` gate on the removal sweep is wrong.** Unobservable in this corpus
and therefore carried as a labelled hypothesis, not a result.
5. **`shpt` is not the fleet walk.** If the census leaf were fed by `ShipRecs` rather than by
walking fleets, the fix would be different. Lane E2 already falsifies this: the fleet walk
reproduces 480/480 archived census leaves.
---
## 3. Measured
Built on CT111 (`/srv/re-lab/build/sots-engine-b6`), report run there with
`tools/standalone_report.py --binary …`. Host gates run separately, never `&&`-chained.
| pair | baseline | after | **closed** | **regressed** |
|---|---:|---:|---:|---:|
| `turn1-state.sav` → `turn2-state.sav` | 209 | 158 | **51** | **0** |
| `turn2-state.sav` → `turn3-state.sav` | 108 | 87 | **21** | **0** |
Identical to the pre-lane baseline, which is the prediction holding: **this lane closed 0 and
regressed 0.** `regressedPaths` is empty on both pairs.
The prediction's own numbers, checked one by one:
* three `BQ` frames per reference save (systems owned by players 16, 32 and 576), **0 orders in
all three**, on both sides of both pairs — the standalone now prints this on its own line;
* every `hbq` in `turn1/2/3-state.sav` is `false` (15, 16 and 17 of them), so **0 ship-borne
orders**;
* the four `CDT` ids are `Player.00000016.TurnCommands_v5` plus three `AIAgent` blobs, and the
one command block is the 36-node empty one.
Falsification hypotheses 1 and 2 are therefore both refuted by measurement rather than by
argument, and hypothesis 3 is refuted by the corpus oracle below. Hypothesis 4 stands as a
labelled hypothesis; hypothesis 5 was already refuted by lane E2.
### What the pass does on the saves that are not the reference pair
`sots_turn <save> --phases` now prints a `build queue:` line under S11 on every save:
| save | queues | pending orders | demand (points) |
|---|---:|---:|---:|
| `turn1/2/3-state.sav` | 3 | 0 | 0 |
| `human-turn2-orders.sav` | 20 | 0 | 0 |
| `zuul-turn5-species5.sav` | 4 | 4 | 7,120 |
| `zuul-turn16-noderoute.sav` | 4 | 7 | 12,099 |
| `zuul-turn17-orders2.sav` | 4 | 2 | 5,109 |
| `zuul-turn23-fleet23.sav` | 4 | 1 | 370 |
Five of eleven saves exercise the pass the moment points exist. Nothing is committed: an order
that advanced with no ship behind it leaves a state the game never produces.
### The corpus oracle, which the campaign already owned
`zuul-turn16-noderoute.sav` (frame 16) → `zuul-turn17-rollpending.sav` (frame 17) is a real
consecutive-turn pair. Two systems, six completions, one partial advance:
| system | owner | before (`desID`:`conleft`) | after | solved points |
|---|---|---|---|---:|
| 384 | 16 | 608:1753, 576:1860, 576:1860 | 576:**1291** | **4182** |
| 80 | 32 | 114:959, 114:1889 ×3 (+816:6974 appended in-turn) | 816:**3818** | **9782** |
`test_construction.cpp` does not assume those totals — it **searches** 0..200,000 and asserts a
solution exists and is **unique**. A per-order budget, a non-FIFO drain, or skip-instead-of-stop
each make the search come back empty. The completions then reproduce the `ShipRecs` deltas the
same two files carry (class-0 `srb` 2→4 and 53→57; design 608 2→3; a new record for 576;
design 114 18→22; design 816 unchanged because its order did not finish).
## 4. What changed in the engine
* **`game/sim/construction.{h,cpp}`** — `ShipRecords` and the completion bookkeeping. The
per-class `built` counter's indexed increment has **exactly one writer in the whole image**
(an image-wide byte scan for the form at that displacement returns one site, inside this
pass), so a hull on the wire with that counter bumped came through the build queue and
nothing else — the encounter spawner that also creates ships touches no `ShipRecords`.
`RunSystemConstruction` wraps the point pass and keeps each completion's design id.
* **`game/sim/colony.{h,cpp}`** — one corrected rule: with `points <= 0` the original skips the
**removal sweep** as well, because the entry test branches to the epilogue. Carried as a
labelled hypothesis; no corpus save can reach the state that shows it, and the workload that
would is named in the header.
* **`app/construction_phase.{h,cpp}`** — S11's build-queue sub-pass, reported on its own line.
* **`app/phase_catalog.cpp`** — S11's blurb no longer calls the build queue the input boundary
for the census leaf.
## 5. What this lane did NOT do, said plainly
* **It did not close `shpt[0]`, and no build-queue work can.** The blocker moves to `game/ai`.
* **It did not model the ship or the fleet at birth in code.** The chain is read end to end and
written up in the RE repo (`findings/subsystems/ship-construction.md` §4): `StarShip` is 0xb0
bytes, its id comes from the object-id allocator and lands in `+0x04`, `FltID` is born NULL;
the hull joins its system's **cached home fleet** (`ServerSystem+0x238`) and a fleet is created
only when that slot is empty; every fleet born that way gets `FtFlg |= 0x400` — which
**corrects lane B5**, that bit is not a retreat marker. It is not implemented because the
newborn hull's stats are copied from design words the engine does not yet compute, and because
no save exercises the path with an oracle behind it.
* **The money charge has never fired.** No design in the corpus costs money, so the slot-9
refusal path is read from the bytes and never observed.
* **`srl`, `srk`, `sri` carry no model.** Losses and kills are zero in all eleven saves.

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 @ 3b99e25, 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]
@ -1431,6 +1481,70 @@ constexpr uint32_t StrategyServer_DestroyFleet = 0x0048b980;
constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0;
// thiscall void** (std::map<int, void*>* this, const int* key) // 125 B, ret 4. MSVC std::map<int,T*>::operator[]: _Lbound over the tree from this->_Myhead(+0x04)->_Parent, testing _Isnil at node+0x15 and the key at node+0x0c; if found returns &node->_Myval.second (node+0x10), else default-inserts the pair {key, 0} via _Buynode 0x008b91d0 + _Insert 0x0072b400 and returns the same. NODE IS 0x18 BY ENUMERATION from _Buynode's operator new(0x18): _Left +0x00, _Parent +0x04, _Right +0x08, pair<int,void*> at +0x0c/+0x10, and _Color/_Isnil written as ONE 16-bit store at +0x14/+0x15, plus 2 bytes padding. DELEGATED instruction-level read [verified]
constexpr uint32_t Map_IntPtr_Subscript = 0x0036bce0;
// site The byte AFTER Game::BuildQueue::ProcessTurn's last instruction (`ret 0x8` at 0x00891226, then int3 padding). Ghidra reports the function as 1230 bytes from 0x00890d50, i.e. ending at 0x0089121e -- INSIDE the epilogue, before the security cookie check. The body is 1264 bytes. Earned rule 17. Note that this address is ALSO the entry of SystemBuildQueue_AttachBuiltShip, the function's own slot-10 callee, which is why the fleet half of construction looked absent from the pass [verified]
constexpr uint32_t BuildQueue_ProcessTurn_RealEnd = 0x00491240;
// field ServerPlayer* -- the player that owns the queue. Read at 0x00890de9 (passed to the ship factory), 0x00890ec9 (passed to the system's post-build hook), 0x00890ef4 (the base of the ShipRecords update) and 0x00890f75 (the build-completed event's owner field) [verified]
constexpr uint32_t BuildQueue_off_Owner = 0x0000000c;
// field std::list<ShipBuildOrder> head sentinel. The pass walks it as `node = *(head); while (node != head) node = *node`, so it is the MSVC circular list. Read at 0x00890d9a and re-read every iteration at 0x00890d9d / 0x00891020 [verified]
constexpr uint32_t BuildQueue_off_OrderList = 0x00000010;
// field int -- the list's element count. Used as the reserve hint for the removal sweep's scratch vector at 0x0089104a and decremented once per unlinked order at 0x008911b0 [verified]
constexpr uint32_t BuildQueue_off_OrderCount = 0x00000014;
// field int conleft, measured from the std::list NODE base (node+0x0 next, +0x4 prev, +0x8 the order's own vptr -- the list is polymorphic -- then +0xc desID, +0x10 con, +0x14 sav, +0x18 conleft, +0x1c ordID, +0x20 ShipDesign*). Compared against the remaining points at 0x00890db5, decremented at 0x008910c4, zeroed at 0x0089100c, and it is the removal sweep's predicate at 0x00891070 [verified]
constexpr uint32_t ShipBuildOrder_off_ConLeft = 0x00000018;
// field ShipDesign* -- from the same std::list node base as ShipBuildOrder_off_ConLeft. Loaded at 0x00890dbe and is the source of the money cost (+0xc0), the role flags (+0xb8) and the hull class (+0x12c) the completion reads [verified]
constexpr uint32_t ShipBuildOrder_off_Design = 0x00000020;
// vslot bool (StrategySim* sim, int64 cost) -- vtable slot 9. Called at 0x00890ddc only when design->+0xc0 > 0; a FALSE return SKIPS that order and the pass continues with the next one rather than stopping. Pure in Game::BuildQueue's own vtable (0x00a31328); Game::SystemBuildQueue (0x00a31358) binds 0x00809910 and Game::ShipBuildQueue (0x00a31388) binds 0x0091e480 [verified]
constexpr uint32_t BuildQueue_vslot_ChargeMoney = 0x00000024;
// vslot void (StrategySim* sim, StarShip* ship) -- vtable slot 10, called at 0x00890e20 immediately after the ship is created. Pure in the base vtable; Game::SystemBuildQueue binds 0x00891240, Game::ShipBuildQueue binds 0x0081c000. This is where join-an-existing-fleet vs create-a-new-one is decided [verified]
constexpr uint32_t BuildQueue_vslot_AttachBuiltShip = 0x00000028;
// __thiscall void (SystemBuildQueue* this, StrategySim* sim, StarShip* ship) /* RET 8. Game::SystemBuildQueue vtable slot 10. Routes the newly built hull by design role flags -- design->+0xb8 & 0x80000, design->+0xbc & 0x4, design->+0xb8 & 0x800000, design->+0xb8 & 0x400 each take their own handler -- and FALLS THROUGH at 0x00891328 to `if (ship->FltID == 0) ServerSystem_AttachShipToHomeFleet(sys, ship)`. NOTE the 0x400 tested at 0x00891311 is a DESIGN role bit and is NOT the fleet's FtFlg 0x400 */ [verified]
constexpr uint32_t SystemBuildQueue_AttachBuiltShip = 0x00491240;
// __thiscall void (ServerSystem* this, StarShip* ship) /* The join-or-create step. If this->+0x238 is non-null the cached home fleet is reused; otherwise an id is drawn from the object-id allocator (0x0074f500) and StrategyServer_CreateFleet (lane B5, 0x0085b340) builds a fleet at the system's position with a NULL name override, the result is cached in this->+0x238 (0x0074f513), FtFlg |= 0x20 (0x0074f519), and StarFleet_AddShip links the hull. ONE home fleet per system: every hull built at that system in later turns joins it */ [verified]
constexpr uint32_t ServerSystem_AttachShipToHomeFleet = 0x0034f4d0;
// field StarFleet* -- the cached fleet newly built hulls join. Tested at 0x0074f4d6 and written at 0x0074f513. NEW OFFSET: not in struct-recovery.md's ServerSystem table [verified]
constexpr uint32_t ServerSystem_off_HomeFleet = 0x00000238;
// __thiscall StarShip* (ObjectHost* this, int id, ServerPlayer* owner, ShipDesign* design) /* RET 0xc. operator new(0xb0) at 0x0086571c, ctor 0x00861280, then IDMap_Insert at 0x00865754 which is where the object id lands in ship->+0x4. Reached from exactly two places in the image: the build queue (through the 0x004f41a0 thunk) and the trade manager's encounter spawner */ [verified]
constexpr uint32_t StarShip_Create = 0x004656f0;
// __thiscall void (StarShip* this, ObjectHost* host, ShipDesign* design, ServerPlayer* owner) /* Zeroes the object through 0x0080c960 (which leaves +0x48, +0x60 and +0xac at -1), then writes +0xc host, +0x14 design (DesID), +0x10 owner (PlrID); allocates the ship-borne BuildQueue into +0x98 when design->+0xb8 & 0x400000, and three Population objects into +0x9c/+0xa0/+0xa4 when design->+0xb8 & 0x4000000; finally 0x00854680 copies the cached design stats -- Range +0x20 from design+0xe8, Health +0x24..+0x30 from design+0xec.., RefCap +0x6c, RepCap +0x70, ConCap +0x68 from design+0xd0. FltID (+0x64) is born NULL and is set by StarFleet_AddShip */ [verified]
constexpr uint32_t StarShip_Ctor = 0x00461280;
// field int tblt -- the turn the hull was completed. Born -1 in the constructor's default sweep (0x0080c9fb) and overwritten by the build queue at 0x00890e02 with the StrategySim's Frame word. MEASURED: the six hulls the zuul turn-16 -> turn-17 pair adds all carry tblt equal to the NEW turn number, which independently confirms that BeginProcessTurn's Frame increment happens BEFORE the spine, so a phase reading Frame during a turn sees the turn it is producing, not the one it started from [verified]
constexpr uint32_t StarShip_off_TurnBuilt = 0x000000ac;
// site `inc DWORD PTR [esi+edx*4+0x1b4]` with esi = the queue's owner and edx = design->+0x12c (the hull class). THE ONLY WRITER OF THE PER-CLASS BUILT COUNTER IN THE WHOLE IMAGE: a byte scan for the indexed-increment form at that displacement over all executable sections returns this one site. A hull that reaches the wire with the counter bumped came through the build queue and through nothing else [verified]
constexpr uint32_t ShipRecords_BuiltCounterSite = 0x00490ef7;
// field int built[3] -- the first of Game::ShipRecords' four parallel per-hull-class arrays (built, lost, killed, inService; wire tags srb/srl/srk/sri under the srnc count). SIZED BY ENUMERATION, not by what the code touches: three classes x four arrays x 4 bytes from 0x1b4 lands exactly on 0x1e4, the per-design vector, which is the next thing the same function reads. struct-recovery.md places the ShipRecords sub-object at 0x1b0, so 0x1b0 is its vptr [verified]
constexpr uint32_t ServerPlayer_off_ShipRecordsBuilt = 0x000001b4;
// field std::vector<{int srd; int src; int srb; int srl; int sri}> at +0x1e4/+0x1e8/+0x1ec, stride 0x14 (the wire's srbd section). The completion scans it linearly for a record whose first word equals the design's object id (0x00890f10), appends one when there is no hit (0x00890f4c) and increments the record's third word (0x00890f61). MEASURED: srd really is the design's save id -- the zuul corpus shows records keyed 608/576/114/816/18/34/130 against build orders naming exactly those desIDs [verified]
constexpr uint32_t ServerPlayer_off_ShipRecordsByDesign = 0x000001e4;
// __thiscall int (ServerTradeManagerImpl* this, int* spec) /* THE SECOND AND ONLY OTHER ROOT THAT CREATES SHIPS. Draws two ids from the object-id allocator, creates a StarFleet through StrategyServer_CreateFleet (lane B5, 0x0085b340) (0x0088f314) and then loops StarShip creation (0x0088f375) + StarFleet_AddShip (0x0088f381). Reached only from 0x008926ce, itself reached only from ServerTradeManagerImpl vtable slot 17 (0x008938a0), which picks its target with an RNG draw. It does NOT touch ModCount, and it does NOT touch ShipRecords -- so an encounter squadron is invisible to the per-class built counter, which is why that counter is a clean discriminator for player-built hulls */ [verified]
constexpr uint32_t TradeManager_SpawnEncounterSquadron = 0x0048f070;
// __thiscall void (ShipAction* this, ...) /* The construction-ship wrapper around BuildQueue::ProcessTurn (calls it at 0x00789551 with the ship's own queue at ship->+0x98 and its ConCap at ship->+0x68 as the point budget). REACHABILITY NOTE, and it is a new indirection class for the campaign: this function has ZERO call sites and is in NO vtable. Its address is written into a STACK-BUILT function-pointer table by the ship-action dispatcher (0x007b9c4b `mov eax,0x789500`, stored at 0x007b9c50), alongside five siblings. tools/vtable_map.py cannot see edges of this shape, so `no caller` and `no vtable caller` are BOTH lower bounds */ [verified]
constexpr uint32_t ShipBorneBuildQueue_ProcessTurn = 0x00389500;
// site CORRECTION to addresses.json's BuildQueue_ProcessTurn prototype, which reads `int (BuildQueue* this, ServerSystem* sys, int points)`. The FIRST STACK ARGUMENT IS NOT THE SYSTEM. At the only real call site the pushed value is `[sys+0x10] - 4` (0x0075257c `lea edi,[eax-0x4]` with eax = [esi+0x10], esi = the ServerSystem, ecx = [esi+0xa4] = the queue), i.e. the StrategyServer `S` frame -- the same object lane B5's StrategyServer_CreateFleet takes at S+4, one word higher. Two consequences the old prototype hides: the build-completed event is pushed onto a list at S+0x2b0, NOT onto the system; and the turn stamp written into the new hull at 0x00890e02 is S+0xc, which StrategyServer::Write tags `Frame`. AGREEMENT with lane T section 0 (the two bases four bytes apart) and with lane A2 (S+0xc is Frame, not ModCount) [verified]
constexpr uint32_t BuildQueue_ProcessTurn_Arg0Correction = 0x00352589;
// field ServerSystem* -- the system a SystemBuildQueue belongs to, read by the slot-10 attach handler at 0x00891246 as `(this->+0x4 == 0) ? this->+0x8 : 0`. Also read by the pass itself at 0x00890f89 for the build-completed event [verified]
constexpr uint32_t BuildQueue_off_OwningSystem = 0x00000008;
// thiscall int (ServerSystem* sys, int points, bool estimateOnly) // `ret 8`, real end 0x007517b5 (SEH frame, /GS cookie). Returns the points NOT consumed. Walks the system's fleets through the system's own vtable (slot 2 = count, slot 4 = element), keeps those whose +0x58 equals sys->PID and that pass 0x00813ab0(0,8), and inside each keeps the ships that pass 0x00814da0(0,8) with 0x00815180(ship,1) > 0. With estimateOnly it returns max(points - totalCost, 0) and touches nothing; without it, it distributes round-robin -- share = max(points / shipCount, 1) per pass, take = min(cost, share), applied by 0x008151c0 -- until no ship takes anything or the points run out. THE ROUND ROBIN IS EQUIVALENT TO points - min(points, totalCost): share is at least 1, so every ship with a positive cost takes at least one point per pass, and the only early exit needs every remaining cost to be zero. THIS IS THE SIDE EFFECT that makes ComputeOutputFromRates unsafe to call for its value (the B1 replace double-run defect): ComputeOutputFromRates passes estimateOnly = 0 [verified]
constexpr uint32_t ServerSystem_RepairShipsInOrbit = 0x00351590;
// thiscall int (ServerSystem* sys, double constructionShare) // `ret 8`, real end 0x00746883. Turns the ship-construction channel's rounded share into out[7]: with no owner it is ftol(share), else k = StationCount(sys, PID, 1) and b = STATION_BONUS_SHIPCON (slot 0x00af08ec) taken as 0 unless STRICTLY positive, returning ftol( k x (b x share) + share ). TRUNCATING, not rounding, and note the association -- neither is `share x (1 + b x k)` [verified]
constexpr uint32_t ServerSystem_ConstructionPoints = 0x00346830;
// thiscall int (BuildQueue* q) // 23 bytes, plain `ret`, no frame. Walks the std::list at q+0x10 from its sentinel and sums the dword at +0x18 of each node -- the order's `conleft`. This is the queue demand ComputeOutputFromRates charges against out[7] before anything cascades back to the money channel, and the ONE input of that function that a save can supply in full (Sys/BQ/ords/conleft is on the wire) [verified]
constexpr uint32_t BuildQueue_TotalConstructionLeft = 0x004251e0;
// thiscall double (ServerSystem* sys) // real end 0x00745dc0. The suitability the terraform channel aims at, and the value NormaliseOutputRates compares sys->Suit against with an exact ==. With no owner it returns sys->Suit itself, so an unowned system is always 'at its ideal'. Otherwise it starts from owner->IdealSuit (+0xb0), replaces that with StrategyServer::IdealSuit(sys->server, sys->indi->indsp) when the system carries an independence record (+0x1c8), and finally overrides both with sys->dsu (+0x118) whenever dsu differs from the float behind 0x00aeca6c. NOT the same source as CalcSuitMod's ideal, which is the server's per-species array unconditionally -- the two agree on every corpus save, so the difference is instruction-verified only. Every corpus system carries dsu = FLT_MAX, which is why the sentinel is READ AS FLT_MAX (inferred from the corpus, not from the data files) [verified]
constexpr uint32_t ServerSystem_IdealSuitability = 0x00345d60;
// custom int (/* ESI = ServerSystem* sys */) // real end 0x0074c80f. TAKES ITS `this` IN ESI, not ECX -- a compiler-local helper that inherits the register its caller holds; hooking or calling it as a __thiscall reads the wrong object. Returns 0 with no owner and 0 on an independent colony (+0x1c8), else derives a civilian-share ratio from three int64 population helpers (0x0074a870, 0x0074a8c0, 0x0074a920), scales CIVILIAN_RESOURCES_CONSUMED (slot 0x00ae2ea4) by it and floors the result at 1. Feeds only the resource ledger (out[1], out[2]); it is NOT on the path to out[3] [unverified]
constexpr uint32_t ServerSystem_CivilianConsumption = 0x0034c6f0;
// custom int (/* EBX = ServerSystem* sys */ ServerPlayer* owner) // real end 0x0074615a. TAKES THE SYSTEM IN EBX and the player on the stack -- the second compiler-local helper in this call graph with an inherited register. Returns 0 when either is null. Walks the system's fleets through the system's vtable (slot 2 = count, slot 3 = element), keeps those whose owner (0x0071e280) is the argument and whose +0x78 byte is set, and sums 0x00829180 over the ship vector at +0xa4..+0xa8. ComputeOutput stores the result in out[6]; nothing downstream of out[3] reads it [verified]
constexpr uint32_t SystemRepairDemandForOwner = 0x003460b0;
// thiscall void (TradeManager* mgr, ServerSystem* sys, ServerPlayer* owner, int* a, int* b) // `ret 0x10`. Returns immediately when `sys` is null, else forwards to 0x00833a10(owner, a, b, 0, sys) with the manager still in ECX. ComputeOutput reaches it as `mgr = server->vtbl[2]()` -- a zero-argument getter whose four argument pushes were scheduled BEFORE the call, which reads as a five-argument virtual call and is not one. Fills out[4] and out[5]; nothing downstream of out[3] reads them. The name is INFERRED from the callee's neighbourhood (the trade manager's difficulty multiplier lives at 0x00833938), not from a symbol [unverified]
constexpr uint32_t TradeManager_SystemRouteIncome = 0x0043a5b0;
// cdecl int (ServerSystem* sys, ServerPlayer* owner, int kind) // the station count both station bonuses read. ConstructionPoints 0x00746830 passes kind = 1 (shipyards); GroupOutput 0x0074b7a0 passes kind = 0 (the imperial output bonus). Body not read this lane; the argument order and the two kinds are read off the two call sites [unverified]
constexpr uint32_t ServerSystem_StationCount = 0x00415c10;
// thiscall int (StarShip* ship, int kind) // the per-ship repair demand the orbit repair pass sums and then spends against, always called with kind = 1. Body not read; this is the ONE input of ComputeOutput that no save can currently supply, and until it is read the engine takes the demand as 0 -- which reads a colony with a damaged fleet HIGH, because every point the repair pass would have taken is a point that comes back to the money channel instead [unverified]
constexpr uint32_t Ship_RepairCost = 0x00415180;
// thiscall void (StarShip* ship, int points) // the write half of the orbit repair pass. Body not read [unverified]
constexpr uint32_t Ship_ApplyRepair = 0x004151c0;
// thiscall bool (StarFleet* fleet, int a, int b) // the fleet-level gate of the orbit repair pass, called as (0, 8); the ship-level counterpart is 0x00814da0 with the same arguments. Body not read [unverified]
constexpr uint32_t Fleet_TestFlags = 0x00413ab0;
// thiscall void (Game_ShipDesignDef* this, Mars::Stream* s) // THE DESIGN SERIALIZER LANE D SAID DID NOT EXIST. Slot 1 of the ShipDesignDef vftable 0x009fef64. Writes, in DISK order: WriteBool 'FAIDes' this+0x4, WriteBool 'DHide' this+0x5, WriteBool 'DWep' this+0x6 (a BOOL, not an int -- the campaign schema had it as int; byte-neutral because a 4-char tag makes both items 12 bytes), WriteString 'DName' this+0x8, then THREE 'DSec' frames through StreamableHelper<ShipDesignDef::Section> at this+0x4c, this+0x24, this+0x74 in that order. THREE sections, not five: the ctor 0x00874c70 runs eh_vector_constructor_iterator(this+0x24, stride 0x28, count 3). MEMORY ORDER != WRITE ORDER: the array is [+0x24, +0x4c, +0x74] and the wire is [+0x4c (command), +0x24 (mission), +0x74 (engine)] [verified]
constexpr uint32_t Game_ShipDesignDef_Write = 0x00427390;
// thiscall void (Game_ShipDesignDef* this, Mars::Stream* s) // slot 0 of vftable 0x009fef64. Mirrors Write field for field, same tags, same three DSec frames in the same order [verified]
@ -1955,6 +2069,20 @@ constexpr uint32_t g_flt_RebOutModDecay = 0x00617870;
constexpr uint32_t g_flt_RebOutModMin = 0x00617868;
// data const float = 2.0f, the upper clamp of RebOutMod [verified]
constexpr uint32_t g_flt_RebOutModMax = 0x0061786c;
// thiscall void (StrategyServer* S, vector<uint32>* allianceBroken, vector<uint32>* napBroken, vector<uint32>* cfBroken) // 700 B, ret 0xc. THE DIPLOMACY LEDGER'S PER-TURN STAMP, and the only writer of DiplomacyStats on a turn with no combat and no diplomatic command. Pass A (0x00789920..0x007899c4): over every ORDERED pair (A,B) of the S-frame player vector at S+0x54/+0x58, skipping A==B by POINTER, rel = A->GetRelation(B) 0x0080e050; rel 1 -> slot 8, rel 2 -> slot 0, rel 3 -> slot 4, else skip; then ctor a DiplomacyStats on the stack, GetDipStat(&local,B) 0x008180e0, store (int16)S->Frame(+0xc) at local+8+slot*2, SetDipStat(&local,B) 0x00863950. Field mapping: rel 3 -> lastally(+0x10), rel 2 -> lastnap(+8), rel 1 -> lastcf(+0x18). Pass B (0x007899ca..) is the betrayal counter and indexes the three broken-mask arguments by the inner loop index, substituting a zero local when a vector's length != nPlayers -- with no command stream it is a no-op. SOLE CALLER: ApplyTurnCommands 0x007b18b0 at 0x007b2461, so this runs BEFORE both turn drivers and AFTER BeginProcessTurn's frame bump [mapped]
constexpr uint32_t StrategyServer_StampTreatyTurns = 0x003898c0;
// cdecl int (int myPlyrIdx, PlayerAlliances* a, int otherPlyrIdx) // 58 B. if (myPlyrIdx == otherPlyrIdx) return 3; bit = 1 << otherPlyrIdx (shl by cl, so masked to 5 bits); if (a->AL(+4) & bit) return 3; if (a->NA(+8) & bit) return 2; return (a->CF(+0xc) & bit) ? 1 : 0. THE RELATION CODES ARE 3 = ALLIED (and self), 2 = NON-AGGRESSION, 1 = CEASE-FIRE, 0 = WAR -- strategic-turn-internals.md section 5.2 had 1 and 3 the other way round. The bit is the INDEX FIELD, not the position in the player vector (the opposite of the shared-vision mask), and AL is tested with NO alliance-id guard [mapped]
constexpr uint32_t PlayerAlliances_Relation = 0x002d2050;
// thiscall int (ServerPlayer* this, ServerPlayer* other) // 33 B, ret 4. A thin forwarder: tail-calls the cdecl PlayerAlliances_Relation 0x006d2050 with (this->PlyrIdx(+0x28), &this->Alliances(+0x168), other->PlyrIdx(+0x28)). 60+ call sites across the image [mapped]
constexpr uint32_t ServerPlayer_GetRelation = 0x0040e050;
// thiscall DiplomacyStats* (DiplomacyStats* this) // 55 B. vptr = 0x00a21430; every field zeroed; then lastcf(+0x18) = lastnap(+8) = lastally(+0x10) = -1. So a fresh entry's three 'last in force' fields are -1, NOT 0, and every counter (lastnapbty/bkn*/bty*/deadhome) is 0. This is what distinguishes 'never' from 'on turn 0' in the ledger [mapped]
constexpr uint32_t DiplomacyStats_ctor = 0x0040e7b0;
// thiscall void (ServerPlayer* this, DiplomacyStats* out, ServerPlayer* other) // 294 B, ret 8. if (!out) return; re-initialise *out to the ctor's defaults IN PLACE (the vptr is not touched); out->other(+4) = other->+0x4 (the handle id, i.e. the wire's PlayerID); if (!other) return; then a LINEAR FIRST-MATCH scan of the 0x24-stride vector at this->dipstats(+0x230/+0x234) for entry.other == GetId(other) 0x0042bfb0, copying the entry's thirteen int16 fields (out+8..out+0x21) on a hit. Stride read as 0x38e38e39 / sar 3 [mapped]
constexpr uint32_t ServerPlayer_GetDipStat = 0x004180e0;
// thiscall void (ServerPlayer* this, const DiplomacyStats* src, ServerPlayer* other) // 328 B, ret 8. if (!src || !other) return; the same linear first-match scan; ON A MISS default-construct a DiplomacyStats on the stack and push_back it (0x0085bc40) so a NEW ENTRY IS APPENDED AT THE END, then back().other = other->+0x4; finally copy src's thirteen int16 fields into the entry and re-write other. The append order is therefore the order in which pairs are first stamped, which is player-vector order [mapped]
constexpr uint32_t ServerPlayer_SetDipStat = 0x00463950;
// thiscall bool (StrategyServer* this, int playerId) // 60 B, ret 4. p = HandleMap::Resolve(this + 0x80, playerId) 0x008b9240; if (!p) { Log(2, <0x00a2fb30>, playerId); return false; } p->Status(+0x164) = 4; return true. THE ONLY WRITER OF Player.Status = 4 IN THE IMAGE. Three callers, all End Turn SUBMISSION paths that run before the turn is processed: EndTurn 0x00783be0 (+0x70, passes the client's own id at client+0x148), EndTurnForced 0x00783d30 (+0x7b), OnPlayerEndTurn 0x007d9af0 (+0x35). The other two immediate stores to +0x164 in the image are ProcessTurn +0x5ca (value 1, inside the 0x44-stride encounter-member loop) and ResumePlaying +0xb1 (value 0, the load-path normalisation determinism-oracle.md recorded as 'Status resets 4 -> 0 on load'). There is NO writer between tail phase 31 and the autosave; backlog.md item 6 looks in the wrong place [mapped]
constexpr uint32_t StrategyServer_MarkPlayerTurnEnded = 0x00421a40;
// thiscall bool (TechTree* this, TechPrereqs* prereqs /* = TechDef + 0x88 */) // RET 4. An AND over groups, each group an OR over techs: a group is satisfied by any listed tech whose node exists in this->nodes AND whose state (+0x14) is 4. Zero groups -> TRUE (the function returns satisfied==total with both 0); a group with ZERO entries -> FALSE and the whole test fails, because the inner loop cannot break and the outer one then exits with that group uncounted. Reads only. Called from SetResearched twice: the unforced completion gate on the argument def, and the availability sweep on each node's self-resolved def [verified]
constexpr uint32_t TechTree_PrereqsMet = 0x0017d8e0;
// field TechPrereqs prereqs -- the block TechTree::PrereqsMet is called on. Two MSVC vectors back to back: the flat entry array at +0x00 and the group array at +0x10. SetResearched passes `def + 0x88` at both call sites [verified]

View file

@ -9,6 +9,7 @@ add_library(sots_app STATIC
trade_raid.cpp
treaty.cpp
turn_record.cpp
construction_phase.cpp
visibility_phase.cpp
turn.cpp
report.cpp)

View file

@ -0,0 +1,96 @@
#include "app/construction_phase.h"
#include <cstdarg>
#include <cstdio>
#include <map>
#include "game/sim/construction.h"
namespace sots::app {
namespace {
std::string fmt(const char* f, ...) {
char buf[512];
va_list ap;
va_start(ap, f);
std::vsnprintf(buf, sizeof buf, f, ap);
va_end(ap);
return std::string(buf);
}
} // namespace
ConstructionPhaseResult RunBuildQueues(mars::stream::shapes::SaveGame& game) {
ConstructionPhaseResult r;
// The owner of a queue is the system's `PID`, which is the player's OBJECT id, not its
// position in the player vector. The two are different numbers and confusing them is a
// trap this codebase has already paid for once.
std::map<std::int32_t, std::size_t> playerIndex;
for (std::size_t i = 0; i < game.sim.players.size(); ++i)
playerIndex[game.sim.players[i].playerID] = i;
int shipBorne = 0;
int ordersByOwner = 0;
int unownedQueues = 0;
for (auto& e : game.sim.systems) {
if (!e.sys.bq.has_value()) continue;
++r.queuesVisited;
auto& q = *e.sys.bq;
r.ordersPending += static_cast<int>(q.orders.size());
for (const auto& o : q.orders) r.pointsDemanded += o.conleft;
if (!q.orders.empty()) {
if (playerIndex.count(e.sys.pid))
++ordersByOwner;
else
++unownedQueues;
}
}
// The ship-borne queues. Every one of them is gated behind the ship's `hbq` flag, and
// the count is reported because an order hiding in one would falsify the "no orders on
// the reference pair" reading, which is the load-bearing claim of this phase.
for (const auto& fe : game.sim.fleets)
for (const auto& se : fe.flt.ships)
if (se.ship.hbq) shipBorne += static_cast<int>(se.ship.bq2.orders.size());
r.blockedOnPoints = r.ordersPending > 0;
if (r.queuesVisited == 0) {
r.notes.push_back("no system carries a build queue: a queue is written only for a "
"system with an owner");
return r;
}
r.notes.push_back(fmt("%d system build queue(s), %d pending order(s) demanding %d "
"construction point(s); %d order(s) in ship-borne queues",
r.queuesVisited, r.ordersPending, r.pointsDemanded, shipBorne));
if (r.ordersPending == 0 && shipBorne == 0) {
r.notes.push_back("NOTHING TO BUILD. The pass is faithful and idle: with no order "
"anywhere in the game it cannot create a ship, so it closes no "
"leaf here. On the reference pairs the archived ship census is "
"still one destroyer higher than ours, and the order behind that "
"destroyer is created inside the turn by the AI -- that leaf is "
"blocked on AI order generation, not on this phase");
return r;
}
// Points would come from the system's output vector; that term is the roadmap's item 1
// and is not this lane's. The pass is therefore driven with nothing and reports the
// demand, so the shape of the gap is visible in the run log.
r.notes.push_back(fmt("BLOCKED: construction points come from the per-system output "
"term (out[7] scaled by the shipyard bonus, then out[8] = min(out"
"[7], demand)), which is unmodelled. %d order(s) would be offered "
"points this turn", r.ordersPending));
if (unownedQueues)
r.notes.push_back(fmt("%d queue(s) hold orders but their system's owner id resolves "
"to no player in the save -- reported, not skipped silently",
unownedQueues));
(void)ordersByOwner;
return r;
}
} // namespace sots::app

View file

@ -0,0 +1,58 @@
// S11's build-queue sub-pass, wired to the save shapes.
//
// WHERE IT SITS
// -------------
// `StrategyServer::ProcessTurn` phase 11 walks the systems; each system's own turn runs the
// build queue between the plague pass and the population growth. The engine's S11 already
// runs the parts of the colony turn that need neither the tuning table nor a carrying
// capacity; this is the build-queue part, kept in its own file and reported as its own line
// so its contribution is never folded into S11's other writes.
//
// WHAT IT IS BLOCKED ON, and what it is NOT blocked on
// ----------------------------------------------------
// Two different things, and the campaign had them confused:
//
// * the POINTS. `BuildQueue::ProcessTurn` takes construction points by value. They come
// from the system's output vector -- `out[7]` scaled by the shipyard station bonus, then
// `out[8] = min(out[7], queue demand)` -- which is the per-system output term (roadmap
// item 1). Until that lands this phase has no points to spend and it says so with the
// demand named, rather than inventing a number.
//
// * the ORDERS. On BOTH reference pairs there are none. `turn1-state.sav` and
// `turn2-state.sav` each carry three build queues, one per owned system, and all three
// are empty; every `hbq` is false, so no ship-borne queue exists either; and the only
// `TurnCommands_v5` block is the human's, which is the byte-identical empty one. The
// destroyer the archived turn record counts is built from an order the AI creates
// *during* the turn. So the census leaf `shpt[0]` is blocked on AI order generation, not
// on this phase, and this phase closes nothing on either reference pair by design.
//
// Five of the eleven corpus saves DO carry orders (4, 7, 2, 2 and 1 of them), so the pass is
// exercised the moment the points arrive; the run log reports what it would do on each.
#pragma once
#include <string>
#include <vector>
#include "mars/stream/shapes.h"
namespace sots::app {
struct ConstructionPhaseResult {
int queuesVisited = 0; // systems that carry a build queue at all
int ordersPending = 0; // orders sitting in those queues
int pointsDemanded = 0; // sum of `conleft` over every pending order
int shipsBuilt = 0; // completions this run actually performed
int leafWrites = 0; // save leaves changed (0 while the points are blocked)
int wouldWrite = 0; // leaves a points-fed pass would change
bool blockedOnPoints = false;
std::vector<std::string> notes;
};
// Run the build-queue sub-pass over every system that owns a queue.
//
// `points` is not available from the save, so the pass is driven with zero points and
// reports the demand. Nothing is committed: an order that advanced with no ship behind it
// would leave the save in a state the game never produces, which is worse than not running.
ConstructionPhaseResult RunBuildQueues(mars::stream::shapes::SaveGame& game);
} // namespace sots::app

View file

@ -85,8 +85,12 @@ constexpr PhaseDesc kStrategic[] = {
"upkeep of population carried aboard colony/slaver hulls in transit"},
{Driver::Strategic, 11, "S11", "SystemTurn", PhaseStatus::Partial,
"runs game::sim ProcessColonyTurn per system and commits the parts that need neither the "
"tuning table nor a carrying capacity; plague, growth, resources, slaves, rebellion and "
"the build queue are the sub-passes the model already declares as its input boundary"},
"tuning table nor a carrying capacity; plague, growth, resources, slaves and rebellion "
"are the sub-passes the model still declares as its input boundary. The BUILD QUEUE is "
"modelled and runs (it is the only writer of the per-class built counter in the whole "
"image) but has no points to spend: they come from the per-system output term. It is "
"NOT what the missing destroyer waits on -- no build order exists anywhere in either "
"reference save, so that order is created inside the turn by the AI"},
{Driver::Strategic, 12, "S12", "TradeSliderFinalisation", PhaseStatus::Stub,
"re-normalises the per-system output rates"},
{Driver::Strategic, 13, "S13", "PlayerTurn", PhaseStatus::Partial,
@ -134,26 +138,36 @@ constexpr PhaseDesc kStrategic[] = {
// ServerPlayer::ProcessTurn -- 12 phases, 1..12
// ---------------------------------------------------------------------------------------
constexpr PhaseDesc kPlayer[] = {
{Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Blocked,
"the formula is verified (0 divergences over 4,284 live calls) but one input is not "
"modelled: the money output of each owned system. NOT the same function T31 sums -- the "
"turn path takes ComputeOutput with the system's OWN rate sliders, so its money channel "
"carries the repair pass (which is not side-effect free) and the unspent-industry and "
"unspent-terraforming cascades, none of which are zero once the other channels are "
"funded. Only ComputeBudget's PROJECTED mode uses the max-income form that is now "
"modelled. Evaluated and reported, not committed"},
{Driver::Player, 2, "P02", "ApplyNetToSavings", PhaseStatus::Blocked,
"saturating add of the budget net into savings; blocked behind P01's missing input"},
{Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Partial,
"the formula is verified (0 divergences over 4,284 live calls) and the per-system money "
"input is now modelled on the TURN path -- ComputeOutput with the system's own rate "
"sliders, so the build queue, the ship-repair pass and the infrastructure -> terraform "
"-> money cascade are all live, none of which is the max-income form T31 sums. What is "
"still missing is upstream, not here: S11's civilian growth is not committed, so a "
"colony that grew this turn is priced from its pre-growth population, and the repair "
"demand of damaged ships in orbit is taken as 0. The phase self-checks every run by "
"running the same colonies through the projected path, which the save's own BnkEl "
"states"},
{Driver::Player, 2, "P02", "ApplyNetToSavings", PhaseStatus::Partial,
"saturating add of the budget net into savings, committed. Exact for a player whose "
"colonies did not grow and whose own orders the turn does not change (the independent "
"colony, on both reference pairs); short by the growth for the human, and wrong for an "
"AI whose research rate and target are set by its own orders during the turn (Rung B)"},
{Driver::Player, 3, "P03", "RecordBudgetDerivedFields", PhaseStatus::Blocked,
"trade income, savings-given-away and research-points-given-away land on the turn record "
"and on two player words that are not identified on the wire"},
{Driver::Player, 4, "P04", "ProcessSpecialProjectsSpend", PhaseStatus::Stub,
"special-project spend; the project bodies are opaque on the wire"},
{Driver::Player, 5, "P05", "ProcessResearch", PhaseStatus::Blocked,
"the research slice is verified end to end (35 live calls, 0 divergences) but its "
"allocation comes from P01's budget, so it cannot be driven yet"},
"the research slice is verified end to end (35 live calls, 0 divergences) and P01 now "
"supplies the allocation, but the blocker has MOVED rather than cleared: the only "
"corpus player that reaches this phase with a research target is the AI, and its "
"research rate and target are set by its own orders during the same turn, so the "
"allocation fed in would be wrong. Evaluated and reported, not committed, until AI "
"order generation exists"},
{Driver::Player, 6, "P06", "ResearchRefund", PhaseStatus::Blocked,
"unspent research points converted back to money at the turn's own rate; needs P01 and P05"},
"unspent research points converted back to money at the turn's own rate; needs P05, "
"which is now blocked on the AI's orders rather than on the budget"},
{Driver::Player, 7, "P07", "ClearTimedResearchAccumulators", PhaseStatus::Partial,
"zeroes the three timed-research accumulators that are on the wire; two further words the "
"phase also zeroes are not identified"},

View file

@ -8,6 +8,7 @@
#include <string>
#include "app/alliance.h"
#include "app/construction_phase.h"
#include "app/trade_raid.h"
#include "app/treaty.h"
#include "app/turn_record.h"
@ -97,16 +98,37 @@ struct PlayerPhaseTotals {
int fired[13] = {}; // how many players the phase actually did something for
};
// What the caller has to hand the player driver for P01: the per-system money of every
// owned, non-abandoned system, on the TURN path, and whether the driver may trust it.
struct PlayerBudgetFeed {
std::vector<int> systemIncome;
bool isAI = false;
sim::DifficultyMods difficulty;
bool held = true; // false when a system the player owns could not be found
// The self-check: the same systems run through the PROJECTED path, which is the number
// the save's own `BnkEl` states and lane E1 scored 25/25 against. The two paths are not
// the same function and need not agree -- but on a corpus where every colony is at its
// ideal suitability with full infrastructure and an empty build queue, the whole
// construction channel returns to trade and they should agree to within the trade-point
// rounding. A large delta here is the model failing, and it is visible without a VM.
int projectedIncome = 0;
int turnIncome = 0;
};
void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng,
PlayerPhaseTotals& t) {
// --- P01 ComputeBudget -- blocked on the per-system money output -----------------
// The formula is here and is verified; what is missing is `systemIncome`. We build the
// inputs we do hold so the shape of the gap is visible, then stop.
PlayerPhaseTotals& t, const PlayerBudgetFeed& feed) {
// --- P01 ComputeBudget ------------------------------------------------------------
// The per-system money is `ComputeOutput(s).out[3]`, NOT `ComputeMaxIncome(s)`: the
// turn path runs the system's own sliders, so the build queue, the ship-repair pass and
// the infrastructure -> terraform -> money cascade are all live. See
// sots-re findings/subsystems/output-turn-path.md.
{
sim::BudgetInputs in;
in.savings = p.sav;
in.ownsSystems = !p.owners.empty();
in.maintenance = p.maint;
in.maintenanceDivisor = feed.difficulty.maintenanceDivisor;
in.researchDifficultyMult = feed.difficulty.researchMult;
in.isAI = p.npc;
in.researchRate = p.resRate;
in.resMod = p.resMod;
@ -123,14 +145,19 @@ void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng,
s.fraction = e.xper;
in.expenses.push_back(s);
}
// in.systemIncome stays empty: unmodelled input.
in.systemIncome = feed.systemIncome;
const sim::Budget b = sim::ComputeBudget(in, /*projected=*/false);
const int wouldBe = sim::SaturatingAdd(p.sav, b.net);
++t.fired[1];
if (wouldBe != p.sav) ++t.wouldWrite[2];
if (opt.CommitBlocked("P02")) {
p.sav = wouldBe;
++t.writes[2];
// P02 is the write. It is committed by default now that the money channel is
// modelled; a player whose owned systems could not all be resolved is still
// evaluate-and-report.
if (feed.held || opt.CommitBlocked("P02")) {
if (wouldBe != p.sav) {
p.sav = wouldBe;
++t.writes[2];
}
}
}
@ -320,12 +347,23 @@ bool AddictedTo(const std::vector<mars::stream::shapes::AdctEntry>& a, int speci
return false;
}
// `max(ComputeMaxIncome(s), 0)` for one owned system.
int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const MaxIncomeInputs& ctx) {
// The two terms both money paths share: the output total and everything in the money chain
// except the trade points. `ComputeMaxIncome` and the turn's `ComputeOutput` differ only in
// how they arrive at those trade points.
struct SystemIncomeTerms {
double totalOutputRaw = 0;
sim::SystemMoneyInputs money;
int popSpecies = 0;
double idealSuitability = 0; // the SERVER's per-species baseline (the money cost's ideal)
};
SystemIncomeTerms SystemIncomeTermsFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const MaxIncomeInputs& ctx, double overHarvestRate) {
SystemIncomeTerms out;
// The system's population is credited to the independent race's species when the colony
// has one, otherwise to the owner's. `hindi` is the gate; `indi` is written either way.
const int popSpecies = s.hindi ? s.indi.indsp : owner.species;
out.popSpecies = popSpecies;
const auto species = static_cast<sim::Species>(owner.species);
const std::int64_t resAvail =
@ -333,7 +371,6 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const std::int64_t imperial = static_cast<std::int64_t>(s.pop) + s.pbon;
// --- the output total (lane N's term) ---
double total = 0.0;
if (s.rbfl == 0) {
sim::BaseOutputInputs b;
b.imperialPopulation = imperial;
@ -344,7 +381,7 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
b.resourcesAvailable = resAvail;
b.infra = s.infra;
b.infraBonus = s.ibon;
b.overHarvestRate = 0.0; // the max-income rate vector puts nothing on over-harvest
b.overHarvestRate = overHarvestRate;
b.speciesBaseDemand = sim::ConstantsOf(species).resourceDemand;
b.speciesResourceOutput = sim::ConstantsOf(species).resourceOutput;
sim::OutputModifiers m;
@ -354,10 +391,10 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
m.rebOutMod = owner.rebOutMod;
m.scOutMod = owner.scOutMod;
m.techOutMod = 1.0; // ServerPlayer+0x224, not on the wire
total = sim::TotalSystemOutputRaw(m, ctx.tuning);
out.totalOutputRaw = sim::TotalSystemOutputRaw(m, ctx.tuning);
}
// --- the money chain (this lane's term) ---
// --- the money chain (lane E1's term) ---
sim::PopIncomeRow impRows[sim::kSpeciesCount] = {};
sim::PopIncomeRow civRows[sim::kSpeciesCount] = {};
sim::PopIncomeRow slvRows[sim::kSpeciesCount] = {};
@ -372,7 +409,7 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
impRows[q].addicted = civRows[q].addicted = slvRows[q].addicted = add;
}
sim::SystemMoneyInputs mi;
sim::SystemMoneyInputs& mi = out.money;
mi.popIncomeImperial =
sim::PopulationIncome(sim::PopGroup::Imperial, impRows, true, s.hindi, ctx.tuning);
mi.popIncomeCivilian =
@ -385,12 +422,113 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
mi.serverIncomeMod = ctx.serverIncomeMod;
mi.difficultyIncomeMult =
sim::DifficultyModsFor(owner.aidf, ownerIsAI, owner.npc).incomeMult;
const double ideal = popSpecies >= 0 && popSpecies < static_cast<int>(ctx.idealSuit.size())
? ctx.idealSuit[static_cast<std::size_t>(popSpecies)]
: owner.idealSuit;
mi.suitCostMod =
sim::SuitabilityCostMod(s.suit, ideal, owner.suitTol, owner.rebAI, true, s.vnh);
return sim::SystemMaxIncome(total, mi);
out.idealSuitability = popSpecies >= 0 && popSpecies < static_cast<int>(ctx.idealSuit.size())
? ctx.idealSuit[static_cast<std::size_t>(popSpecies)]
: owner.idealSuit;
mi.suitCostMod = sim::SuitabilityCostMod(s.suit, out.idealSuitability, owner.suitTol,
owner.rebAI, true, s.vnh);
return out;
}
// `ComputeOutput(s).out[3]` -- the money a system contributes to the TURN's budget, as
// opposed to the projected maximum T31 sums. Not clamped: `ComputeBudget` splits a negative
// system into its expense column itself.
//
// One input of the nine this needs is not on the wire and is taken as zero here: the repair
// demand of the owner's damaged ships in orbit, which would need `Ship::RepairCost` over the
// fleets at the system. Every point it would consume is a point that does NOT come back to
// the money channel, so a colony with a damaged fleet reads HIGH.
int SystemTurnMoneyFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const MaxIncomeInputs& ctx) {
const SystemIncomeTerms terms =
SystemIncomeTermsFromWire(s, owner, ownerIsAI, ctx, s.rts.sroh);
sim::IdealSuitabilityInputs isi;
isi.owned = true;
isi.systemSuitability = s.suit;
isi.ownerIdealSuitability = owner.idealSuit;
isi.independent = s.hindi;
isi.serverIdealSuitability = terms.idealSuitability;
isi.systemOverride = s.dsu;
const double ideal = sim::IdealSuitability(isi);
sim::SystemOutputInputs in;
in.rates.trade = s.rts.srt;
in.rates.construction = s.rts.srsc;
in.rates.terraform = s.rts.srtf;
in.rates.infra = s.rts.sri;
// The normaliser's two suppressions, with the predicates the original uses: an EXACT
// equality for suitability and `float32(Infra + ibon) >= 1` for infrastructure.
in.suitAtIdeal = static_cast<double>(s.suit) == ideal;
in.infraFull = sim::F32(static_cast<double>(s.ibon) + s.infra) >= 1.0;
// The leftover split's own infrastructure test reads the RAW Infra against 1.
in.infraExactlyOne = static_cast<double>(s.infra) == 1.0;
in.infra = s.infra;
in.totalOutputRaw = terms.totalOutputRaw;
in.shipyardStations = 0; // StationCount(sys, owner, 1): no corpus system has a station
in.buildQueueDemand = 0;
if (s.bq)
for (const auto& o : s.bq->orders) in.buildQueueDemand += o.conleft;
in.repairDemand = 0; // see the note above
in.terraformPointsNeeded = sim::TerraformPointsNeeded(s.suit, ideal, owner.terraMod);
in.terraformDown = ideal < static_cast<double>(s.suit);
in.terraformMod = owner.terraMod;
in.money = terms.money;
return sim::ComputeSystemOutput(in, ctx.tuning).money;
}
// `max(ComputeMaxIncome(s), 0)` for one owned system.
int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const MaxIncomeInputs& ctx) {
// The max-income rate vector puts nothing on over-harvest, and the two cascade channels
// are provably zero under it, so the trade points are simply the rounded output total.
const SystemIncomeTerms terms = SystemIncomeTermsFromWire(s, owner, ownerIsAI, ctx, 0.0);
return sim::SystemMaxIncome(terms.totalOutputRaw, terms.money);
}
// One `PlayerBudgetFeed` per player, in save order. Built once, from the colony state as it
// stands when the player driver runs -- which is AFTER the per-system turn (the strategic
// driver runs the system turn at phase 11 and the player driver at phase 13), so anything
// `S11` fails to commit is missing from these numbers as well.
std::vector<PlayerBudgetFeed> BuildBudgetFeeds(const SaveGame& game, const TurnOptions& opt) {
MaxIncomeInputs ctx;
ctx.serverIncomeMod = game.sim.incMod;
for (const auto& sp : game.sim.species) ctx.idealSuit.push_back(sp.issu);
std::vector<const Sys*> byId;
std::vector<std::int32_t> ids;
for (const auto& e : game.sim.systems) {
ids.push_back(e.sysID);
byId.push_back(&e.sys);
}
const auto find = [&](std::int32_t id) -> const Sys* {
for (std::size_t i = 0; i < ids.size(); ++i)
if (ids[i] == id) return byId[i];
return nullptr;
};
std::vector<PlayerBudgetFeed> feeds;
feeds.reserve(game.sim.players.size());
for (const auto& pe : game.sim.players) {
const Player& p = pe.player;
PlayerBudgetFeed f;
f.isAI = opt.IsAIPlayer(p.plyrIdx);
f.difficulty = sim::DifficultyModsFor(p.aidf, f.isAI, p.npc);
for (std::int32_t id : p.owners) {
const Sys* s = find(id);
if (!s) {
f.held = false; // a dangling owner id: the sum is incomplete, say so
continue;
}
if (s->abdn) continue; // an abandoned colony is skipped, not counted as zero
const int money = SystemTurnMoneyFromWire(*s, p, f.isAI, ctx);
if (money != 0) f.systemIncome.push_back(money);
f.turnIncome += money;
f.projectedIncome += SystemMaxIncomeFromWire(*s, p, f.isAI, ctx);
}
feeds.push_back(std::move(f));
}
return feeds;
}
void RunUpdateBankruptcyLimits(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec) {
@ -810,11 +948,43 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
rec.notes.push_back(fmt("%d system(s) left their countdown words alone: the "
"companion active-player mask is not identified",
st.skippedCountdown));
// The build-queue sub-pass reports on its own line rather than folding its
// numbers into S11's, because what blocks it is not what blocks the rest of
// the colony turn. See app/construction_phase.h.
{
const ConstructionPhaseResult b = RunBuildQueues(game);
rec.wouldWrite += b.wouldWrite;
for (const auto& n : b.notes) rec.notes.push_back("build queue: " + n);
}
break;
}
case 13: { // S13 PlayerTurn -- the nested driver
for (auto& e : game.sim.players)
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt);
const std::vector<PlayerBudgetFeed> feeds = BuildBudgetFeeds(game, opt);
std::size_t fi = 0;
int fed = 0, agree = 0, worst = 0;
for (auto& e : game.sim.players) {
const PlayerBudgetFeed& f = feeds[fi++];
if (!f.systemIncome.empty()) ++fed;
else {
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt, f);
continue;
}
const int d = f.turnIncome - f.projectedIncome;
if (d == 0) ++agree;
if (d > worst || -d > worst) worst = d < 0 ? -d : d;
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt, f);
}
rec.notes.push_back(fmt(
"%d player(s) had a non-empty per-system money roll-up on the TURN path "
"(ComputeOutput, not ComputeMaxIncome); the ship-repair demand of damaged "
"ships in orbit is taken as 0 and S11's civilian growth is not committed, "
"so a colony that grew this turn is priced from its pre-growth population",
fed));
rec.notes.push_back(fmt(
"turn path vs projected path on the same colony state: %d of %d landed "
"players agree exactly, worst |delta| %d money (the projected sum is what "
"the save's own BnkEl states, so this is a check without a VM)",
agree, fed, worst));
rec.invocations = static_cast<int>(game.sim.players.size());
for (int k = 1; k <= 12; ++k) {
rec.leafWrites += pt.writes[k];

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

@ -4,6 +4,7 @@
# sources with plain g++ in the meantime.
add_library(sots_game_sim STATIC
economy.cpp
construction.cpp
research.cpp
colony.cpp
movement.cpp
@ -18,7 +19,7 @@ endif()
option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF)
if(SOTS_GAME_SIM_TESTS)
enable_testing()
set(_sim_tests economy research colony movement techgraph visibility)
set(_sim_tests economy research colony movement techgraph visibility construction)
foreach(_t IN LISTS _sim_tests)
add_executable(game_sim_test_${_t} ${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_sim/test_${_t}.cpp)
target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim)

View file

@ -389,7 +389,12 @@ OutputSplit SplitOutput(double total, const OutputRates& rates) {
int ConstructionPoints(double constructionShare, int stations, const TuningTable& t) {
// Truncating, not rounding -- this slot goes through the float-to-int helper.
return Ftol(constructionShare * (1.0 + t.STATION_BONUS_SHIPCON * stations));
// C3 correction, from the instruction stream of 0x00746830: the bonus is ignored unless
// it is STRICTLY positive (the same unloaded-table guard the output term carries), and
// the association is `k x (b x cons) + cons`, not `cons x (1 + b x k)`. Both differences
// are invisible while no system has a shipyard station, which is the whole corpus.
const double b = t.STATION_BONUS_SHIPCON > 0.0 ? t.STATION_BONUS_SHIPCON : 0.0;
return Ftol(static_cast<double>(stations) * (b * constructionShare) + constructionShare);
}
OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAtIdeal,
@ -406,7 +411,9 @@ OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAt
wf = rates.terraform;
wi = rates.infra;
}
const double sum = wt + wf + wi;
// C3 correction: the original accumulates `wi + (wf + wt)` on the x87 stack, in that
// association. Reordering it is not free in floating point.
const double sum = wi + (wf + wt);
OutputSplit s;
if (sum <= 0 || leftover <= 0) {
s.trade = std::max(0.0, leftover);
@ -488,6 +495,87 @@ int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in) {
return money > 0 ? money : 0;
}
double IdealSuitability(const IdealSuitabilityInputs& in) {
if (!in.owned) return in.systemSuitability;
double v = in.ownerIdealSuitability;
if (in.independent) v = in.serverIdealSuitability;
// An `!=` against the sentinel, so a NaN override would also win. Nothing in the corpus
// exercises either side of that.
if (in.systemOverride != kIdealSuitabilityNoOverride) v = in.systemOverride;
return v;
}
RepairPassResult RepairShipsInOrbit(int points, int repairDemand) {
RepairPassResult r;
if (points <= 0 || repairDemand <= 0) {
r.left = points;
return r;
}
r.spent = points < repairDemand ? points : repairDemand;
r.left = points - r.spent;
return r;
}
SystemOutput ComputeSystemOutput(const SystemOutputInputs& in, const TuningTable& t) {
SystemOutput o;
const OutputRates r = NormaliseOutputRates(in.rates, in.suitAtIdeal, in.infraFull);
o.normalisedRates = r;
// One rounding of the total, then one rounding per channel off that same value.
const double total = RoundHalfEven(in.totalOutputRaw);
o.totalOutput = Ftol(total);
const OutputSplit split = SplitOutput(total, r);
o.tradePoints = split.trade;
// --- construction: the queue first, then the repair pass -----------------------------
o.construction = ConstructionPoints(split.construction, in.shipyardStations, t);
o.constructionToQueue =
in.buildQueueDemand < o.construction ? in.buildQueueDemand : o.construction;
int rem = o.construction - o.constructionToQueue;
if (rem < 0) rem = 0;
if (rem > 0) {
const RepairPassResult rep = RepairShipsInOrbit(rem, in.repairDemand);
o.constructionToRepair = rep.spent;
rem = rep.left;
}
// --- the leftover redistribution -----------------------------------------------------
// `SplitLeftover`'s `infraFull` argument is the RAW `Infra == 1` test, not the
// `Infra + ibon >= 1` one the normaliser used.
const OutputSplit left =
rem > 0 ? SplitLeftover(static_cast<double>(rem), r, in.suitAtIdeal, in.infraExactlyOne)
: OutputSplit{};
o.leftoverToTrade = left.trade;
// --- infrastructure ------------------------------------------------------------------
const double infraNeed = std::ceil((1.0 - in.infra) / 3.3e-5);
const double poolInfra = left.infra + split.infra;
const double spendInfra = poolInfra < infraNeed ? poolInfra : infraNeed;
double leftInfra = poolInfra - spendInfra;
if (!(leftInfra > 0.0)) leftInfra = 0.0;
// Three separate 80-bit steps, not one x3.3e-5.
const double infraGain = spendInfra / 500.0 * 0.01 * 1.65;
o.infraDelta = F32(infraGain > 0.0 ? infraGain : 0.0);
// --- terraforming: the infrastructure leftover lands in THIS pool ---------------------
const double terraNeed = std::ceil(in.terraformPointsNeeded);
const double poolTerra = (left.terraform + split.terraform) + leftInfra;
const double spendTerra = poolTerra < terraNeed ? poolTerra : terraNeed;
double leftTerra = poolTerra - spendTerra;
if (!(leftTerra > 0.0)) leftTerra = 0.0;
o.leftoverToMoney = leftTerra;
// The same helper the colony pass uses; its sign test is `suit > ideal`, which is the
// original's `IdealSuitability() < Suit` with the operands swapped.
o.suitabilityDelta = TerraformDelta(spendTerra, in.terraformMod,
in.terraformDown ? 1.0 : 0.0, 0.0);
// --- money ----------------------------------------------------------------------------
SystemMoneyInputs m = in.money;
m.tradePoints = (o.leftoverToTrade + o.tradePoints) + leftTerra;
o.money = SystemMoneyIncome(m);
return o;
}
BonusApplyResult ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity,
std::int64_t& pendingBonus, bool owned, bool homeSystem) {
BonusApplyResult r;
@ -541,7 +629,14 @@ void AccrueSystemBonus(const SystemBonusInputs& in, std::int64_t& popBonus, doub
BuildQueueResult ProcessBuildQueue(std::vector<BuildOrder>& queue, int points) {
BuildQueueResult r;
if (points > 0) {
if (points <= 0) {
// The whole body is skipped, REMOVAL SWEEP INCLUDED: the entry test branches
// straight to the epilogue, which returns the points untouched. Corrected by lane
// B6 from the instruction stream; see the header for why no save can show it.
r.pointsLeft = points;
return r;
}
{
for (BuildOrder& o : queue) {
if (o.constructionLeft > points) {
o.constructionLeft -= points;

View file

@ -520,6 +520,126 @@ int SystemMoneyIncome(const SystemMoneyInputs& in);
// for zero science points, which is INFERRED rather than read.
int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in);
// ---------------------------------------------------------------------------------------
// The turn path: `ServerSystem::ComputeOutput`
// ---------------------------------------------------------------------------------------
//
// `ComputeBudget` has two modes and they take a system's money from DIFFERENT functions.
// Projected mode calls `ComputeMaxIncome` (SystemMaxIncome above). The turn calls
// `ComputeOutput`, which runs `ComputeOutputFromRates` with the system's OWN stored sliders,
// so the construction, terraform and infrastructure channels are funded and three edges that
// are provably dead under the max-income vector are live:
//
// * the build queue and the ship-repair pass consume construction points;
// * whatever they leave over is redistributed across trade / terraform / infrastructure
// and the TRADE share is added to the money channel;
// * unspent infrastructure points cascade into the terraform pool, and unspent terraform
// points cascade into the money channel -- two hops, not one.
//
// See sots-re findings/subsystems/output-turn-path.md. Note that the field the earlier
// income-term note called "science" is the SHIP CONSTRUCTION slider; there is no science
// channel in this function (research is bought with money at the empire level).
// `ServerSystem::IdealSuitability` (0x00745d60) -- the suitability the terraform channel
// aims at, and the `==` the rate normaliser tests against.
// unowned -> the system's own suitability (so it is "at its ideal")
// independent colony -> the SERVER's per-species baseline for `indi->indsp`
// otherwise -> the OWNER's own `IdealSuit` field
// and a system-level `dsu` override wins over all three when it is not the sentinel.
// CONFIDENCE: high on the branch order. The sentinel is FLT_MAX: that is INFERRED from the
// corpus (every system carries exactly FLT_MAX there) rather than read out of the data files.
constexpr double kIdealSuitabilityNoOverride = 3.4028234663852886e+38; // FLT_MAX
struct IdealSuitabilityInputs {
bool owned = true;
double systemSuitability = 0; // sys.Suit
double ownerIdealSuitability = 0; // owner's IdealSuit field
bool independent = false; // sys.hindi
double serverIdealSuitability = 0; // server->IdealSuit[indi.indsp], independent only
double systemOverride = kIdealSuitabilityNoOverride; // sys.dsu
};
double IdealSuitability(const IdealSuitabilityInputs& in);
// C3 note on `TerraformPointsNeeded` above (0x00746890): the original does NOT round -- the
// `ceil` belongs to `ComputeOutputFromRates`, which applies it to the returned double. Our
// version folds the `ceil` in, which is harmless because `ceil` is idempotent and every
// caller applies it, but the boundary is worth stating. The sign multiply inside the divisor
// is cancelled by a `fabs`, so the result is always >= 0, and a zero `TerraMod` yields +inf,
// which makes the terraform channel absorb its whole pool with nothing cascading to money.
// That branch is UNEXERCISED -- no corpus player carries TerraMod 0.
// `ServerSystem::RepairShipsInOrbit` (0x00751590) -- **the side effect** that makes
// `ComputeOutputFromRates` unsafe to call for its value. It hands each damaged ship of the
// owner's fleets at the system a share of the construction points left over after the build
// queue, round-robin, and returns what is left.
//
// The round robin is EQUIVALENT to `points - min(points, demand)` and this is a proof rather
// than an observation: the per-pass share is `max(points / shipCount, 1)`, so every ship with
// a positive remaining cost takes at least one point per pass, and the loop's only early exit
// requires every remaining cost to be zero. So it ends either with the points exhausted or
// with the demand met. CONFIDENCE: high; the equivalence is pinned by a test.
struct RepairPassResult {
int spent = 0;
int left = 0;
};
RepairPassResult RepairShipsInOrbit(int points, int repairDemand);
struct SystemOutputInputs {
// --- the rate vector, exactly as the system stores it (NOT normalised) ---
OutputRates rates;
// The two suppressions the normaliser applies. `suitAtIdeal` is an exact `==` against
// IdealSuitability(); `infraFull` is `float32(Infra + ibon) >= 1`.
bool suitAtIdeal = false;
bool infraFull = false;
// The leftover-weight test reads the RAW `Infra` against 1.0 and does NOT add the pending
// bonus, so it is a different predicate from `infraFull` and is carried separately.
bool infraExactlyOne = false;
// --- the output total, unrounded (lane N's TotalSystemOutputRaw) ---
double totalOutputRaw = 0;
// --- construction ---
int shipyardStations = 0; // StationCount(sys, owner, 1)
int buildQueueDemand = 0; // sum of `conleft` over the system's build queue
// Sum of `Ship::RepairCost` over the owner's damaged ships in orbit. NOT modelled from
// the wire anywhere yet; a caller that cannot compute it must leave it 0 and say so.
int repairDemand = 0;
// --- infrastructure ---
double infra = 0; // sys.Infra, for `ceil((1 - Infra) / 3.3e-5)`
// --- terraforming ---
double terraformPointsNeeded = 0; // TerraformPointsNeeded(...)
bool terraformDown = false; // IdealSuitability() < sys.Suit
double terraformMod = 1.0; // owner's TerraMod
// --- money: every field except `tradePoints`, which this function computes ---
SystemMoneyInputs money;
};
struct SystemOutput {
int totalOutput = 0; // out[0], truncated
int money = 0; // out[3] <- the ONLY slot ComputeBudget reads
int construction = 0; // out[7]
int constructionToQueue = 0; // out[8]
int constructionToRepair = 0; // out[9]
double infraDelta = 0; // out[10], a float32
double suitabilityDelta = 0; // out[11], a float32
// Reported so a caller can see which edges actually carried anything.
double tradePoints = 0; // round(total x SRt)
double leftoverToTrade = 0; // the construction leftover's trade share
double leftoverToMoney = 0; // the terraform leftover that reached the money channel
OutputRates normalisedRates;
};
// `ServerSystem::ComputeOutput` restricted to the channels the campaign has models for.
// out[1], out[2], out[4], out[5] and out[6] -- the resource ledger, the trade-route income
// pair and the repair demand -- are NOT produced here: none of them feeds `out[3]`, they have
// their own inputs, and inventing them would be coverage theatre.
// CONFIDENCE: high on the channel algebra and the rounding sites (every one read off the
// instruction stream). The repair spend is only as good as `repairDemand`.
SystemOutput ComputeSystemOutput(const SystemOutputInputs& in, const TuningTable& t);
// ---------------------------------------------------------------------------------------
// System bonus and build queue
// ---------------------------------------------------------------------------------------
@ -599,6 +719,13 @@ struct BuildQueueResult {
// including ones that were already at zero before this turn. More than one order can
// complete in a turn. CONFIDENCE: high -- B4 corrected the money-refusal path (continue, not
// stop), the removal sweep's predicate, and that the leftover is the return value.
//
// The `points <= 0` case skips **everything**, the removal sweep included: the entry test
// branches to the epilogue, which returns the argument. Corrected by lane B6 from the
// instruction stream, and carried as a LABELLED HYPOTHESIS rather than a result because no
// save can exercise it: an order can only reach `conleft <= 0` inside this pass, and this
// pass erases it before returning, so a queue never *starts* a turn with one -- unless a
// design with zero construction cost is ever queued, which no corpus save has done.
BuildQueueResult ProcessBuildQueue(std::vector<BuildOrder>& queue, int points);
// ---------------------------------------------------------------------------------------

View file

@ -0,0 +1,66 @@
#include "game/sim/construction.h"
#include <algorithm>
#include <map>
namespace sots::sim {
ShipDesignRecord& FindOrAppendDesignRecord(ShipRecords& r, int designKey, int hullClass) {
for (ShipDesignRecord& d : r.designs)
if (d.designKey == designKey) return d;
ShipDesignRecord fresh;
fresh.designKey = designKey;
fresh.hullClass = hullClass;
// The original zeroes the remaining three words at the append site; the default member
// initialisers already do that, and they are spelled out here so the append's shape is
// visible next to the read of it.
fresh.built = 0;
fresh.lost = 0;
fresh.inService = 0;
r.designs.push_back(fresh);
return r.designs.back();
}
void RecordShipBuilt(ShipRecords& r, int designKey, int hullClass) {
if (hullClass >= 0 && hullClass < kHullClassCount) ++r.built[hullClass];
++FindOrAppendDesignRecord(r, designKey, hullClass).built;
}
SystemConstructionResult RunSystemConstruction(std::vector<BuildOrder>& queue, int points) {
SystemConstructionResult out;
out.pointsIn = points;
out.ordersBefore = static_cast<int>(queue.size());
// The design an order names is lost once the order is unlinked, so it is captured here.
// Order ids are unique within a queue in every save observed; a duplicate would make the
// last one win, which is why the map is built before the pass rather than after it.
std::map<int, int> designOfOrder;
for (const BuildOrder& o : queue) designOfOrder[o.orderId] = o.designId;
// Whether any order will absorb the remaining points and stop the pass. Recomputed from
// the queue rather than inferred from the result, so it is reported even when the stop
// happens on the first order.
const BuildQueueResult r = ProcessBuildQueue(queue, points);
out.pointsLeft = r.pointsLeft;
out.pointsSpent = out.pointsIn - r.pointsLeft;
out.moneyCharged = r.moneyCharged;
out.ordersAfter = static_cast<int>(queue.size());
out.ordersRemoved = out.ordersBefore - out.ordersAfter;
out.sweepRan = points > 0;
// Points went in, some were spent, and none came back out: the pass stopped inside an
// order rather than running off the end of the queue.
out.advancedPartially = points > 0 && r.pointsLeft == 0 && !queue.empty();
out.completed.reserve(r.completedOrderIds.size());
for (int id : r.completedOrderIds) {
Completion c;
c.orderId = id;
const auto it = designOfOrder.find(id);
c.designId = it == designOfOrder.end() ? 0 : it->second;
out.completed.push_back(c);
}
return out;
}
} // namespace sots::sim

146
src/game/sim/construction.h Normal file
View file

@ -0,0 +1,146 @@
// game::sim -- ship construction: what a completed build order writes.
//
// The point-consuming half of the pass lives in colony.h as `ProcessBuildQueue`, because
// that is where the colony turn's other point channels live. This file holds the half that
// runs *per completed order*: the player's `ShipRecords`, which is the only thing a
// completion writes that the save can see without also creating the ship.
//
// WHERE THIS RUNS
// ---------------
// `StrategyServer::ProcessTurn` phase 11 -> `ServerSystem::ProcessTurn` ->
// `ServerSystem::ProcessBuildQueue` -> `BuildQueue::ProcessTurn`. A second caller exists
// (the ship-borne queue reached from the ship-action dispatcher, i.e. a construction ship
// building a station), and it reuses the same function.
//
// WHAT A COMPLETION WRITES, in the order the original writes it
// -------------------------------------------------------------
// 1. the ship is created and attached, and the new ships of the pass are collected into a
// vector that is handed to the fleet-forming step after the loop;
// 2. `ShipRecords.built[hullClass]` is incremented -- indexed by the design's cached hull
// class ordinal, stride 4;
// 3. the per-design record whose key equals the design's object id is found, appended if
// absent, and its own `built` field is incremented;
// 4. a build-completed event is pushed onto the system's event list;
// 5. `points -= conleft`, `conleft = 0`.
//
// Step 2 has EXACTLY ONE writer in the whole executable -- an image-wide scan for the
// indexed increment at that displacement returns one site, inside this function. So a ship
// that reaches the wire with the per-class counter bumped came through this pass and no
// other. (Losses, kills and in-service are three further parallel arrays with the same
// stride; nothing increments them here, and nothing in the save corpus is ever non-zero for
// losses or kills, so they carry no model.)
//
// The record layout is settled by ENUMERATION, not by what this function touches: the wire
// writes `srnc` groups of {srb, srl, srk, sri} followed by `srbd` records of
// {srd, src, srb, srl, sri}, and the class array's base plus four arrays of three ints lands
// exactly on the per-design vector's first word. Three classes, four arrays, then the
// vector.
#pragma once
#include <cstdint>
#include <vector>
#include "game/sim/colony.h"
namespace sots::sim {
// Destroyer / cruiser / dreadnought. The wire's `srnc` is 3 in every save in the corpus.
constexpr int kHullClassCount = 3;
// One element of the second counted section (`srbd`). `src` is the design's hull class and
// is written once, when the record is appended; a later completion of the same design only
// touches `built`.
struct ShipDesignRecord {
int designKey = 0; // srd -- the design's OBJECT id, not its index
int hullClass = 0; // src
int built = 0; // srb
int lost = 0; // srl
int inService = 0; // sri
};
// Game::ShipRecords, held inline in the player.
struct ShipRecords {
int built[kHullClassCount] = {}; // srb
int lost[kHullClassCount] = {}; // srl
int killed[kHullClassCount] = {}; // srk
int inService[kHullClassCount] = {}; // sri
std::vector<ShipDesignRecord> designs;
};
// Linear search for `designKey` over the per-design vector, appending a fresh record when
// there is no hit. The search is a plain forward scan and the append is a push_back, so the
// vector's order is first-seen and is load-bearing for the wire.
// CONFIDENCE: high -- read instruction by instruction, including the append's field order.
ShipDesignRecord& FindOrAppendDesignRecord(ShipRecords& r, int designKey, int hullClass);
// One completed hull: bump the class counter and the design record's own counter.
// A hull class outside [0, kHullClassCount) leaves the class array alone -- the original
// indexes it unchecked, so an out-of-range class is a corrupt design, not a policy.
// CONFIDENCE: high on both increments; the guard is ours.
void RecordShipBuilt(ShipRecords& r, int designKey, int hullClass);
// ---------------------------------------------------------------------------------------
// One system's construction pass
// ---------------------------------------------------------------------------------------
struct Completion {
int orderId = 0;
int designId = 0;
};
struct SystemConstructionResult {
std::vector<Completion> completed;
int pointsIn = 0;
int pointsLeft = 0; // the original's return value
int pointsSpent = 0; // pointsIn - pointsLeft
int moneyCharged = 0;
int ordersBefore = 0;
int ordersAfter = 0;
int ordersRemoved = 0; // completed here, plus any that were already at or below zero
bool advancedPartially = false; // an order absorbed everything and stopped the pass
bool sweepRan = false; // false when points <= 0: the whole body is skipped
};
// FIFO consumption with the design id of every completion kept, which the queue pass alone
// does not report. `queue` is modified in place exactly as the original modifies the list.
// CONFIDENCE: high -- see colony.h's ProcessBuildQueue for the rules and their evidence.
SystemConstructionResult RunSystemConstruction(std::vector<BuildOrder>& queue, int points);
// ---------------------------------------------------------------------------------------
// The hull and the fleet at birth -- READ, NOT YET IMPLEMENTED
// ---------------------------------------------------------------------------------------
//
// This is written down here rather than coded because the newborn hull copies four cached
// stat words out of its design, and those words are recomputed by the design's own stats
// pass, which this engine models only far enough to get a hull class. Coding it now would be
// writing fields we cannot compute. The whole chain is read instruction by instruction in
// the RE repo; the shape, for the lane that gets the design stats:
//
// THE SHIP. 176 bytes. The object id comes from the network-node id allocator --
// `(counter << 4) | (node & 0xF)`, per-node counters, PRE-incremented, never issuing 0 --
// and is written into the object by the id-map insert, not by the constructor. At birth:
// the owner is the queue's owner and the design is the order's design; range, health,
// construction capacity, refuel capacity and repair capacity are copied from the design's
// cached stat words; the plague and one other word are -1 and everything else is zero.
// Two fields matter to a reimplementation:
// * the FLEET LINK IS NULL at birth and is set by the fleet-join step, not here;
// * the TURN-BUILT stamp is the sim's frame counter, i.e. the number of the turn being
// produced -- MEASURED: the six hulls the zuul frame-16 -> frame-17 pair adds all
// carry the NEW turn number, which is independent evidence that the frame counter is
// incremented before the spine runs.
//
// THE FLEET. 288 bytes. A system caches ONE home fleet; every hull built there joins it,
// and a fleet is created only when that cache is empty. So a second turn of building at
// the same system creates no fleet. A created fleet takes an id from the same allocator,
// is born at the system's position, and always carries flag 0x400 -- which is NOT a
// retreat marker (that reading is corrected) and is NOT the design flag of the same
// numeral that game/design/hull.h warns about. The build path adds 0x20 on top. There is
// NO scalar ship count: the wire's count is the length of the fleet's ship vector.
// A fleet created with no name override asks the player's name generator, which is what
// bumps the generator's counter -- one bump per generated name.
//
// Ships are also created OUTSIDE this pass, by the trade manager's encounter spawner. That
// path touches no ShipRecords at all, which is what makes the per-class built counter a
// clean discriminator between player-built hulls and spawned ones.
} // namespace sots::sim

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

View file

@ -1,5 +1,5 @@
# game/sim tests: four hand-computed suites + a real-save smoke test (skips unless SOTS_SAVES_JSON).
foreach(_t economy research colony movement techgraph visibility)
foreach(_t economy research colony movement techgraph visibility construction)
add_executable(game_sim_test_${_t} test_${_t}.cpp)
target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim)
target_include_directories(game_sim_test_${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})

View file

@ -559,6 +559,219 @@ static void test_max_income() {
}()));
}
// ---------------------------------------------------------------------------------------
// The turn path: ComputeOutput
// ---------------------------------------------------------------------------------------
static void test_ideal_suitability() {
IdealSuitabilityInputs in;
in.owned = false;
in.systemSuitability = 7.5;
in.ownerIdealSuitability = 11.0;
// An unowned system reports its OWN suitability, which is what makes it "at its ideal"
// and suppresses the terraform channel.
CHECK_NEAR(IdealSuitability(in), 7.5, 0.0);
in.owned = true;
CHECK_NEAR(IdealSuitability(in), 11.0, 0.0);
in.independent = true;
in.serverIdealSuitability = 9.25;
CHECK_NEAR(IdealSuitability(in), 9.25, 0.0);
// The per-system override beats both, and the sentinel is FLT_MAX.
in.systemOverride = 3.0;
CHECK_NEAR(IdealSuitability(in), 3.0, 0.0);
in.systemOverride = kIdealSuitabilityNoOverride;
CHECK_NEAR(IdealSuitability(in), 9.25, 0.0);
}
static void test_terraform_points() {
// A planet at its ideal needs nothing, whichever direction it would move.
CHECK_NEAR(TerraformPointsNeeded(10.0, 10.0, 1.0), 0.0, 0.0);
// The rate is TerraMod x 1.8f / 20000, and the sign cancels: the count is the same
// whether the planet is above or below the ideal.
const double up = TerraformPointsNeeded(9.0, 10.0, 1.0);
const double down = TerraformPointsNeeded(11.0, 10.0, 1.0);
CHECK_NEAR(up, down, 0.0);
// Our helper folds in the `ceil` that ComputeOutputFromRates applies to the original's
// return value, so the expected numbers are the ceilings.
CHECK_NEAR(up, std::ceil(1.0 / (1.8000000715255737 / 20000.0)), 0.0); // 11112
// A bigger TerraMod needs proportionally fewer points.
CHECK_NEAR(TerraformPointsNeeded(9.0, 10.0, 3.7),
std::ceil(1.0 / (3.7 * 1.8000000715255737 / 20000.0)), 0.0); // 3004
}
static void test_repair_pass() {
// The round robin's outcome, as a min. Every case the loop can reach:
RepairPassResult r = RepairShipsInOrbit(100, 0); // nothing damaged
CHECK_EQ(r.spent, 0);
CHECK_EQ(r.left, 100);
r = RepairShipsInOrbit(100, 40); // points win
CHECK_EQ(r.spent, 40);
CHECK_EQ(r.left, 60);
r = RepairShipsInOrbit(40, 100); // demand wins; nothing cascades
CHECK_EQ(r.spent, 40);
CHECK_EQ(r.left, 0);
r = RepairShipsInOrbit(0, 100); // the early return
CHECK_EQ(r.spent, 0);
CHECK_EQ(r.left, 0);
}
// A helper matching the corpus's shape: at the ideal, infrastructure exactly 1, no station,
// so the terraform and infrastructure channels are suppressed and both needs are zero.
static SystemOutputInputs CorpusColony(double trade, double construction, double total) {
SystemOutputInputs in;
in.rates.trade = trade;
in.rates.construction = construction;
in.suitAtIdeal = true;
in.infraFull = true;
in.infraExactlyOne = true;
in.infra = 1.0;
in.totalOutputRaw = total;
in.terraformPointsNeeded = 0.0;
return in;
}
static void test_turn_path_output() {
const TuningTable t; // unloaded: no station bonus, which the corpus never exercises
// 1. The claim the whole lane turns on: with an empty build queue and nothing to repair,
// every construction point comes back to the money channel, so a colony that puts
// everything into ship construction earns exactly as much as one that puts everything
// into trade.
{
const SystemOutput allTrade = ComputeSystemOutput(CorpusColony(1.0, 0.0, 4000.0), t);
const SystemOutput allCons = ComputeSystemOutput(CorpusColony(0.0, 1.0, 4000.0), t);
const SystemOutput half = ComputeSystemOutput(CorpusColony(0.5, 0.5, 4000.0), t);
CHECK_EQ(allCons.money, allTrade.money);
CHECK_EQ(half.money, allTrade.money);
// and the leftover really is what carries it on the construction colony
CHECK_NEAR(allCons.tradePoints, 0.0, 0.0);
CHECK_NEAR(allCons.leftoverToTrade, 4000.0, 0.0);
CHECK_EQ(allCons.construction, 4000);
}
// 2. ... which makes it equal to the PROJECTED path, up to the trade-point rounding.
// An even total agrees exactly; an odd one can differ by one trade point because
// `2 x round(T/2)` is not `T`.
{
SystemMoneyInputs m;
const SystemOutput even = ComputeSystemOutput(CorpusColony(0.5, 0.5, 4000.0), t);
CHECK_EQ(even.money, SystemMaxIncome(4000.0, m));
const SystemOutput odd = ComputeSystemOutput(CorpusColony(0.5, 0.5, 4001.0), t);
// 4001 x 0.5 = 2000.5, ties to even -> 2000 twice, so 4000 trade points, not 4001.
CHECK_NEAR(odd.tradePoints + odd.leftoverToTrade, 4000.0, 0.0);
}
// 3. The build queue eats construction points BEFORE the leftover is redistributed, so a
// funded queue is a direct loss of money.
{
SystemOutputInputs in = CorpusColony(0.0, 1.0, 4000.0);
in.buildQueueDemand = 1500;
const SystemOutput o = ComputeSystemOutput(in, t);
CHECK_EQ(o.constructionToQueue, 1500);
CHECK_NEAR(o.leftoverToTrade, 2500.0, 0.0);
// a queue larger than the output takes all of it and leaves nothing
in.buildQueueDemand = 999999;
const SystemOutput starved = ComputeSystemOutput(in, t);
CHECK_EQ(starved.constructionToQueue, 4000);
CHECK_NEAR(starved.leftoverToTrade, 0.0, 0.0);
CHECK_EQ(starved.money, 0);
}
// 4. The repair pass takes its share after the queue and before the redistribution.
{
SystemOutputInputs in = CorpusColony(0.0, 1.0, 4000.0);
in.buildQueueDemand = 1000;
in.repairDemand = 700;
const SystemOutput o = ComputeSystemOutput(in, t);
CHECK_EQ(o.constructionToQueue, 1000);
CHECK_EQ(o.constructionToRepair, 700);
CHECK_NEAR(o.leftoverToTrade, 2300.0, 0.0);
}
// 5. The two cascades, which the max-income path proves ARE zero and this one does not.
// A colony below full infrastructure with a funded infra channel spends what it needs
// and passes the rest to terraforming; terraforming passes ITS rest to money.
{
SystemOutputInputs in;
in.rates.trade = 0.0;
in.rates.construction = 0.0;
in.rates.infra = 1.0;
in.suitAtIdeal = true; // so the terraform channel is suppressed and needs 0
in.infraFull = false;
in.infraExactlyOne = false;
in.infra = 1.0 - 3.3e-5 * 100.0; // exactly 100 points short of full
in.totalOutputRaw = 4000.0;
in.terraformPointsNeeded = 0.0;
const SystemOutput o = ComputeSystemOutput(in, t);
// 100 points close the infrastructure gap, the other 3900 fall through terraforming
// (which needs nothing) into the money channel.
CHECK_NEAR(o.leftoverToMoney, 3900.0, 1e-6);
CHECK(o.infraDelta > 0.0);
SystemMoneyInputs m;
m.tradePoints = 3900.0;
CHECK_EQ(o.money, SystemMoneyIncome(m));
}
// 6. A terraforming colony consumes what it needs and cascades the rest, and the sign of
// the suitability delta follows the direction of travel.
{
SystemOutputInputs in;
in.rates.terraform = 1.0;
in.suitAtIdeal = false;
in.infraFull = true;
in.infraExactlyOne = true;
in.infra = 1.0;
in.totalOutputRaw = 4000.0;
in.terraformPointsNeeded = 250.0;
in.terraformMod = 1.0;
const SystemOutput up = ComputeSystemOutput(in, t);
CHECK_NEAR(up.leftoverToMoney, 3750.0, 1e-6);
CHECK(up.suitabilityDelta > 0.0);
in.terraformDown = true;
const SystemOutput down = ComputeSystemOutput(in, t);
CHECK_NEAR(down.suitabilityDelta, -up.suitabilityDelta, 0.0);
// The point count and the point value use the same rate, so spending exactly the
// needed points closes exactly the gap it was computed from.
const double gap = 250.0 * (1.5 * kTerraform12 * 1.0) / 20000.0;
CHECK_NEAR(up.suitabilityDelta, static_cast<double>(static_cast<float>(gap)), 0.0);
}
// 7. The `SRsc == 1` leftover branch really is a different rule: with construction at
// exactly 1 the weights become 1 / (suit off ideal) / (infra below 1) rather than the
// sliders, so a colony that is off its ideal sends HALF its leftover to terraforming
// instead of all of it to trade.
{
SystemOutputInputs in;
in.rates.construction = 1.0;
in.suitAtIdeal = false; // terraform weight 1
in.infraFull = true; // infra suppressed by the normaliser ...
in.infraExactlyOne = true; // ... and weight 0 in the leftover split
in.infra = 1.0;
in.totalOutputRaw = 4000.0;
in.terraformPointsNeeded = 0.0; // nothing to spend it on, so it cascades to money
const SystemOutput o = ComputeSystemOutput(in, t);
CHECK_NEAR(o.normalisedRates.construction, 1.0, 0.0);
CHECK_NEAR(o.leftoverToTrade, 2000.0, 0.0);
CHECK_NEAR(o.leftoverToMoney, 2000.0, 0.0);
// Both halves reach the money channel here, so the total is the same as if it had
// all gone to trade -- the split matters only when terraforming has work to do.
SystemMoneyInputs m;
m.tradePoints = 4000.0;
CHECK_EQ(o.money, SystemMoneyIncome(m));
}
// 8. An unfunded channel produces nothing, and a total of zero produces no money.
{
const SystemOutput o = ComputeSystemOutput(CorpusColony(0.5, 0.5, 0.0), t);
CHECK_EQ(o.totalOutput, 0);
CHECK_EQ(o.construction, 0);
CHECK_EQ(o.money, 0);
}
}
static void test_difficulty_table() {
// Level 0 gives the break to the human; levels 1 and 2 give it to the AI.
const DifficultyMods e_ai = DifficultyModsFor(0, true, false);
@ -714,6 +927,10 @@ int main() {
test_system_money();
test_population_income();
test_max_income();
test_ideal_suitability();
test_terraform_points();
test_repair_pass();
test_turn_path_output();
test_difficulty_table();
test_bonuses();
test_build_queue();

View file

@ -0,0 +1,244 @@
// Ship construction: the completion bookkeeping, and the two rules of the pass that the
// corpus can and cannot show.
//
// The corpus fixtures at the bottom are NOT invented. They are the build queues of
// `zuul-turn16-noderoute.sav` and the queues the same game carries one turn later in
// `zuul-turn17-rollpending.sav` (frames 16 and 17, a genuine consecutive-turn pair). The
// test does not assume the point totals: it SOLVES for them, and the solve is the
// falsification -- a non-FIFO order, a per-order point budget, or a "skip and continue"
// rule instead of "stop at the first order that cannot finish" each make the solve fail.
#include "game/sim/construction.h"
#include "check.h"
using namespace sots::sim;
// ---------------------------------------------------------------------------------------
// The records a completion writes
// ---------------------------------------------------------------------------------------
static void test_records() {
ShipRecords r;
RecordShipBuilt(r, 608, 0);
CHECK_EQ(r.built[0], 1);
CHECK_EQ(r.designs.size(), std::size_t{1});
CHECK_EQ(r.designs[0].designKey, 608);
CHECK_EQ(r.designs[0].hullClass, 0);
CHECK_EQ(r.designs[0].built, 1);
// A second hull of the same design finds the record rather than appending one.
RecordShipBuilt(r, 608, 0);
CHECK_EQ(r.designs.size(), std::size_t{1});
CHECK_EQ(r.designs[0].built, 2);
CHECK_EQ(r.built[0], 2);
// A different design appends, and the vector's order is first-seen.
RecordShipBuilt(r, 576, 0);
RecordShipBuilt(r, 1136, 2);
CHECK_EQ(r.designs.size(), std::size_t{3});
CHECK_EQ(r.designs[1].designKey, 576);
CHECK_EQ(r.designs[2].designKey, 1136);
CHECK_EQ(r.designs[2].hullClass, 2);
CHECK_EQ(r.built[0], 3);
CHECK_EQ(r.built[2], 1);
// Nothing here touches losses, kills or in-service.
CHECK_EQ(r.lost[0], 0);
CHECK_EQ(r.killed[0], 0);
CHECK_EQ(r.inService[0], 0);
// An out-of-range hull class leaves the class array alone but still gets its own record;
// the original indexes the array unchecked, so this guard is ours and is stated as such.
ShipRecords g;
RecordShipBuilt(g, 7, 9);
CHECK_EQ(g.built[0], 0);
CHECK_EQ(g.designs.size(), std::size_t{1});
CHECK_EQ(g.designs[0].built, 1);
}
// ---------------------------------------------------------------------------------------
// The pass
// ---------------------------------------------------------------------------------------
static void test_pass_reports_designs() {
// {designId, orderId, con, conleft, money, moneyAvailable}
std::vector<BuildOrder> q = {{608, 3, 1980, 1753, 0, true},
{576, 4, 1860, 1860, 0, true},
{576, 5, 1860, 1860, 0, true}};
const SystemConstructionResult r = RunSystemConstruction(q, 4182);
CHECK_EQ(r.completed.size(), std::size_t{2});
CHECK_EQ(r.completed[0].orderId, 3);
CHECK_EQ(r.completed[0].designId, 608);
CHECK_EQ(r.completed[1].orderId, 4);
CHECK_EQ(r.completed[1].designId, 576);
CHECK_EQ(r.pointsSpent, 4182);
CHECK_EQ(r.pointsLeft, 0);
CHECK(r.advancedPartially);
CHECK_EQ(r.ordersRemoved, 2);
CHECK_EQ(q.size(), std::size_t{1});
CHECK_EQ(q[0].orderId, 5);
CHECK_EQ(q[0].constructionLeft, 1291);
}
static void test_points_gate_skips_the_sweep() {
// An order already at zero. This state cannot arise in the corpus -- the pass that
// zeroes an order also erases it -- so the rule is a labelled hypothesis about a
// zero-construction-cost design, and the test pins the behaviour, not a measurement.
std::vector<BuildOrder> q = {{608, 3, 0, 0, 0, true}};
SystemConstructionResult r = RunSystemConstruction(q, 0);
CHECK_EQ(q.size(), std::size_t{1}); // points <= 0: the whole body is skipped
CHECK_EQ(r.pointsLeft, 0);
CHECK(!r.sweepRan);
r = RunSystemConstruction(q, -5);
CHECK_EQ(q.size(), std::size_t{1});
CHECK_EQ(r.pointsLeft, -5);
r = RunSystemConstruction(q, 1); // one point is enough to run the sweep
CHECK(q.empty());
CHECK(r.sweepRan);
CHECK_EQ(r.ordersRemoved, 1);
}
static void test_money_refusal_skips_not_stops() {
std::vector<BuildOrder> q = {{608, 1, 100, 100, 500, false},
{576, 2, 100, 100, 500, true}};
const SystemConstructionResult r = RunSystemConstruction(q, 300);
CHECK_EQ(r.completed.size(), std::size_t{1});
CHECK_EQ(r.completed[0].orderId, 2);
CHECK_EQ(r.moneyCharged, 500);
// The refused order keeps its points and survives the sweep; the pass did not stop at it.
CHECK_EQ(q.size(), std::size_t{1});
CHECK_EQ(q[0].orderId, 1);
CHECK_EQ(r.pointsLeft, 200);
CHECK(!r.advancedPartially);
}
static void test_running_off_the_end() {
std::vector<BuildOrder> q = {{608, 1, 100, 100, 0, true}};
const SystemConstructionResult r = RunSystemConstruction(q, 900);
CHECK_EQ(r.pointsLeft, 800);
CHECK(!r.advancedPartially);
CHECK(q.empty());
}
// ---------------------------------------------------------------------------------------
// The corpus oracle: zuul-turn16-noderoute.sav -> zuul-turn17-rollpending.sav
// ---------------------------------------------------------------------------------------
//
// System 80 (owner 32, an AI) and system 384 (owner 16) each hold a queue at frame 16 and a
// different queue at frame 17. The AI appended one order (58) during the turn, so its
// before-state is the frame-16 queue with that order pushed on the end -- the only fitted
// element in this fixture, and it is fitted from the frame-17 file's own `con`/`ordID`, not
// from the model.
//
// The test searches every point total in a wide range and asserts that the set of totals
// that reproduce the observed after-state is non-empty and is a contiguous run of ONE value
// per system (the transition is exact, not a band), then checks the completions against the
// per-design `srb` deltas the two files carry.
struct Fixture {
const char* what;
std::vector<BuildOrder> before;
std::vector<BuildOrder> after;
std::vector<int> expectedCompletedDesigns;
};
static int solve_points(const Fixture& f, int* solutions) {
int found = -1;
*solutions = 0;
for (int p = 0; p <= 200000; ++p) {
std::vector<BuildOrder> q = f.before;
const SystemConstructionResult r = RunSystemConstruction(q, p);
if (q.size() != f.after.size()) continue;
bool same = true;
for (std::size_t i = 0; i < q.size(); ++i)
if (q[i].orderId != f.after[i].orderId ||
q[i].constructionLeft != f.after[i].constructionLeft)
same = false;
if (!same) continue;
std::vector<int> designs;
for (const Completion& c : r.completed) designs.push_back(c.designId);
if (designs != f.expectedCompletedDesigns) continue;
++*solutions;
if (found < 0) found = p;
}
return found;
}
static void test_corpus_pair() {
// System 384, owner 16. Frame 16: three orders. Frame 17: one, advanced by 569.
Fixture human{"sys 384 / player 16",
{{608, 3, 1980, 1753, 0, true},
{576, 4, 1860, 1860, 0, true},
{576, 5, 1860, 1860, 0, true}},
{{576, 5, 1860, 1291, 0, true}},
{608, 576}};
// System 80, owner 32. Frame 16: four orders of design 114. Frame 17: order 58 only,
// which the AI appended during the turn (con 6974) and which was advanced by 3156.
Fixture ai{"sys 80 / player 32",
{{114, 54, 1889, 959, 0, true},
{114, 55, 1889, 1889, 0, true},
{114, 56, 1889, 1889, 0, true},
{114, 57, 1889, 1889, 0, true},
{816, 58, 6974, 6974, 0, true}},
{{816, 58, 6974, 3818, 0, true}},
{114, 114, 114, 114}};
for (const Fixture* f : {&human, &ai}) {
int solutions = 0;
const int p = solve_points(*f, &solutions);
simtest::report(p >= 0, "a point total reproduces the observed transition", __FILE__,
__LINE__, std::string(f->what));
simtest::report(solutions == 1, "the point total is unique", __FILE__, __LINE__,
std::string(f->what) + " solutions=" + std::to_string(solutions));
}
// The two totals the solve finds, stated so a change to the model is visible as a number.
int n = 0;
CHECK_EQ(solve_points(human, &n), 4182);
CHECK_EQ(solve_points(ai, &n), 9782);
// The per-design `srb` deltas the two saves carry, reproduced by feeding the solved
// totals through the records model. Player 16 (index 0 on the wire): design 608 goes
// 2 -> 3 and a record for 576 appears with 1. Player 32 (index 1): design 114 goes
// 18 -> 22, and the class-0 counters go 2 -> 4 and 53 -> 57.
{
ShipRecords r;
r.built[0] = 2;
r.designs.push_back({656, 0, 0, 0, 2});
r.designs.push_back({608, 0, 2, 0, 2});
std::vector<BuildOrder> q = human.before;
for (const Completion& c : RunSystemConstruction(q, 4182).completed)
RecordShipBuilt(r, c.designId, 0);
CHECK_EQ(r.built[0], 4);
CHECK_EQ(r.designs.size(), std::size_t{3});
CHECK_EQ(r.designs[1].built, 3); // 608
CHECK_EQ(r.designs[2].designKey, 576); // appended, in first-seen order
CHECK_EQ(r.designs[2].built, 1);
}
{
ShipRecords r;
r.built[0] = 53;
r.designs.push_back({816, 0, 6, 0, 8});
r.designs.push_back({18, 0, 23, 0, 23});
r.designs.push_back({34, 0, 4, 0, 4});
r.designs.push_back({114, 0, 18, 0, 18});
r.designs.push_back({130, 0, 2, 0, 2});
std::vector<BuildOrder> q = ai.before;
for (const Completion& c : RunSystemConstruction(q, 9782).completed)
RecordShipBuilt(r, c.designId, 0);
CHECK_EQ(r.built[0], 57);
CHECK_EQ(r.designs.size(), std::size_t{5}); // nothing appended
CHECK_EQ(r.designs[3].built, 22); // 114
CHECK_EQ(r.designs[0].built, 6); // 816 did not complete
}
}
int main() {
test_records();
test_pass_reports_designs();
test_points_gate_skips_the_sweep();
test_money_refusal_skips_not_stops();
test_running_off_the_end();
test_corpus_pair();
return simtest::finish("game_sim_construction");
}