Compare commits
4 Commits
706e1cda8f
...
fdcf83ccec
| Author | SHA1 | Date | |
|---|---|---|---|
| fdcf83ccec | |||
| 74b8bf4116 | |||
| c9a4b474b4 | |||
| 63a9f808bb |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,3 +17,5 @@ pnpm-debug.log*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
.codex
|
||||
|
||||
22
apps/backend/Auth/Api/GetRacesHandler.cs
Normal file
22
apps/backend/Auth/Api/GetRacesHandler.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using FastEndpoints;
|
||||
using SpaceGame.Api.Universe.Bootstrap;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Api;
|
||||
|
||||
public sealed class GetRacesHandler(IStaticDataProvider staticData) : EndpointWithoutRequest<IReadOnlyList<RaceSnapshot>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/auth/races");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var races = staticData.RaceDefinitions.Values
|
||||
.OrderBy(race => race.Name, StringComparer.Ordinal)
|
||||
.Select(race => new RaceSnapshot(race.Id, race.Name, race.Description, race.Icon))
|
||||
.ToList();
|
||||
await SendOkAsync(races, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Api;
|
||||
|
||||
public sealed class RegisterHandler(AuthService authService) : Endpoint<RegisterRequest, AuthSessionResponse>
|
||||
public sealed class RegisterHandler(AuthService authService) : Endpoint<RegisterRequest, RegisterResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
7
apps/backend/Auth/Contracts/Races.cs
Normal file
7
apps/backend/Auth/Contracts/Races.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace SpaceGame.Api.Auth.Contracts;
|
||||
|
||||
public sealed record RaceSnapshot(
|
||||
string Id,
|
||||
string Name,
|
||||
string Description,
|
||||
string Icon);
|
||||
@@ -7,7 +7,7 @@ public sealed class AuthService(
|
||||
RefreshTokenFactory refreshTokenFactory,
|
||||
IPasswordResetDelivery passwordResetDelivery)
|
||||
{
|
||||
public async Task<AuthSessionResponse> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
|
||||
public async Task<RegisterResponse> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = NormalizeEmail(request.Email);
|
||||
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<AuthSessionResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,6 +4,7 @@ public interface IAuthRepository
|
||||
{
|
||||
Task<UserAccount?> FindUserByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
Task<UserAccount?> FindUserByIdAsync(Guid userId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<UserAccount>> ListUsersAsync(CancellationToken cancellationToken);
|
||||
Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
|
||||
Task<UserAccount> UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
|
||||
Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken);
|
||||
|
||||
@@ -4,5 +4,7 @@ public interface IPlayerIdentityResolver
|
||||
{
|
||||
Guid? GetCurrentPlayerId();
|
||||
Guid GetRequiredPlayerId();
|
||||
Guid? GetEffectivePlayerId();
|
||||
Guid GetRequiredEffectivePlayerId();
|
||||
bool CanAccessGm();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,23 @@ public sealed class PostgresAuthRepository(NpgsqlDataSource dataSource) : IAuthR
|
||||
return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserAccount>> ListUsersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
select id, email, password_hash, created_at_utc, roles
|
||||
from auth_users
|
||||
order by email asc
|
||||
""");
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
var users = new List<UserAccount>();
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
users.Add(ReadUser(reader));
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
public async Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class CompletePlayerOnboardingHandler(WorldService worldService) : Endpoint<CompletePlayerOnboardingRequest, PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/player-faction/onboarding");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CompletePlayerOnboardingRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = worldService.CompletePlayerOnboarding(request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(cancellation: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
74
apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs
Normal file
74
apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using FastEndpoints;
|
||||
using SpaceGame.Api.Auth.Runtime;
|
||||
using SpaceGame.Api.Auth.Simulation;
|
||||
using SpaceGame.Api.PlayerFaction.Simulation;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class GetPlayerIdentitiesHandler(IAuthRepository authRepository, IPlayerStateStore playerStateStore)
|
||||
: EndpointWithoutRequest<IReadOnlyList<PlayerIdentitySummaryResponse>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/player-faction/identities");
|
||||
Policies(AuthPolicyNames.GmAccess);
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var users = await authRepository.ListUsersAsync(cancellationToken);
|
||||
var playerFactionsById = playerStateStore.GetPlayerFactions()
|
||||
.ToDictionary(player => player.Id, StringComparer.Ordinal);
|
||||
|
||||
var responses = new List<PlayerIdentitySummaryResponse>(users.Count + playerFactionsById.Count);
|
||||
var seenIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
var userId = user.Id.ToString("N");
|
||||
playerFactionsById.TryGetValue(userId, out var playerFaction);
|
||||
responses.Add(new PlayerIdentitySummaryResponse(
|
||||
userId,
|
||||
user.Email,
|
||||
user.Roles,
|
||||
playerFaction is not null,
|
||||
playerFaction?.Id,
|
||||
playerFaction?.Label,
|
||||
playerFaction?.SovereignFactionId));
|
||||
seenIds.Add(userId);
|
||||
}
|
||||
|
||||
foreach (var playerFaction in playerStateStore.GetPlayerFactions())
|
||||
{
|
||||
if (!seenIds.Add(playerFaction.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
responses.Add(new PlayerIdentitySummaryResponse(
|
||||
playerFaction.Id,
|
||||
$"{playerFaction.Id}@unknown",
|
||||
Array.Empty<string>(),
|
||||
true,
|
||||
playerFaction.Id,
|
||||
playerFaction.Label,
|
||||
playerFaction.SovereignFactionId));
|
||||
}
|
||||
|
||||
await SendOkAsync(
|
||||
responses
|
||||
.OrderBy(response => response.Email, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(response => response.UserId, StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PlayerIdentitySummaryResponse(
|
||||
string UserId,
|
||||
string Email,
|
||||
IReadOnlyList<string> Roles,
|
||||
bool HasPlayerFaction,
|
||||
string? PlayerFactionId,
|
||||
string? PlayerFactionLabel,
|
||||
string? SovereignFactionId);
|
||||
@@ -249,7 +249,10 @@ public sealed record PlayerAlertSnapshot(
|
||||
public sealed record PlayerFactionSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string? PersonaName,
|
||||
string? RaceId,
|
||||
string SovereignFactionId,
|
||||
bool RequiresOnboarding,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SimulationEventRecord> 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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ internal static class StarterStationLayoutResolver
|
||||
string moduleId,
|
||||
string? factionId,
|
||||
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
||||
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
|
||||
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions,
|
||||
IReadOnlyDictionary<string, RecipeDefinition> 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))
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<SVGLineElement | null>(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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLandingPage v-if="!authStore.isAuthenticated" />
|
||||
<div v-else-if="!playerContextReady" class="auth-landing">
|
||||
<div class="auth-landing__backdrop" />
|
||||
<div class="auth-landing__hero">
|
||||
<h1>Preparing player context</h1>
|
||||
<p>Loading your in-universe identity and ownership state.</p>
|
||||
</div>
|
||||
</div>
|
||||
<PlayerOnboardingPanel v-else-if="playerContextReady && playerFaction?.requiresOnboarding" />
|
||||
<div v-else class="viewer-app">
|
||||
<div
|
||||
ref="canvasHostEl"
|
||||
class="viewer-canvas-host"
|
||||
/>
|
||||
<div class="pointer-events-none fixed inset-0">
|
||||
<div class="absolute left-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(360px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:right-5 max-[760px]:bottom-[148px] max-[760px]:w-auto max-[760px]:max-h-[38vh]">
|
||||
<AuthSessionPanel />
|
||||
<CollapsibleHudPanel
|
||||
v-model:collapsed="hudState.gamePanel.collapsed"
|
||||
class-name="topbar"
|
||||
panel-name="game"
|
||||
title="Game"
|
||||
:summary="hudState.gamePanel.summary"
|
||||
:body-text="hudState.gamePanel.bodyText"
|
||||
/>
|
||||
<CollapsibleHudPanel
|
||||
v-model:collapsed="hudState.networkPanel.collapsed"
|
||||
class-name="network-panel"
|
||||
panel-name="network"
|
||||
title="Network"
|
||||
:summary="hudState.networkPanel.summary"
|
||||
:body-text="hudState.networkPanel.bodyText"
|
||||
/>
|
||||
<CollapsibleHudPanel
|
||||
v-model:collapsed="hudState.performancePanel.collapsed"
|
||||
class-name="performance-panel"
|
||||
panel-name="performance"
|
||||
title="Performance"
|
||||
:summary="hudState.performancePanel.summary"
|
||||
:body-text="hudState.performancePanel.bodyText"
|
||||
/>
|
||||
<ViewerEntityBrowserPanel
|
||||
class="min-h-0 flex-1"
|
||||
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
|
||||
/>
|
||||
<div class="viewer-left-sidebar-dock">
|
||||
<section class="viewer-left-sidebar pointer-events-auto">
|
||||
<div class="viewer-left-sidebar__tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="viewer-left-sidebar__tab"
|
||||
:class="leftSidebarTab === 'player' ? 'viewer-left-sidebar__tab--active' : ''"
|
||||
@click="leftSidebarTab = 'player'"
|
||||
>
|
||||
Player Informations
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="viewer-left-sidebar__tab"
|
||||
:class="leftSidebarTab === 'entities' ? 'viewer-left-sidebar__tab--active' : ''"
|
||||
@click="leftSidebarTab = 'entities'"
|
||||
>
|
||||
Entities
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="viewer-left-sidebar__body">
|
||||
<div
|
||||
v-if="leftSidebarTab === 'player'"
|
||||
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--player"
|
||||
>
|
||||
<AuthSessionPanel />
|
||||
</div>
|
||||
<ViewerEntityBrowserPanel
|
||||
v-else
|
||||
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--entities"
|
||||
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hudState.statsOverlay.lines.length > 0"
|
||||
class="viewer-stats-overlay-dock"
|
||||
>
|
||||
<div
|
||||
class="viewer-stats-overlay"
|
||||
:class="hudState.statsOverlay.mode === 'compact' ? 'viewer-stats-overlay--compact' : ''"
|
||||
>
|
||||
<div
|
||||
v-for="(line, index) in hudState.statsOverlay.lines"
|
||||
:key="`${index}-${line}`"
|
||||
class="viewer-stats-overlay__line"
|
||||
:class="line === '' ? 'viewer-stats-overlay__line--spacer' : ''"
|
||||
>
|
||||
{{ line === "" ? "\u00A0" : line }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!hudState.systemPanel.hidden"
|
||||
class="viewer-system-label-dock"
|
||||
>
|
||||
<div class="viewer-system-label">
|
||||
<div class="viewer-system-label__title">
|
||||
{{ hudState.systemPanel.title }}
|
||||
</div>
|
||||
<div class="viewer-system-label__subtitle">
|
||||
{{ hudState.systemPanel.bodyHtml }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute right-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(380px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto">
|
||||
<HtmlInfoPanel
|
||||
class-name="system-panel-section"
|
||||
title="System"
|
||||
:subtitle="hudState.systemPanel.title"
|
||||
:body-html="hudState.systemPanel.bodyHtml"
|
||||
:hidden="hudState.systemPanel.hidden"
|
||||
subtitle-class="system-title"
|
||||
body-class="system-body"
|
||||
/>
|
||||
<ViewerEntityInspectorPanel
|
||||
class="min-h-0 flex-1"
|
||||
:fallback-title="hudState.detailPanel.title"
|
||||
@@ -222,7 +302,7 @@ async function startViewerIfAuthenticated() {
|
||||
<GmOpsWindow
|
||||
v-if="gmOpsOpen"
|
||||
@close="gmOpsOpen = false"
|
||||
@focus="(id, kind) => onFocusSelection({ kind, id }, kind === 'ship' ? 'follow' : 'tactical')"
|
||||
@focus="(id, kind) => onFocusSelection({ kind, id }, 'tactical')"
|
||||
/>
|
||||
<GmTelemetryWindow
|
||||
v-if="gmTelemetryOpen"
|
||||
|
||||
@@ -91,7 +91,7 @@ export class ViewerAppController {
|
||||
private currentDistance = NAV_DISTANCE.system;
|
||||
private desiredDistance = NAV_DISTANCE.system;
|
||||
private orbitYaw = -2.3;
|
||||
private orbitPitch = 0.62;
|
||||
private orbitPitch = 1.08;
|
||||
private cameraMode: CameraMode = "tactical";
|
||||
private dragMode?: DragMode;
|
||||
private dragPointerId?: number;
|
||||
@@ -195,6 +195,7 @@ export class ViewerAppController {
|
||||
return this.sceneDataController.createWorldPresentationContext({
|
||||
world: this.world,
|
||||
activeSystemId: this.activeSystemId,
|
||||
cameraMode: this.cameraMode,
|
||||
povLevel: this.povLevel,
|
||||
orbitYaw: this.orbitYaw,
|
||||
systemCamera: this.systemLayer.camera,
|
||||
@@ -293,7 +294,7 @@ export class ViewerAppController {
|
||||
}
|
||||
|
||||
this.updatePanFromKeyboard(delta);
|
||||
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
|
||||
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.92, 1.32);
|
||||
|
||||
const orbitOffset = this.computeOrbitOffset();
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@ import type { WorldDelta, WorldSnapshot } from "./contracts";
|
||||
import type { TelemetrySnapshot } from "./contractsTelemetry";
|
||||
import type { BalanceSettings } from "./contractsBalance";
|
||||
import type { PlayerFactionSnapshot } from "./contractsPlayerFaction";
|
||||
import type { AuthSessionResponse, ForgotPasswordResponse } from "./contractsAuth";
|
||||
import type { RaceSnapshot } from "./contractsRaces";
|
||||
import type { AuthSessionResponse, ForgotPasswordResponse, RegisterResponse } from "./contractsAuth";
|
||||
import type { PlayerIdentitySummary } from "./contractsIdentity";
|
||||
import type { ShipAutomationCatalogSnapshot } from "./contractsShipAutomation";
|
||||
import type { FactionSnapshot } from "./contractsFactions";
|
||||
import type { ShipSnapshot } from "./contractsShips";
|
||||
import type { StationSnapshot } from "./contractsInfrastructure";
|
||||
import { clearAuthSession, getAuthSession, setAuthSession } from "./authSession";
|
||||
import { getEffectivePlayerIdentityId } from "./effectiveIdentitySession";
|
||||
import type {
|
||||
PlayerAssetAssignmentCommandRequest,
|
||||
PlayerAutomationPolicyCommandRequest,
|
||||
@@ -35,6 +38,12 @@ async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit, option
|
||||
if (session?.accessToken) {
|
||||
headers.set("Authorization", `Bearer ${session.accessToken}`);
|
||||
}
|
||||
if (session?.roles.some((role) => role === "gm" || role === "admin")) {
|
||||
const effectivePlayerId = getEffectivePlayerIdentityId();
|
||||
if (effectivePlayerId) {
|
||||
headers.set("X-Act-As-Player-Id", effectivePlayerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(input, {
|
||||
@@ -160,13 +169,11 @@ export async function resetWorld() {
|
||||
}
|
||||
|
||||
export async function register(request: { email: string; password: string }) {
|
||||
const session = await fetchJson<AuthSessionResponse>("/api/auth/register", {
|
||||
return fetchJson<RegisterResponse>("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
}, { skipAuth: true, skipRefresh: true });
|
||||
setAuthSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function login(request: { email: string; password: string }) {
|
||||
@@ -199,6 +206,22 @@ export async function fetchPlayerFaction(signal?: AbortSignal) {
|
||||
return fetchJson<PlayerFactionSnapshot>("/api/player-faction", { signal });
|
||||
}
|
||||
|
||||
export async function fetchRaces(signal?: AbortSignal) {
|
||||
return fetchJson<RaceSnapshot[]>("/api/auth/races", { signal }, { skipAuth: true });
|
||||
}
|
||||
|
||||
export async function completePlayerOnboarding(request: { name: string; raceId: string }) {
|
||||
return fetchJson<PlayerFactionSnapshot>("/api/player-faction/onboarding", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPlayerIdentities(signal?: AbortSignal) {
|
||||
return fetchJson<PlayerIdentitySummary[]>("/api/player-faction/identities", { signal });
|
||||
}
|
||||
|
||||
export async function fetchShipAutomationCatalog(signal?: AbortSignal) {
|
||||
return fetchJson<ShipAutomationCatalogSnapshot>("/api/ships/catalog", { signal }, { skipAuth: true });
|
||||
}
|
||||
|
||||
@@ -56,9 +56,12 @@ async function submitLogin() {
|
||||
|
||||
async function submitRegister() {
|
||||
await execute(async () => {
|
||||
const session = await register(registerForm);
|
||||
authStore.setSession(session);
|
||||
await register(registerForm);
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
infoMessage.value = "Account created. Sign in to enter the universe.";
|
||||
pane.value = "login";
|
||||
loginForm.email = registerForm.email;
|
||||
registerForm.password = "";
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from "vue";
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { login, register } from "../api";
|
||||
import { fetchPlayerFaction, fetchPlayerIdentities, login, register } from "../api";
|
||||
import { useAuthStore } from "../ui/stores/authStore";
|
||||
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const playerFactionStore = usePlayerFactionStore();
|
||||
const { session, busy } = storeToRefs(authStore);
|
||||
const { session, busy, availablePlayerIdentities, effectivePlayerId } = storeToRefs(authStore);
|
||||
|
||||
const mode = ref<"login" | "register">("login");
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const errorMessage = ref("");
|
||||
const identityBusy = ref(false);
|
||||
const identityError = ref("");
|
||||
const forgotPasswordOpen = ref(false);
|
||||
const forgotPasswordState = reactive({
|
||||
email: "",
|
||||
});
|
||||
|
||||
const selectedIdentityId = computed({
|
||||
get: () => effectivePlayerId.value ?? "",
|
||||
set: (value: string) => {
|
||||
void switchIdentity(value || null);
|
||||
},
|
||||
});
|
||||
|
||||
const canAccessGm = computed(() => authStore.canAccessGm);
|
||||
const activeIdentitySummary = computed(() =>
|
||||
availablePlayerIdentities.value.find((entry) => entry.userId === (effectivePlayerId.value ?? session.value?.userId ?? "")) ?? null,
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
errorMessage.value = "";
|
||||
authStore.setBusy(true);
|
||||
try {
|
||||
const snapshot = mode.value === "login"
|
||||
? await login({ email: email.value, password: password.value })
|
||||
: await register({ email: email.value, password: password.value });
|
||||
authStore.setSession(snapshot);
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
password.value = "";
|
||||
forgotPasswordOpen.value = false;
|
||||
if (mode.value === "login") {
|
||||
const snapshot = await login({ email: email.value, password: password.value });
|
||||
authStore.setSession(snapshot);
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
password.value = "";
|
||||
forgotPasswordOpen.value = false;
|
||||
} else {
|
||||
await register({ email: email.value, password: password.value });
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
errorMessage.value = "";
|
||||
mode.value = "login";
|
||||
password.value = "";
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Authentication failed.";
|
||||
} finally {
|
||||
@@ -42,6 +62,59 @@ function logout() {
|
||||
errorMessage.value = "";
|
||||
password.value = "";
|
||||
}
|
||||
|
||||
async function refreshPlayerContext() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
playerFactionStore.setPlayerFaction(await fetchPlayerFaction());
|
||||
} catch {
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlayerIdentities() {
|
||||
if (!authStore.isAuthenticated || !authStore.canAccessGm) {
|
||||
authStore.setAvailablePlayerIdentities([]);
|
||||
return;
|
||||
}
|
||||
|
||||
identityBusy.value = true;
|
||||
identityError.value = "";
|
||||
try {
|
||||
authStore.setAvailablePlayerIdentities(await fetchPlayerIdentities());
|
||||
} catch (error) {
|
||||
identityError.value = error instanceof Error ? error.message : "Unable to load player identities.";
|
||||
authStore.setAvailablePlayerIdentities([]);
|
||||
} finally {
|
||||
identityBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function switchIdentity(nextPlayerId: string | null) {
|
||||
authStore.setEffectivePlayerId(nextPlayerId);
|
||||
identityBusy.value = true;
|
||||
identityError.value = "";
|
||||
try {
|
||||
await refreshPlayerContext();
|
||||
} catch (error) {
|
||||
identityError.value = error instanceof Error ? error.message : "Unable to switch current identity.";
|
||||
} finally {
|
||||
identityBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => session.value?.userId ?? null,
|
||||
async () => {
|
||||
await loadPlayerIdentities();
|
||||
await refreshPlayerContext();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -52,6 +125,29 @@ function logout() {
|
||||
<div class="text-xs uppercase tracking-[0.22em] text-white/45">Identity</div>
|
||||
<div class="mt-1 text-sm font-semibold">{{ session.email }}</div>
|
||||
<div class="mt-1 text-xs text-white/55">Player {{ session.userId.slice(0, 8) }}</div>
|
||||
<div v-if="canAccessGm" class="mt-3">
|
||||
<div class="text-[10px] uppercase tracking-[0.18em] text-white/45">Current Identity</div>
|
||||
<select
|
||||
v-model="selectedIdentityId"
|
||||
class="mt-1 w-full rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm outline-none transition focus:border-white/30"
|
||||
:disabled="identityBusy"
|
||||
>
|
||||
<option value="">GM Self</option>
|
||||
<option
|
||||
v-for="identity in availablePlayerIdentities"
|
||||
:key="identity.userId"
|
||||
:value="identity.userId"
|
||||
>
|
||||
{{ identity.email }}{{ identity.playerFactionLabel ? ` · ${identity.playerFactionLabel}` : "" }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="mt-1 text-[11px] text-white/50">
|
||||
Acting as
|
||||
{{ activeIdentitySummary?.email ?? session.email }}
|
||||
<span v-if="activeIdentitySummary?.playerFactionLabel"> · {{ activeIdentitySummary.playerFactionLabel }}</span>
|
||||
</div>
|
||||
<div v-if="identityError" class="mt-2 text-[11px] text-[#ffd8cf]">{{ identityError }}</div>
|
||||
</div>
|
||||
<div v-if="session.roles.length > 0" class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="role in session.roles"
|
||||
|
||||
115
apps/viewer/src/components/PlayerOnboardingPanel.vue
Normal file
115
apps/viewer/src/components/PlayerOnboardingPanel.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { completePlayerOnboarding, fetchRaces } from "../api";
|
||||
import type { RaceSnapshot } from "../contractsRaces";
|
||||
import { useAuthStore } from "../ui/stores/authStore";
|
||||
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const playerFactionStore = usePlayerFactionStore();
|
||||
|
||||
const busy = ref(false);
|
||||
const loadingRaces = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const raceOptions = ref<RaceSnapshot[]>([]);
|
||||
const form = reactive({
|
||||
name: "",
|
||||
raceId: "",
|
||||
});
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
form.name.trim().length >= 2 && form.raceId.trim().length > 0 && !busy.value && !loadingRaces.value,
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
loadingRaces.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
raceOptions.value = (await fetchRaces()).sort((left, right) => left.name.localeCompare(right.name));
|
||||
if (!form.raceId && raceOptions.value.length > 0) {
|
||||
form.raceId = raceOptions.value[0].id;
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Unable to load race options.";
|
||||
} finally {
|
||||
loadingRaces.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const snapshot = await completePlayerOnboarding({
|
||||
name: form.name.trim(),
|
||||
raceId: form.raceId,
|
||||
});
|
||||
playerFactionStore.setPlayerFaction(snapshot);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Unable to create your pilot.";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
authStore.clearSession();
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-landing">
|
||||
<div class="auth-landing__backdrop" />
|
||||
|
||||
<div class="auth-landing__hero">
|
||||
<h1>Create your pilot</h1>
|
||||
<p>
|
||||
This account has access to the universe, but it does not have an in-game identity yet. Choose a name and an origin faction, then you will start with a single basic ship.
|
||||
</p>
|
||||
<div class="auth-card">
|
||||
<h2>First Login Setup</h2>
|
||||
|
||||
<form class="auth-card__form" @submit.prevent="submit">
|
||||
<input
|
||||
v-model.trim="form.name"
|
||||
type="text"
|
||||
autocomplete="nickname"
|
||||
maxlength="48"
|
||||
placeholder="Pilot name"
|
||||
>
|
||||
<select v-model="form.raceId" :disabled="loadingRaces || busy">
|
||||
<option value="" disabled>Select a race</option>
|
||||
<option
|
||||
v-for="race in raceOptions"
|
||||
:key="race.id"
|
||||
:value="race.id"
|
||||
>
|
||||
{{ race.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="submit" :disabled="!canSubmit">
|
||||
{{ busy ? "Entering universe..." : "Create pilot" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-if="loadingRaces" class="auth-card__message auth-card__message--info">
|
||||
Loading faction options...
|
||||
</div>
|
||||
<div v-if="errorMessage" class="auth-card__message auth-card__message--error">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="auth-card__footer">
|
||||
<button type="button" class="auth-card__link" @click="signOut">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import type { StationSnapshot } from "../contractsInfrastructure";
|
||||
import type { PlayerFleetSnapshot } from "../contractsPlayerFaction";
|
||||
import type { ShipSnapshot } from "../contractsShips";
|
||||
import { getShipBehaviorLabel } from "../shipAutomationPresentation";
|
||||
import { useGmStore } from "../ui/stores/gmStore";
|
||||
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
|
||||
@@ -9,22 +12,27 @@ import { useViewerSelectionStore, type ViewerSelectionSummary } from "../ui/stor
|
||||
import type { Selectable } from "../viewerTypes";
|
||||
|
||||
type BrowserTab = "visible" | "owned";
|
||||
type BrowserSortKey = "entity" | "location" | "ai" | "hp";
|
||||
type BrowserRowKind = "system" | "station" | "fleet" | "ship";
|
||||
|
||||
interface BrowserItem {
|
||||
interface BrowserRow {
|
||||
key: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
meta?: string;
|
||||
kind: BrowserRowKind;
|
||||
kindLabel: string;
|
||||
name: string;
|
||||
ident: string;
|
||||
location: string;
|
||||
aiStates: string[];
|
||||
hpLabel: string;
|
||||
hpValue: number;
|
||||
selection?: ViewerSelectionSummary;
|
||||
focusSelection?: Selectable;
|
||||
focusMode?: "follow" | "tactical";
|
||||
children: BrowserRow[];
|
||||
}
|
||||
|
||||
interface BrowserSection {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
items: BrowserItem[];
|
||||
interface BrowserDisplayRow extends BrowserRow {
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -40,22 +48,28 @@ const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
|
||||
const { playerFaction } = storeToRefs(playerStore);
|
||||
const { activeSystemId, povLevel } = storeToRefs(sceneStore);
|
||||
|
||||
const activeTab = ref<BrowserTab>("visible");
|
||||
const activeTab = ref<BrowserTab>("owned");
|
||||
const sortKey = ref<BrowserSortKey>("entity");
|
||||
const sortDirection = ref<"asc" | "desc">("asc");
|
||||
const searchText = ref("");
|
||||
const expandedRowKeys = ref<Record<string, boolean>>({});
|
||||
|
||||
const systemById = computed(() => new Map(gmStore.systems.map((system) => [system.id, system])));
|
||||
const stationById = computed(() => new Map(gmStore.stations.map((station) => [station.id, station])));
|
||||
const playerFleetByShipId = computed(() => {
|
||||
const mapping = new Map<string, PlayerFleetSnapshot>();
|
||||
for (const fleet of playerFaction.value?.fleets ?? []) {
|
||||
for (const assetId of fleet.assetIds) {
|
||||
mapping.set(assetId, fleet);
|
||||
}
|
||||
}
|
||||
return mapping;
|
||||
});
|
||||
|
||||
function normalize(text: string) {
|
||||
return text.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function matchesSearch(item: BrowserItem, search: string) {
|
||||
if (!search) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = `${item.label} ${item.subtitle} ${item.meta ?? ""}`.toLowerCase();
|
||||
return haystack.includes(search);
|
||||
}
|
||||
|
||||
function titleCase(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "Unknown";
|
||||
@@ -69,168 +83,387 @@ function titleCase(value: string | null | undefined) {
|
||||
.replace(/\b\w/g, (part) => part.toUpperCase());
|
||||
}
|
||||
|
||||
function buildVisibleSections(): BrowserSection[] {
|
||||
const sections: BrowserSection[] = [];
|
||||
function compactLabel(value: string | null | undefined, fallback: string) {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const words = titleCase(value).split(" ");
|
||||
if (words.length === 1) {
|
||||
return words[0].slice(0, 4).toUpperCase();
|
||||
}
|
||||
return words
|
||||
.slice(0, 2)
|
||||
.map((word) => word.slice(0, 3).toUpperCase())
|
||||
.join("-");
|
||||
}
|
||||
|
||||
function shortId(value: string) {
|
||||
if (value.length <= 8) {
|
||||
return value.toUpperCase();
|
||||
}
|
||||
return `${value.slice(0, 4).toUpperCase()}-${value.slice(-4).toUpperCase()}`;
|
||||
}
|
||||
|
||||
function uniqueTokens(tokens: string[]) {
|
||||
return tokens.filter((token, index) => token.length > 0 && tokens.indexOf(token) === index);
|
||||
}
|
||||
|
||||
function formatShipLocation(ship: ShipSnapshot) {
|
||||
const dockedStation = ship.dockedStationId ? stationById.value.get(ship.dockedStationId) : undefined;
|
||||
if (dockedStation) {
|
||||
return `Docked ${dockedStation.label}`;
|
||||
}
|
||||
|
||||
if (ship.spatialState.transit?.destinationNodeId) {
|
||||
return `Transit ${ship.systemId}`;
|
||||
}
|
||||
|
||||
if (ship.celestialId) {
|
||||
return `Orbit ${titleCase(ship.celestialId)}`;
|
||||
}
|
||||
|
||||
const system = systemById.value.get(ship.systemId);
|
||||
return system?.label ?? ship.systemId;
|
||||
}
|
||||
|
||||
function formatStationLocation(station: StationSnapshot) {
|
||||
const system = systemById.value.get(station.systemId);
|
||||
if (station.celestialId) {
|
||||
return `${system?.label ?? station.systemId} · ${titleCase(station.celestialId)}`;
|
||||
}
|
||||
|
||||
return system?.label ?? station.systemId;
|
||||
}
|
||||
|
||||
function shipAiStates(ship: ShipSnapshot) {
|
||||
const travelToken = ship.spatialState.transit ? "TRV" : "";
|
||||
const dockToken = ship.dockedStationId ? "DCK" : "";
|
||||
const behaviorToken = compactLabel(getShipBehaviorLabel(ship.defaultBehavior.kind), "AUTO");
|
||||
const planToken = ship.activePlan?.steps.length ? "PLAN" : "";
|
||||
const orderToken = ship.orderQueue.length > 0 ? "ORD" : "";
|
||||
const commandToken = ship.commanderId ? "CMD" : "";
|
||||
|
||||
return uniqueTokens([behaviorToken, orderToken, planToken, travelToken, dockToken, commandToken]).slice(0, 5);
|
||||
}
|
||||
|
||||
function stationAiStates(station: StationSnapshot) {
|
||||
return uniqueTokens([
|
||||
station.currentProcesses.length > 0 ? "PROC" : "",
|
||||
station.dockedShips > 0 ? "DCK" : "",
|
||||
station.commanderId ? "CMD" : "",
|
||||
]);
|
||||
}
|
||||
|
||||
function fleetAiStates(fleet: PlayerFleetSnapshot) {
|
||||
return uniqueTokens([
|
||||
compactLabel(fleet.status, "STAT"),
|
||||
compactLabel(fleet.role, "ROLE"),
|
||||
fleet.commanderId ? "CMD" : "",
|
||||
]);
|
||||
}
|
||||
|
||||
function systemAiStates(systemId: string) {
|
||||
const stations = gmStore.stations.filter((station) => station.systemId === systemId).length;
|
||||
const ships = gmStore.ships.filter((ship) => ship.systemId === systemId).length;
|
||||
return uniqueTokens([stations > 0 ? `ST${stations}` : "", ships > 0 ? `SH${ships}` : ""]);
|
||||
}
|
||||
|
||||
function buildShipRow(ship: ShipSnapshot): BrowserRow {
|
||||
return {
|
||||
key: `ship-${ship.id}`,
|
||||
kind: "ship",
|
||||
kindLabel: "SH",
|
||||
name: ship.name,
|
||||
ident: `${titleCase(ship.type)} · ${shortId(ship.id)}`,
|
||||
location: formatShipLocation(ship),
|
||||
aiStates: shipAiStates(ship),
|
||||
hpLabel: Math.round(ship.health).toString(),
|
||||
hpValue: ship.health,
|
||||
selection: { id: ship.id, kind: "ship", label: ship.name },
|
||||
focusSelection: { kind: "ship", id: ship.id },
|
||||
focusMode: "tactical",
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildStationRow(station: StationSnapshot, children: BrowserRow[]): BrowserRow {
|
||||
return {
|
||||
key: `station-${station.id}`,
|
||||
kind: "station",
|
||||
kindLabel: "ST",
|
||||
name: station.label,
|
||||
ident: `${titleCase(station.category)} · ${titleCase(station.objective)}`,
|
||||
location: formatStationLocation(station),
|
||||
aiStates: stationAiStates(station),
|
||||
hpLabel: "--",
|
||||
hpValue: -1,
|
||||
selection: { id: station.id, kind: "station", label: station.label },
|
||||
focusSelection: { kind: "station", id: station.id },
|
||||
focusMode: "tactical",
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFleetRow(fleet: PlayerFleetSnapshot, children: BrowserRow[]): BrowserRow {
|
||||
const homeStation = fleet.homeStationId ? stationById.value.get(fleet.homeStationId) : undefined;
|
||||
const homeSystem = fleet.homeSystemId ? systemById.value.get(fleet.homeSystemId) : undefined;
|
||||
return {
|
||||
key: `fleet-${fleet.id}`,
|
||||
kind: "fleet",
|
||||
kindLabel: "FL",
|
||||
name: fleet.label,
|
||||
ident: `${titleCase(fleet.role)} · ${shortId(fleet.id)}`,
|
||||
location: homeStation ? `Home ${homeStation.label}` : (homeSystem?.label ?? "No home"),
|
||||
aiStates: fleetAiStates(fleet),
|
||||
hpLabel: `${children.length}`,
|
||||
hpValue: children.length,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSystemRow(systemId: string): BrowserRow | null {
|
||||
const system = systemById.value.get(systemId);
|
||||
if (!system) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: `system-${system.id}`,
|
||||
kind: "system",
|
||||
kindLabel: "SY",
|
||||
name: system.label,
|
||||
ident: shortId(system.id),
|
||||
location: "Galaxy",
|
||||
aiStates: systemAiStates(system.id),
|
||||
hpLabel: "--",
|
||||
hpValue: -1,
|
||||
selection: { id: system.id, kind: "system", label: system.label },
|
||||
focusSelection: { kind: "system", id: system.id },
|
||||
focusMode: "tactical",
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisibleRows() {
|
||||
if (povLevel.value === "galaxy" || !activeSystemId.value) {
|
||||
const systems = [...gmStore.systems]
|
||||
.sort((left, right) => left.label.localeCompare(right.label))
|
||||
.map<BrowserItem>((system) => ({
|
||||
key: `system-${system.id}`,
|
||||
label: system.label,
|
||||
subtitle: `${system.planets.length} planets · ${system.stars.length} stars`,
|
||||
meta: system.id,
|
||||
selection: { id: system.id, kind: "system", label: system.label },
|
||||
focusSelection: { kind: "system", id: system.id },
|
||||
focusMode: "tactical",
|
||||
}));
|
||||
sections.push({
|
||||
key: "systems",
|
||||
label: "Systems",
|
||||
count: systems.length,
|
||||
items: systems,
|
||||
});
|
||||
return sections;
|
||||
return gmStore.systems
|
||||
.map((system) => buildSystemRow(system.id))
|
||||
.filter((row): row is BrowserRow => row != null);
|
||||
}
|
||||
|
||||
const systemId = activeSystemId.value;
|
||||
const ships = gmStore.ships
|
||||
.filter((ship) => ship.systemId === systemId)
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map<BrowserItem>((ship) => ({
|
||||
key: `ship-${ship.id}`,
|
||||
label: ship.name,
|
||||
subtitle: `${titleCase(ship.type)} · ${titleCase(ship.state)}`,
|
||||
meta: `${getShipBehaviorLabel(ship.defaultBehavior.kind)}${ship.defaultBehavior.itemId ? ` · ${ship.defaultBehavior.itemId}` : ""}`,
|
||||
selection: { id: ship.id, kind: "ship", label: ship.name },
|
||||
focusSelection: { kind: "ship", id: ship.id },
|
||||
focusMode: "follow",
|
||||
}));
|
||||
const stations = gmStore.stations
|
||||
.filter((station) => station.systemId === systemId)
|
||||
.sort((left, right) => left.label.localeCompare(right.label))
|
||||
.map<BrowserItem>((station) => ({
|
||||
key: `station-${station.id}`,
|
||||
label: station.label,
|
||||
subtitle: `${titleCase(station.category)} · Docked ${station.dockedShips}/${station.dockingPads}`,
|
||||
meta: station.factionId,
|
||||
selection: { id: station.id, kind: "station", label: station.label },
|
||||
focusSelection: { kind: "station", id: station.id },
|
||||
focusMode: "tactical",
|
||||
}));
|
||||
const stations = gmStore.stations.filter((station) => station.systemId === systemId);
|
||||
const ships = gmStore.ships.filter((ship) => ship.systemId === systemId);
|
||||
const stationIds = new Set(stations.map((station) => station.id));
|
||||
const stationChildren = new Map<string, BrowserRow[]>();
|
||||
const fleetChildren = new Map<string, BrowserRow[]>();
|
||||
const independentShips: BrowserRow[] = [];
|
||||
|
||||
sections.push({
|
||||
key: "ships",
|
||||
label: "Ships",
|
||||
count: ships.length,
|
||||
items: ships,
|
||||
});
|
||||
sections.push({
|
||||
key: "stations",
|
||||
label: "Stations",
|
||||
count: stations.length,
|
||||
items: stations,
|
||||
});
|
||||
for (const ship of ships) {
|
||||
const row = buildShipRow(ship);
|
||||
|
||||
return sections;
|
||||
if (ship.dockedStationId && stationIds.has(ship.dockedStationId)) {
|
||||
const children = stationChildren.get(ship.dockedStationId) ?? [];
|
||||
children.push(row);
|
||||
stationChildren.set(ship.dockedStationId, children);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fleet = playerFleetByShipId.value.get(ship.id);
|
||||
if (fleet) {
|
||||
const children = fleetChildren.get(fleet.id) ?? [];
|
||||
children.push(row);
|
||||
fleetChildren.set(fleet.id, children);
|
||||
continue;
|
||||
}
|
||||
|
||||
independentShips.push(row);
|
||||
}
|
||||
|
||||
const stationRows = stations.map((station) => buildStationRow(station, stationChildren.get(station.id) ?? []));
|
||||
const fleetRows = (playerFaction.value?.fleets ?? [])
|
||||
.filter((fleet) => (fleetChildren.get(fleet.id)?.length ?? 0) > 0)
|
||||
.map((fleet) => buildFleetRow(fleet, fleetChildren.get(fleet.id) ?? []));
|
||||
|
||||
return [...stationRows, ...fleetRows, ...independentShips];
|
||||
}
|
||||
|
||||
function buildOwnedSections(): BrowserSection[] {
|
||||
function buildOwnedRows() {
|
||||
const player = playerFaction.value;
|
||||
if (!player) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ships = player.assetRegistry.shipIds
|
||||
const ownedShips = player.assetRegistry.shipIds
|
||||
.map((shipId) => gmStore.ships.find((ship) => ship.id === shipId))
|
||||
.filter((ship): ship is NonNullable<typeof ship> => ship != null)
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map<BrowserItem>((ship) => ({
|
||||
key: `owned-ship-${ship.id}`,
|
||||
label: ship.name,
|
||||
subtitle: `${ship.systemId} · ${titleCase(ship.state)}`,
|
||||
meta: getShipBehaviorLabel(ship.defaultBehavior.kind),
|
||||
selection: { id: ship.id, kind: "ship", label: ship.name },
|
||||
focusSelection: { kind: "ship", id: ship.id },
|
||||
focusMode: "follow",
|
||||
}));
|
||||
const stations = player.assetRegistry.stationIds
|
||||
.filter((ship): ship is ShipSnapshot => ship != null);
|
||||
const ownedStations = player.assetRegistry.stationIds
|
||||
.map((stationId) => gmStore.stations.find((station) => station.id === stationId))
|
||||
.filter((station): station is NonNullable<typeof station> => station != null)
|
||||
.sort((left, right) => left.label.localeCompare(right.label))
|
||||
.map<BrowserItem>((station) => ({
|
||||
key: `owned-station-${station.id}`,
|
||||
label: station.label,
|
||||
subtitle: `${station.systemId} · ${titleCase(station.category)}`,
|
||||
meta: `${station.installedModules.length} modules`,
|
||||
selection: { id: station.id, kind: "station", label: station.label },
|
||||
focusSelection: { kind: "station", id: station.id },
|
||||
focusMode: "tactical",
|
||||
}));
|
||||
const fleets = player.fleets
|
||||
.slice()
|
||||
.sort((left, right) => left.label.localeCompare(right.label))
|
||||
.map<BrowserItem>((fleet) => ({
|
||||
key: `fleet-${fleet.id}`,
|
||||
label: fleet.label,
|
||||
subtitle: `${titleCase(fleet.role)} · ${titleCase(fleet.status)}`,
|
||||
meta: `${fleet.assetIds.length} assets`,
|
||||
}));
|
||||
.filter((station): station is StationSnapshot => station != null);
|
||||
const ownedFleetShipIds = new Set(player.fleets.flatMap((fleet) => fleet.assetIds));
|
||||
const ownedStationIds = new Set(ownedStations.map((station) => station.id));
|
||||
|
||||
return [
|
||||
{
|
||||
key: "owned-fleets",
|
||||
label: "Fleets",
|
||||
count: fleets.length,
|
||||
items: fleets,
|
||||
},
|
||||
{
|
||||
key: "owned-stations",
|
||||
label: "Stations",
|
||||
count: stations.length,
|
||||
items: stations,
|
||||
},
|
||||
{
|
||||
key: "owned-ships",
|
||||
label: "Ships",
|
||||
count: ships.length,
|
||||
items: ships,
|
||||
},
|
||||
];
|
||||
const stationChildren = new Map<string, BrowserRow[]>();
|
||||
for (const ship of ownedShips) {
|
||||
if (!ship.dockedStationId || !ownedStationIds.has(ship.dockedStationId) || ownedFleetShipIds.has(ship.id)) {
|
||||
continue;
|
||||
}
|
||||
const children = stationChildren.get(ship.dockedStationId) ?? [];
|
||||
children.push(buildShipRow(ship));
|
||||
stationChildren.set(ship.dockedStationId, children);
|
||||
}
|
||||
|
||||
const stationRows = ownedStations.map((station) => buildStationRow(station, stationChildren.get(station.id) ?? []));
|
||||
const fleetRows = player.fleets.map((fleet) => buildFleetRow(
|
||||
fleet,
|
||||
fleet.assetIds
|
||||
.map((shipId) => ownedShips.find((ship) => ship.id === shipId))
|
||||
.filter((ship): ship is ShipSnapshot => ship != null)
|
||||
.map((ship) => buildShipRow(ship)),
|
||||
));
|
||||
const independentShips = ownedShips
|
||||
.filter((ship) => !ownedFleetShipIds.has(ship.id) && (!ship.dockedStationId || !ownedStationIds.has(ship.dockedStationId)))
|
||||
.map((ship) => buildShipRow(ship));
|
||||
|
||||
return [...stationRows, ...fleetRows, ...independentShips];
|
||||
}
|
||||
|
||||
const filteredSections = computed(() => {
|
||||
const search = normalize(searchText.value);
|
||||
const sections = activeTab.value === "visible" ? buildVisibleSections() : buildOwnedSections();
|
||||
return sections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) => matchesSearch(item, search)),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
function getRowSortValue(row: BrowserRow, key: BrowserSortKey) {
|
||||
if (key === "hp") {
|
||||
return row.hpValue;
|
||||
}
|
||||
|
||||
if (key === "location") {
|
||||
return row.location;
|
||||
}
|
||||
|
||||
if (key === "ai") {
|
||||
return row.aiStates.join(" ");
|
||||
}
|
||||
|
||||
return `${row.name} ${row.ident}`;
|
||||
}
|
||||
|
||||
function sortRows(rows: BrowserRow[]): BrowserRow[] {
|
||||
const direction = sortDirection.value === "asc" ? 1 : -1;
|
||||
return [...rows]
|
||||
.sort((left, right) => {
|
||||
const leftValue = getRowSortValue(left, sortKey.value);
|
||||
const rightValue = getRowSortValue(right, sortKey.value);
|
||||
|
||||
if (typeof leftValue === "number" && typeof rightValue === "number") {
|
||||
return (leftValue - rightValue) * direction;
|
||||
}
|
||||
|
||||
return String(leftValue).localeCompare(String(rightValue)) * direction;
|
||||
})
|
||||
.map((row) => ({
|
||||
...row,
|
||||
children: sortRows(row.children),
|
||||
}));
|
||||
}
|
||||
|
||||
function rowMatches(row: BrowserRow, search: string) {
|
||||
if (!search) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = `${row.name} ${row.ident} ${row.location} ${row.aiStates.join(" ")} ${row.hpLabel}`.toLowerCase();
|
||||
return haystack.includes(search);
|
||||
}
|
||||
|
||||
function isExpanded(row: BrowserRow) {
|
||||
if (row.children.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return expandedRowKeys.value[row.key] ?? true;
|
||||
}
|
||||
|
||||
function flattenRows(rows: BrowserRow[], search: string, depth = 0, forceExpand = false): BrowserDisplayRow[] {
|
||||
const flattened: BrowserDisplayRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const descendantMatches = flattenRows(row.children, search, depth + 1, forceExpand);
|
||||
const matches = rowMatches(row, search);
|
||||
if (!matches && descendantMatches.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
flattened.push({
|
||||
...row,
|
||||
depth,
|
||||
});
|
||||
|
||||
if (row.children.length > 0 && (forceExpand || isExpanded(row))) {
|
||||
flattened.push(...descendantMatches);
|
||||
}
|
||||
}
|
||||
|
||||
return flattened;
|
||||
}
|
||||
|
||||
const rawRows = computed(() => {
|
||||
if (activeTab.value === "owned") {
|
||||
return buildOwnedRows();
|
||||
}
|
||||
return buildVisibleRows();
|
||||
});
|
||||
|
||||
function selectItem(item: BrowserItem) {
|
||||
if (!item.selection) {
|
||||
const displayRows = computed(() => {
|
||||
const search = normalize(searchText.value);
|
||||
const sortedRows = sortRows(rawRows.value);
|
||||
return flattenRows(sortedRows, search, 0, search.length > 0);
|
||||
});
|
||||
|
||||
function toggleSort(nextKey: BrowserSortKey) {
|
||||
if (sortKey.value === nextKey) {
|
||||
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
return;
|
||||
}
|
||||
|
||||
selectionStore.selectSelection(item.selection, "ui");
|
||||
sortKey.value = nextKey;
|
||||
sortDirection.value = "asc";
|
||||
}
|
||||
|
||||
function focusItem(item: BrowserItem) {
|
||||
if (item.selection) {
|
||||
selectionStore.selectSelection(item.selection, "ui");
|
||||
function toggleRow(row: BrowserDisplayRow) {
|
||||
if (row.children.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (item.focusSelection) {
|
||||
emit("focus", item.focusSelection, item.focusMode);
|
||||
|
||||
expandedRowKeys.value[row.key] = !isExpanded(row);
|
||||
}
|
||||
|
||||
function selectItem(row: BrowserDisplayRow) {
|
||||
if (!row.selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionStore.selectSelection(row.selection, "ui");
|
||||
}
|
||||
|
||||
function focusItem(row: BrowserDisplayRow) {
|
||||
if (row.selection) {
|
||||
selectionStore.selectSelection(row.selection, "ui");
|
||||
}
|
||||
if (row.focusSelection) {
|
||||
emit("focus", row.focusSelection, row.focusMode);
|
||||
}
|
||||
}
|
||||
|
||||
function isSelected(item: BrowserItem) {
|
||||
return !!item.selection
|
||||
&& item.selection.id === selectedEntityId.value
|
||||
&& item.selection.kind === selectedEntityKind.value;
|
||||
function isSelected(row: BrowserDisplayRow) {
|
||||
return !!row.selection
|
||||
&& row.selection.id === selectedEntityId.value
|
||||
&& row.selection.kind === selectedEntityKind.value;
|
||||
}
|
||||
|
||||
function sortMarker(key: BrowserSortKey) {
|
||||
if (sortKey.value !== key) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return sortDirection.value === "asc" ? " ▲" : " ▼";
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -276,47 +509,91 @@ function isSelected(item: BrowserItem) {
|
||||
<div v-if="activeTab === 'owned' && !playerFaction" class="entity-browser-panel__empty">
|
||||
No player-owned assets yet.
|
||||
</div>
|
||||
<div v-else-if="filteredSections.length === 0" class="entity-browser-panel__empty">
|
||||
<div v-else-if="displayRows.length === 0" class="entity-browser-panel__empty">
|
||||
Nothing matches the current view.
|
||||
</div>
|
||||
<div v-else class="entity-browser-panel__sections">
|
||||
<section
|
||||
v-for="section in filteredSections"
|
||||
:key="section.key"
|
||||
class="entity-browser-section"
|
||||
>
|
||||
<header class="entity-browser-section__header">
|
||||
<span>{{ section.label }}</span>
|
||||
<span>{{ section.items.length }}</span>
|
||||
</header>
|
||||
<div class="entity-browser-section__items">
|
||||
<div
|
||||
v-for="item in section.items"
|
||||
:key="item.key"
|
||||
class="entity-browser-item"
|
||||
:class="isSelected(item) ? 'entity-browser-item--selected' : ''"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="entity-browser-item__body"
|
||||
:disabled="!item.selection"
|
||||
@click="selectItem(item)"
|
||||
<div class="entity-browser-table-wrap">
|
||||
<table class="entity-browser-table entity-browser-table--tree">
|
||||
<colgroup>
|
||||
<col class="entity-browser-table__col entity-browser-table__col--entity">
|
||||
<col class="entity-browser-table__col entity-browser-table__col--ident">
|
||||
<col class="entity-browser-table__col entity-browser-table__col--location">
|
||||
<col class="entity-browser-table__col entity-browser-table__col--ai">
|
||||
<col class="entity-browser-table__col entity-browser-table__col--hp">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<button type="button" class="entity-browser-table__sort" @click="toggleSort('entity')">
|
||||
Entity{{ sortMarker("entity") }}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col">Ident</th>
|
||||
<th scope="col">
|
||||
<button type="button" class="entity-browser-table__sort" @click="toggleSort('location')">
|
||||
Location{{ sortMarker("location") }}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col">
|
||||
<button type="button" class="entity-browser-table__sort" @click="toggleSort('ai')">
|
||||
AI{{ sortMarker("ai") }}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="entity-browser-table__numeric">
|
||||
<button type="button" class="entity-browser-table__sort" @click="toggleSort('hp')">
|
||||
HP{{ sortMarker("hp") }}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in displayRows"
|
||||
:key="row.key"
|
||||
class="entity-browser-table__row"
|
||||
:class="isSelected(row) ? 'entity-browser-table__row--selected' : ''"
|
||||
@click="selectItem(row)"
|
||||
@dblclick="focusItem(row)"
|
||||
>
|
||||
<div class="entity-browser-item__label">{{ item.label }}</div>
|
||||
<div class="entity-browser-item__subtitle">{{ item.subtitle }}</div>
|
||||
<div v-if="item.meta" class="entity-browser-item__meta">{{ item.meta }}</div>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.focusSelection"
|
||||
type="button"
|
||||
class="entity-browser-item__focus"
|
||||
@click.stop="focusItem(item)"
|
||||
>
|
||||
Focus
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<td class="entity-browser-table__name">
|
||||
<div class="entity-browser-row" :style="{ paddingLeft: `${row.depth * 0.9}rem` }">
|
||||
<button
|
||||
v-if="row.children.length > 0"
|
||||
type="button"
|
||||
class="entity-browser-row__toggle"
|
||||
@click.stop="toggleRow(row)"
|
||||
>
|
||||
{{ isExpanded(row) ? "-" : "+" }}
|
||||
</button>
|
||||
<span v-else class="entity-browser-row__toggle entity-browser-row__toggle--spacer" />
|
||||
<span class="entity-browser-row__kind" :class="`entity-browser-row__kind--${row.kind}`">
|
||||
{{ row.kindLabel }}
|
||||
</span>
|
||||
<span class="entity-browser-row__label">{{ row.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="entity-browser-table__detail entity-browser-table__cell--truncate">{{ row.ident }}</td>
|
||||
<td class="entity-browser-table__cell--truncate">{{ row.location }}</td>
|
||||
<td class="entity-browser-table__cell--ai">
|
||||
<div class="entity-browser-ai">
|
||||
<span
|
||||
v-for="token in row.aiStates"
|
||||
:key="`${row.key}-${token}`"
|
||||
class="entity-browser-ai__token"
|
||||
>
|
||||
{{ token }}
|
||||
</span>
|
||||
<span v-if="row.aiStates.length === 0" class="entity-browser-table__muted">--</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="entity-browser-table__numeric">
|
||||
{{ row.hpLabel }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -76,6 +76,50 @@ function formatAmount(value: number) {
|
||||
return Math.abs(value - rounded) < 0.005 ? String(rounded) : value.toFixed(1);
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function joinDetail(parts: Array<string | null | undefined>) {
|
||||
return parts.filter((part): part is string => !!part && part.trim().length > 0).join(" · ");
|
||||
}
|
||||
|
||||
function describeOrderTarget(order: {
|
||||
itemId?: string | null;
|
||||
targetEntityId?: string | null;
|
||||
targetSystemId?: string | null;
|
||||
nodeId?: string | null;
|
||||
constructionSiteId?: string | null;
|
||||
sourceStationId?: string | null;
|
||||
destinationStationId?: string | null;
|
||||
moduleId?: string | null;
|
||||
}) {
|
||||
return order.itemId
|
||||
?? order.targetEntityId
|
||||
?? order.targetSystemId
|
||||
?? order.nodeId
|
||||
?? order.constructionSiteId
|
||||
?? order.destinationStationId
|
||||
?? order.sourceStationId
|
||||
?? order.moduleId
|
||||
?? "—";
|
||||
}
|
||||
|
||||
function describeSubTaskTarget(subTask: {
|
||||
itemId?: string | null;
|
||||
targetEntityId?: string | null;
|
||||
targetSystemId?: string | null;
|
||||
targetNodeId?: string | null;
|
||||
moduleId?: string | null;
|
||||
}) {
|
||||
return subTask.itemId
|
||||
?? subTask.targetEntityId
|
||||
?? subTask.targetSystemId
|
||||
?? subTask.targetNodeId
|
||||
?? subTask.moduleId
|
||||
?? "—";
|
||||
}
|
||||
|
||||
const selectedShip = computed(() => {
|
||||
if (selectedEntityKind.value !== "ship" || !selectedEntityId.value) {
|
||||
return null;
|
||||
@@ -100,10 +144,10 @@ const playerShipIds = computed(() =>
|
||||
new Set(playerFaction.value?.assetRegistry.shipIds ?? []),
|
||||
);
|
||||
|
||||
const canAccessGm = computed(() => authStore.canAccessGm);
|
||||
const canAccessGmDirectly = computed(() => authStore.canAccessGm && !authStore.isActingAsAlternateIdentity);
|
||||
|
||||
const canDirectControlSelectedShip = computed(() =>
|
||||
!!selectedShip.value && (canAccessGm.value || playerShipIds.value.has(selectedShip.value.id)),
|
||||
!!selectedShip.value && (canAccessGmDirectly.value || playerShipIds.value.has(selectedShip.value.id)),
|
||||
);
|
||||
|
||||
const directOrders = computed(() =>
|
||||
@@ -135,20 +179,208 @@ const formBehaviorNotes = computed(() =>
|
||||
getShipBehaviorNotes(behaviorForm.kind),
|
||||
);
|
||||
|
||||
watch(selectedShip, (ship) => {
|
||||
if (!ship) {
|
||||
return;
|
||||
const shipStatusRows = computed(() => {
|
||||
if (!selectedShip.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
behaviorForm.kind = ship.defaultBehavior.kind;
|
||||
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
|
||||
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
|
||||
mineOrderForm.systemId = ship.systemId ?? "";
|
||||
mineOrderForm.itemId = "ore";
|
||||
moveOrderSystemId.value = ship.systemId ?? "";
|
||||
actionStatus.value = "";
|
||||
actionError.value = "";
|
||||
}, { immediate: true });
|
||||
return [
|
||||
{ label: "State", value: titleCase(selectedShip.value.state) },
|
||||
{ label: "Behavior", value: getShipBehaviorLabel(selectedShip.value.defaultBehavior.kind) },
|
||||
{ label: "Control", value: titleCase(selectedShip.value.controlSourceKind) },
|
||||
{ label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" },
|
||||
{
|
||||
label: "Plan",
|
||||
value: selectedShip.value.activePlan
|
||||
? `${selectedShip.value.activePlan.kind} · ${titleCase(selectedShip.value.activePlan.status)}`
|
||||
: "none",
|
||||
},
|
||||
{ label: "Failure", value: selectedShip.value.lastAccessFailureReason ?? "none" },
|
||||
{ label: "Commander", value: selectedShip.value.commanderId ?? "none" },
|
||||
{ label: "Docked", value: selectedShip.value.dockedStationId ?? "no" },
|
||||
];
|
||||
});
|
||||
|
||||
const shipCargoSummaryRows = computed(() => {
|
||||
if (!selectedShip.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const usedCargo = selectedShip.value.inventory.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
return [
|
||||
{ label: "Used", value: formatAmount(usedCargo) },
|
||||
{ label: "Capacity", value: formatAmount(selectedShip.value.cargoCapacity) },
|
||||
{ label: "Free", value: formatAmount(Math.max(selectedShip.value.cargoCapacity - usedCargo, 0)) },
|
||||
{ label: "Travel", value: `${formatAmount(selectedShip.value.travelSpeed)} ${selectedShip.value.travelSpeedUnit}` },
|
||||
{ label: "Hull", value: formatAmount(selectedShip.value.health) },
|
||||
{ label: "Regime", value: titleCase(selectedShip.value.spatialState.movementRegime) },
|
||||
];
|
||||
});
|
||||
|
||||
const shipCargoRows = computed(() =>
|
||||
selectedShip.value?.inventory.map((entry) => ({
|
||||
key: entry.itemId,
|
||||
ware: entry.itemId,
|
||||
amount: formatAmount(entry.amount),
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
const shipBehaviorRows = computed(() => {
|
||||
if (!selectedShip.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: "Area", value: selectedShip.value.defaultBehavior.areaSystemId ?? "none" },
|
||||
{ label: "Item", value: selectedShip.value.defaultBehavior.itemId ?? "none" },
|
||||
{ label: "Home Station", value: selectedShip.value.defaultBehavior.homeStationId ?? "none" },
|
||||
{ label: "Target", value: selectedShip.value.defaultBehavior.targetEntityId ?? "none" },
|
||||
{ label: "Range", value: String(selectedShip.value.defaultBehavior.maxSystemRange) },
|
||||
{ label: "Known Only", value: selectedShip.value.defaultBehavior.knownStationsOnly ? "yes" : "no" },
|
||||
];
|
||||
});
|
||||
|
||||
const directOrderRows = computed(() =>
|
||||
directOrders.value.map((order) => ({
|
||||
id: order.id,
|
||||
label: getShipOrderLabel(order.kind),
|
||||
status: titleCase(order.status),
|
||||
target: describeOrderTarget(order),
|
||||
detail: joinDetail([
|
||||
`P${order.priority}`,
|
||||
titleCase(order.sourceKind),
|
||||
order.failureReason ?? undefined,
|
||||
]),
|
||||
})),
|
||||
);
|
||||
|
||||
const behaviorOrderRows = computed(() =>
|
||||
behaviorOrders.value.map((order) => ({
|
||||
id: order.id,
|
||||
label: getShipOrderLabel(order.kind),
|
||||
status: titleCase(order.status),
|
||||
target: describeOrderTarget(order),
|
||||
detail: joinDetail([
|
||||
`P${order.priority}`,
|
||||
getShipOrderSupportStatusLabel(order.kind) ?? undefined,
|
||||
getShipOrderNotes(order.kind) ?? undefined,
|
||||
order.failureReason ?? undefined,
|
||||
]),
|
||||
})),
|
||||
);
|
||||
|
||||
const shipPlanRows = computed(() => {
|
||||
if (!selectedShip.value?.activePlan) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return selectedShip.value.activePlan.steps.flatMap((step) => {
|
||||
const stepRow = {
|
||||
id: step.id,
|
||||
scope: "Step",
|
||||
activity: step.summary || titleCase(step.kind),
|
||||
status: titleCase(step.status),
|
||||
detail: joinDetail([
|
||||
step.blockingReason ?? undefined,
|
||||
`${step.subTasks.length} subtasks`,
|
||||
]),
|
||||
isSubTask: false,
|
||||
};
|
||||
|
||||
const subTaskRows = step.subTasks.map((subTask) => ({
|
||||
id: subTask.id,
|
||||
scope: "Subtask",
|
||||
activity: subTask.summary || titleCase(subTask.kind),
|
||||
status: titleCase(subTask.status),
|
||||
detail: joinDetail([
|
||||
describeSubTaskTarget(subTask),
|
||||
subTask.blockingReason ?? undefined,
|
||||
`${Math.round(subTask.progress * 100)}%`,
|
||||
]),
|
||||
isSubTask: true,
|
||||
}));
|
||||
|
||||
return [stepRow, ...subTaskRows];
|
||||
});
|
||||
});
|
||||
|
||||
const stationStatusRows = computed(() => {
|
||||
if (!selectedStation.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: "Category", value: titleCase(selectedStation.value.category) },
|
||||
{ label: "Objective", value: titleCase(selectedStation.value.objective) },
|
||||
{ label: "Docked", value: `${selectedStation.value.dockedShips} / ${selectedStation.value.dockingPads}` },
|
||||
{
|
||||
label: "Population",
|
||||
value: `${formatAmount(selectedStation.value.population)} / ${formatAmount(selectedStation.value.populationCapacity)}`,
|
||||
},
|
||||
{ label: "Workforce", value: formatAmount(selectedStation.value.workforceRequired) },
|
||||
{ label: "Efficiency", value: formatPercent(selectedStation.value.workforceEffectiveRatio) },
|
||||
{ label: "Commander", value: selectedStation.value.commanderId ?? "none" },
|
||||
{ label: "Policy", value: selectedStation.value.policySetId ?? "none" },
|
||||
];
|
||||
});
|
||||
|
||||
const stationModuleRows = computed(() =>
|
||||
selectedStation.value?.installedModules.map((moduleId) => ({
|
||||
key: moduleId,
|
||||
module: moduleNameById.get(moduleId) ?? moduleId,
|
||||
moduleId,
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
const stationStorageRows = computed(() =>
|
||||
selectedStation.value?.storageUsage.map((entry) => ({
|
||||
key: entry.storageClass,
|
||||
storageClass: titleCase(entry.storageClass),
|
||||
used: formatAmount(entry.used),
|
||||
capacity: formatAmount(entry.capacity),
|
||||
fill: entry.capacity > 0 ? formatPercent(entry.used / entry.capacity) : "0%",
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
const stationInventoryRows = computed(() =>
|
||||
selectedStation.value?.inventory.map((entry) => ({
|
||||
key: entry.itemId,
|
||||
ware: entry.itemId,
|
||||
amount: formatAmount(entry.amount),
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
const stationProcessRows = computed(() =>
|
||||
selectedStation.value?.currentProcesses.map((process) => ({
|
||||
key: `${process.lane}-${process.label}`,
|
||||
lane: process.lane,
|
||||
label: process.label,
|
||||
progress: formatPercent(process.progress),
|
||||
timing: `${Math.ceil(process.timeRemainingSeconds)}s / ${Math.ceil(process.cycleSeconds)}s`,
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
watch(
|
||||
() => `${selectedEntityKind.value ?? "none"}:${selectedEntityId.value ?? "none"}`,
|
||||
() => {
|
||||
const ship = selectedShip.value;
|
||||
if (!ship) {
|
||||
actionStatus.value = "";
|
||||
actionError.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
behaviorForm.kind = ship.defaultBehavior.kind;
|
||||
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
|
||||
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
|
||||
mineOrderForm.systemId = ship.systemId ?? "";
|
||||
mineOrderForm.itemId = "ore";
|
||||
moveOrderSystemId.value = ship.systemId ?? "";
|
||||
actionStatus.value = "";
|
||||
actionError.value = "";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function focusShip(cameraMode?: "follow" | "tactical") {
|
||||
if (!selectedShip.value) {
|
||||
@@ -357,36 +589,52 @@ async function clearOrders() {
|
||||
</div>
|
||||
<div class="entity-inspector-panel__actions">
|
||||
<button type="button" class="entity-inspector-panel__action" @click="focusShip('tactical')">Focus</button>
|
||||
<button type="button" class="entity-inspector-panel__action" @click="focusShip('follow')">Follow</button>
|
||||
<button type="button" class="entity-inspector-panel__action" @click="focusShip('follow')">Track</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Status</h4>
|
||||
<div class="entity-inspector-grid">
|
||||
<div><span>State</span><strong>{{ titleCase(selectedShip.state) }}</strong></div>
|
||||
<div><span>Behavior</span><strong>{{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</strong></div>
|
||||
<div><span>Control</span><strong>{{ selectedShip.controlSourceKind }}</strong></div>
|
||||
<div><span>Assignment</span><strong>{{ selectedShip.assignment?.kind ?? "unassigned" }}</strong></div>
|
||||
<div><span>Plan</span><strong>{{ selectedShip.activePlan ? `${selectedShip.activePlan.kind} · ${selectedShip.activePlan.status}` : "none" }}</strong></div>
|
||||
<div><span>Failure</span><strong>{{ selectedShip.lastAccessFailureReason ?? "none" }}</strong></div>
|
||||
<div class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table entity-inspector-table--kv">
|
||||
<tbody>
|
||||
<tr v-for="row in shipStatusRows" :key="row.label">
|
||||
<th scope="row">{{ row.label }}</th>
|
||||
<td>{{ row.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Cargo</h4>
|
||||
<div class="entity-inspector-grid">
|
||||
<div><span>Used</span><strong>{{ formatAmount(selectedShip.inventory.reduce((sum, entry) => sum + entry.amount, 0)) }}</strong></div>
|
||||
<div><span>Capacity</span><strong>{{ formatAmount(selectedShip.cargoCapacity) }}</strong></div>
|
||||
<div><span>Travel</span><strong>{{ formatAmount(selectedShip.travelSpeed) }} {{ selectedShip.travelSpeedUnit }}</strong></div>
|
||||
<div><span>Hull</span><strong>{{ formatAmount(selectedShip.health) }}</strong></div>
|
||||
<div class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table entity-inspector-table--kv">
|
||||
<tbody>
|
||||
<tr v-for="row in shipCargoSummaryRows" :key="row.label">
|
||||
<th scope="row">{{ row.label }}</th>
|
||||
<td>{{ row.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="shipCargoRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Ware</th>
|
||||
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in shipCargoRows" :key="row.key">
|
||||
<td>{{ row.ware }}</td>
|
||||
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul v-if="selectedShip.inventory.length > 0" class="entity-inspector-list">
|
||||
<li v-for="entry in selectedShip.inventory" :key="entry.itemId">
|
||||
<span>{{ entry.itemId }}</span>
|
||||
<strong>{{ formatAmount(entry.amount) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="entity-inspector-empty">No cargo.</div>
|
||||
</div>
|
||||
|
||||
@@ -395,13 +643,15 @@ async function clearOrders() {
|
||||
<div v-if="selectedBehaviorStatus || selectedBehaviorNotes" class="entity-inspector-note">
|
||||
{{ [selectedBehaviorStatus, selectedBehaviorNotes].filter(Boolean).join(" · ") }}
|
||||
</div>
|
||||
<div class="entity-inspector-grid">
|
||||
<div><span>Area</span><strong>{{ selectedShip.defaultBehavior.areaSystemId ?? "none" }}</strong></div>
|
||||
<div><span>Item</span><strong>{{ selectedShip.defaultBehavior.itemId ?? "none" }}</strong></div>
|
||||
<div><span>Home Station</span><strong>{{ selectedShip.defaultBehavior.homeStationId ?? "none" }}</strong></div>
|
||||
<div><span>Target</span><strong>{{ selectedShip.defaultBehavior.targetEntityId ?? "none" }}</strong></div>
|
||||
<div><span>Range</span><strong>{{ selectedShip.defaultBehavior.maxSystemRange }}</strong></div>
|
||||
<div><span>Known Only</span><strong>{{ selectedShip.defaultBehavior.knownStationsOnly ? "yes" : "no" }}</strong></div>
|
||||
<div class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table entity-inspector-table--kv">
|
||||
<tbody>
|
||||
<tr v-for="row in shipBehaviorRows" :key="row.label">
|
||||
<th scope="row">{{ row.label }}</th>
|
||||
<td>{{ row.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="canDirectControlSelectedShip" class="entity-inspector-form">
|
||||
<label class="entity-inspector-field">
|
||||
@@ -473,52 +723,86 @@ async function clearOrders() {
|
||||
</div>
|
||||
<div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
|
||||
<div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
|
||||
<ul v-if="directOrders.length > 0" class="entity-inspector-list">
|
||||
<li v-for="order in directOrders" :key="order.id">
|
||||
<span>{{ getShipOrderLabel(order.kind) }} · {{ order.status }}</span>
|
||||
<div class="entity-inspector-order-actions">
|
||||
<strong>{{ order.itemId ?? order.targetEntityId ?? order.targetSystemId ?? "—" }}</strong>
|
||||
<button
|
||||
v-if="canDirectControlSelectedShip"
|
||||
type="button"
|
||||
class="entity-inspector-order-remove"
|
||||
:disabled="actionBusy"
|
||||
@click="removeOrder(order.id)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="directOrderRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Order</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Target</th>
|
||||
<th scope="col">Detail</th>
|
||||
<th v-if="canDirectControlSelectedShip" scope="col" class="entity-inspector-table__action-col">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="order in directOrderRows" :key="order.id">
|
||||
<td>{{ order.label }}</td>
|
||||
<td>{{ order.status }}</td>
|
||||
<td>{{ order.target }}</td>
|
||||
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
|
||||
<td v-if="canDirectControlSelectedShip" class="entity-inspector-table__action-col">
|
||||
<button
|
||||
type="button"
|
||||
class="entity-inspector-order-remove"
|
||||
:disabled="actionBusy"
|
||||
@click="removeOrder(order.id)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
|
||||
<div class="entity-inspector-divider">
|
||||
<span>Behavior: {{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
|
||||
</div>
|
||||
<ul v-if="behaviorOrders.length > 0" class="entity-inspector-list">
|
||||
<li v-for="order in behaviorOrders" :key="order.id">
|
||||
<span>{{ getShipOrderLabel(order.kind) }} · {{ order.status }}</span>
|
||||
<strong>{{ [order.itemId ?? order.targetEntityId ?? order.targetSystemId ?? "—", getShipOrderSupportStatusLabel(order.kind), getShipOrderNotes(order.kind)].filter(Boolean).join(" · ") }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="behaviorOrderRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Order</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Target</th>
|
||||
<th scope="col">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="order in behaviorOrderRows" :key="order.id">
|
||||
<td>{{ order.label }}</td>
|
||||
<td>{{ order.status }}</td>
|
||||
<td>{{ order.target }}</td>
|
||||
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="entity-inspector-empty">No behavior orders queued.</div>
|
||||
</div>
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Plan Steps</h4>
|
||||
<ul v-if="selectedShip.activePlan" class="entity-inspector-plan">
|
||||
<li v-for="step in selectedShip.activePlan.steps" :key="step.id">
|
||||
<div class="entity-inspector-plan__step">
|
||||
<span>{{ step.kind }} · {{ step.status }}</span>
|
||||
<strong>{{ step.blockingReason ?? "ok" }}</strong>
|
||||
</div>
|
||||
<ul class="entity-inspector-subtasks">
|
||||
<li v-for="subTask in step.subTasks" :key="subTask.id">
|
||||
<span>{{ subTask.kind }} · {{ subTask.status }}</span>
|
||||
<strong>{{ subTask.blockingReason ?? `${Math.round(subTask.progress * 100)}%` }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="shipPlanRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Scope</th>
|
||||
<th scope="col">Activity</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in shipPlanRows" :key="row.id" :class="row.isSubTask ? 'entity-inspector-table__row--subtask' : ''">
|
||||
<td>{{ row.scope }}</td>
|
||||
<td :class="row.isSubTask ? 'entity-inspector-table__subtask' : ''">{{ row.activity }}</td>
|
||||
<td>{{ row.status }}</td>
|
||||
<td class="entity-inspector-table__detail">{{ row.detail }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="entity-inspector-empty">No active plan.</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -537,46 +821,102 @@ async function clearOrders() {
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Status</h4>
|
||||
<div class="entity-inspector-grid">
|
||||
<div><span>Category</span><strong>{{ titleCase(selectedStation.category) }}</strong></div>
|
||||
<div><span>Objective</span><strong>{{ titleCase(selectedStation.objective) }}</strong></div>
|
||||
<div><span>Docked</span><strong>{{ selectedStation.dockedShips }} / {{ selectedStation.dockingPads }}</strong></div>
|
||||
<div><span>Population</span><strong>{{ formatAmount(selectedStation.population) }} / {{ formatAmount(selectedStation.populationCapacity) }}</strong></div>
|
||||
<div><span>Workforce</span><strong>{{ formatAmount(selectedStation.workforceRequired) }}</strong></div>
|
||||
<div><span>Efficiency</span><strong>{{ Math.round(selectedStation.workforceEffectiveRatio * 100) }}%</strong></div>
|
||||
<div class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table entity-inspector-table--kv">
|
||||
<tbody>
|
||||
<tr v-for="row in stationStatusRows" :key="row.label">
|
||||
<th scope="row">{{ row.label }}</th>
|
||||
<td>{{ row.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Modules</h4>
|
||||
<ul v-if="selectedStation.installedModules.length > 0" class="entity-inspector-list">
|
||||
<li v-for="moduleId in selectedStation.installedModules" :key="moduleId">
|
||||
<span>{{ moduleNameById.get(moduleId) ?? moduleId }}</span>
|
||||
<strong>{{ moduleId }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="stationModuleRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Module</th>
|
||||
<th scope="col">Id</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in stationModuleRows" :key="row.key">
|
||||
<td>{{ row.module }}</td>
|
||||
<td>{{ row.moduleId }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="entity-inspector-empty">No modules installed.</div>
|
||||
</div>
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Storage</h4>
|
||||
<ul v-if="selectedStation.inventory.length > 0" class="entity-inspector-list">
|
||||
<li v-for="entry in selectedStation.inventory" :key="entry.itemId">
|
||||
<span>{{ entry.itemId }}</span>
|
||||
<strong>{{ formatAmount(entry.amount) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="entity-inspector-empty">No inventory.</div>
|
||||
<div v-if="stationStorageRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Class</th>
|
||||
<th scope="col" class="entity-inspector-table__numeric">Used</th>
|
||||
<th scope="col" class="entity-inspector-table__numeric">Capacity</th>
|
||||
<th scope="col" class="entity-inspector-table__numeric">Fill</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in stationStorageRows" :key="row.key">
|
||||
<td>{{ row.storageClass }}</td>
|
||||
<td class="entity-inspector-table__numeric">{{ row.used }}</td>
|
||||
<td class="entity-inspector-table__numeric">{{ row.capacity }}</td>
|
||||
<td class="entity-inspector-table__numeric">{{ row.fill }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="stationInventoryRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Ware</th>
|
||||
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in stationInventoryRows" :key="row.key">
|
||||
<td>{{ row.ware }}</td>
|
||||
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else-if="stationStorageRows.length === 0" class="entity-inspector-empty">No inventory.</div>
|
||||
</div>
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Production</h4>
|
||||
<ul v-if="selectedStation.currentProcesses.length > 0" class="entity-inspector-list">
|
||||
<li v-for="process in selectedStation.currentProcesses" :key="`${process.lane}-${process.label}`">
|
||||
<span>{{ process.label }}</span>
|
||||
<strong>{{ Math.round(process.progress * 100) }}% · {{ Math.ceil(process.timeRemainingSeconds) }}s</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="stationProcessRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Lane</th>
|
||||
<th scope="col">Process</th>
|
||||
<th scope="col" class="entity-inspector-table__numeric">Progress</th>
|
||||
<th scope="col" class="entity-inspector-table__numeric">Timing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in stationProcessRows" :key="row.key">
|
||||
<td>{{ row.lane }}</td>
|
||||
<td>{{ row.label }}</td>
|
||||
<td class="entity-inspector-table__numeric">{{ row.progress }}</td>
|
||||
<td class="entity-inspector-table__numeric">{{ row.timing }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="entity-inspector-empty">No active processes.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -50,7 +50,7 @@ const canControlSelectedShip = computed(() => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authStore.canAccessGm) {
|
||||
if (authStore.canAccessGm && !authStore.isActingAsAlternateIdentity) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
9
apps/viewer/src/contractsIdentity.ts
Normal file
9
apps/viewer/src/contractsIdentity.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface PlayerIdentitySummary {
|
||||
userId: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
hasPlayerFaction: boolean;
|
||||
playerFactionId?: string | null;
|
||||
playerFactionLabel?: string | null;
|
||||
sovereignFactionId?: string | null;
|
||||
}
|
||||
@@ -266,7 +266,10 @@ export interface PlayerAlertSnapshot {
|
||||
export interface PlayerFactionSnapshot {
|
||||
id: string;
|
||||
label: string;
|
||||
personaName?: string | null;
|
||||
raceId?: string | null;
|
||||
sovereignFactionId: string;
|
||||
requiresOnboarding: boolean;
|
||||
status: string;
|
||||
createdAtUtc: string;
|
||||
updatedAtUtc: string;
|
||||
|
||||
6
apps/viewer/src/contractsRaces.ts
Normal file
6
apps/viewer/src/contractsRaces.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface RaceSnapshot {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
47
apps/viewer/src/effectiveIdentitySession.ts
Normal file
47
apps/viewer/src/effectiveIdentitySession.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
const STORAGE_KEY = "space-game.auth.effective-player-id";
|
||||
|
||||
let currentEffectivePlayerId = loadEffectivePlayerId();
|
||||
const listeners = new Set<(playerId: string | null) => void>();
|
||||
|
||||
export function getEffectivePlayerIdentityId() {
|
||||
return currentEffectivePlayerId;
|
||||
}
|
||||
|
||||
export function setEffectivePlayerIdentityId(playerId: string | null) {
|
||||
currentEffectivePlayerId = playerId && playerId.trim().length > 0 ? playerId.trim() : null;
|
||||
persistEffectivePlayerId(currentEffectivePlayerId);
|
||||
for (const listener of listeners) {
|
||||
listener(currentEffectivePlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearEffectivePlayerIdentityId() {
|
||||
setEffectivePlayerIdentityId(null);
|
||||
}
|
||||
|
||||
export function subscribeToEffectivePlayerIdentity(listener: (playerId: string | null) => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function loadEffectivePlayerId() {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
return raw && raw.trim().length > 0 ? raw.trim() : null;
|
||||
}
|
||||
|
||||
function persistEffectivePlayerId(playerId: string | null) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!playerId) {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(STORAGE_KEY, playerId);
|
||||
}
|
||||
@@ -13,14 +13,22 @@ export class ViewerRenderSurface {
|
||||
private readonly onFrame: () => void;
|
||||
private readonly 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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<number, THREE.Vector2>();
|
||||
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) {
|
||||
|
||||
@@ -248,6 +248,54 @@ function renderSystemOwnership(world: WorldState, systemId: string): string {
|
||||
return lines.join("<br>");
|
||||
}
|
||||
|
||||
function titleCaseLabel(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (part) => part.toUpperCase());
|
||||
}
|
||||
|
||||
function describeSystemSubtitle(world: WorldState, systemId: string): string {
|
||||
const control = world.geopolitics?.territory.controlStates.find((state) => state.systemId === systemId);
|
||||
const zone = world.geopolitics?.territory.zones.find((entry) => entry.systemId === systemId);
|
||||
const profile = world.geopolitics?.territory.strategicProfiles.find((entry) => entry.systemId === systemId);
|
||||
const region = world.geopolitics?.economyRegions.regions.find((entry) => entry.systemIds.includes(systemId));
|
||||
|
||||
if (region?.label) {
|
||||
return region.label;
|
||||
}
|
||||
|
||||
if (zone?.reason) {
|
||||
return titleCaseLabel(zone.reason);
|
||||
}
|
||||
|
||||
if (zone?.kind) {
|
||||
return titleCaseLabel(zone.kind);
|
||||
}
|
||||
|
||||
if (profile?.zoneKind) {
|
||||
return titleCaseLabel(profile.zoneKind);
|
||||
}
|
||||
|
||||
if (control?.isContested) {
|
||||
return "Contested";
|
||||
}
|
||||
|
||||
if (control) {
|
||||
return titleCaseLabel(control.controlKind);
|
||||
}
|
||||
|
||||
const claims = [...world.claims.values()].filter((claim) =>
|
||||
claim.systemId === systemId && claim.state !== "destroyed");
|
||||
return claims.length === 0 ? "Deep Space" : `Claims ${claims.length}`;
|
||||
}
|
||||
|
||||
export function buildDetailPanelState(params: DetailPanelParams) {
|
||||
const {
|
||||
world,
|
||||
@@ -525,9 +573,7 @@ export function buildSystemPanelState(params: SystemPanelParams) {
|
||||
return {
|
||||
hidden: false,
|
||||
title: activeSystem.label,
|
||||
bodyHtml: `
|
||||
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
|
||||
`,
|
||||
bodyHtml: describeSystemSubtitle(world, activeSystem.id),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
This document defines the intended combat model for the simulation.
|
||||
|
||||
Combat is primarily a local-space activity. It is how factions, pirates, and defenders contest access, claims, stations, and logistics.
|
||||
Combat is primarily a localspace activity. It is how factions, pirates, and defenders contest access, claims, stations, and logistics.
|
||||
|
||||
## Design Goals
|
||||
|
||||
The combat model should support:
|
||||
|
||||
- local-space tactical fights
|
||||
- localspace tactical fights
|
||||
- piracy and harassment
|
||||
- claim destruction and station contestation
|
||||
- station defense
|
||||
@@ -17,7 +17,7 @@ The combat model should support:
|
||||
|
||||
## Core Principles
|
||||
|
||||
- combat happens in `local-space`
|
||||
- combat happens in `localspace`
|
||||
- claims and structures are physically contestable
|
||||
- piracy should target valuable traffic and vulnerable infrastructure
|
||||
- stations should be defensible but not magically safe
|
||||
@@ -25,7 +25,7 @@ The combat model should support:
|
||||
|
||||
## Combat Space
|
||||
|
||||
Combat belongs in `local-space`.
|
||||
Combat belongs in `localspace`.
|
||||
|
||||
This is where entities can:
|
||||
|
||||
@@ -35,7 +35,7 @@ This is where entities can:
|
||||
- defend stations and claims
|
||||
- intercept miners, haulers, and construction support
|
||||
|
||||
Ships in `system-space` warp transit are not in normal tactical combat.
|
||||
Ships in intra-system warp transit are not in normal tactical combat.
|
||||
|
||||
This keeps tactical fighting distinct from travel.
|
||||
|
||||
@@ -54,7 +54,7 @@ Combat should matter not only for fleet battle, but also for logistics disruptio
|
||||
|
||||
## Claims As Combat Targets
|
||||
|
||||
Claims at Lagrange points should be valid combat targets.
|
||||
Claims at valid construction anchors should be valid combat targets.
|
||||
|
||||
That means:
|
||||
|
||||
@@ -94,7 +94,7 @@ Station safety should depend on actual defensive capacity, not only ownership fl
|
||||
|
||||
## Piracy
|
||||
|
||||
Pirates should be a meaningful local-space threat.
|
||||
Pirates should be a meaningful localspace threat.
|
||||
|
||||
They should favor:
|
||||
|
||||
@@ -203,7 +203,7 @@ See [EVENTS.md](/home/jbourdon/repos/space-game/docs/EVENTS.md) for the combat a
|
||||
|
||||
The following rules should remain true unless deliberately revised:
|
||||
|
||||
- combat is primarily a local-space activity
|
||||
- combat is primarily a localspace activity
|
||||
- claims are destructible and contestable
|
||||
- station construction is a vulnerable phase
|
||||
- piracy should prefer valuable and vulnerable traffic
|
||||
@@ -213,7 +213,7 @@ The following rules should remain true unless deliberately revised:
|
||||
|
||||
## Relationship To Other Documents
|
||||
|
||||
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
|
||||
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
|
||||
- defines where combat is allowed
|
||||
|
||||
- [POLICIES.md](/home/jbourdon/repos/space-game/docs/POLICIES.md)
|
||||
|
||||
@@ -39,7 +39,7 @@ Additional specialized commanders may exist later, such as:
|
||||
- fleet commander
|
||||
- task-group commander
|
||||
- convoy commander
|
||||
- sector commander
|
||||
- localspace defense commander
|
||||
|
||||
## Commander Entity Model
|
||||
|
||||
@@ -75,7 +75,7 @@ Responsibilities:
|
||||
- create or retire station and fleet objectives
|
||||
- respond to large-scale shortages, threats, and opportunities
|
||||
|
||||
The faction commander should reason mostly across `universe-space`, `galaxy-space`, and strategic `system-space`.
|
||||
The faction commander should reason mostly across `universe-space`, `galaxy-space`, and strategic system-level concerns.
|
||||
|
||||
It should not micromanage every ship constantly.
|
||||
|
||||
@@ -343,7 +343,7 @@ The following rules should remain true unless there is a deliberate exception:
|
||||
|
||||
## Relationship To Other Documents
|
||||
|
||||
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
|
||||
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
|
||||
- defines where action happens
|
||||
|
||||
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
||||
|
||||
@@ -14,7 +14,7 @@ The data model should support:
|
||||
- module-based capability
|
||||
- population and workforce
|
||||
- claimable construction sites
|
||||
- local-space combat
|
||||
- localspace combat
|
||||
- scalable replication and future sharding
|
||||
|
||||
## Core Principles
|
||||
@@ -39,8 +39,8 @@ Recommended global ID families:
|
||||
- `factionId`
|
||||
- `commanderId`
|
||||
- `systemId`
|
||||
- `nodeId`
|
||||
- `bubbleId`
|
||||
- `anchorId`
|
||||
- `localspaceId`
|
||||
- `stationId`
|
||||
- `shipId`
|
||||
- `moduleId`
|
||||
@@ -58,8 +58,8 @@ The intended core entities are:
|
||||
1. `Faction`
|
||||
2. `Commander`
|
||||
3. `System`
|
||||
4. `Node`
|
||||
5. `LocalBubble`
|
||||
4. `Anchor`
|
||||
5. `Localspace`
|
||||
6. `Station`
|
||||
7. `Ship`
|
||||
8. `ModuleInstance`
|
||||
@@ -108,7 +108,6 @@ Recommended commander kinds:
|
||||
- `station`
|
||||
- `ship`
|
||||
- `fleet`
|
||||
- `sector`
|
||||
- `task-group`
|
||||
|
||||
## System
|
||||
@@ -124,40 +123,37 @@ Suggested fields:
|
||||
- `nodeIds`
|
||||
- `faction influence later`
|
||||
|
||||
## Node
|
||||
## Anchor
|
||||
|
||||
A node is a meaningful location in system space that owns a local bubble.
|
||||
An anchor is a meaningful location in a system that owns a localspace.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `nodeId`
|
||||
- `anchorId`
|
||||
- `systemId`
|
||||
- `kind`
|
||||
- `systemPosition`
|
||||
- `bubbleId`
|
||||
- `parentNodeId?`
|
||||
- `localspaceId`
|
||||
- `parentAnchorId?`
|
||||
- `orbital metadata?`
|
||||
- `occupyingStructureId?`
|
||||
- `constructionIds?`
|
||||
|
||||
Recommended node kinds:
|
||||
Recommended anchor kinds:
|
||||
|
||||
- `star`
|
||||
- `planet`
|
||||
- `moon`
|
||||
- `lagrange-point`
|
||||
- `station`
|
||||
- `gate`
|
||||
- `resource-site`
|
||||
- `structure`
|
||||
- `resource-node`
|
||||
|
||||
## LocalBubble
|
||||
## Localspace
|
||||
|
||||
A local bubble is the tactical simulation context attached to one node.
|
||||
A localspace is the tactical simulation context attached to one anchor.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `bubbleId`
|
||||
- `nodeId`
|
||||
- `localspaceId`
|
||||
- `anchorId`
|
||||
- `systemId`
|
||||
- `radius`
|
||||
- `occupantShipIds`
|
||||
@@ -168,16 +164,16 @@ Suggested fields:
|
||||
|
||||
## Station
|
||||
|
||||
A station is a structure attached to a Lagrange-point node.
|
||||
A station is a constructed structure that lives inside one localspace.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `stationId`
|
||||
- `ownerFactionId`
|
||||
- `commanderId?`
|
||||
- `nodeId`
|
||||
- `anchorId`
|
||||
- `systemId`
|
||||
- `bubbleId`
|
||||
- `localspaceId`
|
||||
- `moduleIds`
|
||||
- `inventory`
|
||||
- `population`
|
||||
@@ -231,7 +227,7 @@ Recommended host kinds:
|
||||
|
||||
## Claim
|
||||
|
||||
A claim is a vulnerable object placed at a Lagrange point before construction.
|
||||
A claim is a vulnerable object placed at a valid construction anchor before construction.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
@@ -239,8 +235,8 @@ Suggested fields:
|
||||
- `ownerFactionId`
|
||||
- `commanderId?`
|
||||
- `systemId`
|
||||
- `nodeId`
|
||||
- `bubbleId`
|
||||
- `anchorId`
|
||||
- `localspaceId`
|
||||
- `placedAt`
|
||||
- `activatesAt`
|
||||
- `state`
|
||||
@@ -261,8 +257,8 @@ Suggested fields:
|
||||
|
||||
- `constructionSiteId`
|
||||
- `ownerFactionId`
|
||||
- `nodeId`
|
||||
- `bubbleId`
|
||||
- `anchorId`
|
||||
- `localspaceId`
|
||||
- `targetKind`
|
||||
- `targetDefinitionId`
|
||||
- `requiredItems`
|
||||
@@ -354,12 +350,12 @@ Recommended ship spatial state fields:
|
||||
|
||||
- `spaceLayer`
|
||||
- `currentSystemId`
|
||||
- `currentNodeId?`
|
||||
- `currentBubbleId?`
|
||||
- `currentAnchorId?`
|
||||
- `currentLocalspaceId?`
|
||||
- `localPosition?`
|
||||
- `systemPosition?`
|
||||
- `movementRegime`
|
||||
- `destinationNodeId?`
|
||||
- `destinationAnchorId?`
|
||||
- `transitState?`
|
||||
|
||||
Recommended space layers:
|
||||
@@ -367,7 +363,7 @@ Recommended space layers:
|
||||
- `universe-space`
|
||||
- `galaxy-space`
|
||||
- `system-space`
|
||||
- `local-space`
|
||||
- `localspace`
|
||||
|
||||
Recommended movement regimes:
|
||||
|
||||
@@ -466,7 +462,7 @@ The following rules should remain true unless deliberately revised:
|
||||
|
||||
## Relationship To Other Documents
|
||||
|
||||
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
|
||||
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
|
||||
- defines the layered world structure
|
||||
|
||||
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
|
||||
|
||||
@@ -8,12 +8,12 @@ For the implementation migration path from the current codebase to this design s
|
||||
|
||||
## Core Documents
|
||||
|
||||
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
|
||||
- spatial layers
|
||||
- nodes and local bubbles
|
||||
- movement regimes
|
||||
- viewer scale expectations
|
||||
- replication and interest management implications
|
||||
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
|
||||
- canonical world structure
|
||||
- galaxy, systems, celestials, anchors, and localspaces
|
||||
- ship and construction placement
|
||||
- intra-system warp and inter-system FTL
|
||||
- viewer hierarchy and simulation boundaries
|
||||
|
||||
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
|
||||
- commander roles for factions, stations, and ships
|
||||
@@ -59,7 +59,7 @@ For the implementation migration path from the current codebase to this design s
|
||||
- policy-based behavior limits
|
||||
|
||||
- [COMBAT.md](/home/jbourdon/repos/space-game/docs/COMBAT.md)
|
||||
- local-space combat
|
||||
- localspace combat
|
||||
- piracy and station defense
|
||||
- claim destruction and contest
|
||||
- commander-driven engagement behavior
|
||||
@@ -90,7 +90,7 @@ For the implementation migration path from the current codebase to this design s
|
||||
|
||||
- [STATIONS.md](/home/jbourdon/repos/space-game/docs/STATIONS.md)
|
||||
- station roles
|
||||
- local bubble functions
|
||||
- localspace functions
|
||||
- station command requirements
|
||||
- station services, docking, and market responsibilities
|
||||
|
||||
|
||||
@@ -254,13 +254,13 @@ Those support goods are defined as item roles in [ITEMS.md](/home/jbourdon/repos
|
||||
|
||||
## Market And Space
|
||||
|
||||
The market should respect the spatial model in [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md).
|
||||
The market should respect the spatial model in [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md).
|
||||
|
||||
Implications:
|
||||
|
||||
- stations are nodes with local bubbles
|
||||
- docking and transfer happen in local-space
|
||||
- in-system logistics move through warp between nodes
|
||||
- stations live in localspaces owned by anchors
|
||||
- docking and transfer happen in localspace
|
||||
- in-system logistics move through warp between anchors
|
||||
- inter-system trade moves through stargates or FTL
|
||||
|
||||
Distance and travel friction should matter economically.
|
||||
|
||||
@@ -34,7 +34,8 @@ Every event should conceptually have:
|
||||
- `kind`
|
||||
- `spaceLayer`
|
||||
- `systemId?`
|
||||
- `bubbleId?`
|
||||
- `localspaceId?`
|
||||
- `anchorId?`
|
||||
- `primaryEntityKind`
|
||||
- `primaryEntityId`
|
||||
- `relatedEntityIds`
|
||||
@@ -51,7 +52,7 @@ Examples:
|
||||
|
||||
- universe-scale events belong to `universe-space`
|
||||
- strategic system events belong to `galaxy-space` or `system-space`
|
||||
- combat, docking, and claims belong to `local-space`
|
||||
- local tactical events belong to `localspace`
|
||||
|
||||
This is important for interest management.
|
||||
|
||||
@@ -95,8 +96,8 @@ These describe meaningful transitions in movement regime or location context.
|
||||
|
||||
Examples:
|
||||
|
||||
- `entered-local-bubble`
|
||||
- `left-local-bubble`
|
||||
- `entered-localspace`
|
||||
- `left-localspace`
|
||||
- `warp-spooling-started`
|
||||
- `warp-started`
|
||||
- `warp-arrived`
|
||||
@@ -314,7 +315,7 @@ The following rules should remain true unless deliberately revised:
|
||||
- [DATA-MODEL.md](/home/jbourdon/repos/space-game/docs/DATA-MODEL.md)
|
||||
- defines the entities referenced by events
|
||||
|
||||
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
|
||||
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
|
||||
- defines event scope by space layer
|
||||
|
||||
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
||||
|
||||
@@ -103,7 +103,7 @@ This is especially important because your station model is configuration-based r
|
||||
|
||||
## Structural Rule
|
||||
|
||||
Stations are built at one Lagrange point and expanded by adding modules.
|
||||
Stations are built at one valid construction anchor and expanded by adding modules.
|
||||
|
||||
That means modules are the main axis of station growth.
|
||||
|
||||
|
||||
@@ -79,9 +79,9 @@ Docking policy matters because no trade or transfer can happen without actual lo
|
||||
|
||||
Construction rights should be policy-relevant at the faction and system level.
|
||||
|
||||
This does not override the hard structural rule from [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md):
|
||||
This does not override the hard structural rule from [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md):
|
||||
|
||||
- one structure per Lagrange point
|
||||
- construction is only valid at supported construction anchors
|
||||
|
||||
But it does help define who is permitted or tolerated to claim and build in a given system.
|
||||
|
||||
@@ -149,13 +149,13 @@ Examples:
|
||||
|
||||
- friendly factions may trade and dock
|
||||
- neutral factions may trade but not build
|
||||
- hostile factions may be denied docking and targeted in local-space
|
||||
- hostile factions may be denied docking and targeted in localspace
|
||||
|
||||
The exact diplomacy system can evolve later, but policy should be ready to consume those relationship states.
|
||||
|
||||
## Policy And Claims
|
||||
|
||||
Claim objects at Lagrange points are visible and contestable.
|
||||
Claim objects at valid construction anchors are visible and contestable.
|
||||
|
||||
Policy influences:
|
||||
|
||||
@@ -186,7 +186,7 @@ The following rules should remain true unless deliberately revised:
|
||||
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
||||
- policy constrains trade participation
|
||||
|
||||
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
|
||||
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
|
||||
- policy interacts with claims, systems, and local access
|
||||
|
||||
- [STATIONS.md](/home/jbourdon/repos/space-game/docs/STATIONS.md)
|
||||
|
||||
@@ -182,7 +182,7 @@ It should:
|
||||
- allow delivery by traders or support ships
|
||||
- feed the actual building process
|
||||
|
||||
This is especially important during station founding at claimed Lagrange points.
|
||||
This is especially important during station founding at claimed construction anchors.
|
||||
|
||||
## Production Queues
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ The current simulation behavior is driven mostly by:
|
||||
|
||||
This gives the project a working prototype, but it does not yet reflect the design in:
|
||||
|
||||
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
|
||||
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
|
||||
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
|
||||
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
||||
- [WORKFORCE.md](/home/jbourdon/repos/space-game/docs/WORKFORCE.md)
|
||||
@@ -53,11 +53,11 @@ Target design:
|
||||
- `universe-space`
|
||||
- `galaxy-space`
|
||||
- `system-space`
|
||||
- `local-space`
|
||||
- node-attached local bubbles
|
||||
- one structure per Lagrange point
|
||||
- warp between nodes
|
||||
- local gameplay inside bubbles
|
||||
- `localspace`
|
||||
- anchor-owned localspaces
|
||||
- construction only at valid construction anchors
|
||||
- warp between anchors within one system
|
||||
- local gameplay inside localspaces
|
||||
|
||||
Current state:
|
||||
|
||||
@@ -66,16 +66,16 @@ Current state:
|
||||
- resource nodes exist, but only as extractable asteroid/gas sites
|
||||
- stations are positioned directly in system coordinates
|
||||
- ships move by `Position` and `TargetPosition`
|
||||
- no first-class system node graph
|
||||
- no first-class local bubbles
|
||||
- no first-class anchor graph
|
||||
- no first-class localspaces
|
||||
- no claim entities
|
||||
- no construction-site entities
|
||||
|
||||
Primary gaps:
|
||||
|
||||
- [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs) has no `NodeRuntime`, `LocalBubbleRuntime`, `ClaimRuntime`, or `ConstructionSiteRuntime`.
|
||||
- [`ScenarioLoader.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/ScenarioLoader.cs) computes station positions directly instead of creating node-backed placement.
|
||||
- [`SimulationEngine.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs) still treats travel as raw coordinate movement rather than node-to-node transit between spaces.
|
||||
- [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs) has no `AnchorRuntime`, `LocalspaceRuntime`, `ClaimRuntime`, or `ConstructionSiteRuntime`.
|
||||
- [`ScenarioLoader.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/ScenarioLoader.cs) computes station positions directly instead of creating anchor-backed placement.
|
||||
- [`SimulationEngine.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs) still treats travel as raw coordinate movement rather than anchor-to-anchor transit between spaces.
|
||||
|
||||
### Command and Control
|
||||
|
||||
@@ -202,12 +202,12 @@ Current state:
|
||||
|
||||
- viewer consumes a flat snapshot of systems, resource nodes, stations, ships, and factions
|
||||
- systems are strategic and visual, but not tied to explicit multi-space simulation layers
|
||||
- no contracts for bubbles, claims, construction sites, market orders, commanders, or policies
|
||||
- no contracts for localspaces, claims, construction sites, market orders, commanders, or policies
|
||||
|
||||
Primary gaps:
|
||||
|
||||
- [`contracts.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/contracts.ts) mirrors the old backend model.
|
||||
- [`GameViewer.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts) cannot render node graph, local bubbles, claims, or construction states because that data does not exist yet.
|
||||
- [`GameViewer.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts) cannot render an anchor graph, localspaces, claims, or construction states because that data does not exist yet.
|
||||
|
||||
## Subsystem Assessment
|
||||
|
||||
@@ -226,7 +226,7 @@ These should evolve in place.
|
||||
|
||||
- string-based ship state with explicit enums and structured movement state
|
||||
- ship-only planning fields with commander/task-layer entities
|
||||
- direct station placement with node, claim, and construction-site placement
|
||||
- direct station placement with anchor, claim, and construction-site placement
|
||||
- flat event records with typed event payloads
|
||||
|
||||
### Avoid
|
||||
@@ -246,8 +246,8 @@ Goal:
|
||||
Work:
|
||||
|
||||
- extend [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs) with:
|
||||
- `NodeRuntime`
|
||||
- `LocalBubbleRuntime`
|
||||
- `AnchorRuntime`
|
||||
- `LocalspaceRuntime`
|
||||
- `CommanderRuntime`
|
||||
- `ClaimRuntime`
|
||||
- `ConstructionSiteRuntime`
|
||||
@@ -261,33 +261,33 @@ Work:
|
||||
- commander kinds
|
||||
- add structured ship spatial state:
|
||||
- current space layer
|
||||
- current node
|
||||
- current bubble
|
||||
- current anchor
|
||||
- current localspace
|
||||
- current transit
|
||||
|
||||
Why first:
|
||||
|
||||
- every later change depends on this vocabulary existing in runtime state
|
||||
|
||||
### Phase 2: Refactor Scenario and World Building Around Nodes
|
||||
### Phase 2: Refactor Scenario and World Building Around Anchors
|
||||
|
||||
Goal:
|
||||
|
||||
- make systems produce a real node graph with local bubbles and Lagrange-backed construction points
|
||||
- make systems produce a real anchor graph with localspaces and supported construction anchors
|
||||
|
||||
Work:
|
||||
|
||||
- extend [`WorldDefinitions.cs`](/home/jbourdon/repos/space-game/apps/backend/Data/WorldDefinitions.cs) with authored node and claim-related definitions only where necessary
|
||||
- extend [`WorldDefinitions.cs`](/home/jbourdon/repos/space-game/apps/backend/Data/WorldDefinitions.cs) with authored anchor and claim-related definitions only where necessary
|
||||
- update [`ScenarioLoader.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/ScenarioLoader.cs) to:
|
||||
- generate system-space nodes for stars, planets, moons, stations, and Lagrange points
|
||||
- attach local bubbles to nodes
|
||||
- translate existing `planetIndex` and `lagrangeSide` station hints into actual node IDs
|
||||
- generate system-space anchors for stars, planets, moons, Lagrange points, and resource nodes where appropriate
|
||||
- attach localspaces to anchors
|
||||
- translate existing `planetIndex` and `lagrangeSide` station hints into actual anchor IDs
|
||||
- stop treating station placement as an arbitrary coordinate
|
||||
- preserve current authored content while migrating scenario interpretation
|
||||
|
||||
Why second:
|
||||
|
||||
- movement, claims, construction, and viewer transitions all depend on real nodes
|
||||
- movement, claims, construction, and viewer transitions all depend on real anchors
|
||||
|
||||
### Phase 3: Introduce Founding, Claims, and Construction Sites
|
||||
|
||||
@@ -303,7 +303,7 @@ Work:
|
||||
- active
|
||||
- destroyed
|
||||
- add construction-site runtime state with:
|
||||
- target node
|
||||
- target anchor
|
||||
- blueprint or constructible reference
|
||||
- construction storage inventory
|
||||
- construction buy orders
|
||||
@@ -315,7 +315,7 @@ Work:
|
||||
|
||||
Why here:
|
||||
|
||||
- it lets the code start reflecting the station and Lagrange rules without requiring the full economy rewrite first
|
||||
- it lets the code start reflecting the construction-anchor rules without requiring the full economy rewrite first
|
||||
|
||||
### Phase 4: Replace Raw Travel With Space-Aware Movement
|
||||
|
||||
@@ -326,8 +326,8 @@ Goal:
|
||||
Work:
|
||||
|
||||
- replace direct long-range movement with:
|
||||
- local thruster movement inside a bubble
|
||||
- in-system warp between nodes
|
||||
- local thruster movement inside a localspace
|
||||
- in-system warp between anchors
|
||||
- inter-system transit through gates or FTL
|
||||
- update ship runtime in [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs)
|
||||
- split movement logic in [`SimulationEngine.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs) into clearer controllers or subsystems
|
||||
@@ -335,7 +335,7 @@ Work:
|
||||
|
||||
Why here:
|
||||
|
||||
- this is the point where [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md) starts becoming real in the simulation
|
||||
- this is the point where [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md) starts becoming real in the simulation
|
||||
|
||||
### Phase 5: Introduce Commanders and Task Layers
|
||||
|
||||
@@ -376,7 +376,7 @@ Work:
|
||||
|
||||
Why here:
|
||||
|
||||
- once commanders and nodes exist, this becomes a coherent system instead of isolated resource transfers
|
||||
- once commanders and anchors exist, this becomes a coherent system instead of isolated resource transfers
|
||||
|
||||
### Phase 7: Upgrade Events and Streaming
|
||||
|
||||
@@ -391,7 +391,7 @@ Work:
|
||||
- universe
|
||||
- galaxy
|
||||
- system
|
||||
- local bubble
|
||||
- localspace
|
||||
- refactor [`WorldService.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/WorldService.cs) so subscriptions are observer-scoped rather than globally broadcast
|
||||
- support higher-space streaming with filtering into lower-space views
|
||||
|
||||
@@ -408,8 +408,8 @@ Goal:
|
||||
Work:
|
||||
|
||||
- extend [`contracts.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/contracts.ts) for:
|
||||
- nodes
|
||||
- local bubbles
|
||||
- anchors
|
||||
- localspaces
|
||||
- claims
|
||||
- construction sites
|
||||
- market orders
|
||||
@@ -417,8 +417,8 @@ Work:
|
||||
- richer ship movement state
|
||||
- update [`GameViewer.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts) to support:
|
||||
- galaxy/system/local scale transitions
|
||||
- node-centric system view
|
||||
- local bubble detail
|
||||
- anchor-centric system view
|
||||
- localspace detail
|
||||
- regime-aware ship rendering
|
||||
|
||||
Why last:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This document defines the intended role of ships in the simulation.
|
||||
|
||||
Ships are mobile actors that operate across the spatial model in [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md) under the authority of commanders defined in [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md).
|
||||
Ships are mobile actors that operate across the spatial model in [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md) under the authority of commanders defined in [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md).
|
||||
|
||||
## Core Principles
|
||||
|
||||
@@ -35,8 +35,8 @@ From a simulation point of view, the important distinction is not only hull cate
|
||||
|
||||
Ships should move according to the layered spatial model:
|
||||
|
||||
- thrusters in `local-space`
|
||||
- warp in `system-space`
|
||||
- thrusters inside a `localspace`
|
||||
- warp between anchors inside one system
|
||||
- stargate or FTL for inter-system travel
|
||||
|
||||
Ships should not behave as if an entire system is one continuous local dogfighting field.
|
||||
@@ -82,7 +82,7 @@ Those capabilities should primarily come from fitted modules as described in [MO
|
||||
|
||||
## Relationship To Other Documents
|
||||
|
||||
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
|
||||
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
|
||||
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
|
||||
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
||||
- [STATIONS.md](/home/jbourdon/repos/space-game/docs/STATIONS.md)
|
||||
|
||||
518
docs/SPACES.md
518
docs/SPACES.md
@@ -1,518 +0,0 @@
|
||||
# Spaces
|
||||
|
||||
This document defines the intended spatial model for the project.
|
||||
|
||||
It is a gameplay and simulation document first. The viewer should expose these layers clearly, but it does not define them.
|
||||
|
||||
The core idea is that the world is not one continuous field of arbitrary movement. Instead, gameplay happens across nested spatial layers with different rules, scales, and valid actions.
|
||||
|
||||
See [DATA-MODEL.md](/home/jbourdon/repos/space-game/docs/DATA-MODEL.md) for the entity vocabulary that should represent these layers.
|
||||
|
||||
## Design Goals
|
||||
|
||||
The spatial model should support:
|
||||
|
||||
- EVE-like travel pacing and readability
|
||||
- explicit transitions between tactical space and transit space
|
||||
- local combat and interaction bubbles
|
||||
- scalable simulation partitioning
|
||||
- scalable replication and interest management
|
||||
- future server-side sharding by local bubble if necessary
|
||||
|
||||
The intended feel is:
|
||||
|
||||
- local maneuvering is slow, deliberate, and readable
|
||||
- in-system travel is warp-based, not long free-flight
|
||||
- inter-system travel is explicit and infrastructural
|
||||
- zooming the viewer reveals different valid abstractions of the same world
|
||||
|
||||
## Space Layers
|
||||
|
||||
The simulation is divided into four major space layers:
|
||||
|
||||
1. `universe-space`
|
||||
2. `galaxy-space`
|
||||
3. `system-space`
|
||||
4. `local-space`
|
||||
|
||||
These are simulation layers, not just camera levels.
|
||||
|
||||
### `universe-space`
|
||||
|
||||
The broadest simulation layer.
|
||||
|
||||
This is where universe-wide state and events live, such as:
|
||||
|
||||
- global time
|
||||
- global factions and diplomacy
|
||||
- universe-scale event scheduling
|
||||
- future crises, anomalies, migrations, or story events
|
||||
|
||||
`universe-space` is not primarily about positional navigation. It is the top-most simulation context.
|
||||
|
||||
### `galaxy-space`
|
||||
|
||||
The layer where star systems exist as spatially arranged world entities.
|
||||
|
||||
This is where the game models:
|
||||
|
||||
- system positions
|
||||
- large-scale routes and proximity
|
||||
- inter-system connectivity
|
||||
- strategic movement between systems
|
||||
|
||||
Ships do not dogfight in `galaxy-space`. It is the strategic layer connecting systems.
|
||||
|
||||
### `system-space`
|
||||
|
||||
The layer inside a single star system where travel occurs between meaningful locations.
|
||||
|
||||
This is where the game models:
|
||||
|
||||
- stars
|
||||
- planets
|
||||
- moons
|
||||
- stations
|
||||
- structures
|
||||
- gates
|
||||
- resource locations, if they should be direct destinations
|
||||
- Lagrange points
|
||||
- travel relationships between these locations
|
||||
|
||||
`system-space` is the travel layer between local bubbles. Gameplay-wise, this can also be referred to as warp-space for ship movement inside a system.
|
||||
|
||||
### `local-space`
|
||||
|
||||
The tactical simulation bubble attached to a specific node.
|
||||
|
||||
This is where close interaction happens:
|
||||
|
||||
- thruster flight
|
||||
- combat
|
||||
- docking
|
||||
- undocking
|
||||
- mining
|
||||
- construction
|
||||
- rendezvous
|
||||
- local hazards
|
||||
|
||||
Each `local-space` is an isolated simulation bubble attached to one node. Ships leave one local bubble, exist in `system-space` during warp transit, then enter another local bubble on arrival.
|
||||
|
||||
Local bubbles are also the intended future unit of simulation partitioning. A later runtime may move one or more bubbles to dedicated servers without changing the gameplay model.
|
||||
|
||||
## Nodes
|
||||
|
||||
A node is a meaningful location in `system-space` that owns a `local-space` bubble.
|
||||
|
||||
Examples of nodes:
|
||||
|
||||
- star
|
||||
- planet
|
||||
- moon
|
||||
- station
|
||||
- structure
|
||||
- gate
|
||||
- major resource location if desired
|
||||
- each Lagrange point associated with a massive orbital
|
||||
|
||||
Each node should have:
|
||||
|
||||
- a stable identifier
|
||||
- a parent system
|
||||
- a type
|
||||
- a position in `system-space`
|
||||
- a local bubble radius
|
||||
- optional parent/child relationships
|
||||
- optional orbital metadata
|
||||
|
||||
Examples:
|
||||
|
||||
- a planet node orbits a star node
|
||||
- a moon node orbits a planet node
|
||||
- a station or structure node is built at a Lagrange point
|
||||
|
||||
## Lagrange Points
|
||||
|
||||
Each massive orbital should expose five Lagrange points:
|
||||
|
||||
- `L1`
|
||||
- `L2`
|
||||
- `L3`
|
||||
- `L4`
|
||||
- `L5`
|
||||
|
||||
These are valid nodes in `system-space`, each with its own `local-space`.
|
||||
|
||||
They should be computed from orbital relationships rather than authored by hand in most cases.
|
||||
|
||||
Construction rule:
|
||||
|
||||
- each Lagrange point can host exactly one structure
|
||||
|
||||
This scarcity is intentional. Lagrange points should be valuable strategic locations rather than interchangeable empty coordinates.
|
||||
|
||||
Recommended applicability:
|
||||
|
||||
- major orbitals should expose all five Lagrange points
|
||||
- this should apply at least to sufficiently massive planets
|
||||
- moons may expose none or fewer, depending on the final simulation rule
|
||||
|
||||
Lagrange points should not always be immediately buildable by default.
|
||||
|
||||
Recommended structure founding flow:
|
||||
|
||||
1. claim the Lagrange point
|
||||
2. protect the claim until it matures
|
||||
3. activate the claim
|
||||
4. begin station construction
|
||||
|
||||
Lagrange points are useful for:
|
||||
|
||||
- staging areas
|
||||
- logistics hubs
|
||||
- military control points
|
||||
- hidden or contested infrastructure
|
||||
- future economy and navigation design
|
||||
- station and structure placement
|
||||
|
||||
## Structures At Lagrange Points
|
||||
|
||||
Stations and other built structures should always be placed at Lagrange points.
|
||||
|
||||
This should be treated as a world rule, not merely a common pattern.
|
||||
|
||||
Implications:
|
||||
|
||||
- player and AI construction targets for major structures are Lagrange nodes
|
||||
- stations do not occupy arbitrary free positions in system-space
|
||||
- structure placement is tied to orbital geometry
|
||||
- economically and militarily valuable station locations are legible from the system map
|
||||
- each Lagrange point supports only one built structure
|
||||
- to create another station, a faction must secure another valid location
|
||||
|
||||
This makes system geography more understandable and gives Lagrange points durable strategic meaning.
|
||||
|
||||
## Claiming Lagrange Points
|
||||
|
||||
Station construction should begin with an explicit claim on a Lagrange point.
|
||||
|
||||
The claim should behave like a vulnerable placed object.
|
||||
|
||||
Properties:
|
||||
|
||||
- it marks intent to occupy the Lagrange point
|
||||
- it can be attacked or destroyed by enemies or pirates
|
||||
- it requires an activation period before full construction can begin
|
||||
|
||||
Recommended founding sequence:
|
||||
|
||||
1. a faction places a claim object at the target Lagrange point
|
||||
2. the claim survives for its activation time
|
||||
3. the claim becomes active
|
||||
4. station construction storage appears
|
||||
5. the desired station design creates demand for required construction materials
|
||||
6. constructor ships consume those materials to build the station
|
||||
|
||||
This makes Lagrange occupation contestable and visible.
|
||||
|
||||
## Local Bubbles
|
||||
|
||||
Each node owns exactly one local bubble.
|
||||
|
||||
A local bubble defines:
|
||||
|
||||
- the local simulation frame
|
||||
- the tactical interaction radius
|
||||
- the list of occupants
|
||||
- the set of legal actions inside that space
|
||||
- the future server authority boundary
|
||||
|
||||
Local bubbles should be treated as separate simulation contexts, not merely camera zoom-ins.
|
||||
|
||||
Rules:
|
||||
|
||||
- a ship in `local-space` belongs to exactly one bubble
|
||||
- actions such as combat, docking, mining, and construction happen only inside a local bubble
|
||||
- leaving a bubble is an explicit transition into `system-space`
|
||||
- entering a destination bubble is an explicit arrival transition from `system-space`
|
||||
|
||||
See [COMBAT.md](/home/jbourdon/repos/space-game/docs/COMBAT.md) for the combat consequences of this rule.
|
||||
|
||||
## Movement Regimes
|
||||
|
||||
Ships do not move with one universal locomotion model. Their movement depends on their current space layer and travel regime.
|
||||
|
||||
Primary regimes:
|
||||
|
||||
- `local-flight`
|
||||
- `warp`
|
||||
- `stargate-transit`
|
||||
- `ftl-transit`
|
||||
|
||||
### `local-flight`
|
||||
|
||||
Used inside `local-space`.
|
||||
|
||||
Characteristics:
|
||||
|
||||
- uses normal thrusters
|
||||
- supports fine positioning
|
||||
- supports docking and undocking
|
||||
- supports combat
|
||||
- supports mining and close interaction
|
||||
|
||||
This regime should feel heavy, readable, and tactical.
|
||||
|
||||
### `warp`
|
||||
|
||||
Used in `system-space` to travel between nodes in the same system.
|
||||
|
||||
Characteristics:
|
||||
|
||||
- exits one local bubble
|
||||
- enters spool-up
|
||||
- travels through `system-space`
|
||||
- drops out at a destination node
|
||||
- enters the destination local bubble
|
||||
|
||||
The intended feel is close to EVE, but with spool-up emphasis rather than strict align mechanics.
|
||||
|
||||
Recommended warp phases:
|
||||
|
||||
1. `warp-spooling`
|
||||
2. `in-warp`
|
||||
3. `warp-dropout`
|
||||
|
||||
During warp, ships should not be treated as locally maneuvering units.
|
||||
|
||||
### `stargate-transit`
|
||||
|
||||
Used to travel between systems through infrastructure.
|
||||
|
||||
Characteristics:
|
||||
|
||||
- starts from a gate node in local-space
|
||||
- transitions through a gate sequence
|
||||
- arrives at a gate node in another system
|
||||
|
||||
This should be a discrete inter-system travel mode, not merely a very long warp.
|
||||
|
||||
### `ftl-transit`
|
||||
|
||||
Used by ships that have their own FTL capability.
|
||||
|
||||
Characteristics:
|
||||
|
||||
- explicit inter-system travel regime
|
||||
- likely stronger costs, constraints, or cooldowns than gates
|
||||
- may bypass some infrastructure requirements
|
||||
|
||||
This remains distinct from in-system warp.
|
||||
|
||||
## Valid Actions By Space
|
||||
|
||||
### In `universe-space`
|
||||
|
||||
Examples:
|
||||
|
||||
- resolve universe-scale events
|
||||
- evaluate global strategic conditions
|
||||
- future narrative or crisis systems
|
||||
|
||||
### In `galaxy-space`
|
||||
|
||||
Examples:
|
||||
|
||||
- reason about systems
|
||||
- evaluate strategic routes
|
||||
- choose inter-system destinations
|
||||
|
||||
### In `system-space`
|
||||
|
||||
Examples:
|
||||
|
||||
- choose destination nodes
|
||||
- execute warp travel
|
||||
- evaluate node-to-node movement
|
||||
|
||||
### In `local-space`
|
||||
|
||||
Examples:
|
||||
|
||||
- maneuver with thrusters
|
||||
- dock
|
||||
- undock
|
||||
- mine
|
||||
- fight
|
||||
- build
|
||||
- transfer cargo locally
|
||||
|
||||
Rule of thumb:
|
||||
|
||||
- tactical interaction belongs to `local-space`
|
||||
- transit belongs to `system-space`
|
||||
- strategic topology belongs to `galaxy-space`
|
||||
|
||||
## Ship Spatial State
|
||||
|
||||
Ships should eventually be modeled with explicit spatial state, not just one position plus one target position.
|
||||
|
||||
At minimum, a ship should know:
|
||||
|
||||
- current `systemId`
|
||||
- current `spaceLayer`
|
||||
- current `nodeId`, if in local-space
|
||||
- current `localPosition`, if in local-space
|
||||
- current transit data, if in warp or inter-system travel
|
||||
|
||||
Recommended movement/runtime states include:
|
||||
|
||||
- `idle`
|
||||
- `local-flight`
|
||||
- `warp-spooling`
|
||||
- `in-warp`
|
||||
- `warp-dropout`
|
||||
- `docking`
|
||||
- `docked`
|
||||
- `undocking`
|
||||
- `using-stargate`
|
||||
- `ftl-spooling`
|
||||
- `in-ftl`
|
||||
- `arriving-from-ftl`
|
||||
|
||||
This spatial model works together with the command and economy documents:
|
||||
|
||||
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
|
||||
- defines who decides and delegates
|
||||
|
||||
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
||||
- defines how stations and factions participate economically
|
||||
|
||||
- [SHIPS.md](/home/jbourdon/repos/space-game/docs/SHIPS.md)
|
||||
- defines ship-side capability and behavior
|
||||
|
||||
- [STATIONS.md](/home/jbourdon/repos/space-game/docs/STATIONS.md)
|
||||
- defines station-side role and responsibility
|
||||
|
||||
## Orders And Destinations
|
||||
|
||||
Orders should target valid destinations in the spatial model.
|
||||
|
||||
In practice, that means ships should target nodes and bubbles rather than arbitrary points across an entire system.
|
||||
|
||||
Examples:
|
||||
|
||||
- travel to node
|
||||
- warp to node
|
||||
- dock at structure in node
|
||||
- mine in node
|
||||
- use gate to system
|
||||
- jump to system
|
||||
|
||||
This makes planning clearer and keeps traversal tied to the intended world structure.
|
||||
|
||||
## Viewer Expectations
|
||||
|
||||
The viewer should reveal these layers as the player zooms or changes context.
|
||||
|
||||
Desired viewer scales:
|
||||
|
||||
1. `universe/galaxy view`
|
||||
2. `system view`
|
||||
3. `local view`
|
||||
|
||||
### `universe/galaxy view`
|
||||
|
||||
Shows:
|
||||
|
||||
- systems
|
||||
- strategic relationships
|
||||
- large-scale motion and events
|
||||
|
||||
### `system view`
|
||||
|
||||
Shows:
|
||||
|
||||
- nodes inside one system
|
||||
- warp destinations
|
||||
- route structure
|
||||
- high-level in-system movement
|
||||
|
||||
This view should not pretend that all tactical detail is always present.
|
||||
|
||||
### `local view`
|
||||
|
||||
Shows:
|
||||
|
||||
- one node bubble in detail
|
||||
- ships maneuvering with thrusters
|
||||
- combat, docking, mining, and construction
|
||||
|
||||
Viewer zoom should expose valid abstractions of simulation truth rather than inventing unrelated presentation layers.
|
||||
|
||||
## Interest Management
|
||||
|
||||
This spatial model naturally supports interest management for streaming.
|
||||
|
||||
The general rule should be:
|
||||
|
||||
- always stream context from higher spaces
|
||||
- filter detailed context from lower spaces based on interest
|
||||
|
||||
Examples:
|
||||
|
||||
- a client viewing a local bubble still needs relevant system, galaxy, and universe context
|
||||
- a client viewing a system does not need full-fidelity tactical events from every local bubble
|
||||
- a client viewing the galaxy does not need continuous local combat state for every bubble
|
||||
|
||||
Suggested replication principle:
|
||||
|
||||
- high-layer events are broad but low-detail
|
||||
- low-layer events are narrow but high-detail
|
||||
|
||||
Example filtering:
|
||||
|
||||
- `universe-space` events can be broadcast widely
|
||||
- `galaxy-space` events can be scoped by region or strategic relevance
|
||||
- `system-space` events can be scoped by current or observed system
|
||||
- `local-space` events can be scoped by the specific bubble being observed or occupied
|
||||
|
||||
This should make future multiplayer scaling easier than a flat world-wide stream.
|
||||
|
||||
## Recommended Backend Direction
|
||||
|
||||
The backend should move toward:
|
||||
|
||||
- explicit node definitions
|
||||
- explicit local bubble definitions
|
||||
- explicit ship travel regimes
|
||||
- explicit transitions between local, system, and inter-system movement
|
||||
|
||||
Recommended progression:
|
||||
|
||||
1. define nodes and local bubbles in world/runtime contracts
|
||||
2. compute and include Lagrange points for major orbitals
|
||||
3. refactor ship movement around regimes instead of free system-local travel
|
||||
4. restrict tactical actions to local-space
|
||||
5. expose regime and bubble membership in replication contracts
|
||||
6. redesign viewer zoom and replication around these layers
|
||||
|
||||
## Invariants
|
||||
|
||||
These rules should remain true unless there is a very deliberate design reason to break them:
|
||||
|
||||
- every local bubble belongs to exactly one node
|
||||
- every ship is in exactly one major movement regime at a time
|
||||
- ships in different local bubbles are not co-located, even if they are in the same system
|
||||
- movement inside local-space uses thrusters
|
||||
- movement between nodes inside a system uses warp
|
||||
- movement between systems uses stargates or ship FTL
|
||||
- combat, docking, mining, and construction happen only in local-space
|
||||
- viewer abstractions should map back to real simulation layers
|
||||
- every structure occupies exactly one Lagrange point
|
||||
- every Lagrange point supports at most one structure
|
||||
- a station site must be claimed before full construction begins
|
||||
|
||||
## Open Follow-Up Documents
|
||||
|
||||
This document is part of the official design set listed in [DESIGN.md](/home/jbourdon/repos/space-game/docs/DESIGN.md).
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This document defines the intended role of stations in the simulation.
|
||||
|
||||
Stations are persistent nodes in the world that own local bubbles, provide services, participate in the economy, and act through station commanders.
|
||||
Stations are persistent constructions in the world that live in a localspace, provide services, participate in the economy, and act through station commanders.
|
||||
|
||||
## Core Principles
|
||||
|
||||
@@ -17,7 +17,7 @@ A station lives at one location.
|
||||
|
||||
It does not spread across multiple construction points.
|
||||
|
||||
Station growth happens by adding modules to the existing station at its current Lagrange point.
|
||||
Station growth happens by adding modules to the existing station at its current construction location.
|
||||
|
||||
To create another station, a faction needs:
|
||||
|
||||
@@ -25,7 +25,7 @@ To create another station, a faction needs:
|
||||
- another station
|
||||
- another station commander
|
||||
|
||||
Before station construction itself, the faction should also secure the site through the Lagrange-point claim process defined in [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md).
|
||||
Before station construction itself, the faction should secure the chosen site through the appropriate local claim or founding process defined by the universe model.
|
||||
|
||||
Station identity and capability should primarily come from module composition, as described in [MODULES.md](/home/jbourdon/repos/space-game/docs/MODULES.md).
|
||||
|
||||
@@ -44,13 +44,15 @@ Stations may serve as:
|
||||
|
||||
## Spatial Role
|
||||
|
||||
Stations are nodes in `system-space` with their own `local-space`.
|
||||
Stations are constructions that exist inside one `localspace`.
|
||||
|
||||
Stations and other built structures should always be located at Lagrange points.
|
||||
Stations may be built at valid construction anchors such as:
|
||||
|
||||
Each Lagrange point can hold only one structure.
|
||||
- planets
|
||||
- moons
|
||||
- Lagrange points
|
||||
|
||||
Their local bubbles are where:
|
||||
Their localspaces are where:
|
||||
|
||||
- docking happens
|
||||
- cargo transfer happens
|
||||
@@ -58,13 +60,13 @@ Their local bubbles are where:
|
||||
- local construction happens
|
||||
- player and AI interaction happens
|
||||
|
||||
Local-space defense and contestation are described further in [COMBAT.md](/home/jbourdon/repos/space-game/docs/COMBAT.md).
|
||||
Localspace defense and contestation are described further in [COMBAT.md](/home/jbourdon/repos/space-game/docs/COMBAT.md).
|
||||
|
||||
## Founding A Station
|
||||
|
||||
The intended founding flow is:
|
||||
|
||||
1. claim a valid Lagrange point
|
||||
1. claim or secure a valid construction anchor
|
||||
2. wait for the claim to activate
|
||||
3. create station construction storage
|
||||
4. publish buy orders for the required construction materials
|
||||
@@ -141,15 +143,9 @@ Actual participation in trade and docking should still be policy-controlled. See
|
||||
|
||||
Station ownership is not local-bubble exclusive.
|
||||
|
||||
The important hard rule is:
|
||||
Ownership does not imply one faction per localspace.
|
||||
|
||||
- one structure per Lagrange point
|
||||
|
||||
Not:
|
||||
|
||||
- one faction per local bubble
|
||||
|
||||
This means friendly or otherwise permitted factions may build stations within the same system, so long as they use different valid locations.
|
||||
Friendly or otherwise permitted factions may build stations within the same system so long as they use different valid construction locations.
|
||||
|
||||
## Services
|
||||
|
||||
@@ -181,7 +177,7 @@ Population growth and decline follow the rules in [WORKFORCE.md](/home/jbourdon/
|
||||
|
||||
## Relationship To Other Documents
|
||||
|
||||
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
|
||||
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
|
||||
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
|
||||
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
||||
- [SHIPS.md](/home/jbourdon/repos/space-game/docs/SHIPS.md)
|
||||
|
||||
@@ -55,12 +55,12 @@ Orders should survive replanning better than tasks.
|
||||
|
||||
Examples:
|
||||
|
||||
- travel to node
|
||||
- travel to anchor
|
||||
- dock at station
|
||||
- claim Lagrange point
|
||||
- claim construction anchor
|
||||
- build station here
|
||||
- escort this ship
|
||||
- defend this bubble
|
||||
- defend this localspace
|
||||
|
||||
Orders are the main override layer above routine autonomous behavior.
|
||||
|
||||
@@ -240,13 +240,13 @@ This prevents autonomous loops from becoming self-destructive.
|
||||
|
||||
## Space-Aware Tasking
|
||||
|
||||
Tasks should respect the spatial model in [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md).
|
||||
Tasks should respect the spatial model in [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md).
|
||||
|
||||
That means:
|
||||
|
||||
- local maneuvering is distinct from warp
|
||||
- docking is local-space work
|
||||
- cargo transfer is local-space work
|
||||
- docking is localspace work
|
||||
- cargo transfer is localspace work
|
||||
- inter-system travel is distinct from in-system travel
|
||||
|
||||
## Policy-Aware Tasking
|
||||
@@ -305,7 +305,7 @@ The following rules should remain true unless deliberately revised:
|
||||
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
|
||||
- defines who decides and delegates
|
||||
|
||||
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
|
||||
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
|
||||
- defines where tasks can occur
|
||||
|
||||
- [POLICIES.md](/home/jbourdon/repos/space-game/docs/POLICIES.md)
|
||||
|
||||
501
docs/UNIVERSE-MODEL.md
Normal file
501
docs/UNIVERSE-MODEL.md
Normal file
@@ -0,0 +1,501 @@
|
||||
# Universe Model
|
||||
|
||||
This document defines the intended world structure for the game.
|
||||
|
||||
It is the canonical reference for:
|
||||
|
||||
- galaxy structure
|
||||
- solar systems
|
||||
- celestials
|
||||
- anchors
|
||||
- localspaces
|
||||
- ship and station placement
|
||||
- intra-system travel
|
||||
- inter-system travel
|
||||
- construction placement
|
||||
|
||||
This document is design-first. It does not describe the current implementation. It describes the target world model the simulation and viewer should converge toward.
|
||||
|
||||
Where older documents conflict with this one, this document should win.
|
||||
|
||||
## Core Intent
|
||||
|
||||
The game is a galaxy simulation built from nested spatial layers.
|
||||
|
||||
The structure can be understood as a tree:
|
||||
|
||||
- the galaxy contains solar systems
|
||||
- each solar system contains celestials and other derived locations
|
||||
- each meaningful location is an anchor
|
||||
- each anchor owns one localspace
|
||||
|
||||
The intended structure is:
|
||||
|
||||
1. `galaxy`
|
||||
2. `solar system`
|
||||
3. `anchor`
|
||||
4. `localspace`
|
||||
|
||||
Ships and stations do not live in arbitrary free-floating "system local space".
|
||||
|
||||
They live in a localspace tied to something meaningful.
|
||||
|
||||
That anchor is usually:
|
||||
|
||||
- a celestial
|
||||
- a Lagrange point
|
||||
- a resource node
|
||||
|
||||
Stargates and stations are not anchors by themselves. They are constructions that live inside a localspace.
|
||||
|
||||
This keeps the world legible, gives travel structure, and makes infrastructure placement strategically meaningful.
|
||||
|
||||
## Galaxy
|
||||
|
||||
The galaxy is the top-level navigable world map.
|
||||
|
||||
It contains:
|
||||
|
||||
- solar systems
|
||||
- system-to-system distances
|
||||
- inter-system connectivity
|
||||
- the strategic map used for expansion, trade planning, diplomacy, and route planning
|
||||
|
||||
The galaxy is not a combat layer.
|
||||
|
||||
Ships do not dogfight in galaxy space. Inter-system movement is represented strategically until a ship arrives in its destination system.
|
||||
|
||||
The galaxy and system visualizations are primarily about information gathering:
|
||||
|
||||
- what exists
|
||||
- what is close
|
||||
- where resources are
|
||||
- who controls what
|
||||
|
||||
## Solar Systems
|
||||
|
||||
A solar system is a strategic and economic container.
|
||||
|
||||
A solar system contains:
|
||||
|
||||
- stars
|
||||
- planets
|
||||
- moons
|
||||
- Lagrange points
|
||||
- resource nodes
|
||||
- constructions that exist inside localspaces, such as stations and stargates
|
||||
|
||||
A solar system is not itself a single tactical playspace.
|
||||
|
||||
Instead, it is a collection of anchored localspaces plus the travel relationships between them.
|
||||
|
||||
Systems remain important for:
|
||||
|
||||
- map readability
|
||||
- strategic routing
|
||||
- economy aggregation
|
||||
- territorial and diplomatic meaning
|
||||
- visibility and ownership summaries
|
||||
|
||||
## Anchors
|
||||
|
||||
An anchor is a meaningful object in a system that owns a localspace.
|
||||
|
||||
Anchors are first-class world entities.
|
||||
|
||||
Initial anchor types:
|
||||
|
||||
- `star`
|
||||
- `planet`
|
||||
- `moon`
|
||||
- `lagrange-point`
|
||||
- `resource-node`
|
||||
|
||||
Future anchor types may exist, but they should only be introduced if they create real gameplay value.
|
||||
|
||||
Each anchor should have:
|
||||
|
||||
- a stable ID
|
||||
- a parent `systemId`
|
||||
- an anchor type
|
||||
- a position in system space
|
||||
- optional orbital metadata
|
||||
- an associated localspace definition
|
||||
- optional parent/child relationships
|
||||
|
||||
Examples:
|
||||
|
||||
- a planet is an anchor and a celestial
|
||||
- a moon is an anchor and a celestial
|
||||
- a Lagrange point is an anchor but not a celestial
|
||||
- a resource node is an anchor but not a celestial
|
||||
- a Lagrange point can be the child of a moon, which is the child of a planet, which is the child of a star
|
||||
|
||||
Every anchor has exactly one localspace.
|
||||
|
||||
## Celestials
|
||||
|
||||
Celestials are the natural massive bodies in a system.
|
||||
|
||||
Initial celestial types:
|
||||
|
||||
- `star`
|
||||
- `planet`
|
||||
- `moon`
|
||||
|
||||
Celestials exist for three reasons:
|
||||
|
||||
1. they structure the solar system visually and strategically
|
||||
2. they define orbital relationships
|
||||
3. they provide valid anchors for localspaces and derived locations such as Lagrange points
|
||||
|
||||
Every star, planet, and moon gets a localspace.
|
||||
|
||||
Not all anchors are celestials, but all celestials are anchors.
|
||||
|
||||
## Lagrange Points
|
||||
|
||||
Lagrange points are explicit anchors derived from celestial orbital relationships.
|
||||
|
||||
They are not decorative metadata.
|
||||
|
||||
They are valid construction locations, but they are not the only valid construction locations.
|
||||
|
||||
Initial assumptions:
|
||||
|
||||
- major orbitals can expose `L1` through `L5`
|
||||
- each exposed Lagrange point is its own anchor
|
||||
- each exposed Lagrange point has its own localspace
|
||||
- Lagrange points are valid construction sites
|
||||
|
||||
For now, all five may exist for supported orbitals, but the intended direction is that only major planets should necessarily expose all five.
|
||||
|
||||
Lagrange points are useful for:
|
||||
|
||||
- logistics hubs
|
||||
- defense platforms
|
||||
- industrial complexes
|
||||
- stargates
|
||||
- staging areas
|
||||
|
||||
## Resource Nodes
|
||||
|
||||
Resource nodes should be treated as anchors.
|
||||
|
||||
That means a resource node can have:
|
||||
|
||||
- a stable identity
|
||||
- a place in a solar system
|
||||
- its own localspace
|
||||
|
||||
This is desirable because it allows resources to exist anywhere meaningful in a system while still fitting the anchored localspace model.
|
||||
|
||||
A resource node is not a celestial.
|
||||
|
||||
It is an anchor with a localspace centered on an extractable site.
|
||||
|
||||
Resource nodes are not construction sites.
|
||||
|
||||
That is intentional so they can be spawned, depleted, despawned, and regenerated more freely than permanent infrastructure anchors.
|
||||
|
||||
## Localspace
|
||||
|
||||
`localspace` is the tactical simulation term and should be the only term used for this concept.
|
||||
|
||||
Do not use:
|
||||
|
||||
- `sector`
|
||||
- `local-space`
|
||||
- `anchored sector`
|
||||
|
||||
Use:
|
||||
|
||||
- `localspace`
|
||||
|
||||
Each localspace belongs to exactly one anchor.
|
||||
|
||||
Examples:
|
||||
|
||||
- the localspace around a planet
|
||||
- the localspace around a moon
|
||||
- the localspace around a Lagrange point
|
||||
- the localspace around a resource node
|
||||
|
||||
Localspace is where close simulation happens:
|
||||
|
||||
- thruster movement
|
||||
- combat
|
||||
- docking
|
||||
- undocking
|
||||
- mining
|
||||
- station operation
|
||||
- station construction
|
||||
- local logistics
|
||||
- tactical defense
|
||||
|
||||
Ships and constructions do not exist directly in system space. They exist in one localspace at a time unless they are explicitly traveling between anchors.
|
||||
|
||||
## Ship Placement
|
||||
|
||||
A ship always belongs to exactly one localspace unless it is actively transitioning between anchors.
|
||||
|
||||
Normal ship state should be one of:
|
||||
|
||||
- in a localspace
|
||||
- traveling between localspaces in the same system
|
||||
- traveling between systems
|
||||
|
||||
Inside a localspace, ships use tactical movement with thrusters.
|
||||
|
||||
While moving between anchors inside a system, ships should be in an explicit warp travel state rather than pretending that the entire solar system is one free-flight arena.
|
||||
|
||||
## Station Placement
|
||||
|
||||
Stations are not arbitrary coordinates in a system.
|
||||
|
||||
Stations belong to localspaces.
|
||||
|
||||
Stations may be built at any valid anchor that supports the intended construction.
|
||||
|
||||
That includes:
|
||||
|
||||
- planets
|
||||
- moons
|
||||
- Lagrange points
|
||||
|
||||
That does not include resource nodes.
|
||||
|
||||
Lagrange points are one important construction option, not the default answer for all stations.
|
||||
|
||||
Each construction site should be understandable from the system map:
|
||||
|
||||
- what localspace it belongs to
|
||||
- what role it serves
|
||||
- why it matters
|
||||
|
||||
## Stargates
|
||||
|
||||
Stargates are constructed or placed objects that live inside a localspace.
|
||||
|
||||
A stargate is not abstract system metadata.
|
||||
|
||||
It should:
|
||||
|
||||
- exist at a real place in space
|
||||
- have a tactical presence
|
||||
- be defensible or contestable
|
||||
- connect one system to another through a linked gate
|
||||
|
||||
Default rule:
|
||||
|
||||
- a stargate is built in a localspace related to a valid anchor
|
||||
|
||||
Player factions and NPC factions can both own and build stargates.
|
||||
|
||||
Inter-system travel is normally done using gates, though some ships may later support direct FTL as a special capability.
|
||||
|
||||
## System Space
|
||||
|
||||
System space still exists, but it is not the primary gameplay space.
|
||||
|
||||
System space is the strategic layer that relates anchors to one another.
|
||||
|
||||
It is used for:
|
||||
|
||||
- anchor positions
|
||||
- orbital relationships
|
||||
- path planning between anchors
|
||||
- travel graph generation
|
||||
- map presentation
|
||||
|
||||
System space should not be treated as a giant tactical sandbox where ships idle, fight, mine, and build anywhere.
|
||||
|
||||
That is the main implementation mistake this model is meant to prevent.
|
||||
|
||||
## Intra-System Travel
|
||||
|
||||
Travel inside a solar system is movement between anchors.
|
||||
|
||||
This should feel like warp travel, not long-duration manual flight across a whole star system.
|
||||
|
||||
Core rule:
|
||||
|
||||
- ships move tactically inside a localspace using thrusters
|
||||
- ships transition into warp travel state to move to another anchor in the same system
|
||||
- ships arrive into the destination anchor's localspace
|
||||
|
||||
This means intra-system travel has two different regimes:
|
||||
|
||||
1. local tactical movement by thrusters
|
||||
2. anchor-to-anchor warp transit
|
||||
|
||||
Arrival timing is sufficient. The transit does not need fully continuous tactical simulation.
|
||||
|
||||
Ships in warp still exist as simulated travel-state entities with:
|
||||
|
||||
- an origin anchor
|
||||
- a destination anchor
|
||||
- a departure time
|
||||
- an arrival time or ETA
|
||||
- ownership
|
||||
- travel state
|
||||
|
||||
Those ships may be shown individually or as grouped representations in the viewer, but that is a presentation choice rather than a different simulation rule.
|
||||
|
||||
## Inter-System Travel
|
||||
|
||||
Travel between systems is explicit FTL travel.
|
||||
|
||||
Initial rule:
|
||||
|
||||
- inter-system travel happens through gates
|
||||
|
||||
This keeps system boundaries meaningful and keeps the galaxy map strategically understandable.
|
||||
|
||||
Some ships may later have direct FTL capability, probably specialized exploration ships, but that should be treated as an extension to the model rather than the baseline rule.
|
||||
|
||||
## Fleets
|
||||
|
||||
Fleets are an organizational concept, not a distinct spatial simulation layer.
|
||||
|
||||
A fleet is created by an owner decision:
|
||||
|
||||
- player
|
||||
- AI faction
|
||||
- other future controller types if needed
|
||||
|
||||
Fleets are used to:
|
||||
|
||||
- group ships
|
||||
- define hierarchy
|
||||
- configure wing behavior
|
||||
- assign coordinated movement or combat roles
|
||||
|
||||
That means:
|
||||
|
||||
- fleets are not anchors
|
||||
- fleets do not own localspaces
|
||||
- fleets do not use a special travel model
|
||||
|
||||
If ships in a fleet are in warp transit, they are still individual ships in transit.
|
||||
|
||||
The viewer or command layer may choose to display them as one fleet movement when appropriate, but that is derived from ship state and ownership structure, not a different spatial rule.
|
||||
|
||||
## Construction Model
|
||||
|
||||
Construction should be localspace-driven.
|
||||
|
||||
That means:
|
||||
|
||||
- construction starts at a valid anchor
|
||||
- the localspace determines where the construction exists
|
||||
- claims and construction remain spatially meaningful
|
||||
|
||||
This does not mean every anchor type has the same restrictions. It only means construction is never detached from place.
|
||||
|
||||
Recommended founding flow:
|
||||
|
||||
1. identify a valid anchor
|
||||
2. place a claim or founding marker if required
|
||||
3. defend and mature the claim if required
|
||||
4. deploy construction logistics
|
||||
5. build the station or stargate in that localspace
|
||||
|
||||
## Viewer Implications
|
||||
|
||||
The viewer should reflect the world model instead of flattening it.
|
||||
|
||||
Desired viewer hierarchy:
|
||||
|
||||
1. galaxy view
|
||||
2. system view
|
||||
3. localspace view
|
||||
|
||||
Galaxy view should show:
|
||||
|
||||
- systems as a cloud of stars
|
||||
- ownership
|
||||
- routes, especially stargate links
|
||||
- strategic posture
|
||||
|
||||
System view should show tactical information about the system, including:
|
||||
|
||||
- stars
|
||||
- planets
|
||||
- moons
|
||||
- Lagrange points
|
||||
- stations
|
||||
- gates
|
||||
- fleets
|
||||
- ships
|
||||
- enemies
|
||||
- travel relationships
|
||||
- approximate transit positions or transit state for ships moving between anchors
|
||||
- orbital relationships
|
||||
|
||||
Localspace view should show:
|
||||
|
||||
- ships
|
||||
- stations
|
||||
- tactical movement
|
||||
- combat
|
||||
- construction
|
||||
- docking
|
||||
|
||||
The viewer should not imply that the full solar system is one continuous local battlefield.
|
||||
|
||||
## Ownership And Sovereignty
|
||||
|
||||
Ownership and sovereignty should primarily be tracked at system level.
|
||||
|
||||
This is not fully defined yet, but the current design direction is:
|
||||
|
||||
- systems are the main sovereignty unit
|
||||
- localspaces and constructions exist inside systems
|
||||
- local conflicts and control still matter tactically
|
||||
|
||||
## Simulation Implications
|
||||
|
||||
This model supports:
|
||||
|
||||
- clearer AI tasking
|
||||
- clearer travel states
|
||||
- better tactical readability
|
||||
- better interest management
|
||||
- more precise placement of industry, defense, and resource activity
|
||||
|
||||
It also gives a cleaner authority boundary for later scaling:
|
||||
|
||||
- one localspace can become one simulation partition
|
||||
- one system can remain a higher-level strategic container
|
||||
|
||||
This supports:
|
||||
|
||||
- interest management
|
||||
- tactical culling
|
||||
- isolated combat and logistics spaces
|
||||
- future sharding without redefining the gameplay model
|
||||
|
||||
## Non-Goals
|
||||
|
||||
This model does not require:
|
||||
|
||||
- one continuous free-flight world across the whole galaxy
|
||||
- one giant tactical playspace per solar system
|
||||
- arbitrary station placement anywhere
|
||||
- abstract gates that do not exist in space
|
||||
- non-anchored deep-space locations as a core gameplay requirement
|
||||
|
||||
## Current Naming Guidance
|
||||
|
||||
Until the implementation is updated, the following terms should be used consistently in design discussions:
|
||||
|
||||
- `galaxy`: the top-level strategic star map
|
||||
- `system`: the solar system container
|
||||
- `celestial`: star, planet, or moon
|
||||
- `anchor`: anything that owns a localspace
|
||||
- `localspace`: the tactical simulation bubble attached to one anchor
|
||||
- `intra-system warp`: movement between anchors in the same system
|
||||
- `inter-system FTL`: movement between systems
|
||||
|
||||
Avoid using `system local space` and `sector` as design terms going forward. They are too ambiguous and encourage the wrong implementation.
|
||||
41
scripts/start-postgres.sh
Executable file
41
scripts/start-postgres.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
container_name="${SPACEGAME_POSTGRES_CONTAINER:-space-game-postgres}"
|
||||
image_name="${SPACEGAME_POSTGRES_IMAGE:-postgres:16}"
|
||||
host_port="${SPACEGAME_POSTGRES_PORT:-5432}"
|
||||
database_name="${SPACEGAME_POSTGRES_DB:-spacegame}"
|
||||
database_user="${SPACEGAME_POSTGRES_USER:-spacegame}"
|
||||
database_password="${SPACEGAME_POSTGRES_PASSWORD:-spacegame}"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker is required but was not found in PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
existing_container_id="$(docker ps -aq -f "name=^${container_name}$")"
|
||||
|
||||
if [[ -z "${existing_container_id}" ]]; then
|
||||
echo "Creating Postgres container '${container_name}'..."
|
||||
docker run -d \
|
||||
--name "${container_name}" \
|
||||
-e POSTGRES_DB="${database_name}" \
|
||||
-e POSTGRES_USER="${database_user}" \
|
||||
-e POSTGRES_PASSWORD="${database_password}" \
|
||||
-p "${host_port}:5432" \
|
||||
"${image_name}" >/dev/null
|
||||
echo "Postgres container created and started."
|
||||
else
|
||||
running_container_id="$(docker ps -q -f "name=^${container_name}$")"
|
||||
if [[ -n "${running_container_id}" ]]; then
|
||||
echo "Postgres container '${container_name}' is already running."
|
||||
else
|
||||
echo "Starting Postgres container '${container_name}'..."
|
||||
docker start "${container_name}" >/dev/null
|
||||
echo "Postgres container started."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Connection string:"
|
||||
echo "Host=127.0.0.1;Port=${host_port};Database=${database_name};Username=${database_user};Password=${database_password}"
|
||||
137
worksheet.md
137
worksheet.md
@@ -1,137 +0,0 @@
|
||||
# Pre-Commit Review Worksheet
|
||||
|
||||
This worksheet covers the uncommitted work from the long session after `0bb72be`.
|
||||
|
||||
## 1. World Bootstrap, Scenario, And Generation
|
||||
|
||||
- [ ] Confirm the backend now starts from the empty scenario and still wires auth/player-state services correctly.
|
||||
Review: [Program.cs](/home/jbourdon/repos/space-game/apps/backend/Program.cs#L12), [Program.cs](/home/jbourdon/repos/space-game/apps/backend/Program.cs#L69), [Program.cs](/home/jbourdon/repos/space-game/apps/backend/Program.cs#L97), [Program.cs](/home/jbourdon/repos/space-game/apps/backend/Program.cs#L132)
|
||||
- [ ] Confirm the world-build pipeline is now split into explicit phases instead of a single muddy bootstrap path.
|
||||
Review: [WorldBuilder.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Scenario/WorldBuilder.cs#L4), [WorldTopologyBuilder.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Scenario/WorldTopologyBuilder.cs#L5), [ScenarioValidationService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Scenario/ScenarioValidationService.cs#L5), [ScenarioContentBuilder.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs#L9), [WorldRuntimeAssembler.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Scenario/WorldRuntimeAssembler.cs#L5)
|
||||
- [ ] Confirm known systems are generation input now, not hardcoded forced inclusions.
|
||||
Review: [WorldGenerationOptions.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldGenerationOptions.cs#L7), [SystemGenerationService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Scenario/SystemGenerationService.cs#L29), [SystemGenerationService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Scenario/SystemGenerationService.cs#L234), [StaticDataProvider.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Bootstrap/StaticDataProvider.cs#L35)
|
||||
|
||||
## 2. Static Data Canonicalization And Ship Model Cleanup
|
||||
|
||||
- [ ] Confirm static data now loads the promoted `shared/data` files with enum-string deserialization.
|
||||
Review: [StaticDataProvider.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Bootstrap/StaticDataProvider.cs#L19), [StaticDataProvider.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Bootstrap/StaticDataProvider.cs#L27), [StaticDataProvider.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Bootstrap/StaticDataProvider.cs#L30)
|
||||
- [ ] Confirm `ShipDefinition` is now using the X4 domain model directly.
|
||||
Review: [WorldDefinitions.cs](/home/jbourdon/repos/space-game/apps/backend/Definitions/WorldDefinitions.cs#L372), [WorldDefinitions.cs](/home/jbourdon/repos/space-game/apps/backend/Definitions/WorldDefinitions.cs#L390), [WorldDefinitions.cs](/home/jbourdon/repos/space-game/apps/backend/Definitions/WorldDefinitions.cs#L438)
|
||||
- [ ] Confirm the old compatibility baggage is gone or reduced.
|
||||
Review: [WorldDefinitions.cs](/home/jbourdon/repos/space-game/apps/backend/Definitions/WorldDefinitions.cs#L472), [WorldDefinitions.cs](/home/jbourdon/repos/space-game/apps/backend/Definitions/WorldDefinitions.cs#L474), [WorldDefinitions.cs](/home/jbourdon/repos/space-game/apps/backend/Definitions/WorldDefinitions.cs#L479)
|
||||
- [ ] Confirm ship classification and movement capability checks now use explicit helpers instead of fake capability bags or fake role taxonomies.
|
||||
Review: [KnownShipTaxonomy.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/KnownShipTaxonomy.cs#L3), [SimulationRuntimeSupport.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs#L6), [SimulationRuntimeSupport.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs#L12), [SimulationRuntimeSupport.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs#L182)
|
||||
|
||||
## 3. Auth, Versioning, And Player-State Separation
|
||||
|
||||
- [ ] Confirm local auth is wired with JWT access/refresh and no longer depends on the old single-player world-owned faction state.
|
||||
Review: [AuthService.cs](/home/jbourdon/repos/space-game/apps/backend/Auth/Simulation/AuthService.cs#L3), [AuthService.cs](/home/jbourdon/repos/space-game/apps/backend/Auth/Simulation/AuthService.cs#L37), [PostgresAuthRepository.cs](/home/jbourdon/repos/space-game/apps/backend/Auth/Simulation/PostgresAuthRepository.cs#L5), [HttpContextPlayerIdentityResolver.cs](/home/jbourdon/repos/space-game/apps/backend/Auth/Simulation/HttpContextPlayerIdentityResolver.cs#L6)
|
||||
- [ ] Confirm forgot/reset password is wired through the delivery seam rather than hardcoded email behavior.
|
||||
Review: [AuthService.cs](/home/jbourdon/repos/space-game/apps/backend/Auth/Simulation/AuthService.cs#L58), [AuthService.cs](/home/jbourdon/repos/space-game/apps/backend/Auth/Simulation/AuthService.cs#L74), [IPasswordResetDelivery.cs](/home/jbourdon/repos/space-game/apps/backend/Auth/Simulation/IPasswordResetDelivery.cs#L5), [DevPasswordResetDelivery.cs](/home/jbourdon/repos/space-game/apps/backend/Auth/Simulation/DevPasswordResetDelivery.cs#L5)
|
||||
- [ ] Confirm dev accounts and roles are seeded only through the dev seeder.
|
||||
Review: [DevAuthSeeder.cs](/home/jbourdon/repos/space-game/apps/backend/Auth/Simulation/DevAuthSeeder.cs#L12), [Program.cs](/home/jbourdon/repos/space-game/apps/backend/Program.cs#L112), [Program.cs](/home/jbourdon/repos/space-game/apps/backend/Program.cs#L132)
|
||||
- [ ] Confirm version reporting exists and is reviewable during local backend restarts.
|
||||
Review: [AppVersionService.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/AppVersionService.cs#L6), [GetVersionHandler.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Api/GetVersionHandler.cs#L5)
|
||||
- [ ] Confirm player state is no longer owned by `SimulationWorld` and now lives behind a store/projection boundary.
|
||||
Review: [IPlayerStateStore.cs](/home/jbourdon/repos/space-game/apps/backend/PlayerFaction/Simulation/IPlayerStateStore.cs#L3), [PlayerStateStore.cs](/home/jbourdon/repos/space-game/apps/backend/PlayerFaction/Simulation/PlayerStateStore.cs#L3), [PlayerFactionProjectionService.cs](/home/jbourdon/repos/space-game/apps/backend/PlayerFaction/Simulation/PlayerFactionProjectionService.cs#L3), [WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldService.cs#L18)
|
||||
|
||||
## 4. GM / Dev Loop
|
||||
|
||||
- [ ] Confirm the GM account can mutate an empty world without requiring a fake player scenario.
|
||||
Review: [WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldService.cs#L262), [WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldService.cs#L287), [WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldService.cs#L326)
|
||||
- [ ] Confirm direct ship control now works for GM as well as player-owned ships.
|
||||
Review: [WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldService.cs#L98), [WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldService.cs#L115), [WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldService.cs#L131)
|
||||
- [ ] Confirm `Mine Resource` validation is enforced at enqueue time and uses the richer cargo model.
|
||||
Review: [WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldService.cs#L552), [WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldService.cs#L570), [WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldService.cs#L775)
|
||||
|
||||
## 5. Viewer Auth, Landing, And Dev UX
|
||||
|
||||
- [ ] Confirm the viewer is gated behind auth and boots into the landing page until a session exists.
|
||||
Review: [App.vue](/home/jbourdon/repos/space-game/apps/viewer/src/App.vue#L107), [AuthLandingPage.vue](/home/jbourdon/repos/space-game/apps/viewer/src/components/AuthLandingPage.vue#L101), [AuthSessionPanel.vue](/home/jbourdon/repos/space-game/apps/viewer/src/components/AuthSessionPanel.vue#L23)
|
||||
- [ ] Confirm auth session persistence and GM access checks are centralized.
|
||||
Review: [authStore.ts](/home/jbourdon/repos/space-game/apps/viewer/src/ui/stores/authStore.ts#L12), [authStore.ts](/home/jbourdon/repos/space-game/apps/viewer/src/ui/stores/authStore.ts#L15), [authStore.ts](/home/jbourdon/repos/space-game/apps/viewer/src/ui/stores/authStore.ts#L19)
|
||||
- [ ] Confirm the new viewer shell includes the entity list, inspector, and right-click order context menu.
|
||||
Review: [App.vue](/home/jbourdon/repos/space-game/apps/viewer/src/App.vue#L140), [App.vue](/home/jbourdon/repos/space-game/apps/viewer/src/App.vue#L156), [App.vue](/home/jbourdon/repos/space-game/apps/viewer/src/App.vue#L275), [viewerScene.ts](/home/jbourdon/repos/space-game/apps/viewer/src/ui/stores/viewerScene.ts#L4), [viewerOrderContextMenu.ts](/home/jbourdon/repos/space-game/apps/viewer/src/ui/stores/viewerOrderContextMenu.ts#L13)
|
||||
- [ ] Confirm the inspector now mixes read-only state with actionable ship controls.
|
||||
Review: [ViewerEntityInspectorPanel.vue](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerEntityInspectorPanel.vue#L103), [ViewerEntityInspectorPanel.vue](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerEntityInspectorPanel.vue#L320), [ViewerEntityInspectorPanel.vue](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerEntityInspectorPanel.vue#L408), [ViewerEntityInspectorPanel.vue](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerEntityInspectorPanel.vue#L495)
|
||||
- [ ] Confirm the right-click menu respects GM access and issues direct orders rather than changing behaviors.
|
||||
Review: [ViewerShipOrderContextMenu.vue](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerShipOrderContextMenu.vue#L53), [ViewerShipOrderContextMenu.vue](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerShipOrderContextMenu.vue#L149), [ViewerShipOrderContextMenu.vue](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerShipOrderContextMenu.vue#L224)
|
||||
- [ ] Confirm the GM window supports faction, ship, and station spawning.
|
||||
Review: [api.ts](/home/jbourdon/repos/space-game/apps/viewer/src/api.ts#L132), [api.ts](/home/jbourdon/repos/space-game/apps/viewer/src/api.ts#L140), [api.ts](/home/jbourdon/repos/space-game/apps/viewer/src/api.ts#L148), [GmOpsWindow.vue](/home/jbourdon/repos/space-game/apps/viewer/src/components/gm/GmOpsWindow.vue#L663), [GmOpsWindow.vue](/home/jbourdon/repos/space-game/apps/viewer/src/components/gm/GmOpsWindow.vue#L699)
|
||||
|
||||
## 6. Ship Orders, Behaviors, Catalog, And AI Refactor
|
||||
|
||||
- [ ] Confirm the automation catalog is now the central behavior/order vocabulary and is exposed to the viewer.
|
||||
Review: [ShipAutomationCatalog.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/ShipAutomationCatalog.cs#L59), [GetShipAutomationCatalogHandler.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/Api/GetShipAutomationCatalogHandler.cs#L5)
|
||||
- [ ] Confirm the queue-backed model is now the main AI path: emergency plan, sync managed behavior orders, build order plan, then only fallback to idle/blocked behavior plans.
|
||||
Review: [ShipAiService.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.cs#L40), [ShipAiService.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.cs#L48), [ShipAiService.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.cs#L74), [ShipAiService.Planning.Behaviors.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs#L8)
|
||||
- [ ] Confirm the old parallel `Build*BehaviorPlan(...)` path is gone for migrated behaviors.
|
||||
Review: [ShipAiService.BehaviorQueue.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs#L25), [ShipAiService.BehaviorQueue.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs#L53), [ShipAiService.Planning.Orders.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs#L69)
|
||||
- [ ] Confirm the internal managed behavior orders were introduced intentionally and are cataloged.
|
||||
Review: [SimulationKinds.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/SimulationKinds.cs#L177), [SimulationKinds.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/SimulationKinds.cs#L179), [SimulationKinds.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/SimulationKinds.cs#L180), [ShipAutomationCatalog.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/ShipAutomationCatalog.cs#L115)
|
||||
- [ ] Confirm `ShipAiService` was split and moved to `Ships/AI` without changing the external orchestration contract.
|
||||
Review: [ShipAiService.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.cs#L7), [ShipAiService.BehaviorQueue.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs#L25), [ShipAiService.Planning.Orders.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs#L10)
|
||||
|
||||
## 7. Suggested Final Sanity Pass Before Commit
|
||||
|
||||
- [ ] Build backend: `dotnet build apps/backend/SpaceGame.Api.csproj`
|
||||
- [ ] Build viewer: `npm run build` in `apps/viewer`
|
||||
- [ ] Smoke test with `gm/gm`:
|
||||
create faction
|
||||
spawn station
|
||||
spawn miner
|
||||
verify the inspector shows direct orders above the behavior divider
|
||||
- [ ] Smoke test an empty restart:
|
||||
`/api/world` returns an empty world
|
||||
`/api/version` returns current version info
|
||||
`/api/player-faction` behaves via auth/player-state store rather than a world-owned player faction
|
||||
|
||||
## 8. Pending Work (Non-Blocking For This Commit)
|
||||
|
||||
- [ ] Do a systematic live validation pass for all behaviors/orders now marked `Supported`.
|
||||
Focus review: [ShipAutomationCatalog.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/ShipAutomationCatalog.cs#L59), [ShipAiService.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.cs#L40)
|
||||
- [ ] Continue the viewer control-surface polish.
|
||||
Focus review: [ViewerEntityInspectorPanel.vue](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerEntityInspectorPanel.vue#L408), [ViewerShipOrderContextMenu.vue](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerShipOrderContextMenu.vue#L149), [viewer.css](/home/jbourdon/repos/space-game/apps/viewer/src/styles/viewer.css#L1)
|
||||
- [ ] Revisit the long-term ship AI execution model.
|
||||
The current queue-backed architecture is coherent, but it still runs through subtask plans internally.
|
||||
Focus review: [ShipAiService.Planning.Behaviors.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs#L8), [ShipAiService.Planning.Orders.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs#L69), [ShipAiService.Execution.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.Execution.cs#L1)
|
||||
- [ ] Revisit the celestial/orbital hierarchy model.
|
||||
The runtime currently has a flat celestial list with `ParentNodeId`, but orbital updates are still handled by special-case loops instead of a generic parent-first hierarchy pass.
|
||||
Desired direction: keep a flat ordered list, but compute child positions from parent-relative orbital data in one forward pass.
|
||||
Focus review: [SpatialBuilder.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Scenario/SpatialBuilder.cs#L39), [SpatialRuntimeModels.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Runtime/SpatialRuntimeModels.cs#L26), [OrbitalStateUpdater.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/OrbitalStateUpdater.cs#L170), [Celestial.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Contracts/Celestial.cs#L65)
|
||||
- [ ] Revisit scenario-authored ship automation.
|
||||
`ScenarioDefinition.PatrolRoutes` is too coarse; scenario ships should eventually be able to author their own default behavior and parameters directly.
|
||||
Open question: whether scenarios should also be allowed to author an initial order queue, not only a default behavior.
|
||||
Desired direction: remove top-level patrol-route bootstrap and move authored automation closer to ship formations.
|
||||
Focus review: [ScenarioDefinition](/home/jbourdon/repos/space-game/apps/backend/Definitions/WorldDefinitions.cs#L559), [PatrolRouteDefinition](/home/jbourdon/repos/space-game/apps/backend/Definitions/WorldDefinitions.cs#L590), [ScenarioContentBuilder.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs#L23), [ScenarioContentBuilder.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs#L218)
|
||||
- [ ] Revisit behavior composition over internal pseudo-orders.
|
||||
The shared queue model is in place, but some behaviors still compile to internal executable orders like `mine-and-deliver-run`, `supply-fleet-run`, and `salvage-run`.
|
||||
Desired direction: higher-level behaviors should prefer composing a small set of real basic orders rather than relying on behavior-only executable order kinds.
|
||||
Focus review: [ShipAiService.BehaviorQueue.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs#L53), [ShipAiService.Planning.Orders.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs#L69), [SimulationKinds.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/SimulationKinds.cs#L177), [ShipAutomationCatalog.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/ShipAutomationCatalog.cs#L115)
|
||||
- [ ] Revisit the procedural generation dependency on known systems.
|
||||
`SystemGenerationService` still requires at least one known system because known systems are being used both as selectable authored systems and as templates for generated systems.
|
||||
Desired direction: procedural generation should be able to work with `UseKnownSystems = false` and an empty known-system pool.
|
||||
Focus review: [SystemGenerationService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Scenario/SystemGenerationService.cs#L16), [SystemGenerationService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Scenario/SystemGenerationService.cs#L25), [SystemGenerationService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Scenario/SystemGenerationService.cs#L29)
|
||||
- [ ] Revisit the production/dependency graph boundary.
|
||||
`ProductionGraphBuilder` currently models ware and ship production, while module construction lives separately in `ModuleRecipes`.
|
||||
Desired direction: module construction should eventually be represented in the same dependency graph, because station AI ultimately answers the same upstream-input question for production and module building.
|
||||
Focus review: [ProductionGraphBuilder.cs](/home/jbourdon/repos/space-game/apps/backend/Industry/Planning/ProductionGraphBuilder.cs#L5), [StaticDataProvider.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Bootstrap/StaticDataProvider.cs#L33), [StaticDataProvider.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Bootstrap/StaticDataProvider.cs#L72), [StaticDataProvider.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Bootstrap/StaticDataProvider.cs#L89)
|
||||
- [ ] Revisit the ship classifier helpers in `SimulationRuntimeSupport`.
|
||||
`ShipPurpose` is now part of `ShipDefinition`, but helpers like `IsMiningShip`, `IsTransportShip`, `IsConstructionShip`, and `IsMilitaryShip` still reason mostly from `ShipType`.
|
||||
Desired direction: use `ShipPurpose` as the primary role signal in those helpers, and use `ShipType` only for refinements where needed.
|
||||
Focus review: [ShipPurpose](/home/jbourdon/repos/space-game/apps/backend/Definitions/WorldDefinitions.cs#L372), [ShipDefinition.Purpose](/home/jbourdon/repos/space-game/apps/backend/Definitions/WorldDefinitions.cs#L449), [SimulationRuntimeSupport.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs#L12)
|
||||
- [ ] Revisit `GetTotalCargoCapacity()` and AI cargo assumptions.
|
||||
If ships can support multiple cargo kinds, AI should use the relevant cargo capacity for the current job instead of the total capacity across unrelated bays.
|
||||
Example: a mining ship with a dedicated fuel bay should not treat fuel storage as mined-ore capacity.
|
||||
Desired direction: remove or reduce `GetTotalCargoCapacity()` usage in AI paths that really need per-cargo-kind reasoning.
|
||||
Focus review: [ShipDefinition cargo helpers](/home/jbourdon/repos/space-game/apps/backend/Definitions/WorldDefinitions.cs#L472), [SimulationRuntimeSupport.cs](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs#L182), [ShipAiService.Planning.Orders.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs#L170), [ShipAiService.Execution.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.Execution.cs#L445)
|
||||
- [ ] Revisit the auth/account architecture boundary.
|
||||
The current auth stack is custom (repository, password flow, JWT issuance, reset flow) rather than using the more conventional ASP.NET Core Identity model.
|
||||
Desired direction: make an explicit architectural decision later about whether to keep owning this custom stack or align with the standard .NET Identity approach before external-provider growth.
|
||||
Focus review: [AuthService.cs](/home/jbourdon/repos/space-game/apps/backend/Auth/Simulation/AuthService.cs#L3), [PostgresAuthRepository.cs](/home/jbourdon/repos/space-game/apps/backend/Auth/Simulation/PostgresAuthRepository.cs#L5), [HttpContextPlayerIdentityResolver.cs](/home/jbourdon/repos/space-game/apps/backend/Auth/Simulation/HttpContextPlayerIdentityResolver.cs#L6), [Program.cs](/home/jbourdon/repos/space-game/apps/backend/Program.cs#L65)
|
||||
- [ ] Revisit the runtime/domain mutation boundary for ships.
|
||||
Ship mutations like enqueueing orders, removing direct orders, and updating default behavior should eventually live closer to a focused ship-domain service rather than being split across `WorldService` and `PlayerFactionService`.
|
||||
Desired direction: keep runtime ship data compact and cache-friendly, but move ship mutation logic toward data-oriented ship services operating over the ship collection, instead of drifting into an anemic app-service model.
|
||||
Focus review: [WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldService.cs#L98), [WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldService.cs#L131), [PlayerFactionService.cs](/home/jbourdon/repos/space-game/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs#L45), [ShipAiService.cs](/home/jbourdon/repos/space-game/apps/backend/Ships/AI/ShipAiService.cs#L7)
|
||||
- [ ] Expand GM/entity editing beyond the current bootstrap loop.
|
||||
Focus review: [WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldService.cs#L262), [WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Universe/Simulation/WorldService.cs#L326), [GmOpsWindow.vue](/home/jbourdon/repos/space-game/apps/viewer/src/components/gm/GmOpsWindow.vue#L699)
|
||||
Reference in New Issue
Block a user