merge lane B6: build-queue completion pass; shpt[0] cannot close from a save with no orders - blocker moves to game/ai

This commit is contained in:
alex 2026-09-08 14:56:16 -04:00
commit 2fd08522ec
13 changed files with 806 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -79,8 +79,12 @@ constexpr PhaseDesc kStrategic[] = {
"upkeep of population carried aboard colony/slaver hulls in transit"}, "upkeep of population carried aboard colony/slaver hulls in transit"},
{Driver::Strategic, 11, "S11", "SystemTurn", PhaseStatus::Partial, {Driver::Strategic, 11, "S11", "SystemTurn", PhaseStatus::Partial,
"runs game::sim ProcessColonyTurn per system and commits the parts that need neither the " "runs game::sim ProcessColonyTurn per system and commits the parts that need neither the "
"tuning table nor a carrying capacity; plague, growth, resources, slaves, rebellion and " "tuning table nor a carrying capacity; plague, growth, resources, slaves and rebellion "
"the build queue are the sub-passes the model already declares as its input boundary"}, "are the sub-passes the model still declares as its input boundary. The BUILD QUEUE is "
"modelled and runs (it is the only writer of the per-class built counter in the whole "
"image) but has no points to spend: they come from the per-system output term. It is "
"NOT what the missing destroyer waits on -- no build order exists anywhere in either "
"reference save, so that order is created inside the turn by the AI"},
{Driver::Strategic, 12, "S12", "TradeSliderFinalisation", PhaseStatus::Stub, {Driver::Strategic, 12, "S12", "TradeSliderFinalisation", PhaseStatus::Stub,
"re-normalises the per-system output rates"}, "re-normalises the per-system output rates"},
{Driver::Strategic, 13, "S13", "PlayerTurn", PhaseStatus::Partial, {Driver::Strategic, 13, "S13", "PlayerTurn", PhaseStatus::Partial,

View file

@ -8,6 +8,7 @@
#include <string> #include <string>
#include "app/alliance.h" #include "app/alliance.h"
#include "app/construction_phase.h"
#include "app/trade_raid.h" #include "app/trade_raid.h"
#include "app/turn_record.h" #include "app/turn_record.h"
#include "app/visibility_phase.h" #include "app/visibility_phase.h"
@ -922,6 +923,14 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
rec.notes.push_back(fmt("%d system(s) left their countdown words alone: the " rec.notes.push_back(fmt("%d system(s) left their countdown words alone: the "
"companion active-player mask is not identified", "companion active-player mask is not identified",
st.skippedCountdown)); st.skippedCountdown));
// The build-queue sub-pass reports on its own line rather than folding its
// numbers into S11's, because what blocks it is not what blocks the rest of
// the colony turn. See app/construction_phase.h.
{
const ConstructionPhaseResult b = RunBuildQueues(game);
rec.wouldWrite += b.wouldWrite;
for (const auto& n : b.notes) rec.notes.push_back("build queue: " + n);
}
break; break;
} }
case 13: { // S13 PlayerTurn -- the nested driver case 13: { // S13 PlayerTurn -- the nested driver

View file

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

View file

@ -629,7 +629,14 @@ void AccrueSystemBonus(const SystemBonusInputs& in, std::int64_t& popBonus, doub
BuildQueueResult ProcessBuildQueue(std::vector<BuildOrder>& queue, int points) { BuildQueueResult ProcessBuildQueue(std::vector<BuildOrder>& queue, int points) {
BuildQueueResult r; BuildQueueResult r;
if (points > 0) { if (points <= 0) {
// The whole body is skipped, REMOVAL SWEEP INCLUDED: the entry test branches
// straight to the epilogue, which returns the points untouched. Corrected by lane
// B6 from the instruction stream; see the header for why no save can show it.
r.pointsLeft = points;
return r;
}
{
for (BuildOrder& o : queue) { for (BuildOrder& o : queue) {
if (o.constructionLeft > points) { if (o.constructionLeft > points) {
o.constructionLeft -= points; o.constructionLeft -= points;

View file

@ -719,6 +719,13 @@ struct BuildQueueResult {
// including ones that were already at zero before this turn. More than one order can // including ones that were already at zero before this turn. More than one order can
// complete in a turn. CONFIDENCE: high -- B4 corrected the money-refusal path (continue, not // complete in a turn. CONFIDENCE: high -- B4 corrected the money-refusal path (continue, not
// stop), the removal sweep's predicate, and that the leftover is the return value. // stop), the removal sweep's predicate, and that the leftover is the return value.
//
// The `points <= 0` case skips **everything**, the removal sweep included: the entry test
// branches to the epilogue, which returns the argument. Corrected by lane B6 from the
// instruction stream, and carried as a LABELLED HYPOTHESIS rather than a result because no
// save can exercise it: an order can only reach `conleft <= 0` inside this pass, and this
// pass erases it before returning, so a queue never *starts* a turn with one -- unless a
// design with zero construction cost is ever queued, which no corpus save has done.
BuildQueueResult ProcessBuildQueue(std::vector<BuildOrder>& queue, int points); BuildQueueResult ProcessBuildQueue(std::vector<BuildOrder>& queue, int points);
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------

View file

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

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

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

View file

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

View file

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