From 63a9f808bbc696db19b96443ceba4c74824f03b3 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Mon, 6 Apr 2026 17:12:44 -0400 Subject: [PATCH] Add player onboarding and tactical viewer updates --- apps/backend/Auth/Api/GetRacesHandler.cs | 22 + apps/backend/Auth/Api/RegisterHandler.cs | 2 +- apps/backend/Auth/Contracts/AuthContracts.cs | 5 + apps/backend/Auth/Contracts/Races.cs | 7 + apps/backend/Auth/Simulation/AuthService.cs | 4 +- .../HttpContextPlayerIdentityResolver.cs | 17 + .../Auth/Simulation/IAuthRepository.cs | 1 + .../Simulation/IPlayerIdentityResolver.cs | 2 + .../Auth/Simulation/PostgresAuthRepository.cs | 17 + .../Api/CompletePlayerOnboardingHandler.cs | 31 + .../Api/GetPlayerIdentitiesHandler.cs | 74 ++ .../PlayerFaction/Contracts/PlayerFaction.cs | 3 + .../Contracts/PlayerFactionCommands.cs | 4 + .../Runtime/PlayerFactionRuntimeModels.cs | 3 + .../PlayerFactionProjectionService.cs | 3 + .../Simulation/PlayerFactionService.cs | 109 ++- .../Scenario/ScenarioContentBuilder.cs | 6 +- .../Scenario/StarterStationLayoutResolver.cs | 7 +- .../Universe/Scenario/WorldSeedingService.cs | 3 +- .../Universe/Simulation/WorldService.cs | 147 +++- apps/viewer/src/App.vue | 184 +++-- apps/viewer/src/ViewerAppController.ts | 5 +- apps/viewer/src/api.ts | 31 +- .../viewer/src/components/AuthLandingPage.vue | 7 +- .../src/components/AuthSessionPanel.vue | 116 +++- .../src/components/PlayerOnboardingPanel.vue | 115 +++ .../components/ViewerEntityBrowserPanel.vue | 653 +++++++++++++----- .../components/ViewerEntityInspectorPanel.vue | 546 ++++++++++++--- .../components/ViewerShipOrderContextMenu.vue | 2 +- apps/viewer/src/contractsAuth.ts | 6 + apps/viewer/src/contractsIdentity.ts | 9 + apps/viewer/src/contractsPlayerFaction.ts | 3 + apps/viewer/src/contractsRaces.ts | 6 + apps/viewer/src/effectiveIdentitySession.ts | 47 ++ .../runtime/rendering/ViewerRenderSurface.ts | 9 + apps/viewer/src/styles/viewer.css | 511 +++++++++++--- apps/viewer/src/ui/stores/authStore.ts | 28 + apps/viewer/src/viewerCamera.ts | 36 + apps/viewer/src/viewerControllerFactory.ts | 32 +- apps/viewer/src/viewerControls.ts | 18 + apps/viewer/src/viewerHudState.ts | 12 + .../viewer/src/viewerInteractionController.ts | 161 +++-- apps/viewer/src/viewerPanels.ts | 52 +- .../src/viewerPresentationController.ts | 58 ++ apps/viewer/src/viewerSceneDataController.ts | 2 + apps/viewer/src/viewerSceneFactory.ts | 49 +- apps/viewer/src/viewerTelemetry.ts | 25 +- apps/viewer/src/viewerTypes.ts | 2 +- apps/viewer/src/viewerWorldLifecycle.ts | 1 - apps/viewer/src/viewerWorldPresentation.ts | 40 +- apps/viewer/vite.config.ts | 2 +- scripts/start-postgres.sh | 41 ++ 52 files changed, 2699 insertions(+), 577 deletions(-) create mode 100644 apps/backend/Auth/Api/GetRacesHandler.cs create mode 100644 apps/backend/Auth/Contracts/Races.cs create mode 100644 apps/backend/PlayerFaction/Api/CompletePlayerOnboardingHandler.cs create mode 100644 apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs create mode 100644 apps/viewer/src/components/PlayerOnboardingPanel.vue create mode 100644 apps/viewer/src/contractsIdentity.ts create mode 100644 apps/viewer/src/contractsRaces.ts create mode 100644 apps/viewer/src/effectiveIdentitySession.ts create mode 100755 scripts/start-postgres.sh diff --git a/apps/backend/Auth/Api/GetRacesHandler.cs b/apps/backend/Auth/Api/GetRacesHandler.cs new file mode 100644 index 0000000..79a5de8 --- /dev/null +++ b/apps/backend/Auth/Api/GetRacesHandler.cs @@ -0,0 +1,22 @@ +using FastEndpoints; +using SpaceGame.Api.Universe.Bootstrap; + +namespace SpaceGame.Api.Auth.Api; + +public sealed class GetRacesHandler(IStaticDataProvider staticData) : EndpointWithoutRequest> +{ + 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); + } +} diff --git a/apps/backend/Auth/Api/RegisterHandler.cs b/apps/backend/Auth/Api/RegisterHandler.cs index e7cc370..7237908 100644 --- a/apps/backend/Auth/Api/RegisterHandler.cs +++ b/apps/backend/Auth/Api/RegisterHandler.cs @@ -2,7 +2,7 @@ using FastEndpoints; namespace SpaceGame.Api.Auth.Api; -public sealed class RegisterHandler(AuthService authService) : Endpoint +public sealed class RegisterHandler(AuthService authService) : Endpoint { public override void Configure() { diff --git a/apps/backend/Auth/Contracts/AuthContracts.cs b/apps/backend/Auth/Contracts/AuthContracts.cs index ed268f3..0150bbb 100644 --- a/apps/backend/Auth/Contracts/AuthContracts.cs +++ b/apps/backend/Auth/Contracts/AuthContracts.cs @@ -37,6 +37,11 @@ public sealed record AuthSessionResponse( string RefreshToken, DateTimeOffset RefreshTokenExpiresAtUtc); +public sealed record RegisterResponse( + Guid UserId, + string Email, + bool RequiresLogin); + public sealed record ForgotPasswordResponse( bool Accepted, string? ResetToken = null); diff --git a/apps/backend/Auth/Contracts/Races.cs b/apps/backend/Auth/Contracts/Races.cs new file mode 100644 index 0000000..8dd021a --- /dev/null +++ b/apps/backend/Auth/Contracts/Races.cs @@ -0,0 +1,7 @@ +namespace SpaceGame.Api.Auth.Contracts; + +public sealed record RaceSnapshot( + string Id, + string Name, + string Description, + string Icon); diff --git a/apps/backend/Auth/Simulation/AuthService.cs b/apps/backend/Auth/Simulation/AuthService.cs index 8f86cb0..4c25909 100644 --- a/apps/backend/Auth/Simulation/AuthService.cs +++ b/apps/backend/Auth/Simulation/AuthService.cs @@ -7,7 +7,7 @@ public sealed class AuthService( RefreshTokenFactory refreshTokenFactory, IPasswordResetDelivery passwordResetDelivery) { - public async Task RegisterAsync(RegisterRequest request, CancellationToken cancellationToken) + public async Task RegisterAsync(RegisterRequest request, CancellationToken cancellationToken) { var email = NormalizeEmail(request.Email); ValidatePassword(request.Password); @@ -18,7 +18,7 @@ public sealed class AuthService( } var user = await authRepository.CreateUserAsync(email, passwordHasher.HashPassword(request.Password), [], cancellationToken); - return await CreateSessionAsync(user, cancellationToken); + return new RegisterResponse(user.Id, user.Email, true); } public async Task LoginAsync(LoginRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/Auth/Simulation/HttpContextPlayerIdentityResolver.cs b/apps/backend/Auth/Simulation/HttpContextPlayerIdentityResolver.cs index 4488df2..29be518 100644 --- a/apps/backend/Auth/Simulation/HttpContextPlayerIdentityResolver.cs +++ b/apps/backend/Auth/Simulation/HttpContextPlayerIdentityResolver.cs @@ -5,6 +5,8 @@ namespace SpaceGame.Api.Auth.Simulation; public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpContextAccessor) : IPlayerIdentityResolver { + public const string EffectivePlayerHeaderName = "X-Act-As-Player-Id"; + public Guid? GetCurrentPlayerId() { var subject = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) @@ -15,6 +17,21 @@ public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpC public Guid GetRequiredPlayerId() => GetCurrentPlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required."); + public Guid? GetEffectivePlayerId() + { + var currentPlayerId = GetCurrentPlayerId(); + if (!CanAccessGm()) + { + return currentPlayerId; + } + + var requestedIdentity = httpContextAccessor.HttpContext?.Request.Headers[EffectivePlayerHeaderName].FirstOrDefault(); + return Guid.TryParse(requestedIdentity, out var effectivePlayerId) ? effectivePlayerId : currentPlayerId; + } + + public Guid GetRequiredEffectivePlayerId() => + GetEffectivePlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required."); + public bool CanAccessGm() { var user = httpContextAccessor.HttpContext?.User; diff --git a/apps/backend/Auth/Simulation/IAuthRepository.cs b/apps/backend/Auth/Simulation/IAuthRepository.cs index f493e74..1e01b77 100644 --- a/apps/backend/Auth/Simulation/IAuthRepository.cs +++ b/apps/backend/Auth/Simulation/IAuthRepository.cs @@ -4,6 +4,7 @@ public interface IAuthRepository { Task FindUserByEmailAsync(string email, CancellationToken cancellationToken); Task FindUserByIdAsync(Guid userId, CancellationToken cancellationToken); + Task> ListUsersAsync(CancellationToken cancellationToken); Task CreateUserAsync(string email, string passwordHash, IReadOnlyCollection roles, CancellationToken cancellationToken); Task UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection roles, CancellationToken cancellationToken); Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken); diff --git a/apps/backend/Auth/Simulation/IPlayerIdentityResolver.cs b/apps/backend/Auth/Simulation/IPlayerIdentityResolver.cs index 0a6e119..dde4e76 100644 --- a/apps/backend/Auth/Simulation/IPlayerIdentityResolver.cs +++ b/apps/backend/Auth/Simulation/IPlayerIdentityResolver.cs @@ -4,5 +4,7 @@ public interface IPlayerIdentityResolver { Guid? GetCurrentPlayerId(); Guid GetRequiredPlayerId(); + Guid? GetEffectivePlayerId(); + Guid GetRequiredEffectivePlayerId(); bool CanAccessGm(); } diff --git a/apps/backend/Auth/Simulation/PostgresAuthRepository.cs b/apps/backend/Auth/Simulation/PostgresAuthRepository.cs index ea9e3ce..d43d855 100644 --- a/apps/backend/Auth/Simulation/PostgresAuthRepository.cs +++ b/apps/backend/Auth/Simulation/PostgresAuthRepository.cs @@ -28,6 +28,23 @@ public sealed class PostgresAuthRepository(NpgsqlDataSource dataSource) : IAuthR return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null; } + public async Task> 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(); + while (await reader.ReadAsync(cancellationToken)) + { + users.Add(ReadUser(reader)); + } + + return users; + } + public async Task CreateUserAsync(string email, string passwordHash, IReadOnlyCollection roles, CancellationToken cancellationToken) { var userId = Guid.NewGuid(); diff --git a/apps/backend/PlayerFaction/Api/CompletePlayerOnboardingHandler.cs b/apps/backend/PlayerFaction/Api/CompletePlayerOnboardingHandler.cs new file mode 100644 index 0000000..9cdaf99 --- /dev/null +++ b/apps/backend/PlayerFaction/Api/CompletePlayerOnboardingHandler.cs @@ -0,0 +1,31 @@ +using FastEndpoints; + +namespace SpaceGame.Api.PlayerFaction.Api; + +public sealed class CompletePlayerOnboardingHandler(WorldService worldService) : Endpoint +{ + 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); + } + } +} diff --git a/apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs b/apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs new file mode 100644 index 0000000..de9df10 --- /dev/null +++ b/apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs @@ -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> +{ + 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(users.Count + playerFactionsById.Count); + var seenIds = new HashSet(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(), + 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 Roles, + bool HasPlayerFaction, + string? PlayerFactionId, + string? PlayerFactionLabel, + string? SovereignFactionId); diff --git a/apps/backend/PlayerFaction/Contracts/PlayerFaction.cs b/apps/backend/PlayerFaction/Contracts/PlayerFaction.cs index 8bce83f..8cb2704 100644 --- a/apps/backend/PlayerFaction/Contracts/PlayerFaction.cs +++ b/apps/backend/PlayerFaction/Contracts/PlayerFaction.cs @@ -249,7 +249,10 @@ public sealed record PlayerAlertSnapshot( public sealed record PlayerFactionSnapshot( string Id, string Label, + string? PersonaName, + string? RaceId, string SovereignFactionId, + bool RequiresOnboarding, string Status, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc, diff --git a/apps/backend/PlayerFaction/Contracts/PlayerFactionCommands.cs b/apps/backend/PlayerFaction/Contracts/PlayerFactionCommands.cs index 2605909..e40daa7 100644 --- a/apps/backend/PlayerFaction/Contracts/PlayerFactionCommands.cs +++ b/apps/backend/PlayerFaction/Contracts/PlayerFactionCommands.cs @@ -1,5 +1,9 @@ namespace SpaceGame.Api.PlayerFaction.Contracts; +public sealed record CompletePlayerOnboardingRequest( + string Name, + string RaceId); + public sealed record PlayerOrganizationCommandRequest( string Kind, string Label, diff --git a/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs b/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs index 2edd146..9ae0f3a 100644 --- a/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs +++ b/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs @@ -6,7 +6,10 @@ public sealed class PlayerFactionRuntime { public required string Id { get; init; } public required string Label { get; set; } + public string? PersonaName { get; set; } + public string? RaceId { get; set; } public required string SovereignFactionId { get; set; } + public bool RequiresOnboarding { get; set; } = true; public string Status { get; set; } = "active"; public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; diff --git a/apps/backend/PlayerFaction/Simulation/PlayerFactionProjectionService.cs b/apps/backend/PlayerFaction/Simulation/PlayerFactionProjectionService.cs index fb5ac88..1cb0d9c 100644 --- a/apps/backend/PlayerFaction/Simulation/PlayerFactionProjectionService.cs +++ b/apps/backend/PlayerFaction/Simulation/PlayerFactionProjectionService.cs @@ -12,7 +12,10 @@ public sealed class PlayerFactionProjectionService return new PlayerFactionSnapshot( player.Id, player.Label, + player.PersonaName, + player.RaceId, player.SovereignFactionId, + player.RequiresOnboarding, player.Status, player.CreatedAtUtc, player.UpdatedAtUtc, diff --git a/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs b/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs index 7b96ab1..b37143b 100644 --- a/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs +++ b/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs @@ -20,14 +20,12 @@ internal sealed class PlayerFactionService internal PlayerFactionRuntime EnsureDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId) { - var sovereignFaction = world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).FirstOrDefault() - ?? throw new InvalidOperationException("Cannot create a player faction domain without any factions in the world."); - var player = playerStateStore.GetOrAddPlayerFaction(playerId, () => new PlayerFactionRuntime { Id = PlayerFactionDomainId, - Label = $"{sovereignFaction.Label} Command", - SovereignFactionId = sovereignFaction.Id, + Label = "Pending Pilot", + SovereignFactionId = string.Empty, + RequiresOnboarding = true, CreatedAtUtc = world.GeneratedAtUtc, UpdatedAtUtc = world.GeneratedAtUtc, }); @@ -37,6 +35,58 @@ internal sealed class PlayerFactionService return player; } + internal PlayerFactionRuntime CompleteOnboarding( + SimulationWorld world, + IPlayerStateStore playerStateStore, + string playerId, + CompletePlayerOnboardingRequest request) + { + var player = EnsureDomain(world, playerStateStore, playerId); + if (!player.RequiresOnboarding) + { + throw new InvalidOperationException("Player onboarding has already been completed."); + } + + var personaName = request.Name.Trim(); + if (personaName.Length < 2) + { + throw new InvalidOperationException("Player name must contain at least 2 characters."); + } + + if (personaName.Length > 48) + { + throw new InvalidOperationException("Player name must contain at most 48 characters."); + } + + var ownedFactionId = BuildOwnedFactionId(playerId); + if (world.Factions.Any(faction => string.Equals(faction.Id, ownedFactionId, StringComparison.Ordinal))) + { + throw new InvalidOperationException($"Player faction '{ownedFactionId}' already exists in the current world."); + } + + player.Label = personaName; + player.PersonaName = personaName; + player.RaceId = request.RaceId.Trim(); + player.SovereignFactionId = ownedFactionId; + player.RequiresOnboarding = false; + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + return player; + } + + internal PlayerFactionRuntime EnsureInitializedDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId) + { + var player = EnsureDomain(world, playerStateStore, playerId); + if (player.RequiresOnboarding || string.IsNullOrWhiteSpace(player.SovereignFactionId)) + { + throw new InvalidOperationException("Player onboarding must be completed before issuing gameplay commands."); + } + + return player; + } + + internal static string BuildOwnedFactionId(string playerId) => + $"player-{playerId.Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant()}"; + internal void Update(SimulationWorld world, IPlayerStateStore playerStateStore, float _deltaSeconds, ICollection events) { if (playerStateStore.GetPlayerFactions().Count == 0) @@ -63,7 +113,7 @@ internal sealed class PlayerFactionService internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerOrganizationCommandRequest request) { - var player = EnsureDomain(world, playerStateStore, playerId); + var player = EnsureInitializedDomain(world, playerStateStore, playerId); var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player)); var nowUtc = DateTimeOffset.UtcNow; @@ -180,7 +230,7 @@ internal sealed class PlayerFactionService internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId) { - var player = EnsureDomain(world, playerStateStore, playerId); + var player = EnsureInitializedDomain(world, playerStateStore, playerId); RemoveOrganization(player, organizationId); player.Assignments.RemoveAll(assignment => assignment.FleetId == organizationId || @@ -198,7 +248,7 @@ internal sealed class PlayerFactionService internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId, PlayerOrganizationMembershipCommandRequest request) { - var player = EnsureDomain(world, playerStateStore, playerId); + var player = EnsureInitializedDomain(world, playerStateStore, playerId); var kind = ResolveOrganizationKind(player, organizationId); switch (kind) { @@ -249,7 +299,7 @@ internal sealed class PlayerFactionService internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? directiveId, PlayerDirectiveCommandRequest request) { - var player = EnsureDomain(world, playerStateStore, playerId); + var player = EnsureInitializedDomain(world, playerStateStore, playerId); var directive = directiveId is null ? null : player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal)); @@ -326,7 +376,7 @@ internal sealed class PlayerFactionService internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string directiveId) { - var player = EnsureDomain(world, playerStateStore, playerId); + var player = EnsureInitializedDomain(world, playerStateStore, playerId); player.Directives.RemoveAll(directive => directive.Id == directiveId); foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId)) { @@ -340,7 +390,7 @@ internal sealed class PlayerFactionService internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? policyId, PlayerPolicyCommandRequest request) { - var player = EnsureDomain(world, playerStateStore, playerId); + var player = EnsureInitializedDomain(world, playerStateStore, playerId); var policy = policyId is null ? null : player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal)); @@ -411,7 +461,7 @@ internal sealed class PlayerFactionService internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request) { - var player = EnsureDomain(world, playerStateStore, playerId); + var player = EnsureInitializedDomain(world, playerStateStore, playerId); var policy = automationPolicyId is null ? null : player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal)); @@ -469,7 +519,7 @@ internal sealed class PlayerFactionService internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request) { - var player = EnsureDomain(world, playerStateStore, playerId); + var player = EnsureInitializedDomain(world, playerStateStore, playerId); var policy = reinforcementPolicyId is null ? null : player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal)); @@ -503,7 +553,7 @@ internal sealed class PlayerFactionService internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? productionProgramId, PlayerProductionProgramCommandRequest request) { - var player = EnsureDomain(world, playerStateStore, playerId); + var player = EnsureInitializedDomain(world, playerStateStore, playerId); var program = productionProgramId is null ? null : player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal)); @@ -535,7 +585,7 @@ internal sealed class PlayerFactionService internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string assetId, PlayerAssetAssignmentCommandRequest request) { - var player = EnsureDomain(world, playerStateStore, playerId); + var player = EnsureInitializedDomain(world, playerStateStore, playerId); var assignment = player.Assignments.FirstOrDefault(candidate => string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) && string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal)); @@ -594,7 +644,7 @@ internal sealed class PlayerFactionService internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerStrategicIntentCommandRequest request) { - var player = EnsureDomain(world, playerStateStore, playerId); + var player = EnsureInitializedDomain(world, playerStateStore, playerId); player.StrategicIntent.StrategicPosture = request.StrategicPosture; player.StrategicIntent.EconomicPosture = request.EconomicPosture; player.StrategicIntent.MilitaryPosture = request.MilitaryPosture; @@ -610,7 +660,7 @@ internal sealed class PlayerFactionService internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipOrderCommandRequest request) { - var player = EnsureDomain(world, playerStateStore, playerId); + var player = EnsureInitializedDomain(world, playerStateStore, playerId); if (!player.AssetRegistry.ShipIds.Contains(shipId)) { return null; @@ -669,7 +719,7 @@ internal sealed class PlayerFactionService internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId) { - var player = EnsureDomain(world, playerStateStore, playerId); + var player = EnsureInitializedDomain(world, playerStateStore, playerId); if (!player.AssetRegistry.ShipIds.Contains(shipId)) { return null; @@ -712,7 +762,7 @@ internal sealed class PlayerFactionService internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipDefaultBehaviorCommandRequest request) { - var player = EnsureDomain(world, playerStateStore, playerId); + var player = EnsureInitializedDomain(world, playerStateStore, playerId); if (!player.AssetRegistry.ShipIds.Contains(shipId)) { return null; @@ -852,6 +902,24 @@ internal sealed class PlayerFactionService private static void SyncRegistry(SimulationWorld world, PlayerFactionRuntime player) { + if (string.IsNullOrWhiteSpace(player.SovereignFactionId)) + { + SyncSet(player.AssetRegistry.ShipIds, []); + SyncSet(player.AssetRegistry.StationIds, []); + SyncSet(player.AssetRegistry.CommanderIds, []); + SyncSet(player.AssetRegistry.ClaimIds, []); + SyncSet(player.AssetRegistry.ConstructionSiteIds, []); + SyncSet(player.AssetRegistry.PolicySetIds, player.Policies.Where(entry => entry.PolicySetId is not null).Select(entry => entry.PolicySetId!)); + SyncSet(player.AssetRegistry.MarketOrderIds, []); + SyncSet(player.AssetRegistry.FleetIds, player.Fleets.Select(fleet => fleet.Id)); + SyncSet(player.AssetRegistry.TaskForceIds, player.TaskForces.Select(taskForce => taskForce.Id)); + SyncSet(player.AssetRegistry.StationGroupIds, player.StationGroups.Select(group => group.Id)); + SyncSet(player.AssetRegistry.EconomicRegionIds, player.EconomicRegions.Select(region => region.Id)); + SyncSet(player.AssetRegistry.FrontIds, player.Fronts.Select(front => front.Id)); + SyncSet(player.AssetRegistry.ReserveIds, player.Reserves.Select(reserve => reserve.Id)); + return; + } + SyncSet(player.AssetRegistry.ShipIds, world.Ships.Where(ship => ship.FactionId == player.SovereignFactionId).Select(ship => ship.Id)); SyncSet(player.AssetRegistry.StationIds, world.Stations.Where(station => station.FactionId == player.SovereignFactionId).Select(station => station.Id)); SyncSet(player.AssetRegistry.CommanderIds, world.Commanders.Where(commander => commander.FactionId == player.SovereignFactionId).Select(commander => commander.Id)); @@ -1224,8 +1292,7 @@ internal sealed class PlayerFactionService return player.AutomationPolicies.FirstOrDefault(policy => policy.Id == automationId); } - return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId) - ?? player.AutomationPolicies.FirstOrDefault(policy => policy.Id == "player-core-automation"); + return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId); } private static PlayerFactionPolicyRuntime? ResolvePolicy(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string assetId) diff --git a/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs b/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs index 2474413..7fabcd4 100644 --- a/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs +++ b/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs @@ -90,7 +90,8 @@ public sealed class ScenarioContentBuilder( powerModuleId, plan.FactionId, staticData.ModuleDefinitions, - staticData.ItemDefinitions) + staticData.ItemDefinitions, + staticData.Recipes) .FirstOrDefault(moduleId => { return staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition) @@ -117,7 +118,8 @@ public sealed class ScenarioContentBuilder( objectiveModuleId, plan.FactionId, staticData.ModuleDefinitions, - staticData.ItemDefinitions)) + staticData.ItemDefinitions, + staticData.Recipes)) { EnsureStartingModule(startingModules, storageModuleId); } diff --git a/apps/backend/Universe/Scenario/StarterStationLayoutResolver.cs b/apps/backend/Universe/Scenario/StarterStationLayoutResolver.cs index 181ccb7..932f363 100644 --- a/apps/backend/Universe/Scenario/StarterStationLayoutResolver.cs +++ b/apps/backend/Universe/Scenario/StarterStationLayoutResolver.cs @@ -30,7 +30,8 @@ internal static class StarterStationLayoutResolver string moduleId, string? factionId, IReadOnlyDictionary moduleDefinitions, - IReadOnlyDictionary itemDefinitions) + IReadOnlyDictionary itemDefinitions, + IReadOnlyDictionary recipes) { if (!moduleDefinitions.TryGetValue(moduleId, out var moduleDefinition)) { @@ -40,6 +41,10 @@ internal static class StarterStationLayoutResolver foreach (var wareId in moduleDefinition.BuildRecipes .SelectMany(production => production.Wares.Select(ware => ware.ItemId)) .Concat(moduleDefinition.ProductItemIds) + .Concat(recipes.Values + .Where(recipe => recipe.RequiredModules.Contains(moduleId, StringComparer.Ordinal)) + .SelectMany(recipe => recipe.Inputs.Select(input => input.ItemId) + .Concat(recipe.Outputs.Select(output => output.ItemId)))) .Distinct(StringComparer.Ordinal)) { if (!itemDefinitions.TryGetValue(wareId, out var itemDefinition)) diff --git a/apps/backend/Universe/Scenario/WorldSeedingService.cs b/apps/backend/Universe/Scenario/WorldSeedingService.cs index 1ca359a..db6d690 100644 --- a/apps/backend/Universe/Scenario/WorldSeedingService.cs +++ b/apps/backend/Universe/Scenario/WorldSeedingService.cs @@ -201,7 +201,8 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData) objectiveModuleId, station.FactionId, world.ModuleDefinitions, - world.ItemDefinitions)) + world.ItemDefinitions, + world.Recipes)) { if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal)) { diff --git a/apps/backend/Universe/Simulation/WorldService.cs b/apps/backend/Universe/Simulation/WorldService.cs index 843b96e..44d313a 100644 --- a/apps/backend/Universe/Simulation/WorldService.cs +++ b/apps/backend/Universe/Simulation/WorldService.cs @@ -10,6 +10,7 @@ namespace SpaceGame.Api.Universe.Simulation; public sealed class WorldService { private const int DeltaHistoryLimit = 256; + private const string StarterPlayerShipId = "ship_arg_s_scout_01_a"; private readonly Lock _sync = new(); private readonly OrbitalSimulationSnapshot _orbitalSimulation; @@ -148,11 +149,6 @@ public sealed class WorldService { lock (_sync) { - if (_world.Factions.Count == 0) - { - return null; - } - var playerKey = GetCurrentPlayerKey(); var player = _playerFaction.TryGetDomain(_playerStateStore, playerKey) ?? _playerFaction.EnsureDomain(_world, _playerStateStore, playerKey); @@ -160,6 +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) { lock (_sync) @@ -530,7 +546,102 @@ public sealed class WorldService private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() => _playerFactionProjection.ToSnapshot(_playerFaction.TryGetDomain(_playerStateStore, GetCurrentPlayerKey())); - private string GetCurrentPlayerKey() => _playerIdentityResolver.GetRequiredPlayerId().ToString("N"); + private FactionRuntime CreatePlayerOwnedFactionUnsafe(PlayerFactionRuntime player, RaceDefinition race) + { + var playerFaction = new FactionRuntime + { + Id = player.SovereignFactionId, + Label = player.PersonaName ?? player.Label, + Color = ResolvePlayerFactionColor(race.Id), + Credits = 25000f, + }; + + _world.Factions.Add(playerFaction); + + var policy = _worldSeedingService.CreatePolicies([playerFaction]).Single(); + var templateFaction = _staticData.FactionDefinitions.Values + .Where(candidate => string.Equals(candidate.RaceId, race.Id, StringComparison.Ordinal)) + .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) + .Select(candidate => _world.Factions.FirstOrDefault(worldFaction => string.Equals(worldFaction.Id, candidate.Id, StringComparison.Ordinal))) + .FirstOrDefault(candidate => candidate is not null); + if (templateFaction?.DefaultPolicySetId is { } racePolicyId + && _world.Policies.FirstOrDefault(candidate => candidate.Id == racePolicyId) is { } racePolicy) + { + policy.TradeAccessPolicy = racePolicy.TradeAccessPolicy; + policy.DockingAccessPolicy = racePolicy.DockingAccessPolicy; + policy.ConstructionAccessPolicy = racePolicy.ConstructionAccessPolicy; + policy.OperationalRangePolicy = racePolicy.OperationalRangePolicy; + policy.CombatEngagementPolicy = racePolicy.CombatEngagementPolicy; + policy.FleeHullRatio = racePolicy.FleeHullRatio; + policy.AvoidHostileSystems = racePolicy.AvoidHostileSystems; + foreach (var systemId in racePolicy.BlacklistedSystemIds) + { + policy.BlacklistedSystemIds.Add(systemId); + } + } + _world.Policies.Add(policy); + + var factionCommander = CreateFactionCommander(playerFaction); + _world.Commanders.Add(factionCommander); + playerFaction.CommanderIds.Add(factionCommander.Id); + return playerFaction; + } + + private string ResolveStarterSystemIdUnsafe() + { + return _world.Systems + .Select(system => system.Definition.Id) + .OrderBy(systemId => systemId, StringComparer.Ordinal) + .FirstOrDefault() + ?? throw new InvalidOperationException("No systems are available for player onboarding."); + } + + private void SpawnPlayerStarterShipUnsafe(FactionRuntime playerFaction, string systemId) + { + var request = new SpawnShipCommandRequest( + playerFaction.Id, + systemId, + StarterPlayerShipId, + Idle); + var system = _world.Systems.First(candidate => string.Equals(candidate.Definition.Id, request.SystemId, StringComparison.Ordinal)); + var definition = ResolveShipDefinition(request, playerFaction.Id); + var shipId = $"ship-{playerFaction.Id}-{definition.Id}-{Guid.NewGuid():N}".ToLowerInvariant(); + var spawnPosition = ResolveSpawnPosition(system.Definition.Id); + var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, null); + + var 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(); @@ -807,7 +918,8 @@ public sealed class WorldService powerModuleId, factionId, _staticData.ModuleDefinitions, - _staticData.ItemDefinitions) + _staticData.ItemDefinitions, + _staticData.Recipes) .FirstOrDefault(moduleId => { return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition) @@ -820,6 +932,24 @@ public sealed class WorldService EnsureStationModule(modules, defaultContainerStorageModuleId); } + var defaultSolidStorageModuleId = StarterStationLayoutResolver.ResolveRequiredStorageModuleIds( + powerModuleId, + factionId, + _staticData.ModuleDefinitions, + _staticData.ItemDefinitions, + _staticData.Recipes) + .FirstOrDefault(moduleId => + { + return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition) + && definition is StorageModuleDefinition storageDefinition + && storageDefinition.StorageKind == StorageKind.Solid; + }); + + if (defaultSolidStorageModuleId is not null) + { + EnsureStationModule(modules, defaultSolidStorageModuleId); + } + var objectiveModuleId = StarterStationLayoutResolver.ResolveObjectiveModuleId(objective, factionId, _staticData.ModuleDefinitions); if (!string.IsNullOrWhiteSpace(objectiveModuleId)) { @@ -828,7 +958,8 @@ public sealed class WorldService objectiveModuleId, factionId, _staticData.ModuleDefinitions, - _staticData.ItemDefinitions)) + _staticData.ItemDefinitions, + _staticData.Recipes)) { EnsureStationModule(modules, storageModuleId); } diff --git a/apps/viewer/src/App.vue b/apps/viewer/src/App.vue index 1325025..8f11ba6 100644 --- a/apps/viewer/src/App.vue +++ b/apps/viewer/src/App.vue @@ -2,8 +2,6 @@ import { storeToRefs } from "pinia"; import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { GameViewer } from "./GameViewer"; -import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue"; -import HtmlInfoPanel from "./components/HtmlInfoPanel.vue"; import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue"; import ViewerEntityBrowserPanel from "./components/ViewerEntityBrowserPanel.vue"; import ViewerEntityInspectorPanel from "./components/ViewerEntityInspectorPanel.vue"; @@ -13,9 +11,12 @@ import GmTelemetryWindow from "./components/gm/GmTelemetryWindow.vue"; import GmSettingsWindow from "./components/gm/GmSettingsWindow.vue"; import AuthSessionPanel from "./components/AuthSessionPanel.vue"; import AuthLandingPage from "./components/AuthLandingPage.vue"; +import PlayerOnboardingPanel from "./components/PlayerOnboardingPanel.vue"; +import { fetchPlayerFaction } from "./api"; import { useShipAutomationCatalogStore } from "./ui/stores/shipAutomationCatalogStore"; import { createViewerHudState } from "./viewerHudState"; import { useAuthStore } from "./ui/stores/authStore"; +import { usePlayerFactionStore } from "./ui/stores/playerFactionStore"; import { useViewerSelectionStore } from "./ui/stores/viewerSelection"; import type { Selectable } from "./viewerTypes"; @@ -27,19 +28,24 @@ const hoverConnectorLineEl = ref(null); const hudState = createViewerHudState(); const authStore = useAuthStore(); +const playerFactionStore = usePlayerFactionStore(); const automationCatalogStore = useShipAutomationCatalogStore(); const selectionStore = useViewerSelectionStore(); const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore); -const { canAccessGm } = storeToRefs(authStore); +const { canAccessGm, effectivePlayerId } = storeToRefs(authStore); +const { playerFaction } = storeToRefs(playerFactionStore); let viewer: GameViewer | undefined; const gmOpsOpen = ref(false); const gmTelemetryOpen = ref(false); const gmSettingsOpen = ref(false); const gmMenuOpen = ref(false); +const leftSidebarTab = ref<"player" | "entities">("player"); +const playerContextReady = ref(false); onMounted(async () => { void automationCatalogStore.load(); + await refreshPlayerContext(); await startViewerIfAuthenticated(); }); @@ -47,15 +53,35 @@ onBeforeUnmount(() => { viewer?.dispose(); }); -watch(() => authStore.isAuthenticated, async (isAuthenticated) => { - if (isAuthenticated) { - await startViewerIfAuthenticated(); - return; - } +watch( + [() => authStore.isAuthenticated, () => effectivePlayerId.value], + async ([isAuthenticated]) => { + if (!isAuthenticated) { + playerContextReady.value = false; + playerFactionStore.setPlayerFaction(null); + viewer?.dispose(); + viewer = undefined; + return; + } - viewer?.dispose(); - viewer = undefined; -}); + await refreshPlayerContext(); + await startViewerIfAuthenticated(); + }, + { immediate: true }, +); + +watch( + () => playerFaction.value?.requiresOnboarding ?? false, + async (requiresOnboarding) => { + if (requiresOnboarding) { + viewer?.dispose(); + viewer = undefined; + return; + } + + await startViewerIfAuthenticated(); + }, +); function onHistoryWindowResize(id: string, width: number, height: number) { const windowState = hudState.historyWindows.find((entry) => entry.id === id); @@ -76,7 +102,7 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic } async function startViewerIfAuthenticated() { - if (!authStore.isAuthenticated || viewer) { + if (!authStore.isAuthenticated || viewer || !playerContextReady.value || playerFaction.value?.requiresOnboarding) { return; } @@ -101,58 +127,112 @@ async function startViewerIfAuthenticated() { }); void viewer.start(); } + +async function refreshPlayerContext() { + if (!authStore.isAuthenticated) { + playerContextReady.value = false; + playerFactionStore.setPlayerFaction(null); + return; + } + + playerContextReady.value = false; + try { + playerFactionStore.setPlayerFaction(await fetchPlayerFaction()); + } catch { + playerFactionStore.setPlayerFaction(null); + } finally { + playerContextReady.value = true; + } +} @@ -537,46 +821,102 @@ async function clearOrders() {

Status

-
-
Category{{ titleCase(selectedStation.category) }}
-
Objective{{ titleCase(selectedStation.objective) }}
-
Docked{{ selectedStation.dockedShips }} / {{ selectedStation.dockingPads }}
-
Population{{ formatAmount(selectedStation.population) }} / {{ formatAmount(selectedStation.populationCapacity) }}
-
Workforce{{ formatAmount(selectedStation.workforceRequired) }}
-
Efficiency{{ Math.round(selectedStation.workforceEffectiveRatio * 100) }}%
+
+ + + + + + + +
{{ row.label }}{{ row.value }}

Modules

-
    -
  • - {{ moduleNameById.get(moduleId) ?? moduleId }} - {{ moduleId }} -
  • -
+
+ + + + + + + + + + + + + +
ModuleId
{{ row.module }}{{ row.moduleId }}
+
No modules installed.

Storage

-
    -
  • - {{ entry.itemId }} - {{ formatAmount(entry.amount) }} -
  • -
-
No inventory.
+
+ + + + + + + + + + + + + + + + + +
ClassUsedCapacityFill
{{ row.storageClass }}{{ row.used }}{{ row.capacity }}{{ row.fill }}
+
+
+ + + + + + + + + + + + + +
WareAmount
{{ row.ware }}{{ row.amount }}
+
+
No inventory.

Production

-
    -
  • - {{ process.label }} - {{ Math.round(process.progress * 100) }}% · {{ Math.ceil(process.timeRemainingSeconds) }}s -
  • -
+
+ + + + + + + + + + + + + + + + + +
LaneProcessProgressTiming
{{ row.lane }}{{ row.label }}{{ row.progress }}{{ row.timing }}
+
No active processes.
diff --git a/apps/viewer/src/components/ViewerShipOrderContextMenu.vue b/apps/viewer/src/components/ViewerShipOrderContextMenu.vue index a67e9c7..861cd88 100644 --- a/apps/viewer/src/components/ViewerShipOrderContextMenu.vue +++ b/apps/viewer/src/components/ViewerShipOrderContextMenu.vue @@ -50,7 +50,7 @@ const canControlSelectedShip = computed(() => { return false; } - if (authStore.canAccessGm) { + if (authStore.canAccessGm && !authStore.isActingAsAlternateIdentity) { return true; } diff --git a/apps/viewer/src/contractsAuth.ts b/apps/viewer/src/contractsAuth.ts index fd2bdf9..63f7961 100644 --- a/apps/viewer/src/contractsAuth.ts +++ b/apps/viewer/src/contractsAuth.ts @@ -8,6 +8,12 @@ export interface AuthSessionResponse { refreshTokenExpiresAtUtc: string; } +export interface RegisterResponse { + userId: string; + email: string; + requiresLogin: boolean; +} + export interface ForgotPasswordResponse { accepted: boolean; resetToken?: string | null; diff --git a/apps/viewer/src/contractsIdentity.ts b/apps/viewer/src/contractsIdentity.ts new file mode 100644 index 0000000..0b6b2fb --- /dev/null +++ b/apps/viewer/src/contractsIdentity.ts @@ -0,0 +1,9 @@ +export interface PlayerIdentitySummary { + userId: string; + email: string; + roles: string[]; + hasPlayerFaction: boolean; + playerFactionId?: string | null; + playerFactionLabel?: string | null; + sovereignFactionId?: string | null; +} diff --git a/apps/viewer/src/contractsPlayerFaction.ts b/apps/viewer/src/contractsPlayerFaction.ts index 2a4f9f9..4c290e1 100644 --- a/apps/viewer/src/contractsPlayerFaction.ts +++ b/apps/viewer/src/contractsPlayerFaction.ts @@ -266,7 +266,10 @@ export interface PlayerAlertSnapshot { export interface PlayerFactionSnapshot { id: string; label: string; + personaName?: string | null; + raceId?: string | null; sovereignFactionId: string; + requiresOnboarding: boolean; status: string; createdAtUtc: string; updatedAtUtc: string; diff --git a/apps/viewer/src/contractsRaces.ts b/apps/viewer/src/contractsRaces.ts new file mode 100644 index 0000000..2b4f791 --- /dev/null +++ b/apps/viewer/src/contractsRaces.ts @@ -0,0 +1,6 @@ +export interface RaceSnapshot { + id: string; + name: string; + description: string; + icon: string; +} diff --git a/apps/viewer/src/effectiveIdentitySession.ts b/apps/viewer/src/effectiveIdentitySession.ts new file mode 100644 index 0000000..b80fa07 --- /dev/null +++ b/apps/viewer/src/effectiveIdentitySession.ts @@ -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); +} diff --git a/apps/viewer/src/runtime/rendering/ViewerRenderSurface.ts b/apps/viewer/src/runtime/rendering/ViewerRenderSurface.ts index f3d06ae..e545e44 100644 --- a/apps/viewer/src/runtime/rendering/ViewerRenderSurface.ts +++ b/apps/viewer/src/runtime/rendering/ViewerRenderSurface.ts @@ -13,14 +13,22 @@ export class ViewerRenderSurface { private readonly onFrame: () => void; private readonly onResizeCallback: (width: number, height: number) => void; private readonly resizeListener = () => this.resize(); + private readonly resizeObserver?: ResizeObserver; constructor(options: ViewerRenderSurfaceOptions) { this.container = options.container; this.renderer = options.renderer; this.onFrame = options.onFrame; this.onResizeCallback = options.onResize; + this.renderer.domElement.style.width = "100%"; + this.renderer.domElement.style.height = "100%"; + this.renderer.domElement.style.display = "block"; this.container.append(this.renderer.domElement); window.addEventListener("resize", this.resizeListener); + if (typeof ResizeObserver !== "undefined") { + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(this.container); + } this.resize(); } @@ -46,6 +54,7 @@ export class ViewerRenderSurface { dispose() { this.stop(); window.removeEventListener("resize", this.resizeListener); + this.resizeObserver?.disconnect(); this.renderer.dispose(); this.renderer.domElement.remove(); } diff --git a/apps/viewer/src/styles/viewer.css b/apps/viewer/src/styles/viewer.css index 2882ec6..126f33b 100644 --- a/apps/viewer/src/styles/viewer.css +++ b/apps/viewer/src/styles/viewer.css @@ -19,6 +19,7 @@ body, margin: 0; width: 100%; height: 100%; + min-height: 100dvh; overflow: hidden; background: radial-gradient(circle at top, rgba(89, 132, 247, 0.16), transparent 30%), @@ -269,12 +270,162 @@ select { canvas { display: block; + touch-action: none; } .viewer-app, .viewer-canvas-host { width: 100%; + height: 100dvh; + min-height: 100dvh; +} + +.viewer-canvas-host { + touch-action: none; +} + +.viewer-left-sidebar-dock { + position: absolute; + inset: 0 auto 0 0; + width: min(360px, 100vw); + padding: 0; +} + +.viewer-left-sidebar { + display: flex; + flex-direction: column; height: 100%; + min-height: 0; + padding: 16px; + background: + linear-gradient(180deg, rgba(7, 14, 27, 0.9), rgba(7, 14, 27, 0.78)), + radial-gradient(circle at top left, rgba(127, 214, 255, 0.08), transparent 34%); + border-right: 1px solid rgba(132, 196, 255, 0.14); + backdrop-filter: blur(18px); + box-shadow: 18px 0 42px rgba(0, 0, 0, 0.18); +} + +.viewer-left-sidebar__tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + flex: 0 0 auto; +} + +.viewer-left-sidebar__tab { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + color: var(--viewer-text); + padding: 0.75rem 0.95rem; + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; + transition: background 120ms ease, border-color 120ms ease; +} + +.viewer-left-sidebar__tab:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.22); +} + +.viewer-left-sidebar__tab--active { + background: rgba(116, 196, 255, 0.14); + border-color: rgba(116, 196, 255, 0.32); +} + +.viewer-left-sidebar__body { + display: flex; + flex: 1 1 auto; + min-height: 0; + margin-top: 0.9rem; + overflow: hidden; +} + +.viewer-left-sidebar__panel { + flex: 1 1 auto; + min-height: 0; +} + +.viewer-left-sidebar__panel--player { + overflow: auto; + padding-right: 0.2rem; +} + +.viewer-left-sidebar__panel--entities { + height: 100%; +} + +.viewer-left-sidebar__panel--entities.entity-browser-panel { + height: 100%; + padding: 0; + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; + backdrop-filter: none; +} + +.viewer-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, @@ -1257,54 +1408,253 @@ canvas { color: rgba(173, 220, 255, 0.64); } -.entity-browser-section__items { - display: flex; - flex-direction: column; - gap: 0.45rem; -} - -.entity-browser-item { - display: flex; - align-items: stretch; - gap: 0.55rem; -} - -.entity-browser-item__body { - flex: 1 1 auto; - text-align: left; - padding: 0.75rem 0.85rem; +.entity-browser-table-wrap, +.entity-inspector-table-wrap { + overflow: auto; + border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 1rem; + background: rgba(255, 255, 255, 0.03); } -.entity-browser-item__body:disabled { - opacity: 0.82; - cursor: default; +.entity-browser-table, +.entity-inspector-table { + width: 100%; + border-collapse: collapse; + min-width: 0; } -.entity-browser-item--selected .entity-browser-item__body { - border-color: rgba(116, 196, 255, 0.38); +.entity-browser-table { + table-layout: fixed; +} + +.entity-browser-table__col--entity { + width: 38%; +} + +.entity-browser-table__col--ident { + width: 18%; +} + +.entity-browser-table__col--location { + width: 22%; +} + +.entity-browser-table__col--ai { + width: 14%; +} + +.entity-browser-table__col--hp { + width: 8%; +} + +.entity-browser-table th, +.entity-browser-table td, +.entity-inspector-table th, +.entity-inspector-table td { + padding: 0.68rem 0.8rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + font-size: 0.78rem; + text-align: left; + vertical-align: middle; +} + +.entity-browser-table th, +.entity-browser-table td { + padding: 0.42rem 0.5rem; +} + +.entity-browser-table thead th, +.entity-inspector-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: rgba(7, 12, 18, 0.96); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: rgba(173, 220, 255, 0.72); +} + +.entity-browser-table tbody tr:last-child td, +.entity-inspector-table tbody tr:last-child td, +.entity-inspector-table tbody tr:last-child th { + border-bottom: none; +} + +.entity-browser-table__sort { + border: none; + background: transparent; + color: inherit; + font: inherit; + letter-spacing: inherit; + text-transform: inherit; + padding: 0; +} + +.entity-browser-table__row { + cursor: pointer; + transition: background 120ms ease; +} + +.entity-browser-table__row:hover { + background: rgba(255, 255, 255, 0.04); +} + +.entity-browser-table__row--selected { background: rgba(116, 196, 255, 0.12); } -.entity-browser-item__label { - font-size: 0.88rem; +.entity-browser-table__name { + font-size: 0.78rem; font-weight: 600; } -.entity-browser-item__subtitle, -.entity-browser-item__meta { - margin-top: 0.18rem; - font-size: 0.75rem; +.entity-browser-table__cell--truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entity-browser-table__cell--ai { + overflow: hidden; +} + +.entity-browser-table__detail, +.entity-inspector-table__detail { color: var(--viewer-muted); } -.entity-browser-item__focus, +.entity-browser-table__action-col, +.entity-inspector-table__action-col, +.entity-inspector-table__numeric { + text-align: right; +} + +.entity-browser-table__numeric { + text-align: right; + white-space: nowrap; +} + +.entity-browser-table__action, .entity-inspector-panel__action { - padding: 0.65rem 0.9rem; - font-size: 0.78rem; + padding: 0.5rem 0.72rem; + font-size: 0.72rem; align-self: center; } +.entity-browser-table__action { + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + color: var(--viewer-text); + border-radius: 999px; + transition: background 120ms ease, border-color 120ms ease; +} + +.entity-browser-table__action:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.22); +} + +.entity-browser-table__muted { + color: var(--viewer-muted); +} + +.entity-browser-row { + display: flex; + align-items: center; + gap: 0.38rem; + min-width: 0; +} + +.entity-browser-row__toggle { + width: 1rem; + height: 1rem; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 0.35rem; + background: rgba(255, 255, 255, 0.04); + color: var(--viewer-text); + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + font-size: 0.66rem; + line-height: 1; + padding: 0; + flex: 0 0 auto; +} + +.entity-browser-row__toggle--spacer { + visibility: hidden; +} + +.entity-browser-row__kind { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.75rem; + padding: 0.14rem 0.3rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: rgba(234, 244, 255, 0.88); + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + font-size: 0.58rem; + letter-spacing: 0.08em; + text-transform: uppercase; + flex: 0 0 auto; +} + +.entity-browser-row__kind--system { + border-color: rgba(127, 214, 255, 0.24); + color: rgba(127, 214, 255, 0.96); +} + +.entity-browser-row__kind--station { + border-color: rgba(255, 191, 105, 0.22); + color: rgba(255, 222, 168, 0.92); +} + +.entity-browser-row__kind--fleet { + border-color: rgba(146, 255, 200, 0.22); + color: rgba(190, 255, 223, 0.92); +} + +.entity-browser-row__kind--ship { + border-color: rgba(255, 255, 255, 0.14); +} + +.entity-browser-row__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entity-browser-ai { + display: flex; + flex-wrap: wrap; + gap: 0.2rem; + max-width: 100%; + overflow: hidden; +} + +.entity-browser-ai__token { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2rem; + max-width: 100%; + padding: 0.13rem 0.28rem; + border-radius: 999px; + background: rgba(127, 214, 255, 0.08); + border: 1px solid rgba(127, 214, 255, 0.14); + color: rgba(206, 233, 255, 0.84); + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + font-size: 0.56rem; + letter-spacing: 0.06em; + text-transform: uppercase; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .entity-inspector-panel__actions { display: flex; gap: 0.45rem; @@ -1328,71 +1678,31 @@ canvas { color: rgba(173, 220, 255, 0.7); } -.entity-inspector-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.7rem 0.9rem; -} - -.entity-inspector-grid span { - display: block; - font-size: 0.72rem; - color: var(--viewer-muted); +.entity-inspector-table--kv th { + width: 38%; + background: rgba(255, 255, 255, 0.02); + font-size: 0.68rem; text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.12em; + color: rgba(173, 220, 255, 0.68); } -.entity-inspector-grid strong { - display: block; - margin-top: 0.15rem; - font-size: 0.86rem; +.entity-inspector-table--kv td { + font-size: 0.84rem; font-weight: 600; } -.entity-inspector-list, -.entity-inspector-plan, -.entity-inspector-subtasks { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.45rem; +.entity-inspector-table__row--subtask { + background: rgba(255, 255, 255, 0.02); } -.entity-inspector-list li, -.entity-inspector-plan__step, -.entity-inspector-subtasks li { - 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-table__subtask { + padding-left: 1.45rem; } -.entity-inspector-list span, -.entity-inspector-plan__step span, -.entity-inspector-subtasks span { - 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-table__subtask::before { + content: "↳ "; + color: rgba(173, 220, 255, 0.58); } .entity-inspector-panel__fallback { @@ -1592,8 +1902,30 @@ canvas { } @media (max-width: 760px) { - .entity-inspector-grid { - grid-template-columns: minmax(0, 1fr); + .viewer-left-sidebar-dock { + width: min(360px, 100vw); + } + + .viewer-left-sidebar { + padding: 14px; + } + + .viewer-stats-overlay-dock { + top: 96px; + left: 20px; + right: 20px; + max-width: none; + } + + .viewer-system-label-dock { + top: 20px; + left: 20px; + right: 20px; + max-width: none; + } + + .viewer-system-label__title { + font-size: clamp(1.35rem, 1rem + 1vw, 1.8rem); } .entity-inspector-inline-form, @@ -1602,4 +1934,9 @@ canvas { flex-direction: column; align-items: stretch; } + + .entity-browser-table, + .entity-inspector-table { + min-width: 640px; + } } diff --git a/apps/viewer/src/ui/stores/authStore.ts b/apps/viewer/src/ui/stores/authStore.ts index e42b3b7..4e23242 100644 --- a/apps/viewer/src/ui/stores/authStore.ts +++ b/apps/viewer/src/ui/stores/authStore.ts @@ -1,10 +1,19 @@ import { defineStore } from "pinia"; import type { AuthSessionResponse } from "../../contractsAuth"; +import type { PlayerIdentitySummary } from "../../contractsIdentity"; import { clearAuthSession, getAuthSession, setAuthSession, subscribeToAuthSession } from "../../authSession"; +import { + clearEffectivePlayerIdentityId, + getEffectivePlayerIdentityId, + setEffectivePlayerIdentityId, + subscribeToEffectivePlayerIdentity, +} from "../../effectiveIdentitySession"; export const useAuthStore = defineStore("auth", { state: () => ({ session: getAuthSession() as AuthSessionResponse | null, + effectivePlayerId: getEffectivePlayerIdentityId() as string | null, + availablePlayerIdentities: [] as PlayerIdentitySummary[], busy: false, initialized: false, }), @@ -14,19 +23,35 @@ export const useAuthStore = defineStore("auth", { roles: (state) => state.session?.roles ?? [], canAccessGm: (state) => (state.session?.roles ?? []).some((role) => role === "gm" || role === "admin"), accessToken: (state) => state.session?.accessToken ?? null, + isActingAsAlternateIdentity: (state) => state.effectivePlayerId != null && state.effectivePlayerId !== state.session?.userId, + activePlayerId: (state) => state.effectivePlayerId ?? state.session?.userId ?? null, }, actions: { setSession(session: AuthSessionResponse | null) { this.session = session; setAuthSession(session); + if (!session || !(session.roles ?? []).some((role) => role === "gm" || role === "admin")) { + this.effectivePlayerId = null; + clearEffectivePlayerIdentityId(); + } }, clearSession() { this.session = null; + this.effectivePlayerId = null; + this.availablePlayerIdentities = []; clearAuthSession(); + clearEffectivePlayerIdentityId(); }, setBusy(busy: boolean) { this.busy = busy; }, + setEffectivePlayerId(playerId: string | null) { + this.effectivePlayerId = playerId && playerId.trim().length > 0 ? playerId.trim() : null; + setEffectivePlayerIdentityId(this.effectivePlayerId); + }, + setAvailablePlayerIdentities(identities: PlayerIdentitySummary[]) { + this.availablePlayerIdentities = identities; + }, initialize() { if (this.initialized) { return; @@ -36,6 +61,9 @@ export const useAuthStore = defineStore("auth", { subscribeToAuthSession((session) => { this.session = session as AuthSessionResponse | null; }); + subscribeToEffectivePlayerIdentity((playerId) => { + this.effectivePlayerId = playerId; + }); }, }, }); diff --git a/apps/viewer/src/viewerCamera.ts b/apps/viewer/src/viewerCamera.ts index 866c4c8..536df59 100644 --- a/apps/viewer/src/viewerCamera.ts +++ b/apps/viewer/src/viewerCamera.ts @@ -103,6 +103,42 @@ export function updatePanFromKeyboard( 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 { const { world, diff --git a/apps/viewer/src/viewerControllerFactory.ts b/apps/viewer/src/viewerControllerFactory.ts index 0b118ed..84e5a1c 100644 --- a/apps/viewer/src/viewerControllerFactory.ts +++ b/apps/viewer/src/viewerControllerFactory.ts @@ -5,6 +5,8 @@ import { ViewerPresentationController } from "./viewerPresentationController"; import { ViewerSceneDataController } from "./viewerSceneDataController"; import { ViewerWorldLifecycle } from "./viewerWorldLifecycle"; import { ViewerHistoryWindowController } from "./viewerHistoryWindowController"; +import { applyPanFromScreenDelta } from "./viewerCamera"; +import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE } from "./viewerConstants"; import { useViewerSceneStore } from "./ui/stores/viewerScene"; import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu"; import { viewerPinia } from "./ui/stores/pinia"; @@ -236,14 +238,21 @@ export function createViewerControllers(host: any) { getFollowCameraPosition: () => host.followCameraPosition, getFollowCameraFocus: () => host.followCameraFocus, screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y), - applyOrbitDelta: (delta: THREE.Vector2) => { - if (host.cameraMode === "follow") { - host.followOrbitYaw += delta.x * 0.008; - host.followOrbitPitch = THREE.MathUtils.clamp(host.followOrbitPitch + delta.y * 0.004, 0.02, 1.45); - } else { - host.orbitYaw += delta.x * 0.008; - host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3); - } + applyPanDelta: (delta: THREE.Vector2) => { + const bounds = host.renderer.domElement.getBoundingClientRect(); + applyPanFromScreenDelta( + delta, + host.orbitYaw, + host.currentDistance, + host.povLevel, + host.activeSystemId, + host.systemAnchor, + host.galaxyAnchor, + bounds.width, + bounds.height, + MIN_CAMERA_DISTANCE, + MAX_CAMERA_DISTANCE, + ); }, syncFollowStateFromSelection: () => navigationController.syncFollowStateFromSelection(), updatePanels: () => host.updatePanels(), @@ -251,6 +260,11 @@ export function createViewerControllers(host: any) { updateGamePanel: (mode) => host.updateGamePanel(mode), openOrderContextMenu: (x, y, target) => orderContextMenuStore.open(x, y, target), closeOrderContextMenu: () => orderContextMenuStore.close(), + getStatsOverlayMode: () => host.hudState.statsOverlay.mode, + setStatsOverlayMode: (mode) => { + host.hudState.statsOverlay.mode = mode; + }, + refreshStatsOverlay: () => presentationController.refreshStatsOverlay(), historyController, }); @@ -269,6 +283,7 @@ export function wireViewerEvents(host: any) { canvas.addEventListener("pointerdown", host.interactionController.onPointerDown); canvas.addEventListener("pointermove", host.interactionController.onPointerMove); canvas.addEventListener("pointerup", host.interactionController.onPointerUp); + canvas.addEventListener("pointercancel", host.interactionController.onPointerUp); canvas.addEventListener("pointerleave", host.interactionController.onPointerUp); canvas.addEventListener("click", host.interactionController.onClick); canvas.addEventListener("contextmenu", host.interactionController.onContextMenu); @@ -284,6 +299,7 @@ export function wireViewerEvents(host: any) { canvas.removeEventListener("pointerdown", host.interactionController.onPointerDown); canvas.removeEventListener("pointermove", host.interactionController.onPointerMove); canvas.removeEventListener("pointerup", host.interactionController.onPointerUp); + canvas.removeEventListener("pointercancel", host.interactionController.onPointerUp); canvas.removeEventListener("pointerleave", host.interactionController.onPointerUp); canvas.removeEventListener("click", host.interactionController.onClick); canvas.removeEventListener("contextmenu", host.interactionController.onContextMenu); diff --git a/apps/viewer/src/viewerControls.ts b/apps/viewer/src/viewerControls.ts index 00e8b36..269122d 100644 --- a/apps/viewer/src/viewerControls.ts +++ b/apps/viewer/src/viewerControls.ts @@ -3,6 +3,7 @@ import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewer import { scaleGalaxyVector, toThreeVector } from "./viewerMath"; import { rawObject } from "./viewerScenePrimitives"; import { resolveShipWorldPosition } from "./viewerWorldPresentation"; +import type { StatsOverlayMode } from "./viewerHudState"; import type { CameraMode, Selectable, @@ -250,3 +251,20 @@ export function applyKeyboardControl(params: { return { cameraMode, desiredDistance }; } + +export function cycleStatsOverlayMode(current: StatsOverlayMode): StatsOverlayMode { + switch (current) { + case "hidden": + return "compact"; + case "compact": + return "status"; + case "status": + return "network"; + case "network": + return "performance"; + case "performance": + return "full"; + default: + return "hidden"; + } +} diff --git a/apps/viewer/src/viewerHudState.ts b/apps/viewer/src/viewerHudState.ts index 54703d7..311217b 100644 --- a/apps/viewer/src/viewerHudState.ts +++ b/apps/viewer/src/viewerHudState.ts @@ -8,6 +8,13 @@ export interface HudPanelState { bodyText: string; } +export type StatsOverlayMode = "hidden" | "compact" | "status" | "network" | "performance" | "full"; + +export interface StatsOverlayState { + mode: StatsOverlayMode; + lines: string[]; +} + export interface HudHtmlPanelState { hidden: boolean; title: string; @@ -100,6 +107,7 @@ export interface ViewerHudState { gamePanel: HudPanelState; networkPanel: HudPanelState; performancePanel: HudPanelState; + statsOverlay: StatsOverlayState; systemPanel: HudHtmlPanelState; detailPanel: HudHtmlPanelState; error: HudErrorState; @@ -135,6 +143,10 @@ export function createViewerHudState(): ViewerHudState { summary: "Waiting", bodyText: "Waiting for frame samples.", }, + statsOverlay: { + mode: "compact", + lines: [], + }, systemPanel: { hidden: false, title: "Deep Space", diff --git a/apps/viewer/src/viewerInteractionController.ts b/apps/viewer/src/viewerInteractionController.ts index 162b291..658345a 100644 --- a/apps/viewer/src/viewerInteractionController.ts +++ b/apps/viewer/src/viewerInteractionController.ts @@ -1,20 +1,18 @@ import * as THREE from "three"; import { - completeMarqueeSelection, - hideMarqueeBox, pickSelectableHitAtClientPosition, pickSelectableAtClientPosition, updateHoverLabel, - updateMarqueeBox, } from "./viewerInteraction"; import { applyKeyboardControl, + cycleStatsOverlayMode, toggleCameraMode, navigateFromWheel, } from "./viewerControls"; -import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT, NAV_DISTANCE_SHIP_HULL } from "./viewerConstants"; +import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE_PLANET_ORBIT } from "./viewerConstants"; import { ViewerHistoryWindowController } from "./viewerHistoryWindowController"; -import type { ViewerHudState } from "./viewerHudState"; +import type { StatsOverlayMode, ViewerHudState } from "./viewerHudState"; import type { CameraMode, DragMode, @@ -61,88 +59,128 @@ export interface ViewerInteractionContext { getFollowCameraPosition: () => THREE.Vector3; getFollowCameraFocus: () => THREE.Vector3; screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2; - applyOrbitDelta: (delta: THREE.Vector2) => void; + applyPanDelta: (delta: THREE.Vector2) => void; syncFollowStateFromSelection: () => void; updatePanels: () => void; focusOnSelection: (selection: Selectable) => void; updateGamePanel: (mode: string) => void; openOrderContextMenu: (x: number, y: number, target: ViewerOrderContextMenuTarget) => void; closeOrderContextMenu: () => void; + getStatsOverlayMode: () => StatsOverlayMode; + setStatsOverlayMode: (mode: StatsOverlayMode) => void; + refreshStatsOverlay: () => void; historyController: ViewerHistoryWindowController; } export class ViewerInteractionController { + private readonly activePointers = new Map(); + private pinchStartDistance?: number; + private pinchStartZoom?: number; + private pinchLastCenter?: THREE.Vector2; + constructor(private readonly context: ViewerInteractionContext) {} readonly onPointerDown = (event: PointerEvent) => { - if (event.button === 1) { - this.context.setDragMode("orbit"); - this.context.setDragPointerId(event.pointerId); - this.context.dragLast.copy(this.context.screenPointFromClient(event.clientX, event.clientY)); - this.context.renderer.domElement.setPointerCapture(event.pointerId); - return; - } - if (event.button !== 0) { return; } - this.context.setDragMode("marquee"); - this.context.setDragPointerId(event.pointerId); - this.context.dragStart.copy(this.context.screenPointFromClient(event.clientX, event.clientY)); - this.context.dragLast.copy(this.context.dragStart); - this.context.setMarqueeActive(false); + const point = this.context.screenPointFromClient(event.clientX, event.clientY); + this.activePointers.set(event.pointerId, point); this.context.renderer.domElement.setPointerCapture(event.pointerId); + + if (this.activePointers.size >= 2) { + const gesture = this.getPinchGesture(); + if (!gesture) { + return; + } + + this.context.setSuppressClickSelection(true); + this.context.setDragMode("pinch"); + this.context.setDragPointerId(event.pointerId); + this.pinchStartDistance = gesture.distance; + this.pinchStartZoom = this.context.getDesiredDistance(); + this.pinchLastCenter = gesture.center; + return; + } + + this.context.setDragMode("pan"); + this.context.setDragPointerId(event.pointerId); + this.context.dragStart.copy(point); + this.context.dragLast.copy(point); }; readonly onPointerMove = (event: PointerEvent) => { this.updateHoverLabel(event); + const point = this.context.screenPointFromClient(event.clientX, event.clientY); + if (this.activePointers.has(event.pointerId)) { + this.activePointers.set(event.pointerId, point); + } + if (this.context.getDragPointerId() !== event.pointerId || !this.context.getDragMode()) { return; } - const point = this.context.screenPointFromClient(event.clientX, event.clientY); - if (this.context.getDragMode() === "orbit") { - const delta = point.clone().sub(this.context.dragLast); - this.context.dragLast.copy(point); - this.context.applyOrbitDelta(delta); + if (this.context.getDragMode() === "pinch") { + const gesture = this.getPinchGesture(); + if (!gesture || !this.pinchStartDistance || !this.pinchStartZoom || !this.pinchLastCenter) { + return; + } + + const zoomRatio = THREE.MathUtils.clamp(gesture.distance / this.pinchStartDistance, 0.25, 4); + this.context.setDesiredDistance(THREE.MathUtils.clamp( + this.pinchStartZoom / zoomRatio, + MIN_CAMERA_DISTANCE, + MAX_CAMERA_DISTANCE, + )); + const centerDelta = gesture.center.clone().sub(this.pinchLastCenter); + this.pinchLastCenter = gesture.center; + this.context.applyPanDelta(centerDelta); return; } + const delta = point.clone().sub(this.context.dragLast); const dragDistance = point.distanceTo(this.context.dragStart); - if (!this.context.getMarqueeActive() && dragDistance > 8) { - this.context.setMarqueeActive(true); + if (dragDistance > 6) { this.context.setSuppressClickSelection(true); - this.context.hudState.marquee.visible = true; - this.context.marqueeEl.style.display = "block"; } - - if (!this.context.getMarqueeActive()) { - return; - } - this.context.dragLast.copy(point); - updateMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl, this.context.dragStart, this.context.dragLast); + this.context.applyPanDelta(delta); }; readonly onPointerUp = (event: PointerEvent) => { - if (this.context.getDragPointerId() !== event.pointerId) { - return; - } - if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) { this.context.renderer.domElement.releasePointerCapture(event.pointerId); } + this.activePointers.delete(event.pointerId); - if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) { - this.completeMarqueeSelection(); - hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl); + if (this.activePointers.size >= 2) { + const gesture = this.getPinchGesture(); + if (gesture) { + this.context.setDragMode("pinch"); + this.pinchStartDistance = gesture.distance; + this.pinchStartZoom = this.context.getDesiredDistance(); + this.pinchLastCenter = gesture.center; + } + return; } - this.context.setDragMode(undefined); - this.context.setDragPointerId(undefined); - this.context.setMarqueeActive(false); + const remainingPointer = this.activePointers.entries().next(); + if (!remainingPointer.done) { + const [pointerId, point] = remainingPointer.value; + this.context.setDragMode("pan"); + this.context.setDragPointerId(pointerId); + this.context.dragStart.copy(point); + this.context.dragLast.copy(point); + } else { + this.context.setDragMode(undefined); + this.context.setDragPointerId(undefined); + } + + this.pinchStartDistance = undefined; + this.pinchStartZoom = undefined; + this.pinchLastCenter = undefined; }; readonly onClick = (event: MouseEvent) => { @@ -225,8 +263,7 @@ export class ViewerInteractionController { this.context.setSelectedItems([{ kind: "ship", id: shipId }]); this.context.syncFollowStateFromSelection(); this.context.focusOnSelection({ kind: "ship", id: shipId }); - this.toggleCameraMode("follow"); - this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL); + this.toggleCameraMode("tactical"); this.context.updatePanels(); this.context.updateGamePanel("Live"); return; @@ -268,8 +305,7 @@ export class ViewerInteractionController { } if (selection.kind === "ship") { - this.toggleCameraMode("follow"); - this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL); + this.toggleCameraMode("tactical"); this.context.updatePanels(); this.context.updateGamePanel("Live"); return; @@ -288,6 +324,13 @@ export class ViewerInteractionController { } const key = event.key.toLowerCase(); + if (key === "f10") { + event.preventDefault(); + this.context.setStatsOverlayMode(cycleStatsOverlayMode(this.context.getStatsOverlayMode())); + this.context.refreshStatsOverlay(); + return; + } + const controlState = applyKeyboardControl({ keyState: this.context.keyState, cameraMode: this.context.getCameraMode(), @@ -371,17 +414,17 @@ export class ViewerInteractionController { ); } - private completeMarqueeSelection() { - const selection = completeMarqueeSelection({ - renderer: this.context.renderer, - systemCamera: this.context.systemCamera, - dragStart: this.context.dragStart, - dragLast: this.context.dragLast, - systemSelectableTargets: this.context.systemSelectableTargets, - }); - this.context.setSelectedItems(selection); - this.context.syncFollowStateFromSelection(); - this.context.updatePanels(); + private getPinchGesture() { + const points = [...this.activePointers.values()]; + if (points.length < 2) { + return null; + } + + const [first, second] = points; + return { + center: first.clone().add(second).multiplyScalar(0.5), + distance: first.distanceTo(second), + }; } private shouldFocusSelectionOnClick(selection: Selectable) { diff --git a/apps/viewer/src/viewerPanels.ts b/apps/viewer/src/viewerPanels.ts index 0023a51..953e16b 100644 --- a/apps/viewer/src/viewerPanels.ts +++ b/apps/viewer/src/viewerPanels.ts @@ -248,6 +248,54 @@ function renderSystemOwnership(world: WorldState, systemId: string): string { return lines.join("
"); } +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) { const { world, @@ -525,9 +573,7 @@ export function buildSystemPanelState(params: SystemPanelParams) { return { hidden: false, title: activeSystem.label, - bodyHtml: ` -

${renderSystemOwnership(world, activeSystem.id)}

- `, + bodyHtml: describeSystemSubtitle(world, activeSystem.id), }; } diff --git a/apps/viewer/src/viewerPresentationController.ts b/apps/viewer/src/viewerPresentationController.ts index 4c6740b..84696b3 100644 --- a/apps/viewer/src/viewerPresentationController.ts +++ b/apps/viewer/src/viewerPresentationController.ts @@ -1,5 +1,6 @@ import * as THREE from "three"; import { + describeCompactStatsLine, describeNetworkPanel, describePerformancePanel, recordPerformanceStats, @@ -40,6 +41,56 @@ export interface ViewerPresentationContext { export class ViewerPresentationController { 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() { this.context.ambienceGroup.renderOrder = -10; this.context.ambienceGroup.add(createBackdropStars(document)); @@ -67,6 +118,7 @@ export class ViewerPresentationController { updateNetworkPanel() { this.context.hudState.networkPanel.bodyText = describeNetworkPanel(this.context.networkStats); this.context.hudState.networkPanel.summary = summarizeNetworkStats(this.context.networkStats); + this.refreshStatsOverlayLines(); } recordPerformanceStats(frameMs: number) { @@ -79,6 +131,7 @@ export class ViewerPresentationController { this.context.hudState.performancePanel.bodyText = bodyText; } this.context.hudState.performancePanel.summary = summarizePerformanceStats(this.context.performanceStats); + this.refreshStatsOverlayLines(); } updateShipPresentation() { @@ -116,6 +169,11 @@ export class ViewerPresentationController { }); this.context.hudState.gamePanel.bodyText = state.bodyText; this.context.hudState.gamePanel.summary = state.summaryText; + this.refreshStatsOverlayLines(state.bodyText); + } + + refreshStatsOverlay() { + this.refreshStatsOverlayLines(); } updateSystemPanel() { diff --git a/apps/viewer/src/viewerSceneDataController.ts b/apps/viewer/src/viewerSceneDataController.ts index d19fa38..2d9edaf 100644 --- a/apps/viewer/src/viewerSceneDataController.ts +++ b/apps/viewer/src/viewerSceneDataController.ts @@ -202,6 +202,7 @@ export class ViewerSceneDataController { createWorldPresentationContext(overrides: { world: any; activeSystemId?: string; + cameraMode: any; povLevel: any; orbitYaw: number; systemCamera: THREE.PerspectiveCamera; @@ -214,6 +215,7 @@ export class ViewerSceneDataController { worldTimeSyncMs: this.context.getWorldTimeSyncMs(), worldSeed: this.context.getWorldSeed(), activeSystemId: overrides.activeSystemId, + cameraMode: overrides.cameraMode, povLevel: overrides.povLevel, orbitYaw: overrides.orbitYaw, camera: overrides.systemCamera, diff --git a/apps/viewer/src/viewerSceneFactory.ts b/apps/viewer/src/viewerSceneFactory.ts index e3d4097..26a0124 100644 --- a/apps/viewer/src/viewerSceneFactory.ts +++ b/apps/viewer/src/viewerSceneFactory.ts @@ -571,19 +571,55 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n export function createShipTacticalIcon(documentRef: Document, color: string, size: number): SceneNode { const canvas = documentRef.createElement("canvas"); canvas.width = 128; - canvas.height = 96; + canvas.height = 192; const context = canvas.getContext("2d"); if (!context) { throw new Error("Unable to create ship tactical icon"); } context.clearRect(0, 0, canvas.width, canvas.height); + context.lineCap = "round"; + context.lineJoin = "round"; context.strokeStyle = color; - context.fillStyle = "rgba(7, 16, 30, 0.7)"; - context.lineWidth = 5; + context.fillStyle = "rgba(7, 16, 30, 0.8)"; + 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.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(); const texture = new THREE.CanvasTexture(canvas); @@ -593,9 +629,10 @@ export function createShipTacticalIcon(documentRef: Document, color: string, siz depthWrite: false, depthTest: false, color: "#ffffff", + fog: false, })); - sprite.center.set(0.28, 0.5); - sprite.scale.set(size * 1.7, size * 1.275, 1); + sprite.center.set(0.5, 0.08); + sprite.scale.set(size * 1.2, size * 1.8, 1); sprite.visible = false; return createSceneNode(sprite); } diff --git a/apps/viewer/src/viewerTelemetry.ts b/apps/viewer/src/viewerTelemetry.ts index 4839e40..dbbc365 100644 --- a/apps/viewer/src/viewerTelemetry.ts +++ b/apps/viewer/src/viewerTelemetry.ts @@ -41,14 +41,18 @@ export function describeNetworkPanel(networkStats: NetworkStats) { } 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 recentBytes = networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0); const recentWindowSeconds = networkStats.throughputSamples.length > 1 ? Math.max((now - networkStats.throughputSamples[0].atMs) / 1000, 1) : 1; - const kbPerSecond = recentBytes / 1024 / recentWindowSeconds; - const direction = networkStats.streamConnected ? "live" : "offline"; - return `${direction} | down ${kbPerSecond.toFixed(1)} KB/s | ${networkStats.deltasReceived} d`; + return recentBytes / 1024 / recentWindowSeconds; } export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) { @@ -116,12 +120,23 @@ export function describePerformancePanel( } 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 elapsedWindowSeconds = samples.length > 1 ? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25) : 1; - const fps = samples.length > 1 + return samples.length > 1 ? (samples.length - 1) / elapsedWindowSeconds : 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`; } diff --git a/apps/viewer/src/viewerTypes.ts b/apps/viewer/src/viewerTypes.ts index 5239b80..b49baf6 100644 --- a/apps/viewer/src/viewerTypes.ts +++ b/apps/viewer/src/viewerTypes.ts @@ -19,7 +19,7 @@ import type { export type PovLevel = "local" | "system" | "galaxy"; export type SelectionGroup = "ships" | "structures" | "celestials"; -export type DragMode = "orbit" | "marquee"; +export type DragMode = "pan" | "pinch"; export type CameraMode = "tactical" | "follow"; export type Selectable = diff --git a/apps/viewer/src/viewerWorldLifecycle.ts b/apps/viewer/src/viewerWorldLifecycle.ts index 3bd04a4..5a97bcc 100644 --- a/apps/viewer/src/viewerWorldLifecycle.ts +++ b/apps/viewer/src/viewerWorldLifecycle.ts @@ -152,7 +152,6 @@ export class ViewerWorldLifecycle { } applySnapshot(snapshot: WorldSnapshot) { - usePlayerFactionStore(viewerPinia).setPlayerFaction(null); this.context.setWorldTimeSyncMs(performance.now()); const signature = `${snapshot.seed}|${snapshot.systems.length}`; if (signature !== this.context.getWorldSignature()) { diff --git a/apps/viewer/src/viewerWorldPresentation.ts b/apps/viewer/src/viewerWorldPresentation.ts index a49a4ce..b911327 100644 --- a/apps/viewer/src/viewerWorldPresentation.ts +++ b/apps/viewer/src/viewerWorldPresentation.ts @@ -16,8 +16,6 @@ import { updateSystemStarPresentation, getAnimatedShipLocalPosition, iconWorldScale, - MIN_ICON_PIXELS, - MAX_ICON_PIXELS, } from "./viewerPresentation"; import { rawObject } from "./viewerScenePrimitives"; import type { @@ -42,6 +40,13 @@ import type { 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 { world?: WorldState; worldTimeSyncMs: number; @@ -53,6 +58,7 @@ export interface WorldOrbitalContext { export interface WorldPresentationContext extends WorldOrbitalContext { activeSystemId?: string; + cameraMode: CameraMode; povLevel: PovLevel; orbitYaw: number; camera: THREE.PerspectiveCamera; @@ -95,14 +101,22 @@ export function updateWorldPresentation(context: WorldPresentationContext) { visual.icon.setPosition(rawObject(visual.mesh).position.clone()); const shipVisible = isShipVisible(renderMode, context.activeSystemId, ship); 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( visual.iconBaseScale, - iconWorldScale(distToShip, context.camera, MIN_ICON_PIXELS), - iconWorldScale(distToShip, context.camera, MAX_ICON_PIXELS + 10), + iconWorldScale(distToShip, context.camera, SHIP_BILLBOARD_MIN_PIXELS), + iconWorldScale(distToShip, context.camera, SHIP_BILLBOARD_MAX_PIXELS), ); 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); const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw); if (desiredHeading.lengthSq() > 0.01) { @@ -135,9 +149,19 @@ export function updateWorldPresentation(context: WorldPresentationContext) { for (const visual of context.stationVisuals.values()) { 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.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()) { diff --git a/apps/viewer/vite.config.ts b/apps/viewer/vite.config.ts index ccd93aa..ac20f75 100644 --- a/apps/viewer/vite.config.ts +++ b/apps/viewer/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ port: 5174, allowedHosts: ["sobina.local"], proxy: { - "/api": "http://127.0.0.1:5079", + "/api": "http://127.0.0.1:5080", }, }, build: { diff --git a/scripts/start-postgres.sh b/scripts/start-postgres.sh new file mode 100755 index 0000000..360ee44 --- /dev/null +++ b/scripts/start-postgres.sh @@ -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}"