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.* .env.*
!.env.example !.env.example
.codex

View File

@@ -3,4 +3,8 @@
<Folder Name="/apps/backend/"> <Folder Name="/apps/backend/">
<Project Path="apps/backend/SpaceGame.Api.csproj" /> <Project Path="apps/backend/SpaceGame.Api.csproj" />
</Folder> </Folder>
<Folder Name="/tests/" />
<Folder Name="/tests/backend/">
<Project Path="tests/backend/SpaceGame.Api.Tests.csproj" />
</Folder>
</Solution> </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; 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() public override void Configure()
{ {

View File

@@ -37,6 +37,11 @@ public sealed record AuthSessionResponse(
string RefreshToken, string RefreshToken,
DateTimeOffset RefreshTokenExpiresAtUtc); DateTimeOffset RefreshTokenExpiresAtUtc);
public sealed record RegisterResponse(
Guid UserId,
string Email,
bool RequiresLogin);
public sealed record ForgotPasswordResponse( public sealed record ForgotPasswordResponse(
bool Accepted, bool Accepted,
string? ResetToken = null); 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, RefreshTokenFactory refreshTokenFactory,
IPasswordResetDelivery passwordResetDelivery) 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); var email = NormalizeEmail(request.Email);
ValidatePassword(request.Password); ValidatePassword(request.Password);
@@ -18,7 +18,7 @@ public sealed class AuthService(
} }
var user = await authRepository.CreateUserAsync(email, passwordHasher.HashPassword(request.Password), [], cancellationToken); 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) 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 sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpContextAccessor) : IPlayerIdentityResolver
{ {
public const string EffectivePlayerHeaderName = "X-Act-As-Player-Id";
public Guid? GetCurrentPlayerId() public Guid? GetCurrentPlayerId()
{ {
var subject = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) var subject = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
@@ -15,6 +17,21 @@ public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpC
public Guid GetRequiredPlayerId() => public Guid GetRequiredPlayerId() =>
GetCurrentPlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required."); 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() public bool CanAccessGm()
{ {
var user = httpContextAccessor.HttpContext?.User; var user = httpContextAccessor.HttpContext?.User;

View File

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

View File

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

View File

@@ -28,6 +28,23 @@ public sealed class PostgresAuthRepository(NpgsqlDataSource dataSource) : IAuthR
return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null; 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) public async Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken)
{ {
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();

View File

@@ -559,6 +559,9 @@ public sealed class ShipCargoDefinition
public sealed class ScenarioDefinition public sealed class ScenarioDefinition
{ {
public required WorldGenerationOptions WorldGeneration { get; set; } 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<InitialStationDefinition> InitialStations { get; set; }
public required List<ShipFormationDefinition> ShipFormations { get; set; } public required List<ShipFormationDefinition> ShipFormations { get; set; }
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; } public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, bool UseOrders,
string? StagingOrderKind, string? StagingOrderKind,
string? ItemId, string? ItemId,
string? PreferredNodeId, string? PreferredAnchorId,
string? PreferredConstructionSiteId, string? PreferredConstructionSiteId,
string? PreferredModuleId, string? PreferredModuleId,
int Priority, int Priority,
@@ -249,7 +249,10 @@ public sealed record PlayerAlertSnapshot(
public sealed record PlayerFactionSnapshot( public sealed record PlayerFactionSnapshot(
string Id, string Id,
string Label, string Label,
string? PersonaName,
string? RaceId,
string SovereignFactionId, string SovereignFactionId,
bool RequiresOnboarding,
string Status, string Status,
DateTimeOffset CreatedAtUtc, DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc, DateTimeOffset UpdatedAtUtc,

View File

@@ -1,5 +1,9 @@
namespace SpaceGame.Api.PlayerFaction.Contracts; namespace SpaceGame.Api.PlayerFaction.Contracts;
public sealed record CompletePlayerOnboardingRequest(
string Name,
string RaceId);
public sealed record PlayerOrganizationCommandRequest( public sealed record PlayerOrganizationCommandRequest(
string Kind, string Kind,
string Label, string Label,
@@ -41,7 +45,7 @@ public sealed record PlayerDirectiveCommandRequest(
string? SourceStationId, string? SourceStationId,
string? DestinationStationId, string? DestinationStationId,
string? ItemId, string? ItemId,
string? PreferredNodeId, string? PreferredAnchorId,
string? PreferredConstructionSiteId, string? PreferredConstructionSiteId,
string? PreferredModuleId, string? PreferredModuleId,
int Priority, int Priority,

View File

@@ -6,7 +6,10 @@ public sealed class PlayerFactionRuntime
{ {
public required string Id { get; init; } public required string Id { get; init; }
public required string Label { get; set; } public required string Label { get; set; }
public string? PersonaName { get; set; }
public string? RaceId { get; set; }
public required string SovereignFactionId { get; set; } public required string SovereignFactionId { get; set; }
public bool RequiresOnboarding { get; set; } = true;
public string Status { get; set; } = "active"; public string Status { get; set; } = "active";
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { 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 bool UseOrders { get; set; }
public string? StagingOrderKind { get; set; } public string? StagingOrderKind { get; set; }
public string? ItemId { get; set; } public string? ItemId { get; set; }
public string? PreferredNodeId { get; set; } public string? PreferredAnchorId { get; set; }
public string? PreferredConstructionSiteId { get; set; } public string? PreferredConstructionSiteId { get; set; }
public string? PreferredModuleId { get; set; } public string? PreferredModuleId { get; set; }
public int Priority { get; set; } = 50; public int Priority { get; set; } = 50;

View File

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

View File

@@ -12,7 +12,10 @@ public sealed class PlayerFactionProjectionService
return new PlayerFactionSnapshot( return new PlayerFactionSnapshot(
player.Id, player.Id,
player.Label, player.Label,
player.PersonaName,
player.RaceId,
player.SovereignFactionId, player.SovereignFactionId,
player.RequiresOnboarding,
player.Status, player.Status,
player.CreatedAtUtc, player.CreatedAtUtc,
player.UpdatedAtUtc, player.UpdatedAtUtc,
@@ -198,7 +201,7 @@ public sealed class PlayerFactionProjectionService
directive.UseOrders, directive.UseOrders,
directive.StagingOrderKind, directive.StagingOrderKind,
directive.ItemId, directive.ItemId,
directive.PreferredNodeId, directive.PreferredAnchorId,
directive.PreferredConstructionSiteId, directive.PreferredConstructionSiteId,
directive.PreferredModuleId, directive.PreferredModuleId,
directive.Priority, directive.Priority,
@@ -258,7 +261,7 @@ public sealed class PlayerFactionProjectionService
template.SourceStationId, template.SourceStationId,
template.DestinationStationId, template.DestinationStationId,
template.ItemId, template.ItemId,
template.NodeId, template.AnchorId,
template.ConstructionSiteId, template.ConstructionSiteId,
template.ModuleId, template.ModuleId,
template.WaitSeconds, template.WaitSeconds,

View File

@@ -20,14 +20,12 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId) 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 var player = playerStateStore.GetOrAddPlayerFaction(playerId, () => new PlayerFactionRuntime
{ {
Id = PlayerFactionDomainId, Id = PlayerFactionDomainId,
Label = $"{sovereignFaction.Label} Command", Label = "Pending Pilot",
SovereignFactionId = sovereignFaction.Id, SovereignFactionId = string.Empty,
RequiresOnboarding = true,
CreatedAtUtc = world.GeneratedAtUtc, CreatedAtUtc = world.GeneratedAtUtc,
UpdatedAtUtc = world.GeneratedAtUtc, UpdatedAtUtc = world.GeneratedAtUtc,
}); });
@@ -37,6 +35,58 @@ internal sealed class PlayerFactionService
return player; 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) internal void Update(SimulationWorld world, IPlayerStateStore playerStateStore, float _deltaSeconds, ICollection<SimulationEventRecord> events)
{ {
if (playerStateStore.GetPlayerFactions().Count == 0) if (playerStateStore.GetPlayerFactions().Count == 0)
@@ -63,7 +113,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerOrganizationCommandRequest request) 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 id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player));
var nowUtc = DateTimeOffset.UtcNow; var nowUtc = DateTimeOffset.UtcNow;
@@ -180,7 +230,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId) 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); RemoveOrganization(player, organizationId);
player.Assignments.RemoveAll(assignment => player.Assignments.RemoveAll(assignment =>
assignment.FleetId == organizationId || assignment.FleetId == organizationId ||
@@ -198,7 +248,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId, PlayerOrganizationMembershipCommandRequest request) 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); var kind = ResolveOrganizationKind(player, organizationId);
switch (kind) switch (kind)
{ {
@@ -249,7 +299,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? directiveId, PlayerDirectiveCommandRequest request) 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 var directive = directiveId is null
? null ? null
: player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal)); : player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal));
@@ -279,7 +329,7 @@ internal sealed class PlayerFactionService
directive.SourceStationId = request.SourceStationId; directive.SourceStationId = request.SourceStationId;
directive.DestinationStationId = request.DestinationStationId; directive.DestinationStationId = request.DestinationStationId;
directive.ItemId = request.ItemId; directive.ItemId = request.ItemId;
directive.PreferredNodeId = request.PreferredNodeId; directive.PreferredAnchorId = request.PreferredAnchorId;
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId; directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
directive.PreferredModuleId = request.PreferredModuleId; directive.PreferredModuleId = request.PreferredModuleId;
directive.Priority = request.Priority; directive.Priority = request.Priority;
@@ -305,7 +355,7 @@ internal sealed class PlayerFactionService
SourceStationId = template.SourceStationId, SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId, DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId, ItemId = template.ItemId,
NodeId = template.NodeId, AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId, ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId, ModuleId = template.ModuleId,
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
@@ -326,7 +376,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string directiveId) 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); player.Directives.RemoveAll(directive => directive.Id == directiveId);
foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == 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) 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 var policy = policyId is null
? null ? null
: player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal)); : 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) 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 var policy = automationPolicyId is null
? null ? null
: player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal)); : player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal));
@@ -451,7 +501,7 @@ internal sealed class PlayerFactionService
SourceStationId = template.SourceStationId, SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId, DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId, ItemId = template.ItemId,
NodeId = template.NodeId, AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId, ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId, ModuleId = template.ModuleId,
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
@@ -469,7 +519,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request) 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 var policy = reinforcementPolicyId is null
? null ? null
: player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal)); : 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) 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 var program = productionProgramId is null
? null ? null
: player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal)); : 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) 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 => var assignment = player.Assignments.FirstOrDefault(candidate =>
string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) && string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) &&
string.Equals(candidate.AssetKind, request.AssetKind, 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) 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.StrategicPosture = request.StrategicPosture;
player.StrategicIntent.EconomicPosture = request.EconomicPosture; player.StrategicIntent.EconomicPosture = request.EconomicPosture;
player.StrategicIntent.MilitaryPosture = request.MilitaryPosture; 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) 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)) if (!player.AssetRegistry.ShipIds.Contains(shipId))
{ {
return null; return null;
@@ -622,12 +672,7 @@ internal sealed class PlayerFactionService
return null; return null;
} }
if (ship.OrderQueue.Count >= 8) ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime
{
throw new InvalidOperationException("Order queue is full.");
}
ship.OrderQueue.Add(new ShipOrderRuntime
{ {
Id = $"order-{ship.Id}-{Guid.NewGuid():N}", Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
Kind = request.Kind, Kind = request.Kind,
@@ -642,7 +687,7 @@ internal sealed class PlayerFactionService
SourceStationId = request.SourceStationId, SourceStationId = request.SourceStationId,
DestinationStationId = request.DestinationStationId, DestinationStationId = request.DestinationStationId,
ItemId = request.ItemId, ItemId = request.ItemId,
NodeId = request.NodeId, AnchorId = request.AnchorId,
ConstructionSiteId = request.ConstructionSiteId, ConstructionSiteId = request.ConstructionSiteId,
ModuleId = request.ModuleId, ModuleId = request.ModuleId,
WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f), WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f),
@@ -654,12 +699,7 @@ internal sealed class PlayerFactionService
AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Name}.", "ship", shipId); AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Name}.", "ship", shipId);
player.UpdatedAtUtc = DateTimeOffset.UtcNow; player.UpdatedAtUtc = DateTimeOffset.UtcNow;
ship.ControlSourceKind = "player-order"; ship.ControlSourceKind = "player-order";
ship.ControlSourceId = ship.OrderQueue ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
ship.ControlReason = request.Label ?? request.Kind; ship.ControlReason = request.Label ?? request.Kind;
ship.NeedsReplan = true; ship.NeedsReplan = true;
ship.LastReplanReason = "player-order-enqueued"; 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) 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)) if (!player.AssetRegistry.ShipIds.Contains(shipId))
{ {
return null; return null;
@@ -681,28 +721,18 @@ internal sealed class PlayerFactionService
return null; return null;
} }
var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId); var removed = ship.OrderQueue.RemoveById(orderId);
if (removed > 0) if (removed)
{ {
AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Name}.", "ship", shipId); AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Name}.", "ship", shipId);
player.UpdatedAtUtc = DateTimeOffset.UtcNow; player.UpdatedAtUtc = DateTimeOffset.UtcNow;
} }
ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player) ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
? "player-order" ? "player-order"
: "player-manual"; : "player-manual";
ship.ControlSourceId = ship.OrderQueue ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
.Where(order => order.SourceKind == ShipOrderSourceKind.Player) ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(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()
?? "manual-player-control"; ?? "manual-player-control";
ship.NeedsReplan = true; ship.NeedsReplan = true;
ship.LastReplanReason = "player-order-removed"; ship.LastReplanReason = "player-order-removed";
@@ -710,9 +740,96 @@ internal sealed class PlayerFactionService
return ship; 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) 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)) if (!player.AssetRegistry.ShipIds.Contains(shipId))
{ {
return null; return null;
@@ -755,7 +872,7 @@ internal sealed class PlayerFactionService
directive.SourceStationId = request.HomeStationId; directive.SourceStationId = request.HomeStationId;
directive.DestinationStationId = null; directive.DestinationStationId = null;
directive.ItemId = request.ItemId; directive.ItemId = request.ItemId;
directive.PreferredNodeId = request.PreferredNodeId; directive.PreferredAnchorId = request.PreferredAnchorId;
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId; directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
directive.PreferredModuleId = request.PreferredModuleId; directive.PreferredModuleId = request.PreferredModuleId;
directive.Priority = 100; directive.Priority = 100;
@@ -781,7 +898,7 @@ internal sealed class PlayerFactionService
SourceStationId = template.SourceStationId, SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId, DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId, ItemId = template.ItemId,
NodeId = template.NodeId, AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId, ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId, ModuleId = template.ModuleId,
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
@@ -852,6 +969,24 @@ internal sealed class PlayerFactionService
private static void SyncRegistry(SimulationWorld world, PlayerFactionRuntime player) 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.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.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)); 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 player.AutomationPolicies.FirstOrDefault(policy => policy.Id == automationId);
} }
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId) return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId);
?? player.AutomationPolicies.FirstOrDefault(policy => policy.Id == "player-core-automation");
} }
private static PlayerFactionPolicyRuntime? ResolvePolicy(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string 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" ? "player-directive"
: automation is not null : automation is not null
? "player-automation" ? "player-automation"
: ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player) : ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
? "player-order" ? "player-order"
: "player-manual"; : "player-manual";
var desiredControlSourceId = directive?.Id var desiredControlSourceId = directive?.Id
?? automation?.Id ?? automation?.Id
?? ship.OrderQueue ?? ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
var desiredControlReason = directive?.Label var desiredControlReason = directive?.Label
?? automation?.Label ?? automation?.Label
?? ship.OrderQueue ?? ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Label ?? order.Kind)
.FirstOrDefault()
?? (hasBehaviorSource ? "delegated-player-control" : "manual-player-control"); ?? (hasBehaviorSource ? "delegated-player-control" : "manual-player-control");
var assignmentChanged = !AssignmentsEqual(commander.Assignment, desiredAssignment); var assignmentChanged = !AssignmentsEqual(commander.Assignment, desiredAssignment);
@@ -1351,7 +1475,7 @@ internal sealed class PlayerFactionService
AreaSystemId = directive?.TargetSystemId ?? directive?.HomeSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId, AreaSystemId = directive?.TargetSystemId ?? directive?.HomeSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId,
TargetEntityId = directive?.TargetEntityId, TargetEntityId = directive?.TargetEntityId,
ItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.ItemId, ItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.ItemId,
PreferredNodeId = directive?.PreferredNodeId ?? ship.DefaultBehavior.PreferredNodeId, PreferredAnchorId = directive?.PreferredAnchorId ?? ship.DefaultBehavior.PreferredAnchorId,
PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId, PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId,
PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId, PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId,
TargetPosition = directive?.TargetPosition, TargetPosition = directive?.TargetPosition,
@@ -1371,7 +1495,7 @@ internal sealed class PlayerFactionService
private static bool ReconcileDirectiveOrders(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation) private static bool ReconcileDirectiveOrders(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation)
{ {
var aiOrderId = directive is null ? null : $"player-order-{directive.Id}"; 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; var useOrders = directive?.UseOrders ?? automation?.UseOrders ?? false;
if (!useOrders || directive is null || string.IsNullOrWhiteSpace(directive.StagingOrderKind)) if (!useOrders || directive is null || string.IsNullOrWhiteSpace(directive.StagingOrderKind))
@@ -1394,7 +1518,7 @@ internal sealed class PlayerFactionService
SourceStationId = directive.SourceStationId ?? directive.HomeStationId, SourceStationId = directive.SourceStationId ?? directive.HomeStationId,
DestinationStationId = directive.DestinationStationId, DestinationStationId = directive.DestinationStationId,
ItemId = directive.ItemId, ItemId = directive.ItemId,
NodeId = directive.PreferredNodeId, AnchorId = directive.PreferredAnchorId,
ConstructionSiteId = directive.PreferredConstructionSiteId, ConstructionSiteId = directive.PreferredConstructionSiteId,
ModuleId = directive.PreferredModuleId, ModuleId = directive.PreferredModuleId,
WaitSeconds = directive.WaitSeconds, WaitSeconds = directive.WaitSeconds,
@@ -1403,17 +1527,16 @@ internal sealed class PlayerFactionService
KnownStationsOnly = directive.KnownStationsOnly, KnownStationsOnly = directive.KnownStationsOnly,
}; };
var existing = ship.OrderQueue.FirstOrDefault(order => order.Id == aiOrderId); var existing = ship.OrderQueue.FindById(aiOrderId!);
if (existing is null) if (existing is null)
{ {
ship.OrderQueue.Add(desiredOrder); ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
return true; return true;
} }
if (!ShipOrdersEqual(existing, desiredOrder)) if (!ShipOrdersEqual(existing, desiredOrder))
{ {
ship.OrderQueue.Remove(existing); ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
ship.OrderQueue.Add(desiredOrder);
return true; return true;
} }
@@ -1458,7 +1581,7 @@ internal sealed class PlayerFactionService
target.AreaSystemId = source.AreaSystemId; target.AreaSystemId = source.AreaSystemId;
target.TargetEntityId = source.TargetEntityId; target.TargetEntityId = source.TargetEntityId;
target.ItemId = source.ItemId; target.ItemId = source.ItemId;
target.PreferredNodeId = source.PreferredNodeId; target.PreferredAnchorId = source.PreferredAnchorId;
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId; target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
target.PreferredModuleId = source.PreferredModuleId; target.PreferredModuleId = source.PreferredModuleId;
target.TargetPosition = source.TargetPosition; target.TargetPosition = source.TargetPosition;
@@ -1479,7 +1602,7 @@ internal sealed class PlayerFactionService
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal) && string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
&& string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
&& string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal) && string.Equals(left.PreferredAnchorId, right.PreferredAnchorId, StringComparison.Ordinal)
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal) && string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal) && string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
&& Nullable.Equals(left.TargetPosition, right.TargetPosition) && Nullable.Equals(left.TargetPosition, right.TargetPosition)
@@ -1500,7 +1623,7 @@ internal sealed class PlayerFactionService
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
&& string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) && string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal)
&& string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
&& left.WaitSeconds.Equals(right.WaitSeconds) && left.WaitSeconds.Equals(right.WaitSeconds)
@@ -1522,7 +1645,7 @@ internal sealed class PlayerFactionService
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
&& string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) && string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal)
&& string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
&& left.WaitSeconds.Equals(right.WaitSeconds) && left.WaitSeconds.Equals(right.WaitSeconds)
@@ -1567,7 +1690,7 @@ internal sealed class PlayerFactionService
SourceStationId = template.SourceStationId, SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId, DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId, ItemId = template.ItemId,
NodeId = template.NodeId, AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId, ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId, ModuleId = template.ModuleId,
WaitSeconds = template.WaitSeconds, WaitSeconds = template.WaitSeconds,

View File

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

View File

@@ -6,7 +6,7 @@ using Microsoft.IdentityModel.Tokens;
using Npgsql; using Npgsql;
using SpaceGame.Api.Universe.Bootstrap; using SpaceGame.Api.Universe.Bootstrap;
const string StartupScenarioPath = "scenarios/empty.json"; const string StartupScenarioPath = "scenarios/minimal.json";
var builder = WebApplication.CreateBuilder(args); 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 AdvancedAutoMine = "advanced-auto-mine";
public const string ExpertAutoMine = "expert-auto-mine"; public const string ExpertAutoMine = "expert-auto-mine";
public const string DockAndWait = "dock-and-wait"; public const string DockAtStation = "dock-at-station";
public const string FlyAndWait = "fly-and-wait"; public const string Move = "move";
public const string FlyToObject = "fly-to-object"; public const string FlyToObject = "fly-to-object";
public const string FollowShip = "follow-ship"; public const string FollowShip = "follow-ship";
public const string HoldPosition = "hold-position"; public const string HoldPosition = "hold-position";
@@ -60,29 +60,29 @@ public static class ShipAutomationCatalog
{ {
public static readonly IReadOnlyList<ShipBehaviorDefinition> Behaviors = 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.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.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.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.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.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.DockAtStation, "Dock At Station", "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.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.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.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.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.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.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.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.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.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."), 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 = public static readonly IReadOnlyList<ShipOrderDefinition> Orders =
[ [
new(ShipOrderKinds.DockAndWait, "Dock 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.FlyAndWait, "Fly To And Wait", "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.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.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.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."), new(ShipOrderKinds.AttackTarget, "Attack Target", "Combat", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),

View File

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

View File

@@ -7,6 +7,22 @@ public static class SimulationUnits
public static float AuToKilometers(float au) => au * KilometersPerAu; 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) => public static float AuPerSecondToKilometersPerSecond(float auPerSecond) =>
auPerSecond * KilometersPerAu; auPerSecond * KilometersPerAu;

View File

@@ -6,27 +6,12 @@ namespace SpaceGame.Api.Ships.AI;
public sealed partial class ShipAiService 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) private void SyncBehaviorOrders(SimulationWorld world, ShipRuntime ship)
{ {
var desiredOrder = BuildManagedBehaviorOrder(world, ship); var desiredOrder = BuildManagedBehaviorOrder(world, ship);
ship.OrderQueue.RemoveAll(order => ship.OrderQueue.RemoveWhere(order =>
order.SourceKind == ShipOrderSourceKind.Behavior order.SourceKind == ShipOrderSourceKind.Behavior
&& order.Id.StartsWith("behavior-", StringComparison.Ordinal)
&& (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))); && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)));
if (desiredOrder is null) if (desiredOrder is null)
@@ -34,10 +19,10 @@ public sealed partial class ShipAiService
return; 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) if (existing is null)
{ {
ship.OrderQueue.Add(desiredOrder); ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
return; return;
} }
@@ -46,8 +31,7 @@ public sealed partial class ShipAiService
return; return;
} }
ship.OrderQueue.Remove(existing); ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
ship.OrderQueue.Add(desiredOrder);
} }
private ShipOrderRuntime? BuildManagedBehaviorOrder(SimulationWorld world, ShipRuntime ship) 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); var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? ship.DefaultBehavior.HomeStationId);
if (station is null) if (station is null)
@@ -88,38 +72,36 @@ public sealed partial class ShipAiService
ship.LastAccessFailureReason = null; ship.LastAccessFailureReason = null;
return new ShipOrderRuntime return new ShipOrderRuntime
{ {
Id = $"behavior-{ship.Id}-dock-and-wait", Id = $"behavior-{ship.Id}-dock-at-station",
Kind = ShipOrderKinds.DockAndWait, Kind = ShipOrderKinds.DockAtStation,
SourceKind = ShipOrderSourceKind.Behavior, SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind, SourceId = behaviorKind,
Priority = 0, Priority = 0,
InterruptCurrentPlan = false, InterruptCurrentPlan = false,
Label = $"Dock and wait at {station.Label}", Label = $"Dock at {station.Label}",
TargetEntityId = station.Id, TargetEntityId = station.Id,
TargetSystemId = station.SystemId, TargetSystemId = station.SystemId,
DestinationStationId = station.Id, DestinationStationId = station.Id,
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
Radius = ship.DefaultBehavior.Radius, Radius = ship.DefaultBehavior.Radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
}; };
} }
if (string.Equals(behaviorKind, FlyAndWait, StringComparison.Ordinal)) if (string.Equals(behaviorKind, Move, StringComparison.Ordinal))
{ {
ship.LastAccessFailureReason = null; ship.LastAccessFailureReason = null;
return new ShipOrderRuntime return new ShipOrderRuntime
{ {
Id = $"behavior-{ship.Id}-fly-and-wait", Id = $"behavior-{ship.Id}-move",
Kind = ShipOrderKinds.FlyAndWait, Kind = ShipOrderKinds.Move,
SourceKind = ShipOrderSourceKind.Behavior, SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind, SourceId = behaviorKind,
Priority = 0, Priority = 0,
InterruptCurrentPlan = false, InterruptCurrentPlan = false,
Label = "Fly and wait", Label = "Fly to position",
TargetSystemId = systemId, TargetSystemId = systemId,
TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position, TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position,
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
Radius = ship.DefaultBehavior.Radius, Radius = ship.DefaultBehavior.Radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
@@ -288,7 +270,7 @@ public sealed partial class ShipAiService
TargetSystemId = opportunity.Node.SystemId, TargetSystemId = opportunity.Node.SystemId,
DestinationStationId = opportunity.DropOffStation.Id, DestinationStationId = opportunity.DropOffStation.Id,
ItemId = opportunity.Node.ItemId, ItemId = opportunity.Node.ItemId,
NodeId = opportunity.Node.Id, AnchorId = opportunity.Node.AnchorId,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
}; };
@@ -306,13 +288,12 @@ public sealed partial class ShipAiService
} }
ship.LastAccessFailureReason = null; ship.LastAccessFailureReason = null;
return CreateManagedFlyAndWaitOrder( return CreateManagedMoveOrder(
ship, ship,
behaviorKind, behaviorKind,
"Protect position", "Protect position",
targetSystemId, targetSystemId,
targetPosition, targetPosition,
MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
MathF.Max(6f, ship.DefaultBehavior.Radius)); MathF.Max(6f, ship.DefaultBehavior.Radius));
} }
@@ -365,13 +346,12 @@ public sealed partial class ShipAiService
} }
ship.LastAccessFailureReason = null; ship.LastAccessFailureReason = null;
return CreateManagedFlyAndWaitOrder( return CreateManagedMoveOrder(
ship, ship,
behaviorKind, behaviorKind,
$"Guard {station.Label}", $"Guard {station.Label}",
station.SystemId, station.SystemId,
GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)), 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)); MathF.Max(6f, ship.DefaultBehavior.Radius));
} }
@@ -410,7 +390,7 @@ public sealed partial class ShipAiService
&& SelectKnownStationVisit(world, ship, homeStation) is { } visitStation) && SelectKnownStationVisit(world, ship, homeStation) is { } visitStation)
{ {
ship.LastAccessFailureReason = null; 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"; ship.LastAccessFailureReason = "no-trade-route";
@@ -509,7 +489,7 @@ public sealed partial class ShipAiService
SourceStationId = template.SourceStationId, SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId, DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId, ItemId = template.ItemId,
NodeId = template.NodeId, AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId, ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId, ModuleId = template.ModuleId,
WaitSeconds = template.WaitSeconds, WaitSeconds = template.WaitSeconds,
@@ -561,7 +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) if (node is null)
{ {
ship.LastAccessFailureReason = "no-mineable-node"; ship.LastAccessFailureReason = "no-mineable-node";
@@ -578,8 +558,9 @@ public sealed partial class ShipAiService
Priority = 0, Priority = 0,
InterruptCurrentPlan = false, InterruptCurrentPlan = false,
Label = $"Mine {itemId} in {systemId}", Label = $"Mine {itemId} in {systemId}",
TargetEntityId = node.Id,
TargetSystemId = node.SystemId, TargetSystemId = node.SystemId,
NodeId = node.Id, AnchorId = node.AnchorId,
ItemId = node.ItemId, ItemId = node.ItemId,
WaitSeconds = 0f, WaitSeconds = 0f,
Radius = 0f, Radius = 0f,
@@ -601,7 +582,7 @@ public sealed partial class ShipAiService
&& left.TargetPosition == right.TargetPosition && left.TargetPosition == right.TargetPosition
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
&& string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) && string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal)
&& left.WaitSeconds.Equals(right.WaitSeconds) && left.WaitSeconds.Equals(right.WaitSeconds)
&& left.Radius.Equals(right.Radius) && left.Radius.Equals(right.Radius)
&& left.MaxSystemRange == right.MaxSystemRange && left.MaxSystemRange == right.MaxSystemRange
@@ -640,7 +621,7 @@ public sealed partial class ShipAiService
} }
ship.LastAccessFailureReason = null; 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( private static ShipOrderRuntime CreateManagedAttackOrder(
@@ -686,11 +667,11 @@ public sealed partial class ShipAiService
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, 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() new()
{ {
Id = $"behavior-{ship.Id}-{behaviorKind}-dock-and-wait", Id = $"behavior-{ship.Id}-{behaviorKind}-dock-at-station",
Kind = ShipOrderKinds.DockAndWait, Kind = ShipOrderKinds.DockAtStation,
SourceKind = ShipOrderSourceKind.Behavior, SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind, SourceId = behaviorKind,
Priority = 0, Priority = 0,
@@ -699,25 +680,23 @@ public sealed partial class ShipAiService
TargetEntityId = station.Id, TargetEntityId = station.Id,
TargetSystemId = station.SystemId, TargetSystemId = station.SystemId,
DestinationStationId = station.Id, DestinationStationId = station.Id,
WaitSeconds = waitSeconds,
Radius = ship.DefaultBehavior.Radius, Radius = ship.DefaultBehavior.Radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
}; };
private static ShipOrderRuntime CreateManagedFlyAndWaitOrder( private static ShipOrderRuntime CreateManagedMoveOrder(
ShipRuntime ship, ShipRuntime ship,
string behaviorKind, string behaviorKind,
string label, string label,
string targetSystemId, string targetSystemId,
Vector3 targetPosition, Vector3 targetPosition,
float waitSeconds,
float radius, float radius,
string? orderIdSuffix = null) => string? orderIdSuffix = null) =>
new() new()
{ {
Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}", Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}",
Kind = ShipOrderKinds.FlyAndWait, Kind = ShipOrderKinds.Move,
SourceKind = ShipOrderSourceKind.Behavior, SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind, SourceId = behaviorKind,
Priority = 0, Priority = 0,
@@ -725,7 +704,6 @@ public sealed partial class ShipAiService
Label = label, Label = label,
TargetSystemId = targetSystemId, TargetSystemId = targetSystemId,
TargetPosition = targetPosition, TargetPosition = targetPosition,
WaitSeconds = waitSeconds,
Radius = radius, Radius = radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,

View File

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

View File

@@ -9,6 +9,11 @@ public sealed partial class ShipAiService
{ {
private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ShipSubTaskRuntime subTask) private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ShipSubTaskRuntime subTask)
{ {
if (!string.IsNullOrWhiteSpace(subTask.TargetAnchorId) && ResolveAnchor(world, subTask.TargetAnchorId) is not null)
{
return subTask.TargetPosition ?? Vector3.Zero;
}
if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId))
{ {
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
@@ -44,15 +49,20 @@ public sealed partial class ShipAiService
if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId))
{ {
var station = ResolveStation(world, subTask.TargetEntityId); var station = ResolveStation(world, subTask.TargetEntityId);
if (station?.CelestialId is not null) if (station?.AnchorId is not null)
{ {
return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId); return ResolveAnchorBackedCelestial(world, station.AnchorId);
} }
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
if (site?.CelestialId is not null) if (site?.AnchorId is not null)
{ {
return world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); return ResolveAnchorBackedCelestial(world, site.AnchorId);
}
if (ResolveAnchor(world, subTask.TargetEntityId) is { } anchorBackedCelestialTarget)
{
return ResolveAnchorBackedCelestial(world, anchorBackedCelestialTarget.Id);
} }
var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
@@ -76,27 +86,149 @@ public sealed partial class ShipAiService
.FirstOrDefault(); .FirstOrDefault();
} }
private static AnchorRuntime? ResolveTravelTargetAnchor(SimulationWorld world, ShipSubTaskRuntime subTask, Vector3 targetPosition)
{
if (!string.IsNullOrWhiteSpace(subTask.TargetAnchorId) && ResolveAnchor(world, subTask.TargetAnchorId) is { } explicitTargetAnchor)
{
return explicitTargetAnchor;
}
if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId))
{
var station = ResolveStation(world, subTask.TargetEntityId);
if (station?.AnchorId is not null)
{
return ResolveAnchor(world, station.AnchorId);
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
if (site?.AnchorId is not null)
{
return ResolveAnchor(world, site.AnchorId);
}
var node = ResolveNode(world, subTask.TargetEntityId);
if (node is not null)
{
return ResolveAnchor(world, node.AnchorId);
}
if (ResolveAnchor(world, subTask.TargetEntityId) is { } directAnchor)
{
return directAnchor;
}
if (world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } celestial)
{
return ResolveAnchor(world, celestial.Id);
}
if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } wreck)
{
return world.Anchors
.Where(candidate => candidate.SystemId == wreck.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(wreck.Position))
.FirstOrDefault();
}
}
return world.Anchors
.Where(candidate => subTask.TargetSystemId is null || candidate.SystemId == subTask.TargetSystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(targetPosition))
.FirstOrDefault();
}
private static AnchorRuntime? ResolveCurrentAnchor(SimulationWorld world, ShipRuntime ship)
{
if (ship.SpatialState.CurrentAnchorId is not null && ResolveAnchor(world, ship.SpatialState.CurrentAnchorId) is { } explicitAnchor)
{
return explicitAnchor;
}
if (ship.DockedStationId is not null && ResolveStation(world, ship.DockedStationId)?.AnchorId is { } dockAnchorId)
{
return ResolveAnchor(world, dockAnchorId);
}
return world.Anchors
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ResolveShipSystemPosition(world, ship)))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship) private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship)
{ {
if (ship.SpatialState.CurrentCelestialId is not null) if (ship.SpatialState.CurrentAnchorId is not null && ResolveAnchorBackedCelestial(world, ship.SpatialState.CurrentAnchorId) is { } currentAnchorCelestial)
{ {
return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId); return currentAnchorCelestial;
} }
return world.Celestials return world.Celestials
.Where(candidate => candidate.SystemId == ship.SystemId) .Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position)) .OrderBy(candidate => candidate.Position.DistanceTo(ResolveShipSystemPosition(world, ship)))
.FirstOrDefault(); .FirstOrDefault();
} }
private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) => private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) =>
world.Celestials.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star); world.Celestials.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star);
private static AnchorRuntime? ResolveSystemEntryAnchor(SimulationWorld world, string systemId) =>
world.Anchors.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star);
private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) => private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) =>
world.Systems.FirstOrDefault(candidate => candidate.Definition.Id == systemId)?.Position ?? Vector3.Zero; world.Systems.FirstOrDefault(candidate => candidate.Definition.Id == systemId)?.Position ?? Vector3.Zero;
private static Vector3 ResolveAnchorPosition(SimulationWorld world, string? anchorId, Vector3 fallbackPosition) =>
ResolveAnchor(world, anchorId)?.Position ?? fallbackPosition;
private static Vector3 ResolveStationSystemPosition(SimulationWorld world, StationRuntime station)
{
if (station.AnchorId is not null && ResolveAnchor(world, station.AnchorId) is { } anchor)
{
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) => 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) => private static float GetWarpTravelSpeed(ShipRuntime ship) =>
SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed) * GetSkillFactor(ship.Skills.Navigation); 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 policy = ResolvePolicy(world, ship.PolicySetId);
var preferredItemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId; var preferredItemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId;
var preferredAnchorId = ship.DefaultBehavior.PreferredAnchorId;
var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange);
var effectiveMiningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); var effectiveMiningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination);
string? deniedReason = null; string? deniedReason = null;
@@ -194,6 +327,11 @@ public sealed partial class ShipAiService
return false; return false;
} }
if (preferredAnchorId is not null && !string.Equals(node.AnchorId, preferredAnchorId, StringComparison.Ordinal))
{
return false;
}
if (!TryCheckSystemAllowed(world, policy, ship.FactionId, node.SystemId, "military", out var reason)) if (!TryCheckSystemAllowed(world, policy, ship.FactionId, node.SystemId, "military", out var reason))
{ {
deniedReason ??= reason; deniedReason ??= reason;
@@ -214,7 +352,7 @@ public sealed partial class ShipAiService
+ (effectiveMiningSkill * 10f) + (effectiveMiningSkill * 10f)
- distancePenalty - distancePenalty
- routeRiskPenalty - routeRiskPenalty
- node.Position.DistanceTo(ship.Position); - ResolveNodeSystemPosition(world, node).DistanceTo(ResolveShipSystemPosition(world, ship));
return new MiningOpportunity(node, buyer, score, $"Mine {node.ItemId} for {buyer.Label}"); return new MiningOpportunity(node, buyer, score, $"Mine {node.ItemId} for {buyer.Label}");
}) })
.OrderByDescending(candidate => candidate.Score) .OrderByDescending(candidate => candidate.Score)
@@ -452,7 +590,7 @@ public sealed partial class ShipAiService
?? homeStation; ?? homeStation;
} }
private static ResourceNodeRuntime? SelectLocalMiningNode(SimulationWorld world, ShipRuntime ship, string systemId, string itemId) private static ResourceNodeRuntime? SelectLocalMiningNode(SimulationWorld world, ShipRuntime ship, string systemId, string itemId, string? anchorId = null)
{ {
var policy = ResolvePolicy(world, ship.PolicySetId); var policy = ResolvePolicy(world, ship.PolicySetId);
string? deniedReason = null; string? deniedReason = null;
@@ -467,6 +605,11 @@ public sealed partial class ShipAiService
return false; return false;
} }
if (anchorId is not null && !string.Equals(candidate.AnchorId, anchorId, StringComparison.Ordinal))
{
return false;
}
if (!TryCheckSystemAllowed(world, policy, ship.FactionId, candidate.SystemId, "military", out var reason)) if (!TryCheckSystemAllowed(world, policy, ship.FactionId, candidate.SystemId, "military", out var reason))
{ {
deniedReason ??= reason; deniedReason ??= reason;
@@ -487,6 +630,54 @@ public sealed partial class ShipAiService
return node; return node;
} }
private static ResourceDepositRuntime? ResolveResourceDeposit(SimulationWorld world, string? depositId)
{
if (string.IsNullOrWhiteSpace(depositId))
{
return null;
}
foreach (var node in world.Nodes)
{
var deposit = node.Deposits.FirstOrDefault(candidate => string.Equals(candidate.Id, depositId, StringComparison.Ordinal));
if (deposit is not null)
{
return deposit;
}
}
return null;
}
private static ResourceDepositRuntime? SelectMiningDeposit(ResourceNodeRuntime node, string shipId)
{
return node.Deposits
.Where(candidate => candidate.OreRemaining > 0.01f)
.OrderByDescending(candidate => candidate.OreRemaining)
.ThenBy(candidate => candidate.Id, StringComparer.Ordinal)
.FirstOrDefault();
}
private static void SyncNodeOreTotals(ResourceNodeRuntime node)
{
node.OreRemaining = node.Deposits.Sum(candidate => candidate.OreRemaining);
}
private static AnchorRuntime? ResolveMiningAnchor(SimulationWorld world, string? anchorId, string? nodeId)
{
if (anchorId is not null)
{
return ResolveAnchor(world, anchorId);
}
if (nodeId is not null && ResolveNode(world, nodeId) is { } node)
{
return ResolveAnchor(world, node.AnchorId);
}
return null;
}
private static StationRuntime? SelectLocalAutoMineBuyer(SimulationWorld world, ShipRuntime ship, string systemId, string itemId) private static StationRuntime? SelectLocalAutoMineBuyer(SimulationWorld world, ShipRuntime ship, string systemId, string itemId)
{ {
var policy = ResolvePolicy(world, ship.PolicySetId); var policy = ResolvePolicy(world, ship.PolicySetId);
@@ -686,9 +877,14 @@ public sealed partial class ShipAiService
return (celestial.SystemId, celestial.Position); return (celestial.SystemId, celestial.Position);
} }
if (ResolveAnchor(world, entityId) is { } anchor)
{
return (anchor.SystemId, anchor.Position);
}
if (world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId) is { } site) if (world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId) is { } site)
{ {
var position = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? Vector3.Zero; var position = ResolveAnchor(world, site.AnchorId)?.Position ?? Vector3.Zero;
return (site.SystemId, position); return (site.SystemId, position);
} }
@@ -720,6 +916,16 @@ public sealed partial class ShipAiService
private static StationRuntime? ResolveStation(SimulationWorld world, string? stationId) => private static StationRuntime? ResolveStation(SimulationWorld world, string? stationId) =>
stationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == stationId); stationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == stationId);
private static AnchorRuntime? ResolveAnchor(SimulationWorld world, string? anchorId) =>
anchorId is null ? null : world.Anchors.FirstOrDefault(candidate => candidate.Id == anchorId);
private static CelestialRuntime? ResolveAnchorBackedCelestial(SimulationWorld world, string? anchorId)
{
var anchor = ResolveAnchor(world, anchorId);
var celestialId = SpatialBuilder.ResolveCompatibleCelestialId(anchor);
return celestialId is null ? null : world.Celestials.FirstOrDefault(candidate => candidate.Id == celestialId);
}
private static ResourceNodeRuntime? ResolveNode(SimulationWorld world, string? nodeId) => private static ResourceNodeRuntime? ResolveNode(SimulationWorld world, string? nodeId) =>
nodeId is null ? null : world.Nodes.FirstOrDefault(candidate => candidate.Id == nodeId); nodeId is null ? null : world.Nodes.FirstOrDefault(candidate => candidate.Id == nodeId);
@@ -793,9 +999,6 @@ public sealed partial class ShipAiService
? null ? null
: world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment; : 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) private static StationRuntime? ResolveSupportStation(SimulationWorld world, ShipRuntime ship, ConstructionSiteRuntime site)
{ {
return ResolveStation(world, ResolveAssignment(world, ship)?.HomeStationId ?? ship.DefaultBehavior.HomeStationId) 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) if (site?.StationId is null && site is not null)
{ {
var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position; var anchorPosition = ResolveAnchor(world, site.AnchorId)?.Position
?? station.Position;
return GetResourceHoldPosition(anchorPosition, ship.Id, 78f); return GetResourceHoldPosition(anchorPosition, ship.Id, 78f);
} }
@@ -827,47 +1031,46 @@ public sealed partial class ShipAiService
private static void TrackHistory(ShipRuntime ship) private static void TrackHistory(ShipRuntime ship)
{ {
var plan = ship.ActivePlan; var orderId = ship.ActiveOrderId ?? "none";
var step = GetCurrentStep(plan); var subTask = ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex];
var subTask = step is null || step.CurrentSubTaskIndex >= step.SubTasks.Count ? null : step.SubTasks[step.CurrentSubTaskIndex]; var signature = $"{ship.State.ToContractValue()}|{orderId}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}";
var signature = $"{ship.State.ToContractValue()}|{plan?.Kind ?? "none"}|{step?.Kind ?? "none"}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}";
if (ship.LastSignature == signature) if (ship.LastSignature == signature)
{ {
return; return;
} }
ship.LastSignature = signature; 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) if (ship.History.Count > 24)
{ {
ship.History.RemoveAt(0); 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 currentOrderId = ship.ActiveOrderId;
var currentStepId = GetCurrentStep(ship.ActivePlan)?.Id; var currentTaskId = ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex].Id;
var occurredAtUtc = DateTimeOffset.UtcNow; var occurredAtUtc = DateTimeOffset.UtcNow;
if (previousState != ship.State) if (previousState != ship.State)
{ {
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Name} state {previousState.ToContractValue()} -> {ship.State.ToContractValue()}.", occurredAtUtc)); 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) private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site)
{ {
var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); var anchor = ResolveAnchor(world, site.AnchorId);
if (anchor is null || site.BlueprintId is null) if (anchor is null || site.BlueprintId is null)
{ {
site.State = ConstructionSiteStateKinds.Destroyed; site.State = ConstructionSiteStateKinds.Destroyed;
@@ -878,13 +1081,13 @@ public sealed partial class ShipAiService
{ {
Id = $"station-{world.Stations.Count + 1}", Id = $"station-{world.Stations.Count + 1}",
SystemId = site.SystemId, SystemId = site.SystemId,
AnchorId = site.AnchorId,
Label = BuildFoundedStationLabel(site.TargetDefinitionId), Label = BuildFoundedStationLabel(site.TargetDefinitionId),
Category = "station", Category = "station",
Objective = DetermineFoundationObjective(site.TargetDefinitionId), Objective = DetermineFoundationObjective(site.TargetDefinitionId),
Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color, Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color,
Position = anchor.Position, Position = Vector3.Zero,
FactionId = site.FactionId, FactionId = site.FactionId,
CelestialId = site.CelestialId,
Health = 600f, Health = 600f,
MaxHealth = 600f, MaxHealth = 600f,
}; };

View File

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

View File

@@ -1,5 +1,4 @@
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Stations.Simulation.StationSimulationService; using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
@@ -7,7 +6,7 @@ namespace SpaceGame.Api.Ships.AI;
public sealed partial class ShipAiService 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); var policy = ResolvePolicy(world, ship.PolicySetId);
if (policy is null) if (policy is null)
@@ -37,86 +36,75 @@ public sealed partial class ShipAiService
.ThenBy(station => station.Position.DistanceTo(ship.Position)) .ThenBy(station => station.Position.DistanceTo(ship.Position))
.FirstOrDefault(); .FirstOrDefault();
var plan = new ShipPlanRuntime return new ShipOrderRuntime
{ {
Id = $"plan-{ship.Id}-safety-{Guid.NewGuid():N}", Id = $"rule-{ship.Id}-flee",
SourceKind = AiPlanSourceKind.Rule, Kind = ShipOrderKinds.Flee,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = ShipOrderKinds.Flee, SourceId = ShipOrderKinds.Flee,
Kind = "safety-flee", Priority = 1000,
Summary = "Emergency retreat", 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 return order.Kind switch
{ {
var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMovePlan(ship, order), var kind when string.Equals(kind, ShipOrderKinds.Flee, StringComparison.Ordinal) => BuildFleeSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderPlan(world, ship, order), var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMoveSubTasks(ship, order),
var kind when string.Equals(kind, ShipOrderKinds.DockAndWait, StringComparison.Ordinal) => BuildDockAndWaitOrderPlan(world, ship, order), var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderSubTasks(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) => BuildFlyToObjectOrderSubTasks(world, 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) => BuildFollowShipOrderSubTasks(world, 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) => BuildTradeOrderSubTasks(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) => BuildMineOrderSubTasks(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) => BuildMineLocalOrderSubTasks(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) => BuildMineAndDeliverRunOrderSubTasks(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) => BuildSellMinedCargoOrderSubTasks(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) => BuildSupplyFleetOrderSubTasks(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) => BuildAutoSalvageOrderSubTasks(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) => BuildBuildOrderSubTasks(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) => BuildAttackOrderSubTasks(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) => BuildHoldSubTasks(ship, order),
var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldOrderPlan(ship, order),
_ => null, _ => 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); var safeStation = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId);
return assignment is null if (safeStation is null)
? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind) {
: (assignment.BehaviorKind, assignment.ObjectiveId); return
[
CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f),
];
}
return
[
CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), 0f),
CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f),
];
} }
private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order) private static IReadOnlyList<ShipSubTaskRuntime> BuildMoveSubTasks(ShipRuntime ship, ShipOrderRuntime order)
{ {
var targetSystemId = order.TargetSystemId ?? ship.SystemId; var targetSystemId = order.TargetSystemId ?? ship.SystemId;
var targetPosition = order.TargetPosition ?? ship.Position; var targetPosition = order.TargetPosition ?? ship.Position;
return CreatePlan( return
ship,
AiPlanSourceKind.Order,
order.Id,
ShipOrderKinds.Move,
order.Label ?? "Move order",
[
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); var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId);
if (station is null) if (station is null)
@@ -125,25 +113,14 @@ public sealed partial class ShipAiService
return null; return null;
} }
return CreatePlan( return
ship,
AiPlanSourceKind.Order,
order.Id,
"dock-at-station",
order.Label ?? $"Dock at {station.Label}",
[
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) 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),
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)
])
]);
} }
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) 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 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 systemId = order.TargetSystemId ?? ship.SystemId;
var itemId = order.ItemId; var itemId = order.ItemId;
@@ -171,7 +148,8 @@ public sealed partial class ShipAiService
return null; return null;
} }
var node = ResolveNode(world, order.NodeId); var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId);
var node = ResolveNode(world, order.TargetEntityId);
if (node is not null) if (node is not null)
{ {
if (!string.Equals(node.SystemId, systemId, StringComparison.Ordinal)) if (!string.Equals(node.SystemId, systemId, StringComparison.Ordinal))
@@ -188,7 +166,7 @@ public sealed partial class ShipAiService
} }
else else
{ {
node = SelectLocalMiningNode(world, ship, systemId, itemId); node = SelectLocalMiningNode(world, ship, systemId, itemId, anchor?.Id);
} }
if (node is null) if (node is null)
@@ -197,24 +175,30 @@ public sealed partial class ShipAiService
return null; return null;
} }
return BuildLocalMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {itemId} in {systemId}"); return 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) if (node is null)
{ {
order.FailureReason = "mine-order-incomplete"; order.FailureReason = "mine-order-incomplete";
return null; return null;
} }
return BuildLocalMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {node.ItemId}"); return 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); var buyer = ResolveStation(world, order.DestinationStationId);
if (node is null || buyer is null) if (node is null || buyer is null)
{ {
@@ -222,10 +206,10 @@ public sealed partial class ShipAiService
return null; return null;
} }
return BuildMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, buyer, order.Label ?? $"Mine {node.ItemId} for {buyer.Label}"); return 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); var buyer = ResolveStation(world, order.DestinationStationId ?? order.TargetEntityId);
if (buyer is null || string.IsNullOrWhiteSpace(order.ItemId)) if (buyer is null || string.IsNullOrWhiteSpace(order.ItemId))
@@ -234,10 +218,10 @@ public sealed partial class ShipAiService
return null; 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 homeStation = ResolveStation(world, order.SourceStationId ?? ship.DefaultBehavior.HomeStationId);
var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.RemainingAmount > 0.01f); 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)); var approach = GetFormationPosition(wreck.Position, ship.Id, MathF.Max(8f, order.Radius > 0f ? order.Radius : ship.DefaultBehavior.Radius * 0.25f));
return CreatePlan( return BuildSalvageSubTasks(ship, wreck, homeStation, approach);
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),
])
]);
} }
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 sourceStation = ResolveStation(world, order.SourceStationId);
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f); var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
@@ -296,10 +261,10 @@ public sealed partial class ShipAiService
amount, amount,
MathF.Max(16f, order.Radius), MathF.Max(16f, order.Radius),
order.Label ?? $"Supply {targetShip.Definition.Name} with {order.ItemId}"); 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)); var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (order.ConstructionSiteId ?? order.TargetEntityId));
if (site is null) if (site is null)
@@ -315,10 +280,10 @@ public sealed partial class ShipAiService
return null; 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; var targetId = order.TargetEntityId;
if (targetId is null) if (targetId is null)
@@ -327,45 +292,10 @@ public sealed partial class ShipAiService
return null; 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) private IReadOnlyList<ShipSubTaskRuntime>? BuildFlyToObjectOrderSubTasks(SimulationWorld world, 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)
{ {
var targetEntityId = order.TargetEntityId; var targetEntityId = order.TargetEntityId;
if (targetEntityId is null) if (targetEntityId is null)
@@ -381,10 +311,10 @@ public sealed partial class ShipAiService
return null; 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); var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
if (targetShip is null) if (targetShip is null)
@@ -393,69 +323,6 @@ public sealed partial class ShipAiService
return null; 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}"); return BuildFollowShipSubTasks(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)
])
]);
} }
} }

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ public sealed record ShipOrderCommandRequest(
string? SourceStationId, string? SourceStationId,
string? DestinationStationId, string? DestinationStationId,
string? ItemId, string? ItemId,
string? NodeId, string? AnchorId,
string? ConstructionSiteId, string? ConstructionSiteId,
string? ModuleId, string? ModuleId,
float? WaitSeconds, float? WaitSeconds,
@@ -19,6 +19,28 @@ public sealed record ShipOrderCommandRequest(
int? MaxSystemRange, int? MaxSystemRange,
bool? KnownStationsOnly); 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( public sealed record ShipOrderTemplateCommandRequest(
string Kind, string Kind,
string? Label, string? Label,
@@ -28,7 +50,7 @@ public sealed record ShipOrderTemplateCommandRequest(
string? SourceStationId, string? SourceStationId,
string? DestinationStationId, string? DestinationStationId,
string? ItemId, string? ItemId,
string? NodeId, string? AnchorId,
string? ConstructionSiteId, string? ConstructionSiteId,
string? ModuleId, string? ModuleId,
float? WaitSeconds, float? WaitSeconds,
@@ -43,7 +65,7 @@ public sealed record ShipDefaultBehaviorCommandRequest(
string? AreaSystemId, string? AreaSystemId,
string? TargetEntityId, string? TargetEntityId,
string? ItemId, string? ItemId,
string? PreferredNodeId, string? PreferredAnchorId,
string? PreferredConstructionSiteId, string? PreferredConstructionSiteId,
string? PreferredModuleId, string? PreferredModuleId,
Vector3Dto? TargetPosition, Vector3Dto? TargetPosition,

View File

@@ -23,7 +23,7 @@ public sealed record ShipOrderSnapshot(
string? SourceStationId, string? SourceStationId,
string? DestinationStationId, string? DestinationStationId,
string? ItemId, string? ItemId,
string? NodeId, string? AnchorId,
string? ConstructionSiteId, string? ConstructionSiteId,
string? ModuleId, string? ModuleId,
float WaitSeconds, float WaitSeconds,
@@ -41,7 +41,7 @@ public sealed record ShipOrderTemplateSnapshot(
string? SourceStationId, string? SourceStationId,
string? DestinationStationId, string? DestinationStationId,
string? ItemId, string? ItemId,
string? NodeId, string? AnchorId,
string? ConstructionSiteId, string? ConstructionSiteId,
string? ModuleId, string? ModuleId,
float WaitSeconds, float WaitSeconds,
@@ -56,7 +56,7 @@ public sealed record DefaultBehaviorSnapshot(
string? AreaSystemId, string? AreaSystemId,
string? TargetEntityId, string? TargetEntityId,
string? ItemId, string? ItemId,
string? PreferredNodeId, string? PreferredAnchorId,
string? PreferredConstructionSiteId, string? PreferredConstructionSiteId,
string? PreferredModuleId, string? PreferredModuleId,
Vector3Dto? TargetPosition, Vector3Dto? TargetPosition,
@@ -95,7 +95,9 @@ public sealed record ShipSubTaskSnapshot(
string Summary, string Summary,
string? TargetEntityId, string? TargetEntityId,
string? TargetSystemId, string? TargetSystemId,
string? TargetNodeId, string? TargetAnchorId,
string? TargetResourceNodeId,
string? TargetResourceDepositId,
Vector3Dto? TargetPosition, Vector3Dto? TargetPosition,
string? ItemId, string? ItemId,
string? ModuleId, string? ModuleId,
@@ -106,35 +108,13 @@ public sealed record ShipSubTaskSnapshot(
float TotalSeconds, float TotalSeconds,
string? BlockingReason); 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( public sealed record ShipSnapshot(
string Id, string Id,
string Name, string Name,
string Purpose, string Purpose,
string Type, string Type,
string SystemId, string SystemId,
string? AnchorId,
Vector3Dto LocalPosition, Vector3Dto LocalPosition,
Vector3Dto LocalVelocity, Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition, Vector3Dto TargetLocalPosition,
@@ -143,19 +123,17 @@ public sealed record ShipSnapshot(
DefaultBehaviorSnapshot DefaultBehavior, DefaultBehaviorSnapshot DefaultBehavior,
ShipAssignmentSnapshot? Assignment, ShipAssignmentSnapshot? Assignment,
ShipSkillProfileSnapshot Skills, ShipSkillProfileSnapshot Skills,
ShipPlanSnapshot? ActivePlan,
string? CurrentStepId,
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks, IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
string ControlSourceKind, string ControlSourceKind,
string? ControlSourceId, string? ControlSourceId,
string? ControlReason, string? ControlReason,
string? LastReplanReason, string? LastReplanReason,
string? LastAccessFailureReason, string? LastAccessFailureReason,
string? CelestialId,
string? DockedStationId, string? DockedStationId,
string? CommanderId, string? CommanderId,
string? PolicySetId, string? PolicySetId,
float CargoCapacity, float CargoCapacity,
IReadOnlyList<string> CargoTypes,
float TravelSpeed, float TravelSpeed,
string TravelSpeedUnit, string TravelSpeedUnit,
IReadOnlyList<InventoryEntry> Inventory, IReadOnlyList<InventoryEntry> Inventory,
@@ -170,6 +148,7 @@ public sealed record ShipDelta(
string Purpose, string Purpose,
string Type, string Type,
string SystemId, string SystemId,
string? AnchorId,
Vector3Dto LocalPosition, Vector3Dto LocalPosition,
Vector3Dto LocalVelocity, Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition, Vector3Dto TargetLocalPosition,
@@ -178,19 +157,17 @@ public sealed record ShipDelta(
DefaultBehaviorSnapshot DefaultBehavior, DefaultBehaviorSnapshot DefaultBehavior,
ShipAssignmentSnapshot? Assignment, ShipAssignmentSnapshot? Assignment,
ShipSkillProfileSnapshot Skills, ShipSkillProfileSnapshot Skills,
ShipPlanSnapshot? ActivePlan,
string? CurrentStepId,
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks, IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
string ControlSourceKind, string ControlSourceKind,
string? ControlSourceId, string? ControlSourceId,
string? ControlReason, string? ControlReason,
string? LastReplanReason, string? LastReplanReason,
string? LastAccessFailureReason, string? LastAccessFailureReason,
string? CelestialId,
string? DockedStationId, string? DockedStationId,
string? CommanderId, string? CommanderId,
string? PolicySetId, string? PolicySetId,
float CargoCapacity, float CargoCapacity,
IReadOnlyList<string> CargoTypes,
float TravelSpeed, float TravelSpeed,
string TravelSpeedUnit, string TravelSpeedUnit,
IReadOnlyList<InventoryEntry> Inventory, IReadOnlyList<InventoryEntry> Inventory,
@@ -202,17 +179,17 @@ public sealed record ShipDelta(
public sealed record ShipSpatialStateSnapshot( public sealed record ShipSpatialStateSnapshot(
string SpaceLayer, string SpaceLayer,
string CurrentSystemId, string CurrentSystemId,
string? CurrentCelestialId, string? CurrentAnchorId,
Vector3Dto? LocalPosition, Vector3Dto? LocalPosition,
Vector3Dto? SystemPosition, Vector3Dto? SystemPosition,
string MovementRegime, string MovementRegime,
string? DestinationNodeId, string? DestinationAnchorId,
ShipTransitSnapshot? Transit); ShipTransitSnapshot? Transit);
public sealed record ShipTransitSnapshot( public sealed record ShipTransitSnapshot(
string Regime, string Regime,
string? OriginNodeId, string? OriginAnchorId,
string? DestinationNodeId, string? DestinationAnchorId,
DateTimeOffset? StartedAtUtc, DateTimeOffset? StartedAtUtc,
DateTimeOffset? ArrivalDueAtUtc, DateTimeOffset? ArrivalDueAtUtc,
float Progress); float Progress);

View File

@@ -12,8 +12,7 @@ public sealed class ShipRuntime
public Vector3 Velocity { get; set; } = Vector3.Zero; public Vector3 Velocity { get; set; } = Vector3.Zero;
public ShipState State { get; set; } = ShipState.Idle; public ShipState State { get; set; } = ShipState.Idle;
public required DefaultBehaviorRuntime DefaultBehavior { get; set; } public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
public List<ShipOrderRuntime> OrderQueue { get; } = []; public ShipOrderQueue OrderQueue { get; } = new();
public ShipPlanRuntime? ActivePlan { get; set; }
public required ShipSkillProfileRuntime Skills { get; set; } public required ShipSkillProfileRuntime Skills { get; set; }
public bool NeedsReplan { get; set; } = true; public bool NeedsReplan { get; set; } = true;
public float ReplanCooldownSeconds { get; set; } public float ReplanCooldownSeconds { get; set; }
@@ -30,10 +29,190 @@ public sealed class ShipRuntime
public float Health { get; set; } public float Health { get; set; }
public HashSet<string> KnownStationIds { get; } = new(StringComparer.Ordinal); public HashSet<string> KnownStationIds { get; } = new(StringComparer.Ordinal);
public List<string> History { get; } = []; 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 LastSignature { get; set; } = string.Empty;
public string LastDeltaSignature { 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 sealed class ShipSkillProfileRuntime
{ {
public int Navigation { get; set; } public int Navigation { get; set; }
@@ -60,7 +239,7 @@ public sealed class ShipOrderRuntime
public string? SourceStationId { get; set; } public string? SourceStationId { get; set; }
public string? DestinationStationId { get; set; } public string? DestinationStationId { get; set; }
public string? ItemId { get; set; } public string? ItemId { get; set; }
public string? NodeId { get; set; } public string? AnchorId { get; set; }
public string? ConstructionSiteId { get; set; } public string? ConstructionSiteId { get; set; }
public string? ModuleId { get; set; } public string? ModuleId { get; set; }
public float WaitSeconds { get; set; } public float WaitSeconds { get; set; }
@@ -78,7 +257,7 @@ public sealed class DefaultBehaviorRuntime
public string? AreaSystemId { get; set; } public string? AreaSystemId { get; set; }
public string? TargetEntityId { get; set; } public string? TargetEntityId { get; set; }
public string? ItemId { get; set; } public string? ItemId { get; set; }
public string? PreferredNodeId { get; set; } public string? PreferredAnchorId { get; set; }
public string? PreferredConstructionSiteId { get; set; } public string? PreferredConstructionSiteId { get; set; }
public string? PreferredModuleId { get; set; } public string? PreferredModuleId { get; set; }
public Vector3? TargetPosition { get; set; } public Vector3? TargetPosition { get; set; }
@@ -102,7 +281,7 @@ public sealed class ShipOrderTemplateRuntime
public string? SourceStationId { get; set; } public string? SourceStationId { get; set; }
public string? DestinationStationId { get; set; } public string? DestinationStationId { get; set; }
public string? ItemId { get; set; } public string? ItemId { get; set; }
public string? NodeId { get; set; } public string? AnchorId { get; set; }
public string? ConstructionSiteId { get; set; } public string? ConstructionSiteId { get; set; }
public string? ModuleId { get; set; } public string? ModuleId { get; set; }
public float WaitSeconds { get; set; } public float WaitSeconds { get; set; }
@@ -111,33 +290,6 @@ public sealed class ShipOrderTemplateRuntime
public bool KnownStationsOnly { get; set; } 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 sealed class ShipSubTaskRuntime
{ {
public required string Id { get; init; } public required string Id { get; init; }
@@ -146,7 +298,9 @@ public sealed class ShipSubTaskRuntime
public WorkStatus Status { get; set; } = WorkStatus.Pending; public WorkStatus Status { get; set; } = WorkStatus.Pending;
public string? TargetEntityId { get; set; } public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; } public string? TargetSystemId { get; set; }
public string? TargetNodeId { get; set; } public string? TargetAnchorId { get; set; }
public string? TargetResourceNodeId { get; set; }
public string? TargetResourceDepositId { get; set; }
public Vector3? TargetPosition { get; set; } public Vector3? TargetPosition { get; set; }
public string? ItemId { get; set; } public string? ItemId { get; set; }
public string? ModuleId { get; set; } public string? ModuleId { get; set; }

View File

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

View File

@@ -24,6 +24,7 @@ internal sealed class SimulationProjectionService
false, false,
events, events,
BuildCelestialDeltas(world), BuildCelestialDeltas(world),
BuildAnchorDeltas(world),
BuildNodeDeltas(world), BuildNodeDeltas(world),
BuildStationDeltas(world), BuildStationDeltas(world),
BuildClaimDeltas(world), BuildClaimDeltas(world),
@@ -87,26 +88,37 @@ internal sealed class SimulationProjectionService
c.Kind, c.Kind,
c.OrbitalAnchor, c.OrbitalAnchor,
c.LocalSpaceRadius, c.LocalSpaceRadius,
c.ParentNodeId, c.ParentAnchorId,
c.OccupyingStructureId, c.OccupyingStructureId,
c.OrbitReferenceId)).ToList(), c.OrbitReferenceId)).ToList(),
world.Anchors.Select(ToAnchorDelta).Select(anchor => new AnchorSnapshot(
anchor.Id,
anchor.SystemId,
anchor.Kind,
anchor.SystemPosition,
anchor.LocalSpaceRadius,
anchor.ParentAnchorId,
anchor.OccupyingStructureId,
anchor.OrbitReferenceId)).ToList(),
world.Nodes.Select(ToNodeDelta).Select(node => new ResourceNodeSnapshot( world.Nodes.Select(ToNodeDelta).Select(node => new ResourceNodeSnapshot(
node.Id, node.Id,
node.AnchorId,
node.SystemId, node.SystemId,
node.LocalPosition, node.LocalPosition,
node.CelestialId, node.LocalSpaceRadius,
node.SourceKind, node.SourceKind,
node.OreRemaining, node.OreRemaining,
node.MaxOre, node.MaxOre,
node.ItemId)).ToList(), node.ItemId,
node.Deposits)).ToList(),
world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot( world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot(
station.Id, station.Id,
station.Label, station.Label,
station.Category, station.Category,
station.Objective, station.Objective,
station.SystemId, station.SystemId,
station.AnchorId,
station.LocalPosition, station.LocalPosition,
station.CelestialId,
station.Color, station.Color,
station.DockedShips, station.DockedShips,
station.DockedShipIds, station.DockedShipIds,
@@ -127,7 +139,7 @@ internal sealed class SimulationProjectionService
claim.Id, claim.Id,
claim.FactionId, claim.FactionId,
claim.SystemId, claim.SystemId,
claim.CelestialId, claim.AnchorId,
claim.State, claim.State,
claim.Health, claim.Health,
claim.PlacedAtUtc, claim.PlacedAtUtc,
@@ -136,7 +148,7 @@ internal sealed class SimulationProjectionService
site.Id, site.Id,
site.FactionId, site.FactionId,
site.SystemId, site.SystemId,
site.CelestialId, site.AnchorId,
site.TargetKind, site.TargetKind,
site.TargetDefinitionId, site.TargetDefinitionId,
site.BlueprintId, site.BlueprintId,
@@ -180,6 +192,7 @@ internal sealed class SimulationProjectionService
ship.Purpose, ship.Purpose,
ship.Type, ship.Type,
ship.SystemId, ship.SystemId,
ship.AnchorId,
ship.LocalPosition, ship.LocalPosition,
ship.LocalVelocity, ship.LocalVelocity,
ship.TargetLocalPosition, ship.TargetLocalPosition,
@@ -188,19 +201,17 @@ internal sealed class SimulationProjectionService
ship.DefaultBehavior, ship.DefaultBehavior,
ship.Assignment, ship.Assignment,
ship.Skills, ship.Skills,
ship.ActivePlan,
ship.CurrentStepId,
ship.ActiveSubTasks, ship.ActiveSubTasks,
ship.ControlSourceKind, ship.ControlSourceKind,
ship.ControlSourceId, ship.ControlSourceId,
ship.ControlReason, ship.ControlReason,
ship.LastReplanReason, ship.LastReplanReason,
ship.LastAccessFailureReason, ship.LastAccessFailureReason,
ship.CelestialId,
ship.DockedStationId, ship.DockedStationId,
ship.CommanderId, ship.CommanderId,
ship.PolicySetId, ship.PolicySetId,
ship.CargoCapacity, ship.CargoCapacity,
ship.CargoTypes,
ship.TravelSpeed, ship.TravelSpeed,
ship.TravelSpeedUnit, ship.TravelSpeedUnit,
ship.Inventory, ship.Inventory,
@@ -239,6 +250,11 @@ internal sealed class SimulationProjectionService
celestial.LastDeltaSignature = BuildCelestialSignature(celestial); celestial.LastDeltaSignature = BuildCelestialSignature(celestial);
} }
foreach (var anchor in world.Anchors)
{
anchor.LastDeltaSignature = BuildAnchorSignature(anchor);
}
foreach (var station in world.Stations) foreach (var station in world.Stations)
{ {
station.LastDeltaSignature = BuildStationSignature(world, station); station.LastDeltaSignature = BuildStationSignature(world, station);
@@ -298,6 +314,24 @@ internal sealed class SimulationProjectionService
return deltas; return deltas;
} }
private static IReadOnlyList<AnchorDelta> BuildAnchorDeltas(SimulationWorld world)
{
var deltas = new List<AnchorDelta>();
foreach (var anchor in world.Anchors)
{
var signature = BuildAnchorSignature(anchor);
if (signature == anchor.LastDeltaSignature)
{
continue;
}
anchor.LastDeltaSignature = signature;
deltas.Add(ToAnchorDelta(anchor));
}
return deltas;
}
private static IReadOnlyList<CelestialDelta> BuildCelestialDeltas(SimulationWorld world) private static IReadOnlyList<CelestialDelta> BuildCelestialDeltas(SimulationWorld world)
{ {
var deltas = new List<CelestialDelta>(); var deltas = new List<CelestialDelta>();
@@ -466,17 +500,30 @@ internal sealed class SimulationProjectionService
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)); string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
private static string BuildNodeSignature(ResourceNodeRuntime node) => private static string BuildNodeSignature(ResourceNodeRuntime node) =>
$"{node.SystemId}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.CelestialId}|{node.OreRemaining:0.###}"; string.Join("|",
node.SystemId,
node.AnchorId,
$"{node.Position.X:0.###}",
$"{node.Position.Y:0.###}",
$"{node.Position.Z:0.###}",
$"{node.OreRemaining:0.###}",
string.Join(",",
node.Deposits
.OrderBy(deposit => deposit.Id, StringComparer.Ordinal)
.Select(deposit => $"{deposit.Id}:{deposit.Position.X:0.###}:{deposit.Position.Y:0.###}:{deposit.Position.Z:0.###}:{deposit.OreRemaining:0.###}")));
private static string BuildCelestialSignature(CelestialRuntime celestial) => private static string BuildCelestialSignature(CelestialRuntime celestial) =>
$"{celestial.SystemId}|{celestial.Kind.ToContractValue()}|{celestial.Position.X:0.###}|{celestial.Position.Y:0.###}|{celestial.Position.Z:0.###}|{celestial.LocalSpaceRadius:0.###}|{celestial.ParentNodeId}|{celestial.OccupyingStructureId}|{celestial.OrbitReferenceId}"; $"{celestial.SystemId}|{celestial.Kind.ToContractValue()}|{celestial.Position.X:0.###}|{celestial.Position.Y:0.###}|{celestial.Position.Z:0.###}|{celestial.LocalSpaceRadius:0.###}|{celestial.ParentAnchorId}|{celestial.OccupyingStructureId}|{celestial.OrbitReferenceId}";
private static string BuildAnchorSignature(AnchorRuntime anchor) =>
$"{anchor.SystemId}|{anchor.Kind.ToContractValue()}|{anchor.Position.X:0.###}|{anchor.Position.Y:0.###}|{anchor.Position.Z:0.###}|{anchor.LocalSpaceRadius:0.###}|{anchor.ParentAnchorId}|{anchor.OccupyingStructureId}|{anchor.OrbitReferenceId}|{anchor.SourceEntityKind}|{anchor.SourceEntityId}";
private static string BuildStationSignature(SimulationWorld world, StationRuntime station) private static string BuildStationSignature(SimulationWorld world, StationRuntime station)
{ {
var processes = ToStationActionProgressSnapshots(world, station); var processes = ToStationActionProgressSnapshots(world, station);
return string.Join("|", return string.Join("|",
station.SystemId, station.SystemId,
station.CelestialId ?? "none", station.AnchorId ?? "none",
station.CommanderId ?? "none", station.CommanderId ?? "none",
station.PolicySetId ?? "none", station.PolicySetId ?? "none",
BuildInventorySignature(station.Inventory), BuildInventorySignature(station.Inventory),
@@ -495,10 +542,10 @@ internal sealed class SimulationProjectionService
} }
private static string BuildClaimSignature(ClaimRuntime claim) => private static string BuildClaimSignature(ClaimRuntime claim) =>
$"{claim.FactionId}|{claim.SystemId}|{claim.CelestialId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}"; $"{claim.FactionId}|{claim.SystemId}|{claim.AnchorId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}";
private static string BuildConstructionSiteSignature(ConstructionSiteRuntime site) => private static string BuildConstructionSiteSignature(ConstructionSiteRuntime site) =>
$"{site.FactionId}|{site.SystemId}|{site.CelestialId}|{site.TargetKind}|{site.TargetDefinitionId}|{site.BlueprintId}|{site.ClaimId}|{site.StationId}|{site.State}|{site.Progress:0.###}|{BuildInventorySignature(site.Inventory)}|{BuildInventorySignature(site.RequiredItems)}|{BuildInventorySignature(site.DeliveredItems)}|{string.Join(",", site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal))}"; $"{site.FactionId}|{site.SystemId}|{site.AnchorId}|{site.TargetKind}|{site.TargetDefinitionId}|{site.BlueprintId}|{site.ClaimId}|{site.StationId}|{site.State}|{site.Progress:0.###}|{BuildInventorySignature(site.Inventory)}|{BuildInventorySignature(site.RequiredItems)}|{BuildInventorySignature(site.DeliveredItems)}|{string.Join(",", site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal))}";
private static string BuildMarketOrderSignature(MarketOrderRuntime order) => private static string BuildMarketOrderSignature(MarketOrderRuntime order) =>
$"{order.FactionId}|{order.StationId}|{order.ConstructionSiteId}|{order.Kind}|{order.ItemId}|{order.Amount:0.###}|{order.RemainingAmount:0.###}|{order.Valuation:0.###}|{order.ReserveThreshold?.ToString("0.###") ?? "none"}|{order.PolicySetId}|{order.State}"; $"{order.FactionId}|{order.StationId}|{order.ConstructionSiteId}|{order.Kind}|{order.ItemId}|{order.Amount:0.###}|{order.RemainingAmount:0.###}|{order.Valuation:0.###}|{order.ReserveThreshold?.ToString("0.###") ?? "none"}|{order.PolicySetId}|{order.State}";
@@ -520,9 +567,6 @@ internal sealed class SimulationProjectionService
ship.TargetPosition.Z.ToString("0.###"), ship.TargetPosition.Z.ToString("0.###"),
ship.State.ToContractValue(), ship.State.ToContractValue(),
string.Join(",", ship.OrderQueue 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}")), .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.Kind,
ship.DefaultBehavior.TargetEntityId ?? "none", 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 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}" ? $"{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", : "no-assignment",
ship.ActivePlan?.Kind ?? "none",
ship.ActivePlan?.Status.ToContractValue() ?? "none",
ship.ActivePlan?.CurrentStepIndex.ToString(CultureInfo.InvariantCulture) ?? "-1",
string.Join(",", string.Join(",",
ToActiveSubTaskSnapshots(ship).Select(subTask => ToActiveSubTaskSnapshots(ship).Select(subTask =>
$"{subTask.Id}:{subTask.Kind}:{subTask.Status}:{subTask.Progress:0.###}:{subTask.ElapsedSeconds:0.###}:{subTask.BlockingReason ?? "none"}")), $"{subTask.Id}:{subTask.Kind}:{subTask.Status}:{subTask.Progress:0.###}:{subTask.ElapsedSeconds:0.###}:{subTask.BlockingReason ?? "none"}")),
ship.SpatialState.CurrentCelestialId ?? "none", ship.SpatialState.CurrentAnchorId ?? "none",
ship.DockedStationId ?? "none", ship.DockedStationId ?? "none",
ship.CommanderId ?? "none", ship.CommanderId ?? "none",
ship.PolicySetId ?? "none", ship.PolicySetId ?? "none",
ship.SpatialState.SpaceLayer.ToContractValue(), ship.SpatialState.SpaceLayer.ToContractValue(),
ship.SpatialState.CurrentCelestialId ?? "none", ship.SpatialState.CurrentAnchorId ?? "none",
ship.SpatialState.MovementRegime.ToContractValue(), ship.SpatialState.MovementRegime.ToContractValue(),
ship.SpatialState.DestinationNodeId ?? "none", ship.SpatialState.DestinationAnchorId ?? "none",
ship.SpatialState.Transit?.Regime.ToContractValue() ?? "none", ship.SpatialState.Transit?.Regime.ToContractValue() ?? "none",
ship.SpatialState.Transit?.OriginNodeId ?? "none", ship.SpatialState.Transit?.OriginAnchorId ?? "none",
ship.SpatialState.Transit?.DestinationNodeId ?? "none", ship.SpatialState.Transit?.DestinationAnchorId ?? "none",
ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0", ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0",
GetShipCargoAmount(ship).ToString("0.###"), GetShipCargoAmount(ship).ToString("0.###"),
ship.Skills.Navigation.ToString(CultureInfo.InvariantCulture), ship.Skills.Navigation.ToString(CultureInfo.InvariantCulture),
@@ -571,7 +612,9 @@ internal sealed class SimulationProjectionService
ship.Skills.Combat.ToString(CultureInfo.InvariantCulture), ship.Skills.Combat.ToString(CultureInfo.InvariantCulture),
ship.Skills.Construction.ToString(CultureInfo.InvariantCulture), ship.Skills.Construction.ToString(CultureInfo.InvariantCulture),
ship.Health.ToString("0.###"), 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) => private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
string.Join(",", string.Join(",",
@@ -653,13 +696,33 @@ internal sealed class SimulationProjectionService
private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new( private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new(
node.Id, node.Id,
node.AnchorId,
node.SystemId, node.SystemId,
ToDto(node.Position), ToDto(node.Position),
node.CelestialId, node.LocalSpaceRadius,
node.SourceKind, node.SourceKind,
node.OreRemaining, node.OreRemaining,
node.MaxOre, node.MaxOre,
node.ItemId); node.ItemId,
node.Deposits.Select(ToResourceDepositSnapshot).ToList());
private static ResourceDepositSnapshot ToResourceDepositSnapshot(ResourceDepositRuntime deposit) => new(
deposit.Id,
deposit.NodeId,
deposit.AnchorId,
ToDto(deposit.Position),
deposit.OreRemaining,
deposit.MaxOre);
private static AnchorDelta ToAnchorDelta(AnchorRuntime anchor) => new(
anchor.Id,
anchor.SystemId,
anchor.Kind.ToContractValue(),
ToDto(anchor.Position),
anchor.LocalSpaceRadius,
anchor.ParentAnchorId,
anchor.OccupyingStructureId,
anchor.OrbitReferenceId);
private static CelestialDelta ToCelestialDelta(CelestialRuntime celestial) => new( private static CelestialDelta ToCelestialDelta(CelestialRuntime celestial) => new(
celestial.Id, celestial.Id,
@@ -667,7 +730,7 @@ internal sealed class SimulationProjectionService
celestial.Kind.ToContractValue(), celestial.Kind.ToContractValue(),
ToDto(celestial.Position), ToDto(celestial.Position),
celestial.LocalSpaceRadius, celestial.LocalSpaceRadius,
celestial.ParentNodeId, celestial.ParentAnchorId,
celestial.OccupyingStructureId, celestial.OccupyingStructureId,
celestial.OrbitReferenceId); celestial.OrbitReferenceId);
@@ -677,8 +740,8 @@ internal sealed class SimulationProjectionService
station.Category, station.Category,
station.Objective, station.Objective,
station.SystemId, station.SystemId,
station.AnchorId,
ToDto(station.Position), ToDto(station.Position),
station.CelestialId,
station.Color, station.Color,
station.DockedShipIds.Count, station.DockedShipIds.Count,
station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
@@ -737,7 +800,7 @@ internal sealed class SimulationProjectionService
claim.Id, claim.Id,
claim.FactionId, claim.FactionId,
claim.SystemId, claim.SystemId,
claim.CelestialId, claim.AnchorId,
claim.State, claim.State,
claim.Health, claim.Health,
claim.PlacedAtUtc, claim.PlacedAtUtc,
@@ -747,7 +810,7 @@ internal sealed class SimulationProjectionService
site.Id, site.Id,
site.FactionId, site.FactionId,
site.SystemId, site.SystemId,
site.CelestialId, site.AnchorId,
site.TargetKind, site.TargetKind,
site.TargetDefinitionId, site.TargetDefinitionId,
site.BlueprintId, site.BlueprintId,
@@ -811,6 +874,7 @@ internal sealed class SimulationProjectionService
ship.Definition.Purpose.ToDataValue(), ship.Definition.Purpose.ToDataValue(),
ship.Definition.Type.ToDataValue(), ship.Definition.Type.ToDataValue(),
ship.SystemId, ship.SystemId,
ship.SpatialState.CurrentAnchorId,
ToDto(ship.Position), ToDto(ship.Position),
ToDto(ship.Velocity), ToDto(ship.Velocity),
ToDto(ship.TargetPosition), ToDto(ship.TargetPosition),
@@ -819,19 +883,22 @@ internal sealed class SimulationProjectionService
ToDefaultBehaviorSnapshot(ship.DefaultBehavior), ToDefaultBehaviorSnapshot(ship.DefaultBehavior),
ToShipAssignmentSnapshot(commander), ToShipAssignmentSnapshot(commander),
new ShipSkillProfileSnapshot(ship.Skills.Navigation, ship.Skills.Trade, ship.Skills.Mining, ship.Skills.Combat, ship.Skills.Construction), 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), ToActiveSubTaskSnapshots(ship),
ship.ControlSourceKind, ship.ControlSourceKind,
ship.ControlSourceId, ship.ControlSourceId,
ship.ControlReason, ship.ControlReason,
ship.LastReplanReason, ship.LastReplanReason,
ship.LastAccessFailureReason, ship.LastAccessFailureReason,
ship.SpatialState.CurrentCelestialId,
ship.DockedStationId, ship.DockedStationId,
ship.CommanderId, ship.CommanderId,
ship.PolicySetId, ship.PolicySetId,
ship.Definition.GetTotalCargoCapacity(), ship.Definition.GetTotalCargoCapacity(),
ship.Definition.Cargo
.SelectMany(entry => entry.Types)
.Where(type => !string.IsNullOrWhiteSpace(type))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(type => type, StringComparer.OrdinalIgnoreCase)
.ToList(),
ToShipTravelSpeed(ship).Speed, ToShipTravelSpeed(ship).Speed,
ToShipTravelSpeed(ship).Unit, ToShipTravelSpeed(ship).Unit,
@@ -848,7 +915,7 @@ internal sealed class SimulationProjectionService
{ {
MovementRegimeKind.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"), MovementRegimeKind.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"),
MovementRegimeKind.Warp => (ship.State == ShipState.Warping ? ship.Definition.WarpSpeed : 0f, "AU/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) => private static IReadOnlyList<ShipOrderSnapshot> ToShipOrderSnapshots(ShipRuntime ship) =>
ship.OrderQueue ship.OrderQueue
.OrderByDescending(GetOrderSourcePriority)
.ThenByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => new ShipOrderSnapshot( .Select(order => new ShipOrderSnapshot(
order.Id, order.Id,
order.Kind, order.Kind,
@@ -880,7 +944,7 @@ internal sealed class SimulationProjectionService
order.SourceStationId, order.SourceStationId,
order.DestinationStationId, order.DestinationStationId,
order.ItemId, order.ItemId,
order.NodeId, order.AnchorId,
order.ConstructionSiteId, order.ConstructionSiteId,
order.ModuleId, order.ModuleId,
order.WaitSeconds, order.WaitSeconds,
@@ -890,14 +954,6 @@ internal sealed class SimulationProjectionService
order.FailureReason)) order.FailureReason))
.ToList(); .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) => private static DefaultBehaviorSnapshot ToDefaultBehaviorSnapshot(DefaultBehaviorRuntime behavior) =>
new( new(
behavior.Kind, behavior.Kind,
@@ -906,7 +962,7 @@ internal sealed class SimulationProjectionService
behavior.AreaSystemId, behavior.AreaSystemId,
behavior.TargetEntityId, behavior.TargetEntityId,
behavior.ItemId, behavior.ItemId,
behavior.PreferredNodeId, behavior.PreferredAnchorId,
behavior.PreferredConstructionSiteId, behavior.PreferredConstructionSiteId,
behavior.PreferredModuleId, behavior.PreferredModuleId,
behavior.TargetPosition is null ? null : ToDto(behavior.TargetPosition.Value), behavior.TargetPosition is null ? null : ToDto(behavior.TargetPosition.Value),
@@ -929,7 +985,7 @@ internal sealed class SimulationProjectionService
template.SourceStationId, template.SourceStationId,
template.DestinationStationId, template.DestinationStationId,
template.ItemId, template.ItemId,
template.NodeId, template.AnchorId,
template.ConstructionSiteId, template.ConstructionSiteId,
template.ModuleId, template.ModuleId,
template.WaitSeconds, template.WaitSeconds,
@@ -964,48 +1020,18 @@ internal sealed class SimulationProjectionService
assignment.UpdatedAtUtc); 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) => private static ShipSubTaskSnapshot ToShipSubTaskSnapshot(ShipSubTaskRuntime subTask) =>
new( new(
subTask.Id, subTask.Id,
subTask.Kind, subTask.Kind,
subTask.Status.ToContractValue(), subTask.Status.ToContractValue(),
subTask.Summary, subTask.Summary,
subTask.TargetEntityId, subTask.TargetEntityId,
subTask.TargetSystemId, subTask.TargetSystemId,
subTask.TargetNodeId, subTask.TargetAnchorId,
subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value), subTask.TargetResourceNodeId,
subTask.TargetResourceDepositId,
subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value),
subTask.ItemId, subTask.ItemId,
subTask.ModuleId, subTask.ModuleId,
subTask.Threshold, subTask.Threshold,
@@ -1017,23 +1043,12 @@ internal sealed class SimulationProjectionService
private static IReadOnlyList<ShipSubTaskSnapshot> ToActiveSubTaskSnapshots(ShipRuntime ship) private static IReadOnlyList<ShipSubTaskSnapshot> ToActiveSubTaskSnapshots(ShipRuntime ship)
{ {
var step = GetCurrentShipStep(ship); return ship.ActiveSubTasks
if (step is null)
{
return [];
}
return step.SubTasks
.Where(subTask => subTask.Status is WorkStatus.Pending or WorkStatus.Active or WorkStatus.Blocked) .Where(subTask => subTask.Status is WorkStatus.Pending or WorkStatus.Active or WorkStatus.Blocked)
.Select(ToShipSubTaskSnapshot) .Select(ToShipSubTaskSnapshot)
.ToList(); .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) private static CommanderAssignmentSnapshot ToCommanderAssignmentSnapshot(CommanderRuntime commander)
{ {
var assignment = commander.Assignment; var assignment = commander.Assignment;
@@ -1408,7 +1423,7 @@ internal sealed class SimulationProjectionService
claim.SourceClaimId, claim.SourceClaimId,
claim.FactionId, claim.FactionId,
claim.SystemId, claim.SystemId,
claim.CelestialId, claim.AnchorId,
claim.Status, claim.Status,
claim.ClaimKind, claim.ClaimKind,
claim.ClaimStrength, claim.ClaimStrength,
@@ -1564,15 +1579,15 @@ internal sealed class SimulationProjectionService
private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new( private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new(
state.SpaceLayer.ToContractValue(), state.SpaceLayer.ToContractValue(),
state.CurrentSystemId, state.CurrentSystemId,
state.CurrentCelestialId, state.CurrentAnchorId,
state.LocalPosition is null ? null : ToDto(state.LocalPosition.Value), state.LocalPosition is null ? null : ToDto(state.LocalPosition.Value),
state.SystemPosition is null ? null : ToDto(state.SystemPosition.Value), state.SystemPosition is null ? null : ToDto(state.SystemPosition.Value),
state.MovementRegime.ToContractValue(), state.MovementRegime.ToContractValue(),
state.DestinationNodeId, state.DestinationAnchorId,
state.Transit is null ? null : new ShipTransitSnapshot( state.Transit is null ? null : new ShipTransitSnapshot(
state.Transit.Regime.ToContractValue(), state.Transit.Regime.ToContractValue(),
state.Transit.OriginNodeId, state.Transit.OriginAnchorId,
state.Transit.DestinationNodeId, state.Transit.DestinationAnchorId,
state.Transit.StartedAtUtc, state.Transit.StartedAtUtc,
state.Transit.ArrivalDueAtUtc, state.Transit.ArrivalDueAtUtc,
state.Transit.Progress)); state.Transit.Progress));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,15 @@ using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario; namespace SpaceGame.Api.Universe.Scenario;
public sealed class SpatialBuilder(IBalanceService balance) public sealed class SpatialBuilder
{ {
internal static bool IsConstructibleAnchorKind(SpatialNodeKind kind) => kind is SpatialNodeKind.Planet or SpatialNodeKind.Moon or SpatialNodeKind.LagrangePoint;
internal static string? ResolveCompatibleCelestialId(AnchorRuntime? anchor) =>
anchor is not null && string.Equals(anchor.SourceEntityKind, "celestial", StringComparison.Ordinal)
? anchor.SourceEntityId
: null;
internal ScenarioSpatialLayout BuildLayout(IReadOnlyList<SystemRuntime> systems) internal ScenarioSpatialLayout BuildLayout(IReadOnlyList<SystemRuntime> systems)
{ {
var systemGraphs = systems.ToDictionary( var systemGraphs = systems.ToDictionary(
@@ -11,6 +18,19 @@ public sealed class SpatialBuilder(IBalanceService balance)
BuildSystemSpatialGraph, BuildSystemSpatialGraph,
StringComparer.Ordinal); StringComparer.Ordinal);
var celestials = systemGraphs.Values.SelectMany(graph => graph.Celestials).ToList(); var celestials = systemGraphs.Values.SelectMany(graph => graph.Celestials).ToList();
var anchors = celestials.Select(celestial => new AnchorRuntime
{
Id = celestial.Id,
SystemId = celestial.SystemId,
Kind = celestial.Kind,
Position = celestial.Position,
LocalSpaceRadius = celestial.LocalSpaceRadius,
ParentAnchorId = celestial.ParentAnchorId,
OrbitReferenceId = celestial.OrbitReferenceId,
OccupyingStructureId = celestial.OccupyingStructureId,
SourceEntityKind = "celestial",
SourceEntityId = celestial.Id,
}).ToList();
var nodes = new List<ResourceNodeRuntime>(); var nodes = new List<ResourceNodeRuntime>();
var nodeIdCounter = 0; var nodeIdCounter = 0;
@@ -20,24 +40,43 @@ public sealed class SpatialBuilder(IBalanceService balance)
foreach (var node in system.Definition.ResourceNodes) foreach (var node in system.Definition.ResourceNodes)
{ {
var anchorCelestial = ResolveResourceNodeAnchor(systemGraph, node); var anchorCelestial = ResolveResourceNodeAnchor(systemGraph, node);
var nodeId = $"node-{++nodeIdCounter}";
var localPosition = ComputeResourceNodeLocalPosition(node);
var anchorPosition = anchorCelestial is null
? localPosition
: Add(anchorCelestial.Position, localPosition);
nodes.Add(new ResourceNodeRuntime nodes.Add(new ResourceNodeRuntime
{ {
Id = $"node-{++nodeIdCounter}", Id = nodeId,
AnchorId = nodeId,
SystemId = system.Definition.Id, SystemId = system.Definition.Id,
Position = ComputeResourceNodePosition(anchorCelestial, node, balance.YPlane), Position = localPosition,
SourceKind = node.SourceKind, SourceKind = node.SourceKind,
ItemId = node.ItemId, ItemId = node.ItemId,
CelestialId = anchorCelestial?.Id, LocalSpaceRadius = LocalSpaceRadius,
OrbitRadius = node.RadiusOffset, OrbitRadius = node.RadiusOffset,
OrbitPhase = node.Angle, OrbitPhase = node.Angle,
OrbitInclination = DegreesToRadians(node.InclinationDegrees), OrbitInclination = DegreesToRadians(node.InclinationDegrees),
OreRemaining = node.OreAmount, OreRemaining = node.OreAmount,
MaxOre = node.OreAmount, MaxOre = node.OreAmount,
}); });
nodes[^1].Deposits.AddRange(BuildResourceDeposits(system.Definition.Id, nodeId, node, node.OreAmount));
anchors.Add(new AnchorRuntime
{
Id = nodeId,
SystemId = system.Definition.Id,
Kind = SpatialNodeKind.ResourceNode,
Position = anchorPosition,
LocalSpaceRadius = LocalSpaceRadius,
ParentAnchorId = anchorCelestial?.Id,
SourceEntityKind = "resource-node",
SourceEntityId = nodeId,
});
} }
} }
return new ScenarioSpatialLayout(systemGraphs, celestials, nodes); return new ScenarioSpatialLayout(systemGraphs, anchors, celestials, nodes);
} }
private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system) private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system)
@@ -70,7 +109,7 @@ public sealed class SpatialBuilder(IBalanceService balance)
kind: SpatialNodeKind.Planet, kind: SpatialNodeKind.Planet,
position: planetPosition, position: planetPosition,
localSpaceRadius: LocalSpaceRadius, localSpaceRadius: LocalSpaceRadius,
parentNodeId: primaryStarNodeId); parentAnchorId: primaryStarNodeId);
var lagrangeNodes = new Dictionary<string, CelestialRuntime>(StringComparer.Ordinal); var lagrangeNodes = new Dictionary<string, CelestialRuntime>(StringComparer.Ordinal);
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet)) foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet))
@@ -82,7 +121,7 @@ public sealed class SpatialBuilder(IBalanceService balance)
kind: SpatialNodeKind.LagrangePoint, kind: SpatialNodeKind.LagrangePoint,
position: point.Position, position: point.Position,
localSpaceRadius: LocalSpaceRadius, localSpaceRadius: LocalSpaceRadius,
parentNodeId: planetCelestial.Id, parentAnchorId: planetCelestial.Id,
orbitReferenceId: point.Designation); orbitReferenceId: point.Designation);
lagrangeNodes[point.Designation] = lagrangeCelestial; lagrangeNodes[point.Designation] = lagrangeCelestial;
} }
@@ -100,7 +139,7 @@ public sealed class SpatialBuilder(IBalanceService balance)
kind: SpatialNodeKind.Moon, kind: SpatialNodeKind.Moon,
position: moonPosition, position: moonPosition,
localSpaceRadius: LocalSpaceRadius, localSpaceRadius: LocalSpaceRadius,
parentNodeId: planetCelestial.Id); parentAnchorId: planetCelestial.Id);
} }
} }
@@ -114,7 +153,7 @@ public sealed class SpatialBuilder(IBalanceService balance)
SpatialNodeKind kind, SpatialNodeKind kind,
Vector3 position, Vector3 position,
float localSpaceRadius, float localSpaceRadius,
string? parentNodeId = null, string? parentAnchorId = null,
string? orbitReferenceId = null) string? orbitReferenceId = null)
{ {
var celestial = new CelestialRuntime var celestial = new CelestialRuntime
@@ -124,7 +163,7 @@ public sealed class SpatialBuilder(IBalanceService balance)
Kind = kind, Kind = kind,
Position = position, Position = position,
LocalSpaceRadius = localSpaceRadius, LocalSpaceRadius = localSpaceRadius,
ParentNodeId = parentNodeId, ParentAnchorId = parentAnchorId,
OrbitReferenceId = orbitReferenceId, OrbitReferenceId = orbitReferenceId,
}; };
@@ -183,7 +222,7 @@ public sealed class SpatialBuilder(IBalanceService balance)
InitialStationDefinition plan, InitialStationDefinition plan,
SystemRuntime system, SystemRuntime system,
SystemSpatialGraph graph, SystemSpatialGraph graph,
IReadOnlyCollection<CelestialRuntime> existingCelestials) IReadOnlyCollection<AnchorRuntime> existingAnchors)
{ {
if (plan.PlanetIndex is int planetIndex && if (plan.PlanetIndex is int planetIndex &&
graph.LagrangeNodesByPlanetIndex.TryGetValue(planetIndex, out var lagrangeNodes)) graph.LagrangeNodesByPlanetIndex.TryGetValue(planetIndex, out var lagrangeNodes))
@@ -191,28 +230,32 @@ public sealed class SpatialBuilder(IBalanceService balance)
var designation = ResolveLagrangeDesignation(plan.LagrangeSide); var designation = ResolveLagrangeDesignation(plan.LagrangeSide);
if (lagrangeNodes.TryGetValue(designation, out var lagrangeCelestial)) if (lagrangeNodes.TryGetValue(designation, out var lagrangeCelestial))
{ {
return new StationPlacement(lagrangeCelestial, lagrangeCelestial.Position); var lagrangeAnchor = existingAnchors.First(anchor => string.Equals(anchor.Id, lagrangeCelestial.Id, StringComparison.Ordinal));
return new StationPlacement(lagrangeAnchor, lagrangeCelestial, lagrangeAnchor.Position);
} }
} }
if (plan.Position is { Length: 3 }) if (plan.Position is { Length: 3 })
{ {
var targetPosition = NormalizeScenarioPoint(system, plan.Position); var targetPosition = NormalizeScenarioPoint(system, plan.Position);
var preferredCelestial = existingCelestials var preferredAnchor = existingAnchors
.Where(c => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint) .Where(anchor => anchor.SystemId == system.Definition.Id && anchor.Kind == SpatialNodeKind.LagrangePoint)
.OrderBy(c => c.Position.DistanceTo(targetPosition)) .OrderBy(anchor => anchor.Position.DistanceTo(targetPosition))
.FirstOrDefault() .FirstOrDefault()
?? existingCelestials ?? existingAnchors
.Where(c => c.SystemId == system.Definition.Id) .Where(anchor => anchor.SystemId == system.Definition.Id && IsConstructibleAnchorKind(anchor.Kind))
.OrderBy(c => c.Position.DistanceTo(targetPosition)) .OrderBy(anchor => anchor.Position.DistanceTo(targetPosition))
.First(); .First();
return new StationPlacement(preferredCelestial, preferredCelestial.Position); var preferredCelestial = graph.Celestials.FirstOrDefault(c => string.Equals(c.Id, ResolveCompatibleCelestialId(preferredAnchor), StringComparison.Ordinal));
return new StationPlacement(preferredAnchor, preferredCelestial, preferredAnchor.Position);
} }
var fallbackCelestial = graph.Celestials var fallbackAnchor = existingAnchors
.FirstOrDefault(c => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId)) .Where(anchor => anchor.SystemId == system.Definition.Id)
?? graph.Celestials.First(c => c.Kind == SpatialNodeKind.Planet); .FirstOrDefault(anchor => anchor.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(anchor.OccupyingStructureId))
return new StationPlacement(fallbackCelestial, fallbackCelestial.Position); ?? existingAnchors.First(anchor => anchor.SystemId == system.Definition.Id && anchor.Kind == SpatialNodeKind.Planet);
var fallbackCelestial = graph.Celestials.FirstOrDefault(c => string.Equals(c.Id, ResolveCompatibleCelestialId(fallbackAnchor), StringComparison.Ordinal));
return new StationPlacement(fallbackAnchor, fallbackCelestial, fallbackAnchor.Position);
} }
private static string ResolveLagrangeDesignation(int? lagrangeSide) => lagrangeSide switch private static string ResolveLagrangeDesignation(int? lagrangeSide) => lagrangeSide switch
@@ -256,20 +299,110 @@ public sealed class SpatialBuilder(IBalanceService balance)
return graph.Celestials.FirstOrDefault(c => c.Id == planetNodeId); return graph.Celestials.FirstOrDefault(c => c.Id == planetNodeId);
} }
private static Vector3 ComputeResourceNodePosition(CelestialRuntime? anchorCelestial, ResourceNodeDefinition definition, float yPlane) private static Vector3 ComputeResourceNodeLocalPosition(ResourceNodeDefinition definition)
{ {
var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.04f, 25000f); var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.04f, 25000f);
var offset = new Vector3( return new Vector3(
MathF.Cos(definition.Angle) * definition.RadiusOffset, MathF.Cos(definition.Angle) * definition.RadiusOffset,
verticalOffset, verticalOffset,
MathF.Sin(definition.Angle) * definition.RadiusOffset); MathF.Sin(definition.Angle) * definition.RadiusOffset);
}
if (anchorCelestial is null) private static IReadOnlyList<ResourceDepositRuntime> BuildResourceDeposits(
string systemId,
string nodeId,
ResourceNodeDefinition definition,
float oreAmount)
{
var derivedDepositCount = Math.Clamp((int)MathF.Round(MathF.Sqrt(MathF.Max(oreAmount, 1f)) / 18f), 4, 18);
var depositCount = Math.Clamp(definition.ShardCount > 0 ? definition.ShardCount : derivedDepositCount, 4, 48);
var deposits = new List<ResourceDepositRuntime>(depositCount);
var weightTotal = 0f;
var weights = new float[depositCount];
var random = new Random(ComputeDeterministicSeed(systemId, nodeId, "resource-deposits"));
for (var index = 0; index < depositCount; index += 1)
{ {
return new Vector3(offset.X, yPlane + offset.Y, offset.Z); var weight = 0.8f + (NextFloat01(random) * 1.6f);
weights[index] = weight;
weightTotal += weight;
} }
return Add(anchorCelestial.Position, offset); // Resource node localspace should read as a compact mineable field around the node core,
// not as sparse debris spread across the entire anchor volume.
var scatterRadius = MathF.Max(120f, MathF.Min(LocalSpaceRadius * 0.2f, 900f));
for (var index = 0; index < depositCount; index += 1)
{
var angle = NextFloat01(random) * MathF.PI * 2f;
var radiusFactor = 0.12f + (NextFloat01(random) * 0.82f);
var radius = scatterRadius * MathF.Sqrt(radiusFactor);
var vertical = (NextFloat01(random) - 0.5f) * MathF.Max(40f, scatterRadius * 0.18f);
var localPosition = new Vector3(
MathF.Cos(angle) * radius,
vertical,
MathF.Sin(angle) * radius);
var maxOre = oreAmount * (weights[index] / MathF.Max(weightTotal, 0.001f));
deposits.Add(new ResourceDepositRuntime
{
Id = $"{nodeId}-deposit-{index + 1}",
NodeId = nodeId,
AnchorId = nodeId,
Position = localPosition,
OreRemaining = maxOre,
MaxOre = maxOre,
});
}
return deposits;
}
private static int ComputeDeterministicSeed(string systemId, string nodeId, string salt)
{
unchecked
{
var hash = 17;
foreach (var character in systemId)
{
hash = (hash * 31) + character;
}
foreach (var character in nodeId)
{
hash = (hash * 31) + character;
}
foreach (var character in salt)
{
hash = (hash * 31) + character;
}
return hash;
}
}
private static float NextFloat01(Random random) => (float)random.NextDouble();
private static float Hash01(string systemId, string nodeId, string salt)
{
unchecked
{
var hash = 17;
foreach (var character in systemId)
{
hash = (hash * 31) + character;
}
foreach (var character in nodeId)
{
hash = (hash * 31) + character;
}
foreach (var character in salt)
{
hash = (hash * 31) + character;
}
return (hash & 0x7fffffff) / (float)int.MaxValue;
}
} }
private static Vector3 ComputePlanetPosition(PlanetDefinition planet) private static Vector3 ComputePlanetPosition(PlanetDefinition planet)
@@ -286,20 +419,25 @@ public sealed class SpatialBuilder(IBalanceService balance)
return Add(planetPosition, local); return Add(planetPosition, local);
} }
internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<CelestialRuntime> celestials) internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<AnchorRuntime> anchors)
{ {
var nearestCelestial = celestials var systemPosition = SimulationUnits.MetersToKilometers(position);
.Where(c => c.SystemId == systemId) var nearestAnchor = anchors
.OrderBy(c => c.Position.DistanceTo(position)) .Where(anchor => anchor.SystemId == systemId)
.OrderBy(anchor => anchor.Position.DistanceTo(systemPosition))
.FirstOrDefault(); .FirstOrDefault();
var localPosition = position;
var resolvedSystemPosition = nearestAnchor is null
? systemPosition
: Add(nearestAnchor.Position, SimulationUnits.MetersToKilometers(localPosition));
return new ShipSpatialStateRuntime return new ShipSpatialStateRuntime
{ {
CurrentSystemId = systemId, CurrentSystemId = systemId,
SpaceLayer = SpaceLayerKind.LocalSpace, SpaceLayer = SpaceLayerKind.LocalSpace,
CurrentCelestialId = nearestCelestial?.Id, CurrentAnchorId = nearestAnchor?.Id,
LocalPosition = position, LocalPosition = localPosition,
SystemPosition = position, SystemPosition = resolvedSystemPosition,
MovementRegime = MovementRegimeKind.LocalFlight, MovementRegime = MovementRegimeKind.LocalFlight,
}; };
} }
@@ -307,6 +445,7 @@ public sealed class SpatialBuilder(IBalanceService balance)
public sealed record ScenarioSpatialLayout( public sealed record ScenarioSpatialLayout(
IReadOnlyDictionary<string, SystemSpatialGraph> SystemGraphs, IReadOnlyDictionary<string, SystemSpatialGraph> SystemGraphs,
List<AnchorRuntime> Anchors,
List<CelestialRuntime> Celestials, List<CelestialRuntime> Celestials,
List<ResourceNodeRuntime> Nodes); List<ResourceNodeRuntime> Nodes);
@@ -317,4 +456,4 @@ public sealed record SystemSpatialGraph(
internal sealed record LagrangePointPlacement(string Designation, Vector3 Position); internal sealed record LagrangePointPlacement(string Designation, Vector3 Position);
internal sealed record StationPlacement(CelestialRuntime AnchorCelestial, Vector3 Position); internal sealed record StationPlacement(AnchorRuntime Anchor, CelestialRuntime? Celestial, Vector3 Position);

View File

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

View File

@@ -16,7 +16,11 @@ public sealed class WorldBuilder(
WorldGenerationOptions worldGenerationOptions, WorldGenerationOptions worldGenerationOptions,
ScenarioDefinition? scenarioDefinition) 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); var scenario = scenarioDefinition ?? scenarioValidationService.CreateEmptyScenario(worldGenerationOptions, topology.Systems);
scenarioValidationService.Validate(scenario, topology.Systems.Select(system => system.Id).ToHashSet(StringComparer.Ordinal)); 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 policies = seedingService.CreatePolicies(factions);
var commanders = seedingService.CreateCommanders(factions, content.Stations, content.Ships); var commanders = seedingService.CreateCommanders(factions, content.Stations, content.Ships);
var nowUtc = DateTimeOffset.UtcNow; var nowUtc = DateTimeOffset.UtcNow;
var claims = seedingService.CreateClaims(content.Stations, topology.SpatialLayout.Celestials, nowUtc); var claims = seedingService.CreateClaims(content.Stations, topology.SpatialLayout.Anchors, nowUtc);
var world = new SimulationWorld var world = new SimulationWorld
{ {
Label = "Split Viewer / Simulation World", Label = "Split Viewer / Simulation World",
Seed = worldGenerationOptions.Seed, Seed = worldGenerationOptions.Seed,
Systems = topology.SystemRuntimes.ToList(), Systems = topology.SystemRuntimes.ToList(),
Anchors = topology.SpatialLayout.Anchors,
Celestials = topology.SpatialLayout.Celestials, Celestials = topology.SpatialLayout.Celestials,
Nodes = topology.SpatialLayout.Nodes, Nodes = topology.SpatialLayout.Nodes,
Wrecks = [], Wrecks = [],

View File

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

View File

@@ -13,6 +13,22 @@ public sealed class WorldTopologyBuilder(
generationService.PrepareKnownSystems(staticData.KnownSystems), generationService.PrepareKnownSystems(staticData.KnownSystems),
worldGenerationOptions); 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 var systemRuntimes = systems
.Select(definition => new SystemRuntime .Select(definition => new SystemRuntime
{ {

View File

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

View File

@@ -10,6 +10,7 @@ namespace SpaceGame.Api.Universe.Simulation;
public sealed class WorldService public sealed class WorldService
{ {
private const int DeltaHistoryLimit = 256; private const int DeltaHistoryLimit = 256;
private const string StarterPlayerShipId = "ship_arg_s_scout_01_a";
private readonly Lock _sync = new(); private readonly Lock _sync = new();
private readonly OrbitalSimulationSnapshot _orbitalSimulation; 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) public ShipSnapshot? UpdateShipDefaultBehavior(string shipId, ShipDefaultBehaviorCommandRequest request)
{ {
lock (_sync) lock (_sync)
@@ -148,11 +182,6 @@ public sealed class WorldService
{ {
lock (_sync) lock (_sync)
{ {
if (_world.Factions.Count == 0)
{
return null;
}
var playerKey = GetCurrentPlayerKey(); var playerKey = GetCurrentPlayerKey();
var player = _playerFaction.TryGetDomain(_playerStateStore, playerKey) var player = _playerFaction.TryGetDomain(_playerStateStore, playerKey)
?? _playerFaction.EnsureDomain(_world, _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) public PlayerFactionSnapshot? CreatePlayerOrganization(PlayerOrganizationCommandRequest request)
{ {
lock (_sync) lock (_sync)
@@ -299,6 +348,8 @@ public sealed class WorldService
string.Equals(candidate.FactionId, faction.Id, StringComparison.Ordinal) string.Equals(candidate.FactionId, faction.Id, StringComparison.Ordinal)
&& string.Equals(candidate.SystemId, system.Definition.Id, StringComparison.Ordinal)); && string.Equals(candidate.SystemId, system.Definition.Id, StringComparison.Ordinal));
var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, homeStation); var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, homeStation);
var spatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Anchors);
var localPosition = spatialState.LocalPosition ?? Vector3.Zero;
var ship = new ShipRuntime var ship = new ShipRuntime
{ {
@@ -306,9 +357,9 @@ public sealed class WorldService
SystemId = system.Definition.Id, SystemId = system.Definition.Id,
Definition = definition, Definition = definition,
FactionId = faction.Id, FactionId = faction.Id,
Position = spawnPosition, Position = localPosition,
TargetPosition = spawnPosition, TargetPosition = localPosition,
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Celestials), SpatialState = spatialState,
DefaultBehavior = defaultBehavior, DefaultBehavior = defaultBehavior,
Skills = ShipBootstrapPolicy.CreateSkills(definition), Skills = ShipBootstrapPolicy.CreateSkills(definition),
Health = definition.Hull, Health = definition.Hull,
@@ -336,15 +387,18 @@ public sealed class WorldService
? $"{faction.Label} {ToTitleCaseToken(objective)} {CountFactionStationsInSystem(faction.Id, system.Definition.Id) + 1}" ? $"{faction.Label} {ToTitleCaseToken(objective)} {CountFactionStationsInSystem(faction.Id, system.Definition.Id) + 1}"
: request.Label.Trim(); : request.Label.Trim();
var stationId = $"station-{faction.Id}-{objective}-{Guid.NewGuid():N}".ToLowerInvariant(); var stationId = $"station-{faction.Id}-{objective}-{Guid.NewGuid():N}".ToLowerInvariant();
var position = ResolveStationSpawnPosition(system.Definition.Id); var requestedPosition = ResolveStationSpawnPosition(system.Definition.Id);
var anchor = ResolveNearestConstructibleAnchor(system.Definition.Id, requestedPosition)
?? throw new InvalidOperationException($"System '{system.Definition.Id}' does not have a valid constructible anchor for station spawning.");
var station = new StationRuntime var station = new StationRuntime
{ {
Id = stationId, Id = stationId,
SystemId = system.Definition.Id, SystemId = system.Definition.Id,
AnchorId = anchor.Id,
Label = label, Label = label,
Color = faction.Color, Color = faction.Color,
Objective = objective, Objective = objective,
Position = position, Position = Vector3.Zero,
FactionId = faction.Id, FactionId = faction.Id,
PolicySetId = faction.DefaultPolicySetId, PolicySetId = faction.DefaultPolicySetId,
Health = 600f, Health = 600f,
@@ -359,6 +413,7 @@ public sealed class WorldService
station.PopulationCapacity = GetStationSupportedPopulation(_world.ModuleDefinitions, station); station.PopulationCapacity = GetStationSupportedPopulation(_world.ModuleDefinitions, station);
station.WorkforceRequired = GetStationRequiredWorkforce(_world.ModuleDefinitions, station); station.WorkforceRequired = GetStationRequiredWorkforce(_world.ModuleDefinitions, station);
_world.Stations.Add(station); _world.Stations.Add(station);
anchor.OccupyingStructureId = station.Id;
new GeopoliticalSimulationService().Update(_world, 0f, []); new GeopoliticalSimulationService().Update(_world, 0f, []);
PublishSnapshotRefreshUnsafe("spawn-station", $"Spawned station {station.Id}", "station", station.Id); PublishSnapshotRefreshUnsafe("spawn-station", $"Spawned station {station.Id}", "station", station.Id);
@@ -474,6 +529,7 @@ public sealed class WorldService
[], [],
[], [],
[], [],
[],
null); null);
_history.Enqueue(worldDelta); _history.Enqueue(worldDelta);
@@ -510,6 +566,7 @@ public sealed class WorldService
[], [],
[], [],
[], [],
[],
null); null);
_history.Enqueue(worldDelta); _history.Enqueue(worldDelta);
@@ -530,7 +587,104 @@ public sealed class WorldService
private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() => private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() =>
_playerFactionProjection.ToSnapshot(_playerFaction.TryGetDomain(_playerStateStore, GetCurrentPlayerKey())); _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(); 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) private ShipRuntime? EnqueueGmShipOrderUnsafe(string shipId, ShipOrderCommandRequest request)
{ {
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
@@ -581,12 +759,7 @@ public sealed class WorldService
return null; return null;
} }
if (ship.OrderQueue.Count >= 8) ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime
{
throw new InvalidOperationException("Order queue is full.");
}
ship.OrderQueue.Add(new ShipOrderRuntime
{ {
Id = $"order-{ship.Id}-{Guid.NewGuid():N}", Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
Kind = request.Kind, Kind = request.Kind,
@@ -601,7 +774,7 @@ public sealed class WorldService
SourceStationId = request.SourceStationId, SourceStationId = request.SourceStationId,
DestinationStationId = request.DestinationStationId, DestinationStationId = request.DestinationStationId,
ItemId = request.ItemId, ItemId = request.ItemId,
NodeId = request.NodeId, AnchorId = request.AnchorId,
ConstructionSiteId = request.ConstructionSiteId, ConstructionSiteId = request.ConstructionSiteId,
ModuleId = request.ModuleId, ModuleId = request.ModuleId,
WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f), WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f),
@@ -611,12 +784,7 @@ public sealed class WorldService
}); });
ship.ControlSourceKind = "gm-order"; ship.ControlSourceKind = "gm-order";
ship.ControlSourceId = ship.OrderQueue ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
ship.ControlReason = request.Label ?? request.Kind; ship.ControlReason = request.Label ?? request.Kind;
ship.NeedsReplan = true; ship.NeedsReplan = true;
ship.LastReplanReason = "gm-order-enqueued"; ship.LastReplanReason = "gm-order-enqueued";
@@ -632,22 +800,12 @@ public sealed class WorldService
return null; return null;
} }
ship.OrderQueue.RemoveAll(order => order.Id == orderId); ship.OrderQueue.RemoveById(orderId);
ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player) ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
? "gm-order" ? "gm-order"
: "gm-manual"; : "gm-manual";
ship.ControlSourceId = ship.OrderQueue ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
.Where(order => order.SourceKind == ShipOrderSourceKind.Player) ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(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()
?? "manual-gm-control"; ?? "manual-gm-control";
ship.NeedsReplan = true; ship.NeedsReplan = true;
ship.LastReplanReason = "gm-order-removed"; ship.LastReplanReason = "gm-order-removed";
@@ -655,6 +813,59 @@ public sealed class WorldService
return ship; 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) private ShipRuntime? ConfigureGmShipBehaviorUnsafe(string shipId, ShipDefaultBehaviorCommandRequest request)
{ {
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
@@ -669,7 +880,7 @@ public sealed class WorldService
ship.DefaultBehavior.AreaSystemId = request.AreaSystemId; ship.DefaultBehavior.AreaSystemId = request.AreaSystemId;
ship.DefaultBehavior.TargetEntityId = request.TargetEntityId; ship.DefaultBehavior.TargetEntityId = request.TargetEntityId;
ship.DefaultBehavior.ItemId = request.ItemId; ship.DefaultBehavior.ItemId = request.ItemId;
ship.DefaultBehavior.PreferredNodeId = request.PreferredNodeId; ship.DefaultBehavior.PreferredAnchorId = request.PreferredAnchorId;
ship.DefaultBehavior.PreferredConstructionSiteId = request.PreferredConstructionSiteId; ship.DefaultBehavior.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
ship.DefaultBehavior.PreferredModuleId = request.PreferredModuleId; ship.DefaultBehavior.PreferredModuleId = request.PreferredModuleId;
ship.DefaultBehavior.TargetPosition = request.TargetPosition is null ship.DefaultBehavior.TargetPosition = request.TargetPosition is null
@@ -696,7 +907,7 @@ public sealed class WorldService
SourceStationId = template.SourceStationId, SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId, DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId, ItemId = template.ItemId,
NodeId = template.NodeId, AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId, ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId, ModuleId = template.ModuleId,
WaitSeconds = template.WaitSeconds ?? 0f, WaitSeconds = template.WaitSeconds ?? 0f,
@@ -716,6 +927,26 @@ public sealed class WorldService
return ship; 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() private CommanderRuntime CreateFactionCommander(FactionRuntime faction) => new()
{ {
Id = $"commander-faction-{faction.Id}", 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); 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) private IReadOnlyList<string> BuildStarterStationModules(string factionId, string objective)
{ {
var modules = new List<string>(); var modules = new List<string>();
@@ -807,7 +1051,8 @@ public sealed class WorldService
powerModuleId, powerModuleId,
factionId, factionId,
_staticData.ModuleDefinitions, _staticData.ModuleDefinitions,
_staticData.ItemDefinitions) _staticData.ItemDefinitions,
_staticData.Recipes)
.FirstOrDefault(moduleId => .FirstOrDefault(moduleId =>
{ {
return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition) return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
@@ -820,6 +1065,24 @@ public sealed class WorldService
EnsureStationModule(modules, defaultContainerStorageModuleId); 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); var objectiveModuleId = StarterStationLayoutResolver.ResolveObjectiveModuleId(objective, factionId, _staticData.ModuleDefinitions);
if (!string.IsNullOrWhiteSpace(objectiveModuleId)) if (!string.IsNullOrWhiteSpace(objectiveModuleId))
{ {
@@ -828,7 +1091,8 @@ public sealed class WorldService
objectiveModuleId, objectiveModuleId,
factionId, factionId,
_staticData.ModuleDefinitions, _staticData.ModuleDefinitions,
_staticData.ItemDefinitions)) _staticData.ItemDefinitions,
_staticData.Recipes))
{ {
EnsureStationModule(modules, storageModuleId); EnsureStationModule(modules, storageModuleId);
} }
@@ -948,9 +1212,9 @@ public sealed class WorldService
} }
var systemFilter = scope.SystemId; var systemFilter = scope.SystemId;
if (string.Equals(scope.ScopeKind, "local-celestial", StringComparison.OrdinalIgnoreCase) && systemFilter is null && scope.CelestialId is not null) if (string.Equals(scope.ScopeKind, "local-anchor", StringComparison.OrdinalIgnoreCase) && systemFilter is null && scope.AnchorId is not null)
{ {
systemFilter = ResolveCelestialSystemId(scope.CelestialId); systemFilter = ResolveAnchorSystemId(scope.AnchorId);
} }
return delta with return delta with
@@ -960,6 +1224,7 @@ public sealed class WorldService
.Where((evt) => IsEventVisibleToScope(evt, scope, systemFilter)) .Where((evt) => IsEventVisibleToScope(evt, scope, systemFilter))
.ToList(), .ToList(),
Celestials = delta.Celestials.Where((celestial) => systemFilter is null || celestial.SystemId == systemFilter).ToList(), Celestials = delta.Celestials.Where((celestial) => systemFilter is null || celestial.SystemId == systemFilter).ToList(),
Anchors = delta.Anchors.Where((anchor) => systemFilter is null || anchor.SystemId == systemFilter).ToList(),
Nodes = delta.Nodes.Where((node) => systemFilter is null || node.SystemId == systemFilter).ToList(), Nodes = delta.Nodes.Where((node) => systemFilter is null || node.SystemId == systemFilter).ToList(),
Stations = delta.Stations.Where((station) => systemFilter is null || station.SystemId == systemFilter).ToList(), Stations = delta.Stations.Where((station) => systemFilter is null || station.SystemId == systemFilter).ToList(),
Claims = delta.Claims.Where((claim) => systemFilter is null || claim.SystemId == systemFilter).ToList(), Claims = delta.Claims.Where((claim) => systemFilter is null || claim.SystemId == systemFilter).ToList(),
@@ -1005,8 +1270,8 @@ public sealed class WorldService
ScopeEntityId = scopeEntityId, ScopeEntityId = scopeEntityId,
}; };
private string? ResolveCelestialSystemId(string celestialId) => private string? ResolveAnchorSystemId(string anchorId) =>
_world.Celestials.FirstOrDefault((c) => c.Id == celestialId)?.SystemId; _world.Anchors.FirstOrDefault((anchor) => anchor.Id == anchorId)?.SystemId;
private string? ResolveMarketOrderSystemId(string orderId) private string? ResolveMarketOrderSystemId(string orderId)
{ {
@@ -1050,7 +1315,7 @@ public sealed class WorldService
{ {
"universe" => true, "universe" => true,
"system" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter, "system" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter,
"local-celestial" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter, "local-anchor" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter,
_ => true, _ => true,
}; };
} }

View File

@@ -1,9 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { GameViewer } from "./GameViewer"; import { GameViewer } from "./GameViewer";
import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue"; import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
import ViewerEntityBrowserPanel from "./components/ViewerEntityBrowserPanel.vue"; import ViewerEntityBrowserPanel from "./components/ViewerEntityBrowserPanel.vue";
import ViewerEntityInspectorPanel from "./components/ViewerEntityInspectorPanel.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 GmSettingsWindow from "./components/gm/GmSettingsWindow.vue";
import AuthSessionPanel from "./components/AuthSessionPanel.vue"; import AuthSessionPanel from "./components/AuthSessionPanel.vue";
import AuthLandingPage from "./components/AuthLandingPage.vue"; import AuthLandingPage from "./components/AuthLandingPage.vue";
import PlayerOnboardingPanel from "./components/PlayerOnboardingPanel.vue";
import { fetchPlayerFaction } from "./api";
import { useShipAutomationCatalogStore } from "./ui/stores/shipAutomationCatalogStore"; import { useShipAutomationCatalogStore } from "./ui/stores/shipAutomationCatalogStore";
import { createViewerHudState } from "./viewerHudState"; import { createViewerHudState } from "./viewerHudState";
import { useAuthStore } from "./ui/stores/authStore"; import { useAuthStore } from "./ui/stores/authStore";
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
import { useViewerSelectionStore } from "./ui/stores/viewerSelection"; import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { Selectable } from "./viewerTypes"; import type { Selectable } from "./viewerTypes";
@@ -27,35 +28,73 @@ const hoverConnectorLineEl = ref<SVGLineElement | null>(null);
const hudState = createViewerHudState(); const hudState = createViewerHudState();
const authStore = useAuthStore(); const authStore = useAuthStore();
const playerFactionStore = usePlayerFactionStore();
const automationCatalogStore = useShipAutomationCatalogStore(); const automationCatalogStore = useShipAutomationCatalogStore();
const selectionStore = useViewerSelectionStore(); const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore); const { selectedEntityId } = storeToRefs(selectionStore);
const { canAccessGm } = storeToRefs(authStore); const { canAccessGm, effectivePlayerId, isActingAsAlternateIdentity } = storeToRefs(authStore);
const { playerFaction } = storeToRefs(playerFactionStore);
let viewer: GameViewer | undefined; let viewer: GameViewer | undefined;
const gmOpsOpen = ref(false); const gmOpsOpen = ref(false);
const gmTelemetryOpen = ref(false); const gmTelemetryOpen = ref(false);
const gmSettingsOpen = ref(false); const gmSettingsOpen = ref(false);
const gmMenuOpen = 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 () => { onMounted(async () => {
window.addEventListener("pointermove", onWindowPointerMove);
window.addEventListener("pointerup", stopRightSidebarResize);
window.addEventListener("pointercancel", stopRightSidebarResize);
void automationCatalogStore.load(); void automationCatalogStore.load();
await refreshPlayerContext();
await startViewerIfAuthenticated(); await startViewerIfAuthenticated();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener("pointermove", onWindowPointerMove);
window.removeEventListener("pointerup", stopRightSidebarResize);
window.removeEventListener("pointercancel", stopRightSidebarResize);
viewer?.dispose(); viewer?.dispose();
}); });
watch(() => authStore.isAuthenticated, async (isAuthenticated) => { watch(
if (isAuthenticated) { [() => authStore.isAuthenticated, () => effectivePlayerId.value],
async ([isAuthenticated]) => {
if (!isAuthenticated) {
playerContextReady.value = false;
playerFactionStore.setPlayerFaction(null);
viewer?.dispose();
viewer = undefined;
return;
}
await refreshPlayerContext();
await startViewerIfAuthenticated(); await startViewerIfAuthenticated();
return; },
} { immediate: true },
);
viewer?.dispose(); watch(
viewer = undefined; () => shouldShowOnboarding.value,
}); async (requiresOnboarding) => {
if (requiresOnboarding) {
viewer?.dispose();
viewer = undefined;
return;
}
await startViewerIfAuthenticated();
},
);
function onHistoryWindowResize(id: string, width: number, height: number) { function onHistoryWindowResize(id: string, width: number, height: number) {
const windowState = hudState.historyWindows.find((entry) => entry.id === id); const windowState = hudState.historyWindows.find((entry) => entry.id === id);
@@ -75,8 +114,31 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
viewer?.focusSelection(selection, cameraMode); viewer?.focusSelection(selection, cameraMode);
} }
function startRightSidebarResize(event: PointerEvent) {
if (window.innerWidth <= 760 || event.button !== 0) {
return;
}
rightSidebarResizing.value = true;
event.preventDefault();
}
function onWindowPointerMove(event: PointerEvent) {
if (!rightSidebarResizing.value) {
return;
}
const minWidth = 280;
const maxWidth = Math.min(720, Math.max(window.innerWidth - 240, minWidth));
rightSidebarWidth.value = Math.min(maxWidth, Math.max(minWidth, window.innerWidth - event.clientX));
}
function stopRightSidebarResize() {
rightSidebarResizing.value = false;
}
async function startViewerIfAuthenticated() { async function startViewerIfAuthenticated() {
if (!authStore.isAuthenticated || viewer) { if (!authStore.isAuthenticated || viewer || !playerContextReady.value || shouldShowOnboarding.value) {
return; return;
} }
@@ -101,78 +163,133 @@ async function startViewerIfAuthenticated() {
}); });
void viewer.start(); 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> </script>
<template> <template>
<AuthLandingPage v-if="!authStore.isAuthenticated" /> <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 v-else class="viewer-app">
<div <div
ref="canvasHostEl" ref="canvasHostEl"
class="viewer-canvas-host" class="viewer-canvas-host"
/> />
<div class="pointer-events-none fixed inset-0"> <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">
<AuthSessionPanel /> <section class="viewer-left-sidebar pointer-events-auto">
<CollapsibleHudPanel <div class="viewer-left-sidebar__tabs">
v-model:collapsed="hudState.gamePanel.collapsed" <button
class-name="topbar" type="button"
panel-name="game" class="viewer-left-sidebar__tab"
title="Game" :class="leftSidebarTab === 'player' ? 'viewer-left-sidebar__tab--active' : ''"
:summary="hudState.gamePanel.summary" @click="leftSidebarTab = 'player'"
:body-text="hudState.gamePanel.bodyText" >
/> Player Informations
<CollapsibleHudPanel </button>
v-model:collapsed="hudState.networkPanel.collapsed" <button
class-name="network-panel" type="button"
panel-name="network" class="viewer-left-sidebar__tab"
title="Network" :class="leftSidebarTab === 'entities' ? 'viewer-left-sidebar__tab--active' : ''"
:summary="hudState.networkPanel.summary" @click="leftSidebarTab = 'entities'"
:body-text="hudState.networkPanel.bodyText" >
/> Entities
<CollapsibleHudPanel </button>
v-model:collapsed="hudState.performancePanel.collapsed" </div>
class-name="performance-panel"
panel-name="performance" <div class="viewer-left-sidebar__body">
title="Performance" <div
:summary="hudState.performancePanel.summary" v-if="leftSidebarTab === 'player'"
:body-text="hudState.performancePanel.bodyText" class="viewer-left-sidebar__panel viewer-left-sidebar__panel--player"
/> >
<ViewerEntityBrowserPanel <AuthSessionPanel />
class="min-h-0 flex-1" </div>
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)" <ViewerEntityBrowserPanel
/> v-else
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--entities"
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
/>
</div>
</section>
</div> </div>
<div class="absolute right-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(380px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto"> <div
<HtmlInfoPanel v-if="hudState.statsOverlay.lines.length > 0"
class-name="system-panel-section" class="viewer-stats-overlay-dock"
title="System" >
:subtitle="hudState.systemPanel.title"
:body-html="hudState.systemPanel.bodyHtml"
:hidden="hudState.systemPanel.hidden"
subtitle-class="system-title"
body-class="system-body"
/>
<ViewerEntityInspectorPanel
class="min-h-0 flex-1"
: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-stats-overlay"
:hidden="hudState.error.hidden" :class="hudState.statsOverlay.mode === 'compact' ? 'viewer-stats-overlay--compact' : ''"
> >
{{ hudState.error.message }} <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>
<button </div>
v-if="selectedEntityId"
type="button" <div
class="selection-action-button pointer-events-auto self-end rounded-full border border-white/10 bg-white/5 px-3.5 py-2.5 text-sm text-[color:var(--viewer-text)] transition hover:bg-white/10" v-if="!hudState.systemPanel.hidden"
@click="selectionStore.clearSelection('ui')" class="viewer-system-label-dock"
> >
Clear {{ selectedEntityLabel ?? "Selection" }} <div class="viewer-system-label">
</button> <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="viewer-right-sidebar__panel"
:fallback-title="hudState.detailPanel.title"
:fallback-html="hudState.detailPanel.bodyHtml"
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
/>
</div>
<div
class="viewer-right-sidebar__error"
:hidden="hudState.error.hidden"
>
{{ hudState.error.message }}
</div>
</section>
</div> </div>
<div ref="historyLayerHostEl"> <div ref="historyLayerHostEl">
@@ -222,7 +339,7 @@ async function startViewerIfAuthenticated() {
<GmOpsWindow <GmOpsWindow
v-if="gmOpsOpen" v-if="gmOpsOpen"
@close="gmOpsOpen = false" @close="gmOpsOpen = false"
@focus="(id, kind) => onFocusSelection({ kind, id }, kind === 'ship' ? 'follow' : 'tactical')" @focus="(id, kind) => onFocusSelection({ kind, id }, 'tactical')"
/> />
<GmTelemetryWindow <GmTelemetryWindow
v-if="gmTelemetryOpen" v-if="gmTelemetryOpen"

View File

@@ -1,14 +1,18 @@
import * as THREE from "three"; import * as THREE from "three";
import { import {
LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM,
LOCAL_CAMERA_DISTANCE_AT_TRANSITION,
LOCAL_SYSTEM_BACKDROP_DISTANCE,
MAX_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE,
MIN_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE,
MIN_LOCAL_CAMERA_DISTANCE,
NAV_DISTANCE, NAV_DISTANCE,
} from "./viewerConstants"; } from "./viewerConstants";
import { updatePanFromKeyboard } from "./viewerCamera"; import { updatePanFromKeyboard } from "./viewerCamera";
import { setShellReticleOpacity } from "./viewerControls"; import { setShellReticleOpacity } from "./viewerControls";
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop"; import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
import { updateSystemStarPresentation } from "./viewerPresentation"; import { updateSystemStarPresentation } from "./viewerPresentation";
import { resolveFocusedCelestialId } from "./viewerSelection"; import { resolveFocusedAnchorId } from "./viewerSelection";
import { describeSelectionParent } from "./viewerPanels"; import { describeSelectionParent } from "./viewerPanels";
import { import {
createInitialNetworkStats, createInitialNetworkStats,
@@ -30,6 +34,7 @@ import { SystemLayer } from "./viewerSystemLayer";
import { LocalLayer } from "./viewerLocalLayer"; import { LocalLayer } from "./viewerLocalLayer";
import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState"; import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState";
import { describeSelectable } from "./viewerSelection"; import { describeSelectable } from "./viewerSelection";
import { resolveLocalAnchorOffset } from "./viewerWorldPresentation";
import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection"; import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection";
import { useViewerSceneStore } from "./ui/stores/viewerScene"; import { useViewerSceneStore } from "./ui/stores/viewerScene";
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu"; import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
@@ -88,10 +93,11 @@ export class ViewerAppController {
private selectedItems: Selectable[] = []; private selectedItems: Selectable[] = [];
private worldSignature = ""; private worldSignature = "";
private povLevel: PovLevel = "system"; private povLevel: PovLevel = "system";
private previousPovLevel: PovLevel = "system";
private currentDistance = NAV_DISTANCE.system; private currentDistance = NAV_DISTANCE.system;
private desiredDistance = NAV_DISTANCE.system; private desiredDistance = NAV_DISTANCE.system;
private orbitYaw = -2.3; private orbitYaw = -2.3;
private orbitPitch = 0.62; private orbitPitch = 1.08;
private cameraMode: CameraMode = "tactical"; private cameraMode: CameraMode = "tactical";
private dragMode?: DragMode; private dragMode?: DragMode;
private dragPointerId?: number; private dragPointerId?: number;
@@ -100,6 +106,7 @@ export class ViewerAppController {
private marqueeActive = false; private marqueeActive = false;
private suppressClickSelection = false; private suppressClickSelection = false;
private activeSystemId?: string; private activeSystemId?: string;
private cameraFocusedAnchorId?: string;
private cameraTargetShipId?: string; private cameraTargetShipId?: string;
private readonly followCameraPosition = new THREE.Vector3(); private readonly followCameraPosition = new THREE.Vector3();
private readonly followCameraFocus = new THREE.Vector3(); private readonly followCameraFocus = new THREE.Vector3();
@@ -195,6 +202,8 @@ export class ViewerAppController {
return this.sceneDataController.createWorldPresentationContext({ return this.sceneDataController.createWorldPresentationContext({
world: this.world, world: this.world,
activeSystemId: this.activeSystemId, activeSystemId: this.activeSystemId,
focusedAnchorId: this.resolveFocusedAnchorId(),
cameraMode: this.cameraMode,
povLevel: this.povLevel, povLevel: this.povLevel,
orbitYaw: this.orbitYaw, orbitYaw: this.orbitYaw,
systemCamera: this.systemLayer.camera, systemCamera: this.systemLayer.camera,
@@ -260,15 +269,34 @@ export class ViewerAppController {
}); });
} }
private computeOrbitOffset(): THREE.Vector3 { private computeOrbitOffset(cameraDistance: number): THREE.Vector3 {
const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch); const horizontalDistance = cameraDistance * Math.cos(this.orbitPitch);
return new THREE.Vector3( return new THREE.Vector3(
Math.cos(this.orbitYaw) * horizontalDistance, Math.cos(this.orbitYaw) * horizontalDistance,
this.currentDistance * Math.sin(this.orbitPitch), cameraDistance * Math.sin(this.orbitPitch),
Math.sin(this.orbitYaw) * horizontalDistance, 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) { private updateCamera(delta: number) {
const nextState = stepCamera({ const nextState = stepCamera({
currentDistance: this.currentDistance, currentDistance: this.currentDistance,
@@ -277,33 +305,37 @@ export class ViewerAppController {
delta, delta,
}); });
this.currentDistance = nextState.currentDistance; this.currentDistance = nextState.currentDistance;
this.previousPovLevel = this.povLevel;
this.povLevel = nextState.povLevel; this.povLevel = nextState.povLevel;
this.orbitPitch = nextState.orbitPitch; this.orbitPitch = nextState.orbitPitch;
if (this.sceneStore.povLevel !== this.povLevel) { if (this.sceneStore.povLevel !== this.povLevel) {
this.sceneStore.setViewContext(this.activeSystemId ?? null, this.povLevel); this.sceneStore.setViewContext(this.activeSystemId ?? null, this.povLevel);
} }
this.navigationController.updateActiveSystem(); this.navigationController.updateActiveSystem();
this.navigationController.syncGalaxyAnchorToActiveSystem();
this.updateCameraFocusedAnchor();
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) { if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
// Follow camera directly controls systemLayer.camera in updateFollowCamera. // Follow camera directly controls systemLayer.camera in updateFollowCamera.
// Still update galaxy camera independently. // Still update galaxy camera independently.
const orbitOffset = this.computeOrbitOffset(); const systemOrbitOffset = this.computeOrbitOffset(this.resolveSystemOrbitCameraDistance());
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset); this.galaxyLayer.updateCamera(this.galaxyAnchor, systemOrbitOffset);
return; return;
} }
this.updatePanFromKeyboard(delta); 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) { 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 // Update star dot scales in galaxy scene
updateSystemStarPresentation( updateSystemStarPresentation(
@@ -349,8 +381,49 @@ export class ViewerAppController {
this.interactionController.refreshHistoryWindows(); this.interactionController.refreshHistoryWindows();
} }
private resolveFocusedCelestialId() { private resolveFocusedAnchorId() {
return resolveFocusedCelestialId(this.world, this.selectedItems); 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) { 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 { TelemetrySnapshot } from "./contractsTelemetry";
import type { BalanceSettings } from "./contractsBalance"; import type { BalanceSettings } from "./contractsBalance";
import type { PlayerFactionSnapshot } from "./contractsPlayerFaction"; 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 { ShipAutomationCatalogSnapshot } from "./contractsShipAutomation";
import type { FactionSnapshot } from "./contractsFactions"; import type { FactionSnapshot } from "./contractsFactions";
import type { ShipSnapshot } from "./contractsShips"; import type { ShipSnapshot } from "./contractsShips";
import type { StationSnapshot } from "./contractsInfrastructure"; import type { StationSnapshot } from "./contractsInfrastructure";
import { clearAuthSession, getAuthSession, setAuthSession } from "./authSession"; import { clearAuthSession, getAuthSession, setAuthSession } from "./authSession";
import { getEffectivePlayerIdentityId } from "./effectiveIdentitySession";
import type { import type {
PlayerAssetAssignmentCommandRequest, PlayerAssetAssignmentCommandRequest,
PlayerAutomationPolicyCommandRequest, PlayerAutomationPolicyCommandRequest,
@@ -20,12 +23,13 @@ import type {
import type { import type {
ShipDefaultBehaviorCommandRequest, ShipDefaultBehaviorCommandRequest,
ShipOrderCommandRequest, ShipOrderCommandRequest,
ShipOrderUpdateCommandRequest,
} from "./shipCommands"; } from "./shipCommands";
export interface WorldStreamScope { export interface WorldStreamScope {
scopeKind?: string; scopeKind?: string;
systemId?: string | null; systemId?: string | null;
bubbleId?: string | null; anchorId?: string | null;
} }
async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit, options?: { skipAuth?: boolean; skipRefresh?: boolean }): Promise<T> { async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit, options?: { skipAuth?: boolean; skipRefresh?: boolean }): Promise<T> {
@@ -35,6 +39,12 @@ async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit, option
if (session?.accessToken) { if (session?.accessToken) {
headers.set("Authorization", `Bearer ${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, { const response = await fetch(input, {
@@ -96,8 +106,8 @@ export function openWorldStream(
if (scope?.systemId) { if (scope?.systemId) {
query.set("systemId", scope.systemId); query.set("systemId", scope.systemId);
} }
if (scope?.bubbleId) { if (scope?.anchorId) {
query.set("bubbleId", scope.bubbleId); query.set("anchorId", scope.anchorId);
} }
const stream = new EventSource(`/api/world/stream?${query.toString()}`); const stream = new EventSource(`/api/world/stream?${query.toString()}`);
@@ -160,13 +170,11 @@ export async function resetWorld() {
} }
export async function register(request: { email: string; password: string }) { 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", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(request), body: JSON.stringify(request),
}, { skipAuth: true, skipRefresh: true }); }, { skipAuth: true, skipRefresh: true });
setAuthSession(session);
return session;
} }
export async function login(request: { email: string; password: string }) { 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 }); 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) { export async function fetchShipAutomationCatalog(signal?: AbortSignal) {
return fetchJson<ShipAutomationCatalogSnapshot>("/api/ships/catalog", { signal }, { skipAuth: true }); return fetchJson<ShipAutomationCatalogSnapshot>("/api/ships/catalog", { signal }, { skipAuth: true });
} }
@@ -295,3 +319,11 @@ export async function removeShipOrder(shipId: string, orderId: string) {
method: "DELETE", 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() { async function submitRegister() {
await execute(async () => { await execute(async () => {
const session = await register(registerForm); await register(registerForm);
authStore.setSession(session);
playerFactionStore.setPlayerFaction(null); 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"> <script setup lang="ts">
import { reactive, ref } from "vue"; import { computed, reactive, ref, watch } from "vue";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { login, register } from "../api"; import { fetchPlayerFaction, fetchPlayerIdentities, login, register } from "../api";
import { useAuthStore } from "../ui/stores/authStore"; import { useAuthStore } from "../ui/stores/authStore";
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore"; import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
const authStore = useAuthStore(); const authStore = useAuthStore();
const playerFactionStore = usePlayerFactionStore(); const playerFactionStore = usePlayerFactionStore();
const { session, busy } = storeToRefs(authStore); const { session, busy, availablePlayerIdentities, effectivePlayerId } = storeToRefs(authStore);
const mode = ref<"login" | "register">("login"); const mode = ref<"login" | "register">("login");
const email = ref(""); const email = ref("");
const password = ref(""); const password = ref("");
const errorMessage = ref(""); const errorMessage = ref("");
const identityBusy = ref(false);
const identityError = ref("");
const forgotPasswordOpen = ref(false); const forgotPasswordOpen = ref(false);
const forgotPasswordState = reactive({ const forgotPasswordState = reactive({
email: "", 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() { async function submit() {
errorMessage.value = ""; errorMessage.value = "";
authStore.setBusy(true); authStore.setBusy(true);
try { try {
const snapshot = mode.value === "login" if (mode.value === "login") {
? await login({ email: email.value, password: password.value }) const snapshot = await login({ email: email.value, password: password.value });
: await register({ email: email.value, password: password.value }); authStore.setSession(snapshot);
authStore.setSession(snapshot); playerFactionStore.setPlayerFaction(null);
playerFactionStore.setPlayerFaction(null); password.value = "";
password.value = ""; forgotPasswordOpen.value = false;
forgotPasswordOpen.value = false; } else {
await register({ email: email.value, password: password.value });
playerFactionStore.setPlayerFaction(null);
errorMessage.value = "";
mode.value = "login";
password.value = "";
}
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error ? error.message : "Authentication failed."; errorMessage.value = error instanceof Error ? error.message : "Authentication failed.";
} finally { } finally {
@@ -42,6 +62,59 @@ function logout() {
errorMessage.value = ""; errorMessage.value = "";
password.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> </script>
<template> <template>
@@ -52,6 +125,29 @@ function logout() {
<div class="text-xs uppercase tracking-[0.22em] text-white/45">Identity</div> <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-sm font-semibold">{{ session.email }}</div>
<div class="mt-1 text-xs text-white/55">Player {{ session.userId.slice(0, 8) }}</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"> <div v-if="session.roles.length > 0" class="mt-2 flex flex-wrap gap-1.5">
<span <span
v-for="role in session.roles" 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"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import type { StationSnapshot } from "../contractsInfrastructure";
import type { PlayerFleetSnapshot } from "../contractsPlayerFaction";
import type { ShipSnapshot } from "../contractsShips";
import { getShipBehaviorLabel } from "../shipAutomationPresentation"; import { getShipBehaviorLabel } from "../shipAutomationPresentation";
import { useGmStore } from "../ui/stores/gmStore"; import { useGmStore } from "../ui/stores/gmStore";
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore"; import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
@@ -9,22 +12,27 @@ import { useViewerSelectionStore, type ViewerSelectionSummary } from "../ui/stor
import type { Selectable } from "../viewerTypes"; import type { Selectable } from "../viewerTypes";
type BrowserTab = "visible" | "owned"; type BrowserTab = "visible" | "owned";
type BrowserSortKey = "entity" | "location" | "ai" | "hp";
type BrowserRowKind = "system" | "station" | "fleet" | "ship";
interface BrowserItem { interface BrowserRow {
key: string; key: string;
label: string; kind: BrowserRowKind;
subtitle: string; kindLabel: string;
meta?: string; name: string;
ident: string;
location: string;
aiStates: string[];
hpLabel: string;
hpValue: number;
selection?: ViewerSelectionSummary; selection?: ViewerSelectionSummary;
focusSelection?: Selectable; focusSelection?: Selectable;
focusMode?: "follow" | "tactical"; focusMode?: "follow" | "tactical";
children: BrowserRow[];
} }
interface BrowserSection { interface BrowserDisplayRow extends BrowserRow {
key: string; depth: number;
label: string;
count: number;
items: BrowserItem[];
} }
const emit = defineEmits<{ const emit = defineEmits<{
@@ -40,22 +48,28 @@ const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
const { playerFaction } = storeToRefs(playerStore); const { playerFaction } = storeToRefs(playerStore);
const { activeSystemId, povLevel } = storeToRefs(sceneStore); 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 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) { function normalize(text: string) {
return text.trim().toLowerCase(); 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) { function titleCase(value: string | null | undefined) {
if (!value) { if (!value) {
return "Unknown"; return "Unknown";
@@ -69,168 +83,407 @@ function titleCase(value: string | null | undefined) {
.replace(/\b\w/g, (part) => part.toUpperCase()); .replace(/\b\w/g, (part) => part.toUpperCase());
} }
function buildVisibleSections(): BrowserSection[] { function compactLabel(value: string | null | undefined, fallback: string) {
const sections: BrowserSection[] = []; if (!value) {
return fallback;
}
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}`,
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: "tactical",
children: [],
};
}
function buildStationRow(station: StationSnapshot, children: BrowserRow[]): BrowserRow {
return {
key: `station-${station.id}`,
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",
children,
};
}
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) { if (povLevel.value === "galaxy" || !activeSystemId.value) {
const systems = [...gmStore.systems] return gmStore.systems
.sort((left, right) => left.label.localeCompare(right.label)) .map((system) => buildSystemRow(system.id))
.map<BrowserItem>((system) => ({ .filter((row): row is BrowserRow => row != null);
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;
} }
const systemId = activeSystemId.value; const systemId = activeSystemId.value;
const ships = gmStore.ships const stations = gmStore.stations.filter((station) => station.systemId === systemId);
.filter((ship) => ship.systemId === systemId) const ships = gmStore.ships.filter((ship) => ship.systemId === systemId);
.sort((left, right) => left.name.localeCompare(right.name)) const stationIds = new Set(stations.map((station) => station.id));
.map<BrowserItem>((ship) => ({ const stationChildren = new Map<string, BrowserRow[]>();
key: `ship-${ship.id}`, const fleetChildren = new Map<string, BrowserRow[]>();
label: ship.name, const independentShips: BrowserRow[] = [];
subtitle: `${titleCase(ship.type)} · ${titleCase(ship.state)}`,
meta: `${getShipBehaviorLabel(ship.defaultBehavior.kind)}${ship.defaultBehavior.itemId ? ` · ${ship.defaultBehavior.itemId}` : ""}`,
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) => ({
key: `station-${station.id}`,
label: station.label,
subtitle: `${titleCase(station.category)} · Docked ${station.dockedShips}/${station.dockingPads}`,
meta: station.factionId,
selection: { id: station.id, kind: "station", label: station.label },
focusSelection: { kind: "station", id: station.id },
focusMode: "tactical",
}));
sections.push({ for (const ship of ships) {
key: "ships", const row = buildShipRow(ship);
label: "Ships",
count: ships.length,
items: ships,
});
sections.push({
key: "stations",
label: "Stations",
count: stations.length,
items: stations,
});
return sections; 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 buildOwnedSections(): BrowserSection[] { function buildOwnedRows() {
const player = playerFaction.value; const player = playerFaction.value;
if (!player) { if (!player) {
return []; return [];
} }
const ships = player.assetRegistry.shipIds const ownedShips = player.assetRegistry.shipIds
.map((shipId) => gmStore.ships.find((ship) => ship.id === shipId)) .map((shipId) => gmStore.ships.find((ship) => ship.id === shipId))
.filter((ship): ship is NonNullable<typeof ship> => ship != null) .filter((ship): ship is ShipSnapshot => ship != null);
.sort((left, right) => left.name.localeCompare(right.name)) const ownedStations = player.assetRegistry.stationIds
.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
.map((stationId) => gmStore.stations.find((station) => station.id === stationId)) .map((stationId) => gmStore.stations.find((station) => station.id === stationId))
.filter((station): station is NonNullable<typeof station> => station != null) .filter((station): station is StationSnapshot => station != null);
.sort((left, right) => left.label.localeCompare(right.label)) const ownedFleetShipIds = new Set(player.fleets.flatMap((fleet) => fleet.assetIds));
.map<BrowserItem>((station) => ({ const ownedStationIds = new Set(ownedStations.map((station) => station.id));
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`,
}));
return [ const stationChildren = new Map<string, BrowserRow[]>();
{ for (const ship of ownedShips) {
key: "owned-fleets", if (!ship.dockedStationId || !ownedStationIds.has(ship.dockedStationId) || ownedFleetShipIds.has(ship.id)) {
label: "Fleets", continue;
count: fleets.length, }
items: fleets, const children = stationChildren.get(ship.dockedStationId) ?? [];
}, children.push(buildShipRow(ship));
{ stationChildren.set(ship.dockedStationId, children);
key: "owned-stations", }
label: "Stations",
count: stations.length, const stationRows = ownedStations.map((station) => buildStationRow(station, stationChildren.get(station.id) ?? []));
items: stations, const fleetRows = player.fleets.map((fleet) => buildFleetRow(
}, fleet,
{ fleet.assetIds
key: "owned-ships", .map((shipId) => ownedShips.find((ship) => ship.id === shipId))
label: "Ships", .filter((ship): ship is ShipSnapshot => ship != null)
count: ships.length, .map((ship) => buildShipRow(ship)),
items: ships, ));
}, const independentShips = ownedShips
]; .filter((ship) => !ownedFleetShipIds.has(ship.id) && (!ship.dockedStationId || !ownedStationIds.has(ship.dockedStationId)))
.map((ship) => buildShipRow(ship));
return [...stationRows, ...fleetRows, ...independentShips];
} }
const filteredSections = computed(() => { function getRowSortValue(row: BrowserRow, key: BrowserSortKey) {
const search = normalize(searchText.value); if (key === "hp") {
const sections = activeTab.value === "visible" ? buildVisibleSections() : buildOwnedSections(); return row.hpValue;
return sections }
.map((section) => ({
...section, if (key === "location") {
items: section.items.filter((item) => matchesSearch(item, search)), return row.location;
})) }
.filter((section) => section.items.length > 0);
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,
});
if (row.children.length > 0 && (forceExpand || isExpanded(row))) {
flattened.push(...descendantMatches);
}
}
return flattened;
}
const rawRows = computed(() => {
if (activeTab.value === "owned") {
return buildOwnedRows();
}
return buildVisibleRows();
}); });
function selectItem(item: BrowserItem) { const displayRows = computed(() => {
if (!item.selection) { 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; return;
} }
selectionStore.selectSelection(item.selection, "ui"); sortKey.value = nextKey;
sortDirection.value = "asc";
} }
function focusItem(item: BrowserItem) { function toggleRow(row: BrowserDisplayRow) {
if (item.selection) { if (row.children.length === 0) {
selectionStore.selectSelection(item.selection, "ui"); 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) { function isSelected(row: BrowserDisplayRow) {
return !!item.selection return !!row.selection
&& item.selection.id === selectedEntityId.value && row.selection.id === selectedEntityId.value
&& item.selection.kind === selectedEntityKind.value; && row.selection.kind === selectedEntityKind.value;
}
function sortMarker(key: BrowserSortKey) {
if (sortKey.value !== key) {
return "";
}
return sortDirection.value === "asc" ? " ▲" : " ▼";
} }
</script> </script>
@@ -276,47 +529,91 @@ function isSelected(item: BrowserItem) {
<div v-if="activeTab === 'owned' && !playerFaction" class="entity-browser-panel__empty"> <div v-if="activeTab === 'owned' && !playerFaction" class="entity-browser-panel__empty">
No player-owned assets yet. No player-owned assets yet.
</div> </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. Nothing matches the current view.
</div> </div>
<div v-else class="entity-browser-panel__sections"> <div v-else class="entity-browser-panel__sections">
<section <div class="entity-browser-table-wrap">
v-for="section in filteredSections" <table class="entity-browser-table entity-browser-table--tree">
:key="section.key" <colgroup>
class="entity-browser-section" <col class="entity-browser-table__col entity-browser-table__col--entity">
> <col class="entity-browser-table__col entity-browser-table__col--ident">
<header class="entity-browser-section__header"> <col class="entity-browser-table__col entity-browser-table__col--location">
<span>{{ section.label }}</span> <col class="entity-browser-table__col entity-browser-table__col--ai">
<span>{{ section.items.length }}</span> <col class="entity-browser-table__col entity-browser-table__col--hp">
</header> </colgroup>
<div class="entity-browser-section__items"> <thead>
<div <tr>
v-for="item in section.items" <th scope="col">
:key="item.key" <button type="button" class="entity-browser-table__sort" @click="toggleSort('entity')">
class="entity-browser-item" Entity{{ sortMarker("entity") }}
:class="isSelected(item) ? 'entity-browser-item--selected' : ''" </button>
> </th>
<button <th scope="col">Ident</th>
type="button" <th scope="col">
class="entity-browser-item__body" <button type="button" class="entity-browser-table__sort" @click="toggleSort('location')">
:disabled="!item.selection" Location{{ sortMarker("location") }}
@click="selectItem(item)" </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)"
> >
<div class="entity-browser-item__label">{{ item.label }}</div> <td class="entity-browser-table__name">
<div class="entity-browser-item__subtitle">{{ item.subtitle }}</div> <div class="entity-browser-row" :style="{ paddingLeft: `${row.depth * 0.9}rem` }">
<div v-if="item.meta" class="entity-browser-item__meta">{{ item.meta }}</div> <button
</button> v-if="row.children.length > 0"
<button type="button"
v-if="item.focusSelection" class="entity-browser-row__toggle"
type="button" @click.stop="toggleRow(row)"
class="entity-browser-item__focus" >
@click.stop="focusItem(item)" {{ isExpanded(row) ? "-" : "+" }}
> </button>
Focus <span v-else class="entity-browser-row__toggle entity-browser-row__toggle--spacer" />
</button> <span class="entity-browser-row__kind" :class="`entity-browser-row__kind--${row.kind}`">
</div> {{ row.kindLabel }}
</div> </span>
</section> <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>
</div> </div>
</section> </section>
</template> </template>

View File

@@ -2,7 +2,8 @@
import { computed, reactive, ref, watch } from "vue"; import { computed, reactive, ref, watch } from "vue";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import modulesData from "../../../../shared/data/modules.json"; 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 { import {
formatShipAutomationSupportStatus, formatShipAutomationSupportStatus,
getShipBehaviorLabel, getShipBehaviorLabel,
@@ -43,16 +44,23 @@ const behaviorForm = reactive({
areaSystemId: "", areaSystemId: "",
itemId: "ore", itemId: "ore",
}); });
const mineOrderForm = reactive({
systemId: "",
itemId: "ore",
});
const moveOrderSystemId = ref("");
const actionBusy = ref(false); const actionBusy = ref(false);
const actionStatus = ref(""); const actionStatus = ref("");
const actionError = 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>( const moduleNameById = new Map<string, string>(
(modulesData as { id: string; name: string }[]).map((module) => [module.id, module.name]), (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); 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(() => { const selectedShip = computed(() => {
if (selectedEntityKind.value !== "ship" || !selectedEntityId.value) { if (selectedEntityKind.value !== "ship" || !selectedEntityId.value) {
return null; return null;
@@ -100,18 +190,14 @@ const playerShipIds = computed(() =>
new Set(playerFaction.value?.assetRegistry.shipIds ?? []), new Set(playerFaction.value?.assetRegistry.shipIds ?? []),
); );
const canAccessGm = computed(() => authStore.canAccessGm); const canAccessGmDirectly = computed(() => authStore.canAccessGm && !authStore.isActingAsAlternateIdentity);
const canDirectControlSelectedShip = computed(() => 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(() => const directOrders = computed(() =>
selectedShip.value?.orderQueue.filter((order) => order.sourceKind !== "behavior") ?? [], selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "player") ?? [],
);
const behaviorOrders = computed(() =>
selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "behavior") ?? [],
); );
const editableBehaviorDefinitions = computed(() => const editableBehaviorDefinitions = computed(() =>
@@ -135,20 +221,293 @@ const formBehaviorNotes = computed(() =>
getShipBehaviorNotes(behaviorForm.kind), getShipBehaviorNotes(behaviorForm.kind),
); );
watch(selectedShip, (ship) => { const behaviorGeneratedOrderCount = computed(() =>
if (!ship) { 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";
actionStatus.value = "";
actionError.value = "";
expandedDirectOrderId.value = null;
},
{ immediate: true },
);
function supportsOrderField(kind: string, field: "targetSystemId" | "targetEntityId" | "itemId" | "waitSeconds" | "radius" | "maxSystemRange" | "knownStationsOnly") {
switch (field) {
case "targetSystemId":
return kind === "move" || kind === "mine-and-deliver";
case "targetEntityId":
return kind === "follow-ship" || kind === "attack-target";
case "itemId":
return kind === "mine-and-deliver";
case "waitSeconds":
return kind === "hold-position" || kind === "follow-ship";
case "radius":
return kind === "move" || kind === "follow-ship";
case "maxSystemRange":
return kind === "mine-and-deliver";
case "knownStationsOnly":
return kind === "mine-and-deliver";
default:
return false;
}
}
function loadOrderEditor(order: ShipOrderSnapshot) {
orderEditForm.label = order.label ?? "";
orderEditForm.priority = String(order.priority);
orderEditForm.interruptCurrentPlan = order.interruptCurrentPlan;
orderEditForm.targetSystemId = order.targetSystemId ?? "";
orderEditForm.targetEntityId = order.targetEntityId ?? "";
orderEditForm.itemId = order.itemId ?? "ore";
orderEditForm.waitSeconds = String(order.waitSeconds ?? 0);
orderEditForm.radius = String(order.radius ?? 0);
orderEditForm.maxSystemRange = order.maxSystemRange == null ? "" : String(order.maxSystemRange);
orderEditForm.knownStationsOnly = order.knownStationsOnly;
}
function toggleOrderEditor(order: ShipOrderSnapshot) {
if (expandedDirectOrderId.value === order.id) {
expandedDirectOrderId.value = null;
return; return;
} }
behaviorForm.kind = ship.defaultBehavior.kind; loadOrderEditor(order);
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? ""; expandedDirectOrderId.value = order.id;
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore"; }
mineOrderForm.systemId = ship.systemId ?? "";
mineOrderForm.itemId = "ore"; function parseNumber(value: string, fallback: number) {
moveOrderSystemId.value = ship.systemId ?? ""; const parsed = Number(value);
actionStatus.value = ""; return Number.isFinite(parsed) ? parsed : fallback;
actionError.value = ""; }
}, { immediate: true });
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") { function focusShip(cameraMode?: "follow" | "tactical") {
if (!selectedShip.value) { if (!selectedShip.value) {
@@ -197,7 +556,7 @@ async function saveBehavior() {
itemId: behaviorForm.kind === "local-auto-mine" itemId: behaviorForm.kind === "local-auto-mine"
? (behaviorForm.itemId.trim() || null) ? (behaviorForm.itemId.trim() || null)
: null, : null,
preferredNodeId: null, preferredAnchorId: null,
preferredConstructionSiteId: null, preferredConstructionSiteId: null,
preferredModuleId: null, preferredModuleId: null,
targetPosition: null, targetPosition: null,
@@ -212,114 +571,6 @@ async function saveBehavior() {
}, "Default behavior updated."); }, "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) { async function removeOrder(orderId: string) {
if (!selectedShip.value || !canDirectControlSelectedShip.value) { if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return; return;
@@ -357,51 +608,145 @@ async function clearOrders() {
</div> </div>
<div class="entity-inspector-panel__actions"> <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('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> </div>
</header> </header>
<div class="entity-inspector-section"> <div class="entity-inspector-section">
<h4>Status</h4> <h4>Status</h4>
<div class="entity-inspector-grid"> <div class="entity-inspector-table-wrap">
<div><span>State</span><strong>{{ titleCase(selectedShip.state) }}</strong></div> <table class="entity-inspector-table entity-inspector-table--kv">
<div><span>Behavior</span><strong>{{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</strong></div> <tbody>
<div><span>Control</span><strong>{{ selectedShip.controlSourceKind }}</strong></div> <tr v-for="row in shipStatusRows" :key="row.label">
<div><span>Assignment</span><strong>{{ selectedShip.assignment?.kind ?? "unassigned" }}</strong></div> <th scope="row">{{ row.label }}</th>
<div><span>Plan</span><strong>{{ selectedShip.activePlan ? `${selectedShip.activePlan.kind} · ${selectedShip.activePlan.status}` : "none" }}</strong></div> <td>{{ row.value }}</td>
<div><span>Failure</span><strong>{{ selectedShip.lastAccessFailureReason ?? "none" }}</strong></div> </tr>
</tbody>
</table>
</div> </div>
</div> </div>
<div class="entity-inspector-section"> <div class="entity-inspector-section">
<h4>Cargo</h4> <h4>Order Queue</h4>
<div class="entity-inspector-grid"> <div v-if="canDirectControlSelectedShip && directOrders.length > 0" class="entity-inspector-actions-row">
<div><span>Used</span><strong>{{ formatAmount(selectedShip.inventory.reduce((sum, entry) => sum + entry.amount, 0)) }}</strong></div> <button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="clearOrders">Clear Orders</button>
<div><span>Capacity</span><strong>{{ formatAmount(selectedShip.cargoCapacity) }}</strong></div> </div>
<div><span>Travel</span><strong>{{ formatAmount(selectedShip.travelSpeed) }} {{ selectedShip.travelSpeedUnit }}</strong></div> <div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
<div><span>Hull</span><strong>{{ formatAmount(selectedShip.health) }}</strong></div> <div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
<div v-if="directOrders.length > 0" class="entity-inspector-order-list">
<article v-for="order in directOrders" :key="order.id" class="entity-inspector-order-card">
<header class="entity-inspector-order-card__header">
<button
type="button"
class="entity-inspector-order-card__toggle"
:aria-expanded="expandedDirectOrderId === order.id"
@click="toggleOrderEditor(order)"
>
<span>{{ expandedDirectOrderId === order.id ? "▾" : "▸" }}</span>
<span>{{ getShipOrderLabel(order.kind) }}</span>
</button>
<div class="entity-inspector-order-card__actions">
<span class="entity-inspector-order-card__status">{{ titleCase(order.status) }}</span>
<button
v-if="canDirectControlSelectedShip"
type="button"
class="entity-inspector-order-remove"
:disabled="actionBusy"
@click="removeOrder(order.id)"
>
Remove
</button>
</div>
</header>
<div class="entity-inspector-order-card__summary">
<span>{{ describeOrderTarget(order) }}</span>
<span>{{ joinDetail([`P${order.priority}`, titleCase(order.sourceKind), describeOrderFailure(order) ?? undefined]) }}</span>
</div>
<div v-if="expandedDirectOrderId === order.id" class="entity-inspector-order-editor">
<div class="entity-inspector-note">
{{ [getShipOrderSupportStatusLabel(order.kind), getShipOrderNotes(order.kind)].filter(Boolean).join(" · ") }}
</div>
<div class="entity-inspector-form">
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Label</span>
<input v-model="orderEditForm.label" type="text" />
</label>
<div class="entity-inspector-inline-form">
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Priority</span>
<input v-model="orderEditForm.priority" type="number" min="0" step="1" />
</label>
<label class="entity-inspector-field entity-inspector-field--checkbox">
<span>Interrupt current plan</span>
<input v-model="orderEditForm.interruptCurrentPlan" type="checkbox" />
</label>
</div>
<label v-if="supportsOrderField(order.kind, 'targetSystemId')" class="entity-inspector-field entity-inspector-field--grow">
<span>Target System</span>
<select v-model="orderEditForm.targetSystemId">
<option value="">None</option>
<option v-for="system in gmStore.systems" :key="system.id" :value="system.id">{{ system.label }} ({{ system.id }})</option>
</select>
</label>
<label v-if="supportsOrderField(order.kind, 'targetEntityId')" class="entity-inspector-field entity-inspector-field--grow">
<span>Target Entity Id</span>
<input v-model="orderEditForm.targetEntityId" type="text" />
</label>
<label v-if="supportsOrderField(order.kind, 'itemId')" class="entity-inspector-field entity-inspector-field--grow">
<span>Ware</span>
<select v-model="orderEditForm.itemId">
<option v-for="itemId in commonMiningItems" :key="itemId" :value="itemId">{{ itemId }}</option>
</select>
</label>
<div v-if="supportsOrderField(order.kind, 'waitSeconds') || supportsOrderField(order.kind, 'radius')" class="entity-inspector-inline-form">
<label v-if="supportsOrderField(order.kind, 'waitSeconds')" class="entity-inspector-field entity-inspector-field--grow">
<span>Wait Seconds</span>
<input v-model="orderEditForm.waitSeconds" type="number" min="0" step="1" />
</label>
<label v-if="supportsOrderField(order.kind, 'radius')" class="entity-inspector-field entity-inspector-field--grow">
<span>Radius</span>
<input v-model="orderEditForm.radius" type="number" min="0" step="1" />
</label>
</div>
<div v-if="supportsOrderField(order.kind, 'maxSystemRange') || supportsOrderField(order.kind, 'knownStationsOnly')" class="entity-inspector-inline-form">
<label v-if="supportsOrderField(order.kind, 'maxSystemRange')" class="entity-inspector-field entity-inspector-field--grow">
<span>Max System Range</span>
<input v-model="orderEditForm.maxSystemRange" type="number" min="0" step="1" />
</label>
<label v-if="supportsOrderField(order.kind, 'knownStationsOnly')" class="entity-inspector-field entity-inspector-field--checkbox">
<span>Known Stations Only</span>
<input v-model="orderEditForm.knownStationsOnly" type="checkbox" />
</label>
</div>
<div class="entity-inspector-order-actions">
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="saveOrder(order)">Save</button>
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="toggleOrderEditor(order)">Cancel</button>
</div>
</div>
</div>
</article>
</div>
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
<div class="entity-inspector-note">
Behavior-generated queue entries are managed from Default Behavior.
<span v-if="behaviorGeneratedOrderCount > 0"> Active generated orders: {{ behaviorGeneratedOrderCount }}.</span>
</div> </div>
<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>
<div class="entity-inspector-section"> <div class="entity-inspector-section">
<h4>Behavior</h4> <h4>Default Behavior</h4>
<div v-if="selectedBehaviorStatus || selectedBehaviorNotes" class="entity-inspector-note"> <div v-if="selectedBehaviorStatus || selectedBehaviorNotes" class="entity-inspector-note">
{{ [selectedBehaviorStatus, selectedBehaviorNotes].filter(Boolean).join(" · ") }} {{ [selectedBehaviorStatus, selectedBehaviorNotes].filter(Boolean).join(" · ") }}
</div> </div>
<div class="entity-inspector-grid"> <div class="entity-inspector-table-wrap">
<div><span>Area</span><strong>{{ selectedShip.defaultBehavior.areaSystemId ?? "none" }}</strong></div> <table class="entity-inspector-table entity-inspector-table--kv">
<div><span>Item</span><strong>{{ selectedShip.defaultBehavior.itemId ?? "none" }}</strong></div> <tbody>
<div><span>Home Station</span><strong>{{ selectedShip.defaultBehavior.homeStationId ?? "none" }}</strong></div> <tr v-for="row in shipBehaviorRows" :key="row.label">
<div><span>Target</span><strong>{{ selectedShip.defaultBehavior.targetEntityId ?? "none" }}</strong></div> <th scope="row">{{ row.label }}</th>
<div><span>Range</span><strong>{{ selectedShip.defaultBehavior.maxSystemRange }}</strong></div> <td>{{ row.value }}</td>
<div><span>Known Only</span><strong>{{ selectedShip.defaultBehavior.knownStationsOnly ? "yes" : "no" }}</strong></div> </tr>
</tbody>
</table>
</div> </div>
<div v-if="canDirectControlSelectedShip" class="entity-inspector-form"> <div v-if="canDirectControlSelectedShip" class="entity-inspector-form">
<label class="entity-inspector-field"> <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. Direct behavior editing is only available for player-owned ships or GM users.
</div> </div>
</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>
<template v-else-if="selectedStation"> <template v-else-if="selectedStation">
@@ -537,46 +797,97 @@ async function clearOrders() {
<div class="entity-inspector-section"> <div class="entity-inspector-section">
<h4>Status</h4> <h4>Status</h4>
<div class="entity-inspector-grid"> <div class="entity-inspector-table-wrap">
<div><span>Category</span><strong>{{ titleCase(selectedStation.category) }}</strong></div> <table class="entity-inspector-table entity-inspector-table--kv">
<div><span>Objective</span><strong>{{ titleCase(selectedStation.objective) }}</strong></div> <tbody>
<div><span>Docked</span><strong>{{ selectedStation.dockedShips }} / {{ selectedStation.dockingPads }}</strong></div> <tr v-for="row in stationStatusRows" :key="row.label">
<div><span>Population</span><strong>{{ formatAmount(selectedStation.population) }} / {{ formatAmount(selectedStation.populationCapacity) }}</strong></div> <th scope="row">{{ row.label }}</th>
<div><span>Workforce</span><strong>{{ formatAmount(selectedStation.workforceRequired) }}</strong></div> <td>{{ row.value }}</td>
<div><span>Efficiency</span><strong>{{ Math.round(selectedStation.workforceEffectiveRatio * 100) }}%</strong></div> </tr>
</tbody>
</table>
</div> </div>
</div> </div>
<div class="entity-inspector-section"> <div class="entity-inspector-section">
<h4>Modules</h4> <h4>Modules</h4>
<ul v-if="selectedStation.installedModules.length > 0" class="entity-inspector-list"> <div v-if="stationModuleRows.length > 0" class="entity-inspector-table-wrap">
<li v-for="moduleId in selectedStation.installedModules" :key="moduleId"> <table class="entity-inspector-table">
<span>{{ moduleNameById.get(moduleId) ?? moduleId }}</span> <thead>
<strong>{{ moduleId }}</strong> <tr>
</li> <th scope="col">Module</th>
</ul> <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 v-else class="entity-inspector-empty">No modules installed.</div>
</div> </div>
<div class="entity-inspector-section"> <div class="entity-inspector-section">
<h4>Storage</h4> <h4>Storage</h4>
<ul v-if="selectedStation.inventory.length > 0" class="entity-inspector-list"> <div v-if="stationStorageRows.length > 0" class="entity-inspector-capacity-list">
<li v-for="entry in selectedStation.inventory" :key="entry.itemId"> <div v-for="row in stationStorageRows" :key="row.key" class="entity-inspector-capacity">
<span>{{ entry.itemId }}</span> <div class="entity-inspector-capacity__header">
<strong>{{ formatAmount(entry.amount) }}</strong> <span class="entity-inspector-capacity__label">{{ row.label }}</span>
</li> <span class="entity-inspector-capacity__value">{{ row.valueLabel }} / {{ row.maxLabel }}</span>
</ul> </div>
<div v-else class="entity-inspector-empty">No inventory.</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>
<div class="entity-inspector-section"> <div class="entity-inspector-section">
<h4>Production</h4> <h4>Production</h4>
<ul v-if="selectedStation.currentProcesses.length > 0" class="entity-inspector-list"> <div v-if="stationProcessRows.length > 0" class="entity-inspector-table-wrap">
<li v-for="process in selectedStation.currentProcesses" :key="`${process.lane}-${process.label}`"> <table class="entity-inspector-table">
<span>{{ process.label }}</span> <thead>
<strong>{{ Math.round(process.progress * 100) }}% · {{ Math.ceil(process.timeRemainingSeconds) }}s</strong> <tr>
</li> <th scope="col">Lane</th>
</ul> <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 v-else class="entity-inspector-empty">No active processes.</div>
</div> </div>
</template> </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 = type MenuAction =
| "mine-resource" | "mine-resource"
| "fly-to-and-wait" | "fly-to"
| "follow" | "follow"
| "attack"; | "attack";
@@ -50,7 +50,7 @@ const canControlSelectedShip = computed(() => {
return false; return false;
} }
if (authStore.canAccessGm) { if (authStore.canAccessGm && !authStore.isActingAsAlternateIdentity) {
return true; return true;
} }
@@ -105,13 +105,14 @@ const actions = computed<OrderMenuActionEntry[]>(() => {
case "station": case "station":
case "celestial": case "celestial":
case "construction-site": case "construction-site":
case "point":
return [{ return [{
key: "fly-to-and-wait", key: "fly-to",
orderKind: "fly-and-wait", orderKind: "move",
label: getShipOrderLabel("fly-and-wait"), label: getShipOrderLabel("move"),
detail: target.value.label, detail: target.value.label,
supportStatus: getShipOrderSupportStatusLabel("fly-and-wait"), supportStatus: getShipOrderSupportStatusLabel("move"),
notes: getShipOrderNotes("fly-and-wait"), notes: getShipOrderNotes("move"),
}]; }];
case "system": case "system":
return emptyActions(); return emptyActions();
@@ -157,7 +158,7 @@ async function runAction(action: MenuAction) {
sourceStationId: null, sourceStationId: null,
destinationStationId: null, destinationStationId: null,
itemId, itemId,
nodeId: target.value.selection.kind === "node" ? target.value.selection.id : null, anchorId: target.value.anchorId ?? null,
constructionSiteId: null, constructionSiteId: null,
moduleId: null, moduleId: null,
waitSeconds: 0, waitSeconds: 0,
@@ -170,9 +171,9 @@ async function runAction(action: MenuAction) {
return; return;
} }
if (action === "fly-to-and-wait") { if (action === "fly-to") {
const ship = await enqueueShipOrder(selectedShip.value.id, { const ship = await enqueueShipOrder(selectedShip.value.id, {
kind: "fly-and-wait", kind: "move",
priority: 100, priority: 100,
interruptCurrentPlan: true, interruptCurrentPlan: true,
label: `Fly to ${target.value.label}`, label: `Fly to ${target.value.label}`,
@@ -182,10 +183,10 @@ async function runAction(action: MenuAction) {
sourceStationId: null, sourceStationId: null,
destinationStationId: null, destinationStationId: null,
itemId: null, itemId: null,
nodeId: null, anchorId: null,
constructionSiteId: null, constructionSiteId: null,
moduleId: null, moduleId: null,
waitSeconds: 8, waitSeconds: 0,
radius: 0, radius: 0,
maxSystemRange: 0, maxSystemRange: 0,
knownStationsOnly: false, knownStationsOnly: false,
@@ -207,7 +208,7 @@ async function runAction(action: MenuAction) {
sourceStationId: null, sourceStationId: null,
destinationStationId: null, destinationStationId: null,
itemId: null, itemId: null,
nodeId: null, anchorId: null,
constructionSiteId: null, constructionSiteId: null,
moduleId: null, moduleId: null,
waitSeconds: 6, waitSeconds: 6,
@@ -232,7 +233,7 @@ async function runAction(action: MenuAction) {
sourceStationId: null, sourceStationId: null,
destinationStationId: null, destinationStationId: null,
itemId: null, itemId: null,
nodeId: null, anchorId: null,
constructionSiteId: null, constructionSiteId: null,
moduleId: null, moduleId: null,
waitSeconds: 0, waitSeconds: 0,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -207,7 +207,7 @@ export interface PlayerDirectiveSnapshot {
useOrders: boolean; useOrders: boolean;
stagingOrderKind?: string | null; stagingOrderKind?: string | null;
itemId?: string | null; itemId?: string | null;
preferredNodeId?: string | null; preferredAnchorId?: string | null;
preferredConstructionSiteId?: string | null; preferredConstructionSiteId?: string | null;
preferredModuleId?: string | null; preferredModuleId?: string | null;
priority: number; priority: number;
@@ -266,7 +266,10 @@ export interface PlayerAlertSnapshot {
export interface PlayerFactionSnapshot { export interface PlayerFactionSnapshot {
id: string; id: string;
label: string; label: string;
personaName?: string | null;
raceId?: string | null;
sovereignFactionId: string; sovereignFactionId: string;
requiresOnboarding: boolean;
status: string; status: string;
createdAtUtc: string; createdAtUtc: string;
updatedAtUtc: 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; sourceStationId?: string | null;
destinationStationId?: string | null; destinationStationId?: string | null;
itemId?: string | null; itemId?: string | null;
nodeId?: string | null; anchorId?: string | null;
constructionSiteId?: string | null; constructionSiteId?: string | null;
moduleId?: string | null; moduleId?: string | null;
waitSeconds: number; waitSeconds: number;
@@ -43,7 +43,7 @@ export interface ShipOrderTemplateSnapshot {
sourceStationId?: string | null; sourceStationId?: string | null;
destinationStationId?: string | null; destinationStationId?: string | null;
itemId?: string | null; itemId?: string | null;
nodeId?: string | null; anchorId?: string | null;
constructionSiteId?: string | null; constructionSiteId?: string | null;
moduleId?: string | null; moduleId?: string | null;
waitSeconds: number; waitSeconds: number;
@@ -59,7 +59,7 @@ export interface DefaultBehaviorSnapshot {
areaSystemId?: string | null; areaSystemId?: string | null;
targetEntityId?: string | null; targetEntityId?: string | null;
itemId?: string | null; itemId?: string | null;
preferredNodeId?: string | null; preferredAnchorId?: string | null;
preferredConstructionSiteId?: string | null; preferredConstructionSiteId?: string | null;
preferredModuleId?: string | null; preferredModuleId?: string | null;
targetPosition?: Vector3Dto | null; targetPosition?: Vector3Dto | null;
@@ -100,7 +100,9 @@ export interface ShipSubTaskSnapshot {
summary: string; summary: string;
targetEntityId?: string | null; targetEntityId?: string | null;
targetSystemId?: string | null; targetSystemId?: string | null;
targetNodeId?: string | null; targetAnchorId?: string | null;
targetResourceNodeId?: string | null;
targetResourceDepositId?: string | null;
targetPosition?: Vector3Dto | null; targetPosition?: Vector3Dto | null;
itemId?: string | null; itemId?: string | null;
moduleId?: string | null; moduleId?: string | null;
@@ -112,37 +114,13 @@ export interface ShipSubTaskSnapshot {
blockingReason?: string | null; 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 { export interface ShipSnapshot {
id: string; id: string;
name: string; name: string;
purpose: string; purpose: string;
type: string; type: string;
systemId: string; systemId: string;
anchorId?: string | null;
localPosition: Vector3Dto; localPosition: Vector3Dto;
localVelocity: Vector3Dto; localVelocity: Vector3Dto;
targetLocalPosition: Vector3Dto; targetLocalPosition: Vector3Dto;
@@ -151,19 +129,17 @@ export interface ShipSnapshot {
defaultBehavior: DefaultBehaviorSnapshot; defaultBehavior: DefaultBehaviorSnapshot;
assignment?: ShipAssignmentSnapshot | null; assignment?: ShipAssignmentSnapshot | null;
skills: ShipSkillProfileSnapshot; skills: ShipSkillProfileSnapshot;
activePlan?: ShipPlanSnapshot | null;
currentStepId?: string | null;
activeSubTasks: ShipSubTaskSnapshot[]; activeSubTasks: ShipSubTaskSnapshot[];
controlSourceKind: string; controlSourceKind: string;
controlSourceId?: string | null; controlSourceId?: string | null;
controlReason?: string | null; controlReason?: string | null;
lastReplanReason?: string | null; lastReplanReason?: string | null;
lastAccessFailureReason?: string | null; lastAccessFailureReason?: string | null;
celestialId?: string | null;
dockedStationId?: string | null; dockedStationId?: string | null;
commanderId?: string | null; commanderId?: string | null;
policySetId?: string | null; policySetId?: string | null;
cargoCapacity: number; cargoCapacity: number;
cargoTypes: string[];
travelSpeed: number; travelSpeed: number;
travelSpeedUnit: string; travelSpeedUnit: string;
inventory: InventoryEntry[]; inventory: InventoryEntry[];
@@ -178,18 +154,18 @@ export interface ShipDelta extends ShipSnapshot {}
export interface ShipSpatialStateSnapshot { export interface ShipSpatialStateSnapshot {
spaceLayer: string; spaceLayer: string;
currentSystemId: string; currentSystemId: string;
currentCelestialId?: string | null; currentAnchorId?: string | null;
localPosition?: Vector3Dto | null; localPosition?: Vector3Dto | null;
systemPosition?: Vector3Dto | null; systemPosition?: Vector3Dto | null;
movementRegime: string; movementRegime: string;
destinationNodeId?: string | null; destinationAnchorId?: string | null;
transit?: ShipTransitSnapshot | null; transit?: ShipTransitSnapshot | null;
} }
export interface ShipTransitSnapshot { export interface ShipTransitSnapshot {
regime: string; regime: string;
originNodeId?: string | null; originAnchorId?: string | null;
destinationNodeId?: string | null; destinationAnchorId?: string | null;
startedAtUtc?: string | null; startedAtUtc?: string | null;
arrivalDueAtUtc?: string | null; arrivalDueAtUtc?: string | null;
progress: number; progress: number;

View File

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

View File

@@ -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; sourceStationId?: string | null;
destinationStationId?: string | null; destinationStationId?: string | null;
itemId?: string | null; itemId?: string | null;
preferredNodeId?: string | null; preferredAnchorId?: string | null;
preferredConstructionSiteId?: string | null; preferredConstructionSiteId?: string | null;
preferredModuleId?: string | null; preferredModuleId?: string | null;
priority: number; priority: number;

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ body,
margin: 0; margin: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 100dvh;
overflow: hidden; overflow: hidden;
background: background:
radial-gradient(circle at top, rgba(89, 132, 247, 0.16), transparent 30%), radial-gradient(circle at top, rgba(89, 132, 247, 0.16), transparent 30%),
@@ -269,12 +270,230 @@ select {
canvas { canvas {
display: block; display: block;
touch-action: none;
} }
.viewer-app, .viewer-app,
.viewer-canvas-host { .viewer-canvas-host {
width: 100%; 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%; 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, .panel-summary,
@@ -450,112 +669,6 @@ canvas {
gap: 8px; 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-copy,
.history-window-close { .history-window-close {
border: 1px solid rgba(127, 214, 255, 0.22); border: 1px solid rgba(127, 214, 255, 0.22);
@@ -566,64 +679,15 @@ canvas {
cursor: pointer; 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-copy,
.history-window-close { .history-window-close {
padding: 8px 12px; 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 { .selection-action-button {
pointer-events: auto; 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 Windows ──────────────────────────────────────────────────────────── */
.gm-window { .gm-window {
@@ -1257,54 +1321,253 @@ canvas {
color: rgba(173, 220, 255, 0.64); color: rgba(173, 220, 255, 0.64);
} }
.entity-browser-section__items { .entity-browser-table-wrap,
display: flex; .entity-inspector-table-wrap {
flex-direction: column; overflow: auto;
gap: 0.45rem; border: 1px solid rgba(255, 255, 255, 0.08);
}
.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;
border-radius: 1rem; border-radius: 1rem;
background: rgba(255, 255, 255, 0.03);
} }
.entity-browser-item__body:disabled { .entity-browser-table,
opacity: 0.82; .entity-inspector-table {
cursor: default; width: 100%;
border-collapse: collapse;
min-width: 0;
} }
.entity-browser-item--selected .entity-browser-item__body { .entity-browser-table {
border-color: rgba(116, 196, 255, 0.38); 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); background: rgba(116, 196, 255, 0.12);
} }
.entity-browser-item__label { .entity-browser-table__name {
font-size: 0.88rem; font-size: 0.78rem;
font-weight: 600; font-weight: 600;
} }
.entity-browser-item__subtitle, .entity-browser-table__cell--truncate {
.entity-browser-item__meta { overflow: hidden;
margin-top: 0.18rem; text-overflow: ellipsis;
font-size: 0.75rem; white-space: nowrap;
}
.entity-browser-table__cell--ai {
overflow: hidden;
}
.entity-browser-table__detail,
.entity-inspector-table__detail {
color: var(--viewer-muted); 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 { .entity-inspector-panel__action {
padding: 0.65rem 0.9rem; padding: 0.5rem 0.72rem;
font-size: 0.78rem; font-size: 0.72rem;
align-self: center; 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 { .entity-inspector-panel__actions {
display: flex; display: flex;
gap: 0.45rem; gap: 0.45rem;
@@ -1328,71 +1591,87 @@ canvas {
color: rgba(173, 220, 255, 0.7); color: rgba(173, 220, 255, 0.7);
} }
.entity-inspector-grid { .entity-inspector-table--kv th {
display: grid; width: 38%;
grid-template-columns: repeat(2, minmax(0, 1fr)); background: rgba(255, 255, 255, 0.02);
gap: 0.7rem 0.9rem; font-size: 0.68rem;
}
.entity-inspector-grid span {
display: block;
font-size: 0.72rem;
color: var(--viewer-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.12em;
color: rgba(173, 220, 255, 0.68);
} }
.entity-inspector-grid strong { .entity-inspector-table--kv td {
display: block; font-size: 0.84rem;
margin-top: 0.15rem;
font-size: 0.86rem;
font-weight: 600; font-weight: 600;
} }
.entity-inspector-list, .entity-inspector-table__row--subtask {
.entity-inspector-plan, background: rgba(255, 255, 255, 0.02);
.entity-inspector-subtasks {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
} }
.entity-inspector-list li, .entity-inspector-table__subtask {
.entity-inspector-plan__step, padding-left: 1.45rem;
.entity-inspector-subtasks li { }
.entity-inspector-table__subtask::before {
content: "↳ ";
color: rgba(173, 220, 255, 0.58);
}
.entity-inspector-capacity-list {
display: flex; 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; justify-content: space-between;
gap: 1rem; gap: 0.7rem;
align-items: baseline;
padding: 0.55rem 0.7rem;
border-radius: 0.9rem;
background: rgba(255, 255, 255, 0.035);
} }
.entity-inspector-list span, .entity-inspector-capacity__header {
.entity-inspector-plan__step span, margin-bottom: 0.45rem;
.entity-inspector-subtasks span {
font-size: 0.8rem;
} }
.entity-inspector-list strong, .entity-inspector-capacity__label {
.entity-inspector-plan__step strong, font-size: 0.74rem;
.entity-inspector-subtasks strong { letter-spacing: 0.12em;
font-size: 0.75rem; text-transform: uppercase;
color: var(--viewer-muted); color: rgba(173, 220, 255, 0.72);
} }
.entity-inspector-plan > li { .entity-inspector-capacity__value,
display: flex; .entity-inspector-capacity__scale span {
flex-direction: column; font-family: var(--viewer-mono-font);
gap: 0.4rem; font-size: 0.76rem;
color: rgba(255, 255, 255, 0.78);
} }
.entity-inspector-subtasks { .entity-inspector-capacity__track {
padding-left: 0.8rem; position: relative;
flex: 1 1 auto;
min-width: 0;
height: 0.65rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.entity-inspector-capacity__fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: inherit;
background: linear-gradient(90deg, rgba(116, 196, 255, 0.5), rgba(116, 196, 255, 0.9));
} }
.entity-inspector-panel__fallback { .entity-inspector-panel__fallback {
@@ -1425,6 +1704,65 @@ canvas {
align-items: center; 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 { .entity-inspector-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1436,6 +1774,10 @@ canvas {
flex: 1 1 auto; flex: 1 1 auto;
} }
.entity-inspector-field--checkbox {
justify-content: flex-end;
}
.entity-inspector-field span { .entity-inspector-field span {
font-size: 0.72rem; font-size: 0.72rem;
color: var(--viewer-muted); color: var(--viewer-muted);
@@ -1459,6 +1801,14 @@ canvas {
border-color: rgba(173, 220, 255, 0.4); 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 { .entity-inspector-note {
margin-top: 0.9rem; margin-top: 0.9rem;
color: var(--viewer-muted); color: var(--viewer-muted);
@@ -1592,8 +1942,47 @@ canvas {
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.entity-inspector-grid { .viewer-left-sidebar-dock {
grid-template-columns: minmax(0, 1fr); 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, .entity-inspector-inline-form,
@@ -1602,4 +1991,9 @@ canvas {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.entity-browser-table,
.entity-inspector-table {
min-width: 640px;
}
} }

View File

@@ -1,10 +1,19 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import type { AuthSessionResponse } from "../../contractsAuth"; import type { AuthSessionResponse } from "../../contractsAuth";
import type { PlayerIdentitySummary } from "../../contractsIdentity";
import { clearAuthSession, getAuthSession, setAuthSession, subscribeToAuthSession } from "../../authSession"; import { clearAuthSession, getAuthSession, setAuthSession, subscribeToAuthSession } from "../../authSession";
import {
clearEffectivePlayerIdentityId,
getEffectivePlayerIdentityId,
setEffectivePlayerIdentityId,
subscribeToEffectivePlayerIdentity,
} from "../../effectiveIdentitySession";
export const useAuthStore = defineStore("auth", { export const useAuthStore = defineStore("auth", {
state: () => ({ state: () => ({
session: getAuthSession() as AuthSessionResponse | null, session: getAuthSession() as AuthSessionResponse | null,
effectivePlayerId: getEffectivePlayerIdentityId() as string | null,
availablePlayerIdentities: [] as PlayerIdentitySummary[],
busy: false, busy: false,
initialized: false, initialized: false,
}), }),
@@ -14,19 +23,35 @@ export const useAuthStore = defineStore("auth", {
roles: (state) => state.session?.roles ?? [], roles: (state) => state.session?.roles ?? [],
canAccessGm: (state) => (state.session?.roles ?? []).some((role) => role === "gm" || role === "admin"), canAccessGm: (state) => (state.session?.roles ?? []).some((role) => role === "gm" || role === "admin"),
accessToken: (state) => state.session?.accessToken ?? null, 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: { actions: {
setSession(session: AuthSessionResponse | null) { setSession(session: AuthSessionResponse | null) {
this.session = session; this.session = session;
setAuthSession(session); setAuthSession(session);
if (!session || !(session.roles ?? []).some((role) => role === "gm" || role === "admin")) {
this.effectivePlayerId = null;
clearEffectivePlayerIdentityId();
}
}, },
clearSession() { clearSession() {
this.session = null; this.session = null;
this.effectivePlayerId = null;
this.availablePlayerIdentities = [];
clearAuthSession(); clearAuthSession();
clearEffectivePlayerIdentityId();
}, },
setBusy(busy: boolean) { setBusy(busy: boolean) {
this.busy = busy; 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() { initialize() {
if (this.initialized) { if (this.initialized) {
return; return;
@@ -36,6 +61,9 @@ export const useAuthStore = defineStore("auth", {
subscribeToAuthSession((session) => { subscribeToAuthSession((session) => {
this.session = session as AuthSessionResponse | null; 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 { Vector3Dto } from "../../contractsCommon";
import type { Selectable } from "../../viewerTypes"; import type { Selectable } from "../../viewerTypes";
export interface ViewerOrderContextMenuPointSelection {
kind: "point";
id: "local-point";
}
export interface ViewerOrderContextMenuTarget { export interface ViewerOrderContextMenuTarget {
selection: Selectable; selection: Selectable | ViewerOrderContextMenuPointSelection;
label: string; label: string;
systemId?: string | null; systemId?: string | null;
anchorId?: string | null;
itemId?: string | null; itemId?: string | null;
targetPosition?: Vector3Dto | null; targetPosition?: Vector3Dto | null;
} }

View File

@@ -18,7 +18,7 @@ interface ResolveSelectionPositionParams {
nodeVisuals: Map<string, NodeVisual>; nodeVisuals: Map<string, NodeVisual>;
planetVisuals: PlanetVisual[]; planetVisuals: PlanetVisual[];
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3; computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3; resolvePointPosition: (systemId: string, celestialId?: string | null, anchorId?: string | null) => THREE.Vector3;
} }
interface FocusOnSelectionParams extends ResolveSelectionPositionParams { interface FocusOnSelectionParams extends ResolveSelectionPositionParams {
@@ -47,7 +47,7 @@ interface SeedSystemFocusParams {
nodeVisuals: Map<string, NodeVisual>; nodeVisuals: Map<string, NodeVisual>;
planetVisuals: PlanetVisual[]; planetVisuals: PlanetVisual[];
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3; computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3; resolvePointPosition: (systemId: string, celestialId?: string | null, anchorId?: string | null) => THREE.Vector3;
} }
interface CameraFocusParams { interface CameraFocusParams {
@@ -92,10 +92,10 @@ export function updatePanFromKeyboard(
const right = new THREE.Vector3(-forward.z, 0, forward.x); const right = new THREE.Vector3(-forward.z, 0, forward.x);
const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z)); const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z));
if (activeSystemId) { 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, 80, 4000, KILOMETERS_PER_AU * 0.002, KILOMETERS_PER_AU * 0.35)
: THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 40, 180000); : THREE.MathUtils.mapLinear(currentDistance, Math.max(minimumDistance, 4), 4000, 8, 6000);
systemAnchor.addScaledVector(pan, speedKilometers * delta); systemAnchor.addScaledVector(pan, panSpeed * delta);
return; return;
} }
@@ -103,6 +103,49 @@ export function updatePanFromKeyboard(
galaxyAnchor.addScaledVector(pan, speed * delta); 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 { export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined {
const { const {
world, world,
@@ -199,11 +242,11 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams)
} }
if (selection.kind === "claim") { if (selection.kind === "claim") {
const claim = world.claims.get(selection.id); const claim = world.claims.get(selection.id);
return claim ? resolvePointPosition(claim.systemId, claim.celestialId) : undefined; return claim ? resolvePointPosition(claim.systemId, null, claim.anchorId) : undefined;
} }
if (selection.kind === "construction-site") { if (selection.kind === "construction-site") {
const site = world.constructionSites.get(selection.id); const site = world.constructionSites.get(selection.id);
return site ? resolvePointPosition(site.systemId, site.celestialId) : undefined; return site ? resolvePointPosition(site.systemId, null, site.anchorId) : undefined;
} }
if (selection.kind === "planet") { if (selection.kind === "planet") {
const system = world.systems.get(selection.systemId); const system = world.systems.get(selection.systemId);
@@ -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 * System scene coordinate system: star at origin, all positions scaled by
* DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE. * DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE.
*/ */

View File

@@ -1,7 +1,7 @@
import type { PovLevel } from "./viewerTypes"; import type { PovLevel } from "./viewerTypes";
export const NAV_DISTANCE: Record<PovLevel, number> = { export const NAV_DISTANCE: Record<PovLevel, number> = {
local: 18, local: 180,
system: 3200, system: 3200,
galaxy: 32000, 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. // 0.00005 units = ~3 km — allows scrolling very close to ships and structures.
export const MIN_CAMERA_DISTANCE = 0.00005; export const MIN_CAMERA_DISTANCE = 0.00005;
export const MAX_CAMERA_DISTANCE = 150000; 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 { export interface ZoomBlend {
localWeight: number; localWeight: number;

View File

@@ -5,6 +5,8 @@ import { ViewerPresentationController } from "./viewerPresentationController";
import { ViewerSceneDataController } from "./viewerSceneDataController"; import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerWorldLifecycle } from "./viewerWorldLifecycle"; import { ViewerWorldLifecycle } from "./viewerWorldLifecycle";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController"; import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import { applyPanFromScreenDelta } from "./viewerCamera";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE } from "./viewerConstants";
import { useViewerSceneStore } from "./ui/stores/viewerScene"; import { useViewerSceneStore } from "./ui/stores/viewerScene";
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu"; import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
import { viewerPinia } from "./ui/stores/pinia"; import { viewerPinia } from "./ui/stores/pinia";
@@ -28,8 +30,14 @@ export function createViewerControllers(host: any) {
claimGroup: host.systemLayer.claimGroup, claimGroup: host.systemLayer.claimGroup,
constructionSiteGroup: host.systemLayer.constructionSiteGroup, constructionSiteGroup: host.systemLayer.constructionSiteGroup,
shipGroup: host.systemLayer.shipGroup, shipGroup: host.systemLayer.shipGroup,
localNodeGroup: host.localLayer.nodeGroup,
localStationGroup: host.localLayer.stationGroup,
localClaimGroup: host.localLayer.claimGroup,
localConstructionSiteGroup: host.localLayer.constructionSiteGroup,
localShipGroup: host.localLayer.shipGroup,
galaxySelectableTargets: host.galaxyLayer.selectableTargets, galaxySelectableTargets: host.galaxyLayer.selectableTargets,
systemSelectableTargets: host.systemLayer.selectableTargets, systemSelectableTargets: host.systemLayer.selectableTargets,
localSelectableTargets: host.localLayer.selectableTargets,
systemVisuals: host.galaxyLayer.systemVisuals, systemVisuals: host.galaxyLayer.systemVisuals,
planetVisuals: host.systemLayer.planetVisuals, planetVisuals: host.systemLayer.planetVisuals,
celestialVisuals: host.systemLayer.celestialVisuals, celestialVisuals: host.systemLayer.celestialVisuals,
@@ -38,6 +46,11 @@ export function createViewerControllers(host: any) {
claimVisuals: host.systemLayer.claimVisuals, claimVisuals: host.systemLayer.claimVisuals,
constructionSiteVisuals: host.systemLayer.constructionSiteVisuals, constructionSiteVisuals: host.systemLayer.constructionSiteVisuals,
shipVisuals: host.systemLayer.shipVisuals, shipVisuals: host.systemLayer.shipVisuals,
localNodeVisuals: host.localLayer.nodeVisuals,
localStationVisuals: host.localLayer.stationVisuals,
localClaimVisuals: host.localLayer.claimVisuals,
localConstructionSiteVisuals: host.localLayer.constructionSiteVisuals,
localShipVisuals: host.localLayer.shipVisuals,
}); });
const navigationController = new ViewerNavigationController({ const navigationController = new ViewerNavigationController({
@@ -99,6 +112,7 @@ export function createViewerControllers(host: any) {
getCameraMode: () => host.cameraMode, getCameraMode: () => host.cameraMode,
getCameraTargetShipId: () => host.cameraTargetShipId, getCameraTargetShipId: () => host.cameraTargetShipId,
getPovLevel: () => host.povLevel, getPovLevel: () => host.povLevel,
getFocusedAnchorId: () => host.resolveFocusedAnchorId(),
getSelectedItems: () => host.selectedItems, getSelectedItems: () => host.selectedItems,
getWorldTimeSyncMs: () => host.worldTimeSyncMs, getWorldTimeSyncMs: () => host.worldTimeSyncMs,
getCurrentDistance: () => host.currentDistance, getCurrentDistance: () => host.currentDistance,
@@ -150,8 +164,9 @@ export function createViewerControllers(host: any) {
applyClaimDeltas: (claims) => sceneDataController.applyClaimDeltas(claims), applyClaimDeltas: (claims) => sceneDataController.applyClaimDeltas(claims),
applyConstructionSiteDeltas: (sites) => sceneDataController.applyConstructionSiteDeltas(sites), applyConstructionSiteDeltas: (sites) => sceneDataController.applyConstructionSiteDeltas(sites),
applyShipDeltas: (ships, tickIntervalMs) => sceneDataController.applyShipDeltas(ships, tickIntervalMs), applyShipDeltas: (ships, tickIntervalMs) => sceneDataController.applyShipDeltas(ships, tickIntervalMs),
refreshLocalLayer: () => sceneDataController.refreshLocalLayer(host.world, host.resolveFocusedAnchorId()),
refreshHistoryWindows: () => host.refreshHistoryWindows(), refreshHistoryWindows: () => host.refreshHistoryWindows(),
resolveFocusedCelestialId: () => host.resolveFocusedCelestialId(), resolveFocusedAnchorId: () => host.resolveFocusedAnchorId(),
updateSystemSummaries: () => host.updateSystemSummaries(), updateSystemSummaries: () => host.updateSystemSummaries(),
applyZoomPresentation: () => presentationController.applyZoomPresentation(), applyZoomPresentation: () => presentationController.applyZoomPresentation(),
updateNetworkPanel: () => presentationController.updateNetworkPanel(), updateNetworkPanel: () => presentationController.updateNetworkPanel(),
@@ -189,8 +204,10 @@ export function createViewerControllers(host: any) {
mouse: host.mouse, mouse: host.mouse,
galaxyCamera: host.galaxyLayer.camera, galaxyCamera: host.galaxyLayer.camera,
systemCamera: host.systemLayer.camera, systemCamera: host.systemLayer.camera,
localCamera: host.localLayer.camera,
galaxySelectableTargets: host.galaxyLayer.selectableTargets, galaxySelectableTargets: host.galaxyLayer.selectableTargets,
systemSelectableTargets: host.systemLayer.selectableTargets, systemSelectableTargets: host.systemLayer.selectableTargets,
localSelectableTargets: host.localLayer.selectableTargets,
hoverLabelEl: host.hoverLabelEl, hoverLabelEl: host.hoverLabelEl,
hoverConnectorLineEl: host.hoverConnectorLineEl, hoverConnectorLineEl: host.hoverConnectorLineEl,
marqueeEl: host.marqueeEl, marqueeEl: host.marqueeEl,
@@ -235,15 +252,27 @@ export function createViewerControllers(host: any) {
}, },
getFollowCameraPosition: () => host.followCameraPosition, getFollowCameraPosition: () => host.followCameraPosition,
getFollowCameraFocus: () => host.followCameraFocus, getFollowCameraFocus: () => host.followCameraFocus,
getLocalRootPosition: () => host.localLayer.localRoot.position.clone(),
getFocusedAnchorId: () => host.resolveFocusedAnchorId(),
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y), screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
applyOrbitDelta: (delta: THREE.Vector2) => { applyPanDelta: (delta: THREE.Vector2) => {
if (host.cameraMode === "follow") { const bounds = host.renderer.domElement.getBoundingClientRect();
host.followOrbitYaw += delta.x * 0.008; applyPanFromScreenDelta(
host.followOrbitPitch = THREE.MathUtils.clamp(host.followOrbitPitch + delta.y * 0.004, 0.02, 1.45); delta,
} else { host.orbitYaw,
host.orbitYaw += delta.x * 0.008; host.currentDistance,
host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3); 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(), syncFollowStateFromSelection: () => navigationController.syncFollowStateFromSelection(),
updatePanels: () => host.updatePanels(), updatePanels: () => host.updatePanels(),
@@ -251,6 +280,11 @@ export function createViewerControllers(host: any) {
updateGamePanel: (mode) => host.updateGamePanel(mode), updateGamePanel: (mode) => host.updateGamePanel(mode),
openOrderContextMenu: (x, y, target) => orderContextMenuStore.open(x, y, target), openOrderContextMenu: (x, y, target) => orderContextMenuStore.open(x, y, target),
closeOrderContextMenu: () => orderContextMenuStore.close(), closeOrderContextMenu: () => orderContextMenuStore.close(),
getStatsOverlayMode: () => host.hudState.statsOverlay.mode,
setStatsOverlayMode: (mode) => {
host.hudState.statsOverlay.mode = mode;
},
refreshStatsOverlay: () => presentationController.refreshStatsOverlay(),
historyController, historyController,
}); });
@@ -269,6 +303,7 @@ export function wireViewerEvents(host: any) {
canvas.addEventListener("pointerdown", host.interactionController.onPointerDown); canvas.addEventListener("pointerdown", host.interactionController.onPointerDown);
canvas.addEventListener("pointermove", host.interactionController.onPointerMove); canvas.addEventListener("pointermove", host.interactionController.onPointerMove);
canvas.addEventListener("pointerup", host.interactionController.onPointerUp); canvas.addEventListener("pointerup", host.interactionController.onPointerUp);
canvas.addEventListener("pointercancel", host.interactionController.onPointerUp);
canvas.addEventListener("pointerleave", host.interactionController.onPointerUp); canvas.addEventListener("pointerleave", host.interactionController.onPointerUp);
canvas.addEventListener("click", host.interactionController.onClick); canvas.addEventListener("click", host.interactionController.onClick);
canvas.addEventListener("contextmenu", host.interactionController.onContextMenu); canvas.addEventListener("contextmenu", host.interactionController.onContextMenu);
@@ -284,6 +319,7 @@ export function wireViewerEvents(host: any) {
canvas.removeEventListener("pointerdown", host.interactionController.onPointerDown); canvas.removeEventListener("pointerdown", host.interactionController.onPointerDown);
canvas.removeEventListener("pointermove", host.interactionController.onPointerMove); canvas.removeEventListener("pointermove", host.interactionController.onPointerMove);
canvas.removeEventListener("pointerup", host.interactionController.onPointerUp); canvas.removeEventListener("pointerup", host.interactionController.onPointerUp);
canvas.removeEventListener("pointercancel", host.interactionController.onPointerUp);
canvas.removeEventListener("pointerleave", host.interactionController.onPointerUp); canvas.removeEventListener("pointerleave", host.interactionController.onPointerUp);
canvas.removeEventListener("click", host.interactionController.onClick); canvas.removeEventListener("click", host.interactionController.onClick);
canvas.removeEventListener("contextmenu", host.interactionController.onContextMenu); canvas.removeEventListener("contextmenu", host.interactionController.onContextMenu);

View File

@@ -1,10 +1,12 @@
import * as THREE from "three"; 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 { scaleGalaxyVector, toThreeVector } from "./viewerMath";
import { rawObject } from "./viewerScenePrimitives"; import { rawObject } from "./viewerScenePrimitives";
import { resolveShipWorldPosition } from "./viewerWorldPresentation"; import { resolveShipWorldPosition } from "./viewerWorldPresentation";
import type { StatsOverlayMode } from "./viewerHudState";
import type { import type {
CameraMode, CameraMode,
PovLevel,
Selectable, Selectable,
ShipVisual, ShipVisual,
SystemVisual, SystemVisual,
@@ -147,9 +149,11 @@ export function updateFollowCamera(params: {
if (ship.spatialState.movementRegime === "ftl-transit") { if (ship.spatialState.movementRegime === "ftl-transit") {
systemAnchor.set(0, 0, 0); systemAnchor.set(0, 0, 0);
const destinationNodeId = ship.spatialState.transit?.destinationNodeId; const destinationAnchorId = ship.spatialState.transit?.destinationAnchorId ?? ship.spatialState.destinationAnchorId;
const destinationCelestial = destinationNodeId ? world.celestials.get(destinationNodeId) : undefined; const destinationAnchor = destinationAnchorId ? world.anchors.get(destinationAnchorId) : undefined;
const destinationSystem = destinationCelestial ? world.systems.get(destinationCelestial.systemId) : undefined; const destinationSystem = destinationAnchor
? world.systems.get(destinationAnchor.systemId)
: undefined;
const originSystem = world.systems.get(ship.systemId); const originSystem = world.systems.get(ship.systemId);
if (originSystem && destinationSystem) { if (originSystem && destinationSystem) {
followCameraDesiredDirection followCameraDesiredDirection
@@ -209,10 +213,12 @@ export function setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opa
material.needsUpdate = true; 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 clampedDelta = THREE.MathUtils.clamp(deltaY, -180, 180);
const zoomFactor = Math.exp(clampedDelta * 0.00135); 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: { export function applyKeyboardControl(params: {
@@ -250,3 +256,20 @@ export function applyKeyboardControl(params: {
return { cameraMode, desiredDistance }; 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; bodyText: string;
} }
export type StatsOverlayMode = "hidden" | "compact" | "status" | "network" | "performance" | "full";
export interface StatsOverlayState {
mode: StatsOverlayMode;
lines: string[];
}
export interface HudHtmlPanelState { export interface HudHtmlPanelState {
hidden: boolean; hidden: boolean;
title: string; title: string;
@@ -25,43 +32,6 @@ export interface HudProgressBar {
progress: number; 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 { export interface HistoryWindowState {
id: string; id: string;
target: Selectable; target: Selectable;
@@ -100,10 +70,10 @@ export interface ViewerHudState {
gamePanel: HudPanelState; gamePanel: HudPanelState;
networkPanel: HudPanelState; networkPanel: HudPanelState;
performancePanel: HudPanelState; performancePanel: HudPanelState;
statsOverlay: StatsOverlayState;
systemPanel: HudHtmlPanelState; systemPanel: HudHtmlPanelState;
detailPanel: HudHtmlPanelState; detailPanel: HudHtmlPanelState;
error: HudErrorState; error: HudErrorState;
opsStrip: OpsStripState;
historyWindows: HistoryWindowState[]; historyWindows: HistoryWindowState[];
hoverLabel: HoverLabelState; hoverLabel: HoverLabelState;
marquee: MarqueeState; marquee: MarqueeState;
@@ -135,6 +105,10 @@ export function createViewerHudState(): ViewerHudState {
summary: "Waiting", summary: "Waiting",
bodyText: "Waiting for frame samples.", bodyText: "Waiting for frame samples.",
}, },
statsOverlay: {
mode: "compact",
lines: [],
},
systemPanel: { systemPanel: {
hidden: false, hidden: false,
title: "Deep Space", title: "Deep Space",
@@ -149,11 +123,6 @@ export function createViewerHudState(): ViewerHudState {
hidden: true, hidden: true,
message: "", message: "",
}, },
opsStrip: {
factions: [],
stations: [],
ships: [],
},
historyWindows: [], historyWindows: [],
hoverLabel: { hoverLabel: {
hidden: true, hidden: true,

View File

@@ -1,7 +1,7 @@
import * as THREE from "three"; import * as THREE from "three";
import { describeHoverLabel, getSelectionGroup } from "./viewerSelection"; import { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants"; 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 { HoverLabelState, MarqueeState } from "./viewerHudState";
import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes"; import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes";
@@ -36,14 +36,17 @@ export function pickSelectableAtClientPosition(
renderer: THREE.WebGLRenderer, renderer: THREE.WebGLRenderer,
raycaster: THREE.Raycaster, raycaster: THREE.Raycaster,
mouse: THREE.Vector2, mouse: THREE.Vector2,
povLevel: PovLevel,
galaxyCamera: THREE.Camera, galaxyCamera: THREE.Camera,
galaxySelectableTargets: Map<THREE.Object3D, Selectable>, galaxySelectableTargets: Map<THREE.Object3D, Selectable>,
systemCamera: THREE.Camera, systemCamera: THREE.Camera,
systemSelectableTargets: Map<THREE.Object3D, Selectable>, systemSelectableTargets: Map<THREE.Object3D, Selectable>,
localCamera: THREE.Camera,
localSelectableTargets: Map<THREE.Object3D, Selectable>,
clientX: number, clientX: number,
clientY: number, clientY: number,
) { ) {
const hit = pickSelectableHitAtClientPosition(renderer, raycaster, mouse, galaxyCamera, galaxySelectableTargets, systemCamera, systemSelectableTargets, clientX, clientY); const hit = pickSelectableHitAtClientPosition(renderer, raycaster, mouse, povLevel, galaxyCamera, galaxySelectableTargets, systemCamera, systemSelectableTargets, localCamera, localSelectableTargets, clientX, clientY);
return hit?.selection; return hit?.selection;
} }
@@ -51,13 +54,23 @@ export function pickSelectableHitAtClientPosition(
renderer: THREE.WebGLRenderer, renderer: THREE.WebGLRenderer,
raycaster: THREE.Raycaster, raycaster: THREE.Raycaster,
mouse: THREE.Vector2, mouse: THREE.Vector2,
povLevel: PovLevel,
galaxyCamera: THREE.Camera, galaxyCamera: THREE.Camera,
galaxySelectableTargets: Map<THREE.Object3D, Selectable>, galaxySelectableTargets: Map<THREE.Object3D, Selectable>,
systemCamera: THREE.Camera, systemCamera: THREE.Camera,
systemSelectableTargets: Map<THREE.Object3D, Selectable>, systemSelectableTargets: Map<THREE.Object3D, Selectable>,
localCamera: THREE.Camera,
localSelectableTargets: Map<THREE.Object3D, Selectable>,
clientX: number, clientX: number,
clientY: number, clientY: number,
): HoverPickResult | undefined { ): HoverPickResult | undefined {
if (povLevel === "local") {
const localHit = pickOneCamera(renderer, raycaster, mouse, localCamera, localSelectableTargets, clientX, clientY);
if (localHit) {
return localHit;
}
}
// Try system camera first (higher priority when in a system) // Try system camera first (higher priority when in a system)
const systemHit = pickOneCamera(renderer, raycaster, mouse, systemCamera, systemSelectableTargets, clientX, clientY); const systemHit = pickOneCamera(renderer, raycaster, mouse, systemCamera, systemSelectableTargets, clientX, clientY);
if (systemHit) { if (systemHit) {
@@ -156,13 +169,17 @@ function formatHoverDistance(
|| selection.kind === "construction-site"; || selection.kind === "construction-site";
if (inActiveSystem && activeSystemId) { if (inActiveSystem && activeSystemId) {
if (povLevel === "local") {
return formatAdaptiveDistanceFromMeters(displayDistance);
}
const kilometers = displayDistance / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE); const kilometers = displayDistance / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
return povLevel === "system" return povLevel === "system"
? formatSystemDistance(kilometers / KILOMETERS_PER_AU) ? formatSystemDistance(kilometers / KILOMETERS_PER_AU)
: formatAdaptiveDistanceFromKilometers(kilometers); : formatAdaptiveDistanceFromKilometers(kilometers);
} }
return formatAdaptiveDistanceFromKilometers(displayDistance / DISPLAY_UNITS_PER_KILOMETER); return formatAdaptiveDistanceFromKilometers((displayDistance / DISPLAY_UNITS_PER_KILOMETER) / METERS_PER_KILOMETER);
} }
export function updateMarqueeBox( export function updateMarqueeBox(

View File

@@ -1,20 +1,18 @@
import * as THREE from "three"; import * as THREE from "three";
import { import {
completeMarqueeSelection,
hideMarqueeBox,
pickSelectableHitAtClientPosition, pickSelectableHitAtClientPosition,
pickSelectableAtClientPosition, pickSelectableAtClientPosition,
updateHoverLabel, updateHoverLabel,
updateMarqueeBox,
} from "./viewerInteraction"; } from "./viewerInteraction";
import { import {
applyKeyboardControl, applyKeyboardControl,
cycleStatsOverlayMode,
toggleCameraMode, toggleCameraMode,
navigateFromWheel, navigateFromWheel,
} from "./viewerControls"; } 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 { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import type { ViewerHudState } from "./viewerHudState"; import type { StatsOverlayMode, ViewerHudState } from "./viewerHudState";
import type { import type {
CameraMode, CameraMode,
DragMode, DragMode,
@@ -22,7 +20,10 @@ import type {
WorldState, WorldState,
PovLevel, PovLevel,
} from "./viewerTypes"; } from "./viewerTypes";
import type { ViewerOrderContextMenuTarget } from "./ui/stores/viewerOrderContextMenu"; import type {
ViewerOrderContextMenuPointSelection,
ViewerOrderContextMenuTarget,
} from "./ui/stores/viewerOrderContextMenu";
export interface ViewerInteractionContext { export interface ViewerInteractionContext {
renderer: THREE.WebGLRenderer; renderer: THREE.WebGLRenderer;
@@ -30,8 +31,10 @@ export interface ViewerInteractionContext {
mouse: THREE.Vector2; mouse: THREE.Vector2;
galaxyCamera: THREE.PerspectiveCamera; galaxyCamera: THREE.PerspectiveCamera;
systemCamera: THREE.PerspectiveCamera; systemCamera: THREE.PerspectiveCamera;
localCamera: THREE.PerspectiveCamera;
galaxySelectableTargets: Map<THREE.Object3D, Selectable>; galaxySelectableTargets: Map<THREE.Object3D, Selectable>;
systemSelectableTargets: Map<THREE.Object3D, Selectable>; systemSelectableTargets: Map<THREE.Object3D, Selectable>;
localSelectableTargets: Map<THREE.Object3D, Selectable>;
hoverLabelEl: HTMLDivElement; hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement; hoverConnectorLineEl: SVGLineElement;
marqueeEl: HTMLDivElement; marqueeEl: HTMLDivElement;
@@ -60,89 +63,131 @@ export interface ViewerInteractionContext {
setCameraTargetShipId: (value: string | undefined) => void; setCameraTargetShipId: (value: string | undefined) => void;
getFollowCameraPosition: () => THREE.Vector3; getFollowCameraPosition: () => THREE.Vector3;
getFollowCameraFocus: () => THREE.Vector3; getFollowCameraFocus: () => THREE.Vector3;
getLocalRootPosition: () => THREE.Vector3;
getFocusedAnchorId: () => string | undefined;
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2; screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
applyOrbitDelta: (delta: THREE.Vector2) => void; applyPanDelta: (delta: THREE.Vector2) => void;
syncFollowStateFromSelection: () => void; syncFollowStateFromSelection: () => void;
updatePanels: () => void; updatePanels: () => void;
focusOnSelection: (selection: Selectable) => void; focusOnSelection: (selection: Selectable) => void;
updateGamePanel: (mode: string) => void; updateGamePanel: (mode: string) => void;
openOrderContextMenu: (x: number, y: number, target: ViewerOrderContextMenuTarget) => void; openOrderContextMenu: (x: number, y: number, target: ViewerOrderContextMenuTarget) => void;
closeOrderContextMenu: () => void; closeOrderContextMenu: () => void;
getStatsOverlayMode: () => StatsOverlayMode;
setStatsOverlayMode: (mode: StatsOverlayMode) => void;
refreshStatsOverlay: () => void;
historyController: ViewerHistoryWindowController; historyController: ViewerHistoryWindowController;
} }
export class ViewerInteractionController { 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) {} constructor(private readonly context: ViewerInteractionContext) {}
readonly onPointerDown = (event: PointerEvent) => { 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) { if (event.button !== 0) {
return; return;
} }
this.context.setDragMode("marquee"); const point = this.context.screenPointFromClient(event.clientX, event.clientY);
this.context.setDragPointerId(event.pointerId); this.activePointers.set(event.pointerId, point);
this.context.dragStart.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
this.context.dragLast.copy(this.context.dragStart);
this.context.setMarqueeActive(false);
this.context.renderer.domElement.setPointerCapture(event.pointerId); 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) => { readonly onPointerMove = (event: PointerEvent) => {
this.updateHoverLabel(event); 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()) { if (this.context.getDragPointerId() !== event.pointerId || !this.context.getDragMode()) {
return; return;
} }
const point = this.context.screenPointFromClient(event.clientX, event.clientY); if (this.context.getDragMode() === "pinch") {
if (this.context.getDragMode() === "orbit") { const gesture = this.getPinchGesture();
const delta = point.clone().sub(this.context.dragLast); if (!gesture || !this.pinchStartDistance || !this.pinchStartZoom || !this.pinchLastCenter) {
this.context.dragLast.copy(point); return;
this.context.applyOrbitDelta(delta); }
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; return;
} }
const delta = point.clone().sub(this.context.dragLast);
const dragDistance = point.distanceTo(this.context.dragStart); const dragDistance = point.distanceTo(this.context.dragStart);
if (!this.context.getMarqueeActive() && dragDistance > 8) { if (dragDistance > 6) {
this.context.setMarqueeActive(true);
this.context.setSuppressClickSelection(true); 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); 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) => { readonly onPointerUp = (event: PointerEvent) => {
if (this.context.getDragPointerId() !== event.pointerId) {
return;
}
if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) { if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) {
this.context.renderer.domElement.releasePointerCapture(event.pointerId); this.context.renderer.domElement.releasePointerCapture(event.pointerId);
} }
this.activePointers.delete(event.pointerId);
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) { if (this.activePointers.size >= 2) {
this.completeMarqueeSelection(); const gesture = this.getPinchGesture();
hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl); if (gesture) {
this.context.setDragMode("pinch");
this.pinchStartDistance = gesture.distance;
this.pinchStartZoom = this.context.getDesiredDistance();
this.pinchLastCenter = gesture.center;
}
return;
} }
this.context.setDragMode(undefined); const remainingPointer = this.activePointers.entries().next();
this.context.setDragPointerId(undefined); if (!remainingPointer.done) {
this.context.setMarqueeActive(false); 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.pinchStartDistance = undefined;
this.pinchStartZoom = undefined;
this.pinchLastCenter = undefined;
}; };
readonly onClick = (event: MouseEvent) => { readonly onClick = (event: MouseEvent) => {
@@ -166,11 +211,9 @@ export class ViewerInteractionController {
this.context.closeOrderContextMenu(); this.context.closeOrderContextMenu();
const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY); const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY);
if (!picked) { const target = picked
return; ? this.buildOrderContextTarget(picked)
} : this.buildLocalPointContextTarget(event.clientX, event.clientY);
const target = this.buildOrderContextTarget(picked);
if (!target) { if (!target) {
return; return;
} }
@@ -178,72 +221,6 @@ export class ViewerInteractionController {
this.context.openOrderContextMenu(event.clientX, event.clientY, target); 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 onHistoryLayerClick = (event: MouseEvent) => this.context.historyController.onHistoryLayerClick(event);
readonly onHistoryLayerPointerDown = (event: PointerEvent) => this.context.historyController.onHistoryLayerPointerDown(event); readonly onHistoryLayerPointerDown = (event: PointerEvent) => this.context.historyController.onHistoryLayerPointerDown(event);
@@ -268,8 +245,7 @@ export class ViewerInteractionController {
} }
if (selection.kind === "ship") { if (selection.kind === "ship") {
this.toggleCameraMode("follow"); this.toggleCameraMode("tactical");
this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL);
this.context.updatePanels(); this.context.updatePanels();
this.context.updateGamePanel("Live"); this.context.updateGamePanel("Live");
return; return;
@@ -278,7 +254,7 @@ export class ViewerInteractionController {
readonly onWheel = (event: WheelEvent) => { readonly onWheel = (event: WheelEvent) => {
event.preventDefault(); 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"); this.context.updateGamePanel("Live");
}; };
@@ -288,6 +264,13 @@ export class ViewerInteractionController {
} }
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
if (key === "f10") {
event.preventDefault();
this.context.setStatsOverlayMode(cycleStatsOverlayMode(this.context.getStatsOverlayMode()));
this.context.refreshStatsOverlay();
return;
}
const controlState = applyKeyboardControl({ const controlState = applyKeyboardControl({
keyState: this.context.keyState, keyState: this.context.keyState,
cameraMode: this.context.getCameraMode(), cameraMode: this.context.getCameraMode(),
@@ -348,10 +331,13 @@ export class ViewerInteractionController {
this.context.renderer, this.context.renderer,
this.context.raycaster, this.context.raycaster,
this.context.mouse, this.context.mouse,
this.context.getPovLevel(),
this.context.galaxyCamera, this.context.galaxyCamera,
this.context.galaxySelectableTargets, this.context.galaxySelectableTargets,
this.context.systemCamera, this.context.systemCamera,
this.context.systemSelectableTargets, this.context.systemSelectableTargets,
this.context.localCamera,
this.context.localSelectableTargets,
clientX, clientX,
clientY, clientY,
); );
@@ -362,26 +348,29 @@ export class ViewerInteractionController {
this.context.renderer, this.context.renderer,
this.context.raycaster, this.context.raycaster,
this.context.mouse, this.context.mouse,
this.context.getPovLevel(),
this.context.galaxyCamera, this.context.galaxyCamera,
this.context.galaxySelectableTargets, this.context.galaxySelectableTargets,
this.context.systemCamera, this.context.systemCamera,
this.context.systemSelectableTargets, this.context.systemSelectableTargets,
this.context.localCamera,
this.context.localSelectableTargets,
clientX, clientX,
clientY, clientY,
); );
} }
private completeMarqueeSelection() { private getPinchGesture() {
const selection = completeMarqueeSelection({ const points = [...this.activePointers.values()];
renderer: this.context.renderer, if (points.length < 2) {
systemCamera: this.context.systemCamera, return null;
dragStart: this.context.dragStart, }
dragLast: this.context.dragLast,
systemSelectableTargets: this.context.systemSelectableTargets, const [first, second] = points;
}); return {
this.context.setSelectedItems(selection); center: first.clone().add(second).multiplyScalar(0.5),
this.context.syncFollowStateFromSelection(); distance: first.distanceTo(second),
this.context.updatePanels(); };
} }
private shouldFocusSelectionOnClick(selection: Selectable) { private shouldFocusSelectionOnClick(selection: Selectable) {
@@ -423,6 +412,7 @@ export class ViewerInteractionController {
selection, selection,
label: node.itemId, label: node.itemId,
systemId: node.systemId, systemId: node.systemId,
anchorId: node.anchorId,
itemId: node.itemId, itemId: node.itemId,
targetPosition: node.localPosition, targetPosition: node.localPosition,
} : null; } : null;
@@ -456,4 +446,45 @@ export class ViewerInteractionController {
return null; 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 * as THREE from "three";
import type {
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
Selectable,
ShipVisual,
StructureVisual,
} from "./viewerTypes";
/** /**
* Local rendering layer. * Local rendering layer.
* Scene coordinate unit: reserved for future close-up detail. * Scene coordinate unit: reserved for future close-up detail.
* Camera far plane covers immediate surroundings. * Camera far plane covers immediate surroundings.
* Currently empty — populated when local-space objects are introduced.
*/ */
export class LocalLayer { export class LocalLayer {
readonly 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 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); private static readonly ORIGIN = new THREE.Vector3(0, 0, 0);
updateCamera(orbitOffset: THREE.Vector3) { constructor() {
this.camera.position.copy(orbitOffset); this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.8));
this.camera.lookAt(LocalLayer.ORIGIN); 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) { onResize(aspect: number) {
@@ -26,3 +69,13 @@ export class LocalLayer {
renderer.render(this.scene, this.camera); 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"; import type { ZoomBlend } from "./viewerConstants";
export const KILOMETERS_PER_AU = 149_597_870.7; 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_KILOMETER = 0.0000015;
export const DISPLAY_UNITS_PER_LIGHT_YEAR = 2600; export const DISPLAY_UNITS_PER_LIGHT_YEAR = 2600;
@@ -44,7 +45,7 @@ function formatNumber(value: number, fractionDigits: number) {
} }
export function formatLocalDistance(value: number): string { 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 { export function formatSystemDistance(value: number): string {
@@ -76,6 +77,16 @@ export function formatAdaptiveDistanceFromKilometers(kilometers: number): string
return `${formatNumber(meters, meters >= 100 ? 0 : 1)} m`; 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 { export function formatShipSpeed(ship: ShipSnapshot): string {
const speed = Math.max(0, ship.travelSpeed); const speed = Math.max(0, ship.travelSpeed);
const unit = ship.travelSpeedUnit; const unit = ship.travelSpeedUnit;
@@ -107,7 +118,7 @@ export function smoothBand(value: number, start: number, end: number): number {
} }
export function computeZoomBlend(distance: number): ZoomBlend { export function computeZoomBlend(distance: number): ZoomBlend {
const localToSystem = smoothBand(distance, 1200, 5200); const localToSystem = smoothBand(distance, 120, 650);
const systemToUniverse = smoothBand(distance, 9000, 22000); const systemToUniverse = smoothBand(distance, 9000, 22000);
return { return {

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