Compare commits

...

8 Commits

137 changed files with 8102 additions and 3729 deletions

2
.gitignore vendored
View File

@@ -17,3 +17,5 @@ pnpm-debug.log*
.env
.env.*
!.env.example
.codex

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

@@ -0,0 +1,22 @@
using FastEndpoints;
using SpaceGame.Api.Universe.Bootstrap;
namespace SpaceGame.Api.Auth.Api;
public sealed class GetRacesHandler(IStaticDataProvider staticData) : EndpointWithoutRequest<IReadOnlyList<RaceSnapshot>>
{
public override void Configure()
{
Get("/api/auth/races");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken cancellationToken)
{
var races = staticData.RaceDefinitions.Values
.OrderBy(race => race.Name, StringComparer.Ordinal)
.Select(race => new RaceSnapshot(race.Id, race.Name, race.Description, race.Icon))
.ToList();
await SendOkAsync(races, cancellationToken);
}
}

View File

@@ -2,7 +2,7 @@ using FastEndpoints;
namespace SpaceGame.Api.Auth.Api;
public sealed class RegisterHandler(AuthService authService) : Endpoint<RegisterRequest, AuthSessionResponse>
public sealed class RegisterHandler(AuthService authService) : Endpoint<RegisterRequest, RegisterResponse>
{
public override void Configure()
{

View File

@@ -37,6 +37,11 @@ public sealed record AuthSessionResponse(
string RefreshToken,
DateTimeOffset RefreshTokenExpiresAtUtc);
public sealed record RegisterResponse(
Guid UserId,
string Email,
bool RequiresLogin);
public sealed record ForgotPasswordResponse(
bool Accepted,
string? ResetToken = null);

View File

@@ -0,0 +1,7 @@
namespace SpaceGame.Api.Auth.Contracts;
public sealed record RaceSnapshot(
string Id,
string Name,
string Description,
string Icon);

View File

@@ -7,7 +7,7 @@ public sealed class AuthService(
RefreshTokenFactory refreshTokenFactory,
IPasswordResetDelivery passwordResetDelivery)
{
public async Task<AuthSessionResponse> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
public async Task<RegisterResponse> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
{
var email = NormalizeEmail(request.Email);
ValidatePassword(request.Password);
@@ -18,7 +18,7 @@ public sealed class AuthService(
}
var user = await authRepository.CreateUserAsync(email, passwordHasher.HashPassword(request.Password), [], cancellationToken);
return await CreateSessionAsync(user, cancellationToken);
return new RegisterResponse(user.Id, user.Email, true);
}
public async Task<AuthSessionResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken)

View File

@@ -5,6 +5,8 @@ namespace SpaceGame.Api.Auth.Simulation;
public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpContextAccessor) : IPlayerIdentityResolver
{
public const string EffectivePlayerHeaderName = "X-Act-As-Player-Id";
public Guid? GetCurrentPlayerId()
{
var subject = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
@@ -15,6 +17,21 @@ public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpC
public Guid GetRequiredPlayerId() =>
GetCurrentPlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required.");
public Guid? GetEffectivePlayerId()
{
var currentPlayerId = GetCurrentPlayerId();
if (!CanAccessGm())
{
return currentPlayerId;
}
var requestedIdentity = httpContextAccessor.HttpContext?.Request.Headers[EffectivePlayerHeaderName].FirstOrDefault();
return Guid.TryParse(requestedIdentity, out var effectivePlayerId) ? effectivePlayerId : currentPlayerId;
}
public Guid GetRequiredEffectivePlayerId() =>
GetEffectivePlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required.");
public bool CanAccessGm()
{
var user = httpContextAccessor.HttpContext?.User;

View File

@@ -4,6 +4,7 @@ public interface IAuthRepository
{
Task<UserAccount?> FindUserByEmailAsync(string email, CancellationToken cancellationToken);
Task<UserAccount?> FindUserByIdAsync(Guid userId, CancellationToken cancellationToken);
Task<IReadOnlyList<UserAccount>> ListUsersAsync(CancellationToken cancellationToken);
Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
Task<UserAccount> UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken);

View File

@@ -4,5 +4,7 @@ public interface IPlayerIdentityResolver
{
Guid? GetCurrentPlayerId();
Guid GetRequiredPlayerId();
Guid? GetEffectivePlayerId();
Guid GetRequiredEffectivePlayerId();
bool CanAccessGm();
}

View File

@@ -28,6 +28,23 @@ public sealed class PostgresAuthRepository(NpgsqlDataSource dataSource) : IAuthR
return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null;
}
public async Task<IReadOnlyList<UserAccount>> ListUsersAsync(CancellationToken cancellationToken)
{
await using var command = dataSource.CreateCommand("""
select id, email, password_hash, created_at_utc, roles
from auth_users
order by email asc
""");
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var users = new List<UserAccount>();
while (await reader.ReadAsync(cancellationToken))
{
users.Add(ReadUser(reader));
}
return users;
}
public async Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken)
{
var userId = Guid.NewGuid();

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

@@ -0,0 +1,31 @@
using FastEndpoints;
namespace SpaceGame.Api.PlayerFaction.Api;
public sealed class CompletePlayerOnboardingHandler(WorldService worldService) : Endpoint<CompletePlayerOnboardingRequest, PlayerFactionSnapshot>
{
public override void Configure()
{
Post("/api/player-faction/onboarding");
}
public override async Task HandleAsync(CompletePlayerOnboardingRequest request, CancellationToken cancellationToken)
{
try
{
var snapshot = worldService.CompletePlayerOnboarding(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

@@ -0,0 +1,73 @@
using FastEndpoints;
using SpaceGame.Api.Auth.Runtime;
using SpaceGame.Api.Auth.Simulation;
using SpaceGame.Api.PlayerFaction.Simulation;
namespace SpaceGame.Api.PlayerFaction.Api;
public sealed class GetPlayerIdentitiesHandler(IAuthRepository authRepository, IPlayerStateStore playerStateStore)
: EndpointWithoutRequest<IReadOnlyList<PlayerIdentitySummaryResponse>>
{
public override void Configure()
{
Get("/api/player-faction/identities");
Policies(AuthPolicyNames.GmAccess);
}
public override async Task HandleAsync(CancellationToken cancellationToken)
{
var users = await authRepository.ListUsersAsync(cancellationToken);
var playerFactionsByPlayerId = playerStateStore.GetPlayerFactionsByPlayerId();
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");
playerFactionsByPlayerId.TryGetValue(userId, out var playerFaction);
responses.Add(new PlayerIdentitySummaryResponse(
userId,
user.Email,
user.Roles,
playerFaction is not null,
playerFaction?.Id,
playerFaction?.Label,
playerFaction?.SovereignFactionId));
seenIds.Add(userId);
}
foreach (var (playerId, playerFaction) in playerFactionsByPlayerId)
{
if (!seenIds.Add(playerId))
{
continue;
}
responses.Add(new PlayerIdentitySummaryResponse(
playerId,
$"{playerId}@unknown",
Array.Empty<string>(),
true,
playerId,
playerFaction.Label,
playerFaction.SovereignFactionId));
}
await SendOkAsync(
responses
.OrderBy(response => response.Email, StringComparer.OrdinalIgnoreCase)
.ThenBy(response => response.UserId, StringComparer.Ordinal)
.ToList(),
cancellationToken);
}
}
public sealed record PlayerIdentitySummaryResponse(
string UserId,
string Email,
IReadOnlyList<string> Roles,
bool HasPlayerFaction,
string? PlayerFactionId,
string? PlayerFactionLabel,
string? 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,
@@ -249,7 +249,10 @@ public sealed record PlayerAlertSnapshot(
public sealed record PlayerFactionSnapshot(
string Id,
string Label,
string? PersonaName,
string? RaceId,
string SovereignFactionId,
bool RequiresOnboarding,
string Status,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc,

View File

@@ -1,5 +1,9 @@
namespace SpaceGame.Api.PlayerFaction.Contracts;
public sealed record CompletePlayerOnboardingRequest(
string Name,
string RaceId);
public sealed record PlayerOrganizationCommandRequest(
string Kind,
string Label,
@@ -41,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

@@ -6,7 +6,10 @@ public sealed class PlayerFactionRuntime
{
public required string Id { get; init; }
public required string Label { get; set; }
public string? PersonaName { get; set; }
public string? RaceId { get; set; }
public required string SovereignFactionId { get; set; }
public bool RequiresOnboarding { get; set; } = true;
public string Status { get; set; } = "active";
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
@@ -248,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

@@ -12,7 +12,10 @@ public sealed class PlayerFactionProjectionService
return new PlayerFactionSnapshot(
player.Id,
player.Label,
player.PersonaName,
player.RaceId,
player.SovereignFactionId,
player.RequiresOnboarding,
player.Status,
player.CreatedAtUtc,
player.UpdatedAtUtc,
@@ -198,7 +201,7 @@ public sealed class PlayerFactionProjectionService
directive.UseOrders,
directive.StagingOrderKind,
directive.ItemId,
directive.PreferredNodeId,
directive.PreferredAnchorId,
directive.PreferredConstructionSiteId,
directive.PreferredModuleId,
directive.Priority,
@@ -258,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

@@ -20,14 +20,12 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
{
var sovereignFaction = world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).FirstOrDefault()
?? throw new InvalidOperationException("Cannot create a player faction domain without any factions in the world.");
var player = playerStateStore.GetOrAddPlayerFaction(playerId, () => new PlayerFactionRuntime
{
Id = PlayerFactionDomainId,
Label = $"{sovereignFaction.Label} Command",
SovereignFactionId = sovereignFaction.Id,
Label = "Pending Pilot",
SovereignFactionId = string.Empty,
RequiresOnboarding = true,
CreatedAtUtc = world.GeneratedAtUtc,
UpdatedAtUtc = world.GeneratedAtUtc,
});
@@ -37,6 +35,58 @@ internal sealed class PlayerFactionService
return player;
}
internal PlayerFactionRuntime CompleteOnboarding(
SimulationWorld world,
IPlayerStateStore playerStateStore,
string playerId,
CompletePlayerOnboardingRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
if (!player.RequiresOnboarding)
{
throw new InvalidOperationException("Player onboarding has already been completed.");
}
var personaName = request.Name.Trim();
if (personaName.Length < 2)
{
throw new InvalidOperationException("Player name must contain at least 2 characters.");
}
if (personaName.Length > 48)
{
throw new InvalidOperationException("Player name must contain at most 48 characters.");
}
var ownedFactionId = BuildOwnedFactionId(playerId);
if (world.Factions.Any(faction => string.Equals(faction.Id, ownedFactionId, StringComparison.Ordinal)))
{
throw new InvalidOperationException($"Player faction '{ownedFactionId}' already exists in the current world.");
}
player.Label = personaName;
player.PersonaName = personaName;
player.RaceId = request.RaceId.Trim();
player.SovereignFactionId = ownedFactionId;
player.RequiresOnboarding = false;
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
return player;
}
internal PlayerFactionRuntime EnsureInitializedDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
{
var player = EnsureDomain(world, playerStateStore, playerId);
if (player.RequiresOnboarding || string.IsNullOrWhiteSpace(player.SovereignFactionId))
{
throw new InvalidOperationException("Player onboarding must be completed before issuing gameplay commands.");
}
return player;
}
internal static string BuildOwnedFactionId(string playerId) =>
$"player-{playerId.Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant()}";
internal void Update(SimulationWorld world, IPlayerStateStore playerStateStore, float _deltaSeconds, ICollection<SimulationEventRecord> events)
{
if (playerStateStore.GetPlayerFactions().Count == 0)
@@ -63,7 +113,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerOrganizationCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player));
var nowUtc = DateTimeOffset.UtcNow;
@@ -180,7 +230,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
RemoveOrganization(player, organizationId);
player.Assignments.RemoveAll(assignment =>
assignment.FleetId == organizationId ||
@@ -198,7 +248,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId, PlayerOrganizationMembershipCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var kind = ResolveOrganizationKind(player, organizationId);
switch (kind)
{
@@ -249,7 +299,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? directiveId, PlayerDirectiveCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var directive = directiveId is null
? null
: player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal));
@@ -279,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;
@@ -305,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),
@@ -326,7 +376,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string directiveId)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
player.Directives.RemoveAll(directive => directive.Id == directiveId);
foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId))
{
@@ -340,7 +390,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? policyId, PlayerPolicyCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var policy = policyId is null
? null
: player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal));
@@ -411,7 +461,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var policy = automationPolicyId is null
? null
: player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal));
@@ -451,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),
@@ -469,7 +519,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var policy = reinforcementPolicyId is null
? null
: player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal));
@@ -503,7 +553,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? productionProgramId, PlayerProductionProgramCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var program = productionProgramId is null
? null
: player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal));
@@ -535,7 +585,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string assetId, PlayerAssetAssignmentCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var assignment = player.Assignments.FirstOrDefault(candidate =>
string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) &&
string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal));
@@ -594,7 +644,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerStrategicIntentCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
player.StrategicIntent.StrategicPosture = request.StrategicPosture;
player.StrategicIntent.EconomicPosture = request.EconomicPosture;
player.StrategicIntent.MilitaryPosture = request.MilitaryPosture;
@@ -610,7 +660,7 @@ internal sealed class PlayerFactionService
internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipOrderCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
@@ -622,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,
@@ -642,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),
@@ -654,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";
@@ -669,7 +709,7 @@ internal sealed class PlayerFactionService
internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
@@ -681,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";
@@ -710,9 +740,96 @@ 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 = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
@@ -755,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;
@@ -781,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),
@@ -852,6 +969,24 @@ internal sealed class PlayerFactionService
private static void SyncRegistry(SimulationWorld world, PlayerFactionRuntime player)
{
if (string.IsNullOrWhiteSpace(player.SovereignFactionId))
{
SyncSet(player.AssetRegistry.ShipIds, []);
SyncSet(player.AssetRegistry.StationIds, []);
SyncSet(player.AssetRegistry.CommanderIds, []);
SyncSet(player.AssetRegistry.ClaimIds, []);
SyncSet(player.AssetRegistry.ConstructionSiteIds, []);
SyncSet(player.AssetRegistry.PolicySetIds, player.Policies.Where(entry => entry.PolicySetId is not null).Select(entry => entry.PolicySetId!));
SyncSet(player.AssetRegistry.MarketOrderIds, []);
SyncSet(player.AssetRegistry.FleetIds, player.Fleets.Select(fleet => fleet.Id));
SyncSet(player.AssetRegistry.TaskForceIds, player.TaskForces.Select(taskForce => taskForce.Id));
SyncSet(player.AssetRegistry.StationGroupIds, player.StationGroups.Select(group => group.Id));
SyncSet(player.AssetRegistry.EconomicRegionIds, player.EconomicRegions.Select(region => region.Id));
SyncSet(player.AssetRegistry.FrontIds, player.Fronts.Select(front => front.Id));
SyncSet(player.AssetRegistry.ReserveIds, player.Reserves.Select(reserve => reserve.Id));
return;
}
SyncSet(player.AssetRegistry.ShipIds, world.Ships.Where(ship => ship.FactionId == player.SovereignFactionId).Select(ship => ship.Id));
SyncSet(player.AssetRegistry.StationIds, world.Stations.Where(station => station.FactionId == player.SovereignFactionId).Select(station => station.Id));
SyncSet(player.AssetRegistry.CommanderIds, world.Commanders.Where(commander => commander.FactionId == player.SovereignFactionId).Select(commander => commander.Id));
@@ -1224,8 +1359,7 @@ internal sealed class PlayerFactionService
return player.AutomationPolicies.FirstOrDefault(policy => policy.Id == automationId);
}
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId)
?? player.AutomationPolicies.FirstOrDefault(policy => policy.Id == "player-core-automation");
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId);
}
private static PlayerFactionPolicyRuntime? ResolvePolicy(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string assetId)
@@ -1254,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);
@@ -1351,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,
@@ -1371,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))
@@ -1394,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,
@@ -1403,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;
}
@@ -1458,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;
@@ -1479,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)
@@ -1500,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)
@@ -1522,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)
@@ -1567,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))
{
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;
}
ship.State = ShipState.Warping;
}
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));
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,293 +1,149 @@
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-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)
])
]);
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 ShipPlanRuntime BuildFleetSupplyPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, FleetSupplyPlan plan)
private IReadOnlyList<ShipSubTaskRuntime> BuildFleetSupplySubTasks(FleetSupplyPlan plan)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
SupplyFleet,
plan.Summary,
[
CreateStep("step-fleet-acquire", "acquire-cargo", $"Load {plan.ItemId} at {plan.SourceStation.Label}",
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),
])
]);
];
}
private ShipPlanRuntime BuildConstructionPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ConstructionSiteRuntime site, StationRuntime supportStation, string summary)
private IReadOnlyList<ShipSubTaskRuntime> BuildConstructionSubTasks(ConstructionSiteRuntime site, StationRuntime supportStation)
{
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}",
var targetPosition = supportStation.Position;
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-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f)
])
]);
CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f),
CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f),
];
}
private ShipPlanRuntime BuildAttackPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string? targetSystemId, string summary)
private static IReadOnlyList<ShipSubTaskRuntime> BuildAttackSubTasks(string targetEntityId, string? targetSystemId, string summary)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.AttackTarget,
summary,
return
[
CreateStep("step-attack", ShipOrderKinds.AttackTarget, summary,
[
CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? ship.SystemId, ship.Position, targetEntityId, 26f, 0f)
])
]);
CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? string.Empty, Vector3.Zero, targetEntityId, 26f, 0f),
];
}
private ShipPlanRuntime BuildDockAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime station, float waitSeconds, string summary)
private static IReadOnlyList<ShipSubTaskRuntime> BuildFlyToObjectSubTasks(string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.DockAndWait,
summary,
[
CreateStep("step-dock-wait-travel", "travel", $"Travel to {station.Label}",
[
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),
])
]);
}
private ShipPlanRuntime BuildFlyAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, float waitSeconds, string summary)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.FlyAndWait,
summary,
[
CreateStep("step-fly-wait", ShipOrderKinds.FlyAndWait, summary,
[
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),
])
]);
}
private ShipPlanRuntime BuildFlyToObjectPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.FlyToObject,
summary,
[
CreateStep("step-fly-object", ShipOrderKinds.FlyToObject, summary,
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)),
])
]);
];
}
private ShipPlanRuntime BuildFollowShipPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ShipRuntime targetShip, float radius, float durationSeconds, string summary)
{
return BuildFollowPlan(ship, sourceKind, sourceId, targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, 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 ShipPlanRuntime BuildFollowPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary)
private static IReadOnlyList<ShipSubTaskRuntime> BuildFollowSubTasks(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,
return
[
CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds),
])
]);
];
}
private ShipPlanRuntime CreateIdlePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary)
private static IReadOnlyList<ShipSubTaskRuntime> BuildHoldSubTasks(ShipRuntime ship, ShipOrderRuntime order)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
Idle,
summary,
return
[
CreateStep("step-idle", ShipOrderKinds.HoldPosition, summary,
CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f),
];
}
private IReadOnlyList<ShipSubTaskRuntime> BuildMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node, StationRuntime homeStation)
{
var deposit = SelectMiningDeposit(node, ship.Id);
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
return
[
CreateSubTask("sub-idle", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 1.5f)
])
]);
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 CreateBlockedPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary, string blockingReason)
private IReadOnlyList<ShipSubTaskRuntime> BuildLocalMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node)
{
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;
var deposit = SelectMiningDeposit(node, ship.Id);
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
return
[
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 static ShipPlanRuntime CreatePlan(
ShipRuntime ship,
AiPlanSourceKind sourceKind,
string sourceId,
string kind,
string summary,
IReadOnlyList<ShipPlanStepRuntime> steps)
private IReadOnlyList<ShipSubTaskRuntime> BuildLocalMiningDeliverySubTasks(ShipRuntime ship, StationRuntime buyer, string itemId)
{
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;
var amount = MathF.Max(1f, GetInventoryAmount(ship.Inventory, itemId));
return
[
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 static ShipPlanStepRuntime CreateStep(string id, string kind, string summary, IReadOnlyList<ShipSubTaskRuntime> subTasks)
private IReadOnlyList<ShipSubTaskRuntime> BuildSalvageSubTasks(ShipRuntime ship, WreckRuntime wreck, StationRuntime homeStation, Vector3 approach)
{
var step = new ShipPlanStepRuntime
{
Id = id,
Kind = kind,
Summary = summary,
};
step.SubTasks.AddRange(subTasks);
return step;
return
[
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 static ShipSubTaskRuntime CreateSubTask(
@@ -301,7 +157,9 @@ public sealed partial class ShipAiService
float amount,
string? itemId = null,
string? moduleId = null,
string? targetNodeId = null) =>
string? targetAnchorId = null,
string? targetResourceNodeId = null,
string? targetResourceDepositId = null) =>
new()
{
Id = id,
@@ -310,7 +168,9 @@ public sealed partial class ShipAiService
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
TargetEntityId = targetEntityId,
TargetNodeId = targetNodeId,
TargetAnchorId = targetAnchorId,
TargetResourceNodeId = targetResourceNodeId,
TargetResourceDepositId = targetResourceDepositId,
ItemId = itemId,
ModuleId = moduleId,
Threshold = threshold,

View File

@@ -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),
];
}
private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order)
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 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",
return
[
CreateStep("step-move", "travel", order.Label ?? "Travel",
[
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}",
return
[
CreateStep("step-dock-travel", "travel", $"Travel to {station.Label}",
[
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;
EnsureOrderExecution(world, ship, events);
ExecuteOrder(world, ship, deltaSeconds, events);
TrackHistory(ship);
EmitStateEvents(ship, previousState, previousOrderId, previousTaskId, events);
}
var desiredSourceKind = topOrder is null ? AiPlanSourceKind.DefaultBehavior : AiPlanSourceKind.Order;
var desiredSourceId = topOrder?.Id ?? ResolveBehaviorSource(world, ship).SourceId;
var currentPlan = ship.ActivePlan;
private void EnsureOrderExecution(SimulationWorld world, ShipRuntime ship, ICollection<SimulationEventRecord> events)
{
var currentOrder = ship.OrderQueue.GetCurrentOrder();
if (currentOrder is null)
{
ClearActiveOrder(ship);
ApplyIdleOrBlockedState(world, ship);
return;
}
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)
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)
{
return;
}
if (ship.ReplanCooldownSeconds > 0f && currentPlan is null)
if (ship.ReplanCooldownSeconds > 0f && !string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal))
{
return;
}
ShipPlanRuntime? nextPlan = desiredSourceKind == AiPlanSourceKind.Order
? BuildOrderPlan(world, ship, topOrder!)
: BuildBehaviorFallbackPlan(world, ship);
if (nextPlan is null)
var subTasks = BuildOrderSubTasks(world, ship, currentOrder);
if (subTasks is null || subTasks.Count == 0)
{
nextPlan = CreateIdlePlan(ship, desiredSourceKind, desiredSourceId, "No viable plan");
}
if (nextPlan.Kind != Idle)
{
ship.LastAccessFailureReason = null;
}
ReplacePlan(ship, nextPlan, "replanned", events);
}
private void ExecutePlan(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
var plan = ship.ActivePlan;
if (plan is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
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;
}
if (plan.CurrentStepIndex >= plan.Steps.Count)
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)
{
CompletePlan(ship, plan, events);
var order = ship.ActiveOrderId is null ? null : ship.OrderQueue.FindById(ship.ActiveOrderId);
if (order is null)
{
ClearActiveOrder(ship);
ApplyIdleOrBlockedState(world, ship);
return;
}
plan.UpdatedAtUtc = DateTimeOffset.UtcNow;
var step = plan.Steps[plan.CurrentStepIndex];
if (step.Status == AiPlanStepStatus.Planned)
if (ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count)
{
step.Status = AiPlanStepStatus.Running;
}
if (step.CurrentSubTaskIndex >= step.SubTasks.Count)
{
CompleteStep(plan, step);
CompleteOrderExecution(ship, order, events);
return;
}
var subTask = step.SubTasks[step.CurrentSubTaskIndex];
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;
}
ship.ActiveOrderId = order.Id;
ship.ActiveSubTaskIndex = 0;
ship.ActiveSubTasks.Clear();
ship.ActiveSubTasks.AddRange(subTasks);
ship.NeedsReplan = false;
ship.ReplanCooldownSeconds = 0f;
ship.LastReplanReason = "order-execution-started";
ship.LastDeltaSignature = string.Empty;
}
private static void CompletePlan(ShipRuntime ship, ShipPlanRuntime plan, ICollection<SimulationEventRecord> events)
private static void ClearActiveOrder(ShipRuntime ship)
{
plan.Status = AiPlanStatus.Completed;
var completedOrder = plan.SourceKind == AiPlanSourceKind.Order
? ship.OrderQueue.FirstOrDefault(order => order.Id == plan.SourceId)
: null;
if (completedOrder is not null)
ship.ActiveOrderId = null;
ship.ActiveSubTaskIndex = 0;
ship.ActiveSubTasks.Clear();
}
private void CompleteOrderExecution(ShipRuntime ship, ShipOrderRuntime order, ICollection<SimulationEventRecord> events)
{
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.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;
}
}
ship.ActivePlan = null;
ClearActiveOrder(ship);
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));
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 ReplacePlan(ShipRuntime ship, ShipPlanRuntime nextPlan, string reason, ICollection<SimulationEventRecord> events)
private void FailOrderExecution(ShipRuntime ship, ShipOrderRuntime order, string failureReason, 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;
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));
}
ship.ActivePlan = nextPlan;
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));
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,38 +1020,6 @@ 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,
@@ -1004,7 +1028,9 @@ internal sealed class SimulationProjectionService
subTask.Summary,
subTask.TargetEntityId,
subTask.TargetSystemId,
subTask.TargetNodeId,
subTask.TargetAnchorId,
subTask.TargetResourceNodeId,
subTask.TargetResourceDepositId,
subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value),
subTask.ItemId,
subTask.ModuleId,
@@ -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)
@@ -90,7 +94,8 @@ public sealed class ScenarioContentBuilder(
powerModuleId,
plan.FactionId,
staticData.ModuleDefinitions,
staticData.ItemDefinitions)
staticData.ItemDefinitions,
staticData.Recipes)
.FirstOrDefault(moduleId =>
{
return staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
@@ -117,7 +122,8 @@ public sealed class ScenarioContentBuilder(
objectiveModuleId,
plan.FactionId,
staticData.ModuleDefinitions,
staticData.ItemDefinitions))
staticData.ItemDefinitions,
staticData.Recipes))
{
EnsureStartingModule(startingModules, storageModuleId);
}
@@ -160,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)
{
@@ -179,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
{
@@ -186,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)
{
return new Vector3(offset.X, yPlane + offset.Y, offset.Z);
}
return Add(anchorCelestial.Position, offset);
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)
{
var weight = 0.8f + (NextFloat01(random) * 1.6f);
weights[index] = weight;
weightTotal += weight;
}
// 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

@@ -30,7 +30,8 @@ internal static class StarterStationLayoutResolver
string moduleId,
string? factionId,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions,
IReadOnlyDictionary<string, RecipeDefinition> recipes)
{
if (!moduleDefinitions.TryGetValue(moduleId, out var moduleDefinition))
{
@@ -40,6 +41,10 @@ internal static class StarterStationLayoutResolver
foreach (var wareId in moduleDefinition.BuildRecipes
.SelectMany(production => production.Wares.Select(ware => ware.ItemId))
.Concat(moduleDefinition.ProductItemIds)
.Concat(recipes.Values
.Where(recipe => recipe.RequiredModules.Contains(moduleId, StringComparer.Ordinal))
.SelectMany(recipe => recipe.Inputs.Select(input => input.ItemId)
.Concat(recipe.Outputs.Select(output => output.ItemId))))
.Distinct(StringComparer.Ordinal))
{
if (!itemDefinitions.TryGetValue(wareId, out var itemDefinition))

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,
@@ -201,7 +201,8 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData)
objectiveModuleId,
station.FactionId,
world.ModuleDefinitions,
world.ItemDefinitions))
world.ItemDefinitions,
world.Recipes))
{
if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal))
{

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
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.CurrentCelestialId = nearestCelestial?.Id;
}
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

@@ -10,6 +10,7 @@ namespace SpaceGame.Api.Universe.Simulation;
public sealed class WorldService
{
private const int DeltaHistoryLimit = 256;
private const string StarterPlayerShipId = "ship_arg_s_scout_01_a";
private readonly Lock _sync = new();
private readonly OrbitalSimulationSnapshot _orbitalSimulation;
@@ -128,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)
@@ -148,11 +182,6 @@ public sealed class WorldService
{
lock (_sync)
{
if (_world.Factions.Count == 0)
{
return null;
}
var playerKey = GetCurrentPlayerKey();
var player = _playerFaction.TryGetDomain(_playerStateStore, playerKey)
?? _playerFaction.EnsureDomain(_world, _playerStateStore, playerKey);
@@ -160,6 +189,26 @@ public sealed class WorldService
}
}
public PlayerFactionSnapshot? CompletePlayerOnboarding(CompletePlayerOnboardingRequest request)
{
lock (_sync)
{
if (!_staticData.RaceDefinitions.TryGetValue(request.RaceId.Trim(), out var race))
{
throw new InvalidOperationException($"Race '{request.RaceId}' is not defined in static data.");
}
var playerKey = GetCurrentPlayerKey();
var player = _playerFaction.CompleteOnboarding(_world, _playerStateStore, playerKey, request);
var playerFaction = CreatePlayerOwnedFactionUnsafe(player, race);
var starterSystemId = ResolveStarterSystemIdUnsafe();
SpawnPlayerStarterShipUnsafe(playerFaction, starterSystemId);
_playerFaction.EnsureInitializedDomain(_world, _playerStateStore, playerKey);
PublishSnapshotRefreshUnsafe("player-onboarding", $"Initialized player {player.PersonaName}", "faction", playerFaction.Id);
return GetPlayerFactionSnapshotUnsafe();
}
}
public PlayerFactionSnapshot? CreatePlayerOrganization(PlayerOrganizationCommandRequest request)
{
lock (_sync)
@@ -299,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
{
@@ -306,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,
@@ -336,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,
@@ -359,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);
@@ -474,6 +529,7 @@ public sealed class WorldService
[],
[],
[],
[],
null);
_history.Enqueue(worldDelta);
@@ -510,6 +566,7 @@ public sealed class WorldService
[],
[],
[],
[],
null);
_history.Enqueue(worldDelta);
@@ -530,7 +587,104 @@ public sealed class WorldService
private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() =>
_playerFactionProjection.ToSnapshot(_playerFaction.TryGetDomain(_playerStateStore, GetCurrentPlayerKey()));
private string GetCurrentPlayerKey() => _playerIdentityResolver.GetRequiredPlayerId().ToString("N");
private FactionRuntime CreatePlayerOwnedFactionUnsafe(PlayerFactionRuntime player, RaceDefinition race)
{
var playerFaction = new FactionRuntime
{
Id = player.SovereignFactionId,
Label = player.PersonaName ?? player.Label,
Color = ResolvePlayerFactionColor(race.Id),
Credits = 25000f,
};
_world.Factions.Add(playerFaction);
var policy = _worldSeedingService.CreatePolicies([playerFaction]).Single();
var templateFaction = _staticData.FactionDefinitions.Values
.Where(candidate => string.Equals(candidate.RaceId, race.Id, StringComparison.Ordinal))
.OrderBy(candidate => candidate.Id, StringComparer.Ordinal)
.Select(candidate => _world.Factions.FirstOrDefault(worldFaction => string.Equals(worldFaction.Id, candidate.Id, StringComparison.Ordinal)))
.FirstOrDefault(candidate => candidate is not null);
if (templateFaction?.DefaultPolicySetId is { } racePolicyId
&& _world.Policies.FirstOrDefault(candidate => candidate.Id == racePolicyId) is { } racePolicy)
{
policy.TradeAccessPolicy = racePolicy.TradeAccessPolicy;
policy.DockingAccessPolicy = racePolicy.DockingAccessPolicy;
policy.ConstructionAccessPolicy = racePolicy.ConstructionAccessPolicy;
policy.OperationalRangePolicy = racePolicy.OperationalRangePolicy;
policy.CombatEngagementPolicy = racePolicy.CombatEngagementPolicy;
policy.FleeHullRatio = racePolicy.FleeHullRatio;
policy.AvoidHostileSystems = racePolicy.AvoidHostileSystems;
foreach (var systemId in racePolicy.BlacklistedSystemIds)
{
policy.BlacklistedSystemIds.Add(systemId);
}
}
_world.Policies.Add(policy);
var factionCommander = CreateFactionCommander(playerFaction);
_world.Commanders.Add(factionCommander);
playerFaction.CommanderIds.Add(factionCommander.Id);
return playerFaction;
}
private string ResolveStarterSystemIdUnsafe()
{
return _world.Systems
.Select(system => system.Definition.Id)
.OrderBy(systemId => systemId, StringComparer.Ordinal)
.FirstOrDefault()
?? throw new InvalidOperationException("No systems are available for player onboarding.");
}
private void SpawnPlayerStarterShipUnsafe(FactionRuntime playerFaction, string systemId)
{
var request = new SpawnShipCommandRequest(
playerFaction.Id,
systemId,
StarterPlayerShipId,
Idle);
var system = _world.Systems.First(candidate => string.Equals(candidate.Definition.Id, request.SystemId, StringComparison.Ordinal));
var definition = ResolveShipDefinition(request, playerFaction.Id);
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
{
Id = shipId,
SystemId = system.Definition.Id,
Definition = definition,
FactionId = playerFaction.Id,
Position = localPosition,
TargetPosition = localPosition,
SpatialState = spatialState,
DefaultBehavior = defaultBehavior,
Skills = ShipBootstrapPolicy.CreateSkills(definition),
Health = definition.Hull,
};
_world.Ships.Add(ship);
EnsureShipCommander(playerFaction, ship);
new GeopoliticalSimulationService().Update(_world, 0f, []);
}
private string ResolvePlayerFactionColor(string raceId) =>
raceId switch
{
"argon" => "#3b82f6",
"boron" => "#14b8a6",
"paranid" => "#eab308",
"split" => "#b91c1c",
"teladi" => "#22c55e",
"terran" => "#38bdf8",
"xenon" => "#9ca3af",
_ => "#94a3b8",
};
private string GetCurrentPlayerKey() => _playerIdentityResolver.GetRequiredEffectivePlayerId().ToString("N");
private bool CanCurrentActorAccessGm() => _playerIdentityResolver.CanAccessGm();
@@ -573,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);
@@ -581,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,
@@ -601,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),
@@ -611,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";
@@ -632,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";
@@ -655,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);
@@ -669,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
@@ -696,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,
@@ -716,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}",
@@ -794,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>();
@@ -807,7 +1051,8 @@ public sealed class WorldService
powerModuleId,
factionId,
_staticData.ModuleDefinitions,
_staticData.ItemDefinitions)
_staticData.ItemDefinitions,
_staticData.Recipes)
.FirstOrDefault(moduleId =>
{
return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
@@ -820,6 +1065,24 @@ public sealed class WorldService
EnsureStationModule(modules, defaultContainerStorageModuleId);
}
var defaultSolidStorageModuleId = StarterStationLayoutResolver.ResolveRequiredStorageModuleIds(
powerModuleId,
factionId,
_staticData.ModuleDefinitions,
_staticData.ItemDefinitions,
_staticData.Recipes)
.FirstOrDefault(moduleId =>
{
return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
&& definition is StorageModuleDefinition storageDefinition
&& storageDefinition.StorageKind == StorageKind.Solid;
});
if (defaultSolidStorageModuleId is not null)
{
EnsureStationModule(modules, defaultSolidStorageModuleId);
}
var objectiveModuleId = StarterStationLayoutResolver.ResolveObjectiveModuleId(objective, factionId, _staticData.ModuleDefinitions);
if (!string.IsNullOrWhiteSpace(objectiveModuleId))
{
@@ -828,7 +1091,8 @@ public sealed class WorldService
objectiveModuleId,
factionId,
_staticData.ModuleDefinitions,
_staticData.ItemDefinitions))
_staticData.ItemDefinitions,
_staticData.Recipes))
{
EnsureStationModule(modules, storageModuleId);
}
@@ -948,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
@@ -960,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(),
@@ -1005,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)
{
@@ -1050,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,9 +1,7 @@
<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 CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
import ViewerEntityBrowserPanel from "./components/ViewerEntityBrowserPanel.vue";
import ViewerEntityInspectorPanel from "./components/ViewerEntityInspectorPanel.vue";
@@ -13,9 +11,12 @@ import GmTelemetryWindow from "./components/gm/GmTelemetryWindow.vue";
import GmSettingsWindow from "./components/gm/GmSettingsWindow.vue";
import AuthSessionPanel from "./components/AuthSessionPanel.vue";
import AuthLandingPage from "./components/AuthLandingPage.vue";
import PlayerOnboardingPanel from "./components/PlayerOnboardingPanel.vue";
import { fetchPlayerFaction } from "./api";
import { useShipAutomationCatalogStore } from "./ui/stores/shipAutomationCatalogStore";
import { createViewerHudState } from "./viewerHudState";
import { useAuthStore } from "./ui/stores/authStore";
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { Selectable } from "./viewerTypes";
@@ -27,35 +28,73 @@ const hoverConnectorLineEl = ref<SVGLineElement | null>(null);
const hudState = createViewerHudState();
const authStore = useAuthStore();
const playerFactionStore = usePlayerFactionStore();
const automationCatalogStore = useShipAutomationCatalogStore();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
const { canAccessGm } = storeToRefs(authStore);
const { selectedEntityId } = storeToRefs(selectionStore);
const { canAccessGm, effectivePlayerId, isActingAsAlternateIdentity } = storeToRefs(authStore);
const { playerFaction } = storeToRefs(playerFactionStore);
let viewer: GameViewer | undefined;
const gmOpsOpen = ref(false);
const gmTelemetryOpen = ref(false);
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();
});
watch(() => authStore.isAuthenticated, async (isAuthenticated) => {
if (isAuthenticated) {
await startViewerIfAuthenticated();
watch(
[() => authStore.isAuthenticated, () => effectivePlayerId.value],
async ([isAuthenticated]) => {
if (!isAuthenticated) {
playerContextReady.value = false;
playerFactionStore.setPlayerFaction(null);
viewer?.dispose();
viewer = undefined;
return;
}
await refreshPlayerContext();
await startViewerIfAuthenticated();
},
{ immediate: true },
);
watch(
() => shouldShowOnboarding.value,
async (requiresOnboarding) => {
if (requiresOnboarding) {
viewer?.dispose();
viewer = undefined;
});
return;
}
await startViewerIfAuthenticated();
},
);
function onHistoryWindowResize(id: string, width: number, height: number) {
const windowState = hudState.historyWindows.find((entry) => entry.id === id);
@@ -75,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) {
if (!authStore.isAuthenticated || viewer || !playerContextReady.value || shouldShowOnboarding.value) {
return;
}
@@ -101,78 +163,133 @@ async function startViewerIfAuthenticated() {
});
void viewer.start();
}
async function refreshPlayerContext() {
if (!authStore.isAuthenticated) {
playerContextReady.value = false;
playerFactionStore.setPlayerFaction(null);
return;
}
playerContextReady.value = false;
try {
playerFactionStore.setPlayerFaction(await fetchPlayerFaction());
} catch {
playerFactionStore.setPlayerFaction(null);
} finally {
playerContextReady.value = true;
}
}
</script>
<template>
<AuthLandingPage v-if="!authStore.isAuthenticated" />
<div v-else-if="!playerContextReady" class="auth-landing">
<div class="auth-landing__backdrop" />
<div class="auth-landing__hero">
<h1>Preparing player context</h1>
<p>Loading your in-universe identity and ownership state.</p>
</div>
</div>
<PlayerOnboardingPanel v-else-if="shouldShowOnboarding" />
<div v-else class="viewer-app">
<div
ref="canvasHostEl"
class="viewer-canvas-host"
/>
<div class="pointer-events-none fixed inset-0">
<div class="absolute left-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(360px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:right-5 max-[760px]:bottom-[148px] max-[760px]:w-auto max-[760px]:max-h-[38vh]">
<div class="viewer-left-sidebar-dock">
<section class="viewer-left-sidebar pointer-events-auto">
<div class="viewer-left-sidebar__tabs">
<button
type="button"
class="viewer-left-sidebar__tab"
:class="leftSidebarTab === 'player' ? 'viewer-left-sidebar__tab--active' : ''"
@click="leftSidebarTab = 'player'"
>
Player Informations
</button>
<button
type="button"
class="viewer-left-sidebar__tab"
:class="leftSidebarTab === 'entities' ? 'viewer-left-sidebar__tab--active' : ''"
@click="leftSidebarTab = 'entities'"
>
Entities
</button>
</div>
<div class="viewer-left-sidebar__body">
<div
v-if="leftSidebarTab === 'player'"
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--player"
>
<AuthSessionPanel />
<CollapsibleHudPanel
v-model:collapsed="hudState.gamePanel.collapsed"
class-name="topbar"
panel-name="game"
title="Game"
:summary="hudState.gamePanel.summary"
:body-text="hudState.gamePanel.bodyText"
/>
<CollapsibleHudPanel
v-model:collapsed="hudState.networkPanel.collapsed"
class-name="network-panel"
panel-name="network"
title="Network"
:summary="hudState.networkPanel.summary"
:body-text="hudState.networkPanel.bodyText"
/>
<CollapsibleHudPanel
v-model:collapsed="hudState.performancePanel.collapsed"
class-name="performance-panel"
panel-name="performance"
title="Performance"
:summary="hudState.performancePanel.summary"
:body-text="hudState.performancePanel.bodyText"
/>
</div>
<ViewerEntityBrowserPanel
class="min-h-0 flex-1"
v-else
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--entities"
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
/>
</div>
</section>
</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">
<HtmlInfoPanel
class-name="system-panel-section"
title="System"
:subtitle="hudState.systemPanel.title"
:body-html="hudState.systemPanel.bodyHtml"
:hidden="hudState.systemPanel.hidden"
subtitle-class="system-title"
body-class="system-body"
<div
v-if="hudState.statsOverlay.lines.length > 0"
class="viewer-stats-overlay-dock"
>
<div
class="viewer-stats-overlay"
:class="hudState.statsOverlay.mode === 'compact' ? 'viewer-stats-overlay--compact' : ''"
>
<div
v-for="(line, index) in hudState.statsOverlay.lines"
:key="`${index}-${line}`"
class="viewer-stats-overlay__line"
:class="line === '' ? 'viewer-stats-overlay__line--spacer' : ''"
>
{{ line === "" ? "\u00A0" : line }}
</div>
</div>
</div>
<div
v-if="!hudState.systemPanel.hidden"
class="viewer-system-label-dock"
>
<div class="viewer-system-label">
<div class="viewer-system-label__title">
{{ hudState.systemPanel.title }}
</div>
<div class="viewer-system-label__subtitle">
{{ hudState.systemPanel.bodyHtml }}
</div>
</div>
</div>
<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="min-h-0 flex-1"
class="viewer-right-sidebar__panel"
:fallback-title="hudState.detailPanel.title"
:fallback-html="hudState.detailPanel.bodyHtml"
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
/>
</div>
<div
class="pointer-events-auto rounded-xl bg-[rgba(255,116,88,0.14)] px-3.5 py-3 text-[#ffd8cf]"
class="viewer-right-sidebar__error"
: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>
</section>
</div>
<div ref="historyLayerHostEl">
@@ -222,7 +339,7 @@ async function startViewerIfAuthenticated() {
<GmOpsWindow
v-if="gmOpsOpen"
@close="gmOpsOpen = false"
@focus="(id, kind) => onFocusSelection({ kind, id }, kind === 'ship' ? 'follow' : 'tactical')"
@focus="(id, kind) => onFocusSelection({ kind, id }, 'tactical')"
/>
<GmTelemetryWindow
v-if="gmTelemetryOpen"

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,10 +93,11 @@ 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;
private orbitPitch = 0.62;
private orbitPitch = 1.08;
private cameraMode: CameraMode = "tactical";
private dragMode?: DragMode;
private dragPointerId?: number;
@@ -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,8 @@ 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,
systemCamera: this.systemLayer.camera,
@@ -260,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,
@@ -277,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.18, 1.3);
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(
@@ -349,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

@@ -2,12 +2,15 @@ import type { WorldDelta, WorldSnapshot } from "./contracts";
import type { TelemetrySnapshot } from "./contractsTelemetry";
import type { BalanceSettings } from "./contractsBalance";
import type { PlayerFactionSnapshot } from "./contractsPlayerFaction";
import type { AuthSessionResponse, ForgotPasswordResponse } from "./contractsAuth";
import type { RaceSnapshot } from "./contractsRaces";
import type { AuthSessionResponse, ForgotPasswordResponse, RegisterResponse } from "./contractsAuth";
import type { PlayerIdentitySummary } from "./contractsIdentity";
import type { ShipAutomationCatalogSnapshot } from "./contractsShipAutomation";
import type { FactionSnapshot } from "./contractsFactions";
import type { ShipSnapshot } from "./contractsShips";
import type { StationSnapshot } from "./contractsInfrastructure";
import { clearAuthSession, getAuthSession, setAuthSession } from "./authSession";
import { getEffectivePlayerIdentityId } from "./effectiveIdentitySession";
import type {
PlayerAssetAssignmentCommandRequest,
PlayerAutomationPolicyCommandRequest,
@@ -20,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> {
@@ -35,6 +39,12 @@ async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit, option
if (session?.accessToken) {
headers.set("Authorization", `Bearer ${session.accessToken}`);
}
if (session?.roles.some((role) => role === "gm" || role === "admin")) {
const effectivePlayerId = getEffectivePlayerIdentityId();
if (effectivePlayerId) {
headers.set("X-Act-As-Player-Id", effectivePlayerId);
}
}
}
const response = await fetch(input, {
@@ -96,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()}`);
@@ -160,13 +170,11 @@ export async function resetWorld() {
}
export async function register(request: { email: string; password: string }) {
const session = await fetchJson<AuthSessionResponse>("/api/auth/register", {
return fetchJson<RegisterResponse>("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
}, { skipAuth: true, skipRefresh: true });
setAuthSession(session);
return session;
}
export async function login(request: { email: string; password: string }) {
@@ -199,6 +207,22 @@ export async function fetchPlayerFaction(signal?: AbortSignal) {
return fetchJson<PlayerFactionSnapshot>("/api/player-faction", { signal });
}
export async function fetchRaces(signal?: AbortSignal) {
return fetchJson<RaceSnapshot[]>("/api/auth/races", { signal }, { skipAuth: true });
}
export async function completePlayerOnboarding(request: { name: string; raceId: string }) {
return fetchJson<PlayerFactionSnapshot>("/api/player-faction/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
}
export async function fetchPlayerIdentities(signal?: AbortSignal) {
return fetchJson<PlayerIdentitySummary[]>("/api/player-faction/identities", { signal });
}
export async function fetchShipAutomationCatalog(signal?: AbortSignal) {
return fetchJson<ShipAutomationCatalogSnapshot>("/api/ships/catalog", { signal }, { skipAuth: true });
}
@@ -295,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

@@ -56,9 +56,12 @@ async function submitLogin() {
async function submitRegister() {
await execute(async () => {
const session = await register(registerForm);
authStore.setSession(session);
await register(registerForm);
playerFactionStore.setPlayerFaction(null);
infoMessage.value = "Account created. Sign in to enter the universe.";
pane.value = "login";
loginForm.email = registerForm.email;
registerForm.password = "";
});
}

View File

@@ -1,34 +1,54 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { computed, reactive, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import { login, register } from "../api";
import { fetchPlayerFaction, fetchPlayerIdentities, login, register } from "../api";
import { useAuthStore } from "../ui/stores/authStore";
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
const authStore = useAuthStore();
const playerFactionStore = usePlayerFactionStore();
const { session, busy } = storeToRefs(authStore);
const { session, busy, availablePlayerIdentities, effectivePlayerId } = storeToRefs(authStore);
const mode = ref<"login" | "register">("login");
const email = ref("");
const password = ref("");
const errorMessage = ref("");
const identityBusy = ref(false);
const identityError = ref("");
const forgotPasswordOpen = ref(false);
const forgotPasswordState = reactive({
email: "",
});
const selectedIdentityId = computed({
get: () => effectivePlayerId.value ?? "",
set: (value: string) => {
void switchIdentity(value || null);
},
});
const canAccessGm = computed(() => authStore.canAccessGm);
const activeIdentitySummary = computed(() =>
availablePlayerIdentities.value.find((entry) => entry.userId === (effectivePlayerId.value ?? session.value?.userId ?? "")) ?? null,
);
async function submit() {
errorMessage.value = "";
authStore.setBusy(true);
try {
const snapshot = mode.value === "login"
? await login({ email: email.value, password: password.value })
: await register({ email: email.value, password: password.value });
if (mode.value === "login") {
const snapshot = await login({ email: email.value, password: password.value });
authStore.setSession(snapshot);
playerFactionStore.setPlayerFaction(null);
password.value = "";
forgotPasswordOpen.value = false;
} else {
await register({ email: email.value, password: password.value });
playerFactionStore.setPlayerFaction(null);
errorMessage.value = "";
mode.value = "login";
password.value = "";
}
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "Authentication failed.";
} finally {
@@ -42,6 +62,59 @@ function logout() {
errorMessage.value = "";
password.value = "";
}
async function refreshPlayerContext() {
if (!authStore.isAuthenticated) {
playerFactionStore.setPlayerFaction(null);
return;
}
try {
playerFactionStore.setPlayerFaction(await fetchPlayerFaction());
} catch {
playerFactionStore.setPlayerFaction(null);
}
}
async function loadPlayerIdentities() {
if (!authStore.isAuthenticated || !authStore.canAccessGm) {
authStore.setAvailablePlayerIdentities([]);
return;
}
identityBusy.value = true;
identityError.value = "";
try {
authStore.setAvailablePlayerIdentities(await fetchPlayerIdentities());
} catch (error) {
identityError.value = error instanceof Error ? error.message : "Unable to load player identities.";
authStore.setAvailablePlayerIdentities([]);
} finally {
identityBusy.value = false;
}
}
async function switchIdentity(nextPlayerId: string | null) {
authStore.setEffectivePlayerId(nextPlayerId);
identityBusy.value = true;
identityError.value = "";
try {
await refreshPlayerContext();
} catch (error) {
identityError.value = error instanceof Error ? error.message : "Unable to switch current identity.";
} finally {
identityBusy.value = false;
}
}
watch(
() => session.value?.userId ?? null,
async () => {
await loadPlayerIdentities();
await refreshPlayerContext();
},
{ immediate: true },
);
</script>
<template>
@@ -52,6 +125,29 @@ function logout() {
<div class="text-xs uppercase tracking-[0.22em] text-white/45">Identity</div>
<div class="mt-1 text-sm font-semibold">{{ session.email }}</div>
<div class="mt-1 text-xs text-white/55">Player {{ session.userId.slice(0, 8) }}</div>
<div v-if="canAccessGm" class="mt-3">
<div class="text-[10px] uppercase tracking-[0.18em] text-white/45">Current Identity</div>
<select
v-model="selectedIdentityId"
class="mt-1 w-full rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm outline-none transition focus:border-white/30"
:disabled="identityBusy"
>
<option value="">GM Self</option>
<option
v-for="identity in availablePlayerIdentities"
:key="identity.userId"
:value="identity.userId"
>
{{ identity.email }}{{ identity.playerFactionLabel ? ` · ${identity.playerFactionLabel}` : "" }}
</option>
</select>
<div class="mt-1 text-[11px] text-white/50">
Acting as
{{ activeIdentitySummary?.email ?? session.email }}
<span v-if="activeIdentitySummary?.playerFactionLabel"> · {{ activeIdentitySummary.playerFactionLabel }}</span>
</div>
<div v-if="identityError" class="mt-2 text-[11px] text-[#ffd8cf]">{{ identityError }}</div>
</div>
<div v-if="session.roles.length > 0" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="role in session.roles"

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { completePlayerOnboarding, fetchRaces } from "../api";
import type { RaceSnapshot } from "../contractsRaces";
import { useAuthStore } from "../ui/stores/authStore";
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
const authStore = useAuthStore();
const playerFactionStore = usePlayerFactionStore();
const busy = ref(false);
const loadingRaces = ref(false);
const errorMessage = ref("");
const raceOptions = ref<RaceSnapshot[]>([]);
const form = reactive({
name: "",
raceId: "",
});
const canSubmit = computed(() =>
form.name.trim().length >= 2 && form.raceId.trim().length > 0 && !busy.value && !loadingRaces.value,
);
onMounted(async () => {
loadingRaces.value = true;
errorMessage.value = "";
try {
raceOptions.value = (await fetchRaces()).sort((left, right) => left.name.localeCompare(right.name));
if (!form.raceId && raceOptions.value.length > 0) {
form.raceId = raceOptions.value[0].id;
}
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "Unable to load race options.";
} finally {
loadingRaces.value = false;
}
});
async function submit() {
if (!canSubmit.value) {
return;
}
busy.value = true;
errorMessage.value = "";
try {
const snapshot = await completePlayerOnboarding({
name: form.name.trim(),
raceId: form.raceId,
});
playerFactionStore.setPlayerFaction(snapshot);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "Unable to create your pilot.";
} finally {
busy.value = false;
}
}
function signOut() {
authStore.clearSession();
playerFactionStore.setPlayerFaction(null);
}
</script>
<template>
<div class="auth-landing">
<div class="auth-landing__backdrop" />
<div class="auth-landing__hero">
<h1>Create your pilot</h1>
<p>
This account has access to the universe, but it does not have an in-game identity yet. Choose a name and an origin faction, then you will start with a single basic ship.
</p>
<div class="auth-card">
<h2>First Login Setup</h2>
<form class="auth-card__form" @submit.prevent="submit">
<input
v-model.trim="form.name"
type="text"
autocomplete="nickname"
maxlength="48"
placeholder="Pilot name"
>
<select v-model="form.raceId" :disabled="loadingRaces || busy">
<option value="" disabled>Select a race</option>
<option
v-for="race in raceOptions"
:key="race.id"
:value="race.id"
>
{{ race.name }}
</option>
</select>
<button type="submit" :disabled="!canSubmit">
{{ busy ? "Entering universe..." : "Create pilot" }}
</button>
</form>
<div v-if="loadingRaces" class="auth-card__message auth-card__message--info">
Loading faction options...
</div>
<div v-if="errorMessage" class="auth-card__message auth-card__message--error">
{{ errorMessage }}
</div>
<div class="auth-card__footer">
<button type="button" class="auth-card__link" @click="signOut">
Sign out
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { storeToRefs } from "pinia";
import type { StationSnapshot } from "../contractsInfrastructure";
import type { PlayerFleetSnapshot } from "../contractsPlayerFaction";
import type { ShipSnapshot } from "../contractsShips";
import { getShipBehaviorLabel } from "../shipAutomationPresentation";
import { useGmStore } from "../ui/stores/gmStore";
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
@@ -9,22 +12,27 @@ import { useViewerSelectionStore, type ViewerSelectionSummary } from "../ui/stor
import type { Selectable } from "../viewerTypes";
type BrowserTab = "visible" | "owned";
type BrowserSortKey = "entity" | "location" | "ai" | "hp";
type BrowserRowKind = "system" | "station" | "fleet" | "ship";
interface BrowserItem {
interface BrowserRow {
key: string;
label: string;
subtitle: string;
meta?: string;
kind: BrowserRowKind;
kindLabel: string;
name: string;
ident: string;
location: string;
aiStates: string[];
hpLabel: string;
hpValue: number;
selection?: ViewerSelectionSummary;
focusSelection?: Selectable;
focusMode?: "follow" | "tactical";
children: BrowserRow[];
}
interface BrowserSection {
key: string;
label: string;
count: number;
items: BrowserItem[];
interface BrowserDisplayRow extends BrowserRow {
depth: number;
}
const emit = defineEmits<{
@@ -40,22 +48,28 @@ const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
const { playerFaction } = storeToRefs(playerStore);
const { activeSystemId, povLevel } = storeToRefs(sceneStore);
const activeTab = ref<BrowserTab>("visible");
const activeTab = ref<BrowserTab>("owned");
const sortKey = ref<BrowserSortKey>("entity");
const sortDirection = ref<"asc" | "desc">("asc");
const searchText = ref("");
const expandedRowKeys = ref<Record<string, boolean>>({});
const systemById = computed(() => new Map(gmStore.systems.map((system) => [system.id, system])));
const stationById = computed(() => new Map(gmStore.stations.map((station) => [station.id, station])));
const playerFleetByShipId = computed(() => {
const mapping = new Map<string, PlayerFleetSnapshot>();
for (const fleet of playerFaction.value?.fleets ?? []) {
for (const assetId of fleet.assetIds) {
mapping.set(assetId, fleet);
}
}
return mapping;
});
function normalize(text: string) {
return text.trim().toLowerCase();
}
function matchesSearch(item: BrowserItem, search: string) {
if (!search) {
return true;
}
const haystack = `${item.label} ${item.subtitle} ${item.meta ?? ""}`.toLowerCase();
return haystack.includes(search);
}
function titleCase(value: string | null | undefined) {
if (!value) {
return "Unknown";
@@ -69,168 +83,407 @@ function titleCase(value: string | null | undefined) {
.replace(/\b\w/g, (part) => part.toUpperCase());
}
function buildVisibleSections(): BrowserSection[] {
const sections: BrowserSection[] = [];
if (povLevel.value === "galaxy" || !activeSystemId.value) {
const systems = [...gmStore.systems]
.sort((left, right) => left.label.localeCompare(right.label))
.map<BrowserItem>((system) => ({
key: `system-${system.id}`,
label: system.label,
subtitle: `${system.planets.length} planets · ${system.stars.length} stars`,
meta: system.id,
selection: { id: system.id, kind: "system", label: system.label },
focusSelection: { kind: "system", id: system.id },
focusMode: "tactical",
}));
sections.push({
key: "systems",
label: "Systems",
count: systems.length,
items: systems,
});
return sections;
function compactLabel(value: string | null | undefined, fallback: string) {
if (!value) {
return fallback;
}
const systemId = activeSystemId.value;
const ships = gmStore.ships
.filter((ship) => ship.systemId === systemId)
.sort((left, right) => left.name.localeCompare(right.name))
.map<BrowserItem>((ship) => ({
const words = titleCase(value).split(" ");
if (words.length === 1) {
return words[0].slice(0, 4).toUpperCase();
}
return words
.slice(0, 2)
.map((word) => word.slice(0, 3).toUpperCase())
.join("-");
}
function shortId(value: string) {
if (value.length <= 8) {
return value.toUpperCase();
}
return `${value.slice(0, 4).toUpperCase()}-${value.slice(-4).toUpperCase()}`;
}
function uniqueTokens(tokens: string[]) {
return tokens.filter((token, index) => token.length > 0 && tokens.indexOf(token) === index);
}
function formatShipLocation(ship: ShipSnapshot) {
const dockedStation = ship.dockedStationId ? stationById.value.get(ship.dockedStationId) : undefined;
if (dockedStation) {
return `Docked ${dockedStation.label}`;
}
const transitAnchorId = ship.spatialState.transit?.destinationAnchorId ?? ship.spatialState.destinationAnchorId;
if (transitAnchorId) {
return `Transit ${titleCase(transitAnchorId)}`;
}
if (ship.spatialState.currentAnchorId) {
return `Anchor ${compactAnchorId(ship.spatialState.currentAnchorId)}`;
}
const system = systemById.value.get(ship.systemId);
return system?.label ?? ship.systemId;
}
function formatStationLocation(station: StationSnapshot) {
const system = systemById.value.get(station.systemId);
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 taskToken = ship.activeSubTasks.length > 0 ? "TSK" : "";
const orderToken = ship.orderQueue.length > 0 ? "ORD" : "";
const commandToken = ship.commanderId ? "CMD" : "";
return uniqueTokens([behaviorToken, orderToken, taskToken, travelToken, dockToken, commandToken]).slice(0, 5);
}
function stationAiStates(station: StationSnapshot) {
return uniqueTokens([
station.currentProcesses.length > 0 ? "PROC" : "",
station.dockedShips > 0 ? "DCK" : "",
station.commanderId ? "CMD" : "",
]);
}
function fleetAiStates(fleet: PlayerFleetSnapshot) {
return uniqueTokens([
compactLabel(fleet.status, "STAT"),
compactLabel(fleet.role, "ROLE"),
fleet.commanderId ? "CMD" : "",
]);
}
function systemAiStates(systemId: string) {
const stations = gmStore.stations.filter((station) => station.systemId === systemId).length;
const ships = gmStore.ships.filter((ship) => ship.systemId === systemId).length;
return uniqueTokens([stations > 0 ? `ST${stations}` : "", ships > 0 ? `SH${ships}` : ""]);
}
function buildShipRow(ship: ShipSnapshot): BrowserRow {
return {
key: `ship-${ship.id}`,
label: ship.name,
subtitle: `${titleCase(ship.type)} · ${titleCase(ship.state)}`,
meta: `${getShipBehaviorLabel(ship.defaultBehavior.kind)}${ship.defaultBehavior.itemId ? ` · ${ship.defaultBehavior.itemId}` : ""}`,
kind: "ship",
kindLabel: "SH",
name: ship.name,
ident: `${titleCase(ship.type)} · ${shortId(ship.id)}`,
location: formatShipLocation(ship),
aiStates: shipAiStates(ship),
hpLabel: Math.round(ship.health).toString(),
hpValue: ship.health,
selection: { id: ship.id, kind: "ship", label: ship.name },
focusSelection: { kind: "ship", id: ship.id },
focusMode: "follow",
}));
const stations = gmStore.stations
.filter((station) => station.systemId === systemId)
.sort((left, right) => left.label.localeCompare(right.label))
.map<BrowserItem>((station) => ({
focusMode: "tactical",
children: [],
};
}
function buildStationRow(station: StationSnapshot, children: BrowserRow[]): BrowserRow {
return {
key: `station-${station.id}`,
label: station.label,
subtitle: `${titleCase(station.category)} · Docked ${station.dockedShips}/${station.dockingPads}`,
meta: station.factionId,
kind: "station",
kindLabel: "ST",
name: station.label,
ident: `${titleCase(station.category)} · ${titleCase(station.objective)}`,
location: formatStationLocation(station),
aiStates: stationAiStates(station),
hpLabel: "--",
hpValue: -1,
selection: { id: station.id, kind: "station", label: station.label },
focusSelection: { kind: "station", id: station.id },
focusMode: "tactical",
}));
sections.push({
key: "ships",
label: "Ships",
count: ships.length,
items: ships,
});
sections.push({
key: "stations",
label: "Stations",
count: stations.length,
items: stations,
});
return sections;
children,
};
}
function buildOwnedSections(): BrowserSection[] {
function buildFleetRow(fleet: PlayerFleetSnapshot, children: BrowserRow[]): BrowserRow {
const homeStation = fleet.homeStationId ? stationById.value.get(fleet.homeStationId) : undefined;
const homeSystem = fleet.homeSystemId ? systemById.value.get(fleet.homeSystemId) : undefined;
return {
key: `fleet-${fleet.id}`,
kind: "fleet",
kindLabel: "FL",
name: fleet.label,
ident: `${titleCase(fleet.role)} · ${shortId(fleet.id)}`,
location: homeStation ? `Home ${homeStation.label}` : (homeSystem?.label ?? "No home"),
aiStates: fleetAiStates(fleet),
hpLabel: `${children.length}`,
hpValue: children.length,
children,
};
}
function buildSystemRow(systemId: string): BrowserRow | null {
const system = systemById.value.get(systemId);
if (!system) {
return null;
}
return {
key: `system-${system.id}`,
kind: "system",
kindLabel: "SY",
name: system.label,
ident: shortId(system.id),
location: "Galaxy",
aiStates: systemAiStates(system.id),
hpLabel: "--",
hpValue: -1,
selection: { id: system.id, kind: "system", label: system.label },
focusSelection: { kind: "system", id: system.id },
focusMode: "tactical",
children: [],
};
}
function buildVisibleRows() {
if (povLevel.value === "galaxy" || !activeSystemId.value) {
return gmStore.systems
.map((system) => buildSystemRow(system.id))
.filter((row): row is BrowserRow => row != null);
}
const systemId = activeSystemId.value;
const stations = gmStore.stations.filter((station) => station.systemId === systemId);
const ships = gmStore.ships.filter((ship) => ship.systemId === systemId);
const stationIds = new Set(stations.map((station) => station.id));
const stationChildren = new Map<string, BrowserRow[]>();
const fleetChildren = new Map<string, BrowserRow[]>();
const independentShips: BrowserRow[] = [];
for (const ship of ships) {
const row = buildShipRow(ship);
if (ship.dockedStationId && stationIds.has(ship.dockedStationId)) {
const children = stationChildren.get(ship.dockedStationId) ?? [];
children.push(row);
stationChildren.set(ship.dockedStationId, children);
continue;
}
const fleet = playerFleetByShipId.value.get(ship.id);
if (fleet) {
const children = fleetChildren.get(fleet.id) ?? [];
children.push(row);
fleetChildren.set(fleet.id, children);
continue;
}
independentShips.push(row);
}
const stationRows = stations.map((station) => buildStationRow(station, stationChildren.get(station.id) ?? []));
const fleetRows = (playerFaction.value?.fleets ?? [])
.filter((fleet) => (fleetChildren.get(fleet.id)?.length ?? 0) > 0)
.map((fleet) => buildFleetRow(fleet, fleetChildren.get(fleet.id) ?? []));
return [...stationRows, ...fleetRows, ...independentShips];
}
function buildOwnedRows() {
const player = playerFaction.value;
if (!player) {
return [];
}
const ships = player.assetRegistry.shipIds
const ownedShips = player.assetRegistry.shipIds
.map((shipId) => gmStore.ships.find((ship) => ship.id === shipId))
.filter((ship): ship is NonNullable<typeof ship> => ship != null)
.sort((left, right) => left.name.localeCompare(right.name))
.map<BrowserItem>((ship) => ({
key: `owned-ship-${ship.id}`,
label: ship.name,
subtitle: `${ship.systemId} · ${titleCase(ship.state)}`,
meta: getShipBehaviorLabel(ship.defaultBehavior.kind),
selection: { id: ship.id, kind: "ship", label: ship.name },
focusSelection: { kind: "ship", id: ship.id },
focusMode: "follow",
}));
const stations = player.assetRegistry.stationIds
.filter((ship): ship is ShipSnapshot => ship != null);
const ownedStations = player.assetRegistry.stationIds
.map((stationId) => gmStore.stations.find((station) => station.id === stationId))
.filter((station): station is NonNullable<typeof station> => station != null)
.sort((left, right) => left.label.localeCompare(right.label))
.map<BrowserItem>((station) => ({
key: `owned-station-${station.id}`,
label: station.label,
subtitle: `${station.systemId} · ${titleCase(station.category)}`,
meta: `${station.installedModules.length} modules`,
selection: { id: station.id, kind: "station", label: station.label },
focusSelection: { kind: "station", id: station.id },
focusMode: "tactical",
}));
const fleets = player.fleets
.slice()
.sort((left, right) => left.label.localeCompare(right.label))
.map<BrowserItem>((fleet) => ({
key: `fleet-${fleet.id}`,
label: fleet.label,
subtitle: `${titleCase(fleet.role)} · ${titleCase(fleet.status)}`,
meta: `${fleet.assetIds.length} assets`,
}));
.filter((station): station is StationSnapshot => station != null);
const ownedFleetShipIds = new Set(player.fleets.flatMap((fleet) => fleet.assetIds));
const ownedStationIds = new Set(ownedStations.map((station) => station.id));
return [
{
key: "owned-fleets",
label: "Fleets",
count: fleets.length,
items: fleets,
},
{
key: "owned-stations",
label: "Stations",
count: stations.length,
items: stations,
},
{
key: "owned-ships",
label: "Ships",
count: ships.length,
items: ships,
},
];
const stationChildren = new Map<string, BrowserRow[]>();
for (const ship of ownedShips) {
if (!ship.dockedStationId || !ownedStationIds.has(ship.dockedStationId) || ownedFleetShipIds.has(ship.id)) {
continue;
}
const children = stationChildren.get(ship.dockedStationId) ?? [];
children.push(buildShipRow(ship));
stationChildren.set(ship.dockedStationId, children);
}
const filteredSections = computed(() => {
const search = normalize(searchText.value);
const sections = activeTab.value === "visible" ? buildVisibleSections() : buildOwnedSections();
return sections
.map((section) => ({
...section,
items: section.items.filter((item) => matchesSearch(item, search)),
}))
.filter((section) => section.items.length > 0);
const stationRows = ownedStations.map((station) => buildStationRow(station, stationChildren.get(station.id) ?? []));
const fleetRows = player.fleets.map((fleet) => buildFleetRow(
fleet,
fleet.assetIds
.map((shipId) => ownedShips.find((ship) => ship.id === shipId))
.filter((ship): ship is ShipSnapshot => ship != null)
.map((ship) => buildShipRow(ship)),
));
const independentShips = ownedShips
.filter((ship) => !ownedFleetShipIds.has(ship.id) && (!ship.dockedStationId || !ownedStationIds.has(ship.dockedStationId)))
.map((ship) => buildShipRow(ship));
return [...stationRows, ...fleetRows, ...independentShips];
}
function getRowSortValue(row: BrowserRow, key: BrowserSortKey) {
if (key === "hp") {
return row.hpValue;
}
if (key === "location") {
return row.location;
}
if (key === "ai") {
return row.aiStates.join(" ");
}
return `${row.name} ${row.ident}`;
}
function sortRows(rows: BrowserRow[]): BrowserRow[] {
const direction = sortDirection.value === "asc" ? 1 : -1;
return [...rows]
.sort((left, right) => {
const leftValue = getRowSortValue(left, sortKey.value);
const rightValue = getRowSortValue(right, sortKey.value);
if (typeof leftValue === "number" && typeof rightValue === "number") {
return (leftValue - rightValue) * direction;
}
return String(leftValue).localeCompare(String(rightValue)) * direction;
})
.map((row) => ({
...row,
children: sortRows(row.children),
}));
}
function rowMatches(row: BrowserRow, search: string) {
if (!search) {
return true;
}
const haystack = `${row.name} ${row.ident} ${row.location} ${row.aiStates.join(" ")} ${row.hpLabel}`.toLowerCase();
return haystack.includes(search);
}
function isExpanded(row: BrowserRow) {
if (row.children.length === 0) {
return false;
}
return expandedRowKeys.value[row.key] ?? true;
}
function flattenRows(rows: BrowserRow[], search: string, depth = 0, forceExpand = false): BrowserDisplayRow[] {
const flattened: BrowserDisplayRow[] = [];
for (const row of rows) {
const descendantMatches = flattenRows(row.children, search, depth + 1, forceExpand);
const matches = rowMatches(row, search);
if (!matches && descendantMatches.length === 0) {
continue;
}
flattened.push({
...row,
depth,
});
function selectItem(item: BrowserItem) {
if (!item.selection) {
if (row.children.length > 0 && (forceExpand || isExpanded(row))) {
flattened.push(...descendantMatches);
}
}
return flattened;
}
const rawRows = computed(() => {
if (activeTab.value === "owned") {
return buildOwnedRows();
}
return buildVisibleRows();
});
const displayRows = computed(() => {
const search = normalize(searchText.value);
const sortedRows = sortRows(rawRows.value);
return flattenRows(sortedRows, search, 0, search.length > 0);
});
function toggleSort(nextKey: BrowserSortKey) {
if (sortKey.value === nextKey) {
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
return;
}
selectionStore.selectSelection(item.selection, "ui");
sortKey.value = nextKey;
sortDirection.value = "asc";
}
function focusItem(item: BrowserItem) {
if (item.selection) {
selectionStore.selectSelection(item.selection, "ui");
function toggleRow(row: BrowserDisplayRow) {
if (row.children.length === 0) {
return;
}
if (item.focusSelection) {
emit("focus", item.focusSelection, item.focusMode);
expandedRowKeys.value[row.key] = !isExpanded(row);
}
function selectItem(row: BrowserDisplayRow) {
if (!row.selection) {
return;
}
selectionStore.selectSelection(row.selection, "ui");
}
function focusItem(row: BrowserDisplayRow) {
if (row.selection) {
selectionStore.selectSelection(row.selection, "ui");
}
if (row.focusSelection) {
emit("focus", row.focusSelection, row.focusMode);
}
}
function isSelected(item: BrowserItem) {
return !!item.selection
&& item.selection.id === selectedEntityId.value
&& item.selection.kind === selectedEntityKind.value;
function isSelected(row: BrowserDisplayRow) {
return !!row.selection
&& row.selection.id === selectedEntityId.value
&& row.selection.kind === selectedEntityKind.value;
}
function sortMarker(key: BrowserSortKey) {
if (sortKey.value !== key) {
return "";
}
return sortDirection.value === "asc" ? " ▲" : " ▼";
}
</script>
@@ -276,47 +529,91 @@ function isSelected(item: BrowserItem) {
<div v-if="activeTab === 'owned' && !playerFaction" class="entity-browser-panel__empty">
No player-owned assets yet.
</div>
<div v-else-if="filteredSections.length === 0" class="entity-browser-panel__empty">
<div v-else-if="displayRows.length === 0" class="entity-browser-panel__empty">
Nothing matches the current view.
</div>
<div v-else class="entity-browser-panel__sections">
<section
v-for="section in filteredSections"
:key="section.key"
class="entity-browser-section"
>
<header class="entity-browser-section__header">
<span>{{ section.label }}</span>
<span>{{ section.items.length }}</span>
</header>
<div class="entity-browser-section__items">
<div
v-for="item in section.items"
:key="item.key"
class="entity-browser-item"
:class="isSelected(item) ? 'entity-browser-item--selected' : ''"
>
<button
type="button"
class="entity-browser-item__body"
:disabled="!item.selection"
@click="selectItem(item)"
>
<div class="entity-browser-item__label">{{ item.label }}</div>
<div class="entity-browser-item__subtitle">{{ item.subtitle }}</div>
<div v-if="item.meta" class="entity-browser-item__meta">{{ item.meta }}</div>
<div class="entity-browser-table-wrap">
<table class="entity-browser-table entity-browser-table--tree">
<colgroup>
<col class="entity-browser-table__col entity-browser-table__col--entity">
<col class="entity-browser-table__col entity-browser-table__col--ident">
<col class="entity-browser-table__col entity-browser-table__col--location">
<col class="entity-browser-table__col entity-browser-table__col--ai">
<col class="entity-browser-table__col entity-browser-table__col--hp">
</colgroup>
<thead>
<tr>
<th scope="col">
<button type="button" class="entity-browser-table__sort" @click="toggleSort('entity')">
Entity{{ sortMarker("entity") }}
</button>
<button
v-if="item.focusSelection"
type="button"
class="entity-browser-item__focus"
@click.stop="focusItem(item)"
>
Focus
</th>
<th scope="col">Ident</th>
<th scope="col">
<button type="button" class="entity-browser-table__sort" @click="toggleSort('location')">
Location{{ sortMarker("location") }}
</button>
</th>
<th scope="col">
<button type="button" class="entity-browser-table__sort" @click="toggleSort('ai')">
AI{{ sortMarker("ai") }}
</button>
</th>
<th scope="col" class="entity-browser-table__numeric">
<button type="button" class="entity-browser-table__sort" @click="toggleSort('hp')">
HP{{ sortMarker("hp") }}
</button>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in displayRows"
:key="row.key"
class="entity-browser-table__row"
:class="isSelected(row) ? 'entity-browser-table__row--selected' : ''"
@click="selectItem(row)"
@dblclick="focusItem(row)"
>
<td class="entity-browser-table__name">
<div class="entity-browser-row" :style="{ paddingLeft: `${row.depth * 0.9}rem` }">
<button
v-if="row.children.length > 0"
type="button"
class="entity-browser-row__toggle"
@click.stop="toggleRow(row)"
>
{{ isExpanded(row) ? "-" : "+" }}
</button>
<span v-else class="entity-browser-row__toggle entity-browser-row__toggle--spacer" />
<span class="entity-browser-row__kind" :class="`entity-browser-row__kind--${row.kind}`">
{{ row.kindLabel }}
</span>
<span class="entity-browser-row__label">{{ row.name }}</span>
</div>
</td>
<td class="entity-browser-table__detail entity-browser-table__cell--truncate">{{ row.ident }}</td>
<td class="entity-browser-table__cell--truncate">{{ row.location }}</td>
<td class="entity-browser-table__cell--ai">
<div class="entity-browser-ai">
<span
v-for="token in row.aiStates"
:key="`${row.key}-${token}`"
class="entity-browser-ai__token"
>
{{ token }}
</span>
<span v-if="row.aiStates.length === 0" class="entity-browser-table__muted">--</span>
</div>
</td>
<td class="entity-browser-table__numeric">
{{ row.hpLabel }}
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</section>
</template>

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]),
@@ -76,6 +84,88 @@ function formatAmount(value: number) {
return Math.abs(value - rounded) < 0.005 ? String(rounded) : value.toFixed(1);
}
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;
anchorId?: string | null;
constructionSiteId?: string | null;
sourceStationId?: string | null;
destinationStationId?: string | null;
moduleId?: string | null;
}) {
return order.itemId
?? order.targetEntityId
?? order.targetSystemId
?? order.anchorId
?? order.constructionSiteId
?? order.destinationStationId
?? order.sourceStationId
?? order.moduleId
?? "—";
}
function describeSubTaskTarget(subTask: {
itemId?: string | null;
targetEntityId?: string | null;
targetSystemId?: string | null;
targetAnchorId?: string | null;
targetResourceNodeId?: string | null;
moduleId?: string | null;
}) {
return subTask.itemId
?? subTask.targetEntityId
?? subTask.targetSystemId
?? subTask.targetAnchorId
?? subTask.targetResourceNodeId
?? subTask.moduleId
?? "—";
}
const selectedShip = computed(() => {
if (selectedEntityKind.value !== "ship" || !selectedEntityId.value) {
return null;
@@ -100,18 +190,14 @@ const playerShipIds = computed(() =>
new Set(playerFaction.value?.assetRegistry.shipIds ?? []),
);
const canAccessGm = computed(() => authStore.canAccessGm);
const canAccessGmDirectly = computed(() => authStore.canAccessGm && !authStore.isActingAsAlternateIdentity);
const canDirectControlSelectedShip = computed(() =>
!!selectedShip.value && (canAccessGm.value || playerShipIds.value.has(selectedShip.value.id)),
!!selectedShip.value && (canAccessGmDirectly.value || playerShipIds.value.has(selectedShip.value.id)),
);
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(() =>
@@ -135,20 +221,293 @@ const formBehaviorNotes = computed(() =>
getShipBehaviorNotes(behaviorForm.kind),
);
watch(selectedShip, (ship) => {
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: "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" },
{ label: "Commander", value: selectedShip.value.commanderId ?? "none" },
{ label: "Docked", value: selectedShip.value.dockedStationId ?? "no" },
];
});
const shipCargoBarRows = computed(() => {
if (!selectedShip.value) {
return [];
}
const usedCargo = selectedShip.value.inventory.reduce((sum, entry) => sum + entry.amount, 0);
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(() =>
selectedShip.value?.inventory.map((entry) => ({
key: entry.itemId,
ware: entry.itemId,
amount: formatAmount(entry.amount),
})) ?? [],
);
const shipBehaviorRows = computed(() => {
if (!selectedShip.value) {
return [];
}
return [
{ label: "Area", value: selectedShip.value.defaultBehavior.areaSystemId ?? "none" },
{ label: "Item", value: selectedShip.value.defaultBehavior.itemId ?? "none" },
{ label: "Home Station", value: selectedShip.value.defaultBehavior.homeStationId ?? "none" },
{ label: "Target", value: selectedShip.value.defaultBehavior.targetEntityId ?? "none" },
{ label: "Range", value: String(selectedShip.value.defaultBehavior.maxSystemRange) },
{ label: "Known Only", value: selectedShip.value.defaultBehavior.knownStationsOnly ? "yes" : "no" },
];
});
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),
describeOrderFailure(order) ?? undefined,
]),
})),
);
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([
describeSubTaskTarget(subTask),
subTask.blockingReason ?? undefined,
`${Math.round(subTask.progress * 100)}%`,
]),
isSubTask: false,
})),
);
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}` },
{
label: "Population",
value: `${formatAmount(selectedStation.value.population)} / ${formatAmount(selectedStation.value.populationCapacity)}`,
},
{ label: "Workforce", value: formatAmount(selectedStation.value.workforceRequired) },
{ label: "Efficiency", value: formatPercent(selectedStation.value.workforceEffectiveRatio) },
{ label: "Commander", value: selectedStation.value.commanderId ?? "none" },
{ label: "Policy", value: selectedStation.value.policySetId ?? "none" },
];
});
const stationModuleRows = computed(() =>
selectedStation.value?.installedModules.map((moduleId) => ({
key: moduleId,
module: moduleNameById.get(moduleId) ?? moduleId,
moduleId,
})) ?? [],
);
const stationStorageRows = computed(() =>
selectedStation.value?.storageUsage.map((entry) => ({
key: entry.storageClass,
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,
})) ?? [],
);
const stationInventoryRows = computed(() =>
selectedStation.value?.inventory.map((entry) => ({
key: entry.itemId,
ware: entry.itemId,
amount: formatAmount(entry.amount),
})) ?? [],
);
const stationProcessRows = computed(() =>
selectedStation.value?.currentProcesses.map((process) => ({
key: `${process.lane}-${process.label}`,
lane: process.lane,
label: process.label,
progress: formatPercent(process.progress),
timing: `${Math.ceil(process.timeRemainingSeconds)}s / ${Math.ceil(process.cycleSeconds)}s`,
})) ?? [],
);
watch(
() => `${selectedEntityKind.value ?? "none"}:${selectedEntityId.value ?? "none"}`,
() => {
const ship = selectedShip.value;
if (!ship) {
actionStatus.value = "";
actionError.value = "";
return;
}
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 = "";
}, { immediate: true });
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) {
@@ -197,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,
@@ -212,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;
@@ -357,51 +608,145 @@ async function clearOrders() {
</div>
<div class="entity-inspector-panel__actions">
<button type="button" class="entity-inspector-panel__action" @click="focusShip('tactical')">Focus</button>
<button type="button" class="entity-inspector-panel__action" @click="focusShip('follow')">Follow</button>
<button type="button" class="entity-inspector-panel__action" @click="focusShip('follow')">Track</button>
</div>
</header>
<div class="entity-inspector-section">
<h4>Status</h4>
<div class="entity-inspector-grid">
<div><span>State</span><strong>{{ titleCase(selectedShip.state) }}</strong></div>
<div><span>Behavior</span><strong>{{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</strong></div>
<div><span>Control</span><strong>{{ selectedShip.controlSourceKind }}</strong></div>
<div><span>Assignment</span><strong>{{ selectedShip.assignment?.kind ?? "unassigned" }}</strong></div>
<div><span>Plan</span><strong>{{ selectedShip.activePlan ? `${selectedShip.activePlan.kind} · ${selectedShip.activePlan.status}` : "none" }}</strong></div>
<div><span>Failure</span><strong>{{ selectedShip.lastAccessFailureReason ?? "none" }}</strong></div>
<div class="entity-inspector-table-wrap">
<table class="entity-inspector-table entity-inspector-table--kv">
<tbody>
<tr v-for="row in shipStatusRows" :key="row.label">
<th scope="row">{{ row.label }}</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="entity-inspector-section">
<h4>Cargo</h4>
<div class="entity-inspector-grid">
<div><span>Used</span><strong>{{ formatAmount(selectedShip.inventory.reduce((sum, entry) => sum + entry.amount, 0)) }}</strong></div>
<div><span>Capacity</span><strong>{{ formatAmount(selectedShip.cargoCapacity) }}</strong></div>
<div><span>Travel</span><strong>{{ formatAmount(selectedShip.travelSpeed) }} {{ selectedShip.travelSpeedUnit }}</strong></div>
<div><span>Hull</span><strong>{{ formatAmount(selectedShip.health) }}</strong></div>
<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="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>
<ul v-if="selectedShip.inventory.length > 0" class="entity-inspector-list">
<li v-for="entry in selectedShip.inventory" :key="entry.itemId">
<span>{{ entry.itemId }}</span>
<strong>{{ formatAmount(entry.amount) }}</strong>
</li>
</ul>
<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>
<div class="entity-inspector-grid">
<div><span>Area</span><strong>{{ selectedShip.defaultBehavior.areaSystemId ?? "none" }}</strong></div>
<div><span>Item</span><strong>{{ selectedShip.defaultBehavior.itemId ?? "none" }}</strong></div>
<div><span>Home Station</span><strong>{{ selectedShip.defaultBehavior.homeStationId ?? "none" }}</strong></div>
<div><span>Target</span><strong>{{ selectedShip.defaultBehavior.targetEntityId ?? "none" }}</strong></div>
<div><span>Range</span><strong>{{ selectedShip.defaultBehavior.maxSystemRange }}</strong></div>
<div><span>Known Only</span><strong>{{ selectedShip.defaultBehavior.knownStationsOnly ? "yes" : "no" }}</strong></div>
<div class="entity-inspector-table-wrap">
<table class="entity-inspector-table entity-inspector-table--kv">
<tbody>
<tr v-for="row in shipBehaviorRows" :key="row.label">
<th scope="row">{{ row.label }}</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="canDirectControlSelectedShip" class="entity-inspector-form">
<label class="entity-inspector-field">
@@ -436,91 +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>
<ul v-if="directOrders.length > 0" class="entity-inspector-list">
<li v-for="order in directOrders" :key="order.id">
<span>{{ getShipOrderLabel(order.kind) }} · {{ order.status }}</span>
<div class="entity-inspector-order-actions">
<strong>{{ order.itemId ?? order.targetEntityId ?? order.targetSystemId ?? "—" }}</strong>
<button
v-if="canDirectControlSelectedShip"
type="button"
class="entity-inspector-order-remove"
:disabled="actionBusy"
@click="removeOrder(order.id)"
>
Remove
</button>
</div>
</li>
</ul>
<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>
<ul v-if="behaviorOrders.length > 0" class="entity-inspector-list">
<li v-for="order in behaviorOrders" :key="order.id">
<span>{{ getShipOrderLabel(order.kind) }} · {{ order.status }}</span>
<strong>{{ [order.itemId ?? order.targetEntityId ?? order.targetSystemId ?? "—", getShipOrderSupportStatusLabel(order.kind), getShipOrderNotes(order.kind)].filter(Boolean).join(" · ") }}</strong>
</li>
</ul>
<div v-else class="entity-inspector-empty">No behavior orders queued.</div>
</div>
<div class="entity-inspector-section">
<h4>Plan Steps</h4>
<ul v-if="selectedShip.activePlan" class="entity-inspector-plan">
<li v-for="step in selectedShip.activePlan.steps" :key="step.id">
<div class="entity-inspector-plan__step">
<span>{{ step.kind }} · {{ step.status }}</span>
<strong>{{ step.blockingReason ?? "ok" }}</strong>
</div>
<ul class="entity-inspector-subtasks">
<li v-for="subTask in step.subTasks" :key="subTask.id">
<span>{{ subTask.kind }} · {{ subTask.status }}</span>
<strong>{{ subTask.blockingReason ?? `${Math.round(subTask.progress * 100)}%` }}</strong>
</li>
</ul>
</li>
</ul>
<div v-else class="entity-inspector-empty">No active plan.</div>
</div>
</template>
<template v-else-if="selectedStation">
@@ -537,46 +797,97 @@ async function clearOrders() {
<div class="entity-inspector-section">
<h4>Status</h4>
<div class="entity-inspector-grid">
<div><span>Category</span><strong>{{ titleCase(selectedStation.category) }}</strong></div>
<div><span>Objective</span><strong>{{ titleCase(selectedStation.objective) }}</strong></div>
<div><span>Docked</span><strong>{{ selectedStation.dockedShips }} / {{ selectedStation.dockingPads }}</strong></div>
<div><span>Population</span><strong>{{ formatAmount(selectedStation.population) }} / {{ formatAmount(selectedStation.populationCapacity) }}</strong></div>
<div><span>Workforce</span><strong>{{ formatAmount(selectedStation.workforceRequired) }}</strong></div>
<div><span>Efficiency</span><strong>{{ Math.round(selectedStation.workforceEffectiveRatio * 100) }}%</strong></div>
<div class="entity-inspector-table-wrap">
<table class="entity-inspector-table entity-inspector-table--kv">
<tbody>
<tr v-for="row in stationStatusRows" :key="row.label">
<th scope="row">{{ row.label }}</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="entity-inspector-section">
<h4>Modules</h4>
<ul v-if="selectedStation.installedModules.length > 0" class="entity-inspector-list">
<li v-for="moduleId in selectedStation.installedModules" :key="moduleId">
<span>{{ moduleNameById.get(moduleId) ?? moduleId }}</span>
<strong>{{ moduleId }}</strong>
</li>
</ul>
<div v-if="stationModuleRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Module</th>
<th scope="col">Id</th>
</tr>
</thead>
<tbody>
<tr v-for="row in stationModuleRows" :key="row.key">
<td>{{ row.module }}</td>
<td>{{ row.moduleId }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No modules installed.</div>
</div>
<div class="entity-inspector-section">
<h4>Storage</h4>
<ul v-if="selectedStation.inventory.length > 0" class="entity-inspector-list">
<li v-for="entry in selectedStation.inventory" :key="entry.itemId">
<span>{{ entry.itemId }}</span>
<strong>{{ formatAmount(entry.amount) }}</strong>
</li>
</ul>
<div v-else class="entity-inspector-empty">No inventory.</div>
<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">
<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 stationInventoryRows" :key="row.key">
<td>{{ row.ware }}</td>
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else-if="stationStorageRows.length === 0" class="entity-inspector-empty">No inventory.</div>
</div>
<div class="entity-inspector-section">
<h4>Production</h4>
<ul v-if="selectedStation.currentProcesses.length > 0" class="entity-inspector-list">
<li v-for="process in selectedStation.currentProcesses" :key="`${process.lane}-${process.label}`">
<span>{{ process.label }}</span>
<strong>{{ Math.round(process.progress * 100) }}% · {{ Math.ceil(process.timeRemainingSeconds) }}s</strong>
</li>
</ul>
<div v-if="stationProcessRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Lane</th>
<th scope="col">Process</th>
<th scope="col" class="entity-inspector-table__numeric">Progress</th>
<th scope="col" class="entity-inspector-table__numeric">Timing</th>
</tr>
</thead>
<tbody>
<tr v-for="row in stationProcessRows" :key="row.key">
<td>{{ row.lane }}</td>
<td>{{ row.label }}</td>
<td class="entity-inspector-table__numeric">{{ row.progress }}</td>
<td class="entity-inspector-table__numeric">{{ row.timing }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No active processes.</div>
</div>
</template>

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";
@@ -50,7 +50,7 @@ const canControlSelectedShip = computed(() => {
return false;
}
if (authStore.canAccessGm) {
if (authStore.canAccessGm && !authStore.isActingAsAlternateIdentity) {
return true;
}
@@ -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

@@ -8,6 +8,12 @@ export interface AuthSessionResponse {
refreshTokenExpiresAtUtc: string;
}
export interface RegisterResponse {
userId: string;
email: string;
requiresLogin: boolean;
}
export interface ForgotPasswordResponse {
accepted: boolean;
resetToken?: string | null;

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

@@ -0,0 +1,9 @@
export interface PlayerIdentitySummary {
userId: string;
email: string;
roles: string[];
hasPlayerFaction: boolean;
playerFactionId?: string | null;
playerFactionLabel?: string | null;
sovereignFactionId?: string | null;
}

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;
@@ -266,7 +266,10 @@ export interface PlayerAlertSnapshot {
export interface PlayerFactionSnapshot {
id: string;
label: string;
personaName?: string | null;
raceId?: string | null;
sovereignFactionId: string;
requiresOnboarding: boolean;
status: string;
createdAtUtc: string;
updatedAtUtc: string;

View File

@@ -0,0 +1,6 @@
export interface RaceSnapshot {
id: string;
name: string;
description: string;
icon: string;
}

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

@@ -0,0 +1,47 @@
const STORAGE_KEY = "space-game.auth.effective-player-id";
let currentEffectivePlayerId = loadEffectivePlayerId();
const listeners = new Set<(playerId: string | null) => void>();
export function getEffectivePlayerIdentityId() {
return currentEffectivePlayerId;
}
export function setEffectivePlayerIdentityId(playerId: string | null) {
currentEffectivePlayerId = playerId && playerId.trim().length > 0 ? playerId.trim() : null;
persistEffectivePlayerId(currentEffectivePlayerId);
for (const listener of listeners) {
listener(currentEffectivePlayerId);
}
}
export function clearEffectivePlayerIdentityId() {
setEffectivePlayerIdentityId(null);
}
export function subscribeToEffectivePlayerIdentity(listener: (playerId: string | null) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function loadEffectivePlayerId() {
if (typeof window === "undefined") {
return null;
}
const raw = window.localStorage.getItem(STORAGE_KEY);
return raw && raw.trim().length > 0 ? raw.trim() : null;
}
function persistEffectivePlayerId(playerId: string | null) {
if (typeof window === "undefined") {
return;
}
if (!playerId) {
window.localStorage.removeItem(STORAGE_KEY);
return;
}
window.localStorage.setItem(STORAGE_KEY, playerId);
}

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

@@ -13,14 +13,22 @@ export class ViewerRenderSurface {
private readonly onFrame: () => void;
private readonly onResizeCallback: (width: number, height: number) => void;
private readonly resizeListener = () => this.resize();
private readonly resizeObserver?: ResizeObserver;
constructor(options: ViewerRenderSurfaceOptions) {
this.container = options.container;
this.renderer = options.renderer;
this.onFrame = options.onFrame;
this.onResizeCallback = options.onResize;
this.renderer.domElement.style.width = "100%";
this.renderer.domElement.style.height = "100%";
this.renderer.domElement.style.display = "block";
this.container.append(this.renderer.domElement);
window.addEventListener("resize", this.resizeListener);
if (typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(this.container);
}
this.resize();
}
@@ -46,6 +54,7 @@ export class ViewerRenderSurface {
dispose() {
this.stop();
window.removeEventListener("resize", this.resizeListener);
this.resizeObserver?.disconnect();
this.renderer.dispose();
this.renderer.domElement.remove();
}

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

@@ -19,6 +19,7 @@ body,
margin: 0;
width: 100%;
height: 100%;
min-height: 100dvh;
overflow: hidden;
background:
radial-gradient(circle at top, rgba(89, 132, 247, 0.16), transparent 30%),
@@ -269,12 +270,230 @@ select {
canvas {
display: block;
touch-action: none;
}
.viewer-app,
.viewer-canvas-host {
width: 100%;
height: 100dvh;
min-height: 100dvh;
}
.viewer-canvas-host {
touch-action: none;
}
.viewer-left-sidebar-dock {
position: absolute;
inset: 0 auto 0 0;
width: min(360px, 100vw);
padding: 0;
}
.viewer-left-sidebar {
display: flex;
flex-direction: column;
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 left, rgba(127, 214, 255, 0.08), transparent 34%);
border-right: 1px solid rgba(132, 196, 255, 0.14);
backdrop-filter: blur(18px);
box-shadow: 18px 0 42px rgba(0, 0, 0, 0.18);
}
.viewer-left-sidebar__tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
flex: 0 0 auto;
}
.viewer-left-sidebar__tab {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: var(--viewer-text);
padding: 0.75rem 0.95rem;
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
transition: background 120ms ease, border-color 120ms ease;
}
.viewer-left-sidebar__tab:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.22);
}
.viewer-left-sidebar__tab--active {
background: rgba(116, 196, 255, 0.14);
border-color: rgba(116, 196, 255, 0.32);
}
.viewer-left-sidebar__body {
display: flex;
flex: 1 1 auto;
min-height: 0;
margin-top: 0.9rem;
overflow: hidden;
}
.viewer-left-sidebar__panel {
flex: 1 1 auto;
min-height: 0;
}
.viewer-left-sidebar__panel--player {
overflow: auto;
padding-right: 0.2rem;
}
.viewer-left-sidebar__panel--entities {
height: 100%;
}
.viewer-left-sidebar__panel--entities.entity-browser-panel {
height: 100%;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.viewer-right-sidebar-dock {
position: absolute;
inset: 0 0 0 auto;
width: min(380px, 100vw);
padding: 0;
}
.viewer-right-sidebar {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
min-height: 0;
padding: 16px;
background:
linear-gradient(180deg, rgba(7, 14, 27, 0.9), rgba(7, 14, 27, 0.78)),
radial-gradient(circle at top right, rgba(127, 214, 255, 0.08), transparent 34%);
border-left: 1px solid rgba(132, 196, 255, 0.14);
backdrop-filter: blur(18px);
box-shadow: -18px 0 42px rgba(0, 0, 0, 0.18);
}
.viewer-right-sidebar__resize-handle {
position: absolute;
inset: 0 auto 0 -8px;
width: 16px;
cursor: ew-resize;
}
.viewer-right-sidebar__resize-handle::before {
content: "";
position: absolute;
inset: 0 6px;
background: rgba(132, 196, 255, 0.06);
transition: background 120ms ease;
}
.viewer-right-sidebar__resize-handle:hover::before,
.viewer-right-sidebar__resize-handle--active::before {
background: rgba(132, 196, 255, 0.18);
}
.viewer-right-sidebar__body {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.viewer-right-sidebar__panel.entity-inspector-panel {
height: 100%;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.viewer-right-sidebar__error {
margin-top: 0.9rem;
border-radius: 1rem;
background: rgba(255, 116, 88, 0.14);
padding: 0.85rem 0.95rem;
color: #ffd8cf;
}
.viewer-stats-overlay {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.8rem;
line-height: 1.35;
color: rgba(234, 244, 255, 0.92);
text-shadow:
0 1px 0 rgba(0, 0, 0, 0.85),
0 0 12px rgba(0, 0, 0, 0.42);
white-space: pre-wrap;
letter-spacing: 0.01em;
}
.viewer-stats-overlay-dock {
position: absolute;
top: 20px;
left: calc(min(360px, calc(100vw - 40px)) + 56px);
max-width: min(420px, calc(100vw - 496px));
}
.viewer-system-label-dock {
position: absolute;
top: 22px;
right: calc(min(380px, calc(100vw - 40px)) + 48px);
max-width: min(340px, calc(100vw - 500px));
pointer-events: none;
}
.viewer-system-label {
color: rgba(238, 246, 255, 0.96);
text-shadow:
0 1px 0 rgba(0, 0, 0, 0.88),
0 0 18px rgba(0, 0, 0, 0.42);
}
.viewer-system-label__title {
font-family: "Space Grotesk", "Segoe UI", sans-serif;
font-size: clamp(1.5rem, 1.1rem + 1vw, 2.1rem);
font-weight: 600;
line-height: 0.95;
letter-spacing: -0.04em;
text-wrap: balance;
}
.viewer-system-label__subtitle {
margin-top: 6px;
color: rgba(203, 219, 235, 0.78);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.74rem;
line-height: 1.35;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.viewer-stats-overlay--compact {
font-size: 0.88rem;
font-weight: 500;
}
.viewer-stats-overlay__line--spacer {
line-height: 0.65;
}
.panel-summary,
@@ -450,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);
@@ -566,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 {
@@ -1257,54 +1321,253 @@ canvas {
color: rgba(173, 220, 255, 0.64);
}
.entity-browser-section__items {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.entity-browser-item {
display: flex;
align-items: stretch;
gap: 0.55rem;
}
.entity-browser-item__body {
flex: 1 1 auto;
text-align: left;
padding: 0.75rem 0.85rem;
.entity-browser-table-wrap,
.entity-inspector-table-wrap {
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.03);
}
.entity-browser-item__body:disabled {
opacity: 0.82;
cursor: default;
.entity-browser-table,
.entity-inspector-table {
width: 100%;
border-collapse: collapse;
min-width: 0;
}
.entity-browser-item--selected .entity-browser-item__body {
border-color: rgba(116, 196, 255, 0.38);
.entity-browser-table {
table-layout: fixed;
}
.entity-browser-table__col--entity {
width: 38%;
}
.entity-browser-table__col--ident {
width: 18%;
}
.entity-browser-table__col--location {
width: 22%;
}
.entity-browser-table__col--ai {
width: 14%;
}
.entity-browser-table__col--hp {
width: 8%;
}
.entity-browser-table th,
.entity-browser-table td,
.entity-inspector-table th,
.entity-inspector-table td {
padding: 0.68rem 0.8rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
font-size: 0.78rem;
text-align: left;
vertical-align: middle;
}
.entity-browser-table th,
.entity-browser-table td {
padding: 0.42rem 0.5rem;
}
.entity-browser-table thead th,
.entity-inspector-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: rgba(7, 12, 18, 0.96);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: rgba(173, 220, 255, 0.72);
}
.entity-browser-table tbody tr:last-child td,
.entity-inspector-table tbody tr:last-child td,
.entity-inspector-table tbody tr:last-child th {
border-bottom: none;
}
.entity-browser-table__sort {
border: none;
background: transparent;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
padding: 0;
}
.entity-browser-table__row {
cursor: pointer;
transition: background 120ms ease;
}
.entity-browser-table__row:hover {
background: rgba(255, 255, 255, 0.04);
}
.entity-browser-table__row--selected {
background: rgba(116, 196, 255, 0.12);
}
.entity-browser-item__label {
font-size: 0.88rem;
.entity-browser-table__name {
font-size: 0.78rem;
font-weight: 600;
}
.entity-browser-item__subtitle,
.entity-browser-item__meta {
margin-top: 0.18rem;
font-size: 0.75rem;
.entity-browser-table__cell--truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entity-browser-table__cell--ai {
overflow: hidden;
}
.entity-browser-table__detail,
.entity-inspector-table__detail {
color: var(--viewer-muted);
}
.entity-browser-item__focus,
.entity-browser-table__action-col,
.entity-inspector-table__action-col,
.entity-inspector-table__numeric {
text-align: right;
}
.entity-browser-table__numeric {
text-align: right;
white-space: nowrap;
}
.entity-browser-table__action,
.entity-inspector-panel__action {
padding: 0.65rem 0.9rem;
font-size: 0.78rem;
padding: 0.5rem 0.72rem;
font-size: 0.72rem;
align-self: center;
}
.entity-browser-table__action {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: var(--viewer-text);
border-radius: 999px;
transition: background 120ms ease, border-color 120ms ease;
}
.entity-browser-table__action:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.22);
}
.entity-browser-table__muted {
color: var(--viewer-muted);
}
.entity-browser-row {
display: flex;
align-items: center;
gap: 0.38rem;
min-width: 0;
}
.entity-browser-row__toggle {
width: 1rem;
height: 1rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.35rem;
background: rgba(255, 255, 255, 0.04);
color: var(--viewer-text);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.66rem;
line-height: 1;
padding: 0;
flex: 0 0 auto;
}
.entity-browser-row__toggle--spacer {
visibility: hidden;
}
.entity-browser-row__kind {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
padding: 0.14rem 0.3rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: rgba(234, 244, 255, 0.88);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.58rem;
letter-spacing: 0.08em;
text-transform: uppercase;
flex: 0 0 auto;
}
.entity-browser-row__kind--system {
border-color: rgba(127, 214, 255, 0.24);
color: rgba(127, 214, 255, 0.96);
}
.entity-browser-row__kind--station {
border-color: rgba(255, 191, 105, 0.22);
color: rgba(255, 222, 168, 0.92);
}
.entity-browser-row__kind--fleet {
border-color: rgba(146, 255, 200, 0.22);
color: rgba(190, 255, 223, 0.92);
}
.entity-browser-row__kind--ship {
border-color: rgba(255, 255, 255, 0.14);
}
.entity-browser-row__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entity-browser-ai {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
max-width: 100%;
overflow: hidden;
}
.entity-browser-ai__token {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
max-width: 100%;
padding: 0.13rem 0.28rem;
border-radius: 999px;
background: rgba(127, 214, 255, 0.08);
border: 1px solid rgba(127, 214, 255, 0.14);
color: rgba(206, 233, 255, 0.84);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.56rem;
letter-spacing: 0.06em;
text-transform: uppercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entity-inspector-panel__actions {
display: flex;
gap: 0.45rem;
@@ -1328,71 +1591,87 @@ canvas {
color: rgba(173, 220, 255, 0.7);
}
.entity-inspector-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem 0.9rem;
}
.entity-inspector-grid span {
display: block;
font-size: 0.72rem;
color: var(--viewer-muted);
.entity-inspector-table--kv th {
width: 38%;
background: rgba(255, 255, 255, 0.02);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.12em;
color: rgba(173, 220, 255, 0.68);
}
.entity-inspector-grid strong {
display: block;
margin-top: 0.15rem;
font-size: 0.86rem;
.entity-inspector-table--kv td {
font-size: 0.84rem;
font-weight: 600;
}
.entity-inspector-list,
.entity-inspector-plan,
.entity-inspector-subtasks {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
.entity-inspector-table__row--subtask {
background: rgba(255, 255, 255, 0.02);
}
.entity-inspector-list li,
.entity-inspector-plan__step,
.entity-inspector-subtasks li {
.entity-inspector-table__subtask {
padding-left: 1.45rem;
}
.entity-inspector-table__subtask::before {
content: "↳ ";
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: 1rem;
align-items: baseline;
padding: 0.55rem 0.7rem;
border-radius: 0.9rem;
background: rgba(255, 255, 255, 0.035);
gap: 0.7rem;
}
.entity-inspector-list span,
.entity-inspector-plan__step span,
.entity-inspector-subtasks span {
font-size: 0.8rem;
.entity-inspector-capacity__header {
margin-bottom: 0.45rem;
}
.entity-inspector-list strong,
.entity-inspector-plan__step strong,
.entity-inspector-subtasks strong {
font-size: 0.75rem;
color: var(--viewer-muted);
.entity-inspector-capacity__label {
font-size: 0.74rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(173, 220, 255, 0.72);
}
.entity-inspector-plan > li {
display: flex;
flex-direction: column;
gap: 0.4rem;
.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-subtasks {
padding-left: 0.8rem;
.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 {
@@ -1425,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;
@@ -1436,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);
@@ -1459,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);
@@ -1592,8 +1942,47 @@ canvas {
}
@media (max-width: 760px) {
.entity-inspector-grid {
grid-template-columns: minmax(0, 1fr);
.viewer-left-sidebar-dock {
width: min(360px, 100vw);
}
.viewer-left-sidebar {
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;
right: 20px;
max-width: none;
}
.viewer-system-label-dock {
top: 20px;
left: 20px;
right: 20px;
max-width: none;
}
.viewer-system-label__title {
font-size: clamp(1.35rem, 1rem + 1vw, 1.8rem);
}
.entity-inspector-inline-form,
@@ -1602,4 +1991,9 @@ canvas {
flex-direction: column;
align-items: stretch;
}
.entity-browser-table,
.entity-inspector-table {
min-width: 640px;
}
}

View File

@@ -1,10 +1,19 @@
import { defineStore } from "pinia";
import type { AuthSessionResponse } from "../../contractsAuth";
import type { PlayerIdentitySummary } from "../../contractsIdentity";
import { clearAuthSession, getAuthSession, setAuthSession, subscribeToAuthSession } from "../../authSession";
import {
clearEffectivePlayerIdentityId,
getEffectivePlayerIdentityId,
setEffectivePlayerIdentityId,
subscribeToEffectivePlayerIdentity,
} from "../../effectiveIdentitySession";
export const useAuthStore = defineStore("auth", {
state: () => ({
session: getAuthSession() as AuthSessionResponse | null,
effectivePlayerId: getEffectivePlayerIdentityId() as string | null,
availablePlayerIdentities: [] as PlayerIdentitySummary[],
busy: false,
initialized: false,
}),
@@ -14,19 +23,35 @@ export const useAuthStore = defineStore("auth", {
roles: (state) => state.session?.roles ?? [],
canAccessGm: (state) => (state.session?.roles ?? []).some((role) => role === "gm" || role === "admin"),
accessToken: (state) => state.session?.accessToken ?? null,
isActingAsAlternateIdentity: (state) => state.effectivePlayerId != null && state.effectivePlayerId !== state.session?.userId,
activePlayerId: (state) => state.effectivePlayerId ?? state.session?.userId ?? null,
},
actions: {
setSession(session: AuthSessionResponse | null) {
this.session = session;
setAuthSession(session);
if (!session || !(session.roles ?? []).some((role) => role === "gm" || role === "admin")) {
this.effectivePlayerId = null;
clearEffectivePlayerIdentityId();
}
},
clearSession() {
this.session = null;
this.effectivePlayerId = null;
this.availablePlayerIdentities = [];
clearAuthSession();
clearEffectivePlayerIdentityId();
},
setBusy(busy: boolean) {
this.busy = busy;
},
setEffectivePlayerId(playerId: string | null) {
this.effectivePlayerId = playerId && playerId.trim().length > 0 ? playerId.trim() : null;
setEffectivePlayerIdentityId(this.effectivePlayerId);
},
setAvailablePlayerIdentities(identities: PlayerIdentitySummary[]) {
this.availablePlayerIdentities = identities;
},
initialize() {
if (this.initialized) {
return;
@@ -36,6 +61,9 @@ export const useAuthStore = defineStore("auth", {
subscribeToAuthSession((session) => {
this.session = session as AuthSessionResponse | null;
});
subscribeToEffectivePlayerIdentity((playerId) => {
this.effectivePlayerId = playerId;
});
},
},
});

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;
}
@@ -103,6 +103,49 @@ export function updatePanFromKeyboard(
galaxyAnchor.addScaledVector(pan, speed * delta);
}
export function applyPanFromScreenDelta(
delta: THREE.Vector2,
orbitYaw: number,
currentDistance: number,
cameraFovDegrees: number,
povLevel: PovLevel,
activeSystemId: string | undefined,
systemAnchor: THREE.Vector3,
galaxyAnchor: THREE.Vector3,
viewportWidth: number,
viewportHeight: number,
minimumDistance: number,
maximumDistance: number,
) {
const safeWidth = Math.max(viewportWidth, 1);
const safeHeight = Math.max(viewportHeight, 1);
const normalized = new THREE.Vector2(delta.x / safeWidth, delta.y / safeHeight);
if (normalized.lengthSq() === 0) {
return;
}
const forward = new THREE.Vector3(Math.cos(orbitYaw), 0, Math.sin(orbitYaw));
const right = new THREE.Vector3(-forward.z, 0, forward.x);
const visibleHeight = 2 * Math.tan(THREE.MathUtils.degToRad(cameraFovDegrees) * 0.5) * currentDistance;
const visibleWidth = visibleHeight * (safeWidth / safeHeight);
const horizontalDistance = normalized.x * visibleWidth;
const verticalDistance = -normalized.y * visibleHeight;
const pan = right.multiplyScalar(horizontalDistance).add(forward.multiplyScalar(verticalDistance));
if (activeSystemId) {
if (povLevel === "local") {
systemAnchor.add(pan);
return;
}
const systemDisplayToKilometers = 1 / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
systemAnchor.addScaledVector(pan, systemDisplayToKilometers);
return;
}
galaxyAnchor.add(pan);
}
export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined {
const {
world,
@@ -199,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);
@@ -315,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

@@ -5,6 +5,8 @@ import { ViewerPresentationController } from "./viewerPresentationController";
import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerWorldLifecycle } from "./viewerWorldLifecycle";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import { applyPanFromScreenDelta } from "./viewerCamera";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE } from "./viewerConstants";
import { useViewerSceneStore } from "./ui/stores/viewerScene";
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
import { viewerPinia } from "./ui/stores/pinia";
@@ -28,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,
@@ -38,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({
@@ -99,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,
@@ -150,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(),
@@ -189,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,
@@ -235,15 +252,27 @@ 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),
applyOrbitDelta: (delta: THREE.Vector2) => {
if (host.cameraMode === "follow") {
host.followOrbitYaw += delta.x * 0.008;
host.followOrbitPitch = THREE.MathUtils.clamp(host.followOrbitPitch + delta.y * 0.004, 0.02, 1.45);
} else {
host.orbitYaw += delta.x * 0.008;
host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3);
}
applyPanDelta: (delta: THREE.Vector2) => {
const bounds = host.renderer.domElement.getBoundingClientRect();
applyPanFromScreenDelta(
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,
host.galaxyAnchor,
bounds.width,
bounds.height,
MIN_CAMERA_DISTANCE,
MAX_CAMERA_DISTANCE,
);
},
syncFollowStateFromSelection: () => navigationController.syncFollowStateFromSelection(),
updatePanels: () => host.updatePanels(),
@@ -251,6 +280,11 @@ export function createViewerControllers(host: any) {
updateGamePanel: (mode) => host.updateGamePanel(mode),
openOrderContextMenu: (x, y, target) => orderContextMenuStore.open(x, y, target),
closeOrderContextMenu: () => orderContextMenuStore.close(),
getStatsOverlayMode: () => host.hudState.statsOverlay.mode,
setStatsOverlayMode: (mode) => {
host.hudState.statsOverlay.mode = mode;
},
refreshStatsOverlay: () => presentationController.refreshStatsOverlay(),
historyController,
});
@@ -269,6 +303,7 @@ export function wireViewerEvents(host: any) {
canvas.addEventListener("pointerdown", host.interactionController.onPointerDown);
canvas.addEventListener("pointermove", host.interactionController.onPointerMove);
canvas.addEventListener("pointerup", host.interactionController.onPointerUp);
canvas.addEventListener("pointercancel", host.interactionController.onPointerUp);
canvas.addEventListener("pointerleave", host.interactionController.onPointerUp);
canvas.addEventListener("click", host.interactionController.onClick);
canvas.addEventListener("contextmenu", host.interactionController.onContextMenu);
@@ -284,6 +319,7 @@ export function wireViewerEvents(host: any) {
canvas.removeEventListener("pointerdown", host.interactionController.onPointerDown);
canvas.removeEventListener("pointermove", host.interactionController.onPointerMove);
canvas.removeEventListener("pointerup", host.interactionController.onPointerUp);
canvas.removeEventListener("pointercancel", host.interactionController.onPointerUp);
canvas.removeEventListener("pointerleave", host.interactionController.onPointerUp);
canvas.removeEventListener("click", host.interactionController.onClick);
canvas.removeEventListener("contextmenu", host.interactionController.onContextMenu);

View File

@@ -1,10 +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,
@@ -147,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
@@ -209,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: {
@@ -250,3 +256,20 @@ export function applyKeyboardControl(params: {
return { cameraMode, desiredDistance };
}
export function cycleStatsOverlayMode(current: StatsOverlayMode): StatsOverlayMode {
switch (current) {
case "hidden":
return "compact";
case "compact":
return "status";
case "status":
return "network";
case "network":
return "performance";
case "performance":
return "full";
default:
return "hidden";
}
}

View File

@@ -8,6 +8,13 @@ export interface HudPanelState {
bodyText: string;
}
export type StatsOverlayMode = "hidden" | "compact" | "status" | "network" | "performance" | "full";
export interface StatsOverlayState {
mode: StatsOverlayMode;
lines: string[];
}
export interface HudHtmlPanelState {
hidden: boolean;
title: string;
@@ -25,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;
@@ -100,10 +70,10 @@ export interface ViewerHudState {
gamePanel: HudPanelState;
networkPanel: HudPanelState;
performancePanel: HudPanelState;
statsOverlay: StatsOverlayState;
systemPanel: HudHtmlPanelState;
detailPanel: HudHtmlPanelState;
error: HudErrorState;
opsStrip: OpsStripState;
historyWindows: HistoryWindowState[];
hoverLabel: HoverLabelState;
marquee: MarqueeState;
@@ -135,6 +105,10 @@ export function createViewerHudState(): ViewerHudState {
summary: "Waiting",
bodyText: "Waiting for frame samples.",
},
statsOverlay: {
mode: "compact",
lines: [],
},
systemPanel: {
hidden: false,
title: "Deep Space",
@@ -149,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

@@ -1,20 +1,18 @@
import * as THREE from "three";
import {
completeMarqueeSelection,
hideMarqueeBox,
pickSelectableHitAtClientPosition,
pickSelectableAtClientPosition,
updateHoverLabel,
updateMarqueeBox,
} from "./viewerInteraction";
import {
applyKeyboardControl,
cycleStatsOverlayMode,
toggleCameraMode,
navigateFromWheel,
} from "./viewerControls";
import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT, NAV_DISTANCE_SHIP_HULL } from "./viewerConstants";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE_PLANET_ORBIT } from "./viewerConstants";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import type { ViewerHudState } from "./viewerHudState";
import type { StatsOverlayMode, ViewerHudState } from "./viewerHudState";
import type {
CameraMode,
DragMode,
@@ -22,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;
@@ -30,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;
@@ -60,89 +63,131 @@ 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;
applyOrbitDelta: (delta: THREE.Vector2) => void;
applyPanDelta: (delta: THREE.Vector2) => void;
syncFollowStateFromSelection: () => void;
updatePanels: () => void;
focusOnSelection: (selection: Selectable) => void;
updateGamePanel: (mode: string) => void;
openOrderContextMenu: (x: number, y: number, target: ViewerOrderContextMenuTarget) => void;
closeOrderContextMenu: () => void;
getStatsOverlayMode: () => StatsOverlayMode;
setStatsOverlayMode: (mode: StatsOverlayMode) => void;
refreshStatsOverlay: () => void;
historyController: ViewerHistoryWindowController;
}
export class ViewerInteractionController {
private readonly activePointers = new Map<number, THREE.Vector2>();
private pinchStartDistance?: number;
private pinchStartZoom?: number;
private pinchLastCenter?: THREE.Vector2;
constructor(private readonly context: ViewerInteractionContext) {}
readonly onPointerDown = (event: PointerEvent) => {
if (event.button === 1) {
this.context.setDragMode("orbit");
this.context.setDragPointerId(event.pointerId);
this.context.dragLast.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
this.context.renderer.domElement.setPointerCapture(event.pointerId);
return;
}
if (event.button !== 0) {
return;
}
this.context.setDragMode("marquee");
this.context.setDragPointerId(event.pointerId);
this.context.dragStart.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
this.context.dragLast.copy(this.context.dragStart);
this.context.setMarqueeActive(false);
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
this.activePointers.set(event.pointerId, point);
this.context.renderer.domElement.setPointerCapture(event.pointerId);
if (this.activePointers.size >= 2) {
const gesture = this.getPinchGesture();
if (!gesture) {
return;
}
this.context.setSuppressClickSelection(true);
this.context.setDragMode("pinch");
this.context.setDragPointerId(event.pointerId);
this.pinchStartDistance = gesture.distance;
this.pinchStartZoom = this.context.getDesiredDistance();
this.pinchLastCenter = gesture.center;
return;
}
this.context.setDragMode("pan");
this.context.setDragPointerId(event.pointerId);
this.context.dragStart.copy(point);
this.context.dragLast.copy(point);
};
readonly onPointerMove = (event: PointerEvent) => {
this.updateHoverLabel(event);
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
if (this.activePointers.has(event.pointerId)) {
this.activePointers.set(event.pointerId, point);
}
if (this.context.getDragPointerId() !== event.pointerId || !this.context.getDragMode()) {
return;
}
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
if (this.context.getDragMode() === "orbit") {
if (this.context.getDragMode() === "pinch") {
const gesture = this.getPinchGesture();
if (!gesture || !this.pinchStartDistance || !this.pinchStartZoom || !this.pinchLastCenter) {
return;
}
const zoomRatio = THREE.MathUtils.clamp(gesture.distance / this.pinchStartDistance, 0.25, 4);
this.context.setDesiredDistance(THREE.MathUtils.clamp(
this.pinchStartZoom / zoomRatio,
MIN_CAMERA_DISTANCE,
MAX_CAMERA_DISTANCE,
));
const centerDelta = gesture.center.clone().sub(this.pinchLastCenter);
this.pinchLastCenter = gesture.center;
this.context.applyPanDelta(centerDelta);
return;
}
const delta = point.clone().sub(this.context.dragLast);
this.context.dragLast.copy(point);
this.context.applyOrbitDelta(delta);
return;
}
const dragDistance = point.distanceTo(this.context.dragStart);
if (!this.context.getMarqueeActive() && dragDistance > 8) {
this.context.setMarqueeActive(true);
if (dragDistance > 6) {
this.context.setSuppressClickSelection(true);
this.context.hudState.marquee.visible = true;
this.context.marqueeEl.style.display = "block";
}
if (!this.context.getMarqueeActive()) {
return;
}
this.context.dragLast.copy(point);
updateMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl, this.context.dragStart, this.context.dragLast);
this.context.applyPanDelta(delta);
};
readonly onPointerUp = (event: PointerEvent) => {
if (this.context.getDragPointerId() !== event.pointerId) {
return;
}
if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) {
this.context.renderer.domElement.releasePointerCapture(event.pointerId);
}
this.activePointers.delete(event.pointerId);
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
this.completeMarqueeSelection();
hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl);
if (this.activePointers.size >= 2) {
const gesture = this.getPinchGesture();
if (gesture) {
this.context.setDragMode("pinch");
this.pinchStartDistance = gesture.distance;
this.pinchStartZoom = this.context.getDesiredDistance();
this.pinchLastCenter = gesture.center;
}
return;
}
const remainingPointer = this.activePointers.entries().next();
if (!remainingPointer.done) {
const [pointerId, point] = remainingPointer.value;
this.context.setDragMode("pan");
this.context.setDragPointerId(pointerId);
this.context.dragStart.copy(point);
this.context.dragLast.copy(point);
} else {
this.context.setDragMode(undefined);
this.context.setDragPointerId(undefined);
this.context.setMarqueeActive(false);
}
this.pinchStartDistance = undefined;
this.pinchStartZoom = undefined;
this.pinchLastCenter = undefined;
};
readonly onClick = (event: MouseEvent) => {
@@ -166,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;
}
@@ -178,72 +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("follow");
this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL);
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);
@@ -268,8 +245,7 @@ export class ViewerInteractionController {
}
if (selection.kind === "ship") {
this.toggleCameraMode("follow");
this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL);
this.toggleCameraMode("tactical");
this.context.updatePanels();
this.context.updateGamePanel("Live");
return;
@@ -278,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");
};
@@ -288,6 +264,13 @@ export class ViewerInteractionController {
}
const key = event.key.toLowerCase();
if (key === "f10") {
event.preventDefault();
this.context.setStatsOverlayMode(cycleStatsOverlayMode(this.context.getStatsOverlayMode()));
this.context.refreshStatsOverlay();
return;
}
const controlState = applyKeyboardControl({
keyState: this.context.keyState,
cameraMode: this.context.getCameraMode(),
@@ -348,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,
);
@@ -362,26 +348,29 @@ 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,
);
}
private completeMarqueeSelection() {
const selection = completeMarqueeSelection({
renderer: this.context.renderer,
systemCamera: this.context.systemCamera,
dragStart: this.context.dragStart,
dragLast: this.context.dragLast,
systemSelectableTargets: this.context.systemSelectableTargets,
});
this.context.setSelectedItems(selection);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
private getPinchGesture() {
const points = [...this.activePointers.values()];
if (points.length < 2) {
return null;
}
const [first, second] = points;
return {
center: first.clone().add(second).multiplyScalar(0.5),
distance: first.distanceTo(second),
};
}
private shouldFocusSelectionOnClick(selection: Selectable) {
@@ -423,6 +412,7 @@ export class ViewerInteractionController {
selection,
label: node.itemId,
systemId: node.systemId,
anchorId: node.anchorId,
itemId: node.itemId,
targetPosition: node.localPosition,
} : null;
@@ -456,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 {

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