Add player onboarding and tactical viewer updates
This commit is contained in:
22
apps/backend/Auth/Api/GetRacesHandler.cs
Normal file
22
apps/backend/Auth/Api/GetRacesHandler.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using FastEndpoints;
|
||||
using SpaceGame.Api.Universe.Bootstrap;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Api;
|
||||
|
||||
public sealed class GetRacesHandler(IStaticDataProvider staticData) : EndpointWithoutRequest<IReadOnlyList<RaceSnapshot>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/auth/races");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var races = staticData.RaceDefinitions.Values
|
||||
.OrderBy(race => race.Name, StringComparer.Ordinal)
|
||||
.Select(race => new RaceSnapshot(race.Id, race.Name, race.Description, race.Icon))
|
||||
.ToList();
|
||||
await SendOkAsync(races, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Api;
|
||||
|
||||
public sealed class RegisterHandler(AuthService authService) : Endpoint<RegisterRequest, AuthSessionResponse>
|
||||
public sealed class RegisterHandler(AuthService authService) : Endpoint<RegisterRequest, RegisterResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
|
||||
@@ -37,6 +37,11 @@ public sealed record AuthSessionResponse(
|
||||
string RefreshToken,
|
||||
DateTimeOffset RefreshTokenExpiresAtUtc);
|
||||
|
||||
public sealed record RegisterResponse(
|
||||
Guid UserId,
|
||||
string Email,
|
||||
bool RequiresLogin);
|
||||
|
||||
public sealed record ForgotPasswordResponse(
|
||||
bool Accepted,
|
||||
string? ResetToken = null);
|
||||
|
||||
7
apps/backend/Auth/Contracts/Races.cs
Normal file
7
apps/backend/Auth/Contracts/Races.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace SpaceGame.Api.Auth.Contracts;
|
||||
|
||||
public sealed record RaceSnapshot(
|
||||
string Id,
|
||||
string Name,
|
||||
string Description,
|
||||
string Icon);
|
||||
@@ -7,7 +7,7 @@ public sealed class AuthService(
|
||||
RefreshTokenFactory refreshTokenFactory,
|
||||
IPasswordResetDelivery passwordResetDelivery)
|
||||
{
|
||||
public async Task<AuthSessionResponse> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
|
||||
public async Task<RegisterResponse> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = NormalizeEmail(request.Email);
|
||||
ValidatePassword(request.Password);
|
||||
@@ -18,7 +18,7 @@ public sealed class AuthService(
|
||||
}
|
||||
|
||||
var user = await authRepository.CreateUserAsync(email, passwordHasher.HashPassword(request.Password), [], cancellationToken);
|
||||
return await CreateSessionAsync(user, cancellationToken);
|
||||
return new RegisterResponse(user.Id, user.Email, true);
|
||||
}
|
||||
|
||||
public async Task<AuthSessionResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpContextAccessor) : IPlayerIdentityResolver
|
||||
{
|
||||
public const string EffectivePlayerHeaderName = "X-Act-As-Player-Id";
|
||||
|
||||
public Guid? GetCurrentPlayerId()
|
||||
{
|
||||
var subject = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
@@ -15,6 +17,21 @@ public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpC
|
||||
public Guid GetRequiredPlayerId() =>
|
||||
GetCurrentPlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required.");
|
||||
|
||||
public Guid? GetEffectivePlayerId()
|
||||
{
|
||||
var currentPlayerId = GetCurrentPlayerId();
|
||||
if (!CanAccessGm())
|
||||
{
|
||||
return currentPlayerId;
|
||||
}
|
||||
|
||||
var requestedIdentity = httpContextAccessor.HttpContext?.Request.Headers[EffectivePlayerHeaderName].FirstOrDefault();
|
||||
return Guid.TryParse(requestedIdentity, out var effectivePlayerId) ? effectivePlayerId : currentPlayerId;
|
||||
}
|
||||
|
||||
public Guid GetRequiredEffectivePlayerId() =>
|
||||
GetEffectivePlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required.");
|
||||
|
||||
public bool CanAccessGm()
|
||||
{
|
||||
var user = httpContextAccessor.HttpContext?.User;
|
||||
|
||||
@@ -4,6 +4,7 @@ public interface IAuthRepository
|
||||
{
|
||||
Task<UserAccount?> FindUserByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
Task<UserAccount?> FindUserByIdAsync(Guid userId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<UserAccount>> ListUsersAsync(CancellationToken cancellationToken);
|
||||
Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
|
||||
Task<UserAccount> UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
|
||||
Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken);
|
||||
|
||||
@@ -4,5 +4,7 @@ public interface IPlayerIdentityResolver
|
||||
{
|
||||
Guid? GetCurrentPlayerId();
|
||||
Guid GetRequiredPlayerId();
|
||||
Guid? GetEffectivePlayerId();
|
||||
Guid GetRequiredEffectivePlayerId();
|
||||
bool CanAccessGm();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,23 @@ public sealed class PostgresAuthRepository(NpgsqlDataSource dataSource) : IAuthR
|
||||
return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserAccount>> ListUsersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
select id, email, password_hash, created_at_utc, roles
|
||||
from auth_users
|
||||
order by email asc
|
||||
""");
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
var users = new List<UserAccount>();
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
users.Add(ReadUser(reader));
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
public async Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class CompletePlayerOnboardingHandler(WorldService worldService) : Endpoint<CompletePlayerOnboardingRequest, PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/player-faction/onboarding");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CompletePlayerOnboardingRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = worldService.CompletePlayerOnboarding(request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(cancellation: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
74
apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs
Normal file
74
apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using FastEndpoints;
|
||||
using SpaceGame.Api.Auth.Runtime;
|
||||
using SpaceGame.Api.Auth.Simulation;
|
||||
using SpaceGame.Api.PlayerFaction.Simulation;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class GetPlayerIdentitiesHandler(IAuthRepository authRepository, IPlayerStateStore playerStateStore)
|
||||
: EndpointWithoutRequest<IReadOnlyList<PlayerIdentitySummaryResponse>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/player-faction/identities");
|
||||
Policies(AuthPolicyNames.GmAccess);
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var users = await authRepository.ListUsersAsync(cancellationToken);
|
||||
var playerFactionsById = playerStateStore.GetPlayerFactions()
|
||||
.ToDictionary(player => player.Id, StringComparer.Ordinal);
|
||||
|
||||
var responses = new List<PlayerIdentitySummaryResponse>(users.Count + playerFactionsById.Count);
|
||||
var seenIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
var userId = user.Id.ToString("N");
|
||||
playerFactionsById.TryGetValue(userId, out var playerFaction);
|
||||
responses.Add(new PlayerIdentitySummaryResponse(
|
||||
userId,
|
||||
user.Email,
|
||||
user.Roles,
|
||||
playerFaction is not null,
|
||||
playerFaction?.Id,
|
||||
playerFaction?.Label,
|
||||
playerFaction?.SovereignFactionId));
|
||||
seenIds.Add(userId);
|
||||
}
|
||||
|
||||
foreach (var playerFaction in playerStateStore.GetPlayerFactions())
|
||||
{
|
||||
if (!seenIds.Add(playerFaction.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
responses.Add(new PlayerIdentitySummaryResponse(
|
||||
playerFaction.Id,
|
||||
$"{playerFaction.Id}@unknown",
|
||||
Array.Empty<string>(),
|
||||
true,
|
||||
playerFaction.Id,
|
||||
playerFaction.Label,
|
||||
playerFaction.SovereignFactionId));
|
||||
}
|
||||
|
||||
await SendOkAsync(
|
||||
responses
|
||||
.OrderBy(response => response.Email, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(response => response.UserId, StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PlayerIdentitySummaryResponse(
|
||||
string UserId,
|
||||
string Email,
|
||||
IReadOnlyList<string> Roles,
|
||||
bool HasPlayerFaction,
|
||||
string? PlayerFactionId,
|
||||
string? PlayerFactionLabel,
|
||||
string? SovereignFactionId);
|
||||
@@ -249,7 +249,10 @@ public sealed record PlayerAlertSnapshot(
|
||||
public sealed record PlayerFactionSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string? PersonaName,
|
||||
string? RaceId,
|
||||
string SovereignFactionId,
|
||||
bool RequiresOnboarding,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
namespace SpaceGame.Api.PlayerFaction.Contracts;
|
||||
|
||||
public sealed record CompletePlayerOnboardingRequest(
|
||||
string Name,
|
||||
string RaceId);
|
||||
|
||||
public sealed record PlayerOrganizationCommandRequest(
|
||||
string Kind,
|
||||
string Label,
|
||||
|
||||
@@ -6,7 +6,10 @@ public sealed class PlayerFactionRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string? PersonaName { get; set; }
|
||||
public string? RaceId { get; set; }
|
||||
public required string SovereignFactionId { get; set; }
|
||||
public bool RequiresOnboarding { get; set; } = true;
|
||||
public string Status { get; set; } = "active";
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -12,7 +12,10 @@ public sealed class PlayerFactionProjectionService
|
||||
return new PlayerFactionSnapshot(
|
||||
player.Id,
|
||||
player.Label,
|
||||
player.PersonaName,
|
||||
player.RaceId,
|
||||
player.SovereignFactionId,
|
||||
player.RequiresOnboarding,
|
||||
player.Status,
|
||||
player.CreatedAtUtc,
|
||||
player.UpdatedAtUtc,
|
||||
|
||||
@@ -20,14 +20,12 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
|
||||
{
|
||||
var sovereignFaction = world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).FirstOrDefault()
|
||||
?? throw new InvalidOperationException("Cannot create a player faction domain without any factions in the world.");
|
||||
|
||||
var player = playerStateStore.GetOrAddPlayerFaction(playerId, () => new PlayerFactionRuntime
|
||||
{
|
||||
Id = PlayerFactionDomainId,
|
||||
Label = $"{sovereignFaction.Label} Command",
|
||||
SovereignFactionId = sovereignFaction.Id,
|
||||
Label = "Pending Pilot",
|
||||
SovereignFactionId = string.Empty,
|
||||
RequiresOnboarding = true,
|
||||
CreatedAtUtc = world.GeneratedAtUtc,
|
||||
UpdatedAtUtc = world.GeneratedAtUtc,
|
||||
});
|
||||
@@ -37,6 +35,58 @@ internal sealed class PlayerFactionService
|
||||
return player;
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime CompleteOnboarding(
|
||||
SimulationWorld world,
|
||||
IPlayerStateStore playerStateStore,
|
||||
string playerId,
|
||||
CompletePlayerOnboardingRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
if (!player.RequiresOnboarding)
|
||||
{
|
||||
throw new InvalidOperationException("Player onboarding has already been completed.");
|
||||
}
|
||||
|
||||
var personaName = request.Name.Trim();
|
||||
if (personaName.Length < 2)
|
||||
{
|
||||
throw new InvalidOperationException("Player name must contain at least 2 characters.");
|
||||
}
|
||||
|
||||
if (personaName.Length > 48)
|
||||
{
|
||||
throw new InvalidOperationException("Player name must contain at most 48 characters.");
|
||||
}
|
||||
|
||||
var ownedFactionId = BuildOwnedFactionId(playerId);
|
||||
if (world.Factions.Any(faction => string.Equals(faction.Id, ownedFactionId, StringComparison.Ordinal)))
|
||||
{
|
||||
throw new InvalidOperationException($"Player faction '{ownedFactionId}' already exists in the current world.");
|
||||
}
|
||||
|
||||
player.Label = personaName;
|
||||
player.PersonaName = personaName;
|
||||
player.RaceId = request.RaceId.Trim();
|
||||
player.SovereignFactionId = ownedFactionId;
|
||||
player.RequiresOnboarding = false;
|
||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
return player;
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime EnsureInitializedDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
if (player.RequiresOnboarding || string.IsNullOrWhiteSpace(player.SovereignFactionId))
|
||||
{
|
||||
throw new InvalidOperationException("Player onboarding must be completed before issuing gameplay commands.");
|
||||
}
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
internal static string BuildOwnedFactionId(string playerId) =>
|
||||
$"player-{playerId.Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant()}";
|
||||
|
||||
internal void Update(SimulationWorld world, IPlayerStateStore playerStateStore, float _deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
if (playerStateStore.GetPlayerFactions().Count == 0)
|
||||
@@ -63,7 +113,7 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerOrganizationCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player));
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -180,7 +230,7 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
RemoveOrganization(player, organizationId);
|
||||
player.Assignments.RemoveAll(assignment =>
|
||||
assignment.FleetId == organizationId ||
|
||||
@@ -198,7 +248,7 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId, PlayerOrganizationMembershipCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var kind = ResolveOrganizationKind(player, organizationId);
|
||||
switch (kind)
|
||||
{
|
||||
@@ -249,7 +299,7 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? directiveId, PlayerDirectiveCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var directive = directiveId is null
|
||||
? null
|
||||
: player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal));
|
||||
@@ -326,7 +376,7 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string directiveId)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
player.Directives.RemoveAll(directive => directive.Id == directiveId);
|
||||
foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId))
|
||||
{
|
||||
@@ -340,7 +390,7 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? policyId, PlayerPolicyCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var policy = policyId is null
|
||||
? null
|
||||
: player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal));
|
||||
@@ -411,7 +461,7 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var policy = automationPolicyId is null
|
||||
? null
|
||||
: player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal));
|
||||
@@ -469,7 +519,7 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var policy = reinforcementPolicyId is null
|
||||
? null
|
||||
: player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal));
|
||||
@@ -503,7 +553,7 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? productionProgramId, PlayerProductionProgramCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var program = productionProgramId is null
|
||||
? null
|
||||
: player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal));
|
||||
@@ -535,7 +585,7 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string assetId, PlayerAssetAssignmentCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var assignment = player.Assignments.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) &&
|
||||
string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal));
|
||||
@@ -594,7 +644,7 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerStrategicIntentCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
player.StrategicIntent.StrategicPosture = request.StrategicPosture;
|
||||
player.StrategicIntent.EconomicPosture = request.EconomicPosture;
|
||||
player.StrategicIntent.MilitaryPosture = request.MilitaryPosture;
|
||||
@@ -610,7 +660,7 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipOrderCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||
{
|
||||
return null;
|
||||
@@ -669,7 +719,7 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||
{
|
||||
return null;
|
||||
@@ -712,7 +762,7 @@ internal sealed class PlayerFactionService
|
||||
|
||||
internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipDefaultBehaviorCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||
{
|
||||
return null;
|
||||
@@ -852,6 +902,24 @@ internal sealed class PlayerFactionService
|
||||
|
||||
private static void SyncRegistry(SimulationWorld world, PlayerFactionRuntime player)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(player.SovereignFactionId))
|
||||
{
|
||||
SyncSet(player.AssetRegistry.ShipIds, []);
|
||||
SyncSet(player.AssetRegistry.StationIds, []);
|
||||
SyncSet(player.AssetRegistry.CommanderIds, []);
|
||||
SyncSet(player.AssetRegistry.ClaimIds, []);
|
||||
SyncSet(player.AssetRegistry.ConstructionSiteIds, []);
|
||||
SyncSet(player.AssetRegistry.PolicySetIds, player.Policies.Where(entry => entry.PolicySetId is not null).Select(entry => entry.PolicySetId!));
|
||||
SyncSet(player.AssetRegistry.MarketOrderIds, []);
|
||||
SyncSet(player.AssetRegistry.FleetIds, player.Fleets.Select(fleet => fleet.Id));
|
||||
SyncSet(player.AssetRegistry.TaskForceIds, player.TaskForces.Select(taskForce => taskForce.Id));
|
||||
SyncSet(player.AssetRegistry.StationGroupIds, player.StationGroups.Select(group => group.Id));
|
||||
SyncSet(player.AssetRegistry.EconomicRegionIds, player.EconomicRegions.Select(region => region.Id));
|
||||
SyncSet(player.AssetRegistry.FrontIds, player.Fronts.Select(front => front.Id));
|
||||
SyncSet(player.AssetRegistry.ReserveIds, player.Reserves.Select(reserve => reserve.Id));
|
||||
return;
|
||||
}
|
||||
|
||||
SyncSet(player.AssetRegistry.ShipIds, world.Ships.Where(ship => ship.FactionId == player.SovereignFactionId).Select(ship => ship.Id));
|
||||
SyncSet(player.AssetRegistry.StationIds, world.Stations.Where(station => station.FactionId == player.SovereignFactionId).Select(station => station.Id));
|
||||
SyncSet(player.AssetRegistry.CommanderIds, world.Commanders.Where(commander => commander.FactionId == player.SovereignFactionId).Select(commander => commander.Id));
|
||||
@@ -1224,8 +1292,7 @@ internal sealed class PlayerFactionService
|
||||
return player.AutomationPolicies.FirstOrDefault(policy => policy.Id == automationId);
|
||||
}
|
||||
|
||||
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId)
|
||||
?? player.AutomationPolicies.FirstOrDefault(policy => policy.Id == "player-core-automation");
|
||||
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId);
|
||||
}
|
||||
|
||||
private static PlayerFactionPolicyRuntime? ResolvePolicy(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string assetId)
|
||||
|
||||
@@ -90,7 +90,8 @@ public sealed class ScenarioContentBuilder(
|
||||
powerModuleId,
|
||||
plan.FactionId,
|
||||
staticData.ModuleDefinitions,
|
||||
staticData.ItemDefinitions)
|
||||
staticData.ItemDefinitions,
|
||||
staticData.Recipes)
|
||||
.FirstOrDefault(moduleId =>
|
||||
{
|
||||
return staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
|
||||
@@ -117,7 +118,8 @@ public sealed class ScenarioContentBuilder(
|
||||
objectiveModuleId,
|
||||
plan.FactionId,
|
||||
staticData.ModuleDefinitions,
|
||||
staticData.ItemDefinitions))
|
||||
staticData.ItemDefinitions,
|
||||
staticData.Recipes))
|
||||
{
|
||||
EnsureStartingModule(startingModules, storageModuleId);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ internal static class StarterStationLayoutResolver
|
||||
string moduleId,
|
||||
string? factionId,
|
||||
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
||||
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
|
||||
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions,
|
||||
IReadOnlyDictionary<string, RecipeDefinition> recipes)
|
||||
{
|
||||
if (!moduleDefinitions.TryGetValue(moduleId, out var moduleDefinition))
|
||||
{
|
||||
@@ -40,6 +41,10 @@ internal static class StarterStationLayoutResolver
|
||||
foreach (var wareId in moduleDefinition.BuildRecipes
|
||||
.SelectMany(production => production.Wares.Select(ware => ware.ItemId))
|
||||
.Concat(moduleDefinition.ProductItemIds)
|
||||
.Concat(recipes.Values
|
||||
.Where(recipe => recipe.RequiredModules.Contains(moduleId, StringComparer.Ordinal))
|
||||
.SelectMany(recipe => recipe.Inputs.Select(input => input.ItemId)
|
||||
.Concat(recipe.Outputs.Select(output => output.ItemId))))
|
||||
.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (!itemDefinitions.TryGetValue(wareId, out var itemDefinition))
|
||||
|
||||
@@ -201,7 +201,8 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData)
|
||||
objectiveModuleId,
|
||||
station.FactionId,
|
||||
world.ModuleDefinitions,
|
||||
world.ItemDefinitions))
|
||||
world.ItemDefinitions,
|
||||
world.Recipes))
|
||||
{
|
||||
if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal))
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace SpaceGame.Api.Universe.Simulation;
|
||||
public sealed class WorldService
|
||||
{
|
||||
private const int DeltaHistoryLimit = 256;
|
||||
private const string StarterPlayerShipId = "ship_arg_s_scout_01_a";
|
||||
|
||||
private readonly Lock _sync = new();
|
||||
private readonly OrbitalSimulationSnapshot _orbitalSimulation;
|
||||
@@ -148,11 +149,6 @@ public sealed class WorldService
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_world.Factions.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var playerKey = GetCurrentPlayerKey();
|
||||
var player = _playerFaction.TryGetDomain(_playerStateStore, playerKey)
|
||||
?? _playerFaction.EnsureDomain(_world, _playerStateStore, playerKey);
|
||||
@@ -160,6 +156,26 @@ public sealed class WorldService
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? CompletePlayerOnboarding(CompletePlayerOnboardingRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_staticData.RaceDefinitions.TryGetValue(request.RaceId.Trim(), out var race))
|
||||
{
|
||||
throw new InvalidOperationException($"Race '{request.RaceId}' is not defined in static data.");
|
||||
}
|
||||
|
||||
var playerKey = GetCurrentPlayerKey();
|
||||
var player = _playerFaction.CompleteOnboarding(_world, _playerStateStore, playerKey, request);
|
||||
var playerFaction = CreatePlayerOwnedFactionUnsafe(player, race);
|
||||
var starterSystemId = ResolveStarterSystemIdUnsafe();
|
||||
SpawnPlayerStarterShipUnsafe(playerFaction, starterSystemId);
|
||||
_playerFaction.EnsureInitializedDomain(_world, _playerStateStore, playerKey);
|
||||
PublishSnapshotRefreshUnsafe("player-onboarding", $"Initialized player {player.PersonaName}", "faction", playerFaction.Id);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? CreatePlayerOrganization(PlayerOrganizationCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
@@ -530,7 +546,102 @@ public sealed class WorldService
|
||||
private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() =>
|
||||
_playerFactionProjection.ToSnapshot(_playerFaction.TryGetDomain(_playerStateStore, GetCurrentPlayerKey()));
|
||||
|
||||
private string GetCurrentPlayerKey() => _playerIdentityResolver.GetRequiredPlayerId().ToString("N");
|
||||
private FactionRuntime CreatePlayerOwnedFactionUnsafe(PlayerFactionRuntime player, RaceDefinition race)
|
||||
{
|
||||
var playerFaction = new FactionRuntime
|
||||
{
|
||||
Id = player.SovereignFactionId,
|
||||
Label = player.PersonaName ?? player.Label,
|
||||
Color = ResolvePlayerFactionColor(race.Id),
|
||||
Credits = 25000f,
|
||||
};
|
||||
|
||||
_world.Factions.Add(playerFaction);
|
||||
|
||||
var policy = _worldSeedingService.CreatePolicies([playerFaction]).Single();
|
||||
var templateFaction = _staticData.FactionDefinitions.Values
|
||||
.Where(candidate => string.Equals(candidate.RaceId, race.Id, StringComparison.Ordinal))
|
||||
.OrderBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.Select(candidate => _world.Factions.FirstOrDefault(worldFaction => string.Equals(worldFaction.Id, candidate.Id, StringComparison.Ordinal)))
|
||||
.FirstOrDefault(candidate => candidate is not null);
|
||||
if (templateFaction?.DefaultPolicySetId is { } racePolicyId
|
||||
&& _world.Policies.FirstOrDefault(candidate => candidate.Id == racePolicyId) is { } racePolicy)
|
||||
{
|
||||
policy.TradeAccessPolicy = racePolicy.TradeAccessPolicy;
|
||||
policy.DockingAccessPolicy = racePolicy.DockingAccessPolicy;
|
||||
policy.ConstructionAccessPolicy = racePolicy.ConstructionAccessPolicy;
|
||||
policy.OperationalRangePolicy = racePolicy.OperationalRangePolicy;
|
||||
policy.CombatEngagementPolicy = racePolicy.CombatEngagementPolicy;
|
||||
policy.FleeHullRatio = racePolicy.FleeHullRatio;
|
||||
policy.AvoidHostileSystems = racePolicy.AvoidHostileSystems;
|
||||
foreach (var systemId in racePolicy.BlacklistedSystemIds)
|
||||
{
|
||||
policy.BlacklistedSystemIds.Add(systemId);
|
||||
}
|
||||
}
|
||||
_world.Policies.Add(policy);
|
||||
|
||||
var factionCommander = CreateFactionCommander(playerFaction);
|
||||
_world.Commanders.Add(factionCommander);
|
||||
playerFaction.CommanderIds.Add(factionCommander.Id);
|
||||
return playerFaction;
|
||||
}
|
||||
|
||||
private string ResolveStarterSystemIdUnsafe()
|
||||
{
|
||||
return _world.Systems
|
||||
.Select(system => system.Definition.Id)
|
||||
.OrderBy(systemId => systemId, StringComparer.Ordinal)
|
||||
.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("No systems are available for player onboarding.");
|
||||
}
|
||||
|
||||
private void SpawnPlayerStarterShipUnsafe(FactionRuntime playerFaction, string systemId)
|
||||
{
|
||||
var request = new SpawnShipCommandRequest(
|
||||
playerFaction.Id,
|
||||
systemId,
|
||||
StarterPlayerShipId,
|
||||
Idle);
|
||||
var system = _world.Systems.First(candidate => string.Equals(candidate.Definition.Id, request.SystemId, StringComparison.Ordinal));
|
||||
var definition = ResolveShipDefinition(request, playerFaction.Id);
|
||||
var shipId = $"ship-{playerFaction.Id}-{definition.Id}-{Guid.NewGuid():N}".ToLowerInvariant();
|
||||
var spawnPosition = ResolveSpawnPosition(system.Definition.Id);
|
||||
var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, null);
|
||||
|
||||
var ship = new ShipRuntime
|
||||
{
|
||||
Id = shipId,
|
||||
SystemId = system.Definition.Id,
|
||||
Definition = definition,
|
||||
FactionId = playerFaction.Id,
|
||||
Position = spawnPosition,
|
||||
TargetPosition = spawnPosition,
|
||||
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Celestials),
|
||||
DefaultBehavior = defaultBehavior,
|
||||
Skills = ShipBootstrapPolicy.CreateSkills(definition),
|
||||
Health = definition.Hull,
|
||||
};
|
||||
|
||||
_world.Ships.Add(ship);
|
||||
EnsureShipCommander(playerFaction, ship);
|
||||
new GeopoliticalSimulationService().Update(_world, 0f, []);
|
||||
}
|
||||
|
||||
private string ResolvePlayerFactionColor(string raceId) =>
|
||||
raceId switch
|
||||
{
|
||||
"argon" => "#3b82f6",
|
||||
"boron" => "#14b8a6",
|
||||
"paranid" => "#eab308",
|
||||
"split" => "#b91c1c",
|
||||
"teladi" => "#22c55e",
|
||||
"terran" => "#38bdf8",
|
||||
"xenon" => "#9ca3af",
|
||||
_ => "#94a3b8",
|
||||
};
|
||||
|
||||
private string GetCurrentPlayerKey() => _playerIdentityResolver.GetRequiredEffectivePlayerId().ToString("N");
|
||||
|
||||
private bool CanCurrentActorAccessGm() => _playerIdentityResolver.CanAccessGm();
|
||||
|
||||
@@ -807,7 +918,8 @@ public sealed class WorldService
|
||||
powerModuleId,
|
||||
factionId,
|
||||
_staticData.ModuleDefinitions,
|
||||
_staticData.ItemDefinitions)
|
||||
_staticData.ItemDefinitions,
|
||||
_staticData.Recipes)
|
||||
.FirstOrDefault(moduleId =>
|
||||
{
|
||||
return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
|
||||
@@ -820,6 +932,24 @@ public sealed class WorldService
|
||||
EnsureStationModule(modules, defaultContainerStorageModuleId);
|
||||
}
|
||||
|
||||
var defaultSolidStorageModuleId = StarterStationLayoutResolver.ResolveRequiredStorageModuleIds(
|
||||
powerModuleId,
|
||||
factionId,
|
||||
_staticData.ModuleDefinitions,
|
||||
_staticData.ItemDefinitions,
|
||||
_staticData.Recipes)
|
||||
.FirstOrDefault(moduleId =>
|
||||
{
|
||||
return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
|
||||
&& definition is StorageModuleDefinition storageDefinition
|
||||
&& storageDefinition.StorageKind == StorageKind.Solid;
|
||||
});
|
||||
|
||||
if (defaultSolidStorageModuleId is not null)
|
||||
{
|
||||
EnsureStationModule(modules, defaultSolidStorageModuleId);
|
||||
}
|
||||
|
||||
var objectiveModuleId = StarterStationLayoutResolver.ResolveObjectiveModuleId(objective, factionId, _staticData.ModuleDefinitions);
|
||||
if (!string.IsNullOrWhiteSpace(objectiveModuleId))
|
||||
{
|
||||
@@ -828,7 +958,8 @@ public sealed class WorldService
|
||||
objectiveModuleId,
|
||||
factionId,
|
||||
_staticData.ModuleDefinitions,
|
||||
_staticData.ItemDefinitions))
|
||||
_staticData.ItemDefinitions,
|
||||
_staticData.Recipes))
|
||||
{
|
||||
EnsureStationModule(modules, storageModuleId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user