cargo component refactoring

This commit is contained in:
2026-06-21 18:35:02 +02:00
parent 665060bcd2
commit a472ec196c
17 changed files with 188 additions and 114 deletions

View File

@@ -546,8 +546,8 @@ static const StatEntry kKnownStats[] = {
{"weapon", "attack_range", "_m"},
{"weapon", "attack_rate", "_hz"},
{"salvage", "collection_range", "_m"},
{"salvage", "cargo_capacity", ""},
{"salvage", "collection_rate", "_hz"},
{"cargo", "cargo_capacity", ""},
{"repair", "repair_rate", "_hz"},
{"repair", "repair_range", "_m"},
};

View File

@@ -4,6 +4,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/AttackBehavior.h
${CMAKE_CURRENT_SOURCE_DIR}/BehaviorKind.h
${CMAKE_CURRENT_SOURCE_DIR}/BehaviorScores.h
${CMAKE_CURRENT_SOURCE_DIR}/CargoComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/DeliverScrapBehavior.h
${CMAKE_CURRENT_SOURCE_DIR}/DespawnAtComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodyComponent.h
@@ -17,7 +18,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/RepairBehavior.h
${CMAKE_CURRENT_SOURCE_DIR}/RepairToolComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/RetreatBehavior.h
${CMAKE_CURRENT_SOURCE_DIR}/SalvageCargoComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/SalvagerComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/SalvageScrapBehavior.h
${CMAKE_CURRENT_SOURCE_DIR}/ScrapDataComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBehaviorComponent.h

View File

@@ -0,0 +1,10 @@
#pragma once
// Single shared salvage cargo pool for a ship (REQ-MOD-CARGO-CAPACITY). Attached
// to a ship at spawn only when its cargo capacity stat is greater than 0. All of
// the ship's salvage modules deposit into this one pool; delivery draws from it.
struct CargoComponent
{
int maxCapacity;
int current;
};

View File

@@ -1,10 +0,0 @@
#pragma once
struct SalvageCargoComponent
{
int capacity;
int current;
float collectionRange_tiles;
int collectionIntervalTicks;
int cooldownTicksRemaining;
};

View File

@@ -0,0 +1,11 @@
#pragma once
// Per-instance salvage collection emitter. Each placed salvage module owns one of
// these and runs its own collection cycle on its own cooldown. The collected scrap
// is stored in the owning ship's shared CargoComponent (REQ-SHP-SALVAGE), not here.
struct SalvagerComponent
{
float collectionRange_tiles;
int collectionIntervalTicks;
int cooldownTicksRemaining;
};

View File

@@ -6,13 +6,14 @@
#include "Building.h"
#include "BuildingSystem.h"
#include "CargoComponent.h"
#include "DeliverScrapBehavior.h"
#include "EntityAdmin.h"
#include <map>
#include "ModuleOwnerComponent.h"
#include "PositionComponent.h"
#include "SalvageCargoComponent.h"
#include "SalvagerComponent.h"
#include "ScrapDataComponent.h"
#include "ScrapSystem.h"
#include "tracing.h"
@@ -32,44 +33,53 @@ void SalvagerSystem::tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem&
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
// Tick down per-module collection cooldowns.
m_admin.forEach<SalvageCargoComponent>(
[](entt::entity /*e*/, SalvageCargoComponent& c)
m_admin.forEach<SalvagerComponent>(
[](entt::entity /*e*/, SalvagerComponent& s)
{
if (c.cooldownTicksRemaining > 0) { --c.cooldownTicksRemaining; }
if (s.cooldownTicksRemaining > 0) { --s.cooldownTicksRemaining; }
});
// Scrap units already claimed by not-yet-applied collection cycles, so two
// modules don't both target the last unit of the same pile (the claim would be
// dropped at apply time). A pile is available while its amount exceeds its claims.
std::map<entt::entity, int> claimedUnits;
// Collection cycles already in flight toward each ship's shared cargo pool, so
// concurrent modules on the same ship never start more cycles than the remaining
// capacity can hold (REQ-SHP-SALVAGE).
std::map<entt::entity, int> pendingByShip;
for (const PendingCollection& pc : m_pendingCollections)
{
++claimedUnits[pc.scrap];
++pendingByShip[pc.ship];
}
// Cycle start: each ready, in-range module with free cargo begins a collection
// cycle — emit the beam now, collect one scrap mid-beam, start the cooldown now.
m_admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity moduleEntity, SalvageCargoComponent& c, const ModuleOwnerComponent& o)
// Cycle start: each ready, in-range module whose ship's pool has free space begins
// a collection cycle — emit the beam now, collect one scrap mid-beam, start the
// cooldown now.
m_admin.forEach<SalvagerComponent, ModuleOwnerComponent>(
[&](entt::entity /*moduleEntity*/, SalvagerComponent& s, const ModuleOwnerComponent& o)
{
if (c.current >= c.capacity || c.cooldownTicksRemaining > 0) { return; }
if (c.collectionIntervalTicks <= 0) { return; }
if (!m_admin.hasAll<PositionComponent>(o.owner)) { return; }
if (s.cooldownTicksRemaining > 0 || s.collectionIntervalTicks <= 0) { return; }
if (!m_admin.hasAll<PositionComponent, CargoComponent>(o.owner)) { return; }
const CargoComponent& cargo = m_admin.get<CargoComponent>(o.owner);
if (cargo.current + pendingByShip[o.owner] >= cargo.maxCapacity) { return; }
const QVector2D ownerPos = m_admin.get<PositionComponent>(o.owner).value;
for (const ScrapInfo& si : allScrap)
{
if ((si.position - ownerPos).length() > c.collectionRange_tiles) { continue; }
if ((si.position - ownerPos).length() > s.collectionRange_tiles) { continue; }
if (claimedUnits[si.entity] >= m_admin.get<ScrapDataComponent>(si.entity).amount)
{
continue; // every remaining unit of this pile is already spoken for
}
outBeamFiredEvents.push_back(
BeamFiredEvent{BeamKind::Salvage, o.owner, si.entity, currentTick});
m_pendingCollections.push_back({moduleEntity, si.entity,
m_pendingCollections.push_back({o.owner, si.entity,
currentTick + kBeamImpactDelayTicks});
++claimedUnits[si.entity];
c.cooldownTicksRemaining = c.collectionIntervalTicks;
++pendingByShip[o.owner];
s.cooldownTicksRemaining = s.collectionIntervalTicks;
break;
}
});
@@ -86,18 +96,14 @@ void SalvagerSystem::tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem&
bay->anchor.y() + bay->footprint.height() / 2.0f);
if ((pos.value - bayCenter).length() > 1.0f) { return; }
// Decrement the first non-empty salvage child belonging to this ship.
bool delivered = false;
m_admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity /*ce*/, SalvageCargoComponent& c, const ModuleOwnerComponent& o)
{
if (delivered || o.owner != ship || c.current <= 0) { return; }
if (buildings.deliverScrapToSalvageBay(deliver.deliveryBay))
{
--c.current;
delivered = true;
}
});
// Hand over one unit from the ship's shared cargo pool.
if (!m_admin.hasAll<CargoComponent>(ship)) { return; }
CargoComponent& cargo = m_admin.get<CargoComponent>(ship);
if (cargo.current <= 0) { return; }
if (buildings.deliverScrapToSalvageBay(deliver.deliveryBay))
{
--cargo.current;
}
});
}
@@ -108,12 +114,12 @@ void SalvagerSystem::applyPendingCollections(Tick currentTick, ScrapSystem& scra
{
if (it->appliesAt <= currentTick)
{
if (m_admin.isValid(it->module) && m_admin.hasAll<SalvageCargoComponent>(it->module))
if (m_admin.isValid(it->ship) && m_admin.hasAll<CargoComponent>(it->ship))
{
SalvageCargoComponent& c = m_admin.get<SalvageCargoComponent>(it->module);
if (c.current < c.capacity && scraps.collectOne(it->scrap))
CargoComponent& cargo = m_admin.get<CargoComponent>(it->ship);
if (cargo.current < cargo.maxCapacity && scraps.collectOne(it->scrap))
{
++c.current;
++cargo.current;
}
}
it = m_pendingCollections.erase(it);

View File

@@ -27,7 +27,7 @@ public:
private:
struct PendingCollection
{
entt::entity module;
entt::entity ship;
entt::entity scrap;
Tick appliesAt;
};

View File

@@ -9,6 +9,7 @@
#include "AdvanceBehavior.h"
#include "AttackBehavior.h"
#include "BehaviorScores.h"
#include "CargoComponent.h"
#include "DeliverScrapBehavior.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
@@ -21,8 +22,8 @@
#include "RepairBehavior.h"
#include "RepairToolComponent.h"
#include "RetreatBehavior.h"
#include "SalvageCargoComponent.h"
#include "SalvageScrapBehavior.h"
#include "SalvagerComponent.h"
#include "SelectedBehaviorComponent.h"
#include "SensorRangeComponent.h"
#include "StandbyBehavior.h"
@@ -107,6 +108,10 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
std::vector<entt::entity> salvageChildren;
std::vector<entt::entity> repairChildren;
// Cargo capacity is a ship-level stat (REQ-MOD-CARGO-CAPACITY): its base is the
// sum of every cargo-providing module's contribution, accumulated here.
double cargoCapacityBase = 0.0;
for (const PlacedModule& pm : modules)
{
const ModuleDef* modDef = findModuleDef(pm.moduleId);
@@ -136,20 +141,19 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
if (modDef->salvageCapability)
{
SalvageCargoComponent cargo;
cargo.capacity = static_cast<int>(
modDef->salvageCapability->cargoCapacityFormula.evaluate(mx));
cargo.current = 0;
cargo.collectionRange_tiles = static_cast<float>(
cargoCapacityBase += modDef->salvageCapability->cargoCapacityFormula.evaluate(mx);
SalvagerComponent salvager;
salvager.collectionRange_tiles = static_cast<float>(
modDef->salvageCapability->collectionRangeFormula.evaluate(mx)) / tileSize;
const double rate = modDef->salvageCapability->collectionRateFormula.evaluate(mx);
cargo.collectionIntervalTicks = (rate > 0.0)
salvager.collectionIntervalTicks = (rate > 0.0)
? static_cast<int>(kTickRateHz / rate + 0.5)
: 0;
cargo.cooldownTicksRemaining = 0;
salvager.cooldownTicksRemaining = 0;
entt::entity child = m_admin.createModuleEntity();
m_admin.addComponent<SalvageCargoComponent>(child, cargo);
m_admin.addComponent<SalvagerComponent>(child, salvager);
m_admin.addComponent<ModuleOwnerComponent>(child, ModuleOwnerComponent{entity});
salvageChildren.push_back(child);
}
@@ -184,6 +188,8 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
std::map<std::string, std::pair<double, double>> weaponMods;
std::map<std::string, std::pair<double, double>> salvageMods;
std::map<std::string, std::pair<double, double>> repairMods;
// Ship-level cargo capacity modifiers ([module.cargo]); applied to the pool.
std::map<std::string, std::pair<double, double>> cargoMods;
for (const PlacedModule& pm : modules)
{
@@ -204,15 +210,16 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
const bool isWeaponStat = (sm.stat == "damage"
|| sm.stat == "attack_range"
|| sm.stat == "attack_rate");
const bool isSalvageStat = (sm.stat == "collection_range"
|| sm.stat == "cargo_capacity");
const bool isSalvageStat = (sm.stat == "collection_range");
const bool isRepairStat = (sm.stat == "repair_rate"
|| sm.stat == "repair_range");
const bool isCargoStat = (sm.stat == "cargo_capacity");
std::map<std::string, std::pair<double, double>>* target = &hullMods;
if (isWeaponStat) { target = &weaponMods; }
if (isSalvageStat) { target = &salvageMods; }
if (isRepairStat) { target = &repairMods; }
if (isCargoStat) { target = &cargoMods; }
std::pair<double, double>& acc = (*target)[sm.stat];
if (sm.modifierType == "multiplicative")
@@ -305,23 +312,33 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
// Apply salvage modifiers to each salvage child.
for (entt::entity child : salvageChildren)
{
SalvageCargoComponent& c = m_admin.get<SalvageCargoComponent>(child);
float fRange = c.collectionRange_tiles;
float fCapacity = static_cast<float>(c.capacity);
SalvagerComponent& c = m_admin.get<SalvagerComponent>(child);
float fRange = c.collectionRange_tiles;
// Apply rate modifier: compute rate from interval, apply multiplier, convert back.
float fRate = (c.collectionIntervalTicks > 0)
? static_cast<float>(kTickRateHz) / static_cast<float>(c.collectionIntervalTicks)
: 0.0f;
applyMod(fRange, "collection_range", salvageMods);
applyMod(fCapacity, "cargo_capacity", salvageMods);
applyMod(fRate, "collection_rate", salvageMods);
applyMod(fRange, "collection_range", salvageMods);
applyMod(fRate, "collection_rate", salvageMods);
c.collectionRange_tiles = fRange;
c.capacity = static_cast<int>(fCapacity + 0.5f);
c.collectionIntervalTicks = (fRate > 0.0f)
? static_cast<int>(static_cast<float>(kTickRateHz) / fRate + 0.5f)
: 0;
}
// Cargo capacity is a ship-level stat: apply [module.cargo] modifiers to the
// summed base, then attach the shared cargo pool when the ship can hold anything
// (REQ-MOD-CARGO-CAPACITY).
{
float fCapacity = static_cast<float>(cargoCapacityBase);
applyMod(fCapacity, "cargo_capacity", cargoMods);
const int maxCapacity = static_cast<int>(fCapacity + 0.5f);
if (maxCapacity > 0)
{
m_admin.addComponent<CargoComponent>(entity, CargoComponent{maxCapacity, 0});
}
}
// Apply repair modifiers to each repair child.
for (entt::entity child : repairChildren)
{
@@ -383,7 +400,7 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
float maxCollRange = 0.0f;
for (entt::entity child : salvageChildren)
{
const float r = m_admin.get<SalvageCargoComponent>(child).collectionRange_tiles;
const float r = m_admin.get<SalvagerComponent>(child).collectionRange_tiles;
if (r > maxCollRange) { maxCollRange = r; }
}

View File

@@ -1,12 +1,11 @@
#include "BehaviorTargeting.h"
#include "CargoComponent.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "ModuleOwnerComponent.h"
#include "PositionComponent.h"
#include "SalvageCargoComponent.h"
#include "ShipIdentityComponent.h"
#include "StationBodyComponent.h"
@@ -72,13 +71,10 @@ std::vector<CombatantInfo> buildCombatants(EntityAdmin& admin)
std::unordered_map<entt::entity, CargoState> buildCargoByShip(EntityAdmin& admin)
{
std::unordered_map<entt::entity, CargoState> cargoByShip;
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&cargoByShip](entt::entity /*ce*/, const SalvageCargoComponent& c,
const ModuleOwnerComponent& o)
admin.forEach<CargoComponent>(
[&cargoByShip](entt::entity ship, const CargoComponent& c)
{
CargoState& agg = cargoByShip[o.owner];
agg.current += c.current;
agg.capacity += c.capacity;
cargoByShip[ship] = CargoState{c.current, c.maxCapacity};
});
return cargoByShip;
}

View File

@@ -42,7 +42,7 @@ std::vector<RepairableInfo> buildRepairables(EntityAdmin& admin);
// All ships, stations, and the HQ proxy — candidates for attack targeting.
std::vector<CombatantInfo> buildCombatants(EntityAdmin& admin);
// Aggregated salvage cargo per owning ship, summed across its salvage modules.
// Salvage cargo pool per ship, read from each ship's shared CargoComponent.
std::unordered_map<entt::entity, CargoState> buildCargoByShip(EntityAdmin& admin);
// True when the ship's aggregated cargo is at capacity (and it has any capacity).

View File

@@ -4,12 +4,13 @@
#include <stdexcept>
#include <utility>
#include "CargoComponent.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "RepairToolComponent.h"
#include "SalvageCargoComponent.h"
#include "SalvagerComponent.h"
#include "SensorRangeComponent.h"
#include "Tick.h"
#include "WeaponComponent.h"
@@ -66,6 +67,10 @@ ShipStats calculateShipStats(const GameConfig& config,
std::vector<SalvageInstance> salvageInstances;
std::vector<RepairInstance> repairInstances;
// Cargo capacity is a ship-level stat (REQ-MOD-CARGO-CAPACITY): base is the sum
// of every salvage module's contribution.
double cargoCapacityBase = 0.0;
for (const PlacedModule& pm : modules)
{
const ModuleDef* def = findModuleDef(pm.moduleId);
@@ -85,6 +90,8 @@ ShipStats calculateShipStats(const GameConfig& config,
}
if (def->salvageCapability)
{
cargoCapacityBase += def->salvageCapability->cargoCapacityFormula.evaluate(mx);
SalvageInstance si;
si.range_tiles = static_cast<float>(def->salvageCapability->collectionRangeFormula.evaluate(mx) / tileSize);
si.rate = static_cast<float>(def->salvageCapability->collectionRateFormula.evaluate(mx));
@@ -106,6 +113,7 @@ ShipStats calculateShipStats(const GameConfig& config,
std::map<std::string, std::pair<double, double>> weaponMods;
std::map<std::string, std::pair<double, double>> salvageMods;
std::map<std::string, std::pair<double, double>> repairMods;
std::map<std::string, std::pair<double, double>> cargoMods;
for (const PlacedModule& pm : modules)
{
@@ -123,15 +131,16 @@ ShipStats calculateShipStats(const GameConfig& config,
const bool isWeaponStat = (sm.stat == "damage"
|| sm.stat == "attack_range"
|| sm.stat == "attack_rate");
const bool isSalvageStat = (sm.stat == "collection_range"
|| sm.stat == "cargo_capacity");
const bool isSalvageStat = (sm.stat == "collection_range");
const bool isRepairStat = (sm.stat == "repair_rate"
|| sm.stat == "repair_range");
const bool isCargoStat = (sm.stat == "cargo_capacity");
std::map<std::string, std::pair<double, double>>* target = &hullMods;
if (isWeaponStat) { target = &weaponMods; }
if (isSalvageStat) { target = &salvageMods; }
if (isRepairStat) { target = &repairMods; }
if (isCargoStat) { target = &cargoMods; }
std::pair<double, double>& acc = (*target)[sm.stat];
if (sm.modifierType == "multiplicative")
@@ -232,6 +241,14 @@ ShipStats calculateShipStats(const GameConfig& config,
result.salvage = ShipStats::SalvageStats{combinedRate, maxRange};
}
// Cargo capacity is a ship-level stat: apply [module.cargo] modifiers to the
// summed base (REQ-MOD-CARGO-CAPACITY).
{
float capacity = static_cast<float>(cargoCapacityBase);
applyMod(capacity, "cargo_capacity", cargoMods);
result.cargoCapacity = static_cast<int>(capacity + 0.5f);
}
// Apply repair modifiers and compute combined stats.
if (!repairInstances.empty())
{
@@ -287,8 +304,8 @@ ShipStats buildShipStatsFromEntity(const EntityAdmin& admin, entt::entity shipEn
if (w.range_tiles > weaponMaxRange) { weaponMaxRange = w.range_tiles; }
});
admin.forEach<ModuleOwnerComponent, SalvageCargoComponent>(
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const SalvageCargoComponent& s)
admin.forEach<ModuleOwnerComponent, SalvagerComponent>(
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const SalvagerComponent& s)
{
if (owner.owner != shipEntity) { return; }
hasSalvage = true;
@@ -299,6 +316,12 @@ ShipStats buildShipStatsFromEntity(const EntityAdmin& admin, entt::entity shipEn
if (s.collectionRange_tiles > salvageMaxRange) { salvageMaxRange = s.collectionRange_tiles; }
});
admin.forEach<CargoComponent>(
[&](entt::entity ship, const CargoComponent& c)
{
if (ship == shipEntity) { result.cargoCapacity = c.maxCapacity; }
});
admin.forEach<ModuleOwnerComponent, RepairToolComponent>(
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const RepairToolComponent& r)
{

View File

@@ -24,6 +24,7 @@ struct ShipStats
float maneuveringAcceleration_tpss;
float angularAcceleration_radpss;
float maxRotationSpeed_radps;
int cargoCapacity = 0;
struct WeaponStats
{

View File

@@ -33,9 +33,10 @@
#include "RepairSystem.h"
#include "RepairToolComponent.h"
#include "RetreatBehavior.h"
#include "CargoComponent.h"
#include "Rotation.h"
#include "SalvageCargoComponent.h"
#include "SalvageScrapBehavior.h"
#include "SalvagerComponent.h"
#include "SalvagerSystem.h"
#include "ScrapSystem.h"
#include "SelectedBehaviorComponent.h"
@@ -179,8 +180,8 @@ static ShipLayoutConfig makeTwoModuleLayout(const std::string& moduleId)
static entt::entity firstSalvageChild(EntityAdmin& admin, entt::entity ship)
{
entt::entity result = entt::null;
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity ce, const SalvageCargoComponent&, const ModuleOwnerComponent& o)
admin.forEach<SalvagerComponent, ModuleOwnerComponent>(
[&](entt::entity ce, const SalvagerComponent&, const ModuleOwnerComponent& o)
{
if (o.owner == ship && result == entt::null) { result = ce; }
});
@@ -944,7 +945,7 @@ TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]"
const entt::entity sc = firstSalvageChild(f.admin, ship);
REQUIRE(f.admin.isValid(sc));
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 1);
REQUIRE(f.admin.get<CargoComponent>(ship).current == 1);
REQUIRE_FALSE(f.admin.isValid(scrapEntity));
}
@@ -969,10 +970,9 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f),
false, salvageLayout);
{
const entt::entity sc = firstSalvageChild(f.admin, ship);
REQUIRE(f.admin.isValid(sc));
SalvageCargoComponent& cargo = f.admin.get<SalvageCargoComponent>(sc);
cargo.current = cargo.capacity; // full cargo
REQUIRE(f.admin.isValid(firstSalvageChild(f.admin, ship)));
CargoComponent& cargo = f.admin.get<CargoComponent>(ship);
cargo.current = cargo.maxCapacity; // full cargo
}
f.decide();
@@ -990,13 +990,9 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
static int totalSalvageCurrent(EntityAdmin& admin, entt::entity ship)
{
int total = 0;
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity /*ce*/, const SalvageCargoComponent& c, const ModuleOwnerComponent& o)
{
if (o.owner == ship) { total += c.current; }
});
return total;
return admin.hasAll<CargoComponent>(ship)
? admin.get<CargoComponent>(ship).current
: 0;
}
TEST_CASE("SalvagerSystem: module does not collect scrap beyond its collection range",
@@ -1011,7 +1007,7 @@ TEST_CASE("SalvagerSystem: module does not collect scrap beyond its collection r
f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
REQUIRE(f.admin.get<CargoComponent>(ship).current == 0);
}
TEST_CASE("SalvagerSystem: module collects scrap within its collection range",
@@ -1026,7 +1022,7 @@ TEST_CASE("SalvagerSystem: module collects scrap within its collection range",
f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 1);
REQUIRE(f.admin.get<CargoComponent>(ship).current == 1);
}
// ---------------------------------------------------------------------------
@@ -1045,10 +1041,10 @@ TEST_CASE("SalvagerSystem: collection sets cooldown on module", "[behavior]")
// collected until mid-beam (REQ-SHP-SALVAGE), so cargo is still empty now.
f.salvageTick();
const SalvageCargoComponent& cargo =
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship));
REQUIRE(cargo.current == 0);
REQUIRE(cargo.cooldownTicksRemaining == cargo.collectionIntervalTicks);
REQUIRE(f.admin.get<CargoComponent>(ship).current == 0);
const SalvagerComponent& salvager =
f.admin.get<SalvagerComponent>(firstSalvageChild(f.admin, ship));
REQUIRE(salvager.cooldownTicksRemaining == salvager.collectionIntervalTicks);
}
TEST_CASE("SalvagerSystem: module on cooldown does not collect scrap", "[behavior]")
@@ -1059,11 +1055,11 @@ TEST_CASE("SalvagerSystem: module on cooldown does not collect scrap", "[behavio
false, salvageLayout);
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).cooldownTicksRemaining = 10;
f.admin.get<SalvagerComponent>(firstSalvageChild(f.admin, ship)).cooldownTicksRemaining = 10;
f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
REQUIRE(f.admin.get<CargoComponent>(ship).current == 0);
}
TEST_CASE("SalvagerSystem: module collects again after cooldown expires", "[behavior]")
@@ -1076,17 +1072,17 @@ TEST_CASE("SalvagerSystem: module collects again after cooldown expires", "[beha
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 1);
REQUIRE(f.admin.get<CargoComponent>(ship).current == 1);
// Shorten cooldown to 1 tick and place a second scrap.
f.admin.get<SalvageCargoComponent>(sc).cooldownTicksRemaining = 1;
f.admin.get<SalvagerComponent>(sc).cooldownTicksRemaining = 1;
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
// Once the cooldown expires the module starts another cycle and collects the
// second scrap after the mid-beam delay.
f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 2);
REQUIRE(f.admin.get<CargoComponent>(ship).current == 2);
}
// ---------------------------------------------------------------------------
@@ -1119,12 +1115,12 @@ TEST_CASE("SalvagerSystem: second salvage module does not collect when first is
// Put the first salvage child on cooldown.
entt::entity blocked = entt::null;
f.admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity ce, SalvageCargoComponent& c, const ModuleOwnerComponent& o)
f.admin.forEach<SalvagerComponent, ModuleOwnerComponent>(
[&](entt::entity ce, SalvagerComponent& s, const ModuleOwnerComponent& o)
{
if (o.owner == ship && blocked == entt::null)
{
c.cooldownTicksRemaining = 99;
s.cooldownTicksRemaining = 99;
blocked = ce;
}
});

View File

@@ -19,9 +19,10 @@
#include "RepairBehavior.h"
#include "RepairToolComponent.h"
#include "RetreatBehavior.h"
#include "CargoComponent.h"
#include "Rotation.h"
#include "SalvageCargoComponent.h"
#include "SalvageScrapBehavior.h"
#include "SalvagerComponent.h"
#include "SelectedBehaviorComponent.h"
#include "SensorRangeComponent.h"
#include "ShipLayout.h"
@@ -52,8 +53,8 @@ static entt::entity firstWeaponChild(EntityAdmin& admin, entt::entity ship)
static entt::entity firstSalvageChild(EntityAdmin& admin, entt::entity ship)
{
entt::entity result = entt::null;
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity ce, const SalvageCargoComponent&, const ModuleOwnerComponent& o)
admin.forEach<SalvagerComponent, ModuleOwnerComponent>(
[&](entt::entity ce, const SalvagerComponent&, const ModuleOwnerComponent& o)
{
if (o.owner == ship && result == entt::null) { result = ce; }
});
@@ -220,8 +221,9 @@ TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
// salvager: cargo_capacity_formula = "10", collection_range_m_formula = "500" m → 500/10 = 50 tiles
const entt::entity sc = firstSalvageChild(admin, e);
REQUIRE(admin.isValid(sc));
REQUIRE(admin.get<SalvageCargoComponent>(sc).capacity == 10);
REQUIRE(admin.get<SalvageCargoComponent>(sc).current == 0);
// Cargo capacity is now a ship-level pool (REQ-MOD-CARGO-CAPACITY).
REQUIRE(admin.get<CargoComponent>(e).maxCapacity == 10);
REQUIRE(admin.get<CargoComponent>(e).current == 0);
REQUIRE(admin.get<DeliverScrapBehavior>(e).deliveryBay == kInvalidBuildingId);
REQUIRE_FALSE(admin.get<SalvageScrapBehavior>(e).scrapTarget.has_value());
REQUIRE(admin.get<SalvageScrapBehavior>(e).maxCollectionRange_tiles == Approx(50.0f));

View File

@@ -50,6 +50,8 @@ ShipStatsPanel::ShipStatsPanel(const GameConfig* config, QWidget* parent)
m_maneuveringAccelLabel = makeStatLabel(this);
m_angularAccelLabel = makeStatLabel(this);
m_maxRotSpeedLabel = makeStatLabel(this);
m_cargoCapacityLabel = makeStatLabel(this);
m_cargoCapacityLabel->setVisible(false);
layout->addWidget(m_hpLabel);
layout->addWidget(m_speedLabel);
@@ -58,6 +60,7 @@ ShipStatsPanel::ShipStatsPanel(const GameConfig* config, QWidget* parent)
layout->addWidget(m_maneuveringAccelLabel);
layout->addWidget(m_angularAccelLabel);
layout->addWidget(m_maxRotSpeedLabel);
layout->addWidget(m_cargoCapacityLabel);
// Weapon capability section.
m_weaponSection = new QWidget(this);
@@ -151,6 +154,19 @@ void ShipStatsPanel::applyStats(const ShipStats& stats, const QString& hpText)
m_maxRotSpeedLabel->setText(
tr("Max Rotation: %1 rad/s").arg(fmt(stats.maxRotationSpeed_radps)));
// Cargo capacity is shown only when the ship can actually hold cargo
// (REQ-MOD-UI-STATS-PANEL).
if (stats.cargoCapacity > 0)
{
m_cargoCapacityLabel->setText(
tr("Cargo Capacity: %1").arg(stats.cargoCapacity));
m_cargoCapacityLabel->setVisible(true);
}
else
{
m_cargoCapacityLabel->setVisible(false);
}
if (stats.weapons.has_value())
{
m_weaponDpsLabel->setText(

View File

@@ -42,6 +42,7 @@ private:
QLabel* m_maneuveringAccelLabel;
QLabel* m_angularAccelLabel;
QLabel* m_maxRotSpeedLabel;
QLabel* m_cargoCapacityLabel;
QWidget* m_weaponSection;
QLabel* m_weaponDpsLabel;