58 Commits

Author SHA1 Message Date
f39fe9a118 move the debug stats panel out of the renderer 2026-08-05 22:14:55 +02:00
e5dcb9de5f extract WorldRenderer 2026-08-05 22:14:55 +02:00
dc83add5c6 move two placement queries out of the view into PlacementRules 2026-08-05 22:14:54 +02:00
3a1951559d share the world shapes both views (game and balancing) draw identically 2026-08-05 22:14:54 +02:00
26d7448492 extract BuildModeController, fixing a silent blueprint exit 2026-08-05 22:14:54 +02:00
202f583067 extract SelectionController 2026-08-05 22:14:54 +02:00
d5ab44b9bf move the remaining hotkeys into the InputMapper 2026-08-05 22:14:53 +02:00
bb1ffab8fc fix a bug where view continues to pan when window lost focus while panning 2026-08-05 22:14:53 +02:00
e1445fe508 move the already-event-driven hotkeys into the InputMapper 2026-08-05 22:14:53 +02:00
caa810f66d move pan input into an InputMapper 2026-08-05 22:14:53 +02:00
f6df95abb2 extract the scroll position into WorldCamera 2026-08-05 22:14:52 +02:00
fa9dbd62ad use WorldCoordinates in ArenaView too 2026-08-05 22:14:52 +02:00
2af09d9eb1 extract the world<->widget transform into WorldCoordinates 2026-08-05 22:14:52 +02:00
4f7fdb8a4c add tone, critique and class layout rules to CLAUDE.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
2026-08-05 22:14:37 +02:00
949937d2c2 re-use PlacementFixture in BuildingTests 2026-08-05 07:53:08 +02:00
4c166bf47f depend on the registry instead of DebrisSystem in the AI path 2026-08-05 07:25:59 +02:00
60260540cd make deconstruction its own system 2026-08-05 07:10:46 +02:00
1f4503176b make construction its own system (extracted from BuildingSystem) 2026-08-05 06:57:12 +02:00
fd85e8e10a free the buffer setup and belt registration from BuildingSystem 2026-08-05 06:55:13 +02:00
114a43b205 make BuildingSystem stateless: FactoryState becomes a parameter 2026-08-05 06:50:11 +02:00
d87d063b10 move the placement rules and the config-dependent queries off BuildingSystem 2026-08-05 06:49:49 +02:00
537597c854 delete the unused getAllBeltTiles and BeltTileInfo 2026-08-05 06:49:28 +02:00
9c3be0fbd0 extract the production rules as free functions over config and building 2026-08-05 06:49:15 +02:00
58b94223f7 migrate every factory query off BuildingSystem onto the free functions 2026-08-05 06:46:13 +02:00
1fb63cce4e move the asteroid width bound into FactoryState 2026-08-05 06:45:33 +02:00
0b7e94b4e4 drop CombatSystem's unused BuildingSystem parameter 2026-08-05 06:45:16 +02:00
0408336cf9 depend on factory data instead of BuildingSystem in the AI path 2026-08-05 06:44:49 +02:00
46932e4abf move FactoryState ownership out of BuildingSystem to Simulation 2026-08-05 06:43:52 +02:00
0edea5d961 gather the factory's world data into FactoryState 2026-08-05 06:43:30 +02:00
60cc187d92 add BuildingGrid to manage tile occupancy 2026-08-04 18:26:01 +02:00
3990351a16 share BuildingSystem's free functions instead of copying them 2026-08-04 18:24:38 +02:00
bd344e4fbe add FieldSelectionPanel for extracting the ships/stations/debris selection 2026-08-04 18:23:28 +02:00
64c344c3a3 correct the belt subsystem interface description in architecture.md 2026-08-04 18:12:02 +02:00
02c7fed9b4 allow "auto" for named local lambdas and iterator types via claude.md 2026-08-04 18:11:37 +02:00
5d4a975384 cover unlock state in the determinism tests 2026-08-04 18:11:03 +02:00
475df0e5fd extract unlock state from Simulation to UnlockState class 2026-08-04 18:10:43 +02:00
61634f6fd2 move the shared TOML helpers into the utility namespace to avoid name collisions 2026-08-04 18:05:52 +02:00
c8ff7da345 extract load methods into their own files 2026-08-04 18:05:27 +02:00
d664ab54cc extract shared TOML helpers into TomlHelpers.h/.cpp 2026-08-04 18:02:23 +02:00
906000b0e9 drop the dead payloads from the state-change events 2026-08-03 22:02:47 +02:00
d9ef6aa728 make HeaderBar read the tick, artifacts and boss wave from the simulation instead of event 2026-08-03 22:02:19 +02:00
b44a85685e make BlueprintPanel read the block stock from the simulation instead of event 2026-08-03 22:02:05 +02:00
edd1c31785 remove duplicate findModuleDef from ShipLayoutPreview 2026-08-03 22:00:25 +02:00
785ce3ebfe remove duplicate findBuildingDef from BuildingSystem 2026-08-03 21:14:13 +02:00
7017f8b4dc route the win-path restart through ResetCommand 2026-08-03 21:13:43 +02:00
77a842f884 dedupe AttackExecutor and RepairExecutor via executeOrbitAndAssign 2026-08-03 21:13:24 +02:00
d5ba72d554 add ModalPauseScope for the pause-around-modal idiom 2026-08-03 21:11:52 +02:00
83177729e9 extract MainWindow::reloadConfig 2026-08-03 21:11:02 +02:00
a8e933b7f2 drop EntityAdmin::add in favour of addComponent 2026-08-03 21:09:18 +02:00
e02e323cb2 share a single ItemIconCache across the UI 2026-08-03 21:08:19 +02:00
b4e622daa5 extract the shared Centroid helper into ai/Centroid.h 2026-08-03 21:06:06 +02:00
1150985c1f share one loadTestConfig() helper across the tests 2026-08-03 21:05:28 +02:00
594c3b93c5 make HeaderBar read block stock and expansion cost from the simulation 2026-08-03 21:04:43 +02:00
932b57720c dedupe tunnel lookup and key tunnel tiles by QPoint 2026-08-03 21:02:06 +02:00
ca727bef35 extract Simulation::initializeSubsystems to remove duplicate code 2026-08-03 20:59:00 +02:00
553a7e0701 add findShipDef/findModuleDef/findRecipeDef to config structs and re-use them in the rest of the code base 2026-08-03 20:57:31 +02:00
af6828c348 remove duplicate findBuildingDef from GameWorldView 2026-08-03 20:50:03 +02:00
3671e1d7e6 fix splitter filters being lost when rotating in place and add test 2026-08-03 20:49:10 +02:00
154 changed files with 9395 additions and 6598 deletions

View File

@@ -5,6 +5,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Interaction ## Interaction
* ONLY modify code or other files if explicitly asked to do so * ONLY modify code or other files if explicitly asked to do so
* keep the tone professional, brief and to the point — brevity applies to prose and
preamble, not to the substance of an objection or a design rationale
* be critical: where there is a concrete technical reason to disagree, name it once;
if the user reaffirms, proceed with their call without re-litigating
## Project Overview ## Project Overview
@@ -31,7 +35,23 @@ keep the citation accurate.
## Coding Guidelines ## Coding Guidelines
* avoid duplicate code * avoid duplicate code
* do not use the "auto" keyword * when planning a change, weigh the long-term maintainability of the codebase instead of
defaulting to the lowest-effort patch — but no speculative generality: never build or
prepare for functionality we may never need. If the maintainable solution is much larger
than the request, say so and let the user decide the scope.
* class layout: static members first, then non-static; within each, public, then protected,
then private (Qt `slots:`/`signals:` are ordinary non-static access groups). Inside an
access group the order is: nested types, static constants, aliases, methods, fields,
friends. Out-of-line method definitions follow the declaration order. Applies to new
classes and to files being edited anyway — don't reorder existing headers just to comply.
* 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
@@ -303,7 +329,11 @@ Buildings and the belt subsystem stay outside any entity model regardless of wha
## Rendering ## Rendering
The game world is rendered by a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization. The game world is drawn into a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization.
The drawing itself lives in `WorldRenderer`, not in the widget. `paintGL` is a call sequence: build the frame's `WorldCoordinates`, hand the renderer a `WorldRenderFrame`, then draw the screen-anchored chrome. The split is the world-space / screen-space line, and it is exact: the renderer draws everything positioned in tiles, while everything positioned in pixels — the pause and deconstruct vignettes, the replay overlay, the debug stats panel — stays with the widget. A useful consequence is that the renderer draws no translatable text at all (its text is config-driven glyphs, ASCII port arrows, and numbers), so it needs no `tr()` and no tie to the meta-object system.
`WorldRenderFrame` is what makes the renderer independent of the widget. The renderer reads the simulation directly, but everything else it draws is interaction state the widget owns — the selection, the active build mode, live beams, the copy-settings feedback, the box-select rectangle. Those are gathered into the frame each `paintGL` and passed by reference, so the renderer keeps no copy that a later click could invalidate. The renderer knows nothing about input: the widget resolves clicks and hit-tests, and the renderer only draws the result.
### Render Loop ### Render Loop
@@ -331,9 +361,11 @@ Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly
### Coordinates and Scrolling ### Coordinates and Scrolling
- `GameWorldView` holds a continuous `scrollXTiles` (float). A / D input pans this smoothly (REQ-UI-SCROLL). - The horizontal view position lives in `WorldCamera` (`lib/core/`) as a continuous view-center X in tiles. A / D input pans it smoothly (REQ-UI-SCROLL) at a position-dependent speed (REQ-UI-SCROLL-SPEED). The camera works purely in world units — tiles and tiles/second, never pixels — which is what keeps it independent of `WorldCoordinates`; the two meet only where `GameWorldView` feeds `getViewCenterXTiles()` into the transform.
- At the start of `paintEvent`, a single `painter.translate(-scrollXTiles * tilePx, 0)` maps world tile units into widget pixels (`tilePx = 20`, per REQ-GW-TILE-SIZE). - The camera takes no simulation dependency. Its pan limits move with asteroid expansion and with pushes, so `GameWorldView` reads them from the sim each frame and passes them in as `ScrollBounds`; the camera clamps on every `advance()`, not only when panning, so the view follows the bounds inward when they shrink. Pan *intent* is likewise passed in as a `PanDirection` rather than read from key state, so the camera is unaffected if controls later become rebindable. Both properties are what make it a plain value with unit tests (`WorldCameraTest`) — notably over the two-ramp pan-speed curve, whose overlapping-band and zero-width-band cases are otherwise easy to break unnoticed.
- Mouse input converts the other way: `worldX = mouseX / tilePx + scrollXTiles`; apply `floor` for a tile. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles. - The world↔widget transform itself lives in `WorldCoordinates` (`lib/core/`), not in the view. It is an immutable value, built through one of two named factories that differ only in how `tilePx` and the left edge are derived; everything downstream is shared. `scrolling(...)` is the game world: `tilePx` makes the world height fill the viewport (REQ-GW-TILE-SIZE) and the view pans horizontally. `fitToWorld(...)` is the balancing tool's arena: a fixed world shown whole, so `tilePx` is the tighter of the two axis fits and there is no scroll. Being a plain value with no Qt Widgets dependency, it is unit-tested (`WorldCoordinatesTest`) even though the widgets around it are not.
- `GameWorldView::getCoordinates()` and `ArenaView::getCoordinates()` each build one per frame in `paintGL` and per event in the mouse handlers, and pass it down: every world-space `draw<X>` takes a `const WorldCoordinates&`, while the screen-space draws (vignette borders, replay overlay, debug text) take none. The snapshot is deliberately never cached in a member — a resize or a scroll would silently invalidate it.
- Conversions are per-call arithmetic rather than a `painter.translate`, because hit-testing needs the inverse (`widgetToWorld` / `widgetToTile`, flooring for a tile) as often as drawing needs the forward direction. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles, which is why the flooring must not be truncation.
### Culling ### Culling
@@ -343,6 +375,8 @@ The renderer iterates only entities and tiles whose world X lies within the visi
Shapes are hardcoded in the renderer — a building is a rectangle per footprint tile, a ship is an oriented arrow/triangle, a belt item is a 10×10 square, scrap is a small circle, a beam is a line. These structural choices live in the `draw<X>(painter, entity)` functions of the UI and are not expected to change frequently. Shapes are hardcoded in the renderer — a building is a rectangle per footprint tile, a ship is an oriented arrow/triangle, a belt item is a 10×10 square, scrap is a small circle, a beam is a line. These structural choices live in the `draw<X>(painter, entity)` functions of the UI and are not expected to change frequently.
The few shapes the game view and the balancing tool's arena view draw *identically* — the ship body, the health bar, the debris marker, the sensor-range circle — live in `ui/WorldPrimitives` as free functions over explicit values. The arena exists to eyeball combat, so it only works while a ship there looks like a ship in the game; keeping these in one place means a retuned ship shape cannot silently stop applying to the tool that measures it. The balancing target does not link the `ui` library, so it compiles that file into itself, the same way it already does for `VisualsLoader` and `ShipStatsPanel` (see `balancing/CMakeLists.txt`). Everything the two views draw differently — selection highlights, beams, target lines, and all of the factory — stays with each view; the shared set is deliberately not grown beyond shapes that are genuinely the same.
Colors, outline widths, glyph text, and tile tints live in a separate config file, `visuals.toml`, loaded once by the UI at startup using the same pattern and lifetime as the sim config files (see Config Loading). The file is UI-scoped: the sim does not read it and does not depend on it. Colors, outline widths, glyph text, and tile tints live in a separate config file, `visuals.toml`, loaded once by the UI at startup using the same pattern and lifetime as the sim config files (see Config Loading). The file is UI-scoped: the sim does not read it and does not depend on it.
Sketch of `visuals.toml`: Sketch of `visuals.toml`:

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>
@@ -29,6 +30,7 @@
#include "ShipIdentityComponent.h" #include "ShipIdentityComponent.h"
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "DebrisComponent.h" #include "DebrisComponent.h"
#include "WorldPrimitives.h"
namespace namespace
{ {
@@ -175,55 +177,36 @@ void ArenaView::paintGL()
QPainter painter(this); QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, false); painter.setRenderHint(QPainter::Antialiasing, false);
drawTiles(painter); // One transform snapshot for the whole frame; every draw below reads the
drawBuildings(painter); // viewport through it.
drawStations(painter); const WorldCoordinates coordinates = getCoordinates();
drawDebris(painter);
drawTiles(painter, coordinates);
drawBuildings(painter, coordinates);
drawStations(painter, coordinates);
drawDebris(painter, coordinates);
if (m_debugDraw) if (m_debugDraw)
{ {
drawDebugSensorRanges(painter); drawDebugSensorRanges(painter, coordinates);
drawDebugTargetLines(painter); drawDebugTargetLines(painter, coordinates);
} }
drawShips(painter); drawShips(painter, coordinates);
drawBeams(painter); drawBeams(painter, coordinates);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Coordinate helpers // Coordinate helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
float ArenaView::getTilePx() const WorldCoordinates ArenaView::getCoordinates() const
{ {
// The arena is a fixed, fully visible world — unlike the game view it has no
// scrolling, so the tile size comes from fitting the whole arena in the widget.
const ArenaConfig& ac = m_sim->getArenaConfig(); const ArenaConfig& ac = m_sim->getArenaConfig();
const int totalWidth = ac.playerBufferWidth_tiles const int totalWidth = ac.playerBufferWidth_tiles
+ ac.contestZoneWidth_tiles + ac.contestZoneWidth_tiles
+ ac.enemyBufferWidth_tiles; + ac.enemyBufferWidth_tiles;
const int totalHeight = ac.heightTiles; return WorldCoordinates::fitToWorld(size(), totalWidth, ac.heightTiles);
if (totalWidth <= 0 || totalHeight <= 0) { return 1.0f; }
const float pxPerTileH = static_cast<float>(height()) / static_cast<float>(totalHeight);
const float pxPerTileW = static_cast<float>(width()) / static_cast<float>(totalWidth);
return std::min(pxPerTileH, pxPerTileW);
}
QPointF ArenaView::worldToWidget(QVector2D worldPos) const
{
return QPointF(
static_cast<qreal>(worldPos.x() * getTilePx()),
static_cast<qreal>(worldPos.y() * getTilePx()));
}
QPointF ArenaView::tileToWidget(QPoint tile) const
{
return worldToWidget(QVector2D(static_cast<float>(tile.x()),
static_cast<float>(tile.y())));
}
QRectF ArenaView::tileRect(QPoint tile) const
{
const QPointF tl = tileToWidget(tile);
return QRectF(tl.x(), tl.y(),
static_cast<qreal>(getTilePx()), static_cast<qreal>(getTilePx()));
} }
std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
@@ -235,19 +218,11 @@ std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
return m_sim->getAdmin().get<PositionComponent>(entity).value; return m_sim->getAdmin().get<PositionComponent>(entity).value;
} }
QVector2D ArenaView::widgetToWorld(QPoint widgetPt) const
{
const float px = getTilePx();
if (px < 0.001f) { return QVector2D(0.0f, 0.0f); }
return QVector2D(static_cast<float>(widgetPt.x()) / px,
static_cast<float>(widgetPt.y()) / px);
}
void ArenaView::mousePressEvent(QMouseEvent* event) void ArenaView::mousePressEvent(QMouseEvent* event)
{ {
if (event->button() == Qt::LeftButton) if (event->button() == Qt::LeftButton)
{ {
const QVector2D worldPos = widgetToWorld(event->pos()); const QVector2D worldPos = getCoordinates().widgetToWorld(event->pos());
entt::entity hit = entityAtWorldPos(m_sim->getAdmin(), worldPos); entt::entity hit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
if (hit != entt::null) if (hit != entt::null)
@@ -286,7 +261,7 @@ void ArenaView::keyPressEvent(QKeyEvent* event)
// Rendering // Rendering
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void ArenaView::drawTiles(QPainter& painter) void ArenaView::drawTiles(QPainter& painter, const WorldCoordinates& coordinates)
{ {
const ArenaConfig& ac = m_sim->getArenaConfig(); const ArenaConfig& ac = m_sim->getArenaConfig();
const int totalWidth = ac.playerBufferWidth_tiles const int totalWidth = ac.playerBufferWidth_tiles
@@ -299,14 +274,14 @@ void ArenaView::drawTiles(QPainter& painter)
{ {
for (int y = 0; y < totalHeight; ++y) for (int y = 0; y < totalHeight; ++y)
{ {
painter.fillRect(tileRect(QPoint(x, y)), m_visuals->space.fill); painter.fillRect(coordinates.tileRect(QPoint(x, y)), m_visuals->space.fill);
} }
} }
} }
void ArenaView::drawBuildings(QPainter& painter) void ArenaView::drawBuildings(QPainter& painter, const WorldCoordinates& coordinates)
{ {
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);
@@ -316,13 +291,13 @@ void ArenaView::drawBuildings(QPainter& painter)
painter.setPen(Qt::NoPen); painter.setPen(Qt::NoPen);
for (const QPoint& cell : b.bodyCells) for (const QPoint& cell : b.bodyCells)
{ {
painter.fillRect(tileRect(cell), bv.fill); painter.fillRect(coordinates.tileRect(cell), bv.fill);
} }
const QPointF tl = tileToWidget(b.anchor); const QPointF tl = coordinates.tileToWidget(b.anchor);
const QRectF bboxRect(tl.x(), tl.y(), const QRectF bboxRect(tl.x(), tl.y(),
b.footprint.width() * static_cast<qreal>(getTilePx()), b.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
b.footprint.height() * static_cast<qreal>(getTilePx())); b.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
painter.setPen(QPen(bv.outline, 1)); painter.setPen(QPen(bv.outline, 1));
painter.setBrush(Qt::NoBrush); painter.setBrush(Qt::NoBrush);
@@ -336,20 +311,16 @@ void ArenaView::drawBuildings(QPainter& painter)
} }
} }
void ArenaView::drawDebris(QPainter& painter) void ArenaView::drawDebris(QPainter& painter, const WorldCoordinates& coordinates)
{ {
const float r = getTilePx() * 0.2f; for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
for (const DebrisInfo& debris : m_sim->getDebrisSystem().getAllDebrisInfo())
{ {
const QPointF center = worldToWidget(debris.position); drawDebrisMarker(painter, coordinates,
painter.setBrush(QColor(128, 110, 90)); coordinates.worldToWidget(debris.position));
painter.setPen(QPen(QColor(50, 40, 30), 1));
painter.drawEllipse(center,
static_cast<qreal>(r), static_cast<qreal>(r));
} }
} }
void ArenaView::drawStations(QPainter& painter) void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordinates)
{ {
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>( m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h) [&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h)
@@ -365,13 +336,13 @@ void ArenaView::drawStations(QPainter& painter)
painter.setPen(Qt::NoPen); painter.setPen(Qt::NoPen);
for (const QPoint& cell : sb.bodyCells) for (const QPoint& cell : sb.bodyCells)
{ {
painter.fillRect(tileRect(cell), bv.fill); painter.fillRect(coordinates.tileRect(cell), bv.fill);
} }
const QPointF tl = tileToWidget(sb.anchor); const QPointF tl = coordinates.tileToWidget(sb.anchor);
const QRectF bboxRect(tl.x(), tl.y(), const QRectF bboxRect(tl.x(), tl.y(),
sb.footprint.width() * static_cast<qreal>(getTilePx()), sb.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
sb.footprint.height() * static_cast<qreal>(getTilePx())); sb.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
painter.setPen(QPen(bv.outline, 1)); painter.setPen(QPen(bv.outline, 1));
painter.setBrush(Qt::NoBrush); painter.setBrush(Qt::NoBrush);
@@ -379,14 +350,9 @@ void ArenaView::drawStations(QPainter& painter)
if (h.maxHp > 0.0f) if (h.maxHp > 0.0f)
{ {
const float fraction = std::max(0.0f, h.hp / h.maxHp); drawHealthBar(painter, coordinates, bboxRect.left(),
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12; bboxRect.bottom() + 1.0, bboxRect.width(),
const qreal barY = bboxRect.bottom() + 1.0; h.hp / h.maxHp, f.isEnemy);
const qreal barW = bboxRect.width();
painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH),
QColor(60, 60, 60));
painter.fillRect(QRectF(bboxRect.left(), barY, barW * static_cast<qreal>(fraction), barH),
f.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
} }
if (m_selectedEntity.has_value() && *m_selectedEntity == e) if (m_selectedEntity.has_value() && *m_selectedEntity == e)
@@ -398,8 +364,10 @@ void ArenaView::drawStations(QPainter& painter)
}); });
} }
void ArenaView::drawShips(QPainter& painter) void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates)
{ {
const float forward = getShipForwardExtentPx(coordinates);
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent, m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent, HealthComponent>( FactionComponent, HealthComponent>(
[&](entt::entity e, const ShipIdentityComponent& si, [&](entt::entity e, const ShipIdentityComponent& si,
@@ -410,40 +378,22 @@ void ArenaView::drawShips(QPainter& painter)
m_visuals->ships.find(si.schematicId); m_visuals->ships.find(si.schematicId);
if (it == m_visuals->ships.end()) { return; } if (it == m_visuals->ships.end()) { return; }
const QPointF center = worldToWidget(pos.value); const QPointF center = coordinates.worldToWidget(pos.value);
const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians)); drawShipBody(painter, coordinates, center, facing.radians,
const QVector2D perp(-dir.y(), dir.x()); it->second.fill, it->second.outline);
const float fwd = getTilePx() * 0.45f;
const float side = getTilePx() * 0.25f;
QPolygonF tri;
tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd),
center.y() + static_cast<qreal>(dir.y() * fwd))
<< QPointF(center.x() + static_cast<qreal>(perp.x() * side - dir.x() * side),
center.y() + static_cast<qreal>(perp.y() * side - dir.y() * side))
<< QPointF(center.x() + static_cast<qreal>(-perp.x() * side - dir.x() * side),
center.y() + static_cast<qreal>(-perp.y() * side - dir.y() * side));
painter.setPen(QPen(it->second.outline, 1));
painter.setBrush(it->second.fill);
painter.drawPolygon(tri);
if (h.maxHp > 0.0f) if (h.maxHp > 0.0f)
{ {
const float fraction = std::max(0.0f, h.hp / h.maxHp); const qreal barW = static_cast<qreal>(forward) * 2.0;
const qreal barW = static_cast<qreal>(fwd) * 2.0; const qreal barX = center.x() - static_cast<qreal>(forward);
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12; const qreal barY = center.y() + static_cast<qreal>(forward) + 1.0;
const qreal barX = center.x() - static_cast<qreal>(fwd); drawHealthBar(painter, coordinates, barX, barY, barW,
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0; h.hp / h.maxHp, fac.isEnemy);
painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60));
painter.fillRect(QRectF(barX, barY, barW * static_cast<qreal>(fraction), barH),
fac.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
} }
if (m_selectedEntity.has_value() && *m_selectedEntity == e) if (m_selectedEntity.has_value() && *m_selectedEntity == e)
{ {
const qreal radius = static_cast<qreal>(getTilePx()) * 0.55; const qreal radius = static_cast<qreal>(coordinates.getTilePx()) * 0.55;
painter.setPen(QPen(QColor(255, 255, 0), 2)); painter.setPen(QPen(QColor(255, 255, 0), 2));
painter.setBrush(Qt::NoBrush); painter.setBrush(Qt::NoBrush);
painter.drawEllipse(center, radius, radius); painter.drawEllipse(center, radius, radius);
@@ -451,9 +401,9 @@ void ArenaView::drawShips(QPainter& painter)
}); });
} }
void ArenaView::drawDebugSensorRanges(QPainter& painter) void ArenaView::drawDebugSensorRanges(QPainter& painter,
const WorldCoordinates& coordinates)
{ {
painter.setBrush(Qt::NoBrush);
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>( m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si, [&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const SensorRangeComponent& sensor) const PositionComponent& pos, const SensorRangeComponent& sensor)
@@ -462,17 +412,14 @@ void ArenaView::drawDebugSensorRanges(QPainter& painter)
m_visuals->ships.find(si.schematicId); m_visuals->ships.find(si.schematicId);
if (it == m_visuals->ships.end()) { return; } if (it == m_visuals->ships.end()) { return; }
const QPointF center = worldToWidget(pos.value); drawSensorRange(painter, coordinates,
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles) coordinates.worldToWidget(pos.value),
* static_cast<qreal>(getTilePx()); sensor.value_tiles, it->second.outline);
QColor circleColor = it->second.outline;
circleColor.setAlpha(77);
painter.setPen(QPen(circleColor, 1));
painter.drawEllipse(center, radiusPx, radiusPx);
}); });
} }
void ArenaView::drawDebugTargetLines(QPainter& painter) void ArenaView::drawDebugTargetLines(QPainter& painter,
const WorldCoordinates& coordinates)
{ {
// Draw a thin translucent line from a ship to a target, colored by the ship's // Draw a thin translucent line from a ship to a target, colored by the ship's
// team to match the per-side HQ/station colors used elsewhere in the arena // team to match the per-side HQ/station colors used elsewhere in the arena
@@ -490,7 +437,8 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
QColor lineColor = it->second.fill; QColor lineColor = it->second.fill;
lineColor.setAlpha(128); lineColor.setAlpha(128);
painter.setPen(QPen(lineColor, 1)); painter.setPen(QPen(lineColor, 1));
painter.drawLine(worldToWidget(from), worldToWidget(to)); painter.drawLine(coordinates.worldToWidget(from),
coordinates.worldToWidget(to));
}; };
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
@@ -535,7 +483,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
}); });
} }
void ArenaView::drawBeams(QPainter& painter) void ArenaView::drawBeams(QPainter& painter, const WorldCoordinates& coordinates)
{ {
for (const ActiveBeam& beam : m_activeBeams) for (const ActiveBeam& beam : m_activeBeams)
{ {
@@ -551,7 +499,7 @@ void ArenaView::drawBeams(QPainter& painter)
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break; case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
} }
painter.setPen(QPen(color, m_visuals->beams.widthPx)); painter.setPen(QPen(color, m_visuals->beams.widthPx));
painter.drawLine(worldToWidget(*shooterPos), painter.drawLine(coordinates.worldToWidget(*shooterPos),
worldToWidget(*targetPos + beam.targetOffset)); coordinates.worldToWidget(*targetPos + beam.targetOffset));
} }
} }

View File

@@ -17,6 +17,7 @@
#include "Tick.h" #include "Tick.h"
#include "TickDriver.h" #include "TickDriver.h"
#include "VisualsConfig.h" #include "VisualsConfig.h"
#include "WorldCoordinates.h"
class ArenaSimulation; class ArenaSimulation;
class QPainter; class QPainter;
@@ -47,22 +48,22 @@ private slots:
private: private:
void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override; void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
void drawTiles(QPainter& painter); void drawTiles(QPainter& painter, const WorldCoordinates& coordinates);
void drawBuildings(QPainter& painter); void drawBuildings(QPainter& painter, const WorldCoordinates& coordinates);
void drawStations(QPainter& painter); void drawStations(QPainter& painter, const WorldCoordinates& coordinates);
void drawDebris(QPainter& painter); void drawDebris(QPainter& painter, const WorldCoordinates& coordinates);
void drawShips(QPainter& painter); void drawShips(QPainter& painter, const WorldCoordinates& coordinates);
void drawDebugSensorRanges(QPainter& painter); void drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates);
void drawDebugTargetLines(QPainter& painter); void drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates);
void drawBeams(QPainter& painter); void drawBeams(QPainter& painter, const WorldCoordinates& coordinates);
float getTilePx() const; // The world <-> widget transform for the current viewport size. The arena
QPointF worldToWidget(QVector2D worldPos) const; // shows the whole world at once and never scrolls, so this fits the arena's
QPointF tileToWidget(QPoint tile) const; // full extent into the widget; like GameWorldView's, it is a per-frame
QRectF tileRect(QPoint tile) const; // snapshot rather than cached state.
WorldCoordinates getCoordinates() const;
std::optional<QVector2D> entityPosition(entt::entity entity) const; std::optional<QVector2D> entityPosition(entt::entity entity) const;
QVector2D widgetToWorld(QPoint widgetPt) const;
struct ActiveBeam struct ActiveBeam
{ {

View File

@@ -9,6 +9,10 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h
# Shared world-space shapes so the arena keeps looking like the game
# (see WorldPrimitives.h). The balancing target does not link the ui library,
# so the few ui files it needs are compiled into it, as above.
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -23,5 +27,6 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.cpp
PARENT_SCOPE PARENT_SCOPE
) )

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

@@ -0,0 +1,261 @@
#include "BuildModeController.h"
#include <memory>
#include <utility>
#include "BlueprintModeExitedEvent.h"
#include "BuilderModeExitedEvent.h"
#include "DeconstructModeChangedEvent.h"
#include "EventManager.h"
namespace
{
Rotation rotateClockwise(Rotation rotation)
{
switch (rotation)
{
case Rotation::North: return Rotation::East;
case Rotation::East: return Rotation::South;
case Rotation::South: return Rotation::West;
case Rotation::West: return Rotation::North;
}
return Rotation::East;
}
Rotation rotateCounterClockwise(Rotation rotation)
{
switch (rotation)
{
case Rotation::North: return Rotation::West;
case Rotation::East: return Rotation::North;
case Rotation::South: return Rotation::East;
case Rotation::West: return Rotation::South;
}
return Rotation::East;
}
} // namespace
BuildMode BuildModeController::getMode() const
{
return m_mode;
}
bool BuildModeController::isBuilderMode() const
{
return m_mode == BuildMode::Builder;
}
bool BuildModeController::isBlueprintMode() const
{
return m_mode == BuildMode::Blueprint;
}
bool BuildModeController::isDeconstructMode() const
{
return m_mode == BuildMode::Deconstruct;
}
void BuildModeController::enterMode(BuildMode mode)
{
if (m_mode == mode) { return; }
// Leave the current mode properly, so its widget hears about it however the
// player left. Each exit clears only its own state.
switch (m_mode)
{
case BuildMode::Builder:
m_draggingBelt = false;
m_beltDragPath.clear();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuilderModeExitedEvent>());
break;
case BuildMode::Blueprint:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintModeExitedEvent>());
break;
case BuildMode::Deconstruct:
m_deconstructHoverBuildingId.reset();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DeconstructModeChangedEvent>(false));
break;
case BuildMode::None:
break;
}
m_mode = mode;
if (mode == BuildMode::Deconstruct)
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DeconstructModeChangedEvent>(true));
}
}
void BuildModeController::enterBuilderMode(BuildingType type)
{
enterMode(BuildMode::Builder);
m_builderType = type;
m_ghostRotation = Rotation::East;
m_ghostValid = false;
m_tunnelGhostType = BuildingType::TunnelEntry;
m_tunnelPartnerTile.reset();
}
void BuildModeController::enterBlueprintMode(Blueprint blueprint)
{
enterMode(BuildMode::Blueprint);
// The layout starts where the builder ghost last was, so switching from a
// building to a blueprint does not jump the preview across the world.
m_blueprintGhostTile = m_ghostTile;
m_blueprint = std::move(blueprint);
}
void BuildModeController::toggleDeconstructMode()
{
enterMode(isDeconstructMode() ? BuildMode::None : BuildMode::Deconstruct);
}
void BuildModeController::exitBuilderMode()
{
if (!isBuilderMode()) { return; }
enterMode(BuildMode::None);
}
void BuildModeController::exitBlueprintMode()
{
if (!isBlueprintMode()) { return; }
enterMode(BuildMode::None);
}
void BuildModeController::exitCurrentMode()
{
enterMode(BuildMode::None);
}
BuildingType BuildModeController::getBuilderType() const
{
return m_builderType;
}
bool BuildModeController::isTunnelMode() const
{
return isBuilderMode() && m_builderType == BuildingType::TunnelEntry;
}
BuildingType BuildModeController::getEffectiveBuilderType() const
{
return isTunnelMode() ? m_tunnelGhostType : m_builderType;
}
QPoint BuildModeController::getGhostTile() const
{
return m_ghostTile;
}
Rotation BuildModeController::getGhostRotation() const
{
return m_ghostRotation;
}
bool BuildModeController::isGhostValid() const
{
return m_ghostValid;
}
void BuildModeController::setGhostTile(QPoint tile)
{
m_ghostTile = tile;
}
void BuildModeController::setGhostValidity(bool valid)
{
m_ghostValid = valid;
}
void BuildModeController::rotateGhost(bool clockwise)
{
m_ghostRotation = clockwise ? rotateClockwise(m_ghostRotation)
: rotateCounterClockwise(m_ghostRotation);
}
BuildingType BuildModeController::getTunnelGhostType() const
{
return m_tunnelGhostType;
}
const std::optional<QPoint>& BuildModeController::getTunnelPartnerTile() const
{
return m_tunnelPartnerTile;
}
void BuildModeController::setTunnelGhost(BuildingType resolvedType,
std::optional<QPoint> partnerTile)
{
m_tunnelGhostType = resolvedType;
m_tunnelPartnerTile = std::move(partnerTile);
}
bool BuildModeController::isDraggingBelt() const
{
return m_draggingBelt;
}
QPoint BuildModeController::getBeltDragAnchor() const
{
return m_beltDragAnchor;
}
const std::vector<BeltPathTile>& BuildModeController::getBeltDragPath() const
{
return m_beltDragPath;
}
void BuildModeController::beginBeltDrag(QPoint anchorTile)
{
m_draggingBelt = true;
m_beltDragAnchor = anchorTile;
}
void BuildModeController::setBeltDragPath(std::vector<BeltPathTile> path)
{
m_beltDragPath = std::move(path);
}
void BuildModeController::cancelBeltDrag()
{
m_draggingBelt = false;
m_beltDragPath.clear();
}
const Blueprint& BuildModeController::getBlueprint() const
{
return m_blueprint;
}
Blueprint& BuildModeController::getMutableBlueprint()
{
return m_blueprint;
}
QPoint BuildModeController::getBlueprintGhostTile() const
{
return m_blueprintGhostTile;
}
void BuildModeController::setBlueprintGhostTile(QPoint tile)
{
m_blueprintGhostTile = tile;
}
const std::optional<BuildingId>&
BuildModeController::getDeconstructHoverBuildingId() const
{
return m_deconstructHoverBuildingId;
}
void BuildModeController::setDeconstructHoverBuildingId(std::optional<BuildingId> id)
{
m_deconstructHoverBuildingId = std::move(id);
}

View File

@@ -0,0 +1,123 @@
#pragma once
#include <optional>
#include <vector>
#include <QPoint>
#include "BeltDragPath.h"
#include "Blueprint.h"
#include "BuildingId.h"
#include "BuildingType.h"
#include "Rotation.h"
// Which of the mutually exclusive world-interaction modes is active
// (REQ-UI-HOTKEYS, REQ-BLD-GHOST, REQ-UI-BLUEPRINT-PLACE, REQ-BLD-DECONSTRUCT).
enum class BuildMode
{
None, // plain selection
Builder, // placing one building type, ghost following the cursor
Blueprint, // placing a saved multi-building layout
Deconstruct // marking buildings for demolition
};
// The active build mode and the transient state that belongs to it.
//
// These modes were previously three independent flags, and every entry point
// cleared the other two by hand — inconsistently, which is how entering builder
// mode came to drop a blueprint without announcing it. Here exclusivity is
// structural: one mode is active, and every transition runs through enterMode(),
// which exits whatever was active first and publishes the same events regardless
// of which way the player got there.
//
// Everything needing the simulation — placement validity, tunnel matching, belt
// path building — stays with the caller, which computes and hands back the result
// (setGhostValidity, setTunnelGhost, setBeltDragPath). That keeps this a plain
// value that can be tested without a world.
class BuildModeController
{
public:
BuildMode getMode() const;
bool isBuilderMode() const;
bool isBlueprintMode() const;
bool isDeconstructMode() const;
// --- transitions ----------------------------------------------------------
// Each leaves the previously active mode with its proper exit event.
void enterBuilderMode(BuildingType type);
void enterBlueprintMode(Blueprint blueprint);
// Leaves deconstruct mode if it is active, enters it otherwise
// (REQ-BLD-DECONSTRUCT-CLICK).
void toggleDeconstructMode();
void exitBuilderMode();
void exitBlueprintMode();
// Backs out of whichever mode is active, if any (the Q key and right-click).
void exitCurrentMode();
// --- builder mode ---------------------------------------------------------
// Only meaningful while isBuilderMode().
BuildingType getBuilderType() const;
// True while the builder type is TunnelEntry, where the ghost resolves to an
// entry or an exit by hovered position (REQ-BLD-TUNNEL-MODE).
bool isTunnelMode() const;
// The type the ghost currently represents: the position-resolved tunnel type in
// tunnel mode, the plain builder type otherwise.
BuildingType getEffectiveBuilderType() const;
QPoint getGhostTile() const;
Rotation getGhostRotation() const;
bool isGhostValid() const;
void setGhostTile(QPoint tile);
void setGhostValidity(bool valid);
// Turns the ghost one quarter turn. Validity is not rechecked here; the caller
// does that and calls setGhostValidity, because only it can see the world.
void rotateGhost(bool clockwise);
BuildingType getTunnelGhostType() const;
const std::optional<QPoint>& getTunnelPartnerTile() const;
void setTunnelGhost(BuildingType resolvedType, std::optional<QPoint> partnerTile);
// --- belt drag placement (REQ-BLD-BELT-DRAG) ------------------------------
bool isDraggingBelt() const;
QPoint getBeltDragAnchor() const;
const std::vector<BeltPathTile>& getBeltDragPath() const;
void beginBeltDrag(QPoint anchorTile);
void setBeltDragPath(std::vector<BeltPathTile> path);
// Drops the drag without placing anything, staying in builder mode.
void cancelBeltDrag();
// --- blueprint mode -------------------------------------------------------
// Only meaningful while isBlueprintMode().
const Blueprint& getBlueprint() const;
// Mutable so the caller can rotate the layout in place; rotating a blueprint
// needs building footprints from the config, which does not belong here.
Blueprint& getMutableBlueprint();
QPoint getBlueprintGhostTile() const;
void setBlueprintGhostTile(QPoint tile);
// --- deconstruct mode -----------------------------------------------------
const std::optional<BuildingId>& getDeconstructHoverBuildingId() const;
void setDeconstructHoverBuildingId(std::optional<BuildingId> id);
private:
// The single transition point: leaves the active mode, then enters `mode`.
void enterMode(BuildMode mode);
BuildMode m_mode = BuildMode::None;
BuildingType m_builderType = BuildingType::Belt;
QPoint m_ghostTile;
Rotation m_ghostRotation = Rotation::East;
bool m_ghostValid = false;
BuildingType m_tunnelGhostType = BuildingType::TunnelEntry;
std::optional<QPoint> m_tunnelPartnerTile;
bool m_draggingBelt = false;
QPoint m_beltDragAnchor;
std::vector<BeltPathTile> m_beltDragPath;
Blueprint m_blueprint;
QPoint m_blueprintGhostTile;
std::optional<BuildingId> m_deconstructHoverBuildingId;
};

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,20 +9,30 @@ 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
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h ${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h
PARENT_SCOPE PARENT_SCOPE
) )
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
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp ${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp
PARENT_SCOPE PARENT_SCOPE
) )

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

@@ -0,0 +1,165 @@
#include "SelectionController.h"
#include <algorithm>
#include <memory>
#include "DebrisSelectionChangedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventManager.h"
#include "SelectionChangedEvent.h"
namespace
{
template <typename T>
bool contains(const std::vector<T>& items, const T& item)
{
return std::find(items.begin(), items.end(), item) != items.end();
}
// Applies `hits` to `selection` per `mode`. Replace is handled by the caller so
// that an empty hit list can mean "clear this category" there but "leave this
// category alone" here.
template <typename T>
void combine(std::vector<T>& selection, const std::vector<T>& hits, SelectionMode mode)
{
for (const T& hit : hits)
{
const typename std::vector<T>::iterator it =
std::find(selection.begin(), selection.end(), hit);
if (it == selection.end())
{
selection.push_back(hit);
}
else if (mode == SelectionMode::Toggle)
{
// Only a toggle removes; an additive box drag never deselects.
selection.erase(it);
}
}
}
} // namespace
const std::vector<BuildingId>& SelectionController::getSelectedBuildings() const
{
return m_buildings;
}
const std::vector<entt::entity>& SelectionController::getSelectedActors() const
{
return m_actors;
}
const std::vector<entt::entity>& SelectionController::getSelectedDebris() const
{
return m_debris;
}
bool SelectionController::isActorSelected(entt::entity actor) const
{
return contains(m_actors, actor);
}
bool SelectionController::isDebrisSelected(entt::entity debris) const
{
return contains(m_debris, debris);
}
void SelectionController::selectBuildings(const std::vector<BuildingId>& ids,
SelectionMode mode)
{
// Buildings win over field objects (REQ-UI-SELECTION-CATEGORIES).
if (clearActorsQuietly()) { publishActors(); }
if (clearDebrisQuietly()) { publishDebris(); }
if (mode == SelectionMode::Replace)
{
m_buildings = ids;
}
else
{
combine(m_buildings, ids, mode);
}
publishBuildings();
}
void SelectionController::selectFieldObjects(const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris,
SelectionMode mode)
{
if (clearBuildingsQuietly()) { publishBuildings(); }
if (mode == SelectionMode::Replace)
{
m_actors = actors;
m_debris = debris;
}
else
{
combine(m_actors, actors, mode);
combine(m_debris, debris, mode);
}
publishActors();
publishDebris();
}
void SelectionController::clearAll()
{
if (clearBuildingsQuietly()) { publishBuildings(); }
if (clearActorsQuietly()) { publishActors(); }
if (clearDebrisQuietly()) { publishDebris(); }
}
void SelectionController::setSelectedActors(std::vector<entt::entity> actors)
{
if (actors == m_actors) { return; }
m_actors = std::move(actors);
publishActors();
}
void SelectionController::setSelectedDebris(std::vector<entt::entity> debris)
{
if (debris == m_debris) { return; }
m_debris = std::move(debris);
publishDebris();
}
bool SelectionController::clearBuildingsQuietly()
{
if (m_buildings.empty()) { return false; }
m_buildings.clear();
return true;
}
bool SelectionController::clearActorsQuietly()
{
if (m_actors.empty()) { return false; }
m_actors.clear();
return true;
}
bool SelectionController::clearDebrisQuietly()
{
if (m_debris.empty()) { return false; }
m_debris.clear();
return true;
}
void SelectionController::publishBuildings() const
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_buildings));
}
void SelectionController::publishActors() const
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectionChangedEvent>(m_actors));
}
void SelectionController::publishDebris() const
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DebrisSelectionChangedEvent>(m_debris));
}

View File

@@ -0,0 +1,75 @@
#pragma once
#include <vector>
#include "BuildingId.h"
#include "entt/entity/entity.hpp"
// How a new hit combines with what is already selected.
enum class SelectionMode
{
Replace, // plain click or drag: the hit becomes the whole selection
Toggle, // Ctrl + click: the hit joins the selection, or leaves it if present
Add // Ctrl + box drag: the hits join the selection, never leave it
};
// The player's current selection across the three categories, and the rules that
// govern moving between them (REQ-UI-SELECTION-CATEGORIES).
//
// The rules were previously written out once for point-clicks and once for box
// drags, which is why they live here now: buildings win over field objects, so
// selecting a building clears actors and debris, and selecting either of those
// clears buildings — but actors and debris coexist with each other
// (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT, REQ-UI-MULTI-SELECT,
// REQ-UI-DEBRIS-MULTI-SELECT). A point-click and a box drag then differ only in
// the SelectionMode they pass and in how many hits they pass.
//
// Every mutator publishes the change events, so callers never emit them by hand.
// Hit-testing is not done here: callers resolve what was hit and pass the result
// in, which keeps this free of any simulation dependency.
class SelectionController
{
public:
const std::vector<BuildingId>& getSelectedBuildings() const;
const std::vector<entt::entity>& getSelectedActors() const;
const std::vector<entt::entity>& getSelectedDebris() const;
bool isActorSelected(entt::entity actor) const;
bool isDebrisSelected(entt::entity debris) const;
// Selects buildings and/or construction sites, clearing any field selection.
// Always publishes SelectionChangedEvent, even when the result is unchanged,
// so a click on an already-selected building still refreshes its panel.
void selectBuildings(const std::vector<BuildingId>& ids, SelectionMode mode);
// Selects field objects, clearing any building selection. Actors and debris are
// set together so a Replace can express "these actors and no debris" — which is
// what a plain click on an actor means — while a Toggle or Add leaves the
// category whose vector is empty untouched. Always publishes both field events.
void selectFieldObjects(const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris,
SelectionMode mode);
// Empties all three categories, publishing only for those that were non-empty.
void clearAll();
// Replace one category outright, publishing only if it actually changed. For
// the per-frame prune of entities that despawned or died: the liveness query
// belongs to the caller, which is the one that can see the simulation.
void setSelectedActors(std::vector<entt::entity> actors);
void setSelectedDebris(std::vector<entt::entity> debris);
private:
// Each returns whether anything changed, without publishing.
bool clearBuildingsQuietly();
bool clearActorsQuietly();
bool clearDebrisQuietly();
void publishBuildings() const;
void publishActors() const;
void publishDebris() const;
std::vector<BuildingId> m_buildings;
std::vector<entt::entity> m_actors;
std::vector<entt::entity> m_debris;
};

View File

@@ -153,3 +153,13 @@ std::optional<QPoint> findTunnelPartner(const TunnelLookup& lookup, QPoint tile,
return std::nullopt; return std::nullopt;
} }
TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels)
{
return [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
{
const TunnelTileMap::const_iterator it = tunnels.find(tile);
if (it == tunnels.end()) { return std::nullopt; }
return it->second;
};
}

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <functional> #include <functional>
#include <map>
#include <optional> #include <optional>
#include <QPoint> #include <QPoint>
@@ -9,6 +10,17 @@
#include "BuildingType.h" #include "BuildingType.h"
#include "Rotation.h" #include "Rotation.h"
// QPoint has no operator<, so an explicit ordering is needed to key a map or set
// by tile.
struct QPointCompare
{
bool operator()(const QPoint& a, const QPoint& b) const
{
if (a.x() != b.x()) { return a.x() < b.x(); }
return a.y() < b.y();
}
};
// A tunnel building occupying a single tile: whether it is an entry or an exit and // A tunnel building occupying a single tile: whether it is an entry or an exit and
// the direction it faces. Used by the tunnel pairing scan (REQ-BLD-TUNNEL-PAIR) and // the direction it faces. Used by the tunnel pairing scan (REQ-BLD-TUNNEL-PAIR) and
// the unified tunnel build mode (REQ-BLD-TUNNEL-MODE). // the unified tunnel build mode (REQ-BLD-TUNNEL-MODE).
@@ -22,6 +34,13 @@ struct TunnelTileInfo
// direction, or std::nullopt when the tile holds no tunnel building. // direction, or std::nullopt when the tile holds no tunnel building.
using TunnelLookup = std::function<std::optional<TunnelTileInfo>(QPoint)>; using TunnelLookup = std::function<std::optional<TunnelTileInfo>(QPoint)>;
// Tunnel entries/exits indexed by their single-cell tile (REQ-BLD-TUNNEL-MODE).
using TunnelTileMap = std::map<QPoint, TunnelTileInfo, QPointCompare>;
// Wraps a tunnel tile index in the lookup functor the helpers below take. The
// returned functor references `tunnels`, which must outlive it.
TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels);
// Steps from `start` in `stepDir` over the tiles at distance 1..maxDistance and // Steps from `start` in `stepDir` over the tiles at distance 1..maxDistance and
// returns the first tile whose tunnel faces `targetFacing`. Tunnel buildings facing // returns the first tile whose tunnel faces `targetFacing`. Tunnel buildings facing
// any other direction are skipped, mirroring the "stop at the first same-direction // any other direction are skipped, mirroring the "stop at the first same-direction

View File

@@ -0,0 +1,72 @@
#include "WorldCamera.h"
#include <algorithm>
namespace
{
// Linearly blend from valueAt0 (for x <= x0) to valueAt1 (for x >= x1), clamped
// outside [x0, x1]. A zero- or negative-width band collapses to a hard step at x1.
float lerpClamped(float valueAt0, float valueAt1, float x0, float x1, float x)
{
if (x1 <= x0) { return x < x1 ? valueAt0 : valueAt1; }
const float t = std::max(0.0f, std::min(1.0f, (x - x0) / (x1 - x0)));
return valueAt0 + (valueAt1 - valueAt0) * t;
}
}
WorldCamera::WorldCamera(const WorldScroll& scroll, const WorldRegions& regions)
: m_scroll(&scroll)
, m_regions(&regions)
, m_viewCenterXTiles(0.0f)
{
}
bool WorldCamera::advance(PanDirection direction, qint64 elapsedMs,
ScrollBounds bounds)
{
const float before = m_viewCenterXTiles;
if (direction != PanDirection::None)
{
const float distance =
getPanSpeedTilesPerSecondAt(m_viewCenterXTiles, bounds.rightTiles)
* static_cast<float>(elapsedMs) / 1000.0f;
m_viewCenterXTiles += (direction == PanDirection::Left) ? -distance : distance;
}
m_viewCenterXTiles = std::max(bounds.leftTiles,
std::min(m_viewCenterXTiles, bounds.rightTiles));
return m_viewCenterXTiles != before;
}
float WorldCamera::getViewCenterXTiles() const
{
return m_viewCenterXTiles;
}
void WorldCamera::reset()
{
m_viewCenterXTiles = 0.0f;
}
float WorldCamera::getPanSpeedTilesPerSecondAt(float viewCenterXTiles,
float contestZoneRightEdgeTiles) const
{
// Slow near the asteroid/player buffer, fast across the contest zone, with a
// linear ramp straddling each contest-zone boundary (REQ-UI-SCROLL-SPEED). The
// contest zone spans from the player buffer's right edge to the enemy stations,
// the latter tracked live so the ramp follows the front line as it is pushed.
const float slow = static_cast<float>(m_scroll->panSpeedSlow_tps);
const float fast = static_cast<float>(m_scroll->panSpeedFast_tps);
const float half = static_cast<float>(m_scroll->panRampBandWidth_tiles) / 2.0f;
const float leftEdge = static_cast<float>(m_regions->playerBufferWidth_tiles);
const float rightEdge = contestZoneRightEdgeTiles;
// Rising ramp at the left boundary (slow -> fast) and falling ramp at the right
// boundary (fast -> slow); their minimum yields flat-slow outside, flat-fast in
// the middle, and — if the bands overlap in a narrow contest zone — a single peak
// below the fast speed where the two ramps cross.
const float leftRamp = lerpClamped(slow, fast, leftEdge - half, leftEdge + half, viewCenterXTiles);
const float rightRamp = lerpClamped(fast, slow, rightEdge - half, rightEdge + half, viewCenterXTiles);
return std::min(leftRamp, rightRamp);
}

View File

@@ -0,0 +1,72 @@
#pragma once
#include <QtGlobal>
#include "WorldConfig.h"
// Which way the player is currently panning the view (REQ-UI-SCROLL). A plain
// direction rather than key state: the camera is deliberately agnostic about how
// the intent was expressed, so rebindable controls would change nothing here.
enum class PanDirection
{
None,
Left,
Right
};
// The horizontal limits of the view center, in world tiles (REQ-GW-SCROLL-LIMIT):
// the view can pan left until the asteroid's left edge is centered and right until
// the enemy stations are. Both move as the game progresses — the left edge with
// asteroid expansion (REQ-GW-ASTEROID-EXPAND), the right edge as stations are
// pushed back (REQ-GW-PUSH-EXPAND) — so they are supplied per frame by the caller
// that can see the simulation, rather than queried here. That keeps the camera a
// value with no simulation dependency.
struct ScrollBounds
{
float leftTiles;
float rightTiles;
};
// Horizontal view position for the game world (REQ-UI-SCROLL, REQ-UI-SCROLL-SPEED).
// Works purely in world units — tiles and tiles per second, never pixels. Turning
// the resulting position into a widget transform is WorldCoordinates' job; the two
// meet only where the view feeds getViewCenterXTiles() into that transform.
class WorldCamera
{
public:
// Both config structs are referenced rather than copied: they live inside the
// Simulation's GameConfig, which is assigned in place on restart
// (REQ-CFG-RELOAD), so a camera built once still picks up reloaded tuning.
WorldCamera(const WorldScroll& scroll, const WorldRegions& regions);
// Pans by `direction` for `elapsedMs` of wall-clock time, then clamps into
// `bounds`. Wall clock rather than ticks because panning is presentation only
// (REQ-UI-NO-ZOOM's sibling concern) and keeps working while the simulation is
// paused. Clamping happens on every call, not just when panning, so the view
// follows the bounds inward when they shrink.
//
// Returns true when the view center actually moved — including when it moved
// only because the bounds did. Callers use that to refresh anything anchored to
// the world under a stationary cursor, such as the box-select rectangle.
bool advance(PanDirection direction, qint64 elapsedMs, ScrollBounds bounds);
// World X (tiles) at the center of the viewport.
float getViewCenterXTiles() const;
// Returns the view to the start-of-run position. Deliberately does not clamp:
// a new run's bounds are not known here, and the next advance() clamps anyway.
void reset();
// Pan speed at a given view center, in tiles/s (REQ-UI-SCROLL-SPEED). Public
// because the ramp shape is the subtle part of this class and is worth testing
// directly; advance() uses it internally. `contestZoneRightEdgeTiles` is the
// live right-hand boundary — the same value as ScrollBounds::rightTiles — so
// the ramp follows the front line as it is pushed.
float getPanSpeedTilesPerSecondAt(float viewCenterXTiles,
float contestZoneRightEdgeTiles) const;
private:
const WorldScroll* m_scroll;
const WorldRegions* m_regions;
float m_viewCenterXTiles;
};

View File

@@ -0,0 +1,118 @@
#include "WorldCoordinates.h"
#include <algorithm>
#include <cmath>
namespace
{
// A zero-height widget, a not-yet-shown widget, or a degenerate world size
// would otherwise make every conversion divide by zero.
float sanitizeTilePx(float tilePx)
{
return tilePx > 0.0f ? tilePx : 1.0f;
}
}
WorldCoordinates WorldCoordinates::scrolling(QSize widgetSize_px,
int worldHeight_tiles,
float viewCenterX_tiles)
{
float tilePx = 1.0f;
if (worldHeight_tiles > 0)
{
tilePx = static_cast<float>(widgetSize_px.height())
/ static_cast<float>(worldHeight_tiles);
}
tilePx = sanitizeTilePx(tilePx);
const float viewportWidthTiles = static_cast<float>(widgetSize_px.width()) / tilePx;
return WorldCoordinates(tilePx, static_cast<float>(widgetSize_px.width()),
viewCenterX_tiles - viewportWidthTiles / 2.0f,
worldHeight_tiles);
}
WorldCoordinates WorldCoordinates::fitToWorld(QSize widgetSize_px,
int worldWidth_tiles,
int worldHeight_tiles)
{
float tilePx = 1.0f;
if (worldWidth_tiles > 0 && worldHeight_tiles > 0)
{
// The tighter of the two fits, so the whole world stays on screen.
tilePx = std::min(
static_cast<float>(widgetSize_px.height()) / static_cast<float>(worldHeight_tiles),
static_cast<float>(widgetSize_px.width()) / static_cast<float>(worldWidth_tiles));
}
tilePx = sanitizeTilePx(tilePx);
return WorldCoordinates(tilePx, static_cast<float>(widgetSize_px.width()),
0.0f, worldHeight_tiles);
}
WorldCoordinates::WorldCoordinates(float tilePx, float viewportWidth_px,
float viewLeft_tiles, int worldHeight_tiles)
: m_tilePx(tilePx)
, m_viewportWidthTiles(viewportWidth_px / tilePx)
, m_viewLeftTiles(viewLeft_tiles)
, m_worldHeightTiles(worldHeight_tiles)
{
}
float WorldCoordinates::getTilePx() const
{
return m_tilePx;
}
float WorldCoordinates::getViewportWidthTiles() const
{
return m_viewportWidthTiles;
}
float WorldCoordinates::getViewLeftTiles() const
{
return m_viewLeftTiles;
}
QPointF WorldCoordinates::worldToWidget(QVector2D worldPos) const
{
return QPointF(
static_cast<qreal>((worldPos.x() - m_viewLeftTiles) * m_tilePx),
static_cast<qreal>(worldPos.y() * m_tilePx));
}
QPointF WorldCoordinates::tileToWidget(QPoint tile) const
{
return worldToWidget(QVector2D(static_cast<float>(tile.x()),
static_cast<float>(tile.y())));
}
QPoint WorldCoordinates::widgetToTile(QPoint widgetPoint) const
{
const QVector2D world = widgetToWorld(widgetPoint);
return QPoint(static_cast<int>(std::floor(world.x())),
static_cast<int>(std::floor(world.y())));
}
QVector2D WorldCoordinates::widgetToWorld(QPoint widgetPoint) const
{
return QVector2D(
static_cast<float>(widgetPoint.x()) / m_tilePx + m_viewLeftTiles,
static_cast<float>(widgetPoint.y()) / m_tilePx);
}
QRectF WorldCoordinates::tileRect(QPoint tile) const
{
const QPointF topLeft = tileToWidget(tile);
return QRectF(topLeft.x(), topLeft.y(),
static_cast<qreal>(m_tilePx), static_cast<qreal>(m_tilePx));
}
QRect WorldCoordinates::getViewportRect() const
{
const int left = static_cast<int>(std::floor(m_viewLeftTiles)) - 1;
const int top = 0;
const int right = static_cast<int>(
std::ceil(m_viewLeftTiles + m_viewportWidthTiles)) + 1;
const int bottom = m_worldHeightTiles;
return QRect(left, top, right - left, bottom - top);
}

View File

@@ -0,0 +1,63 @@
#pragma once
#include <QPoint>
#include <QPointF>
#include <QRect>
#include <QRectF>
#include <QSize>
#include <QVector2D>
// Immutable snapshot of the world <-> widget transform for one viewport state
// (REQ-GW-COORDS). Tiles are square; the two factories below differ only in how
// the tile size and the left edge are derived, and everything downstream of that
// is shared.
//
// The transform is a value: it is constructed from the viewport size and the view
// state, and never observes them again. A caller therefore builds one per frame
// (or per event) rather than holding one across a resize or a scroll, which would
// silently go stale.
class WorldCoordinates
{
public:
// The scrolling game world: the tile size is whatever makes the world height
// exactly fill the viewport height (REQ-GW-TILE-SIZE, no zoom per
// REQ-UI-NO-ZOOM), and the view pans horizontally. `viewCenterX_tiles` is the
// world X at the center of the viewport, matching how the scroll position is
// stored and clamped (REQ-GW-SCROLL-LIMIT).
static WorldCoordinates scrolling(QSize widgetSize_px, int worldHeight_tiles,
float viewCenterX_tiles);
// A whole world shown at once with no scrolling, as the balancing tool's arena
// does: the tile size is whichever axis is the tighter fit, so nothing is cut
// off, and the world origin sits at the widget's top-left. A viewport wider
// than the fitted world leaves empty space to the right rather than centering.
static WorldCoordinates fitToWorld(QSize widgetSize_px, int worldWidth_tiles,
int worldHeight_tiles);
// Side length of one tile in pixels. Always positive: a degenerate world or
// viewport size falls back to 1.0 so no conversion below divides by zero.
float getTilePx() const;
float getViewportWidthTiles() const;
// World X (tiles) at the left edge of the viewport.
float getViewLeftTiles() const;
QPointF worldToWidget(QVector2D worldPos) const;
QPointF tileToWidget(QPoint tile) const;
QPoint widgetToTile(QPoint widgetPoint) const;
QVector2D widgetToWorld(QPoint widgetPoint) const;
// Widget-space rect covering the whole of `tile`.
QRectF tileRect(QPoint tile) const;
// Tile-space rect of everything currently on screen, widened by one column on
// each side so items straddling an edge are still drawn.
QRect getViewportRect() const;
private:
WorldCoordinates(float tilePx, float viewportWidth_px, float viewLeft_tiles,
int worldHeight_tiles);
float m_tilePx;
float m_viewportWidthTiles;
float m_viewLeftTiles;
int m_worldHeightTiles;
};

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

@@ -16,6 +16,12 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PanDirectionChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PauseToggleRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SpeedStepRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GhostRotationRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ModeCancelRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggleRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildHotkeyPressedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildHotkeyPressedEvent.h

View File

@@ -0,0 +1,12 @@
#pragma once
#include "Event.h"
// The player asked to toggle the debug overlays (REQ-UI-HOTKEYS). The request to
// flip the flag; DebugDrawToggledEvent is the announcement that it was flipped and
// what it now is. Splitting the two keeps the flag itself in one owner.
class DebugDrawToggleRequestedEvent : public Event
{
public:
DebugDrawToggleRequestedEvent() = default;
};

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

@@ -0,0 +1,13 @@
#pragma once
#include "Event.h"
// The player asked to rotate the placement ghost (REQ-BLD-ROTATE, REQ-UI-HOTKEYS).
// Sent whether or not a builder or blueprint mode is actually active; deciding
// there is nothing to rotate is the receiver's job.
class GhostRotationRequestedEvent : public Event
{
public:
explicit GhostRotationRequestedEvent(bool clockwise) : clockwise(clockwise) {}
const bool clockwise;
};

View File

@@ -0,0 +1,13 @@
#pragma once
#include "Event.h"
// The player pressed the one "get me out of the current mode" key (REQ-UI-HOTKEYS).
// Intentionally says only that, not which mode to leave: which of builder,
// blueprint placement, or deconstruct is active — and that the key falls through to
// entering deconstruct mode when none of them is — is state only the receiver has.
class ModeCancelRequestedEvent : public Event
{
public:
ModeCancelRequestedEvent() = default;
};

View File

@@ -0,0 +1,22 @@
#pragma once
#include "Event.h"
#include "WorldCamera.h"
// The direction the player is currently panning the view has changed
// (REQ-UI-SCROLL). Deliberately level-triggered: the payload is the complete
// current direction, including PanDirection::None when panning stops, rather than
// separate started/stopped events. A receiver that only ever saw edges would have
// to reconstruct the state and would be left panning forever if one edge went
// missing — which is exactly what happens when the widget loses focus mid-pan.
//
// Unlike the state-change events elsewhere in the UI, the receiver does cache this
// payload instead of re-reading the value from somewhere authoritative. That is
// correct here: input has no other source of truth to re-read from, so the
// publisher (InputMapper) is the authority and the payload is the value.
class PanDirectionChangedEvent : public Event
{
public:
explicit PanDirectionChangedEvent(PanDirection direction) : direction(direction) {}
const PanDirection direction;
};

View File

@@ -0,0 +1,12 @@
#pragma once
#include "Event.h"
// The player asked to pause or unpause (REQ-UI-HOTKEYS). Carries no speed: which
// speed to restore on unpause is the receiver's business, since it is the one that
// remembers what was running before the pause.
class PauseToggleRequestedEvent : public Event
{
public:
PauseToggleRequestedEvent() = default;
};

View File

@@ -0,0 +1,14 @@
#pragma once
#include "Event.h"
// The player asked to step the game speed one notch (REQ-UI-HOTKEYS): +1 faster,
// -1 slower. A relative step rather than a target speed, because the ladder of
// available speeds belongs to the receiver — contrast SpeedChangeRequestedEvent,
// which names an absolute multiplier and is what the speed buttons send.
class SpeedStepRequestedEvent : public Event
{
public:
explicit SpeedStepRequestedEvent(int delta) : delta(delta) {}
const int delta;
};

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,240 @@
#include "FactoryQueries.h"
#include <algorithm>
#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;
}
std::vector<BuildingId> buildingsInBox(const FactoryState& state,
QPoint cornerA, QPoint cornerB)
{
const int x0 = std::min(cornerA.x(), cornerB.x());
const int y0 = std::min(cornerA.y(), cornerB.y());
const int x1 = std::max(cornerA.x(), cornerB.x());
const int y1 = std::max(cornerA.y(), cornerB.y());
const auto covers = [&](const std::vector<QPoint>& bodyCells)
{
for (const QPoint& cell : bodyCells)
{
if (cell.x() >= x0 && cell.x() <= x1
&& cell.y() >= y0 && cell.y() <= y1)
{
return true;
}
}
return false;
};
std::vector<BuildingId> ids;
for (const Building& building : getAllBuildings(state))
{
if (covers(building.bodyCells)) { ids.push_back(building.id); }
}
for (const ConstructionSite& site : getAllSites(state))
{
if (covers(site.bodyCells)) { ids.push_back(site.id); }
}
return ids;
}
TunnelTileMap collectTunnelTiles(const FactoryState& state)
{
// Index every tunnel entry/exit — built or still a construction site — by its
// single-cell tile, so a just-placed tunnel (not yet constructed) is matchable
// (REQ-BLD-TUNNEL-MODE, REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
TunnelTileMap tunnels;
for (const Building& building : getAllBuildings(state))
{
if (building.type == BuildingType::TunnelEntry
|| building.type == BuildingType::TunnelExit)
{
tunnels[building.anchor] = TunnelTileInfo{building.type, building.rotation};
}
}
for (const ConstructionSite& site : getAllSites(state))
{
if (site.type == BuildingType::TunnelEntry
|| site.type == BuildingType::TunnelExit)
{
tunnels[site.anchor] = TunnelTileInfo{site.type, site.rotation};
}
}
return tunnels;
}

View File

@@ -0,0 +1,82 @@
#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"
#include "TunnelCompletion.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);
// Ids of all buildings and construction sites whose footprint intersects the tile
// box spanned by the two (unordered) corner tiles (REQ-UI-MULTI-SELECT,
// REQ-BLD-DECONSTRUCT-BOX).
std::vector<BuildingId> buildingsInBox(const FactoryState& state,
QPoint cornerA, QPoint cornerB);
// Every tunnel entry and exit, built or still a construction site, indexed by its
// single-cell tile. Shared by the placement preview and the selection highlight.
TunnelTileMap collectTunnelTiles(const FactoryState& state);

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,200 @@
#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;
}
bool canPlaceBuilding(const FactoryState& state, const GameConfig& config,
BuildingType type, QPoint anchor, Rotation rotation)
{
// Terrain and world-bounds validity first (REQ-BLD-PLACE-VALID); occupancy is
// the extra rule this adds.
if (!isPlacementValid(state, config, type, anchor, rotation))
{
return false;
}
const BuildingDef* def = config.buildings.findBuildingDef(type);
if (!def) { return false; }
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation);
bool anyOccupied = false;
for (const QPoint& relativeCell : parsed.bodyCells)
{
if (isTileOccupied(state, anchor + relativeCell))
{
anyOccupied = true;
break;
}
}
if (anyOccupied)
{
// Occupied is still placeable when what is there is the same building being
// re-oriented (REQ-BLD-ROTATE-IN-PLACE).
return findRotateInPlaceTarget(state, config, type, anchor, rotation).has_value();
}
return true;
}
std::vector<BeltDragResolved> resolveBeltDragPath(const std::vector<BeltPathTile>& path,
const FactoryState& state,
const GameConfig& config,
int buildingBlocksStock)
{
std::vector<BeltDragResolved> resolved;
resolved.reserve(path.size());
const BuildingDef* def = config.buildings.findBuildingDef(BuildingType::Belt);
const int beltCost = (def != nullptr) ? def->cost : 0;
int spent = 0;
for (const BeltPathTile& entry : path)
{
BeltDragResolved item;
const std::optional<BuildingId> rotateTarget =
findRotateInPlaceTarget(state, config, BuildingType::Belt,
entry.tile, entry.rotation);
if (rotateTarget.has_value())
{
// A tile holding only a belt (or belt site) is re-oriented, no cost.
item.action = BeltTileAction::RotateInPlace;
item.affordable = true;
item.rotateId = rotateTarget;
}
else if (canPlaceBuilding(state, config, BuildingType::Belt,
entry.tile, entry.rotation))
{
// Empty, valid cell: a new belt, subject to cumulative affordability.
item.action = BeltTileAction::PlaceNew;
item.affordable = (spent + beltCost <= buildingBlocksStock);
item.rotateId = std::nullopt;
if (item.affordable) { spent += beltCost; }
}
else
{
// Occupied by a non-belt building/site, or otherwise invalid terrain.
item.action = BeltTileAction::Invalid;
item.affordable = false;
item.rotateId = std::nullopt;
}
resolved.push_back(item);
}
return resolved;
}

View File

@@ -0,0 +1,71 @@
#pragma once
#include <optional>
#include <vector>
#include <QPoint>
#include "BeltDragPath.h"
#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);
// True if placing here would actually do something: the terrain and bounds rules of
// isPlacementValid hold, and the body cells are either all free or occupied only by
// a building this placement would rotate in place. This is the question the ghost
// asks to colour itself and the click path asks before enqueuing a command
// (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID, REQ-BLD-ROTATE-IN-PLACE).
bool canPlaceBuilding(const FactoryState& state, const GameConfig& config,
BuildingType type, QPoint anchor, Rotation rotation);
// What a belt drag would do to one tile of its path (REQ-BLD-BELT-DRAG).
enum class BeltTileAction
{
PlaceNew, // empty, valid cell: a new belt, subject to affordability
RotateInPlace, // already a belt (or belt site): re-oriented, free
Invalid // occupied by something else, or invalid terrain
};
struct BeltDragResolved
{
BeltTileAction action;
bool affordable; // meaningful only for PlaceNew
std::optional<BuildingId> rotateId; // set only for RotateInPlace
};
// Classifies every tile of a belt drag path against the current factory state,
// spending `buildingBlocksStock` cumulatively across the PlaceNew tiles so a path
// longer than the player can afford is only partly buildable (REQ-BLD-BELT-DRAG).
// Shared so the previewed ghosts and the placement on release cannot disagree.
std::vector<BeltDragResolved> resolveBeltDragPath(const std::vector<BeltPathTile>& path,
const FactoryState& state,
const GameConfig& config,
int buildingBlocksStock);

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

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