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

@@ -168,9 +168,9 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- Stance: aggressive (advance toward enemies) / defensive (hold position near asteroid).
- Target priority: closest / highest HP / structures first.
- REQ-SHP-RALLY: After spawning, aggressive-stance ships with weapon modules move to and orbit the **rally point** — the midpoint between the two player defence stations (center of their Y-span, at the player defence stations' X position) — at the rally orbit radius (REQ-SHP-ORBIT). While orbiting the rally point, ships still engage any enemy that enters sensor range (switching to the combat orbit per REQ-SHP-COMBAT). Every `world.toml [world].departure_interval_seconds` seconds (default 20), all ships with weapon modules currently at the rally point depart simultaneously and begin their normal aggressive advance toward the enemy. The departure timer is global and shared across all shipyards; it is not reset by individual ship arrivals at the rally point.
- REQ-SHP-SALVAGE: Ships with at least one **salvage module** (player) — patrol by moving forward (rightward, away from the asteroid) while searching sensor range. If scrap enters sensor range, navigate toward it by orbiting it at the salvage orbit radius (REQ-SHP-ORBIT); when it is within a module's `collection_range`, that module begins collecting from it, one scrap per cycle (see below). Once all cargo is full, fly to a Salvage Bay and deliver (a direct approach, not an orbit — the ship must reach the bay); after delivery, resume patrol. If an enemy ship enters sensor range, the ship retreats (REQ-SHP-RETREAT) until no enemy is in sensor range, then resumes patrol — this applies regardless of whether the ship is targeting or carrying scrap. Ships with salvage modules are vulnerable to enemy ships while operating.
- REQ-SHP-SALVAGE: Ships with at least one **salvage module** (player) — patrol by moving forward (rightward, away from the asteroid) while searching sensor range. If scrap enters sensor range, navigate toward it by orbiting it at the salvage orbit radius (REQ-SHP-ORBIT); when it is within a module's `collection_range`, that module begins collecting from it, one scrap per cycle (see below). Once the ship's cargo pool is full, fly to a Salvage Bay and deliver (a direct approach, not an orbit — the ship must reach the bay); after delivery, resume patrol. If an enemy ship enters sensor range, the ship retreats (REQ-SHP-RETREAT) until no enemy is in sensor range, then resumes patrol — this applies regardless of whether the ship is targeting or carrying scrap. Ships with salvage modules are vulnerable to enemy ships while operating.
Each salvage module instance operates independently: it has its own cargo hold (`cargo_capacity`), collection range (`collection_range`), and collection rate (`collection_rate`, in collection cycles per second). A module starts a collection cycle when it is off cooldown, has free cargo space, and a scrap pile is within its `collection_range`. Starting a cycle emits a collection beam toward that scrap pile (REQ-SHP-FIRING-BEAM) and begins a 0.15-second effect delay (half the beam duration); the module's cooldown of `1 / collection_rate` seconds begins at cycle start, not at effect application. When the delay expires, exactly 1 scrap is removed from the targeted pile and added to the module's cargo — unless the pile has already been fully depleted or despawned, or the cargo is now full, in which case the collection is silently dropped. A scrap pile worth more than 1 (REQ-RES-SCRAP-DROP) is depleted one scrap per cycle and persists, with its remaining amount decremented, until it is fully collected or despawns. A ship with multiple salvage modules can therefore run multiple collection cycles concurrently (one per ready module), and instances of different module types may have different ranges and rates. The ship navigates based on the maximum collection range across all installed salvage modules.
All salvage modules on a ship deposit into a single shared **cargo pool** whose size is the ship's cargo capacity stat (REQ-MOD-CARGO-CAPACITY). Each salvage module instance still runs its own collection cycle independently, with its own collection range (`collection_range`) and collection rate (`collection_rate`, in collection cycles per second). A module starts a collection cycle when it is off cooldown, the shared cargo pool has free space, and a scrap pile is within its `collection_range`. Free space is measured against the pool's current contents **plus the collection cycles already in flight toward the pool** (scrap claimed by cycles whose effect delay has not yet elapsed); each in-flight cycle is registered against the ship so that concurrent modules on the same ship never start more cycles than the remaining capacity can hold. Starting a cycle emits a collection beam toward that scrap pile (REQ-SHP-FIRING-BEAM) and begins a 0.15-second effect delay (half the beam duration); the module's cooldown of `1 / collection_rate` seconds begins at cycle start, not at effect application. When the delay expires, exactly 1 scrap is removed from the targeted pile and added to the ship's cargo pool — unless the pile has already been fully depleted or despawned, or the pool is now full, in which case the collection is silently dropped. A scrap pile worth more than 1 (REQ-RES-SCRAP-DROP) is depleted one scrap per cycle and persists, with its remaining amount decremented, until it is fully collected or despawns. A ship with multiple salvage modules can therefore run multiple collection cycles concurrently (one per ready module), and instances of different module types may have different ranges and rates. The ship navigates based on the maximum collection range across all installed salvage modules.
Salvage collection cycles and delivery are processed regardless of which behavior the ship is currently executing; the salvage behavior only governs where the ship navigates (toward scrap, toward a Salvage Bay, or — when retreating — toward the rally point).
- REQ-SHP-REPAIR: Ships with at least one **repair module** (player) — when no more urgent behavior applies, hold with the fleet (REQ-SHP-STANDBY) rather than charging the enemy, so damaged allies stay within sensor range. If a damaged player defence station or player ship enters sensor range, navigate toward it by orbiting it at the repair orbit radius (REQ-SHP-ORBIT) and repair. If an enemy ship enters sensor range, the ship retreats (REQ-SHP-RETREAT) until no enemy is in sensor range — except that it holds its ground and keeps repairing while a damaged friendly remains within sensor range (REQ-SHP-RETREAT), retreating only once there is nothing left to repair — then resumes patrol. The player can configure the target priority per shipyard:
@@ -198,9 +198,9 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- `production_time_seconds` — time added to the ship's production cycle per instance.
- `fill_color` — fill color used to render this module's cells in the layout grid.
- `glyph` — single character rendered on this module's cells in the layout grid and preview widget.
- An optional **capability section** (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas. A module with base stat formulas is a capability module — each placed instance grants the ship an independent weapon, salvage bay, or repair tool with its own state (cooldown, target, cargo). A ship may have multiple capability module instances of the same or different types. Base stat formulas per capability type:
- An optional **capability section** (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas. A module with base stat formulas is a capability module — each placed instance grants the ship an independent weapon, salvage bay, or repair tool with its own state (cooldown, target). A ship may have multiple capability module instances of the same or different types. Base stat formulas per capability type:
- **Weapon** (`[module.weapon]`): `damage_formula`, `attack_range_formula`, `attack_rate_formula`.
- **Salvage** (`[module.salvage]`): `collection_range_formula` (tiles), `cargo_capacity_formula` (integer scrap units), `collection_rate_formula` (collection cycles per second; each cycle collects 1 scrap).
- **Salvage** (`[module.salvage]`): `collection_range_formula` (tiles), `cargo_capacity_formula` (integer scrap units; contributes to the ship's cargo capacity stat per REQ-MOD-CARGO-CAPACITY), `collection_rate_formula` (collection cycles per second; each cycle collects 1 scrap).
- **Repair** (`[module.repair]`): `repair_rate_formula` (repair cycles per second), `repair_amount_hp_formula` (HP restored per repair cycle), `repair_range_formula` (tiles).
- Zero or more **passive stat modifier formulas** (`added_*`/`multiplied_*`) that boost stats on the ship hull or on capability module instances (see REQ-MOD-STAT-CALC). A single module may be both a capability module and provide passive modifiers.
@@ -239,9 +239,12 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- `[module.weapon]` — modifiers apply to every weapon module instance on the ship.
- `[module.salvage]` — modifiers apply to every salvage module instance on the ship.
- `[module.repair]` — modifiers apply to every repair module instance on the ship.
- `[module.cargo]` — modifiers (`added_cargo_capacity`/`multiplied_cargo_capacity`) apply to the ship's **cargo capacity**, a ship-level stat (see REQ-MOD-CARGO-CAPACITY).
Example: `[module.sensor].added_sensor_range_formula` adds to the ship's sensor range. `[module.weapon].multiplied_damage_formula` multiplies the damage of every weapon module instance on the ship.
- REQ-MOD-CARGO-CAPACITY: **Cargo capacity** is a first-class ship stat — the total number of scrap units the ship can hold in the single shared cargo pool used by its salvage modules (REQ-SHP-SALVAGE). Unlike other ship stats it has no hull base formula; its `base` (per REQ-MOD-STAT-CALC) is the **sum** of the `cargo_capacity` base values of every cargo-providing capability module instance on the ship — currently each salvage module's `cargo_capacity_formula`, evaluated at that module instance's `player_production_level`. Passive modifiers targeting `cargo_capacity` are declared under the `[module.cargo]` category and apply to this ship-level sum (`final = base × total_multiplier + total_additive`); they are not salvage-category modifiers and therefore scale the whole pool rather than any single instance. A ship whose cargo capacity is 0 (no cargo-providing module) is given no cargo pool.
### Module UI
- REQ-MOD-UI-PREVIEW: When a schematic is selected in a shipyard's selected building panel, a small non-interactive **ship layout preview** widget is shown below the schematic dropdown. The preview renders the ship's layout grid at a reduced scale: buildable cells without a module are shown as white, non-buildable cells are shown as black, and cells occupied by a module are shown in that module's `fill_color` with the module's `glyph` character. Below the preview, a "Configure" button is shown.
@@ -264,6 +267,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- Maneuvering acceleration
- Angular acceleration
- Max rotation speed
- Cargo capacity — shown only when the ship's cargo capacity (REQ-MOD-CARGO-CAPACITY) is greater than 0
In addition, the panel shows capability module stats conditioned on which capability module types are present in the current layout:
- **Weapons** (shown only if at least one weapon module is placed): combined DPS = Σ(damage_i × attack_rate_i) across all weapon module instances; maximum range = max(attack_range_i) across all weapon module instances.

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;