derive threat cost dynamically

This commit is contained in:
2026-06-13 21:50:00 +02:00
parent 3716c2b734
commit 10c5ad678f
28 changed files with 498 additions and 79 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
#pragma once
#include "Event.h"
class DebugDrawToggledEvent : public Event
{
public:
explicit DebugDrawToggledEvent(bool active)
: active(active)
{
}
const bool active;
};

View File

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

View 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;
}

View 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);

View File

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