Add player onboarding and tactical viewer updates

This commit is contained in:
2026-04-06 17:12:44 -04:00
parent 706e1cda8f
commit 63a9f808bb
52 changed files with 2699 additions and 577 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
using FastEndpoints;
namespace SpaceGame.Api.PlayerFaction.Api;
public sealed class CompletePlayerOnboardingHandler(WorldService worldService) : Endpoint<CompletePlayerOnboardingRequest, PlayerFactionSnapshot>
{
public override void Configure()
{
Post("/api/player-faction/onboarding");
}
public override async Task HandleAsync(CompletePlayerOnboardingRequest request, CancellationToken cancellationToken)
{
try
{
var snapshot = worldService.CompletePlayerOnboarding(request);
if (snapshot is null)
{
await SendNotFoundAsync(cancellationToken);
return;
}
await SendOkAsync(snapshot, cancellationToken);
}
catch (InvalidOperationException ex)
{
AddError(ex.Message);
await SendErrorsAsync(cancellation: cancellationToken);
}
}
}

View File

@@ -0,0 +1,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);

View File

@@ -249,7 +249,10 @@ public sealed record PlayerAlertSnapshot(
public sealed record PlayerFactionSnapshot( public sealed record PlayerFactionSnapshot(
string Id, string Id,
string Label, string Label,
string? PersonaName,
string? RaceId,
string SovereignFactionId, string SovereignFactionId,
bool RequiresOnboarding,
string Status, string Status,
DateTimeOffset CreatedAtUtc, DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc, DateTimeOffset UpdatedAtUtc,

View File

@@ -1,5 +1,9 @@
namespace SpaceGame.Api.PlayerFaction.Contracts; namespace SpaceGame.Api.PlayerFaction.Contracts;
public sealed record CompletePlayerOnboardingRequest(
string Name,
string RaceId);
public sealed record PlayerOrganizationCommandRequest( public sealed record PlayerOrganizationCommandRequest(
string Kind, string Kind,
string Label, string Label,

View File

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

View File

@@ -12,7 +12,10 @@ public sealed class PlayerFactionProjectionService
return new PlayerFactionSnapshot( return new PlayerFactionSnapshot(
player.Id, player.Id,
player.Label, player.Label,
player.PersonaName,
player.RaceId,
player.SovereignFactionId, player.SovereignFactionId,
player.RequiresOnboarding,
player.Status, player.Status,
player.CreatedAtUtc, player.CreatedAtUtc,
player.UpdatedAtUtc, player.UpdatedAtUtc,

View File

@@ -20,14 +20,12 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId) internal PlayerFactionRuntime EnsureDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
{ {
var sovereignFaction = world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).FirstOrDefault()
?? throw new InvalidOperationException("Cannot create a player faction domain without any factions in the world.");
var player = playerStateStore.GetOrAddPlayerFaction(playerId, () => new PlayerFactionRuntime var player = playerStateStore.GetOrAddPlayerFaction(playerId, () => new PlayerFactionRuntime
{ {
Id = PlayerFactionDomainId, Id = PlayerFactionDomainId,
Label = $"{sovereignFaction.Label} Command", Label = "Pending Pilot",
SovereignFactionId = sovereignFaction.Id, SovereignFactionId = string.Empty,
RequiresOnboarding = true,
CreatedAtUtc = world.GeneratedAtUtc, CreatedAtUtc = world.GeneratedAtUtc,
UpdatedAtUtc = world.GeneratedAtUtc, UpdatedAtUtc = world.GeneratedAtUtc,
}); });
@@ -37,6 +35,58 @@ internal sealed class PlayerFactionService
return player; return player;
} }
internal PlayerFactionRuntime CompleteOnboarding(
SimulationWorld world,
IPlayerStateStore playerStateStore,
string playerId,
CompletePlayerOnboardingRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
if (!player.RequiresOnboarding)
{
throw new InvalidOperationException("Player onboarding has already been completed.");
}
var personaName = request.Name.Trim();
if (personaName.Length < 2)
{
throw new InvalidOperationException("Player name must contain at least 2 characters.");
}
if (personaName.Length > 48)
{
throw new InvalidOperationException("Player name must contain at most 48 characters.");
}
var ownedFactionId = BuildOwnedFactionId(playerId);
if (world.Factions.Any(faction => string.Equals(faction.Id, ownedFactionId, StringComparison.Ordinal)))
{
throw new InvalidOperationException($"Player faction '{ownedFactionId}' already exists in the current world.");
}
player.Label = personaName;
player.PersonaName = personaName;
player.RaceId = request.RaceId.Trim();
player.SovereignFactionId = ownedFactionId;
player.RequiresOnboarding = false;
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
return player;
}
internal PlayerFactionRuntime EnsureInitializedDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
{
var player = EnsureDomain(world, playerStateStore, playerId);
if (player.RequiresOnboarding || string.IsNullOrWhiteSpace(player.SovereignFactionId))
{
throw new InvalidOperationException("Player onboarding must be completed before issuing gameplay commands.");
}
return player;
}
internal static string BuildOwnedFactionId(string playerId) =>
$"player-{playerId.Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant()}";
internal void Update(SimulationWorld world, IPlayerStateStore playerStateStore, float _deltaSeconds, ICollection<SimulationEventRecord> events) internal void Update(SimulationWorld world, IPlayerStateStore playerStateStore, float _deltaSeconds, ICollection<SimulationEventRecord> events)
{ {
if (playerStateStore.GetPlayerFactions().Count == 0) if (playerStateStore.GetPlayerFactions().Count == 0)
@@ -63,7 +113,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerOrganizationCommandRequest request) internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerOrganizationCommandRequest request)
{ {
var player = EnsureDomain(world, playerStateStore, playerId); var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player)); var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player));
var nowUtc = DateTimeOffset.UtcNow; var nowUtc = DateTimeOffset.UtcNow;
@@ -180,7 +230,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId) internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId)
{ {
var player = EnsureDomain(world, playerStateStore, playerId); var player = EnsureInitializedDomain(world, playerStateStore, playerId);
RemoveOrganization(player, organizationId); RemoveOrganization(player, organizationId);
player.Assignments.RemoveAll(assignment => player.Assignments.RemoveAll(assignment =>
assignment.FleetId == organizationId || assignment.FleetId == organizationId ||
@@ -198,7 +248,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId, PlayerOrganizationMembershipCommandRequest request) internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId, PlayerOrganizationMembershipCommandRequest request)
{ {
var player = EnsureDomain(world, playerStateStore, playerId); var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var kind = ResolveOrganizationKind(player, organizationId); var kind = ResolveOrganizationKind(player, organizationId);
switch (kind) switch (kind)
{ {
@@ -249,7 +299,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? directiveId, PlayerDirectiveCommandRequest request) internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? directiveId, PlayerDirectiveCommandRequest request)
{ {
var player = EnsureDomain(world, playerStateStore, playerId); var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var directive = directiveId is null var directive = directiveId is null
? null ? null
: player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal)); : player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal));
@@ -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)

View File

@@ -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);
} }

View File

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

View File

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

View File

@@ -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);
} }

View File

@@ -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]) => {
if (!isAuthenticated) {
playerContextReady.value = false;
playerFactionStore.setPlayerFaction(null);
viewer?.dispose();
viewer = undefined;
return; return;
} }
await refreshPlayerContext();
await startViewerIfAuthenticated();
},
{ immediate: true },
);
watch(
() => playerFaction.value?.requiresOnboarding ?? false,
async (requiresOnboarding) => {
if (requiresOnboarding) {
viewer?.dispose(); viewer?.dispose();
viewer = undefined; viewer = undefined;
}); return;
}
await startViewerIfAuthenticated();
},
);
function onHistoryWindowResize(id: string, width: number, height: number) { function onHistoryWindowResize(id: string, width: number, height: number) {
const windowState = hudState.historyWindows.find((entry) => entry.id === id); const windowState = hudState.historyWindows.find((entry) => entry.id === id);
@@ -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">
<section class="viewer-left-sidebar pointer-events-auto">
<div class="viewer-left-sidebar__tabs">
<button
type="button"
class="viewer-left-sidebar__tab"
:class="leftSidebarTab === 'player' ? 'viewer-left-sidebar__tab--active' : ''"
@click="leftSidebarTab = 'player'"
>
Player Informations
</button>
<button
type="button"
class="viewer-left-sidebar__tab"
:class="leftSidebarTab === 'entities' ? 'viewer-left-sidebar__tab--active' : ''"
@click="leftSidebarTab = 'entities'"
>
Entities
</button>
</div>
<div class="viewer-left-sidebar__body">
<div
v-if="leftSidebarTab === 'player'"
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--player"
>
<AuthSessionPanel /> <AuthSessionPanel />
<CollapsibleHudPanel </div>
v-model:collapsed="hudState.gamePanel.collapsed"
class-name="topbar"
panel-name="game"
title="Game"
:summary="hudState.gamePanel.summary"
:body-text="hudState.gamePanel.bodyText"
/>
<CollapsibleHudPanel
v-model:collapsed="hudState.networkPanel.collapsed"
class-name="network-panel"
panel-name="network"
title="Network"
:summary="hudState.networkPanel.summary"
:body-text="hudState.networkPanel.bodyText"
/>
<CollapsibleHudPanel
v-model:collapsed="hudState.performancePanel.collapsed"
class-name="performance-panel"
panel-name="performance"
title="Performance"
:summary="hudState.performancePanel.summary"
:body-text="hudState.performancePanel.bodyText"
/>
<ViewerEntityBrowserPanel <ViewerEntityBrowserPanel
class="min-h-0 flex-1" v-else
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--entities"
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)" @focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
/> />
</div> </div>
</section>
</div>
<div
v-if="hudState.statsOverlay.lines.length > 0"
class="viewer-stats-overlay-dock"
>
<div
class="viewer-stats-overlay"
:class="hudState.statsOverlay.mode === 'compact' ? 'viewer-stats-overlay--compact' : ''"
>
<div
v-for="(line, index) in hudState.statsOverlay.lines"
:key="`${index}-${line}`"
class="viewer-stats-overlay__line"
:class="line === '' ? 'viewer-stats-overlay__line--spacer' : ''"
>
{{ line === "" ? "\u00A0" : line }}
</div>
</div>
</div>
<div
v-if="!hudState.systemPanel.hidden"
class="viewer-system-label-dock"
>
<div class="viewer-system-label">
<div class="viewer-system-label__title">
{{ hudState.systemPanel.title }}
</div>
<div class="viewer-system-label__subtitle">
{{ hudState.systemPanel.bodyHtml }}
</div>
</div>
</div>
<div class="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"

View File

@@ -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();

View File

@@ -2,12 +2,15 @@ import type { WorldDelta, WorldSnapshot } from "./contracts";
import type { TelemetrySnapshot } from "./contractsTelemetry"; import type { TelemetrySnapshot } from "./contractsTelemetry";
import type { BalanceSettings } from "./contractsBalance"; import type { BalanceSettings } from "./contractsBalance";
import type { PlayerFactionSnapshot } from "./contractsPlayerFaction"; import type { PlayerFactionSnapshot } from "./contractsPlayerFaction";
import type { AuthSessionResponse, ForgotPasswordResponse } from "./contractsAuth"; import type { RaceSnapshot } from "./contractsRaces";
import type { AuthSessionResponse, ForgotPasswordResponse, RegisterResponse } from "./contractsAuth";
import type { PlayerIdentitySummary } from "./contractsIdentity";
import type { ShipAutomationCatalogSnapshot } from "./contractsShipAutomation"; import type { ShipAutomationCatalogSnapshot } from "./contractsShipAutomation";
import type { FactionSnapshot } from "./contractsFactions"; import type { FactionSnapshot } from "./contractsFactions";
import type { ShipSnapshot } from "./contractsShips"; import type { ShipSnapshot } from "./contractsShips";
import type { StationSnapshot } from "./contractsInfrastructure"; import type { StationSnapshot } from "./contractsInfrastructure";
import { clearAuthSession, getAuthSession, setAuthSession } from "./authSession"; import { clearAuthSession, getAuthSession, setAuthSession } from "./authSession";
import { getEffectivePlayerIdentityId } from "./effectiveIdentitySession";
import type { import type {
PlayerAssetAssignmentCommandRequest, PlayerAssetAssignmentCommandRequest,
PlayerAutomationPolicyCommandRequest, PlayerAutomationPolicyCommandRequest,
@@ -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 });
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,8 +179,194 @@ const formBehaviorNotes = computed(() =>
getShipBehaviorNotes(behaviorForm.kind), getShipBehaviorNotes(behaviorForm.kind),
); );
watch(selectedShip, (ship) => { const shipStatusRows = computed(() => {
if (!selectedShip.value) {
return [];
}
return [
{ label: "State", value: titleCase(selectedShip.value.state) },
{ label: "Behavior", value: getShipBehaviorLabel(selectedShip.value.defaultBehavior.kind) },
{ label: "Control", value: titleCase(selectedShip.value.controlSourceKind) },
{ label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" },
{
label: "Plan",
value: selectedShip.value.activePlan
? `${selectedShip.value.activePlan.kind} · ${titleCase(selectedShip.value.activePlan.status)}`
: "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) { if (!ship) {
actionStatus.value = "";
actionError.value = "";
return; return;
} }
@@ -148,7 +378,9 @@ watch(selectedShip, (ship) => {
moveOrderSystemId.value = ship.systemId ?? ""; moveOrderSystemId.value = ship.systemId ?? "";
actionStatus.value = ""; actionStatus.value = "";
actionError.value = ""; actionError.value = "";
}, { immediate: true }); },
{ 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,13 +723,25 @@ 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>
<th scope="col">Status</th>
<th scope="col">Target</th>
<th scope="col">Detail</th>
<th v-if="canDirectControlSelectedShip" scope="col" class="entity-inspector-table__action-col">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="order in directOrderRows" :key="order.id">
<td>{{ order.label }}</td>
<td>{{ order.status }}</td>
<td>{{ order.target }}</td>
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
<td v-if="canDirectControlSelectedShip" class="entity-inspector-table__action-col">
<button <button
v-if="canDirectControlSelectedShip"
type="button" type="button"
class="entity-inspector-order-remove" class="entity-inspector-order-remove"
:disabled="actionBusy" :disabled="actionBusy"
@@ -487,38 +749,60 @@ async function clearOrders() {
> >
Remove Remove
</button> </button>
</td>
</tr>
</tbody>
</table>
</div> </div>
</li>
</ul>
<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>
<th scope="col">Activity</th>
<th scope="col">Status</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody>
<tr v-for="row in shipPlanRows" :key="row.id" :class="row.isSubTask ? 'entity-inspector-table__row--subtask' : ''">
<td>{{ row.scope }}</td>
<td :class="row.isSubTask ? 'entity-inspector-table__subtask' : ''">{{ row.activity }}</td>
<td>{{ row.status }}</td>
<td class="entity-inspector-table__detail">{{ row.detail }}</td>
</tr>
</tbody>
</table>
</div> </div>
<ul class="entity-inspector-subtasks">
<li v-for="subTask in step.subTasks" :key="subTask.id">
<span>{{ subTask.kind }} · {{ subTask.status }}</span>
<strong>{{ subTask.blockingReason ?? `${Math.round(subTask.progress * 100)}%` }}</strong>
</li>
</ul>
</li>
</ul>
<div v-else class="entity-inspector-empty">No active plan.</div> <div 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}
} }

View File

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

View File

@@ -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,

View File

@@ -5,6 +5,8 @@ import { ViewerPresentationController } from "./viewerPresentationController";
import { ViewerSceneDataController } from "./viewerSceneDataController"; import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerWorldLifecycle } from "./viewerWorldLifecycle"; import { ViewerWorldLifecycle } from "./viewerWorldLifecycle";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController"; import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import { applyPanFromScreenDelta } from "./viewerCamera";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE } from "./viewerConstants";
import { useViewerSceneStore } from "./ui/stores/viewerScene"; import { useViewerSceneStore } from "./ui/stores/viewerScene";
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu"; import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
import { viewerPinia } from "./ui/stores/pinia"; import { viewerPinia } from "./ui/stores/pinia";
@@ -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);

View File

@@ -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";
}
}

View File

@@ -8,6 +8,13 @@ export interface HudPanelState {
bodyText: string; bodyText: string;
} }
export type StatsOverlayMode = "hidden" | "compact" | "status" | "network" | "performance" | "full";
export interface StatsOverlayState {
mode: StatsOverlayMode;
lines: string[];
}
export interface HudHtmlPanelState { export interface HudHtmlPanelState {
hidden: boolean; hidden: boolean;
title: string; title: string;
@@ -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",

View File

@@ -1,20 +1,18 @@
import * as THREE from "three"; import * as THREE from "three";
import { import {
completeMarqueeSelection,
hideMarqueeBox,
pickSelectableHitAtClientPosition, pickSelectableHitAtClientPosition,
pickSelectableAtClientPosition, pickSelectableAtClientPosition,
updateHoverLabel, updateHoverLabel,
updateMarqueeBox,
} from "./viewerInteraction"; } from "./viewerInteraction";
import { import {
applyKeyboardControl, applyKeyboardControl,
cycleStatsOverlayMode,
toggleCameraMode, toggleCameraMode,
navigateFromWheel, navigateFromWheel,
} from "./viewerControls"; } from "./viewerControls";
import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT, NAV_DISTANCE_SHIP_HULL } from "./viewerConstants"; import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE_PLANET_ORBIT } from "./viewerConstants";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController"; import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import type { ViewerHudState } from "./viewerHudState"; import type { StatsOverlayMode, ViewerHudState } from "./viewerHudState";
import type { import type {
CameraMode, CameraMode,
DragMode, DragMode,
@@ -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();
if (!gesture || !this.pinchStartDistance || !this.pinchStartZoom || !this.pinchLastCenter) {
return;
}
const zoomRatio = THREE.MathUtils.clamp(gesture.distance / this.pinchStartDistance, 0.25, 4);
this.context.setDesiredDistance(THREE.MathUtils.clamp(
this.pinchStartZoom / zoomRatio,
MIN_CAMERA_DISTANCE,
MAX_CAMERA_DISTANCE,
));
const centerDelta = gesture.center.clone().sub(this.pinchLastCenter);
this.pinchLastCenter = gesture.center;
this.context.applyPanDelta(centerDelta);
return;
}
const delta = point.clone().sub(this.context.dragLast); const delta = point.clone().sub(this.context.dragLast);
this.context.dragLast.copy(point);
this.context.applyOrbitDelta(delta);
return;
}
const dragDistance = point.distanceTo(this.context.dragStart); const dragDistance = point.distanceTo(this.context.dragStart);
if (!this.context.getMarqueeActive() && dragDistance > 8) { if (dragDistance > 6) {
this.context.setMarqueeActive(true);
this.context.setSuppressClickSelection(true); this.context.setSuppressClickSelection(true);
this.context.hudState.marquee.visible = true;
this.context.marqueeEl.style.display = "block";
} }
if (!this.context.getMarqueeActive()) {
return;
}
this.context.dragLast.copy(point); this.context.dragLast.copy(point);
updateMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl, this.context.dragStart, this.context.dragLast); this.context.applyPanDelta(delta);
}; };
readonly onPointerUp = (event: PointerEvent) => { readonly onPointerUp = (event: PointerEvent) => {
if (this.context.getDragPointerId() !== event.pointerId) {
return;
}
if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) { if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) {
this.context.renderer.domElement.releasePointerCapture(event.pointerId); this.context.renderer.domElement.releasePointerCapture(event.pointerId);
} }
this.activePointers.delete(event.pointerId);
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) { if (this.activePointers.size >= 2) {
this.completeMarqueeSelection(); const gesture = this.getPinchGesture();
hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl); if (gesture) {
this.context.setDragMode("pinch");
this.pinchStartDistance = gesture.distance;
this.pinchStartZoom = this.context.getDesiredDistance();
this.pinchLastCenter = gesture.center;
}
return;
} }
const remainingPointer = this.activePointers.entries().next();
if (!remainingPointer.done) {
const [pointerId, point] = remainingPointer.value;
this.context.setDragMode("pan");
this.context.setDragPointerId(pointerId);
this.context.dragStart.copy(point);
this.context.dragLast.copy(point);
} else {
this.context.setDragMode(undefined); this.context.setDragMode(undefined);
this.context.setDragPointerId(undefined); this.context.setDragPointerId(undefined);
this.context.setMarqueeActive(false); }
this.pinchStartDistance = undefined;
this.pinchStartZoom = undefined;
this.pinchLastCenter = undefined;
}; };
readonly onClick = (event: MouseEvent) => { readonly onClick = (event: MouseEvent) => {
@@ -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) {

View File

@@ -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>
`,
}; };
} }

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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);
} }

View File

@@ -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`;
} }

View File

@@ -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 =

View File

@@ -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()) {

View File

@@ -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()) {

View File

@@ -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
View 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}"