derive threat cost dynamically
This commit is contained in:
@@ -429,14 +429,6 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
|
||||
bpMt["production_time_seconds"], file, bpPath + ".production_time_seconds");
|
||||
}
|
||||
|
||||
// Threat
|
||||
{
|
||||
const std::string tPath = elemPath + ".threat";
|
||||
const toml::table& tTable = requireTable(mt["threat"], file, tPath);
|
||||
toml::table& tMt = const_cast<toml::table&>(tTable);
|
||||
def.threat.costFormula = requireFormula(tMt["cost_formula"], file, tPath + ".cost_formula");
|
||||
}
|
||||
|
||||
// Health
|
||||
{
|
||||
const std::string hPath = elemPath + ".health";
|
||||
@@ -587,7 +579,6 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
||||
mt["player_production_level"], file, elemPath + ".player_production_level"));
|
||||
def.productionTimeSeconds = requireDouble(
|
||||
mt["production_time_seconds"], file, elemPath + ".production_time_seconds");
|
||||
def.threatCost = requireDouble(mt["threat_cost"], file, elemPath + ".threat_cost");
|
||||
def.fillColor = requireString(mt["fill_color"], file, elemPath + ".fill_color");
|
||||
def.glyph = requireString(mt["glyph"], file, elemPath + ".glyph");
|
||||
|
||||
@@ -704,5 +695,6 @@ GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir)
|
||||
cfg.ships = loadShips(configDir + "/ships.toml");
|
||||
cfg.stations = loadStations(configDir + "/stations.toml");
|
||||
cfg.modules = loadModules(configDir + "/modules.toml");
|
||||
cfg.threatCosts = computeThreatCostTable(cfg);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "ShipsConfig.h"
|
||||
#include "StationsConfig.h"
|
||||
#include "ModulesConfig.h"
|
||||
#include "ThreatCostCalculator.h"
|
||||
|
||||
// Aggregate of all simulation config files. Loaded at startup and reloaded
|
||||
// from disk on each game restart (REQ-CFG-RELOAD). See architecture.md "Config Loading".
|
||||
@@ -17,4 +18,5 @@ struct GameConfig
|
||||
ShipsConfig ships;
|
||||
StationsConfig stations;
|
||||
ModulesConfig modules;
|
||||
ThreatCostTable threatCosts;
|
||||
};
|
||||
|
||||
@@ -45,7 +45,6 @@ struct ModuleDef
|
||||
std::vector<RecipeIngredient> materials;
|
||||
int playerProductionLevel;
|
||||
double productionTimeSeconds;
|
||||
double threatCost;
|
||||
std::string fillColor;
|
||||
std::string glyph;
|
||||
std::vector<ModuleStatModifier> statModifiers;
|
||||
|
||||
@@ -16,13 +16,6 @@ struct ShipSchematic
|
||||
double productionTimeSeconds;
|
||||
};
|
||||
|
||||
// Wave scheduling cost (REQ-WAV-THREAT-COST). Ships with cost_formula that
|
||||
// always evaluates to 0 are ineligible as wave picks.
|
||||
struct ShipThreat
|
||||
{
|
||||
Formula costFormula;
|
||||
};
|
||||
|
||||
struct ShipHealth
|
||||
{
|
||||
Formula hpFormula; // REQ-SHP-STATS
|
||||
@@ -55,7 +48,6 @@ struct ShipDef
|
||||
std::vector<std::string> layout;
|
||||
|
||||
ShipSchematic schematic;
|
||||
ShipThreat threat;
|
||||
ShipHealth health;
|
||||
ShipMovement movement;
|
||||
ShipSensor sensor;
|
||||
|
||||
@@ -24,6 +24,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ArenaStartRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/WeaponFiredEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
|
||||
14
src/lib/eventsystem/event/DebugDrawToggledEvent.h
Normal file
14
src/lib/eventsystem/event/DebugDrawToggledEvent.h
Normal file
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include "Event.h"
|
||||
|
||||
class DebugDrawToggledEvent : public Event
|
||||
{
|
||||
public:
|
||||
explicit DebugDrawToggledEvent(bool active)
|
||||
: active(active)
|
||||
{
|
||||
}
|
||||
|
||||
const bool active;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
@@ -21,6 +22,7 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
247
src/lib/sim/ThreatCostCalculator.cpp
Normal file
247
src/lib/sim/ThreatCostCalculator.cpp
Normal file
@@ -0,0 +1,247 @@
|
||||
#include "ThreatCostCalculator.h"
|
||||
|
||||
#include <limits>
|
||||
#include <set>
|
||||
|
||||
#include "GameConfig.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
struct RecipeRef
|
||||
{
|
||||
const RecipeDef* recipe;
|
||||
std::string outputItem;
|
||||
int outputAmount;
|
||||
double probability;
|
||||
};
|
||||
|
||||
double computeMaterialThreat(const ThreatCostTable& table,
|
||||
const std::vector<RecipeIngredient>& materials)
|
||||
{
|
||||
double total = 0.0;
|
||||
for (const RecipeIngredient& mat : materials)
|
||||
{
|
||||
std::map<std::string, double>::const_iterator it = table.itemThreat.find(mat.item);
|
||||
if (it != table.itemThreat.end())
|
||||
{
|
||||
total += it->second * mat.amount;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
bool allInputsResolved(const RecipeDef& recipe,
|
||||
const std::map<std::string, double>& resolved)
|
||||
{
|
||||
for (const RecipeIngredient& input : recipe.inputs)
|
||||
{
|
||||
if (resolved.find(input.item) == resolved.end())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
double computeRecipeThreat(const RecipeDef& recipe,
|
||||
const std::map<std::string, double>& resolved)
|
||||
{
|
||||
double threat = recipe.durationSeconds;
|
||||
for (const RecipeIngredient& input : recipe.inputs)
|
||||
{
|
||||
threat += resolved.at(input.item) * input.amount;
|
||||
}
|
||||
return threat;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
ThreatCostTable computeThreatCostTable(const GameConfig& config)
|
||||
{
|
||||
ThreatCostTable table;
|
||||
|
||||
// Build lookup: output item → non-reprocessing recipes and reprocessing recipes.
|
||||
std::map<std::string, std::vector<RecipeRef>> nonReprocessingRecipes;
|
||||
std::map<std::string, std::vector<RecipeRef>> reprocessingRecipes;
|
||||
|
||||
for (const RecipeDef& recipe : config.recipes.recipes)
|
||||
{
|
||||
if (recipe.building == BuildingType::ReprocessingPlant)
|
||||
{
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
RecipeRef ref;
|
||||
ref.recipe = &recipe;
|
||||
ref.outputItem = out.item;
|
||||
ref.outputAmount = out.amount;
|
||||
ref.probability = out.probability.value_or(1.0);
|
||||
reprocessingRecipes[out.item].push_back(ref);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
RecipeRef ref;
|
||||
ref.recipe = &recipe;
|
||||
ref.outputItem = out.item;
|
||||
ref.outputAmount = out.amount;
|
||||
ref.probability = 1.0;
|
||||
nonReprocessingRecipes[out.item].push_back(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all item names that need resolving.
|
||||
std::set<std::string> unresolved;
|
||||
for (const std::pair<const std::string, std::vector<RecipeRef>>& entry : nonReprocessingRecipes)
|
||||
{
|
||||
unresolved.insert(entry.first);
|
||||
}
|
||||
for (const std::pair<const std::string, std::vector<RecipeRef>>& entry : reprocessingRecipes)
|
||||
{
|
||||
unresolved.insert(entry.first);
|
||||
}
|
||||
|
||||
// Iteratively resolve non-reprocessing items.
|
||||
bool progress = true;
|
||||
while (progress)
|
||||
{
|
||||
progress = false;
|
||||
std::set<std::string> newlyResolved;
|
||||
for (const std::string& item : unresolved)
|
||||
{
|
||||
std::map<std::string, std::vector<RecipeRef>>::const_iterator it =
|
||||
nonReprocessingRecipes.find(item);
|
||||
if (it == nonReprocessingRecipes.end())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double maxThreat = -1.0;
|
||||
for (const RecipeRef& ref : it->second)
|
||||
{
|
||||
if (allInputsResolved(*ref.recipe, table.itemThreat))
|
||||
{
|
||||
double threat = computeRecipeThreat(*ref.recipe, table.itemThreat);
|
||||
if (threat > maxThreat)
|
||||
{
|
||||
maxThreat = threat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (maxThreat >= 0.0)
|
||||
{
|
||||
table.itemThreat[item] = maxThreat;
|
||||
newlyResolved.insert(item);
|
||||
progress = true;
|
||||
}
|
||||
}
|
||||
for (const std::string& item : newlyResolved)
|
||||
{
|
||||
unresolved.erase(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute scrap threat (REQ-THREAT-SCRAP): find the ship with the smallest
|
||||
// scrap_drop and use its threat cost.
|
||||
int minScrapDrop = std::numeric_limits<int>::max();
|
||||
const ShipDef* cheapestScrapShip = nullptr;
|
||||
for (const ShipDef& def : config.ships.ships)
|
||||
{
|
||||
if (def.loot.scrapDrop > 0 && def.loot.scrapDrop < minScrapDrop)
|
||||
{
|
||||
minScrapDrop = def.loot.scrapDrop;
|
||||
cheapestScrapShip = &def;
|
||||
}
|
||||
}
|
||||
|
||||
if (cheapestScrapShip != nullptr)
|
||||
{
|
||||
double shipThreat = calculateShipThreatCost(table, config,
|
||||
cheapestScrapShip->id, cheapestScrapShip->defaultModules);
|
||||
table.scrapThreat = shipThreat / minScrapDrop;
|
||||
}
|
||||
|
||||
// Resolve reprocessing-only items.
|
||||
for (const std::string& item : unresolved)
|
||||
{
|
||||
std::map<std::string, std::vector<RecipeRef>>::const_iterator it =
|
||||
reprocessingRecipes.find(item);
|
||||
if (it == reprocessingRecipes.end())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const RecipeRef& ref : it->second)
|
||||
{
|
||||
int scrapPerCycle = 0;
|
||||
for (const RecipeIngredient& input : ref.recipe->inputs)
|
||||
{
|
||||
scrapPerCycle += input.amount;
|
||||
}
|
||||
|
||||
double threat = (table.scrapThreat * scrapPerCycle
|
||||
+ ref.recipe->durationSeconds) / ref.probability;
|
||||
std::map<std::string, double>::iterator existing = table.itemThreat.find(item);
|
||||
if (existing == table.itemThreat.end() || threat > existing->second)
|
||||
{
|
||||
table.itemThreat[item] = threat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
|
||||
double calculateShipThreatCost(const ThreatCostTable& table,
|
||||
const GameConfig& config,
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (shipDef == nullptr)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
double threat = shipDef->schematic.productionTimeSeconds;
|
||||
|
||||
// Add material threat for ship base materials.
|
||||
threat += computeMaterialThreat(table, shipDef->schematic.materials);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
if (moduleDef == nullptr)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
threat += moduleDef->productionTimeSeconds;
|
||||
threat += computeMaterialThreat(table, moduleDef->materials);
|
||||
}
|
||||
|
||||
return threat;
|
||||
}
|
||||
22
src/lib/sim/ThreatCostCalculator.h
Normal file
22
src/lib/sim/ThreatCostCalculator.h
Normal file
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "ShipLayout.h"
|
||||
|
||||
struct GameConfig;
|
||||
|
||||
struct ThreatCostTable
|
||||
{
|
||||
std::map<std::string, double> itemThreat;
|
||||
double scrapThreat = 0.0;
|
||||
};
|
||||
|
||||
ThreatCostTable computeThreatCostTable(const GameConfig& config);
|
||||
|
||||
double calculateShipThreatCost(const ThreatCostTable& table,
|
||||
const GameConfig& config,
|
||||
const std::string& shipId,
|
||||
const std::vector<PlacedModule>& modules);
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <algorithm>
|
||||
|
||||
#include "ShipSystem.h"
|
||||
#include "ThreatCostCalculator.h"
|
||||
#include "tracing.h"
|
||||
|
||||
WaveSystem::WaveSystem(const GameConfig& config, std::mt19937& rng)
|
||||
@@ -177,7 +178,8 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::selectWaveShips(double& budget,
|
||||
std::vector<EligibleShip> eligible;
|
||||
for (const ShipDef& def : m_config.ships.ships)
|
||||
{
|
||||
const double cost = def.threat.costFormula.evaluate(static_cast<double>(shipLevel));
|
||||
const double cost = calculateShipThreatCost(m_config.threatCosts, m_config,
|
||||
def.id, def.defaultModules);
|
||||
if (cost > 0.0)
|
||||
{
|
||||
EligibleShip es;
|
||||
|
||||
@@ -19,5 +19,6 @@ add_files(
|
||||
BlueprintSerializerTest.cpp
|
||||
ModuleConfigTest.cpp
|
||||
ShipModuleTest.cpp
|
||||
ThreatCostCalculatorTest.cpp
|
||||
RecipeSchematicTest.cpp
|
||||
)
|
||||
|
||||
@@ -40,7 +40,6 @@ TEST_CASE("ConfigLoader: loadModules parses modules.toml", "[config][modules]")
|
||||
CHECK(armor.materials[0].amount == 2);
|
||||
CHECK(armor.playerProductionLevel == 1);
|
||||
CHECK(armor.productionTimeSeconds == Approx(3.0));
|
||||
CHECK(armor.threatCost == Approx(2.0));
|
||||
CHECK(armor.fillColor == "#808080");
|
||||
CHECK(armor.glyph == "A");
|
||||
REQUIRE(armor.statModifiers.size() == 1);
|
||||
|
||||
110
src/test/ThreatCostCalculatorTest.cpp
Normal file
110
src/test/ThreatCostCalculatorTest.cpp
Normal file
@@ -0,0 +1,110 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include "ConfigLoader.h"
|
||||
#include "ThreatCostCalculator.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: miner item threat equals duration", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
CHECK(table.itemThreat.at("iron_ore") == Approx(1.0));
|
||||
CHECK(table.itemThreat.at("copper_ore") == Approx(1.5));
|
||||
}
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: smelter item threat includes input costs", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
// iron_ingot: duration 2.0 + iron_ore(1.0) * 2 = 4.0
|
||||
CHECK(table.itemThreat.at("iron_ingot") == Approx(4.0));
|
||||
// copper_ingot: duration 2.5 + copper_ore(1.5) * 2 = 5.5
|
||||
CHECK(table.itemThreat.at("copper_ingot") == Approx(5.5));
|
||||
}
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: assembler takes max across recipes", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
// circuit_board has three non-reprocessing recipes:
|
||||
// 5.0 + iron_ingot(4.0)*3 + copper_ingot(5.5)*2 = 28.0
|
||||
// 3.0 + copper_ingot(5.5)*3 = 19.5
|
||||
// 6.0 + iron_ingot(4.0)*5 = 26.0
|
||||
// max = 28.0
|
||||
CHECK(table.itemThreat.at("circuit_board") == Approx(28.0));
|
||||
}
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: scrap threat from cheapest ship", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
// Cheapest ship by scrap_drop is interceptor (scrap_drop=2).
|
||||
// Interceptor threat: 10 + iron_ingot(4)*3 + circuit_board(28)*1
|
||||
// + laser_cannon(5 + iron_ingot(4)*1) = 10 + 12 + 28 + 9 = 59.0
|
||||
// scrapThreat = 59.0 / 2 = 29.5
|
||||
CHECK(table.scrapThreat == Approx(29.5));
|
||||
}
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: reprocessing-only item threat", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
// advanced_alloy: reprocessing recipe with scrap*5, duration 3.0, probability 0.1
|
||||
// (29.5 * 5 + 3.0) / 0.1 = 1505.0
|
||||
CHECK(table.itemThreat.at("advanced_alloy") == Approx(1505.0));
|
||||
}
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: ship threat with default modules", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
// interceptor: 10 + iron_ingot(4)*3 + circuit_board(28)*1 + laser_cannon(5 + 4*1) = 59.0
|
||||
double interceptorThreat = calculateShipThreatCost(
|
||||
table, cfg, "interceptor", cfg.ships.ships[0].defaultModules);
|
||||
CHECK(interceptorThreat == Approx(59.0));
|
||||
|
||||
// salvage_ship (no default modules): 10 + iron_ingot(4)*4 = 26.0
|
||||
double salvageThreat = calculateShipThreatCost(
|
||||
table, cfg, "salvage_ship", cfg.ships.ships[2].defaultModules);
|
||||
CHECK(salvageThreat == Approx(26.0));
|
||||
}
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: ship threat with custom modules", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
// interceptor base: 10 + iron_ingot(4)*3 + circuit_board(28)*1 = 50.0
|
||||
// + armor_plate: 3 + iron_ingot(4)*2 = 11.0
|
||||
// + sensor_booster: 2 + circuit_board(28)*1 = 30.0
|
||||
// total = 50.0 + 11.0 + 30.0 = 91.0
|
||||
std::vector<PlacedModule> modules;
|
||||
PlacedModule armor;
|
||||
armor.moduleId = "armor_plate";
|
||||
modules.push_back(armor);
|
||||
PlacedModule sensor;
|
||||
sensor.moduleId = "sensor_booster";
|
||||
modules.push_back(sensor);
|
||||
|
||||
double threat = calculateShipThreatCost(table, cfg, "interceptor", modules);
|
||||
CHECK(threat == Approx(91.0));
|
||||
}
|
||||
|
||||
TEST_CASE("ThreatCostCalculator: unknown ship returns zero", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
double threat = calculateShipThreatCost(table, cfg, "nonexistent_ship", {});
|
||||
CHECK(threat == Approx(0.0));
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
#include "ShipsConfig.h"
|
||||
#include "Simulation.h"
|
||||
#include "Tick.h"
|
||||
#include "ThreatCostCalculator.h"
|
||||
#include "WaveSystem.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
@@ -201,25 +202,16 @@ TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave]
|
||||
REQUIRE(foundEnemyShip);
|
||||
}
|
||||
|
||||
TEST_CASE("WaveSystem: only eligible ships (cost > 0) appear in waves", "[wave]")
|
||||
TEST_CASE("WaveSystem: all ships have positive dynamic threat cost", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
const GameConfig cfg = loadConfig();
|
||||
|
||||
// Run long enough for several waves.
|
||||
const int limit = static_cast<int>(secondsToTicks(120.0));
|
||||
for (int i = 0; i < limit; ++i)
|
||||
for (const ShipDef& def : cfg.ships.ships)
|
||||
{
|
||||
sim.tick();
|
||||
const double cost = calculateShipThreatCost(cfg.threatCosts, cfg,
|
||||
def.id, def.defaultModules);
|
||||
CHECK(cost > 0.0);
|
||||
}
|
||||
|
||||
sim.admin().forEach<ShipIdentityComponent, FactionComponent>(
|
||||
[&](entt::entity /*e*/, const ShipIdentityComponent& si, const FactionComponent& f)
|
||||
{
|
||||
if (!f.isEnemy) { return; }
|
||||
// salvage_ship and repair_ship have cost_formula = "0" and must not spawn.
|
||||
REQUIRE(si.schematicId != "salvage_ship");
|
||||
REQUIRE(si.schematicId != "repair_ship");
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1144,6 +1144,8 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
|
||||
break;
|
||||
case Qt::Key_M:
|
||||
m_debugDraw = !m_debugDraw;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<DebugDrawToggledEvent>(m_debugDraw));
|
||||
break;
|
||||
case Qt::Key_L:
|
||||
EventManager::getInstance()->addEvent(std::make_shared<TracePrintRequestedEvent>());
|
||||
@@ -1438,6 +1440,11 @@ double GameWorldView::gameSpeed() const
|
||||
return m_gameSpeedMultiplier;
|
||||
}
|
||||
|
||||
bool GameWorldView::isDebugDrawEnabled() const
|
||||
{
|
||||
return m_debugDraw;
|
||||
}
|
||||
|
||||
void GameWorldView::resetFrameTimer()
|
||||
{
|
||||
m_frameTimer.restart();
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include "EventHandler.h"
|
||||
#include "ExitBlueprintModeRequestedEvent.h"
|
||||
#include "ExitBuilderModeRequestedEvent.h"
|
||||
#include "DebugDrawToggledEvent.h"
|
||||
#include "WeaponFiredEvent.h"
|
||||
#include "SchematicChoiceOption.h"
|
||||
#include "SpeedChangeRequestedEvent.h"
|
||||
@@ -65,6 +66,7 @@ public:
|
||||
~GameWorldView() override;
|
||||
|
||||
double gameSpeed() const;
|
||||
bool isDebugDrawEnabled() const;
|
||||
void resetFrameTimer();
|
||||
void setGameSpeed(double multiplier);
|
||||
void resetForNewGame();
|
||||
|
||||
@@ -223,6 +223,7 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
|
||||
m_layoutBlueprints,
|
||||
std::move(unlockedModuleIds),
|
||||
std::move(moduleLevels),
|
||||
m_gameWorldView->isDebugDrawEnabled(),
|
||||
this);
|
||||
if (dialog.exec() == QDialog::Accepted && dialog.result().has_value())
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "ShipStatsCalculator.h"
|
||||
#include "ShipStatsPanel.h"
|
||||
#include "ThreatCostCalculator.h"
|
||||
#include "StationBodyComponent.h"
|
||||
#include "TickAdvancedEvent.h"
|
||||
#include "Building.h"
|
||||
@@ -784,6 +785,19 @@ void SelectedBuildingPanel::buildEntityShip(entt::entity entity)
|
||||
|
||||
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
|
||||
m_entityStatsPanel->refreshFromLive(stats, health.hp);
|
||||
m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw);
|
||||
|
||||
for (const ShipDef& def : m_config->ships.ships)
|
||||
{
|
||||
if (def.id == identity.schematicId)
|
||||
{
|
||||
double threat = calculateShipThreatCost(
|
||||
m_config->threatCosts, *m_config, def.id, def.defaultModules);
|
||||
m_entityStatsPanel->setThreatCost(threat);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
m_entityStatsPanel->show();
|
||||
|
||||
m_stationStatsLabel->hide();
|
||||
@@ -869,3 +883,9 @@ void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEv
|
||||
{
|
||||
onSelectionChanged(event->ids);
|
||||
}
|
||||
|
||||
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)
|
||||
{
|
||||
m_debugDraw = event->active;
|
||||
m_entityStatsPanel->setDebugDrawEnabled(event->active);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
#include "Building.h"
|
||||
#include "BuildingId.h"
|
||||
#include "DebugDrawToggledEvent.h"
|
||||
#include "EntitySelectedEvent.h"
|
||||
#include "EventHandler.h"
|
||||
#include "GameConfig.h"
|
||||
@@ -33,7 +34,8 @@ class QVBoxLayout;
|
||||
class SelectedBuildingPanel : public QWidget,
|
||||
public CombinedEventHandler<TickAdvancedEvent,
|
||||
EntitySelectedEvent,
|
||||
SelectionChangedEvent>
|
||||
SelectionChangedEvent,
|
||||
DebugDrawToggledEvent>
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
@@ -46,6 +48,7 @@ private:
|
||||
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
|
||||
|
||||
private slots:
|
||||
void onRecipeChanged(int comboIndex);
|
||||
@@ -86,6 +89,7 @@ private:
|
||||
QPoint m_splitterTile;
|
||||
std::string m_currentRecipeId;
|
||||
|
||||
bool m_debugDraw = false;
|
||||
std::optional<entt::entity> m_selectedEntity;
|
||||
ShipStatsPanel* m_entityStatsPanel;
|
||||
QLabel* m_entityTitleLabel;
|
||||
|
||||
@@ -366,6 +366,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
|
||||
std::vector<ShipLayoutBlueprint>& allBlueprints,
|
||||
std::set<std::string> unlockedModuleIds,
|
||||
std::map<std::string, int> moduleLevels,
|
||||
bool debugDraw,
|
||||
QWidget* parent)
|
||||
: QDialog(parent)
|
||||
, m_config(config)
|
||||
@@ -380,6 +381,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
|
||||
, m_removeButton(nullptr)
|
||||
, m_gridWidget(nullptr)
|
||||
, m_statsPanel(nullptr)
|
||||
, m_debugDraw(debugDraw)
|
||||
{
|
||||
setWindowTitle(tr("Configure Ship Layout"));
|
||||
setModal(true);
|
||||
@@ -434,6 +436,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
|
||||
|
||||
// Left column: ship stats panel.
|
||||
m_statsPanel = new ShipStatsPanel(config, this);
|
||||
m_statsPanel->setDebugDrawEnabled(m_debugDraw);
|
||||
columnsLayout->addWidget(m_statsPanel);
|
||||
|
||||
// Center column: module selection buttons.
|
||||
|
||||
@@ -28,6 +28,7 @@ public:
|
||||
std::vector<ShipLayoutBlueprint>& allBlueprints,
|
||||
std::set<std::string> unlockedModuleIds,
|
||||
std::map<std::string, int> moduleLevels,
|
||||
bool debugDraw,
|
||||
QWidget* parent = nullptr);
|
||||
|
||||
std::optional<ShipLayoutConfig> result() const;
|
||||
@@ -77,6 +78,7 @@ private:
|
||||
QPushButton* m_removeButton;
|
||||
QWidget* m_gridWidget;
|
||||
ShipStatsPanel* m_statsPanel;
|
||||
bool m_debugDraw;
|
||||
|
||||
std::optional<ShipLayoutConfig> m_result;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include "GameConfig.h"
|
||||
#include "ShipStatsCalculator.h"
|
||||
#include "ThreatCostCalculator.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -103,6 +104,11 @@ ShipStatsPanel::ShipStatsPanel(const GameConfig* config, QWidget* parent)
|
||||
m_repairSection->setVisible(false);
|
||||
layout->addWidget(m_repairSection);
|
||||
|
||||
// Threat cost — debug-only, initially hidden.
|
||||
m_threatCostLabel = makeStatLabel(this);
|
||||
m_threatCostLabel->setVisible(false);
|
||||
layout->addWidget(m_threatCostLabel);
|
||||
|
||||
layout->addStretch();
|
||||
}
|
||||
|
||||
@@ -115,6 +121,10 @@ void ShipStatsPanel::refresh(const std::string& shipId,
|
||||
moduleLevelOverrides);
|
||||
const QString hpText = tr("HP: %1").arg(static_cast<int>(stats.hp + 0.5f));
|
||||
applyStats(stats, hpText);
|
||||
|
||||
const double threat = calculateShipThreatCost(m_config->threatCosts, *m_config,
|
||||
shipId, modules);
|
||||
setThreatCost(threat);
|
||||
}
|
||||
|
||||
void ShipStatsPanel::refreshFromLive(const ShipStats& stats, float currentHp)
|
||||
@@ -180,3 +190,15 @@ void ShipStatsPanel::applyStats(const ShipStats& stats, const QString& hpText)
|
||||
m_repairSection->setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
void ShipStatsPanel::setThreatCost(double cost)
|
||||
{
|
||||
m_threatCostLabel->setText(tr("Threat Cost: %1").arg(cost, 0, 'f', 1));
|
||||
m_threatCostLabel->setVisible(m_debugDraw);
|
||||
}
|
||||
|
||||
void ShipStatsPanel::setDebugDrawEnabled(bool enabled)
|
||||
{
|
||||
m_debugDraw = enabled;
|
||||
m_threatCostLabel->setVisible(m_debugDraw);
|
||||
}
|
||||
|
||||
@@ -26,10 +26,14 @@ public:
|
||||
|
||||
void refreshFromLive(const ShipStats& stats, float currentHp);
|
||||
|
||||
void setThreatCost(double cost);
|
||||
void setDebugDrawEnabled(bool enabled);
|
||||
|
||||
private:
|
||||
void applyStats(const ShipStats& stats, const QString& hpText);
|
||||
|
||||
const GameConfig* m_config;
|
||||
bool m_debugDraw = false;
|
||||
|
||||
QLabel* m_hpLabel;
|
||||
QLabel* m_speedLabel;
|
||||
@@ -50,4 +54,6 @@ private:
|
||||
QWidget* m_repairSection;
|
||||
QLabel* m_repairRateLabel;
|
||||
QLabel* m_repairRangeLabel;
|
||||
|
||||
QLabel* m_threatCostLabel;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user