game/sim + app: ship construction -- the build-queue completion bookkeeping, and S11's sub-pass wired in

game/sim/construction.{h,cpp}: Game::ShipRecords (four per-hull-class arrays plus the
per-design vector, sized by ENUMERATION against the wire, not by what the code touches)
and the completion bookkeeping BuildQueue::ProcessTurn performs -- the per-class built
counter, whose indexed increment has EXACTLY ONE writer in the whole image, and the
find-or-append per-design record keyed by the design's object id. RunSystemConstruction
wraps the point pass and keeps each completion's design id, which the point pass alone
does not report.

game/sim/colony: corrected from the instruction stream -- with points <= 0 the entry test
branches to the epilogue, so the REMOVAL SWEEP IS SKIPPED TOO. Carried as a labelled
hypothesis: no corpus save can reach the state that shows it.

app/construction_phase.{h,cpp}: S11's build-queue sub-pass, reported on its own line
because what blocks it is not what blocks the rest of the colony turn. It is blocked on
the per-system output term for points; it is NOT what the archived ship census waits on.

tests/game_sim/test_construction.cpp: 65 checks, including a corpus oracle the campaign
already owned and had not noticed -- zuul-turn16-noderoute -> zuul-turn17-rollpending is a
real consecutive-turn pair in which six orders complete and one is partially advanced.
The test SOLVES for the point total rather than assuming it, so a non-FIFO order, a
per-order budget or skip-instead-of-stop each falsify it.
This commit is contained in:
lane-b6 2026-09-08 14:39:20 -04:00
parent 9f8d75d9cb
commit 0fcbb69ba5
12 changed files with 607 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

@ -0,0 +1,109 @@
// 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);
} // 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).
foreach(_t economy research colony movement techgraph visibility)
foreach(_t economy research colony movement techgraph visibility construction)
add_executable(game_sim_test_${_t} test_${_t}.cpp)
target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim)
target_include_directories(game_sim_test_${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})

View file

@ -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");
}