31 Commits

Author SHA1 Message Date
cb5572ffdd cover unlock state in the determinism tests
The scripted session only placed buildings, so every unlock container stayed at
its initial value for the whole run and the checksum never saw them change. It
now destroys the enemy stations twice and takes the offered schematic choice,
so awarded groups, per-schematic levels and the derived recipe/item sets are
exercised too.

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 14:09:28 +02:00
2c9433cea6 move the shared TOML helpers into the utility namespace
The names are generic (makeError, requireInt, parseFile), and at global scope
with external linkage they would form an overload set with the same-named
anonymous-namespace helpers in VisualsLoader.cpp and BalancingConfig.cpp the
moment either file includes TomlHelpers.h — silently, since the signatures
differ. Namespacing keeps that door shut.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The sole caller already had the ModulesConfig one dereference away.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:34:56 +02:00
b722955f7e fix splitter filters being lost when rotating in place
rotateInPlace re-implemented the belt-tile re-registration switch inline
instead of calling reregisterBeltTile, and its splitter branch omitted the
setSplitterFilters call the canonical version has. Since an operational
splitter keeps its filters only in BeltSystem, removeTile discarded them and
rotating a configured splitter silently reset it to "accept all".

Replace the duplicated switch with a call to reregisterBeltTile, capturing
the filters beforehand via getSplitterInfo — the same idiom deconstruct
already uses. This removes the second copy of the switch that allowed the
two to drift apart in the first place.

Add a regression test; the existing [rotate-in-place] cases covered belt
tiles only, which is why this went unnoticed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:22:53 +02:00
80 changed files with 2340 additions and 2136 deletions

View File

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

View File

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

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
{
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
{
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
{
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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -95,55 +95,6 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
// 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
{
b.inputBuffer.counts.clear();
@@ -237,7 +188,7 @@ void BuildingSystem::initShipyardBuffers(Building& b) const
b.inputBuffer.caps.clear();
b.outputBuffer.items.clear();
b.outputBuffer.capacity = 0;
const ShipDef* def = findShipDef(b.recipeId);
const ShipDef* def = m_config.ships.findShipDef(b.recipeId);
if (!def)
{
return;
@@ -252,7 +203,7 @@ void BuildingSystem::initShipyardBuffers(Building& b) const
{
for (const PlacedModule& pm : b.shipLayout->placedModules)
{
const ModuleDef* modDef = findModuleDef(pm.moduleId);
const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
if (!modDef)
{
continue;
@@ -272,7 +223,7 @@ void BuildingSystem::initSalvageBayBuffer(Building& b) const
// Salvage Bay has no recipe-driven buffer; its output-buffer holding size for
// ship drop-off is config-defined (REQ-BLD-SALVAGE-BAY).
b.outputBuffer.items.clear();
const BuildingDef* def = findBuildingDef(BuildingType::SalvageBay);
const BuildingDef* def = m_config.buildings.findBuildingDef(BuildingType::SalvageBay);
b.outputBuffer.capacity =
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0;
}
@@ -345,7 +296,7 @@ std::vector<Port> BuildingSystem::getInputPorts(BuildingId id) const
{
// A site stores no ports; derive its output ports from the mask (absolute)
// and run the same input-edge scan (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS).
const BuildingDef* def = findBuildingDef(site->type);
const BuildingDef* def = m_config.buildings.findBuildingDef(site->type);
if (def == nullptr) { return {}; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, site->rotation);
std::vector<Port> outputPortsAbsolute;
@@ -391,7 +342,7 @@ std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe
std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor,
Rotation rotation, Tick currentTick)
{
const BuildingDef* def = findBuildingDef(type);
const BuildingDef* def = m_config.buildings.findBuildingDef(type);
assert(def != nullptr);
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation);
@@ -455,7 +406,7 @@ bool BuildingSystem::bodyCellsWithinWorldBounds(const std::vector<QPoint>& bodyC
bool BuildingSystem::isPlacementValid(BuildingType type, QPoint anchor,
Rotation rotation) const
{
const BuildingDef* def = findBuildingDef(type);
const BuildingDef* def = m_config.buildings.findBuildingDef(type);
if (def == nullptr)
{
return false;
@@ -510,7 +461,7 @@ int BuildingSystem::deconstruct(BuildingId id, Tick currentTick)
{
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_tileOccupancy.erase({cell.x(), cell.y()});
@@ -646,7 +597,7 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
}
else
{
const RecipeDef* recipe = findRecipe(recipeId, building.type);
const RecipeDef* recipe = m_config.recipes.findRecipeDef(recipeId, building.type);
if (recipe)
{
initBuffers(building, *recipe);
@@ -701,7 +652,7 @@ BuildingSystem::getSiteSplitterInfo(BuildingId id) const
if (site.id != id) { continue; }
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(
def ? def->surfaceMask : std::vector<std::string>{}, site.rotation);
if (mask.outputPorts.size() < 2) { return std::nullopt; }
@@ -748,7 +699,7 @@ void BuildingSystem::tickConstruction(Tick currentTick)
// Guard: if somehow the front site was never started, start it now.
if (front.completesAt == 0)
{
const BuildingDef* def = findBuildingDef(front.type);
const BuildingDef* def = m_config.buildings.findBuildingDef(front.type);
if (def)
{
front.completesAt = currentTick + secondsToTicks(def->constructionTimeSeconds);
@@ -762,7 +713,7 @@ void BuildingSystem::tickConstruction(Tick currentTick)
}
// 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(
def ? def->surfaceMask : std::vector<std::string>{},
front.rotation);
@@ -809,7 +760,7 @@ void BuildingSystem::tickConstruction(Tick currentTick)
}
else
{
const RecipeDef* recipe = findRecipe(building.recipeId, building.type);
const RecipeDef* recipe = m_config.recipes.findRecipeDef(building.recipeId, building.type);
if (recipe)
{
initBuffers(building, *recipe);
@@ -828,7 +779,8 @@ void BuildingSystem::tickConstruction(Tick currentTick)
// Start next queued site if present.
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)
{
m_constructionQueue.front().completesAt =
@@ -896,7 +848,7 @@ void BuildingSystem::tickDeconstruction(Tick currentTick)
{
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_tileOccupancy.erase({cell.x(), cell.y()});
@@ -1187,7 +1139,7 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
{
continue;
}
const ShipDef* shipDef = findShipDef(building.recipeId);
const ShipDef* shipDef = m_config.ships.findShipDef(building.recipeId);
if (!shipDef)
{
continue;
@@ -1252,7 +1204,7 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
{
for (const PlacedModule& pm : building.shipLayout->placedModules)
{
const ModuleDef* modDef = findModuleDef(pm.moduleId);
const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
if (modDef)
{
totalTime += modDef->productionTimeSeconds;
@@ -1466,7 +1418,7 @@ BuildingSystem::gatherCandidateRecipes(const Building& b) const
}
else
{
const RecipeDef* recipe = findRecipe(b.recipeId, b.type);
const RecipeDef* recipe = m_config.recipes.findRecipeDef(b.recipeId, b.type);
if (recipe)
{
candidates.push_back(recipe);
@@ -1495,7 +1447,7 @@ std::map<std::string, int>
BuildingSystem::computeShipyardRequiredMaterials(const Building& b) const
{
std::map<std::string, int> requiredMaterials;
const ShipDef* shipDef = findShipDef(b.recipeId);
const ShipDef* shipDef = m_config.ships.findShipDef(b.recipeId);
if (!shipDef)
{
return requiredMaterials;
@@ -1508,7 +1460,7 @@ BuildingSystem::computeShipyardRequiredMaterials(const Building& b) const
{
for (const PlacedModule& pm : b.shipLayout->placedModules)
{
const ModuleDef* modDef = findModuleDef(pm.moduleId);
const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
if (!modDef)
{
continue;
@@ -1638,7 +1590,7 @@ std::optional<BuildingId> BuildingSystem::findRotateInPlaceTarget(
return std::nullopt;
}
const BuildingDef* def = findBuildingDef(type);
const BuildingDef* def = m_config.buildings.findBuildingDef(type);
if (!def) { return std::nullopt; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rot);
@@ -1698,7 +1650,7 @@ void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
b.rotation = newRotation;
const BuildingDef* def = findBuildingDef(b.type);
const BuildingDef* def = m_config.buildings.findBuildingDef(b.type);
if (!def) { return; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, newRotation);
@@ -1719,29 +1671,25 @@ void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
// the new port set (REQ-MAT-INPUT-INTAKE).
b.incomingItems.assign(b.inputPorts.size(), {});
// Re-register with BeltSystem (items on tile are discarded).
if (b.type == BuildingType::Belt)
// Re-register with BeltSystem (items on tile are discarded). A splitter's
// 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.placeBelt(b.anchor, newRotation);
}
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);
reregisterBeltTile(b, splitterFilterA, splitterFilterB);
}
return;

View File

@@ -267,10 +267,6 @@ private:
// the status light (REQ-UI-STATUS-LIGHT).
bool hasInputsToStart(const Building& b) const;
const BuildingDef* findBuildingDef(BuildingType type) const;
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;
const ShipDef* findShipDef(const std::string& id) const;
const ModuleDef* findModuleDef(const std::string& id) const;
void initBuffers(Building& b, const RecipeDef& recipe) const;
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input
// caps span the union of every recipe of the building's type; no player

View File

@@ -19,6 +19,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.h
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h
${CMAKE_CURRENT_SOURCE_DIR}/UnlockState.h
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
PARENT_SCOPE
)
@@ -40,6 +41,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/UnlockState.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
PARENT_SCOPE
)

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -20,6 +20,7 @@
#include "SimulationTestAccess.h"
#include "SurfaceMask.h"
#include "Tick.h"
#include "TestConfig.h"
// ---------------------------------------------------------------------------
// 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:
// building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false
// are silently excluded before the bounding-box center and offsets are computed.
@@ -312,7 +308,7 @@ TEST_CASE("Blueprint: non-axis-aligned offset rotates correctly", "[blueprint]")
TEST_CASE("Blueprint: CW constellation rotation updates offset and building rotation", "[blueprint]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
// Building one tile to the right, facing East.
Blueprint bp;
bp.name = "test";
@@ -332,7 +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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
Blueprint bp;
bp.name = "test";
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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
Blueprint bp;
bp.name = "test";
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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
// Two buildings left and right of center; after CW they should be above and below.
Blueprint bp;
bp.name = "test";
@@ -398,7 +394,7 @@ TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[
TEST_CASE("Blueprint: CW rotation keeps belt adjacent to miner output port", "[blueprint]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
// East miner: anchor (0,0), body cells (0,0),(1,0),(0,1).
// Output port indicator '>' at (1,1) → port tile (1,1), direction East.
@@ -436,7 +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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
Blueprint bp;
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",
"[blueprint]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
// Hq has no entry in buildings.toml, so it is treated as non-player-placeable.
const BuildingSpec hq{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Hq, Rotation::East };
@@ -485,7 +481,7 @@ TEST_CASE("Blueprint creation: non-player-placeable building alone yields empty
TEST_CASE("Blueprint creation: mixed selection keeps only player-placeable buildings",
"[blueprint]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const BuildingSpec belt{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East };
const BuildingSpec hq { QPoint(-3, 0), {QPoint(-3, 0)}, BuildingType::Hq, Rotation::East };
@@ -498,7 +494,7 @@ TEST_CASE("Blueprint creation: mixed selection keeps only player-placeable build
TEST_CASE("Blueprint creation: bounding box ignores non-player-placeable buildings",
"[blueprint]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
// Belt at (-5, 0). HQ at (-3, 0) — excluded from the blueprint.
// If HQ were included: bboxX = [-5, -3], center.x = -4, belt offset = -1.
@@ -520,7 +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)
// at cursor tile (-5, 0). Expected anchors: (-6, 0) and (-4, 0).
// (Belt surface_mask ["A>"] — body at relative (0,0), port at (1,0).)
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
const QPoint cursor(-5, 0);
const QPoint offsetA(-1, 0);
@@ -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]")
{
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
// Find belt cost from config (belt cost = 2 in test config).
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",
"[blueprint]")
{
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
// Find miner cost (15 in test config) — expensive enough to exhaust a small stock.
int minerCost = 0;
@@ -594,7 +590,7 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId a
TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and charges nothing",
"[blueprint]")
{
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
const int startBlocks = sim.getBuildingBlocksStock();
// A miner is all-asteroid; placing it in space (x >= 0) violates the terrain
@@ -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",
"[blueprint]")
{
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
int minerCost = 0;
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]")
{
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
@@ -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",
"[blueprint]")
{
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
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]")
{
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
// Freshly placed → a ConstructionSite (not ticked to completion). A 1x1 belt keeps
// the body-cell bounding-box centered on the anchor, so a single site → zero offset.
@@ -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]")
{
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
const BuildingId id =
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",
"[blueprint]")
{
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
// Building A: place, configure, and tick to completion so it is operational.
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]")
{
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
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.
// This confirms the guard in placeBlueprintAtTile passes for start-unlocked schematics.
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
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.
// This confirms the guard in placeBlueprintAtTile blocks locked schematics,
// leaving the shipyard's schematic unset.
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
}
@@ -842,7 +838,7 @@ TEST_CASE("Blueprint: building without layout has nullopt shipLayout", "[bluepri
TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout", "[blueprint]")
{
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
// Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East:
// A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles.
@@ -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",
"[blueprint]")
{
Simulation sim(loadConfig());
Simulation sim(loadTestConfig());
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);

View File

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

View File

@@ -19,16 +19,12 @@
#include "Port.h"
#include "Rotation.h"
#include "Tick.h"
#include "TestConfig.h"
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
static Item makeItem(const std::string& id)
{
Item item;
@@ -86,7 +82,7 @@ static std::vector<Item> outputSideItems(const Building& b)
// Owns a BuildingSystem and its dependencies for placement-bounds tests.
struct PlacementFixture
{
GameConfig cfg = loadConfig();
GameConfig cfg = loadTestConfig();
BeltSystem belts{cfg.world.beltSpeed_tps};
int stock = 0;
std::mt19937 rng{0};
@@ -110,7 +106,7 @@ struct PlacementFixture
TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0;
std::mt19937 rng(0);
@@ -638,7 +634,7 @@ TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites",
TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle state",
"[building]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
// Fast belt so items are immediately available for peek/take.
BeltSystem belts(static_cast<double>(kTickRateHz));
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",
"[building]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt: 1 tile/tick
int stock = 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",
"[building]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt
int stock = 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",
"[building]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 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",
"[building]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 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",
"[building]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 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",
"[building]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0;
// 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",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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)",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 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")));
}
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 "
"to the built splitter (REQ-BLD-SITE-CONFIG)", "[building]")
{

View File

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

View File

@@ -22,11 +22,7 @@
#include "StationBodyComponent.h"
#include "Tick.h"
#include "WeaponComponent.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
#include "TestConfig.h"
static const ShipDef* findCombatShip(const GameConfig& cfg)
{
@@ -64,7 +60,7 @@ struct CombatFixture
CombatSystem combat;
explicit CombatFixture()
: cfg(loadConfig())
: cfg(loadTestConfig())
, rng(42)
, nextBuildingId(1)
, 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]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
// Find the player station entity via ECS.
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]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
entt::entity stationEntity = entt::null;
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]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
entt::entity stationEntity = entt::null;
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]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
const ShipDef* combatDef = findCombatShip(sim.getConfig());
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]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
// Scrap dropped on death is derived from the ship's as-built threat cost
// (REQ-RES-DEBRIS-DROP): round(threat * scrap_per_threat). The interceptor's
@@ -424,7 +420,7 @@ TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
TEST_CASE("CombatSystem: HQ death sets game over", "[combat]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
// Damage the HQ proxy entity (has HqProxy + Health).
sim.getAdmin().forEach<HqProxyComponent, HealthComponent>(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,11 +22,7 @@
#include "SimulationTestAccess.h"
#include "Tick.h"
#include "WeaponComponent.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
#include "TestConfig.h"
static const ShipDef* findSchematic(const GameConfig& cfg, const std::string& id)
{
@@ -108,7 +104,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId,
TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
@@ -123,7 +119,7 @@ TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]")
TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
@@ -148,7 +144,7 @@ TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
@@ -173,7 +169,7 @@ TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
@@ -206,7 +202,7 @@ TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
"[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
@@ -233,7 +229,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
"[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
@@ -269,7 +265,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
"[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
// The schematic carries a weapon in its (wave-only) default loadout. This
@@ -312,7 +308,7 @@ TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
@@ -341,7 +337,7 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
"[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
@@ -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]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
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]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
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]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
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]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ShipDef* def = findSchematic(cfg, "interceptor");
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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ShipDef* def = findSchematic(cfg, "interceptor");
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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ShipDef* def = findSchematic(cfg, "interceptor");
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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ShipDef* def = findSchematic(cfg, "interceptor");
REQUIRE(def != nullptr);

View File

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

View File

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

View File

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

15
src/test/TestConfig.h Normal file
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 "ThreatCostCalculator.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
#include "TestConfig.h"
TEST_CASE("ThreatCostCalculator: miner item threat equals duration", "[threat]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("iron_ore") == Approx(1.0));
@@ -19,7 +15,7 @@ TEST_CASE("ThreatCostCalculator: miner item threat equals duration", "[threat]")
TEST_CASE("ThreatCostCalculator: smelter item threat includes input costs", "[threat]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts;
// iron_ingot: duration 2.0 + iron_ore(1.0) * 2 = 4.0
@@ -30,7 +26,7 @@ TEST_CASE("ThreatCostCalculator: smelter item threat includes input costs", "[th
TEST_CASE("ThreatCostCalculator: assembler takes max across recipes", "[threat]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts;
// circuit_board has three non-reprocessing recipes:
@@ -43,7 +39,7 @@ TEST_CASE("ThreatCostCalculator: assembler takes max across recipes", "[threat]"
TEST_CASE("ThreatCostCalculator: scrap threat is 1 / scrap_per_threat", "[threat]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts;
// REQ-THREAT-SCRAP: scrap threat is the constant 1 / world.scrap_per_threat.
@@ -54,7 +50,7 @@ TEST_CASE("ThreatCostCalculator: scrap threat is 1 / scrap_per_threat", "[threat
TEST_CASE("ThreatCostCalculator: reprocessing-only item threat", "[threat]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts;
// advanced_alloy: reprocessing recipe with scrap*5, duration 3.0, probability 0.1
@@ -64,7 +60,7 @@ TEST_CASE("ThreatCostCalculator: reprocessing-only item threat", "[threat]")
TEST_CASE("ThreatCostCalculator: ship threat with default modules", "[threat]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts;
// interceptor: 10 + iron_ingot(4)*3 + circuit_board(28)*1 + laser_cannon(5 + 4*1) = 59.0
@@ -80,7 +76,7 @@ TEST_CASE("ThreatCostCalculator: ship threat with default modules", "[threat]")
TEST_CASE("ThreatCostCalculator: ship threat with custom modules", "[threat]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts;
// interceptor base: 10 + iron_ingot(4)*3 + circuit_board(28)*1 = 50.0
@@ -101,7 +97,7 @@ TEST_CASE("ThreatCostCalculator: ship threat with custom modules", "[threat]")
TEST_CASE("ThreatCostCalculator: unknown ship returns zero", "[threat]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts;
double threat = calculateShipThreatCost(table, cfg, "nonexistent_ship", {});
@@ -113,7 +109,7 @@ TEST_CASE("ThreatCostCalculator: unknown ship returns zero", "[threat]")
// be excluded. iron_ingot threat must not be inflated by the scrap path.
TEST_CASE("ThreatCostCalculator: scrap-consuming recipe excluded when scrap-free recipe exists", "[threat]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts;
// scrap_iron recipe: duration=1.0, 1 scrap (threat=1.0) -> 1 iron_ingot.
@@ -132,7 +128,7 @@ TEST_CASE("ThreatCostCalculator: scrap-consuming recipe excluded when scrap-free
// Per-unit threat = (3.0 + iron_ore(1.0)*1) / 2 = 4.0 / 2 = 2.0.
TEST_CASE("ThreatCostCalculator: per-unit division by output amount", "[threat]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("dual_wire") == Approx(2.0));
@@ -146,7 +142,7 @@ TEST_CASE("ThreatCostCalculator: per-unit division by output amount", "[threat]"
// downstream_product = 2.0 + 80.0*1 = 82.0.
TEST_CASE("ThreatCostCalculator: downstream-of-reprocessing item resolves via fixpoint", "[threat]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("advanced_alloy") == Approx(80.0));
@@ -161,7 +157,7 @@ TEST_CASE("ThreatCostCalculator: downstream-of-reprocessing item resolves via fi
// expected threat = max(2.0, 29.0) = 29.0, not 2.0.
TEST_CASE("ThreatCostCalculator: staggered recipes committed only when all computable", "[threat]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("staggered_item") == Approx(29.0));

View File

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

View File

@@ -22,15 +22,11 @@
#include "SimulationTestAccess.h"
#include "StationBodyComponent.h"
#include "UnlocksConfig.h"
#include "TestConfig.h"
namespace
{
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
UnlockGroupDef& findGroup(GameConfig& cfg, const std::string& id)
{
for (UnlockGroupDef& group : cfg.unlocks.groups)
@@ -85,7 +81,7 @@ TEST_CASE("UnlockPrereq: a group gated behind a locked ship is withheld from the
// quick_circuit (recipe group, level 0) would normally be eligible at the
// first station destruction. Gate it behind the repair_ship group (level 0,
// locked at game start).
GameConfig cfg = loadConfig();
GameConfig cfg = loadTestConfig();
findGroup(cfg, "quick_circuit").requiredGroupIds = {"repair_ship"};
Simulation sim(std::move(cfg), 123);
@@ -103,7 +99,7 @@ TEST_CASE("UnlockPrereq: a group gated behind a locked ship is withheld from the
TEST_CASE("UnlockPrereq: the gated group becomes eligible once its prerequisite is awarded",
"[unlock_prereq]")
{
GameConfig cfg = loadConfig();
GameConfig cfg = loadTestConfig();
findGroup(cfg, "quick_circuit").requiredGroupIds = {"repair_ship"};
Simulation sim(std::move(cfg), 123);
@@ -126,7 +122,7 @@ TEST_CASE("UnlockPrereq: a module group gated behind another module is withheld
{
// Make laser_cannon and armor_plate (both start-unlocked in the test config)
// lockable via new unlock groups, with armor_plate gated behind laser_cannon.
GameConfig cfg = loadConfig();
GameConfig cfg = loadTestConfig();
cfg.unlocks.groups.push_back(
UnlockGroupDef{"laser_cannon", 0, {}, {}, {"laser_cannon"}, {}, {}});
cfg.unlocks.groups.push_back(

View File

@@ -25,11 +25,7 @@
#include "Tick.h"
#include "ThreatCostCalculator.h"
#include "WaveSystem.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
#include "TestConfig.h"
// ---------------------------------------------------------------------------
// Threat accumulation
@@ -37,7 +33,7 @@ static GameConfig loadConfig()
TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
@@ -55,7 +51,7 @@ TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]")
TEST_CASE("WaveSystem: threatAccumulationRate matches the rate formula outside quiet windows",
"[wave]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
@@ -65,7 +61,7 @@ TEST_CASE("WaveSystem: threatAccumulationRate matches the rate formula outside q
TEST_CASE("WaveSystem: threatAccumulationRate is 0 during a quiet window", "[wave]")
{
GameConfig cfg = loadConfig();
GameConfig cfg = loadTestConfig();
// Start with the boss countdown already at the pre-boss quiet threshold.
cfg.world.waves.bossCountdownSeconds = cfg.world.waves.bossQuietBeforeSeconds;
std::mt19937 rng(42);
@@ -83,7 +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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
std::mt19937 rng(42);
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]")
{
const Simulation sim(loadConfig(), 42);
const Simulation sim(loadTestConfig(), 42);
// HQ is still a Building (for belt integration).
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]")
{
const Simulation sim(loadConfig(), 42);
const Simulation sim(loadTestConfig(), 42);
const float expectedHp =
static_cast<float>(sim.getConfig().stations.hq.hpFormula.evaluate(0.0));
@@ -145,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]")
{
const Simulation sim(loadConfig(), 42);
const Simulation sim(loadTestConfig(), 42);
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]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
int armedPlayerStations = 0;
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]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
int armedEnemyStations = 0;
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]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
// The maximum gap is gapMaxSeconds = 45s → 1350 ticks.
// Run 1500 ticks to guarantee at least one wave has triggered.
@@ -234,7 +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]")
{
const GameConfig cfg = loadConfig();
const GameConfig cfg = loadTestConfig();
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]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
// Damage both enemy stations to 0.
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]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
@@ -291,7 +287,7 @@ TEST_CASE("WaveSystem: push generates pending schematic choices", "[wave]")
TEST_CASE("WaveSystem: push schematic choices have valid ids", "[wave]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
@@ -339,7 +335,7 @@ TEST_CASE("WaveSystem: push schematic choices have valid ids", "[wave]")
TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
@@ -359,7 +355,7 @@ TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
@@ -375,7 +371,7 @@ TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]
TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]")
{
Simulation sim(loadConfig(), 42);
Simulation sim(loadTestConfig(), 42);
// Record the X position of the initial enemy stations.
int initialX = std::numeric_limits<int>::min();

View File

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

View File

@@ -53,10 +53,11 @@ private:
void loadFromDisk();
void saveToDisk() const;
// The simulation is the single source of truth for the block stock; the
// change event is only a refresh signal.
Simulation* m_sim;
const GameConfig* m_config;
std::vector<BuildingId> m_selectedBuildingIds;
int m_currentBlocks;
std::optional<int> m_activeIndex; // nullopt = no blueprint selected
std::vector<Blueprint> m_blueprints;
std::vector<QPushButton*> m_blueprintButtons;

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h

View File

@@ -197,11 +197,13 @@ QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl)
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, const std::string& configDir,
const ParsedReplay* replay, QWidget* parent)
ItemIconCache* itemIcons, const ParsedReplay* replay,
QWidget* parent)
: QOpenGLWidget(parent)
, m_sim(sim)
, m_config(config)
, m_visuals(visuals)
, m_itemIcons(itemIcons)
, m_commandManager(*sim)
, m_gameSpeedMultiplier(1.0)
, m_prevNonZeroSpeed(1.0)
@@ -223,11 +225,6 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
loadBuildingIcons(configDir);
// Item icons live beside the config dir, mirroring the building icons
// (REQ-UI-ITEM-ICON, REQ-UI-WORLD-ICON).
m_itemIcons = std::make_unique<ItemIconCache>(QDir::cleanPath(
QString::fromStdString(configDir) + "/../icons/items"));
m_renderTimer = new QTimer(this);
m_renderTimer->setInterval(16);
connect(m_renderTimer, &QTimer::timeout, this, &GameWorldView::onFrame);
@@ -406,26 +403,26 @@ void GameWorldView::onFrame()
{
m_lastTick = newTick;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TickAdvancedEvent>(newTick));
std::make_shared<TickAdvancedEvent>());
}
if (newBlocks != m_lastBlocks)
{
m_lastBlocks = newBlocks;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuildingBlocksChangedEvent>(newBlocks));
std::make_shared<BuildingBlocksChangedEvent>());
}
if (newExpCost != m_lastExpansionCost)
{
m_lastExpansionCost = newExpCost;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExpansionCostChangedEvent>(newExpCost));
std::make_shared<ExpansionCostChangedEvent>());
}
if (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown)
{
m_lastBossCounter = newBoss;
m_lastBossCountdown = newCountdown;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BossWaveUpdatedEvent>(newBoss, newCountdown));
std::make_shared<BossWaveUpdatedEvent>());
}
// Unlocked building set changes only after a drop is applied or on Restart
@@ -488,8 +485,7 @@ void GameWorldView::onFrame()
{
m_lastArtifactCount = currentArtifactCount;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ArtifactCountChangedEvent>(
currentArtifactCount, m_sim->getConfig().world.artifacts.artifactWinCount));
std::make_shared<ArtifactCountChangedEvent>());
}
update();
@@ -669,15 +665,6 @@ void GameWorldView::clampScroll()
// Placement helpers
// ---------------------------------------------------------------------------
const BuildingDef* GameWorldView::findBuildingDef(BuildingType type) const
{
for (const BuildingDef& def : m_config->buildings.buildings)
{
if (def.type == type) { return &def; }
}
return nullptr;
}
bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
Rotation rot) const
{
@@ -689,7 +676,7 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
return false;
}
const BuildingDef* def = findBuildingDef(type);
const BuildingDef* def = m_config->buildings.findBuildingDef(type);
if (!def) { return false; }
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rot);
@@ -892,7 +879,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
{
continue;
}
const BuildingDef* def = findBuildingDef(bb.type);
const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
if (def) { totalCost += def->cost; }
}
if (m_sim->getBuildingBlocksStock() < totalCost) { return; }
@@ -969,29 +956,39 @@ BuildingType GameWorldView::effectiveBuilderType() const
return inTunnelMode() ? m_tunnelGhostType : *m_builderType;
}
std::map<std::pair<int, int>, TunnelTileInfo> GameWorldView::collectTunnelTiles() const
TunnelTileMap GameWorldView::collectTunnelTiles() const
{
// Index every tunnel entry/exit — built or still a construction site — by its
// single-cell tile, so a just-placed tunnel (not yet constructed) is matchable
// (REQ-BLD-TUNNEL-MODE, REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
std::map<std::pair<int, int>, TunnelTileInfo> tunnels;
TunnelTileMap tunnels;
for (const Building& b : m_sim->getBuildings().getAllBuildings())
{
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())
{
if (s.type == BuildingType::TunnelEntry || s.type == BuildingType::TunnelExit)
{
tunnels[{s.anchor.x(), s.anchor.y()}] = TunnelTileInfo{s.type, s.rotation};
tunnels[s.anchor] = TunnelTileInfo{s.type, s.rotation};
}
}
return tunnels;
}
TunnelLookup GameWorldView::makeTunnelLookup(const TunnelTileMap& tunnels)
{
return [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
{
const TunnelTileMap::const_iterator it = tunnels.find(tile);
if (it == tunnels.end()) { return std::nullopt; }
return it->second;
};
}
void GameWorldView::updateTunnelGhost()
{
m_tunnelGhostType = BuildingType::TunnelEntry;
@@ -1004,14 +1001,8 @@ void GameWorldView::updateTunnelGhost()
return;
}
const std::map<std::pair<int, int>, TunnelTileInfo> tunnels = collectTunnelTiles();
const TunnelLookup lookup = [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
{
const std::map<std::pair<int, int>, TunnelTileInfo>::const_iterator it =
tunnels.find({tile.x(), tile.y()});
if (it == tunnels.end()) { return std::nullopt; }
return it->second;
};
const TunnelTileMap tunnels = collectTunnelTiles();
const TunnelLookup lookup = makeTunnelLookup(tunnels);
const TunnelCompletion completion =
resolveTunnelCompletion(lookup, m_ghostTile, m_ghostRotation,
@@ -1133,7 +1124,7 @@ std::vector<GameWorldView::BeltDragResolved> GameWorldView::resolveBeltDragPath(
std::vector<BeltDragResolved> resolved;
resolved.reserve(m_beltDragPath.size());
const BuildingDef* def = findBuildingDef(BuildingType::Belt);
const BuildingDef* def = m_config->buildings.findBuildingDef(BuildingType::Belt);
const int beltCost = (def != nullptr) ? def->cost : 0;
const int stock = m_sim->getBuildingBlocksStock();
int spent = 0;
@@ -1405,7 +1396,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
painter.setBrush(Qt::NoBrush);
painter.drawRect(bboxRect);
const BuildingDef* siteDef = findBuildingDef(s.type);
const BuildingDef* siteDef = m_config->buildings.findBuildingDef(s.type);
if (siteDef)
{
// Glyph + progress percentage
@@ -1988,21 +1979,15 @@ void GameWorldView::drawSelectedTunnelConnections(QPainter& painter)
{
if (m_selectedBuildingIds.empty()) { return; }
const std::map<std::pair<int, int>, TunnelTileInfo> tunnels = collectTunnelTiles();
const TunnelTileMap tunnels = collectTunnelTiles();
if (tunnels.empty()) { return; }
const TunnelLookup lookup = [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
{
const std::map<std::pair<int, int>, TunnelTileInfo>::const_iterator it =
tunnels.find({tile.x(), tile.y()});
if (it == tunnels.end()) { return std::nullopt; }
return it->second;
};
const TunnelLookup lookup = makeTunnelLookup(tunnels);
// Collect the tiles to highlight in a set so a connection selected from both ends
// (or overlapping runs) is filled exactly once — filling a semi-transparent green
// twice would darken it (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
std::set<std::pair<int, int>> highlightTiles;
std::set<QPoint, QPointCompare> highlightTiles;
for (const BuildingId id : m_selectedBuildingIds)
{
std::optional<QPoint> anchor;
@@ -2032,15 +2017,15 @@ void GameWorldView::drawSelectedTunnelConnections(QPainter& painter)
(delta.y() > 0) - (delta.y() < 0));
for (QPoint t = *anchor; ; t += stepDir)
{
highlightTiles.insert({t.x(), t.y()});
highlightTiles.insert(t);
if (t == *partner) { break; }
}
}
const QColor green = m_visuals->overlays.tunnelPreview;
for (const std::pair<int, int>& tile : highlightTiles)
for (const QPoint& tile : highlightTiles)
{
painter.fillRect(tileRect(QPoint(tile.first, tile.second)), green);
painter.fillRect(tileRect(tile), green);
}
}
@@ -2177,7 +2162,7 @@ void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type,
QPoint anchorTile, Rotation rotation,
bool valid, bool showPortTargetGlyphs)
{
const BuildingDef* def = findBuildingDef(type);
const BuildingDef* def = m_config->buildings.findBuildingDef(type);
if (!def) { return; }
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
@@ -2960,7 +2945,7 @@ void GameWorldView::rotateGhost(bool clockwise)
{
for (BlueprintBuilding& bb : m_blueprintMode->buildings)
{
const BuildingDef* def = findBuildingDef(bb.type);
const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
if (!def) { continue; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
int minX = INT_MAX, minY = INT_MAX;
@@ -3259,7 +3244,7 @@ void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotat
bool GameWorldView::canAfford(BuildingType type) const
{
const BuildingDef* def = findBuildingDef(type);
const BuildingDef* def = m_config->buildings.findBuildingDef(type);
if (!def) { return false; }
return m_sim->getBuildingBlocksStock() >= def->cost;
}

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

View File

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

View File

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

View File

@@ -27,6 +27,8 @@
#include "SelectedBuildingPanel.h"
#include "ShipLayoutBlueprintSerializer.h"
#include "ShipLayoutDialog.h"
#include "ItemIconCache.h"
#include "ModalPauseScope.h"
#include "Simulation.h"
#include "Tick.h"
#include "VisualsLoader.h"
@@ -47,10 +49,13 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
const std::string itemsIconDir = QDir::cleanPath(
QString::fromStdString(m_configDir) + "/../icons/items").toStdString();
m_headerBar = new HeaderBar(&sim->getConfig(), itemsIconDir, this);
m_itemIcons = std::make_unique<ItemIconCache>(
QString::fromStdString(itemsIconDir));
m_headerBar = new HeaderBar(sim, &sim->getConfig(), m_itemIcons.get(), this);
m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
m_replay.get(), this);
m_itemIcons.get(), m_replay.get(), this);
m_sidePanel = new QWidget(this);
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
@@ -63,7 +68,7 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel);
m_buildButtonGrid = new BuildButtonGrid(sim, &sim->getConfig(), iconDir, itemsIconDir, m_sidePanel);
m_buildButtonGrid = new BuildButtonGrid(sim, &sim->getConfig(), iconDir, m_itemIcons.get(), m_sidePanel);
m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel);
sideLayout->addWidget(m_selectedBuildingPanel, 1);
@@ -163,8 +168,7 @@ void MainWindow::layoutPanels()
void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event)
{
const double prevSpeed = m_gameWorldView->getGameSpeed();
m_gameWorldView->setGameSpeed(0.0);
ModalPauseScope pause(*m_gameWorldView);
ModalDimScope dim(*m_dimOverlay);
SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, this);
@@ -175,15 +179,11 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
command->choiceIndex = dialog.getChosenIndex();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
}
void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*event*/)
{
const double prevSpeed = m_gameWorldView->getGameSpeed();
m_gameWorldView->setGameSpeed(0.0);
ModalPauseScope pause(*m_gameWorldView);
ModalDimScope dim(*m_dimOverlay);
QMessageBox box(this);
@@ -197,39 +197,47 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
QAbstractButton* clicked = box.clickedButton();
if (clicked == restartBtn)
{
std::shared_ptr<GameConfig> newConfig;
try
std::optional<GameConfig> newConfig = reloadConfig();
if (!newConfig.has_value())
{
newConfig = std::make_shared<GameConfig>(
ConfigLoader::loadFromDirectory(m_configDir));
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals);
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
}
catch (const std::exception& e)
{
QMessageBox::critical(this, tr("Config Error"),
tr("Failed to reload config:\n%1").arg(e.what()));
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
return;
}
// Restart is a command boundary; the view resets when the drain applies
// it (see GameWorldView::onFrame). A fresh random seed starts a new run.
// resetForNewGame() sets the speed for the new run, so the pre-restart
// speed is deliberately not restored here.
pause.release();
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::move(newConfig);
command->config = std::make_shared<GameConfig>(std::move(*newConfig));
command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else if (clicked == quitBtn)
{
pause.release();
close();
}
else
}
std::optional<GameConfig> MainWindow::reloadConfig()
{
// Config is reloaded from disk on every restart (REQ-CFG-RELOAD); a malformed
// file must not leave the window half-updated, so the visuals are only applied
// once both files have parsed.
try
{
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals);
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
return newConfig;
}
catch (const std::exception& e)
{
QMessageBox::critical(this, tr("Config Error"),
tr("Failed to reload config:\n%1").arg(e.what()));
return std::nullopt;
}
}
@@ -237,8 +245,7 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
const std::string& schematicId,
const ShipLayoutConfig& currentLayout)
{
const double prevSpeed = m_gameWorldView->getGameSpeed();
m_gameWorldView->setGameSpeed(0.0);
ModalPauseScope pause(*m_gameWorldView);
std::set<std::string> unlockedModuleIds;
for (const ModuleDef& def : m_sim->getConfig().modules.modules)
@@ -264,9 +271,6 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
}
void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event)
@@ -296,8 +300,7 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event)
{
const double prevSpeed = m_gameWorldView->getGameSpeed();
m_gameWorldView->setGameSpeed(0.0);
ModalPauseScope pause(*m_gameWorldView);
// A construction site has no Building yet; fall back to its site record so
// the recipe/schematic can be chosen before it is built (REQ-BLD-SITE-CONFIG).
@@ -306,8 +309,6 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
b ? nullptr : m_sim->getBuildings().findSite(event->buildingId);
if (!b && !s)
{
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
return;
}
@@ -328,11 +329,7 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
bool autoOpenLayout = false;
std::string chosenSchematic;
// Item icons live beside the config dir, mirroring the buildings icon path
// (REQ-UI-ITEM-ICON, REQ-UI-BUILD-ICON).
const QString itemIconDir = QDir::cleanPath(
QString::fromStdString(m_configDir) + "/../icons/items");
RecipeSelectionDialog dialog(options, title, itemIconDir, this);
RecipeSelectionDialog dialog(options, title, m_itemIcons.get(), this);
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
{
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
@@ -350,13 +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 shipyard's layout, so open the dialog with the chosen schematic and an
// empty layout rather than reading the not-yet-updated building state. Speed
// is already restored so the helper snapshots the real speed to restore.
// is restored first so the helper snapshots the real speed to restore.
pause.restore();
if (autoOpenLayout)
{
openShipLayoutDialog(event->buildingId, chosenSchematic, ShipLayoutConfig{});
@@ -382,24 +377,14 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
if (box.clickedButton() == restartBtn)
{
std::shared_ptr<GameConfig> newConfig;
try
std::optional<GameConfig> newConfig = reloadConfig();
if (!newConfig.has_value())
{
newConfig = std::make_shared<GameConfig>(
ConfigLoader::loadFromDirectory(m_configDir));
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals);
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
}
catch (const std::exception& e)
{
QMessageBox::critical(this, tr("Config Error"),
tr("Failed to reload config:\n%1").arg(e.what()));
return;
}
// Restart is a command boundary; the view resets when the drain applies it.
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::move(newConfig);
command->config = std::make_shared<GameConfig>(std::move(*newConfig));
command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
@@ -429,21 +414,17 @@ void MainWindow::handleEvent(std::shared_ptr<const WinEvent> /*event*/)
if (box.clickedButton() == restartBtn)
{
try
std::optional<GameConfig> newConfig = reloadConfig();
if (!newConfig.has_value())
{
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals);
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
m_sim->reset(std::move(newConfig));
}
catch (const std::exception& e)
{
QMessageBox::critical(this, tr("Config Error"),
tr("Failed to reload config:\n%1").arg(e.what()));
return;
}
m_gameWorldView->resetForNewGame();
// Restart is a command boundary; the view resets when the drain applies it.
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::make_shared<GameConfig>(std::move(*newConfig));
command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else
{

View File

@@ -1,6 +1,7 @@
#pragma once
#include <memory>
#include <optional>
#include <string>
#include <vector>
@@ -9,6 +10,7 @@
#include "BuildingId.h"
#include "EscapeMenuRequestedEvent.h"
#include "EventHandler.h"
#include "GameConfig.h"
#include "GameOverEvent.h"
#include "LayoutDialogRequestedEvent.h"
#include "ModalDimOverlay.h"
@@ -27,6 +29,7 @@ class HeaderBar;
class SelectedBuildingPanel;
class BuildButtonGrid;
class BlueprintPanel;
class ItemIconCache;
class QCloseEvent;
class QResizeEvent;
@@ -57,6 +60,12 @@ private:
void handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> 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
// current layout, applying the result via SetShipLayoutCommand (REQ-MOD-UI-DIALOG).
void openShipLayoutDialog(BuildingId shipyardId,
@@ -68,6 +77,9 @@ private:
std::string m_configDir;
VisualsConfig m_visuals;
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;
HeaderBar* m_headerBar;
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(
const std::vector<RecipeSelectionOption>& options,
const QString& title, const QString& itemIconDir, QWidget* parent)
const QString& title, ItemIconCache* itemIcons, QWidget* parent)
: QDialog(parent)
{
setWindowTitle(title);
setModal(true);
ItemIconCache iconCache(itemIconDir);
QVBoxLayout* mainLayout = new QVBoxLayout(this);
QGridLayout* grid = new QGridLayout();
mainLayout->addLayout(grid);
@@ -131,9 +129,9 @@ RecipeSelectionDialog::RecipeSelectionDialog(
QPushButton* button = new QPushButton(this);
// 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.
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())));
button->setIconSize(kOptionIconSize);
}

View File

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

View File

@@ -12,15 +12,6 @@
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)
{
switch (type)
@@ -120,7 +111,7 @@ SchematicChoiceDialog::SchematicChoiceDialog(
QLabel* recipeLabel = new QLabel(
QString::fromStdString(toDisplayName(recipeId)), card);
recipeLabel->setAlignment(Qt::AlignCenter);
if (const RecipeDef* def = findRecipe(recipes, recipeId))
if (const RecipeDef* def = recipes.findRecipeDef(recipeId))
{
recipeLabel->setToolTip(buildRecipeTooltip(*def));
}

View File

@@ -430,14 +430,7 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
// the cycle time and progress can be shown (REQ-UI-PRODUCTION-PROGRESS).
if (!recipe && isAutoRecipeBuilding(b->type) && b->production.has_value())
{
for (const RecipeDef& r : m_config->recipes.recipes)
{
if (r.id == b->production->recipeId && r.building == b->type)
{
recipe = &r;
break;
}
}
recipe = m_config->recipes.findRecipeDef(b->production->recipeId, b->type);
}
QString bufText;
@@ -465,18 +458,14 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
{
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)
{
if (ing.item == entry.first.id)
{
perCycle += ing.amount;
}
}
break;
perCycle += ing.amount;
}
}
}
@@ -545,13 +534,11 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
{
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;
break;
}
durationSeconds += modDef->productionTimeSeconds;
}
}
}
@@ -617,7 +604,7 @@ void SelectedBuildingPanel::updateShipyardLayoutWidgets(
layout = *shipLayout;
}
m_layoutPreview->setShipAndLayout(
shipDef->layout, layout, &m_config->modules.modules);
shipDef->layout, layout, &m_config->modules);
}
else
{
@@ -633,21 +620,13 @@ void SelectedBuildingPanel::updateShipyardLayoutWidgets(
const RecipeDef* SelectedBuildingPanel::findRecipe(const Building* b) const
{
if (b->recipeId.empty()) { return nullptr; }
for (const RecipeDef& r : m_config->recipes.recipes)
{
if (r.id == b->recipeId && r.building == b->type) { return &r; }
}
return nullptr;
return m_config->recipes.findRecipeDef(b->recipeId, b->type);
}
const ShipDef* SelectedBuildingPanel::findShipDef(const std::string& id) const
{
if (id.empty()) { return nullptr; }
for (const ShipDef& s : m_config->ships.ships)
{
if (s.id == id) { return &s; }
}
return nullptr;
return m_config->ships.findShipDef(id);
}
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
@@ -1088,15 +1067,14 @@ void SelectedBuildingPanel::buildEntityShip(entt::entity entity)
admin.get<SelectedBehaviorComponent>(entity).winner);
m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw);
for (const ShipDef& def : m_config->ships.ships)
const ShipDef* schematicDef =
m_config->ships.findShipDef(identity.schematicId);
if (schematicDef)
{
if (def.id == identity.schematicId)
{
double threat = calculateShipThreatCost(
m_config->threatCosts, *m_config, def.id, def.defaultModules);
m_entityStatsPanel->setThreatCost(threat);
break;
}
const double threat = calculateShipThreatCost(
m_config->threatCosts, *m_config, schematicDef->id,
schematicDef->defaultModules);
m_entityStatsPanel->setThreatCost(threat);
}
m_entityStatsPanel->show();

View File

@@ -243,14 +243,7 @@ private:
{
return nullptr;
}
for (const ModuleDef& def : m_config->modules.modules)
{
if (def.id == id)
{
return &def;
}
}
return nullptr;
return m_config->modules.findModuleDef(id);
}
std::vector<std::string> rotateMask(const std::vector<std::string>& mask,
@@ -426,13 +419,10 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
setModal(true);
// 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 = def.layout;
break;
}
m_shipLayout = shipDef->layout;
}
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)
{
const PlacedModule& pm = m_placedModules[i];
const ModuleDef* def = nullptr;
for (const ModuleDef& d : m_config->modules.modules)
{
if (d.id == pm.moduleId) { def = &d; break; }
}
const ModuleDef* def = m_config->modules.findModuleDef(pm.moduleId);
if (!def)
{
continue;
@@ -807,11 +793,7 @@ void ShipLayoutDialog::loadLayoutBlueprint(const std::vector<PlacedModule>& modu
for (const PlacedModule& pm : modules)
{
// Validate module type exists and is unlocked.
const ModuleDef* def = nullptr;
for (const ModuleDef& d : m_config->modules.modules)
{
if (d.id == pm.moduleId) { def = &d; break; }
}
const ModuleDef* def = m_config->modules.findModuleDef(pm.moduleId);
if (!def || m_unlockedModuleIds.count(def->id) == 0) { continue; }
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 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)
{
if (grid.empty())
@@ -113,7 +100,7 @@ void ShipLayoutPreview::showPlaceholder()
void ShipLayoutPreview::setShipAndLayout(const std::vector<std::string>& shipLayout,
const ShipLayoutConfig& layout,
const std::vector<ModuleDef>* modules)
const ModulesConfig* modules)
{
m_placeholder = false;
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)
{
const PlacedModule& pm = m_placedModules[i];
const ModuleDef* def = findModuleDef(*m_modules, pm.moduleId);
const ModuleDef* def = m_modules->findModuleDef(pm.moduleId);
if (!def)
{
continue;
@@ -208,7 +195,7 @@ void ShipLayoutPreview::paintEvent(QPaintEvent* /*event*/)
else if (cell.moduleIndex.has_value())
{
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);
if (def)
{

View File

@@ -18,7 +18,7 @@ public:
void setShipAndLayout(const std::vector<std::string>& shipLayout,
const ShipLayoutConfig& layout,
const std::vector<ModuleDef>* modules);
const ModulesConfig* modules);
void clear();
// 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<PlacedModule> m_placedModules;
const std::vector<ModuleDef>* m_modules;
const ModulesConfig* m_modules;
int m_rows;
int m_cols;
bool m_placeholder = false;