Derive ship scrap drop from threat
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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]")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]")
|
||||
|
||||
Reference in New Issue
Block a user