Compare commits

...

4 Commits

103 changed files with 5037 additions and 2627 deletions

View File

@@ -3,4 +3,8 @@
<Folder Name="/apps/backend/">
<Project Path="apps/backend/SpaceGame.Api.csproj" />
</Folder>
<Folder Name="/tests/" />
<Folder Name="/tests/backend/">
<Project Path="tests/backend/SpaceGame.Api.Tests.csproj" />
</Folder>
</Solution>

View File

@@ -559,6 +559,9 @@ public sealed class ShipCargoDefinition
public sealed class ScenarioDefinition
{
public required WorldGenerationOptions WorldGeneration { get; set; }
// Temporary QA escape hatch so a scenario can pin an exact topology.
// Do not treat this as the long-term world authoring model.
public List<SolarSystemDefinition>? Systems { get; set; }
public required List<InitialStationDefinition> InitialStations { get; set; }
public required List<ShipFormationDefinition> ShipFormations { get; set; }
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }

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)
@@ -2834,7 +2834,7 @@ internal sealed class CommanderPlanningService
TargetEntityId = objective.TargetEntityId,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
DestinationStationId = objective.BehaviorKind == DockAndWait ? objective.TargetEntityId : null,
DestinationStationId = objective.BehaviorKind == DockAtStation ? objective.TargetEntityId : null,
ItemId = objective.ItemId,
WaitSeconds = 0f,
Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f),
@@ -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;
@@ -2873,13 +2874,13 @@ internal sealed class CommanderPlanningService
private static bool ReconcileAiOrders(ShipRuntime ship, ShipOrderRuntime? desiredOrder)
{
var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal) && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))) > 0;
var changed = ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal) && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))) > 0;
if (desiredOrder is null)
{
return changed;
}
var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal));
var existing = ship.OrderQueue.FindById(desiredOrder.Id);
if (existing is not null)
{
if (ShipOrdersEqual(existing, desiredOrder))
@@ -2887,18 +2888,18 @@ internal sealed class CommanderPlanningService
return changed;
}
ship.OrderQueue.Remove(existing);
changed = true;
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
return true;
}
if (ship.OrderQueue.Count >= MaxAiOrdersPerShip)
{
changed |= ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0;
changed |= ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0;
}
if (ship.OrderQueue.Count < 8)
if (ship.OrderQueue.Count < ShipOrderQueue.MaxOrders)
{
ship.OrderQueue.Add(desiredOrder);
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
changed = true;
}
@@ -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),
@@ -672,12 +672,7 @@ internal sealed class PlayerFactionService
return null;
}
if (ship.OrderQueue.Count >= 8)
{
throw new InvalidOperationException("Order queue is full.");
}
ship.OrderQueue.Add(new ShipOrderRuntime
ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime
{
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
Kind = request.Kind,
@@ -692,7 +687,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),
@@ -704,12 +699,7 @@ internal sealed class PlayerFactionService
AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Name}.", "ship", shipId);
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
ship.ControlSourceKind = "player-order";
ship.ControlSourceId = ship.OrderQueue
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
ship.ControlReason = request.Label ?? request.Kind;
ship.NeedsReplan = true;
ship.LastReplanReason = "player-order-enqueued";
@@ -731,28 +721,18 @@ internal sealed class PlayerFactionService
return null;
}
var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId);
if (removed > 0)
var removed = ship.OrderQueue.RemoveById(orderId);
if (removed)
{
AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Name}.", "ship", shipId);
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
? "player-order"
: "player-manual";
ship.ControlSourceId = ship.OrderQueue
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
ship.ControlReason = ship.OrderQueue
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Label ?? order.Kind)
.FirstOrDefault()
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
?? "manual-player-control";
ship.NeedsReplan = true;
ship.LastReplanReason = "player-order-removed";
@@ -760,6 +740,93 @@ internal sealed class PlayerFactionService
return ship;
}
internal ShipRuntime? UpdateDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId, ShipOrderUpdateCommandRequest request)
{
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
}
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
if (ship is null)
{
return null;
}
var order = ship.OrderQueue.FindById(orderId);
if (order is null || order.SourceKind != ShipOrderSourceKind.Player)
{
return null;
}
order.Priority = request.Priority;
order.InterruptCurrentPlan = request.InterruptCurrentPlan;
order.Label = request.Label;
order.TargetEntityId = request.TargetEntityId;
order.TargetSystemId = request.TargetSystemId;
order.TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z);
order.SourceStationId = request.SourceStationId;
order.DestinationStationId = request.DestinationStationId;
order.ItemId = request.ItemId;
order.AnchorId = request.AnchorId;
order.ConstructionSiteId = request.ConstructionSiteId;
order.ModuleId = request.ModuleId;
order.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f);
order.Radius = MathF.Max(0f, request.Radius ?? 0f);
order.MaxSystemRange = request.MaxSystemRange;
order.KnownStationsOnly = request.KnownStationsOnly ?? false;
order.Status = OrderStatus.Queued;
order.FailureReason = null;
AddDecision(player, "ship-order-updated", $"Updated order {orderId} on {ship.Definition.Name}.", "ship", shipId);
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
? "player-order"
: "player-manual";
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
?? request.Label
?? request.Kind;
ship.NeedsReplan = true;
ship.LastReplanReason = "player-order-updated";
ship.LastDeltaSignature = string.Empty;
return ship;
}
internal ShipRuntime? ReorderDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId, int targetIndex)
{
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
}
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
if (ship is null)
{
return null;
}
if (!ship.OrderQueue.TryMovePlayerOrder(orderId, targetIndex))
{
return ship;
}
AddDecision(player, "ship-order-reordered", $"Reordered order {orderId} on {ship.Definition.Name}.", "ship", shipId);
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
? "player-order"
: "player-manual";
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
?? "manual-player-control";
ship.NeedsReplan = true;
ship.LastReplanReason = "player-order-reordered";
ship.LastDeltaSignature = string.Empty;
return ship;
}
internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipDefaultBehaviorCommandRequest request)
{
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
@@ -805,7 +872,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 +898,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),
@@ -1321,25 +1388,15 @@ internal sealed class PlayerFactionService
? "player-directive"
: automation is not null
? "player-automation"
: ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
: ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
? "player-order"
: "player-manual";
var desiredControlSourceId = directive?.Id
?? automation?.Id
?? ship.OrderQueue
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
?? ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
var desiredControlReason = directive?.Label
?? automation?.Label
?? ship.OrderQueue
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Label ?? order.Kind)
.FirstOrDefault()
?? ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
?? (hasBehaviorSource ? "delegated-player-control" : "manual-player-control");
var assignmentChanged = !AssignmentsEqual(commander.Assignment, desiredAssignment);
@@ -1418,7 +1475,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,
@@ -1438,7 +1495,7 @@ internal sealed class PlayerFactionService
private static bool ReconcileDirectiveOrders(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation)
{
var aiOrderId = directive is null ? null : $"player-order-{directive.Id}";
var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("player-order-", StringComparison.Ordinal) && order.Id != aiOrderId) > 0;
var changed = ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("player-order-", StringComparison.Ordinal) && order.Id != aiOrderId) > 0;
var useOrders = directive?.UseOrders ?? automation?.UseOrders ?? false;
if (!useOrders || directive is null || string.IsNullOrWhiteSpace(directive.StagingOrderKind))
@@ -1461,7 +1518,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,
@@ -1470,17 +1527,16 @@ internal sealed class PlayerFactionService
KnownStationsOnly = directive.KnownStationsOnly,
};
var existing = ship.OrderQueue.FirstOrDefault(order => order.Id == aiOrderId);
var existing = ship.OrderQueue.FindById(aiOrderId!);
if (existing is null)
{
ship.OrderQueue.Add(desiredOrder);
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
return true;
}
if (!ShipOrdersEqual(existing, desiredOrder))
{
ship.OrderQueue.Remove(existing);
ship.OrderQueue.Add(desiredOrder);
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
return true;
}
@@ -1525,7 +1581,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 +1602,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 +1623,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 +1645,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 +1690,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,7 +6,7 @@ using Microsoft.IdentityModel.Tokens;
using Npgsql;
using SpaceGame.Api.Universe.Bootstrap;
const string StartupScenarioPath = "scenarios/empty.json";
const string StartupScenarioPath = "scenarios/minimal.json";
var builder = WebApplication.CreateBuilder(args);

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("SpaceGame.Api.Tests")]

View File

@@ -34,8 +34,8 @@ public static class ShipBehaviorKinds
public const string AdvancedAutoMine = "advanced-auto-mine";
public const string ExpertAutoMine = "expert-auto-mine";
public const string DockAndWait = "dock-and-wait";
public const string FlyAndWait = "fly-and-wait";
public const string DockAtStation = "dock-at-station";
public const string Move = "move";
public const string FlyToObject = "fly-to-object";
public const string FollowShip = "follow-ship";
public const string HoldPosition = "hold-position";
@@ -60,29 +60,29 @@ public static class ShipAutomationCatalog
{
public static readonly IReadOnlyList<ShipBehaviorDefinition> Behaviors =
[
new(ShipBehaviorKinds.Patrol, "Patrol", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait orders from the active patrol context."),
new(ShipBehaviorKinds.Patrol, "Patrol", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or move orders from the active patrol context."),
new(ShipBehaviorKinds.Police, "Police", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or follow-ship inspection orders from the active policing context."),
new(ShipBehaviorKinds.ProtectPosition, "Protect Position", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait orders from the defended position context."),
new(ShipBehaviorKinds.ProtectPosition, "Protect Position", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or move orders from the defended position context."),
new(ShipBehaviorKinds.ProtectShip, "Protect Ship", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or follow-ship escort orders from the guarded ship context."),
new(ShipBehaviorKinds.ProtectStation, "Protect Station", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait guard orders from the defended station context."),
new(ShipBehaviorKinds.ProtectStation, "Protect Station", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or move guard orders from the defended station context."),
new(ShipBehaviorKinds.LocalAutoMine, "Local AutoMine", "Mining", ShipAutomationSupportStatus.PartiallySupported, "Queue-backed for solo mining; broader order-generation model still in progress."),
new(ShipBehaviorKinds.AdvancedAutoMine, "Advanced AutoMine", "Mining", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal mine-and-deliver run order from the current mining opportunity."),
new(ShipBehaviorKinds.ExpertAutoMine, "Expert AutoMine", "Mining", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal mine-and-deliver run order from the current mining opportunity."),
new(ShipBehaviorKinds.DockAndWait, "Dock And Wait", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
new(ShipBehaviorKinds.FlyAndWait, "Fly And Wait", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
new(ShipBehaviorKinds.DockAtStation, "Dock At Station", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
new(ShipBehaviorKinds.Move, "Fly To Position", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
new(ShipBehaviorKinds.FlyToObject, "Fly To Object", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
new(ShipBehaviorKinds.FollowShip, "Follow Ship", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
new(ShipBehaviorKinds.HoldPosition, "Hold Position", "Navigation", ShipAutomationSupportStatus.Supported, "Default baseline behavior; queue-backed behavior order is active."),
new(ShipBehaviorKinds.AutoSalvage, "AutoSalvage", "Salvage", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal salvage run order for wreck recovery."),
new(ShipBehaviorKinds.LocalAutoTrade, "Local AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-and-wait orders from the current market context."),
new(ShipBehaviorKinds.LocalAutoTrade, "Local AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-at-station orders from the current market context."),
new(ShipBehaviorKinds.AdvancedAutoTrade, "Advanced AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route orders from the current market context."),
new(ShipBehaviorKinds.FillShortages, "Fill Shortages", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route orders from the current market context."),
new(ShipBehaviorKinds.FindBuildTasks, "Find Build Tasks", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing construction-support trade routes from the current market context."),
new(ShipBehaviorKinds.RevisitKnownStations, "Revisit Known Stations", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-and-wait orders from known-station context."),
new(ShipBehaviorKinds.RevisitKnownStations, "Revisit Known Stations", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-at-station orders from known-station context."),
new(ShipBehaviorKinds.SupplyFleet, "Supply Fleet", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal fleet supply run order."),
new(ShipBehaviorKinds.RepeatOrders, "Repeat Orders", "Advanced", ShipAutomationSupportStatus.Supported, "Queue-backed behavior generating the current repeat-order template at the bottom of the stack."),
@@ -94,12 +94,11 @@ public static class ShipAutomationCatalog
public static readonly IReadOnlyList<ShipOrderDefinition> Orders =
[
new(ShipOrderKinds.DockAndWait, "Dock And Wait", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
new(ShipOrderKinds.FlyAndWait, "Fly To And Wait", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
new(ShipOrderKinds.DockAtStation, "Dock At Station", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
new(ShipOrderKinds.Move, "Fly To", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order completes on arrival."),
new(ShipOrderKinds.FlyToObject, "Fly To Object", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
new(ShipOrderKinds.FollowShip, "Follow Ship", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
new(ShipOrderKinds.HoldPosition, "Hold Position", "Navigation", ShipAutomationSupportStatus.Supported, "Direct order supported in backend."),
new(ShipOrderKinds.Move, "Move", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Low-level direct movement order; viewer may present richer labels such as Fly To And Wait instead."),
new(ShipOrderKinds.AttackTarget, "Attack Target", "Combat", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),

View File

@@ -6,6 +6,7 @@ public enum SpatialNodeKind
Planet,
Moon,
LagrangePoint,
ResourceNode,
}
public enum WorkStatus
@@ -45,16 +46,6 @@ public enum AiPlanStatus
Interrupted,
}
public enum AiPlanStepStatus
{
Planned,
Running,
Blocked,
Completed,
Failed,
Interrupted,
}
public enum AiPlanSourceKind
{
Rule,
@@ -164,8 +155,6 @@ public static class ShipOrderKinds
{
public const string Move = "move";
public const string DockAtStation = "dock-at-station";
public const string DockAndWait = "dock-and-wait";
public const string FlyAndWait = "fly-and-wait";
public const string FlyToObject = "fly-to-object";
public const string FollowShip = "follow-ship";
public const string TradeRoute = "trade-route";
@@ -286,6 +275,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),
};
@@ -322,17 +312,6 @@ public static class SimulationEnumMappings
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
public static string ToContractValue(this AiPlanStepStatus status) => status switch
{
AiPlanStepStatus.Planned => "planned",
AiPlanStepStatus.Running => "running",
AiPlanStepStatus.Blocked => "blocked",
AiPlanStepStatus.Completed => "completed",
AiPlanStepStatus.Failed => "failed",
AiPlanStepStatus.Interrupted => "interrupted",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
public static string ToContractValue(this AiPlanSourceKind kind) => kind switch
{
AiPlanSourceKind.Rule => "rule",

View File

@@ -7,6 +7,22 @@ public static class SimulationUnits
public static float AuToKilometers(float au) => au * KilometersPerAu;
public static float KilometersToMeters(float kilometers) => kilometers * MetersPerKilometer;
public static float MetersToKilometers(float meters) => meters / MetersPerKilometer;
public static Vector3 KilometersToMeters(Vector3 kilometers) =>
new(
KilometersToMeters(kilometers.X),
KilometersToMeters(kilometers.Y),
KilometersToMeters(kilometers.Z));
public static Vector3 MetersToKilometers(Vector3 meters) =>
new(
MetersToKilometers(meters.X),
MetersToKilometers(meters.Y),
MetersToKilometers(meters.Z));
public static float AuPerSecondToKilometersPerSecond(float auPerSecond) =>
auPerSecond * KilometersPerAu;

View File

@@ -6,27 +6,12 @@ namespace SpaceGame.Api.Ships.AI;
public sealed partial class ShipAiService
{
private static ShipOrderRuntime? GetTopOrder(ShipRuntime ship) =>
ship.OrderQueue
.Where(order => order.Status is OrderStatus.Queued or OrderStatus.Active)
.OrderByDescending(GetOrderSourcePriority)
.ThenByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.FirstOrDefault();
private static int GetOrderSourcePriority(ShipOrderRuntime order) => order.SourceKind switch
{
ShipOrderSourceKind.Player => 300,
ShipOrderSourceKind.Commander => 200,
ShipOrderSourceKind.Behavior => 100,
_ => 0,
};
private void SyncBehaviorOrders(SimulationWorld world, ShipRuntime ship)
{
var desiredOrder = BuildManagedBehaviorOrder(world, ship);
ship.OrderQueue.RemoveAll(order =>
ship.OrderQueue.RemoveWhere(order =>
order.SourceKind == ShipOrderSourceKind.Behavior
&& order.Id.StartsWith("behavior-", StringComparison.Ordinal)
&& (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)));
if (desiredOrder is null)
@@ -34,10 +19,10 @@ public sealed partial class ShipAiService
return;
}
var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal));
var existing = ship.OrderQueue.FindById(desiredOrder.Id);
if (existing is null)
{
ship.OrderQueue.Add(desiredOrder);
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
return;
}
@@ -46,8 +31,7 @@ public sealed partial class ShipAiService
return;
}
ship.OrderQueue.Remove(existing);
ship.OrderQueue.Add(desiredOrder);
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
}
private ShipOrderRuntime? BuildManagedBehaviorOrder(SimulationWorld world, ShipRuntime ship)
@@ -76,7 +60,7 @@ public sealed partial class ShipAiService
};
}
if (string.Equals(behaviorKind, DockAndWait, StringComparison.Ordinal))
if (string.Equals(behaviorKind, DockAtStation, StringComparison.Ordinal))
{
var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? ship.DefaultBehavior.HomeStationId);
if (station is null)
@@ -88,38 +72,36 @@ public sealed partial class ShipAiService
ship.LastAccessFailureReason = null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-dock-and-wait",
Kind = ShipOrderKinds.DockAndWait,
Id = $"behavior-{ship.Id}-dock-at-station",
Kind = ShipOrderKinds.DockAtStation,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = $"Dock and wait at {station.Label}",
Label = $"Dock at {station.Label}",
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
DestinationStationId = station.Id,
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
Radius = ship.DefaultBehavior.Radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}
if (string.Equals(behaviorKind, FlyAndWait, StringComparison.Ordinal))
if (string.Equals(behaviorKind, Move, StringComparison.Ordinal))
{
ship.LastAccessFailureReason = null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-fly-and-wait",
Kind = ShipOrderKinds.FlyAndWait,
Id = $"behavior-{ship.Id}-move",
Kind = ShipOrderKinds.Move,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = "Fly and wait",
Label = "Fly to position",
TargetSystemId = systemId,
TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position,
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
Radius = ship.DefaultBehavior.Radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
@@ -288,7 +270,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,
};
@@ -306,13 +288,12 @@ public sealed partial class ShipAiService
}
ship.LastAccessFailureReason = null;
return CreateManagedFlyAndWaitOrder(
return CreateManagedMoveOrder(
ship,
behaviorKind,
"Protect position",
targetSystemId,
targetPosition,
MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
MathF.Max(6f, ship.DefaultBehavior.Radius));
}
@@ -365,13 +346,12 @@ public sealed partial class ShipAiService
}
ship.LastAccessFailureReason = null;
return CreateManagedFlyAndWaitOrder(
return CreateManagedMoveOrder(
ship,
behaviorKind,
$"Guard {station.Label}",
station.SystemId,
GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)),
MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
MathF.Max(6f, ship.DefaultBehavior.Radius));
}
@@ -410,7 +390,7 @@ public sealed partial class ShipAiService
&& SelectKnownStationVisit(world, ship, homeStation) is { } visitStation)
{
ship.LastAccessFailureReason = null;
return CreateManagedDockAndWaitOrder(ship, behaviorKind, visitStation, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Revisit {visitStation.Label}");
return CreateManagedDockAtStationOrder(ship, behaviorKind, visitStation, $"Revisit {visitStation.Label}");
}
ship.LastAccessFailureReason = "no-trade-route";
@@ -509,7 +489,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 +541,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 +558,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 +582,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
@@ -640,7 +621,7 @@ public sealed partial class ShipAiService
}
ship.LastAccessFailureReason = null;
return CreateManagedFlyAndWaitOrder(ship, sourceKind, "Patrol waypoint", targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), MathF.Max(6f, ship.DefaultBehavior.Radius), orderIdSuffix: "patrol-fly-and-wait");
return CreateManagedMoveOrder(ship, sourceKind, "Patrol waypoint", targetSystemId, targetPosition, MathF.Max(6f, ship.DefaultBehavior.Radius), orderIdSuffix: "patrol-move");
}
private static ShipOrderRuntime CreateManagedAttackOrder(
@@ -686,11 +667,11 @@ public sealed partial class ShipAiService
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
private static ShipOrderRuntime CreateManagedDockAndWaitOrder(ShipRuntime ship, string behaviorKind, StationRuntime station, float waitSeconds, string label) =>
private static ShipOrderRuntime CreateManagedDockAtStationOrder(ShipRuntime ship, string behaviorKind, StationRuntime station, string label) =>
new()
{
Id = $"behavior-{ship.Id}-{behaviorKind}-dock-and-wait",
Kind = ShipOrderKinds.DockAndWait,
Id = $"behavior-{ship.Id}-{behaviorKind}-dock-at-station",
Kind = ShipOrderKinds.DockAtStation,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
@@ -699,25 +680,23 @@ public sealed partial class ShipAiService
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
DestinationStationId = station.Id,
WaitSeconds = waitSeconds,
Radius = ship.DefaultBehavior.Radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
private static ShipOrderRuntime CreateManagedFlyAndWaitOrder(
private static ShipOrderRuntime CreateManagedMoveOrder(
ShipRuntime ship,
string behaviorKind,
string label,
string targetSystemId,
Vector3 targetPosition,
float waitSeconds,
float radius,
string? orderIdSuffix = null) =>
new()
{
Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}",
Kind = ShipOrderKinds.FlyAndWait,
Kind = ShipOrderKinds.Move,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
@@ -725,7 +704,6 @@ public sealed partial class ShipAiService
Label = label,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
WaitSeconds = waitSeconds,
Radius = radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,

View File

@@ -6,7 +6,7 @@ namespace SpaceGame.Api.Ships.AI;
public sealed partial class ShipAiService
{
private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipPlanStepRuntime step, ShipSubTaskRuntime subTask, float deltaSeconds)
private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
{
return subTask.Kind switch
{
@@ -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,23 @@ 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);
var localSystemOffset = SimulationUnits.MetersToKilometers(ship.Position);
ship.SpatialState.SystemPosition = currentAnchor is null
? localSystemOffset
: new Vector3(
currentAnchor.Position.X + localSystemOffset.X,
currentAnchor.Position.Y + localSystemOffset.Y,
currentAnchor.Position.Z + localSystemOffset.Z);
if (distance <= MathF.Max(subTask.Threshold, balance.ArrivalThreshold))
{
@@ -621,13 +650,28 @@ 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;
var arrivalSystemOffset = SimulationUnits.MetersToKilometers(targetPosition);
ship.SpatialState.SystemPosition = targetAnchor is null
? arrivalSystemOffset
: new Vector3(
targetAnchor.Position.X + arrivalSystemOffset.X,
targetAnchor.Position.Y + arrivalSystemOffset.Y,
targetAnchor.Position.Z + arrivalSystemOffset.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);
var movedSystemOffset = SimulationUnits.MetersToKilometers(ship.Position);
ship.SpatialState.SystemPosition = currentAnchor is null
? movedSystemOffset
: new Vector3(
currentAnchor.Position.X + movedSystemOffset.X,
currentAnchor.Position.Y + movedSystemOffset.Y,
currentAnchor.Position.Z + movedSystemOffset.Z);
return SubTaskOutcome.Active;
}
@@ -637,18 +681,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 +706,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 +756,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 +781,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 +814,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 +823,15 @@ 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;
var arrivalSystemOffset = SimulationUnits.MetersToKilometers(targetPosition);
ship.SpatialState.SystemPosition = targetAnchor is null
? arrivalSystemOffset
: new Vector3(
targetAnchor.Position.X + arrivalSystemOffset.X,
targetAnchor.Position.Y + arrivalSystemOffset.Y,
targetAnchor.Position.Z + arrivalSystemOffset.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,27 +86,149 @@ 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)
{
var localOffset = SimulationUnits.MetersToKilometers(station.Position);
return new Vector3(
anchor.Position.X + localOffset.X,
anchor.Position.Y + localOffset.Y,
anchor.Position.Z + localOffset.Z);
}
return SimulationUnits.MetersToKilometers(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)
{
var localOffset = SimulationUnits.MetersToKilometers(ship.Position);
return new Vector3(
anchor.Position.X + localOffset.X,
anchor.Position.Y + localOffset.Y,
anchor.Position.Z + localOffset.Z);
}
return SimulationUnits.MetersToKilometers(ship.Position);
}
private static float GetLocalTravelSpeed(ShipRuntime ship) =>
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed) * GetSkillFactor(ship.Skills.Navigation);
ship.Definition.Speed * GetSkillFactor(ship.Skills.Navigation);
private static float GetWarpTravelSpeed(ShipRuntime ship) =>
SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed) * GetSkillFactor(ship.Skills.Navigation);
@@ -183,6 +315,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 +327,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 +352,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 +590,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 +605,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 +630,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 +877,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 +916,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);
@@ -793,9 +999,6 @@ public sealed partial class ShipAiService
? null
: world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment;
private static ShipPlanStepRuntime? GetCurrentStep(ShipPlanRuntime? plan) =>
plan is null || plan.CurrentStepIndex >= plan.Steps.Count ? null : plan.Steps[plan.CurrentStepIndex];
private static StationRuntime? ResolveSupportStation(SimulationWorld world, ShipRuntime ship, ConstructionSiteRuntime site)
{
return ResolveStation(world, ResolveAssignment(world, ship)?.HomeStationId ?? ship.DefaultBehavior.HomeStationId)
@@ -815,7 +1018,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);
}
@@ -827,47 +1031,46 @@ public sealed partial class ShipAiService
private static void TrackHistory(ShipRuntime ship)
{
var plan = ship.ActivePlan;
var step = GetCurrentStep(plan);
var subTask = step is null || step.CurrentSubTaskIndex >= step.SubTasks.Count ? null : step.SubTasks[step.CurrentSubTaskIndex];
var signature = $"{ship.State.ToContractValue()}|{plan?.Kind ?? "none"}|{step?.Kind ?? "none"}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}";
var orderId = ship.ActiveOrderId ?? "none";
var subTask = ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex];
var signature = $"{ship.State.ToContractValue()}|{orderId}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}";
if (ship.LastSignature == signature)
{
return;
}
ship.LastSignature = signature;
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} plan={plan?.Kind ?? "none"} step={step?.Kind ?? "none"} subTask={subTask?.Kind ?? "none"} cargo={GetShipCargoAmount(ship):0.#}");
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} order={orderId} task={subTask?.Kind ?? "none"} cargo={GetShipCargoAmount(ship):0.#}");
if (ship.History.Count > 24)
{
ship.History.RemoveAt(0);
}
}
private static void EmitStateEvents(ShipRuntime ship, ShipState previousState, string? previousPlanId, string? previousStepId, ICollection<SimulationEventRecord> events)
private static void EmitStateEvents(ShipRuntime ship, ShipState previousState, string? previousOrderId, string? previousTaskId, ICollection<SimulationEventRecord> events)
{
var currentPlanId = ship.ActivePlan?.Id;
var currentStepId = GetCurrentStep(ship.ActivePlan)?.Id;
var currentOrderId = ship.ActiveOrderId;
var currentTaskId = ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex].Id;
var occurredAtUtc = DateTimeOffset.UtcNow;
if (previousState != ship.State)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Name} state {previousState.ToContractValue()} -> {ship.State.ToContractValue()}.", occurredAtUtc));
}
if (!string.Equals(previousPlanId, currentPlanId, StringComparison.Ordinal))
if (!string.Equals(previousOrderId, currentOrderId, StringComparison.Ordinal))
{
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-changed", $"{ship.Definition.Name} switched active plan.", occurredAtUtc));
events.Add(new SimulationEventRecord("ship", ship.Id, "order-changed", $"{ship.Definition.Name} switched active order.", occurredAtUtc));
}
if (!string.Equals(previousStepId, currentStepId, StringComparison.Ordinal))
if (!string.Equals(previousTaskId, currentTaskId, StringComparison.Ordinal))
{
events.Add(new SimulationEventRecord("ship", ship.Id, "step-changed", $"{ship.Definition.Name} advanced plan step.", occurredAtUtc));
events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Name} advanced active task.", occurredAtUtc));
}
}
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 +1081,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

@@ -1,319 +1,179 @@
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
namespace SpaceGame.Api.Ships.AI;
public sealed partial class ShipAiService
{
private ShipPlanRuntime BuildBehaviorFallbackPlan(SimulationWorld world, ShipRuntime ship)
{
var (behaviorKind, sourceId) = ResolveBehaviorSource(world, ship);
var failureReason = ship.LastAccessFailureReason;
if (string.Equals(behaviorKind, Idle, StringComparison.Ordinal))
{
return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Idle");
}
if (IsBehaviorBlockingFailure(behaviorKind, failureReason))
{
return CreateBlockedPlan(
ship,
AiPlanSourceKind.DefaultBehavior,
sourceId,
DescribeBehaviorFallbackSummary(world, ship, behaviorKind, failureReason),
failureReason!);
}
return CreateIdlePlan(
ship,
AiPlanSourceKind.DefaultBehavior,
sourceId,
DescribeBehaviorFallbackSummary(world, ship, behaviorKind, failureReason));
}
private static bool IsBehaviorBlockingFailure(string behaviorKind, string? failureReason) => failureReason switch
{
"missing-item" => true,
"no-suitable-buyer" => true,
"no-mineable-node" when string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal) => true,
"no-mineable-node" when string.Equals(behaviorKind, ShipBehaviorKinds.LocalAutoMine, StringComparison.Ordinal) => true,
_ => false,
};
private static string DescribeBehaviorFallbackSummary(SimulationWorld world, ShipRuntime ship, string behaviorKind, string? failureReason)
private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship)
{
var assignment = ResolveAssignment(world, ship);
var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
var itemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId ?? "resource";
return failureReason switch
{
"missing-item" => "No mining ware configured",
"no-suitable-buyer" => $"No buyer for {itemId} in {systemId}",
"no-mineable-node" when string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal) => $"No {itemId} to mine in {systemId}",
"no-mineable-node" => "No mineable node",
"no-home-station" => "No home station",
"no-trade-route" => "No trade route",
"no-fleet-to-supply" => "No fleet to supply",
"station-missing" => "No station to dock",
"target-ship-missing" => "No ship to follow",
"target-missing" => "No object target",
"no-salvage-target" => "No salvage target",
"no-repeat-orders" => "No repeat orders",
"no-construction-site" => "No construction site",
"support-station-missing" => "No support station",
_ => "Idle",
};
return assignment is null
? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind)
: (assignment.BehaviorKind, assignment.ObjectiveId);
}
private ShipPlanRuntime BuildTradePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, TradeRoutePlan route, string summary)
private IReadOnlyList<ShipSubTaskRuntime> BuildTradeSubTasks(ShipRuntime ship, TradeRoutePlan route)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.TradeRoute,
summary,
[
CreateStep("step-acquire", "acquire-cargo", $"Load {route.ItemId} at {route.SourceStation.Label}",
return
[
CreateSubTask("sub-acquire-travel", ShipTaskKinds.Travel, $"Travel to {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(route.SourceStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-acquire-dock", ShipTaskKinds.Dock, $"Dock at {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 4f, 0f),
CreateSubTask("sub-acquire-load", ShipTaskKinds.LoadCargo, $"Load {route.ItemId}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
CreateSubTask("sub-acquire-undock", ShipTaskKinds.Undock, $"Undock from {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(4f, 12f), 0f)
]),
CreateStep("step-deliver", "deliver-cargo", $"Deliver {route.ItemId} to {route.DestinationStation.Label}",
CreateSubTask("sub-acquire-travel", ShipTaskKinds.Travel, $"Travel to {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(route.SourceStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-acquire-dock", ShipTaskKinds.Dock, $"Dock at {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 4f, 0f),
CreateSubTask("sub-acquire-load", ShipTaskKinds.LoadCargo, $"Load {route.ItemId}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
CreateSubTask("sub-acquire-undock", ShipTaskKinds.Undock, $"Undock from {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(4f, 12f), 0f),
CreateSubTask("sub-route-travel", ShipTaskKinds.Travel, $"Travel to {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(route.DestinationStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-route-dock", ShipTaskKinds.Dock, $"Dock at {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 4f, 0f),
CreateSubTask("sub-route-unload", ShipTaskKinds.UnloadCargo, $"Unload {route.ItemId}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
CreateSubTask("sub-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f),
];
}
private IReadOnlyList<ShipSubTaskRuntime> BuildFleetSupplySubTasks(FleetSupplyPlan plan)
{
return
[
CreateSubTask("sub-route-travel", ShipTaskKinds.Travel, $"Travel to {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(route.DestinationStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-route-dock", ShipTaskKinds.Dock, $"Dock at {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 4f, 0f),
CreateSubTask("sub-route-unload", ShipTaskKinds.UnloadCargo, $"Unload {route.ItemId}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
CreateSubTask("sub-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f)
])
]);
CreateSubTask("sub-fleet-source-travel", ShipTaskKinds.Travel, $"Travel to {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, MathF.Max(plan.SourceStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-fleet-source-dock", ShipTaskKinds.Dock, $"Dock at {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 4f, 0f),
CreateSubTask("sub-fleet-source-load", ShipTaskKinds.LoadCargo, $"Load {plan.ItemId}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 0f, plan.Amount, itemId: plan.ItemId),
CreateSubTask("sub-fleet-source-undock", ShipTaskKinds.Undock, $"Undock from {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 12f, 0f),
CreateSubTask("sub-fleet-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Name}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, 6f),
CreateSubTask("sub-fleet-transfer", ShipTaskKinds.TransferCargoToShip, $"Transfer {plan.ItemId}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, plan.Amount, itemId: plan.ItemId),
];
}
private ShipPlanRuntime BuildFleetSupplyPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, FleetSupplyPlan plan)
private IReadOnlyList<ShipSubTaskRuntime> BuildConstructionSubTasks(ConstructionSiteRuntime site, StationRuntime supportStation)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
SupplyFleet,
plan.Summary,
[
CreateStep("step-fleet-acquire", "acquire-cargo", $"Load {plan.ItemId} at {plan.SourceStation.Label}",
var targetPosition = supportStation.Position;
return
[
CreateSubTask("sub-fleet-source-travel", ShipTaskKinds.Travel, $"Travel to {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, MathF.Max(plan.SourceStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-fleet-source-dock", ShipTaskKinds.Dock, $"Dock at {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 4f, 0f),
CreateSubTask("sub-fleet-source-load", ShipTaskKinds.LoadCargo, $"Load {plan.ItemId}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 0f, plan.Amount, itemId: plan.ItemId),
CreateSubTask("sub-fleet-source-undock", ShipTaskKinds.Undock, $"Undock from {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 12f, 0f),
]),
CreateStep("step-fleet-deliver", "deliver-fleet", $"Deliver {plan.ItemId} to {plan.TargetShip.Definition.Name}",
[
CreateSubTask("sub-fleet-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Name}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, 6f),
CreateSubTask("sub-fleet-transfer", ShipTaskKinds.TransferCargoToShip, $"Transfer {plan.ItemId}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, plan.Amount, itemId: plan.ItemId),
])
]);
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, supportStation.Position, site.Id, 12f, 0f),
CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f),
];
}
private ShipPlanRuntime BuildConstructionPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ConstructionSiteRuntime site, StationRuntime supportStation, string summary)
private static IReadOnlyList<ShipSubTaskRuntime> BuildAttackSubTasks(string targetEntityId, string? targetSystemId, string summary)
{
var targetPosition = site.StationId is null ? supportStation.Position : supportStation.Position;
return CreatePlan(
ship,
sourceKind,
sourceId,
"construction-support",
summary,
[
CreateStep("step-construction-deliver", "deliver-materials", $"Deliver materials to {site.Id}",
return
[
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)
]),
CreateStep("step-construction-build", "build-site", $"Build {site.Id}",
CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? string.Empty, Vector3.Zero, targetEntityId, 26f, 0f),
];
}
private static IReadOnlyList<ShipSubTaskRuntime> BuildFlyToObjectSubTasks(string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary)
{
return
[
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-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f),
];
}
private ShipPlanRuntime BuildAttackPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string? targetSystemId, string summary)
private static IReadOnlyList<ShipSubTaskRuntime> BuildFollowShipSubTasks(ShipRuntime targetShip, float radius, float durationSeconds, string summary) =>
BuildFollowSubTasks(targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary);
private static IReadOnlyList<ShipSubTaskRuntime> BuildFollowSubTasks(string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.AttackTarget,
summary,
[
CreateStep("step-attack", ShipOrderKinds.AttackTarget, summary,
return
[
CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? ship.SystemId, ship.Position, targetEntityId, 26f, 0f)
])
]);
CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds),
];
}
private ShipPlanRuntime BuildDockAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime station, float waitSeconds, string summary)
private static IReadOnlyList<ShipSubTaskRuntime> BuildHoldSubTasks(ShipRuntime ship, ShipOrderRuntime order)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.DockAndWait,
summary,
[
CreateStep("step-dock-wait-travel", "travel", $"Travel to {station.Label}",
return
[
CreateSubTask("sub-dock-wait-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(station.Radius + 12f, 12f), 0f),
CreateSubTask("sub-dock-wait-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f),
CreateSubTask("sub-dock-wait-hold", ShipTaskKinds.HoldPosition, $"Wait at {station.Label}", station.SystemId, station.Position, station.Id, 0f, waitSeconds),
])
]);
CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f),
];
}
private ShipPlanRuntime BuildFlyAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, float waitSeconds, string summary)
private IReadOnlyList<ShipSubTaskRuntime> BuildMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node, StationRuntime homeStation)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.FlyAndWait,
summary,
[
CreateStep("step-fly-wait", ShipOrderKinds.FlyAndWait, summary,
var deposit = SelectMiningDeposit(node, ship.Id);
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
return
[
CreateSubTask("sub-fly-wait-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, null, 6f, 0f),
CreateSubTask("sub-fly-wait-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, null, 0f, waitSeconds),
])
]);
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),
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity()),
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f),
];
}
private ShipPlanRuntime BuildFlyToObjectPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary)
private IReadOnlyList<ShipSubTaskRuntime> BuildLocalMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.FlyToObject,
summary,
[
CreateStep("step-fly-object", ShipOrderKinds.FlyToObject, summary,
var deposit = SelectMiningDeposit(node, ship.Id);
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
return
[
CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f),
CreateSubTask("sub-fly-object-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, targetEntityId, 0f, MathF.Max(1f, ship.DefaultBehavior.WaitSeconds)),
])
]);
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),
];
}
private ShipPlanRuntime BuildFollowShipPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ShipRuntime targetShip, float radius, float durationSeconds, string summary)
private IReadOnlyList<ShipSubTaskRuntime> BuildLocalMiningDeliverySubTasks(ShipRuntime ship, StationRuntime buyer, string itemId)
{
return BuildFollowPlan(ship, sourceKind, sourceId, targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary);
}
private ShipPlanRuntime BuildFollowPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.FollowShip,
summary,
[
CreateStep("step-follow", "follow-target", summary,
var amount = MathF.Max(1f, GetInventoryAmount(ship.Inventory, itemId));
return
[
CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds),
])
]);
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(buyer.Radius + 12f, 12f), 0f),
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 4f, 0f),
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload {itemId} at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 0f, amount, itemId: itemId),
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(4f, 12f), 0f),
];
}
private ShipPlanRuntime CreateIdlePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary)
private IReadOnlyList<ShipSubTaskRuntime> BuildSalvageSubTasks(ShipRuntime ship, WreckRuntime wreck, StationRuntime homeStation, Vector3 approach)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
Idle,
summary,
[
CreateStep("step-idle", ShipOrderKinds.HoldPosition, summary,
return
[
CreateSubTask("sub-idle", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 1.5f)
])
]);
CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {wreck.Id}", wreck.SystemId, approach, wreck.Id, 8f, 0f),
CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {wreck.ItemId}", wreck.SystemId, approach, wreck.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
CreateSubTask("sub-salvage-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-salvage-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
CreateSubTask("sub-salvage-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload salvage at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f),
];
}
private ShipPlanRuntime CreateBlockedPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary, string blockingReason)
{
var subTask = CreateSubTask("sub-blocked", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 0f);
subTask.Status = WorkStatus.Blocked;
subTask.BlockingReason = blockingReason;
var step = CreateStep("step-blocked", "blocked", summary, [subTask]);
step.Status = AiPlanStepStatus.Blocked;
step.BlockingReason = blockingReason;
var plan = CreatePlan(ship, sourceKind, sourceId, "blocked", summary, [step]);
plan.Status = AiPlanStatus.Blocked;
plan.FailureReason = blockingReason;
return plan;
}
private static ShipPlanRuntime CreatePlan(
ShipRuntime ship,
AiPlanSourceKind sourceKind,
string sourceId,
string kind,
string summary,
IReadOnlyList<ShipPlanStepRuntime> steps)
{
var plan = new ShipPlanRuntime
{
Id = $"plan-{ship.Id}-{Guid.NewGuid():N}",
SourceKind = sourceKind,
SourceId = sourceId,
Kind = kind,
Summary = summary,
};
plan.Steps.AddRange(steps);
return plan;
}
private static ShipPlanStepRuntime CreateStep(string id, string kind, string summary, IReadOnlyList<ShipSubTaskRuntime> subTasks)
{
var step = new ShipPlanStepRuntime
private static ShipSubTaskRuntime CreateSubTask(
string id,
string kind,
string summary,
string targetSystemId,
Vector3 targetPosition,
string? targetEntityId,
float threshold,
float amount,
string? itemId = null,
string? moduleId = null,
string? targetAnchorId = null,
string? targetResourceNodeId = null,
string? targetResourceDepositId = null) =>
new()
{
Id = id,
Kind = kind,
Summary = summary,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
TargetEntityId = targetEntityId,
TargetAnchorId = targetAnchorId,
TargetResourceNodeId = targetResourceNodeId,
TargetResourceDepositId = targetResourceDepositId,
ItemId = itemId,
ModuleId = moduleId,
Threshold = threshold,
Amount = amount,
};
step.SubTasks.AddRange(subTasks);
return step;
}
private static ShipSubTaskRuntime CreateSubTask(
string id,
string kind,
string summary,
string targetSystemId,
Vector3 targetPosition,
string? targetEntityId,
float threshold,
float amount,
string? itemId = null,
string? moduleId = null,
string? targetNodeId = null) =>
new()
{
Id = id,
Kind = kind,
Summary = summary,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
TargetEntityId = targetEntityId,
TargetNodeId = targetNodeId,
ItemId = itemId,
ModuleId = moduleId,
Threshold = threshold,
Amount = amount,
};
}

View File

@@ -1,5 +1,4 @@
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
@@ -7,7 +6,7 @@ namespace SpaceGame.Api.Ships.AI;
public sealed partial class ShipAiService
{
private ShipPlanRuntime? BuildEmergencyPlan(SimulationWorld world, ShipRuntime ship)
private ShipOrderRuntime? BuildEmergencyOrder(SimulationWorld world, ShipRuntime ship)
{
var policy = ResolvePolicy(world, ship.PolicySetId);
if (policy is null)
@@ -37,86 +36,75 @@ public sealed partial class ShipAiService
.ThenBy(station => station.Position.DistanceTo(ship.Position))
.FirstOrDefault();
var plan = new ShipPlanRuntime
return new ShipOrderRuntime
{
Id = $"plan-{ship.Id}-safety-{Guid.NewGuid():N}",
SourceKind = AiPlanSourceKind.Rule,
Id = $"rule-{ship.Id}-flee",
Kind = ShipOrderKinds.Flee,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = ShipOrderKinds.Flee,
Kind = "safety-flee",
Summary = "Emergency retreat",
Priority = 1000,
InterruptCurrentPlan = true,
Label = "Emergency retreat",
TargetEntityId = safeStation?.Id,
TargetSystemId = safeStation?.SystemId ?? ship.SystemId,
TargetPosition = safeStation?.Position ?? ship.Position,
DestinationStationId = safeStation?.Id,
Radius = safeStation is null ? 0f : MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f),
};
if (safeStation is null)
{
plan.Steps.Add(CreateStep("step-flee-hold", ShipOrderKinds.HoldPosition, "Hold position away from hostiles",
[
CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f)
]));
return plan;
}
plan.Steps.Add(CreateStep("step-flee-travel", "travel", "Travel to safe station",
[
CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), 0f)
]));
plan.Steps.Add(CreateStep("step-flee-dock", "dock", "Dock at safe station",
[
CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f)
]));
return plan;
}
private ShipPlanRuntime? BuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
private IReadOnlyList<ShipSubTaskRuntime>? BuildOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
return order.Kind switch
{
var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMovePlan(ship, order),
var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.DockAndWait, StringComparison.Ordinal) => BuildDockAndWaitOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.FlyAndWait, StringComparison.Ordinal) => BuildFlyAndWaitOrderPlan(ship, order),
var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.MineLocal, StringComparison.Ordinal) => BuildMineLocalOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliverRun, StringComparison.Ordinal) => BuildMineAndDeliverRunOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.SellMinedCargo, StringComparison.Ordinal) => BuildSellMinedCargoOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.SupplyFleetRun, StringComparison.Ordinal) => BuildSupplyFleetOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.SalvageRun, StringComparison.Ordinal) => BuildAutoSalvageOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldOrderPlan(ship, order),
var kind when string.Equals(kind, ShipOrderKinds.Flee, StringComparison.Ordinal) => BuildFleeSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMoveSubTasks(ship, order),
var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderSubTasks(world, order),
var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderSubTasks(world, order),
var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.MineLocal, StringComparison.Ordinal) => BuildMineLocalOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliverRun, StringComparison.Ordinal) => BuildMineAndDeliverRunOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.SellMinedCargo, StringComparison.Ordinal) => BuildSellMinedCargoOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.SupplyFleetRun, StringComparison.Ordinal) => BuildSupplyFleetOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.SalvageRun, StringComparison.Ordinal) => BuildAutoSalvageOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderSubTasks(order),
var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldSubTasks(ship, order),
_ => null,
};
}
private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship)
private IReadOnlyList<ShipSubTaskRuntime> BuildFleeSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var assignment = ResolveAssignment(world, ship);
return assignment is null
? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind)
: (assignment.BehaviorKind, assignment.ObjectiveId);
var safeStation = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId);
if (safeStation is null)
{
return
[
CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f),
];
}
return
[
CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), 0f),
CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f),
];
}
private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order)
private static IReadOnlyList<ShipSubTaskRuntime> BuildMoveSubTasks(ShipRuntime ship, ShipOrderRuntime order)
{
var targetSystemId = order.TargetSystemId ?? ship.SystemId;
var targetPosition = order.TargetPosition ?? ship.Position;
return CreatePlan(
ship,
AiPlanSourceKind.Order,
order.Id,
ShipOrderKinds.Move,
order.Label ?? "Move order",
[
CreateStep("step-move", "travel", order.Label ?? "Travel",
return
[
CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, 0f, 0f)
])
]);
CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, MathF.Max(0f, order.Radius), 0f),
];
}
private ShipPlanRuntime? BuildDockOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
private IReadOnlyList<ShipSubTaskRuntime>? BuildDockOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId);
if (station is null)
@@ -125,25 +113,14 @@ public sealed partial class ShipAiService
return null;
}
return CreatePlan(
ship,
AiPlanSourceKind.Order,
order.Id,
"dock-at-station",
order.Label ?? $"Dock at {station.Label}",
[
CreateStep("step-dock-travel", "travel", $"Travel to {station.Label}",
return
[
CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(balance.ArrivalThreshold, station.Radius + 12f), 0f)
]),
CreateStep("step-dock", "dock", $"Dock at {station.Label}",
[
CreateSubTask("sub-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f)
])
]);
CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(balance.ArrivalThreshold, station.Radius + 12f), 0f),
CreateSubTask("sub-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f),
];
}
private ShipPlanRuntime? BuildTradeOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
private IReadOnlyList<ShipSubTaskRuntime>? BuildTradeOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
if (order.SourceStationId is null || order.DestinationStationId is null || order.ItemId is null)
{
@@ -158,10 +135,10 @@ public sealed partial class ShipAiService
return null;
}
return BuildTradePlan(ship, AiPlanSourceKind.Order, order.Id, route, order.Label ?? route.Summary);
return BuildTradeSubTasks(ship, route);
}
private ShipPlanRuntime? BuildMineOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
private IReadOnlyList<ShipSubTaskRuntime>? BuildMineOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var systemId = order.TargetSystemId ?? ship.SystemId;
var itemId = order.ItemId;
@@ -171,7 +148,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 +166,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 +175,30 @@ public sealed partial class ShipAiService
return null;
}
return BuildLocalMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {itemId} in {systemId}");
return BuildLocalMiningSubTasks(ship, node);
}
private ShipPlanRuntime? BuildMineLocalOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
private IReadOnlyList<ShipSubTaskRuntime>? BuildMineLocalOrderSubTasks(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 BuildLocalMiningSubTasks(ship, node);
}
private ShipPlanRuntime? BuildMineAndDeliverRunOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
private IReadOnlyList<ShipSubTaskRuntime>? BuildMineAndDeliverRunOrderSubTasks(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,10 +206,10 @@ 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 BuildMiningSubTasks(ship, node, buyer);
}
private ShipPlanRuntime? BuildSellMinedCargoOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
private IReadOnlyList<ShipSubTaskRuntime>? BuildSellMinedCargoOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var buyer = ResolveStation(world, order.DestinationStationId ?? order.TargetEntityId);
if (buyer is null || string.IsNullOrWhiteSpace(order.ItemId))
@@ -234,10 +218,10 @@ public sealed partial class ShipAiService
return null;
}
return BuildLocalMiningDeliveryPlan(ship, AiPlanSourceKind.Order, order.Id, buyer, order.ItemId, order.Label ?? $"Sell {order.ItemId}");
return BuildLocalMiningDeliverySubTasks(ship, buyer, order.ItemId);
}
private ShipPlanRuntime? BuildAutoSalvageOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
private IReadOnlyList<ShipSubTaskRuntime>? BuildAutoSalvageOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var homeStation = ResolveStation(world, order.SourceStationId ?? ship.DefaultBehavior.HomeStationId);
var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.RemainingAmount > 0.01f);
@@ -248,29 +232,10 @@ public sealed partial class ShipAiService
}
var approach = GetFormationPosition(wreck.Position, ship.Id, MathF.Max(8f, order.Radius > 0f ? order.Radius : ship.DefaultBehavior.Radius * 0.25f));
return CreatePlan(
ship,
AiPlanSourceKind.Order,
order.Id,
AutoSalvage,
order.Label ?? $"Salvage {wreck.ItemId}",
[
CreateStep("step-salvage-collect", "salvage", $"Salvage {wreck.ItemId}",
[
CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {wreck.Id}", wreck.SystemId, approach, wreck.Id, 8f, 0f),
CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {wreck.ItemId}", wreck.SystemId, approach, wreck.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
]),
CreateStep("step-salvage-deliver", "deliver-salvage", $"Deliver salvage to {homeStation.Label}",
[
CreateSubTask("sub-salvage-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-salvage-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
CreateSubTask("sub-salvage-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload salvage at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f),
])
]);
return BuildSalvageSubTasks(ship, wreck, homeStation, approach);
}
private ShipPlanRuntime? BuildSupplyFleetOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
private IReadOnlyList<ShipSubTaskRuntime>? BuildSupplyFleetOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var sourceStation = ResolveStation(world, order.SourceStationId);
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
@@ -296,10 +261,10 @@ public sealed partial class ShipAiService
amount,
MathF.Max(16f, order.Radius),
order.Label ?? $"Supply {targetShip.Definition.Name} with {order.ItemId}");
return BuildFleetSupplyPlan(ship, AiPlanSourceKind.Order, order.Id, plan);
return BuildFleetSupplySubTasks(plan);
}
private ShipPlanRuntime? BuildBuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
private IReadOnlyList<ShipSubTaskRuntime>? BuildBuildOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (order.ConstructionSiteId ?? order.TargetEntityId));
if (site is null)
@@ -315,10 +280,10 @@ public sealed partial class ShipAiService
return null;
}
return BuildConstructionPlan(ship, AiPlanSourceKind.Order, order.Id, site, supportStation, order.Label ?? $"Build {site.BlueprintId}");
return BuildConstructionSubTasks(site, supportStation);
}
private ShipPlanRuntime? BuildAttackOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
private IReadOnlyList<ShipSubTaskRuntime>? BuildAttackOrderSubTasks(ShipOrderRuntime order)
{
var targetId = order.TargetEntityId;
if (targetId is null)
@@ -327,45 +292,10 @@ public sealed partial class ShipAiService
return null;
}
return BuildAttackPlan(ship, AiPlanSourceKind.Order, order.Id, targetId, order.TargetSystemId, order.Label ?? "Attack target");
return BuildAttackSubTasks(targetId, order.TargetSystemId, order.Label ?? "Attack target");
}
private ShipPlanRuntime BuildHoldOrderPlan(ShipRuntime ship, ShipOrderRuntime order)
{
return CreatePlan(
ship,
AiPlanSourceKind.Order,
order.Id,
ShipOrderKinds.HoldPosition,
order.Label ?? "Hold position",
[
CreateStep("step-hold", ShipOrderKinds.HoldPosition, order.Label ?? "Hold position",
[
CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f)
])
]);
}
private ShipPlanRuntime? BuildDockAndWaitOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId);
if (station is null)
{
order.FailureReason = "station-missing";
return null;
}
return BuildDockAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, station, MathF.Max(1f, order.WaitSeconds), order.Label ?? $"Dock and wait at {station.Label}");
}
private ShipPlanRuntime BuildFlyAndWaitOrderPlan(ShipRuntime ship, ShipOrderRuntime order)
{
var systemId = order.TargetSystemId ?? ship.SystemId;
var targetPosition = order.TargetPosition ?? ship.Position;
return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, systemId, targetPosition, MathF.Max(1f, order.WaitSeconds), order.Label ?? "Fly and wait");
}
private ShipPlanRuntime? BuildFlyToObjectOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
private IReadOnlyList<ShipSubTaskRuntime>? BuildFlyToObjectOrderSubTasks(SimulationWorld world, ShipOrderRuntime order)
{
var targetEntityId = order.TargetEntityId;
if (targetEntityId is null)
@@ -381,10 +311,10 @@ public sealed partial class ShipAiService
return null;
}
return BuildFlyToObjectPlan(ship, AiPlanSourceKind.Order, order.Id, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}");
return BuildFlyToObjectSubTasks(objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}");
}
private ShipPlanRuntime? BuildFollowShipOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
private IReadOnlyList<ShipSubTaskRuntime>? BuildFollowShipOrderSubTasks(SimulationWorld world, ShipOrderRuntime order)
{
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
if (targetShip is null)
@@ -393,69 +323,6 @@ public sealed partial class ShipAiService
return null;
}
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)
{
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.MineAndDeliver,
summary,
[
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())
]),
CreateStep("step-deliver", "deliver", $"Deliver {node.ItemId} to {homeStation.Label}",
[
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity()),
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f)
])
]);
}
private ShipPlanRuntime BuildLocalMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, string summary)
{
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.MineLocal,
summary,
[
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)
])
]);
}
private ShipPlanRuntime BuildLocalMiningDeliveryPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime buyer, string itemId, string summary)
{
var amount = MathF.Max(1f, GetInventoryAmount(ship.Inventory, itemId));
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.SellMinedCargo,
summary,
[
CreateStep("step-deliver", "deliver", $"Deliver {itemId} to {buyer.Label}",
[
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(buyer.Radius + 12f, 12f), 0f),
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 4f, 0f),
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload {itemId} at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 0f, amount, itemId: itemId),
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(4f, 12f), 0f)
])
]);
return BuildFollowShipSubTasks(targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Name}");
}
}

View File

@@ -26,191 +26,195 @@ public sealed partial class ShipAiService
}
var previousState = ship.State;
var previousPlanId = ship.ActivePlan?.Id;
var previousStepId = GetCurrentStep(ship.ActivePlan)?.Id;
EnsurePlan(world, ship, events);
ExecutePlan(world, ship, deltaSeconds, events);
TrackHistory(ship);
EmitStateEvents(ship, previousState, previousPlanId, previousStepId, events);
}
private void EnsurePlan(SimulationWorld world, ShipRuntime ship, ICollection<SimulationEventRecord> events)
{
var emergencyPlan = BuildEmergencyPlan(world, ship);
if (emergencyPlan is not null)
{
ship.LastReplanReason = "rule-safety";
ReplacePlan(ship, emergencyPlan, "rule-safety", events);
return;
}
var previousOrderId = ship.ActiveOrderId;
var previousTaskId = GetCurrentSubTask(ship)?.Id;
SyncEmergencyOrders(world, ship);
SyncBehaviorOrders(world, ship);
var topOrder = GetTopOrder(ship);
if (topOrder is not null && topOrder.Status == OrderStatus.Queued)
{
topOrder.Status = OrderStatus.Active;
}
var desiredSourceKind = topOrder is null ? AiPlanSourceKind.DefaultBehavior : AiPlanSourceKind.Order;
var desiredSourceId = topOrder?.Id ?? ResolveBehaviorSource(world, ship).SourceId;
var currentPlan = ship.ActivePlan;
if (currentPlan is not null
&& currentPlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed and not AiPlanStatus.Interrupted
&& currentPlan.SourceKind == desiredSourceKind
&& string.Equals(currentPlan.SourceId, desiredSourceId, StringComparison.Ordinal)
&& !ship.NeedsReplan)
{
return;
}
if (ship.ReplanCooldownSeconds > 0f && currentPlan is null)
{
return;
}
ShipPlanRuntime? nextPlan = desiredSourceKind == AiPlanSourceKind.Order
? BuildOrderPlan(world, ship, topOrder!)
: BuildBehaviorFallbackPlan(world, ship);
if (nextPlan is null)
{
nextPlan = CreateIdlePlan(ship, desiredSourceKind, desiredSourceId, "No viable plan");
}
if (nextPlan.Kind != Idle)
{
ship.LastAccessFailureReason = null;
}
ReplacePlan(ship, nextPlan, "replanned", events);
EnsureOrderExecution(world, ship, events);
ExecuteOrder(world, ship, deltaSeconds, events);
TrackHistory(ship);
EmitStateEvents(ship, previousState, previousOrderId, previousTaskId, events);
}
private void ExecutePlan(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
private void EnsureOrderExecution(SimulationWorld world, ShipRuntime ship, ICollection<SimulationEventRecord> events)
{
var plan = ship.ActivePlan;
if (plan is null)
var currentOrder = ship.OrderQueue.GetCurrentOrder();
if (currentOrder is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
ClearActiveOrder(ship);
ApplyIdleOrBlockedState(world, ship);
return;
}
if (plan.CurrentStepIndex >= plan.Steps.Count)
if (currentOrder.Status == OrderStatus.Queued)
{
currentOrder.Status = OrderStatus.Active;
}
if (!ship.NeedsReplan
&& string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal)
&& ship.ActiveSubTaskIndex < ship.ActiveSubTasks.Count)
{
CompletePlan(ship, plan, events);
return;
}
plan.UpdatedAtUtc = DateTimeOffset.UtcNow;
var step = plan.Steps[plan.CurrentStepIndex];
if (step.Status == AiPlanStepStatus.Planned)
if (ship.ReplanCooldownSeconds > 0f && !string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal))
{
step.Status = AiPlanStepStatus.Running;
}
if (step.CurrentSubTaskIndex >= step.SubTasks.Count)
{
CompleteStep(plan, step);
return;
}
var subTask = step.SubTasks[step.CurrentSubTaskIndex];
var subTasks = BuildOrderSubTasks(world, ship, currentOrder);
if (subTasks is null || subTasks.Count == 0)
{
FailOrder(ship, currentOrder, currentOrder.FailureReason ?? "order-unavailable");
ClearActiveOrder(ship);
ship.NeedsReplan = true;
ship.ReplanCooldownSeconds = 0.1f;
ship.LastReplanReason = currentOrder.FailureReason ?? "order-unavailable";
events.Add(new SimulationEventRecord("ship", ship.Id, "order-failed", $"{ship.Definition.Name} failed {currentOrder.Label ?? currentOrder.Kind}.", DateTimeOffset.UtcNow));
ApplyIdleOrBlockedState(world, ship);
return;
}
BeginOrderExecution(ship, currentOrder, subTasks);
events.Add(new SimulationEventRecord("ship", ship.Id, "order-started", $"{ship.Definition.Name} started {currentOrder.Label ?? currentOrder.Kind}.", DateTimeOffset.UtcNow));
}
private void ExecuteOrder(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
var order = ship.ActiveOrderId is null ? null : ship.OrderQueue.FindById(ship.ActiveOrderId);
if (order is null)
{
ClearActiveOrder(ship);
ApplyIdleOrBlockedState(world, ship);
return;
}
if (ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count)
{
CompleteOrderExecution(ship, order, events);
return;
}
var subTask = ship.ActiveSubTasks[ship.ActiveSubTaskIndex];
if (subTask.Status == WorkStatus.Pending)
{
subTask.Status = WorkStatus.Active;
}
else if (subTask.Status == WorkStatus.Blocked)
{
step.Status = AiPlanStepStatus.Blocked;
step.BlockingReason = subTask.BlockingReason;
plan.Status = AiPlanStatus.Blocked;
ship.State = ShipState.Blocked;
ship.TargetPosition = subTask.TargetPosition ?? ship.Position;
return;
}
plan.Status = AiPlanStatus.Running;
var outcome = UpdateSubTask(world, ship, step, subTask, deltaSeconds);
var outcome = UpdateSubTask(world, ship, subTask, deltaSeconds);
switch (outcome)
{
case SubTaskOutcome.Active:
step.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStepStatus.Blocked : AiPlanStepStatus.Running;
plan.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStatus.Blocked : AiPlanStatus.Running;
return;
case SubTaskOutcome.Completed:
subTask.Status = WorkStatus.Completed;
subTask.Progress = 1f;
step.CurrentSubTaskIndex += 1;
step.BlockingReason = null;
if (step.CurrentSubTaskIndex >= step.SubTasks.Count)
ship.ActiveSubTaskIndex += 1;
if (ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count)
{
CompleteStep(plan, step);
CompleteOrderExecution(ship, order, events);
}
return;
case SubTaskOutcome.Failed:
subTask.Status = WorkStatus.Failed;
step.Status = AiPlanStepStatus.Failed;
plan.Status = AiPlanStatus.Failed;
plan.FailureReason = subTask.BlockingReason ?? "subtask-failed";
ship.NeedsReplan = true;
ship.ReplanCooldownSeconds = 0.5f;
ship.LastReplanReason = plan.FailureReason;
FailOrderExecution(ship, order, subTask.BlockingReason ?? "subtask-failed", events);
return;
}
}
private static void CompleteStep(ShipPlanRuntime plan, ShipPlanStepRuntime step)
private static void BeginOrderExecution(ShipRuntime ship, ShipOrderRuntime order, IReadOnlyList<ShipSubTaskRuntime> subTasks)
{
step.Status = AiPlanStepStatus.Completed;
step.BlockingReason = null;
plan.CurrentStepIndex += 1;
if (plan.CurrentStepIndex >= plan.Steps.Count)
{
plan.Status = AiPlanStatus.Completed;
}
}
private static void CompletePlan(ShipRuntime ship, ShipPlanRuntime plan, ICollection<SimulationEventRecord> events)
{
plan.Status = AiPlanStatus.Completed;
var completedOrder = plan.SourceKind == AiPlanSourceKind.Order
? ship.OrderQueue.FirstOrDefault(order => order.Id == plan.SourceId)
: null;
if (completedOrder is not null)
{
completedOrder.Status = OrderStatus.Completed;
ship.OrderQueue.RemoveAll(order => order.Id == completedOrder.Id);
if (completedOrder.SourceKind == ShipOrderSourceKind.Behavior
&& string.Equals(completedOrder.SourceId, RepeatOrders, StringComparison.Ordinal)
&& ship.DefaultBehavior.RepeatOrders.Count > 0)
{
ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count;
}
}
ship.ActivePlan = null;
ship.NeedsReplan = true;
ship.ReplanCooldownSeconds = 0.25f;
ship.LastReplanReason = "plan-completed";
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-completed", $"{ship.Definition.Name} completed {plan.Kind}.", DateTimeOffset.UtcNow));
}
private void ReplacePlan(ShipRuntime ship, ShipPlanRuntime nextPlan, string reason, ICollection<SimulationEventRecord> events)
{
if (ship.ActivePlan is not null && ship.ActivePlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed)
{
ship.ActivePlan.Status = AiPlanStatus.Interrupted;
ship.ActivePlan.InterruptReason = reason;
}
ship.ActivePlan = nextPlan;
ship.ActiveOrderId = order.Id;
ship.ActiveSubTaskIndex = 0;
ship.ActiveSubTasks.Clear();
ship.ActiveSubTasks.AddRange(subTasks);
ship.NeedsReplan = false;
ship.ReplanCooldownSeconds = 0f;
ship.LastReplanReason = reason;
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-updated", $"{ship.Definition.Name} planned {nextPlan.Kind}.", DateTimeOffset.UtcNow));
ship.LastReplanReason = "order-execution-started";
ship.LastDeltaSignature = string.Empty;
}
private static void ClearActiveOrder(ShipRuntime ship)
{
ship.ActiveOrderId = null;
ship.ActiveSubTaskIndex = 0;
ship.ActiveSubTasks.Clear();
}
private void CompleteOrderExecution(ShipRuntime ship, ShipOrderRuntime order, ICollection<SimulationEventRecord> events)
{
ship.OrderQueue.TryCompleteOrder(order.Id);
if (order.SourceKind == ShipOrderSourceKind.Behavior
&& string.Equals(order.SourceId, RepeatOrders, StringComparison.Ordinal)
&& ship.DefaultBehavior.RepeatOrders.Count > 0)
{
ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count;
}
ClearActiveOrder(ship);
ship.NeedsReplan = true;
ship.ReplanCooldownSeconds = 0.25f;
ship.LastReplanReason = "order-completed";
ship.LastDeltaSignature = string.Empty;
events.Add(new SimulationEventRecord("ship", ship.Id, "order-completed", $"{ship.Definition.Name} completed {order.Label ?? order.Kind}.", DateTimeOffset.UtcNow));
}
private void FailOrderExecution(ShipRuntime ship, ShipOrderRuntime order, string failureReason, ICollection<SimulationEventRecord> events)
{
FailOrder(ship, order, failureReason);
ClearActiveOrder(ship);
ship.NeedsReplan = true;
ship.ReplanCooldownSeconds = 0.5f;
ship.LastReplanReason = failureReason;
ship.LastDeltaSignature = string.Empty;
events.Add(new SimulationEventRecord("ship", ship.Id, "order-failed", $"{ship.Definition.Name} failed {order.Label ?? order.Kind}.", DateTimeOffset.UtcNow));
}
private static void FailOrder(ShipRuntime ship, ShipOrderRuntime order, string failureReason)
{
ship.OrderQueue.TryFailOrder(order.Id, failureReason);
ship.LastDeltaSignature = string.Empty;
}
private static ShipSubTaskRuntime? GetCurrentSubTask(ShipRuntime ship) =>
ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex];
private void ApplyIdleOrBlockedState(SimulationWorld world, ShipRuntime ship)
{
var (behaviorKind, _) = ResolveBehaviorSource(world, ship);
if (IsBehaviorBlockingFailure(behaviorKind, ship.LastAccessFailureReason))
{
ship.State = ShipState.Blocked;
ship.TargetPosition = ship.Position;
return;
}
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
}
private void SyncEmergencyOrders(SimulationWorld world, ShipRuntime ship)
{
var desiredOrder = BuildEmergencyOrder(world, ship);
ship.OrderQueue.RemoveWhere(order =>
order.SourceKind == ShipOrderSourceKind.Behavior
&& string.Equals(order.SourceId, ShipOrderKinds.Flee, StringComparison.Ordinal)
&& (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)));
if (desiredOrder is null)
{
return;
}
ship.OrderQueue.AddOrReplaceManagedOrderAtFront(desiredOrder);
}
}

View File

@@ -0,0 +1,31 @@
using FastEndpoints;
namespace SpaceGame.Api.Ships.Api;
public sealed class ReorderShipOrderHandler(WorldService worldService) : Endpoint<ShipOrderReorderRequest, ShipSnapshot>
{
public override void Configure()
{
Put("/api/ships/{shipId}/orders/{orderId}/position");
}
public override async Task HandleAsync(ShipOrderReorderRequest request, CancellationToken cancellationToken)
{
var shipId = Route<string>("shipId");
var orderId = Route<string>("orderId");
if (string.IsNullOrWhiteSpace(shipId) || string.IsNullOrWhiteSpace(orderId))
{
await SendNotFoundAsync(cancellationToken);
return;
}
var snapshot = worldService.ReorderShipOrder(shipId, orderId, request);
if (snapshot is null)
{
await SendNotFoundAsync(cancellationToken);
return;
}
await SendOkAsync(snapshot, cancellationToken);
}
}

View File

@@ -0,0 +1,39 @@
using FastEndpoints;
namespace SpaceGame.Api.Ships.Api;
public sealed class UpdateShipOrderHandler(WorldService worldService) : Endpoint<ShipOrderUpdateCommandRequest, ShipSnapshot>
{
public override void Configure()
{
Put("/api/ships/{shipId}/orders/{orderId}");
}
public override async Task HandleAsync(ShipOrderUpdateCommandRequest request, CancellationToken cancellationToken)
{
var shipId = Route<string>("shipId");
var orderId = Route<string>("orderId");
if (string.IsNullOrWhiteSpace(shipId) || string.IsNullOrWhiteSpace(orderId))
{
await SendNotFoundAsync(cancellationToken);
return;
}
try
{
var snapshot = worldService.UpdateShipOrder(shipId, orderId, request);
if (snapshot is null)
{
await SendNotFoundAsync(cancellationToken);
return;
}
await SendOkAsync(snapshot, cancellationToken);
}
catch (InvalidOperationException ex)
{
AddError(ex.Message);
await SendErrorsAsync(cancellation: cancellationToken);
}
}
}

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,
@@ -19,6 +19,28 @@ public sealed record ShipOrderCommandRequest(
int? MaxSystemRange,
bool? KnownStationsOnly);
public sealed record ShipOrderUpdateCommandRequest(
string Kind,
int Priority,
bool InterruptCurrentPlan,
string? Label,
string? TargetEntityId,
string? TargetSystemId,
Vector3Dto? TargetPosition,
string? SourceStationId,
string? DestinationStationId,
string? ItemId,
string? AnchorId,
string? ConstructionSiteId,
string? ModuleId,
float? WaitSeconds,
float? Radius,
int? MaxSystemRange,
bool? KnownStationsOnly);
public sealed record ShipOrderReorderRequest(
int TargetIndex);
public sealed record ShipOrderTemplateCommandRequest(
string Kind,
string? Label,
@@ -28,7 +50,7 @@ public sealed record ShipOrderTemplateCommandRequest(
string? SourceStationId,
string? DestinationStationId,
string? ItemId,
string? NodeId,
string? AnchorId,
string? ConstructionSiteId,
string? ModuleId,
float? WaitSeconds,
@@ -43,7 +65,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,
@@ -106,35 +108,13 @@ public sealed record ShipSubTaskSnapshot(
float TotalSeconds,
string? BlockingReason);
public sealed record ShipPlanStepSnapshot(
string Id,
string Kind,
string Status,
string Summary,
string? BlockingReason,
int CurrentSubTaskIndex,
IReadOnlyList<ShipSubTaskSnapshot> SubTasks);
public sealed record ShipPlanSnapshot(
string Id,
string SourceKind,
string SourceId,
string Kind,
string Status,
string Summary,
int CurrentStepIndex,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc,
string? InterruptReason,
string? FailureReason,
IReadOnlyList<ShipPlanStepSnapshot> Steps);
public sealed record ShipSnapshot(
string Id,
string Name,
string Purpose,
string Type,
string SystemId,
string? AnchorId,
Vector3Dto LocalPosition,
Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition,
@@ -143,19 +123,17 @@ public sealed record ShipSnapshot(
DefaultBehaviorSnapshot DefaultBehavior,
ShipAssignmentSnapshot? Assignment,
ShipSkillProfileSnapshot Skills,
ShipPlanSnapshot? ActivePlan,
string? CurrentStepId,
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
string ControlSourceKind,
string? ControlSourceId,
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 +148,7 @@ public sealed record ShipDelta(
string Purpose,
string Type,
string SystemId,
string? AnchorId,
Vector3Dto LocalPosition,
Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition,
@@ -178,19 +157,17 @@ public sealed record ShipDelta(
DefaultBehaviorSnapshot DefaultBehavior,
ShipAssignmentSnapshot? Assignment,
ShipSkillProfileSnapshot Skills,
ShipPlanSnapshot? ActivePlan,
string? CurrentStepId,
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
string ControlSourceKind,
string? ControlSourceId,
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 +179,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

@@ -12,8 +12,7 @@ public sealed class ShipRuntime
public Vector3 Velocity { get; set; } = Vector3.Zero;
public ShipState State { get; set; } = ShipState.Idle;
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
public List<ShipOrderRuntime> OrderQueue { get; } = [];
public ShipPlanRuntime? ActivePlan { get; set; }
public ShipOrderQueue OrderQueue { get; } = new();
public required ShipSkillProfileRuntime Skills { get; set; }
public bool NeedsReplan { get; set; } = true;
public float ReplanCooldownSeconds { get; set; }
@@ -30,10 +29,190 @@ public sealed class ShipRuntime
public float Health { get; set; }
public HashSet<string> KnownStationIds { get; } = new(StringComparer.Ordinal);
public List<string> History { get; } = [];
public string? ActiveOrderId { get; set; }
public int ActiveSubTaskIndex { get; set; }
public List<ShipSubTaskRuntime> ActiveSubTasks { get; } = [];
public string LastSignature { get; set; } = string.Empty;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ShipOrderQueue : IReadOnlyList<ShipOrderRuntime>
{
public const int MaxOrders = 8;
private readonly List<ShipOrderRuntime> _orders = [];
public int Count => _orders.Count;
public ShipOrderRuntime this[int index] => _orders[index];
public IEnumerator<ShipOrderRuntime> GetEnumerator() => _orders.GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
public void Enqueue(ShipOrderRuntime order)
{
if (_orders.Count >= MaxOrders)
{
throw new InvalidOperationException("Order queue is full.");
}
_orders.Add(order);
}
public void EnqueuePlayerOrder(ShipOrderRuntime order)
{
if (order.SourceKind != ShipOrderSourceKind.Player)
{
throw new InvalidOperationException("Player segment only accepts player orders.");
}
EnsureCapacityForNewOrder(order.Id);
_orders.Insert(GetManagedInsertionIndex(), order);
}
public void EnqueueManagedOrder(ShipOrderRuntime order)
{
EnsureCapacityForNewOrder(order.Id);
_orders.Add(order);
}
public void AddOrReplaceManagedOrder(ShipOrderRuntime order)
=> AddOrReplaceManagedOrder(order, insertAtFront: false);
public void AddOrReplaceManagedOrderAtFront(ShipOrderRuntime order)
=> AddOrReplaceManagedOrder(order, insertAtFront: true);
private void AddOrReplaceManagedOrder(ShipOrderRuntime order, bool insertAtFront)
{
var existingIndex = _orders.FindIndex(candidate => string.Equals(candidate.Id, order.Id, StringComparison.Ordinal));
if (existingIndex >= 0)
{
_orders[existingIndex] = order;
return;
}
EnsureCapacityForNewOrder(order.Id);
if (insertAtFront)
{
_orders.Insert(GetManagedInsertionIndex(), order);
return;
}
_orders.Add(order);
}
public bool Remove(ShipOrderRuntime order) => RemoveById(order.Id);
public bool RemoveById(string orderId) => _orders.RemoveAll(order => string.Equals(order.Id, orderId, StringComparison.Ordinal)) > 0;
public int RemoveWhere(Predicate<ShipOrderRuntime> predicate) => _orders.RemoveAll(predicate);
public ShipOrderRuntime? FindById(string orderId) => _orders.FirstOrDefault(order => string.Equals(order.Id, orderId, StringComparison.Ordinal));
public ShipOrderRuntime? FindLeadingOrderForSource(ShipOrderSourceKind sourceKind) =>
_orders.FirstOrDefault(order => order.SourceKind == sourceKind);
public string? GetLeadingOrderLabelForSource(ShipOrderSourceKind sourceKind) =>
FindLeadingOrderForSource(sourceKind) is { } order
? order.Label ?? order.Kind
: null;
public bool HasOrdersFromSource(ShipOrderSourceKind sourceKind) => _orders.Any(order => order.SourceKind == sourceKind);
public ShipOrderRuntime? GetCurrentOrder() =>
_orders.FirstOrDefault(order => order.Status is OrderStatus.Queued or OrderStatus.Active);
public bool TryMovePlayerOrder(string orderId, int targetIndex)
{
var currentIndex = _orders.FindIndex(order => string.Equals(order.Id, orderId, StringComparison.Ordinal));
if (currentIndex < 0)
{
return false;
}
var order = _orders[currentIndex];
if (order.SourceKind != ShipOrderSourceKind.Player)
{
return false;
}
var playerOrderIds = _orders
.Select((candidate, index) => (candidate, index))
.Where(entry => entry.candidate.SourceKind == ShipOrderSourceKind.Player)
.Select(entry => entry.index)
.ToList();
if (playerOrderIds.Count <= 1)
{
return true;
}
var clampedPlayerIndex = Math.Clamp(targetIndex, 0, playerOrderIds.Count - 1);
var destinationIndex = playerOrderIds[clampedPlayerIndex];
if (currentIndex == destinationIndex)
{
return true;
}
_orders.RemoveAt(currentIndex);
if (currentIndex < destinationIndex)
{
destinationIndex -= 1;
}
_orders.Insert(destinationIndex, order);
return true;
}
public bool TryCompleteOrder(string orderId) => TryTransitionOrder(orderId, OrderStatus.Completed);
public bool TryFailOrder(string orderId, string? failureReason = null)
{
var order = FindById(orderId);
if (order is null)
{
return false;
}
order.FailureReason = failureReason ?? order.FailureReason;
if (order.SourceKind == ShipOrderSourceKind.Player)
{
order.Status = OrderStatus.Failed;
return true;
}
return TryTransitionOrder(orderId, OrderStatus.Failed);
}
public bool TryTransitionOrder(string orderId, OrderStatus terminalStatus)
{
var order = FindById(orderId);
if (order is null)
{
return false;
}
order.Status = terminalStatus;
return RemoveById(orderId);
}
private int GetManagedInsertionIndex() =>
_orders.TakeWhile(order => order.SourceKind == ShipOrderSourceKind.Player).Count();
private void EnsureCapacityForNewOrder(string orderId)
{
if (FindById(orderId) is not null)
{
return;
}
if (_orders.Count >= MaxOrders)
{
throw new InvalidOperationException("Order queue is full.");
}
}
}
public sealed class ShipSkillProfileRuntime
{
public int Navigation { get; set; }
@@ -60,7 +239,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 +257,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 +281,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; }
@@ -111,33 +290,6 @@ public sealed class ShipOrderTemplateRuntime
public bool KnownStationsOnly { get; set; }
}
public sealed class ShipPlanRuntime
{
public required string Id { get; init; }
public required AiPlanSourceKind SourceKind { get; init; }
public required string SourceId { get; init; }
public required string Kind { get; init; }
public required string Summary { get; set; }
public AiPlanStatus Status { get; set; } = AiPlanStatus.Planned;
public int CurrentStepIndex { get; set; }
public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public string? InterruptReason { get; set; }
public string? FailureReason { get; set; }
public List<ShipPlanStepRuntime> Steps { get; } = [];
}
public sealed class ShipPlanStepRuntime
{
public required string Id { get; init; }
public required string Kind { get; init; }
public required string Summary { get; set; }
public AiPlanStepStatus Status { get; set; } = AiPlanStepStatus.Planned;
public int CurrentSubTaskIndex { get; set; }
public string? BlockingReason { get; set; }
public List<ShipSubTaskRuntime> SubTasks { get; } = [];
}
public sealed class ShipSubTaskRuntime
{
public required string Id { get; init; }
@@ -146,7 +298,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,
@@ -188,19 +201,17 @@ internal sealed class SimulationProjectionService
ship.DefaultBehavior,
ship.Assignment,
ship.Skills,
ship.ActivePlan,
ship.CurrentStepId,
ship.ActiveSubTasks,
ship.ControlSourceKind,
ship.ControlSourceId,
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 +250,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 +314,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 +500,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 +542,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}";
@@ -520,9 +567,6 @@ internal sealed class SimulationProjectionService
ship.TargetPosition.Z.ToString("0.###"),
ship.State.ToContractValue(),
string.Join(",", ship.OrderQueue
.OrderByDescending(GetOrderSourcePriority)
.ThenByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => $"{order.Id}:{order.Kind}:{order.SourceKind.ToContractValue()}:{order.SourceId}:{order.Status.ToContractValue()}:{order.Priority}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")),
ship.DefaultBehavior.Kind,
ship.DefaultBehavior.TargetEntityId ?? "none",
@@ -546,23 +590,20 @@ internal sealed class SimulationProjectionService
ship.CommanderId is not null && world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment is { } assignment
? $"{assignment.ObjectiveId}:{assignment.Kind}:{assignment.BehaviorKind}:{assignment.Status}:{assignment.CampaignId}:{assignment.TheaterId}:{assignment.TargetSystemId}:{assignment.TargetEntityId}:{assignment.ItemId}:{assignment.Priority:0.###}:{assignment.UpdatedAtUtc.UtcTicks}"
: "no-assignment",
ship.ActivePlan?.Kind ?? "none",
ship.ActivePlan?.Status.ToContractValue() ?? "none",
ship.ActivePlan?.CurrentStepIndex.ToString(CultureInfo.InvariantCulture) ?? "-1",
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),
@@ -571,7 +612,9 @@ internal sealed class SimulationProjectionService
ship.Skills.Combat.ToString(CultureInfo.InvariantCulture),
ship.Skills.Construction.ToString(CultureInfo.InvariantCulture),
ship.Health.ToString("0.###"),
GetCurrentShipStep(ship)?.Id ?? "none");
ship.ActiveSubTaskIndex >= 0 && ship.ActiveSubTaskIndex < ship.ActiveSubTasks.Count
? ship.ActiveSubTasks[ship.ActiveSubTaskIndex].Id
: "none");
private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
string.Join(",",
@@ -653,13 +696,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 +730,7 @@ internal sealed class SimulationProjectionService
celestial.Kind.ToContractValue(),
ToDto(celestial.Position),
celestial.LocalSpaceRadius,
celestial.ParentNodeId,
celestial.ParentAnchorId,
celestial.OccupyingStructureId,
celestial.OrbitReferenceId);
@@ -677,8 +740,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 +800,7 @@ internal sealed class SimulationProjectionService
claim.Id,
claim.FactionId,
claim.SystemId,
claim.CelestialId,
claim.AnchorId,
claim.State,
claim.Health,
claim.PlacedAtUtc,
@@ -747,7 +810,7 @@ internal sealed class SimulationProjectionService
site.Id,
site.FactionId,
site.SystemId,
site.CelestialId,
site.AnchorId,
site.TargetKind,
site.TargetDefinitionId,
site.BlueprintId,
@@ -811,6 +874,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),
@@ -819,19 +883,22 @@ internal sealed class SimulationProjectionService
ToDefaultBehaviorSnapshot(ship.DefaultBehavior),
ToShipAssignmentSnapshot(commander),
new ShipSkillProfileSnapshot(ship.Skills.Navigation, ship.Skills.Trade, ship.Skills.Mining, ship.Skills.Combat, ship.Skills.Construction),
ToShipPlanSnapshot(ship.ActivePlan),
GetCurrentShipStep(ship)?.Id,
ToActiveSubTaskSnapshots(ship),
ship.ControlSourceKind,
ship.ControlSourceId,
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,
@@ -848,7 +915,7 @@ internal sealed class SimulationProjectionService
{
MovementRegimeKind.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"),
MovementRegimeKind.Warp => (ship.State == ShipState.Warping ? ship.Definition.WarpSpeed : 0f, "AU/s"),
_ => (MathF.Sqrt(MathF.Max(0f, ship.Velocity.LengthSquared())) * SimulationUnits.MetersPerKilometer, "m/s"),
_ => (MathF.Sqrt(MathF.Max(0f, ship.Velocity.LengthSquared())), "m/s"),
};
}
@@ -861,9 +928,6 @@ internal sealed class SimulationProjectionService
private static IReadOnlyList<ShipOrderSnapshot> ToShipOrderSnapshots(ShipRuntime ship) =>
ship.OrderQueue
.OrderByDescending(GetOrderSourcePriority)
.ThenByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => new ShipOrderSnapshot(
order.Id,
order.Kind,
@@ -880,7 +944,7 @@ internal sealed class SimulationProjectionService
order.SourceStationId,
order.DestinationStationId,
order.ItemId,
order.NodeId,
order.AnchorId,
order.ConstructionSiteId,
order.ModuleId,
order.WaitSeconds,
@@ -890,14 +954,6 @@ internal sealed class SimulationProjectionService
order.FailureReason))
.ToList();
private static int GetOrderSourcePriority(ShipOrderRuntime order) => order.SourceKind switch
{
ShipOrderSourceKind.Player => 300,
ShipOrderSourceKind.Commander => 200,
ShipOrderSourceKind.Behavior => 100,
_ => 0,
};
private static DefaultBehaviorSnapshot ToDefaultBehaviorSnapshot(DefaultBehaviorRuntime behavior) =>
new(
behavior.Kind,
@@ -906,7 +962,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 +985,7 @@ internal sealed class SimulationProjectionService
template.SourceStationId,
template.DestinationStationId,
template.ItemId,
template.NodeId,
template.AnchorId,
template.ConstructionSiteId,
template.ModuleId,
template.WaitSeconds,
@@ -964,48 +1020,18 @@ internal sealed class SimulationProjectionService
assignment.UpdatedAtUtc);
}
private static ShipPlanSnapshot? ToShipPlanSnapshot(ShipPlanRuntime? plan)
{
if (plan is null)
{
return null;
}
return new ShipPlanSnapshot(
plan.Id,
plan.SourceKind.ToContractValue(),
plan.SourceId,
plan.Kind,
plan.Status.ToContractValue(),
plan.Summary,
plan.CurrentStepIndex,
plan.CreatedAtUtc,
plan.UpdatedAtUtc,
plan.InterruptReason,
plan.FailureReason,
plan.Steps.Select(ToShipPlanStepSnapshot).ToList());
}
private static ShipPlanStepSnapshot ToShipPlanStepSnapshot(ShipPlanStepRuntime step) =>
new(
step.Id,
step.Kind,
step.Status.ToContractValue(),
step.Summary,
step.BlockingReason,
step.CurrentSubTaskIndex,
step.SubTasks.Select(ToShipSubTaskSnapshot).ToList());
private static ShipSubTaskSnapshot ToShipSubTaskSnapshot(ShipSubTaskRuntime subTask) =>
new(
subTask.Id,
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,
@@ -1017,23 +1043,12 @@ internal sealed class SimulationProjectionService
private static IReadOnlyList<ShipSubTaskSnapshot> ToActiveSubTaskSnapshots(ShipRuntime ship)
{
var step = GetCurrentShipStep(ship);
if (step is null)
{
return [];
}
return step.SubTasks
return ship.ActiveSubTasks
.Where(subTask => subTask.Status is WorkStatus.Pending or WorkStatus.Active or WorkStatus.Blocked)
.Select(ToShipSubTaskSnapshot)
.ToList();
}
private static ShipPlanStepRuntime? GetCurrentShipStep(ShipRuntime ship) =>
ship.ActivePlan is null || ship.ActivePlan.CurrentStepIndex >= ship.ActivePlan.Steps.Count
? null
: ship.ActivePlan.Steps[ship.ActivePlan.CurrentStepIndex];
private static CommanderAssignmentSnapshot ToCommanderAssignmentSnapshot(CommanderRuntime commander)
{
var assignment = commander.Assignment;
@@ -1408,7 +1423,7 @@ internal sealed class SimulationProjectionService
claim.SourceClaimId,
claim.FactionId,
claim.SystemId,
claim.CelestialId,
claim.AnchorId,
claim.Status,
claim.ClaimKind,
claim.ClaimStrength,
@@ -1564,15 +1579,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,110 @@ 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 derivedDepositCount = Math.Clamp((int)MathF.Round(MathF.Sqrt(MathF.Max(oreAmount, 1f)) / 18f), 4, 18);
var depositCount = Math.Clamp(definition.ShardCount > 0 ? definition.ShardCount : derivedDepositCount, 4, 48);
var deposits = new List<ResourceDepositRuntime>(depositCount);
var weightTotal = 0f;
var weights = new float[depositCount];
var random = new Random(ComputeDeterministicSeed(systemId, nodeId, "resource-deposits"));
for (var index = 0; index < depositCount; index += 1)
{
return new Vector3(offset.X, yPlane + offset.Y, offset.Z);
var weight = 0.8f + (NextFloat01(random) * 1.6f);
weights[index] = weight;
weightTotal += weight;
}
return Add(anchorCelestial.Position, offset);
// Resource node localspace should read as a compact mineable field around the node core,
// not as sparse debris spread across the entire anchor volume.
var scatterRadius = MathF.Max(120f, MathF.Min(LocalSpaceRadius * 0.2f, 900f));
for (var index = 0; index < depositCount; index += 1)
{
var angle = NextFloat01(random) * MathF.PI * 2f;
var radiusFactor = 0.12f + (NextFloat01(random) * 0.82f);
var radius = scatterRadius * MathF.Sqrt(radiusFactor);
var vertical = (NextFloat01(random) - 0.5f) * MathF.Max(40f, scatterRadius * 0.18f);
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 int ComputeDeterministicSeed(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;
}
}
private static float NextFloat01(Random random) => (float)random.NextDouble();
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,20 +419,25 @@ 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 systemPosition = SimulationUnits.MetersToKilometers(position);
var nearestAnchor = anchors
.Where(anchor => anchor.SystemId == systemId)
.OrderBy(anchor => anchor.Position.DistanceTo(systemPosition))
.FirstOrDefault();
var localPosition = position;
var resolvedSystemPosition = nearestAnchor is null
? systemPosition
: Add(nearestAnchor.Position, SimulationUnits.MetersToKilometers(localPosition));
return new ShipSpatialStateRuntime
{
CurrentSystemId = systemId,
SpaceLayer = SpaceLayerKind.LocalSpace,
CurrentCelestialId = nearestCelestial?.Id,
LocalPosition = position,
SystemPosition = position,
CurrentAnchorId = nearestAnchor?.Id,
LocalPosition = localPosition,
SystemPosition = resolvedSystemPosition,
MovementRegime = MovementRegimeKind.LocalFlight,
};
}
@@ -307,6 +445,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 +456,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

@@ -16,7 +16,11 @@ public sealed class WorldBuilder(
WorldGenerationOptions worldGenerationOptions,
ScenarioDefinition? scenarioDefinition)
{
var topology = topologyBuilder.Build(worldGenerationOptions);
// Temporary QA override: allow a scenario to provide an exact system list
// instead of going through procedural topology generation.
var topology = scenarioDefinition?.Systems is { Count: > 0 } scenarioSystems
? topologyBuilder.Build(scenarioSystems)
: topologyBuilder.Build(worldGenerationOptions);
var scenario = scenarioDefinition ?? scenarioValidationService.CreateEmptyScenario(worldGenerationOptions, topology.Systems);
scenarioValidationService.Validate(scenario, topology.Systems.Select(system => system.Id).ToHashSet(StringComparer.Ordinal));

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

@@ -13,6 +13,22 @@ public sealed class WorldTopologyBuilder(
generationService.PrepareKnownSystems(staticData.KnownSystems),
worldGenerationOptions);
return BuildFromDefinitions(systems);
}
public WorldBuildTopology Build(IReadOnlyList<SolarSystemDefinition> systems)
{
if (systems.Count == 0)
{
throw new InvalidOperationException("Scenario-defined systems cannot be empty.");
}
// Temporary QA-only path for fixed-topology scenarios such as "minimal".
return BuildFromDefinitions(systems);
}
private WorldBuildTopology BuildFromDefinitions(IReadOnlyList<SolarSystemDefinition> systems)
{
var systemRuntimes = systems
.Select(definition => new SystemRuntime
{

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,30 @@ 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;
var localSystemOffset = SimulationUnits.MetersToKilometers(ship.Position);
ship.SpatialState.SystemPosition = currentAnchor is null
? localSystemOffset
: Add(currentAnchor.Position, localSystemOffset);
if (ship.DockedStationId is null)
{
@@ -282,9 +319,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

@@ -129,6 +129,39 @@ public sealed class WorldService
}
}
public ShipSnapshot? UpdateShipOrder(string shipId, string orderId, ShipOrderUpdateCommandRequest request)
{
lock (_sync)
{
ValidateShipOrderRequestUnsafe(shipId, ToCommandRequest(request));
var ship = CanCurrentActorAccessGm()
? UpdateGmShipOrderUnsafe(shipId, orderId, request)
: _playerFaction.UpdateDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, orderId, request);
if (ship is null)
{
return null;
}
return GetShipSnapshotUnsafe(ship.Id);
}
}
public ShipSnapshot? ReorderShipOrder(string shipId, string orderId, ShipOrderReorderRequest request)
{
lock (_sync)
{
var ship = CanCurrentActorAccessGm()
? ReorderGmShipOrderUnsafe(shipId, orderId, request.TargetIndex)
: _playerFaction.ReorderDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, orderId, request.TargetIndex);
if (ship is null)
{
return null;
}
return GetShipSnapshotUnsafe(ship.Id);
}
}
public ShipSnapshot? UpdateShipDefaultBehavior(string shipId, ShipDefaultBehaviorCommandRequest request)
{
lock (_sync)
@@ -315,6 +348,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 +357,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 +387,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 +413,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 +529,7 @@ public sealed class WorldService
[],
[],
[],
[],
null);
_history.Enqueue(worldDelta);
@@ -526,6 +566,7 @@ public sealed class WorldService
[],
[],
[],
[],
null);
_history.Enqueue(worldDelta);
@@ -608,6 +649,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 +658,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,
@@ -684,6 +727,30 @@ public sealed class WorldService
}
}
private static void ApplyShipOrderRequest(ShipOrderRuntime order, ShipOrderUpdateCommandRequest request)
{
order.Priority = request.Priority;
order.InterruptCurrentPlan = request.InterruptCurrentPlan;
order.Label = request.Label;
order.TargetEntityId = request.TargetEntityId;
order.TargetSystemId = request.TargetSystemId;
order.TargetPosition = request.TargetPosition is null
? null
: new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z);
order.SourceStationId = request.SourceStationId;
order.DestinationStationId = request.DestinationStationId;
order.ItemId = request.ItemId;
order.AnchorId = request.AnchorId;
order.ConstructionSiteId = request.ConstructionSiteId;
order.ModuleId = request.ModuleId;
order.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f);
order.Radius = MathF.Max(0f, request.Radius ?? 0f);
order.MaxSystemRange = request.MaxSystemRange;
order.KnownStationsOnly = request.KnownStationsOnly ?? false;
order.Status = OrderStatus.Queued;
order.FailureReason = null;
}
private ShipRuntime? EnqueueGmShipOrderUnsafe(string shipId, ShipOrderCommandRequest request)
{
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
@@ -692,12 +759,7 @@ public sealed class WorldService
return null;
}
if (ship.OrderQueue.Count >= 8)
{
throw new InvalidOperationException("Order queue is full.");
}
ship.OrderQueue.Add(new ShipOrderRuntime
ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime
{
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
Kind = request.Kind,
@@ -712,7 +774,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),
@@ -722,12 +784,7 @@ public sealed class WorldService
});
ship.ControlSourceKind = "gm-order";
ship.ControlSourceId = ship.OrderQueue
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
ship.ControlReason = request.Label ?? request.Kind;
ship.NeedsReplan = true;
ship.LastReplanReason = "gm-order-enqueued";
@@ -743,22 +800,12 @@ public sealed class WorldService
return null;
}
ship.OrderQueue.RemoveAll(order => order.Id == orderId);
ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
ship.OrderQueue.RemoveById(orderId);
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
? "gm-order"
: "gm-manual";
ship.ControlSourceId = ship.OrderQueue
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
ship.ControlReason = ship.OrderQueue
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Label ?? order.Kind)
.FirstOrDefault()
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
?? "manual-gm-control";
ship.NeedsReplan = true;
ship.LastReplanReason = "gm-order-removed";
@@ -766,6 +813,59 @@ public sealed class WorldService
return ship;
}
private ShipRuntime? UpdateGmShipOrderUnsafe(string shipId, string orderId, ShipOrderUpdateCommandRequest request)
{
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
if (ship is null)
{
return null;
}
var order = ship.OrderQueue.FindById(orderId);
if (order is null || order.SourceKind != ShipOrderSourceKind.Player)
{
return null;
}
ApplyShipOrderRequest(order, request);
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
? "gm-order"
: "gm-manual";
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
?? request.Label
?? request.Kind;
ship.NeedsReplan = true;
ship.LastReplanReason = "gm-order-updated";
ship.LastDeltaSignature = string.Empty;
return ship;
}
private ShipRuntime? ReorderGmShipOrderUnsafe(string shipId, string orderId, int targetIndex)
{
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
if (ship is null)
{
return null;
}
if (!ship.OrderQueue.TryMovePlayerOrder(orderId, targetIndex))
{
return ship;
}
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
? "gm-order"
: "gm-manual";
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
?? "manual-gm-control";
ship.NeedsReplan = true;
ship.LastReplanReason = "gm-order-reordered";
ship.LastDeltaSignature = string.Empty;
return ship;
}
private ShipRuntime? ConfigureGmShipBehaviorUnsafe(string shipId, ShipDefaultBehaviorCommandRequest request)
{
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
@@ -780,7 +880,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 +907,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,
@@ -827,6 +927,26 @@ public sealed class WorldService
return ship;
}
private static ShipOrderCommandRequest ToCommandRequest(ShipOrderUpdateCommandRequest request) =>
new(
request.Kind,
request.Priority,
request.InterruptCurrentPlan,
request.Label,
request.TargetEntityId,
request.TargetSystemId,
request.TargetPosition,
request.SourceStationId,
request.DestinationStationId,
request.ItemId,
request.AnchorId,
request.ConstructionSiteId,
request.ModuleId,
request.WaitSeconds,
request.Radius,
request.MaxSystemRange,
request.KnownStationsOnly);
private CommanderRuntime CreateFactionCommander(FactionRuntime faction) => new()
{
Id = $"commander-faction-{faction.Id}",
@@ -905,6 +1025,19 @@ public sealed class WorldService
return new Vector3(MathF.Cos(angle) * radius, 0f, MathF.Sin(angle) * radius);
}
private AnchorRuntime? ResolveNearestConstructibleAnchor(string systemId, Vector3 position)
{
var systemPosition = SimulationUnits.MetersToKilometers(position);
return _world.Anchors
.Where(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal))
.Where(candidate => SpatialBuilder.IsConstructibleAnchorKind(candidate.Kind))
.OrderBy(candidate => candidate.Position.DistanceTo(systemPosition))
.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 +1212,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 +1224,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 +1270,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 +1315,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

@@ -1,14 +1,18 @@
import * as THREE from "three";
import {
LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM,
LOCAL_CAMERA_DISTANCE_AT_TRANSITION,
LOCAL_SYSTEM_BACKDROP_DISTANCE,
MAX_CAMERA_DISTANCE,
MIN_CAMERA_DISTANCE,
MIN_LOCAL_CAMERA_DISTANCE,
NAV_DISTANCE,
} from "./viewerConstants";
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,
@@ -30,6 +34,7 @@ import { SystemLayer } from "./viewerSystemLayer";
import { LocalLayer } from "./viewerLocalLayer";
import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState";
import { describeSelectable } from "./viewerSelection";
import { resolveLocalAnchorOffset } from "./viewerWorldPresentation";
import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection";
import { useViewerSceneStore } from "./ui/stores/viewerScene";
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
@@ -88,6 +93,7 @@ export class ViewerAppController {
private selectedItems: Selectable[] = [];
private worldSignature = "";
private povLevel: PovLevel = "system";
private previousPovLevel: PovLevel = "system";
private currentDistance = NAV_DISTANCE.system;
private desiredDistance = NAV_DISTANCE.system;
private orbitYaw = -2.3;
@@ -100,6 +106,7 @@ export class ViewerAppController {
private marqueeActive = false;
private suppressClickSelection = false;
private activeSystemId?: string;
private cameraFocusedAnchorId?: string;
private cameraTargetShipId?: string;
private readonly followCameraPosition = new THREE.Vector3();
private readonly followCameraFocus = new THREE.Vector3();
@@ -195,6 +202,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,
@@ -261,15 +269,34 @@ export class ViewerAppController {
});
}
private computeOrbitOffset(): THREE.Vector3 {
const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch);
private computeOrbitOffset(cameraDistance: number): THREE.Vector3 {
const horizontalDistance = cameraDistance * Math.cos(this.orbitPitch);
return new THREE.Vector3(
Math.cos(this.orbitYaw) * horizontalDistance,
this.currentDistance * Math.sin(this.orbitPitch),
cameraDistance * Math.sin(this.orbitPitch),
Math.sin(this.orbitYaw) * horizontalDistance,
);
}
private resolveLocalOrbitCameraDistance() {
const clamped = THREE.MathUtils.clamp(this.currentDistance, MIN_LOCAL_CAMERA_DISTANCE, 650);
return THREE.MathUtils.mapLinear(
clamped,
MIN_LOCAL_CAMERA_DISTANCE,
650,
LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM,
LOCAL_CAMERA_DISTANCE_AT_TRANSITION,
);
}
private resolveSystemOrbitCameraDistance() {
if (this.povLevel !== "local") {
return this.currentDistance;
}
return LOCAL_SYSTEM_BACKDROP_DISTANCE;
}
private updateCamera(delta: number) {
const nextState = stepCamera({
currentDistance: this.currentDistance,
@@ -278,33 +305,37 @@ export class ViewerAppController {
delta,
});
this.currentDistance = nextState.currentDistance;
this.previousPovLevel = this.povLevel;
this.povLevel = nextState.povLevel;
this.orbitPitch = nextState.orbitPitch;
if (this.sceneStore.povLevel !== this.povLevel) {
this.sceneStore.setViewContext(this.activeSystemId ?? null, this.povLevel);
}
this.navigationController.updateActiveSystem();
this.navigationController.syncGalaxyAnchorToActiveSystem();
this.updateCameraFocusedAnchor();
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
// Follow camera directly controls systemLayer.camera in updateFollowCamera.
// Still update galaxy camera independently.
const orbitOffset = this.computeOrbitOffset();
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
const systemOrbitOffset = this.computeOrbitOffset(this.resolveSystemOrbitCameraDistance());
this.galaxyLayer.updateCamera(this.galaxyAnchor, systemOrbitOffset);
return;
}
this.updatePanFromKeyboard(delta);
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.92, 1.32);
const orbitOffset = this.computeOrbitOffset();
const systemOrbitOffset = this.computeOrbitOffset(this.resolveSystemOrbitCameraDistance());
const localOrbitOffset = this.computeOrbitOffset(this.resolveLocalOrbitCameraDistance());
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
this.galaxyLayer.updateCamera(this.galaxyAnchor, systemOrbitOffset);
if (this.activeSystemId) {
this.systemLayer.updateCamera(getSystemCameraFocus(this.systemAnchor), orbitOffset);
this.systemLayer.updateCamera(getSystemCameraFocus(this.systemAnchor), systemOrbitOffset);
}
this.localLayer.updateCamera(orbitOffset);
this.localLayer.updateCamera(this.systemAnchor, localOrbitOffset, resolveLocalAnchorOffset(this.world, this.resolveFocusedAnchorId()));
// Update star dot scales in galaxy scene
updateSystemStarPresentation(
@@ -350,8 +381,49 @@ export class ViewerAppController {
this.interactionController.refreshHistoryWindows();
}
private resolveFocusedCelestialId() {
return resolveFocusedCelestialId(this.world, this.selectedItems);
private resolveFocusedAnchorId() {
return this.cameraFocusedAnchorId;
}
private updateCameraFocusedAnchor() {
if (!this.world || !this.activeSystemId || this.povLevel === "galaxy") {
this.cameraFocusedAnchorId = undefined;
return;
}
if (this.povLevel === "system") {
this.cameraFocusedAnchorId = this.resolveNearestAnchorToSystemFocus();
return;
}
if (this.previousPovLevel !== "local" || !this.cameraFocusedAnchorId) {
this.cameraFocusedAnchorId = this.resolveNearestAnchorToSystemFocus() ?? this.cameraFocusedAnchorId;
}
}
private resolveNearestAnchorToSystemFocus() {
if (!this.world || !this.activeSystemId) {
return undefined;
}
let bestAnchorId: string | undefined;
let bestDistance = Number.POSITIVE_INFINITY;
for (const anchor of this.world.anchors.values()) {
if (anchor.systemId !== this.activeSystemId) {
continue;
}
const dx = anchor.systemPosition.x - this.systemAnchor.x;
const dy = anchor.systemPosition.y - this.systemAnchor.y;
const dz = anchor.systemPosition.z - this.systemAnchor.z;
const distanceSquared = (dx * dx) + (dy * dy) + (dz * dz);
if (distanceSquared < bestDistance) {
bestDistance = distanceSquared;
bestAnchorId = anchor.id;
}
}
return bestAnchorId;
}
private onResize(width: number, height: number) {

View File

@@ -23,12 +23,13 @@ import type {
import type {
ShipDefaultBehaviorCommandRequest,
ShipOrderCommandRequest,
ShipOrderUpdateCommandRequest,
} from "./shipCommands";
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 +106,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()}`);
@@ -318,3 +319,11 @@ export async function removeShipOrder(shipId: string, orderId: string) {
method: "DELETE",
});
}
export async function updateShipOrder(shipId: string, orderId: string, request: ShipOrderUpdateCommandRequest) {
return fetchJson<ShipSnapshot>(`/api/ships/${shipId}/orders/${orderId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
}

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,22 +130,41 @@ 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" : "";
const behaviorToken = compactLabel(getShipBehaviorLabel(ship.defaultBehavior.kind), "AUTO");
const planToken = ship.activePlan?.steps.length ? "PLAN" : "";
const taskToken = ship.activeSubTasks.length > 0 ? "TSK" : "";
const orderToken = ship.orderQueue.length > 0 ? "ORD" : "";
const commandToken = ship.commanderId ? "CMD" : "";
return uniqueTokens([behaviorToken, orderToken, planToken, travelToken, dockToken, commandToken]).slice(0, 5);
return uniqueTokens([behaviorToken, orderToken, taskToken, travelToken, dockToken, commandToken]).slice(0, 5);
}
function stationAiStates(station: StationSnapshot) {

View File

@@ -2,7 +2,8 @@
import { computed, reactive, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import modulesData from "../../../../shared/data/modules.json";
import { enqueueShipOrder, removeShipOrder, updateShipDefaultBehavior } from "../api";
import { removeShipOrder, updateShipDefaultBehavior, updateShipOrder } from "../api";
import type { ShipOrderSnapshot } from "../contractsShips";
import {
formatShipAutomationSupportStatus,
getShipBehaviorLabel,
@@ -43,16 +44,23 @@ const behaviorForm = reactive({
areaSystemId: "",
itemId: "ore",
});
const mineOrderForm = reactive({
systemId: "",
itemId: "ore",
});
const moveOrderSystemId = ref("");
const actionBusy = ref(false);
const actionStatus = ref("");
const actionError = ref("");
const expandedDirectOrderId = ref<string | null>(null);
const orderEditForm = reactive({
label: "",
priority: "100",
interruptCurrentPlan: true,
targetSystemId: "",
targetEntityId: "",
itemId: "",
waitSeconds: "0",
radius: "0",
maxSystemRange: "",
knownStationsOnly: false,
});
const moduleNameById = new Map<string, string>(
(modulesData as { id: string; name: string }[]).map((module) => [module.id, module.name]),
@@ -80,15 +88,51 @@ 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(" · ");
}
function describeOrderFailure(order: {
failureReason?: string | null;
kind: string;
itemId?: string | null;
}) {
switch (order.failureReason) {
case "mine-order-node-missing":
return `Cannot find ${order.itemId ?? "resource"} to mine`;
case "mine-order-item-missing":
return "No mining ware selected";
case "mine-order-node-system-mismatch":
return "Selected mining target is in the wrong system";
case "mine-order-node-item-mismatch":
return `Selected mining target does not provide ${order.itemId ?? "the requested ware"}`;
case "mine-order-incomplete":
case "mine-and-deliver-order-incomplete":
return `Cannot complete ${getShipOrderLabel(order.kind).toLowerCase()}`;
case "target-ship-missing":
return "Target ship no longer exists";
case "target-missing":
return "Target no longer exists";
case "station-missing":
return "Station no longer exists";
default:
return order.failureReason ? titleCase(order.failureReason) : null;
}
}
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 +141,7 @@ function describeOrderTarget(order: {
return order.itemId
?? order.targetEntityId
?? order.targetSystemId
?? order.nodeId
?? order.anchorId
?? order.constructionSiteId
?? order.destinationStationId
?? order.sourceStationId
@@ -109,13 +153,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
?? "—";
}
@@ -151,11 +197,7 @@ const canDirectControlSelectedShip = computed(() =>
);
const directOrders = computed(() =>
selectedShip.value?.orderQueue.filter((order) => order.sourceKind !== "behavior") ?? [],
);
const behaviorOrders = computed(() =>
selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "behavior") ?? [],
selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "player") ?? [],
);
const editableBehaviorDefinitions = computed(() =>
@@ -179,20 +221,30 @@ const formBehaviorNotes = computed(() =>
getShipBehaviorNotes(behaviorForm.kind),
);
const behaviorGeneratedOrderCount = computed(() =>
selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "behavior").length ?? 0,
);
const shipStatusRows = computed(() => {
if (!selectedShip.value) {
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" },
{
label: "Plan",
value: selectedShip.value.activePlan
? `${selectedShip.value.activePlan.kind} · ${titleCase(selectedShip.value.activePlan.status)}`
label: "Activity",
value: selectedShip.value.activeSubTasks[0]
? `${selectedShip.value.activeSubTasks[0].summary || titleCase(selectedShip.value.activeSubTasks[0].kind)} · ${titleCase(selectedShip.value.activeSubTasks[0].status)}`
: "none",
},
{ label: "Failure", value: selectedShip.value.lastAccessFailureReason ?? "none" },
@@ -201,20 +253,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(() =>
@@ -243,74 +296,45 @@ const shipBehaviorRows = computed(() => {
const directOrderRows = computed(() =>
directOrders.value.map((order) => ({
id: order.id,
kind: order.kind,
label: getShipOrderLabel(order.kind),
status: titleCase(order.status),
target: describeOrderTarget(order),
detail: joinDetail([
`P${order.priority}`,
titleCase(order.sourceKind),
order.failureReason ?? undefined,
describeOrderFailure(order) ?? undefined,
]),
})),
);
const behaviorOrderRows = computed(() =>
behaviorOrders.value.map((order) => ({
id: order.id,
label: getShipOrderLabel(order.kind),
status: titleCase(order.status),
target: describeOrderTarget(order),
const shipPlanRows = computed(() =>
(selectedShip.value?.activeSubTasks ?? []).map((subTask) => ({
id: subTask.id,
scope: "Task",
activity: subTask.summary || titleCase(subTask.kind),
status: titleCase(subTask.status),
detail: joinDetail([
`P${order.priority}`,
getShipOrderSupportStatusLabel(order.kind) ?? undefined,
getShipOrderNotes(order.kind) ?? undefined,
order.failureReason ?? undefined,
describeSubTaskTarget(subTask),
subTask.blockingReason ?? undefined,
`${Math.round(subTask.progress * 100)}%`,
]),
isSubTask: false,
})),
);
const shipPlanRows = computed(() => {
if (!selectedShip.value?.activePlan) {
return [];
}
return selectedShip.value.activePlan.steps.flatMap((step) => {
const stepRow = {
id: step.id,
scope: "Step",
activity: step.summary || titleCase(step.kind),
status: titleCase(step.status),
detail: joinDetail([
step.blockingReason ?? undefined,
`${step.subTasks.length} subtasks`,
]),
isSubTask: false,
};
const subTaskRows = step.subTasks.map((subTask) => ({
id: subTask.id,
scope: "Subtask",
activity: subTask.summary || titleCase(subTask.kind),
status: titleCase(subTask.status),
detail: joinDetail([
describeSubTaskTarget(subTask),
subTask.blockingReason ?? undefined,
`${Math.round(subTask.progress * 100)}%`,
]),
isSubTask: true,
}));
return [stepRow, ...subTaskRows];
});
});
const stationStatusRows = computed(() => {
if (!selectedStation.value) {
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 +359,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,
})) ?? [],
);
@@ -373,15 +399,116 @@ watch(
behaviorForm.kind = ship.defaultBehavior.kind;
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
mineOrderForm.systemId = ship.systemId ?? "";
mineOrderForm.itemId = "ore";
moveOrderSystemId.value = ship.systemId ?? "";
actionStatus.value = "";
actionError.value = "";
expandedDirectOrderId.value = null;
},
{ immediate: true },
);
function supportsOrderField(kind: string, field: "targetSystemId" | "targetEntityId" | "itemId" | "waitSeconds" | "radius" | "maxSystemRange" | "knownStationsOnly") {
switch (field) {
case "targetSystemId":
return kind === "move" || kind === "mine-and-deliver";
case "targetEntityId":
return kind === "follow-ship" || kind === "attack-target";
case "itemId":
return kind === "mine-and-deliver";
case "waitSeconds":
return kind === "hold-position" || kind === "follow-ship";
case "radius":
return kind === "move" || kind === "follow-ship";
case "maxSystemRange":
return kind === "mine-and-deliver";
case "knownStationsOnly":
return kind === "mine-and-deliver";
default:
return false;
}
}
function loadOrderEditor(order: ShipOrderSnapshot) {
orderEditForm.label = order.label ?? "";
orderEditForm.priority = String(order.priority);
orderEditForm.interruptCurrentPlan = order.interruptCurrentPlan;
orderEditForm.targetSystemId = order.targetSystemId ?? "";
orderEditForm.targetEntityId = order.targetEntityId ?? "";
orderEditForm.itemId = order.itemId ?? "ore";
orderEditForm.waitSeconds = String(order.waitSeconds ?? 0);
orderEditForm.radius = String(order.radius ?? 0);
orderEditForm.maxSystemRange = order.maxSystemRange == null ? "" : String(order.maxSystemRange);
orderEditForm.knownStationsOnly = order.knownStationsOnly;
}
function toggleOrderEditor(order: ShipOrderSnapshot) {
if (expandedDirectOrderId.value === order.id) {
expandedDirectOrderId.value = null;
return;
}
loadOrderEditor(order);
expandedDirectOrderId.value = order.id;
}
function parseNumber(value: string, fallback: number) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function parseOptionalInt(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : null;
}
async function saveOrder(order: ShipOrderSnapshot) {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
}
await runShipAction(async () => {
const ship = await updateShipOrder(selectedShip.value!.id, order.id, {
kind: order.kind,
priority: Math.max(0, Math.round(parseNumber(orderEditForm.priority, order.priority))),
interruptCurrentPlan: orderEditForm.interruptCurrentPlan,
label: orderEditForm.label.trim() || null,
targetEntityId: supportsOrderField(order.kind, "targetEntityId")
? (orderEditForm.targetEntityId.trim() || null)
: order.targetEntityId ?? null,
targetSystemId: supportsOrderField(order.kind, "targetSystemId")
? (orderEditForm.targetSystemId.trim() || null)
: order.targetSystemId ?? null,
targetPosition: order.targetPosition ?? null,
sourceStationId: order.sourceStationId ?? null,
destinationStationId: order.destinationStationId ?? null,
itemId: supportsOrderField(order.kind, "itemId")
? (orderEditForm.itemId.trim() || null)
: order.itemId ?? null,
anchorId: order.anchorId ?? null,
constructionSiteId: order.constructionSiteId ?? null,
moduleId: order.moduleId ?? null,
waitSeconds: supportsOrderField(order.kind, "waitSeconds")
? parseNumber(orderEditForm.waitSeconds, order.waitSeconds)
: order.waitSeconds,
radius: supportsOrderField(order.kind, "radius")
? parseNumber(orderEditForm.radius, order.radius)
: order.radius,
maxSystemRange: supportsOrderField(order.kind, "maxSystemRange")
? parseOptionalInt(orderEditForm.maxSystemRange)
: order.maxSystemRange ?? null,
knownStationsOnly: supportsOrderField(order.kind, "knownStationsOnly")
? orderEditForm.knownStationsOnly
: order.knownStationsOnly,
});
gmStore.upsertShip(ship);
expandedDirectOrderId.value = null;
}, "Order updated.");
}
function focusShip(cameraMode?: "follow" | "tactical") {
if (!selectedShip.value) {
return;
@@ -429,7 +556,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,
@@ -444,114 +571,6 @@ async function saveBehavior() {
}, "Default behavior updated.");
}
async function queueHoldPositionOrder() {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
}
await runShipAction(async () => {
const ship = await enqueueShipOrder(selectedShip.value!.id, {
kind: "hold-position",
priority: 100,
interruptCurrentPlan: true,
label: "Hold position",
targetEntityId: null,
targetSystemId: null,
targetPosition: null,
sourceStationId: null,
destinationStationId: null,
itemId: null,
nodeId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 0,
radius: 0,
maxSystemRange: 0,
knownStationsOnly: false,
});
gmStore.upsertShip(ship);
}, "Hold position order queued.");
}
async function queueMoveOrder() {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
}
const targetSystemId = moveOrderSystemId.value.trim();
if (!targetSystemId) {
actionError.value = "Select a target system.";
actionStatus.value = "";
return;
}
await runShipAction(async () => {
const ship = await enqueueShipOrder(selectedShip.value!.id, {
kind: "move",
priority: 90,
interruptCurrentPlan: true,
label: `Move to ${targetSystemId}`,
targetEntityId: null,
targetSystemId,
targetPosition: null,
sourceStationId: null,
destinationStationId: null,
itemId: null,
nodeId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 0,
radius: 0,
maxSystemRange: 0,
knownStationsOnly: false,
});
gmStore.upsertShip(ship);
}, "Move order queued.");
}
async function queueMineResourceOrder() {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
}
const targetSystemId = mineOrderForm.systemId.trim() || selectedShip.value.systemId;
const itemId = mineOrderForm.itemId.trim();
if (!targetSystemId) {
actionError.value = "Select a mining system.";
actionStatus.value = "";
return;
}
if (!itemId) {
actionError.value = "Select a ware to mine.";
actionStatus.value = "";
return;
}
await runShipAction(async () => {
const ship = await enqueueShipOrder(selectedShip.value!.id, {
kind: "mine-and-deliver",
priority: 95,
interruptCurrentPlan: true,
label: `Mine ${itemId} in ${targetSystemId}`,
targetEntityId: null,
targetSystemId,
targetPosition: null,
sourceStationId: null,
destinationStationId: null,
itemId,
nodeId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 0,
radius: 0,
maxSystemRange: 0,
knownStationsOnly: false,
});
gmStore.upsertShip(ship);
}, "Mine Resource order queued.");
}
async function removeOrder(orderId: string) {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
@@ -608,38 +627,114 @@ async function clearOrders() {
</div>
<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>
<h4>Order Queue</h4>
<div v-if="canDirectControlSelectedShip && directOrders.length > 0" class="entity-inspector-actions-row">
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="clearOrders">Clear Orders</button>
</div>
<div v-if="shipCargoRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Ware</th>
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="row in shipCargoRows" :key="row.key">
<td>{{ row.ware }}</td>
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
</tr>
</tbody>
</table>
<div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
<div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
<div v-if="directOrders.length > 0" class="entity-inspector-order-list">
<article v-for="order in directOrders" :key="order.id" class="entity-inspector-order-card">
<header class="entity-inspector-order-card__header">
<button
type="button"
class="entity-inspector-order-card__toggle"
:aria-expanded="expandedDirectOrderId === order.id"
@click="toggleOrderEditor(order)"
>
<span>{{ expandedDirectOrderId === order.id ? "▾" : "▸" }}</span>
<span>{{ getShipOrderLabel(order.kind) }}</span>
</button>
<div class="entity-inspector-order-card__actions">
<span class="entity-inspector-order-card__status">{{ titleCase(order.status) }}</span>
<button
v-if="canDirectControlSelectedShip"
type="button"
class="entity-inspector-order-remove"
:disabled="actionBusy"
@click="removeOrder(order.id)"
>
Remove
</button>
</div>
</header>
<div class="entity-inspector-order-card__summary">
<span>{{ describeOrderTarget(order) }}</span>
<span>{{ joinDetail([`P${order.priority}`, titleCase(order.sourceKind), describeOrderFailure(order) ?? undefined]) }}</span>
</div>
<div v-if="expandedDirectOrderId === order.id" class="entity-inspector-order-editor">
<div class="entity-inspector-note">
{{ [getShipOrderSupportStatusLabel(order.kind), getShipOrderNotes(order.kind)].filter(Boolean).join(" · ") }}
</div>
<div class="entity-inspector-form">
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Label</span>
<input v-model="orderEditForm.label" type="text" />
</label>
<div class="entity-inspector-inline-form">
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Priority</span>
<input v-model="orderEditForm.priority" type="number" min="0" step="1" />
</label>
<label class="entity-inspector-field entity-inspector-field--checkbox">
<span>Interrupt current plan</span>
<input v-model="orderEditForm.interruptCurrentPlan" type="checkbox" />
</label>
</div>
<label v-if="supportsOrderField(order.kind, 'targetSystemId')" class="entity-inspector-field entity-inspector-field--grow">
<span>Target System</span>
<select v-model="orderEditForm.targetSystemId">
<option value="">None</option>
<option v-for="system in gmStore.systems" :key="system.id" :value="system.id">{{ system.label }} ({{ system.id }})</option>
</select>
</label>
<label v-if="supportsOrderField(order.kind, 'targetEntityId')" class="entity-inspector-field entity-inspector-field--grow">
<span>Target Entity Id</span>
<input v-model="orderEditForm.targetEntityId" type="text" />
</label>
<label v-if="supportsOrderField(order.kind, 'itemId')" class="entity-inspector-field entity-inspector-field--grow">
<span>Ware</span>
<select v-model="orderEditForm.itemId">
<option v-for="itemId in commonMiningItems" :key="itemId" :value="itemId">{{ itemId }}</option>
</select>
</label>
<div v-if="supportsOrderField(order.kind, 'waitSeconds') || supportsOrderField(order.kind, 'radius')" class="entity-inspector-inline-form">
<label v-if="supportsOrderField(order.kind, 'waitSeconds')" class="entity-inspector-field entity-inspector-field--grow">
<span>Wait Seconds</span>
<input v-model="orderEditForm.waitSeconds" type="number" min="0" step="1" />
</label>
<label v-if="supportsOrderField(order.kind, 'radius')" class="entity-inspector-field entity-inspector-field--grow">
<span>Radius</span>
<input v-model="orderEditForm.radius" type="number" min="0" step="1" />
</label>
</div>
<div v-if="supportsOrderField(order.kind, 'maxSystemRange') || supportsOrderField(order.kind, 'knownStationsOnly')" class="entity-inspector-inline-form">
<label v-if="supportsOrderField(order.kind, 'maxSystemRange')" class="entity-inspector-field entity-inspector-field--grow">
<span>Max System Range</span>
<input v-model="orderEditForm.maxSystemRange" type="number" min="0" step="1" />
</label>
<label v-if="supportsOrderField(order.kind, 'knownStationsOnly')" class="entity-inspector-field entity-inspector-field--checkbox">
<span>Known Stations Only</span>
<input v-model="orderEditForm.knownStationsOnly" type="checkbox" />
</label>
</div>
<div class="entity-inspector-order-actions">
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="saveOrder(order)">Save</button>
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="toggleOrderEditor(order)">Cancel</button>
</div>
</div>
</div>
</article>
</div>
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
<div class="entity-inspector-note">
Behavior-generated queue entries are managed from Default Behavior.
<span v-if="behaviorGeneratedOrderCount > 0"> Active generated orders: {{ behaviorGeneratedOrderCount }}.</span>
</div>
<div v-else class="entity-inspector-empty">No cargo.</div>
</div>
<div class="entity-inspector-section">
<h4>Behavior</h4>
<h4>Default Behavior</h4>
<div v-if="selectedBehaviorStatus || selectedBehaviorNotes" class="entity-inspector-note">
{{ [selectedBehaviorStatus, selectedBehaviorNotes].filter(Boolean).join(" · ") }}
</div>
@@ -686,125 +781,6 @@ async function clearOrders() {
Direct behavior editing is only available for player-owned ships or GM users.
</div>
</div>
<div class="entity-inspector-section">
<h4>Orders</h4>
<div v-if="canDirectControlSelectedShip" class="entity-inspector-form">
<div class="entity-inspector-actions-row">
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="queueHoldPositionOrder">Hold Position</button>
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy || directOrders.length === 0" @click="clearOrders">Clear Orders</button>
</div>
<div class="entity-inspector-inline-form">
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Move To System</span>
<select v-model="moveOrderSystemId">
<option value="">Select system</option>
<option v-for="system in gmStore.systems" :key="system.id" :value="system.id">{{ system.label }} ({{ system.id }})</option>
</select>
</label>
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="queueMoveOrder">Queue Move</button>
</div>
<div class="entity-inspector-inline-form">
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Mine Resource System</span>
<select v-model="mineOrderForm.systemId">
<option value="">Current system</option>
<option v-for="system in gmStore.systems" :key="system.id" :value="system.id">{{ system.label }} ({{ system.id }})</option>
</select>
</label>
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Ware</span>
<select v-model="mineOrderForm.itemId">
<option v-for="itemId in commonMiningItems" :key="itemId" :value="itemId">{{ itemId }}</option>
</select>
</label>
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="queueMineResourceOrder">Queue Mine</button>
</div>
</div>
<div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
<div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
<div v-if="directOrderRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Order</th>
<th scope="col">Status</th>
<th scope="col">Target</th>
<th scope="col">Detail</th>
<th v-if="canDirectControlSelectedShip" scope="col" class="entity-inspector-table__action-col">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="order in directOrderRows" :key="order.id">
<td>{{ order.label }}</td>
<td>{{ order.status }}</td>
<td>{{ order.target }}</td>
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
<td v-if="canDirectControlSelectedShip" class="entity-inspector-table__action-col">
<button
type="button"
class="entity-inspector-order-remove"
:disabled="actionBusy"
@click="removeOrder(order.id)"
>
Remove
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
<div class="entity-inspector-divider">
<span>Behavior: {{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
</div>
<div v-if="behaviorOrderRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Order</th>
<th scope="col">Status</th>
<th scope="col">Target</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody>
<tr v-for="order in behaviorOrderRows" :key="order.id">
<td>{{ order.label }}</td>
<td>{{ order.status }}</td>
<td>{{ order.target }}</td>
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No behavior orders queued.</div>
</div>
<div class="entity-inspector-section">
<h4>Plan Steps</h4>
<div v-if="shipPlanRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Scope</th>
<th scope="col">Activity</th>
<th scope="col">Status</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody>
<tr v-for="row in shipPlanRows" :key="row.id" :class="row.isSubTask ? 'entity-inspector-table__row--subtask' : ''">
<td>{{ row.scope }}</td>
<td :class="row.isSubTask ? 'entity-inspector-table__subtask' : ''">{{ row.activity }}</td>
<td>{{ row.status }}</td>
<td class="entity-inspector-table__detail">{{ row.detail }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No active plan.</div>
</div>
</template>
<template v-else-if="selectedStation">
@@ -856,25 +832,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

@@ -1,184 +0,0 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import type { OpsStripState } from "../viewerHudState";
import { useViewerSelectionStore } from "../ui/stores/viewerSelection";
import type { Selectable } from "../viewerTypes";
defineProps<{
state: OpsStripState;
}>();
const emit = defineEmits<{
history: [selection: Selectable];
focus: [selection: Selectable, cameraMode?: "follow" | "tactical"];
}>();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
function isSelected(kind: Selectable["kind"], id: string) {
return selectedEntityKind.value === kind && selectedEntityId.value === id;
}
function onStationClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "station", label }, "ui");
}
function onStationDoubleClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "station", label }, "ui");
emit("focus", { kind: "station", id }, "tactical");
}
function onShipClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "ship", label }, "ui");
}
function onShipDoubleClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "ship", label }, "ui");
emit("focus", { kind: "ship", id }, "follow");
}
</script>
<template>
<section class="ops-strip">
<article
v-for="faction in state.factions"
:key="faction.id"
class="ship-card faction-card"
:data-faction-id="faction.id"
>
<div class="ship-card-header">
<h3>{{ faction.label }}</h3>
<span class="ship-card-badge">faction</span>
</div>
<div
v-if="faction.stateLines.length > 0"
class="ship-card-ai"
>
<p class="ship-card-section-title">GOAP State</p>
<p
v-for="line in faction.stateLines"
:key="line"
>
{{ line }}
</p>
</div>
<div
v-if="faction.priorities.length > 0"
class="ship-card-ai"
>
<p class="ship-card-section-title">Priorities</p>
<p
v-for="priority in faction.priorities"
:key="`${faction.id}-${priority.label}`"
class="ship-card-split-line"
>
<span>{{ priority.label }}</span>
<span>{{ priority.value }}</span>
</p>
</div>
</article>
<article
v-for="station in state.stations"
:key="station.id"
:class="['ship-card', 'station-card', isSelected('station', station.id) && 'is-selected']"
:data-station-id="station.id"
@click="onStationClick(station.id, station.label)"
@dblclick="onStationDoubleClick(station.id, station.label)"
>
<div class="ship-card-header">
<h3>{{ station.label }}</h3>
<span class="ship-card-badge">{{ station.badge }}</span>
</div>
<p
v-for="line in station.lines"
:key="`${station.id}-${line}`"
>
{{ line }}
</p>
<div
v-if="station.processes.length > 0"
class="ship-card-ai"
>
<div
v-for="process in station.processes"
:key="`${station.id}-${process.label}`"
class="ship-action-progress"
>
<div class="ship-action-progress-label">
<span>{{ process.label }}</span>
<span>{{ process.valueLabel }}</span>
</div>
<div class="ship-action-progress-track">
<div
class="ship-action-progress-fill"
:style="{ width: `${process.progress}%` }"
/>
</div>
</div>
</div>
</article>
<article
v-for="ship in state.ships"
:key="ship.id"
:class="['ship-card', isSelected('ship', ship.id) && 'is-selected', ship.followed && 'is-followed']"
:data-ship-id="ship.id"
@click="onShipClick(ship.id, ship.label)"
@dblclick="onShipDoubleClick(ship.id, ship.label)"
>
<div class="ship-card-header">
<h3>{{ ship.label }}</h3>
<div class="ship-card-meta">
<span class="ship-card-badge">{{ ship.badge }}</span>
<button
type="button"
class="ship-card-history-button"
:data-history-ship-id="ship.id"
:aria-label="`Open history for ${ship.label}`"
title="Open history"
@click.stop="emit('history', { kind: 'ship', id: ship.id })"
>
&#128340;
</button>
</div>
</div>
<p
v-for="line in ship.locationLines"
:key="`${ship.id}-${line}`"
>
{{ line }}
</p>
<p
v-for="line in ship.lines"
:key="`${ship.id}-${line}`"
>
{{ line }}
</p>
<div
v-if="ship.action"
class="ship-action-progress"
>
<div class="ship-action-progress-label">
<span>{{ ship.action.label }}</span>
<span>{{ ship.action.valueLabel }}</span>
</div>
<div class="ship-action-progress-track">
<div
class="ship-action-progress-fill"
:style="{ width: `${ship.action.progress}%` }"
/>
</div>
</div>
<div class="ship-card-ai">
<p
v-for="line in ship.aiLines"
:key="`${ship.id}-${line}`"
>
{{ line }}
</p>
</div>
</article>
</section>
</template>

View File

@@ -12,7 +12,7 @@ import { useViewerSelectionStore } from "../ui/stores/viewerSelection";
type MenuAction =
| "mine-resource"
| "fly-to-and-wait"
| "fly-to"
| "follow"
| "attack";
@@ -105,13 +105,14 @@ const actions = computed<OrderMenuActionEntry[]>(() => {
case "station":
case "celestial":
case "construction-site":
case "point":
return [{
key: "fly-to-and-wait",
orderKind: "fly-and-wait",
label: getShipOrderLabel("fly-and-wait"),
key: "fly-to",
orderKind: "move",
label: getShipOrderLabel("move"),
detail: target.value.label,
supportStatus: getShipOrderSupportStatusLabel("fly-and-wait"),
notes: getShipOrderNotes("fly-and-wait"),
supportStatus: getShipOrderSupportStatusLabel("move"),
notes: getShipOrderNotes("move"),
}];
case "system":
return emptyActions();
@@ -157,7 +158,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,
@@ -170,9 +171,9 @@ async function runAction(action: MenuAction) {
return;
}
if (action === "fly-to-and-wait") {
if (action === "fly-to") {
const ship = await enqueueShipOrder(selectedShip.value.id, {
kind: "fly-and-wait",
kind: "move",
priority: 100,
interruptCurrentPlan: true,
label: `Fly to ${target.value.label}`,
@@ -182,10 +183,10 @@ async function runAction(action: MenuAction) {
sourceStationId: null,
destinationStationId: null,
itemId: null,
nodeId: null,
anchorId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 8,
waitSeconds: 0,
radius: 0,
maxSystemRange: 0,
knownStationsOnly: false,
@@ -207,7 +208,7 @@ async function runAction(action: MenuAction) {
sourceStationId: null,
destinationStationId: null,
itemId: null,
nodeId: null,
anchorId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 6,
@@ -232,7 +233,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,15 +274,11 @@ type ShipRow = {
plan: string;
step: string;
subtask: string;
cargo: number;
health: number;
};
const shipRows = computed<ShipRow[]>(() =>
gmStore.ships.map((s) => {
const topOrder = [...s.orderQueue]
.sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
const currentStep = s.activePlan?.steps[s.activePlan.currentStepIndex];
const topOrder = s.orderQueue[0];
const currentSubTask = s.activeSubTasks[0];
return {
id: s.id,
@@ -302,11 +291,9 @@ const shipRows = computed<ShipRow[]>(() =>
assignment: s.assignment ? titleCaseToken(s.assignment.kind) : "—",
behavior: getShipBehaviorLabel(s.defaultBehavior.kind),
orders: topOrder ? `${getShipOrderLabel(topOrder.kind)} · ${s.orderQueue.length}` : "—",
plan: s.activePlan ? `${titleCaseToken(s.activePlan.kind)} · ${titleCaseToken(s.activePlan.status)}` : "—",
step: currentStep ? `${titleCaseToken(currentStep.kind)} · ${titleCaseToken(currentStep.status)}` : "—",
plan: currentSubTask ? "Task execution" : "—",
step: currentSubTask ? titleCaseToken(currentSubTask.kind) : "—",
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 +316,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 +355,6 @@ type StationRow = {
docked: string;
orders: number;
orderDetails: MarketOrderSnapshot[];
cargo: number;
modules: number;
};
@@ -400,7 +381,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 +401,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,
@@ -691,7 +691,7 @@ async function submitDirectOrder() {
<div v-if="selectedShip" class="player-card">
<strong>Behavior</strong>
<span>{{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
<span>Orders {{ selectedShip.orderQueue.length }} · Plan {{ selectedShip.activePlan?.kind ?? "none" }}</span>
<span>Orders {{ selectedShip.orderQueue.length }} · Tasks {{ selectedShip.activeSubTasks.length }}</span>
<span>Command {{ titleCase(selectedShip.controlSourceKind) }}<template v-if="selectedShip.controlReason"> · {{ selectedShip.controlReason }}</template></span>
<span v-if="selectedShip.lastReplanReason">Replan {{ selectedShip.lastReplanReason }}</span>
<span v-if="selectedShip.lastAccessFailureReason">Access {{ selectedShip.lastAccessFailureReason }}</span>
@@ -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;
@@ -112,37 +114,13 @@ export interface ShipSubTaskSnapshot {
blockingReason?: string | null;
}
export interface ShipPlanStepSnapshot {
id: string;
kind: string;
status: string;
summary: string;
blockingReason?: string | null;
currentSubTaskIndex: number;
subTasks: ShipSubTaskSnapshot[];
}
export interface ShipPlanSnapshot {
id: string;
sourceKind: string;
sourceId: string;
kind: string;
status: string;
summary: string;
currentStepIndex: number;
createdAtUtc: string;
updatedAtUtc: string;
interruptReason?: string | null;
failureReason?: string | null;
steps: ShipPlanStepSnapshot[];
}
export interface ShipSnapshot {
id: string;
name: string;
purpose: string;
type: string;
systemId: string;
anchorId?: string | null;
localPosition: Vector3Dto;
localVelocity: Vector3Dto;
targetLocalPosition: Vector3Dto;
@@ -151,19 +129,17 @@ export interface ShipSnapshot {
defaultBehavior: DefaultBehaviorSnapshot;
assignment?: ShipAssignmentSnapshot | null;
skills: ShipSkillProfileSnapshot;
activePlan?: ShipPlanSnapshot | null;
currentStepId?: string | null;
activeSubTasks: ShipSubTaskSnapshot[];
controlSourceKind: string;
controlSourceId?: string | null;
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 +154,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,27 @@ 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;
radius?: number | null;
maxSystemRange?: number | null;
knownStationsOnly?: boolean | null;
}
export interface ShipOrderUpdateCommandRequest {
kind: string;
priority: number;
interruptCurrentPlan: boolean;
label?: string | null;
targetEntityId?: string | null;
targetSystemId?: string | null;
targetPosition?: Vector3Dto | null;
sourceStationId?: string | null;
destinationStationId?: string | null;
itemId?: string | null;
anchorId?: string | null;
constructionSiteId?: string | null;
moduleId?: string | null;
waitSeconds?: number | null;
@@ -28,7 +48,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;
@@ -601,112 +669,6 @@ canvas {
gap: 8px;
}
.ops-strip {
position: absolute;
left: 0;
right: 0;
bottom: 0;
width: 50vw;
min-height: 128px;
display: flex;
align-items: stretch;
gap: 0;
overflow-x: auto;
overflow-y: hidden;
pointer-events: auto;
scrollbar-width: thin;
background: linear-gradient(180deg, rgba(5, 10, 18, 0), rgba(5, 10, 18, 0.92) 28%);
}
.ship-card {
border-top: 1px solid rgba(127, 214, 255, 0.14);
border-right: 1px solid rgba(127, 214, 255, 0.1);
background: linear-gradient(180deg, rgba(10, 20, 36, 0.96), rgba(6, 12, 22, 0.98));
padding: 10px 12px 12px;
min-width: 208px;
max-width: 208px;
display: flex;
flex-direction: column;
gap: 6px;
color: var(--viewer-text);
cursor: pointer;
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
}
.ship-card:hover {
transform: translateY(-2px);
border-color: rgba(127, 214, 255, 0.38);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.28);
}
.ship-card.is-selected {
border-top-color: rgba(255, 191, 105, 0.82);
background: linear-gradient(180deg, rgba(31, 33, 20, 0.9), rgba(20, 18, 10, 0.92));
}
.ship-card.is-followed {
box-shadow: inset 0 0 0 1px rgba(127, 214, 255, 0.34);
}
.ship-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.ship-card h3 {
margin: 0;
font-size: 0.82rem;
line-height: 1.15;
letter-spacing: 0.04em;
}
.ship-card p {
margin: 2px 0 0;
color: var(--viewer-muted);
line-height: 1.35;
font-size: 0.72rem;
white-space: pre-line;
}
.ship-card-header + p {
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ship-card-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.ship-card-badge {
padding: 3px 8px;
border-radius: 999px;
background: rgba(127, 214, 255, 0.12);
color: var(--viewer-accent);
font-size: 0.64rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.ship-card-ai {
margin-top: 2px;
padding-top: 6px;
border-top: 1px solid rgba(127, 214, 255, 0.08);
}
.ship-card-section-title {
margin: 0;
color: var(--viewer-accent);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.ship-card-history-button,
.history-window-copy,
.history-window-close {
border: 1px solid rgba(127, 214, 255, 0.22);
@@ -717,64 +679,15 @@ canvas {
cursor: pointer;
}
.ship-card-history-button {
width: 24px;
height: 24px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
align-self: flex-end;
font-size: 0.78rem;
line-height: 1;
}
.history-window-copy,
.history-window-close {
padding: 8px 12px;
}
.faction-card {
border-top-color: rgba(180, 130, 255, 0.3);
cursor: default;
}
.faction-card:hover {
transform: none;
border-color: rgba(180, 130, 255, 0.5);
}
.station-card {
border-top-color: rgba(127, 255, 180, 0.22);
}
.station-card:hover {
border-color: rgba(127, 255, 180, 0.5);
}
.ship-card-split-line {
display: flex;
justify-content: space-between;
gap: 12px;
}
.selection-action-button {
pointer-events: auto;
}
@media (max-width: 1080px) {
.ops-strip {
width: 100vw;
}
}
@media (max-width: 760px) {
.ops-strip {
width: 100vw;
min-height: 120px;
}
}
/* ── GM Windows ──────────────────────────────────────────────────────────── */
.gm-window {
@@ -1705,6 +1618,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;
@@ -1735,6 +1704,65 @@ canvas {
align-items: center;
}
.entity-inspector-order-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.entity-inspector-order-card {
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-order-card__header,
.entity-inspector-order-card__actions,
.entity-inspector-order-card__summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
}
.entity-inspector-order-card__toggle {
display: inline-flex;
align-items: center;
gap: 0.55rem;
border: none;
background: transparent;
color: var(--viewer-text);
padding: 0;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.entity-inspector-order-card__status {
font-size: 0.72rem;
color: rgba(173, 220, 255, 0.78);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.entity-inspector-order-card__summary {
margin-top: 0.55rem;
align-items: flex-start;
color: var(--viewer-muted);
font-size: 0.76rem;
}
.entity-inspector-order-card__summary span:last-child {
text-align: right;
}
.entity-inspector-order-editor {
margin-top: 0.8rem;
padding-top: 0.8rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.entity-inspector-field {
display: flex;
flex-direction: column;
@@ -1746,6 +1774,10 @@ canvas {
flex: 1 1 auto;
}
.entity-inspector-field--checkbox {
justify-content: flex-end;
}
.entity-inspector-field span {
font-size: 0.72rem;
color: var(--viewer-muted);
@@ -1769,6 +1801,14 @@ canvas {
border-color: rgba(173, 220, 255, 0.4);
}
.entity-inspector-field--checkbox input {
width: 1rem;
height: 1rem;
min-width: 1rem;
padding: 0;
accent-color: #7fd6ff;
}
.entity-inspector-note {
margin-top: 0.9rem;
color: var(--viewer-muted);
@@ -1910,6 +1950,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

@@ -2,10 +2,16 @@ import { defineStore } from "pinia";
import type { Vector3Dto } from "../../contractsCommon";
import type { Selectable } from "../../viewerTypes";
export interface ViewerOrderContextMenuPointSelection {
kind: "point";
id: "local-point";
}
export interface ViewerOrderContextMenuTarget {
selection: Selectable;
selection: Selectable | ViewerOrderContextMenuPointSelection;
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 {
@@ -92,10 +92,10 @@ export function updatePanFromKeyboard(
const right = new THREE.Vector3(-forward.z, 0, forward.x);
const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z));
if (activeSystemId) {
const speedKilometers = povLevel === "system"
const panSpeed = povLevel === "system"
? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.002, KILOMETERS_PER_AU * 0.35)
: THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 40, 180000);
systemAnchor.addScaledVector(pan, speedKilometers * delta);
: THREE.MathUtils.mapLinear(currentDistance, Math.max(minimumDistance, 4), 4000, 8, 6000);
systemAnchor.addScaledVector(pan, panSpeed * delta);
return;
}
@@ -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,24 @@ 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);
if (povLevel === "local") {
systemAnchor.add(pan);
return;
}
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 +242,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);
@@ -351,7 +358,7 @@ export function getCameraFocusWorldPosition(params: CameraFocusParams): THREE.Ve
}
/**
* Convert a local km position to system-scene display coordinates.
* Convert a system-space kilometer position to system-scene display coordinates.
* System scene coordinate system: star at origin, all positions scaled by
* DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE.
*/

View File

@@ -1,7 +1,7 @@
import type { PovLevel } from "./viewerTypes";
export const NAV_DISTANCE: Record<PovLevel, number> = {
local: 18,
local: 180,
system: 3200,
galaxy: 32000,
};
@@ -21,6 +21,11 @@ export const MOON_RENDER_SCALE = 1.1;
// 0.00005 units = ~3 km — allows scrolling very close to ships and structures.
export const MIN_CAMERA_DISTANCE = 0.00005;
export const MAX_CAMERA_DISTANCE = 150000;
export const MIN_LOCAL_CAMERA_DISTANCE = 4;
export const MAX_LOCAL_CAMERA_DISTANCE = 120000;
export const LOCAL_SYSTEM_BACKDROP_DISTANCE = 650;
export const LOCAL_CAMERA_DISTANCE_AT_TRANSITION = 100000;
export const LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM = 40;
export interface ZoomBlend {
localWeight: number;

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({
@@ -101,6 +112,7 @@ export function createViewerControllers(host: any) {
getCameraMode: () => host.cameraMode,
getCameraTargetShipId: () => host.cameraTargetShipId,
getPovLevel: () => host.povLevel,
getFocusedAnchorId: () => host.resolveFocusedAnchorId(),
getSelectedItems: () => host.selectedItems,
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
getCurrentDistance: () => host.currentDistance,
@@ -152,8 +164,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 +204,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,
@@ -237,6 +252,8 @@ export function createViewerControllers(host: any) {
},
getFollowCameraPosition: () => host.followCameraPosition,
getFollowCameraFocus: () => host.followCameraFocus,
getLocalRootPosition: () => host.localLayer.localRoot.position.clone(),
getFocusedAnchorId: () => host.resolveFocusedAnchorId(),
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
applyPanDelta: (delta: THREE.Vector2) => {
const bounds = host.renderer.domElement.getBoundingClientRect();
@@ -244,6 +261,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

@@ -1,11 +1,12 @@
import * as THREE from "three";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewerConstants";
import { MAX_CAMERA_DISTANCE, MAX_LOCAL_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, MIN_LOCAL_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewerConstants";
import { scaleGalaxyVector, toThreeVector } from "./viewerMath";
import { rawObject } from "./viewerScenePrimitives";
import { resolveShipWorldPosition } from "./viewerWorldPresentation";
import type { StatsOverlayMode } from "./viewerHudState";
import type {
CameraMode,
PovLevel,
Selectable,
ShipVisual,
SystemVisual,
@@ -148,9 +149,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
@@ -210,10 +213,12 @@ export function setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opa
material.needsUpdate = true;
}
export function navigateFromWheel(desiredDistance: number, deltaY: number) {
export function navigateFromWheel(desiredDistance: number, deltaY: number, povLevel: PovLevel) {
const clampedDelta = THREE.MathUtils.clamp(deltaY, -180, 180);
const zoomFactor = Math.exp(clampedDelta * 0.00135);
return THREE.MathUtils.clamp(desiredDistance * zoomFactor, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE);
const minimumDistance = povLevel === "local" ? MIN_LOCAL_CAMERA_DISTANCE : MIN_CAMERA_DISTANCE;
const maximumDistance = povLevel === "local" ? MAX_LOCAL_CAMERA_DISTANCE : MAX_CAMERA_DISTANCE;
return THREE.MathUtils.clamp(desiredDistance * zoomFactor, minimumDistance, maximumDistance);
}
export function applyKeyboardControl(params: {

View File

@@ -32,43 +32,6 @@ export interface HudProgressBar {
progress: number;
}
export interface OpsFactionCardState {
kind: "faction";
id: string;
label: string;
stateLines: string[];
priorities: { label: string; value: string }[];
}
export interface OpsStationCardState {
kind: "station";
id: string;
label: string;
badge: string;
selected: boolean;
lines: string[];
processes: HudProgressBar[];
}
export interface OpsShipCardState {
kind: "ship";
id: string;
label: string;
badge: string;
selected: boolean;
followed: boolean;
locationLines: string[];
lines: string[];
action?: HudProgressBar;
aiLines: string[];
}
export interface OpsStripState {
factions: OpsFactionCardState[];
stations: OpsStationCardState[];
ships: OpsShipCardState[];
}
export interface HistoryWindowState {
id: string;
target: Selectable;
@@ -111,7 +74,6 @@ export interface ViewerHudState {
systemPanel: HudHtmlPanelState;
detailPanel: HudHtmlPanelState;
error: HudErrorState;
opsStrip: OpsStripState;
historyWindows: HistoryWindowState[];
hoverLabel: HoverLabelState;
marquee: MarqueeState;
@@ -161,11 +123,6 @@ export function createViewerHudState(): ViewerHudState {
hidden: true,
message: "",
},
opsStrip: {
factions: [],
stations: [],
ships: [],
},
historyWindows: [],
hoverLabel: {
hidden: true,

View File

@@ -1,7 +1,7 @@
import * as THREE from "three";
import { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath";
import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, METERS_PER_KILOMETER, formatAdaptiveDistanceFromKilometers, formatAdaptiveDistanceFromMeters, formatSystemDistance } from "./viewerMath";
import type { HoverLabelState, MarqueeState } from "./viewerHudState";
import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes";
@@ -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) {
@@ -156,13 +169,17 @@ function formatHoverDistance(
|| selection.kind === "construction-site";
if (inActiveSystem && activeSystemId) {
if (povLevel === "local") {
return formatAdaptiveDistanceFromMeters(displayDistance);
}
const kilometers = displayDistance / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
return povLevel === "system"
? formatSystemDistance(kilometers / KILOMETERS_PER_AU)
: formatAdaptiveDistanceFromKilometers(kilometers);
}
return formatAdaptiveDistanceFromKilometers(displayDistance / DISPLAY_UNITS_PER_KILOMETER);
return formatAdaptiveDistanceFromKilometers((displayDistance / DISPLAY_UNITS_PER_KILOMETER) / METERS_PER_KILOMETER);
}
export function updateMarqueeBox(

View File

@@ -20,7 +20,10 @@ import type {
WorldState,
PovLevel,
} from "./viewerTypes";
import type { ViewerOrderContextMenuTarget } from "./ui/stores/viewerOrderContextMenu";
import type {
ViewerOrderContextMenuPointSelection,
ViewerOrderContextMenuTarget,
} from "./ui/stores/viewerOrderContextMenu";
export interface ViewerInteractionContext {
renderer: THREE.WebGLRenderer;
@@ -28,8 +31,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;
@@ -58,6 +63,8 @@ export interface ViewerInteractionContext {
setCameraTargetShipId: (value: string | undefined) => void;
getFollowCameraPosition: () => THREE.Vector3;
getFollowCameraFocus: () => THREE.Vector3;
getLocalRootPosition: () => THREE.Vector3;
getFocusedAnchorId: () => string | undefined;
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
applyPanDelta: (delta: THREE.Vector2) => void;
syncFollowStateFromSelection: () => void;
@@ -204,11 +211,9 @@ export class ViewerInteractionController {
this.context.closeOrderContextMenu();
const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY);
if (!picked) {
return;
}
const target = this.buildOrderContextTarget(picked);
const target = picked
? this.buildOrderContextTarget(picked)
: this.buildLocalPointContextTarget(event.clientX, event.clientY);
if (!target) {
return;
}
@@ -216,71 +221,6 @@ export class ViewerInteractionController {
this.context.openOrderContextMenu(event.clientX, event.clientY, target);
};
readonly onOpsStripClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const historyButton = target.closest<HTMLElement>("[data-history-ship-id]");
const historyShipId = historyButton?.dataset.historyShipId;
if (historyShipId) {
this.context.historyController.openHistoryWindow({ kind: "ship", id: historyShipId });
return;
}
const shipCard = target.closest<HTMLElement>("[data-ship-id]");
const shipId = shipCard?.dataset.shipId;
if (shipId) {
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
return;
}
const stationCard = target.closest<HTMLElement>("[data-station-id]");
const stationId = stationCard?.dataset.stationId;
if (stationId) {
this.context.setSelectedItems([{ kind: "station", id: stationId }]);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
}
};
readonly onOpsStripDoubleClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (target.closest("[data-history-ship-id]")) {
return;
}
const shipCard = target.closest<HTMLElement>("[data-ship-id]");
const shipId = shipCard?.dataset.shipId;
if (shipId) {
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
this.context.syncFollowStateFromSelection();
this.context.focusOnSelection({ kind: "ship", id: shipId });
this.toggleCameraMode("tactical");
this.context.updatePanels();
this.context.updateGamePanel("Live");
return;
}
const stationCard = target.closest<HTMLElement>("[data-station-id]");
const stationId = stationCard?.dataset.stationId;
if (stationId) {
this.context.setSelectedItems([{ kind: "station", id: stationId }]);
this.context.syncFollowStateFromSelection();
this.toggleCameraMode("tactical");
this.context.focusOnSelection({ kind: "station", id: stationId });
this.context.updatePanels();
this.context.updateGamePanel("Live");
}
};
readonly onHistoryLayerClick = (event: MouseEvent) => this.context.historyController.onHistoryLayerClick(event);
readonly onHistoryLayerPointerDown = (event: PointerEvent) => this.context.historyController.onHistoryLayerPointerDown(event);
@@ -314,7 +254,7 @@ export class ViewerInteractionController {
readonly onWheel = (event: WheelEvent) => {
event.preventDefault();
this.context.setDesiredDistance(navigateFromWheel(this.context.getDesiredDistance(), event.deltaY));
this.context.setDesiredDistance(navigateFromWheel(this.context.getDesiredDistance(), event.deltaY, this.context.getPovLevel()));
this.context.updateGamePanel("Live");
};
@@ -391,10 +331,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 +348,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 +412,7 @@ export class ViewerInteractionController {
selection,
label: node.itemId,
systemId: node.systemId,
anchorId: node.anchorId,
itemId: node.itemId,
targetPosition: node.localPosition,
} : null;
@@ -499,4 +446,45 @@ export class ViewerInteractionController {
return null;
}
}
private buildLocalPointContextTarget(clientX: number, clientY: number): ViewerOrderContextMenuTarget | null {
if (this.context.getPovLevel() !== "local") {
return null;
}
const world = this.context.getWorld();
const systemId = this.context.getActiveSystemId();
const anchorId = this.context.getFocusedAnchorId();
if (!world || !systemId || !anchorId || !world.anchors.has(anchorId)) {
return null;
}
const bounds = this.context.renderer.domElement.getBoundingClientRect();
this.context.mouse.x = ((clientX - bounds.left) / bounds.width) * 2 - 1;
this.context.mouse.y = -((clientY - bounds.top) / bounds.height) * 2 + 1;
this.context.raycaster.setFromCamera(this.context.mouse, this.context.localCamera);
const localRootPosition = this.context.getLocalRootPosition();
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -localRootPosition.y);
const worldIntersection = new THREE.Vector3();
if (!this.context.raycaster.ray.intersectPlane(plane, worldIntersection)) {
return null;
}
const localPosition = worldIntersection.sub(localRootPosition);
const rounded = localPosition.clone().round();
const selection: ViewerOrderContextMenuPointSelection = { kind: "point", id: "local-point" };
return {
selection,
label: `Point ${rounded.x}m, ${rounded.y}m, ${rounded.z}m`,
systemId,
anchorId,
targetPosition: {
x: rounded.x,
y: rounded.y,
z: rounded.z,
},
};
}
}

View File

@@ -1,20 +1,63 @@
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 localRoot = new THREE.Group();
readonly fineGrid = createLocalGrid(1000, 10, 0x35506d, 0x233449, 0.42);
readonly majorGrid = createLocalGrid(10000, 100, 0x6d88a3, 0x4b6078, 0.42);
readonly outerGrid = createLocalGrid(80000, 1000, 0x7e98b2, 0x55687f, 0.26);
readonly scene = new THREE.Scene();
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 2000);
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200000);
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);
updateCamera(orbitOffset: THREE.Vector3) {
this.camera.position.copy(orbitOffset);
this.camera.lookAt(LocalLayer.ORIGIN);
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.localRoot.add(
this.fineGrid,
this.majorGrid,
this.outerGrid,
this.nodeGroup,
this.stationGroup,
this.claimGroup,
this.constructionSiteGroup,
this.shipGroup,
);
this.scene.add(this.localRoot);
}
updateCamera(localFocus: THREE.Vector3, orbitOffset: THREE.Vector3, anchorOffset: THREE.Vector3) {
const worldFocus = localFocus.clone().add(anchorOffset);
this.localRoot.position.copy(anchorOffset);
this.camera.position.copy(worldFocus).add(orbitOffset);
this.camera.lookAt(worldFocus);
}
onResize(aspect: number) {
@@ -26,3 +69,13 @@ export class LocalLayer {
renderer.render(this.scene, this.camera);
}
}
function createLocalGrid(sizeMeters: number, stepMeters: number, majorColor: number, minorColor: number, opacity: number) {
const divisions = Math.max(1, Math.round(sizeMeters / stepMeters));
const grid = new THREE.GridHelper(sizeMeters, divisions, majorColor, minorColor);
const material = grid.material as THREE.Material & { opacity: number; transparent: boolean };
material.transparent = true;
material.opacity = opacity;
grid.position.y = -0.04;
return grid;
}

View File

@@ -15,6 +15,7 @@ import type {
import type { ZoomBlend } from "./viewerConstants";
export const KILOMETERS_PER_AU = 149_597_870.7;
export const METERS_PER_KILOMETER = 1000;
export const DISPLAY_UNITS_PER_KILOMETER = 0.0000015;
export const DISPLAY_UNITS_PER_LIGHT_YEAR = 2600;
@@ -44,7 +45,7 @@ function formatNumber(value: number, fractionDigits: number) {
}
export function formatLocalDistance(value: number): string {
return `${formatNumber(value, 0)} km`;
return `${formatNumber(value, value >= 100 ? 0 : 1)} m`;
}
export function formatSystemDistance(value: number): string {
@@ -76,6 +77,16 @@ export function formatAdaptiveDistanceFromKilometers(kilometers: number): string
return `${formatNumber(meters, meters >= 100 ? 0 : 1)} m`;
}
export function formatAdaptiveDistanceFromMeters(meters: number): string {
const absoluteMeters = Math.max(0, meters);
if (absoluteMeters >= METERS_PER_KILOMETER) {
const kilometers = absoluteMeters / METERS_PER_KILOMETER;
return `${formatNumber(kilometers, kilometers >= 100 ? 0 : 2)} km`;
}
return `${formatNumber(absoluteMeters, absoluteMeters >= 100 ? 0 : 1)} m`;
}
export function formatShipSpeed(ship: ShipSnapshot): string {
const speed = Math.max(0, ship.travelSpeed);
const unit = ship.travelSpeedUnit;
@@ -107,7 +118,7 @@ export function smoothBand(value: number, start: number, end: number): number {
}
export function computeZoomBlend(distance: number): ZoomBlend {
const localToSystem = smoothBand(distance, 1200, 5200);
const localToSystem = smoothBand(distance, 120, 650);
const systemToUniverse = smoothBand(distance, 9000, 22000);
return {

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),
});
}
@@ -179,7 +196,7 @@ export class ViewerNavigationController {
return toDisplayLocalPosition(localPosition);
}
/** Returns a display position for the system camera, derived from a raw local position in km. */
/** Returns a display position for the system camera, derived from a raw local position in meters. */
toSystemDisplayPosition(localPosition: THREE.Vector3) {
return toDisplayLocalPosition(localPosition);
}

View File

@@ -1,172 +0,0 @@
import type { StationSnapshot } from "./contractsInfrastructure";
import type { FactionSnapshot } from "./contractsFactions";
import type {
HudProgressBar,
OpsFactionCardState,
OpsShipCardState,
OpsStationCardState,
OpsStripState,
} from "./viewerHudState";
import { describeShipCurrentAction, describeShipLocation, describeShipState } from "./viewerSelection";
import { getShipBehaviorLabel, getShipOrderLabel } from "./shipAutomationPresentation";
import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes";
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
import { viewerPinia } from "./ui/stores/pinia";
function buildFactionCard(world: WorldState, faction: FactionSnapshot): OpsFactionCardState {
const playerFaction = usePlayerFactionStore(viewerPinia).playerFaction;
if (playerFaction && playerFaction.sovereignFactionId === faction.id) {
const selectedDirective = playerFaction.directives[0];
return {
kind: "faction",
id: faction.id,
label: `${faction.label} Command`,
stateLines: [
`Player ${playerFaction.assetRegistry.shipIds.length} ships · ${playerFaction.assetRegistry.stationIds.length} stations`,
`Groups ${playerFaction.fleets.length + playerFaction.taskForces.length + playerFaction.stationGroups.length + playerFaction.economicRegions.length + playerFaction.fronts.length + playerFaction.reserves.length}`,
`Intent ${playerFaction.strategicIntent.strategicPosture} · ${playerFaction.strategicIntent.economicPosture}`,
`Alerts ${playerFaction.alerts.length} · Decisions ${playerFaction.decisionLog.length}`,
`Lead ${selectedDirective ? `${selectedDirective.behaviorKind} · ${selectedDirective.scopeKind}` : "no active directives"}`,
],
priorities: [
{ label: "Reserve", value: `${Math.round(playerFaction.strategicIntent.desiredReserveRatio * 100)}%` },
{ label: "Auto", value: `${Number(playerFaction.strategicIntent.allowDelegatedEconomicAutomation)}/${Number(playerFaction.strategicIntent.allowDelegatedCombatAutomation)}` },
],
};
}
const strategicState = faction.strategicState;
const economic = strategicState.economicAssessment;
const activeCampaigns = strategicState.campaigns.filter((campaign) => campaign.status === "active");
const activeTheaters = strategicState.theaters.filter((theater) => theater.status === "active");
const activeWars = world.geopolitics?.diplomacy.wars.filter((war) => war.factionAId === faction.id || war.factionBId === faction.id).length ?? 0;
const contestedSystems = world.geopolitics?.territory.controlStates.filter((state) =>
state.isContested && (state.controllerFactionId === faction.id || state.primaryClaimantFactionId === faction.id || state.claimantFactionIds.includes(faction.id))).length ?? 0;
const leadCampaign = [...strategicState.campaigns]
.sort((left, right) => right.priority - left.priority)[0];
const leadTheater = [...strategicState.theaters]
.sort((left, right) => right.priority - left.priority)[0];
const latestDecision = [...faction.decisionLog]
.sort((left, right) => right.occurredAtUtc.localeCompare(left.occurredAtUtc))[0];
return {
kind: "faction",
id: faction.id,
label: faction.label,
stateLines: [
`Posture ${faction.doctrine.strategicPosture} · ${faction.doctrine.militaryPosture}`,
`Campaigns ${activeCampaigns.length} · Fronts ${activeTheaters.length} · Wars ${activeWars}`,
`Commit ${economic.militaryShipCount}/${economic.targetMilitaryShipCount} mil · ${economic.minerShipCount}/${economic.targetMinerShipCount} min`,
`Reserve ${strategicState.budget.reservedMilitaryAssets} mil · ${strategicState.budget.reservedLogisticsAssets} log`,
`Bottleneck ${economic.industrialBottleneckItemId ?? "none"} · Contested ${contestedSystems}${latestDecision ? ` · ${latestDecision.kind}` : ""}`,
],
priorities: [
...(leadCampaign ? [{ label: leadCampaign.kind, value: leadCampaign.priority.toFixed(0) }] : []),
...(leadTheater ? [{ label: leadTheater.kind, value: leadTheater.priority.toFixed(0) }] : []),
],
};
}
function buildProgressBar(label: string, progress: number): HudProgressBar {
return {
label,
valueLabel: `${Math.round(progress * 100)}%`,
progress: Number((progress * 100).toFixed(1)),
};
}
function buildStationCard(station: StationSnapshot, isSelected: boolean): OpsStationCardState {
const cargo = station.inventory.reduce((sum, entry) => sum + entry.amount, 0);
return {
kind: "station",
id: station.id,
label: station.label,
badge: station.category,
selected: isSelected,
lines: [
station.systemId,
`Docked ${station.dockedShips} / ${station.dockingPads}`,
`Cargo ${cargo.toFixed(0)} · Pop ${station.population.toFixed(0)}`,
`Modules ${station.installedModules.length}`,
],
processes: station.currentProcesses.map((process) => buildProgressBar(process.label, process.progress)),
};
}
function buildShipCard(
world: WorldState,
ship: WorldState["ships"] extends Map<string, infer Ship> ? Ship : never,
isSelected: boolean,
isFollowed: boolean,
): OpsShipCardState {
const cargo = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
const shipLocation = describeShipLocation(world, ship);
const shipState = describeShipState(world, ship);
const shipAction = describeShipCurrentAction(ship);
const currentStep = ship.activePlan?.steps[ship.activePlan.currentStepIndex];
const topOrder = [...ship.orderQueue]
.sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
return {
kind: "ship",
id: ship.id,
label: ship.name,
badge: ship.type,
selected: isSelected,
followed: isFollowed,
locationLines: [shipLocation.system, ...(shipLocation.local ? [shipLocation.local] : [])],
lines: [
`Cargo ${cargo.toFixed(0)}`,
`State ${shipState}`,
],
action: shipAction ? buildProgressBar(shipAction.label, shipAction.progress) : undefined,
aiLines: [
`Assignment ${ship.assignment?.kind ?? "unassigned"}`,
`Behavior ${getShipBehaviorLabel(ship.defaultBehavior.kind)}`,
`Plan ${ship.activePlan ? `${ship.activePlan.kind}${currentStep ? ` · ${currentStep.kind}` : ""}` : "none"}`,
`Orders ${topOrder ? `${getShipOrderLabel(topOrder.kind)} +${Math.max(0, ship.orderQueue.length - 1)}` : "none"}`,
],
};
}
export function buildOpsStripState(
world: WorldState | undefined,
selectedItems: Selectable[],
cameraMode: CameraMode,
cameraTargetShipId?: string,
povLevel?: PovLevel,
activeSystemId?: string,
): OpsStripState {
if (!world) {
return {
factions: [],
stations: [],
ships: [],
};
}
const isSystemFiltered = povLevel !== "galaxy" && activeSystemId != null;
const factions = [...world.factions.values()]
.sort((left, right) => left.label.localeCompare(right.label))
.map((faction) => buildFactionCard(world, faction));
const stations = [...world.stations.values()]
.filter((station) => !isSystemFiltered || station.systemId === activeSystemId)
.sort((left, right) => left.label.localeCompare(right.label))
.map((station) => buildStationCard(
station,
selectedItems.length === 1 && selectedItems[0].kind === "station" && selectedItems[0].id === station.id,
));
const ships = [...world.ships.values()]
.filter((ship) => !isSystemFiltered || ship.systemId === activeSystemId)
.sort((left, right) => left.name.localeCompare(right.name))
.map((ship) => buildShipCard(
world,
ship,
selectedItems.length === 1 && selectedItems[0].kind === "ship" && selectedItems[0].id === ship.id,
cameraMode === "follow" && cameraTargetShipId === ship.id,
));
return { factions, stations, ships };
}

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,
@@ -346,7 +346,6 @@ export function buildDetailPanelState(params: DetailPanelParams) {
const shipBehavior = describeShipBehavior(ship);
const shipOrder = describeShipOrder(ship);
const shipAction = describeShipCurrentAction(ship);
const currentStep = ship.activePlan?.steps[ship.activePlan.currentStepIndex];
const orderQueue = ship.orderQueue.length > 0
? ship.orderQueue.slice(0, 4).map((order) => `${getShipOrderLabel(order.kind)} [${order.status}]`).join("<br>")
: "none";
@@ -369,7 +368,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
<p>State ${shipState}</p>
<p>Order ${shipOrder}</p>
<p>Queue ${orderQueue}</p>
<p>Plan ${ship.activePlan ? `${ship.activePlan.kind} · ${ship.activePlan.status}` : "none"}${currentStep ? `<br>Step ${currentStep.kind} · ${currentStep.status}` : ""}</p>
<p>Activity ${subTaskList}</p>
<p>Subtasks ${subTaskList}</p>
${ship.lastReplanReason ? `<p>Last replan ${ship.lastReplanReason}</p>` : ""}
${ship.lastAccessFailureReason ? `<p>Access ${ship.lastAccessFailureReason}</p>` : ""}
@@ -461,9 +460,9 @@ 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>
<p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} m</p>
`,
};
}
@@ -477,7 +476,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 +493,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 +607,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

@@ -30,6 +30,7 @@ export interface ViewerPresentationContext {
getCameraMode: () => any;
getCameraTargetShipId: () => string | undefined;
getPovLevel: () => any;
getFocusedAnchorId: () => string | undefined;
getSelectedItems: () => Selectable[];
getWorldTimeSyncMs: () => number;
getCurrentDistance: () => number;
@@ -106,12 +107,52 @@ export class ViewerPresentationController {
applyZoomPresentation() {
const povLevel = this.context.getPovLevel();
const world = this.context.getWorld();
const focusedAnchorId = this.context.getFocusedAnchorId();
const focusedAnchor = focusedAnchorId ? world?.anchors.get(focusedAnchorId) : undefined;
const focusedPlanetMatch = focusedAnchorId?.match(/^node-[^-]+-planet-(\d+)$/);
const focusedMoonMatch = focusedAnchorId?.match(/^node-[^-]+-planet-(\d+)-moon-(\d+)$/);
const focusedPlanetIndex = focusedMoonMatch
? Number.parseInt(focusedMoonMatch[1], 10) - 1
: (focusedPlanetMatch ? Number.parseInt(focusedPlanetMatch[1], 10) - 1 : undefined);
const focusedMoonIndex = focusedMoonMatch ? Number.parseInt(focusedMoonMatch[2], 10) - 1 : undefined;
this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035);
const showPlanetIcons = povLevel !== "local";
for (const visual of this.context.planetVisuals) {
for (const [planetIndex, visual] of this.context.planetVisuals.entries()) {
visual.icon.setVisible(showPlanetIcons);
if (povLevel === "local") {
const showPlanetMesh = focusedAnchor?.kind === "planet"
? planetIndex === focusedPlanetIndex
: focusedAnchor?.kind === "moon"
? planetIndex === focusedPlanetIndex
: false;
visual.mesh.setVisible(showPlanetMesh);
visual.orbit.setVisible(false);
if (visual.ring) {
visual.ring.setVisible(showPlanetMesh);
}
for (const [moonIndex, moon] of visual.moons.entries()) {
const showMoonMesh = focusedAnchor?.kind === "moon"
&& planetIndex === focusedPlanetIndex
&& moonIndex === focusedMoonIndex;
moon.mesh.setVisible(showMoonMesh);
moon.icon.setVisible(false);
moon.orbit.setVisible(false);
}
continue;
}
visual.mesh.setVisible(true);
visual.orbit.setVisible(true);
if (visual.ring) {
visual.ring.setVisible(true);
}
}
for (const systemVisual of this.context.systemVisuals.values()) {
systemVisual.starCluster.setVisible(povLevel !== "local" || focusedAnchor?.kind === "star");
}
}

View File

@@ -4,20 +4,20 @@ import type { ShipSnapshot } from "./contracts";
export function shipSize(ship: ShipSnapshot) {
switch (ship.type) {
case "carrier":
return 0.018;
return 18;
case "battleship":
return 0.012;
return 12;
case "destroyer":
return 0.009;
return 9;
case "builder":
case "freighter":
case "transporter":
case "resupplier":
case "miner":
case "largeminer":
return 0.01;
return 10;
default:
return 0.007;
return 7;
}
}

View File

@@ -14,6 +14,18 @@ import {
syncShips as syncShipScene,
syncStations as syncStationScene,
} from "./viewerSceneSync";
import {
createClaimMesh,
createConstructionSiteMesh,
createLocalResourceDepositMesh,
createLocalResourceNodeMesh,
createNodeMesh,
createResourceDepositMesh,
createShipMesh,
createShipTacticalIcon,
createStationMesh,
createTacticalIcon,
} from "./viewerSceneFactory";
import {
deriveNodeOrbital,
deriveOrbitalFromLocalPosition,
@@ -43,7 +55,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 +73,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 +89,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 +159,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 = createLocalResourceNodeMesh(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 = createLocalResourceDepositMesh(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 +381,7 @@ export class ViewerSceneDataController {
createWorldPresentationContext(overrides: {
world: any;
activeSystemId?: string;
focusedAnchorId?: string;
cameraMode: any;
povLevel: any;
orbitYaw: number;
@@ -215,17 +395,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 +434,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 +450,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,64 @@ 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 createLocalResourceNodeMesh(node: ResourceNodeSnapshot): SceneNode {
const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas";
const oreRatio = node.maxOre <= 0.01 ? 0 : node.oreRemaining / node.maxOre;
const radius = isGas
? 120 + (oreRatio * 30)
: 55 + (oreRatio * 20);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(radius, isGas ? 18 : 16, isGas ? 18 : 16),
new THREE.MeshStandardMaterial({
color: isGas ? 0x7fd6ff : 0xb28b59,
roughness: isGas ? 0.4 : 0.92,
metalness: isGas ? 0.06 : 0.08,
transparent: isGas,
opacity: isGas ? 0.5 : 1,
emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xb28b59).multiplyScalar(isGas ? 0.18 : 0.03),
}),
);
return createSceneNode(mesh);
}
export function createLocalResourceDepositMesh(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 radius = isGas
? 8 + (oreRatio * 4)
: 3 + (oreRatio * 4);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(radius, 12, 12),
new THREE.MeshStandardMaterial({
color: isGas ? 0x92deff : 0xd0ad77,
roughness: isGas ? 0.36 : 0.95,
metalness: isGas ? 0.04 : 0.02,
transparent: isGas,
opacity: isGas ? 0.58 : 1,
emissive: new THREE.Color(isGas ? 0x92deff : 0xd0ad77).multiplyScalar(isGas ? 0.16 : 0.025),
}),
);
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(
@@ -211,17 +270,31 @@ export function createStationMesh(station: StationSnapshot): SceneNode {
}
export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): SceneNode {
const geometry = new THREE.ConeGeometry(size, length, 7);
geometry.rotateX(Math.PI / 2);
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({
color,
emissive: new THREE.Color(color).multiplyScalar(0.18),
}),
const root = new THREE.Group();
const material = new THREE.MeshStandardMaterial({
color,
emissive: new THREE.Color(color).multiplyScalar(0.28),
});
const bodyRadius = Math.max(size * 0.48, 2.4);
const bodyLength = Math.max(length - (bodyRadius * 1.8), bodyRadius * 1.2);
const body = new THREE.Mesh(
new THREE.CapsuleGeometry(bodyRadius, bodyLength, 6, 12),
material,
);
mesh.position.copy(toThreeVector(ship.localPosition));
return createSceneNode(mesh);
body.rotation.x = Math.PI / 2;
root.add(body);
const nose = new THREE.Mesh(
new THREE.ConeGeometry(Math.max(bodyRadius * 0.72, 1.8), Math.max(bodyRadius * 1.4, 3.2), 8),
material,
);
nose.rotation.x = Math.PI / 2;
nose.position.z = (bodyLength * 0.5) + (bodyRadius * 0.55);
root.add(nose);
root.position.copy(toThreeVector(ship.localPosition));
return createSceneNode(root);
}
function createStarGlowTexture(documentRef: Document): THREE.CanvasTexture {

View File

@@ -65,11 +65,23 @@ import {
} from "./viewerScenePrimitives";
import type { SceneNode } from "./viewerScenePrimitives";
/** Scale a local km position to system-scene display coordinates. */
/** Scale a system-space kilometer position to system-scene display coordinates. */
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 `node-${selected.systemId}-planet-${selected.planetIndex + 1}`;
}
if (selected.kind === "moon") {
return `node-${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 {
@@ -362,8 +429,7 @@ export function describeShipBehavior(ship: ShipSnapshot): string {
}
export function describeShipOrder(ship: ShipSnapshot): string {
const activeOrder = [...ship.orderQueue]
.sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
const activeOrder = ship.orderQueue.find((order) => order.status === "queued" || order.status === "active");
if (activeOrder) {
return activeOrder.label ?? getShipOrderLabel(activeOrder.kind);
}
@@ -372,10 +438,6 @@ export function describeShipOrder(ship: ShipSnapshot): string {
return describeShipObjective(ship.assignment.kind);
}
if (ship.activePlan) {
return ship.activePlan.summary || ship.activePlan.kind;
}
return getShipBehaviorLabel(ship.defaultBehavior.kind);
}
@@ -406,8 +468,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 +478,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 +508,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 +534,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 +546,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

@@ -1,6 +1,5 @@
import { fetchWorldSnapshot, openWorldStream } from "./api";
import type { ViewerHudState } from "./viewerHudState";
import { buildOpsStripState } from "./viewerOpsStrip";
import { useGmStore } from "./ui/stores/gmStore";
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
import { viewerPinia } from "./ui/stores/pinia";
@@ -65,8 +64,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 +165,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,20 +186,12 @@ 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();
}
rebuildFactions(_factions: FactionSnapshot[]) {
this.context.hudState.opsStrip = buildOpsStripState(
this.context.getWorld(),
this.context.getSelectedItems(),
this.context.getCameraMode(),
this.context.getCameraTargetShipId(),
this.context.getPovLevel(),
this.context.getActiveSystemId(),
);
const world = this.context.getWorld();
if (world) {
useGmStore(viewerPinia).updateWorld(
@@ -219,6 +212,7 @@ export class ViewerWorldLifecycle {
}
this.context.refreshHistoryWindows();
this.context.refreshLocalLayer();
this.context.updateSystemPanel();
this.refreshStreamScopeIfNeeded();
const detailState = buildDetailPanelState({
@@ -241,12 +235,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

@@ -3,6 +3,7 @@ import {
DISPLAY_UNITS_PER_KILOMETER,
DISPLAY_UNITS_PER_LIGHT_YEAR,
KILOMETERS_PER_AU,
METERS_PER_KILOMETER,
computeMoonLocalPosition,
computePlanetLocalPosition,
currentWorldTimeSeconds,
@@ -10,7 +11,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 +59,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 +102,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 +131,21 @@ 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);
visual.mesh.setPosition(localPosition);
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,40 @@ 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(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()) {
visual.mesh.setPosition(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.localClaimVisuals.values()) {
visual.mesh.setPosition(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(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 +261,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;
@@ -306,16 +408,12 @@ export function describeGameStatus(params: GameStatusParams) {
? `gal pos: ${fmtVec(galaxyAnchor.clone().divideScalar(DISPLAY_UNITS_PER_LIGHT_YEAR), 2)} ly`
: "";
// System space: systemAnchor in AU — changes only during system navigation
const sysPos = systemAnchor
const sysPos = povLevel !== "local" && systemAnchor
? `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
: undefined;
const locPos = systemAnchor && celestialAnchor
? `loc pos: ${fmtVec(systemAnchor.clone().sub(toThreeVector(celestialAnchor)), 0)} km`
// Local space: local focus in meters relative to the focused anchor
const locPos = povLevel === "local" && systemAnchor
? `loc pos: ${fmtVec(systemAnchor, 0)} m`
: "";
return {
@@ -342,6 +440,33 @@ export function updateGameStatus(params: GameStatusParams & { statusEl: HTMLDivE
}
}
export function resolveLocalAnchorOffset(world: WorldState | undefined, focusedAnchorId?: string): THREE.Vector3 {
if (!world || !focusedAnchorId) {
return new THREE.Vector3(0, 0, 0);
}
const anchor = world.anchors.get(focusedAnchorId);
if (!anchor) {
return new THREE.Vector3(0, 0, 0);
}
if (anchor.kind === "lagrange-point" || anchor.kind === "resource-node") {
return new THREE.Vector3(0, 0, 0);
}
const bodyRadiusMeters = resolveAnchorBodyRadius(world, anchor) * METERS_PER_KILOMETER;
if (bodyRadiusMeters <= 1) {
return new THREE.Vector3(0, 0, 0);
}
const safeOffset = Math.min(
Math.max(bodyRadiusMeters * 1.08, 120),
Math.max(anchor.localSpaceRadius * 0.55, 300),
);
return new THREE.Vector3(-safeOffset, 0, 0);
}
export function deriveNodeOrbital(
context: WorldOrbitalContext,
node: ResourceNodeSnapshot | ResourceNodeDelta,
@@ -415,7 +540,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) {
@@ -426,6 +558,32 @@ export function resolvePointPosition(context: WorldOrbitalContext, _systemId: st
return new THREE.Vector3(0, 0, 0);
}
function resolveAnchorBodyRadius(world: WorldState, anchor: { id: string; systemId: string; kind: string }) {
const system = world.systems.get(anchor.systemId);
if (!system) {
return 0;
}
if (anchor.kind === "star") {
return system.stars[0]?.size ?? 0;
}
const planetMatch = /^node-[^-]+-planet-(\d+)$/.exec(anchor.id);
if (planetMatch) {
const planetIndex = Number.parseInt(planetMatch[1], 10) - 1;
return system.planets[planetIndex]?.size ?? 0;
}
const moonMatch = /^node-[^-]+-planet-(\d+)-moon-(\d+)$/.exec(anchor.id);
if (moonMatch) {
const planetIndex = Number.parseInt(moonMatch[1], 10) - 1;
const moonIndex = Number.parseInt(moonMatch[2], 10) - 1;
return system.planets[planetIndex]?.moons[moonIndex]?.size ?? 0;
}
return 0;
}
export function computeCelestialLocalPosition(context: WorldOrbitalContext, visual: CelestialVisual, timeSeconds: number) {
return computeCelestialLocalPositionById(context, visual.id, timeSeconds) ?? visual.orbitalAnchor.clone();
}
@@ -446,17 +604,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 +706,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: {

View File

@@ -40,7 +40,6 @@ Recommended global ID families:
- `commanderId`
- `systemId`
- `anchorId`
- `localspaceId`
- `stationId`
- `shipId`
- `moduleId`
@@ -59,15 +58,14 @@ The intended core entities are:
2. `Commander`
3. `System`
4. `Anchor`
5. `Localspace`
6. `Station`
7. `Ship`
8. `ModuleInstance`
9. `Claim`
10. `ConstructionSite`
11. `MarketOrder`
12. `Recipe`
13. `PolicySet`
5. `Station`
6. `Ship`
7. `ModuleInstance`
8. `Claim`
9. `ConstructionSite`
10. `MarketOrder`
11. `Recipe`
12. `PolicySet`
## Faction
@@ -120,12 +118,12 @@ Suggested fields:
- `label`
- `galaxyPosition`
- `star definition`
- `nodeIds`
- `anchorIds`
- `faction influence later`
## Anchor
An anchor is a meaningful location in a system that owns a localspace.
An anchor is a meaningful location in a system.
Suggested fields:
@@ -133,7 +131,7 @@ Suggested fields:
- `systemId`
- `kind`
- `systemPosition`
- `localspaceId`
- `localspaceRadius`
- `parentAnchorId?`
- `orbital metadata?`
- `constructionIds?`
@@ -146,25 +144,9 @@ Recommended anchor kinds:
- `lagrange-point`
- `resource-node`
## Localspace
A localspace is the tactical simulation context attached to one anchor.
Suggested fields:
- `localspaceId`
- `anchorId`
- `systemId`
- `radius`
- `occupantShipIds`
- `occupantStationIds`
- `occupantClaimIds`
- `occupantConstructionSiteIds`
- `serverAuthorityId later`
## Station
A station is a constructed structure that lives inside one localspace.
A station is a constructed structure that lives at one anchor.
Suggested fields:
@@ -173,7 +155,6 @@ Suggested fields:
- `commanderId?`
- `anchorId`
- `systemId`
- `localspaceId`
- `moduleIds`
- `inventory`
- `population`
@@ -236,7 +217,6 @@ Suggested fields:
- `commanderId?`
- `systemId`
- `anchorId`
- `localspaceId`
- `placedAt`
- `activatesAt`
- `state`
@@ -258,7 +238,6 @@ Suggested fields:
- `constructionSiteId`
- `ownerFactionId`
- `anchorId`
- `localspaceId`
- `targetKind`
- `targetDefinitionId`
- `requiredItems`
@@ -351,7 +330,6 @@ Recommended ship spatial state fields:
- `spaceLayer`
- `currentSystemId`
- `currentAnchorId?`
- `currentLocalspaceId?`
- `localPosition?`
- `systemPosition?`
- `movementRegime`

View File

@@ -34,7 +34,6 @@ Every event should conceptually have:
- `kind`
- `spaceLayer`
- `systemId?`
- `localspaceId?`
- `anchorId?`
- `primaryEntityKind`
- `primaryEntityId`

View File

@@ -73,7 +73,7 @@ Current state:
Primary gaps:
- [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs) has no `AnchorRuntime`, `LocalspaceRuntime`, `ClaimRuntime`, or `ConstructionSiteRuntime`.
- [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs) has no first-class `AnchorRuntime` aligned with the target design, plus no fully anchor-native `ClaimRuntime` or `ConstructionSiteRuntime`.
- [`ScenarioLoader.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/ScenarioLoader.cs) computes station positions directly instead of creating anchor-backed placement.
- [`SimulationEngine.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs) still treats travel as raw coordinate movement rather than anchor-to-anchor transit between spaces.
@@ -247,7 +247,6 @@ Work:
- extend [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs) with:
- `AnchorRuntime`
- `LocalspaceRuntime`
- `CommanderRuntime`
- `ClaimRuntime`
- `ConstructionSiteRuntime`
@@ -262,7 +261,6 @@ Work:
- add structured ship spatial state:
- current space layer
- current anchor
- current localspace
- current transit
Why first:

View File

@@ -8,7 +8,7 @@ It is the canonical reference for:
- solar systems
- celestials
- anchors
- localspaces
- localspaces as the tactical space around anchors
- ship and station placement
- intra-system travel
- inter-system travel
@@ -27,14 +27,13 @@ The structure can be understood as a tree:
- the galaxy contains solar systems
- each solar system contains celestials and other derived locations
- each meaningful location is an anchor
- each anchor owns one localspace
- each anchor has a localspace around it
The intended structure is:
1. `galaxy`
2. `solar system`
3. `anchor`
4. `localspace`
Ships and stations do not live in arbitrary free-floating "system local space".
@@ -99,7 +98,7 @@ Systems remain important for:
## Anchors
An anchor is a meaningful object in a system that owns a localspace.
An anchor is a meaningful object in a system.
Anchors are first-class world entities.
@@ -120,7 +119,7 @@ Each anchor should have:
- an anchor type
- a position in system space
- optional orbital metadata
- an associated localspace definition
- a localspace around it
- optional parent/child relationships
Examples:
@@ -131,7 +130,7 @@ Examples:
- a resource node is an anchor but not a celestial
- a Lagrange point can be the child of a moon, which is the child of a planet, which is the child of a star
Every anchor has exactly one localspace.
Every anchor has exactly one localspace around it.
## Celestials
@@ -147,9 +146,9 @@ Celestials exist for three reasons:
1. they structure the solar system visually and strategically
2. they define orbital relationships
3. they provide valid anchors for localspaces and derived locations such as Lagrange points
3. they provide valid anchors and derived locations such as Lagrange points
Every star, planet, and moon gets a localspace.
Every star, planet, and moon has a localspace around it.
Not all anchors are celestials, but all celestials are anchors.
@@ -165,7 +164,7 @@ Initial assumptions:
- major orbitals can expose `L1` through `L5`
- each exposed Lagrange point is its own anchor
- each exposed Lagrange point has its own localspace
- each exposed Lagrange point has its own localspace around it
- Lagrange points are valid construction sites
For now, all five may exist for supported orbitals, but the intended direction is that only major planets should necessarily expose all five.
@@ -186,7 +185,7 @@ That means a resource node can have:
- a stable identity
- a place in a solar system
- its own localspace
- its own localspace around it
This is desirable because it allows resources to exist anywhere meaningful in a system while still fitting the anchored localspace model.
@@ -198,6 +197,22 @@ Resource nodes are not construction sites.
That is intentional so they can be spawned, depleted, despawned, and regenerated more freely than permanent infrastructure anchors.
Resource nodes are strategic mining destinations.
That means:
- outside localspace, a miner travels to a resource-node anchor
- inside that localspace, the miner chooses a concrete extractable target
The concrete extractable target is a tactical mining concern, not a strategic travel identity.
So:
- travel uses `anchorId`
- local extraction uses a localspace-level target such as a rock, cluster, or gas pocket
Do not use a generic `NodeId` to mean both.
## Localspace
`localspace` is the tactical simulation term and should be the only term used for this concept.
@@ -212,7 +227,7 @@ Use:
- `localspace`
Each localspace belongs to exactly one anchor.
Each localspace is the tactical space around exactly one anchor.
Examples:
@@ -233,16 +248,26 @@ Localspace is where close simulation happens:
- local logistics
- tactical defense
For mining specifically:
- the destination localspace is chosen by anchor
- the final extractable target is chosen only after the ship is inside that localspace
This preserves the distinction between:
- strategic travel to a mining site
- tactical mining behavior inside that site
Ships and constructions do not exist directly in system space. They exist in one localspace at a time unless they are explicitly traveling between anchors.
## Ship Placement
A ship always belongs to exactly one localspace unless it is actively transitioning between anchors.
A ship always belongs to exactly one anchor unless it is actively transitioning between anchors.
Normal ship state should be one of:
- in a localspace
- traveling between localspaces in the same system
- at an anchor
- traveling between anchors in the same system
- traveling between systems
Inside a localspace, ships use tactical movement with thrusters.
@@ -444,6 +469,82 @@ Localspace view should show:
The viewer should not imply that the full solar system is one continuous local battlefield.
Viewer zoom transitions are a client-side presentation effect.
That means:
- zooming from galaxy to system does not imply one shared coordinate space
- zooming from system to localspace does not imply one shared coordinate space
- the client may animate or blend between views for readability
- those effects do not define simulation truth
## Scales And Units
Each spatial layer owns its own units.
Those units should be native to that layer, not projections of one universal master coordinate system.
### Galaxy
The galaxy layer uses:
- `light-years`
This is the scale for:
- system positions
- inter-system distances
- strategic map layout
### System
The system layer uses:
- `AU`
This is the scale for:
- anchor positions inside a system
- orbital relationships
- intra-system routing and warp travel
Variation at localspace scale is intentionally insignificant at this layer.
### Localspace
The localspace layer uses:
- `meters`
- `m/s`
This is the scale for:
- tactical movement
- combat
- docking
- mining
- construction
Variation at system scale is intentionally insignificant at this layer.
## Cross-Layer Rule
The simulation should not try to preserve one continuous coordinate frame across galaxy, system, and localspace.
Cross-layer relationships should be modeled through:
- containment
- references
- travel state
Not through:
- universal coordinate conversion
- raw projection of localspace offsets into system space
- raw projection of system offsets into galaxy space
The viewer may animate transitions between layers, but that is presentation only. It does not mean the simulation uses one continuous spatial frame.
## Ownership And Sovereignty
Ownership and sovereignty should primarily be tracked at system level.
@@ -451,7 +552,7 @@ Ownership and sovereignty should primarily be tracked at system level.
This is not fully defined yet, but the current design direction is:
- systems are the main sovereignty unit
- localspaces and constructions exist inside systems
- anchors and constructions exist inside systems
- local conflicts and control still matter tactically
## Simulation Implications
@@ -466,7 +567,7 @@ This model supports:
It also gives a cleaner authority boundary for later scaling:
- one localspace can become one simulation partition
- one anchor can become one tactical simulation partition
- one system can remain a higher-level strategic container
This supports:
@@ -493,8 +594,8 @@ Until the implementation is updated, the following terms should be used consiste
- `galaxy`: the top-level strategic star map
- `system`: the solar system container
- `celestial`: star, planet, or moon
- `anchor`: anything that owns a localspace
- `localspace`: the tactical simulation bubble attached to one anchor
- `anchor`: a meaningful location in a system and the primary tactical simulation container
- `localspace`: the tactical simulation space around an anchor, not a separately identified entity
- `intra-system warp`: movement between anchors in the same system
- `inter-system FTL`: movement between systems

View File

@@ -0,0 +1,26 @@
# Backlog
## Improve CreateFactionForm
Confirm whether GM faction creation is limited to predefined faction ids or is incorrectly restricted to `terran`. Improve the GM faction creation flow so it presents valid faction choices clearly instead of relying on unclear freeform id edits.
From: V-010 validation
## Improve GM Ship Spawn Options
Extend the GM ship spawn form so it allows choosing what kind of ship to create instead of always spawning the same default result. At minimum, support a few clear presets such as hauler, fighter, miner, and builder. Longer term, allow selecting the ship type directly and optionally configuring modules before spawn.
From: V-011 validation, V-013 validation
## Spawn GM Ships In A Neutral Starting State
Spawn GM ships with a minimal default behavior such as `hold-position` instead of immediately assigning `local-auto-mine`. Newly created ships should start in a predictable manual-control state unless the GM explicitly asks for another behavior.
From: V-011 validation
## Spawn GM Ships At A Chosen Anchor
Extend GM ship spawning so the GM can choose an anchor, not just a system. Ships should spawn into the selected anchor's localspace at a safe non-colliding position.
From: V-011 validation
## Improve GM Station Spawn Options
Add a proper station spawn flow or form so the GM can configure the station before creating it. The spawn flow should allow choosing the station role or preset and selecting its intended location before it appears in the world.
From: V-012 validation

713
docs/VALIDATION.md Normal file
View File

@@ -0,0 +1,713 @@
# Manual Validation Plan
This document defines the manual validation passes to run against the current game basis.
It is intentionally focused on behavior validation, not implementation details.
The goal is to verify that the simulation can perform the core actions of the game correctly before writing deeper automated simulation tests.
## Purpose
This validation plan answers the following questions:
- does the world boot cleanly and reproducibly
- can we create the minimum actors needed to exercise gameplay
- can ships receive and complete direct orders
- can ships run supported default behaviors without getting stuck
- do movement, mining, docking, and combat work at the simulation level
- does the viewer reflect the same state the backend is executing
This document is the manual test source of truth for the current phase.
Later, these same checks should become simulation-first tests running directly against the real runtime.
## Scope
This phase is intentionally centered on `empty.json`.
That is correct for now.
The purpose of `empty.json` is to validate primitive actions and control behavior with minimal scenario noise.
It is not yet the basis for validating full economy, expansion, or long-horizon faction behavior.
Those should be validated later using richer scenarios after the primitives are trustworthy.
## Current Baseline
Development startup currently loads:
- [`shared/data/scenarios/empty.json`](/home/jbourdon/repos/space-game/shared/data/scenarios/empty.json)
The backend startup path is defined in:
- [`apps/backend/Program.cs`](/home/jbourdon/repos/space-game/apps/backend/Program.cs)
World reset returns to the startup scenario through:
- [`apps/backend/Universe/Api/ResetWorldHandler.cs`](/home/jbourdon/repos/space-game/apps/backend/Universe/Api/ResetWorldHandler.cs)
## Environment
Manual runs should use a reproducible local development setup.
Suggested startup:
1. Start postgres with `./scripts/start-postgres.sh`
2. Start backend in development mode
3. Start the viewer
4. Log in as a GM user
5. Reset the world before each test pass
Relevant files:
- [`scripts/start-postgres.sh`](/home/jbourdon/repos/space-game/scripts/start-postgres.sh)
- [`apps/backend/appsettings.Development.json`](/home/jbourdon/repos/space-game/apps/backend/appsettings.Development.json)
- [`apps/viewer/package.json`](/home/jbourdon/repos/space-game/apps/viewer/package.json)
Development GM credentials currently include:
- `gm` / `gm`
- `admin` / `admin`
## Test Method
Each manual test should record:
- setup
- action
- expected result
- observed result
- pass or fail
- notes
Recommended rule:
- if a test leaves the world in a noisy or questionable state, reset before the next test
Recommended evidence to capture:
- ship state
- ship spatial state
- active plan and subtasks
- order queue
- inventory changes
- station docking state
- viewer selection and inspector state
## Phase 1: Boot And Baseline
These tests must pass before behavior testing has value.
### V-001 Backend boots cleanly
Setup:
- start backend in development mode
Expected:
- startup succeeds
- auth schema initializes
- dev users seed
- world loads from `empty.json`
- no startup exception is thrown
### V-002 Viewer connects and renders world
Setup:
- start viewer and open the app
Expected:
- world snapshot loads
- live delta stream connects
- no obvious contract mismatch or rendering crash appears
### V-003 Reset returns world to clean baseline
Setup:
- use the GM reset action
Expected:
- world returns to startup scenario
- previously spawned factions, ships, and stations are gone
- sequence and snapshot refresh behave cleanly
### V-004 Empty world is actually minimal
Setup:
- inspect the world after reset
Expected:
- systems, celestials, anchors, and resource nodes exist
- no initial factions, stations, or ships exist unless intentionally seeded later
## Phase 2: Minimal Actor Creation
These tests prove the empty world can be turned into a controlled validation sandbox.
### V-010 Create a faction
Method:
- use the GM faction creation flow
Relevant API:
- `POST /api/gm/factions`
Expected:
- the faction appears in the world
- it is visible in the GM UI
- no duplicate or invalid-creation error occurs for a valid faction id
### V-011 Spawn a ship
Method:
- spawn a ship for the created faction in a known system
Relevant API:
- `POST /api/gm/ships`
Expected:
- the ship appears in the selected system
- the ship has a valid id, faction, system, and spatial state
- the viewer can select and inspect it
### V-012 Spawn a station
Method:
- spawn a station for the created faction in a known system
Relevant API:
- `POST /api/gm/stations`
Expected:
- the station appears in the world
- the station has a valid anchor association or valid placement according to current runtime rules
- the viewer can focus and inspect it
### V-013 Spawn multiple ships of different roles
Method:
- create at least:
- one miner-capable ship
- one combat-capable ship
- one generic utility or trader if available
Expected:
- each ship spawns without corrupting world state
- each ship reports sensible movement, cargo, and behavior fields
## Phase 3: Direct Order Validation
This phase validates immediate control and plan execution.
Relevant backend surface:
- [`apps/backend/Ships/Contracts/ShipCommands.cs`](/home/jbourdon/repos/space-game/apps/backend/Ships/Contracts/ShipCommands.cs)
- [`apps/backend/Ships/Contracts/Ships.cs`](/home/jbourdon/repos/space-game/apps/backend/Ships/Contracts/Ships.cs)
- [`apps/backend/Ships/Api/EnqueueShipOrderHandler.cs`](/home/jbourdon/repos/space-game/apps/backend/Ships/Api/EnqueueShipOrderHandler.cs)
### V-020 Queue a move or fly order
Method:
- issue a direct move-style order to a ship
- prefer `fly-and-wait` through the current viewer flow
Expected:
- the order appears in the queue
- an active plan is created
- subtasks are coherent
- the ship moves toward the target
- the order eventually completes
Watch for:
- ship never leaving idle
- plan created but no subtask progress
- target position mismatch
- order stays executing forever
### V-021 Queue follow ship
Method:
- spawn two ships
- issue `follow-ship` from one to the other
Expected:
- the follower tracks the target ship
- the follower updates position as the target moves
- no oscillation or runaway drift appears
### V-022 Queue attack target
Method:
- spawn two ships from opposing factions if required by current hostility logic
- issue `attack-target`
Expected:
- order is accepted
- attacker closes to engagement range
- combat state transitions occur
- health changes on the target if combat is functioning
Watch for:
- invalid target acceptance
- attacker never approaching
- attacker stuck in transit or wait state
- combat order silently failing
### V-023 Queue mine resource
Method:
- issue `mine-and-deliver` against a valid resource in the current system
Expected:
- ship selects a valid resource node or deposit
- ship reaches the mining location
- mining progress occurs
- cargo increases
- delivery or post-mining behavior is coherent
Watch for:
- no valid mining target selected
- ship arrives but never mines
- cargo remains unchanged
- order fails with missing target when a target exists
### V-024 Queue dock and wait if station exists
Method:
- spawn a station
- issue a docking-capable order path
Expected:
- ship requests or performs docking
- docked state is visible
- station dock count updates
- undocking or wait completion works
### V-025 Remove an order
Method:
- queue an order, then remove it before completion
Expected:
- the order is removed cleanly
- the ship replans safely
- the ship returns to default behavior or idle state
- no orphan active subtasks remain
## Phase 4: Default Behavior Validation
This phase validates autonomous ship control rather than one-shot direct orders.
Relevant backend surface:
- [`apps/backend/Shared/Runtime/ShipAutomationCatalog.cs`](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/ShipAutomationCatalog.cs)
- [`apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs`](/home/jbourdon/repos/space-game/apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs)
Relevant viewer surfaces:
- [`apps/viewer/src/components/ViewerEntityInspectorPanel.vue`](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerEntityInspectorPanel.vue)
- [`apps/viewer/src/components/ViewerShipOrderContextMenu.vue`](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerShipOrderContextMenu.vue)
### V-030 Hold position
Method:
- set default behavior to `hold-position`
Expected:
- ship remains stable
- behavior is reflected in inspector state
- no unintended autonomous orders are generated
### V-031 Fly and wait behavior
Method:
- set default behavior to `fly-and-wait`
- provide a valid target position or object
Expected:
- behavior-backed order is synthesized
- ship moves to the target
- ship waits as configured
- behavior continues to own control after completion
### V-032 Follow ship behavior
Method:
- set default behavior to `follow-ship`
Expected:
- managed follow behavior is generated
- the ship stays near its target within reasonable tolerance
### V-033 Patrol behavior
Method:
- configure patrol points if supported through current UI flow
Expected:
- patrol orders are generated from the behavior
- ship cycles patrol movement cleanly
- if a threat appears, patrol can interrupt into attack behavior as intended
### V-034 Local auto mine
Method:
- set `local-auto-mine` on a miner in a system with mineable nodes
Expected:
- if a valid local mining target exists, the ship mines it
- if no valid target exists, failure is readable and not destructive
Note:
- current catalog marks this as partially supported
### V-035 Advanced or expert auto mine
Method:
- set `advanced-auto-mine` or `expert-auto-mine`
Expected:
- behavior synthesizes a mine-and-deliver run
- ship selects a resource source and delivery path
- behavior can repeat after completion
### V-036 Combat guard behaviors
Method:
- validate one or more of:
- `protect-position`
- `protect-ship`
- `protect-station`
- `police`
Expected:
- behavior creates managed guard or intercept orders
- threat response is coherent
- ship returns to guarding behavior after engagement if still valid
## Phase 5: Spatial And Transit Validation
This phase validates the new universe-model runtime behavior.
Primary concern:
- ships should behave as anchor-aware entities rather than generic free-flying system dots
### V-040 Spatial state is coherent at rest
Expected:
- a resting ship reports a sensible `SpatialState`
- `SpaceLayer`, `CurrentSystemId`, `CurrentAnchorId`, and `MovementRegime` agree with the visible world state
### V-041 Local movement remains local
Expected:
- local movement updates local position coherently
- the ship does not accidentally enter invalid transit state
### V-042 Intra-system transit is explicit
Method:
- send a ship between distant anchors if the current order flow supports it
Expected:
- movement regime transitions are explicit
- transit state reports origin, destination, and progress
- arrival returns the ship to a valid anchor-local state
### V-043 Inter-system travel if available
Method:
- attempt a cross-system route through current supported mechanics
Expected:
- system change happens through a coherent transit path
- no entity duplication or dropped ship occurs
## Phase 6: Docking, Cargo, And Station Interaction
These tests prove basic station interaction works.
### V-050 Docking updates both sides
Expected:
- ship shows docked station id
- station docked ship list updates
- dock count changes are visible in the viewer
### V-051 Cargo transfer changes inventory
Method:
- use a mining or delivery flow involving a station
Expected:
- ship inventory changes
- station inventory changes
- transfer is not purely cosmetic
### V-052 Invalid docking fails cleanly
Method:
- attempt docking or a delivery path with a ship or station that should not support it
Expected:
- failure is visible and readable
- ship does not become stuck in permanent docking state
## Phase 7: Combat Validation
These tests are still primitive in the empty-world phase.
The goal is not full tactical balance.
The goal is to prove the combat loop exists and behaves coherently.
### V-060 Attack order enters engagement
Expected:
- attacker closes on target
- attack state appears
- target health changes if weapons and hostility permit combat
### V-061 Combat resolves to a stable end state
Expected:
- one of the following happens cleanly:
- target destroyed
- attacker disengages
- order fails with a readable reason
No permanent broken state should remain.
### V-062 Non-combat ship does not behave like a combat ship
Method:
- issue combat pressure to a non-combat or civilian ship if possible
Expected:
- behavior is limited, defensive, or clearly incapable
- it should not unrealistically perform like a dedicated combat hull unless current design says it can
## Phase 8: Invalid And Edge Cases
These are mandatory because many simulation regressions hide in failure handling rather than happy paths.
### V-070 Invalid target order
Method:
- send an order with a missing or invalid target
Expected:
- backend rejects the order or marks it failed cleanly
- no corrupted plan remains
### V-071 Remove target during execution
Method:
- destroy or invalidate the target context while a ship is executing an order
Expected:
- ship replans or fails safely
- no null-state or endless execution loop appears
### V-072 Reset during active simulation
Method:
- reset the world while ships are active
Expected:
- viewer refreshes cleanly
- no stale selected entity state causes crashes
- world stream recovers to fresh baseline
### V-073 Behavior with impossible prerequisites
Method:
- assign a behavior that requires a target, station, or ware that is not available
Expected:
- failure is readable
- ship falls back safely
- behavior does not create runaway order spam
## Phase 9: Viewer-State Validation
The simulation may be correct while the viewer is misleading.
That is still a failure.
### V-080 Inspector reflects real ship state
Expected:
- order queue, active plan, subtasks, inventory, health, and spatial state match observed behavior
### V-081 Selection survives world updates
Expected:
- selecting a ship or station remains stable through normal delta updates
### V-082 Focus and follow modes remain usable
Expected:
- camera focus and tracking do not break during movement, docking, or combat
### V-083 Context actions target the intended entity
Method:
- use context menu actions such as:
- mine resource
- fly to and wait
- follow ship
- attack
Expected:
- the generated order matches the selected target
- the resulting ship action matches the command label
## Recommended Manual Run Order
Run in this order:
1. boot and reset validation
2. faction creation
3. ship spawn
4. station spawn
5. direct navigation order
6. direct mining order
7. docking and delivery
8. direct attack order
9. default behavior checks
10. edge and failure checks
11. viewer consistency pass
## Minimum Pass Criteria
The current basis of the game should be considered working only if all of the following are true:
- world startup and reset are reliable
- actors can be spawned into an empty baseline
- at least one ship can move successfully
- at least one ship can mine successfully
- at least one ship can attack successfully
- at least one ship can dock and transfer inventory successfully
- direct orders can be added and removed cleanly
- default behaviors can control ships without obvious stuck states
- viewer state remains trustworthy during all of the above
## Failure Reporting
When a test fails, record:
- test id
- exact setup
- exact action taken
- whether failure happened in backend, simulation behavior, or viewer representation
- whether reset recovers the world cleanly
- likely regression area if visible from inspector or logs
Suggested failure categories:
- startup
- API contract
- planning
- subtask execution
- movement
- docking
- mining
- combat
- inventory
- viewer sync
- reset or stream lifecycle
## Follow-Up
After this manual pass stabilizes:
1. turn the most important Phase 1 through Phase 4 checks into runtime-level simulation tests
2. prefer real simulation execution over mocked unit tests
3. add richer scenario validation only after primitive behavior passes consistently
That next phase should validate composed loops:
- mine -> dock -> unload
- trade route -> station inventory update
- construction support
- guard and intercept response
- longer-run autonomous behavior without manual intervention

View File

@@ -0,0 +1,66 @@
{
"worldGeneration": {
"seed": 1,
"targetSystemCount": 1,
"useKnownSystems": false,
"aiControllerFactionCount": 0,
"generatePlayerFaction": false
},
"systems": [
{
"id": "minimal",
"label": "Minimal Test System",
"position": [0, 0, 0],
"stars": [
{
"kind": "main-sequence",
"color": "#fff1b8",
"glow": "#ffd35a",
"size": 420000,
"orbitRadius": 0,
"orbitSpeed": 0,
"orbitPhaseAtEpoch": 0
}
],
"asteroidField": {
"decorationCount": 0,
"radiusOffset": 0,
"radiusVariance": 0,
"heightVariance": 0
},
"resourceNodes": [
{
"sourceKind": "asteroid-belt",
"angle": 0.6,
"radiusOffset": 180000,
"inclinationDegrees": 3,
"oreAmount": 12000,
"itemId": "ore",
"shardCount": 9
}
],
"planets": [
{
"label": "Primer",
"planetType": "terrestrial",
"shape": "sphere",
"moons": [],
"orbitRadius": 0.8,
"orbitSpeed": 0.14,
"orbitEccentricity": 0.01,
"orbitInclination": 0,
"orbitLongitudeOfAscendingNode": 0,
"orbitArgumentOfPeriapsis": 0,
"orbitPhaseAtEpoch": 0,
"size": 6200,
"color": "#6ea7d4",
"tilt": 0,
"hasRing": false
}
]
}
],
"initialStations": [],
"shipFormations": [],
"patrolRoutes": []
}

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