SV: the script-object event bus, and what it writes in a turn

`SvSctOb` is not written by direct calls. Every update goes through an event bus: a
driver notifies the root object with an integer id, the root fans the delivery out to
every child, and each delivery is two steps -- a generic handler that takes the id, then
one event-specific vtable slot that does not. The id -> slot map is a 33-entry jump table
in the image, so which class reacts to which event is recovered and exhaustive rather than
inferred from what the saves happen to show. Five of the 33 rows are not in slot order,
including two the tail sends.

Three handlers write the eight leaves that diverged:

  * the slavers' difficulty tier, on the tail's end-of-turn delivery -- a three-record
    stack table scanned against the frame, boundaries 1/50/100, stored only on a change,
    and at frame 100 and above the scan runs off the end and stores nothing, so the tier
    can never reach 2;
  * the refugees' one-shot latch, on the turn-begin delivery, with a design instantiation
    behind the same latch that nothing here can do;
  * the swarm queen's hives, also at turn begin, registered on the systems carrying the
    SWARM's scenario tag (the queen's constructor stores 3 for that and 10 for its own
    encounter id) and then ticked -- and the tick is the whole explanation of a target
    turn that reads 31 after one turn and 32 after the next. It is not re-rolled; it slips
    forward by one every turn the spawn gates stay shut.

New host phase H03 for the turn-begin delivery, run right after the frame counter where
the original sends it, and tail phase T20 implemented. Rules are pure in game/sim.

Measured on CT111, closed and regressed stated separately:

  default                 turn1->turn2  209 -> 126 (was 128)  closed 83, regressed 0
                          turn2->turn3  108 ->  67 (was  69)  closed 41, regressed 0
  --commit-blocked=H03    turn1->turn2  209 -> 124            closed 87, regressed 2
                          turn2->turn3  108 ->  67            closed 41, regressed 0

Registering a hive closes the four leaves that say which systems have hives and that they
have no queens, and opens two carrying a target turn known to be wrong: the original draws
it from the strategic generator inside the turn-begin step, outside both turn drivers, and
neither the two data-file constants nor the generator's position there is settled. That
trade is a flag, not a default.

The prediction in docs/SV-script-objects.md was committed before the build, and P5 was
wrong: it called the second pair a null control, and the second pair is where the slip
rule is tested EXACTLY -- two hives, two target turns, both landing on the oracle with no
draw and no fitting.

Gates run separately: clean-room OK, host ctest 51/51, CT111 shim cross-build clean.
This commit is contained in:
alex 2026-09-08 16:46:25 -04:00
parent aabd8a3506
commit 2d93404c67
12 changed files with 958 additions and 12 deletions

264
docs/SV-script-objects.md Normal file
View file

@ -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[<slot(evt)>](...); // 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 = <instantiate "_Refugee_Trader" from the "Mission" section of the data files>;
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 <spawn gates fail>: ++hive.nextQ
elif hive.nextQ <= frame: <spawn a queen>
```
`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.

View file

@ -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)

View file

@ -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"},
};
@ -231,7 +240,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"},
@ -251,7 +263,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 "

213
src/app/script_phase.cpp Normal file
View file

@ -0,0 +1,213 @@
#include "app/script_phase.h"
#include <cstdarg>
#include <cstdio>
#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<std::int32_t>& ids,
std::vector<std::int32_t>& 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<std::int32_t> sysIds, tags;
SystemScenarioTags(game, sysIds, tags);
std::vector<sim::Hive> 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<std::int32_t> owed = sim::HivesToRegister(sysIds, tags, hives);
const std::vector<std::int32_t> registered =
registerHives ? owed : std::vector<std::int32_t>{};
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<int>(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<int>(before), static_cast<int>(owed.size()),
static_cast<int>(registered.size()), pruned, tick.slipped,
static_cast<int>(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<int>(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

54
src/app/script_phase.h Normal file
View file

@ -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 <string>
#include <vector>
#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<std::string> 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

View file

@ -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"
@ -920,13 +921,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;
@ -1230,6 +1246,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);
} else if (tp[i].index == 36) {
@ -1241,7 +1263,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;

View file

@ -9,6 +9,7 @@ add_library(sots_game_sim STATIC
colony.cpp
movement.cpp
visibility.cpp
scriptobjects.cpp
techgraph.cpp)
target_include_directories(sots_game_sim PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..)
target_compile_features(sots_game_sim PUBLIC cxx_std_17)
@ -19,7 +20,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)
set(_sim_tests economy research colony movement techgraph visibility construction 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)

View file

@ -0,0 +1,100 @@
#include "game/sim/scriptobjects.h"
#include <algorithm>
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<Hive>& hives, std::int32_t systemId) {
for (const Hive& h : hives)
if (h.systemId == systemId) return true;
return false;
}
} // namespace
std::vector<std::int32_t> HivesToRegister(const std::vector<std::int32_t>& systemIds,
const std::vector<std::int32_t>& scenarioTags,
const std::vector<Hive>& hives,
std::int32_t scenarioTag) {
std::vector<std::int32_t> 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<Hive>& hives, const std::vector<std::int32_t>& systemIds,
const std::vector<std::int32_t>& 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<int>(before - hives.size());
}
HiveTickResult TickHives(std::vector<Hive>& 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

View file

@ -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 <cstdint>
#include <vector>
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<std::int32_t> HivesToRegister(const std::vector<std::int32_t>& systemIds,
const std::vector<std::int32_t>& scenarioTags,
const std::vector<Hive>& hives,
std::int32_t scenarioTag = kSwarmScenarioTag);
// Drop hives whose system no longer carries the scenario tag. Returns how many went.
int PruneHives(std::vector<Hive>& hives, const std::vector<std::int32_t>& systemIds,
const std::vector<std::int32_t>& 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<Hive>& hives, int frame, bool gatesOpen = false);
} // namespace sots::sim

View file

@ -49,9 +49,9 @@ int main() {
CHECK(static_cast<int>(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);

View file

@ -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

View file

@ -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<std::int32_t> ids = {64, 336, 400, 448, 480};
const std::vector<std::int32_t> tags = {5, 3, 3, 4, 4};
std::vector<Hive> hives;
std::vector<std::int32_t> 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<std::int32_t> ids = {336, 400};
std::vector<std::int32_t> tags = {3, 3};
std::vector<Hive> 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<Hive> 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<Hive> 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");
}