8 Commits

Author SHA1 Message Date
590bca458c foo 2026-08-05 08:21:30 +02:00
59067a9c49 put BuildingTest's 33 hand-rolled fixtures onto PlacementFixture
Every one of the 33 standalone tests rebuilt the same seven-line preamble and
the same four-lambda BuildingSystem construction, while PlacementFixture sat
alongside doing exactly that. Each session of this refactor made those 33 blocks
a line longer, which is what made it worth fixing now.

They were not quite identical: 25 used the configured belt speed and 8 a fast
belt of one tile per tick, marked by comments rather than by anything the code
said. The fixture now takes an optional belt speed and kFastBeltSpeed_tps names
the concept, so the difference is visible at the call site instead of buried in
a static_cast.

The per-test comments that explained a choice — the fast belt, the RNG seed —
are kept; the fixture uses the same seed 0 those tests set by hand.

452 test cases and 3431 assertions before and after, so nothing was dropped in
the conversion.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-05 07:29:03 +02:00
72d85d681c depend on the registry instead of DebrisSystem in the AI path
getAllDebrisInfo and collectOne only ever touched EntityAdmin — DebrisSystem
holds nothing else — so they become free functions over the registry. That lets
AiSystem, SalvagerSystem and SalvageScrapEvaluator drop their DebrisSystem&
parameters entirely; SalvagerSystem already held the admin, and the other two
were handed it alongside.

No system in lib/ecs/system takes another system now. Every tick signature names
the data it works on: the registry, the factory state, or both.

DebrisSystem keeps spawn, tickDespawn and consume — the first two are genuine
tick behaviour rather than lookups.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-05 07:16:36 +02:00
a86ba3428a make deconstruction its own system
DeconstructionSystem takes over the demolition queue: it runs the front entry's
timer and, when it elapses, removes the building, releases its tiles and credits
the partial refund. Simulation::tick calls it directly, in the position
tickDeconstruction held.

It needs no BeltSystem, unlike its construction counterpart: a belt, splitter or
tunnel end is unregistered the moment it is queued, not when the timer completes.
It does need the refund sink, so it takes the same addBuildingBlocks callback
BuildingSystem holds.

startFrontDeconstruction becomes a shared free function rather than moving:
BuildingSystem::deconstruct starts the timer when it queues the first entry, and
the system restarts it after each completion.

Stubbing the refund sink out in the test helper made two tests fail on the
refund not arriving — correctly. runTicks now threads the caller's stock through
instead.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-05 07:01:22 +02:00
56b7248ac7 make construction its own system
ConstructionSystem takes over the construction queue: it runs the front site's
timer and, when it elapses, builds the Building itself — ports, buffers, belt
registration, and starting the next queued site. Simulation::tick calls it
directly, in the same position tickConstruction held.

No handoff. The earlier sketch had it return the completed site for BuildingSystem
to materialise, which put an intermediate value in Simulation and made the two
calls correct only when adjacent and ordered. Once the queries and the buffer
helpers became free functions there was nothing left on BuildingSystem that
materialisation needed, so the system does the whole job and the invariant
disappears rather than being documented.

Holds only the config; the world arrives per tick, like the systems in
lib/ecs/system.

Verified with a golden-checksum capture before and after — all four sample ticks
identical, which is the check that matters here since this moves a call in the
tick order.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-05 06:49:20 +02:00
009f8c6d14 free the buffer setup and belt registration from BuildingSystem
Prerequisite for ConstructionSystem completing a building itself rather than
handing a finished site back: materialisation needs the buffer initialisers and
the BeltSystem registration, and both were BuildingSystem members.

initBuffers turned out to need nothing at all — it works purely on the Building
and RecipeDef it is given. The other three need only the config.
reregisterBeltTile takes BeltSystem and the config; it stays shared rather than
moving, because cancelDeconstruction and rotateInPlace use it too and are staying
on BuildingSystem.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-05 06:38:31 +02:00
a90218f5c0 make BuildingSystem stateless: FactoryState becomes a parameter
The member reference is gone. All 23 methods that read or write the factory now
take FactoryState& (const for the two item walks and the checksum fold), so a
BuildingSystem is no longer bound to one state and its signatures say which data
each call touches. It holds only config, belts, rng and the callbacks — the same
shape as AiSystem and CombatSystem.

This completes what phase 2 set out to do; the ownership move landed earlier, but
the systems kept reaching the data through a member until the queries were off
them.

Seeding the asteroid bound moved with the state, and that broke four tests: the
fixtures build their own FactoryState, which defaulted the bound to 0 and refused
every placement on the asteroid. Rather than fix the four call sites, makeFactoryState()
now creates a run's state from the config, and Simulation, ArenaSimulation and the
test fixtures all use it — there is one place that knows what a fresh factory
looks like.

Verified with a golden-checksum capture before and after — all four sample ticks
identical — and by re-running the declaration/definition check over the header.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 23:00:09 +02:00
7ce0751c60 remove the stale findRotateInPlaceTarget declaration
Third declaration left behind by a move — the definition went to
PlacementRules.cpp but the declaration stayed. Checked the rest of the header
mechanically this time: every other declared method has a definition.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 22:43:00 +02:00
35 changed files with 976 additions and 1179 deletions

View File

@@ -1,3 +1,6 @@
set(TARGET_BASE_NAME "${PRODUCT_NAME}")
set(TARGET_APP_NAME "${TARGET_BASE_NAME}")

View File

@@ -46,9 +46,10 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
, m_finished(false)
, m_stopRequested(false)
{
m_factoryState = makeFactoryState(m_gameConfig);
m_buildingSystem = std::make_unique<BuildingSystem>(
m_gameConfig,
m_factoryState,
m_beltSystem,
[this]() { return allocateBuildingId(); },
[](int) {},
@@ -163,7 +164,7 @@ void ArenaSimulation::placeStructures()
hp, hp, false);
// Tag as an HQ so it is excluded from repair targeting (REQ-SHP-REPAIR).
m_admin.addComponent<HqProxyComponent>(m_team1HqEntity);
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
}
// Team 2 HQ — ECS proxy entity, enemy faction (isEnemy=true). No weapon.
@@ -184,7 +185,7 @@ void ArenaSimulation::placeStructures()
hp, hp, true);
// Tag as an HQ so it is excluded from repair targeting (REQ-SHP-REPAIR).
m_admin.addComponent<HqProxyComponent>(m_team2HqEntity);
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
}
auto placeArenaStation = [&](const ArenaStationEntry& entry, bool isEnemy)
@@ -238,7 +239,7 @@ void ArenaSimulation::placeStructures()
m_admin.addComponent<ModuleOwnerComponent>(wChild,
ModuleOwnerComponent{stationEntity});
}
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
};
for (const ArenaStationEntry& entry : m_arenaConfig.teams[0].stations)
@@ -323,9 +324,9 @@ void ArenaSimulation::tick()
// Ship behavior systems (tick step 7): evaluate, select winner, execute.
// Module + combat systems emit their tool beams into a shared buffer.
m_shipSystem->clearMovementIntents();
m_aiSystem->tick(m_admin, m_factoryState, *m_debrisSystem);
m_aiSystem->tick(m_admin, m_factoryState);
std::vector<BeamFiredEvent> beamFiredEvents;
m_salvagerSystem->tick(m_currentTick, *m_debrisSystem, m_factoryState, beamFiredEvents);
m_salvagerSystem->tick(m_currentTick, m_factoryState, beamFiredEvents);
m_repairSystem->tick(m_currentTick, beamFiredEvents);
// Combat resolution (tick step 8).
@@ -393,7 +394,7 @@ void ArenaSimulation::tickDeaths()
for (entt::entity deadEntity : deadStations)
{
const StationBodyComponent& sb = m_admin.get<StationBodyComponent>(deadEntity);
m_buildingSystem->unregisterTileOccupancy(sb.bodyCells);
m_buildingSystem->unregisterTileOccupancy(m_factoryState, sb.bodyCells);
{
std::vector<entt::entity> stationChildren;
m_admin.forEach<ModuleOwnerComponent>(

View File

@@ -340,7 +340,7 @@ void ArenaView::drawBuildings(QPainter& painter)
void ArenaView::drawDebris(QPainter& painter)
{
const float r = getTilePx() * 0.2f;
for (const DebrisInfo& debris : m_sim->getDebrisSystem().getAllDebrisInfo())
for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
{
const QPointF center = worldToWidget(debris.position);
painter.setBrush(QColor(128, 110, 90));

View File

@@ -43,8 +43,7 @@ AiSystem::AiSystem(const GameConfig& config)
{
}
void AiSystem::tick(EntityAdmin& admin, const FactoryState& state,
const DebrisSystem& debris)
void AiSystem::tick(EntityAdmin& admin, const FactoryState& state)
{
TRACE();
@@ -55,7 +54,7 @@ void AiSystem::tick(EntityAdmin& admin, const FactoryState& state,
m_retreatEvaluator.evaluate(admin);
m_attackEvaluator.evaluate(admin);
m_repairEvaluator.evaluate(admin);
m_salvageScrapEvaluator.evaluate(admin, debris);
m_salvageScrapEvaluator.evaluate(admin);
m_deliverScrapEvaluator.evaluate(admin, state);
// Phase 2: pick the highest-scoring behavior per ship.

View File

@@ -20,7 +20,6 @@
#include "StandbyExecutor.h"
class EntityAdmin;
class DebrisSystem;
struct GameConfig;
// Orchestrates ship-behavior decision-making in three batched phases:
@@ -35,7 +34,7 @@ class AiSystem
public:
explicit AiSystem(const GameConfig& config);
void tick(EntityAdmin& admin, const FactoryState& state, const DebrisSystem& debris);
void tick(EntityAdmin& admin, const FactoryState& state);
private:
void selectWinningBehaviors(EntityAdmin& admin);

View File

@@ -46,13 +46,13 @@ std::optional<int> DebrisSystem::consume(entt::entity entity)
return amount;
}
bool DebrisSystem::collectOne(entt::entity entity)
bool collectOne(EntityAdmin& admin, entt::entity entity)
{
if (!m_admin.isValid(entity) || !m_admin.hasAll<DebrisComponent>(entity))
if (!admin.isValid(entity) || !admin.hasAll<DebrisComponent>(entity))
{
return false;
}
DebrisComponent& data = m_admin.get<DebrisComponent>(entity);
DebrisComponent& data = admin.get<DebrisComponent>(entity);
if (data.amount <= 0)
{
return false;
@@ -60,18 +60,18 @@ bool DebrisSystem::collectOne(entt::entity entity)
--data.amount;
if (data.amount <= 0)
{
m_admin.destroy(entity);
admin.destroy(entity);
}
return true;
}
std::vector<DebrisInfo> DebrisSystem::getAllDebrisInfo() const
std::vector<DebrisInfo> getAllDebrisInfo(const EntityAdmin& admin)
{
std::vector<DebrisInfo> result;
m_admin.forEach<DebrisComponent>(
[&result, this](entt::entity e, const DebrisComponent& sd)
admin.forEach<DebrisComponent>(
[&result, &admin](entt::entity e, const DebrisComponent& sd)
{
result.push_back(DebrisInfo{e, m_admin.get<PositionComponent>(e).value, sd.amount});
result.push_back(DebrisInfo{e, admin.get<PositionComponent>(e).value, sd.amount});
});
return result;
}

View File

@@ -38,9 +38,17 @@ public:
// false if the entity is invalid or already empty (REQ-SHP-SALVAGE).
bool collectOne(entt::entity entity);
// Lightweight snapshot for callers that need to iterate all debris.
std::vector<DebrisInfo> getAllDebrisInfo() const;
private:
EntityAdmin& m_admin;
};
// Debris state read and changed straight off the registry — no system needed.
// Lightweight snapshot for callers that need to iterate all debris.
std::vector<DebrisInfo> getAllDebrisInfo(const EntityAdmin& admin);
// Collects a single scrap unit from the debris: decrements its amount by one,
// destroying the entity once depleted. Returns true if a scrap was collected,
// false if the entity is invalid or already empty (REQ-SHP-SALVAGE).
bool collectOne(EntityAdmin& admin, entt::entity entity);

View File

@@ -24,14 +24,14 @@ SalvagerSystem::SalvagerSystem(EntityAdmin& admin)
{
}
void SalvagerSystem::tick(Tick currentTick, DebrisSystem& debris, FactoryState& state,
void SalvagerSystem::tick(Tick currentTick, FactoryState& state,
std::vector<BeamFiredEvent>& outBeamFiredEvents)
{
TRACE();
// Apply collections whose mid-beam delay has elapsed (cycles started earlier).
applyPendingCollections(currentTick, debris);
applyPendingCollections(currentTick);
const std::vector<DebrisInfo> allDebris = debris.getAllDebrisInfo();
const std::vector<DebrisInfo> allDebris = getAllDebrisInfo(m_admin);
// Tick down per-module collection cooldowns.
m_admin.forEach<SalvagerComponent>(
@@ -108,7 +108,7 @@ void SalvagerSystem::tick(Tick currentTick, DebrisSystem& debris, FactoryState&
});
}
void SalvagerSystem::applyPendingCollections(Tick currentTick, DebrisSystem& debris)
void SalvagerSystem::applyPendingCollections(Tick currentTick)
{
std::vector<PendingCollection>::iterator it = m_pendingCollections.begin();
while (it != m_pendingCollections.end())
@@ -118,7 +118,7 @@ void SalvagerSystem::applyPendingCollections(Tick currentTick, DebrisSystem& deb
if (m_admin.isValid(it->ship) && m_admin.hasAll<CargoComponent>(it->ship))
{
CargoComponent& cargo = m_admin.get<CargoComponent>(it->ship);
if (cargo.current < cargo.maxCapacity && debris.collectOne(it->debris))
if (cargo.current < cargo.maxCapacity && collectOne(m_admin, it->debris))
{
++cargo.current;
}

View File

@@ -10,7 +10,6 @@
#include "entt/entity/entity.hpp"
class EntityAdmin;
class DebrisSystem;
// World-mutation system for salvage modules: each module runs a collection cycle
// on its own cooldown. When a cycle starts it emits a salvage beam toward an
@@ -22,7 +21,7 @@ class SalvagerSystem
public:
explicit SalvagerSystem(EntityAdmin& admin);
void tick(Tick currentTick, DebrisSystem& debris, FactoryState& state,
void tick(Tick currentTick, FactoryState& state,
std::vector<BeamFiredEvent>& outBeamFiredEvents);
private:
@@ -33,7 +32,7 @@ private:
Tick appliesAt;
};
void applyPendingCollections(Tick currentTick, DebrisSystem& debris);
void applyPendingCollections(Tick currentTick);
EntityAdmin& m_admin;
std::vector<PendingCollection> m_pendingCollections;

View File

@@ -15,11 +15,11 @@
#include "SensorRangeComponent.h"
#include "tracing.h"
void SalvageScrapEvaluator::evaluate(EntityAdmin& admin, const DebrisSystem& debris)
void SalvageScrapEvaluator::evaluate(EntityAdmin& admin)
{
TRACE();
const std::unordered_map<entt::entity, CargoState> cargoByShip = buildCargoByShip(admin);
const std::vector<DebrisInfo> allDebris = debris.getAllDebrisInfo();
const std::vector<DebrisInfo> allDebris = getAllDebrisInfo(admin);
admin.forEach<SalvageScrapBehavior, PositionComponent, SensorRangeComponent>(
[&](entt::entity e, SalvageScrapBehavior& salvage, const PositionComponent& pos,

View File

@@ -1,7 +1,6 @@
#pragma once
class EntityAdmin;
class DebrisSystem;
// When cargo is not full, finds the nearest debris within sensor range and sets
// it as the target, scoring high. Scores inactive when cargo is full or no debris
@@ -9,5 +8,5 @@ class DebrisSystem;
class SalvageScrapEvaluator
{
public:
void evaluate(EntityAdmin& admin, const DebrisSystem& debris);
void evaluate(EntityAdmin& admin);
};

View File

@@ -0,0 +1,173 @@
#include "BuildingBuffers.h"
#include <algorithm>
#include <cassert>
#include "BuildingType.h"
#include "ItemType.h"
#include "ModulesConfig.h"
#include "ShipsConfig.h"
void initBuffers(Building& b, const RecipeDef& recipe)
{
b.inputBuffer.counts.clear();
b.inputBuffer.caps.clear();
for (const RecipeIngredient& ing : recipe.inputs)
{
const ItemType type{ing.item};
b.inputBuffer.counts[type] = 0;
b.inputBuffer.caps[type] = 2 * ing.amount;
}
b.outputBuffer.items.clear();
if (b.type == BuildingType::ReprocessingPlant)
{
// 1× max-per-roll (REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
int maxAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
if (out.amount > maxAmount)
{
maxAmount = out.amount;
}
}
b.outputBuffer.capacity = maxAmount;
}
else
{
// 2× per-cycle output.
int totalAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
totalAmount += out.amount;
}
b.outputBuffer.capacity = 2 * totalAmount;
}
}
void initAutoBuffers(const GameConfig& config, Building& b)
{
b.inputBuffer.counts.clear();
b.inputBuffer.caps.clear();
// Union the inputs of every recipe of this building type; the cap for each
// item is twice the largest per-cycle requirement across those recipes.
// Output capacity follows the same rules as initBuffers: the Reprocessing
// Plant holds one cycle's max output (REQ-MAT-OUTPUT-BUFFER-REPROCESSING),
// other auto buildings hold twice the largest per-cycle output.
int outputCapacity = 0;
for (const RecipeDef& recipe : config.recipes.recipes)
{
if (recipe.building != b.type)
{
continue;
}
for (const RecipeIngredient& ing : recipe.inputs)
{
const ItemType type{ing.item};
b.inputBuffer.counts[type] = 0;
b.inputBuffer.caps[type] =
std::max(b.inputBuffer.caps[type], 2 * ing.amount);
}
if (b.type == BuildingType::ReprocessingPlant)
{
int maxAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
maxAmount = std::max(maxAmount, out.amount);
}
outputCapacity = std::max(outputCapacity, maxAmount);
}
else
{
int totalAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
totalAmount += out.amount;
}
outputCapacity = std::max(outputCapacity, 2 * totalAmount);
}
}
b.outputBuffer.items.clear();
b.outputBuffer.capacity = outputCapacity;
}
void initShipyardBuffers(const GameConfig& config, Building& b)
{
b.inputBuffer.counts.clear();
b.inputBuffer.caps.clear();
b.outputBuffer.items.clear();
b.outputBuffer.capacity = 0;
const ShipDef* def = config.ships.findShipDef(b.recipeId);
if (!def)
{
return;
}
for (const RecipeIngredient& ing : def->schematic.materials)
{
const ItemType type{ing.item};
b.inputBuffer.counts[type] = 0;
b.inputBuffer.caps[type] = 2 * ing.amount;
}
if (b.shipLayout.has_value())
{
for (const PlacedModule& pm : b.shipLayout->placedModules)
{
const ModuleDef* modDef = config.modules.findModuleDef(pm.moduleId);
if (!modDef)
{
continue;
}
for (const RecipeIngredient& ing : modDef->materials)
{
const ItemType type{ing.item};
b.inputBuffer.counts.try_emplace(type, 0);
b.inputBuffer.caps[type] += 2 * ing.amount;
}
}
}
}
void initSalvageBayBuffer(const GameConfig& config, Building& b)
{
// Salvage Bay has no recipe-driven buffer; its output-buffer holding size for
// ship drop-off is config-defined (REQ-BLD-SALVAGE-BAY).
b.outputBuffer.items.clear();
const BuildingDef* def = config.buildings.findBuildingDef(BuildingType::SalvageBay);
b.outputBuffer.capacity =
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0;
}
void reregisterBeltTile(BeltSystem& belts, const GameConfig& config,
const Building& building,
const std::vector<ItemType>& splitterFilterA,
const std::vector<ItemType>& splitterFilterB)
{
switch (building.type)
{
case BuildingType::Belt:
belts.placeBelt(building.anchor, building.rotation);
break;
case BuildingType::Splitter:
assert(building.outputPorts.size() >= 2);
belts.placeSplitter(building.anchor,
building.outputPorts[0].direction,
building.outputPorts[1].direction);
belts.setSplitterFilters(building.anchor, splitterFilterA, splitterFilterB);
break;
case BuildingType::TunnelEntry:
belts.placeTunnelEntry(building.anchor, building.rotation,
config.world.tunnelMaxDistance_tiles);
break;
case BuildingType::TunnelExit:
belts.placeTunnelExit(building.anchor, building.rotation);
break;
default:
break;
}
}

View File

@@ -0,0 +1,39 @@
#pragma once
#include <vector>
#include "BeltSystem.h"
#include "Building.h"
#include "GameConfig.h"
#include "ItemType.h"
#include "RecipesConfig.h"
// Setting a building up when it starts existing or is reconfigured: sizing its
// input/output buffers from what it will produce, and handing belt-like types back
// to BeltSystem. Free functions over the config and the building — they read no
// factory state, so both BuildingSystem and ConstructionSystem can use them.
// Buffers for a building running one known recipe: inputs capped at twice each
// ingredient's per-cycle amount, output at twice the per-cycle total (one cycle's
// max for a Reprocessing Plant, REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
void initBuffers(Building& b, const RecipeDef& recipe);
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant), unioned over
// every recipe of its type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
void initAutoBuffers(const GameConfig& config, Building& b);
// Buffers for a shipyard: its schematic's materials plus those of every placed
// module (REQ-BLD-SHIPYARD).
void initShipyardBuffers(const GameConfig& config, Building& b);
// The Salvage Bay holds no recipe inputs; its output capacity is config-defined
// (REQ-BLD-SALVAGE-BAY).
void initSalvageBayBuffer(const GameConfig& config, Building& b);
// Registers a belt, splitter or tunnel end with BeltSystem. A splitter's filters
// live in BeltSystem and are lost by removeTile, so they are passed back in
// (REQ-BLD-SPLITTER). No-op for every other building type.
void reregisterBeltTile(BeltSystem& belts, const GameConfig& config,
const Building& building,
const std::vector<ItemType>& splitterFilterA,
const std::vector<ItemType>& splitterFilterB);

View File

@@ -26,7 +26,6 @@ bool inputLaneEntryFree(const std::vector<BeltItemSlot>& lane)
} // namespace
BuildingSystem::BuildingSystem(const GameConfig& config,
FactoryState& state,
BeltSystem& belts,
std::function<BuildingId()> allocateBuildingId,
std::function<void(int)> addBuildingBlocks,
@@ -35,7 +34,6 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
std::function<bool(const std::string&)> isItemUnlocked,
std::mt19937& rng)
: m_config(config)
, m_state(state)
, m_belts(belts)
, m_allocateBuildingId(std::move(allocateBuildingId))
, m_addBuildingBlocks(std::move(addBuildingBlocks))
@@ -43,146 +41,12 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
, m_isItemUnlocked(std::move(isItemUnlocked))
, m_rng(rng)
{
m_state.asteroidWidth_tiles = config.world.regions.asteroidWidth_tiles;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
void BuildingSystem::initBuffers(Building& b, const RecipeDef& recipe) const
{
b.inputBuffer.counts.clear();
b.inputBuffer.caps.clear();
for (const RecipeIngredient& ing : recipe.inputs)
{
const ItemType type{ing.item};
b.inputBuffer.counts[type] = 0;
b.inputBuffer.caps[type] = 2 * ing.amount;
}
b.outputBuffer.items.clear();
if (b.type == BuildingType::ReprocessingPlant)
{
// 1× max-per-roll (REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
int maxAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
if (out.amount > maxAmount)
{
maxAmount = out.amount;
}
}
b.outputBuffer.capacity = maxAmount;
}
else
{
// 2× per-cycle output.
int totalAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
totalAmount += out.amount;
}
b.outputBuffer.capacity = 2 * totalAmount;
}
}
void BuildingSystem::initAutoBuffers(Building& b) const
{
b.inputBuffer.counts.clear();
b.inputBuffer.caps.clear();
// Union the inputs of every recipe of this building type; the cap for each
// item is twice the largest per-cycle requirement across those recipes.
// Output capacity follows the same rules as initBuffers: the Reprocessing
// Plant holds one cycle's max output (REQ-MAT-OUTPUT-BUFFER-REPROCESSING),
// other auto buildings hold twice the largest per-cycle output.
int outputCapacity = 0;
for (const RecipeDef& recipe : m_config.recipes.recipes)
{
if (recipe.building != b.type)
{
continue;
}
for (const RecipeIngredient& ing : recipe.inputs)
{
const ItemType type{ing.item};
b.inputBuffer.counts[type] = 0;
b.inputBuffer.caps[type] =
std::max(b.inputBuffer.caps[type], 2 * ing.amount);
}
if (b.type == BuildingType::ReprocessingPlant)
{
int maxAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
maxAmount = std::max(maxAmount, out.amount);
}
outputCapacity = std::max(outputCapacity, maxAmount);
}
else
{
int totalAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
totalAmount += out.amount;
}
outputCapacity = std::max(outputCapacity, 2 * totalAmount);
}
}
b.outputBuffer.items.clear();
b.outputBuffer.capacity = outputCapacity;
}
void BuildingSystem::initShipyardBuffers(Building& b) const
{
b.inputBuffer.counts.clear();
b.inputBuffer.caps.clear();
b.outputBuffer.items.clear();
b.outputBuffer.capacity = 0;
const ShipDef* def = m_config.ships.findShipDef(b.recipeId);
if (!def)
{
return;
}
for (const RecipeIngredient& ing : def->schematic.materials)
{
const ItemType type{ing.item};
b.inputBuffer.counts[type] = 0;
b.inputBuffer.caps[type] = 2 * ing.amount;
}
if (b.shipLayout.has_value())
{
for (const PlacedModule& pm : b.shipLayout->placedModules)
{
const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
if (!modDef)
{
continue;
}
for (const RecipeIngredient& ing : modDef->materials)
{
const ItemType type{ing.item};
b.inputBuffer.counts.try_emplace(type, 0);
b.inputBuffer.caps[type] += 2 * ing.amount;
}
}
}
}
void BuildingSystem::initSalvageBayBuffer(Building& b) const
{
// Salvage Bay has no recipe-driven buffer; its output-buffer holding size for
// ship drop-off is config-defined (REQ-BLD-SALVAGE-BAY).
b.outputBuffer.items.clear();
const BuildingDef* def = m_config.buildings.findBuildingDef(BuildingType::SalvageBay);
b.outputBuffer.capacity =
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0;
}
std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe)
{
@@ -213,7 +77,7 @@ std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe
// Placement
// ---------------------------------------------------------------------------
std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor,
std::optional<BuildingId> BuildingSystem::place(FactoryState& state, BuildingType type, QPoint anchor,
Rotation rotation, Tick currentTick)
{
const BuildingDef* def = m_config.buildings.findBuildingDef(type);
@@ -221,7 +85,7 @@ std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation);
// Reject placements that fall outside the world (REQ-BLD-PLACE-VALID).
if (!bodyCellsWithinWorldBounds(m_state, m_config, mask.bodyCells, anchor))
if (!bodyCellsWithinWorldBounds(state, m_config, mask.bodyCells, anchor))
{
return std::nullopt;
}
@@ -232,7 +96,7 @@ std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor
for (const QPoint& cell : mask.bodyCells)
{
const QPoint absCell = anchor + cell;
m_state.grid.occupy(absCell, id);
state.grid.occupy(absCell, id);
}
// Build construction site.
@@ -247,13 +111,13 @@ std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor
site.bodyCells.push_back(anchor + cell);
}
if (m_state.constructionQueue.empty())
if (state.constructionQueue.empty())
{
site.completesAt = currentTick + secondsToTicks(def->constructionTimeSeconds);
}
// else: completesAt remains 0 (queued, not yet started).
m_state.constructionQueue.push_back(std::move(site));
state.constructionQueue.push_back(std::move(site));
return id;
}
@@ -261,19 +125,19 @@ std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor
// Deconstruct
// ---------------------------------------------------------------------------
int BuildingSystem::deconstruct(BuildingId id, Tick currentTick)
int BuildingSystem::deconstruct(FactoryState& state, BuildingId id, Tick currentTick)
{
// Construction site? Removed instantly with the full refund; never queued
// for deconstruction (REQ-BLD-DECONSTRUCT).
for (std::deque<ConstructionSite>::iterator it = m_state.constructionQueue.begin();
it != m_state.constructionQueue.end();
for (std::deque<ConstructionSite>::iterator it = state.constructionQueue.begin();
it != state.constructionQueue.end();
++it)
{
if (it->id == id)
{
const BuildingDef* def = m_config.buildings.findBuildingDef(it->type);
m_state.grid.release(it->bodyCells);
m_state.constructionQueue.erase(it);
state.grid.release(it->bodyCells);
state.constructionQueue.erase(it);
if (def)
{
return def->cost;
@@ -285,7 +149,7 @@ int BuildingSystem::deconstruct(BuildingId id, Tick currentTick)
// Operational building? Append it to the deconstruction queue rather than
// removing it now; the partial refund is credited on completion in
// tickDeconstruction (REQ-BLD-DECON-QUEUE).
for (Building& building : m_state.buildings)
for (Building& building : state.buildings)
{
if (building.id != id) { continue; }
if (building.queuedForDeconstruction) { return 0; } // already queued
@@ -313,11 +177,11 @@ int BuildingSystem::deconstruct(BuildingId id, Tick currentTick)
m_belts.removeTile(building.anchor);
}
const bool wasEmpty = m_state.deconstructionQueue.empty();
m_state.deconstructionQueue.push_back(std::move(entry));
const bool wasEmpty = state.deconstructionQueue.empty();
state.deconstructionQueue.push_back(std::move(entry));
if (wasEmpty)
{
startFrontDeconstruction(currentTick);
startFrontDeconstruction(state, m_config, currentTick);
}
return 0;
}
@@ -325,25 +189,14 @@ int BuildingSystem::deconstruct(BuildingId id, Tick currentTick)
return 0;
}
void BuildingSystem::startFrontDeconstruction(Tick currentTick)
{
if (m_state.deconstructionQueue.empty()) { return; }
DeconstructionEntry& front = m_state.deconstructionQueue.front();
if (front.completesAt == 0)
{
front.completesAt =
currentTick + secondsToTicks(m_config.world.deconstructionTimeSeconds);
}
}
// ---------------------------------------------------------------------------
// Set recipe
// ---------------------------------------------------------------------------
void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
void BuildingSystem::setRecipe(FactoryState& state, BuildingId id, const std::string& recipeId)
{
// Construction site: store recipe for when building completes.
for (ConstructionSite& site : m_state.constructionQueue)
for (ConstructionSite& site : state.constructionQueue)
{
if (site.id == id)
{
@@ -366,7 +219,7 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
}
// Operational building: clear buffers and re-init.
for (Building& building : m_state.buildings)
for (Building& building : state.buildings)
{
if (building.id == id)
{
@@ -400,7 +253,7 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
{
if (building.type == BuildingType::Shipyard)
{
initShipyardBuffers(building);
initShipyardBuffers(m_config, building);
}
else
{
@@ -416,9 +269,9 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
}
}
void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout)
void BuildingSystem::setShipLayout(FactoryState& state, BuildingId id, const ShipLayoutConfig& layout)
{
for (ConstructionSite& site : m_state.constructionQueue)
for (ConstructionSite& site : state.constructionQueue)
{
if (site.id == id)
{
@@ -427,7 +280,7 @@ void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout
}
}
for (Building& building : m_state.buildings)
for (Building& building : state.buildings)
{
if (building.id == id)
{
@@ -444,18 +297,18 @@ void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
if (!building.recipeId.empty() && building.type == BuildingType::Shipyard)
{
initShipyardBuffers(building);
initShipyardBuffers(m_config, building);
}
return;
}
}
}
void BuildingSystem::setSiteSplitterFilters(BuildingId id,
void BuildingSystem::setSiteSplitterFilters(FactoryState& state, BuildingId id,
const std::vector<ItemType>& filterA,
const std::vector<ItemType>& filterB)
{
for (ConstructionSite& site : m_state.constructionQueue)
for (ConstructionSite& site : state.constructionQueue)
{
if (site.id == id && site.type == BuildingType::Splitter)
{
@@ -470,188 +323,10 @@ void BuildingSystem::setSiteSplitterFilters(BuildingId id,
// Tick hooks
// ---------------------------------------------------------------------------
void BuildingSystem::tickConstruction(Tick currentTick)
void BuildingSystem::cancelDeconstruction(FactoryState& state, BuildingId id)
{
TRACE();
if (m_state.constructionQueue.empty())
{
return;
}
ConstructionSite& front = m_state.constructionQueue.front();
// Guard: if somehow the front site was never started, start it now.
if (front.completesAt == 0)
{
const BuildingDef* def = m_config.buildings.findBuildingDef(front.type);
if (def)
{
front.completesAt = currentTick + secondsToTicks(def->constructionTimeSeconds);
}
return;
}
if (currentTick < front.completesAt)
{
return;
}
// Promote construction site to an operational Building.
const BuildingDef* def = m_config.buildings.findBuildingDef(front.type);
const ParsedSurfaceMask mask = parseSurfaceMask(
def ? def->surfaceMask : std::vector<std::string>{},
front.rotation);
Building building;
building.id = front.id;
building.anchor = front.anchor;
building.footprint = front.footprint;
building.rotation = front.rotation;
building.type = front.type;
building.recipeId = front.recipeId;
building.shipLayout = front.shipLayout;
for (const QPoint& cell : mask.bodyCells)
{
building.bodyCells.push_back(front.anchor + cell);
}
for (const Port& port : mask.outputPorts)
{
Port absPort;
absPort.tile = front.anchor + port.tile;
absPort.direction = port.direction;
building.outputPorts.push_back(absPort);
}
building.emergingItems.resize(building.outputPorts.size());
building.inputPorts = computeInputPorts(building.bodyCells, building.outputPorts);
building.incomingItems.assign(building.inputPorts.size(), {});
if (building.type == BuildingType::SalvageBay)
{
initSalvageBayBuffer(building);
}
else if (isAutoRecipeBuildingType(building.type))
{
// Smelter/Reprocessing Plant need no recipe selection; buffers are set
// up from all recipes of the type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
initAutoBuffers(building);
}
else if (!building.recipeId.empty())
{
if (building.type == BuildingType::Shipyard)
{
initShipyardBuffers(building);
}
else
{
const RecipeDef* recipe = m_config.recipes.findRecipeDef(building.recipeId, building.type);
if (recipe)
{
initBuffers(building, *recipe);
}
}
}
// Register with BeltSystem before the move (mask/building stays valid). Any
// filters configured while under construction carry over (REQ-BLD-SITE-CONFIG).
reregisterBeltTile(building, front.splitterFilterA, front.splitterFilterB);
m_state.buildings.push_back(std::move(building));
m_state.constructionQueue.pop_front();
// Start next queued site if present.
if (!m_state.constructionQueue.empty() && m_state.constructionQueue.front().completesAt == 0)
{
const BuildingDef* nextDef =
m_config.buildings.findBuildingDef(m_state.constructionQueue.front().type);
if (nextDef)
{
m_state.constructionQueue.front().completesAt =
currentTick + secondsToTicks(nextDef->constructionTimeSeconds);
}
}
}
void BuildingSystem::reregisterBeltTile(const Building& building,
const std::vector<ItemType>& splitterFilterA,
const std::vector<ItemType>& splitterFilterB)
{
switch (building.type)
{
case BuildingType::Belt:
m_belts.placeBelt(building.anchor, building.rotation);
break;
case BuildingType::Splitter:
assert(building.outputPorts.size() >= 2);
m_belts.placeSplitter(building.anchor,
building.outputPorts[0].direction,
building.outputPorts[1].direction);
m_belts.setSplitterFilters(building.anchor, splitterFilterA, splitterFilterB);
break;
case BuildingType::TunnelEntry:
m_belts.placeTunnelEntry(building.anchor, building.rotation,
m_config.world.tunnelMaxDistance_tiles);
break;
case BuildingType::TunnelExit:
m_belts.placeTunnelExit(building.anchor, building.rotation);
break;
default:
break;
}
}
void BuildingSystem::tickDeconstruction(Tick currentTick)
{
TRACE();
if (m_state.deconstructionQueue.empty())
{
return;
}
DeconstructionEntry& front = m_state.deconstructionQueue.front();
// Guard: if the front entry's timer was never started, start it now.
if (front.completesAt == 0)
{
startFrontDeconstruction(currentTick);
return;
}
if (currentTick < front.completesAt)
{
return;
}
// Remove the building from the world and credit its refund (REQ-BLD-DECONSTRUCT).
// Belt/tunnel/splitter tiles were already unregistered when the building was
// queued (see deconstruct), so only tile occupancy and the record remain.
for (std::vector<Building>::iterator it = m_state.buildings.begin();
it != m_state.buildings.end();
++it)
{
if (it->id != front.id) { continue; }
const BuildingDef* def = m_config.buildings.findBuildingDef(it->type);
m_state.grid.release(it->bodyCells);
m_state.buildings.erase(it);
if (def)
{
m_addBuildingBlocks(def->cost * m_config.world.refundPercentage / 100);
}
break;
}
m_state.deconstructionQueue.pop_front();
// Start the next queued deconstruction, if any.
startFrontDeconstruction(currentTick);
}
void BuildingSystem::cancelDeconstruction(BuildingId id)
{
for (std::deque<DeconstructionEntry>::iterator it = m_state.deconstructionQueue.begin();
it != m_state.deconstructionQueue.end();
for (std::deque<DeconstructionEntry>::iterator it = state.deconstructionQueue.begin();
it != state.deconstructionQueue.end();
++it)
{
if (it->id != id) { continue; }
@@ -659,27 +334,27 @@ void BuildingSystem::cancelDeconstruction(BuildingId id)
// Resume operation: clear the flag and re-register belt/tunnel/splitter
// tiles that were unregistered at enqueue (which re-pairs tunnels,
// REQ-BLD-TUNNEL-PAIR). Deconstruction progress is discarded; no refund.
if (Building* building = findBuilding(m_state, id))
if (Building* building = findBuilding(state, id))
{
building->queuedForDeconstruction = false;
reregisterBeltTile(*building, it->splitterFilterA, it->splitterFilterB);
reregisterBeltTile(m_belts, m_config, *building, it->splitterFilterA, it->splitterFilterB);
}
m_state.deconstructionQueue.erase(it);
state.deconstructionQueue.erase(it);
// If the running front was removed, the new front (completesAt == 0) has
// its timer started by the next tickDeconstruction guard.
return;
}
}
void BuildingSystem::tickBeltPull()
void BuildingSystem::tickBeltPull(FactoryState& state)
{
TRACE();
// Same per-tick step as the belts, so items travel inward at belt speed
// (REQ-GW-BELT-SPEED, REQ-MAT-INPUT-INTAKE).
const double progressPerTick = m_belts.getProgressPerTick_tpt();
for (Building& building : m_state.buildings)
for (Building& building : state.buildings)
{
// A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE).
if (building.queuedForDeconstruction) { continue; }
@@ -759,17 +434,17 @@ void BuildingSystem::depositToInputBelt(Building& consumer,
consumer.incomingItems[inputPortIndex].push_back(BeltItemSlot{item, 0.0});
}
bool BuildingSystem::tryDirectCoupleDeposit(BuildingId producerId,
bool BuildingSystem::tryDirectCoupleDeposit(FactoryState& state, BuildingId producerId,
const Port& outputPort,
const Item& item)
{
const std::optional<BuildingId> ownerId = m_state.grid.findOwner(outputPort.tile);
const std::optional<BuildingId> ownerId = state.grid.findOwner(outputPort.tile);
if (!ownerId.has_value() || *ownerId == producerId)
{
return false;
}
Building* consumer = findBuilding(m_state, *ownerId);
Building* consumer = findBuilding(state, *ownerId);
if (!consumer)
{
return false; // an unbuilt construction site, or not an operational building
@@ -794,10 +469,10 @@ bool BuildingSystem::tryDirectCoupleDeposit(BuildingId producerId,
return false;
}
void BuildingSystem::tickProduction(Tick currentTick)
void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick)
{
TRACE();
for (Building& building : m_state.buildings)
for (Building& building : state.buildings)
{
// A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE).
if (building.queuedForDeconstruction) { continue; }
@@ -897,10 +572,10 @@ void BuildingSystem::tickProduction(Tick currentTick)
}
}
void BuildingSystem::tickShipyardProduction(Tick currentTick)
void BuildingSystem::tickShipyardProduction(FactoryState& state, Tick currentTick)
{
TRACE();
for (Building& building : m_state.buildings)
for (Building& building : state.buildings)
{
// A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE).
if (building.queuedForDeconstruction) { continue; }
@@ -993,14 +668,14 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
}
}
void BuildingSystem::tickOutputBelts()
void BuildingSystem::tickOutputBelts(FactoryState& state)
{
TRACE();
// Use BeltSystem's own per-tick step so emerging items travel at exactly the
// same speed as real belts (REQ-GW-BELT-SPEED, REQ-MAT-OUTPUT-EMERGE).
const double progressPerTick = m_belts.getProgressPerTick_tpt();
for (Building& building : m_state.buildings)
for (Building& building : state.buildings)
{
// A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE).
if (building.queuedForDeconstruction) { continue; }
@@ -1023,7 +698,7 @@ void BuildingSystem::tickOutputBelts()
{
const Item item = lane.front().item;
if (m_belts.tryPutItem(port.tile, item, port.direction)
|| tryDirectCoupleDeposit(building.id, port, item))
|| tryDirectCoupleDeposit(state, building.id, port, item))
{
lane.erase(lane.begin());
}
@@ -1043,10 +718,10 @@ void BuildingSystem::tickOutputBelts()
}
}
void BuildingSystem::forEachEmergingItem(
void BuildingSystem::forEachEmergingItem(const FactoryState& state,
const std::function<void(const ItemType&, QPointF)>& visit) const
{
for (const Building& building : m_state.buildings)
for (const Building& building : state.buildings)
{
for (std::size_t p = 0; p < building.outputPorts.size(); ++p)
{
@@ -1065,10 +740,10 @@ void BuildingSystem::forEachEmergingItem(
}
}
void BuildingSystem::forEachIncomingItem(
void BuildingSystem::forEachIncomingItem(const FactoryState& state,
const std::function<void(const ItemType&, QPointF)>& visit) const
{
for (const Building& building : m_state.buildings)
for (const Building& building : state.buildings)
{
for (std::size_t p = 0; p < building.inputPorts.size(); ++p)
{
@@ -1096,10 +771,10 @@ void BuildingSystem::forEachIncomingItem(
void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
void BuildingSystem::rotateInPlace(FactoryState& state, BuildingId id, Rotation newRotation)
{
// Construction site path — just update rotation; no ports to recompute.
for (ConstructionSite& site : m_state.constructionQueue)
for (ConstructionSite& site : state.constructionQueue)
{
if (site.id == id)
{
@@ -1109,7 +784,7 @@ void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
}
// Operational building path.
for (Building& b : m_state.buildings)
for (Building& b : state.buildings)
{
if (b.id != id) { continue; }
@@ -1154,14 +829,14 @@ void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
}
m_belts.removeTile(b.anchor);
reregisterBeltTile(b, splitterFilterA, splitterFilterB);
reregisterBeltTile(m_belts, m_config, b, splitterFilterA, splitterFilterB);
}
return;
}
}
BuildingId BuildingSystem::placeImmediate(BuildingType type,
BuildingId BuildingSystem::placeImmediate(FactoryState& state, BuildingType type,
const std::vector<std::string>& surfaceMask,
QPoint anchor, Rotation rotation)
{
@@ -1179,7 +854,7 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type,
{
const QPoint absCell = anchor + cell;
building.bodyCells.push_back(absCell);
m_state.grid.occupy(absCell, id);
state.grid.occupy(absCell, id);
}
for (const Port& port : mask.outputPorts)
{
@@ -1194,17 +869,17 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type,
if (type == BuildingType::SalvageBay)
{
initSalvageBayBuffer(building);
initSalvageBayBuffer(m_config, building);
}
m_state.buildings.push_back(std::move(building));
state.buildings.push_back(std::move(building));
return id;
}
bool BuildingSystem::removeBuilding(BuildingId id)
bool BuildingSystem::removeBuilding(FactoryState& state, BuildingId id)
{
for (std::vector<Building>::iterator it = m_state.buildings.begin();
it != m_state.buildings.end();
for (std::vector<Building>::iterator it = state.buildings.begin();
it != state.buildings.end();
++it)
{
if (it->id == id)
@@ -1214,31 +889,31 @@ bool BuildingSystem::removeBuilding(BuildingId id)
{
m_belts.removeTile(it->anchor);
}
m_state.grid.release(it->bodyCells);
m_state.buildings.erase(it);
state.grid.release(it->bodyCells);
state.buildings.erase(it);
return true;
}
}
return false;
}
void BuildingSystem::forEachBuilding(std::function<void(Building&)> fn)
void BuildingSystem::forEachBuilding(FactoryState& state, std::function<void(Building&)> fn)
{
for (Building& b : m_state.buildings)
for (Building& b : state.buildings)
{
fn(b);
}
}
void BuildingSystem::registerTileOccupancy(const std::vector<QPoint>& cells,
void BuildingSystem::registerTileOccupancy(FactoryState& state, const std::vector<QPoint>& cells,
BuildingId ownerPlaceholder)
{
m_state.grid.occupy(cells, ownerPlaceholder);
state.grid.occupy(cells, ownerPlaceholder);
}
void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells)
void BuildingSystem::unregisterTileOccupancy(FactoryState& state, const std::vector<QPoint>& cells)
{
m_state.grid.release(cells);
state.grid.release(cells);
}
namespace
@@ -1270,12 +945,12 @@ void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
}
} // namespace
void BuildingSystem::appendChecksum(Hasher& hasher) const
void BuildingSystem::appendChecksum(const FactoryState& state, Hasher& hasher) const
{
// m_state.buildings keeps a stable, deterministic order (append on build, swap-free
// state.buildings keeps a stable, deterministic order (append on build, swap-free
// erase aside — both runs perform identical operations, so order matches).
hasher.append(m_state.buildings.size());
for (const Building& b : m_state.buildings)
hasher.append(state.buildings.size());
for (const Building& b : state.buildings)
{
hasher.append(b.id);
hasher.append(b.anchor);
@@ -1318,8 +993,8 @@ void BuildingSystem::appendChecksum(Hasher& hasher) const
hasher.append(b.queuedForDeconstruction);
}
hasher.append(m_state.constructionQueue.size());
for (const ConstructionSite& s : m_state.constructionQueue)
hasher.append(state.constructionQueue.size());
for (const ConstructionSite& s : state.constructionQueue)
{
hasher.append(s.id);
hasher.append(s.anchor);
@@ -1336,8 +1011,8 @@ void BuildingSystem::appendChecksum(Hasher& hasher) const
for (const ItemType& type : s.splitterFilterB) { hasher.append(type.id); }
}
hasher.append(m_state.deconstructionQueue.size());
for (const DeconstructionEntry& e : m_state.deconstructionQueue)
hasher.append(state.deconstructionQueue.size());
for (const DeconstructionEntry& e : state.deconstructionQueue)
{
hasher.append(e.id);
hasher.append(e.completesAt);
@@ -1347,5 +1022,5 @@ void BuildingSystem::appendChecksum(Hasher& hasher) const
for (const ItemType& type : e.splitterFilterB) { hasher.append(type.id); }
}
m_state.grid.appendChecksum(hasher);
state.grid.appendChecksum(hasher);
}

View File

@@ -16,6 +16,8 @@
#include "BeltSystem.h"
#include "Building.h"
#include "FactoryState.h"
#include "BuildingBuffers.h"
#include "DeconstructionSystem.h"
#include "PlacementRules.h"
#include "ProductionRules.h"
#include "BuildingType.h"
@@ -37,7 +39,6 @@ class BuildingSystem
{
public:
BuildingSystem(const GameConfig& config,
FactoryState& state,
BeltSystem& belts,
std::function<BuildingId()> allocateBuildingId,
std::function<void(int)> addBuildingBlocks,
@@ -53,7 +54,7 @@ public:
// queue. Terrain type (A vs S) is NOT checked here so that tests can stage
// arbitrary layouts; the player-facing entry point
// (Simulation::tryPlaceBuilding) enforces the full rule via isPlacementValid.
std::optional<BuildingId> place(BuildingType type, QPoint anchor, Rotation rotation,
std::optional<BuildingId> place(FactoryState& state, BuildingType type, QPoint anchor, Rotation rotation,
Tick currentTick);
// Returns true if the placement satisfies REQ-BLD-PLACE-VALID terrain and
@@ -65,7 +66,8 @@ public:
// Sets the current buildable asteroid width in tiles. Grows the left
// placement bound as the player unlocks asteroid expansions (REQ-EXP-UNLOCK).
// Defaults to world.regions.asteroid_width_tiles at construction.
void setAsteroidWidth_tiles(int widthTiles) { m_state.asteroidWidth_tiles = widthTiles; }
void setAsteroidWidth_tiles(FactoryState& state, int widthTiles) const
{ state.asteroidWidth_tiles = widthTiles; }
// Mark a building or construction site for demolition (REQ-BLD-DECONSTRUCT).
// A construction site is removed instantly and the full cost is returned.
@@ -73,23 +75,23 @@ public:
// (REQ-BLD-DECON-QUEUE) and stops operating at once; its (partial) refund is
// credited later, on completion in tickDeconstruction, so this returns 0 for
// it. Returns 0 for unknown ids and for a building already queued.
int deconstruct(BuildingId id, Tick currentTick);
int deconstruct(FactoryState& state, BuildingId id, Tick currentTick);
// Take a building back out of the deconstruction queue before it is removed
// (REQ-BLD-DECON-QUEUE). Clears its queued flag and resumes operation
// (re-registering belt/tunnel/splitter tiles); discards deconstruction
// progress and credits no refund. No-op if the id is not queued.
void cancelDeconstruction(BuildingId id);
void cancelDeconstruction(FactoryState& state, BuildingId id);
// True if the building is currently in the deconstruction queue.
// Set the recipe (or schematic id for shipyard) on a building or queued
// construction site. Clears both buffers on an operational building.
void setRecipe(BuildingId id, const std::string& recipeId);
void setRecipe(FactoryState& state, BuildingId id, const std::string& recipeId);
// Set the module layout for a shipyard. Cancels in-progress production
// (materials discarded) and reinitializes input buffers (REQ-BLD-SHIPYARD).
void setShipLayout(BuildingId id, const ShipLayoutConfig& layout);
void setShipLayout(FactoryState& state, BuildingId id, const ShipLayoutConfig& layout);
// Splitter filter configuration for a queued/under-construction Splitter
// site (REQ-BLD-SITE-CONFIG). Operational splitters are configured through
@@ -98,23 +100,21 @@ public:
// output directions (derived from its surface mask) and stored filters, or
// nullopt if the id is not a Splitter site. The stored filters are applied
// to BeltSystem when the splitter finishes building (tickConstruction).
void setSiteSplitterFilters(BuildingId id,
void setSiteSplitterFilters(FactoryState& state, BuildingId id,
const std::vector<ItemType>& filterA,
const std::vector<ItemType>& filterB);
// -- Tick hooks (called from Simulation::tick in the documented order) ---
void tickConstruction(Tick currentTick);
// Advances the deconstruction queue (REQ-BLD-DECON-QUEUE): one building at a
// time, in parallel with tickConstruction. Removes the front building and
// credits its refund when its timer elapses.
void tickDeconstruction(Tick currentTick);
void tickBeltPull();
void tickProduction(Tick currentTick);
void tickShipyardProduction(Tick currentTick);
void tickBeltPull(FactoryState& state);
void tickProduction(FactoryState& state, Tick currentTick);
void tickShipyardProduction(FactoryState& state, Tick currentTick);
// Advances each building's virtual output belts, hands finished items off onto
// the adjacent real belt, and feeds new buffered items into them
// (REQ-MAT-OUTPUT-EMERGE).
void tickOutputBelts();
void tickOutputBelts(FactoryState& state);
// -- Queries -------------------------------------------------------------
@@ -134,26 +134,19 @@ public:
// virtual output belt (REQ-MAT-OUTPUT-EMERGE), passing the item type and its
// world-space centre (in tile units). Least-progressed first (drawn bottom) so
// callers can paint in visit order (REQ-GW-TILE-SIZE ordering).
void forEachEmergingItem(
void forEachEmergingItem(const FactoryState& state,
const std::function<void(const ItemType&, QPointF)>& visit) const;
// Visits every item currently travelling inward on a building input port's
// virtual input belt (REQ-MAT-INPUT-INTAKE), passing the item type and its
// world-space centre (in tile units). Least-progressed first (drawn bottom).
void forEachIncomingItem(
void forEachIncomingItem(const FactoryState& state,
const std::function<void(const ItemType&, QPointF)>& visit) const;
// Returns the entity id of the building or construction site whose footprint
// exactly coincides with the ghost (type, anchor, rot) and is of the same
// building type. Returns nullopt otherwise.
std::optional<BuildingId> findRotateInPlaceTarget(BuildingType type,
QPoint anchor,
Rotation rot) const;
// Rotate an existing building or construction site to newRotation in place.
// For belt-type operational buildings, re-registers with BeltSystem (items
// currently on the tile are discarded by BeltSystem::removeTile).
void rotateInPlace(BuildingId id, Rotation newRotation);
void rotateInPlace(FactoryState& state, BuildingId id, Rotation newRotation);
// Input-capable adjacent tiles for a building or construction site
@@ -162,8 +155,8 @@ public:
// the target. Output-port edges are excluded. Empty for an unknown id.
// Register / unregister tile occupancy for ECS station entities.
void registerTileOccupancy(const std::vector<QPoint>& cells, BuildingId ownerPlaceholder);
void unregisterTileOccupancy(const std::vector<QPoint>& cells);
void registerTileOccupancy(FactoryState& state, const std::vector<QPoint>& cells, BuildingId ownerPlaceholder);
void unregisterTileOccupancy(FactoryState& state, const std::vector<QPoint>& cells);
// Place one "scrap" item into a SalvageBay's output buffer.
// Returns false if bay not found, wrong type, or output buffer is full.
@@ -171,33 +164,29 @@ public:
// Bypass the construction queue and create a fully-operational Building
// immediately. Used for pre-placed structures (HQ, defence stations).
// surfaceMask comes from the relevant config struct.
BuildingId placeImmediate(BuildingType type,
BuildingId placeImmediate(FactoryState& state, BuildingType type,
const std::vector<std::string>& surfaceMask,
QPoint anchor, Rotation rotation);
// Remove an operational building by id without refund (used for deaths).
// Returns true if found and removed.
bool removeBuilding(BuildingId id);
bool removeBuilding(FactoryState& state, BuildingId id);
// Mutable iteration over all operational buildings.
void forEachBuilding(std::function<void(Building&)> fn);
void forEachBuilding(FactoryState& state, std::function<void(Building&)> fn);
// -- Determinism ---------------------------------------------------------
// Folds all building, construction-site, and tile-occupancy state into the
// hasher in deterministic order (see docs/replay_design.md).
void appendChecksum(Hasher& hasher) const;
void appendChecksum(const FactoryState& state, Hasher& hasher) const;
private:
// Starts the front deconstruction-queue entry's timer if not yet started
// (mirrors how tickConstruction starts a queued construction site).
void startFrontDeconstruction(Tick currentTick);
// Registers a belt/splitter/tunnel building's tile with the belt subsystem
// (on construction completion, or when un-queuing a deconstruction). No-op for
// non-belt-subsystem types. Splitter filters are (re)applied after placement.
void reregisterBeltTile(const Building& building,
const std::vector<ItemType>& splitterFilterA,
const std::vector<ItemType>& splitterFilterB);
// True if the consumer would accept `type` at the given input port right now:
// it is a required input (or a building block for the HQ), the reservation-aware
@@ -213,7 +202,7 @@ private:
// Attempts to hand an emerging output item straight into a directly adjacent
// building whose input edge meets the producer's output port (REQ-MAT-DIRECT-COUPLE).
// Returns true if the item was accepted onto the consumer's input belt.
bool tryDirectCoupleDeposit(BuildingId producerId,
bool tryDirectCoupleDeposit(FactoryState& state, BuildingId producerId,
const Port& outputPort,
const Item& item);
@@ -228,21 +217,14 @@ private:
// (ignoring output-buffer space); drives the Starved/Blocked distinction of
// the status light (REQ-UI-STATUS-LIGHT).
void initBuffers(Building& b, const RecipeDef& recipe) const;
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input
// caps span the union of every recipe of the building's type; no player
// recipe is selected (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
void initAutoBuffers(Building& b) const;
void initShipyardBuffers(Building& b) const;
void initSalvageBayBuffer(Building& b) const;
// Core input-edge scan shared by operational buildings and construction sites.
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
const GameConfig& m_config;
// The factory's world data — buildings, queued work, tile ownership. Owned by
// Simulation, not by this system (see FactoryState.h).
FactoryState& m_state;
BeltSystem& m_belts;
std::function<BuildingId()> m_allocateBuildingId;

View File

@@ -13,6 +13,9 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/Building.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/ConstructionSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructionSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingBuffers.h
${CMAKE_CURRENT_SOURCE_DIR}/FactoryState.h
${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.h
${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.h
@@ -42,6 +45,9 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ConstructionSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructionSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingBuffers.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.cpp
${CMAKE_CURRENT_SOURCE_DIR}/PlacementRules.cpp

View File

@@ -0,0 +1,112 @@
#include "ConstructionSystem.h"
#include "BuildingBuffers.h"
#include "BuildingType.h"
#include "FactoryQueries.h"
#include "PortGeometry.h"
#include "SurfaceMask.h"
#include "tracing.h"
void ConstructionSystem::tick(FactoryState& state, BeltSystem& belts, Tick currentTick)
{
TRACE();
if (state.constructionQueue.empty())
{
return;
}
ConstructionSite& front = state.constructionQueue.front();
// Guard: if somehow the front site was never started, start it now.
if (front.completesAt == 0)
{
const BuildingDef* def = m_config.buildings.findBuildingDef(front.type);
if (def)
{
front.completesAt = currentTick + secondsToTicks(def->constructionTimeSeconds);
}
return;
}
if (currentTick < front.completesAt)
{
return;
}
// Promote construction site to an operational Building.
const BuildingDef* def = m_config.buildings.findBuildingDef(front.type);
const ParsedSurfaceMask mask = parseSurfaceMask(
def ? def->surfaceMask : std::vector<std::string>{},
front.rotation);
Building building;
building.id = front.id;
building.anchor = front.anchor;
building.footprint = front.footprint;
building.rotation = front.rotation;
building.type = front.type;
building.recipeId = front.recipeId;
building.shipLayout = front.shipLayout;
for (const QPoint& cell : mask.bodyCells)
{
building.bodyCells.push_back(front.anchor + cell);
}
for (const Port& port : mask.outputPorts)
{
Port absPort;
absPort.tile = front.anchor + port.tile;
absPort.direction = port.direction;
building.outputPorts.push_back(absPort);
}
building.emergingItems.resize(building.outputPorts.size());
building.inputPorts = computeInputPorts(building.bodyCells, building.outputPorts);
building.incomingItems.assign(building.inputPorts.size(), {});
if (building.type == BuildingType::SalvageBay)
{
initSalvageBayBuffer(m_config, building);
}
else if (isAutoRecipeBuildingType(building.type))
{
// Smelter/Reprocessing Plant need no recipe selection; buffers are set
// up from all recipes of the type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
initAutoBuffers(m_config, building);
}
else if (!building.recipeId.empty())
{
if (building.type == BuildingType::Shipyard)
{
initShipyardBuffers(m_config, building);
}
else
{
const RecipeDef* recipe = m_config.recipes.findRecipeDef(building.recipeId, building.type);
if (recipe)
{
initBuffers(building, *recipe);
}
}
}
// Register with BeltSystem before the move (mask/building stays valid). Any
// filters configured while under construction carry over (REQ-BLD-SITE-CONFIG).
reregisterBeltTile(belts, m_config, building, front.splitterFilterA, front.splitterFilterB);
state.buildings.push_back(std::move(building));
state.constructionQueue.pop_front();
// Start next queued site if present.
if (!state.constructionQueue.empty() && state.constructionQueue.front().completesAt == 0)
{
const BuildingDef* nextDef =
m_config.buildings.findBuildingDef(state.constructionQueue.front().type);
if (nextDef)
{
state.constructionQueue.front().completesAt =
currentTick + secondsToTicks(nextDef->constructionTimeSeconds);
}
}
}

View File

@@ -0,0 +1,30 @@
#pragma once
#include "BeltSystem.h"
#include "FactoryState.h"
#include "GameConfig.h"
#include "Tick.h"
// Advances the construction queue and turns a finished site into an operational
// building (REQ-BLD-CONSTRUCTION). One site is built at a time, in queue order:
// the front site's timer runs, and when it elapses the site becomes a Building —
// its ports and buffers are derived from its definition, its belt tile is handed
// back to BeltSystem, and the next queued site starts.
//
// It completes the building itself rather than handing the finished site back to
// BuildingSystem: everything materialisation needs is either in FactoryState, the
// config, or a free function (see BuildingBuffers.h, PortGeometry.h), so there is
// no intermediate value to pass and no ordering rule between two calls.
//
// Holds only the config; the world it works on arrives per tick, like the other
// systems in lib/ecs/system.
class ConstructionSystem
{
public:
explicit ConstructionSystem(const GameConfig& config) : m_config(config) {}
void tick(FactoryState& state, BeltSystem& belts, Tick currentTick);
private:
const GameConfig& m_config;
};

View File

@@ -0,0 +1,67 @@
#include "DeconstructionSystem.h"
#include <vector>
#include "Building.h"
#include "tracing.h"
void startFrontDeconstruction(FactoryState& state, const GameConfig& config,
Tick currentTick)
{
if (state.deconstructionQueue.empty()) { return; }
DeconstructionEntry& front = state.deconstructionQueue.front();
if (front.completesAt == 0)
{
front.completesAt =
currentTick + secondsToTicks(config.world.deconstructionTimeSeconds);
}
}
void DeconstructionSystem::tick(FactoryState& state, Tick currentTick)
{
TRACE();
if (state.deconstructionQueue.empty())
{
return;
}
DeconstructionEntry& front = state.deconstructionQueue.front();
// Guard: if the front entry's timer was never started, start it now.
if (front.completesAt == 0)
{
startFrontDeconstruction(state, m_config, currentTick);
return;
}
if (currentTick < front.completesAt)
{
return;
}
// Remove the building from the world and credit its refund (REQ-BLD-DECONSTRUCT).
// Belt/tunnel/splitter tiles were already unregistered when the building was
// queued (see deconstruct), so only tile occupancy and the record remain.
for (std::vector<Building>::iterator it = state.buildings.begin();
it != state.buildings.end();
++it)
{
if (it->id != front.id) { continue; }
const BuildingDef* def = m_config.buildings.findBuildingDef(it->type);
state.grid.release(it->bodyCells);
state.buildings.erase(it);
if (def)
{
m_addBuildingBlocks(def->cost * m_config.world.refundPercentage / 100);
}
break;
}
state.deconstructionQueue.pop_front();
// Start the next queued deconstruction, if any.
startFrontDeconstruction(state, m_config, currentTick);
}

View File

@@ -0,0 +1,36 @@
#pragma once
#include <functional>
#include "FactoryState.h"
#include "GameConfig.h"
#include "Tick.h"
// The queue timer for pending demolitions (REQ-BLD-DECON-QUEUE): one building at a
// time, in parallel with construction. When the front entry's timer elapses the
// building is removed from the world, its tiles are released, and its partial refund
// is credited.
//
// It needs no BeltSystem: a belt, splitter or tunnel end is unregistered the moment
// it is queued (see BuildingSystem::deconstruct), not when the timer completes.
//
// Holds the config and the refund sink; the world arrives per tick.
class DeconstructionSystem
{
public:
DeconstructionSystem(const GameConfig& config,
std::function<void(int)> addBuildingBlocks)
: m_config(config), m_addBuildingBlocks(std::move(addBuildingBlocks)) {}
void tick(FactoryState& state, Tick currentTick);
private:
const GameConfig& m_config;
std::function<void(int)> m_addBuildingBlocks;
};
// Starts the timer on the front entry of the deconstruction queue, if it has one and
// it has not started yet. Shared: BuildingSystem::deconstruct starts the timer when it
// queues the first entry, and DeconstructionSystem restarts it after each completion.
void startFrontDeconstruction(FactoryState& state, const GameConfig& config,
Tick currentTick);

View File

@@ -4,6 +4,7 @@
#include <vector>
#include "Building.h"
#include "GameConfig.h"
#include "BuildingGrid.h"
#include "BuildingId.h"
#include "ItemType.h"
@@ -52,3 +53,14 @@ struct FactoryState
// Seeded from config by BuildingSystem's constructor.
int asteroidWidth_tiles = 0;
};
// A fresh factory for a new run: nothing built, and the asteroid bound seeded from
// config. Every owner of a FactoryState creates it this way — the bound has no
// sensible default without the config, so a default-constructed state would refuse
// every placement on the asteroid.
inline FactoryState makeFactoryState(const GameConfig& config)
{
FactoryState state;
state.asteroidWidth_tiles = config.world.regions.asteroidWidth_tiles;
return state;
}

View File

@@ -1,6 +1,8 @@
#include "Simulation.h"
#include "FactoryQueries.h"
#include "ConstructionSystem.h"
#include "DeconstructionSystem.h"
#include "PlacementRules.h"
#include <algorithm>
@@ -50,6 +52,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
{
m_currentEnemyStationEntities[0] = entt::null;
m_currentEnemyStationEntities[1] = entt::null;
m_factoryState = makeFactoryState(m_config);
initializeSubsystems();
@@ -97,7 +100,7 @@ void Simulation::reset(unsigned int seed)
m_pendingSchematicChoices.clear();
m_admin.clear();
m_factoryState = FactoryState{};
m_factoryState = makeFactoryState(m_config);
m_beltSystem = BeltSystem(m_config.world.beltSpeed_tps);
initializeSubsystems();
@@ -109,7 +112,6 @@ void Simulation::initializeSubsystems()
{
m_buildingSystem = std::make_unique<BuildingSystem>(
m_config,
m_factoryState,
m_beltSystem,
[this]() { return allocateBuildingId(); },
[this](int amount) { m_buildingBlocksStock += amount; },
@@ -123,6 +125,9 @@ void Simulation::initializeSubsystems()
},
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng);
m_constructionSystem = std::make_unique<ConstructionSystem>(m_config);
m_deconstructionSystem = std::make_unique<DeconstructionSystem>(
m_config, [this](int amount) { m_buildingBlocksStock += amount; });
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>(m_config);
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
@@ -153,15 +158,15 @@ void Simulation::apply(const Command& command)
const BuildingId id = *placed;
if (c.recipeId.has_value())
{
m_buildingSystem->setRecipe(id, *c.recipeId);
m_buildingSystem->setRecipe(m_factoryState, id, *c.recipeId);
}
if (c.shipLayout.has_value())
{
m_buildingSystem->setShipLayout(id, *c.shipLayout);
m_buildingSystem->setShipLayout(m_factoryState, id, *c.shipLayout);
}
if (c.hasSplitterFilters)
{
m_buildingSystem->setSiteSplitterFilters(id, c.splitterFilterA, c.splitterFilterB);
m_buildingSystem->setSiteSplitterFilters(m_factoryState, id, c.splitterFilterA, c.splitterFilterB);
}
break;
}
@@ -174,26 +179,26 @@ void Simulation::apply(const Command& command)
case CommandKind::RotateInPlace:
{
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
m_buildingSystem->rotateInPlace(*c.id, c.newRotation);
m_buildingSystem->rotateInPlace(m_factoryState, *c.id, c.newRotation);
break;
}
case CommandKind::SetRecipe:
{
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
m_buildingSystem->setRecipe(*c.id, c.recipeId);
m_buildingSystem->setRecipe(m_factoryState, *c.id, c.recipeId);
break;
}
case CommandKind::SetShipLayout:
{
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
m_buildingSystem->setShipLayout(*c.id, c.layout);
m_buildingSystem->setShipLayout(m_factoryState, *c.id, c.layout);
break;
}
case CommandKind::SetSiteSplitterFilters:
{
const SetSiteSplitterFiltersCommand& c =
static_cast<const SetSiteSplitterFiltersCommand&>(command);
m_buildingSystem->setSiteSplitterFilters(*c.id, c.filterA, c.filterB);
m_buildingSystem->setSiteSplitterFilters(m_factoryState, *c.id, c.filterA, c.filterB);
break;
}
case CommandKind::SetSplitterFilters:
@@ -243,12 +248,12 @@ void Simulation::tick()
m_waveSystem->tickThreatAccumulation();
// Construction + production pipeline
m_buildingSystem->tickConstruction(m_currentTick);
m_buildingSystem->tickDeconstruction(m_currentTick); // parallel to construction
m_buildingSystem->tickBeltPull(); // step 3
m_buildingSystem->tickProduction(m_currentTick); // step 4
m_buildingSystem->tickShipyardProduction(m_currentTick); // step 4b
m_buildingSystem->tickOutputBelts(); // step 5
m_constructionSystem->tick(m_factoryState, m_beltSystem, m_currentTick);
m_deconstructionSystem->tick(m_factoryState, m_currentTick); // parallel to construction
m_buildingSystem->tickBeltPull(m_factoryState); // step 3
m_buildingSystem->tickProduction(m_factoryState, m_currentTick); // step 4
m_buildingSystem->tickShipyardProduction(m_factoryState, m_currentTick); // step 4b
m_buildingSystem->tickOutputBelts(m_factoryState); // step 5
m_beltSystem.tick(); // step 6
// Step 7: ship behavior systems (movement arbitration via intent priority)
@@ -263,10 +268,10 @@ void Simulation::tick()
m_shipSystem->clearMovementIntents();
// Score-based behavior selection: evaluate, select winner, execute (sets
// movement intent + preferred module targets only — no world mutation).
m_aiSystem->tick(m_admin, m_factoryState, *m_debrisSystem);
m_aiSystem->tick(m_admin, m_factoryState);
// Module systems perform the world mutation (collection/delivery, healing).
// Each emits its tool beams and applies its own delayed (mid-beam) effects.
m_salvagerSystem->tick(m_currentTick, *m_debrisSystem, m_factoryState, m_beamFiredEvents);
m_salvagerSystem->tick(m_currentTick, m_factoryState, m_beamFiredEvents);
m_repairSystem->tick(m_currentTick, m_beamFiredEvents);
// Step 8: combat resolution
@@ -306,8 +311,7 @@ void Simulation::placeInitialStructures()
(m_config.world.heightTiles - hqParsed.footprint.height()) / 2;
const float hqHp =
static_cast<float>(m_config.stations.hq.hpFormula.evaluate(0.0));
m_hqBuildingId = m_buildingSystem->placeImmediate(
BuildingType::Hq,
m_hqBuildingId = m_buildingSystem->placeImmediate(m_factoryState, BuildingType::Hq,
m_config.stations.hq.surfaceMask,
QPoint(hqAnchorX, hqAnchorY),
Rotation::East);
@@ -356,7 +360,7 @@ void Simulation::placeInitialStructures()
m_admin.addComponent<ModuleOwnerComponent>(wChild,
ModuleOwnerComponent{m_playerStation1Entity});
}
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
}
{
const QPoint anchor(psAnchorX, ps2Y);
@@ -373,7 +377,7 @@ void Simulation::placeInitialStructures()
m_admin.addComponent<ModuleOwnerComponent>(wChild,
ModuleOwnerComponent{m_playerStation2Entity});
}
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
}
// Rally point: center of the player defence stations' X column, world vertical midpoint.
@@ -428,7 +432,7 @@ void Simulation::placeEnemyStationSet(int generation)
m_admin.addComponent<ModuleOwnerComponent>(wChild,
ModuleOwnerComponent{m_currentEnemyStationEntities[0]});
}
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
}
{
const QPoint anchor(anchorX, y2);
@@ -445,7 +449,7 @@ void Simulation::placeEnemyStationSet(int generation)
m_admin.addComponent<ModuleOwnerComponent>(wChild,
ModuleOwnerComponent{m_currentEnemyStationEntities[1]});
}
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
}
}
@@ -519,7 +523,7 @@ void Simulation::tickDeathsAndLoot()
{
m_debrisSystem->spawn(pos.value, scrap, despawnAt);
}
m_buildingSystem->unregisterTileOccupancy(sb.bodyCells);
m_buildingSystem->unregisterTileOccupancy(m_factoryState, sb.bodyCells);
{
std::vector<entt::entity> stationChildren;
m_admin.forEach<ModuleOwnerComponent>(
@@ -671,7 +675,7 @@ unsigned long long Simulation::computeStateChecksum() const
m_unlockState.appendChecksum(hasher);
// Subsystems contribute their own state.
m_buildingSystem->appendChecksum(hasher);
m_buildingSystem->appendChecksum(m_factoryState, hasher);
m_beltSystem.appendChecksum(hasher);
// ECS component state. View iteration order is a pure function of the
@@ -784,7 +788,7 @@ void Simulation::tryExpandAsteroid()
}
m_buildingBlocksStock -= cost;
++m_expansionsPurchased;
m_buildingSystem->setAsteroidWidth_tiles(getCurrentAsteroidWidth_tiles());
m_buildingSystem->setAsteroidWidth_tiles(m_factoryState, getCurrentAsteroidWidth_tiles());
}
bool Simulation::isGameOver() const
@@ -880,17 +884,17 @@ std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint
return std::nullopt;
}
m_buildingBlocksStock -= cost;
return m_buildingSystem->place(type, anchor, rotation, m_currentTick);
return m_buildingSystem->place(m_factoryState, type, anchor, rotation, m_currentTick);
}
void Simulation::deconstruct(BuildingId id)
{
m_buildingBlocksStock += m_buildingSystem->deconstruct(id, m_currentTick);
m_buildingBlocksStock += m_buildingSystem->deconstruct(m_factoryState, id, m_currentTick);
}
void Simulation::cancelDeconstruction(BuildingId id)
{
m_buildingSystem->cancelDeconstruction(id);
m_buildingSystem->cancelDeconstruction(m_factoryState, id);
}
BuildingSystem& Simulation::getBuildingsMutable()

View File

@@ -25,6 +25,8 @@
class AiSystem;
class BuildingSystem;
class ConstructionSystem;
class DeconstructionSystem;
struct Command;
class Hasher;
class CombatSystem;
@@ -218,6 +220,8 @@ private:
FactoryState m_factoryState;
BeltSystem m_beltSystem;
std::unique_ptr<BuildingSystem> m_buildingSystem;
std::unique_ptr<ConstructionSystem> m_constructionSystem;
std::unique_ptr<DeconstructionSystem> m_deconstructionSystem;
std::unique_ptr<ShipSystem> m_shipSystem;
std::unique_ptr<AiSystem> m_aiSystem;
std::unique_ptr<MovementIntentSystem> m_movementIntentSystem;

View File

@@ -15,6 +15,7 @@
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "ConstructionSystem.h"
#include "FactoryState.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
@@ -56,13 +57,14 @@
struct Fixture
{
GameConfig cfg;
FactoryState state;
FactoryState state = makeFactoryState(cfg);
BeltSystem belts;
BuildingId nextBuildingId;
int stock;
std::mt19937 rng;
EntityAdmin admin;
BuildingSystem buildings;
ConstructionSystem construction;
ShipSystem ships;
AiSystem ai;
SalvagerSystem salvager;
@@ -79,12 +81,13 @@ struct Fixture
, nextBuildingId(1)
, stock(0)
, rng(42)
, buildings(cfg, state, belts,
, buildings(cfg, belts,
[this]() { return nextBuildingId++; },
[this](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng)
, construction(cfg)
, ships(cfg, admin)
, ai(cfg)
, salvager(admin)
@@ -98,14 +101,14 @@ struct Fixture
void decide()
{
ships.clearMovementIntents();
ai.tick(admin, state, scraps);
ai.tick(admin, state);
}
// World mutation: collection/delivery and healing.
void runModules()
{
beamEvents.clear();
salvager.tick(tick, scraps, state, beamEvents);
salvager.tick(tick, state, beamEvents);
repair.tick(tick, beamEvents);
}
@@ -138,7 +141,7 @@ struct Fixture
void salvageTick()
{
beamEvents.clear();
salvager.tick(tick, scraps, state, beamEvents);
salvager.tick(tick, state, beamEvents);
++tick;
}
@@ -952,12 +955,12 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
{
Fixture f;
const BuildingId bayId = f.buildings.place(BuildingType::SalvageBay,
const BuildingId bayId = f.buildings.place(f.state, BuildingType::SalvageBay,
QPoint(-4, 0), Rotation::East, 0).value();
Tick t = 0;
for (int i = 0; i < 500; ++i)
{
f.buildings.tickConstruction(t++);
f.construction.tick(f.state, f.belts, t++);
if (findBuilding(f.state, bayId) != nullptr)
{
break;
@@ -987,12 +990,12 @@ TEST_CASE("SalvagerSystem: full-cargo ship at its SalvageBay hands over cargo",
{
Fixture f;
const BuildingId bayId = f.buildings.place(BuildingType::SalvageBay,
const BuildingId bayId = f.buildings.place(f.state, BuildingType::SalvageBay,
QPoint(-4, 0), Rotation::East, 0).value();
Tick t = 0;
for (int i = 0; i < 500; ++i)
{
f.buildings.tickConstruction(t++);
f.construction.tick(f.state, f.belts, t++);
if (findBuilding(f.state, bayId) != nullptr) { break; }
}
const Building* bay = findBuilding(f.state, bayId);

View File

@@ -666,7 +666,7 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), id, "mine_iron_ore");
const ConstructionSite* site = findSite(sim.getFactoryState(), id);
REQUIRE(site != nullptr);
@@ -680,7 +680,7 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_copper_ore");
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), id, "mine_copper_ore");
// Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300.
// Run 301 ticks (0..300) to process the completion tick.
@@ -726,7 +726,7 @@ TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blue
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), id, "mine_iron_ore");
const Blueprint bp = captureBlueprintFromSelection(sim, { id });
@@ -743,7 +743,7 @@ TEST_CASE("Blueprint creation: mixed operational building and construction site
const BuildingId idA =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(idA != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(idA, "mine_iron_ore");
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), idA, "mine_iron_ore");
for (int i = 0; i <= static_cast<int>(secondsToTicks(10.0)); ++i) { sim.tick(); }
REQUIRE(findBuilding(sim.getFactoryState(), idA) != nullptr);
@@ -751,7 +751,7 @@ TEST_CASE("Blueprint creation: mixed operational building and construction site
const BuildingId idB =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-6, 0), Rotation::East).value();
REQUIRE(idB != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(idB, "mine_copper_ore");
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), idB, "mine_copper_ore");
REQUIRE(findSite(sim.getFactoryState(), idB) != nullptr);
const Blueprint bp = captureBlueprintFromSelection(sim, { idA, idB });
@@ -854,7 +854,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), id, layout);
const ConstructionSite* site = findSite(sim.getFactoryState(), id);
REQUIRE(site != nullptr);
@@ -878,7 +878,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
pm.rotation = Rotation::North;
layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), id, layout);
// Shipyard construction_time_seconds = 30 in the test config.
double constructionTime = 0.0;

View File

@@ -37,8 +37,7 @@ BuildingId placeOperational(Simulation& sim, const GameConfig& cfg,
{
const BuildingDef* def = findDef(cfg, type);
REQUIRE(def != nullptr);
return SimulationTestAccess::buildings(sim).placeImmediate(
type, def->surfaceMask, anchor, Rotation::East);
return SimulationTestAccess::buildings(sim).placeImmediate(SimulationTestAccess::state(sim), type, def->surfaceMask, anchor, Rotation::East);
}
const ShipDef* findAvailableSchematic(const GameConfig& cfg)
@@ -67,7 +66,7 @@ TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[copyconfig]"
Simulation sim(loadTestConfig(), 7);
const BuildingId id = placeOperational(sim, cfg, BuildingType::Miner, QPoint(0, 0));
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), id, "mine_iron_ore");
const std::optional<BuildingConfig> config = readBuildingConfig(sim, id);
REQUIRE(config.has_value());
@@ -103,8 +102,8 @@ TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout",
REQUIRE(schematic != nullptr);
const BuildingId id = placeOperational(sim, cfg, BuildingType::Shipyard, QPoint(0, 0));
SimulationTestAccess::buildings(sim).setRecipe(id, schematic->id);
SimulationTestAccess::buildings(sim).setShipLayout(id, ShipLayoutConfig{});
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), id, schematic->id);
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), id, ShipLayoutConfig{});
const std::optional<BuildingConfig> config = readBuildingConfig(sim, id);
REQUIRE(config.has_value());
@@ -126,7 +125,7 @@ TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]")
REQUIRE(findBuilding(sim.getFactoryState(), id) == nullptr);
REQUIRE(findSite(sim.getFactoryState(), id) != nullptr);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), id, "mine_iron_ore");
const std::optional<BuildingConfig> config = readBuildingConfig(sim, id);
REQUIRE(config.has_value());

File diff suppressed because it is too large Load Diff

View File

@@ -52,7 +52,7 @@ static entt::entity findWeaponChild(EntityAdmin& admin, entt::entity ship)
struct CombatFixture
{
GameConfig cfg;
FactoryState state;
FactoryState state = makeFactoryState(cfg);
std::mt19937 rng;
EntityAdmin admin;
BuildingId nextBuildingId;
@@ -67,7 +67,7 @@ struct CombatFixture
, nextBuildingId(1)
, belts(cfg.world.beltSpeed_tps)
, ships(cfg, admin)
, buildings(cfg, state, belts,
, buildings(cfg, belts,
[this]() { return nextBuildingId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
@@ -415,7 +415,7 @@ TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
sim.tick();
const std::vector<DebrisInfo> scraps = sim.getDebrisSystem().getAllDebrisInfo();
const std::vector<DebrisInfo> scraps = getAllDebrisInfo(sim.getAdmin());
REQUIRE(scraps.size() == 1);
CHECK(sim.getAdmin().get<DebrisComponent>(scraps[0].entity).amount == 59);
}

View File

@@ -47,7 +47,7 @@ TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe"
const BuildingId id =
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
SimulationTestAccess::buildings(viaDirect).setRecipe(id, "mine_iron_ore");
SimulationTestAccess::buildings(viaDirect).setRecipe(SimulationTestAccess::state(viaDirect), id, "mine_iron_ore");
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
}

View File

@@ -116,16 +116,16 @@ TEST_CASE("DebrisSystem: collectOne depletes one scrap and keeps the debris unti
const entt::entity e = ss.spawn(QVector2D(0.0f, 0.0f), 3, 100);
REQUIRE(ss.collectOne(e));
REQUIRE(collectOne(admin, e));
REQUIRE(admin.isValid(e));
REQUIRE(admin.get<DebrisComponent>(e).amount == 2);
REQUIRE(ss.collectOne(e));
REQUIRE(collectOne(admin, e));
REQUIRE(admin.isValid(e));
REQUIRE(admin.get<DebrisComponent>(e).amount == 1);
// Final unit collected: the debris is removed once depleted.
REQUIRE(ss.collectOne(e));
REQUIRE(collectOne(admin, e));
REQUIRE_FALSE(admin.isValid(e));
}
@@ -134,7 +134,7 @@ TEST_CASE("DebrisSystem: collectOne returns false for an invalid entity", "[debr
EntityAdmin admin;
DebrisSystem ss(admin);
REQUIRE_FALSE(ss.collectOne(entt::null));
REQUIRE_FALSE(collectOne(admin, entt::null));
}
// ---------------------------------------------------------------------------
@@ -149,7 +149,7 @@ TEST_CASE("DebrisSystem: getAllDebrisInfo returns all spawned debris", "[debris]
ss.spawn(QVector2D(1.0f, 2.0f), 3, 100);
ss.spawn(QVector2D(4.0f, 5.0f), 6, 200);
const std::vector<DebrisInfo> info = ss.getAllDebrisInfo();
const std::vector<DebrisInfo> info = getAllDebrisInfo(admin);
REQUIRE(info.size() == 2);
}
@@ -161,7 +161,7 @@ TEST_CASE("DebrisSystem: getAllDebrisInfo reports each debris entry.s remaining
const entt::entity a = ss.spawn(QVector2D(1.0f, 2.0f), 3, 100);
const entt::entity b = ss.spawn(QVector2D(4.0f, 5.0f), 6, 200);
const std::vector<DebrisInfo> info = ss.getAllDebrisInfo();
const std::vector<DebrisInfo> info = getAllDebrisInfo(admin);
REQUIRE(info.size() == 2);
for (const DebrisInfo& i : info)
{

View File

@@ -62,8 +62,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
{
return SimulationTestAccess::buildings(sim).placeImmediate(
BuildingType::Shipyard,
return SimulationTestAccess::buildings(sim).placeImmediate(SimulationTestAccess::state(sim), BuildingType::Shipyard,
yardDef.surfaceMask,
QPoint(0, 0),
Rotation::East);
@@ -73,7 +72,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId,
const ShipDef& def,
const ShipLayoutConfig& layout)
{
SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b) {
SimulationTestAccess::buildings(sim).forEachBuilding(SimulationTestAccess::state(sim), [&](Building& b) {
if (b.id != yardId)
{
return;
@@ -208,7 +207,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");
ShipLayoutConfig layout;
PlacedModule pm;
@@ -217,7 +216,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
const Building* b = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(b != nullptr);
@@ -237,7 +236,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");
// Fill materials and tick to start production.
ShipLayoutConfig emptyLayout;
@@ -256,7 +255,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(b2 != nullptr);
@@ -279,7 +278,7 @@ TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(yardId, "interceptor");
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");
// Deliberately no setShipLayout: recipe set, layout left unconfigured.
// Charge only the base-hull materials (an empty layout adds none).
@@ -314,7 +313,7 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");
ShipLayoutConfig layout;
PlacedModule pm;
@@ -322,13 +321,13 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
const Building* b1 = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(b1 != nullptr);
REQUIRE(b1->shipLayout.has_value());
SimulationTestAccess::buildings(sim).setRecipe(yardId,"destroyer");
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"destroyer");
const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(b2 != nullptr);
@@ -343,7 +342,7 @@ TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");
ShipLayoutConfig layout;
PlacedModule pm;
@@ -351,14 +350,14 @@ TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
const Building* b1 = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(b1 != nullptr);
REQUIRE(b1->shipLayout.has_value());
// Re-selecting the same recipe must be a no-op and preserve the layout.
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");
const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(b2 != nullptr);

View File

@@ -58,8 +58,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
{
return SimulationTestAccess::buildings(sim).placeImmediate(
BuildingType::Shipyard,
return SimulationTestAccess::buildings(sim).placeImmediate(SimulationTestAccess::state(sim), BuildingType::Shipyard,
yardDef.surfaceMask,
QPoint(0, 0),
Rotation::East);
@@ -75,7 +74,7 @@ static int countShips(Simulation& sim)
static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def)
{
SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b)
SimulationTestAccess::buildings(sim).forEachBuilding(SimulationTestAccess::state(sim), [&](Building& b)
{
if (b.id != yardId)
{
@@ -107,7 +106,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
const BuildingId yardId = placeShipyard(sim, *yardDef);
REQUIRE(yardId != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, def->id);
fillMaterials(sim, yardId, *def);
// First tick: materials consumed, production cycle starts — no ship yet.
@@ -166,7 +165,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
const int shipsBefore = countShips(sim);
const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, def->id);
// Materials remain at zero (default after setRecipe); no cycle starts.
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
@@ -188,7 +187,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, def->id);
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);

View File

@@ -7,6 +7,7 @@
#include "BuildingId.h"
#include "BuildingType.h"
#include "Rotation.h"
#include "FactoryState.h"
#include "Simulation.h"
class BeltSystem;
@@ -25,6 +26,7 @@ class BuildingSystem;
struct SimulationTestAccess
{
static BuildingSystem& buildings(Simulation& sim) { return sim.getBuildingsMutable(); }
static FactoryState& state(Simulation& sim) { return sim.m_factoryState; }
static BeltSystem& belts(Simulation& sim) { return sim.getBeltsMutable(); }
static std::optional<BuildingId> place(Simulation& sim, BuildingType type,

View File

@@ -367,7 +367,7 @@ int FieldSelectionPanel::selectedDebrisScrapTotal() const
{
// Sum the remaining scrap across the still-living selected debris (REQ-UI-DEBRIS-PANEL).
int total = 0;
for (const DebrisInfo& info : m_sim->getDebrisSystem().getAllDebrisInfo())
for (const DebrisInfo& info : getAllDebrisInfo(m_sim->getAdmin()))
{
if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity)
!= m_selectedDebris.end())

View File

@@ -777,7 +777,7 @@ void GameWorldView::pruneDespawnedDebris()
if (m_selectedDebris.empty()) { return; }
std::vector<entt::entity> live;
for (const DebrisInfo& info : m_sim->getDebrisSystem().getAllDebrisInfo())
for (const DebrisInfo& info : getAllDebrisInfo(m_sim->getAdmin()))
{
if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity)
!= m_selectedDebris.end())
@@ -1510,7 +1510,7 @@ void GameWorldView::drawSelectionHighlights(QPainter& painter)
if (!m_selectedDebris.empty())
{
const qreal outlineRadius = static_cast<qreal>(getTilePx() * 0.2f) + 3.0;
for (const DebrisInfo& debris : m_sim->getDebrisSystem().getAllDebrisInfo())
for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
{
if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), debris.entity)
== m_selectedDebris.end()) { continue; }
@@ -1605,8 +1605,8 @@ void GameWorldView::drawPortItems(QPainter& painter)
painter.save();
painter.setClipRegion(clip);
m_sim->getBuildings().forEachEmergingItem(drawItem);
m_sim->getBuildings().forEachIncomingItem(drawItem);
m_sim->getBuildings().forEachEmergingItem(m_sim->getFactoryState(), drawItem);
m_sim->getBuildings().forEachIncomingItem(m_sim->getFactoryState(), drawItem);
painter.restore();
}
@@ -1654,7 +1654,7 @@ void GameWorldView::drawBeltItems(QPainter& painter)
void GameWorldView::drawDebris(QPainter& painter)
{
const float r = getTilePx() * 0.2f;
for (const DebrisInfo& debris : m_sim->getDebrisSystem().getAllDebrisInfo())
for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
{
const QPointF center = worldToWidget(debris.position);
painter.setBrush(QColor(128, 110, 90));