14 Commits

Author SHA1 Message Date
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
57 changed files with 734 additions and 899 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,14 +11,10 @@
#include "SimulationTestAccess.h"
#include "StateChecksum.h"
#include "Tick.h"
#include "TestConfig.h"
namespace
{
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
constexpr int kScriptTicks = 2000;
// 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.
std::vector<std::uint64_t> runScriptedSession(unsigned int seed)
{
Simulation sim(loadConfig(), seed);
Simulation sim(loadTestConfig(), seed);
// Tick 0: a miner feeding a short belt line on the asteroid.
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
@@ -121,8 +117,8 @@ TEST_CASE("fingerprintRng: equal states match, advanced states differ", "[determ
TEST_CASE("Simulation::rngFingerprint is stable for equal seeds", "[determinism]")
{
const Simulation a(loadConfig(), 777);
const Simulation b(loadConfig(), 777);
const Simulation a(loadTestConfig(), 777);
const Simulation b(loadTestConfig(), 777);
REQUIRE(a.getRngFingerprint() == b.getRngFingerprint());
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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