19 Commits

Author SHA1 Message Date
b1720dc2b3 drop the now-dead payloads from the sim-backed state-change events
TickAdvanced, BuildingBlocksChanged, ExpansionCostChanged, BossWaveUpdated and
ArtifactCountChanged all duplicated state the Simulation already owns. With
every subscriber re-reading from the sim, the fields had no readers left, so
the five events become payload-free refresh signals and the emitters keep the
values only as locals for change detection.

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

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

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

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

The sole caller already had the ModulesConfig one dereference away.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:22:53 +02:00
66 changed files with 822 additions and 984 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
@@ -1427,6 +1423,36 @@ TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSyste
REQUIRE(belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore")));
}
TEST_CASE("BuildingSystem: rotateInPlace preserves the output filters of a splitter "
"(REQ-BLD-SPLITTER)", "[building][rotate-in-place]")
{
PlacementFixture f;
const QPoint tile(5, 5);
const BuildingId id = f.bs.place(BuildingType::Splitter, tile, Rotation::East, 0).value();
// Run until construction completes, so the splitter is registered with BeltSystem.
Tick tick = 0;
while (f.bs.getAllBuildings().empty() && tick < 100000)
{
runTicks(f.bs, f.belts, 1, tick);
}
REQUIRE(f.bs.getAllBuildings().size() == 1);
const std::vector<ItemType> filterA{ ItemType{"iron_ore"} };
const std::vector<ItemType> filterB{ ItemType{"copper_ore"} };
f.belts.setSplitterFilters(tile, filterA, filterB);
f.bs.rotateInPlace(id, Rotation::North);
// The tile is re-registered with BeltSystem carrying the filters it had before
// the rotation — rotating must not reset a configured splitter to "accept all".
const std::optional<BeltSystem::SplitterInfo> info = f.belts.getSplitterInfo(tile);
REQUIRE(info.has_value());
REQUIRE(info->filterA == filterA);
REQUIRE(info->filterB == filterB);
}
TEST_CASE("BuildingSystem: splitter filters configured on a construction site carry over "
"to the built splitter (REQ-BLD-SITE-CONFIG)", "[building]")
{

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,8 @@
#include "SelectedBuildingPanel.h"
#include "ShipLayoutBlueprintSerializer.h"
#include "ShipLayoutDialog.h"
#include "ItemIconCache.h"
#include "ModalPauseScope.h"
#include "Simulation.h"
#include "Tick.h"
#include "VisualsLoader.h"
@@ -47,10 +49,13 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
const std::string itemsIconDir = QDir::cleanPath(
QString::fromStdString(m_configDir) + "/../icons/items").toStdString();
m_headerBar = new HeaderBar(&sim->getConfig(), itemsIconDir, this);
m_itemIcons = std::make_unique<ItemIconCache>(
QString::fromStdString(itemsIconDir));
m_headerBar = new HeaderBar(sim, &sim->getConfig(), m_itemIcons.get(), this);
m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
m_replay.get(), this);
m_itemIcons.get(), m_replay.get(), this);
m_sidePanel = new QWidget(this);
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
@@ -63,7 +68,7 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel);
m_buildButtonGrid = new BuildButtonGrid(sim, &sim->getConfig(), iconDir, itemsIconDir, m_sidePanel);
m_buildButtonGrid = new BuildButtonGrid(sim, &sim->getConfig(), iconDir, m_itemIcons.get(), m_sidePanel);
m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel);
sideLayout->addWidget(m_selectedBuildingPanel, 1);
@@ -163,8 +168,7 @@ void MainWindow::layoutPanels()
void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event)
{
const double prevSpeed = m_gameWorldView->getGameSpeed();
m_gameWorldView->setGameSpeed(0.0);
ModalPauseScope pause(*m_gameWorldView);
ModalDimScope dim(*m_dimOverlay);
SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, this);
@@ -175,15 +179,11 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
command->choiceIndex = dialog.getChosenIndex();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
}
void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*event*/)
{
const double prevSpeed = m_gameWorldView->getGameSpeed();
m_gameWorldView->setGameSpeed(0.0);
ModalPauseScope pause(*m_gameWorldView);
ModalDimScope dim(*m_dimOverlay);
QMessageBox box(this);
@@ -197,39 +197,47 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
QAbstractButton* clicked = box.clickedButton();
if (clicked == restartBtn)
{
std::shared_ptr<GameConfig> newConfig;
try
std::optional<GameConfig> newConfig = reloadConfig();
if (!newConfig.has_value())
{
newConfig = std::make_shared<GameConfig>(
ConfigLoader::loadFromDirectory(m_configDir));
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals);
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
}
catch (const std::exception& e)
{
QMessageBox::critical(this, tr("Config Error"),
tr("Failed to reload config:\n%1").arg(e.what()));
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
return;
}
// Restart is a command boundary; the view resets when the drain applies
// it (see GameWorldView::onFrame). A fresh random seed starts a new run.
// resetForNewGame() sets the speed for the new run, so the pre-restart
// speed is deliberately not restored here.
pause.release();
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::move(newConfig);
command->config = std::make_shared<GameConfig>(std::move(*newConfig));
command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else if (clicked == quitBtn)
{
pause.release();
close();
}
else
}
std::optional<GameConfig> MainWindow::reloadConfig()
{
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
// 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
{
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,20 +458,16 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
{
for (const PlacedModule& pm : b->shipLayout->placedModules)
{
for (const ModuleDef& modDef : m_config->modules.modules)
{
if (modDef.id == pm.moduleId)
{
for (const RecipeIngredient& ing : modDef.materials)
const ModuleDef* modDef =
m_config->modules.findModuleDef(pm.moduleId);
if (!modDef) { continue; }
for (const RecipeIngredient& ing : modDef->materials)
{
if (ing.item == entry.first.id)
{
perCycle += ing.amount;
}
}
break;
}
}
}
}
}
@@ -545,13 +534,11 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
{
for (const PlacedModule& pm : b->shipLayout->placedModules)
{
for (const ModuleDef& modDef : m_config->modules.modules)
const ModuleDef* modDef =
m_config->modules.findModuleDef(pm.moduleId);
if (modDef)
{
if (modDef.id == pm.moduleId)
{
durationSeconds += modDef.productionTimeSeconds;
break;
}
durationSeconds += modDef->productionTimeSeconds;
}
}
}
@@ -617,7 +604,7 @@ void SelectedBuildingPanel::updateShipyardLayoutWidgets(
layout = *shipLayout;
}
m_layoutPreview->setShipAndLayout(
shipDef->layout, layout, &m_config->modules.modules);
shipDef->layout, layout, &m_config->modules);
}
else
{
@@ -633,21 +620,13 @@ void SelectedBuildingPanel::updateShipyardLayoutWidgets(
const RecipeDef* SelectedBuildingPanel::findRecipe(const Building* b) const
{
if (b->recipeId.empty()) { return nullptr; }
for (const RecipeDef& r : m_config->recipes.recipes)
{
if (r.id == b->recipeId && r.building == b->type) { return &r; }
}
return nullptr;
return m_config->recipes.findRecipeDef(b->recipeId, b->type);
}
const ShipDef* SelectedBuildingPanel::findShipDef(const std::string& id) const
{
if (id.empty()) { return nullptr; }
for (const ShipDef& s : m_config->ships.ships)
{
if (s.id == id) { return &s; }
}
return nullptr;
return m_config->ships.findShipDef(id);
}
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
@@ -1088,15 +1067,14 @@ void SelectedBuildingPanel::buildEntityShip(entt::entity entity)
admin.get<SelectedBehaviorComponent>(entity).winner);
m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw);
for (const ShipDef& def : m_config->ships.ships)
const ShipDef* schematicDef =
m_config->ships.findShipDef(identity.schematicId);
if (schematicDef)
{
if (def.id == identity.schematicId)
{
double threat = calculateShipThreatCost(
m_config->threatCosts, *m_config, def.id, def.defaultModules);
const double threat = calculateShipThreatCost(
m_config->threatCosts, *m_config, schematicDef->id,
schematicDef->defaultModules);
m_entityStatsPanel->setThreatCost(threat);
break;
}
}
m_entityStatsPanel->show();

View File

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

View File

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

View File

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