sots-engine/tests/game_sim/test_construction.cpp
lane-b6 0fcbb69ba5 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.
2026-09-08 14:39:20 -04:00

244 lines
10 KiB
C++

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