Complete universe model migration

This commit is contained in:
2026-04-07 14:16:59 -04:00
parent d0c6e30304
commit 6c92ab50c8
76 changed files with 2061 additions and 1072 deletions

View File

@@ -1097,14 +1097,14 @@ internal sealed class CommanderPlanningService
{ {
theaters.Add(new FactionTheaterRuntime theaters.Add(new FactionTheaterRuntime
{ {
Id = $"theater-expansion-{expansionProject.SystemId}-{expansionProject.CelestialId}", Id = $"theater-expansion-{expansionProject.SystemId}-{expansionProject.AnchorId}",
Kind = "expansion-front", Kind = "expansion-front",
SystemId = expansionProject.SystemId, SystemId = expansionProject.SystemId,
Status = "active", Status = "active",
Priority = 65f + (economicAssessment.HasShipyard ? 0f : 15f), Priority = 65f + (economicAssessment.HasShipyard ? 0f : 15f),
SupplyRisk = ComputeSystemRisk(world, faction, expansionProject.SystemId), SupplyRisk = ComputeSystemRisk(world, faction, expansionProject.SystemId),
FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, expansionProject.SystemId), FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, expansionProject.SystemId),
AnchorEntityId = expansionProject.SiteId ?? expansionProject.CelestialId, AnchorEntityId = expansionProject.SiteId ?? expansionProject.AnchorId,
AnchorPosition = ResolveExpansionAnchor(world, expansionProject), AnchorPosition = ResolveExpansionAnchor(world, expansionProject),
UpdatedAtUtc = nowUtc, UpdatedAtUtc = nowUtc,
}); });
@@ -1272,7 +1272,7 @@ internal sealed class CommanderPlanningService
], ],
"expansion" => "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}-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." }, 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, AreaSystemId = areaSystemId,
TargetEntityId = objective.TargetEntityId, TargetEntityId = objective.TargetEntityId,
ItemId = objective.ItemId ?? fallback.ItemId, ItemId = objective.ItemId ?? fallback.ItemId,
PreferredNodeId = fallback.PreferredNodeId, PreferredAnchorId = fallback.PreferredAnchorId,
PreferredConstructionSiteId = objective.Kind is "construct-site" or "supply-site" ? objective.TargetEntityId : fallback.PreferredConstructionSiteId, PreferredConstructionSiteId = objective.Kind is "construct-site" or "supply-site" ? objective.TargetEntityId : fallback.PreferredConstructionSiteId,
PreferredModuleId = fallback.PreferredModuleId, PreferredModuleId = fallback.PreferredModuleId,
TargetPosition = objective.TargetPosition ?? fallback.TargetPosition, TargetPosition = objective.TargetPosition ?? fallback.TargetPosition,
@@ -2750,7 +2750,7 @@ internal sealed class CommanderPlanningService
target.AreaSystemId = source.AreaSystemId; target.AreaSystemId = source.AreaSystemId;
target.TargetEntityId = source.TargetEntityId; target.TargetEntityId = source.TargetEntityId;
target.ItemId = source.ItemId; target.ItemId = source.ItemId;
target.PreferredNodeId = source.PreferredNodeId; target.PreferredAnchorId = source.PreferredAnchorId;
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId; target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
target.PreferredModuleId = source.PreferredModuleId; target.PreferredModuleId = source.PreferredModuleId;
target.TargetPosition = source.TargetPosition; target.TargetPosition = source.TargetPosition;
@@ -2771,7 +2771,7 @@ internal sealed class CommanderPlanningService
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal) && string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
&& string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, 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.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal) && string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
&& Nullable.Equals(left.TargetPosition, right.TargetPosition) && 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.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, 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.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
&& left.WaitSeconds.Equals(right.WaitSeconds) && left.WaitSeconds.Equals(right.WaitSeconds)
@@ -2863,9 +2863,10 @@ internal sealed class CommanderPlanningService
} }
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId); 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; return null;
@@ -2919,7 +2920,7 @@ internal sealed class CommanderPlanningService
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, 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.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
&& left.WaitSeconds.Equals(right.WaitSeconds) && left.WaitSeconds.Equals(right.WaitSeconds)
@@ -3382,7 +3383,7 @@ internal sealed class CommanderPlanningService
{ {
"defense-front" => $"Defend {theater.SystemId} from hostile pressure.", "defense-front" => $"Defend {theater.SystemId} from hostile pressure.",
"offense-front" => $"Project force into {theater.SystemId}.", "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}.", "economic-front" => $"Stabilize commodity shortages around {theater.AnchorEntityId ?? theater.SystemId}.",
_ => theater.Kind, _ => theater.Kind,
}; };
@@ -3424,13 +3425,13 @@ internal sealed class CommanderPlanningService
private static Vector3 ResolveExpansionAnchor(SimulationWorld world, IndustryExpansionProject project) private static Vector3 ResolveExpansionAnchor(SimulationWorld world, IndustryExpansionProject project)
{ {
if (project.SiteId is not null if (project.SiteId is not null
&& world.ConstructionSites.FirstOrDefault(site => site.Id == project.SiteId) is { } site && world.ConstructionSites.FirstOrDefault(site => site.Id == project.SiteId) is { } site)
&& world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId) is { } siteCelestial)
{ {
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); ?? ResolveSystemAnchor(world, project.SystemId);
} }

View File

@@ -88,7 +88,7 @@ public sealed record TerritoryClaimSnapshot(
string? SourceClaimId, string? SourceClaimId,
string FactionId, string FactionId,
string SystemId, string SystemId,
string CelestialId, string AnchorId,
string Status, string Status,
string ClaimKind, string ClaimKind,
float ClaimStrength, float ClaimStrength,

View File

@@ -126,7 +126,7 @@ public sealed class TerritoryClaimRuntime
public string? SourceClaimId { get; set; } public string? SourceClaimId { get; set; }
public required string FactionId { get; set; } public required string FactionId { get; set; }
public required string SystemId { 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 Status { get; set; } = "active";
public string ClaimKind { get; set; } = "infrastructure"; public string ClaimKind { get; set; } = "infrastructure";
public float ClaimStrength { get; set; } public float ClaimStrength { get; set; }

View File

@@ -161,7 +161,7 @@ internal sealed class GeopoliticalSimulationService
SourceClaimId = claim.Id, SourceClaimId = claim.Id,
FactionId = claim.FactionId, FactionId = claim.FactionId,
SystemId = claim.SystemId, SystemId = claim.SystemId,
CelestialId = claim.CelestialId, AnchorId = claim.AnchorId,
Status = claim.State, Status = claim.State,
ClaimKind = "infrastructure", ClaimKind = "infrastructure",
ClaimStrength = claim.State == ClaimStateKinds.Active ? 1f : 0.65f, ClaimStrength = claim.State == ClaimStateKinds.Active ? 1f : 0.65f,

View File

@@ -21,13 +21,13 @@ internal static class FactionIndustryPlanner
return null; return null;
} }
var targetCelestial = SelectFoundationCelestial(world, factionId, bottleneckCommodity); var targetAnchor = SelectFoundationAnchor(world, factionId, bottleneckCommodity);
if (targetCelestial is null) if (targetAnchor is null)
{ {
return null; return null;
} }
var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId); var supportStation = SelectSupportStation(world, factionId, moduleId, targetAnchor.SystemId);
if (supportStation is null) if (supportStation is null)
{ {
return null; return null;
@@ -36,8 +36,8 @@ internal static class FactionIndustryPlanner
return new IndustryExpansionProject( return new IndustryExpansionProject(
bottleneckCommodity, bottleneckCommodity,
moduleId, moduleId,
targetCelestial.SystemId, targetAnchor.SystemId,
targetCelestial.Id, targetAnchor.Id,
supportStation.Id); supportStation.Id);
} }
@@ -93,13 +93,13 @@ internal static class FactionIndustryPlanner
return null; return null;
} }
var targetCelestial = SelectLogisticsFoundationCelestial(world, factionId); var targetAnchor = SelectLogisticsFoundationAnchor(world, factionId);
if (targetCelestial is null) if (targetAnchor is null)
{ {
return null; return null;
} }
var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetCelestial.SystemId); var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetAnchor.SystemId);
if (supportStation is null) if (supportStation is null)
{ {
return null; return null;
@@ -108,8 +108,8 @@ internal static class FactionIndustryPlanner
return new IndustryExpansionProject( return new IndustryExpansionProject(
"shipyard", "shipyard",
shipyardModuleId, shipyardModuleId,
targetCelestial.SystemId, targetAnchor.SystemId,
targetCelestial.Id, targetAnchor.Id,
supportStation.Id); supportStation.Id);
} }
@@ -129,13 +129,13 @@ internal static class FactionIndustryPlanner
return null; return null;
} }
var bootstrapCelestial = SelectFoundationCelestial(world, factionId, bootstrapCommodity); var bootstrapAnchor = SelectFoundationAnchor(world, factionId, bootstrapCommodity);
if (bootstrapCelestial is null) if (bootstrapAnchor is null)
{ {
return null; return null;
} }
var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapCelestial.SystemId); var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapAnchor.SystemId);
if (bootstrapSupportStation is null) if (bootstrapSupportStation is null)
{ {
return null; return null;
@@ -144,8 +144,8 @@ internal static class FactionIndustryPlanner
return new IndustryExpansionProject( return new IndustryExpansionProject(
bootstrapCommodity, bootstrapCommodity,
bootstrapModuleId, bootstrapModuleId,
bootstrapCelestial.SystemId, bootstrapAnchor.SystemId,
bootstrapCelestial.Id, bootstrapAnchor.Id,
bootstrapSupportStation.Id); bootstrapSupportStation.Id);
} }
@@ -161,13 +161,13 @@ internal static class FactionIndustryPlanner
return null; return null;
} }
var targetCelestial = SelectFoundationCelestial(world, factionId, commodityId); var targetAnchor = SelectFoundationAnchor(world, factionId, commodityId);
if (targetCelestial is null) if (targetAnchor is null)
{ {
return null; return null;
} }
var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId); var supportStation = SelectSupportStation(world, factionId, moduleId, targetAnchor.SystemId);
if (supportStation is null) if (supportStation is null)
{ {
return null; return null;
@@ -176,8 +176,8 @@ internal static class FactionIndustryPlanner
return new IndustryExpansionProject( return new IndustryExpansionProject(
commodityId, commodityId,
moduleId, moduleId,
targetCelestial.SystemId, targetAnchor.SystemId,
targetCelestial.Id, targetAnchor.Id,
supportStation.Id); supportStation.Id);
} }
@@ -207,7 +207,7 @@ internal static class FactionIndustryPlanner
site.TargetDefinitionId, site.TargetDefinitionId,
site.BlueprintId, site.BlueprintId,
site.SystemId, site.SystemId,
site.CelestialId, site.AnchorId,
supportStationId, supportStationId,
site.Id); site.Id);
} }
@@ -225,7 +225,7 @@ internal static class FactionIndustryPlanner
} }
var nowUtc = DateTimeOffset.UtcNow; var nowUtc = DateTimeOffset.UtcNow;
var claimId = $"claim-{factionId}-{project.CelestialId}"; var claimId = $"claim-{factionId}-{project.AnchorId}";
if (world.Claims.All(candidate => candidate.Id != claimId)) if (world.Claims.All(candidate => candidate.Id != claimId))
{ {
world.Claims.Add(new ClaimRuntime world.Claims.Add(new ClaimRuntime
@@ -233,7 +233,7 @@ internal static class FactionIndustryPlanner
Id = claimId, Id = claimId,
FactionId = factionId, FactionId = factionId,
SystemId = project.SystemId, SystemId = project.SystemId,
CelestialId = project.CelestialId, AnchorId = project.AnchorId,
PlacedAtUtc = nowUtc, PlacedAtUtc = nowUtc,
ActivatesAtUtc = nowUtc.AddSeconds(8), ActivatesAtUtc = nowUtc.AddSeconds(8),
State = ClaimStateKinds.Activating, State = ClaimStateKinds.Activating,
@@ -246,7 +246,7 @@ internal static class FactionIndustryPlanner
return; return;
} }
var siteId = $"site-{factionId}-{project.CelestialId}"; var siteId = $"site-{factionId}-{project.AnchorId}";
if (world.ConstructionSites.Any(candidate => candidate.Id == siteId)) if (world.ConstructionSites.Any(candidate => candidate.Id == siteId))
{ {
return; return;
@@ -257,7 +257,7 @@ internal static class FactionIndustryPlanner
Id = siteId, Id = siteId,
FactionId = factionId, FactionId = factionId,
SystemId = project.SystemId, SystemId = project.SystemId,
CelestialId = project.CelestialId, AnchorId = project.AnchorId,
TargetKind = "station-foundation", TargetKind = "station-foundation",
TargetDefinitionId = project.CommodityId, TargetDefinitionId = project.CommodityId,
BlueprintId = project.ModuleId, BlueprintId = project.ModuleId,
@@ -450,51 +450,51 @@ internal static class FactionIndustryPlanner
private static float GetTargetLevelSeconds(string itemId) => private static float GetTargetLevelSeconds(string itemId) =>
string.Equals(itemId, "water", StringComparison.Ordinal) ? WaterTargetLevelSeconds : CommodityTargetLevelSeconds; 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); var resourceItems = ResolveRootResourceItems(world, commodityId);
return world.Celestials return world.Anchors
.Where(celestial => .Where(anchor =>
celestial.Kind == SpatialNodeKind.LagrangePoint anchor.Kind == SpatialNodeKind.LagrangePoint
&& celestial.OccupyingStructureId is null && anchor.OccupyingStructureId is null
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed) && world.Claims.All(claim => claim.AnchorId != anchor.Id || claim.State == ClaimStateKinds.Destroyed)
&& IsExpansionSystemEligible(world, factionId, celestial.SystemId)) && IsExpansionSystemEligible(world, factionId, anchor.SystemId))
.OrderByDescending(celestial => ScoreCelestial(world, factionId, celestial, resourceItems)) .OrderByDescending(anchor => ScoreAnchor(world, factionId, anchor, resourceItems))
.FirstOrDefault(); .FirstOrDefault();
} }
private static CelestialRuntime? SelectLogisticsFoundationCelestial(SimulationWorld world, string factionId) private static AnchorRuntime? SelectLogisticsFoundationAnchor(SimulationWorld world, string factionId)
{ {
return world.Celestials return world.Anchors
.Where(celestial => .Where(anchor =>
celestial.Kind == SpatialNodeKind.LagrangePoint anchor.Kind == SpatialNodeKind.LagrangePoint
&& celestial.OccupyingStructureId is null && anchor.OccupyingStructureId is null
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed) && world.Claims.All(claim => claim.AnchorId != anchor.Id || claim.State == ClaimStateKinds.Destroyed)
&& IsExpansionSystemEligible(world, factionId, celestial.SystemId)) && IsExpansionSystemEligible(world, factionId, anchor.SystemId))
.OrderByDescending(celestial => world.Stations.Count(station => .OrderByDescending(anchor => world.Stations.Count(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal) string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal))) && string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal)))
.ThenByDescending(celestial => world.Stations .ThenByDescending(anchor => world.Stations
.Where(station => .Where(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal) 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())) .Sum(station => station.Inventory.Values.Sum()))
.FirstOrDefault(); .FirstOrDefault();
} }
private static float ScoreCelestial(SimulationWorld world, string factionId, CelestialRuntime celestial, IReadOnlyCollection<string> resourceItems) private static float ScoreAnchor(SimulationWorld world, string factionId, AnchorRuntime anchor, IReadOnlyCollection<string> resourceItems)
{ {
var resourceScore = world.Nodes 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); .Sum(node => node.OreRemaining);
var factionPresence = world.Stations.Count(station => var factionPresence = world.Stations.Count(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal) string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)); && string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal));
var controlState = GeopoliticalSimulationService.GetSystemControlState(world, celestial.SystemId); var controlState = GeopoliticalSimulationService.GetSystemControlState(world, anchor.SystemId);
var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, celestial.SystemId); var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, anchor.SystemId);
var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == celestial.SystemId); var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == anchor.SystemId);
var pressure = world.Geopolitics?.Territory.Pressures 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) .OrderByDescending(entry => entry.HostileInfluence)
.ThenBy(entry => entry.Id, StringComparer.Ordinal) .ThenBy(entry => entry.Id, StringComparer.Ordinal)
.FirstOrDefault(); .FirstOrDefault();
@@ -515,7 +515,7 @@ internal static class FactionIndustryPlanner
}; };
var securityPenalty = ((pressure?.HostileInfluence ?? 0f) * 14f) var securityPenalty = ((pressure?.HostileInfluence ?? 0f) * 14f)
+ ((strategicProfile?.TerritorialPressure ?? 0f) * 9f) + ((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 return resourceScore
+ (factionPresence * 5_000f) + (factionPresence * 5_000f)
+ controlBias + controlBias
@@ -585,6 +585,6 @@ internal sealed record IndustryExpansionProject(
string CommodityId, string CommodityId,
string ModuleId, string ModuleId,
string SystemId, string SystemId,
string CelestialId, string AnchorId,
string SupportStationId, string SupportStationId,
string? SiteId = null); string? SiteId = null);

View File

@@ -17,16 +17,15 @@ public sealed class GetPlayerIdentitiesHandler(IAuthRepository authRepository, I
public override async Task HandleAsync(CancellationToken cancellationToken) public override async Task HandleAsync(CancellationToken cancellationToken)
{ {
var users = await authRepository.ListUsersAsync(cancellationToken); var users = await authRepository.ListUsersAsync(cancellationToken);
var playerFactionsById = playerStateStore.GetPlayerFactions() var playerFactionsByPlayerId = playerStateStore.GetPlayerFactionsByPlayerId();
.ToDictionary(player => player.Id, StringComparer.Ordinal);
var responses = new List<PlayerIdentitySummaryResponse>(users.Count + playerFactionsById.Count); var responses = new List<PlayerIdentitySummaryResponse>(users.Count + playerFactionsByPlayerId.Count);
var seenIds = new HashSet<string>(StringComparer.Ordinal); var seenIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var user in users) foreach (var user in users)
{ {
var userId = user.Id.ToString("N"); var userId = user.Id.ToString("N");
playerFactionsById.TryGetValue(userId, out var playerFaction); playerFactionsByPlayerId.TryGetValue(userId, out var playerFaction);
responses.Add(new PlayerIdentitySummaryResponse( responses.Add(new PlayerIdentitySummaryResponse(
userId, userId,
user.Email, user.Email,
@@ -38,19 +37,19 @@ public sealed class GetPlayerIdentitiesHandler(IAuthRepository authRepository, I
seenIds.Add(userId); 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; continue;
} }
responses.Add(new PlayerIdentitySummaryResponse( responses.Add(new PlayerIdentitySummaryResponse(
playerFaction.Id, playerId,
$"{playerFaction.Id}@unknown", $"{playerId}@unknown",
Array.Empty<string>(), Array.Empty<string>(),
true, true,
playerFaction.Id, playerId,
playerFaction.Label, playerFaction.Label,
playerFaction.SovereignFactionId)); playerFaction.SovereignFactionId));
} }

View File

@@ -194,7 +194,7 @@ public sealed record PlayerDirectiveSnapshot(
bool UseOrders, bool UseOrders,
string? StagingOrderKind, string? StagingOrderKind,
string? ItemId, string? ItemId,
string? PreferredNodeId, string? PreferredAnchorId,
string? PreferredConstructionSiteId, string? PreferredConstructionSiteId,
string? PreferredModuleId, string? PreferredModuleId,
int Priority, int Priority,

View File

@@ -45,7 +45,7 @@ public sealed record PlayerDirectiveCommandRequest(
string? SourceStationId, string? SourceStationId,
string? DestinationStationId, string? DestinationStationId,
string? ItemId, string? ItemId,
string? PreferredNodeId, string? PreferredAnchorId,
string? PreferredConstructionSiteId, string? PreferredConstructionSiteId,
string? PreferredModuleId, string? PreferredModuleId,
int Priority, int Priority,

View File

@@ -251,7 +251,7 @@ public sealed class PlayerDirectiveRuntime
public bool UseOrders { get; set; } public bool UseOrders { get; set; }
public string? StagingOrderKind { get; set; } public string? StagingOrderKind { get; set; }
public string? ItemId { get; set; } public string? ItemId { get; set; }
public string? PreferredNodeId { get; set; } public string? PreferredAnchorId { get; set; }
public string? PreferredConstructionSiteId { get; set; } public string? PreferredConstructionSiteId { get; set; }
public string? PreferredModuleId { get; set; } public string? PreferredModuleId { get; set; }
public int Priority { get; set; } = 50; public int Priority { get; set; } = 50;

View File

@@ -5,5 +5,6 @@ public interface IPlayerStateStore
bool TryGetPlayerFaction(string playerId, out PlayerFactionRuntime playerFaction); bool TryGetPlayerFaction(string playerId, out PlayerFactionRuntime playerFaction);
PlayerFactionRuntime GetOrAddPlayerFaction(string playerId, Func<PlayerFactionRuntime> factory); PlayerFactionRuntime GetOrAddPlayerFaction(string playerId, Func<PlayerFactionRuntime> factory);
IReadOnlyCollection<PlayerFactionRuntime> GetPlayerFactions(); IReadOnlyCollection<PlayerFactionRuntime> GetPlayerFactions();
IReadOnlyDictionary<string, PlayerFactionRuntime> GetPlayerFactionsByPlayerId();
void Clear(); void Clear();
} }

View File

@@ -201,7 +201,7 @@ public sealed class PlayerFactionProjectionService
directive.UseOrders, directive.UseOrders,
directive.StagingOrderKind, directive.StagingOrderKind,
directive.ItemId, directive.ItemId,
directive.PreferredNodeId, directive.PreferredAnchorId,
directive.PreferredConstructionSiteId, directive.PreferredConstructionSiteId,
directive.PreferredModuleId, directive.PreferredModuleId,
directive.Priority, directive.Priority,
@@ -261,7 +261,7 @@ public sealed class PlayerFactionProjectionService
template.SourceStationId, template.SourceStationId,
template.DestinationStationId, template.DestinationStationId,
template.ItemId, template.ItemId,
template.NodeId, template.AnchorId,
template.ConstructionSiteId, template.ConstructionSiteId,
template.ModuleId, template.ModuleId,
template.WaitSeconds, template.WaitSeconds,

View File

@@ -329,7 +329,7 @@ internal sealed class PlayerFactionService
directive.SourceStationId = request.SourceStationId; directive.SourceStationId = request.SourceStationId;
directive.DestinationStationId = request.DestinationStationId; directive.DestinationStationId = request.DestinationStationId;
directive.ItemId = request.ItemId; directive.ItemId = request.ItemId;
directive.PreferredNodeId = request.PreferredNodeId; directive.PreferredAnchorId = request.PreferredAnchorId;
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId; directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
directive.PreferredModuleId = request.PreferredModuleId; directive.PreferredModuleId = request.PreferredModuleId;
directive.Priority = request.Priority; directive.Priority = request.Priority;
@@ -355,7 +355,7 @@ internal sealed class PlayerFactionService
SourceStationId = template.SourceStationId, SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId, DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId, ItemId = template.ItemId,
NodeId = template.NodeId, AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId, ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId, ModuleId = template.ModuleId,
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
@@ -501,7 +501,7 @@ internal sealed class PlayerFactionService
SourceStationId = template.SourceStationId, SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId, DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId, ItemId = template.ItemId,
NodeId = template.NodeId, AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId, ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId, ModuleId = template.ModuleId,
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
@@ -692,7 +692,7 @@ internal sealed class PlayerFactionService
SourceStationId = request.SourceStationId, SourceStationId = request.SourceStationId,
DestinationStationId = request.DestinationStationId, DestinationStationId = request.DestinationStationId,
ItemId = request.ItemId, ItemId = request.ItemId,
NodeId = request.NodeId, AnchorId = request.AnchorId,
ConstructionSiteId = request.ConstructionSiteId, ConstructionSiteId = request.ConstructionSiteId,
ModuleId = request.ModuleId, ModuleId = request.ModuleId,
WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f), WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f),
@@ -805,7 +805,7 @@ internal sealed class PlayerFactionService
directive.SourceStationId = request.HomeStationId; directive.SourceStationId = request.HomeStationId;
directive.DestinationStationId = null; directive.DestinationStationId = null;
directive.ItemId = request.ItemId; directive.ItemId = request.ItemId;
directive.PreferredNodeId = request.PreferredNodeId; directive.PreferredAnchorId = request.PreferredAnchorId;
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId; directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
directive.PreferredModuleId = request.PreferredModuleId; directive.PreferredModuleId = request.PreferredModuleId;
directive.Priority = 100; directive.Priority = 100;
@@ -831,7 +831,7 @@ internal sealed class PlayerFactionService
SourceStationId = template.SourceStationId, SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId, DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId, ItemId = template.ItemId,
NodeId = template.NodeId, AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId, ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId, ModuleId = template.ModuleId,
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), 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, AreaSystemId = directive?.TargetSystemId ?? directive?.HomeSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId,
TargetEntityId = directive?.TargetEntityId, TargetEntityId = directive?.TargetEntityId,
ItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.ItemId, 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, PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId,
PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId, PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId,
TargetPosition = directive?.TargetPosition, TargetPosition = directive?.TargetPosition,
@@ -1461,7 +1461,7 @@ internal sealed class PlayerFactionService
SourceStationId = directive.SourceStationId ?? directive.HomeStationId, SourceStationId = directive.SourceStationId ?? directive.HomeStationId,
DestinationStationId = directive.DestinationStationId, DestinationStationId = directive.DestinationStationId,
ItemId = directive.ItemId, ItemId = directive.ItemId,
NodeId = directive.PreferredNodeId, AnchorId = directive.PreferredAnchorId,
ConstructionSiteId = directive.PreferredConstructionSiteId, ConstructionSiteId = directive.PreferredConstructionSiteId,
ModuleId = directive.PreferredModuleId, ModuleId = directive.PreferredModuleId,
WaitSeconds = directive.WaitSeconds, WaitSeconds = directive.WaitSeconds,
@@ -1525,7 +1525,7 @@ internal sealed class PlayerFactionService
target.AreaSystemId = source.AreaSystemId; target.AreaSystemId = source.AreaSystemId;
target.TargetEntityId = source.TargetEntityId; target.TargetEntityId = source.TargetEntityId;
target.ItemId = source.ItemId; target.ItemId = source.ItemId;
target.PreferredNodeId = source.PreferredNodeId; target.PreferredAnchorId = source.PreferredAnchorId;
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId; target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
target.PreferredModuleId = source.PreferredModuleId; target.PreferredModuleId = source.PreferredModuleId;
target.TargetPosition = source.TargetPosition; target.TargetPosition = source.TargetPosition;
@@ -1546,7 +1546,7 @@ internal sealed class PlayerFactionService
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal) && string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
&& string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, 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.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal) && string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
&& Nullable.Equals(left.TargetPosition, right.TargetPosition) && 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.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, 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.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
&& left.WaitSeconds.Equals(right.WaitSeconds) && left.WaitSeconds.Equals(right.WaitSeconds)
@@ -1589,7 +1589,7 @@ internal sealed class PlayerFactionService
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, 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.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
&& left.WaitSeconds.Equals(right.WaitSeconds) && left.WaitSeconds.Equals(right.WaitSeconds)
@@ -1634,7 +1634,7 @@ internal sealed class PlayerFactionService
SourceStationId = template.SourceStationId, SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId, DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId, ItemId = template.ItemId,
NodeId = template.NodeId, AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId, ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId, ModuleId = template.ModuleId,
WaitSeconds = template.WaitSeconds, WaitSeconds = template.WaitSeconds,

View File

@@ -22,5 +22,8 @@ public sealed class PlayerStateStore : IPlayerStateStore
public IReadOnlyCollection<PlayerFactionRuntime> GetPlayerFactions() => public IReadOnlyCollection<PlayerFactionRuntime> GetPlayerFactions() =>
_playerFactions.Values.ToList(); _playerFactions.Values.ToList();
public IReadOnlyDictionary<string, PlayerFactionRuntime> GetPlayerFactionsByPlayerId() =>
new Dictionary<string, PlayerFactionRuntime>(_playerFactions, StringComparer.Ordinal);
public void Clear() => _playerFactions.Clear(); public void Clear() => _playerFactions.Clear();
} }

View File

@@ -6,6 +6,7 @@ public enum SpatialNodeKind
Planet, Planet,
Moon, Moon,
LagrangePoint, LagrangePoint,
ResourceNode,
} }
public enum WorkStatus public enum WorkStatus
@@ -286,6 +287,7 @@ public static class SimulationEnumMappings
SpatialNodeKind.Planet => "planet", SpatialNodeKind.Planet => "planet",
SpatialNodeKind.Moon => "moon", SpatialNodeKind.Moon => "moon",
SpatialNodeKind.LagrangePoint => "lagrange-point", SpatialNodeKind.LagrangePoint => "lagrange-point",
SpatialNodeKind.ResourceNode => "resource-node",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
}; };

View File

@@ -288,7 +288,7 @@ public sealed partial class ShipAiService
TargetSystemId = opportunity.Node.SystemId, TargetSystemId = opportunity.Node.SystemId,
DestinationStationId = opportunity.DropOffStation.Id, DestinationStationId = opportunity.DropOffStation.Id,
ItemId = opportunity.Node.ItemId, ItemId = opportunity.Node.ItemId,
NodeId = opportunity.Node.Id, AnchorId = opportunity.Node.AnchorId,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
}; };
@@ -509,7 +509,7 @@ public sealed partial class ShipAiService
SourceStationId = template.SourceStationId, SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId, DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId, ItemId = template.ItemId,
NodeId = template.NodeId, AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId, ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId, ModuleId = template.ModuleId,
WaitSeconds = template.WaitSeconds, 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) if (node is null)
{ {
ship.LastAccessFailureReason = "no-mineable-node"; ship.LastAccessFailureReason = "no-mineable-node";
@@ -578,8 +578,9 @@ public sealed partial class ShipAiService
Priority = 0, Priority = 0,
InterruptCurrentPlan = false, InterruptCurrentPlan = false,
Label = $"Mine {itemId} in {systemId}", Label = $"Mine {itemId} in {systemId}",
TargetEntityId = node.Id,
TargetSystemId = node.SystemId, TargetSystemId = node.SystemId,
NodeId = node.Id, AnchorId = node.AnchorId,
ItemId = node.ItemId, ItemId = node.ItemId,
WaitSeconds = 0f, WaitSeconds = 0f,
Radius = 0f, Radius = 0f,
@@ -601,7 +602,7 @@ public sealed partial class ShipAiService
&& left.TargetPosition == right.TargetPosition && left.TargetPosition == right.TargetPosition
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, 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.WaitSeconds.Equals(right.WaitSeconds)
&& left.Radius.Equals(right.Radius) && left.Radius.Equals(right.Radius)
&& left.MaxSystemRange == right.MaxSystemRange && left.MaxSystemRange == right.MaxSystemRange

View File

@@ -69,7 +69,7 @@ public sealed partial class ShipAiService
} }
var targetPosition = ResolveCurrentTargetPosition(world, subTask); var targetPosition = ResolveCurrentTargetPosition(world, subTask);
var targetCelestial = ResolveTravelTargetCelestial(world, subTask, targetPosition); var targetAnchor = ResolveTravelTargetAnchor(world, subTask, targetPosition);
ship.TargetPosition = targetPosition; ship.TargetPosition = targetPosition;
if (ship.SystemId != subTask.TargetSystemId) if (ship.SystemId != subTask.TargetSystemId)
@@ -81,32 +81,33 @@ public sealed partial class ShipAiService
return SubTaskOutcome.Failed; return SubTaskOutcome.Failed;
} }
var destinationEntryCelestial = ResolveSystemEntryCelestial(world, subTask.TargetSystemId); var destinationEntryAnchor = ResolveSystemEntryAnchor(world, subTask.TargetSystemId) ?? targetAnchor;
var destinationEntryPosition = destinationEntryCelestial?.Position ?? targetPosition; var destinationEntryPosition = destinationEntryAnchor?.Position ?? targetPosition;
return UpdateFtlTransit(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, destinationEntryPosition, destinationEntryCelestial, completeOnArrival, targetPosition); return UpdateFtlTransit(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, destinationEntryPosition, destinationEntryAnchor, completeOnArrival, targetPosition, targetAnchor);
} }
var currentCelestial = ResolveCurrentCelestial(world, ship); var currentAnchor = ResolveCurrentAnchor(world, ship);
if (targetCelestial is not null if (targetAnchor is not null
&& currentCelestial is not null && currentAnchor is not null
&& !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal)) && !string.Equals(currentAnchor.Id, targetAnchor.Id, StringComparison.Ordinal))
{ {
if (!CanWarp(ship.Definition)) 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 if (targetAnchor is not null
&& ship.Position.DistanceTo(targetPosition) > WarpEngageDistanceKilometers && currentAnchor is not null
&& !string.Equals(currentAnchor.Id, targetAnchor.Id, StringComparison.Ordinal)
&& CanWarp(ship.Definition)) && 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) 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) 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)) if (node is null || !CanExtractNode(ship, node, world))
{ {
subTask.BlockingReason = "node-missing"; subTask.BlockingReason = "node-missing";
@@ -165,9 +166,28 @@ public sealed partial class ShipAiService
return SubTaskOutcome.Failed; 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; ship.TargetPosition = targetPosition;
if (ship.Position.DistanceTo(targetPosition) > MathF.Max(subTask.Threshold, 8f)) if (distanceToTarget > approachThreshold && !effectivelyAtDeposit)
{ {
ship.State = ShipState.MiningApproach; ship.State = ShipState.MiningApproach;
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); 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 remainingCapacity = MathF.Max(0f, ship.Definition.GetTotalCargoCapacity() - cargoAmount);
var mined = MathF.Min(balance.MiningRate * GetSkillFactor(ship.Skills.Mining), remainingCapacity); 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) if (mined <= 0.01f)
{ {
return SubTaskOutcome.Completed; return SubTaskOutcome.Completed;
} }
AddInventory(ship.Inventory, node.ItemId, mined); 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) if (GetShipCargoAmount(ship) >= ship.Definition.GetTotalCargoCapacity() - 0.01f || node.OreRemaining <= 0.01f)
{ {
return SubTaskOutcome.Completed; return SubTaskOutcome.Completed;
@@ -605,15 +626,22 @@ public sealed partial class ShipAiService
float deltaSeconds, float deltaSeconds,
string targetSystemId, string targetSystemId,
Vector3 targetPosition, Vector3 targetPosition,
CelestialRuntime? targetCelestial, AnchorRuntime? currentAnchor,
AnchorRuntime? targetAnchor,
bool completeOnArrival) bool completeOnArrival)
{ {
var distance = ship.Position.DistanceTo(targetPosition); var distance = ship.Position.DistanceTo(targetPosition);
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace; ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
ship.SpatialState.Transit = null; 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); 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)) if (distance <= MathF.Max(subTask.Threshold, balance.ArrivalThreshold))
{ {
@@ -621,13 +649,26 @@ public sealed partial class ShipAiService
ship.TargetPosition = targetPosition; ship.TargetPosition = targetPosition;
ship.SystemId = targetSystemId; ship.SystemId = targetSystemId;
ship.SpatialState.CurrentSystemId = 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; ship.State = ShipState.Arriving;
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
} }
ship.State = ShipState.LocalFlight; ship.State = ShipState.LocalFlight;
ship.SpatialState.CurrentAnchorId = currentAnchor?.Id;
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); 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; return SubTaskOutcome.Active;
} }
@@ -637,18 +678,24 @@ public sealed partial class ShipAiService
ShipSubTaskRuntime subTask, ShipSubTaskRuntime subTask,
float deltaSeconds, float deltaSeconds,
Vector3 targetPosition, Vector3 targetPosition,
CelestialRuntime targetCelestial, AnchorRuntime currentAnchor,
AnchorRuntime targetAnchor,
bool completeOnArrival) bool completeOnArrival)
{ {
var transit = ship.SpatialState.Transit; 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 transit = new ShipTransitRuntime
{ {
Regime = MovementRegimeKind.Warp, Regime = MovementRegimeKind.Warp,
OriginNodeId = ship.SpatialState.CurrentCelestialId, OriginAnchorId = currentAnchor.Id,
DestinationNodeId = targetCelestial.Id, DestinationAnchorId = targetAnchor.Id,
StartedAtUtc = world.GeneratedAtUtc, StartedAtUtc = world.GeneratedAtUtc,
ArrivalDueAtUtc = world.GeneratedAtUtc.AddSeconds(initialSpoolDuration + initialTravelDuration),
}; };
ship.SpatialState.Transit = transit; ship.SpatialState.Transit = transit;
subTask.ElapsedSeconds = 0f; subTask.ElapsedSeconds = 0f;
@@ -656,33 +703,47 @@ public sealed partial class ShipAiService
ship.SpatialState.SpaceLayer = SpaceLayerKind.SystemSpace; ship.SpatialState.SpaceLayer = SpaceLayerKind.SystemSpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.Warp; ship.SpatialState.MovementRegime = MovementRegimeKind.Warp;
ship.SpatialState.CurrentCelestialId = null; ship.SpatialState.CurrentAnchorId = null;
ship.SpatialState.DestinationNodeId = targetCelestial.Id; ship.SpatialState.DestinationAnchorId = targetAnchor.Id;
var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); var spoolDurationSeconds = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
if (ship.State != ShipState.Warping) 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; ship.State = ShipState.SpoolingWarp;
if (!AdvanceTimedSubTask(subTask, deltaSeconds, spoolDuration)) ship.Position = Vector3.Zero;
{ ship.TargetPosition = Vector3.Zero;
return SubTaskOutcome.Active; ship.SpatialState.SystemPosition = originPosition;
} transit.Progress = Math.Clamp(elapsedSeconds / totalDuration, 0f, 1f);
subTask.Progress = transit.Progress;
ship.State = ShipState.Warping; return SubTaskOutcome.Active;
} }
var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null ship.State = ShipState.Warping;
? ship.Position.DistanceTo(targetPosition) var warpTravelDuration = MathF.Max(0.001f, totalDuration - spoolDurationSeconds);
: (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition))); var travelElapsed = Math.Clamp(elapsedSeconds - spoolDurationSeconds, 0f, warpTravelDuration);
ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds); var travelProgress = Math.Clamp(travelElapsed / warpTravelDuration, 0f, 1f);
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance)); 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; subTask.Progress = transit.Progress;
if (ship.Position.DistanceTo(targetPosition) > 18f) if (elapsedSeconds < totalDuration - 0.001f)
{ {
return SubTaskOutcome.Active; 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( private SubTaskOutcome UpdateFtlTransit(
@@ -692,20 +753,24 @@ public sealed partial class ShipAiService
float deltaSeconds, float deltaSeconds,
string targetSystemId, string targetSystemId,
Vector3 entryPosition, Vector3 entryPosition,
CelestialRuntime? targetCelestial, AnchorRuntime? entryAnchor,
bool completeOnArrival, bool completeOnArrival,
Vector3 finalTargetPosition) Vector3 finalTargetPosition,
AnchorRuntime? finalTargetAnchor)
{ {
var destinationNodeId = targetCelestial?.Id; var destinationAnchorId = entryAnchor?.Id;
var transit = ship.SpatialState.Transit; 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 transit = new ShipTransitRuntime
{ {
Regime = MovementRegimeKind.FtlTransit, Regime = MovementRegimeKind.FtlTransit,
OriginNodeId = ship.SpatialState.CurrentCelestialId, OriginAnchorId = ship.SpatialState.CurrentAnchorId,
DestinationNodeId = destinationNodeId, DestinationAnchorId = destinationAnchorId,
StartedAtUtc = world.GeneratedAtUtc, StartedAtUtc = world.GeneratedAtUtc,
ArrivalDueAtUtc = world.GeneratedAtUtc.AddSeconds(initialSpoolDuration + initialTravelDuration),
}; };
ship.SpatialState.Transit = transit; ship.SpatialState.Transit = transit;
subTask.ElapsedSeconds = 0f; subTask.ElapsedSeconds = 0f;
@@ -713,39 +778,32 @@ public sealed partial class ShipAiService
ship.SpatialState.SpaceLayer = SpaceLayerKind.GalaxySpace; ship.SpatialState.SpaceLayer = SpaceLayerKind.GalaxySpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.FtlTransit; ship.SpatialState.MovementRegime = MovementRegimeKind.FtlTransit;
ship.SpatialState.CurrentCelestialId = null; ship.SpatialState.CurrentAnchorId = null;
ship.SpatialState.DestinationNodeId = destinationNodeId; ship.SpatialState.DestinationAnchorId = destinationAnchorId;
if (ship.State != ShipState.Ftl) var spoolDurationSeconds = MathF.Max(ship.Definition.SpoolTime, 0.1f);
{ var startedAtUtc = transit.StartedAtUtc ?? world.GeneratedAtUtc;
ship.State = ShipState.SpoolingFtl; var arrivalDueAtUtc = transit.ArrivalDueAtUtc ?? world.GeneratedAtUtc;
if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(ship.Definition.SpoolTime, 0.1f))) var totalDuration = MathF.Max(0.1f, (float)(arrivalDueAtUtc - startedAtUtc).TotalSeconds);
{ var elapsedSeconds = Math.Clamp((float)(world.GeneratedAtUtc - startedAtUtc).TotalSeconds, 0f, totalDuration);
return SubTaskOutcome.Active; ship.State = elapsedSeconds < spoolDurationSeconds ? ShipState.SpoolingFtl : ShipState.Ftl;
} transit.Progress = Math.Clamp(elapsedSeconds / totalDuration, 0f, 1f);
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));
subTask.Progress = transit.Progress; subTask.Progress = transit.Progress;
if (transit.Progress < 0.999f) if (elapsedSeconds < totalDuration - 0.001f)
{ {
return SubTaskOutcome.Active; return SubTaskOutcome.Active;
} }
ship.Position = entryPosition; ship.Position = Vector3.Zero;
ship.TargetPosition = finalTargetPosition; ship.TargetPosition = finalTargetPosition;
ship.SystemId = targetSystemId; ship.SystemId = targetSystemId;
ship.SpatialState.CurrentSystemId = targetSystemId; ship.SpatialState.CurrentSystemId = targetSystemId;
ship.SpatialState.Transit = null; ship.SpatialState.Transit = null;
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace; ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; ship.SpatialState.CurrentAnchorId = entryAnchor?.Id;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id; ship.SpatialState.DestinationAnchorId = finalTargetAnchor?.Id ?? entryAnchor?.Id;
ship.SpatialState.SystemPosition = entryPosition;
ship.State = ShipState.Arriving; ship.State = ShipState.Arriving;
// Cross-system travel is only complete once the ship finishes the // Cross-system travel is only complete once the ship finishes the
@@ -753,7 +811,7 @@ public sealed partial class ShipAiService
return SubTaskOutcome.Active; 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.Position = targetPosition;
ship.TargetPosition = targetPosition; ship.TargetPosition = targetPosition;
@@ -762,8 +820,14 @@ public sealed partial class ShipAiService
ship.SpatialState.Transit = null; ship.SpatialState.Transit = null;
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace; ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; ship.SpatialState.CurrentAnchorId = targetAnchor?.Id;
ship.SpatialState.DestinationNodeId = targetCelestial?.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; ship.State = ShipState.Arriving;
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
} }

View File

@@ -9,6 +9,11 @@ public sealed partial class ShipAiService
{ {
private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ShipSubTaskRuntime subTask) 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)) if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId))
{ {
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == 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)) if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId))
{ {
var station = ResolveStation(world, 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); 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); var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
@@ -76,25 +86,145 @@ public sealed partial class ShipAiService
.FirstOrDefault(); .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) 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 return world.Celestials
.Where(candidate => candidate.SystemId == ship.SystemId) .Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position)) .OrderBy(candidate => candidate.Position.DistanceTo(ResolveShipSystemPosition(world, ship)))
.FirstOrDefault(); .FirstOrDefault();
} }
private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) => private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) =>
world.Celestials.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star); 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) => private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) =>
world.Systems.FirstOrDefault(candidate => candidate.Definition.Id == systemId)?.Position ?? Vector3.Zero; 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) => private static float GetLocalTravelSpeed(ShipRuntime ship) =>
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed) * GetSkillFactor(ship.Skills.Navigation); 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 policy = ResolvePolicy(world, ship.PolicySetId);
var preferredItemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId; var preferredItemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId;
var preferredAnchorId = ship.DefaultBehavior.PreferredAnchorId;
var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange);
var effectiveMiningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); var effectiveMiningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination);
string? deniedReason = null; string? deniedReason = null;
@@ -194,6 +325,11 @@ public sealed partial class ShipAiService
return false; 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)) if (!TryCheckSystemAllowed(world, policy, ship.FactionId, node.SystemId, "military", out var reason))
{ {
deniedReason ??= reason; deniedReason ??= reason;
@@ -214,7 +350,7 @@ public sealed partial class ShipAiService
+ (effectiveMiningSkill * 10f) + (effectiveMiningSkill * 10f)
- distancePenalty - distancePenalty
- routeRiskPenalty - 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}"); return new MiningOpportunity(node, buyer, score, $"Mine {node.ItemId} for {buyer.Label}");
}) })
.OrderByDescending(candidate => candidate.Score) .OrderByDescending(candidate => candidate.Score)
@@ -452,7 +588,7 @@ public sealed partial class ShipAiService
?? homeStation; ?? 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); var policy = ResolvePolicy(world, ship.PolicySetId);
string? deniedReason = null; string? deniedReason = null;
@@ -467,6 +603,11 @@ public sealed partial class ShipAiService
return false; 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)) if (!TryCheckSystemAllowed(world, policy, ship.FactionId, candidate.SystemId, "military", out var reason))
{ {
deniedReason ??= reason; deniedReason ??= reason;
@@ -487,6 +628,54 @@ public sealed partial class ShipAiService
return node; 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) private static StationRuntime? SelectLocalAutoMineBuyer(SimulationWorld world, ShipRuntime ship, string systemId, string itemId)
{ {
var policy = ResolvePolicy(world, ship.PolicySetId); var policy = ResolvePolicy(world, ship.PolicySetId);
@@ -686,9 +875,14 @@ public sealed partial class ShipAiService
return (celestial.SystemId, celestial.Position); 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) 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); return (site.SystemId, position);
} }
@@ -720,6 +914,16 @@ public sealed partial class ShipAiService
private static StationRuntime? ResolveStation(SimulationWorld world, string? stationId) => private static StationRuntime? ResolveStation(SimulationWorld world, string? stationId) =>
stationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == 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) => private static ResourceNodeRuntime? ResolveNode(SimulationWorld world, string? nodeId) =>
nodeId is null ? null : world.Nodes.FirstOrDefault(candidate => candidate.Id == 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) 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); 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) 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) if (anchor is null || site.BlueprintId is null)
{ {
site.State = ConstructionSiteStateKinds.Destroyed; site.State = ConstructionSiteStateKinds.Destroyed;
@@ -878,13 +1083,13 @@ public sealed partial class ShipAiService
{ {
Id = $"station-{world.Stations.Count + 1}", Id = $"station-{world.Stations.Count + 1}",
SystemId = site.SystemId, SystemId = site.SystemId,
AnchorId = site.AnchorId,
Label = BuildFoundedStationLabel(site.TargetDefinitionId), Label = BuildFoundedStationLabel(site.TargetDefinitionId),
Category = "station", Category = "station",
Objective = DetermineFoundationObjective(site.TargetDefinitionId), Objective = DetermineFoundationObjective(site.TargetDefinitionId),
Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color, Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color,
Position = anchor.Position, Position = Vector3.Zero,
FactionId = site.FactionId, FactionId = site.FactionId,
CelestialId = site.CelestialId,
Health = 600f, Health = 600f,
MaxHealth = 600f, MaxHealth = 600f,
}; };

View File

@@ -128,11 +128,11 @@ public sealed partial class ShipAiService
CreateStep("step-construction-deliver", "deliver-materials", $"Deliver materials to {site.Id}", 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-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}", 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, float amount,
string? itemId = null, string? itemId = null,
string? moduleId = null, string? moduleId = null,
string? targetNodeId = null) => string? targetAnchorId = null,
string? targetResourceNodeId = null,
string? targetResourceDepositId = null) =>
new() new()
{ {
Id = id, Id = id,
@@ -310,7 +312,9 @@ public sealed partial class ShipAiService
TargetSystemId = targetSystemId, TargetSystemId = targetSystemId,
TargetPosition = targetPosition, TargetPosition = targetPosition,
TargetEntityId = targetEntityId, TargetEntityId = targetEntityId,
TargetNodeId = targetNodeId, TargetAnchorId = targetAnchorId,
TargetResourceNodeId = targetResourceNodeId,
TargetResourceDepositId = targetResourceDepositId,
ItemId = itemId, ItemId = itemId,
ModuleId = moduleId, ModuleId = moduleId,
Threshold = threshold, Threshold = threshold,

View File

@@ -171,7 +171,8 @@ public sealed partial class ShipAiService
return null; 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 (node is not null)
{ {
if (!string.Equals(node.SystemId, systemId, StringComparison.Ordinal)) if (!string.Equals(node.SystemId, systemId, StringComparison.Ordinal))
@@ -188,7 +189,7 @@ public sealed partial class ShipAiService
} }
else else
{ {
node = SelectLocalMiningNode(world, ship, systemId, itemId); node = SelectLocalMiningNode(world, ship, systemId, itemId, anchor?.Id);
} }
if (node is null) if (node is null)
@@ -197,24 +198,30 @@ public sealed partial class ShipAiService
return null; 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) 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) if (node is null)
{ {
order.FailureReason = "mine-order-incomplete"; order.FailureReason = "mine-order-incomplete";
return null; 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) 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); var buyer = ResolveStation(world, order.DestinationStationId);
if (node is null || buyer is null) if (node is null || buyer is null)
{ {
@@ -222,7 +229,7 @@ public sealed partial class ShipAiService
return null; 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) 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}"); 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( return CreatePlan(
ship, ship,
sourceKind, sourceKind,
@@ -408,8 +416,8 @@ public sealed partial class ShipAiService
[ [
CreateStep("step-mine", "mine", $"Mine {node.ItemId}", 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-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()) 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}", 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( return CreatePlan(
ship, ship,
sourceKind, sourceKind,
@@ -433,8 +442,8 @@ public sealed partial class ShipAiService
[ [
CreateStep("step-mine", "mine", $"Mine {node.ItemId}", 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-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) 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)
]) ])
]); ]);
} }

View File

@@ -11,7 +11,7 @@ public sealed record ShipOrderCommandRequest(
string? SourceStationId, string? SourceStationId,
string? DestinationStationId, string? DestinationStationId,
string? ItemId, string? ItemId,
string? NodeId, string? AnchorId,
string? ConstructionSiteId, string? ConstructionSiteId,
string? ModuleId, string? ModuleId,
float? WaitSeconds, float? WaitSeconds,
@@ -28,7 +28,7 @@ public sealed record ShipOrderTemplateCommandRequest(
string? SourceStationId, string? SourceStationId,
string? DestinationStationId, string? DestinationStationId,
string? ItemId, string? ItemId,
string? NodeId, string? AnchorId,
string? ConstructionSiteId, string? ConstructionSiteId,
string? ModuleId, string? ModuleId,
float? WaitSeconds, float? WaitSeconds,
@@ -43,7 +43,7 @@ public sealed record ShipDefaultBehaviorCommandRequest(
string? AreaSystemId, string? AreaSystemId,
string? TargetEntityId, string? TargetEntityId,
string? ItemId, string? ItemId,
string? PreferredNodeId, string? PreferredAnchorId,
string? PreferredConstructionSiteId, string? PreferredConstructionSiteId,
string? PreferredModuleId, string? PreferredModuleId,
Vector3Dto? TargetPosition, Vector3Dto? TargetPosition,

View File

@@ -23,7 +23,7 @@ public sealed record ShipOrderSnapshot(
string? SourceStationId, string? SourceStationId,
string? DestinationStationId, string? DestinationStationId,
string? ItemId, string? ItemId,
string? NodeId, string? AnchorId,
string? ConstructionSiteId, string? ConstructionSiteId,
string? ModuleId, string? ModuleId,
float WaitSeconds, float WaitSeconds,
@@ -41,7 +41,7 @@ public sealed record ShipOrderTemplateSnapshot(
string? SourceStationId, string? SourceStationId,
string? DestinationStationId, string? DestinationStationId,
string? ItemId, string? ItemId,
string? NodeId, string? AnchorId,
string? ConstructionSiteId, string? ConstructionSiteId,
string? ModuleId, string? ModuleId,
float WaitSeconds, float WaitSeconds,
@@ -56,7 +56,7 @@ public sealed record DefaultBehaviorSnapshot(
string? AreaSystemId, string? AreaSystemId,
string? TargetEntityId, string? TargetEntityId,
string? ItemId, string? ItemId,
string? PreferredNodeId, string? PreferredAnchorId,
string? PreferredConstructionSiteId, string? PreferredConstructionSiteId,
string? PreferredModuleId, string? PreferredModuleId,
Vector3Dto? TargetPosition, Vector3Dto? TargetPosition,
@@ -95,7 +95,9 @@ public sealed record ShipSubTaskSnapshot(
string Summary, string Summary,
string? TargetEntityId, string? TargetEntityId,
string? TargetSystemId, string? TargetSystemId,
string? TargetNodeId, string? TargetAnchorId,
string? TargetResourceNodeId,
string? TargetResourceDepositId,
Vector3Dto? TargetPosition, Vector3Dto? TargetPosition,
string? ItemId, string? ItemId,
string? ModuleId, string? ModuleId,
@@ -135,6 +137,7 @@ public sealed record ShipSnapshot(
string Purpose, string Purpose,
string Type, string Type,
string SystemId, string SystemId,
string? AnchorId,
Vector3Dto LocalPosition, Vector3Dto LocalPosition,
Vector3Dto LocalVelocity, Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition, Vector3Dto TargetLocalPosition,
@@ -151,11 +154,11 @@ public sealed record ShipSnapshot(
string? ControlReason, string? ControlReason,
string? LastReplanReason, string? LastReplanReason,
string? LastAccessFailureReason, string? LastAccessFailureReason,
string? CelestialId,
string? DockedStationId, string? DockedStationId,
string? CommanderId, string? CommanderId,
string? PolicySetId, string? PolicySetId,
float CargoCapacity, float CargoCapacity,
IReadOnlyList<string> CargoTypes,
float TravelSpeed, float TravelSpeed,
string TravelSpeedUnit, string TravelSpeedUnit,
IReadOnlyList<InventoryEntry> Inventory, IReadOnlyList<InventoryEntry> Inventory,
@@ -170,6 +173,7 @@ public sealed record ShipDelta(
string Purpose, string Purpose,
string Type, string Type,
string SystemId, string SystemId,
string? AnchorId,
Vector3Dto LocalPosition, Vector3Dto LocalPosition,
Vector3Dto LocalVelocity, Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition, Vector3Dto TargetLocalPosition,
@@ -186,11 +190,11 @@ public sealed record ShipDelta(
string? ControlReason, string? ControlReason,
string? LastReplanReason, string? LastReplanReason,
string? LastAccessFailureReason, string? LastAccessFailureReason,
string? CelestialId,
string? DockedStationId, string? DockedStationId,
string? CommanderId, string? CommanderId,
string? PolicySetId, string? PolicySetId,
float CargoCapacity, float CargoCapacity,
IReadOnlyList<string> CargoTypes,
float TravelSpeed, float TravelSpeed,
string TravelSpeedUnit, string TravelSpeedUnit,
IReadOnlyList<InventoryEntry> Inventory, IReadOnlyList<InventoryEntry> Inventory,
@@ -202,17 +206,17 @@ public sealed record ShipDelta(
public sealed record ShipSpatialStateSnapshot( public sealed record ShipSpatialStateSnapshot(
string SpaceLayer, string SpaceLayer,
string CurrentSystemId, string CurrentSystemId,
string? CurrentCelestialId, string? CurrentAnchorId,
Vector3Dto? LocalPosition, Vector3Dto? LocalPosition,
Vector3Dto? SystemPosition, Vector3Dto? SystemPosition,
string MovementRegime, string MovementRegime,
string? DestinationNodeId, string? DestinationAnchorId,
ShipTransitSnapshot? Transit); ShipTransitSnapshot? Transit);
public sealed record ShipTransitSnapshot( public sealed record ShipTransitSnapshot(
string Regime, string Regime,
string? OriginNodeId, string? OriginAnchorId,
string? DestinationNodeId, string? DestinationAnchorId,
DateTimeOffset? StartedAtUtc, DateTimeOffset? StartedAtUtc,
DateTimeOffset? ArrivalDueAtUtc, DateTimeOffset? ArrivalDueAtUtc,
float Progress); float Progress);

View File

@@ -60,7 +60,7 @@ public sealed class ShipOrderRuntime
public string? SourceStationId { get; set; } public string? SourceStationId { get; set; }
public string? DestinationStationId { get; set; } public string? DestinationStationId { get; set; }
public string? ItemId { get; set; } public string? ItemId { get; set; }
public string? NodeId { get; set; } public string? AnchorId { get; set; }
public string? ConstructionSiteId { get; set; } public string? ConstructionSiteId { get; set; }
public string? ModuleId { get; set; } public string? ModuleId { get; set; }
public float WaitSeconds { get; set; } public float WaitSeconds { get; set; }
@@ -78,7 +78,7 @@ public sealed class DefaultBehaviorRuntime
public string? AreaSystemId { get; set; } public string? AreaSystemId { get; set; }
public string? TargetEntityId { get; set; } public string? TargetEntityId { get; set; }
public string? ItemId { get; set; } public string? ItemId { get; set; }
public string? PreferredNodeId { get; set; } public string? PreferredAnchorId { get; set; }
public string? PreferredConstructionSiteId { get; set; } public string? PreferredConstructionSiteId { get; set; }
public string? PreferredModuleId { get; set; } public string? PreferredModuleId { get; set; }
public Vector3? TargetPosition { get; set; } public Vector3? TargetPosition { get; set; }
@@ -102,7 +102,7 @@ public sealed class ShipOrderTemplateRuntime
public string? SourceStationId { get; set; } public string? SourceStationId { get; set; }
public string? DestinationStationId { get; set; } public string? DestinationStationId { get; set; }
public string? ItemId { get; set; } public string? ItemId { get; set; }
public string? NodeId { get; set; } public string? AnchorId { get; set; }
public string? ConstructionSiteId { get; set; } public string? ConstructionSiteId { get; set; }
public string? ModuleId { get; set; } public string? ModuleId { get; set; }
public float WaitSeconds { get; set; } public float WaitSeconds { get; set; }
@@ -146,7 +146,9 @@ public sealed class ShipSubTaskRuntime
public WorkStatus Status { get; set; } = WorkStatus.Pending; public WorkStatus Status { get; set; } = WorkStatus.Pending;
public string? TargetEntityId { get; set; } public string? TargetEntityId { get; set; }
public string? TargetSystemId { 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 Vector3? TargetPosition { get; set; }
public string? ItemId { get; set; } public string? ItemId { get; set; }
public string? ModuleId { get; set; } public string? ModuleId { get; set; }

View File

@@ -104,12 +104,12 @@ internal sealed class SimulationEngine
CreateWreck(world, "station", station.Id, station.SystemId, station.Position, station.MaxHealth * 0.12f); CreateWreck(world, "station", station.Id, station.SystemId, station.Position, station.MaxHealth * 0.12f);
world.Stations.Remove(station); 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.Health = 0f;
claim.State = ClaimStateKinds.Destroyed; claim.State = ClaimStateKinds.Destroyed;

View File

@@ -24,6 +24,7 @@ internal sealed class SimulationProjectionService
false, false,
events, events,
BuildCelestialDeltas(world), BuildCelestialDeltas(world),
BuildAnchorDeltas(world),
BuildNodeDeltas(world), BuildNodeDeltas(world),
BuildStationDeltas(world), BuildStationDeltas(world),
BuildClaimDeltas(world), BuildClaimDeltas(world),
@@ -87,26 +88,37 @@ internal sealed class SimulationProjectionService
c.Kind, c.Kind,
c.OrbitalAnchor, c.OrbitalAnchor,
c.LocalSpaceRadius, c.LocalSpaceRadius,
c.ParentNodeId, c.ParentAnchorId,
c.OccupyingStructureId, c.OccupyingStructureId,
c.OrbitReferenceId)).ToList(), 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( world.Nodes.Select(ToNodeDelta).Select(node => new ResourceNodeSnapshot(
node.Id, node.Id,
node.AnchorId,
node.SystemId, node.SystemId,
node.LocalPosition, node.LocalPosition,
node.CelestialId, node.LocalSpaceRadius,
node.SourceKind, node.SourceKind,
node.OreRemaining, node.OreRemaining,
node.MaxOre, node.MaxOre,
node.ItemId)).ToList(), node.ItemId,
node.Deposits)).ToList(),
world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot( world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot(
station.Id, station.Id,
station.Label, station.Label,
station.Category, station.Category,
station.Objective, station.Objective,
station.SystemId, station.SystemId,
station.AnchorId,
station.LocalPosition, station.LocalPosition,
station.CelestialId,
station.Color, station.Color,
station.DockedShips, station.DockedShips,
station.DockedShipIds, station.DockedShipIds,
@@ -127,7 +139,7 @@ internal sealed class SimulationProjectionService
claim.Id, claim.Id,
claim.FactionId, claim.FactionId,
claim.SystemId, claim.SystemId,
claim.CelestialId, claim.AnchorId,
claim.State, claim.State,
claim.Health, claim.Health,
claim.PlacedAtUtc, claim.PlacedAtUtc,
@@ -136,7 +148,7 @@ internal sealed class SimulationProjectionService
site.Id, site.Id,
site.FactionId, site.FactionId,
site.SystemId, site.SystemId,
site.CelestialId, site.AnchorId,
site.TargetKind, site.TargetKind,
site.TargetDefinitionId, site.TargetDefinitionId,
site.BlueprintId, site.BlueprintId,
@@ -180,6 +192,7 @@ internal sealed class SimulationProjectionService
ship.Purpose, ship.Purpose,
ship.Type, ship.Type,
ship.SystemId, ship.SystemId,
ship.AnchorId,
ship.LocalPosition, ship.LocalPosition,
ship.LocalVelocity, ship.LocalVelocity,
ship.TargetLocalPosition, ship.TargetLocalPosition,
@@ -196,11 +209,11 @@ internal sealed class SimulationProjectionService
ship.ControlReason, ship.ControlReason,
ship.LastReplanReason, ship.LastReplanReason,
ship.LastAccessFailureReason, ship.LastAccessFailureReason,
ship.CelestialId,
ship.DockedStationId, ship.DockedStationId,
ship.CommanderId, ship.CommanderId,
ship.PolicySetId, ship.PolicySetId,
ship.CargoCapacity, ship.CargoCapacity,
ship.CargoTypes,
ship.TravelSpeed, ship.TravelSpeed,
ship.TravelSpeedUnit, ship.TravelSpeedUnit,
ship.Inventory, ship.Inventory,
@@ -239,6 +252,11 @@ internal sealed class SimulationProjectionService
celestial.LastDeltaSignature = BuildCelestialSignature(celestial); celestial.LastDeltaSignature = BuildCelestialSignature(celestial);
} }
foreach (var anchor in world.Anchors)
{
anchor.LastDeltaSignature = BuildAnchorSignature(anchor);
}
foreach (var station in world.Stations) foreach (var station in world.Stations)
{ {
station.LastDeltaSignature = BuildStationSignature(world, station); station.LastDeltaSignature = BuildStationSignature(world, station);
@@ -298,6 +316,24 @@ internal sealed class SimulationProjectionService
return deltas; return deltas;
} }
private static IReadOnlyList<AnchorDelta> BuildAnchorDeltas(SimulationWorld world)
{
var deltas = new List<AnchorDelta>();
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<CelestialDelta> BuildCelestialDeltas(SimulationWorld world) private static IReadOnlyList<CelestialDelta> BuildCelestialDeltas(SimulationWorld world)
{ {
var deltas = new List<CelestialDelta>(); var deltas = new List<CelestialDelta>();
@@ -466,17 +502,30 @@ internal sealed class SimulationProjectionService
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)); string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
private static string BuildNodeSignature(ResourceNodeRuntime node) => 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) => 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) private static string BuildStationSignature(SimulationWorld world, StationRuntime station)
{ {
var processes = ToStationActionProgressSnapshots(world, station); var processes = ToStationActionProgressSnapshots(world, station);
return string.Join("|", return string.Join("|",
station.SystemId, station.SystemId,
station.CelestialId ?? "none", station.AnchorId ?? "none",
station.CommanderId ?? "none", station.CommanderId ?? "none",
station.PolicySetId ?? "none", station.PolicySetId ?? "none",
BuildInventorySignature(station.Inventory), BuildInventorySignature(station.Inventory),
@@ -495,10 +544,10 @@ internal sealed class SimulationProjectionService
} }
private static string BuildClaimSignature(ClaimRuntime claim) => 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) => 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) => 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}"; $"{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(",", string.Join(",",
ToActiveSubTaskSnapshots(ship).Select(subTask => ToActiveSubTaskSnapshots(ship).Select(subTask =>
$"{subTask.Id}:{subTask.Kind}:{subTask.Status}:{subTask.Progress:0.###}:{subTask.ElapsedSeconds:0.###}:{subTask.BlockingReason ?? "none"}")), $"{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.DockedStationId ?? "none",
ship.CommanderId ?? "none", ship.CommanderId ?? "none",
ship.PolicySetId ?? "none", ship.PolicySetId ?? "none",
ship.SpatialState.SpaceLayer.ToContractValue(), ship.SpatialState.SpaceLayer.ToContractValue(),
ship.SpatialState.CurrentCelestialId ?? "none", ship.SpatialState.CurrentAnchorId ?? "none",
ship.SpatialState.MovementRegime.ToContractValue(), ship.SpatialState.MovementRegime.ToContractValue(),
ship.SpatialState.DestinationNodeId ?? "none", ship.SpatialState.DestinationAnchorId ?? "none",
ship.SpatialState.Transit?.Regime.ToContractValue() ?? "none", ship.SpatialState.Transit?.Regime.ToContractValue() ?? "none",
ship.SpatialState.Transit?.OriginNodeId ?? "none", ship.SpatialState.Transit?.OriginAnchorId ?? "none",
ship.SpatialState.Transit?.DestinationNodeId ?? "none", ship.SpatialState.Transit?.DestinationAnchorId ?? "none",
ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0", ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0",
GetShipCargoAmount(ship).ToString("0.###"), GetShipCargoAmount(ship).ToString("0.###"),
ship.Skills.Navigation.ToString(CultureInfo.InvariantCulture), ship.Skills.Navigation.ToString(CultureInfo.InvariantCulture),
@@ -653,13 +702,33 @@ internal sealed class SimulationProjectionService
private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new( private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new(
node.Id, node.Id,
node.AnchorId,
node.SystemId, node.SystemId,
ToDto(node.Position), ToDto(node.Position),
node.CelestialId, node.LocalSpaceRadius,
node.SourceKind, node.SourceKind,
node.OreRemaining, node.OreRemaining,
node.MaxOre, 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( private static CelestialDelta ToCelestialDelta(CelestialRuntime celestial) => new(
celestial.Id, celestial.Id,
@@ -667,7 +736,7 @@ internal sealed class SimulationProjectionService
celestial.Kind.ToContractValue(), celestial.Kind.ToContractValue(),
ToDto(celestial.Position), ToDto(celestial.Position),
celestial.LocalSpaceRadius, celestial.LocalSpaceRadius,
celestial.ParentNodeId, celestial.ParentAnchorId,
celestial.OccupyingStructureId, celestial.OccupyingStructureId,
celestial.OrbitReferenceId); celestial.OrbitReferenceId);
@@ -677,8 +746,8 @@ internal sealed class SimulationProjectionService
station.Category, station.Category,
station.Objective, station.Objective,
station.SystemId, station.SystemId,
station.AnchorId,
ToDto(station.Position), ToDto(station.Position),
station.CelestialId,
station.Color, station.Color,
station.DockedShipIds.Count, station.DockedShipIds.Count,
station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
@@ -737,7 +806,7 @@ internal sealed class SimulationProjectionService
claim.Id, claim.Id,
claim.FactionId, claim.FactionId,
claim.SystemId, claim.SystemId,
claim.CelestialId, claim.AnchorId,
claim.State, claim.State,
claim.Health, claim.Health,
claim.PlacedAtUtc, claim.PlacedAtUtc,
@@ -747,7 +816,7 @@ internal sealed class SimulationProjectionService
site.Id, site.Id,
site.FactionId, site.FactionId,
site.SystemId, site.SystemId,
site.CelestialId, site.AnchorId,
site.TargetKind, site.TargetKind,
site.TargetDefinitionId, site.TargetDefinitionId,
site.BlueprintId, site.BlueprintId,
@@ -811,6 +880,7 @@ internal sealed class SimulationProjectionService
ship.Definition.Purpose.ToDataValue(), ship.Definition.Purpose.ToDataValue(),
ship.Definition.Type.ToDataValue(), ship.Definition.Type.ToDataValue(),
ship.SystemId, ship.SystemId,
ship.SpatialState.CurrentAnchorId,
ToDto(ship.Position), ToDto(ship.Position),
ToDto(ship.Velocity), ToDto(ship.Velocity),
ToDto(ship.TargetPosition), ToDto(ship.TargetPosition),
@@ -827,11 +897,16 @@ internal sealed class SimulationProjectionService
ship.ControlReason, ship.ControlReason,
ship.LastReplanReason, ship.LastReplanReason,
ship.LastAccessFailureReason, ship.LastAccessFailureReason,
ship.SpatialState.CurrentCelestialId,
ship.DockedStationId, ship.DockedStationId,
ship.CommanderId, ship.CommanderId,
ship.PolicySetId, ship.PolicySetId,
ship.Definition.GetTotalCargoCapacity(), 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).Speed,
ToShipTravelSpeed(ship).Unit, ToShipTravelSpeed(ship).Unit,
@@ -880,7 +955,7 @@ internal sealed class SimulationProjectionService
order.SourceStationId, order.SourceStationId,
order.DestinationStationId, order.DestinationStationId,
order.ItemId, order.ItemId,
order.NodeId, order.AnchorId,
order.ConstructionSiteId, order.ConstructionSiteId,
order.ModuleId, order.ModuleId,
order.WaitSeconds, order.WaitSeconds,
@@ -906,7 +981,7 @@ internal sealed class SimulationProjectionService
behavior.AreaSystemId, behavior.AreaSystemId,
behavior.TargetEntityId, behavior.TargetEntityId,
behavior.ItemId, behavior.ItemId,
behavior.PreferredNodeId, behavior.PreferredAnchorId,
behavior.PreferredConstructionSiteId, behavior.PreferredConstructionSiteId,
behavior.PreferredModuleId, behavior.PreferredModuleId,
behavior.TargetPosition is null ? null : ToDto(behavior.TargetPosition.Value), behavior.TargetPosition is null ? null : ToDto(behavior.TargetPosition.Value),
@@ -929,7 +1004,7 @@ internal sealed class SimulationProjectionService
template.SourceStationId, template.SourceStationId,
template.DestinationStationId, template.DestinationStationId,
template.ItemId, template.ItemId,
template.NodeId, template.AnchorId,
template.ConstructionSiteId, template.ConstructionSiteId,
template.ModuleId, template.ModuleId,
template.WaitSeconds, template.WaitSeconds,
@@ -1002,10 +1077,12 @@ internal sealed class SimulationProjectionService
subTask.Kind, subTask.Kind,
subTask.Status.ToContractValue(), subTask.Status.ToContractValue(),
subTask.Summary, subTask.Summary,
subTask.TargetEntityId, subTask.TargetEntityId,
subTask.TargetSystemId, subTask.TargetSystemId,
subTask.TargetNodeId, subTask.TargetAnchorId,
subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value), subTask.TargetResourceNodeId,
subTask.TargetResourceDepositId,
subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value),
subTask.ItemId, subTask.ItemId,
subTask.ModuleId, subTask.ModuleId,
subTask.Threshold, subTask.Threshold,
@@ -1408,7 +1485,7 @@ internal sealed class SimulationProjectionService
claim.SourceClaimId, claim.SourceClaimId,
claim.FactionId, claim.FactionId,
claim.SystemId, claim.SystemId,
claim.CelestialId, claim.AnchorId,
claim.Status, claim.Status,
claim.ClaimKind, claim.ClaimKind,
claim.ClaimStrength, claim.ClaimStrength,
@@ -1564,15 +1641,15 @@ internal sealed class SimulationProjectionService
private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new( private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new(
state.SpaceLayer.ToContractValue(), state.SpaceLayer.ToContractValue(),
state.CurrentSystemId, state.CurrentSystemId,
state.CurrentCelestialId, state.CurrentAnchorId,
state.LocalPosition is null ? null : ToDto(state.LocalPosition.Value), state.LocalPosition is null ? null : ToDto(state.LocalPosition.Value),
state.SystemPosition is null ? null : ToDto(state.SystemPosition.Value), state.SystemPosition is null ? null : ToDto(state.SystemPosition.Value),
state.MovementRegime.ToContractValue(), state.MovementRegime.ToContractValue(),
state.DestinationNodeId, state.DestinationAnchorId,
state.Transit is null ? null : new ShipTransitSnapshot( state.Transit is null ? null : new ShipTransitSnapshot(
state.Transit.Regime.ToContractValue(), state.Transit.Regime.ToContractValue(),
state.Transit.OriginNodeId, state.Transit.OriginAnchorId,
state.Transit.DestinationNodeId, state.Transit.DestinationAnchorId,
state.Transit.StartedAtUtc, state.Transit.StartedAtUtc,
state.Transit.ArrivalDueAtUtc, state.Transit.ArrivalDueAtUtc,
state.Transit.Progress)); state.Transit.Progress));

View File

@@ -10,8 +10,8 @@ public sealed record StationSnapshot(
string Category, string Category,
string Objective, string Objective,
string SystemId, string SystemId,
string? AnchorId,
Vector3Dto LocalPosition, Vector3Dto LocalPosition,
string? CelestialId,
string Color, string Color,
int DockedShips, int DockedShips,
IReadOnlyList<string> DockedShipIds, IReadOnlyList<string> DockedShipIds,
@@ -35,8 +35,8 @@ public sealed record StationDelta(
string Category, string Category,
string Objective, string Objective,
string SystemId, string SystemId,
string? AnchorId,
Vector3Dto LocalPosition, Vector3Dto LocalPosition,
string? CelestialId,
string Color, string Color,
int DockedShips, int DockedShips,
IReadOnlyList<string> DockedShipIds, IReadOnlyList<string> DockedShipIds,
@@ -74,7 +74,7 @@ public sealed record ClaimSnapshot(
string Id, string Id,
string FactionId, string FactionId,
string SystemId, string SystemId,
string CelestialId, string AnchorId,
string State, string State,
float Health, float Health,
DateTimeOffset PlacedAtUtc, DateTimeOffset PlacedAtUtc,
@@ -84,7 +84,7 @@ public sealed record ClaimDelta(
string Id, string Id,
string FactionId, string FactionId,
string SystemId, string SystemId,
string CelestialId, string AnchorId,
string State, string State,
float Health, float Health,
DateTimeOffset PlacedAtUtc, DateTimeOffset PlacedAtUtc,
@@ -94,7 +94,7 @@ public sealed record ConstructionSiteSnapshot(
string Id, string Id,
string FactionId, string FactionId,
string SystemId, string SystemId,
string CelestialId, string AnchorId,
string TargetKind, string TargetKind,
string TargetDefinitionId, string TargetDefinitionId,
string? BlueprintId, string? BlueprintId,
@@ -112,7 +112,7 @@ public sealed record ConstructionSiteDelta(
string Id, string Id,
string FactionId, string FactionId,
string SystemId, string SystemId,
string CelestialId, string AnchorId,
string TargetKind, string TargetKind,
string TargetDefinitionId, string TargetDefinitionId,
string? BlueprintId, string? BlueprintId,

View File

@@ -5,7 +5,7 @@ public sealed class ClaimRuntime
public required string Id { get; init; } public required string Id { get; init; }
public required string FactionId { get; init; } public required string FactionId { get; init; }
public required string SystemId { 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 string? CommanderId { get; set; }
public DateTimeOffset PlacedAtUtc { get; init; } public DateTimeOffset PlacedAtUtc { get; init; }
public DateTimeOffset ActivatesAtUtc { get; set; } public DateTimeOffset ActivatesAtUtc { get; set; }
@@ -19,7 +19,7 @@ public sealed class ConstructionSiteRuntime
public required string Id { get; init; } public required string Id { get; init; }
public required string FactionId { get; init; } public required string FactionId { get; init; }
public required string SystemId { 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 TargetKind { get; init; }
public required string TargetDefinitionId { get; init; } public required string TargetDefinitionId { get; init; }
public string? BlueprintId { get; set; } public string? BlueprintId { get; set; }

View File

@@ -7,6 +7,7 @@ public sealed class StationRuntime
{ {
public required string Id { get; init; } public required string Id { get; init; }
public required string SystemId { get; init; } public required string SystemId { get; init; }
public string? AnchorId { get; set; }
public required string Label { get; set; } public required string Label { get; set; }
public string Category { get; set; } = "station"; public string Category { get; set; } = "station";
public string Objective { get; set; } = "general"; public string Objective { get; set; } = "general";
@@ -14,7 +15,6 @@ public sealed class StationRuntime
public required Vector3 Position { get; set; } public required Vector3 Position { get; set; }
public float Radius { get; set; } = 24f; public float Radius { get; set; } = 24f;
public required string FactionId { get; init; } public required string FactionId { get; init; }
public string? CelestialId { get; set; }
public string? CommanderId { get; set; } public string? CommanderId { get; set; }
public string? PolicySetId { get; set; } public string? PolicySetId { get; set; }
public List<StationModuleRuntime> Modules { get; } = []; public List<StationModuleRuntime> Modules { get; } = [];

View File

@@ -100,7 +100,7 @@ internal sealed class StationLifecycleService
{ {
CurrentSystemId = station.SystemId, CurrentSystemId = station.SystemId,
SpaceLayer = SpaceLayerKind.LocalSpace, SpaceLayer = SpaceLayerKind.LocalSpace,
CurrentCelestialId = station.CelestialId, CurrentAnchorId = station.AnchorId,
LocalPosition = position, LocalPosition = position,
SystemPosition = position, SystemPosition = position,
MovementRegime = MovementRegimeKind.LocalFlight, MovementRegime = MovementRegimeKind.LocalFlight,

View File

@@ -33,11 +33,11 @@ public sealed class StreamWorldHandler(WorldService worldService) : EndpointWith
} }
var systemId = HttpContext.Request.Query["systemId"].ToString(); 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( var scope = new ObserverScope(
scopeKind, scopeKind,
string.IsNullOrWhiteSpace(systemId) ? null : systemId, string.IsNullOrWhiteSpace(systemId) ? null : systemId,
string.IsNullOrWhiteSpace(bubbleId) ? null : bubbleId); string.IsNullOrWhiteSpace(anchorId) ? null : anchorId);
var stream = worldService.Subscribe(scope, afterSequence, cancellationToken); var stream = worldService.Subscribe(scope, afterSequence, cancellationToken);
await HttpContext.Response.WriteAsync(": connected\n\n", cancellationToken); await HttpContext.Response.WriteAsync(": connected\n\n", cancellationToken);

View File

@@ -42,25 +42,57 @@ public sealed record PlanetSnapshot(
string Color, string Color,
bool HasRing); bool HasRing);
public sealed record ResourceDepositSnapshot(
string Id,
string NodeId,
string AnchorId,
Vector3Dto LocalPosition,
float OreRemaining,
float MaxOre);
public sealed record ResourceNodeSnapshot( public sealed record ResourceNodeSnapshot(
string Id, string Id,
string AnchorId,
string SystemId, string SystemId,
Vector3Dto LocalPosition, Vector3Dto LocalPosition,
string? CelestialId, float LocalSpaceRadius,
string SourceKind, string SourceKind,
float OreRemaining, float OreRemaining,
float MaxOre, float MaxOre,
string ItemId); string ItemId,
IReadOnlyList<ResourceDepositSnapshot> Deposits);
public sealed record ResourceNodeDelta( public sealed record ResourceNodeDelta(
string Id, string Id,
string AnchorId,
string SystemId, string SystemId,
Vector3Dto LocalPosition, Vector3Dto LocalPosition,
string? CelestialId, float LocalSpaceRadius,
string SourceKind, string SourceKind,
float OreRemaining, float OreRemaining,
float MaxOre, float MaxOre,
string ItemId); string ItemId,
IReadOnlyList<ResourceDepositSnapshot> 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( public sealed record CelestialSnapshot(
string Id, string Id,
@@ -68,7 +100,7 @@ public sealed record CelestialSnapshot(
string Kind, string Kind,
Vector3Dto OrbitalAnchor, Vector3Dto OrbitalAnchor,
float LocalSpaceRadius, float LocalSpaceRadius,
string? ParentNodeId, string? ParentAnchorId,
string? OccupyingStructureId, string? OccupyingStructureId,
string? OrbitReferenceId); string? OrbitReferenceId);
@@ -78,6 +110,6 @@ public sealed record CelestialDelta(
string Kind, string Kind,
Vector3Dto OrbitalAnchor, Vector3Dto OrbitalAnchor,
float LocalSpaceRadius, float LocalSpaceRadius,
string? ParentNodeId, string? ParentAnchorId,
string? OccupyingStructureId, string? OccupyingStructureId,
string? OrbitReferenceId); string? OrbitReferenceId);

View File

@@ -10,6 +10,7 @@ public sealed record WorldSnapshot(
DateTimeOffset GeneratedAtUtc, DateTimeOffset GeneratedAtUtc,
IReadOnlyList<SystemSnapshot> Systems, IReadOnlyList<SystemSnapshot> Systems,
IReadOnlyList<CelestialSnapshot> Celestials, IReadOnlyList<CelestialSnapshot> Celestials,
IReadOnlyList<AnchorSnapshot> Anchors,
IReadOnlyList<ResourceNodeSnapshot> Nodes, IReadOnlyList<ResourceNodeSnapshot> Nodes,
IReadOnlyList<StationSnapshot> Stations, IReadOnlyList<StationSnapshot> Stations,
IReadOnlyList<ClaimSnapshot> Claims, IReadOnlyList<ClaimSnapshot> Claims,
@@ -29,6 +30,7 @@ public sealed record WorldDelta(
bool RequiresSnapshotRefresh, bool RequiresSnapshotRefresh,
IReadOnlyList<SimulationEventRecord> Events, IReadOnlyList<SimulationEventRecord> Events,
IReadOnlyList<CelestialDelta> Celestials, IReadOnlyList<CelestialDelta> Celestials,
IReadOnlyList<AnchorDelta> Anchors,
IReadOnlyList<ResourceNodeDelta> Nodes, IReadOnlyList<ResourceNodeDelta> Nodes,
IReadOnlyList<StationDelta> Stations, IReadOnlyList<StationDelta> Stations,
IReadOnlyList<ClaimDelta> Claims, IReadOnlyList<ClaimDelta> Claims,
@@ -54,7 +56,7 @@ public sealed record SimulationEventRecord(
public sealed record ObserverScope( public sealed record ObserverScope(
string ScopeKind, string ScopeKind,
string? SystemId = null, string? SystemId = null,
string? CelestialId = null); string? AnchorId = null);
public sealed record OrbitalSimulationSnapshot( public sealed record OrbitalSimulationSnapshot(
double SimulatedSecondsPerRealSecond); double SimulatedSecondsPerRealSecond);

View File

@@ -6,6 +6,7 @@ public sealed class SimulationWorld
public required string Label { get; init; } public required string Label { get; init; }
public required int Seed { get; init; } public required int Seed { get; init; }
public required List<SystemRuntime> Systems { get; init; } public required List<SystemRuntime> Systems { get; init; }
public required List<AnchorRuntime> Anchors { get; init; }
public required List<ResourceNodeRuntime> Nodes { get; init; } public required List<ResourceNodeRuntime> Nodes { get; init; }
public required List<CelestialRuntime> Celestials { get; init; } public required List<CelestialRuntime> Celestials { get; init; }
public required List<WreckRuntime> Wrecks { get; init; } public required List<WreckRuntime> Wrecks { get; init; }

View File

@@ -7,22 +7,49 @@ public sealed class SystemRuntime
public required Vector3 Position { get; init; } 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 sealed class ResourceNodeRuntime
{ {
public required string Id { get; init; } public required string Id { get; init; }
public required string AnchorId { get; init; }
public required string SystemId { get; init; } public required string SystemId { get; init; }
public required Vector3 Position { get; set; } public required Vector3 Position { get; set; }
public required string SourceKind { get; init; } public required string SourceKind { get; init; }
public required string ItemId { 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 OrbitRadius { get; init; }
public float OrbitPhase { get; init; } public float OrbitPhase { get; init; }
public float OrbitInclination { get; init; } public float OrbitInclination { get; init; }
public float OreRemaining { get; set; } public float OreRemaining { get; set; }
public float MaxOre { get; init; } public float MaxOre { get; init; }
public List<ResourceDepositRuntime> Deposits { get; } = [];
public string LastDeltaSignature { get; set; } = string.Empty; 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 sealed class CelestialRuntime
{ {
public required string Id { get; init; } public required string Id { get; init; }
@@ -30,7 +57,7 @@ public sealed class CelestialRuntime
public required SpatialNodeKind Kind { get; init; } public required SpatialNodeKind Kind { get; init; }
public required Vector3 Position { get; set; } public required Vector3 Position { get; set; }
public float LocalSpaceRadius { get; init; } public float LocalSpaceRadius { get; init; }
public string? ParentNodeId { get; set; } public string? ParentAnchorId { get; set; }
public string? OccupyingStructureId { get; set; } public string? OccupyingStructureId { get; set; }
public string? OrbitReferenceId { get; set; } public string? OrbitReferenceId { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty; public string LastDeltaSignature { get; set; } = string.Empty;
@@ -52,19 +79,19 @@ public sealed class ShipSpatialStateRuntime
{ {
public SpaceLayerKind SpaceLayer { get; set; } = SpaceLayerKind.LocalSpace; public SpaceLayerKind SpaceLayer { get; set; } = SpaceLayerKind.LocalSpace;
public required string CurrentSystemId { get; set; } public required string CurrentSystemId { get; set; }
public string? CurrentCelestialId { get; set; } public string? CurrentAnchorId { get; set; }
public Vector3? LocalPosition { get; set; } public Vector3? LocalPosition { get; set; }
public Vector3? SystemPosition { get; set; } public Vector3? SystemPosition { get; set; }
public MovementRegimeKind MovementRegime { get; set; } = MovementRegimeKind.LocalFlight; public MovementRegimeKind MovementRegime { get; set; } = MovementRegimeKind.LocalFlight;
public string? DestinationNodeId { get; set; } public string? DestinationAnchorId { get; set; }
public ShipTransitRuntime? Transit { get; set; } public ShipTransitRuntime? Transit { get; set; }
} }
public sealed class ShipTransitRuntime public sealed class ShipTransitRuntime
{ {
public required MovementRegimeKind Regime { get; init; } public required MovementRegimeKind Regime { get; init; }
public string? OriginNodeId { get; init; } public string? OriginAnchorId { get; init; }
public string? DestinationNodeId { get; init; } public string? DestinationAnchorId { get; init; }
public DateTimeOffset? StartedAtUtc { get; set; } public DateTimeOffset? StartedAtUtc { get; set; }
public DateTimeOffset? ArrivalDueAtUtc { get; set; } public DateTimeOffset? ArrivalDueAtUtc { get; set; }
public float Progress { get; set; } public float Progress { get; set; }

View File

@@ -18,13 +18,13 @@ public sealed class ScenarioContentBuilder(
scenario, scenario,
topology.SystemsById, topology.SystemsById,
topology.SpatialLayout.SystemGraphs, topology.SpatialLayout.SystemGraphs,
topology.SpatialLayout.Celestials); topology.SpatialLayout.Anchors);
var patrolRoutes = BuildPatrolRoutes(scenario, topology.SystemsById); var patrolRoutes = BuildPatrolRoutes(scenario, topology.SystemsById);
var ships = CreateShips( var ships = CreateShips(
scenario, scenario,
topology.SystemsById, topology.SystemsById,
topology.SpatialLayout.Celestials, topology.SpatialLayout.Anchors,
patrolRoutes, patrolRoutes,
stations); stations);
@@ -35,7 +35,7 @@ public sealed class ScenarioContentBuilder(
ScenarioDefinition scenario, ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById, IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyDictionary<string, SystemSpatialGraph> systemGraphs, IReadOnlyDictionary<string, SystemSpatialGraph> systemGraphs,
IReadOnlyCollection<CelestialRuntime> celestials) IReadOnlyCollection<AnchorRuntime> anchors)
{ {
var stations = new List<StationRuntime>(); var stations = new List<StationRuntime>();
var stationIdCounter = 0; var stationIdCounter = 0;
@@ -47,23 +47,27 @@ public sealed class ScenarioContentBuilder(
throw new InvalidOperationException($"Scenario station '{plan.Label}' references unknown system '{plan.SystemId}'."); 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 var station = new StationRuntime
{ {
Id = $"station-{++stationIdCounter}", Id = $"station-{++stationIdCounter}",
SystemId = system.Definition.Id, SystemId = system.Definition.Id,
AnchorId = placement.Anchor.Id,
Label = plan.Label, Label = plan.Label,
Color = plan.Color, Color = plan.Color,
Objective = StationSimulationService.NormalizeStationObjective(plan.Objective), Objective = StationSimulationService.NormalizeStationObjective(plan.Objective),
Position = placement.Position, Position = Vector3.Zero,
FactionId = GetRequiredFactionId(plan.FactionId, $"station '{plan.Label}'"), FactionId = GetRequiredFactionId(plan.FactionId, $"station '{plan.Label}'"),
CelestialId = placement.AnchorCelestial.Id,
Health = 600f, Health = 600f,
MaxHealth = 600f, MaxHealth = 600f,
}; };
stations.Add(station); 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); var startingModules = BuildStartingModules(plan);
foreach (var moduleId in startingModules) foreach (var moduleId in startingModules)
@@ -162,7 +166,7 @@ public sealed class ScenarioContentBuilder(
private List<ShipRuntime> CreateShips( private List<ShipRuntime> CreateShips(
ScenarioDefinition scenario, ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById, IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyCollection<CelestialRuntime> celestials, IReadOnlyCollection<AnchorRuntime> anchors,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes, IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
IReadOnlyCollection<StationRuntime> stations) IReadOnlyCollection<StationRuntime> stations)
{ {
@@ -181,6 +185,8 @@ public sealed class ScenarioContentBuilder(
var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f); var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f);
var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset); var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset);
var factionId = GetRequiredFactionId(formation.FactionId, $"ship formation '{formation.ShipId}' in system '{formation.SystemId}'"); 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 ships.Add(new ShipRuntime
{ {
@@ -188,9 +194,9 @@ public sealed class ScenarioContentBuilder(
SystemId = formation.SystemId, SystemId = formation.SystemId,
Definition = definition, Definition = definition,
FactionId = factionId, FactionId = factionId,
Position = position, Position = localPosition,
TargetPosition = position, TargetPosition = localPosition,
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, celestials), SpatialState = spatialState,
DefaultBehavior = CreateBehavior( DefaultBehavior = CreateBehavior(
definition, definition,
formation.SystemId, formation.SystemId,

View File

@@ -2,8 +2,15 @@ using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario; 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<SystemRuntime> systems) internal ScenarioSpatialLayout BuildLayout(IReadOnlyList<SystemRuntime> systems)
{ {
var systemGraphs = systems.ToDictionary( var systemGraphs = systems.ToDictionary(
@@ -11,6 +18,19 @@ public sealed class SpatialBuilder(IBalanceService balance)
BuildSystemSpatialGraph, BuildSystemSpatialGraph,
StringComparer.Ordinal); StringComparer.Ordinal);
var celestials = systemGraphs.Values.SelectMany(graph => graph.Celestials).ToList(); 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<ResourceNodeRuntime>(); var nodes = new List<ResourceNodeRuntime>();
var nodeIdCounter = 0; var nodeIdCounter = 0;
@@ -20,24 +40,43 @@ public sealed class SpatialBuilder(IBalanceService balance)
foreach (var node in system.Definition.ResourceNodes) foreach (var node in system.Definition.ResourceNodes)
{ {
var anchorCelestial = ResolveResourceNodeAnchor(systemGraph, node); 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 nodes.Add(new ResourceNodeRuntime
{ {
Id = $"node-{++nodeIdCounter}", Id = nodeId,
AnchorId = nodeId,
SystemId = system.Definition.Id, SystemId = system.Definition.Id,
Position = ComputeResourceNodePosition(anchorCelestial, node, balance.YPlane), Position = localPosition,
SourceKind = node.SourceKind, SourceKind = node.SourceKind,
ItemId = node.ItemId, ItemId = node.ItemId,
CelestialId = anchorCelestial?.Id, LocalSpaceRadius = LocalSpaceRadius,
OrbitRadius = node.RadiusOffset, OrbitRadius = node.RadiusOffset,
OrbitPhase = node.Angle, OrbitPhase = node.Angle,
OrbitInclination = DegreesToRadians(node.InclinationDegrees), OrbitInclination = DegreesToRadians(node.InclinationDegrees),
OreRemaining = node.OreAmount, OreRemaining = node.OreAmount,
MaxOre = 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) private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system)
@@ -70,7 +109,7 @@ public sealed class SpatialBuilder(IBalanceService balance)
kind: SpatialNodeKind.Planet, kind: SpatialNodeKind.Planet,
position: planetPosition, position: planetPosition,
localSpaceRadius: LocalSpaceRadius, localSpaceRadius: LocalSpaceRadius,
parentNodeId: primaryStarNodeId); parentAnchorId: primaryStarNodeId);
var lagrangeNodes = new Dictionary<string, CelestialRuntime>(StringComparer.Ordinal); var lagrangeNodes = new Dictionary<string, CelestialRuntime>(StringComparer.Ordinal);
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet)) foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet))
@@ -82,7 +121,7 @@ public sealed class SpatialBuilder(IBalanceService balance)
kind: SpatialNodeKind.LagrangePoint, kind: SpatialNodeKind.LagrangePoint,
position: point.Position, position: point.Position,
localSpaceRadius: LocalSpaceRadius, localSpaceRadius: LocalSpaceRadius,
parentNodeId: planetCelestial.Id, parentAnchorId: planetCelestial.Id,
orbitReferenceId: point.Designation); orbitReferenceId: point.Designation);
lagrangeNodes[point.Designation] = lagrangeCelestial; lagrangeNodes[point.Designation] = lagrangeCelestial;
} }
@@ -100,7 +139,7 @@ public sealed class SpatialBuilder(IBalanceService balance)
kind: SpatialNodeKind.Moon, kind: SpatialNodeKind.Moon,
position: moonPosition, position: moonPosition,
localSpaceRadius: LocalSpaceRadius, localSpaceRadius: LocalSpaceRadius,
parentNodeId: planetCelestial.Id); parentAnchorId: planetCelestial.Id);
} }
} }
@@ -114,7 +153,7 @@ public sealed class SpatialBuilder(IBalanceService balance)
SpatialNodeKind kind, SpatialNodeKind kind,
Vector3 position, Vector3 position,
float localSpaceRadius, float localSpaceRadius,
string? parentNodeId = null, string? parentAnchorId = null,
string? orbitReferenceId = null) string? orbitReferenceId = null)
{ {
var celestial = new CelestialRuntime var celestial = new CelestialRuntime
@@ -124,7 +163,7 @@ public sealed class SpatialBuilder(IBalanceService balance)
Kind = kind, Kind = kind,
Position = position, Position = position,
LocalSpaceRadius = localSpaceRadius, LocalSpaceRadius = localSpaceRadius,
ParentNodeId = parentNodeId, ParentAnchorId = parentAnchorId,
OrbitReferenceId = orbitReferenceId, OrbitReferenceId = orbitReferenceId,
}; };
@@ -183,7 +222,7 @@ public sealed class SpatialBuilder(IBalanceService balance)
InitialStationDefinition plan, InitialStationDefinition plan,
SystemRuntime system, SystemRuntime system,
SystemSpatialGraph graph, SystemSpatialGraph graph,
IReadOnlyCollection<CelestialRuntime> existingCelestials) IReadOnlyCollection<AnchorRuntime> existingAnchors)
{ {
if (plan.PlanetIndex is int planetIndex && if (plan.PlanetIndex is int planetIndex &&
graph.LagrangeNodesByPlanetIndex.TryGetValue(planetIndex, out var lagrangeNodes)) graph.LagrangeNodesByPlanetIndex.TryGetValue(planetIndex, out var lagrangeNodes))
@@ -191,28 +230,32 @@ public sealed class SpatialBuilder(IBalanceService balance)
var designation = ResolveLagrangeDesignation(plan.LagrangeSide); var designation = ResolveLagrangeDesignation(plan.LagrangeSide);
if (lagrangeNodes.TryGetValue(designation, out var lagrangeCelestial)) 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 }) if (plan.Position is { Length: 3 })
{ {
var targetPosition = NormalizeScenarioPoint(system, plan.Position); var targetPosition = NormalizeScenarioPoint(system, plan.Position);
var preferredCelestial = existingCelestials var preferredAnchor = existingAnchors
.Where(c => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint) .Where(anchor => anchor.SystemId == system.Definition.Id && anchor.Kind == SpatialNodeKind.LagrangePoint)
.OrderBy(c => c.Position.DistanceTo(targetPosition)) .OrderBy(anchor => anchor.Position.DistanceTo(targetPosition))
.FirstOrDefault() .FirstOrDefault()
?? existingCelestials ?? existingAnchors
.Where(c => c.SystemId == system.Definition.Id) .Where(anchor => anchor.SystemId == system.Definition.Id && IsConstructibleAnchorKind(anchor.Kind))
.OrderBy(c => c.Position.DistanceTo(targetPosition)) .OrderBy(anchor => anchor.Position.DistanceTo(targetPosition))
.First(); .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 var fallbackAnchor = existingAnchors
.FirstOrDefault(c => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId)) .Where(anchor => anchor.SystemId == system.Definition.Id)
?? graph.Celestials.First(c => c.Kind == SpatialNodeKind.Planet); .FirstOrDefault(anchor => anchor.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(anchor.OccupyingStructureId))
return new StationPlacement(fallbackCelestial, fallbackCelestial.Position); ?? 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 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); 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 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, MathF.Cos(definition.Angle) * definition.RadiusOffset,
verticalOffset, verticalOffset,
MathF.Sin(definition.Angle) * definition.RadiusOffset); MathF.Sin(definition.Angle) * definition.RadiusOffset);
}
if (anchorCelestial is null) private static IReadOnlyList<ResourceDepositRuntime> 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<ResourceDepositRuntime>(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) private static Vector3 ComputePlanetPosition(PlanetDefinition planet)
@@ -286,19 +389,22 @@ public sealed class SpatialBuilder(IBalanceService balance)
return Add(planetPosition, local); return Add(planetPosition, local);
} }
internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<CelestialRuntime> celestials) internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<AnchorRuntime> anchors)
{ {
var nearestCelestial = celestials var nearestAnchor = anchors
.Where(c => c.SystemId == systemId) .Where(anchor => anchor.SystemId == systemId)
.OrderBy(c => c.Position.DistanceTo(position)) .OrderBy(anchor => anchor.Position.DistanceTo(position))
.FirstOrDefault(); .FirstOrDefault();
var localPosition = nearestAnchor is null
? position
: position.Subtract(nearestAnchor.Position);
return new ShipSpatialStateRuntime return new ShipSpatialStateRuntime
{ {
CurrentSystemId = systemId, CurrentSystemId = systemId,
SpaceLayer = SpaceLayerKind.LocalSpace, SpaceLayer = SpaceLayerKind.LocalSpace,
CurrentCelestialId = nearestCelestial?.Id, CurrentAnchorId = nearestAnchor?.Id,
LocalPosition = position, LocalPosition = localPosition,
SystemPosition = position, SystemPosition = position,
MovementRegime = MovementRegimeKind.LocalFlight, MovementRegime = MovementRegimeKind.LocalFlight,
}; };
@@ -307,6 +413,7 @@ public sealed class SpatialBuilder(IBalanceService balance)
public sealed record ScenarioSpatialLayout( public sealed record ScenarioSpatialLayout(
IReadOnlyDictionary<string, SystemSpatialGraph> SystemGraphs, IReadOnlyDictionary<string, SystemSpatialGraph> SystemGraphs,
List<AnchorRuntime> Anchors,
List<CelestialRuntime> Celestials, List<CelestialRuntime> Celestials,
List<ResourceNodeRuntime> Nodes); List<ResourceNodeRuntime> Nodes);
@@ -317,4 +424,4 @@ public sealed record SystemSpatialGraph(
internal sealed record LagrangePointPlacement(string Designation, Vector3 Position); 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);

View File

@@ -18,13 +18,14 @@ public sealed class WorldRuntimeAssembler(
var policies = seedingService.CreatePolicies(factions); var policies = seedingService.CreatePolicies(factions);
var commanders = seedingService.CreateCommanders(factions, content.Stations, content.Ships); var commanders = seedingService.CreateCommanders(factions, content.Stations, content.Ships);
var nowUtc = DateTimeOffset.UtcNow; 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 var world = new SimulationWorld
{ {
Label = "Split Viewer / Simulation World", Label = "Split Viewer / Simulation World",
Seed = worldGenerationOptions.Seed, Seed = worldGenerationOptions.Seed,
Systems = topology.SystemRuntimes.ToList(), Systems = topology.SystemRuntimes.ToList(),
Anchors = topology.SpatialLayout.Anchors,
Celestials = topology.SpatialLayout.Celestials, Celestials = topology.SpatialLayout.Celestials,
Nodes = topology.SpatialLayout.Nodes, Nodes = topology.SpatialLayout.Nodes,
Wrecks = [], Wrecks = [],

View File

@@ -74,27 +74,27 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData)
internal List<ClaimRuntime> CreateClaims( internal List<ClaimRuntime> CreateClaims(
IReadOnlyCollection<StationRuntime> stations, IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<CelestialRuntime> celestials, IReadOnlyCollection<AnchorRuntime> anchors,
DateTimeOffset nowUtc) DateTimeOffset nowUtc)
{ {
var stationsByCelestialId = stations var stationsByAnchorId = stations
.Where(station => station.CelestialId is not null) .Where(station => !string.IsNullOrWhiteSpace(station.AnchorId))
.ToDictionary(station => station.CelestialId!, StringComparer.Ordinal); .ToDictionary(station => station.AnchorId!, StringComparer.Ordinal);
var claims = new List<ClaimRuntime>(); var claims = new List<ClaimRuntime>();
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; continue;
} }
claims.Add(new ClaimRuntime claims.Add(new ClaimRuntime
{ {
Id = $"claim-{celestial.Id}", Id = $"claim-{anchor.Id}",
FactionId = station.FactionId, FactionId = station.FactionId,
SystemId = celestial.SystemId, SystemId = anchor.SystemId,
CelestialId = celestial.Id, AnchorId = anchor.Id,
PlacedAtUtc = nowUtc, PlacedAtUtc = nowUtc,
ActivatesAtUtc = nowUtc.AddSeconds(8), ActivatesAtUtc = nowUtc.AddSeconds(8),
State = ClaimStateKinds.Activating, State = ClaimStateKinds.Activating,
@@ -119,12 +119,12 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData)
} }
var moduleId = InfrastructureSimulationService.GetNextStationModuleToBuild(station, world); var moduleId = InfrastructureSimulationService.GetNextStationModuleToBuild(station, world);
if (moduleId is null || station.CelestialId is null) if (moduleId is null || station.AnchorId is null)
{ {
continue; 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)) if (claim is null || !world.ModuleRecipes.TryGetValue(moduleId, out var recipe))
{ {
continue; continue;
@@ -135,7 +135,7 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData)
Id = $"site-{station.Id}", Id = $"site-{station.Id}",
FactionId = station.FactionId, FactionId = station.FactionId,
SystemId = station.SystemId, SystemId = station.SystemId,
CelestialId = station.CelestialId, AnchorId = station.AnchorId,
TargetKind = "station-module", TargetKind = "station-module",
TargetDefinitionId = "station", TargetDefinitionId = "station",
BlueprintId = moduleId, BlueprintId = moduleId,

View File

@@ -1,5 +1,7 @@
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using SpaceGame.Api.Universe.Scenario;
namespace SpaceGame.Api.Universe.Simulation; namespace SpaceGame.Api.Universe.Simulation;
internal sealed class OrbitalStateUpdater internal sealed class OrbitalStateUpdater
@@ -223,22 +225,47 @@ internal sealed class OrbitalStateUpdater
foreach (var station in world.Stations) 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) 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; 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)) 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.CurrentSystemId = ship.SystemId;
ship.SpatialState.LocalPosition = ship.Position; ship.SpatialState.LocalPosition = ship.Position;
ship.SpatialState.SystemPosition = ship.Position;
if (ship.SpatialState.Transit is not null) if (ship.SpatialState.Transit is not null)
{ {
ship.SpatialState.CurrentCelestialId = null; ship.SpatialState.CurrentAnchorId = null;
continue; continue;
} }
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace; ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
var nearestCelestial = world.Celestials var currentAnchor = ship.SpatialState.CurrentAnchorId is not null
.Where(candidate => candidate.SystemId == ship.SystemId) ? world.Anchors.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentAnchorId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position)) : null;
.FirstOrDefault(); if (currentAnchor is null || !string.Equals(currentAnchor.SystemId, ship.SystemId, StringComparison.Ordinal))
ship.SpatialState.CurrentCelestialId = nearestCelestial?.Id; {
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) if (ship.DockedStationId is null)
{ {
@@ -282,9 +318,9 @@ internal sealed class OrbitalStateUpdater
} }
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); 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;
} }
} }
} }

View File

@@ -315,6 +315,8 @@ public sealed class WorldService
string.Equals(candidate.FactionId, faction.Id, StringComparison.Ordinal) string.Equals(candidate.FactionId, faction.Id, StringComparison.Ordinal)
&& string.Equals(candidate.SystemId, system.Definition.Id, StringComparison.Ordinal)); && string.Equals(candidate.SystemId, system.Definition.Id, StringComparison.Ordinal));
var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, homeStation); 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 var ship = new ShipRuntime
{ {
@@ -322,9 +324,9 @@ public sealed class WorldService
SystemId = system.Definition.Id, SystemId = system.Definition.Id,
Definition = definition, Definition = definition,
FactionId = faction.Id, FactionId = faction.Id,
Position = spawnPosition, Position = localPosition,
TargetPosition = spawnPosition, TargetPosition = localPosition,
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Celestials), SpatialState = spatialState,
DefaultBehavior = defaultBehavior, DefaultBehavior = defaultBehavior,
Skills = ShipBootstrapPolicy.CreateSkills(definition), Skills = ShipBootstrapPolicy.CreateSkills(definition),
Health = definition.Hull, Health = definition.Hull,
@@ -352,15 +354,18 @@ public sealed class WorldService
? $"{faction.Label} {ToTitleCaseToken(objective)} {CountFactionStationsInSystem(faction.Id, system.Definition.Id) + 1}" ? $"{faction.Label} {ToTitleCaseToken(objective)} {CountFactionStationsInSystem(faction.Id, system.Definition.Id) + 1}"
: request.Label.Trim(); : request.Label.Trim();
var stationId = $"station-{faction.Id}-{objective}-{Guid.NewGuid():N}".ToLowerInvariant(); 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 var station = new StationRuntime
{ {
Id = stationId, Id = stationId,
SystemId = system.Definition.Id, SystemId = system.Definition.Id,
AnchorId = anchor.Id,
Label = label, Label = label,
Color = faction.Color, Color = faction.Color,
Objective = objective, Objective = objective,
Position = position, Position = Vector3.Zero,
FactionId = faction.Id, FactionId = faction.Id,
PolicySetId = faction.DefaultPolicySetId, PolicySetId = faction.DefaultPolicySetId,
Health = 600f, Health = 600f,
@@ -375,6 +380,7 @@ public sealed class WorldService
station.PopulationCapacity = GetStationSupportedPopulation(_world.ModuleDefinitions, station); station.PopulationCapacity = GetStationSupportedPopulation(_world.ModuleDefinitions, station);
station.WorkforceRequired = GetStationRequiredWorkforce(_world.ModuleDefinitions, station); station.WorkforceRequired = GetStationRequiredWorkforce(_world.ModuleDefinitions, station);
_world.Stations.Add(station); _world.Stations.Add(station);
anchor.OccupyingStructureId = station.Id;
new GeopoliticalSimulationService().Update(_world, 0f, []); new GeopoliticalSimulationService().Update(_world, 0f, []);
PublishSnapshotRefreshUnsafe("spawn-station", $"Spawned station {station.Id}", "station", station.Id); PublishSnapshotRefreshUnsafe("spawn-station", $"Spawned station {station.Id}", "station", station.Id);
@@ -490,6 +496,7 @@ public sealed class WorldService
[], [],
[], [],
[], [],
[],
null); null);
_history.Enqueue(worldDelta); _history.Enqueue(worldDelta);
@@ -526,6 +533,7 @@ public sealed class WorldService
[], [],
[], [],
[], [],
[],
null); null);
_history.Enqueue(worldDelta); _history.Enqueue(worldDelta);
@@ -608,6 +616,8 @@ public sealed class WorldService
var shipId = $"ship-{playerFaction.Id}-{definition.Id}-{Guid.NewGuid():N}".ToLowerInvariant(); var shipId = $"ship-{playerFaction.Id}-{definition.Id}-{Guid.NewGuid():N}".ToLowerInvariant();
var spawnPosition = ResolveSpawnPosition(system.Definition.Id); var spawnPosition = ResolveSpawnPosition(system.Definition.Id);
var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, null); 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 var ship = new ShipRuntime
{ {
@@ -615,9 +625,9 @@ public sealed class WorldService
SystemId = system.Definition.Id, SystemId = system.Definition.Id,
Definition = definition, Definition = definition,
FactionId = playerFaction.Id, FactionId = playerFaction.Id,
Position = spawnPosition, Position = localPosition,
TargetPosition = spawnPosition, TargetPosition = localPosition,
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Celestials), SpatialState = spatialState,
DefaultBehavior = defaultBehavior, DefaultBehavior = defaultBehavior,
Skills = ShipBootstrapPolicy.CreateSkills(definition), Skills = ShipBootstrapPolicy.CreateSkills(definition),
Health = definition.Hull, Health = definition.Hull,
@@ -712,7 +722,7 @@ public sealed class WorldService
SourceStationId = request.SourceStationId, SourceStationId = request.SourceStationId,
DestinationStationId = request.DestinationStationId, DestinationStationId = request.DestinationStationId,
ItemId = request.ItemId, ItemId = request.ItemId,
NodeId = request.NodeId, AnchorId = request.AnchorId,
ConstructionSiteId = request.ConstructionSiteId, ConstructionSiteId = request.ConstructionSiteId,
ModuleId = request.ModuleId, ModuleId = request.ModuleId,
WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f), WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f),
@@ -780,7 +790,7 @@ public sealed class WorldService
ship.DefaultBehavior.AreaSystemId = request.AreaSystemId; ship.DefaultBehavior.AreaSystemId = request.AreaSystemId;
ship.DefaultBehavior.TargetEntityId = request.TargetEntityId; ship.DefaultBehavior.TargetEntityId = request.TargetEntityId;
ship.DefaultBehavior.ItemId = request.ItemId; ship.DefaultBehavior.ItemId = request.ItemId;
ship.DefaultBehavior.PreferredNodeId = request.PreferredNodeId; ship.DefaultBehavior.PreferredAnchorId = request.PreferredAnchorId;
ship.DefaultBehavior.PreferredConstructionSiteId = request.PreferredConstructionSiteId; ship.DefaultBehavior.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
ship.DefaultBehavior.PreferredModuleId = request.PreferredModuleId; ship.DefaultBehavior.PreferredModuleId = request.PreferredModuleId;
ship.DefaultBehavior.TargetPosition = request.TargetPosition is null ship.DefaultBehavior.TargetPosition = request.TargetPosition is null
@@ -807,7 +817,7 @@ public sealed class WorldService
SourceStationId = template.SourceStationId, SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId, DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId, ItemId = template.ItemId,
NodeId = template.NodeId, AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId, ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId, ModuleId = template.ModuleId,
WaitSeconds = template.WaitSeconds ?? 0f, WaitSeconds = template.WaitSeconds ?? 0f,
@@ -905,6 +915,16 @@ public sealed class WorldService
return new Vector3(MathF.Cos(angle) * radius, 0f, MathF.Sin(angle) * radius); 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<string> BuildStarterStationModules(string factionId, string objective) private IReadOnlyList<string> BuildStarterStationModules(string factionId, string objective)
{ {
var modules = new List<string>(); var modules = new List<string>();
@@ -1079,9 +1099,9 @@ public sealed class WorldService
} }
var systemFilter = scope.SystemId; 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 return delta with
@@ -1091,6 +1111,7 @@ public sealed class WorldService
.Where((evt) => IsEventVisibleToScope(evt, scope, systemFilter)) .Where((evt) => IsEventVisibleToScope(evt, scope, systemFilter))
.ToList(), .ToList(),
Celestials = delta.Celestials.Where((celestial) => systemFilter is null || celestial.SystemId == 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(), Nodes = delta.Nodes.Where((node) => systemFilter is null || node.SystemId == systemFilter).ToList(),
Stations = delta.Stations.Where((station) => systemFilter is null || station.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(), Claims = delta.Claims.Where((claim) => systemFilter is null || claim.SystemId == systemFilter).ToList(),
@@ -1136,8 +1157,8 @@ public sealed class WorldService
ScopeEntityId = scopeEntityId, ScopeEntityId = scopeEntityId,
}; };
private string? ResolveCelestialSystemId(string celestialId) => private string? ResolveAnchorSystemId(string anchorId) =>
_world.Celestials.FirstOrDefault((c) => c.Id == celestialId)?.SystemId; _world.Anchors.FirstOrDefault((anchor) => anchor.Id == anchorId)?.SystemId;
private string? ResolveMarketOrderSystemId(string orderId) private string? ResolveMarketOrderSystemId(string orderId)
{ {
@@ -1181,7 +1202,7 @@ public sealed class WorldService
{ {
"universe" => true, "universe" => true,
"system" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter, "system" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter,
"local-celestial" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter, "local-anchor" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter,
_ => true, _ => true,
}; };
} }

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { GameViewer } from "./GameViewer"; import { GameViewer } from "./GameViewer";
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue"; import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
import ViewerEntityBrowserPanel from "./components/ViewerEntityBrowserPanel.vue"; import ViewerEntityBrowserPanel from "./components/ViewerEntityBrowserPanel.vue";
@@ -31,8 +31,8 @@ const authStore = useAuthStore();
const playerFactionStore = usePlayerFactionStore(); const playerFactionStore = usePlayerFactionStore();
const automationCatalogStore = useShipAutomationCatalogStore(); const automationCatalogStore = useShipAutomationCatalogStore();
const selectionStore = useViewerSelectionStore(); const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore); const { selectedEntityId } = storeToRefs(selectionStore);
const { canAccessGm, effectivePlayerId } = storeToRefs(authStore); const { canAccessGm, effectivePlayerId, isActingAsAlternateIdentity } = storeToRefs(authStore);
const { playerFaction } = storeToRefs(playerFactionStore); const { playerFaction } = storeToRefs(playerFactionStore);
let viewer: GameViewer | undefined; let viewer: GameViewer | undefined;
@@ -42,14 +42,27 @@ const gmSettingsOpen = ref(false);
const gmMenuOpen = ref(false); const gmMenuOpen = ref(false);
const leftSidebarTab = ref<"player" | "entities">("player"); const leftSidebarTab = ref<"player" | "entities">("player");
const playerContextReady = ref(false); const playerContextReady = ref(false);
const rightSidebarWidth = ref(380);
const rightSidebarResizing = ref(false);
const shouldShowOnboarding = computed(() =>
!!playerContextReady.value
&& !!playerFaction.value?.requiresOnboarding
&& (!canAccessGm.value || isActingAsAlternateIdentity.value),
);
onMounted(async () => { onMounted(async () => {
window.addEventListener("pointermove", onWindowPointerMove);
window.addEventListener("pointerup", stopRightSidebarResize);
window.addEventListener("pointercancel", stopRightSidebarResize);
void automationCatalogStore.load(); void automationCatalogStore.load();
await refreshPlayerContext(); await refreshPlayerContext();
await startViewerIfAuthenticated(); await startViewerIfAuthenticated();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener("pointermove", onWindowPointerMove);
window.removeEventListener("pointerup", stopRightSidebarResize);
window.removeEventListener("pointercancel", stopRightSidebarResize);
viewer?.dispose(); viewer?.dispose();
}); });
@@ -71,7 +84,7 @@ watch(
); );
watch( watch(
() => playerFaction.value?.requiresOnboarding ?? false, () => shouldShowOnboarding.value,
async (requiresOnboarding) => { async (requiresOnboarding) => {
if (requiresOnboarding) { if (requiresOnboarding) {
viewer?.dispose(); viewer?.dispose();
@@ -101,8 +114,31 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
viewer?.focusSelection(selection, cameraMode); viewer?.focusSelection(selection, cameraMode);
} }
function startRightSidebarResize(event: PointerEvent) {
if (window.innerWidth <= 760 || event.button !== 0) {
return;
}
rightSidebarResizing.value = true;
event.preventDefault();
}
function onWindowPointerMove(event: PointerEvent) {
if (!rightSidebarResizing.value) {
return;
}
const minWidth = 280;
const maxWidth = Math.min(720, Math.max(window.innerWidth - 240, minWidth));
rightSidebarWidth.value = Math.min(maxWidth, Math.max(minWidth, window.innerWidth - event.clientX));
}
function stopRightSidebarResize() {
rightSidebarResizing.value = false;
}
async function startViewerIfAuthenticated() { async function startViewerIfAuthenticated() {
if (!authStore.isAuthenticated || viewer || !playerContextReady.value || playerFaction.value?.requiresOnboarding) { if (!authStore.isAuthenticated || viewer || !playerContextReady.value || shouldShowOnboarding.value) {
return; return;
} }
@@ -155,7 +191,7 @@ async function refreshPlayerContext() {
<p>Loading your in-universe identity and ownership state.</p> <p>Loading your in-universe identity and ownership state.</p>
</div> </div>
</div> </div>
<PlayerOnboardingPanel v-else-if="playerContextReady && playerFaction?.requiresOnboarding" /> <PlayerOnboardingPanel v-else-if="shouldShowOnboarding" />
<div v-else class="viewer-app"> <div v-else class="viewer-app">
<div <div
ref="canvasHostEl" ref="canvasHostEl"
@@ -232,27 +268,28 @@ async function refreshPlayerContext() {
</div> </div>
</div> </div>
<div class="absolute right-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(380px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto"> <div class="viewer-right-sidebar-dock" :style="{ width: `${rightSidebarWidth}px` }">
<ViewerEntityInspectorPanel <section class="viewer-right-sidebar pointer-events-auto">
class="min-h-0 flex-1" <div
:fallback-title="hudState.detailPanel.title" class="viewer-right-sidebar__resize-handle"
:fallback-html="hudState.detailPanel.bodyHtml" :class="rightSidebarResizing ? 'viewer-right-sidebar__resize-handle--active' : ''"
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)" @pointerdown="startRightSidebarResize"
/> />
<div <div class="viewer-right-sidebar__body">
class="pointer-events-auto rounded-xl bg-[rgba(255,116,88,0.14)] px-3.5 py-3 text-[#ffd8cf]" <ViewerEntityInspectorPanel
:hidden="hudState.error.hidden" class="viewer-right-sidebar__panel"
> :fallback-title="hudState.detailPanel.title"
{{ hudState.error.message }} :fallback-html="hudState.detailPanel.bodyHtml"
</div> @focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
<button />
v-if="selectedEntityId" </div>
type="button" <div
class="selection-action-button pointer-events-auto self-end rounded-full border border-white/10 bg-white/5 px-3.5 py-2.5 text-sm text-[color:var(--viewer-text)] transition hover:bg-white/10" class="viewer-right-sidebar__error"
@click="selectionStore.clearSelection('ui')" :hidden="hudState.error.hidden"
> >
Clear {{ selectedEntityLabel ?? "Selection" }} {{ hudState.error.message }}
</button> </div>
</section>
</div> </div>
<div ref="historyLayerHostEl"> <div ref="historyLayerHostEl">

View File

@@ -8,7 +8,7 @@ import { updatePanFromKeyboard } from "./viewerCamera";
import { setShellReticleOpacity } from "./viewerControls"; import { setShellReticleOpacity } from "./viewerControls";
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop"; import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
import { updateSystemStarPresentation } from "./viewerPresentation"; import { updateSystemStarPresentation } from "./viewerPresentation";
import { resolveFocusedCelestialId } from "./viewerSelection"; import { resolveFocusedAnchorId } from "./viewerSelection";
import { describeSelectionParent } from "./viewerPanels"; import { describeSelectionParent } from "./viewerPanels";
import { import {
createInitialNetworkStats, createInitialNetworkStats,
@@ -195,6 +195,7 @@ export class ViewerAppController {
return this.sceneDataController.createWorldPresentationContext({ return this.sceneDataController.createWorldPresentationContext({
world: this.world, world: this.world,
activeSystemId: this.activeSystemId, activeSystemId: this.activeSystemId,
focusedAnchorId: this.resolveFocusedAnchorId(),
cameraMode: this.cameraMode, cameraMode: this.cameraMode,
povLevel: this.povLevel, povLevel: this.povLevel,
orbitYaw: this.orbitYaw, orbitYaw: this.orbitYaw,
@@ -284,6 +285,7 @@ export class ViewerAppController {
this.sceneStore.setViewContext(this.activeSystemId ?? null, this.povLevel); this.sceneStore.setViewContext(this.activeSystemId ?? null, this.povLevel);
} }
this.navigationController.updateActiveSystem(); this.navigationController.updateActiveSystem();
this.navigationController.syncGalaxyAnchorToActiveSystem();
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) { if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
// Follow camera directly controls systemLayer.camera in updateFollowCamera. // Follow camera directly controls systemLayer.camera in updateFollowCamera.
@@ -350,8 +352,8 @@ export class ViewerAppController {
this.interactionController.refreshHistoryWindows(); this.interactionController.refreshHistoryWindows();
} }
private resolveFocusedCelestialId() { private resolveFocusedAnchorId() {
return resolveFocusedCelestialId(this.world, this.selectedItems); return resolveFocusedAnchorId(this.world, this.selectedItems);
} }
private onResize(width: number, height: number) { private onResize(width: number, height: number) {

View File

@@ -28,7 +28,7 @@ import type {
export interface WorldStreamScope { export interface WorldStreamScope {
scopeKind?: string; scopeKind?: string;
systemId?: string | null; systemId?: string | null;
bubbleId?: string | null; anchorId?: string | null;
} }
async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit, options?: { skipAuth?: boolean; skipRefresh?: boolean }): Promise<T> { async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit, options?: { skipAuth?: boolean; skipRefresh?: boolean }): Promise<T> {
@@ -105,8 +105,8 @@ export function openWorldStream(
if (scope?.systemId) { if (scope?.systemId) {
query.set("systemId", scope.systemId); query.set("systemId", scope.systemId);
} }
if (scope?.bubbleId) { if (scope?.anchorId) {
query.set("bubbleId", scope.bubbleId); query.set("anchorId", scope.anchorId);
} }
const stream = new EventSource(`/api/world/stream?${query.toString()}`); const stream = new EventSource(`/api/world/stream?${query.toString()}`);

View File

@@ -115,12 +115,13 @@ function formatShipLocation(ship: ShipSnapshot) {
return `Docked ${dockedStation.label}`; return `Docked ${dockedStation.label}`;
} }
if (ship.spatialState.transit?.destinationNodeId) { const transitAnchorId = ship.spatialState.transit?.destinationAnchorId ?? ship.spatialState.destinationAnchorId;
return `Transit ${ship.systemId}`; if (transitAnchorId) {
return `Transit ${titleCase(transitAnchorId)}`;
} }
if (ship.celestialId) { if (ship.spatialState.currentAnchorId) {
return `Orbit ${titleCase(ship.celestialId)}`; return `Anchor ${compactAnchorId(ship.spatialState.currentAnchorId)}`;
} }
const system = systemById.value.get(ship.systemId); const system = systemById.value.get(ship.systemId);
@@ -129,13 +130,32 @@ function formatShipLocation(ship: ShipSnapshot) {
function formatStationLocation(station: StationSnapshot) { function formatStationLocation(station: StationSnapshot) {
const system = systemById.value.get(station.systemId); const system = systemById.value.get(station.systemId);
if (station.celestialId) { if (station.anchorId) {
return `${system?.label ?? station.systemId} · ${titleCase(station.celestialId)}`; return `${system?.label ?? station.systemId} · ${compactAnchorId(station.anchorId)}`;
} }
return system?.label ?? station.systemId; return system?.label ?? station.systemId;
} }
function compactAnchorId(value: string) {
const lagrangeMatch = value.match(/(l[1-5])$/i);
if (lagrangeMatch) {
return lagrangeMatch[1].toUpperCase();
}
const moonMatch = value.match(/moon-(\d+)$/i);
if (moonMatch) {
return `Moon ${moonMatch[1]}`;
}
const planetMatch = value.match(/planet-(\d+)$/i);
if (planetMatch) {
return `Planet ${planetMatch[1]}`;
}
return titleCase(value);
}
function shipAiStates(ship: ShipSnapshot) { function shipAiStates(ship: ShipSnapshot) {
const travelToken = ship.spatialState.transit ? "TRV" : ""; const travelToken = ship.spatialState.transit ? "TRV" : "";
const dockToken = ship.dockedStationId ? "DCK" : ""; const dockToken = ship.dockedStationId ? "DCK" : "";

View File

@@ -80,6 +80,14 @@ function formatPercent(value: number) {
return `${Math.round(value * 100)}%`; return `${Math.round(value * 100)}%`;
} }
function formatCargoTypeLabel(types: string[] | null | undefined) {
if (!types || types.length === 0) {
return "general";
}
return types.join(" / ");
}
function joinDetail(parts: Array<string | null | undefined>) { function joinDetail(parts: Array<string | null | undefined>) {
return parts.filter((part): part is string => !!part && part.trim().length > 0).join(" · "); return parts.filter((part): part is string => !!part && part.trim().length > 0).join(" · ");
} }
@@ -88,7 +96,7 @@ function describeOrderTarget(order: {
itemId?: string | null; itemId?: string | null;
targetEntityId?: string | null; targetEntityId?: string | null;
targetSystemId?: string | null; targetSystemId?: string | null;
nodeId?: string | null; anchorId?: string | null;
constructionSiteId?: string | null; constructionSiteId?: string | null;
sourceStationId?: string | null; sourceStationId?: string | null;
destinationStationId?: string | null; destinationStationId?: string | null;
@@ -97,7 +105,7 @@ function describeOrderTarget(order: {
return order.itemId return order.itemId
?? order.targetEntityId ?? order.targetEntityId
?? order.targetSystemId ?? order.targetSystemId
?? order.nodeId ?? order.anchorId
?? order.constructionSiteId ?? order.constructionSiteId
?? order.destinationStationId ?? order.destinationStationId
?? order.sourceStationId ?? order.sourceStationId
@@ -109,13 +117,15 @@ function describeSubTaskTarget(subTask: {
itemId?: string | null; itemId?: string | null;
targetEntityId?: string | null; targetEntityId?: string | null;
targetSystemId?: string | null; targetSystemId?: string | null;
targetNodeId?: string | null; targetAnchorId?: string | null;
targetResourceNodeId?: string | null;
moduleId?: string | null; moduleId?: string | null;
}) { }) {
return subTask.itemId return subTask.itemId
?? subTask.targetEntityId ?? subTask.targetEntityId
?? subTask.targetSystemId ?? subTask.targetSystemId
?? subTask.targetNodeId ?? subTask.targetAnchorId
?? subTask.targetResourceNodeId
?? subTask.moduleId ?? subTask.moduleId
?? "—"; ?? "—";
} }
@@ -184,8 +194,14 @@ const shipStatusRows = computed(() => {
return []; return [];
} }
const shipLocation = selectedShip.value.spatialState.currentAnchorId
?? selectedShip.value.anchorId
?? selectedShip.value.systemId
?? "unknown";
return [ return [
{ label: "State", value: titleCase(selectedShip.value.state) }, { label: "State", value: titleCase(selectedShip.value.state) },
{ label: "Location", value: shipLocation },
{ label: "Behavior", value: getShipBehaviorLabel(selectedShip.value.defaultBehavior.kind) }, { label: "Behavior", value: getShipBehaviorLabel(selectedShip.value.defaultBehavior.kind) },
{ label: "Control", value: titleCase(selectedShip.value.controlSourceKind) }, { label: "Control", value: titleCase(selectedShip.value.controlSourceKind) },
{ label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" }, { label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" },
@@ -201,20 +217,21 @@ const shipStatusRows = computed(() => {
]; ];
}); });
const shipCargoSummaryRows = computed(() => { const shipCargoBarRows = computed(() => {
if (!selectedShip.value) { if (!selectedShip.value) {
return []; return [];
} }
const usedCargo = selectedShip.value.inventory.reduce((sum, entry) => sum + entry.amount, 0); const usedCargo = selectedShip.value.inventory.reduce((sum, entry) => sum + entry.amount, 0);
return [ return [{
{ label: "Used", value: formatAmount(usedCargo) }, key: "cargo",
{ label: "Capacity", value: formatAmount(selectedShip.value.cargoCapacity) }, label: `${formatCargoTypeLabel(selectedShip.value.cargoTypes)}`,
{ label: "Free", value: formatAmount(Math.max(selectedShip.value.cargoCapacity - usedCargo, 0)) }, value: usedCargo,
{ label: "Travel", value: `${formatAmount(selectedShip.value.travelSpeed)} ${selectedShip.value.travelSpeedUnit}` }, valueLabel: formatAmount(usedCargo),
{ label: "Hull", value: formatAmount(selectedShip.value.health) }, max: selectedShip.value.cargoCapacity,
{ label: "Regime", value: titleCase(selectedShip.value.spatialState.movementRegime) }, maxLabel: formatAmount(selectedShip.value.cargoCapacity),
]; fillRatio: selectedShip.value.cargoCapacity > 0 ? usedCargo / selectedShip.value.cargoCapacity : 0,
}];
}); });
const shipCargoRows = computed(() => const shipCargoRows = computed(() =>
@@ -309,8 +326,13 @@ const stationStatusRows = computed(() => {
return []; return [];
} }
const stationLocation = selectedStation.value.anchorId
?? selectedStation.value.systemId
?? "unknown";
return [ return [
{ label: "Category", value: titleCase(selectedStation.value.category) }, { label: "Category", value: titleCase(selectedStation.value.category) },
{ label: "Location", value: stationLocation },
{ label: "Objective", value: titleCase(selectedStation.value.objective) }, { label: "Objective", value: titleCase(selectedStation.value.objective) },
{ label: "Docked", value: `${selectedStation.value.dockedShips} / ${selectedStation.value.dockingPads}` }, { label: "Docked", value: `${selectedStation.value.dockedShips} / ${selectedStation.value.dockingPads}` },
{ {
@@ -335,10 +357,12 @@ const stationModuleRows = computed(() =>
const stationStorageRows = computed(() => const stationStorageRows = computed(() =>
selectedStation.value?.storageUsage.map((entry) => ({ selectedStation.value?.storageUsage.map((entry) => ({
key: entry.storageClass, key: entry.storageClass,
storageClass: titleCase(entry.storageClass), label: titleCase(entry.storageClass),
used: formatAmount(entry.used), value: entry.used,
capacity: formatAmount(entry.capacity), valueLabel: formatAmount(entry.used),
fill: entry.capacity > 0 ? formatPercent(entry.used / entry.capacity) : "0%", max: entry.capacity,
maxLabel: formatAmount(entry.capacity),
fillRatio: entry.capacity > 0 ? entry.used / entry.capacity : 0,
})) ?? [], })) ?? [],
); );
@@ -429,7 +453,7 @@ async function saveBehavior() {
itemId: behaviorForm.kind === "local-auto-mine" itemId: behaviorForm.kind === "local-auto-mine"
? (behaviorForm.itemId.trim() || null) ? (behaviorForm.itemId.trim() || null)
: null, : null,
preferredNodeId: null, preferredAnchorId: null,
preferredConstructionSiteId: null, preferredConstructionSiteId: null,
preferredModuleId: null, preferredModuleId: null,
targetPosition: null, targetPosition: null,
@@ -461,7 +485,7 @@ async function queueHoldPositionOrder() {
sourceStationId: null, sourceStationId: null,
destinationStationId: null, destinationStationId: null,
itemId: null, itemId: null,
nodeId: null, anchorId: null,
constructionSiteId: null, constructionSiteId: null,
moduleId: null, moduleId: null,
waitSeconds: 0, waitSeconds: 0,
@@ -497,7 +521,7 @@ async function queueMoveOrder() {
sourceStationId: null, sourceStationId: null,
destinationStationId: null, destinationStationId: null,
itemId: null, itemId: null,
nodeId: null, anchorId: null,
constructionSiteId: null, constructionSiteId: null,
moduleId: null, moduleId: null,
waitSeconds: 0, waitSeconds: 0,
@@ -540,7 +564,7 @@ async function queueMineResourceOrder() {
sourceStationId: null, sourceStationId: null,
destinationStationId: null, destinationStationId: null,
itemId, itemId,
nodeId: null, anchorId: null,
constructionSiteId: null, constructionSiteId: null,
moduleId: null, moduleId: null,
waitSeconds: 0, waitSeconds: 0,
@@ -609,15 +633,20 @@ async function clearOrders() {
<div class="entity-inspector-section"> <div class="entity-inspector-section">
<h4>Cargo</h4> <h4>Cargo</h4>
<div class="entity-inspector-table-wrap"> <div class="entity-inspector-capacity-list">
<table class="entity-inspector-table entity-inspector-table--kv"> <div v-for="row in shipCargoBarRows" :key="row.key" class="entity-inspector-capacity">
<tbody> <div class="entity-inspector-capacity__header">
<tr v-for="row in shipCargoSummaryRows" :key="row.label"> <span class="entity-inspector-capacity__label">{{ row.label }}</span>
<th scope="row">{{ row.label }}</th> <span class="entity-inspector-capacity__value">{{ row.valueLabel }} / {{ row.maxLabel }}</span>
<td>{{ row.value }}</td> </div>
</tr> <div class="entity-inspector-capacity__scale">
</tbody> <span>0</span>
</table> <div class="entity-inspector-capacity__track">
<div class="entity-inspector-capacity__fill" :style="{ width: `${Math.max(0, Math.min(100, row.fillRatio * 100))}%` }"></div>
</div>
<span>{{ row.maxLabel }}</span>
</div>
</div>
</div> </div>
<div v-if="shipCargoRows.length > 0" class="entity-inspector-table-wrap"> <div v-if="shipCargoRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table"> <table class="entity-inspector-table">
@@ -635,7 +664,7 @@ async function clearOrders() {
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-else class="entity-inspector-empty">No cargo.</div> <div v-else class="entity-inspector-empty">No wares loaded.</div>
</div> </div>
<div class="entity-inspector-section"> <div class="entity-inspector-section">
@@ -856,25 +885,20 @@ async function clearOrders() {
<div class="entity-inspector-section"> <div class="entity-inspector-section">
<h4>Storage</h4> <h4>Storage</h4>
<div v-if="stationStorageRows.length > 0" class="entity-inspector-table-wrap"> <div v-if="stationStorageRows.length > 0" class="entity-inspector-capacity-list">
<table class="entity-inspector-table"> <div v-for="row in stationStorageRows" :key="row.key" class="entity-inspector-capacity">
<thead> <div class="entity-inspector-capacity__header">
<tr> <span class="entity-inspector-capacity__label">{{ row.label }}</span>
<th scope="col">Class</th> <span class="entity-inspector-capacity__value">{{ row.valueLabel }} / {{ row.maxLabel }}</span>
<th scope="col" class="entity-inspector-table__numeric">Used</th> </div>
<th scope="col" class="entity-inspector-table__numeric">Capacity</th> <div class="entity-inspector-capacity__scale">
<th scope="col" class="entity-inspector-table__numeric">Fill</th> <span>0</span>
</tr> <div class="entity-inspector-capacity__track">
</thead> <div class="entity-inspector-capacity__fill" :style="{ width: `${Math.max(0, Math.min(100, row.fillRatio * 100))}%` }"></div>
<tbody> </div>
<tr v-for="row in stationStorageRows" :key="row.key"> <span>{{ row.maxLabel }}</span>
<td>{{ row.storageClass }}</td> </div>
<td class="entity-inspector-table__numeric">{{ row.used }}</td> </div>
<td class="entity-inspector-table__numeric">{{ row.capacity }}</td>
<td class="entity-inspector-table__numeric">{{ row.fill }}</td>
</tr>
</tbody>
</table>
</div> </div>
<div v-if="stationInventoryRows.length > 0" class="entity-inspector-table-wrap"> <div v-if="stationInventoryRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table"> <table class="entity-inspector-table">

View File

@@ -157,7 +157,7 @@ async function runAction(action: MenuAction) {
sourceStationId: null, sourceStationId: null,
destinationStationId: null, destinationStationId: null,
itemId, itemId,
nodeId: target.value.selection.kind === "node" ? target.value.selection.id : null, anchorId: target.value.anchorId ?? null,
constructionSiteId: null, constructionSiteId: null,
moduleId: null, moduleId: null,
waitSeconds: 0, waitSeconds: 0,
@@ -182,7 +182,7 @@ async function runAction(action: MenuAction) {
sourceStationId: null, sourceStationId: null,
destinationStationId: null, destinationStationId: null,
itemId: null, itemId: null,
nodeId: null, anchorId: null,
constructionSiteId: null, constructionSiteId: null,
moduleId: null, moduleId: null,
waitSeconds: 8, waitSeconds: 8,
@@ -207,7 +207,7 @@ async function runAction(action: MenuAction) {
sourceStationId: null, sourceStationId: null,
destinationStationId: null, destinationStationId: null,
itemId: null, itemId: null,
nodeId: null, anchorId: null,
constructionSiteId: null, constructionSiteId: null,
moduleId: null, moduleId: null,
waitSeconds: 6, waitSeconds: 6,
@@ -232,7 +232,7 @@ async function runAction(action: MenuAction) {
sourceStationId: null, sourceStationId: null,
destinationStationId: null, destinationStationId: null,
itemId: null, itemId: null,
nodeId: null, anchorId: null,
constructionSiteId: null, constructionSiteId: null,
moduleId: null, moduleId: null,
waitSeconds: 0, waitSeconds: 0,

View File

@@ -149,13 +149,6 @@ function compactRate(value: number | null | undefined) {
return `${sign}${value.toFixed(2)}/s`; return `${sign}${value.toFixed(2)}/s`;
} }
function formatCargoAmount(value: number | null | undefined) {
if (value == null || Number.isNaN(value)) return "—";
const rounded = Math.round(value);
if (Math.abs(value - rounded) < 0.005) return String(rounded);
return value.toFixed(2).replace(/\.?0+$/, "");
}
function formatPercent(value: number | null | undefined) { function formatPercent(value: number | null | undefined) {
if (value == null || Number.isNaN(value)) return "—"; if (value == null || Number.isNaN(value)) return "—";
return `${Math.round(value * 100)}%`; return `${Math.round(value * 100)}%`;
@@ -281,8 +274,6 @@ type ShipRow = {
plan: string; plan: string;
step: string; step: string;
subtask: string; subtask: string;
cargo: number;
health: number;
}; };
const shipRows = computed<ShipRow[]>(() => const shipRows = computed<ShipRow[]>(() =>
@@ -305,8 +296,6 @@ const shipRows = computed<ShipRow[]>(() =>
plan: s.activePlan ? `${titleCaseToken(s.activePlan.kind)} · ${titleCaseToken(s.activePlan.status)}` : "—", plan: s.activePlan ? `${titleCaseToken(s.activePlan.kind)} · ${titleCaseToken(s.activePlan.status)}` : "—",
step: currentStep ? `${titleCaseToken(currentStep.kind)} · ${titleCaseToken(currentStep.status)}` : "—", step: currentStep ? `${titleCaseToken(currentStep.kind)} · ${titleCaseToken(currentStep.status)}` : "—",
subtask: currentSubTask ? `${titleCaseToken(currentSubTask.kind)} ${Math.round(currentSubTask.progress * 100)}%` : "—", subtask: currentSubTask ? `${titleCaseToken(currentSubTask.kind)} ${Math.round(currentSubTask.progress * 100)}%` : "—",
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
health: Math.round(s.health),
}; };
}), }),
); );
@@ -329,16 +318,11 @@ const shipColumns = [
shipColumnHelper.accessor("plan", { header: "Plan" }), shipColumnHelper.accessor("plan", { header: "Plan" }),
shipColumnHelper.accessor("step", { header: "Current Step" }), shipColumnHelper.accessor("step", { header: "Current Step" }),
shipColumnHelper.accessor("subtask", { header: "SubTask" }), shipColumnHelper.accessor("subtask", { header: "SubTask" }),
shipColumnHelper.accessor("cargo", {
header: "Cargo",
cell: (info) => formatCargoAmount(info.getValue()),
}),
shipColumnHelper.accessor("health", { header: "HP" }),
]; ];
const shipFilter = ref(""); const shipFilter = ref("");
const shipSorting = ref<SortingState>([]); const shipSorting = ref<SortingState>([]);
const shipOrder = useColumnOrder(["label", "class", "factionColor", "faction", "system", "state", "assignment", "behavior", "orders", "plan", "step", "subtask", "cargo", "health"]); const shipOrder = useColumnOrder(["label", "class", "factionColor", "faction", "system", "state", "assignment", "behavior", "orders", "plan", "step", "subtask"]);
const shipTable = useVueTable({ const shipTable = useVueTable({
get data() { return shipRows.value; }, get data() { return shipRows.value; },
@@ -373,7 +357,6 @@ type StationRow = {
docked: string; docked: string;
orders: number; orders: number;
orderDetails: MarketOrderSnapshot[]; orderDetails: MarketOrderSnapshot[];
cargo: number;
modules: number; modules: number;
}; };
@@ -400,7 +383,6 @@ const stationRows = computed<StationRow[]>(() =>
const order = marketOrderMap.value.get(id); const order = marketOrderMap.value.get(id);
return order ? [order] : []; return order ? [order] : [];
}), }),
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
modules: s.installedModules.length, modules: s.installedModules.length,
})), })),
); );
@@ -421,16 +403,12 @@ const stationColumns = [
stationColumnHelper.accessor("workforce", { header: "Workforce" }), stationColumnHelper.accessor("workforce", { header: "Workforce" }),
stationColumnHelper.accessor("docked", { header: "Docked" }), stationColumnHelper.accessor("docked", { header: "Docked" }),
stationColumnHelper.accessor("orders", { header: "Orders" }), stationColumnHelper.accessor("orders", { header: "Orders" }),
stationColumnHelper.accessor("cargo", {
header: "Cargo",
cell: (info) => formatCargoAmount(info.getValue()),
}),
stationColumnHelper.accessor("modules", { header: "Modules" }), stationColumnHelper.accessor("modules", { header: "Modules" }),
]; ];
const stationFilter = ref(""); const stationFilter = ref("");
const stationSorting = ref<SortingState>([]); const stationSorting = ref<SortingState>([]);
const stationOrder = useColumnOrder(["label", "category", "objective", "factionColor", "faction", "system", "process", "workforce", "docked", "orders", "cargo", "modules"]); const stationOrder = useColumnOrder(["label", "category", "objective", "factionColor", "faction", "system", "process", "workforce", "docked", "orders", "modules"]);
const stationTable = useVueTable({ const stationTable = useVueTable({
get data() { return stationRows.value; }, get data() { return stationRows.value; },

View File

@@ -251,7 +251,7 @@ const behaviorForm = reactive({
areaSystemId: "", areaSystemId: "",
targetEntityId: "", targetEntityId: "",
itemId: "", itemId: "",
preferredNodeId: "", preferredAnchorId: "",
preferredConstructionSiteId: "", preferredConstructionSiteId: "",
preferredModuleId: "", preferredModuleId: "",
waitSeconds: 3, waitSeconds: 3,
@@ -268,7 +268,7 @@ const orderForm = reactive({
targetEntityId: "", targetEntityId: "",
targetSystemId: "", targetSystemId: "",
itemId: "", itemId: "",
nodeId: "", anchorId: "",
constructionSiteId: "", constructionSiteId: "",
moduleId: "", moduleId: "",
waitSeconds: 3, waitSeconds: 3,
@@ -344,7 +344,7 @@ watch(selectedShip, (ship) => {
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId; behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId;
behaviorForm.targetEntityId = ship.defaultBehavior.targetEntityId ?? ""; behaviorForm.targetEntityId = ship.defaultBehavior.targetEntityId ?? "";
behaviorForm.itemId = ship.defaultBehavior.itemId ?? ""; behaviorForm.itemId = ship.defaultBehavior.itemId ?? "";
behaviorForm.preferredNodeId = ship.defaultBehavior.preferredNodeId ?? ""; behaviorForm.preferredAnchorId = ship.defaultBehavior.preferredAnchorId ?? "";
behaviorForm.preferredConstructionSiteId = ship.defaultBehavior.preferredConstructionSiteId ?? ""; behaviorForm.preferredConstructionSiteId = ship.defaultBehavior.preferredConstructionSiteId ?? "";
behaviorForm.preferredModuleId = ship.defaultBehavior.preferredModuleId ?? ""; behaviorForm.preferredModuleId = ship.defaultBehavior.preferredModuleId ?? "";
behaviorForm.waitSeconds = ship.defaultBehavior.waitSeconds; behaviorForm.waitSeconds = ship.defaultBehavior.waitSeconds;
@@ -484,7 +484,7 @@ async function submitDirective() {
sourceStationId: null, sourceStationId: null,
destinationStationId: null, destinationStationId: null,
itemId: directiveForm.itemId || null, itemId: directiveForm.itemId || null,
preferredNodeId: null, preferredAnchorId: null,
preferredConstructionSiteId: null, preferredConstructionSiteId: null,
preferredModuleId: null, preferredModuleId: null,
priority: directiveForm.priority, priority: directiveForm.priority,
@@ -612,7 +612,7 @@ async function submitDirectBehavior() {
areaSystemId: behaviorForm.areaSystemId || null, areaSystemId: behaviorForm.areaSystemId || null,
targetEntityId: behaviorForm.targetEntityId || null, targetEntityId: behaviorForm.targetEntityId || null,
itemId: behaviorForm.itemId || null, itemId: behaviorForm.itemId || null,
preferredNodeId: behaviorForm.preferredNodeId || null, preferredAnchorId: behaviorForm.preferredAnchorId || null,
preferredConstructionSiteId: behaviorForm.preferredConstructionSiteId || null, preferredConstructionSiteId: behaviorForm.preferredConstructionSiteId || null,
preferredModuleId: behaviorForm.preferredModuleId || null, preferredModuleId: behaviorForm.preferredModuleId || null,
targetPosition: null, targetPosition: null,
@@ -646,7 +646,7 @@ async function submitDirectOrder() {
sourceStationId: null, sourceStationId: null,
destinationStationId: null, destinationStationId: null,
itemId: orderForm.itemId || null, itemId: orderForm.itemId || null,
nodeId: orderForm.nodeId || null, anchorId: orderForm.anchorId || null,
constructionSiteId: orderForm.constructionSiteId || null, constructionSiteId: orderForm.constructionSiteId || null,
moduleId: orderForm.moduleId || null, moduleId: orderForm.moduleId || null,
waitSeconds: orderForm.waitSeconds, waitSeconds: orderForm.waitSeconds,
@@ -706,7 +706,7 @@ async function submitDirectOrder() {
<label><span>Area System</span><input v-model="behaviorForm.areaSystemId" type="text"></label> <label><span>Area System</span><input v-model="behaviorForm.areaSystemId" type="text"></label>
<label><span>Target Entity</span><input v-model="behaviorForm.targetEntityId" type="text"></label> <label><span>Target Entity</span><input v-model="behaviorForm.targetEntityId" type="text"></label>
<label><span>Item</span><input v-model="behaviorForm.itemId" type="text"></label> <label><span>Item</span><input v-model="behaviorForm.itemId" type="text"></label>
<label><span>Preferred Node</span><input v-model="behaviorForm.preferredNodeId" type="text"></label> <label><span>Preferred Anchor</span><input v-model="behaviorForm.preferredAnchorId" type="text"></label>
<label><span>Construction Site</span><input v-model="behaviorForm.preferredConstructionSiteId" type="text"></label> <label><span>Construction Site</span><input v-model="behaviorForm.preferredConstructionSiteId" type="text"></label>
<label><span>Module</span><input v-model="behaviorForm.preferredModuleId" type="text"></label> <label><span>Module</span><input v-model="behaviorForm.preferredModuleId" type="text"></label>
<label><span>Radius</span><input v-model.number="behaviorForm.radius" type="number" min="0" step="1"></label> <label><span>Radius</span><input v-model.number="behaviorForm.radius" type="number" min="0" step="1"></label>
@@ -723,7 +723,7 @@ async function submitDirectOrder() {
<label><span>Target System</span><input v-model="orderForm.targetSystemId" type="text"></label> <label><span>Target System</span><input v-model="orderForm.targetSystemId" type="text"></label>
<label><span>Target Entity</span><input v-model="orderForm.targetEntityId" type="text"></label> <label><span>Target Entity</span><input v-model="orderForm.targetEntityId" type="text"></label>
<label><span>Item</span><input v-model="orderForm.itemId" type="text"></label> <label><span>Item</span><input v-model="orderForm.itemId" type="text"></label>
<label><span>Node</span><input v-model="orderForm.nodeId" type="text"></label> <label><span>Anchor</span><input v-model="orderForm.anchorId" type="text"></label>
<label><span>Construction Site</span><input v-model="orderForm.constructionSiteId" type="text"></label> <label><span>Construction Site</span><input v-model="orderForm.constructionSiteId" type="text"></label>
<label><span>Module</span><input v-model="orderForm.moduleId" type="text"></label> <label><span>Module</span><input v-model="orderForm.moduleId" type="text"></label>
<label><span>Priority</span><input v-model.number="orderForm.priority" type="number" min="0" step="1"></label> <label><span>Priority</span><input v-model.number="orderForm.priority" type="number" min="0" step="1"></label>

View File

@@ -7,10 +7,13 @@ export type {
OrbitalSimulationSnapshot, OrbitalSimulationSnapshot,
} from "./contractsWorld"; } from "./contractsWorld";
export type { export type {
AnchorSnapshot,
AnchorDelta,
StarSnapshot, StarSnapshot,
MoonSnapshot, MoonSnapshot,
SystemSnapshot, SystemSnapshot,
PlanetSnapshot, PlanetSnapshot,
ResourceDepositSnapshot,
ResourceNodeSnapshot, ResourceNodeSnapshot,
ResourceNodeDelta, ResourceNodeDelta,
CelestialSnapshot, CelestialSnapshot,

View File

@@ -46,26 +46,50 @@ export interface PlanetSnapshot {
hasRing: boolean; hasRing: boolean;
} }
export interface ResourceDepositSnapshot {
id: string;
nodeId: string;
anchorId: string;
localPosition: Vector3Dto;
oreRemaining: number;
maxOre: number;
}
export interface ResourceNodeSnapshot { export interface ResourceNodeSnapshot {
id: string; id: string;
anchorId: string;
systemId: string; systemId: string;
localPosition: Vector3Dto; localPosition: Vector3Dto;
celestialId?: string | null; localSpaceRadius: number;
sourceKind: string; sourceKind: string;
oreRemaining: number; oreRemaining: number;
maxOre: number; maxOre: number;
itemId: string; itemId: string;
deposits: ResourceDepositSnapshot[];
} }
export interface ResourceNodeDelta extends ResourceNodeSnapshot {} export interface ResourceNodeDelta extends ResourceNodeSnapshot {}
export interface AnchorSnapshot {
id: string;
systemId: string;
kind: string;
systemPosition: Vector3Dto;
localSpaceRadius: number;
parentAnchorId?: string | null;
occupyingStructureId?: string | null;
orbitReferenceId?: string | null;
}
export interface AnchorDelta extends AnchorSnapshot {}
export interface CelestialSnapshot { export interface CelestialSnapshot {
id: string; id: string;
systemId: string; systemId: string;
kind: string; kind: string;
orbitalAnchor: Vector3Dto; orbitalAnchor: Vector3Dto;
localSpaceRadius: number; localSpaceRadius: number;
parentNodeId?: string | null; parentAnchorId?: string | null;
occupyingStructureId?: string | null; occupyingStructureId?: string | null;
orbitReferenceId?: string | null; orbitReferenceId?: string | null;
} }

View File

@@ -93,7 +93,7 @@ export interface TerritoryClaimSnapshot {
sourceClaimId?: string | null; sourceClaimId?: string | null;
factionId: string; factionId: string;
systemId: string; systemId: string;
celestialId?: string | null; anchorId: string;
status: string; status: string;
claimKind: string; claimKind: string;
claimStrength: number; claimStrength: number;

View File

@@ -27,8 +27,8 @@ export interface StationSnapshot {
category: string; category: string;
objective: string; objective: string;
systemId: string; systemId: string;
anchorId?: string | null;
localPosition: Vector3Dto; localPosition: Vector3Dto;
celestialId?: string | null;
color: string; color: string;
dockedShips: number; dockedShips: number;
dockedShipIds: string[]; dockedShipIds: string[];
@@ -53,7 +53,7 @@ export interface ClaimSnapshot {
id: string; id: string;
factionId: string; factionId: string;
systemId: string; systemId: string;
celestialId: string; anchorId: string;
state: string; state: string;
health: number; health: number;
placedAtUtc: string; placedAtUtc: string;
@@ -66,7 +66,7 @@ export interface ConstructionSiteSnapshot {
id: string; id: string;
factionId: string; factionId: string;
systemId: string; systemId: string;
celestialId: string; anchorId: string;
targetKind: string; targetKind: string;
targetDefinitionId: string; targetDefinitionId: string;
blueprintId?: string | null; blueprintId?: string | null;

View File

@@ -207,7 +207,7 @@ export interface PlayerDirectiveSnapshot {
useOrders: boolean; useOrders: boolean;
stagingOrderKind?: string | null; stagingOrderKind?: string | null;
itemId?: string | null; itemId?: string | null;
preferredNodeId?: string | null; preferredAnchorId?: string | null;
preferredConstructionSiteId?: string | null; preferredConstructionSiteId?: string | null;
preferredModuleId?: string | null; preferredModuleId?: string | null;
priority: number; priority: number;

View File

@@ -24,7 +24,7 @@ export interface ShipOrderSnapshot {
sourceStationId?: string | null; sourceStationId?: string | null;
destinationStationId?: string | null; destinationStationId?: string | null;
itemId?: string | null; itemId?: string | null;
nodeId?: string | null; anchorId?: string | null;
constructionSiteId?: string | null; constructionSiteId?: string | null;
moduleId?: string | null; moduleId?: string | null;
waitSeconds: number; waitSeconds: number;
@@ -43,7 +43,7 @@ export interface ShipOrderTemplateSnapshot {
sourceStationId?: string | null; sourceStationId?: string | null;
destinationStationId?: string | null; destinationStationId?: string | null;
itemId?: string | null; itemId?: string | null;
nodeId?: string | null; anchorId?: string | null;
constructionSiteId?: string | null; constructionSiteId?: string | null;
moduleId?: string | null; moduleId?: string | null;
waitSeconds: number; waitSeconds: number;
@@ -59,7 +59,7 @@ export interface DefaultBehaviorSnapshot {
areaSystemId?: string | null; areaSystemId?: string | null;
targetEntityId?: string | null; targetEntityId?: string | null;
itemId?: string | null; itemId?: string | null;
preferredNodeId?: string | null; preferredAnchorId?: string | null;
preferredConstructionSiteId?: string | null; preferredConstructionSiteId?: string | null;
preferredModuleId?: string | null; preferredModuleId?: string | null;
targetPosition?: Vector3Dto | null; targetPosition?: Vector3Dto | null;
@@ -100,7 +100,9 @@ export interface ShipSubTaskSnapshot {
summary: string; summary: string;
targetEntityId?: string | null; targetEntityId?: string | null;
targetSystemId?: string | null; targetSystemId?: string | null;
targetNodeId?: string | null; targetAnchorId?: string | null;
targetResourceNodeId?: string | null;
targetResourceDepositId?: string | null;
targetPosition?: Vector3Dto | null; targetPosition?: Vector3Dto | null;
itemId?: string | null; itemId?: string | null;
moduleId?: string | null; moduleId?: string | null;
@@ -143,6 +145,7 @@ export interface ShipSnapshot {
purpose: string; purpose: string;
type: string; type: string;
systemId: string; systemId: string;
anchorId?: string | null;
localPosition: Vector3Dto; localPosition: Vector3Dto;
localVelocity: Vector3Dto; localVelocity: Vector3Dto;
targetLocalPosition: Vector3Dto; targetLocalPosition: Vector3Dto;
@@ -159,11 +162,11 @@ export interface ShipSnapshot {
controlReason?: string | null; controlReason?: string | null;
lastReplanReason?: string | null; lastReplanReason?: string | null;
lastAccessFailureReason?: string | null; lastAccessFailureReason?: string | null;
celestialId?: string | null;
dockedStationId?: string | null; dockedStationId?: string | null;
commanderId?: string | null; commanderId?: string | null;
policySetId?: string | null; policySetId?: string | null;
cargoCapacity: number; cargoCapacity: number;
cargoTypes: string[];
travelSpeed: number; travelSpeed: number;
travelSpeedUnit: string; travelSpeedUnit: string;
inventory: InventoryEntry[]; inventory: InventoryEntry[];
@@ -178,18 +181,18 @@ export interface ShipDelta extends ShipSnapshot {}
export interface ShipSpatialStateSnapshot { export interface ShipSpatialStateSnapshot {
spaceLayer: string; spaceLayer: string;
currentSystemId: string; currentSystemId: string;
currentCelestialId?: string | null; currentAnchorId?: string | null;
localPosition?: Vector3Dto | null; localPosition?: Vector3Dto | null;
systemPosition?: Vector3Dto | null; systemPosition?: Vector3Dto | null;
movementRegime: string; movementRegime: string;
destinationNodeId?: string | null; destinationAnchorId?: string | null;
transit?: ShipTransitSnapshot | null; transit?: ShipTransitSnapshot | null;
} }
export interface ShipTransitSnapshot { export interface ShipTransitSnapshot {
regime: string; regime: string;
originNodeId?: string | null; originAnchorId?: string | null;
destinationNodeId?: string | null; destinationAnchorId?: string | null;
startedAtUtc?: string | null; startedAtUtc?: string | null;
arrivalDueAtUtc?: string | null; arrivalDueAtUtc?: string | null;
progress: number; progress: number;

View File

@@ -9,6 +9,8 @@ import type {
FactionSnapshot, FactionSnapshot,
} from "./contractsFactions"; } from "./contractsFactions";
import type { import type {
AnchorDelta,
AnchorSnapshot,
CelestialDelta, CelestialDelta,
CelestialSnapshot, CelestialSnapshot,
ResourceNodeDelta, ResourceNodeDelta,
@@ -37,6 +39,7 @@ export interface WorldSnapshot {
generatedAtUtc: string; generatedAtUtc: string;
systems: SystemSnapshot[]; systems: SystemSnapshot[];
celestials: CelestialSnapshot[]; celestials: CelestialSnapshot[];
anchors: AnchorSnapshot[];
nodes: ResourceNodeSnapshot[]; nodes: ResourceNodeSnapshot[];
stations: import("./contractsInfrastructure").StationSnapshot[]; stations: import("./contractsInfrastructure").StationSnapshot[];
claims: ClaimSnapshot[]; claims: ClaimSnapshot[];
@@ -57,6 +60,7 @@ export interface WorldDelta {
requiresSnapshotRefresh: boolean; requiresSnapshotRefresh: boolean;
events: SimulationEventRecord[]; events: SimulationEventRecord[];
celestials: CelestialDelta[]; celestials: CelestialDelta[];
anchors: AnchorDelta[];
nodes: ResourceNodeDelta[]; nodes: ResourceNodeDelta[];
stations: import("./contractsInfrastructure").StationDelta[]; stations: import("./contractsInfrastructure").StationDelta[];
claims: ClaimDelta[]; claims: ClaimDelta[];
@@ -84,7 +88,7 @@ export interface SimulationEventRecord {
export interface ObserverScope { export interface ObserverScope {
scopeKind: string; scopeKind: string;
systemId?: string | null; systemId?: string | null;
celestialId?: string | null; anchorId?: string | null;
} }
export interface OrbitalSimulationSnapshot { export interface OrbitalSimulationSnapshot {

View File

@@ -44,7 +44,7 @@ export interface PlayerDirectiveCommandRequest {
sourceStationId?: string | null; sourceStationId?: string | null;
destinationStationId?: string | null; destinationStationId?: string | null;
itemId?: string | null; itemId?: string | null;
preferredNodeId?: string | null; preferredAnchorId?: string | null;
preferredConstructionSiteId?: string | null; preferredConstructionSiteId?: string | null;
preferredModuleId?: string | null; preferredModuleId?: string | null;
priority: number; priority: number;

View File

@@ -12,7 +12,7 @@ export interface ShipOrderCommandRequest {
sourceStationId?: string | null; sourceStationId?: string | null;
destinationStationId?: string | null; destinationStationId?: string | null;
itemId?: string | null; itemId?: string | null;
nodeId?: string | null; anchorId?: string | null;
constructionSiteId?: string | null; constructionSiteId?: string | null;
moduleId?: string | null; moduleId?: string | null;
waitSeconds?: number | null; waitSeconds?: number | null;
@@ -28,7 +28,7 @@ export interface ShipDefaultBehaviorCommandRequest {
areaSystemId?: string | null; areaSystemId?: string | null;
targetEntityId?: string | null; targetEntityId?: string | null;
itemId?: string | null; itemId?: string | null;
preferredNodeId?: string | null; preferredAnchorId?: string | null;
preferredConstructionSiteId?: string | null; preferredConstructionSiteId?: string | null;
preferredModuleId?: string | null; preferredModuleId?: string | null;
targetPosition?: Vector3Dto | null; targetPosition?: Vector3Dto | null;

View File

@@ -366,6 +366,74 @@ canvas {
backdrop-filter: none; backdrop-filter: none;
} }
.viewer-right-sidebar-dock {
position: absolute;
inset: 0 0 0 auto;
width: min(380px, 100vw);
padding: 0;
}
.viewer-right-sidebar {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
min-height: 0;
padding: 16px;
background:
linear-gradient(180deg, rgba(7, 14, 27, 0.9), rgba(7, 14, 27, 0.78)),
radial-gradient(circle at top right, rgba(127, 214, 255, 0.08), transparent 34%);
border-left: 1px solid rgba(132, 196, 255, 0.14);
backdrop-filter: blur(18px);
box-shadow: -18px 0 42px rgba(0, 0, 0, 0.18);
}
.viewer-right-sidebar__resize-handle {
position: absolute;
inset: 0 auto 0 -8px;
width: 16px;
cursor: ew-resize;
}
.viewer-right-sidebar__resize-handle::before {
content: "";
position: absolute;
inset: 0 6px;
background: rgba(132, 196, 255, 0.06);
transition: background 120ms ease;
}
.viewer-right-sidebar__resize-handle:hover::before,
.viewer-right-sidebar__resize-handle--active::before {
background: rgba(132, 196, 255, 0.18);
}
.viewer-right-sidebar__body {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.viewer-right-sidebar__panel.entity-inspector-panel {
height: 100%;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.viewer-right-sidebar__error {
margin-top: 0.9rem;
border-radius: 1rem;
background: rgba(255, 116, 88, 0.14);
padding: 0.85rem 0.95rem;
color: #ffd8cf;
}
.viewer-stats-overlay { .viewer-stats-overlay {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace; font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.8rem; font-size: 0.8rem;
@@ -1705,6 +1773,62 @@ canvas {
color: rgba(173, 220, 255, 0.58); color: rgba(173, 220, 255, 0.58);
} }
.entity-inspector-capacity-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.entity-inspector-capacity {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.03);
padding: 0.8rem 0.9rem;
}
.entity-inspector-capacity__header,
.entity-inspector-capacity__scale {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
}
.entity-inspector-capacity__header {
margin-bottom: 0.45rem;
}
.entity-inspector-capacity__label {
font-size: 0.74rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(173, 220, 255, 0.72);
}
.entity-inspector-capacity__value,
.entity-inspector-capacity__scale span {
font-family: var(--viewer-mono-font);
font-size: 0.76rem;
color: rgba(255, 255, 255, 0.78);
}
.entity-inspector-capacity__track {
position: relative;
flex: 1 1 auto;
min-width: 0;
height: 0.65rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.entity-inspector-capacity__fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: inherit;
background: linear-gradient(90deg, rgba(116, 196, 255, 0.5), rgba(116, 196, 255, 0.9));
}
.entity-inspector-panel__fallback { .entity-inspector-panel__fallback {
margin-top: 0.9rem; margin-top: 0.9rem;
font-size: 0.83rem; font-size: 0.83rem;
@@ -1910,6 +2034,23 @@ canvas {
padding: 14px; padding: 14px;
} }
.viewer-right-sidebar-dock {
inset: auto 20px 148px 20px;
width: auto;
max-height: 38vh;
}
.viewer-right-sidebar {
padding: 14px;
border-left: none;
border-top: 1px solid rgba(132, 196, 255, 0.14);
box-shadow: 0 -18px 42px rgba(0, 0, 0, 0.18);
}
.viewer-right-sidebar__resize-handle {
display: none;
}
.viewer-stats-overlay-dock { .viewer-stats-overlay-dock {
top: 96px; top: 96px;
left: 20px; left: 20px;

View File

@@ -6,6 +6,7 @@ export interface ViewerOrderContextMenuTarget {
selection: Selectable; selection: Selectable;
label: string; label: string;
systemId?: string | null; systemId?: string | null;
anchorId?: string | null;
itemId?: string | null; itemId?: string | null;
targetPosition?: Vector3Dto | null; targetPosition?: Vector3Dto | null;
} }

View File

@@ -18,7 +18,7 @@ interface ResolveSelectionPositionParams {
nodeVisuals: Map<string, NodeVisual>; nodeVisuals: Map<string, NodeVisual>;
planetVisuals: PlanetVisual[]; planetVisuals: PlanetVisual[];
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3; computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3; resolvePointPosition: (systemId: string, celestialId?: string | null, anchorId?: string | null) => THREE.Vector3;
} }
interface FocusOnSelectionParams extends ResolveSelectionPositionParams { interface FocusOnSelectionParams extends ResolveSelectionPositionParams {
@@ -47,7 +47,7 @@ interface SeedSystemFocusParams {
nodeVisuals: Map<string, NodeVisual>; nodeVisuals: Map<string, NodeVisual>;
planetVisuals: PlanetVisual[]; planetVisuals: PlanetVisual[];
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3; computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3; resolvePointPosition: (systemId: string, celestialId?: string | null, anchorId?: string | null) => THREE.Vector3;
} }
interface CameraFocusParams { interface CameraFocusParams {
@@ -107,6 +107,7 @@ export function applyPanFromScreenDelta(
delta: THREE.Vector2, delta: THREE.Vector2,
orbitYaw: number, orbitYaw: number,
currentDistance: number, currentDistance: number,
cameraFovDegrees: number,
povLevel: PovLevel, povLevel: PovLevel,
activeSystemId: string | undefined, activeSystemId: string | undefined,
systemAnchor: THREE.Vector3, systemAnchor: THREE.Vector3,
@@ -125,18 +126,19 @@ export function applyPanFromScreenDelta(
const forward = new THREE.Vector3(Math.cos(orbitYaw), 0, Math.sin(orbitYaw)); const forward = new THREE.Vector3(Math.cos(orbitYaw), 0, Math.sin(orbitYaw));
const right = new THREE.Vector3(-forward.z, 0, forward.x); const right = new THREE.Vector3(-forward.z, 0, forward.x);
const pan = right.multiplyScalar(-normalized.x).add(forward.multiplyScalar(-normalized.y)); const visibleHeight = 2 * Math.tan(THREE.MathUtils.degToRad(cameraFovDegrees) * 0.5) * currentDistance;
const visibleWidth = visibleHeight * (safeWidth / safeHeight);
const horizontalDistance = normalized.x * visibleWidth;
const verticalDistance = -normalized.y * visibleHeight;
const pan = right.multiplyScalar(horizontalDistance).add(forward.multiplyScalar(verticalDistance));
if (activeSystemId) { if (activeSystemId) {
const scale = povLevel === "system" const systemDisplayToKilometers = 1 / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.35, KILOMETERS_PER_AU * 6.5) systemAnchor.addScaledVector(pan, systemDisplayToKilometers);
: THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 1200, 180000);
systemAnchor.addScaledVector(pan, scale);
return; return;
} }
const galaxyScale = THREE.MathUtils.mapLinear(currentDistance, minimumDistance, maximumDistance, 1800, 22000); galaxyAnchor.add(pan);
galaxyAnchor.addScaledVector(pan, galaxyScale);
} }
export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined { export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined {
@@ -235,11 +237,11 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams)
} }
if (selection.kind === "claim") { if (selection.kind === "claim") {
const claim = world.claims.get(selection.id); const claim = world.claims.get(selection.id);
return claim ? resolvePointPosition(claim.systemId, claim.celestialId) : undefined; return claim ? resolvePointPosition(claim.systemId, null, claim.anchorId) : undefined;
} }
if (selection.kind === "construction-site") { if (selection.kind === "construction-site") {
const site = world.constructionSites.get(selection.id); const site = world.constructionSites.get(selection.id);
return site ? resolvePointPosition(site.systemId, site.celestialId) : undefined; return site ? resolvePointPosition(site.systemId, null, site.anchorId) : undefined;
} }
if (selection.kind === "planet") { if (selection.kind === "planet") {
const system = world.systems.get(selection.systemId); const system = world.systems.get(selection.systemId);

View File

@@ -30,8 +30,14 @@ export function createViewerControllers(host: any) {
claimGroup: host.systemLayer.claimGroup, claimGroup: host.systemLayer.claimGroup,
constructionSiteGroup: host.systemLayer.constructionSiteGroup, constructionSiteGroup: host.systemLayer.constructionSiteGroup,
shipGroup: host.systemLayer.shipGroup, shipGroup: host.systemLayer.shipGroup,
localNodeGroup: host.localLayer.nodeGroup,
localStationGroup: host.localLayer.stationGroup,
localClaimGroup: host.localLayer.claimGroup,
localConstructionSiteGroup: host.localLayer.constructionSiteGroup,
localShipGroup: host.localLayer.shipGroup,
galaxySelectableTargets: host.galaxyLayer.selectableTargets, galaxySelectableTargets: host.galaxyLayer.selectableTargets,
systemSelectableTargets: host.systemLayer.selectableTargets, systemSelectableTargets: host.systemLayer.selectableTargets,
localSelectableTargets: host.localLayer.selectableTargets,
systemVisuals: host.galaxyLayer.systemVisuals, systemVisuals: host.galaxyLayer.systemVisuals,
planetVisuals: host.systemLayer.planetVisuals, planetVisuals: host.systemLayer.planetVisuals,
celestialVisuals: host.systemLayer.celestialVisuals, celestialVisuals: host.systemLayer.celestialVisuals,
@@ -40,6 +46,11 @@ export function createViewerControllers(host: any) {
claimVisuals: host.systemLayer.claimVisuals, claimVisuals: host.systemLayer.claimVisuals,
constructionSiteVisuals: host.systemLayer.constructionSiteVisuals, constructionSiteVisuals: host.systemLayer.constructionSiteVisuals,
shipVisuals: host.systemLayer.shipVisuals, shipVisuals: host.systemLayer.shipVisuals,
localNodeVisuals: host.localLayer.nodeVisuals,
localStationVisuals: host.localLayer.stationVisuals,
localClaimVisuals: host.localLayer.claimVisuals,
localConstructionSiteVisuals: host.localLayer.constructionSiteVisuals,
localShipVisuals: host.localLayer.shipVisuals,
}); });
const navigationController = new ViewerNavigationController({ const navigationController = new ViewerNavigationController({
@@ -152,8 +163,9 @@ export function createViewerControllers(host: any) {
applyClaimDeltas: (claims) => sceneDataController.applyClaimDeltas(claims), applyClaimDeltas: (claims) => sceneDataController.applyClaimDeltas(claims),
applyConstructionSiteDeltas: (sites) => sceneDataController.applyConstructionSiteDeltas(sites), applyConstructionSiteDeltas: (sites) => sceneDataController.applyConstructionSiteDeltas(sites),
applyShipDeltas: (ships, tickIntervalMs) => sceneDataController.applyShipDeltas(ships, tickIntervalMs), applyShipDeltas: (ships, tickIntervalMs) => sceneDataController.applyShipDeltas(ships, tickIntervalMs),
refreshLocalLayer: () => sceneDataController.refreshLocalLayer(host.world, host.resolveFocusedAnchorId()),
refreshHistoryWindows: () => host.refreshHistoryWindows(), refreshHistoryWindows: () => host.refreshHistoryWindows(),
resolveFocusedCelestialId: () => host.resolveFocusedCelestialId(), resolveFocusedAnchorId: () => host.resolveFocusedAnchorId(),
updateSystemSummaries: () => host.updateSystemSummaries(), updateSystemSummaries: () => host.updateSystemSummaries(),
applyZoomPresentation: () => presentationController.applyZoomPresentation(), applyZoomPresentation: () => presentationController.applyZoomPresentation(),
updateNetworkPanel: () => presentationController.updateNetworkPanel(), updateNetworkPanel: () => presentationController.updateNetworkPanel(),
@@ -191,8 +203,10 @@ export function createViewerControllers(host: any) {
mouse: host.mouse, mouse: host.mouse,
galaxyCamera: host.galaxyLayer.camera, galaxyCamera: host.galaxyLayer.camera,
systemCamera: host.systemLayer.camera, systemCamera: host.systemLayer.camera,
localCamera: host.localLayer.camera,
galaxySelectableTargets: host.galaxyLayer.selectableTargets, galaxySelectableTargets: host.galaxyLayer.selectableTargets,
systemSelectableTargets: host.systemLayer.selectableTargets, systemSelectableTargets: host.systemLayer.selectableTargets,
localSelectableTargets: host.localLayer.selectableTargets,
hoverLabelEl: host.hoverLabelEl, hoverLabelEl: host.hoverLabelEl,
hoverConnectorLineEl: host.hoverConnectorLineEl, hoverConnectorLineEl: host.hoverConnectorLineEl,
marqueeEl: host.marqueeEl, marqueeEl: host.marqueeEl,
@@ -244,6 +258,9 @@ export function createViewerControllers(host: any) {
delta, delta,
host.orbitYaw, host.orbitYaw,
host.currentDistance, host.currentDistance,
host.activeSystemId
? (host.povLevel === "local" ? host.localLayer.camera.fov : host.systemLayer.camera.fov)
: host.galaxyLayer.camera.fov,
host.povLevel, host.povLevel,
host.activeSystemId, host.activeSystemId,
host.systemAnchor, host.systemAnchor,

View File

@@ -148,9 +148,11 @@ export function updateFollowCamera(params: {
if (ship.spatialState.movementRegime === "ftl-transit") { if (ship.spatialState.movementRegime === "ftl-transit") {
systemAnchor.set(0, 0, 0); systemAnchor.set(0, 0, 0);
const destinationNodeId = ship.spatialState.transit?.destinationNodeId; const destinationAnchorId = ship.spatialState.transit?.destinationAnchorId ?? ship.spatialState.destinationAnchorId;
const destinationCelestial = destinationNodeId ? world.celestials.get(destinationNodeId) : undefined; const destinationAnchor = destinationAnchorId ? world.anchors.get(destinationAnchorId) : undefined;
const destinationSystem = destinationCelestial ? world.systems.get(destinationCelestial.systemId) : undefined; const destinationSystem = destinationAnchor
? world.systems.get(destinationAnchor.systemId)
: undefined;
const originSystem = world.systems.get(ship.systemId); const originSystem = world.systems.get(ship.systemId);
if (originSystem && destinationSystem) { if (originSystem && destinationSystem) {
followCameraDesiredDirection followCameraDesiredDirection

View File

@@ -36,14 +36,17 @@ export function pickSelectableAtClientPosition(
renderer: THREE.WebGLRenderer, renderer: THREE.WebGLRenderer,
raycaster: THREE.Raycaster, raycaster: THREE.Raycaster,
mouse: THREE.Vector2, mouse: THREE.Vector2,
povLevel: PovLevel,
galaxyCamera: THREE.Camera, galaxyCamera: THREE.Camera,
galaxySelectableTargets: Map<THREE.Object3D, Selectable>, galaxySelectableTargets: Map<THREE.Object3D, Selectable>,
systemCamera: THREE.Camera, systemCamera: THREE.Camera,
systemSelectableTargets: Map<THREE.Object3D, Selectable>, systemSelectableTargets: Map<THREE.Object3D, Selectable>,
localCamera: THREE.Camera,
localSelectableTargets: Map<THREE.Object3D, Selectable>,
clientX: number, clientX: number,
clientY: number, clientY: number,
) { ) {
const hit = pickSelectableHitAtClientPosition(renderer, raycaster, mouse, galaxyCamera, galaxySelectableTargets, systemCamera, systemSelectableTargets, clientX, clientY); const hit = pickSelectableHitAtClientPosition(renderer, raycaster, mouse, povLevel, galaxyCamera, galaxySelectableTargets, systemCamera, systemSelectableTargets, localCamera, localSelectableTargets, clientX, clientY);
return hit?.selection; return hit?.selection;
} }
@@ -51,13 +54,23 @@ export function pickSelectableHitAtClientPosition(
renderer: THREE.WebGLRenderer, renderer: THREE.WebGLRenderer,
raycaster: THREE.Raycaster, raycaster: THREE.Raycaster,
mouse: THREE.Vector2, mouse: THREE.Vector2,
povLevel: PovLevel,
galaxyCamera: THREE.Camera, galaxyCamera: THREE.Camera,
galaxySelectableTargets: Map<THREE.Object3D, Selectable>, galaxySelectableTargets: Map<THREE.Object3D, Selectable>,
systemCamera: THREE.Camera, systemCamera: THREE.Camera,
systemSelectableTargets: Map<THREE.Object3D, Selectable>, systemSelectableTargets: Map<THREE.Object3D, Selectable>,
localCamera: THREE.Camera,
localSelectableTargets: Map<THREE.Object3D, Selectable>,
clientX: number, clientX: number,
clientY: number, clientY: number,
): HoverPickResult | undefined { ): HoverPickResult | undefined {
if (povLevel === "local") {
const localHit = pickOneCamera(renderer, raycaster, mouse, localCamera, localSelectableTargets, clientX, clientY);
if (localHit) {
return localHit;
}
}
// Try system camera first (higher priority when in a system) // Try system camera first (higher priority when in a system)
const systemHit = pickOneCamera(renderer, raycaster, mouse, systemCamera, systemSelectableTargets, clientX, clientY); const systemHit = pickOneCamera(renderer, raycaster, mouse, systemCamera, systemSelectableTargets, clientX, clientY);
if (systemHit) { if (systemHit) {

View File

@@ -28,8 +28,10 @@ export interface ViewerInteractionContext {
mouse: THREE.Vector2; mouse: THREE.Vector2;
galaxyCamera: THREE.PerspectiveCamera; galaxyCamera: THREE.PerspectiveCamera;
systemCamera: THREE.PerspectiveCamera; systemCamera: THREE.PerspectiveCamera;
localCamera: THREE.PerspectiveCamera;
galaxySelectableTargets: Map<THREE.Object3D, Selectable>; galaxySelectableTargets: Map<THREE.Object3D, Selectable>;
systemSelectableTargets: Map<THREE.Object3D, Selectable>; systemSelectableTargets: Map<THREE.Object3D, Selectable>;
localSelectableTargets: Map<THREE.Object3D, Selectable>;
hoverLabelEl: HTMLDivElement; hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement; hoverConnectorLineEl: SVGLineElement;
marqueeEl: HTMLDivElement; marqueeEl: HTMLDivElement;
@@ -391,10 +393,13 @@ export class ViewerInteractionController {
this.context.renderer, this.context.renderer,
this.context.raycaster, this.context.raycaster,
this.context.mouse, this.context.mouse,
this.context.getPovLevel(),
this.context.galaxyCamera, this.context.galaxyCamera,
this.context.galaxySelectableTargets, this.context.galaxySelectableTargets,
this.context.systemCamera, this.context.systemCamera,
this.context.systemSelectableTargets, this.context.systemSelectableTargets,
this.context.localCamera,
this.context.localSelectableTargets,
clientX, clientX,
clientY, clientY,
); );
@@ -405,10 +410,13 @@ export class ViewerInteractionController {
this.context.renderer, this.context.renderer,
this.context.raycaster, this.context.raycaster,
this.context.mouse, this.context.mouse,
this.context.getPovLevel(),
this.context.galaxyCamera, this.context.galaxyCamera,
this.context.galaxySelectableTargets, this.context.galaxySelectableTargets,
this.context.systemCamera, this.context.systemCamera,
this.context.systemSelectableTargets, this.context.systemSelectableTargets,
this.context.localCamera,
this.context.localSelectableTargets,
clientX, clientX,
clientY, clientY,
); );
@@ -466,6 +474,7 @@ export class ViewerInteractionController {
selection, selection,
label: node.itemId, label: node.itemId,
systemId: node.systemId, systemId: node.systemId,
anchorId: node.anchorId,
itemId: node.itemId, itemId: node.itemId,
targetPosition: node.localPosition, targetPosition: node.localPosition,
} : null; } : null;

View File

@@ -1,17 +1,50 @@
import * as THREE from "three"; import * as THREE from "three";
import type {
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
Selectable,
ShipVisual,
StructureVisual,
} from "./viewerTypes";
/** /**
* Local rendering layer. * Local rendering layer.
* Scene coordinate unit: reserved for future close-up detail. * Scene coordinate unit: reserved for future close-up detail.
* Camera far plane covers immediate surroundings. * Camera far plane covers immediate surroundings.
* Currently empty — populated when local-space objects are introduced.
*/ */
export class LocalLayer { export class LocalLayer {
readonly scene = new THREE.Scene(); readonly scene = new THREE.Scene();
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 2000); readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 2000);
readonly nodeGroup = new THREE.Group();
readonly stationGroup = new THREE.Group();
readonly claimGroup = new THREE.Group();
readonly constructionSiteGroup = new THREE.Group();
readonly shipGroup = new THREE.Group();
readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
readonly shipVisuals = new Map<string, ShipVisual>();
readonly nodeVisuals = new Map<string, NodeVisual>();
readonly stationVisuals = new Map<string, StructureVisual>();
readonly claimVisuals = new Map<string, ClaimVisual>();
readonly constructionSiteVisuals = new Map<string, ConstructionSiteVisual>();
private static readonly ORIGIN = new THREE.Vector3(0, 0, 0); private static readonly ORIGIN = new THREE.Vector3(0, 0, 0);
constructor() {
this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.8));
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.4);
keyLight.position.set(180, 220, 140);
this.scene.add(keyLight);
this.scene.add(
this.nodeGroup,
this.stationGroup,
this.claimGroup,
this.constructionSiteGroup,
this.shipGroup,
);
}
updateCamera(orbitOffset: THREE.Vector3) { updateCamera(orbitOffset: THREE.Vector3) {
this.camera.position.copy(orbitOffset); this.camera.position.copy(orbitOffset);
this.camera.lookAt(LocalLayer.ORIGIN); this.camera.lookAt(LocalLayer.ORIGIN);

View File

@@ -1,4 +1,5 @@
import * as THREE from "three"; import * as THREE from "three";
import { scaleGalaxyVector, toThreeVector } from "./viewerMath";
import { import {
determineActiveSystemId, determineActiveSystemId,
focusOnSelection, focusOnSelection,
@@ -62,6 +63,22 @@ export interface ViewerNavigationContext {
export class ViewerNavigationController { export class ViewerNavigationController {
constructor(private readonly context: ViewerNavigationContext) {} constructor(private readonly context: ViewerNavigationContext) {}
syncGalaxyAnchorToActiveSystem() {
const world = this.context.getWorld();
const activeSystemId = this.context.getActiveSystemId();
const povLevel = this.context.getPovLevel();
if (!world || !activeSystemId || povLevel === "galaxy") {
return;
}
const system = world.systems.get(activeSystemId);
if (!system) {
return;
}
this.context.galaxyAnchor.copy(scaleGalaxyVector(toThreeVector(system.galaxyPosition)));
}
focusOnSelection(selection: Selectable) { focusOnSelection(selection: Selectable) {
focusOnSelection({ focusOnSelection({
world: this.context.getWorld(), world: this.context.getWorld(),
@@ -70,7 +87,7 @@ export class ViewerNavigationController {
nodeVisuals: this.context.nodeVisuals, nodeVisuals: this.context.nodeVisuals,
planetVisuals: this.context.planetVisuals, planetVisuals: this.context.planetVisuals,
computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds), computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds),
resolvePointPosition: (systemId, celestialId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, celestialId), resolvePointPosition: (systemId, celestialId, anchorId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, celestialId, anchorId),
activeSystemId: this.context.getActiveSystemId(), activeSystemId: this.context.getActiveSystemId(),
galaxyAnchor: this.context.galaxyAnchor, galaxyAnchor: this.context.galaxyAnchor,
systemAnchor: this.context.systemAnchor, systemAnchor: this.context.systemAnchor,
@@ -85,7 +102,7 @@ export class ViewerNavigationController {
nodeVisuals: this.context.nodeVisuals, nodeVisuals: this.context.nodeVisuals,
planetVisuals: this.context.planetVisuals, planetVisuals: this.context.planetVisuals,
computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds), computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds),
resolvePointPosition: (systemId, celestialId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, celestialId), resolvePointPosition: (systemId, celestialId, anchorId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, celestialId, anchorId),
}); });
} }
@@ -171,7 +188,7 @@ export class ViewerNavigationController {
nodeVisuals: this.context.nodeVisuals, nodeVisuals: this.context.nodeVisuals,
planetVisuals: this.context.planetVisuals, planetVisuals: this.context.planetVisuals,
computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds), computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds),
resolvePointPosition: (systemIdValue, celestialId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemIdValue, celestialId), resolvePointPosition: (systemIdValue, celestialId, anchorId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemIdValue, celestialId, anchorId),
}); });
} }

View File

@@ -52,7 +52,7 @@ const moduleProductionById = new Map<string, {
const itemTransportById = new Map<string, string>( const itemTransportById = new Map<string, string>(
(itemsData as { id: string; transport: string }[]).map((item) => [item.id, item.transport]), (itemsData as { id: string; transport: string }[]).map((item) => [item.id, item.transport]),
); );
import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection"; import { describeAnchorPathWithinSystem, describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
import type { import type {
CameraMode, CameraMode,
NodeVisual, NodeVisual,
@@ -461,7 +461,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
title: `${celestial.kind} celestial`, title: `${celestial.kind} celestial`,
bodyHtml: ` bodyHtml: `
<p>${celestial.systemId}</p> <p>${celestial.systemId}</p>
<p>Parent ${celestial.parentNodeId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "none"}</p> <p>Parent ${celestial.parentAnchorId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "none"}</p>
<p>Occupying structure ${celestial.occupyingStructureId ?? "none"}</p> <p>Occupying structure ${celestial.occupyingStructureId ?? "none"}</p>
<p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} km</p> <p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} km</p>
`, `,
@@ -477,7 +477,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
title: `Claim ${claim.id}`, title: `Claim ${claim.id}`,
bodyHtml: ` bodyHtml: `
<p>${claim.systemId}</p> <p>${claim.systemId}</p>
<p>Celestial ${claim.celestialId}</p> <p>Anchor ${describeAnchorPathWithinSystem(world, claim.systemId, claim.anchorId) ?? claim.anchorId}</p>
<p>State ${claim.state}<br>Health ${claim.health.toFixed(0)}</p> <p>State ${claim.state}<br>Health ${claim.health.toFixed(0)}</p>
<p>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p> <p>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p>
`, `,
@@ -494,7 +494,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
title: `Construction ${site.id}`, title: `Construction ${site.id}`,
bodyHtml: ` bodyHtml: `
<p>${site.systemId}</p> <p>${site.systemId}</p>
<p>Celestial ${site.celestialId}</p> <p>Anchor ${describeAnchorPathWithinSystem(world, site.systemId, site.anchorId) ?? site.anchorId}</p>
<p>${site.targetKind} ${site.targetDefinitionId}</p> <p>${site.targetKind} ${site.targetDefinitionId}</p>
<p>State ${site.state}<br>Progress ${(site.progress * 100).toFixed(0)}%</p> <p>State ${site.state}<br>Progress ${(site.progress * 100).toFixed(0)}%</p>
<p>Orders ${orderCount}<br>Assigned constructors ${site.assignedConstructorShipIds.length}</p> <p>Orders ${orderCount}<br>Assigned constructors ${site.assignedConstructorShipIds.length}</p>
@@ -608,24 +608,33 @@ export function describeSelectionParent(
return "unknown"; return "unknown";
} }
return station.celestialId return station.anchorId
? describeCelestialPathWithinSystem(world, station.systemId, station.celestialId) ?? `${station.systemId} network` ? describeAnchorPathWithinSystem(world, station.systemId, station.anchorId) ?? `${station.systemId} network`
: "unknown"; : "unknown";
} }
if (selection.kind === "node") { if (selection.kind === "node") {
const node = world.nodes.get(selection.id); const node = world.nodes.get(selection.id);
const visual = node ? nodeVisuals.get(selection.id) : undefined; return node
return describeOrbitalParent(world, node?.systemId, visual?.anchor); ? describeAnchorPathWithinSystem(world, node.systemId, node.anchorId) ?? node.anchorId
: "unknown";
} }
if (selection.kind === "celestial") { if (selection.kind === "celestial") {
const celestial = world.celestials.get(selection.id); const celestial = world.celestials.get(selection.id);
return celestial?.parentNodeId ?? `${celestial?.systemId ?? "unknown"} network`; return celestial
? describeCelestialPathWithinSystem(world, celestial.systemId, celestial.id) ?? `${celestial.systemId} network`
: "unknown";
} }
if (selection.kind === "claim") { if (selection.kind === "claim") {
return world.claims.get(selection.id)?.celestialId ?? "unknown"; const claim = world.claims.get(selection.id);
return claim
? describeAnchorPathWithinSystem(world, claim.systemId, claim.anchorId) ?? claim.anchorId
: "unknown";
} }
if (selection.kind === "construction-site") { if (selection.kind === "construction-site") {
return world.constructionSites.get(selection.id)?.celestialId ?? "unknown"; const site = world.constructionSites.get(selection.id);
return site
? describeAnchorPathWithinSystem(world, site.systemId, site.anchorId) ?? site.anchorId
: "unknown";
} }
return "unknown"; return "unknown";

View File

@@ -14,6 +14,16 @@ import {
syncShips as syncShipScene, syncShips as syncShipScene,
syncStations as syncStationScene, syncStations as syncStationScene,
} from "./viewerSceneSync"; } from "./viewerSceneSync";
import {
createClaimMesh,
createConstructionSiteMesh,
createNodeMesh,
createResourceDepositMesh,
createShipMesh,
createShipTacticalIcon,
createStationMesh,
createTacticalIcon,
} from "./viewerSceneFactory";
import { import {
deriveNodeOrbital, deriveNodeOrbital,
deriveOrbitalFromLocalPosition, deriveOrbitalFromLocalPosition,
@@ -43,7 +53,7 @@ import type {
SystemSnapshot, SystemSnapshot,
} from "./contracts"; } from "./contracts";
import type { OrbitalAnchor, Selectable } from "./viewerTypes"; import type { OrbitalAnchor, Selectable } from "./viewerTypes";
import { rawObject } from "./viewerScenePrimitives"; import { rawObject, registerSelectableTarget } from "./viewerScenePrimitives";
export interface ViewerSceneDataContext { export interface ViewerSceneDataContext {
documentRef: Document; documentRef: Document;
@@ -61,8 +71,14 @@ export interface ViewerSceneDataContext {
claimGroup: THREE.Group; claimGroup: THREE.Group;
constructionSiteGroup: THREE.Group; constructionSiteGroup: THREE.Group;
shipGroup: THREE.Group; shipGroup: THREE.Group;
localNodeGroup: THREE.Group;
localStationGroup: THREE.Group;
localClaimGroup: THREE.Group;
localConstructionSiteGroup: THREE.Group;
localShipGroup: THREE.Group;
galaxySelectableTargets: Map<THREE.Object3D, Selectable>; galaxySelectableTargets: Map<THREE.Object3D, Selectable>;
systemSelectableTargets: Map<THREE.Object3D, Selectable>; systemSelectableTargets: Map<THREE.Object3D, Selectable>;
localSelectableTargets: Map<THREE.Object3D, Selectable>;
systemVisuals: Map<any, any>; systemVisuals: Map<any, any>;
planetVisuals: any[]; planetVisuals: any[];
celestialVisuals: Map<any, any>; celestialVisuals: Map<any, any>;
@@ -71,6 +87,11 @@ export interface ViewerSceneDataContext {
claimVisuals: Map<any, any>; claimVisuals: Map<any, any>;
constructionSiteVisuals: Map<any, any>; constructionSiteVisuals: Map<any, any>;
shipVisuals: Map<any, any>; shipVisuals: Map<any, any>;
localNodeVisuals: Map<any, any>;
localStationVisuals: Map<any, any>;
localClaimVisuals: Map<any, any>;
localConstructionSiteVisuals: Map<any, any>;
localShipVisuals: Map<any, any>;
} }
export class ViewerSceneDataController { export class ViewerSceneDataController {
@@ -136,6 +157,162 @@ export class ViewerSceneDataController {
applyShipDeltaUpdates(this.createSceneSyncContext(), ships, tickIntervalMs, this.context.getActiveSystemId()); applyShipDeltaUpdates(this.createSceneSyncContext(), ships, tickIntervalMs, this.context.getActiveSystemId());
} }
refreshLocalLayer(world: any, focusedAnchorId?: string) {
this.context.localNodeGroup.clear();
this.context.localStationGroup.clear();
this.context.localClaimGroup.clear();
this.context.localConstructionSiteGroup.clear();
this.context.localShipGroup.clear();
this.context.localSelectableTargets.clear();
this.context.localNodeVisuals.clear();
this.context.localStationVisuals.clear();
this.context.localClaimVisuals.clear();
this.context.localConstructionSiteVisuals.clear();
this.context.localShipVisuals.clear();
if (!world || !focusedAnchorId) {
return;
}
const activeSystemId = this.context.getActiveSystemId();
const inFocusedSystem = (systemId: string) => !activeSystemId || systemId === activeSystemId;
for (const node of world.nodes.values()) {
if (node.anchorId !== focusedAnchorId || !inFocusedSystem(node.systemId)) {
continue;
}
const mesh = createNodeMesh(node);
const icon = createTacticalIcon(this.context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 100);
const localPosition = new THREE.Vector3(0, 0, 0);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
this.context.localNodeVisuals.set(node.id, {
systemId: node.systemId,
anchorId: node.anchorId,
mesh,
icon,
sourceKind: node.sourceKind,
anchor: { kind: "star" as const },
localPosition,
orbitRadius: 0,
orbitPhase: 0,
orbitInclination: 0,
});
this.context.localNodeGroup.add(rawObject(mesh), rawObject(icon));
for (const deposit of node.deposits) {
const depositMesh = createResourceDepositMesh(deposit, node);
this.context.localNodeGroup.add(rawObject(depositMesh));
}
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "node", id: node.id });
registerSelectableTarget(this.context.localSelectableTargets, icon, { kind: "node", id: node.id });
}
for (const station of world.stations.values()) {
if (station.anchorId !== focusedAnchorId || !inFocusedSystem(station.systemId)) {
continue;
}
const mesh = createStationMesh(station);
const icon = createTacticalIcon(this.context.documentRef, station.color, 130);
const localPosition = new THREE.Vector3(station.localPosition.x, station.localPosition.y, station.localPosition.z);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
this.context.localStationVisuals.set(station.id, {
id: station.id,
systemId: station.systemId,
anchorId: station.anchorId,
mesh,
icon,
anchor: { kind: "star" as const },
orbitRadius: 0,
orbitPhase: 0,
orbitInclination: 0,
localPosition,
});
this.context.localStationGroup.add(rawObject(mesh), rawObject(icon));
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "station", id: station.id });
registerSelectableTarget(this.context.localSelectableTargets, icon, { kind: "station", id: station.id });
}
for (const claim of world.claims.values()) {
if (claim.anchorId !== focusedAnchorId || !inFocusedSystem(claim.systemId)) {
continue;
}
const mesh = createClaimMesh(claim);
const icon = createTacticalIcon(this.context.documentRef, "#ff5b5b", 90);
const localPosition = new THREE.Vector3(0, 0, 0);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
this.context.localClaimVisuals.set(claim.id, {
id: claim.id,
anchorId: claim.anchorId,
systemId: claim.systemId,
mesh,
icon,
localPosition,
});
this.context.localClaimGroup.add(rawObject(mesh), rawObject(icon));
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "claim", id: claim.id });
registerSelectableTarget(this.context.localSelectableTargets, icon, { kind: "claim", id: claim.id });
}
for (const site of world.constructionSites.values()) {
if (site.anchorId !== focusedAnchorId || !inFocusedSystem(site.systemId)) {
continue;
}
const mesh = createConstructionSiteMesh(site);
const icon = createTacticalIcon(this.context.documentRef, "#9df29c", 90);
const localPosition = new THREE.Vector3(0, 0, 0);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
this.context.localConstructionSiteVisuals.set(site.id, {
id: site.id,
anchorId: site.anchorId,
systemId: site.systemId,
mesh,
icon,
localPosition,
});
this.context.localConstructionSiteGroup.add(rawObject(mesh), rawObject(icon));
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "construction-site", id: site.id });
registerSelectableTarget(this.context.localSelectableTargets, icon, { kind: "construction-site", id: site.id });
}
for (const ship of world.ships.values()) {
const shipAnchorId = ship.spatialState.currentAnchorId ?? ship.anchorId;
if (shipAnchorId !== focusedAnchorId || !inFocusedSystem(ship.systemId) || ship.spatialState.spaceLayer !== "local-space") {
continue;
}
const shipColor = shipPresentationColor(ship);
const mesh = createShipMesh(ship, shipSize(ship), shipLength(ship), shipColor);
const icon = createShipTacticalIcon(this.context.documentRef, shipColor, 78);
const localPosition = new THREE.Vector3(ship.localPosition.x, ship.localPosition.y, ship.localPosition.z);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
icon.setColor(shipColor);
this.context.localShipVisuals.set(ship.id, {
systemId: ship.systemId,
anchorId: shipAnchorId,
mesh,
icon,
iconBaseScale: 78,
startPosition: localPosition.clone(),
authoritativePosition: localPosition.clone(),
targetPosition: new THREE.Vector3(ship.targetLocalPosition.x, ship.targetLocalPosition.y, ship.targetLocalPosition.z),
velocity: new THREE.Vector3(ship.localVelocity.x, ship.localVelocity.y, ship.localVelocity.z),
receivedAtMs: performance.now(),
blendDurationMs: Math.max(world.tickIntervalMs ?? 80, 80),
});
this.context.localShipGroup.add(rawObject(mesh), rawObject(icon));
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "ship", id: ship.id });
registerSelectableTarget(this.context.localSelectableTargets, icon, { kind: "ship", id: ship.id });
}
}
/** /**
* Called when the active system changes. Swaps which system's root is in systemScene * Called when the active system changes. Swaps which system's root is in systemScene
* and updates visibility of all system-filtered objects. * and updates visibility of all system-filtered objects.
@@ -202,6 +379,7 @@ export class ViewerSceneDataController {
createWorldPresentationContext(overrides: { createWorldPresentationContext(overrides: {
world: any; world: any;
activeSystemId?: string; activeSystemId?: string;
focusedAnchorId?: string;
cameraMode: any; cameraMode: any;
povLevel: any; povLevel: any;
orbitYaw: number; orbitYaw: number;
@@ -215,17 +393,23 @@ export class ViewerSceneDataController {
worldTimeSyncMs: this.context.getWorldTimeSyncMs(), worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
worldSeed: this.context.getWorldSeed(), worldSeed: this.context.getWorldSeed(),
activeSystemId: overrides.activeSystemId, activeSystemId: overrides.activeSystemId,
focusedAnchorId: overrides.focusedAnchorId,
cameraMode: overrides.cameraMode, cameraMode: overrides.cameraMode,
povLevel: overrides.povLevel, povLevel: overrides.povLevel,
orbitYaw: overrides.orbitYaw, orbitYaw: overrides.orbitYaw,
camera: overrides.systemCamera, camera: overrides.systemCamera,
systemAnchor: overrides.systemAnchor, systemAnchor: overrides.systemAnchor,
shipVisuals: this.context.shipVisuals, shipVisuals: this.context.shipVisuals,
localShipVisuals: this.context.localShipVisuals,
nodeVisuals: this.context.nodeVisuals, nodeVisuals: this.context.nodeVisuals,
localNodeVisuals: this.context.localNodeVisuals,
celestialVisuals: this.context.celestialVisuals, celestialVisuals: this.context.celestialVisuals,
stationVisuals: this.context.stationVisuals, stationVisuals: this.context.stationVisuals,
localStationVisuals: this.context.localStationVisuals,
claimVisuals: this.context.claimVisuals, claimVisuals: this.context.claimVisuals,
localClaimVisuals: this.context.localClaimVisuals,
constructionSiteVisuals: this.context.constructionSiteVisuals, constructionSiteVisuals: this.context.constructionSiteVisuals,
localConstructionSiteVisuals: this.context.localConstructionSiteVisuals,
systemVisuals: this.context.systemVisuals, systemVisuals: this.context.systemVisuals,
systemSummaryVisuals: new Map(), systemSummaryVisuals: new Map(),
toDisplayLocalPosition: overrides.toDisplayLocalPosition, toDisplayLocalPosition: overrides.toDisplayLocalPosition,
@@ -248,8 +432,14 @@ export class ViewerSceneDataController {
claimGroup: this.context.claimGroup, claimGroup: this.context.claimGroup,
constructionSiteGroup: this.context.constructionSiteGroup, constructionSiteGroup: this.context.constructionSiteGroup,
shipGroup: this.context.shipGroup, shipGroup: this.context.shipGroup,
localNodeGroup: this.context.localNodeGroup,
localStationGroup: this.context.localStationGroup,
localClaimGroup: this.context.localClaimGroup,
localConstructionSiteGroup: this.context.localConstructionSiteGroup,
localShipGroup: this.context.localShipGroup,
galaxySelectableTargets: this.context.galaxySelectableTargets, galaxySelectableTargets: this.context.galaxySelectableTargets,
systemSelectableTargets: this.context.systemSelectableTargets, systemSelectableTargets: this.context.systemSelectableTargets,
localSelectableTargets: this.context.localSelectableTargets,
systemVisuals: this.context.systemVisuals, systemVisuals: this.context.systemVisuals,
planetVisuals: this.context.planetVisuals, planetVisuals: this.context.planetVisuals,
celestialVisuals: this.context.celestialVisuals, celestialVisuals: this.context.celestialVisuals,
@@ -258,12 +448,17 @@ export class ViewerSceneDataController {
claimVisuals: this.context.claimVisuals, claimVisuals: this.context.claimVisuals,
constructionSiteVisuals: this.context.constructionSiteVisuals, constructionSiteVisuals: this.context.constructionSiteVisuals,
shipVisuals: this.context.shipVisuals, shipVisuals: this.context.shipVisuals,
localNodeVisuals: this.context.localNodeVisuals,
localStationVisuals: this.context.localStationVisuals,
localClaimVisuals: this.context.localClaimVisuals,
localConstructionSiteVisuals: this.context.localConstructionSiteVisuals,
localShipVisuals: this.context.localShipVisuals,
shipSize, shipSize,
shipLength, shipLength,
shipPresentationColor, shipPresentationColor,
celestialColor, celestialColor,
createCirclePoints, createCirclePoints,
resolvePointPosition: (systemId: string, celestialId?: string | null) => resolvePointPosition(this.context.getWorldPresentationContext(), systemId, celestialId), resolvePointPosition: (systemId: string, celestialId?: string | null, anchorId?: string | null) => resolvePointPosition(this.context.getWorldPresentationContext(), systemId, celestialId, anchorId),
resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => resolveOrbitalAnchor(this.context.getWorldPresentationContext(), systemId, localPosition), resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => resolveOrbitalAnchor(this.context.getWorldPresentationContext(), systemId, localPosition),
deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: OrbitalAnchor) => deriveNodeOrbital(this.context.getWorldPresentationContext(), node, anchor), deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: OrbitalAnchor) => deriveNodeOrbital(this.context.getWorldPresentationContext(), node, anchor),
deriveOrbitalFromLocalPosition: (localPosition: THREE.Vector3, systemId: string, anchor: OrbitalAnchor) => deriveOrbitalFromLocalPosition(this.context.getWorldPresentationContext(), localPosition, systemId, anchor), deriveOrbitalFromLocalPosition: (localPosition: THREE.Vector3, systemId: string, anchor: OrbitalAnchor) => deriveOrbitalFromLocalPosition(this.context.getWorldPresentationContext(), localPosition, systemId, anchor),

View File

@@ -11,6 +11,7 @@ import type {
ConstructionSiteSnapshot, ConstructionSiteSnapshot,
MoonSnapshot, MoonSnapshot,
PlanetSnapshot, PlanetSnapshot,
ResourceDepositSnapshot,
ResourceNodeSnapshot, ResourceNodeSnapshot,
ShipSnapshot, ShipSnapshot,
StationSnapshot, StationSnapshot,
@@ -46,6 +47,23 @@ export function createNodeMesh(node: ResourceNodeSnapshot): SceneNode {
return createSceneNode(mesh); return createSceneNode(mesh);
} }
export function createResourceDepositMesh(deposit: ResourceDepositSnapshot, node: ResourceNodeSnapshot): SceneNode {
const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas";
const oreRatio = deposit.maxOre <= 0.01 ? 0 : deposit.oreRemaining / deposit.maxOre;
const mesh = new THREE.Mesh(
isGas ? new THREE.SphereGeometry(10, 12, 12) : new THREE.DodecahedronGeometry(8 + (oreRatio * 5), 0),
new THREE.MeshStandardMaterial({
color: isGas ? 0x7fd6ff : 0xc9a165,
flatShading: !isGas,
transparent: isGas,
opacity: isGas ? 0.55 : 1,
emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xc9a165).multiplyScalar(isGas ? 0.18 : 0.05),
}),
);
mesh.position.copy(toThreeVector(deposit.localPosition));
return createSceneNode(mesh);
}
export function createCelestialMesh(node: CelestialSnapshot, celestialColor: (kind: string) => string): SceneNode { export function createCelestialMesh(node: CelestialSnapshot, celestialColor: (kind: string) => string): SceneNode {
const color = celestialColor(node.kind); const color = celestialColor(node.kind);
return createSceneNode(new THREE.Mesh( return createSceneNode(new THREE.Mesh(

View File

@@ -70,6 +70,18 @@ function toSystemPos(localPosition: THREE.Vector3): THREE.Vector3 {
return localPosition.clone().multiplyScalar(DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE); return localPosition.clone().multiplyScalar(DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
} }
function resolveShipSystemPosition(ship: ShipSnapshot | ShipDelta, context: SceneSyncContext) {
if (ship.spatialState.systemPosition) {
return toThreeVector(ship.spatialState.systemPosition);
}
if (ship.anchorId) {
return context.resolvePointPosition(ship.systemId, null, ship.anchorId);
}
return toThreeVector(ship.localPosition);
}
interface SceneSyncContext { interface SceneSyncContext {
documentRef: Document; documentRef: Document;
worldOrbitalTimeSeconds?: number; worldOrbitalTimeSeconds?: number;
@@ -98,7 +110,7 @@ interface SceneSyncContext {
shipPresentationColor: (ship: ShipSnapshot) => string; shipPresentationColor: (ship: ShipSnapshot) => string;
celestialColor: (kind: string) => string; celestialColor: (kind: string) => string;
createCirclePoints: (radius: number, segments: number) => THREE.Vector3[]; createCirclePoints: (radius: number, segments: number) => THREE.Vector3[];
resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3; resolvePointPosition: (systemId: string, celestialId?: string | null, anchorId?: string | null) => THREE.Vector3;
resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => NodeVisual["anchor"]; resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => NodeVisual["anchor"];
deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: NodeVisual["anchor"]) => { deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: NodeVisual["anchor"]) => {
radius: number; radius: number;
@@ -250,7 +262,8 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
const mesh = createNodeMesh(node); const mesh = createNodeMesh(node);
const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 100); const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 100);
const localPosition = toThreeVector(node.localPosition); const localPosition = toThreeVector(node.localPosition);
const displayPos = toSystemPos(localPosition); const systemPosition = context.resolvePointPosition(node.systemId, null, node.anchorId);
const displayPos = toSystemPos(systemPosition);
mesh.setPosition(displayPos); mesh.setPosition(displayPos);
icon.setPosition(displayPos); icon.setPosition(displayPos);
const isActive = node.systemId === activeSystemId; const isActive = node.systemId === activeSystemId;
@@ -260,6 +273,7 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
const orbital = context.deriveNodeOrbital(node, anchor); const orbital = context.deriveNodeOrbital(node, anchor);
context.nodeVisuals.set(node.id, { context.nodeVisuals.set(node.id, {
systemId: node.systemId, systemId: node.systemId,
anchorId: node.anchorId,
mesh, mesh,
icon, icon,
sourceKind: node.sourceKind, sourceKind: node.sourceKind,
@@ -283,7 +297,8 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
const mesh = createStationMesh(station); const mesh = createStationMesh(station);
const icon = createTacticalIcon(context.documentRef, station.color, 130); const icon = createTacticalIcon(context.documentRef, station.color, 130);
const localPosition = toThreeVector(station.localPosition); const localPosition = toThreeVector(station.localPosition);
const displayPos = toSystemPos(localPosition); const systemPosition = context.resolvePointPosition(station.systemId, null, station.anchorId);
const displayPos = toSystemPos(systemPosition);
mesh.setPosition(displayPos); mesh.setPosition(displayPos);
icon.setPosition(displayPos); icon.setPosition(displayPos);
const isActive = station.systemId === activeSystemId; const isActive = station.systemId === activeSystemId;
@@ -294,6 +309,7 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
context.stationVisuals.set(station.id, { context.stationVisuals.set(station.id, {
id: station.id, id: station.id,
systemId: station.systemId, systemId: station.systemId,
anchorId: station.anchorId,
mesh, mesh,
icon, icon,
anchor, anchor,
@@ -313,7 +329,7 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[], a
context.claimVisuals.clear(); context.claimVisuals.clear();
for (const claim of claims) { for (const claim of claims) {
const localPosition = context.resolvePointPosition(claim.systemId, claim.celestialId); const localPosition = context.resolvePointPosition(claim.systemId, null, claim.anchorId);
const displayPos = toSystemPos(localPosition); const displayPos = toSystemPos(localPosition);
const mesh = createClaimMesh(claim); const mesh = createClaimMesh(claim);
const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 90); const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 90);
@@ -324,7 +340,7 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[], a
icon.setVisible(isActive); icon.setVisible(isActive);
context.claimVisuals.set(claim.id, { context.claimVisuals.set(claim.id, {
id: claim.id, id: claim.id,
celestialId: claim.celestialId, anchorId: claim.anchorId,
systemId: claim.systemId, systemId: claim.systemId,
mesh, mesh,
icon, icon,
@@ -341,7 +357,7 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
context.constructionSiteVisuals.clear(); context.constructionSiteVisuals.clear();
for (const site of sites) { for (const site of sites) {
const localPosition = context.resolvePointPosition(site.systemId, site.celestialId); const localPosition = context.resolvePointPosition(site.systemId, null, site.anchorId);
const displayPos = toSystemPos(localPosition); const displayPos = toSystemPos(localPosition);
const mesh = createConstructionSiteMesh(site); const mesh = createConstructionSiteMesh(site);
const icon = createTacticalIcon(context.documentRef, "#9df29c", 90); const icon = createTacticalIcon(context.documentRef, "#9df29c", 90);
@@ -352,7 +368,7 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
icon.setVisible(isActive); icon.setVisible(isActive);
context.constructionSiteVisuals.set(site.id, { context.constructionSiteVisuals.set(site.id, {
id: site.id, id: site.id,
celestialId: site.celestialId, anchorId: site.anchorId,
systemId: site.systemId, systemId: site.systemId,
mesh, mesh,
icon, icon,
@@ -374,7 +390,7 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick
const iconBaseScale = 78; const iconBaseScale = 78;
const icon = createShipTacticalIcon(context.documentRef, shipColor, iconBaseScale); const icon = createShipTacticalIcon(context.documentRef, shipColor, iconBaseScale);
const localPosition = toThreeVector(ship.localPosition); const localPosition = toThreeVector(ship.localPosition);
const displayPos = toSystemPos(localPosition); const displayPos = toSystemPos(resolveShipSystemPosition(ship, context));
mesh.setPosition(displayPos); mesh.setPosition(displayPos);
icon.setPosition(displayPos); icon.setPosition(displayPos);
icon.setColor(shipColor); icon.setColor(shipColor);
@@ -386,6 +402,7 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick
registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "ship", id: ship.id }); registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "ship", id: ship.id });
context.shipVisuals.set(ship.id, { context.shipVisuals.set(ship.id, {
systemId: ship.systemId, systemId: ship.systemId,
anchorId: ship.anchorId ?? ship.spatialState.currentAnchorId ?? undefined,
mesh, mesh,
icon, icon,
iconBaseScale, iconBaseScale,
@@ -430,6 +447,7 @@ export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDe
} }
visual.systemId = node.systemId; visual.systemId = node.systemId;
visual.anchorId = node.anchorId;
visual.sourceKind = node.sourceKind; visual.sourceKind = node.sourceKind;
visual.localPosition.copy(toThreeVector(node.localPosition)); visual.localPosition.copy(toThreeVector(node.localPosition));
visual.anchor = context.resolveOrbitalAnchor(node.systemId, visual.localPosition); visual.anchor = context.resolveOrbitalAnchor(node.systemId, visual.localPosition);
@@ -452,6 +470,7 @@ export function applyStationDeltas(context: SceneSyncContext, stations: StationD
} }
visual.systemId = station.systemId; visual.systemId = station.systemId;
visual.anchorId = station.anchorId;
visual.localPosition.copy(toThreeVector(station.localPosition)); visual.localPosition.copy(toThreeVector(station.localPosition));
visual.anchor = context.resolveOrbitalAnchor(station.systemId, visual.localPosition); visual.anchor = context.resolveOrbitalAnchor(station.systemId, visual.localPosition);
const orbital = context.deriveOrbitalFromLocalPosition(visual.localPosition, station.systemId, visual.anchor); const orbital = context.deriveOrbitalFromLocalPosition(visual.localPosition, station.systemId, visual.anchor);
@@ -474,7 +493,8 @@ export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[]
} }
visual.systemId = claim.systemId; visual.systemId = claim.systemId;
visual.localPosition.copy(context.resolvePointPosition(claim.systemId, claim.celestialId)); visual.anchorId = claim.anchorId;
visual.localPosition.copy(context.resolvePointPosition(claim.systemId, null, claim.anchorId));
const displayPos = toSystemPos(visual.localPosition); const displayPos = toSystemPos(visual.localPosition);
visual.mesh.setPosition(displayPos); visual.mesh.setPosition(displayPos);
visual.icon.setPosition(displayPos); visual.icon.setPosition(displayPos);
@@ -494,7 +514,8 @@ export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: Co
} }
visual.systemId = site.systemId; visual.systemId = site.systemId;
visual.localPosition.copy(context.resolvePointPosition(site.systemId, site.celestialId)); visual.anchorId = site.anchorId;
visual.localPosition.copy(context.resolvePointPosition(site.systemId, null, site.anchorId));
const displayPos = toSystemPos(visual.localPosition); const displayPos = toSystemPos(visual.localPosition);
visual.mesh.setPosition(displayPos); visual.mesh.setPosition(displayPos);
visual.icon.setPosition(displayPos); visual.icon.setPosition(displayPos);
@@ -514,6 +535,7 @@ export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], t
} }
visual.systemId = ship.systemId; visual.systemId = ship.systemId;
visual.anchorId = ship.anchorId ?? ship.spatialState.currentAnchorId ?? undefined;
visual.startPosition.copy(getAnimatedShipLocalPosition(visual)); visual.startPosition.copy(getAnimatedShipLocalPosition(visual));
visual.authoritativePosition.copy(toThreeVector(ship.localPosition)); visual.authoritativePosition.copy(toThreeVector(ship.localPosition));
visual.targetPosition.copy(toThreeVector(ship.targetLocalPosition)); visual.targetPosition.copy(toThreeVector(ship.targetLocalPosition));

View File

@@ -20,10 +20,17 @@ export function describeSelectable(world: WorldState | undefined, item: Selectab
return world.stations.get(item.id)?.label ?? item.id; return world.stations.get(item.id)?.label ?? item.id;
} }
if (item.kind === "node") { if (item.kind === "node") {
return item.id; const node = world.nodes.get(item.id);
return node ? `${node.itemId} source` : item.id;
} }
if (item.kind === "celestial") { if (item.kind === "celestial") {
return `${world.celestials.get(item.id)?.kind ?? "celestial"} ${item.id}`; const celestial = world.celestials.get(item.id);
if (!celestial) {
return item.id;
}
return describeCelestialPathWithinSystem(world, celestial.systemId, celestial.id)
?? `${world.systems.get(celestial.systemId)?.label ?? celestial.systemId} / ${celestial.kind}`;
} }
if (item.kind === "claim") { if (item.kind === "claim") {
return `claim ${item.id}`; return `claim ${item.id}`;
@@ -113,9 +120,7 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab
return item.id; return item.id;
} }
const anchorPath = node.celestialId const anchorPath = describeAnchorPathWithinSystem(world, node.systemId, node.anchorId);
? describeCelestialPathWithinSystem(world, node.systemId, node.celestialId)
: undefined;
return anchorPath ? `${anchorPath} / ${node.itemId}` : `${node.systemId} / ${node.itemId}`; return anchorPath ? `${anchorPath} / ${node.itemId}` : `${node.systemId} / ${node.itemId}`;
} }
@@ -135,16 +140,16 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab
if (item.kind === "claim") { if (item.kind === "claim") {
const claim = world.claims.get(item.id); const claim = world.claims.get(item.id);
const anchorPath = claim?.celestialId const anchorPath = claim
? describeCelestialPathWithinSystem(world, claim.systemId, claim.celestialId) ? describeAnchorPathWithinSystem(world, claim.systemId, claim.anchorId)
: undefined; : undefined;
return anchorPath ? `${anchorPath} claim` : `Claim ${item.id}`; return anchorPath ? `${anchorPath} claim` : `Claim ${item.id}`;
} }
if (item.kind === "construction-site") { if (item.kind === "construction-site") {
const site = world.constructionSites.get(item.id); const site = world.constructionSites.get(item.id);
const anchorPath = site?.celestialId const anchorPath = site
? describeCelestialPathWithinSystem(world, site.systemId, site.celestialId) ? describeAnchorPathWithinSystem(world, site.systemId, site.anchorId)
: undefined; : undefined;
const siteLabel = site ? (site.blueprintId ?? site.targetDefinitionId) : item.id; const siteLabel = site ? (site.blueprintId ?? site.targetDefinitionId) : item.id;
return anchorPath ? `${anchorPath} / ${siteLabel}` : `Construction ${siteLabel}`; return anchorPath ? `${anchorPath} / ${siteLabel}` : `Construction ${siteLabel}`;
@@ -210,20 +215,74 @@ export function resolveFocusedCelestialId(world: WorldState | undefined, selecte
return selected.id; return selected.id;
} }
if (selected.kind === "ship") { if (selected.kind === "ship") {
return world.ships.get(selected.id)?.spatialState.currentCelestialId ?? world.ships.get(selected.id)?.celestialId ?? undefined; const ship = world.ships.get(selected.id);
return ship?.spatialState.currentAnchorId && world.celestials.has(ship.spatialState.currentAnchorId)
? ship.spatialState.currentAnchorId
: (ship?.anchorId && world.celestials.has(ship.anchorId) ? ship.anchorId : undefined);
} }
if (selected.kind === "station") { if (selected.kind === "station") {
return world.stations.get(selected.id)?.celestialId ?? undefined; const station = world.stations.get(selected.id);
return station?.anchorId && world.celestials.has(station.anchorId) ? station.anchorId : undefined;
} }
if (selected.kind === "claim") { if (selected.kind === "claim") {
return world.claims.get(selected.id)?.celestialId ?? undefined; const claim = world.claims.get(selected.id);
return claim && world.celestials.has(claim.anchorId) ? claim.anchorId : undefined;
} }
if (selected.kind === "construction-site") { if (selected.kind === "construction-site") {
return world.constructionSites.get(selected.id)?.celestialId ?? undefined; const site = world.constructionSites.get(selected.id);
return site && world.celestials.has(site.anchorId) ? site.anchorId : undefined;
} }
return undefined; return undefined;
} }
export function resolveFocusedAnchorId(world: WorldState | undefined, selectedItems: Selectable[]): string | undefined {
if (!world || selectedItems.length !== 1) {
return undefined;
}
const selected = selectedItems[0];
if (selected.kind === "node") {
return world.nodes.get(selected.id)?.anchorId;
}
if (selected.kind === "ship") {
const ship = world.ships.get(selected.id);
return ship?.spatialState.currentAnchorId
?? ship?.anchorId
?? resolveFocusedCelestialId(world, selectedItems);
}
if (selected.kind === "station") {
const station = world.stations.get(selected.id);
return station?.anchorId
?? resolveFocusedCelestialId(world, selectedItems);
}
if (selected.kind === "claim") {
const claim = world.claims.get(selected.id);
return claim?.anchorId
?? resolveFocusedCelestialId(world, selectedItems);
}
if (selected.kind === "construction-site") {
const site = world.constructionSites.get(selected.id);
return site?.anchorId
?? resolveFocusedCelestialId(world, selectedItems);
}
if (selected.kind === "celestial") {
if (world.anchors.has(selected.id)) {
return selected.id;
}
const orbitBackedAnchor = [...world.anchors.values()].find((anchor) => anchor.orbitReferenceId === selected.id);
return orbitBackedAnchor?.id;
}
if (selected.kind === "planet") {
return `${selected.systemId}-planet-${selected.planetIndex + 1}`;
}
if (selected.kind === "moon") {
return `${selected.systemId}-planet-${selected.planetIndex + 1}-moon-${selected.moonIndex + 1}`;
}
return undefined;
}
export function describeOrbitalParent(world: WorldState | undefined, systemId?: string, anchor?: OrbitalAnchor): string { export function describeOrbitalParent(world: WorldState | undefined, systemId?: string, anchor?: OrbitalAnchor): string {
if (!world || !systemId) { if (!world || !systemId) {
return "unknown"; return "unknown";
@@ -330,23 +389,31 @@ export function describeShipState(world: WorldState | undefined, ship: ShipSnaps
return baseState; return baseState;
} }
const destinationNodeId = ship.spatialState.destinationNodeId ?? ship.spatialState.transit?.destinationNodeId; const destinationAnchorId = ship.spatialState.destinationAnchorId ?? ship.spatialState.transit?.destinationAnchorId;
if (!destinationNodeId) { if (!destinationAnchorId) {
return baseState; return baseState;
} }
const destinationCelestial = world.celestials.get(destinationNodeId); const destinationAnchor = destinationAnchorId ? world.anchors.get(destinationAnchorId) : undefined;
if (!destinationCelestial) {
return `${baseState} -> ${destinationNodeId}`;
}
if (baseState === "warping" || baseState === "spooling-warp") { if (baseState === "warping" || baseState === "spooling-warp") {
const destinationPath = describeCelestialPathWithinSystem(world, destinationCelestial.systemId, destinationNodeId); const destinationSystemId = destinationAnchor?.systemId ?? ship.spatialState.currentSystemId ?? ship.systemId;
return `${baseState} -> ${destinationPath ?? destinationNodeId}`; const destinationPath = describeAnchorPathWithinSystem(
world,
destinationSystemId,
destinationAnchorId,
);
return `${baseState} -> ${destinationPath ?? destinationAnchorId}`;
} }
const destinationSystem = world.systems.get(destinationCelestial.systemId); const destinationSystemId = destinationAnchor?.systemId
return `${baseState} -> ${destinationSystem?.label ?? destinationCelestial.systemId}`; ?? ship.spatialState.currentSystemId
?? ship.systemId;
const destinationSystem = world.systems.get(destinationSystemId);
if (!destinationSystem) {
return `${baseState} -> ${destinationAnchorId}`;
}
return `${baseState} -> ${destinationSystem.label}`;
} }
export function describeShipObjective(objective: string): string { export function describeShipObjective(objective: string): string {
@@ -406,8 +473,8 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn
if (ship.dockedStationId) { if (ship.dockedStationId) {
const station = world.stations.get(ship.dockedStationId); const station = world.stations.get(ship.dockedStationId);
if (station) { if (station) {
const anchorPath = station.celestialId const anchorPath = station.anchorId
? describeCelestialPathWithinSystem(world, station.systemId, station.celestialId) ? describeAnchorPathWithinSystem(world, station.systemId, station.anchorId)
: undefined; : undefined;
return { return {
system: systemLabel, system: systemLabel,
@@ -416,11 +483,11 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn
} }
} }
const currentCelestialId = ship.spatialState.currentCelestialId ?? ship.celestialId; const currentAnchorId = ship.spatialState.currentAnchorId ?? ship.anchorId;
if (currentCelestialId) { if (currentAnchorId) {
const celestialPath = describeCelestialPathWithinSystem(world, systemId, currentCelestialId); const anchorPath = describeAnchorPathWithinSystem(world, systemId, currentAnchorId);
if (celestialPath) { if (anchorPath) {
return { system: systemLabel, local: celestialPath }; return { system: systemLabel, local: anchorPath };
} }
} }
@@ -446,9 +513,9 @@ export function describeActiveSpace(
return activeSystem.label; return activeSystem.label;
} }
const celestialId = resolveFocusedCelestialId(world, selectedItems); const anchorId = resolveFocusedAnchorId(world, selectedItems);
if (celestialId) { if (anchorId) {
const localPath = describeCelestialPathWithinSystem(world, activeSystem.id, celestialId); const localPath = describeAnchorPathWithinSystem(world, activeSystem.id, anchorId);
return localPath return localPath
? `${activeSystem.label} / ${localPath}` ? `${activeSystem.label} / ${localPath}`
: activeSystem.label; : activeSystem.label;
@@ -472,10 +539,9 @@ export function describeCelestialPathWithinSystem(world: WorldState, systemId: s
return undefined; return undefined;
} }
if (celestial.parentNodeId) { const anchorId = resolveAnchorIdForCelestial(world, celestialId);
const parentPath = describeCelestialPathWithinSystem(world, systemId, celestial.parentNodeId); if (anchorId) {
const segment = describeCelestialSegment(system, celestial); return describeAnchorPathWithinSystem(world, systemId, anchorId);
return parentPath ? `${parentPath}/${segment}` : segment;
} }
if (celestial.kind === "star") { if (celestial.kind === "star") {
@@ -485,6 +551,60 @@ export function describeCelestialPathWithinSystem(world: WorldState, systemId: s
return describeCelestialSegment(system, celestial); return describeCelestialSegment(system, celestial);
} }
export function describeAnchorPathWithinSystem(world: WorldState, systemId: string, anchorId: string, celestialId?: string | null): string | undefined {
const anchor = world.anchors.get(anchorId);
if (anchor?.parentAnchorId) {
const parentPath = describeAnchorPathWithinSystem(world, systemId, anchor.parentAnchorId);
const segment = describeAnchorSegment(anchor);
return parentPath ? `${parentPath}/${segment}` : segment;
}
if (celestialId) {
return describeCelestialPathWithinSystem(world, systemId, celestialId);
}
if (!anchor) {
return undefined;
}
return describeAnchorSegment(anchor);
}
function describeAnchorSegment(anchor: { kind: string; id: string; orbitReferenceId?: string | null }): string {
if (anchor.orbitReferenceId) {
return describeAnchorOrbitReference(anchor.orbitReferenceId);
}
if (anchor.kind === "resource-node") {
return anchor.id;
}
return anchor.kind.replace(/-/g, " ");
}
function resolveAnchorIdForCelestial(world: WorldState, celestialId: string): string | undefined {
return world.anchors.has(celestialId) ? celestialId : undefined;
}
function describeAnchorOrbitReference(referenceId: string): string {
const lagrangeMatch = referenceId.match(/(l[1-5])$/i);
if (lagrangeMatch) {
return lagrangeMatch[1].toUpperCase();
}
const moonMatch = referenceId.match(/moon-(\d+)$/i);
if (moonMatch) {
return `Moon ${moonMatch[1]}`;
}
const planetMatch = referenceId.match(/planet-(\d+)$/i);
if (planetMatch) {
return `Planet ${planetMatch[1]}`;
}
return referenceId;
}
function describeCelestialSegment(system: SystemSnapshot, celestial: CelestialSnapshot): string { function describeCelestialSegment(system: SystemSnapshot, celestial: CelestialSnapshot): string {
const moonMatch = celestial.id.match(/-planet-(\d+)-moon-(\d+)$/); const moonMatch = celestial.id.match(/-planet-(\d+)-moon-(\d+)$/);
if (moonMatch) { if (moonMatch) {

View File

@@ -41,6 +41,7 @@ export function createWorldState(snapshot: WorldSnapshot): WorldState {
generatedAtUtc: snapshot.generatedAtUtc, generatedAtUtc: snapshot.generatedAtUtc,
systems: new Map(snapshot.systems.map((system) => [system.id, system])), systems: new Map(snapshot.systems.map((system) => [system.id, system])),
celestials: new Map(snapshot.celestials.map((celestial) => [celestial.id, celestial])), celestials: new Map(snapshot.celestials.map((celestial) => [celestial.id, celestial])),
anchors: new Map(snapshot.anchors.map((anchor) => [anchor.id, anchor])),
nodes: new Map(snapshot.nodes.map((node) => [node.id, node])), nodes: new Map(snapshot.nodes.map((node) => [node.id, node])),
stations: new Map(snapshot.stations.map((station) => [station.id, station])), stations: new Map(snapshot.stations.map((station) => [station.id, station])),
claims: new Map(snapshot.claims.map((claim) => [claim.id, claim])), claims: new Map(snapshot.claims.map((claim) => [claim.id, claim])),
@@ -65,6 +66,9 @@ export function applyDeltaToWorld(world: WorldState, delta: WorldDelta): boolean
for (const celestial of delta.celestials) { for (const celestial of delta.celestials) {
world.celestials.set(celestial.id, celestial); world.celestials.set(celestial.id, celestial);
} }
for (const anchor of delta.anchors) {
world.anchors.set(anchor.id, anchor);
}
for (const node of delta.nodes) { for (const node of delta.nodes) {
world.nodes.set(node.id, node); world.nodes.set(node.id, node);
} }
@@ -101,6 +105,7 @@ export function recordDeltaStats(networkStats: NetworkStats, delta: WorldDelta,
+ delta.stations.length + delta.stations.length
+ delta.nodes.length + delta.nodes.length
+ delta.celestials.length + delta.celestials.length
+ delta.anchors.length
+ delta.claims.length + delta.claims.length
+ delta.constructionSites.length + delta.constructionSites.length
+ delta.marketOrders.length + delta.marketOrders.length

View File

@@ -1,6 +1,7 @@
import * as THREE from "three"; import * as THREE from "three";
import type { SceneNode } from "./viewerScenePrimitives"; import type { SceneNode } from "./viewerScenePrimitives";
import type { import type {
AnchorSnapshot,
CelestialSnapshot, CelestialSnapshot,
ClaimSnapshot, ClaimSnapshot,
ConstructionSiteSnapshot, ConstructionSiteSnapshot,
@@ -35,6 +36,7 @@ export type Selectable =
export interface ShipVisual { export interface ShipVisual {
systemId: string; systemId: string;
anchorId?: string;
mesh: SceneNode; mesh: SceneNode;
icon: SceneNode; icon: SceneNode;
iconBaseScale: number; iconBaseScale: number;
@@ -74,6 +76,7 @@ export type OrbitalAnchor =
export interface NodeVisual { export interface NodeVisual {
systemId: string; systemId: string;
anchorId: string;
mesh: SceneNode; mesh: SceneNode;
icon: SceneNode; icon: SceneNode;
sourceKind: string; sourceKind: string;
@@ -96,7 +99,8 @@ export interface CelestialVisual {
export interface ClaimVisual { export interface ClaimVisual {
id: string; id: string;
celestialId: string; anchorId: string;
celestialId?: string | null;
systemId: string; systemId: string;
mesh: SceneNode; mesh: SceneNode;
icon: SceneNode; icon: SceneNode;
@@ -105,7 +109,8 @@ export interface ClaimVisual {
export interface ConstructionSiteVisual { export interface ConstructionSiteVisual {
id: string; id: string;
celestialId: string; anchorId: string;
celestialId?: string | null;
systemId: string; systemId: string;
mesh: SceneNode; mesh: SceneNode;
icon: SceneNode; icon: SceneNode;
@@ -115,6 +120,7 @@ export interface ConstructionSiteVisual {
export interface StructureVisual { export interface StructureVisual {
id: string; id: string;
systemId: string; systemId: string;
anchorId?: string | null;
mesh: SceneNode; mesh: SceneNode;
icon: SceneNode; icon: SceneNode;
anchor: OrbitalAnchor; anchor: OrbitalAnchor;
@@ -145,6 +151,7 @@ export interface WorldState {
generatedAtUtc: string; generatedAtUtc: string;
systems: Map<string, SystemSnapshot>; systems: Map<string, SystemSnapshot>;
celestials: Map<string, CelestialSnapshot>; celestials: Map<string, CelestialSnapshot>;
anchors: Map<string, AnchorSnapshot>;
nodes: Map<string, ResourceNodeSnapshot>; nodes: Map<string, ResourceNodeSnapshot>;
stations: Map<string, StationSnapshot>; stations: Map<string, StationSnapshot>;
claims: Map<string, ClaimSnapshot>; claims: Map<string, ClaimSnapshot>;

View File

@@ -65,8 +65,9 @@ export interface ViewerWorldLifecycleContext {
applyClaimDeltas: (claims: ClaimDelta[]) => void; applyClaimDeltas: (claims: ClaimDelta[]) => void;
applyConstructionSiteDeltas: (sites: ConstructionSiteDelta[]) => void; applyConstructionSiteDeltas: (sites: ConstructionSiteDelta[]) => void;
applyShipDeltas: (ships: ShipDelta[], tickIntervalMs: number) => void; applyShipDeltas: (ships: ShipDelta[], tickIntervalMs: number) => void;
refreshLocalLayer: () => void;
refreshHistoryWindows: () => void; refreshHistoryWindows: () => void;
resolveFocusedCelestialId: () => string | undefined; resolveFocusedAnchorId: () => string | undefined;
updateSystemSummaries: () => void; updateSystemSummaries: () => void;
applyZoomPresentation: () => void; applyZoomPresentation: () => void;
updateNetworkPanel: () => void; updateNetworkPanel: () => void;
@@ -165,6 +166,7 @@ export class ViewerWorldLifecycle {
this.context.syncClaims(snapshot.claims); this.context.syncClaims(snapshot.claims);
this.context.syncConstructionSites(snapshot.constructionSites); this.context.syncConstructionSites(snapshot.constructionSites);
this.context.syncShips(snapshot.ships, snapshot.tickIntervalMs); this.context.syncShips(snapshot.ships, snapshot.tickIntervalMs);
this.context.refreshLocalLayer();
this.rebuildFactions(snapshot.factions); this.rebuildFactions(snapshot.factions);
this.context.updateSystemSummaries(); this.context.updateSystemSummaries();
this.context.applyZoomPresentation(); this.context.applyZoomPresentation();
@@ -185,6 +187,7 @@ export class ViewerWorldLifecycle {
this.context.applyClaimDeltas(delta.claims); this.context.applyClaimDeltas(delta.claims);
this.context.applyConstructionSiteDeltas(delta.constructionSites); this.context.applyConstructionSiteDeltas(delta.constructionSites);
this.context.applyShipDeltas(delta.ships, delta.tickIntervalMs); this.context.applyShipDeltas(delta.ships, delta.tickIntervalMs);
this.context.refreshLocalLayer();
this.rebuildFactions(cloneFactions(world)); this.rebuildFactions(cloneFactions(world));
this.context.updateSystemSummaries(); this.context.updateSystemSummaries();
} }
@@ -219,6 +222,7 @@ export class ViewerWorldLifecycle {
} }
this.context.refreshHistoryWindows(); this.context.refreshHistoryWindows();
this.context.refreshLocalLayer();
this.context.updateSystemPanel(); this.context.updateSystemPanel();
this.refreshStreamScopeIfNeeded(); this.refreshStreamScopeIfNeeded();
const detailState = buildDetailPanelState({ const detailState = buildDetailPanelState({
@@ -241,12 +245,12 @@ export class ViewerWorldLifecycle {
return { scopeKind: "universe" as const }; return { scopeKind: "universe" as const };
} }
const celestialId = this.context.resolveFocusedCelestialId(); const anchorId = this.context.resolveFocusedAnchorId();
if (this.context.getPovLevel() === "local" && celestialId) { if (this.context.getPovLevel() === "local" && anchorId) {
return { return {
scopeKind: "local-celestial" as const, scopeKind: "local-anchor" as const,
systemId: activeSystemId, systemId: activeSystemId,
celestialId, anchorId,
}; };
} }

View File

@@ -10,7 +10,7 @@ import {
toThreeVector, toThreeVector,
} from "./viewerMath"; } from "./viewerMath";
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants"; import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
import { describeActiveSpace, resolveFocusedCelestialId } from "./viewerSelection"; import { describeActiveSpace, resolveFocusedAnchorId } from "./viewerSelection";
import { import {
resolveShipHeading, resolveShipHeading,
updateSystemStarPresentation, updateSystemStarPresentation,
@@ -58,15 +58,21 @@ export interface WorldOrbitalContext {
export interface WorldPresentationContext extends WorldOrbitalContext { export interface WorldPresentationContext extends WorldOrbitalContext {
activeSystemId?: string; activeSystemId?: string;
focusedAnchorId?: string;
cameraMode: CameraMode; cameraMode: CameraMode;
povLevel: PovLevel; povLevel: PovLevel;
orbitYaw: number; orbitYaw: number;
camera: THREE.PerspectiveCamera; camera: THREE.PerspectiveCamera;
systemAnchor: THREE.Vector3; systemAnchor: THREE.Vector3;
shipVisuals: Map<string, ShipVisual>; shipVisuals: Map<string, ShipVisual>;
localShipVisuals: Map<string, ShipVisual>;
claimVisuals: Map<string, ClaimVisual>; claimVisuals: Map<string, ClaimVisual>;
localClaimVisuals: Map<string, ClaimVisual>;
constructionSiteVisuals: Map<string, ConstructionSiteVisual>; constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
localConstructionSiteVisuals: Map<string, ConstructionSiteVisual>;
systemVisuals: Map<string, SystemVisual>; systemVisuals: Map<string, SystemVisual>;
localNodeVisuals: Map<string, NodeVisual>;
localStationVisuals: Map<string, StructureVisual>;
systemSummaryVisuals: Map<string, any>; systemSummaryVisuals: Map<string, any>;
toDisplayLocalPosition: (localPosition: THREE.Vector3) => THREE.Vector3; toDisplayLocalPosition: (localPosition: THREE.Vector3) => THREE.Vector3;
updateSystemDetailVisibility: () => void; updateSystemDetailVisibility: () => void;
@@ -95,7 +101,7 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
continue; continue;
} }
const worldPosition = getAnimatedShipLocalPosition(visual, now); const worldPosition = resolveShipRenderPosition(context, ship, visual, now, renderMode);
const displayPosition = context.toDisplayLocalPosition(worldPosition); const displayPosition = context.toDisplayLocalPosition(worldPosition);
visual.mesh.setPosition(displayPosition); visual.mesh.setPosition(displayPosition);
visual.icon.setPosition(rawObject(visual.mesh).position.clone()); visual.icon.setPosition(rawObject(visual.mesh).position.clone());
@@ -124,8 +130,22 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
} }
} }
for (const [shipId, visual] of context.localShipVisuals.entries()) {
const ship = context.world?.ships.get(shipId);
if (!ship) {
continue;
}
const localPosition = getAnimatedShipLocalPosition(visual, now);
const displayPosition = context.toDisplayLocalPosition(localPosition);
visual.mesh.setPosition(displayPosition);
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
for (const visual of context.nodeVisuals.values()) { for (const visual of context.nodeVisuals.values()) {
const animatedLocalPosition = computeNodeLocalPosition(context, visual, worldTimeSeconds); const animatedLocalPosition = resolveNodeRenderPosition(context, visual, worldTimeSeconds, renderMode);
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition)); visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone()); visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId); visual.mesh.setVisible(visual.systemId === context.activeSystemId);
@@ -148,7 +168,7 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
} }
for (const visual of context.stationVisuals.values()) { for (const visual of context.stationVisuals.values()) {
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds); const animatedLocalPosition = resolveStructureRenderPosition(context, visual, worldTimeSeconds, renderMode);
const displayPosition = context.toDisplayLocalPosition(animatedLocalPosition); const displayPosition = context.toDisplayLocalPosition(animatedLocalPosition);
visual.mesh.setPosition(displayPosition); visual.mesh.setPosition(displayPosition);
visual.icon.setPosition(rawObject(visual.mesh).position.clone()); visual.icon.setPosition(rawObject(visual.mesh).position.clone());
@@ -165,7 +185,7 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
} }
for (const visual of context.claimVisuals.values()) { for (const visual of context.claimVisuals.values()) {
const animatedLocalPosition = computeCelestialLocalPositionById(context, visual.celestialId, worldTimeSeconds) ?? visual.localPosition.clone(); const animatedLocalPosition = visual.localPosition.clone();
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition)); visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone()); visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId); visual.mesh.setVisible(visual.systemId === context.activeSystemId);
@@ -173,12 +193,41 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
} }
for (const visual of context.constructionSiteVisuals.values()) { for (const visual of context.constructionSiteVisuals.values()) {
const animatedLocalPosition = computeCelestialLocalPositionById(context, visual.celestialId, worldTimeSeconds) ?? visual.localPosition.clone(); const animatedLocalPosition = visual.localPosition.clone();
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition)); visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone()); visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId); visual.mesh.setVisible(visual.systemId === context.activeSystemId);
visual.icon.setVisible(visual.systemId === context.activeSystemId); visual.icon.setVisible(visual.systemId === context.activeSystemId);
} }
for (const visual of context.localNodeVisuals.values()) {
visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
for (const visual of context.localStationVisuals.values()) {
const displayPosition = context.toDisplayLocalPosition(visual.localPosition.clone());
visual.mesh.setPosition(displayPosition);
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
for (const visual of context.localClaimVisuals.values()) {
visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
for (const visual of context.localConstructionSiteVisuals.values()) {
visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
} }
type RenderSpaceMode = "galaxy" | "system" | "local"; type RenderSpaceMode = "galaxy" | "system" | "local";
@@ -213,6 +262,60 @@ export function resolveShipWorldPosition(
return context.toDisplayLocalPosition(animatedLocalPosition); return context.toDisplayLocalPosition(animatedLocalPosition);
} }
function resolveAnchorSystemPosition(context: WorldOrbitalContext, anchorId?: string | null) {
if (!anchorId) {
return undefined;
}
const anchor = context.world?.anchors.get(anchorId);
return anchor ? toThreeVector(anchor.systemPosition) : undefined;
}
function resolveShipRenderPosition(
context: WorldPresentationContext,
ship: ShipSnapshot,
visual: ShipVisual,
now: number,
renderMode: RenderSpaceMode,
) {
const animatedLocalPosition = getAnimatedShipLocalPosition(visual, now);
const currentAnchorId = ship.spatialState.currentAnchorId ?? ship.anchorId ?? visual.anchorId;
const anchoredSystemPosition = resolveAnchorSystemPosition(context, currentAnchorId)
?? (ship.spatialState.systemPosition ? toThreeVector(ship.spatialState.systemPosition) : undefined);
if (renderMode === "local" && currentAnchorId && currentAnchorId === context.focusedAnchorId) {
return animatedLocalPosition;
}
return anchoredSystemPosition ?? animatedLocalPosition;
}
function resolveNodeRenderPosition(
context: WorldPresentationContext,
visual: NodeVisual,
timeSeconds: number,
renderMode: RenderSpaceMode,
) {
if (renderMode === "local" && visual.anchorId === context.focusedAnchorId) {
return computeNodeLocalPosition(context, visual, timeSeconds);
}
return resolveAnchorSystemPosition(context, visual.anchorId) ?? visual.localPosition.clone();
}
function resolveStructureRenderPosition(
context: WorldPresentationContext,
visual: StructureVisual,
timeSeconds: number,
renderMode: RenderSpaceMode,
) {
if (renderMode === "local" && visual.anchorId && visual.anchorId === context.focusedAnchorId) {
return resolveStructureAnimatedLocalPosition(context, visual, timeSeconds);
}
return resolveAnchorSystemPosition(context, visual.anchorId) ?? visual.localPosition.clone();
}
export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map<string, any>) { export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map<string, any>) {
if (!world) { if (!world) {
return; return;
@@ -310,9 +413,9 @@ export function describeGameStatus(params: GameStatusParams) {
? `sys pos: ${fmtVec(systemAnchor.clone().divideScalar(KILOMETERS_PER_AU), 3)} AU` ? `sys pos: ${fmtVec(systemAnchor.clone().divideScalar(KILOMETERS_PER_AU), 3)} AU`
: ""; : "";
// Local space: position relative to the focused celestial's orbital anchor in km // Local space: position relative to the focused celestial's orbital anchor in km
const focusedCelestialId = resolveFocusedCelestialId(world, selectedItems); const focusedAnchorId = resolveFocusedAnchorId(world, selectedItems);
const celestialAnchor = focusedCelestialId const celestialAnchor = focusedAnchorId
? world?.celestials.get(focusedCelestialId)?.orbitalAnchor ? (world?.anchors.get(focusedAnchorId)?.systemPosition ?? world?.celestials.get(focusedAnchorId)?.orbitalAnchor)
: undefined; : undefined;
const locPos = systemAnchor && celestialAnchor const locPos = systemAnchor && celestialAnchor
? `loc pos: ${fmtVec(systemAnchor.clone().sub(toThreeVector(celestialAnchor)), 0)} km` ? `loc pos: ${fmtVec(systemAnchor.clone().sub(toThreeVector(celestialAnchor)), 0)} km`
@@ -415,7 +518,14 @@ export function resolveOrbitalAnchor(context: WorldOrbitalContext, systemId: str
return bestAnchor; return bestAnchor;
} }
export function resolvePointPosition(context: WorldOrbitalContext, _systemId: string, celestialId?: string | null) { export function resolvePointPosition(context: WorldOrbitalContext, _systemId: string, celestialId?: string | null, anchorId?: string | null) {
if (anchorId) {
const anchor = context.world?.anchors.get(anchorId);
if (anchor) {
return toThreeVector(anchor.systemPosition);
}
}
if (celestialId) { if (celestialId) {
const celestial = context.world?.celestials.get(celestialId); const celestial = context.world?.celestials.get(celestialId);
if (celestial) { if (celestial) {
@@ -446,17 +556,17 @@ export function computeCelestialLocalPositionById(
} }
const basePosition = toThreeVector(celestial.orbitalAnchor); const basePosition = toThreeVector(celestial.orbitalAnchor);
if (!celestial.parentNodeId) { if (!celestial.parentAnchorId) {
return basePosition; return basePosition;
} }
const parentCelestial = context.world.celestials.get(celestial.parentNodeId); const parentCelestial = context.world.celestials.get(celestial.parentAnchorId);
if (!parentCelestial) { if (!parentCelestial) {
return basePosition; return basePosition;
} }
visiting.add(celestialId); visiting.add(celestialId);
const parentCurrentPosition = computeCelestialLocalPositionById(context, celestial.parentNodeId, timeSeconds, visiting); const parentCurrentPosition = computeCelestialLocalPositionById(context, celestial.parentAnchorId, timeSeconds, visiting);
visiting.delete(celestialId); visiting.delete(celestialId);
if (!parentCurrentPosition) { if (!parentCurrentPosition) {
return basePosition; return basePosition;
@@ -548,9 +658,16 @@ function resolveStructureAnimatedLocalPosition(context: WorldOrbitalContext, vis
} }
const station = context.world.stations.get(visual.id); const station = context.world.stations.get(visual.id);
if (!station?.celestialId) { if (!station) {
return computeStructureLocalPosition(context, visual, timeSeconds, 0.14); return computeStructureLocalPosition(context, visual, timeSeconds, 0.14);
} }
return computeCelestialLocalPositionById(context, station.celestialId, timeSeconds) ?? visual.localPosition.clone(); if (station.anchorId) {
const anchor = context.world.anchors.get(station.anchorId);
if (anchor) {
return toThreeVector(anchor.systemPosition);
}
}
return computeStructureLocalPosition(context, visual, timeSeconds, 0.14);
} }

View File

@@ -12,7 +12,7 @@ export default defineConfig({
port: 5174, port: 5174,
allowedHosts: ["sobina.local"], allowedHosts: ["sobina.local"],
proxy: { proxy: {
"/api": "http://127.0.0.1:5080", "/api": "http://127.0.0.1:5079",
}, },
}, },
build: { build: {

View File

@@ -1,443 +0,0 @@
# Universe Model Migration Worksheet
This worksheet is the implementation-facing companion to [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md).
It exists to drive the migration from the current runtime model to the target spatial model without turning the work into a blind rewrite.
This is not a new design authority. If this worksheet conflicts with [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md), the universe model wins.
## Goal
Move the simulation and viewer from the current flatter `system + coordinates + node/celestial overlays` model to:
- `system`
- `anchor`
- `localspace` as the tactical space around an anchor
- ships and constructions living at one anchor
- intra-system travel represented as anchor-to-anchor warp transit
## Scope
### Required
- Introduce first-class `Anchor` runtime concepts.
- Treat `localspace` as the tactical space around an anchor, not a separately identified entity.
- Rebind ships, stations, claims, construction sites, and resource nodes to anchors.
- Make intra-system travel anchor-to-anchor instead of raw free coordinate travel.
- Expose anchor-based spatial truth in backend contracts.
- Update the viewer to consume anchors as the primary spatial model.
### Recommended
- Preserve current authored content and scenario data where possible.
- Migrate incrementally behind compatibility adapters instead of a big-bang rewrite.
- Keep galaxy/system/local presentation working throughout the migration.
- Prefer additive contract changes first, then remove old fields once the stack is stable.
- Make every compatibility bridge loud, temporary, and easy to delete.
### Optional
- Rename every old `node` term immediately.
- Rework the full event streaming model during the first spatial pass.
- Rebuild AI/commander architecture in the same pass.
## Current Reality
The codebase already has partial pieces of the target model:
- celestials and Lagrange points exist
- resource nodes already behave like anchored locations
- ships already carry transit and space-layer state
- claims, construction sites, market orders, and policies already exist
- the viewer already has galaxy/system/local presentation logic
The real problem is that these are still layered on top of a flatter simulation core.
## Main Gaps
### 1. No First-Class Anchor Entity
Current code spreads the concept across:
- `CelestialRuntime`
- `ResourceNodeRuntime`
- `SystemId`
- raw positions
- `CurrentCelestialId`
- `DestinationNodeId`
This is the biggest structural gap.
### 2. Stations And Construction Are Still Celestial-Centric
Current contracts and runtime bind station-like structures to `CelestialId`.
That does not match the target model where:
- anchors are generic
- not all anchors are celestials
- construction happens at valid construction anchors
### 3. Movement Truth Is Still Coordinate-First
Ships still fundamentally operate through:
- local positions
- target positions
- system IDs
Transit exists, but the simulation still thinks in free coordinates first.
The target model wants:
- localspace movement inside an anchor-owned tactical bubble
- explicit anchor-to-anchor warp state inside a system
### 4. Viewer Contracts Still Mirror The Old World
The viewer still consumes:
- systems
- celestials
- resource nodes
- stations
- ships
It does not consume:
- anchors
- localspaces
- anchor topology as the primary graph
### 5. Localspace Is Not Yet The Simulation Boundary
The docs assume localspace is the natural tactical partition.
The code still treats the system as the practical runtime container, with local context layered on top.
### 6. Units And Coordinate Separation Are Not Explicit Enough
The target model assumes separate spatial units by layer:
- galaxy in `ly`
- system in `AU`
- localspace in `m` and `m/s`
The codebase currently mixes spatial concerns more freely than the target model allows.
That creates risk of accidental cross-layer coordinate coupling.
## Migration Strategy
Do this in seams.
Do not try to land the whole universe model in one rewrite.
## Units And Coordinate Rules
The migration must preserve separate native units by layer.
### Required
- Galaxy space uses `ly`.
- System space uses `AU`.
- Localspace uses `m` and `m/s`.
- Do not introduce a universal shared coordinate model spanning all layers.
- Cross-layer movement should use references and travel state, not coordinate projection.
### Recommended
- Keep unit-specific types or field names obvious in code and contracts.
- Keep conversion logic local to presentation helpers when a client animation needs it.
- Treat viewer zoom transitions as presentation-only effects.
### Disallowed
- Treating localspace offsets as meaningful system-space coordinates.
- Treating system-space offsets as meaningful galaxy coordinates.
- Building migration helpers that silently convert all positions into one synthetic global frame.
## Compatibility Rules
Compatibility bridges are allowed only as explicit migration tools.
They must not become silent permanent architecture.
### Required
- Every compatibility field, mapper, and adapter must be clearly marked.
- In C#, use `[Obsolete]` with a concrete migration reason when possible.
- In TypeScript/Vue, use an explicit `OBSOLETE` or `COMPATIBILITY BRIDGE` comment.
- Compatibility names should be obvious:
- `Legacy...`
- `Compat...`
- `...CompatibilityAdapter`
- Compatibility code should stay at seams:
- contracts
- mapping
- serialization
- viewer ingestion
### Recommended
- Avoid compatibility logic in the core simulation path unless there is no safer seam.
- Every compatibility bridge should have a removal condition.
- When a compatibility bridge stops being necessary, remove it in the same migration branch if practical.
### Disallowed
- Silent fallback fields with no marker.
- Compatibility helpers with neutral names that look permanent.
- New feature work that depends on deprecated spatial fields without an explicit exception.
## Compatibility Inventory
This section should remain in the worksheet during the migration and be kept current.
Each entry should be removed from this list when the compatibility bridge is removed from the codebase.
| Status | Area | Compatibility Item | Reason | Removal Condition |
| --- | --- | --- | --- | --- |
| Active | Backend contracts | Legacy `CelestialId` placement fields alongside `anchorId` | Preserve contract compatibility while moving structures and ships to anchor-based placement truth | Remove once backend and viewer both consume anchor placement directly |
| Active | Backend ship state | Legacy `DestinationNodeId` / node-based transit naming during anchor migration | Preserve existing travel wiring while renaming and rebinding spatial state | Remove once runtime and contracts use anchor terminology end-to-end |
| Active | Viewer ingestion | Compatibility mapping from legacy world snapshot fields into anchor-based view models | Allow incremental frontend migration without blocking backend progress | Remove once viewer contracts expose anchors as primary data |
| Active | Mining orders and directives | Overloaded generic `NodeId` usage for both mining travel and extraction targeting | The current name hides the required split between anchor travel and localspace extraction choice | Remove once mining travel uses `AnchorId` and extraction uses an explicit localspace target concept |
## Phase 1. Stabilize Vocabulary
### Objective
Introduce the target terms and IDs without breaking current gameplay.
### Required
- Add runtime `Anchor` models.
- Add contract snapshots/deltas for anchors.
- Add `anchorId` to:
- ships
- stations
- claims
- construction sites
- resource nodes
- Keep existing `CelestialId` and `NodeId` compatibility fields temporarily where needed.
### Success Criteria
- world snapshot can describe anchors directly
- old code still runs through compatibility mapping
## Phase 2. Make Scenario Loading Anchor-First
### Objective
Make scenario/spatial generation create anchors as first-class data.
### Required
- Treat stars, planets, moons, Lagrange points, and resource nodes as anchor-generation inputs.
- Define the localspace around each anchor.
- Stop treating station placement as a direct system-coordinate choice.
- Resolve starter stations and spawned stations through valid anchors.
### Recommended
- Preserve `planetIndex` and `lagrangeSide` authored hints by translating them into anchor selection.
### Success Criteria
- every meaningful location is represented as an anchor
- every anchor has a localspace around it
- station spawn/founding resolves to an anchor, not a raw point
## Phase 3. Rebind Structures And Sites
### Objective
Make infrastructure belong to anchors instead of generic system/celestial placement.
### Required
- Stations reference `anchorId`.
- Claims reference `anchorId`.
- Construction sites reference `anchorId`.
- Resource nodes reference `anchorId`.
### Recommended
- Keep `CelestialId` only as a derived convenience when the anchor is a celestial.
### Success Criteria
- no structure requires `CelestialId` as its primary placement key
## Phase 4. Replace Free Intra-System Travel
### Objective
Make intra-system travel obey the universe model.
### Required
- Ships in tactical state belong to one anchor's localspace.
- Ships leaving a localspace enter explicit warp transit.
- Transit stores:
- origin anchor
- destination anchor
- started at
- arrival due at
- progress
- Arrival resolves at the destination anchor.
### Recommended
- Keep current vector movement only for local tactical motion.
### Success Criteria
- ships no longer “just move across the whole system” as the primary truth
- warp is anchor-to-anchor by model, not just by UI
### Status
Implemented.
Notes:
- ship runtime travel truth is now anchor-first
- warp transit stores origin/destination anchors plus timing/progress
- FTL transit uses anchor-based entry/destination semantics
- viewer ship state and travel presentation now prefer anchor-based transit fields
- legacy node/celestial travel fields remain only as explicit compatibility bridges
Mining-specific clarification:
- travel to a mining site should target an `anchorId`
- selecting a concrete rock or gas pocket should happen only after arrival inside that anchor's localspace
- generic `NodeId` usage is still overloaded in some mining/order/directive paths and should be split in a later migration slice
## Phase 4A. Split Mining Travel From Local Extraction
### Objective
Make mining obey the same anchor/localspace boundary as the rest of the universe model.
### Required
- Mining travel targets an `anchorId`.
- Resource nodes remain anchors and strategic mining destinations.
- Concrete extractable targets are chosen only inside the destination localspace.
- Do not use one generic field to mean both:
- strategic mining destination
- tactical extraction target
### Recommended
- Rename overloaded mining `NodeId` fields to explicit meanings during this slice.
- Keep any remaining compatibility fields loud and temporary.
### Success Criteria
- mining travel is anchor-first
- tactical extraction targeting is localspace-only
- generic mining `NodeId` naming is no longer the primary truth
## Phase 5. Update Viewer Contracts
### Objective
Expose the new world truth cleanly to the frontend.
### Required
- Add anchor snapshots/deltas.
- Update ship snapshots to reference anchor-based state explicitly.
- Update station/claim/construction snapshots similarly.
### Recommended
- Keep a short compatibility window where old viewer fields still exist.
### Success Criteria
- the viewer no longer has to infer the world model from mixed legacy fields
## Phase 6. Update Viewer Presentation
### Objective
Make the viewer's tactical style align with the new simulation model.
### Required
- System view uses anchors as the primary navigable graph.
- Local view resolves the localspace around one anchor.
- Ships in warp display as transit-state entities between anchors.
- Construction and claims render at valid anchors.
### Recommended
- Keep a tactical-style presentation; do not regress into cinematic free-flight assumptions.
### Success Criteria
- viewer hierarchy matches `galaxy -> system -> localspace`
## What Can Stay
These areas are worth preserving:
- data-driven item/module/recipe definitions
- current snapshot/delta transport pattern
- current tactical-style visual direction
- current claims/construction/policy/market-order runtime concepts
- current player onboarding and per-player faction work
## What Must Change First
If implementation starts immediately, start here:
1. add `Anchor` runtime models
2. add backend contracts for them
3. make `SpatialBuilder` produce them
4. add `anchorId` to ships and structures
5. only then refactor movement and viewer logic
This is the smallest seam that changes the model without exploding the whole codebase.
## Risks
### High Risk
- changing movement and placement truth at the same time
- trying to rename everything in one pass
- mixing commander/AI refactors into the first spatial migration
### Medium Risk
- stale viewer assumptions around `systemId + celestialId`
- hidden GM/player tooling that assumes node/celestial placement
- compatibility bugs during contract overlap
### Lower Risk
- doc drift, now that `UNIVERSE-MODEL.md` is canonical
## Non-Goals For The First Migration Pass
- full commander architecture rewrite
- full event streaming redesign
- full economy redesign
- sovereignty redesign
- fleet hierarchy implementation
Those systems should adapt to the new spatial model, not block it.
## Exit Condition
This worksheet is complete when the codebase can truthfully say:
- every meaningful place in a system is an anchor
- every anchor has a localspace around it
- ships and constructions live at anchors
- intra-system travel is anchor-to-anchor warp
- the viewer consumes anchors directly