diff --git a/docs/SV-script-objects.md b/docs/SV-script-objects.md new file mode 100644 index 0000000..ec1a7de --- /dev/null +++ b/docs/SV-script-objects.md @@ -0,0 +1,264 @@ +# SV — what updates `SvSctOb` during a turn + +Lane SV. Written **before** the build (rule 2). The falsification section is §5. + +The eight leaves of `/Sim/SvSctOb` that diverge on the reference pair belong to three of the +twelve script objects the save carries. This lane asked what writes them, found the mechanism, +and implements the part of it the standalone can reach. + +--- + +## 1. The mechanism: one event bus, two steps per delivery + +`StrategyServer+0x1b4` holds the root script object (a `Game::SVSOSots`). Every site that +notifies it does the **same two-step**: a generic handler taking the event id, then one +event-specific virtual slot with no id: + +``` +script->vft[0x10](evt, arg); // generic: every object sees every event +script->vft[](...); // specific: one slot per event id +``` + +The root's generic handler fans the same delivery out to **every child** script object through +a shared dispatcher, which repeats both steps per child and carries a **33-entry jump table** +mapping `evt` (0..0x20) to the specific slot. So `evt -> slot` is a real, exhaustive encoding +in the image, not a guess: + +| evt | slot | evt | slot | evt | slot | +|---|---|---|---|---|---| +| 0x00 | +0x14 | 0x0b | +0x40 | 0x16 | +0x70 | +| 0x01 | +0x18 | 0x0c | +0x44 | 0x17 | +0x74 | +| 0x02 | +0x1c | 0x0d | +0x48 | 0x18 | +0x68 | +| 0x03 | +0x20 | 0x0e | +0x4c | 0x19 | +0x7c | +| 0x04 | +0x24 | 0x0f | +0x50 | 0x1a | +0x80 | +| 0x05 | +0x28 | 0x10 | +0x54 | 0x1b | +0x84 | +| 0x06 | +0x2c | 0x11 | +0x58 | 0x1c | +0x78 | +| 0x07 | +0x30 | 0x12 | +0x5c | 0x1d | +0x88 | +| 0x08 | +0x34 | 0x13 | +0x60 | 0x1e | +0x8c | +| 0x09 | +0x38 | 0x14 | +0x64 | 0x1f | +0x90 | +| 0x0a | +0x3c | 0x15 | +0x6c | 0x20 | +0x94 | + +The table is what proves the hand-written pairs in the two turn drivers are event deliveries and +not ad-hoc calls: the tail's `vft[0x10](8,0); vft[0x34]()` is exactly `evt 8`, and +`vft[0x10](0x14,0); vft[0x64]()` is exactly `evt 0x14`. + +## 2. Where a turn delivers events + +Every site that reads `StrategyServer+0x1b4` and dispatches, with the id it sends: + +| driver | event | when | +|---|---|---| +| `BeginProcessTurn` | **0x13** | first thing in the turn, right after the frame counter | +| `StrategyServer::ProcessTurn` | 6, then 0x1c | the spine | +| `StrategyServer::MoveFleet` | 0xe | per fleet move | +| `ApplyEncounterResult` | 7 | per encounter, in the tail | +| `OnAllCombatDone_Tail` | **8**, then **0x14**, **0x15**, then **0x1c** | tail phases 8, 20 and one site lane K's phase map does not list | +| `BuildTurnEvents` | 0x1a, ?, 0x1b | after the tail | +| `SynchronizePlayer`, `LoadGame`, `ResumePlaying`, and eight others | 1..5, 0xd, 0x17, 0x18, 0x2 | not a turn | + +**Correction to lane K.** `combat-done-tail.md` lists three script-hook sites in the tail +(phases 8 and 20). There is a **fourth**, after the maintenance/research recompute, and it sends +**event 0x1c** — the same id `ProcessTurn` sends. Lane K's tier-4 note attributes 0x1c to +`ProcessTurn` alone. + +Of the twelve classes our saves carry, only these override a slot a turn delivers: + +| class | evt 0x13 (turn begin) | evt 8 | evt 0x14 | evt 0x1c | evt 6 | evt 7 | +|---|---|---|---|---|---|---| +| VonNeumann (1) | yes | yes | — | yes | yes | yes | +| Swarm (3) | yes | — | — | — | — | yes | +| Derelict (4) / Monitor (5) / CrowRuins (17) | — | — | — | — | — | yes | +| SlaversRefuel (9) | — | — | **yes** (via its generic handler) | — | — | yes | +| SwarmQueen (10) | **yes** | — | **yes** | — | — | — | +| Refugees (20) | **yes** | yes | — | — | yes | yes | +| Traps / CrowDefenders / IndependentSystems | yes | yes | yes | — | yes | — | +| GrandMenaceTrigger | yes | — | — | — | — | — | + +`evt 0x15` is overridden by **nobody** — the tail's second phase-20 pair is dead in every class +our saves hold. + +## 3. The three writers behind the eight leaves + +`EncObj[3]` is EncID 9 (SlaversRefuel), `EncObj[5]` is EncID 10 (SwarmQueen), `EncObj[6]` is +EncID 20 (Refugees). + +### 3a. `CDiff` (1 leaf) — SlaversRefuel, event 0x14, the tail + +The class overrides only the **generic** handler, which does nothing unless `evt == 0x14`. The +body builds a **three-record table on the stack** and walks it against the server's frame +counter: + +| threshold | payload | +|---|---| +| 1 | (1, 1) | +| 50 | (2, 3) | +| 100 | (2, 5) | + +It scans for the first record whose threshold is **greater** than the frame, and writes +`index - 1` into `CDiff` — but only if that differs from what is there. Three consequences, +all from the instruction stream and none of them guessable from the data: + +* frame ≤ 0 → the first record already exceeds it, index 0, `jle` exit: **no write**; +* frame in 1..49 → `CDiff = 0`; frame in 50..99 → `CDiff = 1`; +* **frame ≥ 100 → the scan runs off the end and there is no write at all**, so `CDiff` can never + reach 2 through this path. That looks like an off-by-one in the original and is recorded as + what the code does, not as what it presumably meant. + +Only when `CDiff` changes does the function continue into a per-system pass. That pass writes +nothing this object serialises (`NAsg`, `NTD`, `NAD` are unchanged across the pair), so it is +**not** modelled and is labelled below. + +### 3b. `ini` + `didc`/`did` (3 leaves) — Refugees, event 0x13, turn begin + +``` +if (!ini) { + ini = true; + obj = ; + if (obj) dids.push_back(obj.handleId); +} +``` + +`ini` is a one-shot latch and it is the whole gate. The push-back is **not** modelled: the id it +appends is `1712`, and the same turn's save also gains design `1712`, ship `1728` and fleet +`1744` — three consecutive handle allocations, `NMnx` 106 → 109. That is the refugee-trader +convoy being created from the data files, and nothing in the standalone allocates handles or +instantiates a design template. So this lane commits the latch and names the rest. + +### 3c. `Hives` (4 leaves) — SwarmQueen, event 0x13, turn begin + +The constructor is decisive about the two ids this class carries: it stores **3** in the +`SVScriptObject` scenario tag and **10** in its own encounter id. So the queen operates on the +**Swarm's** systems, not on its own. + +``` +for each system with system.EggScio == this.scenarioTag (== 3): + if no hive already references it: + hive.system = system + hive.queen = 0 + hive.nextQ = frame + LO + rand(HI - LO) <-- ONE MT DRAW PER NEW HIVE + hives.push_back(hive) +prune hives whose system.EggScio != 3 +for each hive with queen == 0: + if : ++hive.nextQ + elif hive.nextQ <= frame: +``` + +`EggScio` is confirmed as the system's owning-scenario tag by the data and not only by the code: +in `turn1-state.sav` exactly the two systems with `EggScio == 3` (336, 400) are the two the +Swarm has infested and the two that get hives; `EggScio == 4` are the two systems the Derelict +has fleets on; `EggScio == 5` is the Monitor's one system. + +`++nextQ` is the whole explanation of a number that looked impossible: `NextQ` reads 31/29 after +turn 1 and 32/30 after turn 2. It is not re-rolled — it **slips forward by one every turn the +spawn gates fail**, so a hive's queen date walks away from it until the gates open. + +## 4. What is predicted + +Implemented in `src/game/sim/scriptobjects.{h,cpp}` and driven from three phases in `src/app`. + +**P1 — `CDiff`.** `/Sim/SvSctOb/EncObj[3]/CDiff` closes, `-1 -> 0`, on both reference pairs. +This is the only one of the eight that is completely free of anything the standalone lacks. + +**P2 — `ini`.** `/Sim/SvSctOb/EncObj[6]/ini` closes, `False -> True`, on both pairs. +`didc` and `did` do **not** close and do **not** regress: `didc` stays 0 against the oracle's 1 +and `did` stays absent. + +**P3 — `Hives`.** `/Sim/SvSctOb/EncObj[5]/Hives/.` and `.[0]` (the count, 2) close. `.[1]` and +`.[2]` do **not**, because `NextQ` needs a draw this lane cannot place. Two of four. + +**P4 — regressions: zero.** Nothing here writes a leaf that currently agrees. + +**P5 — the pair total.** 128 → 124, closed 4, regressed 0, on `turn1 -> turn2`. Pair 2 is +`turn2 -> turn3`, where the latch and the tier are already set and the hives already exist, so +**pair 2 moves by 0** — and that asymmetry is itself the check that these are one-shot rules and +not per-turn ones. + +**P6 — an RNG claim, not measured here.** Lane Z measured a turn at 18–22 generator words, *all* +inside `ProcessTurn`, residual outside the two drivers **exactly zero** — on turns 6 and 64, +where the hives already existed. On the reference pair the hives are **created**, and creation +draws once per hive from the strategic generator inside `BeginProcessTurn`, which is **outside +both turn drivers and before either of them**. So the reference turn should cost lane Z's +`ProcessTurn` total **plus at least two words**, and lane Z's "residual is exactly zero" is a +statement about the turns it measured, not about a turn. + +## 5. How this could be wrong, and the symptom of each way + +1. **`CDiff` reads a different counter than the frame.** The handler reads the server's `+0xc`, + which `BeginProcessTurn` increments while logging "Begin processing turn N", so it is the + frame. If it were instead the modification counter, the tier for the reference pair would + still be 0 (both are small), so **this corpus cannot separate them** — the 1..49 window + swallows the difference. Symptom elsewhere: a save at frame ~50 with a very different + ModCount would put `CDiff` on the wrong side of the boundary. +2. **The stack table is read with the wrong stride.** If the records were 2 dwords rather than 3, + the thresholds would be 1/1/50 and the reference pair would come out `CDiff = 1`, not 0. The + symptom is immediate and visible in the very leaf we are trying to close. +3. **`ini` is set somewhere else as well.** If some other handler also latches it, committing it + at turn begin is right by accident. Symptom: none on this corpus. Stated as a risk. +4. **`ini` should not be set without the design.** If the original's latch were written only + *after* a successful instantiation, then a standalone that cannot instantiate should leave it + false, and closing it here is closing a leaf with the wrong reason. Read from the instruction + stream: the store to the latch is the **second instruction of the guarded block**, before the + lookup and unconditional on its result. So the latch is not conditional on the design. +5. **The hive set is keyed by something other than `EggScio`.** Symptom: the wrong count, or + hives on systems 448/480/64. The count leaf would then regress rather than close. +6. **`NextQ` might be reachable after all.** If the two creation draws are the first draws of the + turn and the standalone's generator is loaded from the save, a future lane that models the + two config constants could reproduce them exactly. This lane does not claim they are + unreachable, only that it has not placed them. +7. **The per-system pass after a `CDiff` change might write something.** It runs on the + reference pair (the tier changes on turn 1). If it wrote a leaf, a regression would appear + somewhere outside `SvSctOb` — which is exactly what P4 would catch. + +## 5a. What actually happened + +Measured on CT111, `tools/standalone_report.py`. Closed and regressed are stated separately +and never netted, and the two configurations are stated separately too. + +**Default (hive registration off).** + +| pair | before | after | closed | regressed | +|---|---|---|---|---| +| turn1 -> turn2 | 209 -> 128 | 209 -> **126** | 83 (was 81) | **0** | +| turn2 -> turn3 | 108 -> 69 | 108 -> **67** | 41 (was 39) | **0** | + +**With `--commit-blocked=H03` (hive registration on).** + +| pair | before | after | closed | regressed | +|---|---|---|---|---| +| turn1 -> turn2 | 209 -> 128 | 209 -> **124** | 87 | **2** | +| turn2 -> turn3 | 108 -> 69 | 108 -> **67** | 41 | **0** | + +Scoring the predictions. + +* **P1 held.** `CDiff` closed, `-1 -> 0`, on pair 1; pair 2 already carried 0 and the store + is conditional, so it correctly did nothing there. +* **P2 held**, including its negative half: `didc` and `did` neither closed nor regressed. +* **P3 held**, and only under the flag: the count and the list-shape leaves close, the two + `NextQ` leaves do not — and they show up as **regressions**, because the tool's baseline + had those two positions on the "agrees" side while our tree had no hive there at all. That + is the honest reading of a knowingly-wrong value and it is why the registration is opt-in. +* **P4 held** in the default configuration and **failed as stated** under the flag: 2 + regressions, both named above, both the same field. +* **P5 was WRONG, and wrong in the direction that matters.** It said pair 2 would move by 0, + because the latch and the tier are already set there and the hives already exist. Pair 2 + moved by **2**: `Hives/.[1]/NextQ` and `.[2]/NextQ` closed, 31 -> 32 and 29 -> 30. The + prediction forgot that the hives being present is exactly what lets the **slip** run, and + the slip is a per-turn rule, not a one-shot. So the second pair is not the null control + P5 called it — it is the only **exact** test of the rule this lane recovered, and it + passes: two hives, two independent target turns, both landing on the oracle's value with + no fitting and no draw. A rule that reproduces two numbers it was not built from is worth + more than the pair-1 leaves it was aimed at. +* **P6 is untested here.** It is a claim about the generator, not about leaves, and this lane + did not instrument it. It is recorded so the next lane to touch the RNG ledger can falsify + it cheaply: hook the turn-begin delivery on a save whose swarm hives do not yet exist. + +## 6. What no save exercises (rule 6) + +* `evt 0x15` — no class our saves hold overrides it. The tail's second phase-20 pair is a no-op + on every workload we can build from this corpus. +* The queen **spawn** arm: no hive in any corpus save has a queen, so only the `++NextQ` slip + arm has ever run. Workload needed: a swarm game run past the spawn gates. +* `CDiff` tiers 1 and 2: needs a save at frame ≥ 50. The Zuul saves reach turn 23. +* The Refugees `dids` push: needs design instantiation from the data files, not a different save. +* Two script objects with a factory entry and no occurrence, and the whole alliance/contact + family of events, remain unexercised — the corpus has no alliance and no two-empire contact. diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index fb63c8a..d26057c 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -12,6 +12,7 @@ add_library(sots_app STATIC construction_phase.cpp event_phase.cpp growth_phase.cpp + script_phase.cpp visibility_phase.cpp turn.cpp report.cpp) diff --git a/src/app/phase_catalog.cpp b/src/app/phase_catalog.cpp index e398ea0..6ad586c 100644 --- a/src/app/phase_catalog.cpp +++ b/src/app/phase_catalog.cpp @@ -44,13 +44,22 @@ constexpr PhaseDesc kHost[] = { {Driver::Host, 0, "H00", "BeginProcessTurn", PhaseStatus::Implemented, "advances the frame counter, which is the turn number the whole game displays; runs " "before the spine and is where the 'Begin processing turn N' line comes from"}, - {Driver::Host, 1, "H02", "StampTreatyTurns", PhaseStatus::Implemented, + {Driver::Host, 1, "H03", "ScriptHookTurnBegin", PhaseStatus::Partial, + "the turn's FIRST script-object event delivery, sent from inside BeginProcessTurn right " + "after the frame counter. Two of the twelve script objects our saves carry react to it. " + "The refugees' one-shot latch IS modelled and committed; the design instantiation behind " + "the same latch is not, because nothing here allocates handles. The swarm queen's hive " + "registration, prune and per-turn slip ARE modelled -- which system each hive sits on and " + "that it has no queen are exact -- but the hive's target turn is drawn from the strategic " + "generator here, outside both turn drivers, and that draw is not placed. See " + "docs/SV-script-objects.md"}, + {Driver::Host, 2, "H02", "StampTreatyTurns", PhaseStatus::Implemented, "the diplomacy ledger's 'this treaty was last in force on turn N' stamp, over every " "ORDERED pair of players that holds one, creating the entry on demand. Runs in the " "command-application step -- after the frame counter has advanced and before either " "turn driver -- so it stamps the NEW turn. It is the only writer of these fields on a " "turn with no combat and no diplomatic command"}, - {Driver::Host, 2, "H01", "SaveWriterInvariants", PhaseStatus::Implemented, + {Driver::Host, 3, "H01", "SaveWriterInvariants", PhaseStatus::Implemented, "the summary's turn number is the simulation's frame counter -- an identity that holds " "across every save in the corpus and belongs to the writer, not to a turn phase"}, }; @@ -237,7 +246,10 @@ constexpr PhaseDesc kTail[] = { {Driver::Tail, 6, "T06", "ApplyEncounterResults", PhaseStatus::Stub, "DRAWS RNG -- node-cannon and salvage paths; the word count is combat-dependent"}, {Driver::Tail, 7, "T07", "ClearEncounters", PhaseStatus::Stub, ""}, - {Driver::Tail, 8, "T08", "ScriptHookCombatDone", PhaseStatus::Stub, ""}, + {Driver::Tail, 8, "T08", "ScriptHookCombatDone", PhaseStatus::Stub, + "script-object event 8. Five of the twelve classes our saves carry override it -- von " + "Neumann, refugees, traps, crow defenders, independent systems -- and NONE of them moves " + "a leaf that diverges on either reference pair, so this phase is listed and not run"}, {Driver::Tail, 9, "T09", "AdvanceAIRebellionPostCombat", PhaseStatus::Stub, ""}, {Driver::Tail, 10, "T10", "NodeSpaceTravelSecondPass", PhaseStatus::Stub, "node-space travel runs a SECOND time this turn"}, @@ -257,7 +269,13 @@ constexpr PhaseDesc kTail[] = { "pairs, so nothing here has evidence to build it against"}, {Driver::Tail, 18, "T18", "PostFleetWarnings", PhaseStatus::Stub, ""}, {Driver::Tail, 19, "T19", "DrainInfraTerraformQueue", PhaseStatus::Stub, ""}, - {Driver::Tail, 20, "T20", "ScriptHooksTurnEnd", PhaseStatus::Stub, ""}, + {Driver::Tail, 20, "T20", "ScriptHooksTurnEnd", PhaseStatus::Partial, + "two script-object event deliveries, 0x14 then 0x15. The slavers-refuel difficulty tier " + "IS modelled and committed: a three-record table scanned against the frame, boundaries at " + "1/50/100, written only when it changes -- and at frame 100 and above the scan runs off " + "the end and writes nothing, so the tier can never reach 2. The per-system pass the " + "original enters after a tier change is not modelled. Event 0x15 is overridden by no " + "class in any save we hold"}, {Driver::Tail, 21, "T21", "UpdateSurveyAndSystemStats", PhaseStatus::Partial, "the explored sweep IS modelled and committed: every player who can currently see a " "system has now surveyed it. The event this owes per newly-surveyed pair is not " diff --git a/src/app/script_phase.cpp b/src/app/script_phase.cpp new file mode 100644 index 0000000..f5ea5b4 --- /dev/null +++ b/src/app/script_phase.cpp @@ -0,0 +1,213 @@ +#include "app/script_phase.h" + +#include +#include + +#include "game/sim/scriptobjects.h" + +namespace sots::app { +namespace { + +using mars::stream::shapes::EncounterObject; +using mars::stream::shapes::SaveGame; +using mars::stream::shapes::ScriptObjects; + +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); +} + +// The encounter body for one id, or null. Ids the save does not carry are normal: the +// factory has 23 slots and no game instantiates them all. +EncounterObject* Encounter(SaveGame& game, std::int32_t encID) { + if (!game.sim.svSctOb) return nullptr; + ScriptObjects& root = *game.sim.svSctOb; + for (auto& e : root.encounters) + if (e.id == encID) return &e.obj; + return nullptr; +} + +// Every system's id and its owning-scenario tag, in save order -- which is the order the +// original visits them in, and therefore the order it would draw in. +void SystemScenarioTags(const SaveGame& game, std::vector& ids, + std::vector& tags) { + ids.reserve(game.sim.systems.size()); + tags.reserve(game.sim.systems.size()); + for (const auto& e : game.sim.systems) { + ids.push_back(e.sysID); + tags.push_back(e.sys.eggScio); + } +} + +} // namespace + +ScriptPhaseResult RunScriptTurnBegin(SaveGame& game, bool registerHives) { + ScriptPhaseResult r; + if (!game.sim.svSctOb) { + r.notes.push_back("this save carries no script-object tree; nothing to deliver to"); + return r; + } + const int frame = game.sim.frame; + + // --- EncID 20, refugees: the one-shot latch ------------------------------------------ + if (EncounterObject* enc = Encounter(game, 20)) { + ++r.objectsVisited; + const sim::RefugeesTurnBeginResult res = sim::RefugeesTurnBegin(enc->refugees.ini); + if (res.latched) { + r.leafWrites += 1; + r.notes.push_back("refugees: ini False -> True, the one-shot latch the original " + "sets on the first turn it is notified"); + } else { + r.notes.push_back("refugees: already latched, so this delivery is a no-op -- " + "which is the rule, not a gap"); + } + if (res.convoyOwed && res.latched) + r.notes.push_back("NOT MODELLED behind the same latch: the refugee-trader convoy. " + "The original instantiates a design from the data files and " + "appends its handle to `dids`; on the reference pair that is " + "design 1712, and the same turn also allocates ship 1728 and " + "fleet 1744 (NMnx 106 -> 109). Nothing here allocates handles, " + "so `didc`/`did` stay where the input save left them"); + } + + // --- EncID 10, swarm queen: register, prune, tick ------------------------------------- + if (EncounterObject* enc = Encounter(game, 10)) { + ++r.objectsVisited; + std::vector sysIds, tags; + SystemScenarioTags(game, sysIds, tags); + + std::vector hives; + hives.reserve(enc->swarmQueen.hives.size()); + for (const auto& h : enc->swarmQueen.hives) + hives.push_back(sim::Hive{h.hiveID, h.queenID, h.nextQ}); + + const std::size_t before = hives.size(); + const std::vector owed = sim::HivesToRegister(sysIds, tags, hives); + const std::vector registered = + registerHives ? owed : std::vector{}; + for (std::int32_t sysId : registered) { + sim::Hive h; + h.systemId = sysId; + h.queenId = 0; + // The original's target turn is `frame + LO + draw(HI - LO)`, one draw from the + // strategic generator per new hive, with LO and HI two data-file constants. The + // standalone models neither the constants nor the generator position at this + // point in the turn, so the field is left at the frame it was registered on and + // is KNOWN WRONG. It is written rather than skipped because the hive's identity + // -- which system, and that it has no queen -- is exact, and that is the part + // the save's structure records. + h.nextQueenTurn = frame; + hives.push_back(h); + } + const int pruned = sim::PruneHives(hives, sysIds, tags); + const sim::HiveTickResult tick = sim::TickHives(hives, frame); + + bool changed = hives.size() != enc->swarmQueen.hives.size(); + if (!changed) { + for (std::size_t i = 0; i < hives.size(); ++i) { + const auto& a = hives[i]; + const auto& b = enc->swarmQueen.hives[i]; + if (a.systemId != b.hiveID || a.queenId != b.queenID || + a.nextQueenTurn != b.nextQ) { + changed = true; + break; + } + } + } + if (changed) { + enc->swarmQueen.hives.clear(); + enc->swarmQueen.hives.reserve(hives.size()); + for (const sim::Hive& h : hives) { + mars::stream::shapes::SVSOSwarmQueenHive w; + w.hiveID = h.systemId; + w.queenID = h.queenId; + w.nextQ = h.nextQueenTurn; + enc->swarmQueen.hives.push_back(w); + } + // The count is one leaf; each hive contributes its three. + r.leafWrites += 1 + 3 * static_cast(hives.size()); + } + r.notes.push_back(fmt("swarm queen: %d hive(s) before, %d owed, %d registered, " + "%d pruned, %d slipped a turn, %d now", + static_cast(before), static_cast(owed.size()), + static_cast(registered.size()), pruned, tick.slipped, + static_cast(hives.size()))); + if (!owed.empty() && !registerHives) + r.notes.push_back(fmt("%d hive(s) are OWED and not written. Which systems they sit " + "on and that they have no queens are exact; their target " + "turn is not, and writing them would close four leaves and " + "open two carrying a number known to be wrong. " + "--commit-blocked=H03 takes that trade", + static_cast(owed.size()))); + if (!registered.empty()) + r.notes.push_back("KNOWN WRONG on the hives registered here: `NextQ`. The " + "original draws it -- frame + LO + draw(HI - LO), one draw per " + "new hive, from the strategic generator, inside the turn-begin " + "step. That is OUTSIDE both turn drivers and before either of " + "them, and the campaign's measured RNG ledger (18-22 words, " + "residual zero) was taken on turns where the hives already " + "existed, so it has never seen this draw. Which system each " + "hive sits on, and that it has no queen, ARE exact"); + if (tick.slipped > 0) + r.notes.push_back("the tick took the arm every corpus save takes: the spawn gates " + "fail and the target turn slips forward by exactly one. That " + "single increment is why the field reads 31 after turn 1 and 32 " + "after turn 2 -- it walks, it is not re-rolled"); + if (tick.spawnsOwed > 0) + r.notes.push_back(fmt("NOT MODELLED: %d queen spawn(s). No hive in any corpus " + "save has ever had a queen", tick.spawnsOwed)); + } + + if (r.objectsVisited == 0) + r.notes.push_back("neither the refugees nor the swarm queen is in this save's tree"); + return r; +} + +ScriptPhaseResult RunScriptTurnEnd(SaveGame& game) { + ScriptPhaseResult r; + if (!game.sim.svSctOb) { + r.notes.push_back("this save carries no script-object tree; nothing to deliver to"); + return r; + } + const int frame = game.sim.frame; + + // --- EncID 9, slavers refuel: the difficulty tier ------------------------------------- + if (EncounterObject* enc = Encounter(game, 9)) { + ++r.objectsVisited; + const int stored = enc->slavers.cdiff; + const sim::SlaverTierResult res = sim::SlaversTurnEnd(stored, frame); + if (res.wrote) { + enc->slavers.cdiff = res.tier; + r.leafWrites += 1; + r.notes.push_back(fmt("slavers: CDiff %d -> %d at frame %d (tier boundaries " + "1/50/100)", stored, res.tier, frame)); + r.notes.push_back("NOT MODELLED, and it runs only when the tier changes: the " + "per-system pass the original enters after this store. It " + "writes nothing this object serialises -- NAsg, NTD and NAD are " + "unchanged across both reference pairs -- so its effect " + "elsewhere is a labelled hypothesis, and a regression outside " + "SvSctOb is what would falsify it"); + } else if (sim::SlaverDifficultyTier(frame) < 0) { + r.notes.push_back(fmt("slavers: at frame %d the tier scan writes nothing. Below 1 " + "the first threshold already exceeds the frame; at 100 and " + "above the scan runs off the end of a three-record table, " + "so the tier can never reach 2 by this path", frame)); + } else { + r.notes.push_back(fmt("slavers: CDiff already %d at frame %d; the store is " + "conditional on a change", stored, frame)); + } + } + + if (r.objectsVisited == 0) + r.notes.push_back("the slavers-refuel encounter is not in this save's tree"); + r.notes.push_back("the second delivery this tail step makes (event 0x15) is overridden by " + "NO class in any save we hold -- it is dead on this corpus, and that is " + "read from the vtables, not inferred from the bytes"); + return r; +} + +} // namespace sots::app diff --git a/src/app/script_phase.h b/src/app/script_phase.h new file mode 100644 index 0000000..97392b3 --- /dev/null +++ b/src/app/script_phase.h @@ -0,0 +1,54 @@ +// The script-object event deliveries a turn makes, wired to the save shapes. +// +// Three of a turn's six deliveries move something the save records, and they land in two +// different drivers: +// +// H03 turn begin (event 0x13) -- the refugees latch, and the swarm queen's hives +// T08 the tail's combat-done hook (event 8) -- nothing our saves' classes override +// that this lane models +// T20 the tail's end-of-turn hooks (events 0x14, 0x15) -- the slavers' difficulty tier +// +// The bus, the id -> slot table and the per-class override map are in +// docs/SV-script-objects.md and in game/sim/scriptobjects.h. What lives here is only the +// bridge: pull the object out of the save's `SvSctOb` tree by its encounter id, run the +// rule, write the result back. +// +// The tree is a keyed variant list. An id we do not model is carried as an opaque node, so +// asking for one that is not there is normal and returns "no such object" rather than +// creating one -- these phases never add an encounter to a save that has none. +#pragma once + +#include +#include + +#include "mars/stream/shapes.h" + +namespace sots::app { + +// What one script-object phase did, in the terms the run log prints. +struct ScriptPhaseResult { + int objectsVisited = 0; // script objects the phase had a rule for and found + int leafWrites = 0; // save leaves this phase changed + std::vector notes; +}; + +// H03: the turn-begin delivery. Runs immediately after the frame counter, which is where +// the original sends it -- and the ordering is load-bearing, because both rules here read +// the NEW frame. +// +// `registerHives` is opt-in and defaults OFF, and the reason is worth stating rather than +// burying in a flag. Registering a hive is a rule this lane recovered exactly EXCEPT for one +// field: the hive's target turn is `frame + LO + draw(HI - LO)`, one draw from the strategic +// generator, and neither the two data-file constants nor the generator's position at this +// point in the turn is settled. Writing the hive therefore closes the four leaves that say +// WHICH systems have hives and that they have no queens, and opens two that carry a number +// we know is wrong. That is a trade an integrator should make deliberately, so it is a flag +// (`--commit-blocked=H03`) and not a default. Pruning and the per-turn slip are exact and +// always run. +ScriptPhaseResult RunScriptTurnBegin(mars::stream::shapes::SaveGame& game, + bool registerHives = false); + +// T20: the tail's end-of-turn deliveries. +ScriptPhaseResult RunScriptTurnEnd(mars::stream::shapes::SaveGame& game); + +} // namespace sots::app diff --git a/src/app/turn.cpp b/src/app/turn.cpp index b47291d..031fdd5 100644 --- a/src/app/turn.cpp +++ b/src/app/turn.cpp @@ -11,6 +11,7 @@ #include "app/construction_phase.h" #include "app/event_phase.h" #include "app/growth_phase.h" +#include "app/script_phase.h" #include "app/trade_raid.h" #include "app/treaty.h" #include "app/turn_record.h" @@ -997,13 +998,28 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { r.records.push_back(rec); } + // H03 ScriptHookTurnBegin: the turn's first script-object event delivery, sent from + // inside BeginProcessTurn immediately after the frame counter above. The ordering is + // load-bearing in the same way H02's is -- both rules this delivery runs read the NEW + // frame, and a step placed before H00 would read the old one. + { + PhaseRecord rec; + rec.desc = &hp[1]; + const ScriptPhaseResult s = RunScriptTurnBegin(game, opt.CommitBlocked("H03")); + rec.invocations = s.objectsVisited; + rec.leafWrites = s.leafWrites; + rec.committed = s.leafWrites > 0; + rec.notes = s.notes; + r.records.push_back(rec); + } + // H02 StampTreatyTurns: the diplomacy ledger's "last in force on turn N" stamp. It runs // in the command-application step, which is AFTER the frame counter above and before // either turn driver -- the ordering is load-bearing, because the value stamped is the // new turn and a step placed before H00 would stamp every entry one turn short. { PhaseRecord rec; - rec.desc = &hp[1]; + rec.desc = &hp[2]; const TreatyStampResult ts = StampTreatyTurns(game.sim.players, game.sim.frame); rec.invocations = ts.pairsStamped; rec.leafWrites = ts.leafWrites; @@ -1327,6 +1343,12 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { rec.leafWrites = v.leafWrites; rec.committed = v.leafWrites > 0; rec.notes = v.notes; + } else if (tp[i].index == 20) { + const ScriptPhaseResult s = RunScriptTurnEnd(game); + rec.invocations = s.objectsVisited; + rec.leafWrites = s.leafWrites; + rec.committed = s.leafWrites > 0; + rec.notes = s.notes; } else if (tp[i].index == 31) { RunUpdateBankruptcyLimits(game, opt, rec, difficultyColumns); } else if (tp[i].index == 36) { @@ -1338,7 +1360,7 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { // H01 SaveWriterInvariants. { PhaseRecord rec; - rec.desc = &hp[2]; + rec.desc = &hp[3]; const int before = game.summary.turn; ApplySaveWriterInvariants(game, r); rec.invocations = 1; diff --git a/src/game/sim/CMakeLists.txt b/src/game/sim/CMakeLists.txt index c9aa890..5925cda 100644 --- a/src/game/sim/CMakeLists.txt +++ b/src/game/sim/CMakeLists.txt @@ -9,6 +9,7 @@ add_library(sots_game_sim STATIC colony.cpp movement.cpp visibility.cpp + scriptobjects.cpp techgraph.cpp player_turn.cpp) target_include_directories(sots_game_sim PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..) @@ -20,7 +21,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 construction player_turn) + set(_sim_tests economy research colony movement techgraph visibility construction player_turn scriptobjects) 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) diff --git a/src/game/sim/scriptobjects.cpp b/src/game/sim/scriptobjects.cpp new file mode 100644 index 0000000..f5a0d1c --- /dev/null +++ b/src/game/sim/scriptobjects.cpp @@ -0,0 +1,100 @@ +#include "game/sim/scriptobjects.h" + +#include + +namespace sots::sim { + +int SlaverDifficultyTier(int frame) { + // The scan the handler performs: walk the table, stop at the first threshold GREATER + // than the frame, and take that index minus one. Running off the end is not "the last + // tier" -- it is no write at all. + int index = 0; + for (; index < 3; ++index) { + if (kSlaverTierThresholds[index] > frame) break; + } + if (index >= 3) return -1; // frame >= 100: the scan exhausted the table + if (index <= 0) return -1; // frame <= 0: the first record already exceeds it + return index - 1; +} + +SlaverTierResult SlaversTurnEnd(int stored, int frame) { + SlaverTierResult r; + r.tier = stored; + const int tier = SlaverDifficultyTier(frame); + if (tier < 0) return r; // the handler returns before the store + if (tier == stored) return r; + r.wrote = true; + r.tier = tier; + return r; +} + +RefugeesTurnBeginResult RefugeesTurnBegin(bool& ini) { + RefugeesTurnBeginResult r; + if (ini) return r; + // The store to the latch is the second instruction of the guarded block, before the + // data-file lookup and unconditional on whether it finds anything. So a caller that + // cannot instantiate the convoy still latches, and the two facts are separate. + ini = true; + r.latched = true; + r.convoyOwed = true; + return r; +} + +namespace { + +bool HasHiveFor(const std::vector& hives, std::int32_t systemId) { + for (const Hive& h : hives) + if (h.systemId == systemId) return true; + return false; +} + +} // namespace + +std::vector HivesToRegister(const std::vector& systemIds, + const std::vector& scenarioTags, + const std::vector& hives, + std::int32_t scenarioTag) { + std::vector out; + const std::size_t n = std::min(systemIds.size(), scenarioTags.size()); + for (std::size_t i = 0; i < n; ++i) { + if (scenarioTags[i] != scenarioTag) continue; + if (HasHiveFor(hives, systemIds[i])) continue; + out.push_back(systemIds[i]); + } + return out; +} + +int PruneHives(std::vector& hives, const std::vector& systemIds, + const std::vector& scenarioTags, std::int32_t scenarioTag) { + const std::size_t n = std::min(systemIds.size(), scenarioTags.size()); + const std::size_t before = hives.size(); + hives.erase(std::remove_if(hives.begin(), hives.end(), + [&](const Hive& h) { + for (std::size_t i = 0; i < n; ++i) { + if (systemIds[i] != h.systemId) continue; + return scenarioTags[i] != scenarioTag; + } + // A hive whose system is not in the list at all has lost + // its system and goes the same way. + return true; + }), + hives.end()); + return static_cast(before - hives.size()); +} + +HiveTickResult TickHives(std::vector& hives, int frame, bool gatesOpen) { + HiveTickResult r; + for (Hive& h : hives) { + if (h.queenId != 0) continue; + if (!gatesOpen) { + ++h.nextQueenTurn; + ++r.slipped; + continue; + } + if (h.nextQueenTurn > frame) continue; // the date has not arrived; no slip either + ++r.spawnsOwed; + } + return r; +} + +} // namespace sots::sim diff --git a/src/game/sim/scriptobjects.h b/src/game/sim/scriptobjects.h new file mode 100644 index 0000000..55ac11e --- /dev/null +++ b/src/game/sim/scriptobjects.h @@ -0,0 +1,131 @@ +// The script objects' per-turn rules. +// +// A SOTS game carries a tree of "script objects" -- the scripted encounters (von Neumann, +// swarm, derelicts, monitors, slavers, crow ruins, refugees) and the scenario objects +// (traps, crow defenders, independent systems, grand-menace trigger). They hang off the +// strategy server, they are serialised into the save, and they are updated by an EVENT BUS +// rather than by direct calls from the turn drivers. +// +// The bus is the thing to understand. A driver notifies the ROOT object with an event id, +// and the root fans the same delivery out to every child. Each delivery is TWO steps: a +// generic handler that receives the id, and one event-specific virtual slot that does not. +// The id -> slot map is a 33-entry table in the image, so "which object reacts to which +// event" is an exhaustive, recovered fact, not an inference from what the saves happen to +// show. See docs/SV-script-objects.md for the whole table and for the event a turn sends +// at each of its six delivery points. +// +// This header holds the rules themselves as pure functions -- no save shapes, no I/O -- for +// the three objects the reference pair actually moves. Everything they need is passed in. +// +// WHAT IS DELIBERATELY NOT HERE +// +// * The refugee-trader convoy. The refugees object latches an "initialised" flag on the +// first turn and, behind the same latch, instantiates a design from the data files and +// records its handle. The latch is modelled; the instantiation is not, because nothing +// in the standalone allocates handles. `RefugeesTurnBegin` says so in its result. +// * The queen spawn. Only the "not yet" arm of the swarm-queen tick has ever run on any +// save this project holds; no hive in the corpus has a queen. +// * The draw. A newly registered hive takes its target turn from the strategic generator. +// That draw happens inside the turn-begin step, which is OUTSIDE both turn drivers and +// before either of them -- a place the campaign's measured RNG ledger has never had a +// turn that reached. `HiveRegistration` reports the draw it would need and lets the +// caller decide; it never invents a number. +#pragma once + +#include +#include + +namespace sots::sim { + +// --------------------------------------------------------------------------------------- +// Slavers refuel: the difficulty tier +// --------------------------------------------------------------------------------------- + +// The slavers object reacts to exactly one event -- the tail's end-of-turn hook -- and all +// it does there is move a difficulty tier. The tier comes from a three-record table built +// on the stack of the handler, scanned for the FIRST record whose threshold EXCEEDS the +// frame; the tier is that record's index minus one. +// +// The scan has two edges that a reader of the data alone would never find, and both are +// what the instructions do rather than what they presumably meant: +// +// frame <= 0 the first record already exceeds it, so the index is 0 and the handler +// returns before writing anything; +// frame >= 100 the scan runs off the end of the table and again writes NOTHING, so the +// tier can never reach 2 by this path. +// +// The tier is written only when it differs from the stored one, which is why the reference +// pair moves it on turn 1 and never again. +inline constexpr int kSlaverTierThresholds[3] = {1, 50, 100}; + +// The tier this frame implies, or -1 for "the handler writes nothing at this frame". +int SlaverDifficultyTier(int frame); + +// The result of running the slavers' end-of-turn hook. +struct SlaverTierResult { + bool wrote = false; // the stored tier changed + int tier = -1; // what it now is (unchanged when `wrote` is false) +}; + +// `stored` is the object's current CDiff. +SlaverTierResult SlaversTurnEnd(int stored, int frame); + +// --------------------------------------------------------------------------------------- +// Refugees: the one-shot latch +// --------------------------------------------------------------------------------------- + +struct RefugeesTurnBeginResult { + bool latched = false; // the flag moved false -> true on this call + bool convoyOwed = false; // a design instantiation is owed and is not modelled +}; + +// Runs at turn begin. `ini` is read and written. +RefugeesTurnBeginResult RefugeesTurnBegin(bool& ini); + +// --------------------------------------------------------------------------------------- +// Swarm queen: hives +// --------------------------------------------------------------------------------------- + +// One hive. `systemId` is the star system it sits on; `nextQueenTurn` is an ABSOLUTE turn, +// not a countdown, and `queenId` is 0 until a queen exists. +struct Hive { + std::int32_t systemId = 0; + std::int32_t queenId = 0; + std::int32_t nextQueenTurn = 0; +}; + +// The swarm queen's constructor stores TWO ids: the scenario tag it operates on (3, the +// swarm's) and its own encounter id (10). It is the first that selects systems, so the +// queen registers hives on the SWARM's systems. A star system names its owning scenario in +// the field the save calls `EggScio`. +inline constexpr std::int32_t kSwarmScenarioTag = 3; + +// Which of `systemIds` need a hive, given the hives that already exist. `scenarioTags` is +// parallel to `systemIds` and holds each system's `EggScio`. The order is the order the +// systems are visited, which is the order the original draws in. +std::vector HivesToRegister(const std::vector& systemIds, + const std::vector& scenarioTags, + const std::vector& hives, + std::int32_t scenarioTag = kSwarmScenarioTag); + +// Drop hives whose system no longer carries the scenario tag. Returns how many went. +int PruneHives(std::vector& hives, const std::vector& systemIds, + const std::vector& scenarioTags, + std::int32_t scenarioTag = kSwarmScenarioTag); + +// The per-turn tick over the hives that have no queen. Every corpus save takes the same arm +// of it: the spawn gates fail and the target turn SLIPS FORWARD BY ONE. That single +// increment is the whole explanation of a target turn that reads 31 after turn 1 and 32 +// after turn 2 -- it is not re-rolled, it walks. +// +// `gatesOpen` is the caller's answer for "may a queen spawn at all this turn". The +// standalone has no evidence for any of those gates, so it passes false and this function +// takes the arm the corpus has always taken; the parameter exists so the shape of the rule +// is stated rather than assumed away. +struct HiveTickResult { + int slipped = 0; // hives whose target turn moved forward one + int spawnsOwed = 0; // hives that would have spawned and are not modelled +}; +HiveTickResult TickHives(std::vector& hives, int frame, bool gatesOpen = false); + +} // namespace sots::sim diff --git a/tests/app/test_catalog.cpp b/tests/app/test_catalog.cpp index 68937aa..49d2514 100644 --- a/tests/app/test_catalog.cpp +++ b/tests/app/test_catalog.cpp @@ -49,9 +49,9 @@ int main() { CHECK(static_cast(ns + np) == kSpinePhaseCount); // The host steps are deliberately NOT part of the milestone's denominator. Their ids run - // H00, H02, H01 because the table is in EXECUTION order and H02 was read later than the - // step it runs before; the id is the stable name, the index is the ordinal. - CHECK(nh == 3); + // H00, H03, H02, H01 because the table is in EXECUTION order and each id was assigned when + // the step was read, not when it runs; the id is the stable name, the index is the ordinal. + CHECK(nh == 4); CheckTable(h, nh, Driver::Host, 0, ids, names); CheckTable(s, ns, Driver::Strategic, 0, ids, names); CheckTable(p, np, Driver::Player, 1, ids, names); diff --git a/tests/game_sim/build_and_run.sh b/tests/game_sim/build_and_run.sh index b632b4f..f96dad1 100755 --- a/tests/game_sim/build_and_run.sh +++ b/tests/game_sim/build_and_run.sh @@ -13,7 +13,8 @@ mkdir -p "$build" CXX="${CXX:-g++}" CXXFLAGS="${CXXFLAGS:--std=c++17 -O1 -g -Wall -Wextra -Werror -pedantic}" srcs=("$root"/src/game/sim/economy.cpp "$root"/src/game/sim/research.cpp \ - "$root"/src/game/sim/colony.cpp "$root"/src/game/sim/movement.cpp) + "$root"/src/game/sim/colony.cpp "$root"/src/game/sim/movement.cpp \ + "$root"/src/game/sim/scriptobjects.cpp) objs=() for s in "${srcs[@]}"; do @@ -23,7 +24,7 @@ for s in "${srcs[@]}"; do done status=0 -for t in economy research colony movement; do +for t in economy research colony movement scriptobjects; do exe="$build/test_$t" $CXX $CXXFLAGS -I"$root/src" -I"$here" "$here/test_$t.cpp" "${objs[@]}" -o "$exe" if ! "$exe"; then status=1; fi diff --git a/tests/game_sim/test_scriptobjects.cpp b/tests/game_sim/test_scriptobjects.cpp new file mode 100644 index 0000000..c3767c8 --- /dev/null +++ b/tests/game_sim/test_scriptobjects.cpp @@ -0,0 +1,141 @@ +#include "game/sim/scriptobjects.h" + +#include "check.h" + +using namespace sots::sim; + +// The tier scan, at the boundaries the corpus cannot reach. Rule 23: the reference pair only +// ever exercises frame 1 and 2, so every other row here is hand-computed from the table the +// handler builds and is the only check those rows will ever get. +static void test_slaver_tier() { + // Below the first threshold the scan stops at index 0 and returns before storing. + CHECK_EQ(SlaverDifficultyTier(-5), -1); + CHECK_EQ(SlaverDifficultyTier(0), -1); + // 1..49 is tier 0. Both ends by hand. + CHECK_EQ(SlaverDifficultyTier(1), 0); + CHECK_EQ(SlaverDifficultyTier(2), 0); + CHECK_EQ(SlaverDifficultyTier(49), 0); + // 50..99 is tier 1. Both ends by hand. + CHECK_EQ(SlaverDifficultyTier(50), 1); + CHECK_EQ(SlaverDifficultyTier(99), 1); + // And at 100 the scan runs off the end of a three-record table, so it writes NOTHING. + // Tier 2 is unreachable through this path. This is what the instructions do; a reader + // who assumed "last tier wins" would have it wrong from turn 100 on. + CHECK_EQ(SlaverDifficultyTier(100), -1); + CHECK_EQ(SlaverDifficultyTier(1000), -1); +} + +static void test_slaver_store_is_conditional() { + // The reference pair: -1 at frame 1 becomes 0. + SlaverTierResult r = SlaversTurnEnd(-1, 1); + CHECK(r.wrote); + CHECK_EQ(r.tier, 0); + // The turn after: already 0, so no store. This is why the second reference pair moves + // nothing here, and that asymmetry is the check that the rule is one-shot per tier. + r = SlaversTurnEnd(0, 2); + CHECK(!r.wrote); + CHECK_EQ(r.tier, 0); + // Past 100 the scan writes nothing even though the stored tier is stale. + r = SlaversTurnEnd(1, 250); + CHECK(!r.wrote); + CHECK_EQ(r.tier, 1); + // A save that somehow carries a tier ahead of its frame is written back DOWN, because + // the store is on inequality, not on ordering. + r = SlaversTurnEnd(1, 5); + CHECK(r.wrote); + CHECK_EQ(r.tier, 0); +} + +static void test_refugee_latch() { + bool ini = false; + RefugeesTurnBeginResult r = RefugeesTurnBegin(ini); + CHECK(r.latched); + CHECK(ini); + CHECK(r.convoyOwed); + // Second delivery: the latch holds and nothing is owed again. + r = RefugeesTurnBegin(ini); + CHECK(!r.latched); + CHECK(!r.convoyOwed); + CHECK(ini); +} + +// The reference pair's map: two systems tagged with the swarm's scenario id, and three +// tagged with someone else's. Only the two get hives. +static void test_hive_registration() { + const std::vector ids = {64, 336, 400, 448, 480}; + const std::vector tags = {5, 3, 3, 4, 4}; + std::vector hives; + std::vector owed = HivesToRegister(ids, tags, hives); + CHECK_EQ(owed.size(), std::size_t{2}); + CHECK_EQ(owed[0], std::int32_t{336}); + CHECK_EQ(owed[1], std::int32_t{400}); + + hives.push_back(Hive{336, 0, 30}); + hives.push_back(Hive{400, 0, 28}); + // Registration is idempotent: nothing is owed once the hives exist, which is why the + // second reference pair moves no hive leaf. + owed = HivesToRegister(ids, tags, hives); + CHECK(owed.empty()); +} + +static void test_hive_prune() { + const std::vector ids = {336, 400}; + std::vector tags = {3, 3}; + std::vector hives = {Hive{336, 0, 30}, Hive{400, 0, 28}}; + CHECK_EQ(PruneHives(hives, ids, tags), 0); + CHECK_EQ(hives.size(), std::size_t{2}); + + // The swarm loses one system: its hive goes. + tags[1] = -1; + CHECK_EQ(PruneHives(hives, ids, tags), 1); + CHECK_EQ(hives.size(), std::size_t{1}); + CHECK_EQ(hives[0].systemId, std::int32_t{336}); + + // A hive whose system is not in the map at all goes the same way. + hives.push_back(Hive{9999, 0, 5}); + CHECK_EQ(PruneHives(hives, ids, tags), 1); + CHECK_EQ(hives.size(), std::size_t{1}); +} + +// The whole explanation of a number that looked impossible: 31 after turn 1, 32 after turn +// 2. It is not re-rolled -- it walks forward one per turn the gates stay shut. +static void test_hive_tick_slips() { + std::vector hives = {Hive{336, 0, 30}, Hive{400, 0, 28}}; + HiveTickResult t = TickHives(hives, 1); + CHECK_EQ(t.slipped, 2); + CHECK_EQ(hives[0].nextQueenTurn, std::int32_t{31}); + CHECK_EQ(hives[1].nextQueenTurn, std::int32_t{29}); + t = TickHives(hives, 2); + CHECK_EQ(t.slipped, 2); + CHECK_EQ(hives[0].nextQueenTurn, std::int32_t{32}); + CHECK_EQ(hives[1].nextQueenTurn, std::int32_t{30}); + + // A hive that already has a queen is not ticked at all. + hives[0].queenId = 77; + t = TickHives(hives, 3); + CHECK_EQ(t.slipped, 1); + CHECK_EQ(hives[0].nextQueenTurn, std::int32_t{32}); +} + +// The arm no corpus save has ever taken, stated so the shape of the rule is on record: with +// the gates open a hive whose date has arrived spawns, and one whose date has not does NOT +// slip -- the slip belongs to the failed-gate arm alone. +static void test_hive_tick_gates_open() { + std::vector hives = {Hive{336, 0, 3}, Hive{400, 0, 99}}; + const HiveTickResult t = TickHives(hives, 10, /*gatesOpen=*/true); + CHECK_EQ(t.spawnsOwed, 1); + CHECK_EQ(t.slipped, 0); + CHECK_EQ(hives[0].nextQueenTurn, std::int32_t{3}); + CHECK_EQ(hives[1].nextQueenTurn, std::int32_t{99}); +} + +int main() { + test_slaver_tier(); + test_slaver_store_is_conditional(); + test_refugee_latch(); + test_hive_registration(); + test_hive_prune(); + test_hive_tick_slips(); + test_hive_tick_gates_open(); + return simtest::finish("scriptobjects"); +}