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:
parent
9f8d75d9cb
commit
0fcbb69ba5
12 changed files with 607 additions and 5 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
96
src/app/construction_phase.cpp
Normal file
96
src/app/construction_phase.cpp
Normal 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
|
||||||
58
src/app/construction_phase.h
Normal file
58
src/app/construction_phase.h
Normal 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
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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 "
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -541,7 +541,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;
|
||||||
|
|
|
||||||
|
|
@ -599,6 +599,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);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
66
src/game/sim/construction.cpp
Normal file
66
src/game/sim/construction.cpp
Normal 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
109
src/game/sim/construction.h
Normal 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
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
244
tests/game_sim/test_construction.cpp
Normal file
244
tests/game_sim/test_construction.cpp
Normal 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");
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue