27 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
b3d6264ed3 move the placement rules and the config-dependent queries off BuildingSystem
isPlacementValid, findRotateInPlaceTarget and the bodyCellsWithinWorldBounds
helper become PlacementRules.h — where a building may go and what already sits
on those tiles, answered from the factory state and the config. getInputPorts
and getSiteSplitterInfo join FactoryQueries.h, whose header comment now says
plainly that the last two also take the config because answering them means
reading a building definition.

computeInputPorts goes to PortGeometry.h alongside outputBodyTile/inputBodyTile:
it needs only Port and QPoint, so it belongs in core rather than in sim.

BuildingSystem is left with no query that reads the factory — its remaining const
methods are the emerging/incoming item walks, the checksum fold, and the buffer
initialisers. It changes the factory now; it no longer describes it.

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-04 22:31:16 +02:00
ade716edf2 delete the unused getAllBeltTiles and BeltTileInfo
Nothing in lib, ui, balancing or the tests calls it — the only references were
its own declaration and definition.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 22:23:34 +02:00
3272431353 extract the production rules as free functions over config and building
gatherCandidateRecipes, recipeInputsAvailable, computeShipyardRequiredMaterials,
hasInputsToStart and getProductionStatus read no factory state — they answer
"what can this building produce, and can it start" from the config and the
Building alone. They move to ProductionRules.h as free functions, and the
ProductionStatus enum goes with them since it is that group's return type.

Two of the five are pure in their arguments; the other three need GameConfig
through gatherCandidateRecipes and computeShipyardRequiredMaterials, so config is
a parameter rather than the group being split across two headers.

Only two callers outside BuildingSystem existed — the status light in
GameWorldView and one test lambda — so this is nearly all internal.

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-04 22:17:01 +02:00
d1b688f45e remove two declarations left behind by the query migration
isTileOccupied and findNearestBuilding kept their declarations in
BuildingSystem.h after their definitions were deleted. Nothing calls them, so it
built and linked, but a caller would have hit an unresolved symbol rather than a
missing-member error.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 22:01:26 +02:00
df7f60c898 migrate every factory query off BuildingSystem onto the free functions
The eleven forwarding members added last commit are gone; callers now read the
data directly through FactoryQueries.h. Simulation and ArenaSimulation expose
getFactoryState() so the UI, the balancing view and the tests can reach it.

isQueuedForDeconstruction joined the free functions along the way — it only
reaches findBuilding, so it was state-pure too.

No facade was introduced. The chained form was the reason one looked attractive,
but rewriting sim.getBuildings().findBuilding(id) to findBuilding(sim.getFactoryState(), id)
turned out to be mechanical, and the result says which data is read rather than
which system happens to own it.

BuildingSystem.cpp is down to 1735 lines and no longer answers questions about
the factory — it only changes it. What remains on it are the mutators, the tick
phases, and the queries that also need GameConfig.

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-04 21:52:49 +02:00
58e173ad5b move the asteroid width bound into FactoryState
It was the last piece of mutable world data BuildingSystem still owned, and the
placement queries need it: isPlacementValid reaches it through
bodyCellsWithinWorldBounds, so those queries cannot become free functions over
FactoryState while the bound lives on the system.

Left out of the checksum deliberately. It is derived from config and Simulation's
expansion count, which is folded already, so adding it would change every
checksum without adding information.

Still seeded from config by BuildingSystem's constructor, which keeps the
initialization at exactly the point it happened before; reset() clears the state
before initializeSubsystems() rebuilds the system, so the ordering holds.

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-04 21:38:30 +02:00
bb50f527d6 drop CombatSystem's unused BuildingSystem parameter
The parameter was already commented out in the definition — combat resolution
never touched it. Removing it also removes the last reference to BuildingSystem
from CombatSystem, so the forward declaration goes too.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 21:34:20 +02:00
7540c21d5c depend on factory data instead of BuildingSystem in the AI path
Ten queries that read nothing but FactoryState become free functions in
FactoryQueries.h; the BuildingSystem methods stay as one-line forwards, so no
existing caller moves yet.

That lets the AI path drop its dependency on the system entirely. AiSystem,
SalvagerSystem, DeliverScrapEvaluator and DeliverScrapExecutor took a
BuildingSystem& purely to call findBuilding, findNearestBuilding and
deliverScrapToSalvageBay — all three are state-pure — so they now take
FactoryState& and say what they actually read. Four forward declarations of
BuildingSystem go with them.

No facade: the queries are plain free functions over the data. A facade was
considered to spare the ~180 UI call sites, but the AI needed only the data and
would have been given GameConfig it has no use for.

isProductionBuildingType moves to BuildingType.h beside isAutoRecipeBuildingType
and isBeltSubsystemType rather than being copied into the new file.

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-04 21:26:49 +02:00
2522a8c974 move FactoryState ownership out of BuildingSystem to Simulation
Simulation (and ArenaSimulation in the balancing tool) now owns the factory's
world data; BuildingSystem holds a reference to it. This is what lets the systems
that operate on the data be handed the same state — phase 3's construction and
deconstruction systems, and later the ecs/system/ classes that today take a
BuildingSystem& only to query it.

reset() clears the state alongside m_admin and m_beltSystem, matching how the
subsystems were already rebuilt from scratch.

Falls short of the tick-argument form I sketched: BuildingSystem still reaches
the data through a member reference rather than a parameter. Making it truly
stateless means the const query surface has to find the data some other way, and
that surface is large — findBuilding alone has 64 call sites, with findSite,
getAllBuildings, getAllSites, isTileOccupied and the rest behind it. Doing that
needs a queries facade behind Simulation::getBuildings() so the callers do not
all move, which is its own decision rather than a side effect of this one.

The constructor gains a parameter, so the four owners and the three test fixtures
that build a BuildingSystem directly are updated; the 33 files that only use one
are untouched.

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-04 20:55:32 +02:00
e39b81eb22 gather the factory's world data into FactoryState
BuildingSystem is the one system in the codebase that owns the world data it
operates on. The ecs/system/ classes already do the opposite — AiSystem and
SalvagerSystem hold config and their own scratch, and take EntityAdmin and the
other systems as tick arguments — so this is bringing the outlier in line, not
inventing a pattern.

Phase 1 of that: the buildings vector, both work queues and the tile grid move
into a FactoryState struct, still owned by BuildingSystem. Every method reaches
through m_state. The public API is untouched, so none of the 33 files that
reference BuildingSystem needed a change.

DeconstructionEntry moves out of BuildingSystem's private section into
FactoryState.h, since the queue that holds it lives there now.

m_asteroidWidth_tiles stays on the system: it is not checksummed and is a cached
placement bound derived from config and the expansion count, not factory data.

The intent is for Simulation to own FactoryState and pass it into the tick
methods, leaving the systems stateless over it. FactoryState.h notes explicitly
that this is a data/behaviour split and not a step toward putting buildings in
the entity model, which architecture.md rules out.

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-04 20:39:34 +02:00
71d0dad3f2 give tile occupancy its own class, BuildingGrid
Eleven methods maintained m_tileOccupancy by hand — place, deconstruct,
removeBuilding, placeImmediate, tickDeconstruction, findRotateInPlaceTarget and
tryDirectCoupleDeposit all indexed a raw std::map<std::pair<int,int>, BuildingId>
directly, so the invariant "occupancy stays in sync with placement" was
re-implemented at every call site. They now ask and tell a small owned index
instead: occupy / release / isOccupied / findOwner.

BuildingGrid is a member of BuildingSystem, not a peer system: it has no
per-tick behaviour and nothing outside BuildingSystem touches it.

The internal keying stays std::pair<int,int> rather than moving to QPoint. The
checksum folds the entries in map iteration order, so the comparator is part of
the determinism contract; changing it is a separate decision, not a side effect
of this move. 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-04 17:22:40 +02:00
fda88fe75c share BuildingSystem's free functions instead of copying them
Four of the five file-local helpers in BuildingSystem.cpp had duplicates
elsewhere: isAutoRecipeBuildingType and isBeltSubsystemType were re-spelled as
isAutoRecipeBuilding and isBeltLike in SelectedBuildingPanel.cpp, and
outputBodyTile was copied verbatim as portBodyTile in GameWorldView.cpp. Same
predicates, different names, so a change to one would silently not reach the
others.

The two BuildingType predicates move to BuildingType.h, which already hosts the
free functions over that enum and is already included by both lib and ui. The
port geometry moves to a new PortGeometry.h; inputBodyTile has no duplicate but
is outputBodyTile's counterpart and belongs beside it — the sim moves items
across the port edge and the renderer draws the virtual belt there, so the two
must agree on which tile a port owns.

inputLaneEntryFree stays file-local: single use, and tied to BeltItemSlot rather
than to building types or port geometry.

Verified the six moved bodies are character-identical to their originals.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 16:09:01 +02:00
d713257fb5 move the field selection out of SelectedBuildingPanel
The two selection categories used to arbitrate ownership of the panel by
poking each other's widgets: buildFieldSelection() called clearContent()
and buildEmpty(), buildEmpty() hid the four entity widgets, and
hideAllWidgets() hid the scrap label. Splitting the halves apart without
naming an arbiter would only have spread that across a class boundary.

SelectedBuildingPanel is now the sole arbiter. It still receives all
three selection events, forwards the two field ones to the embedded
FieldSelectionPanel, and drops its own selection and content as soon as
the field panel reports a selection (yieldToFieldSelection), mirroring
what onSelectionChanged() already did in the other direction. The field
panel decides only what to render and whether it is visible at all.

Dropping the field branch of refreshSelectionDisplay() is behaviour
preserving: whenever the field category owns the panel, m_singleBuildingId
is null, so the building refresh returns immediately anyway.

clearContent() and buildEmpty() became identical once the cross-half
hiding was gone, so only buildEmpty() remains.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 15:47:22 +02:00
0029236135 add FieldSelectionPanel for the ships/stations/debris selection
SelectedBuildingPanel has grown to 1200 lines by carrying two unrelated
selection categories. Introduce the field half as its own widget first,
so the cut-over is a separate, reviewable step.

The panel owns only its own selection state and widgets: it renders the
single-object stats panel (ship, station, debris) or the multi-object
count summary, subscribes to the tick/commands-applied refresh signals
and the debug-draw toggle, and hides itself while it has no selection.
Which category owns the side panel is not its decision - the parent
feeds it through setSelectedEntities/setSelectedDebris/clearSelection.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 15:42:25 +02:00
622447c45b correct the belt subsystem interface description in architecture.md
The section documented a 5-method port interface and claimed "no other system
ever asks what is on tile X". The real surface is 15 methods and peekItem does
ask exactly that, so the doc was misleading about a subsystem it exists to
explain. It also showed a tryPutItem signature that no longer matches.

Describes what is there now, grouped by purpose, and separates the two claims
that had been conflated: item transport is still port-only, but tile topology
is genuinely coupled to BuildingSystem because belts are Buildings for cost and
construction. The v2 migration note is qualified accordingly.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 15:24:31 +02:00
a34d66f548 allow auto for named local lambdas and iterator types
The blanket ban could not be satisfied: a lambda's type is unnameable, and
std::function is the wrong substitute in per-tick hot paths, so around seven
named local lambdas in lib violated the rule with no way to comply. Spelled-out
iterator types were being followed inconsistently in the same file.

Carving out the two cases makes the guideline enforceable rather than silently
broken. No code is changed here; existing spelled-out iterator types stay valid.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 15:21:32 +02:00
cb5572ffdd cover unlock state in the determinism tests
The scripted session only placed buildings, so every unlock container stayed at
its initial value for the whole run and the checksum never saw them change. It
now destroys the enemy stations twice and takes the offered schematic choice,
so awarded groups, per-schematic levels and the derived recipe/item sets are
exercised too.

Adds a test that pins down that unlock state actually reaches the checksum: two
sessions in lockstep, one takes the choice, checksums must diverge. The
scripted-session tests cannot show this themselves — they compare runs against
each other, so they pass whether or not UnlockState is in the fold. Verified by
temporarily removing the fold: the new test fails, the old two do not.

The first choice is asserted rather than assumed, so a config change that stops
offering a group fails loudly instead of silently dropping the coverage.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 15:08:04 +02:00
10ba226af7 wire Simulation to forward schematic/unlock queries to UnlockState
Simulation now owns an UnlockState member (constructed before
initializeSubsystems(), since BuildingSystem's spawn-gating lambda
calls into it via isSchematicUnlocked instead of poking the old
m_schematicLevels map directly). The public isXUnlocked accessors
become one-line forwards, applySchematicChoice's group-awarding block
becomes a single awardUnlockGroup() call, and generateSchematicChoices
(which still owns m_rng and must not change call ordering) now reads
group state and builds options through UnlockState.

The checksum fold at computeStateChecksum's schematic/unlock section
had to move together with the containers it reads; UnlockState::
appendChecksum makes the identical seven appendSchematicMap/
appendStringSet calls in the identical order, so the fold is
unaffected. Verified via a temporary golden-checksum test case
(added, checked, then removed) that tick1/100/999/1999 checksums for
seed 12345 are byte-identical to pre-refactor.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 14:10:52 +02:00
0a3288d1d1 add UnlockState class for schematic/unlock bookkeeping
Simulation.h/.cpp had grown a large block of schematic/unlock state
(containers, the implicit-unlock traversal, checksum folding) that has
nothing to do with tick orchestration. Split it into its own class so
Simulation stays legible as "tick orchestration + subsystem handles".

This commit only adds the new UnlockState.h/.cpp (registered in
CMakeLists.txt) with the containers, types, and logic moved in
verbatim; nothing references it yet, so this is a no-op for behavior.
Simulation is wired to use it in the next commit.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 14:09:28 +02:00
67 changed files with 3301 additions and 2803 deletions

View File

@@ -31,7 +31,14 @@ keep the citation accurate.
## Coding Guidelines ## Coding Guidelines
* avoid duplicate code * avoid duplicate code
* do not use the "auto" keyword * do not use the "auto" keyword, with two exceptions:
* **named local lambdas** — a lambda's type is unnameable, and `std::function`
is not an acceptable substitute in per-tick code because it adds a heap
allocation and an indirect call
* **iterator types** — `auto it = m_buildings.find(id)` is allowed where
spelling the iterator out adds length without adding information
* everywhere else the type is written out; in particular `auto` is not used
for plain values, return values, or range-for element types
* use Qt utility data types (like QPoint, QVector3D, QString, etc.) * use Qt utility data types (like QPoint, QVector3D, QString, etc.)
* wrap strings that appear in the UI with Qt's "tr()" * wrap strings that appear in the UI with Qt's "tr()"
* use the EventManager/EventHandler instead of defining own signals and slots * use the EventManager/EventHandler instead of defining own signals and slots

View File

@@ -136,17 +136,43 @@ Belts and splitters are their own specialized subsystem. Belt items are **not**
### Public Interface ### Public Interface
Narrow and representation-agnostic: `BeltSystem.h` is authoritative. The surface is wider than the original design sketch — 15 public methods in five groups, not the 5-method port interface this section used to describe:
```cpp ```cpp
class BeltSystem { class BeltSystem {
public: public:
bool tryPutItem(Port port, Item item); // Placement — belts/splitters/tunnels are Buildings for cost and
// construction, so BuildingSystem registers and unregisters their tiles.
void placeBelt(QPoint tile, Rotation direction);
void placeTunnelEntry(QPoint tile, Rotation direction, int maxDistance);
void placeTunnelExit(QPoint tile, Rotation direction);
void placeSplitter(QPoint tile, Rotation outputA, Rotation outputB);
void removeTile(QPoint tile);
// Splitter filter configuration (REQ-BLD-SPLITTER). A splitter's filters
// live here, not on Building, so callers that re-register a tile must
// carry them across (see BuildingSystem::reregisterBeltTile).
void setSplitterFilters(QPoint tile, const std::vector<ItemType>& filterA,
const std::vector<ItemType>& filterB);
std::optional<SplitterInfo> getSplitterInfo(QPoint tile) const;
// Port interface (buildings <-> belts)
bool tryPutItem(QPoint tile, Item item, Rotation fromDir = Rotation::West);
std::optional<Item> tryTakeItem(Port port); std::optional<Item> tryTakeItem(Port port);
std::optional<ItemType> peekItem(Port port) const;
double getProgressPerTick_tpt() const; // shared so building output items
// travel at belt speed (REQ-MAT-OUTPUT-EMERGE)
// Maintenance
void clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR void clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR
void tick(); void tick();
// Rendering
void forEachVisualItem(QRect viewportTiles, void forEachVisualItem(QRect viewportTiles,
std::function<void(VisualItem)> visit) const; std::function<void(VisualItem)> visit) const;
// Determinism (docs/replay_design.md)
void appendChecksum(Hasher& hasher) const;
}; };
struct VisualItem { struct VisualItem {
@@ -155,12 +181,12 @@ struct VisualItem {
}; };
``` ```
Buildings interact with belts only through port-level push and pull. Rendering reads only through `forEachVisualItem`. No other system ever asks "what is on tile X". Item *transport* is still reached only through push and pull: `tryPutItem` / `tryTakeItem` move items, `peekItem` reveals the leading item's type but never an identity, and rendering reads only through `forEachVisualItem`. The growth is in tile **topology** — placement, removal and splitter filters — which `BuildingSystem` drives because belts are `Building`s for cost, construction and deconstruction. That coupling is real and is not going away.
### Implementation Strategy ### Implementation Strategy
- v1: per-tile representation. Each belt tile stores up to 2 items with a progress value in `[0, 1]` along the tile's belt direction. Sufficient for the scale this game targets. - v1: per-tile representation. Each belt tile stores up to 2 items with a progress value in `[0, 1]` along the tile's belt direction. Sufficient for the scale this game targets.
- v2 (optional, only if v1 profiles poorly): Factorio-style belt-segment compression. Because the public interface never exposes tile-level item identity, migration is internal to the subsystem. - v2 (optional, only if v1 profiles poorly): Factorio-style belt-segment compression. The migration argument still holds for the item representation, since no method exposes tile-level item identity — but a v2 would have to keep the placement and splitter-filter methods working per tile, which is a stronger constraint than this section originally implied.
### Rendering Note ### Rendering Note

View File

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

View File

@@ -46,6 +46,8 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
, m_finished(false) , m_finished(false)
, m_stopRequested(false) , m_stopRequested(false)
{ {
m_factoryState = makeFactoryState(m_gameConfig);
m_buildingSystem = std::make_unique<BuildingSystem>( m_buildingSystem = std::make_unique<BuildingSystem>(
m_gameConfig, m_gameConfig,
m_beltSystem, m_beltSystem,
@@ -162,7 +164,7 @@ void ArenaSimulation::placeStructures()
hp, hp, false); hp, hp, false);
// Tag as an HQ so it is excluded from repair targeting (REQ-SHP-REPAIR). // Tag as an HQ so it is excluded from repair targeting (REQ-SHP-REPAIR).
m_admin.addComponent<HqProxyComponent>(m_team1HqEntity); 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. // Team 2 HQ — ECS proxy entity, enemy faction (isEnemy=true). No weapon.
@@ -183,7 +185,7 @@ void ArenaSimulation::placeStructures()
hp, hp, true); hp, hp, true);
// Tag as an HQ so it is excluded from repair targeting (REQ-SHP-REPAIR). // Tag as an HQ so it is excluded from repair targeting (REQ-SHP-REPAIR).
m_admin.addComponent<HqProxyComponent>(m_team2HqEntity); 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) auto placeArenaStation = [&](const ArenaStationEntry& entry, bool isEnemy)
@@ -237,7 +239,7 @@ void ArenaSimulation::placeStructures()
m_admin.addComponent<ModuleOwnerComponent>(wChild, m_admin.addComponent<ModuleOwnerComponent>(wChild,
ModuleOwnerComponent{stationEntity}); ModuleOwnerComponent{stationEntity});
} }
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId()); m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
}; };
for (const ArenaStationEntry& entry : m_arenaConfig.teams[0].stations) for (const ArenaStationEntry& entry : m_arenaConfig.teams[0].stations)
@@ -322,13 +324,13 @@ void ArenaSimulation::tick()
// Ship behavior systems (tick step 7): evaluate, select winner, execute. // Ship behavior systems (tick step 7): evaluate, select winner, execute.
// Module + combat systems emit their tool beams into a shared buffer. // Module + combat systems emit their tool beams into a shared buffer.
m_shipSystem->clearMovementIntents(); m_shipSystem->clearMovementIntents();
m_aiSystem->tick(m_admin, *m_buildingSystem, *m_debrisSystem); m_aiSystem->tick(m_admin, m_factoryState);
std::vector<BeamFiredEvent> beamFiredEvents; std::vector<BeamFiredEvent> beamFiredEvents;
m_salvagerSystem->tick(m_currentTick, *m_debrisSystem, *m_buildingSystem, beamFiredEvents); m_salvagerSystem->tick(m_currentTick, m_factoryState, beamFiredEvents);
m_repairSystem->tick(m_currentTick, beamFiredEvents); m_repairSystem->tick(m_currentTick, beamFiredEvents);
// Combat resolution (tick step 8). // Combat resolution (tick step 8).
m_combatSystem->tick(m_currentTick, m_admin, *m_buildingSystem, beamFiredEvents); m_combatSystem->tick(m_currentTick, m_admin, beamFiredEvents);
m_beamFiredEvents.insert(m_beamFiredEvents.end(), beamFiredEvents.begin(), beamFiredEvents.end()); m_beamFiredEvents.insert(m_beamFiredEvents.end(), beamFiredEvents.begin(), beamFiredEvents.end());
m_combatSystem->applyPendingDamage(m_currentTick, m_admin); m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
@@ -392,7 +394,7 @@ void ArenaSimulation::tickDeaths()
for (entt::entity deadEntity : deadStations) for (entt::entity deadEntity : deadStations)
{ {
const StationBodyComponent& sb = m_admin.get<StationBodyComponent>(deadEntity); 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; std::vector<entt::entity> stationChildren;
m_admin.forEach<ModuleOwnerComponent>( m_admin.forEach<ModuleOwnerComponent>(
@@ -487,6 +489,11 @@ const ArenaConfig& ArenaSimulation::getArenaConfig() const
return m_arenaConfig; return m_arenaConfig;
} }
const FactoryState& ArenaSimulation::getFactoryState() const
{
return m_factoryState;
}
const BuildingSystem& ArenaSimulation::getBuildings() const const BuildingSystem& ArenaSimulation::getBuildings() const
{ {
return *m_buildingSystem; return *m_buildingSystem;

View File

@@ -10,6 +10,7 @@
#include "BalancingConfig.h" #include "BalancingConfig.h"
#include "BeltSystem.h" #include "BeltSystem.h"
#include "FactoryState.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "BuildingId.h" #include "BuildingId.h"
@@ -85,6 +86,7 @@ public:
const ArenaConfig& getArenaConfig() const; const ArenaConfig& getArenaConfig() const;
const BuildingSystem& getBuildings() const; const BuildingSystem& getBuildings() const;
const FactoryState& getFactoryState() const;
const ShipSystem& getShips() const; const ShipSystem& getShips() const;
const DebrisSystem& getDebrisSystem() const; const DebrisSystem& getDebrisSystem() const;
EntityAdmin& getAdmin(); EntityAdmin& getAdmin();
@@ -107,6 +109,7 @@ private:
BuildingId m_nextBuildingId; BuildingId m_nextBuildingId;
EntityAdmin m_admin; EntityAdmin m_admin;
FactoryState m_factoryState;
BeltSystem m_beltSystem; BeltSystem m_beltSystem;
std::unique_ptr<BuildingSystem> m_buildingSystem; std::unique_ptr<BuildingSystem> m_buildingSystem;
std::unique_ptr<ShipSystem> m_shipSystem; std::unique_ptr<ShipSystem> m_shipSystem;

View File

@@ -1,4 +1,5 @@
#include "ArenaView.h" #include "ArenaView.h"
#include "FactoryQueries.h"
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
@@ -306,7 +307,7 @@ void ArenaView::drawTiles(QPainter& painter)
void ArenaView::drawBuildings(QPainter& painter) void ArenaView::drawBuildings(QPainter& painter)
{ {
for (const Building& b : m_sim->getBuildings().getAllBuildings()) for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{ {
const std::map<BuildingType, BuildingVisuals>::const_iterator it = const std::map<BuildingType, BuildingVisuals>::const_iterator it =
m_visuals->buildings.find(b.type); m_visuals->buildings.find(b.type);
@@ -339,7 +340,7 @@ void ArenaView::drawBuildings(QPainter& painter)
void ArenaView::drawDebris(QPainter& painter) void ArenaView::drawDebris(QPainter& painter)
{ {
const float r = getTilePx() * 0.2f; 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); const QPointF center = worldToWidget(debris.position);
painter.setBrush(QColor(128, 110, 90)); painter.setBrush(QColor(128, 110, 90));

View File

@@ -38,3 +38,32 @@ std::string buildingTypeId(BuildingType type)
} }
return ""; return "";
} }
bool isAutoRecipeBuildingType(BuildingType type)
{
return type == BuildingType::Smelter
|| type == BuildingType::ReprocessingPlant;
}
bool isBeltSubsystemType(BuildingType type)
{
return type == BuildingType::Belt
|| type == BuildingType::Splitter
|| type == BuildingType::TunnelEntry
|| type == BuildingType::TunnelExit;
}
bool isProductionBuildingType(BuildingType type)
{
switch (type)
{
case BuildingType::Miner:
case BuildingType::Smelter:
case BuildingType::Assembler:
case BuildingType::ReprocessingPlant:
case BuildingType::Shipyard:
return true;
default:
return false;
}
}

View File

@@ -29,3 +29,17 @@ std::optional<BuildingType> parseBuildingType(const std::string& id);
// Canonical id string for a BuildingType. The inverse of parseBuildingType. // Canonical id string for a BuildingType. The inverse of parseBuildingType.
std::string buildingTypeId(BuildingType type); std::string buildingTypeId(BuildingType type);
// Smelter and Reprocessing Plant have no player-selected recipe
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). They auto-process whatever inputs
// they receive, matching against every recipe of their building type.
bool isAutoRecipeBuildingType(BuildingType type);
// Buildings that run a production cycle: Miner, Smelter, Assembler, Reprocessing
// Plant and Shipyard (REQ-UI-DEBUG-OVERLAY counts these).
bool isProductionBuildingType(BuildingType type);
// Belts, splitters, and tunnel ends keep their runtime data in the belt subsystem
// rather than in the Building instance, so placing/removing them must register or
// unregister a tile with BeltSystem.
bool isBeltSubsystemType(BuildingType type);

View File

@@ -9,6 +9,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h ${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h
${CMAKE_CURRENT_SOURCE_DIR}/Item.h ${CMAKE_CURRENT_SOURCE_DIR}/Item.h
${CMAKE_CURRENT_SOURCE_DIR}/Port.h ${CMAKE_CURRENT_SOURCE_DIR}/Port.h
${CMAKE_CURRENT_SOURCE_DIR}/PortGeometry.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceOption.h ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceOption.h
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h ${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.h
@@ -19,6 +20,7 @@ SET(HDRS
SET(SRCS SET(SRCS
${SRCS} ${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.cpp
${CMAKE_CURRENT_SOURCE_DIR}/PortGeometry.cpp
${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.cpp ${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.cpp
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp ${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.cpp

View File

@@ -0,0 +1,57 @@
#include "PortGeometry.h"
#include <set>
#include <utility>
std::vector<Port> computeInputPorts(
const std::vector<QPoint>& bodyCells,
const std::vector<Port>& outputPorts)
{
// Build lookup sets for quick membership checks.
std::set<std::pair<int, int>> bodySet;
for (const QPoint& cell : bodyCells)
{
bodySet.insert({cell.x(), cell.y()});
}
std::set<std::pair<int, int>> outputPortTiles;
for (const Port& port : outputPorts)
{
outputPortTiles.insert({port.tile.x(), port.tile.y()});
}
// Neighbour deltas and the corresponding "inward" belt direction.
const int dx[4] = {-1, 1, 0, 0};
const int dy[4] = { 0, 0, -1, 1};
const Rotation inward[4] = {
Rotation::East, // neighbour is to the West; belt flows East toward building
Rotation::West, // neighbour is to the East; belt flows West toward building
Rotation::South, // neighbour is above (row-1); belt flows South toward building
Rotation::North // neighbour is below (row+1); belt flows North toward building
};
std::set<std::pair<int, int>> seen;
std::vector<Port> inputPorts;
for (const QPoint& cell : bodyCells)
{
for (int i = 0; i < 4; ++i)
{
const int nx = cell.x() + dx[i];
const int ny = cell.y() + dy[i];
const std::pair<int, int> neighbor = {nx, ny};
if (bodySet.count(neighbor)) { continue; }
if (outputPortTiles.count(neighbor)){ continue; }
if (seen.count(neighbor)) { continue; }
seen.insert(neighbor);
Port port;
port.tile = QPoint(nx, ny);
port.direction = inward[i];
inputPorts.push_back(port);
}
}
return inputPorts;
}

View File

@@ -0,0 +1,55 @@
#pragma once
#include <vector>
#include <QPoint>
#include "Port.h"
#include "Rotation.h"
// Geometry of a building's input/output ports. A Port names the tile *outside* the
// building together with the direction items flow across it; these helpers give the
// building body tile on the other side of that edge, which is where the virtual
// input/output belt lives.
//
// Shared by the simulation (which moves items across the edge) and the renderer
// (which draws the virtual belt), so the two cannot disagree about which tile a
// port belongs to.
// The building body tile that owns an output port, given the port's outside tile
// (port.tile) and its facing direction. The virtual output belt occupies this tile
// and flows toward port.tile (REQ-MAT-OUTPUT-EMERGE).
inline QPoint outputBodyTile(QPoint portTile, Rotation direction)
{
switch (direction)
{
case Rotation::East: return portTile + QPoint(-1, 0);
case Rotation::West: return portTile + QPoint( 1, 0);
case Rotation::North: return portTile + QPoint( 0, 1);
case Rotation::South: return portTile + QPoint( 0, -1);
}
return portTile;
}
// The building body tile an input port feeds into, given the port's outside belt
// tile (port.tile) and its inward flow direction. The virtual input belt occupies
// this tile and flows from the outer edge (progress 0.0) to the centre (0.5)
// (REQ-MAT-INPUT-INTAKE).
inline QPoint inputBodyTile(QPoint portTile, Rotation inwardDirection)
{
switch (inwardDirection)
{
case Rotation::East: return portTile + QPoint( 1, 0);
case Rotation::West: return portTile + QPoint(-1, 0);
case Rotation::North: return portTile + QPoint( 0, -1);
case Rotation::South: return portTile + QPoint( 0, 1);
}
return portTile;
}
// Every belt-facing edge of a footprint that is not already an output port — the
// tiles a belt can feed the building from, with the direction items must flow to
// enter (REQ-MAT-INPUT-PORTS, REQ-BLD-BELT-DRAG). bodyCells and outputPorts are
// in absolute tile coordinates, and so is the result.
std::vector<Port> computeInputPorts(const std::vector<QPoint>& bodyCells,
const std::vector<Port>& outputPorts);

View File

@@ -1,4 +1,5 @@
#include "AiSystem.h" #include "AiSystem.h"
#include "FactoryQueries.h"
#include <limits> #include <limits>
@@ -42,8 +43,7 @@ AiSystem::AiSystem(const GameConfig& config)
{ {
} }
void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings, void AiSystem::tick(EntityAdmin& admin, const FactoryState& state)
const DebrisSystem& debris)
{ {
TRACE(); TRACE();
@@ -54,8 +54,8 @@ void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
m_retreatEvaluator.evaluate(admin); m_retreatEvaluator.evaluate(admin);
m_attackEvaluator.evaluate(admin); m_attackEvaluator.evaluate(admin);
m_repairEvaluator.evaluate(admin); m_repairEvaluator.evaluate(admin);
m_salvageScrapEvaluator.evaluate(admin, debris); m_salvageScrapEvaluator.evaluate(admin);
m_deliverScrapEvaluator.evaluate(admin, buildings); m_deliverScrapEvaluator.evaluate(admin, state);
// Phase 2: pick the highest-scoring behavior per ship. // Phase 2: pick the highest-scoring behavior per ship.
selectWinningBehaviors(admin); selectWinningBehaviors(admin);
@@ -68,7 +68,7 @@ void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
m_attackExecutor.execute(admin); m_attackExecutor.execute(admin);
m_repairExecutor.execute(admin); m_repairExecutor.execute(admin);
m_salvageScrapExecutor.execute(admin); m_salvageScrapExecutor.execute(admin);
m_deliverScrapExecutor.execute(admin, buildings); m_deliverScrapExecutor.execute(admin, state);
} }
void AiSystem::selectWinningBehaviors(EntityAdmin& admin) void AiSystem::selectWinningBehaviors(EntityAdmin& admin)

View File

@@ -1,5 +1,7 @@
#pragma once #pragma once
#include "FactoryQueries.h"
#include "AdvanceEvaluator.h" #include "AdvanceEvaluator.h"
#include "AdvanceExecutor.h" #include "AdvanceExecutor.h"
#include "AttackEvaluator.h" #include "AttackEvaluator.h"
@@ -17,9 +19,7 @@
#include "StandbyEvaluator.h" #include "StandbyEvaluator.h"
#include "StandbyExecutor.h" #include "StandbyExecutor.h"
class BuildingSystem;
class EntityAdmin; class EntityAdmin;
class DebrisSystem;
struct GameConfig; struct GameConfig;
// Orchestrates ship-behavior decision-making in three batched phases: // Orchestrates ship-behavior decision-making in three batched phases:
@@ -34,7 +34,7 @@ class AiSystem
public: public:
explicit AiSystem(const GameConfig& config); explicit AiSystem(const GameConfig& config);
void tick(EntityAdmin& admin, const BuildingSystem& buildings, const DebrisSystem& debris); void tick(EntityAdmin& admin, const FactoryState& state);
private: private:
void selectWinningBehaviors(EntityAdmin& admin); void selectWinningBehaviors(EntityAdmin& admin);

View File

@@ -17,7 +17,6 @@ CombatSystem::CombatSystem(const GameConfig& config)
void CombatSystem::tick(Tick currentTick, void CombatSystem::tick(Tick currentTick,
EntityAdmin& admin, EntityAdmin& admin,
BuildingSystem& /*buildings*/,
std::vector<BeamFiredEvent>& outBeamFiredEvents) std::vector<BeamFiredEvent>& outBeamFiredEvents)
{ {
TRACE(); TRACE();

View File

@@ -15,7 +15,6 @@
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
class BuildingSystem;
class EntityAdmin; class EntityAdmin;
class CombatSystem class CombatSystem
@@ -25,7 +24,6 @@ public:
void tick(Tick currentTick, void tick(Tick currentTick,
EntityAdmin& admin, EntityAdmin& admin,
BuildingSystem& buildings,
std::vector<BeamFiredEvent>& outBeamFiredEvents); std::vector<BeamFiredEvent>& outBeamFiredEvents);
void applyPendingDamage(Tick currentTick, EntityAdmin& admin); void applyPendingDamage(Tick currentTick, EntityAdmin& admin);

View File

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

View File

@@ -38,9 +38,17 @@ public:
// false if the entity is invalid or already empty (REQ-SHP-SALVAGE). // false if the entity is invalid or already empty (REQ-SHP-SALVAGE).
bool collectOne(entt::entity entity); bool collectOne(entt::entity entity);
// Lightweight snapshot for callers that need to iterate all debris.
std::vector<DebrisInfo> getAllDebrisInfo() const;
private: private:
EntityAdmin& m_admin; 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

@@ -1,4 +1,5 @@
#include "SalvagerSystem.h" #include "SalvagerSystem.h"
#include "FactoryQueries.h"
#include <vector> #include <vector>
@@ -23,14 +24,14 @@ SalvagerSystem::SalvagerSystem(EntityAdmin& admin)
{ {
} }
void SalvagerSystem::tick(Tick currentTick, DebrisSystem& debris, BuildingSystem& buildings, void SalvagerSystem::tick(Tick currentTick, FactoryState& state,
std::vector<BeamFiredEvent>& outBeamFiredEvents) std::vector<BeamFiredEvent>& outBeamFiredEvents)
{ {
TRACE(); TRACE();
// Apply collections whose mid-beam delay has elapsed (cycles started earlier). // 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. // Tick down per-module collection cooldowns.
m_admin.forEach<SalvagerComponent>( m_admin.forEach<SalvagerComponent>(
@@ -89,7 +90,7 @@ void SalvagerSystem::tick(Tick currentTick, DebrisSystem& debris, BuildingSystem
[&](entt::entity ship, const DeliverScrapBehavior& deliver, const PositionComponent& pos) [&](entt::entity ship, const DeliverScrapBehavior& deliver, const PositionComponent& pos)
{ {
if (!deliver.deliveryBay.has_value()) { return; } if (!deliver.deliveryBay.has_value()) { return; }
const Building* bay = buildings.findBuilding(*deliver.deliveryBay); const Building* bay = findBuilding(state, *deliver.deliveryBay);
if (!bay) { return; } if (!bay) { return; }
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f, const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
@@ -100,14 +101,14 @@ void SalvagerSystem::tick(Tick currentTick, DebrisSystem& debris, BuildingSystem
if (!m_admin.hasAll<CargoComponent>(ship)) { return; } if (!m_admin.hasAll<CargoComponent>(ship)) { return; }
CargoComponent& cargo = m_admin.get<CargoComponent>(ship); CargoComponent& cargo = m_admin.get<CargoComponent>(ship);
if (cargo.current <= 0) { return; } if (cargo.current <= 0) { return; }
if (buildings.deliverScrapToSalvageBay(*deliver.deliveryBay)) if (deliverScrapToSalvageBay(state, *deliver.deliveryBay))
{ {
--cargo.current; --cargo.current;
} }
}); });
} }
void SalvagerSystem::applyPendingCollections(Tick currentTick, DebrisSystem& debris) void SalvagerSystem::applyPendingCollections(Tick currentTick)
{ {
std::vector<PendingCollection>::iterator it = m_pendingCollections.begin(); std::vector<PendingCollection>::iterator it = m_pendingCollections.begin();
while (it != m_pendingCollections.end()) while (it != m_pendingCollections.end())
@@ -117,7 +118,7 @@ void SalvagerSystem::applyPendingCollections(Tick currentTick, DebrisSystem& deb
if (m_admin.isValid(it->ship) && m_admin.hasAll<CargoComponent>(it->ship)) if (m_admin.isValid(it->ship) && m_admin.hasAll<CargoComponent>(it->ship))
{ {
CargoComponent& cargo = m_admin.get<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; ++cargo.current;
} }

View File

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

View File

@@ -1,4 +1,5 @@
#include "DeliverScrapEvaluator.h" #include "DeliverScrapEvaluator.h"
#include "FactoryQueries.h"
#include <unordered_map> #include <unordered_map>
@@ -12,7 +13,7 @@
#include "PositionComponent.h" #include "PositionComponent.h"
#include "tracing.h" #include "tracing.h"
void DeliverScrapEvaluator::evaluate(EntityAdmin& admin, const BuildingSystem& buildings) void DeliverScrapEvaluator::evaluate(EntityAdmin& admin, const FactoryState& state)
{ {
TRACE(); TRACE();
const std::unordered_map<entt::entity, CargoState> cargoByShip = buildCargoByShip(admin); const std::unordered_map<entt::entity, CargoState> cargoByShip = buildCargoByShip(admin);
@@ -34,7 +35,7 @@ void DeliverScrapEvaluator::evaluate(EntityAdmin& admin, const BuildingSystem& b
if (!deliver.deliveryBay.has_value()) if (!deliver.deliveryBay.has_value())
{ {
const Building* bay = const Building* bay =
buildings.findNearestBuilding(pos.value, BuildingType::SalvageBay); findNearestBuilding(state, pos.value, BuildingType::SalvageBay);
if (bay) { deliver.deliveryBay = bay->id; } if (bay) { deliver.deliveryBay = bay->id; }
} }

View File

@@ -1,12 +1,13 @@
#pragma once #pragma once
#include "FactoryQueries.h"
class EntityAdmin; class EntityAdmin;
class BuildingSystem;
// Scores high only when the ship's cargo is full, and assigns the nearest // Scores high only when the ship's cargo is full, and assigns the nearest
// SalvageBay as the delivery destination. // SalvageBay as the delivery destination.
class DeliverScrapEvaluator class DeliverScrapEvaluator
{ {
public: public:
void evaluate(EntityAdmin& admin, const BuildingSystem& buildings); void evaluate(EntityAdmin& admin, const FactoryState& state);
}; };

View File

@@ -1,4 +1,5 @@
#include "DeliverScrapExecutor.h" #include "DeliverScrapExecutor.h"
#include "FactoryQueries.h"
#include <QVector2D> #include <QVector2D>
@@ -12,7 +13,7 @@
#include "SelectedBehaviorComponent.h" #include "SelectedBehaviorComponent.h"
#include "tracing.h" #include "tracing.h"
void DeliverScrapExecutor::execute(EntityAdmin& admin, const BuildingSystem& buildings) void DeliverScrapExecutor::execute(EntityAdmin& admin, const FactoryState& state)
{ {
TRACE(); TRACE();
admin.forEach<DeliverScrapBehavior, SelectedBehaviorComponent, PositionComponent, admin.forEach<DeliverScrapBehavior, SelectedBehaviorComponent, PositionComponent,
@@ -26,7 +27,7 @@ void DeliverScrapExecutor::execute(EntityAdmin& admin, const BuildingSystem& bui
QVector2D dest = pos.value; QVector2D dest = pos.value;
if (deliver.deliveryBay.has_value()) if (deliver.deliveryBay.has_value())
{ {
const Building* bay = buildings.findBuilding(*deliver.deliveryBay); const Building* bay = findBuilding(state, *deliver.deliveryBay);
if (bay) if (bay)
{ {
dest = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f, dest = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f,

View File

@@ -1,12 +1,13 @@
#pragma once #pragma once
#include "FactoryQueries.h"
class EntityAdmin; class EntityAdmin;
class BuildingSystem;
// Moves a ship toward its delivery bay when DeliverScrap is the winning // Moves a ship toward its delivery bay when DeliverScrap is the winning
// behavior. Never decrements cargo — SalvagerSystem performs the delivery. // behavior. Never decrements cargo — SalvagerSystem performs the delivery.
class DeliverScrapExecutor class DeliverScrapExecutor
{ {
public: public:
void execute(EntityAdmin& admin, const BuildingSystem& buildings); void execute(EntityAdmin& admin, const FactoryState& state);
}; };

View File

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

View File

@@ -1,7 +1,6 @@
#pragma once #pragma once
class EntityAdmin; class EntityAdmin;
class DebrisSystem;
// When cargo is not full, finds the nearest debris within sensor range and sets // 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 // it as the target, scoring high. Scores inactive when cargo is full or no debris
@@ -9,5 +8,5 @@ class DebrisSystem;
class SalvageScrapEvaluator class SalvageScrapEvaluator
{ {
public: 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

@@ -1,4 +1,5 @@
#include "BuildingConfig.h" #include "BuildingConfig.h"
#include "FactoryQueries.h"
#include <algorithm> #include <algorithm>
#include <climits> #include <climits>
@@ -26,8 +27,8 @@ struct SelectedBuilding
// (the HQ and defence stations, per REQ-UI-BLUEPRINT-CREATE). // (the HQ and defence stations, per REQ-UI-BLUEPRINT-CREATE).
std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, BuildingId id) std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, BuildingId id)
{ {
const Building* building = sim.getBuildings().findBuilding(id); const Building* building = findBuilding(sim.getFactoryState(), id);
const ConstructionSite* site = building ? nullptr : sim.getBuildings().findSite(id); const ConstructionSite* site = building ? nullptr : findSite(sim.getFactoryState(), id);
if (!building && !site) if (!building && !site)
{ {
return std::nullopt; return std::nullopt;
@@ -52,8 +53,8 @@ std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, Building
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id) std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id)
{ {
const Building* building = sim.getBuildings().findBuilding(id); const Building* building = findBuilding(sim.getFactoryState(), id);
const ConstructionSite* site = building ? nullptr : sim.getBuildings().findSite(id); const ConstructionSite* site = building ? nullptr : findSite(sim.getFactoryState(), id);
if (!building && !site) if (!building && !site)
{ {
return std::nullopt; return std::nullopt;

View File

@@ -0,0 +1,52 @@
#include "BuildingGrid.h"
#include "StateChecksum.h"
void BuildingGrid::occupy(QPoint cell, BuildingId id)
{
m_owners[{cell.x(), cell.y()}] = id;
}
void BuildingGrid::occupy(const std::vector<QPoint>& cells, BuildingId id)
{
for (const QPoint& cell : cells)
{
occupy(cell, id);
}
}
void BuildingGrid::release(const std::vector<QPoint>& cells)
{
for (const QPoint& cell : cells)
{
m_owners.erase({cell.x(), cell.y()});
}
}
bool BuildingGrid::isOccupied(QPoint tile) const
{
return m_owners.count({tile.x(), tile.y()}) > 0;
}
std::optional<BuildingId> BuildingGrid::findOwner(QPoint tile) const
{
const std::map<std::pair<int, int>, BuildingId>::const_iterator it =
m_owners.find({tile.x(), tile.y()});
if (it == m_owners.end())
{
return std::nullopt;
}
return it->second;
}
void BuildingGrid::appendChecksum(Hasher& hasher) const
{
// std::map iterates in sorted key order.
hasher.append(m_owners.size());
for (const std::pair<const std::pair<int, int>, BuildingId>& entry : m_owners)
{
hasher.append(entry.first.first);
hasher.append(entry.first.second);
hasher.append(entry.second);
}
}

View File

@@ -0,0 +1,46 @@
#pragma once
#include <map>
#include <optional>
#include <utility>
#include <vector>
#include <QPoint>
#include "BuildingId.h"
class Hasher;
// The authority on which building owns which world tile.
//
// Every building and construction site claims its body cells here when it is placed
// and releases them when it is removed, so the map is the single place that knows
// whether a tile is free. It is a plain index owned by BuildingSystem, not a system:
// it has no per-tick behaviour and nothing outside BuildingSystem touches it.
//
// Keys are deliberately std::pair<int, int> rather than QPoint: the checksum folds the
// entries in map iteration order (docs/replay_design.md), so the comparator is part of
// the determinism contract and is not changed casually.
class BuildingGrid
{
public:
// Records absolute body cells as owned by id. Re-occupying a cell overwrites its
// previous owner, matching the placement paths that reserve cells for a site and
// then hand them to the building it becomes.
void occupy(QPoint cell, BuildingId id);
void occupy(const std::vector<QPoint>& cells, BuildingId id);
// Releases absolute body cells. Cells that are not occupied are ignored.
void release(const std::vector<QPoint>& cells);
bool isOccupied(QPoint tile) const;
// The building owning the tile, or nullopt when the tile is free.
std::optional<BuildingId> findOwner(QPoint tile) const;
// Folds the occupancy into the hasher in deterministic order.
void appendChecksum(Hasher& hasher) const;
private:
std::map<std::pair<int, int>, BuildingId> m_owners;
};

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,11 @@
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Building.h" #include "Building.h"
#include "FactoryState.h"
#include "BuildingBuffers.h"
#include "DeconstructionSystem.h"
#include "PlacementRules.h"
#include "ProductionRules.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "BuildingId.h" #include "BuildingId.h"
#include "GameConfig.h" #include "GameConfig.h"
@@ -26,18 +31,6 @@
class Hasher; class Hasher;
// Production state of a building for the UI status light (REQ-UI-STATUS-LIGHT).
// The simulation owns the classification so it stays in sync with the
// production-cycle predicates (REQ-MAT-CYCLE); the UI maps each value to a fill
// color.
enum class ProductionStatus
{
Unconfigured, // no recipe/schematic selected (grey)
Producing, // a production cycle is active (green)
Starved, // idle: a required input is missing / Salvage Bay empty (red)
Blocked, // idle: output buffer full, inputs otherwise present (yellow)
};
// Manages building placement, construction queuing, and the per-tick // Manages building placement, construction queuing, and the per-tick
// production loop (belt→building pull, production, building→belt push). // production loop (belt→building pull, production, building→belt push).
// All types including Belt and Splitter are stored as Building instances; // All types including Belt and Splitter are stored as Building instances;
@@ -61,7 +54,7 @@ public:
// queue. Terrain type (A vs S) is NOT checked here so that tests can stage // queue. Terrain type (A vs S) is NOT checked here so that tests can stage
// arbitrary layouts; the player-facing entry point // arbitrary layouts; the player-facing entry point
// (Simulation::tryPlaceBuilding) enforces the full rule via isPlacementValid. // (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); Tick currentTick);
// Returns true if the placement satisfies REQ-BLD-PLACE-VALID terrain and // Returns true if the placement satisfies REQ-BLD-PLACE-VALID terrain and
@@ -69,13 +62,12 @@ public:
// other body (A) cell sits on the asteroid (x < 0 and x >= the left edge), // other body (A) cell sits on the asteroid (x < 0 and x >= the left edge),
// and every cell has 0 <= y < world.height_tiles. There is no right-side // and every cell has 0 <= y < world.height_tiles. There is no right-side
// bound — space extends rightward. Tile occupancy is NOT checked here. // bound — space extends rightward. Tile occupancy is NOT checked here.
bool isPlacementValid(BuildingType type, QPoint anchor,
Rotation rotation) const;
// Sets the current buildable asteroid width in tiles. Grows the left // Sets the current buildable asteroid width in tiles. Grows the left
// placement bound as the player unlocks asteroid expansions (REQ-EXP-UNLOCK). // placement bound as the player unlocks asteroid expansions (REQ-EXP-UNLOCK).
// Defaults to world.regions.asteroid_width_tiles at construction. // Defaults to world.regions.asteroid_width_tiles at construction.
void setAsteroidWidth_tiles(int widthTiles) { m_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). // Mark a building or construction site for demolition (REQ-BLD-DECONSTRUCT).
// A construction site is removed instantly and the full cost is returned. // A construction site is removed instantly and the full cost is returned.
@@ -83,24 +75,23 @@ public:
// (REQ-BLD-DECON-QUEUE) and stops operating at once; its (partial) refund is // (REQ-BLD-DECON-QUEUE) and stops operating at once; its (partial) refund is
// credited later, on completion in tickDeconstruction, so this returns 0 for // credited later, on completion in tickDeconstruction, so this returns 0 for
// it. Returns 0 for unknown ids and for a building already queued. // 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 // Take a building back out of the deconstruction queue before it is removed
// (REQ-BLD-DECON-QUEUE). Clears its queued flag and resumes operation // (REQ-BLD-DECON-QUEUE). Clears its queued flag and resumes operation
// (re-registering belt/tunnel/splitter tiles); discards deconstruction // (re-registering belt/tunnel/splitter tiles); discards deconstruction
// progress and credits no refund. No-op if the id is not queued. // 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. // True if the building is currently in the deconstruction queue.
bool isQueuedForDeconstruction(BuildingId id) const;
// Set the recipe (or schematic id for shipyard) on a building or queued // Set the recipe (or schematic id for shipyard) on a building or queued
// construction site. Clears both buffers on an operational building. // 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 // Set the module layout for a shipyard. Cancels in-progress production
// (materials discarded) and reinitializes input buffers (REQ-BLD-SHIPYARD). // (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 // Splitter filter configuration for a queued/under-construction Splitter
// site (REQ-BLD-SITE-CONFIG). Operational splitters are configured through // site (REQ-BLD-SITE-CONFIG). Operational splitters are configured through
@@ -109,130 +100,94 @@ public:
// output directions (derived from its surface mask) and stored filters, or // 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 // nullopt if the id is not a Splitter site. The stored filters are applied
// to BeltSystem when the splitter finishes building (tickConstruction). // to BeltSystem when the splitter finishes building (tickConstruction).
std::optional<BeltSystem::SplitterInfo> getSiteSplitterInfo(BuildingId id) const; void setSiteSplitterFilters(FactoryState& state, BuildingId id,
void setSiteSplitterFilters(BuildingId id,
const std::vector<ItemType>& filterA, const std::vector<ItemType>& filterA,
const std::vector<ItemType>& filterB); const std::vector<ItemType>& filterB);
// -- Tick hooks (called from Simulation::tick in the documented order) --- // -- 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 // Advances the deconstruction queue (REQ-BLD-DECON-QUEUE): one building at a
// time, in parallel with tickConstruction. Removes the front building and // time, in parallel with tickConstruction. Removes the front building and
// credits its refund when its timer elapses. // credits its refund when its timer elapses.
void tickDeconstruction(Tick currentTick); void tickBeltPull(FactoryState& state);
void tickBeltPull(); void tickProduction(FactoryState& state, Tick currentTick);
void tickProduction(Tick currentTick); void tickShipyardProduction(FactoryState& state, Tick currentTick);
void tickShipyardProduction(Tick currentTick);
// Advances each building's virtual output belts, hands finished items off onto // Advances each building's virtual output belts, hands finished items off onto
// the adjacent real belt, and feeds new buffered items into them // the adjacent real belt, and feeds new buffered items into them
// (REQ-MAT-OUTPUT-EMERGE). // (REQ-MAT-OUTPUT-EMERGE).
void tickOutputBelts(); void tickOutputBelts(FactoryState& state);
// -- Queries ------------------------------------------------------------- // -- Queries -------------------------------------------------------------
struct BeltTileInfo
{
BuildingId buildingId;
QPoint tile;
BuildingType type; // Belt or Splitter
Rotation directionA; // Belt: its direction; Splitter: first output
Rotation directionB; // Splitter: second output; Belt: same as directionA
};
const Building* findBuilding(BuildingId id) const;
const ConstructionSite* findSite(BuildingId id) const;
std::vector<Building> getAllBuildings() const;
std::vector<ConstructionSite> getAllSites() const;
// REQ-UI-DEBUG-OVERLAY "Max Factory Production": count of completed // REQ-UI-DEBUG-OVERLAY "Max Factory Production": count of completed
// (operational) Miner/Smelter/Assembler/ReprocessingPlant/Shipyard buildings. // (operational) Miner/Smelter/Assembler/ReprocessingPlant/Shipyard buildings.
int getProductionBuildingCount() const;
// REQ-UI-DEBUG-OVERLAY "Current Factory Production": subset of the above // REQ-UI-DEBUG-OVERLAY "Current Factory Production": subset of the above
// that currently has an active production cycle. // that currently has an active production cycle.
int getActiveProductionBuildingCount() const;
// Production state for the UI status light (REQ-UI-STATUS-LIGHT). Returns // Production state for the UI status light (REQ-UI-STATUS-LIGHT). Returns
// nullopt for building types that show no light (belts, splitters, tunnels, // nullopt for building types that show no light (belts, splitters, tunnels,
// HQ, defence stations). The Salvage Bay is a two-state special case: // HQ, defence stations). The Salvage Bay is a two-state special case:
// Producing while its output buffer holds scrap, Starved when empty. // Producing while its output buffer holds scrap, Starved when empty.
std::optional<ProductionStatus> getProductionStatus(const Building& building) const;
std::vector<BeltTileInfo> getAllBeltTiles() const;
bool isTileOccupied(QPoint tile) const;
// Visits every item currently emerging from a building output port on its // Visits every item currently emerging from a building output port on its
// virtual output belt (REQ-MAT-OUTPUT-EMERGE), passing the item type and its // 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 // world-space centre (in tile units). Least-progressed first (drawn bottom) so
// callers can paint in visit order (REQ-GW-TILE-SIZE ordering). // 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; const std::function<void(const ItemType&, QPointF)>& visit) const;
// Visits every item currently travelling inward on a building input port's // 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 // virtual input belt (REQ-MAT-INPUT-INTAKE), passing the item type and its
// world-space centre (in tile units). Least-progressed first (drawn bottom). // 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; 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. // Rotate an existing building or construction site to newRotation in place.
// For belt-type operational buildings, re-registers with BeltSystem (items // For belt-type operational buildings, re-registers with BeltSystem (items
// currently on the tile are discarded by BeltSystem::removeTile). // currently on the tile are discarded by BeltSystem::removeTile).
void rotateInPlace(BuildingId id, Rotation newRotation); void rotateInPlace(FactoryState& state, BuildingId id, Rotation newRotation);
// Find nearest operational building of the given type; nullptr if none.
const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const;
// Input-capable adjacent tiles for a building or construction site // Input-capable adjacent tiles for a building or construction site
// (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS): each returned Port.tile is the // (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS): each returned Port.tile is the
// outside adjacent tile and Port.direction is the belt facing that points into // outside adjacent tile and Port.direction is the belt facing that points into
// the target. Output-port edges are excluded. Empty for an unknown id. // the target. Output-port edges are excluded. Empty for an unknown id.
std::vector<Port> getInputPorts(BuildingId id) const;
// Register / unregister tile occupancy for ECS station entities. // Register / unregister tile occupancy for ECS station entities.
void registerTileOccupancy(const std::vector<QPoint>& cells, BuildingId ownerPlaceholder); void registerTileOccupancy(FactoryState& state, const std::vector<QPoint>& cells, BuildingId ownerPlaceholder);
void unregisterTileOccupancy(const std::vector<QPoint>& cells); void unregisterTileOccupancy(FactoryState& state, const std::vector<QPoint>& cells);
// Place one "scrap" item into a SalvageBay's output buffer. // Place one "scrap" item into a SalvageBay's output buffer.
// Returns false if bay not found, wrong type, or output buffer is full. // Returns false if bay not found, wrong type, or output buffer is full.
bool deliverScrapToSalvageBay(BuildingId bayId);
// Bypass the construction queue and create a fully-operational Building // Bypass the construction queue and create a fully-operational Building
// immediately. Used for pre-placed structures (HQ, defence stations). // immediately. Used for pre-placed structures (HQ, defence stations).
// surfaceMask comes from the relevant config struct. // surfaceMask comes from the relevant config struct.
BuildingId placeImmediate(BuildingType type, BuildingId placeImmediate(FactoryState& state, BuildingType type,
const std::vector<std::string>& surfaceMask, const std::vector<std::string>& surfaceMask,
QPoint anchor, Rotation rotation); QPoint anchor, Rotation rotation);
// Remove an operational building by id without refund (used for deaths). // Remove an operational building by id without refund (used for deaths).
// Returns true if found and removed. // Returns true if found and removed.
bool removeBuilding(BuildingId id); bool removeBuilding(FactoryState& state, BuildingId id);
// Mutable iteration over all operational buildings. // Mutable iteration over all operational buildings.
void forEachBuilding(std::function<void(Building&)> fn); void forEachBuilding(FactoryState& state, std::function<void(Building&)> fn);
// -- Determinism --------------------------------------------------------- // -- Determinism ---------------------------------------------------------
// Folds all building, construction-site, and tile-occupancy state into the // Folds all building, construction-site, and tile-occupancy state into the
// hasher in deterministic order (see docs/replay_design.md). // hasher in deterministic order (see docs/replay_design.md).
void appendChecksum(Hasher& hasher) const; void appendChecksum(const FactoryState& state, Hasher& hasher) const;
private: private:
// Starts the front deconstruction-queue entry's timer if not yet started // Starts the front deconstruction-queue entry's timer if not yet started
// (mirrors how tickConstruction starts a queued construction site). // (mirrors how tickConstruction starts a queued construction site).
void startFrontDeconstruction(Tick currentTick);
// Registers a belt/splitter/tunnel building's tile with the belt subsystem // Registers a belt/splitter/tunnel building's tile with the belt subsystem
// (on construction completion, or when un-queuing a deconstruction). No-op for // (on construction completion, or when un-queuing a deconstruction). No-op for
// non-belt-subsystem types. Splitter filters are (re)applied after placement. // 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);
Building* findBuildingMutable(BuildingId id);
// True if the consumer would accept `type` at the given input port right now: // 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 // it is a required input (or a building block for the HQ), the reservation-aware
// buffer has room, and the input belt entry is free (REQ-MAT-INPUT-INTAKE). // buffer has room, and the input belt entry is free (REQ-MAT-INPUT-INTAKE).
@@ -247,7 +202,7 @@ private:
// Attempts to hand an emerging output item straight into a directly adjacent // 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). // 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. // 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 Port& outputPort,
const Item& item); const Item& item);
@@ -255,35 +210,22 @@ private:
// building (Smelter, Reprocessing Plant) offers every recipe of its type with // building (Smelter, Reprocessing Plant) offers every recipe of its type with
// inputs; other buildings offer only their selected recipe. Shared by // inputs; other buildings offer only their selected recipe. Shared by
// tickProduction and the status classifier (REQ-MAT-CYCLE, REQ-UI-STATUS-LIGHT). // tickProduction and the status classifier (REQ-MAT-CYCLE, REQ-UI-STATUS-LIGHT).
std::vector<const RecipeDef*> gatherCandidateRecipes(const Building& b) const;
// True if every input of `recipe` is present in `b`'s input buffers in the // True if every input of `recipe` is present in `b`'s input buffers in the
// required per-cycle amount (REQ-MAT-CYCLE input check). // required per-cycle amount (REQ-MAT-CYCLE input check).
bool recipeInputsAvailable(const Building& b,
const RecipeDef& recipe) const;
// Combined base + module materials a shipyard needs per ship (REQ-BLD-SHIPYARD). // Combined base + module materials a shipyard needs per ship (REQ-BLD-SHIPYARD).
std::map<std::string, int> computeShipyardRequiredMaterials(const Building& b) const;
// True if the building currently has all inputs/materials to start a cycle // True if the building currently has all inputs/materials to start a cycle
// (ignoring output-buffer space); drives the Starved/Blocked distinction of // (ignoring output-buffer space); drives the Starved/Blocked distinction of
// the status light (REQ-UI-STATUS-LIGHT). // the status light (REQ-UI-STATUS-LIGHT).
bool hasInputsToStart(const Building& b) const;
void initBuffers(Building& b, const RecipeDef& recipe) const;
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input // Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input
// caps span the union of every recipe of the building's type; no player // caps span the union of every recipe of the building's type; no player
// recipe is selected (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). // recipe is selected (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
void initAutoBuffers(Building& b) const;
void initShipyardBuffers(Building& b) const;
void initSalvageBayBuffer(Building& b) const;
std::vector<Port> computeInputPorts(const Building& b) const;
// Core input-edge scan shared by operational buildings and construction sites. // Core input-edge scan shared by operational buildings and construction sites.
std::vector<Port> computeInputPorts(const std::vector<QPoint>& bodyCells,
const std::vector<Port>& outputPorts) const;
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe); std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
bool bodyCellsWithinWorldBounds(
const std::vector<QPoint>& bodyCells,
QPoint anchor) const;
const GameConfig& m_config; const GameConfig& m_config;
BeltSystem& m_belts; BeltSystem& m_belts;
std::function<BuildingId()> m_allocateBuildingId; std::function<BuildingId()> m_allocateBuildingId;
std::function<void(int)> m_addBuildingBlocks; std::function<void(int)> m_addBuildingBlocks;
@@ -291,24 +233,4 @@ private:
const std::optional<ShipLayoutConfig>&)> m_spawnShip; const std::optional<ShipLayoutConfig>&)> m_spawnShip;
std::function<bool(const std::string&)> m_isItemUnlocked; std::function<bool(const std::string&)> m_isItemUnlocked;
std::mt19937& m_rng; std::mt19937& m_rng;
int m_asteroidWidth_tiles;
std::vector<Building> m_buildings;
std::deque<ConstructionSite> m_constructionQueue;
// One pending demolition of a fully-built building (REQ-BLD-DECON-QUEUE).
// completesAt == 0 means "queued but its timer has not started yet"
// (mirrors ConstructionSite). For a Splitter, the filters it had are captured
// here so cancelDeconstruction can restore them on re-registration.
struct DeconstructionEntry
{
BuildingId id = kInvalidBuildingId;
Tick completesAt = 0;
std::vector<ItemType> splitterFilterA;
std::vector<ItemType> splitterFilterB;
};
std::deque<DeconstructionEntry> m_deconstructionQueue;
// Maps every occupied body-cell coordinate to the entity that owns it.
std::map<std::pair<int, int>, BuildingId> m_tileOccupancy;
}; };

View File

@@ -12,6 +12,14 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/Building.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.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
${CMAKE_CURRENT_SOURCE_DIR}/PlacementRules.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
@@ -19,6 +27,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.h ${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.h
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h ${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h
${CMAKE_CURRENT_SOURCE_DIR}/UnlockState.h
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -35,11 +44,19 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.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
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.cpp ${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/UnlockState.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
PARENT_SCOPE PARENT_SCOPE
) )

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

@@ -0,0 +1,181 @@
#include "FactoryQueries.h"
#include <limits>
#include "PortGeometry.h"
#include "SurfaceMask.h"
#include "Item.h"
#include "ItemType.h"
const Building* findBuilding(const FactoryState& state, BuildingId id)
{
for (const Building& building : state.buildings)
{
if (building.id == id)
{
return &building;
}
}
return nullptr;
}
Building* findBuilding(FactoryState& state, BuildingId id)
{
for (Building& building : state.buildings)
{
if (building.id == id)
{
return &building;
}
}
return nullptr;
}
const ConstructionSite* findSite(const FactoryState& state, BuildingId id)
{
for (const ConstructionSite& site : state.constructionQueue)
{
if (site.id == id)
{
return &site;
}
}
return nullptr;
}
std::vector<Building> getAllBuildings(const FactoryState& state)
{
return state.buildings;
}
std::vector<ConstructionSite> getAllSites(const FactoryState& state)
{
return std::vector<ConstructionSite>(state.constructionQueue.begin(),
state.constructionQueue.end());
}
int getProductionBuildingCount(const FactoryState& state)
{
int count = 0;
for (const Building& b : state.buildings)
{
if (isProductionBuildingType(b.type)) { ++count; }
}
return count;
}
int getActiveProductionBuildingCount(const FactoryState& state)
{
int count = 0;
for (const Building& b : state.buildings)
{
if (isProductionBuildingType(b.type) && b.production.has_value()) { ++count; }
}
return count;
}
bool isTileOccupied(const FactoryState& state, QPoint tile)
{
return state.grid.isOccupied(tile);
}
bool isQueuedForDeconstruction(const FactoryState& state, BuildingId id)
{
const Building* building = findBuilding(state, id);
return building && building->queuedForDeconstruction;
}
const Building* findNearestBuilding(const FactoryState& state, QVector2D worldPos,
BuildingType type)
{
const Building* best = nullptr;
float bestDist = std::numeric_limits<float>::max();
for (const Building& b : state.buildings)
{
if (b.type != type)
{
continue;
}
QVector2D center(b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
float dist = (center - worldPos).length();
if (dist < bestDist)
{
bestDist = dist;
best = &b;
}
}
return best;
}
bool deliverScrapToSalvageBay(FactoryState& state, BuildingId bayId)
{
Building* bay = findBuilding(state, bayId);
if (!bay || bay->type != BuildingType::SalvageBay)
{
return false;
}
if (bay->queuedForDeconstruction)
{
return false; // queued for deconstruction: stopped operating (REQ-BLD-DECON-QUEUE)
}
// Emerging scrap still counts against the bay's holding capacity
// (REQ-MAT-OUTPUT-EMERGE).
if (bay->getOutputItemCount() >= bay->outputBuffer.capacity)
{
return false;
}
bay->outputBuffer.items.push_back(Item{ItemType{"scrap"}});
return true;
}
std::vector<Port> getInputPorts(const FactoryState& state, const GameConfig& config,
BuildingId id)
{
if (const Building* building = findBuilding(state, id))
{
return building->inputPorts;
}
if (const ConstructionSite* site = findSite(state, id))
{
// A site stores no ports; derive its output ports from the mask (absolute)
// and run the same input-edge scan (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS).
const BuildingDef* def = config.buildings.findBuildingDef(site->type);
if (def == nullptr) { return {}; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, site->rotation);
std::vector<Port> outputPortsAbsolute;
outputPortsAbsolute.reserve(mask.outputPorts.size());
for (const Port& port : mask.outputPorts)
{
outputPortsAbsolute.push_back(Port{ site->anchor + port.tile, port.direction });
}
return computeInputPorts(site->bodyCells, outputPortsAbsolute);
}
return {};
}
std::optional<BeltSystem::SplitterInfo>
getSiteSplitterInfo(const FactoryState& state, const GameConfig& config, BuildingId id)
{
for (const ConstructionSite& site : state.constructionQueue)
{
if (site.id != id) { continue; }
if (site.type != BuildingType::Splitter) { return std::nullopt; }
const BuildingDef* def = config.buildings.findBuildingDef(site.type);
const ParsedSurfaceMask mask = parseSurfaceMask(
def ? def->surfaceMask : std::vector<std::string>{}, site.rotation);
if (mask.outputPorts.size() < 2) { return std::nullopt; }
BeltSystem::SplitterInfo info;
info.outputA = mask.outputPorts[0].direction;
info.outputB = mask.outputPorts[1].direction;
info.filterA = site.splitterFilterA;
info.filterB = site.splitterFilterB;
return info;
}
return std::nullopt;
}

View File

@@ -0,0 +1,71 @@
#pragma once
#include <vector>
#include <QPoint>
#include <QVector2D>
#include "Building.h"
#include "BuildingId.h"
#include "BuildingType.h"
#include "BeltSystem.h"
#include "FactoryState.h"
#include "GameConfig.h"
#include "Port.h"
// Queries and operations over the factory's world data that need nothing but that
// data — no config, no belts, no RNG. Free functions rather than BuildingSystem
// methods so that callers depend on the data they read instead of on the system
// that happens to tick it (see FactoryState.h).
//
// Most need nothing but the state. The two at the bottom also take the config,
// because answering them means reading a building definition — but still no belts,
// no RNG and no system.
// The building with the given id, or nullptr when no building has it. Construction
// sites are not buildings yet — use findSite for those.
const Building* findBuilding(const FactoryState& state, BuildingId id);
Building* findBuilding(FactoryState& state, BuildingId id);
// The queued construction site with the given id, or nullptr.
const ConstructionSite* findSite(const FactoryState& state, BuildingId id);
std::vector<Building> getAllBuildings(const FactoryState& state);
std::vector<ConstructionSite> getAllSites(const FactoryState& state);
// REQ-UI-DEBUG-OVERLAY "Max Factory Production": count of completed
// (operational) Miner/Smelter/Assembler/ReprocessingPlant/Shipyard buildings.
int getProductionBuildingCount(const FactoryState& state);
// REQ-UI-DEBUG-OVERLAY "Current Factory Production": subset of the above that
// currently has an active production cycle.
int getActiveProductionBuildingCount(const FactoryState& state);
bool isTileOccupied(const FactoryState& state, QPoint tile);
// True while the building is in the deconstruction queue (REQ-BLD-DECON-QUEUE).
bool isQueuedForDeconstruction(const FactoryState& state, BuildingId id);
// The nearest building of the given type to a world position, or nullptr when
// none exists. Distance is measured to the building's footprint centre.
const Building* findNearestBuilding(const FactoryState& state, QVector2D worldPos,
BuildingType type);
// Hands one scrap to a Salvage Bay's output buffer (REQ-BLD-SALVAGE-BAY). Fails
// if the id is not a Salvage Bay, it is queued for deconstruction
// (REQ-BLD-DECON-QUEUE), or its holding capacity is already taken — emerging
// scrap counts against that capacity (REQ-MAT-OUTPUT-EMERGE).
bool deliverScrapToSalvageBay(FactoryState& state, BuildingId bayId);
// Every belt-facing edge of the building or site with this id (REQ-MAT-INPUT-PORTS,
// REQ-BLD-BELT-DRAG). Empty when the id is unknown. A site has no stored ports, so
// they are derived from its surface mask.
std::vector<Port> getInputPorts(const FactoryState& state, const GameConfig& config,
BuildingId id);
// The two output directions and stored filters of a queued Splitter site
// (REQ-BLD-SITE-CONFIG), or nullopt if the id is not one. Operational splitters are
// configured through BeltSystem by tile instead.
std::optional<BeltSystem::SplitterInfo> getSiteSplitterInfo(const FactoryState& state,
const GameConfig& config,
BuildingId id);

View File

@@ -0,0 +1,66 @@
#pragma once
#include <deque>
#include <vector>
#include "Building.h"
#include "GameConfig.h"
#include "BuildingGrid.h"
#include "BuildingId.h"
#include "ItemType.h"
#include "Tick.h"
// One pending demolition of a fully-built building (REQ-BLD-DECON-QUEUE).
// completesAt == 0 means "queued but its timer has not started yet"
// (mirrors ConstructionSite). For a Splitter, the filters it had are captured
// here so cancelDeconstruction can restore them on re-registration.
struct DeconstructionEntry
{
BuildingId id = kInvalidBuildingId;
Tick completesAt = 0;
std::vector<ItemType> splitterFilterA;
std::vector<ItemType> splitterFilterB;
};
// The factory's world data: every building, the work queued on them, and the
// tile ownership index. This is the buildings-side counterpart to EntityAdmin —
// data with no behaviour of its own beyond what BuildingGrid encapsulates.
//
// Buildings deliberately stay a plain vector rather than becoming EnTT entities
// (see docs/architecture.md). Separating this data from the systems that operate
// on it is not a step toward putting them in the entity model; it is the same
// data/behaviour split the ecs/system/ classes already follow, where world data
// arrives as a tick argument instead of being owned by the system.
//
// Owned by Simulation (and by ArenaSimulation in the balancing tool), not by the
// systems that operate on it. BuildingSystem holds a reference. The remaining step
// is to pass this into the tick methods instead, so the systems become stateless
// over it — that one is gated on the query surface, which today reaches the data
// through BuildingSystem's ~180 const call sites.
struct FactoryState
{
std::vector<Building> buildings;
std::deque<ConstructionSite> constructionQueue;
std::deque<DeconstructionEntry> deconstructionQueue;
// The authority on which building owns which tile; every placement and removal
// path claims and releases its body cells here.
BuildingGrid grid;
// Current buildable asteroid width, the left bound for placement. Grows as the
// player buys expansions (REQ-EXP-UNLOCK). Deliberately not checksummed: it is
// derived from config and Simulation's expansion count, which is folded already.
// 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

@@ -0,0 +1,121 @@
#include "PlacementRules.h"
#include "BuildingType.h"
#include "FactoryQueries.h"
#include "SurfaceMask.h"
bool bodyCellsWithinWorldBounds(const FactoryState& state, const GameConfig& config,const std::vector<QPoint>& bodyCells,
QPoint anchor)
{
const int heightTiles = config.world.heightTiles;
const int leftEdgeX = -state.asteroidWidth_tiles;
for (const QPoint& cell : bodyCells)
{
const QPoint worldCell = anchor + cell;
if (worldCell.y() < 0 || worldCell.y() >= heightTiles)
{
return false;
}
if (worldCell.x() < leftEdgeX)
{
return false;
}
}
return true;
}
bool isPlacementValid(const FactoryState& state, const GameConfig& config,BuildingType type, QPoint anchor,
Rotation rotation)
{
const BuildingDef* def = config.buildings.findBuildingDef(type);
if (def == nullptr)
{
return false;
}
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation);
if (!bodyCellsWithinWorldBounds(state, config, mask.bodyCells, anchor))
{
return false;
}
// Terrain: ship-dock (S) cells must sit in space (x >= 0); all other body
// (A) cells must sit on the asteroid (x < 0). (REQ-BLD-PLACE-VALID)
for (const QPoint& cell : mask.bodyCells)
{
const QPoint worldCell = anchor + cell;
bool isShipDock = false;
for (const QPoint& dock : mask.shipDockCells)
{
if (dock == cell)
{
isShipDock = true;
break;
}
}
if (isShipDock)
{
if (worldCell.x() < 0)
{
return false;
}
}
else if (worldCell.x() >= 0)
{
return false;
}
}
return true;
}
std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state, const GameConfig& config,
BuildingType type, QPoint anchor, Rotation rot)
{
// Tunnel Entries and Tunnel Exits cannot be rotated in place; re-orienting a
// tunnel requires deconstructing and re-placing it (REQ-BLD-ROTATE-IN-PLACE).
if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit)
{
return std::nullopt;
}
const BuildingDef* def = config.buildings.findBuildingDef(type);
if (!def) { return std::nullopt; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rot);
if (mask.bodyCells.empty()) { return std::nullopt; }
// All body cells must be occupied by the same entity.
const QPoint firstAbs = anchor + mask.bodyCells[0];
const std::optional<BuildingId> firstOwner = state.grid.findOwner(firstAbs);
if (!firstOwner.has_value()) { return std::nullopt; }
const BuildingId candidateId = *firstOwner;
for (const QPoint& rel : mask.bodyCells)
{
const std::optional<BuildingId> owner = state.grid.findOwner(anchor + rel);
if (!owner.has_value() || *owner != candidateId)
{
return std::nullopt;
}
}
// Verify the candidate is the same building type with the same cell count.
for (const ConstructionSite& site : state.constructionQueue)
{
if (site.id != candidateId) { continue; }
if (site.type != type) { return std::nullopt; }
if (site.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; }
return candidateId;
}
for (const Building& b : state.buildings)
{
if (b.id != candidateId) { continue; }
if (b.type != type) { return std::nullopt; }
if (b.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; }
return candidateId;
}
return std::nullopt;
}

View File

@@ -0,0 +1,38 @@
#pragma once
#include <optional>
#include <vector>
#include <QPoint>
#include "BuildingId.h"
#include "BuildingType.h"
#include "FactoryState.h"
#include "GameConfig.h"
#include "Rotation.h"
// Where a building may be placed, and what is already sitting on those tiles.
// Free functions over the factory state and the config — they read no other
// system state, so they do not belong to BuildingSystem.
// True if every body cell lies inside the world: 0 <= y < world.height_tiles and
// x >= the current asteroid left edge (REQ-BLD-PLACE-VALID). Terrain type is not
// checked — see isPlacementValid for the full rule.
bool bodyCellsWithinWorldBounds(const FactoryState& state, const GameConfig& config,
const std::vector<QPoint>& bodyCells, QPoint anchor);
// True if the placement satisfies REQ-BLD-PLACE-VALID terrain and world-bounds
// rules: every ship-dock (S) cell sits in space (x >= 0), every other body (A)
// cell sits on the asteroid (x < 0 and x >= the left edge), and every cell has
// 0 <= y < world.height_tiles. There is no right-side bound — space extends
// rightward. Tile occupancy is NOT checked here.
bool isPlacementValid(const FactoryState& state, const GameConfig& config,
BuildingType type, QPoint anchor, Rotation rotation);
// The building or site that a ghost of the given type/anchor/rotation would
// rotate in place rather than replace: same type, same body cells, one owner
// (REQ-BLD-ROTATE-IN-PLACE). Tunnels never qualify.
std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state,
const GameConfig& config,
BuildingType type, QPoint anchor,
Rotation rot);

View File

@@ -0,0 +1,142 @@
#include "ProductionRules.h"
#include "BuildingType.h"
#include "ItemType.h"
#include "ModulesConfig.h"
#include "ShipsConfig.h"
std::vector<const RecipeDef*>
gatherCandidateRecipes(const GameConfig& config, const Building& b)
{
std::vector<const RecipeDef*> candidates;
if (isAutoRecipeBuildingType(b.type))
{
for (const RecipeDef& r : config.recipes.recipes)
{
if (r.building == b.type && !r.inputs.empty())
{
candidates.push_back(&r);
}
}
}
else
{
const RecipeDef* recipe = config.recipes.findRecipeDef(b.recipeId, b.type);
if (recipe)
{
candidates.push_back(recipe);
}
}
return candidates;
}
bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe)
{
for (const RecipeIngredient& ing : recipe.inputs)
{
const std::map<ItemType, int>::const_iterator it =
b.inputBuffer.counts.find(ItemType{ing.item});
const int have = (it != b.inputBuffer.counts.end()) ? it->second : 0;
if (have < ing.amount)
{
return false;
}
}
return true;
}
std::map<std::string, int>
computeShipyardRequiredMaterials(const GameConfig& config, const Building& b)
{
std::map<std::string, int> requiredMaterials;
const ShipDef* shipDef = config.ships.findShipDef(b.recipeId);
if (!shipDef)
{
return requiredMaterials;
}
for (const RecipeIngredient& ing : shipDef->schematic.materials)
{
requiredMaterials[ing.item] += 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)
{
requiredMaterials[ing.item] += ing.amount;
}
}
}
return requiredMaterials;
}
bool hasInputsToStart(const GameConfig& config, const Building& b)
{
if (b.type == BuildingType::Shipyard)
{
const std::map<std::string, int> required =
computeShipyardRequiredMaterials(config, b);
for (const std::pair<const std::string, int>& req : required)
{
const std::map<ItemType, int>::const_iterator it =
b.inputBuffer.counts.find(ItemType{req.first});
const int have = (it != b.inputBuffer.counts.end()) ? it->second : 0;
if (have < req.second)
{
return false;
}
}
return true;
}
// Recipe buildings: startable if any candidate recipe's inputs are satisfied.
// A Miner recipe has no inputs, so an idle Miner is always startable and its
// only idle reason is a full output buffer.
for (const RecipeDef* recipe : gatherCandidateRecipes(config, b))
{
if (recipeInputsAvailable(b, *recipe))
{
return true;
}
}
return false;
}
std::optional<ProductionStatus>
getProductionStatus(const GameConfig& config, const Building& building)
{
// Salvage Bay has no recipe or production cycle (REQ-BLD-SALVAGE-BAY): it is
// "producing" while it holds scrap to push out, and starved when empty.
if (building.type == BuildingType::SalvageBay)
{
return building.getOutputItemCount() >= 1 ? ProductionStatus::Producing
: ProductionStatus::Starved;
}
// Only the five recipe/cycle production types show a status light besides the
// Salvage Bay; belts, splitters, tunnels, HQ, and stations show none.
if (!isProductionBuildingType(building.type))
{
return std::nullopt;
}
// Grey only applies to player-configured types; auto-recipe buildings
// (Smelter, Reprocessing Plant) always run an implicit recipe.
if (!isAutoRecipeBuildingType(building.type) && building.recipeId.empty())
{
return ProductionStatus::Unconfigured;
}
if (building.production.has_value())
{
return ProductionStatus::Producing;
}
// Idle: missing inputs (red) take precedence over a full output buffer
// (yellow). If inputs are present yet the building is idle, the only remaining
// reason it could not start a cycle is a full output buffer (REQ-MAT-CYCLE).
return hasInputsToStart(config, building) ? ProductionStatus::Blocked
: ProductionStatus::Starved;
}

View File

@@ -0,0 +1,47 @@
#pragma once
#include <map>
#include <optional>
#include <string>
#include <vector>
#include "Building.h"
#include "GameConfig.h"
#include "RecipesConfig.h"
// Production state of a building for the UI status light (REQ-UI-STATUS-LIGHT).
// The simulation owns the classification so it stays in sync with the
// production-cycle predicates (REQ-MAT-CYCLE); the UI maps each value to a fill
// color.
enum class ProductionStatus
{
Unconfigured, // no recipe/schematic selected (grey)
Producing, // a production cycle is active (green)
Starved, // idle: a required input is missing / Salvage Bay empty (red)
Blocked, // idle: output buffer full, inputs otherwise present (yellow)
};
// The rules deciding what a building can produce and whether it can start.
// Pure functions of the config and the building itself — they read no factory
// state, so they are free functions rather than BuildingSystem members.
// Recipes this building could run: every recipe of its type for an auto-recipe
// building (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), otherwise just its selected one.
std::vector<const RecipeDef*> gatherCandidateRecipes(const GameConfig& config,
const Building& b);
// True when the building's input buffer holds every ingredient the recipe needs.
bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe);
// Total materials a shipyard needs for its schematic plus its placed modules
// (REQ-BLD-SHIPYARD), keyed by item id.
std::map<std::string, int> computeShipyardRequiredMaterials(const GameConfig& config,
const Building& b);
// True when a production cycle could start right now, ignoring output-buffer space.
bool hasInputsToStart(const GameConfig& config, const Building& b);
// Status light for a building, or nullopt for types that show none — belts,
// splitters, tunnels, HQ and defence stations (REQ-UI-STATUS-LIGHT).
std::optional<ProductionStatus> getProductionStatus(const GameConfig& config,
const Building& building);

View File

@@ -1,12 +1,16 @@
#include "Simulation.h" #include "Simulation.h"
#include "FactoryQueries.h"
#include "ConstructionSystem.h"
#include "DeconstructionSystem.h"
#include "PlacementRules.h"
#include <algorithm> #include <algorithm>
#include <cassert> #include <cassert>
#include <cmath> #include <cmath>
#include "AiSystem.h" #include "AiSystem.h"
#include "Command.h" #include "Command.h"
#include "DisplayName.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "CombatSystem.h" #include "CombatSystem.h"
#include "DynamicBodyComponent.h" #include "DynamicBodyComponent.h"
@@ -43,14 +47,16 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
, m_hqProxyEntity(entt::null) , m_hqProxyEntity(entt::null)
, m_playerStation1Entity(entt::null) , m_playerStation1Entity(entt::null)
, m_playerStation2Entity(entt::null) , m_playerStation2Entity(entt::null)
, m_unlockState(m_config)
, m_beltSystem(m_config.world.beltSpeed_tps) , m_beltSystem(m_config.world.beltSpeed_tps)
{ {
m_currentEnemyStationEntities[0] = entt::null; m_currentEnemyStationEntities[0] = entt::null;
m_currentEnemyStationEntities[1] = entt::null; m_currentEnemyStationEntities[1] = entt::null;
m_factoryState = makeFactoryState(m_config);
initializeSubsystems(); initializeSubsystems();
initializeUnlockState(); m_unlockState.initializeUnlockState();
placeInitialStructures(); placeInitialStructures();
registerForEvents(); registerForEvents();
} }
@@ -94,10 +100,11 @@ void Simulation::reset(unsigned int seed)
m_pendingSchematicChoices.clear(); m_pendingSchematicChoices.clear();
m_admin.clear(); m_admin.clear();
m_factoryState = makeFactoryState(m_config);
m_beltSystem = BeltSystem(m_config.world.beltSpeed_tps); m_beltSystem = BeltSystem(m_config.world.beltSpeed_tps);
initializeSubsystems(); initializeSubsystems();
initializeUnlockState(); m_unlockState.initializeUnlockState();
placeInitialStructures(); placeInitialStructures();
} }
@@ -110,9 +117,7 @@ void Simulation::initializeSubsystems()
[this](int amount) { m_buildingBlocksStock += amount; }, [this](int amount) { m_buildingBlocksStock += amount; },
[this](const std::string& id, QVector2D pos, [this](const std::string& id, QVector2D pos,
const std::optional<ShipLayoutConfig>& layout) { const std::optional<ShipLayoutConfig>& layout) {
const std::map<std::string, SchematicState>::const_iterator it = if (!isSchematicUnlocked(id))
m_schematicLevels.find(id);
if (it == m_schematicLevels.end() || !it->second.unlocked)
{ {
return; return;
} }
@@ -120,6 +125,9 @@ void Simulation::initializeSubsystems()
}, },
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); }, [this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng); 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_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>(m_config); m_aiSystem = std::make_unique<AiSystem>(m_config);
m_movementIntentSystem = std::make_unique<MovementIntentSystem>(); m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
@@ -131,55 +139,6 @@ void Simulation::initializeSubsystems()
m_combatSystem = std::make_unique<CombatSystem>(m_config); m_combatSystem = std::make_unique<CombatSystem>(m_config);
} }
void Simulation::initializeUnlockState()
{
// Cache the ids granted by some unlock group (REQ-LOCK-EXPLICIT); an item
// starts locked iff it is granted by a group.
m_grantedShipIds.clear();
m_grantedModuleIds.clear();
m_grantedBuildingIds.clear();
m_grantedRecipeIds.clear();
for (const UnlockGroupDef& group : m_config.unlocks.groups)
{
m_grantedShipIds.insert(group.ships.begin(), group.ships.end());
m_grantedModuleIds.insert(group.modules.begin(), group.modules.end());
m_grantedBuildingIds.insert(group.buildings.begin(), group.buildings.end());
m_grantedRecipeIds.insert(group.recipes.begin(), group.recipes.end());
}
m_awardedUnlockGroupIds.clear();
m_schematicLevels.clear();
for (const ShipDef& def : m_config.ships.ships)
{
SchematicState state;
state.unlocked = (m_grantedShipIds.count(def.id) == 0);
m_schematicLevels[def.id] = state;
}
m_moduleSchematicLevels.clear();
for (const ModuleDef& def : m_config.modules.modules)
{
SchematicState state;
state.unlocked = (m_grantedModuleIds.count(def.id) == 0);
m_moduleSchematicLevels[def.id] = state;
}
m_buildingLevels.clear();
for (const BuildingDef& def : m_config.buildings.buildings)
{
SchematicState state;
state.unlocked = (m_grantedBuildingIds.count(def.id) == 0);
m_buildingLevels[def.id] = state;
}
// Gated assembler recipes start locked; unlocked_at_start recipes are handled
// in the REQ-LOCK-IMPLICIT traversal, not tracked here.
m_unlockedRecipeSchematicIds.clear();
recomputeUnlocked();
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tick // tick
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -199,15 +158,15 @@ void Simulation::apply(const Command& command)
const BuildingId id = *placed; const BuildingId id = *placed;
if (c.recipeId.has_value()) if (c.recipeId.has_value())
{ {
m_buildingSystem->setRecipe(id, *c.recipeId); m_buildingSystem->setRecipe(m_factoryState, id, *c.recipeId);
} }
if (c.shipLayout.has_value()) if (c.shipLayout.has_value())
{ {
m_buildingSystem->setShipLayout(id, *c.shipLayout); m_buildingSystem->setShipLayout(m_factoryState, id, *c.shipLayout);
} }
if (c.hasSplitterFilters) if (c.hasSplitterFilters)
{ {
m_buildingSystem->setSiteSplitterFilters(id, c.splitterFilterA, c.splitterFilterB); m_buildingSystem->setSiteSplitterFilters(m_factoryState, id, c.splitterFilterA, c.splitterFilterB);
} }
break; break;
} }
@@ -220,26 +179,26 @@ void Simulation::apply(const Command& command)
case CommandKind::RotateInPlace: case CommandKind::RotateInPlace:
{ {
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command); 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; break;
} }
case CommandKind::SetRecipe: case CommandKind::SetRecipe:
{ {
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command); 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; break;
} }
case CommandKind::SetShipLayout: case CommandKind::SetShipLayout:
{ {
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command); 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; break;
} }
case CommandKind::SetSiteSplitterFilters: case CommandKind::SetSiteSplitterFilters:
{ {
const SetSiteSplitterFiltersCommand& c = const SetSiteSplitterFiltersCommand& c =
static_cast<const SetSiteSplitterFiltersCommand&>(command); 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; break;
} }
case CommandKind::SetSplitterFilters: case CommandKind::SetSplitterFilters:
@@ -289,12 +248,12 @@ void Simulation::tick()
m_waveSystem->tickThreatAccumulation(); m_waveSystem->tickThreatAccumulation();
// Construction + production pipeline // Construction + production pipeline
m_buildingSystem->tickConstruction(m_currentTick); m_constructionSystem->tick(m_factoryState, m_beltSystem, m_currentTick);
m_buildingSystem->tickDeconstruction(m_currentTick); // parallel to construction m_deconstructionSystem->tick(m_factoryState, m_currentTick); // parallel to construction
m_buildingSystem->tickBeltPull(); // step 3 m_buildingSystem->tickBeltPull(m_factoryState); // step 3
m_buildingSystem->tickProduction(m_currentTick); // step 4 m_buildingSystem->tickProduction(m_factoryState, m_currentTick); // step 4
m_buildingSystem->tickShipyardProduction(m_currentTick); // step 4b m_buildingSystem->tickShipyardProduction(m_factoryState, m_currentTick); // step 4b
m_buildingSystem->tickOutputBelts(); // step 5 m_buildingSystem->tickOutputBelts(m_factoryState); // step 5
m_beltSystem.tick(); // step 6 m_beltSystem.tick(); // step 6
// Step 7: ship behavior systems (movement arbitration via intent priority) // Step 7: ship behavior systems (movement arbitration via intent priority)
@@ -309,15 +268,14 @@ void Simulation::tick()
m_shipSystem->clearMovementIntents(); m_shipSystem->clearMovementIntents();
// Score-based behavior selection: evaluate, select winner, execute (sets // Score-based behavior selection: evaluate, select winner, execute (sets
// movement intent + preferred module targets only — no world mutation). // movement intent + preferred module targets only — no world mutation).
m_aiSystem->tick(m_admin, *m_buildingSystem, *m_debrisSystem); m_aiSystem->tick(m_admin, m_factoryState);
// Module systems perform the world mutation (collection/delivery, healing). // Module systems perform the world mutation (collection/delivery, healing).
// Each emits its tool beams and applies its own delayed (mid-beam) effects. // Each emits its tool beams and applies its own delayed (mid-beam) effects.
m_salvagerSystem->tick(m_currentTick, *m_debrisSystem, *m_buildingSystem, m_beamFiredEvents); m_salvagerSystem->tick(m_currentTick, m_factoryState, m_beamFiredEvents);
m_repairSystem->tick(m_currentTick, m_beamFiredEvents); m_repairSystem->tick(m_currentTick, m_beamFiredEvents);
// Step 8: combat resolution // Step 8: combat resolution
m_combatSystem->tick(m_currentTick, m_admin, m_combatSystem->tick(m_currentTick, m_admin, m_beamFiredEvents);
*m_buildingSystem, m_beamFiredEvents);
// Step 8b: deferred damage whose impact tick has arrived // Step 8b: deferred damage whose impact tick has arrived
m_combatSystem->applyPendingDamage(m_currentTick, m_admin); m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
@@ -353,8 +311,7 @@ void Simulation::placeInitialStructures()
(m_config.world.heightTiles - hqParsed.footprint.height()) / 2; (m_config.world.heightTiles - hqParsed.footprint.height()) / 2;
const float hqHp = const float hqHp =
static_cast<float>(m_config.stations.hq.hpFormula.evaluate(0.0)); static_cast<float>(m_config.stations.hq.hpFormula.evaluate(0.0));
m_hqBuildingId = m_buildingSystem->placeImmediate( m_hqBuildingId = m_buildingSystem->placeImmediate(m_factoryState, BuildingType::Hq,
BuildingType::Hq,
m_config.stations.hq.surfaceMask, m_config.stations.hq.surfaceMask,
QPoint(hqAnchorX, hqAnchorY), QPoint(hqAnchorX, hqAnchorY),
Rotation::East); Rotation::East);
@@ -403,7 +360,7 @@ void Simulation::placeInitialStructures()
m_admin.addComponent<ModuleOwnerComponent>(wChild, m_admin.addComponent<ModuleOwnerComponent>(wChild,
ModuleOwnerComponent{m_playerStation1Entity}); ModuleOwnerComponent{m_playerStation1Entity});
} }
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId()); m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
} }
{ {
const QPoint anchor(psAnchorX, ps2Y); const QPoint anchor(psAnchorX, ps2Y);
@@ -420,7 +377,7 @@ void Simulation::placeInitialStructures()
m_admin.addComponent<ModuleOwnerComponent>(wChild, m_admin.addComponent<ModuleOwnerComponent>(wChild,
ModuleOwnerComponent{m_playerStation2Entity}); 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. // Rally point: center of the player defence stations' X column, world vertical midpoint.
@@ -475,7 +432,7 @@ void Simulation::placeEnemyStationSet(int generation)
m_admin.addComponent<ModuleOwnerComponent>(wChild, m_admin.addComponent<ModuleOwnerComponent>(wChild,
ModuleOwnerComponent{m_currentEnemyStationEntities[0]}); ModuleOwnerComponent{m_currentEnemyStationEntities[0]});
} }
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId()); m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
} }
{ {
const QPoint anchor(anchorX, y2); const QPoint anchor(anchorX, y2);
@@ -492,7 +449,7 @@ void Simulation::placeEnemyStationSet(int generation)
m_admin.addComponent<ModuleOwnerComponent>(wChild, m_admin.addComponent<ModuleOwnerComponent>(wChild,
ModuleOwnerComponent{m_currentEnemyStationEntities[1]}); ModuleOwnerComponent{m_currentEnemyStationEntities[1]});
} }
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId()); m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
} }
} }
@@ -566,7 +523,7 @@ void Simulation::tickDeathsAndLoot()
{ {
m_debrisSystem->spawn(pos.value, scrap, despawnAt); m_debrisSystem->spawn(pos.value, scrap, despawnAt);
} }
m_buildingSystem->unregisterTileOccupancy(sb.bodyCells); m_buildingSystem->unregisterTileOccupancy(m_factoryState, sb.bodyCells);
{ {
std::vector<entt::entity> stationChildren; std::vector<entt::entity> stationChildren;
m_admin.forEach<ModuleOwnerComponent>( m_admin.forEach<ModuleOwnerComponent>(
@@ -613,9 +570,9 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
std::vector<const UnlockGroupDef*> pool; std::vector<const UnlockGroupDef*> pool;
for (const UnlockGroupDef& group : m_config.unlocks.groups) for (const UnlockGroupDef& group : m_config.unlocks.groups)
{ {
if (m_awardedUnlockGroupIds.count(group.id) > 0) { continue; } if (m_unlockState.isUnlockGroupAwarded(group.id)) { continue; }
if (group.stationLevel < 0 || group.stationLevel > destroyedStationLevel) { continue; } if (group.stationLevel < 0 || group.stationLevel > destroyedStationLevel) { continue; }
if (!prerequisitesSatisfied(group.requiredGroupIds)) { continue; } if (!m_unlockState.prerequisitesSatisfied(group.requiredGroupIds)) { continue; }
pool.push_back(&group); pool.push_back(&group);
} }
@@ -639,7 +596,7 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
const std::size_t endIdx = pool.size() - 1 - static_cast<std::size_t>(i); const std::size_t endIdx = pool.size() - 1 - static_cast<std::size_t>(i);
std::swap(pool[rollIdx], pool[endIdx]); std::swap(pool[rollIdx], pool[endIdx]);
m_pendingSchematicChoices.push_back(makeUnlockOption(*pool[endIdx])); m_pendingSchematicChoices.push_back(m_unlockState.makeUnlockOption(*pool[endIdx]));
} }
if (artifactRolled) if (artifactRolled)
@@ -651,48 +608,6 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
} }
} }
SchematicChoiceOption Simulation::makeUnlockOption(const UnlockGroupDef& group) const
{
SchematicChoiceOption option;
option.isArtifact = false;
option.unlockGroupId = group.id;
option.displayName = toDisplayName(group.id);
for (const std::string& id : group.ships)
{
option.grantedItems.push_back({SchematicType::Ship, id, toDisplayName(id)});
}
for (const std::string& id : group.modules)
{
option.grantedItems.push_back({SchematicType::Module, id, toDisplayName(id)});
}
for (const std::string& id : group.buildings)
{
option.grantedItems.push_back({SchematicType::Building, id, toDisplayName(id)});
}
for (const std::string& id : group.recipes)
{
option.grantedItems.push_back({SchematicType::Recipe, id, toDisplayName(id)});
}
// REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by
// awarding this whole group. Seed the hypothetical explicit-unlock sets with
// every grant (ship + module materials via step 1a, recipe outputs via step
// 1b), then diff against the current implicit set.
std::set<std::string> hypotheticalShipIds = getUnlockedShipSchematicIds();
std::set<std::string> hypotheticalModuleIds = getUnlockedModuleSchematicIds();
std::set<std::string> hypotheticalRecipeSchematicIds = m_unlockedRecipeSchematicIds;
for (const std::string& id : group.ships) { hypotheticalShipIds.insert(id); }
for (const std::string& id : group.modules) { hypotheticalModuleIds.insert(id); }
for (const std::string& id : group.recipes) { hypotheticalRecipeSchematicIds.insert(id); }
const UnlockedSets hypothetical = computeUnlockedSets(
hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds);
option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical);
return option;
}
void Simulation::applySchematicChoice(int choiceIndex) void Simulation::applySchematicChoice(int choiceIndex)
{ {
assert(choiceIndex >= 0 && choiceIndex < static_cast<int>(m_pendingSchematicChoices.size())); assert(choiceIndex >= 0 && choiceIndex < static_cast<int>(m_pendingSchematicChoices.size()));
@@ -709,204 +624,24 @@ void Simulation::applySchematicChoice(int choiceIndex)
return; return;
} }
// Award the whole unlock group (REQ-DEF-SCHEMATIC-DROP): unlock every granted m_unlockState.awardUnlockGroup(chosen);
// ship, module, building, and assembler recipe at once.
m_awardedUnlockGroupIds.insert(chosen.unlockGroupId);
for (const GrantedSchematic& grant : chosen.grantedItems)
{
switch (grant.type)
{
case SchematicType::Ship: m_schematicLevels.at(grant.id).unlocked = true; break;
case SchematicType::Module: m_moduleSchematicLevels.at(grant.id).unlocked = true; break;
case SchematicType::Building: m_buildingLevels.at(grant.id).unlocked = true; break;
case SchematicType::Recipe: m_unlockedRecipeSchematicIds.insert(grant.id); break;
}
}
recomputeUnlocked();
m_pendingSchematicChoices.clear(); m_pendingSchematicChoices.clear();
} }
// ---------------------------------------------------------------------------
// Implicit unlock computation (REQ-LOCK-IMPLICIT)
// ---------------------------------------------------------------------------
void Simulation::recomputeUnlocked()
{
const UnlockedSets result = computeUnlockedSets(
getUnlockedShipSchematicIds(), getUnlockedModuleSchematicIds(), m_unlockedRecipeSchematicIds);
m_unlockedItemIds = result.itemIds;
m_unlockedRecipeIds = result.recipeIds;
}
std::set<std::string> Simulation::getUnlockedShipSchematicIds() const
{
std::set<std::string> ids;
for (const auto& [id, state] : m_schematicLevels)
{
if (state.unlocked) { ids.insert(id); }
}
return ids;
}
std::set<std::string> Simulation::getUnlockedModuleSchematicIds() const
{
std::set<std::string> ids;
for (const auto& [id, state] : m_moduleSchematicLevels)
{
if (state.unlocked) { ids.insert(id); }
}
return ids;
}
bool Simulation::prerequisitesSatisfied(const std::vector<std::string>& requiredGroupIds) const
{
// A prerequisite is satisfied only once the named unlock group has been
// awarded (REQ-LOCK-PREREQ).
for (const std::string& groupId : requiredGroupIds)
{
if (m_awardedUnlockGroupIds.count(groupId) == 0) { return false; }
}
return true;
}
Simulation::UnlockedSets Simulation::computeUnlockedSets(
const std::set<std::string>& unlockedShipSchematicIds,
const std::set<std::string>& unlockedModuleSchematicIds,
const std::set<std::string>& unlockedRecipeSchematicIds) const
{
UnlockedSets result;
for (const ShipDef& def : m_config.ships.ships)
{
if (unlockedShipSchematicIds.count(def.id) == 0) { continue; }
for (const RecipeIngredient& mat : def.schematic.materials)
{
result.itemIds.insert(mat.item);
}
}
for (const ModuleDef& def : m_config.modules.modules)
{
if (unlockedModuleSchematicIds.count(def.id) == 0) { continue; }
for (const RecipeIngredient& mat : def.materials)
{
result.itemIds.insert(mat.item);
}
}
for (const RecipeDef& def : m_config.recipes.recipes)
{
// An assembler recipe seeds the base set when it is explicitly available:
// flagged unlocked_at_start (base recipes the graph can't reach), or a
// gated recipe whose unlock group has been awarded (REQ-LOCK-EXPLICIT).
if (def.building == BuildingType::Assembler
&& (def.unlockedAtStart || unlockedRecipeSchematicIds.count(def.id) > 0))
{
for (const RecipeOutput& out : def.outputs)
{
result.itemIds.insert(out.item);
}
}
}
bool changed = true;
while (changed)
{
changed = false;
for (const RecipeDef& recipe : m_config.recipes.recipes)
{
if (recipe.building != BuildingType::Miner
&& recipe.building != BuildingType::Smelter
&& recipe.building != BuildingType::Assembler)
{
continue;
}
// Skip a gated assembler recipe (granted by an unlock group) whose
// group has not yet been awarded (REQ-LOCK-IMPLICIT step 2).
if (recipe.building == BuildingType::Assembler
&& m_grantedRecipeIds.count(recipe.id) > 0
&& unlockedRecipeSchematicIds.count(recipe.id) == 0)
{
continue;
}
bool producesUnlocked = false;
for (const RecipeOutput& out : recipe.outputs)
{
if (result.itemIds.count(out.item) > 0)
{
producesUnlocked = true;
break;
}
}
if (!producesUnlocked) { continue; }
if (recipe.building == BuildingType::Miner
|| recipe.building == BuildingType::Assembler)
{
result.recipeIds.insert(recipe.id);
}
for (const RecipeIngredient& ing : recipe.inputs)
{
if (result.itemIds.insert(ing.item).second)
{
changed = true;
}
}
}
}
return result;
}
std::vector<std::string> Simulation::computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const
{
std::vector<std::string> recipeIds;
for (const std::string& recipeId : hypothetical.recipeIds)
{
if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; }
recipeIds.push_back(recipeId);
}
std::sort(recipeIds.begin(), recipeIds.end(),
[](const std::string& lhs, const std::string& rhs)
{
return toDisplayName(lhs) < toDisplayName(rhs);
});
return recipeIds;
}
bool Simulation::isRecipeUnlocked(const std::string& recipeId) const bool Simulation::isRecipeUnlocked(const std::string& recipeId) const
{ {
return m_unlockedRecipeIds.count(recipeId) > 0; return m_unlockState.isRecipeUnlocked(recipeId);
} }
bool Simulation::isItemUnlocked(const std::string& itemId) const bool Simulation::isItemUnlocked(const std::string& itemId) const
{ {
return m_unlockedItemIds.count(itemId) > 0; return m_unlockState.isItemUnlocked(itemId);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Determinism (see docs/replay_design.md) // Determinism (see docs/replay_design.md)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void Simulation::appendSchematicMap(Hasher& hasher,
const std::map<std::string, SchematicState>& levels)
{
hasher.append(levels.size());
for (const std::pair<const std::string, SchematicState>& entry : levels)
{
hasher.append(entry.first);
hasher.append(entry.second.unlocked);
}
}
void Simulation::appendStringSet(Hasher& hasher, const std::set<std::string>& ids)
{
hasher.append(ids.size());
for (const std::string& id : ids)
{
hasher.append(id);
}
}
unsigned long long Simulation::getRngFingerprint() const unsigned long long Simulation::getRngFingerprint() const
{ {
return fingerprintRng(m_rng); return fingerprintRng(m_rng);
@@ -937,16 +672,10 @@ unsigned long long Simulation::computeStateChecksum() const
hasher.append(getNormalGapRemainingTicks()); hasher.append(getNormalGapRemainingTicks());
// Schematic / unlock state (std::map and std::set iterate in sorted order). // Schematic / unlock state (std::map and std::set iterate in sorted order).
appendSchematicMap(hasher, m_schematicLevels); m_unlockState.appendChecksum(hasher);
appendSchematicMap(hasher, m_moduleSchematicLevels);
appendSchematicMap(hasher, m_buildingLevels);
appendStringSet(hasher, m_awardedUnlockGroupIds);
appendStringSet(hasher, m_unlockedRecipeSchematicIds);
appendStringSet(hasher, m_unlockedRecipeIds);
appendStringSet(hasher, m_unlockedItemIds);
// Subsystems contribute their own state. // Subsystems contribute their own state.
m_buildingSystem->appendChecksum(hasher); m_buildingSystem->appendChecksum(m_factoryState, hasher);
m_beltSystem.appendChecksum(hasher); m_beltSystem.appendChecksum(hasher);
// ECS component state. View iteration order is a pure function of the // ECS component state. View iteration order is a pure function of the
@@ -1059,7 +788,7 @@ void Simulation::tryExpandAsteroid()
} }
m_buildingBlocksStock -= cost; m_buildingBlocksStock -= cost;
++m_expansionsPurchased; ++m_expansionsPurchased;
m_buildingSystem->setAsteroidWidth_tiles(getCurrentAsteroidWidth_tiles()); m_buildingSystem->setAsteroidWidth_tiles(m_factoryState, getCurrentAsteroidWidth_tiles());
} }
bool Simulation::isGameOver() const bool Simulation::isGameOver() const
@@ -1089,12 +818,12 @@ double Simulation::getThreatAccumulationRate() const
double Simulation::getMaxFactoryProductionThreatRate() const double Simulation::getMaxFactoryProductionThreatRate() const
{ {
return static_cast<double>(m_buildingSystem->getProductionBuildingCount()); return static_cast<double>(getProductionBuildingCount(m_factoryState));
} }
double Simulation::getCurrentFactoryProductionThreatRate() const double Simulation::getCurrentFactoryProductionThreatRate() const
{ {
return static_cast<double>(m_buildingSystem->getActiveProductionBuildingCount()); return static_cast<double>(getActiveProductionBuildingCount(m_factoryState));
} }
int Simulation::getBossWaveCounter() const int Simulation::getBossWaveCounter() const
@@ -1114,37 +843,17 @@ Tick Simulation::getNormalGapRemainingTicks() const
bool Simulation::isSchematicUnlocked(const std::string& shipId) const bool Simulation::isSchematicUnlocked(const std::string& shipId) const
{ {
const std::map<std::string, SchematicState>::const_iterator it = return m_unlockState.isSchematicUnlocked(shipId);
m_schematicLevels.find(shipId);
if (it == m_schematicLevels.end())
{
return false;
}
return it->second.unlocked;
} }
bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const
{ {
const std::map<std::string, SchematicState>::const_iterator it = return m_unlockState.isModuleSchematicUnlocked(moduleId);
m_moduleSchematicLevels.find(moduleId);
if (it == m_moduleSchematicLevels.end())
{
return false;
}
return it->second.unlocked;
} }
bool Simulation::isBuildingUnlocked(BuildingType type) const bool Simulation::isBuildingUnlocked(BuildingType type) const
{ {
const BuildingDef* def = m_config.buildings.findBuildingDef(type); return m_unlockState.isBuildingUnlocked(type);
if (def == nullptr)
{
// Types without a config entry (e.g. HQ, defence stations) are unrestricted.
return true;
}
const std::map<std::string, SchematicState>::const_iterator it =
m_buildingLevels.find(def->id);
return it == m_buildingLevels.end() ? true : it->second.unlocked;
} }
std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation) std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
@@ -1156,7 +865,7 @@ std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint
return std::nullopt; return std::nullopt;
} }
if (!m_buildingSystem->isPlacementValid(type, anchor, rotation)) if (!isPlacementValid(m_factoryState, m_config, type, anchor, rotation))
{ {
return std::nullopt; return std::nullopt;
} }
@@ -1175,17 +884,17 @@ std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint
return std::nullopt; return std::nullopt;
} }
m_buildingBlocksStock -= cost; 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) 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) void Simulation::cancelDeconstruction(BuildingId id)
{ {
m_buildingSystem->cancelDeconstruction(id); m_buildingSystem->cancelDeconstruction(m_factoryState, id);
} }
BuildingSystem& Simulation::getBuildingsMutable() BuildingSystem& Simulation::getBuildingsMutable()
@@ -1193,6 +902,11 @@ BuildingSystem& Simulation::getBuildingsMutable()
return *m_buildingSystem; return *m_buildingSystem;
} }
const FactoryState& Simulation::getFactoryState() const
{
return m_factoryState;
}
const BuildingSystem& Simulation::getBuildings() const const BuildingSystem& Simulation::getBuildings() const
{ {
return *m_buildingSystem; return *m_buildingSystem;

View File

@@ -1,16 +1,15 @@
#pragma once #pragma once
#include <map>
#include <memory> #include <memory>
#include <optional> #include <optional>
#include <random> #include <random>
#include <set>
#include <string> #include <string>
#include <vector> #include <vector>
#include <QPoint> #include <QPoint>
#include "BeltSystem.h" #include "BeltSystem.h"
#include "FactoryState.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
#include "SchematicChoiceOption.h" #include "SchematicChoiceOption.h"
@@ -22,9 +21,12 @@
#include "Rotation.h" #include "Rotation.h"
#include "Tick.h" #include "Tick.h"
#include "TracePrintRequestedEvent.h" #include "TracePrintRequestedEvent.h"
#include "UnlockState.h"
class AiSystem; class AiSystem;
class BuildingSystem; class BuildingSystem;
class ConstructionSystem;
class DeconstructionSystem;
struct Command; struct Command;
class Hasher; class Hasher;
class CombatSystem; class CombatSystem;
@@ -119,6 +121,9 @@ public:
// chokepoint) or, in tests, SimulationTestAccess — so production code cannot // chokepoint) or, in tests, SimulationTestAccess — so production code cannot
// mutate the factory outside the recorded command path (docs/replay_design.md). // mutate the factory outside the recorded command path (docs/replay_design.md).
const BuildingSystem& getBuildings() const; const BuildingSystem& getBuildings() const;
// The factory's world data, for the free queries in FactoryQueries.h.
const FactoryState& getFactoryState() const;
const BeltSystem& getBelts() const; const BeltSystem& getBelts() const;
ShipSystem& getShips(); ShipSystem& getShips();
const ShipSystem& getShips() const; const ShipSystem& getShips() const;
@@ -203,73 +208,20 @@ private:
entt::entity m_playerStation2Entity; entt::entity m_playerStation2Entity;
entt::entity m_currentEnemyStationEntities[2]; entt::entity m_currentEnemyStationEntities[2];
// Schematic unlock state (REQ-DEF-SCHEMATIC-DROP). // Schematic/unlock bookkeeping (REQ-DEF-SCHEMATIC-DROP, REQ-LOCK-EXPLICIT,
struct SchematicState // REQ-LOCK-IMPLICIT, REQ-LOCK-BUILDING, REQ-LOCK-PREREQ). Constructed before
{ // initializeSubsystems() runs since BuildingSystem's spawn-gating lambda
bool unlocked; // calls into it (see initializeSubsystems()).
}; UnlockState m_unlockState;
std::map<std::string, SchematicState> m_schematicLevels;
std::map<std::string, SchematicState> m_moduleSchematicLevels;
std::map<std::string, SchematicState> m_buildingLevels;
// Unlock groups awarded so far (REQ-LOCK-EXPLICIT). Group ids.
std::set<std::string> m_awardedUnlockGroupIds;
// Ids granted by some unlock group, per kind — cached from config at init.
// An item starts locked iff it appears in the corresponding set.
std::set<std::string> m_grantedShipIds;
std::set<std::string> m_grantedModuleIds;
std::set<std::string> m_grantedBuildingIds;
std::set<std::string> m_grantedRecipeIds;
// Builds the granted-id sets and initializes all per-item unlock maps from
// them (shared by the constructor and reset). Ends with recomputeUnlocked().
void initializeUnlockState();
// Builds a schematic choice option for one unlock group (REQ-DEF-SCHEMATIC-DROP).
SchematicChoiceOption makeUnlockOption(const UnlockGroupDef& group) const;
// Determinism helpers — fold sub-state into the hasher in deterministic order.
static void appendSchematicMap(Hasher& hasher,
const std::map<std::string, SchematicState>& levels);
static void appendStringSet(Hasher& hasher, const std::set<std::string>& ids);
// Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT).
std::set<std::string> m_unlockedRecipeSchematicIds;
// Implicit unlock sets derived from schematic state (REQ-LOCK-IMPLICIT).
std::set<std::string> m_unlockedRecipeIds;
std::set<std::string> m_unlockedItemIds;
// Recomputes m_unlockedRecipeIds and m_unlockedItemIds from current schematic state.
void recomputeUnlocked();
// Result of the REQ-LOCK-IMPLICIT traversal.
struct UnlockedSets
{
std::set<std::string> itemIds;
std::set<std::string> recipeIds;
};
// Pure REQ-LOCK-IMPLICIT traversal given hypothetical explicit-unlock sets.
UnlockedSets computeUnlockedSets(const std::set<std::string>& unlockedShipSchematicIds,
const std::set<std::string>& unlockedModuleSchematicIds,
const std::set<std::string>& unlockedRecipeSchematicIds) const;
// Current explicit-unlock id sets, derived from m_schematicLevels / m_moduleSchematicLevels.
std::set<std::string> getUnlockedShipSchematicIds() const;
std::set<std::string> getUnlockedModuleSchematicIds() const;
// True if every prerequisite unlock group has been awarded (REQ-LOCK-PREREQ).
bool prerequisitesSatisfied(const std::vector<std::string>& requiredGroupIds) const;
// Ids (sorted alphabetically by display name) of the recipes in
// hypothetical.recipeIds that are not yet in m_unlockedRecipeIds.
std::vector<std::string> computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const;
EntityAdmin m_admin; EntityAdmin m_admin;
// The factory's world data. Owned here, not by BuildingSystem, so the systems
// that operate on it can be handed the same state (see FactoryState.h).
FactoryState m_factoryState;
BeltSystem m_beltSystem; BeltSystem m_beltSystem;
std::unique_ptr<BuildingSystem> m_buildingSystem; 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<ShipSystem> m_shipSystem;
std::unique_ptr<AiSystem> m_aiSystem; std::unique_ptr<AiSystem> m_aiSystem;
std::unique_ptr<MovementIntentSystem> m_movementIntentSystem; std::unique_ptr<MovementIntentSystem> m_movementIntentSystem;

352
src/lib/sim/UnlockState.cpp Normal file
View File

@@ -0,0 +1,352 @@
#include "UnlockState.h"
#include <algorithm>
#include "DisplayName.h"
#include "StateChecksum.h"
UnlockState::UnlockState(const GameConfig& config)
: m_config(config)
{
}
void UnlockState::initializeUnlockState()
{
// Cache the ids granted by some unlock group (REQ-LOCK-EXPLICIT); an item
// starts locked iff it is granted by a group.
m_grantedShipIds.clear();
m_grantedModuleIds.clear();
m_grantedBuildingIds.clear();
m_grantedRecipeIds.clear();
for (const UnlockGroupDef& group : m_config.unlocks.groups)
{
m_grantedShipIds.insert(group.ships.begin(), group.ships.end());
m_grantedModuleIds.insert(group.modules.begin(), group.modules.end());
m_grantedBuildingIds.insert(group.buildings.begin(), group.buildings.end());
m_grantedRecipeIds.insert(group.recipes.begin(), group.recipes.end());
}
m_awardedUnlockGroupIds.clear();
m_schematicLevels.clear();
for (const ShipDef& def : m_config.ships.ships)
{
SchematicState state;
state.unlocked = (m_grantedShipIds.count(def.id) == 0);
m_schematicLevels[def.id] = state;
}
m_moduleSchematicLevels.clear();
for (const ModuleDef& def : m_config.modules.modules)
{
SchematicState state;
state.unlocked = (m_grantedModuleIds.count(def.id) == 0);
m_moduleSchematicLevels[def.id] = state;
}
m_buildingLevels.clear();
for (const BuildingDef& def : m_config.buildings.buildings)
{
SchematicState state;
state.unlocked = (m_grantedBuildingIds.count(def.id) == 0);
m_buildingLevels[def.id] = state;
}
// Gated assembler recipes start locked; unlocked_at_start recipes are handled
// in the REQ-LOCK-IMPLICIT traversal, not tracked here.
m_unlockedRecipeSchematicIds.clear();
recomputeUnlocked();
}
bool UnlockState::isSchematicUnlocked(const std::string& shipId) const
{
const std::map<std::string, SchematicState>::const_iterator it =
m_schematicLevels.find(shipId);
if (it == m_schematicLevels.end())
{
return false;
}
return it->second.unlocked;
}
bool UnlockState::isModuleSchematicUnlocked(const std::string& moduleId) const
{
const std::map<std::string, SchematicState>::const_iterator it =
m_moduleSchematicLevels.find(moduleId);
if (it == m_moduleSchematicLevels.end())
{
return false;
}
return it->second.unlocked;
}
bool UnlockState::isRecipeUnlocked(const std::string& recipeId) const
{
return m_unlockedRecipeIds.count(recipeId) > 0;
}
bool UnlockState::isItemUnlocked(const std::string& itemId) const
{
return m_unlockedItemIds.count(itemId) > 0;
}
bool UnlockState::isBuildingUnlocked(BuildingType type) const
{
const BuildingDef* def = m_config.buildings.findBuildingDef(type);
if (def == nullptr)
{
// Types without a config entry (e.g. HQ, defence stations) are unrestricted.
return true;
}
const std::map<std::string, SchematicState>::const_iterator it =
m_buildingLevels.find(def->id);
return it == m_buildingLevels.end() ? true : it->second.unlocked;
}
bool UnlockState::isUnlockGroupAwarded(const std::string& groupId) const
{
return m_awardedUnlockGroupIds.count(groupId) > 0;
}
bool UnlockState::prerequisitesSatisfied(const std::vector<std::string>& requiredGroupIds) const
{
// A prerequisite is satisfied only once the named unlock group has been
// awarded (REQ-LOCK-PREREQ).
for (const std::string& groupId : requiredGroupIds)
{
if (m_awardedUnlockGroupIds.count(groupId) == 0) { return false; }
}
return true;
}
SchematicChoiceOption UnlockState::makeUnlockOption(const UnlockGroupDef& group) const
{
SchematicChoiceOption option;
option.isArtifact = false;
option.unlockGroupId = group.id;
option.displayName = toDisplayName(group.id);
for (const std::string& id : group.ships)
{
option.grantedItems.push_back({SchematicType::Ship, id, toDisplayName(id)});
}
for (const std::string& id : group.modules)
{
option.grantedItems.push_back({SchematicType::Module, id, toDisplayName(id)});
}
for (const std::string& id : group.buildings)
{
option.grantedItems.push_back({SchematicType::Building, id, toDisplayName(id)});
}
for (const std::string& id : group.recipes)
{
option.grantedItems.push_back({SchematicType::Recipe, id, toDisplayName(id)});
}
// REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by
// awarding this whole group. Seed the hypothetical explicit-unlock sets with
// every grant (ship + module materials via step 1a, recipe outputs via step
// 1b), then diff against the current implicit set.
std::set<std::string> hypotheticalShipIds = getUnlockedShipSchematicIds();
std::set<std::string> hypotheticalModuleIds = getUnlockedModuleSchematicIds();
std::set<std::string> hypotheticalRecipeSchematicIds = m_unlockedRecipeSchematicIds;
for (const std::string& id : group.ships) { hypotheticalShipIds.insert(id); }
for (const std::string& id : group.modules) { hypotheticalModuleIds.insert(id); }
for (const std::string& id : group.recipes) { hypotheticalRecipeSchematicIds.insert(id); }
const UnlockedSets hypothetical = computeUnlockedSets(
hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds);
option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical);
return option;
}
void UnlockState::awardUnlockGroup(const SchematicChoiceOption& chosen)
{
// Award the whole unlock group (REQ-DEF-SCHEMATIC-DROP): unlock every granted
// ship, module, building, and assembler recipe at once.
m_awardedUnlockGroupIds.insert(chosen.unlockGroupId);
for (const GrantedSchematic& grant : chosen.grantedItems)
{
switch (grant.type)
{
case SchematicType::Ship: m_schematicLevels.at(grant.id).unlocked = true; break;
case SchematicType::Module: m_moduleSchematicLevels.at(grant.id).unlocked = true; break;
case SchematicType::Building: m_buildingLevels.at(grant.id).unlocked = true; break;
case SchematicType::Recipe: m_unlockedRecipeSchematicIds.insert(grant.id); break;
}
}
recomputeUnlocked();
}
// ---------------------------------------------------------------------------
// Implicit unlock computation (REQ-LOCK-IMPLICIT)
// ---------------------------------------------------------------------------
void UnlockState::recomputeUnlocked()
{
const UnlockedSets result = computeUnlockedSets(
getUnlockedShipSchematicIds(), getUnlockedModuleSchematicIds(), m_unlockedRecipeSchematicIds);
m_unlockedItemIds = result.itemIds;
m_unlockedRecipeIds = result.recipeIds;
}
std::set<std::string> UnlockState::getUnlockedShipSchematicIds() const
{
std::set<std::string> ids;
for (const auto& [id, state] : m_schematicLevels)
{
if (state.unlocked) { ids.insert(id); }
}
return ids;
}
std::set<std::string> UnlockState::getUnlockedModuleSchematicIds() const
{
std::set<std::string> ids;
for (const auto& [id, state] : m_moduleSchematicLevels)
{
if (state.unlocked) { ids.insert(id); }
}
return ids;
}
UnlockState::UnlockedSets UnlockState::computeUnlockedSets(
const std::set<std::string>& unlockedShipSchematicIds,
const std::set<std::string>& unlockedModuleSchematicIds,
const std::set<std::string>& unlockedRecipeSchematicIds) const
{
UnlockedSets result;
for (const ShipDef& def : m_config.ships.ships)
{
if (unlockedShipSchematicIds.count(def.id) == 0) { continue; }
for (const RecipeIngredient& mat : def.schematic.materials)
{
result.itemIds.insert(mat.item);
}
}
for (const ModuleDef& def : m_config.modules.modules)
{
if (unlockedModuleSchematicIds.count(def.id) == 0) { continue; }
for (const RecipeIngredient& mat : def.materials)
{
result.itemIds.insert(mat.item);
}
}
for (const RecipeDef& def : m_config.recipes.recipes)
{
// An assembler recipe seeds the base set when it is explicitly available:
// flagged unlocked_at_start (base recipes the graph can't reach), or a
// gated recipe whose unlock group has been awarded (REQ-LOCK-EXPLICIT).
if (def.building == BuildingType::Assembler
&& (def.unlockedAtStart || unlockedRecipeSchematicIds.count(def.id) > 0))
{
for (const RecipeOutput& out : def.outputs)
{
result.itemIds.insert(out.item);
}
}
}
bool changed = true;
while (changed)
{
changed = false;
for (const RecipeDef& recipe : m_config.recipes.recipes)
{
if (recipe.building != BuildingType::Miner
&& recipe.building != BuildingType::Smelter
&& recipe.building != BuildingType::Assembler)
{
continue;
}
// Skip a gated assembler recipe (granted by an unlock group) whose
// group has not yet been awarded (REQ-LOCK-IMPLICIT step 2).
if (recipe.building == BuildingType::Assembler
&& m_grantedRecipeIds.count(recipe.id) > 0
&& unlockedRecipeSchematicIds.count(recipe.id) == 0)
{
continue;
}
bool producesUnlocked = false;
for (const RecipeOutput& out : recipe.outputs)
{
if (result.itemIds.count(out.item) > 0)
{
producesUnlocked = true;
break;
}
}
if (!producesUnlocked) { continue; }
if (recipe.building == BuildingType::Miner
|| recipe.building == BuildingType::Assembler)
{
result.recipeIds.insert(recipe.id);
}
for (const RecipeIngredient& ing : recipe.inputs)
{
if (result.itemIds.insert(ing.item).second)
{
changed = true;
}
}
}
}
return result;
}
std::vector<std::string> UnlockState::computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const
{
std::vector<std::string> recipeIds;
for (const std::string& recipeId : hypothetical.recipeIds)
{
if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; }
recipeIds.push_back(recipeId);
}
std::sort(recipeIds.begin(), recipeIds.end(),
[](const std::string& lhs, const std::string& rhs)
{
return toDisplayName(lhs) < toDisplayName(rhs);
});
return recipeIds;
}
// ---------------------------------------------------------------------------
// Determinism (see docs/replay_design.md)
// ---------------------------------------------------------------------------
void UnlockState::appendSchematicMap(Hasher& hasher,
const std::map<std::string, SchematicState>& levels)
{
hasher.append(levels.size());
for (const std::pair<const std::string, SchematicState>& entry : levels)
{
hasher.append(entry.first);
hasher.append(entry.second.unlocked);
}
}
void UnlockState::appendStringSet(Hasher& hasher, const std::set<std::string>& ids)
{
hasher.append(ids.size());
for (const std::string& id : ids)
{
hasher.append(id);
}
}
void UnlockState::appendChecksum(Hasher& hasher) const
{
appendSchematicMap(hasher, m_schematicLevels);
appendSchematicMap(hasher, m_moduleSchematicLevels);
appendSchematicMap(hasher, m_buildingLevels);
appendStringSet(hasher, m_awardedUnlockGroupIds);
appendStringSet(hasher, m_unlockedRecipeSchematicIds);
appendStringSet(hasher, m_unlockedRecipeIds);
appendStringSet(hasher, m_unlockedItemIds);
}

129
src/lib/sim/UnlockState.h Normal file
View File

@@ -0,0 +1,129 @@
#pragma once
#include <map>
#include <set>
#include <string>
#include <vector>
#include "BuildingType.h"
#include "GameConfig.h"
#include "SchematicChoiceOption.h"
class Hasher;
// Owns schematic/unlock bookkeeping for one run: which ship, module, and
// building schematics are unlocked (REQ-LOCK-EXPLICIT), which assembler recipe
// schematics have been explicitly granted, and the implicit recipe/item unlock
// sets derived from that state (REQ-LOCK-IMPLICIT). Reads config the same way
// BuildingSystem does (a bound const reference to Simulation::m_config, which is
// safe across restart because that member's storage address never changes —
// reset() move-assigns into it rather than replacing it).
//
// Simulation forwards its isXUnlocked-style public queries here and drives
// state changes (awarding an unlock group) here; the RNG-touching schematic
// choice generation itself stays in Simulation (call ordering of m_rng is the
// determinism backbone and must not move).
class UnlockState
{
public:
explicit UnlockState(const GameConfig& config);
// Builds the granted-id sets and initializes all per-item unlock maps from
// them (shared by the constructor and Simulation::reset). Ends with
// recomputeUnlocked().
void initializeUnlockState();
// Ship schematic state query.
bool isSchematicUnlocked(const std::string& shipId) const;
// Module schematic state query.
bool isModuleSchematicUnlocked(const std::string& moduleId) const;
// Implicit recipe/item unlock queries (REQ-LOCK-IMPLICIT).
bool isRecipeUnlocked(const std::string& recipeId) const;
bool isItemUnlocked(const std::string& itemId) const;
// Building unlock query (REQ-LOCK-BUILDING). True if the building type is not
// gated by any unlock group, or its granting group has been awarded.
bool isBuildingUnlocked(BuildingType type) const;
// True if the unlock group has already been awarded to the player.
bool isUnlockGroupAwarded(const std::string& groupId) const;
// True if every prerequisite unlock group has been awarded (REQ-LOCK-PREREQ).
bool prerequisitesSatisfied(const std::vector<std::string>& requiredGroupIds) const;
// Builds a schematic choice option for one unlock group (REQ-DEF-SCHEMATIC-DROP).
SchematicChoiceOption makeUnlockOption(const UnlockGroupDef& group) const;
// Awards the unlock group backing `chosen` (REQ-DEF-SCHEMATIC-DROP): marks
// every granted ship/module/building schematic unlocked, records granted
// recipe schematics, marks the group as awarded, and recomputes the implicit
// unlock sets. Mirrors the non-artifact branch of the original
// Simulation::applySchematicChoice exactly; callers still special-case
// chosen.isArtifact themselves before calling this.
void awardUnlockGroup(const SchematicChoiceOption& chosen);
// Determinism helper (see Simulation::computeStateChecksum): folds unlock
// state into the hasher via the same seven calls, in the same order, that
// used to live at the Simulation::computeStateChecksum call site.
void appendChecksum(Hasher& hasher) const;
private:
// Schematic unlock state (REQ-DEF-SCHEMATIC-DROP).
struct SchematicState
{
bool unlocked;
};
// Recomputes m_unlockedRecipeIds and m_unlockedItemIds from current schematic state.
void recomputeUnlocked();
// Result of the REQ-LOCK-IMPLICIT traversal.
struct UnlockedSets
{
std::set<std::string> itemIds;
std::set<std::string> recipeIds;
};
// Pure REQ-LOCK-IMPLICIT traversal given hypothetical explicit-unlock sets.
UnlockedSets computeUnlockedSets(const std::set<std::string>& unlockedShipSchematicIds,
const std::set<std::string>& unlockedModuleSchematicIds,
const std::set<std::string>& unlockedRecipeSchematicIds) const;
// Current explicit-unlock id sets, derived from m_schematicLevels / m_moduleSchematicLevels.
std::set<std::string> getUnlockedShipSchematicIds() const;
std::set<std::string> getUnlockedModuleSchematicIds() const;
// Ids (sorted alphabetically by display name) of the recipes in
// hypothetical.recipeIds that are not yet in m_unlockedRecipeIds.
std::vector<std::string> computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const;
// Determinism helpers — fold sub-state into the hasher in deterministic order.
static void appendSchematicMap(Hasher& hasher,
const std::map<std::string, SchematicState>& levels);
static void appendStringSet(Hasher& hasher, const std::set<std::string>& ids);
const GameConfig& m_config;
std::map<std::string, SchematicState> m_schematicLevels;
std::map<std::string, SchematicState> m_moduleSchematicLevels;
std::map<std::string, SchematicState> m_buildingLevels;
// Unlock groups awarded so far (REQ-LOCK-EXPLICIT). Group ids.
std::set<std::string> m_awardedUnlockGroupIds;
// Ids granted by some unlock group, per kind — cached from config at init.
// An item starts locked iff it appears in the corresponding set.
std::set<std::string> m_grantedShipIds;
std::set<std::string> m_grantedModuleIds;
std::set<std::string> m_grantedBuildingIds;
std::set<std::string> m_grantedRecipeIds;
// Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT).
std::set<std::string> m_unlockedRecipeSchematicIds;
// Implicit unlock sets derived from schematic state (REQ-LOCK-IMPLICIT).
std::set<std::string> m_unlockedRecipeIds;
std::set<std::string> m_unlockedItemIds;
};

View File

@@ -1,4 +1,5 @@
#include "catch.hpp" #include "catch.hpp"
#include "FactoryQueries.h"
#include <cmath> #include <cmath>
#include <random> #include <random>
@@ -14,6 +15,8 @@
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "ConstructionSystem.h"
#include "FactoryState.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "DeliverScrapBehavior.h" #include "DeliverScrapBehavior.h"
@@ -54,12 +57,14 @@
struct Fixture struct Fixture
{ {
GameConfig cfg; GameConfig cfg;
FactoryState state = makeFactoryState(cfg);
BeltSystem belts; BeltSystem belts;
BuildingId nextBuildingId; BuildingId nextBuildingId;
int stock; int stock;
std::mt19937 rng; std::mt19937 rng;
EntityAdmin admin; EntityAdmin admin;
BuildingSystem buildings; BuildingSystem buildings;
ConstructionSystem construction;
ShipSystem ships; ShipSystem ships;
AiSystem ai; AiSystem ai;
SalvagerSystem salvager; SalvagerSystem salvager;
@@ -82,6 +87,7 @@ struct Fixture
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; }, [](const std::string&) -> bool { return true; },
rng) rng)
, construction(cfg)
, ships(cfg, admin) , ships(cfg, admin)
, ai(cfg) , ai(cfg)
, salvager(admin) , salvager(admin)
@@ -95,14 +101,14 @@ struct Fixture
void decide() void decide()
{ {
ships.clearMovementIntents(); ships.clearMovementIntents();
ai.tick(admin, buildings, scraps); ai.tick(admin, state);
} }
// World mutation: collection/delivery and healing. // World mutation: collection/delivery and healing.
void runModules() void runModules()
{ {
beamEvents.clear(); beamEvents.clear();
salvager.tick(tick, scraps, buildings, beamEvents); salvager.tick(tick, state, beamEvents);
repair.tick(tick, beamEvents); repair.tick(tick, beamEvents);
} }
@@ -135,7 +141,7 @@ struct Fixture
void salvageTick() void salvageTick()
{ {
beamEvents.clear(); beamEvents.clear();
salvager.tick(tick, scraps, buildings, beamEvents); salvager.tick(tick, state, beamEvents);
++tick; ++tick;
} }
@@ -949,18 +955,18 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
{ {
Fixture f; 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(); QPoint(-4, 0), Rotation::East, 0).value();
Tick t = 0; Tick t = 0;
for (int i = 0; i < 500; ++i) for (int i = 0; i < 500; ++i)
{ {
f.buildings.tickConstruction(t++); f.construction.tick(f.state, f.belts, t++);
if (f.buildings.findBuilding(bayId) != nullptr) if (findBuilding(f.state, bayId) != nullptr)
{ {
break; break;
} }
} }
REQUIRE(f.buildings.findBuilding(bayId) != nullptr); REQUIRE(findBuilding(f.state, bayId) != nullptr);
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(5.0f, 0.0f), const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(5.0f, 0.0f),
@@ -984,15 +990,15 @@ TEST_CASE("SalvagerSystem: full-cargo ship at its SalvageBay hands over cargo",
{ {
Fixture f; 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(); QPoint(-4, 0), Rotation::East, 0).value();
Tick t = 0; Tick t = 0;
for (int i = 0; i < 500; ++i) for (int i = 0; i < 500; ++i)
{ {
f.buildings.tickConstruction(t++); f.construction.tick(f.state, f.belts, t++);
if (f.buildings.findBuilding(bayId) != nullptr) { break; } if (findBuilding(f.state, bayId) != nullptr) { break; }
} }
const Building* bay = f.buildings.findBuilding(bayId); const Building* bay = findBuilding(f.state, bayId);
REQUIRE(bay != nullptr); REQUIRE(bay != nullptr);
// Config-driven output-buffer capacity is applied on placement (REQ-BLD-SALVAGE-BAY). // Config-driven output-buffer capacity is applied on placement (REQ-BLD-SALVAGE-BAY).
REQUIRE(bay->outputBuffer.capacity == 20); REQUIRE(bay->outputBuffer.capacity == 20);
@@ -1013,7 +1019,7 @@ TEST_CASE("SalvagerSystem: full-cargo ship at its SalvageBay hands over cargo",
// One unit handed over from cargo into the bay's output buffer. // One unit handed over from cargo into the bay's output buffer.
REQUIRE(f.admin.get<CargoComponent>(ship).current == before - 1); REQUIRE(f.admin.get<CargoComponent>(ship).current == before - 1);
const Building* bayAfter = f.buildings.findBuilding(bayId); const Building* bayAfter = findBuilding(f.state, bayId);
REQUIRE(bayAfter != nullptr); REQUIRE(bayAfter != nullptr);
REQUIRE(bayAfter->outputBuffer.items.size() == 1); REQUIRE(bayAfter->outputBuffer.items.size() == 1);
REQUIRE(bayAfter->outputBuffer.items.front().type.id == "scrap"); REQUIRE(bayAfter->outputBuffer.items.front().type.id == "scrap");

View File

@@ -1,4 +1,5 @@
#include "catch.hpp" #include "catch.hpp"
#include "FactoryQueries.h"
#include <algorithm> #include <algorithm>
#include <climits> #include <climits>
@@ -529,9 +530,9 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor",
REQUIRE(idA != kInvalidBuildingId); REQUIRE(idA != kInvalidBuildingId);
REQUIRE(idB != kInvalidBuildingId); REQUIRE(idB != kInvalidBuildingId);
REQUIRE(sim.getBuildings().isTileOccupied(cursor + offsetA)); // (-6, 0) REQUIRE(isTileOccupied(sim.getFactoryState(), cursor + offsetA)); // (-6, 0)
REQUIRE(sim.getBuildings().isTileOccupied(cursor + offsetB)); // (-4, 0) REQUIRE(isTileOccupied(sim.getFactoryState(), cursor + offsetB)); // (-4, 0)
REQUIRE_FALSE(sim.getBuildings().isTileOccupied(cursor)); // center not occupied REQUIRE_FALSE(isTileOccupied(sim.getFactoryState(), cursor)); // center not occupied
} }
TEST_CASE("Blueprint placement: cost is deducted for each building in sequence", "[blueprint]") TEST_CASE("Blueprint placement: cost is deducted for each building in sequence", "[blueprint]")
@@ -600,7 +601,7 @@ TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and ch
REQUIRE_FALSE(id.has_value()); REQUIRE_FALSE(id.has_value());
REQUIRE(sim.getBuildingBlocksStock() == startBlocks); REQUIRE(sim.getBuildingBlocksStock() == startBlocks);
REQUIRE(sim.getBuildings().getAllSites().empty()); REQUIRE(getAllSites(sim.getFactoryState()).empty());
} }
TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies tiles, charges cost", TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies tiles, charges cost",
@@ -623,11 +624,11 @@ TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies
REQUIRE(id != kInvalidBuildingId); REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.getBuildingBlocksStock() == startBlocks - minerCost); REQUIRE(sim.getBuildingBlocksStock() == startBlocks - minerCost);
REQUIRE(sim.getBuildings().isTileOccupied(QPoint(-3, 0))); REQUIRE(isTileOccupied(sim.getFactoryState(), QPoint(-3, 0)));
REQUIRE(sim.getBuildings().isTileOccupied(QPoint(-2, 0))); REQUIRE(isTileOccupied(sim.getFactoryState(), QPoint(-2, 0)));
REQUIRE(sim.getBuildings().isTileOccupied(QPoint(-3, 1))); REQUIRE(isTileOccupied(sim.getFactoryState(), QPoint(-3, 1)));
// The output-port tile (1,1)+anchor = (-2,1) is not a body cell. // The output-port tile (1,1)+anchor = (-2,1) is not a body cell.
REQUIRE_FALSE(sim.getBuildings().isTileOccupied(QPoint(-2, 1))); REQUIRE_FALSE(isTileOccupied(sim.getFactoryState(), QPoint(-2, 1)));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -665,9 +666,9 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value(); const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId); 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 = sim.getBuildings().findSite(id); const ConstructionSite* site = findSite(sim.getFactoryState(), id);
REQUIRE(site != nullptr); REQUIRE(site != nullptr);
REQUIRE(site->recipeId == "mine_iron_ore"); REQUIRE(site->recipeId == "mine_iron_ore");
} }
@@ -679,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(); const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId); 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. // Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300.
// Run 301 ticks (0..300) to process the completion tick. // Run 301 ticks (0..300) to process the completion tick.
@@ -688,7 +689,7 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
sim.tick(); sim.tick();
} }
const Building* b = sim.getBuildings().findBuilding(id); const Building* b = findBuilding(sim.getFactoryState(), id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
REQUIRE(b->recipeId == "mine_copper_ore"); REQUIRE(b->recipeId == "mine_copper_ore");
} }
@@ -708,8 +709,8 @@ TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]")
const BuildingId id = const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East).value(); SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId); REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.getBuildings().findSite(id) != nullptr); REQUIRE(findSite(sim.getFactoryState(), id) != nullptr);
REQUIRE(sim.getBuildings().findBuilding(id) == nullptr); REQUIRE(findBuilding(sim.getFactoryState(), id) == nullptr);
const Blueprint bp = captureBlueprintFromSelection(sim, { id }); const Blueprint bp = captureBlueprintFromSelection(sim, { id });
@@ -725,7 +726,7 @@ TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blue
const BuildingId id = const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value(); SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId); 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 }); const Blueprint bp = captureBlueprintFromSelection(sim, { id });
@@ -742,16 +743,16 @@ TEST_CASE("Blueprint creation: mixed operational building and construction site
const BuildingId idA = const BuildingId idA =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value(); SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(idA != kInvalidBuildingId); 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(); } for (int i = 0; i <= static_cast<int>(secondsToTicks(10.0)); ++i) { sim.tick(); }
REQUIRE(sim.getBuildings().findBuilding(idA) != nullptr); REQUIRE(findBuilding(sim.getFactoryState(), idA) != nullptr);
// Building B: place and configure, but leave as a construction site. // Building B: place and configure, but leave as a construction site.
const BuildingId idB = const BuildingId idB =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-6, 0), Rotation::East).value(); SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-6, 0), Rotation::East).value();
REQUIRE(idB != kInvalidBuildingId); REQUIRE(idB != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(idB, "mine_copper_ore"); SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), idB, "mine_copper_ore");
REQUIRE(sim.getBuildings().findSite(idB) != nullptr); REQUIRE(findSite(sim.getFactoryState(), idB) != nullptr);
const Blueprint bp = captureBlueprintFromSelection(sim, { idA, idB }); const Blueprint bp = captureBlueprintFromSelection(sim, { idA, idB });
@@ -775,7 +776,7 @@ TEST_CASE("Blueprint creation: selectionHasPlaceableBuilding sees a construction
const BuildingId id = const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value(); SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId); REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.getBuildings().findSite(id) != nullptr); REQUIRE(findSite(sim.getFactoryState(), id) != nullptr);
REQUIRE(selectionHasPlaceableBuilding(sim, { id })); REQUIRE(selectionHasPlaceableBuilding(sim, { id }));
} }
@@ -853,9 +854,9 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
pm.rotation = Rotation::East; pm.rotation = Rotation::East;
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(id, layout); SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), id, layout);
const ConstructionSite* site = sim.getBuildings().findSite(id); const ConstructionSite* site = findSite(sim.getFactoryState(), id);
REQUIRE(site != nullptr); REQUIRE(site != nullptr);
REQUIRE(site->shipLayout.has_value()); REQUIRE(site->shipLayout.has_value());
REQUIRE(site->shipLayout->placedModules.size() == 1); REQUIRE(site->shipLayout->placedModules.size() == 1);
@@ -877,7 +878,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
pm.rotation = Rotation::North; pm.rotation = Rotation::North;
layout.placedModules.push_back(pm); 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. // Shipyard construction_time_seconds = 30 in the test config.
double constructionTime = 0.0; double constructionTime = 0.0;
@@ -892,7 +893,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
sim.tick(); sim.tick();
} }
const Building* b = sim.getBuildings().findBuilding(id); const Building* b = findBuilding(sim.getFactoryState(), id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
REQUIRE(b->shipLayout.has_value()); REQUIRE(b->shipLayout.has_value());
REQUIRE(b->shipLayout->placedModules.size() == 1); REQUIRE(b->shipLayout->placedModules.size() == 1);

View File

@@ -1,4 +1,5 @@
#include "catch.hpp" #include "catch.hpp"
#include "FactoryQueries.h"
#include <algorithm> #include <algorithm>
@@ -36,8 +37,7 @@ BuildingId placeOperational(Simulation& sim, const GameConfig& cfg,
{ {
const BuildingDef* def = findDef(cfg, type); const BuildingDef* def = findDef(cfg, type);
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
return SimulationTestAccess::buildings(sim).placeImmediate( return SimulationTestAccess::buildings(sim).placeImmediate(SimulationTestAccess::state(sim), type, def->surfaceMask, anchor, Rotation::East);
type, def->surfaceMask, anchor, Rotation::East);
} }
const ShipDef* findAvailableSchematic(const GameConfig& cfg) const ShipDef* findAvailableSchematic(const GameConfig& cfg)
@@ -66,7 +66,7 @@ TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[copyconfig]"
Simulation sim(loadTestConfig(), 7); Simulation sim(loadTestConfig(), 7);
const BuildingId id = placeOperational(sim, cfg, BuildingType::Miner, QPoint(0, 0)); 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); const std::optional<BuildingConfig> config = readBuildingConfig(sim, id);
REQUIRE(config.has_value()); REQUIRE(config.has_value());
@@ -102,8 +102,8 @@ TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout",
REQUIRE(schematic != nullptr); REQUIRE(schematic != nullptr);
const BuildingId id = placeOperational(sim, cfg, BuildingType::Shipyard, QPoint(0, 0)); const BuildingId id = placeOperational(sim, cfg, BuildingType::Shipyard, QPoint(0, 0));
SimulationTestAccess::buildings(sim).setRecipe(id, schematic->id); SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), id, schematic->id);
SimulationTestAccess::buildings(sim).setShipLayout(id, ShipLayoutConfig{}); SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), id, ShipLayoutConfig{});
const std::optional<BuildingConfig> config = readBuildingConfig(sim, id); const std::optional<BuildingConfig> config = readBuildingConfig(sim, id);
REQUIRE(config.has_value()); REQUIRE(config.has_value());
@@ -122,10 +122,10 @@ TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]")
const BuildingId id = const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value(); SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId); REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.getBuildings().findBuilding(id) == nullptr); REQUIRE(findBuilding(sim.getFactoryState(), id) == nullptr);
REQUIRE(sim.getBuildings().findSite(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); const std::optional<BuildingConfig> config = readBuildingConfig(sim, id);
REQUIRE(config.has_value()); REQUIRE(config.has_value());

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "FactoryState.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "CombatSystem.h" #include "CombatSystem.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
@@ -51,6 +52,7 @@ static entt::entity findWeaponChild(EntityAdmin& admin, entt::entity ship)
struct CombatFixture struct CombatFixture
{ {
GameConfig cfg; GameConfig cfg;
FactoryState state = makeFactoryState(cfg);
std::mt19937 rng; std::mt19937 rng;
EntityAdmin admin; EntityAdmin admin;
BuildingId nextBuildingId; BuildingId nextBuildingId;
@@ -110,7 +112,7 @@ TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[comb
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<BeamFiredEvent> events; std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, events);
f.combat.applyPendingDamage(5, f.admin); f.combat.applyPendingDamage(5, f.admin);
REQUIRE(f.admin.get<HealthComponent>(player).hp < hpBefore); REQUIRE(f.admin.get<HealthComponent>(player).hp < hpBefore);
@@ -143,13 +145,13 @@ TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]"
}; };
std::vector<BeamFiredEvent> events; std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, events);
REQUIRE_FALSE(enemyFiredIn(events)); REQUIRE_FALSE(enemyFiredIn(events));
f.combat.tick(1, f.admin, f.buildings, events); f.combat.tick(1, f.admin, events);
REQUIRE_FALSE(enemyFiredIn(events)); REQUIRE_FALSE(enemyFiredIn(events));
f.combat.tick(2, f.admin, f.buildings, events); f.combat.tick(2, f.admin, events);
REQUIRE(enemyFiredIn(events)); REQUIRE(enemyFiredIn(events));
} }
@@ -164,7 +166,7 @@ TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]")
f.wireEnemyTarget(enemy, player); f.wireEnemyTarget(enemy, player);
std::vector<BeamFiredEvent> events; std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, events);
REQUIRE(events.empty()); REQUIRE(events.empty());
} }
@@ -307,7 +309,7 @@ TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]")
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<BeamFiredEvent> events; std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, events);
for (Tick t = 1; t < 5; ++t) for (Tick t = 1; t < 5; ++t)
{ {
@@ -329,7 +331,7 @@ TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]")
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<BeamFiredEvent> events; std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, events);
f.combat.applyPendingDamage(5, f.admin); f.combat.applyPendingDamage(5, f.admin);
REQUIRE(f.admin.get<HealthComponent>(player).hp < hpBefore); REQUIRE(f.admin.get<HealthComponent>(player).hp < hpBefore);
@@ -346,7 +348,7 @@ TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[comb
f.wireEnemyTarget(enemy, player); f.wireEnemyTarget(enemy, player);
std::vector<BeamFiredEvent> events; std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, events);
f.ships.despawn(player); f.ships.despawn(player);
@@ -369,7 +371,7 @@ TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<BeamFiredEvent> events; std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, events);
f.ships.despawn(enemy); f.ships.despawn(enemy);
@@ -413,7 +415,7 @@ TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
sim.tick(); sim.tick();
const std::vector<DebrisInfo> scraps = sim.getDebrisSystem().getAllDebrisInfo(); const std::vector<DebrisInfo> scraps = getAllDebrisInfo(sim.getAdmin());
REQUIRE(scraps.size() == 1); REQUIRE(scraps.size() == 1);
CHECK(sim.getAdmin().get<DebrisComponent>(scraps[0].entity).amount == 59); 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 = const BuildingId id =
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value(); 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()); 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); 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.isValid(e));
REQUIRE(admin.get<DebrisComponent>(e).amount == 2); REQUIRE(admin.get<DebrisComponent>(e).amount == 2);
REQUIRE(ss.collectOne(e)); REQUIRE(collectOne(admin, e));
REQUIRE(admin.isValid(e)); REQUIRE(admin.isValid(e));
REQUIRE(admin.get<DebrisComponent>(e).amount == 1); REQUIRE(admin.get<DebrisComponent>(e).amount == 1);
// Final unit collected: the debris is removed once depleted. // Final unit collected: the debris is removed once depleted.
REQUIRE(ss.collectOne(e)); REQUIRE(collectOne(admin, e));
REQUIRE_FALSE(admin.isValid(e)); REQUIRE_FALSE(admin.isValid(e));
} }
@@ -134,7 +134,7 @@ TEST_CASE("DebrisSystem: collectOne returns false for an invalid entity", "[debr
EntityAdmin admin; EntityAdmin admin;
DebrisSystem ss(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(1.0f, 2.0f), 3, 100);
ss.spawn(QVector2D(4.0f, 5.0f), 6, 200); 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); 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 a = ss.spawn(QVector2D(1.0f, 2.0f), 3, 100);
const entt::entity b = ss.spawn(QVector2D(4.0f, 5.0f), 6, 200); 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); REQUIRE(info.size() == 2);
for (const DebrisInfo& i : info) for (const DebrisInfo& i : info)
{ {

View File

@@ -5,11 +5,15 @@
#include <vector> #include <vector>
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "FactionComponent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "HealthComponent.h"
#include "Rotation.h" #include "Rotation.h"
#include "SchematicChoiceOption.h"
#include "Simulation.h" #include "Simulation.h"
#include "SimulationTestAccess.h" #include "SimulationTestAccess.h"
#include "StateChecksum.h" #include "StateChecksum.h"
#include "StationBodyComponent.h"
#include "Tick.h" #include "Tick.h"
#include "TestConfig.h" #include "TestConfig.h"
@@ -17,9 +21,33 @@ namespace
{ {
constexpr int kScriptTicks = 2000; constexpr int kScriptTicks = 2000;
// Ticks at which the scripted session destroys the enemy stations, and the ticks
// on which the resulting schematic choice is taken. A station dying triggers the
// choice generation (REQ-DEF-SCHEMATIC-DROP), which lands during that same tick,
// so the choice is applied on the tick after.
constexpr int kFirstStationKillTick = 800;
constexpr int kFirstChoiceTick = kFirstStationKillTick + 1;
constexpr int kSecondStationKillTick = 1400;
constexpr int kSecondChoiceTick = kSecondStationKillTick + 1;
// Zeroes the HP of every enemy station, so the next tick processes their death.
void killEnemyStations(Simulation& sim)
{
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity, StationBodyComponent&, FactionComponent& faction,
HealthComponent& health)
{
if (faction.isEnemy) { health.hp = 0.0f; }
});
}
// Runs a fixed scripted session and returns the full-state checksum after every // Runs a fixed scripted session and returns the full-state checksum after every
// tick. The script places a small factory, deconstructs part of it mid-run, and // tick. The script places a small factory, deconstructs part of it mid-run, and
// otherwise lets waves/combat run so the RNG stream and ECS state are exercised. // otherwise lets waves/combat run so the RNG stream and ECS state are exercised.
// It also destroys the enemy stations twice and takes the offered schematic
// choice, so that unlock state (awarded groups, per-schematic levels, and the
// implicit recipe/item sets derived from them) is exercised as well and reaches
// the checksum via UnlockState::appendChecksum.
std::vector<std::uint64_t> runScriptedSession(unsigned int seed) std::vector<std::uint64_t> runScriptedSession(unsigned int seed)
{ {
Simulation sim(loadTestConfig(), seed); Simulation sim(loadTestConfig(), seed);
@@ -40,6 +68,26 @@ std::vector<std::uint64_t> runScriptedSession(unsigned int seed)
SimulationTestAccess::place(sim, BuildingType::Smelter, QPoint(-3, 3), Rotation::East); SimulationTestAccess::place(sim, BuildingType::Smelter, QPoint(-3, 3), Rotation::East);
} }
if (t == kFirstStationKillTick || t == kSecondStationKillTick)
{
killEnemyStations(sim);
}
if (t == kFirstChoiceTick)
{
// Guarded rather than assumed: if the test config ever stops offering
// a group here, this script would silently stop covering unlock state.
REQUIRE(sim.hasSchematicChoicesPending());
SimulationTestAccess::applySchematicChoice(sim, 0);
}
// The second award is opportunistic — whether a group is still eligible
// depends on what the first one granted and on the prerequisite gating.
if (t == kSecondChoiceTick && sim.hasSchematicChoicesPending())
{
SimulationTestAccess::applySchematicChoice(sim, 0);
}
sim.tick(); sim.tick();
checksums.push_back(sim.computeStateChecksum()); checksums.push_back(sim.computeStateChecksum());
} }
@@ -152,3 +200,44 @@ TEST_CASE("Simulation: different seeds diverge in state checksum", "[determinism
// the RNG-driven divergence; a constant checksum would be a broken hash). // the RNG-driven divergence; a constant checksum would be a broken hash).
REQUIRE(a != b); REQUIRE(a != b);
} }
// ---------------------------------------------------------------------------
// Unlock state coverage
// ---------------------------------------------------------------------------
TEST_CASE("Simulation: unlock state contributes to the state checksum",
"[determinism][unlock]")
{
// Two sessions with identical history up to the schematic choice; only one
// takes the choice. This pins down that awarding an unlock group actually
// reaches the checksum, which the scripted-session tests above rely on but
// cannot show on their own: they would still pass if UnlockState were left
// out of the fold entirely.
Simulation taken(loadTestConfig(), 12345u);
Simulation skipped(loadTestConfig(), 12345u);
for (int t = 0; t < kFirstStationKillTick; ++t)
{
taken.tick();
skipped.tick();
}
killEnemyStations(taken);
killEnemyStations(skipped);
taken.tick();
skipped.tick();
// In lockstep before the choice, so the divergence below has one cause.
REQUIRE(taken.computeStateChecksum() == skipped.computeStateChecksum());
REQUIRE(taken.hasSchematicChoicesPending());
// An artifact choice bumps m_artifactCount, which is folded separately; the
// divergence would then not be attributable to unlock state.
REQUIRE_FALSE(taken.getPendingSchematicChoices()[0].isArtifact);
SimulationTestAccess::applySchematicChoice(taken, 0);
// The pending-choice list is not itself folded into the checksum, so the
// only state that changed is the unlock bookkeeping.
REQUIRE(taken.computeStateChecksum() != skipped.computeStateChecksum());
}

View File

@@ -1,4 +1,5 @@
#include "catch.hpp" #include "catch.hpp"
#include "FactoryQueries.h"
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
@@ -61,8 +62,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef) static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
{ {
return SimulationTestAccess::buildings(sim).placeImmediate( return SimulationTestAccess::buildings(sim).placeImmediate(SimulationTestAccess::state(sim), BuildingType::Shipyard,
BuildingType::Shipyard,
yardDef.surfaceMask, yardDef.surfaceMask,
QPoint(0, 0), QPoint(0, 0),
Rotation::East); Rotation::East);
@@ -72,7 +72,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId,
const ShipDef& def, const ShipDef& def,
const ShipLayoutConfig& layout) const ShipLayoutConfig& layout)
{ {
SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b) { SimulationTestAccess::buildings(sim).forEachBuilding(SimulationTestAccess::state(sim), [&](Building& b) {
if (b.id != yardId) if (b.id != yardId)
{ {
return; return;
@@ -207,7 +207,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor"); SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");
ShipLayoutConfig layout; ShipLayoutConfig layout;
PlacedModule pm; PlacedModule pm;
@@ -216,9 +216,9 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
pm.rotation = Rotation::East; pm.rotation = Rotation::East;
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout); SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
const Building* b = sim.getBuildings().findBuilding(yardId); const Building* b = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
// armor_plate needs 2 iron_ingot; interceptor needs 3 iron_ingot + 1 circuit_board // armor_plate needs 2 iron_ingot; interceptor needs 3 iron_ingot + 1 circuit_board
// Total iron_ingot = 5, buffer cap = 2 * 5 = 10 // Total iron_ingot = 5, buffer cap = 2 * 5 = 10
@@ -236,14 +236,14 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef); 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. // Fill materials and tick to start production.
ShipLayoutConfig emptyLayout; ShipLayoutConfig emptyLayout;
fillMaterials(sim, yardId, *def, emptyLayout); fillMaterials(sim, yardId, *def, emptyLayout);
sim.tick(); sim.tick();
const Building* b1 = sim.getBuildings().findBuilding(yardId); const Building* b1 = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(b1 != nullptr); REQUIRE(b1 != nullptr);
REQUIRE(b1->production.has_value()); REQUIRE(b1->production.has_value());
@@ -255,9 +255,9 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
pm.rotation = Rotation::East; pm.rotation = Rotation::East;
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout); SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
const Building* b2 = sim.getBuildings().findBuilding(yardId); const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(b2 != nullptr); REQUIRE(b2 != nullptr);
CHECK_FALSE(b2->production.has_value()); CHECK_FALSE(b2->production.has_value());
} }
@@ -278,7 +278,7 @@ TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef); 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. // Deliberately no setShipLayout: recipe set, layout left unconfigured.
// Charge only the base-hull materials (an empty layout adds none). // Charge only the base-hull materials (an empty layout adds none).
@@ -313,7 +313,7 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor"); SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");
ShipLayoutConfig layout; ShipLayoutConfig layout;
PlacedModule pm; PlacedModule pm;
@@ -321,15 +321,15 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
pm.position = QPoint(0, 0); pm.position = QPoint(0, 0);
pm.rotation = Rotation::East; pm.rotation = Rotation::East;
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout); SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
const Building* b1 = sim.getBuildings().findBuilding(yardId); const Building* b1 = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(b1 != nullptr); REQUIRE(b1 != nullptr);
REQUIRE(b1->shipLayout.has_value()); REQUIRE(b1->shipLayout.has_value());
SimulationTestAccess::buildings(sim).setRecipe(yardId,"destroyer"); SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"destroyer");
const Building* b2 = sim.getBuildings().findBuilding(yardId); const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(b2 != nullptr); REQUIRE(b2 != nullptr);
CHECK_FALSE(b2->shipLayout.has_value()); CHECK_FALSE(b2->shipLayout.has_value());
} }
@@ -342,7 +342,7 @@ TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor"); SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");
ShipLayoutConfig layout; ShipLayoutConfig layout;
PlacedModule pm; PlacedModule pm;
@@ -350,16 +350,16 @@ TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
pm.position = QPoint(0, 0); pm.position = QPoint(0, 0);
pm.rotation = Rotation::East; pm.rotation = Rotation::East;
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout); SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
const Building* b1 = sim.getBuildings().findBuilding(yardId); const Building* b1 = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(b1 != nullptr); REQUIRE(b1 != nullptr);
REQUIRE(b1->shipLayout.has_value()); REQUIRE(b1->shipLayout.has_value());
// Re-selecting the same recipe must be a no-op and preserve the layout. // 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 = sim.getBuildings().findBuilding(yardId); const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(b2 != nullptr); REQUIRE(b2 != nullptr);
REQUIRE(b2->shipLayout.has_value()); REQUIRE(b2->shipLayout.has_value());
REQUIRE(b2->shipLayout->placedModules.size() == 1); REQUIRE(b2->shipLayout->placedModules.size() == 1);

View File

@@ -1,4 +1,5 @@
#include "catch.hpp" #include "catch.hpp"
#include "FactoryQueries.h"
#include <algorithm> #include <algorithm>
@@ -57,8 +58,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef) static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
{ {
return SimulationTestAccess::buildings(sim).placeImmediate( return SimulationTestAccess::buildings(sim).placeImmediate(SimulationTestAccess::state(sim), BuildingType::Shipyard,
BuildingType::Shipyard,
yardDef.surfaceMask, yardDef.surfaceMask,
QPoint(0, 0), QPoint(0, 0),
Rotation::East); Rotation::East);
@@ -74,7 +74,7 @@ static int countShips(Simulation& sim)
static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def) 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) if (b.id != yardId)
{ {
@@ -106,7 +106,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
const BuildingId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
REQUIRE(yardId != kInvalidBuildingId); REQUIRE(yardId != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id); SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, def->id);
fillMaterials(sim, yardId, *def); fillMaterials(sim, yardId, *def);
// First tick: materials consumed, production cycle starts — no ship yet. // First tick: materials consumed, production cycle starts — no ship yet.
@@ -165,7 +165,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
const int shipsBefore = countShips(sim); const int shipsBefore = countShips(sim);
const BuildingId yardId = placeShipyard(sim, *yardDef); 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. // Materials remain at zero (default after setRecipe); no cycle starts.
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds); const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
@@ -187,7 +187,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef); 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); const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
@@ -214,7 +214,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
// Verify the shipyard production field cleared (i.e. the cycle completed // Verify the shipyard production field cleared (i.e. the cycle completed
// and is not still running). // and is not still running).
bool productionCleared = false; bool productionCleared = false;
for (const Building& b : sim.getBuildings().getAllBuildings()) for (const Building& b : getAllBuildings(sim.getFactoryState()))
{ {
if (b.id == yardId) if (b.id == yardId)
{ {

View File

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

View File

@@ -1,4 +1,5 @@
#include "catch.hpp" #include "catch.hpp"
#include "FactoryQueries.h"
#include <random> #include <random>
@@ -100,7 +101,7 @@ TEST_CASE("WaveSystem: Simulation pre-places HQ + 2 player + 2 enemy stations",
// HQ is still a Building (for belt integration). // HQ is still a Building (for belt integration).
int hqCount = 0; int hqCount = 0;
for (const Building& b : sim.getBuildings().getAllBuildings()) for (const Building& b : getAllBuildings(sim.getFactoryState()))
{ {
if (b.type == BuildingType::Hq) { ++hqCount; } if (b.type == BuildingType::Hq) { ++hqCount; }
} }
@@ -143,7 +144,7 @@ TEST_CASE("WaveSystem: HQ anchor is at asteroid right edge", "[wave]")
{ {
const Simulation sim(loadTestConfig(), 42); const Simulation sim(loadTestConfig(), 42);
for (const Building& b : sim.getBuildings().getAllBuildings()) for (const Building& b : getAllBuildings(sim.getFactoryState()))
{ {
if (b.type != BuildingType::Hq) { continue; } if (b.type != BuildingType::Hq) { continue; }
// Rightmost body cell must be at x = -1 (asteroid right edge). // Rightmost body cell must be at x = -1 (asteroid right edge).

View File

@@ -9,6 +9,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h
@@ -30,6 +31,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp

View File

@@ -0,0 +1,403 @@
#include "FieldSelectionPanel.h"
#include <algorithm>
#include <map>
#include <string>
#include <QFont>
#include <QLabel>
#include <QStringList>
#include <QVBoxLayout>
#include "DebrisSystem.h"
#include "DisplayName.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "SelectedBehaviorComponent.h"
#include "ShipIdentityComponent.h"
#include "ShipStatsCalculator.h"
#include "ShipStatsPanel.h"
#include "Simulation.h"
#include "StationBodyComponent.h"
#include "ThreatCostCalculator.h"
#include "WeaponComponent.h"
FieldSelectionPanel::FieldSelectionPanel(Simulation* sim,
const GameConfig* config,
QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
{
// Zero margins and the same spacing as the enclosing SelectedBuildingPanel layout, so
// nesting the field widgets in this panel leaves their geometry unchanged.
m_layout = new QVBoxLayout(this);
m_layout->setContentsMargins(0, 0, 0, 0);
m_layout->setSpacing(4);
m_layout->setAlignment(Qt::AlignTop);
m_entityTitleLabel = new QLabel(this);
QFont titleFont = m_entityTitleLabel->font();
titleFont.setBold(true);
m_entityTitleLabel->setFont(titleFont);
m_layout->addWidget(m_entityTitleLabel);
m_entityTitleLabel->hide();
m_entityStatsPanel = new ShipStatsPanel(config, this);
m_layout->addWidget(m_entityStatsPanel);
m_entityStatsPanel->hide();
m_stationStatsLabel = new QLabel(this);
m_stationStatsLabel->setWordWrap(true);
m_layout->addWidget(m_stationStatsLabel);
m_stationStatsLabel->hide();
m_entitySummaryLabel = new QLabel(this);
m_entitySummaryLabel->setWordWrap(true);
m_layout->addWidget(m_entitySummaryLabel);
m_entitySummaryLabel->hide();
m_scrapLabel = new QLabel(this);
m_layout->addWidget(m_scrapLabel);
m_scrapLabel->hide();
hide();
registerForEvents();
}
FieldSelectionPanel::~FieldSelectionPanel()
{
unregisterForEvents();
}
void FieldSelectionPanel::setSelectedEntities(const std::vector<entt::entity>& entities)
{
m_selectedEntities = entities;
rebuild();
}
void FieldSelectionPanel::setSelectedDebris(const std::vector<entt::entity>& debris)
{
m_selectedDebris = debris;
rebuild();
}
void FieldSelectionPanel::clearSelection()
{
m_selectedEntities.clear();
m_selectedDebris.clear();
rebuild();
}
bool FieldSelectionPanel::hasSelection() const
{
return !m_selectedEntities.empty() || !m_selectedDebris.empty();
}
void FieldSelectionPanel::hideAllWidgets()
{
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_entitySummaryLabel->hide();
m_scrapLabel->hide();
}
void FieldSelectionPanel::rebuild()
{
if (!hasSelection())
{
// Nothing in the field category: take no space, leaving the panel to whatever
// the building category shows (REQ-UI-SELECTION-CATEGORIES).
hideAllWidgets();
hide();
return;
}
show();
EntityAdmin& admin = m_sim->getAdmin();
// A full single-object stats panel is shown only for a lone field object: one actor
// with no debris, or one piece of debris with no actors. As soon as the selection holds
// more than one object (multiple actors, multiple debris, or actors plus debris), the
// panel switches to the compact count summary (REQ-UI-FIELD-MULTI-SELECTION).
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
{
m_entitySummaryLabel->hide();
m_scrapLabel->hide();
const entt::entity entity = m_selectedEntities.front();
if (admin.isValid(entity) && admin.hasAll<ShipIdentityComponent>(entity))
{
buildEntityShip(entity);
}
else if (admin.isValid(entity) && admin.hasAll<StationBodyComponent>(entity))
{
buildEntityStation(entity);
}
else
{
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
}
return;
}
if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
{
// Single piece of debris: a "Debris" heading plus a "Scrap" stat row, styled like
// the ship/station stats panels (REQ-UI-DEBRIS-PANEL).
m_entitySummaryLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
buildDebrisSingle();
return;
}
// More than one field object: a compact count summary. buildEntitySummary() appends the
// "Debris x N" and "Scrap x N" lines when debris is part of the selection.
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_scrapLabel->hide();
buildEntitySummary();
}
void FieldSelectionPanel::refreshDisplay()
{
if (!hasSelection()) { return; }
// Keep the live values current: the single-actor stats panel, the single-debris stats
// panel (whose Scrap row shrinks as it is collected), or the count summary (whose Scrap
// line shrinks likewise) — matching the layout chosen by rebuild()
// (REQ-UI-SHIP-STATS-PANEL, REQ-UI-DEBRIS-PANEL).
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
{
refreshEntityStats();
}
else if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
{
buildDebrisSingle();
}
else
{
buildEntitySummary();
}
}
void FieldSelectionPanel::buildDebrisSingle()
{
// "Debris" heading + a single "Scrap" stat row for the piece's remaining amount,
// mirroring the single-actor stats panels (REQ-UI-DEBRIS-PANEL).
m_entityTitleLabel->setText(tr("Debris"));
m_entityTitleLabel->show();
m_scrapLabel->setText(tr("Scrap: %1").arg(selectedDebrisScrapTotal()));
m_scrapLabel->show();
}
void FieldSelectionPanel::buildEntitySummary()
{
EntityAdmin& admin = m_sim->getAdmin();
// Group actors by faction + kind + ship schematic, preserving first-seen order
// (REQ-UI-FIELD-MULTI-SELECTION).
std::vector<QString> keys;
std::map<QString, int> counts;
std::map<QString, QString> labels;
for (entt::entity entity : m_selectedEntities)
{
if (!admin.isValid(entity)) { continue; }
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
&& admin.get<FactionComponent>(entity).isEnemy;
QString key;
QString label;
if (admin.hasAll<ShipIdentityComponent>(entity))
{
const std::string& id = admin.get<ShipIdentityComponent>(entity).schematicId;
const QString name = QString::fromStdString(toDisplayName(id));
key = (isEnemy ? QStringLiteral("ship:enemy:") : QStringLiteral("ship:player:"))
+ QString::fromStdString(id);
label = isEnemy ? tr("Enemy %1").arg(name) : name;
}
else if (admin.hasAll<StationBodyComponent>(entity))
{
key = isEnemy ? QStringLiteral("station:enemy") : QStringLiteral("station:player");
label = isEnemy ? tr("Enemy Defence Station") : tr("Player Defence Station");
}
else
{
continue;
}
if (counts.find(key) == counts.end())
{
keys.push_back(key);
labels[key] = label;
}
counts[key] += 1;
}
// One "<type> x <count>" line per group (matching the recipe tooltip and the building
// multi-selection). No total-count header, consistent with the building panel. When
// debris is part of the selection, a "Debris x <count>" line followed by a
// "Scrap x <total>" line are appended into the same label so the line spacing is
// uniform (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL).
QStringList lines;
for (const QString& key : keys)
{
lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]);
}
if (!m_selectedDebris.empty())
{
lines << tr("Debris x %1").arg(static_cast<int>(m_selectedDebris.size()));
lines << scrapTotalText();
}
m_entitySummaryLabel->setText(lines.join('\n'));
m_entitySummaryLabel->show();
}
void FieldSelectionPanel::buildEntityShip(entt::entity entity)
{
EntityAdmin& admin = m_sim->getAdmin();
const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(entity);
const HealthComponent& health = admin.get<HealthComponent>(entity);
m_entityTitleLabel->setText(tr("Ship: %1")
.arg(QString::fromStdString(identity.schematicId)));
m_entityTitleLabel->show();
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw);
const ShipDef* schematicDef =
m_config->ships.findShipDef(identity.schematicId);
if (schematicDef)
{
const double threat = calculateShipThreatCost(
m_config->threatCosts, *m_config, schematicDef->id,
schematicDef->defaultModules);
m_entityStatsPanel->setThreatCost(threat);
}
m_entityStatsPanel->show();
m_stationStatsLabel->hide();
}
void FieldSelectionPanel::buildEntityStation(entt::entity entity)
{
EntityAdmin& admin = m_sim->getAdmin();
const HealthComponent& health = admin.get<HealthComponent>(entity);
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
&& admin.get<FactionComponent>(entity).isEnemy;
m_entityTitleLabel->setText(isEnemy
? tr("Enemy Defence Station")
: tr("Player Defence Station"));
m_entityTitleLabel->show();
float totalDps = 0.0f;
float maxRange = 0.0f;
bool hasWeapons = false;
admin.forEach<ModuleOwnerComponent, WeaponComponent>(
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w)
{
if (owner.owner != entity) { return; }
hasWeapons = true;
totalDps += w.damage * w.fireRateHz;
if (w.range_tiles > maxRange) { maxRange = w.range_tiles; }
});
QString statsText = tr("HP: %1 / %2")
.arg(static_cast<int>(health.hp + 0.5f))
.arg(static_cast<int>(health.maxHp + 0.5f));
if (hasWeapons)
{
statsText += tr("\nDPS: %1").arg(QString::number(static_cast<double>(totalDps), 'f', 1));
statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast<double>(maxRange), 'f', 1));
}
m_stationStatsLabel->setText(statsText);
m_stationStatsLabel->show();
m_entityStatsPanel->hide();
}
void FieldSelectionPanel::refreshEntityStats()
{
// Only the single-actor stats panel needs a live refresh; the multi-actor summary is
// static counts, and GameWorldView prunes dead/despawned actors and re-emits the
// selection (REQ-UI-ENTITY-CLICK-SELECT), so the panel does not mutate it here.
if (m_selectedEntities.size() != 1) { return; }
EntityAdmin& admin = m_sim->getAdmin();
const entt::entity entity = m_selectedEntities.front();
if (!admin.isValid(entity) || !admin.hasAll<HealthComponent>(entity)) { return; }
const HealthComponent& health = admin.get<HealthComponent>(entity);
if (health.hp <= 0.0f) { return; }
if (admin.hasAll<ShipIdentityComponent>(entity))
{
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
}
else if (admin.hasAll<StationBodyComponent>(entity))
{
buildEntityStation(entity);
}
}
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 : getAllDebrisInfo(m_sim->getAdmin()))
{
if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity)
!= m_selectedDebris.end())
{
total += info.amount;
}
}
return total;
}
QString FieldSelectionPanel::scrapTotalText() const
{
return tr("Scrap x %1").arg(selectedDebrisScrapTotal());
}
void FieldSelectionPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
{
refreshDisplay();
}
void FieldSelectionPanel::handleEvent(
std::shared_ptr<const PlayerCommandsAppliedEvent> /*event*/)
{
// Player commands are applied by a queued drain, not synchronously. When the game is
// paused no tick advances, so TickAdvancedEvent never fires; refresh here too.
refreshDisplay();
}
void FieldSelectionPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)
{
m_debugDraw = event->active;
m_entityStatsPanel->setDebugDrawEnabled(event->active);
}

View File

@@ -0,0 +1,91 @@
#pragma once
#include <vector>
#include <QString>
#include <QWidget>
#include "entt/entity/entity.hpp"
#include "DebugDrawToggledEvent.h"
#include "EventHandler.h"
#include "PlayerCommandsAppliedEvent.h"
#include "TickAdvancedEvent.h"
struct GameConfig;
class Simulation;
class ShipStatsPanel;
class QLabel;
class QVBoxLayout;
// Renders the "field" selection category — ships, defence stations and debris — as either
// a single-object stats panel (ship, station, or debris) or a compact multi-object count
// summary (REQ-UI-SELECTION-CATEGORIES, REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL).
//
// The panel owns its own selection state and its own widgets, and nothing else. Which of
// the two selection categories owns the side panel is arbitrated by the parent
// SelectedBuildingPanel: it feeds this panel through setSelectedEntities() /
// setSelectedDebris() / clearSelection() and asks it via hasSelection(). This panel hides
// itself whenever its selection is empty, so an inactive field category takes no space.
class FieldSelectionPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent,
DebugDrawToggledEvent>
{
Q_OBJECT
public:
FieldSelectionPanel(Simulation* sim, const GameConfig* config,
QWidget* parent = nullptr);
~FieldSelectionPanel() override;
// Replaces the selected actors (ships and defence stations); debris is left alone,
// the two coexist within the field category (REQ-UI-SELECTION-CATEGORIES).
void setSelectedEntities(const std::vector<entt::entity>& entities);
// Replaces the selected debris; the selected actors are left alone.
void setSelectedDebris(const std::vector<entt::entity>& debris);
// Drops the whole field selection — used when the building category takes over.
void clearSelection();
// True while the field category has anything selected, i.e. while this panel owns
// the side panel's content.
bool hasSelection() const;
private:
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
// Picks the layout for the current selection and shows/hides this panel accordingly.
void rebuild();
// Keeps the live values of the layout chosen by rebuild() current.
void refreshDisplay();
void buildEntityShip(entt::entity entity);
void buildEntityStation(entt::entity entity);
void buildEntitySummary();
void buildDebrisSingle();
void refreshEntityStats();
void hideAllWidgets();
// Summed remaining scrap across the selected debris (REQ-UI-DEBRIS-PANEL).
int selectedDebrisScrapTotal() const;
// "Scrap x N" line for the multi-object summary (REQ-UI-FIELD-MULTI-SELECTION).
QString scrapTotalText() const;
Simulation* m_sim;
const GameConfig* m_config;
bool m_debugDraw = false;
// The selected ships/defence stations. Shares the "field" selection category with
// debris (m_selectedDebris): both can be non-empty at once (REQ-UI-SELECTION-CATEGORIES).
std::vector<entt::entity> m_selectedEntities;
std::vector<entt::entity> m_selectedDebris;
QVBoxLayout* m_layout;
QLabel* m_entityTitleLabel;
ShipStatsPanel* m_entityStatsPanel;
QLabel* m_stationStatsLabel;
QLabel* m_entitySummaryLabel;
// Shows the debris "Scrap" stat row (single selection) — the scrap total for the
// multi-object summary lives in m_entitySummaryLabel instead.
QLabel* m_scrapLabel;
};

View File

@@ -1,4 +1,7 @@
#include "GameWorldView.h" #include "GameWorldView.h"
#include "PlacementRules.h"
#include "FactoryQueries.h"
#include "ProductionRules.h"
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -50,6 +53,7 @@
#include "HealthComponent.h" #include "HealthComponent.h"
#include "HqProxyComponent.h" #include "HqProxyComponent.h"
#include "ItemIconCache.h" #include "ItemIconCache.h"
#include "PortGeometry.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "RepairBehavior.h" #include "RepairBehavior.h"
#include "SalvageScrapBehavior.h" #include "SalvageScrapBehavior.h"
@@ -166,18 +170,6 @@ Rotation rotateCounterClockwise(Rotation r)
return Rotation::East; return Rotation::East;
} }
QPoint portBodyTile(QPoint portTile, Rotation direction)
{
switch (direction)
{
case Rotation::East: return portTile + QPoint(-1, 0);
case Rotation::West: return portTile + QPoint( 1, 0);
case Rotation::North: return portTile + QPoint( 0, 1);
case Rotation::South: return portTile + QPoint( 0, -1);
}
return portTile;
}
// Fill color for a building's status light per its production state // Fill color for a building's status light per its production state
// (REQ-UI-STATUS-LIGHT). // (REQ-UI-STATUS-LIGHT).
QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl) QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl)
@@ -588,7 +580,7 @@ QRect GameWorldView::getViewportRect() const
float GameWorldView::getAsteroidLeftEdge() const float GameWorldView::getAsteroidLeftEdge() const
{ {
float leftX = -static_cast<float>(m_sim->getCurrentAsteroidWidth_tiles()); float leftX = -static_cast<float>(m_sim->getCurrentAsteroidWidth_tiles());
for (const Building& b : m_sim->getBuildings().getAllBuildings()) for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{ {
for (const QPoint& cell : b.bodyCells) for (const QPoint& cell : b.bodyCells)
{ {
@@ -671,7 +663,7 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
// Terrain and world-bounds validity are owned by the simulation // Terrain and world-bounds validity are owned by the simulation
// (REQ-BLD-PLACE-VALID); the presentation layer only adds the occupancy / // (REQ-BLD-PLACE-VALID); the presentation layer only adds the occupancy /
// rotate-in-place check. // rotate-in-place check.
if (!m_sim->getBuildings().isPlacementValid(type, anchor, rot)) if (!isPlacementValid(m_sim->getFactoryState(), m_sim->getConfig(), type, anchor, rot))
{ {
return false; return false;
} }
@@ -683,7 +675,7 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
bool anyOccupied = false; bool anyOccupied = false;
for (const QPoint& relCell : parsed.bodyCells) for (const QPoint& relCell : parsed.bodyCells)
{ {
if (m_sim->getBuildings().isTileOccupied(anchor + relCell)) if (isTileOccupied(m_sim->getFactoryState(), anchor + relCell))
{ {
anyOccupied = true; anyOccupied = true;
break; break;
@@ -692,14 +684,14 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
if (anyOccupied) if (anyOccupied)
{ {
return m_sim->getBuildings().findRotateInPlaceTarget(type, anchor, rot).has_value(); return findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), type, anchor, rot).has_value();
} }
return true; return true;
} }
std::optional<BuildingId> GameWorldView::buildingAtTile(QPoint tile) const std::optional<BuildingId> GameWorldView::buildingAtTile(QPoint tile) const
{ {
for (const Building& b : m_sim->getBuildings().getAllBuildings()) for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{ {
for (const QPoint& cell : b.bodyCells) for (const QPoint& cell : b.bodyCells)
{ {
@@ -714,7 +706,7 @@ std::optional<BuildingId> GameWorldView::buildingAtTile(QPoint tile) const
std::optional<BuildingId> GameWorldView::siteAtTile(QPoint tile) const std::optional<BuildingId> GameWorldView::siteAtTile(QPoint tile) const
{ {
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites()) for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState()))
{ {
for (const QPoint& cell : s.bodyCells) for (const QPoint& cell : s.bodyCells)
{ {
@@ -736,7 +728,7 @@ std::vector<BuildingId> GameWorldView::buildingsInBox(QPoint cornerA, QPoint cor
const int y1 = std::max(cornerA.y(), cornerB.y()); const int y1 = std::max(cornerA.y(), cornerB.y());
std::vector<BuildingId> ids; std::vector<BuildingId> ids;
for (const Building& b : m_sim->getBuildings().getAllBuildings()) for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{ {
for (const QPoint& cell : b.bodyCells) for (const QPoint& cell : b.bodyCells)
{ {
@@ -748,7 +740,7 @@ std::vector<BuildingId> GameWorldView::buildingsInBox(QPoint cornerA, QPoint cor
} }
} }
} }
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites()) for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState()))
{ {
for (const QPoint& cell : s.bodyCells) for (const QPoint& cell : s.bodyCells)
{ {
@@ -785,7 +777,7 @@ void GameWorldView::pruneDespawnedDebris()
if (m_selectedDebris.empty()) { return; } if (m_selectedDebris.empty()) { return; }
std::vector<entt::entity> live; 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) if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity)
!= m_selectedDebris.end()) != m_selectedDebris.end())
@@ -874,8 +866,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
for (const BlueprintBuilding& bb : bp.buildings) for (const BlueprintBuilding& bb : bp.buildings)
{ {
if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
if (m_sim->getBuildings().findRotateInPlaceTarget( if (findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), bb.type, center + bb.offset, bb.rotation).has_value())
bb.type, center + bb.offset, bb.rotation).has_value())
{ {
continue; continue;
} }
@@ -889,7 +880,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
const QPoint anchor = center + bb.offset; const QPoint anchor = center + bb.offset;
const std::optional<BuildingId> rotateTarget = const std::optional<BuildingId> rotateTarget =
m_sim->getBuildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation); findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), bb.type, anchor, bb.rotation);
if (rotateTarget.has_value()) if (rotateTarget.has_value())
{ {
std::shared_ptr<RotateInPlaceCommand> rotateCommand = std::shared_ptr<RotateInPlaceCommand> rotateCommand =
@@ -962,14 +953,14 @@ TunnelTileMap GameWorldView::collectTunnelTiles() const
// single-cell tile, so a just-placed tunnel (not yet constructed) is matchable // single-cell tile, so a just-placed tunnel (not yet constructed) is matchable
// (REQ-BLD-TUNNEL-MODE, REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). // (REQ-BLD-TUNNEL-MODE, REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
TunnelTileMap tunnels; TunnelTileMap tunnels;
for (const Building& b : m_sim->getBuildings().getAllBuildings()) for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{ {
if (b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit) if (b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit)
{ {
tunnels[b.anchor] = TunnelTileInfo{b.type, b.rotation}; tunnels[b.anchor] = TunnelTileInfo{b.type, b.rotation};
} }
} }
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites()) for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState()))
{ {
if (s.type == BuildingType::TunnelEntry || s.type == BuildingType::TunnelExit) if (s.type == BuildingType::TunnelEntry || s.type == BuildingType::TunnelExit)
{ {
@@ -1025,7 +1016,7 @@ void GameWorldView::placeAtTile(QPoint tile)
} }
const std::optional<BuildingId> rotateTarget = const std::optional<BuildingId> rotateTarget =
m_sim->getBuildings().findRotateInPlaceTarget(type, tile, m_ghostRotation); findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), type, tile, m_ghostRotation);
if (rotateTarget.has_value()) if (rotateTarget.has_value())
{ {
std::shared_ptr<RotateInPlaceCommand> command = std::shared_ptr<RotateInPlaceCommand> command =
@@ -1045,7 +1036,7 @@ void GameWorldView::placeAtTile(QPoint tile)
|| type == BuildingType::TunnelEntry || type == BuildingType::TunnelEntry
|| type == BuildingType::TunnelExit) || type == BuildingType::TunnelExit)
{ {
if (!m_sim->getBuildings().isTileOccupied(tile) && canAfford(type)) if (!isTileOccupied(m_sim->getFactoryState(), tile) && canAfford(type))
{ {
enqueuePlaceBuilding(type, tile, m_ghostRotation); enqueuePlaceBuilding(type, tile, m_ghostRotation);
} }
@@ -1072,7 +1063,7 @@ void GameWorldView::recomputeBeltDragPath(QPoint cursorTile)
std::optional<BuildingType> targetType; std::optional<BuildingType> targetType;
if (targetId.has_value()) if (targetId.has_value())
{ {
if (const Building* building = m_sim->getBuildings().findBuilding(*targetId)) if (const Building* building = findBuilding(m_sim->getFactoryState(), *targetId))
{ {
targetType = building->type; targetType = building->type;
} }
@@ -1080,7 +1071,7 @@ void GameWorldView::recomputeBeltDragPath(QPoint cursorTile)
else if (std::optional<BuildingId> siteId = siteAtTile(cursorTile); siteId.has_value()) else if (std::optional<BuildingId> siteId = siteAtTile(cursorTile); siteId.has_value())
{ {
targetId = siteId; targetId = siteId;
if (const ConstructionSite* site = m_sim->getBuildings().findSite(*siteId)) if (const ConstructionSite* site = findSite(m_sim->getFactoryState(), *siteId))
{ {
targetType = site->type; targetType = site->type;
} }
@@ -1089,7 +1080,7 @@ void GameWorldView::recomputeBeltDragPath(QPoint cursorTile)
if (targetId.has_value() && targetType.has_value() if (targetId.has_value() && targetType.has_value()
&& *targetType != BuildingType::Belt) && *targetType != BuildingType::Belt)
{ {
const std::vector<Port> inputPorts = m_sim->getBuildings().getInputPorts(*targetId); const std::vector<Port> inputPorts = getInputPorts(m_sim->getFactoryState(), m_sim->getConfig(), *targetId);
std::optional<Port> best; std::optional<Port> best;
float bestDistanceSq = 0.0f; float bestDistanceSq = 0.0f;
for (const Port& port : inputPorts) for (const Port& port : inputPorts)
@@ -1133,8 +1124,7 @@ std::vector<GameWorldView::BeltDragResolved> GameWorldView::resolveBeltDragPath(
{ {
BeltDragResolved item; BeltDragResolved item;
const std::optional<BuildingId> rotateTarget = const std::optional<BuildingId> rotateTarget =
m_sim->getBuildings().findRotateInPlaceTarget( findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), BuildingType::Belt, entry.tile, entry.rotation);
BuildingType::Belt, entry.tile, entry.rotation);
if (rotateTarget.has_value()) if (rotateTarget.has_value())
{ {
// A tile holding only a belt (or belt site) is re-oriented, no cost. // A tile holding only a belt (or belt site) is re-oriented, no cost.
@@ -1309,7 +1299,7 @@ bool GameWorldView::drawBuildingIcon(QPainter& painter, BuildingType type,
void GameWorldView::drawBuildings(QPainter& painter) void GameWorldView::drawBuildings(QPainter& painter)
{ {
for (const Building& b : m_sim->getBuildings().getAllBuildings()) for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{ {
const std::map<BuildingType, BuildingVisuals>::const_iterator it = const std::map<BuildingType, BuildingVisuals>::const_iterator it =
m_visuals->buildings.find(b.type); m_visuals->buildings.find(b.type);
@@ -1341,7 +1331,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
for (const Port& port : b.outputPorts) for (const Port& port : b.outputPorts)
{ {
drawPortGlyph(painter, portBodyTile(port.tile, port.direction), drawPortGlyph(painter, outputBodyTile(port.tile, port.direction),
port.direction, bv.outline, /*centered*/ false); port.direction, bv.outline, /*centered*/ false);
} }
@@ -1351,7 +1341,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
// building footprints are rectangular, so a corner of the axis-aligned // building footprints are rectangular, so a corner of the axis-aligned
// bounding box is the true corner. // bounding box is the true corner.
if (const std::optional<ProductionStatus> status = if (const std::optional<ProductionStatus> status =
m_sim->getBuildings().getProductionStatus(b)) getProductionStatus(m_sim->getConfig(), b))
{ {
const float px = getTilePx(); const float px = getTilePx();
const float r = px * 0.18f; const float r = px * 0.18f;
@@ -1376,7 +1366,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
} }
painter.setOpacity(0.5); painter.setOpacity(0.5);
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites()) for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState()))
{ {
const std::map<BuildingType, BuildingVisuals>::const_iterator it = const std::map<BuildingType, BuildingVisuals>::const_iterator it =
m_visuals->buildings.find(s.type); m_visuals->buildings.find(s.type);
@@ -1441,7 +1431,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
for (const Port& port : siteMask.outputPorts) for (const Port& port : siteMask.outputPorts)
{ {
const QPoint absBody = s.anchor const QPoint absBody = s.anchor
+ portBodyTile(port.tile, port.direction); + outputBodyTile(port.tile, port.direction);
drawPortGlyph(painter, absBody, port.direction, bv.outline, drawPortGlyph(painter, absBody, port.direction, bv.outline,
/*centered*/ false); /*centered*/ false);
} }
@@ -1453,7 +1443,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
// after every building and construction site fill so a belt (or other tile) // after every building and construction site fill so a belt (or other tile)
// placed directly below the HQ cannot overpaint the bar (REQ-UI-STATUS-LIGHT // placed directly below the HQ cannot overpaint the bar (REQ-UI-STATUS-LIGHT
// neighbours case, same rationale as the selection highlights below). // neighbours case, same rationale as the selection highlights below).
for (const Building& b : m_sim->getBuildings().getAllBuildings()) for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{ {
if (b.type != BuildingType::Hq) { continue; } if (b.type != BuildingType::Hq) { continue; }
const QPointF tl = tileToWidget(b.anchor); const QPointF tl = tileToWidget(b.anchor);
@@ -1484,12 +1474,12 @@ std::optional<QRectF> GameWorldView::footprintWidgetRect(BuildingId id) const
std::optional<QPoint> anchor; std::optional<QPoint> anchor;
std::optional<QSize> footprint; std::optional<QSize> footprint;
if (const Building* b = m_sim->getBuildings().findBuilding(id)) if (const Building* b = findBuilding(m_sim->getFactoryState(), id))
{ {
anchor = b->anchor; anchor = b->anchor;
footprint = b->footprint; footprint = b->footprint;
} }
else if (const ConstructionSite* s = m_sim->getBuildings().findSite(id)) else if (const ConstructionSite* s = findSite(m_sim->getFactoryState(), id))
{ {
anchor = s->anchor; anchor = s->anchor;
footprint = s->footprint; footprint = s->footprint;
@@ -1520,7 +1510,7 @@ void GameWorldView::drawSelectionHighlights(QPainter& painter)
if (!m_selectedDebris.empty()) if (!m_selectedDebris.empty())
{ {
const qreal outlineRadius = static_cast<qreal>(getTilePx() * 0.2f) + 3.0; 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) if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), debris.entity)
== m_selectedDebris.end()) { continue; } == m_selectedDebris.end()) { continue; }
@@ -1541,13 +1531,13 @@ void GameWorldView::drawCopyConfigFeedback(QPainter& painter)
painter.setPen(Qt::NoPen); painter.setPen(Qt::NoPen);
painter.setBrush(color); painter.setBrush(color);
const BuildingType type = m_copiedConfig->type; const BuildingType type = m_copiedConfig->type;
for (const Building& b : m_sim->getBuildings().getAllBuildings()) for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{ {
if (b.type != type) { continue; } if (b.type != type) { continue; }
const std::optional<QRectF> rect = footprintWidgetRect(b.id); const std::optional<QRectF> rect = footprintWidgetRect(b.id);
if (rect.has_value()) { painter.drawRect(*rect); } if (rect.has_value()) { painter.drawRect(*rect); }
} }
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites()) for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState()))
{ {
if (s.type != type) { continue; } if (s.type != type) { continue; }
const std::optional<QRectF> rect = footprintWidgetRect(s.id); const std::optional<QRectF> rect = footprintWidgetRect(s.id);
@@ -1582,7 +1572,7 @@ void GameWorldView::drawPortItems(QPainter& painter)
const double margin = kPortMarginTiles * static_cast<double>(getTilePx()); const double margin = kPortMarginTiles * static_cast<double>(getTilePx());
QRegion clip(rect()); QRegion clip(rect());
for (const Building& b : m_sim->getBuildings().getAllBuildings()) for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{ {
if (b.type == BuildingType::Belt || b.type == BuildingType::Splitter if (b.type == BuildingType::Belt || b.type == BuildingType::Splitter
|| b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit) || b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit)
@@ -1615,8 +1605,8 @@ void GameWorldView::drawPortItems(QPainter& painter)
painter.save(); painter.save();
painter.setClipRegion(clip); painter.setClipRegion(clip);
m_sim->getBuildings().forEachEmergingItem(drawItem); m_sim->getBuildings().forEachEmergingItem(m_sim->getFactoryState(), drawItem);
m_sim->getBuildings().forEachIncomingItem(drawItem); m_sim->getBuildings().forEachIncomingItem(m_sim->getFactoryState(), drawItem);
painter.restore(); painter.restore();
} }
@@ -1664,7 +1654,7 @@ void GameWorldView::drawBeltItems(QPainter& painter)
void GameWorldView::drawDebris(QPainter& painter) void GameWorldView::drawDebris(QPainter& painter)
{ {
const float r = getTilePx() * 0.2f; 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); const QPointF center = worldToWidget(debris.position);
painter.setBrush(QColor(128, 110, 90)); painter.setBrush(QColor(128, 110, 90));
@@ -1993,11 +1983,11 @@ void GameWorldView::drawSelectedTunnelConnections(QPainter& painter)
std::optional<QPoint> anchor; std::optional<QPoint> anchor;
std::optional<BuildingType> type; std::optional<BuildingType> type;
Rotation rotation = Rotation::East; Rotation rotation = Rotation::East;
if (const Building* b = m_sim->getBuildings().findBuilding(id)) if (const Building* b = findBuilding(m_sim->getFactoryState(), id))
{ {
anchor = b->anchor; type = b->type; rotation = b->rotation; anchor = b->anchor; type = b->type; rotation = b->rotation;
} }
else if (const ConstructionSite* s = m_sim->getBuildings().findSite(id)) else if (const ConstructionSite* s = findSite(m_sim->getFactoryState(), id))
{ {
anchor = s->anchor; type = s->type; rotation = s->rotation; anchor = s->anchor; type = s->type; rotation = s->rotation;
} }
@@ -2102,7 +2092,7 @@ void GameWorldView::drawOverlays(QPainter& painter)
// Queued for deconstruction: tint every building currently in the // Queued for deconstruction: tint every building currently in the
// deconstruction queue, regardless of mode (REQ-BLD-DECON-QUEUE). // deconstruction queue, regardless of mode (REQ-BLD-DECON-QUEUE).
for (const Building& b : m_sim->getBuildings().getAllBuildings()) for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{ {
if (!b.queuedForDeconstruction) { continue; } if (!b.queuedForDeconstruction) { continue; }
for (const QPoint& cell : b.bodyCells) for (const QPoint& cell : b.bodyCells)
@@ -2117,12 +2107,12 @@ void GameWorldView::drawOverlays(QPainter& painter)
{ {
for (BuildingId id : buildingsInBox(m_boxStartTile, m_boxCurrentTile)) for (BuildingId id : buildingsInBox(m_boxStartTile, m_boxCurrentTile))
{ {
const Building* b = m_sim->getBuildings().findBuilding(id); const Building* b = findBuilding(m_sim->getFactoryState(), id);
if (b && b->type == BuildingType::Hq) { continue; } if (b && b->type == BuildingType::Hq) { continue; }
const std::vector<QPoint>* cells = nullptr; const std::vector<QPoint>* cells = nullptr;
const ConstructionSite* s = nullptr; const ConstructionSite* s = nullptr;
if (b) { cells = &b->bodyCells; } if (b) { cells = &b->bodyCells; }
else if ((s = m_sim->getBuildings().findSite(id))) { cells = &s->bodyCells; } else if ((s = findSite(m_sim->getFactoryState(), id))) { cells = &s->bodyCells; }
if (cells) if (cells)
{ {
for (const QPoint& cell : *cells) for (const QPoint& cell : *cells)
@@ -2134,7 +2124,7 @@ void GameWorldView::drawOverlays(QPainter& painter)
} }
else if (m_deconstructMode && m_deconstructHoverBuildingId.has_value()) else if (m_deconstructMode && m_deconstructHoverBuildingId.has_value())
{ {
const Building* b = m_sim->getBuildings().findBuilding(*m_deconstructHoverBuildingId); const Building* b = findBuilding(m_sim->getFactoryState(), *m_deconstructHoverBuildingId);
if (b) if (b)
{ {
for (const QPoint& cell : b->bodyCells) for (const QPoint& cell : b->bodyCells)
@@ -2215,7 +2205,7 @@ void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type,
for (const Port& port : parsed.outputPorts) for (const Port& port : parsed.outputPorts)
{ {
drawPortGlyph(painter, anchorTile + portBodyTile(port.tile, port.direction), drawPortGlyph(painter, anchorTile + outputBodyTile(port.tile, port.direction),
port.direction, lineColor, /*centered*/ false); port.direction, lineColor, /*centered*/ false);
} }
@@ -2759,7 +2749,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
if (m_deconstructMode) if (m_deconstructMode)
{ {
const BuildingSystem& buildings = m_sim->getBuildings(); const FactoryState& factory = m_sim->getFactoryState();
// Split covered ids into construction sites (removed instantly) and // Split covered ids into construction sites (removed instantly) and
// operational deconstructible buildings (the HQ is protected; player // operational deconstructible buildings (the HQ is protected; player
@@ -2768,12 +2758,12 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
std::vector<BuildingId> operational; std::vector<BuildingId> operational;
for (BuildingId id : boxIds) for (BuildingId id : boxIds)
{ {
if (const Building* b = buildings.findBuilding(id)) if (const Building* b = findBuilding(factory, id))
{ {
if (b->type == BuildingType::Hq) { continue; } if (b->type == BuildingType::Hq) { continue; }
operational.push_back(id); operational.push_back(id);
} }
else if (buildings.findSite(id)) else if (findSite(factory, id))
{ {
sites.push_back(id); sites.push_back(id);
} }
@@ -2795,7 +2785,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
bool allQueued = !operational.empty(); bool allQueued = !operational.empty();
for (BuildingId id : operational) for (BuildingId id : operational)
{ {
if (!buildings.isQueuedForDeconstruction(id)) { allQueued = false; break; } if (!isQueuedForDeconstruction(factory, id)) { allQueued = false; break; }
} }
for (BuildingId id : operational) for (BuildingId id : operational)
{ {
@@ -2806,7 +2796,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
command->id = id; command->id = id;
enqueueCommand(command); enqueueCommand(command);
} }
else if (!buildings.isQueuedForDeconstruction(id)) else if (!isQueuedForDeconstruction(factory, id))
{ {
std::shared_ptr<DeconstructCommand> command = std::shared_ptr<DeconstructCommand> command =
std::make_shared<DeconstructCommand>(); std::make_shared<DeconstructCommand>();
@@ -3008,7 +2998,7 @@ void GameWorldView::pasteConfigTo(BuildingId id)
{ {
// Operational splitters are configured by tile; sites by BuildingId // Operational splitters are configured by tile; sites by BuildingId
// (mirrors SelectedBuildingPanel::onSplitterFilterChanged). // (mirrors SelectedBuildingPanel::onSplitterFilterChanged).
if (const Building* building = m_sim->getBuildings().findBuilding(id)) if (const Building* building = findBuilding(m_sim->getFactoryState(), id))
{ {
std::shared_ptr<SetSplitterFiltersCommand> command = std::shared_ptr<SetSplitterFiltersCommand> command =
std::make_shared<SetSplitterFiltersCommand>(); std::make_shared<SetSplitterFiltersCommand>();

View File

@@ -1,4 +1,5 @@
#include "MainWindow.h" #include "MainWindow.h"
#include "FactoryQueries.h"
#include <map> #include <map>
#include <random> #include <random>
@@ -277,9 +278,9 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
{ {
// A construction site has no Building yet; fall back to its site record so // A construction site has no Building yet; fall back to its site record so
// the shipyard layout can be configured before it is built (REQ-BLD-SITE-CONFIG). // the shipyard layout can be configured before it is built (REQ-BLD-SITE-CONFIG).
const Building* b = m_sim->getBuildings().findBuilding(event->shipyardId); const Building* b = findBuilding(m_sim->getFactoryState(), event->shipyardId);
const ConstructionSite* s = const ConstructionSite* s =
b ? nullptr : m_sim->getBuildings().findSite(event->shipyardId); b ? nullptr : findSite(m_sim->getFactoryState(), event->shipyardId);
if (!b && !s) if (!b && !s)
{ {
return; return;
@@ -304,9 +305,9 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
// A construction site has no Building yet; fall back to its site record so // A construction site has no Building yet; fall back to its site record so
// the recipe/schematic can be chosen before it is built (REQ-BLD-SITE-CONFIG). // the recipe/schematic can be chosen before it is built (REQ-BLD-SITE-CONFIG).
const Building* b = m_sim->getBuildings().findBuilding(event->buildingId); const Building* b = findBuilding(m_sim->getFactoryState(), event->buildingId);
const ConstructionSite* s = const ConstructionSite* s =
b ? nullptr : m_sim->getBuildings().findSite(event->buildingId); b ? nullptr : findSite(m_sim->getFactoryState(), event->buildingId);
if (!b && !s) if (!b && !s)
{ {
return; return;

View File

@@ -1,4 +1,5 @@
#include "SelectedBuildingPanel.h" #include "SelectedBuildingPanel.h"
#include "FactoryQueries.h"
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -9,26 +10,14 @@
#include <QLabel> #include <QLabel>
#include <QListWidget> #include <QListWidget>
#include <QPushButton> #include <QPushButton>
#include <QStringList>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Command.h" #include "Command.h"
#include "CommandRequestedEvent.h" #include "CommandRequestedEvent.h"
#include "DisplayName.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "EntitySelectionChangedEvent.h" #include "EntitySelectionChangedEvent.h"
#include "EventManager.h" #include "EventManager.h"
#include "FactionComponent.h" #include "FieldSelectionPanel.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "SelectedBehaviorComponent.h"
#include "ShipIdentityComponent.h"
#include "ShipStatsCalculator.h"
#include "ShipStatsPanel.h"
#include "ThreatCostCalculator.h"
#include "StationBodyComponent.h"
#include "TickAdvancedEvent.h" #include "TickAdvancedEvent.h"
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
@@ -40,10 +29,8 @@
#include "RecipeSelectionDialog.h" #include "RecipeSelectionDialog.h"
#include "RecipeSelectionRequestedEvent.h" #include "RecipeSelectionRequestedEvent.h"
#include "Rotation.h" #include "Rotation.h"
#include "DebrisSystem.h"
#include "ShipLayoutPreview.h" #include "ShipLayoutPreview.h"
#include "Simulation.h" #include "Simulation.h"
#include "WeaponComponent.h"
namespace namespace
{ {
@@ -98,20 +85,6 @@ bool hasRecipeSelection(BuildingType type)
|| type == BuildingType::Shipyard; || type == BuildingType::Shipyard;
} }
// Auto-recipe buildings have no selected recipe; their production is driven by
// whatever inputs they receive.
bool isAutoRecipeBuilding(BuildingType type)
{
return type == BuildingType::Smelter
|| type == BuildingType::ReprocessingPlant;
}
bool isBeltLike(BuildingType type)
{
return type == BuildingType::Belt || type == BuildingType::Splitter
|| type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit;
}
QString rotationLabel(Rotation r) QString rotationLabel(Rotation r)
{ {
switch (r) switch (r)
@@ -182,30 +155,10 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
connect(m_filterBList, &QListWidget::itemChanged, connect(m_filterBList, &QListWidget::itemChanged,
this, &SelectedBuildingPanel::onSplitterFilterChanged); this, &SelectedBuildingPanel::onSplitterFilterChanged);
m_entityTitleLabel = new QLabel(this); // The field selection renders below the building content and hides itself while
QFont titleFont = m_entityTitleLabel->font(); // nothing field-side is selected, so it costs no space then.
titleFont.setBold(true); m_fieldSelectionPanel = new FieldSelectionPanel(sim, config, this);
m_entityTitleLabel->setFont(titleFont); m_layout->addWidget(m_fieldSelectionPanel);
m_layout->addWidget(m_entityTitleLabel);
m_entityTitleLabel->hide();
m_entityStatsPanel = new ShipStatsPanel(config, this);
m_layout->addWidget(m_entityStatsPanel);
m_entityStatsPanel->hide();
m_stationStatsLabel = new QLabel(this);
m_stationStatsLabel->setWordWrap(true);
m_layout->addWidget(m_stationStatsLabel);
m_stationStatsLabel->hide();
m_entitySummaryLabel = new QLabel(this);
m_entitySummaryLabel->setWordWrap(true);
m_layout->addWidget(m_entitySummaryLabel);
m_entitySummaryLabel->hide();
m_scrapLabel = new QLabel(this);
m_layout->addWidget(m_scrapLabel);
m_scrapLabel->hide();
buildEmpty(); buildEmpty();
@@ -224,13 +177,21 @@ void SelectedBuildingPanel::onSelectionChanged(const std::vector<BuildingId>& id
{ {
// A building selection is exclusive: it supersedes any field selection — // A building selection is exclusive: it supersedes any field selection —
// actors and scrap (REQ-UI-SELECTION-CATEGORIES). // actors and scrap (REQ-UI-SELECTION-CATEGORIES).
clearEntityDisplay(); m_fieldSelectionPanel->clearSelection();
m_selectedDebris.clear();
m_scrapLabel->hide();
} }
rebuild(); rebuild();
} }
void SelectedBuildingPanel::yieldToFieldSelection()
{
// The mirror image of onSelectionChanged(): a field selection — actors, debris, or
// both — supersedes any building selection (REQ-UI-SELECTION-CATEGORIES). An empty
// field selection changes nothing here: the building content, if any, keeps the panel.
if (!m_fieldSelectionPanel->hasSelection()) { return; }
m_selectedBuildingIds.clear();
buildEmpty();
}
void SelectedBuildingPanel::rebuild() void SelectedBuildingPanel::rebuild()
{ {
if (m_selectedBuildingIds.empty()) if (m_selectedBuildingIds.empty())
@@ -259,22 +220,14 @@ void SelectedBuildingPanel::hideAllWidgets()
m_filterBLabel->hide(); m_filterBLabel->hide();
m_filterBList->hide(); m_filterBList->hide();
m_buffersLabel->hide(); m_buffersLabel->hide();
m_scrapLabel->hide();
}
void SelectedBuildingPanel::clearContent()
{
m_singleBuildingId = std::nullopt;
hideAllWidgets();
} }
void SelectedBuildingPanel::buildEmpty() void SelectedBuildingPanel::buildEmpty()
{ {
clearContent(); // Shows nothing for the building category — either because nothing is selected or
m_entityTitleLabel->hide(); // because the field category has taken the panel over.
m_entityStatsPanel->hide(); m_singleBuildingId = std::nullopt;
m_stationStatsLabel->hide(); hideAllWidgets();
m_entitySummaryLabel->hide();
} }
void SelectedBuildingPanel::buildSingle(BuildingId id) void SelectedBuildingPanel::buildSingle(BuildingId id)
@@ -282,8 +235,8 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
m_singleBuildingId = id; m_singleBuildingId = id;
hideAllWidgets(); hideAllWidgets();
const Building* b = m_sim->getBuildings().findBuilding(id); const Building* b = findBuilding(m_sim->getFactoryState(), id);
const ConstructionSite* s = b ? nullptr : m_sim->getBuildings().findSite(id); const ConstructionSite* s = b ? nullptr : findSite(m_sim->getFactoryState(), id);
if (!b && !s) if (!b && !s)
{ {
buildEmpty(); buildEmpty();
@@ -347,7 +300,7 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
// Belt "Clear" removes items from a live belt tile; a construction site has // Belt "Clear" removes items from a live belt tile; a construction site has
// none and is not registered with BeltSystem yet, so hide it for sites. // none and is not registered with BeltSystem yet, so hide it for sites.
if (isBeltLike(type) && !m_singleIsSite) if (isBeltSubsystemType(type) && !m_singleIsSite)
{ {
m_clearBeltBtn->show(); m_clearBeltBtn->show();
} }
@@ -361,7 +314,7 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
std::optional<BeltSystem::SplitterInfo> info; std::optional<BeltSystem::SplitterInfo> info;
if (m_singleIsSite) if (m_singleIsSite)
{ {
info = m_sim->getBuildings().getSiteSplitterInfo(id); info = getSiteSplitterInfo(m_sim->getFactoryState(), m_sim->getConfig(), id);
} }
else else
{ {
@@ -428,7 +381,7 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
// Auto-recipe buildings (Smelter, Reprocessing Plant) have no selected // Auto-recipe buildings (Smelter, Reprocessing Plant) have no selected
// recipe; while a cycle runs, resolve the recipe actually in production so // recipe; while a cycle runs, resolve the recipe actually in production so
// the cycle time and progress can be shown (REQ-UI-PRODUCTION-PROGRESS). // the cycle time and progress can be shown (REQ-UI-PRODUCTION-PROGRESS).
if (!recipe && isAutoRecipeBuilding(b->type) && b->production.has_value()) if (!recipe && isAutoRecipeBuildingType(b->type) && b->production.has_value())
{ {
recipe = m_config->recipes.findRecipeDef(b->production->recipeId, b->type); recipe = m_config->recipes.findRecipeDef(b->production->recipeId, b->type);
} }
@@ -522,7 +475,7 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
} }
if (isProductionBuilding(b->type) if (isProductionBuilding(b->type)
&& (recipe || shipDef || isAutoRecipeBuilding(b->type))) && (recipe || shipDef || isAutoRecipeBuildingType(b->type)))
{ {
if (recipe || shipDef) if (recipe || shipDef)
{ {
@@ -646,29 +599,11 @@ void SelectedBuildingPanel::handleEvent(
void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason) void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
{ {
if (!m_selectedEntities.empty() || !m_selectedDebris.empty()) // Only a single selected building has live content to refresh. While the field
{ // category owns the panel there is none: yieldToFieldSelection() has cleared it, so
// Field selection. Keep the live values current: the single-actor stats panel, // this returns immediately and the field panel refreshes itself off the same events.
// the single-debris stats panel (whose Scrap row shrinks as it is collected), or
// the count summary (whose Scrap line shrinks likewise) — matching the layout
// chosen by buildFieldSelection() (REQ-UI-SHIP-STATS-PANEL, REQ-UI-DEBRIS-PANEL).
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
{
refreshEntityStats();
}
else if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
{
buildDebrisSingle();
}
else
{
buildEntitySummary();
}
return;
}
if (!m_singleBuildingId.has_value()) { return; } if (!m_singleBuildingId.has_value()) { return; }
const Building* b = m_sim->getBuildings().findBuilding(*m_singleBuildingId); const Building* b = findBuilding(m_sim->getFactoryState(), *m_singleBuildingId);
if (b) if (b)
{ {
if (m_titleLabel->text().startsWith(tr("(Building) "))) if (m_titleLabel->text().startsWith(tr("(Building) ")))
@@ -681,7 +616,7 @@ void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
} }
return; return;
} }
const ConstructionSite* s = m_sim->getBuildings().findSite(*m_singleBuildingId); const ConstructionSite* s = findSite(m_sim->getFactoryState(), *m_singleBuildingId);
if (s) if (s)
{ {
// A periodic tick only advances construction progress, so update just the // A periodic tick only advances construction progress, so update just the
@@ -715,13 +650,13 @@ void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
std::map<BuildingType, int> counts; std::map<BuildingType, int> counts;
for (BuildingId id : ids) for (BuildingId id : ids)
{ {
const Building* b = m_sim->getBuildings().findBuilding(id); const Building* b = findBuilding(m_sim->getFactoryState(), id);
if (b) if (b)
{ {
counts[b->type]++; counts[b->type]++;
continue; continue;
} }
const ConstructionSite* s = m_sim->getBuildings().findSite(id); const ConstructionSite* s = findSite(m_sim->getFactoryState(), id);
if (s) if (s)
{ {
counts[s->type]++; counts[s->type]++;
@@ -735,7 +670,7 @@ void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
{ {
text += buildingTypeName(entry.first) + " x " text += buildingTypeName(entry.first) + " x "
+ QString::number(entry.second) + "\n"; + QString::number(entry.second) + "\n";
if (isBeltLike(entry.first)) if (isBeltSubsystemType(entry.first))
{ {
hasBelt = true; hasBelt = true;
} }
@@ -883,8 +818,8 @@ void SelectedBuildingPanel::onClearBelt()
std::vector<QPoint> tiles; std::vector<QPoint> tiles;
for (BuildingId id : m_selectedBuildingIds) for (BuildingId id : m_selectedBuildingIds)
{ {
const Building* b = m_sim->getBuildings().findBuilding(id); const Building* b = findBuilding(m_sim->getFactoryState(), id);
if (b && isBeltLike(b->type)) if (b && isBeltSubsystemType(b->type))
{ {
for (const QPoint& cell : b->bodyCells) for (const QPoint& cell : b->bodyCells)
{ {
@@ -904,259 +839,8 @@ void SelectedBuildingPanel::onClearBelt()
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) void SelectedBuildingPanel::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event)
{ {
m_selectedEntities = event->entities; m_fieldSelectionPanel->setSelectedEntities(event->entities);
if (!m_selectedEntities.empty()) yieldToFieldSelection();
{
// A field selection supersedes any building selection (REQ-UI-SELECTION-CATEGORIES).
m_selectedBuildingIds.clear();
}
buildFieldSelection();
}
void SelectedBuildingPanel::buildFieldSelection()
{
if (m_selectedEntities.empty() && m_selectedDebris.empty())
{
// Nothing in the field category. Fall back to empty unless buildings own the panel.
clearEntityDisplay();
m_scrapLabel->hide();
if (m_selectedBuildingIds.empty())
{
buildEmpty();
}
return;
}
// A field selection owns the panel: drop any building content.
clearContent();
EntityAdmin& admin = m_sim->getAdmin();
// A full single-object stats panel is shown only for a lone field object: one actor
// with no debris, or one piece of debris with no actors. As soon as the selection holds
// more than one object (multiple actors, multiple debris, or actors plus debris), the
// panel switches to the compact count summary (REQ-UI-FIELD-MULTI-SELECTION).
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
{
m_entitySummaryLabel->hide();
m_scrapLabel->hide();
const entt::entity entity = m_selectedEntities.front();
if (admin.isValid(entity) && admin.hasAll<ShipIdentityComponent>(entity))
{
buildEntityShip(entity);
}
else if (admin.isValid(entity) && admin.hasAll<StationBodyComponent>(entity))
{
buildEntityStation(entity);
}
else
{
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
}
return;
}
if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
{
// Single piece of debris: a "Debris" heading plus a "Scrap" stat row, styled like
// the ship/station stats panels (REQ-UI-DEBRIS-PANEL).
m_entitySummaryLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
buildDebrisSingle();
return;
}
// More than one field object: a compact count summary. buildEntitySummary() appends the
// "Debris x N" and "Scrap x N" lines when debris is part of the selection.
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_scrapLabel->hide();
buildEntitySummary();
}
void SelectedBuildingPanel::buildDebrisSingle()
{
// "Debris" heading + a single "Scrap" stat row for the piece's remaining amount,
// mirroring the single-actor stats panels (REQ-UI-DEBRIS-PANEL).
m_entityTitleLabel->setText(tr("Debris"));
m_entityTitleLabel->show();
m_scrapLabel->setText(tr("Scrap: %1").arg(selectedDebrisScrapTotal()));
m_scrapLabel->show();
}
void SelectedBuildingPanel::buildEntitySummary()
{
EntityAdmin& admin = m_sim->getAdmin();
// Group actors by faction + kind + ship schematic, preserving first-seen order
// (REQ-UI-FIELD-MULTI-SELECTION).
std::vector<QString> keys;
std::map<QString, int> counts;
std::map<QString, QString> labels;
for (entt::entity entity : m_selectedEntities)
{
if (!admin.isValid(entity)) { continue; }
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
&& admin.get<FactionComponent>(entity).isEnemy;
QString key;
QString label;
if (admin.hasAll<ShipIdentityComponent>(entity))
{
const std::string& id = admin.get<ShipIdentityComponent>(entity).schematicId;
const QString name = QString::fromStdString(toDisplayName(id));
key = (isEnemy ? QStringLiteral("ship:enemy:") : QStringLiteral("ship:player:"))
+ QString::fromStdString(id);
label = isEnemy ? tr("Enemy %1").arg(name) : name;
}
else if (admin.hasAll<StationBodyComponent>(entity))
{
key = isEnemy ? QStringLiteral("station:enemy") : QStringLiteral("station:player");
label = isEnemy ? tr("Enemy Defence Station") : tr("Player Defence Station");
}
else
{
continue;
}
if (counts.find(key) == counts.end())
{
keys.push_back(key);
labels[key] = label;
}
counts[key] += 1;
}
// One "<type> x <count>" line per group (matching the recipe tooltip and the building
// multi-selection). No total-count header, consistent with the building panel. When
// debris is part of the selection, a "Debris x <count>" line followed by a
// "Scrap x <total>" line are appended into the same label so the line spacing is
// uniform (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL).
QStringList lines;
for (const QString& key : keys)
{
lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]);
}
if (!m_selectedDebris.empty())
{
lines << tr("Debris x %1").arg(static_cast<int>(m_selectedDebris.size()));
lines << scrapTotalText();
}
m_entitySummaryLabel->setText(lines.join('\n'));
m_entitySummaryLabel->show();
}
void SelectedBuildingPanel::buildEntityShip(entt::entity entity)
{
EntityAdmin& admin = m_sim->getAdmin();
const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(entity);
const HealthComponent& health = admin.get<HealthComponent>(entity);
m_entityTitleLabel->setText(tr("Ship: %1")
.arg(QString::fromStdString(identity.schematicId)));
m_entityTitleLabel->show();
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw);
const ShipDef* schematicDef =
m_config->ships.findShipDef(identity.schematicId);
if (schematicDef)
{
const double threat = calculateShipThreatCost(
m_config->threatCosts, *m_config, schematicDef->id,
schematicDef->defaultModules);
m_entityStatsPanel->setThreatCost(threat);
}
m_entityStatsPanel->show();
m_stationStatsLabel->hide();
}
void SelectedBuildingPanel::buildEntityStation(entt::entity entity)
{
EntityAdmin& admin = m_sim->getAdmin();
const HealthComponent& health = admin.get<HealthComponent>(entity);
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
&& admin.get<FactionComponent>(entity).isEnemy;
m_entityTitleLabel->setText(isEnemy
? tr("Enemy Defence Station")
: tr("Player Defence Station"));
m_entityTitleLabel->show();
float totalDps = 0.0f;
float maxRange = 0.0f;
bool hasWeapons = false;
admin.forEach<ModuleOwnerComponent, WeaponComponent>(
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w)
{
if (owner.owner != entity) { return; }
hasWeapons = true;
totalDps += w.damage * w.fireRateHz;
if (w.range_tiles > maxRange) { maxRange = w.range_tiles; }
});
QString statsText = tr("HP: %1 / %2")
.arg(static_cast<int>(health.hp + 0.5f))
.arg(static_cast<int>(health.maxHp + 0.5f));
if (hasWeapons)
{
statsText += tr("\nDPS: %1").arg(QString::number(static_cast<double>(totalDps), 'f', 1));
statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast<double>(maxRange), 'f', 1));
}
m_stationStatsLabel->setText(statsText);
m_stationStatsLabel->show();
m_entityStatsPanel->hide();
}
void SelectedBuildingPanel::refreshEntityStats()
{
// Only the single-actor stats panel needs a live refresh; the multi-actor summary is
// static counts, and GameWorldView prunes dead/despawned actors and re-emits the
// selection (REQ-UI-ENTITY-CLICK-SELECT), so the panel does not mutate it here.
if (m_selectedEntities.size() != 1) { return; }
EntityAdmin& admin = m_sim->getAdmin();
const entt::entity entity = m_selectedEntities.front();
if (!admin.isValid(entity) || !admin.hasAll<HealthComponent>(entity)) { return; }
const HealthComponent& health = admin.get<HealthComponent>(entity);
if (health.hp <= 0.0f) { return; }
if (admin.hasAll<ShipIdentityComponent>(entity))
{
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
}
else if (admin.hasAll<StationBodyComponent>(entity))
{
buildEntityStation(entity);
}
}
void SelectedBuildingPanel::clearEntityDisplay()
{
m_selectedEntities.clear();
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_entitySummaryLabel->hide();
} }
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event) void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
@@ -1167,38 +851,8 @@ void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEv
void SelectedBuildingPanel::handleEvent( void SelectedBuildingPanel::handleEvent(
std::shared_ptr<const DebrisSelectionChangedEvent> event) std::shared_ptr<const DebrisSelectionChangedEvent> event)
{ {
m_selectedDebris = event->debris;
if (!m_selectedDebris.empty())
{
// Debris is a field object: it supersedes any building selection but coexists // Debris is a field object: it supersedes any building selection but coexists
// with actors (REQ-UI-SELECTION-CATEGORIES). // with actors (REQ-UI-SELECTION-CATEGORIES).
m_selectedBuildingIds.clear(); m_fieldSelectionPanel->setSelectedDebris(event->debris);
} yieldToFieldSelection();
buildFieldSelection();
}
int SelectedBuildingPanel::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())
{
if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity)
!= m_selectedDebris.end())
{
total += info.amount;
}
}
return total;
}
QString SelectedBuildingPanel::scrapTotalText() const
{
return tr("Scrap x %1").arg(selectedDebrisScrapTotal());
}
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)
{
m_debugDraw = event->active;
m_entityStatsPanel->setDebugDrawEnabled(event->active);
} }

View File

@@ -7,12 +7,9 @@
#include <QPoint> #include <QPoint>
#include <QWidget> #include <QWidget>
#include "entt/entity/entity.hpp"
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Building.h" #include "Building.h"
#include "BuildingId.h" #include "BuildingId.h"
#include "DebugDrawToggledEvent.h"
#include "EntitySelectionChangedEvent.h" #include "EntitySelectionChangedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "GameConfig.h" #include "GameConfig.h"
@@ -26,20 +23,27 @@
#include "TickAdvancedEvent.h" #include "TickAdvancedEvent.h"
class Simulation; class Simulation;
class FieldSelectionPanel;
class ShipLayoutPreview; class ShipLayoutPreview;
class ShipStatsPanel;
class QLabel; class QLabel;
class QListWidget; class QListWidget;
class QPushButton; class QPushButton;
class QVBoxLayout; class QVBoxLayout;
// Shows the current selection. The building category (buildings and construction sites)
// is rendered by this panel itself; the field category (ships, defence stations, debris)
// is rendered by the embedded FieldSelectionPanel.
//
// The two categories are mutually exclusive (REQ-UI-SELECTION-CATEGORIES) and this panel
// is the sole arbiter of which one owns the content: it listens to all three selection
// events, forwards the field ones to the child panel, and drops the losing category's
// content. Neither panel touches the other's widgets.
class SelectedBuildingPanel : public QWidget, class SelectedBuildingPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent, public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent, PlayerCommandsAppliedEvent,
EntitySelectionChangedEvent, EntitySelectionChangedEvent,
SelectionChangedEvent, SelectionChangedEvent,
DebrisSelectionChangedEvent, DebrisSelectionChangedEvent>
DebugDrawToggledEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -54,7 +58,6 @@ private:
void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override; void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override; void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebrisSelectionChangedEvent> event) override; void handleEvent(std::shared_ptr<const DebrisSelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
private slots: private slots:
void onSelectRecipeClicked(); void onSelectRecipeClicked();
@@ -73,17 +76,14 @@ private:
}; };
void onSelectionChanged(const std::vector<BuildingId>& ids); void onSelectionChanged(const std::vector<BuildingId>& ids);
// Gives the panel to the field category once it has anything selected.
void yieldToFieldSelection();
void refreshSelectionDisplay(RefreshReason reason); void refreshSelectionDisplay(RefreshReason reason);
void rebuild(); void rebuild();
void hideAllWidgets(); void hideAllWidgets();
void clearContent();
void buildEmpty(); void buildEmpty();
void buildSingle(BuildingId id); void buildSingle(BuildingId id);
void buildMulti(const std::vector<BuildingId>& ids); void buildMulti(const std::vector<BuildingId>& ids);
// Summed remaining scrap across the selected debris (REQ-UI-DEBRIS-PANEL).
int selectedDebrisScrapTotal() const;
// "Scrap x N" line for the multi-object summary (REQ-UI-FIELD-MULTI-SELECTION).
QString scrapTotalText() const;
void refreshBuffers(const Building* b); void refreshBuffers(const Building* b);
void refreshSiteProgress(const ConstructionSite* s); void refreshSiteProgress(const ConstructionSite* s);
void updateShipyardLayoutWidgets(BuildingType type, void updateShipyardLayoutWidgets(BuildingType type,
@@ -116,28 +116,7 @@ private:
QPoint m_splitterTile; QPoint m_splitterTile;
std::string m_currentRecipeId; std::string m_currentRecipeId;
bool m_debugDraw = false; // Renders the field selection (actors + debris) below the building content
// The selected ships/defence stations. Shares the "field" selection category with // (REQ-UI-FIELD-MULTI-SELECTION). Hides itself while nothing field-side is selected.
// debris (m_selectedDebris): both can be non-empty at once (REQ-UI-SELECTION-CATEGORIES). FieldSelectionPanel* m_fieldSelectionPanel;
std::vector<entt::entity> m_selectedEntities;
ShipStatsPanel* m_entityStatsPanel;
QLabel* m_entityTitleLabel;
QLabel* m_stationStatsLabel;
QLabel* m_entitySummaryLabel;
std::vector<entt::entity> m_selectedDebris;
// Shows the debris "Scrap" stat row (single selection) — the scrap total for the
// multi-object summary lives in m_entitySummaryLabel instead.
QLabel* m_scrapLabel;
// Renders the combined field selection (actors + debris): a single-object stats panel
// (ship, station, or debris) or a multi-object count summary that appends the debris
// count and scrap total when debris is also selected (REQ-UI-FIELD-MULTI-SELECTION).
void buildFieldSelection();
void buildEntityShip(entt::entity entity);
void buildEntityStation(entt::entity entity);
void buildEntitySummary();
void buildDebrisSingle();
void refreshEntityStats();
void clearEntityDisplay();
}; };