Compare commits
45 Commits
master
...
3272431353
| Author | SHA1 | Date | |
|---|---|---|---|
| 3272431353 | |||
| d1b688f45e | |||
| df7f60c898 | |||
| 58e173ad5b | |||
| bb50f527d6 | |||
| 7540c21d5c | |||
| 2522a8c974 | |||
| e39b81eb22 | |||
| 71d0dad3f2 | |||
| fda88fe75c | |||
| d713257fb5 | |||
| 0029236135 | |||
| 622447c45b | |||
| a34d66f548 | |||
| cb5572ffdd | |||
| 10ba226af7 | |||
| 0a3288d1d1 | |||
| 2c9433cea6 | |||
| 21eb6ad096 | |||
| bf579bb76e | |||
| 6bde69dc33 | |||
| bf99cd0694 | |||
| 6f107e3479 | |||
| 8f42519911 | |||
| bb7b90f9ea | |||
| e702c01005 | |||
| b1720dc2b3 | |||
| 099f0b55fc | |||
| b133a21914 | |||
| 69848eb9f8 | |||
| 993325d97c | |||
| 75f306f650 | |||
| 0eb9c97e5d | |||
| 28d0416458 | |||
| 59fde8dbbc | |||
| 5355f9f77d | |||
| f678dab387 | |||
| ebee62166d | |||
| 3c549a160c | |||
| dc58f6ea32 | |||
| 5bd804601c | |||
| 92a4f02cef | |||
| 84d32b6c16 | |||
| d31ff68ab7 | |||
| b722955f7e |
@@ -31,7 +31,14 @@ keep the citation accurate.
|
||||
## Coding Guidelines
|
||||
|
||||
* avoid duplicate code
|
||||
* do not use the "auto" keyword
|
||||
* do not use the "auto" keyword, with two exceptions:
|
||||
* **named local lambdas** — a lambda's type is unnameable, and `std::function`
|
||||
is not an acceptable substitute in per-tick code because it adds a heap
|
||||
allocation and an indirect call
|
||||
* **iterator types** — `auto it = m_buildings.find(id)` is allowed where
|
||||
spelling the iterator out adds length without adding information
|
||||
* everywhere else the type is written out; in particular `auto` is not used
|
||||
for plain values, return values, or range-for element types
|
||||
* use Qt utility data types (like QPoint, QVector3D, QString, etc.)
|
||||
* wrap strings that appear in the UI with Qt's "tr()"
|
||||
* use the EventManager/EventHandler instead of defining own signals and slots
|
||||
|
||||
@@ -136,17 +136,43 @@ Belts and splitters are their own specialized subsystem. Belt items are **not**
|
||||
|
||||
### 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
|
||||
class BeltSystem {
|
||||
public:
|
||||
bool tryPutItem(Port port, Item item);
|
||||
std::optional<Item> tryTakeItem(Port port);
|
||||
// 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<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 tick();
|
||||
|
||||
// Rendering
|
||||
void forEachVisualItem(QRect viewportTiles,
|
||||
std::function<void(VisualItem)> visit) const;
|
||||
|
||||
// Determinism (docs/replay_design.md)
|
||||
void appendChecksum(Hasher& hasher) const;
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
|
||||
{
|
||||
m_buildingSystem = std::make_unique<BuildingSystem>(
|
||||
m_gameConfig,
|
||||
m_factoryState,
|
||||
m_beltSystem,
|
||||
[this]() { return allocateBuildingId(); },
|
||||
[](int) {},
|
||||
@@ -322,13 +323,13 @@ void ArenaSimulation::tick()
|
||||
// Ship behavior systems (tick step 7): evaluate, select winner, execute.
|
||||
// Module + combat systems emit their tool beams into a shared buffer.
|
||||
m_shipSystem->clearMovementIntents();
|
||||
m_aiSystem->tick(m_admin, *m_buildingSystem, *m_debrisSystem);
|
||||
m_aiSystem->tick(m_admin, m_factoryState, *m_debrisSystem);
|
||||
std::vector<BeamFiredEvent> beamFiredEvents;
|
||||
m_salvagerSystem->tick(m_currentTick, *m_debrisSystem, *m_buildingSystem, beamFiredEvents);
|
||||
m_salvagerSystem->tick(m_currentTick, *m_debrisSystem, m_factoryState, beamFiredEvents);
|
||||
m_repairSystem->tick(m_currentTick, beamFiredEvents);
|
||||
|
||||
// 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_combatSystem->applyPendingDamage(m_currentTick, m_admin);
|
||||
|
||||
@@ -487,6 +488,11 @@ const ArenaConfig& ArenaSimulation::getArenaConfig() const
|
||||
return m_arenaConfig;
|
||||
}
|
||||
|
||||
const FactoryState& ArenaSimulation::getFactoryState() const
|
||||
{
|
||||
return m_factoryState;
|
||||
}
|
||||
|
||||
const BuildingSystem& ArenaSimulation::getBuildings() const
|
||||
{
|
||||
return *m_buildingSystem;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include "BalancingConfig.h"
|
||||
#include "BeltSystem.h"
|
||||
#include "FactoryState.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "BuildingId.h"
|
||||
|
||||
@@ -85,6 +86,7 @@ public:
|
||||
|
||||
const ArenaConfig& getArenaConfig() const;
|
||||
const BuildingSystem& getBuildings() const;
|
||||
const FactoryState& getFactoryState() const;
|
||||
const ShipSystem& getShips() const;
|
||||
const DebrisSystem& getDebrisSystem() const;
|
||||
EntityAdmin& getAdmin();
|
||||
@@ -107,6 +109,7 @@ private:
|
||||
BuildingId m_nextBuildingId;
|
||||
|
||||
EntityAdmin m_admin;
|
||||
FactoryState m_factoryState;
|
||||
BeltSystem m_beltSystem;
|
||||
std::unique_ptr<BuildingSystem> m_buildingSystem;
|
||||
std::unique_ptr<ShipSystem> m_shipSystem;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "ArenaView.h"
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
@@ -306,7 +307,7 @@ void ArenaView::drawTiles(QPainter& painter)
|
||||
|
||||
void ArenaView::drawBuildings(QPainter& painter)
|
||||
{
|
||||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||||
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
||||
{
|
||||
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
|
||||
m_visuals->buildings.find(b.type);
|
||||
|
||||
@@ -13,6 +13,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprintSerializer.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TomlHelpers.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -20,9 +21,17 @@ SET(SRCS
|
||||
${SRCS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Formula.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}/BlueprintSerializer.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprintSerializer.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TomlHelpers.cpp
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
|
||||
@@ -1,779 +1,10 @@
|
||||
#include "ConfigLoader.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#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;
|
||||
}
|
||||
#include "TomlHelpers.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -815,11 +46,11 @@ void validateUnlocks(const GameConfig& cfg)
|
||||
{
|
||||
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)
|
||||
{
|
||||
throw makeError(file, gPath,
|
||||
throw utility::makeError(file, gPath,
|
||||
"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 + "'";
|
||||
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()
|
||||
&& 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);
|
||||
@@ -852,7 +83,7 @@ void validateUnlocks(const GameConfig& cfg)
|
||||
{
|
||||
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 + "'");
|
||||
}
|
||||
}
|
||||
|
||||
58
src/lib/config/ConfigLoaderBuildings.cpp
Normal file
58
src/lib/config/ConfigLoaderBuildings.cpp
Normal 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;
|
||||
}
|
||||
182
src/lib/config/ConfigLoaderModules.cpp
Normal file
182
src/lib/config/ConfigLoaderModules.cpp
Normal 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;
|
||||
}
|
||||
109
src/lib/config/ConfigLoaderRecipes.cpp
Normal file
109
src/lib/config/ConfigLoaderRecipes.cpp
Normal 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;
|
||||
}
|
||||
133
src/lib/config/ConfigLoaderShips.cpp
Normal file
133
src/lib/config/ConfigLoaderShips.cpp
Normal 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;
|
||||
}
|
||||
47
src/lib/config/ConfigLoaderStations.cpp
Normal file
47
src/lib/config/ConfigLoaderStations.cpp
Normal 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;
|
||||
}
|
||||
62
src/lib/config/ConfigLoaderUnlocks.cpp
Normal file
62
src/lib/config/ConfigLoaderUnlocks.cpp
Normal 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;
|
||||
}
|
||||
79
src/lib/config/ConfigLoaderWorld.cpp
Normal file
79
src/lib/config/ConfigLoaderWorld.cpp
Normal 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;
|
||||
}
|
||||
@@ -59,4 +59,18 @@ struct ModuleDef
|
||||
struct ModulesConfig
|
||||
{
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -47,4 +47,32 @@ struct RecipeDef
|
||||
struct RecipesConfig
|
||||
{
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,4 +49,18 @@ struct ShipDef
|
||||
struct ShipsConfig
|
||||
{
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
172
src/lib/config/TomlHelpers.cpp
Normal file
172
src/lib/config/TomlHelpers.cpp
Normal 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
|
||||
70
src/lib/config/TomlHelpers.h
Normal file
70
src/lib/config/TomlHelpers.h
Normal 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
|
||||
@@ -38,3 +38,32 @@ std::string buildingTypeId(BuildingType type)
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,3 +29,17 @@ std::optional<BuildingType> parseBuildingType(const std::string& id);
|
||||
|
||||
// Canonical id string for a BuildingType. The inverse of parseBuildingType.
|
||||
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);
|
||||
|
||||
@@ -9,6 +9,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Item.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Port.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/PortGeometry.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceOption.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.h
|
||||
|
||||
@@ -45,11 +45,11 @@ entt::entity EntityAdmin::spawnShip(QVector2D position, float hp, float maxHp,
|
||||
const std::string& schematicId, bool isEnemy)
|
||||
{
|
||||
entt::entity entity = createEntity();
|
||||
add<PositionComponent>(entity, PositionComponent{position});
|
||||
add<HealthComponent>(entity, HealthComponent{hp, maxHp});
|
||||
add<FactionComponent>(entity, FactionComponent{isEnemy});
|
||||
add<FacingComponent>(entity, FacingComponent{0.0f});
|
||||
add<DynamicBodyComponent>(entity, DynamicBodyComponent{
|
||||
addComponent<PositionComponent>(entity, PositionComponent{position});
|
||||
addComponent<HealthComponent>(entity, HealthComponent{hp, maxHp});
|
||||
addComponent<FactionComponent>(entity, FactionComponent{isEnemy});
|
||||
addComponent<FacingComponent>(entity, FacingComponent{0.0f});
|
||||
addComponent<DynamicBodyComponent>(entity, DynamicBodyComponent{
|
||||
maxSpeed_tpt,
|
||||
mainAcceleration_tptt,
|
||||
maneuveringAcceleration_tptt,
|
||||
@@ -60,9 +60,9 @@ entt::entity EntityAdmin::spawnShip(QVector2D position, float hp, float maxHp,
|
||||
QVector2D(0.0f, 0.0f), // linearAcceleration_tptt
|
||||
0.0f // angularAcceleration_rptt
|
||||
});
|
||||
add<SensorRangeComponent>(entity, SensorRangeComponent{sensorRange_tiles});
|
||||
add<ShipIdentityComponent>(entity, ShipIdentityComponent{schematicId});
|
||||
add<MovementIntentComponent>(entity, MovementIntentComponent{0, QVector2D(0.0f, 0.0f)});
|
||||
addComponent<SensorRangeComponent>(entity, SensorRangeComponent{sensorRange_tiles});
|
||||
addComponent<ShipIdentityComponent>(entity, ShipIdentityComponent{schematicId});
|
||||
addComponent<MovementIntentComponent>(entity, MovementIntentComponent{0, QVector2D(0.0f, 0.0f)});
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -73,28 +73,28 @@ entt::entity EntityAdmin::spawnStation(QPoint anchor, QSize footprint,
|
||||
entt::entity entity = createEntity();
|
||||
QVector2D center(anchor.x() + footprint.width() / 2.0f,
|
||||
anchor.y() + footprint.height() / 2.0f);
|
||||
add<PositionComponent>(entity, PositionComponent{center});
|
||||
add<HealthComponent>(entity, HealthComponent{hp, maxHp});
|
||||
add<FactionComponent>(entity, FactionComponent{isEnemy});
|
||||
add<StationBodyComponent>(entity, StationBodyComponent{anchor, footprint, bodyCells});
|
||||
addComponent<PositionComponent>(entity, PositionComponent{center});
|
||||
addComponent<HealthComponent>(entity, HealthComponent{hp, maxHp});
|
||||
addComponent<FactionComponent>(entity, FactionComponent{isEnemy});
|
||||
addComponent<StationBodyComponent>(entity, StationBodyComponent{anchor, footprint, bodyCells});
|
||||
return entity;
|
||||
}
|
||||
|
||||
entt::entity EntityAdmin::spawnDebris(QVector2D position, int amount, Tick despawnAt)
|
||||
{
|
||||
entt::entity entity = createEntity();
|
||||
add<PositionComponent>(entity, PositionComponent{position});
|
||||
add<DebrisComponent>(entity, DebrisComponent{amount});
|
||||
add<DespawnAtComponent>(entity, DespawnAtComponent{despawnAt});
|
||||
addComponent<PositionComponent>(entity, PositionComponent{position});
|
||||
addComponent<DebrisComponent>(entity, DebrisComponent{amount});
|
||||
addComponent<DespawnAtComponent>(entity, DespawnAtComponent{despawnAt});
|
||||
return entity;
|
||||
}
|
||||
|
||||
entt::entity EntityAdmin::spawnHqProxy(QVector2D position, float hp, float maxHp)
|
||||
{
|
||||
entt::entity entity = createEntity();
|
||||
add<PositionComponent>(entity, PositionComponent{position});
|
||||
add<HealthComponent>(entity, HealthComponent{hp, maxHp});
|
||||
add<FactionComponent>(entity, FactionComponent{false});
|
||||
add<HqProxyComponent>(entity);
|
||||
addComponent<PositionComponent>(entity, PositionComponent{position});
|
||||
addComponent<HealthComponent>(entity, HealthComponent{hp, maxHp});
|
||||
addComponent<FactionComponent>(entity, FactionComponent{false});
|
||||
addComponent<HqProxyComponent>(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -73,9 +73,6 @@ public:
|
||||
private:
|
||||
entt::entity createEntity();
|
||||
|
||||
template <typename T, typename... Args>
|
||||
void add(entt::entity entity, Args&&... args);
|
||||
|
||||
entt::registry m_registry;
|
||||
};
|
||||
|
||||
@@ -133,10 +130,4 @@ void EntityAdmin::removeComponent(entt::entity 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
|
||||
|
||||
45
src/lib/core/PortGeometry.h
Normal file
45
src/lib/core/PortGeometry.h
Normal file
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "AiSystem.h"
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <limits>
|
||||
|
||||
@@ -42,7 +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();
|
||||
@@ -55,7 +56,7 @@ void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
|
||||
m_attackEvaluator.evaluate(admin);
|
||||
m_repairEvaluator.evaluate(admin);
|
||||
m_salvageScrapEvaluator.evaluate(admin, debris);
|
||||
m_deliverScrapEvaluator.evaluate(admin, buildings);
|
||||
m_deliverScrapEvaluator.evaluate(admin, state);
|
||||
|
||||
// Phase 2: pick the highest-scoring behavior per ship.
|
||||
selectWinningBehaviors(admin);
|
||||
@@ -68,7 +69,7 @@ void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
|
||||
m_attackExecutor.execute(admin);
|
||||
m_repairExecutor.execute(admin);
|
||||
m_salvageScrapExecutor.execute(admin);
|
||||
m_deliverScrapExecutor.execute(admin, buildings);
|
||||
m_deliverScrapExecutor.execute(admin, state);
|
||||
}
|
||||
|
||||
void AiSystem::selectWinningBehaviors(EntityAdmin& admin)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include "AdvanceEvaluator.h"
|
||||
#include "AdvanceExecutor.h"
|
||||
#include "AttackEvaluator.h"
|
||||
@@ -17,7 +19,6 @@
|
||||
#include "StandbyEvaluator.h"
|
||||
#include "StandbyExecutor.h"
|
||||
|
||||
class BuildingSystem;
|
||||
class EntityAdmin;
|
||||
class DebrisSystem;
|
||||
struct GameConfig;
|
||||
@@ -34,7 +35,7 @@ class AiSystem
|
||||
public:
|
||||
explicit AiSystem(const GameConfig& config);
|
||||
|
||||
void tick(EntityAdmin& admin, const BuildingSystem& buildings, const DebrisSystem& debris);
|
||||
void tick(EntityAdmin& admin, const FactoryState& state, const DebrisSystem& debris);
|
||||
|
||||
private:
|
||||
void selectWinningBehaviors(EntityAdmin& admin);
|
||||
|
||||
@@ -5,8 +5,10 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackEvaluator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackExecutor.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/DeliverScrapExecutor.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/OrbitAndAssignExecutor.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyEvaluator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyExecutor.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairEvaluator.h
|
||||
|
||||
@@ -17,7 +17,6 @@ CombatSystem::CombatSystem(const GameConfig& config)
|
||||
|
||||
void CombatSystem::tick(Tick currentTick,
|
||||
EntityAdmin& admin,
|
||||
BuildingSystem& /*buildings*/,
|
||||
std::vector<BeamFiredEvent>& outBeamFiredEvents)
|
||||
{
|
||||
TRACE();
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
|
||||
class BuildingSystem;
|
||||
class EntityAdmin;
|
||||
|
||||
class CombatSystem
|
||||
@@ -25,7 +24,6 @@ public:
|
||||
|
||||
void tick(Tick currentTick,
|
||||
EntityAdmin& admin,
|
||||
BuildingSystem& buildings,
|
||||
std::vector<BeamFiredEvent>& outBeamFiredEvents);
|
||||
|
||||
void applyPendingDamage(Tick currentTick, EntityAdmin& admin);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "SalvagerSystem.h"
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
@@ -23,7 +24,7 @@ SalvagerSystem::SalvagerSystem(EntityAdmin& admin)
|
||||
{
|
||||
}
|
||||
|
||||
void SalvagerSystem::tick(Tick currentTick, DebrisSystem& debris, BuildingSystem& buildings,
|
||||
void SalvagerSystem::tick(Tick currentTick, DebrisSystem& debris, FactoryState& state,
|
||||
std::vector<BeamFiredEvent>& outBeamFiredEvents)
|
||||
{
|
||||
TRACE();
|
||||
@@ -89,7 +90,7 @@ void SalvagerSystem::tick(Tick currentTick, DebrisSystem& debris, BuildingSystem
|
||||
[&](entt::entity ship, const DeliverScrapBehavior& deliver, const PositionComponent& pos)
|
||||
{
|
||||
if (!deliver.deliveryBay.has_value()) { return; }
|
||||
const Building* bay = buildings.findBuilding(*deliver.deliveryBay);
|
||||
const Building* bay = findBuilding(state, *deliver.deliveryBay);
|
||||
if (!bay) { return; }
|
||||
|
||||
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
||||
@@ -100,7 +101,7 @@ void SalvagerSystem::tick(Tick currentTick, DebrisSystem& debris, BuildingSystem
|
||||
if (!m_admin.hasAll<CargoComponent>(ship)) { return; }
|
||||
CargoComponent& cargo = m_admin.get<CargoComponent>(ship);
|
||||
if (cargo.current <= 0) { return; }
|
||||
if (buildings.deliverScrapToSalvageBay(*deliver.deliveryBay))
|
||||
if (deliverScrapToSalvageBay(state, *deliver.deliveryBay))
|
||||
{
|
||||
--cargo.current;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "BeamFiredEvent.h"
|
||||
@@ -7,7 +9,6 @@
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
|
||||
class BuildingSystem;
|
||||
class EntityAdmin;
|
||||
class DebrisSystem;
|
||||
|
||||
@@ -21,7 +22,7 @@ class SalvagerSystem
|
||||
public:
|
||||
explicit SalvagerSystem(EntityAdmin& admin);
|
||||
|
||||
void tick(Tick currentTick, DebrisSystem& debris, BuildingSystem& buildings,
|
||||
void tick(Tick currentTick, DebrisSystem& debris, FactoryState& state,
|
||||
std::vector<BeamFiredEvent>& outBeamFiredEvents);
|
||||
|
||||
private:
|
||||
|
||||
@@ -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,
|
||||
QVector2D position, bool isEnemy,
|
||||
const std::optional<ShipLayoutConfig>& layout)
|
||||
{
|
||||
const ShipDef* def = findShipDef(schematicId);
|
||||
const ShipDef* def = m_config.ships.findShipDef(schematicId);
|
||||
assert(def != nullptr);
|
||||
|
||||
const float tickRate = static_cast<float>(kTickRateHz);
|
||||
@@ -116,7 +92,7 @@ entt::entity ShipSystem::spawn(const std::string& schematicId,
|
||||
|
||||
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->weaponCapability)
|
||||
@@ -184,7 +160,7 @@ entt::entity ShipSystem::spawn(const std::string& schematicId,
|
||||
|
||||
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 + "'"); }
|
||||
|
||||
for (const ModuleStatModifier& sm : modDef->statModifiers)
|
||||
|
||||
@@ -38,9 +38,6 @@ public:
|
||||
void setRetreatEnabled(bool enabled);
|
||||
|
||||
private:
|
||||
const ShipDef* findShipDef(const std::string& schematicId) const;
|
||||
const ModuleDef* findModuleDef(const std::string& id) const;
|
||||
|
||||
const GameConfig& m_config;
|
||||
EntityAdmin& m_admin;
|
||||
QVector2D m_rallyPoint;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include "AdvanceBehavior.h"
|
||||
#include "BehaviorKind.h"
|
||||
#include "Centroid.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "HealthComponent.h"
|
||||
@@ -16,28 +17,6 @@
|
||||
#include "StationBodyComponent.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)
|
||||
{
|
||||
TRACE();
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
|
||||
#include "AttackBehavior.h"
|
||||
#include "BehaviorKind.h"
|
||||
#include "DynamicBodyComponent.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "MovementIntentComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "OrbitAndAssignExecutor.h"
|
||||
#include "tracing.h"
|
||||
#include "WeaponComponent.h"
|
||||
|
||||
@@ -15,55 +11,7 @@ void AttackExecutor::execute(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
|
||||
// Ships: move toward the behavior target.
|
||||
admin.forEach<AttackBehavior, SelectedBehaviorComponent, PositionComponent,
|
||||
MovementIntentComponent>(
|
||||
[&](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;
|
||||
}
|
||||
});
|
||||
// Orbit the attack target and hand it to every weapon that can reach it
|
||||
// (REQ-SHP-ORBIT).
|
||||
executeOrbitAndAssign<AttackBehavior, WeaponComponent>(admin, BehaviorKind::Attack);
|
||||
}
|
||||
|
||||
26
src/lib/ecs/system/ai/Centroid.h
Normal file
26
src/lib/ecs/system/ai/Centroid.h
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "DeliverScrapEvaluator.h"
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <unordered_map>
|
||||
|
||||
@@ -12,7 +13,7 @@
|
||||
#include "PositionComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void DeliverScrapEvaluator::evaluate(EntityAdmin& admin, const BuildingSystem& buildings)
|
||||
void DeliverScrapEvaluator::evaluate(EntityAdmin& admin, const FactoryState& state)
|
||||
{
|
||||
TRACE();
|
||||
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())
|
||||
{
|
||||
const Building* bay =
|
||||
buildings.findNearestBuilding(pos.value, BuildingType::SalvageBay);
|
||||
findNearestBuilding(state, pos.value, BuildingType::SalvageBay);
|
||||
if (bay) { deliver.deliveryBay = bay->id; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
class EntityAdmin;
|
||||
class BuildingSystem;
|
||||
|
||||
// Scores high only when the ship's cargo is full, and assigns the nearest
|
||||
// SalvageBay as the delivery destination.
|
||||
class DeliverScrapEvaluator
|
||||
{
|
||||
public:
|
||||
void evaluate(EntityAdmin& admin, const BuildingSystem& buildings);
|
||||
void evaluate(EntityAdmin& admin, const FactoryState& state);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "DeliverScrapExecutor.h"
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
@@ -12,7 +13,7 @@
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void DeliverScrapExecutor::execute(EntityAdmin& admin, const BuildingSystem& buildings)
|
||||
void DeliverScrapExecutor::execute(EntityAdmin& admin, const FactoryState& state)
|
||||
{
|
||||
TRACE();
|
||||
admin.forEach<DeliverScrapBehavior, SelectedBehaviorComponent, PositionComponent,
|
||||
@@ -26,7 +27,7 @@ void DeliverScrapExecutor::execute(EntityAdmin& admin, const BuildingSystem& bui
|
||||
QVector2D dest = pos.value;
|
||||
if (deliver.deliveryBay.has_value())
|
||||
{
|
||||
const Building* bay = buildings.findBuilding(*deliver.deliveryBay);
|
||||
const Building* bay = findBuilding(state, *deliver.deliveryBay);
|
||||
if (bay)
|
||||
{
|
||||
dest = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
class EntityAdmin;
|
||||
class BuildingSystem;
|
||||
|
||||
// Moves a ship toward its delivery bay when DeliverScrap is the winning
|
||||
// behavior. Never decrements cargo — SalvagerSystem performs the delivery.
|
||||
class DeliverScrapExecutor
|
||||
{
|
||||
public:
|
||||
void execute(EntityAdmin& admin, const BuildingSystem& buildings);
|
||||
void execute(EntityAdmin& admin, const FactoryState& state);
|
||||
};
|
||||
|
||||
89
src/lib/ecs/system/ai/OrbitAndAssignExecutor.h
Normal file
89
src/lib/ecs/system/ai/OrbitAndAssignExecutor.h
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,69 +1,17 @@
|
||||
#include "RepairExecutor.h"
|
||||
|
||||
#include "BehaviorKind.h"
|
||||
#include "DynamicBodyComponent.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "MovementIntentComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "OrbitAndAssignExecutor.h"
|
||||
#include "RepairBehavior.h"
|
||||
#include "RepairToolComponent.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void RepairExecutor::execute(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
|
||||
// Ships: move toward the repair target.
|
||||
admin.forEach<RepairBehavior, SelectedBehaviorComponent, PositionComponent,
|
||||
MovementIntentComponent>(
|
||||
[&](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;
|
||||
}
|
||||
});
|
||||
// Orbit the repair target and hand it to every repair tool that can reach it
|
||||
// (REQ-SHP-ORBIT).
|
||||
executeOrbitAndAssign<RepairBehavior, RepairToolComponent>(admin, BehaviorKind::Repair);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <QVector2D>
|
||||
|
||||
#include "BehaviorKind.h"
|
||||
#include "Centroid.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "HealthComponent.h"
|
||||
@@ -16,28 +17,6 @@
|
||||
#include "StationBodyComponent.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)
|
||||
{
|
||||
TRACE();
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
#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
|
||||
{
|
||||
public:
|
||||
ArtifactCountChangedEvent(int count, int winCount) : count(count), winCount(winCount) {}
|
||||
const int count;
|
||||
const int winCount;
|
||||
};
|
||||
|
||||
@@ -2,16 +2,11 @@
|
||||
#define BOSS_WAVE_UPDATED_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
|
||||
{
|
||||
public:
|
||||
BossWaveUpdatedEvent(int counter, Tick countdownTicks)
|
||||
: counter(counter), countdownTicks(countdownTicks) {}
|
||||
const int counter;
|
||||
const Tick countdownTicks;
|
||||
};
|
||||
|
||||
#endif // BOSS_WAVE_UPDATED_EVENT_H
|
||||
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
|
||||
#include "Event.h"
|
||||
|
||||
// Fired when the building block stock changes. Carries no payload — subscribers
|
||||
// re-read Simulation::getBuildingBlocksStock().
|
||||
class BuildingBlocksChangedEvent : public Event
|
||||
{
|
||||
public:
|
||||
explicit BuildingBlocksChangedEvent(int blocks) : blocks(blocks) {}
|
||||
const int blocks;
|
||||
};
|
||||
|
||||
#endif // BUILDING_BLOCKS_CHANGED_EVENT_H
|
||||
|
||||
|
||||
@@ -4,14 +4,11 @@
|
||||
#include "Event.h"
|
||||
|
||||
// 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
|
||||
// building blocks so the header Expand button can update its caption/enabled
|
||||
// state (REQ-UI-EXPAND-BUTTON).
|
||||
// startup and again after each expansion is purchased. Carries no payload — the
|
||||
// header Expand button re-reads Simulation::getCurrentExpansionCost() to update
|
||||
// its caption and enabled state (REQ-UI-EXPAND-BUTTON).
|
||||
class ExpansionCostChangedEvent : public Event
|
||||
{
|
||||
public:
|
||||
explicit ExpansionCostChangedEvent(int cost) : cost(cost) {}
|
||||
const int cost;
|
||||
};
|
||||
|
||||
#endif // EXPANSION_COST_CHANGED_EVENT_H
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
#define TICK_ADVANCED_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
|
||||
{
|
||||
public:
|
||||
explicit TickAdvancedEvent(Tick tick) : tick(tick) {}
|
||||
const Tick tick;
|
||||
};
|
||||
|
||||
#endif // TICK_ADVANCED_EVENT_H
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "BuildingConfig.h"
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
@@ -26,8 +27,8 @@ struct SelectedBuilding
|
||||
// (the HQ and defence stations, per REQ-UI-BLUEPRINT-CREATE).
|
||||
std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, BuildingId id)
|
||||
{
|
||||
const Building* building = sim.getBuildings().findBuilding(id);
|
||||
const ConstructionSite* site = building ? nullptr : sim.getBuildings().findSite(id);
|
||||
const Building* building = findBuilding(sim.getFactoryState(), id);
|
||||
const ConstructionSite* site = building ? nullptr : findSite(sim.getFactoryState(), id);
|
||||
if (!building && !site)
|
||||
{
|
||||
return std::nullopt;
|
||||
@@ -52,8 +53,8 @@ std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, Building
|
||||
|
||||
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id)
|
||||
{
|
||||
const Building* building = sim.getBuildings().findBuilding(id);
|
||||
const ConstructionSite* site = building ? nullptr : sim.getBuildings().findSite(id);
|
||||
const Building* building = findBuilding(sim.getFactoryState(), id);
|
||||
const ConstructionSite* site = building ? nullptr : findSite(sim.getFactoryState(), id);
|
||||
if (!building && !site)
|
||||
{
|
||||
return std::nullopt;
|
||||
|
||||
52
src/lib/sim/BuildingGrid.cpp
Normal file
52
src/lib/sim/BuildingGrid.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
46
src/lib/sim/BuildingGrid.h
Normal file
46
src/lib/sim/BuildingGrid.h
Normal 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
@@ -15,6 +15,8 @@
|
||||
|
||||
#include "BeltSystem.h"
|
||||
#include "Building.h"
|
||||
#include "FactoryState.h"
|
||||
#include "ProductionRules.h"
|
||||
#include "BuildingType.h"
|
||||
#include "BuildingId.h"
|
||||
#include "GameConfig.h"
|
||||
@@ -26,18 +28,6 @@
|
||||
|
||||
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
|
||||
// production loop (belt→building pull, production, building→belt push).
|
||||
// All types including Belt and Splitter are stored as Building instances;
|
||||
@@ -46,6 +36,7 @@ class BuildingSystem
|
||||
{
|
||||
public:
|
||||
BuildingSystem(const GameConfig& config,
|
||||
FactoryState& state,
|
||||
BeltSystem& belts,
|
||||
std::function<BuildingId()> allocateBuildingId,
|
||||
std::function<void(int)> addBuildingBlocks,
|
||||
@@ -75,7 +66,7 @@ public:
|
||||
// Sets the current buildable asteroid width in tiles. Grows the left
|
||||
// placement bound as the player unlocks asteroid expansions (REQ-EXP-UNLOCK).
|
||||
// Defaults to world.regions.asteroid_width_tiles at construction.
|
||||
void setAsteroidWidth_tiles(int widthTiles) { m_asteroidWidth_tiles = widthTiles; }
|
||||
void setAsteroidWidth_tiles(int widthTiles) { m_state.asteroidWidth_tiles = widthTiles; }
|
||||
|
||||
// Mark a building or construction site for demolition (REQ-BLD-DECONSTRUCT).
|
||||
// A construction site is removed instantly and the full cost is returned.
|
||||
@@ -92,7 +83,6 @@ public:
|
||||
void cancelDeconstruction(BuildingId id);
|
||||
|
||||
// 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
|
||||
// construction site. Clears both buffers on an operational building.
|
||||
@@ -138,26 +128,18 @@ public:
|
||||
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
|
||||
// (operational) Miner/Smelter/Assembler/ReprocessingPlant/Shipyard buildings.
|
||||
int getProductionBuildingCount() const;
|
||||
|
||||
// REQ-UI-DEBUG-OVERLAY "Current Factory Production": subset of the above
|
||||
// that currently has an active production cycle.
|
||||
int getActiveProductionBuildingCount() const;
|
||||
|
||||
// Production state for the UI status light (REQ-UI-STATUS-LIGHT). Returns
|
||||
// nullopt for building types that show no light (belts, splitters, tunnels,
|
||||
// HQ, defence stations). The Salvage Bay is a two-state special case:
|
||||
// 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
|
||||
// virtual output belt (REQ-MAT-OUTPUT-EMERGE), passing the item type and its
|
||||
@@ -184,8 +166,6 @@ public:
|
||||
// currently on the tile are discarded by BeltSystem::removeTile).
|
||||
void rotateInPlace(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
|
||||
// (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS): each returned Port.tile is the
|
||||
@@ -199,7 +179,6 @@ public:
|
||||
|
||||
// Place one "scrap" item into a SalvageBay's output buffer.
|
||||
// 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
|
||||
// immediately. Used for pre-placed structures (HQ, defence stations).
|
||||
@@ -232,7 +211,6 @@ private:
|
||||
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:
|
||||
// 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).
|
||||
@@ -255,22 +233,13 @@ private:
|
||||
// building (Smelter, Reprocessing Plant) offers every recipe of its type with
|
||||
// inputs; other buildings offer only their selected recipe. Shared by
|
||||
// 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
|
||||
// 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).
|
||||
std::map<std::string, int> computeShipyardRequiredMaterials(const Building& b) const;
|
||||
// True if the building currently has all inputs/materials to start a cycle
|
||||
// (ignoring output-buffer space); drives the Starved/Blocked distinction of
|
||||
// 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
|
||||
// caps span the union of every recipe of the building's type; no player
|
||||
@@ -288,6 +257,11 @@ private:
|
||||
QPoint anchor) const;
|
||||
|
||||
const GameConfig& m_config;
|
||||
|
||||
// The factory's world data — buildings, queued work, tile ownership. Owned by
|
||||
// Simulation, not by this system (see FactoryState.h).
|
||||
FactoryState& m_state;
|
||||
|
||||
BeltSystem& m_belts;
|
||||
std::function<BuildingId()> m_allocateBuildingId;
|
||||
std::function<void(int)> m_addBuildingBlocks;
|
||||
@@ -295,24 +269,4 @@ private:
|
||||
const std::optional<ShipLayoutConfig>&)> m_spawnShip;
|
||||
std::function<bool(const std::string&)> m_isItemUnlocked;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,10 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Building.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/FactoryState.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
|
||||
@@ -19,6 +23,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/UnlockState.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
@@ -35,11 +40,15 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/UnlockState.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
128
src/lib/sim/FactoryQueries.cpp
Normal file
128
src/lib/sim/FactoryQueries.cpp
Normal file
@@ -0,0 +1,128 @@
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <limits>
|
||||
|
||||
#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;
|
||||
}
|
||||
54
src/lib/sim/FactoryQueries.h
Normal file
54
src/lib/sim/FactoryQueries.h
Normal file
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
#include <QVector2D>
|
||||
|
||||
#include "Building.h"
|
||||
#include "BuildingId.h"
|
||||
#include "BuildingType.h"
|
||||
#include "FactoryState.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).
|
||||
//
|
||||
// A helper belongs here only if it is a pure function of FactoryState. Anything
|
||||
// needing GameConfig or the asteroid bound stays on BuildingSystem for now.
|
||||
|
||||
// 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);
|
||||
54
src/lib/sim/FactoryState.h
Normal file
54
src/lib/sim/FactoryState.h
Normal file
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <deque>
|
||||
#include <vector>
|
||||
|
||||
#include "Building.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;
|
||||
};
|
||||
142
src/lib/sim/ProductionRules.cpp
Normal file
142
src/lib/sim/ProductionRules.cpp
Normal 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;
|
||||
}
|
||||
47
src/lib/sim/ProductionRules.h
Normal file
47
src/lib/sim/ProductionRules.h
Normal 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);
|
||||
@@ -21,22 +21,9 @@ ShipStats calculateShipStats(const GameConfig& config,
|
||||
{
|
||||
ShipStats result{};
|
||||
|
||||
const ShipDef* shipDef = nullptr;
|
||||
for (const ShipDef& d : config.ships.ships)
|
||||
{
|
||||
if (d.id == shipId) { shipDef = &d; break; }
|
||||
}
|
||||
const ShipDef* shipDef = config.ships.findShipDef(shipId);
|
||||
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;
|
||||
|
||||
// --- Base hull stats (convert from SI to display units) ------------------
|
||||
@@ -67,7 +54,7 @@ ShipStats calculateShipStats(const GameConfig& config,
|
||||
|
||||
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->weaponCapability)
|
||||
@@ -107,7 +94,7 @@ ShipStats calculateShipStats(const GameConfig& config,
|
||||
|
||||
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 + "'"); }
|
||||
|
||||
for (const ModuleStatModifier& sm : def->statModifiers)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
#include "Simulation.h"
|
||||
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
|
||||
#include "AiSystem.h"
|
||||
#include "Command.h"
|
||||
#include "DisplayName.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "CombatSystem.h"
|
||||
#include "DynamicBodyComponent.h"
|
||||
@@ -43,39 +44,15 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
|
||||
, m_hqProxyEntity(entt::null)
|
||||
, m_playerStation1Entity(entt::null)
|
||||
, m_playerStation2Entity(entt::null)
|
||||
, m_unlockState(m_config)
|
||||
, m_beltSystem(m_config.world.beltSpeed_tps)
|
||||
{
|
||||
m_currentEnemyStationEntities[0] = entt::null;
|
||||
m_currentEnemyStationEntities[1] = entt::null;
|
||||
|
||||
m_buildingSystem = std::make_unique<BuildingSystem>(
|
||||
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);
|
||||
initializeSubsystems();
|
||||
|
||||
initializeUnlockState();
|
||||
m_unlockState.initializeUnlockState();
|
||||
placeInitialStructures();
|
||||
registerForEvents();
|
||||
}
|
||||
@@ -119,17 +96,25 @@ void Simulation::reset(unsigned int seed)
|
||||
m_pendingSchematicChoices.clear();
|
||||
|
||||
m_admin.clear();
|
||||
m_factoryState = FactoryState{};
|
||||
m_beltSystem = BeltSystem(m_config.world.beltSpeed_tps);
|
||||
initializeSubsystems();
|
||||
|
||||
m_unlockState.initializeUnlockState();
|
||||
placeInitialStructures();
|
||||
}
|
||||
|
||||
void Simulation::initializeSubsystems()
|
||||
{
|
||||
m_buildingSystem = std::make_unique<BuildingSystem>(
|
||||
m_config,
|
||||
m_factoryState,
|
||||
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)
|
||||
if (!isSchematicUnlocked(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -146,58 +131,6 @@ void Simulation::reset(unsigned int seed)
|
||||
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();
|
||||
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();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -329,15 +262,14 @@ void Simulation::tick()
|
||||
m_shipSystem->clearMovementIntents();
|
||||
// Score-based behavior selection: evaluate, select winner, execute (sets
|
||||
// movement intent + preferred module targets only — no world mutation).
|
||||
m_aiSystem->tick(m_admin, *m_buildingSystem, *m_debrisSystem);
|
||||
m_aiSystem->tick(m_admin, m_factoryState, *m_debrisSystem);
|
||||
// Module systems perform the world mutation (collection/delivery, healing).
|
||||
// Each emits its tool beams and applies its own delayed (mid-beam) effects.
|
||||
m_salvagerSystem->tick(m_currentTick, *m_debrisSystem, *m_buildingSystem, m_beamFiredEvents);
|
||||
m_salvagerSystem->tick(m_currentTick, *m_debrisSystem, m_factoryState, m_beamFiredEvents);
|
||||
m_repairSystem->tick(m_currentTick, m_beamFiredEvents);
|
||||
|
||||
// Step 8: combat resolution
|
||||
m_combatSystem->tick(m_currentTick, m_admin,
|
||||
*m_buildingSystem, m_beamFiredEvents);
|
||||
m_combatSystem->tick(m_currentTick, m_admin, m_beamFiredEvents);
|
||||
|
||||
// Step 8b: deferred damage whose impact tick has arrived
|
||||
m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
|
||||
@@ -633,9 +565,9 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
||||
std::vector<const UnlockGroupDef*> pool;
|
||||
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 (!prerequisitesSatisfied(group.requiredGroupIds)) { continue; }
|
||||
if (!m_unlockState.prerequisitesSatisfied(group.requiredGroupIds)) { continue; }
|
||||
pool.push_back(&group);
|
||||
}
|
||||
|
||||
@@ -659,7 +591,7 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
||||
const std::size_t endIdx = pool.size() - 1 - static_cast<std::size_t>(i);
|
||||
std::swap(pool[rollIdx], pool[endIdx]);
|
||||
|
||||
m_pendingSchematicChoices.push_back(makeUnlockOption(*pool[endIdx]));
|
||||
m_pendingSchematicChoices.push_back(m_unlockState.makeUnlockOption(*pool[endIdx]));
|
||||
}
|
||||
|
||||
if (artifactRolled)
|
||||
@@ -671,48 +603,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)
|
||||
{
|
||||
assert(choiceIndex >= 0 && choiceIndex < static_cast<int>(m_pendingSchematicChoices.size()));
|
||||
@@ -729,204 +619,24 @@ void Simulation::applySchematicChoice(int choiceIndex)
|
||||
return;
|
||||
}
|
||||
|
||||
// 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();
|
||||
m_unlockState.awardUnlockGroup(chosen);
|
||||
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
|
||||
{
|
||||
return m_unlockedRecipeIds.count(recipeId) > 0;
|
||||
return m_unlockState.isRecipeUnlocked(recipeId);
|
||||
}
|
||||
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
{
|
||||
return fingerprintRng(m_rng);
|
||||
@@ -957,13 +667,7 @@ unsigned long long Simulation::computeStateChecksum() const
|
||||
hasher.append(getNormalGapRemainingTicks());
|
||||
|
||||
// Schematic / unlock state (std::map and std::set iterate in sorted order).
|
||||
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);
|
||||
m_unlockState.appendChecksum(hasher);
|
||||
|
||||
// Subsystems contribute their own state.
|
||||
m_buildingSystem->appendChecksum(hasher);
|
||||
@@ -1109,12 +813,12 @@ double Simulation::getThreatAccumulationRate() const
|
||||
|
||||
double Simulation::getMaxFactoryProductionThreatRate() const
|
||||
{
|
||||
return static_cast<double>(m_buildingSystem->getProductionBuildingCount());
|
||||
return static_cast<double>(getProductionBuildingCount(m_factoryState));
|
||||
}
|
||||
|
||||
double Simulation::getCurrentFactoryProductionThreatRate() const
|
||||
{
|
||||
return static_cast<double>(m_buildingSystem->getActiveProductionBuildingCount());
|
||||
return static_cast<double>(getActiveProductionBuildingCount(m_factoryState));
|
||||
}
|
||||
|
||||
int Simulation::getBossWaveCounter() const
|
||||
@@ -1134,37 +838,17 @@ Tick Simulation::getNormalGapRemainingTicks() const
|
||||
|
||||
bool Simulation::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;
|
||||
return m_unlockState.isSchematicUnlocked(shipId);
|
||||
}
|
||||
|
||||
bool Simulation::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;
|
||||
return m_unlockState.isModuleSchematicUnlocked(moduleId);
|
||||
}
|
||||
|
||||
bool Simulation::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;
|
||||
return m_unlockState.isBuildingUnlocked(type);
|
||||
}
|
||||
|
||||
std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
|
||||
@@ -1213,6 +897,11 @@ BuildingSystem& Simulation::getBuildingsMutable()
|
||||
return *m_buildingSystem;
|
||||
}
|
||||
|
||||
const FactoryState& Simulation::getFactoryState() const
|
||||
{
|
||||
return m_factoryState;
|
||||
}
|
||||
|
||||
const BuildingSystem& Simulation::getBuildings() const
|
||||
{
|
||||
return *m_buildingSystem;
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#include "BeltSystem.h"
|
||||
#include "FactoryState.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "entt/entity/entity.hpp"
|
||||
#include "SchematicChoiceOption.h"
|
||||
@@ -22,6 +21,7 @@
|
||||
#include "Rotation.h"
|
||||
#include "Tick.h"
|
||||
#include "TracePrintRequestedEvent.h"
|
||||
#include "UnlockState.h"
|
||||
|
||||
class AiSystem;
|
||||
class BuildingSystem;
|
||||
@@ -119,6 +119,9 @@ public:
|
||||
// chokepoint) or, in tests, SimulationTestAccess — so production code cannot
|
||||
// mutate the factory outside the recorded command path (docs/replay_design.md).
|
||||
const BuildingSystem& getBuildings() const;
|
||||
|
||||
// The factory's world data, for the free queries in FactoryQueries.h.
|
||||
const FactoryState& getFactoryState() const;
|
||||
const BeltSystem& getBelts() const;
|
||||
ShipSystem& getShips();
|
||||
const ShipSystem& getShips() const;
|
||||
@@ -165,6 +168,11 @@ private:
|
||||
|
||||
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.
|
||||
void placeInitialStructures();
|
||||
|
||||
@@ -198,71 +206,16 @@ private:
|
||||
entt::entity m_playerStation2Entity;
|
||||
entt::entity m_currentEnemyStationEntities[2];
|
||||
|
||||
// Schematic unlock state (REQ-DEF-SCHEMATIC-DROP).
|
||||
struct SchematicState
|
||||
{
|
||||
bool unlocked;
|
||||
};
|
||||
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;
|
||||
// Schematic/unlock bookkeeping (REQ-DEF-SCHEMATIC-DROP, REQ-LOCK-EXPLICIT,
|
||||
// REQ-LOCK-IMPLICIT, REQ-LOCK-BUILDING, REQ-LOCK-PREREQ). Constructed before
|
||||
// initializeSubsystems() runs since BuildingSystem's spawn-gating lambda
|
||||
// calls into it (see initializeSubsystems()).
|
||||
UnlockState m_unlockState;
|
||||
|
||||
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;
|
||||
std::unique_ptr<BuildingSystem> m_buildingSystem;
|
||||
std::unique_ptr<ShipSystem> m_shipSystem;
|
||||
|
||||
@@ -335,15 +335,7 @@ double calculateShipThreatCost(const ThreatCostTable& table,
|
||||
const std::string& shipId,
|
||||
const std::vector<PlacedModule>& modules)
|
||||
{
|
||||
const ShipDef* shipDef = nullptr;
|
||||
for (const ShipDef& d : config.ships.ships)
|
||||
{
|
||||
if (d.id == shipId)
|
||||
{
|
||||
shipDef = &d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const ShipDef* shipDef = config.ships.findShipDef(shipId);
|
||||
if (shipDef == nullptr)
|
||||
{
|
||||
return 0.0;
|
||||
@@ -357,15 +349,7 @@ double calculateShipThreatCost(const ThreatCostTable& table,
|
||||
// Add module production times and material threats.
|
||||
for (const PlacedModule& pm : modules)
|
||||
{
|
||||
const ModuleDef* moduleDef = nullptr;
|
||||
for (const ModuleDef& d : config.modules.modules)
|
||||
{
|
||||
if (d.id == pm.moduleId)
|
||||
{
|
||||
moduleDef = &d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const ModuleDef* moduleDef = config.modules.findModuleDef(pm.moduleId);
|
||||
if (moduleDef == nullptr)
|
||||
{
|
||||
continue;
|
||||
|
||||
352
src/lib/sim/UnlockState.cpp
Normal file
352
src/lib/sim/UnlockState.cpp
Normal 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
129
src/lib/sim/UnlockState.h
Normal 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;
|
||||
};
|
||||
@@ -11,11 +11,7 @@
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "StationBodyComponent.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
#include "TestConfig.h"
|
||||
|
||||
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",
|
||||
"[artifact_win]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
CHECK(cfg.world.artifacts.artifactWinCount == 3);
|
||||
// 0.05 * x at x=2 should be 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",
|
||||
"[artifact_win]")
|
||||
{
|
||||
const Simulation sim(loadConfig());
|
||||
const Simulation sim(loadTestConfig());
|
||||
CHECK(sim.getArtifactCount() == 0);
|
||||
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",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
GameConfig cfg = loadTestConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
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",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
GameConfig cfg = loadTestConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
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",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
GameConfig cfg = loadTestConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("0");
|
||||
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",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
GameConfig cfg = loadTestConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
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",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
GameConfig cfg = loadTestConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
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",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
GameConfig cfg = loadTestConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
cfg.world.artifacts.artifactWinCount = 1;
|
||||
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",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
GameConfig cfg = loadTestConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
cfg.world.artifacts.artifactWinCount = 2;
|
||||
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",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
GameConfig cfg = loadTestConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
cfg.world.artifacts.artifactWinCount = 2;
|
||||
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",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
GameConfig cfg = loadTestConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
cfg.world.artifacts.artifactWinCount = 1;
|
||||
Simulation sim(std::move(cfg));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "catch.hpp"
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <random>
|
||||
@@ -14,6 +15,7 @@
|
||||
#include "BeltSystem.h"
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "FactoryState.h"
|
||||
#include "BuildingType.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "DeliverScrapBehavior.h"
|
||||
@@ -45,19 +47,16 @@
|
||||
#include "ShipLayout.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "Tick.h"
|
||||
#include "TestConfig.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
struct Fixture
|
||||
{
|
||||
GameConfig cfg;
|
||||
FactoryState state;
|
||||
BeltSystem belts;
|
||||
BuildingId nextBuildingId;
|
||||
int stock;
|
||||
@@ -75,12 +74,12 @@ struct Fixture
|
||||
std::vector<BeamFiredEvent> beamEvents;
|
||||
|
||||
explicit Fixture()
|
||||
: cfg(loadConfig())
|
||||
: cfg(loadTestConfig())
|
||||
, belts(cfg.world.beltSpeed_tps)
|
||||
, nextBuildingId(1)
|
||||
, stock(0)
|
||||
, rng(42)
|
||||
, buildings(cfg, belts,
|
||||
, buildings(cfg, state, belts,
|
||||
[this]() { return nextBuildingId++; },
|
||||
[this](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
@@ -99,14 +98,14 @@ struct Fixture
|
||||
void decide()
|
||||
{
|
||||
ships.clearMovementIntents();
|
||||
ai.tick(admin, buildings, scraps);
|
||||
ai.tick(admin, state, scraps);
|
||||
}
|
||||
|
||||
// World mutation: collection/delivery and healing.
|
||||
void runModules()
|
||||
{
|
||||
beamEvents.clear();
|
||||
salvager.tick(tick, scraps, buildings, beamEvents);
|
||||
salvager.tick(tick, scraps, state, beamEvents);
|
||||
repair.tick(tick, beamEvents);
|
||||
}
|
||||
|
||||
@@ -139,7 +138,7 @@ struct Fixture
|
||||
void salvageTick()
|
||||
{
|
||||
beamEvents.clear();
|
||||
salvager.tick(tick, scraps, buildings, beamEvents);
|
||||
salvager.tick(tick, scraps, state, beamEvents);
|
||||
++tick;
|
||||
}
|
||||
|
||||
@@ -959,12 +958,12 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
|
||||
for (int i = 0; i < 500; ++i)
|
||||
{
|
||||
f.buildings.tickConstruction(t++);
|
||||
if (f.buildings.findBuilding(bayId) != nullptr)
|
||||
if (findBuilding(f.state, bayId) != nullptr)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(f.buildings.findBuilding(bayId) != nullptr);
|
||||
REQUIRE(findBuilding(f.state, bayId) != nullptr);
|
||||
|
||||
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
||||
const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(5.0f, 0.0f),
|
||||
@@ -994,9 +993,9 @@ TEST_CASE("SalvagerSystem: full-cargo ship at its SalvageBay hands over cargo",
|
||||
for (int i = 0; i < 500; ++i)
|
||||
{
|
||||
f.buildings.tickConstruction(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);
|
||||
// Config-driven output-buffer capacity is applied on placement (REQ-BLD-SALVAGE-BAY).
|
||||
REQUIRE(bay->outputBuffer.capacity == 20);
|
||||
@@ -1017,7 +1016,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.
|
||||
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->outputBuffer.items.size() == 1);
|
||||
REQUIRE(bayAfter->outputBuffer.items.front().type.id == "scrap");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "catch.hpp"
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
@@ -20,6 +21,7 @@
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "SurfaceMask.h"
|
||||
#include "Tick.h"
|
||||
#include "TestConfig.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers that mirror the production implementations under test.
|
||||
@@ -147,11 +149,6 @@ static void applyRotationCCW(Blueprint& bp, const GameConfig& cfg)
|
||||
}
|
||||
}
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
// Mirrors BlueprintPanel::createBlueprintFromSelection's player-placeable filter:
|
||||
// building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false
|
||||
// are silently excluded before the bounding-box center and offsets are computed.
|
||||
@@ -312,7 +309,7 @@ TEST_CASE("Blueprint: non-axis-aligned offset rotates correctly", "[blueprint]")
|
||||
|
||||
TEST_CASE("Blueprint: CW constellation rotation updates offset and building rotation", "[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
// Building one tile to the right, facing East.
|
||||
Blueprint bp;
|
||||
bp.name = "test";
|
||||
@@ -332,7 +329,7 @@ TEST_CASE("Blueprint: CW constellation rotation updates offset and building rota
|
||||
|
||||
TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rotation", "[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
Blueprint bp;
|
||||
bp.name = "test";
|
||||
BlueprintBuilding bb;
|
||||
@@ -351,7 +348,7 @@ TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rot
|
||||
|
||||
TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
Blueprint bp;
|
||||
bp.name = "test";
|
||||
BlueprintBuilding bb;
|
||||
@@ -371,7 +368,7 @@ TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "
|
||||
|
||||
TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
// Two buildings left and right of center; after CW they should be above and below.
|
||||
Blueprint bp;
|
||||
bp.name = "test";
|
||||
@@ -398,7 +395,7 @@ TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[
|
||||
|
||||
TEST_CASE("Blueprint: CW rotation keeps belt adjacent to miner output port", "[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
|
||||
// East miner: anchor (0,0), body cells (0,0),(1,0),(0,1).
|
||||
// Output port indicator '>' at (1,1) → port tile (1,1), direction East.
|
||||
@@ -436,7 +433,7 @@ TEST_CASE("Blueprint: CW rotation keeps belt adjacent to miner output port", "[b
|
||||
|
||||
TEST_CASE("Blueprint: CCW rotation keeps belt adjacent to miner output port", "[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
|
||||
Blueprint bp;
|
||||
bp.name = "test";
|
||||
@@ -473,7 +470,7 @@ TEST_CASE("Blueprint: CCW rotation keeps belt adjacent to miner output port", "[
|
||||
TEST_CASE("Blueprint creation: non-player-placeable building alone yields empty blueprint",
|
||||
"[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
|
||||
// Hq has no entry in buildings.toml, so it is treated as non-player-placeable.
|
||||
const BuildingSpec hq{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Hq, Rotation::East };
|
||||
@@ -485,7 +482,7 @@ TEST_CASE("Blueprint creation: non-player-placeable building alone yields empty
|
||||
TEST_CASE("Blueprint creation: mixed selection keeps only player-placeable buildings",
|
||||
"[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
|
||||
const BuildingSpec belt{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East };
|
||||
const BuildingSpec hq { QPoint(-3, 0), {QPoint(-3, 0)}, BuildingType::Hq, Rotation::East };
|
||||
@@ -498,7 +495,7 @@ TEST_CASE("Blueprint creation: mixed selection keeps only player-placeable build
|
||||
TEST_CASE("Blueprint creation: bounding box ignores non-player-placeable buildings",
|
||||
"[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
|
||||
// Belt at (-5, 0). HQ at (-3, 0) — excluded from the blueprint.
|
||||
// If HQ were included: bboxX = [-5, -3], center.x = -4, belt offset = -1.
|
||||
@@ -520,7 +517,7 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor",
|
||||
// Simulate placing a two-belt blueprint with offsets (-1, 0) and (+1, 0)
|
||||
// at cursor tile (-5, 0). Expected anchors: (-6, 0) and (-4, 0).
|
||||
// (Belt surface_mask ["A>"] — body at relative (0,0), port at (1,0).)
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
const QPoint cursor(-5, 0);
|
||||
const QPoint offsetA(-1, 0);
|
||||
@@ -533,14 +530,14 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor",
|
||||
|
||||
REQUIRE(idA != kInvalidBuildingId);
|
||||
REQUIRE(idB != kInvalidBuildingId);
|
||||
REQUIRE(sim.getBuildings().isTileOccupied(cursor + offsetA)); // (-6, 0)
|
||||
REQUIRE(sim.getBuildings().isTileOccupied(cursor + offsetB)); // (-4, 0)
|
||||
REQUIRE_FALSE(sim.getBuildings().isTileOccupied(cursor)); // center not occupied
|
||||
REQUIRE(isTileOccupied(sim.getFactoryState(), cursor + offsetA)); // (-6, 0)
|
||||
REQUIRE(isTileOccupied(sim.getFactoryState(), cursor + offsetB)); // (-4, 0)
|
||||
REQUIRE_FALSE(isTileOccupied(sim.getFactoryState(), cursor)); // center not occupied
|
||||
}
|
||||
|
||||
TEST_CASE("Blueprint placement: cost is deducted for each building in sequence", "[blueprint]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
// Find belt cost from config (belt cost = 2 in test config).
|
||||
int beltCost = 0;
|
||||
@@ -563,7 +560,7 @@ TEST_CASE("Blueprint placement: cost is deducted for each building in sequence",
|
||||
TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId and deducts nothing",
|
||||
"[blueprint]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
// Find miner cost (15 in test config) — expensive enough to exhaust a small stock.
|
||||
int minerCost = 0;
|
||||
@@ -594,7 +591,7 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId a
|
||||
TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and charges nothing",
|
||||
"[blueprint]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
const int startBlocks = sim.getBuildingBlocksStock();
|
||||
|
||||
// A miner is all-asteroid; placing it in space (x >= 0) violates the terrain
|
||||
@@ -604,13 +601,13 @@ TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and ch
|
||||
|
||||
REQUIRE_FALSE(id.has_value());
|
||||
REQUIRE(sim.getBuildingBlocksStock() == startBlocks);
|
||||
REQUIRE(sim.getBuildings().getAllSites().empty());
|
||||
REQUIRE(getAllSites(sim.getFactoryState()).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies tiles, charges cost",
|
||||
"[blueprint]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
int minerCost = 0;
|
||||
for (const BuildingDef& def : sim.getConfig().buildings.buildings)
|
||||
@@ -627,11 +624,11 @@ TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies
|
||||
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
REQUIRE(sim.getBuildingBlocksStock() == startBlocks - minerCost);
|
||||
REQUIRE(sim.getBuildings().isTileOccupied(QPoint(-3, 0)));
|
||||
REQUIRE(sim.getBuildings().isTileOccupied(QPoint(-2, 0)));
|
||||
REQUIRE(sim.getBuildings().isTileOccupied(QPoint(-3, 1)));
|
||||
REQUIRE(isTileOccupied(sim.getFactoryState(), QPoint(-3, 0)));
|
||||
REQUIRE(isTileOccupied(sim.getFactoryState(), QPoint(-2, 0)));
|
||||
REQUIRE(isTileOccupied(sim.getFactoryState(), QPoint(-3, 1)));
|
||||
// The output-port tile (1,1)+anchor = (-2,1) is not a body cell.
|
||||
REQUIRE_FALSE(sim.getBuildings().isTileOccupied(QPoint(-2, 1)));
|
||||
REQUIRE_FALSE(isTileOccupied(sim.getFactoryState(), QPoint(-2, 1)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -663,7 +660,7 @@ TEST_CASE("Blueprint: building with no recipe has empty recipeId", "[blueprint]"
|
||||
|
||||
TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "[blueprint]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
|
||||
@@ -671,7 +668,7 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
|
||||
|
||||
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
|
||||
|
||||
const ConstructionSite* site = sim.getBuildings().findSite(id);
|
||||
const ConstructionSite* site = findSite(sim.getFactoryState(), id);
|
||||
REQUIRE(site != nullptr);
|
||||
REQUIRE(site->recipeId == "mine_iron_ore");
|
||||
}
|
||||
@@ -679,7 +676,7 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
|
||||
TEST_CASE("Blueprint placement: recipe transfers to building after construction completes",
|
||||
"[blueprint]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
@@ -692,7 +689,7 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
|
||||
sim.tick();
|
||||
}
|
||||
|
||||
const Building* b = sim.getBuildings().findBuilding(id);
|
||||
const Building* b = findBuilding(sim.getFactoryState(), id);
|
||||
REQUIRE(b != nullptr);
|
||||
REQUIRE(b->recipeId == "mine_copper_ore");
|
||||
}
|
||||
@@ -705,15 +702,15 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
|
||||
|
||||
TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
// Freshly placed → a ConstructionSite (not ticked to completion). A 1x1 belt keeps
|
||||
// the body-cell bounding-box centered on the anchor, so a single site → zero offset.
|
||||
const BuildingId id =
|
||||
SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East).value();
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
REQUIRE(sim.getBuildings().findSite(id) != nullptr);
|
||||
REQUIRE(sim.getBuildings().findBuilding(id) == nullptr);
|
||||
REQUIRE(findSite(sim.getFactoryState(), id) != nullptr);
|
||||
REQUIRE(findBuilding(sim.getFactoryState(), id) == nullptr);
|
||||
|
||||
const Blueprint bp = captureBlueprintFromSelection(sim, { id });
|
||||
|
||||
@@ -724,7 +721,7 @@ TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]")
|
||||
|
||||
TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blueprint]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
const BuildingId id =
|
||||
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
|
||||
@@ -740,7 +737,7 @@ TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blue
|
||||
TEST_CASE("Blueprint creation: mixed operational building and construction site are both captured",
|
||||
"[blueprint]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
// Building A: place, configure, and tick to completion so it is operational.
|
||||
const BuildingId idA =
|
||||
@@ -748,14 +745,14 @@ TEST_CASE("Blueprint creation: mixed operational building and construction site
|
||||
REQUIRE(idA != kInvalidBuildingId);
|
||||
SimulationTestAccess::buildings(sim).setRecipe(idA, "mine_iron_ore");
|
||||
for (int i = 0; i <= static_cast<int>(secondsToTicks(10.0)); ++i) { sim.tick(); }
|
||||
REQUIRE(sim.getBuildings().findBuilding(idA) != nullptr);
|
||||
REQUIRE(findBuilding(sim.getFactoryState(), idA) != nullptr);
|
||||
|
||||
// Building B: place and configure, but leave as a construction site.
|
||||
const BuildingId idB =
|
||||
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-6, 0), Rotation::East).value();
|
||||
REQUIRE(idB != kInvalidBuildingId);
|
||||
SimulationTestAccess::buildings(sim).setRecipe(idB, "mine_copper_ore");
|
||||
REQUIRE(sim.getBuildings().findSite(idB) != nullptr);
|
||||
REQUIRE(findSite(sim.getFactoryState(), idB) != nullptr);
|
||||
|
||||
const Blueprint bp = captureBlueprintFromSelection(sim, { idA, idB });
|
||||
|
||||
@@ -772,14 +769,14 @@ TEST_CASE("Blueprint creation: mixed operational building and construction site
|
||||
|
||||
TEST_CASE("Blueprint creation: selectionHasPlaceableBuilding sees a construction site", "[blueprint]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
REQUIRE_FALSE(selectionHasPlaceableBuilding(sim, {}));
|
||||
|
||||
const BuildingId id =
|
||||
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
REQUIRE(sim.getBuildings().findSite(id) != nullptr);
|
||||
REQUIRE(findSite(sim.getFactoryState(), id) != nullptr);
|
||||
REQUIRE(selectionHasPlaceableBuilding(sim, { id }));
|
||||
}
|
||||
|
||||
@@ -787,7 +784,7 @@ TEST_CASE("Blueprint placement: interceptor schematic is unlocked at game start"
|
||||
{
|
||||
// "interceptor" has unlock_at_station_level = -1 in the test config.
|
||||
// This confirms the guard in placeBlueprintAtTile passes for start-unlocked schematics.
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
REQUIRE(sim.isSchematicUnlocked("interceptor"));
|
||||
}
|
||||
|
||||
@@ -796,7 +793,7 @@ TEST_CASE("Blueprint placement: repair_ship schematic is locked at game start",
|
||||
// "repair_ship" has unlock_at_station_level = 0 in the test config.
|
||||
// This confirms the guard in placeBlueprintAtTile blocks locked schematics,
|
||||
// leaving the shipyard's schematic unset.
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
|
||||
}
|
||||
|
||||
@@ -842,7 +839,7 @@ TEST_CASE("Blueprint: building without layout has nullopt shipLayout", "[bluepri
|
||||
|
||||
TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout", "[blueprint]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
// Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East:
|
||||
// A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles.
|
||||
@@ -859,7 +856,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
|
||||
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
|
||||
|
||||
const ConstructionSite* site = sim.getBuildings().findSite(id);
|
||||
const ConstructionSite* site = findSite(sim.getFactoryState(), id);
|
||||
REQUIRE(site != nullptr);
|
||||
REQUIRE(site->shipLayout.has_value());
|
||||
REQUIRE(site->shipLayout->placedModules.size() == 1);
|
||||
@@ -869,7 +866,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
|
||||
TEST_CASE("Blueprint placement: ship layout transfers to building after construction completes",
|
||||
"[blueprint]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East).value();
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
@@ -896,7 +893,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
|
||||
sim.tick();
|
||||
}
|
||||
|
||||
const Building* b = sim.getBuildings().findBuilding(id);
|
||||
const Building* b = findBuilding(sim.getFactoryState(), id);
|
||||
REQUIRE(b != nullptr);
|
||||
REQUIRE(b->shipLayout.has_value());
|
||||
REQUIRE(b->shipLayout->placedModules.size() == 1);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "catch.hpp"
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
@@ -13,6 +14,7 @@
|
||||
#include "ShipsConfig.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "TestConfig.h"
|
||||
|
||||
// readBuildingConfig underpins the copy-settings gesture (REQ-BLD-COPY-CONFIG):
|
||||
// it extracts a building's recipe / schematic / layout / splitter filters so they
|
||||
@@ -21,11 +23,6 @@
|
||||
|
||||
namespace
|
||||
{
|
||||
GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
const BuildingDef* findDef(const GameConfig& cfg, BuildingType type)
|
||||
{
|
||||
for (const BuildingDef& def : cfg.buildings.buildings)
|
||||
@@ -66,8 +63,8 @@ const ShipDef* findAvailableSchematic(const GameConfig& cfg)
|
||||
|
||||
TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[copyconfig]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
Simulation sim(loadConfig(), 7);
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
Simulation sim(loadTestConfig(), 7);
|
||||
|
||||
const BuildingId id = placeOperational(sim, cfg, BuildingType::Miner, QPoint(0, 0));
|
||||
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
|
||||
@@ -84,8 +81,8 @@ TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[copyconfig]"
|
||||
TEST_CASE("readBuildingConfig leaves recipe unset when nothing is selected",
|
||||
"[copyconfig]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
Simulation sim(loadConfig(), 7);
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
Simulation sim(loadTestConfig(), 7);
|
||||
|
||||
const BuildingId id = placeOperational(sim, cfg, BuildingType::Assembler, QPoint(0, 0));
|
||||
|
||||
@@ -99,8 +96,8 @@ TEST_CASE("readBuildingConfig leaves recipe unset when nothing is selected",
|
||||
TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout",
|
||||
"[copyconfig]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
Simulation sim(loadConfig(), 7);
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
Simulation sim(loadTestConfig(), 7);
|
||||
|
||||
const ShipDef* schematic = findAvailableSchematic(cfg);
|
||||
REQUIRE(schematic != nullptr);
|
||||
@@ -119,15 +116,15 @@ TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout",
|
||||
|
||||
TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
Simulation sim(loadConfig(), 7);
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
Simulation sim(loadTestConfig(), 7);
|
||||
|
||||
// A placed miner enters the construction queue as a site (not yet operational).
|
||||
const BuildingId id =
|
||||
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
REQUIRE(sim.getBuildings().findBuilding(id) == nullptr);
|
||||
REQUIRE(sim.getBuildings().findSite(id) != nullptr);
|
||||
REQUIRE(findBuilding(sim.getFactoryState(), id) == nullptr);
|
||||
REQUIRE(findSite(sim.getFactoryState(), id) != nullptr);
|
||||
|
||||
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
|
||||
|
||||
@@ -140,6 +137,6 @@ TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]")
|
||||
|
||||
TEST_CASE("readBuildingConfig returns nullopt for an unknown id", "[copyconfig]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 7);
|
||||
Simulation sim(loadTestConfig(), 7);
|
||||
CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value());
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
||||
add_files(
|
||||
TEST_FILES
|
||||
|
||||
TestConfig.h
|
||||
|
||||
test.cpp
|
||||
FormulaTest.cpp
|
||||
ConfigLoaderTest.cpp
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "BeltSystem.h"
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "FactoryState.h"
|
||||
#include "BuildingType.h"
|
||||
#include "CombatSystem.h"
|
||||
#include "ConfigLoader.h"
|
||||
@@ -22,11 +23,7 @@
|
||||
#include "StationBodyComponent.h"
|
||||
#include "Tick.h"
|
||||
#include "WeaponComponent.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
#include "TestConfig.h"
|
||||
|
||||
static const ShipDef* findCombatShip(const GameConfig& cfg)
|
||||
{
|
||||
@@ -55,6 +52,7 @@ static entt::entity findWeaponChild(EntityAdmin& admin, entt::entity ship)
|
||||
struct CombatFixture
|
||||
{
|
||||
GameConfig cfg;
|
||||
FactoryState state;
|
||||
std::mt19937 rng;
|
||||
EntityAdmin admin;
|
||||
BuildingId nextBuildingId;
|
||||
@@ -64,12 +62,12 @@ struct CombatFixture
|
||||
CombatSystem combat;
|
||||
|
||||
explicit CombatFixture()
|
||||
: cfg(loadConfig())
|
||||
: cfg(loadTestConfig())
|
||||
, rng(42)
|
||||
, nextBuildingId(1)
|
||||
, belts(cfg.world.beltSpeed_tps)
|
||||
, ships(cfg, admin)
|
||||
, buildings(cfg, belts,
|
||||
, buildings(cfg, state, belts,
|
||||
[this]() { return nextBuildingId++; },
|
||||
[](int){},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
@@ -114,7 +112,7 @@ TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[comb
|
||||
const float hpBefore = f.admin.get<HealthComponent>(player).hp;
|
||||
|
||||
std::vector<BeamFiredEvent> events;
|
||||
f.combat.tick(0, f.admin, f.buildings, events);
|
||||
f.combat.tick(0, f.admin, events);
|
||||
f.combat.applyPendingDamage(5, f.admin);
|
||||
|
||||
REQUIRE(f.admin.get<HealthComponent>(player).hp < hpBefore);
|
||||
@@ -147,13 +145,13 @@ TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]"
|
||||
};
|
||||
|
||||
std::vector<BeamFiredEvent> events;
|
||||
f.combat.tick(0, f.admin, f.buildings, events);
|
||||
f.combat.tick(0, f.admin, events);
|
||||
REQUIRE_FALSE(enemyFiredIn(events));
|
||||
|
||||
f.combat.tick(1, f.admin, f.buildings, events);
|
||||
f.combat.tick(1, f.admin, events);
|
||||
REQUIRE_FALSE(enemyFiredIn(events));
|
||||
|
||||
f.combat.tick(2, f.admin, f.buildings, events);
|
||||
f.combat.tick(2, f.admin, events);
|
||||
REQUIRE(enemyFiredIn(events));
|
||||
}
|
||||
|
||||
@@ -168,7 +166,7 @@ TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]")
|
||||
f.wireEnemyTarget(enemy, player);
|
||||
|
||||
std::vector<BeamFiredEvent> events;
|
||||
f.combat.tick(0, f.admin, f.buildings, events);
|
||||
f.combat.tick(0, f.admin, events);
|
||||
REQUIRE(events.empty());
|
||||
}
|
||||
|
||||
@@ -178,7 +176,7 @@ TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]")
|
||||
|
||||
TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
// Find the player station entity via ECS.
|
||||
entt::entity stationEntity = entt::null;
|
||||
@@ -217,7 +215,7 @@ TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]
|
||||
|
||||
TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
entt::entity stationEntity = entt::null;
|
||||
QVector2D stationCenter;
|
||||
@@ -255,7 +253,7 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]
|
||||
|
||||
TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
entt::entity stationEntity = entt::null;
|
||||
QVector2D stationCenter;
|
||||
@@ -311,7 +309,7 @@ TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]")
|
||||
const float hpBefore = f.admin.get<HealthComponent>(player).hp;
|
||||
|
||||
std::vector<BeamFiredEvent> events;
|
||||
f.combat.tick(0, f.admin, f.buildings, events);
|
||||
f.combat.tick(0, f.admin, events);
|
||||
|
||||
for (Tick t = 1; t < 5; ++t)
|
||||
{
|
||||
@@ -333,7 +331,7 @@ TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]")
|
||||
const float hpBefore = f.admin.get<HealthComponent>(player).hp;
|
||||
|
||||
std::vector<BeamFiredEvent> events;
|
||||
f.combat.tick(0, f.admin, f.buildings, events);
|
||||
f.combat.tick(0, f.admin, events);
|
||||
f.combat.applyPendingDamage(5, f.admin);
|
||||
|
||||
REQUIRE(f.admin.get<HealthComponent>(player).hp < hpBefore);
|
||||
@@ -350,7 +348,7 @@ TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[comb
|
||||
f.wireEnemyTarget(enemy, player);
|
||||
|
||||
std::vector<BeamFiredEvent> events;
|
||||
f.combat.tick(0, f.admin, f.buildings, events);
|
||||
f.combat.tick(0, f.admin, events);
|
||||
|
||||
f.ships.despawn(player);
|
||||
|
||||
@@ -373,7 +371,7 @@ TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat
|
||||
const float hpBefore = f.admin.get<HealthComponent>(player).hp;
|
||||
|
||||
std::vector<BeamFiredEvent> events;
|
||||
f.combat.tick(0, f.admin, f.buildings, events);
|
||||
f.combat.tick(0, f.admin, events);
|
||||
|
||||
f.ships.despawn(enemy);
|
||||
|
||||
@@ -388,7 +386,7 @@ TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat
|
||||
|
||||
TEST_CASE("CombatSystem: dead ship is removed after tick step 9", "[combat]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
const ShipDef* combatDef = findCombatShip(sim.getConfig());
|
||||
REQUIRE(combatDef != nullptr);
|
||||
@@ -405,7 +403,7 @@ TEST_CASE("CombatSystem: dead ship is removed after tick step 9", "[combat]")
|
||||
|
||||
TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
// Scrap dropped on death is derived from the ship's as-built threat cost
|
||||
// (REQ-RES-DEBRIS-DROP): round(threat * scrap_per_threat). The interceptor's
|
||||
@@ -424,7 +422,7 @@ TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
|
||||
|
||||
TEST_CASE("CombatSystem: HQ death sets game over", "[combat]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
// Damage the HQ proxy entity (has HqProxy + Health).
|
||||
sim.getAdmin().forEach<HqProxyComponent, HealthComponent>(
|
||||
|
||||
@@ -11,14 +11,7 @@
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "Tick.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
} // namespace
|
||||
#include "TestConfig.h"
|
||||
|
||||
// The command chokepoint (Simulation::apply) must produce exactly the same state
|
||||
// as driving the underlying mutators directly — that equivalence is what lets a
|
||||
@@ -26,8 +19,8 @@ GameConfig loadConfig()
|
||||
|
||||
TEST_CASE("apply(PlaceBuildingCommand) matches direct placement", "[command]")
|
||||
{
|
||||
Simulation viaCommand(loadConfig(), 99);
|
||||
Simulation viaDirect(loadConfig(), 99);
|
||||
Simulation viaCommand(loadTestConfig(), 99);
|
||||
Simulation viaDirect(loadTestConfig(), 99);
|
||||
|
||||
PlaceBuildingCommand command;
|
||||
command.type = BuildingType::Miner;
|
||||
@@ -42,8 +35,8 @@ TEST_CASE("apply(PlaceBuildingCommand) matches direct placement", "[command]")
|
||||
|
||||
TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe", "[command]")
|
||||
{
|
||||
Simulation viaCommand(loadConfig(), 99);
|
||||
Simulation viaDirect(loadConfig(), 99);
|
||||
Simulation viaCommand(loadTestConfig(), 99);
|
||||
Simulation viaDirect(loadTestConfig(), 99);
|
||||
|
||||
PlaceBuildingCommand command;
|
||||
command.type = BuildingType::Miner;
|
||||
@@ -61,8 +54,8 @@ TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe"
|
||||
|
||||
TEST_CASE("apply(DeconstructCommand) matches direct deconstruct", "[command]")
|
||||
{
|
||||
Simulation viaCommand(loadConfig(), 99);
|
||||
Simulation viaDirect(loadConfig(), 99);
|
||||
Simulation viaCommand(loadTestConfig(), 99);
|
||||
Simulation viaDirect(loadTestConfig(), 99);
|
||||
|
||||
const BuildingId idA =
|
||||
SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East).value();
|
||||
@@ -81,8 +74,8 @@ TEST_CASE("apply(DeconstructCommand) matches direct deconstruct", "[command]")
|
||||
|
||||
TEST_CASE("apply(CancelDeconstructionCommand) matches direct cancelDeconstruction", "[command]")
|
||||
{
|
||||
Simulation viaCommand(loadConfig(), 99);
|
||||
Simulation viaDirect(loadConfig(), 99);
|
||||
Simulation viaCommand(loadTestConfig(), 99);
|
||||
Simulation viaDirect(loadTestConfig(), 99);
|
||||
|
||||
const BuildingId idA =
|
||||
SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East).value();
|
||||
@@ -110,8 +103,8 @@ TEST_CASE("apply(CancelDeconstructionCommand) matches direct cancelDeconstructio
|
||||
|
||||
TEST_CASE("CommandManager drains queued commands in FIFO order through apply", "[command]")
|
||||
{
|
||||
Simulation viaManager(loadConfig(), 99);
|
||||
Simulation viaDirect(loadConfig(), 99);
|
||||
Simulation viaManager(loadTestConfig(), 99);
|
||||
Simulation viaDirect(loadTestConfig(), 99);
|
||||
|
||||
CommandManager manager(viaManager);
|
||||
|
||||
|
||||
@@ -5,28 +5,52 @@
|
||||
#include <vector>
|
||||
|
||||
#include "ConfigLoader.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "GameConfig.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "Rotation.h"
|
||||
#include "SchematicChoiceOption.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "StateChecksum.h"
|
||||
#include "StationBodyComponent.h"
|
||||
#include "Tick.h"
|
||||
#include "TestConfig.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
constexpr int kScriptTicks = 2000;
|
||||
|
||||
// Ticks at which the scripted session destroys the enemy stations, and the ticks
|
||||
// on which the resulting schematic choice is taken. A station dying triggers the
|
||||
// choice generation (REQ-DEF-SCHEMATIC-DROP), which lands during that same tick,
|
||||
// so the choice is applied on the tick after.
|
||||
constexpr int kFirstStationKillTick = 800;
|
||||
constexpr int kFirstChoiceTick = kFirstStationKillTick + 1;
|
||||
constexpr int kSecondStationKillTick = 1400;
|
||||
constexpr int kSecondChoiceTick = kSecondStationKillTick + 1;
|
||||
|
||||
// Zeroes the HP of every enemy station, so the next tick processes their death.
|
||||
void killEnemyStations(Simulation& sim)
|
||||
{
|
||||
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
[](entt::entity, StationBodyComponent&, FactionComponent& faction,
|
||||
HealthComponent& health)
|
||||
{
|
||||
if (faction.isEnemy) { health.hp = 0.0f; }
|
||||
});
|
||||
}
|
||||
|
||||
// Runs a fixed scripted session and returns the full-state checksum after every
|
||||
// tick. The script places a small factory, deconstructs part of it mid-run, and
|
||||
// otherwise lets waves/combat run so the RNG stream and ECS state are exercised.
|
||||
// It also destroys the enemy stations twice and takes the offered schematic
|
||||
// choice, so that unlock state (awarded groups, per-schematic levels, and the
|
||||
// implicit recipe/item sets derived from them) is exercised as well and reaches
|
||||
// the checksum via UnlockState::appendChecksum.
|
||||
std::vector<std::uint64_t> runScriptedSession(unsigned int seed)
|
||||
{
|
||||
Simulation sim(loadConfig(), seed);
|
||||
Simulation sim(loadTestConfig(), seed);
|
||||
|
||||
// Tick 0: a miner feeding a short belt line on the asteroid.
|
||||
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
@@ -44,6 +68,26 @@ std::vector<std::uint64_t> runScriptedSession(unsigned int seed)
|
||||
SimulationTestAccess::place(sim, BuildingType::Smelter, QPoint(-3, 3), Rotation::East);
|
||||
}
|
||||
|
||||
if (t == kFirstStationKillTick || t == kSecondStationKillTick)
|
||||
{
|
||||
killEnemyStations(sim);
|
||||
}
|
||||
|
||||
if (t == kFirstChoiceTick)
|
||||
{
|
||||
// Guarded rather than assumed: if the test config ever stops offering
|
||||
// a group here, this script would silently stop covering unlock state.
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||
}
|
||||
|
||||
// The second award is opportunistic — whether a group is still eligible
|
||||
// depends on what the first one granted and on the prerequisite gating.
|
||||
if (t == kSecondChoiceTick && sim.hasSchematicChoicesPending())
|
||||
{
|
||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||
}
|
||||
|
||||
sim.tick();
|
||||
checksums.push_back(sim.computeStateChecksum());
|
||||
}
|
||||
@@ -121,8 +165,8 @@ TEST_CASE("fingerprintRng: equal states match, advanced states differ", "[determ
|
||||
|
||||
TEST_CASE("Simulation::rngFingerprint is stable for equal seeds", "[determinism]")
|
||||
{
|
||||
const Simulation a(loadConfig(), 777);
|
||||
const Simulation b(loadConfig(), 777);
|
||||
const Simulation a(loadTestConfig(), 777);
|
||||
const Simulation b(loadTestConfig(), 777);
|
||||
|
||||
REQUIRE(a.getRngFingerprint() == b.getRngFingerprint());
|
||||
}
|
||||
@@ -156,3 +200,44 @@ TEST_CASE("Simulation: different seeds diverge in state checksum", "[determinism
|
||||
// the RNG-driven divergence; a constant checksum would be a broken hash).
|
||||
REQUIRE(a != b);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unlock state coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Simulation: unlock state contributes to the state checksum",
|
||||
"[determinism][unlock]")
|
||||
{
|
||||
// Two sessions with identical history up to the schematic choice; only one
|
||||
// takes the choice. This pins down that awarding an unlock group actually
|
||||
// reaches the checksum, which the scripted-session tests above rely on but
|
||||
// cannot show on their own: they would still pass if UnlockState were left
|
||||
// out of the fold entirely.
|
||||
Simulation taken(loadTestConfig(), 12345u);
|
||||
Simulation skipped(loadTestConfig(), 12345u);
|
||||
|
||||
for (int t = 0; t < kFirstStationKillTick; ++t)
|
||||
{
|
||||
taken.tick();
|
||||
skipped.tick();
|
||||
}
|
||||
killEnemyStations(taken);
|
||||
killEnemyStations(skipped);
|
||||
taken.tick();
|
||||
skipped.tick();
|
||||
|
||||
// In lockstep before the choice, so the divergence below has one cause.
|
||||
REQUIRE(taken.computeStateChecksum() == skipped.computeStateChecksum());
|
||||
|
||||
REQUIRE(taken.hasSchematicChoicesPending());
|
||||
|
||||
// An artifact choice bumps m_artifactCount, which is folded separately; the
|
||||
// divergence would then not be attributable to unlock state.
|
||||
REQUIRE_FALSE(taken.getPendingSchematicChoices()[0].isArtifact);
|
||||
|
||||
SimulationTestAccess::applySchematicChoice(taken, 0);
|
||||
|
||||
// The pending-choice list is not itself folded into the checksum, so the
|
||||
// only state that changed is the unlock bookkeeping.
|
||||
REQUIRE(taken.computeStateChecksum() != skipped.computeStateChecksum());
|
||||
}
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
#include "ConfigLoader.h"
|
||||
#include "ModulesConfig.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
#include "TestConfig.h"
|
||||
|
||||
static const ModuleDef* findModule(const GameConfig& cfg, const std::string& id)
|
||||
{
|
||||
@@ -28,7 +24,7 @@ static const ModuleStatModifier* findModifier(const ModuleDef& def, const std::s
|
||||
|
||||
TEST_CASE("ConfigLoader: loadModules parses modules.toml", "[config][modules]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
REQUIRE(cfg.modules.modules.size() >= 2);
|
||||
|
||||
const ModuleDef& armor = cfg.modules.modules[0];
|
||||
@@ -49,7 +45,7 @@ TEST_CASE("ConfigLoader: loadModules parses modules.toml", "[config][modules]")
|
||||
|
||||
TEST_CASE("ConfigLoader: loadModules parses additive modifiers", "[config][modules]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
REQUIRE(cfg.modules.modules.size() >= 2);
|
||||
|
||||
const ModuleDef& sensor = cfg.modules.modules[1];
|
||||
@@ -62,7 +58,7 @@ TEST_CASE("ConfigLoader: loadModules parses additive modifiers", "[config][modul
|
||||
|
||||
TEST_CASE("ConfigLoader: multiplicative modifier with unit suffix is parsed (weapon_primer)", "[config][modules]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ModuleDef* primer = findModule(cfg, "weapon_primer");
|
||||
REQUIRE(primer != nullptr);
|
||||
REQUIRE(primer->statModifiers.size() == 1);
|
||||
@@ -74,7 +70,7 @@ TEST_CASE("ConfigLoader: multiplicative modifier with unit suffix is parsed (wea
|
||||
|
||||
TEST_CASE("ConfigLoader: weapon_stabilizer parses two multiplicative weapon modifiers", "[config][modules]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ModuleDef* stab = findModule(cfg, "weapon_stabilizer");
|
||||
REQUIRE(stab != nullptr);
|
||||
REQUIRE(stab->statModifiers.size() == 2);
|
||||
@@ -92,7 +88,7 @@ TEST_CASE("ConfigLoader: weapon_stabilizer parses two multiplicative weapon modi
|
||||
|
||||
TEST_CASE("ConfigLoader: afterburner parses multiplicative speed and additive main_acceleration", "[config][modules]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ModuleDef* ab = findModule(cfg, "afterburner");
|
||||
REQUIRE(ab != nullptr);
|
||||
REQUIRE(ab->statModifiers.size() == 2);
|
||||
@@ -110,7 +106,7 @@ TEST_CASE("ConfigLoader: afterburner parses multiplicative speed and additive ma
|
||||
|
||||
TEST_CASE("ConfigLoader: maneuvering_thrusters parses multiplicative speed and additive maneuvering_acceleration", "[config][modules]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ModuleDef* mt = findModule(cfg, "maneuvering_thrusters");
|
||||
REQUIRE(mt != nullptr);
|
||||
REQUIRE(mt->statModifiers.size() == 2);
|
||||
@@ -128,7 +124,7 @@ TEST_CASE("ConfigLoader: maneuvering_thrusters parses multiplicative speed and a
|
||||
|
||||
TEST_CASE("ConfigLoader: loadShips parses layout field", "[config][ships]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
REQUIRE(!cfg.ships.ships.empty());
|
||||
|
||||
const ShipDef& ship = cfg.ships.ships[0];
|
||||
|
||||
@@ -12,11 +12,7 @@
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "StationBodyComponent.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
#include "TestConfig.h"
|
||||
|
||||
// Zeros the HP of both enemy defence stations and advances one tick so that
|
||||
// tickDeathsAndLoot fires, triggering the push and schematic choices.
|
||||
@@ -62,7 +58,7 @@ static bool awaitRecipeUnlock(Simulation& sim, const std::string& recipeId,
|
||||
|
||||
TEST_CASE("RecipeSchematic: unlocked_at_start = true parsed correctly", "[recipe_schematic]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
||||
[](const RecipeDef& r) { return r.id == "premium_circuit"; });
|
||||
REQUIRE(it != cfg.recipes.recipes.end());
|
||||
@@ -71,7 +67,7 @@ TEST_CASE("RecipeSchematic: unlocked_at_start = true parsed correctly", "[recipe
|
||||
|
||||
TEST_CASE("RecipeSchematic: a gated recipe is granted by an unlock group", "[recipe_schematic]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const bool granted = std::any_of(cfg.unlocks.groups.begin(), cfg.unlocks.groups.end(),
|
||||
[](const UnlockGroupDef& g)
|
||||
{
|
||||
@@ -82,7 +78,7 @@ TEST_CASE("RecipeSchematic: a gated recipe is granted by an unlock group", "[rec
|
||||
|
||||
TEST_CASE("RecipeSchematic: an untagged assembler recipe has unlocked_at_start = false", "[recipe_schematic]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
||||
[](const RecipeDef& r) { return r.id == "circuit_board"; });
|
||||
REQUIRE(it != cfg.recipes.recipes.end());
|
||||
@@ -96,21 +92,21 @@ TEST_CASE("RecipeSchematic: an untagged assembler recipe has unlocked_at_start =
|
||||
TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = -1 is unlocked at game start",
|
||||
"[recipe_schematic]")
|
||||
{
|
||||
const Simulation sim(loadConfig());
|
||||
const Simulation sim(loadTestConfig());
|
||||
REQUIRE(sim.isRecipeUnlocked("premium_circuit"));
|
||||
}
|
||||
|
||||
TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = 0 is locked at game start",
|
||||
"[recipe_schematic]")
|
||||
{
|
||||
const Simulation sim(loadConfig());
|
||||
const Simulation sim(loadTestConfig());
|
||||
REQUIRE_FALSE(sim.isRecipeUnlocked("quick_circuit"));
|
||||
}
|
||||
|
||||
TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = 1 is locked at game start",
|
||||
"[recipe_schematic]")
|
||||
{
|
||||
const Simulation sim(loadConfig());
|
||||
const Simulation sim(loadTestConfig());
|
||||
REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit"));
|
||||
}
|
||||
|
||||
@@ -123,7 +119,7 @@ TEST_CASE("RecipeSchematic: -1 recipe seeds its output item into the implicit un
|
||||
{
|
||||
// premium_circuit is not needed by any ship or module schematic, so it can
|
||||
// only reach the implicit set via the -1 recipe seed in Phase 1.
|
||||
const Simulation sim(loadConfig());
|
||||
const Simulation sim(loadTestConfig());
|
||||
REQUIRE(sim.isItemUnlocked("premium_circuit"));
|
||||
}
|
||||
|
||||
@@ -132,7 +128,7 @@ TEST_CASE("RecipeSchematic: -1 recipe's inputs are in the implicit unlock set",
|
||||
{
|
||||
// premium_circuit takes circuit_board as input; that item was already
|
||||
// implicitly unlocked by ship schematics, so it must remain unlocked.
|
||||
const Simulation sim(loadConfig());
|
||||
const Simulation sim(loadTestConfig());
|
||||
REQUIRE(sim.isItemUnlocked("circuit_board"));
|
||||
}
|
||||
|
||||
@@ -142,7 +138,7 @@ TEST_CASE("RecipeSchematic: locked recipe's unique input is not in the implicit
|
||||
// exotic_alloy has unlock_at_station_level = 0 (locked at start) and takes
|
||||
// exotic_ore as input. exotic_ore is only reachable through this locked
|
||||
// recipe, so it must not appear in the implicit set.
|
||||
const Simulation sim(loadConfig());
|
||||
const Simulation sim(loadTestConfig());
|
||||
REQUIRE_FALSE(sim.isItemUnlocked("exotic_ore"));
|
||||
}
|
||||
|
||||
@@ -152,7 +148,7 @@ TEST_CASE("RecipeSchematic: locked recipe's output item is not in the implicit u
|
||||
// exotic_alloy is produced only by the locked recipe of the same name and
|
||||
// is not needed by any schematic, so neither the item nor the recipe should
|
||||
// be unlocked at game start.
|
||||
const Simulation sim(loadConfig());
|
||||
const Simulation sim(loadTestConfig());
|
||||
REQUIRE_FALSE(sim.isItemUnlocked("exotic_alloy"));
|
||||
REQUIRE_FALSE(sim.isRecipeUnlocked("exotic_alloy"));
|
||||
}
|
||||
@@ -162,7 +158,7 @@ TEST_CASE("RecipeSchematic: normal implicit unlock is unaffected for untagged as
|
||||
{
|
||||
// circuit_board carries no unlock_at_station_level and is needed by ships
|
||||
// that start unlocked, so it must still be implicitly unlocked.
|
||||
const Simulation sim(loadConfig());
|
||||
const Simulation sim(loadTestConfig());
|
||||
REQUIRE(sim.isRecipeUnlocked("circuit_board"));
|
||||
}
|
||||
|
||||
@@ -176,7 +172,7 @@ TEST_CASE("RecipeSchematic: eligible recipe schematic is eventually awarded on s
|
||||
// quick_circuit has unlock_at_station_level = 0 and produces circuit_board
|
||||
// (already implicitly unlocked), so it is eligible from the first station
|
||||
// destruction. With up to 150 trials it must be awarded at least once.
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
REQUIRE(awaitRecipeUnlock(sim, "quick_circuit"));
|
||||
}
|
||||
|
||||
@@ -185,7 +181,7 @@ TEST_CASE("RecipeSchematic: an implicitly-gated recipe with no unlock group is n
|
||||
{
|
||||
// exotic_alloy is in no unlock group and its output/inputs are unreachable
|
||||
// via the item graph, so it can never be dropped or implicitly unlocked.
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
for (int i = 0; i < 50; ++i)
|
||||
{
|
||||
killEnemyStationsAndApply(sim);
|
||||
@@ -198,7 +194,7 @@ TEST_CASE("RecipeSchematic: recipe with level > destroyed station level is not a
|
||||
{
|
||||
// advanced_circuit has unlock_at_station_level = 1. Destroying a single
|
||||
// level-0 station set must not award it regardless of the RNG outcome.
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
killEnemyStationsAndApply(sim);
|
||||
REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit"));
|
||||
}
|
||||
@@ -208,14 +204,14 @@ TEST_CASE("RecipeSchematic: recipe with higher level is awarded once eligible st
|
||||
{
|
||||
// After enough destructions to pass station level 1, advanced_circuit must
|
||||
// eventually be awarded.
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
REQUIRE(awaitRecipeUnlock(sim, "advanced_circuit", 300));
|
||||
}
|
||||
|
||||
TEST_CASE("RecipeSchematic: awarded recipe schematic stays unlocked and is not awarded again",
|
||||
"[recipe_schematic]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
awaitRecipeUnlock(sim, "quick_circuit");
|
||||
REQUIRE(sim.isRecipeUnlocked("quick_circuit"));
|
||||
|
||||
@@ -231,7 +227,7 @@ TEST_CASE("RecipeSchematic: awarded recipe schematic stays unlocked and is not a
|
||||
TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices",
|
||||
"[recipe_schematic]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
bool foundRecipeChoice = false;
|
||||
for (int i = 0; i < 150 && !foundRecipeChoice; ++i)
|
||||
@@ -260,7 +256,7 @@ TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices",
|
||||
TEST_CASE("RecipeSchematic: reset re-locks a previously awarded recipe schematic",
|
||||
"[recipe_schematic]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
awaitRecipeUnlock(sim, "quick_circuit");
|
||||
REQUIRE(sim.isRecipeUnlocked("quick_circuit"));
|
||||
|
||||
@@ -272,7 +268,7 @@ TEST_CASE("RecipeSchematic: reset re-locks a previously awarded recipe schematic
|
||||
TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items accessible",
|
||||
"[recipe_schematic]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
sim.reset();
|
||||
|
||||
REQUIRE(sim.isRecipeUnlocked("premium_circuit"));
|
||||
@@ -286,7 +282,7 @@ TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items
|
||||
TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and empty for level-ups",
|
||||
"[recipe_schematic]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
for (int i = 0; i < 100; ++i)
|
||||
{
|
||||
@@ -310,8 +306,8 @@ TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and
|
||||
TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds matches recipes that actually become unlocked",
|
||||
"[recipe_schematic]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
const GameConfig cfg = loadConfig();
|
||||
Simulation sim(loadTestConfig());
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
|
||||
auto unlockedTrackedRecipeIds = [&]()
|
||||
{
|
||||
@@ -365,7 +361,7 @@ TEST_CASE("SchematicDrop: an owned ship schematic is never offered again",
|
||||
{
|
||||
// repair_ship has unlock_at_station_level = 0 in the test config, so it is
|
||||
// locked at start and becomes eligible once a station set is destroyed.
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
|
||||
|
||||
bool wasUnlocked = false;
|
||||
|
||||
@@ -16,14 +16,10 @@
|
||||
#include "ReplayRecorder.h"
|
||||
#include "Rotation.h"
|
||||
#include "Simulation.h"
|
||||
#include "TestConfig.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
std::string tempOutputDir()
|
||||
{
|
||||
return (QDir::tempPath() + "/dota_factory_replay_playback_test").toStdString();
|
||||
@@ -148,7 +144,7 @@ TEST_CASE("a recorded run replays to byte-identical state with no desync", "[rep
|
||||
std::string replayPath;
|
||||
std::uint64_t recordedFinalChecksum = 0;
|
||||
{
|
||||
Simulation rec(loadConfig(), seed);
|
||||
Simulation rec(loadTestConfig(), seed);
|
||||
CommandManager manager(rec);
|
||||
std::unique_ptr<ReplayRecorder> recorder =
|
||||
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
|
||||
@@ -179,7 +175,7 @@ TEST_CASE("a recorded run replays to byte-identical state with no desync", "[rep
|
||||
REQUIRE_FALSE(parsed->entries.empty());
|
||||
|
||||
// --- Replay it. ---
|
||||
Simulation play(loadConfig(), parsed->header.seed);
|
||||
Simulation play(loadTestConfig(), parsed->header.seed);
|
||||
ReplayPlayer player(play, parsed->entries);
|
||||
player.start();
|
||||
while (!player.isFinished())
|
||||
@@ -201,7 +197,7 @@ TEST_CASE("ReplayPlayer reports a desync when a checksum is corrupted", "[replay
|
||||
|
||||
std::string replayPath;
|
||||
{
|
||||
Simulation rec(loadConfig(), seed);
|
||||
Simulation rec(loadTestConfig(), seed);
|
||||
CommandManager manager(rec);
|
||||
std::unique_ptr<ReplayRecorder> recorder =
|
||||
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
|
||||
@@ -228,7 +224,7 @@ TEST_CASE("ReplayPlayer reports a desync when a checksum is corrupted", "[replay
|
||||
}
|
||||
REQUIRE(corrupted);
|
||||
|
||||
Simulation play(loadConfig(), parsed->header.seed);
|
||||
Simulation play(loadTestConfig(), parsed->header.seed);
|
||||
ReplayPlayer player(play, parsed->entries);
|
||||
player.start();
|
||||
while (!player.isFinished())
|
||||
@@ -250,7 +246,7 @@ TEST_CASE("a long recorded run (through waves and combat) replays with no desync
|
||||
std::string replayPath;
|
||||
std::uint64_t recordedFinalChecksum = 0;
|
||||
{
|
||||
Simulation rec(loadConfig(), seed);
|
||||
Simulation rec(loadTestConfig(), seed);
|
||||
CommandManager manager(rec);
|
||||
std::unique_ptr<ReplayRecorder> recorder =
|
||||
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
|
||||
@@ -275,7 +271,7 @@ TEST_CASE("a long recorded run (through waves and combat) replays with no desync
|
||||
const std::optional<ParsedReplay> parsed = readReplayFile(replayPath);
|
||||
REQUIRE(parsed.has_value());
|
||||
|
||||
Simulation play(loadConfig(), parsed->header.seed);
|
||||
Simulation play(loadTestConfig(), parsed->header.seed);
|
||||
ReplayPlayer player(play, parsed->entries);
|
||||
player.start();
|
||||
while (!player.isFinished())
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "GameConfig.h"
|
||||
#include "ReplayRecorder.h"
|
||||
#include "Simulation.h"
|
||||
#include "TestConfig.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -144,7 +145,7 @@ TEST_CASE("ReplayRecorder writes a well-formed file", "[replay]")
|
||||
|
||||
TEST_CASE("CommandManager records commands and an initial checksum on drain", "[replay]")
|
||||
{
|
||||
Simulation sim(ConfigLoader::loadFromDirectory(CONFIG_DIR), 7u);
|
||||
Simulation sim(loadTestConfig(), 7u);
|
||||
CommandManager manager(sim);
|
||||
|
||||
std::unique_ptr<ReplayRecorder> recorder =
|
||||
@@ -187,7 +188,7 @@ TEST_CASE("ReplayRecorder startNewRun rolls to a new file", "[replay]")
|
||||
|
||||
TEST_CASE("CommandManager rolls the replay file on a Reset command", "[replay]")
|
||||
{
|
||||
Simulation sim(ConfigLoader::loadFromDirectory(CONFIG_DIR), 1u);
|
||||
Simulation sim(loadTestConfig(), 1u);
|
||||
CommandManager manager(sim);
|
||||
|
||||
std::unique_ptr<ReplayRecorder> recorder =
|
||||
@@ -197,7 +198,7 @@ TEST_CASE("CommandManager rolls the replay file on a Reset command", "[replay]")
|
||||
const std::string firstPath = recorderPtr->getCurrentFilePath();
|
||||
|
||||
std::shared_ptr<ResetCommand> reset = std::make_shared<ResetCommand>();
|
||||
reset->config = std::make_shared<GameConfig>(ConfigLoader::loadFromDirectory(CONFIG_DIR));
|
||||
reset->config = std::make_shared<GameConfig>(loadTestConfig());
|
||||
reset->seed = 999u;
|
||||
manager.enqueue(reset);
|
||||
manager.drain();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "catch.hpp"
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
@@ -22,11 +23,7 @@
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "Tick.h"
|
||||
#include "WeaponComponent.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
#include "TestConfig.h"
|
||||
|
||||
static const ShipDef* findSchematic(const GameConfig& cfg, const std::string& id)
|
||||
{
|
||||
@@ -108,7 +105,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId,
|
||||
|
||||
TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
@@ -123,7 +120,7 @@ TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]")
|
||||
|
||||
TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
@@ -148,7 +145,7 @@ TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
|
||||
|
||||
TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
@@ -173,7 +170,7 @@ TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
|
||||
|
||||
TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
@@ -206,7 +203,7 @@ TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
|
||||
TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
|
||||
"[modules][shipyard]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
||||
REQUIRE(yardDef != nullptr);
|
||||
|
||||
@@ -222,7 +219,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
|
||||
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
|
||||
|
||||
const Building* b = sim.getBuildings().findBuilding(yardId);
|
||||
const Building* b = findBuilding(sim.getFactoryState(), yardId);
|
||||
REQUIRE(b != nullptr);
|
||||
// armor_plate needs 2 iron_ingot; interceptor needs 3 iron_ingot + 1 circuit_board
|
||||
// Total iron_ingot = 5, buffer cap = 2 * 5 = 10
|
||||
@@ -233,7 +230,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
|
||||
TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
|
||||
"[modules][shipyard]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
||||
@@ -247,7 +244,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
|
||||
fillMaterials(sim, yardId, *def, emptyLayout);
|
||||
sim.tick();
|
||||
|
||||
const Building* b1 = sim.getBuildings().findBuilding(yardId);
|
||||
const Building* b1 = findBuilding(sim.getFactoryState(), yardId);
|
||||
REQUIRE(b1 != nullptr);
|
||||
REQUIRE(b1->production.has_value());
|
||||
|
||||
@@ -261,7 +258,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
|
||||
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
|
||||
|
||||
const Building* b2 = sim.getBuildings().findBuilding(yardId);
|
||||
const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
|
||||
REQUIRE(b2 != nullptr);
|
||||
CHECK_FALSE(b2->production.has_value());
|
||||
}
|
||||
@@ -269,7 +266,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
|
||||
TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
|
||||
"[modules][shipyard]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
// The schematic carries a weapon in its (wave-only) default loadout. This
|
||||
@@ -312,7 +309,7 @@ TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
|
||||
|
||||
TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
||||
REQUIRE(yardDef != nullptr);
|
||||
|
||||
@@ -327,13 +324,13 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
|
||||
layout.placedModules.push_back(pm);
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
|
||||
|
||||
const Building* b1 = sim.getBuildings().findBuilding(yardId);
|
||||
const Building* b1 = findBuilding(sim.getFactoryState(), yardId);
|
||||
REQUIRE(b1 != nullptr);
|
||||
REQUIRE(b1->shipLayout.has_value());
|
||||
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId,"destroyer");
|
||||
|
||||
const Building* b2 = sim.getBuildings().findBuilding(yardId);
|
||||
const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
|
||||
REQUIRE(b2 != nullptr);
|
||||
CHECK_FALSE(b2->shipLayout.has_value());
|
||||
}
|
||||
@@ -341,7 +338,7 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
|
||||
TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
|
||||
"[modules][shipyard]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
||||
REQUIRE(yardDef != nullptr);
|
||||
|
||||
@@ -356,14 +353,14 @@ TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
|
||||
layout.placedModules.push_back(pm);
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
|
||||
|
||||
const Building* b1 = sim.getBuildings().findBuilding(yardId);
|
||||
const Building* b1 = findBuilding(sim.getFactoryState(), yardId);
|
||||
REQUIRE(b1 != nullptr);
|
||||
REQUIRE(b1->shipLayout.has_value());
|
||||
|
||||
// Re-selecting the same recipe must be a no-op and preserve the layout.
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
|
||||
|
||||
const Building* b2 = sim.getBuildings().findBuilding(yardId);
|
||||
const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
|
||||
REQUIRE(b2 != nullptr);
|
||||
REQUIRE(b2->shipLayout.has_value());
|
||||
REQUIRE(b2->shipLayout->placedModules.size() == 1);
|
||||
@@ -376,7 +373,7 @@ TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
|
||||
|
||||
TEST_CASE("Ship spawn: weapon_primer multiplies attack rate in simulation", "[modules]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
@@ -401,7 +398,7 @@ TEST_CASE("Ship spawn: weapon_primer multiplies attack rate in simulation", "[mo
|
||||
|
||||
TEST_CASE("Ship spawn: weapon_stabilizer multiplies attack range in simulation", "[modules]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
@@ -428,7 +425,7 @@ TEST_CASE("Ship spawn: weapon_stabilizer multiplies attack range in simulation",
|
||||
|
||||
TEST_CASE("Ship spawn: afterburner additive main_acceleration is converted m/s² to tiles/tick", "[modules]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
@@ -453,7 +450,7 @@ TEST_CASE("Ship spawn: afterburner additive main_acceleration is converted m/s²
|
||||
|
||||
TEST_CASE("Ship spawn: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/tick", "[modules]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
@@ -482,7 +479,7 @@ TEST_CASE("Ship spawn: maneuvering_thrusters additive maneuvering_acceleration i
|
||||
|
||||
TEST_CASE("calculateShipStats: weapon_primer multiplies attack rate in stats view", "[modules]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ShipDef* def = findSchematic(cfg, "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
@@ -506,7 +503,7 @@ TEST_CASE("calculateShipStats: weapon_primer multiplies attack rate in stats vie
|
||||
|
||||
TEST_CASE("calculateShipStats: weapon_stabilizer multiplies attack range in stats view", "[modules]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ShipDef* def = findSchematic(cfg, "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
@@ -532,7 +529,7 @@ TEST_CASE("calculateShipStats: weapon_stabilizer multiplies attack range in stat
|
||||
|
||||
TEST_CASE("calculateShipStats: afterburner additive main_acceleration is converted m/s² to tiles/s²", "[modules]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ShipDef* def = findSchematic(cfg, "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
@@ -556,7 +553,7 @@ TEST_CASE("calculateShipStats: afterburner additive main_acceleration is convert
|
||||
|
||||
TEST_CASE("calculateShipStats: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/s²", "[modules]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ShipDef* def = findSchematic(cfg, "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
|
||||
@@ -29,11 +29,7 @@
|
||||
#include "ShipSystem.h"
|
||||
#include "Tick.h"
|
||||
#include "WeaponComponent.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
#include "TestConfig.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -91,7 +87,7 @@ TEST_CASE("ShipSystem: interceptor spawn has weapon child and attack behavior, n
|
||||
"[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
|
||||
const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
@@ -115,7 +111,7 @@ TEST_CASE("ShipSystem: interceptor spawn has weapon child and attack behavior, n
|
||||
TEST_CASE("ShipSystem: enemy combat ship has no rally or retreat behavior", "[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
|
||||
const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f), /*isEnemy=*/true);
|
||||
@@ -129,7 +125,7 @@ TEST_CASE("ShipSystem: enemy combat ship has no rally or retreat behavior", "[sh
|
||||
TEST_CASE("ShipSystem: setRetreatEnabled(false) suppresses player retreat behavior", "[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
ss.setRetreatEnabled(false);
|
||||
|
||||
@@ -144,7 +140,7 @@ TEST_CASE("ShipSystem: setRetreatEnabled(false) suppresses player retreat behavi
|
||||
TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
|
||||
const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
@@ -166,7 +162,7 @@ TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]
|
||||
TEST_CASE("ShipSystem: interceptor hp matches config value", "[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
|
||||
const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
@@ -178,7 +174,7 @@ TEST_CASE("ShipSystem: interceptor hp matches config value", "[ship]")
|
||||
TEST_CASE("ShipSystem: interceptor maxSpeed_tpt matches config value / tileSize / kTickRateHz", "[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
|
||||
const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
@@ -196,7 +192,7 @@ TEST_CASE("ShipSystem: salvage_ship spawn with salvage module has cargo child an
|
||||
"[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
|
||||
const ShipLayoutConfig layout = makeSingleModuleLayout("salvager");
|
||||
@@ -212,7 +208,7 @@ TEST_CASE("ShipSystem: salvage_ship spawn with salvage module has cargo child an
|
||||
TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
|
||||
const ShipLayoutConfig layout = makeSingleModuleLayout("salvager");
|
||||
@@ -237,7 +233,7 @@ TEST_CASE("ShipSystem: repair_ship spawn with repair module has repair child and
|
||||
"[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
|
||||
const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool");
|
||||
@@ -252,7 +248,7 @@ TEST_CASE("ShipSystem: repair_ship spawn with repair module has repair child and
|
||||
TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas", "[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
|
||||
const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool");
|
||||
@@ -277,7 +273,7 @@ TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas",
|
||||
TEST_CASE("ShipSystem: spawned ships are valid entities", "[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
|
||||
const entt::entity e1 = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
@@ -292,7 +288,7 @@ TEST_CASE("ShipSystem: spawned ships are valid entities", "[ship]")
|
||||
TEST_CASE("ShipSystem: despawn removes the ship and its weapon children", "[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
|
||||
const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "catch.hpp"
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
@@ -16,11 +17,7 @@
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "Tick.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
#include "TestConfig.h"
|
||||
|
||||
// A ship starts unlocked iff no unlock group grants it (REQ-LOCK-EXPLICIT).
|
||||
static bool startsUnlocked(const GameConfig& cfg, const std::string& shipId)
|
||||
@@ -98,7 +95,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def
|
||||
TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
|
||||
"[shipyard]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
const ShipDef* def = findAvailableSchematic(sim.getConfig());
|
||||
REQUIRE(def != nullptr);
|
||||
@@ -143,7 +140,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
|
||||
|
||||
TEST_CASE("Shipyard: does not spawn without a schematic set", "[shipyard]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
||||
REQUIRE(yardDef != nullptr);
|
||||
@@ -159,7 +156,7 @@ TEST_CASE("Shipyard: does not spawn without a schematic set", "[shipyard]")
|
||||
|
||||
TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
const ShipDef* def = findAvailableSchematic(sim.getConfig());
|
||||
REQUIRE(def != nullptr);
|
||||
@@ -183,7 +180,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
|
||||
|
||||
TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipyard]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
const ShipDef* def = findAvailableSchematic(sim.getConfig());
|
||||
REQUIRE(def != nullptr);
|
||||
@@ -218,7 +215,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
|
||||
// Verify the shipyard production field cleared (i.e. the cycle completed
|
||||
// and is not still running).
|
||||
bool productionCleared = false;
|
||||
for (const Building& b : sim.getBuildings().getAllBuildings())
|
||||
for (const Building& b : getAllBuildings(sim.getFactoryState()))
|
||||
{
|
||||
if (b.id == yardId)
|
||||
{
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
#include "Simulation.h"
|
||||
#include "Tick.h"
|
||||
#include "TickDriver.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
#include "TestConfig.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulation
|
||||
@@ -17,14 +13,14 @@ static GameConfig loadConfig()
|
||||
|
||||
TEST_CASE("Simulation::currentTick starts at 0", "[simulation]")
|
||||
{
|
||||
const Simulation sim(loadConfig());
|
||||
const Simulation sim(loadTestConfig());
|
||||
|
||||
REQUIRE(sim.getCurrentTick() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Simulation::tick increments currentTick by 1", "[simulation]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
sim.tick();
|
||||
|
||||
@@ -33,7 +29,7 @@ TEST_CASE("Simulation::tick increments currentTick by 1", "[simulation]")
|
||||
|
||||
TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
for (int i = 0; i < 10; ++i)
|
||||
{
|
||||
@@ -45,14 +41,14 @@ TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]")
|
||||
|
||||
TEST_CASE("Simulation::drainBeamFiredEvents returns empty initially", "[simulation]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
REQUIRE(sim.drainBeamFiredEvents().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Simulation::drainBeamFiredEvents clears queue on drain", "[simulation]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
// First drain: empty.
|
||||
sim.drainBeamFiredEvents();
|
||||
@@ -63,7 +59,7 @@ TEST_CASE("Simulation::drainBeamFiredEvents clears queue on drain", "[simulation
|
||||
|
||||
TEST_CASE("Simulation::hasSchematicChoicesPending returns false initially", "[simulation]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
Simulation sim(loadTestConfig());
|
||||
|
||||
REQUIRE_FALSE(sim.hasSchematicChoicesPending());
|
||||
}
|
||||
|
||||
15
src/test/TestConfig.h
Normal file
15
src/test/TestConfig.h
Normal file
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include "ConfigLoader.h"
|
||||
#include "GameConfig.h"
|
||||
|
||||
// Loads the test fixture config set (CONFIG_DIR points at bin/test/data/config
|
||||
// for the test target). Shared by every test that needs a full GameConfig, so
|
||||
// the one-liner is not repeated per translation unit.
|
||||
//
|
||||
// Like SimulationTestAccess.h this header lives under src/test and is
|
||||
// deliberately off the lib/ui/app include path.
|
||||
inline GameConfig loadTestConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
@@ -2,15 +2,11 @@
|
||||
|
||||
#include "ConfigLoader.h"
|
||||
#include "ThreatCostCalculator.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
#include "TestConfig.h"
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: miner item threat equals duration", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
CHECK(table.itemThreat.at("iron_ore") == Approx(1.0));
|
||||
@@ -19,7 +15,7 @@ TEST_CASE("ThreatCostCalculator: miner item threat equals duration", "[threat]")
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: smelter item threat includes input costs", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
// iron_ingot: duration 2.0 + iron_ore(1.0) * 2 = 4.0
|
||||
@@ -30,7 +26,7 @@ TEST_CASE("ThreatCostCalculator: smelter item threat includes input costs", "[th
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: assembler takes max across recipes", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
// circuit_board has three non-reprocessing recipes:
|
||||
@@ -43,7 +39,7 @@ TEST_CASE("ThreatCostCalculator: assembler takes max across recipes", "[threat]"
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: scrap threat is 1 / scrap_per_threat", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
// REQ-THREAT-SCRAP: scrap threat is the constant 1 / world.scrap_per_threat.
|
||||
@@ -54,7 +50,7 @@ TEST_CASE("ThreatCostCalculator: scrap threat is 1 / scrap_per_threat", "[threat
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: reprocessing-only item threat", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
// advanced_alloy: reprocessing recipe with scrap*5, duration 3.0, probability 0.1
|
||||
@@ -64,7 +60,7 @@ TEST_CASE("ThreatCostCalculator: reprocessing-only item threat", "[threat]")
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: ship threat with default modules", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
// interceptor: 10 + iron_ingot(4)*3 + circuit_board(28)*1 + laser_cannon(5 + 4*1) = 59.0
|
||||
@@ -80,7 +76,7 @@ TEST_CASE("ThreatCostCalculator: ship threat with default modules", "[threat]")
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: ship threat with custom modules", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
// interceptor base: 10 + iron_ingot(4)*3 + circuit_board(28)*1 = 50.0
|
||||
@@ -101,7 +97,7 @@ TEST_CASE("ThreatCostCalculator: ship threat with custom modules", "[threat]")
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: unknown ship returns zero", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
double threat = calculateShipThreatCost(table, cfg, "nonexistent_ship", {});
|
||||
@@ -113,7 +109,7 @@ TEST_CASE("ThreatCostCalculator: unknown ship returns zero", "[threat]")
|
||||
// be excluded. iron_ingot threat must not be inflated by the scrap path.
|
||||
TEST_CASE("ThreatCostCalculator: scrap-consuming recipe excluded when scrap-free recipe exists", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
// scrap_iron recipe: duration=1.0, 1 scrap (threat=1.0) -> 1 iron_ingot.
|
||||
@@ -132,7 +128,7 @@ TEST_CASE("ThreatCostCalculator: scrap-consuming recipe excluded when scrap-free
|
||||
// Per-unit threat = (3.0 + iron_ore(1.0)*1) / 2 = 4.0 / 2 = 2.0.
|
||||
TEST_CASE("ThreatCostCalculator: per-unit division by output amount", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
CHECK(table.itemThreat.at("dual_wire") == Approx(2.0));
|
||||
@@ -146,7 +142,7 @@ TEST_CASE("ThreatCostCalculator: per-unit division by output amount", "[threat]"
|
||||
// downstream_product = 2.0 + 80.0*1 = 82.0.
|
||||
TEST_CASE("ThreatCostCalculator: downstream-of-reprocessing item resolves via fixpoint", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
CHECK(table.itemThreat.at("advanced_alloy") == Approx(80.0));
|
||||
@@ -161,7 +157,7 @@ TEST_CASE("ThreatCostCalculator: downstream-of-reprocessing item resolves via fi
|
||||
// expected threat = max(2.0, 29.0) = 29.0, not 2.0.
|
||||
TEST_CASE("ThreatCostCalculator: staggered recipes committed only when all computable", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
CHECK(table.itemThreat.at("staggered_item") == Approx(29.0));
|
||||
|
||||
@@ -19,15 +19,11 @@
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "StationBodyComponent.h"
|
||||
#include "UnlocksConfig.h"
|
||||
#include "TestConfig.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
void killEnemyStations(Simulation& sim)
|
||||
{
|
||||
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
@@ -42,7 +38,7 @@ void killEnemyStations(Simulation& sim)
|
||||
// bay together, gated to the given station level.
|
||||
GameConfig configWithSalvageGroup(int stationLevel)
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
GameConfig cfg = loadTestConfig();
|
||||
cfg.unlocks.groups.clear();
|
||||
cfg.unlocks.groups.push_back(
|
||||
UnlockGroupDef{"salvage_operations", stationLevel, {}, {}, {"salvager"}, {"salvage_bay"}, {}});
|
||||
|
||||
@@ -22,15 +22,11 @@
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "StationBodyComponent.h"
|
||||
#include "UnlocksConfig.h"
|
||||
#include "TestConfig.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
UnlockGroupDef& findGroup(GameConfig& cfg, const std::string& id)
|
||||
{
|
||||
for (UnlockGroupDef& group : cfg.unlocks.groups)
|
||||
@@ -85,7 +81,7 @@ TEST_CASE("UnlockPrereq: a group gated behind a locked ship is withheld from the
|
||||
// quick_circuit (recipe group, level 0) would normally be eligible at the
|
||||
// first station destruction. Gate it behind the repair_ship group (level 0,
|
||||
// locked at game start).
|
||||
GameConfig cfg = loadConfig();
|
||||
GameConfig cfg = loadTestConfig();
|
||||
findGroup(cfg, "quick_circuit").requiredGroupIds = {"repair_ship"};
|
||||
|
||||
Simulation sim(std::move(cfg), 123);
|
||||
@@ -103,7 +99,7 @@ TEST_CASE("UnlockPrereq: a group gated behind a locked ship is withheld from the
|
||||
TEST_CASE("UnlockPrereq: the gated group becomes eligible once its prerequisite is awarded",
|
||||
"[unlock_prereq]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
GameConfig cfg = loadTestConfig();
|
||||
findGroup(cfg, "quick_circuit").requiredGroupIds = {"repair_ship"};
|
||||
|
||||
Simulation sim(std::move(cfg), 123);
|
||||
@@ -126,7 +122,7 @@ TEST_CASE("UnlockPrereq: a module group gated behind another module is withheld
|
||||
{
|
||||
// Make laser_cannon and armor_plate (both start-unlocked in the test config)
|
||||
// lockable via new unlock groups, with armor_plate gated behind laser_cannon.
|
||||
GameConfig cfg = loadConfig();
|
||||
GameConfig cfg = loadTestConfig();
|
||||
cfg.unlocks.groups.push_back(
|
||||
UnlockGroupDef{"laser_cannon", 0, {}, {}, {"laser_cannon"}, {}, {}});
|
||||
cfg.unlocks.groups.push_back(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "catch.hpp"
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <random>
|
||||
|
||||
@@ -25,11 +26,7 @@
|
||||
#include "Tick.h"
|
||||
#include "ThreatCostCalculator.h"
|
||||
#include "WaveSystem.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
#include "TestConfig.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Threat accumulation
|
||||
@@ -37,7 +34,7 @@ static GameConfig loadConfig()
|
||||
|
||||
TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
std::mt19937 rng(42);
|
||||
WaveSystem ws(cfg, rng);
|
||||
|
||||
@@ -55,7 +52,7 @@ TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]")
|
||||
TEST_CASE("WaveSystem: threatAccumulationRate matches the rate formula outside quiet windows",
|
||||
"[wave]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
std::mt19937 rng(42);
|
||||
WaveSystem ws(cfg, rng);
|
||||
|
||||
@@ -65,7 +62,7 @@ TEST_CASE("WaveSystem: threatAccumulationRate matches the rate formula outside q
|
||||
|
||||
TEST_CASE("WaveSystem: threatAccumulationRate is 0 during a quiet window", "[wave]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
GameConfig cfg = loadTestConfig();
|
||||
// Start with the boss countdown already at the pre-boss quiet threshold.
|
||||
cfg.world.waves.bossCountdownSeconds = cfg.world.waves.bossQuietBeforeSeconds;
|
||||
std::mt19937 rng(42);
|
||||
@@ -83,7 +80,7 @@ TEST_CASE("WaveSystem: threatAccumulationRate is 0 during a quiet window", "[wav
|
||||
|
||||
TEST_CASE("WaveSystem: generation starts at 0 and increments on station destruction", "[wave]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
std::mt19937 rng(42);
|
||||
WaveSystem ws(cfg, rng);
|
||||
|
||||
@@ -100,11 +97,11 @@ TEST_CASE("WaveSystem: generation starts at 0 and increments on station destruct
|
||||
|
||||
TEST_CASE("WaveSystem: Simulation pre-places HQ + 2 player + 2 enemy stations", "[wave]")
|
||||
{
|
||||
const Simulation sim(loadConfig(), 42);
|
||||
const Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
// HQ is still a Building (for belt integration).
|
||||
int hqCount = 0;
|
||||
for (const Building& b : sim.getBuildings().getAllBuildings())
|
||||
for (const Building& b : getAllBuildings(sim.getFactoryState()))
|
||||
{
|
||||
if (b.type == BuildingType::Hq) { ++hqCount; }
|
||||
}
|
||||
@@ -126,7 +123,7 @@ TEST_CASE("WaveSystem: Simulation pre-places HQ + 2 player + 2 enemy stations",
|
||||
|
||||
TEST_CASE("WaveSystem: HQ has correct initial HP from config", "[wave]")
|
||||
{
|
||||
const Simulation sim(loadConfig(), 42);
|
||||
const Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
const float expectedHp =
|
||||
static_cast<float>(sim.getConfig().stations.hq.hpFormula.evaluate(0.0));
|
||||
@@ -145,9 +142,9 @@ TEST_CASE("WaveSystem: HQ has correct initial HP from config", "[wave]")
|
||||
|
||||
TEST_CASE("WaveSystem: HQ anchor is at asteroid right edge", "[wave]")
|
||||
{
|
||||
const Simulation sim(loadConfig(), 42);
|
||||
const Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
for (const Building& b : sim.getBuildings().getAllBuildings())
|
||||
for (const Building& b : getAllBuildings(sim.getFactoryState()))
|
||||
{
|
||||
if (b.type != BuildingType::Hq) { continue; }
|
||||
// Rightmost body cell must be at x = -1 (asteroid right edge).
|
||||
@@ -162,7 +159,7 @@ TEST_CASE("WaveSystem: HQ anchor is at asteroid right edge", "[wave]")
|
||||
|
||||
TEST_CASE("WaveSystem: player stations have weapon set", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
int armedPlayerStations = 0;
|
||||
sim.getAdmin().forEach<WeaponComponent, ModuleOwnerComponent>(
|
||||
@@ -183,7 +180,7 @@ TEST_CASE("WaveSystem: player stations have weapon set", "[wave]")
|
||||
|
||||
TEST_CASE("WaveSystem: enemy stations have weapon set", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
int armedEnemyStations = 0;
|
||||
sim.getAdmin().forEach<WeaponComponent, ModuleOwnerComponent>(
|
||||
@@ -208,7 +205,7 @@ TEST_CASE("WaveSystem: enemy stations have weapon set", "[wave]")
|
||||
|
||||
TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
// The maximum gap is gapMaxSeconds = 45s → 1350 ticks.
|
||||
// Run 1500 ticks to guarantee at least one wave has triggered.
|
||||
@@ -234,7 +231,7 @@ TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave]
|
||||
|
||||
TEST_CASE("WaveSystem: all ships have positive dynamic threat cost", "[wave]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
|
||||
for (const ShipDef& def : cfg.ships.ships)
|
||||
{
|
||||
@@ -250,7 +247,7 @@ TEST_CASE("WaveSystem: all ships have positive dynamic threat cost", "[wave]")
|
||||
|
||||
TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
// Damage both enemy stations to 0.
|
||||
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
@@ -273,7 +270,7 @@ TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]"
|
||||
|
||||
TEST_CASE("WaveSystem: push generates pending schematic choices", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
|
||||
@@ -291,7 +288,7 @@ TEST_CASE("WaveSystem: push generates pending schematic choices", "[wave]")
|
||||
|
||||
TEST_CASE("WaveSystem: push schematic choices have valid ids", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
|
||||
@@ -339,7 +336,7 @@ TEST_CASE("WaveSystem: push schematic choices have valid ids", "[wave]")
|
||||
|
||||
TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
|
||||
@@ -359,7 +356,7 @@ TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
|
||||
|
||||
TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
|
||||
@@ -375,7 +372,7 @@ TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]
|
||||
|
||||
TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
Simulation sim(loadTestConfig(), 42);
|
||||
|
||||
// Record the X position of the initial enemy stations.
|
||||
int initialX = std::numeric_limits<int>::min();
|
||||
|
||||
@@ -27,7 +27,6 @@ BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidge
|
||||
: QWidget(parent)
|
||||
, m_sim(sim)
|
||||
, m_config(config)
|
||||
, m_currentBlocks(0)
|
||||
{
|
||||
QVBoxLayout* layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(4, 4, 4, 4);
|
||||
@@ -71,9 +70,8 @@ void BlueprintPanel::onSelectionChanged(const std::vector<BuildingId>& ids)
|
||||
refreshButtonStates();
|
||||
}
|
||||
|
||||
void BlueprintPanel::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event)
|
||||
void BlueprintPanel::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
|
||||
{
|
||||
m_currentBlocks = event->blocks;
|
||||
refreshButtonStates();
|
||||
}
|
||||
|
||||
@@ -247,10 +245,12 @@ void BlueprintPanel::refreshButtonStates()
|
||||
// A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE).
|
||||
m_createBtn->setEnabled(selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds));
|
||||
|
||||
const int blocks = m_sim->getBuildingBlocksStock();
|
||||
|
||||
for (int i = 0; i < static_cast<int>(m_blueprintButtons.size()); ++i)
|
||||
{
|
||||
const int cost = computeBlueprintCost(m_blueprints[static_cast<std::size_t>(i)]);
|
||||
const bool canAfford = m_currentBlocks >= cost;
|
||||
const bool canAfford = blocks >= cost;
|
||||
m_blueprintButtons[static_cast<std::size_t>(i)]->setEnabled(
|
||||
canAfford || m_activeIndex == i);
|
||||
}
|
||||
|
||||
@@ -53,10 +53,11 @@ private:
|
||||
void loadFromDisk();
|
||||
void saveToDisk() const;
|
||||
|
||||
// The simulation is the single source of truth for the block stock; the
|
||||
// change event is only a refresh signal.
|
||||
Simulation* m_sim;
|
||||
const GameConfig* m_config;
|
||||
std::vector<BuildingId> m_selectedBuildingIds;
|
||||
int m_currentBlocks;
|
||||
std::optional<int> m_activeIndex; // nullopt = no blueprint selected
|
||||
std::vector<Blueprint> m_blueprints;
|
||||
std::vector<QPushButton*> m_blueprintButtons;
|
||||
|
||||
@@ -184,13 +184,12 @@ namespace
|
||||
|
||||
BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config,
|
||||
const std::string& iconDir,
|
||||
const std::string& itemsIconDir, QWidget* parent)
|
||||
ItemIconCache* itemIcons, QWidget* parent)
|
||||
: QWidget(parent)
|
||||
, m_sim(sim)
|
||||
, m_config(config)
|
||||
, m_iconDir(iconDir)
|
||||
, m_itemIcons(std::make_unique<ItemIconCache>(
|
||||
QString::fromStdString(itemsIconDir)))
|
||||
, m_itemIcons(itemIcons)
|
||||
{
|
||||
QGridLayout* layout = new QGridLayout(this);
|
||||
layout->setSpacing(4);
|
||||
|
||||
@@ -32,11 +32,12 @@ class BuildButtonGrid : public QWidget,
|
||||
|
||||
public:
|
||||
// iconDir is the directory holding the per-building "<id>.svg" chip icons
|
||||
// (REQ-UI-BUILD-GRID); itemsIconDir holds the per-item icons and supplies the
|
||||
// building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Both are
|
||||
// read from disk at runtime, like the config files.
|
||||
// (REQ-UI-BUILD-GRID), read from disk at runtime like the config files.
|
||||
// itemIcons is the window-wide per-item icon cache and supplies the
|
||||
// building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Not
|
||||
// owned; must outlive this widget.
|
||||
BuildButtonGrid(Simulation* sim, const GameConfig* config,
|
||||
const std::string& iconDir, const std::string& itemsIconDir,
|
||||
const std::string& iconDir, ItemIconCache* itemIcons,
|
||||
QWidget* parent = nullptr);
|
||||
~BuildButtonGrid() override;
|
||||
|
||||
@@ -65,7 +66,7 @@ private:
|
||||
Simulation* m_sim;
|
||||
const GameConfig* m_config;
|
||||
std::string m_iconDir;
|
||||
std::unique_ptr<ItemIconCache> m_itemIcons;
|
||||
ItemIconCache* m_itemIcons; // Not owned; lives in MainWindow.
|
||||
std::vector<BuildingType> m_types;
|
||||
std::vector<QPushButton*> m_buttons;
|
||||
std::map<BuildingType, int> m_costs;
|
||||
|
||||
@@ -4,10 +4,12 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h
|
||||
@@ -29,6 +31,7 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp
|
||||
|
||||
403
src/ui/FieldSelectionPanel.cpp
Normal file
403
src/ui/FieldSelectionPanel.cpp
Normal file
@@ -0,0 +1,403 @@
|
||||
#include "FieldSelectionPanel.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
#include <QFont>
|
||||
#include <QLabel>
|
||||
#include <QStringList>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "DebrisSystem.h"
|
||||
#include "DisplayName.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "GameConfig.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "ShipStatsCalculator.h"
|
||||
#include "ShipStatsPanel.h"
|
||||
#include "Simulation.h"
|
||||
#include "StationBodyComponent.h"
|
||||
#include "ThreatCostCalculator.h"
|
||||
#include "WeaponComponent.h"
|
||||
|
||||
|
||||
FieldSelectionPanel::FieldSelectionPanel(Simulation* sim,
|
||||
const GameConfig* config,
|
||||
QWidget* parent)
|
||||
: QWidget(parent)
|
||||
, m_sim(sim)
|
||||
, m_config(config)
|
||||
{
|
||||
// Zero margins and the same spacing as the enclosing SelectedBuildingPanel layout, so
|
||||
// nesting the field widgets in this panel leaves their geometry unchanged.
|
||||
m_layout = new QVBoxLayout(this);
|
||||
m_layout->setContentsMargins(0, 0, 0, 0);
|
||||
m_layout->setSpacing(4);
|
||||
m_layout->setAlignment(Qt::AlignTop);
|
||||
|
||||
m_entityTitleLabel = new QLabel(this);
|
||||
QFont titleFont = m_entityTitleLabel->font();
|
||||
titleFont.setBold(true);
|
||||
m_entityTitleLabel->setFont(titleFont);
|
||||
m_layout->addWidget(m_entityTitleLabel);
|
||||
m_entityTitleLabel->hide();
|
||||
|
||||
m_entityStatsPanel = new ShipStatsPanel(config, this);
|
||||
m_layout->addWidget(m_entityStatsPanel);
|
||||
m_entityStatsPanel->hide();
|
||||
|
||||
m_stationStatsLabel = new QLabel(this);
|
||||
m_stationStatsLabel->setWordWrap(true);
|
||||
m_layout->addWidget(m_stationStatsLabel);
|
||||
m_stationStatsLabel->hide();
|
||||
|
||||
m_entitySummaryLabel = new QLabel(this);
|
||||
m_entitySummaryLabel->setWordWrap(true);
|
||||
m_layout->addWidget(m_entitySummaryLabel);
|
||||
m_entitySummaryLabel->hide();
|
||||
|
||||
m_scrapLabel = new QLabel(this);
|
||||
m_layout->addWidget(m_scrapLabel);
|
||||
m_scrapLabel->hide();
|
||||
|
||||
hide();
|
||||
|
||||
registerForEvents();
|
||||
}
|
||||
|
||||
FieldSelectionPanel::~FieldSelectionPanel()
|
||||
{
|
||||
unregisterForEvents();
|
||||
}
|
||||
|
||||
void FieldSelectionPanel::setSelectedEntities(const std::vector<entt::entity>& entities)
|
||||
{
|
||||
m_selectedEntities = entities;
|
||||
rebuild();
|
||||
}
|
||||
|
||||
void FieldSelectionPanel::setSelectedDebris(const std::vector<entt::entity>& debris)
|
||||
{
|
||||
m_selectedDebris = debris;
|
||||
rebuild();
|
||||
}
|
||||
|
||||
void FieldSelectionPanel::clearSelection()
|
||||
{
|
||||
m_selectedEntities.clear();
|
||||
m_selectedDebris.clear();
|
||||
rebuild();
|
||||
}
|
||||
|
||||
bool FieldSelectionPanel::hasSelection() const
|
||||
{
|
||||
return !m_selectedEntities.empty() || !m_selectedDebris.empty();
|
||||
}
|
||||
|
||||
void FieldSelectionPanel::hideAllWidgets()
|
||||
{
|
||||
m_entityTitleLabel->hide();
|
||||
m_entityStatsPanel->hide();
|
||||
m_stationStatsLabel->hide();
|
||||
m_entitySummaryLabel->hide();
|
||||
m_scrapLabel->hide();
|
||||
}
|
||||
|
||||
void FieldSelectionPanel::rebuild()
|
||||
{
|
||||
if (!hasSelection())
|
||||
{
|
||||
// Nothing in the field category: take no space, leaving the panel to whatever
|
||||
// the building category shows (REQ-UI-SELECTION-CATEGORIES).
|
||||
hideAllWidgets();
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
show();
|
||||
|
||||
EntityAdmin& admin = m_sim->getAdmin();
|
||||
|
||||
// A full single-object stats panel is shown only for a lone field object: one actor
|
||||
// with no debris, or one piece of debris with no actors. As soon as the selection holds
|
||||
// more than one object (multiple actors, multiple debris, or actors plus debris), the
|
||||
// panel switches to the compact count summary (REQ-UI-FIELD-MULTI-SELECTION).
|
||||
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
|
||||
{
|
||||
m_entitySummaryLabel->hide();
|
||||
m_scrapLabel->hide();
|
||||
const entt::entity entity = m_selectedEntities.front();
|
||||
if (admin.isValid(entity) && admin.hasAll<ShipIdentityComponent>(entity))
|
||||
{
|
||||
buildEntityShip(entity);
|
||||
}
|
||||
else if (admin.isValid(entity) && admin.hasAll<StationBodyComponent>(entity))
|
||||
{
|
||||
buildEntityStation(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
m_entityTitleLabel->hide();
|
||||
m_entityStatsPanel->hide();
|
||||
m_stationStatsLabel->hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
|
||||
{
|
||||
// Single piece of debris: a "Debris" heading plus a "Scrap" stat row, styled like
|
||||
// the ship/station stats panels (REQ-UI-DEBRIS-PANEL).
|
||||
m_entitySummaryLabel->hide();
|
||||
m_entityStatsPanel->hide();
|
||||
m_stationStatsLabel->hide();
|
||||
buildDebrisSingle();
|
||||
return;
|
||||
}
|
||||
|
||||
// More than one field object: a compact count summary. buildEntitySummary() appends the
|
||||
// "Debris x N" and "Scrap x N" lines when debris is part of the selection.
|
||||
m_entityTitleLabel->hide();
|
||||
m_entityStatsPanel->hide();
|
||||
m_stationStatsLabel->hide();
|
||||
m_scrapLabel->hide();
|
||||
buildEntitySummary();
|
||||
}
|
||||
|
||||
void FieldSelectionPanel::refreshDisplay()
|
||||
{
|
||||
if (!hasSelection()) { return; }
|
||||
|
||||
// Keep the live values current: the single-actor stats panel, the single-debris stats
|
||||
// panel (whose Scrap row shrinks as it is collected), or the count summary (whose Scrap
|
||||
// line shrinks likewise) — matching the layout chosen by rebuild()
|
||||
// (REQ-UI-SHIP-STATS-PANEL, REQ-UI-DEBRIS-PANEL).
|
||||
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
|
||||
{
|
||||
refreshEntityStats();
|
||||
}
|
||||
else if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
|
||||
{
|
||||
buildDebrisSingle();
|
||||
}
|
||||
else
|
||||
{
|
||||
buildEntitySummary();
|
||||
}
|
||||
}
|
||||
|
||||
void FieldSelectionPanel::buildDebrisSingle()
|
||||
{
|
||||
// "Debris" heading + a single "Scrap" stat row for the piece's remaining amount,
|
||||
// mirroring the single-actor stats panels (REQ-UI-DEBRIS-PANEL).
|
||||
m_entityTitleLabel->setText(tr("Debris"));
|
||||
m_entityTitleLabel->show();
|
||||
m_scrapLabel->setText(tr("Scrap: %1").arg(selectedDebrisScrapTotal()));
|
||||
m_scrapLabel->show();
|
||||
}
|
||||
|
||||
void FieldSelectionPanel::buildEntitySummary()
|
||||
{
|
||||
EntityAdmin& admin = m_sim->getAdmin();
|
||||
|
||||
// Group actors by faction + kind + ship schematic, preserving first-seen order
|
||||
// (REQ-UI-FIELD-MULTI-SELECTION).
|
||||
std::vector<QString> keys;
|
||||
std::map<QString, int> counts;
|
||||
std::map<QString, QString> labels;
|
||||
|
||||
for (entt::entity entity : m_selectedEntities)
|
||||
{
|
||||
if (!admin.isValid(entity)) { continue; }
|
||||
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
|
||||
&& admin.get<FactionComponent>(entity).isEnemy;
|
||||
|
||||
QString key;
|
||||
QString label;
|
||||
if (admin.hasAll<ShipIdentityComponent>(entity))
|
||||
{
|
||||
const std::string& id = admin.get<ShipIdentityComponent>(entity).schematicId;
|
||||
const QString name = QString::fromStdString(toDisplayName(id));
|
||||
key = (isEnemy ? QStringLiteral("ship:enemy:") : QStringLiteral("ship:player:"))
|
||||
+ QString::fromStdString(id);
|
||||
label = isEnemy ? tr("Enemy %1").arg(name) : name;
|
||||
}
|
||||
else if (admin.hasAll<StationBodyComponent>(entity))
|
||||
{
|
||||
key = isEnemy ? QStringLiteral("station:enemy") : QStringLiteral("station:player");
|
||||
label = isEnemy ? tr("Enemy Defence Station") : tr("Player Defence Station");
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (counts.find(key) == counts.end())
|
||||
{
|
||||
keys.push_back(key);
|
||||
labels[key] = label;
|
||||
}
|
||||
counts[key] += 1;
|
||||
}
|
||||
|
||||
// One "<type> x <count>" line per group (matching the recipe tooltip and the building
|
||||
// multi-selection). No total-count header, consistent with the building panel. When
|
||||
// debris is part of the selection, a "Debris x <count>" line followed by a
|
||||
// "Scrap x <total>" line are appended into the same label so the line spacing is
|
||||
// uniform (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL).
|
||||
QStringList lines;
|
||||
for (const QString& key : keys)
|
||||
{
|
||||
lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]);
|
||||
}
|
||||
if (!m_selectedDebris.empty())
|
||||
{
|
||||
lines << tr("Debris x %1").arg(static_cast<int>(m_selectedDebris.size()));
|
||||
lines << scrapTotalText();
|
||||
}
|
||||
m_entitySummaryLabel->setText(lines.join('\n'));
|
||||
m_entitySummaryLabel->show();
|
||||
}
|
||||
|
||||
void FieldSelectionPanel::buildEntityShip(entt::entity entity)
|
||||
{
|
||||
EntityAdmin& admin = m_sim->getAdmin();
|
||||
const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(entity);
|
||||
const HealthComponent& health = admin.get<HealthComponent>(entity);
|
||||
|
||||
m_entityTitleLabel->setText(tr("Ship: %1")
|
||||
.arg(QString::fromStdString(identity.schematicId)));
|
||||
m_entityTitleLabel->show();
|
||||
|
||||
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
|
||||
m_entityStatsPanel->refreshFromLive(stats, health.hp);
|
||||
m_entityStatsPanel->setBehavior(
|
||||
admin.get<SelectedBehaviorComponent>(entity).winner);
|
||||
m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw);
|
||||
|
||||
const ShipDef* schematicDef =
|
||||
m_config->ships.findShipDef(identity.schematicId);
|
||||
if (schematicDef)
|
||||
{
|
||||
const double threat = calculateShipThreatCost(
|
||||
m_config->threatCosts, *m_config, schematicDef->id,
|
||||
schematicDef->defaultModules);
|
||||
m_entityStatsPanel->setThreatCost(threat);
|
||||
}
|
||||
|
||||
m_entityStatsPanel->show();
|
||||
|
||||
m_stationStatsLabel->hide();
|
||||
}
|
||||
|
||||
void FieldSelectionPanel::buildEntityStation(entt::entity entity)
|
||||
{
|
||||
EntityAdmin& admin = m_sim->getAdmin();
|
||||
const HealthComponent& health = admin.get<HealthComponent>(entity);
|
||||
|
||||
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
|
||||
&& admin.get<FactionComponent>(entity).isEnemy;
|
||||
m_entityTitleLabel->setText(isEnemy
|
||||
? tr("Enemy Defence Station")
|
||||
: tr("Player Defence Station"));
|
||||
m_entityTitleLabel->show();
|
||||
|
||||
float totalDps = 0.0f;
|
||||
float maxRange = 0.0f;
|
||||
bool hasWeapons = false;
|
||||
|
||||
admin.forEach<ModuleOwnerComponent, WeaponComponent>(
|
||||
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w)
|
||||
{
|
||||
if (owner.owner != entity) { return; }
|
||||
hasWeapons = true;
|
||||
totalDps += w.damage * w.fireRateHz;
|
||||
if (w.range_tiles > maxRange) { maxRange = w.range_tiles; }
|
||||
});
|
||||
|
||||
QString statsText = tr("HP: %1 / %2")
|
||||
.arg(static_cast<int>(health.hp + 0.5f))
|
||||
.arg(static_cast<int>(health.maxHp + 0.5f));
|
||||
|
||||
if (hasWeapons)
|
||||
{
|
||||
statsText += tr("\nDPS: %1").arg(QString::number(static_cast<double>(totalDps), 'f', 1));
|
||||
statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast<double>(maxRange), 'f', 1));
|
||||
}
|
||||
|
||||
m_stationStatsLabel->setText(statsText);
|
||||
m_stationStatsLabel->show();
|
||||
|
||||
m_entityStatsPanel->hide();
|
||||
}
|
||||
|
||||
void FieldSelectionPanel::refreshEntityStats()
|
||||
{
|
||||
// Only the single-actor stats panel needs a live refresh; the multi-actor summary is
|
||||
// static counts, and GameWorldView prunes dead/despawned actors and re-emits the
|
||||
// selection (REQ-UI-ENTITY-CLICK-SELECT), so the panel does not mutate it here.
|
||||
if (m_selectedEntities.size() != 1) { return; }
|
||||
|
||||
EntityAdmin& admin = m_sim->getAdmin();
|
||||
const entt::entity entity = m_selectedEntities.front();
|
||||
|
||||
if (!admin.isValid(entity) || !admin.hasAll<HealthComponent>(entity)) { return; }
|
||||
const HealthComponent& health = admin.get<HealthComponent>(entity);
|
||||
if (health.hp <= 0.0f) { return; }
|
||||
|
||||
if (admin.hasAll<ShipIdentityComponent>(entity))
|
||||
{
|
||||
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
|
||||
m_entityStatsPanel->refreshFromLive(stats, health.hp);
|
||||
m_entityStatsPanel->setBehavior(
|
||||
admin.get<SelectedBehaviorComponent>(entity).winner);
|
||||
}
|
||||
else if (admin.hasAll<StationBodyComponent>(entity))
|
||||
{
|
||||
buildEntityStation(entity);
|
||||
}
|
||||
}
|
||||
|
||||
int FieldSelectionPanel::selectedDebrisScrapTotal() const
|
||||
{
|
||||
// Sum the remaining scrap across the still-living selected debris (REQ-UI-DEBRIS-PANEL).
|
||||
int total = 0;
|
||||
for (const DebrisInfo& info : m_sim->getDebrisSystem().getAllDebrisInfo())
|
||||
{
|
||||
if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity)
|
||||
!= m_selectedDebris.end())
|
||||
{
|
||||
total += info.amount;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
QString FieldSelectionPanel::scrapTotalText() const
|
||||
{
|
||||
return tr("Scrap x %1").arg(selectedDebrisScrapTotal());
|
||||
}
|
||||
|
||||
void FieldSelectionPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
|
||||
{
|
||||
refreshDisplay();
|
||||
}
|
||||
|
||||
void FieldSelectionPanel::handleEvent(
|
||||
std::shared_ptr<const PlayerCommandsAppliedEvent> /*event*/)
|
||||
{
|
||||
// Player commands are applied by a queued drain, not synchronously. When the game is
|
||||
// paused no tick advances, so TickAdvancedEvent never fires; refresh here too.
|
||||
refreshDisplay();
|
||||
}
|
||||
|
||||
void FieldSelectionPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)
|
||||
{
|
||||
m_debugDraw = event->active;
|
||||
m_entityStatsPanel->setDebugDrawEnabled(event->active);
|
||||
}
|
||||
91
src/ui/FieldSelectionPanel.h
Normal file
91
src/ui/FieldSelectionPanel.h
Normal file
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
|
||||
#include "DebugDrawToggledEvent.h"
|
||||
#include "EventHandler.h"
|
||||
#include "PlayerCommandsAppliedEvent.h"
|
||||
#include "TickAdvancedEvent.h"
|
||||
|
||||
struct GameConfig;
|
||||
class Simulation;
|
||||
class ShipStatsPanel;
|
||||
class QLabel;
|
||||
class QVBoxLayout;
|
||||
|
||||
// Renders the "field" selection category — ships, defence stations and debris — as either
|
||||
// a single-object stats panel (ship, station, or debris) or a compact multi-object count
|
||||
// summary (REQ-UI-SELECTION-CATEGORIES, REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL).
|
||||
//
|
||||
// The panel owns its own selection state and its own widgets, and nothing else. Which of
|
||||
// the two selection categories owns the side panel is arbitrated by the parent
|
||||
// SelectedBuildingPanel: it feeds this panel through setSelectedEntities() /
|
||||
// setSelectedDebris() / clearSelection() and asks it via hasSelection(). This panel hides
|
||||
// itself whenever its selection is empty, so an inactive field category takes no space.
|
||||
class FieldSelectionPanel : public QWidget,
|
||||
public CombinedEventHandler<TickAdvancedEvent,
|
||||
PlayerCommandsAppliedEvent,
|
||||
DebugDrawToggledEvent>
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
FieldSelectionPanel(Simulation* sim, const GameConfig* config,
|
||||
QWidget* parent = nullptr);
|
||||
~FieldSelectionPanel() override;
|
||||
|
||||
// Replaces the selected actors (ships and defence stations); debris is left alone,
|
||||
// the two coexist within the field category (REQ-UI-SELECTION-CATEGORIES).
|
||||
void setSelectedEntities(const std::vector<entt::entity>& entities);
|
||||
// Replaces the selected debris; the selected actors are left alone.
|
||||
void setSelectedDebris(const std::vector<entt::entity>& debris);
|
||||
// Drops the whole field selection — used when the building category takes over.
|
||||
void clearSelection();
|
||||
// True while the field category has anything selected, i.e. while this panel owns
|
||||
// the side panel's content.
|
||||
bool hasSelection() const;
|
||||
|
||||
private:
|
||||
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
|
||||
|
||||
// Picks the layout for the current selection and shows/hides this panel accordingly.
|
||||
void rebuild();
|
||||
// Keeps the live values of the layout chosen by rebuild() current.
|
||||
void refreshDisplay();
|
||||
void buildEntityShip(entt::entity entity);
|
||||
void buildEntityStation(entt::entity entity);
|
||||
void buildEntitySummary();
|
||||
void buildDebrisSingle();
|
||||
void refreshEntityStats();
|
||||
void hideAllWidgets();
|
||||
// Summed remaining scrap across the selected debris (REQ-UI-DEBRIS-PANEL).
|
||||
int selectedDebrisScrapTotal() const;
|
||||
// "Scrap x N" line for the multi-object summary (REQ-UI-FIELD-MULTI-SELECTION).
|
||||
QString scrapTotalText() const;
|
||||
|
||||
Simulation* m_sim;
|
||||
const GameConfig* m_config;
|
||||
|
||||
bool m_debugDraw = false;
|
||||
|
||||
// The selected ships/defence stations. Shares the "field" selection category with
|
||||
// debris (m_selectedDebris): both can be non-empty at once (REQ-UI-SELECTION-CATEGORIES).
|
||||
std::vector<entt::entity> m_selectedEntities;
|
||||
std::vector<entt::entity> m_selectedDebris;
|
||||
|
||||
QVBoxLayout* m_layout;
|
||||
QLabel* m_entityTitleLabel;
|
||||
ShipStatsPanel* m_entityStatsPanel;
|
||||
QLabel* m_stationStatsLabel;
|
||||
QLabel* m_entitySummaryLabel;
|
||||
// Shows the debris "Scrap" stat row (single selection) — the scrap total for the
|
||||
// multi-object summary lives in m_entitySummaryLabel instead.
|
||||
QLabel* m_scrapLabel;
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
#include "GameWorldView.h"
|
||||
#include "FactoryQueries.h"
|
||||
#include "ProductionRules.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
@@ -50,6 +52,7 @@
|
||||
#include "HealthComponent.h"
|
||||
#include "HqProxyComponent.h"
|
||||
#include "ItemIconCache.h"
|
||||
#include "PortGeometry.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "RepairBehavior.h"
|
||||
#include "SalvageScrapBehavior.h"
|
||||
@@ -166,18 +169,6 @@ Rotation rotateCounterClockwise(Rotation r)
|
||||
return Rotation::East;
|
||||
}
|
||||
|
||||
QPoint portBodyTile(QPoint portTile, Rotation direction)
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case Rotation::East: return portTile + QPoint(-1, 0);
|
||||
case Rotation::West: return portTile + QPoint( 1, 0);
|
||||
case Rotation::North: return portTile + QPoint( 0, 1);
|
||||
case Rotation::South: return portTile + QPoint( 0, -1);
|
||||
}
|
||||
return portTile;
|
||||
}
|
||||
|
||||
// Fill color for a building's status light per its production state
|
||||
// (REQ-UI-STATUS-LIGHT).
|
||||
QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl)
|
||||
@@ -197,11 +188,13 @@ QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl)
|
||||
|
||||
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
||||
const VisualsConfig* visuals, const std::string& configDir,
|
||||
const ParsedReplay* replay, QWidget* parent)
|
||||
ItemIconCache* itemIcons, const ParsedReplay* replay,
|
||||
QWidget* parent)
|
||||
: QOpenGLWidget(parent)
|
||||
, m_sim(sim)
|
||||
, m_config(config)
|
||||
, m_visuals(visuals)
|
||||
, m_itemIcons(itemIcons)
|
||||
, m_commandManager(*sim)
|
||||
, m_gameSpeedMultiplier(1.0)
|
||||
, m_prevNonZeroSpeed(1.0)
|
||||
@@ -223,11 +216,6 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
||||
|
||||
loadBuildingIcons(configDir);
|
||||
|
||||
// Item icons live beside the config dir, mirroring the building icons
|
||||
// (REQ-UI-ITEM-ICON, REQ-UI-WORLD-ICON).
|
||||
m_itemIcons = std::make_unique<ItemIconCache>(QDir::cleanPath(
|
||||
QString::fromStdString(configDir) + "/../icons/items"));
|
||||
|
||||
m_renderTimer = new QTimer(this);
|
||||
m_renderTimer->setInterval(16);
|
||||
connect(m_renderTimer, &QTimer::timeout, this, &GameWorldView::onFrame);
|
||||
@@ -406,26 +394,26 @@ void GameWorldView::onFrame()
|
||||
{
|
||||
m_lastTick = newTick;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<TickAdvancedEvent>(newTick));
|
||||
std::make_shared<TickAdvancedEvent>());
|
||||
}
|
||||
if (newBlocks != m_lastBlocks)
|
||||
{
|
||||
m_lastBlocks = newBlocks;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<BuildingBlocksChangedEvent>(newBlocks));
|
||||
std::make_shared<BuildingBlocksChangedEvent>());
|
||||
}
|
||||
if (newExpCost != m_lastExpansionCost)
|
||||
{
|
||||
m_lastExpansionCost = newExpCost;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<ExpansionCostChangedEvent>(newExpCost));
|
||||
std::make_shared<ExpansionCostChangedEvent>());
|
||||
}
|
||||
if (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown)
|
||||
{
|
||||
m_lastBossCounter = newBoss;
|
||||
m_lastBossCountdown = newCountdown;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<BossWaveUpdatedEvent>(newBoss, newCountdown));
|
||||
std::make_shared<BossWaveUpdatedEvent>());
|
||||
}
|
||||
|
||||
// Unlocked building set changes only after a drop is applied or on Restart
|
||||
@@ -488,8 +476,7 @@ void GameWorldView::onFrame()
|
||||
{
|
||||
m_lastArtifactCount = currentArtifactCount;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<ArtifactCountChangedEvent>(
|
||||
currentArtifactCount, m_sim->getConfig().world.artifacts.artifactWinCount));
|
||||
std::make_shared<ArtifactCountChangedEvent>());
|
||||
}
|
||||
|
||||
update();
|
||||
@@ -592,7 +579,7 @@ QRect GameWorldView::getViewportRect() const
|
||||
float GameWorldView::getAsteroidLeftEdge() const
|
||||
{
|
||||
float leftX = -static_cast<float>(m_sim->getCurrentAsteroidWidth_tiles());
|
||||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||||
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
||||
{
|
||||
for (const QPoint& cell : b.bodyCells)
|
||||
{
|
||||
@@ -669,15 +656,6 @@ void GameWorldView::clampScroll()
|
||||
// Placement helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BuildingDef* GameWorldView::findBuildingDef(BuildingType type) const
|
||||
{
|
||||
for (const BuildingDef& def : m_config->buildings.buildings)
|
||||
{
|
||||
if (def.type == type) { return &def; }
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
|
||||
Rotation rot) const
|
||||
{
|
||||
@@ -689,14 +667,14 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
|
||||
return false;
|
||||
}
|
||||
|
||||
const BuildingDef* def = findBuildingDef(type);
|
||||
const BuildingDef* def = m_config->buildings.findBuildingDef(type);
|
||||
if (!def) { return false; }
|
||||
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rot);
|
||||
|
||||
bool anyOccupied = false;
|
||||
for (const QPoint& relCell : parsed.bodyCells)
|
||||
{
|
||||
if (m_sim->getBuildings().isTileOccupied(anchor + relCell))
|
||||
if (isTileOccupied(m_sim->getFactoryState(), anchor + relCell))
|
||||
{
|
||||
anyOccupied = true;
|
||||
break;
|
||||
@@ -712,7 +690,7 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
|
||||
|
||||
std::optional<BuildingId> GameWorldView::buildingAtTile(QPoint tile) const
|
||||
{
|
||||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||||
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
||||
{
|
||||
for (const QPoint& cell : b.bodyCells)
|
||||
{
|
||||
@@ -727,7 +705,7 @@ std::optional<BuildingId> GameWorldView::buildingAtTile(QPoint tile) const
|
||||
|
||||
std::optional<BuildingId> GameWorldView::siteAtTile(QPoint tile) const
|
||||
{
|
||||
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
|
||||
for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState()))
|
||||
{
|
||||
for (const QPoint& cell : s.bodyCells)
|
||||
{
|
||||
@@ -749,7 +727,7 @@ std::vector<BuildingId> GameWorldView::buildingsInBox(QPoint cornerA, QPoint cor
|
||||
const int y1 = std::max(cornerA.y(), cornerB.y());
|
||||
|
||||
std::vector<BuildingId> ids;
|
||||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||||
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
||||
{
|
||||
for (const QPoint& cell : b.bodyCells)
|
||||
{
|
||||
@@ -761,7 +739,7 @@ std::vector<BuildingId> GameWorldView::buildingsInBox(QPoint cornerA, QPoint cor
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
|
||||
for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState()))
|
||||
{
|
||||
for (const QPoint& cell : s.bodyCells)
|
||||
{
|
||||
@@ -892,7 +870,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const BuildingDef* def = findBuildingDef(bb.type);
|
||||
const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
|
||||
if (def) { totalCost += def->cost; }
|
||||
}
|
||||
if (m_sim->getBuildingBlocksStock() < totalCost) { return; }
|
||||
@@ -969,29 +947,39 @@ BuildingType GameWorldView::effectiveBuilderType() const
|
||||
return inTunnelMode() ? m_tunnelGhostType : *m_builderType;
|
||||
}
|
||||
|
||||
std::map<std::pair<int, int>, TunnelTileInfo> GameWorldView::collectTunnelTiles() const
|
||||
TunnelTileMap GameWorldView::collectTunnelTiles() const
|
||||
{
|
||||
// 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).
|
||||
std::map<std::pair<int, int>, TunnelTileInfo> tunnels;
|
||||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||||
TunnelTileMap tunnels;
|
||||
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
||||
{
|
||||
if (b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit)
|
||||
{
|
||||
tunnels[{b.anchor.x(), b.anchor.y()}] = TunnelTileInfo{b.type, b.rotation};
|
||||
tunnels[b.anchor] = TunnelTileInfo{b.type, b.rotation};
|
||||
}
|
||||
}
|
||||
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
|
||||
for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState()))
|
||||
{
|
||||
if (s.type == BuildingType::TunnelEntry || s.type == BuildingType::TunnelExit)
|
||||
{
|
||||
tunnels[{s.anchor.x(), s.anchor.y()}] = TunnelTileInfo{s.type, s.rotation};
|
||||
tunnels[s.anchor] = TunnelTileInfo{s.type, s.rotation};
|
||||
}
|
||||
}
|
||||
return tunnels;
|
||||
}
|
||||
|
||||
TunnelLookup GameWorldView::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;
|
||||
};
|
||||
}
|
||||
|
||||
void GameWorldView::updateTunnelGhost()
|
||||
{
|
||||
m_tunnelGhostType = BuildingType::TunnelEntry;
|
||||
@@ -1004,14 +992,8 @@ void GameWorldView::updateTunnelGhost()
|
||||
return;
|
||||
}
|
||||
|
||||
const std::map<std::pair<int, int>, TunnelTileInfo> tunnels = collectTunnelTiles();
|
||||
const TunnelLookup lookup = [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
|
||||
{
|
||||
const std::map<std::pair<int, int>, TunnelTileInfo>::const_iterator it =
|
||||
tunnels.find({tile.x(), tile.y()});
|
||||
if (it == tunnels.end()) { return std::nullopt; }
|
||||
return it->second;
|
||||
};
|
||||
const TunnelTileMap tunnels = collectTunnelTiles();
|
||||
const TunnelLookup lookup = makeTunnelLookup(tunnels);
|
||||
|
||||
const TunnelCompletion completion =
|
||||
resolveTunnelCompletion(lookup, m_ghostTile, m_ghostRotation,
|
||||
@@ -1054,7 +1036,7 @@ void GameWorldView::placeAtTile(QPoint tile)
|
||||
|| type == BuildingType::TunnelEntry
|
||||
|| type == BuildingType::TunnelExit)
|
||||
{
|
||||
if (!m_sim->getBuildings().isTileOccupied(tile) && canAfford(type))
|
||||
if (!isTileOccupied(m_sim->getFactoryState(), tile) && canAfford(type))
|
||||
{
|
||||
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
||||
}
|
||||
@@ -1081,7 +1063,7 @@ void GameWorldView::recomputeBeltDragPath(QPoint cursorTile)
|
||||
std::optional<BuildingType> targetType;
|
||||
if (targetId.has_value())
|
||||
{
|
||||
if (const Building* building = m_sim->getBuildings().findBuilding(*targetId))
|
||||
if (const Building* building = findBuilding(m_sim->getFactoryState(), *targetId))
|
||||
{
|
||||
targetType = building->type;
|
||||
}
|
||||
@@ -1089,7 +1071,7 @@ void GameWorldView::recomputeBeltDragPath(QPoint cursorTile)
|
||||
else if (std::optional<BuildingId> siteId = siteAtTile(cursorTile); siteId.has_value())
|
||||
{
|
||||
targetId = siteId;
|
||||
if (const ConstructionSite* site = m_sim->getBuildings().findSite(*siteId))
|
||||
if (const ConstructionSite* site = findSite(m_sim->getFactoryState(), *siteId))
|
||||
{
|
||||
targetType = site->type;
|
||||
}
|
||||
@@ -1133,7 +1115,7 @@ std::vector<GameWorldView::BeltDragResolved> GameWorldView::resolveBeltDragPath(
|
||||
std::vector<BeltDragResolved> resolved;
|
||||
resolved.reserve(m_beltDragPath.size());
|
||||
|
||||
const BuildingDef* def = findBuildingDef(BuildingType::Belt);
|
||||
const BuildingDef* def = m_config->buildings.findBuildingDef(BuildingType::Belt);
|
||||
const int beltCost = (def != nullptr) ? def->cost : 0;
|
||||
const int stock = m_sim->getBuildingBlocksStock();
|
||||
int spent = 0;
|
||||
@@ -1318,7 +1300,7 @@ bool GameWorldView::drawBuildingIcon(QPainter& painter, BuildingType type,
|
||||
|
||||
void GameWorldView::drawBuildings(QPainter& painter)
|
||||
{
|
||||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||||
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
||||
{
|
||||
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
|
||||
m_visuals->buildings.find(b.type);
|
||||
@@ -1350,7 +1332,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
|
||||
|
||||
for (const Port& port : b.outputPorts)
|
||||
{
|
||||
drawPortGlyph(painter, portBodyTile(port.tile, port.direction),
|
||||
drawPortGlyph(painter, outputBodyTile(port.tile, port.direction),
|
||||
port.direction, bv.outline, /*centered*/ false);
|
||||
}
|
||||
|
||||
@@ -1360,7 +1342,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
|
||||
// building footprints are rectangular, so a corner of the axis-aligned
|
||||
// bounding box is the true corner.
|
||||
if (const std::optional<ProductionStatus> status =
|
||||
m_sim->getBuildings().getProductionStatus(b))
|
||||
getProductionStatus(m_sim->getConfig(), b))
|
||||
{
|
||||
const float px = getTilePx();
|
||||
const float r = px * 0.18f;
|
||||
@@ -1385,7 +1367,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
|
||||
}
|
||||
|
||||
painter.setOpacity(0.5);
|
||||
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
|
||||
for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState()))
|
||||
{
|
||||
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
|
||||
m_visuals->buildings.find(s.type);
|
||||
@@ -1405,7 +1387,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.drawRect(bboxRect);
|
||||
|
||||
const BuildingDef* siteDef = findBuildingDef(s.type);
|
||||
const BuildingDef* siteDef = m_config->buildings.findBuildingDef(s.type);
|
||||
if (siteDef)
|
||||
{
|
||||
// Glyph + progress percentage
|
||||
@@ -1450,7 +1432,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
|
||||
for (const Port& port : siteMask.outputPorts)
|
||||
{
|
||||
const QPoint absBody = s.anchor
|
||||
+ portBodyTile(port.tile, port.direction);
|
||||
+ outputBodyTile(port.tile, port.direction);
|
||||
drawPortGlyph(painter, absBody, port.direction, bv.outline,
|
||||
/*centered*/ false);
|
||||
}
|
||||
@@ -1462,7 +1444,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
|
||||
// after every building and construction site fill so a belt (or other tile)
|
||||
// placed directly below the HQ cannot overpaint the bar (REQ-UI-STATUS-LIGHT
|
||||
// neighbours case, same rationale as the selection highlights below).
|
||||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||||
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
||||
{
|
||||
if (b.type != BuildingType::Hq) { continue; }
|
||||
const QPointF tl = tileToWidget(b.anchor);
|
||||
@@ -1493,12 +1475,12 @@ std::optional<QRectF> GameWorldView::footprintWidgetRect(BuildingId id) const
|
||||
std::optional<QPoint> anchor;
|
||||
std::optional<QSize> footprint;
|
||||
|
||||
if (const Building* b = m_sim->getBuildings().findBuilding(id))
|
||||
if (const Building* b = findBuilding(m_sim->getFactoryState(), id))
|
||||
{
|
||||
anchor = b->anchor;
|
||||
footprint = b->footprint;
|
||||
}
|
||||
else if (const ConstructionSite* s = m_sim->getBuildings().findSite(id))
|
||||
else if (const ConstructionSite* s = findSite(m_sim->getFactoryState(), id))
|
||||
{
|
||||
anchor = s->anchor;
|
||||
footprint = s->footprint;
|
||||
@@ -1550,13 +1532,13 @@ void GameWorldView::drawCopyConfigFeedback(QPainter& painter)
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(color);
|
||||
const BuildingType type = m_copiedConfig->type;
|
||||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||||
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
||||
{
|
||||
if (b.type != type) { continue; }
|
||||
const std::optional<QRectF> rect = footprintWidgetRect(b.id);
|
||||
if (rect.has_value()) { painter.drawRect(*rect); }
|
||||
}
|
||||
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
|
||||
for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState()))
|
||||
{
|
||||
if (s.type != type) { continue; }
|
||||
const std::optional<QRectF> rect = footprintWidgetRect(s.id);
|
||||
@@ -1591,7 +1573,7 @@ void GameWorldView::drawPortItems(QPainter& painter)
|
||||
const double margin = kPortMarginTiles * static_cast<double>(getTilePx());
|
||||
|
||||
QRegion clip(rect());
|
||||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||||
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
||||
{
|
||||
if (b.type == BuildingType::Belt || b.type == BuildingType::Splitter
|
||||
|| b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit)
|
||||
@@ -1988,31 +1970,25 @@ void GameWorldView::drawSelectedTunnelConnections(QPainter& painter)
|
||||
{
|
||||
if (m_selectedBuildingIds.empty()) { return; }
|
||||
|
||||
const std::map<std::pair<int, int>, TunnelTileInfo> tunnels = collectTunnelTiles();
|
||||
const TunnelTileMap tunnels = collectTunnelTiles();
|
||||
if (tunnels.empty()) { return; }
|
||||
|
||||
const TunnelLookup lookup = [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
|
||||
{
|
||||
const std::map<std::pair<int, int>, TunnelTileInfo>::const_iterator it =
|
||||
tunnels.find({tile.x(), tile.y()});
|
||||
if (it == tunnels.end()) { return std::nullopt; }
|
||||
return it->second;
|
||||
};
|
||||
const TunnelLookup lookup = makeTunnelLookup(tunnels);
|
||||
|
||||
// Collect the tiles to highlight in a set so a connection selected from both ends
|
||||
// (or overlapping runs) is filled exactly once — filling a semi-transparent green
|
||||
// twice would darken it (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
|
||||
std::set<std::pair<int, int>> highlightTiles;
|
||||
std::set<QPoint, QPointCompare> highlightTiles;
|
||||
for (const BuildingId id : m_selectedBuildingIds)
|
||||
{
|
||||
std::optional<QPoint> anchor;
|
||||
std::optional<BuildingType> type;
|
||||
Rotation rotation = Rotation::East;
|
||||
if (const Building* b = m_sim->getBuildings().findBuilding(id))
|
||||
if (const Building* b = findBuilding(m_sim->getFactoryState(), id))
|
||||
{
|
||||
anchor = b->anchor; type = b->type; rotation = b->rotation;
|
||||
}
|
||||
else if (const ConstructionSite* s = m_sim->getBuildings().findSite(id))
|
||||
else if (const ConstructionSite* s = findSite(m_sim->getFactoryState(), id))
|
||||
{
|
||||
anchor = s->anchor; type = s->type; rotation = s->rotation;
|
||||
}
|
||||
@@ -2032,15 +2008,15 @@ void GameWorldView::drawSelectedTunnelConnections(QPainter& painter)
|
||||
(delta.y() > 0) - (delta.y() < 0));
|
||||
for (QPoint t = *anchor; ; t += stepDir)
|
||||
{
|
||||
highlightTiles.insert({t.x(), t.y()});
|
||||
highlightTiles.insert(t);
|
||||
if (t == *partner) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
const QColor green = m_visuals->overlays.tunnelPreview;
|
||||
for (const std::pair<int, int>& tile : highlightTiles)
|
||||
for (const QPoint& tile : highlightTiles)
|
||||
{
|
||||
painter.fillRect(tileRect(QPoint(tile.first, tile.second)), green);
|
||||
painter.fillRect(tileRect(tile), green);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2117,7 +2093,7 @@ void GameWorldView::drawOverlays(QPainter& painter)
|
||||
|
||||
// Queued for deconstruction: tint every building currently in the
|
||||
// deconstruction queue, regardless of mode (REQ-BLD-DECON-QUEUE).
|
||||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||||
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
||||
{
|
||||
if (!b.queuedForDeconstruction) { continue; }
|
||||
for (const QPoint& cell : b.bodyCells)
|
||||
@@ -2132,12 +2108,12 @@ void GameWorldView::drawOverlays(QPainter& painter)
|
||||
{
|
||||
for (BuildingId id : buildingsInBox(m_boxStartTile, m_boxCurrentTile))
|
||||
{
|
||||
const Building* b = m_sim->getBuildings().findBuilding(id);
|
||||
const Building* b = findBuilding(m_sim->getFactoryState(), id);
|
||||
if (b && b->type == BuildingType::Hq) { continue; }
|
||||
const std::vector<QPoint>* cells = nullptr;
|
||||
const ConstructionSite* s = nullptr;
|
||||
if (b) { cells = &b->bodyCells; }
|
||||
else if ((s = m_sim->getBuildings().findSite(id))) { cells = &s->bodyCells; }
|
||||
else if ((s = findSite(m_sim->getFactoryState(), id))) { cells = &s->bodyCells; }
|
||||
if (cells)
|
||||
{
|
||||
for (const QPoint& cell : *cells)
|
||||
@@ -2149,7 +2125,7 @@ void GameWorldView::drawOverlays(QPainter& painter)
|
||||
}
|
||||
else if (m_deconstructMode && m_deconstructHoverBuildingId.has_value())
|
||||
{
|
||||
const Building* b = m_sim->getBuildings().findBuilding(*m_deconstructHoverBuildingId);
|
||||
const Building* b = findBuilding(m_sim->getFactoryState(), *m_deconstructHoverBuildingId);
|
||||
if (b)
|
||||
{
|
||||
for (const QPoint& cell : b->bodyCells)
|
||||
@@ -2177,7 +2153,7 @@ void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type,
|
||||
QPoint anchorTile, Rotation rotation,
|
||||
bool valid, bool showPortTargetGlyphs)
|
||||
{
|
||||
const BuildingDef* def = findBuildingDef(type);
|
||||
const BuildingDef* def = m_config->buildings.findBuildingDef(type);
|
||||
if (!def) { return; }
|
||||
|
||||
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
|
||||
@@ -2230,7 +2206,7 @@ void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type,
|
||||
|
||||
for (const Port& port : parsed.outputPorts)
|
||||
{
|
||||
drawPortGlyph(painter, anchorTile + portBodyTile(port.tile, port.direction),
|
||||
drawPortGlyph(painter, anchorTile + outputBodyTile(port.tile, port.direction),
|
||||
port.direction, lineColor, /*centered*/ false);
|
||||
}
|
||||
|
||||
@@ -2774,7 +2750,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
||||
|
||||
if (m_deconstructMode)
|
||||
{
|
||||
const BuildingSystem& buildings = m_sim->getBuildings();
|
||||
const FactoryState& factory = m_sim->getFactoryState();
|
||||
|
||||
// Split covered ids into construction sites (removed instantly) and
|
||||
// operational deconstructible buildings (the HQ is protected; player
|
||||
@@ -2783,12 +2759,12 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
||||
std::vector<BuildingId> operational;
|
||||
for (BuildingId id : boxIds)
|
||||
{
|
||||
if (const Building* b = buildings.findBuilding(id))
|
||||
if (const Building* b = findBuilding(factory, id))
|
||||
{
|
||||
if (b->type == BuildingType::Hq) { continue; }
|
||||
operational.push_back(id);
|
||||
}
|
||||
else if (buildings.findSite(id))
|
||||
else if (findSite(factory, id))
|
||||
{
|
||||
sites.push_back(id);
|
||||
}
|
||||
@@ -2810,7 +2786,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
||||
bool allQueued = !operational.empty();
|
||||
for (BuildingId id : operational)
|
||||
{
|
||||
if (!buildings.isQueuedForDeconstruction(id)) { allQueued = false; break; }
|
||||
if (!isQueuedForDeconstruction(factory, id)) { allQueued = false; break; }
|
||||
}
|
||||
for (BuildingId id : operational)
|
||||
{
|
||||
@@ -2821,7 +2797,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
||||
command->id = id;
|
||||
enqueueCommand(command);
|
||||
}
|
||||
else if (!buildings.isQueuedForDeconstruction(id))
|
||||
else if (!isQueuedForDeconstruction(factory, id))
|
||||
{
|
||||
std::shared_ptr<DeconstructCommand> command =
|
||||
std::make_shared<DeconstructCommand>();
|
||||
@@ -2960,7 +2936,7 @@ void GameWorldView::rotateGhost(bool clockwise)
|
||||
{
|
||||
for (BlueprintBuilding& bb : m_blueprintMode->buildings)
|
||||
{
|
||||
const BuildingDef* def = findBuildingDef(bb.type);
|
||||
const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
|
||||
if (!def) { continue; }
|
||||
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
|
||||
int minX = INT_MAX, minY = INT_MAX;
|
||||
@@ -3023,7 +2999,7 @@ void GameWorldView::pasteConfigTo(BuildingId id)
|
||||
{
|
||||
// Operational splitters are configured by tile; sites by BuildingId
|
||||
// (mirrors SelectedBuildingPanel::onSplitterFilterChanged).
|
||||
if (const Building* building = m_sim->getBuildings().findBuilding(id))
|
||||
if (const Building* building = findBuilding(m_sim->getFactoryState(), id))
|
||||
{
|
||||
std::shared_ptr<SetSplitterFiltersCommand> command =
|
||||
std::make_shared<SetSplitterFiltersCommand>();
|
||||
@@ -3259,7 +3235,7 @@ void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotat
|
||||
|
||||
bool GameWorldView::canAfford(BuildingType type) const
|
||||
{
|
||||
const BuildingDef* def = findBuildingDef(type);
|
||||
const BuildingDef* def = m_config->buildings.findBuildingDef(type);
|
||||
if (!def) { return false; }
|
||||
return m_sim->getBuildingBlocksStock() >= def->cost;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ struct QPointCompare
|
||||
}
|
||||
};
|
||||
|
||||
// Tunnel entries/exits indexed by their single-cell tile (REQ-BLD-TUNNEL-MODE).
|
||||
using TunnelTileMap = std::map<QPoint, TunnelTileInfo, QPointCompare>;
|
||||
|
||||
class GameWorldView : public QOpenGLWidget,
|
||||
public CombinedEventHandler<BeamFiredEvent,
|
||||
BuildingTypeSelectedEvent,
|
||||
@@ -80,9 +83,12 @@ class GameWorldView : public QOpenGLWidget,
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
// itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); not
|
||||
// owned, must outlive this widget.
|
||||
GameWorldView(Simulation* sim, const GameConfig* config,
|
||||
const VisualsConfig* visuals, const std::string& configDir,
|
||||
const ParsedReplay* replay, QWidget* parent = nullptr);
|
||||
ItemIconCache* itemIcons, const ParsedReplay* replay,
|
||||
QWidget* parent = nullptr);
|
||||
~GameWorldView() override;
|
||||
|
||||
double getGameSpeed() const;
|
||||
@@ -182,7 +188,6 @@ private:
|
||||
void clampScroll();
|
||||
|
||||
bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const;
|
||||
const BuildingDef* findBuildingDef(BuildingType type) const;
|
||||
std::optional<BuildingId> buildingAtTile(QPoint tile) const;
|
||||
std::optional<BuildingId> siteAtTile(QPoint tile) const;
|
||||
// Ids of all buildings and construction sites whose footprint intersects
|
||||
@@ -243,7 +248,10 @@ private:
|
||||
void updateTunnelGhost();
|
||||
// Indexes every tunnel entry/exit — built or still a construction site — by its
|
||||
// single-cell tile. Shared by the placement preview and the selection highlight.
|
||||
std::map<std::pair<int, int>, TunnelTileInfo> collectTunnelTiles() const;
|
||||
TunnelTileMap collectTunnelTiles() const;
|
||||
// Wraps a tunnel tile index in the lookup functor the TunnelCompletion helpers
|
||||
// take. The returned functor references `tunnels`, which must outlive it.
|
||||
static TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels);
|
||||
// Draws the green connection highlight for every selected tunnel end that has a
|
||||
// matching end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
|
||||
void drawSelectedTunnelConnections(QPainter& painter);
|
||||
@@ -304,9 +312,10 @@ private:
|
||||
};
|
||||
std::map<BuildingType, BuildingIconRenderers> m_buildingIcons;
|
||||
|
||||
// Per-item icon cache (REQ-UI-ITEM-ICON), loaded from <configDir>/../icons/items.
|
||||
// Shared draw path for belt and port items; pixmaps are cached per target size.
|
||||
std::unique_ptr<ItemIconCache> m_itemIcons;
|
||||
// Per-item icon cache (REQ-UI-ITEM-ICON), shared window-wide and owned by
|
||||
// MainWindow. Shared draw path for belt and port items; pixmaps are cached
|
||||
// per target size.
|
||||
ItemIconCache* m_itemIcons;
|
||||
|
||||
// Funnels all player input into the single Simulation::apply chokepoint.
|
||||
CommandManager m_commandManager;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "EventManager.h"
|
||||
#include "IconCaption.h"
|
||||
#include "ItemIconCache.h"
|
||||
#include "Simulation.h"
|
||||
#include "SpeedChangeRequestedEvent.h"
|
||||
#include "Tick.h"
|
||||
|
||||
@@ -30,11 +31,11 @@ namespace
|
||||
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
|
||||
const int HeaderBar::kSpeedCount = 5;
|
||||
|
||||
HeaderBar::HeaderBar(const GameConfig* config, const std::string& itemsIconDir,
|
||||
QWidget* parent)
|
||||
HeaderBar::HeaderBar(const Simulation* sim, const GameConfig* config,
|
||||
ItemIconCache* itemIcons, QWidget* parent)
|
||||
: QWidget(parent)
|
||||
, m_itemIcons(std::make_unique<ItemIconCache>(
|
||||
QString::fromStdString(itemsIconDir)))
|
||||
, m_itemIcons(itemIcons)
|
||||
, m_sim(sim)
|
||||
{
|
||||
QHBoxLayout* layout = new QHBoxLayout(this);
|
||||
layout->setContentsMargins(8, 4, 8, 4);
|
||||
@@ -106,25 +107,23 @@ HeaderBar::~HeaderBar()
|
||||
unregisterForEvents();
|
||||
}
|
||||
|
||||
void HeaderBar::handleEvent(std::shared_ptr<const TickAdvancedEvent> event)
|
||||
void HeaderBar::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
|
||||
{
|
||||
const int totalSeconds = static_cast<int>(ticksToSeconds(event->tick));
|
||||
const int totalSeconds = static_cast<int>(ticksToSeconds(m_sim->getCurrentTick()));
|
||||
m_timeLabel->setText(
|
||||
QString("%1:%2")
|
||||
.arg(totalSeconds / 60, 2, 10, QChar('0'))
|
||||
.arg(totalSeconds % 60, 2, 10, QChar('0')));
|
||||
}
|
||||
|
||||
void HeaderBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event)
|
||||
void HeaderBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
|
||||
{
|
||||
m_blocks = event->blocks;
|
||||
updateBlocksLabel();
|
||||
updateExpandButton();
|
||||
}
|
||||
|
||||
void HeaderBar::handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> event)
|
||||
void HeaderBar::handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> /*event*/)
|
||||
{
|
||||
m_expansionCost = event->cost;
|
||||
updateExpandButton();
|
||||
}
|
||||
|
||||
@@ -138,32 +137,37 @@ QPixmap HeaderBar::blockIcon() const
|
||||
|
||||
void HeaderBar::updateBlocksLabel()
|
||||
{
|
||||
const int blocks = m_sim->getBuildingBlocksStock();
|
||||
|
||||
const QPixmap icon = blockIcon();
|
||||
if (icon.isNull())
|
||||
{
|
||||
// Fallback text form when no building_block icon exists (REQ-UI-BLOCKS-ICON).
|
||||
m_blocksLabel->setText(tr("Stock: %1 Blocks").arg(m_blocks));
|
||||
m_blocksLabel->setText(tr("Stock: %1 Blocks").arg(blocks));
|
||||
return;
|
||||
}
|
||||
m_blocksLabel->setPixmap(renderCaptionWithIcon(
|
||||
tr("Stock: %1").arg(m_blocks), icon, font(),
|
||||
tr("Stock: %1").arg(blocks), icon, font(),
|
||||
m_blocksLabel->palette().color(QPalette::WindowText)));
|
||||
}
|
||||
|
||||
void HeaderBar::updateExpandButton()
|
||||
{
|
||||
m_expandButton->setEnabled(m_blocks >= m_expansionCost);
|
||||
const int blocks = m_sim->getBuildingBlocksStock();
|
||||
const int expansionCost = m_sim->getCurrentExpansionCost();
|
||||
|
||||
m_expandButton->setEnabled(blocks >= expansionCost);
|
||||
|
||||
const QPixmap icon = blockIcon();
|
||||
if (icon.isNull())
|
||||
{
|
||||
// Fallback text form when no building_block icon exists (REQ-UI-EXPAND-BUTTON).
|
||||
m_expandButton->setIcon(QIcon());
|
||||
m_expandButton->setText(tr("Expand: %1 Blocks").arg(m_expansionCost));
|
||||
m_expandButton->setText(tr("Expand: %1 Blocks").arg(expansionCost));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString text = tr("Expand: %1").arg(m_expansionCost);
|
||||
const QString text = tr("Expand: %1").arg(expansionCost);
|
||||
const QPalette& pal = m_expandButton->palette();
|
||||
const QPixmap normal = renderCaptionWithIcon(
|
||||
text, icon, m_expandButton->font(), pal.color(QPalette::ButtonText));
|
||||
@@ -191,22 +195,26 @@ void HeaderBar::handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event)
|
||||
}
|
||||
}
|
||||
|
||||
void HeaderBar::handleEvent(std::shared_ptr<const BossWaveUpdatedEvent> event)
|
||||
void HeaderBar::handleEvent(std::shared_ptr<const BossWaveUpdatedEvent> /*event*/)
|
||||
{
|
||||
const int bossSeconds = static_cast<int>(
|
||||
ticksToSeconds(event->countdownTicks > 0 ? event->countdownTicks : 0));
|
||||
const Tick countdownTicks = m_sim->getBossCountdownTicks();
|
||||
const int bossSeconds = static_cast<int>(
|
||||
ticksToSeconds(countdownTicks > 0 ? countdownTicks : 0));
|
||||
m_bossWaveLabel->setText(
|
||||
tr("Boss Wave #%1")
|
||||
.arg(event->counter));
|
||||
.arg(m_sim->getBossWaveCounter()));
|
||||
m_nextBossLabel->setText(
|
||||
tr("Next boss: %1:%2")
|
||||
.arg(bossSeconds / 60)
|
||||
.arg(bossSeconds % 60, 2, 10, QChar('0')));
|
||||
}
|
||||
|
||||
void HeaderBar::handleEvent(std::shared_ptr<const ArtifactCountChangedEvent> event)
|
||||
void HeaderBar::handleEvent(std::shared_ptr<const ArtifactCountChangedEvent> /*event*/)
|
||||
{
|
||||
m_artifactsLabel->setText(tr("Artifacts: %1/%2").arg(event->count).arg(event->winCount));
|
||||
m_artifactsLabel->setText(
|
||||
tr("Artifacts: %1/%2")
|
||||
.arg(m_sim->getArtifactCount())
|
||||
.arg(m_sim->getConfig().world.artifacts.artifactWinCount));
|
||||
}
|
||||
|
||||
void HeaderBar::onSpeedButton(int index)
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
class ItemIconCache;
|
||||
class Simulation;
|
||||
|
||||
class HeaderBar : public QWidget,
|
||||
public CombinedEventHandler<TickAdvancedEvent,
|
||||
@@ -32,11 +33,11 @@ class HeaderBar : public QWidget,
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
// itemsIconDir holds the per-item icon SVGs (REQ-UI-ITEM-ICON); used to show the
|
||||
// building_block icon in the stock display and expand button (REQ-UI-BLOCKS-ICON,
|
||||
// REQ-UI-EXPAND-BUTTON).
|
||||
HeaderBar(const GameConfig* config, const std::string& itemsIconDir,
|
||||
QWidget* parent = nullptr);
|
||||
// itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); used to
|
||||
// show the building_block icon in the stock display and expand button
|
||||
// (REQ-UI-BLOCKS-ICON, REQ-UI-EXPAND-BUTTON). Not owned; must outlive this widget.
|
||||
HeaderBar(const Simulation* sim, const GameConfig* config,
|
||||
ItemIconCache* itemIcons, QWidget* parent = nullptr);
|
||||
~HeaderBar() override;
|
||||
|
||||
private slots:
|
||||
@@ -54,9 +55,10 @@ private:
|
||||
// expansion cost and building block stock (REQ-UI-EXPAND-BUTTON).
|
||||
void updateExpandButton();
|
||||
|
||||
// Refreshes the building blocks stock display from m_blocks: `Stock: <n>` with
|
||||
// the building_block icon after it, or the `Stock: <n> Blocks` text fallback when
|
||||
// no icon file exists (REQ-UI-BLOCKS-ICON).
|
||||
// Refreshes the building blocks stock display from the simulation:
|
||||
// `Stock: <n>` with the building_block icon after it, or the
|
||||
// `Stock: <n> Blocks` text fallback when no icon file exists
|
||||
// (REQ-UI-BLOCKS-ICON).
|
||||
void updateBlocksLabel();
|
||||
|
||||
// The building_block icon at the header's text height, or a null pixmap when no
|
||||
@@ -71,10 +73,11 @@ private:
|
||||
QPushButton* m_expandButton;
|
||||
std::vector<QPushButton*> m_speedButtons;
|
||||
|
||||
std::unique_ptr<ItemIconCache> m_itemIcons;
|
||||
ItemIconCache* m_itemIcons; // Not owned; lives in MainWindow.
|
||||
|
||||
int m_blocks = 0;
|
||||
int m_expansionCost = 0;
|
||||
// The simulation is the single source of truth for everything the header
|
||||
// displays; the change events are only refresh signals.
|
||||
const Simulation* m_sim;
|
||||
|
||||
static const double kSpeeds[];
|
||||
static const int kSpeedCount;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "MainWindow.h"
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <map>
|
||||
#include <random>
|
||||
@@ -27,6 +28,8 @@
|
||||
#include "SelectedBuildingPanel.h"
|
||||
#include "ShipLayoutBlueprintSerializer.h"
|
||||
#include "ShipLayoutDialog.h"
|
||||
#include "ItemIconCache.h"
|
||||
#include "ModalPauseScope.h"
|
||||
#include "Simulation.h"
|
||||
#include "Tick.h"
|
||||
#include "VisualsLoader.h"
|
||||
@@ -47,10 +50,13 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
|
||||
const std::string itemsIconDir = QDir::cleanPath(
|
||||
QString::fromStdString(m_configDir) + "/../icons/items").toStdString();
|
||||
|
||||
m_headerBar = new HeaderBar(&sim->getConfig(), itemsIconDir, this);
|
||||
m_itemIcons = std::make_unique<ItemIconCache>(
|
||||
QString::fromStdString(itemsIconDir));
|
||||
|
||||
m_headerBar = new HeaderBar(sim, &sim->getConfig(), m_itemIcons.get(), this);
|
||||
|
||||
m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
|
||||
m_replay.get(), this);
|
||||
m_itemIcons.get(), m_replay.get(), this);
|
||||
|
||||
m_sidePanel = new QWidget(this);
|
||||
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
|
||||
@@ -63,7 +69,7 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
|
||||
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
|
||||
|
||||
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel);
|
||||
m_buildButtonGrid = new BuildButtonGrid(sim, &sim->getConfig(), iconDir, itemsIconDir, m_sidePanel);
|
||||
m_buildButtonGrid = new BuildButtonGrid(sim, &sim->getConfig(), iconDir, m_itemIcons.get(), m_sidePanel);
|
||||
m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel);
|
||||
|
||||
sideLayout->addWidget(m_selectedBuildingPanel, 1);
|
||||
@@ -163,8 +169,7 @@ void MainWindow::layoutPanels()
|
||||
|
||||
void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event)
|
||||
{
|
||||
const double prevSpeed = m_gameWorldView->getGameSpeed();
|
||||
m_gameWorldView->setGameSpeed(0.0);
|
||||
ModalPauseScope pause(*m_gameWorldView);
|
||||
|
||||
ModalDimScope dim(*m_dimOverlay);
|
||||
SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, this);
|
||||
@@ -175,15 +180,11 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
|
||||
command->choiceIndex = dialog.getChosenIndex();
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<CommandRequestedEvent>(command));
|
||||
|
||||
m_gameWorldView->setGameSpeed(prevSpeed);
|
||||
m_gameWorldView->resetFrameTimer();
|
||||
}
|
||||
|
||||
void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*event*/)
|
||||
{
|
||||
const double prevSpeed = m_gameWorldView->getGameSpeed();
|
||||
m_gameWorldView->setGameSpeed(0.0);
|
||||
ModalPauseScope pause(*m_gameWorldView);
|
||||
|
||||
ModalDimScope dim(*m_dimOverlay);
|
||||
QMessageBox box(this);
|
||||
@@ -197,39 +198,47 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
|
||||
QAbstractButton* clicked = box.clickedButton();
|
||||
if (clicked == restartBtn)
|
||||
{
|
||||
std::shared_ptr<GameConfig> newConfig;
|
||||
try
|
||||
std::optional<GameConfig> newConfig = reloadConfig();
|
||||
if (!newConfig.has_value())
|
||||
{
|
||||
newConfig = std::make_shared<GameConfig>(
|
||||
ConfigLoader::loadFromDirectory(m_configDir));
|
||||
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
||||
m_visuals = std::move(newVisuals);
|
||||
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
QMessageBox::critical(this, tr("Config Error"),
|
||||
tr("Failed to reload config:\n%1").arg(e.what()));
|
||||
m_gameWorldView->setGameSpeed(prevSpeed);
|
||||
m_gameWorldView->resetFrameTimer();
|
||||
return;
|
||||
}
|
||||
// Restart is a command boundary; the view resets when the drain applies
|
||||
// it (see GameWorldView::onFrame). A fresh random seed starts a new run.
|
||||
// resetForNewGame() sets the speed for the new run, so the pre-restart
|
||||
// speed is deliberately not restored here.
|
||||
pause.release();
|
||||
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
|
||||
command->config = std::move(newConfig);
|
||||
command->config = std::make_shared<GameConfig>(std::move(*newConfig));
|
||||
command->seed = std::random_device{}();
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<CommandRequestedEvent>(command));
|
||||
}
|
||||
else if (clicked == quitBtn)
|
||||
{
|
||||
pause.release();
|
||||
close();
|
||||
}
|
||||
else
|
||||
}
|
||||
|
||||
std::optional<GameConfig> MainWindow::reloadConfig()
|
||||
{
|
||||
// Config is reloaded from disk on every restart (REQ-CFG-RELOAD); a malformed
|
||||
// file must not leave the window half-updated, so the visuals are only applied
|
||||
// once both files have parsed.
|
||||
try
|
||||
{
|
||||
m_gameWorldView->setGameSpeed(prevSpeed);
|
||||
m_gameWorldView->resetFrameTimer();
|
||||
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
|
||||
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
||||
m_visuals = std::move(newVisuals);
|
||||
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
|
||||
return newConfig;
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
QMessageBox::critical(this, tr("Config Error"),
|
||||
tr("Failed to reload config:\n%1").arg(e.what()));
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,8 +246,7 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
|
||||
const std::string& schematicId,
|
||||
const ShipLayoutConfig& currentLayout)
|
||||
{
|
||||
const double prevSpeed = m_gameWorldView->getGameSpeed();
|
||||
m_gameWorldView->setGameSpeed(0.0);
|
||||
ModalPauseScope pause(*m_gameWorldView);
|
||||
|
||||
std::set<std::string> unlockedModuleIds;
|
||||
for (const ModuleDef& def : m_sim->getConfig().modules.modules)
|
||||
@@ -264,18 +272,15 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<CommandRequestedEvent>(command));
|
||||
}
|
||||
|
||||
m_gameWorldView->setGameSpeed(prevSpeed);
|
||||
m_gameWorldView->resetFrameTimer();
|
||||
}
|
||||
|
||||
void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event)
|
||||
{
|
||||
// A construction site has no Building yet; fall back to its site record so
|
||||
// the shipyard layout can be configured before it is built (REQ-BLD-SITE-CONFIG).
|
||||
const Building* b = m_sim->getBuildings().findBuilding(event->shipyardId);
|
||||
const Building* b = findBuilding(m_sim->getFactoryState(), event->shipyardId);
|
||||
const ConstructionSite* s =
|
||||
b ? nullptr : m_sim->getBuildings().findSite(event->shipyardId);
|
||||
b ? nullptr : findSite(m_sim->getFactoryState(), event->shipyardId);
|
||||
if (!b && !s)
|
||||
{
|
||||
return;
|
||||
@@ -296,18 +301,15 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
|
||||
|
||||
void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event)
|
||||
{
|
||||
const double prevSpeed = m_gameWorldView->getGameSpeed();
|
||||
m_gameWorldView->setGameSpeed(0.0);
|
||||
ModalPauseScope pause(*m_gameWorldView);
|
||||
|
||||
// A construction site has no Building yet; fall back to its site record so
|
||||
// the recipe/schematic can be chosen before it is built (REQ-BLD-SITE-CONFIG).
|
||||
const Building* b = m_sim->getBuildings().findBuilding(event->buildingId);
|
||||
const Building* b = findBuilding(m_sim->getFactoryState(), event->buildingId);
|
||||
const ConstructionSite* s =
|
||||
b ? nullptr : m_sim->getBuildings().findSite(event->buildingId);
|
||||
b ? nullptr : findSite(m_sim->getFactoryState(), event->buildingId);
|
||||
if (!b && !s)
|
||||
{
|
||||
m_gameWorldView->setGameSpeed(prevSpeed);
|
||||
m_gameWorldView->resetFrameTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -328,11 +330,7 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
|
||||
|
||||
bool autoOpenLayout = false;
|
||||
std::string chosenSchematic;
|
||||
// Item icons live beside the config dir, mirroring the buildings icon path
|
||||
// (REQ-UI-ITEM-ICON, REQ-UI-BUILD-ICON).
|
||||
const QString itemIconDir = QDir::cleanPath(
|
||||
QString::fromStdString(m_configDir) + "/../icons/items");
|
||||
RecipeSelectionDialog dialog(options, title, itemIconDir, this);
|
||||
RecipeSelectionDialog dialog(options, title, m_itemIcons.get(), this);
|
||||
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
|
||||
{
|
||||
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
|
||||
@@ -350,13 +348,11 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
|
||||
}
|
||||
}
|
||||
|
||||
m_gameWorldView->setGameSpeed(prevSpeed);
|
||||
m_gameWorldView->resetFrameTimer();
|
||||
|
||||
// The SetRecipeCommand above is queued (drains on a later frame) and clears
|
||||
// the shipyard's layout, so open the dialog with the chosen schematic and an
|
||||
// empty layout rather than reading the not-yet-updated building state. Speed
|
||||
// is already restored so the helper snapshots the real speed to restore.
|
||||
// is restored first so the helper snapshots the real speed to restore.
|
||||
pause.restore();
|
||||
if (autoOpenLayout)
|
||||
{
|
||||
openShipLayoutDialog(event->buildingId, chosenSchematic, ShipLayoutConfig{});
|
||||
@@ -382,24 +378,14 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
|
||||
|
||||
if (box.clickedButton() == restartBtn)
|
||||
{
|
||||
std::shared_ptr<GameConfig> newConfig;
|
||||
try
|
||||
std::optional<GameConfig> newConfig = reloadConfig();
|
||||
if (!newConfig.has_value())
|
||||
{
|
||||
newConfig = std::make_shared<GameConfig>(
|
||||
ConfigLoader::loadFromDirectory(m_configDir));
|
||||
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
||||
m_visuals = std::move(newVisuals);
|
||||
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
QMessageBox::critical(this, tr("Config Error"),
|
||||
tr("Failed to reload config:\n%1").arg(e.what()));
|
||||
return;
|
||||
}
|
||||
// Restart is a command boundary; the view resets when the drain applies it.
|
||||
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
|
||||
command->config = std::move(newConfig);
|
||||
command->config = std::make_shared<GameConfig>(std::move(*newConfig));
|
||||
command->seed = std::random_device{}();
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<CommandRequestedEvent>(command));
|
||||
@@ -429,21 +415,17 @@ void MainWindow::handleEvent(std::shared_ptr<const WinEvent> /*event*/)
|
||||
|
||||
if (box.clickedButton() == restartBtn)
|
||||
{
|
||||
try
|
||||
std::optional<GameConfig> newConfig = reloadConfig();
|
||||
if (!newConfig.has_value())
|
||||
{
|
||||
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
|
||||
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
||||
m_visuals = std::move(newVisuals);
|
||||
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
|
||||
m_sim->reset(std::move(newConfig));
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
QMessageBox::critical(this, tr("Config Error"),
|
||||
tr("Failed to reload config:\n%1").arg(e.what()));
|
||||
return;
|
||||
}
|
||||
m_gameWorldView->resetForNewGame();
|
||||
// Restart is a command boundary; the view resets when the drain applies it.
|
||||
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
|
||||
command->config = std::make_shared<GameConfig>(std::move(*newConfig));
|
||||
command->seed = std::random_device{}();
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<CommandRequestedEvent>(command));
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user