Compare commits

...

4 Commits

69 changed files with 3330 additions and 1367 deletions

2
.gitignore vendored
View File

@@ -17,3 +17,5 @@ pnpm-debug.log*
.env
.env.*
!.env.example
.codex

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
using FastEndpoints;
using SpaceGame.Api.Auth.Runtime;
using SpaceGame.Api.Auth.Simulation;
using SpaceGame.Api.PlayerFaction.Simulation;
namespace SpaceGame.Api.PlayerFaction.Api;
public sealed class GetPlayerIdentitiesHandler(IAuthRepository authRepository, IPlayerStateStore playerStateStore)
: EndpointWithoutRequest<IReadOnlyList<PlayerIdentitySummaryResponse>>
{
public override void Configure()
{
Get("/api/player-faction/identities");
Policies(AuthPolicyNames.GmAccess);
}
public override async Task HandleAsync(CancellationToken cancellationToken)
{
var users = await authRepository.ListUsersAsync(cancellationToken);
var playerFactionsById = playerStateStore.GetPlayerFactions()
.ToDictionary(player => player.Id, StringComparer.Ordinal);
var responses = new List<PlayerIdentitySummaryResponse>(users.Count + playerFactionsById.Count);
var seenIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var user in users)
{
var userId = user.Id.ToString("N");
playerFactionsById.TryGetValue(userId, out var playerFaction);
responses.Add(new PlayerIdentitySummaryResponse(
userId,
user.Email,
user.Roles,
playerFaction is not null,
playerFaction?.Id,
playerFaction?.Label,
playerFaction?.SovereignFactionId));
seenIds.Add(userId);
}
foreach (var playerFaction in playerStateStore.GetPlayerFactions())
{
if (!seenIds.Add(playerFaction.Id))
{
continue;
}
responses.Add(new PlayerIdentitySummaryResponse(
playerFaction.Id,
$"{playerFaction.Id}@unknown",
Array.Empty<string>(),
true,
playerFaction.Id,
playerFaction.Label,
playerFaction.SovereignFactionId));
}
await SendOkAsync(
responses
.OrderBy(response => response.Email, StringComparer.OrdinalIgnoreCase)
.ThenBy(response => response.UserId, StringComparer.Ordinal)
.ToList(),
cancellationToken);
}
}
public sealed record PlayerIdentitySummaryResponse(
string UserId,
string Email,
IReadOnlyList<string> Roles,
bool HasPlayerFaction,
string? PlayerFactionId,
string? PlayerFactionLabel,
string? SovereignFactionId);

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,14 +20,12 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
{
var sovereignFaction = world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).FirstOrDefault()
?? throw new InvalidOperationException("Cannot create a player faction domain without any factions in the world.");
var player = playerStateStore.GetOrAddPlayerFaction(playerId, () => new PlayerFactionRuntime
{
Id = PlayerFactionDomainId,
Label = $"{sovereignFaction.Label} Command",
SovereignFactionId = sovereignFaction.Id,
Label = "Pending Pilot",
SovereignFactionId = string.Empty,
RequiresOnboarding = true,
CreatedAtUtc = world.GeneratedAtUtc,
UpdatedAtUtc = world.GeneratedAtUtc,
});
@@ -37,6 +35,58 @@ internal sealed class PlayerFactionService
return player;
}
internal PlayerFactionRuntime CompleteOnboarding(
SimulationWorld world,
IPlayerStateStore playerStateStore,
string playerId,
CompletePlayerOnboardingRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
if (!player.RequiresOnboarding)
{
throw new InvalidOperationException("Player onboarding has already been completed.");
}
var personaName = request.Name.Trim();
if (personaName.Length < 2)
{
throw new InvalidOperationException("Player name must contain at least 2 characters.");
}
if (personaName.Length > 48)
{
throw new InvalidOperationException("Player name must contain at most 48 characters.");
}
var ownedFactionId = BuildOwnedFactionId(playerId);
if (world.Factions.Any(faction => string.Equals(faction.Id, ownedFactionId, StringComparison.Ordinal)))
{
throw new InvalidOperationException($"Player faction '{ownedFactionId}' already exists in the current world.");
}
player.Label = personaName;
player.PersonaName = personaName;
player.RaceId = request.RaceId.Trim();
player.SovereignFactionId = ownedFactionId;
player.RequiresOnboarding = false;
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
return player;
}
internal PlayerFactionRuntime EnsureInitializedDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
{
var player = EnsureDomain(world, playerStateStore, playerId);
if (player.RequiresOnboarding || string.IsNullOrWhiteSpace(player.SovereignFactionId))
{
throw new InvalidOperationException("Player onboarding must be completed before issuing gameplay commands.");
}
return player;
}
internal static string BuildOwnedFactionId(string playerId) =>
$"player-{playerId.Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant()}";
internal void Update(SimulationWorld world, IPlayerStateStore playerStateStore, float _deltaSeconds, ICollection<SimulationEventRecord> events)
{
if (playerStateStore.GetPlayerFactions().Count == 0)
@@ -63,7 +113,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerOrganizationCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player));
var nowUtc = DateTimeOffset.UtcNow;
@@ -180,7 +230,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
RemoveOrganization(player, organizationId);
player.Assignments.RemoveAll(assignment =>
assignment.FleetId == organizationId ||
@@ -198,7 +248,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId, PlayerOrganizationMembershipCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var kind = ResolveOrganizationKind(player, organizationId);
switch (kind)
{
@@ -249,7 +299,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? directiveId, PlayerDirectiveCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var directive = directiveId is null
? null
: player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal));
@@ -326,7 +376,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string directiveId)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
player.Directives.RemoveAll(directive => directive.Id == directiveId);
foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId))
{
@@ -340,7 +390,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? policyId, PlayerPolicyCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var policy = policyId is null
? null
: player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal));
@@ -411,7 +461,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var policy = automationPolicyId is null
? null
: player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal));
@@ -469,7 +519,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var policy = reinforcementPolicyId is null
? null
: player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal));
@@ -503,7 +553,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? productionProgramId, PlayerProductionProgramCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var program = productionProgramId is null
? null
: player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal));
@@ -535,7 +585,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string assetId, PlayerAssetAssignmentCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var assignment = player.Assignments.FirstOrDefault(candidate =>
string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) &&
string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal));
@@ -594,7 +644,7 @@ internal sealed class PlayerFactionService
internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerStrategicIntentCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
player.StrategicIntent.StrategicPosture = request.StrategicPosture;
player.StrategicIntent.EconomicPosture = request.EconomicPosture;
player.StrategicIntent.MilitaryPosture = request.MilitaryPosture;
@@ -610,7 +660,7 @@ internal sealed class PlayerFactionService
internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipOrderCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
@@ -669,7 +719,7 @@ internal sealed class PlayerFactionService
internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
@@ -712,7 +762,7 @@ internal sealed class PlayerFactionService
internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipDefaultBehaviorCommandRequest request)
{
var player = EnsureDomain(world, playerStateStore, playerId);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
@@ -852,6 +902,24 @@ internal sealed class PlayerFactionService
private static void SyncRegistry(SimulationWorld world, PlayerFactionRuntime player)
{
if (string.IsNullOrWhiteSpace(player.SovereignFactionId))
{
SyncSet(player.AssetRegistry.ShipIds, []);
SyncSet(player.AssetRegistry.StationIds, []);
SyncSet(player.AssetRegistry.CommanderIds, []);
SyncSet(player.AssetRegistry.ClaimIds, []);
SyncSet(player.AssetRegistry.ConstructionSiteIds, []);
SyncSet(player.AssetRegistry.PolicySetIds, player.Policies.Where(entry => entry.PolicySetId is not null).Select(entry => entry.PolicySetId!));
SyncSet(player.AssetRegistry.MarketOrderIds, []);
SyncSet(player.AssetRegistry.FleetIds, player.Fleets.Select(fleet => fleet.Id));
SyncSet(player.AssetRegistry.TaskForceIds, player.TaskForces.Select(taskForce => taskForce.Id));
SyncSet(player.AssetRegistry.StationGroupIds, player.StationGroups.Select(group => group.Id));
SyncSet(player.AssetRegistry.EconomicRegionIds, player.EconomicRegions.Select(region => region.Id));
SyncSet(player.AssetRegistry.FrontIds, player.Fronts.Select(front => front.Id));
SyncSet(player.AssetRegistry.ReserveIds, player.Reserves.Select(reserve => reserve.Id));
return;
}
SyncSet(player.AssetRegistry.ShipIds, world.Ships.Where(ship => ship.FactionId == player.SovereignFactionId).Select(ship => ship.Id));
SyncSet(player.AssetRegistry.StationIds, world.Stations.Where(station => station.FactionId == player.SovereignFactionId).Select(station => station.Id));
SyncSet(player.AssetRegistry.CommanderIds, world.Commanders.Where(commander => commander.FactionId == player.SovereignFactionId).Select(commander => commander.Id));
@@ -1224,8 +1292,7 @@ internal sealed class PlayerFactionService
return player.AutomationPolicies.FirstOrDefault(policy => policy.Id == automationId);
}
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId)
?? player.AutomationPolicies.FirstOrDefault(policy => policy.Id == "player-core-automation");
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId);
}
private static PlayerFactionPolicyRuntime? ResolvePolicy(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string assetId)

View File

@@ -90,7 +90,8 @@ public sealed class ScenarioContentBuilder(
powerModuleId,
plan.FactionId,
staticData.ModuleDefinitions,
staticData.ItemDefinitions)
staticData.ItemDefinitions,
staticData.Recipes)
.FirstOrDefault(moduleId =>
{
return staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
@@ -117,7 +118,8 @@ public sealed class ScenarioContentBuilder(
objectiveModuleId,
plan.FactionId,
staticData.ModuleDefinitions,
staticData.ItemDefinitions))
staticData.ItemDefinitions,
staticData.Recipes))
{
EnsureStartingModule(startingModules, storageModuleId);
}

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ namespace SpaceGame.Api.Universe.Simulation;
public sealed class WorldService
{
private const int DeltaHistoryLimit = 256;
private const string StarterPlayerShipId = "ship_arg_s_scout_01_a";
private readonly Lock _sync = new();
private readonly OrbitalSimulationSnapshot _orbitalSimulation;
@@ -148,11 +149,6 @@ public sealed class WorldService
{
lock (_sync)
{
if (_world.Factions.Count == 0)
{
return null;
}
var playerKey = GetCurrentPlayerKey();
var player = _playerFaction.TryGetDomain(_playerStateStore, playerKey)
?? _playerFaction.EnsureDomain(_world, _playerStateStore, playerKey);
@@ -160,6 +156,26 @@ public sealed class WorldService
}
}
public PlayerFactionSnapshot? CompletePlayerOnboarding(CompletePlayerOnboardingRequest request)
{
lock (_sync)
{
if (!_staticData.RaceDefinitions.TryGetValue(request.RaceId.Trim(), out var race))
{
throw new InvalidOperationException($"Race '{request.RaceId}' is not defined in static data.");
}
var playerKey = GetCurrentPlayerKey();
var player = _playerFaction.CompleteOnboarding(_world, _playerStateStore, playerKey, request);
var playerFaction = CreatePlayerOwnedFactionUnsafe(player, race);
var starterSystemId = ResolveStarterSystemIdUnsafe();
SpawnPlayerStarterShipUnsafe(playerFaction, starterSystemId);
_playerFaction.EnsureInitializedDomain(_world, _playerStateStore, playerKey);
PublishSnapshotRefreshUnsafe("player-onboarding", $"Initialized player {player.PersonaName}", "faction", playerFaction.Id);
return GetPlayerFactionSnapshotUnsafe();
}
}
public PlayerFactionSnapshot? CreatePlayerOrganization(PlayerOrganizationCommandRequest request)
{
lock (_sync)
@@ -530,7 +546,102 @@ public sealed class WorldService
private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() =>
_playerFactionProjection.ToSnapshot(_playerFaction.TryGetDomain(_playerStateStore, GetCurrentPlayerKey()));
private string GetCurrentPlayerKey() => _playerIdentityResolver.GetRequiredPlayerId().ToString("N");
private FactionRuntime CreatePlayerOwnedFactionUnsafe(PlayerFactionRuntime player, RaceDefinition race)
{
var playerFaction = new FactionRuntime
{
Id = player.SovereignFactionId,
Label = player.PersonaName ?? player.Label,
Color = ResolvePlayerFactionColor(race.Id),
Credits = 25000f,
};
_world.Factions.Add(playerFaction);
var policy = _worldSeedingService.CreatePolicies([playerFaction]).Single();
var templateFaction = _staticData.FactionDefinitions.Values
.Where(candidate => string.Equals(candidate.RaceId, race.Id, StringComparison.Ordinal))
.OrderBy(candidate => candidate.Id, StringComparer.Ordinal)
.Select(candidate => _world.Factions.FirstOrDefault(worldFaction => string.Equals(worldFaction.Id, candidate.Id, StringComparison.Ordinal)))
.FirstOrDefault(candidate => candidate is not null);
if (templateFaction?.DefaultPolicySetId is { } racePolicyId
&& _world.Policies.FirstOrDefault(candidate => candidate.Id == racePolicyId) is { } racePolicy)
{
policy.TradeAccessPolicy = racePolicy.TradeAccessPolicy;
policy.DockingAccessPolicy = racePolicy.DockingAccessPolicy;
policy.ConstructionAccessPolicy = racePolicy.ConstructionAccessPolicy;
policy.OperationalRangePolicy = racePolicy.OperationalRangePolicy;
policy.CombatEngagementPolicy = racePolicy.CombatEngagementPolicy;
policy.FleeHullRatio = racePolicy.FleeHullRatio;
policy.AvoidHostileSystems = racePolicy.AvoidHostileSystems;
foreach (var systemId in racePolicy.BlacklistedSystemIds)
{
policy.BlacklistedSystemIds.Add(systemId);
}
}
_world.Policies.Add(policy);
var factionCommander = CreateFactionCommander(playerFaction);
_world.Commanders.Add(factionCommander);
playerFaction.CommanderIds.Add(factionCommander.Id);
return playerFaction;
}
private string ResolveStarterSystemIdUnsafe()
{
return _world.Systems
.Select(system => system.Definition.Id)
.OrderBy(systemId => systemId, StringComparer.Ordinal)
.FirstOrDefault()
?? throw new InvalidOperationException("No systems are available for player onboarding.");
}
private void SpawnPlayerStarterShipUnsafe(FactionRuntime playerFaction, string systemId)
{
var request = new SpawnShipCommandRequest(
playerFaction.Id,
systemId,
StarterPlayerShipId,
Idle);
var system = _world.Systems.First(candidate => string.Equals(candidate.Definition.Id, request.SystemId, StringComparison.Ordinal));
var definition = ResolveShipDefinition(request, playerFaction.Id);
var shipId = $"ship-{playerFaction.Id}-{definition.Id}-{Guid.NewGuid():N}".ToLowerInvariant();
var spawnPosition = ResolveSpawnPosition(system.Definition.Id);
var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, null);
var ship = new ShipRuntime
{
Id = shipId,
SystemId = system.Definition.Id,
Definition = definition,
FactionId = playerFaction.Id,
Position = spawnPosition,
TargetPosition = spawnPosition,
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Celestials),
DefaultBehavior = defaultBehavior,
Skills = ShipBootstrapPolicy.CreateSkills(definition),
Health = definition.Hull,
};
_world.Ships.Add(ship);
EnsureShipCommander(playerFaction, ship);
new GeopoliticalSimulationService().Update(_world, 0f, []);
}
private string ResolvePlayerFactionColor(string raceId) =>
raceId switch
{
"argon" => "#3b82f6",
"boron" => "#14b8a6",
"paranid" => "#eab308",
"split" => "#b91c1c",
"teladi" => "#22c55e",
"terran" => "#38bdf8",
"xenon" => "#9ca3af",
_ => "#94a3b8",
};
private string GetCurrentPlayerKey() => _playerIdentityResolver.GetRequiredEffectivePlayerId().ToString("N");
private bool CanCurrentActorAccessGm() => _playerIdentityResolver.CanAccessGm();
@@ -807,7 +918,8 @@ public sealed class WorldService
powerModuleId,
factionId,
_staticData.ModuleDefinitions,
_staticData.ItemDefinitions)
_staticData.ItemDefinitions,
_staticData.Recipes)
.FirstOrDefault(moduleId =>
{
return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
@@ -820,6 +932,24 @@ public sealed class WorldService
EnsureStationModule(modules, defaultContainerStorageModuleId);
}
var defaultSolidStorageModuleId = StarterStationLayoutResolver.ResolveRequiredStorageModuleIds(
powerModuleId,
factionId,
_staticData.ModuleDefinitions,
_staticData.ItemDefinitions,
_staticData.Recipes)
.FirstOrDefault(moduleId =>
{
return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
&& definition is StorageModuleDefinition storageDefinition
&& storageDefinition.StorageKind == StorageKind.Solid;
});
if (defaultSolidStorageModuleId is not null)
{
EnsureStationModule(modules, defaultSolidStorageModuleId);
}
var objectiveModuleId = StarterStationLayoutResolver.ResolveObjectiveModuleId(objective, factionId, _staticData.ModuleDefinitions);
if (!string.IsNullOrWhiteSpace(objectiveModuleId))
{
@@ -828,7 +958,8 @@ public sealed class WorldService
objectiveModuleId,
factionId,
_staticData.ModuleDefinitions,
_staticData.ItemDefinitions))
_staticData.ItemDefinitions,
_staticData.Recipes))
{
EnsureStationModule(modules, storageModuleId);
}

View File

@@ -2,8 +2,6 @@
import { storeToRefs } from "pinia";
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { GameViewer } from "./GameViewer";
import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
import ViewerEntityBrowserPanel from "./components/ViewerEntityBrowserPanel.vue";
import ViewerEntityInspectorPanel from "./components/ViewerEntityInspectorPanel.vue";
@@ -13,9 +11,12 @@ import GmTelemetryWindow from "./components/gm/GmTelemetryWindow.vue";
import GmSettingsWindow from "./components/gm/GmSettingsWindow.vue";
import AuthSessionPanel from "./components/AuthSessionPanel.vue";
import AuthLandingPage from "./components/AuthLandingPage.vue";
import PlayerOnboardingPanel from "./components/PlayerOnboardingPanel.vue";
import { fetchPlayerFaction } from "./api";
import { useShipAutomationCatalogStore } from "./ui/stores/shipAutomationCatalogStore";
import { createViewerHudState } from "./viewerHudState";
import { useAuthStore } from "./ui/stores/authStore";
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { Selectable } from "./viewerTypes";
@@ -27,19 +28,24 @@ const hoverConnectorLineEl = ref<SVGLineElement | null>(null);
const hudState = createViewerHudState();
const authStore = useAuthStore();
const playerFactionStore = usePlayerFactionStore();
const automationCatalogStore = useShipAutomationCatalogStore();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
const { canAccessGm } = storeToRefs(authStore);
const { canAccessGm, effectivePlayerId } = storeToRefs(authStore);
const { playerFaction } = storeToRefs(playerFactionStore);
let viewer: GameViewer | undefined;
const gmOpsOpen = ref(false);
const gmTelemetryOpen = ref(false);
const gmSettingsOpen = ref(false);
const gmMenuOpen = ref(false);
const leftSidebarTab = ref<"player" | "entities">("player");
const playerContextReady = ref(false);
onMounted(async () => {
void automationCatalogStore.load();
await refreshPlayerContext();
await startViewerIfAuthenticated();
});
@@ -47,15 +53,35 @@ onBeforeUnmount(() => {
viewer?.dispose();
});
watch(() => authStore.isAuthenticated, async (isAuthenticated) => {
if (isAuthenticated) {
await startViewerIfAuthenticated();
return;
}
watch(
[() => authStore.isAuthenticated, () => effectivePlayerId.value],
async ([isAuthenticated]) => {
if (!isAuthenticated) {
playerContextReady.value = false;
playerFactionStore.setPlayerFaction(null);
viewer?.dispose();
viewer = undefined;
return;
}
viewer?.dispose();
viewer = undefined;
});
await refreshPlayerContext();
await startViewerIfAuthenticated();
},
{ immediate: true },
);
watch(
() => playerFaction.value?.requiresOnboarding ?? false,
async (requiresOnboarding) => {
if (requiresOnboarding) {
viewer?.dispose();
viewer = undefined;
return;
}
await startViewerIfAuthenticated();
},
);
function onHistoryWindowResize(id: string, width: number, height: number) {
const windowState = hudState.historyWindows.find((entry) => entry.id === id);
@@ -76,7 +102,7 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
}
async function startViewerIfAuthenticated() {
if (!authStore.isAuthenticated || viewer) {
if (!authStore.isAuthenticated || viewer || !playerContextReady.value || playerFaction.value?.requiresOnboarding) {
return;
}
@@ -101,58 +127,112 @@ async function startViewerIfAuthenticated() {
});
void viewer.start();
}
async function refreshPlayerContext() {
if (!authStore.isAuthenticated) {
playerContextReady.value = false;
playerFactionStore.setPlayerFaction(null);
return;
}
playerContextReady.value = false;
try {
playerFactionStore.setPlayerFaction(await fetchPlayerFaction());
} catch {
playerFactionStore.setPlayerFaction(null);
} finally {
playerContextReady.value = true;
}
}
</script>
<template>
<AuthLandingPage v-if="!authStore.isAuthenticated" />
<div v-else-if="!playerContextReady" class="auth-landing">
<div class="auth-landing__backdrop" />
<div class="auth-landing__hero">
<h1>Preparing player context</h1>
<p>Loading your in-universe identity and ownership state.</p>
</div>
</div>
<PlayerOnboardingPanel v-else-if="playerContextReady && playerFaction?.requiresOnboarding" />
<div v-else class="viewer-app">
<div
ref="canvasHostEl"
class="viewer-canvas-host"
/>
<div class="pointer-events-none fixed inset-0">
<div class="absolute left-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(360px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:right-5 max-[760px]:bottom-[148px] max-[760px]:w-auto max-[760px]:max-h-[38vh]">
<AuthSessionPanel />
<CollapsibleHudPanel
v-model:collapsed="hudState.gamePanel.collapsed"
class-name="topbar"
panel-name="game"
title="Game"
:summary="hudState.gamePanel.summary"
:body-text="hudState.gamePanel.bodyText"
/>
<CollapsibleHudPanel
v-model:collapsed="hudState.networkPanel.collapsed"
class-name="network-panel"
panel-name="network"
title="Network"
:summary="hudState.networkPanel.summary"
:body-text="hudState.networkPanel.bodyText"
/>
<CollapsibleHudPanel
v-model:collapsed="hudState.performancePanel.collapsed"
class-name="performance-panel"
panel-name="performance"
title="Performance"
:summary="hudState.performancePanel.summary"
:body-text="hudState.performancePanel.bodyText"
/>
<ViewerEntityBrowserPanel
class="min-h-0 flex-1"
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
/>
<div class="viewer-left-sidebar-dock">
<section class="viewer-left-sidebar pointer-events-auto">
<div class="viewer-left-sidebar__tabs">
<button
type="button"
class="viewer-left-sidebar__tab"
:class="leftSidebarTab === 'player' ? 'viewer-left-sidebar__tab--active' : ''"
@click="leftSidebarTab = 'player'"
>
Player Informations
</button>
<button
type="button"
class="viewer-left-sidebar__tab"
:class="leftSidebarTab === 'entities' ? 'viewer-left-sidebar__tab--active' : ''"
@click="leftSidebarTab = 'entities'"
>
Entities
</button>
</div>
<div class="viewer-left-sidebar__body">
<div
v-if="leftSidebarTab === 'player'"
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--player"
>
<AuthSessionPanel />
</div>
<ViewerEntityBrowserPanel
v-else
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--entities"
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
/>
</div>
</section>
</div>
<div
v-if="hudState.statsOverlay.lines.length > 0"
class="viewer-stats-overlay-dock"
>
<div
class="viewer-stats-overlay"
:class="hudState.statsOverlay.mode === 'compact' ? 'viewer-stats-overlay--compact' : ''"
>
<div
v-for="(line, index) in hudState.statsOverlay.lines"
:key="`${index}-${line}`"
class="viewer-stats-overlay__line"
:class="line === '' ? 'viewer-stats-overlay__line--spacer' : ''"
>
{{ line === "" ? "\u00A0" : line }}
</div>
</div>
</div>
<div
v-if="!hudState.systemPanel.hidden"
class="viewer-system-label-dock"
>
<div class="viewer-system-label">
<div class="viewer-system-label__title">
{{ hudState.systemPanel.title }}
</div>
<div class="viewer-system-label__subtitle">
{{ hudState.systemPanel.bodyHtml }}
</div>
</div>
</div>
<div class="absolute right-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(380px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto">
<HtmlInfoPanel
class-name="system-panel-section"
title="System"
:subtitle="hudState.systemPanel.title"
:body-html="hudState.systemPanel.bodyHtml"
:hidden="hudState.systemPanel.hidden"
subtitle-class="system-title"
body-class="system-body"
/>
<ViewerEntityInspectorPanel
class="min-h-0 flex-1"
:fallback-title="hudState.detailPanel.title"
@@ -222,7 +302,7 @@ async function startViewerIfAuthenticated() {
<GmOpsWindow
v-if="gmOpsOpen"
@close="gmOpsOpen = false"
@focus="(id, kind) => onFocusSelection({ kind, id }, kind === 'ship' ? 'follow' : 'tactical')"
@focus="(id, kind) => onFocusSelection({ kind, id }, 'tactical')"
/>
<GmTelemetryWindow
v-if="gmTelemetryOpen"

View File

@@ -91,7 +91,7 @@ export class ViewerAppController {
private currentDistance = NAV_DISTANCE.system;
private desiredDistance = NAV_DISTANCE.system;
private orbitYaw = -2.3;
private orbitPitch = 0.62;
private orbitPitch = 1.08;
private cameraMode: CameraMode = "tactical";
private dragMode?: DragMode;
private dragPointerId?: number;
@@ -195,6 +195,7 @@ export class ViewerAppController {
return this.sceneDataController.createWorldPresentationContext({
world: this.world,
activeSystemId: this.activeSystemId,
cameraMode: this.cameraMode,
povLevel: this.povLevel,
orbitYaw: this.orbitYaw,
systemCamera: this.systemLayer.camera,
@@ -293,7 +294,7 @@ export class ViewerAppController {
}
this.updatePanFromKeyboard(delta);
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.92, 1.32);
const orbitOffset = this.computeOrbitOffset();

View File

@@ -2,12 +2,15 @@ import type { WorldDelta, WorldSnapshot } from "./contracts";
import type { TelemetrySnapshot } from "./contractsTelemetry";
import type { BalanceSettings } from "./contractsBalance";
import type { PlayerFactionSnapshot } from "./contractsPlayerFaction";
import type { AuthSessionResponse, ForgotPasswordResponse } from "./contractsAuth";
import type { RaceSnapshot } from "./contractsRaces";
import type { AuthSessionResponse, ForgotPasswordResponse, RegisterResponse } from "./contractsAuth";
import type { PlayerIdentitySummary } from "./contractsIdentity";
import type { ShipAutomationCatalogSnapshot } from "./contractsShipAutomation";
import type { FactionSnapshot } from "./contractsFactions";
import type { ShipSnapshot } from "./contractsShips";
import type { StationSnapshot } from "./contractsInfrastructure";
import { clearAuthSession, getAuthSession, setAuthSession } from "./authSession";
import { getEffectivePlayerIdentityId } from "./effectiveIdentitySession";
import type {
PlayerAssetAssignmentCommandRequest,
PlayerAutomationPolicyCommandRequest,
@@ -35,6 +38,12 @@ async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit, option
if (session?.accessToken) {
headers.set("Authorization", `Bearer ${session.accessToken}`);
}
if (session?.roles.some((role) => role === "gm" || role === "admin")) {
const effectivePlayerId = getEffectivePlayerIdentityId();
if (effectivePlayerId) {
headers.set("X-Act-As-Player-Id", effectivePlayerId);
}
}
}
const response = await fetch(input, {
@@ -160,13 +169,11 @@ export async function resetWorld() {
}
export async function register(request: { email: string; password: string }) {
const session = await fetchJson<AuthSessionResponse>("/api/auth/register", {
return fetchJson<RegisterResponse>("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
}, { skipAuth: true, skipRefresh: true });
setAuthSession(session);
return session;
}
export async function login(request: { email: string; password: string }) {
@@ -199,6 +206,22 @@ export async function fetchPlayerFaction(signal?: AbortSignal) {
return fetchJson<PlayerFactionSnapshot>("/api/player-faction", { signal });
}
export async function fetchRaces(signal?: AbortSignal) {
return fetchJson<RaceSnapshot[]>("/api/auth/races", { signal }, { skipAuth: true });
}
export async function completePlayerOnboarding(request: { name: string; raceId: string }) {
return fetchJson<PlayerFactionSnapshot>("/api/player-faction/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
}
export async function fetchPlayerIdentities(signal?: AbortSignal) {
return fetchJson<PlayerIdentitySummary[]>("/api/player-faction/identities", { signal });
}
export async function fetchShipAutomationCatalog(signal?: AbortSignal) {
return fetchJson<ShipAutomationCatalogSnapshot>("/api/ships/catalog", { signal }, { skipAuth: true });
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -76,6 +76,50 @@ function formatAmount(value: number) {
return Math.abs(value - rounded) < 0.005 ? String(rounded) : value.toFixed(1);
}
function formatPercent(value: number) {
return `${Math.round(value * 100)}%`;
}
function joinDetail(parts: Array<string | null | undefined>) {
return parts.filter((part): part is string => !!part && part.trim().length > 0).join(" · ");
}
function describeOrderTarget(order: {
itemId?: string | null;
targetEntityId?: string | null;
targetSystemId?: string | null;
nodeId?: string | null;
constructionSiteId?: string | null;
sourceStationId?: string | null;
destinationStationId?: string | null;
moduleId?: string | null;
}) {
return order.itemId
?? order.targetEntityId
?? order.targetSystemId
?? order.nodeId
?? order.constructionSiteId
?? order.destinationStationId
?? order.sourceStationId
?? order.moduleId
?? "—";
}
function describeSubTaskTarget(subTask: {
itemId?: string | null;
targetEntityId?: string | null;
targetSystemId?: string | null;
targetNodeId?: string | null;
moduleId?: string | null;
}) {
return subTask.itemId
?? subTask.targetEntityId
?? subTask.targetSystemId
?? subTask.targetNodeId
?? subTask.moduleId
?? "—";
}
const selectedShip = computed(() => {
if (selectedEntityKind.value !== "ship" || !selectedEntityId.value) {
return null;
@@ -100,10 +144,10 @@ const playerShipIds = computed(() =>
new Set(playerFaction.value?.assetRegistry.shipIds ?? []),
);
const canAccessGm = computed(() => authStore.canAccessGm);
const canAccessGmDirectly = computed(() => authStore.canAccessGm && !authStore.isActingAsAlternateIdentity);
const canDirectControlSelectedShip = computed(() =>
!!selectedShip.value && (canAccessGm.value || playerShipIds.value.has(selectedShip.value.id)),
!!selectedShip.value && (canAccessGmDirectly.value || playerShipIds.value.has(selectedShip.value.id)),
);
const directOrders = computed(() =>
@@ -135,20 +179,208 @@ const formBehaviorNotes = computed(() =>
getShipBehaviorNotes(behaviorForm.kind),
);
watch(selectedShip, (ship) => {
if (!ship) {
return;
const shipStatusRows = computed(() => {
if (!selectedShip.value) {
return [];
}
behaviorForm.kind = ship.defaultBehavior.kind;
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
mineOrderForm.systemId = ship.systemId ?? "";
mineOrderForm.itemId = "ore";
moveOrderSystemId.value = ship.systemId ?? "";
actionStatus.value = "";
actionError.value = "";
}, { immediate: true });
return [
{ label: "State", value: titleCase(selectedShip.value.state) },
{ label: "Behavior", value: getShipBehaviorLabel(selectedShip.value.defaultBehavior.kind) },
{ label: "Control", value: titleCase(selectedShip.value.controlSourceKind) },
{ label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" },
{
label: "Plan",
value: selectedShip.value.activePlan
? `${selectedShip.value.activePlan.kind} · ${titleCase(selectedShip.value.activePlan.status)}`
: "none",
},
{ label: "Failure", value: selectedShip.value.lastAccessFailureReason ?? "none" },
{ label: "Commander", value: selectedShip.value.commanderId ?? "none" },
{ label: "Docked", value: selectedShip.value.dockedStationId ?? "no" },
];
});
const shipCargoSummaryRows = computed(() => {
if (!selectedShip.value) {
return [];
}
const usedCargo = selectedShip.value.inventory.reduce((sum, entry) => sum + entry.amount, 0);
return [
{ label: "Used", value: formatAmount(usedCargo) },
{ label: "Capacity", value: formatAmount(selectedShip.value.cargoCapacity) },
{ label: "Free", value: formatAmount(Math.max(selectedShip.value.cargoCapacity - usedCargo, 0)) },
{ label: "Travel", value: `${formatAmount(selectedShip.value.travelSpeed)} ${selectedShip.value.travelSpeedUnit}` },
{ label: "Hull", value: formatAmount(selectedShip.value.health) },
{ label: "Regime", value: titleCase(selectedShip.value.spatialState.movementRegime) },
];
});
const shipCargoRows = computed(() =>
selectedShip.value?.inventory.map((entry) => ({
key: entry.itemId,
ware: entry.itemId,
amount: formatAmount(entry.amount),
})) ?? [],
);
const shipBehaviorRows = computed(() => {
if (!selectedShip.value) {
return [];
}
return [
{ label: "Area", value: selectedShip.value.defaultBehavior.areaSystemId ?? "none" },
{ label: "Item", value: selectedShip.value.defaultBehavior.itemId ?? "none" },
{ label: "Home Station", value: selectedShip.value.defaultBehavior.homeStationId ?? "none" },
{ label: "Target", value: selectedShip.value.defaultBehavior.targetEntityId ?? "none" },
{ label: "Range", value: String(selectedShip.value.defaultBehavior.maxSystemRange) },
{ label: "Known Only", value: selectedShip.value.defaultBehavior.knownStationsOnly ? "yes" : "no" },
];
});
const directOrderRows = computed(() =>
directOrders.value.map((order) => ({
id: order.id,
label: getShipOrderLabel(order.kind),
status: titleCase(order.status),
target: describeOrderTarget(order),
detail: joinDetail([
`P${order.priority}`,
titleCase(order.sourceKind),
order.failureReason ?? undefined,
]),
})),
);
const behaviorOrderRows = computed(() =>
behaviorOrders.value.map((order) => ({
id: order.id,
label: getShipOrderLabel(order.kind),
status: titleCase(order.status),
target: describeOrderTarget(order),
detail: joinDetail([
`P${order.priority}`,
getShipOrderSupportStatusLabel(order.kind) ?? undefined,
getShipOrderNotes(order.kind) ?? undefined,
order.failureReason ?? undefined,
]),
})),
);
const shipPlanRows = computed(() => {
if (!selectedShip.value?.activePlan) {
return [];
}
return selectedShip.value.activePlan.steps.flatMap((step) => {
const stepRow = {
id: step.id,
scope: "Step",
activity: step.summary || titleCase(step.kind),
status: titleCase(step.status),
detail: joinDetail([
step.blockingReason ?? undefined,
`${step.subTasks.length} subtasks`,
]),
isSubTask: false,
};
const subTaskRows = step.subTasks.map((subTask) => ({
id: subTask.id,
scope: "Subtask",
activity: subTask.summary || titleCase(subTask.kind),
status: titleCase(subTask.status),
detail: joinDetail([
describeSubTaskTarget(subTask),
subTask.blockingReason ?? undefined,
`${Math.round(subTask.progress * 100)}%`,
]),
isSubTask: true,
}));
return [stepRow, ...subTaskRows];
});
});
const stationStatusRows = computed(() => {
if (!selectedStation.value) {
return [];
}
return [
{ label: "Category", value: titleCase(selectedStation.value.category) },
{ label: "Objective", value: titleCase(selectedStation.value.objective) },
{ label: "Docked", value: `${selectedStation.value.dockedShips} / ${selectedStation.value.dockingPads}` },
{
label: "Population",
value: `${formatAmount(selectedStation.value.population)} / ${formatAmount(selectedStation.value.populationCapacity)}`,
},
{ label: "Workforce", value: formatAmount(selectedStation.value.workforceRequired) },
{ label: "Efficiency", value: formatPercent(selectedStation.value.workforceEffectiveRatio) },
{ label: "Commander", value: selectedStation.value.commanderId ?? "none" },
{ label: "Policy", value: selectedStation.value.policySetId ?? "none" },
];
});
const stationModuleRows = computed(() =>
selectedStation.value?.installedModules.map((moduleId) => ({
key: moduleId,
module: moduleNameById.get(moduleId) ?? moduleId,
moduleId,
})) ?? [],
);
const stationStorageRows = computed(() =>
selectedStation.value?.storageUsage.map((entry) => ({
key: entry.storageClass,
storageClass: titleCase(entry.storageClass),
used: formatAmount(entry.used),
capacity: formatAmount(entry.capacity),
fill: entry.capacity > 0 ? formatPercent(entry.used / entry.capacity) : "0%",
})) ?? [],
);
const stationInventoryRows = computed(() =>
selectedStation.value?.inventory.map((entry) => ({
key: entry.itemId,
ware: entry.itemId,
amount: formatAmount(entry.amount),
})) ?? [],
);
const stationProcessRows = computed(() =>
selectedStation.value?.currentProcesses.map((process) => ({
key: `${process.lane}-${process.label}`,
lane: process.lane,
label: process.label,
progress: formatPercent(process.progress),
timing: `${Math.ceil(process.timeRemainingSeconds)}s / ${Math.ceil(process.cycleSeconds)}s`,
})) ?? [],
);
watch(
() => `${selectedEntityKind.value ?? "none"}:${selectedEntityId.value ?? "none"}`,
() => {
const ship = selectedShip.value;
if (!ship) {
actionStatus.value = "";
actionError.value = "";
return;
}
behaviorForm.kind = ship.defaultBehavior.kind;
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
mineOrderForm.systemId = ship.systemId ?? "";
mineOrderForm.itemId = "ore";
moveOrderSystemId.value = ship.systemId ?? "";
actionStatus.value = "";
actionError.value = "";
},
{ immediate: true },
);
function focusShip(cameraMode?: "follow" | "tactical") {
if (!selectedShip.value) {
@@ -357,36 +589,52 @@ async function clearOrders() {
</div>
<div class="entity-inspector-panel__actions">
<button type="button" class="entity-inspector-panel__action" @click="focusShip('tactical')">Focus</button>
<button type="button" class="entity-inspector-panel__action" @click="focusShip('follow')">Follow</button>
<button type="button" class="entity-inspector-panel__action" @click="focusShip('follow')">Track</button>
</div>
</header>
<div class="entity-inspector-section">
<h4>Status</h4>
<div class="entity-inspector-grid">
<div><span>State</span><strong>{{ titleCase(selectedShip.state) }}</strong></div>
<div><span>Behavior</span><strong>{{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</strong></div>
<div><span>Control</span><strong>{{ selectedShip.controlSourceKind }}</strong></div>
<div><span>Assignment</span><strong>{{ selectedShip.assignment?.kind ?? "unassigned" }}</strong></div>
<div><span>Plan</span><strong>{{ selectedShip.activePlan ? `${selectedShip.activePlan.kind} · ${selectedShip.activePlan.status}` : "none" }}</strong></div>
<div><span>Failure</span><strong>{{ selectedShip.lastAccessFailureReason ?? "none" }}</strong></div>
<div class="entity-inspector-table-wrap">
<table class="entity-inspector-table entity-inspector-table--kv">
<tbody>
<tr v-for="row in shipStatusRows" :key="row.label">
<th scope="row">{{ row.label }}</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="entity-inspector-section">
<h4>Cargo</h4>
<div class="entity-inspector-grid">
<div><span>Used</span><strong>{{ formatAmount(selectedShip.inventory.reduce((sum, entry) => sum + entry.amount, 0)) }}</strong></div>
<div><span>Capacity</span><strong>{{ formatAmount(selectedShip.cargoCapacity) }}</strong></div>
<div><span>Travel</span><strong>{{ formatAmount(selectedShip.travelSpeed) }} {{ selectedShip.travelSpeedUnit }}</strong></div>
<div><span>Hull</span><strong>{{ formatAmount(selectedShip.health) }}</strong></div>
<div class="entity-inspector-table-wrap">
<table class="entity-inspector-table entity-inspector-table--kv">
<tbody>
<tr v-for="row in shipCargoSummaryRows" :key="row.label">
<th scope="row">{{ row.label }}</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="shipCargoRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Ware</th>
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="row in shipCargoRows" :key="row.key">
<td>{{ row.ware }}</td>
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
</tr>
</tbody>
</table>
</div>
<ul v-if="selectedShip.inventory.length > 0" class="entity-inspector-list">
<li v-for="entry in selectedShip.inventory" :key="entry.itemId">
<span>{{ entry.itemId }}</span>
<strong>{{ formatAmount(entry.amount) }}</strong>
</li>
</ul>
<div v-else class="entity-inspector-empty">No cargo.</div>
</div>
@@ -395,13 +643,15 @@ async function clearOrders() {
<div v-if="selectedBehaviorStatus || selectedBehaviorNotes" class="entity-inspector-note">
{{ [selectedBehaviorStatus, selectedBehaviorNotes].filter(Boolean).join(" · ") }}
</div>
<div class="entity-inspector-grid">
<div><span>Area</span><strong>{{ selectedShip.defaultBehavior.areaSystemId ?? "none" }}</strong></div>
<div><span>Item</span><strong>{{ selectedShip.defaultBehavior.itemId ?? "none" }}</strong></div>
<div><span>Home Station</span><strong>{{ selectedShip.defaultBehavior.homeStationId ?? "none" }}</strong></div>
<div><span>Target</span><strong>{{ selectedShip.defaultBehavior.targetEntityId ?? "none" }}</strong></div>
<div><span>Range</span><strong>{{ selectedShip.defaultBehavior.maxSystemRange }}</strong></div>
<div><span>Known Only</span><strong>{{ selectedShip.defaultBehavior.knownStationsOnly ? "yes" : "no" }}</strong></div>
<div class="entity-inspector-table-wrap">
<table class="entity-inspector-table entity-inspector-table--kv">
<tbody>
<tr v-for="row in shipBehaviorRows" :key="row.label">
<th scope="row">{{ row.label }}</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="canDirectControlSelectedShip" class="entity-inspector-form">
<label class="entity-inspector-field">
@@ -473,52 +723,86 @@ async function clearOrders() {
</div>
<div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
<div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
<ul v-if="directOrders.length > 0" class="entity-inspector-list">
<li v-for="order in directOrders" :key="order.id">
<span>{{ getShipOrderLabel(order.kind) }} · {{ order.status }}</span>
<div class="entity-inspector-order-actions">
<strong>{{ order.itemId ?? order.targetEntityId ?? order.targetSystemId ?? "—" }}</strong>
<button
v-if="canDirectControlSelectedShip"
type="button"
class="entity-inspector-order-remove"
:disabled="actionBusy"
@click="removeOrder(order.id)"
>
Remove
</button>
</div>
</li>
</ul>
<div v-if="directOrderRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Order</th>
<th scope="col">Status</th>
<th scope="col">Target</th>
<th scope="col">Detail</th>
<th v-if="canDirectControlSelectedShip" scope="col" class="entity-inspector-table__action-col">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="order in directOrderRows" :key="order.id">
<td>{{ order.label }}</td>
<td>{{ order.status }}</td>
<td>{{ order.target }}</td>
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
<td v-if="canDirectControlSelectedShip" class="entity-inspector-table__action-col">
<button
type="button"
class="entity-inspector-order-remove"
:disabled="actionBusy"
@click="removeOrder(order.id)"
>
Remove
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
<div class="entity-inspector-divider">
<span>Behavior: {{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
</div>
<ul v-if="behaviorOrders.length > 0" class="entity-inspector-list">
<li v-for="order in behaviorOrders" :key="order.id">
<span>{{ getShipOrderLabel(order.kind) }} · {{ order.status }}</span>
<strong>{{ [order.itemId ?? order.targetEntityId ?? order.targetSystemId ?? "—", getShipOrderSupportStatusLabel(order.kind), getShipOrderNotes(order.kind)].filter(Boolean).join(" · ") }}</strong>
</li>
</ul>
<div v-if="behaviorOrderRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Order</th>
<th scope="col">Status</th>
<th scope="col">Target</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody>
<tr v-for="order in behaviorOrderRows" :key="order.id">
<td>{{ order.label }}</td>
<td>{{ order.status }}</td>
<td>{{ order.target }}</td>
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No behavior orders queued.</div>
</div>
<div class="entity-inspector-section">
<h4>Plan Steps</h4>
<ul v-if="selectedShip.activePlan" class="entity-inspector-plan">
<li v-for="step in selectedShip.activePlan.steps" :key="step.id">
<div class="entity-inspector-plan__step">
<span>{{ step.kind }} · {{ step.status }}</span>
<strong>{{ step.blockingReason ?? "ok" }}</strong>
</div>
<ul class="entity-inspector-subtasks">
<li v-for="subTask in step.subTasks" :key="subTask.id">
<span>{{ subTask.kind }} · {{ subTask.status }}</span>
<strong>{{ subTask.blockingReason ?? `${Math.round(subTask.progress * 100)}%` }}</strong>
</li>
</ul>
</li>
</ul>
<div v-if="shipPlanRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Scope</th>
<th scope="col">Activity</th>
<th scope="col">Status</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody>
<tr v-for="row in shipPlanRows" :key="row.id" :class="row.isSubTask ? 'entity-inspector-table__row--subtask' : ''">
<td>{{ row.scope }}</td>
<td :class="row.isSubTask ? 'entity-inspector-table__subtask' : ''">{{ row.activity }}</td>
<td>{{ row.status }}</td>
<td class="entity-inspector-table__detail">{{ row.detail }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No active plan.</div>
</div>
</template>
@@ -537,46 +821,102 @@ async function clearOrders() {
<div class="entity-inspector-section">
<h4>Status</h4>
<div class="entity-inspector-grid">
<div><span>Category</span><strong>{{ titleCase(selectedStation.category) }}</strong></div>
<div><span>Objective</span><strong>{{ titleCase(selectedStation.objective) }}</strong></div>
<div><span>Docked</span><strong>{{ selectedStation.dockedShips }} / {{ selectedStation.dockingPads }}</strong></div>
<div><span>Population</span><strong>{{ formatAmount(selectedStation.population) }} / {{ formatAmount(selectedStation.populationCapacity) }}</strong></div>
<div><span>Workforce</span><strong>{{ formatAmount(selectedStation.workforceRequired) }}</strong></div>
<div><span>Efficiency</span><strong>{{ Math.round(selectedStation.workforceEffectiveRatio * 100) }}%</strong></div>
<div class="entity-inspector-table-wrap">
<table class="entity-inspector-table entity-inspector-table--kv">
<tbody>
<tr v-for="row in stationStatusRows" :key="row.label">
<th scope="row">{{ row.label }}</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="entity-inspector-section">
<h4>Modules</h4>
<ul v-if="selectedStation.installedModules.length > 0" class="entity-inspector-list">
<li v-for="moduleId in selectedStation.installedModules" :key="moduleId">
<span>{{ moduleNameById.get(moduleId) ?? moduleId }}</span>
<strong>{{ moduleId }}</strong>
</li>
</ul>
<div v-if="stationModuleRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Module</th>
<th scope="col">Id</th>
</tr>
</thead>
<tbody>
<tr v-for="row in stationModuleRows" :key="row.key">
<td>{{ row.module }}</td>
<td>{{ row.moduleId }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No modules installed.</div>
</div>
<div class="entity-inspector-section">
<h4>Storage</h4>
<ul v-if="selectedStation.inventory.length > 0" class="entity-inspector-list">
<li v-for="entry in selectedStation.inventory" :key="entry.itemId">
<span>{{ entry.itemId }}</span>
<strong>{{ formatAmount(entry.amount) }}</strong>
</li>
</ul>
<div v-else class="entity-inspector-empty">No inventory.</div>
<div v-if="stationStorageRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Class</th>
<th scope="col" class="entity-inspector-table__numeric">Used</th>
<th scope="col" class="entity-inspector-table__numeric">Capacity</th>
<th scope="col" class="entity-inspector-table__numeric">Fill</th>
</tr>
</thead>
<tbody>
<tr v-for="row in stationStorageRows" :key="row.key">
<td>{{ row.storageClass }}</td>
<td class="entity-inspector-table__numeric">{{ row.used }}</td>
<td class="entity-inspector-table__numeric">{{ row.capacity }}</td>
<td class="entity-inspector-table__numeric">{{ row.fill }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="stationInventoryRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Ware</th>
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="row in stationInventoryRows" :key="row.key">
<td>{{ row.ware }}</td>
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else-if="stationStorageRows.length === 0" class="entity-inspector-empty">No inventory.</div>
</div>
<div class="entity-inspector-section">
<h4>Production</h4>
<ul v-if="selectedStation.currentProcesses.length > 0" class="entity-inspector-list">
<li v-for="process in selectedStation.currentProcesses" :key="`${process.lane}-${process.label}`">
<span>{{ process.label }}</span>
<strong>{{ Math.round(process.progress * 100) }}% · {{ Math.ceil(process.timeRemainingSeconds) }}s</strong>
</li>
</ul>
<div v-if="stationProcessRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Lane</th>
<th scope="col">Process</th>
<th scope="col" class="entity-inspector-table__numeric">Progress</th>
<th scope="col" class="entity-inspector-table__numeric">Timing</th>
</tr>
</thead>
<tbody>
<tr v-for="row in stationProcessRows" :key="row.key">
<td>{{ row.lane }}</td>
<td>{{ row.label }}</td>
<td class="entity-inspector-table__numeric">{{ row.progress }}</td>
<td class="entity-inspector-table__numeric">{{ row.timing }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No active processes.</div>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ body,
margin: 0;
width: 100%;
height: 100%;
min-height: 100dvh;
overflow: hidden;
background:
radial-gradient(circle at top, rgba(89, 132, 247, 0.16), transparent 30%),
@@ -269,12 +270,162 @@ select {
canvas {
display: block;
touch-action: none;
}
.viewer-app,
.viewer-canvas-host {
width: 100%;
height: 100dvh;
min-height: 100dvh;
}
.viewer-canvas-host {
touch-action: none;
}
.viewer-left-sidebar-dock {
position: absolute;
inset: 0 auto 0 0;
width: min(360px, 100vw);
padding: 0;
}
.viewer-left-sidebar {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 16px;
background:
linear-gradient(180deg, rgba(7, 14, 27, 0.9), rgba(7, 14, 27, 0.78)),
radial-gradient(circle at top left, rgba(127, 214, 255, 0.08), transparent 34%);
border-right: 1px solid rgba(132, 196, 255, 0.14);
backdrop-filter: blur(18px);
box-shadow: 18px 0 42px rgba(0, 0, 0, 0.18);
}
.viewer-left-sidebar__tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
flex: 0 0 auto;
}
.viewer-left-sidebar__tab {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: var(--viewer-text);
padding: 0.75rem 0.95rem;
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
transition: background 120ms ease, border-color 120ms ease;
}
.viewer-left-sidebar__tab:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.22);
}
.viewer-left-sidebar__tab--active {
background: rgba(116, 196, 255, 0.14);
border-color: rgba(116, 196, 255, 0.32);
}
.viewer-left-sidebar__body {
display: flex;
flex: 1 1 auto;
min-height: 0;
margin-top: 0.9rem;
overflow: hidden;
}
.viewer-left-sidebar__panel {
flex: 1 1 auto;
min-height: 0;
}
.viewer-left-sidebar__panel--player {
overflow: auto;
padding-right: 0.2rem;
}
.viewer-left-sidebar__panel--entities {
height: 100%;
}
.viewer-left-sidebar__panel--entities.entity-browser-panel {
height: 100%;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.viewer-stats-overlay {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.8rem;
line-height: 1.35;
color: rgba(234, 244, 255, 0.92);
text-shadow:
0 1px 0 rgba(0, 0, 0, 0.85),
0 0 12px rgba(0, 0, 0, 0.42);
white-space: pre-wrap;
letter-spacing: 0.01em;
}
.viewer-stats-overlay-dock {
position: absolute;
top: 20px;
left: calc(min(360px, calc(100vw - 40px)) + 56px);
max-width: min(420px, calc(100vw - 496px));
}
.viewer-system-label-dock {
position: absolute;
top: 22px;
right: calc(min(380px, calc(100vw - 40px)) + 48px);
max-width: min(340px, calc(100vw - 500px));
pointer-events: none;
}
.viewer-system-label {
color: rgba(238, 246, 255, 0.96);
text-shadow:
0 1px 0 rgba(0, 0, 0, 0.88),
0 0 18px rgba(0, 0, 0, 0.42);
}
.viewer-system-label__title {
font-family: "Space Grotesk", "Segoe UI", sans-serif;
font-size: clamp(1.5rem, 1.1rem + 1vw, 2.1rem);
font-weight: 600;
line-height: 0.95;
letter-spacing: -0.04em;
text-wrap: balance;
}
.viewer-system-label__subtitle {
margin-top: 6px;
color: rgba(203, 219, 235, 0.78);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.74rem;
line-height: 1.35;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.viewer-stats-overlay--compact {
font-size: 0.88rem;
font-weight: 500;
}
.viewer-stats-overlay__line--spacer {
line-height: 0.65;
}
.panel-summary,
@@ -1257,54 +1408,253 @@ canvas {
color: rgba(173, 220, 255, 0.64);
}
.entity-browser-section__items {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.entity-browser-item {
display: flex;
align-items: stretch;
gap: 0.55rem;
}
.entity-browser-item__body {
flex: 1 1 auto;
text-align: left;
padding: 0.75rem 0.85rem;
.entity-browser-table-wrap,
.entity-inspector-table-wrap {
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.03);
}
.entity-browser-item__body:disabled {
opacity: 0.82;
cursor: default;
.entity-browser-table,
.entity-inspector-table {
width: 100%;
border-collapse: collapse;
min-width: 0;
}
.entity-browser-item--selected .entity-browser-item__body {
border-color: rgba(116, 196, 255, 0.38);
.entity-browser-table {
table-layout: fixed;
}
.entity-browser-table__col--entity {
width: 38%;
}
.entity-browser-table__col--ident {
width: 18%;
}
.entity-browser-table__col--location {
width: 22%;
}
.entity-browser-table__col--ai {
width: 14%;
}
.entity-browser-table__col--hp {
width: 8%;
}
.entity-browser-table th,
.entity-browser-table td,
.entity-inspector-table th,
.entity-inspector-table td {
padding: 0.68rem 0.8rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
font-size: 0.78rem;
text-align: left;
vertical-align: middle;
}
.entity-browser-table th,
.entity-browser-table td {
padding: 0.42rem 0.5rem;
}
.entity-browser-table thead th,
.entity-inspector-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: rgba(7, 12, 18, 0.96);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: rgba(173, 220, 255, 0.72);
}
.entity-browser-table tbody tr:last-child td,
.entity-inspector-table tbody tr:last-child td,
.entity-inspector-table tbody tr:last-child th {
border-bottom: none;
}
.entity-browser-table__sort {
border: none;
background: transparent;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
padding: 0;
}
.entity-browser-table__row {
cursor: pointer;
transition: background 120ms ease;
}
.entity-browser-table__row:hover {
background: rgba(255, 255, 255, 0.04);
}
.entity-browser-table__row--selected {
background: rgba(116, 196, 255, 0.12);
}
.entity-browser-item__label {
font-size: 0.88rem;
.entity-browser-table__name {
font-size: 0.78rem;
font-weight: 600;
}
.entity-browser-item__subtitle,
.entity-browser-item__meta {
margin-top: 0.18rem;
font-size: 0.75rem;
.entity-browser-table__cell--truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entity-browser-table__cell--ai {
overflow: hidden;
}
.entity-browser-table__detail,
.entity-inspector-table__detail {
color: var(--viewer-muted);
}
.entity-browser-item__focus,
.entity-browser-table__action-col,
.entity-inspector-table__action-col,
.entity-inspector-table__numeric {
text-align: right;
}
.entity-browser-table__numeric {
text-align: right;
white-space: nowrap;
}
.entity-browser-table__action,
.entity-inspector-panel__action {
padding: 0.65rem 0.9rem;
font-size: 0.78rem;
padding: 0.5rem 0.72rem;
font-size: 0.72rem;
align-self: center;
}
.entity-browser-table__action {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: var(--viewer-text);
border-radius: 999px;
transition: background 120ms ease, border-color 120ms ease;
}
.entity-browser-table__action:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.22);
}
.entity-browser-table__muted {
color: var(--viewer-muted);
}
.entity-browser-row {
display: flex;
align-items: center;
gap: 0.38rem;
min-width: 0;
}
.entity-browser-row__toggle {
width: 1rem;
height: 1rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.35rem;
background: rgba(255, 255, 255, 0.04);
color: var(--viewer-text);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.66rem;
line-height: 1;
padding: 0;
flex: 0 0 auto;
}
.entity-browser-row__toggle--spacer {
visibility: hidden;
}
.entity-browser-row__kind {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
padding: 0.14rem 0.3rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: rgba(234, 244, 255, 0.88);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.58rem;
letter-spacing: 0.08em;
text-transform: uppercase;
flex: 0 0 auto;
}
.entity-browser-row__kind--system {
border-color: rgba(127, 214, 255, 0.24);
color: rgba(127, 214, 255, 0.96);
}
.entity-browser-row__kind--station {
border-color: rgba(255, 191, 105, 0.22);
color: rgba(255, 222, 168, 0.92);
}
.entity-browser-row__kind--fleet {
border-color: rgba(146, 255, 200, 0.22);
color: rgba(190, 255, 223, 0.92);
}
.entity-browser-row__kind--ship {
border-color: rgba(255, 255, 255, 0.14);
}
.entity-browser-row__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entity-browser-ai {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
max-width: 100%;
overflow: hidden;
}
.entity-browser-ai__token {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
max-width: 100%;
padding: 0.13rem 0.28rem;
border-radius: 999px;
background: rgba(127, 214, 255, 0.08);
border: 1px solid rgba(127, 214, 255, 0.14);
color: rgba(206, 233, 255, 0.84);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.56rem;
letter-spacing: 0.06em;
text-transform: uppercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entity-inspector-panel__actions {
display: flex;
gap: 0.45rem;
@@ -1328,71 +1678,31 @@ canvas {
color: rgba(173, 220, 255, 0.7);
}
.entity-inspector-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem 0.9rem;
}
.entity-inspector-grid span {
display: block;
font-size: 0.72rem;
color: var(--viewer-muted);
.entity-inspector-table--kv th {
width: 38%;
background: rgba(255, 255, 255, 0.02);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.12em;
color: rgba(173, 220, 255, 0.68);
}
.entity-inspector-grid strong {
display: block;
margin-top: 0.15rem;
font-size: 0.86rem;
.entity-inspector-table--kv td {
font-size: 0.84rem;
font-weight: 600;
}
.entity-inspector-list,
.entity-inspector-plan,
.entity-inspector-subtasks {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
.entity-inspector-table__row--subtask {
background: rgba(255, 255, 255, 0.02);
}
.entity-inspector-list li,
.entity-inspector-plan__step,
.entity-inspector-subtasks li {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: baseline;
padding: 0.55rem 0.7rem;
border-radius: 0.9rem;
background: rgba(255, 255, 255, 0.035);
.entity-inspector-table__subtask {
padding-left: 1.45rem;
}
.entity-inspector-list span,
.entity-inspector-plan__step span,
.entity-inspector-subtasks span {
font-size: 0.8rem;
}
.entity-inspector-list strong,
.entity-inspector-plan__step strong,
.entity-inspector-subtasks strong {
font-size: 0.75rem;
color: var(--viewer-muted);
}
.entity-inspector-plan > li {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.entity-inspector-subtasks {
padding-left: 0.8rem;
.entity-inspector-table__subtask::before {
content: "↳ ";
color: rgba(173, 220, 255, 0.58);
}
.entity-inspector-panel__fallback {
@@ -1592,8 +1902,30 @@ canvas {
}
@media (max-width: 760px) {
.entity-inspector-grid {
grid-template-columns: minmax(0, 1fr);
.viewer-left-sidebar-dock {
width: min(360px, 100vw);
}
.viewer-left-sidebar {
padding: 14px;
}
.viewer-stats-overlay-dock {
top: 96px;
left: 20px;
right: 20px;
max-width: none;
}
.viewer-system-label-dock {
top: 20px;
left: 20px;
right: 20px;
max-width: none;
}
.viewer-system-label__title {
font-size: clamp(1.35rem, 1rem + 1vw, 1.8rem);
}
.entity-inspector-inline-form,
@@ -1602,4 +1934,9 @@ canvas {
flex-direction: column;
align-items: stretch;
}
.entity-browser-table,
.entity-inspector-table {
min-width: 640px;
}
}

View File

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

View File

@@ -103,6 +103,42 @@ export function updatePanFromKeyboard(
galaxyAnchor.addScaledVector(pan, speed * delta);
}
export function applyPanFromScreenDelta(
delta: THREE.Vector2,
orbitYaw: number,
currentDistance: number,
povLevel: PovLevel,
activeSystemId: string | undefined,
systemAnchor: THREE.Vector3,
galaxyAnchor: THREE.Vector3,
viewportWidth: number,
viewportHeight: number,
minimumDistance: number,
maximumDistance: number,
) {
const safeWidth = Math.max(viewportWidth, 1);
const safeHeight = Math.max(viewportHeight, 1);
const normalized = new THREE.Vector2(delta.x / safeWidth, delta.y / safeHeight);
if (normalized.lengthSq() === 0) {
return;
}
const forward = new THREE.Vector3(Math.cos(orbitYaw), 0, Math.sin(orbitYaw));
const right = new THREE.Vector3(-forward.z, 0, forward.x);
const pan = right.multiplyScalar(-normalized.x).add(forward.multiplyScalar(-normalized.y));
if (activeSystemId) {
const scale = povLevel === "system"
? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.35, KILOMETERS_PER_AU * 6.5)
: THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 1200, 180000);
systemAnchor.addScaledVector(pan, scale);
return;
}
const galaxyScale = THREE.MathUtils.mapLinear(currentDistance, minimumDistance, maximumDistance, 1800, 22000);
galaxyAnchor.addScaledVector(pan, galaxyScale);
}
export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined {
const {
world,

View File

@@ -5,6 +5,8 @@ import { ViewerPresentationController } from "./viewerPresentationController";
import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerWorldLifecycle } from "./viewerWorldLifecycle";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import { applyPanFromScreenDelta } from "./viewerCamera";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE } from "./viewerConstants";
import { useViewerSceneStore } from "./ui/stores/viewerScene";
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
import { viewerPinia } from "./ui/stores/pinia";
@@ -236,14 +238,21 @@ export function createViewerControllers(host: any) {
getFollowCameraPosition: () => host.followCameraPosition,
getFollowCameraFocus: () => host.followCameraFocus,
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
applyOrbitDelta: (delta: THREE.Vector2) => {
if (host.cameraMode === "follow") {
host.followOrbitYaw += delta.x * 0.008;
host.followOrbitPitch = THREE.MathUtils.clamp(host.followOrbitPitch + delta.y * 0.004, 0.02, 1.45);
} else {
host.orbitYaw += delta.x * 0.008;
host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3);
}
applyPanDelta: (delta: THREE.Vector2) => {
const bounds = host.renderer.domElement.getBoundingClientRect();
applyPanFromScreenDelta(
delta,
host.orbitYaw,
host.currentDistance,
host.povLevel,
host.activeSystemId,
host.systemAnchor,
host.galaxyAnchor,
bounds.width,
bounds.height,
MIN_CAMERA_DISTANCE,
MAX_CAMERA_DISTANCE,
);
},
syncFollowStateFromSelection: () => navigationController.syncFollowStateFromSelection(),
updatePanels: () => host.updatePanels(),
@@ -251,6 +260,11 @@ export function createViewerControllers(host: any) {
updateGamePanel: (mode) => host.updateGamePanel(mode),
openOrderContextMenu: (x, y, target) => orderContextMenuStore.open(x, y, target),
closeOrderContextMenu: () => orderContextMenuStore.close(),
getStatsOverlayMode: () => host.hudState.statsOverlay.mode,
setStatsOverlayMode: (mode) => {
host.hudState.statsOverlay.mode = mode;
},
refreshStatsOverlay: () => presentationController.refreshStatsOverlay(),
historyController,
});
@@ -269,6 +283,7 @@ export function wireViewerEvents(host: any) {
canvas.addEventListener("pointerdown", host.interactionController.onPointerDown);
canvas.addEventListener("pointermove", host.interactionController.onPointerMove);
canvas.addEventListener("pointerup", host.interactionController.onPointerUp);
canvas.addEventListener("pointercancel", host.interactionController.onPointerUp);
canvas.addEventListener("pointerleave", host.interactionController.onPointerUp);
canvas.addEventListener("click", host.interactionController.onClick);
canvas.addEventListener("contextmenu", host.interactionController.onContextMenu);
@@ -284,6 +299,7 @@ export function wireViewerEvents(host: any) {
canvas.removeEventListener("pointerdown", host.interactionController.onPointerDown);
canvas.removeEventListener("pointermove", host.interactionController.onPointerMove);
canvas.removeEventListener("pointerup", host.interactionController.onPointerUp);
canvas.removeEventListener("pointercancel", host.interactionController.onPointerUp);
canvas.removeEventListener("pointerleave", host.interactionController.onPointerUp);
canvas.removeEventListener("click", host.interactionController.onClick);
canvas.removeEventListener("contextmenu", host.interactionController.onContextMenu);

View File

@@ -3,6 +3,7 @@ import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewer
import { scaleGalaxyVector, toThreeVector } from "./viewerMath";
import { rawObject } from "./viewerScenePrimitives";
import { resolveShipWorldPosition } from "./viewerWorldPresentation";
import type { StatsOverlayMode } from "./viewerHudState";
import type {
CameraMode,
Selectable,
@@ -250,3 +251,20 @@ export function applyKeyboardControl(params: {
return { cameraMode, desiredDistance };
}
export function cycleStatsOverlayMode(current: StatsOverlayMode): StatsOverlayMode {
switch (current) {
case "hidden":
return "compact";
case "compact":
return "status";
case "status":
return "network";
case "network":
return "performance";
case "performance":
return "full";
default:
return "hidden";
}
}

View File

@@ -8,6 +8,13 @@ export interface HudPanelState {
bodyText: string;
}
export type StatsOverlayMode = "hidden" | "compact" | "status" | "network" | "performance" | "full";
export interface StatsOverlayState {
mode: StatsOverlayMode;
lines: string[];
}
export interface HudHtmlPanelState {
hidden: boolean;
title: string;
@@ -100,6 +107,7 @@ export interface ViewerHudState {
gamePanel: HudPanelState;
networkPanel: HudPanelState;
performancePanel: HudPanelState;
statsOverlay: StatsOverlayState;
systemPanel: HudHtmlPanelState;
detailPanel: HudHtmlPanelState;
error: HudErrorState;
@@ -135,6 +143,10 @@ export function createViewerHudState(): ViewerHudState {
summary: "Waiting",
bodyText: "Waiting for frame samples.",
},
statsOverlay: {
mode: "compact",
lines: [],
},
systemPanel: {
hidden: false,
title: "Deep Space",

View File

@@ -1,20 +1,18 @@
import * as THREE from "three";
import {
completeMarqueeSelection,
hideMarqueeBox,
pickSelectableHitAtClientPosition,
pickSelectableAtClientPosition,
updateHoverLabel,
updateMarqueeBox,
} from "./viewerInteraction";
import {
applyKeyboardControl,
cycleStatsOverlayMode,
toggleCameraMode,
navigateFromWheel,
} from "./viewerControls";
import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT, NAV_DISTANCE_SHIP_HULL } from "./viewerConstants";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE_PLANET_ORBIT } from "./viewerConstants";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import type { ViewerHudState } from "./viewerHudState";
import type { StatsOverlayMode, ViewerHudState } from "./viewerHudState";
import type {
CameraMode,
DragMode,
@@ -61,88 +59,128 @@ export interface ViewerInteractionContext {
getFollowCameraPosition: () => THREE.Vector3;
getFollowCameraFocus: () => THREE.Vector3;
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
applyOrbitDelta: (delta: THREE.Vector2) => void;
applyPanDelta: (delta: THREE.Vector2) => void;
syncFollowStateFromSelection: () => void;
updatePanels: () => void;
focusOnSelection: (selection: Selectable) => void;
updateGamePanel: (mode: string) => void;
openOrderContextMenu: (x: number, y: number, target: ViewerOrderContextMenuTarget) => void;
closeOrderContextMenu: () => void;
getStatsOverlayMode: () => StatsOverlayMode;
setStatsOverlayMode: (mode: StatsOverlayMode) => void;
refreshStatsOverlay: () => void;
historyController: ViewerHistoryWindowController;
}
export class ViewerInteractionController {
private readonly activePointers = new Map<number, THREE.Vector2>();
private pinchStartDistance?: number;
private pinchStartZoom?: number;
private pinchLastCenter?: THREE.Vector2;
constructor(private readonly context: ViewerInteractionContext) {}
readonly onPointerDown = (event: PointerEvent) => {
if (event.button === 1) {
this.context.setDragMode("orbit");
this.context.setDragPointerId(event.pointerId);
this.context.dragLast.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
this.context.renderer.domElement.setPointerCapture(event.pointerId);
return;
}
if (event.button !== 0) {
return;
}
this.context.setDragMode("marquee");
this.context.setDragPointerId(event.pointerId);
this.context.dragStart.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
this.context.dragLast.copy(this.context.dragStart);
this.context.setMarqueeActive(false);
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
this.activePointers.set(event.pointerId, point);
this.context.renderer.domElement.setPointerCapture(event.pointerId);
if (this.activePointers.size >= 2) {
const gesture = this.getPinchGesture();
if (!gesture) {
return;
}
this.context.setSuppressClickSelection(true);
this.context.setDragMode("pinch");
this.context.setDragPointerId(event.pointerId);
this.pinchStartDistance = gesture.distance;
this.pinchStartZoom = this.context.getDesiredDistance();
this.pinchLastCenter = gesture.center;
return;
}
this.context.setDragMode("pan");
this.context.setDragPointerId(event.pointerId);
this.context.dragStart.copy(point);
this.context.dragLast.copy(point);
};
readonly onPointerMove = (event: PointerEvent) => {
this.updateHoverLabel(event);
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
if (this.activePointers.has(event.pointerId)) {
this.activePointers.set(event.pointerId, point);
}
if (this.context.getDragPointerId() !== event.pointerId || !this.context.getDragMode()) {
return;
}
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
if (this.context.getDragMode() === "orbit") {
const delta = point.clone().sub(this.context.dragLast);
this.context.dragLast.copy(point);
this.context.applyOrbitDelta(delta);
if (this.context.getDragMode() === "pinch") {
const gesture = this.getPinchGesture();
if (!gesture || !this.pinchStartDistance || !this.pinchStartZoom || !this.pinchLastCenter) {
return;
}
const zoomRatio = THREE.MathUtils.clamp(gesture.distance / this.pinchStartDistance, 0.25, 4);
this.context.setDesiredDistance(THREE.MathUtils.clamp(
this.pinchStartZoom / zoomRatio,
MIN_CAMERA_DISTANCE,
MAX_CAMERA_DISTANCE,
));
const centerDelta = gesture.center.clone().sub(this.pinchLastCenter);
this.pinchLastCenter = gesture.center;
this.context.applyPanDelta(centerDelta);
return;
}
const delta = point.clone().sub(this.context.dragLast);
const dragDistance = point.distanceTo(this.context.dragStart);
if (!this.context.getMarqueeActive() && dragDistance > 8) {
this.context.setMarqueeActive(true);
if (dragDistance > 6) {
this.context.setSuppressClickSelection(true);
this.context.hudState.marquee.visible = true;
this.context.marqueeEl.style.display = "block";
}
if (!this.context.getMarqueeActive()) {
return;
}
this.context.dragLast.copy(point);
updateMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl, this.context.dragStart, this.context.dragLast);
this.context.applyPanDelta(delta);
};
readonly onPointerUp = (event: PointerEvent) => {
if (this.context.getDragPointerId() !== event.pointerId) {
return;
}
if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) {
this.context.renderer.domElement.releasePointerCapture(event.pointerId);
}
this.activePointers.delete(event.pointerId);
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
this.completeMarqueeSelection();
hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl);
if (this.activePointers.size >= 2) {
const gesture = this.getPinchGesture();
if (gesture) {
this.context.setDragMode("pinch");
this.pinchStartDistance = gesture.distance;
this.pinchStartZoom = this.context.getDesiredDistance();
this.pinchLastCenter = gesture.center;
}
return;
}
this.context.setDragMode(undefined);
this.context.setDragPointerId(undefined);
this.context.setMarqueeActive(false);
const remainingPointer = this.activePointers.entries().next();
if (!remainingPointer.done) {
const [pointerId, point] = remainingPointer.value;
this.context.setDragMode("pan");
this.context.setDragPointerId(pointerId);
this.context.dragStart.copy(point);
this.context.dragLast.copy(point);
} else {
this.context.setDragMode(undefined);
this.context.setDragPointerId(undefined);
}
this.pinchStartDistance = undefined;
this.pinchStartZoom = undefined;
this.pinchLastCenter = undefined;
};
readonly onClick = (event: MouseEvent) => {
@@ -225,8 +263,7 @@ export class ViewerInteractionController {
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
this.context.syncFollowStateFromSelection();
this.context.focusOnSelection({ kind: "ship", id: shipId });
this.toggleCameraMode("follow");
this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL);
this.toggleCameraMode("tactical");
this.context.updatePanels();
this.context.updateGamePanel("Live");
return;
@@ -268,8 +305,7 @@ export class ViewerInteractionController {
}
if (selection.kind === "ship") {
this.toggleCameraMode("follow");
this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL);
this.toggleCameraMode("tactical");
this.context.updatePanels();
this.context.updateGamePanel("Live");
return;
@@ -288,6 +324,13 @@ export class ViewerInteractionController {
}
const key = event.key.toLowerCase();
if (key === "f10") {
event.preventDefault();
this.context.setStatsOverlayMode(cycleStatsOverlayMode(this.context.getStatsOverlayMode()));
this.context.refreshStatsOverlay();
return;
}
const controlState = applyKeyboardControl({
keyState: this.context.keyState,
cameraMode: this.context.getCameraMode(),
@@ -371,17 +414,17 @@ export class ViewerInteractionController {
);
}
private completeMarqueeSelection() {
const selection = completeMarqueeSelection({
renderer: this.context.renderer,
systemCamera: this.context.systemCamera,
dragStart: this.context.dragStart,
dragLast: this.context.dragLast,
systemSelectableTargets: this.context.systemSelectableTargets,
});
this.context.setSelectedItems(selection);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
private getPinchGesture() {
const points = [...this.activePointers.values()];
if (points.length < 2) {
return null;
}
const [first, second] = points;
return {
center: first.clone().add(second).multiplyScalar(0.5),
distance: first.distanceTo(second),
};
}
private shouldFocusSelectionOnClick(selection: Selectable) {

View File

@@ -248,6 +248,54 @@ function renderSystemOwnership(world: WorldState, systemId: string): string {
return lines.join("<br>");
}
function titleCaseLabel(value: string | null | undefined): string {
if (!value) {
return "Unknown";
}
return value
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (part) => part.toUpperCase());
}
function describeSystemSubtitle(world: WorldState, systemId: string): string {
const control = world.geopolitics?.territory.controlStates.find((state) => state.systemId === systemId);
const zone = world.geopolitics?.territory.zones.find((entry) => entry.systemId === systemId);
const profile = world.geopolitics?.territory.strategicProfiles.find((entry) => entry.systemId === systemId);
const region = world.geopolitics?.economyRegions.regions.find((entry) => entry.systemIds.includes(systemId));
if (region?.label) {
return region.label;
}
if (zone?.reason) {
return titleCaseLabel(zone.reason);
}
if (zone?.kind) {
return titleCaseLabel(zone.kind);
}
if (profile?.zoneKind) {
return titleCaseLabel(profile.zoneKind);
}
if (control?.isContested) {
return "Contested";
}
if (control) {
return titleCaseLabel(control.controlKind);
}
const claims = [...world.claims.values()].filter((claim) =>
claim.systemId === systemId && claim.state !== "destroyed");
return claims.length === 0 ? "Deep Space" : `Claims ${claims.length}`;
}
export function buildDetailPanelState(params: DetailPanelParams) {
const {
world,
@@ -525,9 +573,7 @@ export function buildSystemPanelState(params: SystemPanelParams) {
return {
hidden: false,
title: activeSystem.label,
bodyHtml: `
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
`,
bodyHtml: describeSystemSubtitle(world, activeSystem.id),
};
}

View File

@@ -1,5 +1,6 @@
import * as THREE from "three";
import {
describeCompactStatsLine,
describeNetworkPanel,
describePerformancePanel,
recordPerformanceStats,
@@ -40,6 +41,56 @@ export interface ViewerPresentationContext {
export class ViewerPresentationController {
constructor(private readonly context: ViewerPresentationContext) { }
private refreshStatsOverlayLines(gameBodyText?: string) {
const mode = this.context.hudState.statsOverlay.mode;
if (mode === "hidden") {
this.context.hudState.statsOverlay.lines = [];
return;
}
const compactLine = describeCompactStatsLine(this.context.networkStats, this.context.performanceStats);
const gameLines = (gameBodyText ?? this.context.hudState.gamePanel.bodyText)
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const networkLines = this.context.hudState.networkPanel.bodyText
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const performanceLines = this.context.hudState.performancePanel.bodyText
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
switch (mode) {
case "compact":
this.context.hudState.statsOverlay.lines = [compactLine];
return;
case "status":
this.context.hudState.statsOverlay.lines = [compactLine, ...gameLines];
return;
case "network":
this.context.hudState.statsOverlay.lines = [compactLine, ...networkLines];
return;
case "performance":
this.context.hudState.statsOverlay.lines = [compactLine, ...performanceLines];
return;
case "full":
this.context.hudState.statsOverlay.lines = [
compactLine,
"",
...gameLines,
"",
...networkLines,
"",
...performanceLines,
];
return;
default:
this.context.hudState.statsOverlay.lines = [];
}
}
initializeAmbience() {
this.context.ambienceGroup.renderOrder = -10;
this.context.ambienceGroup.add(createBackdropStars(document));
@@ -67,6 +118,7 @@ export class ViewerPresentationController {
updateNetworkPanel() {
this.context.hudState.networkPanel.bodyText = describeNetworkPanel(this.context.networkStats);
this.context.hudState.networkPanel.summary = summarizeNetworkStats(this.context.networkStats);
this.refreshStatsOverlayLines();
}
recordPerformanceStats(frameMs: number) {
@@ -79,6 +131,7 @@ export class ViewerPresentationController {
this.context.hudState.performancePanel.bodyText = bodyText;
}
this.context.hudState.performancePanel.summary = summarizePerformanceStats(this.context.performanceStats);
this.refreshStatsOverlayLines();
}
updateShipPresentation() {
@@ -116,6 +169,11 @@ export class ViewerPresentationController {
});
this.context.hudState.gamePanel.bodyText = state.bodyText;
this.context.hudState.gamePanel.summary = state.summaryText;
this.refreshStatsOverlayLines(state.bodyText);
}
refreshStatsOverlay() {
this.refreshStatsOverlayLines();
}
updateSystemPanel() {

View File

@@ -202,6 +202,7 @@ export class ViewerSceneDataController {
createWorldPresentationContext(overrides: {
world: any;
activeSystemId?: string;
cameraMode: any;
povLevel: any;
orbitYaw: number;
systemCamera: THREE.PerspectiveCamera;
@@ -214,6 +215,7 @@ export class ViewerSceneDataController {
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
worldSeed: this.context.getWorldSeed(),
activeSystemId: overrides.activeSystemId,
cameraMode: overrides.cameraMode,
povLevel: overrides.povLevel,
orbitYaw: overrides.orbitYaw,
camera: overrides.systemCamera,

View File

@@ -571,19 +571,55 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n
export function createShipTacticalIcon(documentRef: Document, color: string, size: number): SceneNode {
const canvas = documentRef.createElement("canvas");
canvas.width = 128;
canvas.height = 96;
canvas.height = 192;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create ship tactical icon");
}
context.clearRect(0, 0, canvas.width, canvas.height);
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = color;
context.fillStyle = "rgba(7, 16, 30, 0.7)";
context.lineWidth = 5;
context.fillStyle = "rgba(7, 16, 30, 0.8)";
context.lineWidth = 6;
context.globalAlpha = 0.92;
context.beginPath();
context.moveTo(64, 182);
context.lineTo(64, 108);
context.stroke();
context.globalAlpha = 1;
context.beginPath();
context.arc(64, 70, 26, 0, Math.PI * 2);
context.fill();
context.stroke();
context.beginPath();
context.arc(34, 48, 18, 0, Math.PI * 2);
context.moveTo(64, 34);
context.lineTo(64, 49);
context.stroke();
context.beginPath();
context.moveTo(64, 91);
context.lineTo(64, 106);
context.stroke();
context.beginPath();
context.moveTo(28, 70);
context.lineTo(43, 70);
context.stroke();
context.beginPath();
context.moveTo(85, 70);
context.lineTo(100, 70);
context.stroke();
context.beginPath();
context.moveTo(44, 116);
context.lineTo(64, 136);
context.lineTo(84, 116);
context.stroke();
const texture = new THREE.CanvasTexture(canvas);
@@ -593,9 +629,10 @@ export function createShipTacticalIcon(documentRef: Document, color: string, siz
depthWrite: false,
depthTest: false,
color: "#ffffff",
fog: false,
}));
sprite.center.set(0.28, 0.5);
sprite.scale.set(size * 1.7, size * 1.275, 1);
sprite.center.set(0.5, 0.08);
sprite.scale.set(size * 1.2, size * 1.8, 1);
sprite.visible = false;
return createSceneNode(sprite);
}

View File

@@ -41,14 +41,18 @@ export function describeNetworkPanel(networkStats: NetworkStats) {
}
export function summarizeNetworkStats(networkStats: NetworkStats): string {
const kbPerSecond = estimateDownlinkKbPerSecond(networkStats);
const direction = networkStats.streamConnected ? "live" : "offline";
return `${direction} | down ${kbPerSecond.toFixed(1)} KB/s | ${networkStats.deltasReceived} d`;
}
export function estimateDownlinkKbPerSecond(networkStats: NetworkStats): number {
const now = performance.now();
const recentBytes = networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0);
const recentWindowSeconds = networkStats.throughputSamples.length > 1
? Math.max((now - networkStats.throughputSamples[0].atMs) / 1000, 1)
: 1;
const kbPerSecond = recentBytes / 1024 / recentWindowSeconds;
const direction = networkStats.streamConnected ? "live" : "offline";
return `${direction} | down ${kbPerSecond.toFixed(1)} KB/s | ${networkStats.deltasReceived} d`;
return recentBytes / 1024 / recentWindowSeconds;
}
export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) {
@@ -116,12 +120,23 @@ export function describePerformancePanel(
}
export function summarizePerformanceStats(performanceStats: PerformanceStats): string {
const fps = estimateFps(performanceStats);
return `FPS ${fps.toFixed(1)} | ${performanceStats.lastFrameMs.toFixed(1)} ms`;
}
export function estimateFps(performanceStats: PerformanceStats): number {
const samples = performanceStats.frameSamples;
const elapsedWindowSeconds = samples.length > 1
? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25)
: 1;
const fps = samples.length > 1
return samples.length > 1
? (samples.length - 1) / elapsedWindowSeconds
: 0;
return `FPS ${fps.toFixed(1)} | ${performanceStats.lastFrameMs.toFixed(1)} ms`;
}
export function describeCompactStatsLine(networkStats: NetworkStats, performanceStats: PerformanceStats): string {
const onlineLabel = networkStats.streamConnected ? "Online" : "Offline";
const fps = estimateFps(performanceStats);
const down = estimateDownlinkKbPerSecond(networkStats);
return `${onlineLabel} | FPS ${fps.toFixed(1)} | Down ${down.toFixed(1)} KB/s`;
}

View File

@@ -19,7 +19,7 @@ import type {
export type PovLevel = "local" | "system" | "galaxy";
export type SelectionGroup = "ships" | "structures" | "celestials";
export type DragMode = "orbit" | "marquee";
export type DragMode = "pan" | "pinch";
export type CameraMode = "tactical" | "follow";
export type Selectable =

View File

@@ -152,7 +152,6 @@ export class ViewerWorldLifecycle {
}
applySnapshot(snapshot: WorldSnapshot) {
usePlayerFactionStore(viewerPinia).setPlayerFaction(null);
this.context.setWorldTimeSyncMs(performance.now());
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
if (signature !== this.context.getWorldSignature()) {

View File

@@ -16,8 +16,6 @@ import {
updateSystemStarPresentation,
getAnimatedShipLocalPosition,
iconWorldScale,
MIN_ICON_PIXELS,
MAX_ICON_PIXELS,
} from "./viewerPresentation";
import { rawObject } from "./viewerScenePrimitives";
import type {
@@ -42,6 +40,13 @@ import type {
type SummaryIconKind = "ship" | "station" | "structure";
const SHIP_BILLBOARD_HIDE_DISTANCE = 0.003;
const SHIP_BILLBOARD_FULL_DISTANCE = 0.018;
const SHIP_BILLBOARD_MIN_PIXELS = 34;
const SHIP_BILLBOARD_MAX_PIXELS = 82;
const STATION_ICON_MIN_PIXELS = 28;
const STATION_ICON_MAX_PIXELS = 72;
export interface WorldOrbitalContext {
world?: WorldState;
worldTimeSyncMs: number;
@@ -53,6 +58,7 @@ export interface WorldOrbitalContext {
export interface WorldPresentationContext extends WorldOrbitalContext {
activeSystemId?: string;
cameraMode: CameraMode;
povLevel: PovLevel;
orbitYaw: number;
camera: THREE.PerspectiveCamera;
@@ -95,14 +101,22 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
const shipVisible = isShipVisible(renderMode, context.activeSystemId, ship);
const distToShip = context.camera.position.distanceTo(displayPosition);
const useTacticalIcon = renderMode !== "local" || distToShip > 0.012;
const billboardOpacity = context.cameraMode === "tactical"
? 1
: THREE.MathUtils.clamp(
(distToShip - SHIP_BILLBOARD_HIDE_DISTANCE) / (SHIP_BILLBOARD_FULL_DISTANCE - SHIP_BILLBOARD_HIDE_DISTANCE),
0,
1,
);
const useTacticalIcon = context.cameraMode === "tactical" || billboardOpacity > 0.01;
const iconScale = THREE.MathUtils.clamp(
visual.iconBaseScale,
iconWorldScale(distToShip, context.camera, MIN_ICON_PIXELS),
iconWorldScale(distToShip, context.camera, MAX_ICON_PIXELS + 10),
iconWorldScale(distToShip, context.camera, SHIP_BILLBOARD_MIN_PIXELS),
iconWorldScale(distToShip, context.camera, SHIP_BILLBOARD_MAX_PIXELS),
);
visual.icon.setScaleScalar(iconScale);
visual.mesh.setVisible(shipVisible && !useTacticalIcon);
visual.icon.setOpacity(shipVisible ? billboardOpacity : 0);
visual.mesh.setVisible(shipVisible && context.cameraMode !== "tactical" && billboardOpacity < 0.98);
visual.icon.setVisible(shipVisible && useTacticalIcon);
const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw);
if (desiredHeading.lengthSq() > 0.01) {
@@ -135,9 +149,19 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
for (const visual of context.stationVisuals.values()) {
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
const displayPosition = context.toDisplayLocalPosition(animatedLocalPosition);
visual.mesh.setPosition(displayPosition);
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
const stationVisible = visual.systemId === context.activeSystemId;
const distToStation = context.camera.position.distanceTo(displayPosition);
const stationIconScale = THREE.MathUtils.clamp(
130,
iconWorldScale(distToStation, context.camera, STATION_ICON_MIN_PIXELS),
iconWorldScale(distToStation, context.camera, STATION_ICON_MAX_PIXELS),
);
visual.icon.setScaleScalar(stationIconScale);
visual.icon.setVisible(stationVisible);
visual.mesh.setVisible(stationVisible && renderMode === "local" && context.cameraMode !== "tactical");
}
for (const visual of context.claimVisuals.values()) {

View File

@@ -12,7 +12,7 @@ export default defineConfig({
port: 5174,
allowedHosts: ["sobina.local"],
proxy: {
"/api": "http://127.0.0.1:5079",
"/api": "http://127.0.0.1:5080",
},
},
build: {

View File

@@ -2,13 +2,13 @@
This document defines the intended combat model for the simulation.
Combat is primarily a local-space activity. It is how factions, pirates, and defenders contest access, claims, stations, and logistics.
Combat is primarily a localspace activity. It is how factions, pirates, and defenders contest access, claims, stations, and logistics.
## Design Goals
The combat model should support:
- local-space tactical fights
- localspace tactical fights
- piracy and harassment
- claim destruction and station contestation
- station defense
@@ -17,7 +17,7 @@ The combat model should support:
## Core Principles
- combat happens in `local-space`
- combat happens in `localspace`
- claims and structures are physically contestable
- piracy should target valuable traffic and vulnerable infrastructure
- stations should be defensible but not magically safe
@@ -25,7 +25,7 @@ The combat model should support:
## Combat Space
Combat belongs in `local-space`.
Combat belongs in `localspace`.
This is where entities can:
@@ -35,7 +35,7 @@ This is where entities can:
- defend stations and claims
- intercept miners, haulers, and construction support
Ships in `system-space` warp transit are not in normal tactical combat.
Ships in intra-system warp transit are not in normal tactical combat.
This keeps tactical fighting distinct from travel.
@@ -54,7 +54,7 @@ Combat should matter not only for fleet battle, but also for logistics disruptio
## Claims As Combat Targets
Claims at Lagrange points should be valid combat targets.
Claims at valid construction anchors should be valid combat targets.
That means:
@@ -94,7 +94,7 @@ Station safety should depend on actual defensive capacity, not only ownership fl
## Piracy
Pirates should be a meaningful local-space threat.
Pirates should be a meaningful localspace threat.
They should favor:
@@ -203,7 +203,7 @@ See [EVENTS.md](/home/jbourdon/repos/space-game/docs/EVENTS.md) for the combat a
The following rules should remain true unless deliberately revised:
- combat is primarily a local-space activity
- combat is primarily a localspace activity
- claims are destructible and contestable
- station construction is a vulnerable phase
- piracy should prefer valuable and vulnerable traffic
@@ -213,7 +213,7 @@ The following rules should remain true unless deliberately revised:
## Relationship To Other Documents
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
- defines where combat is allowed
- [POLICIES.md](/home/jbourdon/repos/space-game/docs/POLICIES.md)

View File

@@ -39,7 +39,7 @@ Additional specialized commanders may exist later, such as:
- fleet commander
- task-group commander
- convoy commander
- sector commander
- localspace defense commander
## Commander Entity Model
@@ -75,7 +75,7 @@ Responsibilities:
- create or retire station and fleet objectives
- respond to large-scale shortages, threats, and opportunities
The faction commander should reason mostly across `universe-space`, `galaxy-space`, and strategic `system-space`.
The faction commander should reason mostly across `universe-space`, `galaxy-space`, and strategic system-level concerns.
It should not micromanage every ship constantly.
@@ -343,7 +343,7 @@ The following rules should remain true unless there is a deliberate exception:
## Relationship To Other Documents
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
- defines where action happens
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)

View File

@@ -14,7 +14,7 @@ The data model should support:
- module-based capability
- population and workforce
- claimable construction sites
- local-space combat
- localspace combat
- scalable replication and future sharding
## Core Principles
@@ -39,8 +39,8 @@ Recommended global ID families:
- `factionId`
- `commanderId`
- `systemId`
- `nodeId`
- `bubbleId`
- `anchorId`
- `localspaceId`
- `stationId`
- `shipId`
- `moduleId`
@@ -58,8 +58,8 @@ The intended core entities are:
1. `Faction`
2. `Commander`
3. `System`
4. `Node`
5. `LocalBubble`
4. `Anchor`
5. `Localspace`
6. `Station`
7. `Ship`
8. `ModuleInstance`
@@ -108,7 +108,6 @@ Recommended commander kinds:
- `station`
- `ship`
- `fleet`
- `sector`
- `task-group`
## System
@@ -124,40 +123,37 @@ Suggested fields:
- `nodeIds`
- `faction influence later`
## Node
## Anchor
A node is a meaningful location in system space that owns a local bubble.
An anchor is a meaningful location in a system that owns a localspace.
Suggested fields:
- `nodeId`
- `anchorId`
- `systemId`
- `kind`
- `systemPosition`
- `bubbleId`
- `parentNodeId?`
- `localspaceId`
- `parentAnchorId?`
- `orbital metadata?`
- `occupyingStructureId?`
- `constructionIds?`
Recommended node kinds:
Recommended anchor kinds:
- `star`
- `planet`
- `moon`
- `lagrange-point`
- `station`
- `gate`
- `resource-site`
- `structure`
- `resource-node`
## LocalBubble
## Localspace
A local bubble is the tactical simulation context attached to one node.
A localspace is the tactical simulation context attached to one anchor.
Suggested fields:
- `bubbleId`
- `nodeId`
- `localspaceId`
- `anchorId`
- `systemId`
- `radius`
- `occupantShipIds`
@@ -168,16 +164,16 @@ Suggested fields:
## Station
A station is a structure attached to a Lagrange-point node.
A station is a constructed structure that lives inside one localspace.
Suggested fields:
- `stationId`
- `ownerFactionId`
- `commanderId?`
- `nodeId`
- `anchorId`
- `systemId`
- `bubbleId`
- `localspaceId`
- `moduleIds`
- `inventory`
- `population`
@@ -231,7 +227,7 @@ Recommended host kinds:
## Claim
A claim is a vulnerable object placed at a Lagrange point before construction.
A claim is a vulnerable object placed at a valid construction anchor before construction.
Suggested fields:
@@ -239,8 +235,8 @@ Suggested fields:
- `ownerFactionId`
- `commanderId?`
- `systemId`
- `nodeId`
- `bubbleId`
- `anchorId`
- `localspaceId`
- `placedAt`
- `activatesAt`
- `state`
@@ -261,8 +257,8 @@ Suggested fields:
- `constructionSiteId`
- `ownerFactionId`
- `nodeId`
- `bubbleId`
- `anchorId`
- `localspaceId`
- `targetKind`
- `targetDefinitionId`
- `requiredItems`
@@ -354,12 +350,12 @@ Recommended ship spatial state fields:
- `spaceLayer`
- `currentSystemId`
- `currentNodeId?`
- `currentBubbleId?`
- `currentAnchorId?`
- `currentLocalspaceId?`
- `localPosition?`
- `systemPosition?`
- `movementRegime`
- `destinationNodeId?`
- `destinationAnchorId?`
- `transitState?`
Recommended space layers:
@@ -367,7 +363,7 @@ Recommended space layers:
- `universe-space`
- `galaxy-space`
- `system-space`
- `local-space`
- `localspace`
Recommended movement regimes:
@@ -466,7 +462,7 @@ The following rules should remain true unless deliberately revised:
## Relationship To Other Documents
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
- defines the layered world structure
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)

View File

@@ -8,12 +8,12 @@ For the implementation migration path from the current codebase to this design s
## Core Documents
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
- spatial layers
- nodes and local bubbles
- movement regimes
- viewer scale expectations
- replication and interest management implications
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
- canonical world structure
- galaxy, systems, celestials, anchors, and localspaces
- ship and construction placement
- intra-system warp and inter-system FTL
- viewer hierarchy and simulation boundaries
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
- commander roles for factions, stations, and ships
@@ -59,7 +59,7 @@ For the implementation migration path from the current codebase to this design s
- policy-based behavior limits
- [COMBAT.md](/home/jbourdon/repos/space-game/docs/COMBAT.md)
- local-space combat
- localspace combat
- piracy and station defense
- claim destruction and contest
- commander-driven engagement behavior
@@ -90,7 +90,7 @@ For the implementation migration path from the current codebase to this design s
- [STATIONS.md](/home/jbourdon/repos/space-game/docs/STATIONS.md)
- station roles
- local bubble functions
- localspace functions
- station command requirements
- station services, docking, and market responsibilities

View File

@@ -254,13 +254,13 @@ Those support goods are defined as item roles in [ITEMS.md](/home/jbourdon/repos
## Market And Space
The market should respect the spatial model in [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md).
The market should respect the spatial model in [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md).
Implications:
- stations are nodes with local bubbles
- docking and transfer happen in local-space
- in-system logistics move through warp between nodes
- stations live in localspaces owned by anchors
- docking and transfer happen in localspace
- in-system logistics move through warp between anchors
- inter-system trade moves through stargates or FTL
Distance and travel friction should matter economically.

View File

@@ -34,7 +34,8 @@ Every event should conceptually have:
- `kind`
- `spaceLayer`
- `systemId?`
- `bubbleId?`
- `localspaceId?`
- `anchorId?`
- `primaryEntityKind`
- `primaryEntityId`
- `relatedEntityIds`
@@ -51,7 +52,7 @@ Examples:
- universe-scale events belong to `universe-space`
- strategic system events belong to `galaxy-space` or `system-space`
- combat, docking, and claims belong to `local-space`
- local tactical events belong to `localspace`
This is important for interest management.
@@ -95,8 +96,8 @@ These describe meaningful transitions in movement regime or location context.
Examples:
- `entered-local-bubble`
- `left-local-bubble`
- `entered-localspace`
- `left-localspace`
- `warp-spooling-started`
- `warp-started`
- `warp-arrived`
@@ -314,7 +315,7 @@ The following rules should remain true unless deliberately revised:
- [DATA-MODEL.md](/home/jbourdon/repos/space-game/docs/DATA-MODEL.md)
- defines the entities referenced by events
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
- defines event scope by space layer
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)

View File

@@ -103,7 +103,7 @@ This is especially important because your station model is configuration-based r
## Structural Rule
Stations are built at one Lagrange point and expanded by adding modules.
Stations are built at one valid construction anchor and expanded by adding modules.
That means modules are the main axis of station growth.

View File

@@ -79,9 +79,9 @@ Docking policy matters because no trade or transfer can happen without actual lo
Construction rights should be policy-relevant at the faction and system level.
This does not override the hard structural rule from [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md):
This does not override the hard structural rule from [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md):
- one structure per Lagrange point
- construction is only valid at supported construction anchors
But it does help define who is permitted or tolerated to claim and build in a given system.
@@ -149,13 +149,13 @@ Examples:
- friendly factions may trade and dock
- neutral factions may trade but not build
- hostile factions may be denied docking and targeted in local-space
- hostile factions may be denied docking and targeted in localspace
The exact diplomacy system can evolve later, but policy should be ready to consume those relationship states.
## Policy And Claims
Claim objects at Lagrange points are visible and contestable.
Claim objects at valid construction anchors are visible and contestable.
Policy influences:
@@ -186,7 +186,7 @@ The following rules should remain true unless deliberately revised:
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
- policy constrains trade participation
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
- policy interacts with claims, systems, and local access
- [STATIONS.md](/home/jbourdon/repos/space-game/docs/STATIONS.md)

View File

@@ -182,7 +182,7 @@ It should:
- allow delivery by traders or support ships
- feed the actual building process
This is especially important during station founding at claimed Lagrange points.
This is especially important during station founding at claimed construction anchors.
## Production Queues

View File

@@ -26,7 +26,7 @@ The current simulation behavior is driven mostly by:
This gives the project a working prototype, but it does not yet reflect the design in:
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
- [WORKFORCE.md](/home/jbourdon/repos/space-game/docs/WORKFORCE.md)
@@ -53,11 +53,11 @@ Target design:
- `universe-space`
- `galaxy-space`
- `system-space`
- `local-space`
- node-attached local bubbles
- one structure per Lagrange point
- warp between nodes
- local gameplay inside bubbles
- `localspace`
- anchor-owned localspaces
- construction only at valid construction anchors
- warp between anchors within one system
- local gameplay inside localspaces
Current state:
@@ -66,16 +66,16 @@ Current state:
- resource nodes exist, but only as extractable asteroid/gas sites
- stations are positioned directly in system coordinates
- ships move by `Position` and `TargetPosition`
- no first-class system node graph
- no first-class local bubbles
- no first-class anchor graph
- no first-class localspaces
- no claim entities
- no construction-site entities
Primary gaps:
- [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs) has no `NodeRuntime`, `LocalBubbleRuntime`, `ClaimRuntime`, or `ConstructionSiteRuntime`.
- [`ScenarioLoader.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/ScenarioLoader.cs) computes station positions directly instead of creating node-backed placement.
- [`SimulationEngine.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs) still treats travel as raw coordinate movement rather than node-to-node transit between spaces.
- [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs) has no `AnchorRuntime`, `LocalspaceRuntime`, `ClaimRuntime`, or `ConstructionSiteRuntime`.
- [`ScenarioLoader.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/ScenarioLoader.cs) computes station positions directly instead of creating anchor-backed placement.
- [`SimulationEngine.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs) still treats travel as raw coordinate movement rather than anchor-to-anchor transit between spaces.
### Command and Control
@@ -202,12 +202,12 @@ Current state:
- viewer consumes a flat snapshot of systems, resource nodes, stations, ships, and factions
- systems are strategic and visual, but not tied to explicit multi-space simulation layers
- no contracts for bubbles, claims, construction sites, market orders, commanders, or policies
- no contracts for localspaces, claims, construction sites, market orders, commanders, or policies
Primary gaps:
- [`contracts.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/contracts.ts) mirrors the old backend model.
- [`GameViewer.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts) cannot render node graph, local bubbles, claims, or construction states because that data does not exist yet.
- [`GameViewer.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts) cannot render an anchor graph, localspaces, claims, or construction states because that data does not exist yet.
## Subsystem Assessment
@@ -226,7 +226,7 @@ These should evolve in place.
- string-based ship state with explicit enums and structured movement state
- ship-only planning fields with commander/task-layer entities
- direct station placement with node, claim, and construction-site placement
- direct station placement with anchor, claim, and construction-site placement
- flat event records with typed event payloads
### Avoid
@@ -246,8 +246,8 @@ Goal:
Work:
- extend [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs) with:
- `NodeRuntime`
- `LocalBubbleRuntime`
- `AnchorRuntime`
- `LocalspaceRuntime`
- `CommanderRuntime`
- `ClaimRuntime`
- `ConstructionSiteRuntime`
@@ -261,33 +261,33 @@ Work:
- commander kinds
- add structured ship spatial state:
- current space layer
- current node
- current bubble
- current anchor
- current localspace
- current transit
Why first:
- every later change depends on this vocabulary existing in runtime state
### Phase 2: Refactor Scenario and World Building Around Nodes
### Phase 2: Refactor Scenario and World Building Around Anchors
Goal:
- make systems produce a real node graph with local bubbles and Lagrange-backed construction points
- make systems produce a real anchor graph with localspaces and supported construction anchors
Work:
- extend [`WorldDefinitions.cs`](/home/jbourdon/repos/space-game/apps/backend/Data/WorldDefinitions.cs) with authored node and claim-related definitions only where necessary
- extend [`WorldDefinitions.cs`](/home/jbourdon/repos/space-game/apps/backend/Data/WorldDefinitions.cs) with authored anchor and claim-related definitions only where necessary
- update [`ScenarioLoader.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/ScenarioLoader.cs) to:
- generate system-space nodes for stars, planets, moons, stations, and Lagrange points
- attach local bubbles to nodes
- translate existing `planetIndex` and `lagrangeSide` station hints into actual node IDs
- generate system-space anchors for stars, planets, moons, Lagrange points, and resource nodes where appropriate
- attach localspaces to anchors
- translate existing `planetIndex` and `lagrangeSide` station hints into actual anchor IDs
- stop treating station placement as an arbitrary coordinate
- preserve current authored content while migrating scenario interpretation
Why second:
- movement, claims, construction, and viewer transitions all depend on real nodes
- movement, claims, construction, and viewer transitions all depend on real anchors
### Phase 3: Introduce Founding, Claims, and Construction Sites
@@ -303,7 +303,7 @@ Work:
- active
- destroyed
- add construction-site runtime state with:
- target node
- target anchor
- blueprint or constructible reference
- construction storage inventory
- construction buy orders
@@ -315,7 +315,7 @@ Work:
Why here:
- it lets the code start reflecting the station and Lagrange rules without requiring the full economy rewrite first
- it lets the code start reflecting the construction-anchor rules without requiring the full economy rewrite first
### Phase 4: Replace Raw Travel With Space-Aware Movement
@@ -326,8 +326,8 @@ Goal:
Work:
- replace direct long-range movement with:
- local thruster movement inside a bubble
- in-system warp between nodes
- local thruster movement inside a localspace
- in-system warp between anchors
- inter-system transit through gates or FTL
- update ship runtime in [`RuntimeModels.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs)
- split movement logic in [`SimulationEngine.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs) into clearer controllers or subsystems
@@ -335,7 +335,7 @@ Work:
Why here:
- this is the point where [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md) starts becoming real in the simulation
- this is the point where [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md) starts becoming real in the simulation
### Phase 5: Introduce Commanders and Task Layers
@@ -376,7 +376,7 @@ Work:
Why here:
- once commanders and nodes exist, this becomes a coherent system instead of isolated resource transfers
- once commanders and anchors exist, this becomes a coherent system instead of isolated resource transfers
### Phase 7: Upgrade Events and Streaming
@@ -391,7 +391,7 @@ Work:
- universe
- galaxy
- system
- local bubble
- localspace
- refactor [`WorldService.cs`](/home/jbourdon/repos/space-game/apps/backend/Simulation/WorldService.cs) so subscriptions are observer-scoped rather than globally broadcast
- support higher-space streaming with filtering into lower-space views
@@ -408,8 +408,8 @@ Goal:
Work:
- extend [`contracts.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/contracts.ts) for:
- nodes
- local bubbles
- anchors
- localspaces
- claims
- construction sites
- market orders
@@ -417,8 +417,8 @@ Work:
- richer ship movement state
- update [`GameViewer.ts`](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts) to support:
- galaxy/system/local scale transitions
- node-centric system view
- local bubble detail
- anchor-centric system view
- localspace detail
- regime-aware ship rendering
Why last:

View File

@@ -2,7 +2,7 @@
This document defines the intended role of ships in the simulation.
Ships are mobile actors that operate across the spatial model in [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md) under the authority of commanders defined in [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md).
Ships are mobile actors that operate across the spatial model in [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md) under the authority of commanders defined in [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md).
## Core Principles
@@ -35,8 +35,8 @@ From a simulation point of view, the important distinction is not only hull cate
Ships should move according to the layered spatial model:
- thrusters in `local-space`
- warp in `system-space`
- thrusters inside a `localspace`
- warp between anchors inside one system
- stargate or FTL for inter-system travel
Ships should not behave as if an entire system is one continuous local dogfighting field.
@@ -82,7 +82,7 @@ Those capabilities should primarily come from fitted modules as described in [MO
## Relationship To Other Documents
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
- [STATIONS.md](/home/jbourdon/repos/space-game/docs/STATIONS.md)

View File

@@ -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).

View File

@@ -2,7 +2,7 @@
This document defines the intended role of stations in the simulation.
Stations are persistent nodes in the world that own local bubbles, provide services, participate in the economy, and act through station commanders.
Stations are persistent constructions in the world that live in a localspace, provide services, participate in the economy, and act through station commanders.
## Core Principles
@@ -17,7 +17,7 @@ A station lives at one location.
It does not spread across multiple construction points.
Station growth happens by adding modules to the existing station at its current Lagrange point.
Station growth happens by adding modules to the existing station at its current construction location.
To create another station, a faction needs:
@@ -25,7 +25,7 @@ To create another station, a faction needs:
- another station
- another station commander
Before station construction itself, the faction should also secure the site through the Lagrange-point claim process defined in [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md).
Before station construction itself, the faction should secure the chosen site through the appropriate local claim or founding process defined by the universe model.
Station identity and capability should primarily come from module composition, as described in [MODULES.md](/home/jbourdon/repos/space-game/docs/MODULES.md).
@@ -44,13 +44,15 @@ Stations may serve as:
## Spatial Role
Stations are nodes in `system-space` with their own `local-space`.
Stations are constructions that exist inside one `localspace`.
Stations and other built structures should always be located at Lagrange points.
Stations may be built at valid construction anchors such as:
Each Lagrange point can hold only one structure.
- planets
- moons
- Lagrange points
Their local bubbles are where:
Their localspaces are where:
- docking happens
- cargo transfer happens
@@ -58,13 +60,13 @@ Their local bubbles are where:
- local construction happens
- player and AI interaction happens
Local-space defense and contestation are described further in [COMBAT.md](/home/jbourdon/repos/space-game/docs/COMBAT.md).
Localspace defense and contestation are described further in [COMBAT.md](/home/jbourdon/repos/space-game/docs/COMBAT.md).
## Founding A Station
The intended founding flow is:
1. claim a valid Lagrange point
1. claim or secure a valid construction anchor
2. wait for the claim to activate
3. create station construction storage
4. publish buy orders for the required construction materials
@@ -141,15 +143,9 @@ Actual participation in trade and docking should still be policy-controlled. See
Station ownership is not local-bubble exclusive.
The important hard rule is:
Ownership does not imply one faction per localspace.
- one structure per Lagrange point
Not:
- one faction per local bubble
This means friendly or otherwise permitted factions may build stations within the same system, so long as they use different valid locations.
Friendly or otherwise permitted factions may build stations within the same system so long as they use different valid construction locations.
## Services
@@ -181,7 +177,7 @@ Population growth and decline follow the rules in [WORKFORCE.md](/home/jbourdon/
## Relationship To Other Documents
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
- [ECONOMY.md](/home/jbourdon/repos/space-game/docs/ECONOMY.md)
- [SHIPS.md](/home/jbourdon/repos/space-game/docs/SHIPS.md)

View File

@@ -55,12 +55,12 @@ Orders should survive replanning better than tasks.
Examples:
- travel to node
- travel to anchor
- dock at station
- claim Lagrange point
- claim construction anchor
- build station here
- escort this ship
- defend this bubble
- defend this localspace
Orders are the main override layer above routine autonomous behavior.
@@ -240,13 +240,13 @@ This prevents autonomous loops from becoming self-destructive.
## Space-Aware Tasking
Tasks should respect the spatial model in [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md).
Tasks should respect the spatial model in [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md).
That means:
- local maneuvering is distinct from warp
- docking is local-space work
- cargo transfer is local-space work
- docking is localspace work
- cargo transfer is localspace work
- inter-system travel is distinct from in-system travel
## Policy-Aware Tasking
@@ -305,7 +305,7 @@ The following rules should remain true unless deliberately revised:
- [COMMANDERS.md](/home/jbourdon/repos/space-game/docs/COMMANDERS.md)
- defines who decides and delegates
- [SPACES.md](/home/jbourdon/repos/space-game/docs/SPACES.md)
- [UNIVERSE-MODEL.md](/home/jbourdon/repos/space-game/docs/UNIVERSE-MODEL.md)
- defines where tasks can occur
- [POLICIES.md](/home/jbourdon/repos/space-game/docs/POLICIES.md)

501
docs/UNIVERSE-MODEL.md Normal file
View 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
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
container_name="${SPACEGAME_POSTGRES_CONTAINER:-space-game-postgres}"
image_name="${SPACEGAME_POSTGRES_IMAGE:-postgres:16}"
host_port="${SPACEGAME_POSTGRES_PORT:-5432}"
database_name="${SPACEGAME_POSTGRES_DB:-spacegame}"
database_user="${SPACEGAME_POSTGRES_USER:-spacegame}"
database_password="${SPACEGAME_POSTGRES_PASSWORD:-spacegame}"
if ! command -v docker >/dev/null 2>&1; then
echo "docker is required but was not found in PATH." >&2
exit 1
fi
existing_container_id="$(docker ps -aq -f "name=^${container_name}$")"
if [[ -z "${existing_container_id}" ]]; then
echo "Creating Postgres container '${container_name}'..."
docker run -d \
--name "${container_name}" \
-e POSTGRES_DB="${database_name}" \
-e POSTGRES_USER="${database_user}" \
-e POSTGRES_PASSWORD="${database_password}" \
-p "${host_port}:5432" \
"${image_name}" >/dev/null
echo "Postgres container created and started."
else
running_container_id="$(docker ps -q -f "name=^${container_name}$")"
if [[ -n "${running_container_id}" ]]; then
echo "Postgres container '${container_name}' is already running."
else
echo "Starting Postgres container '${container_name}'..."
docker start "${container_name}" >/dev/null
echo "Postgres container started."
fi
fi
echo "Connection string:"
echo "Host=127.0.0.1;Port=${host_port};Database=${database_name};Username=${database_user};Password=${database_password}"

View File

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