Derive ship scrap drop from threat

This commit is contained in:
2026-07-02 21:30:38 +02:00
parent 5bc581bcb8
commit 81b1c7a66b
16 changed files with 61 additions and 113 deletions

View File

@@ -330,15 +330,11 @@ void ArenaSimulation::tickDeaths()
{
const ShipIdentityComponent& si = m_admin.get<ShipIdentityComponent>(deadEntity);
const PositionComponent& pos = m_admin.get<PositionComponent>(deadEntity);
for (const ShipDef& def : m_gameConfig.ships.ships)
if (si.scrapDrop > 0)
{
if (def.id == si.schematicId && def.loot.scrapDrop > 0)
{
const Tick despawnAt = m_currentTick
+ secondsToTicks(m_gameConfig.world.scrapDespawnSeconds);
m_scrapSystem->spawn(pos.value, def.loot.scrapDrop, despawnAt);
break;
}
const Tick despawnAt = m_currentTick
+ secondsToTicks(m_gameConfig.world.scrapDespawnSeconds);
m_scrapSystem->spawn(pos.value, si.scrapDrop, despawnAt);
}
m_shipSystem->despawn(deadEntity);
}

View File

@@ -264,6 +264,7 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
cfg.refundPercentage = static_cast<int>(requireInt(tbl["world"]["refund_percentage"], file, "world.refund_percentage"));
cfg.startingBuildingBlocks = static_cast<int>(requireInt(tbl["world"]["starting_building_blocks"], file, "world.starting_building_blocks"));
cfg.scrapDespawnSeconds = requireDouble(tbl["world"]["scrap_despawn_seconds"], file, "world.scrap_despawn_seconds");
cfg.scrapPerThreat = requireDouble(tbl["world"]["scrap_per_threat"], file, "world.scrap_per_threat");
cfg.tileSize_m = requireDouble(tbl["world"]["tile_size_m"], file, "world.tile_size_m");
cfg.beltSpeed_tps = requireDouble(tbl["world"]["belt_speed_mps"], file, "world.belt_speed_mps") / cfg.tileSize_m;
cfg.tunnelMaxDistance_tiles = static_cast<int>(requireInt(tbl["world"]["tunnel_max_distance_tiles"], file, "world.tunnel_max_distance_tiles"));
@@ -466,14 +467,6 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
def.sensor.sensorRangeFormula = requireFormula(snsMt["sensor_range_m_formula"], file, snsPath + ".sensor_range_m_formula");
}
// Loot
{
const std::string lPath = elemPath + ".loot";
const toml::table& lTable = requireTable(mt["loot"], file, lPath);
toml::table& lMt = const_cast<toml::table&>(lTable);
def.loot.scrapDrop = static_cast<int>(requireInt(lMt["scrap_drop"], file, lPath + ".scrap_drop"));
}
// Optional: default_modules (REQ-WAV-DEFAULT-MODULES)
if (mt.contains("default_modules"))
{

View File

@@ -35,12 +35,6 @@ struct ShipSensor
Formula sensorRangeFormula; // REQ-SHP-SENSOR, REQ-SHP-STATS
};
// Scrap dropped on destruction (REQ-RES-SCRAP-DROP).
struct ShipLoot
{
int scrapDrop;
};
struct ShipDef
{
std::string id;
@@ -51,7 +45,6 @@ struct ShipDef
ShipHealth health;
ShipMovement movement;
ShipSensor sensor;
ShipLoot loot;
// Module layout used for enemy wave ships (REQ-WAV-DEFAULT-MODULES).
std::vector<PlacedModule> defaultModules;

View File

@@ -60,6 +60,7 @@ struct WorldConfig
int refundPercentage; // REQ-BLD-DEMOLISH
int startingBuildingBlocks; // REQ-HQ-STARTING-BLOCKS
double scrapDespawnSeconds; // REQ-RES-SCRAP-DROP
double scrapPerThreat; // REQ-RES-SCRAP-DROP, REQ-THREAT-SCRAP (scrap dropped per unit threat)
double tileSize_m; // metres per tile (REQ-GW-TILE-SIZE)
double beltSpeed_tps; // REQ-GW-BELT-SPEED (tiles/s, converted from m/s in config)
int tunnelMaxDistance_tiles; // REQ-BLD-TUNNEL-PAIR

View File

@@ -6,4 +6,7 @@ struct ShipIdentityComponent
{
int level;
std::string schematicId;
// Scrap dropped on destruction, derived from the ship's as-built threat cost
// at spawn time (REQ-RES-SCRAP-DROP).
int scrapDrop = 0;
};

View File

@@ -1,6 +1,8 @@
#include "ShipSystem.h"
#include <algorithm>
#include <cassert>
#include <cmath>
#include <map>
#include <stdexcept>
#include <utility>
@@ -26,7 +28,9 @@
#include "SalvagerComponent.h"
#include "SelectedBehaviorComponent.h"
#include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
#include "StandbyBehavior.h"
#include "ThreatCostCalculator.h"
#include "Tick.h"
#include "tracing.h"
#include "WeaponComponent.h"
@@ -103,6 +107,16 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
const std::vector<PlacedModule>& modules =
layout.has_value() ? layout->placedModules : def->defaultModules;
// Derive the scrap dropped on destruction from the ship's as-built threat cost
// (REQ-RES-SCRAP-DROP): round(threat * scrap_per_threat), floored at 1 for any
// ship with threat > 0. Computed once here since threat is level-independent.
const double threatCost = calculateShipThreatCost(m_config.threatCosts, m_config,
schematicId, modules);
const int scrapDrop = threatCost > 0.0
? std::max(1, static_cast<int>(std::lround(threatCost * m_config.world.scrapPerThreat)))
: 0;
m_admin.get<ShipIdentityComponent>(entity).scrapDrop = scrapDrop;
// --- Pass 1: create capability child entities ----------------------------
std::vector<entt::entity> weaponChildren;
std::vector<entt::entity> salvageChildren;

View File

@@ -551,15 +551,11 @@ void Simulation::tickDeathsAndLoot()
{
const ShipIdentityComponent& si = m_admin.get<ShipIdentityComponent>(deadEntity);
const PositionComponent& pos = m_admin.get<PositionComponent>(deadEntity);
for (const ShipDef& def : m_config.ships.ships)
if (si.scrapDrop > 0)
{
if (def.id == si.schematicId && def.loot.scrapDrop > 0)
{
const Tick despawnAt = m_currentTick
+ secondsToTicks(m_config.world.scrapDespawnSeconds);
m_scrapSystem->spawn(pos.value, def.loot.scrapDrop, despawnAt);
break;
}
const Tick despawnAt = m_currentTick
+ secondsToTicks(m_config.world.scrapDespawnSeconds);
m_scrapSystem->spawn(pos.value, si.scrapDrop, despawnAt);
}
m_shipSystem->despawn(deadEntity);
}

View File

@@ -1,6 +1,5 @@
#include "ThreatCostCalculator.h"
#include <limits>
#include <set>
#include "GameConfig.h"
@@ -62,6 +61,14 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
{
ThreatCostTable table;
// Scrap threat (REQ-THREAT-SCRAP) is the constant inverse of the scrap-drop
// conversion (REQ-RES-SCRAP-DROP): one scrap is worth 1 / scrap_per_threat.
// Set it up front so reprocessing-only item threats (below) can use it, and so
// it no longer depends on any ship's threat cost.
table.scrapThreat = config.world.scrapPerThreat > 0.0
? 1.0 / config.world.scrapPerThreat
: 0.0;
// 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;
@@ -146,26 +153,6 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
}
}
// 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)
{

View File

@@ -14,6 +14,7 @@
#include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "ModuleOwnerComponent.h"
#include "ScrapDataComponent.h"
#include "ScrapSystem.h"
#include "ShipSystem.h"
#include "Simulation.h"
@@ -406,24 +407,19 @@ TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* droppingDef = nullptr;
for (const ShipDef& def : sim.config().ships.ships)
{
if (def.loot.scrapDrop > 0)
{
droppingDef = &def;
break;
}
}
REQUIRE(droppingDef != nullptr);
const entt::entity ship = sim.ships().spawn(droppingDef->id, 1,
// Scrap dropped on death is derived from the ship's as-built threat cost
// (REQ-RES-SCRAP-DROP): round(threat * scrap_per_threat). The interceptor's
// threat is 59.0 and the test config sets scrap_per_threat = 1.0, so it drops
// round(59.0 * 1.0) = 59 scrap.
const entt::entity ship = sim.ships().spawn("interceptor", 1,
QVector2D(10.0f, 10.0f));
sim.admin().get<HealthComponent>(ship).hp = -1.0f;
sim.tick();
REQUIRE(!sim.scraps().allScrapInfo().empty());
const std::vector<ScrapInfo> scraps = sim.scraps().allScrapInfo();
REQUIRE(scraps.size() == 1);
CHECK(sim.admin().get<ScrapDataComponent>(scraps[0].entity).amount == 59);
}
TEST_CASE("CombatSystem: HQ death sets game over", "[combat]")

View File

@@ -78,6 +78,7 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
REQUIRE(cfg.world.push.bossAdvanceSeconds == Approx(60.0));
REQUIRE(cfg.world.orbitFactor == Approx(0.8));
REQUIRE(cfg.world.rallyOrbitRadius_tiles == Approx(5.0));
REQUIRE(cfg.world.scrapPerThreat == Approx(1.0));
// Spot-check that a config-derived formula computes as expected.
// threat_rate_formula = "x": evaluates to the input value.
@@ -167,6 +168,7 @@ TEST_CASE("Missing field in world.toml is rejected with the field path", "[confi
height_tiles = 60
refund_percentage = 75
scrap_despawn_seconds = 30
scrap_per_threat = 0.01
tile_size_m = 10
belt_speed_mps = 20
starting_building_blocks = 100
@@ -217,6 +219,7 @@ TEST_CASE("Malformed formula in world.toml is rejected with field identification
height_tiles = 60
refund_percentage = 75
scrap_despawn_seconds = 30
scrap_per_threat = 0.01
tile_size_m = 10
belt_speed_mps = 20
starting_building_blocks = 100
@@ -268,6 +271,7 @@ TEST_CASE("Inverted wave gap range is rejected", "[config]")
height_tiles = 60
refund_percentage = 75
scrap_despawn_seconds = 30
scrap_per_threat = 0.01
tile_size_m = 10
belt_speed_mps = 20

View File

@@ -41,16 +41,15 @@ TEST_CASE("ThreatCostCalculator: assembler takes max across recipes", "[threat]"
CHECK(table.itemThreat.at("circuit_board") == Approx(28.0));
}
TEST_CASE("ThreatCostCalculator: scrap threat from cheapest ship", "[threat]")
TEST_CASE("ThreatCostCalculator: scrap threat is 1 / scrap_per_threat", "[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));
// REQ-THREAT-SCRAP: scrap threat is the constant 1 / world.scrap_per_threat.
// The test config sets scrap_per_threat = 1.0, so scrapThreat = 1.0.
CHECK(table.scrapThreat == Approx(1.0 / cfg.world.scrapPerThreat));
CHECK(table.scrapThreat == Approx(1.0));
}
TEST_CASE("ThreatCostCalculator: reprocessing-only item threat", "[threat]")
@@ -59,8 +58,8 @@ TEST_CASE("ThreatCostCalculator: reprocessing-only item threat", "[threat]")
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));
// scrapThreat = 1.0 (= 1 / scrap_per_threat), so (1.0 * 5 + 3.0) / 0.1 = 80.0
CHECK(table.itemThreat.at("advanced_alloy") == Approx(80.0));
}
TEST_CASE("ThreatCostCalculator: ship threat with default modules", "[threat]")