merge lane SV: script-object event bus (CMake lists merged into their constructs, rule 22)
This commit is contained in:
commit
fe8ed45de4
12 changed files with 958 additions and 12 deletions
264
docs/SV-script-objects.md
Normal file
264
docs/SV-script-objects.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
213
src/app/script_phase.cpp
Normal file
213
src/app/script_phase.cpp
Normal 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
54
src/app/script_phase.h
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
100
src/game/sim/scriptobjects.cpp
Normal file
100
src/game/sim/scriptobjects.cpp
Normal 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
|
||||
131
src/game/sim/scriptobjects.h
Normal file
131
src/game/sim/scriptobjects.h
Normal 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
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
141
tests/game_sim/test_scriptobjects.cpp
Normal file
141
tests/game_sim/test_scriptobjects.cpp
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue