29 Commits

Author SHA1 Message Date
60cc187d92 add BuildingGrid to manage tile occupancy 2026-08-04 18:26:01 +02:00
3990351a16 share BuildingSystem's free functions instead of copying them 2026-08-04 18:24:38 +02:00
bd344e4fbe add FieldSelectionPanel for extracting the ships/stations/debris selection 2026-08-04 18:23:28 +02:00
64c344c3a3 correct the belt subsystem interface description in architecture.md 2026-08-04 18:12:02 +02:00
02c7fed9b4 allow "auto" for named local lambdas and iterator types via claude.md 2026-08-04 18:11:37 +02:00
5d4a975384 cover unlock state in the determinism tests 2026-08-04 18:11:03 +02:00
475df0e5fd extract unlock state from Simulation to UnlockState class 2026-08-04 18:10:43 +02:00
61634f6fd2 move the shared TOML helpers into the utility namespace to avoid name collisions 2026-08-04 18:05:52 +02:00
c8ff7da345 extract load methods into their own files 2026-08-04 18:05:27 +02:00
d664ab54cc extract shared TOML helpers into TomlHelpers.h/.cpp 2026-08-04 18:02:23 +02:00
906000b0e9 drop the dead payloads from the state-change events 2026-08-03 22:02:47 +02:00
d9ef6aa728 make HeaderBar read the tick, artifacts and boss wave from the simulation instead of event 2026-08-03 22:02:19 +02:00
b44a85685e make BlueprintPanel read the block stock from the simulation instead of event 2026-08-03 22:02:05 +02:00
edd1c31785 remove duplicate findModuleDef from ShipLayoutPreview 2026-08-03 22:00:25 +02:00
785ce3ebfe remove duplicate findBuildingDef from BuildingSystem 2026-08-03 21:14:13 +02:00
7017f8b4dc route the win-path restart through ResetCommand 2026-08-03 21:13:43 +02:00
77a842f884 dedupe AttackExecutor and RepairExecutor via executeOrbitAndAssign 2026-08-03 21:13:24 +02:00
d5ba72d554 add ModalPauseScope for the pause-around-modal idiom 2026-08-03 21:11:52 +02:00
83177729e9 extract MainWindow::reloadConfig 2026-08-03 21:11:02 +02:00
a8e933b7f2 drop EntityAdmin::add in favour of addComponent 2026-08-03 21:09:18 +02:00
e02e323cb2 share a single ItemIconCache across the UI 2026-08-03 21:08:19 +02:00
b4e622daa5 extract the shared Centroid helper into ai/Centroid.h 2026-08-03 21:06:06 +02:00
1150985c1f share one loadTestConfig() helper across the tests 2026-08-03 21:05:28 +02:00
594c3b93c5 make HeaderBar read block stock and expansion cost from the simulation 2026-08-03 21:04:43 +02:00
932b57720c dedupe tunnel lookup and key tunnel tiles by QPoint 2026-08-03 21:02:06 +02:00
ca727bef35 extract Simulation::initializeSubsystems to remove duplicate code 2026-08-03 20:59:00 +02:00
553a7e0701 add findShipDef/findModuleDef/findRecipeDef to config structs and re-use them in the rest of the code base 2026-08-03 20:57:31 +02:00
af6828c348 remove duplicate findBuildingDef from GameWorldView 2026-08-03 20:50:03 +02:00
3671e1d7e6 fix splitter filters being lost when rotating in place and add test 2026-08-03 20:49:10 +02:00
91 changed files with 3113 additions and 2661 deletions

View File

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

View File

@@ -136,17 +136,43 @@ Belts and splitters are their own specialized subsystem. Belt items are **not**
### Public Interface ### Public Interface
Narrow and representation-agnostic: `BeltSystem.h` is authoritative. The surface is wider than the original design sketch — 15 public methods in five groups, not the 5-method port interface this section used to describe:
```cpp ```cpp
class BeltSystem { class BeltSystem {
public: public:
bool tryPutItem(Port port, Item item); // Placement — belts/splitters/tunnels are Buildings for cost and
std::optional<Item> tryTakeItem(Port port); // 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 clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR
void tick(); void tick();
// Rendering
void forEachVisualItem(QRect viewportTiles, void forEachVisualItem(QRect viewportTiles,
std::function<void(VisualItem)> visit) const; std::function<void(VisualItem)> visit) const;
// Determinism (docs/replay_design.md)
void appendChecksum(Hasher& hasher) const;
}; };
struct VisualItem { struct VisualItem {
@@ -155,12 +181,12 @@ struct VisualItem {
}; };
``` ```
Buildings interact with belts only through port-level push and pull. Rendering reads only through `forEachVisualItem`. No other system ever asks "what is on tile X". Item *transport* is still reached only through push and pull: `tryPutItem` / `tryTakeItem` move items, `peekItem` reveals the leading item's type but never an identity, and rendering reads only through `forEachVisualItem`. The growth is in tile **topology** — placement, removal and splitter filters — which `BuildingSystem` drives because belts are `Building`s for cost, construction and deconstruction. That coupling is real and is not going away.
### Implementation Strategy ### Implementation Strategy
- v1: per-tile representation. Each belt tile stores up to 2 items with a progress value in `[0, 1]` along the tile's belt direction. Sufficient for the scale this game targets. - v1: per-tile representation. Each belt tile stores up to 2 items with a progress value in `[0, 1]` along the tile's belt direction. Sufficient for the scale this game targets.
- v2 (optional, only if v1 profiles poorly): Factorio-style belt-segment compression. Because the public interface never exposes tile-level item identity, migration is internal to the subsystem. - v2 (optional, only if v1 profiles poorly): Factorio-style belt-segment compression. The migration argument still holds for the item representation, since no method exposes tile-level item identity — but a v2 would have to keep the placement and splitter-filter methods working per tile, which is a stronger constraint than this section originally implied.
### Rendering Note ### Rendering Note

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,3 +38,17 @@ std::string buildingTypeId(BuildingType type)
} }
return ""; return "";
} }
bool isAutoRecipeBuildingType(BuildingType type)
{
return type == BuildingType::Smelter
|| type == BuildingType::ReprocessingPlant;
}
bool isBeltSubsystemType(BuildingType type)
{
return type == BuildingType::Belt
|| type == BuildingType::Splitter
|| type == BuildingType::TunnelEntry
|| type == BuildingType::TunnelExit;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,63 +6,13 @@
#include <random> #include <random>
#include <set> #include <set>
#include "PortGeometry.h"
#include "StateChecksum.h" #include "StateChecksum.h"
#include "SurfaceMask.h" #include "SurfaceMask.h"
#include "tracing.h" #include "tracing.h"
namespace namespace
{ {
// 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)
{
return type == BuildingType::Smelter
|| type == BuildingType::ReprocessingPlant;
}
// 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)
{
return type == BuildingType::Belt
|| type == BuildingType::Splitter
|| type == BuildingType::TunnelEntry
|| type == BuildingType::TunnelExit;
}
// 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).
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).
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;
}
// An input belt accepts a new item at progress 0.0 only when it holds fewer than // An input belt accepts a new item at progress 0.0 only when it holds fewer than
// three items and the entry slot is clear (nothing within a quarter tile of 0.0), // three items and the entry slot is clear (nothing within a quarter tile of 0.0),
// matching the belt packing used elsewhere (REQ-GW-BELT-CAPACITY). // matching the belt packing used elsewhere (REQ-GW-BELT-CAPACITY).
@@ -95,55 +45,6 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
// Private helpers // Private helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const BuildingDef* BuildingSystem::findBuildingDef(BuildingType type) const
{
for (const BuildingDef& def : m_config.buildings.buildings)
{
if (def.type == type)
{
return &def;
}
}
return nullptr;
}
const RecipeDef* BuildingSystem::findRecipe(const std::string& id,
BuildingType type) const
{
for (const RecipeDef& recipe : m_config.recipes.recipes)
{
if (recipe.id == id && recipe.building == type)
{
return &recipe;
}
}
return nullptr;
}
const ShipDef* BuildingSystem::findShipDef(const std::string& id) const
{
for (const ShipDef& def : m_config.ships.ships)
{
if (def.id == id)
{
return &def;
}
}
return nullptr;
}
const ModuleDef* BuildingSystem::findModuleDef(const std::string& id) const
{
for (const ModuleDef& def : m_config.modules.modules)
{
if (def.id == id)
{
return &def;
}
}
return nullptr;
}
void BuildingSystem::initBuffers(Building& b, const RecipeDef& recipe) const void BuildingSystem::initBuffers(Building& b, const RecipeDef& recipe) const
{ {
b.inputBuffer.counts.clear(); b.inputBuffer.counts.clear();
@@ -237,7 +138,7 @@ void BuildingSystem::initShipyardBuffers(Building& b) const
b.inputBuffer.caps.clear(); b.inputBuffer.caps.clear();
b.outputBuffer.items.clear(); b.outputBuffer.items.clear();
b.outputBuffer.capacity = 0; b.outputBuffer.capacity = 0;
const ShipDef* def = findShipDef(b.recipeId); const ShipDef* def = m_config.ships.findShipDef(b.recipeId);
if (!def) if (!def)
{ {
return; return;
@@ -252,7 +153,7 @@ void BuildingSystem::initShipyardBuffers(Building& b) const
{ {
for (const PlacedModule& pm : b.shipLayout->placedModules) for (const PlacedModule& pm : b.shipLayout->placedModules)
{ {
const ModuleDef* modDef = findModuleDef(pm.moduleId); const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
if (!modDef) if (!modDef)
{ {
continue; continue;
@@ -272,7 +173,7 @@ void BuildingSystem::initSalvageBayBuffer(Building& b) const
// Salvage Bay has no recipe-driven buffer; its output-buffer holding size for // Salvage Bay has no recipe-driven buffer; its output-buffer holding size for
// ship drop-off is config-defined (REQ-BLD-SALVAGE-BAY). // ship drop-off is config-defined (REQ-BLD-SALVAGE-BAY).
b.outputBuffer.items.clear(); b.outputBuffer.items.clear();
const BuildingDef* def = findBuildingDef(BuildingType::SalvageBay); const BuildingDef* def = m_config.buildings.findBuildingDef(BuildingType::SalvageBay);
b.outputBuffer.capacity = b.outputBuffer.capacity =
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0; (def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0;
} }
@@ -345,7 +246,7 @@ std::vector<Port> BuildingSystem::getInputPorts(BuildingId id) const
{ {
// A site stores no ports; derive its output ports from the mask (absolute) // A site stores no ports; derive its output ports from the mask (absolute)
// and run the same input-edge scan (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS). // and run the same input-edge scan (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS).
const BuildingDef* def = findBuildingDef(site->type); const BuildingDef* def = m_config.buildings.findBuildingDef(site->type);
if (def == nullptr) { return {}; } if (def == nullptr) { return {}; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, site->rotation); const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, site->rotation);
std::vector<Port> outputPortsAbsolute; std::vector<Port> outputPortsAbsolute;
@@ -391,7 +292,7 @@ std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe
std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor, std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor,
Rotation rotation, Tick currentTick) Rotation rotation, Tick currentTick)
{ {
const BuildingDef* def = findBuildingDef(type); const BuildingDef* def = m_config.buildings.findBuildingDef(type);
assert(def != nullptr); assert(def != nullptr);
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation); const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation);
@@ -407,7 +308,7 @@ std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor
for (const QPoint& cell : mask.bodyCells) for (const QPoint& cell : mask.bodyCells)
{ {
const QPoint absCell = anchor + cell; const QPoint absCell = anchor + cell;
m_tileOccupancy[{absCell.x(), absCell.y()}] = id; m_grid.occupy(absCell, id);
} }
// Build construction site. // Build construction site.
@@ -455,7 +356,7 @@ bool BuildingSystem::bodyCellsWithinWorldBounds(const std::vector<QPoint>& bodyC
bool BuildingSystem::isPlacementValid(BuildingType type, QPoint anchor, bool BuildingSystem::isPlacementValid(BuildingType type, QPoint anchor,
Rotation rotation) const Rotation rotation) const
{ {
const BuildingDef* def = findBuildingDef(type); const BuildingDef* def = m_config.buildings.findBuildingDef(type);
if (def == nullptr) if (def == nullptr)
{ {
return false; return false;
@@ -510,11 +411,8 @@ int BuildingSystem::deconstruct(BuildingId id, Tick currentTick)
{ {
if (it->id == id) if (it->id == id)
{ {
const BuildingDef* def = findBuildingDef(it->type); const BuildingDef* def = m_config.buildings.findBuildingDef(it->type);
for (const QPoint& cell : it->bodyCells) m_grid.release(it->bodyCells);
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
m_constructionQueue.erase(it); m_constructionQueue.erase(it);
if (def) if (def)
{ {
@@ -646,7 +544,7 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
} }
else else
{ {
const RecipeDef* recipe = findRecipe(recipeId, building.type); const RecipeDef* recipe = m_config.recipes.findRecipeDef(recipeId, building.type);
if (recipe) if (recipe)
{ {
initBuffers(building, *recipe); initBuffers(building, *recipe);
@@ -701,7 +599,7 @@ BuildingSystem::getSiteSplitterInfo(BuildingId id) const
if (site.id != id) { continue; } if (site.id != id) { continue; }
if (site.type != BuildingType::Splitter) { return std::nullopt; } if (site.type != BuildingType::Splitter) { return std::nullopt; }
const BuildingDef* def = findBuildingDef(site.type); const BuildingDef* def = m_config.buildings.findBuildingDef(site.type);
const ParsedSurfaceMask mask = parseSurfaceMask( const ParsedSurfaceMask mask = parseSurfaceMask(
def ? def->surfaceMask : std::vector<std::string>{}, site.rotation); def ? def->surfaceMask : std::vector<std::string>{}, site.rotation);
if (mask.outputPorts.size() < 2) { return std::nullopt; } if (mask.outputPorts.size() < 2) { return std::nullopt; }
@@ -748,7 +646,7 @@ void BuildingSystem::tickConstruction(Tick currentTick)
// Guard: if somehow the front site was never started, start it now. // Guard: if somehow the front site was never started, start it now.
if (front.completesAt == 0) if (front.completesAt == 0)
{ {
const BuildingDef* def = findBuildingDef(front.type); const BuildingDef* def = m_config.buildings.findBuildingDef(front.type);
if (def) if (def)
{ {
front.completesAt = currentTick + secondsToTicks(def->constructionTimeSeconds); front.completesAt = currentTick + secondsToTicks(def->constructionTimeSeconds);
@@ -762,7 +660,7 @@ void BuildingSystem::tickConstruction(Tick currentTick)
} }
// Promote construction site to an operational Building. // Promote construction site to an operational Building.
const BuildingDef* def = findBuildingDef(front.type); const BuildingDef* def = m_config.buildings.findBuildingDef(front.type);
const ParsedSurfaceMask mask = parseSurfaceMask( const ParsedSurfaceMask mask = parseSurfaceMask(
def ? def->surfaceMask : std::vector<std::string>{}, def ? def->surfaceMask : std::vector<std::string>{},
front.rotation); front.rotation);
@@ -809,7 +707,7 @@ void BuildingSystem::tickConstruction(Tick currentTick)
} }
else else
{ {
const RecipeDef* recipe = findRecipe(building.recipeId, building.type); const RecipeDef* recipe = m_config.recipes.findRecipeDef(building.recipeId, building.type);
if (recipe) if (recipe)
{ {
initBuffers(building, *recipe); initBuffers(building, *recipe);
@@ -828,7 +726,8 @@ void BuildingSystem::tickConstruction(Tick currentTick)
// Start next queued site if present. // Start next queued site if present.
if (!m_constructionQueue.empty() && m_constructionQueue.front().completesAt == 0) if (!m_constructionQueue.empty() && m_constructionQueue.front().completesAt == 0)
{ {
const BuildingDef* nextDef = findBuildingDef(m_constructionQueue.front().type); const BuildingDef* nextDef =
m_config.buildings.findBuildingDef(m_constructionQueue.front().type);
if (nextDef) if (nextDef)
{ {
m_constructionQueue.front().completesAt = m_constructionQueue.front().completesAt =
@@ -896,11 +795,8 @@ void BuildingSystem::tickDeconstruction(Tick currentTick)
{ {
if (it->id != front.id) { continue; } if (it->id != front.id) { continue; }
const BuildingDef* def = findBuildingDef(it->type); const BuildingDef* def = m_config.buildings.findBuildingDef(it->type);
for (const QPoint& cell : it->bodyCells) m_grid.release(it->bodyCells);
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
m_buildings.erase(it); m_buildings.erase(it);
if (def) if (def)
{ {
@@ -1036,14 +932,13 @@ bool BuildingSystem::tryDirectCoupleDeposit(BuildingId producerId,
const Port& outputPort, const Port& outputPort,
const Item& item) const Item& item)
{ {
const std::map<std::pair<int, int>, BuildingId>::const_iterator occIt = const std::optional<BuildingId> ownerId = m_grid.findOwner(outputPort.tile);
m_tileOccupancy.find({outputPort.tile.x(), outputPort.tile.y()}); if (!ownerId.has_value() || *ownerId == producerId)
if (occIt == m_tileOccupancy.end() || occIt->second == producerId)
{ {
return false; return false;
} }
Building* consumer = findBuildingMutable(occIt->second); Building* consumer = findBuildingMutable(*ownerId);
if (!consumer) if (!consumer)
{ {
return false; // an unbuilt construction site, or not an operational building return false; // an unbuilt construction site, or not an operational building
@@ -1187,7 +1082,7 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
{ {
continue; continue;
} }
const ShipDef* shipDef = findShipDef(building.recipeId); const ShipDef* shipDef = m_config.ships.findShipDef(building.recipeId);
if (!shipDef) if (!shipDef)
{ {
continue; continue;
@@ -1252,7 +1147,7 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
{ {
for (const PlacedModule& pm : building.shipLayout->placedModules) for (const PlacedModule& pm : building.shipLayout->placedModules)
{ {
const ModuleDef* modDef = findModuleDef(pm.moduleId); const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
if (modDef) if (modDef)
{ {
totalTime += modDef->productionTimeSeconds; totalTime += modDef->productionTimeSeconds;
@@ -1466,7 +1361,7 @@ BuildingSystem::gatherCandidateRecipes(const Building& b) const
} }
else else
{ {
const RecipeDef* recipe = findRecipe(b.recipeId, b.type); const RecipeDef* recipe = m_config.recipes.findRecipeDef(b.recipeId, b.type);
if (recipe) if (recipe)
{ {
candidates.push_back(recipe); candidates.push_back(recipe);
@@ -1495,7 +1390,7 @@ std::map<std::string, int>
BuildingSystem::computeShipyardRequiredMaterials(const Building& b) const BuildingSystem::computeShipyardRequiredMaterials(const Building& b) const
{ {
std::map<std::string, int> requiredMaterials; std::map<std::string, int> requiredMaterials;
const ShipDef* shipDef = findShipDef(b.recipeId); const ShipDef* shipDef = m_config.ships.findShipDef(b.recipeId);
if (!shipDef) if (!shipDef)
{ {
return requiredMaterials; return requiredMaterials;
@@ -1508,7 +1403,7 @@ BuildingSystem::computeShipyardRequiredMaterials(const Building& b) const
{ {
for (const PlacedModule& pm : b.shipLayout->placedModules) for (const PlacedModule& pm : b.shipLayout->placedModules)
{ {
const ModuleDef* modDef = findModuleDef(pm.moduleId); const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
if (!modDef) if (!modDef)
{ {
continue; continue;
@@ -1625,7 +1520,7 @@ std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::getAllBeltTiles() cons
bool BuildingSystem::isTileOccupied(QPoint tile) const bool BuildingSystem::isTileOccupied(QPoint tile) const
{ {
return m_tileOccupancy.count({tile.x(), tile.y()}) > 0; return m_grid.isOccupied(tile);
} }
std::optional<BuildingId> BuildingSystem::findRotateInPlaceTarget( std::optional<BuildingId> BuildingSystem::findRotateInPlaceTarget(
@@ -1638,7 +1533,7 @@ std::optional<BuildingId> BuildingSystem::findRotateInPlaceTarget(
return std::nullopt; return std::nullopt;
} }
const BuildingDef* def = findBuildingDef(type); const BuildingDef* def = m_config.buildings.findBuildingDef(type);
if (!def) { return std::nullopt; } if (!def) { return std::nullopt; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rot); const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rot);
@@ -1646,15 +1541,14 @@ std::optional<BuildingId> BuildingSystem::findRotateInPlaceTarget(
// All body cells must be occupied by the same entity. // All body cells must be occupied by the same entity.
const QPoint firstAbs = anchor + mask.bodyCells[0]; const QPoint firstAbs = anchor + mask.bodyCells[0];
const auto firstIt = m_tileOccupancy.find({firstAbs.x(), firstAbs.y()}); const std::optional<BuildingId> firstOwner = m_grid.findOwner(firstAbs);
if (firstIt == m_tileOccupancy.end()) { return std::nullopt; } if (!firstOwner.has_value()) { return std::nullopt; }
const BuildingId candidateId = firstIt->second; const BuildingId candidateId = *firstOwner;
for (const QPoint& rel : mask.bodyCells) for (const QPoint& rel : mask.bodyCells)
{ {
const QPoint abs = anchor + rel; const std::optional<BuildingId> owner = m_grid.findOwner(anchor + rel);
const auto it = m_tileOccupancy.find({abs.x(), abs.y()}); if (!owner.has_value() || *owner != candidateId)
if (it == m_tileOccupancy.end() || it->second != candidateId)
{ {
return std::nullopt; return std::nullopt;
} }
@@ -1698,7 +1592,7 @@ void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
b.rotation = newRotation; b.rotation = newRotation;
const BuildingDef* def = findBuildingDef(b.type); const BuildingDef* def = m_config.buildings.findBuildingDef(b.type);
if (!def) { return; } if (!def) { return; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, newRotation); const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, newRotation);
@@ -1719,29 +1613,25 @@ void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
// the new port set (REQ-MAT-INPUT-INTAKE). // the new port set (REQ-MAT-INPUT-INTAKE).
b.incomingItems.assign(b.inputPorts.size(), {}); b.incomingItems.assign(b.inputPorts.size(), {});
// Re-register with BeltSystem (items on tile are discarded). // Re-register with BeltSystem (items on tile are discarded). A splitter's
if (b.type == BuildingType::Belt) // filters live in BeltSystem and would be lost by removeTile, so capture
// them first and hand them back to reregisterBeltTile (REQ-BLD-SPLITTER).
if (isBeltSubsystemType(b.type))
{ {
std::vector<ItemType> splitterFilterA;
std::vector<ItemType> splitterFilterB;
if (b.type == BuildingType::Splitter)
{
if (const std::optional<BeltSystem::SplitterInfo> info =
m_belts.getSplitterInfo(b.anchor))
{
splitterFilterA = info->filterA;
splitterFilterB = info->filterB;
}
}
m_belts.removeTile(b.anchor); m_belts.removeTile(b.anchor);
m_belts.placeBelt(b.anchor, newRotation); reregisterBeltTile(b, splitterFilterA, splitterFilterB);
}
else if (b.type == BuildingType::Splitter)
{
m_belts.removeTile(b.anchor);
assert(mask.outputPorts.size() >= 2);
m_belts.placeSplitter(b.anchor,
mask.outputPorts[0].direction,
mask.outputPorts[1].direction);
}
else if (b.type == BuildingType::TunnelEntry)
{
m_belts.removeTile(b.anchor);
m_belts.placeTunnelEntry(b.anchor, newRotation, m_config.world.tunnelMaxDistance_tiles);
}
else if (b.type == BuildingType::TunnelExit)
{
m_belts.removeTile(b.anchor);
m_belts.placeTunnelExit(b.anchor, newRotation);
} }
return; return;
@@ -1818,7 +1708,7 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type,
{ {
const QPoint absCell = anchor + cell; const QPoint absCell = anchor + cell;
building.bodyCells.push_back(absCell); building.bodyCells.push_back(absCell);
m_tileOccupancy[{absCell.x(), absCell.y()}] = id; m_grid.occupy(absCell, id);
} }
for (const Port& port : mask.outputPorts) for (const Port& port : mask.outputPorts)
{ {
@@ -1853,10 +1743,7 @@ bool BuildingSystem::removeBuilding(BuildingId id)
{ {
m_belts.removeTile(it->anchor); m_belts.removeTile(it->anchor);
} }
for (const QPoint& cell : it->bodyCells) m_grid.release(it->bodyCells);
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
m_buildings.erase(it); m_buildings.erase(it);
return true; return true;
} }
@@ -1875,18 +1762,12 @@ void BuildingSystem::forEachBuilding(std::function<void(Building&)> fn)
void BuildingSystem::registerTileOccupancy(const std::vector<QPoint>& cells, void BuildingSystem::registerTileOccupancy(const std::vector<QPoint>& cells,
BuildingId ownerPlaceholder) BuildingId ownerPlaceholder)
{ {
for (const QPoint& cell : cells) m_grid.occupy(cells, ownerPlaceholder);
{
m_tileOccupancy[{cell.x(), cell.y()}] = ownerPlaceholder;
}
} }
void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells) void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells)
{ {
for (const QPoint& cell : cells) m_grid.release(cells);
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
} }
namespace namespace
@@ -1995,12 +1876,5 @@ void BuildingSystem::appendChecksum(Hasher& hasher) const
for (const ItemType& type : e.splitterFilterB) { hasher.append(type.id); } for (const ItemType& type : e.splitterFilterB) { hasher.append(type.id); }
} }
// std::map iterates in sorted key order. m_grid.appendChecksum(hasher);
hasher.append(m_tileOccupancy.size());
for (const std::pair<const std::pair<int, int>, BuildingId>& entry : m_tileOccupancy)
{
hasher.append(entry.first.first);
hasher.append(entry.first.second);
hasher.append(entry.second);
}
} }

View File

@@ -15,6 +15,7 @@
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Building.h" #include "Building.h"
#include "BuildingGrid.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "BuildingId.h" #include "BuildingId.h"
#include "GameConfig.h" #include "GameConfig.h"
@@ -267,10 +268,6 @@ private:
// the status light (REQ-UI-STATUS-LIGHT). // the status light (REQ-UI-STATUS-LIGHT).
bool hasInputsToStart(const Building& b) const; 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; void initBuffers(Building& b, const RecipeDef& recipe) const;
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input // Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input
// caps span the union of every recipe of the building's type; no player // caps span the union of every recipe of the building's type; no player
@@ -313,6 +310,7 @@ private:
}; };
std::deque<DeconstructionEntry> m_deconstructionQueue; std::deque<DeconstructionEntry> m_deconstructionQueue;
// Maps every occupied body-cell coordinate to the entity that owns it. // The authority on which building owns which tile; every placement and removal
std::map<std::pair<int, int>, BuildingId> m_tileOccupancy; // path claims and releases its body cells here.
BuildingGrid m_grid;
}; };

View File

@@ -12,6 +12,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/Building.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
@@ -19,6 +20,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.h ${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.h
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h ${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h
${CMAKE_CURRENT_SOURCE_DIR}/UnlockState.h
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -35,11 +37,13 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.cpp ${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/UnlockState.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
PARENT_SCOPE PARENT_SCOPE
) )

View File

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

View File

@@ -6,7 +6,6 @@
#include "AiSystem.h" #include "AiSystem.h"
#include "Command.h" #include "Command.h"
#include "DisplayName.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "CombatSystem.h" #include "CombatSystem.h"
#include "DynamicBodyComponent.h" #include "DynamicBodyComponent.h"
@@ -43,39 +42,15 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
, m_hqProxyEntity(entt::null) , m_hqProxyEntity(entt::null)
, m_playerStation1Entity(entt::null) , m_playerStation1Entity(entt::null)
, m_playerStation2Entity(entt::null) , m_playerStation2Entity(entt::null)
, m_unlockState(m_config)
, m_beltSystem(m_config.world.beltSpeed_tps) , m_beltSystem(m_config.world.beltSpeed_tps)
{ {
m_currentEnemyStationEntities[0] = entt::null; m_currentEnemyStationEntities[0] = entt::null;
m_currentEnemyStationEntities[1] = entt::null; m_currentEnemyStationEntities[1] = entt::null;
m_buildingSystem = std::make_unique<BuildingSystem>( initializeSubsystems();
m_config,
m_beltSystem,
[this]() { return allocateBuildingId(); },
[this](int amount) { m_buildingBlocksStock += amount; },
[this](const std::string& id, QVector2D pos,
const std::optional<ShipLayoutConfig>& layout) {
const std::map<std::string, SchematicState>::const_iterator it =
m_schematicLevels.find(id);
if (it == m_schematicLevels.end() || !it->second.unlocked)
{
return;
}
m_shipSystem->spawn(id, pos, /*isEnemy=*/false, layout);
},
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>(m_config);
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
m_debrisSystem = std::make_unique<DebrisSystem>(m_admin);
m_salvagerSystem = std::make_unique<SalvagerSystem>(m_admin);
m_repairSystem = std::make_unique<RepairSystem>(m_admin);
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config);
initializeUnlockState(); m_unlockState.initializeUnlockState();
placeInitialStructures(); placeInitialStructures();
registerForEvents(); registerForEvents();
} }
@@ -120,6 +95,14 @@ void Simulation::reset(unsigned int seed)
m_admin.clear(); m_admin.clear();
m_beltSystem = BeltSystem(m_config.world.beltSpeed_tps); m_beltSystem = BeltSystem(m_config.world.beltSpeed_tps);
initializeSubsystems();
m_unlockState.initializeUnlockState();
placeInitialStructures();
}
void Simulation::initializeSubsystems()
{
m_buildingSystem = std::make_unique<BuildingSystem>( m_buildingSystem = std::make_unique<BuildingSystem>(
m_config, m_config,
m_beltSystem, m_beltSystem,
@@ -127,9 +110,7 @@ void Simulation::reset(unsigned int seed)
[this](int amount) { m_buildingBlocksStock += amount; }, [this](int amount) { m_buildingBlocksStock += amount; },
[this](const std::string& id, QVector2D pos, [this](const std::string& id, QVector2D pos,
const std::optional<ShipLayoutConfig>& layout) { const std::optional<ShipLayoutConfig>& layout) {
const std::map<std::string, SchematicState>::const_iterator it = if (!isSchematicUnlocked(id))
m_schematicLevels.find(id);
if (it == m_schematicLevels.end() || !it->second.unlocked)
{ {
return; return;
} }
@@ -146,58 +127,6 @@ void Simulation::reset(unsigned int seed)
m_repairSystem = std::make_unique<RepairSystem>(m_admin); m_repairSystem = std::make_unique<RepairSystem>(m_admin);
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng); m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config); m_combatSystem = std::make_unique<CombatSystem>(m_config);
initializeUnlockState();
placeInitialStructures();
}
void Simulation::initializeUnlockState()
{
// Cache the ids granted by some unlock group (REQ-LOCK-EXPLICIT); an item
// starts locked iff it is granted by a group.
m_grantedShipIds.clear();
m_grantedModuleIds.clear();
m_grantedBuildingIds.clear();
m_grantedRecipeIds.clear();
for (const UnlockGroupDef& group : m_config.unlocks.groups)
{
m_grantedShipIds.insert(group.ships.begin(), group.ships.end());
m_grantedModuleIds.insert(group.modules.begin(), group.modules.end());
m_grantedBuildingIds.insert(group.buildings.begin(), group.buildings.end());
m_grantedRecipeIds.insert(group.recipes.begin(), group.recipes.end());
}
m_awardedUnlockGroupIds.clear();
m_schematicLevels.clear();
for (const ShipDef& def : m_config.ships.ships)
{
SchematicState state;
state.unlocked = (m_grantedShipIds.count(def.id) == 0);
m_schematicLevels[def.id] = state;
}
m_moduleSchematicLevels.clear();
for (const ModuleDef& def : m_config.modules.modules)
{
SchematicState state;
state.unlocked = (m_grantedModuleIds.count(def.id) == 0);
m_moduleSchematicLevels[def.id] = state;
}
m_buildingLevels.clear();
for (const BuildingDef& def : m_config.buildings.buildings)
{
SchematicState state;
state.unlocked = (m_grantedBuildingIds.count(def.id) == 0);
m_buildingLevels[def.id] = state;
}
// Gated assembler recipes start locked; unlocked_at_start recipes are handled
// in the REQ-LOCK-IMPLICIT traversal, not tracked here.
m_unlockedRecipeSchematicIds.clear();
recomputeUnlocked();
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -633,9 +562,9 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
std::vector<const UnlockGroupDef*> pool; std::vector<const UnlockGroupDef*> pool;
for (const UnlockGroupDef& group : m_config.unlocks.groups) for (const UnlockGroupDef& group : m_config.unlocks.groups)
{ {
if (m_awardedUnlockGroupIds.count(group.id) > 0) { continue; } if (m_unlockState.isUnlockGroupAwarded(group.id)) { continue; }
if (group.stationLevel < 0 || group.stationLevel > destroyedStationLevel) { continue; } if (group.stationLevel < 0 || group.stationLevel > destroyedStationLevel) { continue; }
if (!prerequisitesSatisfied(group.requiredGroupIds)) { continue; } if (!m_unlockState.prerequisitesSatisfied(group.requiredGroupIds)) { continue; }
pool.push_back(&group); pool.push_back(&group);
} }
@@ -659,7 +588,7 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
const std::size_t endIdx = pool.size() - 1 - static_cast<std::size_t>(i); const std::size_t endIdx = pool.size() - 1 - static_cast<std::size_t>(i);
std::swap(pool[rollIdx], pool[endIdx]); std::swap(pool[rollIdx], pool[endIdx]);
m_pendingSchematicChoices.push_back(makeUnlockOption(*pool[endIdx])); m_pendingSchematicChoices.push_back(m_unlockState.makeUnlockOption(*pool[endIdx]));
} }
if (artifactRolled) if (artifactRolled)
@@ -671,48 +600,6 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
} }
} }
SchematicChoiceOption Simulation::makeUnlockOption(const UnlockGroupDef& group) const
{
SchematicChoiceOption option;
option.isArtifact = false;
option.unlockGroupId = group.id;
option.displayName = toDisplayName(group.id);
for (const std::string& id : group.ships)
{
option.grantedItems.push_back({SchematicType::Ship, id, toDisplayName(id)});
}
for (const std::string& id : group.modules)
{
option.grantedItems.push_back({SchematicType::Module, id, toDisplayName(id)});
}
for (const std::string& id : group.buildings)
{
option.grantedItems.push_back({SchematicType::Building, id, toDisplayName(id)});
}
for (const std::string& id : group.recipes)
{
option.grantedItems.push_back({SchematicType::Recipe, id, toDisplayName(id)});
}
// REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by
// awarding this whole group. Seed the hypothetical explicit-unlock sets with
// every grant (ship + module materials via step 1a, recipe outputs via step
// 1b), then diff against the current implicit set.
std::set<std::string> hypotheticalShipIds = getUnlockedShipSchematicIds();
std::set<std::string> hypotheticalModuleIds = getUnlockedModuleSchematicIds();
std::set<std::string> hypotheticalRecipeSchematicIds = m_unlockedRecipeSchematicIds;
for (const std::string& id : group.ships) { hypotheticalShipIds.insert(id); }
for (const std::string& id : group.modules) { hypotheticalModuleIds.insert(id); }
for (const std::string& id : group.recipes) { hypotheticalRecipeSchematicIds.insert(id); }
const UnlockedSets hypothetical = computeUnlockedSets(
hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds);
option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical);
return option;
}
void Simulation::applySchematicChoice(int choiceIndex) void Simulation::applySchematicChoice(int choiceIndex)
{ {
assert(choiceIndex >= 0 && choiceIndex < static_cast<int>(m_pendingSchematicChoices.size())); assert(choiceIndex >= 0 && choiceIndex < static_cast<int>(m_pendingSchematicChoices.size()));
@@ -729,204 +616,24 @@ void Simulation::applySchematicChoice(int choiceIndex)
return; return;
} }
// Award the whole unlock group (REQ-DEF-SCHEMATIC-DROP): unlock every granted m_unlockState.awardUnlockGroup(chosen);
// ship, module, building, and assembler recipe at once.
m_awardedUnlockGroupIds.insert(chosen.unlockGroupId);
for (const GrantedSchematic& grant : chosen.grantedItems)
{
switch (grant.type)
{
case SchematicType::Ship: m_schematicLevels.at(grant.id).unlocked = true; break;
case SchematicType::Module: m_moduleSchematicLevels.at(grant.id).unlocked = true; break;
case SchematicType::Building: m_buildingLevels.at(grant.id).unlocked = true; break;
case SchematicType::Recipe: m_unlockedRecipeSchematicIds.insert(grant.id); break;
}
}
recomputeUnlocked();
m_pendingSchematicChoices.clear(); m_pendingSchematicChoices.clear();
} }
// ---------------------------------------------------------------------------
// Implicit unlock computation (REQ-LOCK-IMPLICIT)
// ---------------------------------------------------------------------------
void Simulation::recomputeUnlocked()
{
const UnlockedSets result = computeUnlockedSets(
getUnlockedShipSchematicIds(), getUnlockedModuleSchematicIds(), m_unlockedRecipeSchematicIds);
m_unlockedItemIds = result.itemIds;
m_unlockedRecipeIds = result.recipeIds;
}
std::set<std::string> Simulation::getUnlockedShipSchematicIds() const
{
std::set<std::string> ids;
for (const auto& [id, state] : m_schematicLevels)
{
if (state.unlocked) { ids.insert(id); }
}
return ids;
}
std::set<std::string> Simulation::getUnlockedModuleSchematicIds() const
{
std::set<std::string> ids;
for (const auto& [id, state] : m_moduleSchematicLevels)
{
if (state.unlocked) { ids.insert(id); }
}
return ids;
}
bool Simulation::prerequisitesSatisfied(const std::vector<std::string>& requiredGroupIds) const
{
// A prerequisite is satisfied only once the named unlock group has been
// awarded (REQ-LOCK-PREREQ).
for (const std::string& groupId : requiredGroupIds)
{
if (m_awardedUnlockGroupIds.count(groupId) == 0) { return false; }
}
return true;
}
Simulation::UnlockedSets Simulation::computeUnlockedSets(
const std::set<std::string>& unlockedShipSchematicIds,
const std::set<std::string>& unlockedModuleSchematicIds,
const std::set<std::string>& unlockedRecipeSchematicIds) const
{
UnlockedSets result;
for (const ShipDef& def : m_config.ships.ships)
{
if (unlockedShipSchematicIds.count(def.id) == 0) { continue; }
for (const RecipeIngredient& mat : def.schematic.materials)
{
result.itemIds.insert(mat.item);
}
}
for (const ModuleDef& def : m_config.modules.modules)
{
if (unlockedModuleSchematicIds.count(def.id) == 0) { continue; }
for (const RecipeIngredient& mat : def.materials)
{
result.itemIds.insert(mat.item);
}
}
for (const RecipeDef& def : m_config.recipes.recipes)
{
// An assembler recipe seeds the base set when it is explicitly available:
// flagged unlocked_at_start (base recipes the graph can't reach), or a
// gated recipe whose unlock group has been awarded (REQ-LOCK-EXPLICIT).
if (def.building == BuildingType::Assembler
&& (def.unlockedAtStart || unlockedRecipeSchematicIds.count(def.id) > 0))
{
for (const RecipeOutput& out : def.outputs)
{
result.itemIds.insert(out.item);
}
}
}
bool changed = true;
while (changed)
{
changed = false;
for (const RecipeDef& recipe : m_config.recipes.recipes)
{
if (recipe.building != BuildingType::Miner
&& recipe.building != BuildingType::Smelter
&& recipe.building != BuildingType::Assembler)
{
continue;
}
// Skip a gated assembler recipe (granted by an unlock group) whose
// group has not yet been awarded (REQ-LOCK-IMPLICIT step 2).
if (recipe.building == BuildingType::Assembler
&& m_grantedRecipeIds.count(recipe.id) > 0
&& unlockedRecipeSchematicIds.count(recipe.id) == 0)
{
continue;
}
bool producesUnlocked = false;
for (const RecipeOutput& out : recipe.outputs)
{
if (result.itemIds.count(out.item) > 0)
{
producesUnlocked = true;
break;
}
}
if (!producesUnlocked) { continue; }
if (recipe.building == BuildingType::Miner
|| recipe.building == BuildingType::Assembler)
{
result.recipeIds.insert(recipe.id);
}
for (const RecipeIngredient& ing : recipe.inputs)
{
if (result.itemIds.insert(ing.item).second)
{
changed = true;
}
}
}
}
return result;
}
std::vector<std::string> Simulation::computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const
{
std::vector<std::string> recipeIds;
for (const std::string& recipeId : hypothetical.recipeIds)
{
if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; }
recipeIds.push_back(recipeId);
}
std::sort(recipeIds.begin(), recipeIds.end(),
[](const std::string& lhs, const std::string& rhs)
{
return toDisplayName(lhs) < toDisplayName(rhs);
});
return recipeIds;
}
bool Simulation::isRecipeUnlocked(const std::string& recipeId) const bool Simulation::isRecipeUnlocked(const std::string& recipeId) const
{ {
return m_unlockedRecipeIds.count(recipeId) > 0; return m_unlockState.isRecipeUnlocked(recipeId);
} }
bool Simulation::isItemUnlocked(const std::string& itemId) const bool Simulation::isItemUnlocked(const std::string& itemId) const
{ {
return m_unlockedItemIds.count(itemId) > 0; return m_unlockState.isItemUnlocked(itemId);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Determinism (see docs/replay_design.md) // Determinism (see docs/replay_design.md)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void Simulation::appendSchematicMap(Hasher& hasher,
const std::map<std::string, SchematicState>& levels)
{
hasher.append(levels.size());
for (const std::pair<const std::string, SchematicState>& entry : levels)
{
hasher.append(entry.first);
hasher.append(entry.second.unlocked);
}
}
void Simulation::appendStringSet(Hasher& hasher, const std::set<std::string>& ids)
{
hasher.append(ids.size());
for (const std::string& id : ids)
{
hasher.append(id);
}
}
unsigned long long Simulation::getRngFingerprint() const unsigned long long Simulation::getRngFingerprint() const
{ {
return fingerprintRng(m_rng); return fingerprintRng(m_rng);
@@ -957,13 +664,7 @@ unsigned long long Simulation::computeStateChecksum() const
hasher.append(getNormalGapRemainingTicks()); hasher.append(getNormalGapRemainingTicks());
// Schematic / unlock state (std::map and std::set iterate in sorted order). // Schematic / unlock state (std::map and std::set iterate in sorted order).
appendSchematicMap(hasher, m_schematicLevels); m_unlockState.appendChecksum(hasher);
appendSchematicMap(hasher, m_moduleSchematicLevels);
appendSchematicMap(hasher, m_buildingLevels);
appendStringSet(hasher, m_awardedUnlockGroupIds);
appendStringSet(hasher, m_unlockedRecipeSchematicIds);
appendStringSet(hasher, m_unlockedRecipeIds);
appendStringSet(hasher, m_unlockedItemIds);
// Subsystems contribute their own state. // Subsystems contribute their own state.
m_buildingSystem->appendChecksum(hasher); m_buildingSystem->appendChecksum(hasher);
@@ -1134,37 +835,17 @@ Tick Simulation::getNormalGapRemainingTicks() const
bool Simulation::isSchematicUnlocked(const std::string& shipId) const bool Simulation::isSchematicUnlocked(const std::string& shipId) const
{ {
const std::map<std::string, SchematicState>::const_iterator it = return m_unlockState.isSchematicUnlocked(shipId);
m_schematicLevels.find(shipId);
if (it == m_schematicLevels.end())
{
return false;
}
return it->second.unlocked;
} }
bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const
{ {
const std::map<std::string, SchematicState>::const_iterator it = return m_unlockState.isModuleSchematicUnlocked(moduleId);
m_moduleSchematicLevels.find(moduleId);
if (it == m_moduleSchematicLevels.end())
{
return false;
}
return it->second.unlocked;
} }
bool Simulation::isBuildingUnlocked(BuildingType type) const bool Simulation::isBuildingUnlocked(BuildingType type) const
{ {
const BuildingDef* def = m_config.buildings.findBuildingDef(type); return m_unlockState.isBuildingUnlocked(type);
if (def == nullptr)
{
// Types without a config entry (e.g. HQ, defence stations) are unrestricted.
return true;
}
const std::map<std::string, SchematicState>::const_iterator it =
m_buildingLevels.find(def->id);
return it == m_buildingLevels.end() ? true : it->second.unlocked;
} }
std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation) std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)

View File

@@ -1,10 +1,8 @@
#pragma once #pragma once
#include <map>
#include <memory> #include <memory>
#include <optional> #include <optional>
#include <random> #include <random>
#include <set>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -22,6 +20,7 @@
#include "Rotation.h" #include "Rotation.h"
#include "Tick.h" #include "Tick.h"
#include "TracePrintRequestedEvent.h" #include "TracePrintRequestedEvent.h"
#include "UnlockState.h"
class AiSystem; class AiSystem;
class BuildingSystem; class BuildingSystem;
@@ -165,6 +164,11 @@ private:
BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId. BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId.
// (Re-)create every owned subsystem. Shared by the constructor and reset();
// the construction order is load-bearing for determinism, so both paths must
// go through here. Only called before the first tick of a run.
void initializeSubsystems();
// Populate HQ, player defence stations, and the first enemy station set. // Populate HQ, player defence stations, and the first enemy station set.
void placeInitialStructures(); void placeInitialStructures();
@@ -198,69 +202,11 @@ private:
entt::entity m_playerStation2Entity; entt::entity m_playerStation2Entity;
entt::entity m_currentEnemyStationEntities[2]; entt::entity m_currentEnemyStationEntities[2];
// Schematic unlock state (REQ-DEF-SCHEMATIC-DROP). // Schematic/unlock bookkeeping (REQ-DEF-SCHEMATIC-DROP, REQ-LOCK-EXPLICIT,
struct SchematicState // REQ-LOCK-IMPLICIT, REQ-LOCK-BUILDING, REQ-LOCK-PREREQ). Constructed before
{ // initializeSubsystems() runs since BuildingSystem's spawn-gating lambda
bool unlocked; // calls into it (see initializeSubsystems()).
}; UnlockState m_unlockState;
std::map<std::string, SchematicState> m_schematicLevels;
std::map<std::string, SchematicState> m_moduleSchematicLevels;
std::map<std::string, SchematicState> m_buildingLevels;
// Unlock groups awarded so far (REQ-LOCK-EXPLICIT). Group ids.
std::set<std::string> m_awardedUnlockGroupIds;
// Ids granted by some unlock group, per kind — cached from config at init.
// An item starts locked iff it appears in the corresponding set.
std::set<std::string> m_grantedShipIds;
std::set<std::string> m_grantedModuleIds;
std::set<std::string> m_grantedBuildingIds;
std::set<std::string> m_grantedRecipeIds;
// Builds the granted-id sets and initializes all per-item unlock maps from
// them (shared by the constructor and reset). Ends with recomputeUnlocked().
void initializeUnlockState();
// Builds a schematic choice option for one unlock group (REQ-DEF-SCHEMATIC-DROP).
SchematicChoiceOption makeUnlockOption(const UnlockGroupDef& group) const;
// Determinism helpers — fold sub-state into the hasher in deterministic order.
static void appendSchematicMap(Hasher& hasher,
const std::map<std::string, SchematicState>& levels);
static void appendStringSet(Hasher& hasher, const std::set<std::string>& ids);
// Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT).
std::set<std::string> m_unlockedRecipeSchematicIds;
// Implicit unlock sets derived from schematic state (REQ-LOCK-IMPLICIT).
std::set<std::string> m_unlockedRecipeIds;
std::set<std::string> m_unlockedItemIds;
// Recomputes m_unlockedRecipeIds and m_unlockedItemIds from current schematic state.
void recomputeUnlocked();
// Result of the REQ-LOCK-IMPLICIT traversal.
struct UnlockedSets
{
std::set<std::string> itemIds;
std::set<std::string> recipeIds;
};
// Pure REQ-LOCK-IMPLICIT traversal given hypothetical explicit-unlock sets.
UnlockedSets computeUnlockedSets(const std::set<std::string>& unlockedShipSchematicIds,
const std::set<std::string>& unlockedModuleSchematicIds,
const std::set<std::string>& unlockedRecipeSchematicIds) const;
// Current explicit-unlock id sets, derived from m_schematicLevels / m_moduleSchematicLevels.
std::set<std::string> getUnlockedShipSchematicIds() const;
std::set<std::string> getUnlockedModuleSchematicIds() const;
// True if every prerequisite unlock group has been awarded (REQ-LOCK-PREREQ).
bool prerequisitesSatisfied(const std::vector<std::string>& requiredGroupIds) const;
// Ids (sorted alphabetically by display name) of the recipes in
// hypothetical.recipeIds that are not yet in m_unlockedRecipeIds.
std::vector<std::string> computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const;
EntityAdmin m_admin; EntityAdmin m_admin;
BeltSystem m_beltSystem; BeltSystem m_beltSystem;

View File

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

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

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

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

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

View File

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

View File

@@ -45,16 +45,12 @@
#include "ShipLayout.h" #include "ShipLayout.h"
#include "ShipSystem.h" #include "ShipSystem.h"
#include "Tick.h" #include "Tick.h"
#include "TestConfig.h"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fixture // Fixture
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
struct Fixture struct Fixture
{ {
GameConfig cfg; GameConfig cfg;
@@ -75,7 +71,7 @@ struct Fixture
std::vector<BeamFiredEvent> beamEvents; std::vector<BeamFiredEvent> beamEvents;
explicit Fixture() explicit Fixture()
: cfg(loadConfig()) : cfg(loadTestConfig())
, belts(cfg.world.beltSpeed_tps) , belts(cfg.world.beltSpeed_tps)
, nextBuildingId(1) , nextBuildingId(1)
, stock(0) , stock(0)

View File

@@ -20,6 +20,7 @@
#include "SimulationTestAccess.h" #include "SimulationTestAccess.h"
#include "SurfaceMask.h" #include "SurfaceMask.h"
#include "Tick.h" #include "Tick.h"
#include "TestConfig.h"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers that mirror the production implementations under test. // Helpers that mirror the production implementations under test.
@@ -147,11 +148,6 @@ static void applyRotationCCW(Blueprint& bp, const GameConfig& cfg)
} }
} }
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// Mirrors BlueprintPanel::createBlueprintFromSelection's player-placeable filter: // Mirrors BlueprintPanel::createBlueprintFromSelection's player-placeable filter:
// building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false // building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false
// are silently excluded before the bounding-box center and offsets are computed. // are silently excluded before the bounding-box center and offsets are computed.
@@ -312,7 +308,7 @@ TEST_CASE("Blueprint: non-axis-aligned offset rotates correctly", "[blueprint]")
TEST_CASE("Blueprint: CW constellation rotation updates offset and building rotation", "[blueprint]") TEST_CASE("Blueprint: CW constellation rotation updates offset and building rotation", "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
// Building one tile to the right, facing East. // Building one tile to the right, facing East.
Blueprint bp; Blueprint bp;
bp.name = "test"; bp.name = "test";
@@ -332,7 +328,7 @@ TEST_CASE("Blueprint: CW constellation rotation updates offset and building rota
TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rotation", "[blueprint]") TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rotation", "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
Blueprint bp; Blueprint bp;
bp.name = "test"; bp.name = "test";
BlueprintBuilding bb; BlueprintBuilding bb;
@@ -351,7 +347,7 @@ TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rot
TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "[blueprint]") TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
Blueprint bp; Blueprint bp;
bp.name = "test"; bp.name = "test";
BlueprintBuilding bb; BlueprintBuilding bb;
@@ -371,7 +367,7 @@ TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "
TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[blueprint]") TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
// Two buildings left and right of center; after CW they should be above and below. // Two buildings left and right of center; after CW they should be above and below.
Blueprint bp; Blueprint bp;
bp.name = "test"; bp.name = "test";
@@ -398,7 +394,7 @@ TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[
TEST_CASE("Blueprint: CW rotation keeps belt adjacent to miner output port", "[blueprint]") TEST_CASE("Blueprint: CW rotation keeps belt adjacent to miner output port", "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
// East miner: anchor (0,0), body cells (0,0),(1,0),(0,1). // East miner: anchor (0,0), body cells (0,0),(1,0),(0,1).
// Output port indicator '>' at (1,1) → port tile (1,1), direction East. // Output port indicator '>' at (1,1) → port tile (1,1), direction East.
@@ -436,7 +432,7 @@ TEST_CASE("Blueprint: CW rotation keeps belt adjacent to miner output port", "[b
TEST_CASE("Blueprint: CCW rotation keeps belt adjacent to miner output port", "[blueprint]") TEST_CASE("Blueprint: CCW rotation keeps belt adjacent to miner output port", "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
Blueprint bp; Blueprint bp;
bp.name = "test"; bp.name = "test";
@@ -473,7 +469,7 @@ TEST_CASE("Blueprint: CCW rotation keeps belt adjacent to miner output port", "[
TEST_CASE("Blueprint creation: non-player-placeable building alone yields empty blueprint", TEST_CASE("Blueprint creation: non-player-placeable building alone yields empty blueprint",
"[blueprint]") "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
// Hq has no entry in buildings.toml, so it is treated as non-player-placeable. // Hq has no entry in buildings.toml, so it is treated as non-player-placeable.
const BuildingSpec hq{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Hq, Rotation::East }; const BuildingSpec hq{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Hq, Rotation::East };
@@ -485,7 +481,7 @@ TEST_CASE("Blueprint creation: non-player-placeable building alone yields empty
TEST_CASE("Blueprint creation: mixed selection keeps only player-placeable buildings", TEST_CASE("Blueprint creation: mixed selection keeps only player-placeable buildings",
"[blueprint]") "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const BuildingSpec belt{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East }; const BuildingSpec belt{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East };
const BuildingSpec hq { QPoint(-3, 0), {QPoint(-3, 0)}, BuildingType::Hq, Rotation::East }; const BuildingSpec hq { QPoint(-3, 0), {QPoint(-3, 0)}, BuildingType::Hq, Rotation::East };
@@ -498,7 +494,7 @@ TEST_CASE("Blueprint creation: mixed selection keeps only player-placeable build
TEST_CASE("Blueprint creation: bounding box ignores non-player-placeable buildings", TEST_CASE("Blueprint creation: bounding box ignores non-player-placeable buildings",
"[blueprint]") "[blueprint]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
// Belt at (-5, 0). HQ at (-3, 0) — excluded from the blueprint. // Belt at (-5, 0). HQ at (-3, 0) — excluded from the blueprint.
// If HQ were included: bboxX = [-5, -3], center.x = -4, belt offset = -1. // If HQ were included: bboxX = [-5, -3], center.x = -4, belt offset = -1.
@@ -520,7 +516,7 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor",
// Simulate placing a two-belt blueprint with offsets (-1, 0) and (+1, 0) // Simulate placing a two-belt blueprint with offsets (-1, 0) and (+1, 0)
// at cursor tile (-5, 0). Expected anchors: (-6, 0) and (-4, 0). // at cursor tile (-5, 0). Expected anchors: (-6, 0) and (-4, 0).
// (Belt surface_mask ["A>"] — body at relative (0,0), port at (1,0).) // (Belt surface_mask ["A>"] — body at relative (0,0), port at (1,0).)
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
const QPoint cursor(-5, 0); const QPoint cursor(-5, 0);
const QPoint offsetA(-1, 0); const QPoint offsetA(-1, 0);
@@ -540,7 +536,7 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor",
TEST_CASE("Blueprint placement: cost is deducted for each building in sequence", "[blueprint]") TEST_CASE("Blueprint placement: cost is deducted for each building in sequence", "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
// Find belt cost from config (belt cost = 2 in test config). // Find belt cost from config (belt cost = 2 in test config).
int beltCost = 0; int beltCost = 0;
@@ -563,7 +559,7 @@ TEST_CASE("Blueprint placement: cost is deducted for each building in sequence",
TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId and deducts nothing", TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId and deducts nothing",
"[blueprint]") "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
// Find miner cost (15 in test config) — expensive enough to exhaust a small stock. // Find miner cost (15 in test config) — expensive enough to exhaust a small stock.
int minerCost = 0; int minerCost = 0;
@@ -594,7 +590,7 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId a
TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and charges nothing", TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and charges nothing",
"[blueprint]") "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
const int startBlocks = sim.getBuildingBlocksStock(); const int startBlocks = sim.getBuildingBlocksStock();
// A miner is all-asteroid; placing it in space (x >= 0) violates the terrain // A miner is all-asteroid; placing it in space (x >= 0) violates the terrain
@@ -610,7 +606,7 @@ TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and ch
TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies tiles, charges cost", TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies tiles, charges cost",
"[blueprint]") "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
int minerCost = 0; int minerCost = 0;
for (const BuildingDef& def : sim.getConfig().buildings.buildings) for (const BuildingDef& def : sim.getConfig().buildings.buildings)
@@ -663,7 +659,7 @@ TEST_CASE("Blueprint: building with no recipe has empty recipeId", "[blueprint]"
TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "[blueprint]") TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid. // Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value(); const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
@@ -679,7 +675,7 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
TEST_CASE("Blueprint placement: recipe transfers to building after construction completes", TEST_CASE("Blueprint placement: recipe transfers to building after construction completes",
"[blueprint]") "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value(); const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId); REQUIRE(id != kInvalidBuildingId);
@@ -705,7 +701,7 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]") TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
// Freshly placed → a ConstructionSite (not ticked to completion). A 1x1 belt keeps // Freshly placed → a ConstructionSite (not ticked to completion). A 1x1 belt keeps
// the body-cell bounding-box centered on the anchor, so a single site → zero offset. // the body-cell bounding-box centered on the anchor, so a single site → zero offset.
@@ -724,7 +720,7 @@ TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]")
TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blueprint]") TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
const BuildingId id = const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value(); SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
@@ -740,7 +736,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", TEST_CASE("Blueprint creation: mixed operational building and construction site are both captured",
"[blueprint]") "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
// Building A: place, configure, and tick to completion so it is operational. // Building A: place, configure, and tick to completion so it is operational.
const BuildingId idA = const BuildingId idA =
@@ -772,7 +768,7 @@ TEST_CASE("Blueprint creation: mixed operational building and construction site
TEST_CASE("Blueprint creation: selectionHasPlaceableBuilding sees a construction site", "[blueprint]") TEST_CASE("Blueprint creation: selectionHasPlaceableBuilding sees a construction site", "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
REQUIRE_FALSE(selectionHasPlaceableBuilding(sim, {})); REQUIRE_FALSE(selectionHasPlaceableBuilding(sim, {}));
@@ -787,7 +783,7 @@ TEST_CASE("Blueprint placement: interceptor schematic is unlocked at game start"
{ {
// "interceptor" has unlock_at_station_level = -1 in the test config. // "interceptor" has unlock_at_station_level = -1 in the test config.
// This confirms the guard in placeBlueprintAtTile passes for start-unlocked schematics. // This confirms the guard in placeBlueprintAtTile passes for start-unlocked schematics.
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
REQUIRE(sim.isSchematicUnlocked("interceptor")); REQUIRE(sim.isSchematicUnlocked("interceptor"));
} }
@@ -796,7 +792,7 @@ TEST_CASE("Blueprint placement: repair_ship schematic is locked at game start",
// "repair_ship" has unlock_at_station_level = 0 in the test config. // "repair_ship" has unlock_at_station_level = 0 in the test config.
// This confirms the guard in placeBlueprintAtTile blocks locked schematics, // This confirms the guard in placeBlueprintAtTile blocks locked schematics,
// leaving the shipyard's schematic unset. // leaving the shipyard's schematic unset.
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship")); REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
} }
@@ -842,7 +838,7 @@ TEST_CASE("Blueprint: building without layout has nullopt shipLayout", "[bluepri
TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout", "[blueprint]") TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout", "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
// Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East: // Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East:
// A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles. // A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles.
@@ -869,7 +865,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
TEST_CASE("Blueprint placement: ship layout transfers to building after construction completes", TEST_CASE("Blueprint placement: ship layout transfers to building after construction completes",
"[blueprint]") "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadTestConfig());
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East).value(); const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId); REQUIRE(id != kInvalidBuildingId);

View File

@@ -13,6 +13,7 @@
#include "ShipsConfig.h" #include "ShipsConfig.h"
#include "Simulation.h" #include "Simulation.h"
#include "SimulationTestAccess.h" #include "SimulationTestAccess.h"
#include "TestConfig.h"
// readBuildingConfig underpins the copy-settings gesture (REQ-BLD-COPY-CONFIG): // readBuildingConfig underpins the copy-settings gesture (REQ-BLD-COPY-CONFIG):
// it extracts a building's recipe / schematic / layout / splitter filters so they // it extracts a building's recipe / schematic / layout / splitter filters so they
@@ -21,11 +22,6 @@
namespace namespace
{ {
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
const BuildingDef* findDef(const GameConfig& cfg, BuildingType type) const BuildingDef* findDef(const GameConfig& cfg, BuildingType type)
{ {
for (const BuildingDef& def : cfg.buildings.buildings) for (const BuildingDef& def : cfg.buildings.buildings)
@@ -66,8 +62,8 @@ const ShipDef* findAvailableSchematic(const GameConfig& cfg)
TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[copyconfig]") TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[copyconfig]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
Simulation sim(loadConfig(), 7); Simulation sim(loadTestConfig(), 7);
const BuildingId id = placeOperational(sim, cfg, BuildingType::Miner, QPoint(0, 0)); const BuildingId id = placeOperational(sim, cfg, BuildingType::Miner, QPoint(0, 0));
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore"); SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
@@ -84,8 +80,8 @@ TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[copyconfig]"
TEST_CASE("readBuildingConfig leaves recipe unset when nothing is selected", TEST_CASE("readBuildingConfig leaves recipe unset when nothing is selected",
"[copyconfig]") "[copyconfig]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
Simulation sim(loadConfig(), 7); Simulation sim(loadTestConfig(), 7);
const BuildingId id = placeOperational(sim, cfg, BuildingType::Assembler, QPoint(0, 0)); const BuildingId id = placeOperational(sim, cfg, BuildingType::Assembler, QPoint(0, 0));
@@ -99,8 +95,8 @@ TEST_CASE("readBuildingConfig leaves recipe unset when nothing is selected",
TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout", TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout",
"[copyconfig]") "[copyconfig]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
Simulation sim(loadConfig(), 7); Simulation sim(loadTestConfig(), 7);
const ShipDef* schematic = findAvailableSchematic(cfg); const ShipDef* schematic = findAvailableSchematic(cfg);
REQUIRE(schematic != nullptr); REQUIRE(schematic != nullptr);
@@ -119,8 +115,8 @@ TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout",
TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]") TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
Simulation sim(loadConfig(), 7); Simulation sim(loadTestConfig(), 7);
// A placed miner enters the construction queue as a site (not yet operational). // A placed miner enters the construction queue as a site (not yet operational).
const BuildingId id = const BuildingId id =
@@ -140,6 +136,6 @@ TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]")
TEST_CASE("readBuildingConfig returns nullopt for an unknown id", "[copyconfig]") TEST_CASE("readBuildingConfig returns nullopt for an unknown id", "[copyconfig]")
{ {
Simulation sim(loadConfig(), 7); Simulation sim(loadTestConfig(), 7);
CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value()); CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value());
} }

View File

@@ -19,16 +19,12 @@
#include "Port.h" #include "Port.h"
#include "Rotation.h" #include "Rotation.h"
#include "Tick.h" #include "Tick.h"
#include "TestConfig.h"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fixture helpers // Fixture helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
static Item makeItem(const std::string& id) static Item makeItem(const std::string& id)
{ {
Item item; Item item;
@@ -86,7 +82,7 @@ static std::vector<Item> outputSideItems(const Building& b)
// Owns a BuildingSystem and its dependencies for placement-bounds tests. // Owns a BuildingSystem and its dependencies for placement-bounds tests.
struct PlacementFixture struct PlacementFixture
{ {
GameConfig cfg = loadConfig(); GameConfig cfg = loadTestConfig();
BeltSystem belts{cfg.world.beltSpeed_tps}; BeltSystem belts{cfg.world.beltSpeed_tps};
int stock = 0; int stock = 0;
std::mt19937 rng{0}; std::mt19937 rng{0};
@@ -110,7 +106,7 @@ struct PlacementFixture
TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building]") TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -218,7 +214,7 @@ TEST_CASE("BuildingSystem: isPlacementValid enforces terrain and world bounds",
TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after construction", TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after construction",
"[building]") "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -247,7 +243,7 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con
TEST_CASE("BuildingSystem: placed building enters construction queue", "[building]") TEST_CASE("BuildingSystem: placed building enters construction queue", "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -289,7 +285,7 @@ TEST_CASE("BuildingSystem: deconstructing a construction site removes it instant
TEST_CASE("BuildingSystem: first queued building starts construction immediately", TEST_CASE("BuildingSystem: first queued building starts construction immediately",
"[building]") "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -307,7 +303,7 @@ TEST_CASE("BuildingSystem: first queued building starts construction immediately
TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[building]") TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -329,7 +325,7 @@ TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[b
TEST_CASE("BuildingSystem: construction completes after configured duration", "[building]") TEST_CASE("BuildingSystem: construction completes after configured duration", "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -495,7 +491,7 @@ TEST_CASE("BuildingSystem: splitter filters survive a queue/un-queue round-trip"
TEST_CASE("BuildingSystem: second building starts after first completes", "[building]") TEST_CASE("BuildingSystem: second building starts after first completes", "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -525,7 +521,7 @@ TEST_CASE("BuildingSystem: second building starts after first completes", "[buil
TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[building]") TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -558,7 +554,7 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui
TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]") TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -598,7 +594,7 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites", "[building]") TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites", "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -638,7 +634,7 @@ TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites",
TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle state", TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle state",
"[building]") "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -679,7 +675,7 @@ TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle
TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing belt", TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing belt",
"[building]") "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
// Fast belt so items are immediately available for peek/take. // Fast belt so items are immediately available for peek/take.
BeltSystem belts(static_cast<double>(kTickRateHz)); BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0; int stock = 0;
@@ -722,7 +718,7 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
TEST_CASE("BuildingSystem: accepted input travels inward before entering the buffer", TEST_CASE("BuildingSystem: accepted input travels inward before entering the buffer",
"[building]") "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt: 1 tile/tick BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt: 1 tile/tick
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -763,7 +759,7 @@ TEST_CASE("BuildingSystem: accepted input travels inward before entering the buf
TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at the cap", TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at the cap",
"[building]") "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -805,7 +801,7 @@ TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at th
TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection", TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
"[building]") "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -851,7 +847,7 @@ TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete batch waits", TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete batch waits",
"[building]") "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -905,7 +901,7 @@ TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete
TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[building]") TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -944,7 +940,7 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui
TEST_CASE("BuildingSystem: output port couples directly into an adjacent input port", TEST_CASE("BuildingSystem: output port couples directly into an adjacent input port",
"[building]") "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -984,7 +980,7 @@ TEST_CASE("BuildingSystem: output port couples directly into an adjacent input p
TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stuck", TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stuck",
"[building]") "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -1023,7 +1019,7 @@ TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stu
TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production", TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production",
"[building]") "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -1066,7 +1062,7 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max output per roll", TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max output per roll",
"[building]") "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -1097,7 +1093,7 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then stalls", TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then stalls",
"[building]") "[building]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0; int stock = 0;
// Seed chosen so first roll produces 2-item output (iron_ingot), filling buffer. // Seed chosen so first roll produces 2-item output (iron_ingot), filling buffer.
@@ -1156,7 +1152,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is empty", TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is empty",
"[building][rotate-in-place]") "[building][rotate-in-place]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -1175,7 +1171,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a queued belt (same type, different rotation)", TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a queued belt (same type, different rotation)",
"[building][rotate-in-place]") "[building][rotate-in-place]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -1198,7 +1194,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a que
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a completed operational belt", TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a completed operational belt",
"[building][rotate-in-place]") "[building][rotate-in-place]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -1225,7 +1221,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building type differs", TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building type differs",
"[building][rotate-in-place]") "[building][rotate-in-place]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -1247,7 +1243,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building
TEST_CASE("BuildingSystem: findRotateInPlaceTarget never rotates a tunnel in place", TEST_CASE("BuildingSystem: findRotateInPlaceTarget never rotates a tunnel in place",
"[building][rotate-in-place]") "[building][rotate-in-place]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -1273,7 +1269,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget never rotates a tunnel in pla
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprints only partially overlap", TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprints only partially overlap",
"[building][rotate-in-place]") "[building][rotate-in-place]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -1297,7 +1293,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprin
TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-tile building with rotated ghost", TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-tile building with rotated ghost",
"[building][rotate-in-place]") "[building][rotate-in-place]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -1326,7 +1322,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t
TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a construction site", TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a construction site",
"[building][rotate-in-place]") "[building][rotate-in-place]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -1349,7 +1345,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a constru
TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of a queued site", TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of a queued site",
"[building][rotate-in-place]") "[building][rotate-in-place]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -1373,7 +1369,7 @@ TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of
TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direction on an operational building", TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direction on an operational building",
"[building][rotate-in-place]") "[building][rotate-in-place]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -1404,7 +1400,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direct
TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSystem so it still accepts items", TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSystem so it still accepts items",
"[building][rotate-in-place]") "[building][rotate-in-place]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps); BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
@@ -1427,6 +1423,36 @@ TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSyste
REQUIRE(belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"))); REQUIRE(belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore")));
} }
TEST_CASE("BuildingSystem: rotateInPlace preserves the output filters of a splitter "
"(REQ-BLD-SPLITTER)", "[building][rotate-in-place]")
{
PlacementFixture f;
const QPoint tile(5, 5);
const BuildingId id = f.bs.place(BuildingType::Splitter, tile, Rotation::East, 0).value();
// Run until construction completes, so the splitter is registered with BeltSystem.
Tick tick = 0;
while (f.bs.getAllBuildings().empty() && tick < 100000)
{
runTicks(f.bs, f.belts, 1, tick);
}
REQUIRE(f.bs.getAllBuildings().size() == 1);
const std::vector<ItemType> filterA{ ItemType{"iron_ore"} };
const std::vector<ItemType> filterB{ ItemType{"copper_ore"} };
f.belts.setSplitterFilters(tile, filterA, filterB);
f.bs.rotateInPlace(id, Rotation::North);
// The tile is re-registered with BeltSystem carrying the filters it had before
// the rotation — rotating must not reset a configured splitter to "accept all".
const std::optional<BeltSystem::SplitterInfo> info = f.belts.getSplitterInfo(tile);
REQUIRE(info.has_value());
REQUIRE(info->filterA == filterA);
REQUIRE(info->filterB == filterB);
}
TEST_CASE("BuildingSystem: splitter filters configured on a construction site carry over " TEST_CASE("BuildingSystem: splitter filters configured on a construction site carry over "
"to the built splitter (REQ-BLD-SITE-CONFIG)", "[building]") "to the built splitter (REQ-BLD-SITE-CONFIG)", "[building]")
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -2,15 +2,11 @@
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "ThreatCostCalculator.h" #include "ThreatCostCalculator.h"
#include "TestConfig.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
TEST_CASE("ThreatCostCalculator: miner item threat equals duration", "[threat]") TEST_CASE("ThreatCostCalculator: miner item threat equals duration", "[threat]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts; const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("iron_ore") == Approx(1.0)); 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]") TEST_CASE("ThreatCostCalculator: smelter item threat includes input costs", "[threat]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts; const ThreatCostTable& table = cfg.threatCosts;
// iron_ingot: duration 2.0 + iron_ore(1.0) * 2 = 4.0 // 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]") TEST_CASE("ThreatCostCalculator: assembler takes max across recipes", "[threat]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts; const ThreatCostTable& table = cfg.threatCosts;
// circuit_board has three non-reprocessing recipes: // 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]") TEST_CASE("ThreatCostCalculator: scrap threat is 1 / scrap_per_threat", "[threat]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts; const ThreatCostTable& table = cfg.threatCosts;
// REQ-THREAT-SCRAP: scrap threat is the constant 1 / world.scrap_per_threat. // 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]") TEST_CASE("ThreatCostCalculator: reprocessing-only item threat", "[threat]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts; const ThreatCostTable& table = cfg.threatCosts;
// advanced_alloy: reprocessing recipe with scrap*5, duration 3.0, probability 0.1 // 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]") TEST_CASE("ThreatCostCalculator: ship threat with default modules", "[threat]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts; const ThreatCostTable& table = cfg.threatCosts;
// interceptor: 10 + iron_ingot(4)*3 + circuit_board(28)*1 + laser_cannon(5 + 4*1) = 59.0 // 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]") TEST_CASE("ThreatCostCalculator: ship threat with custom modules", "[threat]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts; const ThreatCostTable& table = cfg.threatCosts;
// interceptor base: 10 + iron_ingot(4)*3 + circuit_board(28)*1 = 50.0 // 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]") TEST_CASE("ThreatCostCalculator: unknown ship returns zero", "[threat]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts; const ThreatCostTable& table = cfg.threatCosts;
double threat = calculateShipThreatCost(table, cfg, "nonexistent_ship", {}); 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. // 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]") 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; const ThreatCostTable& table = cfg.threatCosts;
// scrap_iron recipe: duration=1.0, 1 scrap (threat=1.0) -> 1 iron_ingot. // 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. // 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]") TEST_CASE("ThreatCostCalculator: per-unit division by output amount", "[threat]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts; const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("dual_wire") == Approx(2.0)); 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. // downstream_product = 2.0 + 80.0*1 = 82.0.
TEST_CASE("ThreatCostCalculator: downstream-of-reprocessing item resolves via fixpoint", "[threat]") TEST_CASE("ThreatCostCalculator: downstream-of-reprocessing item resolves via fixpoint", "[threat]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts; const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("advanced_alloy") == Approx(80.0)); 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. // expected threat = max(2.0, 29.0) = 29.0, not 2.0.
TEST_CASE("ThreatCostCalculator: staggered recipes committed only when all computable", "[threat]") TEST_CASE("ThreatCostCalculator: staggered recipes committed only when all computable", "[threat]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts; const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("staggered_item") == Approx(29.0)); CHECK(table.itemThreat.at("staggered_item") == Approx(29.0));

View File

@@ -19,15 +19,11 @@
#include "SimulationTestAccess.h" #include "SimulationTestAccess.h"
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "UnlocksConfig.h" #include "UnlocksConfig.h"
#include "TestConfig.h"
namespace namespace
{ {
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
void killEnemyStations(Simulation& sim) void killEnemyStations(Simulation& sim)
{ {
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>( sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
@@ -42,7 +38,7 @@ void killEnemyStations(Simulation& sim)
// bay together, gated to the given station level. // bay together, gated to the given station level.
GameConfig configWithSalvageGroup(int stationLevel) GameConfig configWithSalvageGroup(int stationLevel)
{ {
GameConfig cfg = loadConfig(); GameConfig cfg = loadTestConfig();
cfg.unlocks.groups.clear(); cfg.unlocks.groups.clear();
cfg.unlocks.groups.push_back( cfg.unlocks.groups.push_back(
UnlockGroupDef{"salvage_operations", stationLevel, {}, {}, {"salvager"}, {"salvage_bay"}, {}}); UnlockGroupDef{"salvage_operations", stationLevel, {}, {}, {"salvager"}, {"salvage_bay"}, {}});

View File

@@ -22,15 +22,11 @@
#include "SimulationTestAccess.h" #include "SimulationTestAccess.h"
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "UnlocksConfig.h" #include "UnlocksConfig.h"
#include "TestConfig.h"
namespace namespace
{ {
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
UnlockGroupDef& findGroup(GameConfig& cfg, const std::string& id) UnlockGroupDef& findGroup(GameConfig& cfg, const std::string& id)
{ {
for (UnlockGroupDef& group : cfg.unlocks.groups) 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 // quick_circuit (recipe group, level 0) would normally be eligible at the
// first station destruction. Gate it behind the repair_ship group (level 0, // first station destruction. Gate it behind the repair_ship group (level 0,
// locked at game start). // locked at game start).
GameConfig cfg = loadConfig(); GameConfig cfg = loadTestConfig();
findGroup(cfg, "quick_circuit").requiredGroupIds = {"repair_ship"}; findGroup(cfg, "quick_circuit").requiredGroupIds = {"repair_ship"};
Simulation sim(std::move(cfg), 123); 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", TEST_CASE("UnlockPrereq: the gated group becomes eligible once its prerequisite is awarded",
"[unlock_prereq]") "[unlock_prereq]")
{ {
GameConfig cfg = loadConfig(); GameConfig cfg = loadTestConfig();
findGroup(cfg, "quick_circuit").requiredGroupIds = {"repair_ship"}; findGroup(cfg, "quick_circuit").requiredGroupIds = {"repair_ship"};
Simulation sim(std::move(cfg), 123); 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) // 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. // lockable via new unlock groups, with armor_plate gated behind laser_cannon.
GameConfig cfg = loadConfig(); GameConfig cfg = loadTestConfig();
cfg.unlocks.groups.push_back( cfg.unlocks.groups.push_back(
UnlockGroupDef{"laser_cannon", 0, {}, {}, {"laser_cannon"}, {}, {}}); UnlockGroupDef{"laser_cannon", 0, {}, {}, {"laser_cannon"}, {}, {}});
cfg.unlocks.groups.push_back( cfg.unlocks.groups.push_back(

View File

@@ -25,11 +25,7 @@
#include "Tick.h" #include "Tick.h"
#include "ThreatCostCalculator.h" #include "ThreatCostCalculator.h"
#include "WaveSystem.h" #include "WaveSystem.h"
#include "TestConfig.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Threat accumulation // Threat accumulation
@@ -37,7 +33,7 @@ static GameConfig loadConfig()
TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]") TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
std::mt19937 rng(42); std::mt19937 rng(42);
WaveSystem ws(cfg, rng); WaveSystem ws(cfg, rng);
@@ -55,7 +51,7 @@ TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]")
TEST_CASE("WaveSystem: threatAccumulationRate matches the rate formula outside quiet windows", TEST_CASE("WaveSystem: threatAccumulationRate matches the rate formula outside quiet windows",
"[wave]") "[wave]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadTestConfig();
std::mt19937 rng(42); std::mt19937 rng(42);
WaveSystem ws(cfg, rng); WaveSystem ws(cfg, rng);
@@ -65,7 +61,7 @@ TEST_CASE("WaveSystem: threatAccumulationRate matches the rate formula outside q
TEST_CASE("WaveSystem: threatAccumulationRate is 0 during a quiet window", "[wave]") 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. // Start with the boss countdown already at the pre-boss quiet threshold.
cfg.world.waves.bossCountdownSeconds = cfg.world.waves.bossQuietBeforeSeconds; cfg.world.waves.bossCountdownSeconds = cfg.world.waves.bossQuietBeforeSeconds;
std::mt19937 rng(42); std::mt19937 rng(42);
@@ -83,7 +79,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]") 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); std::mt19937 rng(42);
WaveSystem ws(cfg, rng); WaveSystem ws(cfg, rng);
@@ -100,7 +96,7 @@ 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]") 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). // HQ is still a Building (for belt integration).
int hqCount = 0; int hqCount = 0;
@@ -126,7 +122,7 @@ TEST_CASE("WaveSystem: Simulation pre-places HQ + 2 player + 2 enemy stations",
TEST_CASE("WaveSystem: HQ has correct initial HP from config", "[wave]") TEST_CASE("WaveSystem: HQ has correct initial HP from config", "[wave]")
{ {
const Simulation sim(loadConfig(), 42); const Simulation sim(loadTestConfig(), 42);
const float expectedHp = const float expectedHp =
static_cast<float>(sim.getConfig().stations.hq.hpFormula.evaluate(0.0)); static_cast<float>(sim.getConfig().stations.hq.hpFormula.evaluate(0.0));
@@ -145,7 +141,7 @@ TEST_CASE("WaveSystem: HQ has correct initial HP from config", "[wave]")
TEST_CASE("WaveSystem: HQ anchor is at asteroid right edge", "[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 : sim.getBuildings().getAllBuildings())
{ {
@@ -162,7 +158,7 @@ TEST_CASE("WaveSystem: HQ anchor is at asteroid right edge", "[wave]")
TEST_CASE("WaveSystem: player stations have weapon set", "[wave]") TEST_CASE("WaveSystem: player stations have weapon set", "[wave]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
int armedPlayerStations = 0; int armedPlayerStations = 0;
sim.getAdmin().forEach<WeaponComponent, ModuleOwnerComponent>( sim.getAdmin().forEach<WeaponComponent, ModuleOwnerComponent>(
@@ -183,7 +179,7 @@ TEST_CASE("WaveSystem: player stations have weapon set", "[wave]")
TEST_CASE("WaveSystem: enemy 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; int armedEnemyStations = 0;
sim.getAdmin().forEach<WeaponComponent, ModuleOwnerComponent>( sim.getAdmin().forEach<WeaponComponent, ModuleOwnerComponent>(
@@ -208,7 +204,7 @@ TEST_CASE("WaveSystem: enemy stations have weapon set", "[wave]")
TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[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. // The maximum gap is gapMaxSeconds = 45s → 1350 ticks.
// Run 1500 ticks to guarantee at least one wave has triggered. // Run 1500 ticks to guarantee at least one wave has triggered.
@@ -234,7 +230,7 @@ TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave]
TEST_CASE("WaveSystem: all ships have positive dynamic threat cost", "[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) for (const ShipDef& def : cfg.ships.ships)
{ {
@@ -250,7 +246,7 @@ TEST_CASE("WaveSystem: all ships have positive dynamic threat cost", "[wave]")
TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[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. // Damage both enemy stations to 0.
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>( sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
@@ -273,7 +269,7 @@ TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]"
TEST_CASE("WaveSystem: push generates pending schematic choices", "[wave]") TEST_CASE("WaveSystem: push generates pending schematic choices", "[wave]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>( sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h) [](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
@@ -291,7 +287,7 @@ TEST_CASE("WaveSystem: push generates pending schematic choices", "[wave]")
TEST_CASE("WaveSystem: push schematic choices have valid ids", "[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>( sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h) [](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
@@ -339,7 +335,7 @@ TEST_CASE("WaveSystem: push schematic choices have valid ids", "[wave]")
TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]") TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>( sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h) [](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
@@ -359,7 +355,7 @@ TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]") TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadTestConfig(), 42);
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>( sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h) [](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
@@ -375,7 +371,7 @@ TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]
TEST_CASE("WaveSystem: push places new enemy stations further right", "[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. // Record the X position of the initial enemy stations.
int initialX = std::numeric_limits<int>::min(); int initialX = std::numeric_limits<int>::min();

View File

@@ -27,7 +27,6 @@ BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidge
: QWidget(parent) : QWidget(parent)
, m_sim(sim) , m_sim(sim)
, m_config(config) , m_config(config)
, m_currentBlocks(0)
{ {
QVBoxLayout* layout = new QVBoxLayout(this); QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(4, 4, 4, 4); layout->setContentsMargins(4, 4, 4, 4);
@@ -71,9 +70,8 @@ void BlueprintPanel::onSelectionChanged(const std::vector<BuildingId>& ids)
refreshButtonStates(); refreshButtonStates();
} }
void BlueprintPanel::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) void BlueprintPanel::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
{ {
m_currentBlocks = event->blocks;
refreshButtonStates(); refreshButtonStates();
} }
@@ -247,10 +245,12 @@ void BlueprintPanel::refreshButtonStates()
// A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE). // A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE).
m_createBtn->setEnabled(selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds)); 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) 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 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( m_blueprintButtons[static_cast<std::size_t>(i)]->setEnabled(
canAfford || m_activeIndex == i); canAfford || m_activeIndex == i);
} }

View File

@@ -53,10 +53,11 @@ private:
void loadFromDisk(); void loadFromDisk();
void saveToDisk() const; 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; Simulation* m_sim;
const GameConfig* m_config; const GameConfig* m_config;
std::vector<BuildingId> m_selectedBuildingIds; std::vector<BuildingId> m_selectedBuildingIds;
int m_currentBlocks;
std::optional<int> m_activeIndex; // nullopt = no blueprint selected std::optional<int> m_activeIndex; // nullopt = no blueprint selected
std::vector<Blueprint> m_blueprints; std::vector<Blueprint> m_blueprints;
std::vector<QPushButton*> m_blueprintButtons; std::vector<QPushButton*> m_blueprintButtons;

View File

@@ -184,13 +184,12 @@ namespace
BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config, BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config,
const std::string& iconDir, const std::string& iconDir,
const std::string& itemsIconDir, QWidget* parent) ItemIconCache* itemIcons, QWidget* parent)
: QWidget(parent) : QWidget(parent)
, m_sim(sim) , m_sim(sim)
, m_config(config) , m_config(config)
, m_iconDir(iconDir) , m_iconDir(iconDir)
, m_itemIcons(std::make_unique<ItemIconCache>( , m_itemIcons(itemIcons)
QString::fromStdString(itemsIconDir)))
{ {
QGridLayout* layout = new QGridLayout(this); QGridLayout* layout = new QGridLayout(this);
layout->setSpacing(4); layout->setSpacing(4);

View File

@@ -32,11 +32,12 @@ class BuildButtonGrid : public QWidget,
public: public:
// iconDir is the directory holding the per-building "<id>.svg" chip icons // 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 // (REQ-UI-BUILD-GRID), read from disk at runtime like the config files.
// building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Both are // itemIcons is the window-wide per-item icon cache and supplies the
// read from disk at runtime, like the config files. // 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, BuildButtonGrid(Simulation* sim, const GameConfig* config,
const std::string& iconDir, const std::string& itemsIconDir, const std::string& iconDir, ItemIconCache* itemIcons,
QWidget* parent = nullptr); QWidget* parent = nullptr);
~BuildButtonGrid() override; ~BuildButtonGrid() override;
@@ -65,7 +66,7 @@ private:
Simulation* m_sim; Simulation* m_sim;
const GameConfig* m_config; const GameConfig* m_config;
std::string m_iconDir; 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<BuildingType> m_types;
std::vector<QPushButton*> m_buttons; std::vector<QPushButton*> m_buttons;
std::map<BuildingType, int> m_costs; std::map<BuildingType, int> m_costs;

View File

@@ -4,10 +4,12 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h ${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h ${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h
@@ -29,6 +31,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp

View File

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

View File

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

View File

@@ -50,6 +50,7 @@
#include "HealthComponent.h" #include "HealthComponent.h"
#include "HqProxyComponent.h" #include "HqProxyComponent.h"
#include "ItemIconCache.h" #include "ItemIconCache.h"
#include "PortGeometry.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "RepairBehavior.h" #include "RepairBehavior.h"
#include "SalvageScrapBehavior.h" #include "SalvageScrapBehavior.h"
@@ -166,18 +167,6 @@ Rotation rotateCounterClockwise(Rotation r)
return Rotation::East; return Rotation::East;
} }
QPoint portBodyTile(QPoint portTile, Rotation direction)
{
switch (direction)
{
case Rotation::East: return portTile + QPoint(-1, 0);
case Rotation::West: return portTile + QPoint( 1, 0);
case Rotation::North: return portTile + QPoint( 0, 1);
case Rotation::South: return portTile + QPoint( 0, -1);
}
return portTile;
}
// Fill color for a building's status light per its production state // Fill color for a building's status light per its production state
// (REQ-UI-STATUS-LIGHT). // (REQ-UI-STATUS-LIGHT).
QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl) QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl)
@@ -197,11 +186,13 @@ QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl)
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, const std::string& configDir, const VisualsConfig* visuals, const std::string& configDir,
const ParsedReplay* replay, QWidget* parent) ItemIconCache* itemIcons, const ParsedReplay* replay,
QWidget* parent)
: QOpenGLWidget(parent) : QOpenGLWidget(parent)
, m_sim(sim) , m_sim(sim)
, m_config(config) , m_config(config)
, m_visuals(visuals) , m_visuals(visuals)
, m_itemIcons(itemIcons)
, m_commandManager(*sim) , m_commandManager(*sim)
, m_gameSpeedMultiplier(1.0) , m_gameSpeedMultiplier(1.0)
, m_prevNonZeroSpeed(1.0) , m_prevNonZeroSpeed(1.0)
@@ -223,11 +214,6 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
loadBuildingIcons(configDir); 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 = new QTimer(this);
m_renderTimer->setInterval(16); m_renderTimer->setInterval(16);
connect(m_renderTimer, &QTimer::timeout, this, &GameWorldView::onFrame); connect(m_renderTimer, &QTimer::timeout, this, &GameWorldView::onFrame);
@@ -406,26 +392,26 @@ void GameWorldView::onFrame()
{ {
m_lastTick = newTick; m_lastTick = newTick;
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<TickAdvancedEvent>(newTick)); std::make_shared<TickAdvancedEvent>());
} }
if (newBlocks != m_lastBlocks) if (newBlocks != m_lastBlocks)
{ {
m_lastBlocks = newBlocks; m_lastBlocks = newBlocks;
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuildingBlocksChangedEvent>(newBlocks)); std::make_shared<BuildingBlocksChangedEvent>());
} }
if (newExpCost != m_lastExpansionCost) if (newExpCost != m_lastExpansionCost)
{ {
m_lastExpansionCost = newExpCost; m_lastExpansionCost = newExpCost;
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExpansionCostChangedEvent>(newExpCost)); std::make_shared<ExpansionCostChangedEvent>());
} }
if (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown) if (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown)
{ {
m_lastBossCounter = newBoss; m_lastBossCounter = newBoss;
m_lastBossCountdown = newCountdown; m_lastBossCountdown = newCountdown;
EventManager::getInstance()->sendEventImmediately( 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 // Unlocked building set changes only after a drop is applied or on Restart
@@ -488,8 +474,7 @@ void GameWorldView::onFrame()
{ {
m_lastArtifactCount = currentArtifactCount; m_lastArtifactCount = currentArtifactCount;
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<ArtifactCountChangedEvent>( std::make_shared<ArtifactCountChangedEvent>());
currentArtifactCount, m_sim->getConfig().world.artifacts.artifactWinCount));
} }
update(); update();
@@ -669,15 +654,6 @@ void GameWorldView::clampScroll()
// Placement helpers // 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, bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
Rotation rot) const Rotation rot) const
{ {
@@ -689,7 +665,7 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
return false; return false;
} }
const BuildingDef* def = findBuildingDef(type); const BuildingDef* def = m_config->buildings.findBuildingDef(type);
if (!def) { return false; } if (!def) { return false; }
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rot); const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rot);
@@ -892,7 +868,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
{ {
continue; continue;
} }
const BuildingDef* def = findBuildingDef(bb.type); const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
if (def) { totalCost += def->cost; } if (def) { totalCost += def->cost; }
} }
if (m_sim->getBuildingBlocksStock() < totalCost) { return; } if (m_sim->getBuildingBlocksStock() < totalCost) { return; }
@@ -969,29 +945,39 @@ BuildingType GameWorldView::effectiveBuilderType() const
return inTunnelMode() ? m_tunnelGhostType : *m_builderType; 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 // 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 // single-cell tile, so a just-placed tunnel (not yet constructed) is matchable
// (REQ-BLD-TUNNEL-MODE, REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). // (REQ-BLD-TUNNEL-MODE, REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
std::map<std::pair<int, int>, TunnelTileInfo> tunnels; TunnelTileMap tunnels;
for (const Building& b : m_sim->getBuildings().getAllBuildings()) for (const Building& b : m_sim->getBuildings().getAllBuildings())
{ {
if (b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit) 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 : m_sim->getBuildings().getAllSites())
{ {
if (s.type == BuildingType::TunnelEntry || s.type == BuildingType::TunnelExit) 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; 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() void GameWorldView::updateTunnelGhost()
{ {
m_tunnelGhostType = BuildingType::TunnelEntry; m_tunnelGhostType = BuildingType::TunnelEntry;
@@ -1004,14 +990,8 @@ void GameWorldView::updateTunnelGhost()
return; return;
} }
const std::map<std::pair<int, int>, TunnelTileInfo> tunnels = collectTunnelTiles(); const TunnelTileMap tunnels = collectTunnelTiles();
const TunnelLookup lookup = [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo> const TunnelLookup lookup = makeTunnelLookup(tunnels);
{
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 TunnelCompletion completion = const TunnelCompletion completion =
resolveTunnelCompletion(lookup, m_ghostTile, m_ghostRotation, resolveTunnelCompletion(lookup, m_ghostTile, m_ghostRotation,
@@ -1133,7 +1113,7 @@ std::vector<GameWorldView::BeltDragResolved> GameWorldView::resolveBeltDragPath(
std::vector<BeltDragResolved> resolved; std::vector<BeltDragResolved> resolved;
resolved.reserve(m_beltDragPath.size()); 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 beltCost = (def != nullptr) ? def->cost : 0;
const int stock = m_sim->getBuildingBlocksStock(); const int stock = m_sim->getBuildingBlocksStock();
int spent = 0; int spent = 0;
@@ -1350,7 +1330,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
for (const Port& port : b.outputPorts) for (const Port& port : b.outputPorts)
{ {
drawPortGlyph(painter, portBodyTile(port.tile, port.direction), drawPortGlyph(painter, outputBodyTile(port.tile, port.direction),
port.direction, bv.outline, /*centered*/ false); port.direction, bv.outline, /*centered*/ false);
} }
@@ -1405,7 +1385,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
painter.setBrush(Qt::NoBrush); painter.setBrush(Qt::NoBrush);
painter.drawRect(bboxRect); painter.drawRect(bboxRect);
const BuildingDef* siteDef = findBuildingDef(s.type); const BuildingDef* siteDef = m_config->buildings.findBuildingDef(s.type);
if (siteDef) if (siteDef)
{ {
// Glyph + progress percentage // Glyph + progress percentage
@@ -1450,7 +1430,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
for (const Port& port : siteMask.outputPorts) for (const Port& port : siteMask.outputPorts)
{ {
const QPoint absBody = s.anchor const QPoint absBody = s.anchor
+ portBodyTile(port.tile, port.direction); + outputBodyTile(port.tile, port.direction);
drawPortGlyph(painter, absBody, port.direction, bv.outline, drawPortGlyph(painter, absBody, port.direction, bv.outline,
/*centered*/ false); /*centered*/ false);
} }
@@ -1988,21 +1968,15 @@ void GameWorldView::drawSelectedTunnelConnections(QPainter& painter)
{ {
if (m_selectedBuildingIds.empty()) { return; } if (m_selectedBuildingIds.empty()) { return; }
const std::map<std::pair<int, int>, TunnelTileInfo> tunnels = collectTunnelTiles(); const TunnelTileMap tunnels = collectTunnelTiles();
if (tunnels.empty()) { return; } if (tunnels.empty()) { return; }
const TunnelLookup lookup = [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo> const TunnelLookup lookup = makeTunnelLookup(tunnels);
{
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;
};
// Collect the tiles to highlight in a set so a connection selected from both ends // 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 // (or overlapping runs) is filled exactly once — filling a semi-transparent green
// twice would darken it (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). // 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) for (const BuildingId id : m_selectedBuildingIds)
{ {
std::optional<QPoint> anchor; std::optional<QPoint> anchor;
@@ -2032,15 +2006,15 @@ void GameWorldView::drawSelectedTunnelConnections(QPainter& painter)
(delta.y() > 0) - (delta.y() < 0)); (delta.y() > 0) - (delta.y() < 0));
for (QPoint t = *anchor; ; t += stepDir) for (QPoint t = *anchor; ; t += stepDir)
{ {
highlightTiles.insert({t.x(), t.y()}); highlightTiles.insert(t);
if (t == *partner) { break; } if (t == *partner) { break; }
} }
} }
const QColor green = m_visuals->overlays.tunnelPreview; 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);
} }
} }
@@ -2177,7 +2151,7 @@ void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type,
QPoint anchorTile, Rotation rotation, QPoint anchorTile, Rotation rotation,
bool valid, bool showPortTargetGlyphs) bool valid, bool showPortTargetGlyphs)
{ {
const BuildingDef* def = findBuildingDef(type); const BuildingDef* def = m_config->buildings.findBuildingDef(type);
if (!def) { return; } if (!def) { return; }
const std::map<BuildingType, BuildingVisuals>::const_iterator it = const std::map<BuildingType, BuildingVisuals>::const_iterator it =
@@ -2230,7 +2204,7 @@ void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type,
for (const Port& port : parsed.outputPorts) for (const Port& port : parsed.outputPorts)
{ {
drawPortGlyph(painter, anchorTile + portBodyTile(port.tile, port.direction), drawPortGlyph(painter, anchorTile + outputBodyTile(port.tile, port.direction),
port.direction, lineColor, /*centered*/ false); port.direction, lineColor, /*centered*/ false);
} }
@@ -2960,7 +2934,7 @@ void GameWorldView::rotateGhost(bool clockwise)
{ {
for (BlueprintBuilding& bb : m_blueprintMode->buildings) for (BlueprintBuilding& bb : m_blueprintMode->buildings)
{ {
const BuildingDef* def = findBuildingDef(bb.type); const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
if (!def) { continue; } if (!def) { continue; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation); const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
int minX = INT_MAX, minY = INT_MAX; int minX = INT_MAX, minY = INT_MAX;
@@ -3259,7 +3233,7 @@ void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotat
bool GameWorldView::canAfford(BuildingType type) const bool GameWorldView::canAfford(BuildingType type) const
{ {
const BuildingDef* def = findBuildingDef(type); const BuildingDef* def = m_config->buildings.findBuildingDef(type);
if (!def) { return false; } if (!def) { return false; }
return m_sim->getBuildingBlocksStock() >= def->cost; return m_sim->getBuildingBlocksStock() >= def->cost;
} }

View File

@@ -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, class GameWorldView : public QOpenGLWidget,
public CombinedEventHandler<BeamFiredEvent, public CombinedEventHandler<BeamFiredEvent,
BuildingTypeSelectedEvent, BuildingTypeSelectedEvent,
@@ -80,9 +83,12 @@ class GameWorldView : public QOpenGLWidget,
Q_OBJECT Q_OBJECT
public: 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, GameWorldView(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, const std::string& configDir, const VisualsConfig* visuals, const std::string& configDir,
const ParsedReplay* replay, QWidget* parent = nullptr); ItemIconCache* itemIcons, const ParsedReplay* replay,
QWidget* parent = nullptr);
~GameWorldView() override; ~GameWorldView() override;
double getGameSpeed() const; double getGameSpeed() const;
@@ -182,7 +188,6 @@ private:
void clampScroll(); void clampScroll();
bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const; 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> buildingAtTile(QPoint tile) const;
std::optional<BuildingId> siteAtTile(QPoint tile) const; std::optional<BuildingId> siteAtTile(QPoint tile) const;
// Ids of all buildings and construction sites whose footprint intersects // Ids of all buildings and construction sites whose footprint intersects
@@ -243,7 +248,10 @@ private:
void updateTunnelGhost(); void updateTunnelGhost();
// Indexes every tunnel entry/exit — built or still a construction site — by its // 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. // 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 // Draws the green connection highlight for every selected tunnel end that has a
// matching end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). // matching end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
void drawSelectedTunnelConnections(QPainter& painter); void drawSelectedTunnelConnections(QPainter& painter);
@@ -304,9 +312,10 @@ private:
}; };
std::map<BuildingType, BuildingIconRenderers> m_buildingIcons; std::map<BuildingType, BuildingIconRenderers> m_buildingIcons;
// Per-item icon cache (REQ-UI-ITEM-ICON), loaded from <configDir>/../icons/items. // Per-item icon cache (REQ-UI-ITEM-ICON), shared window-wide and owned by
// Shared draw path for belt and port items; pixmaps are cached per target size. // MainWindow. Shared draw path for belt and port items; pixmaps are cached
std::unique_ptr<ItemIconCache> m_itemIcons; // per target size.
ItemIconCache* m_itemIcons;
// Funnels all player input into the single Simulation::apply chokepoint. // Funnels all player input into the single Simulation::apply chokepoint.
CommandManager m_commandManager; CommandManager m_commandManager;

View File

@@ -17,6 +17,7 @@
#include "EventManager.h" #include "EventManager.h"
#include "IconCaption.h" #include "IconCaption.h"
#include "ItemIconCache.h" #include "ItemIconCache.h"
#include "Simulation.h"
#include "SpeedChangeRequestedEvent.h" #include "SpeedChangeRequestedEvent.h"
#include "Tick.h" #include "Tick.h"
@@ -30,11 +31,11 @@ namespace
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 }; const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
const int HeaderBar::kSpeedCount = 5; const int HeaderBar::kSpeedCount = 5;
HeaderBar::HeaderBar(const GameConfig* config, const std::string& itemsIconDir, HeaderBar::HeaderBar(const Simulation* sim, const GameConfig* config,
QWidget* parent) ItemIconCache* itemIcons, QWidget* parent)
: QWidget(parent) : QWidget(parent)
, m_itemIcons(std::make_unique<ItemIconCache>( , m_itemIcons(itemIcons)
QString::fromStdString(itemsIconDir))) , m_sim(sim)
{ {
QHBoxLayout* layout = new QHBoxLayout(this); QHBoxLayout* layout = new QHBoxLayout(this);
layout->setContentsMargins(8, 4, 8, 4); layout->setContentsMargins(8, 4, 8, 4);
@@ -106,25 +107,23 @@ HeaderBar::~HeaderBar()
unregisterForEvents(); 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( m_timeLabel->setText(
QString("%1:%2") QString("%1:%2")
.arg(totalSeconds / 60, 2, 10, QChar('0')) .arg(totalSeconds / 60, 2, 10, QChar('0'))
.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(); updateBlocksLabel();
updateExpandButton(); updateExpandButton();
} }
void HeaderBar::handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> event) void HeaderBar::handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> /*event*/)
{ {
m_expansionCost = event->cost;
updateExpandButton(); updateExpandButton();
} }
@@ -138,32 +137,37 @@ QPixmap HeaderBar::blockIcon() const
void HeaderBar::updateBlocksLabel() void HeaderBar::updateBlocksLabel()
{ {
const int blocks = m_sim->getBuildingBlocksStock();
const QPixmap icon = blockIcon(); const QPixmap icon = blockIcon();
if (icon.isNull()) if (icon.isNull())
{ {
// Fallback text form when no building_block icon exists (REQ-UI-BLOCKS-ICON). // 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; return;
} }
m_blocksLabel->setPixmap(renderCaptionWithIcon( 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))); m_blocksLabel->palette().color(QPalette::WindowText)));
} }
void HeaderBar::updateExpandButton() 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(); const QPixmap icon = blockIcon();
if (icon.isNull()) if (icon.isNull())
{ {
// Fallback text form when no building_block icon exists (REQ-UI-EXPAND-BUTTON). // Fallback text form when no building_block icon exists (REQ-UI-EXPAND-BUTTON).
m_expandButton->setIcon(QIcon()); m_expandButton->setIcon(QIcon());
m_expandButton->setText(tr("Expand: %1 Blocks").arg(m_expansionCost)); m_expandButton->setText(tr("Expand: %1 Blocks").arg(expansionCost));
return; 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 QPalette& pal = m_expandButton->palette();
const QPixmap normal = renderCaptionWithIcon( const QPixmap normal = renderCaptionWithIcon(
text, icon, m_expandButton->font(), pal.color(QPalette::ButtonText)); 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>( const Tick countdownTicks = m_sim->getBossCountdownTicks();
ticksToSeconds(event->countdownTicks > 0 ? event->countdownTicks : 0)); const int bossSeconds = static_cast<int>(
ticksToSeconds(countdownTicks > 0 ? countdownTicks : 0));
m_bossWaveLabel->setText( m_bossWaveLabel->setText(
tr("Boss Wave #%1") tr("Boss Wave #%1")
.arg(event->counter)); .arg(m_sim->getBossWaveCounter()));
m_nextBossLabel->setText( m_nextBossLabel->setText(
tr("Next boss: %1:%2") tr("Next boss: %1:%2")
.arg(bossSeconds / 60) .arg(bossSeconds / 60)
.arg(bossSeconds % 60, 2, 10, QChar('0'))); .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) void HeaderBar::onSpeedButton(int index)

View File

@@ -20,6 +20,7 @@
class QLabel; class QLabel;
class QPushButton; class QPushButton;
class ItemIconCache; class ItemIconCache;
class Simulation;
class HeaderBar : public QWidget, class HeaderBar : public QWidget,
public CombinedEventHandler<TickAdvancedEvent, public CombinedEventHandler<TickAdvancedEvent,
@@ -32,11 +33,11 @@ class HeaderBar : public QWidget,
Q_OBJECT Q_OBJECT
public: public:
// itemsIconDir holds the per-item icon SVGs (REQ-UI-ITEM-ICON); used to show the // itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); used to
// building_block icon in the stock display and expand button (REQ-UI-BLOCKS-ICON, // show the building_block icon in the stock display and expand button
// REQ-UI-EXPAND-BUTTON). // (REQ-UI-BLOCKS-ICON, REQ-UI-EXPAND-BUTTON). Not owned; must outlive this widget.
HeaderBar(const GameConfig* config, const std::string& itemsIconDir, HeaderBar(const Simulation* sim, const GameConfig* config,
QWidget* parent = nullptr); ItemIconCache* itemIcons, QWidget* parent = nullptr);
~HeaderBar() override; ~HeaderBar() override;
private slots: private slots:
@@ -54,9 +55,10 @@ private:
// expansion cost and building block stock (REQ-UI-EXPAND-BUTTON). // expansion cost and building block stock (REQ-UI-EXPAND-BUTTON).
void updateExpandButton(); void updateExpandButton();
// Refreshes the building blocks stock display from m_blocks: `Stock: <n>` with // Refreshes the building blocks stock display from the simulation:
// the building_block icon after it, or the `Stock: <n> Blocks` text fallback when // `Stock: <n>` with the building_block icon after it, or the
// no icon file exists (REQ-UI-BLOCKS-ICON). // `Stock: <n> Blocks` text fallback when no icon file exists
// (REQ-UI-BLOCKS-ICON).
void updateBlocksLabel(); void updateBlocksLabel();
// The building_block icon at the header's text height, or a null pixmap when no // The building_block icon at the header's text height, or a null pixmap when no
@@ -71,10 +73,11 @@ private:
QPushButton* m_expandButton; QPushButton* m_expandButton;
std::vector<QPushButton*> m_speedButtons; std::vector<QPushButton*> m_speedButtons;
std::unique_ptr<ItemIconCache> m_itemIcons; ItemIconCache* m_itemIcons; // Not owned; lives in MainWindow.
int m_blocks = 0; // The simulation is the single source of truth for everything the header
int m_expansionCost = 0; // displays; the change events are only refresh signals.
const Simulation* m_sim;
static const double kSpeeds[]; static const double kSpeeds[];
static const int kSpeedCount; static const int kSpeedCount;

View File

@@ -27,6 +27,8 @@
#include "SelectedBuildingPanel.h" #include "SelectedBuildingPanel.h"
#include "ShipLayoutBlueprintSerializer.h" #include "ShipLayoutBlueprintSerializer.h"
#include "ShipLayoutDialog.h" #include "ShipLayoutDialog.h"
#include "ItemIconCache.h"
#include "ModalPauseScope.h"
#include "Simulation.h" #include "Simulation.h"
#include "Tick.h" #include "Tick.h"
#include "VisualsLoader.h" #include "VisualsLoader.h"
@@ -47,10 +49,13 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
const std::string itemsIconDir = QDir::cleanPath( const std::string itemsIconDir = QDir::cleanPath(
QString::fromStdString(m_configDir) + "/../icons/items").toStdString(); 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_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); m_sidePanel = new QWidget(this);
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel); QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
@@ -63,7 +68,7 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString(); QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel); 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); m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel);
sideLayout->addWidget(m_selectedBuildingPanel, 1); sideLayout->addWidget(m_selectedBuildingPanel, 1);
@@ -163,8 +168,7 @@ void MainWindow::layoutPanels()
void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event) void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event)
{ {
const double prevSpeed = m_gameWorldView->getGameSpeed(); ModalPauseScope pause(*m_gameWorldView);
m_gameWorldView->setGameSpeed(0.0);
ModalDimScope dim(*m_dimOverlay); ModalDimScope dim(*m_dimOverlay);
SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, this); SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, this);
@@ -175,15 +179,11 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
command->choiceIndex = dialog.getChosenIndex(); command->choiceIndex = dialog.getChosenIndex();
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command)); std::make_shared<CommandRequestedEvent>(command));
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
} }
void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*event*/) void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*event*/)
{ {
const double prevSpeed = m_gameWorldView->getGameSpeed(); ModalPauseScope pause(*m_gameWorldView);
m_gameWorldView->setGameSpeed(0.0);
ModalDimScope dim(*m_dimOverlay); ModalDimScope dim(*m_dimOverlay);
QMessageBox box(this); QMessageBox box(this);
@@ -197,39 +197,47 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
QAbstractButton* clicked = box.clickedButton(); QAbstractButton* clicked = box.clickedButton();
if (clicked == restartBtn) if (clicked == restartBtn)
{ {
std::shared_ptr<GameConfig> newConfig; std::optional<GameConfig> newConfig = reloadConfig();
try 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; return;
} }
// Restart is a command boundary; the view resets when the drain applies // Restart is a command boundary; the view resets when the drain applies
// it (see GameWorldView::onFrame). A fresh random seed starts a new run. // 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>(); 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{}(); command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command)); std::make_shared<CommandRequestedEvent>(command));
} }
else if (clicked == quitBtn) else if (clicked == quitBtn)
{ {
pause.release();
close(); 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); GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
m_gameWorldView->resetFrameTimer(); 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 +245,7 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
const std::string& schematicId, const std::string& schematicId,
const ShipLayoutConfig& currentLayout) const ShipLayoutConfig& currentLayout)
{ {
const double prevSpeed = m_gameWorldView->getGameSpeed(); ModalPauseScope pause(*m_gameWorldView);
m_gameWorldView->setGameSpeed(0.0);
std::set<std::string> unlockedModuleIds; std::set<std::string> unlockedModuleIds;
for (const ModuleDef& def : m_sim->getConfig().modules.modules) for (const ModuleDef& def : m_sim->getConfig().modules.modules)
@@ -264,9 +271,6 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command)); std::make_shared<CommandRequestedEvent>(command));
} }
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
} }
void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event)
@@ -296,8 +300,7 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event)
{ {
const double prevSpeed = m_gameWorldView->getGameSpeed(); ModalPauseScope pause(*m_gameWorldView);
m_gameWorldView->setGameSpeed(0.0);
// A construction site has no Building yet; fall back to its site record so // A construction site has no Building yet; fall back to its site record so
// the recipe/schematic can be chosen before it is built (REQ-BLD-SITE-CONFIG). // the recipe/schematic can be chosen before it is built (REQ-BLD-SITE-CONFIG).
@@ -306,8 +309,6 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
b ? nullptr : m_sim->getBuildings().findSite(event->buildingId); b ? nullptr : m_sim->getBuildings().findSite(event->buildingId);
if (!b && !s) if (!b && !s)
{ {
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
return; return;
} }
@@ -328,11 +329,7 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
bool autoOpenLayout = false; bool autoOpenLayout = false;
std::string chosenSchematic; std::string chosenSchematic;
// Item icons live beside the config dir, mirroring the buildings icon path RecipeSelectionDialog dialog(options, title, m_itemIcons.get(), this);
// (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);
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value()) if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
{ {
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>(); std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
@@ -350,13 +347,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 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 // 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 // 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) if (autoOpenLayout)
{ {
openShipLayoutDialog(event->buildingId, chosenSchematic, ShipLayoutConfig{}); openShipLayoutDialog(event->buildingId, chosenSchematic, ShipLayoutConfig{});
@@ -382,24 +377,14 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
if (box.clickedButton() == restartBtn) if (box.clickedButton() == restartBtn)
{ {
std::shared_ptr<GameConfig> newConfig; std::optional<GameConfig> newConfig = reloadConfig();
try 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; return;
} }
// Restart is a command boundary; the view resets when the drain applies it. // Restart is a command boundary; the view resets when the drain applies it.
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>(); 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{}(); command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command)); std::make_shared<CommandRequestedEvent>(command));
@@ -429,21 +414,17 @@ void MainWindow::handleEvent(std::shared_ptr<const WinEvent> /*event*/)
if (box.clickedButton() == restartBtn) 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; 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 else
{ {

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <memory> #include <memory>
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -9,6 +10,7 @@
#include "BuildingId.h" #include "BuildingId.h"
#include "EscapeMenuRequestedEvent.h" #include "EscapeMenuRequestedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "GameConfig.h"
#include "GameOverEvent.h" #include "GameOverEvent.h"
#include "LayoutDialogRequestedEvent.h" #include "LayoutDialogRequestedEvent.h"
#include "ModalDimOverlay.h" #include "ModalDimOverlay.h"
@@ -27,6 +29,7 @@ class HeaderBar;
class SelectedBuildingPanel; class SelectedBuildingPanel;
class BuildButtonGrid; class BuildButtonGrid;
class BlueprintPanel; class BlueprintPanel;
class ItemIconCache;
class QCloseEvent; class QCloseEvent;
class QResizeEvent; class QResizeEvent;
@@ -57,6 +60,12 @@ private:
void handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) override; void handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) override; void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) override;
// Reloads the game config and visuals.toml from disk (REQ-CFG-RELOAD), shared
// by every restart path. On success the reloaded visuals are applied to this
// window and the fresh GameConfig is returned; on failure a modal error dialog
// is shown and std::nullopt is returned, leaving the window state untouched.
std::optional<GameConfig> reloadConfig();
// Opens the shipyard layout configuration dialog for the given schematic and // Opens the shipyard layout configuration dialog for the given schematic and
// current layout, applying the result via SetShipLayoutCommand (REQ-MOD-UI-DIALOG). // current layout, applying the result via SetShipLayoutCommand (REQ-MOD-UI-DIALOG).
void openShipLayoutDialog(BuildingId shipyardId, void openShipLayoutDialog(BuildingId shipyardId,
@@ -68,6 +77,9 @@ private:
std::string m_configDir; std::string m_configDir;
VisualsConfig m_visuals; VisualsConfig m_visuals;
Simulation* m_sim; Simulation* m_sim;
// One per-item icon cache for the whole window (REQ-UI-ITEM-ICON): the header,
// build grid, world view, and recipe dialog all rasterize the same SVGs.
std::unique_ptr<ItemIconCache> m_itemIcons;
GameWorldView* m_gameWorldView; GameWorldView* m_gameWorldView;
HeaderBar* m_headerBar; HeaderBar* m_headerBar;
SelectedBuildingPanel* m_selectedBuildingPanel; SelectedBuildingPanel* m_selectedBuildingPanel;

55
src/ui/ModalPauseScope.h Normal file
View File

@@ -0,0 +1,55 @@
#pragma once
#include "GameWorldView.h"
// RAII guard for the "pause the game while a modal is open" idiom (REQ-UI-SPEED).
// Constructing it snapshots the current game speed and pauses the game; on scope
// exit it restores the snapshotted speed and rebases the render frame timer, so the
// wall time the player spent in the dialog is not converted into simulation ticks.
//
// Pairs with ModalDimScope, which the same call sites use for the dim overlay.
//
// Two escape hatches for the paths that must not simply restore at scope exit:
// restore() — restore now instead of at scope exit, for when more work has to run
// at the player's real speed before the scope ends (e.g. a follow-up
// dialog that snapshots the speed itself).
// release() — abandon the restore entirely, for when the game is about to be
// reset or the window closed and the old speed is meaningless.
// Both are idempotent and the destructor does nothing once either has run.
class ModalPauseScope
{
public:
explicit ModalPauseScope(GameWorldView& view)
: m_view(view)
, m_previousGameSpeed(view.getGameSpeed())
, m_restorePending(true)
{
m_view.setGameSpeed(0.0);
}
~ModalPauseScope()
{
restore();
}
void restore()
{
if (!m_restorePending) { return; }
m_restorePending = false;
m_view.setGameSpeed(m_previousGameSpeed);
m_view.resetFrameTimer();
}
void release()
{
m_restorePending = false;
}
ModalPauseScope(const ModalPauseScope&) = delete;
ModalPauseScope& operator=(const ModalPauseScope&) = delete;
private:
GameWorldView& m_view;
double m_previousGameSpeed;
bool m_restorePending;
};

View File

@@ -111,14 +111,12 @@ namespace
RecipeSelectionDialog::RecipeSelectionDialog( RecipeSelectionDialog::RecipeSelectionDialog(
const std::vector<RecipeSelectionOption>& options, const std::vector<RecipeSelectionOption>& options,
const QString& title, const QString& itemIconDir, QWidget* parent) const QString& title, ItemIconCache* itemIcons, QWidget* parent)
: QDialog(parent) : QDialog(parent)
{ {
setWindowTitle(title); setWindowTitle(title);
setModal(true); setModal(true);
ItemIconCache iconCache(itemIconDir);
QVBoxLayout* mainLayout = new QVBoxLayout(this); QVBoxLayout* mainLayout = new QVBoxLayout(this);
QGridLayout* grid = new QGridLayout(); QGridLayout* grid = new QGridLayout();
mainLayout->addLayout(grid); mainLayout->addLayout(grid);
@@ -131,9 +129,9 @@ RecipeSelectionDialog::RecipeSelectionDialog(
QPushButton* button = new QPushButton(this); QPushButton* button = new QPushButton(this);
// Icon-only when the produced item has an icon (REQ-UI-RECIPE-ICON); otherwise // Icon-only when the produced item has an icon (REQ-UI-RECIPE-ICON); otherwise
// fall back to the caption. The name stays reachable via the tooltip. // fall back to the caption. The name stays reachable via the tooltip.
if (!option.iconItemId.empty() && iconCache.hasIcon(option.iconItemId)) if (!option.iconItemId.empty() && itemIcons->hasIcon(option.iconItemId))
{ {
button->setIcon(QIcon(iconCache.getPixmap( button->setIcon(QIcon(itemIcons->getPixmap(
option.iconItemId, kOptionIconSize.width()))); option.iconItemId, kOptionIconSize.width())));
button->setIconSize(kOptionIconSize); button->setIconSize(kOptionIconSize);
} }

View File

@@ -12,6 +12,7 @@
struct GameConfig; struct GameConfig;
class Simulation; class Simulation;
class QPushButton; class QPushButton;
class ItemIconCache;
// One selectable entry in the recipe/schematic selection dialog // One selectable entry in the recipe/schematic selection dialog
// (REQ-UI-SELECT-BUTTON). The "(None)" entry uses an empty id. // (REQ-UI-SELECT-BUTTON). The "(None)" entry uses an empty id.
@@ -43,11 +44,11 @@ class RecipeSelectionDialog : public QDialog
Q_OBJECT Q_OBJECT
public: public:
// itemIconDir is the directory holding per-item icon SVGs (REQ-UI-ITEM-ICON); // itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); used to
// used to render recipe options icon-only (REQ-UI-RECIPE-ICON). Options whose // render recipe options icon-only (REQ-UI-RECIPE-ICON). Options whose item has
// item has no icon file fall back to their caption text. // no icon file fall back to their caption text. Not owned.
RecipeSelectionDialog(const std::vector<RecipeSelectionOption>& options, RecipeSelectionDialog(const std::vector<RecipeSelectionOption>& options,
const QString& title, const QString& itemIconDir, const QString& title, ItemIconCache* itemIcons,
QWidget* parent = nullptr); QWidget* parent = nullptr);
std::optional<std::string> getChosenId() const; std::optional<std::string> getChosenId() const;

View File

@@ -12,15 +12,6 @@
namespace namespace
{ {
const RecipeDef* findRecipe(const RecipesConfig& recipes, const std::string& id)
{
for (const RecipeDef& recipe : recipes.recipes)
{
if (recipe.id == id) { return &recipe; }
}
return nullptr;
}
QString grantKindLabel(SchematicType type) QString grantKindLabel(SchematicType type)
{ {
switch (type) switch (type)
@@ -120,7 +111,7 @@ SchematicChoiceDialog::SchematicChoiceDialog(
QLabel* recipeLabel = new QLabel( QLabel* recipeLabel = new QLabel(
QString::fromStdString(toDisplayName(recipeId)), card); QString::fromStdString(toDisplayName(recipeId)), card);
recipeLabel->setAlignment(Qt::AlignCenter); recipeLabel->setAlignment(Qt::AlignCenter);
if (const RecipeDef* def = findRecipe(recipes, recipeId)) if (const RecipeDef* def = recipes.findRecipeDef(recipeId))
{ {
recipeLabel->setToolTip(buildRecipeTooltip(*def)); recipeLabel->setToolTip(buildRecipeTooltip(*def));
} }

View File

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

View File

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

View File

@@ -243,14 +243,7 @@ private:
{ {
return nullptr; return nullptr;
} }
for (const ModuleDef& def : m_config->modules.modules) return m_config->modules.findModuleDef(id);
{
if (def.id == id)
{
return &def;
}
}
return nullptr;
} }
std::vector<std::string> rotateMask(const std::vector<std::string>& mask, std::vector<std::string> rotateMask(const std::vector<std::string>& mask,
@@ -426,13 +419,10 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
setModal(true); setModal(true);
// Find the ship's layout grid. // Find the ship's layout grid.
for (const ShipDef& def : config->ships.ships) const ShipDef* shipDef = config->ships.findShipDef(shipId);
if (shipDef)
{ {
if (def.id == shipId) m_shipLayout = shipDef->layout;
{
m_shipLayout = def.layout;
break;
}
} }
m_rows = static_cast<int>(m_shipLayout.size()); m_rows = static_cast<int>(m_shipLayout.size());
@@ -706,11 +696,7 @@ void ShipLayoutDialog::rebuildOccupancy()
for (int i = 0; i < static_cast<int>(m_placedModules.size()); ++i) for (int i = 0; i < static_cast<int>(m_placedModules.size()); ++i)
{ {
const PlacedModule& pm = m_placedModules[i]; const PlacedModule& pm = m_placedModules[i];
const ModuleDef* def = nullptr; const ModuleDef* def = m_config->modules.findModuleDef(pm.moduleId);
for (const ModuleDef& d : m_config->modules.modules)
{
if (d.id == pm.moduleId) { def = &d; break; }
}
if (!def) if (!def)
{ {
continue; continue;
@@ -807,11 +793,7 @@ void ShipLayoutDialog::loadLayoutBlueprint(const std::vector<PlacedModule>& modu
for (const PlacedModule& pm : modules) for (const PlacedModule& pm : modules)
{ {
// Validate module type exists and is unlocked. // Validate module type exists and is unlocked.
const ModuleDef* def = nullptr; const ModuleDef* def = m_config->modules.findModuleDef(pm.moduleId);
for (const ModuleDef& d : m_config->modules.modules)
{
if (d.id == pm.moduleId) { def = &d; break; }
}
if (!def || m_unlockedModuleIds.count(def->id) == 0) { continue; } if (!def || m_unlockedModuleIds.count(def->id) == 0) { continue; }
const std::vector<std::string> mask = rotatedMask(*def, pm.rotation); const std::vector<std::string> mask = rotatedMask(*def, pm.rotation);

View File

@@ -12,19 +12,6 @@ const int kCellSize = 8;
const int kPlaceholderWidth = 64; const int kPlaceholderWidth = 64;
const int kPlaceholderHeight = 40; const int kPlaceholderHeight = 40;
const ModuleDef* findModuleDef(const std::vector<ModuleDef>& modules,
const std::string& id)
{
for (const ModuleDef& def : modules)
{
if (def.id == id)
{
return &def;
}
}
return nullptr;
}
std::vector<std::string> rotateMaskCW(const std::vector<std::string>& grid) std::vector<std::string> rotateMaskCW(const std::vector<std::string>& grid)
{ {
if (grid.empty()) if (grid.empty())
@@ -113,7 +100,7 @@ void ShipLayoutPreview::showPlaceholder()
void ShipLayoutPreview::setShipAndLayout(const std::vector<std::string>& shipLayout, void ShipLayoutPreview::setShipAndLayout(const std::vector<std::string>& shipLayout,
const ShipLayoutConfig& layout, const ShipLayoutConfig& layout,
const std::vector<ModuleDef>* modules) const ModulesConfig* modules)
{ {
m_placeholder = false; m_placeholder = false;
m_modules = modules; m_modules = modules;
@@ -144,7 +131,7 @@ void ShipLayoutPreview::setShipAndLayout(const std::vector<std::string>& shipLay
for (int i = 0; i < static_cast<int>(m_placedModules.size()); ++i) for (int i = 0; i < static_cast<int>(m_placedModules.size()); ++i)
{ {
const PlacedModule& pm = m_placedModules[i]; const PlacedModule& pm = m_placedModules[i];
const ModuleDef* def = findModuleDef(*m_modules, pm.moduleId); const ModuleDef* def = m_modules->findModuleDef(pm.moduleId);
if (!def) if (!def)
{ {
continue; continue;
@@ -208,7 +195,7 @@ void ShipLayoutPreview::paintEvent(QPaintEvent* /*event*/)
else if (cell.moduleIndex.has_value()) else if (cell.moduleIndex.has_value())
{ {
const PlacedModule& pm = m_placedModules[*cell.moduleIndex]; const PlacedModule& pm = m_placedModules[*cell.moduleIndex];
const ModuleDef* def = findModuleDef(*m_modules, pm.moduleId); const ModuleDef* def = m_modules->findModuleDef(pm.moduleId);
QColor color(Qt::gray); QColor color(Qt::gray);
if (def) if (def)
{ {

View File

@@ -18,7 +18,7 @@ public:
void setShipAndLayout(const std::vector<std::string>& shipLayout, void setShipAndLayout(const std::vector<std::string>& shipLayout,
const ShipLayoutConfig& layout, const ShipLayoutConfig& layout,
const std::vector<ModuleDef>* modules); const ModulesConfig* modules);
void clear(); void clear();
// Shows an empty placeholder box (no ship layout) so the preview stays // Shows an empty placeholder box (no ship layout) so the preview stays
@@ -37,7 +37,7 @@ private:
std::vector<std::vector<CellInfo>> m_grid; std::vector<std::vector<CellInfo>> m_grid;
std::vector<PlacedModule> m_placedModules; std::vector<PlacedModule> m_placedModules;
const std::vector<ModuleDef>* m_modules; const ModulesConfig* m_modules;
int m_rows; int m_rows;
int m_cols; int m_cols;
bool m_placeholder = false; bool m_placeholder = false;