From 6c92ab50c8094c529aefe51d17bcb01f05dcfc73 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Tue, 7 Apr 2026 14:16:59 -0400 Subject: [PATCH] Complete universe model migration --- .../Factions/AI/CommanderPlanningService.cs | 31 +- .../Geopolitics/Contracts/Geopolitics.cs | 2 +- .../Runtime/GeopoliticalRuntimeModels.cs | 2 +- .../GeopoliticalSimulationService.cs | 2 +- .../Planning/FactionIndustryPlanner.cs | 106 ++--- .../Api/GetPlayerIdentitiesHandler.cs | 17 +- .../PlayerFaction/Contracts/PlayerFaction.cs | 2 +- .../Contracts/PlayerFactionCommands.cs | 2 +- .../Runtime/PlayerFactionRuntimeModels.cs | 2 +- .../Simulation/IPlayerStateStore.cs | 1 + .../PlayerFactionProjectionService.cs | 4 +- .../Simulation/PlayerFactionService.cs | 26 +- .../Simulation/PlayerStateStore.cs | 3 + .../backend/Shared/Runtime/SimulationKinds.cs | 2 + .../Ships/AI/ShipAiService.BehaviorQueue.cs | 11 +- .../Ships/AI/ShipAiService.Execution.cs | 210 ++++++--- .../backend/Ships/AI/ShipAiService.Helpers.cs | 233 ++++++++- .../AI/ShipAiService.Planning.Behaviors.cs | 12 +- .../Ships/AI/ShipAiService.Planning.Orders.cs | 39 +- apps/backend/Ships/Contracts/ShipCommands.cs | 6 +- apps/backend/Ships/Contracts/Ships.cs | 24 +- .../Ships/Runtime/ShipRuntimeModels.cs | 10 +- .../Simulation/Core/SimulationEngine.cs | 6 +- .../Core/SimulationProjectionService.cs | 149 ++++-- .../Stations/Contracts/Infrastructure.cs | 12 +- .../Runtime/ConstructionRuntimeModels.cs | 4 +- .../Stations/Runtime/StationRuntimeModels.cs | 2 +- .../Simulation/StationLifecycleService.cs | 2 +- .../Universe/Api/StreamWorldHandler.cs | 4 +- apps/backend/Universe/Contracts/Celestial.cs | 44 +- apps/backend/Universe/Contracts/World.cs | 4 +- .../Universe/Runtime/SimulationWorld.cs | 1 + .../Universe/Runtime/SpatialRuntimeModels.cs | 39 +- .../Scenario/ScenarioContentBuilder.cs | 28 +- .../Universe/Scenario/SpatialBuilder.cs | 177 +++++-- .../Scenario/WorldRuntimeAssembler.cs | 3 +- .../Universe/Scenario/WorldSeedingService.cs | 24 +- .../Simulation/OrbitalStateUpdater.cs | 66 ++- .../Universe/Simulation/WorldService.cs | 53 ++- apps/viewer/src/App.vue | 91 ++-- apps/viewer/src/ViewerAppController.ts | 8 +- apps/viewer/src/api.ts | 6 +- .../components/ViewerEntityBrowserPanel.vue | 32 +- .../components/ViewerEntityInspectorPanel.vue | 124 +++-- .../components/ViewerShipOrderContextMenu.vue | 8 +- apps/viewer/src/components/gm/GmOpsWindow.vue | 26 +- .../components/gm/GmPlayerFactionPanel.vue | 16 +- apps/viewer/src/contracts.ts | 3 + apps/viewer/src/contractsCelestial.ts | 28 +- apps/viewer/src/contractsGeopolitics.ts | 2 +- apps/viewer/src/contractsInfrastructure.ts | 6 +- apps/viewer/src/contractsPlayerFaction.ts | 2 +- apps/viewer/src/contractsShips.ts | 21 +- apps/viewer/src/contractsWorld.ts | 6 +- apps/viewer/src/playerFactionCommands.ts | 2 +- apps/viewer/src/shipCommands.ts | 4 +- apps/viewer/src/styles/viewer.css | 141 ++++++ .../src/ui/stores/viewerOrderContextMenu.ts | 1 + apps/viewer/src/viewerCamera.ts | 24 +- apps/viewer/src/viewerControllerFactory.ts | 19 +- apps/viewer/src/viewerControls.ts | 8 +- apps/viewer/src/viewerInteraction.ts | 15 +- .../viewer/src/viewerInteractionController.ts | 9 + apps/viewer/src/viewerLocalLayer.ts | 35 +- apps/viewer/src/viewerNavigationController.ts | 23 +- apps/viewer/src/viewerPanels.ts | 31 +- apps/viewer/src/viewerSceneDataController.ts | 199 +++++++- apps/viewer/src/viewerSceneFactory.ts | 18 + apps/viewer/src/viewerSceneSync.ts | 42 +- apps/viewer/src/viewerSelection.ts | 196 ++++++-- apps/viewer/src/viewerState.ts | 5 + apps/viewer/src/viewerTypes.ts | 11 +- apps/viewer/src/viewerWorldLifecycle.ts | 14 +- apps/viewer/src/viewerWorldPresentation.ts | 147 +++++- apps/viewer/vite.config.ts | 2 +- docs/UNIVERSE-MODEL-MIGRATION-WORKSHEET.md | 443 ------------------ 76 files changed, 2061 insertions(+), 1072 deletions(-) delete mode 100644 docs/UNIVERSE-MODEL-MIGRATION-WORKSHEET.md diff --git a/apps/backend/Factions/AI/CommanderPlanningService.cs b/apps/backend/Factions/AI/CommanderPlanningService.cs index 4c439be..50c701b 100644 --- a/apps/backend/Factions/AI/CommanderPlanningService.cs +++ b/apps/backend/Factions/AI/CommanderPlanningService.cs @@ -1097,14 +1097,14 @@ internal sealed class CommanderPlanningService { theaters.Add(new FactionTheaterRuntime { - Id = $"theater-expansion-{expansionProject.SystemId}-{expansionProject.CelestialId}", + Id = $"theater-expansion-{expansionProject.SystemId}-{expansionProject.AnchorId}", Kind = "expansion-front", SystemId = expansionProject.SystemId, Status = "active", Priority = 65f + (economicAssessment.HasShipyard ? 0f : 15f), SupplyRisk = ComputeSystemRisk(world, faction, expansionProject.SystemId), FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, expansionProject.SystemId), - AnchorEntityId = expansionProject.SiteId ?? expansionProject.CelestialId, + AnchorEntityId = expansionProject.SiteId ?? expansionProject.AnchorId, AnchorPosition = ResolveExpansionAnchor(world, expansionProject), UpdatedAtUtc = nowUtc, }); @@ -1272,7 +1272,7 @@ internal sealed class CommanderPlanningService ], "expansion" => [ - new FactionPlanStepRuntime { Id = $"{campaign.Id}-claim", Kind = "secure-claim", Status = "active", Summary = $"Secure {expansionProject?.CelestialId ?? campaign.TargetEntityId} for construction." }, + new FactionPlanStepRuntime { Id = $"{campaign.Id}-claim", Kind = "secure-claim", Status = "active", Summary = $"Secure {expansionProject?.AnchorId ?? campaign.TargetEntityId} for construction." }, new FactionPlanStepRuntime { Id = $"{campaign.Id}-supply", Kind = "supply-site", Status = "planned", Summary = "Move construction materials to the site." }, new FactionPlanStepRuntime { Id = $"{campaign.Id}-guard", Kind = "guard-site", Status = "planned", Summary = "Defend the expansion site until operational." }, ], @@ -2725,7 +2725,7 @@ internal sealed class CommanderPlanningService AreaSystemId = areaSystemId, TargetEntityId = objective.TargetEntityId, ItemId = objective.ItemId ?? fallback.ItemId, - PreferredNodeId = fallback.PreferredNodeId, + PreferredAnchorId = fallback.PreferredAnchorId, PreferredConstructionSiteId = objective.Kind is "construct-site" or "supply-site" ? objective.TargetEntityId : fallback.PreferredConstructionSiteId, PreferredModuleId = fallback.PreferredModuleId, TargetPosition = objective.TargetPosition ?? fallback.TargetPosition, @@ -2750,7 +2750,7 @@ internal sealed class CommanderPlanningService target.AreaSystemId = source.AreaSystemId; target.TargetEntityId = source.TargetEntityId; target.ItemId = source.ItemId; - target.PreferredNodeId = source.PreferredNodeId; + target.PreferredAnchorId = source.PreferredAnchorId; target.PreferredConstructionSiteId = source.PreferredConstructionSiteId; target.PreferredModuleId = source.PreferredModuleId; target.TargetPosition = source.TargetPosition; @@ -2771,7 +2771,7 @@ internal sealed class CommanderPlanningService && string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal) && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) - && string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal) + && string.Equals(left.PreferredAnchorId, right.PreferredAnchorId, StringComparison.Ordinal) && string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal) && string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal) && Nullable.Equals(left.TargetPosition, right.TargetPosition) @@ -2792,7 +2792,7 @@ internal sealed class CommanderPlanningService && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) - && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) + && string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal) && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) && left.WaitSeconds.Equals(right.WaitSeconds) @@ -2863,9 +2863,10 @@ internal sealed class CommanderPlanningService } var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId); - if (site?.CelestialId is { } celestialId) + if (site is not null) { - return world.Celestials.FirstOrDefault(celestial => celestial.Id == celestialId)?.Position; + return world.Anchors.FirstOrDefault(anchor => anchor.Id == site.AnchorId)?.Position + ?? Vector3.Zero; } return null; @@ -2919,7 +2920,7 @@ internal sealed class CommanderPlanningService && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) - && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) + && string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal) && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) && left.WaitSeconds.Equals(right.WaitSeconds) @@ -3382,7 +3383,7 @@ internal sealed class CommanderPlanningService { "defense-front" => $"Defend {theater.SystemId} from hostile pressure.", "offense-front" => $"Project force into {theater.SystemId}.", - "expansion-front" => $"Expand into {expansionProject?.CelestialId ?? theater.SystemId}.", + "expansion-front" => $"Expand into {expansionProject?.AnchorId ?? theater.SystemId}.", "economic-front" => $"Stabilize commodity shortages around {theater.AnchorEntityId ?? theater.SystemId}.", _ => theater.Kind, }; @@ -3424,13 +3425,13 @@ internal sealed class CommanderPlanningService private static Vector3 ResolveExpansionAnchor(SimulationWorld world, IndustryExpansionProject project) { if (project.SiteId is not null - && world.ConstructionSites.FirstOrDefault(site => site.Id == project.SiteId) is { } site - && world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId) is { } siteCelestial) + && world.ConstructionSites.FirstOrDefault(site => site.Id == project.SiteId) is { } site) { - return siteCelestial.Position; + return world.Anchors.FirstOrDefault(candidate => candidate.Id == site.AnchorId)?.Position + ?? Vector3.Zero; } - return world.Celestials.FirstOrDefault(candidate => candidate.Id == project.CelestialId)?.Position + return world.Anchors.FirstOrDefault(candidate => candidate.Id == project.AnchorId)?.Position ?? ResolveSystemAnchor(world, project.SystemId); } diff --git a/apps/backend/Geopolitics/Contracts/Geopolitics.cs b/apps/backend/Geopolitics/Contracts/Geopolitics.cs index 1bf4f73..698f2e1 100644 --- a/apps/backend/Geopolitics/Contracts/Geopolitics.cs +++ b/apps/backend/Geopolitics/Contracts/Geopolitics.cs @@ -88,7 +88,7 @@ public sealed record TerritoryClaimSnapshot( string? SourceClaimId, string FactionId, string SystemId, - string CelestialId, + string AnchorId, string Status, string ClaimKind, float ClaimStrength, diff --git a/apps/backend/Geopolitics/Runtime/GeopoliticalRuntimeModels.cs b/apps/backend/Geopolitics/Runtime/GeopoliticalRuntimeModels.cs index c914ab4..adfd3c0 100644 --- a/apps/backend/Geopolitics/Runtime/GeopoliticalRuntimeModels.cs +++ b/apps/backend/Geopolitics/Runtime/GeopoliticalRuntimeModels.cs @@ -126,7 +126,7 @@ public sealed class TerritoryClaimRuntime public string? SourceClaimId { get; set; } public required string FactionId { get; set; } public required string SystemId { get; set; } - public required string CelestialId { get; set; } + public required string AnchorId { get; set; } public string Status { get; set; } = "active"; public string ClaimKind { get; set; } = "infrastructure"; public float ClaimStrength { get; set; } diff --git a/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs b/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs index 5e4fe0a..0f50e58 100644 --- a/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs +++ b/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs @@ -161,7 +161,7 @@ internal sealed class GeopoliticalSimulationService SourceClaimId = claim.Id, FactionId = claim.FactionId, SystemId = claim.SystemId, - CelestialId = claim.CelestialId, + AnchorId = claim.AnchorId, Status = claim.State, ClaimKind = "infrastructure", ClaimStrength = claim.State == ClaimStateKinds.Active ? 1f : 0.65f, diff --git a/apps/backend/Industry/Planning/FactionIndustryPlanner.cs b/apps/backend/Industry/Planning/FactionIndustryPlanner.cs index 9b11db9..842e133 100644 --- a/apps/backend/Industry/Planning/FactionIndustryPlanner.cs +++ b/apps/backend/Industry/Planning/FactionIndustryPlanner.cs @@ -21,13 +21,13 @@ internal static class FactionIndustryPlanner return null; } - var targetCelestial = SelectFoundationCelestial(world, factionId, bottleneckCommodity); - if (targetCelestial is null) + var targetAnchor = SelectFoundationAnchor(world, factionId, bottleneckCommodity); + if (targetAnchor is null) { return null; } - var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId); + var supportStation = SelectSupportStation(world, factionId, moduleId, targetAnchor.SystemId); if (supportStation is null) { return null; @@ -36,8 +36,8 @@ internal static class FactionIndustryPlanner return new IndustryExpansionProject( bottleneckCommodity, moduleId, - targetCelestial.SystemId, - targetCelestial.Id, + targetAnchor.SystemId, + targetAnchor.Id, supportStation.Id); } @@ -93,13 +93,13 @@ internal static class FactionIndustryPlanner return null; } - var targetCelestial = SelectLogisticsFoundationCelestial(world, factionId); - if (targetCelestial is null) + var targetAnchor = SelectLogisticsFoundationAnchor(world, factionId); + if (targetAnchor is null) { return null; } - var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetCelestial.SystemId); + var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetAnchor.SystemId); if (supportStation is null) { return null; @@ -108,8 +108,8 @@ internal static class FactionIndustryPlanner return new IndustryExpansionProject( "shipyard", shipyardModuleId, - targetCelestial.SystemId, - targetCelestial.Id, + targetAnchor.SystemId, + targetAnchor.Id, supportStation.Id); } @@ -129,13 +129,13 @@ internal static class FactionIndustryPlanner return null; } - var bootstrapCelestial = SelectFoundationCelestial(world, factionId, bootstrapCommodity); - if (bootstrapCelestial is null) + var bootstrapAnchor = SelectFoundationAnchor(world, factionId, bootstrapCommodity); + if (bootstrapAnchor is null) { return null; } - var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapCelestial.SystemId); + var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapAnchor.SystemId); if (bootstrapSupportStation is null) { return null; @@ -144,8 +144,8 @@ internal static class FactionIndustryPlanner return new IndustryExpansionProject( bootstrapCommodity, bootstrapModuleId, - bootstrapCelestial.SystemId, - bootstrapCelestial.Id, + bootstrapAnchor.SystemId, + bootstrapAnchor.Id, bootstrapSupportStation.Id); } @@ -161,13 +161,13 @@ internal static class FactionIndustryPlanner return null; } - var targetCelestial = SelectFoundationCelestial(world, factionId, commodityId); - if (targetCelestial is null) + var targetAnchor = SelectFoundationAnchor(world, factionId, commodityId); + if (targetAnchor is null) { return null; } - var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId); + var supportStation = SelectSupportStation(world, factionId, moduleId, targetAnchor.SystemId); if (supportStation is null) { return null; @@ -176,8 +176,8 @@ internal static class FactionIndustryPlanner return new IndustryExpansionProject( commodityId, moduleId, - targetCelestial.SystemId, - targetCelestial.Id, + targetAnchor.SystemId, + targetAnchor.Id, supportStation.Id); } @@ -207,7 +207,7 @@ internal static class FactionIndustryPlanner site.TargetDefinitionId, site.BlueprintId, site.SystemId, - site.CelestialId, + site.AnchorId, supportStationId, site.Id); } @@ -225,7 +225,7 @@ internal static class FactionIndustryPlanner } var nowUtc = DateTimeOffset.UtcNow; - var claimId = $"claim-{factionId}-{project.CelestialId}"; + var claimId = $"claim-{factionId}-{project.AnchorId}"; if (world.Claims.All(candidate => candidate.Id != claimId)) { world.Claims.Add(new ClaimRuntime @@ -233,7 +233,7 @@ internal static class FactionIndustryPlanner Id = claimId, FactionId = factionId, SystemId = project.SystemId, - CelestialId = project.CelestialId, + AnchorId = project.AnchorId, PlacedAtUtc = nowUtc, ActivatesAtUtc = nowUtc.AddSeconds(8), State = ClaimStateKinds.Activating, @@ -246,7 +246,7 @@ internal static class FactionIndustryPlanner return; } - var siteId = $"site-{factionId}-{project.CelestialId}"; + var siteId = $"site-{factionId}-{project.AnchorId}"; if (world.ConstructionSites.Any(candidate => candidate.Id == siteId)) { return; @@ -257,7 +257,7 @@ internal static class FactionIndustryPlanner Id = siteId, FactionId = factionId, SystemId = project.SystemId, - CelestialId = project.CelestialId, + AnchorId = project.AnchorId, TargetKind = "station-foundation", TargetDefinitionId = project.CommodityId, BlueprintId = project.ModuleId, @@ -450,51 +450,51 @@ internal static class FactionIndustryPlanner private static float GetTargetLevelSeconds(string itemId) => string.Equals(itemId, "water", StringComparison.Ordinal) ? WaterTargetLevelSeconds : CommodityTargetLevelSeconds; - private static CelestialRuntime? SelectFoundationCelestial(SimulationWorld world, string factionId, string commodityId) + private static AnchorRuntime? SelectFoundationAnchor(SimulationWorld world, string factionId, string commodityId) { var resourceItems = ResolveRootResourceItems(world, commodityId); - return world.Celestials - .Where(celestial => - celestial.Kind == SpatialNodeKind.LagrangePoint - && celestial.OccupyingStructureId is null - && world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed) - && IsExpansionSystemEligible(world, factionId, celestial.SystemId)) - .OrderByDescending(celestial => ScoreCelestial(world, factionId, celestial, resourceItems)) + return world.Anchors + .Where(anchor => + anchor.Kind == SpatialNodeKind.LagrangePoint + && anchor.OccupyingStructureId is null + && world.Claims.All(claim => claim.AnchorId != anchor.Id || claim.State == ClaimStateKinds.Destroyed) + && IsExpansionSystemEligible(world, factionId, anchor.SystemId)) + .OrderByDescending(anchor => ScoreAnchor(world, factionId, anchor, resourceItems)) .FirstOrDefault(); } - private static CelestialRuntime? SelectLogisticsFoundationCelestial(SimulationWorld world, string factionId) + private static AnchorRuntime? SelectLogisticsFoundationAnchor(SimulationWorld world, string factionId) { - return world.Celestials - .Where(celestial => - celestial.Kind == SpatialNodeKind.LagrangePoint - && celestial.OccupyingStructureId is null - && world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed) - && IsExpansionSystemEligible(world, factionId, celestial.SystemId)) - .OrderByDescending(celestial => world.Stations.Count(station => + return world.Anchors + .Where(anchor => + anchor.Kind == SpatialNodeKind.LagrangePoint + && anchor.OccupyingStructureId is null + && world.Claims.All(claim => claim.AnchorId != anchor.Id || claim.State == ClaimStateKinds.Destroyed) + && IsExpansionSystemEligible(world, factionId, anchor.SystemId)) + .OrderByDescending(anchor => world.Stations.Count(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal) - && string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal))) - .ThenByDescending(celestial => world.Stations + && string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal))) + .ThenByDescending(anchor => world.Stations .Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal) - && string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)) + && string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal)) .Sum(station => station.Inventory.Values.Sum())) .FirstOrDefault(); } - private static float ScoreCelestial(SimulationWorld world, string factionId, CelestialRuntime celestial, IReadOnlyCollection resourceItems) + private static float ScoreAnchor(SimulationWorld world, string factionId, AnchorRuntime anchor, IReadOnlyCollection resourceItems) { var resourceScore = world.Nodes - .Where(node => node.SystemId == celestial.SystemId && resourceItems.Contains(node.ItemId, StringComparer.Ordinal)) + .Where(node => node.SystemId == anchor.SystemId && resourceItems.Contains(node.ItemId, StringComparer.Ordinal)) .Sum(node => node.OreRemaining); var factionPresence = world.Stations.Count(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal) - && string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)); - var controlState = GeopoliticalSimulationService.GetSystemControlState(world, celestial.SystemId); - var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, celestial.SystemId); - var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == celestial.SystemId); + && string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal)); + var controlState = GeopoliticalSimulationService.GetSystemControlState(world, anchor.SystemId); + var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, anchor.SystemId); + var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == anchor.SystemId); var pressure = world.Geopolitics?.Territory.Pressures - .Where(entry => entry.SystemId == celestial.SystemId && entry.FactionId == factionId) + .Where(entry => entry.SystemId == anchor.SystemId && entry.FactionId == factionId) .OrderByDescending(entry => entry.HostileInfluence) .ThenBy(entry => entry.Id, StringComparer.Ordinal) .FirstOrDefault(); @@ -515,7 +515,7 @@ internal static class FactionIndustryPlanner }; var securityPenalty = ((pressure?.HostileInfluence ?? 0f) * 14f) + ((strategicProfile?.TerritorialPressure ?? 0f) * 9f) - + ((world.Geopolitics is null ? 0f : GeopoliticalSimulationService.GetSystemRouteRisk(world, celestial.SystemId, factionId)) * 250f); + + ((world.Geopolitics is null ? 0f : GeopoliticalSimulationService.GetSystemRouteRisk(world, anchor.SystemId, factionId)) * 250f); return resourceScore + (factionPresence * 5_000f) + controlBias @@ -585,6 +585,6 @@ internal sealed record IndustryExpansionProject( string CommodityId, string ModuleId, string SystemId, - string CelestialId, + string AnchorId, string SupportStationId, string? SiteId = null); diff --git a/apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs b/apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs index de9df10..1f358f8 100644 --- a/apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs +++ b/apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs @@ -17,16 +17,15 @@ public sealed class GetPlayerIdentitiesHandler(IAuthRepository authRepository, I public override async Task HandleAsync(CancellationToken cancellationToken) { var users = await authRepository.ListUsersAsync(cancellationToken); - var playerFactionsById = playerStateStore.GetPlayerFactions() - .ToDictionary(player => player.Id, StringComparer.Ordinal); + var playerFactionsByPlayerId = playerStateStore.GetPlayerFactionsByPlayerId(); - var responses = new List(users.Count + playerFactionsById.Count); + var responses = new List(users.Count + playerFactionsByPlayerId.Count); var seenIds = new HashSet(StringComparer.Ordinal); foreach (var user in users) { var userId = user.Id.ToString("N"); - playerFactionsById.TryGetValue(userId, out var playerFaction); + playerFactionsByPlayerId.TryGetValue(userId, out var playerFaction); responses.Add(new PlayerIdentitySummaryResponse( userId, user.Email, @@ -38,19 +37,19 @@ public sealed class GetPlayerIdentitiesHandler(IAuthRepository authRepository, I seenIds.Add(userId); } - foreach (var playerFaction in playerStateStore.GetPlayerFactions()) + foreach (var (playerId, playerFaction) in playerFactionsByPlayerId) { - if (!seenIds.Add(playerFaction.Id)) + if (!seenIds.Add(playerId)) { continue; } responses.Add(new PlayerIdentitySummaryResponse( - playerFaction.Id, - $"{playerFaction.Id}@unknown", + playerId, + $"{playerId}@unknown", Array.Empty(), true, - playerFaction.Id, + playerId, playerFaction.Label, playerFaction.SovereignFactionId)); } diff --git a/apps/backend/PlayerFaction/Contracts/PlayerFaction.cs b/apps/backend/PlayerFaction/Contracts/PlayerFaction.cs index 8cb2704..dce0aa3 100644 --- a/apps/backend/PlayerFaction/Contracts/PlayerFaction.cs +++ b/apps/backend/PlayerFaction/Contracts/PlayerFaction.cs @@ -194,7 +194,7 @@ public sealed record PlayerDirectiveSnapshot( bool UseOrders, string? StagingOrderKind, string? ItemId, - string? PreferredNodeId, + string? PreferredAnchorId, string? PreferredConstructionSiteId, string? PreferredModuleId, int Priority, diff --git a/apps/backend/PlayerFaction/Contracts/PlayerFactionCommands.cs b/apps/backend/PlayerFaction/Contracts/PlayerFactionCommands.cs index e40daa7..df42196 100644 --- a/apps/backend/PlayerFaction/Contracts/PlayerFactionCommands.cs +++ b/apps/backend/PlayerFaction/Contracts/PlayerFactionCommands.cs @@ -45,7 +45,7 @@ public sealed record PlayerDirectiveCommandRequest( string? SourceStationId, string? DestinationStationId, string? ItemId, - string? PreferredNodeId, + string? PreferredAnchorId, string? PreferredConstructionSiteId, string? PreferredModuleId, int Priority, diff --git a/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs b/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs index 9ae0f3a..bcc6033 100644 --- a/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs +++ b/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs @@ -251,7 +251,7 @@ public sealed class PlayerDirectiveRuntime public bool UseOrders { get; set; } public string? StagingOrderKind { get; set; } public string? ItemId { get; set; } - public string? PreferredNodeId { get; set; } + public string? PreferredAnchorId { get; set; } public string? PreferredConstructionSiteId { get; set; } public string? PreferredModuleId { get; set; } public int Priority { get; set; } = 50; diff --git a/apps/backend/PlayerFaction/Simulation/IPlayerStateStore.cs b/apps/backend/PlayerFaction/Simulation/IPlayerStateStore.cs index 56b3e45..a13a87f 100644 --- a/apps/backend/PlayerFaction/Simulation/IPlayerStateStore.cs +++ b/apps/backend/PlayerFaction/Simulation/IPlayerStateStore.cs @@ -5,5 +5,6 @@ public interface IPlayerStateStore bool TryGetPlayerFaction(string playerId, out PlayerFactionRuntime playerFaction); PlayerFactionRuntime GetOrAddPlayerFaction(string playerId, Func factory); IReadOnlyCollection GetPlayerFactions(); + IReadOnlyDictionary GetPlayerFactionsByPlayerId(); void Clear(); } diff --git a/apps/backend/PlayerFaction/Simulation/PlayerFactionProjectionService.cs b/apps/backend/PlayerFaction/Simulation/PlayerFactionProjectionService.cs index 1cb0d9c..04980ab 100644 --- a/apps/backend/PlayerFaction/Simulation/PlayerFactionProjectionService.cs +++ b/apps/backend/PlayerFaction/Simulation/PlayerFactionProjectionService.cs @@ -201,7 +201,7 @@ public sealed class PlayerFactionProjectionService directive.UseOrders, directive.StagingOrderKind, directive.ItemId, - directive.PreferredNodeId, + directive.PreferredAnchorId, directive.PreferredConstructionSiteId, directive.PreferredModuleId, directive.Priority, @@ -261,7 +261,7 @@ public sealed class PlayerFactionProjectionService template.SourceStationId, template.DestinationStationId, template.ItemId, - template.NodeId, + template.AnchorId, template.ConstructionSiteId, template.ModuleId, template.WaitSeconds, diff --git a/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs b/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs index b37143b..c80b971 100644 --- a/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs +++ b/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs @@ -329,7 +329,7 @@ internal sealed class PlayerFactionService directive.SourceStationId = request.SourceStationId; directive.DestinationStationId = request.DestinationStationId; directive.ItemId = request.ItemId; - directive.PreferredNodeId = request.PreferredNodeId; + directive.PreferredAnchorId = request.PreferredAnchorId; directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId; directive.PreferredModuleId = request.PreferredModuleId; directive.Priority = request.Priority; @@ -355,7 +355,7 @@ internal sealed class PlayerFactionService SourceStationId = template.SourceStationId, DestinationStationId = template.DestinationStationId, ItemId = template.ItemId, - NodeId = template.NodeId, + AnchorId = template.AnchorId, ConstructionSiteId = template.ConstructionSiteId, ModuleId = template.ModuleId, WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), @@ -501,7 +501,7 @@ internal sealed class PlayerFactionService SourceStationId = template.SourceStationId, DestinationStationId = template.DestinationStationId, ItemId = template.ItemId, - NodeId = template.NodeId, + AnchorId = template.AnchorId, ConstructionSiteId = template.ConstructionSiteId, ModuleId = template.ModuleId, WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), @@ -692,7 +692,7 @@ internal sealed class PlayerFactionService SourceStationId = request.SourceStationId, DestinationStationId = request.DestinationStationId, ItemId = request.ItemId, - NodeId = request.NodeId, + AnchorId = request.AnchorId, ConstructionSiteId = request.ConstructionSiteId, ModuleId = request.ModuleId, WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f), @@ -805,7 +805,7 @@ internal sealed class PlayerFactionService directive.SourceStationId = request.HomeStationId; directive.DestinationStationId = null; directive.ItemId = request.ItemId; - directive.PreferredNodeId = request.PreferredNodeId; + directive.PreferredAnchorId = request.PreferredAnchorId; directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId; directive.PreferredModuleId = request.PreferredModuleId; directive.Priority = 100; @@ -831,7 +831,7 @@ internal sealed class PlayerFactionService SourceStationId = template.SourceStationId, DestinationStationId = template.DestinationStationId, ItemId = template.ItemId, - NodeId = template.NodeId, + AnchorId = template.AnchorId, ConstructionSiteId = template.ConstructionSiteId, ModuleId = template.ModuleId, WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), @@ -1418,7 +1418,7 @@ internal sealed class PlayerFactionService AreaSystemId = directive?.TargetSystemId ?? directive?.HomeSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId, TargetEntityId = directive?.TargetEntityId, ItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.ItemId, - PreferredNodeId = directive?.PreferredNodeId ?? ship.DefaultBehavior.PreferredNodeId, + PreferredAnchorId = directive?.PreferredAnchorId ?? ship.DefaultBehavior.PreferredAnchorId, PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId, PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId, TargetPosition = directive?.TargetPosition, @@ -1461,7 +1461,7 @@ internal sealed class PlayerFactionService SourceStationId = directive.SourceStationId ?? directive.HomeStationId, DestinationStationId = directive.DestinationStationId, ItemId = directive.ItemId, - NodeId = directive.PreferredNodeId, + AnchorId = directive.PreferredAnchorId, ConstructionSiteId = directive.PreferredConstructionSiteId, ModuleId = directive.PreferredModuleId, WaitSeconds = directive.WaitSeconds, @@ -1525,7 +1525,7 @@ internal sealed class PlayerFactionService target.AreaSystemId = source.AreaSystemId; target.TargetEntityId = source.TargetEntityId; target.ItemId = source.ItemId; - target.PreferredNodeId = source.PreferredNodeId; + target.PreferredAnchorId = source.PreferredAnchorId; target.PreferredConstructionSiteId = source.PreferredConstructionSiteId; target.PreferredModuleId = source.PreferredModuleId; target.TargetPosition = source.TargetPosition; @@ -1546,7 +1546,7 @@ internal sealed class PlayerFactionService && string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal) && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) - && string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal) + && string.Equals(left.PreferredAnchorId, right.PreferredAnchorId, StringComparison.Ordinal) && string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal) && string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal) && Nullable.Equals(left.TargetPosition, right.TargetPosition) @@ -1567,7 +1567,7 @@ internal sealed class PlayerFactionService && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) - && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) + && string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal) && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) && left.WaitSeconds.Equals(right.WaitSeconds) @@ -1589,7 +1589,7 @@ internal sealed class PlayerFactionService && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) - && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) + && string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal) && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) && left.WaitSeconds.Equals(right.WaitSeconds) @@ -1634,7 +1634,7 @@ internal sealed class PlayerFactionService SourceStationId = template.SourceStationId, DestinationStationId = template.DestinationStationId, ItemId = template.ItemId, - NodeId = template.NodeId, + AnchorId = template.AnchorId, ConstructionSiteId = template.ConstructionSiteId, ModuleId = template.ModuleId, WaitSeconds = template.WaitSeconds, diff --git a/apps/backend/PlayerFaction/Simulation/PlayerStateStore.cs b/apps/backend/PlayerFaction/Simulation/PlayerStateStore.cs index 3f6c2ee..888265e 100644 --- a/apps/backend/PlayerFaction/Simulation/PlayerStateStore.cs +++ b/apps/backend/PlayerFaction/Simulation/PlayerStateStore.cs @@ -22,5 +22,8 @@ public sealed class PlayerStateStore : IPlayerStateStore public IReadOnlyCollection GetPlayerFactions() => _playerFactions.Values.ToList(); + public IReadOnlyDictionary GetPlayerFactionsByPlayerId() => + new Dictionary(_playerFactions, StringComparer.Ordinal); + public void Clear() => _playerFactions.Clear(); } diff --git a/apps/backend/Shared/Runtime/SimulationKinds.cs b/apps/backend/Shared/Runtime/SimulationKinds.cs index 95575d0..c2151ef 100644 --- a/apps/backend/Shared/Runtime/SimulationKinds.cs +++ b/apps/backend/Shared/Runtime/SimulationKinds.cs @@ -6,6 +6,7 @@ public enum SpatialNodeKind Planet, Moon, LagrangePoint, + ResourceNode, } public enum WorkStatus @@ -286,6 +287,7 @@ public static class SimulationEnumMappings SpatialNodeKind.Planet => "planet", SpatialNodeKind.Moon => "moon", SpatialNodeKind.LagrangePoint => "lagrange-point", + SpatialNodeKind.ResourceNode => "resource-node", _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), }; diff --git a/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs b/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs index f36e36d..837364f 100644 --- a/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs +++ b/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs @@ -288,7 +288,7 @@ public sealed partial class ShipAiService TargetSystemId = opportunity.Node.SystemId, DestinationStationId = opportunity.DropOffStation.Id, ItemId = opportunity.Node.ItemId, - NodeId = opportunity.Node.Id, + AnchorId = opportunity.Node.AnchorId, MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, }; @@ -509,7 +509,7 @@ public sealed partial class ShipAiService SourceStationId = template.SourceStationId, DestinationStationId = template.DestinationStationId, ItemId = template.ItemId, - NodeId = template.NodeId, + AnchorId = template.AnchorId, ConstructionSiteId = template.ConstructionSiteId, ModuleId = template.ModuleId, WaitSeconds = template.WaitSeconds, @@ -561,7 +561,7 @@ public sealed partial class ShipAiService }; } - var node = SelectLocalMiningNode(world, ship, systemId, itemId); + var node = SelectLocalMiningNode(world, ship, systemId, itemId, ship.DefaultBehavior.PreferredAnchorId); if (node is null) { ship.LastAccessFailureReason = "no-mineable-node"; @@ -578,8 +578,9 @@ public sealed partial class ShipAiService Priority = 0, InterruptCurrentPlan = false, Label = $"Mine {itemId} in {systemId}", + TargetEntityId = node.Id, TargetSystemId = node.SystemId, - NodeId = node.Id, + AnchorId = node.AnchorId, ItemId = node.ItemId, WaitSeconds = 0f, Radius = 0f, @@ -601,7 +602,7 @@ public sealed partial class ShipAiService && left.TargetPosition == right.TargetPosition && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) - && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) + && string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal) && left.WaitSeconds.Equals(right.WaitSeconds) && left.Radius.Equals(right.Radius) && left.MaxSystemRange == right.MaxSystemRange diff --git a/apps/backend/Ships/AI/ShipAiService.Execution.cs b/apps/backend/Ships/AI/ShipAiService.Execution.cs index 83053e0..9bca4e9 100644 --- a/apps/backend/Ships/AI/ShipAiService.Execution.cs +++ b/apps/backend/Ships/AI/ShipAiService.Execution.cs @@ -69,7 +69,7 @@ public sealed partial class ShipAiService } var targetPosition = ResolveCurrentTargetPosition(world, subTask); - var targetCelestial = ResolveTravelTargetCelestial(world, subTask, targetPosition); + var targetAnchor = ResolveTravelTargetAnchor(world, subTask, targetPosition); ship.TargetPosition = targetPosition; if (ship.SystemId != subTask.TargetSystemId) @@ -81,32 +81,33 @@ public sealed partial class ShipAiService return SubTaskOutcome.Failed; } - var destinationEntryCelestial = ResolveSystemEntryCelestial(world, subTask.TargetSystemId); - var destinationEntryPosition = destinationEntryCelestial?.Position ?? targetPosition; - return UpdateFtlTransit(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, destinationEntryPosition, destinationEntryCelestial, completeOnArrival, targetPosition); + var destinationEntryAnchor = ResolveSystemEntryAnchor(world, subTask.TargetSystemId) ?? targetAnchor; + var destinationEntryPosition = destinationEntryAnchor?.Position ?? targetPosition; + return UpdateFtlTransit(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, destinationEntryPosition, destinationEntryAnchor, completeOnArrival, targetPosition, targetAnchor); } - var currentCelestial = ResolveCurrentCelestial(world, ship); - if (targetCelestial is not null - && currentCelestial is not null - && !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal)) + var currentAnchor = ResolveCurrentAnchor(world, ship); + if (targetAnchor is not null + && currentAnchor is not null + && !string.Equals(currentAnchor.Id, targetAnchor.Id, StringComparison.Ordinal)) { if (!CanWarp(ship.Definition)) { - return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival); + return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, currentAnchor, targetAnchor, completeOnArrival); } - return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival); + return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, currentAnchor, targetAnchor, completeOnArrival); } - if (targetCelestial is not null - && ship.Position.DistanceTo(targetPosition) > WarpEngageDistanceKilometers + if (targetAnchor is not null + && currentAnchor is not null + && !string.Equals(currentAnchor.Id, targetAnchor.Id, StringComparison.Ordinal) && CanWarp(ship.Definition)) { - return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival); + return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, currentAnchor, targetAnchor, completeOnArrival); } - return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival); + return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, currentAnchor, targetAnchor, completeOnArrival); } private SubTaskOutcome UpdateAttackSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) @@ -157,7 +158,7 @@ public sealed partial class ShipAiService private SubTaskOutcome UpdateMineSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { - var node = ResolveNode(world, subTask.TargetEntityId ?? subTask.TargetNodeId); + var node = ResolveNode(world, subTask.TargetResourceNodeId ?? subTask.TargetEntityId); if (node is null || !CanExtractNode(ship, node, world)) { subTask.BlockingReason = "node-missing"; @@ -165,9 +166,28 @@ public sealed partial class ShipAiService return SubTaskOutcome.Failed; } - var targetPosition = subTask.TargetPosition ?? GetResourceHoldPosition(node.Position, ship.Id, 20f); + var deposit = ResolveResourceDeposit(world, subTask.TargetResourceDepositId); + if (deposit is null || !string.Equals(deposit.NodeId, node.Id, StringComparison.Ordinal) || deposit.OreRemaining <= 0.01f) + { + deposit = SelectMiningDeposit(node, ship.Id); + subTask.TargetResourceDepositId = deposit?.Id; + } + + if (deposit is null) + { + SyncNodeOreTotals(node); + return SubTaskOutcome.Completed; + } + + var targetPosition = GetResourceHoldPosition(deposit.Position, ship.Id, 20f); + subTask.TargetPosition = targetPosition; + var approachThreshold = MathF.Max(subTask.Threshold, 8f); + var distanceToTarget = ship.Position.DistanceTo(targetPosition); + var distanceToDeposit = ship.Position.DistanceTo(deposit.Position); + var effectivelyAtDeposit = string.Equals(ship.SpatialState.CurrentAnchorId, node.AnchorId, StringComparison.Ordinal) + && distanceToDeposit <= approachThreshold; ship.TargetPosition = targetPosition; - if (ship.Position.DistanceTo(targetPosition) > MathF.Max(subTask.Threshold, 8f)) + if (distanceToTarget > approachThreshold && !effectivelyAtDeposit) { ship.State = ShipState.MiningApproach; ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); @@ -188,14 +208,15 @@ public sealed partial class ShipAiService var remainingCapacity = MathF.Max(0f, ship.Definition.GetTotalCargoCapacity() - cargoAmount); var mined = MathF.Min(balance.MiningRate * GetSkillFactor(ship.Skills.Mining), remainingCapacity); - mined = MathF.Min(mined, node.OreRemaining); + mined = MathF.Min(mined, deposit.OreRemaining); if (mined <= 0.01f) { return SubTaskOutcome.Completed; } AddInventory(ship.Inventory, node.ItemId, mined); - node.OreRemaining = MathF.Max(0f, node.OreRemaining - mined); + deposit.OreRemaining = MathF.Max(0f, deposit.OreRemaining - mined); + SyncNodeOreTotals(node); if (GetShipCargoAmount(ship) >= ship.Definition.GetTotalCargoCapacity() - 0.01f || node.OreRemaining <= 0.01f) { return SubTaskOutcome.Completed; @@ -605,15 +626,22 @@ public sealed partial class ShipAiService float deltaSeconds, string targetSystemId, Vector3 targetPosition, - CelestialRuntime? targetCelestial, + AnchorRuntime? currentAnchor, + AnchorRuntime? targetAnchor, bool completeOnArrival) { var distance = ship.Position.DistanceTo(targetPosition); ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace; ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; ship.SpatialState.Transit = null; - ship.SpatialState.DestinationNodeId = targetCelestial?.Id; + ship.SpatialState.DestinationAnchorId = targetAnchor?.Id ?? currentAnchor?.Id; subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f); + ship.SpatialState.SystemPosition = currentAnchor is null + ? ship.Position + : new Vector3( + currentAnchor.Position.X + ship.Position.X, + currentAnchor.Position.Y + ship.Position.Y, + currentAnchor.Position.Z + ship.Position.Z); if (distance <= MathF.Max(subTask.Threshold, balance.ArrivalThreshold)) { @@ -621,13 +649,26 @@ public sealed partial class ShipAiService ship.TargetPosition = targetPosition; ship.SystemId = targetSystemId; ship.SpatialState.CurrentSystemId = targetSystemId; - ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; + ship.SpatialState.CurrentAnchorId = targetAnchor?.Id ?? currentAnchor?.Id; + ship.SpatialState.SystemPosition = targetAnchor is null + ? targetPosition + : new Vector3( + targetAnchor.Position.X + targetPosition.X, + targetAnchor.Position.Y + targetPosition.Y, + targetAnchor.Position.Z + targetPosition.Z); ship.State = ShipState.Arriving; return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } ship.State = ShipState.LocalFlight; + ship.SpatialState.CurrentAnchorId = currentAnchor?.Id; ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + ship.SpatialState.SystemPosition = currentAnchor is null + ? ship.Position + : new Vector3( + currentAnchor.Position.X + ship.Position.X, + currentAnchor.Position.Y + ship.Position.Y, + currentAnchor.Position.Z + ship.Position.Z); return SubTaskOutcome.Active; } @@ -637,18 +678,24 @@ public sealed partial class ShipAiService ShipSubTaskRuntime subTask, float deltaSeconds, Vector3 targetPosition, - CelestialRuntime targetCelestial, + AnchorRuntime currentAnchor, + AnchorRuntime targetAnchor, bool completeOnArrival) { var transit = ship.SpatialState.Transit; - if (transit is null || transit.Regime != MovementRegimeKind.Warp || transit.DestinationNodeId != targetCelestial.Id) + if (transit is null || transit.Regime != MovementRegimeKind.Warp || transit.DestinationAnchorId != targetAnchor.Id) { + var originAnchorPosition = currentAnchor.Position; + var destinationAnchorPosition = targetAnchor.Position; + var initialSpoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); + var initialTravelDuration = MathF.Max(0.1f, originAnchorPosition.DistanceTo(destinationAnchorPosition) / MathF.Max(GetWarpTravelSpeed(ship), 0.001f)); transit = new ShipTransitRuntime { Regime = MovementRegimeKind.Warp, - OriginNodeId = ship.SpatialState.CurrentCelestialId, - DestinationNodeId = targetCelestial.Id, + OriginAnchorId = currentAnchor.Id, + DestinationAnchorId = targetAnchor.Id, StartedAtUtc = world.GeneratedAtUtc, + ArrivalDueAtUtc = world.GeneratedAtUtc.AddSeconds(initialSpoolDuration + initialTravelDuration), }; ship.SpatialState.Transit = transit; subTask.ElapsedSeconds = 0f; @@ -656,33 +703,47 @@ public sealed partial class ShipAiService ship.SpatialState.SpaceLayer = SpaceLayerKind.SystemSpace; ship.SpatialState.MovementRegime = MovementRegimeKind.Warp; - ship.SpatialState.CurrentCelestialId = null; - ship.SpatialState.DestinationNodeId = targetCelestial.Id; + ship.SpatialState.CurrentAnchorId = null; + ship.SpatialState.DestinationAnchorId = targetAnchor.Id; - var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); - if (ship.State != ShipState.Warping) + var spoolDurationSeconds = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); + var startedAtUtc = transit.StartedAtUtc ?? world.GeneratedAtUtc; + var arrivalDueAtUtc = transit.ArrivalDueAtUtc ?? world.GeneratedAtUtc; + var totalDuration = MathF.Max(0.1f, (float)(arrivalDueAtUtc - startedAtUtc).TotalSeconds); + var elapsedSeconds = Math.Clamp((float)(world.GeneratedAtUtc - startedAtUtc).TotalSeconds, 0f, totalDuration); + var originPosition = ResolveAnchorPosition(world, transit.OriginAnchorId, currentAnchor.Position); + var destinationPosition = ResolveAnchorPosition(world, transit.DestinationAnchorId, targetAnchor.Position); + + if (elapsedSeconds < spoolDurationSeconds) { ship.State = ShipState.SpoolingWarp; - if (!AdvanceTimedSubTask(subTask, deltaSeconds, spoolDuration)) - { - return SubTaskOutcome.Active; - } - - ship.State = ShipState.Warping; + ship.Position = Vector3.Zero; + ship.TargetPosition = Vector3.Zero; + ship.SpatialState.SystemPosition = originPosition; + transit.Progress = Math.Clamp(elapsedSeconds / totalDuration, 0f, 1f); + subTask.Progress = transit.Progress; + return SubTaskOutcome.Active; } - var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null - ? ship.Position.DistanceTo(targetPosition) - : (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition))); - ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds); - transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance)); + ship.State = ShipState.Warping; + var warpTravelDuration = MathF.Max(0.001f, totalDuration - spoolDurationSeconds); + var travelElapsed = Math.Clamp(elapsedSeconds - spoolDurationSeconds, 0f, warpTravelDuration); + var travelProgress = Math.Clamp(travelElapsed / warpTravelDuration, 0f, 1f); + var travelDelta = destinationPosition.Subtract(originPosition); + ship.Position = Vector3.Zero; + ship.TargetPosition = Vector3.Zero; + ship.SpatialState.SystemPosition = new Vector3( + originPosition.X + (travelDelta.X * travelProgress), + originPosition.Y + (travelDelta.Y * travelProgress), + originPosition.Z + (travelDelta.Z * travelProgress)); + transit.Progress = Math.Clamp(elapsedSeconds / totalDuration, 0f, 1f); subTask.Progress = transit.Progress; - if (ship.Position.DistanceTo(targetPosition) > 18f) + if (elapsedSeconds < totalDuration - 0.001f) { return SubTaskOutcome.Active; } - return CompleteTransitArrival(ship, subTask.TargetSystemId ?? ship.SystemId, targetPosition, targetCelestial, completeOnArrival); + return CompleteTransitArrival(ship, subTask.TargetSystemId ?? ship.SystemId, targetPosition, targetAnchor, completeOnArrival); } private SubTaskOutcome UpdateFtlTransit( @@ -692,20 +753,24 @@ public sealed partial class ShipAiService float deltaSeconds, string targetSystemId, Vector3 entryPosition, - CelestialRuntime? targetCelestial, + AnchorRuntime? entryAnchor, bool completeOnArrival, - Vector3 finalTargetPosition) + Vector3 finalTargetPosition, + AnchorRuntime? finalTargetAnchor) { - var destinationNodeId = targetCelestial?.Id; + var destinationAnchorId = entryAnchor?.Id; var transit = ship.SpatialState.Transit; - if (transit is null || transit.Regime != MovementRegimeKind.FtlTransit || transit.DestinationNodeId != destinationNodeId) + if (transit is null || transit.Regime != MovementRegimeKind.FtlTransit || transit.DestinationAnchorId != destinationAnchorId) { + var initialTravelDuration = MathF.Max(0.1f, ResolveSystemGalaxyPosition(world, ship.SystemId).DistanceTo(ResolveSystemGalaxyPosition(world, targetSystemId)) / MathF.Max(ship.Definition.FtlSpeed * GetSkillFactor(ship.Skills.Navigation), 0.001f)); + var initialSpoolDuration = MathF.Max(ship.Definition.SpoolTime, 0.1f); transit = new ShipTransitRuntime { Regime = MovementRegimeKind.FtlTransit, - OriginNodeId = ship.SpatialState.CurrentCelestialId, - DestinationNodeId = destinationNodeId, + OriginAnchorId = ship.SpatialState.CurrentAnchorId, + DestinationAnchorId = destinationAnchorId, StartedAtUtc = world.GeneratedAtUtc, + ArrivalDueAtUtc = world.GeneratedAtUtc.AddSeconds(initialSpoolDuration + initialTravelDuration), }; ship.SpatialState.Transit = transit; subTask.ElapsedSeconds = 0f; @@ -713,39 +778,32 @@ public sealed partial class ShipAiService ship.SpatialState.SpaceLayer = SpaceLayerKind.GalaxySpace; ship.SpatialState.MovementRegime = MovementRegimeKind.FtlTransit; - ship.SpatialState.CurrentCelestialId = null; - ship.SpatialState.DestinationNodeId = destinationNodeId; + ship.SpatialState.CurrentAnchorId = null; + ship.SpatialState.DestinationAnchorId = destinationAnchorId; - if (ship.State != ShipState.Ftl) - { - ship.State = ShipState.SpoolingFtl; - if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(ship.Definition.SpoolTime, 0.1f))) - { - return SubTaskOutcome.Active; - } - - ship.State = ShipState.Ftl; - } - - var originSystemPosition = ResolveSystemGalaxyPosition(world, ship.SystemId); - var destinationSystemPosition = ResolveSystemGalaxyPosition(world, targetSystemId); - var totalDistance = MathF.Max(0.001f, originSystemPosition.DistanceTo(destinationSystemPosition)); - transit.Progress = MathF.Min(1f, transit.Progress + ((ship.Definition.FtlSpeed * GetSkillFactor(ship.Skills.Navigation)) * deltaSeconds / totalDistance)); + var spoolDurationSeconds = MathF.Max(ship.Definition.SpoolTime, 0.1f); + var startedAtUtc = transit.StartedAtUtc ?? world.GeneratedAtUtc; + var arrivalDueAtUtc = transit.ArrivalDueAtUtc ?? world.GeneratedAtUtc; + var totalDuration = MathF.Max(0.1f, (float)(arrivalDueAtUtc - startedAtUtc).TotalSeconds); + var elapsedSeconds = Math.Clamp((float)(world.GeneratedAtUtc - startedAtUtc).TotalSeconds, 0f, totalDuration); + ship.State = elapsedSeconds < spoolDurationSeconds ? ShipState.SpoolingFtl : ShipState.Ftl; + transit.Progress = Math.Clamp(elapsedSeconds / totalDuration, 0f, 1f); subTask.Progress = transit.Progress; - if (transit.Progress < 0.999f) + if (elapsedSeconds < totalDuration - 0.001f) { return SubTaskOutcome.Active; } - ship.Position = entryPosition; + ship.Position = Vector3.Zero; ship.TargetPosition = finalTargetPosition; ship.SystemId = targetSystemId; ship.SpatialState.CurrentSystemId = targetSystemId; ship.SpatialState.Transit = null; ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace; ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; - ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; - ship.SpatialState.DestinationNodeId = targetCelestial?.Id; + ship.SpatialState.CurrentAnchorId = entryAnchor?.Id; + ship.SpatialState.DestinationAnchorId = finalTargetAnchor?.Id ?? entryAnchor?.Id; + ship.SpatialState.SystemPosition = entryPosition; ship.State = ShipState.Arriving; // Cross-system travel is only complete once the ship finishes the @@ -753,7 +811,7 @@ public sealed partial class ShipAiService return SubTaskOutcome.Active; } - private static SubTaskOutcome CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial, bool completeOnArrival) + private static SubTaskOutcome CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, AnchorRuntime? targetAnchor, bool completeOnArrival) { ship.Position = targetPosition; ship.TargetPosition = targetPosition; @@ -762,8 +820,14 @@ public sealed partial class ShipAiService ship.SpatialState.Transit = null; ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace; ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; - ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; - ship.SpatialState.DestinationNodeId = targetCelestial?.Id; + ship.SpatialState.CurrentAnchorId = targetAnchor?.Id; + ship.SpatialState.DestinationAnchorId = targetAnchor?.Id; + ship.SpatialState.SystemPosition = targetAnchor is null + ? targetPosition + : new Vector3( + targetAnchor.Position.X + targetPosition.X, + targetAnchor.Position.Y + targetPosition.Y, + targetAnchor.Position.Z + targetPosition.Z); ship.State = ShipState.Arriving; return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } diff --git a/apps/backend/Ships/AI/ShipAiService.Helpers.cs b/apps/backend/Ships/AI/ShipAiService.Helpers.cs index d30283d..9759f46 100644 --- a/apps/backend/Ships/AI/ShipAiService.Helpers.cs +++ b/apps/backend/Ships/AI/ShipAiService.Helpers.cs @@ -9,6 +9,11 @@ public sealed partial class ShipAiService { private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ShipSubTaskRuntime subTask) { + if (!string.IsNullOrWhiteSpace(subTask.TargetAnchorId) && ResolveAnchor(world, subTask.TargetAnchorId) is not null) + { + return subTask.TargetPosition ?? Vector3.Zero; + } + if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) { var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); @@ -44,15 +49,20 @@ public sealed partial class ShipAiService if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) { var station = ResolveStation(world, subTask.TargetEntityId); - if (station?.CelestialId is not null) + if (station?.AnchorId is not null) { - return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId); + return ResolveAnchorBackedCelestial(world, station.AnchorId); } var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - if (site?.CelestialId is not null) + if (site?.AnchorId is not null) { - return world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); + return ResolveAnchorBackedCelestial(world, site.AnchorId); + } + + if (ResolveAnchor(world, subTask.TargetEntityId) is { } anchorBackedCelestialTarget) + { + return ResolveAnchorBackedCelestial(world, anchorBackedCelestialTarget.Id); } var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); @@ -76,25 +86,145 @@ public sealed partial class ShipAiService .FirstOrDefault(); } + private static AnchorRuntime? ResolveTravelTargetAnchor(SimulationWorld world, ShipSubTaskRuntime subTask, Vector3 targetPosition) + { + if (!string.IsNullOrWhiteSpace(subTask.TargetAnchorId) && ResolveAnchor(world, subTask.TargetAnchorId) is { } explicitTargetAnchor) + { + return explicitTargetAnchor; + } + + if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) + { + var station = ResolveStation(world, subTask.TargetEntityId); + if (station?.AnchorId is not null) + { + return ResolveAnchor(world, station.AnchorId); + } + + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (site?.AnchorId is not null) + { + return ResolveAnchor(world, site.AnchorId); + } + + var node = ResolveNode(world, subTask.TargetEntityId); + if (node is not null) + { + return ResolveAnchor(world, node.AnchorId); + } + + if (ResolveAnchor(world, subTask.TargetEntityId) is { } directAnchor) + { + return directAnchor; + } + + if (world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } celestial) + { + return ResolveAnchor(world, celestial.Id); + } + + if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } wreck) + { + return world.Anchors + .Where(candidate => candidate.SystemId == wreck.SystemId) + .OrderBy(candidate => candidate.Position.DistanceTo(wreck.Position)) + .FirstOrDefault(); + } + } + + return world.Anchors + .Where(candidate => subTask.TargetSystemId is null || candidate.SystemId == subTask.TargetSystemId) + .OrderBy(candidate => candidate.Position.DistanceTo(targetPosition)) + .FirstOrDefault(); + } + + private static AnchorRuntime? ResolveCurrentAnchor(SimulationWorld world, ShipRuntime ship) + { + if (ship.SpatialState.CurrentAnchorId is not null && ResolveAnchor(world, ship.SpatialState.CurrentAnchorId) is { } explicitAnchor) + { + return explicitAnchor; + } + + if (ship.DockedStationId is not null && ResolveStation(world, ship.DockedStationId)?.AnchorId is { } dockAnchorId) + { + return ResolveAnchor(world, dockAnchorId); + } + + return world.Anchors + .Where(candidate => candidate.SystemId == ship.SystemId) + .OrderBy(candidate => candidate.Position.DistanceTo(ResolveShipSystemPosition(world, ship))) + .FirstOrDefault(); + } + private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship) { - if (ship.SpatialState.CurrentCelestialId is not null) + if (ship.SpatialState.CurrentAnchorId is not null && ResolveAnchorBackedCelestial(world, ship.SpatialState.CurrentAnchorId) is { } currentAnchorCelestial) { - return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId); + return currentAnchorCelestial; } return world.Celestials .Where(candidate => candidate.SystemId == ship.SystemId) - .OrderBy(candidate => candidate.Position.DistanceTo(ship.Position)) + .OrderBy(candidate => candidate.Position.DistanceTo(ResolveShipSystemPosition(world, ship))) .FirstOrDefault(); } private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) => world.Celestials.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star); + private static AnchorRuntime? ResolveSystemEntryAnchor(SimulationWorld world, string systemId) => + world.Anchors.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star); + private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) => world.Systems.FirstOrDefault(candidate => candidate.Definition.Id == systemId)?.Position ?? Vector3.Zero; + private static Vector3 ResolveAnchorPosition(SimulationWorld world, string? anchorId, Vector3 fallbackPosition) => + ResolveAnchor(world, anchorId)?.Position ?? fallbackPosition; + + private static Vector3 ResolveStationSystemPosition(SimulationWorld world, StationRuntime station) + { + if (station.AnchorId is not null && ResolveAnchor(world, station.AnchorId) is { } anchor) + { + return new Vector3( + anchor.Position.X + station.Position.X, + anchor.Position.Y + station.Position.Y, + anchor.Position.Z + station.Position.Z); + } + + return station.Position; + } + + private static Vector3 ResolveNodeSystemPosition(SimulationWorld world, ResourceNodeRuntime node) + { + if (ResolveAnchor(world, node.AnchorId) is { } anchor) + { + return new Vector3( + anchor.Position.X + node.Position.X, + anchor.Position.Y + node.Position.Y, + anchor.Position.Z + node.Position.Z); + } + + return node.Position; + } + + private static Vector3 ResolveShipSystemPosition(SimulationWorld world, ShipRuntime ship) + { + if (ship.SpatialState.SystemPosition is { } systemPosition) + { + return systemPosition; + } + + if (ResolveCurrentAnchor(world, ship) is { } anchor) + { + return new Vector3( + anchor.Position.X + ship.Position.X, + anchor.Position.Y + ship.Position.Y, + anchor.Position.Z + ship.Position.Z); + } + + return ship.Position; + } + private static float GetLocalTravelSpeed(ShipRuntime ship) => SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed) * GetSkillFactor(ship.Skills.Navigation); @@ -183,6 +313,7 @@ public sealed partial class ShipAiService { var policy = ResolvePolicy(world, ship.PolicySetId); var preferredItemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId; + var preferredAnchorId = ship.DefaultBehavior.PreferredAnchorId; var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); var effectiveMiningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); string? deniedReason = null; @@ -194,6 +325,11 @@ public sealed partial class ShipAiService return false; } + if (preferredAnchorId is not null && !string.Equals(node.AnchorId, preferredAnchorId, StringComparison.Ordinal)) + { + return false; + } + if (!TryCheckSystemAllowed(world, policy, ship.FactionId, node.SystemId, "military", out var reason)) { deniedReason ??= reason; @@ -214,7 +350,7 @@ public sealed partial class ShipAiService + (effectiveMiningSkill * 10f) - distancePenalty - routeRiskPenalty - - node.Position.DistanceTo(ship.Position); + - ResolveNodeSystemPosition(world, node).DistanceTo(ResolveShipSystemPosition(world, ship)); return new MiningOpportunity(node, buyer, score, $"Mine {node.ItemId} for {buyer.Label}"); }) .OrderByDescending(candidate => candidate.Score) @@ -452,7 +588,7 @@ public sealed partial class ShipAiService ?? homeStation; } - private static ResourceNodeRuntime? SelectLocalMiningNode(SimulationWorld world, ShipRuntime ship, string systemId, string itemId) + private static ResourceNodeRuntime? SelectLocalMiningNode(SimulationWorld world, ShipRuntime ship, string systemId, string itemId, string? anchorId = null) { var policy = ResolvePolicy(world, ship.PolicySetId); string? deniedReason = null; @@ -467,6 +603,11 @@ public sealed partial class ShipAiService return false; } + if (anchorId is not null && !string.Equals(candidate.AnchorId, anchorId, StringComparison.Ordinal)) + { + return false; + } + if (!TryCheckSystemAllowed(world, policy, ship.FactionId, candidate.SystemId, "military", out var reason)) { deniedReason ??= reason; @@ -487,6 +628,54 @@ public sealed partial class ShipAiService return node; } + private static ResourceDepositRuntime? ResolveResourceDeposit(SimulationWorld world, string? depositId) + { + if (string.IsNullOrWhiteSpace(depositId)) + { + return null; + } + + foreach (var node in world.Nodes) + { + var deposit = node.Deposits.FirstOrDefault(candidate => string.Equals(candidate.Id, depositId, StringComparison.Ordinal)); + if (deposit is not null) + { + return deposit; + } + } + + return null; + } + + private static ResourceDepositRuntime? SelectMiningDeposit(ResourceNodeRuntime node, string shipId) + { + return node.Deposits + .Where(candidate => candidate.OreRemaining > 0.01f) + .OrderByDescending(candidate => candidate.OreRemaining) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static void SyncNodeOreTotals(ResourceNodeRuntime node) + { + node.OreRemaining = node.Deposits.Sum(candidate => candidate.OreRemaining); + } + + private static AnchorRuntime? ResolveMiningAnchor(SimulationWorld world, string? anchorId, string? nodeId) + { + if (anchorId is not null) + { + return ResolveAnchor(world, anchorId); + } + + if (nodeId is not null && ResolveNode(world, nodeId) is { } node) + { + return ResolveAnchor(world, node.AnchorId); + } + + return null; + } + private static StationRuntime? SelectLocalAutoMineBuyer(SimulationWorld world, ShipRuntime ship, string systemId, string itemId) { var policy = ResolvePolicy(world, ship.PolicySetId); @@ -686,9 +875,14 @@ public sealed partial class ShipAiService return (celestial.SystemId, celestial.Position); } + if (ResolveAnchor(world, entityId) is { } anchor) + { + return (anchor.SystemId, anchor.Position); + } + if (world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId) is { } site) { - var position = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? Vector3.Zero; + var position = ResolveAnchor(world, site.AnchorId)?.Position ?? Vector3.Zero; return (site.SystemId, position); } @@ -720,6 +914,16 @@ public sealed partial class ShipAiService private static StationRuntime? ResolveStation(SimulationWorld world, string? stationId) => stationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == stationId); + private static AnchorRuntime? ResolveAnchor(SimulationWorld world, string? anchorId) => + anchorId is null ? null : world.Anchors.FirstOrDefault(candidate => candidate.Id == anchorId); + + private static CelestialRuntime? ResolveAnchorBackedCelestial(SimulationWorld world, string? anchorId) + { + var anchor = ResolveAnchor(world, anchorId); + var celestialId = SpatialBuilder.ResolveCompatibleCelestialId(anchor); + return celestialId is null ? null : world.Celestials.FirstOrDefault(candidate => candidate.Id == celestialId); + } + private static ResourceNodeRuntime? ResolveNode(SimulationWorld world, string? nodeId) => nodeId is null ? null : world.Nodes.FirstOrDefault(candidate => candidate.Id == nodeId); @@ -815,7 +1019,8 @@ public sealed partial class ShipAiService if (site?.StationId is null && site is not null) { - var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position; + var anchorPosition = ResolveAnchor(world, site.AnchorId)?.Position + ?? station.Position; return GetResourceHoldPosition(anchorPosition, ship.Id, 78f); } @@ -867,7 +1072,7 @@ public sealed partial class ShipAiService private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site) { - var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); + var anchor = ResolveAnchor(world, site.AnchorId); if (anchor is null || site.BlueprintId is null) { site.State = ConstructionSiteStateKinds.Destroyed; @@ -878,13 +1083,13 @@ public sealed partial class ShipAiService { Id = $"station-{world.Stations.Count + 1}", SystemId = site.SystemId, + AnchorId = site.AnchorId, Label = BuildFoundedStationLabel(site.TargetDefinitionId), Category = "station", Objective = DetermineFoundationObjective(site.TargetDefinitionId), Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color, - Position = anchor.Position, + Position = Vector3.Zero, FactionId = site.FactionId, - CelestialId = site.CelestialId, Health = 600f, MaxHealth = 600f, }; diff --git a/apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs b/apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs index 00653b7..a46e257 100644 --- a/apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs +++ b/apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs @@ -128,11 +128,11 @@ public sealed partial class ShipAiService CreateStep("step-construction-deliver", "deliver-materials", $"Deliver materials to {site.Id}", [ CreateSubTask("sub-construction-travel", ShipTaskKinds.Travel, $"Travel to support station {supportStation.Label}", supportStation.SystemId, targetPosition, supportStation.Id, MathF.Max(supportStation.Radius + 18f, 18f), 0f), - CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f) + CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f) ]), CreateStep("step-construction-build", "build-site", $"Build {site.Id}", [ - CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f) + CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f) ]) ]); } @@ -301,7 +301,9 @@ public sealed partial class ShipAiService float amount, string? itemId = null, string? moduleId = null, - string? targetNodeId = null) => + string? targetAnchorId = null, + string? targetResourceNodeId = null, + string? targetResourceDepositId = null) => new() { Id = id, @@ -310,7 +312,9 @@ public sealed partial class ShipAiService TargetSystemId = targetSystemId, TargetPosition = targetPosition, TargetEntityId = targetEntityId, - TargetNodeId = targetNodeId, + TargetAnchorId = targetAnchorId, + TargetResourceNodeId = targetResourceNodeId, + TargetResourceDepositId = targetResourceDepositId, ItemId = itemId, ModuleId = moduleId, Threshold = threshold, diff --git a/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs b/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs index c5fb2fe..59c1b5b 100644 --- a/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs +++ b/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs @@ -171,7 +171,8 @@ public sealed partial class ShipAiService return null; } - var node = ResolveNode(world, order.NodeId); + var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId); + var node = ResolveNode(world, order.TargetEntityId); if (node is not null) { if (!string.Equals(node.SystemId, systemId, StringComparison.Ordinal)) @@ -188,7 +189,7 @@ public sealed partial class ShipAiService } else { - node = SelectLocalMiningNode(world, ship, systemId, itemId); + node = SelectLocalMiningNode(world, ship, systemId, itemId, anchor?.Id); } if (node is null) @@ -197,24 +198,30 @@ public sealed partial class ShipAiService return null; } - return BuildLocalMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {itemId} in {systemId}"); + return BuildLocalMiningPlan(world, ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {itemId} in {systemId}"); } private ShipPlanRuntime? BuildMineLocalOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { - var node = ResolveNode(world, order.NodeId); + var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId); + var node = ResolveNode(world, order.TargetEntityId) + ?? SelectLocalMiningNode(world, ship, order.TargetSystemId ?? ship.SystemId, order.ItemId ?? ship.DefaultBehavior.ItemId ?? string.Empty, anchor?.Id); if (node is null) { order.FailureReason = "mine-order-incomplete"; return null; } - return BuildLocalMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {node.ItemId}"); + return BuildLocalMiningPlan(world, ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {node.ItemId}"); } private ShipPlanRuntime? BuildMineAndDeliverRunOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { - var node = ResolveNode(world, order.NodeId); + var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId); + var node = ResolveNode(world, order.TargetEntityId) + ?? (string.IsNullOrWhiteSpace(order.ItemId) + ? null + : SelectLocalMiningNode(world, ship, order.TargetSystemId ?? ship.SystemId, order.ItemId, anchor?.Id)); var buyer = ResolveStation(world, order.DestinationStationId); if (node is null || buyer is null) { @@ -222,7 +229,7 @@ public sealed partial class ShipAiService return null; } - return BuildMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, buyer, order.Label ?? $"Mine {node.ItemId} for {buyer.Label}"); + return BuildMiningPlan(world, ship, AiPlanSourceKind.Order, order.Id, node, buyer, order.Label ?? $"Mine {node.ItemId} for {buyer.Label}"); } private ShipPlanRuntime? BuildSellMinedCargoOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) @@ -396,9 +403,10 @@ public sealed partial class ShipAiService return BuildFollowShipPlan(ship, AiPlanSourceKind.Order, order.Id, targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Name}"); } - private ShipPlanRuntime BuildMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, StationRuntime homeStation, string summary) + private ShipPlanRuntime BuildMiningPlan(SimulationWorld world, ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, StationRuntime homeStation, string summary) { - var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f); + var deposit = SelectMiningDeposit(node, ship.Id); + var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f); return CreatePlan( ship, sourceKind, @@ -408,8 +416,8 @@ public sealed partial class ShipAiService [ CreateStep("step-mine", "mine", $"Mine {node.ItemId}", [ - CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} node", node.SystemId, extractionPosition, node.Id, 8f, 0f), - CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity()) + CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId), + CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id) ]), CreateStep("step-deliver", "deliver", $"Deliver {node.ItemId} to {homeStation.Label}", [ @@ -421,9 +429,10 @@ public sealed partial class ShipAiService ]); } - private ShipPlanRuntime BuildLocalMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, string summary) + private ShipPlanRuntime BuildLocalMiningPlan(SimulationWorld world, ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, string summary) { - var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f); + var deposit = SelectMiningDeposit(node, ship.Id); + var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f); return CreatePlan( ship, sourceKind, @@ -433,8 +442,8 @@ public sealed partial class ShipAiService [ CreateStep("step-mine", "mine", $"Mine {node.ItemId}", [ - CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} node", node.SystemId, extractionPosition, node.Id, 8f, 0f), - CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId) + CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId), + CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id) ]) ]); } diff --git a/apps/backend/Ships/Contracts/ShipCommands.cs b/apps/backend/Ships/Contracts/ShipCommands.cs index 4972275..b8635ad 100644 --- a/apps/backend/Ships/Contracts/ShipCommands.cs +++ b/apps/backend/Ships/Contracts/ShipCommands.cs @@ -11,7 +11,7 @@ public sealed record ShipOrderCommandRequest( string? SourceStationId, string? DestinationStationId, string? ItemId, - string? NodeId, + string? AnchorId, string? ConstructionSiteId, string? ModuleId, float? WaitSeconds, @@ -28,7 +28,7 @@ public sealed record ShipOrderTemplateCommandRequest( string? SourceStationId, string? DestinationStationId, string? ItemId, - string? NodeId, + string? AnchorId, string? ConstructionSiteId, string? ModuleId, float? WaitSeconds, @@ -43,7 +43,7 @@ public sealed record ShipDefaultBehaviorCommandRequest( string? AreaSystemId, string? TargetEntityId, string? ItemId, - string? PreferredNodeId, + string? PreferredAnchorId, string? PreferredConstructionSiteId, string? PreferredModuleId, Vector3Dto? TargetPosition, diff --git a/apps/backend/Ships/Contracts/Ships.cs b/apps/backend/Ships/Contracts/Ships.cs index fc61257..6ff7e75 100644 --- a/apps/backend/Ships/Contracts/Ships.cs +++ b/apps/backend/Ships/Contracts/Ships.cs @@ -23,7 +23,7 @@ public sealed record ShipOrderSnapshot( string? SourceStationId, string? DestinationStationId, string? ItemId, - string? NodeId, + string? AnchorId, string? ConstructionSiteId, string? ModuleId, float WaitSeconds, @@ -41,7 +41,7 @@ public sealed record ShipOrderTemplateSnapshot( string? SourceStationId, string? DestinationStationId, string? ItemId, - string? NodeId, + string? AnchorId, string? ConstructionSiteId, string? ModuleId, float WaitSeconds, @@ -56,7 +56,7 @@ public sealed record DefaultBehaviorSnapshot( string? AreaSystemId, string? TargetEntityId, string? ItemId, - string? PreferredNodeId, + string? PreferredAnchorId, string? PreferredConstructionSiteId, string? PreferredModuleId, Vector3Dto? TargetPosition, @@ -95,7 +95,9 @@ public sealed record ShipSubTaskSnapshot( string Summary, string? TargetEntityId, string? TargetSystemId, - string? TargetNodeId, + string? TargetAnchorId, + string? TargetResourceNodeId, + string? TargetResourceDepositId, Vector3Dto? TargetPosition, string? ItemId, string? ModuleId, @@ -135,6 +137,7 @@ public sealed record ShipSnapshot( string Purpose, string Type, string SystemId, + string? AnchorId, Vector3Dto LocalPosition, Vector3Dto LocalVelocity, Vector3Dto TargetLocalPosition, @@ -151,11 +154,11 @@ public sealed record ShipSnapshot( string? ControlReason, string? LastReplanReason, string? LastAccessFailureReason, - string? CelestialId, string? DockedStationId, string? CommanderId, string? PolicySetId, float CargoCapacity, + IReadOnlyList CargoTypes, float TravelSpeed, string TravelSpeedUnit, IReadOnlyList Inventory, @@ -170,6 +173,7 @@ public sealed record ShipDelta( string Purpose, string Type, string SystemId, + string? AnchorId, Vector3Dto LocalPosition, Vector3Dto LocalVelocity, Vector3Dto TargetLocalPosition, @@ -186,11 +190,11 @@ public sealed record ShipDelta( string? ControlReason, string? LastReplanReason, string? LastAccessFailureReason, - string? CelestialId, string? DockedStationId, string? CommanderId, string? PolicySetId, float CargoCapacity, + IReadOnlyList CargoTypes, float TravelSpeed, string TravelSpeedUnit, IReadOnlyList Inventory, @@ -202,17 +206,17 @@ public sealed record ShipDelta( public sealed record ShipSpatialStateSnapshot( string SpaceLayer, string CurrentSystemId, - string? CurrentCelestialId, + string? CurrentAnchorId, Vector3Dto? LocalPosition, Vector3Dto? SystemPosition, string MovementRegime, - string? DestinationNodeId, + string? DestinationAnchorId, ShipTransitSnapshot? Transit); public sealed record ShipTransitSnapshot( string Regime, - string? OriginNodeId, - string? DestinationNodeId, + string? OriginAnchorId, + string? DestinationAnchorId, DateTimeOffset? StartedAtUtc, DateTimeOffset? ArrivalDueAtUtc, float Progress); diff --git a/apps/backend/Ships/Runtime/ShipRuntimeModels.cs b/apps/backend/Ships/Runtime/ShipRuntimeModels.cs index f4c447c..f097a06 100644 --- a/apps/backend/Ships/Runtime/ShipRuntimeModels.cs +++ b/apps/backend/Ships/Runtime/ShipRuntimeModels.cs @@ -60,7 +60,7 @@ public sealed class ShipOrderRuntime public string? SourceStationId { get; set; } public string? DestinationStationId { get; set; } public string? ItemId { get; set; } - public string? NodeId { get; set; } + public string? AnchorId { get; set; } public string? ConstructionSiteId { get; set; } public string? ModuleId { get; set; } public float WaitSeconds { get; set; } @@ -78,7 +78,7 @@ public sealed class DefaultBehaviorRuntime public string? AreaSystemId { get; set; } public string? TargetEntityId { get; set; } public string? ItemId { get; set; } - public string? PreferredNodeId { get; set; } + public string? PreferredAnchorId { get; set; } public string? PreferredConstructionSiteId { get; set; } public string? PreferredModuleId { get; set; } public Vector3? TargetPosition { get; set; } @@ -102,7 +102,7 @@ public sealed class ShipOrderTemplateRuntime public string? SourceStationId { get; set; } public string? DestinationStationId { get; set; } public string? ItemId { get; set; } - public string? NodeId { get; set; } + public string? AnchorId { get; set; } public string? ConstructionSiteId { get; set; } public string? ModuleId { get; set; } public float WaitSeconds { get; set; } @@ -146,7 +146,9 @@ public sealed class ShipSubTaskRuntime public WorkStatus Status { get; set; } = WorkStatus.Pending; public string? TargetEntityId { get; set; } public string? TargetSystemId { get; set; } - public string? TargetNodeId { get; set; } + public string? TargetAnchorId { get; set; } + public string? TargetResourceNodeId { get; set; } + public string? TargetResourceDepositId { get; set; } public Vector3? TargetPosition { get; set; } public string? ItemId { get; set; } public string? ModuleId { get; set; } diff --git a/apps/backend/Simulation/Core/SimulationEngine.cs b/apps/backend/Simulation/Core/SimulationEngine.cs index aa0cd16..700d3f5 100644 --- a/apps/backend/Simulation/Core/SimulationEngine.cs +++ b/apps/backend/Simulation/Core/SimulationEngine.cs @@ -104,12 +104,12 @@ internal sealed class SimulationEngine CreateWreck(world, "station", station.Id, station.SystemId, station.Position, station.MaxHealth * 0.12f); world.Stations.Remove(station); - if (station.CelestialId is not null && world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId) is { } celestial) + if (station.AnchorId is not null && world.Anchors.FirstOrDefault(candidate => candidate.Id == station.AnchorId) is { } anchor) { - celestial.OccupyingStructureId = null; + anchor.OccupyingStructureId = null; } - foreach (var claim in world.Claims.Where(candidate => candidate.CelestialId == station.CelestialId)) + foreach (var claim in world.Claims.Where(candidate => candidate.AnchorId == station.AnchorId)) { claim.Health = 0f; claim.State = ClaimStateKinds.Destroyed; diff --git a/apps/backend/Simulation/Core/SimulationProjectionService.cs b/apps/backend/Simulation/Core/SimulationProjectionService.cs index 6feb72f..6d6def3 100644 --- a/apps/backend/Simulation/Core/SimulationProjectionService.cs +++ b/apps/backend/Simulation/Core/SimulationProjectionService.cs @@ -24,6 +24,7 @@ internal sealed class SimulationProjectionService false, events, BuildCelestialDeltas(world), + BuildAnchorDeltas(world), BuildNodeDeltas(world), BuildStationDeltas(world), BuildClaimDeltas(world), @@ -87,26 +88,37 @@ internal sealed class SimulationProjectionService c.Kind, c.OrbitalAnchor, c.LocalSpaceRadius, - c.ParentNodeId, + c.ParentAnchorId, c.OccupyingStructureId, c.OrbitReferenceId)).ToList(), + world.Anchors.Select(ToAnchorDelta).Select(anchor => new AnchorSnapshot( + anchor.Id, + anchor.SystemId, + anchor.Kind, + anchor.SystemPosition, + anchor.LocalSpaceRadius, + anchor.ParentAnchorId, + anchor.OccupyingStructureId, + anchor.OrbitReferenceId)).ToList(), world.Nodes.Select(ToNodeDelta).Select(node => new ResourceNodeSnapshot( node.Id, + node.AnchorId, node.SystemId, node.LocalPosition, - node.CelestialId, + node.LocalSpaceRadius, node.SourceKind, node.OreRemaining, node.MaxOre, - node.ItemId)).ToList(), + node.ItemId, + node.Deposits)).ToList(), world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot( station.Id, station.Label, station.Category, station.Objective, station.SystemId, + station.AnchorId, station.LocalPosition, - station.CelestialId, station.Color, station.DockedShips, station.DockedShipIds, @@ -127,7 +139,7 @@ internal sealed class SimulationProjectionService claim.Id, claim.FactionId, claim.SystemId, - claim.CelestialId, + claim.AnchorId, claim.State, claim.Health, claim.PlacedAtUtc, @@ -136,7 +148,7 @@ internal sealed class SimulationProjectionService site.Id, site.FactionId, site.SystemId, - site.CelestialId, + site.AnchorId, site.TargetKind, site.TargetDefinitionId, site.BlueprintId, @@ -180,6 +192,7 @@ internal sealed class SimulationProjectionService ship.Purpose, ship.Type, ship.SystemId, + ship.AnchorId, ship.LocalPosition, ship.LocalVelocity, ship.TargetLocalPosition, @@ -196,11 +209,11 @@ internal sealed class SimulationProjectionService ship.ControlReason, ship.LastReplanReason, ship.LastAccessFailureReason, - ship.CelestialId, ship.DockedStationId, ship.CommanderId, ship.PolicySetId, ship.CargoCapacity, + ship.CargoTypes, ship.TravelSpeed, ship.TravelSpeedUnit, ship.Inventory, @@ -239,6 +252,11 @@ internal sealed class SimulationProjectionService celestial.LastDeltaSignature = BuildCelestialSignature(celestial); } + foreach (var anchor in world.Anchors) + { + anchor.LastDeltaSignature = BuildAnchorSignature(anchor); + } + foreach (var station in world.Stations) { station.LastDeltaSignature = BuildStationSignature(world, station); @@ -298,6 +316,24 @@ internal sealed class SimulationProjectionService return deltas; } + private static IReadOnlyList BuildAnchorDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var anchor in world.Anchors) + { + var signature = BuildAnchorSignature(anchor); + if (signature == anchor.LastDeltaSignature) + { + continue; + } + + anchor.LastDeltaSignature = signature; + deltas.Add(ToAnchorDelta(anchor)); + } + + return deltas; + } + private static IReadOnlyList BuildCelestialDeltas(SimulationWorld world) { var deltas = new List(); @@ -466,17 +502,30 @@ internal sealed class SimulationProjectionService string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)); private static string BuildNodeSignature(ResourceNodeRuntime node) => - $"{node.SystemId}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.CelestialId}|{node.OreRemaining:0.###}"; + string.Join("|", + node.SystemId, + node.AnchorId, + $"{node.Position.X:0.###}", + $"{node.Position.Y:0.###}", + $"{node.Position.Z:0.###}", + $"{node.OreRemaining:0.###}", + string.Join(",", + node.Deposits + .OrderBy(deposit => deposit.Id, StringComparer.Ordinal) + .Select(deposit => $"{deposit.Id}:{deposit.Position.X:0.###}:{deposit.Position.Y:0.###}:{deposit.Position.Z:0.###}:{deposit.OreRemaining:0.###}"))); private static string BuildCelestialSignature(CelestialRuntime celestial) => - $"{celestial.SystemId}|{celestial.Kind.ToContractValue()}|{celestial.Position.X:0.###}|{celestial.Position.Y:0.###}|{celestial.Position.Z:0.###}|{celestial.LocalSpaceRadius:0.###}|{celestial.ParentNodeId}|{celestial.OccupyingStructureId}|{celestial.OrbitReferenceId}"; + $"{celestial.SystemId}|{celestial.Kind.ToContractValue()}|{celestial.Position.X:0.###}|{celestial.Position.Y:0.###}|{celestial.Position.Z:0.###}|{celestial.LocalSpaceRadius:0.###}|{celestial.ParentAnchorId}|{celestial.OccupyingStructureId}|{celestial.OrbitReferenceId}"; + + private static string BuildAnchorSignature(AnchorRuntime anchor) => + $"{anchor.SystemId}|{anchor.Kind.ToContractValue()}|{anchor.Position.X:0.###}|{anchor.Position.Y:0.###}|{anchor.Position.Z:0.###}|{anchor.LocalSpaceRadius:0.###}|{anchor.ParentAnchorId}|{anchor.OccupyingStructureId}|{anchor.OrbitReferenceId}|{anchor.SourceEntityKind}|{anchor.SourceEntityId}"; private static string BuildStationSignature(SimulationWorld world, StationRuntime station) { var processes = ToStationActionProgressSnapshots(world, station); return string.Join("|", station.SystemId, - station.CelestialId ?? "none", + station.AnchorId ?? "none", station.CommanderId ?? "none", station.PolicySetId ?? "none", BuildInventorySignature(station.Inventory), @@ -495,10 +544,10 @@ internal sealed class SimulationProjectionService } private static string BuildClaimSignature(ClaimRuntime claim) => - $"{claim.FactionId}|{claim.SystemId}|{claim.CelestialId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}"; + $"{claim.FactionId}|{claim.SystemId}|{claim.AnchorId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}"; private static string BuildConstructionSiteSignature(ConstructionSiteRuntime site) => - $"{site.FactionId}|{site.SystemId}|{site.CelestialId}|{site.TargetKind}|{site.TargetDefinitionId}|{site.BlueprintId}|{site.ClaimId}|{site.StationId}|{site.State}|{site.Progress:0.###}|{BuildInventorySignature(site.Inventory)}|{BuildInventorySignature(site.RequiredItems)}|{BuildInventorySignature(site.DeliveredItems)}|{string.Join(",", site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal))}"; + $"{site.FactionId}|{site.SystemId}|{site.AnchorId}|{site.TargetKind}|{site.TargetDefinitionId}|{site.BlueprintId}|{site.ClaimId}|{site.StationId}|{site.State}|{site.Progress:0.###}|{BuildInventorySignature(site.Inventory)}|{BuildInventorySignature(site.RequiredItems)}|{BuildInventorySignature(site.DeliveredItems)}|{string.Join(",", site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal))}"; private static string BuildMarketOrderSignature(MarketOrderRuntime order) => $"{order.FactionId}|{order.StationId}|{order.ConstructionSiteId}|{order.Kind}|{order.ItemId}|{order.Amount:0.###}|{order.RemainingAmount:0.###}|{order.Valuation:0.###}|{order.ReserveThreshold?.ToString("0.###") ?? "none"}|{order.PolicySetId}|{order.State}"; @@ -552,17 +601,17 @@ internal sealed class SimulationProjectionService string.Join(",", ToActiveSubTaskSnapshots(ship).Select(subTask => $"{subTask.Id}:{subTask.Kind}:{subTask.Status}:{subTask.Progress:0.###}:{subTask.ElapsedSeconds:0.###}:{subTask.BlockingReason ?? "none"}")), - ship.SpatialState.CurrentCelestialId ?? "none", + ship.SpatialState.CurrentAnchorId ?? "none", ship.DockedStationId ?? "none", ship.CommanderId ?? "none", ship.PolicySetId ?? "none", ship.SpatialState.SpaceLayer.ToContractValue(), - ship.SpatialState.CurrentCelestialId ?? "none", + ship.SpatialState.CurrentAnchorId ?? "none", ship.SpatialState.MovementRegime.ToContractValue(), - ship.SpatialState.DestinationNodeId ?? "none", + ship.SpatialState.DestinationAnchorId ?? "none", ship.SpatialState.Transit?.Regime.ToContractValue() ?? "none", - ship.SpatialState.Transit?.OriginNodeId ?? "none", - ship.SpatialState.Transit?.DestinationNodeId ?? "none", + ship.SpatialState.Transit?.OriginAnchorId ?? "none", + ship.SpatialState.Transit?.DestinationAnchorId ?? "none", ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0", GetShipCargoAmount(ship).ToString("0.###"), ship.Skills.Navigation.ToString(CultureInfo.InvariantCulture), @@ -653,13 +702,33 @@ internal sealed class SimulationProjectionService private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new( node.Id, + node.AnchorId, node.SystemId, ToDto(node.Position), - node.CelestialId, + node.LocalSpaceRadius, node.SourceKind, node.OreRemaining, node.MaxOre, - node.ItemId); + node.ItemId, + node.Deposits.Select(ToResourceDepositSnapshot).ToList()); + + private static ResourceDepositSnapshot ToResourceDepositSnapshot(ResourceDepositRuntime deposit) => new( + deposit.Id, + deposit.NodeId, + deposit.AnchorId, + ToDto(deposit.Position), + deposit.OreRemaining, + deposit.MaxOre); + + private static AnchorDelta ToAnchorDelta(AnchorRuntime anchor) => new( + anchor.Id, + anchor.SystemId, + anchor.Kind.ToContractValue(), + ToDto(anchor.Position), + anchor.LocalSpaceRadius, + anchor.ParentAnchorId, + anchor.OccupyingStructureId, + anchor.OrbitReferenceId); private static CelestialDelta ToCelestialDelta(CelestialRuntime celestial) => new( celestial.Id, @@ -667,7 +736,7 @@ internal sealed class SimulationProjectionService celestial.Kind.ToContractValue(), ToDto(celestial.Position), celestial.LocalSpaceRadius, - celestial.ParentNodeId, + celestial.ParentAnchorId, celestial.OccupyingStructureId, celestial.OrbitReferenceId); @@ -677,8 +746,8 @@ internal sealed class SimulationProjectionService station.Category, station.Objective, station.SystemId, + station.AnchorId, ToDto(station.Position), - station.CelestialId, station.Color, station.DockedShipIds.Count, station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), @@ -737,7 +806,7 @@ internal sealed class SimulationProjectionService claim.Id, claim.FactionId, claim.SystemId, - claim.CelestialId, + claim.AnchorId, claim.State, claim.Health, claim.PlacedAtUtc, @@ -747,7 +816,7 @@ internal sealed class SimulationProjectionService site.Id, site.FactionId, site.SystemId, - site.CelestialId, + site.AnchorId, site.TargetKind, site.TargetDefinitionId, site.BlueprintId, @@ -811,6 +880,7 @@ internal sealed class SimulationProjectionService ship.Definition.Purpose.ToDataValue(), ship.Definition.Type.ToDataValue(), ship.SystemId, + ship.SpatialState.CurrentAnchorId, ToDto(ship.Position), ToDto(ship.Velocity), ToDto(ship.TargetPosition), @@ -827,11 +897,16 @@ internal sealed class SimulationProjectionService ship.ControlReason, ship.LastReplanReason, ship.LastAccessFailureReason, - ship.SpatialState.CurrentCelestialId, ship.DockedStationId, ship.CommanderId, ship.PolicySetId, ship.Definition.GetTotalCargoCapacity(), + ship.Definition.Cargo + .SelectMany(entry => entry.Types) + .Where(type => !string.IsNullOrWhiteSpace(type)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(type => type, StringComparer.OrdinalIgnoreCase) + .ToList(), ToShipTravelSpeed(ship).Speed, ToShipTravelSpeed(ship).Unit, @@ -880,7 +955,7 @@ internal sealed class SimulationProjectionService order.SourceStationId, order.DestinationStationId, order.ItemId, - order.NodeId, + order.AnchorId, order.ConstructionSiteId, order.ModuleId, order.WaitSeconds, @@ -906,7 +981,7 @@ internal sealed class SimulationProjectionService behavior.AreaSystemId, behavior.TargetEntityId, behavior.ItemId, - behavior.PreferredNodeId, + behavior.PreferredAnchorId, behavior.PreferredConstructionSiteId, behavior.PreferredModuleId, behavior.TargetPosition is null ? null : ToDto(behavior.TargetPosition.Value), @@ -929,7 +1004,7 @@ internal sealed class SimulationProjectionService template.SourceStationId, template.DestinationStationId, template.ItemId, - template.NodeId, + template.AnchorId, template.ConstructionSiteId, template.ModuleId, template.WaitSeconds, @@ -1002,10 +1077,12 @@ internal sealed class SimulationProjectionService subTask.Kind, subTask.Status.ToContractValue(), subTask.Summary, - subTask.TargetEntityId, - subTask.TargetSystemId, - subTask.TargetNodeId, - subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value), + subTask.TargetEntityId, + subTask.TargetSystemId, + subTask.TargetAnchorId, + subTask.TargetResourceNodeId, + subTask.TargetResourceDepositId, + subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value), subTask.ItemId, subTask.ModuleId, subTask.Threshold, @@ -1408,7 +1485,7 @@ internal sealed class SimulationProjectionService claim.SourceClaimId, claim.FactionId, claim.SystemId, - claim.CelestialId, + claim.AnchorId, claim.Status, claim.ClaimKind, claim.ClaimStrength, @@ -1564,15 +1641,15 @@ internal sealed class SimulationProjectionService private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new( state.SpaceLayer.ToContractValue(), state.CurrentSystemId, - state.CurrentCelestialId, + state.CurrentAnchorId, state.LocalPosition is null ? null : ToDto(state.LocalPosition.Value), state.SystemPosition is null ? null : ToDto(state.SystemPosition.Value), state.MovementRegime.ToContractValue(), - state.DestinationNodeId, + state.DestinationAnchorId, state.Transit is null ? null : new ShipTransitSnapshot( state.Transit.Regime.ToContractValue(), - state.Transit.OriginNodeId, - state.Transit.DestinationNodeId, + state.Transit.OriginAnchorId, + state.Transit.DestinationAnchorId, state.Transit.StartedAtUtc, state.Transit.ArrivalDueAtUtc, state.Transit.Progress)); diff --git a/apps/backend/Stations/Contracts/Infrastructure.cs b/apps/backend/Stations/Contracts/Infrastructure.cs index 7b5d7e9..69842e9 100644 --- a/apps/backend/Stations/Contracts/Infrastructure.cs +++ b/apps/backend/Stations/Contracts/Infrastructure.cs @@ -10,8 +10,8 @@ public sealed record StationSnapshot( string Category, string Objective, string SystemId, + string? AnchorId, Vector3Dto LocalPosition, - string? CelestialId, string Color, int DockedShips, IReadOnlyList DockedShipIds, @@ -35,8 +35,8 @@ public sealed record StationDelta( string Category, string Objective, string SystemId, + string? AnchorId, Vector3Dto LocalPosition, - string? CelestialId, string Color, int DockedShips, IReadOnlyList DockedShipIds, @@ -74,7 +74,7 @@ public sealed record ClaimSnapshot( string Id, string FactionId, string SystemId, - string CelestialId, + string AnchorId, string State, float Health, DateTimeOffset PlacedAtUtc, @@ -84,7 +84,7 @@ public sealed record ClaimDelta( string Id, string FactionId, string SystemId, - string CelestialId, + string AnchorId, string State, float Health, DateTimeOffset PlacedAtUtc, @@ -94,7 +94,7 @@ public sealed record ConstructionSiteSnapshot( string Id, string FactionId, string SystemId, - string CelestialId, + string AnchorId, string TargetKind, string TargetDefinitionId, string? BlueprintId, @@ -112,7 +112,7 @@ public sealed record ConstructionSiteDelta( string Id, string FactionId, string SystemId, - string CelestialId, + string AnchorId, string TargetKind, string TargetDefinitionId, string? BlueprintId, diff --git a/apps/backend/Stations/Runtime/ConstructionRuntimeModels.cs b/apps/backend/Stations/Runtime/ConstructionRuntimeModels.cs index 31976bf..716ba3b 100644 --- a/apps/backend/Stations/Runtime/ConstructionRuntimeModels.cs +++ b/apps/backend/Stations/Runtime/ConstructionRuntimeModels.cs @@ -5,7 +5,7 @@ public sealed class ClaimRuntime public required string Id { get; init; } public required string FactionId { get; init; } public required string SystemId { get; init; } - public required string CelestialId { get; init; } + public required string AnchorId { get; init; } public string? CommanderId { get; set; } public DateTimeOffset PlacedAtUtc { get; init; } public DateTimeOffset ActivatesAtUtc { get; set; } @@ -19,7 +19,7 @@ public sealed class ConstructionSiteRuntime public required string Id { get; init; } public required string FactionId { get; init; } public required string SystemId { get; init; } - public required string CelestialId { get; init; } + public required string AnchorId { get; init; } public required string TargetKind { get; init; } public required string TargetDefinitionId { get; init; } public string? BlueprintId { get; set; } diff --git a/apps/backend/Stations/Runtime/StationRuntimeModels.cs b/apps/backend/Stations/Runtime/StationRuntimeModels.cs index cdc2284..056a7f2 100644 --- a/apps/backend/Stations/Runtime/StationRuntimeModels.cs +++ b/apps/backend/Stations/Runtime/StationRuntimeModels.cs @@ -7,6 +7,7 @@ public sealed class StationRuntime { public required string Id { get; init; } public required string SystemId { get; init; } + public string? AnchorId { get; set; } public required string Label { get; set; } public string Category { get; set; } = "station"; public string Objective { get; set; } = "general"; @@ -14,7 +15,6 @@ public sealed class StationRuntime public required Vector3 Position { get; set; } public float Radius { get; set; } = 24f; public required string FactionId { get; init; } - public string? CelestialId { get; set; } public string? CommanderId { get; set; } public string? PolicySetId { get; set; } public List Modules { get; } = []; diff --git a/apps/backend/Stations/Simulation/StationLifecycleService.cs b/apps/backend/Stations/Simulation/StationLifecycleService.cs index 599c3a9..2c8046f 100644 --- a/apps/backend/Stations/Simulation/StationLifecycleService.cs +++ b/apps/backend/Stations/Simulation/StationLifecycleService.cs @@ -100,7 +100,7 @@ internal sealed class StationLifecycleService { CurrentSystemId = station.SystemId, SpaceLayer = SpaceLayerKind.LocalSpace, - CurrentCelestialId = station.CelestialId, + CurrentAnchorId = station.AnchorId, LocalPosition = position, SystemPosition = position, MovementRegime = MovementRegimeKind.LocalFlight, diff --git a/apps/backend/Universe/Api/StreamWorldHandler.cs b/apps/backend/Universe/Api/StreamWorldHandler.cs index 45da9bf..b04e50c 100644 --- a/apps/backend/Universe/Api/StreamWorldHandler.cs +++ b/apps/backend/Universe/Api/StreamWorldHandler.cs @@ -33,11 +33,11 @@ public sealed class StreamWorldHandler(WorldService worldService) : EndpointWith } var systemId = HttpContext.Request.Query["systemId"].ToString(); - var bubbleId = HttpContext.Request.Query["bubbleId"].ToString(); + var anchorId = HttpContext.Request.Query["anchorId"].ToString(); var scope = new ObserverScope( scopeKind, string.IsNullOrWhiteSpace(systemId) ? null : systemId, - string.IsNullOrWhiteSpace(bubbleId) ? null : bubbleId); + string.IsNullOrWhiteSpace(anchorId) ? null : anchorId); var stream = worldService.Subscribe(scope, afterSequence, cancellationToken); await HttpContext.Response.WriteAsync(": connected\n\n", cancellationToken); diff --git a/apps/backend/Universe/Contracts/Celestial.cs b/apps/backend/Universe/Contracts/Celestial.cs index c6f6300..b0bd058 100644 --- a/apps/backend/Universe/Contracts/Celestial.cs +++ b/apps/backend/Universe/Contracts/Celestial.cs @@ -42,25 +42,57 @@ public sealed record PlanetSnapshot( string Color, bool HasRing); +public sealed record ResourceDepositSnapshot( + string Id, + string NodeId, + string AnchorId, + Vector3Dto LocalPosition, + float OreRemaining, + float MaxOre); + public sealed record ResourceNodeSnapshot( string Id, + string AnchorId, string SystemId, Vector3Dto LocalPosition, - string? CelestialId, + float LocalSpaceRadius, string SourceKind, float OreRemaining, float MaxOre, - string ItemId); + string ItemId, + IReadOnlyList Deposits); public sealed record ResourceNodeDelta( string Id, + string AnchorId, string SystemId, Vector3Dto LocalPosition, - string? CelestialId, + float LocalSpaceRadius, string SourceKind, float OreRemaining, float MaxOre, - string ItemId); + string ItemId, + IReadOnlyList Deposits); + +public sealed record AnchorSnapshot( + string Id, + string SystemId, + string Kind, + Vector3Dto SystemPosition, + float LocalSpaceRadius, + string? ParentAnchorId, + string? OccupyingStructureId, + string? OrbitReferenceId); + +public sealed record AnchorDelta( + string Id, + string SystemId, + string Kind, + Vector3Dto SystemPosition, + float LocalSpaceRadius, + string? ParentAnchorId, + string? OccupyingStructureId, + string? OrbitReferenceId); public sealed record CelestialSnapshot( string Id, @@ -68,7 +100,7 @@ public sealed record CelestialSnapshot( string Kind, Vector3Dto OrbitalAnchor, float LocalSpaceRadius, - string? ParentNodeId, + string? ParentAnchorId, string? OccupyingStructureId, string? OrbitReferenceId); @@ -78,6 +110,6 @@ public sealed record CelestialDelta( string Kind, Vector3Dto OrbitalAnchor, float LocalSpaceRadius, - string? ParentNodeId, + string? ParentAnchorId, string? OccupyingStructureId, string? OrbitReferenceId); diff --git a/apps/backend/Universe/Contracts/World.cs b/apps/backend/Universe/Contracts/World.cs index 9e81b18..2a17039 100644 --- a/apps/backend/Universe/Contracts/World.cs +++ b/apps/backend/Universe/Contracts/World.cs @@ -10,6 +10,7 @@ public sealed record WorldSnapshot( DateTimeOffset GeneratedAtUtc, IReadOnlyList Systems, IReadOnlyList Celestials, + IReadOnlyList Anchors, IReadOnlyList Nodes, IReadOnlyList Stations, IReadOnlyList Claims, @@ -29,6 +30,7 @@ public sealed record WorldDelta( bool RequiresSnapshotRefresh, IReadOnlyList Events, IReadOnlyList Celestials, + IReadOnlyList Anchors, IReadOnlyList Nodes, IReadOnlyList Stations, IReadOnlyList Claims, @@ -54,7 +56,7 @@ public sealed record SimulationEventRecord( public sealed record ObserverScope( string ScopeKind, string? SystemId = null, - string? CelestialId = null); + string? AnchorId = null); public sealed record OrbitalSimulationSnapshot( double SimulatedSecondsPerRealSecond); diff --git a/apps/backend/Universe/Runtime/SimulationWorld.cs b/apps/backend/Universe/Runtime/SimulationWorld.cs index a07748c..4395587 100644 --- a/apps/backend/Universe/Runtime/SimulationWorld.cs +++ b/apps/backend/Universe/Runtime/SimulationWorld.cs @@ -6,6 +6,7 @@ public sealed class SimulationWorld public required string Label { get; init; } public required int Seed { get; init; } public required List Systems { get; init; } + public required List Anchors { get; init; } public required List Nodes { get; init; } public required List Celestials { get; init; } public required List Wrecks { get; init; } diff --git a/apps/backend/Universe/Runtime/SpatialRuntimeModels.cs b/apps/backend/Universe/Runtime/SpatialRuntimeModels.cs index 96b4b08..6a53ad3 100644 --- a/apps/backend/Universe/Runtime/SpatialRuntimeModels.cs +++ b/apps/backend/Universe/Runtime/SpatialRuntimeModels.cs @@ -7,22 +7,49 @@ public sealed class SystemRuntime public required Vector3 Position { get; init; } } +public sealed class AnchorRuntime +{ + public required string Id { get; init; } + public required string SystemId { get; init; } + public required SpatialNodeKind Kind { get; init; } + public required Vector3 Position { get; set; } + public float LocalSpaceRadius { get; set; } + public string? ParentAnchorId { get; set; } + public string? OrbitReferenceId { get; set; } + public string? OccupyingStructureId { get; set; } + public required string SourceEntityKind { get; init; } + public required string SourceEntityId { get; init; } + public string LastDeltaSignature { get; set; } = string.Empty; +} + public sealed class ResourceNodeRuntime { public required string Id { get; init; } + public required string AnchorId { get; init; } public required string SystemId { get; init; } public required Vector3 Position { get; set; } public required string SourceKind { get; init; } public required string ItemId { get; init; } - public string? CelestialId { get; set; } + public float LocalSpaceRadius { get; init; } public float OrbitRadius { get; init; } public float OrbitPhase { get; init; } public float OrbitInclination { get; init; } public float OreRemaining { get; set; } public float MaxOre { get; init; } + public List Deposits { get; } = []; public string LastDeltaSignature { get; set; } = string.Empty; } +public sealed class ResourceDepositRuntime +{ + public required string Id { get; init; } + public required string NodeId { get; init; } + public required string AnchorId { get; init; } + public required Vector3 Position { get; set; } + public float OreRemaining { get; set; } + public float MaxOre { get; init; } +} + public sealed class CelestialRuntime { public required string Id { get; init; } @@ -30,7 +57,7 @@ public sealed class CelestialRuntime public required SpatialNodeKind Kind { get; init; } public required Vector3 Position { get; set; } public float LocalSpaceRadius { get; init; } - public string? ParentNodeId { get; set; } + public string? ParentAnchorId { get; set; } public string? OccupyingStructureId { get; set; } public string? OrbitReferenceId { get; set; } public string LastDeltaSignature { get; set; } = string.Empty; @@ -52,19 +79,19 @@ public sealed class ShipSpatialStateRuntime { public SpaceLayerKind SpaceLayer { get; set; } = SpaceLayerKind.LocalSpace; public required string CurrentSystemId { get; set; } - public string? CurrentCelestialId { get; set; } + public string? CurrentAnchorId { get; set; } public Vector3? LocalPosition { get; set; } public Vector3? SystemPosition { get; set; } public MovementRegimeKind MovementRegime { get; set; } = MovementRegimeKind.LocalFlight; - public string? DestinationNodeId { get; set; } + public string? DestinationAnchorId { get; set; } public ShipTransitRuntime? Transit { get; set; } } public sealed class ShipTransitRuntime { public required MovementRegimeKind Regime { get; init; } - public string? OriginNodeId { get; init; } - public string? DestinationNodeId { get; init; } + public string? OriginAnchorId { get; init; } + public string? DestinationAnchorId { get; init; } public DateTimeOffset? StartedAtUtc { get; set; } public DateTimeOffset? ArrivalDueAtUtc { get; set; } public float Progress { get; set; } diff --git a/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs b/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs index 7fabcd4..26fbc50 100644 --- a/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs +++ b/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs @@ -18,13 +18,13 @@ public sealed class ScenarioContentBuilder( scenario, topology.SystemsById, topology.SpatialLayout.SystemGraphs, - topology.SpatialLayout.Celestials); + topology.SpatialLayout.Anchors); var patrolRoutes = BuildPatrolRoutes(scenario, topology.SystemsById); var ships = CreateShips( scenario, topology.SystemsById, - topology.SpatialLayout.Celestials, + topology.SpatialLayout.Anchors, patrolRoutes, stations); @@ -35,7 +35,7 @@ public sealed class ScenarioContentBuilder( ScenarioDefinition scenario, IReadOnlyDictionary systemsById, IReadOnlyDictionary systemGraphs, - IReadOnlyCollection celestials) + IReadOnlyCollection anchors) { var stations = new List(); var stationIdCounter = 0; @@ -47,23 +47,27 @@ public sealed class ScenarioContentBuilder( throw new InvalidOperationException($"Scenario station '{plan.Label}' references unknown system '{plan.SystemId}'."); } - var placement = SpatialBuilder.ResolveStationPlacement(plan, system, systemGraphs[system.Definition.Id], celestials); + var placement = SpatialBuilder.ResolveStationPlacement(plan, system, systemGraphs[system.Definition.Id], anchors); var station = new StationRuntime { Id = $"station-{++stationIdCounter}", SystemId = system.Definition.Id, + AnchorId = placement.Anchor.Id, Label = plan.Label, Color = plan.Color, Objective = StationSimulationService.NormalizeStationObjective(plan.Objective), - Position = placement.Position, + Position = Vector3.Zero, FactionId = GetRequiredFactionId(plan.FactionId, $"station '{plan.Label}'"), - CelestialId = placement.AnchorCelestial.Id, Health = 600f, MaxHealth = 600f, }; stations.Add(station); - placement.AnchorCelestial.OccupyingStructureId = station.Id; + placement.Anchor.OccupyingStructureId = station.Id; + if (placement.Celestial is not null) + { + placement.Celestial.OccupyingStructureId = station.Id; + } var startingModules = BuildStartingModules(plan); foreach (var moduleId in startingModules) @@ -162,7 +166,7 @@ public sealed class ScenarioContentBuilder( private List CreateShips( ScenarioDefinition scenario, IReadOnlyDictionary systemsById, - IReadOnlyCollection celestials, + IReadOnlyCollection anchors, IReadOnlyDictionary> patrolRoutes, IReadOnlyCollection stations) { @@ -181,6 +185,8 @@ public sealed class ScenarioContentBuilder( var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f); var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset); var factionId = GetRequiredFactionId(formation.FactionId, $"ship formation '{formation.ShipId}' in system '{formation.SystemId}'"); + var spatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, anchors); + var localPosition = spatialState.LocalPosition ?? Vector3.Zero; ships.Add(new ShipRuntime { @@ -188,9 +194,9 @@ public sealed class ScenarioContentBuilder( SystemId = formation.SystemId, Definition = definition, FactionId = factionId, - Position = position, - TargetPosition = position, - SpatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, celestials), + Position = localPosition, + TargetPosition = localPosition, + SpatialState = spatialState, DefaultBehavior = CreateBehavior( definition, formation.SystemId, diff --git a/apps/backend/Universe/Scenario/SpatialBuilder.cs b/apps/backend/Universe/Scenario/SpatialBuilder.cs index 00f7d66..f9589cb 100644 --- a/apps/backend/Universe/Scenario/SpatialBuilder.cs +++ b/apps/backend/Universe/Scenario/SpatialBuilder.cs @@ -2,8 +2,15 @@ using static SpaceGame.Api.Universe.Scenario.LoaderSupport; namespace SpaceGame.Api.Universe.Scenario; -public sealed class SpatialBuilder(IBalanceService balance) +public sealed class SpatialBuilder { + internal static bool IsConstructibleAnchorKind(SpatialNodeKind kind) => kind is SpatialNodeKind.Planet or SpatialNodeKind.Moon or SpatialNodeKind.LagrangePoint; + + internal static string? ResolveCompatibleCelestialId(AnchorRuntime? anchor) => + anchor is not null && string.Equals(anchor.SourceEntityKind, "celestial", StringComparison.Ordinal) + ? anchor.SourceEntityId + : null; + internal ScenarioSpatialLayout BuildLayout(IReadOnlyList systems) { var systemGraphs = systems.ToDictionary( @@ -11,6 +18,19 @@ public sealed class SpatialBuilder(IBalanceService balance) BuildSystemSpatialGraph, StringComparer.Ordinal); var celestials = systemGraphs.Values.SelectMany(graph => graph.Celestials).ToList(); + var anchors = celestials.Select(celestial => new AnchorRuntime + { + Id = celestial.Id, + SystemId = celestial.SystemId, + Kind = celestial.Kind, + Position = celestial.Position, + LocalSpaceRadius = celestial.LocalSpaceRadius, + ParentAnchorId = celestial.ParentAnchorId, + OrbitReferenceId = celestial.OrbitReferenceId, + OccupyingStructureId = celestial.OccupyingStructureId, + SourceEntityKind = "celestial", + SourceEntityId = celestial.Id, + }).ToList(); var nodes = new List(); var nodeIdCounter = 0; @@ -20,24 +40,43 @@ public sealed class SpatialBuilder(IBalanceService balance) foreach (var node in system.Definition.ResourceNodes) { var anchorCelestial = ResolveResourceNodeAnchor(systemGraph, node); + var nodeId = $"node-{++nodeIdCounter}"; + var localPosition = ComputeResourceNodeLocalPosition(node); + var anchorPosition = anchorCelestial is null + ? localPosition + : Add(anchorCelestial.Position, localPosition); nodes.Add(new ResourceNodeRuntime { - Id = $"node-{++nodeIdCounter}", + Id = nodeId, + AnchorId = nodeId, SystemId = system.Definition.Id, - Position = ComputeResourceNodePosition(anchorCelestial, node, balance.YPlane), + Position = localPosition, SourceKind = node.SourceKind, ItemId = node.ItemId, - CelestialId = anchorCelestial?.Id, + LocalSpaceRadius = LocalSpaceRadius, OrbitRadius = node.RadiusOffset, OrbitPhase = node.Angle, OrbitInclination = DegreesToRadians(node.InclinationDegrees), OreRemaining = node.OreAmount, MaxOre = node.OreAmount, }); + nodes[^1].Deposits.AddRange(BuildResourceDeposits(system.Definition.Id, nodeId, node, node.OreAmount)); + + anchors.Add(new AnchorRuntime + { + Id = nodeId, + SystemId = system.Definition.Id, + Kind = SpatialNodeKind.ResourceNode, + Position = anchorPosition, + LocalSpaceRadius = LocalSpaceRadius, + ParentAnchorId = anchorCelestial?.Id, + SourceEntityKind = "resource-node", + SourceEntityId = nodeId, + }); } } - return new ScenarioSpatialLayout(systemGraphs, celestials, nodes); + return new ScenarioSpatialLayout(systemGraphs, anchors, celestials, nodes); } private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system) @@ -70,7 +109,7 @@ public sealed class SpatialBuilder(IBalanceService balance) kind: SpatialNodeKind.Planet, position: planetPosition, localSpaceRadius: LocalSpaceRadius, - parentNodeId: primaryStarNodeId); + parentAnchorId: primaryStarNodeId); var lagrangeNodes = new Dictionary(StringComparer.Ordinal); foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet)) @@ -82,7 +121,7 @@ public sealed class SpatialBuilder(IBalanceService balance) kind: SpatialNodeKind.LagrangePoint, position: point.Position, localSpaceRadius: LocalSpaceRadius, - parentNodeId: planetCelestial.Id, + parentAnchorId: planetCelestial.Id, orbitReferenceId: point.Designation); lagrangeNodes[point.Designation] = lagrangeCelestial; } @@ -100,7 +139,7 @@ public sealed class SpatialBuilder(IBalanceService balance) kind: SpatialNodeKind.Moon, position: moonPosition, localSpaceRadius: LocalSpaceRadius, - parentNodeId: planetCelestial.Id); + parentAnchorId: planetCelestial.Id); } } @@ -114,7 +153,7 @@ public sealed class SpatialBuilder(IBalanceService balance) SpatialNodeKind kind, Vector3 position, float localSpaceRadius, - string? parentNodeId = null, + string? parentAnchorId = null, string? orbitReferenceId = null) { var celestial = new CelestialRuntime @@ -124,7 +163,7 @@ public sealed class SpatialBuilder(IBalanceService balance) Kind = kind, Position = position, LocalSpaceRadius = localSpaceRadius, - ParentNodeId = parentNodeId, + ParentAnchorId = parentAnchorId, OrbitReferenceId = orbitReferenceId, }; @@ -183,7 +222,7 @@ public sealed class SpatialBuilder(IBalanceService balance) InitialStationDefinition plan, SystemRuntime system, SystemSpatialGraph graph, - IReadOnlyCollection existingCelestials) + IReadOnlyCollection existingAnchors) { if (plan.PlanetIndex is int planetIndex && graph.LagrangeNodesByPlanetIndex.TryGetValue(planetIndex, out var lagrangeNodes)) @@ -191,28 +230,32 @@ public sealed class SpatialBuilder(IBalanceService balance) var designation = ResolveLagrangeDesignation(plan.LagrangeSide); if (lagrangeNodes.TryGetValue(designation, out var lagrangeCelestial)) { - return new StationPlacement(lagrangeCelestial, lagrangeCelestial.Position); + var lagrangeAnchor = existingAnchors.First(anchor => string.Equals(anchor.Id, lagrangeCelestial.Id, StringComparison.Ordinal)); + return new StationPlacement(lagrangeAnchor, lagrangeCelestial, lagrangeAnchor.Position); } } if (plan.Position is { Length: 3 }) { var targetPosition = NormalizeScenarioPoint(system, plan.Position); - var preferredCelestial = existingCelestials - .Where(c => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint) - .OrderBy(c => c.Position.DistanceTo(targetPosition)) + var preferredAnchor = existingAnchors + .Where(anchor => anchor.SystemId == system.Definition.Id && anchor.Kind == SpatialNodeKind.LagrangePoint) + .OrderBy(anchor => anchor.Position.DistanceTo(targetPosition)) .FirstOrDefault() - ?? existingCelestials - .Where(c => c.SystemId == system.Definition.Id) - .OrderBy(c => c.Position.DistanceTo(targetPosition)) + ?? existingAnchors + .Where(anchor => anchor.SystemId == system.Definition.Id && IsConstructibleAnchorKind(anchor.Kind)) + .OrderBy(anchor => anchor.Position.DistanceTo(targetPosition)) .First(); - return new StationPlacement(preferredCelestial, preferredCelestial.Position); + var preferredCelestial = graph.Celestials.FirstOrDefault(c => string.Equals(c.Id, ResolveCompatibleCelestialId(preferredAnchor), StringComparison.Ordinal)); + return new StationPlacement(preferredAnchor, preferredCelestial, preferredAnchor.Position); } - var fallbackCelestial = graph.Celestials - .FirstOrDefault(c => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId)) - ?? graph.Celestials.First(c => c.Kind == SpatialNodeKind.Planet); - return new StationPlacement(fallbackCelestial, fallbackCelestial.Position); + var fallbackAnchor = existingAnchors + .Where(anchor => anchor.SystemId == system.Definition.Id) + .FirstOrDefault(anchor => anchor.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(anchor.OccupyingStructureId)) + ?? existingAnchors.First(anchor => anchor.SystemId == system.Definition.Id && anchor.Kind == SpatialNodeKind.Planet); + var fallbackCelestial = graph.Celestials.FirstOrDefault(c => string.Equals(c.Id, ResolveCompatibleCelestialId(fallbackAnchor), StringComparison.Ordinal)); + return new StationPlacement(fallbackAnchor, fallbackCelestial, fallbackAnchor.Position); } private static string ResolveLagrangeDesignation(int? lagrangeSide) => lagrangeSide switch @@ -256,20 +299,80 @@ public sealed class SpatialBuilder(IBalanceService balance) return graph.Celestials.FirstOrDefault(c => c.Id == planetNodeId); } - private static Vector3 ComputeResourceNodePosition(CelestialRuntime? anchorCelestial, ResourceNodeDefinition definition, float yPlane) + private static Vector3 ComputeResourceNodeLocalPosition(ResourceNodeDefinition definition) { var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.04f, 25000f); - var offset = new Vector3( + return new Vector3( MathF.Cos(definition.Angle) * definition.RadiusOffset, verticalOffset, MathF.Sin(definition.Angle) * definition.RadiusOffset); + } - if (anchorCelestial is null) + private static IReadOnlyList BuildResourceDeposits( + string systemId, + string nodeId, + ResourceNodeDefinition definition, + float oreAmount) + { + var depositCount = Math.Clamp((int)MathF.Round(MathF.Sqrt(MathF.Max(oreAmount, 1f)) / 18f), 4, 12); + var deposits = new List(depositCount); + var weightTotal = 0f; + var weights = new float[depositCount]; + for (var index = 0; index < depositCount; index += 1) { - return new Vector3(offset.X, yPlane + offset.Y, offset.Z); + var weight = 0.8f + (Hash01(systemId, nodeId, $"weight-{index}") * 1.6f); + weights[index] = weight; + weightTotal += weight; } - return Add(anchorCelestial.Position, offset); + var scatterRadius = MathF.Max(140f, LocalSpaceRadius * 0.58f); + for (var index = 0; index < depositCount; index += 1) + { + var angle = Hash01(systemId, nodeId, $"angle-{index}") * MathF.PI * 2f; + var radiusFactor = 0.22f + (Hash01(systemId, nodeId, $"radius-{index}") * 0.74f); + var radius = scatterRadius * MathF.Sqrt(radiusFactor); + var vertical = (Hash01(systemId, nodeId, $"vertical-{index}") - 0.5f) * MathF.Max(60f, scatterRadius * 0.14f); + var localPosition = new Vector3( + MathF.Cos(angle) * radius, + vertical, + MathF.Sin(angle) * radius); + var maxOre = oreAmount * (weights[index] / MathF.Max(weightTotal, 0.001f)); + deposits.Add(new ResourceDepositRuntime + { + Id = $"{nodeId}-deposit-{index + 1}", + NodeId = nodeId, + AnchorId = nodeId, + Position = localPosition, + OreRemaining = maxOre, + MaxOre = maxOre, + }); + } + + return deposits; + } + + private static float Hash01(string systemId, string nodeId, string salt) + { + unchecked + { + var hash = 17; + foreach (var character in systemId) + { + hash = (hash * 31) + character; + } + + foreach (var character in nodeId) + { + hash = (hash * 31) + character; + } + + foreach (var character in salt) + { + hash = (hash * 31) + character; + } + + return (hash & 0x7fffffff) / (float)int.MaxValue; + } } private static Vector3 ComputePlanetPosition(PlanetDefinition planet) @@ -286,19 +389,22 @@ public sealed class SpatialBuilder(IBalanceService balance) return Add(planetPosition, local); } - internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection celestials) + internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection anchors) { - var nearestCelestial = celestials - .Where(c => c.SystemId == systemId) - .OrderBy(c => c.Position.DistanceTo(position)) + var nearestAnchor = anchors + .Where(anchor => anchor.SystemId == systemId) + .OrderBy(anchor => anchor.Position.DistanceTo(position)) .FirstOrDefault(); + var localPosition = nearestAnchor is null + ? position + : position.Subtract(nearestAnchor.Position); return new ShipSpatialStateRuntime { CurrentSystemId = systemId, SpaceLayer = SpaceLayerKind.LocalSpace, - CurrentCelestialId = nearestCelestial?.Id, - LocalPosition = position, + CurrentAnchorId = nearestAnchor?.Id, + LocalPosition = localPosition, SystemPosition = position, MovementRegime = MovementRegimeKind.LocalFlight, }; @@ -307,6 +413,7 @@ public sealed class SpatialBuilder(IBalanceService balance) public sealed record ScenarioSpatialLayout( IReadOnlyDictionary SystemGraphs, + List Anchors, List Celestials, List Nodes); @@ -317,4 +424,4 @@ public sealed record SystemSpatialGraph( internal sealed record LagrangePointPlacement(string Designation, Vector3 Position); -internal sealed record StationPlacement(CelestialRuntime AnchorCelestial, Vector3 Position); +internal sealed record StationPlacement(AnchorRuntime Anchor, CelestialRuntime? Celestial, Vector3 Position); diff --git a/apps/backend/Universe/Scenario/WorldRuntimeAssembler.cs b/apps/backend/Universe/Scenario/WorldRuntimeAssembler.cs index 22ea259..2a30847 100644 --- a/apps/backend/Universe/Scenario/WorldRuntimeAssembler.cs +++ b/apps/backend/Universe/Scenario/WorldRuntimeAssembler.cs @@ -18,13 +18,14 @@ public sealed class WorldRuntimeAssembler( var policies = seedingService.CreatePolicies(factions); var commanders = seedingService.CreateCommanders(factions, content.Stations, content.Ships); var nowUtc = DateTimeOffset.UtcNow; - var claims = seedingService.CreateClaims(content.Stations, topology.SpatialLayout.Celestials, nowUtc); + var claims = seedingService.CreateClaims(content.Stations, topology.SpatialLayout.Anchors, nowUtc); var world = new SimulationWorld { Label = "Split Viewer / Simulation World", Seed = worldGenerationOptions.Seed, Systems = topology.SystemRuntimes.ToList(), + Anchors = topology.SpatialLayout.Anchors, Celestials = topology.SpatialLayout.Celestials, Nodes = topology.SpatialLayout.Nodes, Wrecks = [], diff --git a/apps/backend/Universe/Scenario/WorldSeedingService.cs b/apps/backend/Universe/Scenario/WorldSeedingService.cs index db6d690..74f2189 100644 --- a/apps/backend/Universe/Scenario/WorldSeedingService.cs +++ b/apps/backend/Universe/Scenario/WorldSeedingService.cs @@ -74,27 +74,27 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData) internal List CreateClaims( IReadOnlyCollection stations, - IReadOnlyCollection celestials, + IReadOnlyCollection anchors, DateTimeOffset nowUtc) { - var stationsByCelestialId = stations - .Where(station => station.CelestialId is not null) - .ToDictionary(station => station.CelestialId!, StringComparer.Ordinal); + var stationsByAnchorId = stations + .Where(station => !string.IsNullOrWhiteSpace(station.AnchorId)) + .ToDictionary(station => station.AnchorId!, StringComparer.Ordinal); var claims = new List(); - foreach (var celestial in celestials.Where(c => c.Kind == SpatialNodeKind.LagrangePoint)) + foreach (var anchor in anchors.Where(candidate => candidate.Kind == SpatialNodeKind.LagrangePoint)) { - if (!stationsByCelestialId.TryGetValue(celestial.Id, out var station)) + if (!stationsByAnchorId.TryGetValue(anchor.Id, out var station)) { continue; } claims.Add(new ClaimRuntime { - Id = $"claim-{celestial.Id}", + Id = $"claim-{anchor.Id}", FactionId = station.FactionId, - SystemId = celestial.SystemId, - CelestialId = celestial.Id, + SystemId = anchor.SystemId, + AnchorId = anchor.Id, PlacedAtUtc = nowUtc, ActivatesAtUtc = nowUtc.AddSeconds(8), State = ClaimStateKinds.Activating, @@ -119,12 +119,12 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData) } var moduleId = InfrastructureSimulationService.GetNextStationModuleToBuild(station, world); - if (moduleId is null || station.CelestialId is null) + if (moduleId is null || station.AnchorId is null) { continue; } - var claim = world.Claims.FirstOrDefault(candidate => candidate.CelestialId == station.CelestialId); + var claim = world.Claims.FirstOrDefault(candidate => string.Equals(candidate.AnchorId, station.AnchorId, StringComparison.Ordinal)); if (claim is null || !world.ModuleRecipes.TryGetValue(moduleId, out var recipe)) { continue; @@ -135,7 +135,7 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData) Id = $"site-{station.Id}", FactionId = station.FactionId, SystemId = station.SystemId, - CelestialId = station.CelestialId, + AnchorId = station.AnchorId, TargetKind = "station-module", TargetDefinitionId = "station", BlueprintId = moduleId, diff --git a/apps/backend/Universe/Simulation/OrbitalStateUpdater.cs b/apps/backend/Universe/Simulation/OrbitalStateUpdater.cs index d6d3469..e0eb0b8 100644 --- a/apps/backend/Universe/Simulation/OrbitalStateUpdater.cs +++ b/apps/backend/Universe/Simulation/OrbitalStateUpdater.cs @@ -1,5 +1,7 @@ using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; +using SpaceGame.Api.Universe.Scenario; + namespace SpaceGame.Api.Universe.Simulation; internal sealed class OrbitalStateUpdater @@ -223,22 +225,47 @@ internal sealed class OrbitalStateUpdater foreach (var station in world.Stations) { - if (station.CelestialId is null || !celestialsById.TryGetValue(station.CelestialId, out var anchorCelestial)) + if (station.AnchorId is not null && world.Anchors.Any(candidate => candidate.Id == station.AnchorId)) { - continue; + station.Position = Vector3.Zero; } - - station.Position = anchorCelestial.Position; } foreach (var node in world.Nodes) { - if (node.CelestialId is null || !celestialsById.TryGetValue(node.CelestialId, out var anchorCelestial)) + node.Position = ComputeResourceNodeOffset(node, worldTimeSeconds); + } + + var nodeAnchorsById = world.Nodes.ToDictionary(node => node.AnchorId, StringComparer.Ordinal); + foreach (var anchor in world.Anchors) + { + if (string.Equals(anchor.SourceEntityKind, "resource-node", StringComparison.Ordinal)) { + if (nodeAnchorsById.TryGetValue(anchor.Id, out var node)) + { + if (anchor.ParentAnchorId is not null && celestialsById.TryGetValue(anchor.ParentAnchorId, out var anchorCelestial)) + { + anchor.Position = Add(anchorCelestial.Position, node.Position); + } + else + { + anchor.Position = node.Position; + } + + anchor.LocalSpaceRadius = node.LocalSpaceRadius; + } + continue; } - node.Position = Add(anchorCelestial.Position, ComputeResourceNodeOffset(node, worldTimeSeconds)); + if (celestialsById.TryGetValue(anchor.Id, out var celestial)) + { + anchor.Position = celestial.Position; + anchor.LocalSpaceRadius = celestial.LocalSpaceRadius; + anchor.ParentAnchorId = celestial.ParentAnchorId; + anchor.OccupyingStructureId = celestial.OccupyingStructureId; + anchor.OrbitReferenceId = celestial.OrbitReferenceId; + } } foreach (var ship in world.Ships.Where(ship => ship.DockedStationId is not null)) @@ -261,20 +288,29 @@ internal sealed class OrbitalStateUpdater { ship.SpatialState.CurrentSystemId = ship.SystemId; ship.SpatialState.LocalPosition = ship.Position; - ship.SpatialState.SystemPosition = ship.Position; if (ship.SpatialState.Transit is not null) { - ship.SpatialState.CurrentCelestialId = null; + ship.SpatialState.CurrentAnchorId = null; continue; } ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace; ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; - var nearestCelestial = world.Celestials - .Where(candidate => candidate.SystemId == ship.SystemId) - .OrderBy(candidate => candidate.Position.DistanceTo(ship.Position)) - .FirstOrDefault(); - ship.SpatialState.CurrentCelestialId = nearestCelestial?.Id; + var currentAnchor = ship.SpatialState.CurrentAnchorId is not null + ? world.Anchors.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentAnchorId) + : null; + if (currentAnchor is null || !string.Equals(currentAnchor.SystemId, ship.SystemId, StringComparison.Ordinal)) + { + currentAnchor = world.Anchors + .Where(candidate => candidate.SystemId == ship.SystemId) + .OrderBy(candidate => candidate.Position.DistanceTo(ship.Position)) + .FirstOrDefault(); + } + + ship.SpatialState.CurrentAnchorId = currentAnchor?.Id; + ship.SpatialState.SystemPosition = currentAnchor is null + ? ship.Position + : Add(currentAnchor.Position, ship.Position); if (ship.DockedStationId is null) { @@ -282,9 +318,9 @@ internal sealed class OrbitalStateUpdater } var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); - if (station?.CelestialId is not null) + if (station is not null) { - ship.SpatialState.CurrentCelestialId = station.CelestialId; + ship.SpatialState.CurrentAnchorId = station.AnchorId; } } } diff --git a/apps/backend/Universe/Simulation/WorldService.cs b/apps/backend/Universe/Simulation/WorldService.cs index 44d313a..96985ee 100644 --- a/apps/backend/Universe/Simulation/WorldService.cs +++ b/apps/backend/Universe/Simulation/WorldService.cs @@ -315,6 +315,8 @@ public sealed class WorldService string.Equals(candidate.FactionId, faction.Id, StringComparison.Ordinal) && string.Equals(candidate.SystemId, system.Definition.Id, StringComparison.Ordinal)); var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, homeStation); + var spatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Anchors); + var localPosition = spatialState.LocalPosition ?? Vector3.Zero; var ship = new ShipRuntime { @@ -322,9 +324,9 @@ public sealed class WorldService SystemId = system.Definition.Id, Definition = definition, FactionId = faction.Id, - Position = spawnPosition, - TargetPosition = spawnPosition, - SpatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Celestials), + Position = localPosition, + TargetPosition = localPosition, + SpatialState = spatialState, DefaultBehavior = defaultBehavior, Skills = ShipBootstrapPolicy.CreateSkills(definition), Health = definition.Hull, @@ -352,15 +354,18 @@ public sealed class WorldService ? $"{faction.Label} {ToTitleCaseToken(objective)} {CountFactionStationsInSystem(faction.Id, system.Definition.Id) + 1}" : request.Label.Trim(); var stationId = $"station-{faction.Id}-{objective}-{Guid.NewGuid():N}".ToLowerInvariant(); - var position = ResolveStationSpawnPosition(system.Definition.Id); + var requestedPosition = ResolveStationSpawnPosition(system.Definition.Id); + var anchor = ResolveNearestConstructibleAnchor(system.Definition.Id, requestedPosition) + ?? throw new InvalidOperationException($"System '{system.Definition.Id}' does not have a valid constructible anchor for station spawning."); var station = new StationRuntime { Id = stationId, SystemId = system.Definition.Id, + AnchorId = anchor.Id, Label = label, Color = faction.Color, Objective = objective, - Position = position, + Position = Vector3.Zero, FactionId = faction.Id, PolicySetId = faction.DefaultPolicySetId, Health = 600f, @@ -375,6 +380,7 @@ public sealed class WorldService station.PopulationCapacity = GetStationSupportedPopulation(_world.ModuleDefinitions, station); station.WorkforceRequired = GetStationRequiredWorkforce(_world.ModuleDefinitions, station); _world.Stations.Add(station); + anchor.OccupyingStructureId = station.Id; new GeopoliticalSimulationService().Update(_world, 0f, []); PublishSnapshotRefreshUnsafe("spawn-station", $"Spawned station {station.Id}", "station", station.Id); @@ -490,6 +496,7 @@ public sealed class WorldService [], [], [], + [], null); _history.Enqueue(worldDelta); @@ -526,6 +533,7 @@ public sealed class WorldService [], [], [], + [], null); _history.Enqueue(worldDelta); @@ -608,6 +616,8 @@ public sealed class WorldService var shipId = $"ship-{playerFaction.Id}-{definition.Id}-{Guid.NewGuid():N}".ToLowerInvariant(); var spawnPosition = ResolveSpawnPosition(system.Definition.Id); var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, null); + var spatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Anchors); + var localPosition = spatialState.LocalPosition ?? Vector3.Zero; var ship = new ShipRuntime { @@ -615,9 +625,9 @@ public sealed class WorldService SystemId = system.Definition.Id, Definition = definition, FactionId = playerFaction.Id, - Position = spawnPosition, - TargetPosition = spawnPosition, - SpatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Celestials), + Position = localPosition, + TargetPosition = localPosition, + SpatialState = spatialState, DefaultBehavior = defaultBehavior, Skills = ShipBootstrapPolicy.CreateSkills(definition), Health = definition.Hull, @@ -712,7 +722,7 @@ public sealed class WorldService SourceStationId = request.SourceStationId, DestinationStationId = request.DestinationStationId, ItemId = request.ItemId, - NodeId = request.NodeId, + AnchorId = request.AnchorId, ConstructionSiteId = request.ConstructionSiteId, ModuleId = request.ModuleId, WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f), @@ -780,7 +790,7 @@ public sealed class WorldService ship.DefaultBehavior.AreaSystemId = request.AreaSystemId; ship.DefaultBehavior.TargetEntityId = request.TargetEntityId; ship.DefaultBehavior.ItemId = request.ItemId; - ship.DefaultBehavior.PreferredNodeId = request.PreferredNodeId; + ship.DefaultBehavior.PreferredAnchorId = request.PreferredAnchorId; ship.DefaultBehavior.PreferredConstructionSiteId = request.PreferredConstructionSiteId; ship.DefaultBehavior.PreferredModuleId = request.PreferredModuleId; ship.DefaultBehavior.TargetPosition = request.TargetPosition is null @@ -807,7 +817,7 @@ public sealed class WorldService SourceStationId = template.SourceStationId, DestinationStationId = template.DestinationStationId, ItemId = template.ItemId, - NodeId = template.NodeId, + AnchorId = template.AnchorId, ConstructionSiteId = template.ConstructionSiteId, ModuleId = template.ModuleId, WaitSeconds = template.WaitSeconds ?? 0f, @@ -905,6 +915,16 @@ public sealed class WorldService return new Vector3(MathF.Cos(angle) * radius, 0f, MathF.Sin(angle) * radius); } + private AnchorRuntime? ResolveNearestConstructibleAnchor(string systemId, Vector3 position) => + _world.Anchors + .Where(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal)) + .Where(candidate => SpatialBuilder.IsConstructibleAnchorKind(candidate.Kind)) + .OrderBy(candidate => candidate.Position.DistanceTo(position)) + .FirstOrDefault(); + + private string? ResolveNearestAnchorId(string systemId, Vector3 position) => + ResolveNearestConstructibleAnchor(systemId, position)?.Id; + private IReadOnlyList BuildStarterStationModules(string factionId, string objective) { var modules = new List(); @@ -1079,9 +1099,9 @@ public sealed class WorldService } var systemFilter = scope.SystemId; - if (string.Equals(scope.ScopeKind, "local-celestial", StringComparison.OrdinalIgnoreCase) && systemFilter is null && scope.CelestialId is not null) + if (string.Equals(scope.ScopeKind, "local-anchor", StringComparison.OrdinalIgnoreCase) && systemFilter is null && scope.AnchorId is not null) { - systemFilter = ResolveCelestialSystemId(scope.CelestialId); + systemFilter = ResolveAnchorSystemId(scope.AnchorId); } return delta with @@ -1091,6 +1111,7 @@ public sealed class WorldService .Where((evt) => IsEventVisibleToScope(evt, scope, systemFilter)) .ToList(), Celestials = delta.Celestials.Where((celestial) => systemFilter is null || celestial.SystemId == systemFilter).ToList(), + Anchors = delta.Anchors.Where((anchor) => systemFilter is null || anchor.SystemId == systemFilter).ToList(), Nodes = delta.Nodes.Where((node) => systemFilter is null || node.SystemId == systemFilter).ToList(), Stations = delta.Stations.Where((station) => systemFilter is null || station.SystemId == systemFilter).ToList(), Claims = delta.Claims.Where((claim) => systemFilter is null || claim.SystemId == systemFilter).ToList(), @@ -1136,8 +1157,8 @@ public sealed class WorldService ScopeEntityId = scopeEntityId, }; - private string? ResolveCelestialSystemId(string celestialId) => - _world.Celestials.FirstOrDefault((c) => c.Id == celestialId)?.SystemId; + private string? ResolveAnchorSystemId(string anchorId) => + _world.Anchors.FirstOrDefault((anchor) => anchor.Id == anchorId)?.SystemId; private string? ResolveMarketOrderSystemId(string orderId) { @@ -1181,7 +1202,7 @@ public sealed class WorldService { "universe" => true, "system" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter, - "local-celestial" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter, + "local-anchor" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter, _ => true, }; } diff --git a/apps/viewer/src/App.vue b/apps/viewer/src/App.vue index 8f11ba6..065c03b 100644 --- a/apps/viewer/src/App.vue +++ b/apps/viewer/src/App.vue @@ -1,6 +1,6 @@