Compare commits
8 Commits
706e1cda8f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8503855a4c | |||
| 6c92ab50c8 | |||
| d0c6e30304 | |||
| 75568324f5 | |||
| fdcf83ccec | |||
| 74b8bf4116 | |||
| c9a4b474b4 | |||
| 63a9f808bb |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,3 +17,5 @@ pnpm-debug.log*
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
.codex
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
22
apps/backend/Auth/Api/GetRacesHandler.cs
Normal file
22
apps/backend/Auth/Api/GetRacesHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
7
apps/backend/Auth/Contracts/Races.cs
Normal file
7
apps/backend/Auth/Contracts/Races.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SpaceGame.Api.Auth.Contracts;
|
||||||
|
|
||||||
|
public sealed record RaceSnapshot(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Description,
|
||||||
|
string Icon);
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -4,5 +4,7 @@ public interface IPlayerIdentityResolver
|
|||||||
{
|
{
|
||||||
Guid? GetCurrentPlayerId();
|
Guid? GetCurrentPlayerId();
|
||||||
Guid GetRequiredPlayerId();
|
Guid GetRequiredPlayerId();
|
||||||
|
Guid? GetEffectivePlayerId();
|
||||||
|
Guid GetRequiredEffectivePlayerId();
|
||||||
bool CanAccessGm();
|
bool CanAccessGm();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs
Normal file
73
apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs
Normal 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);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
3
apps/backend/Properties/AssemblyInfo.cs
Normal file
3
apps/backend/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("SpaceGame.Api.Tests")]
|
||||||
@@ -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."),
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
ship.SpatialState.SystemPosition = originPosition;
|
||||||
|
transit.Progress = Math.Clamp(elapsedSeconds / totalDuration, 0f, 1f);
|
||||||
|
subTask.Progress = transit.Progress;
|
||||||
return SubTaskOutcome.Active;
|
return SubTaskOutcome.Active;
|
||||||
}
|
}
|
||||||
|
|
||||||
ship.State = ShipState.Warping;
|
ship.State = ShipState.Warping;
|
||||||
}
|
var warpTravelDuration = MathF.Max(0.001f, totalDuration - spoolDurationSeconds);
|
||||||
|
var travelElapsed = Math.Clamp(elapsedSeconds - spoolDurationSeconds, 0f, warpTravelDuration);
|
||||||
var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null
|
var travelProgress = Math.Clamp(travelElapsed / warpTravelDuration, 0f, 1f);
|
||||||
? ship.Position.DistanceTo(targetPosition)
|
var travelDelta = destinationPosition.Subtract(originPosition);
|
||||||
: (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition)));
|
ship.Position = Vector3.Zero;
|
||||||
ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds);
|
ship.TargetPosition = Vector3.Zero;
|
||||||
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,293 +1,149 @@
|
|||||||
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),
|
||||||
]),
|
|
||||||
CreateStep("step-deliver", "deliver-cargo", $"Deliver {route.ItemId} to {route.DestinationStation.Label}",
|
|
||||||
[
|
|
||||||
CreateSubTask("sub-route-travel", ShipTaskKinds.Travel, $"Travel to {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(route.DestinationStation.Radius + 12f, 12f), 0f),
|
CreateSubTask("sub-route-travel", ShipTaskKinds.Travel, $"Travel to {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(route.DestinationStation.Radius + 12f, 12f), 0f),
|
||||||
CreateSubTask("sub-route-dock", ShipTaskKinds.Dock, $"Dock at {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 4f, 0f),
|
CreateSubTask("sub-route-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-unload", ShipTaskKinds.UnloadCargo, $"Unload {route.ItemId}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
|
||||||
CreateSubTask("sub-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f)
|
CreateSubTask("sub-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f),
|
||||||
])
|
];
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime BuildFleetSupplyPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, FleetSupplyPlan plan)
|
private IReadOnlyList<ShipSubTaskRuntime> BuildFleetSupplySubTasks(FleetSupplyPlan plan)
|
||||||
{
|
{
|
||||||
return CreatePlan(
|
return
|
||||||
ship,
|
|
||||||
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-fleet-source-travel", ShipTaskKinds.Travel, $"Travel to {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, MathF.Max(plan.SourceStation.Radius + 12f, 12f), 0f),
|
||||||
CreateSubTask("sub-fleet-source-dock", ShipTaskKinds.Dock, $"Dock at {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 4f, 0f),
|
CreateSubTask("sub-fleet-source-dock", ShipTaskKinds.Dock, $"Dock at {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 4f, 0f),
|
||||||
CreateSubTask("sub-fleet-source-load", ShipTaskKinds.LoadCargo, $"Load {plan.ItemId}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 0f, plan.Amount, itemId: plan.ItemId),
|
CreateSubTask("sub-fleet-source-load", ShipTaskKinds.LoadCargo, $"Load {plan.ItemId}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 0f, plan.Amount, itemId: plan.ItemId),
|
||||||
CreateSubTask("sub-fleet-source-undock", ShipTaskKinds.Undock, $"Undock from {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 12f, 0f),
|
CreateSubTask("sub-fleet-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-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Name}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, 6f),
|
||||||
CreateSubTask("sub-fleet-transfer", ShipTaskKinds.TransferCargoToShip, $"Transfer {plan.ItemId}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, plan.Amount, itemId: plan.ItemId),
|
CreateSubTask("sub-fleet-transfer", ShipTaskKinds.TransferCargoToShip, $"Transfer {plan.ItemId}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, plan.Amount, itemId: plan.ItemId),
|
||||||
])
|
];
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime BuildConstructionPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ConstructionSiteRuntime site, StationRuntime supportStation, string summary)
|
private IReadOnlyList<ShipSubTaskRuntime> BuildConstructionSubTasks(ConstructionSiteRuntime site, StationRuntime supportStation)
|
||||||
{
|
{
|
||||||
var targetPosition = site.StationId is null ? supportStation.Position : supportStation.Position;
|
var targetPosition = supportStation.Position;
|
||||||
return CreatePlan(
|
return
|
||||||
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-construction-travel", ShipTaskKinds.Travel, $"Travel to support station {supportStation.Label}", supportStation.SystemId, targetPosition, supportStation.Id, MathF.Max(supportStation.Radius + 18f, 18f), 0f),
|
||||||
CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f)
|
CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f),
|
||||||
]),
|
CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f),
|
||||||
CreateStep("step-construction-build", "build-site", $"Build {site.Id}",
|
];
|
||||||
[
|
|
||||||
CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f)
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime BuildAttackPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string? targetSystemId, string summary)
|
private static IReadOnlyList<ShipSubTaskRuntime> BuildAttackSubTasks(string targetEntityId, string? targetSystemId, string summary)
|
||||||
{
|
{
|
||||||
return CreatePlan(
|
return
|
||||||
ship,
|
|
||||||
sourceKind,
|
|
||||||
sourceId,
|
|
||||||
ShipOrderKinds.AttackTarget,
|
|
||||||
summary,
|
|
||||||
[
|
[
|
||||||
CreateStep("step-attack", ShipOrderKinds.AttackTarget, summary,
|
CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? string.Empty, Vector3.Zero, targetEntityId, 26f, 0f),
|
||||||
[
|
];
|
||||||
CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? ship.SystemId, ship.Position, targetEntityId, 26f, 0f)
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime BuildDockAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime station, float waitSeconds, string summary)
|
private static IReadOnlyList<ShipSubTaskRuntime> BuildFlyToObjectSubTasks(string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary)
|
||||||
{
|
{
|
||||||
return CreatePlan(
|
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-dock-wait-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f),
|
|
||||||
CreateSubTask("sub-dock-wait-hold", ShipTaskKinds.HoldPosition, $"Wait at {station.Label}", station.SystemId, station.Position, station.Id, 0f, waitSeconds),
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ShipPlanRuntime BuildFlyAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, float waitSeconds, string summary)
|
|
||||||
{
|
|
||||||
return CreatePlan(
|
|
||||||
ship,
|
|
||||||
sourceKind,
|
|
||||||
sourceId,
|
|
||||||
ShipOrderKinds.FlyAndWait,
|
|
||||||
summary,
|
|
||||||
[
|
|
||||||
CreateStep("step-fly-wait", ShipOrderKinds.FlyAndWait, summary,
|
|
||||||
[
|
|
||||||
CreateSubTask("sub-fly-wait-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, null, 6f, 0f),
|
|
||||||
CreateSubTask("sub-fly-wait-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, null, 0f, waitSeconds),
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ShipPlanRuntime BuildFlyToObjectPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary)
|
|
||||||
{
|
|
||||||
return CreatePlan(
|
|
||||||
ship,
|
|
||||||
sourceKind,
|
|
||||||
sourceId,
|
|
||||||
ShipOrderKinds.FlyToObject,
|
|
||||||
summary,
|
|
||||||
[
|
|
||||||
CreateStep("step-fly-object", ShipOrderKinds.FlyToObject, summary,
|
|
||||||
[
|
[
|
||||||
CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f),
|
CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f),
|
||||||
CreateSubTask("sub-fly-object-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, targetEntityId, 0f, MathF.Max(1f, ship.DefaultBehavior.WaitSeconds)),
|
];
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime BuildFollowShipPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ShipRuntime targetShip, float radius, float durationSeconds, string summary)
|
private static IReadOnlyList<ShipSubTaskRuntime> BuildFollowShipSubTasks(ShipRuntime targetShip, float radius, float durationSeconds, string summary) =>
|
||||||
{
|
BuildFollowSubTasks(targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary);
|
||||||
return BuildFollowPlan(ship, sourceKind, sourceId, targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ShipPlanRuntime BuildFollowPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary)
|
private static IReadOnlyList<ShipSubTaskRuntime> BuildFollowSubTasks(string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary)
|
||||||
{
|
{
|
||||||
return CreatePlan(
|
return
|
||||||
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-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds),
|
||||||
])
|
];
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime CreateIdlePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary)
|
private static IReadOnlyList<ShipSubTaskRuntime> BuildHoldSubTasks(ShipRuntime ship, ShipOrderRuntime order)
|
||||||
{
|
{
|
||||||
return CreatePlan(
|
return
|
||||||
ship,
|
|
||||||
sourceKind,
|
|
||||||
sourceId,
|
|
||||||
Idle,
|
|
||||||
summary,
|
|
||||||
[
|
[
|
||||||
CreateStep("step-idle", ShipOrderKinds.HoldPosition, summary,
|
CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<ShipSubTaskRuntime> BuildMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node, StationRuntime homeStation)
|
||||||
|
{
|
||||||
|
var deposit = SelectMiningDeposit(node, ship.Id);
|
||||||
|
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
|
||||||
|
return
|
||||||
[
|
[
|
||||||
CreateSubTask("sub-idle", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 1.5f)
|
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId),
|
||||||
])
|
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id),
|
||||||
]);
|
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
|
||||||
|
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
|
||||||
|
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity()),
|
||||||
|
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime CreateBlockedPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary, string blockingReason)
|
private IReadOnlyList<ShipSubTaskRuntime> BuildLocalMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node)
|
||||||
{
|
{
|
||||||
var subTask = CreateSubTask("sub-blocked", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 0f);
|
var deposit = SelectMiningDeposit(node, ship.Id);
|
||||||
subTask.Status = WorkStatus.Blocked;
|
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
|
||||||
subTask.BlockingReason = blockingReason;
|
return
|
||||||
|
[
|
||||||
var step = CreateStep("step-blocked", "blocked", summary, [subTask]);
|
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId),
|
||||||
step.Status = AiPlanStepStatus.Blocked;
|
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),
|
||||||
step.BlockingReason = blockingReason;
|
];
|
||||||
|
|
||||||
var plan = CreatePlan(ship, sourceKind, sourceId, "blocked", summary, [step]);
|
|
||||||
plan.Status = AiPlanStatus.Blocked;
|
|
||||||
plan.FailureReason = blockingReason;
|
|
||||||
return plan;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ShipPlanRuntime CreatePlan(
|
private IReadOnlyList<ShipSubTaskRuntime> BuildLocalMiningDeliverySubTasks(ShipRuntime ship, StationRuntime buyer, string itemId)
|
||||||
ShipRuntime ship,
|
|
||||||
AiPlanSourceKind sourceKind,
|
|
||||||
string sourceId,
|
|
||||||
string kind,
|
|
||||||
string summary,
|
|
||||||
IReadOnlyList<ShipPlanStepRuntime> steps)
|
|
||||||
{
|
{
|
||||||
var plan = new ShipPlanRuntime
|
var amount = MathF.Max(1f, GetInventoryAmount(ship.Inventory, itemId));
|
||||||
{
|
return
|
||||||
Id = $"plan-{ship.Id}-{Guid.NewGuid():N}",
|
[
|
||||||
SourceKind = sourceKind,
|
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(buyer.Radius + 12f, 12f), 0f),
|
||||||
SourceId = sourceId,
|
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 4f, 0f),
|
||||||
Kind = kind,
|
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload {itemId} at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 0f, amount, itemId: itemId),
|
||||||
Summary = summary,
|
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(4f, 12f), 0f),
|
||||||
};
|
];
|
||||||
plan.Steps.AddRange(steps);
|
|
||||||
return plan;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ShipPlanStepRuntime CreateStep(string id, string kind, string summary, IReadOnlyList<ShipSubTaskRuntime> subTasks)
|
private IReadOnlyList<ShipSubTaskRuntime> BuildSalvageSubTasks(ShipRuntime ship, WreckRuntime wreck, StationRuntime homeStation, Vector3 approach)
|
||||||
{
|
{
|
||||||
var step = new ShipPlanStepRuntime
|
return
|
||||||
{
|
[
|
||||||
Id = id,
|
CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {wreck.Id}", wreck.SystemId, approach, wreck.Id, 8f, 0f),
|
||||||
Kind = kind,
|
CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {wreck.ItemId}", wreck.SystemId, approach, wreck.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
|
||||||
Summary = summary,
|
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),
|
||||||
step.SubTasks.AddRange(subTasks);
|
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),
|
||||||
return step;
|
CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ShipSubTaskRuntime CreateSubTask(
|
private static ShipSubTaskRuntime CreateSubTask(
|
||||||
@@ -301,7 +157,9 @@ public sealed partial class ShipAiService
|
|||||||
float amount,
|
float amount,
|
||||||
string? itemId = null,
|
string? itemId = null,
|
||||||
string? moduleId = null,
|
string? moduleId = null,
|
||||||
string? targetNodeId = null) =>
|
string? targetAnchorId = null,
|
||||||
|
string? targetResourceNodeId = null,
|
||||||
|
string? targetResourceDepositId = null) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
@@ -310,7 +168,9 @@ public sealed partial class ShipAiService
|
|||||||
TargetSystemId = targetSystemId,
|
TargetSystemId = targetSystemId,
|
||||||
TargetPosition = targetPosition,
|
TargetPosition = targetPosition,
|
||||||
TargetEntityId = targetEntityId,
|
TargetEntityId = targetEntityId,
|
||||||
TargetNodeId = targetNodeId,
|
TargetAnchorId = targetAnchorId,
|
||||||
|
TargetResourceNodeId = targetResourceNodeId,
|
||||||
|
TargetResourceDepositId = targetResourceDepositId,
|
||||||
ItemId = itemId,
|
ItemId = itemId,
|
||||||
ModuleId = moduleId,
|
ModuleId = moduleId,
|
||||||
Threshold = threshold,
|
Threshold = threshold,
|
||||||
|
|||||||
@@ -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",
|
private IReadOnlyList<ShipSubTaskRuntime>? BuildOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||||
[
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
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),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order)
|
return
|
||||||
|
[
|
||||||
|
CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), 0f),
|
||||||
|
CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ShipSubTaskRuntime> BuildMoveSubTasks(ShipRuntime ship, ShipOrderRuntime order)
|
||||||
{
|
{
|
||||||
var targetSystemId = order.TargetSystemId ?? ship.SystemId;
|
var 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, MathF.Max(0f, order.Radius), 0f),
|
||||||
[
|
];
|
||||||
CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, 0f, 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", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f),
|
||||||
CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(balance.ArrivalThreshold, station.Radius + 12f), 0f)
|
];
|
||||||
]),
|
|
||||||
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)
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
private void EnsureOrderExecution(SimulationWorld world, ShipRuntime ship, ICollection<SimulationEventRecord> events)
|
||||||
var desiredSourceId = topOrder?.Id ?? ResolveBehaviorSource(world, ship).SourceId;
|
{
|
||||||
var currentPlan = ship.ActivePlan;
|
var currentOrder = ship.OrderQueue.GetCurrentOrder();
|
||||||
|
if (currentOrder is null)
|
||||||
|
{
|
||||||
|
ClearActiveOrder(ship);
|
||||||
|
ApplyIdleOrBlockedState(world, ship);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (currentPlan is not null
|
if (currentOrder.Status == OrderStatus.Queued)
|
||||||
&& currentPlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed and not AiPlanStatus.Interrupted
|
{
|
||||||
&& currentPlan.SourceKind == desiredSourceKind
|
currentOrder.Status = OrderStatus.Active;
|
||||||
&& string.Equals(currentPlan.SourceId, desiredSourceId, StringComparison.Ordinal)
|
}
|
||||||
&& !ship.NeedsReplan)
|
|
||||||
|
if (!ship.NeedsReplan
|
||||||
|
&& string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal)
|
||||||
|
&& ship.ActiveSubTaskIndex < ship.ActiveSubTasks.Count)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ship.ReplanCooldownSeconds > 0f && currentPlan is null)
|
if (ship.ReplanCooldownSeconds > 0f && !string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ShipPlanRuntime? nextPlan = desiredSourceKind == AiPlanSourceKind.Order
|
var subTasks = BuildOrderSubTasks(world, ship, currentOrder);
|
||||||
? BuildOrderPlan(world, ship, topOrder!)
|
if (subTasks is null || subTasks.Count == 0)
|
||||||
: BuildBehaviorFallbackPlan(world, ship);
|
|
||||||
|
|
||||||
if (nextPlan is null)
|
|
||||||
{
|
{
|
||||||
nextPlan = CreateIdlePlan(ship, desiredSourceKind, desiredSourceId, "No viable plan");
|
FailOrder(ship, currentOrder, currentOrder.FailureReason ?? "order-unavailable");
|
||||||
}
|
ClearActiveOrder(ship);
|
||||||
|
ship.NeedsReplan = true;
|
||||||
if (nextPlan.Kind != Idle)
|
ship.ReplanCooldownSeconds = 0.1f;
|
||||||
{
|
ship.LastReplanReason = currentOrder.FailureReason ?? "order-unavailable";
|
||||||
ship.LastAccessFailureReason = null;
|
events.Add(new SimulationEventRecord("ship", ship.Id, "order-failed", $"{ship.Definition.Name} failed {currentOrder.Label ?? currentOrder.Kind}.", DateTimeOffset.UtcNow));
|
||||||
}
|
ApplyIdleOrBlockedState(world, ship);
|
||||||
|
|
||||||
ReplacePlan(ship, nextPlan, "replanned", events);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ExecutePlan(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
|
||||||
{
|
|
||||||
var plan = ship.ActivePlan;
|
|
||||||
if (plan is null)
|
|
||||||
{
|
|
||||||
ship.State = ShipState.Idle;
|
|
||||||
ship.TargetPosition = ship.Position;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plan.CurrentStepIndex >= plan.Steps.Count)
|
BeginOrderExecution(ship, currentOrder, subTasks);
|
||||||
|
events.Add(new SimulationEventRecord("ship", ship.Id, "order-started", $"{ship.Definition.Name} started {currentOrder.Label ?? currentOrder.Kind}.", DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExecuteOrder(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||||
{
|
{
|
||||||
CompletePlan(ship, plan, events);
|
var order = ship.ActiveOrderId is null ? null : ship.OrderQueue.FindById(ship.ActiveOrderId);
|
||||||
|
if (order is null)
|
||||||
|
{
|
||||||
|
ClearActiveOrder(ship);
|
||||||
|
ApplyIdleOrBlockedState(world, ship);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
plan.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
if (ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count)
|
||||||
|
|
||||||
var step = plan.Steps[plan.CurrentStepIndex];
|
|
||||||
if (step.Status == AiPlanStepStatus.Planned)
|
|
||||||
{
|
{
|
||||||
step.Status = AiPlanStepStatus.Running;
|
CompleteOrderExecution(ship, order, events);
|
||||||
}
|
|
||||||
|
|
||||||
if (step.CurrentSubTaskIndex >= step.SubTasks.Count)
|
|
||||||
{
|
|
||||||
CompleteStep(plan, step);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var subTask = step.SubTasks[step.CurrentSubTaskIndex];
|
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);
|
||||||
{
|
ship.NeedsReplan = false;
|
||||||
plan.Status = AiPlanStatus.Completed;
|
ship.ReplanCooldownSeconds = 0f;
|
||||||
}
|
ship.LastReplanReason = "order-execution-started";
|
||||||
|
ship.LastDeltaSignature = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void CompletePlan(ShipRuntime ship, ShipPlanRuntime plan, ICollection<SimulationEventRecord> events)
|
private static void ClearActiveOrder(ShipRuntime ship)
|
||||||
{
|
{
|
||||||
plan.Status = AiPlanStatus.Completed;
|
ship.ActiveOrderId = null;
|
||||||
var completedOrder = plan.SourceKind == AiPlanSourceKind.Order
|
ship.ActiveSubTaskIndex = 0;
|
||||||
? ship.OrderQueue.FirstOrDefault(order => order.Id == plan.SourceId)
|
ship.ActiveSubTasks.Clear();
|
||||||
: null;
|
}
|
||||||
if (completedOrder is not null)
|
|
||||||
|
private void CompleteOrderExecution(ShipRuntime ship, ShipOrderRuntime order, ICollection<SimulationEventRecord> events)
|
||||||
{
|
{
|
||||||
completedOrder.Status = OrderStatus.Completed;
|
ship.OrderQueue.TryCompleteOrder(order.Id);
|
||||||
ship.OrderQueue.RemoveAll(order => order.Id == completedOrder.Id);
|
if (order.SourceKind == ShipOrderSourceKind.Behavior
|
||||||
if (completedOrder.SourceKind == ShipOrderSourceKind.Behavior
|
&& string.Equals(order.SourceId, RepeatOrders, StringComparison.Ordinal)
|
||||||
&& string.Equals(completedOrder.SourceId, RepeatOrders, StringComparison.Ordinal)
|
|
||||||
&& ship.DefaultBehavior.RepeatOrders.Count > 0)
|
&& ship.DefaultBehavior.RepeatOrders.Count > 0)
|
||||||
{
|
{
|
||||||
ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count;
|
ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
ship.ActivePlan = null;
|
ClearActiveOrder(ship);
|
||||||
ship.NeedsReplan = true;
|
ship.NeedsReplan = true;
|
||||||
ship.ReplanCooldownSeconds = 0.25f;
|
ship.ReplanCooldownSeconds = 0.25f;
|
||||||
ship.LastReplanReason = "plan-completed";
|
ship.LastReplanReason = "order-completed";
|
||||||
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-completed", $"{ship.Definition.Name} completed {plan.Kind}.", DateTimeOffset.UtcNow));
|
ship.LastDeltaSignature = string.Empty;
|
||||||
|
events.Add(new SimulationEventRecord("ship", ship.Id, "order-completed", $"{ship.Definition.Name} completed {order.Label ?? order.Kind}.", DateTimeOffset.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ReplacePlan(ShipRuntime ship, ShipPlanRuntime nextPlan, string reason, ICollection<SimulationEventRecord> events)
|
private void FailOrderExecution(ShipRuntime ship, ShipOrderRuntime order, string failureReason, ICollection<SimulationEventRecord> events)
|
||||||
{
|
{
|
||||||
if (ship.ActivePlan is not null && ship.ActivePlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed)
|
FailOrder(ship, order, failureReason);
|
||||||
{
|
ClearActiveOrder(ship);
|
||||||
ship.ActivePlan.Status = AiPlanStatus.Interrupted;
|
ship.NeedsReplan = true;
|
||||||
ship.ActivePlan.InterruptReason = reason;
|
ship.ReplanCooldownSeconds = 0.5f;
|
||||||
|
ship.LastReplanReason = failureReason;
|
||||||
|
ship.LastDeltaSignature = string.Empty;
|
||||||
|
events.Add(new SimulationEventRecord("ship", ship.Id, "order-failed", $"{ship.Definition.Name} failed {order.Label ?? order.Kind}.", DateTimeOffset.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
ship.ActivePlan = nextPlan;
|
private static void FailOrder(ShipRuntime ship, ShipOrderRuntime order, string failureReason)
|
||||||
ship.NeedsReplan = false;
|
{
|
||||||
ship.ReplanCooldownSeconds = 0f;
|
ship.OrderQueue.TryFailOrder(order.Id, failureReason);
|
||||||
ship.LastReplanReason = reason;
|
ship.LastDeltaSignature = string.Empty;
|
||||||
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-updated", $"{ship.Definition.Name} planned {nextPlan.Kind}.", DateTimeOffset.UtcNow));
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
apps/backend/Ships/Api/ReorderShipOrderHandler.cs
Normal file
31
apps/backend/Ships/Api/ReorderShipOrderHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/backend/Ships/Api/UpdateShipOrderHandler.cs
Normal file
39
apps/backend/Ships/Api/UpdateShipOrderHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,38 +1020,6 @@ 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,
|
||||||
@@ -1004,7 +1028,9 @@ internal sealed class SimulationProjectionService
|
|||||||
subTask.Summary,
|
subTask.Summary,
|
||||||
subTask.TargetEntityId,
|
subTask.TargetEntityId,
|
||||||
subTask.TargetSystemId,
|
subTask.TargetSystemId,
|
||||||
subTask.TargetNodeId,
|
subTask.TargetAnchorId,
|
||||||
|
subTask.TargetResourceNodeId,
|
||||||
|
subTask.TargetResourceDepositId,
|
||||||
subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value),
|
subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value),
|
||||||
subTask.ItemId,
|
subTask.ItemId,
|
||||||
subTask.ModuleId,
|
subTask.ModuleId,
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; } = [];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
{
|
|
||||||
return new Vector3(offset.X, yPlane + offset.Y, offset.Z);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Add(anchorCelestial.Position, offset);
|
private static IReadOnlyList<ResourceDepositRuntime> BuildResourceDeposits(
|
||||||
|
string systemId,
|
||||||
|
string nodeId,
|
||||||
|
ResourceNodeDefinition definition,
|
||||||
|
float oreAmount)
|
||||||
|
{
|
||||||
|
var derivedDepositCount = Math.Clamp((int)MathF.Round(MathF.Sqrt(MathF.Max(oreAmount, 1f)) / 18f), 4, 18);
|
||||||
|
var depositCount = Math.Clamp(definition.ShardCount > 0 ? definition.ShardCount : derivedDepositCount, 4, 48);
|
||||||
|
var deposits = new List<ResourceDepositRuntime>(depositCount);
|
||||||
|
var weightTotal = 0f;
|
||||||
|
var weights = new float[depositCount];
|
||||||
|
var random = new Random(ComputeDeterministicSeed(systemId, nodeId, "resource-deposits"));
|
||||||
|
for (var index = 0; index < depositCount; index += 1)
|
||||||
|
{
|
||||||
|
var weight = 0.8f + (NextFloat01(random) * 1.6f);
|
||||||
|
weights[index] = weight;
|
||||||
|
weightTotal += weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource node localspace should read as a compact mineable field around the node core,
|
||||||
|
// not as sparse debris spread across the entire anchor volume.
|
||||||
|
var scatterRadius = MathF.Max(120f, MathF.Min(LocalSpaceRadius * 0.2f, 900f));
|
||||||
|
for (var index = 0; index < depositCount; index += 1)
|
||||||
|
{
|
||||||
|
var angle = NextFloat01(random) * MathF.PI * 2f;
|
||||||
|
var radiusFactor = 0.12f + (NextFloat01(random) * 0.82f);
|
||||||
|
var radius = scatterRadius * MathF.Sqrt(radiusFactor);
|
||||||
|
var vertical = (NextFloat01(random) - 0.5f) * MathF.Max(40f, scatterRadius * 0.18f);
|
||||||
|
var localPosition = new Vector3(
|
||||||
|
MathF.Cos(angle) * radius,
|
||||||
|
vertical,
|
||||||
|
MathF.Sin(angle) * radius);
|
||||||
|
var maxOre = oreAmount * (weights[index] / MathF.Max(weightTotal, 0.001f));
|
||||||
|
deposits.Add(new ResourceDepositRuntime
|
||||||
|
{
|
||||||
|
Id = $"{nodeId}-deposit-{index + 1}",
|
||||||
|
NodeId = nodeId,
|
||||||
|
AnchorId = nodeId,
|
||||||
|
Position = localPosition,
|
||||||
|
OreRemaining = maxOre,
|
||||||
|
MaxOre = maxOre,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return deposits;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ComputeDeterministicSeed(string systemId, string nodeId, string salt)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
var hash = 17;
|
||||||
|
foreach (var character in systemId)
|
||||||
|
{
|
||||||
|
hash = (hash * 31) + character;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var character in nodeId)
|
||||||
|
{
|
||||||
|
hash = (hash * 31) + character;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var character in salt)
|
||||||
|
{
|
||||||
|
hash = (hash * 31) + character;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float NextFloat01(Random random) => (float)random.NextDouble();
|
||||||
|
|
||||||
|
private static float Hash01(string systemId, string nodeId, string salt)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
var hash = 17;
|
||||||
|
foreach (var character in systemId)
|
||||||
|
{
|
||||||
|
hash = (hash * 31) + character;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var character in nodeId)
|
||||||
|
{
|
||||||
|
hash = (hash * 31) + character;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var character in salt)
|
||||||
|
{
|
||||||
|
hash = (hash * 31) + character;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (hash & 0x7fffffff) / (float)int.MaxValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector3 ComputePlanetPosition(PlanetDefinition planet)
|
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);
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [],
|
||||||
|
|||||||
@@ -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))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
? world.Anchors.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentAnchorId)
|
||||||
|
: null;
|
||||||
|
if (currentAnchor is null || !string.Equals(currentAnchor.SystemId, ship.SystemId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
currentAnchor = world.Anchors
|
||||||
.Where(candidate => candidate.SystemId == ship.SystemId)
|
.Where(candidate => candidate.SystemId == ship.SystemId)
|
||||||
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
|
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
ship.SpatialState.CurrentCelestialId = nearestCelestial?.Id;
|
}
|
||||||
|
|
||||||
|
ship.SpatialState.CurrentAnchorId = currentAnchor?.Id;
|
||||||
|
var localSystemOffset = SimulationUnits.MetersToKilometers(ship.Position);
|
||||||
|
ship.SpatialState.SystemPosition = currentAnchor is null
|
||||||
|
? localSystemOffset
|
||||||
|
: Add(currentAnchor.Position, localSystemOffset);
|
||||||
|
|
||||||
if (ship.DockedStationId is null)
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
await startViewerIfAuthenticated();
|
async ([isAuthenticated]) => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
playerContextReady.value = false;
|
||||||
|
playerFactionStore.setPlayerFaction(null);
|
||||||
|
viewer?.dispose();
|
||||||
|
viewer = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await refreshPlayerContext();
|
||||||
|
await startViewerIfAuthenticated();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => shouldShowOnboarding.value,
|
||||||
|
async (requiresOnboarding) => {
|
||||||
|
if (requiresOnboarding) {
|
||||||
viewer?.dispose();
|
viewer?.dispose();
|
||||||
viewer = undefined;
|
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">
|
||||||
|
<section class="viewer-left-sidebar pointer-events-auto">
|
||||||
|
<div class="viewer-left-sidebar__tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="viewer-left-sidebar__tab"
|
||||||
|
:class="leftSidebarTab === 'player' ? 'viewer-left-sidebar__tab--active' : ''"
|
||||||
|
@click="leftSidebarTab = 'player'"
|
||||||
|
>
|
||||||
|
Player Informations
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="viewer-left-sidebar__tab"
|
||||||
|
:class="leftSidebarTab === 'entities' ? 'viewer-left-sidebar__tab--active' : ''"
|
||||||
|
@click="leftSidebarTab = 'entities'"
|
||||||
|
>
|
||||||
|
Entities
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="viewer-left-sidebar__body">
|
||||||
|
<div
|
||||||
|
v-if="leftSidebarTab === 'player'"
|
||||||
|
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--player"
|
||||||
|
>
|
||||||
<AuthSessionPanel />
|
<AuthSessionPanel />
|
||||||
<CollapsibleHudPanel
|
</div>
|
||||||
v-model:collapsed="hudState.gamePanel.collapsed"
|
|
||||||
class-name="topbar"
|
|
||||||
panel-name="game"
|
|
||||||
title="Game"
|
|
||||||
:summary="hudState.gamePanel.summary"
|
|
||||||
:body-text="hudState.gamePanel.bodyText"
|
|
||||||
/>
|
|
||||||
<CollapsibleHudPanel
|
|
||||||
v-model:collapsed="hudState.networkPanel.collapsed"
|
|
||||||
class-name="network-panel"
|
|
||||||
panel-name="network"
|
|
||||||
title="Network"
|
|
||||||
:summary="hudState.networkPanel.summary"
|
|
||||||
:body-text="hudState.networkPanel.bodyText"
|
|
||||||
/>
|
|
||||||
<CollapsibleHudPanel
|
|
||||||
v-model:collapsed="hudState.performancePanel.collapsed"
|
|
||||||
class-name="performance-panel"
|
|
||||||
panel-name="performance"
|
|
||||||
title="Performance"
|
|
||||||
:summary="hudState.performancePanel.summary"
|
|
||||||
:body-text="hudState.performancePanel.bodyText"
|
|
||||||
/>
|
|
||||||
<ViewerEntityBrowserPanel
|
<ViewerEntityBrowserPanel
|
||||||
class="min-h-0 flex-1"
|
v-else
|
||||||
|
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--entities"
|
||||||
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
|
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="absolute right-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(380px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto">
|
<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"
|
<div
|
||||||
:body-html="hudState.systemPanel.bodyHtml"
|
class="viewer-stats-overlay"
|
||||||
:hidden="hudState.systemPanel.hidden"
|
:class="hudState.statsOverlay.mode === 'compact' ? 'viewer-stats-overlay--compact' : ''"
|
||||||
subtitle-class="system-title"
|
>
|
||||||
body-class="system-body"
|
<div
|
||||||
|
v-for="(line, index) in hudState.statsOverlay.lines"
|
||||||
|
:key="`${index}-${line}`"
|
||||||
|
class="viewer-stats-overlay__line"
|
||||||
|
:class="line === '' ? 'viewer-stats-overlay__line--spacer' : ''"
|
||||||
|
>
|
||||||
|
{{ line === "" ? "\u00A0" : line }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!hudState.systemPanel.hidden"
|
||||||
|
class="viewer-system-label-dock"
|
||||||
|
>
|
||||||
|
<div class="viewer-system-label">
|
||||||
|
<div class="viewer-system-label__title">
|
||||||
|
{{ hudState.systemPanel.title }}
|
||||||
|
</div>
|
||||||
|
<div class="viewer-system-label__subtitle">
|
||||||
|
{{ hudState.systemPanel.bodyHtml }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="viewer-right-sidebar-dock" :style="{ width: `${rightSidebarWidth}px` }">
|
||||||
|
<section class="viewer-right-sidebar pointer-events-auto">
|
||||||
|
<div
|
||||||
|
class="viewer-right-sidebar__resize-handle"
|
||||||
|
:class="rightSidebarResizing ? 'viewer-right-sidebar__resize-handle--active' : ''"
|
||||||
|
@pointerdown="startRightSidebarResize"
|
||||||
/>
|
/>
|
||||||
|
<div class="viewer-right-sidebar__body">
|
||||||
<ViewerEntityInspectorPanel
|
<ViewerEntityInspectorPanel
|
||||||
class="min-h-0 flex-1"
|
class="viewer-right-sidebar__panel"
|
||||||
:fallback-title="hudState.detailPanel.title"
|
:fallback-title="hudState.detailPanel.title"
|
||||||
:fallback-html="hudState.detailPanel.bodyHtml"
|
:fallback-html="hudState.detailPanel.bodyHtml"
|
||||||
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
|
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="pointer-events-auto rounded-xl bg-[rgba(255,116,88,0.14)] px-3.5 py-3 text-[#ffd8cf]"
|
class="viewer-right-sidebar__error"
|
||||||
:hidden="hudState.error.hidden"
|
:hidden="hudState.error.hidden"
|
||||||
>
|
>
|
||||||
{{ hudState.error.message }}
|
{{ hudState.error.message }}
|
||||||
</div>
|
</div>
|
||||||
<button
|
</section>
|
||||||
v-if="selectedEntityId"
|
|
||||||
type="button"
|
|
||||||
class="selection-action-button pointer-events-auto self-end rounded-full border border-white/10 bg-white/5 px-3.5 py-2.5 text-sm text-[color:var(--viewer-text)] transition hover:bg-white/10"
|
|
||||||
@click="selectionStore.clearSelection('ui')"
|
|
||||||
>
|
|
||||||
Clear {{ selectedEntityLabel ?? "Selection" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
115
apps/viewer/src/components/PlayerOnboardingPanel.vue
Normal file
115
apps/viewer/src/components/PlayerOnboardingPanel.vue
Normal 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>
|
||||||
@@ -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;
|
||||||
if (povLevel.value === "galaxy" || !activeSystemId.value) {
|
|
||||||
const systems = [...gmStore.systems]
|
|
||||||
.sort((left, right) => left.label.localeCompare(right.label))
|
|
||||||
.map<BrowserItem>((system) => ({
|
|
||||||
key: `system-${system.id}`,
|
|
||||||
label: system.label,
|
|
||||||
subtitle: `${system.planets.length} planets · ${system.stars.length} stars`,
|
|
||||||
meta: system.id,
|
|
||||||
selection: { id: system.id, kind: "system", label: system.label },
|
|
||||||
focusSelection: { kind: "system", id: system.id },
|
|
||||||
focusMode: "tactical",
|
|
||||||
}));
|
|
||||||
sections.push({
|
|
||||||
key: "systems",
|
|
||||||
label: "Systems",
|
|
||||||
count: systems.length,
|
|
||||||
items: systems,
|
|
||||||
});
|
|
||||||
return sections;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemId = activeSystemId.value;
|
const words = titleCase(value).split(" ");
|
||||||
const ships = gmStore.ships
|
if (words.length === 1) {
|
||||||
.filter((ship) => ship.systemId === systemId)
|
return words[0].slice(0, 4).toUpperCase();
|
||||||
.sort((left, right) => left.name.localeCompare(right.name))
|
}
|
||||||
.map<BrowserItem>((ship) => ({
|
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}`,
|
key: `ship-${ship.id}`,
|
||||||
label: ship.name,
|
kind: "ship",
|
||||||
subtitle: `${titleCase(ship.type)} · ${titleCase(ship.state)}`,
|
kindLabel: "SH",
|
||||||
meta: `${getShipBehaviorLabel(ship.defaultBehavior.kind)}${ship.defaultBehavior.itemId ? ` · ${ship.defaultBehavior.itemId}` : ""}`,
|
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 },
|
selection: { id: ship.id, kind: "ship", label: ship.name },
|
||||||
focusSelection: { kind: "ship", id: ship.id },
|
focusSelection: { kind: "ship", id: ship.id },
|
||||||
focusMode: "follow",
|
focusMode: "tactical",
|
||||||
}));
|
children: [],
|
||||||
const stations = gmStore.stations
|
};
|
||||||
.filter((station) => station.systemId === systemId)
|
}
|
||||||
.sort((left, right) => left.label.localeCompare(right.label))
|
|
||||||
.map<BrowserItem>((station) => ({
|
function buildStationRow(station: StationSnapshot, children: BrowserRow[]): BrowserRow {
|
||||||
|
return {
|
||||||
key: `station-${station.id}`,
|
key: `station-${station.id}`,
|
||||||
label: station.label,
|
kind: "station",
|
||||||
subtitle: `${titleCase(station.category)} · Docked ${station.dockedShips}/${station.dockingPads}`,
|
kindLabel: "ST",
|
||||||
meta: station.factionId,
|
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 },
|
selection: { id: station.id, kind: "station", label: station.label },
|
||||||
focusSelection: { kind: "station", id: station.id },
|
focusSelection: { kind: "station", id: station.id },
|
||||||
focusMode: "tactical",
|
focusMode: "tactical",
|
||||||
}));
|
children,
|
||||||
|
};
|
||||||
sections.push({
|
|
||||||
key: "ships",
|
|
||||||
label: "Ships",
|
|
||||||
count: ships.length,
|
|
||||||
items: ships,
|
|
||||||
});
|
|
||||||
sections.push({
|
|
||||||
key: "stations",
|
|
||||||
label: "Stations",
|
|
||||||
count: stations.length,
|
|
||||||
items: stations,
|
|
||||||
});
|
|
||||||
|
|
||||||
return sections;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOwnedSections(): BrowserSection[] {
|
function buildFleetRow(fleet: PlayerFleetSnapshot, children: BrowserRow[]): BrowserRow {
|
||||||
|
const homeStation = fleet.homeStationId ? stationById.value.get(fleet.homeStationId) : undefined;
|
||||||
|
const homeSystem = fleet.homeSystemId ? systemById.value.get(fleet.homeSystemId) : undefined;
|
||||||
|
return {
|
||||||
|
key: `fleet-${fleet.id}`,
|
||||||
|
kind: "fleet",
|
||||||
|
kindLabel: "FL",
|
||||||
|
name: fleet.label,
|
||||||
|
ident: `${titleCase(fleet.role)} · ${shortId(fleet.id)}`,
|
||||||
|
location: homeStation ? `Home ${homeStation.label}` : (homeSystem?.label ?? "No home"),
|
||||||
|
aiStates: fleetAiStates(fleet),
|
||||||
|
hpLabel: `${children.length}`,
|
||||||
|
hpValue: children.length,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemRow(systemId: string): BrowserRow | null {
|
||||||
|
const system = systemById.value.get(systemId);
|
||||||
|
if (!system) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: `system-${system.id}`,
|
||||||
|
kind: "system",
|
||||||
|
kindLabel: "SY",
|
||||||
|
name: system.label,
|
||||||
|
ident: shortId(system.id),
|
||||||
|
location: "Galaxy",
|
||||||
|
aiStates: systemAiStates(system.id),
|
||||||
|
hpLabel: "--",
|
||||||
|
hpValue: -1,
|
||||||
|
selection: { id: system.id, kind: "system", label: system.label },
|
||||||
|
focusSelection: { kind: "system", id: system.id },
|
||||||
|
focusMode: "tactical",
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVisibleRows() {
|
||||||
|
if (povLevel.value === "galaxy" || !activeSystemId.value) {
|
||||||
|
return gmStore.systems
|
||||||
|
.map((system) => buildSystemRow(system.id))
|
||||||
|
.filter((row): row is BrowserRow => row != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemId = activeSystemId.value;
|
||||||
|
const stations = gmStore.stations.filter((station) => station.systemId === systemId);
|
||||||
|
const ships = gmStore.ships.filter((ship) => ship.systemId === systemId);
|
||||||
|
const stationIds = new Set(stations.map((station) => station.id));
|
||||||
|
const stationChildren = new Map<string, BrowserRow[]>();
|
||||||
|
const fleetChildren = new Map<string, BrowserRow[]>();
|
||||||
|
const independentShips: BrowserRow[] = [];
|
||||||
|
|
||||||
|
for (const ship of ships) {
|
||||||
|
const row = buildShipRow(ship);
|
||||||
|
|
||||||
|
if (ship.dockedStationId && stationIds.has(ship.dockedStationId)) {
|
||||||
|
const children = stationChildren.get(ship.dockedStationId) ?? [];
|
||||||
|
children.push(row);
|
||||||
|
stationChildren.set(ship.dockedStationId, children);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fleet = playerFleetByShipId.value.get(ship.id);
|
||||||
|
if (fleet) {
|
||||||
|
const children = fleetChildren.get(fleet.id) ?? [];
|
||||||
|
children.push(row);
|
||||||
|
fleetChildren.set(fleet.id, children);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
independentShips.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stationRows = stations.map((station) => buildStationRow(station, stationChildren.get(station.id) ?? []));
|
||||||
|
const fleetRows = (playerFaction.value?.fleets ?? [])
|
||||||
|
.filter((fleet) => (fleetChildren.get(fleet.id)?.length ?? 0) > 0)
|
||||||
|
.map((fleet) => buildFleetRow(fleet, fleetChildren.get(fleet.id) ?? []));
|
||||||
|
|
||||||
|
return [...stationRows, ...fleetRows, ...independentShips];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOwnedRows() {
|
||||||
const player = playerFaction.value;
|
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
|
|
||||||
type="button"
|
|
||||||
class="entity-browser-item__body"
|
|
||||||
:disabled="!item.selection"
|
|
||||||
@click="selectItem(item)"
|
|
||||||
>
|
|
||||||
<div class="entity-browser-item__label">{{ item.label }}</div>
|
|
||||||
<div class="entity-browser-item__subtitle">{{ item.subtitle }}</div>
|
|
||||||
<div v-if="item.meta" class="entity-browser-item__meta">{{ item.meta }}</div>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
</th>
|
||||||
v-if="item.focusSelection"
|
<th scope="col">Ident</th>
|
||||||
type="button"
|
<th scope="col">
|
||||||
class="entity-browser-item__focus"
|
<button type="button" class="entity-browser-table__sort" @click="toggleSort('location')">
|
||||||
@click.stop="focusItem(item)"
|
Location{{ sortMarker("location") }}
|
||||||
>
|
|
||||||
Focus
|
|
||||||
</button>
|
</button>
|
||||||
|
</th>
|
||||||
|
<th scope="col">
|
||||||
|
<button type="button" class="entity-browser-table__sort" @click="toggleSort('ai')">
|
||||||
|
AI{{ sortMarker("ai") }}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="entity-browser-table__numeric">
|
||||||
|
<button type="button" class="entity-browser-table__sort" @click="toggleSort('hp')">
|
||||||
|
HP{{ sortMarker("hp") }}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in displayRows"
|
||||||
|
:key="row.key"
|
||||||
|
class="entity-browser-table__row"
|
||||||
|
:class="isSelected(row) ? 'entity-browser-table__row--selected' : ''"
|
||||||
|
@click="selectItem(row)"
|
||||||
|
@dblclick="focusItem(row)"
|
||||||
|
>
|
||||||
|
<td class="entity-browser-table__name">
|
||||||
|
<div class="entity-browser-row" :style="{ paddingLeft: `${row.depth * 0.9}rem` }">
|
||||||
|
<button
|
||||||
|
v-if="row.children.length > 0"
|
||||||
|
type="button"
|
||||||
|
class="entity-browser-row__toggle"
|
||||||
|
@click.stop="toggleRow(row)"
|
||||||
|
>
|
||||||
|
{{ isExpanded(row) ? "-" : "+" }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="entity-browser-row__toggle entity-browser-row__toggle--spacer" />
|
||||||
|
<span class="entity-browser-row__kind" :class="`entity-browser-row__kind--${row.kind}`">
|
||||||
|
{{ row.kindLabel }}
|
||||||
|
</span>
|
||||||
|
<span class="entity-browser-row__label">{{ row.name }}</span>
|
||||||
</div>
|
</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>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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(() =>
|
||||||
|
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) {
|
if (!ship) {
|
||||||
|
actionStatus.value = "";
|
||||||
|
actionError.value = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
behaviorForm.kind = ship.defaultBehavior.kind;
|
behaviorForm.kind = ship.defaultBehavior.kind;
|
||||||
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
|
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
|
||||||
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
|
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
|
||||||
mineOrderForm.systemId = ship.systemId ?? "";
|
|
||||||
mineOrderForm.itemId = "ore";
|
|
||||||
moveOrderSystemId.value = ship.systemId ?? "";
|
|
||||||
actionStatus.value = "";
|
actionStatus.value = "";
|
||||||
actionError.value = "";
|
actionError.value = "";
|
||||||
}, { immediate: true });
|
expandedDirectOrderId.value = null;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function supportsOrderField(kind: string, field: "targetSystemId" | "targetEntityId" | "itemId" | "waitSeconds" | "radius" | "maxSystemRange" | "knownStationsOnly") {
|
||||||
|
switch (field) {
|
||||||
|
case "targetSystemId":
|
||||||
|
return kind === "move" || kind === "mine-and-deliver";
|
||||||
|
case "targetEntityId":
|
||||||
|
return kind === "follow-ship" || kind === "attack-target";
|
||||||
|
case "itemId":
|
||||||
|
return kind === "mine-and-deliver";
|
||||||
|
case "waitSeconds":
|
||||||
|
return kind === "hold-position" || kind === "follow-ship";
|
||||||
|
case "radius":
|
||||||
|
return kind === "move" || kind === "follow-ship";
|
||||||
|
case "maxSystemRange":
|
||||||
|
return kind === "mine-and-deliver";
|
||||||
|
case "knownStationsOnly":
|
||||||
|
return kind === "mine-and-deliver";
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOrderEditor(order: ShipOrderSnapshot) {
|
||||||
|
orderEditForm.label = order.label ?? "";
|
||||||
|
orderEditForm.priority = String(order.priority);
|
||||||
|
orderEditForm.interruptCurrentPlan = order.interruptCurrentPlan;
|
||||||
|
orderEditForm.targetSystemId = order.targetSystemId ?? "";
|
||||||
|
orderEditForm.targetEntityId = order.targetEntityId ?? "";
|
||||||
|
orderEditForm.itemId = order.itemId ?? "ore";
|
||||||
|
orderEditForm.waitSeconds = String(order.waitSeconds ?? 0);
|
||||||
|
orderEditForm.radius = String(order.radius ?? 0);
|
||||||
|
orderEditForm.maxSystemRange = order.maxSystemRange == null ? "" : String(order.maxSystemRange);
|
||||||
|
orderEditForm.knownStationsOnly = order.knownStationsOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOrderEditor(order: ShipOrderSnapshot) {
|
||||||
|
if (expandedDirectOrderId.value === order.id) {
|
||||||
|
expandedDirectOrderId.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadOrderEditor(order);
|
||||||
|
expandedDirectOrderId.value = order.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value: string, fallback: number) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalInt(value: string) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(trimmed, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOrder(order: ShipOrderSnapshot) {
|
||||||
|
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runShipAction(async () => {
|
||||||
|
const ship = await updateShipOrder(selectedShip.value!.id, order.id, {
|
||||||
|
kind: order.kind,
|
||||||
|
priority: Math.max(0, Math.round(parseNumber(orderEditForm.priority, order.priority))),
|
||||||
|
interruptCurrentPlan: orderEditForm.interruptCurrentPlan,
|
||||||
|
label: orderEditForm.label.trim() || null,
|
||||||
|
targetEntityId: supportsOrderField(order.kind, "targetEntityId")
|
||||||
|
? (orderEditForm.targetEntityId.trim() || null)
|
||||||
|
: order.targetEntityId ?? null,
|
||||||
|
targetSystemId: supportsOrderField(order.kind, "targetSystemId")
|
||||||
|
? (orderEditForm.targetSystemId.trim() || null)
|
||||||
|
: order.targetSystemId ?? null,
|
||||||
|
targetPosition: order.targetPosition ?? null,
|
||||||
|
sourceStationId: order.sourceStationId ?? null,
|
||||||
|
destinationStationId: order.destinationStationId ?? null,
|
||||||
|
itemId: supportsOrderField(order.kind, "itemId")
|
||||||
|
? (orderEditForm.itemId.trim() || null)
|
||||||
|
: order.itemId ?? null,
|
||||||
|
anchorId: order.anchorId ?? null,
|
||||||
|
constructionSiteId: order.constructionSiteId ?? null,
|
||||||
|
moduleId: order.moduleId ?? null,
|
||||||
|
waitSeconds: supportsOrderField(order.kind, "waitSeconds")
|
||||||
|
? parseNumber(orderEditForm.waitSeconds, order.waitSeconds)
|
||||||
|
: order.waitSeconds,
|
||||||
|
radius: supportsOrderField(order.kind, "radius")
|
||||||
|
? parseNumber(orderEditForm.radius, order.radius)
|
||||||
|
: order.radius,
|
||||||
|
maxSystemRange: supportsOrderField(order.kind, "maxSystemRange")
|
||||||
|
? parseOptionalInt(orderEditForm.maxSystemRange)
|
||||||
|
: order.maxSystemRange ?? null,
|
||||||
|
knownStationsOnly: supportsOrderField(order.kind, "knownStationsOnly")
|
||||||
|
? orderEditForm.knownStationsOnly
|
||||||
|
: order.knownStationsOnly,
|
||||||
|
});
|
||||||
|
gmStore.upsertShip(ship);
|
||||||
|
expandedDirectOrderId.value = null;
|
||||||
|
}, "Order updated.");
|
||||||
|
}
|
||||||
|
|
||||||
function focusShip(cameraMode?: "follow" | "tactical") {
|
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>
|
||||||
|
|||||||
@@ -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 })"
|
|
||||||
>
|
|
||||||
🕔
|
|
||||||
</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>
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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; },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
9
apps/viewer/src/contractsIdentity.ts
Normal file
9
apps/viewer/src/contractsIdentity.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
6
apps/viewer/src/contractsRaces.ts
Normal file
6
apps/viewer/src/contractsRaces.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface RaceSnapshot {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
47
apps/viewer/src/effectiveIdentitySession.ts
Normal file
47
apps/viewer/src/effectiveIdentitySession.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
if (!gesture || !this.pinchStartDistance || !this.pinchStartZoom || !this.pinchLastCenter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomRatio = THREE.MathUtils.clamp(gesture.distance / this.pinchStartDistance, 0.25, 4);
|
||||||
|
this.context.setDesiredDistance(THREE.MathUtils.clamp(
|
||||||
|
this.pinchStartZoom / zoomRatio,
|
||||||
|
MIN_CAMERA_DISTANCE,
|
||||||
|
MAX_CAMERA_DISTANCE,
|
||||||
|
));
|
||||||
|
const centerDelta = gesture.center.clone().sub(this.pinchLastCenter);
|
||||||
|
this.pinchLastCenter = gesture.center;
|
||||||
|
this.context.applyPanDelta(centerDelta);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const delta = point.clone().sub(this.context.dragLast);
|
const delta = point.clone().sub(this.context.dragLast);
|
||||||
this.context.dragLast.copy(point);
|
|
||||||
this.context.applyOrbitDelta(delta);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dragDistance = point.distanceTo(this.context.dragStart);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const remainingPointer = this.activePointers.entries().next();
|
||||||
|
if (!remainingPointer.done) {
|
||||||
|
const [pointerId, point] = remainingPointer.value;
|
||||||
|
this.context.setDragMode("pan");
|
||||||
|
this.context.setDragPointerId(pointerId);
|
||||||
|
this.context.dragStart.copy(point);
|
||||||
|
this.context.dragLast.copy(point);
|
||||||
|
} else {
|
||||||
this.context.setDragMode(undefined);
|
this.context.setDragMode(undefined);
|
||||||
this.context.setDragPointerId(undefined);
|
this.context.setDragPointerId(undefined);
|
||||||
this.context.setMarqueeActive(false);
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user