diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 02824cf..6fcd4da 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -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) diff --git a/src/app/construction_phase.cpp b/src/app/construction_phase.cpp new file mode 100644 index 0000000..477adaf --- /dev/null +++ b/src/app/construction_phase.cpp @@ -0,0 +1,96 @@ +#include "app/construction_phase.h" + +#include +#include +#include + +#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 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(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(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 diff --git a/src/app/construction_phase.h b/src/app/construction_phase.h new file mode 100644 index 0000000..510f9bd --- /dev/null +++ b/src/app/construction_phase.h @@ -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 +#include + +#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 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 diff --git a/src/app/phase_catalog.cpp b/src/app/phase_catalog.cpp index 5b83b06..1921c51 100644 --- a/src/app/phase_catalog.cpp +++ b/src/app/phase_catalog.cpp @@ -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, diff --git a/src/app/turn.cpp b/src/app/turn.cpp index 8cef6eb..63514d5 100644 --- a/src/app/turn.cpp +++ b/src/app/turn.cpp @@ -8,6 +8,7 @@ #include #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 diff --git a/src/game/sim/CMakeLists.txt b/src/game/sim/CMakeLists.txt index c234d55..df5b5bd 100644 --- a/src/game/sim/CMakeLists.txt +++ b/src/game/sim/CMakeLists.txt @@ -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) diff --git a/src/game/sim/colony.cpp b/src/game/sim/colony.cpp index e917df1..19e4e30 100644 --- a/src/game/sim/colony.cpp +++ b/src/game/sim/colony.cpp @@ -541,7 +541,14 @@ void AccrueSystemBonus(const SystemBonusInputs& in, std::int64_t& popBonus, doub BuildQueueResult ProcessBuildQueue(std::vector& 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; diff --git a/src/game/sim/colony.h b/src/game/sim/colony.h index cdc36de..623dec4 100644 --- a/src/game/sim/colony.h +++ b/src/game/sim/colony.h @@ -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& queue, int points); // --------------------------------------------------------------------------------------- diff --git a/src/game/sim/construction.cpp b/src/game/sim/construction.cpp new file mode 100644 index 0000000..39d4b61 --- /dev/null +++ b/src/game/sim/construction.cpp @@ -0,0 +1,66 @@ +#include "game/sim/construction.h" + +#include +#include + +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& queue, int points) { + SystemConstructionResult out; + out.pointsIn = points; + out.ordersBefore = static_cast(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 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(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 diff --git a/src/game/sim/construction.h b/src/game/sim/construction.h new file mode 100644 index 0000000..e79a3b4 --- /dev/null +++ b/src/game/sim/construction.h @@ -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 +#include + +#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 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 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& queue, int points); + +} // namespace sots::sim diff --git a/tests/game_sim/CMakeLists.txt b/tests/game_sim/CMakeLists.txt index 123a95e..559703c 100644 --- a/tests/game_sim/CMakeLists.txt +++ b/tests/game_sim/CMakeLists.txt @@ -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}) diff --git a/tests/game_sim/test_construction.cpp b/tests/game_sim/test_construction.cpp new file mode 100644 index 0000000..2cc29de --- /dev/null +++ b/tests/game_sim/test_construction.cpp @@ -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 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 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 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 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 before; + std::vector after; + std::vector expectedCompletedDesigns; +}; + +static int solve_points(const Fixture& f, int* solutions) { + int found = -1; + *solutions = 0; + for (int p = 0; p <= 200000; ++p) { + std::vector 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 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 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 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"); +}