Add player onboarding and tactical viewer updates
This commit is contained in:
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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs
Normal file
74
apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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 playerFactionsById = playerStateStore.GetPlayerFactions()
|
||||||
|
.ToDictionary(player => player.Id, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var responses = new List<PlayerIdentitySummaryResponse>(users.Count + playerFactionsById.Count);
|
||||||
|
var seenIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
var userId = user.Id.ToString("N");
|
||||||
|
playerFactionsById.TryGetValue(userId, out var playerFaction);
|
||||||
|
responses.Add(new PlayerIdentitySummaryResponse(
|
||||||
|
userId,
|
||||||
|
user.Email,
|
||||||
|
user.Roles,
|
||||||
|
playerFaction is not null,
|
||||||
|
playerFaction?.Id,
|
||||||
|
playerFaction?.Label,
|
||||||
|
playerFaction?.SovereignFactionId));
|
||||||
|
seenIds.Add(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var playerFaction in playerStateStore.GetPlayerFactions())
|
||||||
|
{
|
||||||
|
if (!seenIds.Add(playerFaction.Id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
responses.Add(new PlayerIdentitySummaryResponse(
|
||||||
|
playerFaction.Id,
|
||||||
|
$"{playerFaction.Id}@unknown",
|
||||||
|
Array.Empty<string>(),
|
||||||
|
true,
|
||||||
|
playerFaction.Id,
|
||||||
|
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);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -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));
|
||||||
@@ -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;
|
||||||
@@ -669,7 +719,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;
|
||||||
@@ -712,7 +762,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
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;
|
||||||
@@ -852,6 +902,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 +1292,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)
|
||||||
|
|||||||
@@ -90,7 +90,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 +118,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -148,11 +149,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 +156,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)
|
||||||
@@ -530,7 +546,102 @@ 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 ship = new ShipRuntime
|
||||||
|
{
|
||||||
|
Id = shipId,
|
||||||
|
SystemId = system.Definition.Id,
|
||||||
|
Definition = definition,
|
||||||
|
FactionId = playerFaction.Id,
|
||||||
|
Position = spawnPosition,
|
||||||
|
TargetPosition = spawnPosition,
|
||||||
|
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Celestials),
|
||||||
|
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();
|
||||||
|
|
||||||
@@ -807,7 +918,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 +932,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 +958,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { 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,19 +28,24 @@ 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, selectedEntityLabel } = storeToRefs(selectionStore);
|
||||||
const { canAccessGm } = storeToRefs(authStore);
|
const { canAccessGm, effectivePlayerId } = 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);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
void automationCatalogStore.load();
|
void automationCatalogStore.load();
|
||||||
|
await refreshPlayerContext();
|
||||||
await startViewerIfAuthenticated();
|
await startViewerIfAuthenticated();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,15 +53,35 @@ onBeforeUnmount(() => {
|
|||||||
viewer?.dispose();
|
viewer?.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => authStore.isAuthenticated, async (isAuthenticated) => {
|
watch(
|
||||||
if (isAuthenticated) {
|
[() => authStore.isAuthenticated, () => effectivePlayerId.value],
|
||||||
await startViewerIfAuthenticated();
|
async ([isAuthenticated]) => {
|
||||||
return;
|
if (!isAuthenticated) {
|
||||||
}
|
playerContextReady.value = false;
|
||||||
|
playerFactionStore.setPlayerFaction(null);
|
||||||
|
viewer?.dispose();
|
||||||
|
viewer = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
viewer?.dispose();
|
await refreshPlayerContext();
|
||||||
viewer = undefined;
|
await startViewerIfAuthenticated();
|
||||||
});
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => playerFaction.value?.requiresOnboarding ?? false,
|
||||||
|
async (requiresOnboarding) => {
|
||||||
|
if (requiresOnboarding) {
|
||||||
|
viewer?.dispose();
|
||||||
|
viewer = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await startViewerIfAuthenticated();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
function onHistoryWindowResize(id: string, width: number, height: number) {
|
function onHistoryWindowResize(id: string, width: number, height: number) {
|
||||||
const windowState = hudState.historyWindows.find((entry) => entry.id === id);
|
const windowState = hudState.historyWindows.find((entry) => entry.id === id);
|
||||||
@@ -76,7 +102,7 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startViewerIfAuthenticated() {
|
async function startViewerIfAuthenticated() {
|
||||||
if (!authStore.isAuthenticated || viewer) {
|
if (!authStore.isAuthenticated || viewer || !playerContextReady.value || playerFaction.value?.requiresOnboarding) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,58 +127,112 @@ 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="playerContextReady && playerFaction?.requiresOnboarding" />
|
||||||
<div v-else class="viewer-app">
|
<div v-else class="viewer-app">
|
||||||
<div
|
<div
|
||||||
ref="canvasHostEl"
|
ref="canvasHostEl"
|
||||||
class="viewer-canvas-host"
|
class="viewer-canvas-host"
|
||||||
/>
|
/>
|
||||||
<div class="pointer-events-none fixed inset-0">
|
<div class="pointer-events-none fixed inset-0">
|
||||||
<div class="absolute left-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(360px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:right-5 max-[760px]:bottom-[148px] max-[760px]:w-auto max-[760px]:max-h-[38vh]">
|
<div class="viewer-left-sidebar-dock">
|
||||||
<AuthSessionPanel />
|
<section class="viewer-left-sidebar pointer-events-auto">
|
||||||
<CollapsibleHudPanel
|
<div class="viewer-left-sidebar__tabs">
|
||||||
v-model:collapsed="hudState.gamePanel.collapsed"
|
<button
|
||||||
class-name="topbar"
|
type="button"
|
||||||
panel-name="game"
|
class="viewer-left-sidebar__tab"
|
||||||
title="Game"
|
:class="leftSidebarTab === 'player' ? 'viewer-left-sidebar__tab--active' : ''"
|
||||||
:summary="hudState.gamePanel.summary"
|
@click="leftSidebarTab = 'player'"
|
||||||
:body-text="hudState.gamePanel.bodyText"
|
>
|
||||||
/>
|
Player Informations
|
||||||
<CollapsibleHudPanel
|
</button>
|
||||||
v-model:collapsed="hudState.networkPanel.collapsed"
|
<button
|
||||||
class-name="network-panel"
|
type="button"
|
||||||
panel-name="network"
|
class="viewer-left-sidebar__tab"
|
||||||
title="Network"
|
:class="leftSidebarTab === 'entities' ? 'viewer-left-sidebar__tab--active' : ''"
|
||||||
:summary="hudState.networkPanel.summary"
|
@click="leftSidebarTab = 'entities'"
|
||||||
:body-text="hudState.networkPanel.bodyText"
|
>
|
||||||
/>
|
Entities
|
||||||
<CollapsibleHudPanel
|
</button>
|
||||||
v-model:collapsed="hudState.performancePanel.collapsed"
|
</div>
|
||||||
class-name="performance-panel"
|
|
||||||
panel-name="performance"
|
<div class="viewer-left-sidebar__body">
|
||||||
title="Performance"
|
<div
|
||||||
:summary="hudState.performancePanel.summary"
|
v-if="leftSidebarTab === 'player'"
|
||||||
:body-text="hudState.performancePanel.bodyText"
|
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--player"
|
||||||
/>
|
>
|
||||||
<ViewerEntityBrowserPanel
|
<AuthSessionPanel />
|
||||||
class="min-h-0 flex-1"
|
</div>
|
||||||
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
|
<ViewerEntityBrowserPanel
|
||||||
/>
|
v-else
|
||||||
|
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--entities"
|
||||||
|
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="hudState.statsOverlay.lines.length > 0"
|
||||||
|
class="viewer-stats-overlay-dock"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="viewer-stats-overlay"
|
||||||
|
:class="hudState.statsOverlay.mode === 'compact' ? 'viewer-stats-overlay--compact' : ''"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(line, index) in hudState.statsOverlay.lines"
|
||||||
|
:key="`${index}-${line}`"
|
||||||
|
class="viewer-stats-overlay__line"
|
||||||
|
:class="line === '' ? 'viewer-stats-overlay__line--spacer' : ''"
|
||||||
|
>
|
||||||
|
{{ line === "" ? "\u00A0" : line }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!hudState.systemPanel.hidden"
|
||||||
|
class="viewer-system-label-dock"
|
||||||
|
>
|
||||||
|
<div class="viewer-system-label">
|
||||||
|
<div class="viewer-system-label__title">
|
||||||
|
{{ hudState.systemPanel.title }}
|
||||||
|
</div>
|
||||||
|
<div class="viewer-system-label__subtitle">
|
||||||
|
{{ hudState.systemPanel.bodyHtml }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 class="absolute right-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(380px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto">
|
||||||
<HtmlInfoPanel
|
|
||||||
class-name="system-panel-section"
|
|
||||||
title="System"
|
|
||||||
:subtitle="hudState.systemPanel.title"
|
|
||||||
:body-html="hudState.systemPanel.bodyHtml"
|
|
||||||
:hidden="hudState.systemPanel.hidden"
|
|
||||||
subtitle-class="system-title"
|
|
||||||
body-class="system-body"
|
|
||||||
/>
|
|
||||||
<ViewerEntityInspectorPanel
|
<ViewerEntityInspectorPanel
|
||||||
class="min-h-0 flex-1"
|
class="min-h-0 flex-1"
|
||||||
:fallback-title="hudState.detailPanel.title"
|
:fallback-title="hudState.detailPanel.title"
|
||||||
@@ -222,7 +302,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"
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export class ViewerAppController {
|
|||||||
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;
|
||||||
@@ -195,6 +195,7 @@ export class ViewerAppController {
|
|||||||
return this.sceneDataController.createWorldPresentationContext({
|
return this.sceneDataController.createWorldPresentationContext({
|
||||||
world: this.world,
|
world: this.world,
|
||||||
activeSystemId: this.activeSystemId,
|
activeSystemId: this.activeSystemId,
|
||||||
|
cameraMode: this.cameraMode,
|
||||||
povLevel: this.povLevel,
|
povLevel: this.povLevel,
|
||||||
orbitYaw: this.orbitYaw,
|
orbitYaw: this.orbitYaw,
|
||||||
systemCamera: this.systemLayer.camera,
|
systemCamera: this.systemLayer.camera,
|
||||||
@@ -293,7 +294,7 @@ export class ViewerAppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 orbitOffset = this.computeOrbitOffset();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -35,6 +38,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, {
|
||||||
@@ -160,13 +169,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 +206,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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,387 @@ function titleCase(value: string | null | undefined) {
|
|||||||
.replace(/\b\w/g, (part) => part.toUpperCase());
|
.replace(/\b\w/g, (part) => part.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVisibleSections(): BrowserSection[] {
|
function compactLabel(value: string | null | undefined, fallback: string) {
|
||||||
const sections: BrowserSection[] = [];
|
if (!value) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = titleCase(value).split(" ");
|
||||||
|
if (words.length === 1) {
|
||||||
|
return words[0].slice(0, 4).toUpperCase();
|
||||||
|
}
|
||||||
|
return words
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((word) => word.slice(0, 3).toUpperCase())
|
||||||
|
.join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortId(value: string) {
|
||||||
|
if (value.length <= 8) {
|
||||||
|
return value.toUpperCase();
|
||||||
|
}
|
||||||
|
return `${value.slice(0, 4).toUpperCase()}-${value.slice(-4).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueTokens(tokens: string[]) {
|
||||||
|
return tokens.filter((token, index) => token.length > 0 && tokens.indexOf(token) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShipLocation(ship: ShipSnapshot) {
|
||||||
|
const dockedStation = ship.dockedStationId ? stationById.value.get(ship.dockedStationId) : undefined;
|
||||||
|
if (dockedStation) {
|
||||||
|
return `Docked ${dockedStation.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ship.spatialState.transit?.destinationNodeId) {
|
||||||
|
return `Transit ${ship.systemId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ship.celestialId) {
|
||||||
|
return `Orbit ${titleCase(ship.celestialId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.celestialId) {
|
||||||
|
return `${system?.label ?? station.systemId} · ${titleCase(station.celestialId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return system?.label ?? station.systemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shipAiStates(ship: ShipSnapshot) {
|
||||||
|
const travelToken = ship.spatialState.transit ? "TRV" : "";
|
||||||
|
const dockToken = ship.dockedStationId ? "DCK" : "";
|
||||||
|
const behaviorToken = compactLabel(getShipBehaviorLabel(ship.defaultBehavior.kind), "AUTO");
|
||||||
|
const planToken = ship.activePlan?.steps.length ? "PLAN" : "";
|
||||||
|
const orderToken = ship.orderQueue.length > 0 ? "ORD" : "";
|
||||||
|
const commandToken = ship.commanderId ? "CMD" : "";
|
||||||
|
|
||||||
|
return uniqueTokens([behaviorToken, orderToken, planToken, travelToken, dockToken, commandToken]).slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stationAiStates(station: StationSnapshot) {
|
||||||
|
return uniqueTokens([
|
||||||
|
station.currentProcesses.length > 0 ? "PROC" : "",
|
||||||
|
station.dockedShips > 0 ? "DCK" : "",
|
||||||
|
station.commanderId ? "CMD" : "",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fleetAiStates(fleet: PlayerFleetSnapshot) {
|
||||||
|
return uniqueTokens([
|
||||||
|
compactLabel(fleet.status, "STAT"),
|
||||||
|
compactLabel(fleet.role, "ROLE"),
|
||||||
|
fleet.commanderId ? "CMD" : "",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemAiStates(systemId: string) {
|
||||||
|
const stations = gmStore.stations.filter((station) => station.systemId === systemId).length;
|
||||||
|
const ships = gmStore.ships.filter((ship) => ship.systemId === systemId).length;
|
||||||
|
return uniqueTokens([stations > 0 ? `ST${stations}` : "", ships > 0 ? `SH${ships}` : ""]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShipRow(ship: ShipSnapshot): BrowserRow {
|
||||||
|
return {
|
||||||
|
key: `ship-${ship.id}`,
|
||||||
|
kind: "ship",
|
||||||
|
kindLabel: "SH",
|
||||||
|
name: ship.name,
|
||||||
|
ident: `${titleCase(ship.type)} · ${shortId(ship.id)}`,
|
||||||
|
location: formatShipLocation(ship),
|
||||||
|
aiStates: shipAiStates(ship),
|
||||||
|
hpLabel: Math.round(ship.health).toString(),
|
||||||
|
hpValue: ship.health,
|
||||||
|
selection: { id: ship.id, kind: "ship", label: ship.name },
|
||||||
|
focusSelection: { kind: "ship", id: ship.id },
|
||||||
|
focusMode: "tactical",
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStationRow(station: StationSnapshot, children: BrowserRow[]): BrowserRow {
|
||||||
|
return {
|
||||||
|
key: `station-${station.id}`,
|
||||||
|
kind: "station",
|
||||||
|
kindLabel: "ST",
|
||||||
|
name: station.label,
|
||||||
|
ident: `${titleCase(station.category)} · ${titleCase(station.objective)}`,
|
||||||
|
location: formatStationLocation(station),
|
||||||
|
aiStates: stationAiStates(station),
|
||||||
|
hpLabel: "--",
|
||||||
|
hpValue: -1,
|
||||||
|
selection: { id: station.id, kind: "station", label: station.label },
|
||||||
|
focusSelection: { kind: "station", id: station.id },
|
||||||
|
focusMode: "tactical",
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFleetRow(fleet: PlayerFleetSnapshot, children: BrowserRow[]): BrowserRow {
|
||||||
|
const homeStation = fleet.homeStationId ? stationById.value.get(fleet.homeStationId) : undefined;
|
||||||
|
const homeSystem = fleet.homeSystemId ? systemById.value.get(fleet.homeSystemId) : undefined;
|
||||||
|
return {
|
||||||
|
key: `fleet-${fleet.id}`,
|
||||||
|
kind: "fleet",
|
||||||
|
kindLabel: "FL",
|
||||||
|
name: fleet.label,
|
||||||
|
ident: `${titleCase(fleet.role)} · ${shortId(fleet.id)}`,
|
||||||
|
location: homeStation ? `Home ${homeStation.label}` : (homeSystem?.label ?? "No home"),
|
||||||
|
aiStates: fleetAiStates(fleet),
|
||||||
|
hpLabel: `${children.length}`,
|
||||||
|
hpValue: children.length,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemRow(systemId: string): BrowserRow | null {
|
||||||
|
const system = systemById.value.get(systemId);
|
||||||
|
if (!system) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: `system-${system.id}`,
|
||||||
|
kind: "system",
|
||||||
|
kindLabel: "SY",
|
||||||
|
name: system.label,
|
||||||
|
ident: shortId(system.id),
|
||||||
|
location: "Galaxy",
|
||||||
|
aiStates: systemAiStates(system.id),
|
||||||
|
hpLabel: "--",
|
||||||
|
hpValue: -1,
|
||||||
|
selection: { id: system.id, kind: "system", label: system.label },
|
||||||
|
focusSelection: { kind: "system", id: system.id },
|
||||||
|
focusMode: "tactical",
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVisibleRows() {
|
||||||
if (povLevel.value === "galaxy" || !activeSystemId.value) {
|
if (povLevel.value === "galaxy" || !activeSystemId.value) {
|
||||||
const systems = [...gmStore.systems]
|
return gmStore.systems
|
||||||
.sort((left, right) => left.label.localeCompare(right.label))
|
.map((system) => buildSystemRow(system.id))
|
||||||
.map<BrowserItem>((system) => ({
|
.filter((row): row is BrowserRow => row != null);
|
||||||
key: `system-${system.id}`,
|
|
||||||
label: system.label,
|
|
||||||
subtitle: `${system.planets.length} planets · ${system.stars.length} stars`,
|
|
||||||
meta: system.id,
|
|
||||||
selection: { id: system.id, kind: "system", label: system.label },
|
|
||||||
focusSelection: { kind: "system", id: system.id },
|
|
||||||
focusMode: "tactical",
|
|
||||||
}));
|
|
||||||
sections.push({
|
|
||||||
key: "systems",
|
|
||||||
label: "Systems",
|
|
||||||
count: systems.length,
|
|
||||||
items: systems,
|
|
||||||
});
|
|
||||||
return sections;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemId = activeSystemId.value;
|
const systemId = activeSystemId.value;
|
||||||
const ships = gmStore.ships
|
const stations = gmStore.stations.filter((station) => station.systemId === systemId);
|
||||||
.filter((ship) => ship.systemId === systemId)
|
const ships = gmStore.ships.filter((ship) => ship.systemId === systemId);
|
||||||
.sort((left, right) => left.name.localeCompare(right.name))
|
const stationIds = new Set(stations.map((station) => station.id));
|
||||||
.map<BrowserItem>((ship) => ({
|
const stationChildren = new Map<string, BrowserRow[]>();
|
||||||
key: `ship-${ship.id}`,
|
const fleetChildren = new Map<string, BrowserRow[]>();
|
||||||
label: ship.name,
|
const independentShips: BrowserRow[] = [];
|
||||||
subtitle: `${titleCase(ship.type)} · ${titleCase(ship.state)}`,
|
|
||||||
meta: `${getShipBehaviorLabel(ship.defaultBehavior.kind)}${ship.defaultBehavior.itemId ? ` · ${ship.defaultBehavior.itemId}` : ""}`,
|
|
||||||
selection: { id: ship.id, kind: "ship", label: ship.name },
|
|
||||||
focusSelection: { kind: "ship", id: ship.id },
|
|
||||||
focusMode: "follow",
|
|
||||||
}));
|
|
||||||
const stations = gmStore.stations
|
|
||||||
.filter((station) => station.systemId === systemId)
|
|
||||||
.sort((left, right) => left.label.localeCompare(right.label))
|
|
||||||
.map<BrowserItem>((station) => ({
|
|
||||||
key: `station-${station.id}`,
|
|
||||||
label: station.label,
|
|
||||||
subtitle: `${titleCase(station.category)} · Docked ${station.dockedShips}/${station.dockingPads}`,
|
|
||||||
meta: station.factionId,
|
|
||||||
selection: { id: station.id, kind: "station", label: station.label },
|
|
||||||
focusSelection: { kind: "station", id: station.id },
|
|
||||||
focusMode: "tactical",
|
|
||||||
}));
|
|
||||||
|
|
||||||
sections.push({
|
for (const ship of ships) {
|
||||||
key: "ships",
|
const row = buildShipRow(ship);
|
||||||
label: "Ships",
|
|
||||||
count: ships.length,
|
|
||||||
items: ships,
|
|
||||||
});
|
|
||||||
sections.push({
|
|
||||||
key: "stations",
|
|
||||||
label: "Stations",
|
|
||||||
count: stations.length,
|
|
||||||
items: stations,
|
|
||||||
});
|
|
||||||
|
|
||||||
return sections;
|
if (ship.dockedStationId && stationIds.has(ship.dockedStationId)) {
|
||||||
|
const children = stationChildren.get(ship.dockedStationId) ?? [];
|
||||||
|
children.push(row);
|
||||||
|
stationChildren.set(ship.dockedStationId, children);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fleet = playerFleetByShipId.value.get(ship.id);
|
||||||
|
if (fleet) {
|
||||||
|
const children = fleetChildren.get(fleet.id) ?? [];
|
||||||
|
children.push(row);
|
||||||
|
fleetChildren.set(fleet.id, children);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
independentShips.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stationRows = stations.map((station) => buildStationRow(station, stationChildren.get(station.id) ?? []));
|
||||||
|
const fleetRows = (playerFaction.value?.fleets ?? [])
|
||||||
|
.filter((fleet) => (fleetChildren.get(fleet.id)?.length ?? 0) > 0)
|
||||||
|
.map((fleet) => buildFleetRow(fleet, fleetChildren.get(fleet.id) ?? []));
|
||||||
|
|
||||||
|
return [...stationRows, ...fleetRows, ...independentShips];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOwnedSections(): BrowserSection[] {
|
function buildOwnedRows() {
|
||||||
const player = playerFaction.value;
|
const player = playerFaction.value;
|
||||||
if (!player) {
|
if (!player) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ships = player.assetRegistry.shipIds
|
const ownedShips = player.assetRegistry.shipIds
|
||||||
.map((shipId) => gmStore.ships.find((ship) => ship.id === shipId))
|
.map((shipId) => gmStore.ships.find((ship) => ship.id === shipId))
|
||||||
.filter((ship): ship is NonNullable<typeof ship> => ship != null)
|
.filter((ship): ship is ShipSnapshot => ship != null);
|
||||||
.sort((left, right) => left.name.localeCompare(right.name))
|
const ownedStations = player.assetRegistry.stationIds
|
||||||
.map<BrowserItem>((ship) => ({
|
|
||||||
key: `owned-ship-${ship.id}`,
|
|
||||||
label: ship.name,
|
|
||||||
subtitle: `${ship.systemId} · ${titleCase(ship.state)}`,
|
|
||||||
meta: getShipBehaviorLabel(ship.defaultBehavior.kind),
|
|
||||||
selection: { id: ship.id, kind: "ship", label: ship.name },
|
|
||||||
focusSelection: { kind: "ship", id: ship.id },
|
|
||||||
focusMode: "follow",
|
|
||||||
}));
|
|
||||||
const stations = player.assetRegistry.stationIds
|
|
||||||
.map((stationId) => gmStore.stations.find((station) => station.id === stationId))
|
.map((stationId) => gmStore.stations.find((station) => station.id === stationId))
|
||||||
.filter((station): station is NonNullable<typeof station> => station != null)
|
.filter((station): station is StationSnapshot => station != null);
|
||||||
.sort((left, right) => left.label.localeCompare(right.label))
|
const ownedFleetShipIds = new Set(player.fleets.flatMap((fleet) => fleet.assetIds));
|
||||||
.map<BrowserItem>((station) => ({
|
const ownedStationIds = new Set(ownedStations.map((station) => station.id));
|
||||||
key: `owned-station-${station.id}`,
|
|
||||||
label: station.label,
|
|
||||||
subtitle: `${station.systemId} · ${titleCase(station.category)}`,
|
|
||||||
meta: `${station.installedModules.length} modules`,
|
|
||||||
selection: { id: station.id, kind: "station", label: station.label },
|
|
||||||
focusSelection: { kind: "station", id: station.id },
|
|
||||||
focusMode: "tactical",
|
|
||||||
}));
|
|
||||||
const fleets = player.fleets
|
|
||||||
.slice()
|
|
||||||
.sort((left, right) => left.label.localeCompare(right.label))
|
|
||||||
.map<BrowserItem>((fleet) => ({
|
|
||||||
key: `fleet-${fleet.id}`,
|
|
||||||
label: fleet.label,
|
|
||||||
subtitle: `${titleCase(fleet.role)} · ${titleCase(fleet.status)}`,
|
|
||||||
meta: `${fleet.assetIds.length} assets`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [
|
const stationChildren = new Map<string, BrowserRow[]>();
|
||||||
{
|
for (const ship of ownedShips) {
|
||||||
key: "owned-fleets",
|
if (!ship.dockedStationId || !ownedStationIds.has(ship.dockedStationId) || ownedFleetShipIds.has(ship.id)) {
|
||||||
label: "Fleets",
|
continue;
|
||||||
count: fleets.length,
|
}
|
||||||
items: fleets,
|
const children = stationChildren.get(ship.dockedStationId) ?? [];
|
||||||
},
|
children.push(buildShipRow(ship));
|
||||||
{
|
stationChildren.set(ship.dockedStationId, children);
|
||||||
key: "owned-stations",
|
}
|
||||||
label: "Stations",
|
|
||||||
count: stations.length,
|
const stationRows = ownedStations.map((station) => buildStationRow(station, stationChildren.get(station.id) ?? []));
|
||||||
items: stations,
|
const fleetRows = player.fleets.map((fleet) => buildFleetRow(
|
||||||
},
|
fleet,
|
||||||
{
|
fleet.assetIds
|
||||||
key: "owned-ships",
|
.map((shipId) => ownedShips.find((ship) => ship.id === shipId))
|
||||||
label: "Ships",
|
.filter((ship): ship is ShipSnapshot => ship != null)
|
||||||
count: ships.length,
|
.map((ship) => buildShipRow(ship)),
|
||||||
items: ships,
|
));
|
||||||
},
|
const independentShips = ownedShips
|
||||||
];
|
.filter((ship) => !ownedFleetShipIds.has(ship.id) && (!ship.dockedStationId || !ownedStationIds.has(ship.dockedStationId)))
|
||||||
|
.map((ship) => buildShipRow(ship));
|
||||||
|
|
||||||
|
return [...stationRows, ...fleetRows, ...independentShips];
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredSections = computed(() => {
|
function getRowSortValue(row: BrowserRow, key: BrowserSortKey) {
|
||||||
const search = normalize(searchText.value);
|
if (key === "hp") {
|
||||||
const sections = activeTab.value === "visible" ? buildVisibleSections() : buildOwnedSections();
|
return row.hpValue;
|
||||||
return sections
|
}
|
||||||
.map((section) => ({
|
|
||||||
...section,
|
if (key === "location") {
|
||||||
items: section.items.filter((item) => matchesSearch(item, search)),
|
return row.location;
|
||||||
}))
|
}
|
||||||
.filter((section) => section.items.length > 0);
|
|
||||||
|
if (key === "ai") {
|
||||||
|
return row.aiStates.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${row.name} ${row.ident}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRows(rows: BrowserRow[]): BrowserRow[] {
|
||||||
|
const direction = sortDirection.value === "asc" ? 1 : -1;
|
||||||
|
return [...rows]
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftValue = getRowSortValue(left, sortKey.value);
|
||||||
|
const rightValue = getRowSortValue(right, sortKey.value);
|
||||||
|
|
||||||
|
if (typeof leftValue === "number" && typeof rightValue === "number") {
|
||||||
|
return (leftValue - rightValue) * direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(leftValue).localeCompare(String(rightValue)) * direction;
|
||||||
|
})
|
||||||
|
.map((row) => ({
|
||||||
|
...row,
|
||||||
|
children: sortRows(row.children),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowMatches(row: BrowserRow, search: string) {
|
||||||
|
if (!search) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const haystack = `${row.name} ${row.ident} ${row.location} ${row.aiStates.join(" ")} ${row.hpLabel}`.toLowerCase();
|
||||||
|
return haystack.includes(search);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpanded(row: BrowserRow) {
|
||||||
|
if (row.children.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return expandedRowKeys.value[row.key] ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenRows(rows: BrowserRow[], search: string, depth = 0, forceExpand = false): BrowserDisplayRow[] {
|
||||||
|
const flattened: BrowserDisplayRow[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const descendantMatches = flattenRows(row.children, search, depth + 1, forceExpand);
|
||||||
|
const matches = rowMatches(row, search);
|
||||||
|
if (!matches && descendantMatches.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
flattened.push({
|
||||||
|
...row,
|
||||||
|
depth,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (row.children.length > 0 && (forceExpand || isExpanded(row))) {
|
||||||
|
flattened.push(...descendantMatches);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flattened;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawRows = computed(() => {
|
||||||
|
if (activeTab.value === "owned") {
|
||||||
|
return buildOwnedRows();
|
||||||
|
}
|
||||||
|
return buildVisibleRows();
|
||||||
});
|
});
|
||||||
|
|
||||||
function selectItem(item: BrowserItem) {
|
const displayRows = computed(() => {
|
||||||
if (!item.selection) {
|
const search = normalize(searchText.value);
|
||||||
|
const sortedRows = sortRows(rawRows.value);
|
||||||
|
return flattenRows(sortedRows, search, 0, search.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleSort(nextKey: BrowserSortKey) {
|
||||||
|
if (sortKey.value === nextKey) {
|
||||||
|
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectionStore.selectSelection(item.selection, "ui");
|
sortKey.value = nextKey;
|
||||||
|
sortDirection.value = "asc";
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusItem(item: BrowserItem) {
|
function toggleRow(row: BrowserDisplayRow) {
|
||||||
if (item.selection) {
|
if (row.children.length === 0) {
|
||||||
selectionStore.selectSelection(item.selection, "ui");
|
return;
|
||||||
}
|
}
|
||||||
if (item.focusSelection) {
|
|
||||||
emit("focus", item.focusSelection, item.focusMode);
|
expandedRowKeys.value[row.key] = !isExpanded(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(row: BrowserDisplayRow) {
|
||||||
|
if (!row.selection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionStore.selectSelection(row.selection, "ui");
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusItem(row: BrowserDisplayRow) {
|
||||||
|
if (row.selection) {
|
||||||
|
selectionStore.selectSelection(row.selection, "ui");
|
||||||
|
}
|
||||||
|
if (row.focusSelection) {
|
||||||
|
emit("focus", row.focusSelection, row.focusMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSelected(item: BrowserItem) {
|
function isSelected(row: BrowserDisplayRow) {
|
||||||
return !!item.selection
|
return !!row.selection
|
||||||
&& item.selection.id === selectedEntityId.value
|
&& row.selection.id === selectedEntityId.value
|
||||||
&& item.selection.kind === selectedEntityKind.value;
|
&& row.selection.kind === selectedEntityKind.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortMarker(key: BrowserSortKey) {
|
||||||
|
if (sortKey.value !== key) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortDirection.value === "asc" ? " ▲" : " ▼";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -276,47 +509,91 @@ function isSelected(item: BrowserItem) {
|
|||||||
<div v-if="activeTab === 'owned' && !playerFaction" class="entity-browser-panel__empty">
|
<div v-if="activeTab === 'owned' && !playerFaction" class="entity-browser-panel__empty">
|
||||||
No player-owned assets yet.
|
No player-owned assets yet.
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="filteredSections.length === 0" class="entity-browser-panel__empty">
|
<div v-else-if="displayRows.length === 0" class="entity-browser-panel__empty">
|
||||||
Nothing matches the current view.
|
Nothing matches the current view.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="entity-browser-panel__sections">
|
<div v-else class="entity-browser-panel__sections">
|
||||||
<section
|
<div class="entity-browser-table-wrap">
|
||||||
v-for="section in filteredSections"
|
<table class="entity-browser-table entity-browser-table--tree">
|
||||||
:key="section.key"
|
<colgroup>
|
||||||
class="entity-browser-section"
|
<col class="entity-browser-table__col entity-browser-table__col--entity">
|
||||||
>
|
<col class="entity-browser-table__col entity-browser-table__col--ident">
|
||||||
<header class="entity-browser-section__header">
|
<col class="entity-browser-table__col entity-browser-table__col--location">
|
||||||
<span>{{ section.label }}</span>
|
<col class="entity-browser-table__col entity-browser-table__col--ai">
|
||||||
<span>{{ section.items.length }}</span>
|
<col class="entity-browser-table__col entity-browser-table__col--hp">
|
||||||
</header>
|
</colgroup>
|
||||||
<div class="entity-browser-section__items">
|
<thead>
|
||||||
<div
|
<tr>
|
||||||
v-for="item in section.items"
|
<th scope="col">
|
||||||
:key="item.key"
|
<button type="button" class="entity-browser-table__sort" @click="toggleSort('entity')">
|
||||||
class="entity-browser-item"
|
Entity{{ sortMarker("entity") }}
|
||||||
:class="isSelected(item) ? 'entity-browser-item--selected' : ''"
|
</button>
|
||||||
>
|
</th>
|
||||||
<button
|
<th scope="col">Ident</th>
|
||||||
type="button"
|
<th scope="col">
|
||||||
class="entity-browser-item__body"
|
<button type="button" class="entity-browser-table__sort" @click="toggleSort('location')">
|
||||||
:disabled="!item.selection"
|
Location{{ sortMarker("location") }}
|
||||||
@click="selectItem(item)"
|
</button>
|
||||||
|
</th>
|
||||||
|
<th scope="col">
|
||||||
|
<button type="button" class="entity-browser-table__sort" @click="toggleSort('ai')">
|
||||||
|
AI{{ sortMarker("ai") }}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="entity-browser-table__numeric">
|
||||||
|
<button type="button" class="entity-browser-table__sort" @click="toggleSort('hp')">
|
||||||
|
HP{{ sortMarker("hp") }}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in displayRows"
|
||||||
|
:key="row.key"
|
||||||
|
class="entity-browser-table__row"
|
||||||
|
:class="isSelected(row) ? 'entity-browser-table__row--selected' : ''"
|
||||||
|
@click="selectItem(row)"
|
||||||
|
@dblclick="focusItem(row)"
|
||||||
>
|
>
|
||||||
<div class="entity-browser-item__label">{{ item.label }}</div>
|
<td class="entity-browser-table__name">
|
||||||
<div class="entity-browser-item__subtitle">{{ item.subtitle }}</div>
|
<div class="entity-browser-row" :style="{ paddingLeft: `${row.depth * 0.9}rem` }">
|
||||||
<div v-if="item.meta" class="entity-browser-item__meta">{{ item.meta }}</div>
|
<button
|
||||||
</button>
|
v-if="row.children.length > 0"
|
||||||
<button
|
type="button"
|
||||||
v-if="item.focusSelection"
|
class="entity-browser-row__toggle"
|
||||||
type="button"
|
@click.stop="toggleRow(row)"
|
||||||
class="entity-browser-item__focus"
|
>
|
||||||
@click.stop="focusItem(item)"
|
{{ isExpanded(row) ? "-" : "+" }}
|
||||||
>
|
</button>
|
||||||
Focus
|
<span v-else class="entity-browser-row__toggle entity-browser-row__toggle--spacer" />
|
||||||
</button>
|
<span class="entity-browser-row__kind" :class="`entity-browser-row__kind--${row.kind}`">
|
||||||
</div>
|
{{ row.kindLabel }}
|
||||||
</div>
|
</span>
|
||||||
</section>
|
<span class="entity-browser-row__label">{{ row.name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="entity-browser-table__detail entity-browser-table__cell--truncate">{{ row.ident }}</td>
|
||||||
|
<td class="entity-browser-table__cell--truncate">{{ row.location }}</td>
|
||||||
|
<td class="entity-browser-table__cell--ai">
|
||||||
|
<div class="entity-browser-ai">
|
||||||
|
<span
|
||||||
|
v-for="token in row.aiStates"
|
||||||
|
:key="`${row.key}-${token}`"
|
||||||
|
class="entity-browser-ai__token"
|
||||||
|
>
|
||||||
|
{{ token }}
|
||||||
|
</span>
|
||||||
|
<span v-if="row.aiStates.length === 0" class="entity-browser-table__muted">--</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="entity-browser-table__numeric">
|
||||||
|
{{ row.hpLabel }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -76,6 +76,50 @@ 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 joinDetail(parts: Array<string | null | undefined>) {
|
||||||
|
return parts.filter((part): part is string => !!part && part.trim().length > 0).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeOrderTarget(order: {
|
||||||
|
itemId?: string | null;
|
||||||
|
targetEntityId?: string | null;
|
||||||
|
targetSystemId?: string | null;
|
||||||
|
nodeId?: string | null;
|
||||||
|
constructionSiteId?: string | null;
|
||||||
|
sourceStationId?: string | null;
|
||||||
|
destinationStationId?: string | null;
|
||||||
|
moduleId?: string | null;
|
||||||
|
}) {
|
||||||
|
return order.itemId
|
||||||
|
?? order.targetEntityId
|
||||||
|
?? order.targetSystemId
|
||||||
|
?? order.nodeId
|
||||||
|
?? order.constructionSiteId
|
||||||
|
?? order.destinationStationId
|
||||||
|
?? order.sourceStationId
|
||||||
|
?? order.moduleId
|
||||||
|
?? "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSubTaskTarget(subTask: {
|
||||||
|
itemId?: string | null;
|
||||||
|
targetEntityId?: string | null;
|
||||||
|
targetSystemId?: string | null;
|
||||||
|
targetNodeId?: string | null;
|
||||||
|
moduleId?: string | null;
|
||||||
|
}) {
|
||||||
|
return subTask.itemId
|
||||||
|
?? subTask.targetEntityId
|
||||||
|
?? subTask.targetSystemId
|
||||||
|
?? subTask.targetNodeId
|
||||||
|
?? 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,10 +144,10 @@ 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(() =>
|
||||||
@@ -135,20 +179,208 @@ const formBehaviorNotes = computed(() =>
|
|||||||
getShipBehaviorNotes(behaviorForm.kind),
|
getShipBehaviorNotes(behaviorForm.kind),
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(selectedShip, (ship) => {
|
const shipStatusRows = computed(() => {
|
||||||
if (!ship) {
|
if (!selectedShip.value) {
|
||||||
return;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
behaviorForm.kind = ship.defaultBehavior.kind;
|
return [
|
||||||
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
|
{ label: "State", value: titleCase(selectedShip.value.state) },
|
||||||
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
|
{ label: "Behavior", value: getShipBehaviorLabel(selectedShip.value.defaultBehavior.kind) },
|
||||||
mineOrderForm.systemId = ship.systemId ?? "";
|
{ label: "Control", value: titleCase(selectedShip.value.controlSourceKind) },
|
||||||
mineOrderForm.itemId = "ore";
|
{ label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" },
|
||||||
moveOrderSystemId.value = ship.systemId ?? "";
|
{
|
||||||
actionStatus.value = "";
|
label: "Plan",
|
||||||
actionError.value = "";
|
value: selectedShip.value.activePlan
|
||||||
}, { immediate: true });
|
? `${selectedShip.value.activePlan.kind} · ${titleCase(selectedShip.value.activePlan.status)}`
|
||||||
|
: "none",
|
||||||
|
},
|
||||||
|
{ label: "Failure", value: selectedShip.value.lastAccessFailureReason ?? "none" },
|
||||||
|
{ label: "Commander", value: selectedShip.value.commanderId ?? "none" },
|
||||||
|
{ label: "Docked", value: selectedShip.value.dockedStationId ?? "no" },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const shipCargoSummaryRows = computed(() => {
|
||||||
|
if (!selectedShip.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedCargo = selectedShip.value.inventory.reduce((sum, entry) => sum + entry.amount, 0);
|
||||||
|
return [
|
||||||
|
{ label: "Used", value: formatAmount(usedCargo) },
|
||||||
|
{ label: "Capacity", value: formatAmount(selectedShip.value.cargoCapacity) },
|
||||||
|
{ label: "Free", value: formatAmount(Math.max(selectedShip.value.cargoCapacity - usedCargo, 0)) },
|
||||||
|
{ label: "Travel", value: `${formatAmount(selectedShip.value.travelSpeed)} ${selectedShip.value.travelSpeedUnit}` },
|
||||||
|
{ label: "Hull", value: formatAmount(selectedShip.value.health) },
|
||||||
|
{ label: "Regime", value: titleCase(selectedShip.value.spatialState.movementRegime) },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
label: getShipOrderLabel(order.kind),
|
||||||
|
status: titleCase(order.status),
|
||||||
|
target: describeOrderTarget(order),
|
||||||
|
detail: joinDetail([
|
||||||
|
`P${order.priority}`,
|
||||||
|
titleCase(order.sourceKind),
|
||||||
|
order.failureReason ?? undefined,
|
||||||
|
]),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const behaviorOrderRows = computed(() =>
|
||||||
|
behaviorOrders.value.map((order) => ({
|
||||||
|
id: order.id,
|
||||||
|
label: getShipOrderLabel(order.kind),
|
||||||
|
status: titleCase(order.status),
|
||||||
|
target: describeOrderTarget(order),
|
||||||
|
detail: joinDetail([
|
||||||
|
`P${order.priority}`,
|
||||||
|
getShipOrderSupportStatusLabel(order.kind) ?? undefined,
|
||||||
|
getShipOrderNotes(order.kind) ?? undefined,
|
||||||
|
order.failureReason ?? undefined,
|
||||||
|
]),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const shipPlanRows = computed(() => {
|
||||||
|
if (!selectedShip.value?.activePlan) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedShip.value.activePlan.steps.flatMap((step) => {
|
||||||
|
const stepRow = {
|
||||||
|
id: step.id,
|
||||||
|
scope: "Step",
|
||||||
|
activity: step.summary || titleCase(step.kind),
|
||||||
|
status: titleCase(step.status),
|
||||||
|
detail: joinDetail([
|
||||||
|
step.blockingReason ?? undefined,
|
||||||
|
`${step.subTasks.length} subtasks`,
|
||||||
|
]),
|
||||||
|
isSubTask: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const subTaskRows = step.subTasks.map((subTask) => ({
|
||||||
|
id: subTask.id,
|
||||||
|
scope: "Subtask",
|
||||||
|
activity: subTask.summary || titleCase(subTask.kind),
|
||||||
|
status: titleCase(subTask.status),
|
||||||
|
detail: joinDetail([
|
||||||
|
describeSubTaskTarget(subTask),
|
||||||
|
subTask.blockingReason ?? undefined,
|
||||||
|
`${Math.round(subTask.progress * 100)}%`,
|
||||||
|
]),
|
||||||
|
isSubTask: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [stepRow, ...subTaskRows];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const stationStatusRows = computed(() => {
|
||||||
|
if (!selectedStation.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: "Category", value: titleCase(selectedStation.value.category) },
|
||||||
|
{ 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,
|
||||||
|
storageClass: titleCase(entry.storageClass),
|
||||||
|
used: formatAmount(entry.used),
|
||||||
|
capacity: formatAmount(entry.capacity),
|
||||||
|
fill: entry.capacity > 0 ? formatPercent(entry.used / entry.capacity) : "0%",
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stationInventoryRows = computed(() =>
|
||||||
|
selectedStation.value?.inventory.map((entry) => ({
|
||||||
|
key: entry.itemId,
|
||||||
|
ware: entry.itemId,
|
||||||
|
amount: formatAmount(entry.amount),
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stationProcessRows = computed(() =>
|
||||||
|
selectedStation.value?.currentProcesses.map((process) => ({
|
||||||
|
key: `${process.lane}-${process.label}`,
|
||||||
|
lane: process.lane,
|
||||||
|
label: process.label,
|
||||||
|
progress: formatPercent(process.progress),
|
||||||
|
timing: `${Math.ceil(process.timeRemainingSeconds)}s / ${Math.ceil(process.cycleSeconds)}s`,
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => `${selectedEntityKind.value ?? "none"}:${selectedEntityId.value ?? "none"}`,
|
||||||
|
() => {
|
||||||
|
const ship = selectedShip.value;
|
||||||
|
if (!ship) {
|
||||||
|
actionStatus.value = "";
|
||||||
|
actionError.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
behaviorForm.kind = ship.defaultBehavior.kind;
|
||||||
|
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
|
||||||
|
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
|
||||||
|
mineOrderForm.systemId = ship.systemId ?? "";
|
||||||
|
mineOrderForm.itemId = "ore";
|
||||||
|
moveOrderSystemId.value = ship.systemId ?? "";
|
||||||
|
actionStatus.value = "";
|
||||||
|
actionError.value = "";
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
function focusShip(cameraMode?: "follow" | "tactical") {
|
function focusShip(cameraMode?: "follow" | "tactical") {
|
||||||
if (!selectedShip.value) {
|
if (!selectedShip.value) {
|
||||||
@@ -357,36 +589,52 @@ 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>Cargo</h4>
|
||||||
<div class="entity-inspector-grid">
|
<div class="entity-inspector-table-wrap">
|
||||||
<div><span>Used</span><strong>{{ formatAmount(selectedShip.inventory.reduce((sum, entry) => sum + entry.amount, 0)) }}</strong></div>
|
<table class="entity-inspector-table entity-inspector-table--kv">
|
||||||
<div><span>Capacity</span><strong>{{ formatAmount(selectedShip.cargoCapacity) }}</strong></div>
|
<tbody>
|
||||||
<div><span>Travel</span><strong>{{ formatAmount(selectedShip.travelSpeed) }} {{ selectedShip.travelSpeedUnit }}</strong></div>
|
<tr v-for="row in shipCargoSummaryRows" :key="row.label">
|
||||||
<div><span>Hull</span><strong>{{ formatAmount(selectedShip.health) }}</strong></div>
|
<th scope="row">{{ row.label }}</th>
|
||||||
|
<td>{{ row.value }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-if="shipCargoRows.length > 0" class="entity-inspector-table-wrap">
|
||||||
|
<table class="entity-inspector-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Ware</th>
|
||||||
|
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in shipCargoRows" :key="row.key">
|
||||||
|
<td>{{ row.ware }}</td>
|
||||||
|
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</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 v-else class="entity-inspector-empty">No cargo.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -395,13 +643,15 @@ async function clearOrders() {
|
|||||||
<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">
|
||||||
@@ -473,52 +723,86 @@ async function clearOrders() {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
|
<div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
|
||||||
<div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
|
<div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
|
||||||
<ul v-if="directOrders.length > 0" class="entity-inspector-list">
|
<div v-if="directOrderRows.length > 0" class="entity-inspector-table-wrap">
|
||||||
<li v-for="order in directOrders" :key="order.id">
|
<table class="entity-inspector-table">
|
||||||
<span>{{ getShipOrderLabel(order.kind) }} · {{ order.status }}</span>
|
<thead>
|
||||||
<div class="entity-inspector-order-actions">
|
<tr>
|
||||||
<strong>{{ order.itemId ?? order.targetEntityId ?? order.targetSystemId ?? "—" }}</strong>
|
<th scope="col">Order</th>
|
||||||
<button
|
<th scope="col">Status</th>
|
||||||
v-if="canDirectControlSelectedShip"
|
<th scope="col">Target</th>
|
||||||
type="button"
|
<th scope="col">Detail</th>
|
||||||
class="entity-inspector-order-remove"
|
<th v-if="canDirectControlSelectedShip" scope="col" class="entity-inspector-table__action-col">Action</th>
|
||||||
:disabled="actionBusy"
|
</tr>
|
||||||
@click="removeOrder(order.id)"
|
</thead>
|
||||||
>
|
<tbody>
|
||||||
Remove
|
<tr v-for="order in directOrderRows" :key="order.id">
|
||||||
</button>
|
<td>{{ order.label }}</td>
|
||||||
</div>
|
<td>{{ order.status }}</td>
|
||||||
</li>
|
<td>{{ order.target }}</td>
|
||||||
</ul>
|
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
|
||||||
|
<td v-if="canDirectControlSelectedShip" class="entity-inspector-table__action-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="entity-inspector-order-remove"
|
||||||
|
:disabled="actionBusy"
|
||||||
|
@click="removeOrder(order.id)"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
|
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
|
||||||
<div class="entity-inspector-divider">
|
<div class="entity-inspector-divider">
|
||||||
<span>Behavior: {{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
|
<span>Behavior: {{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="behaviorOrders.length > 0" class="entity-inspector-list">
|
<div v-if="behaviorOrderRows.length > 0" class="entity-inspector-table-wrap">
|
||||||
<li v-for="order in behaviorOrders" :key="order.id">
|
<table class="entity-inspector-table">
|
||||||
<span>{{ getShipOrderLabel(order.kind) }} · {{ order.status }}</span>
|
<thead>
|
||||||
<strong>{{ [order.itemId ?? order.targetEntityId ?? order.targetSystemId ?? "—", getShipOrderSupportStatusLabel(order.kind), getShipOrderNotes(order.kind)].filter(Boolean).join(" · ") }}</strong>
|
<tr>
|
||||||
</li>
|
<th scope="col">Order</th>
|
||||||
</ul>
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Target</th>
|
||||||
|
<th scope="col">Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="order in behaviorOrderRows" :key="order.id">
|
||||||
|
<td>{{ order.label }}</td>
|
||||||
|
<td>{{ order.status }}</td>
|
||||||
|
<td>{{ order.target }}</td>
|
||||||
|
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
<div v-else class="entity-inspector-empty">No behavior orders queued.</div>
|
<div v-else class="entity-inspector-empty">No behavior orders queued.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="entity-inspector-section">
|
<div class="entity-inspector-section">
|
||||||
<h4>Plan Steps</h4>
|
<h4>Plan Steps</h4>
|
||||||
<ul v-if="selectedShip.activePlan" class="entity-inspector-plan">
|
<div v-if="shipPlanRows.length > 0" class="entity-inspector-table-wrap">
|
||||||
<li v-for="step in selectedShip.activePlan.steps" :key="step.id">
|
<table class="entity-inspector-table">
|
||||||
<div class="entity-inspector-plan__step">
|
<thead>
|
||||||
<span>{{ step.kind }} · {{ step.status }}</span>
|
<tr>
|
||||||
<strong>{{ step.blockingReason ?? "ok" }}</strong>
|
<th scope="col">Scope</th>
|
||||||
</div>
|
<th scope="col">Activity</th>
|
||||||
<ul class="entity-inspector-subtasks">
|
<th scope="col">Status</th>
|
||||||
<li v-for="subTask in step.subTasks" :key="subTask.id">
|
<th scope="col">Detail</th>
|
||||||
<span>{{ subTask.kind }} · {{ subTask.status }}</span>
|
</tr>
|
||||||
<strong>{{ subTask.blockingReason ?? `${Math.round(subTask.progress * 100)}%` }}</strong>
|
</thead>
|
||||||
</li>
|
<tbody>
|
||||||
</ul>
|
<tr v-for="row in shipPlanRows" :key="row.id" :class="row.isSubTask ? 'entity-inspector-table__row--subtask' : ''">
|
||||||
</li>
|
<td>{{ row.scope }}</td>
|
||||||
</ul>
|
<td :class="row.isSubTask ? 'entity-inspector-table__subtask' : ''">{{ row.activity }}</td>
|
||||||
|
<td>{{ row.status }}</td>
|
||||||
|
<td class="entity-inspector-table__detail">{{ row.detail }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
<div v-else class="entity-inspector-empty">No active plan.</div>
|
<div v-else class="entity-inspector-empty">No active plan.</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -537,46 +821,102 @@ 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-table-wrap">
|
||||||
<li v-for="entry in selectedStation.inventory" :key="entry.itemId">
|
<table class="entity-inspector-table">
|
||||||
<span>{{ entry.itemId }}</span>
|
<thead>
|
||||||
<strong>{{ formatAmount(entry.amount) }}</strong>
|
<tr>
|
||||||
</li>
|
<th scope="col">Class</th>
|
||||||
</ul>
|
<th scope="col" class="entity-inspector-table__numeric">Used</th>
|
||||||
<div v-else class="entity-inspector-empty">No inventory.</div>
|
<th scope="col" class="entity-inspector-table__numeric">Capacity</th>
|
||||||
|
<th scope="col" class="entity-inspector-table__numeric">Fill</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in stationStorageRows" :key="row.key">
|
||||||
|
<td>{{ row.storageClass }}</td>
|
||||||
|
<td class="entity-inspector-table__numeric">{{ row.used }}</td>
|
||||||
|
<td class="entity-inspector-table__numeric">{{ row.capacity }}</td>
|
||||||
|
<td class="entity-inspector-table__numeric">{{ row.fill }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const canControlSelectedShip = computed(() => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authStore.canAccessGm) {
|
if (authStore.canAccessGm && !authStore.isActingAsAlternateIdentity) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,162 @@ 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-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,
|
||||||
@@ -1257,54 +1408,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 +1678,31 @@ 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 {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: baseline;
|
|
||||||
padding: 0.55rem 0.7rem;
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
background: rgba(255, 255, 255, 0.035);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-inspector-list span,
|
.entity-inspector-table__subtask::before {
|
||||||
.entity-inspector-plan__step span,
|
content: "↳ ";
|
||||||
.entity-inspector-subtasks span {
|
color: rgba(173, 220, 255, 0.58);
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entity-inspector-list strong,
|
|
||||||
.entity-inspector-plan__step strong,
|
|
||||||
.entity-inspector-subtasks strong {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--viewer-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.entity-inspector-plan > li {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entity-inspector-subtasks {
|
|
||||||
padding-left: 0.8rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-inspector-panel__fallback {
|
.entity-inspector-panel__fallback {
|
||||||
@@ -1592,8 +1902,30 @@ 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-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 +1934,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;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,6 +103,42 @@ export function updatePanFromKeyboard(
|
|||||||
galaxyAnchor.addScaledVector(pan, speed * delta);
|
galaxyAnchor.addScaledVector(pan, speed * delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyPanFromScreenDelta(
|
||||||
|
delta: THREE.Vector2,
|
||||||
|
orbitYaw: number,
|
||||||
|
currentDistance: 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 pan = right.multiplyScalar(-normalized.x).add(forward.multiplyScalar(-normalized.y));
|
||||||
|
|
||||||
|
if (activeSystemId) {
|
||||||
|
const scale = povLevel === "system"
|
||||||
|
? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.35, KILOMETERS_PER_AU * 6.5)
|
||||||
|
: THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 1200, 180000);
|
||||||
|
systemAnchor.addScaledVector(pan, scale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const galaxyScale = THREE.MathUtils.mapLinear(currentDistance, minimumDistance, maximumDistance, 1800, 22000);
|
||||||
|
galaxyAnchor.addScaledVector(pan, galaxyScale);
|
||||||
|
}
|
||||||
|
|
||||||
export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined {
|
export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined {
|
||||||
const {
|
const {
|
||||||
world,
|
world,
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -236,14 +238,21 @@ export function createViewerControllers(host: any) {
|
|||||||
getFollowCameraPosition: () => host.followCameraPosition,
|
getFollowCameraPosition: () => host.followCameraPosition,
|
||||||
getFollowCameraFocus: () => host.followCameraFocus,
|
getFollowCameraFocus: () => host.followCameraFocus,
|
||||||
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.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 +260,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 +283,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 +299,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);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewer
|
|||||||
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,
|
||||||
Selectable,
|
Selectable,
|
||||||
@@ -250,3 +251,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;
|
||||||
@@ -100,6 +107,7 @@ 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;
|
||||||
@@ -135,6 +143,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",
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -61,88 +59,128 @@ export interface ViewerInteractionContext {
|
|||||||
getFollowCameraPosition: () => THREE.Vector3;
|
getFollowCameraPosition: () => THREE.Vector3;
|
||||||
getFollowCameraFocus: () => THREE.Vector3;
|
getFollowCameraFocus: () => THREE.Vector3;
|
||||||
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
|
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
|
||||||
applyOrbitDelta: (delta: THREE.Vector2) => void;
|
applyPanDelta: (delta: THREE.Vector2) => void;
|
||||||
syncFollowStateFromSelection: () => void;
|
syncFollowStateFromSelection: () => void;
|
||||||
updatePanels: () => void;
|
updatePanels: () => void;
|
||||||
focusOnSelection: (selection: Selectable) => void;
|
focusOnSelection: (selection: Selectable) => void;
|
||||||
updateGamePanel: (mode: string) => void;
|
updateGamePanel: (mode: string) => void;
|
||||||
openOrderContextMenu: (x: number, y: number, target: ViewerOrderContextMenuTarget) => void;
|
openOrderContextMenu: (x: number, y: number, target: ViewerOrderContextMenuTarget) => void;
|
||||||
closeOrderContextMenu: () => void;
|
closeOrderContextMenu: () => void;
|
||||||
|
getStatsOverlayMode: () => StatsOverlayMode;
|
||||||
|
setStatsOverlayMode: (mode: StatsOverlayMode) => void;
|
||||||
|
refreshStatsOverlay: () => void;
|
||||||
historyController: ViewerHistoryWindowController;
|
historyController: ViewerHistoryWindowController;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ViewerInteractionController {
|
export class ViewerInteractionController {
|
||||||
|
private readonly activePointers = new Map<number, THREE.Vector2>();
|
||||||
|
private pinchStartDistance?: number;
|
||||||
|
private pinchStartZoom?: number;
|
||||||
|
private pinchLastCenter?: THREE.Vector2;
|
||||||
|
|
||||||
constructor(private readonly context: ViewerInteractionContext) {}
|
constructor(private readonly context: ViewerInteractionContext) {}
|
||||||
|
|
||||||
readonly onPointerDown = (event: PointerEvent) => {
|
readonly onPointerDown = (event: PointerEvent) => {
|
||||||
if (event.button === 1) {
|
|
||||||
this.context.setDragMode("orbit");
|
|
||||||
this.context.setDragPointerId(event.pointerId);
|
|
||||||
this.context.dragLast.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
|
|
||||||
this.context.renderer.domElement.setPointerCapture(event.pointerId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.button !== 0) {
|
if (event.button !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.context.setDragMode("marquee");
|
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
|
||||||
this.context.setDragPointerId(event.pointerId);
|
this.activePointers.set(event.pointerId, point);
|
||||||
this.context.dragStart.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
|
|
||||||
this.context.dragLast.copy(this.context.dragStart);
|
|
||||||
this.context.setMarqueeActive(false);
|
|
||||||
this.context.renderer.domElement.setPointerCapture(event.pointerId);
|
this.context.renderer.domElement.setPointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
if (this.activePointers.size >= 2) {
|
||||||
|
const gesture = this.getPinchGesture();
|
||||||
|
if (!gesture) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context.setSuppressClickSelection(true);
|
||||||
|
this.context.setDragMode("pinch");
|
||||||
|
this.context.setDragPointerId(event.pointerId);
|
||||||
|
this.pinchStartDistance = gesture.distance;
|
||||||
|
this.pinchStartZoom = this.context.getDesiredDistance();
|
||||||
|
this.pinchLastCenter = gesture.center;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context.setDragMode("pan");
|
||||||
|
this.context.setDragPointerId(event.pointerId);
|
||||||
|
this.context.dragStart.copy(point);
|
||||||
|
this.context.dragLast.copy(point);
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly onPointerMove = (event: PointerEvent) => {
|
readonly onPointerMove = (event: PointerEvent) => {
|
||||||
this.updateHoverLabel(event);
|
this.updateHoverLabel(event);
|
||||||
|
|
||||||
|
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
|
||||||
|
if (this.activePointers.has(event.pointerId)) {
|
||||||
|
this.activePointers.set(event.pointerId, point);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.context.getDragPointerId() !== event.pointerId || !this.context.getDragMode()) {
|
if (this.context.getDragPointerId() !== event.pointerId || !this.context.getDragMode()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
|
if (this.context.getDragMode() === "pinch") {
|
||||||
if (this.context.getDragMode() === "orbit") {
|
const gesture = this.getPinchGesture();
|
||||||
const delta = point.clone().sub(this.context.dragLast);
|
if (!gesture || !this.pinchStartDistance || !this.pinchStartZoom || !this.pinchLastCenter) {
|
||||||
this.context.dragLast.copy(point);
|
return;
|
||||||
this.context.applyOrbitDelta(delta);
|
}
|
||||||
|
|
||||||
|
const zoomRatio = THREE.MathUtils.clamp(gesture.distance / this.pinchStartDistance, 0.25, 4);
|
||||||
|
this.context.setDesiredDistance(THREE.MathUtils.clamp(
|
||||||
|
this.pinchStartZoom / zoomRatio,
|
||||||
|
MIN_CAMERA_DISTANCE,
|
||||||
|
MAX_CAMERA_DISTANCE,
|
||||||
|
));
|
||||||
|
const centerDelta = gesture.center.clone().sub(this.pinchLastCenter);
|
||||||
|
this.pinchLastCenter = gesture.center;
|
||||||
|
this.context.applyPanDelta(centerDelta);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const delta = point.clone().sub(this.context.dragLast);
|
||||||
const dragDistance = point.distanceTo(this.context.dragStart);
|
const dragDistance = point.distanceTo(this.context.dragStart);
|
||||||
if (!this.context.getMarqueeActive() && dragDistance > 8) {
|
if (dragDistance > 6) {
|
||||||
this.context.setMarqueeActive(true);
|
|
||||||
this.context.setSuppressClickSelection(true);
|
this.context.setSuppressClickSelection(true);
|
||||||
this.context.hudState.marquee.visible = true;
|
|
||||||
this.context.marqueeEl.style.display = "block";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.context.getMarqueeActive()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.context.dragLast.copy(point);
|
this.context.dragLast.copy(point);
|
||||||
updateMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl, this.context.dragStart, this.context.dragLast);
|
this.context.applyPanDelta(delta);
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly onPointerUp = (event: PointerEvent) => {
|
readonly onPointerUp = (event: PointerEvent) => {
|
||||||
if (this.context.getDragPointerId() !== event.pointerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) {
|
if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) {
|
||||||
this.context.renderer.domElement.releasePointerCapture(event.pointerId);
|
this.context.renderer.domElement.releasePointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
|
this.activePointers.delete(event.pointerId);
|
||||||
|
|
||||||
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
|
if (this.activePointers.size >= 2) {
|
||||||
this.completeMarqueeSelection();
|
const gesture = this.getPinchGesture();
|
||||||
hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl);
|
if (gesture) {
|
||||||
|
this.context.setDragMode("pinch");
|
||||||
|
this.pinchStartDistance = gesture.distance;
|
||||||
|
this.pinchStartZoom = this.context.getDesiredDistance();
|
||||||
|
this.pinchLastCenter = gesture.center;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.context.setDragMode(undefined);
|
const remainingPointer = this.activePointers.entries().next();
|
||||||
this.context.setDragPointerId(undefined);
|
if (!remainingPointer.done) {
|
||||||
this.context.setMarqueeActive(false);
|
const [pointerId, point] = remainingPointer.value;
|
||||||
|
this.context.setDragMode("pan");
|
||||||
|
this.context.setDragPointerId(pointerId);
|
||||||
|
this.context.dragStart.copy(point);
|
||||||
|
this.context.dragLast.copy(point);
|
||||||
|
} else {
|
||||||
|
this.context.setDragMode(undefined);
|
||||||
|
this.context.setDragPointerId(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pinchStartDistance = undefined;
|
||||||
|
this.pinchStartZoom = undefined;
|
||||||
|
this.pinchLastCenter = undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly onClick = (event: MouseEvent) => {
|
readonly onClick = (event: MouseEvent) => {
|
||||||
@@ -225,8 +263,7 @@ export class ViewerInteractionController {
|
|||||||
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
|
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
|
||||||
this.context.syncFollowStateFromSelection();
|
this.context.syncFollowStateFromSelection();
|
||||||
this.context.focusOnSelection({ kind: "ship", id: shipId });
|
this.context.focusOnSelection({ kind: "ship", id: shipId });
|
||||||
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;
|
||||||
@@ -268,8 +305,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;
|
||||||
@@ -288,6 +324,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(),
|
||||||
@@ -371,17 +414,17 @@ export class ViewerInteractionController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -248,6 +248,54 @@ function renderSystemOwnership(world: WorldState, systemId: string): string {
|
|||||||
return lines.join("<br>");
|
return lines.join("<br>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function titleCaseLabel(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||||
|
.replace(/[-_]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.replace(/\b\w/g, (part) => part.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSystemSubtitle(world: WorldState, systemId: string): string {
|
||||||
|
const control = world.geopolitics?.territory.controlStates.find((state) => state.systemId === systemId);
|
||||||
|
const zone = world.geopolitics?.territory.zones.find((entry) => entry.systemId === systemId);
|
||||||
|
const profile = world.geopolitics?.territory.strategicProfiles.find((entry) => entry.systemId === systemId);
|
||||||
|
const region = world.geopolitics?.economyRegions.regions.find((entry) => entry.systemIds.includes(systemId));
|
||||||
|
|
||||||
|
if (region?.label) {
|
||||||
|
return region.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zone?.reason) {
|
||||||
|
return titleCaseLabel(zone.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zone?.kind) {
|
||||||
|
return titleCaseLabel(zone.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile?.zoneKind) {
|
||||||
|
return titleCaseLabel(profile.zoneKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control?.isContested) {
|
||||||
|
return "Contested";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control) {
|
||||||
|
return titleCaseLabel(control.controlKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = [...world.claims.values()].filter((claim) =>
|
||||||
|
claim.systemId === systemId && claim.state !== "destroyed");
|
||||||
|
return claims.length === 0 ? "Deep Space" : `Claims ${claims.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDetailPanelState(params: DetailPanelParams) {
|
export function buildDetailPanelState(params: DetailPanelParams) {
|
||||||
const {
|
const {
|
||||||
world,
|
world,
|
||||||
@@ -525,9 +573,7 @@ export function buildSystemPanelState(params: SystemPanelParams) {
|
|||||||
return {
|
return {
|
||||||
hidden: false,
|
hidden: false,
|
||||||
title: activeSystem.label,
|
title: activeSystem.label,
|
||||||
bodyHtml: `
|
bodyHtml: describeSystemSubtitle(world, activeSystem.id),
|
||||||
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
|
|
||||||
`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import {
|
import {
|
||||||
|
describeCompactStatsLine,
|
||||||
describeNetworkPanel,
|
describeNetworkPanel,
|
||||||
describePerformancePanel,
|
describePerformancePanel,
|
||||||
recordPerformanceStats,
|
recordPerformanceStats,
|
||||||
@@ -40,6 +41,56 @@ export interface ViewerPresentationContext {
|
|||||||
export class ViewerPresentationController {
|
export class ViewerPresentationController {
|
||||||
constructor(private readonly context: ViewerPresentationContext) { }
|
constructor(private readonly context: ViewerPresentationContext) { }
|
||||||
|
|
||||||
|
private refreshStatsOverlayLines(gameBodyText?: string) {
|
||||||
|
const mode = this.context.hudState.statsOverlay.mode;
|
||||||
|
if (mode === "hidden") {
|
||||||
|
this.context.hudState.statsOverlay.lines = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compactLine = describeCompactStatsLine(this.context.networkStats, this.context.performanceStats);
|
||||||
|
const gameLines = (gameBodyText ?? this.context.hudState.gamePanel.bodyText)
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const networkLines = this.context.hudState.networkPanel.bodyText
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const performanceLines = this.context.hudState.performancePanel.bodyText
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case "compact":
|
||||||
|
this.context.hudState.statsOverlay.lines = [compactLine];
|
||||||
|
return;
|
||||||
|
case "status":
|
||||||
|
this.context.hudState.statsOverlay.lines = [compactLine, ...gameLines];
|
||||||
|
return;
|
||||||
|
case "network":
|
||||||
|
this.context.hudState.statsOverlay.lines = [compactLine, ...networkLines];
|
||||||
|
return;
|
||||||
|
case "performance":
|
||||||
|
this.context.hudState.statsOverlay.lines = [compactLine, ...performanceLines];
|
||||||
|
return;
|
||||||
|
case "full":
|
||||||
|
this.context.hudState.statsOverlay.lines = [
|
||||||
|
compactLine,
|
||||||
|
"",
|
||||||
|
...gameLines,
|
||||||
|
"",
|
||||||
|
...networkLines,
|
||||||
|
"",
|
||||||
|
...performanceLines,
|
||||||
|
];
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
this.context.hudState.statsOverlay.lines = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
initializeAmbience() {
|
initializeAmbience() {
|
||||||
this.context.ambienceGroup.renderOrder = -10;
|
this.context.ambienceGroup.renderOrder = -10;
|
||||||
this.context.ambienceGroup.add(createBackdropStars(document));
|
this.context.ambienceGroup.add(createBackdropStars(document));
|
||||||
@@ -67,6 +118,7 @@ export class ViewerPresentationController {
|
|||||||
updateNetworkPanel() {
|
updateNetworkPanel() {
|
||||||
this.context.hudState.networkPanel.bodyText = describeNetworkPanel(this.context.networkStats);
|
this.context.hudState.networkPanel.bodyText = describeNetworkPanel(this.context.networkStats);
|
||||||
this.context.hudState.networkPanel.summary = summarizeNetworkStats(this.context.networkStats);
|
this.context.hudState.networkPanel.summary = summarizeNetworkStats(this.context.networkStats);
|
||||||
|
this.refreshStatsOverlayLines();
|
||||||
}
|
}
|
||||||
|
|
||||||
recordPerformanceStats(frameMs: number) {
|
recordPerformanceStats(frameMs: number) {
|
||||||
@@ -79,6 +131,7 @@ export class ViewerPresentationController {
|
|||||||
this.context.hudState.performancePanel.bodyText = bodyText;
|
this.context.hudState.performancePanel.bodyText = bodyText;
|
||||||
}
|
}
|
||||||
this.context.hudState.performancePanel.summary = summarizePerformanceStats(this.context.performanceStats);
|
this.context.hudState.performancePanel.summary = summarizePerformanceStats(this.context.performanceStats);
|
||||||
|
this.refreshStatsOverlayLines();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateShipPresentation() {
|
updateShipPresentation() {
|
||||||
@@ -116,6 +169,11 @@ export class ViewerPresentationController {
|
|||||||
});
|
});
|
||||||
this.context.hudState.gamePanel.bodyText = state.bodyText;
|
this.context.hudState.gamePanel.bodyText = state.bodyText;
|
||||||
this.context.hudState.gamePanel.summary = state.summaryText;
|
this.context.hudState.gamePanel.summary = state.summaryText;
|
||||||
|
this.refreshStatsOverlayLines(state.bodyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshStatsOverlay() {
|
||||||
|
this.refreshStatsOverlayLines();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSystemPanel() {
|
updateSystemPanel() {
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export class ViewerSceneDataController {
|
|||||||
createWorldPresentationContext(overrides: {
|
createWorldPresentationContext(overrides: {
|
||||||
world: any;
|
world: any;
|
||||||
activeSystemId?: string;
|
activeSystemId?: string;
|
||||||
|
cameraMode: any;
|
||||||
povLevel: any;
|
povLevel: any;
|
||||||
orbitYaw: number;
|
orbitYaw: number;
|
||||||
systemCamera: THREE.PerspectiveCamera;
|
systemCamera: THREE.PerspectiveCamera;
|
||||||
@@ -214,6 +215,7 @@ export class ViewerSceneDataController {
|
|||||||
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
|
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
|
||||||
worldSeed: this.context.getWorldSeed(),
|
worldSeed: this.context.getWorldSeed(),
|
||||||
activeSystemId: overrides.activeSystemId,
|
activeSystemId: overrides.activeSystemId,
|
||||||
|
cameraMode: overrides.cameraMode,
|
||||||
povLevel: overrides.povLevel,
|
povLevel: overrides.povLevel,
|
||||||
orbitYaw: overrides.orbitYaw,
|
orbitYaw: overrides.orbitYaw,
|
||||||
camera: overrides.systemCamera,
|
camera: overrides.systemCamera,
|
||||||
|
|||||||
@@ -571,19 +571,55 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n
|
|||||||
export function createShipTacticalIcon(documentRef: Document, color: string, size: number): SceneNode {
|
export function createShipTacticalIcon(documentRef: Document, color: string, size: number): SceneNode {
|
||||||
const canvas = documentRef.createElement("canvas");
|
const canvas = documentRef.createElement("canvas");
|
||||||
canvas.width = 128;
|
canvas.width = 128;
|
||||||
canvas.height = 96;
|
canvas.height = 192;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("Unable to create ship tactical icon");
|
throw new Error("Unable to create ship tactical icon");
|
||||||
}
|
}
|
||||||
|
|
||||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.lineCap = "round";
|
||||||
|
context.lineJoin = "round";
|
||||||
context.strokeStyle = color;
|
context.strokeStyle = color;
|
||||||
context.fillStyle = "rgba(7, 16, 30, 0.7)";
|
context.fillStyle = "rgba(7, 16, 30, 0.8)";
|
||||||
context.lineWidth = 5;
|
context.lineWidth = 6;
|
||||||
|
|
||||||
|
context.globalAlpha = 0.92;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(64, 182);
|
||||||
|
context.lineTo(64, 108);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(64, 70, 26, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.arc(34, 48, 18, 0, Math.PI * 2);
|
context.moveTo(64, 34);
|
||||||
|
context.lineTo(64, 49);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(64, 91);
|
||||||
|
context.lineTo(64, 106);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(28, 70);
|
||||||
|
context.lineTo(43, 70);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(85, 70);
|
||||||
|
context.lineTo(100, 70);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(44, 116);
|
||||||
|
context.lineTo(64, 136);
|
||||||
|
context.lineTo(84, 116);
|
||||||
context.stroke();
|
context.stroke();
|
||||||
|
|
||||||
const texture = new THREE.CanvasTexture(canvas);
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
@@ -593,9 +629,10 @@ export function createShipTacticalIcon(documentRef: Document, color: string, siz
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
depthTest: false,
|
depthTest: false,
|
||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
|
fog: false,
|
||||||
}));
|
}));
|
||||||
sprite.center.set(0.28, 0.5);
|
sprite.center.set(0.5, 0.08);
|
||||||
sprite.scale.set(size * 1.7, size * 1.275, 1);
|
sprite.scale.set(size * 1.2, size * 1.8, 1);
|
||||||
sprite.visible = false;
|
sprite.visible = false;
|
||||||
return createSceneNode(sprite);
|
return createSceneNode(sprite);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,14 +41,18 @@ export function describeNetworkPanel(networkStats: NetworkStats) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function summarizeNetworkStats(networkStats: NetworkStats): string {
|
export function summarizeNetworkStats(networkStats: NetworkStats): string {
|
||||||
|
const kbPerSecond = estimateDownlinkKbPerSecond(networkStats);
|
||||||
|
const direction = networkStats.streamConnected ? "live" : "offline";
|
||||||
|
return `${direction} | down ${kbPerSecond.toFixed(1)} KB/s | ${networkStats.deltasReceived} d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateDownlinkKbPerSecond(networkStats: NetworkStats): number {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const recentBytes = networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0);
|
const recentBytes = networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0);
|
||||||
const recentWindowSeconds = networkStats.throughputSamples.length > 1
|
const recentWindowSeconds = networkStats.throughputSamples.length > 1
|
||||||
? Math.max((now - networkStats.throughputSamples[0].atMs) / 1000, 1)
|
? Math.max((now - networkStats.throughputSamples[0].atMs) / 1000, 1)
|
||||||
: 1;
|
: 1;
|
||||||
const kbPerSecond = recentBytes / 1024 / recentWindowSeconds;
|
return recentBytes / 1024 / recentWindowSeconds;
|
||||||
const direction = networkStats.streamConnected ? "live" : "offline";
|
|
||||||
return `${direction} | down ${kbPerSecond.toFixed(1)} KB/s | ${networkStats.deltasReceived} d`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) {
|
export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) {
|
||||||
@@ -116,12 +120,23 @@ export function describePerformancePanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function summarizePerformanceStats(performanceStats: PerformanceStats): string {
|
export function summarizePerformanceStats(performanceStats: PerformanceStats): string {
|
||||||
|
const fps = estimateFps(performanceStats);
|
||||||
|
return `FPS ${fps.toFixed(1)} | ${performanceStats.lastFrameMs.toFixed(1)} ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateFps(performanceStats: PerformanceStats): number {
|
||||||
const samples = performanceStats.frameSamples;
|
const samples = performanceStats.frameSamples;
|
||||||
const elapsedWindowSeconds = samples.length > 1
|
const elapsedWindowSeconds = samples.length > 1
|
||||||
? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25)
|
? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25)
|
||||||
: 1;
|
: 1;
|
||||||
const fps = samples.length > 1
|
return samples.length > 1
|
||||||
? (samples.length - 1) / elapsedWindowSeconds
|
? (samples.length - 1) / elapsedWindowSeconds
|
||||||
: 0;
|
: 0;
|
||||||
return `FPS ${fps.toFixed(1)} | ${performanceStats.lastFrameMs.toFixed(1)} ms`;
|
}
|
||||||
|
|
||||||
|
export function describeCompactStatsLine(networkStats: NetworkStats, performanceStats: PerformanceStats): string {
|
||||||
|
const onlineLabel = networkStats.streamConnected ? "Online" : "Offline";
|
||||||
|
const fps = estimateFps(performanceStats);
|
||||||
|
const down = estimateDownlinkKbPerSecond(networkStats);
|
||||||
|
return `${onlineLabel} | FPS ${fps.toFixed(1)} | Down ${down.toFixed(1)} KB/s`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type {
|
|||||||
|
|
||||||
export type PovLevel = "local" | "system" | "galaxy";
|
export type PovLevel = "local" | "system" | "galaxy";
|
||||||
export type SelectionGroup = "ships" | "structures" | "celestials";
|
export type SelectionGroup = "ships" | "structures" | "celestials";
|
||||||
export type DragMode = "orbit" | "marquee";
|
export type DragMode = "pan" | "pinch";
|
||||||
export type CameraMode = "tactical" | "follow";
|
export type CameraMode = "tactical" | "follow";
|
||||||
|
|
||||||
export type Selectable =
|
export type Selectable =
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ export class ViewerWorldLifecycle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applySnapshot(snapshot: WorldSnapshot) {
|
applySnapshot(snapshot: WorldSnapshot) {
|
||||||
usePlayerFactionStore(viewerPinia).setPlayerFaction(null);
|
|
||||||
this.context.setWorldTimeSyncMs(performance.now());
|
this.context.setWorldTimeSyncMs(performance.now());
|
||||||
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
|
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
|
||||||
if (signature !== this.context.getWorldSignature()) {
|
if (signature !== this.context.getWorldSignature()) {
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import {
|
|||||||
updateSystemStarPresentation,
|
updateSystemStarPresentation,
|
||||||
getAnimatedShipLocalPosition,
|
getAnimatedShipLocalPosition,
|
||||||
iconWorldScale,
|
iconWorldScale,
|
||||||
MIN_ICON_PIXELS,
|
|
||||||
MAX_ICON_PIXELS,
|
|
||||||
} from "./viewerPresentation";
|
} from "./viewerPresentation";
|
||||||
import { rawObject } from "./viewerScenePrimitives";
|
import { rawObject } from "./viewerScenePrimitives";
|
||||||
import type {
|
import type {
|
||||||
@@ -42,6 +40,13 @@ import type {
|
|||||||
|
|
||||||
type SummaryIconKind = "ship" | "station" | "structure";
|
type SummaryIconKind = "ship" | "station" | "structure";
|
||||||
|
|
||||||
|
const SHIP_BILLBOARD_HIDE_DISTANCE = 0.003;
|
||||||
|
const SHIP_BILLBOARD_FULL_DISTANCE = 0.018;
|
||||||
|
const SHIP_BILLBOARD_MIN_PIXELS = 34;
|
||||||
|
const SHIP_BILLBOARD_MAX_PIXELS = 82;
|
||||||
|
const STATION_ICON_MIN_PIXELS = 28;
|
||||||
|
const STATION_ICON_MAX_PIXELS = 72;
|
||||||
|
|
||||||
export interface WorldOrbitalContext {
|
export interface WorldOrbitalContext {
|
||||||
world?: WorldState;
|
world?: WorldState;
|
||||||
worldTimeSyncMs: number;
|
worldTimeSyncMs: number;
|
||||||
@@ -53,6 +58,7 @@ export interface WorldOrbitalContext {
|
|||||||
|
|
||||||
export interface WorldPresentationContext extends WorldOrbitalContext {
|
export interface WorldPresentationContext extends WorldOrbitalContext {
|
||||||
activeSystemId?: string;
|
activeSystemId?: string;
|
||||||
|
cameraMode: CameraMode;
|
||||||
povLevel: PovLevel;
|
povLevel: PovLevel;
|
||||||
orbitYaw: number;
|
orbitYaw: number;
|
||||||
camera: THREE.PerspectiveCamera;
|
camera: THREE.PerspectiveCamera;
|
||||||
@@ -95,14 +101,22 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
|
|||||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||||
const shipVisible = isShipVisible(renderMode, context.activeSystemId, ship);
|
const shipVisible = isShipVisible(renderMode, context.activeSystemId, ship);
|
||||||
const distToShip = context.camera.position.distanceTo(displayPosition);
|
const distToShip = context.camera.position.distanceTo(displayPosition);
|
||||||
const useTacticalIcon = renderMode !== "local" || distToShip > 0.012;
|
const billboardOpacity = context.cameraMode === "tactical"
|
||||||
|
? 1
|
||||||
|
: THREE.MathUtils.clamp(
|
||||||
|
(distToShip - SHIP_BILLBOARD_HIDE_DISTANCE) / (SHIP_BILLBOARD_FULL_DISTANCE - SHIP_BILLBOARD_HIDE_DISTANCE),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const useTacticalIcon = context.cameraMode === "tactical" || billboardOpacity > 0.01;
|
||||||
const iconScale = THREE.MathUtils.clamp(
|
const iconScale = THREE.MathUtils.clamp(
|
||||||
visual.iconBaseScale,
|
visual.iconBaseScale,
|
||||||
iconWorldScale(distToShip, context.camera, MIN_ICON_PIXELS),
|
iconWorldScale(distToShip, context.camera, SHIP_BILLBOARD_MIN_PIXELS),
|
||||||
iconWorldScale(distToShip, context.camera, MAX_ICON_PIXELS + 10),
|
iconWorldScale(distToShip, context.camera, SHIP_BILLBOARD_MAX_PIXELS),
|
||||||
);
|
);
|
||||||
visual.icon.setScaleScalar(iconScale);
|
visual.icon.setScaleScalar(iconScale);
|
||||||
visual.mesh.setVisible(shipVisible && !useTacticalIcon);
|
visual.icon.setOpacity(shipVisible ? billboardOpacity : 0);
|
||||||
|
visual.mesh.setVisible(shipVisible && context.cameraMode !== "tactical" && billboardOpacity < 0.98);
|
||||||
visual.icon.setVisible(shipVisible && useTacticalIcon);
|
visual.icon.setVisible(shipVisible && useTacticalIcon);
|
||||||
const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw);
|
const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw);
|
||||||
if (desiredHeading.lengthSq() > 0.01) {
|
if (desiredHeading.lengthSq() > 0.01) {
|
||||||
@@ -135,9 +149,19 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
|
|||||||
|
|
||||||
for (const visual of context.stationVisuals.values()) {
|
for (const visual of context.stationVisuals.values()) {
|
||||||
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds);
|
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds);
|
||||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
|
const displayPosition = context.toDisplayLocalPosition(animatedLocalPosition);
|
||||||
|
visual.mesh.setPosition(displayPosition);
|
||||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||||
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
|
const stationVisible = visual.systemId === context.activeSystemId;
|
||||||
|
const distToStation = context.camera.position.distanceTo(displayPosition);
|
||||||
|
const stationIconScale = THREE.MathUtils.clamp(
|
||||||
|
130,
|
||||||
|
iconWorldScale(distToStation, context.camera, STATION_ICON_MIN_PIXELS),
|
||||||
|
iconWorldScale(distToStation, context.camera, STATION_ICON_MAX_PIXELS),
|
||||||
|
);
|
||||||
|
visual.icon.setScaleScalar(stationIconScale);
|
||||||
|
visual.icon.setVisible(stationVisible);
|
||||||
|
visual.mesh.setVisible(stationVisible && renderMode === "local" && context.cameraMode !== "tactical");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const visual of context.claimVisuals.values()) {
|
for (const visual of context.claimVisuals.values()) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
|||||||
port: 5174,
|
port: 5174,
|
||||||
allowedHosts: ["sobina.local"],
|
allowedHosts: ["sobina.local"],
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://127.0.0.1:5079",
|
"/api": "http://127.0.0.1:5080",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
41
scripts/start-postgres.sh
Executable file
41
scripts/start-postgres.sh
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
container_name="${SPACEGAME_POSTGRES_CONTAINER:-space-game-postgres}"
|
||||||
|
image_name="${SPACEGAME_POSTGRES_IMAGE:-postgres:16}"
|
||||||
|
host_port="${SPACEGAME_POSTGRES_PORT:-5432}"
|
||||||
|
database_name="${SPACEGAME_POSTGRES_DB:-spacegame}"
|
||||||
|
database_user="${SPACEGAME_POSTGRES_USER:-spacegame}"
|
||||||
|
database_password="${SPACEGAME_POSTGRES_PASSWORD:-spacegame}"
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "docker is required but was not found in PATH." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
existing_container_id="$(docker ps -aq -f "name=^${container_name}$")"
|
||||||
|
|
||||||
|
if [[ -z "${existing_container_id}" ]]; then
|
||||||
|
echo "Creating Postgres container '${container_name}'..."
|
||||||
|
docker run -d \
|
||||||
|
--name "${container_name}" \
|
||||||
|
-e POSTGRES_DB="${database_name}" \
|
||||||
|
-e POSTGRES_USER="${database_user}" \
|
||||||
|
-e POSTGRES_PASSWORD="${database_password}" \
|
||||||
|
-p "${host_port}:5432" \
|
||||||
|
"${image_name}" >/dev/null
|
||||||
|
echo "Postgres container created and started."
|
||||||
|
else
|
||||||
|
running_container_id="$(docker ps -q -f "name=^${container_name}$")"
|
||||||
|
if [[ -n "${running_container_id}" ]]; then
|
||||||
|
echo "Postgres container '${container_name}' is already running."
|
||||||
|
else
|
||||||
|
echo "Starting Postgres container '${container_name}'..."
|
||||||
|
docker start "${container_name}" >/dev/null
|
||||||
|
echo "Postgres container started."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Connection string:"
|
||||||
|
echo "Host=127.0.0.1;Port=${host_port};Database=${database_name};Username=${database_user};Password=${database_password}"
|
||||||
Reference in New Issue
Block a user