54 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
2c9433cea6 move the shared TOML helpers into the utility namespace
The names are generic (makeError, requireInt, parseFile), and at global scope
with external linkage they would form an overload set with the same-named
anonymous-namespace helpers in VisualsLoader.cpp and BalancingConfig.cpp the
moment either file includes TomlHelpers.h — silently, since the signatures
differ. Namespacing keeps that door shut.

Call sites are qualified explicitly rather than pulled in with a using
directive, matching how utility::getRandomInt and friends are already called.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 09:27:43 +02:00
21eb6ad096 extract loadUnlocks into ConfigLoaderUnlocks.cpp
Finishes the ConfigLoader.cpp domain split. ConfigLoader.cpp now holds
only the cross-domain validateUnlocks pass and loadFromDirectory
orchestrator, as intended — everything else lives in its own
ConfigLoader<Domain>.cpp. Pure move; no logic change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:38:10 +02:00
bf579bb76e extract loadModules into ConfigLoaderModules.cpp
Continues the ConfigLoader.cpp domain split. StatEntry/kKnownStats are
only used by loadModules, so they move along as a domain-local
anonymous-namespace table rather than into TomlHelpers. Pure move; no
logic change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:36:08 +02:00
6bde69dc33 extract loadStations into ConfigLoaderStations.cpp
Continues the ConfigLoader.cpp domain split. Pure move of loadStations;
no domain-specific helpers to relocate here.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:33:40 +02:00
bf99cd0694 extract loadShips into ConfigLoaderShips.cpp
Continues the ConfigLoader.cpp domain split. parseRotationString and
parsePlacedModules are only used by loadShips, so they move along as
domain-local anonymous-namespace helpers rather than into TomlHelpers.
Pure move; no logic change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:31:39 +02:00
6f107e3479 extract loadRecipes into ConfigLoaderRecipes.cpp
Continues the ConfigLoader.cpp domain split. parseRecipeOutputs is only
used by loadRecipes, so it moves along as a domain-local anonymous-
namespace helper rather than into TomlHelpers. Pure move; no logic
change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:29:17 +02:00
8f42519911 extract loadBuildings into ConfigLoaderBuildings.cpp
Continues the ConfigLoader.cpp domain split. Pure move of loadBuildings;
no domain-specific helpers to relocate here.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:27:05 +02:00
bb7b90f9ea extract loadWorld into ConfigLoaderWorld.cpp
Continues the ConfigLoader.cpp domain split: world.toml parsing has no
domain-specific helpers of its own, so this is a straight move of
loadWorld with no logic change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:25:14 +02:00
e702c01005 extract shared TOML helpers into TomlHelpers.h/.cpp
ConfigLoader.cpp had grown to 877 lines by mixing generic TOML-parsing
helpers (used by every per-file loader) with per-domain parsing logic.
Splitting the file per config domain first requires pulling out the
helpers shared by two or more domains, so each domain .cpp can include
them without duplication. Pure move: no logic, message, or ordering
changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:22:48 +02:00
b1720dc2b3 drop the now-dead payloads from the sim-backed state-change events
TickAdvanced, BuildingBlocksChanged, ExpansionCostChanged, BossWaveUpdated and
ArtifactCountChanged all duplicated state the Simulation already owns. With
every subscriber re-reading from the sim, the fields had no readers left, so
the five events become payload-free refresh signals and the emitters keep the
values only as locals for change detection.

This makes the convention uniform: a state-change event backed by the
simulation carries nothing. Events whose state lives in the view (selection,
game speed, deconstruct and debug-draw modes) keep their payloads, since there
is no sim getter behind them.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 21:56:47 +02:00
099f0b55fc make HeaderBar read the tick, artifacts and boss wave from the simulation
The last three payloads HeaderBar still consumed as truth. All are backed by
Simulation getters (getCurrentTick, getArtifactCount, getBossWaveCounter,
getBossCountdownTicks) and the win count by world.artifacts.artifactWinCount,
so the handlers now re-read rather than trust the event.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 21:54:02 +02:00
b133a21914 make BlueprintPanel read the block stock from the simulation
BlueprintPanel was the second panel caching BuildingBlocksChangedEvent's
payload as truth; it already held a Simulation*, so refreshButtonStates()
now re-reads getBuildingBlocksStock() at the point of use, per the "events
are refresh signals" rule.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 21:53:19 +02:00
69848eb9f8 remove the last duplicate findModuleDef from ShipLayoutPreview
ShipLayoutPreview was the one widget the findFooDef sweep missed: it held a
bare const std::vector<ModuleDef>* rather than a config, so the shared
ModulesConfig::findModuleDef was not a drop-in and the file-local copy
survived. Hold the ModulesConfig instead and call the shared finder.

The sole caller already had the ModulesConfig one dereference away.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 21:28:53 +02:00
993325d97c remove duplicate findBuildingDef from BuildingSystem
BuildingSystem::findBuildingDef was a byte-equivalent re-implementation of
BuildingsConfig::findBuildingDef. Same cleanup as the GameWorldView copy;
this one sits in the sim layer, so it was missed by both earlier passes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 21:34:24 +02:00
75f306f650 route the win-path restart through ResetCommand
The Restart button on the Win dialog called Simulation::reset() directly,
while the escape-menu and Game Over restarts enqueue a ResetCommand. That
bypassed the command chokepoint every sim mutation is supposed to flow
through (docs/replay_design.md), so a post-win restart was never recorded
into the replay stream, and it had UI code calling a sim mutator directly.

Mirror the Game Over path instead. The manual resetForNewGame() call goes
away with it: GameWorldView::onFrame already resets the view when it drains
a Reset command.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 21:31:27 +02:00
0eb9c97e5d dedupe AttackExecutor and RepairExecutor via executeOrbitAndAssign
The two executors were line-for-line duplicates: orbit the behavior target,
then hand it to the owner's in-range modules. Both now call a templated
executeOrbitAndAssign<Behavior, ModuleComponent>; the view types, iteration
order, and sequence of component writes are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 21:16:15 +02:00
28d0416458 add ModalPauseScope for the pause-around-modal idiom
Four MainWindow sites hand-rolled snapshot speed / setGameSpeed(0) / modal /
restore + resetFrameTimer, with the restore duplicated on early-return paths.
ModalPauseScope does it via RAII, with restore()/release() for the two sites
that must restore early or not at all.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 21:12:53 +02:00
59fde8dbbc extract MainWindow::reloadConfig
The three restart paths each repeated the same config + visuals reload with
its own try/catch and error dialog. Only that shared part is extracted; each
site keeps its own follow-up (ResetCommand vs. direct Simulation::reset) and
its own error-path cleanup.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 21:06:41 +02:00
5355f9f77d drop EntityAdmin::add in favour of addComponent
The private add<T> template was identical to the public addComponent<T>;
the spawn factory methods now use the public one.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 21:04:03 +02:00
f678dab387 share a single ItemIconCache across the UI
Four separate caches rasterized the same item SVGs, one of them rebuilt on
every recipe-dialog open. MainWindow now owns one cache and hands a
non-owning pointer to HeaderBar, BuildButtonGrid, GameWorldView and
RecipeSelectionDialog.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 21:00:19 +02:00
ebee62166d lift the shared Centroid helper into ai/Centroid.h
AdvanceExecutor and StandbyExecutor each carried a verbatim copy of the
struct in an anonymous namespace; it now sits next to OrbitMath.h.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:55:29 +02:00
3c549a160c share one loadTestConfig() helper across the tests
19 test translation units each defined an identical local loadConfig().
They now include src/test/TestConfig.h, which lives off the lib/ui/app
include path like SimulationTestAccess.h.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:53:21 +02:00
dc58f6ea32 make HeaderBar read block stock and expansion cost from the simulation
HeaderBar was the only panel caching event payloads as truth. It now holds
a const Simulation* and re-reads getBuildingBlocksStock() /
getCurrentExpansionCost() in the handlers, per the "events are refresh
signals" rule.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:49:38 +02:00
5bd804601c dedupe tunnel lookup and key tunnel tiles by QPoint
The identical 7-line TunnelLookup lambda existed in updateTunnelGhost and
drawSelectedTunnelConnections; it is now GameWorldView::makeTunnelLookup.
The tile key moved from std::pair<int, int> to QPoint with the existing
QPointCompare comparator, dropping the manual packing at every site.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:47:28 +02:00
92a4f02cef extract Simulation::initializeSubsystems
The constructor and reset() held a character-for-character identical
26-line subsystem construction block, including three capturing lambdas.
Both run before the first tick, so the closures can be shared. Order is
unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:44:43 +02:00
84d32b6c16 add findShipDef/findModuleDef/findRecipeDef to config structs
Ships/Modules/RecipesConfig now carry lookup helpers mirroring
BuildingsConfig::findBuildingDef. The hand-rolled linear scans in
BuildingSystem, ShipSystem, ShipStatsCalculator, ThreatCostCalculator,
ShipLayoutDialog, SelectedBuildingPanel and SchematicChoiceDialog now
call them instead.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:42:24 +02:00
d31ff68ab7 remove duplicate findBuildingDef from GameWorldView
GameWorldView::findBuildingDef was a byte-equivalent re-implementation of
BuildingsConfig::findBuildingDef. All call sites now use the config helper,
matching SelectedBuildingPanel and BlueprintPanel.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:34:56 +02:00
126 changed files with 4946 additions and 4484 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

@@ -13,6 +13,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.h ${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprintSerializer.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprintSerializer.h
${CMAKE_CURRENT_SOURCE_DIR}/TomlHelpers.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -20,9 +21,17 @@ SET(SRCS
${SRCS} ${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/Formula.cpp ${CMAKE_CURRENT_SOURCE_DIR}/Formula.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoaderWorld.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoaderBuildings.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoaderRecipes.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoaderShips.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoaderStations.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoaderModules.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoaderUnlocks.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprintSerializer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprintSerializer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/TomlHelpers.cpp
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -1,779 +1,10 @@
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include <cstdint>
#include <sstream>
#include <stdexcept>
#include <string> #include <string>
#include <unordered_set> #include <unordered_set>
#include <utility>
#include <vector> #include <vector>
#include <QPoint> #include "TomlHelpers.h"
#include "toml.hpp"
#include "Rotation.h"
#include "ShipLayout.h"
namespace
{
// --- Error helpers --------------------------------------------------------
std::runtime_error makeError(const std::string& file,
const std::string& path,
const std::string& why)
{
return std::runtime_error("Config: " + file + ": '" + path + "' " + why);
}
// --- Typed accessors (throw on missing or wrong type) ---------------------
int64_t requireInt(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
const std::optional<int64_t> value = node.value<int64_t>();
if (!value)
{
throw makeError(file, path, "missing or not an integer");
}
return *value;
}
double requireDouble(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
if (const std::optional<double> v = node.value<double>())
{
return *v;
}
if (const std::optional<int64_t> v = node.value<int64_t>())
{
return static_cast<double>(*v);
}
throw makeError(file, path, "missing or not a number");
}
std::string requireString(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
const std::optional<std::string> value = node.value<std::string>();
if (!value)
{
throw makeError(file, path, "missing or not a string");
}
return *value;
}
bool requireBool(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
const std::optional<bool> value = node.value<bool>();
if (!value)
{
throw makeError(file, path, "missing or not a boolean");
}
return *value;
}
const toml::array& requireArray(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
const toml::array* arr = node.as_array();
if (arr == nullptr)
{
throw makeError(file, path, "missing or not an array");
}
return *arr;
}
const toml::table& requireTable(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
const toml::table* tbl = node.as_table();
if (tbl == nullptr)
{
throw makeError(file, path, "missing or not a table");
}
return *tbl;
}
Formula requireFormula(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
const std::string source = requireString(node, file, path);
try
{
return Formula::compile(source);
}
catch (const std::exception& e)
{
throw makeError(file, path, std::string("formula error: ") + e.what());
}
}
std::vector<std::string> requireStringArray(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
const toml::array& arr = requireArray(node, file, path);
std::vector<std::string> result;
result.reserve(arr.size());
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = path + "[" + std::to_string(i) + "]";
const std::optional<std::string> s = arr[i].value<std::string>();
if (!s)
{
throw makeError(file, elemPath, "not a string");
}
result.push_back(*s);
}
return result;
}
std::vector<RecipeIngredient> parseIngredients(const toml::array& arr,
const std::string& file,
const std::string& path)
{
std::vector<RecipeIngredient> result;
result.reserve(arr.size());
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = path + "[" + std::to_string(i) + "]";
const toml::table* t = arr[i].as_table();
if (t == nullptr)
{
throw makeError(file, elemPath, "not a table");
}
// We need a mutable node_view to reuse our helpers, which is fine
// because the helpers never mutate.
toml::table& mt = const_cast<toml::table&>(*t);
RecipeIngredient ing;
ing.item = requireString(mt["item"], file, elemPath + ".item");
ing.amount = static_cast<int>(requireInt(mt["amount"], file, elemPath + ".amount"));
result.push_back(std::move(ing));
}
return result;
}
std::vector<RecipeOutput> parseRecipeOutputs(const toml::array& arr,
const std::string& file,
const std::string& path)
{
std::vector<RecipeOutput> result;
result.reserve(arr.size());
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = path + "[" + std::to_string(i) + "]";
const toml::table* t = arr[i].as_table();
if (t == nullptr)
{
throw makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<toml::table&>(*t);
RecipeOutput out;
out.item = requireString(mt["item"], file, elemPath + ".item");
out.amount = static_cast<int>(requireInt(mt["amount"], file, elemPath + ".amount"));
if (const std::optional<double> p = mt["probability"].value<double>())
{
out.probability = *p;
}
else if (const std::optional<int64_t> p = mt["probability"].value<int64_t>())
{
out.probability = static_cast<double>(*p);
}
result.push_back(std::move(out));
}
return result;
}
toml::table parseFile(const std::string& path, const std::string& file)
{
try
{
return toml::parse_file(path);
}
catch (const toml::parse_error& e)
{
std::ostringstream oss;
oss << "Config: " << file << ": TOML parse error: " << e.description()
<< " at " << e.source().begin;
throw std::runtime_error(oss.str());
}
}
Rotation parseRotationString(const std::string& s)
{
if (s == "east") { return Rotation::East; }
if (s == "south") { return Rotation::South; }
if (s == "west") { return Rotation::West; }
return Rotation::North;
}
std::vector<PlacedModule> parsePlacedModules(const toml::array& arr,
const std::string& file,
const std::string& path)
{
std::vector<PlacedModule> result;
result.reserve(arr.size());
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = path + "[" + std::to_string(i) + "]";
const toml::table* t = arr[i].as_table();
if (t == nullptr) { continue; }
toml::table& mt = const_cast<toml::table&>(*t);
const std::optional<std::string> type = mt["type"].value<std::string>();
const std::optional<int64_t> x = mt["x"].value<int64_t>();
const std::optional<int64_t> y = mt["y"].value<int64_t>();
const std::optional<std::string> rot = mt["rotation"].value<std::string>();
if (!type || !x || !y || !rot) { continue; }
PlacedModule pm;
pm.moduleId = *type;
pm.position = QPoint(static_cast<int>(*x), static_cast<int>(*y));
pm.rotation = parseRotationString(*rot);
result.push_back(std::move(pm));
}
return result;
}
} // namespace
// --- Per-file loaders -----------------------------------------------------
WorldConfig ConfigLoader::loadWorld(const std::string& path)
{
const std::string file = "world.toml";
toml::table tbl = parseFile(path, file);
WorldConfig cfg;
cfg.heightTiles = static_cast<int>(requireInt(tbl["world"]["height_tiles"], file, "world.height_tiles"));
cfg.refundPercentage = static_cast<int>(requireInt(tbl["world"]["refund_percentage"], file, "world.refund_percentage"));
cfg.deconstructionTimeSeconds = requireDouble(tbl["world"]["deconstruction_time_seconds"], file, "world.deconstruction_time_seconds");
cfg.startingBuildingBlocks = static_cast<int>(requireInt(tbl["world"]["starting_building_blocks"], file, "world.starting_building_blocks"));
cfg.debrisDespawnSeconds = requireDouble(tbl["world"]["debris_despawn_seconds"], file, "world.debris_despawn_seconds");
cfg.scrapPerThreat = requireDouble(tbl["world"]["scrap_per_threat"], file, "world.scrap_per_threat");
cfg.tileSize_m = requireDouble(tbl["world"]["tile_size_m"], file, "world.tile_size_m");
cfg.beltSpeed_tps = requireDouble(tbl["world"]["belt_speed_mps"], file, "world.belt_speed_mps") / cfg.tileSize_m;
cfg.tunnelMaxDistance_tiles = static_cast<int>(requireInt(tbl["world"]["tunnel_max_distance_tiles"], file, "world.tunnel_max_distance_tiles"));
cfg.departureIntervalSeconds = requireDouble(tbl["world"]["departure_interval_seconds"], file, "world.departure_interval_seconds");
cfg.orbitFactor = requireDouble(tbl["world"]["orbit_factor"], file, "world.orbit_factor");
cfg.rallyOrbitRadius_tiles = requireDouble(tbl["world"]["rally_orbit_radius_tiles"], file, "world.rally_orbit_radius_tiles");
if (const std::optional<std::string> tip =
tbl["world"]["building_blocks_tooltip"].value<std::string>())
{
cfg.buildingBlocksTooltip = *tip;
}
if (const std::optional<std::string> tip =
tbl["world"]["artifact_tooltip"].value<std::string>())
{
cfg.artifactTooltip = *tip;
}
cfg.regions.asteroidWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["asteroid_width_tiles"], file, "regions.asteroid_width_tiles"));
cfg.regions.playerBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["player_buffer_width_tiles"], file, "regions.player_buffer_width_tiles"));
cfg.regions.contestZoneWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["contest_zone_width_tiles"], file, "regions.contest_zone_width_tiles"));
cfg.regions.enemyBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["enemy_buffer_width_tiles"], file, "regions.enemy_buffer_width_tiles"));
cfg.expansion.columnsPerExpansion_tiles = static_cast<int>(requireInt(tbl["expansion"]["columns_per_expansion_tiles"], file, "expansion.columns_per_expansion_tiles"));
cfg.expansion.costBuildingBlocksFormula = requireFormula(tbl["expansion"]["cost_building_blocks_formula"], file, "expansion.cost_building_blocks_formula");
cfg.push.pushExpandColumns_tiles = static_cast<int>(requireInt(tbl["push"]["push_expand_columns_tiles"], file, "push.push_expand_columns_tiles"));
cfg.push.bossAdvanceSeconds = requireDouble(tbl["push"]["boss_advance_seconds"], file, "push.boss_advance_seconds");
cfg.waves.threatRateFormula = requireFormula(tbl["waves"]["threat_rate_formula"], file, "waves.threat_rate_formula");
cfg.waves.gapMinSeconds = requireDouble(tbl["waves"]["gap_min_seconds"], file, "waves.gap_min_seconds");
cfg.waves.gapMaxSeconds = requireDouble(tbl["waves"]["gap_max_seconds"], file, "waves.gap_max_seconds");
cfg.waves.spawnDurationSeconds = requireDouble(tbl["waves"]["spawn_duration_seconds"], file, "waves.spawn_duration_seconds");
cfg.waves.bossCountdownSeconds = requireDouble(tbl["waves"]["boss_countdown_seconds"], file, "waves.boss_countdown_seconds");
cfg.waves.bossThreatDurationSeconds = requireDouble(tbl["waves"]["boss_threat_duration_seconds"], file, "waves.boss_threat_duration_seconds");
cfg.waves.bossQuietBeforeSeconds = requireDouble(tbl["waves"]["boss_quiet_before_seconds"], file, "waves.boss_quiet_before_seconds");
cfg.waves.bossQuietAfterSeconds = requireDouble(tbl["waves"]["boss_quiet_after_seconds"], file, "waves.boss_quiet_after_seconds");
if (cfg.waves.gapMinSeconds > cfg.waves.gapMaxSeconds)
{
throw makeError(file, "waves", "gap_min_seconds > gap_max_seconds");
}
cfg.targeting.targetScoreFormula = requireFormula(tbl["targeting"]["target_score_formula"], file, "targeting.target_score_formula");
cfg.targeting.overclaimPenaltyFormula = requireFormula(tbl["targeting"]["overclaim_penalty_formula"], file, "targeting.overclaim_penalty_formula");
cfg.targeting.hysteresis = requireDouble(tbl["targeting"]["target_hysteresis"], file, "targeting.target_hysteresis");
cfg.artifacts.artifactChanceFormula = requireFormula(tbl["artifacts"]["artifact_chance_formula"], file, "artifacts.artifact_chance_formula");
cfg.artifacts.artifactWinCount = static_cast<int>(requireInt(tbl["artifacts"]["artifact_win_count"], file, "artifacts.artifact_win_count"));
cfg.scroll.panSpeedSlow_tps = requireDouble(tbl["scroll"]["pan_speed_slow_tiles_per_second"], file, "scroll.pan_speed_slow_tiles_per_second");
cfg.scroll.panSpeedFast_tps = requireDouble(tbl["scroll"]["pan_speed_fast_tiles_per_second"], file, "scroll.pan_speed_fast_tiles_per_second");
cfg.scroll.panRampBandWidth_tiles = static_cast<int>(requireInt(tbl["scroll"]["pan_ramp_band_width_tiles"], file, "scroll.pan_ramp_band_width_tiles"));
return cfg;
}
BuildingsConfig ConfigLoader::loadBuildings(const std::string& path)
{
const std::string file = "buildings.toml";
toml::table tbl = parseFile(path, file);
BuildingsConfig cfg;
const toml::array& arr = requireArray(tbl["building"], file, "building");
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = "building[" + std::to_string(i) + "]";
const toml::table* bt = arr[i].as_table();
if (bt == nullptr)
{
throw makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<toml::table&>(*bt);
BuildingDef def;
def.id = requireString(mt["id"], file, elemPath + ".id");
def.cost = static_cast<int>(requireInt(mt["cost"], file, elemPath + ".cost"));
def.playerPlaceable = requireBool(mt["player_placeable"], file, elemPath + ".player_placeable");
def.constructionTimeSeconds = requireDouble(mt["construction_time_seconds"], file, elemPath + ".construction_time_seconds");
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
if (mt.contains("output_buffer_capacity"))
{
def.outputBufferCapacity = static_cast<int>(
requireInt(mt["output_buffer_capacity"], file, elemPath + ".output_buffer_capacity"));
}
if (mt.contains("tooltip"))
{
def.tooltip = requireString(mt["tooltip"], file, elemPath + ".tooltip");
}
const std::optional<BuildingType> parsedType = parseBuildingType(def.id);
if (!parsedType)
{
throw makeError(file, elemPath + ".id", "unknown building id '" + def.id + "'");
}
def.type = *parsedType;
cfg.buildings.push_back(std::move(def));
}
return cfg;
}
RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
{
const std::string file = "recipes.toml";
toml::table tbl = parseFile(path, file);
RecipesConfig cfg;
const toml::array& arr = requireArray(tbl["recipe"], file, "recipe");
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = "recipe[" + std::to_string(i) + "]";
const toml::table* rt = arr[i].as_table();
if (rt == nullptr)
{
throw makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<toml::table&>(*rt);
RecipeDef def;
def.id = requireString(mt["id"], file, elemPath + ".id");
def.durationSeconds = requireDouble(mt["duration_seconds"], file, elemPath + ".duration_seconds");
const std::string buildingId = requireString(mt["building"], file, elemPath + ".building");
const std::optional<BuildingType> parsedType = parseBuildingType(buildingId);
if (!parsedType)
{
throw makeError(file, elemPath + ".building",
"unknown building id '" + buildingId + "'");
}
def.building = *parsedType;
if (def.building == BuildingType::Assembler && mt.contains("unlocked_at_start"))
{
def.unlockedAtStart = requireBool(mt["unlocked_at_start"], file,
elemPath + ".unlocked_at_start");
}
// inputs may be omitted (e.g. miner recipes). An empty array is fine.
if (mt.contains("inputs"))
{
const toml::array& inputs = requireArray(mt["inputs"], file, elemPath + ".inputs");
def.inputs = parseIngredients(inputs, file, elemPath + ".inputs");
}
const toml::array& outputs = requireArray(mt["outputs"], file, elemPath + ".outputs");
def.outputs = parseRecipeOutputs(outputs, file, elemPath + ".outputs");
// Optional icon item id (REQ-UI-RECIPE-ICON); defaults to the first output
// in the UI when unset. Not validated against known items here — a missing
// icon is not an error (REQ-UI-ITEM-ICON).
if (mt.contains("icon"))
{
def.icon = requireString(mt["icon"], file, elemPath + ".icon");
}
cfg.recipes.push_back(std::move(def));
}
return cfg;
}
ShipsConfig ConfigLoader::loadShips(const std::string& path)
{
const std::string file = "ships.toml";
toml::table tbl = parseFile(path, file);
ShipsConfig cfg;
const toml::array& arr = requireArray(tbl["ship"], file, "ship");
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = "ship[" + std::to_string(i) + "]";
const toml::table* st = arr[i].as_table();
if (st == nullptr)
{
throw makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<toml::table&>(*st);
ShipDef def;
def.id = requireString(mt["id"], file, elemPath + ".id");
def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout");
// Schematic
{
const std::string bpPath = elemPath + ".schematic";
const toml::table& bpTable = requireTable(mt["schematic"], file, bpPath);
toml::table& bpMt = const_cast<toml::table&>(bpTable);
const toml::array& materials = requireArray(bpMt["materials"], file, bpPath + ".materials");
def.schematic.materials = parseIngredients(materials, file, bpPath + ".materials");
def.schematic.productionTimeSeconds = requireDouble(
bpMt["production_time_seconds"], file, bpPath + ".production_time_seconds");
}
// Health
{
const std::string hPath = elemPath + ".health";
const toml::table& hTable = requireTable(mt["health"], file, hPath);
toml::table& hMt = const_cast<toml::table&>(hTable);
def.health.hp = static_cast<float>(requireDouble(hMt["hp"], file, hPath + ".hp"));
}
// Movement
{
const std::string mPath = elemPath + ".movement";
const toml::table& mTable = requireTable(mt["movement"], file, mPath);
toml::table& mMt = const_cast<toml::table&>(mTable);
def.movement.speed_mps = static_cast<float>(requireDouble(mMt["speed_mps"], file, mPath + ".speed_mps"));
def.movement.mainAcceleration_mpss = static_cast<float>(requireDouble(mMt["main_acceleration_mpss"], file, mPath + ".main_acceleration_mpss"));
def.movement.maneuveringAcceleration_mpss = static_cast<float>(requireDouble(mMt["maneuvering_acceleration_mpss"], file, mPath + ".maneuvering_acceleration_mpss"));
def.movement.angularAcceleration_radpss = static_cast<float>(requireDouble(mMt["angular_acceleration_radpss"], file, mPath + ".angular_acceleration_radpss"));
def.movement.maxRotationSpeed_radps = static_cast<float>(requireDouble(mMt["max_rotation_speed_radps"], file, mPath + ".max_rotation_speed_radps"));
}
// Sensor
{
const std::string snsPath = elemPath + ".sensor";
const toml::table& snsTable = requireTable(mt["sensor"], file, snsPath);
toml::table& snsMt = const_cast<toml::table&>(snsTable);
def.sensor.sensorRange_m = static_cast<float>(requireDouble(snsMt["sensor_range_m"], file, snsPath + ".sensor_range_m"));
}
// Optional: default_modules (REQ-WAV-DEFAULT-MODULES)
if (mt.contains("default_modules"))
{
const toml::array& modArr = requireArray(mt["default_modules"], file,
elemPath + ".default_modules");
def.defaultModules = parsePlacedModules(modArr, file,
elemPath + ".default_modules");
}
cfg.ships.push_back(std::move(def));
}
return cfg;
}
StationsConfig ConfigLoader::loadStations(const std::string& path)
{
const std::string file = "stations.toml";
toml::table tbl = parseFile(path, file);
StationsConfig cfg;
// HQ
{
const std::string p = "hq";
cfg.hq.surfaceMask = requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask");
cfg.hq.hpFormula = requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula");
}
// Player station
{
const std::string p = "player_station";
cfg.playerStation.surfaceMask = requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask");
cfg.playerStation.level = static_cast<int>(requireInt(tbl[p]["level"], file, p + ".level"));
cfg.playerStation.hpFormula = requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula");
cfg.playerStation.damageFormula = requireFormula(tbl[p]["damage_formula"], file, p + ".damage_formula");
cfg.playerStation.rangeFormula = requireFormula(tbl[p]["range_m_formula"], file, p + ".range_m_formula");
cfg.playerStation.fireRateFormula = requireFormula(tbl[p]["fire_rate_hz_formula"], file, p + ".fire_rate_hz_formula");
cfg.playerStation.scrapDropFormula = requireFormula(tbl[p]["scrap_drop_formula"], file, p + ".scrap_drop_formula");
}
// Enemy station
{
const std::string p = "enemy_station";
cfg.enemyStation.surfaceMask = requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask");
cfg.enemyStation.hpFormula = requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula");
cfg.enemyStation.damageFormula = requireFormula(tbl[p]["damage_formula"], file, p + ".damage_formula");
cfg.enemyStation.rangeFormula = requireFormula(tbl[p]["range_m_formula"], file, p + ".range_m_formula");
cfg.enemyStation.fireRateFormula = requireFormula(tbl[p]["fire_rate_hz_formula"], file, p + ".fire_rate_hz_formula");
cfg.enemyStation.scrapDropFormula = requireFormula(tbl[p]["scrap_drop_formula"], file, p + ".scrap_drop_formula");
}
return cfg;
}
// Known category→stat mappings for module stat modifier discovery.
// addedKeySuffix: unit suffix appended before "_formula" for additive modifier keys only.
// Multiplicative modifier keys are always dimensionless and carry no suffix.
struct StatEntry
{
const char* category;
const char* stat;
const char* addedKeySuffix;
};
static const StatEntry kKnownStats[] = {
{"health", "hp", ""},
{"movement", "speed", "_mps"},
{"movement", "main_acceleration", "_mpss"},
{"movement", "maneuvering_acceleration", "_mpss"},
{"sensor", "sensor_range", "_m"},
{"weapon", "damage", ""},
{"weapon", "attack_range", "_m"},
{"weapon", "attack_rate", "_hz"},
{"salvage", "collection_range", "_m"},
{"salvage", "collection_rate", "_hz"},
{"cargo", "cargo_capacity", ""},
{"repair", "repair_rate", "_hz"},
{"repair", "repair_range", "_m"},
};
ModulesConfig ConfigLoader::loadModules(const std::string& path)
{
const std::string file = "modules.toml";
toml::table tbl = parseFile(path, file);
ModulesConfig cfg;
if (!tbl.contains("module"))
{
return cfg;
}
const toml::array& arr = requireArray(tbl["module"], file, "module");
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = "module[" + std::to_string(i) + "]";
const toml::table* st = arr[i].as_table();
if (st == nullptr)
{
throw makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<toml::table&>(*st);
ModuleDef def;
def.id = requireString(mt["id"], file, elemPath + ".id");
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
def.productionTimeSeconds = requireDouble(
mt["production_time_seconds"], file, elemPath + ".production_time_seconds");
def.fillColor = requireString(mt["fill_color"], file, elemPath + ".fill_color");
def.glyph = requireString(mt["glyph"], file, elemPath + ".glyph");
if (mt.contains("tooltip"))
{
def.tooltip = requireString(mt["tooltip"], file, elemPath + ".tooltip");
}
// Materials
{
const toml::array& materials = requireArray(mt["materials"], file, elemPath + ".materials");
def.materials = parseIngredients(materials, file, elemPath + ".materials");
}
// Stat modifiers from [module.<category>] sub-tables
for (const StatEntry& se : kKnownStats)
{
if (!mt.contains(se.category))
{
continue;
}
const toml::table& catTable = requireTable(mt[se.category], file,
elemPath + "." + se.category);
toml::table& catMt = const_cast<toml::table&>(catTable);
const std::string addedKey = std::string("added_") + se.stat + se.addedKeySuffix;
const std::string multipliedKey = std::string("multiplied_") + se.stat + se.addedKeySuffix;
if (catMt.contains(addedKey))
{
ModuleStatModifier mod;
mod.stat = se.stat;
mod.modifierType = "additive";
mod.value = requireDouble(catMt[addedKey], file,
elemPath + "." + se.category + "." + addedKey);
def.statModifiers.push_back(std::move(mod));
}
if (catMt.contains(multipliedKey))
{
ModuleStatModifier mod;
mod.stat = se.stat;
mod.modifierType = "multiplicative";
mod.value = requireDouble(catMt[multipliedKey], file,
elemPath + "." + se.category + "." + multipliedKey);
def.statModifiers.push_back(std::move(mod));
}
}
// Weapon capability section: [module.weapon] with base stat formulas
if (mt.contains("weapon"))
{
const std::string wPath = elemPath + ".weapon";
const toml::table& wTable = requireTable(mt["weapon"], file, wPath);
toml::table& wMt = const_cast<toml::table&>(wTable);
if (wMt.contains("damage") || wMt.contains("attack_range_m")
|| wMt.contains("attack_rate_hz"))
{
ModuleWeaponCapability cap;
cap.damage = static_cast<float>(requireDouble(wMt["damage"],
file, wPath + ".damage"));
cap.attackRange_m = static_cast<float>(requireDouble(wMt["attack_range_m"],
file, wPath + ".attack_range_m"));
cap.attackRate_hz = static_cast<float>(requireDouble(wMt["attack_rate_hz"],
file, wPath + ".attack_rate_hz"));
def.weaponCapability = std::move(cap);
}
}
// Salvage capability section: [module.salvage] with base stat formulas
if (mt.contains("salvage"))
{
const std::string sPath = elemPath + ".salvage";
const toml::table& sTable = requireTable(mt["salvage"], file, sPath);
toml::table& sMt = const_cast<toml::table&>(sTable);
if (sMt.contains("collection_range_m") || sMt.contains("cargo_capacity")
|| sMt.contains("collection_rate_hz"))
{
ModuleSalvageCapability cap;
cap.collectionRange_m = static_cast<float>(requireDouble(sMt["collection_range_m"],
file, sPath + ".collection_range_m"));
cap.cargoCapacity = static_cast<float>(requireDouble(sMt["cargo_capacity"],
file, sPath + ".cargo_capacity"));
cap.collectionRate_hz = static_cast<float>(requireDouble(sMt["collection_rate_hz"],
file, sPath + ".collection_rate_hz"));
def.salvageCapability = std::move(cap);
}
}
// Repair capability section: [module.repair] with base stat formulas
if (mt.contains("repair"))
{
const std::string rPath = elemPath + ".repair";
const toml::table& rTable = requireTable(mt["repair"], file, rPath);
toml::table& rMt = const_cast<toml::table&>(rTable);
if (rMt.contains("repair_rate_hz") || rMt.contains("repair_range_m"))
{
ModuleRepairCapability cap;
cap.repairRate_hz = static_cast<float>(requireDouble(rMt["repair_rate_hz"],
file, rPath + ".repair_rate_hz"));
cap.repairAmountHp = static_cast<float>(requireDouble(rMt["repair_amount_hp"],
file, rPath + ".repair_amount_hp"));
cap.repairRange_m = static_cast<float>(requireDouble(rMt["repair_range_m"],
file, rPath + ".repair_range_m"));
def.repairCapability = std::move(cap);
}
}
cfg.modules.push_back(std::move(def));
}
return cfg;
}
UnlocksConfig ConfigLoader::loadUnlocks(const std::string& path)
{
const std::string file = "unlocks.toml";
toml::table tbl = parseFile(path, file);
UnlocksConfig cfg;
if (!tbl.contains("unlock"))
{
return cfg;
}
const toml::array& arr = requireArray(tbl["unlock"], file, "unlock");
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = "unlock[" + std::to_string(i) + "]";
const toml::table* ut = arr[i].as_table();
if (ut == nullptr)
{
throw makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<toml::table&>(*ut);
UnlockGroupDef def;
def.id = requireString(mt["id"], file, elemPath + ".id");
def.stationLevel = static_cast<int>(
requireInt(mt["station_level"], file, elemPath + ".station_level"));
if (mt.contains("requires"))
{
def.requiredGroupIds = requireStringArray(mt["requires"], file, elemPath + ".requires");
}
if (mt.contains("ships"))
{
def.ships = requireStringArray(mt["ships"], file, elemPath + ".ships");
}
if (mt.contains("modules"))
{
def.modules = requireStringArray(mt["modules"], file, elemPath + ".modules");
}
if (mt.contains("buildings"))
{
def.buildings = requireStringArray(mt["buildings"], file, elemPath + ".buildings");
}
if (mt.contains("recipes"))
{
def.recipes = requireStringArray(mt["recipes"], file, elemPath + ".recipes");
}
cfg.groups.push_back(std::move(def));
}
return cfg;
}
namespace namespace
{ {
@@ -815,11 +46,11 @@ void validateUnlocks(const GameConfig& cfg)
{ {
if (valid.count(id) == 0) if (valid.count(id) == 0)
{ {
throw makeError(file, gPath, "grants unknown " + kind + " '" + id + "'"); throw utility::makeError(file, gPath, "grants unknown " + kind + " '" + id + "'");
} }
if (!granted.insert(id).second) if (!granted.insert(id).second)
{ {
throw makeError(file, gPath, throw utility::makeError(file, gPath,
"grants " + kind + " '" + id + "' which is already granted by another unlock group"); "grants " + kind + " '" + id + "' which is already granted by another unlock group");
} }
} }
@@ -830,13 +61,13 @@ void validateUnlocks(const GameConfig& cfg)
const std::string gPath = "unlock '" + group.id + "'"; const std::string gPath = "unlock '" + group.id + "'";
if (!groupIds.insert(group.id).second) if (!groupIds.insert(group.id).second)
{ {
throw makeError(file, gPath, "duplicate unlock group id"); throw utility::makeError(file, gPath, "duplicate unlock group id");
} }
if (group.ships.empty() && group.modules.empty() if (group.ships.empty() && group.modules.empty()
&& group.buildings.empty() && group.recipes.empty()) && group.buildings.empty() && group.recipes.empty())
{ {
throw makeError(file, gPath, "grants no items (must grant at least one)"); throw utility::makeError(file, gPath, "grants no items (must grant at least one)");
} }
checkGrants(group.ships, shipIds, grantedShipIds, "ship", gPath); checkGrants(group.ships, shipIds, grantedShipIds, "ship", gPath);
@@ -852,7 +83,7 @@ void validateUnlocks(const GameConfig& cfg)
{ {
if (groupIds.count(req) == 0) if (groupIds.count(req) == 0)
{ {
throw makeError(file, "unlock '" + group.id + "'.requires", throw utility::makeError(file, "unlock '" + group.id + "'.requires",
"references unknown unlock group '" + req + "'"); "references unknown unlock group '" + req + "'");
} }
} }

View File

@@ -0,0 +1,58 @@
#include "ConfigLoader.h"
#include <optional>
#include <string>
#include <utility>
#include "toml.hpp"
#include "TomlHelpers.h"
BuildingsConfig ConfigLoader::loadBuildings(const std::string& path)
{
const std::string file = "buildings.toml";
toml::table tbl = utility::parseFile(path, file);
BuildingsConfig cfg;
const toml::array& arr = utility::requireArray(tbl["building"], file, "building");
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = "building[" + std::to_string(i) + "]";
const toml::table* bt = arr[i].as_table();
if (bt == nullptr)
{
throw utility::makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<toml::table&>(*bt);
BuildingDef def;
def.id = utility::requireString(mt["id"], file, elemPath + ".id");
def.cost = static_cast<int>(utility::requireInt(mt["cost"], file, elemPath + ".cost"));
def.playerPlaceable = utility::requireBool(mt["player_placeable"], file, elemPath + ".player_placeable");
def.constructionTimeSeconds = utility::requireDouble(mt["construction_time_seconds"], file, elemPath + ".construction_time_seconds");
def.surfaceMask = utility::requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
if (mt.contains("output_buffer_capacity"))
{
def.outputBufferCapacity = static_cast<int>(
utility::requireInt(mt["output_buffer_capacity"], file, elemPath + ".output_buffer_capacity"));
}
if (mt.contains("tooltip"))
{
def.tooltip = utility::requireString(mt["tooltip"], file, elemPath + ".tooltip");
}
const std::optional<BuildingType> parsedType = parseBuildingType(def.id);
if (!parsedType)
{
throw utility::makeError(file, elemPath + ".id", "unknown building id '" + def.id + "'");
}
def.type = *parsedType;
cfg.buildings.push_back(std::move(def));
}
return cfg;
}

View File

@@ -0,0 +1,182 @@
#include "ConfigLoader.h"
#include <string>
#include <utility>
#include "toml.hpp"
#include "TomlHelpers.h"
namespace
{
// Known category→stat mappings for module stat modifier discovery.
// addedKeySuffix: unit suffix appended before "_formula" for additive modifier keys only.
// Multiplicative modifier keys are always dimensionless and carry no suffix.
struct StatEntry
{
const char* category;
const char* stat;
const char* addedKeySuffix;
};
static const StatEntry kKnownStats[] = {
{"health", "hp", ""},
{"movement", "speed", "_mps"},
{"movement", "main_acceleration", "_mpss"},
{"movement", "maneuvering_acceleration", "_mpss"},
{"sensor", "sensor_range", "_m"},
{"weapon", "damage", ""},
{"weapon", "attack_range", "_m"},
{"weapon", "attack_rate", "_hz"},
{"salvage", "collection_range", "_m"},
{"salvage", "collection_rate", "_hz"},
{"cargo", "cargo_capacity", ""},
{"repair", "repair_rate", "_hz"},
{"repair", "repair_range", "_m"},
};
} // namespace
ModulesConfig ConfigLoader::loadModules(const std::string& path)
{
const std::string file = "modules.toml";
toml::table tbl = utility::parseFile(path, file);
ModulesConfig cfg;
if (!tbl.contains("module"))
{
return cfg;
}
const toml::array& arr = utility::requireArray(tbl["module"], file, "module");
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = "module[" + std::to_string(i) + "]";
const toml::table* st = arr[i].as_table();
if (st == nullptr)
{
throw utility::makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<toml::table&>(*st);
ModuleDef def;
def.id = utility::requireString(mt["id"], file, elemPath + ".id");
def.surfaceMask = utility::requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
def.productionTimeSeconds = utility::requireDouble(
mt["production_time_seconds"], file, elemPath + ".production_time_seconds");
def.fillColor = utility::requireString(mt["fill_color"], file, elemPath + ".fill_color");
def.glyph = utility::requireString(mt["glyph"], file, elemPath + ".glyph");
if (mt.contains("tooltip"))
{
def.tooltip = utility::requireString(mt["tooltip"], file, elemPath + ".tooltip");
}
// Materials
{
const toml::array& materials = utility::requireArray(mt["materials"], file, elemPath + ".materials");
def.materials = utility::parseIngredients(materials, file, elemPath + ".materials");
}
// Stat modifiers from [module.<category>] sub-tables
for (const StatEntry& se : kKnownStats)
{
if (!mt.contains(se.category))
{
continue;
}
const toml::table& catTable = utility::requireTable(mt[se.category], file,
elemPath + "." + se.category);
toml::table& catMt = const_cast<toml::table&>(catTable);
const std::string addedKey = std::string("added_") + se.stat + se.addedKeySuffix;
const std::string multipliedKey = std::string("multiplied_") + se.stat + se.addedKeySuffix;
if (catMt.contains(addedKey))
{
ModuleStatModifier mod;
mod.stat = se.stat;
mod.modifierType = "additive";
mod.value = utility::requireDouble(catMt[addedKey], file,
elemPath + "." + se.category + "." + addedKey);
def.statModifiers.push_back(std::move(mod));
}
if (catMt.contains(multipliedKey))
{
ModuleStatModifier mod;
mod.stat = se.stat;
mod.modifierType = "multiplicative";
mod.value = utility::requireDouble(catMt[multipliedKey], file,
elemPath + "." + se.category + "." + multipliedKey);
def.statModifiers.push_back(std::move(mod));
}
}
// Weapon capability section: [module.weapon] with base stat formulas
if (mt.contains("weapon"))
{
const std::string wPath = elemPath + ".weapon";
const toml::table& wTable = utility::requireTable(mt["weapon"], file, wPath);
toml::table& wMt = const_cast<toml::table&>(wTable);
if (wMt.contains("damage") || wMt.contains("attack_range_m")
|| wMt.contains("attack_rate_hz"))
{
ModuleWeaponCapability cap;
cap.damage = static_cast<float>(utility::requireDouble(wMt["damage"],
file, wPath + ".damage"));
cap.attackRange_m = static_cast<float>(utility::requireDouble(wMt["attack_range_m"],
file, wPath + ".attack_range_m"));
cap.attackRate_hz = static_cast<float>(utility::requireDouble(wMt["attack_rate_hz"],
file, wPath + ".attack_rate_hz"));
def.weaponCapability = std::move(cap);
}
}
// Salvage capability section: [module.salvage] with base stat formulas
if (mt.contains("salvage"))
{
const std::string sPath = elemPath + ".salvage";
const toml::table& sTable = utility::requireTable(mt["salvage"], file, sPath);
toml::table& sMt = const_cast<toml::table&>(sTable);
if (sMt.contains("collection_range_m") || sMt.contains("cargo_capacity")
|| sMt.contains("collection_rate_hz"))
{
ModuleSalvageCapability cap;
cap.collectionRange_m = static_cast<float>(utility::requireDouble(sMt["collection_range_m"],
file, sPath + ".collection_range_m"));
cap.cargoCapacity = static_cast<float>(utility::requireDouble(sMt["cargo_capacity"],
file, sPath + ".cargo_capacity"));
cap.collectionRate_hz = static_cast<float>(utility::requireDouble(sMt["collection_rate_hz"],
file, sPath + ".collection_rate_hz"));
def.salvageCapability = std::move(cap);
}
}
// Repair capability section: [module.repair] with base stat formulas
if (mt.contains("repair"))
{
const std::string rPath = elemPath + ".repair";
const toml::table& rTable = utility::requireTable(mt["repair"], file, rPath);
toml::table& rMt = const_cast<toml::table&>(rTable);
if (rMt.contains("repair_rate_hz") || rMt.contains("repair_range_m"))
{
ModuleRepairCapability cap;
cap.repairRate_hz = static_cast<float>(utility::requireDouble(rMt["repair_rate_hz"],
file, rPath + ".repair_rate_hz"));
cap.repairAmountHp = static_cast<float>(utility::requireDouble(rMt["repair_amount_hp"],
file, rPath + ".repair_amount_hp"));
cap.repairRange_m = static_cast<float>(utility::requireDouble(rMt["repair_range_m"],
file, rPath + ".repair_range_m"));
def.repairCapability = std::move(cap);
}
}
cfg.modules.push_back(std::move(def));
}
return cfg;
}

View File

@@ -0,0 +1,109 @@
#include "ConfigLoader.h"
#include <cstdint>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "toml.hpp"
#include "TomlHelpers.h"
namespace
{
std::vector<RecipeOutput> parseRecipeOutputs(const toml::array& arr,
const std::string& file,
const std::string& path)
{
std::vector<RecipeOutput> result;
result.reserve(arr.size());
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = path + "[" + std::to_string(i) + "]";
const toml::table* t = arr[i].as_table();
if (t == nullptr)
{
throw utility::makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<toml::table&>(*t);
RecipeOutput out;
out.item = utility::requireString(mt["item"], file, elemPath + ".item");
out.amount = static_cast<int>(utility::requireInt(mt["amount"], file, elemPath + ".amount"));
if (const std::optional<double> p = mt["probability"].value<double>())
{
out.probability = *p;
}
else if (const std::optional<int64_t> p = mt["probability"].value<int64_t>())
{
out.probability = static_cast<double>(*p);
}
result.push_back(std::move(out));
}
return result;
}
} // namespace
RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
{
const std::string file = "recipes.toml";
toml::table tbl = utility::parseFile(path, file);
RecipesConfig cfg;
const toml::array& arr = utility::requireArray(tbl["recipe"], file, "recipe");
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = "recipe[" + std::to_string(i) + "]";
const toml::table* rt = arr[i].as_table();
if (rt == nullptr)
{
throw utility::makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<toml::table&>(*rt);
RecipeDef def;
def.id = utility::requireString(mt["id"], file, elemPath + ".id");
def.durationSeconds = utility::requireDouble(mt["duration_seconds"], file, elemPath + ".duration_seconds");
const std::string buildingId = utility::requireString(mt["building"], file, elemPath + ".building");
const std::optional<BuildingType> parsedType = parseBuildingType(buildingId);
if (!parsedType)
{
throw utility::makeError(file, elemPath + ".building",
"unknown building id '" + buildingId + "'");
}
def.building = *parsedType;
if (def.building == BuildingType::Assembler && mt.contains("unlocked_at_start"))
{
def.unlockedAtStart = utility::requireBool(mt["unlocked_at_start"], file,
elemPath + ".unlocked_at_start");
}
// inputs may be omitted (e.g. miner recipes). An empty array is fine.
if (mt.contains("inputs"))
{
const toml::array& inputs = utility::requireArray(mt["inputs"], file, elemPath + ".inputs");
def.inputs = utility::parseIngredients(inputs, file, elemPath + ".inputs");
}
const toml::array& outputs = utility::requireArray(mt["outputs"], file, elemPath + ".outputs");
def.outputs = parseRecipeOutputs(outputs, file, elemPath + ".outputs");
// Optional icon item id (REQ-UI-RECIPE-ICON); defaults to the first output
// in the UI when unset. Not validated against known items here — a missing
// icon is not an error (REQ-UI-ITEM-ICON).
if (mt.contains("icon"))
{
def.icon = utility::requireString(mt["icon"], file, elemPath + ".icon");
}
cfg.recipes.push_back(std::move(def));
}
return cfg;
}

View File

@@ -0,0 +1,133 @@
#include "ConfigLoader.h"
#include <cstdint>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include <QPoint>
#include "toml.hpp"
#include "Rotation.h"
#include "ShipLayout.h"
#include "TomlHelpers.h"
namespace
{
Rotation parseRotationString(const std::string& s)
{
if (s == "east") { return Rotation::East; }
if (s == "south") { return Rotation::South; }
if (s == "west") { return Rotation::West; }
return Rotation::North;
}
std::vector<PlacedModule> parsePlacedModules(const toml::array& arr,
const std::string& file,
const std::string& path)
{
std::vector<PlacedModule> result;
result.reserve(arr.size());
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = path + "[" + std::to_string(i) + "]";
const toml::table* t = arr[i].as_table();
if (t == nullptr) { continue; }
toml::table& mt = const_cast<toml::table&>(*t);
const std::optional<std::string> type = mt["type"].value<std::string>();
const std::optional<int64_t> x = mt["x"].value<int64_t>();
const std::optional<int64_t> y = mt["y"].value<int64_t>();
const std::optional<std::string> rot = mt["rotation"].value<std::string>();
if (!type || !x || !y || !rot) { continue; }
PlacedModule pm;
pm.moduleId = *type;
pm.position = QPoint(static_cast<int>(*x), static_cast<int>(*y));
pm.rotation = parseRotationString(*rot);
result.push_back(std::move(pm));
}
return result;
}
} // namespace
ShipsConfig ConfigLoader::loadShips(const std::string& path)
{
const std::string file = "ships.toml";
toml::table tbl = utility::parseFile(path, file);
ShipsConfig cfg;
const toml::array& arr = utility::requireArray(tbl["ship"], file, "ship");
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = "ship[" + std::to_string(i) + "]";
const toml::table* st = arr[i].as_table();
if (st == nullptr)
{
throw utility::makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<toml::table&>(*st);
ShipDef def;
def.id = utility::requireString(mt["id"], file, elemPath + ".id");
def.layout = utility::requireStringArray(mt["layout"], file, elemPath + ".layout");
// Schematic
{
const std::string bpPath = elemPath + ".schematic";
const toml::table& bpTable = utility::requireTable(mt["schematic"], file, bpPath);
toml::table& bpMt = const_cast<toml::table&>(bpTable);
const toml::array& materials = utility::requireArray(bpMt["materials"], file, bpPath + ".materials");
def.schematic.materials = utility::parseIngredients(materials, file, bpPath + ".materials");
def.schematic.productionTimeSeconds = utility::requireDouble(
bpMt["production_time_seconds"], file, bpPath + ".production_time_seconds");
}
// Health
{
const std::string hPath = elemPath + ".health";
const toml::table& hTable = utility::requireTable(mt["health"], file, hPath);
toml::table& hMt = const_cast<toml::table&>(hTable);
def.health.hp = static_cast<float>(utility::requireDouble(hMt["hp"], file, hPath + ".hp"));
}
// Movement
{
const std::string mPath = elemPath + ".movement";
const toml::table& mTable = utility::requireTable(mt["movement"], file, mPath);
toml::table& mMt = const_cast<toml::table&>(mTable);
def.movement.speed_mps = static_cast<float>(utility::requireDouble(mMt["speed_mps"], file, mPath + ".speed_mps"));
def.movement.mainAcceleration_mpss = static_cast<float>(utility::requireDouble(mMt["main_acceleration_mpss"], file, mPath + ".main_acceleration_mpss"));
def.movement.maneuveringAcceleration_mpss = static_cast<float>(utility::requireDouble(mMt["maneuvering_acceleration_mpss"], file, mPath + ".maneuvering_acceleration_mpss"));
def.movement.angularAcceleration_radpss = static_cast<float>(utility::requireDouble(mMt["angular_acceleration_radpss"], file, mPath + ".angular_acceleration_radpss"));
def.movement.maxRotationSpeed_radps = static_cast<float>(utility::requireDouble(mMt["max_rotation_speed_radps"], file, mPath + ".max_rotation_speed_radps"));
}
// Sensor
{
const std::string snsPath = elemPath + ".sensor";
const toml::table& snsTable = utility::requireTable(mt["sensor"], file, snsPath);
toml::table& snsMt = const_cast<toml::table&>(snsTable);
def.sensor.sensorRange_m = static_cast<float>(utility::requireDouble(snsMt["sensor_range_m"], file, snsPath + ".sensor_range_m"));
}
// Optional: default_modules (REQ-WAV-DEFAULT-MODULES)
if (mt.contains("default_modules"))
{
const toml::array& modArr = utility::requireArray(mt["default_modules"], file,
elemPath + ".default_modules");
def.defaultModules = parsePlacedModules(modArr, file,
elemPath + ".default_modules");
}
cfg.ships.push_back(std::move(def));
}
return cfg;
}

View File

@@ -0,0 +1,47 @@
#include "ConfigLoader.h"
#include <string>
#include "toml.hpp"
#include "TomlHelpers.h"
StationsConfig ConfigLoader::loadStations(const std::string& path)
{
const std::string file = "stations.toml";
toml::table tbl = utility::parseFile(path, file);
StationsConfig cfg;
// HQ
{
const std::string p = "hq";
cfg.hq.surfaceMask = utility::requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask");
cfg.hq.hpFormula = utility::requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula");
}
// Player station
{
const std::string p = "player_station";
cfg.playerStation.surfaceMask = utility::requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask");
cfg.playerStation.level = static_cast<int>(utility::requireInt(tbl[p]["level"], file, p + ".level"));
cfg.playerStation.hpFormula = utility::requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula");
cfg.playerStation.damageFormula = utility::requireFormula(tbl[p]["damage_formula"], file, p + ".damage_formula");
cfg.playerStation.rangeFormula = utility::requireFormula(tbl[p]["range_m_formula"], file, p + ".range_m_formula");
cfg.playerStation.fireRateFormula = utility::requireFormula(tbl[p]["fire_rate_hz_formula"], file, p + ".fire_rate_hz_formula");
cfg.playerStation.scrapDropFormula = utility::requireFormula(tbl[p]["scrap_drop_formula"], file, p + ".scrap_drop_formula");
}
// Enemy station
{
const std::string p = "enemy_station";
cfg.enemyStation.surfaceMask = utility::requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask");
cfg.enemyStation.hpFormula = utility::requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula");
cfg.enemyStation.damageFormula = utility::requireFormula(tbl[p]["damage_formula"], file, p + ".damage_formula");
cfg.enemyStation.rangeFormula = utility::requireFormula(tbl[p]["range_m_formula"], file, p + ".range_m_formula");
cfg.enemyStation.fireRateFormula = utility::requireFormula(tbl[p]["fire_rate_hz_formula"], file, p + ".fire_rate_hz_formula");
cfg.enemyStation.scrapDropFormula = utility::requireFormula(tbl[p]["scrap_drop_formula"], file, p + ".scrap_drop_formula");
}
return cfg;
}

View File

@@ -0,0 +1,62 @@
#include "ConfigLoader.h"
#include <string>
#include <utility>
#include "toml.hpp"
#include "TomlHelpers.h"
UnlocksConfig ConfigLoader::loadUnlocks(const std::string& path)
{
const std::string file = "unlocks.toml";
toml::table tbl = utility::parseFile(path, file);
UnlocksConfig cfg;
if (!tbl.contains("unlock"))
{
return cfg;
}
const toml::array& arr = utility::requireArray(tbl["unlock"], file, "unlock");
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = "unlock[" + std::to_string(i) + "]";
const toml::table* ut = arr[i].as_table();
if (ut == nullptr)
{
throw utility::makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<toml::table&>(*ut);
UnlockGroupDef def;
def.id = utility::requireString(mt["id"], file, elemPath + ".id");
def.stationLevel = static_cast<int>(
utility::requireInt(mt["station_level"], file, elemPath + ".station_level"));
if (mt.contains("requires"))
{
def.requiredGroupIds = utility::requireStringArray(mt["requires"], file, elemPath + ".requires");
}
if (mt.contains("ships"))
{
def.ships = utility::requireStringArray(mt["ships"], file, elemPath + ".ships");
}
if (mt.contains("modules"))
{
def.modules = utility::requireStringArray(mt["modules"], file, elemPath + ".modules");
}
if (mt.contains("buildings"))
{
def.buildings = utility::requireStringArray(mt["buildings"], file, elemPath + ".buildings");
}
if (mt.contains("recipes"))
{
def.recipes = utility::requireStringArray(mt["recipes"], file, elemPath + ".recipes");
}
cfg.groups.push_back(std::move(def));
}
return cfg;
}

View File

@@ -0,0 +1,79 @@
#include "ConfigLoader.h"
#include <optional>
#include <string>
#include "toml.hpp"
#include "TomlHelpers.h"
WorldConfig ConfigLoader::loadWorld(const std::string& path)
{
const std::string file = "world.toml";
toml::table tbl = utility::parseFile(path, file);
WorldConfig cfg;
cfg.heightTiles = static_cast<int>(utility::requireInt(tbl["world"]["height_tiles"], file, "world.height_tiles"));
cfg.refundPercentage = static_cast<int>(utility::requireInt(tbl["world"]["refund_percentage"], file, "world.refund_percentage"));
cfg.deconstructionTimeSeconds = utility::requireDouble(tbl["world"]["deconstruction_time_seconds"], file, "world.deconstruction_time_seconds");
cfg.startingBuildingBlocks = static_cast<int>(utility::requireInt(tbl["world"]["starting_building_blocks"], file, "world.starting_building_blocks"));
cfg.debrisDespawnSeconds = utility::requireDouble(tbl["world"]["debris_despawn_seconds"], file, "world.debris_despawn_seconds");
cfg.scrapPerThreat = utility::requireDouble(tbl["world"]["scrap_per_threat"], file, "world.scrap_per_threat");
cfg.tileSize_m = utility::requireDouble(tbl["world"]["tile_size_m"], file, "world.tile_size_m");
cfg.beltSpeed_tps = utility::requireDouble(tbl["world"]["belt_speed_mps"], file, "world.belt_speed_mps") / cfg.tileSize_m;
cfg.tunnelMaxDistance_tiles = static_cast<int>(utility::requireInt(tbl["world"]["tunnel_max_distance_tiles"], file, "world.tunnel_max_distance_tiles"));
cfg.departureIntervalSeconds = utility::requireDouble(tbl["world"]["departure_interval_seconds"], file, "world.departure_interval_seconds");
cfg.orbitFactor = utility::requireDouble(tbl["world"]["orbit_factor"], file, "world.orbit_factor");
cfg.rallyOrbitRadius_tiles = utility::requireDouble(tbl["world"]["rally_orbit_radius_tiles"], file, "world.rally_orbit_radius_tiles");
if (const std::optional<std::string> tip =
tbl["world"]["building_blocks_tooltip"].value<std::string>())
{
cfg.buildingBlocksTooltip = *tip;
}
if (const std::optional<std::string> tip =
tbl["world"]["artifact_tooltip"].value<std::string>())
{
cfg.artifactTooltip = *tip;
}
cfg.regions.asteroidWidth_tiles = static_cast<int>(utility::requireInt(tbl["regions"]["asteroid_width_tiles"], file, "regions.asteroid_width_tiles"));
cfg.regions.playerBufferWidth_tiles = static_cast<int>(utility::requireInt(tbl["regions"]["player_buffer_width_tiles"], file, "regions.player_buffer_width_tiles"));
cfg.regions.contestZoneWidth_tiles = static_cast<int>(utility::requireInt(tbl["regions"]["contest_zone_width_tiles"], file, "regions.contest_zone_width_tiles"));
cfg.regions.enemyBufferWidth_tiles = static_cast<int>(utility::requireInt(tbl["regions"]["enemy_buffer_width_tiles"], file, "regions.enemy_buffer_width_tiles"));
cfg.expansion.columnsPerExpansion_tiles = static_cast<int>(utility::requireInt(tbl["expansion"]["columns_per_expansion_tiles"], file, "expansion.columns_per_expansion_tiles"));
cfg.expansion.costBuildingBlocksFormula = utility::requireFormula(tbl["expansion"]["cost_building_blocks_formula"], file, "expansion.cost_building_blocks_formula");
cfg.push.pushExpandColumns_tiles = static_cast<int>(utility::requireInt(tbl["push"]["push_expand_columns_tiles"], file, "push.push_expand_columns_tiles"));
cfg.push.bossAdvanceSeconds = utility::requireDouble(tbl["push"]["boss_advance_seconds"], file, "push.boss_advance_seconds");
cfg.waves.threatRateFormula = utility::requireFormula(tbl["waves"]["threat_rate_formula"], file, "waves.threat_rate_formula");
cfg.waves.gapMinSeconds = utility::requireDouble(tbl["waves"]["gap_min_seconds"], file, "waves.gap_min_seconds");
cfg.waves.gapMaxSeconds = utility::requireDouble(tbl["waves"]["gap_max_seconds"], file, "waves.gap_max_seconds");
cfg.waves.spawnDurationSeconds = utility::requireDouble(tbl["waves"]["spawn_duration_seconds"], file, "waves.spawn_duration_seconds");
cfg.waves.bossCountdownSeconds = utility::requireDouble(tbl["waves"]["boss_countdown_seconds"], file, "waves.boss_countdown_seconds");
cfg.waves.bossThreatDurationSeconds = utility::requireDouble(tbl["waves"]["boss_threat_duration_seconds"], file, "waves.boss_threat_duration_seconds");
cfg.waves.bossQuietBeforeSeconds = utility::requireDouble(tbl["waves"]["boss_quiet_before_seconds"], file, "waves.boss_quiet_before_seconds");
cfg.waves.bossQuietAfterSeconds = utility::requireDouble(tbl["waves"]["boss_quiet_after_seconds"], file, "waves.boss_quiet_after_seconds");
if (cfg.waves.gapMinSeconds > cfg.waves.gapMaxSeconds)
{
throw utility::makeError(file, "waves", "gap_min_seconds > gap_max_seconds");
}
cfg.targeting.targetScoreFormula = utility::requireFormula(tbl["targeting"]["target_score_formula"], file, "targeting.target_score_formula");
cfg.targeting.overclaimPenaltyFormula = utility::requireFormula(tbl["targeting"]["overclaim_penalty_formula"], file, "targeting.overclaim_penalty_formula");
cfg.targeting.hysteresis = utility::requireDouble(tbl["targeting"]["target_hysteresis"], file, "targeting.target_hysteresis");
cfg.artifacts.artifactChanceFormula = utility::requireFormula(tbl["artifacts"]["artifact_chance_formula"], file, "artifacts.artifact_chance_formula");
cfg.artifacts.artifactWinCount = static_cast<int>(utility::requireInt(tbl["artifacts"]["artifact_win_count"], file, "artifacts.artifact_win_count"));
cfg.scroll.panSpeedSlow_tps = utility::requireDouble(tbl["scroll"]["pan_speed_slow_tiles_per_second"], file, "scroll.pan_speed_slow_tiles_per_second");
cfg.scroll.panSpeedFast_tps = utility::requireDouble(tbl["scroll"]["pan_speed_fast_tiles_per_second"], file, "scroll.pan_speed_fast_tiles_per_second");
cfg.scroll.panRampBandWidth_tiles = static_cast<int>(utility::requireInt(tbl["scroll"]["pan_ramp_band_width_tiles"], file, "scroll.pan_ramp_band_width_tiles"));
return cfg;
}

View File

@@ -59,4 +59,18 @@ struct ModuleDef
struct ModulesConfig struct ModulesConfig
{ {
std::vector<ModuleDef> modules; std::vector<ModuleDef> modules;
// Returns the definition for the given module id, or nullptr if the id has
// no entry in modules.toml.
const ModuleDef* findModuleDef(const std::string& id) const
{
for (const ModuleDef& def : modules)
{
if (def.id == id)
{
return &def;
}
}
return nullptr;
}
}; };

View File

@@ -47,4 +47,32 @@ struct RecipeDef
struct RecipesConfig struct RecipesConfig
{ {
std::vector<RecipeDef> recipes; std::vector<RecipeDef> recipes;
// Returns the definition for the given recipe id, or nullptr if the id has
// no entry in recipes.toml.
const RecipeDef* findRecipeDef(const std::string& id) const
{
for (const RecipeDef& recipe : recipes)
{
if (recipe.id == id)
{
return &recipe;
}
}
return nullptr;
}
// Same, but additionally requires the recipe to belong to the given building
// type — recipe ids are only unique per building type.
const RecipeDef* findRecipeDef(const std::string& id, BuildingType building) const
{
for (const RecipeDef& recipe : recipes)
{
if (recipe.id == id && recipe.building == building)
{
return &recipe;
}
}
return nullptr;
}
}; };

View File

@@ -49,4 +49,18 @@ struct ShipDef
struct ShipsConfig struct ShipsConfig
{ {
std::vector<ShipDef> ships; std::vector<ShipDef> ships;
// Returns the definition for the given ship schematic id, or nullptr if the
// id has no entry in ships.toml.
const ShipDef* findShipDef(const std::string& id) const
{
for (const ShipDef& def : ships)
{
if (def.id == id)
{
return &def;
}
}
return nullptr;
}
}; };

View File

@@ -0,0 +1,172 @@
#include "TomlHelpers.h"
#include <sstream>
#include <utility>
namespace utility
{
// --- Error helpers --------------------------------------------------------
std::runtime_error makeError(const std::string& file,
const std::string& path,
const std::string& why)
{
return std::runtime_error("Config: " + file + ": '" + path + "' " + why);
}
// --- Typed accessors (throw on missing or wrong type) ---------------------
int64_t requireInt(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
const std::optional<int64_t> value = node.value<int64_t>();
if (!value)
{
throw makeError(file, path, "missing or not an integer");
}
return *value;
}
double requireDouble(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
if (const std::optional<double> v = node.value<double>())
{
return *v;
}
if (const std::optional<int64_t> v = node.value<int64_t>())
{
return static_cast<double>(*v);
}
throw makeError(file, path, "missing or not a number");
}
std::string requireString(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
const std::optional<std::string> value = node.value<std::string>();
if (!value)
{
throw makeError(file, path, "missing or not a string");
}
return *value;
}
bool requireBool(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
const std::optional<bool> value = node.value<bool>();
if (!value)
{
throw makeError(file, path, "missing or not a boolean");
}
return *value;
}
const toml::array& requireArray(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
const toml::array* arr = node.as_array();
if (arr == nullptr)
{
throw makeError(file, path, "missing or not an array");
}
return *arr;
}
const toml::table& requireTable(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
const toml::table* tbl = node.as_table();
if (tbl == nullptr)
{
throw makeError(file, path, "missing or not a table");
}
return *tbl;
}
Formula requireFormula(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
const std::string source = requireString(node, file, path);
try
{
return Formula::compile(source);
}
catch (const std::exception& e)
{
throw makeError(file, path, std::string("formula error: ") + e.what());
}
}
std::vector<std::string> requireStringArray(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path)
{
const toml::array& arr = requireArray(node, file, path);
std::vector<std::string> result;
result.reserve(arr.size());
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = path + "[" + std::to_string(i) + "]";
const std::optional<std::string> s = arr[i].value<std::string>();
if (!s)
{
throw makeError(file, elemPath, "not a string");
}
result.push_back(*s);
}
return result;
}
std::vector<RecipeIngredient> parseIngredients(const toml::array& arr,
const std::string& file,
const std::string& path)
{
std::vector<RecipeIngredient> result;
result.reserve(arr.size());
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = path + "[" + std::to_string(i) + "]";
const toml::table* t = arr[i].as_table();
if (t == nullptr)
{
throw makeError(file, elemPath, "not a table");
}
// We need a mutable node_view to reuse our helpers, which is fine
// because the helpers never mutate.
toml::table& mt = const_cast<toml::table&>(*t);
RecipeIngredient ing;
ing.item = requireString(mt["item"], file, elemPath + ".item");
ing.amount = static_cast<int>(requireInt(mt["amount"], file, elemPath + ".amount"));
result.push_back(std::move(ing));
}
return result;
}
toml::table parseFile(const std::string& path, const std::string& file)
{
try
{
return toml::parse_file(path);
}
catch (const toml::parse_error& e)
{
std::ostringstream oss;
oss << "Config: " << file << ": TOML parse error: " << e.description()
<< " at " << e.source().begin;
throw std::runtime_error(oss.str());
}
}
} // namespace utility

View File

@@ -0,0 +1,70 @@
#pragma once
#include <cstdint>
#include <stdexcept>
#include <string>
#include <vector>
#include "toml.hpp"
#include "Formula.h"
#include "RecipesConfig.h" // for RecipeIngredient
// Shared TOML-parsing helpers used by two or more ConfigLoader per-domain
// loaders. Helpers used by exactly one domain stay local to that domain's
// .cpp file instead.
//
// Namespaced because the names are generic: VisualsLoader.cpp and
// BalancingConfig.cpp each have their own same-named helpers in anonymous
// namespaces, and unqualified globals here would form an overload set with
// them the moment either file includes this header.
namespace utility
{
// --- Error helpers ----------------------------------------------------------
std::runtime_error makeError(const std::string& file,
const std::string& path,
const std::string& why);
// --- Typed accessors (throw on missing or wrong type) -----------------------
int64_t requireInt(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path);
double requireDouble(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path);
std::string requireString(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path);
bool requireBool(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path);
const toml::array& requireArray(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path);
const toml::table& requireTable(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path);
Formula requireFormula(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path);
std::vector<std::string> requireStringArray(const toml::node_view<toml::node>& node,
const std::string& file,
const std::string& path);
std::vector<RecipeIngredient> parseIngredients(const toml::array& arr,
const std::string& file,
const std::string& path);
toml::table parseFile(const std::string& path, const std::string& file);
} // namespace utility

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

@@ -45,11 +45,11 @@ entt::entity EntityAdmin::spawnShip(QVector2D position, float hp, float maxHp,
const std::string& schematicId, bool isEnemy) const std::string& schematicId, bool isEnemy)
{ {
entt::entity entity = createEntity(); entt::entity entity = createEntity();
add<PositionComponent>(entity, PositionComponent{position}); addComponent<PositionComponent>(entity, PositionComponent{position});
add<HealthComponent>(entity, HealthComponent{hp, maxHp}); addComponent<HealthComponent>(entity, HealthComponent{hp, maxHp});
add<FactionComponent>(entity, FactionComponent{isEnemy}); addComponent<FactionComponent>(entity, FactionComponent{isEnemy});
add<FacingComponent>(entity, FacingComponent{0.0f}); addComponent<FacingComponent>(entity, FacingComponent{0.0f});
add<DynamicBodyComponent>(entity, DynamicBodyComponent{ addComponent<DynamicBodyComponent>(entity, DynamicBodyComponent{
maxSpeed_tpt, maxSpeed_tpt,
mainAcceleration_tptt, mainAcceleration_tptt,
maneuveringAcceleration_tptt, maneuveringAcceleration_tptt,
@@ -60,9 +60,9 @@ entt::entity EntityAdmin::spawnShip(QVector2D position, float hp, float maxHp,
QVector2D(0.0f, 0.0f), // linearAcceleration_tptt QVector2D(0.0f, 0.0f), // linearAcceleration_tptt
0.0f // angularAcceleration_rptt 0.0f // angularAcceleration_rptt
}); });
add<SensorRangeComponent>(entity, SensorRangeComponent{sensorRange_tiles}); addComponent<SensorRangeComponent>(entity, SensorRangeComponent{sensorRange_tiles});
add<ShipIdentityComponent>(entity, ShipIdentityComponent{schematicId}); addComponent<ShipIdentityComponent>(entity, ShipIdentityComponent{schematicId});
add<MovementIntentComponent>(entity, MovementIntentComponent{0, QVector2D(0.0f, 0.0f)}); addComponent<MovementIntentComponent>(entity, MovementIntentComponent{0, QVector2D(0.0f, 0.0f)});
return entity; return entity;
} }
@@ -73,28 +73,28 @@ entt::entity EntityAdmin::spawnStation(QPoint anchor, QSize footprint,
entt::entity entity = createEntity(); entt::entity entity = createEntity();
QVector2D center(anchor.x() + footprint.width() / 2.0f, QVector2D center(anchor.x() + footprint.width() / 2.0f,
anchor.y() + footprint.height() / 2.0f); anchor.y() + footprint.height() / 2.0f);
add<PositionComponent>(entity, PositionComponent{center}); addComponent<PositionComponent>(entity, PositionComponent{center});
add<HealthComponent>(entity, HealthComponent{hp, maxHp}); addComponent<HealthComponent>(entity, HealthComponent{hp, maxHp});
add<FactionComponent>(entity, FactionComponent{isEnemy}); addComponent<FactionComponent>(entity, FactionComponent{isEnemy});
add<StationBodyComponent>(entity, StationBodyComponent{anchor, footprint, bodyCells}); addComponent<StationBodyComponent>(entity, StationBodyComponent{anchor, footprint, bodyCells});
return entity; return entity;
} }
entt::entity EntityAdmin::spawnDebris(QVector2D position, int amount, Tick despawnAt) entt::entity EntityAdmin::spawnDebris(QVector2D position, int amount, Tick despawnAt)
{ {
entt::entity entity = createEntity(); entt::entity entity = createEntity();
add<PositionComponent>(entity, PositionComponent{position}); addComponent<PositionComponent>(entity, PositionComponent{position});
add<DebrisComponent>(entity, DebrisComponent{amount}); addComponent<DebrisComponent>(entity, DebrisComponent{amount});
add<DespawnAtComponent>(entity, DespawnAtComponent{despawnAt}); addComponent<DespawnAtComponent>(entity, DespawnAtComponent{despawnAt});
return entity; return entity;
} }
entt::entity EntityAdmin::spawnHqProxy(QVector2D position, float hp, float maxHp) entt::entity EntityAdmin::spawnHqProxy(QVector2D position, float hp, float maxHp)
{ {
entt::entity entity = createEntity(); entt::entity entity = createEntity();
add<PositionComponent>(entity, PositionComponent{position}); addComponent<PositionComponent>(entity, PositionComponent{position});
add<HealthComponent>(entity, HealthComponent{hp, maxHp}); addComponent<HealthComponent>(entity, HealthComponent{hp, maxHp});
add<FactionComponent>(entity, FactionComponent{false}); addComponent<FactionComponent>(entity, FactionComponent{false});
add<HqProxyComponent>(entity); addComponent<HqProxyComponent>(entity);
return entity; return entity;
} }

View File

@@ -73,9 +73,6 @@ public:
private: private:
entt::entity createEntity(); entt::entity createEntity();
template <typename T, typename... Args>
void add(entt::entity entity, Args&&... args);
entt::registry m_registry; entt::registry m_registry;
}; };
@@ -133,10 +130,4 @@ void EntityAdmin::removeComponent(entt::entity entity)
m_registry.remove<T>(entity); m_registry.remove<T>(entity);
} }
template <typename T, typename... Args>
void EntityAdmin::add(entt::entity entity, Args&&... args)
{
m_registry.emplace<T>(entity, std::forward<Args>(args)...);
}
#endif // ENTITY_ADMIN_H #endif // ENTITY_ADMIN_H

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

@@ -5,8 +5,10 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackEvaluator.h ${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackEvaluator.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackExecutor.h ${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/BehaviorTargeting.h ${CMAKE_CURRENT_SOURCE_DIR}/ai/BehaviorTargeting.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/Centroid.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapEvaluator.h ${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapEvaluator.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapExecutor.h ${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/OrbitAndAssignExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyEvaluator.h ${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyEvaluator.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyExecutor.h ${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairEvaluator.h ${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairEvaluator.h

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

@@ -41,35 +41,11 @@ ShipSystem::ShipSystem(const GameConfig& config, EntityAdmin& admin)
{ {
} }
const ShipDef* ShipSystem::findShipDef(const std::string& schematicId) const
{
for (const ShipDef& def : m_config.ships.ships)
{
if (def.id == schematicId)
{
return &def;
}
}
return nullptr;
}
const ModuleDef* ShipSystem::findModuleDef(const std::string& id) const
{
for (const ModuleDef& def : m_config.modules.modules)
{
if (def.id == id)
{
return &def;
}
}
return nullptr;
}
entt::entity ShipSystem::spawn(const std::string& schematicId, entt::entity ShipSystem::spawn(const std::string& schematicId,
QVector2D position, bool isEnemy, QVector2D position, bool isEnemy,
const std::optional<ShipLayoutConfig>& layout) const std::optional<ShipLayoutConfig>& layout)
{ {
const ShipDef* def = findShipDef(schematicId); const ShipDef* def = m_config.ships.findShipDef(schematicId);
assert(def != nullptr); assert(def != nullptr);
const float tickRate = static_cast<float>(kTickRateHz); const float tickRate = static_cast<float>(kTickRateHz);
@@ -116,7 +92,7 @@ entt::entity ShipSystem::spawn(const std::string& schematicId,
for (const PlacedModule& pm : modules) for (const PlacedModule& pm : modules)
{ {
const ModuleDef* modDef = findModuleDef(pm.moduleId); const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); } if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
if (modDef->weaponCapability) if (modDef->weaponCapability)
@@ -184,7 +160,7 @@ entt::entity ShipSystem::spawn(const std::string& schematicId,
for (const PlacedModule& pm : modules) for (const PlacedModule& pm : modules)
{ {
const ModuleDef* modDef = findModuleDef(pm.moduleId); const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); } if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
for (const ModuleStatModifier& sm : modDef->statModifiers) for (const ModuleStatModifier& sm : modDef->statModifiers)

View File

@@ -38,9 +38,6 @@ public:
void setRetreatEnabled(bool enabled); void setRetreatEnabled(bool enabled);
private: private:
const ShipDef* findShipDef(const std::string& schematicId) const;
const ModuleDef* findModuleDef(const std::string& id) const;
const GameConfig& m_config; const GameConfig& m_config;
EntityAdmin& m_admin; EntityAdmin& m_admin;
QVector2D m_rallyPoint; QVector2D m_rallyPoint;

View File

@@ -6,6 +6,7 @@
#include "AdvanceBehavior.h" #include "AdvanceBehavior.h"
#include "BehaviorKind.h" #include "BehaviorKind.h"
#include "Centroid.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "FactionComponent.h" #include "FactionComponent.h"
#include "HealthComponent.h" #include "HealthComponent.h"
@@ -16,28 +17,6 @@
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "tracing.h" #include "tracing.h"
namespace
{
// Accumulates positions to produce their centroid (the center between them).
struct Centroid
{
QVector2D sum;
int count = 0;
void add(const QVector2D& point)
{
sum += point;
count += 1;
}
std::optional<QVector2D> value() const
{
if (count == 0) { return std::nullopt; }
return sum / static_cast<float>(count);
}
};
}
void AdvanceExecutor::execute(EntityAdmin& admin) void AdvanceExecutor::execute(EntityAdmin& admin)
{ {
TRACE(); TRACE();

View File

@@ -2,12 +2,8 @@
#include "AttackBehavior.h" #include "AttackBehavior.h"
#include "BehaviorKind.h" #include "BehaviorKind.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "ModuleOwnerComponent.h" #include "OrbitAndAssignExecutor.h"
#include "MovementIntentComponent.h"
#include "PositionComponent.h"
#include "SelectedBehaviorComponent.h"
#include "tracing.h" #include "tracing.h"
#include "WeaponComponent.h" #include "WeaponComponent.h"
@@ -15,55 +11,7 @@ void AttackExecutor::execute(EntityAdmin& admin)
{ {
TRACE(); TRACE();
// Ships: move toward the behavior target. // Orbit the attack target and hand it to every weapon that can reach it
admin.forEach<AttackBehavior, SelectedBehaviorComponent, PositionComponent, // (REQ-SHP-ORBIT).
MovementIntentComponent>( executeOrbitAndAssign<AttackBehavior, WeaponComponent>(admin, BehaviorKind::Attack);
[&](entt::entity /*e*/, const AttackBehavior& attack,
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
MovementIntentComponent& intent)
{
if (selected.winner != BehaviorKind::Attack) { return; }
if (!attack.currentTarget) { return; }
const entt::entity t = *attack.currentTarget;
QVector2D center = pos.value;
float radius = 0.0f;
QVector2D centerVelocity;
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
{
center = admin.get<PositionComponent>(t).value;
radius = attack.orbitRadius_tiles;
if (admin.hasAll<DynamicBodyComponent>(t))
{
centerVelocity = admin.get<DynamicBodyComponent>(t).velocity_tpt;
}
}
intent = MovementIntentComponent{true, center, radius, centerVelocity};
});
// Weapons: assign the behavior target only if it is within this weapon's range.
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
[&](entt::entity /*we*/, WeaponComponent& weapon, const ModuleOwnerComponent& owner)
{
if (!admin.hasAll<AttackBehavior, SelectedBehaviorComponent>(owner.owner))
{
return;
}
const SelectedBehaviorComponent& selected =
admin.get<SelectedBehaviorComponent>(owner.owner);
if (selected.winner != BehaviorKind::Attack) { return; }
const AttackBehavior& attack = admin.get<AttackBehavior>(owner.owner);
if (!attack.currentTarget) { return; }
const entt::entity t = *attack.currentTarget;
if (!admin.isValid(t) || !admin.hasAll<PositionComponent>(t)) { return; }
const QVector2D ownerPos = admin.get<PositionComponent>(owner.owner).value;
const float dist = (admin.get<PositionComponent>(t).value - ownerPos).length();
if (dist <= weapon.range_tiles)
{
weapon.currentTarget = t;
}
});
} }

View File

@@ -0,0 +1,26 @@
#pragma once
#include <optional>
#include <QVector2D>
// Accumulates positions to produce their centroid (the center between them).
// Shared by the behavior executors that steer toward the middle of a group of
// entities (AdvanceExecutor: defence stations; StandbyExecutor: friendly ships).
struct Centroid
{
QVector2D sum;
int count = 0;
void add(const QVector2D& point)
{
sum += point;
count += 1;
}
std::optional<QVector2D> value() const
{
if (count == 0) { return std::nullopt; }
return sum / static_cast<float>(count);
}
};

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

@@ -0,0 +1,89 @@
#pragma once
#include <QVector2D>
#include "entt/entity/entity.hpp"
#include "BehaviorKind.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "ModuleOwnerComponent.h"
#include "MovementIntentComponent.h"
#include "PositionComponent.h"
#include "SelectedBehaviorComponent.h"
// Shared executor body for the behaviors that orbit a single target entity and then
// hand that target to the ship's in-range modules (REQ-SHP-ORBIT): Attack (with
// WeaponComponent) and Repair (with RepairToolComponent).
//
// Two passes, in this order — the order and the exact sequence of component writes
// are load-bearing for determinism (see the Tick Order section of
// docs/architecture.md):
// 1. Ships that have `Behavior` and won with `kind` write their MovementIntent to
// orbit the behavior's target at the behavior's orbit radius. A target that is
// gone (or has no position) degenerates to "hold position": the ship's own
// position with a zero radius.
// 2. Modules of type `ModuleComponent` whose owner won with `kind` adopt the
// behavior's target, but only when it lies within that module's own range.
// Out-of-range modules keep whatever target they already had, which
// CombatSystem/RepairSystem re-validate.
//
// `Behavior` must expose `std::optional<entt::entity> currentTarget` and
// `float orbitRadius_tiles`; `ModuleComponent` must expose `float range_tiles` and
// `std::optional<entt::entity> currentTarget`.
template <typename Behavior, typename ModuleComponent>
void executeOrbitAndAssign(EntityAdmin& admin, BehaviorKind kind)
{
// Ships: move toward the behavior target.
admin.forEach<Behavior, SelectedBehaviorComponent, PositionComponent,
MovementIntentComponent>(
[&](entt::entity /*e*/, const Behavior& behavior,
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
MovementIntentComponent& intent)
{
if (selected.winner != kind) { return; }
if (!behavior.currentTarget) { return; }
const entt::entity t = *behavior.currentTarget;
QVector2D center = pos.value;
float radius = 0.0f;
QVector2D centerVelocity;
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
{
center = admin.get<PositionComponent>(t).value;
radius = behavior.orbitRadius_tiles;
if (admin.hasAll<DynamicBodyComponent>(t))
{
centerVelocity = admin.get<DynamicBodyComponent>(t).velocity_tpt;
}
}
intent = MovementIntentComponent{true, center, radius, centerVelocity};
});
// Modules: assign the behavior target only if it is within this module's range.
admin.forEach<ModuleComponent, ModuleOwnerComponent>(
[&](entt::entity /*me*/, ModuleComponent& module,
const ModuleOwnerComponent& owner)
{
if (!admin.hasAll<Behavior, SelectedBehaviorComponent>(owner.owner))
{
return;
}
const SelectedBehaviorComponent& selected =
admin.get<SelectedBehaviorComponent>(owner.owner);
if (selected.winner != kind) { return; }
const Behavior& behavior = admin.get<Behavior>(owner.owner);
if (!behavior.currentTarget) { return; }
const entt::entity t = *behavior.currentTarget;
if (!admin.isValid(t) || !admin.hasAll<PositionComponent>(t)) { return; }
const QVector2D ownerPos = admin.get<PositionComponent>(owner.owner).value;
const float dist = (admin.get<PositionComponent>(t).value - ownerPos).length();
if (dist <= module.range_tiles)
{
module.currentTarget = t;
}
});
}

View File

@@ -1,69 +1,17 @@
#include "RepairExecutor.h" #include "RepairExecutor.h"
#include "BehaviorKind.h" #include "BehaviorKind.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "ModuleOwnerComponent.h" #include "OrbitAndAssignExecutor.h"
#include "MovementIntentComponent.h"
#include "PositionComponent.h"
#include "RepairBehavior.h" #include "RepairBehavior.h"
#include "RepairToolComponent.h" #include "RepairToolComponent.h"
#include "SelectedBehaviorComponent.h"
#include "tracing.h" #include "tracing.h"
void RepairExecutor::execute(EntityAdmin& admin) void RepairExecutor::execute(EntityAdmin& admin)
{ {
TRACE(); TRACE();
// Ships: move toward the repair target. // Orbit the repair target and hand it to every repair tool that can reach it
admin.forEach<RepairBehavior, SelectedBehaviorComponent, PositionComponent, // (REQ-SHP-ORBIT).
MovementIntentComponent>( executeOrbitAndAssign<RepairBehavior, RepairToolComponent>(admin, BehaviorKind::Repair);
[&](entt::entity /*e*/, const RepairBehavior& repair,
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
MovementIntentComponent& intent)
{
if (selected.winner != BehaviorKind::Repair) { return; }
if (!repair.currentTarget) { return; }
const entt::entity t = *repair.currentTarget;
QVector2D center = pos.value;
float radius = 0.0f;
QVector2D centerVelocity;
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
{
center = admin.get<PositionComponent>(t).value;
radius = repair.orbitRadius_tiles;
if (admin.hasAll<DynamicBodyComponent>(t))
{
centerVelocity = admin.get<DynamicBodyComponent>(t).velocity_tpt;
}
}
intent = MovementIntentComponent{true, center, radius, centerVelocity};
});
// Repair tools: prefer the behavior target if it is within tool range.
admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
[&](entt::entity /*re*/, RepairToolComponent& tool, const ModuleOwnerComponent& owner)
{
if (!admin.hasAll<RepairBehavior, SelectedBehaviorComponent>(owner.owner))
{
return;
}
const SelectedBehaviorComponent& selected =
admin.get<SelectedBehaviorComponent>(owner.owner);
if (selected.winner != BehaviorKind::Repair) { return; }
const RepairBehavior& repair = admin.get<RepairBehavior>(owner.owner);
if (!repair.currentTarget) { return; }
const entt::entity t = *repair.currentTarget;
if (!admin.isValid(t) || !admin.hasAll<PositionComponent>(t)) { return; }
const QVector2D ownerPos = admin.get<PositionComponent>(owner.owner).value;
const float dist = (admin.get<PositionComponent>(t).value - ownerPos).length();
if (dist <= tool.range_tiles)
{
tool.currentTarget = t;
}
});
} }

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

@@ -5,6 +5,7 @@
#include <QVector2D> #include <QVector2D>
#include "BehaviorKind.h" #include "BehaviorKind.h"
#include "Centroid.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "FactionComponent.h" #include "FactionComponent.h"
#include "HealthComponent.h" #include "HealthComponent.h"
@@ -16,28 +17,6 @@
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "tracing.h" #include "tracing.h"
namespace
{
// Accumulates positions to produce their centroid (the center between them).
struct Centroid
{
QVector2D sum;
int count = 0;
void add(const QVector2D& point)
{
sum += point;
count += 1;
}
std::optional<QVector2D> value() const
{
if (count == 0) { return std::nullopt; }
return sum / static_cast<float>(count);
}
};
}
void StandbyExecutor::execute(EntityAdmin& admin) void StandbyExecutor::execute(EntityAdmin& admin)
{ {
TRACE(); TRACE();

View File

@@ -2,10 +2,9 @@
#include "Event.h" #include "Event.h"
// Fired when the collected artifact count changes. Carries no payload —
// subscribers re-read Simulation::getArtifactCount(), and the win count from
// world.artifacts.artifactWinCount.
class ArtifactCountChangedEvent : public Event class ArtifactCountChangedEvent : public Event
{ {
public:
ArtifactCountChangedEvent(int count, int winCount) : count(count), winCount(winCount) {}
const int count;
const int winCount;
}; };

View File

@@ -2,16 +2,11 @@
#define BOSS_WAVE_UPDATED_EVENT_H #define BOSS_WAVE_UPDATED_EVENT_H
#include "Event.h" #include "Event.h"
#include "Tick.h"
// Fired when the boss wave counter or its countdown changes. Carries no payload —
// subscribers re-read Simulation::getBossWaveCounter() and getBossCountdownTicks().
class BossWaveUpdatedEvent : public Event class BossWaveUpdatedEvent : public Event
{ {
public:
BossWaveUpdatedEvent(int counter, Tick countdownTicks)
: counter(counter), countdownTicks(countdownTicks) {}
const int counter;
const Tick countdownTicks;
}; };
#endif // BOSS_WAVE_UPDATED_EVENT_H #endif // BOSS_WAVE_UPDATED_EVENT_H

View File

@@ -3,12 +3,10 @@
#include "Event.h" #include "Event.h"
// Fired when the building block stock changes. Carries no payload — subscribers
// re-read Simulation::getBuildingBlocksStock().
class BuildingBlocksChangedEvent : public Event class BuildingBlocksChangedEvent : public Event
{ {
public:
explicit BuildingBlocksChangedEvent(int blocks) : blocks(blocks) {}
const int blocks;
}; };
#endif // BUILDING_BLOCKS_CHANGED_EVENT_H #endif // BUILDING_BLOCKS_CHANGED_EVENT_H

View File

@@ -4,14 +4,11 @@
#include "Event.h" #include "Event.h"
// Fired when the current asteroid-expansion cost changes (REQ-EXP-COST): once at // Fired when the current asteroid-expansion cost changes (REQ-EXP-COST): once at
// startup and again after each expansion is purchased. Carries the cost in // startup and again after each expansion is purchased. Carries no payload — the
// building blocks so the header Expand button can update its caption/enabled // header Expand button re-reads Simulation::getCurrentExpansionCost() to update
// state (REQ-UI-EXPAND-BUTTON). // its caption and enabled state (REQ-UI-EXPAND-BUTTON).
class ExpansionCostChangedEvent : public Event class ExpansionCostChangedEvent : public Event
{ {
public:
explicit ExpansionCostChangedEvent(int cost) : cost(cost) {}
const int cost;
}; };
#endif // EXPANSION_COST_CHANGED_EVENT_H #endif // EXPANSION_COST_CHANGED_EVENT_H

View File

@@ -2,14 +2,11 @@
#define TICK_ADVANCED_EVENT_H #define TICK_ADVANCED_EVENT_H
#include "Event.h" #include "Event.h"
#include "Tick.h"
// Fired when the simulation tick advances. Carries no payload — subscribers
// re-read Simulation::getCurrentTick().
class TickAdvancedEvent : public Event class TickAdvancedEvent : public Event
{ {
public:
explicit TickAdvancedEvent(Tick tick) : tick(tick) {}
const Tick tick;
}; };
#endif // TICK_ADVANCED_EVENT_H #endif // TICK_ADVANCED_EVENT_H

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,39 +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;
const BuildingDef* findBuildingDef(BuildingType type) const;
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;
const ShipDef* findShipDef(const std::string& id) const;
const ModuleDef* findModuleDef(const std::string& id) 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;
@@ -295,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

@@ -21,22 +21,9 @@ ShipStats calculateShipStats(const GameConfig& config,
{ {
ShipStats result{}; ShipStats result{};
const ShipDef* shipDef = nullptr; const ShipDef* shipDef = config.ships.findShipDef(shipId);
for (const ShipDef& d : config.ships.ships)
{
if (d.id == shipId) { shipDef = &d; break; }
}
if (!shipDef) { return result; } if (!shipDef) { return result; }
auto findModuleDef = [&](const std::string& id) -> const ModuleDef*
{
for (const ModuleDef& d : config.modules.modules)
{
if (d.id == id) { return &d; }
}
return nullptr;
};
const double tileSize = config.world.tileSize_m; const double tileSize = config.world.tileSize_m;
// --- Base hull stats (convert from SI to display units) ------------------ // --- Base hull stats (convert from SI to display units) ------------------
@@ -67,7 +54,7 @@ ShipStats calculateShipStats(const GameConfig& config,
for (const PlacedModule& pm : modules) for (const PlacedModule& pm : modules)
{ {
const ModuleDef* def = findModuleDef(pm.moduleId); const ModuleDef* def = config.modules.findModuleDef(pm.moduleId);
if (!def) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); } if (!def) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
if (def->weaponCapability) if (def->weaponCapability)
@@ -107,7 +94,7 @@ ShipStats calculateShipStats(const GameConfig& config,
for (const PlacedModule& pm : modules) for (const PlacedModule& pm : modules)
{ {
const ModuleDef* def = findModuleDef(pm.moduleId); const ModuleDef* def = config.modules.findModuleDef(pm.moduleId);
if (!def) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); } if (!def) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
for (const ModuleStatModifier& sm : def->statModifiers) for (const ModuleStatModifier& sm : def->statModifiers)

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,39 +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);
m_buildingSystem = std::make_unique<BuildingSystem>( initializeSubsystems();
m_config,
m_beltSystem,
[this]() { return allocateBuildingId(); },
[this](int amount) { m_buildingBlocksStock += amount; },
[this](const std::string& id, QVector2D pos,
const std::optional<ShipLayoutConfig>& layout) {
const std::map<std::string, SchematicState>::const_iterator it =
m_schematicLevels.find(id);
if (it == m_schematicLevels.end() || !it->second.unlocked)
{
return;
}
m_shipSystem->spawn(id, pos, /*isEnemy=*/false, layout);
},
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>(m_config);
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
m_debrisSystem = std::make_unique<DebrisSystem>(m_admin);
m_salvagerSystem = std::make_unique<SalvagerSystem>(m_admin);
m_repairSystem = std::make_unique<RepairSystem>(m_admin);
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config);
initializeUnlockState(); m_unlockState.initializeUnlockState();
placeInitialStructures(); placeInitialStructures();
registerForEvents(); registerForEvents();
} }
@@ -119,7 +100,16 @@ 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();
m_unlockState.initializeUnlockState();
placeInitialStructures();
}
void Simulation::initializeSubsystems()
{
m_buildingSystem = std::make_unique<BuildingSystem>( m_buildingSystem = std::make_unique<BuildingSystem>(
m_config, m_config,
m_beltSystem, m_beltSystem,
@@ -127,9 +117,7 @@ void Simulation::reset(unsigned int seed)
[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;
} }
@@ -137,6 +125,9 @@ void Simulation::reset(unsigned int seed)
}, },
[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>();
@@ -146,58 +137,6 @@ void Simulation::reset(unsigned int seed)
m_repairSystem = std::make_unique<RepairSystem>(m_admin); m_repairSystem = std::make_unique<RepairSystem>(m_admin);
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng); m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config); m_combatSystem = std::make_unique<CombatSystem>(m_config);
initializeUnlockState();
placeInitialStructures();
}
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();
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -219,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;
} }
@@ -240,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:
@@ -309,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)
@@ -329,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);
@@ -373,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);
@@ -423,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);
@@ -440,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.
@@ -495,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);
@@ -512,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());
} }
} }
@@ -586,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>(
@@ -633,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);
} }
@@ -659,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)
@@ -671,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()));
@@ -729,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);
@@ -957,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
@@ -1079,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
@@ -1109,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
@@ -1134,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)
@@ -1176,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;
} }
@@ -1195,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()
@@ -1213,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;
@@ -165,6 +170,11 @@ private:
BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId. BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId.
// (Re-)create every owned subsystem. Shared by the constructor and reset();
// the construction order is load-bearing for determinism, so both paths must
// go through here. Only called before the first tick of a run.
void initializeSubsystems();
// Populate HQ, player defence stations, and the first enemy station set. // Populate HQ, player defence stations, and the first enemy station set.
void placeInitialStructures(); void placeInitialStructures();
@@ -198,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;

View File

@@ -335,15 +335,7 @@ double calculateShipThreatCost(const ThreatCostTable& table,
const std::string& shipId, const std::string& shipId,
const std::vector<PlacedModule>& modules) const std::vector<PlacedModule>& modules)
{ {
const ShipDef* shipDef = nullptr; const ShipDef* shipDef = config.ships.findShipDef(shipId);
for (const ShipDef& d : config.ships.ships)
{
if (d.id == shipId)
{
shipDef = &d;
break;
}
}
if (shipDef == nullptr) if (shipDef == nullptr)
{ {
return 0.0; return 0.0;
@@ -357,15 +349,7 @@ double calculateShipThreatCost(const ThreatCostTable& table,
// Add module production times and material threats. // Add module production times and material threats.
for (const PlacedModule& pm : modules) for (const PlacedModule& pm : modules)
{ {
const ModuleDef* moduleDef = nullptr; const ModuleDef* moduleDef = config.modules.findModuleDef(pm.moduleId);
for (const ModuleDef& d : config.modules.modules)
{
if (d.id == pm.moduleId)
{
moduleDef = &d;
break;
}
}
if (moduleDef == nullptr) if (moduleDef == nullptr)
{ {
continue; continue;

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

@@ -11,11 +11,7 @@
#include "Simulation.h" #include "Simulation.h"
#include "SimulationTestAccess.h" #include "SimulationTestAccess.h"
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "TestConfig.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
static void killEnemyStations(Simulation& sim) static void killEnemyStations(Simulation& sim)
{ {
@@ -51,7 +47,7 @@ static int findArtifactChoiceIndex(const Simulation& sim)
TEST_CASE("ArtifactWinCondition: artifact_chance_formula and artifact_win_count are loaded", TEST_CASE("ArtifactWinCondition: artifact_chance_formula and artifact_win_count are loaded",
"[artifact_win]") "[artifact_win]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
CHECK(cfg.world.artifacts.artifactWinCount == 3); CHECK(cfg.world.artifacts.artifactWinCount == 3);
// 0.05 * x at x=2 should be 0.1 // 0.05 * x at x=2 should be 0.1
CHECK(cfg.world.artifacts.artifactChanceFormula.evaluate(2.0) == Approx(0.1)); CHECK(cfg.world.artifacts.artifactChanceFormula.evaluate(2.0) == Approx(0.1));
@@ -64,7 +60,7 @@ TEST_CASE("ArtifactWinCondition: artifact_chance_formula and artifact_win_count
TEST_CASE("ArtifactWinCondition: artifact count is 0 and isWon is false at game start", TEST_CASE("ArtifactWinCondition: artifact count is 0 and isWon is false at game start",
"[artifact_win]") "[artifact_win]")
{ {
const Simulation sim(loadConfig()); const Simulation sim(loadTestConfig());
CHECK(sim.getArtifactCount() == 0); CHECK(sim.getArtifactCount() == 0);
CHECK_FALSE(sim.isWon()); CHECK_FALSE(sim.isWon());
} }
@@ -76,7 +72,7 @@ TEST_CASE("ArtifactWinCondition: artifact count is 0 and isWon is false at game
TEST_CASE("ArtifactWinCondition: artifact option appears when chance formula returns 1", TEST_CASE("ArtifactWinCondition: artifact option appears when chance formula returns 1",
"[artifact_win]") "[artifact_win]")
{ {
GameConfig cfg = loadConfig(); GameConfig cfg = loadTestConfig();
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
Simulation sim(std::move(cfg)); Simulation sim(std::move(cfg));
@@ -95,7 +91,7 @@ TEST_CASE("ArtifactWinCondition: artifact option appears when chance formula ret
TEST_CASE("ArtifactWinCondition: at most 2 schematic options accompany the artifact", TEST_CASE("ArtifactWinCondition: at most 2 schematic options accompany the artifact",
"[artifact_win]") "[artifact_win]")
{ {
GameConfig cfg = loadConfig(); GameConfig cfg = loadTestConfig();
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
Simulation sim(std::move(cfg)); Simulation sim(std::move(cfg));
@@ -112,7 +108,7 @@ TEST_CASE("ArtifactWinCondition: at most 2 schematic options accompany the artif
TEST_CASE("ArtifactWinCondition: no artifact option when chance formula returns 0", TEST_CASE("ArtifactWinCondition: no artifact option when chance formula returns 0",
"[artifact_win]") "[artifact_win]")
{ {
GameConfig cfg = loadConfig(); GameConfig cfg = loadTestConfig();
cfg.world.artifacts.artifactChanceFormula = Formula::compile("0"); cfg.world.artifacts.artifactChanceFormula = Formula::compile("0");
Simulation sim(std::move(cfg)); Simulation sim(std::move(cfg));
@@ -133,7 +129,7 @@ TEST_CASE("ArtifactWinCondition: no artifact option when chance formula returns
TEST_CASE("ArtifactWinCondition: selecting artifact increments artifact count", TEST_CASE("ArtifactWinCondition: selecting artifact increments artifact count",
"[artifact_win]") "[artifact_win]")
{ {
GameConfig cfg = loadConfig(); GameConfig cfg = loadTestConfig();
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
Simulation sim(std::move(cfg)); Simulation sim(std::move(cfg));
@@ -151,7 +147,7 @@ TEST_CASE("ArtifactWinCondition: selecting artifact increments artifact count",
TEST_CASE("ArtifactWinCondition: selecting a non-artifact option does not increment artifact count", TEST_CASE("ArtifactWinCondition: selecting a non-artifact option does not increment artifact count",
"[artifact_win]") "[artifact_win]")
{ {
GameConfig cfg = loadConfig(); GameConfig cfg = loadTestConfig();
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
Simulation sim(std::move(cfg)); Simulation sim(std::move(cfg));
@@ -176,7 +172,7 @@ TEST_CASE("ArtifactWinCondition: selecting a non-artifact option does not increm
TEST_CASE("ArtifactWinCondition: isWon becomes true when artifact count reaches win count", TEST_CASE("ArtifactWinCondition: isWon becomes true when artifact count reaches win count",
"[artifact_win]") "[artifact_win]")
{ {
GameConfig cfg = loadConfig(); GameConfig cfg = loadTestConfig();
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
cfg.world.artifacts.artifactWinCount = 1; cfg.world.artifacts.artifactWinCount = 1;
Simulation sim(std::move(cfg)); Simulation sim(std::move(cfg));
@@ -196,7 +192,7 @@ TEST_CASE("ArtifactWinCondition: isWon becomes true when artifact count reaches
TEST_CASE("ArtifactWinCondition: isWon stays false when artifact count is below win count", TEST_CASE("ArtifactWinCondition: isWon stays false when artifact count is below win count",
"[artifact_win]") "[artifact_win]")
{ {
GameConfig cfg = loadConfig(); GameConfig cfg = loadTestConfig();
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
cfg.world.artifacts.artifactWinCount = 2; cfg.world.artifacts.artifactWinCount = 2;
Simulation sim(std::move(cfg)); Simulation sim(std::move(cfg));
@@ -212,7 +208,7 @@ TEST_CASE("ArtifactWinCondition: isWon stays false when artifact count is below
TEST_CASE("ArtifactWinCondition: isWon becomes true after collecting required number of artifacts", TEST_CASE("ArtifactWinCondition: isWon becomes true after collecting required number of artifacts",
"[artifact_win]") "[artifact_win]")
{ {
GameConfig cfg = loadConfig(); GameConfig cfg = loadTestConfig();
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
cfg.world.artifacts.artifactWinCount = 2; cfg.world.artifacts.artifactWinCount = 2;
Simulation sim(std::move(cfg)); Simulation sim(std::move(cfg));
@@ -237,7 +233,7 @@ TEST_CASE("ArtifactWinCondition: isWon becomes true after collecting required nu
TEST_CASE("ArtifactWinCondition: reset clears artifact count and win state", TEST_CASE("ArtifactWinCondition: reset clears artifact count and win state",
"[artifact_win]") "[artifact_win]")
{ {
GameConfig cfg = loadConfig(); GameConfig cfg = loadTestConfig();
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
cfg.world.artifacts.artifactWinCount = 1; cfg.world.artifacts.artifactWinCount = 1;
Simulation sim(std::move(cfg)); Simulation sim(std::move(cfg));

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"
@@ -45,25 +48,23 @@
#include "ShipLayout.h" #include "ShipLayout.h"
#include "ShipSystem.h" #include "ShipSystem.h"
#include "Tick.h" #include "Tick.h"
#include "TestConfig.h"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fixture // Fixture
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
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;
@@ -75,7 +76,7 @@ struct Fixture
std::vector<BeamFiredEvent> beamEvents; std::vector<BeamFiredEvent> beamEvents;
explicit Fixture() explicit Fixture()
: cfg(loadConfig()) : cfg(loadTestConfig())
, belts(cfg.world.beltSpeed_tps) , belts(cfg.world.beltSpeed_tps)
, nextBuildingId(1) , nextBuildingId(1)
, stock(0) , stock(0)
@@ -86,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)
@@ -99,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);
} }
@@ -139,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;
} }
@@ -953,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),
@@ -988,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);
@@ -1017,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>
@@ -20,6 +21,7 @@
#include "SimulationTestAccess.h" #include "SimulationTestAccess.h"
#include "SurfaceMask.h" #include "SurfaceMask.h"
#include "Tick.h" #include "Tick.h"
#include "TestConfig.h"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers that mirror the production implementations under test. // Helpers that mirror the production implementations under test.
@@ -147,11 +149,6 @@ static void applyRotationCCW(Blueprint& bp, const GameConfig& cfg)
} }
} }
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// Mirrors BlueprintPanel::createBlueprintFromSelection's player-placeable filter: // Mirrors BlueprintPanel::createBlueprintFromSelection's player-placeable filter:
// building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false // building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false
// are silently excluded before the bounding-box center and offsets are computed. // are silently excluded before the bounding-box center and offsets are computed.
@@ -312,7 +309,7 @@ TEST_CASE("Blueprint: non-axis-aligned offset rotates correctly", "[blueprint]")
TEST_CASE("Blueprint: CW constellation rotation updates offset and building rotation", "[blueprint]") TEST_CASE("Blueprint: CW constellation rotation updates offset and building rotation", "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
// Building one tile to the right, facing East. // Building one tile to the right, facing East.
Blueprint bp; Blueprint bp;
bp.name = "test"; bp.name = "test";
@@ -332,7 +329,7 @@ TEST_CASE("Blueprint: CW constellation rotation updates offset and building rota
TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rotation", "[blueprint]") TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rotation", "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
Blueprint bp; Blueprint bp;
bp.name = "test"; bp.name = "test";
BlueprintBuilding bb; BlueprintBuilding bb;
@@ -351,7 +348,7 @@ TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rot
TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "[blueprint]") TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
Blueprint bp; Blueprint bp;
bp.name = "test"; bp.name = "test";
BlueprintBuilding bb; BlueprintBuilding bb;
@@ -371,7 +368,7 @@ TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "
TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[blueprint]") TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
// Two buildings left and right of center; after CW they should be above and below. // Two buildings left and right of center; after CW they should be above and below.
Blueprint bp; Blueprint bp;
bp.name = "test"; bp.name = "test";
@@ -398,7 +395,7 @@ TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[
TEST_CASE("Blueprint: CW rotation keeps belt adjacent to miner output port", "[blueprint]") TEST_CASE("Blueprint: CW rotation keeps belt adjacent to miner output port", "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
// East miner: anchor (0,0), body cells (0,0),(1,0),(0,1). // East miner: anchor (0,0), body cells (0,0),(1,0),(0,1).
// Output port indicator '>' at (1,1) → port tile (1,1), direction East. // Output port indicator '>' at (1,1) → port tile (1,1), direction East.
@@ -436,7 +433,7 @@ TEST_CASE("Blueprint: CW rotation keeps belt adjacent to miner output port", "[b
TEST_CASE("Blueprint: CCW rotation keeps belt adjacent to miner output port", "[blueprint]") TEST_CASE("Blueprint: CCW rotation keeps belt adjacent to miner output port", "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
Blueprint bp; Blueprint bp;
bp.name = "test"; bp.name = "test";
@@ -473,7 +470,7 @@ TEST_CASE("Blueprint: CCW rotation keeps belt adjacent to miner output port", "[
TEST_CASE("Blueprint creation: non-player-placeable building alone yields empty blueprint", TEST_CASE("Blueprint creation: non-player-placeable building alone yields empty blueprint",
"[blueprint]") "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
// Hq has no entry in buildings.toml, so it is treated as non-player-placeable. // Hq has no entry in buildings.toml, so it is treated as non-player-placeable.
const BuildingSpec hq{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Hq, Rotation::East }; const BuildingSpec hq{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Hq, Rotation::East };
@@ -485,7 +482,7 @@ TEST_CASE("Blueprint creation: non-player-placeable building alone yields empty
TEST_CASE("Blueprint creation: mixed selection keeps only player-placeable buildings", TEST_CASE("Blueprint creation: mixed selection keeps only player-placeable buildings",
"[blueprint]") "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const BuildingSpec belt{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East }; const BuildingSpec belt{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East };
const BuildingSpec hq { QPoint(-3, 0), {QPoint(-3, 0)}, BuildingType::Hq, Rotation::East }; const BuildingSpec hq { QPoint(-3, 0), {QPoint(-3, 0)}, BuildingType::Hq, Rotation::East };
@@ -498,7 +495,7 @@ TEST_CASE("Blueprint creation: mixed selection keeps only player-placeable build
TEST_CASE("Blueprint creation: bounding box ignores non-player-placeable buildings", TEST_CASE("Blueprint creation: bounding box ignores non-player-placeable buildings",
"[blueprint]") "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
// Belt at (-5, 0). HQ at (-3, 0) — excluded from the blueprint. // Belt at (-5, 0). HQ at (-3, 0) — excluded from the blueprint.
// If HQ were included: bboxX = [-5, -3], center.x = -4, belt offset = -1. // If HQ were included: bboxX = [-5, -3], center.x = -4, belt offset = -1.
@@ -520,7 +517,7 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor",
// Simulate placing a two-belt blueprint with offsets (-1, 0) and (+1, 0) // Simulate placing a two-belt blueprint with offsets (-1, 0) and (+1, 0)
// at cursor tile (-5, 0). Expected anchors: (-6, 0) and (-4, 0). // at cursor tile (-5, 0). Expected anchors: (-6, 0) and (-4, 0).
// (Belt surface_mask ["A>"] — body at relative (0,0), port at (1,0).) // (Belt surface_mask ["A>"] — body at relative (0,0), port at (1,0).)
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
const QPoint cursor(-5, 0); const QPoint cursor(-5, 0);
const QPoint offsetA(-1, 0); const QPoint offsetA(-1, 0);
@@ -533,14 +530,14 @@ 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]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
// Find belt cost from config (belt cost = 2 in test config). // Find belt cost from config (belt cost = 2 in test config).
int beltCost = 0; int beltCost = 0;
@@ -563,7 +560,7 @@ TEST_CASE("Blueprint placement: cost is deducted for each building in sequence",
TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId and deducts nothing", TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId and deducts nothing",
"[blueprint]") "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
// Find miner cost (15 in test config) — expensive enough to exhaust a small stock. // Find miner cost (15 in test config) — expensive enough to exhaust a small stock.
int minerCost = 0; int minerCost = 0;
@@ -594,7 +591,7 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId a
TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and charges nothing", TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and charges nothing",
"[blueprint]") "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
const int startBlocks = sim.getBuildingBlocksStock(); const int startBlocks = sim.getBuildingBlocksStock();
// A miner is all-asteroid; placing it in space (x >= 0) violates the terrain // A miner is all-asteroid; placing it in space (x >= 0) violates the terrain
@@ -604,13 +601,13 @@ 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",
"[blueprint]") "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
int minerCost = 0; int minerCost = 0;
for (const BuildingDef& def : sim.getConfig().buildings.buildings) for (const BuildingDef& def : sim.getConfig().buildings.buildings)
@@ -627,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)));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -663,15 +660,15 @@ TEST_CASE("Blueprint: building with no recipe has empty recipeId", "[blueprint]"
TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "[blueprint]") TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid. // Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
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,11 +676,11 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
TEST_CASE("Blueprint placement: recipe transfers to building after construction completes", TEST_CASE("Blueprint placement: recipe transfers to building after construction completes",
"[blueprint]") "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
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.
@@ -692,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");
} }
@@ -705,15 +702,15 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]") TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
// Freshly placed → a ConstructionSite (not ticked to completion). A 1x1 belt keeps // Freshly placed → a ConstructionSite (not ticked to completion). A 1x1 belt keeps
// the body-cell bounding-box centered on the anchor, so a single site → zero offset. // the body-cell bounding-box centered on the anchor, so a single site → zero offset.
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 });
@@ -724,12 +721,12 @@ TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]")
TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blueprint]") TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
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 });
@@ -740,22 +737,22 @@ TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blue
TEST_CASE("Blueprint creation: mixed operational building and construction site are both captured", TEST_CASE("Blueprint creation: mixed operational building and construction site are both captured",
"[blueprint]") "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
// Building A: place, configure, and tick to completion so it is operational. // Building A: place, configure, and tick to completion so it is operational.
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 });
@@ -772,14 +769,14 @@ TEST_CASE("Blueprint creation: mixed operational building and construction site
TEST_CASE("Blueprint creation: selectionHasPlaceableBuilding sees a construction site", "[blueprint]") TEST_CASE("Blueprint creation: selectionHasPlaceableBuilding sees a construction site", "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
REQUIRE_FALSE(selectionHasPlaceableBuilding(sim, {})); REQUIRE_FALSE(selectionHasPlaceableBuilding(sim, {}));
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 }));
} }
@@ -787,7 +784,7 @@ TEST_CASE("Blueprint placement: interceptor schematic is unlocked at game start"
{ {
// "interceptor" has unlock_at_station_level = -1 in the test config. // "interceptor" has unlock_at_station_level = -1 in the test config.
// This confirms the guard in placeBlueprintAtTile passes for start-unlocked schematics. // This confirms the guard in placeBlueprintAtTile passes for start-unlocked schematics.
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
REQUIRE(sim.isSchematicUnlocked("interceptor")); REQUIRE(sim.isSchematicUnlocked("interceptor"));
} }
@@ -796,7 +793,7 @@ TEST_CASE("Blueprint placement: repair_ship schematic is locked at game start",
// "repair_ship" has unlock_at_station_level = 0 in the test config. // "repair_ship" has unlock_at_station_level = 0 in the test config.
// This confirms the guard in placeBlueprintAtTile blocks locked schematics, // This confirms the guard in placeBlueprintAtTile blocks locked schematics,
// leaving the shipyard's schematic unset. // leaving the shipyard's schematic unset.
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship")); REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
} }
@@ -842,7 +839,7 @@ TEST_CASE("Blueprint: building without layout has nullopt shipLayout", "[bluepri
TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout", "[blueprint]") TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout", "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
// Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East: // Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East:
// A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles. // A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles.
@@ -857,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);
@@ -869,7 +866,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
TEST_CASE("Blueprint placement: ship layout transfers to building after construction completes", TEST_CASE("Blueprint placement: ship layout transfers to building after construction completes",
"[blueprint]") "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East).value(); const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId); REQUIRE(id != kInvalidBuildingId);
@@ -881,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;
@@ -896,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>
@@ -13,6 +14,7 @@
#include "ShipsConfig.h" #include "ShipsConfig.h"
#include "Simulation.h" #include "Simulation.h"
#include "SimulationTestAccess.h" #include "SimulationTestAccess.h"
#include "TestConfig.h"
// readBuildingConfig underpins the copy-settings gesture (REQ-BLD-COPY-CONFIG): // readBuildingConfig underpins the copy-settings gesture (REQ-BLD-COPY-CONFIG):
// it extracts a building's recipe / schematic / layout / splitter filters so they // it extracts a building's recipe / schematic / layout / splitter filters so they
@@ -21,11 +23,6 @@
namespace namespace
{ {
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
const BuildingDef* findDef(const GameConfig& cfg, BuildingType type) const BuildingDef* findDef(const GameConfig& cfg, BuildingType type)
{ {
for (const BuildingDef& def : cfg.buildings.buildings) for (const BuildingDef& def : cfg.buildings.buildings)
@@ -40,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,11 +62,11 @@ const ShipDef* findAvailableSchematic(const GameConfig& cfg)
TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[copyconfig]") TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[copyconfig]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
Simulation sim(loadConfig(), 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());
@@ -84,8 +80,8 @@ TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[copyconfig]"
TEST_CASE("readBuildingConfig leaves recipe unset when nothing is selected", TEST_CASE("readBuildingConfig leaves recipe unset when nothing is selected",
"[copyconfig]") "[copyconfig]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
Simulation sim(loadConfig(), 7); Simulation sim(loadTestConfig(), 7);
const BuildingId id = placeOperational(sim, cfg, BuildingType::Assembler, QPoint(0, 0)); const BuildingId id = placeOperational(sim, cfg, BuildingType::Assembler, QPoint(0, 0));
@@ -99,15 +95,15 @@ TEST_CASE("readBuildingConfig leaves recipe unset when nothing is selected",
TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout", TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout",
"[copyconfig]") "[copyconfig]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
Simulation sim(loadConfig(), 7); Simulation sim(loadTestConfig(), 7);
const ShipDef* schematic = findAvailableSchematic(cfg); const ShipDef* schematic = findAvailableSchematic(cfg);
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());
@@ -119,17 +115,17 @@ TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout",
TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]") TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
Simulation sim(loadConfig(), 7); Simulation sim(loadTestConfig(), 7);
// A placed miner enters the construction queue as a site (not yet operational). // A placed miner enters the construction queue as a site (not yet operational).
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());
@@ -140,6 +136,6 @@ TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]")
TEST_CASE("readBuildingConfig returns nullopt for an unknown id", "[copyconfig]") TEST_CASE("readBuildingConfig returns nullopt for an unknown id", "[copyconfig]")
{ {
Simulation sim(loadConfig(), 7); Simulation sim(loadTestConfig(), 7);
CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value()); CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value());
} }

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@
add_files( add_files(
TEST_FILES TEST_FILES
TestConfig.h
test.cpp test.cpp
FormulaTest.cpp FormulaTest.cpp
ConfigLoaderTest.cpp ConfigLoaderTest.cpp

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"
@@ -22,11 +23,7 @@
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "Tick.h" #include "Tick.h"
#include "WeaponComponent.h" #include "WeaponComponent.h"
#include "TestConfig.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
static const ShipDef* findCombatShip(const GameConfig& cfg) static const ShipDef* findCombatShip(const GameConfig& cfg)
{ {
@@ -55,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;
@@ -64,7 +62,7 @@ struct CombatFixture
CombatSystem combat; CombatSystem combat;
explicit CombatFixture() explicit CombatFixture()
: cfg(loadConfig()) : cfg(loadTestConfig())
, rng(42) , rng(42)
, nextBuildingId(1) , nextBuildingId(1)
, belts(cfg.world.beltSpeed_tps) , belts(cfg.world.beltSpeed_tps)
@@ -114,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);
@@ -147,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));
} }
@@ -168,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());
} }
@@ -178,7 +176,7 @@ TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]")
TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]") TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
// Find the player station entity via ECS. // Find the player station entity via ECS.
entt::entity stationEntity = entt::null; entt::entity stationEntity = entt::null;
@@ -217,7 +215,7 @@ TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]
TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]") TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
entt::entity stationEntity = entt::null; entt::entity stationEntity = entt::null;
QVector2D stationCenter; QVector2D stationCenter;
@@ -255,7 +253,7 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]
TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]") TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
entt::entity stationEntity = entt::null; entt::entity stationEntity = entt::null;
QVector2D stationCenter; QVector2D stationCenter;
@@ -311,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)
{ {
@@ -333,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);
@@ -350,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);
@@ -373,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);
@@ -388,7 +386,7 @@ TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat
TEST_CASE("CombatSystem: dead ship is removed after tick step 9", "[combat]") TEST_CASE("CombatSystem: dead ship is removed after tick step 9", "[combat]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const ShipDef* combatDef = findCombatShip(sim.getConfig()); const ShipDef* combatDef = findCombatShip(sim.getConfig());
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
@@ -405,7 +403,7 @@ TEST_CASE("CombatSystem: dead ship is removed after tick step 9", "[combat]")
TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]") TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
// Scrap dropped on death is derived from the ship's as-built threat cost // Scrap dropped on death is derived from the ship's as-built threat cost
// (REQ-RES-DEBRIS-DROP): round(threat * scrap_per_threat). The interceptor's // (REQ-RES-DEBRIS-DROP): round(threat * scrap_per_threat). The interceptor's
@@ -417,14 +415,14 @@ 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);
} }
TEST_CASE("CombatSystem: HQ death sets game over", "[combat]") TEST_CASE("CombatSystem: HQ death sets game over", "[combat]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
// Damage the HQ proxy entity (has HqProxy + Health). // Damage the HQ proxy entity (has HqProxy + Health).
sim.getAdmin().forEach<HqProxyComponent, HealthComponent>( sim.getAdmin().forEach<HqProxyComponent, HealthComponent>(

View File

@@ -11,14 +11,7 @@
#include "Simulation.h" #include "Simulation.h"
#include "SimulationTestAccess.h" #include "SimulationTestAccess.h"
#include "Tick.h" #include "Tick.h"
#include "TestConfig.h"
namespace
{
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
} // namespace
// The command chokepoint (Simulation::apply) must produce exactly the same state // The command chokepoint (Simulation::apply) must produce exactly the same state
// as driving the underlying mutators directly — that equivalence is what lets a // as driving the underlying mutators directly — that equivalence is what lets a
@@ -26,8 +19,8 @@ GameConfig loadConfig()
TEST_CASE("apply(PlaceBuildingCommand) matches direct placement", "[command]") TEST_CASE("apply(PlaceBuildingCommand) matches direct placement", "[command]")
{ {
Simulation viaCommand(loadConfig(), 99); Simulation viaCommand(loadTestConfig(), 99);
Simulation viaDirect(loadConfig(), 99); Simulation viaDirect(loadTestConfig(), 99);
PlaceBuildingCommand command; PlaceBuildingCommand command;
command.type = BuildingType::Miner; command.type = BuildingType::Miner;
@@ -42,8 +35,8 @@ TEST_CASE("apply(PlaceBuildingCommand) matches direct placement", "[command]")
TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe", "[command]") TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe", "[command]")
{ {
Simulation viaCommand(loadConfig(), 99); Simulation viaCommand(loadTestConfig(), 99);
Simulation viaDirect(loadConfig(), 99); Simulation viaDirect(loadTestConfig(), 99);
PlaceBuildingCommand command; PlaceBuildingCommand command;
command.type = BuildingType::Miner; command.type = BuildingType::Miner;
@@ -54,15 +47,15 @@ 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());
} }
TEST_CASE("apply(DeconstructCommand) matches direct deconstruct", "[command]") TEST_CASE("apply(DeconstructCommand) matches direct deconstruct", "[command]")
{ {
Simulation viaCommand(loadConfig(), 99); Simulation viaCommand(loadTestConfig(), 99);
Simulation viaDirect(loadConfig(), 99); Simulation viaDirect(loadTestConfig(), 99);
const BuildingId idA = const BuildingId idA =
SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East).value(); SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East).value();
@@ -81,8 +74,8 @@ TEST_CASE("apply(DeconstructCommand) matches direct deconstruct", "[command]")
TEST_CASE("apply(CancelDeconstructionCommand) matches direct cancelDeconstruction", "[command]") TEST_CASE("apply(CancelDeconstructionCommand) matches direct cancelDeconstruction", "[command]")
{ {
Simulation viaCommand(loadConfig(), 99); Simulation viaCommand(loadTestConfig(), 99);
Simulation viaDirect(loadConfig(), 99); Simulation viaDirect(loadTestConfig(), 99);
const BuildingId idA = const BuildingId idA =
SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East).value(); SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East).value();
@@ -110,8 +103,8 @@ TEST_CASE("apply(CancelDeconstructionCommand) matches direct cancelDeconstructio
TEST_CASE("CommandManager drains queued commands in FIFO order through apply", "[command]") TEST_CASE("CommandManager drains queued commands in FIFO order through apply", "[command]")
{ {
Simulation viaManager(loadConfig(), 99); Simulation viaManager(loadTestConfig(), 99);
Simulation viaDirect(loadConfig(), 99); Simulation viaDirect(loadTestConfig(), 99);
CommandManager manager(viaManager); CommandManager manager(viaManager);

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,28 +5,52 @@
#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"
namespace namespace
{ {
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
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(loadConfig(), seed); Simulation sim(loadTestConfig(), seed);
// Tick 0: a miner feeding a short belt line on the asteroid. // Tick 0: a miner feeding a short belt line on the asteroid.
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-3, 0), Rotation::East); SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
@@ -44,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());
} }
@@ -121,8 +165,8 @@ TEST_CASE("fingerprintRng: equal states match, advanced states differ", "[determ
TEST_CASE("Simulation::rngFingerprint is stable for equal seeds", "[determinism]") TEST_CASE("Simulation::rngFingerprint is stable for equal seeds", "[determinism]")
{ {
const Simulation a(loadConfig(), 777); const Simulation a(loadTestConfig(), 777);
const Simulation b(loadConfig(), 777); const Simulation b(loadTestConfig(), 777);
REQUIRE(a.getRngFingerprint() == b.getRngFingerprint()); REQUIRE(a.getRngFingerprint() == b.getRngFingerprint());
} }
@@ -156,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

@@ -2,11 +2,7 @@
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "ModulesConfig.h" #include "ModulesConfig.h"
#include "TestConfig.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
static const ModuleDef* findModule(const GameConfig& cfg, const std::string& id) static const ModuleDef* findModule(const GameConfig& cfg, const std::string& id)
{ {
@@ -28,7 +24,7 @@ static const ModuleStatModifier* findModifier(const ModuleDef& def, const std::s
TEST_CASE("ConfigLoader: loadModules parses modules.toml", "[config][modules]") TEST_CASE("ConfigLoader: loadModules parses modules.toml", "[config][modules]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
REQUIRE(cfg.modules.modules.size() >= 2); REQUIRE(cfg.modules.modules.size() >= 2);
const ModuleDef& armor = cfg.modules.modules[0]; const ModuleDef& armor = cfg.modules.modules[0];
@@ -49,7 +45,7 @@ TEST_CASE("ConfigLoader: loadModules parses modules.toml", "[config][modules]")
TEST_CASE("ConfigLoader: loadModules parses additive modifiers", "[config][modules]") TEST_CASE("ConfigLoader: loadModules parses additive modifiers", "[config][modules]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
REQUIRE(cfg.modules.modules.size() >= 2); REQUIRE(cfg.modules.modules.size() >= 2);
const ModuleDef& sensor = cfg.modules.modules[1]; const ModuleDef& sensor = cfg.modules.modules[1];
@@ -62,7 +58,7 @@ TEST_CASE("ConfigLoader: loadModules parses additive modifiers", "[config][modul
TEST_CASE("ConfigLoader: multiplicative modifier with unit suffix is parsed (weapon_primer)", "[config][modules]") TEST_CASE("ConfigLoader: multiplicative modifier with unit suffix is parsed (weapon_primer)", "[config][modules]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ModuleDef* primer = findModule(cfg, "weapon_primer"); const ModuleDef* primer = findModule(cfg, "weapon_primer");
REQUIRE(primer != nullptr); REQUIRE(primer != nullptr);
REQUIRE(primer->statModifiers.size() == 1); REQUIRE(primer->statModifiers.size() == 1);
@@ -74,7 +70,7 @@ TEST_CASE("ConfigLoader: multiplicative modifier with unit suffix is parsed (wea
TEST_CASE("ConfigLoader: weapon_stabilizer parses two multiplicative weapon modifiers", "[config][modules]") TEST_CASE("ConfigLoader: weapon_stabilizer parses two multiplicative weapon modifiers", "[config][modules]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ModuleDef* stab = findModule(cfg, "weapon_stabilizer"); const ModuleDef* stab = findModule(cfg, "weapon_stabilizer");
REQUIRE(stab != nullptr); REQUIRE(stab != nullptr);
REQUIRE(stab->statModifiers.size() == 2); REQUIRE(stab->statModifiers.size() == 2);
@@ -92,7 +88,7 @@ TEST_CASE("ConfigLoader: weapon_stabilizer parses two multiplicative weapon modi
TEST_CASE("ConfigLoader: afterburner parses multiplicative speed and additive main_acceleration", "[config][modules]") TEST_CASE("ConfigLoader: afterburner parses multiplicative speed and additive main_acceleration", "[config][modules]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ModuleDef* ab = findModule(cfg, "afterburner"); const ModuleDef* ab = findModule(cfg, "afterburner");
REQUIRE(ab != nullptr); REQUIRE(ab != nullptr);
REQUIRE(ab->statModifiers.size() == 2); REQUIRE(ab->statModifiers.size() == 2);
@@ -110,7 +106,7 @@ TEST_CASE("ConfigLoader: afterburner parses multiplicative speed and additive ma
TEST_CASE("ConfigLoader: maneuvering_thrusters parses multiplicative speed and additive maneuvering_acceleration", "[config][modules]") TEST_CASE("ConfigLoader: maneuvering_thrusters parses multiplicative speed and additive maneuvering_acceleration", "[config][modules]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ModuleDef* mt = findModule(cfg, "maneuvering_thrusters"); const ModuleDef* mt = findModule(cfg, "maneuvering_thrusters");
REQUIRE(mt != nullptr); REQUIRE(mt != nullptr);
REQUIRE(mt->statModifiers.size() == 2); REQUIRE(mt->statModifiers.size() == 2);
@@ -128,7 +124,7 @@ TEST_CASE("ConfigLoader: maneuvering_thrusters parses multiplicative speed and a
TEST_CASE("ConfigLoader: loadShips parses layout field", "[config][ships]") TEST_CASE("ConfigLoader: loadShips parses layout field", "[config][ships]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
REQUIRE(!cfg.ships.ships.empty()); REQUIRE(!cfg.ships.ships.empty());
const ShipDef& ship = cfg.ships.ships[0]; const ShipDef& ship = cfg.ships.ships[0];

View File

@@ -12,11 +12,7 @@
#include "Simulation.h" #include "Simulation.h"
#include "SimulationTestAccess.h" #include "SimulationTestAccess.h"
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "TestConfig.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// Zeros the HP of both enemy defence stations and advances one tick so that // Zeros the HP of both enemy defence stations and advances one tick so that
// tickDeathsAndLoot fires, triggering the push and schematic choices. // tickDeathsAndLoot fires, triggering the push and schematic choices.
@@ -62,7 +58,7 @@ static bool awaitRecipeUnlock(Simulation& sim, const std::string& recipeId,
TEST_CASE("RecipeSchematic: unlocked_at_start = true parsed correctly", "[recipe_schematic]") TEST_CASE("RecipeSchematic: unlocked_at_start = true parsed correctly", "[recipe_schematic]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(), const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
[](const RecipeDef& r) { return r.id == "premium_circuit"; }); [](const RecipeDef& r) { return r.id == "premium_circuit"; });
REQUIRE(it != cfg.recipes.recipes.end()); REQUIRE(it != cfg.recipes.recipes.end());
@@ -71,7 +67,7 @@ TEST_CASE("RecipeSchematic: unlocked_at_start = true parsed correctly", "[recipe
TEST_CASE("RecipeSchematic: a gated recipe is granted by an unlock group", "[recipe_schematic]") TEST_CASE("RecipeSchematic: a gated recipe is granted by an unlock group", "[recipe_schematic]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const bool granted = std::any_of(cfg.unlocks.groups.begin(), cfg.unlocks.groups.end(), const bool granted = std::any_of(cfg.unlocks.groups.begin(), cfg.unlocks.groups.end(),
[](const UnlockGroupDef& g) [](const UnlockGroupDef& g)
{ {
@@ -82,7 +78,7 @@ TEST_CASE("RecipeSchematic: a gated recipe is granted by an unlock group", "[rec
TEST_CASE("RecipeSchematic: an untagged assembler recipe has unlocked_at_start = false", "[recipe_schematic]") TEST_CASE("RecipeSchematic: an untagged assembler recipe has unlocked_at_start = false", "[recipe_schematic]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(), const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
[](const RecipeDef& r) { return r.id == "circuit_board"; }); [](const RecipeDef& r) { return r.id == "circuit_board"; });
REQUIRE(it != cfg.recipes.recipes.end()); REQUIRE(it != cfg.recipes.recipes.end());
@@ -96,21 +92,21 @@ TEST_CASE("RecipeSchematic: an untagged assembler recipe has unlocked_at_start =
TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = -1 is unlocked at game start", TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = -1 is unlocked at game start",
"[recipe_schematic]") "[recipe_schematic]")
{ {
const Simulation sim(loadConfig()); const Simulation sim(loadTestConfig());
REQUIRE(sim.isRecipeUnlocked("premium_circuit")); REQUIRE(sim.isRecipeUnlocked("premium_circuit"));
} }
TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = 0 is locked at game start", TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = 0 is locked at game start",
"[recipe_schematic]") "[recipe_schematic]")
{ {
const Simulation sim(loadConfig()); const Simulation sim(loadTestConfig());
REQUIRE_FALSE(sim.isRecipeUnlocked("quick_circuit")); REQUIRE_FALSE(sim.isRecipeUnlocked("quick_circuit"));
} }
TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = 1 is locked at game start", TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = 1 is locked at game start",
"[recipe_schematic]") "[recipe_schematic]")
{ {
const Simulation sim(loadConfig()); const Simulation sim(loadTestConfig());
REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit")); REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit"));
} }
@@ -123,7 +119,7 @@ TEST_CASE("RecipeSchematic: -1 recipe seeds its output item into the implicit un
{ {
// premium_circuit is not needed by any ship or module schematic, so it can // premium_circuit is not needed by any ship or module schematic, so it can
// only reach the implicit set via the -1 recipe seed in Phase 1. // only reach the implicit set via the -1 recipe seed in Phase 1.
const Simulation sim(loadConfig()); const Simulation sim(loadTestConfig());
REQUIRE(sim.isItemUnlocked("premium_circuit")); REQUIRE(sim.isItemUnlocked("premium_circuit"));
} }
@@ -132,7 +128,7 @@ TEST_CASE("RecipeSchematic: -1 recipe's inputs are in the implicit unlock set",
{ {
// premium_circuit takes circuit_board as input; that item was already // premium_circuit takes circuit_board as input; that item was already
// implicitly unlocked by ship schematics, so it must remain unlocked. // implicitly unlocked by ship schematics, so it must remain unlocked.
const Simulation sim(loadConfig()); const Simulation sim(loadTestConfig());
REQUIRE(sim.isItemUnlocked("circuit_board")); REQUIRE(sim.isItemUnlocked("circuit_board"));
} }
@@ -142,7 +138,7 @@ TEST_CASE("RecipeSchematic: locked recipe's unique input is not in the implicit
// exotic_alloy has unlock_at_station_level = 0 (locked at start) and takes // exotic_alloy has unlock_at_station_level = 0 (locked at start) and takes
// exotic_ore as input. exotic_ore is only reachable through this locked // exotic_ore as input. exotic_ore is only reachable through this locked
// recipe, so it must not appear in the implicit set. // recipe, so it must not appear in the implicit set.
const Simulation sim(loadConfig()); const Simulation sim(loadTestConfig());
REQUIRE_FALSE(sim.isItemUnlocked("exotic_ore")); REQUIRE_FALSE(sim.isItemUnlocked("exotic_ore"));
} }
@@ -152,7 +148,7 @@ TEST_CASE("RecipeSchematic: locked recipe's output item is not in the implicit u
// exotic_alloy is produced only by the locked recipe of the same name and // exotic_alloy is produced only by the locked recipe of the same name and
// is not needed by any schematic, so neither the item nor the recipe should // is not needed by any schematic, so neither the item nor the recipe should
// be unlocked at game start. // be unlocked at game start.
const Simulation sim(loadConfig()); const Simulation sim(loadTestConfig());
REQUIRE_FALSE(sim.isItemUnlocked("exotic_alloy")); REQUIRE_FALSE(sim.isItemUnlocked("exotic_alloy"));
REQUIRE_FALSE(sim.isRecipeUnlocked("exotic_alloy")); REQUIRE_FALSE(sim.isRecipeUnlocked("exotic_alloy"));
} }
@@ -162,7 +158,7 @@ TEST_CASE("RecipeSchematic: normal implicit unlock is unaffected for untagged as
{ {
// circuit_board carries no unlock_at_station_level and is needed by ships // circuit_board carries no unlock_at_station_level and is needed by ships
// that start unlocked, so it must still be implicitly unlocked. // that start unlocked, so it must still be implicitly unlocked.
const Simulation sim(loadConfig()); const Simulation sim(loadTestConfig());
REQUIRE(sim.isRecipeUnlocked("circuit_board")); REQUIRE(sim.isRecipeUnlocked("circuit_board"));
} }
@@ -176,7 +172,7 @@ TEST_CASE("RecipeSchematic: eligible recipe schematic is eventually awarded on s
// quick_circuit has unlock_at_station_level = 0 and produces circuit_board // quick_circuit has unlock_at_station_level = 0 and produces circuit_board
// (already implicitly unlocked), so it is eligible from the first station // (already implicitly unlocked), so it is eligible from the first station
// destruction. With up to 150 trials it must be awarded at least once. // destruction. With up to 150 trials it must be awarded at least once.
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
REQUIRE(awaitRecipeUnlock(sim, "quick_circuit")); REQUIRE(awaitRecipeUnlock(sim, "quick_circuit"));
} }
@@ -185,7 +181,7 @@ TEST_CASE("RecipeSchematic: an implicitly-gated recipe with no unlock group is n
{ {
// exotic_alloy is in no unlock group and its output/inputs are unreachable // exotic_alloy is in no unlock group and its output/inputs are unreachable
// via the item graph, so it can never be dropped or implicitly unlocked. // via the item graph, so it can never be dropped or implicitly unlocked.
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
for (int i = 0; i < 50; ++i) for (int i = 0; i < 50; ++i)
{ {
killEnemyStationsAndApply(sim); killEnemyStationsAndApply(sim);
@@ -198,7 +194,7 @@ TEST_CASE("RecipeSchematic: recipe with level > destroyed station level is not a
{ {
// advanced_circuit has unlock_at_station_level = 1. Destroying a single // advanced_circuit has unlock_at_station_level = 1. Destroying a single
// level-0 station set must not award it regardless of the RNG outcome. // level-0 station set must not award it regardless of the RNG outcome.
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
killEnemyStationsAndApply(sim); killEnemyStationsAndApply(sim);
REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit")); REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit"));
} }
@@ -208,14 +204,14 @@ TEST_CASE("RecipeSchematic: recipe with higher level is awarded once eligible st
{ {
// After enough destructions to pass station level 1, advanced_circuit must // After enough destructions to pass station level 1, advanced_circuit must
// eventually be awarded. // eventually be awarded.
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
REQUIRE(awaitRecipeUnlock(sim, "advanced_circuit", 300)); REQUIRE(awaitRecipeUnlock(sim, "advanced_circuit", 300));
} }
TEST_CASE("RecipeSchematic: awarded recipe schematic stays unlocked and is not awarded again", TEST_CASE("RecipeSchematic: awarded recipe schematic stays unlocked and is not awarded again",
"[recipe_schematic]") "[recipe_schematic]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
awaitRecipeUnlock(sim, "quick_circuit"); awaitRecipeUnlock(sim, "quick_circuit");
REQUIRE(sim.isRecipeUnlocked("quick_circuit")); REQUIRE(sim.isRecipeUnlocked("quick_circuit"));
@@ -231,7 +227,7 @@ TEST_CASE("RecipeSchematic: awarded recipe schematic stays unlocked and is not a
TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices", TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices",
"[recipe_schematic]") "[recipe_schematic]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
bool foundRecipeChoice = false; bool foundRecipeChoice = false;
for (int i = 0; i < 150 && !foundRecipeChoice; ++i) for (int i = 0; i < 150 && !foundRecipeChoice; ++i)
@@ -260,7 +256,7 @@ TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices",
TEST_CASE("RecipeSchematic: reset re-locks a previously awarded recipe schematic", TEST_CASE("RecipeSchematic: reset re-locks a previously awarded recipe schematic",
"[recipe_schematic]") "[recipe_schematic]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
awaitRecipeUnlock(sim, "quick_circuit"); awaitRecipeUnlock(sim, "quick_circuit");
REQUIRE(sim.isRecipeUnlocked("quick_circuit")); REQUIRE(sim.isRecipeUnlocked("quick_circuit"));
@@ -272,7 +268,7 @@ TEST_CASE("RecipeSchematic: reset re-locks a previously awarded recipe schematic
TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items accessible", TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items accessible",
"[recipe_schematic]") "[recipe_schematic]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
sim.reset(); sim.reset();
REQUIRE(sim.isRecipeUnlocked("premium_circuit")); REQUIRE(sim.isRecipeUnlocked("premium_circuit"));
@@ -286,7 +282,7 @@ TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items
TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and empty for level-ups", TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and empty for level-ups",
"[recipe_schematic]") "[recipe_schematic]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
for (int i = 0; i < 100; ++i) for (int i = 0; i < 100; ++i)
{ {
@@ -310,8 +306,8 @@ TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and
TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds matches recipes that actually become unlocked", TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds matches recipes that actually become unlocked",
"[recipe_schematic]") "[recipe_schematic]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
auto unlockedTrackedRecipeIds = [&]() auto unlockedTrackedRecipeIds = [&]()
{ {
@@ -365,7 +361,7 @@ TEST_CASE("SchematicDrop: an owned ship schematic is never offered again",
{ {
// repair_ship has unlock_at_station_level = 0 in the test config, so it is // repair_ship has unlock_at_station_level = 0 in the test config, so it is
// locked at start and becomes eligible once a station set is destroyed. // locked at start and becomes eligible once a station set is destroyed.
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship")); REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
bool wasUnlocked = false; bool wasUnlocked = false;

View File

@@ -16,14 +16,10 @@
#include "ReplayRecorder.h" #include "ReplayRecorder.h"
#include "Rotation.h" #include "Rotation.h"
#include "Simulation.h" #include "Simulation.h"
#include "TestConfig.h"
namespace namespace
{ {
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
std::string tempOutputDir() std::string tempOutputDir()
{ {
return (QDir::tempPath() + "/dota_factory_replay_playback_test").toStdString(); return (QDir::tempPath() + "/dota_factory_replay_playback_test").toStdString();
@@ -148,7 +144,7 @@ TEST_CASE("a recorded run replays to byte-identical state with no desync", "[rep
std::string replayPath; std::string replayPath;
std::uint64_t recordedFinalChecksum = 0; std::uint64_t recordedFinalChecksum = 0;
{ {
Simulation rec(loadConfig(), seed); Simulation rec(loadTestConfig(), seed);
CommandManager manager(rec); CommandManager manager(rec);
std::unique_ptr<ReplayRecorder> recorder = std::unique_ptr<ReplayRecorder> recorder =
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir()); std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
@@ -179,7 +175,7 @@ TEST_CASE("a recorded run replays to byte-identical state with no desync", "[rep
REQUIRE_FALSE(parsed->entries.empty()); REQUIRE_FALSE(parsed->entries.empty());
// --- Replay it. --- // --- Replay it. ---
Simulation play(loadConfig(), parsed->header.seed); Simulation play(loadTestConfig(), parsed->header.seed);
ReplayPlayer player(play, parsed->entries); ReplayPlayer player(play, parsed->entries);
player.start(); player.start();
while (!player.isFinished()) while (!player.isFinished())
@@ -201,7 +197,7 @@ TEST_CASE("ReplayPlayer reports a desync when a checksum is corrupted", "[replay
std::string replayPath; std::string replayPath;
{ {
Simulation rec(loadConfig(), seed); Simulation rec(loadTestConfig(), seed);
CommandManager manager(rec); CommandManager manager(rec);
std::unique_ptr<ReplayRecorder> recorder = std::unique_ptr<ReplayRecorder> recorder =
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir()); std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
@@ -228,7 +224,7 @@ TEST_CASE("ReplayPlayer reports a desync when a checksum is corrupted", "[replay
} }
REQUIRE(corrupted); REQUIRE(corrupted);
Simulation play(loadConfig(), parsed->header.seed); Simulation play(loadTestConfig(), parsed->header.seed);
ReplayPlayer player(play, parsed->entries); ReplayPlayer player(play, parsed->entries);
player.start(); player.start();
while (!player.isFinished()) while (!player.isFinished())
@@ -250,7 +246,7 @@ TEST_CASE("a long recorded run (through waves and combat) replays with no desync
std::string replayPath; std::string replayPath;
std::uint64_t recordedFinalChecksum = 0; std::uint64_t recordedFinalChecksum = 0;
{ {
Simulation rec(loadConfig(), seed); Simulation rec(loadTestConfig(), seed);
CommandManager manager(rec); CommandManager manager(rec);
std::unique_ptr<ReplayRecorder> recorder = std::unique_ptr<ReplayRecorder> recorder =
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir()); std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
@@ -275,7 +271,7 @@ TEST_CASE("a long recorded run (through waves and combat) replays with no desync
const std::optional<ParsedReplay> parsed = readReplayFile(replayPath); const std::optional<ParsedReplay> parsed = readReplayFile(replayPath);
REQUIRE(parsed.has_value()); REQUIRE(parsed.has_value());
Simulation play(loadConfig(), parsed->header.seed); Simulation play(loadTestConfig(), parsed->header.seed);
ReplayPlayer player(play, parsed->entries); ReplayPlayer player(play, parsed->entries);
player.start(); player.start();
while (!player.isFinished()) while (!player.isFinished())

View File

@@ -16,6 +16,7 @@
#include "GameConfig.h" #include "GameConfig.h"
#include "ReplayRecorder.h" #include "ReplayRecorder.h"
#include "Simulation.h" #include "Simulation.h"
#include "TestConfig.h"
namespace namespace
{ {
@@ -144,7 +145,7 @@ TEST_CASE("ReplayRecorder writes a well-formed file", "[replay]")
TEST_CASE("CommandManager records commands and an initial checksum on drain", "[replay]") TEST_CASE("CommandManager records commands and an initial checksum on drain", "[replay]")
{ {
Simulation sim(ConfigLoader::loadFromDirectory(CONFIG_DIR), 7u); Simulation sim(loadTestConfig(), 7u);
CommandManager manager(sim); CommandManager manager(sim);
std::unique_ptr<ReplayRecorder> recorder = std::unique_ptr<ReplayRecorder> recorder =
@@ -187,7 +188,7 @@ TEST_CASE("ReplayRecorder startNewRun rolls to a new file", "[replay]")
TEST_CASE("CommandManager rolls the replay file on a Reset command", "[replay]") TEST_CASE("CommandManager rolls the replay file on a Reset command", "[replay]")
{ {
Simulation sim(ConfigLoader::loadFromDirectory(CONFIG_DIR), 1u); Simulation sim(loadTestConfig(), 1u);
CommandManager manager(sim); CommandManager manager(sim);
std::unique_ptr<ReplayRecorder> recorder = std::unique_ptr<ReplayRecorder> recorder =
@@ -197,7 +198,7 @@ TEST_CASE("CommandManager rolls the replay file on a Reset command", "[replay]")
const std::string firstPath = recorderPtr->getCurrentFilePath(); const std::string firstPath = recorderPtr->getCurrentFilePath();
std::shared_ptr<ResetCommand> reset = std::make_shared<ResetCommand>(); std::shared_ptr<ResetCommand> reset = std::make_shared<ResetCommand>();
reset->config = std::make_shared<GameConfig>(ConfigLoader::loadFromDirectory(CONFIG_DIR)); reset->config = std::make_shared<GameConfig>(loadTestConfig());
reset->seed = 999u; reset->seed = 999u;
manager.enqueue(reset); manager.enqueue(reset);
manager.drain(); manager.drain();

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"
@@ -22,11 +23,7 @@
#include "SimulationTestAccess.h" #include "SimulationTestAccess.h"
#include "Tick.h" #include "Tick.h"
#include "WeaponComponent.h" #include "WeaponComponent.h"
#include "TestConfig.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
static const ShipDef* findSchematic(const GameConfig& cfg, const std::string& id) static const ShipDef* findSchematic(const GameConfig& cfg, const std::string& id)
{ {
@@ -65,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);
@@ -76,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;
@@ -108,7 +104,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId,
TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]") TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
@@ -123,7 +119,7 @@ TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]")
TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]") TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
@@ -148,7 +144,7 @@ TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]") TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
@@ -173,7 +169,7 @@ TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]") TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
@@ -206,12 +202,12 @@ TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials", TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
"[modules][shipyard]") "[modules][shipyard]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig()); const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
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;
@@ -220,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
@@ -233,21 +229,21 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
TEST_CASE("Shipyard: setShipLayout cancels in-progress production", TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
"[modules][shipyard]") "[modules][shipyard]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig()); const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
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());
@@ -259,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());
} }
@@ -269,7 +265,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
TEST_CASE("Shipyard: builds a bare hull when no layout is configured", TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
"[modules][shipyard]") "[modules][shipyard]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
// The schematic carries a weapon in its (wave-only) default loadout. This // The schematic carries a weapon in its (wave-only) default loadout. This
@@ -282,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).
@@ -312,12 +308,12 @@ TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]") TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig()); const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
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;
@@ -325,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());
} }
@@ -341,12 +337,12 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout", TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
"[modules][shipyard]") "[modules][shipyard]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig()); const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
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;
@@ -354,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);
@@ -376,7 +372,7 @@ TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
TEST_CASE("Ship spawn: weapon_primer multiplies attack rate in simulation", "[modules]") TEST_CASE("Ship spawn: weapon_primer multiplies attack rate in simulation", "[modules]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
@@ -401,7 +397,7 @@ TEST_CASE("Ship spawn: weapon_primer multiplies attack rate in simulation", "[mo
TEST_CASE("Ship spawn: weapon_stabilizer multiplies attack range in simulation", "[modules]") TEST_CASE("Ship spawn: weapon_stabilizer multiplies attack range in simulation", "[modules]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
@@ -428,7 +424,7 @@ TEST_CASE("Ship spawn: weapon_stabilizer multiplies attack range in simulation",
TEST_CASE("Ship spawn: afterburner additive main_acceleration is converted m/s² to tiles/tick", "[modules]") TEST_CASE("Ship spawn: afterburner additive main_acceleration is converted m/s² to tiles/tick", "[modules]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
@@ -453,7 +449,7 @@ TEST_CASE("Ship spawn: afterburner additive main_acceleration is converted m/s²
TEST_CASE("Ship spawn: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/tick", "[modules]") TEST_CASE("Ship spawn: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/tick", "[modules]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
@@ -482,7 +478,7 @@ TEST_CASE("Ship spawn: maneuvering_thrusters additive maneuvering_acceleration i
TEST_CASE("calculateShipStats: weapon_primer multiplies attack rate in stats view", "[modules]") TEST_CASE("calculateShipStats: weapon_primer multiplies attack rate in stats view", "[modules]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ShipDef* def = findSchematic(cfg, "interceptor"); const ShipDef* def = findSchematic(cfg, "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
@@ -506,7 +502,7 @@ TEST_CASE("calculateShipStats: weapon_primer multiplies attack rate in stats vie
TEST_CASE("calculateShipStats: weapon_stabilizer multiplies attack range in stats view", "[modules]") TEST_CASE("calculateShipStats: weapon_stabilizer multiplies attack range in stats view", "[modules]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ShipDef* def = findSchematic(cfg, "interceptor"); const ShipDef* def = findSchematic(cfg, "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
@@ -532,7 +528,7 @@ TEST_CASE("calculateShipStats: weapon_stabilizer multiplies attack range in stat
TEST_CASE("calculateShipStats: afterburner additive main_acceleration is converted m/s² to tiles/s²", "[modules]") TEST_CASE("calculateShipStats: afterburner additive main_acceleration is converted m/s² to tiles/s²", "[modules]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ShipDef* def = findSchematic(cfg, "interceptor"); const ShipDef* def = findSchematic(cfg, "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
@@ -556,7 +552,7 @@ TEST_CASE("calculateShipStats: afterburner additive main_acceleration is convert
TEST_CASE("calculateShipStats: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/s²", "[modules]") TEST_CASE("calculateShipStats: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/s²", "[modules]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ShipDef* def = findSchematic(cfg, "interceptor"); const ShipDef* def = findSchematic(cfg, "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);

View File

@@ -29,11 +29,7 @@
#include "ShipSystem.h" #include "ShipSystem.h"
#include "Tick.h" #include "Tick.h"
#include "WeaponComponent.h" #include "WeaponComponent.h"
#include "TestConfig.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -91,7 +87,7 @@ TEST_CASE("ShipSystem: interceptor spawn has weapon child and attack behavior, n
"[ship]") "[ship]")
{ {
EntityAdmin admin; EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
@@ -115,7 +111,7 @@ TEST_CASE("ShipSystem: interceptor spawn has weapon child and attack behavior, n
TEST_CASE("ShipSystem: enemy combat ship has no rally or retreat behavior", "[ship]") TEST_CASE("ShipSystem: enemy combat ship has no rally or retreat behavior", "[ship]")
{ {
EntityAdmin admin; EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f), /*isEnemy=*/true); const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f), /*isEnemy=*/true);
@@ -129,7 +125,7 @@ TEST_CASE("ShipSystem: enemy combat ship has no rally or retreat behavior", "[sh
TEST_CASE("ShipSystem: setRetreatEnabled(false) suppresses player retreat behavior", "[ship]") TEST_CASE("ShipSystem: setRetreatEnabled(false) suppresses player retreat behavior", "[ship]")
{ {
EntityAdmin admin; EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
ss.setRetreatEnabled(false); ss.setRetreatEnabled(false);
@@ -144,7 +140,7 @@ TEST_CASE("ShipSystem: setRetreatEnabled(false) suppresses player retreat behavi
TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]") TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]")
{ {
EntityAdmin admin; EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
@@ -166,7 +162,7 @@ TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]
TEST_CASE("ShipSystem: interceptor hp matches config value", "[ship]") TEST_CASE("ShipSystem: interceptor hp matches config value", "[ship]")
{ {
EntityAdmin admin; EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
@@ -178,7 +174,7 @@ TEST_CASE("ShipSystem: interceptor hp matches config value", "[ship]")
TEST_CASE("ShipSystem: interceptor maxSpeed_tpt matches config value / tileSize / kTickRateHz", "[ship]") TEST_CASE("ShipSystem: interceptor maxSpeed_tpt matches config value / tileSize / kTickRateHz", "[ship]")
{ {
EntityAdmin admin; EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
@@ -196,7 +192,7 @@ TEST_CASE("ShipSystem: salvage_ship spawn with salvage module has cargo child an
"[ship]") "[ship]")
{ {
EntityAdmin admin; EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const ShipLayoutConfig layout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig layout = makeSingleModuleLayout("salvager");
@@ -212,7 +208,7 @@ TEST_CASE("ShipSystem: salvage_ship spawn with salvage module has cargo child an
TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]") TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
{ {
EntityAdmin admin; EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const ShipLayoutConfig layout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig layout = makeSingleModuleLayout("salvager");
@@ -237,7 +233,7 @@ TEST_CASE("ShipSystem: repair_ship spawn with repair module has repair child and
"[ship]") "[ship]")
{ {
EntityAdmin admin; EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool");
@@ -252,7 +248,7 @@ TEST_CASE("ShipSystem: repair_ship spawn with repair module has repair child and
TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas", "[ship]") TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas", "[ship]")
{ {
EntityAdmin admin; EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool");
@@ -277,7 +273,7 @@ TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas",
TEST_CASE("ShipSystem: spawned ships are valid entities", "[ship]") TEST_CASE("ShipSystem: spawned ships are valid entities", "[ship]")
{ {
EntityAdmin admin; EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const entt::entity e1 = ss.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity e1 = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
@@ -292,7 +288,7 @@ TEST_CASE("ShipSystem: spawned ships are valid entities", "[ship]")
TEST_CASE("ShipSystem: despawn removes the ship and its weapon children", "[ship]") TEST_CASE("ShipSystem: despawn removes the ship and its weapon children", "[ship]")
{ {
EntityAdmin admin; EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));

View File

@@ -1,4 +1,5 @@
#include "catch.hpp" #include "catch.hpp"
#include "FactoryQueries.h"
#include <algorithm> #include <algorithm>
@@ -16,11 +17,7 @@
#include "Simulation.h" #include "Simulation.h"
#include "SimulationTestAccess.h" #include "SimulationTestAccess.h"
#include "Tick.h" #include "Tick.h"
#include "TestConfig.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// A ship starts unlocked iff no unlock group grants it (REQ-LOCK-EXPLICIT). // A ship starts unlocked iff no unlock group grants it (REQ-LOCK-EXPLICIT).
static bool startsUnlocked(const GameConfig& cfg, const std::string& shipId) static bool startsUnlocked(const GameConfig& cfg, const std::string& shipId)
@@ -61,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);
@@ -78,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)
{ {
@@ -98,7 +94,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def
TEST_CASE("Shipyard: spawns a player ship after production cycle completes", TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
"[shipyard]") "[shipyard]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findAvailableSchematic(sim.getConfig()); const ShipDef* def = findAvailableSchematic(sim.getConfig());
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
@@ -110,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.
@@ -143,7 +139,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
TEST_CASE("Shipyard: does not spawn without a schematic set", "[shipyard]") TEST_CASE("Shipyard: does not spawn without a schematic set", "[shipyard]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig()); const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
@@ -159,7 +155,7 @@ TEST_CASE("Shipyard: does not spawn without a schematic set", "[shipyard]")
TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]") TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findAvailableSchematic(sim.getConfig()); const ShipDef* def = findAvailableSchematic(sim.getConfig());
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
@@ -169,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);
@@ -183,7 +179,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipyard]") TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipyard]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findAvailableSchematic(sim.getConfig()); const ShipDef* def = findAvailableSchematic(sim.getConfig());
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
@@ -191,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);
@@ -218,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

@@ -5,11 +5,7 @@
#include "Simulation.h" #include "Simulation.h"
#include "Tick.h" #include "Tick.h"
#include "TickDriver.h" #include "TickDriver.h"
#include "TestConfig.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Simulation // Simulation
@@ -17,14 +13,14 @@ static GameConfig loadConfig()
TEST_CASE("Simulation::currentTick starts at 0", "[simulation]") TEST_CASE("Simulation::currentTick starts at 0", "[simulation]")
{ {
const Simulation sim(loadConfig()); const Simulation sim(loadTestConfig());
REQUIRE(sim.getCurrentTick() == 0); REQUIRE(sim.getCurrentTick() == 0);
} }
TEST_CASE("Simulation::tick increments currentTick by 1", "[simulation]") TEST_CASE("Simulation::tick increments currentTick by 1", "[simulation]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
sim.tick(); sim.tick();
@@ -33,7 +29,7 @@ TEST_CASE("Simulation::tick increments currentTick by 1", "[simulation]")
TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]") TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
for (int i = 0; i < 10; ++i) for (int i = 0; i < 10; ++i)
{ {
@@ -45,14 +41,14 @@ TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]")
TEST_CASE("Simulation::drainBeamFiredEvents returns empty initially", "[simulation]") TEST_CASE("Simulation::drainBeamFiredEvents returns empty initially", "[simulation]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
REQUIRE(sim.drainBeamFiredEvents().empty()); REQUIRE(sim.drainBeamFiredEvents().empty());
} }
TEST_CASE("Simulation::drainBeamFiredEvents clears queue on drain", "[simulation]") TEST_CASE("Simulation::drainBeamFiredEvents clears queue on drain", "[simulation]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
// First drain: empty. // First drain: empty.
sim.drainBeamFiredEvents(); sim.drainBeamFiredEvents();
@@ -63,7 +59,7 @@ TEST_CASE("Simulation::drainBeamFiredEvents clears queue on drain", "[simulation
TEST_CASE("Simulation::hasSchematicChoicesPending returns false initially", "[simulation]") TEST_CASE("Simulation::hasSchematicChoicesPending returns false initially", "[simulation]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
REQUIRE_FALSE(sim.hasSchematicChoicesPending()); REQUIRE_FALSE(sim.hasSchematicChoicesPending());
} }

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,

15
src/test/TestConfig.h Normal file
View File

@@ -0,0 +1,15 @@
#pragma once
#include "ConfigLoader.h"
#include "GameConfig.h"
// Loads the test fixture config set (CONFIG_DIR points at bin/test/data/config
// for the test target). Shared by every test that needs a full GameConfig, so
// the one-liner is not repeated per translation unit.
//
// Like SimulationTestAccess.h this header lives under src/test and is
// deliberately off the lib/ui/app include path.
inline GameConfig loadTestConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}

Some files were not shown because too many files have changed in this diff Show More