33 Commits

Author SHA1 Message Date
d271d65678 allow to set the recipe already for construction sites 2026-06-22 22:15:56 +02:00
e5017ab3c5 fix issue where construction sites could be placed outside of game world and add tests 2026-06-22 21:13:01 +02:00
59688e6532 fix issue where scrolling while drawing selection box did not update selection box size 2026-06-22 21:10:38 +02:00
c7218c7c1e Replace recipe/schematic dropdowns with button and selection dialog 2026-06-22 21:07:36 +02:00
c43225b6fa fix issue where beams were also disappearing while the game was paused 2026-06-21 22:09:25 +02:00
405206211e fix issue where right-click did not exit demolish mode 2026-06-21 21:56:40 +02:00
3d577a11db fix bug where recipe combo stayed visible when construction site was selected 2026-06-21 21:54:15 +02:00
a472ec196c cargo component refactoring 2026-06-21 21:49:46 +02:00
665060bcd2 Merge pull request 'fix issue where repair behavior targets enemy HQ in balancing target' (#1) from fix_repair_targeting into master
Reviewed-on: #1
2026-06-19 19:44:28 +00:00
4818997164 fix issue where repair behavior targets enemy HQ in balancing target 2026-06-19 21:36:04 +02:00
9573b9789a change repair_tool application and add beams for salvager and repair_tool 2026-06-19 21:15:47 +02:00
7924e037aa increase asteroid size 2026-06-18 21:48:43 +02:00
c371b43a6d make repair ships standby with rest of fleet if there is no one to repair (instead of advancing towards the enemy stations) 2026-06-18 21:45:15 +02:00
abab2bbb6e make repair ships not retreat if someone needs help 2026-06-17 22:40:58 +02:00
313fed02ca fix range of repair tool in config 2026-06-17 22:39:10 +02:00
b95eaaaded fix repair tool targeting 2026-06-17 21:52:47 +02:00
41c8ed2938 draw debug lines to repair and salvage behavior targets 2026-06-17 21:41:35 +02:00
7f4ea93a70 show accumulated threat for teams in balancing target 2026-06-17 21:29:02 +02:00
1a682fdb79 make drone movement look more spaceship-like 2026-06-17 20:51:45 +02:00
e0e11b7933 fix mutually canceling orbits 2026-06-17 20:50:31 +02:00
0cf3d64983 allow custom orbit rotations directions 2026-06-17 20:36:11 +02:00
1324a320e2 fix issue where ships cancel their attacks and advance if on low health in balancing target 2026-06-16 22:17:09 +02:00
5219b227c5 improve targeting rules config 2026-06-16 21:51:45 +02:00
0e02d9ec4a make sensor range semi transparent in debug draw mode 2026-06-16 21:51:26 +02:00
74615f5293 add debug draw mode for balancing target 2026-06-16 21:47:41 +02:00
bd2391876c draw debug lines to target 2026-06-16 21:38:58 +02:00
ac97652c60 make ships claim targets 2026-06-16 21:18:28 +02:00
4153b7e2f5 make ships orbit their targets 2026-06-15 21:37:47 +02:00
6b7c3df64a advance towards enemy buildings 2026-06-15 20:52:43 +02:00
e8dd73bcb0 refactor AI system 2026-06-15 09:16:56 +02:00
8451f5a281 fix missed code paths for artificially reduced splitter throughput 2026-06-14 14:50:23 +02:00
0a1b58442c add verification scripts for ship layouts and recipes 2026-06-14 14:24:18 +02:00
997a7778e0 first iteration of fable 5 on ship and module grids and recipes 2026-06-14 14:23:53 +02:00
126 changed files with 4475 additions and 1336 deletions

View File

@@ -105,8 +105,9 @@ fill_color = "#66CCFF"
glyph = "Rp" glyph = "Rp"
[module.repair] [module.repair]
repair_rate_hz_formula = "5 + x" repair_rate_hz_formula = "1"
repair_range_m_formula = "800" repair_amount_hp_formula = "5 + x"
repair_range_m_formula = "80"
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Propulsion # Propulsion

View File

@@ -31,8 +31,8 @@ hp_formula = "3"
[ship.movement] [ship.movement]
speed_mps_formula = "40" speed_mps_formula = "40"
main_acceleration_mpss_formula = "80" main_acceleration_mpss_formula = "2"
maneuvering_acceleration_mpss_formula = "40" maneuvering_acceleration_mpss_formula = "1"
angular_acceleration_radpss_formula = "12.56" angular_acceleration_radpss_formula = "12.56"
max_rotation_speed_radps_formula = "6.28" max_rotation_speed_radps_formula = "6.28"

View File

@@ -325,7 +325,9 @@ outline = "#ffffff"
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
[beams] [beams]
color = "#ff6600" weapon_color = "#ff6600"
repair_color = "#33ff66"
salvage_color = "#33ccff"
width_px = 2 width_px = 2
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@@ -1,5 +1,5 @@
[world] [world]
height_tiles = 30 height_tiles = 40
refund_percentage = 75 refund_percentage = 75
starting_building_blocks = 1000 starting_building_blocks = 1000
scrap_despawn_seconds = 30 scrap_despawn_seconds = 30
@@ -7,9 +7,11 @@ tile_size_m = 10
belt_speed_mps = 20 belt_speed_mps = 20
tunnel_max_distance_tiles = 10 tunnel_max_distance_tiles = 10
departure_interval_seconds = 20 departure_interval_seconds = 20
orbit_factor = 0.8
rally_orbit_radius_tiles = 5.0
[regions] [regions]
asteroid_width_tiles = 40 asteroid_width_tiles = 60
player_buffer_width_tiles = 20 player_buffer_width_tiles = 20
contest_zone_width_tiles = 60 contest_zone_width_tiles = 60
enemy_buffer_width_tiles = 20 enemy_buffer_width_tiles = 20
@@ -22,6 +24,11 @@ cost_building_blocks = 200
push_expand_columns_tiles = 10 push_expand_columns_tiles = 10
boss_advance_seconds = 60 boss_advance_seconds = 60
[targeting]
target_score_formula = "1 / (1 + x)" # x = distance / max weapon range; higher = better, clamped to >=0
overclaim_penalty_formula = "max(0.5, 1 - 0.1*x)" # x = competing claim count; multiplies score, clamped to [0,1]
target_hysteresis = 0.40 # keep current target unless a challenger beats it by >10%
[waves] [waves]
threat_rate_formula = "x" threat_rate_formula = "x"
ship_level_formula = "1" ship_level_formula = "1"

View File

@@ -78,7 +78,8 @@ fill_color = "#66CCFF"
glyph = "Rp" glyph = "Rp"
[module.repair] [module.repair]
repair_rate_hz_formula = "5 + x" repair_rate_hz_formula = "1"
repair_amount_hp_formula = "5 + x"
repair_range_m_formula = "800" repair_range_m_formula = "800"
[[module]] [[module]]

View File

@@ -7,6 +7,8 @@ tile_size_m = 10
belt_speed_mps = 20 belt_speed_mps = 20
tunnel_max_distance_tiles = 10 tunnel_max_distance_tiles = 10
departure_interval_seconds = 20 departure_interval_seconds = 20
orbit_factor = 0.8
rally_orbit_radius_tiles = 5.0
[regions] [regions]
asteroid_width_tiles = 40 asteroid_width_tiles = 40
@@ -22,6 +24,11 @@ cost_building_blocks = 200
push_expand_columns_tiles = 20 push_expand_columns_tiles = 20
boss_advance_seconds = 60 boss_advance_seconds = 60
[targeting]
target_score_formula = "1 / (1 + x)" # x = distance / max weapon range; higher = better, clamped to >=0
overclaim_penalty_formula = "max(0.5, 1 - 0.1*x)" # x = competing claim count; multiplies score, clamped to [0,1]
target_hysteresis = 0.10 # keep current target unless a challenger beats it by >10%
[waves] [waves]
threat_rate_formula = "x" threat_rate_formula = "x"
ship_level_formula = "1 + x / 10" ship_level_formula = "1 + x / 10"

View File

@@ -52,14 +52,14 @@ See REQ-GW-COORDS for the authoritative tile-coordinate convention. This section
Simulation types shared across subsystems: Simulation types shared across subsystems:
- `EntityId` — strictly increasing integer handle, allocated centrally by the simulation. Assigned to every targetable entity: ships, scrap drops, **and** buildings (including HQ and defence stations). Buildings additionally retain their anchor tile for spatial lookups and placement; the `EntityId` is the canonical reference used by ship-component target fields (`Weapon.currentTarget`, `RepairTool.currentTarget`, `ThreatResponse.currentTarget`, etc.), so a combat ship can target either another ship or a defence station uniformly. - `EntityId` — strictly increasing integer handle, allocated centrally by the simulation. Assigned to every targetable entity: ships, scrap drops, **and** buildings (including HQ and defence stations). Buildings additionally retain their anchor tile for spatial lookups and placement; the `EntityId` is the canonical reference used by ship-component target fields (`Weapon.currentTarget`, `RepairTool.currentTarget`, `AttackBehavior.currentTarget`, etc.), so a combat ship can target either another ship or a defence station uniformly.
- `Rotation` — enum `{ North, East, South, West }`. The rotation applied to a building's surface_mask when placed. - `Rotation` — enum `{ North, East, South, West }`. The rotation applied to a building's surface_mask when placed.
- `BuildingType` — enum covering every building type in requirements.md (Miner, Smelter, Assembler, ReprocessingPlant, Shipyard, SalvageBay, Belt, Splitter, Hq, PlayerDefenceStation, EnemyDefenceStation). `Belt` and `Splitter` share the enum for cost, construction, placement, and `visuals.toml` lookup, but their runtime data lives inside the belt subsystem rather than in `Building` instances (see Belt Subsystem). - `BuildingType` — enum covering every building type in requirements.md (Miner, Smelter, Assembler, ReprocessingPlant, Shipyard, SalvageBay, Belt, Splitter, Hq, PlayerDefenceStation, EnemyDefenceStation). `Belt` and `Splitter` share the enum for cost, construction, placement, and `visuals.toml` lookup, but their runtime data lives inside the belt subsystem rather than in `Building` instances (see Belt Subsystem).
- `ItemType` — tagged id of every transportable material (ores, ingots, intermediates, building_blocks, scrap). - `ItemType` — tagged id of every transportable material (ores, ingots, intermediates, building_blocks, scrap).
- `Item``struct Item { ItemType type; }`. Items on belts have no persistent identity across ticks. - `Item``struct Item { ItemType type; }`. Items on belts have no persistent identity across ticks.
- `Port``struct Port { QPoint tile; Rotation direction; }`. Identifies a belt-adjacent cell and the direction of flow across that cell. - `Port``struct Port { QPoint tile; Rotation direction; }`. Identifies a belt-adjacent cell and the direction of flow across that cell.
- `MovementIntent``struct MovementIntent { int priority; QVector2D target; }`. Priority follows the order declared under Movement Arbitration. Cleared at the start of each tick; the highest-priority write wins; `tickMovement` reads the winner. - `MovementIntent``struct MovementIntent { bool active; QVector2D target; }`. Written by the winning behavior's executor (see Movement Arbitration). Cleared (`active = false`) at the start of each tick; `tickMovement` brakes when inactive, otherwise drives toward `target`.
- `WeaponFiredEvent``struct WeaponFiredEvent : public Event { entt::entity shooter; entt::entity target; Tick emittedAt; }`. Transient record emitted each time a weapon fires (REQ-SHP-FIRING, REQ-SHP-FIRING-BEAM). Buffered in a sim-owned vector during the tick, then drained and re-emitted via EventManager by the UI frame handler; see Sim → UI Events. - `BeamFiredEvent``struct BeamFiredEvent : public Event { BeamKind kind; entt::entity shooter; entt::entity target; Tick emittedAt; }`. Transient record emitted each time a weapon fires, a repair tool starts a heal cycle, or a salvage module starts a collection cycle (REQ-SHP-FIRING, REQ-SHP-FIRING-BEAM). `BeamKind` (`Weapon`/`Repair`/`Salvage`) selects the beam color. Buffered in a sim-owned vector during the tick, then drained and re-emitted via EventManager by the UI frame handler; see Sim → UI Events.
- `SchematicChoiceOption``struct SchematicChoiceOption { string schematicId; SchematicType type; string displayName; bool isNewUnlock; int targetLevel; }`. Describes one option in the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP). Up to three are generated when an enemy station set is destroyed. `SchematicType` is `Ship`, `Module`, or `Recipe`. - `SchematicChoiceOption``struct SchematicChoiceOption { string schematicId; SchematicType type; string displayName; bool isNewUnlock; int targetLevel; }`. Describes one option in the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP). Up to three are generated when an enemy station set is destroyed. `SchematicType` is `Ship`, `Module`, or `Recipe`.
- `SchematicChoicesAvailableEvent` — EventManager event carrying a `vector<SchematicChoiceOption>`. Sent by the UI each frame when pending choices are detected; handled by `MainWindow` which opens the schematic choice dialog. - `SchematicChoicesAvailableEvent` — EventManager event carrying a `vector<SchematicChoiceOption>`. Sent by the UI each frame when pending choices are detected; handled by `MainWindow` which opens the schematic choice dialog.
@@ -85,9 +85,9 @@ The EventManager is thread-safe (mutex-guarded).
### Sim → UI Events ### Sim → UI Events
The simulation layer stays free of EventManager — it uses a plain `std::vector<WeaponFiredEvent>` internally (owned by `CombatSystem`). This preserves determinism, tick-order fidelity, and headless testability (Catch2 tests read the queue directly via `drainWeaponFiredEvents()` after `tick()`). The simulation layer stays free of EventManager — it uses a plain `std::vector<BeamFiredEvent>` internally (owned by `Simulation`, filled by the combat, repair, and salvage systems). This preserves determinism, tick-order fidelity, and headless testability (Catch2 tests read the queue directly via `drainBeamFiredEvents()` after `tick()`).
The UI frame handler (`GameWorldView::onFrame` / `ArenaView::onFrame`) bridges the gap: each frame it calls `simulation.drainWeaponFiredEvents()`, then re-emits each `WeaponFiredEvent` via `EventManager::sendEventImmediately()`. Subscribers (the same view's `handleEvent(WeaponFiredEvent)`) create `ActiveBeam` records tracked for 0.3 s of wall time, then discarded. If either the shooter or target entity is gone when the renderer looks them up, the beam is dropped early. The UI frame handler (`GameWorldView::onFrame` / `ArenaView::onFrame`) bridges the gap: each frame it calls `simulation.drainBeamFiredEvents()`, then re-emits each `BeamFiredEvent` via `EventManager::sendEventImmediately()`. Subscribers (the same view's `handleEvent(BeamFiredEvent)`) create `ActiveBeam` records tracked for 0.3 s of wall time, then discarded. If either the shooter or target entity is gone when the renderer looks them up, the beam is dropped early.
Schematic drops: when an enemy station set is destroyed, the simulation generates up to 3 `SchematicChoiceOption` entries and stores them as pending state. The UI polls `hasSchematicChoicesPending()` each frame and, when true, sends a `SchematicChoicesAvailableEvent` via EventManager. `MainWindow` handles this event by pausing the game and opening a modal `SchematicChoiceDialog`. The player's selection is fed back via `applySchematicChoice(index)`. Schematic drops: when an enemy station set is destroyed, the simulation generates up to 3 `SchematicChoiceOption` entries and stores them as pending state. The UI polls `hasSchematicChoicesPending()` each frame and, when true, sends a `SchematicChoicesAvailableEvent` via EventManager. `MainWindow` handles this event by pausing the game and opening a modal `SchematicChoiceDialog`. The player's selection is fed back via `applySchematicChoice(index)`.
@@ -107,8 +107,8 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
4. **Building production** — advance production timers; start new cycles when inputs and output-buffer space permit (REQ-MAT-CYCLE); on completion, deposit output. 4. **Building production** — advance production timers; start new cycles when inputs and output-buffer space permit (REQ-MAT-CYCLE); on completion, deposit output.
5. **Building → belt push** — buildings push items from output buffer onto the belt tile at their output port (REQ-MAT-OUTPUT-PORT). 5. **Building → belt push** — buildings push items from output buffer onto the belt tile at their output port (REQ-MAT-OUTPUT-PORT).
6. **Belt tick** — advance items along belt tiles; apply splitter routing (REQ-BLD-SPLITTER). 6. **Belt tick** — advance items along belt tiles; apply splitter routing (REQ-BLD-SPLITTER).
7. **Ship behavior systems** — clear `MovementIntent` on each ship, then run `tickThreatResponse`, `tickScrapCollector`, `tickRepairBehavior`, `tickHomeReturn` in any order (arbitration is via intent priority). 7. **Ship behavior systems** — clear `MovementIntent` on each ship, then the `AiSystem` runs three batched phases: every behavior **evaluator** scores its behavior and sets its target data; a **selection** pass records the highest-scoring behavior per ship in `SelectedBehaviorComponent`; each behavior **executor** runs for the winner, writing `MovementIntent` and preferred module targets. The module systems then perform world mutation: `SalvagerSystem` (scrap collection/delivery) and `RepairSystem` (healing). See Movement Arbitration.
8. **Combat resolution** — ships and defence stations acquire targets, fire, apply damage; queue deaths. Each fire appends a `WeaponFiredEvent` to the sim's weapon-fired-event queue (REQ-SHP-FIRING-BEAM). 8. **Combat resolution** — ships and defence stations validate/acquire targets, fire, apply damage; queue deaths. Each fire appends a `BeamFiredEvent` to the sim's beam-fired-event queue (REQ-SHP-FIRING-BEAM). The repair and salvage module systems (tick step 7d) append their own `BeamFiredEvent`s to the same queue when they start a cycle.
9. **Deaths & loot** — process queued deaths: drop scrap (REQ-RES-SCRAP-DROP); if a full enemy-defence-station set was destroyed this tick, generate up to 3 schematic choice options (REQ-DEF-SCHEMATIC-DROP) stored as pending state for the UI to present; remove entities. 9. **Deaths & loot** — process queued deaths: drop scrap (REQ-RES-SCRAP-DROP); if a full enemy-defence-station set was destroyed this tick, generate up to 3 schematic choice options (REQ-DEF-SCHEMATIC-DROP) stored as pending state for the UI to present; remove entities.
10. **`tickMovement`** — advance ship positions based on final `MovementIntent`. 10. **`tickMovement`** — advance ship positions based on final `MovementIntent`.
11. **Scrap despawn** — decrement scrap timers; remove expired scrap (REQ-RES-SCRAP-DROP). 11. **Scrap despawn** — decrement scrap timers; remove expired scrap (REQ-RES-SCRAP-DROP).
@@ -212,21 +212,26 @@ Ships follow a component-composition model using `std::optional<Component>` memb
struct Weapon { float damage; float range; float fireRateHz; float cooldownTicks; struct Weapon { float damage; float range; float fireRateHz; float cooldownTicks;
std::optional<EntityId> currentTarget; }; std::optional<EntityId> currentTarget; };
struct SalvageCargo { int capacity; int current; }; struct SalvageCargo { int capacity; int current; };
struct RepairTool { float ratePerTick; std::optional<EntityId> currentTarget; }; struct RepairTool { float repairAmountHp; int repairIntervalTicks; int cooldownTicksRemaining;
float range; std::optional<EntityId> currentTarget; };
``` ```
### Behavior Components ### Behavior Components
Behaviors are decomposed, not bundled into per-role monolithic AIs. This is the critical modeling choice: adding a capability (e.g., putting a `Weapon` on a repair ship) must not require rewriting AI code. Behaviors are decomposed, not bundled into per-role monolithic AIs. This is the critical modeling choice: adding a capability (e.g., putting a `Weapon` on a repair ship) must not require rewriting AI code. Each behavior is a small component carrying its own target data plus a `float score` written by its evaluator each tick.
```cpp ```cpp
struct ThreatResponse { float engagementRange; CombatStance stance; struct AdvanceBehavior { float score; }; // baseline fallback, all ships
CombatTargetPriority priority; struct RallyBehavior { QVector2D rallyPoint; float score; }; // player combat ships
std::optional<EntityId> currentTarget; }; struct RetreatBehavior { float retreatHpFraction; QVector2D retreatPoint; // player ships
struct ScrapCollector { std::optional<QVector2D> scrapTarget; EntityId deliveryBay; }; float score; };
struct RepairBehavior { RepairTargetPriority priority; struct AttackBehavior { std::optional<EntityId> currentTarget; float score; };
std::optional<EntityId> currentTarget; }; struct RepairBehavior { std::optional<EntityId> currentTarget;
struct HomeReturn { float retreatHpFraction; QVector2D homePos; }; float maxRepairRange_tiles; float score; };
struct SalvageScrapBehavior { std::optional<QVector2D> scrapTarget;
float maxCollectionRange_tiles; float score; };
struct DeliverScrapBehavior { BuildingId deliveryBay; float score; };
struct SelectedBehaviorComponent { BehaviorKind winner; float bestScore; }; // selection result
``` ```
### Ship ### Ship
@@ -246,38 +251,42 @@ struct Ship {
std::optional<SalvageCargo> cargo; std::optional<SalvageCargo> cargo;
std::optional<RepairTool> repairTool; std::optional<RepairTool> repairTool;
// Behaviors // Behaviors (attached per capability; AdvanceBehavior + SelectedBehaviorComponent
std::optional<ThreatResponse> threatResponse; // on every ship, RetreatBehavior on player ships, etc.)
std::optional<ScrapCollector> scrapCollector; std::optional<AttackBehavior> attackBehavior;
std::optional<SalvageScrapBehavior> salvageScrapBehavior;
std::optional<DeliverScrapBehavior> deliverScrapBehavior;
std::optional<RepairBehavior> repairBehavior; std::optional<RepairBehavior> repairBehavior;
std::optional<HomeReturn> homeReturn;
// Written by behavior systems, read by movement. // Written by the winning behavior's executor, read by movement.
MovementIntent intent; MovementIntent intent;
}; };
``` ```
### Systems ### Systems
Each behavior has its own tick system. A system iterates a flat `std::vector<Ship>` and skips ships that do not have the relevant components. Each behavior is split into a stateless **evaluator** and **executor** class (one per behavior, e.g. `AttackEvaluator`/`AttackExecutor`), orchestrated by `AiSystem`. Evaluators and executors only read/write behavior components and module target fields — they never mutate the game world. World mutation lives in dedicated module systems that run every tick, independent of which behavior won:
- `tickThreatResponse` — requires `threatResponse` + `weapon`. Acquires target, fires, manages cooldown. - `CombatSystem` — validates each weapon's executor-set target, falls back to nearest-target acquisition, fires, applies damage.
- `tickScrapCollector` — requires `scrapCollector` + `cargo`. Flies to scrap, picks up, returns to delivery bay. - `SalvagerSystem` — collects scrap into cargo and delivers full cargo at a `SalvageBay`.
- `tickRepairBehavior` — requires `repairBehavior` + `repairTool`. Finds damaged target, moves to range, repairs. - `RepairSystem` — validates each repair tool's target, falls back to nearest damaged friendly, applies healing.
- `tickHomeReturn` — requires `homeReturn`. Overrides movement if hp drops below threshold. - `MovementIntentSystem` (`tickMovement`) — reads `MovementIntent`, advances `position`; brakes when inactive.
- `tickMovement` — reads `intent`, advances `position`.
### Movement Arbitration ### Movement Arbitration
When multiple behaviors want to drive movement, a fixed global priority resolves the conflict. Each behavior system writes a `MovementIntent` carrying its priority; a higher-priority write overwrites a lower-priority one. `tickMovement` reads the final winner. Arbitration is **score-based**, not fixed-priority. In a single tick `AiSystem` runs three phases:
Initial priority order (subject to tuning): 1. **Evaluate** — every behavior's evaluator iterates the ships that have its component, sets its target data, and writes a `float score` (see `BehaviorScores.h`). An evaluator returns an inactive score when its behavior does not apply.
2. **Select**`selectWinningBehaviors` resets each `SelectedBehaviorComponent`, then compares every behavior's score per ship, recording the highest as `winner`. Behaviors are considered highest-band first so a strict `>` breaks ties toward the more urgent behavior.
3. **Execute** — each behavior's executor runs only for ships where it is the `winner`, writing the single `MovementIntent` and any preferred module targets.
`AdvanceBehavior` is present on every ship with the lowest score, guaranteeing a winner. The resulting band order:
``` ```
HomeReturn > ThreatResponse > RepairBehavior > ScrapCollector Retreat > Attack / Repair / SalvageScrap / DeliverScrap > Rally > Advance
``` ```
`tickMovement` runs last. Intents are cleared at the start of each tick. `MovementIntent` is cleared (inactive) at the start of each tick; `tickMovement` runs last.
### Why Not ECS ### Why Not ECS
@@ -300,7 +309,7 @@ The game world is rendered by a single `GameWorldView` widget that inherits `QOp
### Threading ### Threading
Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly without locks. If profiling later justifies moving the sim to a worker thread, the pull-style `drainWeaponFiredEvents()` / `getPendingSchematicChoices()` / `applySchematicChoice()` / `forEachVisualItem()` APIs already support a clean snapshot-and-render split; a single mutex at the sim boundary would suffice. The `ArenaSimulation` used by the balancing tool runs headlessly on a worker thread; fire events accumulate in its internal vector and are only drained when `ArenaView` drives `tickOnce()` on the main thread during interactive inspection. Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly without locks. If profiling later justifies moving the sim to a worker thread, the pull-style `drainBeamFiredEvents()` / `getPendingSchematicChoices()` / `applySchematicChoice()` / `forEachVisualItem()` APIs already support a clean snapshot-and-render split; a single mutex at the sim boundary would suffice. The `ArenaSimulation` used by the balancing tool runs headlessly on a worker thread; fire events accumulate in its internal vector and are only drained when `ArenaView` drives `tickOnce()` on the main thread during interactive inspection.
### Layer Order (back to front) ### Layer Order (back to front)
@@ -309,7 +318,7 @@ Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly
3. **Belt items** — 10×10 colored squares emitted by `BeltSystem::forEachVisualItem`. 3. **Belt items** — 10×10 colored squares emitted by `BeltSystem::forEachVisualItem`.
4. **Scrap** — glyphs at world positions. 4. **Scrap** — glyphs at world positions.
5. **Ships** — colored arrows oriented by velocity; color keyed to role (player combat / salvage / repair / enemy). 5. **Ships** — colored arrows oriented by velocity; color keyed to role (player combat / salvage / repair / enemy).
6. **Laser beams** — lines derived from live `WeaponFiredEvent`s kept by the renderer for 0.3 s (REQ-SHP-FIRING-BEAM). 6. **Laser beams** — lines derived from live `BeamFiredEvent`s kept by the renderer for 0.3 s, colored per `BeamKind` (weapon/repair/salvage) (REQ-SHP-FIRING-BEAM).
7. **Build overlays** — ghost in builder mode (REQ-BLD-GHOST), demolish-mode tint, tile highlight under cursor, box-drag selection rectangle. 7. **Build overlays** — ghost in builder mode (REQ-BLD-GHOST), demolish-mode tint, tile highlight under cursor, box-drag selection rectangle.
8. **Screen-space UI** — screen-anchored elements, drawn after resetting the world-space transform. 8. **Screen-space UI** — screen-anchored elements, drawn after resetting the world-space transform.

View File

@@ -4,13 +4,13 @@
Config files use the TOML format. The following config files drive game parameters: Config files use the TOML format. The following config files drive game parameters:
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, enemy ship level formula, belt speed, starting building blocks, departure interval. - **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, enemy ship level formula, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, and combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis).
- **buildings.toml** — building block cost and construction time per building type. - **buildings.toml** — building block cost and construction time per building type.
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlock_at_station_level` (integer): -1 means the recipe is explicitly unlocked at game start; a value ≥ 0 means the recipe starts locked and a schematic for it can be awarded via defence station destruction (see REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP). - **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlock_at_station_level` (integer): -1 means the recipe is explicitly unlocked at game start; a value ≥ 0 means the recipe starts locked and a schematic for it can be awarded via defence station destruction (see REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP).
- **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as formulas of ship level, required build materials, player production level, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the schematic already unlocked), a layout grid defining the ship's module slots, a `scrap_drop` loot value, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES). - **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as formulas of ship level, required build materials, player production level, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the schematic already unlocked), a layout grid defining the ship's module slots, a `scrap_drop` loot value, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES).
- **modules.toml** — per module type: id, surface mask, materials list, initial player production level, production time, fill color, glyph, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the module schematic already unlocked), and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC). - **modules.toml** — per module type: id, surface mask, materials list, initial player production level, production time, fill color, glyph, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the module schematic already unlocked), and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC).
- **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level. - **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level.
- **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; beam color and width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it. - **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; a distinct beam color per tool type (weapon, repair, salvage) and beam width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it.
- **ship_layouts.toml** — named layout blueprints per ship type; written and read by the application to persist the layout blueprint panel (REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). Not a game parameter file; the simulation does not read it. - **ship_layouts.toml** — named layout blueprints per ship type; written and read by the application to persist the layout blueprint panel (REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). Not a game parameter file; the simulation does not read it.
- REQ-CFG-RELOAD: When the player triggers a Restart (REQ-UI-GAME-MENU), all config files are reloaded from disk before the simulation is reset to its initial state. Formula strings are recompiled at that point. This allows config edits made while the application is running to take effect without a full application restart. - REQ-CFG-RELOAD: When the player triggers a Restart (REQ-UI-GAME-MENU), all config files are reloaded from disk before the simulation is reset to its initial state. Formula strings are recompiled at that point. This allows config edits made while the application is running to take effect without a full application restart.
@@ -104,6 +104,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-BLD-BELT-DRAG: For belts, the player can click and drag across multiple tiles to place a construction site on each tile in one gesture. - REQ-BLD-BELT-DRAG: For belts, the player can click and drag across multiple tiles to place a construction site on each tile in one gesture.
- REQ-BLD-TUNNEL-AUTO-SWITCH: After the player successfully places a Tunnel Entry construction site, builder mode automatically switches to Tunnel Exit (and vice versa), preserving the current ghost rotation. This makes it easy to immediately place the paired end without manually selecting the complementary type. - REQ-BLD-TUNNEL-AUTO-SWITCH: After the player successfully places a Tunnel Entry construction site, builder mode automatically switches to Tunnel Exit (and vice versa), preserving the current ghost rotation. This makes it easy to immediately place the paired end without manually selecting the complementary type.
- REQ-BLD-DEMOLISH: The player can demolish a placed factory building. Demolition returns `world.toml [world].refund_percentage` percent of the original building block cost (default 75%) to the global stock. Exception: if the building is still in the construction queue (not yet fully built, including the one currently being constructed), it is removed from the queue and the **full** building block cost is refunded. The HQ and player defence stations cannot be demolished. - REQ-BLD-DEMOLISH: The player can demolish a placed factory building. Demolition returns `world.toml [world].refund_percentage` percent of the original building block cost (default 75%) to the global stock. Exception: if the building is still in the construction queue (not yet fully built, including the one currently being constructed), it is removed from the queue and the **full** building block cost is refunded. The HQ and player defence stations cannot be demolished.
- REQ-BLD-SITE-CONFIG: A construction site — a building that has been placed but is still queued or under construction (REQ-BLD-QUEUE) — can be selected and configured exactly like the equivalent operational building, before it finishes building. Whatever configuration the building type supports is available on the site: the recipe for a Miner or Assembler (REQ-UI-SELECT-BUTTON), the produced-ship schematic and its module layout for a Shipyard (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-PREVIEW, REQ-MOD-UI-DIALOG), and the output filters for a Splitter (REQ-BLD-SPLITTER) — all set through the same Selected Building Panel controls (REQ-UI-CONFIG-INLINE). Only currently unlocked recipes and schematics are offered, exactly as for operational buildings (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC, REQ-LOCK-UI-SPLITTER). The configuration is stored on the construction site and carries over unchanged when construction completes, so the building becomes operational already configured. A construction site has no input/output buffers and runs no production cycle, so the buffer and production-progress portions of the panel (REQ-UI-SINGLE-SELECTION, REQ-UI-PRODUCTION-PROGRESS) are not shown for it; only its construction progress (REQ-UI-CONSTRUCTION-PROGRESS) and its configuration controls appear. (Blueprint placement already applies a stored recipe or schematic to a construction site on placement per REQ-UI-BLUEPRINT-PLACE; this requirement additionally lets the player set or change that configuration directly on an existing site.)
## Building Types ## Building Types
@@ -143,7 +144,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
## Resources ## Resources
- REQ-RES-SCRAP-DROP: Destroyed ships (both player and enemy) and destroyed defence stations (both player and enemy) drop scrap at their location. The scrap amount per ship is defined in `ships.toml [ship.loot].scrap_drop`; for stations it is defined as `stations.toml [player_station].scrap_drop_formula` and `[enemy_station].scrap_drop_formula`. Scrap despawns after `world.toml [world].scrap_despawn_seconds` seconds if not collected. - REQ-RES-SCRAP-DROP: Destroyed ships (both player and enemy) and destroyed defence stations (both player and enemy) drop scrap at their location. The scrap amount per ship is defined in `ships.toml [ship.loot].scrap_drop`; for stations it is defined as `stations.toml [player_station].scrap_drop_formula` and `[enemy_station].scrap_drop_formula`. A scrap drop carries an amount; salvage modules collect it one scrap per cycle (REQ-SHP-SALVAGE), and the drop is removed from the world once its remaining amount reaches zero or `world.toml [world].scrap_despawn_seconds` seconds have elapsed since it was dropped, whichever comes first.
- REQ-RES-SCRAP-COLLECT: Scrap is collected by salvage ships and delivered to a Salvage Bay on the asteroid. From there it can be fed via belt into a smelter (same output as ore) or a Reprocessing Plant. - REQ-RES-SCRAP-COLLECT: Scrap is collected by salvage ships and delivered to a Salvage Bay on the asteroid. From there it can be fed via belt into a smelter (same output as ore) or a Reprocessing Plant.
## Ships ## Ships
@@ -153,22 +154,33 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-SHP-SPAWN-PLAYER: A ship produced by a shipyard spawns centered on the shipyard's output port tile. - REQ-SHP-SPAWN-PLAYER: A ship produced by a shipyard spawns centered on the shipyard's output port tile.
- REQ-SHP-SPAWN-ENEMY: Enemy ships spawn at a uniformly random position within the current enemy buffer zone — random X across the buffer's width and random Y across the world height. - REQ-SHP-SPAWN-ENEMY: Enemy ships spawn at a uniformly random position within the current enemy buffer zone — random X across the buffer's width and random Y across the world height.
- REQ-SHP-MOVEMENT: Ships move using a physics-based model. Each ship has a velocity and a facing direction, both updated each tick. The main acceleration (`main_acceleration_formula`) is applied along the ship's current facing direction only. The maneuvering acceleration (`maneuvering_acceleration_formula`) can be applied in any direction independently of the facing direction, enabling lateral or braking movement without rotating. The angular acceleration (`angular_acceleration_formula`) controls how quickly the ship rotates. Linear speed is capped at the ship's `speed_formula` value; rotation rate is capped at the ship's `max_rotation_speed_formula` value. Ship position refers to the ship's center for all range, sensor, and attack checks. - REQ-SHP-MOVEMENT: Ships move using a physics-based model. Each ship has a velocity and a facing direction, both updated each tick. The main acceleration (`main_acceleration_formula`) is applied along the ship's current facing direction only. The maneuvering acceleration (`maneuvering_acceleration_formula`) can be applied in any direction independently of the facing direction, enabling lateral or braking movement without rotating. The angular acceleration (`angular_acceleration_formula`) controls how quickly the ship rotates. Linear speed is capped at the ship's `speed_formula` value; rotation rate is capped at the ship's `max_rotation_speed_formula` value. Ship position refers to the ship's center for all range, sensor, and attack checks.
- REQ-SHP-ORBIT: Several behaviors keep a ship circling its target at a fixed standoff distance (an **orbit**) rather than approaching a fixed point. The orbit radius depends on the behavior:
- **Combat engagement** (REQ-SHP-COMBAT, REQ-SHP-ENEMY-AI): `world.toml [world].orbit_factor` multiplied by the maximum weapon `attack_range` across the ship's weapon module instances.
- **Repair** (REQ-SHP-REPAIR): `orbit_factor` multiplied by the maximum `repair_range` across the ship's repair module instances.
- **Salvage** (REQ-SHP-SALVAGE): `orbit_factor` multiplied by the maximum `collection_range` across the ship's salvage module instances.
- **Rally** (REQ-SHP-RALLY): `world.toml [world].rally_orbit_radius_tiles` — a fixed radius in tiles, independent of any tool range (the rally point is a position, not a tool-bearing target).
All tool ranges incorporate passive module modifiers (REQ-MOD-STAT-CALC). While orbiting, the ship navigates to maintain the orbit radius from the target's current center (REQ-SHP-MOVEMENT) while moving tangentially around it: if it is farther than the orbit radius it closes in, if it is nearer it backs off, and at the radius it circles. The orbit direction (clockwise or counter-clockwise) is fixed for the duration of orbiting a given target. Orbiting uses the standard physics movement model (REQ-SHP-MOVEMENT) and introduces no new movement constraints. Orbiting does not by itself trigger tool use — weapons, repair tools, and salvage bays still fire/heal/collect strictly per their own range and rate checks (REQ-SHP-FIRING, REQ-SHP-REPAIR, REQ-SHP-SALVAGE). With `orbit_factor` ≤ 1 the orbit lies within the maximum tool range, so the longest-range tool of that type remains in range while the ship orbits.
- REQ-SHP-NO-COLLISION: Ships do not collide with each other or with defence stations; they may visually overlap. - REQ-SHP-NO-COLLISION: Ships do not collide with each other or with defence stations; they may visually overlap.
- REQ-SHP-SENSOR: A ship perceives only entities within its sensor range. Behavior is driven by what is in sensor range; entities outside sensor range are ignored. - REQ-SHP-SENSOR: A ship perceives only entities within its sensor range. Behavior is driven by what is in sensor range; entities outside sensor range are ignored.
- REQ-SHP-FIRING: All weapons — on ships and on defence stations — fire when off cooldown and the target is within attack range. Firing emits a fire event and starts a 0.15-second damage delay (half the beam duration). When that delay expires, damage is applied to the target — unless the target has already been destroyed, in which case the damage is silently dropped. If the shooter is destroyed before the delay expires, damage is still applied when the delay expires. There is no projectile entity and no intervening collision. The weapon's cooldown begins at the moment of firing, not at damage application. - REQ-SHP-FIRING: All weapons — on ships and on defence stations — fire when off cooldown and the target is within attack range. Firing emits a fire event and starts a 0.15-second damage delay (half the beam duration). When that delay expires, damage is applied to the target — unless the target has already been destroyed, in which case the damage is silently dropped. If the shooter is destroyed before the delay expires, damage is still applied when the delay expires. There is no projectile entity and no intervening collision. The weapon's cooldown begins at the moment of firing, not at damage application.
- REQ-SHP-FIRING-BEAM: Each fire event produces a visual laser beam drawn from the shooter's position to the target for 0.3 seconds. The beam endpoint is not the target's center but a point randomly offset from it: the offset direction is uniformly random and the offset magnitude is uniformly random up to half the target's visual size (for ships: half their rendered radius; for buildings/stations: half the shorter side of their tile footprint, in world units). The offset is chosen once per fire event and held fixed for the beam's lifetime. The beam is a pure rendering effect and has no simulation state (does not block movement, does not re-apply damage over its lifetime). Beams follow the shooter and target positions if either moves during the 0.3-second window. The beam is rendered for its full 0.3-second duration even if the shooter or target is destroyed before it expires. - REQ-SHP-FIRING-BEAM: Each weapon fire event (REQ-SHP-FIRING), repair-tool activation (REQ-SHP-REPAIR), and salvage activation (REQ-SHP-SALVAGE) produces a visual beam drawn from the acting ship's position to the target for 0.3 seconds; repair and salvage beams have the same duration as weapon beams. The beam is rendered in the tool type's beam color from `visuals.toml` (a distinct color for weapon, repair, and salvage beams). The beam endpoint is not the target's center but a point randomly offset from it: the offset direction is uniformly random and the offset magnitude is uniformly random up to half the target's visual size (for ships: half their rendered radius; for buildings/stations: half the shorter side of their tile footprint, in world units; for a scrap pile: half its rendered size). The offset is chosen once per activation event and held fixed for the beam's lifetime. The beam is a pure rendering effect and has no simulation state (does not block movement, does not re-apply its effect over its lifetime). Beams follow the acting ship and target positions if either moves during the 0.3-second window. The beam is rendered for its full 0.3-second duration even if the acting ship or target is destroyed before it expires.
- REQ-SHP-COMBAT: Ships with at least one **weapon module** (player) — engage enemy ships within sensor range. The player can configure the following per shipyard (applied to all ships produced by that shipyard): - REQ-SHP-COMBAT: Ships with at least one **weapon module** (player) — engage enemy ships within sensor range. When engaging an enemy, the ship orbits it at the combat orbit radius (REQ-SHP-ORBIT) rather than approaching its center.
- Stance: aggressive (advance toward enemies) / defensive (hold position near asteroid). - REQ-SHP-RALLY: After spawning, 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.
- Target priority: closest / highest HP / structures first. - 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.
- REQ-SHP-RALLY: After spawning, aggressive-stance ships with weapon modules move to and loiter at the **rally point** — the midpoint between the two player defence stations (center of their Y-span, at the player defence stations' X position). While at the rally point, ships still engage any enemy that enters sensor range. 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, move to it; when it is within a module's `collection_range`, that module collects it (consuming the scrap entity). Once all cargo is full, fly to a Salvage Bay and deliver; after delivery, resume patrol. If an enemy ship enters sensor range while not currently targeting or carrying scrap, turn back (move toward the asteroid) until the enemy is no longer in sensor range, then resume patrol. 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 collections per second). After collecting a piece of scrap, the module cannot collect again until `1 / collection_rate` seconds have elapsed. A ship with multiple salvage modules can therefore collect multiple pieces of scrap per tick (one per ready module), and installs 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.
- REQ-SHP-REPAIR: Ships with at least one **repair module** (player) — patrol by moving forward (rightward, away from the asteroid) while searching sensor range. If a damaged player defence station or player ship enters sensor range, move to it and repair. If an enemy ship enters sensor range while not currently repairing, turn back (move toward the asteroid) until the enemy is no longer in sensor range, then resume patrol. The player can configure the target priority per shipyard:
- Defence stations first / ships first / nearest target.
Each repair module instance operates independently: it has its own repair rate (`repair_rate`) and repair range (`repair_range`). On each tick, a module first attempts to heal the ship's current behavior-level navigation target if that target is within the module's `repair_range` and is damaged (HP above zero and below maximum HP). If those conditions are not met — because the target is out of the module's `repair_range`, already at full health, or destroyed — the module independently searches for the nearest damaged friendly (player ship or player defence station) within its own `repair_range` and heals that instead. If no valid target is found within range, the module idles. A ship with multiple repair modules can therefore heal different targets simultaneously. Navigation is driven solely by the behavior-level target; individual module fallback targets do not affect which direction the ship moves. 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-ENEMY-AI: **Enemy ships** — engage the closest valid target (player defence station, HQ, or player ship) within their sensor range. If no target is in sensor range, they move toward the asteroid (leftward in world coordinates). - 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.
Each repair module instance operates independently: it has its own repair rate (`repair_rate`, in repair cycles per second), per-cycle heal amount (`repair_amount_hp`), and repair range (`repair_range`). A module starts a repair cycle when it is off cooldown and a valid repair target is in range. To choose the target, the module first considers the ship's current behavior-level navigation target if that target is within the module's `repair_range` and is damaged (HP above zero and below maximum HP). If those conditions are not met — because the target is out of the module's `repair_range`, already at full health, or destroyed — the module independently searches for the nearest damaged friendly (player ship or player defence station) within its own `repair_range`. If no valid target is found within range, the module idles and starts no cycle. On starting a cycle, the module emits a repair beam toward the chosen target (REQ-SHP-FIRING-BEAM) and begins a 0.15-second effect delay (half the beam duration); the module's cooldown of `1 / repair_rate` seconds begins at cycle start, not at effect application. When the delay expires, `repair_amount_hp` HP is restored to the targeted entity, clamped to its maximum HP — unless that entity is no longer damaged or has been destroyed, in which case the heal is silently dropped. A ship with multiple repair modules can therefore run multiple repair cycles concurrently, healing different targets. Navigation is driven solely by the behavior-level target; individual module fallback targets do not affect which direction the ship moves. Repair cycles are processed regardless of which behavior the ship is currently executing.
- REQ-SHP-STANDBY: **Ships with at least one repair module hold with their fleet when idle**, whether or not they also carry weapon modules. Standby is a low-priority fallback — above the baseline forward advance (REQ-SHP-COMBAT/REQ-SHP-ENEMY-AI advance) but below rally (REQ-SHP-RALLY), so it only wins when no attack, repair, salvage, rally, or retreat behavior applies. A standing-by ship navigates toward the centroid of its other same-faction ships, falling back to the centroid of its own defence stations, and holding position when it has no allies. This keeps repair ships among the allies they exist to heal instead of advancing alone into the enemy. Armed repair ships therefore still rally and depart on the normal schedule (REQ-SHP-RALLY); standby only governs them once rally no longer applies.
- REQ-SHP-RETREAT: **Player ships retreat to the rally point (REQ-SHP-RALLY) when threatened.** A ship retreats while either condition holds: (a) its HP is below a low-HP threshold (currently 30% of its maximum HP); or (b) it has no weapon modules and an enemy ship is within its sensor range — with one exception: a weaponless ship that has at least one repair module does **not** retreat under condition (b) while a damaged friendly (player ship or player defence station, excluding itself) is within its sensor range, so it can keep repairing under fire; it retreats only when no such repair target remains in range. Condition (a) still forces a low-HP repair ship to retreat regardless of available repair targets. Retreating takes priority over the ship's other behaviors and moves it toward the rally point; the ship resumes its normal behavior once neither condition holds. Enemy ships never retreat (REQ-SHP-ENEMY-AI).
- REQ-SHP-ENEMY-AI: **Enemy ships** — engage the closest valid target (player defence station, HQ, or player ship) within their sensor range, orbiting the engaged target at the combat orbit radius (REQ-SHP-ORBIT). If no target is in sensor range, they move toward the asteroid (leftward in world coordinates).
- REQ-SHP-TARGET-SELECT: **Combat target selection.** Both player combat ships (REQ-SHP-COMBAT) and enemy ships (REQ-SHP-ENEMY-AI) pick which hostile to engage by scoring every valid target (an opposing-faction ship, defence station, or HQ) within sensor range and engaging the highest-scoring one. A target's score is the product of a **base desirability** and an **overclaim penalty** (REQ-SHP-TARGET-CLAIM). The base desirability is `world.toml [targeting].target_score_formula` evaluated with `x` set to the target's distance from the ship divided by the ship's maximum weapon `attack_range` (falling back to sensor range for a ship with no weapon), clamped to a minimum of 0. The default formula `1 / (1 + x)` decreases with distance, so — absent any claims — the nearest target is chosen, realizing the closest-target priority referenced by REQ-SHP-COMBAT and REQ-SHP-ENEMY-AI. A ship engages at most one target at a time; all of its weapons fire on that target subject to their own range and rate checks (REQ-SHP-FIRING).
- REQ-SHP-TARGET-CLAIM: **Overclaim penalty.** To stop every ship from dogpiling the same hostile, each target a ship is currently engaging counts as a **claim** on that target. When scoring a candidate, its base desirability (REQ-SHP-TARGET-SELECT) is multiplied by `world.toml [targeting].overclaim_penalty_formula` evaluated with `x` set to the number of ships currently claiming that candidate — a ship never counts its own claim against the target it already holds — clamped to the range [0, 1]. The penalty is 1 (no reduction) at zero claims and decreases as claims accumulate, so heavily-claimed targets become less attractive and ships spread across the available hostiles. The default formula `max(0.5, 1 - 0.1*x)` reduces desirability by 0.1 per claim down to a floor of 0.5. Because claims reflect the previous tick's engagements, target distribution converges over successive ticks rather than instantaneously.
- REQ-SHP-TARGET-HYSTERESIS: **Target stickiness.** A ship keeps engaging its current target as long as that target remains valid and within sensor range, switching to a different target only when the best alternative's score exceeds the current target's score by more than the fractional margin `world.toml [targeting].target_hysteresis` (default 0.10). This prevents ships from rapidly oscillating between targets of near-equal score and preserves focus fire.
- REQ-SHP-SCHEMATICS: The player selects a schematic per shipyard by clicking it. New schematics are unlocked by destroying enemy defence station sets (REQ-DEF-SCHEMATIC-DROP) — there is no physical loot to collect. - REQ-SHP-SCHEMATICS: The player selects a schematic per shipyard by clicking it. New schematics are unlocked by destroying enemy defence station sets (REQ-DEF-SCHEMATIC-DROP) — there is no physical loot to collect.
## Ship Modules ## Ship Modules
@@ -184,10 +196,10 @@ 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. - `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. - `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. - `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`. - **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` (collections per second). - **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` (HP/s), `repair_range_formula` (tiles). - **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. - 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.
- REQ-MOD-LAYOUT: Each ship in `ships.toml` defines a `layout` — a list of strings representing the ship's module grid (see Ship Layout Format). All ships define a layout. - REQ-MOD-LAYOUT: Each ship in `ships.toml` defines a `layout` — a list of strings representing the ship's module grid (see Ship Layout Format). All ships define a layout.
@@ -225,12 +237,15 @@ 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.weapon]` — modifiers apply to every weapon module instance on the ship.
- `[module.salvage]` — modifiers apply to every salvage 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.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. 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 ### 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. - 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 selection button (REQ-UI-SELECT-BUTTON). 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.
- REQ-MOD-UI-DIALOG: Clicking the "Configure" button opens the **layout configuration dialog** as a modal. While the dialog is open, the game is paused (speed set to 0×). On close, the game speed is restored to what it was before the dialog was opened. - REQ-MOD-UI-DIALOG: Clicking the "Configure" button opens the **layout configuration dialog** as a modal. While the dialog is open, the game is paused (speed set to 0×). On close, the game speed is restored to what it was before the dialog was opened.
The dialog contains: The dialog contains:
@@ -250,11 +265,12 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- Maneuvering acceleration - Maneuvering acceleration
- Angular acceleration - Angular acceleration
- Max rotation speed - 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: 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. - **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.
- **Salvage** (shown only if at least one salvage module is placed): combined collection rate = Σ(collection_rate_i) across all salvage module instances; maximum range = max(collection_range_i) across all salvage module instances. - **Salvage** (shown only if at least one salvage module is placed): combined collection rate = Σ(collection_rate_i) across all salvage module instances; maximum range = max(collection_range_i) across all salvage module instances.
- **Repair** (shown only if at least one repair module is placed): combined repair rate = Σ(repair_rate_i) across all repair module instances; maximum range = max(repair_range_i) across all repair module instances. - **Repair** (shown only if at least one repair module is placed): combined repair rate (HP/s) = Σ(repair_rate_i × repair_amount_hp_i) across all repair module instances; maximum range = max(repair_range_i) across all repair module instances.
All capability module stat values incorporate passive modifiers targeting the relevant capability category per REQ-MOD-STAT-CALC. Each capability module instance uses its own `player_production_level` for formula evaluation. All capability module stat values incorporate passive modifiers targeting the relevant capability category per REQ-MOD-STAT-CALC. Each capability module instance uses its own `player_production_level` for formula evaluation.
@@ -300,7 +316,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
For a **ship or module schematic**: if the player does not yet have the schematic, it is unlocked (ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG)). If the player already has it, the schematic's `player_production_level` is incremented by 1 — for ship schematics, subsequent ships of that type are produced at a higher level; for module schematics, all instances of that module type use the higher level in their stat formulas. For a **ship or module schematic**: if the player does not yet have the schematic, it is unlocked (ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG)). If the player already has it, the schematic's `player_production_level` is incremented by 1 — for ship schematics, subsequent ships of that type are produced at a higher level; for module schematics, all instances of that module type use the higher level in their stat formulas.
For an **assembler recipe schematic**: the recipe is explicitly unlocked and becomes available in the assembler recipe-selection dropdown (subject to REQ-LOCK-UI-RECIPE). The schematic is removed from the drop pool permanently (REQ-LOCK-EXPLICIT). The implicit unlock set is recomputed (REQ-LOCK-IMPLICIT). For an **assembler recipe schematic**: the recipe is explicitly unlocked and becomes available in the assembler recipe-selection dialog (subject to REQ-LOCK-UI-RECIPE). The schematic is removed from the drop pool permanently (REQ-LOCK-EXPLICIT). The implicit unlock set is recomputed (REQ-LOCK-IMPLICIT).
## Progression & Locking ## Progression & Locking
@@ -308,15 +324,15 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-LOCK-IMPLICIT: Item types and miner/assembler recipes are **implicitly** unlocked or locked based on the current set of unlocked ship, module, and assembler recipe schematics. The implicit unlock set is recomputed whenever any schematic changes lock state (on Restart or after REQ-DEF-SCHEMATIC-DROP). Computation: - REQ-LOCK-IMPLICIT: Item types and miner/assembler recipes are **implicitly** unlocked or locked based on the current set of unlocked ship, module, and assembler recipe schematics. The implicit unlock set is recomputed whenever any schematic changes lock state (on Restart or after REQ-DEF-SCHEMATIC-DROP). Computation:
1. Start with the union of: (a) all item types listed in `materials` across all currently unlocked ship schematics and all currently unlocked module schematics, and (b) the output item type of every currently explicitly unlocked assembler recipe schematic (REQ-LOCK-EXPLICIT). 1. Start with the union of: (a) all item types listed in `materials` across all currently unlocked ship schematics and all currently unlocked module schematics, and (b) the output item type of every currently explicitly unlocked assembler recipe schematic (REQ-LOCK-EXPLICIT).
2. For each item type in the current set: for every recipe (miner, smelter, or assembler) that produces it — skipping any assembler recipe schematic that defines `unlock_at_station_level` and is not yet explicitly unlocked — add each of that recipe's input item types to the set. If the recipe is a miner recipe or an assembler recipe that does not define `unlock_at_station_level`, mark it as implicitly unlocked. Explicitly unlocked assembler recipe schematics are available in the assembler dropdown by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step. 2. For each item type in the current set: for every recipe (miner, smelter, or assembler) that produces it — skipping any assembler recipe schematic that defines `unlock_at_station_level` and is not yet explicitly unlocked — add each of that recipe's input item types to the set. If the recipe is a miner recipe or an assembler recipe that does not define `unlock_at_station_level`, mark it as implicitly unlocked. Explicitly unlocked assembler recipe schematics are available in the assembler recipe-selection dialog by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step.
3. Repeat step 2 until no new item types are added. 3. Repeat step 2 until no new item types are added.
Item types and miner/assembler recipes not reached by this process (and not explicitly unlocked) are locked. Smelter recipes participate in the traversal to propagate unlocking to their inputs but are never themselves shown in any UI dropdown. Item types and miner/assembler recipes not reached by this process (and not explicitly unlocked) are locked. Smelter recipes participate in the traversal to propagate unlocking to their inputs but are never themselves shown in any UI dropdown.
- REQ-LOCK-REPROCESSING-POOL: The pool of possible outputs for a Reprocessing Plant cycle (REQ-BLD-REPROCESSING) is restricted to item types that are currently implicitly unlocked (REQ-LOCK-IMPLICIT). Weights are renormalized over the eligible outputs. If no eligible outputs remain, the Reprocessing Plant cannot start a production cycle. - REQ-LOCK-REPROCESSING-POOL: The pool of possible outputs for a Reprocessing Plant cycle (REQ-BLD-REPROCESSING) is restricted to item types that are currently implicitly unlocked (REQ-LOCK-IMPLICIT). Weights are renormalized over the eligible outputs. If no eligible outputs remain, the Reprocessing Plant cannot start a production cycle.
- REQ-LOCK-UI-RECIPE: Locked miner ore-type recipes and assembler recipes are not shown in their respective recipe-selection dropdowns. - REQ-LOCK-UI-RECIPE: Locked miner ore-type recipes and assembler recipes are not shown in their respective recipe-selection dialogs (REQ-UI-SELECT-BUTTON).
- REQ-LOCK-UI-SCHEMATIC: Locked ship schematics are not shown in the shipyard's schematic-selection dropdown. - REQ-LOCK-UI-SCHEMATIC: Locked ship schematics are not shown in the shipyard's schematic-selection dialog (REQ-UI-SELECT-BUTTON).
- REQ-LOCK-UI-SPLITTER: Item types that are not implicitly unlocked are excluded from splitter filter dropdowns (REQ-BLD-SPLITTER). - REQ-LOCK-UI-SPLITTER: Item types that are not implicitly unlocked are excluded from splitter filter dropdowns (REQ-BLD-SPLITTER).
@@ -412,11 +428,15 @@ The screen is divided into two columns: a main column (75% width) containing the
### Selected Building Panel ### Selected Building Panel
- REQ-UI-EMPTY-SELECTION: When no building is selected, the panel is empty. - REQ-UI-EMPTY-SELECTION: When no building is selected, the panel is empty.
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs). - REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs). For a selected construction site, the recipe/schematic selection (and, for a shipyard, the layout preview and "Configure" button) are shown but the buffer rows are omitted (REQ-BLD-SITE-CONFIG).
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown. - REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown.
- REQ-UI-MULTI-SELECT: The player selects multiple buildings by box-drag or by Ctrl+clicking individual buildings to add or remove them from the selection. - REQ-UI-MULTI-SELECT: The player selects multiple buildings by box-drag or by Ctrl+clicking individual buildings to add or remove them from the selection.
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. - REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown.
- REQ-UI-CONFIG-INLINE: Recipe, schematic, ship stance, and target priority configuration for a selected building is shown and changed inline within this panel. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic dropdown (REQ-MOD-UI-PREVIEW). - REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel. Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selected building panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened). The dialog contains a grid of option buttons, one per selectable option — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Hovering an option button shows the selection info tooltip (REQ-UI-SELECT-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selected building panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
- REQ-UI-SELECT-TOOLTIP: **Selection info tooltip.** Hovering an option button in the selection dialog (REQ-UI-SELECT-BUTTON), and hovering the selection button in the selected building panel when a selection is set, displays an info tooltip:
- For a **recipe** (Miner or Assembler): the recipe name; the name and quantity of each input item (no inputs are listed for miner recipes, which consume nothing); the completion time (`duration_seconds`); and the name and quantity of the produced output item.
- For a **ship schematic** (Shipyard): the ship's `display_name`; the name and quantity of each base required material (`[ship.schematic].materials`, excluding any module contributions); the base production time (`[ship.schematic].production_time_seconds`); and "Produces: 1 <ship display name>".
- REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels. - REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels.
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. Clicking a ship or defence station clears any existing selection and establishes a single-entity selection containing only that entity. Ships and defence stations cannot participate in multi-select together with buildings. Clicking empty world space (no building, ship, or defence station) clears the selection. - REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. Clicking a ship or defence station clears any existing selection and establishes a single-entity selection containing only that entity. Ships and defence stations cannot participate in multi-select together with buildings. Clicking empty world space (no building, ship, or defence station) clears the selection.
- REQ-UI-SHIP-STATS-PANEL: When a single ship is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **ship stats panel**. The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed at the ship's actual level with its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT). - REQ-UI-SHIP-STATS-PANEL: When a single ship is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **ship stats panel**. The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed at the ship's actual level with its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
@@ -474,7 +494,7 @@ A separate executable target (`balancing`) that links against `lib` but contains
### Simulation ### Simulation
- REQ-BAL-SIM-ENV: Each arena simulates a pure-space environment using the same tick-based simulation as the main game. There is no asteroid, no buildings, no belts, no wave system, and no threat accumulation. Only ships, HQs, defence stations, and combat are active. - REQ-BAL-SIM-ENV: Each arena simulates a pure-space environment using the same tick-based simulation as the main game. There is no asteroid, no buildings, no belts, no wave system, and no threat accumulation. Only ships, HQs, defence stations, and combat are active.
- REQ-BAL-SIM-AI: Ships use the same AI and stats as in the main game. All ships use aggressive stance and closest-target priority. Ships with no target in sensor range advance toward the enemy team's HQ. Ships that detect an enemy in sensor range engage it as in the normal game (REQ-SHP-COMBAT, REQ-SHP-ENEMY-AI). - REQ-BAL-SIM-AI: Ships use the same AI and stats as in the main game. Ships with no target in sensor range advance toward the enemy team's HQ. Ships that detect an enemy in sensor range engage it as in the normal game (REQ-SHP-COMBAT, REQ-SHP-ENEMY-AI).
- REQ-BAL-SIM-SPEED: Each arena that is not being inspected runs its simulation at maximum tick rate (as many ticks per second as the hardware allows), with no rendering. An inspected arena runs at a player-controllable game speed (same speed steps as the main game: 0×, 0.5×, 1×, 2×, 4×) with full rendering in the inspect window, defaulting to 1× on open. - REQ-BAL-SIM-SPEED: Each arena that is not being inspected runs its simulation at maximum tick rate (as many ticks per second as the hardware allows), with no rendering. An inspected arena runs at a player-controllable game speed (same speed steps as the main game: 0×, 0.5×, 1×, 2×, 4×) with full rendering in the inspect window, defaulting to 1× on open.
- REQ-BAL-SIM-PARALLEL: All arenas are simulated in parallel, each on its own thread. - REQ-BAL-SIM-PARALLEL: All arenas are simulated in parallel, each on its own thread.
- REQ-BAL-SIM-END: An arena fight ends when either team's HQ is destroyed or all ships and defence stations of one team have been destroyed. If a team has no defence stations, destroying all its ships is sufficient. When the fight ends, the simulation for that arena stops. - REQ-BAL-SIM-END: An arena fight ends when either team's HQ is destroyed or all ships and defence stations of one team have been destroyed. If a team has no defence stations, destroying all its ships is sufficient. When the fight ends, the simulation for that arena stops.
@@ -484,7 +504,7 @@ A separate executable target (`balancing`) that links against `lib` but contains
- REQ-BAL-UI-WINDOW: On startup the tool displays a window containing a "Reload Config" button and a "Start All" button at the top (in that order, left to right), followed by a scrollable vertical list of arena widgets, one per arena defined in `balancing.toml`. Simulations do not start automatically on startup. All buttons and controls in the main window are disabled while an arena is being inspected (REQ-BAL-UI-INSPECT). - REQ-BAL-UI-WINDOW: On startup the tool displays a window containing a "Reload Config" button and a "Start All" button at the top (in that order, left to right), followed by a scrollable vertical list of arena widgets, one per arena defined in `balancing.toml`. Simulations do not start automatically on startup. All buttons and controls in the main window are disabled while an arena is being inspected (REQ-BAL-UI-INSPECT).
- REQ-BAL-UI-RELOAD: The "Reload Config" button reloads all config files from disk (`balancing.toml`, `ships.toml`, `stations.toml`), stops any running simulations, and replaces the arena widget list with freshly created widgets from the reloaded config. The button is disabled while any arena simulation is currently running. - REQ-BAL-UI-RELOAD: The "Reload Config" button reloads all config files from disk (`balancing.toml`, `ships.toml`, `stations.toml`), stops any running simulations, and replaces the arena widget list with freshly created widgets from the reloaded config. The button is disabled while any arena simulation is currently running.
- REQ-BAL-UI-START-ALL: The "Start All" button is placed above the scrollable arena list, to the right of the "Reload Config" button. Clicking it starts (or restarts) the simulation for every arena that is not currently running. The button is disabled when all arenas are currently running. - REQ-BAL-UI-START-ALL: The "Start All" button is placed above the scrollable arena list, to the right of the "Reload Config" button. Clicking it starts (or restarts) the simulation for every arena that is not currently running. The button is disabled when all arenas are currently running.
- REQ-BAL-UI-WIDGET: Each arena widget displays the arena name, an "Inspect" button (to the right of the arena name), and two columns (one per team). Each column shows the team name as a header, followed by a list of entries. The HQ is always the first entry in each column. Below the HQ, ship types are listed, followed by defence stations (if any). Each entry uses the format `surviving/total TypeName Llevel` — for example `2/3 Fighter L5` or `1/1 HQ L1`. The surviving count updates live as the simulation progresses. When the fight ends, the winning team's name header is prefixed with `[WON]`. - REQ-BAL-UI-WIDGET: Each arena widget displays the arena name, an "Inspect" button (to the right of the arena name), and two columns (one per team). Each column shows the team name as a header, then directly below the header the team's **accumulated threat level** — the sum, across the team's configured ship entries, of each entry's `count` multiplied by the threat cost (REQ-MOD-THREAT) of one ship of that entry computed from its level-independent module layout. Only ships contribute; the HQ and defence stations are excluded. This value is static: it is computed once from the full configured roster and does not change as ships are destroyed during the fight. Below the threat level, the column shows a list of entries. The HQ is always the first entry in each column. Below the HQ, ship types are listed, followed by defence stations (if any). Each entry uses the format `surviving/total TypeName Llevel` — for example `2/3 Fighter L5` or `1/1 HQ L1`. The surviving count updates live as the simulation progresses. When the fight ends, the winning team's name header is prefixed with `[WON]`.
- REQ-BAL-UI-WIDGET-START: Each arena widget contains a "Start" button that starts the simulation for that arena. The button is disabled while the arena's simulation is running. When a finished arena's Start button is clicked, a fresh simulation is created and started (the widget resets to initial unit counts, the border returns to blue, and the previous results are replaced). - REQ-BAL-UI-WIDGET-START: Each arena widget contains a "Start" button that starts the simulation for that arena. The button is disabled while the arena's simulation is running. When a finished arena's Start button is clicked, a fresh simulation is created and started (the widget resets to initial unit counts, the border returns to blue, and the previous results are replaced).
- REQ-BAL-UI-WIDGET-BORDER: Each arena widget has a colored border indicating its state: grey when not yet started, blue while its simulation is running, and green when the fight has ended. - REQ-BAL-UI-WIDGET-BORDER: Each arena widget has a colored border indicating its state: grey when not yet started, blue while its simulation is running, and green when the fight has ended.
- REQ-BAL-UI-INSPECT: Clicking an arena widget's "Inspect" button opens a new inspect window for that arena. Any previously open inspect window is closed first (its arena's simulation is aborted and its widget border returns to grey). The inspected arena is restarted with a fresh simulation that runs at controllable game speed with full rendering (REQ-BAL-SIM-SPEED). The arena widget updates live during inspection (surviving counts, border color, `[WON]` prefix) as it does for non-inspected arenas. Only one inspect window may be open at a time. - REQ-BAL-UI-INSPECT: Clicking an arena widget's "Inspect" button opens a new inspect window for that arena. Any previously open inspect window is closed first (its arena's simulation is aborted and its widget border returns to grey). The inspected arena is restarted with a fresh simulation that runs at controllable game speed with full rendering (REQ-BAL-SIM-SPEED). The arena widget updates live during inspection (surviving counts, border color, `[WON]` prefix) as it does for non-inspected arenas. Only one inspect window may be open at a time.

View File

@@ -14,9 +14,12 @@
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "FactionComponent.h" #include "FactionComponent.h"
#include "HealthComponent.h" #include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "ModuleOwnerComponent.h" #include "ModuleOwnerComponent.h"
#include "MovementIntentSystem.h" #include "MovementIntentSystem.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "RepairSystem.h"
#include "SalvagerSystem.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
#include "ShipIdentityComponent.h" #include "ShipIdentityComponent.h"
#include "ShipSystem.h" #include "ShipSystem.h"
@@ -24,6 +27,7 @@
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "StationsConfig.h" #include "StationsConfig.h"
#include "SurfaceMask.h" #include "SurfaceMask.h"
#include "ThreatCostCalculator.h"
#include "WeaponComponent.h" #include "WeaponComponent.h"
ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig, ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
@@ -51,11 +55,34 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
m_rng); m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_gameConfig, m_admin); m_shipSystem = std::make_unique<ShipSystem>(m_gameConfig, m_admin);
m_aiSystem = std::make_unique<AiSystem>(); // Arena fights are symmetric and aggressive: player-faction ships must not
// retreat (REQ-BAL-SIM-AI). Only one faction would otherwise get retreat.
m_shipSystem->setRetreatEnabled(false);
m_aiSystem = std::make_unique<AiSystem>(m_gameConfig);
m_movementIntentSystem = std::make_unique<MovementIntentSystem>(); m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>(); m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
m_combatSystem = std::make_unique<CombatSystem>(m_gameConfig); m_combatSystem = std::make_unique<CombatSystem>(m_gameConfig);
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin); m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
m_salvagerSystem = std::make_unique<SalvagerSystem>(m_admin);
m_repairSystem = std::make_unique<RepairSystem>(m_admin);
// Static accumulated threat per team: sum of count * per-ship threat cost
// (REQ-MOD-THREAT) over the configured ship roster. Ships only; HQ and
// defence stations are excluded. Level-independent, so computed once here.
for (int ti = 0; ti < 2; ++ti)
{
double teamThreat = 0.0;
for (const ArenaShipEntry& shipEntry : m_arenaConfig.teams[ti].ships)
{
const std::vector<PlacedModule>& modules = shipEntry.layout
? shipEntry.layout->placedModules
: std::vector<PlacedModule>{};
const double shipThreat = calculateShipThreatCost(
m_gameConfig.threatCosts, m_gameConfig, shipEntry.schematicId, modules);
teamThreat += shipThreat * shipEntry.count;
}
m_teamThreat[ti] = teamThreat;
}
placeStructures(); placeStructures();
spawnShips(); spawnShips();
@@ -95,6 +122,8 @@ void ArenaSimulation::placeStructures()
} }
m_team1HqEntity = m_admin.spawnStation(anchor, hqParsed.footprint, absCells, m_team1HqEntity = m_admin.spawnStation(anchor, hqParsed.footprint, absCells,
hp, hp, false); hp, hp, false);
// Tag as an HQ so it is excluded from repair targeting (REQ-SHP-REPAIR).
m_admin.addComponent<HqProxyComponent>(m_team1HqEntity);
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId()); m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
} }
@@ -114,6 +143,8 @@ void ArenaSimulation::placeStructures()
} }
m_team2HqEntity = m_admin.spawnStation(anchor, hqParsed.footprint, absCells, m_team2HqEntity = m_admin.spawnStation(anchor, hqParsed.footprint, absCells,
hp, hp, true); hp, hp, true);
// Tag as an HQ so it is excluded from repair targeting (REQ-SHP-REPAIR).
m_admin.addComponent<HqProxyComponent>(m_team2HqEntity);
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId()); m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
} }
@@ -250,18 +281,17 @@ ArenaStatus ArenaSimulation::status() const
void ArenaSimulation::tick() void ArenaSimulation::tick()
{ {
// Ship behavior systems (tick step 7). // Ship behavior systems (tick step 7): evaluate, select winner, execute.
// Module + combat systems emit their tool beams into a shared buffer.
m_shipSystem->clearMovementIntents(); m_shipSystem->clearMovementIntents();
m_aiSystem->tickHomeReturnBehavior(m_admin); m_aiSystem->tick(m_admin, *m_buildingSystem, *m_scrapSystem);
m_aiSystem->tickThreatResponseBehavior(m_admin, *m_buildingSystem); std::vector<BeamFiredEvent> beamFiredEvents;
m_aiSystem->tickRepairBehavior(m_admin, *m_buildingSystem); m_salvagerSystem->tick(m_currentTick, *m_scrapSystem, *m_buildingSystem, beamFiredEvents);
m_aiSystem->tickRepairTools(m_admin); m_repairSystem->tick(m_currentTick, beamFiredEvents);
m_aiSystem->tickSalvageBehavior(m_admin, *m_scrapSystem, *m_buildingSystem);
// Combat resolution (tick step 8). // Combat resolution (tick step 8).
std::vector<WeaponFiredEvent> weaponFiredEvents; m_combatSystem->tick(m_currentTick, m_admin, *m_buildingSystem, beamFiredEvents);
m_combatSystem->tick(m_currentTick, m_admin, *m_buildingSystem, weaponFiredEvents); m_beamFiredEvents.insert(m_beamFiredEvents.end(), beamFiredEvents.begin(), beamFiredEvents.end());
m_weaponFiredEvents.insert(m_weaponFiredEvents.end(), weaponFiredEvents.begin(), weaponFiredEvents.end());
m_combatSystem->applyPendingDamage(m_currentTick, m_admin); m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
// Deaths (tick step 9, simplified). // Deaths (tick step 9, simplified).
@@ -393,10 +423,10 @@ void ArenaSimulation::tickOnce()
} }
} }
std::vector<WeaponFiredEvent> ArenaSimulation::drainWeaponFiredEvents() std::vector<BeamFiredEvent> ArenaSimulation::drainBeamFiredEvents()
{ {
std::vector<WeaponFiredEvent> result; std::vector<BeamFiredEvent> result;
result.swap(m_weaponFiredEvents); result.swap(m_beamFiredEvents);
return result; return result;
} }
@@ -455,6 +485,7 @@ void ArenaSimulation::updateStatus()
{ {
ArenaStatus::TeamStatus& teamStatus = newStatus.teams[ti]; ArenaStatus::TeamStatus& teamStatus = newStatus.teams[ti];
teamStatus.name = m_arenaConfig.teams[ti].name; teamStatus.name = m_arenaConfig.teams[ti].name;
teamStatus.threatLevel = m_teamThreat[ti];
// HQ entry (always first). // HQ entry (always first).
{ {

View File

@@ -13,7 +13,7 @@
#include "BuildingId.h" #include "BuildingId.h"
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
#include "WeaponFiredEvent.h" #include "BeamFiredEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Tick.h" #include "Tick.h"
@@ -22,6 +22,8 @@ class BuildingSystem;
class CombatSystem; class CombatSystem;
class DynamicBodySystem; class DynamicBodySystem;
class MovementIntentSystem; class MovementIntentSystem;
class RepairSystem;
class SalvagerSystem;
class ShipSystem; class ShipSystem;
class ScrapSystem; class ScrapSystem;
@@ -38,6 +40,7 @@ struct ArenaStatus
struct TeamStatus struct TeamStatus
{ {
std::string name; std::string name;
double threatLevel = 0.0; // accumulated threat of the team's configured ships
std::vector<Entry> entries; // HQ first, then ships, then stations std::vector<Entry> entries; // HQ first, then ships, then stations
}; };
@@ -58,7 +61,7 @@ public:
void requestStop(); void requestStop();
void tickOnce(); void tickOnce();
std::vector<WeaponFiredEvent> drainWeaponFiredEvents(); std::vector<BeamFiredEvent> drainBeamFiredEvents();
ArenaStatus status() const; ArenaStatus status() const;
bool isFinished() const; bool isFinished() const;
@@ -96,6 +99,8 @@ private:
std::unique_ptr<DynamicBodySystem> m_dynamicBodySystem; std::unique_ptr<DynamicBodySystem> m_dynamicBodySystem;
std::unique_ptr<CombatSystem> m_combatSystem; std::unique_ptr<CombatSystem> m_combatSystem;
std::unique_ptr<ScrapSystem> m_scrapSystem; std::unique_ptr<ScrapSystem> m_scrapSystem;
std::unique_ptr<SalvagerSystem> m_salvagerSystem;
std::unique_ptr<RepairSystem> m_repairSystem;
entt::entity m_team1HqEntity; entt::entity m_team1HqEntity;
entt::entity m_team2HqEntity; entt::entity m_team2HqEntity;
@@ -104,7 +109,10 @@ private:
int m_winnerTeam; int m_winnerTeam;
std::atomic<bool> m_stopRequested; std::atomic<bool> m_stopRequested;
std::vector<WeaponFiredEvent> m_weaponFiredEvents; // Static accumulated threat per team, computed once from the configured roster.
double m_teamThreat[2] = {0.0, 0.0};
std::vector<BeamFiredEvent> m_beamFiredEvents;
mutable std::mutex m_statusMutex; mutable std::mutex m_statusMutex;
ArenaStatus m_status; ArenaStatus m_status;

View File

@@ -2,13 +2,16 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <functional>
#include <optional> #include <optional>
#include <QKeyEvent>
#include <QMouseEvent> #include <QMouseEvent>
#include <QPainter> #include <QPainter>
#include <QPoint> #include <QPoint>
#include "ArenaSimulation.h" #include "ArenaSimulation.h"
#include "AttackBehavior.h"
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "EntityHitTest.h" #include "EntityHitTest.h"
@@ -19,9 +22,13 @@
#include "GameSpeedChangedEvent.h" #include "GameSpeedChangedEvent.h"
#include "HealthComponent.h" #include "HealthComponent.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "RepairBehavior.h"
#include "SalvageScrapBehavior.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
#include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h" #include "ShipIdentityComponent.h"
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "ScrapDataComponent.h"
namespace namespace
{ {
@@ -33,11 +40,11 @@ ArenaView::ArenaView(ArenaSimulation* sim, const VisualsConfig* visuals,
: QOpenGLWidget(parent) : QOpenGLWidget(parent)
, m_sim(sim) , m_sim(sim)
, m_visuals(visuals) , m_visuals(visuals)
, m_wallMs(0)
, m_gameSpeedMultiplier(1.0) , m_gameSpeedMultiplier(1.0)
, m_prevNonZeroSpeed(1.0) , m_prevNonZeroSpeed(1.0)
, m_rng(std::random_device{}()) , m_rng(std::random_device{}())
, m_finishedEmitted(false) , m_finishedEmitted(false)
, m_debugDraw(false)
{ {
setFocusPolicy(Qt::StrongFocus); setFocusPolicy(Qt::StrongFocus);
@@ -91,7 +98,6 @@ void ArenaView::togglePause()
void ArenaView::onFrame() void ArenaView::onFrame()
{ {
const qint64 elapsed = m_frameTimer.restart(); const qint64 elapsed = m_frameTimer.restart();
m_wallMs += elapsed;
{ {
const int ticks = m_tickDriver.advance( const int ticks = m_tickDriver.advance(
@@ -104,20 +110,22 @@ void ArenaView::onFrame()
// Emit fire events via EventManager // Emit fire events via EventManager
{ {
const std::vector<WeaponFiredEvent> fires = m_sim->drainWeaponFiredEvents(); const std::vector<BeamFiredEvent> fires = m_sim->drainBeamFiredEvents();
for (const WeaponFiredEvent& fe : fires) for (const BeamFiredEvent& fe : fires)
{ {
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<WeaponFiredEvent>(fe)); std::make_shared<BeamFiredEvent>(fe));
} }
} }
// Expire old beams // Expire old beams. Lifetime is measured in game ticks so beams stay
// visible while the simulation is paused or slowed (REQ-SHP-FIRING-BEAM).
{ {
const Tick now = m_sim->currentTick();
std::vector<ActiveBeam> live; std::vector<ActiveBeam> live;
for (const ActiveBeam& b : m_activeBeams) for (const ActiveBeam& b : m_activeBeams)
{ {
if (m_wallMs - b.emittedWallMs < kBeamLifetimeMs) if (now - b.event.emittedAt < kBeamLifetimeTicks)
{ {
live.push_back(b); live.push_back(b);
} }
@@ -133,7 +141,7 @@ void ArenaView::onFrame()
update(); update();
} }
void ArenaView::handleEvent(std::shared_ptr<const WeaponFiredEvent> event) void ArenaView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
{ {
float maxRadius = 0.125f; float maxRadius = 0.125f;
if (m_sim->admin().isValid(event->target) if (m_sim->admin().isValid(event->target)
@@ -144,6 +152,11 @@ void ArenaView::handleEvent(std::shared_ptr<const WeaponFiredEvent> event)
sb.footprint.height()); sb.footprint.height());
maxRadius = shorter / 2.0f; maxRadius = shorter / 2.0f;
} }
else if (m_sim->admin().isValid(event->target)
&& m_sim->admin().hasAll<ScrapDataComponent>(event->target))
{
maxRadius = 0.1f;
}
std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f); std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f);
std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius); std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius);
@@ -152,7 +165,6 @@ void ArenaView::handleEvent(std::shared_ptr<const WeaponFiredEvent> event)
ActiveBeam beam; ActiveBeam beam;
beam.event = *event; beam.event = *event;
beam.emittedWallMs = m_wallMs;
beam.targetOffset = QVector2D(radius * std::cos(angle), beam.targetOffset = QVector2D(radius * std::cos(angle),
radius * std::sin(angle)); radius * std::sin(angle));
m_activeBeams.push_back(beam); m_activeBeams.push_back(beam);
@@ -167,6 +179,11 @@ void ArenaView::paintGL()
drawBuildings(painter); drawBuildings(painter);
drawStations(painter); drawStations(painter);
drawScrap(painter); drawScrap(painter);
if (m_debugDraw)
{
drawDebugSensorRanges(painter);
drawDebugTargetLines(painter);
}
drawShips(painter); drawShips(painter);
drawBeams(painter); drawBeams(painter);
} }
@@ -249,6 +266,16 @@ void ArenaView::mousePressEvent(QMouseEvent* event)
QOpenGLWidget::mousePressEvent(event); QOpenGLWidget::mousePressEvent(event);
} }
void ArenaView::keyPressEvent(QKeyEvent* event)
{
if (event->key() == Qt::Key_M)
{
m_debugDraw = !m_debugDraw;
return;
}
QOpenGLWidget::keyPressEvent(event);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Rendering // Rendering
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -418,14 +445,106 @@ void ArenaView::drawShips(QPainter& painter)
}); });
} }
void ArenaView::drawDebugSensorRanges(QPainter& painter)
{
painter.setBrush(Qt::NoBrush);
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const SensorRangeComponent& sensor)
{
const std::map<std::string, ShipVisuals>::const_iterator it =
m_visuals->ships.find(si.schematicId);
if (it == m_visuals->ships.end()) { return; }
const QPointF center = worldToWidget(pos.value);
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
* static_cast<qreal>(tilePx());
QColor circleColor = it->second.outline;
circleColor.setAlpha(77);
painter.setPen(QPen(circleColor, 1));
painter.drawEllipse(center, radiusPx, radiusPx);
});
}
void ArenaView::drawDebugTargetLines(QPainter& painter)
{
// Draw a thin translucent line from a ship to a target, colored by the ship's
// team to match the per-side HQ/station colors used elsewhere in the arena
// (team 1 player, team 2 enemy). Shared by the attack, repair and salvage lines.
const std::function<void(bool, const QVector2D&, const QVector2D&)> drawTargetLine =
[&](bool isEnemy, const QVector2D& from, const QVector2D& to)
{
const BuildingType visType = isEnemy
? BuildingType::EnemyDefenceStation
: BuildingType::PlayerDefenceStation;
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
m_visuals->buildings.find(visType);
if (it == m_visuals->buildings.end()) { return; }
QColor lineColor = it->second.fill;
lineColor.setAlpha(128);
painter.setPen(QPen(lineColor, 1));
painter.drawLine(worldToWidget(from), worldToWidget(to));
};
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent,
FactionComponent, AttackBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& fac,
const AttackBehavior& attack)
{
if (!attack.currentTarget.has_value()) { return; }
const std::optional<QVector2D> targetPos =
entityPosition(*attack.currentTarget);
if (!targetPos.has_value()) { return; }
drawTargetLine(fac.isEnemy, pos.value, *targetPos);
});
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent,
FactionComponent, RepairBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& fac,
const RepairBehavior& repair)
{
if (!repair.currentTarget.has_value()) { return; }
const std::optional<QVector2D> targetPos =
entityPosition(*repair.currentTarget);
if (!targetPos.has_value()) { return; }
drawTargetLine(fac.isEnemy, pos.value, *targetPos);
});
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent,
FactionComponent, SalvageScrapBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& fac,
const SalvageScrapBehavior& salvage)
{
if (!salvage.scrapTarget.has_value()) { return; }
drawTargetLine(fac.isEnemy, pos.value, *salvage.scrapTarget);
});
}
void ArenaView::drawBeams(QPainter& painter) void ArenaView::drawBeams(QPainter& painter)
{ {
painter.setPen(QPen(m_visuals->beams.color, m_visuals->beams.widthPx));
for (const ActiveBeam& beam : m_activeBeams) for (const ActiveBeam& beam : m_activeBeams)
{ {
const std::optional<QVector2D> shooterPos = entityPosition(beam.event.shooter); const std::optional<QVector2D> shooterPos = entityPosition(beam.event.shooter);
const std::optional<QVector2D> targetPos = entityPosition(beam.event.target); const std::optional<QVector2D> targetPos = entityPosition(beam.event.target);
if (!shooterPos.has_value() || !targetPos.has_value()) { continue; } if (!shooterPos.has_value() || !targetPos.has_value()) { continue; }
QColor color = m_visuals->beams.weaponColor;
switch (beam.event.kind)
{
case BeamKind::Weapon: color = m_visuals->beams.weaponColor; break;
case BeamKind::Repair: color = m_visuals->beams.repairColor; break;
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
}
painter.setPen(QPen(color, m_visuals->beams.widthPx));
painter.drawLine(worldToWidget(*shooterPos), painter.drawLine(worldToWidget(*shooterPos),
worldToWidget(*targetPos + beam.targetOffset)); worldToWidget(*targetPos + beam.targetOffset));
} }

View File

@@ -10,7 +10,7 @@
#include <QVector2D> #include <QVector2D>
#include "EventHandler.h" #include "EventHandler.h"
#include "WeaponFiredEvent.h" #include "BeamFiredEvent.h"
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
#include "EntitySelectedEvent.h" #include "EntitySelectedEvent.h"
@@ -22,7 +22,7 @@ class ArenaSimulation;
class QPainter; class QPainter;
class ArenaView : public QOpenGLWidget, class ArenaView : public QOpenGLWidget,
public EventHandler<WeaponFiredEvent> public EventHandler<BeamFiredEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -39,18 +39,21 @@ public:
protected: protected:
void paintGL() override; void paintGL() override;
void mousePressEvent(QMouseEvent* event) override; void mousePressEvent(QMouseEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
private slots: private slots:
void onFrame(); void onFrame();
private: private:
void handleEvent(std::shared_ptr<const WeaponFiredEvent> event) override; void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
void drawTiles(QPainter& painter); void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter); void drawBuildings(QPainter& painter);
void drawStations(QPainter& painter); void drawStations(QPainter& painter);
void drawScrap(QPainter& painter); void drawScrap(QPainter& painter);
void drawShips(QPainter& painter); void drawShips(QPainter& painter);
void drawDebugSensorRanges(QPainter& painter);
void drawDebugTargetLines(QPainter& painter);
void drawBeams(QPainter& painter); void drawBeams(QPainter& painter);
float tilePx() const; float tilePx() const;
@@ -63,19 +66,19 @@ private:
struct ActiveBeam struct ActiveBeam
{ {
WeaponFiredEvent event; BeamFiredEvent event;
qint64 emittedWallMs;
QVector2D targetOffset; QVector2D targetOffset;
}; };
static constexpr qint64 kBeamLifetimeMs = 300; // Beam lifetime in game ticks so beams freeze with the simulation when
// paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM).
static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3);
ArenaSimulation* m_sim; ArenaSimulation* m_sim;
const VisualsConfig* m_visuals; const VisualsConfig* m_visuals;
TickDriver m_tickDriver; TickDriver m_tickDriver;
QElapsedTimer m_frameTimer; QElapsedTimer m_frameTimer;
qint64 m_wallMs;
std::mt19937 m_rng; std::mt19937 m_rng;
double m_gameSpeedMultiplier; double m_gameSpeedMultiplier;
double m_prevNonZeroSpeed; double m_prevNonZeroSpeed;
@@ -86,4 +89,6 @@ private:
bool m_finishedEmitted; bool m_finishedEmitted;
std::optional<entt::entity> m_selectedEntity; std::optional<entt::entity> m_selectedEntity;
bool m_debugDraw;
}; };

View File

@@ -61,6 +61,8 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
headerFont.setBold(true); headerFont.setBold(true);
m_team1Header->setFont(headerFont); m_team1Header->setFont(headerFont);
team1Layout->addWidget(m_team1Header); team1Layout->addWidget(m_team1Header);
m_team1Threat = new QLabel(this);
team1Layout->addWidget(m_team1Threat);
m_team1Content = new QLabel(this); m_team1Content = new QLabel(this);
team1Layout->addWidget(m_team1Content); team1Layout->addWidget(m_team1Content);
team1Layout->addStretch(); team1Layout->addStretch();
@@ -71,6 +73,8 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
m_team2Header = new QLabel(this); m_team2Header = new QLabel(this);
m_team2Header->setFont(headerFont); m_team2Header->setFont(headerFont);
team2Layout->addWidget(m_team2Header); team2Layout->addWidget(m_team2Header);
m_team2Threat = new QLabel(this);
team2Layout->addWidget(m_team2Threat);
m_team2Content = new QLabel(this); m_team2Content = new QLabel(this);
team2Layout->addWidget(m_team2Content); team2Layout->addWidget(m_team2Content);
team2Layout->addStretch(); team2Layout->addStretch();
@@ -101,6 +105,7 @@ void ArenaWidget::updateStatus(const ArenaStatus& status)
{ {
const ArenaStatus::TeamStatus& team = status.teams[ti]; const ArenaStatus::TeamStatus& team = status.teams[ti];
QLabel* header = (ti == 0) ? m_team1Header : m_team2Header; QLabel* header = (ti == 0) ? m_team1Header : m_team2Header;
QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat;
QLabel* content = (ti == 0) ? m_team1Content : m_team2Content; QLabel* content = (ti == 0) ? m_team1Content : m_team2Content;
if (status.finished && status.winnerTeam == ti) if (status.finished && status.winnerTeam == ti)
@@ -112,6 +117,8 @@ void ArenaWidget::updateStatus(const ArenaStatus& status)
header->setText(QString::fromStdString(team.name)); header->setText(QString::fromStdString(team.name));
} }
threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0)));
QString lines; QString lines;
for (const ArenaStatus::Entry& entry : team.entries) for (const ArenaStatus::Entry& entry : team.entries)
{ {

View File

@@ -27,6 +27,8 @@ private:
QLabel* m_titleLabel; QLabel* m_titleLabel;
QLabel* m_team1Header; QLabel* m_team1Header;
QLabel* m_team2Header; QLabel* m_team2Header;
QLabel* m_team1Threat;
QLabel* m_team2Threat;
QLabel* m_team1Content; QLabel* m_team1Content;
QLabel* m_team2Content; QLabel* m_team2Content;
QPushButton* m_inspectButton; QPushButton* m_inspectButton;

View File

@@ -91,6 +91,8 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
headerFont.setBold(true); headerFont.setBold(true);
m_team1Header->setFont(headerFont); m_team1Header->setFont(headerFont);
team1Layout->addWidget(m_team1Header); team1Layout->addWidget(m_team1Header);
m_team1Threat = new QLabel(infoPanel);
team1Layout->addWidget(m_team1Threat);
m_team1Content = new QLabel(infoPanel); m_team1Content = new QLabel(infoPanel);
team1Layout->addWidget(m_team1Content); team1Layout->addWidget(m_team1Content);
team1Layout->addStretch(); team1Layout->addStretch();
@@ -100,6 +102,8 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
m_team2Header = new QLabel(infoPanel); m_team2Header = new QLabel(infoPanel);
m_team2Header->setFont(headerFont); m_team2Header->setFont(headerFont);
team2Layout->addWidget(m_team2Header); team2Layout->addWidget(m_team2Header);
m_team2Threat = new QLabel(infoPanel);
team2Layout->addWidget(m_team2Threat);
m_team2Content = new QLabel(infoPanel); m_team2Content = new QLabel(infoPanel);
team2Layout->addWidget(m_team2Content); team2Layout->addWidget(m_team2Content);
team2Layout->addStretch(); team2Layout->addStretch();
@@ -198,6 +202,7 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
{ {
const ArenaStatus::TeamStatus& team = status.teams[ti]; const ArenaStatus::TeamStatus& team = status.teams[ti];
QLabel* header = (ti == 0) ? m_team1Header : m_team2Header; QLabel* header = (ti == 0) ? m_team1Header : m_team2Header;
QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat;
QLabel* content = (ti == 0) ? m_team1Content : m_team2Content; QLabel* content = (ti == 0) ? m_team1Content : m_team2Content;
if (status.finished && status.winnerTeam == ti) if (status.finished && status.winnerTeam == ti)
@@ -209,6 +214,8 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
header->setText(QString::fromStdString(team.name)); header->setText(QString::fromStdString(team.name));
} }
threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0)));
QString lines; QString lines;
for (const ArenaStatus::Entry& entry : team.entries) for (const ArenaStatus::Entry& entry : team.entries)
{ {

View File

@@ -56,6 +56,8 @@ private:
std::vector<QPushButton*> m_speedButtons; std::vector<QPushButton*> m_speedButtons;
QLabel* m_team1Header; QLabel* m_team1Header;
QLabel* m_team2Header; QLabel* m_team2Header;
QLabel* m_team1Threat;
QLabel* m_team2Threat;
QLabel* m_team1Content; QLabel* m_team1Content;
QLabel* m_team2Content; QLabel* m_team2Content;
QTimer* m_pollTimer; QTimer* m_pollTimer;

View File

@@ -268,6 +268,8 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
cfg.beltSpeed_tps = requireDouble(tbl["world"]["belt_speed_mps"], file, "world.belt_speed_mps") / cfg.tileSize_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")); cfg.tunnelMaxDistance_tiles = static_cast<int>(requireInt(tbl["world"]["tunnel_max_distance_tiles"], file, "world.tunnel_max_distance_tiles"));
cfg.departureIntervalSeconds = requireDouble(tbl["world"]["departure_interval_seconds"], file, "world.departure_interval_seconds"); cfg.departureIntervalSeconds = requireDouble(tbl["world"]["departure_interval_seconds"], file, "world.departure_interval_seconds");
cfg.orbitFactor = requireDouble(tbl["world"]["orbit_factor"], file, "world.orbit_factor");
cfg.rallyOrbitRadius_tiles = requireDouble(tbl["world"]["rally_orbit_radius_tiles"], file, "world.rally_orbit_radius_tiles");
cfg.regions.asteroidWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["asteroid_width_tiles"], file, "regions.asteroid_width_tiles")); cfg.regions.asteroidWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["asteroid_width_tiles"], file, "regions.asteroid_width_tiles"));
cfg.regions.playerBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["player_buffer_width_tiles"], file, "regions.player_buffer_width_tiles")); cfg.regions.playerBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["player_buffer_width_tiles"], file, "regions.player_buffer_width_tiles"));
@@ -295,6 +297,10 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
throw makeError(file, "waves", "gap_min_seconds > gap_max_seconds"); throw makeError(file, "waves", "gap_min_seconds > gap_max_seconds");
} }
cfg.targeting.targetScoreFormula = requireFormula(tbl["targeting"]["target_score_formula"], file, "targeting.target_score_formula");
cfg.targeting.overclaimPenaltyFormula = requireFormula(tbl["targeting"]["overclaim_penalty_formula"], file, "targeting.overclaim_penalty_formula");
cfg.targeting.hysteresis = requireDouble(tbl["targeting"]["target_hysteresis"], file, "targeting.target_hysteresis");
return cfg; return cfg;
} }
@@ -540,8 +546,8 @@ static const StatEntry kKnownStats[] = {
{"weapon", "attack_range", "_m"}, {"weapon", "attack_range", "_m"},
{"weapon", "attack_rate", "_hz"}, {"weapon", "attack_rate", "_hz"},
{"salvage", "collection_range", "_m"}, {"salvage", "collection_range", "_m"},
{"salvage", "cargo_capacity", ""},
{"salvage", "collection_rate", "_hz"}, {"salvage", "collection_rate", "_hz"},
{"cargo", "cargo_capacity", ""},
{"repair", "repair_rate", "_hz"}, {"repair", "repair_rate", "_hz"},
{"repair", "repair_range", "_m"}, {"repair", "repair_range", "_m"},
}; };
@@ -674,6 +680,8 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
ModuleRepairCapability cap; ModuleRepairCapability cap;
cap.repairRateFormula = requireFormula(rMt["repair_rate_hz_formula"], cap.repairRateFormula = requireFormula(rMt["repair_rate_hz_formula"],
file, rPath + ".repair_rate_hz_formula"); file, rPath + ".repair_rate_hz_formula");
cap.repairAmountHpFormula = requireFormula(rMt["repair_amount_hp_formula"],
file, rPath + ".repair_amount_hp_formula");
cap.repairRangeFormula = requireFormula(rMt["repair_range_m_formula"], cap.repairRangeFormula = requireFormula(rMt["repair_range_m_formula"],
file, rPath + ".repair_range_m_formula"); file, rPath + ".repair_range_m_formula");
def.repairCapability = std::move(cap); def.repairCapability = std::move(cap);

View File

@@ -4,6 +4,14 @@
#include "tinyexpr.h" #include "tinyexpr.h"
namespace
{
// tinyexpr has no built-in min/max; expose them so config formulas can
// clamp (e.g. a floored overclaim penalty "max(0.5, 1 - 0.1*x)").
double formulaMin(double a, double b) { return a < b ? a : b; }
double formulaMax(double a, double b) { return a > b ? a : b; }
}
Formula::Formula(Formula&& other) noexcept Formula::Formula(Formula&& other) noexcept
: m_source(std::move(other.m_source)) : m_source(std::move(other.m_source))
, m_x(std::move(other.m_x)) , m_x(std::move(other.m_x))
@@ -37,11 +45,14 @@ Formula Formula::compile(const std::string& source)
result.m_x = std::make_unique<double>(0.0); result.m_x = std::make_unique<double>(0.0);
const te_variable variables[] = { const te_variable variables[] = {
{ "x", result.m_x.get(), 0, nullptr }, { "x", result.m_x.get(), TE_VARIABLE, nullptr },
{ "min", reinterpret_cast<const void*>(&formulaMin), TE_FUNCTION2 | TE_FLAG_PURE, nullptr },
{ "max", reinterpret_cast<const void*>(&formulaMax), TE_FUNCTION2 | TE_FLAG_PURE, nullptr },
}; };
int errorPos = 0; int errorPos = 0;
result.m_expr = te_compile(result.m_source.c_str(), variables, 1, &errorPos); const int variableCount = static_cast<int>(sizeof(variables) / sizeof(variables[0]));
result.m_expr = te_compile(result.m_source.c_str(), variables, variableCount, &errorPos);
if (result.m_expr == nullptr) if (result.m_expr == nullptr)
{ {
@@ -66,3 +77,4 @@ double Formula::evaluate(double x) const
*m_x = x; *m_x = x;
return te_eval(m_expr); return te_eval(m_expr);
} }

View File

@@ -33,7 +33,8 @@ struct ModuleSalvageCapability
struct ModuleRepairCapability struct ModuleRepairCapability
{ {
Formula repairRateFormula; Formula repairRateFormula; // repair cycles per second
Formula repairAmountHpFormula; // HP restored per cycle
Formula repairRangeFormula; Formula repairRangeFormula;
}; };

View File

@@ -39,6 +39,14 @@ struct WorldWaves
double bossQuietAfterSeconds; // suppress normal waves this long after boss (REQ-WAV-QUIET) double bossQuietAfterSeconds; // suppress normal waves this long after boss (REQ-WAV-QUIET)
}; };
// Ship target selection (claim-aware scoring).
struct WorldTargeting
{
Formula targetScoreFormula; // x = distance / max weapon range; higher = better
Formula overclaimPenaltyFormula; // x = competing claim count; factor in [0,1]
double hysteresis; // fractional margin a challenger must beat the current target by
};
struct WorldConfig struct WorldConfig
{ {
int heightTiles; // REQ-GW-HEIGHT int heightTiles; // REQ-GW-HEIGHT
@@ -49,9 +57,12 @@ struct WorldConfig
double beltSpeed_tps; // REQ-GW-BELT-SPEED (tiles/s, converted from m/s in config) double beltSpeed_tps; // REQ-GW-BELT-SPEED (tiles/s, converted from m/s in config)
int tunnelMaxDistance_tiles; // REQ-BLD-TUNNEL-PAIR int tunnelMaxDistance_tiles; // REQ-BLD-TUNNEL-PAIR
double departureIntervalSeconds; // REQ-SHP-RALLY double departureIntervalSeconds; // REQ-SHP-RALLY
double orbitFactor; // REQ-SHP-ORBIT (multiplies tool range for orbit radius)
double rallyOrbitRadius_tiles; // REQ-SHP-ORBIT (fixed orbit radius around the rally point)
WorldRegions regions; WorldRegions regions;
WorldExpansion expansion; WorldExpansion expansion;
WorldPush push; WorldPush push;
WorldWaves waves; WorldWaves waves;
WorldTargeting targeting;
}; };

View File

@@ -8,6 +8,12 @@ constexpr int kTickRateHz = 30;
constexpr double kTickDurationMs = 1000.0 / kTickRateHz; constexpr double kTickDurationMs = 1000.0 / kTickRateHz;
constexpr double kTickDurationSeconds = 1.0 / kTickRateHz; constexpr double kTickDurationSeconds = 1.0 / kTickRateHz;
// Delay between a tool activating (emitting its beam) and its effect being
// applied — half the 0.3 s beam duration. Shared by weapons, repair tools, and
// salvage modules so all three apply their effect mid-beam (REQ-SHP-FIRING,
// REQ-SHP-FIRING-BEAM).
constexpr Tick kBeamImpactDelayTicks = 5;
// Converts a wall-clock duration (in seconds, as it appears in config TOML) to // Converts a wall-clock duration (in seconds, as it appears in config TOML) to
// an integer tick count. Rounds to nearest to avoid systematic drift from // an integer tick count. Rounds to nearest to avoid systematic drift from
// repeated conversions. // repeated conversions.

View File

@@ -0,0 +1,9 @@
#pragma once
// Baseline fallback behavior, present on every ship. The executor moves the ship
// toward the opposing side (direction derived from FactionComponent), so a ship
// with no better behavior keeps advancing.
struct AdvanceBehavior
{
float score = 0.0f;
};

View File

@@ -0,0 +1,14 @@
#pragma once
#include <optional>
#include "entt/entity/entity.hpp"
// Combat behavior for ships with weapons (was ThreatResponseBehaviorComponent).
// The evaluator sets currentTarget; the executor pushes it to in-range weapons.
struct AttackBehavior
{
std::optional<entt::entity> currentTarget;
float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT
float score = 0.0f;
};

View File

@@ -0,0 +1,16 @@
#pragma once
// Identifies a ship behavior. Written into SelectedBehaviorComponent by the
// AiSystem selection pass so each behavior's executor can tell whether it won.
enum class BehaviorKind
{
None,
Advance,
Standby,
Rally,
Retreat,
Attack,
Repair,
SalvageScrap,
DeliverScrap
};

View File

@@ -0,0 +1,23 @@
#pragma once
// Score bands for ship-behavior evaluation. The AiSystem selection pass picks
// the behavior with the highest score per ship; these constants define a single
// comparable scale so the desired priority falls out:
// Retreat > Attack > Repair / Salvage / Deliver > Rally > Standby > Advance.
// Evaluators may return kInactive when their behavior does not apply this tick.
namespace BehaviorScores
{
constexpr float kInactive = 0.0f;
constexpr float kAdvance = 0.05f; // baseline fallback; always present
constexpr float kStandby = 0.10f; // repair-capable ships; hold with the fleet
constexpr float kRally = 0.20f;
constexpr float kDeliver = 0.50f; // cargo full
constexpr float kRepair = 0.55f;
constexpr float kSalvage = 0.55f; // cargo not full and scrap in range
constexpr float kAttack = 0.60f; // healthy and target in sensor range
constexpr float kRetreat = 0.90f;
// Health fraction below which a ship is considered "low HP" — used by the
// Retreat evaluator to trigger retreat (which outscores attack).
constexpr float kLowHpFraction = 0.3f;
}

View File

@@ -1,24 +1,31 @@
SET(HDRS SET(HDRS
${HDRS} ${HDRS}
${CMAKE_CURRENT_SOURCE_DIR}/AdvanceBehavior.h
${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}/DespawnAtComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodyComponent.h ${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodyComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/FacingComponent.h ${CMAKE_CURRENT_SOURCE_DIR}/FacingComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/FactionComponent.h ${CMAKE_CURRENT_SOURCE_DIR}/FactionComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/HealthComponent.h ${CMAKE_CURRENT_SOURCE_DIR}/HealthComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/HomeReturnBehaviorComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/HqProxyComponent.h ${CMAKE_CURRENT_SOURCE_DIR}/HqProxyComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentComponent.h ${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/PositionComponent.h ${CMAKE_CURRENT_SOURCE_DIR}/PositionComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/RallyBehaviorComponent.h ${CMAKE_CURRENT_SOURCE_DIR}/RallyBehavior.h
${CMAKE_CURRENT_SOURCE_DIR}/RepairBehaviorComponent.h ${CMAKE_CURRENT_SOURCE_DIR}/RepairBehavior.h
${CMAKE_CURRENT_SOURCE_DIR}/RepairToolComponent.h ${CMAKE_CURRENT_SOURCE_DIR}/RepairToolComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/SalvageBehaviorComponent.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}/ScrapDataComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBehaviorComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/SensorRangeComponent.h ${CMAKE_CURRENT_SOURCE_DIR}/SensorRangeComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipIdentityComponent.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipIdentityComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/StandbyBehavior.h
${CMAKE_CURRENT_SOURCE_DIR}/StationBodyComponent.h ${CMAKE_CURRENT_SOURCE_DIR}/StationBodyComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/ThreatResponseBehaviorComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/WeaponComponent.h ${CMAKE_CURRENT_SOURCE_DIR}/WeaponComponent.h
PARENT_SCOPE PARENT_SCOPE
) )

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

@@ -0,0 +1,12 @@
#pragma once
#include "BuildingId.h"
// Deliver-scrap behavior (one half of the old SalvageBehaviorComponent). Scored
// high only when cargo is full. The evaluator assigns the nearest SalvageBay;
// SalvagerSystem performs the actual delivery.
struct DeliverScrapBehavior
{
BuildingId deliveryBay = kInvalidBuildingId;
float score = 0.0f;
};

View File

@@ -1,9 +0,0 @@
#pragma once
#include <QVector2D>
struct HomeReturnBehaviorComponent
{
float retreatHpFraction;
QVector2D homePos;
};

View File

@@ -2,11 +2,16 @@
#include <QVector2D> #include <QVector2D>
// A ship-behavior system writes this each tick before movement runs; the // The winning behavior's executor writes this each tick before movement runs.
// highest-priority write wins. Priority order is fixed globally — see // `active` is false when no behavior set a destination (the ship brakes); the
// architecture.md "Movement Arbitration". // score-based selection (see architecture.md "Movement Arbitration") decides
// which single executor writes here.
struct MovementIntentComponent struct MovementIntentComponent
{ {
int priority; bool active = false;
QVector2D target; QVector2D target; // straight-line destination, or orbit center when orbitRadius_tiles > 0
float orbitRadius_tiles = 0.0f; // 0 ⇒ go straight to target; >0 ⇒ orbit target at this radius
QVector2D orbitCenterVelocity_tpt; // velocity of the orbit center (0 for a static center); the orbit
// sense is resolved relative to this so a moving target's own motion
// does not bias it
}; };

View File

@@ -0,0 +1,12 @@
#pragma once
#include <QVector2D>
// Player combat ships loiter at the rally point until the departure timer
// removes this component (ShipSystem::triggerRallyDeparture).
struct RallyBehavior
{
QVector2D rallyPoint;
float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT
float score = 0.0f;
};

View File

@@ -1,8 +0,0 @@
#pragma once
#include <QVector2D>
struct RallyBehaviorComponent
{
QVector2D rallyPoint;
};

View File

@@ -0,0 +1,16 @@
#pragma once
#include <optional>
#include "entt/entity/entity.hpp"
// Repair behavior for ships with repair modules. The evaluator picks the nearest
// damaged friendly as currentTarget; the executor moves toward it and assigns
// in-range repair tools. RepairSystem applies the actual healing.
struct RepairBehavior
{
std::optional<entt::entity> currentTarget;
float maxRepairRange_tiles = 0.0f;
float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT
float score = 0.0f;
};

View File

@@ -1,11 +0,0 @@
#pragma once
#include <optional>
#include "entt/entity/entity.hpp"
struct RepairBehaviorComponent
{
std::optional<entt::entity> currentTarget;
float maxRepairRange_tiles = 0.0f;
};

View File

@@ -6,7 +6,9 @@
struct RepairToolComponent struct RepairToolComponent
{ {
float ratePerTick; float repairAmountHp; // HP restored per repair cycle
int repairIntervalTicks; // cycle period = kTickRateHz / repair-rate (cycles/s); 0 = never
int cooldownTicksRemaining; // ticks until this tool may start its next cycle
float range_tiles; float range_tiles;
std::optional<entt::entity> currentTarget; std::optional<entt::entity> currentTarget;
}; };

View File

@@ -0,0 +1,13 @@
#pragma once
#include <QVector2D>
// Player-only retreat behavior (replaces HomeReturnBehaviorComponent). Scored
// high when HP is low, or when an enemy is in sensor range and the ship cannot
// fight back. The executor moves the ship to retreatPoint (the rally point).
struct RetreatBehavior
{
float retreatHpFraction = 0.0f;
QVector2D retreatPoint;
float score = 0.0f;
};

View File

@@ -1,14 +0,0 @@
#pragma once
#include <optional>
#include <QVector2D>
#include "BuildingId.h"
struct SalvageBehaviorComponent
{
std::optional<QVector2D> scrapTarget;
BuildingId deliveryBay; // kInvalidBuildingId until assigned at a salvage bay
float maxCollectionRange_tiles = 0.0f;
};

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,15 @@
#pragma once
#include <optional>
#include <QVector2D>
// Collect-scrap behavior (one half of the old SalvageBehaviorComponent). The
// evaluator finds the nearest scrap and sets scrapTarget when cargo is not full.
struct SalvageScrapBehavior
{
std::optional<QVector2D> scrapTarget;
float maxCollectionRange_tiles = 0.0f;
float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT
float score = 0.0f;
};

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

@@ -0,0 +1,11 @@
#pragma once
#include "BehaviorKind.h"
// Result of the AiSystem selection pass: the highest-scoring behavior for a
// ship this tick. Each behavior's executor acts only when it is the winner.
struct SelectedBehaviorComponent
{
BehaviorKind winner = BehaviorKind::None;
float bestScore = 0.0f;
};

View File

@@ -0,0 +1,11 @@
#pragma once
// Fallback for ships with a repair capability: instead of charging the enemy
// like AdvanceBehavior, the ship holds with its fleet so damaged allies stay in
// sensor range and it can heal them. Scored just above Advance and below Rally,
// so it only wins when no more urgent behavior applies. The executor decides the
// destination (StandbyExecutor).
struct StandbyBehavior
{
float score = 0.0f;
};

View File

@@ -1,10 +0,0 @@
#pragma once
#include <optional>
#include "entt/entity/entity.hpp"
struct ThreatResponseBehaviorComponent
{
std::optional<entt::entity> currentTarget;
};

View File

@@ -1,587 +1,93 @@
#include "AiSystem.h" #include "AiSystem.h"
#include <optional> #include <limits>
#include <unordered_map>
#include <vector>
#include <QVector2D> #include "GameConfig.h"
#include "Building.h" #include "AdvanceBehavior.h"
#include "BuildingSystem.h" #include "AttackBehavior.h"
#include "BuildingType.h" #include "BehaviorKind.h"
#include "BuildingId.h" #include "DeliverScrapBehavior.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "FactionComponent.h" #include "RallyBehavior.h"
#include "HealthComponent.h" #include "RepairBehavior.h"
#include "HomeReturnBehaviorComponent.h" #include "RetreatBehavior.h"
#include "HqProxyComponent.h" #include "SalvageScrapBehavior.h"
#include "ModuleOwnerComponent.h" #include "SelectedBehaviorComponent.h"
#include "MovementIntentComponent.h" #include "StandbyBehavior.h"
#include "PositionComponent.h"
#include "RallyBehaviorComponent.h"
#include "RepairBehaviorComponent.h"
#include "RepairToolComponent.h"
#include "SalvageBehaviorComponent.h"
#include "SalvageCargoComponent.h"
#include "ScrapSystem.h"
#include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
#include "StationBodyComponent.h"
#include "ThreatResponseBehaviorComponent.h"
#include "tracing.h" #include "tracing.h"
// --------------------------------------------------------------------------- namespace
// Shared helpers for repair targeting
// ---------------------------------------------------------------------------
struct RepairableInfo
{ {
entt::entity entity; // Records a behavior's score for its owning ship, keeping the highest seen.
QVector2D position; // Considered high-priority first, so strict '>' breaks ties toward priority.
bool isEnemy; template <typename Behavior>
bool isShip; void consider(EntityAdmin& admin, BehaviorKind kind)
float hp;
float maxHp;
};
static std::vector<RepairableInfo> buildRepairables(EntityAdmin& admin)
{ {
std::vector<RepairableInfo> repairables; admin.forEach<Behavior, SelectedBehaviorComponent>(
[kind](entt::entity /*e*/, const Behavior& behavior,
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent, HealthComponent>( SelectedBehaviorComponent& selected)
[&repairables](entt::entity e, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& f,
const HealthComponent& h)
{ {
repairables.push_back({e, pos.value, f.isEnemy, true, h.hp, h.maxHp}); if (behavior.score > selected.bestScore)
{
selected.bestScore = behavior.score;
selected.winner = kind;
}
}); });
}
admin.forEach<StationBodyComponent, PositionComponent, FactionComponent, HealthComponent>(
[&repairables](entt::entity e, const StationBodyComponent& /*sb*/,
const PositionComponent& pos, const FactionComponent& f,
const HealthComponent& h)
{
repairables.push_back({e, pos.value, f.isEnemy, false, h.hp, h.maxHp});
});
return repairables;
} }
// --------------------------------------------------------------------------- AiSystem::AiSystem(const GameConfig& config)
// tickHomeReturnBehavior (priority 4) : m_attackEvaluator(config.world.targeting)
// --------------------------------------------------------------------------- {
}
void AiSystem::tickHomeReturnBehavior(EntityAdmin& admin) void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
const ScrapSystem& scraps)
{ {
TRACE(); TRACE();
admin.forEach<HomeReturnBehaviorComponent, HealthComponent, MovementIntentComponent>(
[](entt::entity /*e*/, const HomeReturnBehaviorComponent& homeReturnBehavior, // Phase 1: evaluators score behaviors and set their target data.
const HealthComponent& h, MovementIntentComponent& intent) m_advanceEvaluator.evaluate(admin);
{ m_standbyEvaluator.evaluate(admin);
if (h.hp / h.maxHp < homeReturnBehavior.retreatHpFraction) m_rallyEvaluator.evaluate(admin);
{ m_retreatEvaluator.evaluate(admin);
if (4 > intent.priority) m_attackEvaluator.evaluate(admin);
{ m_repairEvaluator.evaluate(admin);
intent = MovementIntentComponent{4, homeReturnBehavior.homePos}; m_salvageScrapEvaluator.evaluate(admin, scraps);
} m_deliverScrapEvaluator.evaluate(admin, buildings);
}
}); // Phase 2: pick the highest-scoring behavior per ship.
selectWinningBehaviors(admin);
// Phase 3: executors run for the winning behavior.
m_advanceExecutor.execute(admin);
m_standbyExecutor.execute(admin);
m_rallyExecutor.execute(admin);
m_retreatExecutor.execute(admin);
m_attackExecutor.execute(admin);
m_repairExecutor.execute(admin);
m_salvageScrapExecutor.execute(admin);
m_deliverScrapExecutor.execute(admin, buildings);
} }
// --------------------------------------------------------------------------- void AiSystem::selectWinningBehaviors(EntityAdmin& admin)
// tickThreatResponseBehavior (priority 3)
// ---------------------------------------------------------------------------
void AiSystem::tickThreatResponseBehavior(EntityAdmin& admin, const BuildingSystem& buildings)
{ {
TRACE(); TRACE();
// Snapshot all combatant entities for target acquisition. admin.forEach<SelectedBehaviorComponent>(
struct CombatantInfo [](entt::entity /*e*/, SelectedBehaviorComponent& selected)
{ {
entt::entity entity; selected.winner = BehaviorKind::None;
QVector2D position; selected.bestScore = std::numeric_limits<float>::lowest();
bool isEnemy;
bool isStation;
};
std::vector<CombatantInfo> combatants;
admin.forEach<PositionComponent, FactionComponent, ShipIdentityComponent>(
[&combatants](entt::entity e, const PositionComponent& pos,
const FactionComponent& f, const ShipIdentityComponent& /*si*/)
{
combatants.push_back({e, pos.value, f.isEnemy, false});
}); });
admin.forEach<PositionComponent, FactionComponent, StationBodyComponent>( // Highest priority first so ties resolve toward the more urgent behavior.
[&combatants](entt::entity e, const PositionComponent& pos, consider<RetreatBehavior>(admin, BehaviorKind::Retreat);
const FactionComponent& f, const StationBodyComponent& /*sb*/) consider<AttackBehavior>(admin, BehaviorKind::Attack);
{ consider<RepairBehavior>(admin, BehaviorKind::Repair);
combatants.push_back({e, pos.value, f.isEnemy, true}); consider<SalvageScrapBehavior>(admin, BehaviorKind::SalvageScrap);
}); consider<DeliverScrapBehavior>(admin, BehaviorKind::DeliverScrap);
consider<RallyBehavior>(admin, BehaviorKind::Rally);
admin.forEach<PositionComponent, FactionComponent, HqProxyComponent>( consider<StandbyBehavior>(admin, BehaviorKind::Standby);
[&combatants](entt::entity e, const PositionComponent& pos, consider<AdvanceBehavior>(admin, BehaviorKind::Advance);
const FactionComponent& f, const HqProxyComponent& /*hq*/)
{
combatants.push_back({e, pos.value, f.isEnemy, true});
});
admin.forEach<ThreatResponseBehaviorComponent, PositionComponent, FactionComponent,
SensorRangeComponent, MovementIntentComponent>(
[&](entt::entity e, ThreatResponseBehaviorComponent& threatResponseBehavior,
PositionComponent& pos, FactionComponent& faction,
SensorRangeComponent& sensor, MovementIntentComponent& intent)
{
const float range = sensor.value_tiles;
// Validate current target.
bool targetValid = false;
if (threatResponseBehavior.currentTarget)
{
const entt::entity t = *threatResponseBehavior.currentTarget;
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
{
const float dist =
(admin.get<PositionComponent>(t).value - pos.value).length();
if (dist <= range)
{
targetValid = true;
}
}
}
if (!targetValid)
{
threatResponseBehavior.currentTarget = std::nullopt;
float bestDist = range;
for (const CombatantInfo& c : combatants)
{
if (c.entity == e) { continue; }
bool isValidTarget = false;
if (!faction.isEnemy)
{
isValidTarget = c.isEnemy;
}
else
{
isValidTarget = !c.isEnemy;
}
if (!isValidTarget) { continue; }
const float dist = (c.position - pos.value).length();
if (dist < bestDist)
{
bestDist = dist;
threatResponseBehavior.currentTarget = c.entity;
}
}
}
if (threatResponseBehavior.currentTarget)
{
const entt::entity t = *threatResponseBehavior.currentTarget;
QVector2D dest = pos.value;
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
{
dest = admin.get<PositionComponent>(t).value;
}
if (3 > intent.priority)
{
intent = MovementIntentComponent{3, dest};
}
}
else
{
if (3 > intent.priority)
{
if (admin.hasAll<RallyBehaviorComponent>(e))
{
intent = MovementIntentComponent{
3, admin.get<RallyBehaviorComponent>(e).rallyPoint};
}
else if (!faction.isEnemy)
{
intent = MovementIntentComponent{
3, QVector2D(pos.value.x() + 1000.0f, pos.value.y())};
}
else
{
intent = MovementIntentComponent{
3, QVector2D(-10000.0f, pos.value.y())};
}
}
}
});
}
// ---------------------------------------------------------------------------
// tickRepairBehavior (priority 2)
// ---------------------------------------------------------------------------
void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings)
{
TRACE();
std::vector<RepairableInfo> repairables = buildRepairables(admin);
// Snapshot enemy ships for threat detection.
struct EnemyInfo
{
QVector2D position;
};
std::vector<EnemyInfo> enemies;
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent>(
[&enemies](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& f)
{
if (f.isEnemy)
{
enemies.push_back({pos.value});
}
});
admin.forEach<RepairBehaviorComponent, PositionComponent,
FactionComponent, SensorRangeComponent, MovementIntentComponent>(
[&](entt::entity e, RepairBehaviorComponent& rb,
PositionComponent& pos, FactionComponent& /*faction*/,
SensorRangeComponent& sensor, MovementIntentComponent& intent)
{
// Flee if enemy nearby.
bool enemyNearby = false;
for (const EnemyInfo& enemy : enemies)
{
if ((enemy.position - pos.value).length() <= sensor.value_tiles)
{
enemyNearby = true;
break;
}
}
if (enemyNearby)
{
if (2 > intent.priority)
{
intent = MovementIntentComponent{
2, QVector2D(-10000.0f, pos.value.y())};
}
return;
}
// Validate current target.
bool targetValid = false;
if (rb.currentTarget)
{
const entt::entity t = *rb.currentTarget;
if (admin.isValid(t) && admin.hasAll<HealthComponent>(t))
{
const HealthComponent& th = admin.get<HealthComponent>(t);
if (th.hp > 0.0f && th.hp < th.maxHp)
{
targetValid = true;
}
}
}
if (!targetValid)
{
rb.currentTarget = std::nullopt;
float bestDist = sensor.value_tiles;
for (const RepairableInfo& r : repairables)
{
if (r.entity == e) { continue; }
if (r.isEnemy) { continue; }
if (r.hp >= r.maxHp) { continue; }
const float dist = (r.position - pos.value).length();
if (dist < bestDist)
{
bestDist = dist;
rb.currentTarget = r.entity;
}
}
}
if (!rb.currentTarget)
{
if (2 > intent.priority)
{
intent = MovementIntentComponent{
2, QVector2D(pos.value.x() + 1000.0f, pos.value.y())};
}
return;
}
const entt::entity target = *rb.currentTarget;
QVector2D targetPos = pos.value;
if (admin.isValid(target) && admin.hasAll<PositionComponent>(target))
{
targetPos = admin.get<PositionComponent>(target).value;
}
if (2 > intent.priority)
{
intent = MovementIntentComponent{2, targetPos};
}
});
}
// ---------------------------------------------------------------------------
// tickRepairTools
// ---------------------------------------------------------------------------
void AiSystem::tickRepairTools(EntityAdmin& admin)
{
TRACE();
const std::vector<RepairableInfo> repairables = buildRepairables(admin);
admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
[&](entt::entity /*e*/, RepairToolComponent& rt, const ModuleOwnerComponent& owner)
{
if (!admin.hasAll<RepairBehaviorComponent>(owner.owner)) { return; }
const RepairBehaviorComponent& rb =
admin.get<RepairBehaviorComponent>(owner.owner);
const PositionComponent& ownerPos =
admin.get<PositionComponent>(owner.owner);
// Try the ship's preferred nav target first.
if (rb.currentTarget)
{
const entt::entity preferred = *rb.currentTarget;
if (admin.isValid(preferred) && admin.hasAll<HealthComponent>(preferred)
&& admin.hasAll<PositionComponent>(preferred))
{
HealthComponent& th = admin.get<HealthComponent>(preferred);
const float dist =
(admin.get<PositionComponent>(preferred).value
- ownerPos.value).length();
if (th.hp > 0.0f && th.hp < th.maxHp && dist <= rt.range_tiles)
{
rt.currentTarget = rb.currentTarget;
th.hp = std::min(th.hp + rt.ratePerTick, th.maxHp);
return;
}
}
}
// Preferred target unavailable; scan for nearest damaged friendly in range.
rt.currentTarget = std::nullopt;
float bestDist = rt.range_tiles;
for (const RepairableInfo& r : repairables)
{
if (r.isEnemy) { continue; }
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
const float dist = (r.position - ownerPos.value).length();
if (dist < bestDist)
{
bestDist = dist;
rt.currentTarget = r.entity;
}
}
if (!rt.currentTarget) { return; }
HealthComponent& targetHealth =
admin.get<HealthComponent>(*rt.currentTarget);
targetHealth.hp = std::min(targetHealth.hp + rt.ratePerTick, targetHealth.maxHp);
});
}
// ---------------------------------------------------------------------------
// tickSalvageBehavior (priority 1)
// ---------------------------------------------------------------------------
void AiSystem::tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps,
BuildingSystem& buildings)
{
TRACE();
// Snapshot enemy ships for threat detection.
struct EnemyShipPos
{
QVector2D position;
};
std::vector<EnemyShipPos> enemyShips;
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent>(
[&enemyShips](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& f)
{
if (f.isEnemy)
{
enemyShips.push_back({pos.value});
}
});
// Aggregate cargo across all salvage-module children per owning ship.
struct AggregatedCargo
{
int totalCurrent = 0;
int totalCapacity = 0;
};
std::unordered_map<entt::entity, AggregatedCargo> cargoByShip;
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity /*ce*/, const SalvageCargoComponent& c, const ModuleOwnerComponent& o)
{
AggregatedCargo& agg = cargoByShip[o.owner];
agg.totalCurrent += c.current;
agg.totalCapacity += c.capacity;
});
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
// Tick down per-module collection cooldowns.
admin.forEach<SalvageCargoComponent>(
[](entt::entity /*e*/, SalvageCargoComponent& c)
{
if (c.cooldownTicksRemaining > 0) { --c.cooldownTicksRemaining; }
});
admin.forEach<SalvageBehaviorComponent, PositionComponent,
SensorRangeComponent, MovementIntentComponent>(
[&](entt::entity e, SalvageBehaviorComponent& salvageBehavior,
PositionComponent& pos,
SensorRangeComponent& sensor, MovementIntentComponent& intent)
{
const float collectRange = salvageBehavior.maxCollectionRange_tiles;
const AggregatedCargo& cargoState = cargoByShip[e];
// Assign nearest SalvageBay if needed.
if (salvageBehavior.deliveryBay == kInvalidBuildingId)
{
const Building* bay = buildings.findNearestBuilding(pos.value,
BuildingType::SalvageBay);
if (bay)
{
salvageBehavior.deliveryBay = bay->id;
}
}
const BuildingId bayId = salvageBehavior.deliveryBay;
QVector2D bayPos = pos.value;
if (bayId != kInvalidBuildingId)
{
const Building* bay = buildings.findBuilding(bayId);
if (bay)
{
bayPos = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f,
bay->anchor.y() + bay->footprint.height() / 2.0f);
}
}
const bool cargoFull = (cargoState.totalCurrent >= cargoState.totalCapacity
&& cargoState.totalCapacity > 0);
if (cargoFull)
{
if (1 > intent.priority)
{
intent = MovementIntentComponent{1, bayPos};
}
if (bayId != kInvalidBuildingId
&& (pos.value - bayPos).length() <= 1.0f)
{
// Decrement first non-empty salvage child.
bool delivered = false;
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity /*ce*/, SalvageCargoComponent& c,
const ModuleOwnerComponent& o)
{
if (delivered || o.owner != e || c.current <= 0) { return; }
if (buildings.deliverScrapToSalvageBay(bayId))
{
--c.current;
delivered = true;
}
});
}
return;
}
// Retreat if enemy near and cargo empty.
bool retreating = false;
if (cargoState.totalCurrent == 0)
{
for (const EnemyShipPos& enemy : enemyShips)
{
if ((enemy.position - pos.value).length() <= collectRange)
{
if (1 > intent.priority)
{
intent = MovementIntentComponent{
1, QVector2D(-10000.0f, pos.value.y())};
}
retreating = true;
break;
}
}
}
if (retreating) { return; }
// Per-module independent collection: each ready module collects one scrap.
bool anythingCollected = false;
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity /*ce*/, SalvageCargoComponent& c,
const ModuleOwnerComponent& o)
{
if (o.owner != e || c.current >= c.capacity
|| c.cooldownTicksRemaining > 0)
{
return;
}
for (const ScrapInfo& si : allScrap)
{
if ((si.position - pos.value).length() > c.collectionRange_tiles) { continue; }
if (scraps.consume(si.entity))
{
++c.current;
c.cooldownTicksRemaining = c.collectionIntervalTicks;
anythingCollected = true;
break;
}
}
});
if (anythingCollected)
{
salvageBehavior.scrapTarget = std::nullopt;
}
// Move toward scrap target or find a new one.
if (salvageBehavior.scrapTarget)
{
if (1 > intent.priority)
{
intent = MovementIntentComponent{1, *salvageBehavior.scrapTarget};
}
}
else
{
float bestDist = sensor.value_tiles;
std::optional<QVector2D> bestPos;
for (const ScrapInfo& si : allScrap)
{
const float dist = (si.position - pos.value).length();
if (dist < bestDist)
{
bestDist = dist;
bestPos = si.position;
}
}
if (bestPos)
{
salvageBehavior.scrapTarget = bestPos;
if (1 > intent.priority)
{
intent = MovementIntentComponent{1, *bestPos};
}
}
else
{
if (1 > intent.priority)
{
intent = MovementIntentComponent{
1, QVector2D(pos.value.x() + 1000.0f, pos.value.y())};
}
}
}
});
} }

View File

@@ -1,15 +1,59 @@
#pragma once #pragma once
#include "AdvanceEvaluator.h"
#include "AdvanceExecutor.h"
#include "AttackEvaluator.h"
#include "AttackExecutor.h"
#include "DeliverScrapEvaluator.h"
#include "DeliverScrapExecutor.h"
#include "RallyEvaluator.h"
#include "RallyExecutor.h"
#include "RepairEvaluator.h"
#include "RepairExecutor.h"
#include "RetreatEvaluator.h"
#include "RetreatExecutor.h"
#include "SalvageScrapEvaluator.h"
#include "SalvageScrapExecutor.h"
#include "StandbyEvaluator.h"
#include "StandbyExecutor.h"
class BuildingSystem; class BuildingSystem;
class EntityAdmin; class EntityAdmin;
class ScrapSystem; class ScrapSystem;
struct GameConfig;
// Orchestrates ship-behavior decision-making in three batched phases:
// 1. evaluators score each behavior and set its target data,
// 2. selectWinningBehaviors picks the highest-scoring behavior per ship,
// 3. executors run for the winning behavior, setting movement intent and
// preferred module targets.
// All world mutation (collection, healing, damage) is left to the module
// systems (SalvagerSystem, RepairSystem, CombatSystem).
class AiSystem class AiSystem
{ {
public: public:
void tickHomeReturnBehavior(EntityAdmin& admin); explicit AiSystem(const GameConfig& config);
void tickThreatResponseBehavior(EntityAdmin& admin, const BuildingSystem& buildings);
void tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings); void tick(EntityAdmin& admin, const BuildingSystem& buildings, const ScrapSystem& scraps);
void tickRepairTools(EntityAdmin& admin);
void tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps, BuildingSystem& buildings); private:
void selectWinningBehaviors(EntityAdmin& admin);
AdvanceEvaluator m_advanceEvaluator;
StandbyEvaluator m_standbyEvaluator;
RallyEvaluator m_rallyEvaluator;
RetreatEvaluator m_retreatEvaluator;
AttackEvaluator m_attackEvaluator;
RepairEvaluator m_repairEvaluator;
SalvageScrapEvaluator m_salvageScrapEvaluator;
DeliverScrapEvaluator m_deliverScrapEvaluator;
AdvanceExecutor m_advanceExecutor;
StandbyExecutor m_standbyExecutor;
RallyExecutor m_rallyExecutor;
RetreatExecutor m_retreatExecutor;
AttackExecutor m_attackExecutor;
RepairExecutor m_repairExecutor;
SalvageScrapExecutor m_salvageScrapExecutor;
DeliverScrapExecutor m_deliverScrapExecutor;
}; };

View File

@@ -1,9 +1,28 @@
SET(HDRS SET(HDRS
${HDRS} ${HDRS}
${CMAKE_CURRENT_SOURCE_DIR}/ai/AdvanceEvaluator.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/AdvanceExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackEvaluator.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/BehaviorTargeting.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapEvaluator.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyEvaluator.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairEvaluator.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/RetreatEvaluator.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/RetreatExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapEvaluator.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/StandbyEvaluator.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/StandbyExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/AiSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/AiSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodySystem.h ${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodySystem.h
${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/RepairSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/SalvagerSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.h
PARENT_SCOPE PARENT_SCOPE
@@ -11,10 +30,29 @@ SET(HDRS
SET(SRCS SET(SRCS
${SRCS} ${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/ai/AdvanceEvaluator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/AdvanceExecutor.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackEvaluator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackExecutor.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/BehaviorTargeting.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapEvaluator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapExecutor.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyEvaluator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyExecutor.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairEvaluator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairExecutor.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/RetreatEvaluator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/RetreatExecutor.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapEvaluator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapExecutor.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/StandbyEvaluator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/StandbyExecutor.cpp
${CMAKE_CURRENT_SOURCE_DIR}/AiSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/AiSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodySystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodySystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RepairSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SalvagerSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.cpp
PARENT_SCOPE PARENT_SCOPE
@@ -23,5 +61,6 @@ SET(SRCS
set(LIB_INCLUDE_PATH set(LIB_INCLUDE_PATH
${LIB_INCLUDE_PATH} ${LIB_INCLUDE_PATH}
${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/ai
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -7,12 +7,9 @@
#include "PositionComponent.h" #include "PositionComponent.h"
#include "SensorRangeComponent.h" #include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h" #include "ShipIdentityComponent.h"
#include "ThreatResponseBehaviorComponent.h"
#include "tracing.h" #include "tracing.h"
#include "WeaponComponent.h" #include "WeaponComponent.h"
static constexpr Tick kWeaponImpactDelayTicks = 5;
CombatSystem::CombatSystem(const GameConfig& config) CombatSystem::CombatSystem(const GameConfig& config)
: m_config(config) : m_config(config)
{ {
@@ -21,21 +18,18 @@ CombatSystem::CombatSystem(const GameConfig& config)
void CombatSystem::tick(Tick currentTick, void CombatSystem::tick(Tick currentTick,
EntityAdmin& admin, EntityAdmin& admin,
BuildingSystem& /*buildings*/, BuildingSystem& /*buildings*/,
std::vector<WeaponFiredEvent>& outWeaponFiredEvents) std::vector<BeamFiredEvent>& outBeamFiredEvents)
{ {
TRACE(); TRACE();
// All weapons (ships and stations) are child entities linked via ModuleOwnerComponent. // All weapons (ships and stations) are child entities linked via ModuleOwnerComponent.
// AttackExecutor has already set each weapon's preferred (in-range) target; here we
// validate it, fall back to nearest-target acquisition, and fire.
admin.forEach<WeaponComponent, ModuleOwnerComponent>( admin.forEach<WeaponComponent, ModuleOwnerComponent>(
[&](entt::entity /*e*/, WeaponComponent& weapon, const ModuleOwnerComponent& owner) [&](entt::entity /*e*/, WeaponComponent& weapon, const ModuleOwnerComponent& owner)
{ {
if (admin.hasAll<ThreatResponseBehaviorComponent>(owner.owner))
{
weapon.currentTarget =
admin.get<ThreatResponseBehaviorComponent>(owner.owner).currentTarget;
}
const PositionComponent& pos = admin.get<PositionComponent>(owner.owner); const PositionComponent& pos = admin.get<PositionComponent>(owner.owner);
const FactionComponent& faction = admin.get<FactionComponent>(owner.owner); const FactionComponent& faction = admin.get<FactionComponent>(owner.owner);
resolveWeapon(owner.owner, weapon, pos, faction, currentTick, admin, outWeaponFiredEvents); resolveWeapon(owner.owner, weapon, pos, faction, currentTick, admin, outBeamFiredEvents);
}); });
} }
@@ -46,7 +40,7 @@ void CombatSystem::resolveWeapon(
const FactionComponent& ownFaction, const FactionComponent& ownFaction,
Tick currentTick, Tick currentTick,
EntityAdmin& admin, EntityAdmin& admin,
std::vector<WeaponFiredEvent>& out) std::vector<BeamFiredEvent>& out)
{ {
if (weapon.cooldownTicks > 0.0f) if (weapon.cooldownTicks > 0.0f)
{ {
@@ -113,9 +107,10 @@ void CombatSystem::resolveWeapon(
const entt::entity targetEntity = *weapon.currentTarget; const entt::entity targetEntity = *weapon.currentTarget;
m_pendingDamage.push_back({targetEntity, weapon.damage, m_pendingDamage.push_back({targetEntity, weapon.damage,
currentTick + kWeaponImpactDelayTicks}); currentTick + kBeamImpactDelayTicks});
WeaponFiredEvent evt; BeamFiredEvent evt;
evt.kind = BeamKind::Weapon;
evt.shooter = shipEntity; evt.shooter = shipEntity;
evt.target = targetEntity; evt.target = targetEntity;
evt.emittedAt = currentTick; evt.emittedAt = currentTick;

View File

@@ -7,7 +7,7 @@
#include "Building.h" #include "Building.h"
#include "FactionComponent.h" #include "FactionComponent.h"
#include "WeaponFiredEvent.h" #include "BeamFiredEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "Tick.h" #include "Tick.h"
@@ -26,7 +26,7 @@ public:
void tick(Tick currentTick, void tick(Tick currentTick,
EntityAdmin& admin, EntityAdmin& admin,
BuildingSystem& buildings, BuildingSystem& buildings,
std::vector<WeaponFiredEvent>& outWeaponFiredEvents); std::vector<BeamFiredEvent>& outBeamFiredEvents);
void applyPendingDamage(Tick currentTick, EntityAdmin& admin); void applyPendingDamage(Tick currentTick, EntityAdmin& admin);
@@ -47,7 +47,7 @@ private:
const FactionComponent& ownFaction, const FactionComponent& ownFaction,
Tick currentTick, Tick currentTick,
EntityAdmin& admin, EntityAdmin& admin,
std::vector<WeaponFiredEvent>& out); std::vector<BeamFiredEvent>& out);
const GameConfig& m_config; const GameConfig& m_config;
}; };

View File

@@ -9,6 +9,7 @@
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "FacingComponent.h" #include "FacingComponent.h"
#include "MovementIntentComponent.h" #include "MovementIntentComponent.h"
#include "OrbitMath.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "tracing.h" #include "tracing.h"
@@ -29,7 +30,7 @@ void MovementIntentSystem::tick(EntityAdmin& admin)
[](entt::entity /*e*/, const PositionComponent& pos, const FacingComponent& facing, [](entt::entity /*e*/, const PositionComponent& pos, const FacingComponent& facing,
DynamicBodyComponent& body, const MovementIntentComponent& intent) DynamicBodyComponent& body, const MovementIntentComponent& intent)
{ {
if (intent.priority == 0) if (!intent.active)
{ {
// No movement intent: brake using available thrust. // No movement intent: brake using available thrust.
const float linearBraking = std::min(body.velocity_tpt.length(), const float linearBraking = std::min(body.velocity_tpt.length(),
@@ -45,7 +46,20 @@ void MovementIntentSystem::tick(EntityAdmin& admin)
return; return;
} }
const QVector2D delta = intent.target - pos.value; // Resolve the steering destination. For orbit intents, pick the orbit
// sense from the ship's current velocity (so ships circling the same
// target spread to both sides) and aim at a point on the orbit circle.
QVector2D destination = intent.target;
if (intent.orbitRadius_tiles > 0.0f)
{
const float sign = OrbitMath::resolveOrbitSign(
pos.value, intent.target, body.velocity_tpt,
intent.orbitCenterVelocity_tpt);
destination = OrbitMath::computeOrbitDestination(
pos.value, intent.target, intent.orbitRadius_tiles, sign);
}
const QVector2D delta = destination - pos.value;
const float dist = delta.length(); const float dist = delta.length();
if (dist < 0.001f) if (dist < 0.001f)

View File

@@ -0,0 +1,109 @@
#include "RepairSystem.h"
#include <algorithm>
#include <optional>
#include <vector>
#include <QVector2D>
#include "BehaviorTargeting.h"
#include "EntityAdmin.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "PositionComponent.h"
#include "RepairToolComponent.h"
#include "tracing.h"
RepairSystem::RepairSystem(EntityAdmin& admin)
: m_admin(admin)
{
}
void RepairSystem::tick(Tick currentTick, std::vector<BeamFiredEvent>& outBeamFiredEvents)
{
TRACE();
// Apply heals whose mid-beam delay has elapsed (cycles started on prior ticks).
applyPendingHeals(currentTick);
const std::vector<RepairableInfo> repairables = buildRepairables(m_admin);
m_admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
[&](entt::entity /*re*/, RepairToolComponent& tool, const ModuleOwnerComponent& owner)
{
if (tool.cooldownTicksRemaining > 0) { --tool.cooldownTicksRemaining; }
if (tool.cooldownTicksRemaining > 0) { return; }
if (tool.repairIntervalTicks <= 0) { return; }
if (!m_admin.hasAll<PositionComponent>(owner.owner)) { return; }
const QVector2D ownerPos = m_admin.get<PositionComponent>(owner.owner).value;
// Choose a target: honour the executor-set target if it is still valid
// and in range, else fall back to the nearest damaged friendly in range.
std::optional<entt::entity> target;
if (tool.currentTarget)
{
const entt::entity t = *tool.currentTarget;
if (m_admin.isValid(t) && m_admin.hasAll<HealthComponent, PositionComponent>(t))
{
const HealthComponent& th = m_admin.get<HealthComponent>(t);
const float dist =
(m_admin.get<PositionComponent>(t).value - ownerPos).length();
if (th.hp > 0.0f && th.hp < th.maxHp && dist <= tool.range_tiles)
{
target = t;
}
}
}
if (!target)
{
tool.currentTarget = std::nullopt;
float bestDist = tool.range_tiles;
for (const RepairableInfo& r : repairables)
{
if (r.isEnemy) { continue; }
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
const float dist = (r.position - ownerPos).length();
if (dist < bestDist)
{
bestDist = dist;
tool.currentTarget = r.entity;
}
}
target = tool.currentTarget;
}
if (!target) { return; }
// Start a repair cycle: emit the beam now, apply the heal mid-beam, and
// begin the cooldown at cycle start (not at effect application).
outBeamFiredEvents.push_back(
BeamFiredEvent{BeamKind::Repair, owner.owner, *target, currentTick});
m_pendingHeals.push_back({*target, tool.repairAmountHp,
currentTick + kBeamImpactDelayTicks});
tool.cooldownTicksRemaining = tool.repairIntervalTicks;
});
}
void RepairSystem::applyPendingHeals(Tick currentTick)
{
std::vector<PendingHeal>::iterator it = m_pendingHeals.begin();
while (it != m_pendingHeals.end())
{
if (it->appliesAt <= currentTick)
{
if (m_admin.isValid(it->target) && m_admin.hasAll<HealthComponent>(it->target))
{
HealthComponent& h = m_admin.get<HealthComponent>(it->target);
if (h.hp > 0.0f && h.hp < h.maxHp)
{
h.hp = std::min(h.hp + it->amountHp, h.maxHp);
}
}
it = m_pendingHeals.erase(it);
}
else
{
++it;
}
}
}

View File

@@ -0,0 +1,36 @@
#pragma once
#include <vector>
#include "BeamFiredEvent.h"
#include "Tick.h"
#include "entt/entity/entity.hpp"
class EntityAdmin;
// World-mutation system for repair modules: each tool runs a cycle on its own
// cooldown. When a cycle starts it picks a target (the RepairExecutor-set target,
// else the nearest damaged friendly in range), emits a repair beam, and schedules
// the heal for mid-beam (kBeamImpactDelayTicks later) — mirroring weapon firing.
// Runs every tick, independent of behavior selection.
class RepairSystem
{
public:
explicit RepairSystem(EntityAdmin& admin);
void tick(Tick currentTick, std::vector<BeamFiredEvent>& outBeamFiredEvents);
private:
struct PendingHeal
{
entt::entity target;
float amountHp;
Tick appliesAt;
};
void applyPendingHeals(Tick currentTick);
EntityAdmin& m_admin;
std::vector<PendingHeal> m_pendingHeals;
};

View File

@@ -0,0 +1,132 @@
#include "SalvagerSystem.h"
#include <vector>
#include <QVector2D>
#include "Building.h"
#include "BuildingSystem.h"
#include "CargoComponent.h"
#include "DeliverScrapBehavior.h"
#include "EntityAdmin.h"
#include <map>
#include "ModuleOwnerComponent.h"
#include "PositionComponent.h"
#include "SalvagerComponent.h"
#include "ScrapDataComponent.h"
#include "ScrapSystem.h"
#include "tracing.h"
SalvagerSystem::SalvagerSystem(EntityAdmin& admin)
: m_admin(admin)
{
}
void SalvagerSystem::tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem& buildings,
std::vector<BeamFiredEvent>& outBeamFiredEvents)
{
TRACE();
// Apply collections whose mid-beam delay has elapsed (cycles started earlier).
applyPendingCollections(currentTick, scraps);
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
// Tick down per-module collection cooldowns.
m_admin.forEach<SalvagerComponent>(
[](entt::entity /*e*/, SalvagerComponent& s)
{
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 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 (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() > 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({o.owner, si.entity,
currentTick + kBeamImpactDelayTicks});
++claimedUnits[si.entity];
++pendingByShip[o.owner];
s.cooldownTicksRemaining = s.collectionIntervalTicks;
break;
}
});
// Delivery: a ship at its assigned bay hands over one unit of cargo per tick.
m_admin.forEach<DeliverScrapBehavior, PositionComponent>(
[&](entt::entity ship, const DeliverScrapBehavior& deliver, const PositionComponent& pos)
{
if (deliver.deliveryBay == kInvalidBuildingId) { return; }
const Building* bay = buildings.findBuilding(deliver.deliveryBay);
if (!bay) { return; }
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
bay->anchor.y() + bay->footprint.height() / 2.0f);
if ((pos.value - bayCenter).length() > 1.0f) { return; }
// 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;
}
});
}
void SalvagerSystem::applyPendingCollections(Tick currentTick, ScrapSystem& scraps)
{
std::vector<PendingCollection>::iterator it = m_pendingCollections.begin();
while (it != m_pendingCollections.end())
{
if (it->appliesAt <= currentTick)
{
if (m_admin.isValid(it->ship) && m_admin.hasAll<CargoComponent>(it->ship))
{
CargoComponent& cargo = m_admin.get<CargoComponent>(it->ship);
if (cargo.current < cargo.maxCapacity && scraps.collectOne(it->scrap))
{
++cargo.current;
}
}
it = m_pendingCollections.erase(it);
}
else
{
++it;
}
}
}

View File

@@ -0,0 +1,39 @@
#pragma once
#include <vector>
#include "BeamFiredEvent.h"
#include "Tick.h"
#include "entt/entity/entity.hpp"
class BuildingSystem;
class EntityAdmin;
class ScrapSystem;
// World-mutation system for salvage modules: each module runs a collection cycle
// on its own cooldown. When a cycle starts it emits a salvage beam toward an
// in-range scrap pile and schedules the collection of one scrap for mid-beam
// (kBeamImpactDelayTicks later) — mirroring weapon firing. Also delivers full
// cargo at a SalvageBay. Runs every tick, independent of behavior selection.
class SalvagerSystem
{
public:
explicit SalvagerSystem(EntityAdmin& admin);
void tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem& buildings,
std::vector<BeamFiredEvent>& outBeamFiredEvents);
private:
struct PendingCollection
{
entt::entity ship;
entt::entity scrap;
Tick appliesAt;
};
void applyPendingCollections(Tick currentTick, ScrapSystem& scraps);
EntityAdmin& m_admin;
std::vector<PendingCollection> m_pendingCollections;
};

View File

@@ -46,6 +46,25 @@ std::optional<int> ScrapSystem::consume(entt::entity entity)
return amount; return amount;
} }
bool ScrapSystem::collectOne(entt::entity entity)
{
if (!m_admin.isValid(entity) || !m_admin.hasAll<ScrapDataComponent>(entity))
{
return false;
}
ScrapDataComponent& data = m_admin.get<ScrapDataComponent>(entity);
if (data.amount <= 0)
{
return false;
}
--data.amount;
if (data.amount <= 0)
{
m_admin.destroy(entity);
}
return true;
}
std::vector<ScrapInfo> ScrapSystem::allScrapInfo() const std::vector<ScrapInfo> ScrapSystem::allScrapInfo() const
{ {
std::vector<ScrapInfo> result; std::vector<ScrapInfo> result;

View File

@@ -28,6 +28,11 @@ public:
// Removes the scrap and returns its amount, or nullopt if not found. // Removes the scrap and returns its amount, or nullopt if not found.
std::optional<int> consume(entt::entity entity); std::optional<int> consume(entt::entity entity);
// Collects a single scrap unit from the pile: decrements its amount by one,
// destroying the entity once depleted. Returns true if a scrap was collected,
// false if the entity is invalid or already empty (REQ-SHP-SALVAGE).
bool collectOne(entt::entity entity);
// Lightweight snapshot for callers that need to iterate all scrap. // Lightweight snapshot for callers that need to iterate all scrap.
std::vector<ScrapInfo> allScrapInfo() const; std::vector<ScrapInfo> allScrapInfo() const;

View File

@@ -6,6 +6,11 @@
#include <utility> #include <utility>
#include <vector> #include <vector>
#include "AdvanceBehavior.h"
#include "AttackBehavior.h"
#include "BehaviorScores.h"
#include "CargoComponent.h"
#include "DeliverScrapBehavior.h"
#include "DynamicBodyComponent.h" #include "DynamicBodyComponent.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "FactionComponent.h" #include "FactionComponent.h"
@@ -13,14 +18,16 @@
#include "ModuleOwnerComponent.h" #include "ModuleOwnerComponent.h"
#include "ModulesConfig.h" #include "ModulesConfig.h"
#include "MovementIntentComponent.h" #include "MovementIntentComponent.h"
#include "RallyBehaviorComponent.h" #include "RallyBehavior.h"
#include "RepairBehaviorComponent.h" #include "RepairBehavior.h"
#include "RepairToolComponent.h" #include "RepairToolComponent.h"
#include "SalvageBehaviorComponent.h" #include "RetreatBehavior.h"
#include "SalvageCargoComponent.h" #include "SalvageScrapBehavior.h"
#include "SalvagerComponent.h"
#include "SelectedBehaviorComponent.h"
#include "SensorRangeComponent.h" #include "SensorRangeComponent.h"
#include "StandbyBehavior.h"
#include "Tick.h" #include "Tick.h"
#include "ThreatResponseBehaviorComponent.h"
#include "tracing.h" #include "tracing.h"
#include "WeaponComponent.h" #include "WeaponComponent.h"
@@ -101,6 +108,10 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
std::vector<entt::entity> salvageChildren; std::vector<entt::entity> salvageChildren;
std::vector<entt::entity> repairChildren; 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) for (const PlacedModule& pm : modules)
{ {
const ModuleDef* modDef = findModuleDef(pm.moduleId); const ModuleDef* modDef = findModuleDef(pm.moduleId);
@@ -130,20 +141,19 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
if (modDef->salvageCapability) if (modDef->salvageCapability)
{ {
SalvageCargoComponent cargo; cargoCapacityBase += modDef->salvageCapability->cargoCapacityFormula.evaluate(mx);
cargo.capacity = static_cast<int>(
modDef->salvageCapability->cargoCapacityFormula.evaluate(mx)); SalvagerComponent salvager;
cargo.current = 0; salvager.collectionRange_tiles = static_cast<float>(
cargo.collectionRange_tiles = static_cast<float>(
modDef->salvageCapability->collectionRangeFormula.evaluate(mx)) / tileSize; modDef->salvageCapability->collectionRangeFormula.evaluate(mx)) / tileSize;
const double rate = modDef->salvageCapability->collectionRateFormula.evaluate(mx); 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) ? static_cast<int>(kTickRateHz / rate + 0.5)
: 0; : 0;
cargo.cooldownTicksRemaining = 0; salvager.cooldownTicksRemaining = 0;
entt::entity child = m_admin.createModuleEntity(); 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}); m_admin.addComponent<ModuleOwnerComponent>(child, ModuleOwnerComponent{entity});
salvageChildren.push_back(child); salvageChildren.push_back(child);
} }
@@ -151,9 +161,14 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
if (modDef->repairCapability) if (modDef->repairCapability)
{ {
RepairToolComponent rt; RepairToolComponent rt;
rt.ratePerTick = static_cast<float>( const double repairRateHz =
modDef->repairCapability->repairRateFormula.evaluate(mx)) modDef->repairCapability->repairRateFormula.evaluate(mx);
/ static_cast<float>(kTickRateHz); rt.repairIntervalTicks = (repairRateHz > 0.0)
? static_cast<int>(kTickRateHz / repairRateHz + 0.5)
: 0;
rt.repairAmountHp = static_cast<float>(
modDef->repairCapability->repairAmountHpFormula.evaluate(mx));
rt.cooldownTicksRemaining = 0;
rt.range_tiles = static_cast<float>( rt.range_tiles = static_cast<float>(
modDef->repairCapability->repairRangeFormula.evaluate(mx)) / tileSize; modDef->repairCapability->repairRangeFormula.evaluate(mx)) / tileSize;
rt.currentTarget = std::nullopt; rt.currentTarget = std::nullopt;
@@ -173,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>> weaponMods;
std::map<std::string, std::pair<double, double>> salvageMods; 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>> 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) for (const PlacedModule& pm : modules)
{ {
@@ -193,15 +210,16 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
const bool isWeaponStat = (sm.stat == "damage" const bool isWeaponStat = (sm.stat == "damage"
|| sm.stat == "attack_range" || sm.stat == "attack_range"
|| sm.stat == "attack_rate"); || sm.stat == "attack_rate");
const bool isSalvageStat = (sm.stat == "collection_range" const bool isSalvageStat = (sm.stat == "collection_range");
|| sm.stat == "cargo_capacity");
const bool isRepairStat = (sm.stat == "repair_rate" const bool isRepairStat = (sm.stat == "repair_rate"
|| sm.stat == "repair_range"); || sm.stat == "repair_range");
const bool isCargoStat = (sm.stat == "cargo_capacity");
std::map<std::string, std::pair<double, double>>* target = &hullMods; std::map<std::string, std::pair<double, double>>* target = &hullMods;
if (isWeaponStat) { target = &weaponMods; } if (isWeaponStat) { target = &weaponMods; }
if (isSalvageStat) { target = &salvageMods; } if (isSalvageStat) { target = &salvageMods; }
if (isRepairStat) { target = &repairMods; } if (isRepairStat) { target = &repairMods; }
if (isCargoStat) { target = &cargoMods; }
std::pair<double, double>& acc = (*target)[sm.stat]; std::pair<double, double>& acc = (*target)[sm.stat];
if (sm.modifierType == "multiplicative") if (sm.modifierType == "multiplicative")
@@ -294,42 +312,86 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
// Apply salvage modifiers to each salvage child. // Apply salvage modifiers to each salvage child.
for (entt::entity child : salvageChildren) for (entt::entity child : salvageChildren)
{ {
SalvageCargoComponent& c = m_admin.get<SalvageCargoComponent>(child); SalvagerComponent& c = m_admin.get<SalvagerComponent>(child);
float fRange = c.collectionRange_tiles; float fRange = c.collectionRange_tiles;
float fCapacity = static_cast<float>(c.capacity);
// Apply rate modifier: compute rate from interval, apply multiplier, convert back. // Apply rate modifier: compute rate from interval, apply multiplier, convert back.
float fRate = (c.collectionIntervalTicks > 0) float fRate = (c.collectionIntervalTicks > 0)
? static_cast<float>(kTickRateHz) / static_cast<float>(c.collectionIntervalTicks) ? static_cast<float>(kTickRateHz) / static_cast<float>(c.collectionIntervalTicks)
: 0.0f; : 0.0f;
applyMod(fRange, "collection_range", salvageMods); applyMod(fRange, "collection_range", salvageMods);
applyMod(fCapacity, "cargo_capacity", salvageMods);
applyMod(fRate, "collection_rate", salvageMods); applyMod(fRate, "collection_rate", salvageMods);
c.collectionRange_tiles = fRange; c.collectionRange_tiles = fRange;
c.capacity = static_cast<int>(fCapacity + 0.5f);
c.collectionIntervalTicks = (fRate > 0.0f) c.collectionIntervalTicks = (fRate > 0.0f)
? static_cast<int>(static_cast<float>(kTickRateHz) / fRate + 0.5f) ? static_cast<int>(static_cast<float>(kTickRateHz) / fRate + 0.5f)
: 0; : 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. // Apply repair modifiers to each repair child.
for (entt::entity child : repairChildren) for (entt::entity child : repairChildren)
{ {
RepairToolComponent& rt = m_admin.get<RepairToolComponent>(child); RepairToolComponent& rt = m_admin.get<RepairToolComponent>(child);
applyMod(rt.ratePerTick, "repair_rate", repairMods); // Apply rate modifier: compute cycles/s from interval, apply, convert back.
float fRate = (rt.repairIntervalTicks > 0)
? static_cast<float>(kTickRateHz) / static_cast<float>(rt.repairIntervalTicks)
: 0.0f;
applyMod(fRate, "repair_rate", repairMods);
applyMod(rt.range_tiles, "repair_range", repairMods); applyMod(rt.range_tiles, "repair_range", repairMods);
rt.repairIntervalTicks = (fRate > 0.0f)
? static_cast<int>(static_cast<float>(kTickRateHz) / fRate + 0.5f)
: 0;
} }
// --- Pass 3: attach behavior components based on capability presence ----- // --- Pass 3: attach behavior components based on capability presence -----
// Baseline: every ship can always fall back to advancing, and needs a slot
// for the per-tick behavior selection result.
m_admin.addComponent<AdvanceBehavior>(entity, AdvanceBehavior{});
m_admin.addComponent<SelectedBehaviorComponent>(entity, SelectedBehaviorComponent{});
// Player ships retreat to the rally point when threatened or badly damaged
// (disabled by the balancing tool to keep arena fights symmetric).
if (!isEnemy && m_retreatEnabled)
{
RetreatBehavior retreat;
retreat.retreatHpFraction = BehaviorScores::kLowHpFraction;
retreat.retreatPoint = m_rallyPoint;
m_admin.addComponent<RetreatBehavior>(entity, retreat);
}
if (!weaponChildren.empty()) if (!weaponChildren.empty())
{ {
m_admin.addComponent<ThreatResponseBehaviorComponent>( float maxWeaponRange = 0.0f;
entity, ThreatResponseBehaviorComponent{}); for (entt::entity child : weaponChildren)
{
const float r = m_admin.get<WeaponComponent>(child).range_tiles;
if (r > maxWeaponRange) { maxWeaponRange = r; }
}
AttackBehavior attack;
attack.orbitRadius_tiles =
maxWeaponRange * static_cast<float>(m_config.world.orbitFactor);
m_admin.addComponent<AttackBehavior>(entity, attack);
if (!isEnemy) if (!isEnemy)
{ {
m_admin.addComponent<RallyBehaviorComponent>( RallyBehavior rally;
entity, RallyBehaviorComponent{m_rallyPoint}); rally.rallyPoint = m_rallyPoint;
rally.orbitRadius_tiles =
static_cast<float>(m_config.world.rallyOrbitRadius_tiles);
m_admin.addComponent<RallyBehavior>(entity, rally);
} }
} }
@@ -338,15 +400,20 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
float maxCollRange = 0.0f; float maxCollRange = 0.0f;
for (entt::entity child : salvageChildren) 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; } if (r > maxCollRange) { maxCollRange = r; }
} }
SalvageBehaviorComponent sb; SalvageScrapBehavior salvage;
sb.scrapTarget = std::nullopt; salvage.scrapTarget = std::nullopt;
sb.deliveryBay = kInvalidBuildingId; salvage.maxCollectionRange_tiles = maxCollRange;
sb.maxCollectionRange_tiles = maxCollRange; salvage.orbitRadius_tiles =
m_admin.addComponent<SalvageBehaviorComponent>(entity, sb); maxCollRange * static_cast<float>(m_config.world.orbitFactor);
m_admin.addComponent<SalvageScrapBehavior>(entity, salvage);
DeliverScrapBehavior deliver;
deliver.deliveryBay = kInvalidBuildingId;
m_admin.addComponent<DeliverScrapBehavior>(entity, deliver);
} }
if (!repairChildren.empty()) if (!repairChildren.empty())
@@ -358,10 +425,17 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
if (r > maxRepairRange) { maxRepairRange = r; } if (r > maxRepairRange) { maxRepairRange = r; }
} }
RepairBehaviorComponent rb; RepairBehavior repair;
rb.currentTarget = std::nullopt; repair.currentTarget = std::nullopt;
rb.maxRepairRange_tiles = maxRepairRange; repair.maxRepairRange_tiles = maxRepairRange;
m_admin.addComponent<RepairBehaviorComponent>(entity, rb); repair.orbitRadius_tiles =
maxRepairRange * static_cast<float>(m_config.world.orbitFactor);
m_admin.addComponent<RepairBehavior>(entity, repair);
// Repair-capable ships hold with the fleet (REQ-SHP-STANDBY) instead of
// charging the enemy when no more urgent behavior applies; this applies
// whether or not the ship also carries weapons.
m_admin.addComponent<StandbyBehavior>(entity, StandbyBehavior{});
} }
return entity; return entity;
@@ -385,7 +459,7 @@ void ShipSystem::clearMovementIntents()
m_admin.forEach<MovementIntentComponent>( m_admin.forEach<MovementIntentComponent>(
[](entt::entity /*e*/, MovementIntentComponent& i) [](entt::entity /*e*/, MovementIntentComponent& i)
{ {
i = MovementIntentComponent{0, QVector2D(0.0f, 0.0f)}; i = MovementIntentComponent{false, QVector2D(0.0f, 0.0f)};
}); });
} }
@@ -394,12 +468,17 @@ void ShipSystem::setRallyPoint(QVector2D point)
m_rallyPoint = point; m_rallyPoint = point;
} }
void ShipSystem::setRetreatEnabled(bool enabled)
{
m_retreatEnabled = enabled;
}
void ShipSystem::triggerRallyDeparture() void ShipSystem::triggerRallyDeparture()
{ {
TRACE(); TRACE();
std::vector<entt::entity> toRemove; std::vector<entt::entity> toRemove;
m_admin.forEach<RallyBehaviorComponent, FactionComponent>( m_admin.forEach<RallyBehavior, FactionComponent>(
[&toRemove](entt::entity e, const RallyBehaviorComponent& /*rb*/, [&toRemove](entt::entity e, const RallyBehavior& /*rb*/,
const FactionComponent& f) const FactionComponent& f)
{ {
if (!f.isEnemy) if (!f.isEnemy)
@@ -409,6 +488,6 @@ void ShipSystem::triggerRallyDeparture()
}); });
for (entt::entity e : toRemove) for (entt::entity e : toRemove)
{ {
m_admin.removeComponent<RallyBehaviorComponent>(e); m_admin.removeComponent<RallyBehavior>(e);
} }
} }

View File

@@ -24,7 +24,7 @@ public:
const std::map<std::string, int>& moduleLevelOverrides = {}); const std::map<std::string, int>& moduleLevelOverrides = {});
void despawn(entt::entity entity); void despawn(entt::entity entity);
// Reset all movement intents to priority 0 before behavior systems run. // Reset all movement intents to inactive before behavior systems run.
void clearMovementIntents(); void clearMovementIntents();
// Set the rally point that newly spawned player combat ships will loiter at. // Set the rally point that newly spawned player combat ships will loiter at.
@@ -33,6 +33,11 @@ public:
// Release all gathered player combat ships to advance toward the enemy. // Release all gathered player combat ships to advance toward the enemy.
void triggerRallyDeparture(); void triggerRallyDeparture();
// Controls whether newly spawned player ships receive a RetreatBehavior. The
// balancing tool disables this so arena fights stay symmetric and aggressive
// (REQ-BAL-SIM-AI); the main game keeps it enabled (REQ-SHP-RETREAT).
void setRetreatEnabled(bool enabled);
private: private:
const ShipDef* findShipDef(const std::string& schematicId) const; const ShipDef* findShipDef(const std::string& schematicId) const;
const ModuleDef* findModuleDef(const std::string& id) const; const ModuleDef* findModuleDef(const std::string& id) const;
@@ -40,4 +45,5 @@ private:
const GameConfig& m_config; const GameConfig& m_config;
EntityAdmin& m_admin; EntityAdmin& m_admin;
QVector2D m_rallyPoint; QVector2D m_rallyPoint;
bool m_retreatEnabled = true;
}; };

View File

@@ -0,0 +1,16 @@
#include "AdvanceEvaluator.h"
#include "AdvanceBehavior.h"
#include "BehaviorScores.h"
#include "EntityAdmin.h"
#include "tracing.h"
void AdvanceEvaluator::evaluate(EntityAdmin& admin)
{
TRACE();
admin.forEach<AdvanceBehavior>(
[](entt::entity /*e*/, AdvanceBehavior& advance)
{
advance.score = BehaviorScores::kAdvance;
});
}

View File

@@ -0,0 +1,11 @@
#pragma once
class EntityAdmin;
// Baseline fallback: gives every ship a constant low score so there is always a
// winning behavior. The actual movement direction is decided by AdvanceExecutor.
class AdvanceEvaluator
{
public:
void evaluate(EntityAdmin& admin);
};

View File

@@ -0,0 +1,112 @@
#include "AdvanceExecutor.h"
#include <optional>
#include <QVector2D>
#include "AdvanceBehavior.h"
#include "BehaviorKind.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "MovementIntentComponent.h"
#include "PositionComponent.h"
#include "SelectedBehaviorComponent.h"
#include "StationBodyComponent.h"
#include "tracing.h"
namespace
{
// Accumulates positions to produce their centroid (the center between them).
struct Centroid
{
QVector2D sum;
int count = 0;
void add(const QVector2D& point)
{
sum += point;
count += 1;
}
std::optional<QVector2D> value() const
{
if (count == 0) { return std::nullopt; }
return sum / static_cast<float>(count);
}
};
}
void AdvanceExecutor::execute(EntityAdmin& admin)
{
TRACE();
// Centroid of each faction's alive defence stations. In the arena the HQ is
// spawned as a station, so it is part of this centroid; in the main game the
// enemy side has only its defence stations.
Centroid enemyStations;
Centroid playerStations;
admin.forEach<StationBodyComponent, PositionComponent, FactionComponent, HealthComponent>(
[&enemyStations, &playerStations](entt::entity /*e*/,
const StationBodyComponent& /*sb*/, const PositionComponent& pos,
const FactionComponent& faction, const HealthComponent& health)
{
if (health.hp <= 0.0f) { return; }
Centroid& centroid = faction.isEnemy ? enemyStations : playerStations;
centroid.add(pos.value);
});
// Fallback target per faction: the HQ proxy (main game only), used when a side
// has lost all of its defence stations.
Centroid enemyHq;
Centroid playerHq;
admin.forEach<HqProxyComponent, PositionComponent, FactionComponent, HealthComponent>(
[&enemyHq, &playerHq](entt::entity /*e*/, const HqProxyComponent& /*hq*/,
const PositionComponent& pos, const FactionComponent& faction,
const HealthComponent& health)
{
if (health.hp <= 0.0f) { return; }
Centroid& centroid = faction.isEnemy ? enemyHq : playerHq;
centroid.add(pos.value);
});
const std::optional<QVector2D> enemyStationCenter = enemyStations.value();
const std::optional<QVector2D> playerStationCenter = playerStations.value();
const std::optional<QVector2D> enemyHqCenter = enemyHq.value();
const std::optional<QVector2D> playerHqCenter = playerHq.value();
admin.forEach<AdvanceBehavior, SelectedBehaviorComponent, PositionComponent,
FactionComponent, MovementIntentComponent>(
[&](entt::entity /*e*/, const AdvanceBehavior& /*advance*/,
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
const FactionComponent& faction, MovementIntentComponent& intent)
{
if (selected.winner != BehaviorKind::Advance) { return; }
// Aim at the center between the opposing side's defence stations; fall
// back to the opposing HQ, then to an off-world point in the advance
// direction so the ship keeps moving when no target structure exists.
const std::optional<QVector2D>& stationCenter =
faction.isEnemy ? playerStationCenter : enemyStationCenter;
const std::optional<QVector2D>& hqCenter =
faction.isEnemy ? playerHqCenter : enemyHqCenter;
QVector2D target;
if (stationCenter)
{
target = *stationCenter;
}
else if (hqCenter)
{
target = *hqCenter;
}
else
{
target = faction.isEnemy
? QVector2D(-10000.0f, pos.value.y())
: QVector2D(pos.value.x() + 1000.0f, pos.value.y());
}
intent = MovementIntentComponent{true, target};
});
}

View File

@@ -0,0 +1,11 @@
#pragma once
class EntityAdmin;
// Moves a ship toward the opposing side when Advance is the winning behavior:
// player ships advance toward +x (the enemy), enemy ships toward -x (the base).
class AdvanceExecutor
{
public:
void execute(EntityAdmin& admin);
};

View File

@@ -0,0 +1,146 @@
#include "AttackEvaluator.h"
#include <algorithm>
#include <unordered_map>
#include <vector>
#include <QVector2D>
#include "AttackBehavior.h"
#include "BehaviorScores.h"
#include "BehaviorTargeting.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "ModuleOwnerComponent.h"
#include "PositionComponent.h"
#include "SensorRangeComponent.h"
#include "tracing.h"
#include "WeaponComponent.h"
#include "WorldConfig.h"
AttackEvaluator::AttackEvaluator(const WorldTargeting& targeting)
: m_targeting(&targeting)
{
}
void AttackEvaluator::evaluate(EntityAdmin& admin)
{
TRACE();
const std::vector<CombatantInfo> combatants = buildCombatants(admin);
// Pass A: the maximum weapon range per ship, used to normalise target
// distance. Ships without a weapon fall back to their sensor range below.
std::unordered_map<entt::entity, float> maxWeaponRange_tiles;
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
[&maxWeaponRange_tiles](entt::entity /*we*/, const WeaponComponent& weapon,
const ModuleOwnerComponent& owner)
{
float& best = maxWeaponRange_tiles[owner.owner];
best = std::max(best, weapon.range_tiles);
});
// Pass B: claim counts, taken from every ship's current target before any
// target is reassigned this tick. Each ship reads the previous tick's claim
// state and excludes its own contribution when scoring its current target.
std::unordered_map<entt::entity, int> claimsByTarget;
admin.forEach<AttackBehavior>(
[&claimsByTarget, &admin](entt::entity /*e*/, const AttackBehavior& attack)
{
if (attack.currentTarget && admin.isValid(*attack.currentTarget))
{
++claimsByTarget[*attack.currentTarget];
}
});
// Pass C: per-ship target selection.
admin.forEach<AttackBehavior, PositionComponent, FactionComponent,
SensorRangeComponent>(
[&](entt::entity e, AttackBehavior& attack, const PositionComponent& pos,
const FactionComponent& faction, const SensorRangeComponent& sensor)
{
const float sensorRange_tiles = sensor.value_tiles;
// Distance normaliser: max weapon range, or sensor range if unarmed.
float weaponRange_tiles = sensorRange_tiles;
const auto weaponRangeIt = maxWeaponRange_tiles.find(e);
if (weaponRangeIt != maxWeaponRange_tiles.end() && weaponRangeIt->second > 0.0f)
{
weaponRange_tiles = weaponRangeIt->second;
}
// Scores a single candidate: base desirability from distance, reduced
// by the overclaim penalty. selfClaimed subtracts this ship's own claim
// so it does not penalise the target it already holds.
const auto scoreOf =
[&](const QVector2D& candidatePos, entt::entity candidate) -> float
{
const float dist = (candidatePos - pos.value).length();
const float x = dist / weaponRange_tiles;
float base = static_cast<float>(m_targeting->targetScoreFormula.evaluate(x));
base = std::max(base, 0.0f);
int claims = 0;
const auto claimIt = claimsByTarget.find(candidate);
if (claimIt != claimsByTarget.end()) { claims = claimIt->second; }
if (attack.currentTarget && candidate == *attack.currentTarget) { --claims; }
float penalty = static_cast<float>(
m_targeting->overclaimPenaltyFormula.evaluate(claims));
penalty = std::clamp(penalty, 0.0f, 1.0f);
return base * penalty;
};
// Find the best candidate among in-range enemies.
std::optional<entt::entity> bestTarget;
float bestScore = 0.0f;
for (const CombatantInfo& c : combatants)
{
if (c.entity == e) { continue; }
const bool isValidTarget = faction.isEnemy ? !c.isEnemy : c.isEnemy;
if (!isValidTarget) { continue; }
const float dist = (c.position - pos.value).length();
if (dist > sensorRange_tiles) { continue; }
const float score = scoreOf(c.position, c.entity);
if (!bestTarget || score > bestScore)
{
bestScore = score;
bestTarget = c.entity;
}
}
// Hysteresis: keep the current target if it is still valid and in
// range, unless a challenger beats its score by more than the margin.
bool keptCurrent = false;
if (attack.currentTarget)
{
const entt::entity t = *attack.currentTarget;
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
{
const QVector2D targetPos = admin.get<PositionComponent>(t).value;
const float dist = (targetPos - pos.value).length();
if (dist <= sensorRange_tiles)
{
const float currentScore = scoreOf(targetPos, t);
const float margin = 1.0f + static_cast<float>(m_targeting->hysteresis);
if (!bestTarget || bestScore <= currentScore * margin)
{
keptCurrent = true;
}
}
}
}
if (!keptCurrent)
{
attack.currentTarget = bestTarget;
}
attack.score = attack.currentTarget
? BehaviorScores::kAttack
: BehaviorScores::kInactive;
});
}

View File

@@ -0,0 +1,22 @@
#pragma once
class EntityAdmin;
struct WorldTargeting;
// Acquires/validates a combat target for ships with weapons. Scores high only
// when the ship's health is not low and a valid target is within sensor range.
//
// Target choice is claim-aware: each tick the desirability of every candidate is
// scored from a configurable distance formula and reduced by a soft overclaim
// penalty that scales with how many other ships already target it, spreading
// ships across enemies instead of dogpiling the nearest one.
class AttackEvaluator
{
public:
explicit AttackEvaluator(const WorldTargeting& targeting);
void evaluate(EntityAdmin& admin);
private:
const WorldTargeting* m_targeting;
};

View File

@@ -0,0 +1,69 @@
#include "AttackExecutor.h"
#include "AttackBehavior.h"
#include "BehaviorKind.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "ModuleOwnerComponent.h"
#include "MovementIntentComponent.h"
#include "PositionComponent.h"
#include "SelectedBehaviorComponent.h"
#include "tracing.h"
#include "WeaponComponent.h"
void AttackExecutor::execute(EntityAdmin& admin)
{
TRACE();
// Ships: move toward the behavior target.
admin.forEach<AttackBehavior, SelectedBehaviorComponent, PositionComponent,
MovementIntentComponent>(
[&](entt::entity /*e*/, const AttackBehavior& attack,
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
MovementIntentComponent& intent)
{
if (selected.winner != BehaviorKind::Attack) { return; }
if (!attack.currentTarget) { return; }
const entt::entity t = *attack.currentTarget;
QVector2D center = pos.value;
float radius = 0.0f;
QVector2D centerVelocity;
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
{
center = admin.get<PositionComponent>(t).value;
radius = attack.orbitRadius_tiles;
if (admin.hasAll<DynamicBodyComponent>(t))
{
centerVelocity = admin.get<DynamicBodyComponent>(t).velocity_tpt;
}
}
intent = MovementIntentComponent{true, center, radius, centerVelocity};
});
// Weapons: assign the behavior target only if it is within this weapon's range.
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
[&](entt::entity /*we*/, WeaponComponent& weapon, const ModuleOwnerComponent& owner)
{
if (!admin.hasAll<AttackBehavior, SelectedBehaviorComponent>(owner.owner))
{
return;
}
const SelectedBehaviorComponent& selected =
admin.get<SelectedBehaviorComponent>(owner.owner);
if (selected.winner != BehaviorKind::Attack) { return; }
const AttackBehavior& attack = admin.get<AttackBehavior>(owner.owner);
if (!attack.currentTarget) { return; }
const entt::entity t = *attack.currentTarget;
if (!admin.isValid(t) || !admin.hasAll<PositionComponent>(t)) { return; }
const QVector2D ownerPos = admin.get<PositionComponent>(owner.owner).value;
const float dist = (admin.get<PositionComponent>(t).value - ownerPos).length();
if (dist <= weapon.range_tiles)
{
weapon.currentTarget = t;
}
});
}

View File

@@ -0,0 +1,12 @@
#pragma once
class EntityAdmin;
// When Attack wins, moves the ship toward its target and assigns that target to
// each weapon that has it in range. Weapons whose range excludes the target are
// left untouched so CombatSystem can keep/acquire a closer target (no thrash).
class AttackExecutor
{
public:
void execute(EntityAdmin& admin);
};

View File

@@ -0,0 +1,85 @@
#include "BehaviorTargeting.h"
#include "CargoComponent.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "PositionComponent.h"
#include "ShipIdentityComponent.h"
#include "StationBodyComponent.h"
std::vector<RepairableInfo> buildRepairables(EntityAdmin& admin)
{
std::vector<RepairableInfo> repairables;
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent, HealthComponent>(
[&repairables](entt::entity e, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& f,
const HealthComponent& h)
{
repairables.push_back({e, pos.value, f.isEnemy, true, h.hp, h.maxHp});
});
admin.forEach<StationBodyComponent, PositionComponent, FactionComponent, HealthComponent>(
[&repairables, &admin](entt::entity e, const StationBodyComponent& /*sb*/,
const PositionComponent& pos, const FactionComponent& f,
const HealthComponent& h)
{
// The HQ is not a repair target — only ships and defence stations are
// (REQ-SHP-REPAIR). In the balancing arena the HQ is spawned as a station,
// so it is identified by its HqProxyComponent tag.
if (admin.hasAll<HqProxyComponent>(e)) { return; }
repairables.push_back({e, pos.value, f.isEnemy, false, h.hp, h.maxHp});
});
return repairables;
}
std::vector<CombatantInfo> buildCombatants(EntityAdmin& admin)
{
std::vector<CombatantInfo> combatants;
admin.forEach<PositionComponent, FactionComponent, ShipIdentityComponent>(
[&combatants](entt::entity e, const PositionComponent& pos,
const FactionComponent& f, const ShipIdentityComponent& /*si*/)
{
combatants.push_back({e, pos.value, f.isEnemy, false});
});
admin.forEach<PositionComponent, FactionComponent, StationBodyComponent>(
[&combatants](entt::entity e, const PositionComponent& pos,
const FactionComponent& f, const StationBodyComponent& /*sb*/)
{
combatants.push_back({e, pos.value, f.isEnemy, true});
});
admin.forEach<PositionComponent, FactionComponent, HqProxyComponent>(
[&combatants, &admin](entt::entity e, const PositionComponent& pos,
const FactionComponent& f, const HqProxyComponent& /*hq*/)
{
// An arena HQ carries both StationBodyComponent and HqProxyComponent; it
// is already listed by the station pass above, so skip it here to avoid
// counting it twice.
if (admin.hasAll<StationBodyComponent>(e)) { return; }
combatants.push_back({e, pos.value, f.isEnemy, true});
});
return combatants;
}
std::unordered_map<entt::entity, CargoState> buildCargoByShip(EntityAdmin& admin)
{
std::unordered_map<entt::entity, CargoState> cargoByShip;
admin.forEach<CargoComponent>(
[&cargoByShip](entt::entity ship, const CargoComponent& c)
{
cargoByShip[ship] = CargoState{c.current, c.maxCapacity};
});
return cargoByShip;
}
bool isCargoFull(const CargoState& cargo)
{
return cargo.capacity > 0 && cargo.current >= cargo.capacity;
}

View File

@@ -0,0 +1,49 @@
#pragma once
#include <unordered_map>
#include <vector>
#include <QVector2D>
#include "entt/entity/entity.hpp"
class EntityAdmin;
// Shared, per-call target snapshots used by behavior evaluators and the repair
// system. Each caller builds its own snapshot (no cross-system caching).
struct RepairableInfo
{
entt::entity entity;
QVector2D position;
bool isEnemy;
bool isShip;
float hp;
float maxHp;
};
struct CombatantInfo
{
entt::entity entity;
QVector2D position;
bool isEnemy;
bool isStation;
};
struct CargoState
{
int current = 0;
int capacity = 0;
};
// All ships and stations with health — candidates for repair targeting.
std::vector<RepairableInfo> buildRepairables(EntityAdmin& admin);
// All ships, stations, and the HQ proxy — candidates for attack targeting.
std::vector<CombatantInfo> buildCombatants(EntityAdmin& admin);
// 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).
bool isCargoFull(const CargoState& cargo);

View File

@@ -0,0 +1,43 @@
#include "DeliverScrapEvaluator.h"
#include <unordered_map>
#include "BehaviorScores.h"
#include "BehaviorTargeting.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "DeliverScrapBehavior.h"
#include "EntityAdmin.h"
#include "PositionComponent.h"
#include "tracing.h"
void DeliverScrapEvaluator::evaluate(EntityAdmin& admin, const BuildingSystem& buildings)
{
TRACE();
const std::unordered_map<entt::entity, CargoState> cargoByShip = buildCargoByShip(admin);
admin.forEach<DeliverScrapBehavior, PositionComponent>(
[&](entt::entity e, DeliverScrapBehavior& deliver, const PositionComponent& pos)
{
const std::unordered_map<entt::entity, CargoState>::const_iterator it =
cargoByShip.find(e);
const bool cargoFull = (it != cargoByShip.end()) && isCargoFull(it->second);
if (!cargoFull)
{
deliver.score = BehaviorScores::kInactive;
return;
}
// Assign nearest SalvageBay if not yet assigned.
if (deliver.deliveryBay == kInvalidBuildingId)
{
const Building* bay =
buildings.findNearestBuilding(pos.value, BuildingType::SalvageBay);
if (bay) { deliver.deliveryBay = bay->id; }
}
deliver.score = BehaviorScores::kDeliver;
});
}

View File

@@ -0,0 +1,12 @@
#pragma once
class EntityAdmin;
class BuildingSystem;
// Scores high only when the ship's cargo is full, and assigns the nearest
// SalvageBay as the delivery destination.
class DeliverScrapEvaluator
{
public:
void evaluate(EntityAdmin& admin, const BuildingSystem& buildings);
};

View File

@@ -0,0 +1,38 @@
#include "DeliverScrapExecutor.h"
#include <QVector2D>
#include "BehaviorKind.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "DeliverScrapBehavior.h"
#include "EntityAdmin.h"
#include "MovementIntentComponent.h"
#include "PositionComponent.h"
#include "SelectedBehaviorComponent.h"
#include "tracing.h"
void DeliverScrapExecutor::execute(EntityAdmin& admin, const BuildingSystem& buildings)
{
TRACE();
admin.forEach<DeliverScrapBehavior, SelectedBehaviorComponent, PositionComponent,
MovementIntentComponent>(
[&](entt::entity /*e*/, const DeliverScrapBehavior& deliver,
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
MovementIntentComponent& intent)
{
if (selected.winner != BehaviorKind::DeliverScrap) { return; }
QVector2D dest = pos.value;
if (deliver.deliveryBay != kInvalidBuildingId)
{
const Building* bay = buildings.findBuilding(deliver.deliveryBay);
if (bay)
{
dest = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f,
bay->anchor.y() + bay->footprint.height() / 2.0f);
}
}
intent = MovementIntentComponent{true, dest};
});
}

View File

@@ -0,0 +1,12 @@
#pragma once
class EntityAdmin;
class BuildingSystem;
// Moves a ship toward its delivery bay when DeliverScrap is the winning
// behavior. Never decrements cargo — SalvagerSystem performs the delivery.
class DeliverScrapExecutor
{
public:
void execute(EntityAdmin& admin, const BuildingSystem& buildings);
};

View File

@@ -0,0 +1,88 @@
#pragma once
#include <cmath>
#include <QVector2D>
// Orbit movement helper (REQ-SHP-ORBIT). Behaviors that keep a ship circling a
// target (attack, repair, salvage, rally) supply an orbit center and radius via
// the movement intent; MovementIntentSystem resolves the orbit direction and
// destination using these helpers.
namespace OrbitMath
{
// Lead angle (radians) by which the radial direction is rotated to produce
// tangential motion. The orbit direction (sign of the rotation) is chosen
// per ship by resolveOrbitSign from the ship's current velocity, so ships
// approaching a target from different sides circle it in different senses
// instead of all bunching on one side.
constexpr float kOrbitLeadAngle_rad = 0.6f;
// Returns the orbit sense (+1 counter-clockwise, -1 clockwise) that matches
// the ship's movement around `center`, so steering reinforces the motion the
// ship already has. The sense is taken from the ship's velocity *relative to
// the center* (`centerVelocity`): for a moving target this both removes the
// target's own motion from the decision and dissolves the degenerate case
// where two ships orbiting each other translate in a straight line — there
// their shared velocity cancels, leaving ~zero relative velocity. When the
// relative velocity is nearly radial or near zero (a head-on approach, a
// freshly spawned ship, or that mutual-translation case) the sense is
// ill-defined; this is an unstable point the ship leaves within a tick or
// two, so a deterministic fallback of +1 is returned.
inline float resolveOrbitSign(const QVector2D& shipPos, const QVector2D& center,
const QVector2D& velocity,
const QVector2D& centerVelocity = QVector2D())
{
const QVector2D radial = shipPos - center;
const QVector2D relativeVelocity = velocity - centerVelocity;
const float radialLength = radial.length();
const float velocityLength = relativeVelocity.length();
if (radialLength < 1.0e-4f || velocityLength < 1.0e-4f)
{
return 1.0f;
}
// z-component of radial x relativeVelocity, normalised to sin(angle).
const float cross = radial.x() * relativeVelocity.y()
- radial.y() * relativeVelocity.x();
const float sinAngle = cross / (radialLength * velocityLength);
constexpr float kRadialEpsilon = 1.0e-3f;
if (std::abs(sinAngle) < kRadialEpsilon)
{
return 1.0f;
}
return (sinAngle > 0.0f) ? 1.0f : -1.0f;
}
// Returns a destination on the orbit circle of `radius` around `center`. The
// result always lies exactly `radius` from `center`, so steering toward it
// both corrects the standoff distance and advances the ship tangentially.
// `sign` selects the orbit sense (+1 counter-clockwise, -1 clockwise). A
// radius of zero or less falls back to the center (legacy "approach the
// target" behavior), e.g. when the ship has no tool range to orbit at.
inline QVector2D computeOrbitDestination(const QVector2D& shipPos,
const QVector2D& center, float radius,
float sign = 1.0f)
{
if (radius <= 0.0f) { return center; }
QVector2D radial = shipPos - center;
float length = radial.length();
if (length < 1.0e-4f)
{
// Ship sits on the center; pick an arbitrary radial direction.
radial = QVector2D(1.0f, 0.0f);
length = 1.0f;
}
const QVector2D radialDirection = radial / length;
const float leadAngle = sign * kOrbitLeadAngle_rad;
const float cosLead = std::cos(leadAngle);
const float sinLead = std::sin(leadAngle);
const QVector2D leadDirection(
radialDirection.x() * cosLead - radialDirection.y() * sinLead,
radialDirection.x() * sinLead + radialDirection.y() * cosLead);
return center + radius * leadDirection;
}
}

View File

@@ -0,0 +1,16 @@
#include "RallyEvaluator.h"
#include "BehaviorScores.h"
#include "EntityAdmin.h"
#include "RallyBehavior.h"
#include "tracing.h"
void RallyEvaluator::evaluate(EntityAdmin& admin)
{
TRACE();
admin.forEach<RallyBehavior>(
[](entt::entity /*e*/, RallyBehavior& rally)
{
rally.score = BehaviorScores::kRally;
});
}

View File

@@ -0,0 +1,12 @@
#pragma once
class EntityAdmin;
// Scores the rally behavior so player combat ships gather at the rally point
// until an enemy appears (Attack outscores it) or the departure timer removes
// the RallyBehavior component.
class RallyEvaluator
{
public:
void evaluate(EntityAdmin& admin);
};

View File

@@ -0,0 +1,23 @@
#include "RallyExecutor.h"
#include "BehaviorKind.h"
#include "EntityAdmin.h"
#include "MovementIntentComponent.h"
#include "RallyBehavior.h"
#include "SelectedBehaviorComponent.h"
#include "tracing.h"
void RallyExecutor::execute(EntityAdmin& admin)
{
TRACE();
admin.forEach<RallyBehavior, SelectedBehaviorComponent,
MovementIntentComponent>(
[](entt::entity /*e*/, const RallyBehavior& rally,
const SelectedBehaviorComponent& selected,
MovementIntentComponent& intent)
{
if (selected.winner != BehaviorKind::Rally) { return; }
intent = MovementIntentComponent{true, rally.rallyPoint,
rally.orbitRadius_tiles};
});
}

View File

@@ -0,0 +1,10 @@
#pragma once
class EntityAdmin;
// Moves a ship to its rally point when Rally is the winning behavior.
class RallyExecutor
{
public:
void execute(EntityAdmin& admin);
};

View File

@@ -0,0 +1,64 @@
#include "RepairEvaluator.h"
#include <vector>
#include "BehaviorScores.h"
#include "BehaviorTargeting.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "PositionComponent.h"
#include "RepairBehavior.h"
#include "SensorRangeComponent.h"
#include "tracing.h"
void RepairEvaluator::evaluate(EntityAdmin& admin)
{
TRACE();
const std::vector<RepairableInfo> repairables = buildRepairables(admin);
admin.forEach<RepairBehavior, PositionComponent, SensorRangeComponent, FactionComponent>(
[&](entt::entity e, RepairBehavior& repair, const PositionComponent& pos,
const SensorRangeComponent& sensor, const FactionComponent& faction)
{
// Validate current target: same faction, alive and still damaged.
bool targetValid = false;
if (repair.currentTarget)
{
const entt::entity t = *repair.currentTarget;
if (admin.isValid(t) && admin.hasAll<HealthComponent, FactionComponent>(t))
{
const HealthComponent& th = admin.get<HealthComponent>(t);
const FactionComponent& tf = admin.get<FactionComponent>(t);
if (tf.isEnemy == faction.isEnemy && th.hp > 0.0f && th.hp < th.maxHp)
{
targetValid = true;
}
}
}
// Acquire nearest damaged friendly within sensor range. Friendly is
// relative to this ship's faction, not the absolute isEnemy flag.
if (!targetValid)
{
repair.currentTarget = std::nullopt;
float bestDist = sensor.value_tiles;
for (const RepairableInfo& r : repairables)
{
if (r.entity == e) { continue; }
if (r.isEnemy != faction.isEnemy) { continue; }
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
const float dist = (r.position - pos.value).length();
if (dist < bestDist)
{
bestDist = dist;
repair.currentTarget = r.entity;
}
}
}
repair.score = repair.currentTarget
? BehaviorScores::kRepair
: BehaviorScores::kInactive;
});
}

View File

@@ -0,0 +1,11 @@
#pragma once
class EntityAdmin;
// Picks the nearest damaged friendly within sensor range as the repair target.
// Scores high when such a target exists.
class RepairEvaluator
{
public:
void evaluate(EntityAdmin& admin);
};

View File

@@ -0,0 +1,69 @@
#include "RepairExecutor.h"
#include "BehaviorKind.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "ModuleOwnerComponent.h"
#include "MovementIntentComponent.h"
#include "PositionComponent.h"
#include "RepairBehavior.h"
#include "RepairToolComponent.h"
#include "SelectedBehaviorComponent.h"
#include "tracing.h"
void RepairExecutor::execute(EntityAdmin& admin)
{
TRACE();
// Ships: move toward the repair target.
admin.forEach<RepairBehavior, SelectedBehaviorComponent, PositionComponent,
MovementIntentComponent>(
[&](entt::entity /*e*/, const RepairBehavior& repair,
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
MovementIntentComponent& intent)
{
if (selected.winner != BehaviorKind::Repair) { return; }
if (!repair.currentTarget) { return; }
const entt::entity t = *repair.currentTarget;
QVector2D center = pos.value;
float radius = 0.0f;
QVector2D centerVelocity;
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
{
center = admin.get<PositionComponent>(t).value;
radius = repair.orbitRadius_tiles;
if (admin.hasAll<DynamicBodyComponent>(t))
{
centerVelocity = admin.get<DynamicBodyComponent>(t).velocity_tpt;
}
}
intent = MovementIntentComponent{true, center, radius, centerVelocity};
});
// Repair tools: prefer the behavior target if it is within tool range.
admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
[&](entt::entity /*re*/, RepairToolComponent& tool, const ModuleOwnerComponent& owner)
{
if (!admin.hasAll<RepairBehavior, SelectedBehaviorComponent>(owner.owner))
{
return;
}
const SelectedBehaviorComponent& selected =
admin.get<SelectedBehaviorComponent>(owner.owner);
if (selected.winner != BehaviorKind::Repair) { return; }
const RepairBehavior& repair = admin.get<RepairBehavior>(owner.owner);
if (!repair.currentTarget) { return; }
const entt::entity t = *repair.currentTarget;
if (!admin.isValid(t) || !admin.hasAll<PositionComponent>(t)) { return; }
const QVector2D ownerPos = admin.get<PositionComponent>(owner.owner).value;
const float dist = (admin.get<PositionComponent>(t).value - ownerPos).length();
if (dist <= tool.range_tiles)
{
tool.currentTarget = t;
}
});
}

View File

@@ -0,0 +1,12 @@
#pragma once
class EntityAdmin;
// When Repair wins, moves the ship toward its target and assigns that target to
// each repair tool that has it in range. RepairSystem applies the healing and
// does fallback acquisition for tools whose preferred target is out of range.
class RepairExecutor
{
public:
void execute(EntityAdmin& admin);
};

View File

@@ -0,0 +1,86 @@
#include "RetreatEvaluator.h"
#include <vector>
#include <QVector2D>
#include "AttackBehavior.h"
#include "BehaviorScores.h"
#include "BehaviorTargeting.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "PositionComponent.h"
#include "RepairBehavior.h"
#include "RetreatBehavior.h"
#include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
#include "tracing.h"
void RetreatEvaluator::evaluate(EntityAdmin& admin)
{
TRACE();
// Snapshot enemy ship positions for threat detection.
std::vector<QVector2D> enemyShips;
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent>(
[&enemyShips](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& f)
{
if (f.isEnemy) { enemyShips.push_back(pos.value); }
});
// Snapshot repairables so weaponless repair ships can decide whether there is
// still a damaged ally worth holding ground for.
const std::vector<RepairableInfo> repairables = buildRepairables(admin);
admin.forEach<RetreatBehavior, PositionComponent, HealthComponent,
SensorRangeComponent, FactionComponent>(
[&](entt::entity e, RetreatBehavior& retreat, const PositionComponent& pos,
const HealthComponent& health, const SensorRangeComponent& sensor,
const FactionComponent& faction)
{
const bool lowHp = (health.maxHp > 0.0f)
&& (health.hp / health.maxHp < retreat.retreatHpFraction);
bool threatened = false;
const bool hasWeapons = admin.hasAll<AttackBehavior>(e);
if (!hasWeapons)
{
bool enemyInRange = false;
for (const QVector2D& enemy : enemyShips)
{
if ((enemy - pos.value).length() <= sensor.value_tiles)
{
enemyInRange = true;
break;
}
}
// A weaponless ship with a repair tool holds its ground while a
// damaged ally remains within sensor range; it only flees once
// there is nothing left to repair.
bool repairTargetInRange = false;
if (enemyInRange && admin.hasAll<RepairBehavior>(e))
{
for (const RepairableInfo& r : repairables)
{
if (r.entity == e) { continue; }
if (r.isEnemy != faction.isEnemy) { continue; }
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
if ((r.position - pos.value).length() <= sensor.value_tiles)
{
repairTargetInRange = true;
break;
}
}
}
threatened = enemyInRange && !repairTargetInRange;
}
retreat.score = (lowHp || threatened)
? BehaviorScores::kRetreat
: BehaviorScores::kInactive;
});
}

View File

@@ -0,0 +1,12 @@
#pragma once
class EntityAdmin;
// Scores high (above all task behaviors) when the ship's health is below its
// retreat threshold, or when an enemy ship is within sensor range and the ship
// has no weapons to fight back with.
class RetreatEvaluator
{
public:
void evaluate(EntityAdmin& admin);
};

View File

@@ -0,0 +1,20 @@
#include "RetreatExecutor.h"
#include "BehaviorKind.h"
#include "EntityAdmin.h"
#include "MovementIntentComponent.h"
#include "RetreatBehavior.h"
#include "SelectedBehaviorComponent.h"
#include "tracing.h"
void RetreatExecutor::execute(EntityAdmin& admin)
{
TRACE();
admin.forEach<RetreatBehavior, SelectedBehaviorComponent, MovementIntentComponent>(
[](entt::entity /*e*/, const RetreatBehavior& retreat,
const SelectedBehaviorComponent& selected, MovementIntentComponent& intent)
{
if (selected.winner != BehaviorKind::Retreat) { return; }
intent = MovementIntentComponent{true, retreat.retreatPoint};
});
}

View File

@@ -0,0 +1,10 @@
#pragma once
class EntityAdmin;
// Moves a ship to its retreat point (the rally point) when Retreat wins.
class RetreatExecutor
{
public:
void execute(EntityAdmin& admin);
};

View File

@@ -0,0 +1,55 @@
#include "SalvageScrapEvaluator.h"
#include <optional>
#include <unordered_map>
#include <vector>
#include <QVector2D>
#include "BehaviorScores.h"
#include "BehaviorTargeting.h"
#include "EntityAdmin.h"
#include "PositionComponent.h"
#include "SalvageScrapBehavior.h"
#include "ScrapSystem.h"
#include "SensorRangeComponent.h"
#include "tracing.h"
void SalvageScrapEvaluator::evaluate(EntityAdmin& admin, const ScrapSystem& scraps)
{
TRACE();
const std::unordered_map<entt::entity, CargoState> cargoByShip = buildCargoByShip(admin);
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
admin.forEach<SalvageScrapBehavior, PositionComponent, SensorRangeComponent>(
[&](entt::entity e, SalvageScrapBehavior& salvage, const PositionComponent& pos,
const SensorRangeComponent& sensor)
{
const std::unordered_map<entt::entity, CargoState>::const_iterator it =
cargoByShip.find(e);
const bool cargoFull = (it != cargoByShip.end()) && isCargoFull(it->second);
if (cargoFull)
{
salvage.scrapTarget = std::nullopt;
salvage.score = BehaviorScores::kInactive;
return;
}
// Find nearest scrap within sensor range.
float bestDist = sensor.value_tiles;
std::optional<QVector2D> bestPos;
for (const ScrapInfo& si : allScrap)
{
const float dist = (si.position - pos.value).length();
if (dist < bestDist)
{
bestDist = dist;
bestPos = si.position;
}
}
salvage.scrapTarget = bestPos;
salvage.score = bestPos ? BehaviorScores::kSalvage : BehaviorScores::kInactive;
});
}

View File

@@ -0,0 +1,13 @@
#pragma once
class EntityAdmin;
class ScrapSystem;
// When cargo is not full, finds the nearest scrap within sensor range and sets
// it as the target, scoring high. Scores inactive when cargo is full or no scrap
// is in range (Advance then handles roaming).
class SalvageScrapEvaluator
{
public:
void evaluate(EntityAdmin& admin, const ScrapSystem& scraps);
};

View File

@@ -0,0 +1,24 @@
#include "SalvageScrapExecutor.h"
#include "BehaviorKind.h"
#include "EntityAdmin.h"
#include "MovementIntentComponent.h"
#include "SalvageScrapBehavior.h"
#include "SelectedBehaviorComponent.h"
#include "tracing.h"
void SalvageScrapExecutor::execute(EntityAdmin& admin)
{
TRACE();
admin.forEach<SalvageScrapBehavior, SelectedBehaviorComponent,
MovementIntentComponent>(
[](entt::entity /*e*/, const SalvageScrapBehavior& salvage,
const SelectedBehaviorComponent& selected,
MovementIntentComponent& intent)
{
if (selected.winner != BehaviorKind::SalvageScrap) { return; }
if (!salvage.scrapTarget) { return; }
intent = MovementIntentComponent{true, *salvage.scrapTarget,
salvage.orbitRadius_tiles};
});
}

View File

@@ -0,0 +1,10 @@
#pragma once
class EntityAdmin;
// Moves a ship toward its scrap target when SalvageScrap is the winning behavior.
class SalvageScrapExecutor
{
public:
void execute(EntityAdmin& admin);
};

View File

@@ -0,0 +1,16 @@
#include "StandbyEvaluator.h"
#include "BehaviorScores.h"
#include "EntityAdmin.h"
#include "StandbyBehavior.h"
#include "tracing.h"
void StandbyEvaluator::evaluate(EntityAdmin& admin)
{
TRACE();
admin.forEach<StandbyBehavior>(
[](entt::entity /*e*/, StandbyBehavior& standby)
{
standby.score = BehaviorScores::kStandby;
});
}

View File

@@ -0,0 +1,12 @@
#pragma once
class EntityAdmin;
// Constant low-priority fallback for repair-capable ships: gives a fixed score
// just above Advance so a repair ship with nothing more urgent to do holds with
// its fleet (StandbyExecutor) instead of charging the enemy.
class StandbyEvaluator
{
public:
void evaluate(EntityAdmin& admin);
};

View File

@@ -0,0 +1,101 @@
#include "StandbyExecutor.h"
#include <optional>
#include <QVector2D>
#include "BehaviorKind.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "MovementIntentComponent.h"
#include "PositionComponent.h"
#include "SelectedBehaviorComponent.h"
#include "ShipIdentityComponent.h"
#include "StandbyBehavior.h"
#include "StationBodyComponent.h"
#include "tracing.h"
namespace
{
// Accumulates positions to produce their centroid (the center between them).
struct Centroid
{
QVector2D sum;
int count = 0;
void add(const QVector2D& point)
{
sum += point;
count += 1;
}
std::optional<QVector2D> value() const
{
if (count == 0) { return std::nullopt; }
return sum / static_cast<float>(count);
}
};
}
void StandbyExecutor::execute(EntityAdmin& admin)
{
TRACE();
// Centroid of each faction's alive ships; a standing-by ship steers toward the
// center of its other same-faction ships so it stays among potential patients.
Centroid enemyShips;
Centroid playerShips;
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent, HealthComponent>(
[&enemyShips, &playerShips](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& faction,
const HealthComponent& health)
{
if (health.hp <= 0.0f) { return; }
Centroid& centroid = faction.isEnemy ? enemyShips : playerShips;
centroid.add(pos.value);
});
// Fallback per faction: the centroid of that side's own alive defence stations.
Centroid enemyStations;
Centroid playerStations;
admin.forEach<StationBodyComponent, PositionComponent, FactionComponent, HealthComponent>(
[&enemyStations, &playerStations](entt::entity /*e*/,
const StationBodyComponent& /*sb*/, const PositionComponent& pos,
const FactionComponent& faction, const HealthComponent& health)
{
if (health.hp <= 0.0f) { return; }
Centroid& centroid = faction.isEnemy ? enemyStations : playerStations;
centroid.add(pos.value);
});
const std::optional<QVector2D> enemyStationCenter = enemyStations.value();
const std::optional<QVector2D> playerStationCenter = playerStations.value();
admin.forEach<StandbyBehavior, SelectedBehaviorComponent, PositionComponent,
FactionComponent, MovementIntentComponent>(
[&](entt::entity /*e*/, const StandbyBehavior& /*standby*/,
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
const FactionComponent& faction, MovementIntentComponent& intent)
{
if (selected.winner != BehaviorKind::Standby) { return; }
const Centroid& allyShips = faction.isEnemy ? enemyShips : playerShips;
const std::optional<QVector2D>& friendlyStationCenter =
faction.isEnemy ? enemyStationCenter : playerStationCenter;
// Aim at the centroid of the other allied ships (excluding self), then
// fall back to the friendly stations, then hold position when alone.
QVector2D target = pos.value;
if (allyShips.count > 1)
{
target = (allyShips.sum - pos.value)
/ static_cast<float>(allyShips.count - 1);
}
else if (friendlyStationCenter)
{
target = *friendlyStationCenter;
}
intent = MovementIntentComponent{true, target};
});
}

View File

@@ -0,0 +1,12 @@
#pragma once
class EntityAdmin;
// Moves a standing-by ship toward the centroid of its other same-faction ships
// (its fleet) so it stays among allies that may need repair, falling back to its
// own defence stations and then to holding position when it has no allies.
class StandbyExecutor
{
public:
void execute(EntityAdmin& admin);
};

View File

@@ -0,0 +1,31 @@
#pragma once
#include "Event.h"
#include "Tick.h"
#include "entt/entity/entity.hpp"
// The kind of tool that produced a beam. Used by the renderer to choose the
// beam color (REQ-SHP-FIRING-BEAM).
enum class BeamKind
{
Weapon,
Repair,
Salvage,
};
// Transient record emitted whenever a weapon fires, a repair tool starts a heal
// cycle, or a salvage module starts a collection cycle (REQ-SHP-FIRING,
// REQ-SHP-FIRING-BEAM). Buffered in a sim-owned vector during the tick, then
// drained and re-emitted via EventManager by the UI frame handler.
struct BeamFiredEvent : public Event
{
BeamFiredEvent() = default;
BeamFiredEvent(BeamKind kind, entt::entity shooter, entt::entity target, Tick emittedAt)
: kind(kind), shooter(shooter), target(target), emittedAt(emittedAt) {}
BeamKind kind = BeamKind::Weapon;
entt::entity shooter = entt::null;
entt::entity target = entt::null;
Tick emittedAt = 0;
};

View File

@@ -20,10 +20,11 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ExitBlueprintModeRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/ExitBlueprintModeRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SpeedChangeRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/SpeedChangeRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/LayoutDialogRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/LayoutDialogRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindowClosedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/InspectWindowClosedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ArenaStartRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/ArenaStartRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/WeaponFiredEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -0,0 +1,12 @@
#pragma once
#include "BuildingId.h"
#include "Event.h"
class RecipeSelectionRequestedEvent : public Event
{
public:
explicit RecipeSelectionRequestedEvent(BuildingId buildingId)
: buildingId(buildingId) {}
const BuildingId buildingId;
};

View File

@@ -1,17 +0,0 @@
#pragma once
#include "Event.h"
#include "Tick.h"
#include "entt/entity/entity.hpp"
struct WeaponFiredEvent : public Event
{
WeaponFiredEvent() = default;
WeaponFiredEvent(entt::entity shooter, entt::entity target, Tick emittedAt)
: shooter(shooter), target(target), emittedAt(emittedAt) {}
entt::entity shooter = entt::null;
entt::entity target = entt::null;
Tick emittedAt = 0;
};

View File

@@ -720,11 +720,18 @@ void BeltSystem::routeSplitterItems()
bool routed = false; bool routed = false;
// A front slot holds only one item, so an item entering at progress 0.0
// would have to traverse the whole tile before the next could enter,
// throttling that output below belt speed and leaving large gaps. Entering
// near the output edge lets the slot clear roughly every quarter tile, so
// the output stays packed (fixes the half-blocked / single-output gap bug).
constexpr double frontEntryProgress = 0.75;
if (matchesA && !matchesB) if (matchesA && !matchesB)
{ {
if (!st.frontA) if (!st.frontA)
{ {
st.frontA = BeltItemSlot{item, 0.0}; st.frontA = BeltItemSlot{item, frontEntryProgress};
routed = true; routed = true;
} }
} }
@@ -732,7 +739,7 @@ void BeltSystem::routeSplitterItems()
{ {
if (!st.frontB) if (!st.frontB)
{ {
st.frontB = BeltItemSlot{item, 0.0}; st.frontB = BeltItemSlot{item, frontEntryProgress};
routed = true; routed = true;
} }
} }
@@ -743,26 +750,26 @@ void BeltSystem::routeSplitterItems()
if (preferA && !st.frontA) if (preferA && !st.frontA)
{ {
st.frontA = BeltItemSlot{item, 0.0}; st.frontA = BeltItemSlot{item, frontEntryProgress};
st.nextOutputIsA = false; st.nextOutputIsA = false;
routed = true; routed = true;
} }
else if (!preferA && !st.frontB) else if (!preferA && !st.frontB)
{ {
st.frontB = BeltItemSlot{item, 0.0}; st.frontB = BeltItemSlot{item, frontEntryProgress};
st.nextOutputIsA = true; st.nextOutputIsA = true;
routed = true; routed = true;
} }
else if (preferA && !st.frontB) else if (preferA && !st.frontB)
{ {
// Preferred (A) is full — fall back to B; nextOutputIsA stays. // Preferred (A) is full — fall back to B; nextOutputIsA stays.
st.frontB = BeltItemSlot{item, 0.75}; st.frontB = BeltItemSlot{item, frontEntryProgress};
routed = true; routed = true;
} }
else if (!preferA && !st.frontA) else if (!preferA && !st.frontA)
{ {
// Preferred (B) is full — fall back to A; nextOutputIsA stays. // Preferred (B) is full — fall back to A; nextOutputIsA stays.
st.frontA = BeltItemSlot{item, 0.75}; st.frontA = BeltItemSlot{item, frontEntryProgress};
routed = true; routed = true;
} }
// else both fronts occupied — back stays. // else both fronts occupied — back stays.

View File

@@ -54,6 +54,11 @@ struct ConstructionSite
std::string recipeId; // may be configured before completion std::string recipeId; // may be configured before completion
Tick completesAt = 0; // 0 = queued but not yet started Tick completesAt = 0; // 0 = queued but not yet started
std::optional<ShipLayoutConfig> shipLayout; std::optional<ShipLayoutConfig> shipLayout;
// Splitter output filters configured before completion (REQ-BLD-SITE-CONFIG).
// Empty = accept all; applied to the BeltSystem when the splitter is built.
std::vector<ItemType> splitterFilterA;
std::vector<ItemType> splitterFilterB;
}; };
// A fully constructed, operational building. // A fully constructed, operational building.

View File

@@ -235,12 +235,18 @@ std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe
BuildingId BuildingSystem::place(BuildingType type, QPoint anchor, BuildingId BuildingSystem::place(BuildingType type, QPoint anchor,
Rotation rotation, Tick currentTick) Rotation rotation, Tick currentTick)
{ {
const BuildingId id = m_allocateBuildingId();
const BuildingDef* def = findBuildingDef(type); const BuildingDef* def = findBuildingDef(type);
assert(def != nullptr); assert(def != nullptr);
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation); const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation);
// Reject placements that fall outside the world (REQ-BLD-PLACE-VALID).
if (!bodyCellsWithinWorldBounds(mask.bodyCells, anchor))
{
return kInvalidBuildingId;
}
const BuildingId id = m_allocateBuildingId();
// Record tile occupancy for body cells. // Record tile occupancy for body cells.
for (const QPoint& cell : mask.bodyCells) for (const QPoint& cell : mask.bodyCells)
{ {
@@ -270,6 +276,70 @@ BuildingId BuildingSystem::place(BuildingType type, QPoint anchor,
return id; return id;
} }
bool BuildingSystem::bodyCellsWithinWorldBounds(const std::vector<QPoint>& bodyCells,
QPoint anchor) const
{
const int heightTiles = m_config.world.heightTiles;
const int leftEdgeX = -m_config.world.regions.asteroidWidth_tiles;
for (const QPoint& cell : bodyCells)
{
const QPoint worldCell = anchor + cell;
if (worldCell.y() < 0 || worldCell.y() >= heightTiles)
{
return false;
}
if (worldCell.x() < leftEdgeX)
{
return false;
}
}
return true;
}
bool BuildingSystem::isPlacementValid(BuildingType type, QPoint anchor,
Rotation rotation) const
{
const BuildingDef* def = findBuildingDef(type);
if (def == nullptr)
{
return false;
}
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation);
if (!bodyCellsWithinWorldBounds(mask.bodyCells, anchor))
{
return false;
}
// Terrain: ship-dock (S) cells must sit in space (x >= 0); all other body
// (A) cells must sit on the asteroid (x < 0). (REQ-BLD-PLACE-VALID)
for (const QPoint& cell : mask.bodyCells)
{
const QPoint worldCell = anchor + cell;
bool isShipDock = false;
for (const QPoint& dock : mask.shipDockCells)
{
if (dock == cell)
{
isShipDock = true;
break;
}
}
if (isShipDock)
{
if (worldCell.x() < 0)
{
return false;
}
}
else if (worldCell.x() >= 0)
{
return false;
}
}
return true;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Demolish // Demolish
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -409,6 +479,44 @@ void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout
} }
} }
std::optional<BeltSystem::SplitterInfo>
BuildingSystem::getSiteSplitterInfo(BuildingId id) const
{
for (const ConstructionSite& site : m_constructionQueue)
{
if (site.id != id) { continue; }
if (site.type != BuildingType::Splitter) { return std::nullopt; }
const BuildingDef* def = findBuildingDef(site.type);
const ParsedSurfaceMask mask = parseSurfaceMask(
def ? def->surfaceMask : std::vector<std::string>{}, site.rotation);
if (mask.outputPorts.size() < 2) { return std::nullopt; }
BeltSystem::SplitterInfo info;
info.outputA = mask.outputPorts[0].direction;
info.outputB = mask.outputPorts[1].direction;
info.filterA = site.splitterFilterA;
info.filterB = site.splitterFilterB;
return info;
}
return std::nullopt;
}
void BuildingSystem::setSiteSplitterFilters(BuildingId id,
const std::vector<ItemType>& filterA,
const std::vector<ItemType>& filterB)
{
for (ConstructionSite& site : m_constructionQueue)
{
if (site.id == id && site.type == BuildingType::Splitter)
{
site.splitterFilterA = filterA;
site.splitterFilterB = filterB;
return;
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tick hooks // Tick hooks
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -494,6 +602,11 @@ void BuildingSystem::tickConstruction(Tick currentTick)
m_belts.placeSplitter(front.anchor, m_belts.placeSplitter(front.anchor,
mask.outputPorts[0].direction, mask.outputPorts[0].direction,
mask.outputPorts[1].direction); mask.outputPorts[1].direction);
// Carry over any filters configured while under construction
// (REQ-BLD-SITE-CONFIG).
m_belts.setSplitterFilters(front.anchor,
front.splitterFilterA,
front.splitterFilterB);
} }
else if (front.type == BuildingType::TunnelEntry) else if (front.type == BuildingType::TunnelEntry)
{ {

View File

@@ -40,11 +40,23 @@ public:
std::mt19937& rng); std::mt19937& rng);
// -- Placement / demolish ------------------------------------------------ // -- Placement / demolish ------------------------------------------------
// Returns the new entity id. Belt and Splitter register with BeltSystem // Returns the new entity id, or kInvalidBuildingId if the placement falls
// directly; other types enter the construction queue. // outside the world bounds (vertical extent and asteroid left edge). Belt
// and Splitter register with BeltSystem directly; other types enter the
// construction queue. Terrain type (A vs S) is NOT checked here so that
// tests can stage arbitrary layouts; the player-facing entry point
// (Simulation::tryPlaceBuilding) enforces the full rule via isPlacementValid.
BuildingId place(BuildingType type, QPoint anchor, Rotation rotation, BuildingId place(BuildingType type, QPoint anchor, Rotation rotation,
Tick currentTick); Tick currentTick);
// Returns true if the placement satisfies REQ-BLD-PLACE-VALID terrain and
// world-bounds rules: every ship-dock (S) cell sits in space (x >= 0), every
// other body (A) cell sits on the asteroid (x < 0 and x >= the left edge),
// and every cell has 0 <= y < world.height_tiles. There is no right-side
// bound — space extends rightward. Tile occupancy is NOT checked here.
bool isPlacementValid(BuildingType type, QPoint anchor,
Rotation rotation) const;
// Remove a building or construction site by id. Returns the refund in // Remove a building or construction site by id. Returns the refund in
// building blocks (floor(cost * refundPercentage / 100)). Returns 0 for // building blocks (floor(cost * refundPercentage / 100)). Returns 0 for
// unknown ids. // unknown ids.
@@ -58,6 +70,18 @@ public:
// (materials discarded) and reinitializes input buffers (REQ-BLD-SHIPYARD). // (materials discarded) and reinitializes input buffers (REQ-BLD-SHIPYARD).
void setShipLayout(BuildingId id, const ShipLayoutConfig& layout); void setShipLayout(BuildingId id, const ShipLayoutConfig& layout);
// Splitter filter configuration for a queued/under-construction Splitter
// site (REQ-BLD-SITE-CONFIG). Operational splitters are configured through
// BeltSystem by tile; these mirror that for sites, which are not yet
// registered with BeltSystem. getSiteSplitterInfo returns the site's two
// output directions (derived from its surface mask) and stored filters, or
// nullopt if the id is not a Splitter site. The stored filters are applied
// to BeltSystem when the splitter finishes building (tickConstruction).
std::optional<BeltSystem::SplitterInfo> getSiteSplitterInfo(BuildingId id) const;
void setSiteSplitterFilters(BuildingId id,
const std::vector<ItemType>& filterA,
const std::vector<ItemType>& filterB);
// -- Tick hooks (called from Simulation::tick in the documented order) --- // -- Tick hooks (called from Simulation::tick in the documented order) ---
void tickConstruction(Tick currentTick); void tickConstruction(Tick currentTick);
void tickBeltPull(); void tickBeltPull();
@@ -136,6 +160,9 @@ private:
void initShipyardBuffers(Building& b) const; void initShipyardBuffers(Building& b) const;
std::vector<Port> computeInputPorts(const Building& b) const; std::vector<Port> computeInputPorts(const Building& b) const;
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe); std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
bool bodyCellsWithinWorldBounds(
const std::vector<QPoint>& bodyCells,
QPoint anchor) const;
const GameConfig& m_config; const GameConfig& m_config;
BeltSystem& m_belts; BeltSystem& m_belts;

Some files were not shown because too many files have changed in this diff Show More