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

View File

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

View File

@@ -126,7 +126,7 @@ public sealed class TerritoryClaimRuntime
public string? SourceClaimId { get; set; }
public required string FactionId { get; set; }
public required string SystemId { get; set; }
public required string CelestialId { get; set; }
public required string AnchorId { get; set; }
public string Status { get; set; } = "active";
public string ClaimKind { get; set; } = "infrastructure";
public float ClaimStrength { get; set; }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,11 @@ public sealed partial class ShipAiService
{
private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ShipSubTaskRuntime subTask)
{
if (!string.IsNullOrWhiteSpace(subTask.TargetAnchorId) && ResolveAnchor(world, subTask.TargetAnchorId) is not null)
{
return subTask.TargetPosition ?? Vector3.Zero;
}
if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId))
{
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
@@ -44,15 +49,20 @@ public sealed partial class ShipAiService
if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId))
{
var station = ResolveStation(world, subTask.TargetEntityId);
if (station?.CelestialId is not null)
if (station?.AnchorId is not null)
{
return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId);
return ResolveAnchorBackedCelestial(world, station.AnchorId);
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
if (site?.CelestialId is not null)
if (site?.AnchorId is not null)
{
return world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
return ResolveAnchorBackedCelestial(world, site.AnchorId);
}
if (ResolveAnchor(world, subTask.TargetEntityId) is { } anchorBackedCelestialTarget)
{
return ResolveAnchorBackedCelestial(world, anchorBackedCelestialTarget.Id);
}
var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
@@ -76,25 +86,145 @@ public sealed partial class ShipAiService
.FirstOrDefault();
}
private static AnchorRuntime? ResolveTravelTargetAnchor(SimulationWorld world, ShipSubTaskRuntime subTask, Vector3 targetPosition)
{
if (!string.IsNullOrWhiteSpace(subTask.TargetAnchorId) && ResolveAnchor(world, subTask.TargetAnchorId) is { } explicitTargetAnchor)
{
return explicitTargetAnchor;
}
if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId))
{
var station = ResolveStation(world, subTask.TargetEntityId);
if (station?.AnchorId is not null)
{
return ResolveAnchor(world, station.AnchorId);
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
if (site?.AnchorId is not null)
{
return ResolveAnchor(world, site.AnchorId);
}
var node = ResolveNode(world, subTask.TargetEntityId);
if (node is not null)
{
return ResolveAnchor(world, node.AnchorId);
}
if (ResolveAnchor(world, subTask.TargetEntityId) is { } directAnchor)
{
return directAnchor;
}
if (world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } celestial)
{
return ResolveAnchor(world, celestial.Id);
}
if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } wreck)
{
return world.Anchors
.Where(candidate => candidate.SystemId == wreck.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(wreck.Position))
.FirstOrDefault();
}
}
return world.Anchors
.Where(candidate => subTask.TargetSystemId is null || candidate.SystemId == subTask.TargetSystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(targetPosition))
.FirstOrDefault();
}
private static AnchorRuntime? ResolveCurrentAnchor(SimulationWorld world, ShipRuntime ship)
{
if (ship.SpatialState.CurrentAnchorId is not null && ResolveAnchor(world, ship.SpatialState.CurrentAnchorId) is { } explicitAnchor)
{
return explicitAnchor;
}
if (ship.DockedStationId is not null && ResolveStation(world, ship.DockedStationId)?.AnchorId is { } dockAnchorId)
{
return ResolveAnchor(world, dockAnchorId);
}
return world.Anchors
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ResolveShipSystemPosition(world, ship)))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship)
{
if (ship.SpatialState.CurrentCelestialId is not null)
if (ship.SpatialState.CurrentAnchorId is not null && ResolveAnchorBackedCelestial(world, ship.SpatialState.CurrentAnchorId) is { } currentAnchorCelestial)
{
return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId);
return currentAnchorCelestial;
}
return world.Celestials
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
.OrderBy(candidate => candidate.Position.DistanceTo(ResolveShipSystemPosition(world, ship)))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) =>
world.Celestials.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star);
private static AnchorRuntime? ResolveSystemEntryAnchor(SimulationWorld world, string systemId) =>
world.Anchors.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star);
private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) =>
world.Systems.FirstOrDefault(candidate => candidate.Definition.Id == systemId)?.Position ?? Vector3.Zero;
private static Vector3 ResolveAnchorPosition(SimulationWorld world, string? anchorId, Vector3 fallbackPosition) =>
ResolveAnchor(world, anchorId)?.Position ?? fallbackPosition;
private static Vector3 ResolveStationSystemPosition(SimulationWorld world, StationRuntime station)
{
if (station.AnchorId is not null && ResolveAnchor(world, station.AnchorId) is { } anchor)
{
return new Vector3(
anchor.Position.X + station.Position.X,
anchor.Position.Y + station.Position.Y,
anchor.Position.Z + station.Position.Z);
}
return station.Position;
}
private static Vector3 ResolveNodeSystemPosition(SimulationWorld world, ResourceNodeRuntime node)
{
if (ResolveAnchor(world, node.AnchorId) is { } anchor)
{
return new Vector3(
anchor.Position.X + node.Position.X,
anchor.Position.Y + node.Position.Y,
anchor.Position.Z + node.Position.Z);
}
return node.Position;
}
private static Vector3 ResolveShipSystemPosition(SimulationWorld world, ShipRuntime ship)
{
if (ship.SpatialState.SystemPosition is { } systemPosition)
{
return systemPosition;
}
if (ResolveCurrentAnchor(world, ship) is { } anchor)
{
return new Vector3(
anchor.Position.X + ship.Position.X,
anchor.Position.Y + ship.Position.Y,
anchor.Position.Z + ship.Position.Z);
}
return ship.Position;
}
private static float GetLocalTravelSpeed(ShipRuntime ship) =>
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed) * GetSkillFactor(ship.Skills.Navigation);
@@ -183,6 +313,7 @@ public sealed partial class ShipAiService
{
var policy = ResolvePolicy(world, ship.PolicySetId);
var preferredItemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId;
var preferredAnchorId = ship.DefaultBehavior.PreferredAnchorId;
var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange);
var effectiveMiningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination);
string? deniedReason = null;
@@ -194,6 +325,11 @@ public sealed partial class ShipAiService
return false;
}
if (preferredAnchorId is not null && !string.Equals(node.AnchorId, preferredAnchorId, StringComparison.Ordinal))
{
return false;
}
if (!TryCheckSystemAllowed(world, policy, ship.FactionId, node.SystemId, "military", out var reason))
{
deniedReason ??= reason;
@@ -214,7 +350,7 @@ public sealed partial class ShipAiService
+ (effectiveMiningSkill * 10f)
- distancePenalty
- routeRiskPenalty
- node.Position.DistanceTo(ship.Position);
- ResolveNodeSystemPosition(world, node).DistanceTo(ResolveShipSystemPosition(world, ship));
return new MiningOpportunity(node, buyer, score, $"Mine {node.ItemId} for {buyer.Label}");
})
.OrderByDescending(candidate => candidate.Score)
@@ -452,7 +588,7 @@ public sealed partial class ShipAiService
?? homeStation;
}
private static ResourceNodeRuntime? SelectLocalMiningNode(SimulationWorld world, ShipRuntime ship, string systemId, string itemId)
private static ResourceNodeRuntime? SelectLocalMiningNode(SimulationWorld world, ShipRuntime ship, string systemId, string itemId, string? anchorId = null)
{
var policy = ResolvePolicy(world, ship.PolicySetId);
string? deniedReason = null;
@@ -467,6 +603,11 @@ public sealed partial class ShipAiService
return false;
}
if (anchorId is not null && !string.Equals(candidate.AnchorId, anchorId, StringComparison.Ordinal))
{
return false;
}
if (!TryCheckSystemAllowed(world, policy, ship.FactionId, candidate.SystemId, "military", out var reason))
{
deniedReason ??= reason;
@@ -487,6 +628,54 @@ public sealed partial class ShipAiService
return node;
}
private static ResourceDepositRuntime? ResolveResourceDeposit(SimulationWorld world, string? depositId)
{
if (string.IsNullOrWhiteSpace(depositId))
{
return null;
}
foreach (var node in world.Nodes)
{
var deposit = node.Deposits.FirstOrDefault(candidate => string.Equals(candidate.Id, depositId, StringComparison.Ordinal));
if (deposit is not null)
{
return deposit;
}
}
return null;
}
private static ResourceDepositRuntime? SelectMiningDeposit(ResourceNodeRuntime node, string shipId)
{
return node.Deposits
.Where(candidate => candidate.OreRemaining > 0.01f)
.OrderByDescending(candidate => candidate.OreRemaining)
.ThenBy(candidate => candidate.Id, StringComparer.Ordinal)
.FirstOrDefault();
}
private static void SyncNodeOreTotals(ResourceNodeRuntime node)
{
node.OreRemaining = node.Deposits.Sum(candidate => candidate.OreRemaining);
}
private static AnchorRuntime? ResolveMiningAnchor(SimulationWorld world, string? anchorId, string? nodeId)
{
if (anchorId is not null)
{
return ResolveAnchor(world, anchorId);
}
if (nodeId is not null && ResolveNode(world, nodeId) is { } node)
{
return ResolveAnchor(world, node.AnchorId);
}
return null;
}
private static StationRuntime? SelectLocalAutoMineBuyer(SimulationWorld world, ShipRuntime ship, string systemId, string itemId)
{
var policy = ResolvePolicy(world, ship.PolicySetId);
@@ -686,9 +875,14 @@ public sealed partial class ShipAiService
return (celestial.SystemId, celestial.Position);
}
if (ResolveAnchor(world, entityId) is { } anchor)
{
return (anchor.SystemId, anchor.Position);
}
if (world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId) is { } site)
{
var position = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? Vector3.Zero;
var position = ResolveAnchor(world, site.AnchorId)?.Position ?? Vector3.Zero;
return (site.SystemId, position);
}
@@ -720,6 +914,16 @@ public sealed partial class ShipAiService
private static StationRuntime? ResolveStation(SimulationWorld world, string? stationId) =>
stationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == stationId);
private static AnchorRuntime? ResolveAnchor(SimulationWorld world, string? anchorId) =>
anchorId is null ? null : world.Anchors.FirstOrDefault(candidate => candidate.Id == anchorId);
private static CelestialRuntime? ResolveAnchorBackedCelestial(SimulationWorld world, string? anchorId)
{
var anchor = ResolveAnchor(world, anchorId);
var celestialId = SpatialBuilder.ResolveCompatibleCelestialId(anchor);
return celestialId is null ? null : world.Celestials.FirstOrDefault(candidate => candidate.Id == celestialId);
}
private static ResourceNodeRuntime? ResolveNode(SimulationWorld world, string? nodeId) =>
nodeId is null ? null : world.Nodes.FirstOrDefault(candidate => candidate.Id == nodeId);
@@ -815,7 +1019,8 @@ public sealed partial class ShipAiService
if (site?.StationId is null && site is not null)
{
var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position;
var anchorPosition = ResolveAnchor(world, site.AnchorId)?.Position
?? station.Position;
return GetResourceHoldPosition(anchorPosition, ship.Id, 78f);
}
@@ -867,7 +1072,7 @@ public sealed partial class ShipAiService
private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site)
{
var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
var anchor = ResolveAnchor(world, site.AnchorId);
if (anchor is null || site.BlueprintId is null)
{
site.State = ConstructionSiteStateKinds.Destroyed;
@@ -878,13 +1083,13 @@ public sealed partial class ShipAiService
{
Id = $"station-{world.Stations.Count + 1}",
SystemId = site.SystemId,
AnchorId = site.AnchorId,
Label = BuildFoundedStationLabel(site.TargetDefinitionId),
Category = "station",
Objective = DetermineFoundationObjective(site.TargetDefinitionId),
Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color,
Position = anchor.Position,
Position = Vector3.Zero,
FactionId = site.FactionId,
CelestialId = site.CelestialId,
Health = 600f,
MaxHealth = 600f,
};

View File

@@ -128,11 +128,11 @@ public sealed partial class ShipAiService
CreateStep("step-construction-deliver", "deliver-materials", $"Deliver materials to {site.Id}",
[
CreateSubTask("sub-construction-travel", ShipTaskKinds.Travel, $"Travel to support station {supportStation.Label}", supportStation.SystemId, targetPosition, supportStation.Id, MathF.Max(supportStation.Radius + 18f, 18f), 0f),
CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f)
CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f)
]),
CreateStep("step-construction-build", "build-site", $"Build {site.Id}",
[
CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f)
CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f)
])
]);
}
@@ -301,7 +301,9 @@ public sealed partial class ShipAiService
float amount,
string? itemId = null,
string? moduleId = null,
string? targetNodeId = null) =>
string? targetAnchorId = null,
string? targetResourceNodeId = null,
string? targetResourceDepositId = null) =>
new()
{
Id = id,
@@ -310,7 +312,9 @@ public sealed partial class ShipAiService
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
TargetEntityId = targetEntityId,
TargetNodeId = targetNodeId,
TargetAnchorId = targetAnchorId,
TargetResourceNodeId = targetResourceNodeId,
TargetResourceDepositId = targetResourceDepositId,
ItemId = itemId,
ModuleId = moduleId,
Threshold = threshold,

View File

@@ -171,7 +171,8 @@ public sealed partial class ShipAiService
return null;
}
var node = ResolveNode(world, order.NodeId);
var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId);
var node = ResolveNode(world, order.TargetEntityId);
if (node is not null)
{
if (!string.Equals(node.SystemId, systemId, StringComparison.Ordinal))
@@ -188,7 +189,7 @@ public sealed partial class ShipAiService
}
else
{
node = SelectLocalMiningNode(world, ship, systemId, itemId);
node = SelectLocalMiningNode(world, ship, systemId, itemId, anchor?.Id);
}
if (node is null)
@@ -197,24 +198,30 @@ public sealed partial class ShipAiService
return null;
}
return BuildLocalMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {itemId} in {systemId}");
return BuildLocalMiningPlan(world, ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {itemId} in {systemId}");
}
private ShipPlanRuntime? BuildMineLocalOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var node = ResolveNode(world, order.NodeId);
var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId);
var node = ResolveNode(world, order.TargetEntityId)
?? SelectLocalMiningNode(world, ship, order.TargetSystemId ?? ship.SystemId, order.ItemId ?? ship.DefaultBehavior.ItemId ?? string.Empty, anchor?.Id);
if (node is null)
{
order.FailureReason = "mine-order-incomplete";
return null;
}
return BuildLocalMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {node.ItemId}");
return BuildLocalMiningPlan(world, ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {node.ItemId}");
}
private ShipPlanRuntime? BuildMineAndDeliverRunOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var node = ResolveNode(world, order.NodeId);
var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId);
var node = ResolveNode(world, order.TargetEntityId)
?? (string.IsNullOrWhiteSpace(order.ItemId)
? null
: SelectLocalMiningNode(world, ship, order.TargetSystemId ?? ship.SystemId, order.ItemId, anchor?.Id));
var buyer = ResolveStation(world, order.DestinationStationId);
if (node is null || buyer is null)
{
@@ -222,7 +229,7 @@ public sealed partial class ShipAiService
return null;
}
return BuildMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, buyer, order.Label ?? $"Mine {node.ItemId} for {buyer.Label}");
return BuildMiningPlan(world, ship, AiPlanSourceKind.Order, order.Id, node, buyer, order.Label ?? $"Mine {node.ItemId} for {buyer.Label}");
}
private ShipPlanRuntime? BuildSellMinedCargoOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
@@ -396,9 +403,10 @@ public sealed partial class ShipAiService
return BuildFollowShipPlan(ship, AiPlanSourceKind.Order, order.Id, targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Name}");
}
private ShipPlanRuntime BuildMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, StationRuntime homeStation, string summary)
private ShipPlanRuntime BuildMiningPlan(SimulationWorld world, ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, StationRuntime homeStation, string summary)
{
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
var deposit = SelectMiningDeposit(node, ship.Id);
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
return CreatePlan(
ship,
sourceKind,
@@ -408,8 +416,8 @@ public sealed partial class ShipAiService
[
CreateStep("step-mine", "mine", $"Mine {node.ItemId}",
[
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} node", node.SystemId, extractionPosition, node.Id, 8f, 0f),
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity())
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId),
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id)
]),
CreateStep("step-deliver", "deliver", $"Deliver {node.ItemId} to {homeStation.Label}",
[
@@ -421,9 +429,10 @@ public sealed partial class ShipAiService
]);
}
private ShipPlanRuntime BuildLocalMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, string summary)
private ShipPlanRuntime BuildLocalMiningPlan(SimulationWorld world, ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, string summary)
{
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
var deposit = SelectMiningDeposit(node, ship.Id);
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
return CreatePlan(
ship,
sourceKind,
@@ -433,8 +442,8 @@ public sealed partial class ShipAiService
[
CreateStep("step-mine", "mine", $"Mine {node.ItemId}",
[
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} node", node.SystemId, extractionPosition, node.Id, 8f, 0f),
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId)
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId),
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id)
])
]);
}

View File

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

View File

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

View File

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

View File

@@ -104,12 +104,12 @@ internal sealed class SimulationEngine
CreateWreck(world, "station", station.Id, station.SystemId, station.Position, station.MaxHealth * 0.12f);
world.Stations.Remove(station);
if (station.CelestialId is not null && world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId) is { } celestial)
if (station.AnchorId is not null && world.Anchors.FirstOrDefault(candidate => candidate.Id == station.AnchorId) is { } anchor)
{
celestial.OccupyingStructureId = null;
anchor.OccupyingStructureId = null;
}
foreach (var claim in world.Claims.Where(candidate => candidate.CelestialId == station.CelestialId))
foreach (var claim in world.Claims.Where(candidate => candidate.AnchorId == station.AnchorId))
{
claim.Health = 0f;
claim.State = ClaimStateKinds.Destroyed;

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ public sealed class ClaimRuntime
public required string Id { get; init; }
public required string FactionId { get; init; }
public required string SystemId { get; init; }
public required string CelestialId { get; init; }
public required string AnchorId { get; init; }
public string? CommanderId { get; set; }
public DateTimeOffset PlacedAtUtc { get; init; }
public DateTimeOffset ActivatesAtUtc { get; set; }
@@ -19,7 +19,7 @@ public sealed class ConstructionSiteRuntime
public required string Id { get; init; }
public required string FactionId { get; init; }
public required string SystemId { get; init; }
public required string CelestialId { get; init; }
public required string AnchorId { get; init; }
public required string TargetKind { get; init; }
public required string TargetDefinitionId { get; init; }
public string? BlueprintId { get; set; }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using SpaceGame.Api.Universe.Scenario;
namespace SpaceGame.Api.Universe.Simulation;
internal sealed class OrbitalStateUpdater
@@ -223,22 +225,47 @@ internal sealed class OrbitalStateUpdater
foreach (var station in world.Stations)
{
if (station.CelestialId is null || !celestialsById.TryGetValue(station.CelestialId, out var anchorCelestial))
if (station.AnchorId is not null && world.Anchors.Any(candidate => candidate.Id == station.AnchorId))
{
continue;
station.Position = Vector3.Zero;
}
station.Position = anchorCelestial.Position;
}
foreach (var node in world.Nodes)
{
if (node.CelestialId is null || !celestialsById.TryGetValue(node.CelestialId, out var anchorCelestial))
node.Position = ComputeResourceNodeOffset(node, worldTimeSeconds);
}
var nodeAnchorsById = world.Nodes.ToDictionary(node => node.AnchorId, StringComparer.Ordinal);
foreach (var anchor in world.Anchors)
{
if (string.Equals(anchor.SourceEntityKind, "resource-node", StringComparison.Ordinal))
{
if (nodeAnchorsById.TryGetValue(anchor.Id, out var node))
{
if (anchor.ParentAnchorId is not null && celestialsById.TryGetValue(anchor.ParentAnchorId, out var anchorCelestial))
{
anchor.Position = Add(anchorCelestial.Position, node.Position);
}
else
{
anchor.Position = node.Position;
}
anchor.LocalSpaceRadius = node.LocalSpaceRadius;
}
continue;
}
node.Position = Add(anchorCelestial.Position, ComputeResourceNodeOffset(node, worldTimeSeconds));
if (celestialsById.TryGetValue(anchor.Id, out var celestial))
{
anchor.Position = celestial.Position;
anchor.LocalSpaceRadius = celestial.LocalSpaceRadius;
anchor.ParentAnchorId = celestial.ParentAnchorId;
anchor.OccupyingStructureId = celestial.OccupyingStructureId;
anchor.OrbitReferenceId = celestial.OrbitReferenceId;
}
}
foreach (var ship in world.Ships.Where(ship => ship.DockedStationId is not null))
@@ -261,20 +288,29 @@ internal sealed class OrbitalStateUpdater
{
ship.SpatialState.CurrentSystemId = ship.SystemId;
ship.SpatialState.LocalPosition = ship.Position;
ship.SpatialState.SystemPosition = ship.Position;
if (ship.SpatialState.Transit is not null)
{
ship.SpatialState.CurrentCelestialId = null;
ship.SpatialState.CurrentAnchorId = null;
continue;
}
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
var nearestCelestial = world.Celestials
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
ship.SpatialState.CurrentCelestialId = nearestCelestial?.Id;
var currentAnchor = ship.SpatialState.CurrentAnchorId is not null
? world.Anchors.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentAnchorId)
: null;
if (currentAnchor is null || !string.Equals(currentAnchor.SystemId, ship.SystemId, StringComparison.Ordinal))
{
currentAnchor = world.Anchors
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
}
ship.SpatialState.CurrentAnchorId = currentAnchor?.Id;
ship.SpatialState.SystemPosition = currentAnchor is null
? ship.Position
: Add(currentAnchor.Position, ship.Position);
if (ship.DockedStationId is null)
{
@@ -282,9 +318,9 @@ internal sealed class OrbitalStateUpdater
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station?.CelestialId is not null)
if (station is not null)
{
ship.SpatialState.CurrentCelestialId = station.CelestialId;
ship.SpatialState.CurrentAnchorId = station.AnchorId;
}
}
}

View File

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

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
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 ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
import ViewerEntityBrowserPanel from "./components/ViewerEntityBrowserPanel.vue";
@@ -31,8 +31,8 @@ const authStore = useAuthStore();
const playerFactionStore = usePlayerFactionStore();
const automationCatalogStore = useShipAutomationCatalogStore();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
const { canAccessGm, effectivePlayerId } = storeToRefs(authStore);
const { selectedEntityId } = storeToRefs(selectionStore);
const { canAccessGm, effectivePlayerId, isActingAsAlternateIdentity } = storeToRefs(authStore);
const { playerFaction } = storeToRefs(playerFactionStore);
let viewer: GameViewer | undefined;
@@ -42,14 +42,27 @@ const gmSettingsOpen = ref(false);
const gmMenuOpen = ref(false);
const leftSidebarTab = ref<"player" | "entities">("player");
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 () => {
window.addEventListener("pointermove", onWindowPointerMove);
window.addEventListener("pointerup", stopRightSidebarResize);
window.addEventListener("pointercancel", stopRightSidebarResize);
void automationCatalogStore.load();
await refreshPlayerContext();
await startViewerIfAuthenticated();
});
onBeforeUnmount(() => {
window.removeEventListener("pointermove", onWindowPointerMove);
window.removeEventListener("pointerup", stopRightSidebarResize);
window.removeEventListener("pointercancel", stopRightSidebarResize);
viewer?.dispose();
});
@@ -71,7 +84,7 @@ watch(
);
watch(
() => playerFaction.value?.requiresOnboarding ?? false,
() => shouldShowOnboarding.value,
async (requiresOnboarding) => {
if (requiresOnboarding) {
viewer?.dispose();
@@ -101,8 +114,31 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
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() {
if (!authStore.isAuthenticated || viewer || !playerContextReady.value || playerFaction.value?.requiresOnboarding) {
if (!authStore.isAuthenticated || viewer || !playerContextReady.value || shouldShowOnboarding.value) {
return;
}
@@ -155,7 +191,7 @@ async function refreshPlayerContext() {
<p>Loading your in-universe identity and ownership state.</p>
</div>
</div>
<PlayerOnboardingPanel v-else-if="playerContextReady && playerFaction?.requiresOnboarding" />
<PlayerOnboardingPanel v-else-if="shouldShowOnboarding" />
<div v-else class="viewer-app">
<div
ref="canvasHostEl"
@@ -232,27 +268,28 @@ async function refreshPlayerContext() {
</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">
<ViewerEntityInspectorPanel
class="min-h-0 flex-1"
:fallback-title="hudState.detailPanel.title"
:fallback-html="hudState.detailPanel.bodyHtml"
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
/>
<div
class="pointer-events-auto rounded-xl bg-[rgba(255,116,88,0.14)] px-3.5 py-3 text-[#ffd8cf]"
:hidden="hudState.error.hidden"
>
{{ hudState.error.message }}
</div>
<button
v-if="selectedEntityId"
type="button"
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"
@click="selectionStore.clearSelection('ui')"
>
Clear {{ selectedEntityLabel ?? "Selection" }}
</button>
<div class="viewer-right-sidebar-dock" :style="{ width: `${rightSidebarWidth}px` }">
<section class="viewer-right-sidebar pointer-events-auto">
<div
class="viewer-right-sidebar__resize-handle"
:class="rightSidebarResizing ? 'viewer-right-sidebar__resize-handle--active' : ''"
@pointerdown="startRightSidebarResize"
/>
<div class="viewer-right-sidebar__body">
<ViewerEntityInspectorPanel
class="viewer-right-sidebar__panel"
:fallback-title="hudState.detailPanel.title"
:fallback-html="hudState.detailPanel.bodyHtml"
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
/>
</div>
<div
class="viewer-right-sidebar__error"
:hidden="hudState.error.hidden"
>
{{ hudState.error.message }}
</div>
</section>
</div>
<div ref="historyLayerHostEl">

View File

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

View File

@@ -28,7 +28,7 @@ import type {
export interface WorldStreamScope {
scopeKind?: string;
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> {
@@ -105,8 +105,8 @@ export function openWorldStream(
if (scope?.systemId) {
query.set("systemId", scope.systemId);
}
if (scope?.bubbleId) {
query.set("bubbleId", scope.bubbleId);
if (scope?.anchorId) {
query.set("anchorId", scope.anchorId);
}
const stream = new EventSource(`/api/world/stream?${query.toString()}`);

View File

@@ -115,12 +115,13 @@ function formatShipLocation(ship: ShipSnapshot) {
return `Docked ${dockedStation.label}`;
}
if (ship.spatialState.transit?.destinationNodeId) {
return `Transit ${ship.systemId}`;
const transitAnchorId = ship.spatialState.transit?.destinationAnchorId ?? ship.spatialState.destinationAnchorId;
if (transitAnchorId) {
return `Transit ${titleCase(transitAnchorId)}`;
}
if (ship.celestialId) {
return `Orbit ${titleCase(ship.celestialId)}`;
if (ship.spatialState.currentAnchorId) {
return `Anchor ${compactAnchorId(ship.spatialState.currentAnchorId)}`;
}
const system = systemById.value.get(ship.systemId);
@@ -129,13 +130,32 @@ function formatShipLocation(ship: ShipSnapshot) {
function formatStationLocation(station: StationSnapshot) {
const system = systemById.value.get(station.systemId);
if (station.celestialId) {
return `${system?.label ?? station.systemId} · ${titleCase(station.celestialId)}`;
if (station.anchorId) {
return `${system?.label ?? station.systemId} · ${compactAnchorId(station.anchorId)}`;
}
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) {
const travelToken = ship.spatialState.transit ? "TRV" : "";
const dockToken = ship.dockedStationId ? "DCK" : "";

View File

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

View File

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

View File

@@ -149,13 +149,6 @@ function compactRate(value: number | null | undefined) {
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) {
if (value == null || Number.isNaN(value)) return "—";
return `${Math.round(value * 100)}%`;
@@ -281,8 +274,6 @@ type ShipRow = {
plan: string;
step: string;
subtask: string;
cargo: number;
health: number;
};
const shipRows = computed<ShipRow[]>(() =>
@@ -305,8 +296,6 @@ const shipRows = computed<ShipRow[]>(() =>
plan: s.activePlan ? `${titleCaseToken(s.activePlan.kind)} · ${titleCaseToken(s.activePlan.status)}` : "—",
step: currentStep ? `${titleCaseToken(currentStep.kind)} · ${titleCaseToken(currentStep.status)}` : "—",
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("step", { header: "Current Step" }),
shipColumnHelper.accessor("subtask", { header: "SubTask" }),
shipColumnHelper.accessor("cargo", {
header: "Cargo",
cell: (info) => formatCargoAmount(info.getValue()),
}),
shipColumnHelper.accessor("health", { header: "HP" }),
];
const shipFilter = ref("");
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({
get data() { return shipRows.value; },
@@ -373,7 +357,6 @@ type StationRow = {
docked: string;
orders: number;
orderDetails: MarketOrderSnapshot[];
cargo: number;
modules: number;
};
@@ -400,7 +383,6 @@ const stationRows = computed<StationRow[]>(() =>
const order = marketOrderMap.value.get(id);
return order ? [order] : [];
}),
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
modules: s.installedModules.length,
})),
);
@@ -421,16 +403,12 @@ const stationColumns = [
stationColumnHelper.accessor("workforce", { header: "Workforce" }),
stationColumnHelper.accessor("docked", { header: "Docked" }),
stationColumnHelper.accessor("orders", { header: "Orders" }),
stationColumnHelper.accessor("cargo", {
header: "Cargo",
cell: (info) => formatCargoAmount(info.getValue()),
}),
stationColumnHelper.accessor("modules", { header: "Modules" }),
];
const stationFilter = ref("");
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({
get data() { return stationRows.value; },

View File

@@ -251,7 +251,7 @@ const behaviorForm = reactive({
areaSystemId: "",
targetEntityId: "",
itemId: "",
preferredNodeId: "",
preferredAnchorId: "",
preferredConstructionSiteId: "",
preferredModuleId: "",
waitSeconds: 3,
@@ -268,7 +268,7 @@ const orderForm = reactive({
targetEntityId: "",
targetSystemId: "",
itemId: "",
nodeId: "",
anchorId: "",
constructionSiteId: "",
moduleId: "",
waitSeconds: 3,
@@ -344,7 +344,7 @@ watch(selectedShip, (ship) => {
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId;
behaviorForm.targetEntityId = ship.defaultBehavior.targetEntityId ?? "";
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "";
behaviorForm.preferredNodeId = ship.defaultBehavior.preferredNodeId ?? "";
behaviorForm.preferredAnchorId = ship.defaultBehavior.preferredAnchorId ?? "";
behaviorForm.preferredConstructionSiteId = ship.defaultBehavior.preferredConstructionSiteId ?? "";
behaviorForm.preferredModuleId = ship.defaultBehavior.preferredModuleId ?? "";
behaviorForm.waitSeconds = ship.defaultBehavior.waitSeconds;
@@ -484,7 +484,7 @@ async function submitDirective() {
sourceStationId: null,
destinationStationId: null,
itemId: directiveForm.itemId || null,
preferredNodeId: null,
preferredAnchorId: null,
preferredConstructionSiteId: null,
preferredModuleId: null,
priority: directiveForm.priority,
@@ -612,7 +612,7 @@ async function submitDirectBehavior() {
areaSystemId: behaviorForm.areaSystemId || null,
targetEntityId: behaviorForm.targetEntityId || null,
itemId: behaviorForm.itemId || null,
preferredNodeId: behaviorForm.preferredNodeId || null,
preferredAnchorId: behaviorForm.preferredAnchorId || null,
preferredConstructionSiteId: behaviorForm.preferredConstructionSiteId || null,
preferredModuleId: behaviorForm.preferredModuleId || null,
targetPosition: null,
@@ -646,7 +646,7 @@ async function submitDirectOrder() {
sourceStationId: null,
destinationStationId: null,
itemId: orderForm.itemId || null,
nodeId: orderForm.nodeId || null,
anchorId: orderForm.anchorId || null,
constructionSiteId: orderForm.constructionSiteId || null,
moduleId: orderForm.moduleId || null,
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>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>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>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>
@@ -723,7 +723,7 @@ async function submitDirectOrder() {
<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>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>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>

View File

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

View File

@@ -46,26 +46,50 @@ export interface PlanetSnapshot {
hasRing: boolean;
}
export interface ResourceDepositSnapshot {
id: string;
nodeId: string;
anchorId: string;
localPosition: Vector3Dto;
oreRemaining: number;
maxOre: number;
}
export interface ResourceNodeSnapshot {
id: string;
anchorId: string;
systemId: string;
localPosition: Vector3Dto;
celestialId?: string | null;
localSpaceRadius: number;
sourceKind: string;
oreRemaining: number;
maxOre: number;
itemId: string;
deposits: ResourceDepositSnapshot[];
}
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 {
id: string;
systemId: string;
kind: string;
orbitalAnchor: Vector3Dto;
localSpaceRadius: number;
parentNodeId?: string | null;
parentAnchorId?: string | null;
occupyingStructureId?: string | null;
orbitReferenceId?: string | null;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -366,6 +366,74 @@ canvas {
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 {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.8rem;
@@ -1705,6 +1773,62 @@ canvas {
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 {
margin-top: 0.9rem;
font-size: 0.83rem;
@@ -1910,6 +2034,23 @@ canvas {
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 {
top: 96px;
left: 20px;

View File

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

View File

@@ -18,7 +18,7 @@ interface ResolveSelectionPositionParams {
nodeVisuals: Map<string, NodeVisual>;
planetVisuals: PlanetVisual[];
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 {
@@ -47,7 +47,7 @@ interface SeedSystemFocusParams {
nodeVisuals: Map<string, NodeVisual>;
planetVisuals: PlanetVisual[];
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 {
@@ -107,6 +107,7 @@ export function applyPanFromScreenDelta(
delta: THREE.Vector2,
orbitYaw: number,
currentDistance: number,
cameraFovDegrees: number,
povLevel: PovLevel,
activeSystemId: string | undefined,
systemAnchor: THREE.Vector3,
@@ -125,18 +126,19 @@ export function applyPanFromScreenDelta(
const forward = new THREE.Vector3(Math.cos(orbitYaw), 0, Math.sin(orbitYaw));
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) {
const scale = povLevel === "system"
? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.35, KILOMETERS_PER_AU * 6.5)
: THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 1200, 180000);
systemAnchor.addScaledVector(pan, scale);
const systemDisplayToKilometers = 1 / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
systemAnchor.addScaledVector(pan, systemDisplayToKilometers);
return;
}
const galaxyScale = THREE.MathUtils.mapLinear(currentDistance, minimumDistance, maximumDistance, 1800, 22000);
galaxyAnchor.addScaledVector(pan, galaxyScale);
galaxyAnchor.add(pan);
}
export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined {
@@ -235,11 +237,11 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams)
}
if (selection.kind === "claim") {
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") {
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") {
const system = world.systems.get(selection.systemId);

View File

@@ -30,8 +30,14 @@ export function createViewerControllers(host: any) {
claimGroup: host.systemLayer.claimGroup,
constructionSiteGroup: host.systemLayer.constructionSiteGroup,
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,
systemSelectableTargets: host.systemLayer.selectableTargets,
localSelectableTargets: host.localLayer.selectableTargets,
systemVisuals: host.galaxyLayer.systemVisuals,
planetVisuals: host.systemLayer.planetVisuals,
celestialVisuals: host.systemLayer.celestialVisuals,
@@ -40,6 +46,11 @@ export function createViewerControllers(host: any) {
claimVisuals: host.systemLayer.claimVisuals,
constructionSiteVisuals: host.systemLayer.constructionSiteVisuals,
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({
@@ -152,8 +163,9 @@ export function createViewerControllers(host: any) {
applyClaimDeltas: (claims) => sceneDataController.applyClaimDeltas(claims),
applyConstructionSiteDeltas: (sites) => sceneDataController.applyConstructionSiteDeltas(sites),
applyShipDeltas: (ships, tickIntervalMs) => sceneDataController.applyShipDeltas(ships, tickIntervalMs),
refreshLocalLayer: () => sceneDataController.refreshLocalLayer(host.world, host.resolveFocusedAnchorId()),
refreshHistoryWindows: () => host.refreshHistoryWindows(),
resolveFocusedCelestialId: () => host.resolveFocusedCelestialId(),
resolveFocusedAnchorId: () => host.resolveFocusedAnchorId(),
updateSystemSummaries: () => host.updateSystemSummaries(),
applyZoomPresentation: () => presentationController.applyZoomPresentation(),
updateNetworkPanel: () => presentationController.updateNetworkPanel(),
@@ -191,8 +203,10 @@ export function createViewerControllers(host: any) {
mouse: host.mouse,
galaxyCamera: host.galaxyLayer.camera,
systemCamera: host.systemLayer.camera,
localCamera: host.localLayer.camera,
galaxySelectableTargets: host.galaxyLayer.selectableTargets,
systemSelectableTargets: host.systemLayer.selectableTargets,
localSelectableTargets: host.localLayer.selectableTargets,
hoverLabelEl: host.hoverLabelEl,
hoverConnectorLineEl: host.hoverConnectorLineEl,
marqueeEl: host.marqueeEl,
@@ -244,6 +258,9 @@ export function createViewerControllers(host: any) {
delta,
host.orbitYaw,
host.currentDistance,
host.activeSystemId
? (host.povLevel === "local" ? host.localLayer.camera.fov : host.systemLayer.camera.fov)
: host.galaxyLayer.camera.fov,
host.povLevel,
host.activeSystemId,
host.systemAnchor,

View File

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

View File

@@ -36,14 +36,17 @@ export function pickSelectableAtClientPosition(
renderer: THREE.WebGLRenderer,
raycaster: THREE.Raycaster,
mouse: THREE.Vector2,
povLevel: PovLevel,
galaxyCamera: THREE.Camera,
galaxySelectableTargets: Map<THREE.Object3D, Selectable>,
systemCamera: THREE.Camera,
systemSelectableTargets: Map<THREE.Object3D, Selectable>,
localCamera: THREE.Camera,
localSelectableTargets: Map<THREE.Object3D, Selectable>,
clientX: 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;
}
@@ -51,13 +54,23 @@ export function pickSelectableHitAtClientPosition(
renderer: THREE.WebGLRenderer,
raycaster: THREE.Raycaster,
mouse: THREE.Vector2,
povLevel: PovLevel,
galaxyCamera: THREE.Camera,
galaxySelectableTargets: Map<THREE.Object3D, Selectable>,
systemCamera: THREE.Camera,
systemSelectableTargets: Map<THREE.Object3D, Selectable>,
localCamera: THREE.Camera,
localSelectableTargets: Map<THREE.Object3D, Selectable>,
clientX: number,
clientY: number,
): 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)
const systemHit = pickOneCamera(renderer, raycaster, mouse, systemCamera, systemSelectableTargets, clientX, clientY);
if (systemHit) {

View File

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

View File

@@ -1,17 +1,50 @@
import * as THREE from "three";
import type {
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
Selectable,
ShipVisual,
StructureVisual,
} from "./viewerTypes";
/**
* Local rendering layer.
* Scene coordinate unit: reserved for future close-up detail.
* Camera far plane covers immediate surroundings.
* Currently empty — populated when local-space objects are introduced.
*/
export class LocalLayer {
readonly scene = new THREE.Scene();
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);
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) {
this.camera.position.copy(orbitOffset);
this.camera.lookAt(LocalLayer.ORIGIN);

View File

@@ -1,4 +1,5 @@
import * as THREE from "three";
import { scaleGalaxyVector, toThreeVector } from "./viewerMath";
import {
determineActiveSystemId,
focusOnSelection,
@@ -62,6 +63,22 @@ export interface ViewerNavigationContext {
export class ViewerNavigationController {
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({
world: this.context.getWorld(),
@@ -70,7 +87,7 @@ export class ViewerNavigationController {
nodeVisuals: this.context.nodeVisuals,
planetVisuals: this.context.planetVisuals,
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(),
galaxyAnchor: this.context.galaxyAnchor,
systemAnchor: this.context.systemAnchor,
@@ -85,7 +102,7 @@ export class ViewerNavigationController {
nodeVisuals: this.context.nodeVisuals,
planetVisuals: this.context.planetVisuals,
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,
planetVisuals: this.context.planetVisuals,
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>(
(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 {
CameraMode,
NodeVisual,
@@ -461,7 +461,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
title: `${celestial.kind} celestial`,
bodyHtml: `
<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>Local space radius ${celestial.localSpaceRadius.toFixed(0)} km</p>
`,
@@ -477,7 +477,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
title: `Claim ${claim.id}`,
bodyHtml: `
<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>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p>
`,
@@ -494,7 +494,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
title: `Construction ${site.id}`,
bodyHtml: `
<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>State ${site.state}<br>Progress ${(site.progress * 100).toFixed(0)}%</p>
<p>Orders ${orderCount}<br>Assigned constructors ${site.assignedConstructorShipIds.length}</p>
@@ -608,24 +608,33 @@ export function describeSelectionParent(
return "unknown";
}
return station.celestialId
? describeCelestialPathWithinSystem(world, station.systemId, station.celestialId) ?? `${station.systemId} network`
return station.anchorId
? describeAnchorPathWithinSystem(world, station.systemId, station.anchorId) ?? `${station.systemId} network`
: "unknown";
}
if (selection.kind === "node") {
const node = world.nodes.get(selection.id);
const visual = node ? nodeVisuals.get(selection.id) : undefined;
return describeOrbitalParent(world, node?.systemId, visual?.anchor);
return node
? describeAnchorPathWithinSystem(world, node.systemId, node.anchorId) ?? node.anchorId
: "unknown";
}
if (selection.kind === "celestial") {
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") {
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") {
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";

View File

@@ -14,6 +14,16 @@ import {
syncShips as syncShipScene,
syncStations as syncStationScene,
} from "./viewerSceneSync";
import {
createClaimMesh,
createConstructionSiteMesh,
createNodeMesh,
createResourceDepositMesh,
createShipMesh,
createShipTacticalIcon,
createStationMesh,
createTacticalIcon,
} from "./viewerSceneFactory";
import {
deriveNodeOrbital,
deriveOrbitalFromLocalPosition,
@@ -43,7 +53,7 @@ import type {
SystemSnapshot,
} from "./contracts";
import type { OrbitalAnchor, Selectable } from "./viewerTypes";
import { rawObject } from "./viewerScenePrimitives";
import { rawObject, registerSelectableTarget } from "./viewerScenePrimitives";
export interface ViewerSceneDataContext {
documentRef: Document;
@@ -61,8 +71,14 @@ export interface ViewerSceneDataContext {
claimGroup: THREE.Group;
constructionSiteGroup: 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>;
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
localSelectableTargets: Map<THREE.Object3D, Selectable>;
systemVisuals: Map<any, any>;
planetVisuals: any[];
celestialVisuals: Map<any, any>;
@@ -71,6 +87,11 @@ export interface ViewerSceneDataContext {
claimVisuals: Map<any, any>;
constructionSiteVisuals: 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 {
@@ -136,6 +157,162 @@ export class ViewerSceneDataController {
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
* and updates visibility of all system-filtered objects.
@@ -202,6 +379,7 @@ export class ViewerSceneDataController {
createWorldPresentationContext(overrides: {
world: any;
activeSystemId?: string;
focusedAnchorId?: string;
cameraMode: any;
povLevel: any;
orbitYaw: number;
@@ -215,17 +393,23 @@ export class ViewerSceneDataController {
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
worldSeed: this.context.getWorldSeed(),
activeSystemId: overrides.activeSystemId,
focusedAnchorId: overrides.focusedAnchorId,
cameraMode: overrides.cameraMode,
povLevel: overrides.povLevel,
orbitYaw: overrides.orbitYaw,
camera: overrides.systemCamera,
systemAnchor: overrides.systemAnchor,
shipVisuals: this.context.shipVisuals,
localShipVisuals: this.context.localShipVisuals,
nodeVisuals: this.context.nodeVisuals,
localNodeVisuals: this.context.localNodeVisuals,
celestialVisuals: this.context.celestialVisuals,
stationVisuals: this.context.stationVisuals,
localStationVisuals: this.context.localStationVisuals,
claimVisuals: this.context.claimVisuals,
localClaimVisuals: this.context.localClaimVisuals,
constructionSiteVisuals: this.context.constructionSiteVisuals,
localConstructionSiteVisuals: this.context.localConstructionSiteVisuals,
systemVisuals: this.context.systemVisuals,
systemSummaryVisuals: new Map(),
toDisplayLocalPosition: overrides.toDisplayLocalPosition,
@@ -248,8 +432,14 @@ export class ViewerSceneDataController {
claimGroup: this.context.claimGroup,
constructionSiteGroup: this.context.constructionSiteGroup,
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,
systemSelectableTargets: this.context.systemSelectableTargets,
localSelectableTargets: this.context.localSelectableTargets,
systemVisuals: this.context.systemVisuals,
planetVisuals: this.context.planetVisuals,
celestialVisuals: this.context.celestialVisuals,
@@ -258,12 +448,17 @@ export class ViewerSceneDataController {
claimVisuals: this.context.claimVisuals,
constructionSiteVisuals: this.context.constructionSiteVisuals,
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,
shipLength,
shipPresentationColor,
celestialColor,
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),
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),

View File

@@ -11,6 +11,7 @@ import type {
ConstructionSiteSnapshot,
MoonSnapshot,
PlanetSnapshot,
ResourceDepositSnapshot,
ResourceNodeSnapshot,
ShipSnapshot,
StationSnapshot,
@@ -46,6 +47,23 @@ export function createNodeMesh(node: ResourceNodeSnapshot): SceneNode {
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 {
const color = celestialColor(node.kind);
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);
}
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 {
documentRef: Document;
worldOrbitalTimeSeconds?: number;
@@ -98,7 +110,7 @@ interface SceneSyncContext {
shipPresentationColor: (ship: ShipSnapshot) => string;
celestialColor: (kind: string) => string;
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"];
deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: NodeVisual["anchor"]) => {
radius: number;
@@ -250,7 +262,8 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
const mesh = createNodeMesh(node);
const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 100);
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);
icon.setPosition(displayPos);
const isActive = node.systemId === activeSystemId;
@@ -260,6 +273,7 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
const orbital = context.deriveNodeOrbital(node, anchor);
context.nodeVisuals.set(node.id, {
systemId: node.systemId,
anchorId: node.anchorId,
mesh,
icon,
sourceKind: node.sourceKind,
@@ -283,7 +297,8 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
const mesh = createStationMesh(station);
const icon = createTacticalIcon(context.documentRef, station.color, 130);
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);
icon.setPosition(displayPos);
const isActive = station.systemId === activeSystemId;
@@ -294,6 +309,7 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
context.stationVisuals.set(station.id, {
id: station.id,
systemId: station.systemId,
anchorId: station.anchorId,
mesh,
icon,
anchor,
@@ -313,7 +329,7 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[], a
context.claimVisuals.clear();
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 mesh = createClaimMesh(claim);
const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 90);
@@ -324,7 +340,7 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[], a
icon.setVisible(isActive);
context.claimVisuals.set(claim.id, {
id: claim.id,
celestialId: claim.celestialId,
anchorId: claim.anchorId,
systemId: claim.systemId,
mesh,
icon,
@@ -341,7 +357,7 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
context.constructionSiteVisuals.clear();
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 mesh = createConstructionSiteMesh(site);
const icon = createTacticalIcon(context.documentRef, "#9df29c", 90);
@@ -352,7 +368,7 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
icon.setVisible(isActive);
context.constructionSiteVisuals.set(site.id, {
id: site.id,
celestialId: site.celestialId,
anchorId: site.anchorId,
systemId: site.systemId,
mesh,
icon,
@@ -374,7 +390,7 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick
const iconBaseScale = 78;
const icon = createShipTacticalIcon(context.documentRef, shipColor, iconBaseScale);
const localPosition = toThreeVector(ship.localPosition);
const displayPos = toSystemPos(localPosition);
const displayPos = toSystemPos(resolveShipSystemPosition(ship, context));
mesh.setPosition(displayPos);
icon.setPosition(displayPos);
icon.setColor(shipColor);
@@ -386,6 +402,7 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick
registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "ship", id: ship.id });
context.shipVisuals.set(ship.id, {
systemId: ship.systemId,
anchorId: ship.anchorId ?? ship.spatialState.currentAnchorId ?? undefined,
mesh,
icon,
iconBaseScale,
@@ -430,6 +447,7 @@ export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDe
}
visual.systemId = node.systemId;
visual.anchorId = node.anchorId;
visual.sourceKind = node.sourceKind;
visual.localPosition.copy(toThreeVector(node.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.anchorId = station.anchorId;
visual.localPosition.copy(toThreeVector(station.localPosition));
visual.anchor = context.resolveOrbitalAnchor(station.systemId, visual.localPosition);
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.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);
visual.mesh.setPosition(displayPos);
visual.icon.setPosition(displayPos);
@@ -494,7 +514,8 @@ export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: Co
}
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);
visual.mesh.setPosition(displayPos);
visual.icon.setPosition(displayPos);
@@ -514,6 +535,7 @@ export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], t
}
visual.systemId = ship.systemId;
visual.anchorId = ship.anchorId ?? ship.spatialState.currentAnchorId ?? undefined;
visual.startPosition.copy(getAnimatedShipLocalPosition(visual));
visual.authoritativePosition.copy(toThreeVector(ship.localPosition));
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;
}
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") {
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") {
return `claim ${item.id}`;
@@ -113,9 +120,7 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab
return item.id;
}
const anchorPath = node.celestialId
? describeCelestialPathWithinSystem(world, node.systemId, node.celestialId)
: undefined;
const anchorPath = describeAnchorPathWithinSystem(world, node.systemId, node.anchorId);
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") {
const claim = world.claims.get(item.id);
const anchorPath = claim?.celestialId
? describeCelestialPathWithinSystem(world, claim.systemId, claim.celestialId)
const anchorPath = claim
? describeAnchorPathWithinSystem(world, claim.systemId, claim.anchorId)
: undefined;
return anchorPath ? `${anchorPath} claim` : `Claim ${item.id}`;
}
if (item.kind === "construction-site") {
const site = world.constructionSites.get(item.id);
const anchorPath = site?.celestialId
? describeCelestialPathWithinSystem(world, site.systemId, site.celestialId)
const anchorPath = site
? describeAnchorPathWithinSystem(world, site.systemId, site.anchorId)
: undefined;
const siteLabel = site ? (site.blueprintId ?? site.targetDefinitionId) : item.id;
return anchorPath ? `${anchorPath} / ${siteLabel}` : `Construction ${siteLabel}`;
@@ -210,20 +215,74 @@ export function resolveFocusedCelestialId(world: WorldState | undefined, selecte
return selected.id;
}
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") {
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") {
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") {
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;
}
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 {
if (!world || !systemId) {
return "unknown";
@@ -330,23 +389,31 @@ export function describeShipState(world: WorldState | undefined, ship: ShipSnaps
return baseState;
}
const destinationNodeId = ship.spatialState.destinationNodeId ?? ship.spatialState.transit?.destinationNodeId;
if (!destinationNodeId) {
const destinationAnchorId = ship.spatialState.destinationAnchorId ?? ship.spatialState.transit?.destinationAnchorId;
if (!destinationAnchorId) {
return baseState;
}
const destinationCelestial = world.celestials.get(destinationNodeId);
if (!destinationCelestial) {
return `${baseState} -> ${destinationNodeId}`;
}
const destinationAnchor = destinationAnchorId ? world.anchors.get(destinationAnchorId) : undefined;
if (baseState === "warping" || baseState === "spooling-warp") {
const destinationPath = describeCelestialPathWithinSystem(world, destinationCelestial.systemId, destinationNodeId);
return `${baseState} -> ${destinationPath ?? destinationNodeId}`;
const destinationSystemId = destinationAnchor?.systemId ?? ship.spatialState.currentSystemId ?? ship.systemId;
const destinationPath = describeAnchorPathWithinSystem(
world,
destinationSystemId,
destinationAnchorId,
);
return `${baseState} -> ${destinationPath ?? destinationAnchorId}`;
}
const destinationSystem = world.systems.get(destinationCelestial.systemId);
return `${baseState} -> ${destinationSystem?.label ?? destinationCelestial.systemId}`;
const destinationSystemId = destinationAnchor?.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 {
@@ -406,8 +473,8 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn
if (ship.dockedStationId) {
const station = world.stations.get(ship.dockedStationId);
if (station) {
const anchorPath = station.celestialId
? describeCelestialPathWithinSystem(world, station.systemId, station.celestialId)
const anchorPath = station.anchorId
? describeAnchorPathWithinSystem(world, station.systemId, station.anchorId)
: undefined;
return {
system: systemLabel,
@@ -416,11 +483,11 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn
}
}
const currentCelestialId = ship.spatialState.currentCelestialId ?? ship.celestialId;
if (currentCelestialId) {
const celestialPath = describeCelestialPathWithinSystem(world, systemId, currentCelestialId);
if (celestialPath) {
return { system: systemLabel, local: celestialPath };
const currentAnchorId = ship.spatialState.currentAnchorId ?? ship.anchorId;
if (currentAnchorId) {
const anchorPath = describeAnchorPathWithinSystem(world, systemId, currentAnchorId);
if (anchorPath) {
return { system: systemLabel, local: anchorPath };
}
}
@@ -446,9 +513,9 @@ export function describeActiveSpace(
return activeSystem.label;
}
const celestialId = resolveFocusedCelestialId(world, selectedItems);
if (celestialId) {
const localPath = describeCelestialPathWithinSystem(world, activeSystem.id, celestialId);
const anchorId = resolveFocusedAnchorId(world, selectedItems);
if (anchorId) {
const localPath = describeAnchorPathWithinSystem(world, activeSystem.id, anchorId);
return localPath
? `${activeSystem.label} / ${localPath}`
: activeSystem.label;
@@ -472,10 +539,9 @@ export function describeCelestialPathWithinSystem(world: WorldState, systemId: s
return undefined;
}
if (celestial.parentNodeId) {
const parentPath = describeCelestialPathWithinSystem(world, systemId, celestial.parentNodeId);
const segment = describeCelestialSegment(system, celestial);
return parentPath ? `${parentPath}/${segment}` : segment;
const anchorId = resolveAnchorIdForCelestial(world, celestialId);
if (anchorId) {
return describeAnchorPathWithinSystem(world, systemId, anchorId);
}
if (celestial.kind === "star") {
@@ -485,6 +551,60 @@ export function describeCelestialPathWithinSystem(world: WorldState, systemId: s
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 {
const moonMatch = celestial.id.match(/-planet-(\d+)-moon-(\d+)$/);
if (moonMatch) {

View File

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

View File

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

View File

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

View File

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