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.*
|
.env.*
|
||||||
!.env.example
|
!.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;
|
namespace SpaceGame.Api.Auth.Api;
|
||||||
|
|
||||||
public sealed class RegisterHandler(AuthService authService) : Endpoint<RegisterRequest, AuthSessionResponse>
|
public sealed class RegisterHandler(AuthService authService) : Endpoint<RegisterRequest, RegisterResponse>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ public sealed record AuthSessionResponse(
|
|||||||
string RefreshToken,
|
string RefreshToken,
|
||||||
DateTimeOffset RefreshTokenExpiresAtUtc);
|
DateTimeOffset RefreshTokenExpiresAtUtc);
|
||||||
|
|
||||||
|
public sealed record RegisterResponse(
|
||||||
|
Guid UserId,
|
||||||
|
string Email,
|
||||||
|
bool RequiresLogin);
|
||||||
|
|
||||||
public sealed record ForgotPasswordResponse(
|
public sealed record ForgotPasswordResponse(
|
||||||
bool Accepted,
|
bool Accepted,
|
||||||
string? ResetToken = null);
|
string? ResetToken = null);
|
||||||
|
|||||||
7
apps/backend/Auth/Contracts/Races.cs
Normal file
7
apps/backend/Auth/Contracts/Races.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SpaceGame.Api.Auth.Contracts;
|
||||||
|
|
||||||
|
public sealed record RaceSnapshot(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Description,
|
||||||
|
string Icon);
|
||||||
@@ -7,7 +7,7 @@ public sealed class AuthService(
|
|||||||
RefreshTokenFactory refreshTokenFactory,
|
RefreshTokenFactory refreshTokenFactory,
|
||||||
IPasswordResetDelivery passwordResetDelivery)
|
IPasswordResetDelivery passwordResetDelivery)
|
||||||
{
|
{
|
||||||
public async Task<AuthSessionResponse> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
|
public async Task<RegisterResponse> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var email = NormalizeEmail(request.Email);
|
var email = NormalizeEmail(request.Email);
|
||||||
ValidatePassword(request.Password);
|
ValidatePassword(request.Password);
|
||||||
@@ -18,7 +18,7 @@ public sealed class AuthService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var user = await authRepository.CreateUserAsync(email, passwordHasher.HashPassword(request.Password), [], cancellationToken);
|
var user = await authRepository.CreateUserAsync(email, passwordHasher.HashPassword(request.Password), [], cancellationToken);
|
||||||
return await CreateSessionAsync(user, cancellationToken);
|
return new RegisterResponse(user.Id, user.Email, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthSessionResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken)
|
public async Task<AuthSessionResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace SpaceGame.Api.Auth.Simulation;
|
|||||||
|
|
||||||
public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpContextAccessor) : IPlayerIdentityResolver
|
public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpContextAccessor) : IPlayerIdentityResolver
|
||||||
{
|
{
|
||||||
|
public const string EffectivePlayerHeaderName = "X-Act-As-Player-Id";
|
||||||
|
|
||||||
public Guid? GetCurrentPlayerId()
|
public Guid? GetCurrentPlayerId()
|
||||||
{
|
{
|
||||||
var subject = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
var subject = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
@@ -15,6 +17,21 @@ public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpC
|
|||||||
public Guid GetRequiredPlayerId() =>
|
public Guid GetRequiredPlayerId() =>
|
||||||
GetCurrentPlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required.");
|
GetCurrentPlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required.");
|
||||||
|
|
||||||
|
public Guid? GetEffectivePlayerId()
|
||||||
|
{
|
||||||
|
var currentPlayerId = GetCurrentPlayerId();
|
||||||
|
if (!CanAccessGm())
|
||||||
|
{
|
||||||
|
return currentPlayerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestedIdentity = httpContextAccessor.HttpContext?.Request.Headers[EffectivePlayerHeaderName].FirstOrDefault();
|
||||||
|
return Guid.TryParse(requestedIdentity, out var effectivePlayerId) ? effectivePlayerId : currentPlayerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid GetRequiredEffectivePlayerId() =>
|
||||||
|
GetEffectivePlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required.");
|
||||||
|
|
||||||
public bool CanAccessGm()
|
public bool CanAccessGm()
|
||||||
{
|
{
|
||||||
var user = httpContextAccessor.HttpContext?.User;
|
var user = httpContextAccessor.HttpContext?.User;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ public interface IAuthRepository
|
|||||||
{
|
{
|
||||||
Task<UserAccount?> FindUserByEmailAsync(string email, CancellationToken cancellationToken);
|
Task<UserAccount?> FindUserByEmailAsync(string email, CancellationToken cancellationToken);
|
||||||
Task<UserAccount?> FindUserByIdAsync(Guid userId, CancellationToken cancellationToken);
|
Task<UserAccount?> FindUserByIdAsync(Guid userId, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<UserAccount>> ListUsersAsync(CancellationToken cancellationToken);
|
||||||
Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
|
Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
|
||||||
Task<UserAccount> UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
|
Task<UserAccount> UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
|
||||||
Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken);
|
Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -4,5 +4,7 @@ public interface IPlayerIdentityResolver
|
|||||||
{
|
{
|
||||||
Guid? GetCurrentPlayerId();
|
Guid? GetCurrentPlayerId();
|
||||||
Guid GetRequiredPlayerId();
|
Guid GetRequiredPlayerId();
|
||||||
|
Guid? GetEffectivePlayerId();
|
||||||
|
Guid GetRequiredEffectivePlayerId();
|
||||||
bool CanAccessGm();
|
bool CanAccessGm();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,23 @@ public sealed class PostgresAuthRepository(NpgsqlDataSource dataSource) : IAuthR
|
|||||||
return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null;
|
return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<UserAccount>> ListUsersAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = dataSource.CreateCommand("""
|
||||||
|
select id, email, password_hash, created_at_utc, roles
|
||||||
|
from auth_users
|
||||||
|
order by email asc
|
||||||
|
""");
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
var users = new List<UserAccount>();
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
users.Add(ReadUser(reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken)
|
public async Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
|
||||||
|
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||||
|
|
||||||
|
public sealed class CompletePlayerOnboardingHandler(WorldService worldService) : Endpoint<CompletePlayerOnboardingRequest, PlayerFactionSnapshot>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/player-faction/onboarding");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CompletePlayerOnboardingRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = worldService.CompletePlayerOnboarding(request);
|
||||||
|
if (snapshot is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendOkAsync(snapshot, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
AddError(ex.Message);
|
||||||
|
await SendErrorsAsync(cancellation: cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs
Normal file
74
apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using SpaceGame.Api.Auth.Runtime;
|
||||||
|
using SpaceGame.Api.Auth.Simulation;
|
||||||
|
using SpaceGame.Api.PlayerFaction.Simulation;
|
||||||
|
|
||||||
|
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||||
|
|
||||||
|
public sealed class GetPlayerIdentitiesHandler(IAuthRepository authRepository, IPlayerStateStore playerStateStore)
|
||||||
|
: EndpointWithoutRequest<IReadOnlyList<PlayerIdentitySummaryResponse>>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/api/player-faction/identities");
|
||||||
|
Policies(AuthPolicyNames.GmAccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var users = await authRepository.ListUsersAsync(cancellationToken);
|
||||||
|
var playerFactionsById = playerStateStore.GetPlayerFactions()
|
||||||
|
.ToDictionary(player => player.Id, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var responses = new List<PlayerIdentitySummaryResponse>(users.Count + playerFactionsById.Count);
|
||||||
|
var seenIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
var userId = user.Id.ToString("N");
|
||||||
|
playerFactionsById.TryGetValue(userId, out var playerFaction);
|
||||||
|
responses.Add(new PlayerIdentitySummaryResponse(
|
||||||
|
userId,
|
||||||
|
user.Email,
|
||||||
|
user.Roles,
|
||||||
|
playerFaction is not null,
|
||||||
|
playerFaction?.Id,
|
||||||
|
playerFaction?.Label,
|
||||||
|
playerFaction?.SovereignFactionId));
|
||||||
|
seenIds.Add(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var playerFaction in playerStateStore.GetPlayerFactions())
|
||||||
|
{
|
||||||
|
if (!seenIds.Add(playerFaction.Id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
responses.Add(new PlayerIdentitySummaryResponse(
|
||||||
|
playerFaction.Id,
|
||||||
|
$"{playerFaction.Id}@unknown",
|
||||||
|
Array.Empty<string>(),
|
||||||
|
true,
|
||||||
|
playerFaction.Id,
|
||||||
|
playerFaction.Label,
|
||||||
|
playerFaction.SovereignFactionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendOkAsync(
|
||||||
|
responses
|
||||||
|
.OrderBy(response => response.Email, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(response => response.UserId, StringComparer.Ordinal)
|
||||||
|
.ToList(),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PlayerIdentitySummaryResponse(
|
||||||
|
string UserId,
|
||||||
|
string Email,
|
||||||
|
IReadOnlyList<string> Roles,
|
||||||
|
bool HasPlayerFaction,
|
||||||
|
string? PlayerFactionId,
|
||||||
|
string? PlayerFactionLabel,
|
||||||
|
string? SovereignFactionId);
|
||||||
@@ -249,7 +249,10 @@ public sealed record PlayerAlertSnapshot(
|
|||||||
public sealed record PlayerFactionSnapshot(
|
public sealed record PlayerFactionSnapshot(
|
||||||
string Id,
|
string Id,
|
||||||
string Label,
|
string Label,
|
||||||
|
string? PersonaName,
|
||||||
|
string? RaceId,
|
||||||
string SovereignFactionId,
|
string SovereignFactionId,
|
||||||
|
bool RequiresOnboarding,
|
||||||
string Status,
|
string Status,
|
||||||
DateTimeOffset CreatedAtUtc,
|
DateTimeOffset CreatedAtUtc,
|
||||||
DateTimeOffset UpdatedAtUtc,
|
DateTimeOffset UpdatedAtUtc,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
namespace SpaceGame.Api.PlayerFaction.Contracts;
|
namespace SpaceGame.Api.PlayerFaction.Contracts;
|
||||||
|
|
||||||
|
public sealed record CompletePlayerOnboardingRequest(
|
||||||
|
string Name,
|
||||||
|
string RaceId);
|
||||||
|
|
||||||
public sealed record PlayerOrganizationCommandRequest(
|
public sealed record PlayerOrganizationCommandRequest(
|
||||||
string Kind,
|
string Kind,
|
||||||
string Label,
|
string Label,
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ public sealed class PlayerFactionRuntime
|
|||||||
{
|
{
|
||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
public required string Label { get; set; }
|
public required string Label { get; set; }
|
||||||
|
public string? PersonaName { get; set; }
|
||||||
|
public string? RaceId { get; set; }
|
||||||
public required string SovereignFactionId { get; set; }
|
public required string SovereignFactionId { get; set; }
|
||||||
|
public bool RequiresOnboarding { get; set; } = true;
|
||||||
public string Status { get; set; } = "active";
|
public string Status { get; set; } = "active";
|
||||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ public sealed class PlayerFactionProjectionService
|
|||||||
return new PlayerFactionSnapshot(
|
return new PlayerFactionSnapshot(
|
||||||
player.Id,
|
player.Id,
|
||||||
player.Label,
|
player.Label,
|
||||||
|
player.PersonaName,
|
||||||
|
player.RaceId,
|
||||||
player.SovereignFactionId,
|
player.SovereignFactionId,
|
||||||
|
player.RequiresOnboarding,
|
||||||
player.Status,
|
player.Status,
|
||||||
player.CreatedAtUtc,
|
player.CreatedAtUtc,
|
||||||
player.UpdatedAtUtc,
|
player.UpdatedAtUtc,
|
||||||
|
|||||||
@@ -20,14 +20,12 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
|
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
|
||||||
{
|
{
|
||||||
var sovereignFaction = world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).FirstOrDefault()
|
|
||||||
?? throw new InvalidOperationException("Cannot create a player faction domain without any factions in the world.");
|
|
||||||
|
|
||||||
var player = playerStateStore.GetOrAddPlayerFaction(playerId, () => new PlayerFactionRuntime
|
var player = playerStateStore.GetOrAddPlayerFaction(playerId, () => new PlayerFactionRuntime
|
||||||
{
|
{
|
||||||
Id = PlayerFactionDomainId,
|
Id = PlayerFactionDomainId,
|
||||||
Label = $"{sovereignFaction.Label} Command",
|
Label = "Pending Pilot",
|
||||||
SovereignFactionId = sovereignFaction.Id,
|
SovereignFactionId = string.Empty,
|
||||||
|
RequiresOnboarding = true,
|
||||||
CreatedAtUtc = world.GeneratedAtUtc,
|
CreatedAtUtc = world.GeneratedAtUtc,
|
||||||
UpdatedAtUtc = world.GeneratedAtUtc,
|
UpdatedAtUtc = world.GeneratedAtUtc,
|
||||||
});
|
});
|
||||||
@@ -37,6 +35,58 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal PlayerFactionRuntime CompleteOnboarding(
|
||||||
|
SimulationWorld world,
|
||||||
|
IPlayerStateStore playerStateStore,
|
||||||
|
string playerId,
|
||||||
|
CompletePlayerOnboardingRequest request)
|
||||||
|
{
|
||||||
|
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||||
|
if (!player.RequiresOnboarding)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Player onboarding has already been completed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var personaName = request.Name.Trim();
|
||||||
|
if (personaName.Length < 2)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Player name must contain at least 2 characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (personaName.Length > 48)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Player name must contain at most 48 characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ownedFactionId = BuildOwnedFactionId(playerId);
|
||||||
|
if (world.Factions.Any(faction => string.Equals(faction.Id, ownedFactionId, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Player faction '{ownedFactionId}' already exists in the current world.");
|
||||||
|
}
|
||||||
|
|
||||||
|
player.Label = personaName;
|
||||||
|
player.PersonaName = personaName;
|
||||||
|
player.RaceId = request.RaceId.Trim();
|
||||||
|
player.SovereignFactionId = ownedFactionId;
|
||||||
|
player.RequiresOnboarding = false;
|
||||||
|
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal PlayerFactionRuntime EnsureInitializedDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
|
||||||
|
{
|
||||||
|
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||||
|
if (player.RequiresOnboarding || string.IsNullOrWhiteSpace(player.SovereignFactionId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Player onboarding must be completed before issuing gameplay commands.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string BuildOwnedFactionId(string playerId) =>
|
||||||
|
$"player-{playerId.Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant()}";
|
||||||
|
|
||||||
internal void Update(SimulationWorld world, IPlayerStateStore playerStateStore, float _deltaSeconds, ICollection<SimulationEventRecord> events)
|
internal void Update(SimulationWorld world, IPlayerStateStore playerStateStore, float _deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||||
{
|
{
|
||||||
if (playerStateStore.GetPlayerFactions().Count == 0)
|
if (playerStateStore.GetPlayerFactions().Count == 0)
|
||||||
@@ -63,7 +113,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerOrganizationCommandRequest request)
|
internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerOrganizationCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player));
|
var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player));
|
||||||
var nowUtc = DateTimeOffset.UtcNow;
|
var nowUtc = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
@@ -180,7 +230,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId)
|
internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
RemoveOrganization(player, organizationId);
|
RemoveOrganization(player, organizationId);
|
||||||
player.Assignments.RemoveAll(assignment =>
|
player.Assignments.RemoveAll(assignment =>
|
||||||
assignment.FleetId == organizationId ||
|
assignment.FleetId == organizationId ||
|
||||||
@@ -198,7 +248,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId, PlayerOrganizationMembershipCommandRequest request)
|
internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId, PlayerOrganizationMembershipCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var kind = ResolveOrganizationKind(player, organizationId);
|
var kind = ResolveOrganizationKind(player, organizationId);
|
||||||
switch (kind)
|
switch (kind)
|
||||||
{
|
{
|
||||||
@@ -249,7 +299,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? directiveId, PlayerDirectiveCommandRequest request)
|
internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? directiveId, PlayerDirectiveCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var directive = directiveId is null
|
var directive = directiveId is null
|
||||||
? null
|
? null
|
||||||
: player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal));
|
: player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal));
|
||||||
@@ -326,7 +376,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string directiveId)
|
internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string directiveId)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
player.Directives.RemoveAll(directive => directive.Id == directiveId);
|
player.Directives.RemoveAll(directive => directive.Id == directiveId);
|
||||||
foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId))
|
foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId))
|
||||||
{
|
{
|
||||||
@@ -340,7 +390,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? policyId, PlayerPolicyCommandRequest request)
|
internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? policyId, PlayerPolicyCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var policy = policyId is null
|
var policy = policyId is null
|
||||||
? null
|
? null
|
||||||
: player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal));
|
: player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal));
|
||||||
@@ -411,7 +461,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
|
internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var policy = automationPolicyId is null
|
var policy = automationPolicyId is null
|
||||||
? null
|
? null
|
||||||
: player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal));
|
: player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal));
|
||||||
@@ -469,7 +519,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
|
internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var policy = reinforcementPolicyId is null
|
var policy = reinforcementPolicyId is null
|
||||||
? null
|
? null
|
||||||
: player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal));
|
: player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal));
|
||||||
@@ -503,7 +553,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? productionProgramId, PlayerProductionProgramCommandRequest request)
|
internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? productionProgramId, PlayerProductionProgramCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var program = productionProgramId is null
|
var program = productionProgramId is null
|
||||||
? null
|
? null
|
||||||
: player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal));
|
: player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal));
|
||||||
@@ -535,7 +585,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string assetId, PlayerAssetAssignmentCommandRequest request)
|
internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string assetId, PlayerAssetAssignmentCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var assignment = player.Assignments.FirstOrDefault(candidate =>
|
var assignment = player.Assignments.FirstOrDefault(candidate =>
|
||||||
string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) &&
|
string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) &&
|
||||||
string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal));
|
string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal));
|
||||||
@@ -594,7 +644,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerStrategicIntentCommandRequest request)
|
internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerStrategicIntentCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
player.StrategicIntent.StrategicPosture = request.StrategicPosture;
|
player.StrategicIntent.StrategicPosture = request.StrategicPosture;
|
||||||
player.StrategicIntent.EconomicPosture = request.EconomicPosture;
|
player.StrategicIntent.EconomicPosture = request.EconomicPosture;
|
||||||
player.StrategicIntent.MilitaryPosture = request.MilitaryPosture;
|
player.StrategicIntent.MilitaryPosture = request.MilitaryPosture;
|
||||||
@@ -610,7 +660,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipOrderCommandRequest request)
|
internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipOrderCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -669,7 +719,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId)
|
internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -712,7 +762,7 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipDefaultBehaviorCommandRequest request)
|
internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipDefaultBehaviorCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -852,6 +902,24 @@ internal sealed class PlayerFactionService
|
|||||||
|
|
||||||
private static void SyncRegistry(SimulationWorld world, PlayerFactionRuntime player)
|
private static void SyncRegistry(SimulationWorld world, PlayerFactionRuntime player)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(player.SovereignFactionId))
|
||||||
|
{
|
||||||
|
SyncSet(player.AssetRegistry.ShipIds, []);
|
||||||
|
SyncSet(player.AssetRegistry.StationIds, []);
|
||||||
|
SyncSet(player.AssetRegistry.CommanderIds, []);
|
||||||
|
SyncSet(player.AssetRegistry.ClaimIds, []);
|
||||||
|
SyncSet(player.AssetRegistry.ConstructionSiteIds, []);
|
||||||
|
SyncSet(player.AssetRegistry.PolicySetIds, player.Policies.Where(entry => entry.PolicySetId is not null).Select(entry => entry.PolicySetId!));
|
||||||
|
SyncSet(player.AssetRegistry.MarketOrderIds, []);
|
||||||
|
SyncSet(player.AssetRegistry.FleetIds, player.Fleets.Select(fleet => fleet.Id));
|
||||||
|
SyncSet(player.AssetRegistry.TaskForceIds, player.TaskForces.Select(taskForce => taskForce.Id));
|
||||||
|
SyncSet(player.AssetRegistry.StationGroupIds, player.StationGroups.Select(group => group.Id));
|
||||||
|
SyncSet(player.AssetRegistry.EconomicRegionIds, player.EconomicRegions.Select(region => region.Id));
|
||||||
|
SyncSet(player.AssetRegistry.FrontIds, player.Fronts.Select(front => front.Id));
|
||||||
|
SyncSet(player.AssetRegistry.ReserveIds, player.Reserves.Select(reserve => reserve.Id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
SyncSet(player.AssetRegistry.ShipIds, world.Ships.Where(ship => ship.FactionId == player.SovereignFactionId).Select(ship => ship.Id));
|
SyncSet(player.AssetRegistry.ShipIds, world.Ships.Where(ship => ship.FactionId == player.SovereignFactionId).Select(ship => ship.Id));
|
||||||
SyncSet(player.AssetRegistry.StationIds, world.Stations.Where(station => station.FactionId == player.SovereignFactionId).Select(station => station.Id));
|
SyncSet(player.AssetRegistry.StationIds, world.Stations.Where(station => station.FactionId == player.SovereignFactionId).Select(station => station.Id));
|
||||||
SyncSet(player.AssetRegistry.CommanderIds, world.Commanders.Where(commander => commander.FactionId == player.SovereignFactionId).Select(commander => commander.Id));
|
SyncSet(player.AssetRegistry.CommanderIds, world.Commanders.Where(commander => commander.FactionId == player.SovereignFactionId).Select(commander => commander.Id));
|
||||||
@@ -1224,8 +1292,7 @@ internal sealed class PlayerFactionService
|
|||||||
return player.AutomationPolicies.FirstOrDefault(policy => policy.Id == automationId);
|
return player.AutomationPolicies.FirstOrDefault(policy => policy.Id == automationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId)
|
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId);
|
||||||
?? player.AutomationPolicies.FirstOrDefault(policy => policy.Id == "player-core-automation");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PlayerFactionPolicyRuntime? ResolvePolicy(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string assetId)
|
private static PlayerFactionPolicyRuntime? ResolvePolicy(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string assetId)
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ public sealed class ScenarioContentBuilder(
|
|||||||
powerModuleId,
|
powerModuleId,
|
||||||
plan.FactionId,
|
plan.FactionId,
|
||||||
staticData.ModuleDefinitions,
|
staticData.ModuleDefinitions,
|
||||||
staticData.ItemDefinitions)
|
staticData.ItemDefinitions,
|
||||||
|
staticData.Recipes)
|
||||||
.FirstOrDefault(moduleId =>
|
.FirstOrDefault(moduleId =>
|
||||||
{
|
{
|
||||||
return staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
|
return staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
|
||||||
@@ -117,7 +118,8 @@ public sealed class ScenarioContentBuilder(
|
|||||||
objectiveModuleId,
|
objectiveModuleId,
|
||||||
plan.FactionId,
|
plan.FactionId,
|
||||||
staticData.ModuleDefinitions,
|
staticData.ModuleDefinitions,
|
||||||
staticData.ItemDefinitions))
|
staticData.ItemDefinitions,
|
||||||
|
staticData.Recipes))
|
||||||
{
|
{
|
||||||
EnsureStartingModule(startingModules, storageModuleId);
|
EnsureStartingModule(startingModules, storageModuleId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ internal static class StarterStationLayoutResolver
|
|||||||
string moduleId,
|
string moduleId,
|
||||||
string? factionId,
|
string? factionId,
|
||||||
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
||||||
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
|
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions,
|
||||||
|
IReadOnlyDictionary<string, RecipeDefinition> recipes)
|
||||||
{
|
{
|
||||||
if (!moduleDefinitions.TryGetValue(moduleId, out var moduleDefinition))
|
if (!moduleDefinitions.TryGetValue(moduleId, out var moduleDefinition))
|
||||||
{
|
{
|
||||||
@@ -40,6 +41,10 @@ internal static class StarterStationLayoutResolver
|
|||||||
foreach (var wareId in moduleDefinition.BuildRecipes
|
foreach (var wareId in moduleDefinition.BuildRecipes
|
||||||
.SelectMany(production => production.Wares.Select(ware => ware.ItemId))
|
.SelectMany(production => production.Wares.Select(ware => ware.ItemId))
|
||||||
.Concat(moduleDefinition.ProductItemIds)
|
.Concat(moduleDefinition.ProductItemIds)
|
||||||
|
.Concat(recipes.Values
|
||||||
|
.Where(recipe => recipe.RequiredModules.Contains(moduleId, StringComparer.Ordinal))
|
||||||
|
.SelectMany(recipe => recipe.Inputs.Select(input => input.ItemId)
|
||||||
|
.Concat(recipe.Outputs.Select(output => output.ItemId))))
|
||||||
.Distinct(StringComparer.Ordinal))
|
.Distinct(StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
if (!itemDefinitions.TryGetValue(wareId, out var itemDefinition))
|
if (!itemDefinitions.TryGetValue(wareId, out var itemDefinition))
|
||||||
|
|||||||
@@ -201,7 +201,8 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData)
|
|||||||
objectiveModuleId,
|
objectiveModuleId,
|
||||||
station.FactionId,
|
station.FactionId,
|
||||||
world.ModuleDefinitions,
|
world.ModuleDefinitions,
|
||||||
world.ItemDefinitions))
|
world.ItemDefinitions,
|
||||||
|
world.Recipes))
|
||||||
{
|
{
|
||||||
if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal))
|
if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ namespace SpaceGame.Api.Universe.Simulation;
|
|||||||
public sealed class WorldService
|
public sealed class WorldService
|
||||||
{
|
{
|
||||||
private const int DeltaHistoryLimit = 256;
|
private const int DeltaHistoryLimit = 256;
|
||||||
|
private const string StarterPlayerShipId = "ship_arg_s_scout_01_a";
|
||||||
|
|
||||||
private readonly Lock _sync = new();
|
private readonly Lock _sync = new();
|
||||||
private readonly OrbitalSimulationSnapshot _orbitalSimulation;
|
private readonly OrbitalSimulationSnapshot _orbitalSimulation;
|
||||||
@@ -148,11 +149,6 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
if (_world.Factions.Count == 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var playerKey = GetCurrentPlayerKey();
|
var playerKey = GetCurrentPlayerKey();
|
||||||
var player = _playerFaction.TryGetDomain(_playerStateStore, playerKey)
|
var player = _playerFaction.TryGetDomain(_playerStateStore, playerKey)
|
||||||
?? _playerFaction.EnsureDomain(_world, _playerStateStore, playerKey);
|
?? _playerFaction.EnsureDomain(_world, _playerStateStore, playerKey);
|
||||||
@@ -160,6 +156,26 @@ public sealed class WorldService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PlayerFactionSnapshot? CompletePlayerOnboarding(CompletePlayerOnboardingRequest request)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (!_staticData.RaceDefinitions.TryGetValue(request.RaceId.Trim(), out var race))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Race '{request.RaceId}' is not defined in static data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var playerKey = GetCurrentPlayerKey();
|
||||||
|
var player = _playerFaction.CompleteOnboarding(_world, _playerStateStore, playerKey, request);
|
||||||
|
var playerFaction = CreatePlayerOwnedFactionUnsafe(player, race);
|
||||||
|
var starterSystemId = ResolveStarterSystemIdUnsafe();
|
||||||
|
SpawnPlayerStarterShipUnsafe(playerFaction, starterSystemId);
|
||||||
|
_playerFaction.EnsureInitializedDomain(_world, _playerStateStore, playerKey);
|
||||||
|
PublishSnapshotRefreshUnsafe("player-onboarding", $"Initialized player {player.PersonaName}", "faction", playerFaction.Id);
|
||||||
|
return GetPlayerFactionSnapshotUnsafe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public PlayerFactionSnapshot? CreatePlayerOrganization(PlayerOrganizationCommandRequest request)
|
public PlayerFactionSnapshot? CreatePlayerOrganization(PlayerOrganizationCommandRequest request)
|
||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
@@ -530,7 +546,102 @@ public sealed class WorldService
|
|||||||
private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() =>
|
private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() =>
|
||||||
_playerFactionProjection.ToSnapshot(_playerFaction.TryGetDomain(_playerStateStore, GetCurrentPlayerKey()));
|
_playerFactionProjection.ToSnapshot(_playerFaction.TryGetDomain(_playerStateStore, GetCurrentPlayerKey()));
|
||||||
|
|
||||||
private string GetCurrentPlayerKey() => _playerIdentityResolver.GetRequiredPlayerId().ToString("N");
|
private FactionRuntime CreatePlayerOwnedFactionUnsafe(PlayerFactionRuntime player, RaceDefinition race)
|
||||||
|
{
|
||||||
|
var playerFaction = new FactionRuntime
|
||||||
|
{
|
||||||
|
Id = player.SovereignFactionId,
|
||||||
|
Label = player.PersonaName ?? player.Label,
|
||||||
|
Color = ResolvePlayerFactionColor(race.Id),
|
||||||
|
Credits = 25000f,
|
||||||
|
};
|
||||||
|
|
||||||
|
_world.Factions.Add(playerFaction);
|
||||||
|
|
||||||
|
var policy = _worldSeedingService.CreatePolicies([playerFaction]).Single();
|
||||||
|
var templateFaction = _staticData.FactionDefinitions.Values
|
||||||
|
.Where(candidate => string.Equals(candidate.RaceId, race.Id, StringComparison.Ordinal))
|
||||||
|
.OrderBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||||
|
.Select(candidate => _world.Factions.FirstOrDefault(worldFaction => string.Equals(worldFaction.Id, candidate.Id, StringComparison.Ordinal)))
|
||||||
|
.FirstOrDefault(candidate => candidate is not null);
|
||||||
|
if (templateFaction?.DefaultPolicySetId is { } racePolicyId
|
||||||
|
&& _world.Policies.FirstOrDefault(candidate => candidate.Id == racePolicyId) is { } racePolicy)
|
||||||
|
{
|
||||||
|
policy.TradeAccessPolicy = racePolicy.TradeAccessPolicy;
|
||||||
|
policy.DockingAccessPolicy = racePolicy.DockingAccessPolicy;
|
||||||
|
policy.ConstructionAccessPolicy = racePolicy.ConstructionAccessPolicy;
|
||||||
|
policy.OperationalRangePolicy = racePolicy.OperationalRangePolicy;
|
||||||
|
policy.CombatEngagementPolicy = racePolicy.CombatEngagementPolicy;
|
||||||
|
policy.FleeHullRatio = racePolicy.FleeHullRatio;
|
||||||
|
policy.AvoidHostileSystems = racePolicy.AvoidHostileSystems;
|
||||||
|
foreach (var systemId in racePolicy.BlacklistedSystemIds)
|
||||||
|
{
|
||||||
|
policy.BlacklistedSystemIds.Add(systemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_world.Policies.Add(policy);
|
||||||
|
|
||||||
|
var factionCommander = CreateFactionCommander(playerFaction);
|
||||||
|
_world.Commanders.Add(factionCommander);
|
||||||
|
playerFaction.CommanderIds.Add(factionCommander.Id);
|
||||||
|
return playerFaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveStarterSystemIdUnsafe()
|
||||||
|
{
|
||||||
|
return _world.Systems
|
||||||
|
.Select(system => system.Definition.Id)
|
||||||
|
.OrderBy(systemId => systemId, StringComparer.Ordinal)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? throw new InvalidOperationException("No systems are available for player onboarding.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SpawnPlayerStarterShipUnsafe(FactionRuntime playerFaction, string systemId)
|
||||||
|
{
|
||||||
|
var request = new SpawnShipCommandRequest(
|
||||||
|
playerFaction.Id,
|
||||||
|
systemId,
|
||||||
|
StarterPlayerShipId,
|
||||||
|
Idle);
|
||||||
|
var system = _world.Systems.First(candidate => string.Equals(candidate.Definition.Id, request.SystemId, StringComparison.Ordinal));
|
||||||
|
var definition = ResolveShipDefinition(request, playerFaction.Id);
|
||||||
|
var shipId = $"ship-{playerFaction.Id}-{definition.Id}-{Guid.NewGuid():N}".ToLowerInvariant();
|
||||||
|
var spawnPosition = ResolveSpawnPosition(system.Definition.Id);
|
||||||
|
var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, null);
|
||||||
|
|
||||||
|
var ship = new ShipRuntime
|
||||||
|
{
|
||||||
|
Id = shipId,
|
||||||
|
SystemId = system.Definition.Id,
|
||||||
|
Definition = definition,
|
||||||
|
FactionId = playerFaction.Id,
|
||||||
|
Position = spawnPosition,
|
||||||
|
TargetPosition = spawnPosition,
|
||||||
|
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Celestials),
|
||||||
|
DefaultBehavior = defaultBehavior,
|
||||||
|
Skills = ShipBootstrapPolicy.CreateSkills(definition),
|
||||||
|
Health = definition.Hull,
|
||||||
|
};
|
||||||
|
|
||||||
|
_world.Ships.Add(ship);
|
||||||
|
EnsureShipCommander(playerFaction, ship);
|
||||||
|
new GeopoliticalSimulationService().Update(_world, 0f, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolvePlayerFactionColor(string raceId) =>
|
||||||
|
raceId switch
|
||||||
|
{
|
||||||
|
"argon" => "#3b82f6",
|
||||||
|
"boron" => "#14b8a6",
|
||||||
|
"paranid" => "#eab308",
|
||||||
|
"split" => "#b91c1c",
|
||||||
|
"teladi" => "#22c55e",
|
||||||
|
"terran" => "#38bdf8",
|
||||||
|
"xenon" => "#9ca3af",
|
||||||
|
_ => "#94a3b8",
|
||||||
|
};
|
||||||
|
|
||||||
|
private string GetCurrentPlayerKey() => _playerIdentityResolver.GetRequiredEffectivePlayerId().ToString("N");
|
||||||
|
|
||||||
private bool CanCurrentActorAccessGm() => _playerIdentityResolver.CanAccessGm();
|
private bool CanCurrentActorAccessGm() => _playerIdentityResolver.CanAccessGm();
|
||||||
|
|
||||||
@@ -807,7 +918,8 @@ public sealed class WorldService
|
|||||||
powerModuleId,
|
powerModuleId,
|
||||||
factionId,
|
factionId,
|
||||||
_staticData.ModuleDefinitions,
|
_staticData.ModuleDefinitions,
|
||||||
_staticData.ItemDefinitions)
|
_staticData.ItemDefinitions,
|
||||||
|
_staticData.Recipes)
|
||||||
.FirstOrDefault(moduleId =>
|
.FirstOrDefault(moduleId =>
|
||||||
{
|
{
|
||||||
return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
|
return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
|
||||||
@@ -820,6 +932,24 @@ public sealed class WorldService
|
|||||||
EnsureStationModule(modules, defaultContainerStorageModuleId);
|
EnsureStationModule(modules, defaultContainerStorageModuleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var defaultSolidStorageModuleId = StarterStationLayoutResolver.ResolveRequiredStorageModuleIds(
|
||||||
|
powerModuleId,
|
||||||
|
factionId,
|
||||||
|
_staticData.ModuleDefinitions,
|
||||||
|
_staticData.ItemDefinitions,
|
||||||
|
_staticData.Recipes)
|
||||||
|
.FirstOrDefault(moduleId =>
|
||||||
|
{
|
||||||
|
return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
|
||||||
|
&& definition is StorageModuleDefinition storageDefinition
|
||||||
|
&& storageDefinition.StorageKind == StorageKind.Solid;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (defaultSolidStorageModuleId is not null)
|
||||||
|
{
|
||||||
|
EnsureStationModule(modules, defaultSolidStorageModuleId);
|
||||||
|
}
|
||||||
|
|
||||||
var objectiveModuleId = StarterStationLayoutResolver.ResolveObjectiveModuleId(objective, factionId, _staticData.ModuleDefinitions);
|
var objectiveModuleId = StarterStationLayoutResolver.ResolveObjectiveModuleId(objective, factionId, _staticData.ModuleDefinitions);
|
||||||
if (!string.IsNullOrWhiteSpace(objectiveModuleId))
|
if (!string.IsNullOrWhiteSpace(objectiveModuleId))
|
||||||
{
|
{
|
||||||
@@ -828,7 +958,8 @@ public sealed class WorldService
|
|||||||
objectiveModuleId,
|
objectiveModuleId,
|
||||||
factionId,
|
factionId,
|
||||||
_staticData.ModuleDefinitions,
|
_staticData.ModuleDefinitions,
|
||||||
_staticData.ItemDefinitions))
|
_staticData.ItemDefinitions,
|
||||||
|
_staticData.Recipes))
|
||||||
{
|
{
|
||||||
EnsureStationModule(modules, storageModuleId);
|
EnsureStationModule(modules, storageModuleId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import { GameViewer } from "./GameViewer";
|
import { GameViewer } from "./GameViewer";
|
||||||
import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
|
|
||||||
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
|
|
||||||
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
|
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
|
||||||
import ViewerEntityBrowserPanel from "./components/ViewerEntityBrowserPanel.vue";
|
import ViewerEntityBrowserPanel from "./components/ViewerEntityBrowserPanel.vue";
|
||||||
import ViewerEntityInspectorPanel from "./components/ViewerEntityInspectorPanel.vue";
|
import ViewerEntityInspectorPanel from "./components/ViewerEntityInspectorPanel.vue";
|
||||||
@@ -13,9 +11,12 @@ import GmTelemetryWindow from "./components/gm/GmTelemetryWindow.vue";
|
|||||||
import GmSettingsWindow from "./components/gm/GmSettingsWindow.vue";
|
import GmSettingsWindow from "./components/gm/GmSettingsWindow.vue";
|
||||||
import AuthSessionPanel from "./components/AuthSessionPanel.vue";
|
import AuthSessionPanel from "./components/AuthSessionPanel.vue";
|
||||||
import AuthLandingPage from "./components/AuthLandingPage.vue";
|
import AuthLandingPage from "./components/AuthLandingPage.vue";
|
||||||
|
import PlayerOnboardingPanel from "./components/PlayerOnboardingPanel.vue";
|
||||||
|
import { fetchPlayerFaction } from "./api";
|
||||||
import { useShipAutomationCatalogStore } from "./ui/stores/shipAutomationCatalogStore";
|
import { useShipAutomationCatalogStore } from "./ui/stores/shipAutomationCatalogStore";
|
||||||
import { createViewerHudState } from "./viewerHudState";
|
import { createViewerHudState } from "./viewerHudState";
|
||||||
import { useAuthStore } from "./ui/stores/authStore";
|
import { useAuthStore } from "./ui/stores/authStore";
|
||||||
|
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
|
||||||
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
|
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
|
||||||
import type { Selectable } from "./viewerTypes";
|
import type { Selectable } from "./viewerTypes";
|
||||||
|
|
||||||
@@ -27,19 +28,24 @@ const hoverConnectorLineEl = ref<SVGLineElement | null>(null);
|
|||||||
|
|
||||||
const hudState = createViewerHudState();
|
const hudState = createViewerHudState();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
const playerFactionStore = usePlayerFactionStore();
|
||||||
const automationCatalogStore = useShipAutomationCatalogStore();
|
const automationCatalogStore = useShipAutomationCatalogStore();
|
||||||
const selectionStore = useViewerSelectionStore();
|
const selectionStore = useViewerSelectionStore();
|
||||||
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
|
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
|
||||||
const { canAccessGm } = storeToRefs(authStore);
|
const { canAccessGm, effectivePlayerId } = storeToRefs(authStore);
|
||||||
|
const { playerFaction } = storeToRefs(playerFactionStore);
|
||||||
let viewer: GameViewer | undefined;
|
let viewer: GameViewer | undefined;
|
||||||
|
|
||||||
const gmOpsOpen = ref(false);
|
const gmOpsOpen = ref(false);
|
||||||
const gmTelemetryOpen = ref(false);
|
const gmTelemetryOpen = ref(false);
|
||||||
const gmSettingsOpen = ref(false);
|
const gmSettingsOpen = ref(false);
|
||||||
const gmMenuOpen = ref(false);
|
const gmMenuOpen = ref(false);
|
||||||
|
const leftSidebarTab = ref<"player" | "entities">("player");
|
||||||
|
const playerContextReady = ref(false);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
void automationCatalogStore.load();
|
void automationCatalogStore.load();
|
||||||
|
await refreshPlayerContext();
|
||||||
await startViewerIfAuthenticated();
|
await startViewerIfAuthenticated();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,15 +53,35 @@ onBeforeUnmount(() => {
|
|||||||
viewer?.dispose();
|
viewer?.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => authStore.isAuthenticated, async (isAuthenticated) => {
|
watch(
|
||||||
if (isAuthenticated) {
|
[() => authStore.isAuthenticated, () => effectivePlayerId.value],
|
||||||
await startViewerIfAuthenticated();
|
async ([isAuthenticated]) => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
playerContextReady.value = false;
|
||||||
|
playerFactionStore.setPlayerFaction(null);
|
||||||
|
viewer?.dispose();
|
||||||
|
viewer = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await refreshPlayerContext();
|
||||||
|
await startViewerIfAuthenticated();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => playerFaction.value?.requiresOnboarding ?? false,
|
||||||
|
async (requiresOnboarding) => {
|
||||||
|
if (requiresOnboarding) {
|
||||||
viewer?.dispose();
|
viewer?.dispose();
|
||||||
viewer = undefined;
|
viewer = undefined;
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await startViewerIfAuthenticated();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
function onHistoryWindowResize(id: string, width: number, height: number) {
|
function onHistoryWindowResize(id: string, width: number, height: number) {
|
||||||
const windowState = hudState.historyWindows.find((entry) => entry.id === id);
|
const windowState = hudState.historyWindows.find((entry) => entry.id === id);
|
||||||
@@ -76,7 +102,7 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startViewerIfAuthenticated() {
|
async function startViewerIfAuthenticated() {
|
||||||
if (!authStore.isAuthenticated || viewer) {
|
if (!authStore.isAuthenticated || viewer || !playerContextReady.value || playerFaction.value?.requiresOnboarding) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,58 +127,112 @@ async function startViewerIfAuthenticated() {
|
|||||||
});
|
});
|
||||||
void viewer.start();
|
void viewer.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshPlayerContext() {
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
playerContextReady.value = false;
|
||||||
|
playerFactionStore.setPlayerFaction(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playerContextReady.value = false;
|
||||||
|
try {
|
||||||
|
playerFactionStore.setPlayerFaction(await fetchPlayerFaction());
|
||||||
|
} catch {
|
||||||
|
playerFactionStore.setPlayerFaction(null);
|
||||||
|
} finally {
|
||||||
|
playerContextReady.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthLandingPage v-if="!authStore.isAuthenticated" />
|
<AuthLandingPage v-if="!authStore.isAuthenticated" />
|
||||||
|
<div v-else-if="!playerContextReady" class="auth-landing">
|
||||||
|
<div class="auth-landing__backdrop" />
|
||||||
|
<div class="auth-landing__hero">
|
||||||
|
<h1>Preparing player context</h1>
|
||||||
|
<p>Loading your in-universe identity and ownership state.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PlayerOnboardingPanel v-else-if="playerContextReady && playerFaction?.requiresOnboarding" />
|
||||||
<div v-else class="viewer-app">
|
<div v-else class="viewer-app">
|
||||||
<div
|
<div
|
||||||
ref="canvasHostEl"
|
ref="canvasHostEl"
|
||||||
class="viewer-canvas-host"
|
class="viewer-canvas-host"
|
||||||
/>
|
/>
|
||||||
<div class="pointer-events-none fixed inset-0">
|
<div class="pointer-events-none fixed inset-0">
|
||||||
<div class="absolute left-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(360px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:right-5 max-[760px]:bottom-[148px] max-[760px]:w-auto max-[760px]:max-h-[38vh]">
|
<div class="viewer-left-sidebar-dock">
|
||||||
|
<section class="viewer-left-sidebar pointer-events-auto">
|
||||||
|
<div class="viewer-left-sidebar__tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="viewer-left-sidebar__tab"
|
||||||
|
:class="leftSidebarTab === 'player' ? 'viewer-left-sidebar__tab--active' : ''"
|
||||||
|
@click="leftSidebarTab = 'player'"
|
||||||
|
>
|
||||||
|
Player Informations
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="viewer-left-sidebar__tab"
|
||||||
|
:class="leftSidebarTab === 'entities' ? 'viewer-left-sidebar__tab--active' : ''"
|
||||||
|
@click="leftSidebarTab = 'entities'"
|
||||||
|
>
|
||||||
|
Entities
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="viewer-left-sidebar__body">
|
||||||
|
<div
|
||||||
|
v-if="leftSidebarTab === 'player'"
|
||||||
|
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--player"
|
||||||
|
>
|
||||||
<AuthSessionPanel />
|
<AuthSessionPanel />
|
||||||
<CollapsibleHudPanel
|
</div>
|
||||||
v-model:collapsed="hudState.gamePanel.collapsed"
|
|
||||||
class-name="topbar"
|
|
||||||
panel-name="game"
|
|
||||||
title="Game"
|
|
||||||
:summary="hudState.gamePanel.summary"
|
|
||||||
:body-text="hudState.gamePanel.bodyText"
|
|
||||||
/>
|
|
||||||
<CollapsibleHudPanel
|
|
||||||
v-model:collapsed="hudState.networkPanel.collapsed"
|
|
||||||
class-name="network-panel"
|
|
||||||
panel-name="network"
|
|
||||||
title="Network"
|
|
||||||
:summary="hudState.networkPanel.summary"
|
|
||||||
:body-text="hudState.networkPanel.bodyText"
|
|
||||||
/>
|
|
||||||
<CollapsibleHudPanel
|
|
||||||
v-model:collapsed="hudState.performancePanel.collapsed"
|
|
||||||
class-name="performance-panel"
|
|
||||||
panel-name="performance"
|
|
||||||
title="Performance"
|
|
||||||
:summary="hudState.performancePanel.summary"
|
|
||||||
:body-text="hudState.performancePanel.bodyText"
|
|
||||||
/>
|
|
||||||
<ViewerEntityBrowserPanel
|
<ViewerEntityBrowserPanel
|
||||||
class="min-h-0 flex-1"
|
v-else
|
||||||
|
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--entities"
|
||||||
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
|
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="hudState.statsOverlay.lines.length > 0"
|
||||||
|
class="viewer-stats-overlay-dock"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="viewer-stats-overlay"
|
||||||
|
:class="hudState.statsOverlay.mode === 'compact' ? 'viewer-stats-overlay--compact' : ''"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(line, index) in hudState.statsOverlay.lines"
|
||||||
|
:key="`${index}-${line}`"
|
||||||
|
class="viewer-stats-overlay__line"
|
||||||
|
:class="line === '' ? 'viewer-stats-overlay__line--spacer' : ''"
|
||||||
|
>
|
||||||
|
{{ line === "" ? "\u00A0" : line }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!hudState.systemPanel.hidden"
|
||||||
|
class="viewer-system-label-dock"
|
||||||
|
>
|
||||||
|
<div class="viewer-system-label">
|
||||||
|
<div class="viewer-system-label__title">
|
||||||
|
{{ hudState.systemPanel.title }}
|
||||||
|
</div>
|
||||||
|
<div class="viewer-system-label__subtitle">
|
||||||
|
{{ hudState.systemPanel.bodyHtml }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="absolute right-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(380px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto">
|
<div class="absolute right-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(380px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto">
|
||||||
<HtmlInfoPanel
|
|
||||||
class-name="system-panel-section"
|
|
||||||
title="System"
|
|
||||||
:subtitle="hudState.systemPanel.title"
|
|
||||||
:body-html="hudState.systemPanel.bodyHtml"
|
|
||||||
:hidden="hudState.systemPanel.hidden"
|
|
||||||
subtitle-class="system-title"
|
|
||||||
body-class="system-body"
|
|
||||||
/>
|
|
||||||
<ViewerEntityInspectorPanel
|
<ViewerEntityInspectorPanel
|
||||||
class="min-h-0 flex-1"
|
class="min-h-0 flex-1"
|
||||||
:fallback-title="hudState.detailPanel.title"
|
:fallback-title="hudState.detailPanel.title"
|
||||||
@@ -222,7 +302,7 @@ async function startViewerIfAuthenticated() {
|
|||||||
<GmOpsWindow
|
<GmOpsWindow
|
||||||
v-if="gmOpsOpen"
|
v-if="gmOpsOpen"
|
||||||
@close="gmOpsOpen = false"
|
@close="gmOpsOpen = false"
|
||||||
@focus="(id, kind) => onFocusSelection({ kind, id }, kind === 'ship' ? 'follow' : 'tactical')"
|
@focus="(id, kind) => onFocusSelection({ kind, id }, 'tactical')"
|
||||||
/>
|
/>
|
||||||
<GmTelemetryWindow
|
<GmTelemetryWindow
|
||||||
v-if="gmTelemetryOpen"
|
v-if="gmTelemetryOpen"
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export class ViewerAppController {
|
|||||||
private currentDistance = NAV_DISTANCE.system;
|
private currentDistance = NAV_DISTANCE.system;
|
||||||
private desiredDistance = NAV_DISTANCE.system;
|
private desiredDistance = NAV_DISTANCE.system;
|
||||||
private orbitYaw = -2.3;
|
private orbitYaw = -2.3;
|
||||||
private orbitPitch = 0.62;
|
private orbitPitch = 1.08;
|
||||||
private cameraMode: CameraMode = "tactical";
|
private cameraMode: CameraMode = "tactical";
|
||||||
private dragMode?: DragMode;
|
private dragMode?: DragMode;
|
||||||
private dragPointerId?: number;
|
private dragPointerId?: number;
|
||||||
@@ -195,6 +195,7 @@ export class ViewerAppController {
|
|||||||
return this.sceneDataController.createWorldPresentationContext({
|
return this.sceneDataController.createWorldPresentationContext({
|
||||||
world: this.world,
|
world: this.world,
|
||||||
activeSystemId: this.activeSystemId,
|
activeSystemId: this.activeSystemId,
|
||||||
|
cameraMode: this.cameraMode,
|
||||||
povLevel: this.povLevel,
|
povLevel: this.povLevel,
|
||||||
orbitYaw: this.orbitYaw,
|
orbitYaw: this.orbitYaw,
|
||||||
systemCamera: this.systemLayer.camera,
|
systemCamera: this.systemLayer.camera,
|
||||||
@@ -293,7 +294,7 @@ export class ViewerAppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.updatePanFromKeyboard(delta);
|
this.updatePanFromKeyboard(delta);
|
||||||
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
|
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.92, 1.32);
|
||||||
|
|
||||||
const orbitOffset = this.computeOrbitOffset();
|
const orbitOffset = this.computeOrbitOffset();
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import type { WorldDelta, WorldSnapshot } from "./contracts";
|
|||||||
import type { TelemetrySnapshot } from "./contractsTelemetry";
|
import type { TelemetrySnapshot } from "./contractsTelemetry";
|
||||||
import type { BalanceSettings } from "./contractsBalance";
|
import type { BalanceSettings } from "./contractsBalance";
|
||||||
import type { PlayerFactionSnapshot } from "./contractsPlayerFaction";
|
import type { PlayerFactionSnapshot } from "./contractsPlayerFaction";
|
||||||
import type { AuthSessionResponse, ForgotPasswordResponse } from "./contractsAuth";
|
import type { RaceSnapshot } from "./contractsRaces";
|
||||||
|
import type { AuthSessionResponse, ForgotPasswordResponse, RegisterResponse } from "./contractsAuth";
|
||||||
|
import type { PlayerIdentitySummary } from "./contractsIdentity";
|
||||||
import type { ShipAutomationCatalogSnapshot } from "./contractsShipAutomation";
|
import type { ShipAutomationCatalogSnapshot } from "./contractsShipAutomation";
|
||||||
import type { FactionSnapshot } from "./contractsFactions";
|
import type { FactionSnapshot } from "./contractsFactions";
|
||||||
import type { ShipSnapshot } from "./contractsShips";
|
import type { ShipSnapshot } from "./contractsShips";
|
||||||
import type { StationSnapshot } from "./contractsInfrastructure";
|
import type { StationSnapshot } from "./contractsInfrastructure";
|
||||||
import { clearAuthSession, getAuthSession, setAuthSession } from "./authSession";
|
import { clearAuthSession, getAuthSession, setAuthSession } from "./authSession";
|
||||||
|
import { getEffectivePlayerIdentityId } from "./effectiveIdentitySession";
|
||||||
import type {
|
import type {
|
||||||
PlayerAssetAssignmentCommandRequest,
|
PlayerAssetAssignmentCommandRequest,
|
||||||
PlayerAutomationPolicyCommandRequest,
|
PlayerAutomationPolicyCommandRequest,
|
||||||
@@ -35,6 +38,12 @@ async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit, option
|
|||||||
if (session?.accessToken) {
|
if (session?.accessToken) {
|
||||||
headers.set("Authorization", `Bearer ${session.accessToken}`);
|
headers.set("Authorization", `Bearer ${session.accessToken}`);
|
||||||
}
|
}
|
||||||
|
if (session?.roles.some((role) => role === "gm" || role === "admin")) {
|
||||||
|
const effectivePlayerId = getEffectivePlayerIdentityId();
|
||||||
|
if (effectivePlayerId) {
|
||||||
|
headers.set("X-Act-As-Player-Id", effectivePlayerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(input, {
|
const response = await fetch(input, {
|
||||||
@@ -160,13 +169,11 @@ export async function resetWorld() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function register(request: { email: string; password: string }) {
|
export async function register(request: { email: string; password: string }) {
|
||||||
const session = await fetchJson<AuthSessionResponse>("/api/auth/register", {
|
return fetchJson<RegisterResponse>("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
}, { skipAuth: true, skipRefresh: true });
|
}, { skipAuth: true, skipRefresh: true });
|
||||||
setAuthSession(session);
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(request: { email: string; password: string }) {
|
export async function login(request: { email: string; password: string }) {
|
||||||
@@ -199,6 +206,22 @@ export async function fetchPlayerFaction(signal?: AbortSignal) {
|
|||||||
return fetchJson<PlayerFactionSnapshot>("/api/player-faction", { signal });
|
return fetchJson<PlayerFactionSnapshot>("/api/player-faction", { signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchRaces(signal?: AbortSignal) {
|
||||||
|
return fetchJson<RaceSnapshot[]>("/api/auth/races", { signal }, { skipAuth: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completePlayerOnboarding(request: { name: string; raceId: string }) {
|
||||||
|
return fetchJson<PlayerFactionSnapshot>("/api/player-faction/onboarding", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPlayerIdentities(signal?: AbortSignal) {
|
||||||
|
return fetchJson<PlayerIdentitySummary[]>("/api/player-faction/identities", { signal });
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchShipAutomationCatalog(signal?: AbortSignal) {
|
export async function fetchShipAutomationCatalog(signal?: AbortSignal) {
|
||||||
return fetchJson<ShipAutomationCatalogSnapshot>("/api/ships/catalog", { signal }, { skipAuth: true });
|
return fetchJson<ShipAutomationCatalogSnapshot>("/api/ships/catalog", { signal }, { skipAuth: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,9 +56,12 @@ async function submitLogin() {
|
|||||||
|
|
||||||
async function submitRegister() {
|
async function submitRegister() {
|
||||||
await execute(async () => {
|
await execute(async () => {
|
||||||
const session = await register(registerForm);
|
await register(registerForm);
|
||||||
authStore.setSession(session);
|
|
||||||
playerFactionStore.setPlayerFaction(null);
|
playerFactionStore.setPlayerFaction(null);
|
||||||
|
infoMessage.value = "Account created. Sign in to enter the universe.";
|
||||||
|
pane.value = "login";
|
||||||
|
loginForm.email = registerForm.email;
|
||||||
|
registerForm.password = "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,54 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from "vue";
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { login, register } from "../api";
|
import { fetchPlayerFaction, fetchPlayerIdentities, login, register } from "../api";
|
||||||
import { useAuthStore } from "../ui/stores/authStore";
|
import { useAuthStore } from "../ui/stores/authStore";
|
||||||
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
|
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const playerFactionStore = usePlayerFactionStore();
|
const playerFactionStore = usePlayerFactionStore();
|
||||||
const { session, busy } = storeToRefs(authStore);
|
const { session, busy, availablePlayerIdentities, effectivePlayerId } = storeToRefs(authStore);
|
||||||
|
|
||||||
const mode = ref<"login" | "register">("login");
|
const mode = ref<"login" | "register">("login");
|
||||||
const email = ref("");
|
const email = ref("");
|
||||||
const password = ref("");
|
const password = ref("");
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
|
const identityBusy = ref(false);
|
||||||
|
const identityError = ref("");
|
||||||
const forgotPasswordOpen = ref(false);
|
const forgotPasswordOpen = ref(false);
|
||||||
const forgotPasswordState = reactive({
|
const forgotPasswordState = reactive({
|
||||||
email: "",
|
email: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedIdentityId = computed({
|
||||||
|
get: () => effectivePlayerId.value ?? "",
|
||||||
|
set: (value: string) => {
|
||||||
|
void switchIdentity(value || null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canAccessGm = computed(() => authStore.canAccessGm);
|
||||||
|
const activeIdentitySummary = computed(() =>
|
||||||
|
availablePlayerIdentities.value.find((entry) => entry.userId === (effectivePlayerId.value ?? session.value?.userId ?? "")) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
authStore.setBusy(true);
|
authStore.setBusy(true);
|
||||||
try {
|
try {
|
||||||
const snapshot = mode.value === "login"
|
if (mode.value === "login") {
|
||||||
? await login({ email: email.value, password: password.value })
|
const snapshot = await login({ email: email.value, password: password.value });
|
||||||
: await register({ email: email.value, password: password.value });
|
|
||||||
authStore.setSession(snapshot);
|
authStore.setSession(snapshot);
|
||||||
playerFactionStore.setPlayerFaction(null);
|
playerFactionStore.setPlayerFaction(null);
|
||||||
password.value = "";
|
password.value = "";
|
||||||
forgotPasswordOpen.value = false;
|
forgotPasswordOpen.value = false;
|
||||||
|
} else {
|
||||||
|
await register({ email: email.value, password: password.value });
|
||||||
|
playerFactionStore.setPlayerFaction(null);
|
||||||
|
errorMessage.value = "";
|
||||||
|
mode.value = "login";
|
||||||
|
password.value = "";
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof Error ? error.message : "Authentication failed.";
|
errorMessage.value = error instanceof Error ? error.message : "Authentication failed.";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -42,6 +62,59 @@ function logout() {
|
|||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
password.value = "";
|
password.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshPlayerContext() {
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
playerFactionStore.setPlayerFaction(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
playerFactionStore.setPlayerFaction(await fetchPlayerFaction());
|
||||||
|
} catch {
|
||||||
|
playerFactionStore.setPlayerFaction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlayerIdentities() {
|
||||||
|
if (!authStore.isAuthenticated || !authStore.canAccessGm) {
|
||||||
|
authStore.setAvailablePlayerIdentities([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
identityBusy.value = true;
|
||||||
|
identityError.value = "";
|
||||||
|
try {
|
||||||
|
authStore.setAvailablePlayerIdentities(await fetchPlayerIdentities());
|
||||||
|
} catch (error) {
|
||||||
|
identityError.value = error instanceof Error ? error.message : "Unable to load player identities.";
|
||||||
|
authStore.setAvailablePlayerIdentities([]);
|
||||||
|
} finally {
|
||||||
|
identityBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchIdentity(nextPlayerId: string | null) {
|
||||||
|
authStore.setEffectivePlayerId(nextPlayerId);
|
||||||
|
identityBusy.value = true;
|
||||||
|
identityError.value = "";
|
||||||
|
try {
|
||||||
|
await refreshPlayerContext();
|
||||||
|
} catch (error) {
|
||||||
|
identityError.value = error instanceof Error ? error.message : "Unable to switch current identity.";
|
||||||
|
} finally {
|
||||||
|
identityBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => session.value?.userId ?? null,
|
||||||
|
async () => {
|
||||||
|
await loadPlayerIdentities();
|
||||||
|
await refreshPlayerContext();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -52,6 +125,29 @@ function logout() {
|
|||||||
<div class="text-xs uppercase tracking-[0.22em] text-white/45">Identity</div>
|
<div class="text-xs uppercase tracking-[0.22em] text-white/45">Identity</div>
|
||||||
<div class="mt-1 text-sm font-semibold">{{ session.email }}</div>
|
<div class="mt-1 text-sm font-semibold">{{ session.email }}</div>
|
||||||
<div class="mt-1 text-xs text-white/55">Player {{ session.userId.slice(0, 8) }}</div>
|
<div class="mt-1 text-xs text-white/55">Player {{ session.userId.slice(0, 8) }}</div>
|
||||||
|
<div v-if="canAccessGm" class="mt-3">
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.18em] text-white/45">Current Identity</div>
|
||||||
|
<select
|
||||||
|
v-model="selectedIdentityId"
|
||||||
|
class="mt-1 w-full rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm outline-none transition focus:border-white/30"
|
||||||
|
:disabled="identityBusy"
|
||||||
|
>
|
||||||
|
<option value="">GM Self</option>
|
||||||
|
<option
|
||||||
|
v-for="identity in availablePlayerIdentities"
|
||||||
|
:key="identity.userId"
|
||||||
|
:value="identity.userId"
|
||||||
|
>
|
||||||
|
{{ identity.email }}{{ identity.playerFactionLabel ? ` · ${identity.playerFactionLabel}` : "" }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="mt-1 text-[11px] text-white/50">
|
||||||
|
Acting as
|
||||||
|
{{ activeIdentitySummary?.email ?? session.email }}
|
||||||
|
<span v-if="activeIdentitySummary?.playerFactionLabel"> · {{ activeIdentitySummary.playerFactionLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="identityError" class="mt-2 text-[11px] text-[#ffd8cf]">{{ identityError }}</div>
|
||||||
|
</div>
|
||||||
<div v-if="session.roles.length > 0" class="mt-2 flex flex-wrap gap-1.5">
|
<div v-if="session.roles.length > 0" class="mt-2 flex flex-wrap gap-1.5">
|
||||||
<span
|
<span
|
||||||
v-for="role in session.roles"
|
v-for="role in session.roles"
|
||||||
|
|||||||
115
apps/viewer/src/components/PlayerOnboardingPanel.vue
Normal file
115
apps/viewer/src/components/PlayerOnboardingPanel.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
import { completePlayerOnboarding, fetchRaces } from "../api";
|
||||||
|
import type { RaceSnapshot } from "../contractsRaces";
|
||||||
|
import { useAuthStore } from "../ui/stores/authStore";
|
||||||
|
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const playerFactionStore = usePlayerFactionStore();
|
||||||
|
|
||||||
|
const busy = ref(false);
|
||||||
|
const loadingRaces = ref(false);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const raceOptions = ref<RaceSnapshot[]>([]);
|
||||||
|
const form = reactive({
|
||||||
|
name: "",
|
||||||
|
raceId: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
form.name.trim().length >= 2 && form.raceId.trim().length > 0 && !busy.value && !loadingRaces.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loadingRaces.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
try {
|
||||||
|
raceOptions.value = (await fetchRaces()).sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
if (!form.raceId && raceOptions.value.length > 0) {
|
||||||
|
form.raceId = raceOptions.value[0].id;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : "Unable to load race options.";
|
||||||
|
} finally {
|
||||||
|
loadingRaces.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!canSubmit.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
try {
|
||||||
|
const snapshot = await completePlayerOnboarding({
|
||||||
|
name: form.name.trim(),
|
||||||
|
raceId: form.raceId,
|
||||||
|
});
|
||||||
|
playerFactionStore.setPlayerFaction(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : "Unable to create your pilot.";
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function signOut() {
|
||||||
|
authStore.clearSession();
|
||||||
|
playerFactionStore.setPlayerFaction(null);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="auth-landing">
|
||||||
|
<div class="auth-landing__backdrop" />
|
||||||
|
|
||||||
|
<div class="auth-landing__hero">
|
||||||
|
<h1>Create your pilot</h1>
|
||||||
|
<p>
|
||||||
|
This account has access to the universe, but it does not have an in-game identity yet. Choose a name and an origin faction, then you will start with a single basic ship.
|
||||||
|
</p>
|
||||||
|
<div class="auth-card">
|
||||||
|
<h2>First Login Setup</h2>
|
||||||
|
|
||||||
|
<form class="auth-card__form" @submit.prevent="submit">
|
||||||
|
<input
|
||||||
|
v-model.trim="form.name"
|
||||||
|
type="text"
|
||||||
|
autocomplete="nickname"
|
||||||
|
maxlength="48"
|
||||||
|
placeholder="Pilot name"
|
||||||
|
>
|
||||||
|
<select v-model="form.raceId" :disabled="loadingRaces || busy">
|
||||||
|
<option value="" disabled>Select a race</option>
|
||||||
|
<option
|
||||||
|
v-for="race in raceOptions"
|
||||||
|
:key="race.id"
|
||||||
|
:value="race.id"
|
||||||
|
>
|
||||||
|
{{ race.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" :disabled="!canSubmit">
|
||||||
|
{{ busy ? "Entering universe..." : "Create pilot" }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-if="loadingRaces" class="auth-card__message auth-card__message--info">
|
||||||
|
Loading faction options...
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMessage" class="auth-card__message auth-card__message--error">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-card__footer">
|
||||||
|
<button type="button" class="auth-card__link" @click="signOut">
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
|
import type { StationSnapshot } from "../contractsInfrastructure";
|
||||||
|
import type { PlayerFleetSnapshot } from "../contractsPlayerFaction";
|
||||||
|
import type { ShipSnapshot } from "../contractsShips";
|
||||||
import { getShipBehaviorLabel } from "../shipAutomationPresentation";
|
import { getShipBehaviorLabel } from "../shipAutomationPresentation";
|
||||||
import { useGmStore } from "../ui/stores/gmStore";
|
import { useGmStore } from "../ui/stores/gmStore";
|
||||||
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
|
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
|
||||||
@@ -9,22 +12,27 @@ import { useViewerSelectionStore, type ViewerSelectionSummary } from "../ui/stor
|
|||||||
import type { Selectable } from "../viewerTypes";
|
import type { Selectable } from "../viewerTypes";
|
||||||
|
|
||||||
type BrowserTab = "visible" | "owned";
|
type BrowserTab = "visible" | "owned";
|
||||||
|
type BrowserSortKey = "entity" | "location" | "ai" | "hp";
|
||||||
|
type BrowserRowKind = "system" | "station" | "fleet" | "ship";
|
||||||
|
|
||||||
interface BrowserItem {
|
interface BrowserRow {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
kind: BrowserRowKind;
|
||||||
subtitle: string;
|
kindLabel: string;
|
||||||
meta?: string;
|
name: string;
|
||||||
|
ident: string;
|
||||||
|
location: string;
|
||||||
|
aiStates: string[];
|
||||||
|
hpLabel: string;
|
||||||
|
hpValue: number;
|
||||||
selection?: ViewerSelectionSummary;
|
selection?: ViewerSelectionSummary;
|
||||||
focusSelection?: Selectable;
|
focusSelection?: Selectable;
|
||||||
focusMode?: "follow" | "tactical";
|
focusMode?: "follow" | "tactical";
|
||||||
|
children: BrowserRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BrowserSection {
|
interface BrowserDisplayRow extends BrowserRow {
|
||||||
key: string;
|
depth: number;
|
||||||
label: string;
|
|
||||||
count: number;
|
|
||||||
items: BrowserItem[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -40,22 +48,28 @@ const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
|
|||||||
const { playerFaction } = storeToRefs(playerStore);
|
const { playerFaction } = storeToRefs(playerStore);
|
||||||
const { activeSystemId, povLevel } = storeToRefs(sceneStore);
|
const { activeSystemId, povLevel } = storeToRefs(sceneStore);
|
||||||
|
|
||||||
const activeTab = ref<BrowserTab>("visible");
|
const activeTab = ref<BrowserTab>("owned");
|
||||||
|
const sortKey = ref<BrowserSortKey>("entity");
|
||||||
|
const sortDirection = ref<"asc" | "desc">("asc");
|
||||||
const searchText = ref("");
|
const searchText = ref("");
|
||||||
|
const expandedRowKeys = ref<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const systemById = computed(() => new Map(gmStore.systems.map((system) => [system.id, system])));
|
||||||
|
const stationById = computed(() => new Map(gmStore.stations.map((station) => [station.id, station])));
|
||||||
|
const playerFleetByShipId = computed(() => {
|
||||||
|
const mapping = new Map<string, PlayerFleetSnapshot>();
|
||||||
|
for (const fleet of playerFaction.value?.fleets ?? []) {
|
||||||
|
for (const assetId of fleet.assetIds) {
|
||||||
|
mapping.set(assetId, fleet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapping;
|
||||||
|
});
|
||||||
|
|
||||||
function normalize(text: string) {
|
function normalize(text: string) {
|
||||||
return text.trim().toLowerCase();
|
return text.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesSearch(item: BrowserItem, search: string) {
|
|
||||||
if (!search) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const haystack = `${item.label} ${item.subtitle} ${item.meta ?? ""}`.toLowerCase();
|
|
||||||
return haystack.includes(search);
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleCase(value: string | null | undefined) {
|
function titleCase(value: string | null | undefined) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "Unknown";
|
return "Unknown";
|
||||||
@@ -69,168 +83,387 @@ function titleCase(value: string | null | undefined) {
|
|||||||
.replace(/\b\w/g, (part) => part.toUpperCase());
|
.replace(/\b\w/g, (part) => part.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVisibleSections(): BrowserSection[] {
|
function compactLabel(value: string | null | undefined, fallback: string) {
|
||||||
const sections: BrowserSection[] = [];
|
if (!value) {
|
||||||
|
return fallback;
|
||||||
if (povLevel.value === "galaxy" || !activeSystemId.value) {
|
|
||||||
const systems = [...gmStore.systems]
|
|
||||||
.sort((left, right) => left.label.localeCompare(right.label))
|
|
||||||
.map<BrowserItem>((system) => ({
|
|
||||||
key: `system-${system.id}`,
|
|
||||||
label: system.label,
|
|
||||||
subtitle: `${system.planets.length} planets · ${system.stars.length} stars`,
|
|
||||||
meta: system.id,
|
|
||||||
selection: { id: system.id, kind: "system", label: system.label },
|
|
||||||
focusSelection: { kind: "system", id: system.id },
|
|
||||||
focusMode: "tactical",
|
|
||||||
}));
|
|
||||||
sections.push({
|
|
||||||
key: "systems",
|
|
||||||
label: "Systems",
|
|
||||||
count: systems.length,
|
|
||||||
items: systems,
|
|
||||||
});
|
|
||||||
return sections;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemId = activeSystemId.value;
|
const words = titleCase(value).split(" ");
|
||||||
const ships = gmStore.ships
|
if (words.length === 1) {
|
||||||
.filter((ship) => ship.systemId === systemId)
|
return words[0].slice(0, 4).toUpperCase();
|
||||||
.sort((left, right) => left.name.localeCompare(right.name))
|
}
|
||||||
.map<BrowserItem>((ship) => ({
|
return words
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((word) => word.slice(0, 3).toUpperCase())
|
||||||
|
.join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortId(value: string) {
|
||||||
|
if (value.length <= 8) {
|
||||||
|
return value.toUpperCase();
|
||||||
|
}
|
||||||
|
return `${value.slice(0, 4).toUpperCase()}-${value.slice(-4).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueTokens(tokens: string[]) {
|
||||||
|
return tokens.filter((token, index) => token.length > 0 && tokens.indexOf(token) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShipLocation(ship: ShipSnapshot) {
|
||||||
|
const dockedStation = ship.dockedStationId ? stationById.value.get(ship.dockedStationId) : undefined;
|
||||||
|
if (dockedStation) {
|
||||||
|
return `Docked ${dockedStation.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ship.spatialState.transit?.destinationNodeId) {
|
||||||
|
return `Transit ${ship.systemId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ship.celestialId) {
|
||||||
|
return `Orbit ${titleCase(ship.celestialId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const system = systemById.value.get(ship.systemId);
|
||||||
|
return system?.label ?? ship.systemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStationLocation(station: StationSnapshot) {
|
||||||
|
const system = systemById.value.get(station.systemId);
|
||||||
|
if (station.celestialId) {
|
||||||
|
return `${system?.label ?? station.systemId} · ${titleCase(station.celestialId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return system?.label ?? station.systemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shipAiStates(ship: ShipSnapshot) {
|
||||||
|
const travelToken = ship.spatialState.transit ? "TRV" : "";
|
||||||
|
const dockToken = ship.dockedStationId ? "DCK" : "";
|
||||||
|
const behaviorToken = compactLabel(getShipBehaviorLabel(ship.defaultBehavior.kind), "AUTO");
|
||||||
|
const planToken = ship.activePlan?.steps.length ? "PLAN" : "";
|
||||||
|
const orderToken = ship.orderQueue.length > 0 ? "ORD" : "";
|
||||||
|
const commandToken = ship.commanderId ? "CMD" : "";
|
||||||
|
|
||||||
|
return uniqueTokens([behaviorToken, orderToken, planToken, travelToken, dockToken, commandToken]).slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stationAiStates(station: StationSnapshot) {
|
||||||
|
return uniqueTokens([
|
||||||
|
station.currentProcesses.length > 0 ? "PROC" : "",
|
||||||
|
station.dockedShips > 0 ? "DCK" : "",
|
||||||
|
station.commanderId ? "CMD" : "",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fleetAiStates(fleet: PlayerFleetSnapshot) {
|
||||||
|
return uniqueTokens([
|
||||||
|
compactLabel(fleet.status, "STAT"),
|
||||||
|
compactLabel(fleet.role, "ROLE"),
|
||||||
|
fleet.commanderId ? "CMD" : "",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemAiStates(systemId: string) {
|
||||||
|
const stations = gmStore.stations.filter((station) => station.systemId === systemId).length;
|
||||||
|
const ships = gmStore.ships.filter((ship) => ship.systemId === systemId).length;
|
||||||
|
return uniqueTokens([stations > 0 ? `ST${stations}` : "", ships > 0 ? `SH${ships}` : ""]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShipRow(ship: ShipSnapshot): BrowserRow {
|
||||||
|
return {
|
||||||
key: `ship-${ship.id}`,
|
key: `ship-${ship.id}`,
|
||||||
label: ship.name,
|
kind: "ship",
|
||||||
subtitle: `${titleCase(ship.type)} · ${titleCase(ship.state)}`,
|
kindLabel: "SH",
|
||||||
meta: `${getShipBehaviorLabel(ship.defaultBehavior.kind)}${ship.defaultBehavior.itemId ? ` · ${ship.defaultBehavior.itemId}` : ""}`,
|
name: ship.name,
|
||||||
|
ident: `${titleCase(ship.type)} · ${shortId(ship.id)}`,
|
||||||
|
location: formatShipLocation(ship),
|
||||||
|
aiStates: shipAiStates(ship),
|
||||||
|
hpLabel: Math.round(ship.health).toString(),
|
||||||
|
hpValue: ship.health,
|
||||||
selection: { id: ship.id, kind: "ship", label: ship.name },
|
selection: { id: ship.id, kind: "ship", label: ship.name },
|
||||||
focusSelection: { kind: "ship", id: ship.id },
|
focusSelection: { kind: "ship", id: ship.id },
|
||||||
focusMode: "follow",
|
focusMode: "tactical",
|
||||||
}));
|
children: [],
|
||||||
const stations = gmStore.stations
|
};
|
||||||
.filter((station) => station.systemId === systemId)
|
}
|
||||||
.sort((left, right) => left.label.localeCompare(right.label))
|
|
||||||
.map<BrowserItem>((station) => ({
|
function buildStationRow(station: StationSnapshot, children: BrowserRow[]): BrowserRow {
|
||||||
|
return {
|
||||||
key: `station-${station.id}`,
|
key: `station-${station.id}`,
|
||||||
label: station.label,
|
kind: "station",
|
||||||
subtitle: `${titleCase(station.category)} · Docked ${station.dockedShips}/${station.dockingPads}`,
|
kindLabel: "ST",
|
||||||
meta: station.factionId,
|
name: station.label,
|
||||||
|
ident: `${titleCase(station.category)} · ${titleCase(station.objective)}`,
|
||||||
|
location: formatStationLocation(station),
|
||||||
|
aiStates: stationAiStates(station),
|
||||||
|
hpLabel: "--",
|
||||||
|
hpValue: -1,
|
||||||
selection: { id: station.id, kind: "station", label: station.label },
|
selection: { id: station.id, kind: "station", label: station.label },
|
||||||
focusSelection: { kind: "station", id: station.id },
|
focusSelection: { kind: "station", id: station.id },
|
||||||
focusMode: "tactical",
|
focusMode: "tactical",
|
||||||
}));
|
children,
|
||||||
|
};
|
||||||
sections.push({
|
|
||||||
key: "ships",
|
|
||||||
label: "Ships",
|
|
||||||
count: ships.length,
|
|
||||||
items: ships,
|
|
||||||
});
|
|
||||||
sections.push({
|
|
||||||
key: "stations",
|
|
||||||
label: "Stations",
|
|
||||||
count: stations.length,
|
|
||||||
items: stations,
|
|
||||||
});
|
|
||||||
|
|
||||||
return sections;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOwnedSections(): BrowserSection[] {
|
function buildFleetRow(fleet: PlayerFleetSnapshot, children: BrowserRow[]): BrowserRow {
|
||||||
|
const homeStation = fleet.homeStationId ? stationById.value.get(fleet.homeStationId) : undefined;
|
||||||
|
const homeSystem = fleet.homeSystemId ? systemById.value.get(fleet.homeSystemId) : undefined;
|
||||||
|
return {
|
||||||
|
key: `fleet-${fleet.id}`,
|
||||||
|
kind: "fleet",
|
||||||
|
kindLabel: "FL",
|
||||||
|
name: fleet.label,
|
||||||
|
ident: `${titleCase(fleet.role)} · ${shortId(fleet.id)}`,
|
||||||
|
location: homeStation ? `Home ${homeStation.label}` : (homeSystem?.label ?? "No home"),
|
||||||
|
aiStates: fleetAiStates(fleet),
|
||||||
|
hpLabel: `${children.length}`,
|
||||||
|
hpValue: children.length,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemRow(systemId: string): BrowserRow | null {
|
||||||
|
const system = systemById.value.get(systemId);
|
||||||
|
if (!system) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: `system-${system.id}`,
|
||||||
|
kind: "system",
|
||||||
|
kindLabel: "SY",
|
||||||
|
name: system.label,
|
||||||
|
ident: shortId(system.id),
|
||||||
|
location: "Galaxy",
|
||||||
|
aiStates: systemAiStates(system.id),
|
||||||
|
hpLabel: "--",
|
||||||
|
hpValue: -1,
|
||||||
|
selection: { id: system.id, kind: "system", label: system.label },
|
||||||
|
focusSelection: { kind: "system", id: system.id },
|
||||||
|
focusMode: "tactical",
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVisibleRows() {
|
||||||
|
if (povLevel.value === "galaxy" || !activeSystemId.value) {
|
||||||
|
return gmStore.systems
|
||||||
|
.map((system) => buildSystemRow(system.id))
|
||||||
|
.filter((row): row is BrowserRow => row != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemId = activeSystemId.value;
|
||||||
|
const stations = gmStore.stations.filter((station) => station.systemId === systemId);
|
||||||
|
const ships = gmStore.ships.filter((ship) => ship.systemId === systemId);
|
||||||
|
const stationIds = new Set(stations.map((station) => station.id));
|
||||||
|
const stationChildren = new Map<string, BrowserRow[]>();
|
||||||
|
const fleetChildren = new Map<string, BrowserRow[]>();
|
||||||
|
const independentShips: BrowserRow[] = [];
|
||||||
|
|
||||||
|
for (const ship of ships) {
|
||||||
|
const row = buildShipRow(ship);
|
||||||
|
|
||||||
|
if (ship.dockedStationId && stationIds.has(ship.dockedStationId)) {
|
||||||
|
const children = stationChildren.get(ship.dockedStationId) ?? [];
|
||||||
|
children.push(row);
|
||||||
|
stationChildren.set(ship.dockedStationId, children);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fleet = playerFleetByShipId.value.get(ship.id);
|
||||||
|
if (fleet) {
|
||||||
|
const children = fleetChildren.get(fleet.id) ?? [];
|
||||||
|
children.push(row);
|
||||||
|
fleetChildren.set(fleet.id, children);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
independentShips.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stationRows = stations.map((station) => buildStationRow(station, stationChildren.get(station.id) ?? []));
|
||||||
|
const fleetRows = (playerFaction.value?.fleets ?? [])
|
||||||
|
.filter((fleet) => (fleetChildren.get(fleet.id)?.length ?? 0) > 0)
|
||||||
|
.map((fleet) => buildFleetRow(fleet, fleetChildren.get(fleet.id) ?? []));
|
||||||
|
|
||||||
|
return [...stationRows, ...fleetRows, ...independentShips];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOwnedRows() {
|
||||||
const player = playerFaction.value;
|
const player = playerFaction.value;
|
||||||
if (!player) {
|
if (!player) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ships = player.assetRegistry.shipIds
|
const ownedShips = player.assetRegistry.shipIds
|
||||||
.map((shipId) => gmStore.ships.find((ship) => ship.id === shipId))
|
.map((shipId) => gmStore.ships.find((ship) => ship.id === shipId))
|
||||||
.filter((ship): ship is NonNullable<typeof ship> => ship != null)
|
.filter((ship): ship is ShipSnapshot => ship != null);
|
||||||
.sort((left, right) => left.name.localeCompare(right.name))
|
const ownedStations = player.assetRegistry.stationIds
|
||||||
.map<BrowserItem>((ship) => ({
|
|
||||||
key: `owned-ship-${ship.id}`,
|
|
||||||
label: ship.name,
|
|
||||||
subtitle: `${ship.systemId} · ${titleCase(ship.state)}`,
|
|
||||||
meta: getShipBehaviorLabel(ship.defaultBehavior.kind),
|
|
||||||
selection: { id: ship.id, kind: "ship", label: ship.name },
|
|
||||||
focusSelection: { kind: "ship", id: ship.id },
|
|
||||||
focusMode: "follow",
|
|
||||||
}));
|
|
||||||
const stations = player.assetRegistry.stationIds
|
|
||||||
.map((stationId) => gmStore.stations.find((station) => station.id === stationId))
|
.map((stationId) => gmStore.stations.find((station) => station.id === stationId))
|
||||||
.filter((station): station is NonNullable<typeof station> => station != null)
|
.filter((station): station is StationSnapshot => station != null);
|
||||||
.sort((left, right) => left.label.localeCompare(right.label))
|
const ownedFleetShipIds = new Set(player.fleets.flatMap((fleet) => fleet.assetIds));
|
||||||
.map<BrowserItem>((station) => ({
|
const ownedStationIds = new Set(ownedStations.map((station) => station.id));
|
||||||
key: `owned-station-${station.id}`,
|
|
||||||
label: station.label,
|
|
||||||
subtitle: `${station.systemId} · ${titleCase(station.category)}`,
|
|
||||||
meta: `${station.installedModules.length} modules`,
|
|
||||||
selection: { id: station.id, kind: "station", label: station.label },
|
|
||||||
focusSelection: { kind: "station", id: station.id },
|
|
||||||
focusMode: "tactical",
|
|
||||||
}));
|
|
||||||
const fleets = player.fleets
|
|
||||||
.slice()
|
|
||||||
.sort((left, right) => left.label.localeCompare(right.label))
|
|
||||||
.map<BrowserItem>((fleet) => ({
|
|
||||||
key: `fleet-${fleet.id}`,
|
|
||||||
label: fleet.label,
|
|
||||||
subtitle: `${titleCase(fleet.role)} · ${titleCase(fleet.status)}`,
|
|
||||||
meta: `${fleet.assetIds.length} assets`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [
|
const stationChildren = new Map<string, BrowserRow[]>();
|
||||||
{
|
for (const ship of ownedShips) {
|
||||||
key: "owned-fleets",
|
if (!ship.dockedStationId || !ownedStationIds.has(ship.dockedStationId) || ownedFleetShipIds.has(ship.id)) {
|
||||||
label: "Fleets",
|
continue;
|
||||||
count: fleets.length,
|
}
|
||||||
items: fleets,
|
const children = stationChildren.get(ship.dockedStationId) ?? [];
|
||||||
},
|
children.push(buildShipRow(ship));
|
||||||
{
|
stationChildren.set(ship.dockedStationId, children);
|
||||||
key: "owned-stations",
|
}
|
||||||
label: "Stations",
|
|
||||||
count: stations.length,
|
const stationRows = ownedStations.map((station) => buildStationRow(station, stationChildren.get(station.id) ?? []));
|
||||||
items: stations,
|
const fleetRows = player.fleets.map((fleet) => buildFleetRow(
|
||||||
},
|
fleet,
|
||||||
{
|
fleet.assetIds
|
||||||
key: "owned-ships",
|
.map((shipId) => ownedShips.find((ship) => ship.id === shipId))
|
||||||
label: "Ships",
|
.filter((ship): ship is ShipSnapshot => ship != null)
|
||||||
count: ships.length,
|
.map((ship) => buildShipRow(ship)),
|
||||||
items: ships,
|
));
|
||||||
},
|
const independentShips = ownedShips
|
||||||
];
|
.filter((ship) => !ownedFleetShipIds.has(ship.id) && (!ship.dockedStationId || !ownedStationIds.has(ship.dockedStationId)))
|
||||||
|
.map((ship) => buildShipRow(ship));
|
||||||
|
|
||||||
|
return [...stationRows, ...fleetRows, ...independentShips];
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredSections = computed(() => {
|
function getRowSortValue(row: BrowserRow, key: BrowserSortKey) {
|
||||||
const search = normalize(searchText.value);
|
if (key === "hp") {
|
||||||
const sections = activeTab.value === "visible" ? buildVisibleSections() : buildOwnedSections();
|
return row.hpValue;
|
||||||
return sections
|
}
|
||||||
.map((section) => ({
|
|
||||||
...section,
|
if (key === "location") {
|
||||||
items: section.items.filter((item) => matchesSearch(item, search)),
|
return row.location;
|
||||||
}))
|
}
|
||||||
.filter((section) => section.items.length > 0);
|
|
||||||
|
if (key === "ai") {
|
||||||
|
return row.aiStates.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${row.name} ${row.ident}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRows(rows: BrowserRow[]): BrowserRow[] {
|
||||||
|
const direction = sortDirection.value === "asc" ? 1 : -1;
|
||||||
|
return [...rows]
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftValue = getRowSortValue(left, sortKey.value);
|
||||||
|
const rightValue = getRowSortValue(right, sortKey.value);
|
||||||
|
|
||||||
|
if (typeof leftValue === "number" && typeof rightValue === "number") {
|
||||||
|
return (leftValue - rightValue) * direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(leftValue).localeCompare(String(rightValue)) * direction;
|
||||||
|
})
|
||||||
|
.map((row) => ({
|
||||||
|
...row,
|
||||||
|
children: sortRows(row.children),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowMatches(row: BrowserRow, search: string) {
|
||||||
|
if (!search) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const haystack = `${row.name} ${row.ident} ${row.location} ${row.aiStates.join(" ")} ${row.hpLabel}`.toLowerCase();
|
||||||
|
return haystack.includes(search);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpanded(row: BrowserRow) {
|
||||||
|
if (row.children.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return expandedRowKeys.value[row.key] ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenRows(rows: BrowserRow[], search: string, depth = 0, forceExpand = false): BrowserDisplayRow[] {
|
||||||
|
const flattened: BrowserDisplayRow[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const descendantMatches = flattenRows(row.children, search, depth + 1, forceExpand);
|
||||||
|
const matches = rowMatches(row, search);
|
||||||
|
if (!matches && descendantMatches.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
flattened.push({
|
||||||
|
...row,
|
||||||
|
depth,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (row.children.length > 0 && (forceExpand || isExpanded(row))) {
|
||||||
|
flattened.push(...descendantMatches);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flattened;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawRows = computed(() => {
|
||||||
|
if (activeTab.value === "owned") {
|
||||||
|
return buildOwnedRows();
|
||||||
|
}
|
||||||
|
return buildVisibleRows();
|
||||||
});
|
});
|
||||||
|
|
||||||
function selectItem(item: BrowserItem) {
|
const displayRows = computed(() => {
|
||||||
if (!item.selection) {
|
const search = normalize(searchText.value);
|
||||||
|
const sortedRows = sortRows(rawRows.value);
|
||||||
|
return flattenRows(sortedRows, search, 0, search.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleSort(nextKey: BrowserSortKey) {
|
||||||
|
if (sortKey.value === nextKey) {
|
||||||
|
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectionStore.selectSelection(item.selection, "ui");
|
sortKey.value = nextKey;
|
||||||
|
sortDirection.value = "asc";
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusItem(item: BrowserItem) {
|
function toggleRow(row: BrowserDisplayRow) {
|
||||||
if (item.selection) {
|
if (row.children.length === 0) {
|
||||||
selectionStore.selectSelection(item.selection, "ui");
|
return;
|
||||||
}
|
}
|
||||||
if (item.focusSelection) {
|
|
||||||
emit("focus", item.focusSelection, item.focusMode);
|
expandedRowKeys.value[row.key] = !isExpanded(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(row: BrowserDisplayRow) {
|
||||||
|
if (!row.selection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionStore.selectSelection(row.selection, "ui");
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusItem(row: BrowserDisplayRow) {
|
||||||
|
if (row.selection) {
|
||||||
|
selectionStore.selectSelection(row.selection, "ui");
|
||||||
|
}
|
||||||
|
if (row.focusSelection) {
|
||||||
|
emit("focus", row.focusSelection, row.focusMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSelected(item: BrowserItem) {
|
function isSelected(row: BrowserDisplayRow) {
|
||||||
return !!item.selection
|
return !!row.selection
|
||||||
&& item.selection.id === selectedEntityId.value
|
&& row.selection.id === selectedEntityId.value
|
||||||
&& item.selection.kind === selectedEntityKind.value;
|
&& row.selection.kind === selectedEntityKind.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortMarker(key: BrowserSortKey) {
|
||||||
|
if (sortKey.value !== key) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortDirection.value === "asc" ? " ▲" : " ▼";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -276,47 +509,91 @@ function isSelected(item: BrowserItem) {
|
|||||||
<div v-if="activeTab === 'owned' && !playerFaction" class="entity-browser-panel__empty">
|
<div v-if="activeTab === 'owned' && !playerFaction" class="entity-browser-panel__empty">
|
||||||
No player-owned assets yet.
|
No player-owned assets yet.
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="filteredSections.length === 0" class="entity-browser-panel__empty">
|
<div v-else-if="displayRows.length === 0" class="entity-browser-panel__empty">
|
||||||
Nothing matches the current view.
|
Nothing matches the current view.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="entity-browser-panel__sections">
|
<div v-else class="entity-browser-panel__sections">
|
||||||
<section
|
<div class="entity-browser-table-wrap">
|
||||||
v-for="section in filteredSections"
|
<table class="entity-browser-table entity-browser-table--tree">
|
||||||
:key="section.key"
|
<colgroup>
|
||||||
class="entity-browser-section"
|
<col class="entity-browser-table__col entity-browser-table__col--entity">
|
||||||
>
|
<col class="entity-browser-table__col entity-browser-table__col--ident">
|
||||||
<header class="entity-browser-section__header">
|
<col class="entity-browser-table__col entity-browser-table__col--location">
|
||||||
<span>{{ section.label }}</span>
|
<col class="entity-browser-table__col entity-browser-table__col--ai">
|
||||||
<span>{{ section.items.length }}</span>
|
<col class="entity-browser-table__col entity-browser-table__col--hp">
|
||||||
</header>
|
</colgroup>
|
||||||
<div class="entity-browser-section__items">
|
<thead>
|
||||||
<div
|
<tr>
|
||||||
v-for="item in section.items"
|
<th scope="col">
|
||||||
:key="item.key"
|
<button type="button" class="entity-browser-table__sort" @click="toggleSort('entity')">
|
||||||
class="entity-browser-item"
|
Entity{{ sortMarker("entity") }}
|
||||||
:class="isSelected(item) ? 'entity-browser-item--selected' : ''"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="entity-browser-item__body"
|
|
||||||
:disabled="!item.selection"
|
|
||||||
@click="selectItem(item)"
|
|
||||||
>
|
|
||||||
<div class="entity-browser-item__label">{{ item.label }}</div>
|
|
||||||
<div class="entity-browser-item__subtitle">{{ item.subtitle }}</div>
|
|
||||||
<div v-if="item.meta" class="entity-browser-item__meta">{{ item.meta }}</div>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
</th>
|
||||||
v-if="item.focusSelection"
|
<th scope="col">Ident</th>
|
||||||
type="button"
|
<th scope="col">
|
||||||
class="entity-browser-item__focus"
|
<button type="button" class="entity-browser-table__sort" @click="toggleSort('location')">
|
||||||
@click.stop="focusItem(item)"
|
Location{{ sortMarker("location") }}
|
||||||
>
|
|
||||||
Focus
|
|
||||||
</button>
|
</button>
|
||||||
|
</th>
|
||||||
|
<th scope="col">
|
||||||
|
<button type="button" class="entity-browser-table__sort" @click="toggleSort('ai')">
|
||||||
|
AI{{ sortMarker("ai") }}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="entity-browser-table__numeric">
|
||||||
|
<button type="button" class="entity-browser-table__sort" @click="toggleSort('hp')">
|
||||||
|
HP{{ sortMarker("hp") }}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in displayRows"
|
||||||
|
:key="row.key"
|
||||||
|
class="entity-browser-table__row"
|
||||||
|
:class="isSelected(row) ? 'entity-browser-table__row--selected' : ''"
|
||||||
|
@click="selectItem(row)"
|
||||||
|
@dblclick="focusItem(row)"
|
||||||
|
>
|
||||||
|
<td class="entity-browser-table__name">
|
||||||
|
<div class="entity-browser-row" :style="{ paddingLeft: `${row.depth * 0.9}rem` }">
|
||||||
|
<button
|
||||||
|
v-if="row.children.length > 0"
|
||||||
|
type="button"
|
||||||
|
class="entity-browser-row__toggle"
|
||||||
|
@click.stop="toggleRow(row)"
|
||||||
|
>
|
||||||
|
{{ isExpanded(row) ? "-" : "+" }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="entity-browser-row__toggle entity-browser-row__toggle--spacer" />
|
||||||
|
<span class="entity-browser-row__kind" :class="`entity-browser-row__kind--${row.kind}`">
|
||||||
|
{{ row.kindLabel }}
|
||||||
|
</span>
|
||||||
|
<span class="entity-browser-row__label">{{ row.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="entity-browser-table__detail entity-browser-table__cell--truncate">{{ row.ident }}</td>
|
||||||
|
<td class="entity-browser-table__cell--truncate">{{ row.location }}</td>
|
||||||
|
<td class="entity-browser-table__cell--ai">
|
||||||
|
<div class="entity-browser-ai">
|
||||||
|
<span
|
||||||
|
v-for="token in row.aiStates"
|
||||||
|
:key="`${row.key}-${token}`"
|
||||||
|
class="entity-browser-ai__token"
|
||||||
|
>
|
||||||
|
{{ token }}
|
||||||
|
</span>
|
||||||
|
<span v-if="row.aiStates.length === 0" class="entity-browser-table__muted">--</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="entity-browser-table__numeric">
|
||||||
|
{{ row.hpLabel }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -76,6 +76,50 @@ function formatAmount(value: number) {
|
|||||||
return Math.abs(value - rounded) < 0.005 ? String(rounded) : value.toFixed(1);
|
return Math.abs(value - rounded) < 0.005 ? String(rounded) : value.toFixed(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPercent(value: number) {
|
||||||
|
return `${Math.round(value * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinDetail(parts: Array<string | null | undefined>) {
|
||||||
|
return parts.filter((part): part is string => !!part && part.trim().length > 0).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeOrderTarget(order: {
|
||||||
|
itemId?: string | null;
|
||||||
|
targetEntityId?: string | null;
|
||||||
|
targetSystemId?: string | null;
|
||||||
|
nodeId?: string | null;
|
||||||
|
constructionSiteId?: string | null;
|
||||||
|
sourceStationId?: string | null;
|
||||||
|
destinationStationId?: string | null;
|
||||||
|
moduleId?: string | null;
|
||||||
|
}) {
|
||||||
|
return order.itemId
|
||||||
|
?? order.targetEntityId
|
||||||
|
?? order.targetSystemId
|
||||||
|
?? order.nodeId
|
||||||
|
?? order.constructionSiteId
|
||||||
|
?? order.destinationStationId
|
||||||
|
?? order.sourceStationId
|
||||||
|
?? order.moduleId
|
||||||
|
?? "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSubTaskTarget(subTask: {
|
||||||
|
itemId?: string | null;
|
||||||
|
targetEntityId?: string | null;
|
||||||
|
targetSystemId?: string | null;
|
||||||
|
targetNodeId?: string | null;
|
||||||
|
moduleId?: string | null;
|
||||||
|
}) {
|
||||||
|
return subTask.itemId
|
||||||
|
?? subTask.targetEntityId
|
||||||
|
?? subTask.targetSystemId
|
||||||
|
?? subTask.targetNodeId
|
||||||
|
?? subTask.moduleId
|
||||||
|
?? "—";
|
||||||
|
}
|
||||||
|
|
||||||
const selectedShip = computed(() => {
|
const selectedShip = computed(() => {
|
||||||
if (selectedEntityKind.value !== "ship" || !selectedEntityId.value) {
|
if (selectedEntityKind.value !== "ship" || !selectedEntityId.value) {
|
||||||
return null;
|
return null;
|
||||||
@@ -100,10 +144,10 @@ const playerShipIds = computed(() =>
|
|||||||
new Set(playerFaction.value?.assetRegistry.shipIds ?? []),
|
new Set(playerFaction.value?.assetRegistry.shipIds ?? []),
|
||||||
);
|
);
|
||||||
|
|
||||||
const canAccessGm = computed(() => authStore.canAccessGm);
|
const canAccessGmDirectly = computed(() => authStore.canAccessGm && !authStore.isActingAsAlternateIdentity);
|
||||||
|
|
||||||
const canDirectControlSelectedShip = computed(() =>
|
const canDirectControlSelectedShip = computed(() =>
|
||||||
!!selectedShip.value && (canAccessGm.value || playerShipIds.value.has(selectedShip.value.id)),
|
!!selectedShip.value && (canAccessGmDirectly.value || playerShipIds.value.has(selectedShip.value.id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const directOrders = computed(() =>
|
const directOrders = computed(() =>
|
||||||
@@ -135,8 +179,194 @@ const formBehaviorNotes = computed(() =>
|
|||||||
getShipBehaviorNotes(behaviorForm.kind),
|
getShipBehaviorNotes(behaviorForm.kind),
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(selectedShip, (ship) => {
|
const shipStatusRows = computed(() => {
|
||||||
|
if (!selectedShip.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: "State", value: titleCase(selectedShip.value.state) },
|
||||||
|
{ label: "Behavior", value: getShipBehaviorLabel(selectedShip.value.defaultBehavior.kind) },
|
||||||
|
{ label: "Control", value: titleCase(selectedShip.value.controlSourceKind) },
|
||||||
|
{ label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" },
|
||||||
|
{
|
||||||
|
label: "Plan",
|
||||||
|
value: selectedShip.value.activePlan
|
||||||
|
? `${selectedShip.value.activePlan.kind} · ${titleCase(selectedShip.value.activePlan.status)}`
|
||||||
|
: "none",
|
||||||
|
},
|
||||||
|
{ label: "Failure", value: selectedShip.value.lastAccessFailureReason ?? "none" },
|
||||||
|
{ label: "Commander", value: selectedShip.value.commanderId ?? "none" },
|
||||||
|
{ label: "Docked", value: selectedShip.value.dockedStationId ?? "no" },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const shipCargoSummaryRows = computed(() => {
|
||||||
|
if (!selectedShip.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedCargo = selectedShip.value.inventory.reduce((sum, entry) => sum + entry.amount, 0);
|
||||||
|
return [
|
||||||
|
{ label: "Used", value: formatAmount(usedCargo) },
|
||||||
|
{ label: "Capacity", value: formatAmount(selectedShip.value.cargoCapacity) },
|
||||||
|
{ label: "Free", value: formatAmount(Math.max(selectedShip.value.cargoCapacity - usedCargo, 0)) },
|
||||||
|
{ label: "Travel", value: `${formatAmount(selectedShip.value.travelSpeed)} ${selectedShip.value.travelSpeedUnit}` },
|
||||||
|
{ label: "Hull", value: formatAmount(selectedShip.value.health) },
|
||||||
|
{ label: "Regime", value: titleCase(selectedShip.value.spatialState.movementRegime) },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const shipCargoRows = computed(() =>
|
||||||
|
selectedShip.value?.inventory.map((entry) => ({
|
||||||
|
key: entry.itemId,
|
||||||
|
ware: entry.itemId,
|
||||||
|
amount: formatAmount(entry.amount),
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const shipBehaviorRows = computed(() => {
|
||||||
|
if (!selectedShip.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: "Area", value: selectedShip.value.defaultBehavior.areaSystemId ?? "none" },
|
||||||
|
{ label: "Item", value: selectedShip.value.defaultBehavior.itemId ?? "none" },
|
||||||
|
{ label: "Home Station", value: selectedShip.value.defaultBehavior.homeStationId ?? "none" },
|
||||||
|
{ label: "Target", value: selectedShip.value.defaultBehavior.targetEntityId ?? "none" },
|
||||||
|
{ label: "Range", value: String(selectedShip.value.defaultBehavior.maxSystemRange) },
|
||||||
|
{ label: "Known Only", value: selectedShip.value.defaultBehavior.knownStationsOnly ? "yes" : "no" },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const directOrderRows = computed(() =>
|
||||||
|
directOrders.value.map((order) => ({
|
||||||
|
id: order.id,
|
||||||
|
label: getShipOrderLabel(order.kind),
|
||||||
|
status: titleCase(order.status),
|
||||||
|
target: describeOrderTarget(order),
|
||||||
|
detail: joinDetail([
|
||||||
|
`P${order.priority}`,
|
||||||
|
titleCase(order.sourceKind),
|
||||||
|
order.failureReason ?? undefined,
|
||||||
|
]),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const behaviorOrderRows = computed(() =>
|
||||||
|
behaviorOrders.value.map((order) => ({
|
||||||
|
id: order.id,
|
||||||
|
label: getShipOrderLabel(order.kind),
|
||||||
|
status: titleCase(order.status),
|
||||||
|
target: describeOrderTarget(order),
|
||||||
|
detail: joinDetail([
|
||||||
|
`P${order.priority}`,
|
||||||
|
getShipOrderSupportStatusLabel(order.kind) ?? undefined,
|
||||||
|
getShipOrderNotes(order.kind) ?? undefined,
|
||||||
|
order.failureReason ?? undefined,
|
||||||
|
]),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const shipPlanRows = computed(() => {
|
||||||
|
if (!selectedShip.value?.activePlan) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedShip.value.activePlan.steps.flatMap((step) => {
|
||||||
|
const stepRow = {
|
||||||
|
id: step.id,
|
||||||
|
scope: "Step",
|
||||||
|
activity: step.summary || titleCase(step.kind),
|
||||||
|
status: titleCase(step.status),
|
||||||
|
detail: joinDetail([
|
||||||
|
step.blockingReason ?? undefined,
|
||||||
|
`${step.subTasks.length} subtasks`,
|
||||||
|
]),
|
||||||
|
isSubTask: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const subTaskRows = step.subTasks.map((subTask) => ({
|
||||||
|
id: subTask.id,
|
||||||
|
scope: "Subtask",
|
||||||
|
activity: subTask.summary || titleCase(subTask.kind),
|
||||||
|
status: titleCase(subTask.status),
|
||||||
|
detail: joinDetail([
|
||||||
|
describeSubTaskTarget(subTask),
|
||||||
|
subTask.blockingReason ?? undefined,
|
||||||
|
`${Math.round(subTask.progress * 100)}%`,
|
||||||
|
]),
|
||||||
|
isSubTask: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [stepRow, ...subTaskRows];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const stationStatusRows = computed(() => {
|
||||||
|
if (!selectedStation.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: "Category", value: titleCase(selectedStation.value.category) },
|
||||||
|
{ label: "Objective", value: titleCase(selectedStation.value.objective) },
|
||||||
|
{ label: "Docked", value: `${selectedStation.value.dockedShips} / ${selectedStation.value.dockingPads}` },
|
||||||
|
{
|
||||||
|
label: "Population",
|
||||||
|
value: `${formatAmount(selectedStation.value.population)} / ${formatAmount(selectedStation.value.populationCapacity)}`,
|
||||||
|
},
|
||||||
|
{ label: "Workforce", value: formatAmount(selectedStation.value.workforceRequired) },
|
||||||
|
{ label: "Efficiency", value: formatPercent(selectedStation.value.workforceEffectiveRatio) },
|
||||||
|
{ label: "Commander", value: selectedStation.value.commanderId ?? "none" },
|
||||||
|
{ label: "Policy", value: selectedStation.value.policySetId ?? "none" },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const stationModuleRows = computed(() =>
|
||||||
|
selectedStation.value?.installedModules.map((moduleId) => ({
|
||||||
|
key: moduleId,
|
||||||
|
module: moduleNameById.get(moduleId) ?? moduleId,
|
||||||
|
moduleId,
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stationStorageRows = computed(() =>
|
||||||
|
selectedStation.value?.storageUsage.map((entry) => ({
|
||||||
|
key: entry.storageClass,
|
||||||
|
storageClass: titleCase(entry.storageClass),
|
||||||
|
used: formatAmount(entry.used),
|
||||||
|
capacity: formatAmount(entry.capacity),
|
||||||
|
fill: entry.capacity > 0 ? formatPercent(entry.used / entry.capacity) : "0%",
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stationInventoryRows = computed(() =>
|
||||||
|
selectedStation.value?.inventory.map((entry) => ({
|
||||||
|
key: entry.itemId,
|
||||||
|
ware: entry.itemId,
|
||||||
|
amount: formatAmount(entry.amount),
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stationProcessRows = computed(() =>
|
||||||
|
selectedStation.value?.currentProcesses.map((process) => ({
|
||||||
|
key: `${process.lane}-${process.label}`,
|
||||||
|
lane: process.lane,
|
||||||
|
label: process.label,
|
||||||
|
progress: formatPercent(process.progress),
|
||||||
|
timing: `${Math.ceil(process.timeRemainingSeconds)}s / ${Math.ceil(process.cycleSeconds)}s`,
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => `${selectedEntityKind.value ?? "none"}:${selectedEntityId.value ?? "none"}`,
|
||||||
|
() => {
|
||||||
|
const ship = selectedShip.value;
|
||||||
if (!ship) {
|
if (!ship) {
|
||||||
|
actionStatus.value = "";
|
||||||
|
actionError.value = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +378,9 @@ watch(selectedShip, (ship) => {
|
|||||||
moveOrderSystemId.value = ship.systemId ?? "";
|
moveOrderSystemId.value = ship.systemId ?? "";
|
||||||
actionStatus.value = "";
|
actionStatus.value = "";
|
||||||
actionError.value = "";
|
actionError.value = "";
|
||||||
}, { immediate: true });
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
function focusShip(cameraMode?: "follow" | "tactical") {
|
function focusShip(cameraMode?: "follow" | "tactical") {
|
||||||
if (!selectedShip.value) {
|
if (!selectedShip.value) {
|
||||||
@@ -357,36 +589,52 @@ async function clearOrders() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="entity-inspector-panel__actions">
|
<div class="entity-inspector-panel__actions">
|
||||||
<button type="button" class="entity-inspector-panel__action" @click="focusShip('tactical')">Focus</button>
|
<button type="button" class="entity-inspector-panel__action" @click="focusShip('tactical')">Focus</button>
|
||||||
<button type="button" class="entity-inspector-panel__action" @click="focusShip('follow')">Follow</button>
|
<button type="button" class="entity-inspector-panel__action" @click="focusShip('follow')">Track</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="entity-inspector-section">
|
<div class="entity-inspector-section">
|
||||||
<h4>Status</h4>
|
<h4>Status</h4>
|
||||||
<div class="entity-inspector-grid">
|
<div class="entity-inspector-table-wrap">
|
||||||
<div><span>State</span><strong>{{ titleCase(selectedShip.state) }}</strong></div>
|
<table class="entity-inspector-table entity-inspector-table--kv">
|
||||||
<div><span>Behavior</span><strong>{{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</strong></div>
|
<tbody>
|
||||||
<div><span>Control</span><strong>{{ selectedShip.controlSourceKind }}</strong></div>
|
<tr v-for="row in shipStatusRows" :key="row.label">
|
||||||
<div><span>Assignment</span><strong>{{ selectedShip.assignment?.kind ?? "unassigned" }}</strong></div>
|
<th scope="row">{{ row.label }}</th>
|
||||||
<div><span>Plan</span><strong>{{ selectedShip.activePlan ? `${selectedShip.activePlan.kind} · ${selectedShip.activePlan.status}` : "none" }}</strong></div>
|
<td>{{ row.value }}</td>
|
||||||
<div><span>Failure</span><strong>{{ selectedShip.lastAccessFailureReason ?? "none" }}</strong></div>
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="entity-inspector-section">
|
<div class="entity-inspector-section">
|
||||||
<h4>Cargo</h4>
|
<h4>Cargo</h4>
|
||||||
<div class="entity-inspector-grid">
|
<div class="entity-inspector-table-wrap">
|
||||||
<div><span>Used</span><strong>{{ formatAmount(selectedShip.inventory.reduce((sum, entry) => sum + entry.amount, 0)) }}</strong></div>
|
<table class="entity-inspector-table entity-inspector-table--kv">
|
||||||
<div><span>Capacity</span><strong>{{ formatAmount(selectedShip.cargoCapacity) }}</strong></div>
|
<tbody>
|
||||||
<div><span>Travel</span><strong>{{ formatAmount(selectedShip.travelSpeed) }} {{ selectedShip.travelSpeedUnit }}</strong></div>
|
<tr v-for="row in shipCargoSummaryRows" :key="row.label">
|
||||||
<div><span>Hull</span><strong>{{ formatAmount(selectedShip.health) }}</strong></div>
|
<th scope="row">{{ row.label }}</th>
|
||||||
|
<td>{{ row.value }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-if="shipCargoRows.length > 0" class="entity-inspector-table-wrap">
|
||||||
|
<table class="entity-inspector-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Ware</th>
|
||||||
|
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in shipCargoRows" :key="row.key">
|
||||||
|
<td>{{ row.ware }}</td>
|
||||||
|
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="selectedShip.inventory.length > 0" class="entity-inspector-list">
|
|
||||||
<li v-for="entry in selectedShip.inventory" :key="entry.itemId">
|
|
||||||
<span>{{ entry.itemId }}</span>
|
|
||||||
<strong>{{ formatAmount(entry.amount) }}</strong>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div v-else class="entity-inspector-empty">No cargo.</div>
|
<div v-else class="entity-inspector-empty">No cargo.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -395,13 +643,15 @@ async function clearOrders() {
|
|||||||
<div v-if="selectedBehaviorStatus || selectedBehaviorNotes" class="entity-inspector-note">
|
<div v-if="selectedBehaviorStatus || selectedBehaviorNotes" class="entity-inspector-note">
|
||||||
{{ [selectedBehaviorStatus, selectedBehaviorNotes].filter(Boolean).join(" · ") }}
|
{{ [selectedBehaviorStatus, selectedBehaviorNotes].filter(Boolean).join(" · ") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="entity-inspector-grid">
|
<div class="entity-inspector-table-wrap">
|
||||||
<div><span>Area</span><strong>{{ selectedShip.defaultBehavior.areaSystemId ?? "none" }}</strong></div>
|
<table class="entity-inspector-table entity-inspector-table--kv">
|
||||||
<div><span>Item</span><strong>{{ selectedShip.defaultBehavior.itemId ?? "none" }}</strong></div>
|
<tbody>
|
||||||
<div><span>Home Station</span><strong>{{ selectedShip.defaultBehavior.homeStationId ?? "none" }}</strong></div>
|
<tr v-for="row in shipBehaviorRows" :key="row.label">
|
||||||
<div><span>Target</span><strong>{{ selectedShip.defaultBehavior.targetEntityId ?? "none" }}</strong></div>
|
<th scope="row">{{ row.label }}</th>
|
||||||
<div><span>Range</span><strong>{{ selectedShip.defaultBehavior.maxSystemRange }}</strong></div>
|
<td>{{ row.value }}</td>
|
||||||
<div><span>Known Only</span><strong>{{ selectedShip.defaultBehavior.knownStationsOnly ? "yes" : "no" }}</strong></div>
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="canDirectControlSelectedShip" class="entity-inspector-form">
|
<div v-if="canDirectControlSelectedShip" class="entity-inspector-form">
|
||||||
<label class="entity-inspector-field">
|
<label class="entity-inspector-field">
|
||||||
@@ -473,13 +723,25 @@ async function clearOrders() {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
|
<div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
|
||||||
<div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
|
<div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
|
||||||
<ul v-if="directOrders.length > 0" class="entity-inspector-list">
|
<div v-if="directOrderRows.length > 0" class="entity-inspector-table-wrap">
|
||||||
<li v-for="order in directOrders" :key="order.id">
|
<table class="entity-inspector-table">
|
||||||
<span>{{ getShipOrderLabel(order.kind) }} · {{ order.status }}</span>
|
<thead>
|
||||||
<div class="entity-inspector-order-actions">
|
<tr>
|
||||||
<strong>{{ order.itemId ?? order.targetEntityId ?? order.targetSystemId ?? "—" }}</strong>
|
<th scope="col">Order</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Target</th>
|
||||||
|
<th scope="col">Detail</th>
|
||||||
|
<th v-if="canDirectControlSelectedShip" scope="col" class="entity-inspector-table__action-col">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="order in directOrderRows" :key="order.id">
|
||||||
|
<td>{{ order.label }}</td>
|
||||||
|
<td>{{ order.status }}</td>
|
||||||
|
<td>{{ order.target }}</td>
|
||||||
|
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
|
||||||
|
<td v-if="canDirectControlSelectedShip" class="entity-inspector-table__action-col">
|
||||||
<button
|
<button
|
||||||
v-if="canDirectControlSelectedShip"
|
|
||||||
type="button"
|
type="button"
|
||||||
class="entity-inspector-order-remove"
|
class="entity-inspector-order-remove"
|
||||||
:disabled="actionBusy"
|
:disabled="actionBusy"
|
||||||
@@ -487,38 +749,60 @@ async function clearOrders() {
|
|||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
|
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
|
||||||
<div class="entity-inspector-divider">
|
<div class="entity-inspector-divider">
|
||||||
<span>Behavior: {{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
|
<span>Behavior: {{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="behaviorOrders.length > 0" class="entity-inspector-list">
|
<div v-if="behaviorOrderRows.length > 0" class="entity-inspector-table-wrap">
|
||||||
<li v-for="order in behaviorOrders" :key="order.id">
|
<table class="entity-inspector-table">
|
||||||
<span>{{ getShipOrderLabel(order.kind) }} · {{ order.status }}</span>
|
<thead>
|
||||||
<strong>{{ [order.itemId ?? order.targetEntityId ?? order.targetSystemId ?? "—", getShipOrderSupportStatusLabel(order.kind), getShipOrderNotes(order.kind)].filter(Boolean).join(" · ") }}</strong>
|
<tr>
|
||||||
</li>
|
<th scope="col">Order</th>
|
||||||
</ul>
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Target</th>
|
||||||
|
<th scope="col">Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="order in behaviorOrderRows" :key="order.id">
|
||||||
|
<td>{{ order.label }}</td>
|
||||||
|
<td>{{ order.status }}</td>
|
||||||
|
<td>{{ order.target }}</td>
|
||||||
|
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
<div v-else class="entity-inspector-empty">No behavior orders queued.</div>
|
<div v-else class="entity-inspector-empty">No behavior orders queued.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="entity-inspector-section">
|
<div class="entity-inspector-section">
|
||||||
<h4>Plan Steps</h4>
|
<h4>Plan Steps</h4>
|
||||||
<ul v-if="selectedShip.activePlan" class="entity-inspector-plan">
|
<div v-if="shipPlanRows.length > 0" class="entity-inspector-table-wrap">
|
||||||
<li v-for="step in selectedShip.activePlan.steps" :key="step.id">
|
<table class="entity-inspector-table">
|
||||||
<div class="entity-inspector-plan__step">
|
<thead>
|
||||||
<span>{{ step.kind }} · {{ step.status }}</span>
|
<tr>
|
||||||
<strong>{{ step.blockingReason ?? "ok" }}</strong>
|
<th scope="col">Scope</th>
|
||||||
|
<th scope="col">Activity</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in shipPlanRows" :key="row.id" :class="row.isSubTask ? 'entity-inspector-table__row--subtask' : ''">
|
||||||
|
<td>{{ row.scope }}</td>
|
||||||
|
<td :class="row.isSubTask ? 'entity-inspector-table__subtask' : ''">{{ row.activity }}</td>
|
||||||
|
<td>{{ row.status }}</td>
|
||||||
|
<td class="entity-inspector-table__detail">{{ row.detail }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<ul class="entity-inspector-subtasks">
|
|
||||||
<li v-for="subTask in step.subTasks" :key="subTask.id">
|
|
||||||
<span>{{ subTask.kind }} · {{ subTask.status }}</span>
|
|
||||||
<strong>{{ subTask.blockingReason ?? `${Math.round(subTask.progress * 100)}%` }}</strong>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div v-else class="entity-inspector-empty">No active plan.</div>
|
<div v-else class="entity-inspector-empty">No active plan.</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -537,46 +821,102 @@ async function clearOrders() {
|
|||||||
|
|
||||||
<div class="entity-inspector-section">
|
<div class="entity-inspector-section">
|
||||||
<h4>Status</h4>
|
<h4>Status</h4>
|
||||||
<div class="entity-inspector-grid">
|
<div class="entity-inspector-table-wrap">
|
||||||
<div><span>Category</span><strong>{{ titleCase(selectedStation.category) }}</strong></div>
|
<table class="entity-inspector-table entity-inspector-table--kv">
|
||||||
<div><span>Objective</span><strong>{{ titleCase(selectedStation.objective) }}</strong></div>
|
<tbody>
|
||||||
<div><span>Docked</span><strong>{{ selectedStation.dockedShips }} / {{ selectedStation.dockingPads }}</strong></div>
|
<tr v-for="row in stationStatusRows" :key="row.label">
|
||||||
<div><span>Population</span><strong>{{ formatAmount(selectedStation.population) }} / {{ formatAmount(selectedStation.populationCapacity) }}</strong></div>
|
<th scope="row">{{ row.label }}</th>
|
||||||
<div><span>Workforce</span><strong>{{ formatAmount(selectedStation.workforceRequired) }}</strong></div>
|
<td>{{ row.value }}</td>
|
||||||
<div><span>Efficiency</span><strong>{{ Math.round(selectedStation.workforceEffectiveRatio * 100) }}%</strong></div>
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="entity-inspector-section">
|
<div class="entity-inspector-section">
|
||||||
<h4>Modules</h4>
|
<h4>Modules</h4>
|
||||||
<ul v-if="selectedStation.installedModules.length > 0" class="entity-inspector-list">
|
<div v-if="stationModuleRows.length > 0" class="entity-inspector-table-wrap">
|
||||||
<li v-for="moduleId in selectedStation.installedModules" :key="moduleId">
|
<table class="entity-inspector-table">
|
||||||
<span>{{ moduleNameById.get(moduleId) ?? moduleId }}</span>
|
<thead>
|
||||||
<strong>{{ moduleId }}</strong>
|
<tr>
|
||||||
</li>
|
<th scope="col">Module</th>
|
||||||
</ul>
|
<th scope="col">Id</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in stationModuleRows" :key="row.key">
|
||||||
|
<td>{{ row.module }}</td>
|
||||||
|
<td>{{ row.moduleId }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
<div v-else class="entity-inspector-empty">No modules installed.</div>
|
<div v-else class="entity-inspector-empty">No modules installed.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="entity-inspector-section">
|
<div class="entity-inspector-section">
|
||||||
<h4>Storage</h4>
|
<h4>Storage</h4>
|
||||||
<ul v-if="selectedStation.inventory.length > 0" class="entity-inspector-list">
|
<div v-if="stationStorageRows.length > 0" class="entity-inspector-table-wrap">
|
||||||
<li v-for="entry in selectedStation.inventory" :key="entry.itemId">
|
<table class="entity-inspector-table">
|
||||||
<span>{{ entry.itemId }}</span>
|
<thead>
|
||||||
<strong>{{ formatAmount(entry.amount) }}</strong>
|
<tr>
|
||||||
</li>
|
<th scope="col">Class</th>
|
||||||
</ul>
|
<th scope="col" class="entity-inspector-table__numeric">Used</th>
|
||||||
<div v-else class="entity-inspector-empty">No inventory.</div>
|
<th scope="col" class="entity-inspector-table__numeric">Capacity</th>
|
||||||
|
<th scope="col" class="entity-inspector-table__numeric">Fill</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in stationStorageRows" :key="row.key">
|
||||||
|
<td>{{ row.storageClass }}</td>
|
||||||
|
<td class="entity-inspector-table__numeric">{{ row.used }}</td>
|
||||||
|
<td class="entity-inspector-table__numeric">{{ row.capacity }}</td>
|
||||||
|
<td class="entity-inspector-table__numeric">{{ row.fill }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-if="stationInventoryRows.length > 0" class="entity-inspector-table-wrap">
|
||||||
|
<table class="entity-inspector-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Ware</th>
|
||||||
|
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in stationInventoryRows" :key="row.key">
|
||||||
|
<td>{{ row.ware }}</td>
|
||||||
|
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="stationStorageRows.length === 0" class="entity-inspector-empty">No inventory.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="entity-inspector-section">
|
<div class="entity-inspector-section">
|
||||||
<h4>Production</h4>
|
<h4>Production</h4>
|
||||||
<ul v-if="selectedStation.currentProcesses.length > 0" class="entity-inspector-list">
|
<div v-if="stationProcessRows.length > 0" class="entity-inspector-table-wrap">
|
||||||
<li v-for="process in selectedStation.currentProcesses" :key="`${process.lane}-${process.label}`">
|
<table class="entity-inspector-table">
|
||||||
<span>{{ process.label }}</span>
|
<thead>
|
||||||
<strong>{{ Math.round(process.progress * 100) }}% · {{ Math.ceil(process.timeRemainingSeconds) }}s</strong>
|
<tr>
|
||||||
</li>
|
<th scope="col">Lane</th>
|
||||||
</ul>
|
<th scope="col">Process</th>
|
||||||
|
<th scope="col" class="entity-inspector-table__numeric">Progress</th>
|
||||||
|
<th scope="col" class="entity-inspector-table__numeric">Timing</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in stationProcessRows" :key="row.key">
|
||||||
|
<td>{{ row.lane }}</td>
|
||||||
|
<td>{{ row.label }}</td>
|
||||||
|
<td class="entity-inspector-table__numeric">{{ row.progress }}</td>
|
||||||
|
<td class="entity-inspector-table__numeric">{{ row.timing }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
<div v-else class="entity-inspector-empty">No active processes.</div>
|
<div v-else class="entity-inspector-empty">No active processes.</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const canControlSelectedShip = computed(() => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authStore.canAccessGm) {
|
if (authStore.canAccessGm && !authStore.isActingAsAlternateIdentity) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export interface AuthSessionResponse {
|
|||||||
refreshTokenExpiresAtUtc: string;
|
refreshTokenExpiresAtUtc: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegisterResponse {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
requiresLogin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ForgotPasswordResponse {
|
export interface ForgotPasswordResponse {
|
||||||
accepted: boolean;
|
accepted: boolean;
|
||||||
resetToken?: string | null;
|
resetToken?: string | null;
|
||||||
|
|||||||
9
apps/viewer/src/contractsIdentity.ts
Normal file
9
apps/viewer/src/contractsIdentity.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface PlayerIdentitySummary {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
hasPlayerFaction: boolean;
|
||||||
|
playerFactionId?: string | null;
|
||||||
|
playerFactionLabel?: string | null;
|
||||||
|
sovereignFactionId?: string | null;
|
||||||
|
}
|
||||||
@@ -266,7 +266,10 @@ export interface PlayerAlertSnapshot {
|
|||||||
export interface PlayerFactionSnapshot {
|
export interface PlayerFactionSnapshot {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
personaName?: string | null;
|
||||||
|
raceId?: string | null;
|
||||||
sovereignFactionId: string;
|
sovereignFactionId: string;
|
||||||
|
requiresOnboarding: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
createdAtUtc: string;
|
createdAtUtc: string;
|
||||||
updatedAtUtc: string;
|
updatedAtUtc: string;
|
||||||
|
|||||||
6
apps/viewer/src/contractsRaces.ts
Normal file
6
apps/viewer/src/contractsRaces.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface RaceSnapshot {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
47
apps/viewer/src/effectiveIdentitySession.ts
Normal file
47
apps/viewer/src/effectiveIdentitySession.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const STORAGE_KEY = "space-game.auth.effective-player-id";
|
||||||
|
|
||||||
|
let currentEffectivePlayerId = loadEffectivePlayerId();
|
||||||
|
const listeners = new Set<(playerId: string | null) => void>();
|
||||||
|
|
||||||
|
export function getEffectivePlayerIdentityId() {
|
||||||
|
return currentEffectivePlayerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setEffectivePlayerIdentityId(playerId: string | null) {
|
||||||
|
currentEffectivePlayerId = playerId && playerId.trim().length > 0 ? playerId.trim() : null;
|
||||||
|
persistEffectivePlayerId(currentEffectivePlayerId);
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(currentEffectivePlayerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearEffectivePlayerIdentityId() {
|
||||||
|
setEffectivePlayerIdentityId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeToEffectivePlayerIdentity(listener: (playerId: string | null) => void) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEffectivePlayerId() {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw && raw.trim().length > 0 ? raw.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistEffectivePlayerId(playerId: string | null) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playerId) {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, playerId);
|
||||||
|
}
|
||||||
@@ -13,14 +13,22 @@ export class ViewerRenderSurface {
|
|||||||
private readonly onFrame: () => void;
|
private readonly onFrame: () => void;
|
||||||
private readonly onResizeCallback: (width: number, height: number) => void;
|
private readonly onResizeCallback: (width: number, height: number) => void;
|
||||||
private readonly resizeListener = () => this.resize();
|
private readonly resizeListener = () => this.resize();
|
||||||
|
private readonly resizeObserver?: ResizeObserver;
|
||||||
|
|
||||||
constructor(options: ViewerRenderSurfaceOptions) {
|
constructor(options: ViewerRenderSurfaceOptions) {
|
||||||
this.container = options.container;
|
this.container = options.container;
|
||||||
this.renderer = options.renderer;
|
this.renderer = options.renderer;
|
||||||
this.onFrame = options.onFrame;
|
this.onFrame = options.onFrame;
|
||||||
this.onResizeCallback = options.onResize;
|
this.onResizeCallback = options.onResize;
|
||||||
|
this.renderer.domElement.style.width = "100%";
|
||||||
|
this.renderer.domElement.style.height = "100%";
|
||||||
|
this.renderer.domElement.style.display = "block";
|
||||||
this.container.append(this.renderer.domElement);
|
this.container.append(this.renderer.domElement);
|
||||||
window.addEventListener("resize", this.resizeListener);
|
window.addEventListener("resize", this.resizeListener);
|
||||||
|
if (typeof ResizeObserver !== "undefined") {
|
||||||
|
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||||||
|
this.resizeObserver.observe(this.container);
|
||||||
|
}
|
||||||
this.resize();
|
this.resize();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +54,7 @@ export class ViewerRenderSurface {
|
|||||||
dispose() {
|
dispose() {
|
||||||
this.stop();
|
this.stop();
|
||||||
window.removeEventListener("resize", this.resizeListener);
|
window.removeEventListener("resize", this.resizeListener);
|
||||||
|
this.resizeObserver?.disconnect();
|
||||||
this.renderer.dispose();
|
this.renderer.dispose();
|
||||||
this.renderer.domElement.remove();
|
this.renderer.domElement.remove();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ body,
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top, rgba(89, 132, 247, 0.16), transparent 30%),
|
radial-gradient(circle at top, rgba(89, 132, 247, 0.16), transparent 30%),
|
||||||
@@ -269,12 +270,162 @@ select {
|
|||||||
|
|
||||||
canvas {
|
canvas {
|
||||||
display: block;
|
display: block;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-app,
|
.viewer-app,
|
||||||
.viewer-canvas-host {
|
.viewer-canvas-host {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-canvas-host {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-left-sidebar-dock {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: min(360px, 100vw);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-left-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(7, 14, 27, 0.9), rgba(7, 14, 27, 0.78)),
|
||||||
|
radial-gradient(circle at top left, rgba(127, 214, 255, 0.08), transparent 34%);
|
||||||
|
border-right: 1px solid rgba(132, 196, 255, 0.14);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
box-shadow: 18px 0 42px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-left-sidebar__tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-left-sidebar__tab {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--viewer-text);
|
||||||
|
padding: 0.75rem 0.95rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-left-sidebar__tab:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-left-sidebar__tab--active {
|
||||||
|
background: rgba(116, 196, 255, 0.14);
|
||||||
|
border-color: rgba(116, 196, 255, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-left-sidebar__body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-left-sidebar__panel {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-left-sidebar__panel--player {
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-left-sidebar__panel--entities {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-left-sidebar__panel--entities.entity-browser-panel {
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-stats-overlay {
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: rgba(234, 244, 255, 0.92);
|
||||||
|
text-shadow:
|
||||||
|
0 1px 0 rgba(0, 0, 0, 0.85),
|
||||||
|
0 0 12px rgba(0, 0, 0, 0.42);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-stats-overlay-dock {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: calc(min(360px, calc(100vw - 40px)) + 56px);
|
||||||
|
max-width: min(420px, calc(100vw - 496px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-system-label-dock {
|
||||||
|
position: absolute;
|
||||||
|
top: 22px;
|
||||||
|
right: calc(min(380px, calc(100vw - 40px)) + 48px);
|
||||||
|
max-width: min(340px, calc(100vw - 500px));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-system-label {
|
||||||
|
color: rgba(238, 246, 255, 0.96);
|
||||||
|
text-shadow:
|
||||||
|
0 1px 0 rgba(0, 0, 0, 0.88),
|
||||||
|
0 0 18px rgba(0, 0, 0, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-system-label__title {
|
||||||
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
|
font-size: clamp(1.5rem, 1.1rem + 1vw, 2.1rem);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-system-label__subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: rgba(203, 219, 235, 0.78);
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-stats-overlay--compact {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-stats-overlay__line--spacer {
|
||||||
|
line-height: 0.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-summary,
|
.panel-summary,
|
||||||
@@ -1257,54 +1408,253 @@ canvas {
|
|||||||
color: rgba(173, 220, 255, 0.64);
|
color: rgba(173, 220, 255, 0.64);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-browser-section__items {
|
.entity-browser-table-wrap,
|
||||||
display: flex;
|
.entity-inspector-table-wrap {
|
||||||
flex-direction: column;
|
overflow: auto;
|
||||||
gap: 0.45rem;
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
|
||||||
|
|
||||||
.entity-browser-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entity-browser-item__body {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.75rem 0.85rem;
|
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-browser-item__body:disabled {
|
.entity-browser-table,
|
||||||
opacity: 0.82;
|
.entity-inspector-table {
|
||||||
cursor: default;
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-browser-item--selected .entity-browser-item__body {
|
.entity-browser-table {
|
||||||
border-color: rgba(116, 196, 255, 0.38);
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__col--entity {
|
||||||
|
width: 38%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__col--ident {
|
||||||
|
width: 18%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__col--location {
|
||||||
|
width: 22%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__col--ai {
|
||||||
|
width: 14%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__col--hp {
|
||||||
|
width: 8%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table th,
|
||||||
|
.entity-browser-table td,
|
||||||
|
.entity-inspector-table th,
|
||||||
|
.entity-inspector-table td {
|
||||||
|
padding: 0.68rem 0.8rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table th,
|
||||||
|
.entity-browser-table td {
|
||||||
|
padding: 0.42rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table thead th,
|
||||||
|
.entity-inspector-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: rgba(7, 12, 18, 0.96);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: rgba(173, 220, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table tbody tr:last-child td,
|
||||||
|
.entity-inspector-table tbody tr:last-child td,
|
||||||
|
.entity-inspector-table tbody tr:last-child th {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__sort {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__row--selected {
|
||||||
background: rgba(116, 196, 255, 0.12);
|
background: rgba(116, 196, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-browser-item__label {
|
.entity-browser-table__name {
|
||||||
font-size: 0.88rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-browser-item__subtitle,
|
.entity-browser-table__cell--truncate {
|
||||||
.entity-browser-item__meta {
|
overflow: hidden;
|
||||||
margin-top: 0.18rem;
|
text-overflow: ellipsis;
|
||||||
font-size: 0.75rem;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__cell--ai {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__detail,
|
||||||
|
.entity-inspector-table__detail {
|
||||||
color: var(--viewer-muted);
|
color: var(--viewer-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-browser-item__focus,
|
.entity-browser-table__action-col,
|
||||||
|
.entity-inspector-table__action-col,
|
||||||
|
.entity-inspector-table__numeric {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__numeric {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__action,
|
||||||
.entity-inspector-panel__action {
|
.entity-inspector-panel__action {
|
||||||
padding: 0.65rem 0.9rem;
|
padding: 0.5rem 0.72rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.72rem;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__action {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--viewer-text);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__action:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-table__muted {
|
||||||
|
color: var(--viewer-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.38rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-row__toggle {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--viewer-text);
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-row__toggle--spacer {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-row__kind {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.75rem;
|
||||||
|
padding: 0.14rem 0.3rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(234, 244, 255, 0.88);
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-row__kind--system {
|
||||||
|
border-color: rgba(127, 214, 255, 0.24);
|
||||||
|
color: rgba(127, 214, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-row__kind--station {
|
||||||
|
border-color: rgba(255, 191, 105, 0.22);
|
||||||
|
color: rgba(255, 222, 168, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-row__kind--fleet {
|
||||||
|
border-color: rgba(146, 255, 200, 0.22);
|
||||||
|
color: rgba(190, 255, 223, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-row__kind--ship {
|
||||||
|
border-color: rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-row__label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-ai {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2rem;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-browser-ai__token {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2rem;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.13rem 0.28rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(127, 214, 255, 0.08);
|
||||||
|
border: 1px solid rgba(127, 214, 255, 0.14);
|
||||||
|
color: rgba(206, 233, 255, 0.84);
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
|
font-size: 0.56rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.entity-inspector-panel__actions {
|
.entity-inspector-panel__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
@@ -1328,71 +1678,31 @@ canvas {
|
|||||||
color: rgba(173, 220, 255, 0.7);
|
color: rgba(173, 220, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-inspector-grid {
|
.entity-inspector-table--kv th {
|
||||||
display: grid;
|
width: 38%;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
background: rgba(255, 255, 255, 0.02);
|
||||||
gap: 0.7rem 0.9rem;
|
font-size: 0.68rem;
|
||||||
}
|
|
||||||
|
|
||||||
.entity-inspector-grid span {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--viewer-muted);
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.12em;
|
||||||
|
color: rgba(173, 220, 255, 0.68);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-inspector-grid strong {
|
.entity-inspector-table--kv td {
|
||||||
display: block;
|
font-size: 0.84rem;
|
||||||
margin-top: 0.15rem;
|
|
||||||
font-size: 0.86rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-inspector-list,
|
.entity-inspector-table__row--subtask {
|
||||||
.entity-inspector-plan,
|
background: rgba(255, 255, 255, 0.02);
|
||||||
.entity-inspector-subtasks {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-inspector-list li,
|
.entity-inspector-table__subtask {
|
||||||
.entity-inspector-plan__step,
|
padding-left: 1.45rem;
|
||||||
.entity-inspector-subtasks li {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: baseline;
|
|
||||||
padding: 0.55rem 0.7rem;
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
background: rgba(255, 255, 255, 0.035);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-inspector-list span,
|
.entity-inspector-table__subtask::before {
|
||||||
.entity-inspector-plan__step span,
|
content: "↳ ";
|
||||||
.entity-inspector-subtasks span {
|
color: rgba(173, 220, 255, 0.58);
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entity-inspector-list strong,
|
|
||||||
.entity-inspector-plan__step strong,
|
|
||||||
.entity-inspector-subtasks strong {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--viewer-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.entity-inspector-plan > li {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entity-inspector-subtasks {
|
|
||||||
padding-left: 0.8rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-inspector-panel__fallback {
|
.entity-inspector-panel__fallback {
|
||||||
@@ -1592,8 +1902,30 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.entity-inspector-grid {
|
.viewer-left-sidebar-dock {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
width: min(360px, 100vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-left-sidebar {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-stats-overlay-dock {
|
||||||
|
top: 96px;
|
||||||
|
left: 20px;
|
||||||
|
right: 20px;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-system-label-dock {
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
right: 20px;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-system-label__title {
|
||||||
|
font-size: clamp(1.35rem, 1rem + 1vw, 1.8rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-inspector-inline-form,
|
.entity-inspector-inline-form,
|
||||||
@@ -1602,4 +1934,9 @@ canvas {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-browser-table,
|
||||||
|
.entity-inspector-table {
|
||||||
|
min-width: 640px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import type { AuthSessionResponse } from "../../contractsAuth";
|
import type { AuthSessionResponse } from "../../contractsAuth";
|
||||||
|
import type { PlayerIdentitySummary } from "../../contractsIdentity";
|
||||||
import { clearAuthSession, getAuthSession, setAuthSession, subscribeToAuthSession } from "../../authSession";
|
import { clearAuthSession, getAuthSession, setAuthSession, subscribeToAuthSession } from "../../authSession";
|
||||||
|
import {
|
||||||
|
clearEffectivePlayerIdentityId,
|
||||||
|
getEffectivePlayerIdentityId,
|
||||||
|
setEffectivePlayerIdentityId,
|
||||||
|
subscribeToEffectivePlayerIdentity,
|
||||||
|
} from "../../effectiveIdentitySession";
|
||||||
|
|
||||||
export const useAuthStore = defineStore("auth", {
|
export const useAuthStore = defineStore("auth", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
session: getAuthSession() as AuthSessionResponse | null,
|
session: getAuthSession() as AuthSessionResponse | null,
|
||||||
|
effectivePlayerId: getEffectivePlayerIdentityId() as string | null,
|
||||||
|
availablePlayerIdentities: [] as PlayerIdentitySummary[],
|
||||||
busy: false,
|
busy: false,
|
||||||
initialized: false,
|
initialized: false,
|
||||||
}),
|
}),
|
||||||
@@ -14,19 +23,35 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
roles: (state) => state.session?.roles ?? [],
|
roles: (state) => state.session?.roles ?? [],
|
||||||
canAccessGm: (state) => (state.session?.roles ?? []).some((role) => role === "gm" || role === "admin"),
|
canAccessGm: (state) => (state.session?.roles ?? []).some((role) => role === "gm" || role === "admin"),
|
||||||
accessToken: (state) => state.session?.accessToken ?? null,
|
accessToken: (state) => state.session?.accessToken ?? null,
|
||||||
|
isActingAsAlternateIdentity: (state) => state.effectivePlayerId != null && state.effectivePlayerId !== state.session?.userId,
|
||||||
|
activePlayerId: (state) => state.effectivePlayerId ?? state.session?.userId ?? null,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
setSession(session: AuthSessionResponse | null) {
|
setSession(session: AuthSessionResponse | null) {
|
||||||
this.session = session;
|
this.session = session;
|
||||||
setAuthSession(session);
|
setAuthSession(session);
|
||||||
|
if (!session || !(session.roles ?? []).some((role) => role === "gm" || role === "admin")) {
|
||||||
|
this.effectivePlayerId = null;
|
||||||
|
clearEffectivePlayerIdentityId();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
clearSession() {
|
clearSession() {
|
||||||
this.session = null;
|
this.session = null;
|
||||||
|
this.effectivePlayerId = null;
|
||||||
|
this.availablePlayerIdentities = [];
|
||||||
clearAuthSession();
|
clearAuthSession();
|
||||||
|
clearEffectivePlayerIdentityId();
|
||||||
},
|
},
|
||||||
setBusy(busy: boolean) {
|
setBusy(busy: boolean) {
|
||||||
this.busy = busy;
|
this.busy = busy;
|
||||||
},
|
},
|
||||||
|
setEffectivePlayerId(playerId: string | null) {
|
||||||
|
this.effectivePlayerId = playerId && playerId.trim().length > 0 ? playerId.trim() : null;
|
||||||
|
setEffectivePlayerIdentityId(this.effectivePlayerId);
|
||||||
|
},
|
||||||
|
setAvailablePlayerIdentities(identities: PlayerIdentitySummary[]) {
|
||||||
|
this.availablePlayerIdentities = identities;
|
||||||
|
},
|
||||||
initialize() {
|
initialize() {
|
||||||
if (this.initialized) {
|
if (this.initialized) {
|
||||||
return;
|
return;
|
||||||
@@ -36,6 +61,9 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
subscribeToAuthSession((session) => {
|
subscribeToAuthSession((session) => {
|
||||||
this.session = session as AuthSessionResponse | null;
|
this.session = session as AuthSessionResponse | null;
|
||||||
});
|
});
|
||||||
|
subscribeToEffectivePlayerIdentity((playerId) => {
|
||||||
|
this.effectivePlayerId = playerId;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,6 +103,42 @@ export function updatePanFromKeyboard(
|
|||||||
galaxyAnchor.addScaledVector(pan, speed * delta);
|
galaxyAnchor.addScaledVector(pan, speed * delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyPanFromScreenDelta(
|
||||||
|
delta: THREE.Vector2,
|
||||||
|
orbitYaw: number,
|
||||||
|
currentDistance: number,
|
||||||
|
povLevel: PovLevel,
|
||||||
|
activeSystemId: string | undefined,
|
||||||
|
systemAnchor: THREE.Vector3,
|
||||||
|
galaxyAnchor: THREE.Vector3,
|
||||||
|
viewportWidth: number,
|
||||||
|
viewportHeight: number,
|
||||||
|
minimumDistance: number,
|
||||||
|
maximumDistance: number,
|
||||||
|
) {
|
||||||
|
const safeWidth = Math.max(viewportWidth, 1);
|
||||||
|
const safeHeight = Math.max(viewportHeight, 1);
|
||||||
|
const normalized = new THREE.Vector2(delta.x / safeWidth, delta.y / safeHeight);
|
||||||
|
if (normalized.lengthSq() === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forward = new THREE.Vector3(Math.cos(orbitYaw), 0, Math.sin(orbitYaw));
|
||||||
|
const right = new THREE.Vector3(-forward.z, 0, forward.x);
|
||||||
|
const pan = right.multiplyScalar(-normalized.x).add(forward.multiplyScalar(-normalized.y));
|
||||||
|
|
||||||
|
if (activeSystemId) {
|
||||||
|
const scale = povLevel === "system"
|
||||||
|
? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.35, KILOMETERS_PER_AU * 6.5)
|
||||||
|
: THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 1200, 180000);
|
||||||
|
systemAnchor.addScaledVector(pan, scale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const galaxyScale = THREE.MathUtils.mapLinear(currentDistance, minimumDistance, maximumDistance, 1800, 22000);
|
||||||
|
galaxyAnchor.addScaledVector(pan, galaxyScale);
|
||||||
|
}
|
||||||
|
|
||||||
export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined {
|
export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined {
|
||||||
const {
|
const {
|
||||||
world,
|
world,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { ViewerPresentationController } from "./viewerPresentationController";
|
|||||||
import { ViewerSceneDataController } from "./viewerSceneDataController";
|
import { ViewerSceneDataController } from "./viewerSceneDataController";
|
||||||
import { ViewerWorldLifecycle } from "./viewerWorldLifecycle";
|
import { ViewerWorldLifecycle } from "./viewerWorldLifecycle";
|
||||||
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
|
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
|
||||||
|
import { applyPanFromScreenDelta } from "./viewerCamera";
|
||||||
|
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE } from "./viewerConstants";
|
||||||
import { useViewerSceneStore } from "./ui/stores/viewerScene";
|
import { useViewerSceneStore } from "./ui/stores/viewerScene";
|
||||||
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
|
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
|
||||||
import { viewerPinia } from "./ui/stores/pinia";
|
import { viewerPinia } from "./ui/stores/pinia";
|
||||||
@@ -236,14 +238,21 @@ export function createViewerControllers(host: any) {
|
|||||||
getFollowCameraPosition: () => host.followCameraPosition,
|
getFollowCameraPosition: () => host.followCameraPosition,
|
||||||
getFollowCameraFocus: () => host.followCameraFocus,
|
getFollowCameraFocus: () => host.followCameraFocus,
|
||||||
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
|
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
|
||||||
applyOrbitDelta: (delta: THREE.Vector2) => {
|
applyPanDelta: (delta: THREE.Vector2) => {
|
||||||
if (host.cameraMode === "follow") {
|
const bounds = host.renderer.domElement.getBoundingClientRect();
|
||||||
host.followOrbitYaw += delta.x * 0.008;
|
applyPanFromScreenDelta(
|
||||||
host.followOrbitPitch = THREE.MathUtils.clamp(host.followOrbitPitch + delta.y * 0.004, 0.02, 1.45);
|
delta,
|
||||||
} else {
|
host.orbitYaw,
|
||||||
host.orbitYaw += delta.x * 0.008;
|
host.currentDistance,
|
||||||
host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3);
|
host.povLevel,
|
||||||
}
|
host.activeSystemId,
|
||||||
|
host.systemAnchor,
|
||||||
|
host.galaxyAnchor,
|
||||||
|
bounds.width,
|
||||||
|
bounds.height,
|
||||||
|
MIN_CAMERA_DISTANCE,
|
||||||
|
MAX_CAMERA_DISTANCE,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
syncFollowStateFromSelection: () => navigationController.syncFollowStateFromSelection(),
|
syncFollowStateFromSelection: () => navigationController.syncFollowStateFromSelection(),
|
||||||
updatePanels: () => host.updatePanels(),
|
updatePanels: () => host.updatePanels(),
|
||||||
@@ -251,6 +260,11 @@ export function createViewerControllers(host: any) {
|
|||||||
updateGamePanel: (mode) => host.updateGamePanel(mode),
|
updateGamePanel: (mode) => host.updateGamePanel(mode),
|
||||||
openOrderContextMenu: (x, y, target) => orderContextMenuStore.open(x, y, target),
|
openOrderContextMenu: (x, y, target) => orderContextMenuStore.open(x, y, target),
|
||||||
closeOrderContextMenu: () => orderContextMenuStore.close(),
|
closeOrderContextMenu: () => orderContextMenuStore.close(),
|
||||||
|
getStatsOverlayMode: () => host.hudState.statsOverlay.mode,
|
||||||
|
setStatsOverlayMode: (mode) => {
|
||||||
|
host.hudState.statsOverlay.mode = mode;
|
||||||
|
},
|
||||||
|
refreshStatsOverlay: () => presentationController.refreshStatsOverlay(),
|
||||||
historyController,
|
historyController,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -269,6 +283,7 @@ export function wireViewerEvents(host: any) {
|
|||||||
canvas.addEventListener("pointerdown", host.interactionController.onPointerDown);
|
canvas.addEventListener("pointerdown", host.interactionController.onPointerDown);
|
||||||
canvas.addEventListener("pointermove", host.interactionController.onPointerMove);
|
canvas.addEventListener("pointermove", host.interactionController.onPointerMove);
|
||||||
canvas.addEventListener("pointerup", host.interactionController.onPointerUp);
|
canvas.addEventListener("pointerup", host.interactionController.onPointerUp);
|
||||||
|
canvas.addEventListener("pointercancel", host.interactionController.onPointerUp);
|
||||||
canvas.addEventListener("pointerleave", host.interactionController.onPointerUp);
|
canvas.addEventListener("pointerleave", host.interactionController.onPointerUp);
|
||||||
canvas.addEventListener("click", host.interactionController.onClick);
|
canvas.addEventListener("click", host.interactionController.onClick);
|
||||||
canvas.addEventListener("contextmenu", host.interactionController.onContextMenu);
|
canvas.addEventListener("contextmenu", host.interactionController.onContextMenu);
|
||||||
@@ -284,6 +299,7 @@ export function wireViewerEvents(host: any) {
|
|||||||
canvas.removeEventListener("pointerdown", host.interactionController.onPointerDown);
|
canvas.removeEventListener("pointerdown", host.interactionController.onPointerDown);
|
||||||
canvas.removeEventListener("pointermove", host.interactionController.onPointerMove);
|
canvas.removeEventListener("pointermove", host.interactionController.onPointerMove);
|
||||||
canvas.removeEventListener("pointerup", host.interactionController.onPointerUp);
|
canvas.removeEventListener("pointerup", host.interactionController.onPointerUp);
|
||||||
|
canvas.removeEventListener("pointercancel", host.interactionController.onPointerUp);
|
||||||
canvas.removeEventListener("pointerleave", host.interactionController.onPointerUp);
|
canvas.removeEventListener("pointerleave", host.interactionController.onPointerUp);
|
||||||
canvas.removeEventListener("click", host.interactionController.onClick);
|
canvas.removeEventListener("click", host.interactionController.onClick);
|
||||||
canvas.removeEventListener("contextmenu", host.interactionController.onContextMenu);
|
canvas.removeEventListener("contextmenu", host.interactionController.onContextMenu);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewer
|
|||||||
import { scaleGalaxyVector, toThreeVector } from "./viewerMath";
|
import { scaleGalaxyVector, toThreeVector } from "./viewerMath";
|
||||||
import { rawObject } from "./viewerScenePrimitives";
|
import { rawObject } from "./viewerScenePrimitives";
|
||||||
import { resolveShipWorldPosition } from "./viewerWorldPresentation";
|
import { resolveShipWorldPosition } from "./viewerWorldPresentation";
|
||||||
|
import type { StatsOverlayMode } from "./viewerHudState";
|
||||||
import type {
|
import type {
|
||||||
CameraMode,
|
CameraMode,
|
||||||
Selectable,
|
Selectable,
|
||||||
@@ -250,3 +251,20 @@ export function applyKeyboardControl(params: {
|
|||||||
|
|
||||||
return { cameraMode, desiredDistance };
|
return { cameraMode, desiredDistance };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cycleStatsOverlayMode(current: StatsOverlayMode): StatsOverlayMode {
|
||||||
|
switch (current) {
|
||||||
|
case "hidden":
|
||||||
|
return "compact";
|
||||||
|
case "compact":
|
||||||
|
return "status";
|
||||||
|
case "status":
|
||||||
|
return "network";
|
||||||
|
case "network":
|
||||||
|
return "performance";
|
||||||
|
case "performance":
|
||||||
|
return "full";
|
||||||
|
default:
|
||||||
|
return "hidden";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ export interface HudPanelState {
|
|||||||
bodyText: string;
|
bodyText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StatsOverlayMode = "hidden" | "compact" | "status" | "network" | "performance" | "full";
|
||||||
|
|
||||||
|
export interface StatsOverlayState {
|
||||||
|
mode: StatsOverlayMode;
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface HudHtmlPanelState {
|
export interface HudHtmlPanelState {
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -100,6 +107,7 @@ export interface ViewerHudState {
|
|||||||
gamePanel: HudPanelState;
|
gamePanel: HudPanelState;
|
||||||
networkPanel: HudPanelState;
|
networkPanel: HudPanelState;
|
||||||
performancePanel: HudPanelState;
|
performancePanel: HudPanelState;
|
||||||
|
statsOverlay: StatsOverlayState;
|
||||||
systemPanel: HudHtmlPanelState;
|
systemPanel: HudHtmlPanelState;
|
||||||
detailPanel: HudHtmlPanelState;
|
detailPanel: HudHtmlPanelState;
|
||||||
error: HudErrorState;
|
error: HudErrorState;
|
||||||
@@ -135,6 +143,10 @@ export function createViewerHudState(): ViewerHudState {
|
|||||||
summary: "Waiting",
|
summary: "Waiting",
|
||||||
bodyText: "Waiting for frame samples.",
|
bodyText: "Waiting for frame samples.",
|
||||||
},
|
},
|
||||||
|
statsOverlay: {
|
||||||
|
mode: "compact",
|
||||||
|
lines: [],
|
||||||
|
},
|
||||||
systemPanel: {
|
systemPanel: {
|
||||||
hidden: false,
|
hidden: false,
|
||||||
title: "Deep Space",
|
title: "Deep Space",
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import {
|
import {
|
||||||
completeMarqueeSelection,
|
|
||||||
hideMarqueeBox,
|
|
||||||
pickSelectableHitAtClientPosition,
|
pickSelectableHitAtClientPosition,
|
||||||
pickSelectableAtClientPosition,
|
pickSelectableAtClientPosition,
|
||||||
updateHoverLabel,
|
updateHoverLabel,
|
||||||
updateMarqueeBox,
|
|
||||||
} from "./viewerInteraction";
|
} from "./viewerInteraction";
|
||||||
import {
|
import {
|
||||||
applyKeyboardControl,
|
applyKeyboardControl,
|
||||||
|
cycleStatsOverlayMode,
|
||||||
toggleCameraMode,
|
toggleCameraMode,
|
||||||
navigateFromWheel,
|
navigateFromWheel,
|
||||||
} from "./viewerControls";
|
} from "./viewerControls";
|
||||||
import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT, NAV_DISTANCE_SHIP_HULL } from "./viewerConstants";
|
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE_PLANET_ORBIT } from "./viewerConstants";
|
||||||
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
|
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
|
||||||
import type { ViewerHudState } from "./viewerHudState";
|
import type { StatsOverlayMode, ViewerHudState } from "./viewerHudState";
|
||||||
import type {
|
import type {
|
||||||
CameraMode,
|
CameraMode,
|
||||||
DragMode,
|
DragMode,
|
||||||
@@ -61,88 +59,128 @@ export interface ViewerInteractionContext {
|
|||||||
getFollowCameraPosition: () => THREE.Vector3;
|
getFollowCameraPosition: () => THREE.Vector3;
|
||||||
getFollowCameraFocus: () => THREE.Vector3;
|
getFollowCameraFocus: () => THREE.Vector3;
|
||||||
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
|
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
|
||||||
applyOrbitDelta: (delta: THREE.Vector2) => void;
|
applyPanDelta: (delta: THREE.Vector2) => void;
|
||||||
syncFollowStateFromSelection: () => void;
|
syncFollowStateFromSelection: () => void;
|
||||||
updatePanels: () => void;
|
updatePanels: () => void;
|
||||||
focusOnSelection: (selection: Selectable) => void;
|
focusOnSelection: (selection: Selectable) => void;
|
||||||
updateGamePanel: (mode: string) => void;
|
updateGamePanel: (mode: string) => void;
|
||||||
openOrderContextMenu: (x: number, y: number, target: ViewerOrderContextMenuTarget) => void;
|
openOrderContextMenu: (x: number, y: number, target: ViewerOrderContextMenuTarget) => void;
|
||||||
closeOrderContextMenu: () => void;
|
closeOrderContextMenu: () => void;
|
||||||
|
getStatsOverlayMode: () => StatsOverlayMode;
|
||||||
|
setStatsOverlayMode: (mode: StatsOverlayMode) => void;
|
||||||
|
refreshStatsOverlay: () => void;
|
||||||
historyController: ViewerHistoryWindowController;
|
historyController: ViewerHistoryWindowController;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ViewerInteractionController {
|
export class ViewerInteractionController {
|
||||||
|
private readonly activePointers = new Map<number, THREE.Vector2>();
|
||||||
|
private pinchStartDistance?: number;
|
||||||
|
private pinchStartZoom?: number;
|
||||||
|
private pinchLastCenter?: THREE.Vector2;
|
||||||
|
|
||||||
constructor(private readonly context: ViewerInteractionContext) {}
|
constructor(private readonly context: ViewerInteractionContext) {}
|
||||||
|
|
||||||
readonly onPointerDown = (event: PointerEvent) => {
|
readonly onPointerDown = (event: PointerEvent) => {
|
||||||
if (event.button === 1) {
|
|
||||||
this.context.setDragMode("orbit");
|
|
||||||
this.context.setDragPointerId(event.pointerId);
|
|
||||||
this.context.dragLast.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
|
|
||||||
this.context.renderer.domElement.setPointerCapture(event.pointerId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.button !== 0) {
|
if (event.button !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.context.setDragMode("marquee");
|
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
|
||||||
this.context.setDragPointerId(event.pointerId);
|
this.activePointers.set(event.pointerId, point);
|
||||||
this.context.dragStart.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
|
|
||||||
this.context.dragLast.copy(this.context.dragStart);
|
|
||||||
this.context.setMarqueeActive(false);
|
|
||||||
this.context.renderer.domElement.setPointerCapture(event.pointerId);
|
this.context.renderer.domElement.setPointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
if (this.activePointers.size >= 2) {
|
||||||
|
const gesture = this.getPinchGesture();
|
||||||
|
if (!gesture) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context.setSuppressClickSelection(true);
|
||||||
|
this.context.setDragMode("pinch");
|
||||||
|
this.context.setDragPointerId(event.pointerId);
|
||||||
|
this.pinchStartDistance = gesture.distance;
|
||||||
|
this.pinchStartZoom = this.context.getDesiredDistance();
|
||||||
|
this.pinchLastCenter = gesture.center;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context.setDragMode("pan");
|
||||||
|
this.context.setDragPointerId(event.pointerId);
|
||||||
|
this.context.dragStart.copy(point);
|
||||||
|
this.context.dragLast.copy(point);
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly onPointerMove = (event: PointerEvent) => {
|
readonly onPointerMove = (event: PointerEvent) => {
|
||||||
this.updateHoverLabel(event);
|
this.updateHoverLabel(event);
|
||||||
|
|
||||||
|
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
|
||||||
|
if (this.activePointers.has(event.pointerId)) {
|
||||||
|
this.activePointers.set(event.pointerId, point);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.context.getDragPointerId() !== event.pointerId || !this.context.getDragMode()) {
|
if (this.context.getDragPointerId() !== event.pointerId || !this.context.getDragMode()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
|
if (this.context.getDragMode() === "pinch") {
|
||||||
if (this.context.getDragMode() === "orbit") {
|
const gesture = this.getPinchGesture();
|
||||||
|
if (!gesture || !this.pinchStartDistance || !this.pinchStartZoom || !this.pinchLastCenter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomRatio = THREE.MathUtils.clamp(gesture.distance / this.pinchStartDistance, 0.25, 4);
|
||||||
|
this.context.setDesiredDistance(THREE.MathUtils.clamp(
|
||||||
|
this.pinchStartZoom / zoomRatio,
|
||||||
|
MIN_CAMERA_DISTANCE,
|
||||||
|
MAX_CAMERA_DISTANCE,
|
||||||
|
));
|
||||||
|
const centerDelta = gesture.center.clone().sub(this.pinchLastCenter);
|
||||||
|
this.pinchLastCenter = gesture.center;
|
||||||
|
this.context.applyPanDelta(centerDelta);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const delta = point.clone().sub(this.context.dragLast);
|
const delta = point.clone().sub(this.context.dragLast);
|
||||||
this.context.dragLast.copy(point);
|
|
||||||
this.context.applyOrbitDelta(delta);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dragDistance = point.distanceTo(this.context.dragStart);
|
const dragDistance = point.distanceTo(this.context.dragStart);
|
||||||
if (!this.context.getMarqueeActive() && dragDistance > 8) {
|
if (dragDistance > 6) {
|
||||||
this.context.setMarqueeActive(true);
|
|
||||||
this.context.setSuppressClickSelection(true);
|
this.context.setSuppressClickSelection(true);
|
||||||
this.context.hudState.marquee.visible = true;
|
|
||||||
this.context.marqueeEl.style.display = "block";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.context.getMarqueeActive()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.context.dragLast.copy(point);
|
this.context.dragLast.copy(point);
|
||||||
updateMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl, this.context.dragStart, this.context.dragLast);
|
this.context.applyPanDelta(delta);
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly onPointerUp = (event: PointerEvent) => {
|
readonly onPointerUp = (event: PointerEvent) => {
|
||||||
if (this.context.getDragPointerId() !== event.pointerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) {
|
if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) {
|
||||||
this.context.renderer.domElement.releasePointerCapture(event.pointerId);
|
this.context.renderer.domElement.releasePointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
|
this.activePointers.delete(event.pointerId);
|
||||||
|
|
||||||
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
|
if (this.activePointers.size >= 2) {
|
||||||
this.completeMarqueeSelection();
|
const gesture = this.getPinchGesture();
|
||||||
hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl);
|
if (gesture) {
|
||||||
|
this.context.setDragMode("pinch");
|
||||||
|
this.pinchStartDistance = gesture.distance;
|
||||||
|
this.pinchStartZoom = this.context.getDesiredDistance();
|
||||||
|
this.pinchLastCenter = gesture.center;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const remainingPointer = this.activePointers.entries().next();
|
||||||
|
if (!remainingPointer.done) {
|
||||||
|
const [pointerId, point] = remainingPointer.value;
|
||||||
|
this.context.setDragMode("pan");
|
||||||
|
this.context.setDragPointerId(pointerId);
|
||||||
|
this.context.dragStart.copy(point);
|
||||||
|
this.context.dragLast.copy(point);
|
||||||
|
} else {
|
||||||
this.context.setDragMode(undefined);
|
this.context.setDragMode(undefined);
|
||||||
this.context.setDragPointerId(undefined);
|
this.context.setDragPointerId(undefined);
|
||||||
this.context.setMarqueeActive(false);
|
}
|
||||||
|
|
||||||
|
this.pinchStartDistance = undefined;
|
||||||
|
this.pinchStartZoom = undefined;
|
||||||
|
this.pinchLastCenter = undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly onClick = (event: MouseEvent) => {
|
readonly onClick = (event: MouseEvent) => {
|
||||||
@@ -225,8 +263,7 @@ export class ViewerInteractionController {
|
|||||||
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
|
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
|
||||||
this.context.syncFollowStateFromSelection();
|
this.context.syncFollowStateFromSelection();
|
||||||
this.context.focusOnSelection({ kind: "ship", id: shipId });
|
this.context.focusOnSelection({ kind: "ship", id: shipId });
|
||||||
this.toggleCameraMode("follow");
|
this.toggleCameraMode("tactical");
|
||||||
this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL);
|
|
||||||
this.context.updatePanels();
|
this.context.updatePanels();
|
||||||
this.context.updateGamePanel("Live");
|
this.context.updateGamePanel("Live");
|
||||||
return;
|
return;
|
||||||
@@ -268,8 +305,7 @@ export class ViewerInteractionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selection.kind === "ship") {
|
if (selection.kind === "ship") {
|
||||||
this.toggleCameraMode("follow");
|
this.toggleCameraMode("tactical");
|
||||||
this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL);
|
|
||||||
this.context.updatePanels();
|
this.context.updatePanels();
|
||||||
this.context.updateGamePanel("Live");
|
this.context.updateGamePanel("Live");
|
||||||
return;
|
return;
|
||||||
@@ -288,6 +324,13 @@ export class ViewerInteractionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const key = event.key.toLowerCase();
|
const key = event.key.toLowerCase();
|
||||||
|
if (key === "f10") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.context.setStatsOverlayMode(cycleStatsOverlayMode(this.context.getStatsOverlayMode()));
|
||||||
|
this.context.refreshStatsOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const controlState = applyKeyboardControl({
|
const controlState = applyKeyboardControl({
|
||||||
keyState: this.context.keyState,
|
keyState: this.context.keyState,
|
||||||
cameraMode: this.context.getCameraMode(),
|
cameraMode: this.context.getCameraMode(),
|
||||||
@@ -371,17 +414,17 @@ export class ViewerInteractionController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private completeMarqueeSelection() {
|
private getPinchGesture() {
|
||||||
const selection = completeMarqueeSelection({
|
const points = [...this.activePointers.values()];
|
||||||
renderer: this.context.renderer,
|
if (points.length < 2) {
|
||||||
systemCamera: this.context.systemCamera,
|
return null;
|
||||||
dragStart: this.context.dragStart,
|
}
|
||||||
dragLast: this.context.dragLast,
|
|
||||||
systemSelectableTargets: this.context.systemSelectableTargets,
|
const [first, second] = points;
|
||||||
});
|
return {
|
||||||
this.context.setSelectedItems(selection);
|
center: first.clone().add(second).multiplyScalar(0.5),
|
||||||
this.context.syncFollowStateFromSelection();
|
distance: first.distanceTo(second),
|
||||||
this.context.updatePanels();
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldFocusSelectionOnClick(selection: Selectable) {
|
private shouldFocusSelectionOnClick(selection: Selectable) {
|
||||||
|
|||||||
@@ -248,6 +248,54 @@ function renderSystemOwnership(world: WorldState, systemId: string): string {
|
|||||||
return lines.join("<br>");
|
return lines.join("<br>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function titleCaseLabel(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||||
|
.replace(/[-_]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.replace(/\b\w/g, (part) => part.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSystemSubtitle(world: WorldState, systemId: string): string {
|
||||||
|
const control = world.geopolitics?.territory.controlStates.find((state) => state.systemId === systemId);
|
||||||
|
const zone = world.geopolitics?.territory.zones.find((entry) => entry.systemId === systemId);
|
||||||
|
const profile = world.geopolitics?.territory.strategicProfiles.find((entry) => entry.systemId === systemId);
|
||||||
|
const region = world.geopolitics?.economyRegions.regions.find((entry) => entry.systemIds.includes(systemId));
|
||||||
|
|
||||||
|
if (region?.label) {
|
||||||
|
return region.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zone?.reason) {
|
||||||
|
return titleCaseLabel(zone.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zone?.kind) {
|
||||||
|
return titleCaseLabel(zone.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile?.zoneKind) {
|
||||||
|
return titleCaseLabel(profile.zoneKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control?.isContested) {
|
||||||
|
return "Contested";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control) {
|
||||||
|
return titleCaseLabel(control.controlKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = [...world.claims.values()].filter((claim) =>
|
||||||
|
claim.systemId === systemId && claim.state !== "destroyed");
|
||||||
|
return claims.length === 0 ? "Deep Space" : `Claims ${claims.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDetailPanelState(params: DetailPanelParams) {
|
export function buildDetailPanelState(params: DetailPanelParams) {
|
||||||
const {
|
const {
|
||||||
world,
|
world,
|
||||||
@@ -525,9 +573,7 @@ export function buildSystemPanelState(params: SystemPanelParams) {
|
|||||||
return {
|
return {
|
||||||
hidden: false,
|
hidden: false,
|
||||||
title: activeSystem.label,
|
title: activeSystem.label,
|
||||||
bodyHtml: `
|
bodyHtml: describeSystemSubtitle(world, activeSystem.id),
|
||||||
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
|
|
||||||
`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import {
|
import {
|
||||||
|
describeCompactStatsLine,
|
||||||
describeNetworkPanel,
|
describeNetworkPanel,
|
||||||
describePerformancePanel,
|
describePerformancePanel,
|
||||||
recordPerformanceStats,
|
recordPerformanceStats,
|
||||||
@@ -40,6 +41,56 @@ export interface ViewerPresentationContext {
|
|||||||
export class ViewerPresentationController {
|
export class ViewerPresentationController {
|
||||||
constructor(private readonly context: ViewerPresentationContext) { }
|
constructor(private readonly context: ViewerPresentationContext) { }
|
||||||
|
|
||||||
|
private refreshStatsOverlayLines(gameBodyText?: string) {
|
||||||
|
const mode = this.context.hudState.statsOverlay.mode;
|
||||||
|
if (mode === "hidden") {
|
||||||
|
this.context.hudState.statsOverlay.lines = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compactLine = describeCompactStatsLine(this.context.networkStats, this.context.performanceStats);
|
||||||
|
const gameLines = (gameBodyText ?? this.context.hudState.gamePanel.bodyText)
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const networkLines = this.context.hudState.networkPanel.bodyText
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const performanceLines = this.context.hudState.performancePanel.bodyText
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case "compact":
|
||||||
|
this.context.hudState.statsOverlay.lines = [compactLine];
|
||||||
|
return;
|
||||||
|
case "status":
|
||||||
|
this.context.hudState.statsOverlay.lines = [compactLine, ...gameLines];
|
||||||
|
return;
|
||||||
|
case "network":
|
||||||
|
this.context.hudState.statsOverlay.lines = [compactLine, ...networkLines];
|
||||||
|
return;
|
||||||
|
case "performance":
|
||||||
|
this.context.hudState.statsOverlay.lines = [compactLine, ...performanceLines];
|
||||||
|
return;
|
||||||
|
case "full":
|
||||||
|
this.context.hudState.statsOverlay.lines = [
|
||||||
|
compactLine,
|
||||||
|
"",
|
||||||
|
...gameLines,
|
||||||
|
"",
|
||||||
|
...networkLines,
|
||||||
|
"",
|
||||||
|
...performanceLines,
|
||||||
|
];
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
this.context.hudState.statsOverlay.lines = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
initializeAmbience() {
|
initializeAmbience() {
|
||||||
this.context.ambienceGroup.renderOrder = -10;
|
this.context.ambienceGroup.renderOrder = -10;
|
||||||
this.context.ambienceGroup.add(createBackdropStars(document));
|
this.context.ambienceGroup.add(createBackdropStars(document));
|
||||||
@@ -67,6 +118,7 @@ export class ViewerPresentationController {
|
|||||||
updateNetworkPanel() {
|
updateNetworkPanel() {
|
||||||
this.context.hudState.networkPanel.bodyText = describeNetworkPanel(this.context.networkStats);
|
this.context.hudState.networkPanel.bodyText = describeNetworkPanel(this.context.networkStats);
|
||||||
this.context.hudState.networkPanel.summary = summarizeNetworkStats(this.context.networkStats);
|
this.context.hudState.networkPanel.summary = summarizeNetworkStats(this.context.networkStats);
|
||||||
|
this.refreshStatsOverlayLines();
|
||||||
}
|
}
|
||||||
|
|
||||||
recordPerformanceStats(frameMs: number) {
|
recordPerformanceStats(frameMs: number) {
|
||||||
@@ -79,6 +131,7 @@ export class ViewerPresentationController {
|
|||||||
this.context.hudState.performancePanel.bodyText = bodyText;
|
this.context.hudState.performancePanel.bodyText = bodyText;
|
||||||
}
|
}
|
||||||
this.context.hudState.performancePanel.summary = summarizePerformanceStats(this.context.performanceStats);
|
this.context.hudState.performancePanel.summary = summarizePerformanceStats(this.context.performanceStats);
|
||||||
|
this.refreshStatsOverlayLines();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateShipPresentation() {
|
updateShipPresentation() {
|
||||||
@@ -116,6 +169,11 @@ export class ViewerPresentationController {
|
|||||||
});
|
});
|
||||||
this.context.hudState.gamePanel.bodyText = state.bodyText;
|
this.context.hudState.gamePanel.bodyText = state.bodyText;
|
||||||
this.context.hudState.gamePanel.summary = state.summaryText;
|
this.context.hudState.gamePanel.summary = state.summaryText;
|
||||||
|
this.refreshStatsOverlayLines(state.bodyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshStatsOverlay() {
|
||||||
|
this.refreshStatsOverlayLines();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSystemPanel() {
|
updateSystemPanel() {
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export class ViewerSceneDataController {
|
|||||||
createWorldPresentationContext(overrides: {
|
createWorldPresentationContext(overrides: {
|
||||||
world: any;
|
world: any;
|
||||||
activeSystemId?: string;
|
activeSystemId?: string;
|
||||||
|
cameraMode: any;
|
||||||
povLevel: any;
|
povLevel: any;
|
||||||
orbitYaw: number;
|
orbitYaw: number;
|
||||||
systemCamera: THREE.PerspectiveCamera;
|
systemCamera: THREE.PerspectiveCamera;
|
||||||
@@ -214,6 +215,7 @@ export class ViewerSceneDataController {
|
|||||||
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
|
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
|
||||||
worldSeed: this.context.getWorldSeed(),
|
worldSeed: this.context.getWorldSeed(),
|
||||||
activeSystemId: overrides.activeSystemId,
|
activeSystemId: overrides.activeSystemId,
|
||||||
|
cameraMode: overrides.cameraMode,
|
||||||
povLevel: overrides.povLevel,
|
povLevel: overrides.povLevel,
|
||||||
orbitYaw: overrides.orbitYaw,
|
orbitYaw: overrides.orbitYaw,
|
||||||
camera: overrides.systemCamera,
|
camera: overrides.systemCamera,
|
||||||
|
|||||||
@@ -571,19 +571,55 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n
|
|||||||
export function createShipTacticalIcon(documentRef: Document, color: string, size: number): SceneNode {
|
export function createShipTacticalIcon(documentRef: Document, color: string, size: number): SceneNode {
|
||||||
const canvas = documentRef.createElement("canvas");
|
const canvas = documentRef.createElement("canvas");
|
||||||
canvas.width = 128;
|
canvas.width = 128;
|
||||||
canvas.height = 96;
|
canvas.height = 192;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("Unable to create ship tactical icon");
|
throw new Error("Unable to create ship tactical icon");
|
||||||
}
|
}
|
||||||
|
|
||||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.lineCap = "round";
|
||||||
|
context.lineJoin = "round";
|
||||||
context.strokeStyle = color;
|
context.strokeStyle = color;
|
||||||
context.fillStyle = "rgba(7, 16, 30, 0.7)";
|
context.fillStyle = "rgba(7, 16, 30, 0.8)";
|
||||||
context.lineWidth = 5;
|
context.lineWidth = 6;
|
||||||
|
|
||||||
|
context.globalAlpha = 0.92;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(64, 182);
|
||||||
|
context.lineTo(64, 108);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(64, 70, 26, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.arc(34, 48, 18, 0, Math.PI * 2);
|
context.moveTo(64, 34);
|
||||||
|
context.lineTo(64, 49);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(64, 91);
|
||||||
|
context.lineTo(64, 106);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(28, 70);
|
||||||
|
context.lineTo(43, 70);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(85, 70);
|
||||||
|
context.lineTo(100, 70);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(44, 116);
|
||||||
|
context.lineTo(64, 136);
|
||||||
|
context.lineTo(84, 116);
|
||||||
context.stroke();
|
context.stroke();
|
||||||
|
|
||||||
const texture = new THREE.CanvasTexture(canvas);
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
@@ -593,9 +629,10 @@ export function createShipTacticalIcon(documentRef: Document, color: string, siz
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
depthTest: false,
|
depthTest: false,
|
||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
|
fog: false,
|
||||||
}));
|
}));
|
||||||
sprite.center.set(0.28, 0.5);
|
sprite.center.set(0.5, 0.08);
|
||||||
sprite.scale.set(size * 1.7, size * 1.275, 1);
|
sprite.scale.set(size * 1.2, size * 1.8, 1);
|
||||||
sprite.visible = false;
|
sprite.visible = false;
|
||||||
return createSceneNode(sprite);
|
return createSceneNode(sprite);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,14 +41,18 @@ export function describeNetworkPanel(networkStats: NetworkStats) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function summarizeNetworkStats(networkStats: NetworkStats): string {
|
export function summarizeNetworkStats(networkStats: NetworkStats): string {
|
||||||
|
const kbPerSecond = estimateDownlinkKbPerSecond(networkStats);
|
||||||
|
const direction = networkStats.streamConnected ? "live" : "offline";
|
||||||
|
return `${direction} | down ${kbPerSecond.toFixed(1)} KB/s | ${networkStats.deltasReceived} d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateDownlinkKbPerSecond(networkStats: NetworkStats): number {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const recentBytes = networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0);
|
const recentBytes = networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0);
|
||||||
const recentWindowSeconds = networkStats.throughputSamples.length > 1
|
const recentWindowSeconds = networkStats.throughputSamples.length > 1
|
||||||
? Math.max((now - networkStats.throughputSamples[0].atMs) / 1000, 1)
|
? Math.max((now - networkStats.throughputSamples[0].atMs) / 1000, 1)
|
||||||
: 1;
|
: 1;
|
||||||
const kbPerSecond = recentBytes / 1024 / recentWindowSeconds;
|
return recentBytes / 1024 / recentWindowSeconds;
|
||||||
const direction = networkStats.streamConnected ? "live" : "offline";
|
|
||||||
return `${direction} | down ${kbPerSecond.toFixed(1)} KB/s | ${networkStats.deltasReceived} d`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) {
|
export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) {
|
||||||
@@ -116,12 +120,23 @@ export function describePerformancePanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function summarizePerformanceStats(performanceStats: PerformanceStats): string {
|
export function summarizePerformanceStats(performanceStats: PerformanceStats): string {
|
||||||
|
const fps = estimateFps(performanceStats);
|
||||||
|
return `FPS ${fps.toFixed(1)} | ${performanceStats.lastFrameMs.toFixed(1)} ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateFps(performanceStats: PerformanceStats): number {
|
||||||
const samples = performanceStats.frameSamples;
|
const samples = performanceStats.frameSamples;
|
||||||
const elapsedWindowSeconds = samples.length > 1
|
const elapsedWindowSeconds = samples.length > 1
|
||||||
? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25)
|
? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25)
|
||||||
: 1;
|
: 1;
|
||||||
const fps = samples.length > 1
|
return samples.length > 1
|
||||||
? (samples.length - 1) / elapsedWindowSeconds
|
? (samples.length - 1) / elapsedWindowSeconds
|
||||||
: 0;
|
: 0;
|
||||||
return `FPS ${fps.toFixed(1)} | ${performanceStats.lastFrameMs.toFixed(1)} ms`;
|
}
|
||||||
|
|
||||||
|
export function describeCompactStatsLine(networkStats: NetworkStats, performanceStats: PerformanceStats): string {
|
||||||
|
const onlineLabel = networkStats.streamConnected ? "Online" : "Offline";
|
||||||
|
const fps = estimateFps(performanceStats);
|
||||||
|
const down = estimateDownlinkKbPerSecond(networkStats);
|
||||||
|
return `${onlineLabel} | FPS ${fps.toFixed(1)} | Down ${down.toFixed(1)} KB/s`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type {
|
|||||||
|
|
||||||
export type PovLevel = "local" | "system" | "galaxy";
|
export type PovLevel = "local" | "system" | "galaxy";
|
||||||
export type SelectionGroup = "ships" | "structures" | "celestials";
|
export type SelectionGroup = "ships" | "structures" | "celestials";
|
||||||
export type DragMode = "orbit" | "marquee";
|
export type DragMode = "pan" | "pinch";
|
||||||
export type CameraMode = "tactical" | "follow";
|
export type CameraMode = "tactical" | "follow";
|
||||||
|
|
||||||
export type Selectable =
|
export type Selectable =
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ export class ViewerWorldLifecycle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applySnapshot(snapshot: WorldSnapshot) {
|
applySnapshot(snapshot: WorldSnapshot) {
|
||||||
usePlayerFactionStore(viewerPinia).setPlayerFaction(null);
|
|
||||||
this.context.setWorldTimeSyncMs(performance.now());
|
this.context.setWorldTimeSyncMs(performance.now());
|
||||||
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
|
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
|
||||||
if (signature !== this.context.getWorldSignature()) {
|
if (signature !== this.context.getWorldSignature()) {
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import {
|
|||||||
updateSystemStarPresentation,
|
updateSystemStarPresentation,
|
||||||
getAnimatedShipLocalPosition,
|
getAnimatedShipLocalPosition,
|
||||||
iconWorldScale,
|
iconWorldScale,
|
||||||
MIN_ICON_PIXELS,
|
|
||||||
MAX_ICON_PIXELS,
|
|
||||||
} from "./viewerPresentation";
|
} from "./viewerPresentation";
|
||||||
import { rawObject } from "./viewerScenePrimitives";
|
import { rawObject } from "./viewerScenePrimitives";
|
||||||
import type {
|
import type {
|
||||||
@@ -42,6 +40,13 @@ import type {
|
|||||||
|
|
||||||
type SummaryIconKind = "ship" | "station" | "structure";
|
type SummaryIconKind = "ship" | "station" | "structure";
|
||||||
|
|
||||||
|
const SHIP_BILLBOARD_HIDE_DISTANCE = 0.003;
|
||||||
|
const SHIP_BILLBOARD_FULL_DISTANCE = 0.018;
|
||||||
|
const SHIP_BILLBOARD_MIN_PIXELS = 34;
|
||||||
|
const SHIP_BILLBOARD_MAX_PIXELS = 82;
|
||||||
|
const STATION_ICON_MIN_PIXELS = 28;
|
||||||
|
const STATION_ICON_MAX_PIXELS = 72;
|
||||||
|
|
||||||
export interface WorldOrbitalContext {
|
export interface WorldOrbitalContext {
|
||||||
world?: WorldState;
|
world?: WorldState;
|
||||||
worldTimeSyncMs: number;
|
worldTimeSyncMs: number;
|
||||||
@@ -53,6 +58,7 @@ export interface WorldOrbitalContext {
|
|||||||
|
|
||||||
export interface WorldPresentationContext extends WorldOrbitalContext {
|
export interface WorldPresentationContext extends WorldOrbitalContext {
|
||||||
activeSystemId?: string;
|
activeSystemId?: string;
|
||||||
|
cameraMode: CameraMode;
|
||||||
povLevel: PovLevel;
|
povLevel: PovLevel;
|
||||||
orbitYaw: number;
|
orbitYaw: number;
|
||||||
camera: THREE.PerspectiveCamera;
|
camera: THREE.PerspectiveCamera;
|
||||||
@@ -95,14 +101,22 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
|
|||||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||||
const shipVisible = isShipVisible(renderMode, context.activeSystemId, ship);
|
const shipVisible = isShipVisible(renderMode, context.activeSystemId, ship);
|
||||||
const distToShip = context.camera.position.distanceTo(displayPosition);
|
const distToShip = context.camera.position.distanceTo(displayPosition);
|
||||||
const useTacticalIcon = renderMode !== "local" || distToShip > 0.012;
|
const billboardOpacity = context.cameraMode === "tactical"
|
||||||
|
? 1
|
||||||
|
: THREE.MathUtils.clamp(
|
||||||
|
(distToShip - SHIP_BILLBOARD_HIDE_DISTANCE) / (SHIP_BILLBOARD_FULL_DISTANCE - SHIP_BILLBOARD_HIDE_DISTANCE),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const useTacticalIcon = context.cameraMode === "tactical" || billboardOpacity > 0.01;
|
||||||
const iconScale = THREE.MathUtils.clamp(
|
const iconScale = THREE.MathUtils.clamp(
|
||||||
visual.iconBaseScale,
|
visual.iconBaseScale,
|
||||||
iconWorldScale(distToShip, context.camera, MIN_ICON_PIXELS),
|
iconWorldScale(distToShip, context.camera, SHIP_BILLBOARD_MIN_PIXELS),
|
||||||
iconWorldScale(distToShip, context.camera, MAX_ICON_PIXELS + 10),
|
iconWorldScale(distToShip, context.camera, SHIP_BILLBOARD_MAX_PIXELS),
|
||||||
);
|
);
|
||||||
visual.icon.setScaleScalar(iconScale);
|
visual.icon.setScaleScalar(iconScale);
|
||||||
visual.mesh.setVisible(shipVisible && !useTacticalIcon);
|
visual.icon.setOpacity(shipVisible ? billboardOpacity : 0);
|
||||||
|
visual.mesh.setVisible(shipVisible && context.cameraMode !== "tactical" && billboardOpacity < 0.98);
|
||||||
visual.icon.setVisible(shipVisible && useTacticalIcon);
|
visual.icon.setVisible(shipVisible && useTacticalIcon);
|
||||||
const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw);
|
const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw);
|
||||||
if (desiredHeading.lengthSq() > 0.01) {
|
if (desiredHeading.lengthSq() > 0.01) {
|
||||||
@@ -135,9 +149,19 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
|
|||||||
|
|
||||||
for (const visual of context.stationVisuals.values()) {
|
for (const visual of context.stationVisuals.values()) {
|
||||||
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds);
|
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds);
|
||||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
|
const displayPosition = context.toDisplayLocalPosition(animatedLocalPosition);
|
||||||
|
visual.mesh.setPosition(displayPosition);
|
||||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||||
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
|
const stationVisible = visual.systemId === context.activeSystemId;
|
||||||
|
const distToStation = context.camera.position.distanceTo(displayPosition);
|
||||||
|
const stationIconScale = THREE.MathUtils.clamp(
|
||||||
|
130,
|
||||||
|
iconWorldScale(distToStation, context.camera, STATION_ICON_MIN_PIXELS),
|
||||||
|
iconWorldScale(distToStation, context.camera, STATION_ICON_MAX_PIXELS),
|
||||||
|
);
|
||||||
|
visual.icon.setScaleScalar(stationIconScale);
|
||||||
|
visual.icon.setVisible(stationVisible);
|
||||||
|
visual.mesh.setVisible(stationVisible && renderMode === "local" && context.cameraMode !== "tactical");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const visual of context.claimVisuals.values()) {
|
for (const visual of context.claimVisuals.values()) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
|||||||
port: 5174,
|
port: 5174,
|
||||||
allowedHosts: ["sobina.local"],
|
allowedHosts: ["sobina.local"],
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://127.0.0.1:5079",
|
"/api": "http://127.0.0.1:5080",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
This document defines the intended combat model for the simulation.
|
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
|
## Design Goals
|
||||||
|
|
||||||
The combat model should support:
|
The combat model should support:
|
||||||
|
|
||||||
- local-space tactical fights
|
- localspace tactical fights
|
||||||
- piracy and harassment
|
- piracy and harassment
|
||||||
- claim destruction and station contestation
|
- claim destruction and station contestation
|
||||||
- station defense
|
- station defense
|
||||||
@@ -17,7 +17,7 @@ The combat model should support:
|
|||||||
|
|
||||||
## Core Principles
|
## Core Principles
|
||||||
|
|
||||||
- combat happens in `local-space`
|
- combat happens in `localspace`
|
||||||
- claims and structures are physically contestable
|
- claims and structures are physically contestable
|
||||||
- piracy should target valuable traffic and vulnerable infrastructure
|
- piracy should target valuable traffic and vulnerable infrastructure
|
||||||
- stations should be defensible but not magically safe
|
- stations should be defensible but not magically safe
|
||||||
@@ -25,7 +25,7 @@ The combat model should support:
|
|||||||
|
|
||||||
## Combat Space
|
## Combat Space
|
||||||
|
|
||||||
Combat belongs in `local-space`.
|
Combat belongs in `localspace`.
|
||||||
|
|
||||||
This is where entities can:
|
This is where entities can:
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ This is where entities can:
|
|||||||
- defend stations and claims
|
- defend stations and claims
|
||||||
- intercept miners, haulers, and construction support
|
- 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.
|
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 As Combat Targets
|
||||||
|
|
||||||
Claims at Lagrange points should be valid combat targets.
|
Claims at valid construction anchors should be valid combat targets.
|
||||||
|
|
||||||
That means:
|
That means:
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ Station safety should depend on actual defensive capacity, not only ownership fl
|
|||||||
|
|
||||||
## Piracy
|
## Piracy
|
||||||
|
|
||||||
Pirates should be a meaningful local-space threat.
|
Pirates should be a meaningful localspace threat.
|
||||||
|
|
||||||
They should favor:
|
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:
|
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
|
- claims are destructible and contestable
|
||||||
- station construction is a vulnerable phase
|
- station construction is a vulnerable phase
|
||||||
- piracy should prefer valuable and vulnerable traffic
|
- piracy should prefer valuable and vulnerable traffic
|
||||||
@@ -213,7 +213,7 @@ The following rules should remain true unless deliberately revised:
|
|||||||
|
|
||||||
## Relationship To Other Documents
|
## 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
|
- defines where combat is allowed
|
||||||
|
|
||||||
- [POLICIES.md](/home/jbourdon/repos/space-game/docs/POLICIES.md)
|
- [POLICIES.md](/home/jbourdon/repos/space-game/docs/POLICIES.md)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Additional specialized commanders may exist later, such as:
|
|||||||
- fleet commander
|
- fleet commander
|
||||||
- task-group commander
|
- task-group commander
|
||||||
- convoy commander
|
- convoy commander
|
||||||
- sector commander
|
- localspace defense commander
|
||||||
|
|
||||||
## Commander Entity Model
|
## Commander Entity Model
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ Responsibilities:
|
|||||||
- create or retire station and fleet objectives
|
- create or retire station and fleet objectives
|
||||||
- respond to large-scale shortages, threats, and opportunities
|
- 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.
|
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
|
## 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
|
- defines where action happens
|
||||||
|
|
||||||
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ The data model should support:
|
|||||||
- module-based capability
|
- module-based capability
|
||||||
- population and workforce
|
- population and workforce
|
||||||
- claimable construction sites
|
- claimable construction sites
|
||||||
- local-space combat
|
- localspace combat
|
||||||
- scalable replication and future sharding
|
- scalable replication and future sharding
|
||||||
|
|
||||||
## Core Principles
|
## Core Principles
|
||||||
@@ -39,8 +39,8 @@ Recommended global ID families:
|
|||||||
- `factionId`
|
- `factionId`
|
||||||
- `commanderId`
|
- `commanderId`
|
||||||
- `systemId`
|
- `systemId`
|
||||||
- `nodeId`
|
- `anchorId`
|
||||||
- `bubbleId`
|
- `localspaceId`
|
||||||
- `stationId`
|
- `stationId`
|
||||||
- `shipId`
|
- `shipId`
|
||||||
- `moduleId`
|
- `moduleId`
|
||||||
@@ -58,8 +58,8 @@ The intended core entities are:
|
|||||||
1. `Faction`
|
1. `Faction`
|
||||||
2. `Commander`
|
2. `Commander`
|
||||||
3. `System`
|
3. `System`
|
||||||
4. `Node`
|
4. `Anchor`
|
||||||
5. `LocalBubble`
|
5. `Localspace`
|
||||||
6. `Station`
|
6. `Station`
|
||||||
7. `Ship`
|
7. `Ship`
|
||||||
8. `ModuleInstance`
|
8. `ModuleInstance`
|
||||||
@@ -108,7 +108,6 @@ Recommended commander kinds:
|
|||||||
- `station`
|
- `station`
|
||||||
- `ship`
|
- `ship`
|
||||||
- `fleet`
|
- `fleet`
|
||||||
- `sector`
|
|
||||||
- `task-group`
|
- `task-group`
|
||||||
|
|
||||||
## System
|
## System
|
||||||
@@ -124,40 +123,37 @@ Suggested fields:
|
|||||||
- `nodeIds`
|
- `nodeIds`
|
||||||
- `faction influence later`
|
- `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:
|
Suggested fields:
|
||||||
|
|
||||||
- `nodeId`
|
- `anchorId`
|
||||||
- `systemId`
|
- `systemId`
|
||||||
- `kind`
|
- `kind`
|
||||||
- `systemPosition`
|
- `systemPosition`
|
||||||
- `bubbleId`
|
- `localspaceId`
|
||||||
- `parentNodeId?`
|
- `parentAnchorId?`
|
||||||
- `orbital metadata?`
|
- `orbital metadata?`
|
||||||
- `occupyingStructureId?`
|
- `constructionIds?`
|
||||||
|
|
||||||
Recommended node kinds:
|
Recommended anchor kinds:
|
||||||
|
|
||||||
- `star`
|
- `star`
|
||||||
- `planet`
|
- `planet`
|
||||||
- `moon`
|
- `moon`
|
||||||
- `lagrange-point`
|
- `lagrange-point`
|
||||||
- `station`
|
- `resource-node`
|
||||||
- `gate`
|
|
||||||
- `resource-site`
|
|
||||||
- `structure`
|
|
||||||
|
|
||||||
## 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:
|
Suggested fields:
|
||||||
|
|
||||||
- `bubbleId`
|
- `localspaceId`
|
||||||
- `nodeId`
|
- `anchorId`
|
||||||
- `systemId`
|
- `systemId`
|
||||||
- `radius`
|
- `radius`
|
||||||
- `occupantShipIds`
|
- `occupantShipIds`
|
||||||
@@ -168,16 +164,16 @@ Suggested fields:
|
|||||||
|
|
||||||
## Station
|
## 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:
|
Suggested fields:
|
||||||
|
|
||||||
- `stationId`
|
- `stationId`
|
||||||
- `ownerFactionId`
|
- `ownerFactionId`
|
||||||
- `commanderId?`
|
- `commanderId?`
|
||||||
- `nodeId`
|
- `anchorId`
|
||||||
- `systemId`
|
- `systemId`
|
||||||
- `bubbleId`
|
- `localspaceId`
|
||||||
- `moduleIds`
|
- `moduleIds`
|
||||||
- `inventory`
|
- `inventory`
|
||||||
- `population`
|
- `population`
|
||||||
@@ -231,7 +227,7 @@ Recommended host kinds:
|
|||||||
|
|
||||||
## Claim
|
## 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:
|
Suggested fields:
|
||||||
|
|
||||||
@@ -239,8 +235,8 @@ Suggested fields:
|
|||||||
- `ownerFactionId`
|
- `ownerFactionId`
|
||||||
- `commanderId?`
|
- `commanderId?`
|
||||||
- `systemId`
|
- `systemId`
|
||||||
- `nodeId`
|
- `anchorId`
|
||||||
- `bubbleId`
|
- `localspaceId`
|
||||||
- `placedAt`
|
- `placedAt`
|
||||||
- `activatesAt`
|
- `activatesAt`
|
||||||
- `state`
|
- `state`
|
||||||
@@ -261,8 +257,8 @@ Suggested fields:
|
|||||||
|
|
||||||
- `constructionSiteId`
|
- `constructionSiteId`
|
||||||
- `ownerFactionId`
|
- `ownerFactionId`
|
||||||
- `nodeId`
|
- `anchorId`
|
||||||
- `bubbleId`
|
- `localspaceId`
|
||||||
- `targetKind`
|
- `targetKind`
|
||||||
- `targetDefinitionId`
|
- `targetDefinitionId`
|
||||||
- `requiredItems`
|
- `requiredItems`
|
||||||
@@ -354,12 +350,12 @@ Recommended ship spatial state fields:
|
|||||||
|
|
||||||
- `spaceLayer`
|
- `spaceLayer`
|
||||||
- `currentSystemId`
|
- `currentSystemId`
|
||||||
- `currentNodeId?`
|
- `currentAnchorId?`
|
||||||
- `currentBubbleId?`
|
- `currentLocalspaceId?`
|
||||||
- `localPosition?`
|
- `localPosition?`
|
||||||
- `systemPosition?`
|
- `systemPosition?`
|
||||||
- `movementRegime`
|
- `movementRegime`
|
||||||
- `destinationNodeId?`
|
- `destinationAnchorId?`
|
||||||
- `transitState?`
|
- `transitState?`
|
||||||
|
|
||||||
Recommended space layers:
|
Recommended space layers:
|
||||||
@@ -367,7 +363,7 @@ Recommended space layers:
|
|||||||
- `universe-space`
|
- `universe-space`
|
||||||
- `galaxy-space`
|
- `galaxy-space`
|
||||||
- `system-space`
|
- `system-space`
|
||||||
- `local-space`
|
- `localspace`
|
||||||
|
|
||||||
Recommended movement regimes:
|
Recommended movement regimes:
|
||||||
|
|
||||||
@@ -466,7 +462,7 @@ The following rules should remain true unless deliberately revised:
|
|||||||
|
|
||||||
## Relationship To Other Documents
|
## 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
|
- defines the layered world structure
|
||||||
|
|
||||||
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
|
- [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
|
## Core Documents
|
||||||
|
|
||||||
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
|
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
|
||||||
- spatial layers
|
- canonical world structure
|
||||||
- nodes and local bubbles
|
- galaxy, systems, celestials, anchors, and localspaces
|
||||||
- movement regimes
|
- ship and construction placement
|
||||||
- viewer scale expectations
|
- intra-system warp and inter-system FTL
|
||||||
- replication and interest management implications
|
- viewer hierarchy and simulation boundaries
|
||||||
|
|
||||||
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
|
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
|
||||||
- commander roles for factions, stations, and ships
|
- 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
|
- policy-based behavior limits
|
||||||
|
|
||||||
- [COMBAT.md](/home/jbourdon/repos/space-game/docs/COMBAT.md)
|
- [COMBAT.md](/home/jbourdon/repos/space-game/docs/COMBAT.md)
|
||||||
- local-space combat
|
- localspace combat
|
||||||
- piracy and station defense
|
- piracy and station defense
|
||||||
- claim destruction and contest
|
- claim destruction and contest
|
||||||
- commander-driven engagement behavior
|
- 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)
|
- [STATIONS.md](/home/jbourdon/repos/space-game/docs/STATIONS.md)
|
||||||
- station roles
|
- station roles
|
||||||
- local bubble functions
|
- localspace functions
|
||||||
- station command requirements
|
- station command requirements
|
||||||
- station services, docking, and market responsibilities
|
- 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
|
## 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:
|
Implications:
|
||||||
|
|
||||||
- stations are nodes with local bubbles
|
- stations live in localspaces owned by anchors
|
||||||
- docking and transfer happen in local-space
|
- docking and transfer happen in localspace
|
||||||
- in-system logistics move through warp between nodes
|
- in-system logistics move through warp between anchors
|
||||||
- inter-system trade moves through stargates or FTL
|
- inter-system trade moves through stargates or FTL
|
||||||
|
|
||||||
Distance and travel friction should matter economically.
|
Distance and travel friction should matter economically.
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ Every event should conceptually have:
|
|||||||
- `kind`
|
- `kind`
|
||||||
- `spaceLayer`
|
- `spaceLayer`
|
||||||
- `systemId?`
|
- `systemId?`
|
||||||
- `bubbleId?`
|
- `localspaceId?`
|
||||||
|
- `anchorId?`
|
||||||
- `primaryEntityKind`
|
- `primaryEntityKind`
|
||||||
- `primaryEntityId`
|
- `primaryEntityId`
|
||||||
- `relatedEntityIds`
|
- `relatedEntityIds`
|
||||||
@@ -51,7 +52,7 @@ Examples:
|
|||||||
|
|
||||||
- universe-scale events belong to `universe-space`
|
- universe-scale events belong to `universe-space`
|
||||||
- strategic system events belong to `galaxy-space` or `system-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.
|
This is important for interest management.
|
||||||
|
|
||||||
@@ -95,8 +96,8 @@ These describe meaningful transitions in movement regime or location context.
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
- `entered-local-bubble`
|
- `entered-localspace`
|
||||||
- `left-local-bubble`
|
- `left-localspace`
|
||||||
- `warp-spooling-started`
|
- `warp-spooling-started`
|
||||||
- `warp-started`
|
- `warp-started`
|
||||||
- `warp-arrived`
|
- `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)
|
- [DATA-MODEL.md](/home/jbourdon/repos/space-game/docs/DATA-MODEL.md)
|
||||||
- defines the entities referenced by events
|
- 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
|
- defines event scope by space layer
|
||||||
|
|
||||||
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
- [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
|
## 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.
|
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.
|
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.
|
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
|
- friendly factions may trade and dock
|
||||||
- neutral factions may trade but not build
|
- 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.
|
The exact diplomacy system can evolve later, but policy should be ready to consume those relationship states.
|
||||||
|
|
||||||
## Policy And Claims
|
## Policy And Claims
|
||||||
|
|
||||||
Claim objects at Lagrange points are visible and contestable.
|
Claim objects at valid construction anchors are visible and contestable.
|
||||||
|
|
||||||
Policy influences:
|
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)
|
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
||||||
- policy constrains trade participation
|
- 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
|
- policy interacts with claims, systems, and local access
|
||||||
|
|
||||||
- [STATIONS.md](/home/jbourdon/repos/space-game/docs/STATIONS.md)
|
- [STATIONS.md](/home/jbourdon/repos/space-game/docs/STATIONS.md)
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ It should:
|
|||||||
- allow delivery by traders or support ships
|
- allow delivery by traders or support ships
|
||||||
- feed the actual building process
|
- 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
|
## 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:
|
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)
|
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
|
||||||
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
||||||
- [WORKFORCE.md](/home/jbourdon/repos/space-game/docs/WORKFORCE.md)
|
- [WORKFORCE.md](/home/jbourdon/repos/space-game/docs/WORKFORCE.md)
|
||||||
@@ -53,11 +53,11 @@ Target design:
|
|||||||
- `universe-space`
|
- `universe-space`
|
||||||
- `galaxy-space`
|
- `galaxy-space`
|
||||||
- `system-space`
|
- `system-space`
|
||||||
- `local-space`
|
- `localspace`
|
||||||
- node-attached local bubbles
|
- anchor-owned localspaces
|
||||||
- one structure per Lagrange point
|
- construction only at valid construction anchors
|
||||||
- warp between nodes
|
- warp between anchors within one system
|
||||||
- local gameplay inside bubbles
|
- local gameplay inside localspaces
|
||||||
|
|
||||||
Current state:
|
Current state:
|
||||||
|
|
||||||
@@ -66,16 +66,16 @@ Current state:
|
|||||||
- resource nodes exist, but only as extractable asteroid/gas sites
|
- resource nodes exist, but only as extractable asteroid/gas sites
|
||||||
- stations are positioned directly in system coordinates
|
- stations are positioned directly in system coordinates
|
||||||
- ships move by `Position` and `TargetPosition`
|
- ships move by `Position` and `TargetPosition`
|
||||||
- no first-class system node graph
|
- no first-class anchor graph
|
||||||
- no first-class local bubbles
|
- no first-class localspaces
|
||||||
- no claim entities
|
- no claim entities
|
||||||
- no construction-site entities
|
- no construction-site entities
|
||||||
|
|
||||||
Primary gaps:
|
Primary gaps:
|
||||||
|
|
||||||
- [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs) has no `NodeRuntime`, `LocalBubbleRuntime`, `ClaimRuntime`, or `ConstructionSiteRuntime`.
|
- [`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 node-backed placement.
|
- [`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 node-to-node transit between spaces.
|
- [`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
|
### Command and Control
|
||||||
|
|
||||||
@@ -202,12 +202,12 @@ Current state:
|
|||||||
|
|
||||||
- viewer consumes a flat snapshot of systems, resource nodes, stations, ships, and factions
|
- 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
|
- 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:
|
Primary gaps:
|
||||||
|
|
||||||
- [`contracts.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/contracts.ts) mirrors the old backend model.
|
- [`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
|
## Subsystem Assessment
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ These should evolve in place.
|
|||||||
|
|
||||||
- string-based ship state with explicit enums and structured movement state
|
- string-based ship state with explicit enums and structured movement state
|
||||||
- ship-only planning fields with commander/task-layer entities
|
- 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
|
- flat event records with typed event payloads
|
||||||
|
|
||||||
### Avoid
|
### Avoid
|
||||||
@@ -246,8 +246,8 @@ Goal:
|
|||||||
Work:
|
Work:
|
||||||
|
|
||||||
- extend [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs) with:
|
- extend [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs) with:
|
||||||
- `NodeRuntime`
|
- `AnchorRuntime`
|
||||||
- `LocalBubbleRuntime`
|
- `LocalspaceRuntime`
|
||||||
- `CommanderRuntime`
|
- `CommanderRuntime`
|
||||||
- `ClaimRuntime`
|
- `ClaimRuntime`
|
||||||
- `ConstructionSiteRuntime`
|
- `ConstructionSiteRuntime`
|
||||||
@@ -261,33 +261,33 @@ Work:
|
|||||||
- commander kinds
|
- commander kinds
|
||||||
- add structured ship spatial state:
|
- add structured ship spatial state:
|
||||||
- current space layer
|
- current space layer
|
||||||
- current node
|
- current anchor
|
||||||
- current bubble
|
- current localspace
|
||||||
- current transit
|
- current transit
|
||||||
|
|
||||||
Why first:
|
Why first:
|
||||||
|
|
||||||
- every later change depends on this vocabulary existing in runtime state
|
- 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:
|
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:
|
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:
|
- 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
|
- generate system-space anchors for stars, planets, moons, Lagrange points, and resource nodes where appropriate
|
||||||
- attach local bubbles to nodes
|
- attach localspaces to anchors
|
||||||
- translate existing `planetIndex` and `lagrangeSide` station hints into actual node IDs
|
- translate existing `planetIndex` and `lagrangeSide` station hints into actual anchor IDs
|
||||||
- stop treating station placement as an arbitrary coordinate
|
- stop treating station placement as an arbitrary coordinate
|
||||||
- preserve current authored content while migrating scenario interpretation
|
- preserve current authored content while migrating scenario interpretation
|
||||||
|
|
||||||
Why second:
|
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
|
### Phase 3: Introduce Founding, Claims, and Construction Sites
|
||||||
|
|
||||||
@@ -303,7 +303,7 @@ Work:
|
|||||||
- active
|
- active
|
||||||
- destroyed
|
- destroyed
|
||||||
- add construction-site runtime state with:
|
- add construction-site runtime state with:
|
||||||
- target node
|
- target anchor
|
||||||
- blueprint or constructible reference
|
- blueprint or constructible reference
|
||||||
- construction storage inventory
|
- construction storage inventory
|
||||||
- construction buy orders
|
- construction buy orders
|
||||||
@@ -315,7 +315,7 @@ Work:
|
|||||||
|
|
||||||
Why here:
|
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
|
### Phase 4: Replace Raw Travel With Space-Aware Movement
|
||||||
|
|
||||||
@@ -326,8 +326,8 @@ Goal:
|
|||||||
Work:
|
Work:
|
||||||
|
|
||||||
- replace direct long-range movement with:
|
- replace direct long-range movement with:
|
||||||
- local thruster movement inside a bubble
|
- local thruster movement inside a localspace
|
||||||
- in-system warp between nodes
|
- in-system warp between anchors
|
||||||
- inter-system transit through gates or FTL
|
- inter-system transit through gates or FTL
|
||||||
- update ship runtime in [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs)
|
- 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
|
- 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:
|
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
|
### Phase 5: Introduce Commanders and Task Layers
|
||||||
|
|
||||||
@@ -376,7 +376,7 @@ Work:
|
|||||||
|
|
||||||
Why here:
|
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
|
### Phase 7: Upgrade Events and Streaming
|
||||||
|
|
||||||
@@ -391,7 +391,7 @@ Work:
|
|||||||
- universe
|
- universe
|
||||||
- galaxy
|
- galaxy
|
||||||
- system
|
- 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
|
- 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
|
- support higher-space streaming with filtering into lower-space views
|
||||||
|
|
||||||
@@ -408,8 +408,8 @@ Goal:
|
|||||||
Work:
|
Work:
|
||||||
|
|
||||||
- extend [`contracts.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/contracts.ts) for:
|
- extend [`contracts.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/contracts.ts) for:
|
||||||
- nodes
|
- anchors
|
||||||
- local bubbles
|
- localspaces
|
||||||
- claims
|
- claims
|
||||||
- construction sites
|
- construction sites
|
||||||
- market orders
|
- market orders
|
||||||
@@ -417,8 +417,8 @@ Work:
|
|||||||
- richer ship movement state
|
- richer ship movement state
|
||||||
- update [`GameViewer.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts) to support:
|
- update [`GameViewer.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts) to support:
|
||||||
- galaxy/system/local scale transitions
|
- galaxy/system/local scale transitions
|
||||||
- node-centric system view
|
- anchor-centric system view
|
||||||
- local bubble detail
|
- localspace detail
|
||||||
- regime-aware ship rendering
|
- regime-aware ship rendering
|
||||||
|
|
||||||
Why last:
|
Why last:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This document defines the intended role of ships in the simulation.
|
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
|
## 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:
|
Ships should move according to the layered spatial model:
|
||||||
|
|
||||||
- thrusters in `local-space`
|
- thrusters inside a `localspace`
|
||||||
- warp in `system-space`
|
- warp between anchors inside one system
|
||||||
- stargate or FTL for inter-system travel
|
- stargate or FTL for inter-system travel
|
||||||
|
|
||||||
Ships should not behave as if an entire system is one continuous local dogfighting field.
|
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
|
## 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)
|
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
|
||||||
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
||||||
- [STATIONS.md](/home/jbourdon/repos/space-game/docs/STATIONS.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.
|
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
|
## Core Principles
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ A station lives at one location.
|
|||||||
|
|
||||||
It does not spread across multiple construction points.
|
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:
|
To create another station, a faction needs:
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ To create another station, a faction needs:
|
|||||||
- another station
|
- another station
|
||||||
- another station commander
|
- 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).
|
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
|
## 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
|
- docking happens
|
||||||
- cargo transfer happens
|
- cargo transfer happens
|
||||||
@@ -58,13 +60,13 @@ Their local bubbles are where:
|
|||||||
- local construction happens
|
- local construction happens
|
||||||
- player and AI interaction 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
|
## Founding A Station
|
||||||
|
|
||||||
The intended founding flow is:
|
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
|
2. wait for the claim to activate
|
||||||
3. create station construction storage
|
3. create station construction storage
|
||||||
4. publish buy orders for the required construction materials
|
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.
|
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
|
Friendly or otherwise permitted factions may build stations within the same system so long as they use different valid construction locations.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Services
|
## Services
|
||||||
|
|
||||||
@@ -181,7 +177,7 @@ Population growth and decline follow the rules in [WORKFORCE.md](/home/jbourdon/
|
|||||||
|
|
||||||
## Relationship To Other Documents
|
## 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)
|
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
|
||||||
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
|
||||||
- [SHIPS.md](/home/jbourdon/repos/space-game/docs/SHIPS.md)
|
- [SHIPS.md](/home/jbourdon/repos/space-game/docs/SHIPS.md)
|
||||||
|
|||||||
@@ -55,12 +55,12 @@ Orders should survive replanning better than tasks.
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
- travel to node
|
- travel to anchor
|
||||||
- dock at station
|
- dock at station
|
||||||
- claim Lagrange point
|
- claim construction anchor
|
||||||
- build station here
|
- build station here
|
||||||
- escort this ship
|
- escort this ship
|
||||||
- defend this bubble
|
- defend this localspace
|
||||||
|
|
||||||
Orders are the main override layer above routine autonomous behavior.
|
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
|
## 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:
|
That means:
|
||||||
|
|
||||||
- local maneuvering is distinct from warp
|
- local maneuvering is distinct from warp
|
||||||
- docking is local-space work
|
- docking is localspace work
|
||||||
- cargo transfer is local-space work
|
- cargo transfer is localspace work
|
||||||
- inter-system travel is distinct from in-system travel
|
- inter-system travel is distinct from in-system travel
|
||||||
|
|
||||||
## Policy-Aware Tasking
|
## 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)
|
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
|
||||||
- defines who decides and delegates
|
- 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
|
- defines where tasks can occur
|
||||||
|
|
||||||
- [POLICIES.md](/home/jbourdon/repos/space-game/docs/POLICIES.md)
|
- [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