载入中...
搜索中...
未找到
RTSSystems.cpp
浏览该文件的文档.
1#include "rts/RTSSystems.h"
2#include "map/Fov.h"
3#include "map/Path.h"
4#include "map/Pathfinder.h"
6#include "sensing/Sensing.h"
8
9#include <algorithm>
10#include <array>
11#include <cmath>
12#include <limits>
13#include <map>
14#include <memory>
15#include <numbers>
16#include <optional>
17#include <string>
18#include <utility>
19#include <vector>
20
21namespace eve::rts {
22namespace {
29
30
31SubjectRef stableSubject(ecs::Entity* entity) {
32 if (auto* unit = dynamic_cast<Unit*>(entity)) return unit->identity()->subject;
33 if (auto* building = dynamic_cast<Building*>(entity)) return building->identity()->subject;
34 if (auto* faction = dynamic_cast<Faction*>(entity)) return faction->identity()->subject;
35 return {};
36}
37
38float weaponRangeDamageFactor(const weapon::WeaponDefinition& definition, float distance) {
39 if (definition.minimumDamageFactor >= 1.0f || distance <= definition.falloffStart ||
40 definition.range <= definition.falloffStart) return 1.0f;
41 const float progress = std::clamp((distance - definition.falloffStart) /
42 (definition.range - definition.falloffStart),
43 0.0f, 1.0f);
44 return 1.0f + (definition.minimumDamageFactor - 1.0f) * progress;
45}
46
47float weaponTargetPreference(const weapon::WeaponDefinition& definition, const TagSet* tags) {
48 if (tags == nullptr) return 1.0f;
49 return std::any_of(definition.preferredTargetTags.begin(), definition.preferredTargetTags.end(),
50 [&](const auto& tag) { return tags->contains(tag); })
51 ? definition.preferredTargetBonus : 1.0f;
52}
53
54float deterministicShotRandom(SubjectRef subject, std::uint64_t sequence, std::uint64_t stream) {
55 std::uint64_t hash = 1469598103934665603ULL;
56 for (const unsigned char character : subject.format()) {
57 hash ^= character;
58 hash *= 1099511628211ULL;
59 }
60 hash ^= sequence + 0x9e3779b97f4a7c15ULL + (stream << 6U) + (stream >> 2U);
61 hash ^= hash >> 30U; hash *= 0xbf58476d1ce4e5b9ULL;
62 hash ^= hash >> 27U; hash *= 0x94d049bb133111ebULL;
63 hash ^= hash >> 31U;
64 return static_cast<float>((hash >> 40U) & 0xffffffU) / 16777216.0f;
65}
66
67struct ShotPlacement {
68 bool missed = false;
69 WorldPosition point;
70};
71
72ShotPlacement placeShot(SubjectRef subject, std::uint64_t sequence,
73 const weapon::WeaponDefinition& definition, WorldPosition intended) {
74 ShotPlacement result{deterministicShotRandom(subject, sequence, 0) >= definition.accuracy, intended};
75 if (!result.missed || definition.scatterRadius <= 0.0f) return result;
76 const float angle = deterministicShotRandom(subject, sequence, 1) * 2.0f *
77 static_cast<float>(std::numbers::pi);
78 const float radius = std::sqrt(deterministicShotRandom(subject, sequence, 2)) * definition.scatterRadius;
79 result.point.x += std::cos(angle) * radius;
80 result.point.y += std::sin(angle) * radius;
81 return result;
82}
83
84
85std::string factionKey(const FactionLink& link) {
86 auto* faction = dynamic_cast<Faction*>(link.resolve());
87 return faction != nullptr && faction->identity()->subject.isValid() ? faction->identity()->subject.format()
88 : std::string{};
89}
90
91Unit* unitBySubject(SubjectRef subject) {
92 if (!subject.isValid()) return nullptr;
93 auto units = ecs::View<Unit, Unit::Identity>();
94 for (auto it = units.begin(); it != units.end(); ++it) {
95 auto [identity] = *it;
96 if (identity->subject == subject)
97 return dynamic_cast<Unit*>(ecs::try_get(identity->self));
98 }
99 return nullptr;
100}
101
102bool hostileTo(Unit& source, const FactionLink& targetFaction) {
103 return source.faction()->link.resolve() != nullptr && targetFaction.resolve() != nullptr &&
104 !FactionRelationSystem::isAllied(source.faction()->link, targetFaction);
105}
106
107float visibleHostileThreatAt(Faction& viewer, WorldPosition point) {
108 const auto visible = [&](SubjectRef subject) {
109 if (!viewer.intel()->enabled) return true;
110 const auto found = std::find_if(viewer.intel()->contacts.begin(), viewer.intel()->contacts.end(),
111 [&](const auto& contact) { return contact.subject == subject; });
112 return found != viewer.intel()->contacts.end() && found->visible && found->detected;
113 };
114 const auto contribution = [&](const weapon::WeaponDefinition& definition, WorldPosition source) {
115 if (definition.range <= 0.0f) return 0.0f;
116 const float distance = std::hypot(source.x - point.x, source.y - point.y);
117 if (distance > definition.range) return 0.0f;
118 return std::max(0.0f, definition.damage) / std::max(0.05f, definition.cooldown) *
119 (0.1f + 1.0f - distance / definition.range);
120 };
121 float threat = 0.0f;
122 auto units = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Faction, Unit::Weapon,
123 Unit::Durability, Unit::Containment>();
124 for (auto it = units.begin(); it != units.end(); ++it) {
125 auto [identity, motion, faction, weaponLink, durability, containment] = *it;
126 if (!durability->alive || containment->container.isBound() ||
127 FactionRelationSystem::isAllied(dynamic_cast<Faction*>(faction->link.resolve()), &viewer) ||
128 !visible(identity->subject))
129 continue;
130 auto* weaponEntity = dynamic_cast<weapon::WeaponEntity*>(weaponLink->link.resolve());
131 const auto* definition = weaponEntity == nullptr ? nullptr : weaponEntity->definition()->def;
132 if (definition != nullptr) threat += contribution(*definition, {motion->x, motion->y});
133 }
134 auto buildings = ecs::View<Building, Building::Identity, Building::Placement, Building::Faction,
135 Building::Weapon, Building::Integrity, Building::Construction>();
136 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
137 auto [identity, placement, faction, weaponLink, integrity, construction] = *it;
138 if (!integrity->alive || construction->progress < 1.0f ||
139 FactionRelationSystem::isAllied(dynamic_cast<Faction*>(faction->link.resolve()), &viewer) ||
140 !visible(identity->subject))
141 continue;
142 auto* weaponEntity = dynamic_cast<weapon::WeaponEntity*>(weaponLink->link.resolve());
143 const auto* definition = weaponEntity == nullptr ? nullptr : weaponEntity->definition()->def;
144 if (definition != nullptr)
145 threat += contribution(*definition, {placement->worldX, placement->worldY});
146 }
147 return threat;
148}
149
150Result<std::size_t> advanceEffects(RTSEffectComponent& effects, const SimulationStep& step) {
151 auto advanced = effects.advance(step);
152 if (!advanced) return Result<std::size_t>::failure(advanced.status());
153 const auto result = std::move(advanced).takeValue();
154 return Result<std::size_t>::success(result.settled,
155 Status::success(result.settled == 0 ? StatusCode::NoOp : StatusCode::Applied));
156}
157
158} // namespace
159
160std::span<const SystemContract> systemContracts() noexcept {
161 static constexpr SystemContract contracts[] = {
162 {"rts.movement_groups", "ecs::View<Unit, Unit::Navigation>; generation-checked owned group members",
163 "Unit::Motion/Navigation/Orders/Durability/Containment/Supply/Morale/Tactics/Crowd; RTS membership; map grid",
164 "Unit::Navigation::formationSpeedFactor/formationTarget/waypointIndex; RTS movement-group membership",
165 "none; removes only runtime membership records", "none",
166 "after navigation/convoy; before native/Crowd motion"},
167 {"rts.command_fan_out", "ecs::View<Unit, Unit::Identity, Unit::Orders>",
168 "Unit::Identity/Motion/Crowd; selection handles; formation input", "Unit::Orders",
169 "none; use ecs::ScopedDefer if future code creates/removes entities/components",
170 "none; caller owns command receipt/event publication", "input.command"},
171 {"rts.motion", "ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Orders>", "Unit::Identity; Unit::Orders",
172 "Unit::Motion", "none", "none", "simulation.movement"},
173 {"rts.movement_order", "arrived Units with finite movement orders",
174 "Unit::Motion; Unit::Navigation; Unit::Orders", "canonical order queue", "none", "none",
175 "simulation.movement"},
176 {"rts.command_state", "Units with Stop, HoldPosition, or AttackMove orders", "Unit::Orders; Unit::Motion",
177 "Unit::Combat; Unit::Navigation; canonical order queue", "none", "none", "simulation.command"},
178 {"rts.navigation", "moving Units with canonical map routes", "Unit::Orders; Unit::Motion; map Pathfinder",
179 "Unit::Navigation", "none", "unreachable route notification", "simulation.navigation"},
180 {"rts.patrol", "Units with active Patrol orders", "Unit::Motion; Unit::Orders",
181 "Unit::Navigation patrol direction", "none", "none", "simulation.navigation"},
182 {"rts.traffic_reservation", "moving Units inside or approaching connected narrow canonical map corridors",
183 "Unit::Identity; Unit::Motion; Unit::Navigation; Unit::Crowd radius; map Pathfinder",
184 "Unit::Navigation::trafficWaiting/trafficRecoveryTarget/plannedOrderId", "none", "none",
185 "simulation.navigation"},
186 {"rts.fog", "vision-enabled Units/Buildings and Factions", "positions; factions; map FOV explored state",
187 "map FOV revealers; Faction::Intel", "none", "none", "simulation.visibility"},
188 {"rts.crowd_motion",
189 "ecs::View<Unit, Identity, Motion, Navigation, Orders, Crowd, Combat, Containment, Supply, Morale, Tactics, "
190 "Command, Effects>",
191 "Unit::Identity/Motion/Navigation/Orders/Crowd/Combat/Containment/Supply/Morale/Tactics/Command/Effects; "
192 "injected Crowd",
193 "Unit::Motion; Unit::Crowd::heading; injected Crowd positions/targets/interaction/velocity", "none",
194 "none; injected Crowd advances synchronously", "simulation.movement; zero-time pre-production reconciliation"},
195 {"rts.worker_assignment", "Unit workers; ResourceNode stock/capacity", "positions; orders; stock; links",
196 "Unit::Worker; Unit::Orders; ResourceNode::Harvest", "none", "none", "simulation.assignment"},
197 {"rts.mining", "Unit workers; ResourceNode stock; Building dropoffs", "active orders; positions; stock",
198 "Unit::Worker; Unit::Orders; ResourceNode::Stock", "none", "delegated resource credit receipt",
199 "simulation.economy"},
200 {"rts.construction", "Building construction; Unit builders", "orders; faction; motion; build rates",
201 "Building::Construction; Unit::Orders", "none", "none", "simulation.construction"},
202 {"rts.repair", "Building integrity; Unit repairers", "orders; faction; integrity; repair rates",
203 "Building::Integrity; Unit::Orders", "none", "delegated resource debit receipt", "simulation.repair"},
204 {"rts.capture", "capturable Buildings; capturing Units", "orders; faction; capture rates",
205 "Building::Capture; Building::Faction; Unit::Orders", "none", "none", "simulation.capture"},
206 {"rts.infrastructure", "live completed Buildings grouped by Faction",
207 "Building::Faction; Construction; Integrity; Infrastructure",
208 "Building::Infrastructure powered/income progress", "none", "delegated economy credit receipt",
209 "simulation.economy"},
210 {"rts.containment", "Units, transports, and garrison Buildings", "orders; faction; capacity; live handles",
211 "Unit::Containment; transport/building occupants; Unit::Motion; Building::Capture", "none", "none",
212 "simulation.containment"},
213 {"rts.supply", "supplier Units/Buildings and armed recipient Units",
214 "orders; factions; range; stock; canonical WeaponEntity resources",
215 "Unit/Building::Supply; Unit::Orders; canonical weapon ammo", "none", "none", "simulation.logistics"},
216 {"rts.supply_convoy", "Units sharing a live supply target",
217 "canonical supply orders; positions; stable subjects", "Unit::Supply convoy projection", "none", "none",
218 "simulation.movement"},
219 {"rts.morale", "live uncontained Units", "positions; factions; suppression; morale auras", "Unit::Morale",
220 "none", "none", "simulation.morale"},
221 {"rts.shield", "live Units and Buildings with shields", "shield capacity, rate, delay and cooldown",
222 "Unit/Building::Shield", "none", "none", "simulation.defense"},
223 {"rts.command_network", "live Units and completed powered Buildings", "positions; factions; command policy",
224 "Unit/Building::Command", "none", "none", "simulation.command"},
225 {"rts.ability", "casting Units and live combat targets", "ability policy; factions; canonical effect state",
226 "Unit::Abilities; target durability/shield/effects", "none", "delegated damage/economy receipts",
227 "simulation.ability"},
228 {"rts.projectile", "canonical pooled weapon projectiles and RTS combat targets",
229 "weapon projectile trajectories; target positions/factions", "target durability/shield", "none",
230 "delegated canonical damage outcomes", "simulation.projectile"},
231 {"rts.artillery", "live uncontained indirect-fire Units", "motion; deployment policy", "Unit::Artillery",
232 "none", "none", "simulation.artillery"},
233 {"rts.fire_support", "friendly indirect-fire responders and exposed hostile artillery",
234 "orders; factions; weapon ranges; last-fire positions", "Unit::Orders; Unit::Artillery", "none", "none",
235 "simulation.fire_support"},
236 {"rts.tactics", "escort and combat-group Units; live hostile candidates",
237 "orders; factions; positions; weapon definitions; durability", "Unit::Tactics; Unit::Combat", "none", "none",
238 "simulation.tactics"},
239 {"rts.ai", "enabled Factions; friendly producers and Units; hostile Buildings",
240 "Faction::Strategy; definitions; factions; orders; positions",
241 "Faction::Strategy; Unit::Orders; Unit::Tactics", "none",
242 "delegated production request; command fan-out receipt", "simulation.ai"},
243 {"rts.combat_fire", "armed Units; live Unit/Building targets", "sensing facts; factions; orders; weapons",
244 "Unit::Combat; Unit/Building durability; weapon runtime state", "none",
245 "delegated weapon events, projectile spawn, and combat damage outcome", "simulation.combat"},
246 {"rts.order_action", "ecs::View<Unit, Unit::Identity, Unit::Orders, Unit::Action>",
247 "Unit::Identity; Unit::Action; active OrderRecord", "Unit::Orders; generic order lifecycle state", "none",
248 "delegated to IRTSActionExecutor/ActionRuntime", "simulation.action"},
249 {"rts.reinforcement_production_policy", "live production Buildings sharing a faction combat group",
250 "Building::Rally; canonical production::WorkQueue tasks; live grouped Units",
251 "Building::Rally policy bookkeeping; canonical task pause/resume/cancel state", "none",
252 "delegated transactional enqueue and atomic cancel/refund receipts", "simulation.production"},
253 {"rts.production_crowd_projection", "ecs::View<Unit, Unit::Motion, Unit::Crowd, Unit::Containment>",
254 "Unit::Crowd/Containment; committed Crowd spawn state", "Unit::Motion; Unit::Crowd::heading", "none",
255 "none; factory publishes displaced peers synchronously", "simulation.production"},
256 {"rts.building_production", "ecs::View<Building, Building::Identity, Building::Production>",
257 "Building::Identity/Placement/Faction/Rally; SimulationStep; transport containment",
258 "Building::Production/Rally; produced Unit::Tactics/Containment/Orders",
259 "factory creates or rolls back roots after the traversal closes",
260 "production blocked/cleared and UnitProduced after authoritative mutation", "simulation.production"},
261 {"rts.technology", "Factions, completed research tasks, and faction-owned entities",
262 "canonical Definitions; canonical Production tasks; entity definitions",
263 "Faction/Unit/Building::Technology; RTS-owned combat, motion, worker and durability projections", "none",
264 "none", "simulation.technology"},
265 {"rts.match", "Match participants and their faction-owned Units/Buildings",
266 "match rules; typed faction links; durability; canonical economy query",
267 "Match participants/state/events; surrender durability", "none", "match lifecycle events", "simulation.match"},
268 {"rts.effects.unit", "ecs::View<Unit, Unit::Identity, Unit::Effects>", "Unit::Identity; SimulationStep",
269 "Unit::Effects", "none", "delegated to effects::EffectContainer", "simulation.effects"},
270 {"rts.effects.building", "ecs::View<Building, Building::Identity, Building::Effects>",
271 "Building::Identity; SimulationStep", "Building::Effects", "none", "delegated to effects::EffectContainer",
272 "simulation.effects"},
273 };
274 return {contracts, sizeof(contracts) / sizeof(contracts[0])};
275}
276
278 LogicalId definition, const PlacementValidation& placement,
279 bool requireInfluence) {
280 if (!std::isfinite(position.x) || !std::isfinite(position.y) || !definition.isValid() || !placement)
283 "RTS building placement requires finite position, definition and provider", "placement"));
284 if (requireInfluence) {
285 bool covered = false;
286 auto buildings = ecs::View<Building, Building::Placement, Building::Faction,
289 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
290 auto [source, owner, construction, integrity, infrastructure] = *it;
291 if (!source->placed || construction->progress < 1.0f || !integrity->alive || !infrastructure->powered ||
292 infrastructure->buildInfluenceRadius <= 0.0f ||
293 !FactionRelationSystem::isAllied(dynamic_cast<Faction*>(owner->link.resolve()), &faction))
294 continue;
295 const float dx = source->worldX - position.x;
296 const float dy = source->worldY - position.y;
297 const float radius = infrastructure->buildInfluenceRadius;
298 if (dx * dx + dy * dy <= radius * radius) {
299 covered = true;
300 break;
301 }
302 }
303 if (!covered)
305 DiagnosticCode::Conflict, "RTS building position is outside powered allied build influence",
306 "placement.influence"));
307 }
308 return placement(position, std::move(definition));
309}
310
312 if (step.delta.nanoseconds() < 0)
314 DiagnosticCode::InvalidArgument, "RTS tactics step delta must be non-negative", "step.delta"));
315 struct Candidate {
316 ecs::EntityHandle handle{};
317 FactionLink* faction = nullptr;
319 double health = 0.0;
320 float shield = 0.0f;
321 combat::CombatState* durability = nullptr;
322 std::string key;
323 ecs::EntityHandle activeTarget{};
324 TagSet* tags = nullptr;
325 bool airborne = false;
326 bool cloaked = false;
327 };
328 std::vector<Candidate> candidates;
331 for (auto it = units.begin(); it != units.end(); ++it) {
332 auto [identity, motion, faction, combat, durability, shield, containment, tags] = *it;
333 if (!durability->alive || containment->container.isBound()) continue;
334 candidates.push_back({identity->self, &faction->link, {motion->x, motion->y}, durability->state.health,
335 shield->value, &durability->state,
336 "u:" + std::to_string(identity->self.id) + ":" +
337 std::to_string(identity->self.generation), combat->target, &tags->values,
338 motion->airborne});
339 }
342 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
343 auto [identity, placement, faction, combat, integrity, shield, tags] = *it;
344 if (!integrity->alive) continue;
345 candidates.push_back({identity->self, &faction->link, {placement->worldX, placement->worldY},
346 integrity->state.health, shield->value, &integrity->state,
347 "b:" + std::to_string(identity->self.id) + ":" +
348 std::to_string(identity->self.generation), combat->target, &tags->values, false});
349 }
350
351 std::size_t processed = 0;
352 auto tacticalUnits = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Orders, Unit::Faction, Unit::Combat,
354 std::map<std::uint64_t, std::vector<Unit*>> groups;
355 struct EscortAssignment {
356 Unit* unit = nullptr;
358 ecs::EntityHandle protectedTarget{};
359 std::vector<ecs::EntityHandle> protectedMembers;
360 float protectionRange = 0.0f;
361 WorldPosition travelDirection{};
362 int screenSector = 0;
363 };
364 std::map<std::string, std::vector<EscortAssignment>> escortGroups;
365 for (auto it = tacticalUnits.begin(); it != tacticalUnits.end(); ++it) {
366 auto [identity, motion, orders, faction, combat, tactics, weaponLink, durability, containment] = *it;
367 if (!durability->alive || containment->container.isBound()) continue;
368 auto* self = dynamic_cast<Unit*>(ecs::try_get(identity->self));
369 if (self == nullptr) continue;
370 auto current = readCurrent(orders->values);
371 if (!current) return Result<std::size_t>::failure(current.status());
372 auto order = std::move(current).takeValue();
373 if (!self->morale()->retreating || tactics->combatGroup == 0 || weaponLink->link.resolve() == nullptr ||
374 (order && order->kind == OrderKind::Attack)) {
375 tactics->retreatFireTeam = -1;
376 tactics->retreatCoverElapsed = 0.0f;
377 tactics->retreatCovering = false;
378 }
379 if (order && order->kind == OrderKind::Escort) {
380 tactics->escortTarget = order->targetEntity;
381 auto* protectedEntity = ecs::try_get(order->targetEntity);
383 FactionLink* protectedFaction = nullptr;
384 if (auto* protectedUnit = dynamic_cast<Unit*>(protectedEntity)) {
385 if (protectedUnit->durability()->alive && !protectedUnit->containment()->container.isBound()) {
386 center = {protectedUnit->motion()->x, protectedUnit->motion()->y};
387 protectedFaction = &protectedUnit->faction()->link;
388 }
389 } else if (auto* protectedBuilding = dynamic_cast<Building*>(protectedEntity)) {
390 if (protectedBuilding->integrity()->alive) {
391 center = {protectedBuilding->placement()->worldX, protectedBuilding->placement()->worldY};
392 protectedFaction = &protectedBuilding->faction()->link;
393 }
394 }
395 if (protectedFaction == nullptr || !FactionRelationSystem::isAllied(*protectedFaction, faction->link)) {
396 auto failed = orders->values.fail(order->id, "escort target is invalid or hostile");
397 if (!failed) return Result<std::size_t>::failure(failed.status());
398 tactics->escortTarget = {};
399 tactics->guardSet = false;
400 tactics->escortInterceptTarget = {};
401 combat->target = {};
402 continue;
403 }
404 std::vector<ecs::EntityHandle> protectedMembers{order->targetEntity};
405 float convoyExtent = 0.0f;
406 WorldPosition travelDirection{};
407 if (auto* protectedUnit = dynamic_cast<Unit*>(protectedEntity);
408 protectedUnit != nullptr && ecs::try_get(protectedUnit->supply()->convoyLeader) != nullptr) {
409 const auto leader = protectedUnit->supply()->convoyLeader;
410 std::vector<Unit*> convoy;
411 auto convoyUnits = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Supply,
413 for (auto convoyIt = convoyUnits.begin(); convoyIt != convoyUnits.end(); ++convoyIt) {
414 auto [memberIdentity, memberMotion, memberSupply, memberDurability, memberContainment] = *convoyIt;
415 if (!memberDurability->alive || memberContainment->container.isBound() ||
416 !isSameHandle(memberSupply->convoyLeader, leader))
417 continue;
418 if (auto* member = dynamic_cast<Unit*>(ecs::try_get(memberIdentity->self)); member != nullptr)
419 convoy.push_back(member);
420 }
421 if (convoy.size() > 1) {
422 center = {};
423 protectedMembers.clear();
424 for (Unit* member : convoy) {
425 center.x += member->motion()->x;
426 center.y += member->motion()->y;
427 protectedMembers.push_back(member->identity()->self);
428 }
429 center.x /= static_cast<float>(convoy.size());
430 center.y /= static_cast<float>(convoy.size());
431 for (Unit* member : convoy)
432 convoyExtent = std::max(convoyExtent,
433 std::hypot(member->motion()->x - center.x, member->motion()->y - center.y));
434 if (auto* convoyLeader = dynamic_cast<Unit*>(ecs::try_get(leader)); convoyLeader != nullptr) {
435 auto leaderOrder = readCurrent(convoyLeader->orders()->values);
436 if (!leaderOrder) return Result<std::size_t>::failure(leaderOrder.status());
437 if (leaderOrder.value()) {
438 travelDirection = {leaderOrder.value()->target.x - convoyLeader->motion()->x,
439 leaderOrder.value()->target.y - convoyLeader->motion()->y};
440 const float length = std::hypot(travelDirection.x, travelDirection.y);
441 if (length > 1e-5f) {
442 travelDirection.x /= length;
443 travelDirection.y /= length;
444 }
445 }
446 }
447 }
448 }
449 WorldPosition offset{tactics->escortOffsetX, tactics->escortOffsetY};
450 if (std::hypot(travelDirection.x, travelDirection.y) > 1e-5f)
451 offset = {travelDirection.x * tactics->escortOffsetX - travelDirection.y * tactics->escortOffsetY,
452 travelDirection.y * tactics->escortOffsetX + travelDirection.x * tactics->escortOffsetY};
453 int screenSector = 0;
454 if (std::hypot(travelDirection.x, travelDirection.y) > 1e-5f) {
455 if (std::abs(tactics->escortOffsetX) >= std::abs(tactics->escortOffsetY))
456 screenSector = tactics->escortOffsetX >= 0.0f ? 2 : -2;
457 else
458 screenSector = tactics->escortOffsetY >= 0.0f ? 1 : -1;
459 }
460 tactics->escortScreenSector = screenSector;
461 tactics->escortSectorMatched = false;
462 tactics->escortReinforcing = false;
463 tactics->escortReinforcementSector = 0;
464 tactics->escortRearGuard = false;
465 tactics->guardX = center.x + offset.x;
466 tactics->guardY = center.y + offset.y;
467 tactics->guardSet = true;
468 combat->guardX = center.x;
469 combat->guardY = center.y;
470 combat->guardSet = true;
471 const float protectionRange = tactics->protectionRange + convoyExtent;
472 combat->leashRange = std::max(combat->leashRange, protectionRange);
473 escortGroups[std::to_string(order->targetEntity.id) + ":" +
474 std::to_string(order->targetEntity.generation)]
475 .push_back({self, center, order->targetEntity, std::move(protectedMembers), protectionRange,
476 travelDirection, screenSector});
477 } else if (tactics->combatGroup != 0 && weaponLink->link.resolve() != nullptr &&
478 (!order || order->kind != OrderKind::Attack)) {
479 tactics->escortScreenSector = 0;
480 tactics->escortSectorMatched = false;
481 tactics->escortReinforcing = false;
482 tactics->escortReinforcementSector = 0;
483 tactics->escortRearGuard = false;
484 tactics->escortInterceptTarget = {};
485 groups[tactics->combatGroup].push_back(self);
486 }
487 }
488 for (auto& [protectedKey, escorts] : escortGroups) {
489 (void)protectedKey;
490 std::sort(escorts.begin(), escorts.end(), [](const auto& left, const auto& right) {
491 return left.unit->identity()->subject.format() < right.unit->identity()->subject.format();
492 });
493 auto* protectedUnit = escorts.empty() ? nullptr
494 : dynamic_cast<Unit*>(ecs::try_get(escorts.front().protectedTarget));
495 if (protectedUnit != nullptr && protectedUnit->morale()->retreating) {
496 WorldPosition retreatDirection{
497 protectedUnit->navigation()->plannedGoal.x - protectedUnit->motion()->x,
498 protectedUnit->navigation()->plannedGoal.y - protectedUnit->motion()->y};
499 float length = std::hypot(retreatDirection.x, retreatDirection.y);
500 if (length > 1e-5f) {
501 retreatDirection.x /= length;
502 retreatDirection.y /= length;
503 const WorldPosition side{-retreatDirection.y, retreatDirection.x};
504 for (std::size_t index = 0; index < escorts.size(); ++index) {
505 auto tactics = escorts[index].unit->tactics();
506 const float slot = static_cast<float>(index) -
507 (static_cast<float>(escorts.size()) - 1.0f) * 0.5f;
508 const float depth = std::max(1.0f,
509 std::hypot(tactics->escortOffsetX, tactics->escortOffsetY));
510 const float spacing = 1.5f;
511 tactics->guardX = escorts[index].center.x - retreatDirection.x * depth + side.x * slot * spacing;
512 tactics->guardY = escorts[index].center.y - retreatDirection.y * depth + side.y * slot * spacing;
513 tactics->escortRearGuard = true;
514 }
515 }
516 }
517 std::set<std::string> claimed;
518 for (const auto& escort : escorts) {
519 Candidate* best = nullptr;
520 bool bestDirect = false;
521 bool bestSector = false;
522 int bestThreatSector = 0;
523 float bestDistance = std::numeric_limits<float>::max();
524 const auto faction = escort.unit->faction();
525 const float range = escort.protectionRange;
526 auto consider = [&](Candidate& candidate, bool allowClaimed) {
527 if ((!allowClaimed && claimed.contains(candidate.key)) || candidate.faction == nullptr ||
528 FactionRelationSystem::isAllied(*candidate.faction, faction->link))
529 return;
530 const float distance = distanceSquared(escort.center.x, escort.center.y,
531 candidate.position.x, candidate.position.y);
532 if (distance > range * range) return;
533 const bool direct =
534 std::any_of(escort.protectedMembers.begin(), escort.protectedMembers.end(),
535 [&](ecs::EntityHandle member) { return isSameHandle(candidate.activeTarget, member); });
536 int threatSector = 0;
537 const float forwardLength = std::hypot(escort.travelDirection.x, escort.travelDirection.y);
538 if (forwardLength > 1e-5f) {
539 const float dx = candidate.position.x - escort.center.x;
540 const float dy = candidate.position.y - escort.center.y;
541 const float longitudinal = dx * escort.travelDirection.x + dy * escort.travelDirection.y;
542 const float lateral = dx * -escort.travelDirection.y + dy * escort.travelDirection.x;
543 threatSector = std::abs(longitudinal) >= std::abs(lateral)
544 ? (longitudinal >= 0.0f ? 2 : -2)
545 : (lateral >= 0.0f ? 1 : -1);
546 }
547 const bool sector = escort.screenSector == 0 || escort.screenSector == threatSector;
548 const bool preferred = best == nullptr ||
549 (direct != bestDirect ? direct > bestDirect
550 : sector != bestSector ? sector > bestSector
551 : distance != bestDistance ? distance < bestDistance : candidate.key < best->key);
552 if (preferred) {
553 best = &candidate;
554 bestDirect = direct;
555 bestSector = sector;
556 bestThreatSector = threatSector;
557 bestDistance = distance;
558 }
559 };
560 for (Candidate& candidate : candidates) consider(candidate, false);
561 if (best == nullptr)
562 for (Candidate& candidate : candidates) consider(candidate, true);
563 const SubjectRef nextIntercept = best == nullptr || best->durability == nullptr
564 ? SubjectRef{} : best->durability->subject;
565 auto tactics = escort.unit->tactics();
566 if (tactics->escortInterceptTarget.isValid() && nextIntercept.isValid() &&
567 tactics->escortInterceptTarget != nextIntercept)
568 ++tactics->escortHandoffCount;
569 tactics->escortInterceptTarget = nextIntercept;
570 escort.unit->combat()->target = best == nullptr ? ecs::EntityHandle{} : best->handle;
571 tactics->escortSectorMatched = best != nullptr && bestSector;
572 tactics->escortReinforcing = best != nullptr && escort.screenSector != 0 && !bestSector;
573 tactics->escortReinforcementSector = tactics->escortReinforcing ? bestThreatSector : 0;
574 if (best != nullptr) claimed.insert(best->key);
575 ++processed;
576 }
577 }
578 constexpr float retreatCoverInterval = 1.5f;
579 for (auto& [groupId, members] : groups) {
580 (void)groupId;
581 std::sort(members.begin(), members.end(), [](Unit* left, Unit* right) {
582 return left->identity()->subject.format() < right->identity()->subject.format();
583 });
584 std::vector<Unit*> retreating;
585 float groupElapsed = 0.0f;
586 for (Unit* member : members) {
587 auto tactics = member->tactics();
588 if (!member->morale()->retreating) {
589 tactics->retreatFireTeam = -1;
590 tactics->retreatCoverElapsed = 0.0f;
591 tactics->retreatCovering = false;
592 continue;
593 }
594 retreating.push_back(member);
595 groupElapsed = std::max(groupElapsed, tactics->retreatCoverElapsed);
596 }
597 if (retreating.size() < 2) {
598 for (Unit* member : retreating) {
599 member->tactics()->retreatFireTeam = -1;
600 member->tactics()->retreatCoverElapsed = 0.0f;
601 member->tactics()->retreatCovering = false;
602 }
603 continue;
604 }
605 groupElapsed += static_cast<float>(step.delta.seconds());
606 const int coveringTeam = static_cast<int>(std::floor(groupElapsed / retreatCoverInterval)) % 2;
607 for (std::size_t index = 0; index < retreating.size(); ++index) {
608 auto tactics = retreating[index]->tactics();
609 tactics->retreatFireTeam = static_cast<int>(index % 2);
610 tactics->retreatCoverElapsed = groupElapsed;
611 tactics->retreatCovering = tactics->retreatFireTeam == coveringTeam;
612 }
613 }
614 // Automatic groups share one deterministic commitment ledger for the tick.
615 // This prevents separately numbered formations from independently budgeting
616 // lethal volleys against the same target. Explicit Attack orders never enter
617 // this path and therefore retain intentional focus-fire semantics.
618 std::map<std::string, double> committed;
619 auto commitShot = [&](SubjectRef source, ecs::Entity* ownFaction, WorldPosition origin,
620 const weapon::WeaponDefinition& definition, const Candidate& aim,
621 float damageFactor) -> Result<void> {
622 const float launchDistance = std::hypot(aim.position.x - origin.x, aim.position.y - origin.y);
623 const double baseDamage = static_cast<double>(definition.damage) *
624 static_cast<double>(std::max(1, definition.projectile.pelletCount)) *
625 static_cast<double>(damageFactor) *
626 static_cast<double>(weaponRangeDamageFactor(definition, launchDistance));
627 const bool splash = definition.projectile.speed > 0.0f && definition.projectile.aoe > 0.0f;
628 for (Candidate& victim : candidates) {
629 if ((!splash && !isSameHandle(victim.handle, aim.handle)) || victim.faction == nullptr ||
630 (!definition.friendlyFire &&
631 FactionRelationSystem::isAllied(dynamic_cast<Faction*>(victim.faction->resolve()),
632 dynamic_cast<Faction*>(ownFaction))) ||
633 (victim.airborne && !definition.targetsAir) || (!victim.airborne && !definition.targetsGround) ||
634 victim.tags == nullptr ||
635 std::any_of(definition.requiredTargetTags.begin(), definition.requiredTargetTags.end(),
636 [&](const auto& tag) { return !victim.tags->contains(tag); }) ||
637 std::any_of(definition.excludedTargetTags.begin(), definition.excludedTargetTags.end(),
638 [&](const auto& tag) { return victim.tags->contains(tag); }))
639 continue;
640 double radial = 1.0;
641 if (splash) {
642 const float distance = std::hypot(victim.position.x - aim.position.x,
643 victim.position.y - aim.position.y);
644 if (distance > definition.projectile.aoe) continue;
645 radial = std::max<double>(definition.splashMinimumDamageFactor,
646 1.0 - distance / definition.projectile.aoe);
647 }
648 const double raw = baseDamage * radial;
649 const double shieldDamage = std::min<double>(std::max(0.0f, victim.shield), raw);
650 double healthDamage = raw - shieldDamage;
651 if (damage != nullptr && victim.durability != nullptr && healthDamage > 0.0) {
654 request.target = victim.durability->subject;
655 request.damageType = definition.damageType.empty() ? "damage.physical" : definition.damageType;
656 request.healthDamage = healthDamage;
657 auto previewed = damage->preview(*victim.durability, request);
658 if (!previewed) return Result<void>::failure(previewed.status());
659 healthDamage = previewed.value().healthDamage;
660 }
661 committed[victim.key] += shieldDamage + healthDamage;
662 }
664 };
665 for (auto& [groupId, members] : groups) {
666 (void)groupId;
667 std::sort(members.begin(), members.end(), [](Unit* left, Unit* right) {
668 return left->identity()->self.id < right->identity()->self.id;
669 });
670 const float volleyPhase = members.front()->tactics()->volleyReleaseRemaining;
671 for (Unit* member : members)
672 if (member->tactics()->coordinatedVolleyInterval > 0.0f)
673 member->tactics()->volleyReleaseRemaining = volleyPhase;
674 WorldPosition groupCenter{};
675 for (Unit* member : members) {
676 groupCenter.x += member->motion()->x;
677 groupCenter.y += member->motion()->y;
678 }
679 groupCenter.x /= static_cast<float>(members.size());
680 groupCenter.y /= static_cast<float>(members.size());
681 for (Unit* shooter : members) {
682 if (shooter->morale()->retreating && !shooter->tactics()->retreatCovering) {
683 shooter->combat()->target = {};
684 continue;
685 }
686 auto* weaponEntity = dynamic_cast<weapon::WeaponEntity*>(shooter->weapon()->link.resolve());
687 if (weaponEntity == nullptr || weaponEntity->definition()->def == nullptr) continue;
688 const auto& definition = *weaponEntity->definition()->def;
689 Candidate* best = nullptr;
690 float bestScore = std::numeric_limits<float>::max();
691 bool bestSector = false;
692 float bestPreference = -1.0f;
693 float bestEffectiveness = -1.0f;
694 for (Candidate& candidate : candidates) {
695 if (candidate.faction == nullptr ||
696 FactionRelationSystem::isAllied(*candidate.faction, shooter->faction()->link) ||
697 isSameHandle(candidate.handle, shooter->identity()->self) ||
698 committed[candidate.key] >= candidate.health + candidate.shield)
699 continue;
700 const float distance = distanceSquared(shooter->motion()->x, shooter->motion()->y,
701 candidate.position.x, candidate.position.y);
702 const float range = shooter->combat()->acquisitionRange > 0.0f
703 ? shooter->combat()->acquisitionRange : definition.range;
704 if (distance > range * range) continue;
705 const float shooterX = shooter->motion()->x - groupCenter.x;
706 const float shooterY = shooter->motion()->y - groupCenter.y;
707 const float targetX = candidate.position.x - groupCenter.x;
708 const float targetY = candidate.position.y - groupCenter.y;
709 const bool sector = shooter->tactics()->threatSector == 0 ||
710 shooterX * targetX + shooterY * targetY > 0.0f;
711 const float preference = weaponTargetPreference(definition, candidate.tags);
712 float effectiveness = 1.0f;
713 if (damage != nullptr && candidate.durability != nullptr && definition.damage > 0.0f) {
715 request.source = shooter->identity()->subject;
716 request.target = candidate.durability->subject;
717 request.damageType = definition.damageType.empty() ? "damage.physical" : definition.damageType;
718 request.healthDamage = definition.damage;
719 auto previewed = damage->preview(*candidate.durability, request);
720 if (!previewed) return Result<std::size_t>::failure(previewed.status());
721 effectiveness = static_cast<float>(previewed.value().healthDamage / definition.damage);
722 }
723 const bool preferred = best == nullptr ||
724 (sector != bestSector ? sector > bestSector
725 : effectiveness != bestEffectiveness ? effectiveness > bestEffectiveness
726 : preference != bestPreference ? preference > bestPreference
727 : distance != bestScore ? distance < bestScore : candidate.key < best->key);
728 if (preferred) {
729 best = &candidate;
730 bestScore = distance;
731 bestSector = sector;
732 bestPreference = preference;
733 bestEffectiveness = effectiveness;
734 }
735 }
736 if (best == nullptr) continue;
737 shooter->combat()->target = best->handle;
738 shooter->tactics()->fireControlEffectiveness = bestEffectiveness < 0.0f ? 1.0f : bestEffectiveness;
739 auto committedShot = commitShot(shooter->identity()->subject, shooter->faction()->link.resolve(),
740 {shooter->motion()->x, shooter->motion()->y}, definition, *best, 1.0f);
741 if (!committedShot) return Result<std::size_t>::failure(committedShot.status());
742 ++processed;
743 }
744 }
745
746 struct TurretNode {
747 Building* building = nullptr;
748 const weapon::WeaponDefinition* weapon = nullptr;
749 std::string key;
750 };
751 std::vector<TurretNode> turrets;
752 auto turretBuildings = ecs::View<Building, Building::Identity, Building::Orders, Building::Faction,
756 for (auto it = turretBuildings.begin(); it != turretBuildings.end(); ++it) {
757 auto [identity, orders, faction, placement, weaponLink, combat, integrity, construction,
758 infrastructure, garrison] = *it;
759 (void)orders; (void)faction; (void)placement; (void)garrison;
760 auto* building = dynamic_cast<Building*>(ecs::try_get(identity->self));
761 auto* weaponEntity = dynamic_cast<weapon::WeaponEntity*>(weaponLink->link.resolve());
762 if (building == nullptr || !integrity->alive || construction->progress < 1.0f ||
763 !infrastructure->powered || combat->airDefenseNetworkRange > 0.0f || weaponEntity == nullptr ||
764 weaponEntity->definition()->def == nullptr) continue;
765 turrets.push_back({building, weaponEntity->definition()->def, identity->subject.format()});
766 }
767 std::sort(turrets.begin(), turrets.end(), [](const auto& left, const auto& right) {
768 return left.key < right.key;
769 });
770 for (const TurretNode& turret : turrets) {
771 Building* building = turret.building;
772 auto current = readCurrent(building->orders()->values);
773 if (!current) return Result<std::size_t>::failure(current.status());
774 if (current.value() && current.value()->kind == OrderKind::Attack) continue;
775 const auto& definition = *turret.weapon;
776 Candidate* best = nullptr;
777 float bestPreference = -1.0f;
778 float bestEffectiveness = -1.0f;
779 float bestDistance = std::numeric_limits<float>::max();
780 for (Candidate& candidate : candidates) {
781 if (candidate.faction == nullptr ||
782 FactionRelationSystem::isAllied(*candidate.faction, building->faction()->link) ||
783 isSameHandle(candidate.handle, building->identity()->self) ||
784 committed[candidate.key] >= candidate.health + candidate.shield ||
785 (candidate.airborne && !definition.targetsAir) || (!candidate.airborne && !definition.targetsGround) ||
786 candidate.tags == nullptr ||
787 std::any_of(definition.requiredTargetTags.begin(), definition.requiredTargetTags.end(),
788 [&](const auto& tag) { return !candidate.tags->contains(tag); }) ||
789 std::any_of(definition.excludedTargetTags.begin(), definition.excludedTargetTags.end(),
790 [&](const auto& tag) { return candidate.tags->contains(tag); }))
791 continue;
792 const float range = building->combat()->acquisitionRange > 0.0f
793 ? building->combat()->acquisitionRange : definition.range;
794 const float distance = distanceSquared(building->placement()->worldX,
795 building->placement()->worldY, candidate.position.x, candidate.position.y);
796 if (distance > range * range) continue;
797 const float preference = weaponTargetPreference(definition, candidate.tags);
798 float effectiveness = 1.0f;
799 if (damage != nullptr && candidate.durability != nullptr && definition.damage > 0.0f) {
801 request.source = building->identity()->subject;
802 request.target = candidate.durability->subject;
803 request.damageType = definition.damageType.empty() ? "damage.physical" : definition.damageType;
804 request.healthDamage = definition.damage;
805 auto previewed = damage->preview(*candidate.durability, request);
806 if (!previewed) return Result<std::size_t>::failure(previewed.status());
807 effectiveness = static_cast<float>(previewed.value().healthDamage / definition.damage);
808 }
809 if (best == nullptr || effectiveness > bestEffectiveness ||
810 (effectiveness == bestEffectiveness && (preference > bestPreference ||
811 (preference == bestPreference && (distance < bestDistance ||
812 (distance == bestDistance && candidate.key < best->key)))))) {
813 best = &candidate;
814 bestPreference = preference;
815 bestEffectiveness = effectiveness;
816 bestDistance = distance;
817 }
818 }
819 building->combat()->target = best == nullptr ? ecs::EntityHandle{} : best->handle;
820 if (best != nullptr) {
821 const float garrisonFactor = 1.0f + building->garrison()->damageBonusPerOccupant *
822 static_cast<float>(building->garrison()->occupants.size());
823 auto committedShot = commitShot(building->identity()->subject, building->faction()->link.resolve(),
824 {building->placement()->worldX, building->placement()->worldY},
825 definition, *best, garrisonFactor);
826 if (!committedShot) return Result<std::size_t>::failure(committedShot.status());
827 }
828 ++processed;
829 }
830
831 struct DefenseNode {
832 Building* building = nullptr;
833 const weapon::WeaponDefinition* weapon = nullptr;
834 std::string key;
835 };
836 std::vector<DefenseNode> defenses;
837 auto defenseBuildings = ecs::View<Building, Building::Identity, Building::Placement, Building::Orders,
840 for (auto it = defenseBuildings.begin(); it != defenseBuildings.end(); ++it) {
841 auto [identity, placement, orders, faction, weaponLink, combat, integrity, construction, infrastructure] = *it;
842 (void)placement; (void)orders; (void)faction;
843 combat->airDefenseNetworkRoot = {};
844 combat->airDefenseNetworkSize = 0;
845 if (!std::isfinite(combat->airDefenseNetworkRange) || combat->airDefenseNetworkRange < 0.0f)
847 DiagnosticCode::InvalidArgument, "RTS air-defense network range must be finite and non-negative",
848 "building.combat.airDefenseNetworkRange"));
849 auto* building = dynamic_cast<Building*>(ecs::try_get(identity->self));
850 auto* weaponEntity = dynamic_cast<weapon::WeaponEntity*>(weaponLink->link.resolve());
851 if (building == nullptr || !integrity->alive || construction->progress < 1.0f ||
852 !infrastructure->powered || combat->airDefenseNetworkRange <= 0.0f || weaponEntity == nullptr ||
853 weaponEntity->definition()->def == nullptr || !weaponEntity->definition()->def->targetsAir)
854 continue;
855 defenses.push_back({building, weaponEntity->definition()->def, identity->subject.format()});
856 }
857 std::sort(defenses.begin(), defenses.end(), [](const auto& left, const auto& right) {
858 return left.key < right.key;
859 });
860 std::vector<bool> visited(defenses.size(), false);
861 for (std::size_t start = 0; start < defenses.size(); ++start) {
862 if (visited[start]) continue;
863 std::vector<std::size_t> component{start};
864 visited[start] = true;
865 for (std::size_t cursor = 0; cursor < component.size(); ++cursor) {
866 const auto index = component[cursor];
867 Building* current = defenses[index].building;
868 for (std::size_t other = 0; other < defenses.size(); ++other) {
869 if (visited[other] || !FactionRelationSystem::isAllied(defenses[other].building->faction()->link,
870 current->faction()->link))
871 continue;
872 Building* candidate = defenses[other].building;
873 const float linkRange = std::max(current->combat()->airDefenseNetworkRange,
874 candidate->combat()->airDefenseNetworkRange);
875 if (distanceSquared(current->placement()->worldX, current->placement()->worldY,
876 candidate->placement()->worldX, candidate->placement()->worldY) <=
877 linkRange * linkRange) {
878 visited[other] = true;
879 component.push_back(other);
880 }
881 }
882 }
883 std::sort(component.begin(), component.end(), [&](auto left, auto right) {
884 return defenses[left].key < defenses[right].key;
885 });
886 const auto root = defenses[component.front()].building->identity()->self;
887 std::set<std::string> claimed;
888 for (const auto index : component) {
889 DefenseNode& defense = defenses[index];
890 Building* building = defense.building;
891 building->combat()->airDefenseNetworkRoot = root;
892 building->combat()->airDefenseNetworkSize = component.size();
893 auto current = readCurrent(building->orders()->values);
894 if (!current) return Result<std::size_t>::failure(current.status());
895 if (current.value() && current.value()->kind == OrderKind::Attack) continue;
896 Candidate* best = nullptr;
897 float bestPreference = -1.0f;
898 float bestDistance = std::numeric_limits<float>::max();
899 for (Candidate& candidate : candidates) {
900 if (!candidate.airborne || candidate.faction == nullptr ||
901 FactionRelationSystem::isAllied(*candidate.faction, building->faction()->link) ||
902 claimed.contains(candidate.key))
903 continue;
904 const auto& definition = *defense.weapon;
905 if (candidate.tags == nullptr ||
906 std::any_of(definition.requiredTargetTags.begin(), definition.requiredTargetTags.end(),
907 [&](const auto& tag) { return !candidate.tags->contains(tag); }) ||
908 std::any_of(definition.excludedTargetTags.begin(), definition.excludedTargetTags.end(),
909 [&](const auto& tag) { return candidate.tags->contains(tag); })) continue;
910 const float range = building->combat()->acquisitionRange > 0.0f
911 ? building->combat()->acquisitionRange : definition.range;
912 const float distance = distanceSquared(building->placement()->worldX,
913 building->placement()->worldY, candidate.position.x, candidate.position.y);
914 if (distance > range * range) continue;
915 const float preference = weaponTargetPreference(definition, candidate.tags);
916 if (best == nullptr || preference > bestPreference ||
917 (preference == bestPreference && (distance < bestDistance ||
918 (distance == bestDistance && candidate.key < best->key)))) {
919 best = &candidate;
920 bestPreference = preference;
921 bestDistance = distance;
922 }
923 }
924 building->combat()->target = best == nullptr ? ecs::EntityHandle{} : best->handle;
925 if (best != nullptr) claimed.insert(best->key);
926 ++processed;
927 }
928 }
929 return Result<std::size_t>::success(processed,
931}
932
934 if (step.delta.nanoseconds() < 0)
936 Diagnostic::error(DiagnosticCode::InvalidArgument, "RTS AI step delta must be non-negative", "step.delta"));
937 std::size_t processed = 0;
938 auto factions = ecs::View<Faction, Faction::Identity, Faction::Strategy>();
939 for (auto it = factions.begin(); it != factions.end(); ++it) {
940 auto [identity, strategy] = *it;
941 auto* faction = dynamic_cast<Faction*>(ecs::try_get(identity->self));
942 if (faction == nullptr || !strategy->enabled) continue;
943 if (!std::isfinite(strategy->thinkInterval) || strategy->thinkInterval <= 0.0f ||
944 strategy->desiredWorkers < 0 || strategy->attackThreshold <= 0 ||
945 !std::isfinite(strategy->formationSpacing) || strategy->formationSpacing <= 0.0f)
947 DiagnosticCode::InvalidArgument, "RTS AI policy values are invalid", "faction.strategy"));
948 strategy->thinkAccumulator += static_cast<float>(step.delta.seconds());
949 if (strategy->thinkAccumulator + 1e-6f < strategy->thinkInterval) continue;
950 strategy->thinkAccumulator = std::fmod(strategy->thinkAccumulator, strategy->thinkInterval);
951
952 int workerCount = 0;
953 std::vector<ecs::EntityHandle> army;
956 for (auto unitIt = units.begin(); unitIt != units.end(); ++unitIt) {
957 auto [unitIdentity, definition, owner, orders, durability, containment] = *unitIt;
958 if (!durability->alive || containment->container.isBound() ||
959 owner->link.resolve() != faction) continue;
960 if (strategy->workerDefinition.isValid() && definition->id == strategy->workerDefinition) ++workerCount;
961 if (strategy->armyDefinition.isValid() && definition->id == strategy->armyDefinition)
962 army.push_back(unitIdentity->self);
963 (void)orders;
964 }
965
966 if (requestProduction) {
967 const LogicalId& wanted = workerCount < strategy->desiredWorkers ? strategy->workerDefinition
968 : strategy->armyDefinition;
969 if (wanted.isValid()) {
970 Building* producer = nullptr;
971 auto buildings = ecs::View<Building, Building::Identity, Building::Faction,
973 for (auto buildingIt = buildings.begin(); buildingIt != buildings.end(); ++buildingIt) {
974 auto [buildingIdentity, owner, construction, integrity, production] = *buildingIt;
975 (void)production;
976 if (!integrity->alive || construction->progress < 1.0f || owner->link.resolve() != faction)
977 continue;
978 auto* candidate = dynamic_cast<Building*>(ecs::try_get(buildingIdentity->self));
979 if (candidate != nullptr && (producer == nullptr ||
980 candidate->identity()->self.id < producer->identity()->self.id)) producer = candidate;
981 }
982 if (producer != nullptr) {
983 auto requested = requestProduction(*faction, *producer, wanted);
984 if (!requested) return Result<std::size_t>::failure(requested.status());
985 ++processed;
986 }
987 }
988 }
989
990 if (static_cast<int>(army.size()) < strategy->attackThreshold) continue;
991 std::vector<ecs::EntityHandle> idleArmy;
992 for (const auto& handle : army) {
993 auto* unit = dynamic_cast<Unit*>(ecs::try_get(handle));
994 if (unit != nullptr && unit->orders()->values.empty()) idleArmy.push_back(handle);
995 }
996 if (idleArmy.empty()) continue;
997 Building* target = nullptr;
1000 for (auto targetIt = targets.begin(); targetIt != targets.end(); ++targetIt) {
1001 auto [targetIdentity, definition, owner, placement, integrity] = *targetIt;
1002 (void)placement;
1003 if (!integrity->alive ||
1004 FactionRelationSystem::isAllied(dynamic_cast<Faction*>(owner->link.resolve()), faction) ||
1005 (strategy->targetBuildingDefinition.isValid() && definition->id != strategy->targetBuildingDefinition))
1006 continue;
1007 auto* candidate = dynamic_cast<Building*>(ecs::try_get(targetIdentity->self));
1008 if (candidate != nullptr && (target == nullptr ||
1009 candidate->identity()->self.id < target->identity()->self.id)) target = candidate;
1010 }
1011 if (target == nullptr && strategy->targetBuildingDefinition.isValid()) {
1012 for (auto targetIt = targets.begin(); targetIt != targets.end(); ++targetIt) {
1013 auto [targetIdentity, definition, owner, placement, integrity] = *targetIt;
1014 (void)definition; (void)placement;
1015 if (!integrity->alive ||
1016 FactionRelationSystem::isAllied(dynamic_cast<Faction*>(owner->link.resolve()), faction))
1017 continue;
1018 auto* candidate = dynamic_cast<Building*>(ecs::try_get(targetIdentity->self));
1019 if (candidate != nullptr && (target == nullptr ||
1020 candidate->identity()->self.id < target->identity()->self.id)) target = candidate;
1021 }
1022 }
1023 if (target == nullptr) continue;
1024 CommandSpec attackMove;
1025 attackMove.kind = OrderKind::AttackMove;
1026 attackMove.target = {target->placement()->worldX, target->placement()->worldY};
1027 FormationSpec formation;
1028 formation.kind = FormationKind::Grid;
1029 formation.spacing = strategy->formationSpacing;
1030 auto issued = CommandFanOutSystem::fanOut(idleArmy, attackMove, formation);
1031 if (!issued) return Result<std::size_t>::failure(issued.status());
1032 const std::uint64_t group = static_cast<std::uint64_t>(identity->self.id) + 1u;
1033 for (const auto& handle : idleArmy) {
1034 auto* unit = dynamic_cast<Unit*>(ecs::try_get(handle));
1035 if (unit != nullptr) unit->tactics()->combatGroup = group;
1036 }
1037 processed += issued.value().accepted;
1038 }
1039 return Result<std::size_t>::success(processed,
1041}
1042
1044 std::size_t completedCount = 0;
1047 for (auto it = view.begin(); it != view.end(); ++it) {
1048 auto [identity, motion, navigation, orders, containment] = *it;
1049 if (identity == nullptr)
1051 DiagnosticCode::InvariantViolation, "RTS movement order candidate has no identity", "unit.identity"));
1052 if (containment->container.isBound() || !motion->arrived || navigation->trafficWaiting) continue;
1053 auto current = readCurrent(orders->values);
1054 if (!current) return Result<std::size_t>::failure(current.status());
1055 auto record = std::move(current).takeValue();
1056 if (!record || (record->kind != OrderKind::Move && record->kind != OrderKind::AttackMove)) continue;
1057 if (record->kind == OrderKind::AttackMove && orders->values.orderCount() <= 1) continue;
1058 const bool hasActivePath = navigation->plannedOrderId == record->id && !navigation->unreachable &&
1059 navigation->waypointIndex < navigation->waypoints.size();
1060 if (hasActivePath && navigation->waypointIndex + 1 < navigation->waypoints.size()) continue;
1061 auto completed = orders->values.complete(record->id);
1062 if (!completed) return Result<std::size_t>::failure(completed.status());
1063 navigation->waypoints.clear();
1064 navigation->waypointIndex = 0;
1065 navigation->plannedOrderId.clear();
1066 navigation->trafficWaiting = false;
1067 ++completedCount;
1068 }
1069 return Result<std::size_t>::success(completedCount,
1070 Status::success(completedCount == 0 ? StatusCode::NoOp : StatusCode::Applied));
1071}
1072
1074 std::size_t processed = 0;
1077 for (auto it = view.begin(); it != view.end(); ++it) {
1078 auto [identity, motion, navigation, orders, combat, containment] = *it;
1079 if (identity == nullptr)
1081 DiagnosticCode::InvariantViolation, "RTS command-state candidate has no identity", "unit.identity"));
1082 auto current = readCurrent(orders->values);
1083 if (!current) return Result<std::size_t>::failure(current.status());
1084 auto record = std::move(current).takeValue();
1085 const bool holding = record && record->kind == OrderKind::HoldPosition;
1086 const bool attackMoving = record && record->kind == OrderKind::AttackMove;
1087 if (holding && !combat->holdPosition) {
1088 combat->guardX = motion->x;
1089 combat->guardY = motion->y;
1090 combat->guardSet = true;
1091 }
1092 combat->holdPosition = holding;
1093 combat->attackMove = attackMoving;
1094 if (!record || record->kind != OrderKind::Stop) continue;
1095 combat->target = {};
1096 combat->guardSet = false;
1097 navigation->waypoints.clear();
1098 navigation->waypointIndex = 0;
1099 navigation->plannedOrderId.clear();
1100 navigation->trafficWaiting = false;
1101 navigation->patrolInitialized = false;
1102 motion->arrived = true;
1103 auto completed = orders->values.complete(record->id);
1104 if (!completed) return Result<std::size_t>::failure(completed.status());
1105 ++processed;
1106 (void)containment;
1107 }
1108 return Result<std::size_t>::success(processed,
1110}
1111
1113 const NavigationEvent& unreachable) {
1114 if (!std::isfinite(grid.cellSize) || grid.cellSize <= 0.0f || !std::isfinite(grid.originX) ||
1115 !std::isfinite(grid.originY))
1118 "RTS navigation grid requires finite origin and positive cell size", "navigation.grid"));
1119 const auto worldToCell = [&](float value, float origin) {
1120 return static_cast<int>(std::lround((value - origin) / grid.cellSize));
1121 };
1122 const auto cellToWorld = [&](int value, float origin) {
1123 return origin + static_cast<float>(value) * grid.cellSize;
1124 };
1125 std::size_t processed = 0;
1128 for (auto it = view.begin(); it != view.end(); ++it) {
1129 auto [identity, motion, navigation, orders, containment, tactics, supply, faction] = *it;
1130 auto* unit = identity == nullptr ? nullptr : dynamic_cast<Unit*>(ecs::try_get(identity->self));
1131 if (unit == nullptr || &*unit->identity() != identity) continue;
1132 if (containment->container.isBound()) continue;
1133 auto current = readCurrent(orders->values);
1134 if (!current) return Result<std::size_t>::failure(current.status());
1135 auto record = std::move(current).takeValue();
1136 if (!record || !isMovementOrder(record->kind)) {
1137 navigation->waypoints.clear();
1138 navigation->waypointIndex = 0;
1139 navigation->plannedOrderId.clear();
1140 navigation->unreachable = false;
1141 navigation->unreachableReported = false;
1142 navigation->trafficWaiting = false;
1143 navigation->patrolInitialized = false;
1144 navigation->patrolTowardTarget = true;
1145 supply->routeThreat = 0.0f;
1146 supply->routeAvoidedThreat = false;
1147 continue;
1148 }
1149 WorldPosition goal = record->target;
1150 if (record->kind == OrderKind::Attack || record->kind == OrderKind::Resupply ||
1151 record->kind == OrderKind::SupplyRelay) {
1152 if (auto target = entityPosition(record->targetEntity)) goal = *target;
1153 if (record->kind == OrderKind::SupplyRelay && supply->rendezvousActive)
1154 goal = supply->rendezvousPoint;
1155 } else if (record->kind == OrderKind::Escort && tactics->guardSet) {
1156 goal = {tactics->guardX, tactics->guardY};
1157 } else if (record->kind == OrderKind::Patrol && navigation->patrolInitialized &&
1158 !navigation->patrolTowardTarget) {
1159 goal = navigation->patrolOrigin;
1160 }
1161 const bool supplyMission = record->kind == OrderKind::Resupply ||
1162 record->kind == OrderKind::SupplyRelay;
1163 auto* viewer = dynamic_cast<Faction*>(faction->link.resolve());
1164 float currentRouteThreat = 0.0f;
1165 if (supplyMission && viewer != nullptr) {
1166 WorldPosition previous{motion->x, motion->y};
1167 for (std::size_t index = navigation->waypointIndex; index < navigation->waypoints.size(); ++index) {
1168 const WorldPosition point = navigation->waypoints[index];
1169 currentRouteThreat += visibleHostileThreatAt(*viewer, point) *
1170 std::hypot(point.x - previous.x, point.y - previous.y);
1171 previous = point;
1172 }
1173 }
1174 const bool threatChanged = supplyMission && navigation->plannedOrderId == record->id &&
1175 currentRouteThreat > supply->routeThreat + 1e-4f;
1176 const bool changed = navigation->plannedOrderId != record->id || threatChanged ||
1177 distanceSquared(goal.x, goal.y, navigation->plannedGoal.x,
1178 navigation->plannedGoal.y) > grid.cellSize * grid.cellSize * 0.25f;
1179 if (changed) {
1180 navigation->waypoints.clear();
1181 navigation->waypointIndex = 0;
1182 navigation->plannedOrderId = record->id;
1183 navigation->plannedGoal = goal;
1184 navigation->unreachable = false;
1185 navigation->unreachableReported = false;
1186 const int startX = worldToCell(motion->x, grid.originX);
1187 const int startY = worldToCell(motion->y, grid.originY);
1188 const int goalX = worldToCell(goal.x, grid.originX);
1189 const int goalY = worldToCell(goal.y, grid.originY);
1190 std::unique_ptr<map::Path> baseline;
1191 std::unique_ptr<map::Path> path;
1192 if (supplyMission) {
1193 baseline.reset(pathfinder.findPath(startX, startY, goalX, goalY));
1194 path.reset(pathfinder.findPath(startX, startY, goalX, goalY,
1195 [&](int x, int y) {
1196 if (viewer == nullptr) return 0.0f;
1197 const WorldPosition point{cellToWorld(x, grid.originX),
1198 cellToWorld(y, grid.originY)};
1199 return visibleHostileThreatAt(*viewer, point) * 0.5f;
1200 }));
1201 const auto exposure = [&](const map::Path* candidate) {
1202 if (candidate == nullptr || candidate->empty() || viewer == nullptr) return 0.0f;
1203 float total = 0.0f;
1204 WorldPosition previous{motion->x, motion->y};
1205 for (int index = 1; index < candidate->getLength(); ++index) {
1206 const WorldPosition point{cellToWorld(candidate->getX(index), grid.originX),
1207 cellToWorld(candidate->getY(index), grid.originY)};
1208 total += visibleHostileThreatAt(*viewer, point) *
1209 std::hypot(point.x - previous.x, point.y - previous.y);
1210 previous = point;
1211 }
1212 return total;
1213 };
1214 supply->routeThreat = exposure(path.get());
1215 supply->routeAvoidedThreat = baseline != nullptr && !baseline->empty() &&
1216 supply->routeThreat + 1e-4f < exposure(baseline.get());
1217 } else {
1218 path.reset(pathfinder.findPath(startX, startY, goalX, goalY));
1219 supply->routeThreat = 0.0f;
1220 supply->routeAvoidedThreat = false;
1221 }
1222 if (path == nullptr || path->empty()) {
1223 navigation->unreachable = true;
1224 } else {
1225 navigation->waypoints.reserve(static_cast<std::size_t>(path->getLength()));
1226 for (int index = 1; index < path->getLength(); ++index)
1227 navigation->waypoints.push_back({cellToWorld(path->getX(index), grid.originX),
1228 cellToWorld(path->getY(index), grid.originY)});
1229 }
1230 } else if (supplyMission) {
1231 supply->routeThreat = currentRouteThreat;
1232 }
1233 while (navigation->waypointIndex < navigation->waypoints.size()) {
1234 const auto waypoint = navigation->waypoints[navigation->waypointIndex];
1235 const float radius = std::max(motion->arrivalRadius, grid.cellSize * 0.1f);
1236 if (distanceSquared(motion->x, motion->y, waypoint.x, waypoint.y) > radius * radius) break;
1237 ++navigation->waypointIndex;
1238 }
1239 if (navigation->unreachable && !navigation->unreachableReported) {
1240 navigation->unreachableReported = true;
1241 if (unreachable) unreachable(*unit, *record);
1242 }
1243 ++processed;
1244 }
1245 return Result<std::size_t>::success(processed,
1246 Status::success(processed == 0 ? StatusCode::NoOp : StatusCode::Applied));
1247}
1248
1249Result<std::size_t> PatrolSystem::step() {
1250 std::size_t processed = 0;
1253 for (auto it = view.begin(); it != view.end(); ++it) {
1254 auto [identity, motion, navigation, orders, containment] = *it;
1255 if (identity == nullptr)
1257 DiagnosticCode::InvariantViolation, "RTS patrol candidate has no identity", "unit.identity"));
1258 auto current = readCurrent(orders->values);
1259 if (!current) return Result<std::size_t>::failure(current.status());
1260 auto record = std::move(current).takeValue();
1261 if (!record || record->kind != OrderKind::Patrol || containment->container.isBound()) {
1262 navigation->patrolInitialized = false;
1263 navigation->patrolTowardTarget = true;
1264 continue;
1265 }
1266 if (!navigation->patrolInitialized) {
1267 navigation->patrolOrigin = {motion->x, motion->y};
1268 navigation->patrolInitialized = true;
1269 navigation->patrolTowardTarget = true;
1270 }
1271 const WorldPosition endpoint = navigation->patrolTowardTarget ? record->target : navigation->patrolOrigin;
1272 const float radius = std::max(motion->arrivalRadius, 0.01f);
1273 if (distanceSquared(motion->x, motion->y, endpoint.x, endpoint.y) <= radius * radius) {
1274 navigation->patrolTowardTarget = !navigation->patrolTowardTarget;
1275 navigation->plannedOrderId.clear();
1276 navigation->waypoints.clear();
1277 navigation->waypointIndex = 0;
1278 navigation->unreachable = false;
1279 navigation->unreachableReported = false;
1280 motion->arrived = false;
1281 }
1282 ++processed;
1283 }
1284 return Result<std::size_t>::success(processed,
1286}
1287
1288void FogOfWarSystem::clear(State& state) noexcept {
1289 for (const auto& binding : state.bindings)
1290 if (binding.provider != nullptr && binding.revealer >= 0)
1291 binding.provider->removeRevealer(binding.revealer);
1292 state.bindings.clear();
1293}
1294
1295const Faction::Intel::Contact* FogOfWarSystem::contact(const Faction& faction, SubjectRef subject) noexcept {
1296 const auto& contacts = const_cast<Faction&>(faction).intel()->contacts;
1297 const auto it = std::find_if(contacts.begin(), contacts.end(),
1298 [&](const auto& value) { return value.subject == subject; });
1299 return it != contacts.end() && it->subject == subject ? &*it : nullptr;
1300}
1301
1302Result<std::size_t> FogOfWarSystem::step(const SimulationStep& step, const NavigationGrid& grid, State& state,
1303 const FogProvider& provider) {
1304 if (!provider)
1306 Diagnostic::error(DiagnosticCode::InvalidArgument, "RTS fog provider is required", "fog.provider"));
1307 if (step.delta.nanoseconds() < 0 || !std::isfinite(grid.cellSize) || grid.cellSize <= 0.0f)
1309 Diagnostic::error(DiagnosticCode::InvalidArgument, "RTS fog step/grid is invalid", "fog.grid"));
1310 struct Source {
1311 ecs::EntityHandle handle{};
1312 ecs::EntityHandle faction{};
1313 map::Fov* fov = nullptr;
1315 float sight = 0.0f;
1316 float detectionRange = 0.0f;
1317 float detectionStrength = 0.0f;
1318 float radarRange = 0.0f;
1319 float radarResolution = 0.0f;
1320 float jammingRange = 0.0f;
1321 };
1322 std::vector<Source> sources;
1323 auto addSource = [&](ecs::EntityHandle handle, const FactionLink& factionLink, WorldPosition position,
1324 float sight, float detectionRange, float detectionStrength, float radarRange,
1325 float radarResolution, float jammingRange, bool enabled) -> Result<void> {
1326 if (!enabled || (sight <= 0.0f && radarRange <= 0.0f && jammingRange <= 0.0f))
1328 if (!isFinitePosition(position) || !std::isfinite(sight) || !std::isfinite(detectionRange) ||
1329 !std::isfinite(detectionStrength) || !std::isfinite(radarRange) || !std::isfinite(radarResolution) ||
1330 !std::isfinite(jammingRange) || sight < 0.0f || detectionRange < 0.0f || detectionStrength < 0.0f ||
1331 radarRange < 0.0f || radarResolution < 0.0f || jammingRange < 0.0f)
1333 DiagnosticCode::InvalidArgument, "RTS vision values must be finite and non-negative", "vision"));
1334 auto* faction = dynamic_cast<Faction*>(factionLink.resolve());
1335 if (faction == nullptr) return Result<void>::success(Status::success(StatusCode::NoOp));
1336 map::Fov* fov = provider(*faction);
1337 if (fov == nullptr) return Result<void>::success(Status::success(StatusCode::NoOp));
1338 sources.push_back({handle, ecs::handle_of(faction), fov, position, sight, detectionRange,
1339 detectionStrength, radarRange, radarResolution, jammingRange});
1341 };
1342 auto units = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Faction, Unit::Vision, Unit::Containment>();
1343 for (auto it = units.begin(); it != units.end(); ++it) {
1344 auto [identity, motion, faction, vision, containment] = *it;
1345 if (containment->container.isBound()) continue;
1346 auto added = addSource(identity->self, faction->link, {motion->x, motion->y}, vision->sightRange,
1347 vision->detectionRange, vision->detectionStrength, vision->radarRange,
1348 vision->radarResolution, vision->jammingRange, vision->enabled);
1349 if (!added) return Result<std::size_t>::failure(added.status());
1350 }
1353 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
1354 auto [identity, placement, faction, vision, construction] = *it;
1355 if (!placement->placed || construction->progress < 1.0f) continue;
1356 auto added = addSource(identity->self, faction->link, {placement->worldX, placement->worldY},
1357 vision->sightRange, vision->detectionRange, vision->detectionStrength,
1358 vision->radarRange, vision->radarResolution, vision->jammingRange,
1359 vision->enabled);
1360 if (!added) return Result<std::size_t>::failure(added.status());
1361 }
1362 state.bindings.erase(
1363 std::remove_if(state.bindings.begin(), state.bindings.end(),
1364 [&](const auto& binding) {
1365 const bool retained = std::any_of(sources.begin(), sources.end(), [&](const Source& source) {
1366 return source.sight > 0.0f && isSameHandle(source.handle, binding.source) &&
1367 source.fov == binding.provider;
1368 });
1369 if (!retained && binding.provider != nullptr && binding.revealer >= 0)
1370 binding.provider->removeRevealer(binding.revealer);
1371 return !retained;
1372 }),
1373 state.bindings.end());
1374 const auto worldToCell = [&](float value, float origin) {
1375 return static_cast<int>(std::lround((value - origin) / grid.cellSize));
1376 };
1377 std::vector<map::Fov*> providers;
1378 for (const auto& source : sources) {
1379 if (source.sight <= 0.0f) continue;
1380 auto binding = std::find_if(state.bindings.begin(), state.bindings.end(), [&](const auto& value) {
1381 return isSameHandle(value.source, source.handle) && value.provider == source.fov;
1382 });
1383 const int x = worldToCell(source.position.x, grid.originX);
1384 const int y = worldToCell(source.position.y, grid.originY);
1385 const int radius = std::max(0, static_cast<int>(std::ceil(source.sight / grid.cellSize)));
1386 if (binding == state.bindings.end()) {
1387 const int revealer = source.fov->addRevealer(x, y, radius);
1388 state.bindings.push_back({source.handle, source.faction, source.fov, revealer});
1389 } else {
1390 source.fov->setRevealerPosition(binding->revealer, x, y);
1391 source.fov->setRevealerRadius(binding->revealer, radius);
1392 binding->faction = source.faction;
1393 }
1394 if (std::find(providers.begin(), providers.end(), source.fov) == providers.end())
1395 providers.push_back(source.fov);
1396 }
1397 for (auto* fov : providers) fov->compute();
1398
1399 auto factions = ecs::View<Faction, Faction::Identity, Faction::Intel>();
1400 for (auto fit = factions.begin(); fit != factions.end(); ++fit) {
1401 auto [identity, intel] = *fit;
1402 auto* viewer = dynamic_cast<Faction*>(ecs::try_get(identity->self));
1403 map::Fov* fov = viewer == nullptr ? nullptr : provider(*viewer);
1404 intel->enabled = fov != nullptr;
1405 for (auto& value : intel->contacts) {
1406 value.ageSeconds += step.delta.seconds();
1407 value.visible = false;
1408 value.detected = false;
1409 }
1410 if (fov == nullptr) continue;
1411 auto observe = [&](SubjectRef subject, std::string_view kind, ecs::EntityHandle targetFaction,
1412 WorldPosition position, bool cloaked, float stealth) {
1413 auto* enemyFaction = dynamic_cast<Faction*>(ecs::try_get(targetFaction));
1414 if (enemyFaction == nullptr || enemyFaction == viewer || provider(*enemyFaction) == fov) return;
1415 const int x = worldToCell(position.x, grid.originX);
1416 const int y = worldToCell(position.y, grid.originY);
1417 bool detected = !cloaked;
1418 if (cloaked && fov->isVisible(x, y)) {
1419 for (const auto& source : sources) {
1420 if (source.fov != fov || source.detectionRange <= 0.0f ||
1421 source.detectionStrength < stealth) continue;
1422 if (distanceSquared(source.position.x, source.position.y, position.x, position.y) <=
1423 source.detectionRange * source.detectionRange) {
1424 detected = true;
1425 break;
1426 }
1427 }
1428 }
1429 const bool visible = fov->isVisible(x, y) && detected;
1430 auto found = std::lower_bound(intel->contacts.begin(), intel->contacts.end(), subject,
1431 [](const auto& value, SubjectRef key) {
1432 return value.subject.format() < key.format();
1433 });
1434 if (!visible) {
1435 const bool jammed = std::any_of(sources.begin(), sources.end(), [&](const Source& jammer) {
1436 if (jammer.jammingRange <= 0.0f || jammer.fov == fov) return false;
1437 return distanceSquared(jammer.position.x, jammer.position.y, position.x, position.y) <=
1438 jammer.jammingRange * jammer.jammingRange;
1439 });
1440 const Source* radar = nullptr;
1441 if (!jammed) {
1442 for (const auto& candidate : sources) {
1443 if (candidate.fov != fov || candidate.radarRange <= 0.0f) continue;
1444 if (distanceSquared(candidate.position.x, candidate.position.y, position.x, position.y) <=
1445 candidate.radarRange * candidate.radarRange) {
1446 radar = &candidate;
1447 break;
1448 }
1449 }
1450 }
1451 if (radar == nullptr) return;
1452 const float resolution = radar->radarResolution > 0.0f ? radar->radarResolution : grid.cellSize;
1453 const WorldPosition quantized{
1454 grid.originX + (std::floor((position.x - grid.originX) / resolution) + 0.5f) * resolution,
1455 grid.originY + (std::floor((position.y - grid.originY) / resolution) + 0.5f) * resolution};
1456 const std::string radarKind = kind == "unit" ? "radar_unit" : "radar_building";
1457 if (found == intel->contacts.end() || found->subject != subject)
1458 intel->contacts.insert(found, {subject, radarKind, quantized, 0.0, false, false});
1459 else {
1460 found->position = quantized;
1461 found->ageSeconds = 0.0;
1462 found->visible = false;
1463 found->detected = false;
1464 found->kind = radarKind;
1465 }
1466 return;
1467 }
1468 if (found == intel->contacts.end() || found->subject != subject)
1469 found = intel->contacts.insert(found, {subject, std::string(kind), position, 0.0, true, detected});
1470 else {
1471 found->position = position;
1472 found->ageSeconds = 0.0;
1473 found->visible = true;
1474 found->detected = detected;
1475 found->kind = kind;
1476 }
1477 };
1478 for (auto it = units.begin(); it != units.end(); ++it) {
1479 auto [targetIdentity, motion, faction, vision, containment] = *it;
1480 if (!containment->container.isBound())
1481 observe(targetIdentity->subject, "unit", faction->link.handle(), {motion->x, motion->y},
1482 vision->cloaked, vision->stealth);
1483 }
1484 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
1485 auto [targetIdentity, placement, faction, vision, construction] = *it;
1486 if (placement->placed)
1487 observe(targetIdentity->subject, "building", faction->link.handle(),
1488 {placement->worldX, placement->worldY}, false, 0.0f);
1489 }
1490 }
1491 return Result<std::size_t>::success(sources.size(),
1492 Status::success(sources.empty() ? StatusCode::NoOp : StatusCode::Applied));
1493}
1494
1495Result<std::size_t> WorkerAssignmentSystem::step() {
1496 struct Candidate {
1497 ResourceNode* node = nullptr;
1498 float distance = 0.0f;
1499 };
1500 std::size_t assigned = 0;
1501 auto workers = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Orders, Unit::Worker>();
1502 for (auto it = workers.begin(); it != workers.end(); ++it) {
1503 auto [identity, motion, orders, worker] = *it;
1504 auto* unit = identity == nullptr ? nullptr : dynamic_cast<Unit*>(ecs::try_get(identity->self));
1505 if (unit == nullptr || &*unit->identity() != identity) continue;
1506 if (!worker->autoAssign || worker->capacity <= 0.0f || worker->gatherRate <= 0.0f ||
1507 worker->resourceType.empty() || !orders->values.empty() || !worker->dropoff.isBound() ||
1508 worker->dropoff.isStale())
1509 continue;
1510
1511 std::vector<Candidate> candidates;
1514 for (auto nodeIt = nodes.begin(); nodeIt != nodes.end(); ++nodeIt) {
1515 auto [nodeIdentity, position, stock, harvest] = *nodeIt;
1516 auto* node = nodeIdentity == nullptr
1517 ? nullptr
1518 : dynamic_cast<ResourceNode*>(ecs::try_get(nodeIdentity->self));
1519 if (node == nullptr || stock->resourceType != worker->resourceType ||
1520 (!stock->infinite && stock->remaining <= 0.0f))
1521 continue;
1522 harvest->workers.erase(
1523 std::remove_if(harvest->workers.begin(), harvest->workers.end(),
1524 [](const ecs::EntityHandle& handle) { return ecs::try_get(handle) == nullptr; }),
1525 harvest->workers.end());
1526 if (harvest->workers.size() >= harvest->capacity) continue;
1527 candidates.push_back({node, distanceSquared(motion->x, motion->y, position->x, position->y)});
1528 }
1529 std::sort(candidates.begin(), candidates.end(), [](const Candidate& left, const Candidate& right) {
1530 if (left.distance != right.distance) return left.distance < right.distance;
1531 return left.node->identity()->self.id < right.node->identity()->self.id;
1532 });
1533 if (candidates.empty()) continue;
1534 ResourceNode* node = candidates.front().node;
1535 auto link = ResourceNodeLink::bind(ecs::handle_of(node));
1536 if (!link) return Result<std::size_t>::failure(link.status());
1537 worker->resourceNode = std::move(link).takeValue();
1538 node->harvest()->workers.push_back(ecs::handle_of(unit));
1539 CommandSpec command;
1540 command.kind = OrderKind::Gather;
1541 command.target = {node->position()->x, node->position()->y};
1542 command.targetEntity = ecs::handle_of(node);
1543 auto queued = orders->values.enqueue(command);
1544 if (!queued) return Result<std::size_t>::failure(queued.status());
1545 std::move(queued).takeValue();
1546 ++assigned;
1547 }
1548 return Result<std::size_t>::success(assigned,
1550}
1551
1552Result<std::size_t> MiningSystem::step(const SimulationStep& step, const ResourceCredit& credit) {
1553 if (step.delta.nanoseconds() < 0 || !credit)
1556 "RTS mining requires non-negative time and a resource credit callback", "mining"));
1557 std::size_t processed = 0;
1558 auto workers = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Orders, Unit::Worker>();
1559 for (auto it = workers.begin(); it != workers.end(); ++it) {
1560 auto [identity, motion, orders, worker] = *it;
1561 auto* unit = identity == nullptr ? nullptr : dynamic_cast<Unit*>(ecs::try_get(identity->self));
1562 if (unit == nullptr || &*unit->identity() != identity) continue;
1563 auto current = readCurrent(orders->values);
1564 if (!current) return Result<std::size_t>::failure(current.status());
1565 auto record = std::move(current).takeValue();
1566 if (!record) continue;
1567
1568 if (record->kind == OrderKind::Gather && motion->arrived) {
1569 auto* node = dynamic_cast<ResourceNode*>(worker->resourceNode.resolve());
1570 if (node == nullptr) {
1571 auto failed = orders->values.fail(record->id, "resource node is stale");
1572 if (!failed) return Result<std::size_t>::failure(failed.status());
1573 worker->resourceNode.reset();
1574 continue;
1575 }
1576 const float room = std::max(0.0f, worker->capacity - worker->cargo);
1577 float amount = std::min(room, worker->gatherRate * static_cast<float>(step.delta.seconds()));
1578 if (!node->stock()->infinite) amount = std::min(amount, node->stock()->remaining);
1579 worker->cargo += amount;
1580 if (!node->stock()->infinite) node->stock()->remaining -= amount;
1581 if (worker->cargo >= worker->capacity || (!node->stock()->infinite && node->stock()->remaining <= 0.0f)) {
1582 auto* dropoff = dynamic_cast<Building*>(worker->dropoff.resolve());
1583 if (dropoff == nullptr) {
1584 auto failed = orders->values.fail(record->id, "dropoff is stale");
1585 if (!failed) return Result<std::size_t>::failure(failed.status());
1586 continue;
1587 }
1588 auto completed = orders->values.complete(record->id);
1589 if (!completed) return Result<std::size_t>::failure(completed.status());
1590 CommandSpec returning;
1591 returning.kind = OrderKind::ReturnCargo;
1592 returning.target = {dropoff->placement()->worldX, dropoff->placement()->worldY};
1593 returning.targetEntity = ecs::handle_of(dropoff);
1594 auto queued = orders->values.enqueue(returning);
1595 if (!queued) return Result<std::size_t>::failure(queued.status());
1596 std::move(queued).takeValue();
1597 }
1598 ++processed;
1599 } else if (record->kind == OrderKind::ReturnCargo && motion->arrived && worker->cargo >= 1.0f) {
1600 const auto whole = static_cast<std::int64_t>(std::floor(worker->cargo));
1601 auto cost = resource::CostSpec::single(worker->resourceType, whole);
1602 if (!cost) return Result<std::size_t>::failure(cost.status());
1603 auto credited = credit(*unit, cost.value());
1604 if (!credited) return Result<std::size_t>::failure(credited.status());
1605 worker->cargo -= static_cast<float>(whole);
1606 auto completed = orders->values.complete(record->id);
1607 if (!completed) return Result<std::size_t>::failure(completed.status());
1608 auto* node = dynamic_cast<ResourceNode*>(worker->resourceNode.resolve());
1609 if (node != nullptr && (node->stock()->infinite || node->stock()->remaining > 0.0f)) {
1610 CommandSpec gather;
1611 gather.kind = OrderKind::Gather;
1612 gather.target = {node->position()->x, node->position()->y};
1613 gather.targetEntity = ecs::handle_of(node);
1614 auto queued = orders->values.enqueue(gather);
1615 if (!queued) return Result<std::size_t>::failure(queued.status());
1616 std::move(queued).takeValue();
1617 } else {
1618 worker->resourceNode.reset();
1619 }
1620 ++processed;
1621 }
1622 }
1623 return Result<std::size_t>::success(processed,
1625}
1626
1627Result<std::size_t> ConstructionSystem::step(const SimulationStep& step, const LifecycleEventSink& events) {
1628 if (step.delta.nanoseconds() < 0)
1630 DiagnosticCode::InvalidArgument, "RTS construction step delta must be non-negative", "step.delta"));
1631 std::size_t processed = 0;
1632 auto buildings = ecs::View<Building, Building::Identity, Building::Construction, Building::Faction>();
1633 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
1634 auto [identity, construction, faction] = *it;
1635 auto* building = identity == nullptr ? nullptr : dynamic_cast<Building*>(ecs::try_get(identity->self));
1636 if (building == nullptr || &*building->identity() != identity) continue;
1637 if (!std::isfinite(construction->progress) || !std::isfinite(construction->buildTimeSeconds) ||
1638 construction->buildTimeSeconds < 0.0f)
1640 DiagnosticCode::InvalidArgument, "invalid RTS construction state", "building.construction"));
1641 if (construction->progress >= 1.0f || construction->paused) continue;
1642
1643 construction->builders.clear();
1644 if (events)
1645 events({LifecycleEventKind::ConstructionCompleted, identity->subject, {},
1646 building->definition()->id.format(), 1.0}, step.tick);
1647 float totalRate = 0.0f;
1648 auto units = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Orders, Unit::Worker, Unit::Faction>();
1649 for (auto unitIt = units.begin(); unitIt != units.end(); ++unitIt) {
1650 auto [unitIdentity, motion, orders, worker, unitFaction] = *unitIt;
1651 auto* unit = unitIdentity == nullptr ? nullptr : dynamic_cast<Unit*>(ecs::try_get(unitIdentity->self));
1652 if (unit == nullptr || !motion->arrived || worker->buildRate <= 0.0f ||
1653 unitFaction->link.resolve() != faction->link.resolve())
1654 continue;
1655 auto current = readCurrent(orders->values);
1656 if (!current) return Result<std::size_t>::failure(current.status());
1657 auto record = std::move(current).takeValue();
1658 if (!record || record->kind != OrderKind::Build || !isSameHandle(record->targetEntity, identity->self))
1659 continue;
1660 construction->builders.push_back(unitIdentity->self);
1661 totalRate += worker->buildRate;
1662 }
1663 if (totalRate <= 0.0f) continue;
1664 if (construction->buildTimeSeconds == 0.0f) construction->progress = 1.0f;
1665 else construction->progress = std::min(1.0f, construction->progress +
1666 totalRate * static_cast<float>(step.delta.seconds()) / construction->buildTimeSeconds);
1667 ++processed;
1668 if (construction->progress < 1.0f) continue;
1669 for (const auto& builderHandle : construction->builders) {
1670 auto* unit = dynamic_cast<Unit*>(ecs::try_get(builderHandle));
1671 if (unit == nullptr) continue;
1672 auto current = readCurrent(unit->orders()->values);
1673 if (!current) return Result<std::size_t>::failure(current.status());
1674 auto record = std::move(current).takeValue();
1675 if (record && record->kind == OrderKind::Build && isSameHandle(record->targetEntity, identity->self)) {
1676 auto completed = unit->orders()->values.complete(record->id);
1677 if (!completed) return Result<std::size_t>::failure(completed.status());
1678 }
1679 }
1680 construction->builders.clear();
1681 }
1682 return Result<std::size_t>::success(processed,
1684}
1685
1686Result<std::size_t> WorkforceAssignmentSystem::step() {
1687 struct WorkerCandidate { Unit* unit; };
1688 struct Target { Building* building; OrderKind kind; std::size_t limit; };
1689 std::size_t processed = 0;
1690 auto factions = ecs::View<Faction, Faction::Identity, Faction::Workforce>();
1691 for (auto factionIt = factions.begin(); factionIt != factions.end(); ++factionIt) {
1692 auto [factionIdentity, policy] = *factionIt;
1693 if (!policy->autoConstruction && !policy->autoRepair) continue;
1694 if (policy->maxBuildersPerSite == 0 || policy->maxRepairersPerBuilding == 0)
1696 "RTS workforce assignment limits must be positive",
1697 "faction.workforce"));
1698 std::vector<WorkerCandidate> idle;
1701 for (auto it = units.begin(); it != units.end(); ++it) {
1702 auto [identity, orders, worker, faction, containment, durability] = *it;
1703 if (!durability->alive || containment->container.isBound() || !orders->values.empty() ||
1704 faction->link.resolve() != ecs::try_get(factionIdentity->self) ||
1705 (worker->buildRate <= 0.0f && worker->repairRate <= 0.0f)) continue;
1706 if (auto* unit = dynamic_cast<Unit*>(ecs::try_get(identity->self))) idle.push_back({unit});
1707 }
1708 std::size_t budget = idle.size() > policy->reserveWorkers ? idle.size() - policy->reserveWorkers : 0;
1709 if (budget == 0) continue;
1710
1711 std::vector<Target> targets;
1714 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
1715 auto [identity, faction, construction, integrity] = *it;
1716 if (faction->link.resolve() != ecs::try_get(factionIdentity->self) || !integrity->alive) continue;
1717 auto* building = dynamic_cast<Building*>(ecs::try_get(identity->self));
1718 if (building == nullptr) continue;
1719 if (policy->autoConstruction && construction->progress < 1.0f && !construction->paused)
1720 targets.push_back({building, OrderKind::Build, policy->maxBuildersPerSite});
1721 else if (policy->autoRepair && construction->progress >= 1.0f &&
1722 integrity->state.health < integrity->state.maxHealth)
1723 targets.push_back({building, OrderKind::Repair, policy->maxRepairersPerBuilding});
1724 }
1725 std::sort(targets.begin(), targets.end(), [](const Target& left, const Target& right) {
1726 return left.building->identity()->self.id < right.building->identity()->self.id;
1727 });
1728 for (const Target& target : targets) {
1729 if (budget == 0 || idle.empty()) break;
1730 std::size_t assigned = 0;
1731 auto assignedView = ecs::View<Unit, Unit::Orders, Unit::Faction>();
1732 for (auto it = assignedView.begin(); it != assignedView.end(); ++it) {
1733 auto [orders, faction] = *it;
1734 if (faction->link.resolve() != ecs::try_get(factionIdentity->self)) continue;
1735 auto current = readCurrent(orders->values);
1736 if (!current) return Result<std::size_t>::failure(current.status());
1737 auto record = std::move(current).takeValue();
1738 if (record && record->kind == target.kind &&
1739 isSameHandle(record->targetEntity, target.building->identity()->self))
1740 ++assigned;
1741 }
1742 while (assigned < target.limit && budget > 0) {
1743 auto best = idle.end();
1744 float bestDistance = std::numeric_limits<float>::max();
1745 for (auto it = idle.begin(); it != idle.end(); ++it) {
1746 auto* worker = it->unit;
1747 if ((target.kind == OrderKind::Build && worker->worker()->buildRate <= 0.0f) ||
1748 (target.kind == OrderKind::Repair && worker->worker()->repairRate <= 0.0f)) continue;
1749 const float distance = distanceSquared(worker->motion()->x, worker->motion()->y,
1750 target.building->placement()->worldX,
1751 target.building->placement()->worldY);
1752 if (best == idle.end() || distance < bestDistance ||
1753 (distance == bestDistance && worker->identity()->self.id < best->unit->identity()->self.id)) {
1754 best = it;
1755 bestDistance = distance;
1756 }
1757 }
1758 if (best == idle.end()) break;
1759 CommandSpec command;
1760 command.kind = target.kind;
1761 command.target = {target.building->placement()->worldX, target.building->placement()->worldY};
1762 command.targetEntity = target.building->identity()->self;
1763 auto queued = best->unit->orders()->values.replace(command);
1764 if (!queued) return Result<std::size_t>::failure(queued.status());
1765 std::move(queued).takeValue();
1766 idle.erase(best);
1767 --budget;
1768 ++assigned;
1769 ++processed;
1770 }
1771 }
1772 }
1773 return Result<std::size_t>::success(processed,
1775}
1776
1777Result<std::size_t> RepairSystem::step(const SimulationStep& step, combat::DamageRuntime& settlement,
1778 const RepairDebit& debit) {
1779 if (step.delta.nanoseconds() < 0 || !debit)
1781 DiagnosticCode::InvalidArgument, "RTS repair requires non-negative time and a debit callback", "repair"));
1782 std::size_t processed = 0;
1783 auto units = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Orders, Unit::Worker, Unit::Faction>();
1784 for (auto it = units.begin(); it != units.end(); ++it) {
1785 auto [identity, motion, orders, worker, faction] = *it;
1786 auto* unit = identity == nullptr ? nullptr : dynamic_cast<Unit*>(ecs::try_get(identity->self));
1787 if (unit == nullptr || !motion->arrived || worker->repairRate <= 0.0f) continue;
1788 auto current = readCurrent(orders->values);
1789 if (!current) return Result<std::size_t>::failure(current.status());
1790 auto record = std::move(current).takeValue();
1791 if (!record || record->kind != OrderKind::Repair) continue;
1792 auto* building = dynamic_cast<Building*>(ecs::try_get(record->targetEntity));
1793 if (building == nullptr || building->faction()->link.resolve() != faction->link.resolve() ||
1794 building->construction()->progress < 1.0f) {
1795 auto failed = orders->values.fail(record->id, "repair target is invalid");
1796 if (!failed) return Result<std::size_t>::failure(failed.status());
1797 continue;
1798 }
1799 auto integrity = building->integrity();
1800 const float missing = static_cast<float>(std::max(0.0, integrity->state.maxHealth - integrity->state.health));
1801 if (missing <= 0.0f) {
1802 auto completed = orders->values.complete(record->id);
1803 if (!completed) return Result<std::size_t>::failure(completed.status());
1804 continue;
1805 }
1806 const float requested = std::min(missing, worker->repairRate * static_cast<float>(step.delta.seconds()));
1807 const double healthBefore = integrity->state.health;
1808 auto restored = settlement.heal(integrity->state, unit->identity()->subject, requested);
1809 if (!restored) return Result<std::size_t>::failure(restored.status());
1810 const float healed = static_cast<float>(restored.value().applied);
1811 const float accumulatedCost = integrity->repairCostRemainder + healed * integrity->repairCostPerHealth;
1812 const auto wholeCost = static_cast<std::int64_t>(std::floor(accumulatedCost));
1813 if (wholeCost > 0) {
1814 auto cost = resource::CostSpec::single(integrity->repairResource, wholeCost);
1815 if (!cost) {
1816 integrity->state.health = healthBefore;
1817 return Result<std::size_t>::failure(cost.status());
1818 }
1819 auto paid = debit(*unit, *building, cost.value());
1820 if (!paid) {
1821 integrity->state.health = healthBefore;
1822 return Result<std::size_t>::failure(paid.status());
1823 }
1824 }
1825 integrity->repairCostRemainder = accumulatedCost - static_cast<float>(wholeCost);
1826 ++processed;
1827 }
1828 return Result<std::size_t>::success(processed,
1830}
1831
1833 if (step.delta.nanoseconds() < 0)
1835 DiagnosticCode::InvalidArgument, "RTS capture step delta must be non-negative", "step.delta"));
1836 struct Force { ecs::EntityHandle faction{}; float strength = 0.0f; std::vector<Unit*> units; };
1837 std::size_t processed = 0;
1840 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
1841 auto [identity, capture, construction, owner, rally] = *it;
1842 auto* building = identity == nullptr ? nullptr : dynamic_cast<Building*>(ecs::try_get(identity->self));
1843 if (building == nullptr || !capture->capturable || capture->blockedByGarrison ||
1844 construction->progress < 1.0f)
1845 continue;
1846 if (!std::isfinite(capture->durationSeconds) || capture->durationSeconds <= 0.0f)
1848 "capture duration must be positive",
1849 "building.capture.durationSeconds"));
1850 std::vector<Force> forces;
1851 auto units = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Orders, Unit::Capture, Unit::Faction>();
1852 for (auto unitIt = units.begin(); unitIt != units.end(); ++unitIt) {
1853 auto [unitIdentity, motion, orders, contribution, faction] = *unitIt;
1854 auto* unit = unitIdentity == nullptr ? nullptr : dynamic_cast<Unit*>(ecs::try_get(unitIdentity->self));
1855 if (unit == nullptr || !motion->arrived || contribution->rate <= 0.0f ||
1856 faction->link.resolve() == nullptr || FactionRelationSystem::isAllied(faction->link, owner->link))
1857 continue;
1858 auto current = readCurrent(orders->values);
1859 if (!current) return Result<std::size_t>::failure(current.status());
1860 auto record = std::move(current).takeValue();
1861 if (!record || record->kind != OrderKind::Capture || !isSameHandle(record->targetEntity, identity->self))
1862 continue;
1863 auto found = std::find_if(forces.begin(), forces.end(), [&](const Force& force) {
1864 return isSameHandle(force.faction, faction->link.handle());
1865 });
1866 if (found == forces.end()) { forces.push_back({faction->link.handle(), 0.0f, {}}); found = forces.end() - 1; }
1867 found->strength += contribution->rate;
1868 found->units.push_back(unit);
1869 }
1870 const float dt = static_cast<float>(step.delta.seconds());
1871 if (forces.empty()) {
1872 capture->progress = std::max(0.0f, capture->progress - dt / capture->durationSeconds);
1873 if (capture->progress == 0.0f) capture->capturingFaction = {};
1874 continue;
1875 }
1876 if (forces.size() != 1) continue;
1877 Force& force = forces.front();
1878 if (ecs::try_get(capture->capturingFaction) != nullptr &&
1879 !isSameHandle(capture->capturingFaction, force.faction)) {
1880 capture->progress = std::max(0.0f, capture->progress - force.strength * dt / capture->durationSeconds);
1881 if (capture->progress == 0.0f) capture->capturingFaction = force.faction;
1882 continue;
1883 }
1884 capture->capturingFaction = force.faction;
1885 capture->progress = std::min(1.0f, capture->progress + force.strength * dt / capture->durationSeconds);
1886 ++processed;
1887 if (capture->progress < 1.0f) continue;
1888 auto linked = FactionLink::bind(force.faction);
1889 if (!linked) return Result<std::size_t>::failure(linked.status());
1890 owner->link = std::move(linked).takeValue();
1891 if (rally->enabled && rally->combatGroup != 0) {
1892 rally->combatGroup = ((static_cast<std::uint64_t>(force.faction.id) + 1u) << 32u) |
1893 (static_cast<std::uint64_t>(identity->self.id) + 1u);
1894 rally->reinforcements.clear();
1895 rally->reinforcementCapped = false;
1896 rally->reinforcementPolicyPausedTask.clear();
1897 rally->reinforcementCappedSeconds = 0.0f;
1898 }
1899 capture->progress = 0.0f;
1900 capture->capturingFaction = {};
1901 if (events) {
1902 Unit* capturer = *std::min_element(force.units.begin(), force.units.end(),
1903 [](Unit* left, Unit* right) {
1904 return left->identity()->subject.format() < right->identity()->subject.format();
1905 });
1906 events({LifecycleEventKind::BuildingCaptured, identity->subject,
1907 capturer->identity()->subject, building->definition()->id.format(), 1.0}, step.tick);
1908 }
1909 for (Unit* unit : force.units) {
1910 auto current = readCurrent(unit->orders()->values);
1911 if (!current) return Result<std::size_t>::failure(current.status());
1912 auto record = std::move(current).takeValue();
1913 if (record) {
1914 auto completed = unit->orders()->values.complete(record->id);
1915 if (!completed) return Result<std::size_t>::failure(completed.status());
1916 }
1917 }
1918 }
1919 return Result<std::size_t>::success(processed,
1921}
1922
1923Result<std::size_t> InfrastructureSystem::step(const SimulationStep& step,
1924 const PassiveIncomeCredit& credit) {
1925 if (step.delta.nanoseconds() < 0)
1927 DiagnosticCode::InvalidArgument, "RTS infrastructure step delta must be non-negative", "step.delta"));
1928
1929 struct Group { std::vector<Building*> buildings; };
1930 std::map<std::string, Group> groups;
1933 for (auto it = view.begin(); it != view.end(); ++it) {
1934 auto [identity, faction, construction, integrity, infrastructure] = *it;
1935 auto* building = identity == nullptr ? nullptr : dynamic_cast<Building*>(ecs::try_get(identity->self));
1936 if (building == nullptr || &*building->identity() != identity) continue;
1937 if (!std::isfinite(infrastructure->powerProduced) || infrastructure->powerProduced < 0.0f ||
1938 !std::isfinite(infrastructure->powerConsumed) || infrastructure->powerConsumed < 0.0f ||
1939 !std::isfinite(infrastructure->incomeRate) || infrastructure->incomeRate < 0.0f ||
1940 !std::isfinite(infrastructure->incomeProgress) || infrastructure->incomeProgress < 0.0f ||
1941 !std::isfinite(infrastructure->buildInfluenceRadius) || infrastructure->buildInfluenceRadius < 0.0f)
1943 DiagnosticCode::InvalidArgument, "RTS building infrastructure values must be finite and non-negative",
1944 "building.infrastructure"));
1945 if (!integrity->alive || integrity->state.health <= 0.0 || construction->progress < 1.0f) {
1946 infrastructure->powered = false;
1947 continue;
1948 }
1949 auto* owner = dynamic_cast<Faction*>(faction->link.resolve());
1950 if (owner == nullptr || !owner->identity()->subject.isValid()) {
1951 infrastructure->powered = infrastructure->powerConsumed == 0.0f;
1952 continue;
1953 }
1954 groups[owner->identity()->subject.format()].buildings.push_back(building);
1955 }
1956
1957 std::size_t processed = 0;
1958 const float dt = static_cast<float>(step.delta.seconds());
1959 for (auto& [_, group] : groups) {
1960 float available = 0.0f;
1961 for (Building* building : group.buildings)
1962 available += building->infrastructure()->powerProduced;
1963 std::sort(group.buildings.begin(), group.buildings.end(), [](Building* left, Building* right) {
1964 if (left->infrastructure()->powerPriority != right->infrastructure()->powerPriority)
1965 return left->infrastructure()->powerPriority > right->infrastructure()->powerPriority;
1966 return left->identity()->subject.format() < right->identity()->subject.format();
1967 });
1968 for (Building* building : group.buildings) {
1969 auto infrastructure = building->infrastructure();
1970 infrastructure->powered = infrastructure->powerConsumed <= available + 1e-6f;
1971 if (infrastructure->powered) available -= infrastructure->powerConsumed;
1972 ++processed;
1973 if (!infrastructure->powered || infrastructure->incomeRate == 0.0f ||
1974 infrastructure->incomeResource.empty()) continue;
1975 infrastructure->incomeProgress += infrastructure->incomeRate * dt;
1976 if (!credit) continue;
1977 const auto whole = static_cast<std::int64_t>(std::floor(infrastructure->incomeProgress));
1978 if (whole <= 0) continue;
1979 auto cost = resource::CostSpec::single(infrastructure->incomeResource, whole);
1980 if (!cost) return Result<std::size_t>::failure(cost.status());
1981 auto receipt = credit(*building, cost.value());
1982 if (!receipt) return Result<std::size_t>::failure(receipt.status());
1983 infrastructure->incomeProgress -= static_cast<float>(whole);
1984 }
1985 }
1986 return Result<std::size_t>::success(processed,
1988}
1989
1990Result<std::size_t> ContainmentSystem::step() {
1991 std::size_t processed = 0;
1992 auto retainedBy = [](const ecs::EntityHandle& occupant, const ecs::EntityHandle& container) {
1993 auto* unit = dynamic_cast<Unit*>(ecs::try_get(occupant));
1994 return unit != nullptr && unit->containment()->container.isBound() &&
1995 isSameHandle(unit->containment()->container.handle(), container);
1996 };
1997 auto transports = ecs::View<Unit, Unit::Identity, Unit::Containment>();
1998 for (auto it = transports.begin(); it != transports.end(); ++it) {
1999 auto [identity, containment] = *it;
2000 std::erase_if(containment->occupants,
2001 [&](const auto& occupant) { return !retainedBy(occupant, identity->self); });
2002 }
2003 auto garrisons = ecs::View<Building, Building::Identity, Building::Garrison, Building::Capture>();
2004 for (auto it = garrisons.begin(); it != garrisons.end(); ++it) {
2005 auto [identity, garrison, capture] = *it;
2006 std::erase_if(garrison->occupants,
2007 [&](const auto& occupant) { return !retainedBy(occupant, identity->self); });
2008 capture->blockedByGarrison = !garrison->occupants.empty();
2009 }
2010 auto units = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Orders, Unit::Faction, Unit::Containment>();
2011 for (auto it = units.begin(); it != units.end(); ++it) {
2012 auto [identity, motion, orders, faction, containment] = *it;
2013 auto* unit = identity == nullptr ? nullptr : dynamic_cast<Unit*>(ecs::try_get(identity->self));
2014 if (unit == nullptr || &*unit->identity() != identity) continue;
2015
2016 if (containment->container.isBound()) {
2017 ecs::Entity* container = containment->container.resolve();
2018 if (auto* transport = dynamic_cast<Unit*>(container)) {
2019 if (!transport->durability()->alive) {
2020 containment->container.reset();
2021 unit->durability()->alive = false;
2022 continue;
2023 }
2024 motion->x = transport->motion()->x;
2025 motion->y = transport->motion()->y;
2026 } else if (auto* building = dynamic_cast<Building*>(container)) {
2027 if (!building->integrity()->alive) {
2028 containment->container.reset();
2029 unit->durability()->alive = false;
2030 continue;
2031 }
2032 motion->x = building->placement()->worldX;
2033 motion->y = building->placement()->worldY;
2034 } else {
2035 containment->container.reset();
2036 }
2037 motion->arrived = true;
2038 ++processed;
2039 continue;
2040 }
2041
2042 auto current = readCurrent(orders->values);
2043 if (!current) return Result<std::size_t>::failure(current.status());
2044 auto record = std::move(current).takeValue();
2045 if (!record || (record->kind != OrderKind::Garrison && record->kind != OrderKind::BoardTransport) ||
2046 !motion->arrived)
2047 continue;
2048
2049 ecs::Entity* target = ecs::try_get(record->targetEntity);
2050 std::vector<ecs::EntityHandle>* occupants = nullptr;
2051 std::size_t capacity = 0;
2052 FactionLink* owner = nullptr;
2054 if (record->kind == OrderKind::BoardTransport) {
2055 auto* transport = dynamic_cast<Unit*>(target);
2056 if (transport != nullptr && transport != unit && transport->durability()->alive) {
2057 occupants = &transport->containment()->occupants;
2058 capacity = transport->containment()->capacity;
2059 owner = &transport->faction()->link;
2060 position = {transport->motion()->x, transport->motion()->y};
2061 }
2062 } else {
2063 auto* building = dynamic_cast<Building*>(target);
2064 if (building != nullptr && building->integrity()->alive && building->construction()->progress >= 1.0f) {
2065 occupants = &building->garrison()->occupants;
2066 capacity = building->garrison()->capacity;
2067 owner = &building->faction()->link;
2068 position = {building->placement()->worldX, building->placement()->worldY};
2069 }
2070 }
2071 if (occupants == nullptr || owner == nullptr || !FactionRelationSystem::isAllied(*owner, faction->link) ||
2072 occupants->size() >= capacity) {
2073 auto failed = orders->values.fail(record->id, "container is invalid, hostile, or full");
2074 if (!failed) return Result<std::size_t>::failure(failed.status());
2075 continue;
2076 }
2077 auto link = ContainerLink::bind(record->targetEntity);
2078 if (!link) return Result<std::size_t>::failure(link.status());
2079 containment->container = std::move(link).takeValue();
2080 occupants->push_back(identity->self);
2081 std::sort(occupants->begin(), occupants->end(), [](const auto& left, const auto& right) {
2082 if (left.id != right.id) return left.id < right.id;
2083 return left.generation < right.generation;
2084 });
2085 motion->x = position.x;
2086 motion->y = position.y;
2087 motion->arrived = true;
2088 if (auto* building = dynamic_cast<Building*>(target)) building->capture()->blockedByGarrison = true;
2089 auto completed = orders->values.complete(record->id);
2090 if (!completed) return Result<std::size_t>::failure(completed.status());
2091 ++processed;
2092 }
2093 return Result<std::size_t>::success(processed,
2095}
2096
2097namespace {
2098Result<std::size_t> releaseOccupants(std::vector<ecs::EntityHandle>& occupants, WorldPosition destination) {
2099 if (!std::isfinite(destination.x) || !std::isfinite(destination.y))
2101 Diagnostic::error(DiagnosticCode::InvalidArgument, "RTS unload destination must be finite", "destination"));
2102 const auto retained = occupants;
2103 occupants.clear();
2104 std::size_t released = 0;
2105 for (std::size_t index = 0; index < retained.size(); ++index) {
2106 auto* unit = dynamic_cast<Unit*>(ecs::try_get(retained[index]));
2107 if (unit == nullptr || !unit->durability()->alive) continue;
2108 const float angle = static_cast<float>(index) * 2.39996323f;
2109 const float radius = 0.75f * std::sqrt(static_cast<float>(index + 1));
2110 const WorldPosition slot{destination.x + std::cos(angle) * radius,
2111 destination.y + std::sin(angle) * radius};
2112 unit->containment()->container = {};
2113 unit->motion()->x = slot.x;
2114 unit->motion()->y = slot.y;
2115 unit->motion()->arrived = true;
2116 CommandSpec move;
2117 move.kind = OrderKind::Move;
2118 move.target = slot;
2119 auto ordered = unit->orders()->values.replace(move);
2120 if (!ordered) return Result<std::size_t>::failure(ordered.status());
2121 std::move(ordered).takeValue();
2122 ++released;
2123 }
2124 return Result<std::size_t>::success(released,
2126}
2127} // namespace
2128
2129Result<std::size_t> ContainmentSystem::unload(Unit& transport, WorldPosition destination) {
2130 return releaseOccupants(transport.containment()->occupants, destination);
2131}
2132
2133Result<std::size_t> ContainmentSystem::evacuate(Building& building, WorldPosition destination) {
2134 auto result = releaseOccupants(building.garrison()->occupants, destination);
2135 if (result) building.capture()->blockedByGarrison = false;
2136 return result;
2137}
2138
2139Result<SupplyRendezvousSelection> SupplyRendezvousSystem::select(
2140 Unit& supplier, Unit& relay, WorldPosition predicted,
2141 map::Pathfinder& pathfinder, const NavigationGrid& grid) {
2142 if (!isFinitePosition(predicted) || !std::isfinite(grid.cellSize) || grid.cellSize <= 0.0f ||
2143 !std::isfinite(grid.originX) || !std::isfinite(grid.originY))
2146 "RTS supply rendezvous requires a finite prediction and grid", "supply.rendezvous"));
2147 auto* faction = dynamic_cast<Faction*>(supplier.faction()->link.resolve());
2148 if (faction == nullptr || !FactionRelationSystem::isAllied(relay.faction()->link, supplier.faction()->link))
2150 DiagnosticCode::StaleHandle, "RTS supply rendezvous requires a shared live faction", "supply.faction"));
2151 WorldPosition forward{relay.motion()->x - supplier.motion()->x,
2152 relay.motion()->y - supplier.motion()->y};
2153 auto relayOrder = readCurrent(relay.orders()->values);
2154 if (!relayOrder) return Result<SupplyRendezvousSelection>::failure(relayOrder.status());
2155 const auto active = std::move(relayOrder).takeValue();
2156 if (active && isMovementOrder(active->kind)) {
2157 WorldPosition goal = active->target;
2158 if (relay.navigation()->plannedOrderId == active->id && !relay.navigation()->unreachable)
2159 goal = relay.navigation()->plannedGoal;
2160 forward = {goal.x - relay.motion()->x, goal.y - relay.motion()->y};
2161 }
2162 const float forwardLength = std::hypot(forward.x, forward.y);
2163 if (forwardLength > 1e-5f) {
2164 forward.x /= forwardLength;
2165 forward.y /= forwardLength;
2166 } else {
2167 forward = {1.0f, 0.0f};
2168 }
2169 const WorldPosition side{-forward.y, forward.x};
2170 const float offset = std::max(4.0f, supplier.supply()->range * 1.5f);
2171 const std::array<WorldPosition, 6> candidates{{
2172 predicted,
2173 {predicted.x + side.x * offset, predicted.y + side.y * offset},
2174 {predicted.x - side.x * offset, predicted.y - side.y * offset},
2175 {predicted.x - forward.x * offset, predicted.y - forward.y * offset},
2176 {predicted.x - forward.x * offset * 0.5f + side.x * offset,
2177 predicted.y - forward.y * offset * 0.5f + side.y * offset},
2178 {predicted.x - forward.x * offset * 0.5f - side.x * offset,
2179 predicted.y - forward.y * offset * 0.5f - side.y * offset}}};
2180 const auto worldToCell = [&](float value, float origin) {
2181 return static_cast<int>(std::lround((value - origin) / grid.cellSize));
2182 };
2183 struct Candidate { WorldPosition target; float threat; float deviation; std::size_t index; };
2184 std::optional<Candidate> best;
2185 std::optional<Candidate> baseline;
2186 for (std::size_t index = 0; index < candidates.size(); ++index) {
2187 const auto candidate = candidates[index];
2188 const int x = worldToCell(candidate.x, grid.originX);
2189 const int y = worldToCell(candidate.y, grid.originY);
2190 if (!pathfinder.isWalkable(x, y)) continue;
2191 std::unique_ptr<map::Path> path(pathfinder.findPath(
2192 worldToCell(supplier.motion()->x, grid.originX),
2193 worldToCell(supplier.motion()->y, grid.originY), x, y));
2194 if (path == nullptr || path->empty()) continue;
2195 Candidate evaluated{candidate, visibleHostileThreatAt(*faction, candidate),
2196 distanceSquared(candidate.x, candidate.y, predicted.x, predicted.y), index};
2197 if (index == 0) baseline = evaluated;
2198 if (!best || evaluated.threat < best->threat - 1e-4f ||
2199 (std::abs(evaluated.threat - best->threat) <= 1e-4f &&
2200 (evaluated.deviation < best->deviation - 1e-4f ||
2201 (std::abs(evaluated.deviation - best->deviation) <= 1e-4f && evaluated.index < best->index))))
2202 best = evaluated;
2203 }
2204 if (!best)
2206 DiagnosticCode::NotFound, "RTS supply rendezvous has no reachable candidate", "supply.rendezvous"));
2207 SupplyRendezvousSelection result{best->target, best->threat, false};
2208 if (baseline) result.avoidedThreat = best->threat < baseline->threat - 1e-4f;
2210}
2211
2212Result<std::size_t> SupplySystem::step(const SimulationStep& step, const AmmoProductionPurchase& purchase,
2213 map::Pathfinder* pathfinder, const NavigationGrid& grid,
2214 const LifecycleEventSink& events) {
2215 if (step.delta.nanoseconds() < 0)
2217 DiagnosticCode::InvalidArgument, "RTS supply step delta must be non-negative", "step.delta"));
2218 std::size_t processed = 0;
2221 for (auto it = producers.begin(); it != producers.end(); ++it) {
2222 auto [identity, construction, integrity, infrastructure, supply] = *it;
2223 auto* building = dynamic_cast<Building*>(ecs::try_get(identity->self));
2224 if (building == nullptr || &*building->identity() != identity) continue;
2225 if (!std::isfinite(supply->stock) || !std::isfinite(supply->capacity) ||
2226 !std::isfinite(supply->productionRate) || !std::isfinite(supply->productionProgress) ||
2227 supply->stock < 0.0f || supply->capacity < 0.0f || supply->productionRate < 0.0f ||
2228 supply->productionProgress < 0.0f || supply->productionCostPerRound < 0)
2231 "RTS building ammunition production values must be finite and non-negative", "building.supply"));
2232 const float remaining = std::max(0.0f, supply->capacity - supply->stock);
2233 if (!integrity->alive || construction->progress < 1.0f || !infrastructure->powered ||
2234 supply->productionResource.empty() || supply->productionRate <= 0.0f || remaining < 1.0f) {
2235 if (remaining < 1.0f) supply->productionProgress = std::min(supply->productionProgress, 0.999f);
2236 continue;
2237 }
2238 supply->productionProgress +=
2239 supply->productionRate * static_cast<float>(step.delta.seconds());
2240 const double readyValue = std::min<double>(
2241 std::floor(supply->productionProgress), std::floor(remaining));
2242 if (readyValue > static_cast<double>(std::numeric_limits<std::size_t>::max()))
2244 DiagnosticCode::InvalidArgument, "RTS ammunition production batch exceeds addressable size",
2245 "building.supply.productionProgress"));
2246 const auto ready = static_cast<std::size_t>(readyValue);
2247 if (ready == 0) continue;
2248 std::size_t purchased = ready;
2249 if (supply->productionCostPerRound > 0) {
2250 if (!purchase) continue;
2251 auto paid = purchase(*building, supply->productionResource,
2252 supply->productionCostPerRound, ready);
2253 if (!paid) return Result<std::size_t>::failure(paid.status());
2254 purchased = std::move(paid).takeValue();
2255 if (purchased > ready)
2258 "RTS ammunition purchase returned more rounds than requested", "purchase"));
2259 }
2260 supply->stock += static_cast<float>(purchased);
2261 supply->productionProgress -= static_cast<float>(purchased);
2262 if (purchased == 0) supply->productionProgress = std::min(supply->productionProgress, 1.0f);
2263 if (events && purchased > 0)
2264 events({LifecycleEventKind::AmmoProduced, identity->subject, {}, supply->productionResource,
2265 static_cast<double>(purchased)}, step.tick);
2266 processed += purchased;
2267 }
2268
2269 auto ammunition = [](Unit& unit) -> std::pair<int, int> {
2270 auto* weaponEntity = dynamic_cast<weapon::WeaponEntity*>(unit.weapon()->link.resolve());
2271 if (weaponEntity == nullptr || weaponEntity->definition()->def == nullptr) return {0, 0};
2272 const auto& resource = weaponEntity->state()->resource;
2273 if (resource.kind != weapon::ResourceKind::Ammo || resource.infinite) return {0, 0};
2274 int carried = std::max(0, static_cast<int>(std::floor(resource.value)));
2275 int capacity = std::max(0, static_cast<int>(std::floor(resource.max)));
2276 if (auto* pool = weaponEntity->state()->ammoPool) {
2277 carried += std::max(0, pool->state()->count);
2278 capacity += pool->state()->max < 0 ? std::max(0, pool->state()->count) : pool->state()->max;
2279 } else if (resource.reserve >= 0) {
2280 carried += resource.reserve;
2281 capacity += std::max(resource.reserve, weaponEntity->definition()->def->reserveSize);
2282 }
2283 return {carried, capacity};
2284 };
2285 auto transfer = [&](Unit& recipient, int rounds) -> int {
2286 auto* weaponEntity = dynamic_cast<weapon::WeaponEntity*>(recipient.weapon()->link.resolve());
2287 if (weaponEntity == nullptr || rounds <= 0) return 0;
2288 auto& resource = weaponEntity->state()->resource;
2289 if (resource.kind != weapon::ResourceKind::Ammo || resource.infinite) return 0;
2290 int accepted = 0;
2291 const int magazineSpace = std::max(0, static_cast<int>(std::floor(resource.max - resource.value)));
2292 const int magazineRounds = std::min(rounds, magazineSpace);
2293 resource.value += static_cast<float>(magazineRounds);
2294 accepted += magazineRounds;
2295 rounds -= magazineRounds;
2296 if (magazineRounds > 0) weapon::WeaponSystem::cancelReload(*weaponEntity);
2297 if (rounds <= 0) return accepted;
2298 if (auto* pool = weaponEntity->state()->ammoPool) {
2299 const int space = pool->state()->max < 0 ? rounds : std::max(0, pool->state()->max - pool->state()->count);
2300 const int pooled = std::min(rounds, space);
2301 pool->state()->count += pooled;
2302 return accepted + pooled;
2303 }
2304 if (resource.reserve < 0) return accepted;
2305 const int reserveCapacity = std::max(resource.reserve, weaponEntity->definition()->def == nullptr
2306 ? resource.reserve
2307 : weaponEntity->definition()->def->reserveSize);
2308 const int reserved = std::min(rounds, std::max(0, reserveCapacity - resource.reserve));
2309 resource.reserve += reserved;
2310 return accepted + reserved;
2311 };
2312
2313 struct Recipient {
2314 Unit* unit = nullptr;
2315 int deficit = 0;
2316 float ratio = 1.0f;
2317 };
2318 std::vector<Recipient> recipients;
2319 auto recipientView = ecs::View<Unit, Unit::Identity, Unit::Weapon, Unit::Faction, Unit::Containment,
2321 for (auto it = recipientView.begin(); it != recipientView.end(); ++it) {
2322 auto [identity, weaponLink, faction, containment, durability, supply] = *it;
2323 (void)weaponLink; (void)faction; (void)supply;
2324 if (!durability->alive || containment->container.isBound()) continue;
2325 auto* unit = dynamic_cast<Unit*>(ecs::try_get(identity->self));
2326 if (unit == nullptr || &*unit->identity() != identity) continue;
2327 const auto [carried, capacity] = ammunition(*unit);
2328 if (capacity > carried) recipients.push_back({unit, capacity - carried, float(carried) / float(capacity)});
2329 }
2330 std::sort(recipients.begin(), recipients.end(), [](const Recipient& left, const Recipient& right) {
2331 const float leftScore = (1.0f - left.ratio) * left.unit->supply()->priority;
2332 const float rightScore = (1.0f - right.ratio) * right.unit->supply()->priority;
2333 if (leftScore != rightScore) return leftScore > rightScore;
2334 return left.unit->identity()->self.id < right.unit->identity()->self.id;
2335 });
2336
2337 std::vector<Unit*> suppliers;
2338 auto supplierView = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Orders, Unit::Faction, Unit::Supply,
2340 for (auto it = supplierView.begin(); it != supplierView.end(); ++it) {
2341 auto [identity, motion, orders, faction, supply, containment, durability] = *it;
2342 (void)motion; (void)orders; (void)faction; (void)supply; (void)containment; (void)durability;
2343 auto* supplier = dynamic_cast<Unit*>(ecs::try_get(identity->self));
2344 if (supplier != nullptr) suppliers.push_back(supplier);
2345 }
2346 std::sort(suppliers.begin(), suppliers.end(), [](Unit* left, Unit* right) {
2347 return left->identity()->subject.format() < right->identity()->subject.format();
2348 });
2349 std::map<std::string, float> recipientReservations;
2350 for (Unit* supplier : suppliers) {
2351 if (!supplier->durability()->alive || supplier->containment()->container.isBound()) continue;
2352 auto* target = dynamic_cast<Unit*>(ecs::try_get(supplier->supply()->assignedTarget));
2353 if (target != nullptr && supplier->supply()->reservedStock > 0.0f)
2354 recipientReservations[target->identity()->subject.format()] += supplier->supply()->reservedStock;
2355 }
2356 for (Unit* supplier : suppliers) {
2357 auto identity = supplier->identity();
2358 auto motion = supplier->motion();
2359 auto orders = supplier->orders();
2360 auto faction = supplier->faction();
2361 auto supply = supplier->supply();
2362 auto containment = supplier->containment();
2363 auto durability = supplier->durability();
2364 if (!durability->alive || containment->container.isBound() || supply->range <= 0.0f ||
2365 supply->transferRate <= 0.0f)
2366 continue;
2367 auto current = readCurrent(orders->values);
2368 if (!current) return Result<std::size_t>::failure(current.status());
2369 auto order = std::move(current).takeValue();
2370 if ((!order || (order->kind != OrderKind::Resupply && order->kind != OrderKind::SupplyRelay)) &&
2371 supply->assignedTarget.table != nullptr) {
2372 if (auto* previous = dynamic_cast<Unit*>(ecs::try_get(supply->assignedTarget))) {
2373 float& reserved = recipientReservations[previous->identity()->subject.format()];
2374 reserved = std::max(0.0f, reserved - supply->reservedStock);
2375 }
2376 supply->assignedTarget = {};
2377 supply->reservedStock = 0.0f;
2378 supply->transferProgress = 0.0f;
2379 supply->returning = false;
2380 supply->rendezvousActive = false;
2381 supply->rendezvousThreat = 0.0f;
2382 supply->rendezvousAvoidedThreat = false;
2383 }
2384 if (!order && supply->returning) {
2385 if (distanceSquared(motion->x, motion->y, supply->returnPoint.x, supply->returnPoint.y) <= 0.01f) {
2386 supply->returning = false;
2387 if (events)
2388 events({LifecycleEventKind::SupplyReturned, identity->subject, {}, {}, 0.0}, step.tick);
2389 }
2390 continue;
2391 }
2392 if (!order && supply->autoDispatch && supply->stock >= 1.0f) {
2393 for (const Recipient& candidate : recipients) {
2394 if (candidate.unit == supplier ||
2395 !FactionRelationSystem::isAllied(candidate.unit->faction()->link, faction->link))
2396 continue;
2397 const auto [liveCarried, liveCapacity] = ammunition(*candidate.unit);
2398 const float liveRatio = liveCapacity <= 0 ? 1.0f
2399 : static_cast<float>(liveCarried) / liveCapacity;
2400 if (liveRatio > supply->autoThreshold) continue;
2401 const std::string recipientKey = candidate.unit->identity()->subject.format();
2402 const float remainingDeficit = std::max(
2403 0.0f, static_cast<float>(std::max(0, liveCapacity - liveCarried)) -
2404 recipientReservations[recipientKey]);
2405 if (remainingDeficit < 1.0f) continue;
2406 CommandSpec command;
2407 command.kind = OrderKind::Resupply;
2408 command.target = {candidate.unit->motion()->x, candidate.unit->motion()->y};
2409 command.targetEntity = candidate.unit->identity()->self;
2410 auto queued = orders->values.enqueue(command);
2411 if (!queued) return Result<std::size_t>::failure(queued.status());
2412 std::move(queued).takeValue();
2413 supply->assignedTarget = candidate.unit->identity()->self;
2414 supply->returnPoint = {motion->x, motion->y};
2415 supply->reservedStock = std::min<float>(remainingDeficit, std::floor(supply->stock));
2416 recipientReservations[recipientKey] += supply->reservedStock;
2417 supply->returning = false;
2418 auto assigned = readCurrent(orders->values);
2419 if (!assigned) return Result<std::size_t>::failure(assigned.status());
2420 order = std::move(assigned).takeValue();
2421 if (events)
2422 events({LifecycleEventKind::SupplyDispatched, identity->subject,
2423 candidate.unit->identity()->subject, {}, supply->reservedStock}, step.tick);
2424 ++processed;
2425 break;
2426 }
2427 }
2428 if (!order && supply->autoDispatch && supply->stock >= 1.0f) {
2429 Unit* relayTarget = nullptr;
2430 float relayRatio = 1.0f;
2431 float relayDistance = std::numeric_limits<float>::max();
2432 for (Unit* candidate : suppliers) {
2433 if (candidate == supplier || !candidate->durability()->alive ||
2434 candidate->containment()->container.isBound() || !candidate->supply()->relayEnabled ||
2435 candidate->supply()->capacity <= 0.0f || candidate->supply()->capacity >= supply->capacity ||
2436 candidate->supply()->stock >= candidate->supply()->capacity ||
2437 !FactionRelationSystem::isAllied(candidate->faction()->link, faction->link))
2438 continue;
2439 const float ratio = candidate->supply()->stock / candidate->supply()->capacity;
2440 if (ratio > supply->autoThreshold) continue;
2441 const float distance = distanceSquared(motion->x, motion->y,
2442 candidate->motion()->x, candidate->motion()->y);
2443 if (relayTarget == nullptr || ratio < relayRatio ||
2444 (ratio == relayRatio && (distance < relayDistance ||
2445 (distance == relayDistance && candidate->identity()->subject.format() <
2446 relayTarget->identity()->subject.format())))) {
2447 relayTarget = candidate;
2448 relayRatio = ratio;
2449 relayDistance = distance;
2450 }
2451 }
2452 if (relayTarget != nullptr) {
2453 const std::string relayKey = relayTarget->identity()->subject.format();
2454 const float remaining = std::max(0.0f, relayTarget->supply()->capacity -
2455 relayTarget->supply()->stock - recipientReservations[relayKey]);
2456 if (remaining >= 1.0f) {
2457 CommandSpec command;
2458 command.kind = OrderKind::SupplyRelay;
2459 command.target = {relayTarget->motion()->x, relayTarget->motion()->y};
2460 command.targetEntity = relayTarget->identity()->self;
2461 auto queued = orders->values.enqueue(command);
2462 if (!queued) return Result<std::size_t>::failure(queued.status());
2463 std::move(queued).takeValue();
2464 supply->assignedTarget = relayTarget->identity()->self;
2465 supply->returnPoint = {motion->x, motion->y};
2466 supply->reservedStock = std::min<float>(remaining, std::floor(supply->stock));
2467 recipientReservations[relayKey] += supply->reservedStock;
2468 supply->returning = false;
2469 auto assigned = readCurrent(orders->values);
2470 if (!assigned) return Result<std::size_t>::failure(assigned.status());
2471 order = std::move(assigned).takeValue();
2472 if (events)
2473 events({LifecycleEventKind::SupplyRelayDispatched, identity->subject,
2474 relayTarget->identity()->subject, {}, supply->reservedStock}, step.tick);
2475 ++processed;
2476 }
2477 }
2478 }
2479 if (!order || (order->kind != OrderKind::Resupply && order->kind != OrderKind::SupplyRelay)) continue;
2480 auto* target = dynamic_cast<Unit*>(ecs::try_get(order->targetEntity));
2481 if (target == nullptr || !target->durability()->alive || target->containment()->container.isBound() ||
2482 !FactionRelationSystem::isAllied(target->faction()->link, faction->link)) {
2483 auto failed = orders->values.fail(order->id, "supply target is invalid or hostile");
2484 if (!failed) return Result<std::size_t>::failure(failed.status());
2485 if (target != nullptr) {
2486 float& reserved = recipientReservations[target->identity()->subject.format()];
2487 reserved = std::max(0.0f, reserved - supply->reservedStock);
2488 }
2489 supply->assignedTarget = {};
2490 supply->reservedStock = 0.0f;
2491 supply->rendezvousActive = false;
2492 supply->rendezvousThreat = 0.0f;
2493 supply->rendezvousAvoidedThreat = false;
2494 continue;
2495 }
2496 if (supply->assignedTarget.table == nullptr) {
2497 supply->assignedTarget = target->identity()->self;
2498 supply->returnPoint = {motion->x, motion->y};
2499 const auto [carried, capacity] = ammunition(*target);
2500 const float requested = order->kind == OrderKind::SupplyRelay
2501 ? std::max(0.0f, target->supply()->capacity - target->supply()->stock)
2502 : static_cast<float>(std::max(0, capacity - carried));
2503 supply->reservedStock = std::min(requested, std::floor(supply->stock));
2504 supply->returning = false;
2505 }
2506 if (order->kind == OrderKind::SupplyRelay &&
2507 (!target->supply()->relayEnabled || target->supply()->capacity >= supply->capacity)) {
2508 auto failed = orders->values.fail(order->id, "supply relay requires a smaller-capacity relay target");
2509 if (!failed) return Result<std::size_t>::failure(failed.status());
2510 supply->assignedTarget = {};
2511 supply->reservedStock = 0.0f;
2512 supply->rendezvousActive = false;
2513 supply->rendezvousThreat = 0.0f;
2514 supply->rendezvousAvoidedThreat = false;
2515 continue;
2516 }
2517 if (order->kind == OrderKind::SupplyRelay) {
2518 WorldPosition rendezvous{target->motion()->x, target->motion()->y};
2519 auto targetOrder = readCurrent(target->orders()->values);
2520 if (!targetOrder) return Result<std::size_t>::failure(targetOrder.status());
2521 const auto moving = std::move(targetOrder).takeValue();
2522 if (moving && isMovementOrder(moving->kind)) {
2523 WorldPosition goal = moving->target;
2524 if (target->navigation()->plannedOrderId == moving->id &&
2525 !target->navigation()->unreachable)
2526 goal = target->navigation()->plannedGoal;
2527 const float dx = goal.x - target->motion()->x;
2528 const float dy = goal.y - target->motion()->y;
2529 const float remaining = std::hypot(dx, dy);
2530 if (remaining > 1e-5f && target->motion()->speed > 0.0f) {
2531 const float travel = std::hypot(target->motion()->x - motion->x,
2532 target->motion()->y - motion->y) /
2533 std::max(0.1f, motion->speed);
2534 const float lead = std::min(remaining,
2535 target->motion()->speed * std::clamp(travel, 0.0f, 4.0f));
2536 rendezvous.x += dx / remaining * lead;
2537 rendezvous.y += dy / remaining * lead;
2538 }
2539 }
2540 supply->rendezvousPoint = rendezvous;
2541 supply->rendezvousActive = true;
2542 supply->rendezvousThreat = 0.0f;
2543 supply->rendezvousAvoidedThreat = false;
2544 if (pathfinder != nullptr) {
2545 auto safe = SupplyRendezvousSystem::select(*supplier, *target, rendezvous, *pathfinder, grid);
2546 if (safe) {
2547 const auto selection = std::move(safe).takeValue();
2548 supply->rendezvousPoint = selection.target;
2549 supply->rendezvousThreat = selection.threat;
2550 supply->rendezvousAvoidedThreat = selection.avoidedThreat;
2551 } else if (safe.code() != StatusCode::NotFound) {
2552 return Result<std::size_t>::failure(safe.status());
2553 } else {
2554 safe.ignore("supply relay falls back when no safe map candidate is reachable");
2555 }
2556 }
2557 } else {
2558 supply->rendezvousActive = false;
2559 supply->rendezvousThreat = 0.0f;
2560 supply->rendezvousAvoidedThreat = false;
2561 }
2562 const float distance = distanceSquared(motion->x, motion->y, target->motion()->x, target->motion()->y);
2563 if (distance > supply->range * supply->range) continue;
2564 supply->transferProgress += supply->transferRate * static_cast<float>(step.delta.seconds());
2565 int ready = std::min({static_cast<int>(std::floor(supply->transferProgress)),
2566 static_cast<int>(std::floor(supply->stock)),
2567 static_cast<int>(std::floor(supply->reservedStock))});
2568 int accepted = 0;
2569 if (order->kind == OrderKind::SupplyRelay && target->supply()->relayEnabled) {
2570 ready = std::min(ready, static_cast<int>(std::floor(
2571 std::max(0.0f, target->supply()->capacity - target->supply()->stock))));
2572 target->supply()->stock += static_cast<float>(ready);
2573 accepted = ready;
2574 } else {
2575 accepted = transfer(*target, ready);
2576 }
2577 supply->stock -= static_cast<float>(accepted);
2578 supply->reservedStock -= static_cast<float>(accepted);
2579 supply->transferProgress -= static_cast<float>(accepted);
2580 if (accepted > 0) {
2581 float& reserved = recipientReservations[target->identity()->subject.format()];
2582 reserved = std::max(0.0f, reserved - static_cast<float>(accepted));
2583 if (events)
2584 events({order->kind == OrderKind::SupplyRelay
2585 ? LifecycleEventKind::SupplyRelayTransferred
2586 : LifecycleEventKind::AmmoResupplied,
2587 identity->subject, target->identity()->subject, {}, static_cast<double>(accepted)},
2588 step.tick);
2589 }
2590 if (accepted > 0) ++processed;
2591 const auto [carried, capacity] = ammunition(*target);
2592 if ((order->kind == OrderKind::Resupply && carried >= capacity) ||
2593 (order->kind == OrderKind::SupplyRelay && target->supply()->stock >= target->supply()->capacity) ||
2594 supply->stock < 1.0f) {
2595 auto completed = orders->values.complete(order->id);
2596 if (!completed) return Result<std::size_t>::failure(completed.status());
2597 float& reserved = recipientReservations[target->identity()->subject.format()];
2598 reserved = std::max(0.0f, reserved - supply->reservedStock);
2599 supply->assignedTarget = {};
2600 supply->reservedStock = 0.0f;
2601 supply->rendezvousActive = false;
2602 supply->rendezvousThreat = 0.0f;
2603 supply->rendezvousAvoidedThreat = false;
2604 supply->returning = true;
2605 if (events)
2606 events({LifecycleEventKind::SupplyReturning, identity->subject,
2607 target->identity()->subject, {}, 0.0}, step.tick);
2608 CommandSpec returnCommand;
2609 returnCommand.kind = OrderKind::Move;
2610 returnCommand.target = supply->returnPoint;
2611 auto queued = orders->values.enqueue(returnCommand);
2612 if (!queued) return Result<std::size_t>::failure(queued.status());
2613 std::move(queued).takeValue();
2614 }
2615 }
2616
2619 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
2620 auto [identity, placement, faction, construction, integrity, supply] = *it;
2621 if (!integrity->alive || construction->progress < 1.0f || supply->stock < 1.0f || supply->range <= 0.0f ||
2622 supply->transferRate <= 0.0f) continue;
2623 for (Recipient& candidate : recipients) {
2624 if (!FactionRelationSystem::isAllied(candidate.unit->faction()->link, faction->link) ||
2625 candidate.deficit <= 0 ||
2626 distanceSquared(placement->worldX, placement->worldY, candidate.unit->motion()->x,
2627 candidate.unit->motion()->y) > supply->range * supply->range)
2628 continue;
2629 supply->transferProgress += supply->transferRate * static_cast<float>(step.delta.seconds());
2630 const int ready = std::min({static_cast<int>(std::floor(supply->transferProgress)),
2631 static_cast<int>(std::floor(supply->stock)), candidate.deficit});
2632 const int accepted = transfer(*candidate.unit, ready);
2633 supply->stock -= static_cast<float>(accepted);
2634 supply->transferProgress -= static_cast<float>(accepted);
2635 candidate.deficit -= accepted;
2636 if (accepted > 0) {
2637 if (events)
2638 events({LifecycleEventKind::AmmoResupplied, identity->subject,
2639 candidate.unit->identity()->subject, {}, static_cast<double>(accepted)}, step.tick);
2640 ++processed;
2641 }
2642 break;
2643 }
2644 }
2645 return Result<std::size_t>::success(processed,
2647}
2648
2649Result<std::size_t> SupplyConvoySystem::step() {
2650 struct Member {
2651 Unit* unit = nullptr;
2652 WorldPosition destination;
2653 };
2654 std::map<std::string, std::vector<Member>> groups;
2657 for (auto it = view.begin(); it != view.end(); ++it) {
2658 auto [identity, motion, orders, faction, supply, containment, durability] = *it;
2659 supply->convoyLeader = {};
2660 supply->convoyIndex = 0;
2661 supply->convoyWaiting = false;
2662 if (!durability->alive || containment->container.isBound() || supply->returning) continue;
2663 auto current = readCurrent(orders->values);
2664 if (!current) return Result<std::size_t>::failure(current.status());
2665 const auto order = std::move(current).takeValue();
2666 if (!order || (order->kind != OrderKind::Resupply && order->kind != OrderKind::SupplyRelay)) continue;
2667 auto* target = dynamic_cast<Unit*>(ecs::try_get(order->targetEntity));
2668 if (target == nullptr) continue;
2669 WorldPosition destination{target->motion()->x, target->motion()->y};
2670 if (order->kind == OrderKind::SupplyRelay && supply->rendezvousActive)
2671 destination = supply->rendezvousPoint;
2672 const std::string key = factionKey(faction->link) + "\n" +
2673 std::to_string(static_cast<int>(order->kind)) + "\n" +
2674 target->identity()->subject.format();
2675 groups[key].push_back({dynamic_cast<Unit*>(ecs::try_get(identity->self)), destination});
2676 (void)motion;
2677 }
2678 std::size_t processed = 0;
2679 for (auto& [key, members] : groups) {
2680 (void)key;
2681 members.erase(std::remove_if(members.begin(), members.end(),
2682 [](const Member& member) { return member.unit == nullptr; }), members.end());
2683 if (members.empty()) continue;
2684 std::sort(members.begin(), members.end(), [](const Member& left, const Member& right) {
2685 const float leftDistance = distanceSquared(left.unit->motion()->x, left.unit->motion()->y,
2686 left.destination.x, left.destination.y);
2687 const float rightDistance = distanceSquared(right.unit->motion()->x, right.unit->motion()->y,
2688 right.destination.x, right.destination.y);
2689 if (leftDistance != rightDistance) return leftDistance < rightDistance;
2690 return left.unit->identity()->subject.format() < right.unit->identity()->subject.format();
2691 });
2692 Unit* leader = members.front().unit;
2693 for (std::size_t index = 0; index < members.size(); ++index) {
2694 auto supply = members[index].unit->supply();
2695 supply->convoyLeader = leader->identity()->self;
2696 supply->convoyIndex = index;
2697 ++processed;
2698 }
2699 if (members.size() < 2) continue;
2700 for (std::size_t index = 1; index < members.size(); ++index) {
2701 Unit* follower = members[index].unit;
2702 const float spacing = leader->crowd()->radius + follower->crowd()->radius + 1.0f;
2703 const float allowed = spacing * 2.5f * static_cast<float>(index);
2704 if (distanceSquared(leader->motion()->x, leader->motion()->y,
2705 follower->motion()->x, follower->motion()->y) > allowed * allowed) {
2706 leader->supply()->convoyWaiting = true;
2707 break;
2708 }
2709 }
2710 }
2711 return Result<std::size_t>::success(processed,
2713}
2714
2716 if (step.delta.nanoseconds() < 0)
2718 DiagnosticCode::InvalidArgument, "RTS morale step delta must be non-negative", "step.delta"));
2719 std::size_t processed = 0;
2722 for (auto it = units.begin(); it != units.end(); ++it) {
2723 auto [identity, motion, faction, morale, containment, durability] = *it;
2724 if (!durability->alive || containment->container.isBound() || morale->capacity <= 0.0f) continue;
2725 if (!std::isfinite(morale->suppression) || !std::isfinite(morale->capacity) || morale->capacity < 0.0f ||
2726 !std::isfinite(morale->recoveryRate) || morale->recoveryRate < 0.0f)
2728 Diagnostic::error(DiagnosticCode::InvalidArgument, "RTS morale values must be finite", "unit.morale"));
2729 float recoveryBonus = 0.0f;
2730 auto sources = ecs::View<Unit, Unit::Motion, Unit::Faction, Unit::Morale, Unit::Containment,
2732 for (auto sourceIt = sources.begin(); sourceIt != sources.end(); ++sourceIt) {
2733 auto [sourceMotion, sourceFaction, aura, sourceContainment, sourceDurability] = *sourceIt;
2734 if (!sourceDurability->alive || sourceContainment->container.isBound() || aura->auraRange <= 0.0f ||
2735 !FactionRelationSystem::isAllied(sourceFaction->link, faction->link))
2736 continue;
2737 if (distanceSquared(motion->x, motion->y, sourceMotion->x, sourceMotion->y) <=
2738 aura->auraRange * aura->auraRange)
2739 recoveryBonus = std::max(recoveryBonus, aura->auraRecoveryBonus);
2740 }
2741 const float before = morale->suppression;
2742 morale->suppression = std::max(0.0f, morale->suppression -
2743 (morale->recoveryRate + recoveryBonus) * static_cast<float>(step.delta.seconds()));
2744 if (morale->active && morale->suppression < morale->capacity * 0.5f) {
2745 morale->active = false;
2746 morale->retreating = false;
2747 if (events)
2748 events({LifecycleEventKind::SuppressionRecovered, identity->subject, {}, {},
2749 morale->suppression}, step.tick);
2750 }
2751 if (morale->suppression != before) ++processed;
2752 (void)identity;
2753 }
2754 return Result<std::size_t>::success(processed,
2756}
2757
2759 if (step.delta.nanoseconds() < 0)
2761 DiagnosticCode::InvalidArgument, "RTS shield step delta must be non-negative", "step.delta"));
2762 const float dt = static_cast<float>(step.delta.seconds());
2763 std::size_t processed = 0;
2764 auto advance = [&](auto* shield, bool alive, SubjectRef subject) -> Result<void> {
2765 if (!std::isfinite(shield->value) || !std::isfinite(shield->capacity) ||
2766 !std::isfinite(shield->regenRate) || !std::isfinite(shield->regenDelay) ||
2767 !std::isfinite(shield->cooldown) || shield->capacity < 0.0f || shield->value < 0.0f ||
2768 shield->value > shield->capacity || shield->regenRate < 0.0f || shield->regenDelay < 0.0f ||
2769 shield->cooldown < 0.0f)
2770 return Result<void>::failure(
2772 "RTS shield values must be finite and within configured ranges", "shield"));
2773 if (!alive || shield->capacity == 0.0f) return Result<void>::success();
2774 const float beforeValue = shield->value;
2775 const float beforeCooldown = shield->cooldown;
2776 shield->cooldown = std::max(0.0f, shield->cooldown - dt);
2777 if (shield->cooldown == 0.0f && shield->value < shield->capacity)
2778 shield->value = std::min(shield->capacity, shield->value + shield->regenRate * dt);
2779 if (beforeValue < shield->capacity && shield->value >= shield->capacity && events)
2780 events({LifecycleEventKind::ShieldRecharged, subject, {}, {}, shield->value}, step.tick);
2781 if (shield->value != beforeValue || shield->cooldown != beforeCooldown) ++processed;
2782 return Result<void>::success();
2783 };
2784 auto units = ecs::View<Unit, Unit::Identity, Unit::Shield, Unit::Durability>();
2785 for (auto it = units.begin(); it != units.end(); ++it) {
2786 auto [identity, shield, durability] = *it;
2787 auto result = advance(shield, durability->alive && durability->state.health > 0.0,
2788 identity->subject);
2789 if (!result) return Result<std::size_t>::failure(result.status());
2790 }
2791 auto buildings = ecs::View<Building, Building::Identity, Building::Shield, Building::Integrity>();
2792 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
2793 auto [identity, shield, integrity] = *it;
2794 auto result = advance(shield, integrity->alive && integrity->state.health > 0.0,
2795 identity->subject);
2796 if (!result) return Result<std::size_t>::failure(result.status());
2797 }
2798 return Result<std::size_t>::success(processed,
2800}
2801
2802Result<std::size_t> CommandNetworkSystem::step() {
2803 struct Jammer { ecs::Entity* faction = nullptr; WorldPosition position; float range = 0.0f; };
2804 std::vector<Jammer> jammers;
2805 auto unitJammers = ecs::View<Unit, Unit::Motion, Unit::Faction, Unit::Command, Unit::Durability,
2807 for (auto it = unitJammers.begin(); it != unitJammers.end(); ++it) {
2808 auto [motion, faction, command, durability, containment] = *it;
2809 if (durability->alive && durability->state.health > 0.0 && !containment->container.isBound() &&
2810 command->jammingRange > 0.0f)
2811 jammers.push_back({faction->link.resolve(), {motion->x, motion->y}, command->jammingRange});
2812 }
2813 auto buildingJammers = ecs::View<Building, Building::Placement, Building::Faction, Building::Command,
2815 for (auto it = buildingJammers.begin(); it != buildingJammers.end(); ++it) {
2816 auto [placement, faction, command, integrity, construction, infrastructure] = *it;
2817 if (integrity->alive && integrity->state.health > 0.0 && construction->progress >= 1.0f &&
2818 infrastructure->powered && command->jammingRange > 0.0f)
2819 jammers.push_back({faction->link.resolve(), {placement->worldX, placement->worldY},
2820 command->jammingRange});
2821 }
2822 auto isJammed = [&](ecs::Entity* faction, WorldPosition position) {
2823 return std::any_of(jammers.begin(), jammers.end(), [&](const Jammer& jammer) {
2824 return jammer.faction != nullptr && faction != nullptr &&
2825 !FactionRelationSystem::isAllied(dynamic_cast<Faction*>(jammer.faction),
2826 dynamic_cast<Faction*>(faction)) &&
2827 distanceSquared(position.x, position.y, jammer.position.x, jammer.position.y) <=
2828 jammer.range * jammer.range;
2829 });
2830 };
2831
2832 struct Source {
2833 ecs::EntityHandle handle{};
2834 ecs::Entity* faction = nullptr;
2835 WorldPosition position;
2836 float range = 0.0f;
2837 int capacity = 0;
2838 int* load = nullptr;
2839 std::string stableId;
2840 };
2841 std::vector<Source> active;
2842 std::vector<Unit*> relays;
2843 std::vector<Unit*> recipients;
2844 auto units = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Faction, Unit::Command, Unit::Durability,
2845 Unit::Containment>();
2846 for (auto it = units.begin(); it != units.end(); ++it) {
2847 auto [identity, motion, faction, command, durability, containment] = *it;
2848 auto* unit = dynamic_cast<Unit*>(ecs::try_get(identity->self));
2849 if (unit == nullptr || &*unit->identity() != identity) continue;
2850 if (!std::isfinite(command->range) || command->range < 0.0f || command->capacity < 0 ||
2851 command->cost <= 0 || !std::isfinite(command->jammingRange) || command->jammingRange < 0.0f ||
2852 !std::isfinite(command->outOfCommandSpeedFactor) || command->outOfCommandSpeedFactor < 0.0f ||
2853 command->outOfCommandSpeedFactor > 1.0f || !std::isfinite(command->outOfCommandDamageFactor) ||
2854 command->outOfCommandDamageFactor < 0.0f || command->outOfCommandDamageFactor > 1.0f)
2856 DiagnosticCode::InvalidArgument, "RTS unit command policy is invalid", "unit.command"));
2857 command->load = 0;
2858 command->source = {};
2859 command->uplink = {};
2860 command->relayActive = false;
2861 const bool live = durability->alive && durability->state.health > 0.0 &&
2862 !containment->container.isBound();
2863 command->jammed = live && isJammed(faction->link.resolve(), {motion->x, motion->y});
2864 command->inCommand = !command->requiresCommand;
2865 if (!live || command->jammed) continue;
2866 if (command->range > 0.0f) {
2867 if (command->relayRequiresUplink) relays.push_back(unit);
2868 else {
2869 command->relayActive = true;
2870 active.push_back({identity->self, faction->link.resolve(), {motion->x, motion->y}, command->range,
2871 command->capacity, &command->load, identity->subject.format()});
2872 }
2873 }
2874 if (command->requiresCommand && !(command->range > 0.0f && command->relayRequiresUplink))
2875 recipients.push_back(unit);
2876 }
2877 auto buildings = ecs::View<Building, Building::Identity, Building::Placement, Building::Faction,
2878 Building::Command, Building::Integrity, Building::Construction,
2879 Building::Infrastructure>();
2880 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
2881 auto [identity, placement, faction, command, integrity, construction, infrastructure] = *it;
2882 command->load = 0;
2883 command->active = false;
2884 if (!std::isfinite(command->range) || command->range < 0.0f || command->capacity < 0 ||
2885 !std::isfinite(command->jammingRange) || command->jammingRange < 0.0f)
2887 DiagnosticCode::InvalidArgument, "RTS building command policy is invalid", "building.command"));
2888 const bool live = integrity->alive && integrity->state.health > 0.0 && construction->progress >= 1.0f &&
2889 infrastructure->powered;
2890 command->jammed = live && isJammed(faction->link.resolve(), {placement->worldX, placement->worldY});
2891 if (!live || command->jammed || command->range <= 0.0f) continue;
2892 command->active = true;
2893 active.push_back({identity->self, faction->link.resolve(), {placement->worldX, placement->worldY},
2894 command->range, command->capacity, &command->load, identity->subject.format()});
2895 }
2896
2897 auto orderUnits = [](Unit* left, Unit* right) {
2898 if (left->command()->priority != right->command()->priority)
2899 return left->command()->priority > right->command()->priority;
2900 return left->identity()->subject.format() < right->identity()->subject.format();
2901 };
2902 std::sort(relays.begin(), relays.end(), orderUnits);
2903 std::sort(recipients.begin(), recipients.end(), orderUnits);
2904 auto cover = [&](Unit& unit, bool reserve) -> Source* {
2905 Source* best = nullptr;
2906 float bestDistance = std::numeric_limits<float>::max();
2907 for (auto& source : active) {
2908 if (isSameHandle(source.handle, unit.identity()->self) ||
2909 !FactionRelationSystem::isAllied(dynamic_cast<Faction*>(source.faction),
2910 dynamic_cast<Faction*>(unit.faction()->link.resolve())) ||
2911 (source.capacity > 0 && *source.load + unit.command()->cost > source.capacity))
2912 continue;
2913 const float candidate = distanceSquared(source.position.x, source.position.y,
2914 unit.motion()->x, unit.motion()->y);
2915 if (candidate > source.range * source.range) continue;
2916 if (best == nullptr || candidate < bestDistance ||
2917 (candidate == bestDistance && source.stableId < best->stableId)) {
2918 best = &source;
2919 bestDistance = candidate;
2920 }
2921 }
2922 if (best != nullptr && reserve) *best->load += unit.command()->cost;
2923 return best;
2924 };
2925 bool changed = true;
2926 while (changed) {
2927 changed = false;
2928 for (Unit* relay : relays) {
2929 if (relay->command()->relayActive) continue;
2930 Source* uplink = cover(*relay, true);
2931 if (uplink == nullptr) continue;
2932 relay->command()->uplink = uplink->handle;
2933 relay->command()->source = uplink->handle;
2934 relay->command()->inCommand = true;
2935 relay->command()->relayActive = true;
2936 active.push_back({relay->identity()->self, relay->faction()->link.resolve(),
2937 {relay->motion()->x, relay->motion()->y}, relay->command()->range,
2938 relay->command()->capacity, &relay->command()->load,
2939 relay->identity()->subject.format()});
2940 changed = true;
2941 }
2942 }
2943 for (Unit* unit : recipients) {
2944 Source* source = cover(*unit, true);
2945 if (source == nullptr) continue;
2946 unit->command()->source = source->handle;
2947 unit->command()->inCommand = true;
2948 }
2949 return Result<std::size_t>::success(active.size() + recipients.size(), Status::success(StatusCode::Applied));
2950}
2951
2952namespace {
2953
2954Result<void> settleAbility(Unit& caster, const AbilitySpec& spec, ecs::EntityHandle targetHandle,
2955 WorldPosition point, combat::DamageRuntime& damage,
2956 const DamageEventSink& damageEvents, SimulationTick tick,
2957 const LifecycleEventSink& events) {
2958 auto affect = [&](ecs::Entity* entity) -> Result<void> {
2959 if (entity == nullptr)
2960 return Result<void>::failure(
2961 Diagnostic::error(DiagnosticCode::StaleHandle, "ability target is stale", "target"));
2962 combat::CombatState* state = nullptr;
2963 bool* alive = nullptr;
2964 FactionLink* faction = nullptr;
2965 RTSEffectComponent* effects = nullptr;
2966 float* shield = nullptr;
2967 float* shieldCooldown = nullptr;
2968 float shieldDelay = 0.0f;
2970 if (auto* unit = dynamic_cast<Unit*>(entity)) {
2971 state = &unit->durability()->state;
2972 alive = &unit->durability()->alive;
2973 faction = &unit->faction()->link;
2974 effects = &unit->effects()->values;
2975 shield = &unit->shield()->value;
2976 shieldCooldown = &unit->shield()->cooldown;
2977 shieldDelay = unit->shield()->regenDelay;
2978 subject = unit->identity()->subject;
2979 } else if (auto* building = dynamic_cast<Building*>(entity)) {
2980 state = &building->integrity()->state;
2981 alive = &building->integrity()->alive;
2982 faction = &building->faction()->link;
2983 effects = &building->effects()->values;
2984 shield = &building->shield()->value;
2985 shieldCooldown = &building->shield()->cooldown;
2986 shieldDelay = building->shield()->regenDelay;
2987 subject = building->identity()->subject;
2988 }
2989 if (state == nullptr || alive == nullptr || !*alive || !subject.isValid())
2991 DiagnosticCode::InvalidArgument, "ability target is not a live RTS combat subject", "target"));
2992 const bool allied = faction != nullptr && FactionRelationSystem::isAllied(*faction, caster.faction()->link);
2993 if ((spec.target == AbilityTarget::Enemy && allied) ||
2994 (spec.target == AbilityTarget::Ally && !allied) ||
2995 (spec.target == AbilityTarget::Self && entity != &caster))
2996 return Result<void>::failure(
2997 Diagnostic::error(DiagnosticCode::Conflict, "ability target relationship is invalid", "target"));
2998 if (spec.damage > 0.0f) {
2999 combat::DamageRequest request;
3000 request.source = caster.identity()->subject;
3001 request.target = subject;
3002 request.damageType = spec.damageType;
3003 request.healthDamage = spec.damage;
3004 request.incomingDamageMultiplier = effects->multiplier("incomingDamageMultiplier");
3005 request.availableShield = shield != nullptr ? *shield : 0.0;
3006 auto outcome = damage.apply(*state, request);
3007 if (!outcome) return Result<void>::failure(outcome.status());
3008 if (shield != nullptr && outcome.value().absorbedShieldDamage > 0.0) {
3009 *shield -= static_cast<float>(outcome.value().absorbedShieldDamage);
3010 if (shieldCooldown != nullptr) *shieldCooldown = shieldDelay;
3011 }
3012 if (damageEvents) damageEvents(request, outcome.value(), tick, DamageChannel::Ability);
3013 if (outcome.value().reaction == combat::HitReaction::Death) {
3014 if (faction != nullptr && hostileTo(caster, *faction)) {
3015 auto awarded = VeterancySystem::award(caster,
3016 static_cast<float>(std::max(1.0, state->maxHealth)));
3017 if (!awarded) return Result<void>::failure(awarded.status());
3018 std::move(awarded).takeValue();
3019 }
3020 *alive = false;
3021 }
3022 }
3023 if (spec.healing > 0.0f) {
3024 auto restored = damage.heal(*state, caster.identity()->subject, spec.healing);
3025 if (!restored) return Result<void>::failure(restored.status());
3026 }
3027 if (spec.appliesEffect && effects != nullptr) {
3028 auto applied = effects->apply(spec.effect);
3029 if (!applied) return Result<void>::failure(applied.status());
3030 std::move(applied).takeValue();
3031 if (events)
3032 events({LifecycleEventKind::StatusApplied, caster.identity()->subject, subject,
3033 spec.effect.id, spec.effect.duration}, tick);
3034 }
3036 };
3037
3038 if (spec.target == AbilityTarget::Self) return affect(&caster);
3039 if (spec.target != AbilityTarget::Point) return affect(ecs::try_get(targetHandle));
3040 const float radius = std::max(0.0f, spec.radius);
3041 std::vector<ecs::Entity*> targets;
3042 auto units = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Durability>();
3043 for (auto it = units.begin(); it != units.end(); ++it) {
3044 auto [identity, motion, durability] = *it;
3045 if (durability->alive && distanceSquared(point.x, point.y, motion->x, motion->y) <= radius * radius)
3046 targets.push_back(ecs::try_get(identity->self));
3047 }
3048 auto buildings = ecs::View<Building, Building::Identity, Building::Placement, Building::Integrity>();
3049 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
3050 auto [identity, placement, integrity] = *it;
3051 if (integrity->alive && distanceSquared(point.x, point.y, placement->worldX, placement->worldY) <=
3052 radius * radius)
3053 targets.push_back(ecs::try_get(identity->self));
3054 }
3055 for (ecs::Entity* entity : targets) {
3056 if (entity == nullptr || entity == &caster) continue;
3057 FactionLink* faction = dynamic_cast<Unit*>(entity) ? &dynamic_cast<Unit*>(entity)->faction()->link
3058 : &dynamic_cast<Building*>(entity)->faction()->link;
3059 if (FactionRelationSystem::isAllied(*faction, caster.faction()->link)) continue;
3060 auto result = affect(entity);
3061 if (!result) return result;
3062 }
3064}
3065
3066Result<void> validateAbility(Unit& caster, const AbilitySpec& spec, ecs::EntityHandle target,
3067 WorldPosition point) {
3068 if (spec.id.empty() || !std::isfinite(spec.range) || spec.range < 0.0f || !std::isfinite(spec.radius) ||
3069 spec.radius < 0.0f || !std::isfinite(spec.cooldown) || spec.cooldown < 0.0f || !std::isfinite(spec.damage) ||
3070 spec.damage < 0.0f || !std::isfinite(spec.healing) || spec.healing < 0.0f || !std::isfinite(spec.castTime) ||
3071 spec.castTime < 0.0f || !std::isfinite(spec.channelTickInterval) || spec.channelTickInterval < 0.0f ||
3072 spec.resourceCost < 0 || (spec.resourceCost > 0 && spec.resourceType.empty()) ||
3073 (spec.channelTickInterval > 0.0f && spec.castTime <= 0.0f) || !isFinitePosition(point))
3075 "RTS ability definition or target point is invalid", "ability"));
3076 if (spec.casterDefinition.isValid() && caster.definition()->id != spec.casterDefinition)
3077 return Result<void>::failure(
3078 Diagnostic::error(DiagnosticCode::Conflict, "unit definition cannot cast this ability", "caster"));
3079 WorldPosition destination = point;
3080 if (spec.target == AbilityTarget::Self) destination = {caster.motion()->x, caster.motion()->y};
3081 else if (spec.target != AbilityTarget::Point) {
3083 if (!position)
3084 return Result<void>::failure(
3085 Diagnostic::error(DiagnosticCode::StaleHandle, "ability target is stale", "target"));
3086 destination = *position;
3087 if (spec.target == AbilityTarget::Enemy &&
3088 !FactionIntelSystem::isTargetable(dynamic_cast<Faction*>(caster.faction()->link.resolve()),
3089 stableSubject(ecs::try_get(target))))
3091 DiagnosticCode::Conflict, "enemy ability target is not currently visible and detected", "target"));
3092 }
3093 if (distanceSquared(caster.motion()->x, caster.motion()->y, destination.x, destination.y) >
3094 spec.range * spec.range)
3095 return Result<void>::failure(
3096 Diagnostic::error(DiagnosticCode::Conflict, "ability target is outside cast range", "target"));
3097 return Result<void>::success();
3098}
3099
3100} // namespace
3101
3102Result<void> AbilitySystem::cast(Unit& caster, const AbilitySpec& spec, ecs::EntityHandle target,
3104 const AbilityResourceDebit& debit,
3105 const DamageEventSink& damageEvents, SimulationTick tick,
3106 const LifecycleEventSink& events) {
3107 auto valid = validateAbility(caster, spec, target, point);
3108 if (!valid) return valid;
3109 if (caster.abilities()->channel)
3110 return Result<void>::failure(
3111 Diagnostic::error(DiagnosticCode::Conflict, "unit is already casting an ability", "ability.channel"));
3112 auto found = std::find_if(caster.abilities()->cooldowns.begin(), caster.abilities()->cooldowns.end(),
3113 [&](const auto& cooldown) { return cooldown.id == spec.id; });
3114 if (found != caster.abilities()->cooldowns.end() && found->remaining > 0.0f)
3115 return Result<void>::failure(
3116 Diagnostic::error(DiagnosticCode::Conflict, "ability is on cooldown", "ability.cooldown"));
3117 if (spec.resourceCost > 0) {
3118 if (!debit)
3119 return Result<void>::failure(
3120 Diagnostic::error(DiagnosticCode::Unsupported, "ability resource debit provider is absent", {}));
3122 if (!cost) return Result<void>::failure(cost.status());
3123 auto paid = debit(caster, cost.value());
3124 if (!paid) return Result<void>::failure(paid.status());
3125 }
3126 if (found == caster.abilities()->cooldowns.end())
3127 caster.abilities()->cooldowns.push_back({spec.id, spec.cooldown});
3128 else found->remaining = spec.cooldown;
3129 if (spec.castTime == 0.0f) {
3130 auto settled = settleAbility(caster, spec, target, point, damage, damageEvents, tick, events);
3131 if (settled && events)
3132 events({LifecycleEventKind::AbilityCast, caster.identity()->subject,
3133 stableSubject(ecs::try_get(target)), spec.id, 1.0}, tick);
3134 return settled;
3135 }
3136 caster.abilities()->channel = Unit::Abilities::Channel{spec, target, point,
3137 caster.durability()->state.health, spec.castTime,
3138 spec.channelTickInterval > 0.0f ? spec.channelTickInterval : spec.castTime};
3139 if (events)
3140 events({LifecycleEventKind::AbilityChannelStarted, caster.identity()->subject,
3141 stableSubject(ecs::try_get(target)), spec.id, spec.castTime}, tick);
3143}
3144
3146 const DamageEventSink& damageEvents,
3147 const LifecycleEventSink& events) {
3148 if (step.delta.nanoseconds() < 0)
3150 Diagnostic::error(DiagnosticCode::InvalidArgument, "RTS ability delta must be non-negative", "step.delta"));
3151 const float dt = static_cast<float>(step.delta.seconds());
3152 std::size_t processed = 0;
3153 auto units = ecs::View<Unit, Unit::Identity, Unit::Abilities, Unit::Durability>();
3154 for (auto it = units.begin(); it != units.end(); ++it) {
3155 auto [identity, abilities, durability] = *it;
3156 auto* unit = dynamic_cast<Unit*>(ecs::try_get(identity->self));
3157 if (unit == nullptr || &*unit->identity() != identity) continue;
3158 for (auto& cooldown : abilities->cooldowns) cooldown.remaining = std::max(0.0f, cooldown.remaining - dt);
3159 if (!abilities->channel) continue;
3160 auto& channel = *abilities->channel;
3161 if (!durability->alive ||
3162 (channel.spec.interruptOnDamage && durability->state.health < channel.startingHealth)) {
3163 if (events)
3164 events({LifecycleEventKind::AbilityInterrupted, identity->subject,
3165 stableSubject(ecs::try_get(channel.target)), channel.spec.id,
3166 channel.remaining}, step.tick);
3167 abilities->channel.reset();
3168 ++processed;
3169 continue;
3170 }
3171 const float elapsed = std::min(dt, channel.remaining);
3172 channel.remaining = std::max(0.0f, channel.remaining - dt);
3173 channel.tickRemaining -= elapsed;
3174 if (channel.spec.channelTickInterval > 0.0f) {
3175 while (channel.tickRemaining <= 1e-6f) {
3176 auto settled = settleAbility(*unit, channel.spec, channel.target, channel.point,
3177 damage, damageEvents, step.tick, events);
3178 if (!settled) return Result<std::size_t>::failure(settled.status());
3179 if (events)
3180 events({LifecycleEventKind::AbilityChannelTick, identity->subject,
3181 stableSubject(ecs::try_get(channel.target)), channel.spec.id,
3182 channel.remaining}, step.tick);
3183 channel.tickRemaining += channel.spec.channelTickInterval;
3184 ++processed;
3185 }
3186 }
3187 if (channel.remaining == 0.0f) {
3188 const AbilitySpec completedSpec = channel.spec;
3189 const SubjectRef completedTarget = stableSubject(ecs::try_get(channel.target));
3190 if (channel.spec.channelTickInterval == 0.0f) {
3191 auto settled = settleAbility(*unit, channel.spec, channel.target, channel.point,
3192 damage, damageEvents, step.tick, events);
3193 if (!settled) return Result<std::size_t>::failure(settled.status());
3194 if (events)
3195 events({LifecycleEventKind::AbilityCast, identity->subject, completedTarget,
3196 completedSpec.id, 1.0}, step.tick);
3197 }
3198 abilities->channel.reset();
3199 if (events)
3200 events({LifecycleEventKind::AbilityChannelCompleted, identity->subject,
3201 completedTarget, completedSpec.id, 0.0}, step.tick);
3202 ++processed;
3203 }
3204 }
3205 return Result<std::size_t>::success(processed,
3207}
3208
3209Result<std::size_t> ArtillerySystem::step(const SimulationStep& step) {
3210 if (step.delta.nanoseconds() < 0)
3212 DiagnosticCode::InvalidArgument, "RTS artillery step delta must be non-negative", "step.delta"));
3213 std::size_t processed = 0;
3215 Unit::Orders>();
3216 for (auto it = units.begin(); it != units.end(); ++it) {
3217 auto [motion, artillery, containment, durability, orders] = *it;
3218 if (!durability->alive || containment->container.isBound()) continue;
3219 if (!std::isfinite(artillery->deployTime) || artillery->deployTime < 0.0f ||
3220 !std::isfinite(artillery->deployRemaining) || artillery->deployRemaining < 0.0f)
3223 "RTS artillery deployment values must be finite and non-negative", "unit.artillery"));
3224 if (!artillery->positionInitialized) {
3225 artillery->previousX = motion->x;
3226 artillery->previousY = motion->y;
3227 artillery->positionInitialized = true;
3228 artillery->deployRemaining = artillery->deployTime;
3229 ++processed;
3230 continue;
3231 }
3232 const bool moved = distanceSquared(motion->x, motion->y, artillery->previousX, artillery->previousY) > 1e-8f;
3233 artillery->previousX = motion->x;
3234 artillery->previousY = motion->y;
3235 if (artillery->relocating) {
3236 auto current = readCurrent(orders->values);
3237 if (!current) return Result<std::size_t>::failure(current.status());
3238 const auto record = std::move(current).takeValue();
3239 if (!record || record->kind != OrderKind::Move) artillery->relocating = false;
3240 }
3241 if (moved) {
3242 artillery->deployRemaining = artillery->deployTime;
3243 ++processed;
3244 } else if (artillery->deployRemaining > 0.0f) {
3245 artillery->deployRemaining = std::max(0.0f, artillery->deployRemaining -
3246 static_cast<float>(step.delta.seconds()));
3247 ++processed;
3248 }
3249 }
3250 return Result<std::size_t>::success(processed,
3252}
3253
3254Result<ArtilleryRelocationSelection> ArtilleryRelocationSystem::select(
3255 Unit& unit, WorldPosition target, float distance, float weaponRange,
3256 map::Pathfinder& pathfinder, const NavigationGrid& grid) {
3257 if (!isFinitePosition(target) || !std::isfinite(distance) || distance <= 0.0f || !std::isfinite(weaponRange) ||
3258 weaponRange < 0.0f || !std::isfinite(grid.cellSize) || grid.cellSize <= 0.0f || !std::isfinite(grid.originX) ||
3259 !std::isfinite(grid.originY))
3262 "RTS artillery relocation requires finite target, range and navigation grid values",
3263 "artillery.relocation"));
3264
3265 auto* ownFaction = dynamic_cast<Faction*>(unit.faction()->link.resolve());
3266 if (ownFaction == nullptr)
3268 Diagnostic::error(DiagnosticCode::StaleHandle, "RTS artillery faction link is stale", "unit.faction"));
3269 const auto visibleToFaction = [&](SubjectRef subject) {
3270 if (ownFaction->intel()->contacts.empty()) return true;
3271 const auto found = std::find_if(ownFaction->intel()->contacts.begin(), ownFaction->intel()->contacts.end(),
3272 [&](const auto& contact) { return contact.subject == subject; });
3273 return found != ownFaction->intel()->contacts.end() && found->visible && found->detected;
3274 };
3275 const auto hostileThreat = [&](WorldPosition candidate) {
3276 float threat = 0.0f;
3279 for (auto it = units.begin(); it != units.end(); ++it) {
3280 auto [identity, motion, faction, weaponLink, durability, containment] = *it;
3281 if (!durability->alive || containment->container.isBound() ||
3282 FactionRelationSystem::isAllied(dynamic_cast<Faction*>(faction->link.resolve()), ownFaction) ||
3283 !visibleToFaction(identity->subject))
3284 continue;
3285 auto* weaponEntity = dynamic_cast<weapon::WeaponEntity*>(weaponLink->link.resolve());
3286 const auto* definition = weaponEntity == nullptr ? nullptr : weaponEntity->definition()->def;
3287 if (definition == nullptr || definition->range <= 0.0f) continue;
3288 const float d2 = distanceSquared(candidate.x, candidate.y, motion->x, motion->y);
3289 if (d2 <= definition->range * definition->range)
3290 threat += std::max(0.0f, definition->damage);
3291 }
3294 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
3295 auto [identity, placement, faction, weaponLink, integrity, construction] = *it;
3296 if (!integrity->alive || construction->progress < 1.0f ||
3297 FactionRelationSystem::isAllied(dynamic_cast<Faction*>(faction->link.resolve()), ownFaction) ||
3298 !visibleToFaction(identity->subject))
3299 continue;
3300 auto* weaponEntity = dynamic_cast<weapon::WeaponEntity*>(weaponLink->link.resolve());
3301 const auto* definition = weaponEntity == nullptr ? nullptr : weaponEntity->definition()->def;
3302 if (definition == nullptr || definition->range <= 0.0f) continue;
3303 const float d2 = distanceSquared(candidate.x, candidate.y,
3304 placement->worldX, placement->worldY);
3305 if (d2 <= definition->range * definition->range)
3306 threat += std::max(0.0f, definition->damage);
3307 }
3308 return threat;
3309 };
3310 const float separation = std::max(unit.crowd()->radius * 2.0f, distance * 0.75f);
3311 const auto conflictCount = [&](WorldPosition candidate) {
3312 int conflicts = 0;
3313 auto allies = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Faction, Unit::Weapon,
3315 for (auto it = allies.begin(); it != allies.end(); ++it) {
3316 auto [identity, motion, faction, weaponLink, artillery, durability, containment] = *it;
3317 if (isSameHandle(identity->self, unit.identity()->self) || !durability->alive ||
3318 containment->container.isBound() ||
3319 !FactionRelationSystem::isAllied(dynamic_cast<Faction*>(faction->link.resolve()), ownFaction))
3320 continue;
3321 auto* weaponEntity = dynamic_cast<weapon::WeaponEntity*>(weaponLink->link.resolve());
3322 const auto* definition = weaponEntity == nullptr ? nullptr : weaponEntity->definition()->def;
3323 if (definition == nullptr ||
3324 (definition->projectile.gravity <= 0.0f && artillery->deployTime <= 0.0f)) continue;
3325 const WorldPosition position = artillery->relocating ? artillery->relocationTarget
3326 : WorldPosition{motion->x, motion->y};
3327 if (distanceSquared(candidate.x, candidate.y, position.x, position.y) < separation * separation)
3328 ++conflicts;
3329 }
3330 return conflicts;
3331 };
3332
3333 const float dx = target.x - unit.motion()->x;
3334 const float dy = target.y - unit.motion()->y;
3335 const float length = std::hypot(dx, dy);
3336 const float forwardX = length > 1e-5f ? dx / length : 1.0f;
3337 const float forwardY = length > 1e-5f ? dy / length : 0.0f;
3338 const float sideX = -forwardY;
3339 const float sideY = forwardX;
3340 const float preferred = deterministicShotRandom(unit.identity()->subject, 0, 17) >= 0.5f ? 1.0f : -1.0f;
3341 const std::array<WorldPosition, 8> directions{{
3342 {sideX * preferred - forwardX * 0.25f, sideY * preferred - forwardY * 0.25f},
3343 {-sideX * preferred - forwardX * 0.25f, -sideY * preferred - forwardY * 0.25f},
3344 {-forwardX + sideX * 0.5f * preferred, -forwardY + sideY * 0.5f * preferred},
3345 {-forwardX - sideX * 0.5f * preferred, -forwardY - sideY * 0.5f * preferred},
3346 {sideX * preferred + forwardX * 0.35f, sideY * preferred + forwardY * 0.35f},
3347 {-sideX * preferred + forwardX * 0.35f, -sideY * preferred + forwardY * 0.35f},
3348 {-forwardX, -forwardY}, {forwardX, forwardY}}};
3349 const auto worldToCell = [&](float value, float origin) {
3350 return static_cast<int>(std::lround((value - origin) / grid.cellSize));
3351 };
3352 struct Candidate {
3354 float threat;
3355 float rangePenalty;
3356 int conflicts;
3357 bool revisit;
3358 std::size_t index;
3359 };
3360 std::optional<Candidate> best;
3361 std::optional<Candidate> legacy;
3362 for (std::size_t index = 0; index < directions.size(); ++index) {
3363 const float directionLength = std::hypot(directions[index].x, directions[index].y);
3364 const WorldPosition candidate{unit.motion()->x + directions[index].x / directionLength * distance,
3365 unit.motion()->y + directions[index].y / directionLength * distance};
3366 const int goalX = worldToCell(candidate.x, grid.originX);
3367 const int goalY = worldToCell(candidate.y, grid.originY);
3368 if (!pathfinder.isWalkable(goalX, goalY)) continue;
3369 std::unique_ptr<map::Path> path(pathfinder.findPath(worldToCell(unit.motion()->x, grid.originX),
3370 worldToCell(unit.motion()->y, grid.originY),
3371 goalX, goalY));
3372 if (path == nullptr || path->empty()) continue;
3373 Candidate evaluated{candidate, hostileThreat(candidate),
3374 std::max(0.0f, std::hypot(candidate.x - target.x, candidate.y - target.y) - weaponRange),
3375 conflictCount(candidate),
3376 unit.artillery()->hasDepartedPosition &&
3377 distanceSquared(candidate.x, candidate.y,
3378 unit.artillery()->departedPosition.x,
3379 unit.artillery()->departedPosition.y) < distance * distance * 0.5625f,
3380 index};
3381 if (index == 0) legacy = evaluated;
3382 const bool better = !best || evaluated.threat < best->threat - 1e-4f ||
3383 (std::abs(evaluated.threat - best->threat) <= 1e-4f &&
3384 (evaluated.rangePenalty < best->rangePenalty - 1e-4f ||
3385 (std::abs(evaluated.rangePenalty - best->rangePenalty) <= 1e-4f &&
3386 (evaluated.conflicts < best->conflicts ||
3387 (evaluated.conflicts == best->conflicts &&
3388 ((best->revisit && !evaluated.revisit) ||
3389 (best->revisit == evaluated.revisit && evaluated.index < best->index)))))));
3390 if (better) best = evaluated;
3391 }
3392 if (!best)
3394 DiagnosticCode::NotFound, "RTS artillery has no reachable relocation candidate", "artillery.relocation"));
3396 selection.target = best->target;
3397 selection.threat = best->threat;
3398 selection.conflicts = best->conflicts;
3399 selection.avoidedDepartedPosition = unit.artillery()->hasDepartedPosition && !best->revisit;
3400 if (legacy) {
3401 selection.avoidedThreat = best->threat < legacy->threat - 1e-4f;
3402 selection.deconflicted = best->conflicts < legacy->conflicts;
3403 }
3406}
3407
3408Result<std::size_t> FireSupportSystem::request(Unit& requester, WorldPosition center, float radius,
3409 int shotsPerResponder, std::size_t maxResponders) {
3410 if (!isFinitePosition(center) || !std::isfinite(radius) || radius <= 0.0f || shotsPerResponder <= 0 ||
3411 !requester.durability()->alive || requester.containment()->container.isBound() ||
3412 requester.faction()->link.resolve() == nullptr)
3414 Diagnostic::error(DiagnosticCode::InvalidArgument, "RTS fire support request is invalid", "fireSupport"));
3415 struct Candidate { Unit* unit = nullptr; float distance = 0.0f; std::string id; };
3416 std::vector<Candidate> candidates;
3419 for (auto it = view.begin(); it != view.end(); ++it) {
3420 auto [identity, motion, faction, orders, weaponLink, artillery, durability, containment, morale] = *it;
3421 auto* unit = dynamic_cast<Unit*>(ecs::try_get(identity->self));
3422 auto* weaponEntity = dynamic_cast<weapon::WeaponEntity*>(weaponLink->link.resolve());
3423 if (unit == nullptr || unit == &requester || !durability->alive || containment->container.isBound() ||
3424 morale->retreating || !FactionRelationSystem::isAllied(faction->link, requester.faction()->link) ||
3425 weaponEntity == nullptr || weaponEntity->definition()->def == nullptr)
3426 continue;
3427 const auto& definition = *weaponEntity->definition()->def;
3428 if (definition.projectile.speed <= 0.0f ||
3429 (definition.projectile.gravity <= 0.0f && artillery->deployTime <= 0.0f)) continue;
3430 auto current = readCurrent(orders->values);
3431 if (!current) return Result<std::size_t>::failure(current.status());
3432 auto record = std::move(current).takeValue();
3433 if (record && record->kind != OrderKind::HoldPosition && record->kind != OrderKind::AttackMove &&
3434 record->kind != OrderKind::Patrol) continue;
3435 const float distance = distanceSquared(motion->x, motion->y, center.x, center.y);
3436 if (distance > definition.range * definition.range) continue;
3437 candidates.push_back({unit, distance, identity->subject.format()});
3438 }
3439 std::sort(candidates.begin(), candidates.end(), [](const Candidate& left, const Candidate& right) {
3440 return left.distance != right.distance ? left.distance < right.distance : left.id < right.id;
3441 });
3442 if (maxResponders > 0 && candidates.size() > maxResponders) candidates.resize(maxResponders);
3443 float dx = center.x - requester.motion()->x, dy = center.y - requester.motion()->y;
3444 const float length = std::hypot(dx, dy);
3445 if (length <= 1e-5f) { dx = 1.0f; dy = 0.0f; }
3446 else { dx /= length; dy /= length; }
3447 const WorldPosition lateral{-dy * radius, dx * radius};
3448 for (const Candidate& candidate : candidates) {
3449 CommandSpec command;
3450 command.kind = OrderKind::SuppressArea;
3451 command.target = {center.x - lateral.x, center.y - lateral.y};
3452 command.secondaryTarget = {center.x + lateral.x, center.y + lateral.y};
3453 command.radius = radius;
3454 auto assigned = candidate.unit->orders()->values.replace(command);
3455 if (!assigned) return Result<std::size_t>::failure(assigned.status());
3456 std::move(assigned).takeValue();
3457 candidate.unit->artillery()->suppressionShotsRemaining = shotsPerResponder;
3458 candidate.unit->artillery()->fireSupportRequester = requester.identity()->self;
3459 }
3460 return Result<std::size_t>::success(candidates.size(),
3461 Status::success(candidates.empty() ? StatusCode::NoOp : StatusCode::Applied));
3462}
3463
3464Result<std::size_t> FireSupportSystem::cancel(Unit& requester) {
3465 std::size_t cancelled = 0;
3466 auto view = ecs::View<Unit, Unit::Identity, Unit::Orders, Unit::Artillery>();
3467 for (auto it = view.begin(); it != view.end(); ++it) {
3468 auto [identity, orders, artillery] = *it;
3469 if (!isSameHandle(artillery->fireSupportRequester, requester.identity()->self)) continue;
3470 auto current = readCurrent(orders->values);
3471 if (!current) return Result<std::size_t>::failure(current.status());
3472 auto record = std::move(current).takeValue();
3473 if (record && record->kind == OrderKind::SuppressArea) {
3474 auto stopped = orders->values.cancel(record->id, "fire support cancelled");
3475 if (!stopped) return Result<std::size_t>::failure(stopped.status());
3476 ++cancelled;
3477 }
3478 artillery->fireSupportRequester = {};
3479 artillery->suppressionShotsRemaining = 0;
3480 (void)identity;
3481 }
3482 return Result<std::size_t>::success(cancelled,
3484}
3485
3486Result<std::size_t> FireSupportSystem::step(const SimulationStep& step) {
3487 struct Exposure { ecs::EntityHandle handle{}; ecs::Entity* faction = nullptr; WorldPosition position; };
3488 std::vector<Exposure> exposures;
3489 auto exposedUnits = ecs::View<Unit, Unit::Identity, Unit::Faction, Unit::Artillery, Unit::Durability>();
3490 for (auto it = exposedUnits.begin(); it != exposedUnits.end(); ++it) {
3491 auto [identity, faction, artillery, durability] = *it;
3492 if (durability->alive && artillery->lastFireTick.value() > 0 &&
3493 step.tick.value() >= artillery->lastFireTick.value())
3494 exposures.push_back({identity->self, faction->link.resolve(), artillery->lastFirePosition});
3495 }
3496 auto exposedBuildings = ecs::View<Building, Building::Identity, Building::Faction, Building::IndirectFire,
3498 for (auto it = exposedBuildings.begin(); it != exposedBuildings.end(); ++it) {
3499 auto [identity, faction, indirect, integrity] = *it;
3500 if (integrity->alive && indirect->lastFireTick.value() > 0 &&
3501 step.tick.value() >= indirect->lastFireTick.value())
3502 exposures.push_back({identity->self, faction->link.resolve(), indirect->lastFirePosition});
3503 }
3504 std::size_t processed = 0;
3505 auto responders = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Faction, Unit::Orders, Unit::Weapon,
3506 Unit::Artillery, Unit::Durability, Unit::Containment>();
3507 for (auto it = responders.begin(); it != responders.end(); ++it) {
3508 auto [identity, motion, faction, orders, weaponLink, artillery, durability, containment] = *it;
3509 auto* unit = dynamic_cast<Unit*>(ecs::try_get(identity->self));
3510 if (unit == nullptr || &*unit->identity() != identity) continue;
3511 if (artillery->fireSupportRequester.table != nullptr &&
3512 ecs::try_get(artillery->fireSupportRequester) == nullptr) {
3513 auto current = readCurrent(orders->values);
3514 if (!current) return Result<std::size_t>::failure(current.status());
3515 auto record = std::move(current).takeValue();
3516 if (record && record->kind == OrderKind::SuppressArea) {
3517 auto stopped = orders->values.cancel(record->id, "fire support requester lost");
3518 if (!stopped) return Result<std::size_t>::failure(stopped.status());
3519 }
3520 artillery->fireSupportRequester = {};
3521 ++processed;
3522 }
3523 if (!artillery->autoCounterBattery || artillery->counterBatteryWindowTicks == 0 ||
3524 !durability->alive || containment->container.isBound()) continue;
3525 auto current = readCurrent(orders->values);
3526 if (!current) return Result<std::size_t>::failure(current.status());
3527 auto record = std::move(current).takeValue();
3528 if (record && record->kind != OrderKind::HoldPosition) continue;
3529 auto* weaponEntity = dynamic_cast<weapon::WeaponEntity*>(weaponLink->link.resolve());
3530 if (weaponEntity == nullptr || weaponEntity->definition()->def == nullptr) continue;
3531 const auto& definition = *weaponEntity->definition()->def;
3532 const Exposure* best = nullptr;
3533 float bestDistance = std::numeric_limits<float>::max();
3534 for (const Exposure& exposure : exposures) {
3535 if (exposure.faction == nullptr ||
3536 FactionRelationSystem::isAllied(dynamic_cast<Faction*>(exposure.faction),
3537 dynamic_cast<Faction*>(faction->link.resolve())))
3538 continue;
3539 auto* hostileUnit = dynamic_cast<Unit*>(ecs::try_get(exposure.handle));
3540 auto* hostileBuilding = dynamic_cast<Building*>(ecs::try_get(exposure.handle));
3541 const auto firedTick = hostileUnit != nullptr ? hostileUnit->artillery()->lastFireTick.value()
3542 : hostileBuilding != nullptr ? hostileBuilding->indirectFire()->lastFireTick.value() : 0;
3543 if (step.tick.value() - firedTick > artillery->counterBatteryWindowTicks) continue;
3544 const float distance = distanceSquared(motion->x, motion->y, exposure.position.x, exposure.position.y);
3545 if (distance > definition.range * definition.range || distance >= bestDistance) continue;
3546 best = &exposure; bestDistance = distance;
3547 }
3548 if (best == nullptr) continue;
3549 CommandSpec counter;
3550 counter.kind = OrderKind::AttackGround;
3551 counter.target = best->position;
3552 auto assigned = orders->values.replace(counter);
3553 if (!assigned) return Result<std::size_t>::failure(assigned.status());
3554 std::move(assigned).takeValue();
3555 ++processed;
3556 }
3557 return Result<std::size_t>::success(processed,
3558 Status::success(processed == 0 ? StatusCode::NoOp : StatusCode::Applied));
3559}
3560
3561std::uint64_t RTSProjectileSystem::key(weapon::ProjectileHandle handle) noexcept {
3562 return (static_cast<std::uint64_t>(handle.generation) << 32u) | handle.slot;
3563}
3564
3565RTSProjectileSystemSnapshot RTSProjectileSystem::snapshot() const {
3567 result.runtime = runtime_.snapshot();
3568 for (auto& slot : result.runtime.slots)
3569 if (slot.state) slot.state->target.reset();
3570 result.payloads.reserve(payloads_.size());
3571 for (const auto& [payloadKey, payload] : payloads_) {
3572 result.payloads.push_back({payloadKey, payload.source, stableSubject(ecs::try_get(payload.faction)),
3573 stableSubject(ecs::try_get(payload.target)), stableSubject(ecs::try_get(payload.observer)),
3574 payload.targetPoint, payload.targetHeight, payload.damageType, payload.damage,
3575 payload.radius, payload.splashMinimumDamageFactor, payload.targetsGround, payload.targetsAir,
3576 payload.friendlyFire, payload.blockedByObstacles, payload.requiredTargetTags,
3577 payload.excludedTargetTags});
3578 }
3579 return result;
3580}
3581
3582Result<void> RTSProjectileSystem::restore(const RTSProjectileSystemSnapshot& snapshot,
3583 const ProjectileSubjectResolver& resolver) {
3584 if (!resolver)
3586 "RTS projectile restore requires a stable subject resolver",
3587 "projectiles.resolver"));
3588 weapon::ProjectileRuntime stagedRuntime;
3589 auto runtimeRestored = stagedRuntime.restore(snapshot.runtime);
3590 if (!runtimeRestored) return runtimeRestored;
3591 std::set<std::uint64_t> liveKeys;
3592 for (const auto& state : stagedRuntime.states()) liveKeys.insert(key(state.handle));
3593 if (liveKeys.size() != snapshot.payloads.size())
3595 DiagnosticCode::InvalidArgument, "RTS projectile snapshot payload count does not match live trajectories",
3596 "projectiles.payloads"));
3597 std::map<std::uint64_t, Payload> stagedPayloads;
3598 for (const auto& value : snapshot.payloads) {
3599 if (!liveKeys.contains(value.key) || stagedPayloads.contains(value.key) || !value.source.isValid() ||
3600 !value.faction.isValid() || !isFinitePosition(value.targetPoint) || !std::isfinite(value.targetHeight) ||
3601 value.damageType.empty() || !std::isfinite(value.damage) || value.damage < 0.0 ||
3602 !std::isfinite(value.radius) || value.radius < 0.0f || !std::isfinite(value.splashMinimumDamageFactor) ||
3603 value.splashMinimumDamageFactor < 0.0f || value.splashMinimumDamageFactor > 1.0f ||
3604 (!value.targetsGround && !value.targetsAir))
3606 "RTS projectile snapshot contains an invalid payload",
3607 "projectiles.payloads"));
3608 auto* faction = dynamic_cast<Faction*>(resolver(value.faction));
3609 ecs::Entity* target = value.target.isValid() ? resolver(value.target) : nullptr;
3610 ecs::Entity* observer = value.observer.isValid() ? resolver(value.observer) : nullptr;
3611 if (faction == nullptr || (value.target.isValid() && dynamic_cast<Unit*>(target) == nullptr &&
3612 dynamic_cast<Building*>(target) == nullptr) ||
3613 (value.observer.isValid() && dynamic_cast<Unit*>(observer) == nullptr &&
3614 dynamic_cast<Building*>(observer) == nullptr))
3616 "RTS projectile snapshot relationship cannot be resolved",
3617 "projectiles.payloads"));
3618 Payload payload;
3619 payload.source = value.source;
3620 payload.faction = ecs::handle_of(faction);
3621 payload.target = target == nullptr ? ecs::EntityHandle{} : ecs::handle_of(target);
3622 payload.observer = observer == nullptr ? ecs::EntityHandle{} : ecs::handle_of(observer);
3623 payload.targetPoint = value.targetPoint;
3624 payload.targetHeight = value.targetHeight;
3625 payload.damageType = value.damageType;
3626 payload.damage = value.damage;
3627 payload.radius = value.radius;
3628 payload.splashMinimumDamageFactor = value.splashMinimumDamageFactor;
3629 payload.targetsGround = value.targetsGround;
3630 payload.targetsAir = value.targetsAir;
3631 payload.friendlyFire = value.friendlyFire;
3632 payload.blockedByObstacles = value.blockedByObstacles;
3633 payload.requiredTargetTags = value.requiredTargetTags;
3634 payload.excludedTargetTags = value.excludedTargetTags;
3635 stagedPayloads.emplace(value.key, std::move(payload));
3636 }
3637 runtime_ = std::move(stagedRuntime);
3638 payloads_ = std::move(stagedPayloads);
3640}
3641
3642Result<weapon::ProjectilePoint> RTSProjectileSystem::position(ecs::EntityHandle target) const {
3643 auto value = entityPosition(target);
3644 if (!value)
3646 Diagnostic::error(DiagnosticCode::StaleHandle, "RTS projectile homing target is stale", "target"));
3648}
3649
3650Result<void> RTSProjectileSystem::launch(SubjectRef source, ecs::EntityHandle faction, WorldPosition origin,
3651 ecs::EntityHandle target, WorldPosition targetPoint,
3652 const weapon::WeaponDefinition& definition, double damageFactor,
3653 ecs::EntityHandle observer, float originHeight, float targetHeight) {
3654 if (!source.isValid() || definition.projectile.speed <= 0.0f || !std::isfinite(damageFactor) ||
3655 damageFactor < 0.0)
3656 return Result<void>::failure(
3658 "RTS projectile requires a source and positive canonical weapon speed", "projectile"));
3659 auto id = LogicalId::fromParts("projectile", definition.id.empty() ? "weapon" : definition.id);
3660 if (!id)
3661 return Result<void>::failure(
3662 Diagnostic::error(DiagnosticCode::InvalidArgument, "weapon id cannot identify a projectile", {}));
3664 projectile.id = *id;
3665 projectile.speed = definition.projectile.speed;
3666 projectile.gravity = std::max(0.0f, definition.projectile.gravity);
3667 projectile.mode = projectile.gravity > 0.0 ? weapon::ProjectileMode::Ballistic
3669 const double flight = std::max(1.0, static_cast<double>(std::max(1.0f, definition.range)) /
3670 static_cast<double>(definition.projectile.speed) * 2.0);
3671 auto lifetime = Duration::fromSeconds(flight);
3672 if (!lifetime) return Result<void>::failure(lifetime.status());
3673 projectile.lifetime = lifetime.value();
3674 WorldPosition destination = targetPoint;
3675 if (auto live = entityPosition(target)) destination = *live;
3677 request.position = {origin.x, originHeight, origin.y};
3678 const double dx = destination.x - origin.x;
3679 const double dz = destination.y - origin.y;
3680 double dy = static_cast<double>(targetHeight - originHeight);
3681 if (projectile.mode == weapon::ProjectileMode::Ballistic) {
3682 const double horizontal = std::hypot(dx, dz);
3683 const double speed2 = static_cast<double>(definition.projectile.speed) *
3684 static_cast<double>(definition.projectile.speed);
3685 const double gravity = static_cast<double>(definition.projectile.gravity);
3686 const double discriminant = speed2 * speed2 - gravity *
3687 (gravity * horizontal * horizontal + 2.0 * dy * speed2);
3688 if (horizontal > 1e-6 && discriminant >= 0.0)
3689 dy = horizontal * (speed2 - std::sqrt(discriminant)) / (gravity * horizontal);
3690 }
3691 request.direction = {dx, dy, dz};
3692 auto spawned = runtime_.spawn(projectile, request);
3693 if (!spawned) return Result<void>::failure(spawned.status());
3694 Payload payload;
3695 payload.source = source;
3696 payload.faction = faction;
3697 payload.target = target;
3698 payload.observer = observer;
3699 payload.targetPoint = destination;
3700 payload.targetHeight = targetHeight;
3701 payload.damageType = definition.damageType.empty() ? "damage.physical" : definition.damageType;
3702 const float launchDistance = std::hypot(destination.x - origin.x, destination.y - origin.y);
3703 payload.damage = static_cast<double>(definition.damage) * std::max(1, definition.projectile.pelletCount) *
3704 damageFactor * weaponRangeDamageFactor(definition, launchDistance);
3705 payload.radius = std::max(0.0f, definition.projectile.aoe);
3706 payload.splashMinimumDamageFactor = definition.splashMinimumDamageFactor;
3707 payload.targetsGround = definition.targetsGround;
3708 payload.targetsAir = definition.targetsAir;
3709 payload.friendlyFire = definition.friendlyFire;
3710 payload.blockedByObstacles = definition.blockedByObstacles;
3711 payload.requiredTargetTags = definition.requiredTargetTags;
3712 payload.excludedTargetTags = definition.excludedTargetTags;
3713 payloads_[key(spawned.value())] = std::move(payload);
3715}
3716
3717Result<std::size_t> RTSProjectileSystem::step(const SimulationStep& step, combat::DamageRuntime& damage,
3718 const ProjectileCollisionQuery& collision,
3719 const DamageEventSink& damageEvents) {
3720 if (step.delta.nanoseconds() < 0)
3722 DiagnosticCode::InvalidArgument, "RTS projectile delta must be non-negative", "step.delta"));
3723 auto observerSees = [](ecs::EntityHandle observerHandle, ecs::EntityHandle targetHandle) {
3724 const auto targetPosition = entityPosition(targetHandle);
3725 if (!targetPosition) return false;
3726 bool cloaked = false;
3727 if (auto* targetUnit = dynamic_cast<Unit*>(ecs::try_get(targetHandle)))
3728 cloaked = targetUnit->vision()->cloaked;
3729 if (auto* observer = dynamic_cast<Unit*>(ecs::try_get(observerHandle))) {
3730 if (!observer->durability()->alive || observer->containment()->container.isBound() ||
3731 !observer->vision()->enabled) return false;
3732 const float range = cloaked ? observer->vision()->detectionRange : observer->vision()->sightRange;
3733 return range > 0.0f && distanceSquared(observer->motion()->x, observer->motion()->y,
3734 targetPosition->x, targetPosition->y) <= range * range;
3735 }
3736 if (auto* observer = dynamic_cast<Building*>(ecs::try_get(observerHandle))) {
3737 if (!observer->integrity()->alive || observer->construction()->progress < 1.0f ||
3738 !observer->infrastructure()->powered || !observer->vision()->enabled || cloaked) return false;
3739 const float range = observer->vision()->sightRange;
3740 return range > 0.0f && distanceSquared(observer->placement()->worldX, observer->placement()->worldY,
3741 targetPosition->x, targetPosition->y) <= range * range;
3742 }
3743 return false;
3744 };
3745 for (auto& [payloadKey, payload] : payloads_) {
3746 (void)payloadKey;
3747 if (payload.observer.table == nullptr) continue;
3748 if (observerSees(payload.observer, payload.target)) {
3749 if (const auto live = entityPosition(payload.target)) payload.targetPoint = *live;
3750 } else {
3751 payload.target = {};
3752 payload.observer = {};
3753 }
3754 }
3755 std::map<std::uint64_t, weapon::ProjectileState> before;
3756 for (const auto& state : runtime_.states()) before.emplace(key(state.handle), state);
3757 auto updated = runtime_.update(step.delta, this);
3758 if (!updated) return Result<std::size_t>::failure(updated.status());
3759 for (const auto& released : updated.value().released) payloads_.erase(key(released));
3760 std::size_t impacts = 0;
3761 for (const auto& handle : updated.value().advanced) {
3762 const auto old = before.find(key(handle));
3763 const auto current = runtime_.find(handle);
3764 const auto payloadIt = payloads_.find(key(handle));
3765 if (old == before.end() || !current || payloadIt == payloads_.end()) continue;
3766 Payload& payload = payloadIt->second;
3767 WorldPosition destination = payload.targetPoint;
3768 if (auto live = entityPosition(payload.target)) destination = *live;
3769 const double ax = old->second.position.x, ay = old->second.position.z;
3770 const double bx = current->position.x, by = current->position.z;
3771 ecs::EntityHandle impactEntity = payload.target;
3772 bool collided = false;
3773 if (payload.blockedByObstacles && collision) {
3774 auto queried = collision({static_cast<float>(ax), static_cast<float>(ay)},
3775 static_cast<float>(old->second.position.y),
3776 {static_cast<float>(bx), static_cast<float>(by)},
3777 static_cast<float>(current->position.y),
3778 payload.source, payload.target);
3779 if (!queried) return Result<std::size_t>::failure(queried.status());
3780 if (queried.value()) {
3781 destination = queried.value()->position;
3782 impactEntity = queried.value()->entity;
3783 collided = true;
3784 }
3785 }
3786 const double az = old->second.position.y, bz = current->position.y;
3787 const double vx = bx - ax, vy = by - ay, vz = bz - az;
3788 const double wx = destination.x - ax, wy = destination.y - ay, wz = payload.targetHeight - az;
3789 const double length2 = vx * vx + vy * vy + vz * vz;
3790 const double t = length2 <= 1e-12 ? 0.0 :
3791 std::clamp((wx * vx + wy * vy + wz * vz) / length2, 0.0, 1.0);
3792 const double dx = ax + vx * t - destination.x;
3793 const double dy = ay + vy * t - destination.y;
3794 const double dz = az + vz * t - payload.targetHeight;
3795 if (!collided && dx * dx + dy * dy + dz * dz > 0.25) continue;
3796
3797 auto apply = [&](ecs::Entity* entity, double scale) -> Result<void> {
3798 combat::CombatState* state = nullptr; bool* alive = nullptr; FactionLink* faction = nullptr;
3799 RTSEffectComponent* effects = nullptr;
3800 float* shield = nullptr; float* cooldown = nullptr; float delay = 0.0f; SubjectRef subject;
3801 TagSet* tags = nullptr; bool airborne = false;
3802 if (auto* unit = dynamic_cast<Unit*>(entity)) {
3803 state = &unit->durability()->state; alive = &unit->durability()->alive;
3804 faction = &unit->faction()->link; shield = &unit->shield()->value;
3805 effects = &unit->effects()->values;
3806 cooldown = &unit->shield()->cooldown; delay = unit->shield()->regenDelay;
3807 subject = unit->identity()->subject; tags = &unit->tags()->values;
3808 airborne = unit->motion()->airborne;
3809 } else if (auto* building = dynamic_cast<Building*>(entity)) {
3810 state = &building->integrity()->state; alive = &building->integrity()->alive;
3811 faction = &building->faction()->link; shield = &building->shield()->value;
3812 effects = &building->effects()->values;
3813 cooldown = &building->shield()->cooldown; delay = building->shield()->regenDelay;
3814 subject = building->identity()->subject; tags = &building->tags()->values;
3815 }
3816 if (state == nullptr || alive == nullptr || !*alive || faction == nullptr ||
3817 (!payload.friendlyFire &&
3818 FactionRelationSystem::isAllied(dynamic_cast<Faction*>(faction->resolve()),
3819 dynamic_cast<Faction*>(ecs::try_get(payload.faction)))) ||
3820 !subject.isValid() || (airborne && !payload.targetsAir) || (!airborne && !payload.targetsGround) ||
3821 tags == nullptr ||
3822 std::any_of(payload.requiredTargetTags.begin(), payload.requiredTargetTags.end(),
3823 [&](const auto& tag) { return !tags->contains(tag); }) ||
3824 std::any_of(payload.excludedTargetTags.begin(), payload.excludedTargetTags.end(),
3825 [&](const auto& tag) { return tags->contains(tag); }))
3828 request.source = payload.source; request.target = subject; request.damageType = payload.damageType;
3829 request.healthDamage = payload.damage * scale;
3830 request.incomingDamageMultiplier = effects->multiplier("incomingDamageMultiplier");
3831 request.availableShield = shield != nullptr ? *shield : 0.0;
3832 auto outcome = damage.apply(*state, request);
3833 if (!outcome) return Result<void>::failure(outcome.status());
3834 if (shield != nullptr && outcome.value().absorbedShieldDamage > 0.0) {
3835 *shield -= static_cast<float>(outcome.value().absorbedShieldDamage);
3836 if (cooldown != nullptr) *cooldown = delay;
3837 }
3838 if (damageEvents) damageEvents(request, outcome.value(), step.tick, DamageChannel::Projectile);
3839 if (outcome.value().reaction == combat::HitReaction::Death) {
3840 if (Unit* source = unitBySubject(payload.source);
3841 source != nullptr && hostileTo(*source, *faction)) {
3842 auto awarded = VeterancySystem::award(*source,
3843 static_cast<float>(std::max(1.0, state->maxHealth)));
3844 if (!awarded) return Result<void>::failure(awarded.status());
3845 std::move(awarded).takeValue();
3846 }
3847 *alive = false;
3848 }
3850 };
3851 if (payload.radius <= 0.0f) {
3852 auto result = apply(ecs::try_get(impactEntity), 1.0);
3853 if (!result) return Result<std::size_t>::failure(result.status());
3854 } else {
3855 auto units = ecs::View<Unit, Unit::Identity, Unit::Motion>();
3856 for (auto it = units.begin(); it != units.end(); ++it) {
3857 auto [identity, motion] = *it;
3858 const float distance = std::hypot(motion->x - destination.x, motion->y - destination.y);
3859 if (distance > payload.radius) continue;
3860 const double radial = 1.0 - distance / payload.radius;
3861 auto result = apply(ecs::try_get(identity->self),
3862 std::max<double>(payload.splashMinimumDamageFactor, radial));
3863 if (!result) return Result<std::size_t>::failure(result.status());
3864 }
3865 auto buildings = ecs::View<Building, Building::Identity, Building::Placement>();
3866 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
3867 auto [identity, placement] = *it;
3868 const float distance = std::hypot(placement->worldX - destination.x,
3869 placement->worldY - destination.y);
3870 if (distance > payload.radius) continue;
3871 const double radial = 1.0 - distance / payload.radius;
3872 auto result = apply(ecs::try_get(identity->self),
3873 std::max<double>(payload.splashMinimumDamageFactor, radial));
3874 if (!result) return Result<std::size_t>::failure(result.status());
3875 }
3876 }
3877 auto released = runtime_.release(handle);
3878 if (!released) return Result<std::size_t>::failure(released.status());
3879 payloads_.erase(key(handle));
3880 ++impacts;
3881 }
3882 return Result<std::size_t>::success(impacts,
3884}
3885
3886Result<std::size_t> CombatFireSystem::step(const SimulationStep& step, State& state,
3888 RTSProjectileSystem* projectiles, const FireLineQuery& fireLine,
3889 map::Pathfinder* pathfinder, const NavigationGrid& navigationGrid,
3890 const CombatHeightQuery& heightQuery,
3891 const DamageEventSink& damageEvents,
3892 const CombatFireEventSink& fireEvents) {
3893 if (step.delta.nanoseconds() < 0)
3895 DiagnosticCode::InvalidArgument, "RTS combat step delta must be non-negative", "step.delta"));
3896 const auto launchHeights = [&](WorldPosition origin, WorldPosition target,
3897 ecs::EntityHandle source, ecs::EntityHandle targetEntity) {
3898 return heightQuery ? heightQuery(origin, target, source, targetEntity) : CombatHeightProfile{};
3899 };
3900 const auto publishShot = [&](weapon::WeaponEntity& weaponEntity, SubjectRef source,
3901 SubjectRef target, WorldPosition point, bool projectile, bool missed) {
3902 if (!fireEvents) return;
3903 fireEvents({projectile ? CombatFireEventKind::ProjectileFired
3904 : CombatFireEventKind::WeaponFired,
3905 source, target, point}, step.tick);
3906 if (missed)
3907 fireEvents({CombatFireEventKind::ShotMissed, source, target, point}, step.tick);
3908 const auto& resource = weaponEntity.state()->resource;
3909 if (resource.kind == weapon::ResourceKind::Ammo && !resource.infinite && resource.value <= 0.0f)
3910 fireEvents({CombatFireEventKind::WeaponDry, source, target, point}, step.tick);
3911 };
3912 std::set<std::string> blockedThisStep;
3913 const auto publishBlocked = [&](SubjectRef source, SubjectRef target, WorldPosition point) {
3914 const std::string key = source.format();
3915 blockedThisStep.insert(key);
3916 if (fireEvents && !state.blockedSubjects.contains(key))
3917 fireEvents({CombatFireEventKind::FireBlocked, source, target, point}, step.tick);
3918 };
3919 const auto updateWeapon = [&](weapon::WeaponEntity& weaponEntity, SubjectRef source,
3921 const auto* definition = weaponEntity.definition()->def;
3922 auto weaponState = weaponEntity.state();
3923 const auto& resource = weaponState->resource;
3924 const float dt = static_cast<float>(step.delta.seconds());
3925 const bool hasReserve = weaponState->ammoPool != nullptr
3926 ? weaponState->ammoPool->state()->count > 0
3927 : definition->reserveSize != 0 && resource.reserve > 0;
3928 const bool startsReload = definition->kind == weapon::WeaponKind::Ranged &&
3929 !resource.reloading && resource.value <= 0.0f &&
3930 weaponState->cooldown <= dt && !weaponState->jammed && hasReserve;
3931 const bool completesReload = definition->kind == weapon::WeaponKind::Ranged &&
3932 resource.reloading &&
3933 resource.reloadProgress + dt >= definition->reloadTime;
3934 weapon::WeaponSystem::update(weaponEntity, dt);
3935 if (startsReload && fireEvents)
3936 fireEvents({CombatFireEventKind::ReloadStarted, source, {}, position}, step.tick);
3937 if (completesReload && fireEvents)
3938 fireEvents({CombatFireEventKind::ReloadCompleted, source, {}, position}, step.tick);
3939 };
3940 struct Target {
3941 ecs::EntityHandle handle{};
3943 FactionLink* faction = nullptr;
3945 combat::CombatState* durability = nullptr;
3946 bool* alive = nullptr;
3947 Unit::Morale* morale = nullptr;
3948 float* shield = nullptr;
3949 float* shieldCooldown = nullptr;
3950 float shieldDelay = 0.0f;
3951 RTSEffectComponent* effects = nullptr;
3952 TagSet* tags = nullptr;
3953 bool airborne = false;
3954 std::string definition;
3955 bool cloaked = false;
3956 };
3957 std::map<std::string, Target> targets;
3958 for (const auto& id : state.mirroredSubjects) {
3959 auto removed = sensing.remove(id);
3960 if (!removed && removed.code() != StatusCode::NotFound) return Result<std::size_t>::failure(removed.status());
3961 if (!removed) removed.ignore("RTS sensing mirror was already absent");
3962 }
3963 state.mirroredSubjects.clear();
3964 auto mirror = [&](std::string id, Target target, std::string_view tags) -> Result<void> {
3965 const std::string faction = target.faction == nullptr ? std::string{} : factionKey(*target.faction);
3966 auto inserted = sensing.upsert(id, target.position.x, target.position.y, faction, tags, "");
3967 if (!inserted) return Result<void>::failure(inserted.status());
3968 state.mirroredSubjects.insert(id);
3969 targets.emplace(std::move(id), target);
3971 };
3972 {
3975 for (auto it = units.begin(); it != units.end(); ++it) {
3976 auto [identity, unitDefinition, motion, faction, durability, containment, morale, shield, effects, tags,
3977 vision] = *it;
3978 if (!identity->subject.isValid() || !durability->alive || durability->state.health <= 0.0 ||
3979 containment->container.isBound()) continue;
3980 auto result = mirror(identity->subject.format(),
3981 {identity->self, identity->subject, &faction->link, {motion->x, motion->y},
3982 &durability->state, &durability->alive, morale, &shield->value,
3983 &shield->cooldown, shield->regenDelay, &effects->values, &tags->values, motion->airborne,
3984 unitDefinition->id.format(), vision->cloaked},
3985 "combat-target,unit");
3986 if (!result) return Result<std::size_t>::failure(result.status());
3987 }
3988 }
3989 {
3992 Building::Tags>();
3993 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
3994 auto [identity, buildingDefinition, placement, faction, integrity, shield, effects, tags] = *it;
3995 if (!identity->subject.isValid() || !integrity->alive || integrity->state.health <= 0.0) continue;
3996 auto result = mirror(identity->subject.format(),
3997 {identity->self, identity->subject, &faction->link,
3998 {placement->worldX, placement->worldY}, &integrity->state, &integrity->alive,
3999 nullptr, &shield->value, &shield->cooldown, shield->regenDelay,
4000 &effects->values, &tags->values, false, buildingDefinition->id.format(), false},
4001 "building,combat-target");
4002 if (!result) return Result<std::size_t>::failure(result.status());
4003 }
4004 }
4005
4006 std::size_t fired = 0;
4007 auto observedFireSpotter = [&](ecs::Entity* ownFaction, const Target& target) -> ecs::EntityHandle {
4008 ecs::EntityHandle best{};
4009 std::string bestKey;
4010 auto consider = [&](ecs::EntityHandle handle, ecs::Entity* observerFaction, WorldPosition position,
4011 float sightRange, float detectionRange, bool enabled) {
4012 const float range = target.cloaked ? detectionRange : sightRange;
4013 const SubjectRef subject = stableSubject(ecs::try_get(handle));
4014 if (!enabled ||
4015 !FactionRelationSystem::isAllied(dynamic_cast<Faction*>(observerFaction),
4016 dynamic_cast<Faction*>(ownFaction)) ||
4017 range <= 0.0f || !subject.isValid() ||
4018 distanceSquared(position.x, position.y, target.position.x, target.position.y) > range * range)
4019 return;
4020 const std::string key = subject.format();
4021 if (best.table == nullptr || key < bestKey) { best = handle; bestKey = key; }
4022 };
4023 auto unitObservers = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Faction, Unit::Vision,
4024 Unit::Durability, Unit::Containment>();
4025 for (auto it = unitObservers.begin(); it != unitObservers.end(); ++it) {
4026 auto [identity, motion, faction, vision, durability, containment] = *it;
4027 if (!durability->alive || containment->container.isBound()) continue;
4028 consider(identity->self, faction->link.resolve(), {motion->x, motion->y}, vision->sightRange,
4029 vision->detectionRange, vision->enabled);
4030 }
4031 auto buildingObservers = ecs::View<Building, Building::Identity, Building::Placement, Building::Faction,
4032 Building::Vision, Building::Integrity, Building::Construction,
4033 Building::Infrastructure>();
4034 for (auto it = buildingObservers.begin(); it != buildingObservers.end(); ++it) {
4035 auto [identity, placement, faction, vision, integrity, construction, infrastructure] = *it;
4036 if (!integrity->alive || construction->progress < 1.0f || !infrastructure->powered) continue;
4037 consider(identity->self, faction->link.resolve(), {placement->worldX, placement->worldY},
4038 vision->sightRange, target.cloaked ? 0.0f : vision->detectionRange, vision->enabled);
4039 }
4040 return best;
4041 };
4042 auto attackers = ecs::View<Unit, Unit::Identity, Unit::Motion, Unit::Orders, Unit::Faction, Unit::Weapon,
4043 Unit::Combat, Unit::Durability, Unit::Containment, Unit::Morale, Unit::Artillery,
4044 Unit::Veterancy, Unit::Command, Unit::Tactics, Unit::Vision, Unit::Effects>();
4045 for (auto it = attackers.begin(); it != attackers.end(); ++it) {
4046 auto [identity, motion, orders, faction, weaponLink, policy, durability, containment, morale, artillery,
4047 veterancy, command, tactics, vision, effects] = *it;
4048 auto* unit = dynamic_cast<Unit*>(ecs::try_get(identity->self));
4049 if (unit == nullptr || &*unit->identity() != identity) continue;
4050 if (!durability->alive || durability->state.health <= 0.0 || containment->container.isBound()) continue;
4051 if (!std::isfinite(veterancy->experience) || veterancy->experience < 0.0f ||
4052 !std::isfinite(veterancy->veteranThreshold) || !std::isfinite(veterancy->eliteThreshold) ||
4053 veterancy->veteranThreshold < 0.0f || veterancy->eliteThreshold < 0.0f ||
4054 (veterancy->veteranThreshold > 0.0f && veterancy->eliteThreshold <= veterancy->veteranThreshold) ||
4055 !std::isfinite(veterancy->veteranDamageFactor) || !std::isfinite(veterancy->eliteDamageFactor) ||
4056 !std::isfinite(veterancy->veteranHealthFactor) || !std::isfinite(veterancy->eliteHealthFactor) ||
4057 veterancy->veteranDamageFactor < 1.0f ||
4058 veterancy->eliteDamageFactor < veterancy->veteranDamageFactor ||
4059 veterancy->veteranHealthFactor < 1.0f ||
4060 veterancy->eliteHealthFactor < veterancy->veteranHealthFactor || veterancy->level < 0 ||
4061 veterancy->level > 2)
4064 "RTS veterancy thresholds and factors are inconsistent", "unit.veterancy"));
4065 auto* weaponEntity = dynamic_cast<weapon::WeaponEntity*>(weaponLink->link.resolve());
4066 if (weaponEntity == nullptr || weaponEntity->definition()->def == nullptr) continue;
4067 updateWeapon(*weaponEntity, identity->subject, {motion->x, motion->y});
4068 const weapon::WeaponDefinition& definition = *weaponEntity->definition()->def;
4069 if (!std::isfinite(definition.preferredTargetBonus) || definition.preferredTargetBonus < 1.0f)
4071 DiagnosticCode::InvalidArgument, "RTS weapon preferred target bonus must be finite and at least one",
4072 "weapon.preferredTargetBonus"));
4073 policy->engagementRange = std::max(0.0f, definition.range);
4074 if (std::any_of(policy->targetPriorities.begin(), policy->targetPriorities.end(), [](const auto& entry) {
4075 return entry.first.empty() || !std::isfinite(entry.second) || entry.second < 0.0f;
4076 }))
4078 DiagnosticCode::InvalidArgument, "RTS target priority entries require non-empty ids and finite weights",
4079 "unit.combat.targetPriorities"));
4080 if (!std::isfinite(policy->turnRateDegrees) || policy->turnRateDegrees < 0.0f ||
4081 !std::isfinite(policy->aimToleranceDegrees) || policy->aimToleranceDegrees < 0.0f)
4084 "RTS turret turn rate and aim tolerance must be finite and non-negative", "unit.combat.aim"));
4085
4086 auto current = readCurrent(orders->values);
4087 if (!current) return Result<std::size_t>::failure(current.status());
4088 auto record = std::move(current).takeValue();
4089 const bool explicitAttack = record && record->kind == OrderKind::Attack;
4090 if (explicitAttack) policy->target = record->targetEntity;
4091
4092 if (!std::isfinite(tactics->coordinatedVolleyInterval) ||
4093 tactics->coordinatedVolleyInterval < 0.0f ||
4094 !std::isfinite(tactics->volleyReleaseRemaining) || tactics->volleyReleaseRemaining < 0.0f)
4096 DiagnosticCode::InvalidArgument, "RTS coordinated volley timing must be finite and non-negative",
4097 "unit.tactics.coordinatedVolley"));
4098 bool volleyReleased = true;
4099 if (!explicitAttack && tactics->combatGroup != 0 && tactics->coordinatedVolleyInterval > 0.0f) {
4100 if (tactics->volleyReleaseRemaining <= 1e-5f)
4101 tactics->volleyReleaseRemaining = tactics->coordinatedVolleyInterval;
4102 tactics->volleyReleaseRemaining = std::max(
4103 0.0f, tactics->volleyReleaseRemaining - static_cast<float>(step.delta.seconds()));
4104 volleyReleased = tactics->volleyReleaseRemaining <= 1e-5f;
4105 tactics->volleyHolding = !volleyReleased;
4106 if (volleyReleased) tactics->volleyReleaseRemaining = tactics->coordinatedVolleyInterval;
4107 } else {
4108 tactics->volleyHolding = false;
4109 }
4110
4111 const bool groundFire = record && (record->kind == OrderKind::AttackGround ||
4112 record->kind == OrderKind::SuppressArea);
4113 if (groundFire) {
4114 if (artillery->deployRemaining > 0.0f) continue;
4115 WorldPosition aimPoint = record->target;
4116 if (record->kind == OrderKind::SuppressArea) {
4117 const float lineX = record->secondaryTarget.x - record->target.x;
4118 const float lineY = record->secondaryTarget.y - record->target.y;
4119 const float lineLength = std::hypot(lineX, lineY);
4120 if (lineLength <= 1e-5f) {
4121 auto failed = orders->values.fail(record->id, "suppression line must have non-zero length");
4122 if (!failed) return Result<std::size_t>::failure(failed.status());
4123 continue;
4124 }
4125 const std::uint32_t sequence = artillery->shotSequence++;
4126 const float t = (static_cast<float>((sequence * 37u) % 101u) + 0.5f) / 101.0f;
4127 const float lateral = (static_cast<float>((sequence * 53u) % 101u) / 100.0f - 0.5f) *
4128 2.0f * record->radius;
4129 aimPoint.x = record->target.x + lineX * t - lineY / lineLength * lateral;
4130 aimPoint.y = record->target.y + lineY * t + lineX / lineLength * lateral;
4131 }
4132 const float range = std::max(0.0f, definition.range);
4133 if (distanceSquared(motion->x, motion->y, aimPoint.x, aimPoint.y) > range * range) continue;
4134 if (fireLine && definition.blockedByObstacles && definition.projectile.gravity <= 0.0f) {
4135 auto clear = fireLine({motion->x, motion->y}, aimPoint, identity->self, {}, definition);
4136 if (!clear) return Result<std::size_t>::failure(clear.status());
4137 if (!clear.value()) {
4138 publishBlocked(identity->subject, {}, aimPoint);
4139 continue;
4140 }
4141 }
4142 const float yaw = std::atan2(aimPoint.y - motion->y, aimPoint.x - motion->x) * 180.0f /
4143 static_cast<float>(std::numbers::pi);
4144 weaponEntity->aim()->yaw = yaw;
4145 weaponEntity->aim()->desiredYaw = yaw;
4146 weapon::AttackRequest attack;
4147 attack.targetX = aimPoint.x;
4148 attack.targetY = aimPoint.y;
4149 attack.hasTarget = true;
4150 attack.muzzleX = motion->x;
4151 attack.muzzleY = motion->y;
4152 attack.shooterId = static_cast<int>(identity->self.id);
4153 if (!weapon::WeaponSystem::tryFire(*weaponEntity, attack)) continue;
4154 const ShotPlacement shot = placeShot(identity->subject, policy->shotSequence++, definition, aimPoint);
4155 if (definition.projectile.speed > 0.0f &&
4156 (definition.projectile.gravity > 0.0f || artillery->deployTime > 0.0f)) {
4157 artillery->lastFireTick = step.tick;
4158 artillery->lastFirePosition = {motion->x, motion->y};
4159 }
4160 if (definition.projectile.speed > 0.0f && projectiles != nullptr) {
4161 const auto heights = launchHeights({motion->x, motion->y}, shot.point, identity->self, {});
4162 const float moraleFactor = morale->active
4163 ? std::clamp(morale->suppressedDamageFactor, 0.0f, 1.0f) : 1.0f;
4164 const float commandFactor = command->requiresCommand && !command->inCommand
4165 ? command->outOfCommandDamageFactor : 1.0f;
4166 auto launched = projectiles->launch(identity->subject, faction->link.handle(),
4167 {motion->x, motion->y}, {}, shot.point, definition,
4168 moraleFactor * commandFactor * policy->upgradeDamageFactor *
4169 static_cast<float>(effects->values.multiplier("damageMultiplier")),
4170 {}, heights.source, heights.target);
4171 if (!launched) return Result<std::size_t>::failure(launched.status());
4172 }
4173 publishShot(*weaponEntity, identity->subject, {}, shot.point,
4174 definition.projectile.speed > 0.0f, shot.missed);
4175 ++fired;
4176 bool finished = record->kind == OrderKind::AttackGround;
4177 if (record->kind == OrderKind::SuppressArea && artillery->suppressionShotsRemaining > 0) {
4178 --artillery->suppressionShotsRemaining;
4179 finished = artillery->suppressionShotsRemaining == 0;
4180 if (finished) artillery->fireSupportRequester = {};
4181 }
4182 if (artillery->shootAndScootDistance > 0.0f) {
4183 const float dx = motion->x - aimPoint.x;
4184 const float dy = motion->y - aimPoint.y;
4185 const float length = std::hypot(dx, dy);
4186 const float awayX = length > 1e-5f ? dx / length : 1.0f;
4187 const float awayY = length > 1e-5f ? dy / length : 0.0f;
4188 WorldPosition relocation{motion->x + awayX * artillery->shootAndScootDistance,
4189 motion->y + awayY * artillery->shootAndScootDistance};
4190 if (pathfinder != nullptr) {
4191 auto selected = ArtilleryRelocationSystem::select(*unit, aimPoint,
4192 artillery->shootAndScootDistance,
4193 definition.range, *pathfinder,
4194 navigationGrid);
4195 if (selected) {
4196 const auto choice = std::move(selected).takeValue();
4197 relocation = choice.target;
4198 artillery->relocationThreat = choice.threat;
4199 artillery->relocationConflictCount = choice.conflicts;
4200 } else if (selected.code() != StatusCode::NotFound) {
4201 return Result<std::size_t>::failure(selected.status());
4202 } else {
4203 selected.ignore("artillery falls back when no canonical-map candidate is reachable");
4204 }
4205 }
4206 auto completed = orders->values.complete(record->id);
4207 if (!completed) return Result<std::size_t>::failure(completed.status());
4208 CommandSpec relocate;
4209 relocate.kind = OrderKind::Move;
4210 relocate.target = relocation;
4211 auto moveOrder = orders->values.enqueue(relocate);
4212 if (!moveOrder) return Result<std::size_t>::failure(moveOrder.status());
4213 std::move(moveOrder).takeValue();
4214 if (!finished && record->kind == OrderKind::SuppressArea) {
4215 CommandSpec resume;
4216 resume.kind = OrderKind::SuppressArea;
4217 resume.target = record->target;
4218 resume.secondaryTarget = record->secondaryTarget;
4219 resume.radius = record->radius;
4220 auto resumed = orders->values.enqueue(resume);
4221 if (!resumed) return Result<std::size_t>::failure(resumed.status());
4222 std::move(resumed).takeValue();
4223 }
4224 artillery->departedPosition = {motion->x, motion->y};
4225 artillery->hasDepartedPosition = true;
4226 artillery->relocationTarget = relocation;
4227 artillery->relocating = true;
4228 } else if (finished) {
4229 auto completed = orders->values.complete(record->id);
4230 if (!completed) return Result<std::size_t>::failure(completed.status());
4231 }
4232 continue;
4233 }
4234
4235 auto validTarget = [&](const ecs::EntityHandle& handle, bool applyLeash) -> Target* {
4236 auto* entity = ecs::try_get(handle);
4237 if (entity == nullptr || isSameHandle(handle, identity->self)) return nullptr;
4238 auto found = std::find_if(targets.begin(), targets.end(),
4239 [&](auto& entry) { return isSameHandle(entry.second.handle, handle); });
4240 if (found == targets.end() || found->second.alive == nullptr || !*found->second.alive ||
4241 found->second.faction == nullptr ||
4242 (!definition.friendlyFire && FactionRelationSystem::isAllied(*found->second.faction, faction->link)))
4243 return nullptr;
4244 if ((found->second.airborne && !definition.targetsAir) ||
4245 (!found->second.airborne && !definition.targetsGround)) return nullptr;
4246 if (found->second.tags == nullptr ||
4247 std::any_of(definition.requiredTargetTags.begin(), definition.requiredTargetTags.end(),
4248 [&](const auto& tag) { return !found->second.tags->contains(tag); }) ||
4249 std::any_of(definition.excludedTargetTags.begin(), definition.excludedTargetTags.end(),
4250 [&](const auto& tag) { return found->second.tags->contains(tag); })) return nullptr;
4251 const float distance = distanceSquared(motion->x, motion->y, found->second.position.x,
4252 found->second.position.y);
4253 const bool indirect = definition.projectile.speed > 0.0f && definition.projectile.gravity > 0.0f;
4254 if (indirect && distance > vision->sightRange * vision->sightRange &&
4255 observedFireSpotter(faction->link.resolve(), found->second).table == nullptr)
4256 return nullptr;
4257 if (!explicitAttack && distance > policy->acquisitionRange * policy->acquisitionRange) return nullptr;
4258 if (explicitAttack && !FactionIntelSystem::isTargetable(dynamic_cast<Faction*>(faction->link.resolve()),
4259 found->second.subject))
4260 return nullptr;
4261 if (applyLeash && policy->stance != CombatStance::Aggressive &&
4262 policy->leashRange > 0.0f && policy->guardSet &&
4263 distanceSquared(policy->guardX, policy->guardY, found->second.position.x,
4264 found->second.position.y) > policy->leashRange * policy->leashRange)
4265 return nullptr;
4266 return &found->second;
4267 };
4268
4269 Target* target = validTarget(policy->target, !explicitAttack);
4270 if (explicitAttack && target == nullptr) {
4271 auto failed = orders->values.fail(record->id, "attack target is stale, allied, or destroyed");
4272 if (!failed) return Result<std::size_t>::failure(failed.status());
4273 policy->target = {};
4274 continue;
4275 }
4276 if (target == nullptr && policy->stance != CombatStance::Passive &&
4277 policy->acquisitionRange > 0.0f) {
4278 if (!policy->guardSet) {
4279 policy->guardX = motion->x;
4280 policy->guardY = motion->y;
4281 policy->guardSet = true;
4282 }
4283 const std::string ownFaction = factionKey(faction->link);
4284 eve::sensing::QuerySpec acquisition;
4285 acquisition.shape = eve::sensing::QueryCircle{motion->x, motion->y, policy->acquisitionRange};
4286 acquisition.requiredTags = {"combat-target"};
4287 acquisition.excludeFactions = {ownFaction};
4288 acquisition.maxRange = policy->acquisitionRange;
4289 acquisition.maxCount = static_cast<std::uint32_t>(targets.size());
4292 auto queried = sensing.query(eve::sensing::QueryOrigin{motion->x, motion->y, std::nullopt}, acquisition);
4293 if (!queried) return Result<std::size_t>::failure(queried.status());
4294 float bestPriority = -1.0f;
4295 for (const auto& candidate : queried.value().ranked()) {
4296 auto found = targets.find(candidate.id);
4297 if (found == targets.end()) continue;
4298 Target* accepted = validTarget(found->second.handle, true);
4299 if (accepted != nullptr) {
4300 auto preferred = policy->targetPriorities.find(accepted->definition);
4301 const float policyPriority = preferred == policy->targetPriorities.end() ? 1.0f : preferred->second;
4302 const float priority = policyPriority * weaponTargetPreference(definition, accepted->tags);
4303 if (target == nullptr || priority > bestPriority) {
4304 target = accepted;
4305 bestPriority = priority;
4306 }
4307 }
4308 }
4309 if (target != nullptr) policy->target = target->handle;
4310 }
4311 if (target == nullptr) {
4312 policy->target = {};
4313 artillery->usingObservedFire = false;
4314 artillery->observedFireSpotter = {};
4315 continue;
4316 }
4317 const bool indirect = definition.projectile.speed > 0.0f && definition.projectile.gravity > 0.0f;
4318 const float targetDistance = distanceSquared(motion->x, motion->y, target->position.x, target->position.y);
4319 if (indirect && targetDistance > vision->sightRange * vision->sightRange) {
4320 artillery->observedFireSpotter = observedFireSpotter(faction->link.resolve(), *target);
4321 artillery->usingObservedFire = artillery->observedFireSpotter.table != nullptr;
4322 } else {
4323 artillery->observedFireSpotter = {};
4324 artillery->usingObservedFire = false;
4325 }
4326 if (!volleyReleased) continue;
4327 const float range = std::max(0.0f, definition.range);
4328 if (distanceSquared(motion->x, motion->y, target->position.x, target->position.y) > range * range) continue;
4329 if (fireLine && definition.blockedByObstacles && definition.projectile.gravity <= 0.0f) {
4330 auto clear = fireLine({motion->x, motion->y}, target->position, identity->self,
4331 target->handle, definition);
4332 if (!clear) return Result<std::size_t>::failure(clear.status());
4333 if (!clear.value()) {
4334 publishBlocked(identity->subject, target->subject, target->position);
4335 continue;
4336 }
4337 }
4338
4339 const float yaw = std::atan2(target->position.y - motion->y, target->position.x - motion->x) *
4340 180.0f / static_cast<float>(std::numbers::pi);
4341 weaponEntity->aim()->desiredYaw = yaw;
4342 weaponEntity->aim()->turnSpeed = policy->turnRateDegrees;
4343 weapon::WeaponSystem::updateAim(*weaponEntity, static_cast<float>(step.delta.seconds()));
4344 if (std::fabs(std::remainder(weaponEntity->aim()->desiredYaw - weaponEntity->aim()->yaw, 360.0f)) >
4345 policy->aimToleranceDegrees) continue;
4346 weapon::AttackRequest attack;
4347 attack.targetX = target->position.x;
4348 attack.targetY = target->position.y;
4349 attack.hasTarget = true;
4350 attack.targetHandle = target->handle;
4351 attack.muzzleX = motion->x;
4352 attack.muzzleY = motion->y;
4353 attack.shooterId = static_cast<int>(identity->self.id);
4354 if (!weapon::WeaponSystem::tryFire(*weaponEntity, attack)) continue;
4355 const ShotPlacement shot = placeShot(identity->subject, policy->shotSequence++, definition,
4356 target->position);
4357 if (definition.projectile.speed > 0.0f &&
4358 (definition.projectile.gravity > 0.0f || artillery->deployTime > 0.0f)) {
4359 artillery->lastFireTick = step.tick;
4360 artillery->lastFirePosition = {motion->x, motion->y};
4361 }
4362 if (definition.projectile.speed > 0.0f && projectiles != nullptr) {
4363 const auto heights = launchHeights({motion->x, motion->y}, shot.point,
4364 identity->self, target->handle);
4365 const float moraleFactor = morale->active ? std::clamp(morale->suppressedDamageFactor, 0.0f, 1.0f)
4366 : 1.0f;
4367 const float commandFactor = command->requiresCommand && !command->inCommand
4368 ? command->outOfCommandDamageFactor : 1.0f;
4369 auto launched = projectiles->launch(identity->subject, faction->link.handle(),
4370 {motion->x, motion->y}, shot.missed ? ecs::EntityHandle{} : target->handle,
4371 shot.point,
4372 definition, moraleFactor * commandFactor *
4373 policy->upgradeDamageFactor *
4374 static_cast<float>(effects->values.multiplier("damageMultiplier")),
4375 artillery->observedFireSpotter, heights.source, heights.target);
4376 if (!launched) return Result<std::size_t>::failure(launched.status());
4377 }
4378 publishShot(*weaponEntity, identity->subject, target->subject, shot.point,
4379 definition.projectile.speed > 0.0f, shot.missed);
4380 ++fired;
4381
4382 // Projectile services own delayed impact. Hitscan/melee settles through
4383 // the canonical combat runtime immediately at the weapon's Active edge.
4384 if (definition.projectile.speed <= 0.0f && !shot.missed && target->durability != nullptr) {
4385 combat::DamageRequest request;
4386 request.source = identity->subject;
4387 request.target = target->subject;
4388 request.damageType = definition.damageType.empty() ? "damage.physical" : definition.damageType;
4389 const float damageFactor = morale->active ? std::clamp(morale->suppressedDamageFactor, 0.0f, 1.0f) : 1.0f;
4390 const float commandFactor = command->requiresCommand && !command->inCommand
4391 ? command->outOfCommandDamageFactor : 1.0f;
4392 const float rangeFactor = weaponRangeDamageFactor(definition,
4393 std::hypot(target->position.x - motion->x, target->position.y - motion->y));
4394 request.healthDamage = static_cast<double>(definition.damage * damageFactor * commandFactor *
4395 policy->upgradeDamageFactor * rangeFactor *
4396 static_cast<float>(effects->values.multiplier("damageMultiplier"))) *
4397 static_cast<double>(std::max(1, definition.projectile.pelletCount));
4398 request.incomingDamageMultiplier = target->effects->multiplier("incomingDamageMultiplier");
4399 request.availableShield = target->shield != nullptr ? *target->shield : 0.0;
4400 auto outcome = damage.apply(*target->durability, request);
4401 if (!outcome) return Result<std::size_t>::failure(outcome.status());
4402 if (target->shield != nullptr && outcome.value().absorbedShieldDamage > 0.0) {
4403 *target->shield -= static_cast<float>(outcome.value().absorbedShieldDamage);
4404 if (target->shieldCooldown != nullptr) *target->shieldCooldown = target->shieldDelay;
4405 }
4406 if (damageEvents) damageEvents(request, outcome.value(), step.tick, DamageChannel::Weapon);
4407 if (target->morale != nullptr && target->morale->capacity > 0.0f && policy->suppressionPerShot > 0.0f) {
4408 float auraFactor = 1.0f;
4409 auto allies = ecs::View<Unit, Unit::Motion, Unit::Faction, Unit::Morale, Unit::Containment,
4410 Unit::Durability>();
4411 for (auto allyIt = allies.begin(); allyIt != allies.end(); ++allyIt) {
4412 auto [allyMotion, allyFaction, aura, allyContainment, allyDurability] = *allyIt;
4413 if (!allyDurability->alive || allyContainment->container.isBound() || aura->auraRange <= 0.0f ||
4414 !FactionRelationSystem::isAllied(allyFaction->link, *target->faction))
4415 continue;
4416 if (distanceSquared(target->position.x, target->position.y, allyMotion->x, allyMotion->y) <=
4417 aura->auraRange * aura->auraRange)
4418 auraFactor = std::min(auraFactor, std::clamp(aura->auraSuppressionFactor, 0.0f, 1.0f));
4419 }
4420 target->morale->suppression = std::min(target->morale->capacity,
4421 target->morale->suppression + policy->suppressionPerShot * auraFactor * rangeFactor);
4422 if (target->morale->suppression >= target->morale->capacity * 0.5f) target->morale->active = true;
4423 if (target->morale->retreatEnabled && !target->morale->retreating &&
4424 target->morale->suppression >= target->morale->capacity * target->morale->retreatThreshold) {
4425 auto* targetUnit = dynamic_cast<Unit*>(ecs::try_get(target->handle));
4426 if (targetUnit != nullptr) {
4427 float awayX = target->position.x - motion->x;
4428 float awayY = target->position.y - motion->y;
4429 const float length = std::hypot(awayX, awayY);
4430 if (length <= 1e-5f) { awayX = 1.0f; awayY = 0.0f; }
4431 else { awayX /= length; awayY /= length; }
4432 CommandSpec retreat;
4433 retreat.kind = OrderKind::Move;
4434 retreat.target = {target->position.x + awayX * target->morale->retreatDistance,
4435 target->position.y + awayY * target->morale->retreatDistance};
4436 auto replaced = targetUnit->orders()->values.replace(retreat);
4437 if (!replaced) return Result<std::size_t>::failure(replaced.status());
4438 std::move(replaced).takeValue();
4439 target->morale->retreating = true;
4440 }
4441 }
4442 }
4443 if (outcome.value().reaction == combat::HitReaction::Death) {
4444 *target->alive = false;
4445 auto awarded = VeterancySystem::award(
4446 *dynamic_cast<Unit*>(ecs::try_get(identity->self)),
4447 static_cast<float>(std::max(1.0, target->durability->maxHealth)));
4448 if (!awarded) return Result<std::size_t>::failure(awarded.status());
4449 std::move(awarded).takeValue();
4450 policy->target = {};
4451 if (explicitAttack) {
4452 auto completed = orders->values.complete(record->id);
4453 if (!completed) return Result<std::size_t>::failure(completed.status());
4454 }
4455 }
4456 }
4457 }
4458 auto armedBuildings = ecs::View<Building, Building::Identity, Building::Placement, Building::Orders,
4459 Building::Faction, Building::Weapon, Building::Combat, Building::Integrity,
4460 Building::Construction, Building::Garrison>();
4461 for (auto it = armedBuildings.begin(); it != armedBuildings.end(); ++it) {
4462 auto [identity, placement, orders, faction, weaponLink, policy, integrity, construction, garrison] = *it;
4463 if (!integrity->alive || integrity->state.health <= 0.0 || construction->progress < 1.0f) continue;
4464 auto* weaponEntity = dynamic_cast<weapon::WeaponEntity*>(weaponLink->link.resolve());
4465 if (weaponEntity == nullptr || weaponEntity->definition()->def == nullptr) continue;
4466 updateWeapon(*weaponEntity, identity->subject, {placement->worldX, placement->worldY});
4467 const weapon::WeaponDefinition& definition = *weaponEntity->definition()->def;
4468 if (!std::isfinite(definition.preferredTargetBonus) || definition.preferredTargetBonus < 1.0f)
4470 DiagnosticCode::InvalidArgument, "RTS weapon preferred target bonus must be finite and at least one",
4471 "weapon.preferredTargetBonus"));
4472 policy->engagementRange = std::max(0.0f, definition.range);
4473 if (std::any_of(policy->targetPriorities.begin(), policy->targetPriorities.end(), [](const auto& entry) {
4474 return entry.first.empty() || !std::isfinite(entry.second) || entry.second < 0.0f;
4475 }))
4477 DiagnosticCode::InvalidArgument, "RTS target priority entries require non-empty ids and finite weights",
4478 "building.combat.targetPriorities"));
4479 if (!std::isfinite(policy->turnRateDegrees) || policy->turnRateDegrees < 0.0f ||
4480 !std::isfinite(policy->aimToleranceDegrees) || policy->aimToleranceDegrees < 0.0f)
4483 "RTS turret turn rate and aim tolerance must be finite and non-negative", "building.combat.aim"));
4484 const float acquisitionRange = policy->acquisitionRange > 0.0f ? policy->acquisitionRange
4485 : policy->engagementRange;
4486
4487 auto current = readCurrent(orders->values);
4488 if (!current) return Result<std::size_t>::failure(current.status());
4489 auto record = std::move(current).takeValue();
4490 const bool explicitAttack = record && record->kind == OrderKind::Attack;
4491 if (explicitAttack) policy->target = record->targetEntity;
4492 auto resolveTarget = [&](const ecs::EntityHandle& handle) -> Target* {
4493 auto found = std::find_if(targets.begin(), targets.end(),
4494 [&](auto& entry) { return isSameHandle(entry.second.handle, handle); });
4495 if (found == targets.end() || found->second.alive == nullptr || !*found->second.alive ||
4496 found->second.faction == nullptr ||
4497 (!definition.friendlyFire && FactionRelationSystem::isAllied(*found->second.faction, faction->link)) ||
4498 isSameHandle(found->second.handle, identity->self))
4499 return nullptr;
4500 if ((found->second.airborne && !definition.targetsAir) ||
4501 (!found->second.airborne && !definition.targetsGround))
4502 return nullptr;
4503 if (found->second.tags == nullptr ||
4504 std::any_of(definition.requiredTargetTags.begin(), definition.requiredTargetTags.end(),
4505 [&](const auto& tag) { return !found->second.tags->contains(tag); }) ||
4506 std::any_of(definition.excludedTargetTags.begin(), definition.excludedTargetTags.end(),
4507 [&](const auto& tag) { return found->second.tags->contains(tag); }))
4508 return nullptr;
4509 if (explicitAttack && !FactionIntelSystem::isTargetable(dynamic_cast<Faction*>(faction->link.resolve()),
4510 found->second.subject))
4511 return nullptr;
4512 return &found->second;
4513 };
4514 Target* target = resolveTarget(policy->target);
4515 if (explicitAttack && target == nullptr) {
4516 auto failed = orders->values.fail(record->id, "building attack target is invalid or destroyed");
4517 if (!failed) return Result<std::size_t>::failure(failed.status());
4518 policy->target = {};
4519 continue;
4520 }
4521 if (target == nullptr && acquisitionRange > 0.0f) {
4522 const std::string ownFaction = factionKey(faction->link);
4523 eve::sensing::QuerySpec acquisition;
4524 acquisition.shape = eve::sensing::QueryCircle{placement->worldX, placement->worldY, acquisitionRange};
4525 acquisition.requiredTags = {"combat-target"};
4526 acquisition.excludeFactions = {ownFaction};
4527 acquisition.maxRange = acquisitionRange;
4528 acquisition.maxCount = static_cast<std::uint32_t>(targets.size());
4531 auto queried = sensing.query(
4532 eve::sensing::QueryOrigin{placement->worldX, placement->worldY, std::nullopt}, acquisition);
4533 if (!queried) return Result<std::size_t>::failure(queried.status());
4534 float bestPriority = -1.0f;
4535 for (const auto& candidate : queried.value().ranked()) {
4536 auto found = targets.find(candidate.id);
4537 if (found == targets.end()) continue;
4538 Target* accepted = resolveTarget(found->second.handle);
4539 if (accepted != nullptr) {
4540 auto preferred = policy->targetPriorities.find(accepted->definition);
4541 const float policyPriority = preferred == policy->targetPriorities.end() ? 1.0f : preferred->second;
4542 const float priority = policyPriority * weaponTargetPreference(definition, accepted->tags);
4543 if (target == nullptr || priority > bestPriority) {
4544 target = accepted;
4545 bestPriority = priority;
4546 }
4547 }
4548 }
4549 if (target != nullptr) policy->target = target->handle;
4550 }
4551 if (target == nullptr) { policy->target = {}; continue; }
4552 const float range = std::max(0.0f, definition.range);
4553 if (distanceSquared(placement->worldX, placement->worldY, target->position.x, target->position.y) >
4554 range * range) continue;
4555 if (fireLine && definition.blockedByObstacles && definition.projectile.gravity <= 0.0f) {
4556 auto clear = fireLine({placement->worldX, placement->worldY}, target->position, identity->self,
4557 target->handle, definition);
4558 if (!clear) return Result<std::size_t>::failure(clear.status());
4559 if (!clear.value()) {
4560 publishBlocked(identity->subject, target->subject, target->position);
4561 continue;
4562 }
4563 }
4564 const float yaw = std::atan2(target->position.y - placement->worldY,
4565 target->position.x - placement->worldX) * 180.0f /
4566 static_cast<float>(std::numbers::pi);
4567 weaponEntity->aim()->desiredYaw = yaw;
4568 weaponEntity->aim()->turnSpeed = policy->turnRateDegrees;
4569 weapon::WeaponSystem::updateAim(*weaponEntity, static_cast<float>(step.delta.seconds()));
4570 if (std::fabs(std::remainder(weaponEntity->aim()->desiredYaw - weaponEntity->aim()->yaw, 360.0f)) >
4571 policy->aimToleranceDegrees) continue;
4572 weapon::AttackRequest attack;
4573 attack.targetX = target->position.x;
4574 attack.targetY = target->position.y;
4575 attack.hasTarget = true;
4576 attack.targetHandle = target->handle;
4577 attack.muzzleX = placement->worldX;
4578 attack.muzzleY = placement->worldY;
4579 attack.shooterId = static_cast<int>(identity->self.id);
4580 if (!weapon::WeaponSystem::tryFire(*weaponEntity, attack)) continue;
4581 const ShotPlacement shot = placeShot(identity->subject, policy->shotSequence++, definition,
4582 target->position);
4583 if (definition.projectile.speed > 0.0f && definition.projectile.gravity > 0.0f) {
4584 auto* firingBuilding = dynamic_cast<Building*>(ecs::try_get(identity->self));
4585 if (firingBuilding != nullptr) {
4586 firingBuilding->indirectFire()->lastFireTick = step.tick;
4587 firingBuilding->indirectFire()->lastFirePosition = {placement->worldX, placement->worldY};
4588 }
4589 }
4590 if (definition.projectile.speed > 0.0f && projectiles != nullptr) {
4591 const auto heights = launchHeights({placement->worldX, placement->worldY}, shot.point,
4592 identity->self, target->handle);
4593 const float garrisonFactor = 1.0f + garrison->damageBonusPerOccupant *
4594 static_cast<float>(garrison->occupants.size());
4595 auto launched = projectiles->launch(identity->subject, faction->link.handle(),
4596 {placement->worldX, placement->worldY},
4597 shot.missed ? ecs::EntityHandle{} : target->handle,
4598 shot.point, definition, garrisonFactor, {},
4599 heights.source, heights.target);
4600 if (!launched) return Result<std::size_t>::failure(launched.status());
4601 }
4602 publishShot(*weaponEntity, identity->subject, target->subject, shot.point,
4603 definition.projectile.speed > 0.0f, shot.missed);
4604 ++fired;
4605 if (definition.projectile.speed <= 0.0f && !shot.missed && target->durability != nullptr) {
4606 combat::DamageRequest request;
4607 request.source = identity->subject;
4608 request.target = target->subject;
4609 request.damageType = definition.damageType.empty() ? "damage.physical" : definition.damageType;
4610 const float garrisonFactor = 1.0f + garrison->damageBonusPerOccupant *
4611 static_cast<float>(garrison->occupants.size());
4612 const float rangeFactor = weaponRangeDamageFactor(definition,
4613 std::hypot(target->position.x - placement->worldX,
4614 target->position.y - placement->worldY));
4615 request.healthDamage = static_cast<double>(definition.damage * garrisonFactor * rangeFactor) *
4616 static_cast<double>(std::max(1, definition.projectile.pelletCount));
4617 request.incomingDamageMultiplier = target->effects->multiplier("incomingDamageMultiplier");
4618 request.availableShield = target->shield != nullptr ? *target->shield : 0.0;
4619 auto outcome = damage.apply(*target->durability, request);
4620 if (!outcome) return Result<std::size_t>::failure(outcome.status());
4621 if (target->shield != nullptr && outcome.value().absorbedShieldDamage > 0.0) {
4622 *target->shield -= static_cast<float>(outcome.value().absorbedShieldDamage);
4623 if (target->shieldCooldown != nullptr) *target->shieldCooldown = target->shieldDelay;
4624 }
4625 if (damageEvents) damageEvents(request, outcome.value(), step.tick, DamageChannel::Weapon);
4626 if (outcome.value().reaction == combat::HitReaction::Death) {
4627 *target->alive = false;
4628 policy->target = {};
4629 if (explicitAttack) {
4630 auto completed = orders->values.complete(record->id);
4631 if (!completed) return Result<std::size_t>::failure(completed.status());
4632 }
4633 }
4634 }
4635 }
4636 state.blockedSubjects = std::move(blockedThisStep);
4637 return Result<std::size_t>::success(fired,
4639}
4640
4641Result<std::size_t> OrderActionSystem::step(const SimulationStep& step, IRTSActionExecutor& executor) {
4642 if (step.delta.nanoseconds() < 0)
4644 DiagnosticCode::InvalidArgument, "RTS action step delta must be non-negative", "step.delta"));
4645 std::size_t processed = 0;
4646 auto view = ecs::View<Unit, Unit::Identity, Unit::Orders, Unit::Action>();
4647 for (auto it = view.begin(); it != view.end(); ++it) {
4648 auto [identity, orders, action] = *it;
4649 (void)action;
4650 Unit* unit = identity == nullptr ? nullptr : dynamic_cast<Unit*>(ecs::try_get(identity->self));
4651 if (unit == nullptr || &*unit->identity() != identity) continue;
4652 auto current = readCurrent(orders->values);
4653 if (!current) return Result<std::size_t>::failure(current.status());
4654 auto record = std::move(current).takeValue();
4655 if (!record) continue;
4656 if (record->kind == OrderKind::Gather || record->kind == OrderKind::ReturnCargo ||
4657 record->kind == OrderKind::Build || record->kind == OrderKind::Repair ||
4658 record->kind == OrderKind::Capture || record->kind == OrderKind::Attack ||
4659 record->kind == OrderKind::Move || record->kind == OrderKind::AttackMove ||
4660 record->kind == OrderKind::Patrol || record->kind == OrderKind::HoldPosition ||
4661 record->kind == OrderKind::Stop ||
4662 record->kind == OrderKind::Garrison || record->kind == OrderKind::BoardTransport ||
4663 record->kind == OrderKind::AttackGround || record->kind == OrderKind::SuppressArea ||
4664 record->kind == OrderKind::Resupply || record->kind == OrderKind::SupplyRelay ||
4665 record->kind == OrderKind::Escort)
4666 continue;
4667
4668 auto executed = executor.execute(*unit, *record, step);
4669 if (!executed) return Result<std::size_t>::failure(executed.status());
4670 const auto outcome = std::move(executed).takeValue();
4671 if (outcome.disposition == ActionDisposition::Completed) {
4672 auto completed = orders->values.complete(record->id);
4673 if (!completed) return Result<std::size_t>::failure(completed.status());
4674 }
4675 ++processed;
4676 }
4678}
4679
4680Result<ReinforcementRequestReceipt> ReinforcementProductionPolicySystem::request(
4681 Building& building, std::string preferredProduct, const ReinforcementEnqueue& enqueue) {
4682 if (preferredProduct.empty() || !enqueue)
4685 "RTS reinforcement request requires a preferred product and enqueue boundary", "reinforcement"));
4686 std::set<std::string> visited;
4687 std::string candidate = preferredProduct;
4689 while (visited.insert(candidate).second) {
4690 auto queued = enqueue(building, candidate);
4692 {std::move(preferredProduct), candidate, std::move(queued).takeValue()},
4694 lastFailure = queued.status();
4695 queued.ignore("reinforcement fallback continues after rejected candidate");
4696 const auto fallback = building.rally()->reinforcementFallbacks.find(candidate);
4697 if (fallback == building.rally()->reinforcementFallbacks.end() || fallback->second.empty())
4699 candidate = fallback->second;
4700 }
4702 Diagnostic::error(DiagnosticCode::InvalidArgument, "RTS reinforcement fallback chain contains a cycle",
4703 "building.rally.reinforcementFallbacks"));
4704}
4705
4706Result<std::size_t> ReinforcementProductionPolicySystem::step(
4707 const SimulationStep& step, const ReinforcementCancel& cancel) {
4708 if (step.delta.nanoseconds() < 0)
4710 DiagnosticCode::InvalidArgument, "RTS reinforcement policy delta must be non-negative", "step.delta"));
4711 struct Demand {
4712 Building* building = nullptr;
4713 const production::ProductionTask* task = nullptr;
4714 std::string product;
4715 int priority = 0;
4716 };
4717 struct Group {
4718 std::vector<Demand> demands;
4719 std::map<std::string, std::size_t> livingByType;
4720 std::size_t living = 0;
4721 std::size_t limit = 0;
4722 std::map<std::string, std::size_t> typeLimits;
4723 };
4724 std::map<std::string, Group> groups;
4727 for (auto it = units.begin(); it != units.end(); ++it) {
4728 auto [definition, faction, tactics, durability, containment] = *it;
4729 if (!durability->alive || containment->container.isBound() || tactics->combatGroup == 0) continue;
4730 auto& group = groups[factionKey(faction->link) + "\n" + std::to_string(tactics->combatGroup)];
4731 ++group.living;
4732 ++group.livingByType[definition->id.format()];
4733 }
4736 for (auto it = buildings.begin(); it != buildings.end(); ++it) {
4737 auto [identity, faction, production, rally, integrity] = *it;
4738 if (!integrity->alive || !rally->enabled || rally->combatGroup == 0) continue;
4739 auto* building = dynamic_cast<Building*>(ecs::try_get(identity->self));
4740 if (building == nullptr || &*building->identity() != identity) continue;
4741 const production::ProductionTask* active = nullptr;
4742 for (int index = 0; index < production->values.taskCount(); ++index) {
4743 auto task = production->values.taskAt(index);
4744 if (!task || task->get().kind != "unit") continue;
4745 const auto state = task->get().state;
4747 task->get().id == rally->reinforcementPolicyPausedTask) {
4748 active = &task->get();
4749 break;
4750 }
4751 }
4752 if (active == nullptr) {
4753 rally->reinforcementCapped = false;
4754 rally->reinforcementPolicyPausedTask.clear();
4755 continue;
4756 }
4757 auto& group = groups[factionKey(faction->link) + "\n" + std::to_string(rally->combatGroup)];
4758 if (group.limit == 0 || (rally->reinforcementLimit > 0 && rally->reinforcementLimit < group.limit))
4759 group.limit = rally->reinforcementLimit;
4760 for (const auto& [product, limit] : rally->reinforcementTypeLimits) {
4761 auto found = group.typeLimits.find(product);
4762 if (found == group.typeLimits.end() || limit < found->second) group.typeLimits[product] = limit;
4763 }
4764 const auto priority = rally->reinforcementTypePriorities.find(active->product);
4765 group.demands.push_back({building, active, active->product,
4766 priority == rally->reinforcementTypePriorities.end() ? 0 : priority->second});
4767 }
4768
4769 std::size_t processed = 0;
4770 for (auto& [key, group] : groups) {
4771 (void)key;
4772 std::sort(group.demands.begin(), group.demands.end(), [](const Demand& left, const Demand& right) {
4773 if (left.priority != right.priority) return left.priority > right.priority;
4774 return left.building->identity()->subject.format() < right.building->identity()->subject.format();
4775 });
4776 std::size_t reserved = 0;
4777 std::map<std::string, std::size_t> reservedByType;
4778 for (Demand& demand : group.demands) {
4779 auto rally = demand.building->rally();
4780 const auto typeLimit = group.typeLimits.find(demand.product);
4781 const bool totalCapped = group.limit > 0 && group.living + reserved >= group.limit;
4782 const bool typeCapped = typeLimit != group.typeLimits.end() &&
4783 group.livingByType[demand.product] + reservedByType[demand.product] >= typeLimit->second;
4784 const bool capped = totalCapped || typeCapped;
4785 if (capped && demand.task->state != production::TaskState::Paused) {
4786 auto paused = demand.building->production()->values.pause(demand.task->id);
4787 if (!paused) return Result<std::size_t>::failure(paused.status());
4788 rally->reinforcementPolicyPausedTask = demand.task->id;
4789 ++processed;
4790 } else if (!capped && demand.task->state == production::TaskState::Paused &&
4791 rally->reinforcementPolicyPausedTask == demand.task->id) {
4792 auto resumed = demand.building->production()->values.resume(demand.task->id);
4793 if (!resumed) return Result<std::size_t>::failure(resumed.status());
4794 rally->reinforcementPolicyPausedTask.clear();
4795 ++processed;
4796 }
4797 rally->reinforcementCapped = capped;
4798 if (capped) {
4799 rally->reinforcementCappedSeconds += static_cast<float>(step.delta.seconds());
4800 if (rally->reinforcementAutoCancelDelay > 0.0f &&
4801 rally->reinforcementCappedSeconds + 1e-5f >= rally->reinforcementAutoCancelDelay && cancel) {
4802 const std::string taskId = demand.task->id;
4803 auto cancelled = cancel(*demand.building, taskId);
4804 if (!cancelled) return Result<std::size_t>::failure(cancelled.status());
4805 rally->reinforcementPolicyPausedTask.clear();
4806 rally->reinforcementCappedSeconds = 0.0f;
4807 rally->reinforcementCapped = false;
4808 ++processed;
4809 continue;
4810 }
4811 } else {
4812 rally->reinforcementCappedSeconds = 0.0f;
4813 }
4814 if (!capped) {
4815 ++reserved;
4816 ++reservedByType[demand.product];
4817 }
4818 }
4819 }
4820 return Result<std::size_t>::success(processed,
4822}
4823
4824
4825Result<std::size_t> ReinforcementSystem::step() {
4826 std::size_t processed = 0;
4827 auto view = ecs::View<Building, Building::Identity, Building::Faction, Building::Rally>();
4828 for (auto it = view.begin(); it != view.end(); ++it) {
4829 auto [identity, faction, rally] = *it;
4830 (void)identity;
4831 if (!rally->enabled || rally->transport.table == nullptr) continue;
4832 auto* transport = dynamic_cast<Unit*>(ecs::try_get(rally->transport));
4833 if (transport == nullptr || !transport->durability()->alive ||
4834 !FactionRelationSystem::isAllied(transport->faction()->link, faction->link)) {
4835 rally->transport = {};
4836 rally->transportActive = false;
4837 continue;
4838 }
4839 auto& occupants = transport->containment()->occupants;
4840 occupants.erase(std::remove_if(occupants.begin(), occupants.end(), [](const auto& handle) {
4841 return dynamic_cast<Unit*>(ecs::try_get(handle)) == nullptr;
4842 }), occupants.end());
4843 if (!rally->transportActive) {
4844 if (occupants.size() < std::max<std::size_t>(1, rally->minimumTransportLoad)) continue;
4845 auto dispatched = transport->orders()->values.replace(rally->command);
4846 if (!dispatched) return Result<std::size_t>::failure(dispatched.status());
4847 std::move(dispatched).takeValue();
4848 transport->tactics()->combatGroup = rally->combatGroup;
4849 rally->transportActive = true;
4850 ++processed;
4851 continue;
4852 }
4853 if (!transport->motion()->arrived) continue;
4854 const auto passengers = occupants;
4855 occupants.clear();
4856 for (const auto& handle : passengers) {
4857 auto* passenger = dynamic_cast<Unit*>(ecs::try_get(handle));
4858 if (passenger == nullptr) continue;
4859 passenger->containment()->container = {};
4860 passenger->motion()->x = transport->motion()->x;
4861 passenger->motion()->y = transport->motion()->y;
4862 passenger->tactics()->combatGroup = rally->combatGroup;
4863 auto ordered = passenger->orders()->values.replace(rally->command);
4864 if (!ordered) return Result<std::size_t>::failure(ordered.status());
4865 std::move(ordered).takeValue();
4866 ++processed;
4867 }
4868 rally->transportActive = false;
4869 }
4870 return Result<std::size_t>::success(processed,
4872}
4873
4874Result<std::size_t> EffectSystem::step(const SimulationStep& step, combat::DamageRuntime& settlement,
4875 const LifecycleEventSink& events) {
4876 if (step.delta.nanoseconds() < 0)
4878 DiagnosticCode::InvalidArgument, "RTS effects step delta must be non-negative", "step.delta"));
4879 std::size_t processed = 0;
4880 {
4881 auto view = ecs::View<Unit, Unit::Identity, Unit::Effects, Unit::Durability>();
4882 for (auto it = view.begin(); it != view.end(); ++it) {
4883 auto [identity, effects, durability] = *it;
4884 Unit* unit = identity == nullptr ? nullptr : dynamic_cast<Unit*>(ecs::try_get(identity->self));
4885 if (unit == nullptr || &*unit->identity() != identity || &*unit->effects() != effects) continue;
4886 const double healing = effects->values.additive("healingPerSecond") * step.delta.seconds();
4887 if (durability->alive && healing > 0.0) {
4888 auto restored = settlement.heal(durability->state, {}, healing);
4889 if (!restored) return Result<std::size_t>::failure(restored.status());
4890 }
4891 const auto before = effects->values.snapshot();
4892 auto advanced = advanceEffects(effects->values, step);
4893 if (!advanced) return Result<std::size_t>::failure(advanced.status());
4894 if (events && advanced.value() > 0) {
4895 const auto after = effects->values.snapshot();
4896 for (int index = 0; index < before.effects.effectCount(); ++index) {
4897 const auto* instance = before.effects.effectAt(index);
4898 if (instance != nullptr && after.effects.find(instance->id) == nullptr)
4899 events({LifecycleEventKind::StatusExpired, identity->subject, {},
4900 instance->type, 0.0}, step.tick);
4901 }
4902 }
4903 processed += std::move(advanced).takeValue();
4904 }
4905 }
4906 {
4907 auto view = ecs::View<Building, Building::Identity, Building::Effects>();
4908 for (auto it = view.begin(); it != view.end(); ++it) {
4909 auto [identity, effects] = *it;
4910 Building* building = identity == nullptr ? nullptr : dynamic_cast<Building*>(ecs::try_get(identity->self));
4911 if (building == nullptr || &*building->identity() != identity || &*building->effects() != effects) continue;
4912 const auto before = effects->values.snapshot();
4913 auto advanced = advanceEffects(effects->values, step);
4914 if (!advanced) return Result<std::size_t>::failure(advanced.status());
4915 if (events && advanced.value() > 0) {
4916 const auto after = effects->values.snapshot();
4917 for (int index = 0; index < before.effects.effectCount(); ++index) {
4918 const auto* instance = before.effects.effectAt(index);
4919 if (instance != nullptr && after.effects.find(instance->id) == nullptr)
4920 events({LifecycleEventKind::StatusExpired, identity->subject, {},
4921 instance->type, 0.0}, step.tick);
4922 }
4923 }
4924 processed += std::move(advanced).takeValue();
4925 }
4926 }
4928}
4929
4930} // namespace eve::rts
LogicalId target
double value
Value::Object payload
Duration start
bool & active
float y
Definition AnimClip.cpp:738
float x
Definition AnimClip.cpp:738
int observer
Definition AnimSmr.cpp:162
int root
Definition AnimSmr.cpp:119
int subject
Definition AnimSmr.cpp:163
building::EdgeCurveGroup group
int priority
int bz
Definition CaveMesh.cpp:114
int ax
Definition CaveMesh.cpp:113
int ay
Definition CaveMesh.cpp:113
int bx
Definition CaveMesh.cpp:114
float length
Definition CaveMesh.cpp:94
int az
Definition CaveMesh.cpp:113
int by
Definition CaveMesh.cpp:114
eve::resource::CostSpec cost
std::map< std::string, Var > values
std::array< std::uint8_t, 32 > hash
Definition Evpack.cpp:172
const GltfImportRequest & request
std::uint32_t alive
std::uint32_t spawned
std::uint32_t capacity
std::uint32_t key
HexVec3 left
HexVec3 right
TokenKind kind
size_t offset
Range range
std::array< float, 3 > position
std::array< float, 3 > scale
bool valid
float distance
std::vector< std::int32_t > order
std::vector< BvhNode > nodes
const std::string * tag
graphics::Canvas * previous
uint32_t groups
Definition OnnxGpgpu.cpp:39
size_t queued
Definition OnnxGpgpu.cpp:60
std::unique_ptr< gpgpu::Sequence > sequence
Definition OnnxGpgpu.cpp:43
size_t directions
Definition OnnxLstm.cpp:29
OnnxCompute * compute
eve::action::ActionVfxBinding binding
std::weak_ptr< const void > lifetime
std::array< std::uint64_t, kPixelChunkSize *kPixelChunkSize > updated
float radius
std::string action
Definition PlayHost.cpp:117
std::string path
Definition PlayHost.cpp:110
std::string id
Definition PlayHost.cpp:108
PrimitiveHandle handle
std::string taskId
float t
bool missed
Deterministic phase-one RTS systems and their ECS contracts.
glm::mat4 view
V3 origin
Definition RoadBake.cpp:138
const RoadNode * node
bool found
int removed
double current
std::string resource
float dz
float dy
float dx
double splash
double gravity
float targetX
std::map< Cell, int > best
TacticalUnit * unit
Battle::Events events
std::vector< UnitCandidate > units
LogicalId definition
SimulationTick tick
std::size_t cursor
ecs::EntityHandle side
int spacing
bool visible
int limit
Definition TreeMesh.cpp:164
float separation
Definition TreeMesh.cpp:159
float step
Definition TreeMesh.cpp:314
float(ui::Theme::* member)[4]
std::map< std::string, std::span< const std::uint8_t > > members
uint32_t index
const UnitySourceAsset & source
std::uint32_t depth
int covered
glm::vec3 point
float vz
float wz
float wx
float vy
float vx
float angle
float wy
static Diagnostic error(DiagnosticCode code, std::string message, std::string path={}, DiagnosticDetails details={}, std::string source={})
Construct an error diagnostic with the standard error severity.
Definition Diagnostic.h:125
static Result< Duration > fromSeconds(double seconds)
Convert finite seconds to the nearest nanosecond.
Definition Time.cpp:16
Human-readable, scoped identifier in the form namespace:name.
Definition Identity.h:411
static std::optional< LogicalId > fromParts(std::string_view namespaceName, std::string_view name)
Builds a logical ID from separate namespace and name components.
Definition Identity.cpp:48
const std::string & format() const noexcept
Returns the canonical namespace:name representation.
Definition Identity.h:436
bool isValid() const noexcept
Returns whether this value contains a valid namespace and name.
Definition Identity.h:433
Move-only operation result carrying either a value or Status.
Definition Result.h:155
static Result success(T value)
Construct a successful result owning value.
Definition Result.h:164
void ignore(std::string_view reason={}) const noexcept
Explicitly discard this result after documenting the reason.
Definition Result.h:393
static Result failure(Status status)
Construct a failed result from a structured status.
Definition Result.h:175
Structured status and zero or more diagnostics for an operation.
Definition Status.h:68
static Status success(StatusCode code=StatusCode::Ok)
Construct a successful status with an explicit non-error outcome.
Definition Status.h:81
Strong, domain-neutral reference to a runtime subject.
Definition SubjectRef.h:26
bool isValid() const noexcept
Return whether this reference is valid and non-nil.
Definition SubjectRef.h:38
Owner-thread deterministic damage coordinator.
Definition Damage.h:125
Result< DamageAmounts > preview(const CombatState &target, const DamageRequest &request) const
Resolve provider damage amounts before settlement stages, without mutating the target.
Definition Damage.cpp:181
Result< settlement::SettlementResult > heal(CombatState &target, SubjectRef source, double amount) const
Resolve and atomically commit health restoration through the configured settlement pipeline.
Definition Damage.cpp:262
Result< DamageOutcome > apply(CombatState &target, const DamageRequest &request) const
Resolve and atomically commit one damage request.
Definition Damage.cpp:200
Dynamic field-of-view / fog-of-war facade. Phase A: 2D shadowcast + multi-revealer + explored memory....
Definition Fov.h:27
Ordered tile-index waypoints from start to goal (inclusive).
Definition Path.h:10
Pathfinding facade. Single-agent: A*. Group (same goal): Flow Field + follow. Grid/topology internals...
Definition Pathfinder.h:20
bool isWalkable(int x, int y) const
True when walkable.
Path * findPath(int sx, int sy, int gx, int gy)
A* from (sx,sy) to (gx,gy). Returns owned Path* (may be empty if unreachable). Never returns nullptr ...
static eve::Result< CostSpec > single(std::string_view resource, std::int64_t amount)
Build a one-resource cost.
static Result< std::size_t > step(const SimulationStep &step, const AIProductionRequest &requestProduction)
Think for enabled factions and submit production/attack decisions.
static Result< void > validate(Faction &faction, WorldPosition position, LogicalId definition, const PlacementValidation &placement, bool requireInfluence=true)
Validate one proposed building position without mutating either domain.
RTS building domain root with placement and production composition.
Definition RTSTypes.h:840
static Result< FanOutReceipt > fanOut(std::span< const ecs::EntityHandle > unitHandles, const CommandSpec &command, const FormationSpec &formation)
Submit one command to every selected live Unit.
static Result< std::size_t > step()
Synchronize combat guard flags and settle Stop orders immediately.
static bool isAllied(Faction *left, Faction *right) noexcept
Return true when both live factions are identical or share a team in a common Match.
Faction domain root; membership is a set of typed runtime handles.
Definition RTSTypes.h:1178
Interface isolating RTS orders from the shared action runtime.
Definition RTSAction.h:38
virtual Result< ActionExecutionResult > execute(Unit &unit, const OrderRecord &order, const SimulationStep &step)=0
Execute or advance one active RTS order.
static Result< std::size_t > step()
Complete arrived Move and AttackMove orders after their final path waypoint.
static Result< std::size_t > step(map::Pathfinder &pathfinder, const NavigationGrid &grid, const NavigationEvent &unreachable={})
Replan changed movement orders and advance reached waypoints.
Entity-local RTS effect component backed by the canonical adapter.
Definition RTSEffects.h:118
double multiplier(std::string_view field) const noexcept
Return a composed active status multiplier.
Definition RTSEffects.h:141
RTS impact adapter over the canonical pooled weapon projectile trajectory runtime.
Definition RTSSystems.h:653
Harvestable RTS resource node; deposited balances are owned by an external resource account.
Definition RTSTypes.h:1071
static Result< std::size_t > step(const combat::DamageRuntime *damage=nullptr, const SimulationStep &step={})
Follow protected entities, intercept local threats, and distribute group fire.
Entity-local sorted tags; this is the RTS entity's tag authority.
Definition RTSTypes.h:153
const std::vector< std::string > & values() const noexcept
Return tags in deterministic order.
Definition RTSTypes.h:162
RTS unit domain root.
Definition RTSTypes.h:495
Gameplay-facing 2D candidate query service; it never values or selects targets.
Definition Sensing.h:151
eve::Result< void > upsert(std::string_view id, float x, float y, std::string_view faction, std::string_view tagsCsv, std::string_view visibleToCsv)
Inserts or replaces mirrored facts. CSV fields contain comma-separated stable keys.
Definition Sensing.cpp:238
eve::Result< void > remove(std::string_view id)
Removes mirrored facts, or returns NotFound when the id is absent.
Definition Sensing.cpp:251
Owner-thread deterministic projectile simulator with a bounded reusable pool.
Result< void > restore(const ProjectileRuntimeSnapshot &snapshot)
Atomically replace this pool from a validated snapshot.
std::vector< ProjectileState > states() const
Return owning live states in stable slot order.
武器实体:数据全部在组件里,行为在 WeaponSystem。
static void update(WeaponEntity &w, float dt)
每帧推进:冷却 / 连发 / 装填 / 炮口旋转 / 逻辑自身。
static void cancelReload(WeaponEntity &w)
打断装填。
static bool tryFire(WeaponEntity &w, const FireRequest &req)
尝试开火;满足条件时扣弹药、调逻辑、推事件并返回 true。
static bool updateAim(WeaponEntity &w, float dt)
炮口朝目标旋转;返回本帧是否到位。
Result< T > applied(T value, std::vector< Diagnostic > diagnostics={})
Construct an Applied result with an owning payload and optional diagnostics.
Result< T > failed(Status status, eve::DiagnosticCode code, RuleId rule, std::string message)
Construct a failed editing result with explicit status and diagnostic categories.
void worldToCell(const GridConfig &cfg, float px, float py, int &cx, int &cy, int mapW, int mapH)
Maps planar coordinates to the nearest cell. Staggered/hex layouts use a bounded nearest-neighbor sea...
float distanceSquared(float ax, float ay, float bx, float by)
Result< std::optional< OrderRecord > > readCurrent(OrderComponent &orders)
bool isSameHandle(const ecs::EntityHandle &left, const ecs::EntityHandle &right)
std::optional< WorldPosition > entityPosition(const ecs::EntityHandle &handle)
bool isFinitePosition(WorldPosition position)
std::function< Result< resource::Receipt >(Unit &, const resource::CostSpec &)> AbilityResourceDebit
Debits an ability resource cost through the game-owned canonical economy boundary.
Definition RTSSystems.h:492
std::function< map::Fov *(Faction &)> FogProvider
Resolves the canonical FOV instance used by a faction; allies may deliberately share one instance.
Definition RTSSystems.h:310
std::span< const SystemContract > systemContracts() noexcept
Return the phase-one RTS ECS contracts for tooling and review.
std::function< Result< resource::Receipt >(Building &, const resource::CostSpec &)> PassiveIncomeCredit
Credits passive building income through the game-owned canonical economy boundary.
Definition RTSSystems.h:402
std::function< void(const LifecycleEvent &, SimulationTick)> LifecycleEventSink
Read-only observer invoked after a non-combat lifecycle transition commits.
Definition RTSSystems.h:127
std::function< Result< std::string >(Building &, std::string_view)> ReinforcementEnqueue
Game-owned transactional enqueue boundary used while resolving reinforcement fallbacks.
Definition RTSSystems.h:746
std::function< Result< resource::Receipt >(Unit &, const resource::CostSpec &)> ResourceCredit
Callback that credits gathered resources to the authoritative account selected by the game.
Definition RTSSystems.h:358
std::function< void(const CombatFireEvent &, SimulationTick)> CombatFireEventSink
Read-only observer invoked after a weapon consumes one authoritative shot.
Definition RTSSystems.h:91
std::function< Result< void >(WorldPosition, LogicalId)> PlacementValidation
Caller-owned canonical terrain/occupancy validation used after RTS influence checks.
Definition RTSSystems.h:183
OrderKind
Commands shared by movement, combat, construction and gathering.
Definition RTSTypes.h:64
EntityLink< FactionLinkTag > FactionLink
Definition RTSTypes.h:285
std::function< Result< std::optional< ProjectileCollision > >(WorldPosition from, float fromHeight, WorldPosition to, float toHeight, SubjectRef source, ecs::EntityHandle intendedTarget)> ProjectileCollisionQuery
Map/physics-owned swept collision query; empty means the segment remained clear.
Definition RTSSystems.h:620
std::function< void(const combat::DamageRequest &request, const combat::DamageOutcome &outcome, SimulationTick tick, DamageChannel channel)> DamageEventSink
Read-only observer invoked after canonical damage has committed.
Definition RTSSystems.h:71
std::function< void(Unit &, const OrderRecord &)> NavigationEvent
Notification emitted once when a newly planned order has no canonical map route.
Definition RTSSystems.h:270
std::function< Result< void >(Faction &, Building &, const LogicalId &)> AIProductionRequest
Authoritative production boundary invoked by AI policy decisions.
Definition RTSSystems.h:571
std::function< Result< bool >(WorldPosition origin, WorldPosition target, ecs::EntityHandle source, ecs::EntityHandle targetEntity, const weapon::WeaponDefinition &weapon)> FireLineQuery
Uses canonical sensing, weapon and damage providers for RTS automatic fire.
Definition RTSSystems.h:584
std::function< CombatHeightProfile(WorldPosition origin, WorldPosition target, ecs::EntityHandle source, ecs::EntityHandle targetEntity)> CombatHeightQuery
Resolve absolute heights for one authorized projectile launch.
Definition RTSSystems.h:589
std::function< Result< resource::Receipt >(Unit &, Building &, const resource::CostSpec &)> RepairDebit
Callback that debits repair costs from the authoritative account selected by the game.
Definition RTSSystems.h:383
std::function< Result< std::size_t >(Building &, std::string_view, std::int64_t, std::size_t)> AmmoProductionPurchase
Assigns and transfers tactical ammunition through canonical WeaponEntity resource state.
Definition RTSSystems.h:425
std::function< Result< void >(Building &, std::string_view)> ReinforcementCancel
Pauses canonical production tasks when a shared rally combat group reaches reinforcement limits.
Definition RTSSystems.h:744
std::function< ecs::Entity *(SubjectRef)> ProjectileSubjectResolver
Resolve one stable RTS subject while restoring projectile relationships.
Definition RTSSystems.h:650
::eve::SubjectRef SubjectRef
Strong reference to a runtime subject.
Definition Targeting.h:76
@ TruncateToMax
Keep the best maxCount candidates after sorting (legacy circle/box limit).
Result< AbilityReceipt > validateAbility(Battle &battle, SubjectRef actor, const LogicalId &action, Cell targetCell, SubjectRef targetUnit, std::string_view payload)
Validate one ability declaration without mutating anything.
WidgetDesc progress(float fraction, std::string id, std::string overlay)
Progress bar; fraction is clamped to [0,1].
Definition Widget.cpp:395
WidgetDesc grid(int columns, std::vector< WidgetDesc > children, std::string id)
Fixed-column grid; children are placed in source order.
Definition Widget.cpp:687
StatusCode
Stable outcome category for an operation.
Definition Status.h:27
detail::StrongUint64< detail::SimulationTickTag > SimulationTick
Deterministic simulation time step; it is not wall-clock time.
Definition Time.h:31
bool enabled
One deterministic fixed-step emitted by SimulationClock.
Definition Time.h:158
Mutable combat state owned by the gameplay domain for one subject.
Definition Damage.h:37
Owning input for one damage transaction.
Definition Damage.h:49
A subject-agnostic continuous task retained for audit and save games.
Definition Production.h:158
Typed ability policy; lifecycle effects remain owned by the canonical effect container.
Definition RTSTypes.h:133
std::int64_t resourceCost
Definition RTSTypes.h:142
std::string resourceType
Definition RTSTypes.h:137
Deterministic, canonical-map-validated artillery relocation choice.
Definition RTSSystems.h:520
Deterministic contested capture state.
Definition RTSTypes.h:920
Automatic targeting policy for a stationary defensive building.
Definition RTSTypes.h:958
Static command provider and hostile jamming policy.
Definition RTSTypes.h:1016
Construction progress and generation-checked assigned builders.
Definition RTSTypes.h:897
Logical building definition identity.
Definition RTSTypes.h:855
Lifecycle-only effects.
Definition RTSTypes.h:885
Owning faction link.
Definition RTSTypes.h:893
Building garrison capacity and generation-checked occupants.
Definition RTSTypes.h:973
Runtime identity.
Definition RTSTypes.h:849
Last indirect-fire exposure for static artillery and counter-battery observation.
Definition RTSTypes.h:1025
RTS infrastructure projection; economy balances remain owned by the linked provider.
Definition RTSTypes.h:1005
Building health and repair economy configuration.
Definition RTSTypes.h:904
Generic building orders.
Definition RTSTypes.h:877
Placement world key and authoritative cell state.
Definition RTSTypes.h:859
Canonical production queue.
Definition RTSTypes.h:869
Rally order copied to newly produced units.
Definition RTSTypes.h:933
RTS shield layer for structures; canonical CombatState remains health authority.
Definition RTSTypes.h:912
Static ammunition stock and transfer policy for completed buildings.
Definition RTSTypes.h:979
Building-local tags.
Definition RTSTypes.h:881
Static vision contribution projected into a faction-owned canonical map FOV provider.
Definition RTSTypes.h:991
Canonical weapon entity used by an armed building.
Definition RTSTypes.h:954
Adapter-owned keys previously mirrored into a shared sensing world.
Definition RTSSystems.h:595
Absolute muzzle and target heights resolved by the authoritative map/game adapter.
Definition RTSSystems.h:586
One domain-neutral command submitted to a unit's generic order queue.
Definition RTSTypes.h:101
ecs::EntityHandle targetEntity
Definition RTSTypes.h:107
WorldPosition target
Definition RTSTypes.h:103
WorldPosition secondaryTarget
Definition RTSTypes.h:108
Runtime-only revealer bindings; authoritative explored cells remain owned by map::Fov.
Definition RTSSystems.h:316
Input to the pure formation planner.
Definition RTSSystems.h:34
World/grid conversion used when an RTS composition consumes a canonical map Pathfinder.
Definition RTSSystems.h:263
Complete in-flight RTS projectile state with stable gameplay relationships.
Definition RTSSystems.h:644
std::vector< RTSProjectilePayloadSnapshot > payloads
Definition RTSSystems.h:646
weapon::ProjectileRuntimeSnapshot runtime
Definition RTSSystems.h:645
Concurrent worker capacity and current generation-checked assignments.
Definition RTSTypes.h:1099
Runtime and persistent identity.
Definition RTSTypes.h:1080
Authoritative world position and interaction radius.
Definition RTSTypes.h:1086
Authoritative remaining resource stock.
Definition RTSTypes.h:1092
A safe map-reachable rendezvous selected around a predicted moving relay intercept.
Definition RTSSystems.h:446
Static metadata for one ECS system's access and phase contract.
Definition RTSSystems.h:56
Indirect-fire deployment and deterministic area-fire policy.
Definition RTSTypes.h:740
RTS stance and pursuit limits used by automatic combat policies.
Definition RTSTypes.h:610
Command-network policy and derived connection state for a mobile unit or relay.
Definition RTSTypes.h:655
Unit containment state for transports and building garrisons.
Definition RTSTypes.h:693
Logical unit definition identity.
Definition RTSTypes.h:510
Canonical combat durability state consumed by combat::DamageRuntime.
Definition RTSTypes.h:631
Lifecycle-only active effects.
Definition RTSTypes.h:522
Owning faction link.
Definition RTSTypes.h:556
Runtime identity and logical unit definition.
Definition RTSTypes.h:504
Suppression, recovery, retreat policy, and friendly morale aura.
Definition RTSTypes.h:724
Authoritative kinematic state used by the phase-one move slice.
Definition RTSTypes.h:560
RTS projection of a canonical map path; the map provider remains the grid authority.
Definition RTSTypes.h:569
Generic command/order state.
Definition RTSTypes.h:526
RTS shield layer consumed before canonical health damage and regenerated deterministically.
Definition RTSTypes.h:636
Tactical ammunition logistics; weapon ammunition remains authoritative in WeaponEntity.
Definition RTSTypes.h:699
Escort geometry and combat-group coordination state.
Definition RTSTypes.h:764
Entity-local tags.
Definition RTSTypes.h:518
Vision contribution projected into a faction-owned canonical map FOV provider.
Definition RTSTypes.h:586
Weapon entity link.
Definition RTSTypes.h:544
Worker gathering and delivery state; balances remain owned by the linked economy account.
Definition RTSTypes.h:598
Two-dimensional deterministic RTS world position.
Definition RTSTypes.h:48
Circle shape for QuerySpec (World2D).
Definition Sensing.h:58
World2D origin used for range tests and distance sorting.
Definition Sensing.h:117
Configurable candidate query against SensingWorld.
Definition Sensing.h:98
CountPolicy countPolicy
Definition Sensing.h:109
std::vector< std::string > excludeFactions
Definition Sensing.h:105
std::vector< std::string > requiredTags
Definition Sensing.h:102
std::uint32_t maxCount
Definition Sensing.h:108
std::string id
Definition Widget.h:17
Data-driven trajectory and lifetime definition.
std::vector< ProjectileSlotSnapshot > slots
Owning spawn request; homing requests require a generation-checked target.
武器模板(registerWeaponsFromJson 注册,进程级注册表)。