16 Commits

Author SHA1 Message Date
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
59 changed files with 787 additions and 939 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,32 +48,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
m_currentEnemyStationEntities[0] = entt::null; m_currentEnemyStationEntities[0] = entt::null;
m_currentEnemyStationEntities[1] = entt::null; m_currentEnemyStationEntities[1] = entt::null;
m_buildingSystem = std::make_unique<BuildingSystem>( initializeSubsystems();
m_config,
m_beltSystem,
[this]() { return allocateBuildingId(); },
[this](int amount) { m_buildingBlocksStock += amount; },
[this](const std::string& id, QVector2D pos,
const std::optional<ShipLayoutConfig>& layout) {
const std::map<std::string, SchematicState>::const_iterator it =
m_schematicLevels.find(id);
if (it == m_schematicLevels.end() || !it->second.unlocked)
{
return;
}
m_shipSystem->spawn(id, pos, /*isEnemy=*/false, layout);
},
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>(m_config);
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
m_debrisSystem = std::make_unique<DebrisSystem>(m_admin);
m_salvagerSystem = std::make_unique<SalvagerSystem>(m_admin);
m_repairSystem = std::make_unique<RepairSystem>(m_admin);
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config);
initializeUnlockState(); initializeUnlockState();
placeInitialStructures(); placeInitialStructures();
@@ -120,6 +95,14 @@ void Simulation::reset(unsigned int seed)
m_admin.clear(); m_admin.clear();
m_beltSystem = BeltSystem(m_config.world.beltSpeed_tps); m_beltSystem = BeltSystem(m_config.world.beltSpeed_tps);
initializeSubsystems();
initializeUnlockState();
placeInitialStructures();
}
void Simulation::initializeSubsystems()
{
m_buildingSystem = std::make_unique<BuildingSystem>( m_buildingSystem = std::make_unique<BuildingSystem>(
m_config, m_config,
m_beltSystem, m_beltSystem,
@@ -146,9 +129,6 @@ void Simulation::reset(unsigned int seed)
m_repairSystem = std::make_unique<RepairSystem>(m_admin); m_repairSystem = std::make_unique<RepairSystem>(m_admin);
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng); m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config); m_combatSystem = std::make_unique<CombatSystem>(m_config);
initializeUnlockState();
placeInitialStructures();
} }
void Simulation::initializeUnlockState() void Simulation::initializeUnlockState()

View File

@@ -165,6 +165,11 @@ private:
BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId. BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId.
// (Re-)create every owned subsystem. Shared by the constructor and reset();
// the construction order is load-bearing for determinism, so both paths must
// go through here. Only called before the first tick of a run.
void initializeSubsystems();
// Populate HQ, player defence stations, and the first enemy station set. // Populate HQ, player defence stations, and the first enemy station set.
void placeInitialStructures(); void placeInitialStructures();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,14 +11,10 @@
#include "SimulationTestAccess.h" #include "SimulationTestAccess.h"
#include "StateChecksum.h" #include "StateChecksum.h"
#include "Tick.h" #include "Tick.h"
#include "TestConfig.h"
namespace namespace
{ {
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
constexpr int kScriptTicks = 2000; constexpr int kScriptTicks = 2000;
// Runs a fixed scripted session and returns the full-state checksum after every // Runs a fixed scripted session and returns the full-state checksum after every
@@ -26,7 +22,7 @@ constexpr int kScriptTicks = 2000;
// otherwise lets waves/combat run so the RNG stream and ECS state are exercised. // otherwise lets waves/combat run so the RNG stream and ECS state are exercised.
std::vector<std::uint64_t> runScriptedSession(unsigned int seed) std::vector<std::uint64_t> runScriptedSession(unsigned int seed)
{ {
Simulation sim(loadConfig(), seed); Simulation sim(loadTestConfig(), seed);
// Tick 0: a miner feeding a short belt line on the asteroid. // Tick 0: a miner feeding a short belt line on the asteroid.
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-3, 0), Rotation::East); SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
@@ -121,8 +117,8 @@ TEST_CASE("fingerprintRng: equal states match, advanced states differ", "[determ
TEST_CASE("Simulation::rngFingerprint is stable for equal seeds", "[determinism]") TEST_CASE("Simulation::rngFingerprint is stable for equal seeds", "[determinism]")
{ {
const Simulation a(loadConfig(), 777); const Simulation a(loadTestConfig(), 777);
const Simulation b(loadConfig(), 777); const Simulation b(loadTestConfig(), 777);
REQUIRE(a.getRngFingerprint() == b.getRngFingerprint()); REQUIRE(a.getRngFingerprint() == b.getRngFingerprint());
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@
#include "EventManager.h" #include "EventManager.h"
#include "IconCaption.h" #include "IconCaption.h"
#include "ItemIconCache.h" #include "ItemIconCache.h"
#include "Simulation.h"
#include "SpeedChangeRequestedEvent.h" #include "SpeedChangeRequestedEvent.h"
#include "Tick.h" #include "Tick.h"
@@ -30,11 +31,11 @@ namespace
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 }; const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
const int HeaderBar::kSpeedCount = 5; const int HeaderBar::kSpeedCount = 5;
HeaderBar::HeaderBar(const GameConfig* config, const std::string& itemsIconDir, HeaderBar::HeaderBar(const Simulation* sim, const GameConfig* config,
QWidget* parent) ItemIconCache* itemIcons, QWidget* parent)
: QWidget(parent) : QWidget(parent)
, m_itemIcons(std::make_unique<ItemIconCache>( , m_itemIcons(itemIcons)
QString::fromStdString(itemsIconDir))) , m_sim(sim)
{ {
QHBoxLayout* layout = new QHBoxLayout(this); QHBoxLayout* layout = new QHBoxLayout(this);
layout->setContentsMargins(8, 4, 8, 4); layout->setContentsMargins(8, 4, 8, 4);
@@ -115,16 +116,14 @@ void HeaderBar::handleEvent(std::shared_ptr<const TickAdvancedEvent> event)
.arg(totalSeconds % 60, 2, 10, QChar('0'))); .arg(totalSeconds % 60, 2, 10, QChar('0')));
} }
void HeaderBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) void HeaderBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
{ {
m_blocks = event->blocks;
updateBlocksLabel(); updateBlocksLabel();
updateExpandButton(); updateExpandButton();
} }
void HeaderBar::handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> event) void HeaderBar::handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> /*event*/)
{ {
m_expansionCost = event->cost;
updateExpandButton(); updateExpandButton();
} }
@@ -138,32 +137,37 @@ QPixmap HeaderBar::blockIcon() const
void HeaderBar::updateBlocksLabel() void HeaderBar::updateBlocksLabel()
{ {
const int blocks = m_sim->getBuildingBlocksStock();
const QPixmap icon = blockIcon(); const QPixmap icon = blockIcon();
if (icon.isNull()) if (icon.isNull())
{ {
// Fallback text form when no building_block icon exists (REQ-UI-BLOCKS-ICON). // Fallback text form when no building_block icon exists (REQ-UI-BLOCKS-ICON).
m_blocksLabel->setText(tr("Stock: %1 Blocks").arg(m_blocks)); m_blocksLabel->setText(tr("Stock: %1 Blocks").arg(blocks));
return; return;
} }
m_blocksLabel->setPixmap(renderCaptionWithIcon( m_blocksLabel->setPixmap(renderCaptionWithIcon(
tr("Stock: %1").arg(m_blocks), icon, font(), tr("Stock: %1").arg(blocks), icon, font(),
m_blocksLabel->palette().color(QPalette::WindowText))); m_blocksLabel->palette().color(QPalette::WindowText)));
} }
void HeaderBar::updateExpandButton() void HeaderBar::updateExpandButton()
{ {
m_expandButton->setEnabled(m_blocks >= m_expansionCost); const int blocks = m_sim->getBuildingBlocksStock();
const int expansionCost = m_sim->getCurrentExpansionCost();
m_expandButton->setEnabled(blocks >= expansionCost);
const QPixmap icon = blockIcon(); const QPixmap icon = blockIcon();
if (icon.isNull()) if (icon.isNull())
{ {
// Fallback text form when no building_block icon exists (REQ-UI-EXPAND-BUTTON). // Fallback text form when no building_block icon exists (REQ-UI-EXPAND-BUTTON).
m_expandButton->setIcon(QIcon()); m_expandButton->setIcon(QIcon());
m_expandButton->setText(tr("Expand: %1 Blocks").arg(m_expansionCost)); m_expandButton->setText(tr("Expand: %1 Blocks").arg(expansionCost));
return; return;
} }
const QString text = tr("Expand: %1").arg(m_expansionCost); const QString text = tr("Expand: %1").arg(expansionCost);
const QPalette& pal = m_expandButton->palette(); const QPalette& pal = m_expandButton->palette();
const QPixmap normal = renderCaptionWithIcon( const QPixmap normal = renderCaptionWithIcon(
text, icon, m_expandButton->font(), pal.color(QPalette::ButtonText)); text, icon, m_expandButton->font(), pal.color(QPalette::ButtonText));

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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