diff --git a/apps/backend/Auth/Api/ForgotPasswordHandler.cs b/apps/backend/Auth/Api/ForgotPasswordHandler.cs new file mode 100644 index 0000000..dd80054 --- /dev/null +++ b/apps/backend/Auth/Api/ForgotPasswordHandler.cs @@ -0,0 +1,17 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Auth.Api; + +public sealed class ForgotPasswordHandler(AuthService authService) : Endpoint +{ + public override void Configure() + { + Post("/api/auth/forgot-password"); + AllowAnonymous(); + } + + public override async Task HandleAsync(ForgotPasswordRequest request, CancellationToken cancellationToken) + { + await SendOkAsync(await authService.ForgotPasswordAsync(request, cancellationToken), cancellationToken); + } +} diff --git a/apps/backend/Auth/Api/LoginHandler.cs b/apps/backend/Auth/Api/LoginHandler.cs new file mode 100644 index 0000000..c58988b --- /dev/null +++ b/apps/backend/Auth/Api/LoginHandler.cs @@ -0,0 +1,25 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Auth.Api; + +public sealed class LoginHandler(AuthService authService) : Endpoint +{ + public override void Configure() + { + Post("/api/auth/login"); + AllowAnonymous(); + } + + public override async Task HandleAsync(LoginRequest request, CancellationToken cancellationToken) + { + try + { + await SendOkAsync(await authService.LoginAsync(request, cancellationToken), cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } + } +} diff --git a/apps/backend/Auth/Api/RefreshTokenHandler.cs b/apps/backend/Auth/Api/RefreshTokenHandler.cs new file mode 100644 index 0000000..21dcdc1 --- /dev/null +++ b/apps/backend/Auth/Api/RefreshTokenHandler.cs @@ -0,0 +1,25 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Auth.Api; + +public sealed class RefreshTokenHandler(AuthService authService) : Endpoint +{ + public override void Configure() + { + Post("/api/auth/refresh"); + AllowAnonymous(); + } + + public override async Task HandleAsync(RefreshTokenRequest request, CancellationToken cancellationToken) + { + try + { + await SendOkAsync(await authService.RefreshAsync(request, cancellationToken), cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } + } +} diff --git a/apps/backend/Auth/Api/RegisterHandler.cs b/apps/backend/Auth/Api/RegisterHandler.cs new file mode 100644 index 0000000..e7cc370 --- /dev/null +++ b/apps/backend/Auth/Api/RegisterHandler.cs @@ -0,0 +1,25 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Auth.Api; + +public sealed class RegisterHandler(AuthService authService) : Endpoint +{ + public override void Configure() + { + Post("/api/auth/register"); + AllowAnonymous(); + } + + public override async Task HandleAsync(RegisterRequest request, CancellationToken cancellationToken) + { + try + { + await SendOkAsync(await authService.RegisterAsync(request, cancellationToken), cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } + } +} diff --git a/apps/backend/Auth/Api/ResetPasswordHandler.cs b/apps/backend/Auth/Api/ResetPasswordHandler.cs new file mode 100644 index 0000000..53fd201 --- /dev/null +++ b/apps/backend/Auth/Api/ResetPasswordHandler.cs @@ -0,0 +1,26 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Auth.Api; + +public sealed class ResetPasswordHandler(AuthService authService) : Endpoint +{ + public override void Configure() + { + Post("/api/auth/reset-password"); + AllowAnonymous(); + } + + public override async Task HandleAsync(ResetPasswordRequest request, CancellationToken cancellationToken) + { + try + { + await authService.ResetPasswordAsync(request, cancellationToken); + await SendNoContentAsync(cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } + } +} diff --git a/apps/backend/Auth/Contracts/AuthContracts.cs b/apps/backend/Auth/Contracts/AuthContracts.cs new file mode 100644 index 0000000..ed268f3 --- /dev/null +++ b/apps/backend/Auth/Contracts/AuthContracts.cs @@ -0,0 +1,42 @@ +namespace SpaceGame.Api.Auth.Contracts; + +public sealed class RegisterRequest +{ + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} + +public sealed class LoginRequest +{ + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} + +public sealed class RefreshTokenRequest +{ + public string RefreshToken { get; set; } = string.Empty; +} + +public sealed class ForgotPasswordRequest +{ + public string Email { get; set; } = string.Empty; +} + +public sealed class ResetPasswordRequest +{ + public string Token { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; +} + +public sealed record AuthSessionResponse( + Guid UserId, + string Email, + IReadOnlyList Roles, + string AccessToken, + DateTimeOffset AccessTokenExpiresAtUtc, + string RefreshToken, + DateTimeOffset RefreshTokenExpiresAtUtc); + +public sealed record ForgotPasswordResponse( + bool Accepted, + string? ResetToken = null); diff --git a/apps/backend/Auth/Runtime/AuthRuntimeModels.cs b/apps/backend/Auth/Runtime/AuthRuntimeModels.cs new file mode 100644 index 0000000..47bd5f8 --- /dev/null +++ b/apps/backend/Auth/Runtime/AuthRuntimeModels.cs @@ -0,0 +1,24 @@ +namespace SpaceGame.Api.Auth.Runtime; + +public sealed record UserAccount( + Guid Id, + string Email, + string PasswordHash, + DateTimeOffset CreatedAtUtc, + IReadOnlyList Roles); + +public sealed record RefreshTokenRecord( + Guid Id, + Guid UserId, + string TokenHash, + DateTimeOffset CreatedAtUtc, + DateTimeOffset ExpiresAtUtc, + DateTimeOffset? RevokedAtUtc); + +public sealed record PasswordResetTokenRecord( + Guid Id, + Guid UserId, + string TokenHash, + DateTimeOffset CreatedAtUtc, + DateTimeOffset ExpiresAtUtc, + DateTimeOffset? ConsumedAtUtc); diff --git a/apps/backend/Auth/Simulation/AuthOptions.cs b/apps/backend/Auth/Simulation/AuthOptions.cs new file mode 100644 index 0000000..3c36dfc --- /dev/null +++ b/apps/backend/Auth/Simulation/AuthOptions.cs @@ -0,0 +1,14 @@ +namespace SpaceGame.Api.Auth.Simulation; + +public sealed class AuthOptions +{ + public string ConnectionString { get; set; } = string.Empty; + public List DevSeedUsers { get; set; } = []; +} + +public sealed class SeedUserOptions +{ + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public List Roles { get; set; } = []; +} diff --git a/apps/backend/Auth/Simulation/AuthPolicyNames.cs b/apps/backend/Auth/Simulation/AuthPolicyNames.cs new file mode 100644 index 0000000..8d04281 --- /dev/null +++ b/apps/backend/Auth/Simulation/AuthPolicyNames.cs @@ -0,0 +1,13 @@ +namespace SpaceGame.Api.Auth.Simulation; + +public static class AuthPolicyNames +{ + public const string AdminAccess = "AdminAccess"; + public const string GmAccess = "GmAccess"; +} + +public static class AuthRoleNames +{ + public const string Gm = "gm"; + public const string Admin = "admin"; +} diff --git a/apps/backend/Auth/Simulation/AuthSchemaInitializer.cs b/apps/backend/Auth/Simulation/AuthSchemaInitializer.cs new file mode 100644 index 0000000..3981d30 --- /dev/null +++ b/apps/backend/Auth/Simulation/AuthSchemaInitializer.cs @@ -0,0 +1,41 @@ +using Npgsql; + +namespace SpaceGame.Api.Auth.Simulation; + +public sealed class AuthSchemaInitializer(NpgsqlDataSource dataSource) +{ + public async Task EnsureSchemaAsync(CancellationToken cancellationToken) + { + await using var command = dataSource.CreateCommand(""" + create table if not exists auth_users ( + id uuid primary key, + email text not null unique, + password_hash text not null, + created_at_utc timestamptz not null, + roles text[] not null default '{}' + ); + + alter table auth_users + add column if not exists roles text[] not null default '{}'; + + create table if not exists auth_refresh_tokens ( + id uuid primary key, + user_id uuid not null references auth_users(id) on delete cascade, + token_hash text not null unique, + created_at_utc timestamptz not null, + expires_at_utc timestamptz not null, + revoked_at_utc timestamptz null + ); + + create table if not exists auth_password_reset_tokens ( + id uuid primary key, + user_id uuid not null references auth_users(id) on delete cascade, + token_hash text not null unique, + created_at_utc timestamptz not null, + expires_at_utc timestamptz not null, + consumed_at_utc timestamptz null + ); + """); + await command.ExecuteNonQueryAsync(cancellationToken); + } +} diff --git a/apps/backend/Auth/Simulation/AuthService.cs b/apps/backend/Auth/Simulation/AuthService.cs new file mode 100644 index 0000000..8f86cb0 --- /dev/null +++ b/apps/backend/Auth/Simulation/AuthService.cs @@ -0,0 +1,120 @@ +namespace SpaceGame.Api.Auth.Simulation; + +public sealed class AuthService( + IAuthRepository authRepository, + LocalPasswordHasher passwordHasher, + ITokenService tokenService, + RefreshTokenFactory refreshTokenFactory, + IPasswordResetDelivery passwordResetDelivery) +{ + public async Task RegisterAsync(RegisterRequest request, CancellationToken cancellationToken) + { + var email = NormalizeEmail(request.Email); + ValidatePassword(request.Password); + + if (await authRepository.FindUserByEmailAsync(email, cancellationToken) is not null) + { + throw new InvalidOperationException("An account already exists for that email."); + } + + var user = await authRepository.CreateUserAsync(email, passwordHasher.HashPassword(request.Password), [], cancellationToken); + return await CreateSessionAsync(user, cancellationToken); + } + + public async Task LoginAsync(LoginRequest request, CancellationToken cancellationToken) + { + var email = NormalizeEmail(request.Email); + var user = await authRepository.FindUserByEmailAsync(email, cancellationToken) + ?? throw new InvalidOperationException("Invalid email or password."); + if (!passwordHasher.VerifyPassword(request.Password, user.PasswordHash)) + { + throw new InvalidOperationException("Invalid email or password."); + } + + return await CreateSessionAsync(user, cancellationToken); + } + + public async Task RefreshAsync(RefreshTokenRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.RefreshToken)) + { + throw new InvalidOperationException("Refresh token is required."); + } + + var tokenHash = refreshTokenFactory.HashToken(request.RefreshToken); + var record = await authRepository.FindRefreshTokenAsync(tokenHash, cancellationToken) + ?? throw new InvalidOperationException("Refresh token is invalid."); + if (record.RevokedAtUtc is not null || record.ExpiresAtUtc <= DateTimeOffset.UtcNow) + { + throw new InvalidOperationException("Refresh token is expired."); + } + + var user = await authRepository.FindUserByIdAsync(record.UserId, cancellationToken) + ?? throw new InvalidOperationException("User account was not found."); + await authRepository.RevokeRefreshTokenAsync(record.Id, cancellationToken); + return await CreateSessionAsync(user, cancellationToken); + } + + public async Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken cancellationToken) + { + var email = NormalizeEmail(request.Email); + var user = await authRepository.FindUserByEmailAsync(email, cancellationToken); + if (user is null) + { + return new ForgotPasswordResponse(true); + } + + var resetToken = refreshTokenFactory.CreateToken(); + var resetTokenHash = refreshTokenFactory.HashToken(resetToken); + var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(30); + await authRepository.StorePasswordResetTokenAsync(user.Id, resetTokenHash, expiresAtUtc, cancellationToken); + return await passwordResetDelivery.DeliverAsync(user, resetToken, cancellationToken); + } + + public async Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Token)) + { + throw new InvalidOperationException("Reset token is required."); + } + + ValidatePassword(request.NewPassword); + var tokenHash = refreshTokenFactory.HashToken(request.Token); + var record = await authRepository.FindPasswordResetTokenAsync(tokenHash, cancellationToken) + ?? throw new InvalidOperationException("Reset token is invalid."); + if (record.ConsumedAtUtc is not null || record.ExpiresAtUtc <= DateTimeOffset.UtcNow) + { + throw new InvalidOperationException("Reset token is expired."); + } + + await authRepository.UpdatePasswordHashAsync(record.UserId, passwordHasher.HashPassword(request.NewPassword), cancellationToken); + await authRepository.ConsumePasswordResetTokenAsync(record.Id, cancellationToken); + await authRepository.RevokeAllRefreshTokensAsync(record.UserId, cancellationToken); + } + + private async Task CreateSessionAsync(UserAccount user, CancellationToken cancellationToken) + { + var (accessToken, accessExpiresAtUtc) = tokenService.CreateAccessToken(user); + var (refreshToken, refreshTokenHash, refreshExpiresAtUtc) = tokenService.CreateRefreshToken(); + await authRepository.StoreRefreshTokenAsync(user.Id, refreshTokenHash, refreshExpiresAtUtc, cancellationToken); + return new AuthSessionResponse(user.Id, user.Email, user.Roles, accessToken, accessExpiresAtUtc, refreshToken, refreshExpiresAtUtc); + } + + private static string NormalizeEmail(string email) + { + if (string.IsNullOrWhiteSpace(email)) + { + throw new InvalidOperationException("Email is required."); + } + + return email.Trim().ToLowerInvariant(); + } + + private static void ValidatePassword(string password) + { + if (string.IsNullOrWhiteSpace(password) || password.Length < 8) + { + throw new InvalidOperationException("Password must be at least 8 characters."); + } + } +} diff --git a/apps/backend/Auth/Simulation/DevAuthSeeder.cs b/apps/backend/Auth/Simulation/DevAuthSeeder.cs new file mode 100644 index 0000000..12a360c --- /dev/null +++ b/apps/backend/Auth/Simulation/DevAuthSeeder.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace SpaceGame.Api.Auth.Simulation; + +public sealed class DevAuthSeeder( + IHostEnvironment hostEnvironment, + IOptions authOptions, + IAuthRepository authRepository, + LocalPasswordHasher passwordHasher) +{ + public async Task SeedAsync(CancellationToken cancellationToken) + { + if (!hostEnvironment.IsDevelopment()) + { + return; + } + + foreach (var seedUser in authOptions.Value.DevSeedUsers) + { + if (string.IsNullOrWhiteSpace(seedUser.Email) || string.IsNullOrWhiteSpace(seedUser.Password)) + { + continue; + } + + await authRepository.UpsertUserAsync( + seedUser.Email.Trim().ToLowerInvariant(), + passwordHasher.HashPassword(seedUser.Password), + seedUser.Roles, + cancellationToken); + } + } +} diff --git a/apps/backend/Auth/Simulation/DevPasswordResetDelivery.cs b/apps/backend/Auth/Simulation/DevPasswordResetDelivery.cs new file mode 100644 index 0000000..78386a3 --- /dev/null +++ b/apps/backend/Auth/Simulation/DevPasswordResetDelivery.cs @@ -0,0 +1,7 @@ +namespace SpaceGame.Api.Auth.Simulation; + +public sealed class DevPasswordResetDelivery : IPasswordResetDelivery +{ + public Task DeliverAsync(UserAccount user, string resetToken, CancellationToken cancellationToken) => + Task.FromResult(new ForgotPasswordResponse(true, resetToken)); +} diff --git a/apps/backend/Auth/Simulation/HttpContextPlayerIdentityResolver.cs b/apps/backend/Auth/Simulation/HttpContextPlayerIdentityResolver.cs new file mode 100644 index 0000000..4488df2 --- /dev/null +++ b/apps/backend/Auth/Simulation/HttpContextPlayerIdentityResolver.cs @@ -0,0 +1,23 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; + +namespace SpaceGame.Api.Auth.Simulation; + +public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpContextAccessor) : IPlayerIdentityResolver +{ + public Guid? GetCurrentPlayerId() + { + var subject = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? httpContextAccessor.HttpContext?.User.FindFirstValue("sub"); + return Guid.TryParse(subject, out var playerId) ? playerId : null; + } + + public Guid GetRequiredPlayerId() => + GetCurrentPlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required."); + + public bool CanAccessGm() + { + var user = httpContextAccessor.HttpContext?.User; + return user?.IsInRole("gm") == true || user?.IsInRole("admin") == true; + } +} diff --git a/apps/backend/Auth/Simulation/IAuthRepository.cs b/apps/backend/Auth/Simulation/IAuthRepository.cs new file mode 100644 index 0000000..f493e74 --- /dev/null +++ b/apps/backend/Auth/Simulation/IAuthRepository.cs @@ -0,0 +1,17 @@ +namespace SpaceGame.Api.Auth.Simulation; + +public interface IAuthRepository +{ + Task FindUserByEmailAsync(string email, CancellationToken cancellationToken); + Task FindUserByIdAsync(Guid userId, CancellationToken cancellationToken); + Task CreateUserAsync(string email, string passwordHash, IReadOnlyCollection roles, CancellationToken cancellationToken); + Task UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection roles, CancellationToken cancellationToken); + Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken); + Task FindRefreshTokenAsync(string tokenHash, CancellationToken cancellationToken); + Task RevokeRefreshTokenAsync(Guid refreshTokenId, CancellationToken cancellationToken); + Task RevokeAllRefreshTokensAsync(Guid userId, CancellationToken cancellationToken); + Task StorePasswordResetTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken); + Task FindPasswordResetTokenAsync(string tokenHash, CancellationToken cancellationToken); + Task ConsumePasswordResetTokenAsync(Guid passwordResetTokenId, CancellationToken cancellationToken); + Task UpdatePasswordHashAsync(Guid userId, string passwordHash, CancellationToken cancellationToken); +} diff --git a/apps/backend/Auth/Simulation/IPasswordResetDelivery.cs b/apps/backend/Auth/Simulation/IPasswordResetDelivery.cs new file mode 100644 index 0000000..bd93d9a --- /dev/null +++ b/apps/backend/Auth/Simulation/IPasswordResetDelivery.cs @@ -0,0 +1,6 @@ +namespace SpaceGame.Api.Auth.Simulation; + +public interface IPasswordResetDelivery +{ + Task DeliverAsync(UserAccount user, string resetToken, CancellationToken cancellationToken); +} diff --git a/apps/backend/Auth/Simulation/IPlayerIdentityResolver.cs b/apps/backend/Auth/Simulation/IPlayerIdentityResolver.cs new file mode 100644 index 0000000..0a6e119 --- /dev/null +++ b/apps/backend/Auth/Simulation/IPlayerIdentityResolver.cs @@ -0,0 +1,8 @@ +namespace SpaceGame.Api.Auth.Simulation; + +public interface IPlayerIdentityResolver +{ + Guid? GetCurrentPlayerId(); + Guid GetRequiredPlayerId(); + bool CanAccessGm(); +} diff --git a/apps/backend/Auth/Simulation/ITokenService.cs b/apps/backend/Auth/Simulation/ITokenService.cs new file mode 100644 index 0000000..a7d039d --- /dev/null +++ b/apps/backend/Auth/Simulation/ITokenService.cs @@ -0,0 +1,7 @@ +namespace SpaceGame.Api.Auth.Simulation; + +public interface ITokenService +{ + (string Token, DateTimeOffset ExpiresAtUtc) CreateAccessToken(UserAccount user); + (string Token, string TokenHash, DateTimeOffset ExpiresAtUtc) CreateRefreshToken(); +} diff --git a/apps/backend/Auth/Simulation/JwtOptions.cs b/apps/backend/Auth/Simulation/JwtOptions.cs new file mode 100644 index 0000000..2201d6d --- /dev/null +++ b/apps/backend/Auth/Simulation/JwtOptions.cs @@ -0,0 +1,10 @@ +namespace SpaceGame.Api.Auth.Simulation; + +public sealed class JwtOptions +{ + public string Issuer { get; set; } = "space-game"; + public string Audience { get; set; } = "space-game-viewer"; + public string SigningKey { get; set; } = string.Empty; + public int AccessTokenLifetimeMinutes { get; set; } = 30; + public int RefreshTokenLifetimeDays { get; set; } = 30; +} diff --git a/apps/backend/Auth/Simulation/JwtTokenService.cs b/apps/backend/Auth/Simulation/JwtTokenService.cs new file mode 100644 index 0000000..f312e14 --- /dev/null +++ b/apps/backend/Auth/Simulation/JwtTokenService.cs @@ -0,0 +1,51 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace SpaceGame.Api.Auth.Simulation; + +public sealed class JwtTokenService( + IOptions jwtOptions, + RefreshTokenFactory refreshTokenFactory) : ITokenService +{ + public (string Token, DateTimeOffset ExpiresAtUtc) CreateAccessToken(UserAccount user) + { + var options = jwtOptions.Value; + var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(options.AccessTokenLifetimeMinutes, 5)); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SigningKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(JwtRegisteredClaimNames.Email, user.Email), + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Email, user.Email), + }.ToList(); + + foreach (var role in user.Roles) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + claims.Add(new Claim("role", role)); + } + + var token = new JwtSecurityToken( + issuer: options.Issuer, + audience: options.Audience, + claims: claims, + notBefore: DateTime.UtcNow, + expires: expiresAtUtc.UtcDateTime, + signingCredentials: credentials); + + return (new JwtSecurityTokenHandler().WriteToken(token), expiresAtUtc); + } + + public (string Token, string TokenHash, DateTimeOffset ExpiresAtUtc) CreateRefreshToken() + { + var token = refreshTokenFactory.CreateToken(); + var tokenHash = refreshTokenFactory.HashToken(token); + var expiresAtUtc = DateTimeOffset.UtcNow.AddDays(Math.Max(jwtOptions.Value.RefreshTokenLifetimeDays, 1)); + return (token, tokenHash, expiresAtUtc); + } +} diff --git a/apps/backend/Auth/Simulation/LocalPasswordHasher.cs b/apps/backend/Auth/Simulation/LocalPasswordHasher.cs new file mode 100644 index 0000000..04b23e2 --- /dev/null +++ b/apps/backend/Auth/Simulation/LocalPasswordHasher.cs @@ -0,0 +1,42 @@ +using System.Security.Cryptography; + +namespace SpaceGame.Api.Auth.Simulation; + +public sealed class LocalPasswordHasher +{ + private const int SaltSize = 16; + private const int KeySize = 32; + private const int IterationCount = 120_000; + + public string HashPassword(string password) + { + ArgumentException.ThrowIfNullOrWhiteSpace(password); + + Span salt = stackalloc byte[SaltSize]; + RandomNumberGenerator.Fill(salt); + var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, IterationCount, HashAlgorithmName.SHA256, KeySize); + return $"pbkdf2-sha256${IterationCount}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}"; + } + + public bool VerifyPassword(string password, string encodedHash) + { + ArgumentException.ThrowIfNullOrWhiteSpace(password); + ArgumentException.ThrowIfNullOrWhiteSpace(encodedHash); + + var parts = encodedHash.Split('$', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 4 || !string.Equals(parts[0], "pbkdf2-sha256", StringComparison.Ordinal)) + { + return false; + } + + if (!int.TryParse(parts[1], out var iterations)) + { + return false; + } + + var salt = Convert.FromBase64String(parts[2]); + var expected = Convert.FromBase64String(parts[3]); + var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, expected.Length); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } +} diff --git a/apps/backend/Auth/Simulation/PostgresAuthRepository.cs b/apps/backend/Auth/Simulation/PostgresAuthRepository.cs new file mode 100644 index 0000000..ea9e3ce --- /dev/null +++ b/apps/backend/Auth/Simulation/PostgresAuthRepository.cs @@ -0,0 +1,199 @@ +using Npgsql; + +namespace SpaceGame.Api.Auth.Simulation; + +public sealed class PostgresAuthRepository(NpgsqlDataSource dataSource) : IAuthRepository +{ + public async Task FindUserByEmailAsync(string email, CancellationToken cancellationToken) + { + await using var command = dataSource.CreateCommand(""" + select id, email, password_hash, created_at_utc, roles + from auth_users + where email = $1 + """); + command.Parameters.AddWithValue(email); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null; + } + + public async Task FindUserByIdAsync(Guid userId, CancellationToken cancellationToken) + { + await using var command = dataSource.CreateCommand(""" + select id, email, password_hash, created_at_utc, roles + from auth_users + where id = $1 + """); + command.Parameters.AddWithValue(userId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null; + } + + public async Task CreateUserAsync(string email, string passwordHash, IReadOnlyCollection roles, CancellationToken cancellationToken) + { + var userId = Guid.NewGuid(); + var createdAtUtc = DateTimeOffset.UtcNow; + await using var command = dataSource.CreateCommand(""" + insert into auth_users (id, email, password_hash, created_at_utc, roles) + values ($1, $2, $3, $4, $5) + """); + command.Parameters.AddWithValue(userId); + command.Parameters.AddWithValue(email); + command.Parameters.AddWithValue(passwordHash); + command.Parameters.AddWithValue(createdAtUtc); + command.Parameters.AddWithValue(roles.Select(role => role.Trim().ToLowerInvariant()).Where(role => role.Length > 0).Distinct(StringComparer.Ordinal).ToArray()); + await command.ExecuteNonQueryAsync(cancellationToken); + return new UserAccount(userId, email, passwordHash, createdAtUtc, roles.Select(role => role.Trim().ToLowerInvariant()).Where(role => role.Length > 0).Distinct(StringComparer.Ordinal).ToArray()); + } + + public async Task UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection roles, CancellationToken cancellationToken) + { + var normalizedRoles = roles.Select(role => role.Trim().ToLowerInvariant()).Where(role => role.Length > 0).Distinct(StringComparer.Ordinal).ToArray(); + var userId = Guid.NewGuid(); + var createdAtUtc = DateTimeOffset.UtcNow; + await using var command = dataSource.CreateCommand(""" + insert into auth_users (id, email, password_hash, created_at_utc, roles) + values ($1, $2, $3, $4, $5) + on conflict (email) do update + set password_hash = excluded.password_hash, + roles = excluded.roles + returning id, email, password_hash, created_at_utc, roles + """); + command.Parameters.AddWithValue(userId); + command.Parameters.AddWithValue(email); + command.Parameters.AddWithValue(passwordHash); + command.Parameters.AddWithValue(createdAtUtc); + command.Parameters.AddWithValue(normalizedRoles); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + await reader.ReadAsync(cancellationToken); + return ReadUser(reader); + } + + public async Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken) + { + await using var command = dataSource.CreateCommand(""" + insert into auth_refresh_tokens (id, user_id, token_hash, created_at_utc, expires_at_utc, revoked_at_utc) + values ($1, $2, $3, $4, $5, null) + """); + command.Parameters.AddWithValue(Guid.NewGuid()); + command.Parameters.AddWithValue(userId); + command.Parameters.AddWithValue(tokenHash); + command.Parameters.AddWithValue(DateTimeOffset.UtcNow); + command.Parameters.AddWithValue(expiresAtUtc); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async Task FindRefreshTokenAsync(string tokenHash, CancellationToken cancellationToken) + { + await using var command = dataSource.CreateCommand(""" + select id, user_id, token_hash, created_at_utc, expires_at_utc, revoked_at_utc + from auth_refresh_tokens + where token_hash = $1 + """); + command.Parameters.AddWithValue(tokenHash); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + return new RefreshTokenRecord( + reader.GetGuid(0), + reader.GetGuid(1), + reader.GetString(2), + reader.GetFieldValue(3), + reader.GetFieldValue(4), + reader.IsDBNull(5) ? null : reader.GetFieldValue(5)); + } + + public async Task RevokeRefreshTokenAsync(Guid refreshTokenId, CancellationToken cancellationToken) + { + await using var command = dataSource.CreateCommand(""" + update auth_refresh_tokens + set revoked_at_utc = $2 + where id = $1 and revoked_at_utc is null + """); + command.Parameters.AddWithValue(refreshTokenId); + command.Parameters.AddWithValue(DateTimeOffset.UtcNow); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async Task RevokeAllRefreshTokensAsync(Guid userId, CancellationToken cancellationToken) + { + await using var command = dataSource.CreateCommand(""" + update auth_refresh_tokens + set revoked_at_utc = $2 + where user_id = $1 and revoked_at_utc is null + """); + command.Parameters.AddWithValue(userId); + command.Parameters.AddWithValue(DateTimeOffset.UtcNow); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async Task StorePasswordResetTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken) + { + await using var command = dataSource.CreateCommand(""" + insert into auth_password_reset_tokens (id, user_id, token_hash, created_at_utc, expires_at_utc, consumed_at_utc) + values ($1, $2, $3, $4, $5, null) + """); + command.Parameters.AddWithValue(Guid.NewGuid()); + command.Parameters.AddWithValue(userId); + command.Parameters.AddWithValue(tokenHash); + command.Parameters.AddWithValue(DateTimeOffset.UtcNow); + command.Parameters.AddWithValue(expiresAtUtc); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async Task FindPasswordResetTokenAsync(string tokenHash, CancellationToken cancellationToken) + { + await using var command = dataSource.CreateCommand(""" + select id, user_id, token_hash, created_at_utc, expires_at_utc, consumed_at_utc + from auth_password_reset_tokens + where token_hash = $1 + """); + command.Parameters.AddWithValue(tokenHash); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + return new PasswordResetTokenRecord( + reader.GetGuid(0), + reader.GetGuid(1), + reader.GetString(2), + reader.GetFieldValue(3), + reader.GetFieldValue(4), + reader.IsDBNull(5) ? null : reader.GetFieldValue(5)); + } + + public async Task ConsumePasswordResetTokenAsync(Guid passwordResetTokenId, CancellationToken cancellationToken) + { + await using var command = dataSource.CreateCommand(""" + update auth_password_reset_tokens + set consumed_at_utc = $2 + where id = $1 and consumed_at_utc is null + """); + command.Parameters.AddWithValue(passwordResetTokenId); + command.Parameters.AddWithValue(DateTimeOffset.UtcNow); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async Task UpdatePasswordHashAsync(Guid userId, string passwordHash, CancellationToken cancellationToken) + { + await using var command = dataSource.CreateCommand(""" + update auth_users + set password_hash = $2 + where id = $1 + """); + command.Parameters.AddWithValue(userId); + command.Parameters.AddWithValue(passwordHash); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private static UserAccount ReadUser(NpgsqlDataReader reader) => new( + reader.GetGuid(0), + reader.GetString(1), + reader.GetString(2), + reader.GetFieldValue(3), + reader.GetFieldValue(4)); +} diff --git a/apps/backend/Auth/Simulation/RefreshTokenFactory.cs b/apps/backend/Auth/Simulation/RefreshTokenFactory.cs new file mode 100644 index 0000000..c3e8d08 --- /dev/null +++ b/apps/backend/Auth/Simulation/RefreshTokenFactory.cs @@ -0,0 +1,21 @@ +using System.Security.Cryptography; +using System.Text; + +namespace SpaceGame.Api.Auth.Simulation; + +public sealed class RefreshTokenFactory +{ + public string CreateToken() + { + Span bytes = stackalloc byte[32]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToBase64String(bytes); + } + + public string HashToken(string token) + { + using var sha = SHA256.Create(); + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(bytes); + } +} diff --git a/apps/backend/Definitions/WorldDefinitions.cs b/apps/backend/Definitions/WorldDefinitions.cs index 28010bf..ea9fdcf 100644 --- a/apps/backend/Definitions/WorldDefinitions.cs +++ b/apps/backend/Definitions/WorldDefinitions.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using SpaceGame.Api.Shared.Runtime; +using SpaceGame.Api.Shared.Runtime; using SpaceGame.Api.Universe.Simulation; namespace SpaceGame.Api.Definitions; @@ -368,6 +369,72 @@ public sealed class PlanetDefinition public bool HasRing { get; set; } } +public enum ShipPurpose +{ + [JsonStringEnumMemberName("auxiliary")] + Auxiliary, + [JsonStringEnumMemberName("mine")] + Mine, + [JsonStringEnumMemberName("build")] + Build, + [JsonStringEnumMemberName("fight")] + Fight, + [JsonStringEnumMemberName("trade")] + Trade, + [JsonStringEnumMemberName("salvage")] + Salvage, + [JsonStringEnumMemberName("dismantling")] + Dismantling, +} + +public enum ShipType +{ + [JsonStringEnumMemberName("resupplier")] + Resupplier, + [JsonStringEnumMemberName("miner")] + Miner, + [JsonStringEnumMemberName("carrier")] + Carrier, + [JsonStringEnumMemberName("fighter")] + Fighter, + [JsonStringEnumMemberName("heavyfighter")] + HeavyFighter, + [JsonStringEnumMemberName("destroyer")] + Destroyer, + [JsonStringEnumMemberName("largeminer")] + LargeMiner, + [JsonStringEnumMemberName("freighter")] + Freighter, + [JsonStringEnumMemberName("bomber")] + Bomber, + [JsonStringEnumMemberName("scavenger")] + Scavenger, + [JsonStringEnumMemberName("frigate")] + Frigate, + [JsonStringEnumMemberName("transporter")] + Transporter, + [JsonStringEnumMemberName("interceptor")] + Interceptor, + [JsonStringEnumMemberName("scout")] + Scout, + [JsonStringEnumMemberName("courier")] + Courier, + [JsonStringEnumMemberName("builder")] + Builder, + [JsonStringEnumMemberName("corvette")] + Corvette, + [JsonStringEnumMemberName("police")] + Police, + [JsonStringEnumMemberName("battleship")] + Battleship, + [JsonStringEnumMemberName("gunboat")] + Gunboat, + [JsonStringEnumMemberName("tug")] + Tug, + [JsonStringEnumMemberName("compactor")] + Compactor, +} + public sealed class ShipDefinition { public required string Id { get; set; } @@ -379,9 +446,9 @@ public sealed class ShipDefinition public float Hull { get; set; } public Dictionary Storage { get; set; } = new(StringComparer.Ordinal); public int People { get; set; } - public string Purpose { get; set; } = string.Empty; + public ShipPurpose Purpose { get; set; } public string Thruster { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; + public ShipType Type { get; set; } public float Mass { get; set; } public ShipInertiaDefinition? Inertia { get; set; } public ShipDragDefinition? Drag { get; set; } @@ -395,12 +462,6 @@ public sealed class ShipDefinition public ItemPriceDefinition? Price { get; set; } public List Production { get; set; } = []; [JsonIgnore] - public string Label => Name; - [JsonIgnore] - public string Kind => InferKind(Purpose); - [JsonIgnore] - public string Class => Type; - [JsonIgnore] public float Speed => InferLocalSpeed(Size); [JsonIgnore] public float WarpSpeed => InferWarpSpeed(Size); @@ -408,53 +469,15 @@ public sealed class ShipDefinition public float FtlSpeed => InferFtlSpeed(Size); [JsonIgnore] public float SpoolTime => InferSpoolTime(Size); - [JsonIgnore] - public float CargoCapacity => Cargo.Sum(entry => entry.Max); - [JsonIgnore] - public StorageKind? CargoKind => Cargo - .SelectMany(entry => entry.Types) - .Select(type => type.ToNullableStorageKind()) - .FirstOrDefault(kind => kind is not null); - [JsonIgnore] - public float MaxHealth => Hull; - [JsonIgnore] - public IReadOnlyList Capabilities => InferCapabilities(Purpose, Type, Cargo, Turrets); + public float GetTotalCargoCapacity() => Cargo.Sum(entry => entry.Max); - private static string InferKind(string purpose) => - purpose switch - { - "build" => "construction", - "trade" => "transport", - "mine" => "mining", - "fight" => "military", - "auxiliary" => "military", - _ => purpose, - }; + public float GetCargoCapacity(StorageKind kind) => + Cargo + .Where(entry => entry.Types.Any(type => type.ToNullableStorageKind() == kind)) + .Sum(entry => entry.Max); - private static List InferCapabilities( - string purpose, - string type, - IReadOnlyCollection cargo, - IReadOnlyCollection turrets) - { - var capabilities = new List { "warp", "ftl" }; - - if (string.Equals(purpose, "mine", StringComparison.Ordinal) - || type.Contains("miner", StringComparison.Ordinal) - || turrets.Any(turret => turret.Types.Contains("mining", StringComparer.Ordinal))) - { - capabilities.Add("mining"); - } - - if (cargo.Any(entry => entry.Types.Contains("container", StringComparer.Ordinal) - || entry.Types.Contains("solid", StringComparer.Ordinal) - || entry.Types.Contains("liquid", StringComparer.Ordinal))) - { - capabilities.Add("cargo"); - } - - return capabilities; - } + public bool SupportsCargoKind(StorageKind kind) => + GetCargoCapacity(kind) > 0f; private static float InferWarpSpeed(string size) => size switch diff --git a/apps/backend/Factions/AI/CommanderPlanningService.cs b/apps/backend/Factions/AI/CommanderPlanningService.cs index 3ea4445..4c439be 100644 --- a/apps/backend/Factions/AI/CommanderPlanningService.cs +++ b/apps/backend/Factions/AI/CommanderPlanningService.cs @@ -1,5 +1,6 @@ using SpaceGame.Api.Industry.Planning; using SpaceGame.Api.Stations.Simulation; +using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; namespace SpaceGame.Api.Factions.AI; @@ -13,8 +14,12 @@ internal sealed class CommanderPlanningService private const int MaxDecisionLogEntries = 40; private const int MaxOutcomeEntries = 32; private const int MaxAiOrdersPerShip = 2; + private const string MilitaryShipCategory = "military"; + private const string MiningShipCategory = "mining"; + private const string TransportShipCategory = "transport"; + private const string ConstructionShipCategory = "construction"; - internal void UpdateCommanders(SimulationWorld world, float deltaSeconds, ICollection events) + internal void UpdateCommanders(SimulationWorld world, IPlayerStateStore playerStateStore, float deltaSeconds, ICollection events) { EnsureHierarchy(world); @@ -33,7 +38,7 @@ internal sealed class CommanderPlanningService foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Faction).ToList()) { - if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) + if (PlayerFactionService.IsPlayerFaction(playerStateStore, commander.FactionId)) { continue; } @@ -48,7 +53,7 @@ internal sealed class CommanderPlanningService foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Fleet).ToList()) { - if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) + if (PlayerFactionService.IsPlayerFaction(playerStateStore, commander.FactionId)) { continue; } @@ -63,7 +68,7 @@ internal sealed class CommanderPlanningService foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Station).ToList()) { - if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) + if (PlayerFactionService.IsPlayerFaction(playerStateStore, commander.FactionId)) { continue; } @@ -78,7 +83,7 @@ internal sealed class CommanderPlanningService foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Ship).ToList()) { - if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) + if (PlayerFactionService.IsPlayerFaction(playerStateStore, commander.FactionId)) { continue; } @@ -268,7 +273,7 @@ internal sealed class CommanderPlanningService CommanderRuntime factionCommander, IReadOnlyDictionary stationCommanders) { - if (string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal)) + if (IsMilitaryShip(ship.Definition)) { return factionCommander; } @@ -456,8 +461,8 @@ internal sealed class CommanderPlanningService ship.Id, nextAssignment is null ? "assignment-cleared" : "assignment-updated", nextAssignment is null - ? $"{ship.Definition.Label} returned to default behavior." - : $"{ship.Definition.Label} assigned to {nextAssignment.Kind}.", + ? $"{ship.Definition.Name} returned to default behavior." + : $"{ship.Definition.Name} assigned to {nextAssignment.Kind}.", DateTimeOffset.UtcNow)); } } @@ -586,10 +591,10 @@ internal sealed class CommanderPlanningService var frontCount = Math.Max(1, threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind is "controlled-system" or "contested-system") + (expansionProject is null ? 0 : 1)); - var militaryShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "military"); - var minerShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && HasShipCapabilities(ship.Definition, "mining")); - var transportShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "transport"); - var constructorShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "construction"); + var militaryShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsMilitaryShip(ship.Definition)); + var minerShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsMiningShip(ship.Definition)); + var transportShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsTransportShip(ship.Definition)); + var constructorShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsConstructionShip(ship.Definition)); var hasShipyard = world.Stations.Any(station => string.Equals(station.FactionId, faction.Id, StringComparison.Ordinal) && station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)); @@ -1365,9 +1370,9 @@ internal sealed class CommanderPlanningService Id = $"{campaign.Id}-protect-station-{station.Id}", CampaignId = campaign.Id, TheaterId = theater?.Id, - Kind = "protect-station", + Kind = ProtectStation, DelegationKind = "ship", - BehaviorKind = "protect-station", + BehaviorKind = ProtectStation, Status = "active", Priority = campaign.Priority + 8f, HomeSystemId = station.SystemId, @@ -1389,7 +1394,7 @@ internal sealed class CommanderPlanningService TheaterId = theater?.Id, Kind = "patrol-front", DelegationKind = "ship", - BehaviorKind = "patrol", + BehaviorKind = Patrol, Status = "active", Priority = campaign.Priority + 2f, HomeSystemId = campaign.TargetSystemId, @@ -1414,7 +1419,7 @@ internal sealed class CommanderPlanningService TheaterId = theater?.Id, Kind = "police-front", DelegationKind = "ship", - BehaviorKind = "police", + BehaviorKind = Police, Status = "active", Priority = campaign.Priority + 1f, HomeSystemId = campaign.TargetSystemId, @@ -1454,7 +1459,7 @@ internal sealed class CommanderPlanningService TheaterId = theater?.Id, Kind = "strike-station", DelegationKind = "ship", - BehaviorKind = "attack-target", + BehaviorKind = AttackTarget, Status = "active", Priority = campaign.Priority + 10f, TargetSystemId = enemyStation.SystemId, @@ -1478,7 +1483,7 @@ internal sealed class CommanderPlanningService TheaterId = theater?.Id, Kind = "hold-front", DelegationKind = "ship", - BehaviorKind = "protect-position", + BehaviorKind = ProtectPosition, Status = "active", Priority = campaign.Priority + 3f, TargetSystemId = campaign.TargetSystemId, @@ -1500,7 +1505,7 @@ internal sealed class CommanderPlanningService TheaterId = theater?.Id, Kind = "fleet-sustainment", DelegationKind = "ship", - BehaviorKind = "supply-fleet", + BehaviorKind = SupplyFleet, Status = "active", Priority = campaign.Priority + 1.5f, HomeSystemId = campaign.TargetSystemId, @@ -1539,7 +1544,7 @@ internal sealed class CommanderPlanningService TheaterId = theater?.Id, Kind = "construct-site", DelegationKind = "ship", - BehaviorKind = "construct-station", + BehaviorKind = ConstructStation, Status = "active", Priority = campaign.Priority + 8f, HomeSystemId = expansionProject.SystemId, @@ -1564,7 +1569,7 @@ internal sealed class CommanderPlanningService TheaterId = theater?.Id, Kind = "supply-site", DelegationKind = "ship", - BehaviorKind = "find-build-tasks", + BehaviorKind = FindBuildTasks, Status = "active", Priority = campaign.Priority + 4f, HomeSystemId = expansionProject.SystemId, @@ -1589,7 +1594,7 @@ internal sealed class CommanderPlanningService TheaterId = theater?.Id, Kind = "guard-site", DelegationKind = "ship", - BehaviorKind = "protect-position", + BehaviorKind = ProtectPosition, Status = "active", Priority = campaign.Priority + 2f, TargetSystemId = expansionProject.SystemId, @@ -1614,7 +1619,7 @@ internal sealed class CommanderPlanningService TheaterId = theater?.Id, Kind = "mine-expansion-input", DelegationKind = "ship", - BehaviorKind = "expert-auto-mine", + BehaviorKind = ExpertAutoMine, Status = "active", Priority = campaign.Priority + 1f, HomeSystemId = expansionProject.SystemId, @@ -1655,7 +1660,7 @@ internal sealed class CommanderPlanningService TheaterId = theater?.Id, Kind = "trade-shortage", DelegationKind = "ship", - BehaviorKind = "fill-shortages", + BehaviorKind = FillShortages, Status = "active", Priority = campaign.Priority + 5f, HomeSystemId = anchorStation?.SystemId, @@ -1680,7 +1685,7 @@ internal sealed class CommanderPlanningService TheaterId = theater?.Id, Kind = "mine-shortage", DelegationKind = "ship", - BehaviorKind = "expert-auto-mine", + BehaviorKind = ExpertAutoMine, Status = "active", Priority = campaign.Priority + 3f, HomeSystemId = anchorStation?.SystemId, @@ -1703,7 +1708,7 @@ internal sealed class CommanderPlanningService TheaterId = theater?.Id, Kind = "revisit-stations", DelegationKind = "ship", - BehaviorKind = "revisit-known-stations", + BehaviorKind = RevisitKnownStations, Status = "active", Priority = campaign.Priority + 0.5f, HomeSystemId = anchorStation?.SystemId, @@ -1743,7 +1748,7 @@ internal sealed class CommanderPlanningService CampaignId = campaign.Id, Kind = "feed-shipyard", DelegationKind = "ship", - BehaviorKind = "fill-shortages", + BehaviorKind = FillShortages, Status = "active", Priority = campaign.Priority + 4f, HomeSystemId = shipyard.SystemId, @@ -1768,7 +1773,7 @@ internal sealed class CommanderPlanningService CampaignId = campaign.Id, Kind = "mine-bottleneck", DelegationKind = "ship", - BehaviorKind = "expert-auto-mine", + BehaviorKind = ExpertAutoMine, Status = "active", Priority = campaign.Priority + 2f, HomeSystemId = shipyard.SystemId, @@ -1838,7 +1843,9 @@ internal sealed class CommanderPlanningService var reservedCommanderIds = new HashSet(StringComparer.Ordinal); var availableMilitaryCommanders = commanders.Count(commander => commander.Kind == CommanderKind.Ship && - world.Ships.FirstOrDefault(ship => ship.Id == commander.ControlledEntityId) is { Definition.Kind: "military", Health: > 0f }); + world.Ships.FirstOrDefault(ship => ship.Id == commander.ControlledEntityId) is { } commanderShip + && commanderShip.Health > 0f + && IsMilitaryShip(commanderShip.Definition)); var committedMilitaryCommanders = 0; foreach (var objective in objectives @@ -1921,11 +1928,11 @@ internal sealed class CommanderPlanningService return objective.BehaviorKind switch { - "construct-station" => string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal), - "find-build-tasks" => string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal), - "fill-shortages" or "advanced-auto-trade" or "revisit-known-stations" or "supply-fleet" => string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal), - "local-auto-mine" or "advanced-auto-mine" or "expert-auto-mine" => HasShipCapabilities(ship.Definition, "mining"), - "patrol" or "police" or "protect-position" or "protect-ship" or "protect-station" or "attack-target" => string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal), + ConstructStation => IsConstructionShip(ship.Definition), + FindBuildTasks => IsTransportShip(ship.Definition), + FillShortages or AdvancedAutoTrade or RevisitKnownStations or SupplyFleet => IsTransportShip(ship.Definition), + LocalAutoMine or AdvancedAutoMine or ExpertAutoMine => IsMiningShip(ship.Definition), + Patrol or Police or ProtectPosition or ProtectShip or ProtectStation or AttackTarget => IsMilitaryShip(ship.Definition), _ => true, }; } @@ -1992,7 +1999,7 @@ internal sealed class CommanderPlanningService Kind = "military-fleet", Status = economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount ? "stable" : "active", Priority = 80f + (threatAssessment.ThreatSignals.Count * 4f), - ShipKind = "military", + ShipKind = MilitaryShipCategory, TargetCount = economicAssessment.TargetMilitaryShipCount, CurrentCount = economicAssessment.MilitaryShipCount, Notes = "Maintain enough military hulls for all active fronts.", @@ -2004,7 +2011,7 @@ internal sealed class CommanderPlanningService Kind = "mining-fleet", Status = economicAssessment.MinerShipCount >= economicAssessment.TargetMinerShipCount ? "stable" : "active", Priority = 60f, - ShipKind = "mining", + ShipKind = MiningShipCategory, TargetCount = economicAssessment.TargetMinerShipCount, CurrentCount = economicAssessment.MinerShipCount, Notes = "Maintain raw resource extraction capacity.", @@ -2016,7 +2023,7 @@ internal sealed class CommanderPlanningService Kind = "logistics-fleet", Status = economicAssessment.TransportShipCount >= economicAssessment.TargetTransportShipCount ? "stable" : "active", Priority = 62f, - ShipKind = "transport", + ShipKind = TransportShipCategory, TargetCount = economicAssessment.TargetTransportShipCount, CurrentCount = economicAssessment.TransportShipCount, Notes = "Maintain logistics throughput across stations and fronts.", @@ -2028,7 +2035,7 @@ internal sealed class CommanderPlanningService Kind = "construction-fleet", Status = economicAssessment.ConstructorShipCount >= economicAssessment.TargetConstructorShipCount ? "stable" : "active", Priority = expansionProject is null ? 35f : 68f, - ShipKind = "construction", + ShipKind = ConstructionShipCategory, TargetCount = economicAssessment.TargetConstructorShipCount, CurrentCount = economicAssessment.ConstructorShipCount, Notes = "Maintain construction capacity for frontier growth.", @@ -2347,10 +2354,10 @@ internal sealed class CommanderPlanningService Kind = "fleet-command", BehaviorKind = campaign.Kind switch { - "offense" => "attack-target", - "defense" => "protect-position", - "expansion" => "protect-position", - _ => "patrol", + "offense" => AttackTarget, + "defense" => ProtectPosition, + "expansion" => ProtectPosition, + _ => Patrol, }, Status = campaign.Status, Priority = campaign.Priority, @@ -2380,7 +2387,7 @@ internal sealed class CommanderPlanningService { ObjectiveId = $"objective-station-{station.Id}-ship-production", Kind = "ship-production-focus", - BehaviorKind = "fill-shortages", + BehaviorKind = FillShortages, Status = "active", Priority = 55f, HomeSystemId = station.SystemId, @@ -2399,7 +2406,7 @@ internal sealed class CommanderPlanningService { ObjectiveId = $"objective-station-{station.Id}-commodity-focus-{bottleneckItem}", Kind = "commodity-focus", - BehaviorKind = "fill-shortages", + BehaviorKind = FillShortages, Status = "active", Priority = 45f, HomeSystemId = station.SystemId, @@ -2418,7 +2425,7 @@ internal sealed class CommanderPlanningService { ObjectiveId = $"objective-station-{station.Id}-expansion-support", Kind = "expansion-support", - BehaviorKind = "find-build-tasks", + BehaviorKind = FindBuildTasks, Status = "active", Priority = 40f, HomeSystemId = station.SystemId, @@ -2435,7 +2442,7 @@ internal sealed class CommanderPlanningService { ObjectiveId = $"objective-station-{station.Id}-oversight", Kind = "station-oversight", - BehaviorKind = "fill-shortages", + BehaviorKind = FillShortages, Status = "active", Priority = 30f, HomeSystemId = station.SystemId, @@ -2460,7 +2467,7 @@ internal sealed class CommanderPlanningService faction.StrategicState.Objectives.Any(objective => objective.CampaignId == campaign.Id && objective.CommanderId is not null && - (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, "supply-fleet", StringComparison.Ordinal)))) + (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, SupplyFleet, StringComparison.Ordinal)))) .Select(campaign => campaign.Id) .ToHashSet(StringComparer.Ordinal); @@ -2510,10 +2517,10 @@ internal sealed class CommanderPlanningService Kind = "fleet-command", BehaviorKind = campaign.Kind switch { - "offense" => "attack-target", - "defense" => "protect-position", - "expansion" => "protect-position", - _ => "patrol", + "offense" => AttackTarget, + "defense" => ProtectPosition, + "expansion" => ProtectPosition, + _ => Patrol, }, Status = campaign.Status, Priority = campaign.Priority + 1f, @@ -2581,7 +2588,7 @@ internal sealed class CommanderPlanningService { if (objective?.CampaignId is not null && fleetCommanders.TryGetValue(objective.CampaignId, out var fleetCommander) - && (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, "supply-fleet", StringComparison.Ordinal))) + && (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, SupplyFleet, StringComparison.Ordinal))) { return fleetCommander.Id; } @@ -2598,25 +2605,39 @@ internal sealed class CommanderPlanningService private static DefaultBehaviorRuntime BuildFallbackBehavior(SimulationWorld world, ShipRuntime ship) { var homeStation = ResolveFallbackHomeStation(world, ship); - if (HasShipCapabilities(ship.Definition, "mining")) + if (IsMiningShip(ship.Definition)) { + if (homeStation is null) + { + return new DefaultBehaviorRuntime + { + Kind = LocalAutoMine, + HomeSystemId = ship.SystemId, + HomeStationId = null, + AreaSystemId = ship.SystemId, + ItemId = "ore", + Radius = 24f, + MaxSystemRange = 0, + }; + } + return new DefaultBehaviorRuntime { - Kind = ship.Definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine", + Kind = ship.Definition.GetTotalCargoCapacity() >= 120f ? ExpertAutoMine : AdvancedAutoMine, HomeSystemId = homeStation?.SystemId ?? ship.SystemId, HomeStationId = homeStation?.Id, AreaSystemId = homeStation?.SystemId ?? ship.SystemId, - PreferredItemId = null, + ItemId = null, Radius = 24f, - MaxSystemRange = ship.Definition.CargoCapacity >= 120f ? 3 : 1, + MaxSystemRange = ship.Definition.GetTotalCargoCapacity() >= 120f ? 3 : 1, }; } - if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal)) + if (IsTransportShip(ship.Definition)) { return new DefaultBehaviorRuntime { - Kind = "advanced-auto-trade", + Kind = AdvancedAutoTrade, HomeSystemId = homeStation?.SystemId ?? ship.SystemId, HomeStationId = homeStation?.Id, AreaSystemId = homeStation?.SystemId ?? ship.SystemId, @@ -2625,11 +2646,11 @@ internal sealed class CommanderPlanningService }; } - if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal)) + if (IsConstructionShip(ship.Definition)) { return new DefaultBehaviorRuntime { - Kind = "construct-station", + Kind = ConstructStation, HomeSystemId = homeStation?.SystemId ?? ship.SystemId, HomeStationId = homeStation?.Id, AreaSystemId = homeStation?.SystemId ?? ship.SystemId, @@ -2638,13 +2659,13 @@ internal sealed class CommanderPlanningService }; } - if (string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal)) + if (IsMilitaryShip(ship.Definition)) { var anchor = homeStation?.Position ?? ship.Position; var patrolRadius = (homeStation?.Radius ?? 30f) + 90f; return new DefaultBehaviorRuntime { - Kind = "patrol", + Kind = Patrol, HomeSystemId = homeStation?.SystemId ?? ship.SystemId, HomeStationId = homeStation?.Id, AreaSystemId = homeStation?.SystemId ?? ship.SystemId, @@ -2660,7 +2681,7 @@ internal sealed class CommanderPlanningService return new DefaultBehaviorRuntime { - Kind = "idle", + Kind = Idle, HomeSystemId = homeStation?.SystemId ?? ship.SystemId, HomeStationId = homeStation?.Id, AreaSystemId = homeStation?.SystemId ?? ship.SystemId, @@ -2684,15 +2705,15 @@ internal sealed class CommanderPlanningService var areaSystemId = objective.TargetSystemId ?? objective.HomeSystemId ?? fallback.AreaSystemId ?? ship.SystemId; var radius = objective.BehaviorKind switch { - "protect-position" or "protect-station" or "patrol" or "police" => MathF.Max(80f, fallback.Radius), - "follow-ship" or "protect-ship" => MathF.Max(18f, fallback.Radius * 0.6f), - "fill-shortages" or "advanced-auto-trade" or "find-build-tasks" => MathF.Max(20f, fallback.Radius), + ProtectPosition or ProtectStation or Patrol or Police => MathF.Max(80f, fallback.Radius), + FollowShip or ProtectShip => MathF.Max(18f, fallback.Radius * 0.6f), + FillShortages or AdvancedAutoTrade or FindBuildTasks => MathF.Max(20f, fallback.Radius), _ => fallback.Radius, }; var maxRange = objective.BehaviorKind switch { - "attack-target" or "protect-position" or "protect-station" or "protect-ship" or "patrol" or "police" => Math.Max(1, fallback.MaxSystemRange), - "fill-shortages" or "advanced-auto-trade" or "find-build-tasks" or "supply-fleet" => Math.Max(2, fallback.MaxSystemRange), + AttackTarget or ProtectPosition or ProtectStation or ProtectShip or Patrol or Police => Math.Max(1, fallback.MaxSystemRange), + FillShortages or AdvancedAutoTrade or FindBuildTasks or SupplyFleet => Math.Max(2, fallback.MaxSystemRange), _ => fallback.MaxSystemRange, }; @@ -2703,16 +2724,16 @@ internal sealed class CommanderPlanningService HomeStationId = objective.HomeStationId ?? fallback.HomeStationId, AreaSystemId = areaSystemId, TargetEntityId = objective.TargetEntityId, - PreferredItemId = objective.ItemId ?? fallback.PreferredItemId, + ItemId = objective.ItemId ?? fallback.ItemId, PreferredNodeId = fallback.PreferredNodeId, PreferredConstructionSiteId = objective.Kind is "construct-site" or "supply-site" ? objective.TargetEntityId : fallback.PreferredConstructionSiteId, PreferredModuleId = fallback.PreferredModuleId, TargetPosition = objective.TargetPosition ?? fallback.TargetPosition, - WaitSeconds = objective.BehaviorKind == "supply-fleet" ? 4f : fallback.WaitSeconds, + WaitSeconds = objective.BehaviorKind == SupplyFleet ? 4f : fallback.WaitSeconds, Radius = radius, MaxSystemRange = maxRange, - KnownStationsOnly = objective.BehaviorKind == "revisit-known-stations", - PatrolPoints = objective.BehaviorKind == "patrol" + KnownStationsOnly = objective.BehaviorKind == RevisitKnownStations, + PatrolPoints = objective.BehaviorKind == Patrol ? BuildPatrolPoints(objective.TargetPosition ?? fallback.TargetPosition ?? ship.Position, radius) : [], PatrolIndex = ship.DefaultBehavior.PatrolIndex, @@ -2728,7 +2749,7 @@ internal sealed class CommanderPlanningService target.HomeStationId = source.HomeStationId; target.AreaSystemId = source.AreaSystemId; target.TargetEntityId = source.TargetEntityId; - target.PreferredItemId = source.PreferredItemId; + target.ItemId = source.ItemId; target.PreferredNodeId = source.PreferredNodeId; target.PreferredConstructionSiteId = source.PreferredConstructionSiteId; target.PreferredModuleId = source.PreferredModuleId; @@ -2749,7 +2770,7 @@ internal sealed class CommanderPlanningService && string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal) && string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal) && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) - && string.Equals(left.PreferredItemId, right.PreferredItemId, StringComparison.Ordinal) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) && string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal) && string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal) && string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal) @@ -2805,13 +2826,15 @@ internal sealed class CommanderPlanningService { Id = $"ai-order-{objective.Id}", Kind = objective.StagingOrderKind, + SourceKind = ShipOrderSourceKind.Commander, + SourceId = objective.Id, Priority = 90 + objective.ReinforcementLevel, InterruptCurrentPlan = true, Label = $"{objective.Kind} staging", TargetEntityId = objective.TargetEntityId, TargetSystemId = targetSystemId, TargetPosition = targetPosition, - DestinationStationId = objective.BehaviorKind == "dock-and-wait" ? objective.TargetEntityId : null, + DestinationStationId = objective.BehaviorKind == DockAndWait ? objective.TargetEntityId : null, ItemId = objective.ItemId, WaitSeconds = 0f, Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f), @@ -2885,6 +2908,8 @@ internal sealed class CommanderPlanningService private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) => string.Equals(left.Id, right.Id, StringComparison.Ordinal) && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && left.SourceKind == right.SourceKind + && string.Equals(left.SourceId, right.SourceId, StringComparison.Ordinal) && left.Priority == right.Priority && left.InterruptCurrentPlan == right.InterruptCurrentPlan && string.Equals(left.Label, right.Label, StringComparison.Ordinal) @@ -2920,7 +2945,7 @@ internal sealed class CommanderPlanningService } private static bool IsCombatObjective(FactionOperationalObjectiveRuntime objective) => - objective.BehaviorKind is "attack-target" or "protect-position" or "protect-ship" or "protect-station" or "patrol" or "police"; + objective.BehaviorKind is AttackTarget or ProtectPosition or ProtectShip or ProtectStation or Patrol or Police; private static float EstimateFriendlyAssetValue(SimulationWorld world, string factionId, string systemId) { diff --git a/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs b/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs index de0b3f7..5e4fe0a 100644 --- a/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs +++ b/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs @@ -1,5 +1,7 @@ using System.Globalization; +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; + namespace SpaceGame.Api.Geopolitics.Simulation; internal sealed class GeopoliticalSimulationService @@ -198,14 +200,24 @@ internal sealed class GeopoliticalSimulationService var stationStrength = (stationsByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 50f; var siteStrength = (sitesByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 18f; var shipStrength = shipsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(ship => - ship.Definition.Kind switch - { - "military" => 9f, - "construction" => 4f, - "transport" => 3f, - _ when ship.Definition.Kind == "mining" || ship.Definition.Kind == "miner" => 3f, - _ => 2f, - }) ?? 0f; + { + if (IsMilitaryShip(ship.Definition)) + { + return 9f; + } + + if (IsConstructionShip(ship.Definition)) + { + return 4f; + } + + if (IsTransportShip(ship.Definition) || IsMiningShip(ship.Definition)) + { + return 3f; + } + + return 2f; + }) ?? 0f; var logisticsStrength = MathF.Min(30f, stationStrength * 0.18f) + siteStrength; influences.Add(new TerritoryInfluenceRuntime { diff --git a/apps/backend/GlobalUsings.cs b/apps/backend/GlobalUsings.cs index ff76e34..6bcc762 100644 --- a/apps/backend/GlobalUsings.cs +++ b/apps/backend/GlobalUsings.cs @@ -1,3 +1,6 @@ +global using SpaceGame.Api.Auth.Contracts; +global using SpaceGame.Api.Auth.Runtime; +global using SpaceGame.Api.Auth.Simulation; global using SpaceGame.Api.Definitions; global using SpaceGame.Api.Economy.Contracts; global using SpaceGame.Api.Economy.Runtime; @@ -15,7 +18,7 @@ global using SpaceGame.Api.Shared.Contracts; global using SpaceGame.Api.Shared.Runtime; global using SpaceGame.Api.Ships.Contracts; global using SpaceGame.Api.Ships.Runtime; -global using SpaceGame.Api.Ships.Simulation; +global using SpaceGame.Api.Ships.AI; global using SpaceGame.Api.Simulation.Core; global using SpaceGame.Api.Stations.Contracts; global using SpaceGame.Api.Stations.Runtime; diff --git a/apps/backend/PlayerFaction/Api/CreatePlayerOrganizationHandler.cs b/apps/backend/PlayerFaction/Api/CreatePlayerOrganizationHandler.cs index 043dc09..444ce89 100644 --- a/apps/backend/PlayerFaction/Api/CreatePlayerOrganizationHandler.cs +++ b/apps/backend/PlayerFaction/Api/CreatePlayerOrganizationHandler.cs @@ -7,7 +7,6 @@ public sealed class CreatePlayerOrganizationHandler(WorldService worldService) : public override void Configure() { Post("/api/player-faction/organizations"); - AllowAnonymous(); } public override async Task HandleAsync(PlayerOrganizationCommandRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/PlayerFaction/Api/DeletePlayerDirectiveHandler.cs b/apps/backend/PlayerFaction/Api/DeletePlayerDirectiveHandler.cs index fe3626c..dced7ac 100644 --- a/apps/backend/PlayerFaction/Api/DeletePlayerDirectiveHandler.cs +++ b/apps/backend/PlayerFaction/Api/DeletePlayerDirectiveHandler.cs @@ -12,7 +12,6 @@ public sealed class DeletePlayerDirectiveHandler(WorldService worldService) : En public override void Configure() { Delete("/api/player-faction/directives/{directiveId}"); - AllowAnonymous(); } public override async Task HandleAsync(DeletePlayerDirectiveRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/PlayerFaction/Api/DeletePlayerOrganizationHandler.cs b/apps/backend/PlayerFaction/Api/DeletePlayerOrganizationHandler.cs index d581c20..1dcc4a5 100644 --- a/apps/backend/PlayerFaction/Api/DeletePlayerOrganizationHandler.cs +++ b/apps/backend/PlayerFaction/Api/DeletePlayerOrganizationHandler.cs @@ -12,7 +12,6 @@ public sealed class DeletePlayerOrganizationHandler(WorldService worldService) : public override void Configure() { Delete("/api/player-faction/organizations/{organizationId}"); - AllowAnonymous(); } public override async Task HandleAsync(DeletePlayerOrganizationRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/PlayerFaction/Api/GetPlayerFactionHandler.cs b/apps/backend/PlayerFaction/Api/GetPlayerFactionHandler.cs index 1bbc8be..909c835 100644 --- a/apps/backend/PlayerFaction/Api/GetPlayerFactionHandler.cs +++ b/apps/backend/PlayerFaction/Api/GetPlayerFactionHandler.cs @@ -7,7 +7,6 @@ public sealed class GetPlayerFactionHandler(WorldService worldService) : Endpoin public override void Configure() { Get("/api/player-faction"); - AllowAnonymous(); } public override async Task HandleAsync(CancellationToken cancellationToken) diff --git a/apps/backend/PlayerFaction/Api/UpdatePlayerOrganizationMembershipHandler.cs b/apps/backend/PlayerFaction/Api/UpdatePlayerOrganizationMembershipHandler.cs index 29aa0b6..ce1bedd 100644 --- a/apps/backend/PlayerFaction/Api/UpdatePlayerOrganizationMembershipHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpdatePlayerOrganizationMembershipHandler.cs @@ -7,7 +7,6 @@ public sealed class UpdatePlayerOrganizationMembershipHandler(WorldService world public override void Configure() { Put("/api/player-faction/organizations/{organizationId}/membership"); - AllowAnonymous(); } public override async Task HandleAsync(PlayerOrganizationMembershipCommandRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/PlayerFaction/Api/UpdatePlayerStrategicIntentHandler.cs b/apps/backend/PlayerFaction/Api/UpdatePlayerStrategicIntentHandler.cs index 05f509f..cb846a5 100644 --- a/apps/backend/PlayerFaction/Api/UpdatePlayerStrategicIntentHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpdatePlayerStrategicIntentHandler.cs @@ -7,7 +7,6 @@ public sealed class UpdatePlayerStrategicIntentHandler(WorldService worldService public override void Configure() { Put("/api/player-faction/strategic-intent"); - AllowAnonymous(); } public override async Task HandleAsync(PlayerStrategicIntentCommandRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerAssignmentHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerAssignmentHandler.cs index 0896571..cf99e9d 100644 --- a/apps/backend/PlayerFaction/Api/UpsertPlayerAssignmentHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerAssignmentHandler.cs @@ -7,7 +7,6 @@ public sealed class UpsertPlayerAssignmentHandler(WorldService worldService) : E public override void Configure() { Put("/api/player-faction/assets/{assetId}/assignment"); - AllowAnonymous(); } public override async Task HandleAsync(PlayerAssetAssignmentCommandRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerAutomationPolicyHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerAutomationPolicyHandler.cs index b0759f4..a3cc54b 100644 --- a/apps/backend/PlayerFaction/Api/UpsertPlayerAutomationPolicyHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerAutomationPolicyHandler.cs @@ -8,7 +8,6 @@ public sealed class UpsertPlayerAutomationPolicyHandler(WorldService worldServic { Post("/api/player-faction/automation-policies"); Put("/api/player-faction/automation-policies/{automationPolicyId}"); - AllowAnonymous(); } public override async Task HandleAsync(PlayerAutomationPolicyCommandRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerDirectiveHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerDirectiveHandler.cs index d622c83..ebdbbe7 100644 --- a/apps/backend/PlayerFaction/Api/UpsertPlayerDirectiveHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerDirectiveHandler.cs @@ -8,7 +8,6 @@ public sealed class UpsertPlayerDirectiveHandler(WorldService worldService) : En { Post("/api/player-faction/directives"); Put("/api/player-faction/directives/{directiveId}"); - AllowAnonymous(); } public override async Task HandleAsync(PlayerDirectiveCommandRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerPolicyHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerPolicyHandler.cs index f189aa7..10a36c7 100644 --- a/apps/backend/PlayerFaction/Api/UpsertPlayerPolicyHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerPolicyHandler.cs @@ -8,7 +8,6 @@ public sealed class UpsertPlayerPolicyHandler(WorldService worldService) : Endpo { Post("/api/player-faction/policies"); Put("/api/player-faction/policies/{policyId}"); - AllowAnonymous(); } public override async Task HandleAsync(PlayerPolicyCommandRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerProductionProgramHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerProductionProgramHandler.cs index 8d5fb36..67e61de 100644 --- a/apps/backend/PlayerFaction/Api/UpsertPlayerProductionProgramHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerProductionProgramHandler.cs @@ -8,7 +8,6 @@ public sealed class UpsertPlayerProductionProgramHandler(WorldService worldServi { Post("/api/player-faction/production-programs"); Put("/api/player-faction/production-programs/{productionProgramId}"); - AllowAnonymous(); } public override async Task HandleAsync(PlayerProductionProgramCommandRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerReinforcementPolicyHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerReinforcementPolicyHandler.cs index bc693fe..7adcebc 100644 --- a/apps/backend/PlayerFaction/Api/UpsertPlayerReinforcementPolicyHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerReinforcementPolicyHandler.cs @@ -8,7 +8,6 @@ public sealed class UpsertPlayerReinforcementPolicyHandler(WorldService worldSer { Post("/api/player-faction/reinforcement-policies"); Put("/api/player-faction/reinforcement-policies/{reinforcementPolicyId}"); - AllowAnonymous(); } public override async Task HandleAsync(PlayerReinforcementPolicyCommandRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs b/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs index 890e270..2edd146 100644 --- a/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs +++ b/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs @@ -1,3 +1,5 @@ +using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; + namespace SpaceGame.Api.PlayerFaction.Runtime; public sealed class PlayerFactionRuntime @@ -180,7 +182,7 @@ public sealed class PlayerAutomationPolicyRuntime public string ScopeKind { get; set; } = "player-faction"; public string? ScopeId { get; set; } public bool Enabled { get; set; } = true; - public string BehaviorKind { get; set; } = "idle"; + public string BehaviorKind { get; set; } = Idle; public bool UseOrders { get; set; } public string? StagingOrderKind { get; set; } public int MaxSystemRange { get; set; } @@ -242,7 +244,7 @@ public sealed class PlayerDirectiveRuntime public string? HomeStationId { get; set; } public string? SourceStationId { get; set; } public string? DestinationStationId { get; set; } - public string BehaviorKind { get; set; } = "idle"; + public string BehaviorKind { get; set; } = Idle; public bool UseOrders { get; set; } public string? StagingOrderKind { get; set; } public string? ItemId { get; set; } diff --git a/apps/backend/PlayerFaction/Simulation/IPlayerStateStore.cs b/apps/backend/PlayerFaction/Simulation/IPlayerStateStore.cs new file mode 100644 index 0000000..56b3e45 --- /dev/null +++ b/apps/backend/PlayerFaction/Simulation/IPlayerStateStore.cs @@ -0,0 +1,9 @@ +namespace SpaceGame.Api.PlayerFaction.Simulation; + +public interface IPlayerStateStore +{ + bool TryGetPlayerFaction(string playerId, out PlayerFactionRuntime playerFaction); + PlayerFactionRuntime GetOrAddPlayerFaction(string playerId, Func factory); + IReadOnlyCollection GetPlayerFactions(); + void Clear(); +} diff --git a/apps/backend/PlayerFaction/Simulation/PlayerFactionProjectionService.cs b/apps/backend/PlayerFaction/Simulation/PlayerFactionProjectionService.cs new file mode 100644 index 0000000..fb5ac88 --- /dev/null +++ b/apps/backend/PlayerFaction/Simulation/PlayerFactionProjectionService.cs @@ -0,0 +1,270 @@ +namespace SpaceGame.Api.PlayerFaction.Simulation; + +public sealed class PlayerFactionProjectionService +{ + public PlayerFactionSnapshot? ToSnapshot(PlayerFactionRuntime? player) + { + if (player is null) + { + return null; + } + + return new PlayerFactionSnapshot( + player.Id, + player.Label, + player.SovereignFactionId, + player.Status, + player.CreatedAtUtc, + player.UpdatedAtUtc, + new PlayerAssetRegistrySnapshot( + player.AssetRegistry.ShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.CommanderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.ClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.ConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.PolicySetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.EconomicRegionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList()), + new PlayerStrategicIntentSnapshot( + player.StrategicIntent.StrategicPosture, + player.StrategicIntent.EconomicPosture, + player.StrategicIntent.MilitaryPosture, + player.StrategicIntent.LogisticsPosture, + player.StrategicIntent.DesiredReserveRatio, + player.StrategicIntent.AllowDelegatedCombatAutomation, + player.StrategicIntent.AllowDelegatedEconomicAutomation, + player.StrategicIntent.Notes), + player.Fleets.Select(fleet => new PlayerFleetSnapshot( + fleet.Id, + fleet.Label, + fleet.Status, + fleet.Role, + fleet.CommanderId, + fleet.FrontId, + fleet.HomeSystemId, + fleet.HomeStationId, + fleet.PolicyId, + fleet.AutomationPolicyId, + fleet.ReinforcementPolicyId, + fleet.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + fleet.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + fleet.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + fleet.UpdatedAtUtc)).ToList(), + player.TaskForces.Select(taskForce => new PlayerTaskForceSnapshot( + taskForce.Id, + taskForce.Label, + taskForce.Status, + taskForce.Role, + taskForce.FleetId, + taskForce.CommanderId, + taskForce.FrontId, + taskForce.PolicyId, + taskForce.AutomationPolicyId, + taskForce.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + taskForce.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + taskForce.UpdatedAtUtc)).ToList(), + player.StationGroups.Select(group => new PlayerStationGroupSnapshot( + group.Id, + group.Label, + group.Status, + group.Role, + group.EconomicRegionId, + group.PolicyId, + group.AutomationPolicyId, + group.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + group.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + group.FocusItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + group.UpdatedAtUtc)).ToList(), + player.EconomicRegions.Select(region => new PlayerEconomicRegionSnapshot( + region.Id, + region.Label, + region.Status, + region.Role, + region.SharedEconomicRegionId, + region.PolicyId, + region.AutomationPolicyId, + region.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.UpdatedAtUtc)).ToList(), + player.Fronts.Select(front => new PlayerFrontSnapshot( + front.Id, + front.Label, + front.Status, + front.Priority, + front.Posture, + front.SharedFrontLineId, + front.TargetFactionId, + front.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.UpdatedAtUtc)).ToList(), + player.Reserves.Select(reserve => new PlayerReserveGroupSnapshot( + reserve.Id, + reserve.Label, + reserve.Status, + reserve.ReserveKind, + reserve.HomeSystemId, + reserve.PolicyId, + reserve.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + reserve.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + reserve.UpdatedAtUtc)).ToList(), + player.Policies.Select(policy => new PlayerFactionPolicySnapshot( + policy.Id, + policy.Label, + policy.ScopeKind, + policy.ScopeId, + policy.PolicySetId, + policy.AllowDelegatedCombat, + policy.AllowDelegatedTrade, + policy.ReserveCreditsRatio, + policy.ReserveMilitaryRatio, + policy.TradeAccessPolicy, + policy.DockingAccessPolicy, + policy.ConstructionAccessPolicy, + policy.OperationalRangePolicy, + policy.CombatEngagementPolicy, + policy.AvoidHostileSystems, + policy.FleeHullRatio, + policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + policy.Notes, + policy.UpdatedAtUtc)).ToList(), + player.AutomationPolicies.Select(policy => new PlayerAutomationPolicySnapshot( + policy.Id, + policy.Label, + policy.ScopeKind, + policy.ScopeId, + policy.Enabled, + policy.BehaviorKind, + policy.UseOrders, + policy.StagingOrderKind, + policy.MaxSystemRange, + policy.KnownStationsOnly, + policy.Radius, + policy.WaitSeconds, + policy.PreferredItemId, + policy.Notes, + policy.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(), + policy.UpdatedAtUtc)).ToList(), + player.ReinforcementPolicies.Select(policy => new PlayerReinforcementPolicySnapshot( + policy.Id, + policy.Label, + policy.ScopeKind, + policy.ScopeId, + policy.ShipKind, + policy.DesiredAssetCount, + policy.MinimumReserveCount, + policy.AutoTransferReserves, + policy.AutoQueueProduction, + policy.SourceReserveId, + policy.TargetFrontId, + policy.Notes, + policy.UpdatedAtUtc)).ToList(), + player.ProductionPrograms.Select(program => new PlayerProductionProgramSnapshot( + program.Id, + program.Label, + program.Status, + program.Kind, + program.TargetShipKind, + program.TargetModuleId, + program.TargetItemId, + program.TargetCount, + program.CurrentCount, + program.StationGroupId, + program.ReinforcementPolicyId, + program.Notes, + program.UpdatedAtUtc)).ToList(), + player.Directives.Select(directive => new PlayerDirectiveSnapshot( + directive.Id, + directive.Label, + directive.Status, + directive.Kind, + directive.ScopeKind, + directive.ScopeId, + directive.TargetEntityId, + directive.TargetSystemId, + directive.TargetPosition is null ? null : ToDto(directive.TargetPosition.Value), + directive.HomeSystemId, + directive.HomeStationId, + directive.SourceStationId, + directive.DestinationStationId, + directive.BehaviorKind, + directive.UseOrders, + directive.StagingOrderKind, + directive.ItemId, + directive.PreferredNodeId, + directive.PreferredConstructionSiteId, + directive.PreferredModuleId, + directive.Priority, + directive.Radius, + directive.WaitSeconds, + directive.MaxSystemRange, + directive.KnownStationsOnly, + directive.PatrolPoints.Select(ToDto).ToList(), + directive.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(), + directive.PolicyId, + directive.AutomationPolicyId, + directive.Notes, + directive.CreatedAtUtc, + directive.UpdatedAtUtc)).ToList(), + player.Assignments.Select(assignment => new PlayerAssignmentSnapshot( + assignment.Id, + assignment.AssetKind, + assignment.AssetId, + assignment.FleetId, + assignment.TaskForceId, + assignment.StationGroupId, + assignment.EconomicRegionId, + assignment.FrontId, + assignment.ReserveId, + assignment.DirectiveId, + assignment.PolicyId, + assignment.AutomationPolicyId, + assignment.Role, + assignment.Status, + assignment.UpdatedAtUtc)).ToList(), + player.DecisionLog.Select(entry => new PlayerDecisionLogEntrySnapshot( + entry.Id, + entry.Kind, + entry.Summary, + entry.RelatedEntityKind, + entry.RelatedEntityId, + entry.OccurredAtUtc)).ToList(), + player.Alerts.Select(alert => new PlayerAlertSnapshot( + alert.Id, + alert.Kind, + alert.Severity, + alert.Summary, + alert.AssetKind, + alert.AssetId, + alert.RelatedDirectiveId, + alert.Status, + alert.CreatedAtUtc)).ToList()); + } + + private static ShipOrderTemplateSnapshot ToShipOrderTemplateSnapshot(ShipOrderTemplateRuntime template) => + new( + template.Kind, + template.Label, + template.TargetEntityId, + template.TargetSystemId, + template.TargetPosition is null ? null : ToDto(template.TargetPosition.Value), + template.SourceStationId, + template.DestinationStationId, + template.ItemId, + template.NodeId, + template.ConstructionSiteId, + template.ModuleId, + template.WaitSeconds, + template.Radius, + template.MaxSystemRange, + template.KnownStationsOnly); + + private static Vector3Dto ToDto(Vector3 vector) => new(vector.X, vector.Y, vector.Z); +} diff --git a/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs b/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs index 56b54ab..7b96ab1 100644 --- a/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs +++ b/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs @@ -1,3 +1,6 @@ +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; +using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; + namespace SpaceGame.Api.PlayerFaction.Simulation; internal sealed class PlayerFactionService @@ -6,58 +9,61 @@ internal sealed class PlayerFactionService private const int MaxAlerts = 32; private const string PlayerFactionDomainId = "player-faction"; - internal static bool IsPlayerFaction(SimulationWorld world, string factionId) => - world.PlayerFaction is not null && string.Equals(world.PlayerFaction.SovereignFactionId, factionId, StringComparison.Ordinal); + internal static bool IsPlayerFaction(IPlayerStateStore playerStateStore, string factionId) => + playerStateStore.GetPlayerFactions().Any(player => + string.Equals(player.SovereignFactionId, factionId, StringComparison.Ordinal)); - internal PlayerFactionRuntime EnsureDomain(SimulationWorld world) + internal PlayerFactionRuntime? TryGetDomain(IPlayerStateStore playerStateStore, string playerId) { - if (world.PlayerFaction is not null) - { - return world.PlayerFaction; - } + return playerStateStore.TryGetPlayerFaction(playerId, out var player) ? player : null; + } + 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."); - world.PlayerFaction = new PlayerFactionRuntime + var player = playerStateStore.GetOrAddPlayerFaction(playerId, () => new PlayerFactionRuntime { Id = PlayerFactionDomainId, Label = $"{sovereignFaction.Label} Command", SovereignFactionId = sovereignFaction.Id, CreatedAtUtc = world.GeneratedAtUtc, UpdatedAtUtc = world.GeneratedAtUtc, - }; + }); - EnsureBaseStructures(world, world.PlayerFaction); - SyncRegistry(world, world.PlayerFaction); - return world.PlayerFaction; + EnsureBaseStructures(world, player); + SyncRegistry(world, player); + return player; } - internal void Update(SimulationWorld world, float _deltaSeconds, ICollection events) + internal void Update(SimulationWorld world, IPlayerStateStore playerStateStore, float _deltaSeconds, ICollection events) { - if (world.PlayerFaction is null && world.Factions.Count == 0) + if (playerStateStore.GetPlayerFactions().Count == 0) { return; } - var player = EnsureDomain(world); - EnsureBaseStructures(world, player); - SyncRegistry(world, player); - PrunePlayerState(world, player); - RefreshGeopoliticalOrganizationContext(world, player); - ReconcileOrganizationAssignments(world, player); - ReconcileDirectiveScopes(player); - RefreshProductionPrograms(world, player); - ApplyStrategicIntegration(world, player); - ApplyPolicies(world, player); - ApplyAssignmentsAndDirectives(world, player, events); - RefreshAlerts(world, player); - player.UpdatedAtUtc = DateTimeOffset.UtcNow; + foreach (var player in playerStateStore.GetPlayerFactions()) + { + EnsureBaseStructures(world, player); + SyncRegistry(world, player); + PrunePlayerState(world, player); + RefreshGeopoliticalOrganizationContext(world, player); + ReconcileOrganizationAssignments(world, player); + ReconcileDirectiveScopes(player); + RefreshProductionPrograms(world, player); + ApplyStrategicIntegration(world, player); + ApplyPolicies(world, player); + ApplyAssignmentsAndDirectives(world, player, events); + RefreshAlerts(world, player); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + } } - internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, PlayerOrganizationCommandRequest request) + internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerOrganizationCommandRequest request) { - var player = EnsureDomain(world); + var player = EnsureDomain(world, playerStateStore, playerId); var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player)); var nowUtc = DateTimeOffset.UtcNow; @@ -172,9 +178,9 @@ internal sealed class PlayerFactionService return player; } - internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, string organizationId) + internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId) { - var player = EnsureDomain(world); + var player = EnsureDomain(world, playerStateStore, playerId); RemoveOrganization(player, organizationId); player.Assignments.RemoveAll(assignment => assignment.FleetId == organizationId || @@ -190,9 +196,9 @@ internal sealed class PlayerFactionService return player; } - internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, string organizationId, PlayerOrganizationMembershipCommandRequest request) + internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId, PlayerOrganizationMembershipCommandRequest request) { - var player = EnsureDomain(world); + var player = EnsureDomain(world, playerStateStore, playerId); var kind = ResolveOrganizationKind(player, organizationId); switch (kind) { @@ -241,9 +247,9 @@ internal sealed class PlayerFactionService return player; } - internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, string? directiveId, PlayerDirectiveCommandRequest request) + internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? directiveId, PlayerDirectiveCommandRequest request) { - var player = EnsureDomain(world); + var player = EnsureDomain(world, playerStateStore, playerId); var directive = directiveId is null ? null : player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal)); @@ -318,9 +324,9 @@ internal sealed class PlayerFactionService return player; } - internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, string directiveId) + internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string directiveId) { - var player = EnsureDomain(world); + var player = EnsureDomain(world, playerStateStore, playerId); player.Directives.RemoveAll(directive => directive.Id == directiveId); foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId)) { @@ -332,9 +338,9 @@ internal sealed class PlayerFactionService return player; } - internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, string? policyId, PlayerPolicyCommandRequest request) + internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? policyId, PlayerPolicyCommandRequest request) { - var player = EnsureDomain(world); + var player = EnsureDomain(world, playerStateStore, playerId); var policy = policyId is null ? null : player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal)); @@ -403,9 +409,9 @@ internal sealed class PlayerFactionService return player; } - internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request) + internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request) { - var player = EnsureDomain(world); + var player = EnsureDomain(world, playerStateStore, playerId); var policy = automationPolicyId is null ? null : player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal)); @@ -461,9 +467,9 @@ internal sealed class PlayerFactionService return player; } - internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request) + internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request) { - var player = EnsureDomain(world); + var player = EnsureDomain(world, playerStateStore, playerId); var policy = reinforcementPolicyId is null ? null : player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal)); @@ -495,9 +501,9 @@ internal sealed class PlayerFactionService return player; } - internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, string? productionProgramId, PlayerProductionProgramCommandRequest request) + internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? productionProgramId, PlayerProductionProgramCommandRequest request) { - var player = EnsureDomain(world); + var player = EnsureDomain(world, playerStateStore, playerId); var program = productionProgramId is null ? null : player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal)); @@ -527,9 +533,9 @@ internal sealed class PlayerFactionService return player; } - internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, string assetId, PlayerAssetAssignmentCommandRequest request) + internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string assetId, PlayerAssetAssignmentCommandRequest request) { - var player = EnsureDomain(world); + var player = EnsureDomain(world, playerStateStore, playerId); var assignment = player.Assignments.FirstOrDefault(candidate => string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) && string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal)); @@ -586,9 +592,9 @@ internal sealed class PlayerFactionService return player; } - internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, PlayerStrategicIntentCommandRequest request) + internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerStrategicIntentCommandRequest request) { - var player = EnsureDomain(world); + var player = EnsureDomain(world, playerStateStore, playerId); player.StrategicIntent.StrategicPosture = request.StrategicPosture; player.StrategicIntent.EconomicPosture = request.EconomicPosture; player.StrategicIntent.MilitaryPosture = request.MilitaryPosture; @@ -602,9 +608,9 @@ internal sealed class PlayerFactionService return player; } - internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, string shipId, ShipOrderCommandRequest request) + internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipOrderCommandRequest request) { - var player = EnsureDomain(world); + var player = EnsureDomain(world, playerStateStore, playerId); if (!player.AssetRegistry.ShipIds.Contains(shipId)) { return null; @@ -625,6 +631,8 @@ internal sealed class PlayerFactionService { Id = $"order-{ship.Id}-{Guid.NewGuid():N}", Kind = request.Kind, + SourceKind = ShipOrderSourceKind.Player, + SourceId = playerId, Priority = request.Priority, InterruptCurrentPlan = request.InterruptCurrentPlan, Label = request.Label, @@ -643,11 +651,11 @@ internal sealed class PlayerFactionService KnownStationsOnly = request.KnownStationsOnly ?? false, }); - AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Label}.", "ship", shipId); + AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Name}.", "ship", shipId); player.UpdatedAtUtc = DateTimeOffset.UtcNow; ship.ControlSourceKind = "player-order"; ship.ControlSourceId = ship.OrderQueue - .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .Where(order => order.SourceKind == ShipOrderSourceKind.Player) .OrderByDescending(order => order.Priority) .ThenBy(order => order.CreatedAtUtc) .Select(order => order.Id) @@ -659,9 +667,9 @@ internal sealed class PlayerFactionService return ship; } - internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, string shipId, string orderId) + internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId) { - var player = EnsureDomain(world); + var player = EnsureDomain(world, playerStateStore, playerId); if (!player.AssetRegistry.ShipIds.Contains(shipId)) { return null; @@ -676,21 +684,21 @@ internal sealed class PlayerFactionService var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId); if (removed > 0) { - AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Label}.", "ship", shipId); + AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Name}.", "ship", shipId); player.UpdatedAtUtc = DateTimeOffset.UtcNow; } - ship.ControlSourceKind = ship.OrderQueue.Any(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player) ? "player-order" : "player-manual"; ship.ControlSourceId = ship.OrderQueue - .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .Where(order => order.SourceKind == ShipOrderSourceKind.Player) .OrderByDescending(order => order.Priority) .ThenBy(order => order.CreatedAtUtc) .Select(order => order.Id) .FirstOrDefault(); ship.ControlReason = ship.OrderQueue - .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .Where(order => order.SourceKind == ShipOrderSourceKind.Player) .OrderByDescending(order => order.Priority) .ThenBy(order => order.CreatedAtUtc) .Select(order => order.Label ?? order.Kind) @@ -702,9 +710,9 @@ internal sealed class PlayerFactionService return ship; } - internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, string shipId, ShipDefaultBehaviorCommandRequest request) + internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipDefaultBehaviorCommandRequest request) { - var player = EnsureDomain(world); + var player = EnsureDomain(world, playerStateStore, playerId); if (!player.AssetRegistry.ShipIds.Contains(shipId)) { return null; @@ -723,7 +731,7 @@ internal sealed class PlayerFactionService directive = new PlayerDirectiveRuntime { Id = directiveId, - Label = $"Direct control {ship.Definition.Label}", + Label = $"Direct control {ship.Definition.Name}", ScopeKind = "ship", ScopeId = shipId, Kind = "direct-control", @@ -732,7 +740,7 @@ internal sealed class PlayerFactionService player.Directives.Add(directive); } - directive.Label = $"Direct control {ship.Definition.Label}"; + directive.Label = $"Direct control {ship.Definition.Name}"; directive.Kind = "direct-control"; directive.ScopeKind = "ship"; directive.ScopeId = shipId; @@ -746,7 +754,7 @@ internal sealed class PlayerFactionService directive.HomeStationId = request.HomeStationId; directive.SourceStationId = request.HomeStationId; directive.DestinationStationId = null; - directive.ItemId = request.PreferredItemId; + directive.ItemId = request.ItemId; directive.PreferredNodeId = request.PreferredNodeId; directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId; directive.PreferredModuleId = request.PreferredModuleId; @@ -793,7 +801,7 @@ internal sealed class PlayerFactionService ship.ControlSourceKind = "player-directive"; ship.ControlSourceId = directive.Id; ship.ControlReason = directive.Label; - AddDecision(player, "ship-behavior-configured", $"Configured {request.Kind} for {ship.Definition.Label}.", "ship", shipId); + AddDecision(player, "ship-behavior-configured", $"Configured {request.Kind} for {ship.Definition.Name}.", "ship", shipId); player.UpdatedAtUtc = directive.UpdatedAtUtc; ship.NeedsReplan = true; ship.LastReplanReason = "player-behavior-configured"; @@ -826,7 +834,7 @@ internal sealed class PlayerFactionService { Id = "player-core-automation", Label = "Core Automation", - BehaviorKind = "idle", + BehaviorKind = Idle, }); } @@ -1035,7 +1043,7 @@ internal sealed class PlayerFactionService var changed = ApplyDirectiveToShip(commander, ship, directive, automation, assignment); if (changed && directive is not null) { - events.Add(new SimulationEventRecord("ship", ship.Id, "player-directive", $"{ship.Definition.Label} aligned to player directive {directive.Label}.", DateTimeOffset.UtcNow, "player", "universe", ship.Id)); + events.Add(new SimulationEventRecord("ship", ship.Id, "player-directive", $"{ship.Definition.Name} aligned to player directive {directive.Label}.", DateTimeOffset.UtcNow, "player", "universe", ship.Id)); } } @@ -1246,13 +1254,13 @@ internal sealed class PlayerFactionService ? "player-directive" : automation is not null ? "player-automation" - : ship.OrderQueue.Any(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + : ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player) ? "player-order" : "player-manual"; var desiredControlSourceId = directive?.Id ?? automation?.Id ?? ship.OrderQueue - .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .Where(order => order.SourceKind == ShipOrderSourceKind.Player) .OrderByDescending(order => order.Priority) .ThenBy(order => order.CreatedAtUtc) .Select(order => order.Id) @@ -1260,7 +1268,7 @@ internal sealed class PlayerFactionService var desiredControlReason = directive?.Label ?? automation?.Label ?? ship.OrderQueue - .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .Where(order => order.SourceKind == ShipOrderSourceKind.Player) .OrderByDescending(order => order.Priority) .ThenBy(order => order.CreatedAtUtc) .Select(order => order.Label ?? order.Kind) @@ -1342,7 +1350,7 @@ internal sealed class PlayerFactionService HomeStationId = directive?.HomeStationId ?? ship.DefaultBehavior.HomeStationId, AreaSystemId = directive?.TargetSystemId ?? directive?.HomeSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId, TargetEntityId = directive?.TargetEntityId, - PreferredItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.PreferredItemId, + ItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.ItemId, PreferredNodeId = directive?.PreferredNodeId ?? ship.DefaultBehavior.PreferredNodeId, PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId, PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId, @@ -1375,6 +1383,8 @@ internal sealed class PlayerFactionService { Id = aiOrderId!, Kind = directive.StagingOrderKind!, + SourceKind = ShipOrderSourceKind.Player, + SourceId = directive.Id, Priority = Math.Max(0, directive.Priority), InterruptCurrentPlan = true, Label = directive.Label, @@ -1447,7 +1457,7 @@ internal sealed class PlayerFactionService target.HomeStationId = source.HomeStationId; target.AreaSystemId = source.AreaSystemId; target.TargetEntityId = source.TargetEntityId; - target.PreferredItemId = source.PreferredItemId; + target.ItemId = source.ItemId; target.PreferredNodeId = source.PreferredNodeId; target.PreferredConstructionSiteId = source.PreferredConstructionSiteId; target.PreferredModuleId = source.PreferredModuleId; @@ -1468,7 +1478,7 @@ internal sealed class PlayerFactionService && string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal) && string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal) && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) - && string.Equals(left.PreferredItemId, right.PreferredItemId, StringComparison.Ordinal) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) && string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal) && string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal) && string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal) @@ -1501,6 +1511,8 @@ internal sealed class PlayerFactionService private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) => string.Equals(left.Id, right.Id, StringComparison.Ordinal) && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && left.SourceKind == right.SourceKind + && string.Equals(left.SourceId, right.SourceId, StringComparison.Ordinal) && left.Priority == right.Priority && left.InterruptCurrentPlan == right.InterruptCurrentPlan && string.Equals(left.Label, right.Label, StringComparison.Ordinal) @@ -1716,7 +1728,7 @@ internal sealed class PlayerFactionService { program.CurrentCount = world.Ships.Count(ship => ship.FactionId == player.SovereignFactionId && - string.Equals(ship.Definition.Kind, program.TargetShipKind, StringComparison.Ordinal)); + string.Equals(GetShipCategory(ship.Definition), program.TargetShipKind, StringComparison.Ordinal)); } else { @@ -2113,7 +2125,7 @@ internal sealed class PlayerFactionService { var available = world.Ships.Count(ship => ship.FactionId == player.SovereignFactionId && - string.Equals(ship.Definition.Kind, policy.ShipKind, StringComparison.Ordinal)); + string.Equals(GetShipCategory(ship.Definition), policy.ShipKind, StringComparison.Ordinal)); if (available < policy.DesiredAssetCount) { player.Alerts.Add(new PlayerAlertRuntime diff --git a/apps/backend/PlayerFaction/Simulation/PlayerStateStore.cs b/apps/backend/PlayerFaction/Simulation/PlayerStateStore.cs new file mode 100644 index 0000000..3f6c2ee --- /dev/null +++ b/apps/backend/PlayerFaction/Simulation/PlayerStateStore.cs @@ -0,0 +1,26 @@ +namespace SpaceGame.Api.PlayerFaction.Simulation; + +public sealed class PlayerStateStore : IPlayerStateStore +{ + private readonly Dictionary _playerFactions = new(StringComparer.Ordinal); + + public bool TryGetPlayerFaction(string playerId, out PlayerFactionRuntime playerFaction) => + _playerFactions.TryGetValue(playerId, out playerFaction!); + + public PlayerFactionRuntime GetOrAddPlayerFaction(string playerId, Func factory) + { + if (_playerFactions.TryGetValue(playerId, out var existing)) + { + return existing; + } + + var created = factory(); + _playerFactions[playerId] = created; + return created; + } + + public IReadOnlyCollection GetPlayerFactions() => + _playerFactions.Values.ToList(); + + public void Clear() => _playerFactions.Clear(); +} diff --git a/apps/backend/Program.cs b/apps/backend/Program.cs index 1d7e2ea..8bbc951 100644 --- a/apps/backend/Program.cs +++ b/apps/backend/Program.cs @@ -1,11 +1,14 @@ +using System.Text; using FastEndpoints; using FastEndpoints.Swagger; -using SpaceGame.Api.Universe.Scenario; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Npgsql; using SpaceGame.Api.Universe.Bootstrap; -using SpaceGame.Api.Universe.Simulation; + +const string StartupScenarioPath = "scenarios/empty.json"; var builder = WebApplication.CreateBuilder(args); -const string StartupScenarioPath = "scenarios/empty.json"; builder.Services.AddCors((options) => { @@ -46,10 +49,67 @@ builder.Services }) .ValidateOnStart(); builder.Services.Configure(builder.Configuration.GetSection("Balance")); -builder.Services.Configure(builder.Configuration.GetSection("WorldGeneration")); builder.Services.Configure(builder.Configuration.GetSection("OrbitalSimulation")); +builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection("Auth")) + .Validate(options => !string.IsNullOrWhiteSpace(options.ConnectionString), "Auth:ConnectionString must be configured.") + .ValidateOnStart(); +builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection("Jwt")) + .Validate(options => !string.IsNullOrWhiteSpace(options.SigningKey), "Jwt:SigningKey must be configured.") + .ValidateOnStart(); + +var jwtOptions = builder.Configuration.GetSection("Jwt").Get() ?? new JwtOptions(); +var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)); +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + ValidIssuer = jwtOptions.Issuer, + ValidAudience = jwtOptions.Audience, + IssuerSigningKey = signingKey, + ClockSkew = TimeSpan.FromSeconds(30), + }; + }); +builder.Services + .AddAuthorizationBuilder() + .AddPolicy(AuthPolicyNames.AdminAccess, policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireRole(AuthRoleNames.Admin); + }) + .AddPolicy(AuthPolicyNames.GmAccess, policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireRole(AuthRoleNames.Gm); + }); +builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton((serviceProvider) => +{ + var authOptions = serviceProvider.GetRequiredService>(); + return new NpgsqlDataSourceBuilder(authOptions.Value.ConnectionString).Build(); +}); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); @@ -68,9 +128,16 @@ builder.Services.AddFastEndpoints(); builder.Services.SwaggerDocument(); var app = builder.Build(); -app.Services.GetRequiredService().LoadFromScenario(StartupScenarioPath); +await app.Services.GetRequiredService().EnsureSchemaAsync(CancellationToken.None); +if (builder.Environment.IsDevelopment()) +{ + await app.Services.GetRequiredService().SeedAsync(CancellationToken.None); + app.Services.GetRequiredService().LoadFromScenario(StartupScenarioPath); +} app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); app.UseFastEndpoints(); app.UseSwaggerGen(); diff --git a/apps/backend/Shared/Contracts/VersionInfo.cs b/apps/backend/Shared/Contracts/VersionInfo.cs new file mode 100644 index 0000000..2b2767b --- /dev/null +++ b/apps/backend/Shared/Contracts/VersionInfo.cs @@ -0,0 +1,7 @@ +namespace SpaceGame.Api.Shared.Contracts; + +public sealed record VersionInfoSnapshot( + string Version, + string Environment, + string? CommitSha, + DateTimeOffset StartedAtUtc); diff --git a/apps/backend/Shared/Runtime/AppVersionService.cs b/apps/backend/Shared/Runtime/AppVersionService.cs new file mode 100644 index 0000000..02d0e7d --- /dev/null +++ b/apps/backend/Shared/Runtime/AppVersionService.cs @@ -0,0 +1,29 @@ +using System.Reflection; +using Microsoft.Extensions.Hosting; + +namespace SpaceGame.Api.Shared.Runtime; + +public sealed class AppVersionService +{ + private readonly VersionInfoSnapshot _snapshot; + + public AppVersionService(IHostEnvironment environment) + { + var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly(); + var informationalVersion = assembly + .GetCustomAttribute()? + .InformationalVersion; + var assemblyVersion = assembly.GetName().Version?.ToString() ?? "0.0.0"; + var version = string.IsNullOrWhiteSpace(informationalVersion) ? assemblyVersion : informationalVersion; + var commitSha = Environment.GetEnvironmentVariable("SPACEGAME_COMMIT_SHA") + ?? Environment.GetEnvironmentVariable("GIT_COMMIT_SHA"); + + _snapshot = new VersionInfoSnapshot( + version, + environment.EnvironmentName, + string.IsNullOrWhiteSpace(commitSha) ? null : commitSha, + DateTimeOffset.UtcNow); + } + + public VersionInfoSnapshot GetSnapshot() => _snapshot; +} diff --git a/apps/backend/Shared/Runtime/KnownShipTaxonomy.cs b/apps/backend/Shared/Runtime/KnownShipTaxonomy.cs new file mode 100644 index 0000000..cb1492a --- /dev/null +++ b/apps/backend/Shared/Runtime/KnownShipTaxonomy.cs @@ -0,0 +1,69 @@ +namespace SpaceGame.Api.Shared.Runtime; + +internal static class KnownShipTypes +{ + internal const string Resupplier = "resupplier"; + internal const string Miner = "miner"; + internal const string Carrier = "carrier"; + internal const string Fighter = "fighter"; + internal const string HeavyFighter = "heavyfighter"; + internal const string Destroyer = "destroyer"; + internal const string LargeMiner = "largeminer"; + internal const string Freighter = "freighter"; + internal const string Bomber = "bomber"; + internal const string Scavenger = "scavenger"; + internal const string Frigate = "frigate"; + internal const string Transporter = "transporter"; + internal const string Interceptor = "interceptor"; + internal const string Scout = "scout"; + internal const string Courier = "courier"; + internal const string Builder = "builder"; + internal const string Corvette = "corvette"; + internal const string Police = "police"; + internal const string Battleship = "battleship"; + internal const string Gunboat = "gunboat"; + internal const string Tug = "tug"; + internal const string Compactor = "compactor"; +} + +internal static class ShipTaxonomyExtensions +{ + internal static string ToDataValue(this ShipPurpose purpose) => + purpose switch + { + ShipPurpose.Auxiliary => "auxiliary", + ShipPurpose.Build => "build", + ShipPurpose.Fight => "fight", + ShipPurpose.Mine => "mine", + ShipPurpose.Trade => "trade", + _ => purpose.ToString(), + }; + + internal static string ToDataValue(this ShipType type) => + type switch + { + ShipType.Resupplier => "resupplier", + ShipType.Miner => "miner", + ShipType.Carrier => "carrier", + ShipType.Fighter => "fighter", + ShipType.HeavyFighter => "heavyfighter", + ShipType.Destroyer => "destroyer", + ShipType.LargeMiner => "largeminer", + ShipType.Freighter => "freighter", + ShipType.Bomber => "bomber", + ShipType.Scavenger => "scavenger", + ShipType.Frigate => "frigate", + ShipType.Transporter => "transporter", + ShipType.Interceptor => "interceptor", + ShipType.Scout => "scout", + ShipType.Courier => "courier", + ShipType.Builder => "builder", + ShipType.Corvette => "corvette", + ShipType.Police => "police", + ShipType.Battleship => "battleship", + ShipType.Gunboat => "gunboat", + ShipType.Tug => "tug", + ShipType.Compactor => "compactor", + _ => type.ToString(), + }; +} diff --git a/apps/backend/Shared/Runtime/ShipAutomationCatalog.cs b/apps/backend/Shared/Runtime/ShipAutomationCatalog.cs new file mode 100644 index 0000000..6d8986e --- /dev/null +++ b/apps/backend/Shared/Runtime/ShipAutomationCatalog.cs @@ -0,0 +1,121 @@ +namespace SpaceGame.Api.Shared.Runtime; + +public enum ShipAutomationSupportStatus +{ + Supported, + PartiallySupported, + NotSupported, + InternalOnly, +} + +public sealed record ShipBehaviorDefinition( + string Id, + string Label, + string Category, + ShipAutomationSupportStatus SupportStatus, + string Notes); + +public sealed record ShipOrderDefinition( + string Id, + string Label, + string Category, + ShipAutomationSupportStatus SupportStatus, + string Notes); + +public static class ShipBehaviorKinds +{ + public const string Patrol = "patrol"; + public const string Police = "police"; + public const string ProtectPosition = "protect-position"; + public const string ProtectShip = "protect-ship"; + public const string ProtectStation = "protect-station"; + + public const string LocalAutoMine = "local-auto-mine"; + public const string AdvancedAutoMine = "advanced-auto-mine"; + public const string ExpertAutoMine = "expert-auto-mine"; + + public const string DockAndWait = "dock-and-wait"; + public const string FlyAndWait = "fly-and-wait"; + public const string FlyToObject = "fly-to-object"; + public const string FollowShip = "follow-ship"; + public const string HoldPosition = "hold-position"; + + public const string AutoSalvage = "auto-salvage"; + + public const string LocalAutoTrade = "local-auto-trade"; + public const string AdvancedAutoTrade = "advanced-auto-trade"; + public const string FillShortages = "fill-shortages"; + public const string FindBuildTasks = "find-build-tasks"; + public const string RevisitKnownStations = "revisit-known-stations"; + public const string SupplyFleet = "supply-fleet"; + + public const string RepeatOrders = "repeat-orders"; + + public const string AttackTarget = "attack-target"; + public const string ConstructStation = "construct-station"; + public const string Idle = "idle"; +} + +public static class ShipAutomationCatalog +{ + public static readonly IReadOnlyList Behaviors = + [ + new(ShipBehaviorKinds.Patrol, "Patrol", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait orders from the active patrol context."), + new(ShipBehaviorKinds.Police, "Police", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or follow-ship inspection orders from the active policing context."), + new(ShipBehaviorKinds.ProtectPosition, "Protect Position", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait orders from the defended position context."), + new(ShipBehaviorKinds.ProtectShip, "Protect Ship", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or follow-ship escort orders from the guarded ship context."), + new(ShipBehaviorKinds.ProtectStation, "Protect Station", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait guard orders from the defended station context."), + + new(ShipBehaviorKinds.LocalAutoMine, "Local AutoMine", "Mining", ShipAutomationSupportStatus.PartiallySupported, "Queue-backed for solo mining; broader order-generation model still in progress."), + new(ShipBehaviorKinds.AdvancedAutoMine, "Advanced AutoMine", "Mining", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal mine-and-deliver run order from the current mining opportunity."), + new(ShipBehaviorKinds.ExpertAutoMine, "Expert AutoMine", "Mining", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal mine-and-deliver run order from the current mining opportunity."), + + new(ShipBehaviorKinds.DockAndWait, "Dock And Wait", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."), + new(ShipBehaviorKinds.FlyAndWait, "Fly And Wait", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."), + new(ShipBehaviorKinds.FlyToObject, "Fly To Object", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."), + new(ShipBehaviorKinds.FollowShip, "Follow Ship", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."), + new(ShipBehaviorKinds.HoldPosition, "Hold Position", "Navigation", ShipAutomationSupportStatus.Supported, "Default baseline behavior; queue-backed behavior order is active."), + + new(ShipBehaviorKinds.AutoSalvage, "AutoSalvage", "Salvage", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal salvage run order for wreck recovery."), + + new(ShipBehaviorKinds.LocalAutoTrade, "Local AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-and-wait orders from the current market context."), + new(ShipBehaviorKinds.AdvancedAutoTrade, "Advanced AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route orders from the current market context."), + new(ShipBehaviorKinds.FillShortages, "Fill Shortages", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route orders from the current market context."), + new(ShipBehaviorKinds.FindBuildTasks, "Find Build Tasks", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing construction-support trade routes from the current market context."), + new(ShipBehaviorKinds.RevisitKnownStations, "Revisit Known Stations", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-and-wait orders from known-station context."), + new(ShipBehaviorKinds.SupplyFleet, "Supply Fleet", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal fleet supply run order."), + + new(ShipBehaviorKinds.RepeatOrders, "Repeat Orders", "Advanced", ShipAutomationSupportStatus.Supported, "Queue-backed behavior generating the current repeat-order template at the bottom of the stack."), + + new(ShipBehaviorKinds.AttackTarget, "Attack Target", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal gameplay behavior used by current combat/control systems, not an X4 exposed default behavior."), + new(ShipBehaviorKinds.ConstructStation, "Construct Station", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal gameplay behavior used by construction ships."), + new(ShipBehaviorKinds.Idle, "Idle", "Internal", ShipAutomationSupportStatus.InternalOnly, "Legacy fallback/internal placeholder; not intended as an exposed player behavior."), + ]; + + public static readonly IReadOnlyList Orders = + [ + new(ShipOrderKinds.DockAndWait, "Dock And Wait", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."), + new(ShipOrderKinds.FlyAndWait, "Fly To And Wait", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."), + new(ShipOrderKinds.FlyToObject, "Fly To Object", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."), + new(ShipOrderKinds.FollowShip, "Follow Ship", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."), + new(ShipOrderKinds.HoldPosition, "Hold Position", "Navigation", ShipAutomationSupportStatus.Supported, "Direct order supported in backend."), + new(ShipOrderKinds.Move, "Move", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Low-level direct movement order; viewer may present richer labels such as Fly To And Wait instead."), + + new(ShipOrderKinds.AttackTarget, "Attack Target", "Combat", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."), + + new(ShipOrderKinds.MineAndDeliver, "Mine Resource", "Mining", ShipAutomationSupportStatus.Supported, "Direct order mines the requested ware in the requested system until cargo is full."), + + new(ShipOrderKinds.TradeRoute, "Trade Route", "Trade", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."), + + new(ShipOrderKinds.BuildAtSite, "Build At Site", "Construction", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."), + + new(ShipOrderKinds.RepeatOrders, "Repeat Orders", "Advanced", ShipAutomationSupportStatus.PartiallySupported, "Represented today as a behavior plus templates, not a normal one-shot direct order."), + + new(ShipOrderKinds.MineLocal, "Mine Local", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for Local AutoMine."), + new(ShipOrderKinds.MineAndDeliverRun, "Mine And Deliver Run", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for Advanced/Expert AutoMine."), + new(ShipOrderKinds.SellMinedCargo, "Sell Mined Cargo", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for Local AutoMine."), + new(ShipOrderKinds.SupplyFleetRun, "Supply Fleet Run", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for Supply Fleet."), + new(ShipOrderKinds.SalvageRun, "Salvage Run", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for AutoSalvage."), + new(ShipOrderKinds.Flee, "Flee", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal emergency order."), + ]; +} diff --git a/apps/backend/Shared/Runtime/SimulationKinds.cs b/apps/backend/Shared/Runtime/SimulationKinds.cs index 6542e21..95575d0 100644 --- a/apps/backend/Shared/Runtime/SimulationKinds.cs +++ b/apps/backend/Shared/Runtime/SimulationKinds.cs @@ -28,6 +28,13 @@ public enum OrderStatus Interrupted, } +public enum ShipOrderSourceKind +{ + Player, + Behavior, + Commander, +} + public enum AiPlanStatus { Planned, @@ -166,6 +173,11 @@ public static class ShipOrderKinds public const string BuildAtSite = "build-at-site"; public const string AttackTarget = "attack-target"; public const string HoldPosition = "hold-position"; + public const string MineLocal = "mine-local"; + public const string MineAndDeliverRun = "mine-and-deliver-run"; + public const string SellMinedCargo = "sell-mined-cargo"; + public const string SupplyFleetRun = "supply-fleet-run"; + public const string SalvageRun = "salvage-run"; public const string RepeatOrders = "repeat-orders"; public const string Flee = "flee"; } @@ -329,6 +341,14 @@ public static class SimulationEnumMappings _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), }; + public static string ToContractValue(this ShipOrderSourceKind kind) => kind switch + { + ShipOrderSourceKind.Player => "player", + ShipOrderSourceKind.Behavior => "behavior", + ShipOrderSourceKind.Commander => "commander", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), + }; + public static string ToContractValue(this ShipState state) => state switch { ShipState.Idle => "idle", diff --git a/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs b/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs index 549a698..fb3afa8 100644 --- a/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs +++ b/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs @@ -3,8 +3,56 @@ namespace SpaceGame.Api.Shared.Runtime; internal static class SimulationRuntimeSupport { - internal static bool HasShipCapabilities(ShipDefinition definition, params string[] capabilities) => - capabilities.All(cap => definition.Capabilities.Contains(cap, StringComparer.Ordinal)); + internal static bool CanWarp(ShipDefinition definition) => + definition.Engines.Count > 0; + + internal static bool CanFtl(ShipDefinition definition) => + definition.Engines.Count > 0; + + internal static bool IsMiningShip(ShipDefinition definition) => + definition.Type is ShipType.Miner or ShipType.LargeMiner; + + internal static bool IsTransportShip(ShipDefinition definition) => + definition.Type is ShipType.Freighter or ShipType.Transporter or ShipType.Courier or ShipType.Resupplier; + + internal static bool IsConstructionShip(ShipDefinition definition) => + definition.Type == ShipType.Builder; + + internal static bool IsMilitaryShip(ShipDefinition definition) => + definition.Type is ShipType.Fighter + or ShipType.HeavyFighter + or ShipType.Destroyer + or ShipType.Bomber + or ShipType.Frigate + or ShipType.Interceptor + or ShipType.Corvette + or ShipType.Battleship + or ShipType.Gunboat; + + internal static string? GetShipCategory(ShipDefinition definition) + { + if (IsMilitaryShip(definition)) + { + return "military"; + } + + if (IsConstructionShip(definition)) + { + return "construction"; + } + + if (IsTransportShip(definition)) + { + return "transport"; + } + + if (IsMiningShip(definition)) + { + return "mining"; + } + + return null; + } internal static int CountStationModules(StationRuntime station, ModuleType moduleType) => station.Modules.Count(module => module.ModuleType == moduleType); @@ -131,13 +179,13 @@ internal static class SimulationRuntimeSupport modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal))); internal static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node, SimulationWorld world) => - HasShipCapabilities(ship.Definition, "mining") + IsMiningShip(ship.Definition) && world.ItemDefinitions.TryGetValue(node.ItemId, out var item) && item.CargoKind is not null - && item.CargoKind == ship.Definition.CargoKind; + && ship.Definition.SupportsCargoKind(item.CargoKind.Value); internal static bool CanBuildClaimBeacon(ShipRuntime ship) => - string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal); + IsMilitaryShip(ship.Definition); internal static float ComputeWorkforceRatio(float population, float workforceRequired) { diff --git a/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs b/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs new file mode 100644 index 0000000..f36e36d --- /dev/null +++ b/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs @@ -0,0 +1,784 @@ +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; +using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; +using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; + +namespace SpaceGame.Api.Ships.AI; + +public sealed partial class ShipAiService +{ + private static ShipOrderRuntime? GetTopOrder(ShipRuntime ship) => + ship.OrderQueue + .Where(order => order.Status is OrderStatus.Queued or OrderStatus.Active) + .OrderByDescending(GetOrderSourcePriority) + .ThenByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .FirstOrDefault(); + + private static int GetOrderSourcePriority(ShipOrderRuntime order) => order.SourceKind switch + { + ShipOrderSourceKind.Player => 300, + ShipOrderSourceKind.Commander => 200, + ShipOrderSourceKind.Behavior => 100, + _ => 0, + }; + + private void SyncBehaviorOrders(SimulationWorld world, ShipRuntime ship) + { + var desiredOrder = BuildManagedBehaviorOrder(world, ship); + ship.OrderQueue.RemoveAll(order => + order.SourceKind == ShipOrderSourceKind.Behavior + && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))); + + if (desiredOrder is null) + { + return; + } + + var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)); + if (existing is null) + { + ship.OrderQueue.Add(desiredOrder); + return; + } + + if (ManagedOrdersEqual(existing, desiredOrder)) + { + return; + } + + ship.OrderQueue.Remove(existing); + ship.OrderQueue.Add(desiredOrder); + } + + private ShipOrderRuntime? BuildManagedBehaviorOrder(SimulationWorld world, ShipRuntime ship) + { + var assignment = ResolveAssignment(world, ship); + var behaviorKind = assignment?.BehaviorKind ?? ship.DefaultBehavior.Kind; + var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + + if (string.Equals(behaviorKind, HoldPosition, StringComparison.Ordinal)) + { + return new ShipOrderRuntime + { + Id = $"behavior-{ship.Id}-hold-position", + Kind = ShipOrderKinds.HoldPosition, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = "Hold position", + TargetSystemId = systemId, + TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position, + WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), + Radius = ship.DefaultBehavior.Radius, + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + } + + if (string.Equals(behaviorKind, DockAndWait, StringComparison.Ordinal)) + { + var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? ship.DefaultBehavior.HomeStationId); + if (station is null) + { + ship.LastAccessFailureReason = "station-missing"; + return null; + } + + ship.LastAccessFailureReason = null; + return new ShipOrderRuntime + { + Id = $"behavior-{ship.Id}-dock-and-wait", + Kind = ShipOrderKinds.DockAndWait, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = $"Dock and wait at {station.Label}", + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + DestinationStationId = station.Id, + WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), + Radius = ship.DefaultBehavior.Radius, + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + } + + if (string.Equals(behaviorKind, FlyAndWait, StringComparison.Ordinal)) + { + ship.LastAccessFailureReason = null; + return new ShipOrderRuntime + { + Id = $"behavior-{ship.Id}-fly-and-wait", + Kind = ShipOrderKinds.FlyAndWait, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = "Fly and wait", + TargetSystemId = systemId, + TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position, + WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), + Radius = ship.DefaultBehavior.Radius, + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + } + + if (string.Equals(behaviorKind, FollowShip, StringComparison.Ordinal)) + { + var targetShip = world.Ships.FirstOrDefault(candidate => + candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId) + && candidate.Health > 0f); + if (targetShip is null) + { + ship.LastAccessFailureReason = "target-ship-missing"; + return null; + } + + ship.LastAccessFailureReason = null; + return new ShipOrderRuntime + { + Id = $"behavior-{ship.Id}-follow-ship", + Kind = ShipOrderKinds.FollowShip, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = $"Follow {targetShip.Definition.Name}", + TargetEntityId = targetShip.Id, + TargetSystemId = targetShip.SystemId, + WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), + Radius = MathF.Max(16f, ship.DefaultBehavior.Radius), + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + } + + if (string.Equals(behaviorKind, FlyToObject, StringComparison.Ordinal)) + { + var targetEntityId = assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId; + var target = ResolveObjectTarget(world, targetEntityId); + if (target is null) + { + ship.LastAccessFailureReason = "target-missing"; + return null; + } + + ship.LastAccessFailureReason = null; + return new ShipOrderRuntime + { + Id = $"behavior-{ship.Id}-fly-to-object", + Kind = ShipOrderKinds.FlyToObject, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = "Fly to object", + TargetEntityId = targetEntityId, + TargetSystemId = target.Value.SystemId, + TargetPosition = target.Value.Position, + WaitSeconds = MathF.Max(1f, ship.DefaultBehavior.WaitSeconds), + Radius = MathF.Max(8f, ship.DefaultBehavior.Radius), + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + } + + if (string.Equals(behaviorKind, Patrol, StringComparison.Ordinal)) + { + return BuildManagedPatrolOrder(world, ship, assignment, behaviorKind); + } + + if (string.Equals(behaviorKind, AttackTarget, StringComparison.Ordinal)) + { + var targetEntityId = assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId; + if (string.IsNullOrWhiteSpace(targetEntityId)) + { + return BuildManagedPatrolOrder(world, ship, assignment, behaviorKind); + } + + var target = ResolveObjectTarget(world, targetEntityId); + ship.LastAccessFailureReason = target is null ? "target-missing" : null; + return new ShipOrderRuntime + { + Id = $"behavior-{ship.Id}-attack-target", + Kind = ShipOrderKinds.AttackTarget, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = "Attack target", + TargetEntityId = targetEntityId, + TargetSystemId = target?.SystemId ?? assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId, + TargetPosition = target?.Position ?? ship.Position, + WaitSeconds = 0f, + Radius = 26f, + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + } + + if (string.Equals(behaviorKind, ConstructStation, StringComparison.Ordinal)) + { + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.PreferredConstructionSiteId)) + ?? world.ConstructionSites + .Where(candidate => candidate.FactionId == ship.FactionId && candidate.State is ConstructionSiteStateKinds.Active or ConstructionSiteStateKinds.Planned) + .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (site is null) + { + ship.LastAccessFailureReason = "no-construction-site"; + return null; + } + + if (ResolveSupportStation(world, ship, site) is null) + { + ship.LastAccessFailureReason = "support-station-missing"; + return null; + } + + ship.LastAccessFailureReason = null; + return new ShipOrderRuntime + { + Id = $"behavior-{ship.Id}-construct-station", + Kind = ShipOrderKinds.BuildAtSite, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = $"Build {site.BlueprintId}", + TargetEntityId = site.Id, + TargetSystemId = site.SystemId, + ConstructionSiteId = site.Id, + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + } + + if (string.Equals(behaviorKind, AdvancedAutoMine, StringComparison.Ordinal) + || string.Equals(behaviorKind, ExpertAutoMine, StringComparison.Ordinal)) + { + var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + if (homeStation is null) + { + ship.LastAccessFailureReason = "no-home-station"; + return null; + } + + var opportunity = SelectMiningOpportunity(world, ship, homeStation, assignment, behaviorKind); + if (opportunity is null) + { + ship.LastAccessFailureReason = "no-mineable-node"; + return null; + } + + ship.LastAccessFailureReason = null; + return new ShipOrderRuntime + { + Id = $"behavior-{ship.Id}-{behaviorKind}-mine-and-deliver", + Kind = ShipOrderKinds.MineAndDeliverRun, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = opportunity.Summary, + TargetEntityId = opportunity.Node.Id, + TargetSystemId = opportunity.Node.SystemId, + DestinationStationId = opportunity.DropOffStation.Id, + ItemId = opportunity.Node.ItemId, + NodeId = opportunity.Node.Id, + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + } + + if (string.Equals(behaviorKind, ProtectPosition, StringComparison.Ordinal)) + { + var targetSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + var targetPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position; + var threat = SelectThreatTarget(world, ship, targetSystemId, targetPosition, MathF.Max(90f, ship.DefaultBehavior.Radius)); + if (threat is not null) + { + ship.LastAccessFailureReason = null; + return CreateManagedAttackOrder(ship, behaviorKind, "Protect position", threat.EntityId, threat.SystemId, threat.Position); + } + + ship.LastAccessFailureReason = null; + return CreateManagedFlyAndWaitOrder( + ship, + behaviorKind, + "Protect position", + targetSystemId, + targetPosition, + MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), + MathF.Max(6f, ship.DefaultBehavior.Radius)); + } + + if (string.Equals(behaviorKind, ProtectShip, StringComparison.Ordinal)) + { + var guardTarget = world.Ships.FirstOrDefault(candidate => + candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId) + && candidate.Health > 0f); + if (guardTarget is null) + { + return BuildManagedPatrolOrder(world, ship, assignment, Patrol); + } + + var threat = SelectThreatTarget( + world, + ship, + guardTarget.SystemId, + guardTarget.Position, + MathF.Max(90f, ship.DefaultBehavior.Radius), + excludeEntityId: guardTarget.Id); + if (threat is not null) + { + ship.LastAccessFailureReason = null; + return CreateManagedAttackOrder(ship, behaviorKind, $"Protect {guardTarget.Definition.Name}", threat.EntityId, threat.SystemId, threat.Position); + } + + ship.LastAccessFailureReason = null; + return CreateManagedFollowShipOrder( + ship, + behaviorKind, + $"Escort {guardTarget.Definition.Name}", + guardTarget, + MathF.Max(18f, ship.DefaultBehavior.Radius * 0.5f), + MathF.Max(2f, ship.DefaultBehavior.WaitSeconds)); + } + + if (string.Equals(behaviorKind, ProtectStation, StringComparison.Ordinal)) + { + var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + if (station is null) + { + return BuildManagedPatrolOrder(world, ship, assignment, Patrol); + } + + var threat = SelectThreatTarget(world, ship, station.SystemId, station.Position, MathF.Max(station.Radius + 80f, ship.DefaultBehavior.Radius)); + if (threat is not null) + { + ship.LastAccessFailureReason = null; + return CreateManagedAttackOrder(ship, behaviorKind, $"Protect {station.Label}", threat.EntityId, threat.SystemId, threat.Position); + } + + ship.LastAccessFailureReason = null; + return CreateManagedFlyAndWaitOrder( + ship, + behaviorKind, + $"Guard {station.Label}", + station.SystemId, + GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)), + MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), + MathF.Max(6f, ship.DefaultBehavior.Radius)); + } + + if (string.Equals(behaviorKind, Police, StringComparison.Ordinal)) + { + var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + var policeSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? homeStation?.SystemId ?? ship.SystemId; + var areaPosition = homeStation?.Position ?? ship.DefaultBehavior.TargetPosition ?? ship.Position; + var contact = SelectPoliceContact(world, ship, policeSystemId, areaPosition, MathF.Max(80f, ship.DefaultBehavior.Radius)); + if (contact is null) + { + return BuildManagedPatrolOrder(world, ship, assignment, Patrol); + } + + ship.LastAccessFailureReason = null; + return contact.Engage + ? CreateManagedAttackOrder(ship, behaviorKind, "Police engage", contact.EntityId, contact.SystemId, contact.Position) + : CreateManagedFollowTargetOrder(ship, behaviorKind, "Police inspect", contact.EntityId, contact.SystemId, contact.Position, MathF.Max(14f, ship.DefaultBehavior.Radius * 0.5f), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds)); + } + + if (string.Equals(behaviorKind, LocalAutoTrade, StringComparison.Ordinal) + || string.Equals(behaviorKind, AdvancedAutoTrade, StringComparison.Ordinal) + || string.Equals(behaviorKind, FillShortages, StringComparison.Ordinal) + || string.Equals(behaviorKind, FindBuildTasks, StringComparison.Ordinal) + || string.Equals(behaviorKind, RevisitKnownStations, StringComparison.Ordinal)) + { + var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + var route = SelectTradeRoute(world, ship, homeStation, behaviorKind, ship.DefaultBehavior.KnownStationsOnly); + if (route is not null) + { + ship.LastAccessFailureReason = null; + return CreateManagedTradeRouteOrder(ship, behaviorKind, route); + } + + if (string.Equals(behaviorKind, RevisitKnownStations, StringComparison.Ordinal) + && SelectKnownStationVisit(world, ship, homeStation) is { } visitStation) + { + ship.LastAccessFailureReason = null; + return CreateManagedDockAndWaitOrder(ship, behaviorKind, visitStation, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Revisit {visitStation.Label}"); + } + + ship.LastAccessFailureReason = "no-trade-route"; + return null; + } + + if (string.Equals(behaviorKind, SupplyFleet, StringComparison.Ordinal)) + { + var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + var plan = SelectFleetSupplyPlan(world, ship, homeStation); + if (plan is null) + { + ship.LastAccessFailureReason = "no-fleet-to-supply"; + return null; + } + + ship.LastAccessFailureReason = null; + return new ShipOrderRuntime + { + Id = $"behavior-{ship.Id}-supply-fleet", + Kind = ShipOrderKinds.SupplyFleetRun, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = plan.Summary, + TargetEntityId = plan.TargetShip.Id, + TargetSystemId = plan.TargetShip.SystemId, + SourceStationId = plan.SourceStation.Id, + ItemId = plan.ItemId, + Radius = plan.Radius, + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + } + + if (string.Equals(behaviorKind, AutoSalvage, StringComparison.Ordinal)) + { + var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + if (homeStation is null) + { + ship.LastAccessFailureReason = "no-home-station"; + return null; + } + + var salvage = SelectSalvageOpportunity(world, ship, homeStation); + if (salvage is null) + { + ship.LastAccessFailureReason = "no-salvage-target"; + return null; + } + + ship.LastAccessFailureReason = null; + return new ShipOrderRuntime + { + Id = $"behavior-{ship.Id}-auto-salvage", + Kind = ShipOrderKinds.SalvageRun, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = salvage.Summary, + TargetEntityId = salvage.Wreck.Id, + TargetSystemId = salvage.Wreck.SystemId, + TargetPosition = salvage.Wreck.Position, + SourceStationId = homeStation.Id, + ItemId = salvage.Wreck.ItemId, + Radius = MathF.Max(8f, ship.DefaultBehavior.Radius * 0.25f), + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + } + + if (string.Equals(behaviorKind, RepeatOrders, StringComparison.Ordinal)) + { + if (ship.DefaultBehavior.RepeatOrders.Count == 0) + { + ship.LastAccessFailureReason = "no-repeat-orders"; + return null; + } + + var template = ship.DefaultBehavior.RepeatOrders[ship.DefaultBehavior.RepeatIndex % ship.DefaultBehavior.RepeatOrders.Count]; + ship.LastAccessFailureReason = null; + return new ShipOrderRuntime + { + Id = $"behavior-{ship.Id}-repeat-{ship.DefaultBehavior.RepeatIndex}", + Kind = template.Kind, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = template.Label, + TargetEntityId = template.TargetEntityId, + TargetSystemId = template.TargetSystemId, + TargetPosition = template.TargetPosition, + SourceStationId = template.SourceStationId, + DestinationStationId = template.DestinationStationId, + ItemId = template.ItemId, + NodeId = template.NodeId, + ConstructionSiteId = template.ConstructionSiteId, + ModuleId = template.ModuleId, + WaitSeconds = template.WaitSeconds, + Radius = template.Radius, + MaxSystemRange = template.MaxSystemRange ?? ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = template.KnownStationsOnly, + }; + } + + if (!string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal)) + { + return null; + } + + var itemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId; + if (string.IsNullOrWhiteSpace(itemId)) + { + ship.LastAccessFailureReason = "missing-item"; + return null; + } + + if (GetShipCargoAmount(ship) >= ship.Definition.GetTotalCargoCapacity() - 0.01f) + { + var buyer = SelectLocalAutoMineBuyer(world, ship, systemId, itemId); + if (buyer is null) + { + ship.LastAccessFailureReason = "no-suitable-buyer"; + return null; + } + + ship.LastAccessFailureReason = null; + return new ShipOrderRuntime + { + Id = $"behavior-{ship.Id}-local-auto-mine-sell", + Kind = ShipOrderKinds.SellMinedCargo, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = $"Sell {itemId} in {systemId}", + TargetEntityId = buyer.Id, + TargetSystemId = buyer.SystemId, + DestinationStationId = buyer.Id, + ItemId = itemId, + WaitSeconds = 0f, + Radius = 0f, + MaxSystemRange = 0, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + } + + var node = SelectLocalMiningNode(world, ship, systemId, itemId); + if (node is null) + { + ship.LastAccessFailureReason = "no-mineable-node"; + return null; + } + + ship.LastAccessFailureReason = null; + return new ShipOrderRuntime + { + Id = $"behavior-{ship.Id}-local-auto-mine-mine", + Kind = ShipOrderKinds.MineLocal, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = $"Mine {itemId} in {systemId}", + TargetSystemId = node.SystemId, + NodeId = node.Id, + ItemId = node.ItemId, + WaitSeconds = 0f, + Radius = 0f, + MaxSystemRange = 0, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + } + + private static bool ManagedOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) => + string.Equals(left.Id, right.Id, StringComparison.Ordinal) + && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && left.SourceKind == right.SourceKind + && string.Equals(left.SourceId, right.SourceId, StringComparison.Ordinal) + && left.Priority == right.Priority + && left.InterruptCurrentPlan == right.InterruptCurrentPlan + && string.Equals(left.Label, right.Label, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) + && left.TargetPosition == right.TargetPosition + && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) + && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) + && left.WaitSeconds.Equals(right.WaitSeconds) + && left.Radius.Equals(right.Radius) + && left.MaxSystemRange == right.MaxSystemRange + && left.KnownStationsOnly == right.KnownStationsOnly; + + private ShipOrderRuntime BuildManagedPatrolOrder(SimulationWorld world, ShipRuntime ship, CommanderAssignmentRuntime? assignment, string sourceKind) + { + var patrolSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + var protectPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position; + var patrolThreat = SelectThreatTarget(world, ship, patrolSystemId, protectPosition, MathF.Max(60f, ship.DefaultBehavior.Radius)); + if (patrolThreat is not null) + { + ship.LastAccessFailureReason = null; + return CreateManagedAttackOrder(ship, sourceKind, "Patrol intercept", patrolThreat.EntityId, patrolThreat.SystemId, patrolThreat.Position, orderIdSuffix: "patrol-attack"); + } + + Vector3 targetPosition; + string targetSystemId; + if (ship.DefaultBehavior.PatrolPoints.Count > 0) + { + var index = ship.DefaultBehavior.PatrolIndex % ship.DefaultBehavior.PatrolPoints.Count; + targetPosition = ship.DefaultBehavior.PatrolPoints[index]; + ship.DefaultBehavior.PatrolIndex = (index + 1) % ship.DefaultBehavior.PatrolPoints.Count; + targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + } + else if (ResolveStation(world, ship.DefaultBehavior.HomeStationId ?? assignment?.HomeStationId) is { } homeStation) + { + var patrolRadius = homeStation.Radius + 90f; + targetPosition = new Vector3(homeStation.Position.X + patrolRadius, homeStation.Position.Y, homeStation.Position.Z); + targetSystemId = homeStation.SystemId; + } + else + { + targetPosition = ship.Position; + targetSystemId = ship.SystemId; + } + + ship.LastAccessFailureReason = null; + return CreateManagedFlyAndWaitOrder(ship, sourceKind, "Patrol waypoint", targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), MathF.Max(6f, ship.DefaultBehavior.Radius), orderIdSuffix: "patrol-fly-and-wait"); + } + + private static ShipOrderRuntime CreateManagedAttackOrder( + ShipRuntime ship, + string behaviorKind, + string label, + string targetEntityId, + string targetSystemId, + Vector3 targetPosition, + string? orderIdSuffix = null) => + new() + { + Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}", + Kind = ShipOrderKinds.AttackTarget, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = label, + TargetEntityId = targetEntityId, + TargetSystemId = targetSystemId, + TargetPosition = targetPosition, + WaitSeconds = 0f, + Radius = 26f, + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + + private static ShipOrderRuntime CreateManagedTradeRouteOrder(ShipRuntime ship, string behaviorKind, TradeRoutePlan route) => + new() + { + Id = $"behavior-{ship.Id}-{behaviorKind}-trade-route", + Kind = ShipOrderKinds.TradeRoute, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = route.Summary, + SourceStationId = route.SourceStation.Id, + DestinationStationId = route.DestinationStation.Id, + ItemId = route.ItemId, + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + + private static ShipOrderRuntime CreateManagedDockAndWaitOrder(ShipRuntime ship, string behaviorKind, StationRuntime station, float waitSeconds, string label) => + new() + { + Id = $"behavior-{ship.Id}-{behaviorKind}-dock-and-wait", + Kind = ShipOrderKinds.DockAndWait, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = label, + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + DestinationStationId = station.Id, + WaitSeconds = waitSeconds, + Radius = ship.DefaultBehavior.Radius, + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + + private static ShipOrderRuntime CreateManagedFlyAndWaitOrder( + ShipRuntime ship, + string behaviorKind, + string label, + string targetSystemId, + Vector3 targetPosition, + float waitSeconds, + float radius, + string? orderIdSuffix = null) => + new() + { + Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}", + Kind = ShipOrderKinds.FlyAndWait, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = label, + TargetSystemId = targetSystemId, + TargetPosition = targetPosition, + WaitSeconds = waitSeconds, + Radius = radius, + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + + private static ShipOrderRuntime CreateManagedFollowShipOrder( + ShipRuntime ship, + string behaviorKind, + string label, + ShipRuntime targetShip, + float radius, + float waitSeconds) => + new() + { + Id = $"behavior-{ship.Id}-{behaviorKind}", + Kind = ShipOrderKinds.FollowShip, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = label, + TargetEntityId = targetShip.Id, + TargetSystemId = targetShip.SystemId, + WaitSeconds = waitSeconds, + Radius = radius, + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; + + private static ShipOrderRuntime CreateManagedFollowTargetOrder( + ShipRuntime ship, + string behaviorKind, + string label, + string targetEntityId, + string targetSystemId, + Vector3 targetPosition, + float radius, + float waitSeconds) => + new() + { + Id = $"behavior-{ship.Id}-{behaviorKind}", + Kind = ShipOrderKinds.FollowShip, + SourceKind = ShipOrderSourceKind.Behavior, + SourceId = behaviorKind, + Priority = 0, + InterruptCurrentPlan = false, + Label = label, + TargetEntityId = targetEntityId, + TargetSystemId = targetSystemId, + TargetPosition = targetPosition, + WaitSeconds = waitSeconds, + Radius = radius, + MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, + }; +} diff --git a/apps/backend/Ships/AI/ShipAiService.Data.cs b/apps/backend/Ships/AI/ShipAiService.Data.cs new file mode 100644 index 0000000..0661240 --- /dev/null +++ b/apps/backend/Ships/AI/ShipAiService.Data.cs @@ -0,0 +1,54 @@ +namespace SpaceGame.Api.Ships.AI; + +public sealed partial class ShipAiService +{ + private enum SubTaskOutcome + { + Active, + Completed, + Failed, + } + + private sealed record TradeRoutePlan( + StationRuntime SourceStation, + StationRuntime DestinationStation, + string ItemId, + float Score, + string Summary); + + private sealed record MiningOpportunity( + ResourceNodeRuntime Node, + StationRuntime DropOffStation, + float Score, + string Summary); + + private sealed record FleetSupplyPlan( + StationRuntime SourceStation, + ShipRuntime TargetShip, + string ItemId, + float Amount, + float Radius, + string Summary); + + private sealed record LocalMiningBuyerCandidate( + StationRuntime Station, + float Score); + + private sealed record ThreatTargetCandidate( + string EntityId, + string SystemId, + Vector3 Position, + float Score); + + private sealed record PoliceContactCandidate( + string EntityId, + string SystemId, + Vector3 Position, + bool Engage, + float Score); + + private sealed record SalvageOpportunity( + WreckRuntime Wreck, + float Score, + string Summary); +} diff --git a/apps/backend/Ships/AI/ShipAiService.Execution.cs b/apps/backend/Ships/AI/ShipAiService.Execution.cs new file mode 100644 index 0000000..83053e0 --- /dev/null +++ b/apps/backend/Ships/AI/ShipAiService.Execution.cs @@ -0,0 +1,770 @@ +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; +using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; +using static SpaceGame.Api.Stations.Simulation.StationSimulationService; + +namespace SpaceGame.Api.Ships.AI; + +public sealed partial class ShipAiService +{ + private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipPlanStepRuntime step, ShipSubTaskRuntime subTask, float deltaSeconds) + { + return subTask.Kind switch + { + var kind when string.Equals(kind, ShipTaskKinds.Travel, StringComparison.Ordinal) => UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: true), + var kind when string.Equals(kind, ShipTaskKinds.FollowTarget, StringComparison.Ordinal) => UpdateFollowSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.Dock, StringComparison.Ordinal) => UpdateDockSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.Undock, StringComparison.Ordinal) => UpdateUndockSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.LoadCargo, StringComparison.Ordinal) => UpdateLoadCargoSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.UnloadCargo, StringComparison.Ordinal) => UpdateUnloadCargoSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.TransferCargoToShip, StringComparison.Ordinal) => UpdateTransferCargoToShipSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.MineNode, StringComparison.Ordinal) => UpdateMineSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.SalvageWreck, StringComparison.Ordinal) => UpdateSalvageSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.DeliverConstruction, StringComparison.Ordinal) => UpdateDeliverConstructionSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.BuildConstructionSite, StringComparison.Ordinal) => UpdateBuildConstructionSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.AttackTarget, StringComparison.Ordinal) => UpdateAttackSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.HoldPosition, StringComparison.Ordinal) => UpdateHoldSubTask(ship, subTask, deltaSeconds), + _ => SubTaskOutcome.Failed, + }; + } + + private SubTaskOutcome UpdateHoldSubTask(ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + ship.State = ShipState.HoldingPosition; + ship.TargetPosition = subTask.TargetPosition ?? ship.Position; + ship.Position = ship.Position.MoveToward(ship.TargetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(ship.TargetPosition))); + return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.1f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateFollowSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); + if (targetShip is null) + { + subTask.BlockingReason = "follow-target-missing"; + return SubTaskOutcome.Failed; + } + + var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 16f)); + subTask.TargetSystemId = targetShip.SystemId; + subTask.TargetPosition = desiredPosition; + subTask.BlockingReason = null; + if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f)) + { + return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + } + + ship.State = ShipState.HoldingPosition; + ship.TargetPosition = desiredPosition; + ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition))); + return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.5f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateTravelSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds, bool completeOnArrival) + { + if (subTask.TargetPosition is null || subTask.TargetSystemId is null) + { + subTask.BlockingReason = "travel-target-missing"; + ship.State = ShipState.Blocked; + return SubTaskOutcome.Failed; + } + + var targetPosition = ResolveCurrentTargetPosition(world, subTask); + var targetCelestial = ResolveTravelTargetCelestial(world, subTask, targetPosition); + ship.TargetPosition = targetPosition; + + if (ship.SystemId != subTask.TargetSystemId) + { + if (!CanFtl(ship.Definition)) + { + subTask.BlockingReason = "ftl-unavailable"; + ship.State = ShipState.Blocked; + return SubTaskOutcome.Failed; + } + + var destinationEntryCelestial = ResolveSystemEntryCelestial(world, subTask.TargetSystemId); + var destinationEntryPosition = destinationEntryCelestial?.Position ?? targetPosition; + return UpdateFtlTransit(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, destinationEntryPosition, destinationEntryCelestial, completeOnArrival, targetPosition); + } + + var currentCelestial = ResolveCurrentCelestial(world, ship); + if (targetCelestial is not null + && currentCelestial is not null + && !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal)) + { + if (!CanWarp(ship.Definition)) + { + return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival); + } + + return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival); + } + + if (targetCelestial is not null + && ship.Position.DistanceTo(targetPosition) > WarpEngageDistanceKilometers + && CanWarp(ship.Definition)) + { + return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival); + } + + return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival); + } + + private SubTaskOutcome UpdateAttackSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); + var hostileStation = hostileShip is null + ? world.Stations.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) + : null; + if ((hostileShip is not null && hostileShip.FactionId == ship.FactionId) + || (hostileStation is not null && hostileStation.FactionId == ship.FactionId)) + { + subTask.BlockingReason = "friendly-target"; + return SubTaskOutcome.Failed; + } + + if (hostileShip is null && hostileStation is null) + { + return SubTaskOutcome.Completed; + } + + var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId; + var targetPosition = hostileShip?.Position ?? hostileStation!.Position; + var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f; + subTask.TargetSystemId = targetSystemId; + subTask.TargetPosition = targetPosition; + subTask.Threshold = attackRange; + + if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange) + { + return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + } + + ship.State = ShipState.EngagingTarget; + ship.TargetPosition = targetPosition; + ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f)); + var damage = GetShipDamagePerSecond(ship) * deltaSeconds * GetSkillFactor(ship.Skills.Combat); + subTask.Progress = 1f; + + if (hostileShip is not null) + { + hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage); + return hostileShip.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + hostileStation!.Health = MathF.Max(0f, hostileStation.Health - (damage * 0.6f)); + return hostileStation.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateMineSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var node = ResolveNode(world, subTask.TargetEntityId ?? subTask.TargetNodeId); + if (node is null || !CanExtractNode(ship, node, world)) + { + subTask.BlockingReason = "node-missing"; + ship.State = ShipState.Blocked; + return SubTaskOutcome.Failed; + } + + var targetPosition = subTask.TargetPosition ?? GetResourceHoldPosition(node.Position, ship.Id, 20f); + ship.TargetPosition = targetPosition; + if (ship.Position.DistanceTo(targetPosition) > MathF.Max(subTask.Threshold, 8f)) + { + ship.State = ShipState.MiningApproach; + ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + return SubTaskOutcome.Active; + } + + var cargoAmount = GetShipCargoAmount(ship); + if (cargoAmount >= ship.Definition.GetTotalCargoCapacity() - 0.01f) + { + return SubTaskOutcome.Completed; + } + + ship.State = ShipState.Mining; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.MiningCycleSeconds)) + { + return SubTaskOutcome.Active; + } + + var remainingCapacity = MathF.Max(0f, ship.Definition.GetTotalCargoCapacity() - cargoAmount); + var mined = MathF.Min(balance.MiningRate * GetSkillFactor(ship.Skills.Mining), remainingCapacity); + mined = MathF.Min(mined, node.OreRemaining); + if (mined <= 0.01f) + { + return SubTaskOutcome.Completed; + } + + AddInventory(ship.Inventory, node.ItemId, mined); + node.OreRemaining = MathF.Max(0f, node.OreRemaining - mined); + if (GetShipCargoAmount(ship) >= ship.Definition.GetTotalCargoCapacity() - 0.01f || node.OreRemaining <= 0.01f) + { + return SubTaskOutcome.Completed; + } + + subTask.ElapsedSeconds = 0f; + return SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateDockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var station = ResolveStation(world, subTask.TargetEntityId); + if (station is null) + { + subTask.BlockingReason = "dock-target-missing"; + ship.State = ShipState.Blocked; + return SubTaskOutcome.Failed; + } + + var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id); + if (padIndex is null) + { + ship.State = ShipState.AwaitingDock; + ship.TargetPosition = GetDockingHoldPosition(station, ship.Id); + if (ship.Position.DistanceTo(ship.TargetPosition) > 4f) + { + ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + } + + subTask.Status = WorkStatus.Blocked; + subTask.BlockingReason = "waiting-for-pad"; + return SubTaskOutcome.Active; + } + + subTask.Status = WorkStatus.Active; + subTask.BlockingReason = null; + ship.AssignedDockingPadIndex = padIndex; + var padPosition = GetDockingPadPosition(station, padIndex.Value); + ship.TargetPosition = padPosition; + if (ship.Position.DistanceTo(padPosition) > 4f) + { + ship.State = ShipState.DockingApproach; + ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + return SubTaskOutcome.Active; + } + + ship.State = ShipState.Docking; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.DockingDuration)) + { + return SubTaskOutcome.Active; + } + + ship.State = ShipState.Docked; + ship.DockedStationId = station.Id; + station.DockedShipIds.Add(ship.Id); + ship.KnownStationIds.Add(station.Id); + ship.Position = padPosition; + ship.TargetPosition = padPosition; + return SubTaskOutcome.Completed; + } + + private SubTaskOutcome UpdateUndockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + if (ship.DockedStationId is null) + { + return SubTaskOutcome.Completed; + } + + var station = ResolveStation(world, ship.DockedStationId); + if (station is null) + { + ship.DockedStationId = null; + ship.AssignedDockingPadIndex = null; + return SubTaskOutcome.Completed; + } + + var undockTarget = GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, balance.UndockDistance); + ship.TargetPosition = undockTarget; + ship.State = ShipState.Undocking; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.UndockingDuration)) + { + ship.Position = GetShipDockedPosition(ship, station); + return SubTaskOutcome.Active; + } + + ship.Position = ship.Position.MoveToward(undockTarget, balance.UndockDistance); + if (ship.Position.DistanceTo(undockTarget) > MathF.Max(subTask.Threshold, 4f)) + { + return SubTaskOutcome.Active; + } + + station.DockedShipIds.Remove(ship.Id); + ReleaseDockingPad(station, ship.Id); + ship.DockedStationId = null; + ship.AssignedDockingPadIndex = null; + return SubTaskOutcome.Completed; + } + + private SubTaskOutcome UpdateLoadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + if (ship.DockedStationId is null) + { + subTask.BlockingReason = "not-docked"; + return SubTaskOutcome.Failed; + } + + var station = ResolveStation(world, ship.DockedStationId); + if (station is null) + { + subTask.BlockingReason = "station-missing"; + return SubTaskOutcome.Failed; + } + + ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.Position = ship.TargetPosition; + ship.State = ShipState.Loading; + var itemId = subTask.ItemId; + if (itemId is null) + { + return SubTaskOutcome.Completed; + } + + var desiredAmount = subTask.Amount > 0f ? subTask.Amount : ship.Definition.GetTotalCargoCapacity(); + var availableCapacity = MathF.Max(0f, ship.Definition.GetTotalCargoCapacity() - GetShipCargoAmount(ship)); + var transferRate = balance.TransferRate * GetSkillFactor(ship.Skills.Trade); + var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(availableCapacity, GetInventoryAmount(station.Inventory, itemId))); + if (moved > 0.01f) + { + RemoveInventory(station.Inventory, itemId, moved); + AddInventory(ship.Inventory, itemId, moved); + } + + var loadedAmount = GetInventoryAmount(ship.Inventory, itemId); + subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(loadedAmount / desiredAmount, 0f, 1f); + return availableCapacity <= 0.01f || GetInventoryAmount(station.Inventory, itemId) <= 0.01f || loadedAmount >= desiredAmount - 0.01f + ? SubTaskOutcome.Completed + : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateUnloadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + if (ship.DockedStationId is null) + { + subTask.BlockingReason = "not-docked"; + return SubTaskOutcome.Failed; + } + + var station = ResolveStation(world, ship.DockedStationId); + if (station is null) + { + subTask.BlockingReason = "station-missing"; + return SubTaskOutcome.Failed; + } + + ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.Position = ship.TargetPosition; + ship.State = ShipState.Transferring; + var transferRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Mining)); + + if (subTask.ItemId is not null) + { + var moved = MathF.Min(transferRate * deltaSeconds, GetInventoryAmount(ship.Inventory, subTask.ItemId)); + var accepted = TryAddStationInventory(world, station, subTask.ItemId, moved); + RemoveInventory(ship.Inventory, subTask.ItemId, accepted); + subTask.Progress = subTask.Amount <= 0.01f + ? 1f + : Math.Clamp(1f - (GetInventoryAmount(ship.Inventory, subTask.ItemId) / subTask.Amount), 0f, 1f); + return GetInventoryAmount(ship.Inventory, subTask.ItemId) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + foreach (var (itemId, amount) in ship.Inventory.ToList().OrderBy(entry => entry.Key, StringComparer.Ordinal)) + { + var moved = MathF.Min(amount, transferRate * deltaSeconds); + var accepted = TryAddStationInventory(world, station, itemId, moved); + RemoveInventory(ship.Inventory, itemId, accepted); + if (accepted > 0.01f) + { + return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + } + + return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateTransferCargoToShipSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); + if (targetShip is null) + { + subTask.BlockingReason = "target-ship-missing"; + return SubTaskOutcome.Failed; + } + + var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 12f)); + subTask.TargetSystemId = targetShip.SystemId; + subTask.TargetPosition = desiredPosition; + if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f)) + { + return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + } + + ship.State = ShipState.Transferring; + ship.TargetPosition = desiredPosition; + ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition))); + if (subTask.ItemId is null) + { + return SubTaskOutcome.Completed; + } + + var targetCapacity = MathF.Max(0f, targetShip.Definition.GetTotalCargoCapacity() - GetShipCargoAmount(targetShip)); + if (targetCapacity <= 0.01f) + { + subTask.BlockingReason = "target-cargo-full"; + return SubTaskOutcome.Failed; + } + + var transferRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Navigation)); + var desiredAmount = subTask.Amount > 0f ? subTask.Amount : GetInventoryAmount(ship.Inventory, subTask.ItemId); + var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(targetCapacity, GetInventoryAmount(ship.Inventory, subTask.ItemId))); + if (moved > 0.01f) + { + RemoveInventory(ship.Inventory, subTask.ItemId, moved); + AddInventory(targetShip.Inventory, subTask.ItemId, moved); + } + + var remaining = GetInventoryAmount(ship.Inventory, subTask.ItemId); + subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(1f - (remaining / desiredAmount), 0f, 1f); + return remaining <= 0.01f || GetShipCargoAmount(targetShip) >= targetShip.Definition.GetTotalCargoCapacity() - 0.01f + ? SubTaskOutcome.Completed + : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateSalvageSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.RemainingAmount > 0.01f); + if (wreck is null) + { + return SubTaskOutcome.Completed; + } + + var desiredPosition = subTask.TargetPosition ?? GetFormationPosition(wreck.Position, ship.Id, 8f); + ship.TargetPosition = desiredPosition; + if (ship.SystemId != wreck.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 8f)) + { + subTask.TargetSystemId = wreck.SystemId; + subTask.TargetPosition = desiredPosition; + return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + } + + ship.State = ShipState.Transferring; + var remainingCapacity = MathF.Max(0f, ship.Definition.GetTotalCargoCapacity() - GetShipCargoAmount(ship)); + if (remainingCapacity <= 0.01f) + { + return SubTaskOutcome.Completed; + } + + if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.4f, balance.MiningCycleSeconds * 0.8f))) + { + return SubTaskOutcome.Active; + } + + var salvageRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Mining, ship.Skills.Trade)); + var recovered = MathF.Min(salvageRate, MathF.Min(remainingCapacity, wreck.RemainingAmount)); + if (recovered > 0.01f) + { + AddInventory(ship.Inventory, wreck.ItemId, recovered); + wreck.RemainingAmount = MathF.Max(0f, wreck.RemainingAmount - recovered); + } + + if (wreck.RemainingAmount <= 0.01f) + { + world.Wrecks.RemoveAll(candidate => candidate.Id == wreck.Id); + } + + subTask.ElapsedSeconds = 0f; + return wreck.RemainingAmount <= 0.01f || GetShipCargoAmount(ship) >= ship.Definition.GetTotalCargoCapacity() - 0.01f + ? SubTaskOutcome.Completed + : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateDeliverConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + var station = site is null ? null : ResolveSupportStation(world, ship, site); + if (site is null || station is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed) + { + subTask.BlockingReason = "construction-target-missing"; + return SubTaskOutcome.Failed; + } + + var supportPosition = ResolveSupportPosition(ship, station, site, world); + if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold))) + { + ship.State = ShipState.LocalFlight; + ship.TargetPosition = supportPosition; + ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + return SubTaskOutcome.Active; + } + + ship.TargetPosition = supportPosition; + ship.Position = supportPosition; + ship.State = ShipState.DeliveringConstruction; + var transferRate = balance.TransferRate * GetSkillFactor(ship.Skills.Construction); + foreach (var required in site.RequiredItems.OrderBy(entry => entry.Key, StringComparer.Ordinal)) + { + var delivered = GetInventoryAmount(site.DeliveredItems, required.Key); + var remaining = MathF.Max(0f, required.Value - delivered); + if (remaining <= 0.01f) + { + continue; + } + + var available = MathF.Max(0f, GetInventoryAmount(station.Inventory, required.Key) - GetStationReserveFloor(world, station, required.Key)); + var moved = MathF.Min(remaining, MathF.Min(available, transferRate * deltaSeconds)); + if (moved <= 0.01f) + { + continue; + } + + RemoveInventory(station.Inventory, required.Key, moved); + AddInventory(site.Inventory, required.Key, moved); + AddInventory(site.DeliveredItems, required.Key, moved); + break; + } + + subTask.Progress = site.RequiredItems.Count == 0 + ? 1f + : site.RequiredItems.Sum(required => + required.Value <= 0.01f + ? 1f + : Math.Clamp(GetInventoryAmount(site.DeliveredItems, required.Key) / required.Value, 0f, 1f)) / site.RequiredItems.Count; + return IsConstructionSiteReady(world, site) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateBuildConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + var station = site is null ? null : ResolveSupportStation(world, ship, site); + if (site is null || station is null || site.BlueprintId is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed) + { + subTask.BlockingReason = "construction-site-missing"; + return SubTaskOutcome.Failed; + } + + var supportPosition = ResolveSupportPosition(ship, station, site, world); + if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold))) + { + ship.State = ShipState.LocalFlight; + ship.TargetPosition = supportPosition; + ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + return SubTaskOutcome.Active; + } + + if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe)) + { + ship.State = ShipState.WaitingMaterials; + subTask.Status = WorkStatus.Blocked; + subTask.BlockingReason = "waiting-materials"; + return SubTaskOutcome.Active; + } + + subTask.Status = WorkStatus.Active; + subTask.BlockingReason = null; + ship.TargetPosition = supportPosition; + ship.Position = supportPosition; + ship.State = ShipState.Constructing; + site.AssignedConstructorShipIds.Add(ship.Id); + site.Progress += deltaSeconds * GetSkillFactor(ship.Skills.Construction); + subTask.Progress = recipe.Duration <= 0.01f ? 1f : Math.Clamp(site.Progress / recipe.Duration, 0f, 1f); + if (site.Progress < recipe.Duration) + { + return SubTaskOutcome.Active; + } + + if (site.StationId is null) + { + CompleteStationFoundation(world, station, site); + } + else + { + AddStationModule(world, station, site.BlueprintId); + PrepareNextConstructionSiteStep(world, station, site); + } + + site.State = ConstructionSiteStateKinds.Completed; + return SubTaskOutcome.Completed; + } + + private static bool AdvanceTimedSubTask(ShipSubTaskRuntime subTask, float deltaSeconds, float requiredSeconds) + { + subTask.TotalSeconds = requiredSeconds; + subTask.ElapsedSeconds += deltaSeconds; + subTask.Progress = requiredSeconds <= 0.01f ? 1f : Math.Clamp(subTask.ElapsedSeconds / requiredSeconds, 0f, 1f); + if (subTask.ElapsedSeconds < requiredSeconds) + { + return false; + } + + subTask.ElapsedSeconds = 0f; + return true; + } + + private SubTaskOutcome UpdateLocalTravel( + SimulationWorld world, + ShipRuntime ship, + ShipSubTaskRuntime subTask, + float deltaSeconds, + string targetSystemId, + Vector3 targetPosition, + CelestialRuntime? targetCelestial, + bool completeOnArrival) + { + var distance = ship.Position.DistanceTo(targetPosition); + ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace; + ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; + ship.SpatialState.Transit = null; + ship.SpatialState.DestinationNodeId = targetCelestial?.Id; + subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f); + + if (distance <= MathF.Max(subTask.Threshold, balance.ArrivalThreshold)) + { + ship.Position = targetPosition; + ship.TargetPosition = targetPosition; + ship.SystemId = targetSystemId; + ship.SpatialState.CurrentSystemId = targetSystemId; + ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; + ship.State = ShipState.Arriving; + return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + ship.State = ShipState.LocalFlight; + ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + return SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateWarpTransit( + SimulationWorld world, + ShipRuntime ship, + ShipSubTaskRuntime subTask, + float deltaSeconds, + Vector3 targetPosition, + CelestialRuntime targetCelestial, + bool completeOnArrival) + { + var transit = ship.SpatialState.Transit; + if (transit is null || transit.Regime != MovementRegimeKind.Warp || transit.DestinationNodeId != targetCelestial.Id) + { + transit = new ShipTransitRuntime + { + Regime = MovementRegimeKind.Warp, + OriginNodeId = ship.SpatialState.CurrentCelestialId, + DestinationNodeId = targetCelestial.Id, + StartedAtUtc = world.GeneratedAtUtc, + }; + ship.SpatialState.Transit = transit; + subTask.ElapsedSeconds = 0f; + } + + ship.SpatialState.SpaceLayer = SpaceLayerKind.SystemSpace; + ship.SpatialState.MovementRegime = MovementRegimeKind.Warp; + ship.SpatialState.CurrentCelestialId = null; + ship.SpatialState.DestinationNodeId = targetCelestial.Id; + + var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); + if (ship.State != ShipState.Warping) + { + ship.State = ShipState.SpoolingWarp; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, spoolDuration)) + { + return SubTaskOutcome.Active; + } + + ship.State = ShipState.Warping; + } + + var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null + ? ship.Position.DistanceTo(targetPosition) + : (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition))); + ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds); + transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance)); + subTask.Progress = transit.Progress; + if (ship.Position.DistanceTo(targetPosition) > 18f) + { + return SubTaskOutcome.Active; + } + + return CompleteTransitArrival(ship, subTask.TargetSystemId ?? ship.SystemId, targetPosition, targetCelestial, completeOnArrival); + } + + private SubTaskOutcome UpdateFtlTransit( + SimulationWorld world, + ShipRuntime ship, + ShipSubTaskRuntime subTask, + float deltaSeconds, + string targetSystemId, + Vector3 entryPosition, + CelestialRuntime? targetCelestial, + bool completeOnArrival, + Vector3 finalTargetPosition) + { + var destinationNodeId = targetCelestial?.Id; + var transit = ship.SpatialState.Transit; + if (transit is null || transit.Regime != MovementRegimeKind.FtlTransit || transit.DestinationNodeId != destinationNodeId) + { + transit = new ShipTransitRuntime + { + Regime = MovementRegimeKind.FtlTransit, + OriginNodeId = ship.SpatialState.CurrentCelestialId, + DestinationNodeId = destinationNodeId, + StartedAtUtc = world.GeneratedAtUtc, + }; + ship.SpatialState.Transit = transit; + subTask.ElapsedSeconds = 0f; + } + + ship.SpatialState.SpaceLayer = SpaceLayerKind.GalaxySpace; + ship.SpatialState.MovementRegime = MovementRegimeKind.FtlTransit; + ship.SpatialState.CurrentCelestialId = null; + ship.SpatialState.DestinationNodeId = destinationNodeId; + + if (ship.State != ShipState.Ftl) + { + ship.State = ShipState.SpoolingFtl; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(ship.Definition.SpoolTime, 0.1f))) + { + return SubTaskOutcome.Active; + } + + ship.State = ShipState.Ftl; + } + + var originSystemPosition = ResolveSystemGalaxyPosition(world, ship.SystemId); + var destinationSystemPosition = ResolveSystemGalaxyPosition(world, targetSystemId); + var totalDistance = MathF.Max(0.001f, originSystemPosition.DistanceTo(destinationSystemPosition)); + transit.Progress = MathF.Min(1f, transit.Progress + ((ship.Definition.FtlSpeed * GetSkillFactor(ship.Skills.Navigation)) * deltaSeconds / totalDistance)); + subTask.Progress = transit.Progress; + if (transit.Progress < 0.999f) + { + return SubTaskOutcome.Active; + } + + ship.Position = entryPosition; + ship.TargetPosition = finalTargetPosition; + ship.SystemId = targetSystemId; + ship.SpatialState.CurrentSystemId = targetSystemId; + ship.SpatialState.Transit = null; + ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace; + ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; + ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; + ship.SpatialState.DestinationNodeId = targetCelestial?.Id; + ship.State = ShipState.Arriving; + + // Cross-system travel is only complete once the ship finishes the + // destination-system local leg to the actual target. + return SubTaskOutcome.Active; + } + + private static SubTaskOutcome CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial, bool completeOnArrival) + { + ship.Position = targetPosition; + ship.TargetPosition = targetPosition; + ship.SystemId = targetSystemId; + ship.SpatialState.CurrentSystemId = targetSystemId; + ship.SpatialState.Transit = null; + ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace; + ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; + ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; + ship.SpatialState.DestinationNodeId = targetCelestial?.Id; + ship.State = ShipState.Arriving; + return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } +} diff --git a/apps/backend/Ships/AI/ShipAiService.Helpers.cs b/apps/backend/Ships/AI/ShipAiService.Helpers.cs new file mode 100644 index 0000000..d30283d --- /dev/null +++ b/apps/backend/Ships/AI/ShipAiService.Helpers.cs @@ -0,0 +1,947 @@ +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; +using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; +using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; +using static SpaceGame.Api.Stations.Simulation.StationSimulationService; + +namespace SpaceGame.Api.Ships.AI; + +public sealed partial class ShipAiService +{ + private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ShipSubTaskRuntime subTask) + { + if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) + { + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (ship is not null) + { + return ship.Position; + } + + var station = ResolveStation(world, subTask.TargetEntityId); + if (station is not null) + { + return station.Position; + } + + var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (celestial is not null) + { + return celestial.Position; + } + + var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (wreck is not null) + { + return wreck.Position; + } + } + + return subTask.TargetPosition ?? Vector3.Zero; + } + + private static CelestialRuntime? ResolveTravelTargetCelestial(SimulationWorld world, ShipSubTaskRuntime subTask, Vector3 targetPosition) + { + if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) + { + var station = ResolveStation(world, subTask.TargetEntityId); + if (station?.CelestialId is not null) + { + return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId); + } + + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (site?.CelestialId is not null) + { + return world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); + } + + var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (celestial is not null) + { + return celestial; + } + + if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } wreck) + { + return world.Celestials + .Where(candidate => candidate.SystemId == wreck.SystemId) + .OrderBy(candidate => candidate.Position.DistanceTo(wreck.Position)) + .FirstOrDefault(); + } + } + + return world.Celestials + .Where(candidate => subTask.TargetSystemId is null || candidate.SystemId == subTask.TargetSystemId) + .OrderBy(candidate => candidate.Position.DistanceTo(targetPosition)) + .FirstOrDefault(); + } + + private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship) + { + if (ship.SpatialState.CurrentCelestialId is not null) + { + return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId); + } + + return world.Celestials + .Where(candidate => candidate.SystemId == ship.SystemId) + .OrderBy(candidate => candidate.Position.DistanceTo(ship.Position)) + .FirstOrDefault(); + } + + private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) => + world.Celestials.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star); + + private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) => + world.Systems.FirstOrDefault(candidate => candidate.Definition.Id == systemId)?.Position ?? Vector3.Zero; + + private static float GetLocalTravelSpeed(ShipRuntime ship) => + SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed) * GetSkillFactor(ship.Skills.Navigation); + + private static float GetWarpTravelSpeed(ShipRuntime ship) => + SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed) * GetSkillFactor(ship.Skills.Navigation); + + private static float GetSkillFactor(int skillLevel) => + Math.Clamp(1f + ((skillLevel - 3) * 0.08f), 0.75f, 1.4f); + + private static int GetEffectiveSkillLevel( + SimulationWorld world, + ShipRuntime ship, + Func captainSelector, + Func managerSelector) + { + var captainLevel = captainSelector(ship.Skills); + if (ship.CommanderId is null) + { + return captainLevel; + } + + var shipCommander = world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId); + var manager = shipCommander?.ParentCommanderId is null + ? shipCommander + : world.Commanders.FirstOrDefault(candidate => candidate.Id == shipCommander.ParentCommanderId) ?? shipCommander; + return Math.Clamp((captainLevel + (manager is null ? 3 : managerSelector(manager.Skills)) + 1) / 2, 1, 5); + } + + private static int ResolveBehaviorSystemRange(SimulationWorld world, ShipRuntime ship, string behaviorKind, int explicitRange) + { + if (explicitRange > 0) + { + return explicitRange; + } + + var tradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination); + var miningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); + var combatSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Combat, skills => skills.Strategy); + return behaviorKind switch + { + LocalAutoMine or LocalAutoTrade => 0, + AdvancedAutoMine => Math.Clamp(1 + ((miningSkill - 1) / 2), 1, 3), + AdvancedAutoTrade => Math.Clamp(1 + ((tradeSkill - 1) / 2), 1, 3), + ExpertAutoMine => Math.Clamp(2 + ((miningSkill - 1) / 2), 2, Math.Max(world.Systems.Count - 1, 2)), + FillShortages or FindBuildTasks or RevisitKnownStations or SupplyFleet => Math.Clamp(1 + ((tradeSkill + 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)), + Patrol or Police or ProtectPosition or ProtectShip or ProtectStation => Math.Clamp(1 + ((combatSkill - 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)), + _ => Math.Max(world.Systems.Count - 1, 0), + }; + } + + private static int GetSystemDistanceTier(SimulationWorld world, string originSystemId, string targetSystemId) + { + if (string.Equals(originSystemId, targetSystemId, StringComparison.Ordinal)) + { + return 0; + } + + var originPosition = ResolveSystemGalaxyPosition(world, originSystemId); + return world.Systems + .OrderBy(system => system.Position.DistanceTo(originPosition)) + .ThenBy(system => system.Definition.Id, StringComparer.Ordinal) + .Select(system => system.Definition.Id) + .TakeWhile(systemId => !string.Equals(systemId, targetSystemId, StringComparison.Ordinal)) + .Count(); + } + + private static bool IsWithinSystemRange(SimulationWorld world, string originSystemId, string targetSystemId, int maxRange) => + maxRange < 0 || GetSystemDistanceTier(world, originSystemId, targetSystemId) <= maxRange; + + private static float GetShipDamagePerSecond(ShipRuntime ship) => + ship.Definition.Type switch + { + ShipType.Frigate => FrigateDps, + ShipType.Destroyer => DestroyerDps, + ShipType.Battleship => CruiserDps, + ShipType.Carrier => CapitalDps, + _ => 4f, + }; + + private static MiningOpportunity? SelectMiningOpportunity( + SimulationWorld world, + ShipRuntime ship, + StationRuntime homeStation, + CommanderAssignmentRuntime? assignment, + string behaviorKind) + { + var policy = ResolvePolicy(world, ship.PolicySetId); + var preferredItemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId; + var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); + var effectiveMiningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); + string? deniedReason = null; + var opportunity = world.Nodes + .Where(node => + { + if (node.OreRemaining <= 0.01f || !CanExtractNode(ship, node, world) || (preferredItemId is not null && !string.Equals(node.ItemId, preferredItemId, StringComparison.Ordinal))) + { + return false; + } + + if (!TryCheckSystemAllowed(world, policy, ship.FactionId, node.SystemId, "military", out var reason)) + { + deniedReason ??= reason; + return false; + } + + return IsWithinSystemRange(world, homeStation.SystemId, node.SystemId, rangeBudget); + }) + .Select(node => + { + var buyer = SelectBestDeliveryStation(world, ship, node.ItemId, homeStation, behaviorKind); + var demandScore = GetFactionDemandScore(world, ship.FactionId, node.ItemId); + var distancePenalty = GetSystemDistanceTier(world, homeStation.SystemId, node.SystemId) * 18f; + var routeRiskPenalty = GeopoliticalSimulationService.GetSystemRouteRisk(world, node.SystemId, ship.FactionId) * 30f; + var score = (node.SystemId == homeStation.SystemId ? 55f : 0f) + + (node.OreRemaining * 0.025f) + + (demandScore * (string.Equals(behaviorKind, ExpertAutoMine, StringComparison.Ordinal) ? 22f : 12f)) + + (effectiveMiningSkill * 10f) + - distancePenalty + - routeRiskPenalty + - node.Position.DistanceTo(ship.Position); + return new MiningOpportunity(node, buyer, score, $"Mine {node.ItemId} for {buyer.Label}"); + }) + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.Node.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (opportunity is null && deniedReason is not null) + { + ship.LastAccessFailureReason = deniedReason; + } + + return opportunity; + } + + private static TradeRoutePlan? SelectTradeRoute( + SimulationWorld world, + ShipRuntime ship, + StationRuntime? homeStation, + string behaviorKind, + bool knownStationsOnly) + { + var policy = ResolvePolicy(world, ship.PolicySetId); + var stationsById = world.Stations + .Where(station => station.FactionId == ship.FactionId) + .ToDictionary(station => station.Id, StringComparer.Ordinal); + var originSystemId = homeStation?.SystemId ?? ship.SystemId; + var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); + var effectiveTradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination); + var requireKnownStations = knownStationsOnly || string.Equals(behaviorKind, RevisitKnownStations, StringComparison.Ordinal); + string? deniedReason = null; + + var route = world.MarketOrders + .Where(order => + order.FactionId == ship.FactionId && + order.Kind == MarketOrderKinds.Buy && + order.RemainingAmount > 0.01f) + .Select(order => + { + StationRuntime? destination = null; + ConstructionSiteRuntime? destinationSite = null; + if (order.StationId is not null && stationsById.TryGetValue(order.StationId, out var destinationStation)) + { + destination = destinationStation; + } + else if (order.ConstructionSiteId is not null) + { + destinationSite = world.ConstructionSites.FirstOrDefault(site => site.Id == order.ConstructionSiteId); + if (destinationSite is not null) + { + destination = ResolveSupportStation(world, ship, destinationSite); + } + } + + if (destination is null) + { + return null; + } + + if (!TryCheckSystemAllowed(world, policy, ship.FactionId, destination.SystemId, "trade", out var destinationDeniedReason)) + { + deniedReason ??= destinationDeniedReason; + return null; + } + if (!IsWithinSystemRange(world, originSystemId, destination.SystemId, rangeBudget)) + { + return null; + } + if (requireKnownStations + && ship.KnownStationIds.Count > 0 + && !ship.KnownStationIds.Contains(destination.Id) + && (homeStation is null || !string.Equals(destination.Id, homeStation.Id, StringComparison.Ordinal))) + { + return null; + } + if (string.Equals(behaviorKind, FindBuildTasks, StringComparison.Ordinal) && destinationSite is null) + { + return null; + } + if (!string.Equals(behaviorKind, FindBuildTasks, StringComparison.Ordinal) && destinationSite is not null) + { + return null; + } + + var source = stationsById.Values + .Where(station => + { + if (station.Id == destination.Id || GetInventoryAmount(station.Inventory, order.ItemId) <= GetStationReserveFloor(world, station, order.ItemId) + 1f) + { + return false; + } + + if (!TryCheckSystemAllowed(world, policy, ship.FactionId, station.SystemId, "trade", out var sourceDeniedReason)) + { + deniedReason ??= sourceDeniedReason; + return false; + } + + if (!IsWithinSystemRange(world, originSystemId, station.SystemId, rangeBudget)) + { + return false; + } + + return !requireKnownStations + || ship.KnownStationIds.Count == 0 + || ship.KnownStationIds.Contains(station.Id) + || (homeStation is not null && string.Equals(station.Id, homeStation.Id, StringComparison.Ordinal)); + }) + .OrderByDescending(station => GetInventoryAmount(station.Inventory, order.ItemId) - GetStationReserveFloor(world, station, order.ItemId)) + .ThenByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (source is null) + { + return null; + } + + var shortageBias = string.Equals(behaviorKind, FillShortages, StringComparison.Ordinal) + ? GetFactionDemandScore(world, ship.FactionId, order.ItemId) * 35f + : 0f; + var buildBias = destinationSite is null ? 0f : 65f; + var revisitBias = string.Equals(behaviorKind, RevisitKnownStations, StringComparison.Ordinal) && ship.KnownStationIds.Contains(source.Id) && ship.KnownStationIds.Contains(destination.Id) + ? 28f + : 0f; + var regionalNeedBias = GetRegionalCommodityPressure(world, ship.FactionId, destination.SystemId, order.ItemId) * 18f; + var systemRangePenalty = (GetSystemDistanceTier(world, originSystemId, source.SystemId) + GetSystemDistanceTier(world, originSystemId, destination.SystemId)) * 16f; + var riskPenalty = + (GeopoliticalSimulationService.GetSystemRouteRisk(world, source.SystemId, ship.FactionId) + + GeopoliticalSimulationService.GetSystemRouteRisk(world, destination.SystemId, ship.FactionId)) * 22f; + var distanceScore = source.Position.DistanceTo(ship.Position) + source.Position.DistanceTo(destination.Position); + var score = (order.Valuation * 50f) + + shortageBias + + buildBias + + revisitBias + + regionalNeedBias + + (effectiveTradeSkill * 12f) + - systemRangePenalty + - riskPenalty + - distanceScore; + var summary = destinationSite is null + ? $"{order.ItemId}: {source.Label} -> {destination.Label}" + : $"{order.ItemId}: {source.Label} -> build support {destination.Label}"; + return new TradeRoutePlan(source, destination, order.ItemId, score, summary); + }) + .Where(route => route is not null) + .Cast() + .OrderByDescending(route => route.Score) + .ThenBy(route => route.ItemId, StringComparer.Ordinal) + .ThenBy(route => route.SourceStation.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (route is null && deniedReason is not null) + { + ship.LastAccessFailureReason = deniedReason; + } + + return route; + } + + private static FleetSupplyPlan? SelectFleetSupplyPlan(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) + { + var assignment = ResolveAssignment(world, ship); + var targetCandidates = world.Ships + .Where(candidate => + candidate.Id != ship.Id && + candidate.FactionId == ship.FactionId && + candidate.Definition.GetTotalCargoCapacity() > 0.01f && + (assignment?.TargetEntityId is null || string.Equals(candidate.Id, assignment.TargetEntityId, StringComparison.Ordinal))) + .OrderByDescending(candidate => IsMilitaryShip(candidate.Definition) ? 1 : 0) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToList(); + if (targetCandidates.Count == 0) + { + return null; + } + + var sourceStations = world.Stations + .Where(station => station.FactionId == ship.FactionId) + .OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .ToList(); + foreach (var target in targetCandidates) + { + var itemId = assignment?.ItemId + ?? sourceStations + .SelectMany(station => station.Inventory) + .Where(entry => entry.Value > 2f) + .OrderByDescending(entry => entry.Value) + .ThenBy(entry => entry.Key, StringComparer.Ordinal) + .Select(entry => entry.Key) + .FirstOrDefault(); + if (itemId is null) + { + continue; + } + + var source = sourceStations.FirstOrDefault(station => GetInventoryAmount(station.Inventory, itemId) > 2f); + if (source is null) + { + continue; + } + + var amount = MathF.Min(MathF.Max(10f, ship.Definition.GetTotalCargoCapacity() * 0.5f), GetInventoryAmount(source.Inventory, itemId)); + return new FleetSupplyPlan(source, target, itemId, amount, MathF.Max(16f, ship.DefaultBehavior.Radius), $"Supply {target.Definition.Name} with {itemId}"); + } + + return null; + } + + private static StationRuntime? SelectKnownStationVisit(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) + { + var candidateIds = ship.KnownStationIds.Count == 0 && homeStation is not null + ? [homeStation.Id] + : ship.KnownStationIds.OrderBy(id => id, StringComparer.Ordinal).ToArray(); + return candidateIds + .Select(id => ResolveStation(world, id)) + .Where(station => station is not null && station.FactionId == ship.FactionId) + .Cast() + .OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) + .ThenBy(station => station.SystemId == ship.SystemId ? 0 : 1) + .ThenBy(station => station.Position.DistanceTo(ship.Position)) + .FirstOrDefault(); + } + + private static StationRuntime SelectBestDeliveryStation(SimulationWorld world, ShipRuntime ship, string itemId, StationRuntime homeStation, string behaviorKind) + { + if (!string.Equals(behaviorKind, ExpertAutoMine, StringComparison.Ordinal)) + { + return homeStation; + } + + return world.Stations + .Where(station => station.FactionId == ship.FactionId) + .OrderByDescending(station => GetFactionDemandScore(world, ship.FactionId, itemId) + GetRegionalCommodityPressure(world, ship.FactionId, station.SystemId, itemId) + (station.Id == homeStation.Id ? 5f : 0f)) + .ThenBy(station => station.SystemId == homeStation.SystemId ? 0 : 1) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault() + ?? homeStation; + } + + private static ResourceNodeRuntime? SelectLocalMiningNode(SimulationWorld world, ShipRuntime ship, string systemId, string itemId) + { + var policy = ResolvePolicy(world, ship.PolicySetId); + string? deniedReason = null; + var node = world.Nodes + .Where(candidate => + { + if (!string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal) + || !string.Equals(candidate.ItemId, itemId, StringComparison.Ordinal) + || candidate.OreRemaining <= 0.01f + || !CanExtractNode(ship, candidate, world)) + { + return false; + } + + if (!TryCheckSystemAllowed(world, policy, ship.FactionId, candidate.SystemId, "military", out var reason)) + { + deniedReason ??= reason; + return false; + } + + return true; + }) + .OrderByDescending(candidate => candidate.OreRemaining) + .ThenBy(candidate => candidate.Position.DistanceTo(ship.Position)) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (node is null && deniedReason is not null) + { + ship.LastAccessFailureReason = deniedReason; + } + + return node; + } + + private static StationRuntime? SelectLocalAutoMineBuyer(SimulationWorld world, ShipRuntime ship, string systemId, string itemId) + { + var policy = ResolvePolicy(world, ship.PolicySetId); + var stationsById = world.Stations.ToDictionary(station => station.Id, StringComparer.Ordinal); + string? deniedReason = null; + var buyer = world.MarketOrders + .Where(order => + order.Kind == MarketOrderKinds.Buy + && string.Equals(order.ItemId, itemId, StringComparison.Ordinal) + && order.RemainingAmount > 0.01f) + .Select(order => + { + StationRuntime? destination = null; + if (order.StationId is not null && stationsById.TryGetValue(order.StationId, out var station)) + { + destination = station; + } + else if (order.ConstructionSiteId is not null) + { + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == order.ConstructionSiteId); + if (site is not null) + { + destination = ResolveSupportStation(world, ship, site); + } + } + + if (destination is null || !string.Equals(destination.SystemId, systemId, StringComparison.Ordinal)) + { + return null; + } + + if (!TryCheckSystemAllowed(world, policy, ship.FactionId, destination.SystemId, "trade", out var reason)) + { + deniedReason ??= reason; + return null; + } + + var score = (order.Valuation * 20f) + + MathF.Min(order.RemainingAmount, ship.Definition.GetTotalCargoCapacity()) + - destination.Position.DistanceTo(ship.Position); + return new LocalMiningBuyerCandidate(destination, score); + }) + .Where(candidate => candidate is not null) + .Cast() + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.Station.Id, StringComparer.Ordinal) + .Select(candidate => candidate.Station) + .FirstOrDefault(); + if (buyer is null && deniedReason is not null) + { + ship.LastAccessFailureReason = deniedReason; + } + + return buyer; + } + + private static float GetFactionDemandScore(SimulationWorld world, string factionId, string itemId) + { + var signal = CommanderPlanningService.FindFactionEconomicAssessment(world, factionId)? + .CommoditySignals + .FirstOrDefault(candidate => candidate.ItemId == itemId); + var regionalBottleneckScore = world.Geopolitics?.EconomyRegions.Bottlenecks + .Where(bottleneck => string.Equals(bottleneck.ItemId, itemId, StringComparison.Ordinal)) + .Join( + world.Geopolitics.EconomyRegions.Regions.Where(region => string.Equals(region.FactionId, factionId, StringComparison.Ordinal)), + bottleneck => bottleneck.RegionId, + region => region.Id, + (bottleneck, _) => bottleneck.Severity) + .DefaultIfEmpty() + .Max() ?? 0f; + if (signal is null) + { + return regionalBottleneckScore * 8f; + } + + return MathF.Max(0f, signal.BuyBacklog + signal.ReservedForConstruction + MathF.Max(0f, -signal.ProjectedNetRatePerSecond * 50f) + (regionalBottleneckScore * 8f)); + } + + private static float GetRegionalCommodityPressure(SimulationWorld world, string factionId, string systemId, string itemId) + { + var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, systemId); + if (region is null) + { + return 0f; + } + + var bottleneck = world.Geopolitics?.EconomyRegions.Bottlenecks + .FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal) + && string.Equals(candidate.ItemId, itemId, StringComparison.Ordinal)); + var assessment = world.Geopolitics?.EconomyRegions.EconomicAssessments + .FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal)); + return (bottleneck?.Severity ?? 0f) + ((assessment?.ConstructionPressure ?? 0f) * 2f); + } + + private static ThreatTargetCandidate? SelectThreatTarget( + SimulationWorld world, + ShipRuntime ship, + string targetSystemId, + Vector3 anchorPosition, + float radius, + string? excludeEntityId = null) + { + var policy = ResolvePolicy(world, ship.PolicySetId); + return world.Ships + .Where(candidate => + candidate.Id != excludeEntityId && + candidate.Health > 0f && + candidate.FactionId != ship.FactionId && + string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) && + candidate.Position.DistanceTo(anchorPosition) <= radius * 1.75f) + .Select(candidate => new ThreatTargetCandidate( + candidate.Id, + candidate.SystemId, + candidate.Position, + 100f + + (IsMilitaryShip(candidate.Definition) ? 30f : 0f) + - candidate.Position.DistanceTo(anchorPosition) + - candidate.Position.DistanceTo(ship.Position) + + (string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase) ? 12f : 0f))) + .Concat(world.Stations + .Where(candidate => + candidate.Id != excludeEntityId && + candidate.FactionId != ship.FactionId && + string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) && + candidate.Position.DistanceTo(anchorPosition) <= radius * 2f) + .Select(candidate => new ThreatTargetCandidate(candidate.Id, candidate.SystemId, candidate.Position, 45f - candidate.Position.DistanceTo(anchorPosition) * 0.2f))) + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static PoliceContactCandidate? SelectPoliceContact(SimulationWorld world, ShipRuntime ship, string systemId, Vector3 anchorPosition, float radius) + { + var policy = ResolvePolicy(world, ship.PolicySetId); + return world.Ships + .Where(candidate => + candidate.Id != ship.Id && + candidate.Health > 0f && + candidate.FactionId != ship.FactionId && + string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal) && + candidate.Position.DistanceTo(anchorPosition) <= radius * 1.5f) + .Select(candidate => + { + var engage = IsMilitaryShip(candidate.Definition) + || string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase); + var score = (engage ? 80f : 40f) + - candidate.Position.DistanceTo(anchorPosition) + - candidate.Position.DistanceTo(ship.Position) + + (IsTransportShip(candidate.Definition) ? 8f : 0f); + return new PoliceContactCandidate(candidate.Id, candidate.SystemId, candidate.Position, engage, score); + }) + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static SalvageOpportunity? SelectSalvageOpportunity(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) + { + if (homeStation is null) + { + return null; + } + + var rangeBudget = ResolveBehaviorSystemRange(world, ship, AutoSalvage, ship.DefaultBehavior.MaxSystemRange > 0 ? ship.DefaultBehavior.MaxSystemRange : 1); + return world.Wrecks + .Where(wreck => + wreck.RemainingAmount > 0.01f && + IsWithinSystemRange(world, homeStation.SystemId, wreck.SystemId, rangeBudget)) + .Select(wreck => new SalvageOpportunity( + wreck, + (wreck.RemainingAmount * 3f) - wreck.Position.DistanceTo(ship.Position) - (GetSystemDistanceTier(world, homeStation.SystemId, wreck.SystemId) * 25f), + $"Salvage {wreck.ItemId} from {wreck.SourceEntityId}")) + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.Wreck.Id, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static (string SystemId, Vector3 Position)? ResolveObjectTarget(SimulationWorld world, string? entityId) + { + if (entityId is null) + { + return null; + } + + if (world.Ships.FirstOrDefault(candidate => candidate.Id == entityId) is { } ship) + { + return (ship.SystemId, ship.Position); + } + + if (ResolveStation(world, entityId) is { } station) + { + return (station.SystemId, station.Position); + } + + if (world.Celestials.FirstOrDefault(candidate => candidate.Id == entityId) is { } celestial) + { + return (celestial.SystemId, celestial.Position); + } + + if (world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId) is { } site) + { + var position = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? Vector3.Zero; + return (site.SystemId, position); + } + + if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == entityId) is { } wreck) + { + return (wreck.SystemId, wreck.Position); + } + + return null; + } + + private static Vector3 GetFormationPosition(Vector3 anchorPosition, string seed, float radius) + { + var hash = Math.Abs(seed.Aggregate(17, (acc, c) => (acc * 31) + c)); + var angle = (hash % 360) * (MathF.PI / 180f); + return new Vector3( + anchorPosition.X + (MathF.Cos(angle) * radius), + anchorPosition.Y, + anchorPosition.Z + (MathF.Sin(angle) * radius)); + } + + private static TradeRoutePlan? ResolveTradeRoute(SimulationWorld world, string itemId, string sourceStationId, string destinationStationId) + { + var source = ResolveStation(world, sourceStationId); + var destination = ResolveStation(world, destinationStationId); + return source is null || destination is null ? null : new TradeRoutePlan(source, destination, itemId, 0f, $"{itemId}: {source.Label} -> {destination.Label}"); + } + + private static StationRuntime? ResolveStation(SimulationWorld world, string? stationId) => + stationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == stationId); + + private static ResourceNodeRuntime? ResolveNode(SimulationWorld world, string? nodeId) => + nodeId is null ? null : world.Nodes.FirstOrDefault(candidate => candidate.Id == nodeId); + + private static PolicySetRuntime? ResolvePolicy(SimulationWorld world, string? policySetId) => + policySetId is null ? null : world.Policies.FirstOrDefault(policy => policy.Id == policySetId); + + private static bool IsSystemAllowed( + SimulationWorld world, + PolicySetRuntime? policy, + string factionId, + string systemId, + string accessKind) => + TryCheckSystemAllowed(world, policy, factionId, systemId, accessKind, out _); + + private static bool TryCheckSystemAllowed( + SimulationWorld world, + PolicySetRuntime? policy, + string factionId, + string systemId, + string accessKind, + out string? denialReason) + { + denialReason = null; + if (policy?.BlacklistedSystemIds.Contains(systemId) == true) + { + denialReason = $"blacklisted:{systemId}"; + return false; + } + + var controlState = GeopoliticalSimulationService.GetSystemControlState(world, systemId); + var authorityFactionId = controlState?.ControllerFactionId ?? controlState?.PrimaryClaimantFactionId; + if (authorityFactionId is null || string.Equals(authorityFactionId, factionId, StringComparison.Ordinal)) + { + return true; + } + + var hasAccess = string.Equals(accessKind, "trade", StringComparison.Ordinal) + ? GeopoliticalSimulationService.HasTradeAccess(world, factionId, authorityFactionId) + : GeopoliticalSimulationService.HasMilitaryAccess(world, factionId, authorityFactionId); + if (!hasAccess) + { + denialReason = $"{accessKind}-access-denied:{authorityFactionId}"; + return false; + } + + if (policy?.AvoidHostileSystems != true) + { + return true; + } + + if (GeopoliticalSimulationService.HasHostileRelation(world, factionId, authorityFactionId)) + { + denialReason = $"hostile-authority:{authorityFactionId}"; + return false; + } + + var hostileInfluencer = controlState?.InfluencingFactionIds.FirstOrDefault(candidate => + !string.Equals(candidate, factionId, StringComparison.Ordinal) + && GeopoliticalSimulationService.HasHostileRelation(world, factionId, candidate)); + if (hostileInfluencer is not null) + { + denialReason = $"hostile-influence:{hostileInfluencer}"; + return false; + } + + return true; + } + + private static CommanderAssignmentRuntime? ResolveAssignment(SimulationWorld world, ShipRuntime ship) => + ship.CommanderId is null + ? null + : world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment; + + private static ShipPlanStepRuntime? GetCurrentStep(ShipPlanRuntime? plan) => + plan is null || plan.CurrentStepIndex >= plan.Steps.Count ? null : plan.Steps[plan.CurrentStepIndex]; + + private static StationRuntime? ResolveSupportStation(SimulationWorld world, ShipRuntime ship, ConstructionSiteRuntime site) + { + return ResolveStation(world, ResolveAssignment(world, ship)?.HomeStationId ?? ship.DefaultBehavior.HomeStationId) + ?? world.Stations + .Where(station => station.FactionId == ship.FactionId) + .OrderByDescending(station => station.SystemId == site.SystemId ? 1 : 0) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static Vector3 ResolveSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world) + { + if (ship.DockedStationId is not null) + { + return GetShipDockedPosition(ship, station); + } + + if (site?.StationId is null && site is not null) + { + var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position; + return GetResourceHoldPosition(anchorPosition, ship.Id, 78f); + } + + return GetConstructionHoldPosition(station, ship.Id); + } + + private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) => + ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f); + + private static void TrackHistory(ShipRuntime ship) + { + var plan = ship.ActivePlan; + var step = GetCurrentStep(plan); + var subTask = step is null || step.CurrentSubTaskIndex >= step.SubTasks.Count ? null : step.SubTasks[step.CurrentSubTaskIndex]; + var signature = $"{ship.State.ToContractValue()}|{plan?.Kind ?? "none"}|{step?.Kind ?? "none"}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}"; + if (ship.LastSignature == signature) + { + return; + } + + ship.LastSignature = signature; + ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} plan={plan?.Kind ?? "none"} step={step?.Kind ?? "none"} subTask={subTask?.Kind ?? "none"} cargo={GetShipCargoAmount(ship):0.#}"); + if (ship.History.Count > 24) + { + ship.History.RemoveAt(0); + } + } + + private static void EmitStateEvents(ShipRuntime ship, ShipState previousState, string? previousPlanId, string? previousStepId, ICollection events) + { + var currentPlanId = ship.ActivePlan?.Id; + var currentStepId = GetCurrentStep(ship.ActivePlan)?.Id; + var occurredAtUtc = DateTimeOffset.UtcNow; + if (previousState != ship.State) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Name} state {previousState.ToContractValue()} -> {ship.State.ToContractValue()}.", occurredAtUtc)); + } + + if (!string.Equals(previousPlanId, currentPlanId, StringComparison.Ordinal)) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "plan-changed", $"{ship.Definition.Name} switched active plan.", occurredAtUtc)); + } + + if (!string.Equals(previousStepId, currentStepId, StringComparison.Ordinal)) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "step-changed", $"{ship.Definition.Name} advanced plan step.", occurredAtUtc)); + } + } + + private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site) + { + var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); + if (anchor is null || site.BlueprintId is null) + { + site.State = ConstructionSiteStateKinds.Destroyed; + return; + } + + var station = new StationRuntime + { + Id = $"station-{world.Stations.Count + 1}", + SystemId = site.SystemId, + Label = BuildFoundedStationLabel(site.TargetDefinitionId), + Category = "station", + Objective = DetermineFoundationObjective(site.TargetDefinitionId), + Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color, + Position = anchor.Position, + FactionId = site.FactionId, + CelestialId = site.CelestialId, + Health = 600f, + MaxHealth = 600f, + }; + + foreach (var moduleId in GetFoundationModules(world, site.BlueprintId)) + { + AddStationModule(world, station, moduleId); + } + + world.Stations.Add(station); + StationLifecycleService.EnsureStationCommander(world, station); + anchor.OccupyingStructureId = station.Id; + site.StationId = station.Id; + PrepareNextConstructionSiteStep(world, station, site); + } + + private static IReadOnlyList GetFoundationModules(SimulationWorld world, string primaryModuleId) + { + var modules = new List { "module_arg_dock_m_01_lowtech" }; + foreach (var itemId in world.ProductionGraph.OutputsByModuleId.GetValueOrDefault(primaryModuleId, [])) + { + if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + { + var storageModule = GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoKind); + if (storageModule is not null && !modules.Contains(storageModule, StringComparer.Ordinal)) + { + modules.Add(storageModule); + } + } + } + + if (!modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal)) + { + modules.Add("module_arg_stor_container_m_01"); + } + + if (!string.Equals(primaryModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal)) + { + modules.Add("module_gen_prod_energycells_01"); + } + + modules.Add(primaryModuleId); + return modules.Distinct(StringComparer.Ordinal).ToList(); + } + + private static string DetermineFoundationObjective(string commodityId) => + commodityId switch + { + "energycells" => "power", + "water" => "water", + "refinedmetals" => "refinery", + "hullparts" => "hullparts", + "claytronics" => "claytronics", + "shipyard" => "shipyard", + _ => "general", + }; + + private static string BuildFoundedStationLabel(string commodityId) => + $"{char.ToUpperInvariant(commodityId[0])}{commodityId[1..]} Foundry"; +} diff --git a/apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs b/apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs new file mode 100644 index 0000000..00653b7 --- /dev/null +++ b/apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs @@ -0,0 +1,319 @@ +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; +using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; + +namespace SpaceGame.Api.Ships.AI; + +public sealed partial class ShipAiService +{ + private ShipPlanRuntime BuildBehaviorFallbackPlan(SimulationWorld world, ShipRuntime ship) + { + var (behaviorKind, sourceId) = ResolveBehaviorSource(world, ship); + var failureReason = ship.LastAccessFailureReason; + if (string.Equals(behaviorKind, Idle, StringComparison.Ordinal)) + { + return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Idle"); + } + + if (IsBehaviorBlockingFailure(behaviorKind, failureReason)) + { + return CreateBlockedPlan( + ship, + AiPlanSourceKind.DefaultBehavior, + sourceId, + DescribeBehaviorFallbackSummary(world, ship, behaviorKind, failureReason), + failureReason!); + } + + return CreateIdlePlan( + ship, + AiPlanSourceKind.DefaultBehavior, + sourceId, + DescribeBehaviorFallbackSummary(world, ship, behaviorKind, failureReason)); + } + + private static bool IsBehaviorBlockingFailure(string behaviorKind, string? failureReason) => failureReason switch + { + "missing-item" => true, + "no-suitable-buyer" => true, + "no-mineable-node" when string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal) => true, + _ => false, + }; + + private static string DescribeBehaviorFallbackSummary(SimulationWorld world, ShipRuntime ship, string behaviorKind, string? failureReason) + { + var assignment = ResolveAssignment(world, ship); + var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + var itemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId ?? "resource"; + + return failureReason switch + { + "missing-item" => "No mining ware configured", + "no-suitable-buyer" => $"No buyer for {itemId} in {systemId}", + "no-mineable-node" when string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal) => $"No {itemId} to mine in {systemId}", + "no-mineable-node" => "No mineable node", + "no-home-station" => "No home station", + "no-trade-route" => "No trade route", + "no-fleet-to-supply" => "No fleet to supply", + "station-missing" => "No station to dock", + "target-ship-missing" => "No ship to follow", + "target-missing" => "No object target", + "no-salvage-target" => "No salvage target", + "no-repeat-orders" => "No repeat orders", + "no-construction-site" => "No construction site", + "support-station-missing" => "No support station", + _ => "Idle", + }; + } + + private ShipPlanRuntime BuildTradePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, TradeRoutePlan route, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + ShipOrderKinds.TradeRoute, + summary, + [ + CreateStep("step-acquire", "acquire-cargo", $"Load {route.ItemId} at {route.SourceStation.Label}", + [ + CreateSubTask("sub-acquire-travel", ShipTaskKinds.Travel, $"Travel to {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(route.SourceStation.Radius + 12f, 12f), 0f), + CreateSubTask("sub-acquire-dock", ShipTaskKinds.Dock, $"Dock at {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 4f, 0f), + CreateSubTask("sub-acquire-load", ShipTaskKinds.LoadCargo, $"Load {route.ItemId}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId), + CreateSubTask("sub-acquire-undock", ShipTaskKinds.Undock, $"Undock from {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(4f, 12f), 0f) + ]), + CreateStep("step-deliver", "deliver-cargo", $"Deliver {route.ItemId} to {route.DestinationStation.Label}", + [ + CreateSubTask("sub-route-travel", ShipTaskKinds.Travel, $"Travel to {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(route.DestinationStation.Radius + 12f, 12f), 0f), + CreateSubTask("sub-route-dock", ShipTaskKinds.Dock, $"Dock at {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 4f, 0f), + CreateSubTask("sub-route-unload", ShipTaskKinds.UnloadCargo, $"Unload {route.ItemId}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId), + CreateSubTask("sub-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f) + ]) + ]); + } + + private ShipPlanRuntime BuildFleetSupplyPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, FleetSupplyPlan plan) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + SupplyFleet, + plan.Summary, + [ + CreateStep("step-fleet-acquire", "acquire-cargo", $"Load {plan.ItemId} at {plan.SourceStation.Label}", + [ + CreateSubTask("sub-fleet-source-travel", ShipTaskKinds.Travel, $"Travel to {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, MathF.Max(plan.SourceStation.Radius + 12f, 12f), 0f), + CreateSubTask("sub-fleet-source-dock", ShipTaskKinds.Dock, $"Dock at {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 4f, 0f), + CreateSubTask("sub-fleet-source-load", ShipTaskKinds.LoadCargo, $"Load {plan.ItemId}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 0f, plan.Amount, itemId: plan.ItemId), + CreateSubTask("sub-fleet-source-undock", ShipTaskKinds.Undock, $"Undock from {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 12f, 0f), + ]), + CreateStep("step-fleet-deliver", "deliver-fleet", $"Deliver {plan.ItemId} to {plan.TargetShip.Definition.Name}", + [ + CreateSubTask("sub-fleet-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Name}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, 6f), + CreateSubTask("sub-fleet-transfer", ShipTaskKinds.TransferCargoToShip, $"Transfer {plan.ItemId}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, plan.Amount, itemId: plan.ItemId), + ]) + ]); + } + + private ShipPlanRuntime BuildConstructionPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ConstructionSiteRuntime site, StationRuntime supportStation, string summary) + { + var targetPosition = site.StationId is null ? supportStation.Position : supportStation.Position; + return CreatePlan( + ship, + sourceKind, + sourceId, + "construction-support", + summary, + [ + CreateStep("step-construction-deliver", "deliver-materials", $"Deliver materials to {site.Id}", + [ + CreateSubTask("sub-construction-travel", ShipTaskKinds.Travel, $"Travel to support station {supportStation.Label}", supportStation.SystemId, targetPosition, supportStation.Id, MathF.Max(supportStation.Radius + 18f, 18f), 0f), + CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f) + ]), + CreateStep("step-construction-build", "build-site", $"Build {site.Id}", + [ + CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f) + ]) + ]); + } + + private ShipPlanRuntime BuildAttackPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string? targetSystemId, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + ShipOrderKinds.AttackTarget, + summary, + [ + CreateStep("step-attack", ShipOrderKinds.AttackTarget, summary, + [ + CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? ship.SystemId, ship.Position, targetEntityId, 26f, 0f) + ]) + ]); + } + + private ShipPlanRuntime BuildDockAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime station, float waitSeconds, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + ShipOrderKinds.DockAndWait, + summary, + [ + CreateStep("step-dock-wait-travel", "travel", $"Travel to {station.Label}", + [ + CreateSubTask("sub-dock-wait-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(station.Radius + 12f, 12f), 0f), + CreateSubTask("sub-dock-wait-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f), + CreateSubTask("sub-dock-wait-hold", ShipTaskKinds.HoldPosition, $"Wait at {station.Label}", station.SystemId, station.Position, station.Id, 0f, waitSeconds), + ]) + ]); + } + + private ShipPlanRuntime BuildFlyAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, float waitSeconds, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + ShipOrderKinds.FlyAndWait, + summary, + [ + CreateStep("step-fly-wait", ShipOrderKinds.FlyAndWait, summary, + [ + CreateSubTask("sub-fly-wait-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, null, 6f, 0f), + CreateSubTask("sub-fly-wait-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, null, 0f, waitSeconds), + ]) + ]); + } + + private ShipPlanRuntime BuildFlyToObjectPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + ShipOrderKinds.FlyToObject, + summary, + [ + CreateStep("step-fly-object", ShipOrderKinds.FlyToObject, summary, + [ + CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f), + CreateSubTask("sub-fly-object-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, targetEntityId, 0f, MathF.Max(1f, ship.DefaultBehavior.WaitSeconds)), + ]) + ]); + } + + private ShipPlanRuntime BuildFollowShipPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ShipRuntime targetShip, float radius, float durationSeconds, string summary) + { + return BuildFollowPlan(ship, sourceKind, sourceId, targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary); + } + + private ShipPlanRuntime BuildFollowPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + ShipOrderKinds.FollowShip, + summary, + [ + CreateStep("step-follow", "follow-target", summary, + [ + CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds), + ]) + ]); + } + + private ShipPlanRuntime CreateIdlePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + Idle, + summary, + [ + CreateStep("step-idle", ShipOrderKinds.HoldPosition, summary, + [ + CreateSubTask("sub-idle", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 1.5f) + ]) + ]); + } + + private ShipPlanRuntime CreateBlockedPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary, string blockingReason) + { + var subTask = CreateSubTask("sub-blocked", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 0f); + subTask.Status = WorkStatus.Blocked; + subTask.BlockingReason = blockingReason; + + var step = CreateStep("step-blocked", "blocked", summary, [subTask]); + step.Status = AiPlanStepStatus.Blocked; + step.BlockingReason = blockingReason; + + var plan = CreatePlan(ship, sourceKind, sourceId, "blocked", summary, [step]); + plan.Status = AiPlanStatus.Blocked; + plan.FailureReason = blockingReason; + return plan; + } + + private static ShipPlanRuntime CreatePlan( + ShipRuntime ship, + AiPlanSourceKind sourceKind, + string sourceId, + string kind, + string summary, + IReadOnlyList steps) + { + var plan = new ShipPlanRuntime + { + Id = $"plan-{ship.Id}-{Guid.NewGuid():N}", + SourceKind = sourceKind, + SourceId = sourceId, + Kind = kind, + Summary = summary, + }; + plan.Steps.AddRange(steps); + return plan; + } + + private static ShipPlanStepRuntime CreateStep(string id, string kind, string summary, IReadOnlyList subTasks) + { + var step = new ShipPlanStepRuntime + { + Id = id, + Kind = kind, + Summary = summary, + }; + step.SubTasks.AddRange(subTasks); + return step; + } + + private static ShipSubTaskRuntime CreateSubTask( + string id, + string kind, + string summary, + string targetSystemId, + Vector3 targetPosition, + string? targetEntityId, + float threshold, + float amount, + string? itemId = null, + string? moduleId = null, + string? targetNodeId = null) => + new() + { + Id = id, + Kind = kind, + Summary = summary, + TargetSystemId = targetSystemId, + TargetPosition = targetPosition, + TargetEntityId = targetEntityId, + TargetNodeId = targetNodeId, + ItemId = itemId, + ModuleId = moduleId, + Threshold = threshold, + Amount = amount, + }; +} diff --git a/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs b/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs new file mode 100644 index 0000000..c5fb2fe --- /dev/null +++ b/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs @@ -0,0 +1,461 @@ +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; +using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; +using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; +using static SpaceGame.Api.Stations.Simulation.StationSimulationService; + +namespace SpaceGame.Api.Ships.AI; + +public sealed partial class ShipAiService +{ + private ShipPlanRuntime? BuildEmergencyPlan(SimulationWorld world, ShipRuntime ship) + { + var policy = ResolvePolicy(world, ship.PolicySetId); + if (policy is null) + { + return null; + } + + var hullRatio = ship.Definition.Hull <= 0.01f ? 1f : ship.Health / ship.Definition.Hull; + if (hullRatio > policy.FleeHullRatio) + { + return null; + } + + var hostileNearby = world.Ships.Any(candidate => + candidate.Health > 0f && + candidate.FactionId != ship.FactionId && + candidate.SystemId == ship.SystemId && + candidate.Position.DistanceTo(ship.Position) <= 200f); + if (!hostileNearby) + { + return null; + } + + var safeStation = world.Stations + .Where(station => station.FactionId == ship.FactionId) + .OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0) + .ThenBy(station => station.Position.DistanceTo(ship.Position)) + .FirstOrDefault(); + + var plan = new ShipPlanRuntime + { + Id = $"plan-{ship.Id}-safety-{Guid.NewGuid():N}", + SourceKind = AiPlanSourceKind.Rule, + SourceId = ShipOrderKinds.Flee, + Kind = "safety-flee", + Summary = "Emergency retreat", + }; + + if (safeStation is null) + { + plan.Steps.Add(CreateStep("step-flee-hold", ShipOrderKinds.HoldPosition, "Hold position away from hostiles", + [ + CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f) + ])); + return plan; + } + + plan.Steps.Add(CreateStep("step-flee-travel", "travel", "Travel to safe station", + [ + CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), 0f) + ])); + plan.Steps.Add(CreateStep("step-flee-dock", "dock", "Dock at safe station", + [ + CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f) + ])); + return plan; + } + + private ShipPlanRuntime? BuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + return order.Kind switch + { + var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMovePlan(ship, order), + var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.DockAndWait, StringComparison.Ordinal) => BuildDockAndWaitOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.FlyAndWait, StringComparison.Ordinal) => BuildFlyAndWaitOrderPlan(ship, order), + var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.MineLocal, StringComparison.Ordinal) => BuildMineLocalOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliverRun, StringComparison.Ordinal) => BuildMineAndDeliverRunOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.SellMinedCargo, StringComparison.Ordinal) => BuildSellMinedCargoOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.SupplyFleetRun, StringComparison.Ordinal) => BuildSupplyFleetOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.SalvageRun, StringComparison.Ordinal) => BuildAutoSalvageOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldOrderPlan(ship, order), + _ => null, + }; + } + + private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship) + { + var assignment = ResolveAssignment(world, ship); + return assignment is null + ? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind) + : (assignment.BehaviorKind, assignment.ObjectiveId); + } + + private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order) + { + var targetSystemId = order.TargetSystemId ?? ship.SystemId; + var targetPosition = order.TargetPosition ?? ship.Position; + return CreatePlan( + ship, + AiPlanSourceKind.Order, + order.Id, + ShipOrderKinds.Move, + order.Label ?? "Move order", + [ + CreateStep("step-move", "travel", order.Label ?? "Travel", + [ + CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, 0f, 0f) + ]) + ]); + } + + private ShipPlanRuntime? BuildDockOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId); + if (station is null) + { + order.FailureReason = "station-missing"; + return null; + } + + return CreatePlan( + ship, + AiPlanSourceKind.Order, + order.Id, + "dock-at-station", + order.Label ?? $"Dock at {station.Label}", + [ + CreateStep("step-dock-travel", "travel", $"Travel to {station.Label}", + [ + CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(balance.ArrivalThreshold, station.Radius + 12f), 0f) + ]), + CreateStep("step-dock", "dock", $"Dock at {station.Label}", + [ + CreateSubTask("sub-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f) + ]) + ]); + } + + private ShipPlanRuntime? BuildTradeOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + if (order.SourceStationId is null || order.DestinationStationId is null || order.ItemId is null) + { + order.FailureReason = "trade-order-incomplete"; + return null; + } + + var route = ResolveTradeRoute(world, order.ItemId, order.SourceStationId, order.DestinationStationId); + if (route is null) + { + order.FailureReason = "trade-route-missing"; + return null; + } + + return BuildTradePlan(ship, AiPlanSourceKind.Order, order.Id, route, order.Label ?? route.Summary); + } + + private ShipPlanRuntime? BuildMineOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var systemId = order.TargetSystemId ?? ship.SystemId; + var itemId = order.ItemId; + if (string.IsNullOrWhiteSpace(itemId)) + { + order.FailureReason = "mine-order-item-missing"; + return null; + } + + var node = ResolveNode(world, order.NodeId); + if (node is not null) + { + if (!string.Equals(node.SystemId, systemId, StringComparison.Ordinal)) + { + order.FailureReason = "mine-order-node-system-mismatch"; + return null; + } + + if (!string.Equals(node.ItemId, itemId, StringComparison.Ordinal)) + { + order.FailureReason = "mine-order-node-item-mismatch"; + return null; + } + } + else + { + node = SelectLocalMiningNode(world, ship, systemId, itemId); + } + + if (node is null) + { + order.FailureReason = "mine-order-node-missing"; + return null; + } + + return BuildLocalMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {itemId} in {systemId}"); + } + + private ShipPlanRuntime? BuildMineLocalOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var node = ResolveNode(world, order.NodeId); + if (node is null) + { + order.FailureReason = "mine-order-incomplete"; + return null; + } + + return BuildLocalMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {node.ItemId}"); + } + + private ShipPlanRuntime? BuildMineAndDeliverRunOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var node = ResolveNode(world, order.NodeId); + var buyer = ResolveStation(world, order.DestinationStationId); + if (node is null || buyer is null) + { + order.FailureReason = "mine-and-deliver-order-incomplete"; + return null; + } + + return BuildMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, buyer, order.Label ?? $"Mine {node.ItemId} for {buyer.Label}"); + } + + private ShipPlanRuntime? BuildSellMinedCargoOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var buyer = ResolveStation(world, order.DestinationStationId ?? order.TargetEntityId); + if (buyer is null || string.IsNullOrWhiteSpace(order.ItemId)) + { + order.FailureReason = "sell-order-incomplete"; + return null; + } + + return BuildLocalMiningDeliveryPlan(ship, AiPlanSourceKind.Order, order.Id, buyer, order.ItemId, order.Label ?? $"Sell {order.ItemId}"); + } + + private ShipPlanRuntime? BuildAutoSalvageOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var homeStation = ResolveStation(world, order.SourceStationId ?? ship.DefaultBehavior.HomeStationId); + var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.RemainingAmount > 0.01f); + if (homeStation is null || wreck is null) + { + order.FailureReason = "salvage-order-incomplete"; + return null; + } + + var approach = GetFormationPosition(wreck.Position, ship.Id, MathF.Max(8f, order.Radius > 0f ? order.Radius : ship.DefaultBehavior.Radius * 0.25f)); + return CreatePlan( + ship, + AiPlanSourceKind.Order, + order.Id, + AutoSalvage, + order.Label ?? $"Salvage {wreck.ItemId}", + [ + CreateStep("step-salvage-collect", "salvage", $"Salvage {wreck.ItemId}", + [ + CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {wreck.Id}", wreck.SystemId, approach, wreck.Id, 8f, 0f), + CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {wreck.ItemId}", wreck.SystemId, approach, wreck.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId), + ]), + CreateStep("step-salvage-deliver", "deliver-salvage", $"Deliver salvage to {homeStation.Label}", + [ + CreateSubTask("sub-salvage-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f), + CreateSubTask("sub-salvage-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f), + CreateSubTask("sub-salvage-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload salvage at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId), + CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f), + ]) + ]); + } + + private ShipPlanRuntime? BuildSupplyFleetOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var sourceStation = ResolveStation(world, order.SourceStationId); + var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f); + if (sourceStation is null || targetShip is null || string.IsNullOrWhiteSpace(order.ItemId)) + { + order.FailureReason = "supply-fleet-order-incomplete"; + return null; + } + + var amount = MathF.Min( + MathF.Max(10f, ship.Definition.GetTotalCargoCapacity() * 0.5f), + GetInventoryAmount(sourceStation.Inventory, order.ItemId)); + if (amount <= 0.01f) + { + order.FailureReason = "supply-item-unavailable"; + return null; + } + + var plan = new FleetSupplyPlan( + sourceStation, + targetShip, + order.ItemId, + amount, + MathF.Max(16f, order.Radius), + order.Label ?? $"Supply {targetShip.Definition.Name} with {order.ItemId}"); + return BuildFleetSupplyPlan(ship, AiPlanSourceKind.Order, order.Id, plan); + } + + private ShipPlanRuntime? BuildBuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (order.ConstructionSiteId ?? order.TargetEntityId)); + if (site is null) + { + order.FailureReason = "construction-site-missing"; + return null; + } + + var supportStation = ResolveSupportStation(world, ship, site); + if (supportStation is null) + { + order.FailureReason = "support-station-missing"; + return null; + } + + return BuildConstructionPlan(ship, AiPlanSourceKind.Order, order.Id, site, supportStation, order.Label ?? $"Build {site.BlueprintId}"); + } + + private ShipPlanRuntime? BuildAttackOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var targetId = order.TargetEntityId; + if (targetId is null) + { + order.FailureReason = "attack-target-missing"; + return null; + } + + return BuildAttackPlan(ship, AiPlanSourceKind.Order, order.Id, targetId, order.TargetSystemId, order.Label ?? "Attack target"); + } + + private ShipPlanRuntime BuildHoldOrderPlan(ShipRuntime ship, ShipOrderRuntime order) + { + return CreatePlan( + ship, + AiPlanSourceKind.Order, + order.Id, + ShipOrderKinds.HoldPosition, + order.Label ?? "Hold position", + [ + CreateStep("step-hold", ShipOrderKinds.HoldPosition, order.Label ?? "Hold position", + [ + CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f) + ]) + ]); + } + + private ShipPlanRuntime? BuildDockAndWaitOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId); + if (station is null) + { + order.FailureReason = "station-missing"; + return null; + } + + return BuildDockAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, station, MathF.Max(1f, order.WaitSeconds), order.Label ?? $"Dock and wait at {station.Label}"); + } + + private ShipPlanRuntime BuildFlyAndWaitOrderPlan(ShipRuntime ship, ShipOrderRuntime order) + { + var systemId = order.TargetSystemId ?? ship.SystemId; + var targetPosition = order.TargetPosition ?? ship.Position; + return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, systemId, targetPosition, MathF.Max(1f, order.WaitSeconds), order.Label ?? "Fly and wait"); + } + + private ShipPlanRuntime? BuildFlyToObjectOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var targetEntityId = order.TargetEntityId; + if (targetEntityId is null) + { + order.FailureReason = "target-missing"; + return null; + } + + var objectTarget = ResolveObjectTarget(world, targetEntityId); + if (objectTarget is null) + { + order.FailureReason = "target-missing"; + return null; + } + + return BuildFlyToObjectPlan(ship, AiPlanSourceKind.Order, order.Id, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}"); + } + + private ShipPlanRuntime? BuildFollowShipOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f); + if (targetShip is null) + { + order.FailureReason = "target-ship-missing"; + return null; + } + + return BuildFollowShipPlan(ship, AiPlanSourceKind.Order, order.Id, targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Name}"); + } + + private ShipPlanRuntime BuildMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, StationRuntime homeStation, string summary) + { + var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f); + return CreatePlan( + ship, + sourceKind, + sourceId, + ShipOrderKinds.MineAndDeliver, + summary, + [ + CreateStep("step-mine", "mine", $"Mine {node.ItemId}", + [ + CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} node", node.SystemId, extractionPosition, node.Id, 8f, 0f), + CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity()) + ]), + CreateStep("step-deliver", "deliver", $"Deliver {node.ItemId} to {homeStation.Label}", + [ + CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f), + CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f), + CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity()), + CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f) + ]) + ]); + } + + private ShipPlanRuntime BuildLocalMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, string summary) + { + var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f); + return CreatePlan( + ship, + sourceKind, + sourceId, + ShipOrderKinds.MineLocal, + summary, + [ + CreateStep("step-mine", "mine", $"Mine {node.ItemId}", + [ + CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} node", node.SystemId, extractionPosition, node.Id, 8f, 0f), + CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId) + ]) + ]); + } + + private ShipPlanRuntime BuildLocalMiningDeliveryPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime buyer, string itemId, string summary) + { + var amount = MathF.Max(1f, GetInventoryAmount(ship.Inventory, itemId)); + return CreatePlan( + ship, + sourceKind, + sourceId, + ShipOrderKinds.SellMinedCargo, + summary, + [ + CreateStep("step-deliver", "deliver", $"Deliver {itemId} to {buyer.Label}", + [ + CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(buyer.Radius + 12f, 12f), 0f), + CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 4f, 0f), + CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload {itemId} at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 0f, amount, itemId: itemId), + CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(4f, 12f), 0f) + ]) + ]); + } +} diff --git a/apps/backend/Ships/AI/ShipAiService.cs b/apps/backend/Ships/AI/ShipAiService.cs new file mode 100644 index 0000000..4df4d3e --- /dev/null +++ b/apps/backend/Ships/AI/ShipAiService.cs @@ -0,0 +1,216 @@ +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; +using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; + +namespace SpaceGame.Api.Ships.AI; + +public sealed partial class ShipAiService +{ + private const float WarpEngageDistanceKilometers = 250_000f; + private const float FrigateDps = 7f; + private const float DestroyerDps = 12f; + private const float CruiserDps = 18f; + private const float CapitalDps = 26f; + + private readonly IBalanceService balance; + + public ShipAiService(IBalanceService balance) + { + this.balance = balance; + } + + internal void UpdateShip(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection events) + { + if (ship.ReplanCooldownSeconds > 0f) + { + ship.ReplanCooldownSeconds = MathF.Max(0f, ship.ReplanCooldownSeconds - deltaSeconds); + } + + var previousState = ship.State; + var previousPlanId = ship.ActivePlan?.Id; + var previousStepId = GetCurrentStep(ship.ActivePlan)?.Id; + + EnsurePlan(world, ship, events); + ExecutePlan(world, ship, deltaSeconds, events); + TrackHistory(ship); + EmitStateEvents(ship, previousState, previousPlanId, previousStepId, events); + } + + private void EnsurePlan(SimulationWorld world, ShipRuntime ship, ICollection events) + { + var emergencyPlan = BuildEmergencyPlan(world, ship); + if (emergencyPlan is not null) + { + ship.LastReplanReason = "rule-safety"; + ReplacePlan(ship, emergencyPlan, "rule-safety", events); + return; + } + + SyncBehaviorOrders(world, ship); + var topOrder = GetTopOrder(ship); + if (topOrder is not null && topOrder.Status == OrderStatus.Queued) + { + topOrder.Status = OrderStatus.Active; + } + + var desiredSourceKind = topOrder is null ? AiPlanSourceKind.DefaultBehavior : AiPlanSourceKind.Order; + var desiredSourceId = topOrder?.Id ?? ResolveBehaviorSource(world, ship).SourceId; + var currentPlan = ship.ActivePlan; + + if (currentPlan is not null + && currentPlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed and not AiPlanStatus.Interrupted + && currentPlan.SourceKind == desiredSourceKind + && string.Equals(currentPlan.SourceId, desiredSourceId, StringComparison.Ordinal) + && !ship.NeedsReplan) + { + return; + } + + if (ship.ReplanCooldownSeconds > 0f && currentPlan is null) + { + return; + } + + ShipPlanRuntime? nextPlan = desiredSourceKind == AiPlanSourceKind.Order + ? BuildOrderPlan(world, ship, topOrder!) + : BuildBehaviorFallbackPlan(world, ship); + + if (nextPlan is null) + { + nextPlan = CreateIdlePlan(ship, desiredSourceKind, desiredSourceId, "No viable plan"); + } + + if (nextPlan.Kind != Idle) + { + ship.LastAccessFailureReason = null; + } + + ReplacePlan(ship, nextPlan, "replanned", events); + } + + private void ExecutePlan(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection events) + { + var plan = ship.ActivePlan; + if (plan is null) + { + ship.State = ShipState.Idle; + ship.TargetPosition = ship.Position; + return; + } + + if (plan.CurrentStepIndex >= plan.Steps.Count) + { + CompletePlan(ship, plan, events); + return; + } + + plan.UpdatedAtUtc = DateTimeOffset.UtcNow; + + var step = plan.Steps[plan.CurrentStepIndex]; + if (step.Status == AiPlanStepStatus.Planned) + { + step.Status = AiPlanStepStatus.Running; + } + + if (step.CurrentSubTaskIndex >= step.SubTasks.Count) + { + CompleteStep(plan, step); + return; + } + + var subTask = step.SubTasks[step.CurrentSubTaskIndex]; + if (subTask.Status == WorkStatus.Pending) + { + subTask.Status = WorkStatus.Active; + } + else if (subTask.Status == WorkStatus.Blocked) + { + step.Status = AiPlanStepStatus.Blocked; + step.BlockingReason = subTask.BlockingReason; + plan.Status = AiPlanStatus.Blocked; + ship.State = ShipState.Blocked; + ship.TargetPosition = subTask.TargetPosition ?? ship.Position; + return; + } + + plan.Status = AiPlanStatus.Running; + + var outcome = UpdateSubTask(world, ship, step, subTask, deltaSeconds); + switch (outcome) + { + case SubTaskOutcome.Active: + step.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStepStatus.Blocked : AiPlanStepStatus.Running; + plan.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStatus.Blocked : AiPlanStatus.Running; + return; + case SubTaskOutcome.Completed: + subTask.Status = WorkStatus.Completed; + subTask.Progress = 1f; + step.CurrentSubTaskIndex += 1; + step.BlockingReason = null; + if (step.CurrentSubTaskIndex >= step.SubTasks.Count) + { + CompleteStep(plan, step); + } + + return; + case SubTaskOutcome.Failed: + subTask.Status = WorkStatus.Failed; + step.Status = AiPlanStepStatus.Failed; + plan.Status = AiPlanStatus.Failed; + plan.FailureReason = subTask.BlockingReason ?? "subtask-failed"; + ship.NeedsReplan = true; + ship.ReplanCooldownSeconds = 0.5f; + ship.LastReplanReason = plan.FailureReason; + return; + } + } + + private static void CompleteStep(ShipPlanRuntime plan, ShipPlanStepRuntime step) + { + step.Status = AiPlanStepStatus.Completed; + step.BlockingReason = null; + plan.CurrentStepIndex += 1; + if (plan.CurrentStepIndex >= plan.Steps.Count) + { + plan.Status = AiPlanStatus.Completed; + } + } + + private static void CompletePlan(ShipRuntime ship, ShipPlanRuntime plan, ICollection events) + { + plan.Status = AiPlanStatus.Completed; + var completedOrder = plan.SourceKind == AiPlanSourceKind.Order + ? ship.OrderQueue.FirstOrDefault(order => order.Id == plan.SourceId) + : null; + if (completedOrder is not null) + { + completedOrder.Status = OrderStatus.Completed; + ship.OrderQueue.RemoveAll(order => order.Id == completedOrder.Id); + if (completedOrder.SourceKind == ShipOrderSourceKind.Behavior + && string.Equals(completedOrder.SourceId, RepeatOrders, StringComparison.Ordinal) + && ship.DefaultBehavior.RepeatOrders.Count > 0) + { + ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count; + } + } + ship.ActivePlan = null; + ship.NeedsReplan = true; + ship.ReplanCooldownSeconds = 0.25f; + ship.LastReplanReason = "plan-completed"; + events.Add(new SimulationEventRecord("ship", ship.Id, "plan-completed", $"{ship.Definition.Name} completed {plan.Kind}.", DateTimeOffset.UtcNow)); + } + + private void ReplacePlan(ShipRuntime ship, ShipPlanRuntime nextPlan, string reason, ICollection events) + { + if (ship.ActivePlan is not null && ship.ActivePlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed) + { + ship.ActivePlan.Status = AiPlanStatus.Interrupted; + ship.ActivePlan.InterruptReason = reason; + } + + ship.ActivePlan = nextPlan; + ship.NeedsReplan = false; + ship.ReplanCooldownSeconds = 0f; + ship.LastReplanReason = reason; + events.Add(new SimulationEventRecord("ship", ship.Id, "plan-updated", $"{ship.Definition.Name} planned {nextPlan.Kind}.", DateTimeOffset.UtcNow)); + } +} diff --git a/apps/backend/Ships/AI/ShipBootstrapPolicy.cs b/apps/backend/Ships/AI/ShipBootstrapPolicy.cs new file mode 100644 index 0000000..3c528e7 --- /dev/null +++ b/apps/backend/Ships/AI/ShipBootstrapPolicy.cs @@ -0,0 +1,31 @@ +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; + +namespace SpaceGame.Api.Ships.AI; + +internal static class ShipBootstrapPolicy +{ + internal static ShipSkillProfileRuntime CreateSkills(ShipDefinition definition) + { + if (IsTransportShip(definition)) + { + return new ShipSkillProfileRuntime { Navigation = 3, Trade = 4, Mining = 1, Combat = 1, Construction = 1 }; + } + + if (IsConstructionShip(definition)) + { + return new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 1, Combat = 1, Construction = 4 }; + } + + if (IsMilitaryShip(definition)) + { + return new ShipSkillProfileRuntime { Navigation = 4, Trade = 1, Mining = 1, Combat = 4, Construction = 1 }; + } + + if (IsMiningShip(definition)) + { + return new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 4, Combat = 1, Construction = 1 }; + } + + return new ShipSkillProfileRuntime { Navigation = 3, Trade = 2, Mining = 1, Combat = 1, Construction = 1 }; + } +} diff --git a/apps/backend/Ships/Api/EnqueueShipOrderHandler.cs b/apps/backend/Ships/Api/EnqueueShipOrderHandler.cs index 9a4fac3..ddb3144 100644 --- a/apps/backend/Ships/Api/EnqueueShipOrderHandler.cs +++ b/apps/backend/Ships/Api/EnqueueShipOrderHandler.cs @@ -7,7 +7,6 @@ public sealed class EnqueueShipOrderHandler(WorldService worldService) : Endpoin public override void Configure() { Post("/api/ships/{shipId}/orders"); - AllowAnonymous(); } public override async Task HandleAsync(ShipOrderCommandRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/Ships/Api/GetShipAutomationCatalogHandler.cs b/apps/backend/Ships/Api/GetShipAutomationCatalogHandler.cs new file mode 100644 index 0000000..a3aaf1c --- /dev/null +++ b/apps/backend/Ships/Api/GetShipAutomationCatalogHandler.cs @@ -0,0 +1,35 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Ships.Api; + +public sealed class GetShipAutomationCatalogHandler : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/api/ships/catalog"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var snapshot = new ShipAutomationCatalogSnapshot( + ShipAutomationCatalog.Behaviors + .Select(definition => new ShipBehaviorDefinitionSnapshot( + definition.Id, + definition.Label, + definition.Category, + definition.SupportStatus.ToString(), + definition.Notes)) + .ToList(), + ShipAutomationCatalog.Orders + .Select(definition => new ShipOrderDefinitionSnapshot( + definition.Id, + definition.Label, + definition.Category, + definition.SupportStatus.ToString(), + definition.Notes)) + .ToList()); + + await SendOkAsync(snapshot, cancellationToken); + } +} diff --git a/apps/backend/Ships/Api/RemoveShipOrderHandler.cs b/apps/backend/Ships/Api/RemoveShipOrderHandler.cs index bc3b77d..bb6d750 100644 --- a/apps/backend/Ships/Api/RemoveShipOrderHandler.cs +++ b/apps/backend/Ships/Api/RemoveShipOrderHandler.cs @@ -13,7 +13,6 @@ public sealed class RemoveShipOrderHandler(WorldService worldService) : Endpoint public override void Configure() { Delete("/api/ships/{shipId}/orders/{orderId}"); - AllowAnonymous(); } public override async Task HandleAsync(RemoveShipOrderRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs b/apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs index d77fe72..9a57be6 100644 --- a/apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs +++ b/apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs @@ -7,7 +7,6 @@ public sealed class UpdateShipDefaultBehaviorHandler(WorldService worldService) public override void Configure() { Put("/api/ships/{shipId}/default-behavior"); - AllowAnonymous(); } public override async Task HandleAsync(ShipDefaultBehaviorCommandRequest request, CancellationToken cancellationToken) diff --git a/apps/backend/Ships/Contracts/ShipAutomation.cs b/apps/backend/Ships/Contracts/ShipAutomation.cs new file mode 100644 index 0000000..42caaf0 --- /dev/null +++ b/apps/backend/Ships/Contracts/ShipAutomation.cs @@ -0,0 +1,19 @@ +namespace SpaceGame.Api.Ships.Contracts; + +public sealed record ShipBehaviorDefinitionSnapshot( + string Id, + string Label, + string Category, + string SupportStatus, + string Notes); + +public sealed record ShipOrderDefinitionSnapshot( + string Id, + string Label, + string Category, + string SupportStatus, + string Notes); + +public sealed record ShipAutomationCatalogSnapshot( + IReadOnlyList Behaviors, + IReadOnlyList Orders); diff --git a/apps/backend/Ships/Contracts/ShipCommands.cs b/apps/backend/Ships/Contracts/ShipCommands.cs index a7a0ef5..4972275 100644 --- a/apps/backend/Ships/Contracts/ShipCommands.cs +++ b/apps/backend/Ships/Contracts/ShipCommands.cs @@ -42,7 +42,7 @@ public sealed record ShipDefaultBehaviorCommandRequest( string? HomeStationId, string? AreaSystemId, string? TargetEntityId, - string? PreferredItemId, + string? ItemId, string? PreferredNodeId, string? PreferredConstructionSiteId, string? PreferredModuleId, diff --git a/apps/backend/Ships/Contracts/Ships.cs b/apps/backend/Ships/Contracts/Ships.cs index a7e245a..fc61257 100644 --- a/apps/backend/Ships/Contracts/Ships.cs +++ b/apps/backend/Ships/Contracts/Ships.cs @@ -10,6 +10,8 @@ public sealed record ShipSkillProfileSnapshot( public sealed record ShipOrderSnapshot( string Id, string Kind, + string SourceKind, + string SourceId, string Status, int Priority, bool InterruptCurrentPlan, @@ -53,7 +55,7 @@ public sealed record DefaultBehaviorSnapshot( string? HomeStationId, string? AreaSystemId, string? TargetEntityId, - string? PreferredItemId, + string? ItemId, string? PreferredNodeId, string? PreferredConstructionSiteId, string? PreferredModuleId, @@ -129,9 +131,9 @@ public sealed record ShipPlanSnapshot( public sealed record ShipSnapshot( string Id, - string Label, - string Kind, - string Class, + string Name, + string Purpose, + string Type, string SystemId, Vector3Dto LocalPosition, Vector3Dto LocalVelocity, @@ -164,9 +166,9 @@ public sealed record ShipSnapshot( public sealed record ShipDelta( string Id, - string Label, - string Kind, - string Class, + string Name, + string Purpose, + string Type, string SystemId, Vector3Dto LocalPosition, Vector3Dto LocalVelocity, diff --git a/apps/backend/Ships/Runtime/ShipRuntimeModels.cs b/apps/backend/Ships/Runtime/ShipRuntimeModels.cs index 1ae78c5..f4c447c 100644 --- a/apps/backend/Ships/Runtime/ShipRuntimeModels.cs +++ b/apps/backend/Ships/Runtime/ShipRuntimeModels.cs @@ -47,6 +47,8 @@ public sealed class ShipOrderRuntime { public required string Id { get; init; } public required string Kind { get; init; } + public required ShipOrderSourceKind SourceKind { get; init; } + public required string SourceId { get; init; } public OrderStatus Status { get; set; } = OrderStatus.Queued; public int Priority { get; set; } public bool InterruptCurrentPlan { get; set; } = true; @@ -75,7 +77,7 @@ public sealed class DefaultBehaviorRuntime public string? HomeStationId { get; set; } public string? AreaSystemId { get; set; } public string? TargetEntityId { get; set; } - public string? PreferredItemId { get; set; } + public string? ItemId { get; set; } public string? PreferredNodeId { get; set; } public string? PreferredConstructionSiteId { get; set; } public string? PreferredModuleId { get; set; } diff --git a/apps/backend/Ships/Simulation/ShipAiService.cs b/apps/backend/Ships/Simulation/ShipAiService.cs deleted file mode 100644 index 1b6ab52..0000000 --- a/apps/backend/Ships/Simulation/ShipAiService.cs +++ /dev/null @@ -1,2705 +0,0 @@ -using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; -using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; -using static SpaceGame.Api.Stations.Simulation.StationSimulationService; - -namespace SpaceGame.Api.Ships.Simulation; - -public sealed class ShipAiService( - IBalanceService balance) -{ - private const float WarpEngageDistanceKilometers = 250_000f; - private const float FrigateDps = 7f; - private const float DestroyerDps = 12f; - private const float CruiserDps = 18f; - private const float CapitalDps = 26f; - - internal void UpdateShip(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection events) - { - if (ship.ReplanCooldownSeconds > 0f) - { - ship.ReplanCooldownSeconds = MathF.Max(0f, ship.ReplanCooldownSeconds - deltaSeconds); - } - - var previousState = ship.State; - var previousPlanId = ship.ActivePlan?.Id; - var previousStepId = GetCurrentStep(ship.ActivePlan)?.Id; - - EnsurePlan(world, ship, events); - ExecutePlan(world, ship, deltaSeconds, events); - TrackHistory(ship); - EmitStateEvents(ship, previousState, previousPlanId, previousStepId, events); - } - - private void EnsurePlan(SimulationWorld world, ShipRuntime ship, ICollection events) - { - var emergencyPlan = BuildEmergencyPlan(world, ship); - if (emergencyPlan is not null) - { - ship.LastReplanReason = "rule-safety"; - ReplacePlan(ship, emergencyPlan, "rule-safety", events); - return; - } - - var topOrder = GetTopOrder(ship); - if (topOrder is not null && topOrder.Status == OrderStatus.Queued) - { - topOrder.Status = OrderStatus.Active; - } - - var desiredSourceKind = topOrder is null ? AiPlanSourceKind.DefaultBehavior : AiPlanSourceKind.Order; - var desiredSourceId = topOrder?.Id ?? ResolveBehaviorSource(world, ship).SourceId; - var currentPlan = ship.ActivePlan; - - if (currentPlan is not null - && currentPlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed and not AiPlanStatus.Interrupted - && currentPlan.SourceKind == desiredSourceKind - && string.Equals(currentPlan.SourceId, desiredSourceId, StringComparison.Ordinal) - && !ship.NeedsReplan) - { - return; - } - - if (ship.ReplanCooldownSeconds > 0f && currentPlan is null) - { - return; - } - - ShipPlanRuntime? nextPlan = desiredSourceKind == AiPlanSourceKind.Order - ? BuildOrderPlan(world, ship, topOrder!) - : BuildBehaviorPlan(world, ship); - - if (nextPlan is null) - { - nextPlan = CreateIdlePlan(ship, desiredSourceKind, desiredSourceId, "No viable plan"); - } - - if (nextPlan.Kind != "idle") - { - ship.LastAccessFailureReason = null; - } - - ReplacePlan(ship, nextPlan, "replanned", events); - } - - private void ExecutePlan(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection events) - { - var plan = ship.ActivePlan; - if (plan is null) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return; - } - - if (plan.CurrentStepIndex >= plan.Steps.Count) - { - CompletePlan(ship, plan, events); - return; - } - - plan.Status = AiPlanStatus.Running; - plan.UpdatedAtUtc = DateTimeOffset.UtcNow; - - var step = plan.Steps[plan.CurrentStepIndex]; - if (step.Status == AiPlanStepStatus.Planned) - { - step.Status = AiPlanStepStatus.Running; - } - - if (step.CurrentSubTaskIndex >= step.SubTasks.Count) - { - CompleteStep(plan, step); - return; - } - - var subTask = step.SubTasks[step.CurrentSubTaskIndex]; - if (subTask.Status == WorkStatus.Pending) - { - subTask.Status = WorkStatus.Active; - } - - var outcome = UpdateSubTask(world, ship, step, subTask, deltaSeconds); - switch (outcome) - { - case SubTaskOutcome.Active: - step.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStepStatus.Blocked : AiPlanStepStatus.Running; - plan.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStatus.Blocked : AiPlanStatus.Running; - return; - case SubTaskOutcome.Completed: - subTask.Status = WorkStatus.Completed; - subTask.Progress = 1f; - step.CurrentSubTaskIndex += 1; - step.BlockingReason = null; - if (step.CurrentSubTaskIndex >= step.SubTasks.Count) - { - CompleteStep(plan, step); - } - return; - case SubTaskOutcome.Failed: - subTask.Status = WorkStatus.Failed; - step.Status = AiPlanStepStatus.Failed; - plan.Status = AiPlanStatus.Failed; - plan.FailureReason = subTask.BlockingReason ?? "subtask-failed"; - ship.NeedsReplan = true; - ship.ReplanCooldownSeconds = 0.5f; - ship.LastReplanReason = plan.FailureReason; - return; - } - } - - private static void CompleteStep(ShipPlanRuntime plan, ShipPlanStepRuntime step) - { - step.Status = AiPlanStepStatus.Completed; - step.BlockingReason = null; - plan.CurrentStepIndex += 1; - if (plan.CurrentStepIndex >= plan.Steps.Count) - { - plan.Status = AiPlanStatus.Completed; - } - } - - private static void CompletePlan(ShipRuntime ship, ShipPlanRuntime plan, ICollection events) - { - plan.Status = AiPlanStatus.Completed; - var completedOrder = plan.SourceKind == AiPlanSourceKind.Order - ? ship.OrderQueue.FirstOrDefault(order => order.Id == plan.SourceId) - : null; - if (completedOrder is not null) - { - completedOrder.Status = OrderStatus.Completed; - ship.OrderQueue.RemoveAll(order => order.Id == completedOrder.Id); - } - else if (plan.SourceKind == AiPlanSourceKind.DefaultBehavior - && string.Equals(ship.DefaultBehavior.Kind, "repeat-orders", StringComparison.Ordinal) - && ship.DefaultBehavior.RepeatOrders.Count > 0) - { - ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count; - } - - ship.ActivePlan = null; - ship.NeedsReplan = true; - ship.ReplanCooldownSeconds = 0.25f; - ship.LastReplanReason = "plan-completed"; - events.Add(new SimulationEventRecord("ship", ship.Id, "plan-completed", $"{ship.Definition.Label} completed {plan.Kind}.", DateTimeOffset.UtcNow)); - } - - private void ReplacePlan(ShipRuntime ship, ShipPlanRuntime nextPlan, string reason, ICollection events) - { - if (ship.ActivePlan is not null && ship.ActivePlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed) - { - ship.ActivePlan.Status = AiPlanStatus.Interrupted; - ship.ActivePlan.InterruptReason = reason; - } - - ship.ActivePlan = nextPlan; - ship.NeedsReplan = false; - ship.ReplanCooldownSeconds = 0f; - ship.LastReplanReason = reason; - events.Add(new SimulationEventRecord("ship", ship.Id, "plan-updated", $"{ship.Definition.Label} planned {nextPlan.Kind}.", DateTimeOffset.UtcNow)); - } - - private ShipPlanRuntime? BuildEmergencyPlan(SimulationWorld world, ShipRuntime ship) - { - var policy = ResolvePolicy(world, ship.PolicySetId); - if (policy is null) - { - return null; - } - - var hullRatio = ship.Definition.MaxHealth <= 0.01f ? 1f : ship.Health / ship.Definition.MaxHealth; - if (hullRatio > policy.FleeHullRatio) - { - return null; - } - - var hostileNearby = world.Ships.Any(candidate => - candidate.Health > 0f && - candidate.FactionId != ship.FactionId && - candidate.SystemId == ship.SystemId && - candidate.Position.DistanceTo(ship.Position) <= 200f); - if (!hostileNearby) - { - return null; - } - - var safeStation = world.Stations - .Where(station => station.FactionId == ship.FactionId) - .OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0) - .ThenBy(station => station.Position.DistanceTo(ship.Position)) - .FirstOrDefault(); - - var plan = new ShipPlanRuntime - { - Id = $"plan-{ship.Id}-safety-{Guid.NewGuid():N}", - SourceKind = AiPlanSourceKind.Rule, - SourceId = ShipOrderKinds.Flee, - Kind = "safety-flee", - Summary = "Emergency retreat", - }; - - if (safeStation is null) - { - plan.Steps.Add(CreateStep("step-flee-hold", "hold-position", "Hold position away from hostiles", - [ - CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f) - ])); - return plan; - } - - plan.Steps.Add(CreateStep("step-flee-travel", "travel", "Travel to safe station", - [ - CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), 0f) - ])); - plan.Steps.Add(CreateStep("step-flee-dock", "dock", "Dock at safe station", - [ - CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f) - ])); - return plan; - } - - private ShipPlanRuntime? BuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - return order.Kind switch - { - var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMovePlan(ship, order), - var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.DockAndWait, StringComparison.Ordinal) => BuildDockAndWaitOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.FlyAndWait, StringComparison.Ordinal) => BuildFlyAndWaitOrderPlan(ship, order), - var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldOrderPlan(ship, order), - _ => null, - }; - } - - private ShipPlanRuntime? BuildBehaviorPlan(SimulationWorld world, ShipRuntime ship) - { - var (behaviorKind, sourceId) = ResolveBehaviorSource(world, ship); - return behaviorKind switch - { - "local-auto-mine" => BuildMiningBehaviorPlan(world, ship, "local-auto-mine", sourceId), - "advanced-auto-mine" => BuildMiningBehaviorPlan(world, ship, "advanced-auto-mine", sourceId), - "expert-auto-mine" => BuildMiningBehaviorPlan(world, ship, "expert-auto-mine", sourceId), - "local-auto-trade" => BuildTradeBehaviorPlan(world, ship, "local-auto-trade", sourceId), - "advanced-auto-trade" => BuildTradeBehaviorPlan(world, ship, "advanced-auto-trade", sourceId), - "fill-shortages" => BuildTradeBehaviorPlan(world, ship, "fill-shortages", sourceId), - "find-build-tasks" => BuildTradeBehaviorPlan(world, ship, "find-build-tasks", sourceId), - "revisit-known-stations" => BuildTradeBehaviorPlan(world, ship, "revisit-known-stations", sourceId), - "supply-fleet" => BuildTradeBehaviorPlan(world, ship, "supply-fleet", sourceId), - "construct-station" => BuildConstructionBehaviorPlan(world, ship, sourceId), - "attack-target" => BuildAttackBehaviorPlan(world, ship, sourceId), - "protect-position" => BuildProtectPositionBehaviorPlan(world, ship, sourceId), - "protect-ship" => BuildProtectShipBehaviorPlan(world, ship, sourceId), - "protect-station" => BuildProtectStationBehaviorPlan(world, ship, sourceId), - "police" => BuildPoliceBehaviorPlan(world, ship, sourceId), - "patrol" => BuildPatrolBehaviorPlan(world, ship, sourceId), - "dock-and-wait" => BuildDockAndWaitBehaviorPlan(world, ship, sourceId), - "fly-and-wait" => BuildFlyAndWaitBehaviorPlan(ship, sourceId), - "fly-to-object" => BuildFlyToObjectBehaviorPlan(world, ship, sourceId), - "follow-ship" => BuildFollowShipBehaviorPlan(world, ship, sourceId), - "hold-position" => BuildBehaviorHoldPositionPlan(ship, sourceId), - "auto-salvage" => BuildAutoSalvageBehaviorPlan(world, ship, sourceId), - "repeat-orders" => BuildRepeatOrdersBehaviorPlan(world, ship, sourceId), - _ => CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Idle"), - }; - } - - private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship) - { - var assignment = ResolveAssignment(world, ship); - return assignment is null - ? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind) - : (assignment.BehaviorKind, assignment.ObjectiveId); - } - - private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order) - { - var targetSystemId = order.TargetSystemId ?? ship.SystemId; - var targetPosition = order.TargetPosition ?? ship.Position; - return CreatePlan( - ship, - AiPlanSourceKind.Order, - order.Id, - "move", - order.Label ?? "Move order", - [ - CreateStep("step-move", "travel", order.Label ?? "Travel", - [ - CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, 0f, 0f) - ]) - ]); - } - - private ShipPlanRuntime? BuildDockOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId); - if (station is null) - { - order.FailureReason = "station-missing"; - return null; - } - - return CreatePlan( - ship, - AiPlanSourceKind.Order, - order.Id, - "dock-at-station", - order.Label ?? $"Dock at {station.Label}", - [ - CreateStep("step-dock-travel", "travel", $"Travel to {station.Label}", - [ - CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(balance.ArrivalThreshold, station.Radius + 12f), 0f) - ]), - CreateStep("step-dock", "dock", $"Dock at {station.Label}", - [ - CreateSubTask("sub-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f) - ]) - ]); - } - - private ShipPlanRuntime? BuildTradeOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - if (order.SourceStationId is null || order.DestinationStationId is null || order.ItemId is null) - { - order.FailureReason = "trade-order-incomplete"; - return null; - } - - var route = ResolveTradeRoute(world, order.ItemId, order.SourceStationId, order.DestinationStationId); - if (route is null) - { - order.FailureReason = "trade-route-missing"; - return null; - } - - return BuildTradePlan(ship, AiPlanSourceKind.Order, order.Id, route, order.Label ?? route.Summary); - } - - private ShipPlanRuntime? BuildMineOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - var homeStation = ResolveStation(world, order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId); - var node = ResolveNode(world, order.NodeId); - if (homeStation is null || node is null) - { - order.FailureReason = "mine-order-incomplete"; - return null; - } - - return BuildMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, homeStation, order.Label ?? $"Mine {node.ItemId}"); - } - - private ShipPlanRuntime? BuildBuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (order.ConstructionSiteId ?? order.TargetEntityId)); - if (site is null) - { - order.FailureReason = "construction-site-missing"; - return null; - } - - var supportStation = ResolveSupportStation(world, ship, site); - if (supportStation is null) - { - order.FailureReason = "support-station-missing"; - return null; - } - - return BuildConstructionPlan(ship, AiPlanSourceKind.Order, order.Id, site, supportStation, order.Label ?? $"Build {site.BlueprintId}"); - } - - private ShipPlanRuntime? BuildAttackOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - var targetId = order.TargetEntityId; - if (targetId is null) - { - order.FailureReason = "attack-target-missing"; - return null; - } - - return BuildAttackPlan(ship, AiPlanSourceKind.Order, order.Id, targetId, order.TargetSystemId, order.Label ?? "Attack target"); - } - - private ShipPlanRuntime BuildHoldOrderPlan(ShipRuntime ship, ShipOrderRuntime order) - { - return CreatePlan( - ship, - AiPlanSourceKind.Order, - order.Id, - "hold-position", - order.Label ?? "Hold position", - [ - CreateStep("step-hold", "hold-position", order.Label ?? "Hold position", - [ - CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f) - ]) - ]); - } - - private ShipPlanRuntime? BuildDockAndWaitOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId); - if (station is null) - { - order.FailureReason = "station-missing"; - return null; - } - - return BuildDockAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, station, MathF.Max(1f, order.WaitSeconds), order.Label ?? $"Dock and wait at {station.Label}"); - } - - private ShipPlanRuntime BuildFlyAndWaitOrderPlan(ShipRuntime ship, ShipOrderRuntime order) - { - var systemId = order.TargetSystemId ?? ship.SystemId; - var targetPosition = order.TargetPosition ?? ship.Position; - return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, systemId, targetPosition, MathF.Max(1f, order.WaitSeconds), order.Label ?? "Fly and wait"); - } - - private ShipPlanRuntime? BuildFlyToObjectOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - var targetEntityId = order.TargetEntityId; - if (targetEntityId is null) - { - order.FailureReason = "target-missing"; - return null; - } - - var objectTarget = ResolveObjectTarget(world, targetEntityId); - if (objectTarget is null) - { - order.FailureReason = "target-missing"; - return null; - } - - return BuildFlyToObjectPlan(ship, AiPlanSourceKind.Order, order.Id, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}"); - } - - private ShipPlanRuntime? BuildFollowShipOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f); - if (targetShip is null) - { - order.FailureReason = "target-ship-missing"; - return null; - } - - return BuildFollowShipPlan(ship, AiPlanSourceKind.Order, order.Id, targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Label}"); - } - - private ShipPlanRuntime? BuildMiningBehaviorPlan(SimulationWorld world, ShipRuntime ship, string behaviorKind, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); - if (homeStation is null) - { - return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No home station"); - } - - var opportunity = SelectMiningOpportunity(world, ship, homeStation, assignment, behaviorKind); - return opportunity is null - ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No mineable node") - : BuildMiningPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, opportunity.Node, opportunity.DropOffStation, opportunity.Summary); - } - - private ShipPlanRuntime BuildMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, StationRuntime homeStation, string summary) - { - var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f); - return CreatePlan( - ship, - sourceKind, - sourceId, - "mine-and-deliver", - summary, - [ - CreateStep("step-mine", "mine", $"Mine {node.ItemId}", - [ - CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} node", node.SystemId, extractionPosition, node.Id, 8f, 0f), - CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.CargoCapacity) - ]), - CreateStep("step-deliver", "deliver", $"Deliver {node.ItemId} to {homeStation.Label}", - [ - CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f), - CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f), - CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.CargoCapacity), - CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f) - ]) - ]); - } - - private ShipPlanRuntime? BuildTradeBehaviorPlan(SimulationWorld world, ShipRuntime ship, string behaviorKind, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); - if (string.Equals(behaviorKind, "supply-fleet", StringComparison.Ordinal)) - { - var fleetPlan = SelectFleetSupplyPlan(world, ship, homeStation); - return fleetPlan is null - ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No fleet to supply") - : BuildFleetSupplyPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, fleetPlan); - } - - var route = SelectTradeRoute(world, ship, homeStation, behaviorKind, ship.DefaultBehavior.KnownStationsOnly); - if (route is not null) - { - return BuildTradePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, route, route.Summary); - } - - if (string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal) - && SelectKnownStationVisit(world, ship, homeStation) is { } visitStation) - { - return BuildDockAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, visitStation, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Revisit {visitStation.Label}"); - } - - return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No trade route"); - } - - private ShipPlanRuntime BuildTradePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, TradeRoutePlan route, string summary) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "trade-route", - summary, - [ - CreateStep("step-acquire", "acquire-cargo", $"Load {route.ItemId} at {route.SourceStation.Label}", - [ - CreateSubTask("sub-acquire-travel", ShipTaskKinds.Travel, $"Travel to {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(route.SourceStation.Radius + 12f, 12f), 0f), - CreateSubTask("sub-acquire-dock", ShipTaskKinds.Dock, $"Dock at {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 4f, 0f), - CreateSubTask("sub-acquire-load", ShipTaskKinds.LoadCargo, $"Load {route.ItemId}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 0f, ship.Definition.CargoCapacity, itemId: route.ItemId), - CreateSubTask("sub-acquire-undock", ShipTaskKinds.Undock, $"Undock from {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(4f, 12f), 0f) - ]), - CreateStep("step-deliver", "deliver-cargo", $"Deliver {route.ItemId} to {route.DestinationStation.Label}", - [ - CreateSubTask("sub-route-travel", ShipTaskKinds.Travel, $"Travel to {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(route.DestinationStation.Radius + 12f, 12f), 0f), - CreateSubTask("sub-route-dock", ShipTaskKinds.Dock, $"Dock at {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 4f, 0f), - CreateSubTask("sub-route-unload", ShipTaskKinds.UnloadCargo, $"Unload {route.ItemId}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 0f, ship.Definition.CargoCapacity, itemId: route.ItemId), - CreateSubTask("sub-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f) - ]) - ]); - } - - private ShipPlanRuntime BuildFleetSupplyPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, FleetSupplyPlan plan) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "supply-fleet", - plan.Summary, - [ - CreateStep("step-fleet-acquire", "acquire-cargo", $"Load {plan.ItemId} at {plan.SourceStation.Label}", - [ - CreateSubTask("sub-fleet-source-travel", ShipTaskKinds.Travel, $"Travel to {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, MathF.Max(plan.SourceStation.Radius + 12f, 12f), 0f), - CreateSubTask("sub-fleet-source-dock", ShipTaskKinds.Dock, $"Dock at {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 4f, 0f), - CreateSubTask("sub-fleet-source-load", ShipTaskKinds.LoadCargo, $"Load {plan.ItemId}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 0f, plan.Amount, itemId: plan.ItemId), - CreateSubTask("sub-fleet-source-undock", ShipTaskKinds.Undock, $"Undock from {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 12f, 0f), - ]), - CreateStep("step-fleet-deliver", "deliver-fleet", $"Deliver {plan.ItemId} to {plan.TargetShip.Definition.Label}", - [ - CreateSubTask("sub-fleet-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Label}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, 6f), - CreateSubTask("sub-fleet-transfer", ShipTaskKinds.TransferCargoToShip, $"Transfer {plan.ItemId}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, plan.Amount, itemId: plan.ItemId), - ]) - ]); - } - - private ShipPlanRuntime? BuildConstructionBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.PreferredConstructionSiteId)) - ?? world.ConstructionSites - .Where(candidate => candidate.FactionId == ship.FactionId && candidate.State is ConstructionSiteStateKinds.Active or ConstructionSiteStateKinds.Planned) - .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) - .FirstOrDefault(); - if (site is null) - { - return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No construction site"); - } - - var supportStation = ResolveSupportStation(world, ship, site); - return supportStation is null - ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No support station") - : BuildConstructionPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, site, supportStation, $"Build {site.BlueprintId}"); - } - - private ShipPlanRuntime BuildConstructionPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ConstructionSiteRuntime site, StationRuntime supportStation, string summary) - { - var targetPosition = site.StationId is null ? supportStation.Position : supportStation.Position; - return CreatePlan( - ship, - sourceKind, - sourceId, - "construction-support", - summary, - [ - CreateStep("step-construction-deliver", "deliver-materials", $"Deliver materials to {site.Id}", - [ - CreateSubTask("sub-construction-travel", ShipTaskKinds.Travel, $"Travel to support station {supportStation.Label}", supportStation.SystemId, targetPosition, supportStation.Id, MathF.Max(supportStation.Radius + 18f, 18f), 0f), - CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f) - ]), - CreateStep("step-construction-build", "build-site", $"Build {site.Id}", - [ - CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f) - ]) - ]); - } - - private ShipPlanRuntime? BuildAttackBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var targetId = assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId; - if (targetId is null) - { - return BuildPatrolBehaviorPlan(world, ship, sourceId); - } - - return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetId, assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId, "Attack target"); - } - - private ShipPlanRuntime BuildAttackPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string? targetSystemId, string summary) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "attack-target", - summary, - [ - CreateStep("step-attack", "attack-target", summary, - [ - CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? ship.SystemId, ship.Position, targetEntityId, 26f, 0f) - ]) - ]); - } - - private ShipPlanRuntime BuildPatrolBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var patrolSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; - var protectPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position; - var patrolThreat = SelectThreatTarget(world, ship, patrolSystemId, protectPosition, MathF.Max(60f, ship.DefaultBehavior.Radius)); - if (patrolThreat is not null) - { - return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, patrolThreat.EntityId, patrolThreat.SystemId, "Patrol intercept"); - } - - var patrolPoints = ship.DefaultBehavior.PatrolPoints; - Vector3 targetPosition; - string targetSystemId; - if (patrolPoints.Count > 0) - { - var index = ship.DefaultBehavior.PatrolIndex % patrolPoints.Count; - targetPosition = patrolPoints[index]; - ship.DefaultBehavior.PatrolIndex = (index + 1) % patrolPoints.Count; - targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; - } - else if (ResolveStation(world, ship.DefaultBehavior.HomeStationId ?? ResolveAssignment(world, ship)?.HomeStationId) is { } homeStation) - { - var patrolRadius = homeStation.Radius + 90f; - targetPosition = new Vector3(homeStation.Position.X + patrolRadius, homeStation.Position.Y, homeStation.Position.Z); - targetSystemId = homeStation.SystemId; - } - else - { - targetPosition = ship.Position; - targetSystemId = ship.SystemId; - } - - return CreatePlan( - ship, - AiPlanSourceKind.DefaultBehavior, - sourceId, - "patrol", - "Patrol sector", - [ - CreateStep("step-patrol-travel", "travel", "Travel patrol waypoint", - [ - CreateSubTask("sub-patrol-travel", ShipTaskKinds.Travel, "Travel patrol waypoint", targetSystemId, targetPosition, null, 10f, 0f) - ]), - CreateStep("step-patrol-hold", "hold-position", "Hold patrol waypoint", - [ - CreateSubTask("sub-patrol-hold", ShipTaskKinds.HoldPosition, "Hold patrol waypoint", targetSystemId, targetPosition, null, 0f, 2f) - ]) - ]); - } - - private ShipPlanRuntime? BuildPoliceBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); - var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? homeStation?.SystemId ?? ship.SystemId; - var areaPosition = homeStation?.Position ?? ship.DefaultBehavior.TargetPosition ?? ship.Position; - var contact = SelectPoliceContact(world, ship, systemId, areaPosition, MathF.Max(80f, ship.DefaultBehavior.Radius)); - if (contact is null) - { - return BuildPatrolBehaviorPlan(world, ship, sourceId); - } - - return contact.Engage - ? BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, contact.EntityId, contact.SystemId, "Police engage") - : BuildFollowPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, contact.EntityId, contact.SystemId, contact.Position, MathF.Max(14f, ship.DefaultBehavior.Radius * 0.5f), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Police inspect"); - } - - private ShipPlanRuntime BuildProtectPositionBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var targetSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; - var targetPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position; - var threat = SelectThreatTarget(world, ship, targetSystemId, targetPosition, MathF.Max(90f, ship.DefaultBehavior.Radius)); - if (threat is not null) - { - return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, "Protect position"); - } - - return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Protect position"); - } - - private ShipPlanRuntime BuildProtectShipBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var guardTarget = world.Ships.FirstOrDefault(candidate => candidate.Id == (ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId) && candidate.Health > 0f); - if (guardTarget is null) - { - return BuildPatrolBehaviorPlan(world, ship, sourceId); - } - - var threat = SelectThreatTarget(world, ship, guardTarget.SystemId, guardTarget.Position, MathF.Max(90f, ship.DefaultBehavior.Radius), excludeEntityId: guardTarget.Id); - if (threat is not null) - { - return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, $"Protect {guardTarget.Definition.Label}"); - } - - return BuildFollowShipPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, guardTarget, MathF.Max(18f, ship.DefaultBehavior.Radius * 0.5f), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Escort {guardTarget.Definition.Label}"); - } - - private ShipPlanRuntime BuildProtectStationBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); - if (station is null) - { - return BuildPatrolBehaviorPlan(world, ship, sourceId); - } - - var threat = SelectThreatTarget(world, ship, station.SystemId, station.Position, MathF.Max(station.Radius + 80f, ship.DefaultBehavior.Radius)); - if (threat is not null) - { - return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, $"Protect {station.Label}"); - } - - return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, station.SystemId, GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Guard {station.Label}"); - } - - private ShipPlanRuntime BuildDockAndWaitBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var station = ResolveStation(world, ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? ship.DefaultBehavior.HomeStationId); - return station is null - ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No station to dock") - : BuildDockAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, station, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Dock and wait at {station.Label}"); - } - - private ShipPlanRuntime BuildFlyAndWaitBehaviorPlan(ShipRuntime ship, string sourceId) - { - var targetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position; - var targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; - return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Fly and wait"); - } - - private ShipPlanRuntime BuildFlyToObjectBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var targetEntityId = ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId; - var objectTarget = ResolveObjectTarget(world, targetEntityId); - return objectTarget is null || targetEntityId is null - ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No object target") - : BuildFlyToObjectPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, "Fly to object"); - } - - private ShipPlanRuntime BuildFollowShipBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == (ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId) && candidate.Health > 0f); - return targetShip is null - ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No ship to follow") - : BuildFollowShipPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetShip, MathF.Max(16f, ship.DefaultBehavior.Radius), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Follow {targetShip.Definition.Label}"); - } - - private ShipPlanRuntime BuildBehaviorHoldPositionPlan(ShipRuntime ship, string sourceId) - { - var targetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position; - var targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; - return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Hold position"); - } - - private ShipPlanRuntime BuildAutoSalvageBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); - var salvage = SelectSalvageOpportunity(world, ship, homeStation); - if (salvage is null || homeStation is null) - { - return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No salvage target"); - } - - var approach = GetFormationPosition(salvage.Wreck.Position, ship.Id, MathF.Max(8f, ship.DefaultBehavior.Radius * 0.25f)); - return CreatePlan( - ship, - AiPlanSourceKind.DefaultBehavior, - sourceId, - "auto-salvage", - salvage.Summary, - [ - CreateStep("step-salvage-collect", "salvage", $"Salvage {salvage.Wreck.ItemId}", - [ - CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {salvage.Wreck.Id}", salvage.Wreck.SystemId, approach, salvage.Wreck.Id, 8f, 0f), - CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {salvage.Wreck.ItemId}", salvage.Wreck.SystemId, approach, salvage.Wreck.Id, 8f, ship.Definition.CargoCapacity, itemId: salvage.Wreck.ItemId), - ]), - CreateStep("step-salvage-deliver", "deliver-salvage", $"Deliver salvage to {homeStation.Label}", - [ - CreateSubTask("sub-salvage-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f), - CreateSubTask("sub-salvage-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f), - CreateSubTask("sub-salvage-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload salvage at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.CargoCapacity, itemId: salvage.Wreck.ItemId), - CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f), - ]) - ]); - } - - private ShipPlanRuntime BuildRepeatOrdersBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - if (ship.DefaultBehavior.RepeatOrders.Count == 0) - { - return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No repeat orders"); - } - - var template = ship.DefaultBehavior.RepeatOrders[ship.DefaultBehavior.RepeatIndex % ship.DefaultBehavior.RepeatOrders.Count]; - var syntheticOrder = new ShipOrderRuntime - { - Id = $"repeat-{ship.Id}-{ship.DefaultBehavior.RepeatIndex}", - Kind = template.Kind, - Label = template.Label, - TargetEntityId = template.TargetEntityId, - TargetSystemId = template.TargetSystemId, - TargetPosition = template.TargetPosition, - SourceStationId = template.SourceStationId, - DestinationStationId = template.DestinationStationId, - ItemId = template.ItemId, - NodeId = template.NodeId, - ConstructionSiteId = template.ConstructionSiteId, - ModuleId = template.ModuleId, - WaitSeconds = template.WaitSeconds, - Radius = template.Radius, - MaxSystemRange = template.MaxSystemRange, - KnownStationsOnly = template.KnownStationsOnly, - }; - - return BuildOrderPlan(world, ship, syntheticOrder) - ?? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Invalid repeat order"); - } - - private ShipPlanRuntime BuildDockAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime station, float waitSeconds, string summary) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "dock-and-wait", - summary, - [ - CreateStep("step-dock-wait-travel", "travel", $"Travel to {station.Label}", - [ - CreateSubTask("sub-dock-wait-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(station.Radius + 12f, 12f), 0f), - CreateSubTask("sub-dock-wait-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f), - CreateSubTask("sub-dock-wait-hold", ShipTaskKinds.HoldPosition, $"Wait at {station.Label}", station.SystemId, station.Position, station.Id, 0f, waitSeconds), - ]) - ]); - } - - private ShipPlanRuntime BuildFlyAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, float waitSeconds, string summary) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "fly-and-wait", - summary, - [ - CreateStep("step-fly-wait", "fly-and-wait", summary, - [ - CreateSubTask("sub-fly-wait-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, null, 6f, 0f), - CreateSubTask("sub-fly-wait-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, null, 0f, waitSeconds), - ]) - ]); - } - - private ShipPlanRuntime BuildFlyToObjectPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "fly-to-object", - summary, - [ - CreateStep("step-fly-object", "fly-to-object", summary, - [ - CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f), - CreateSubTask("sub-fly-object-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, targetEntityId, 0f, MathF.Max(1f, ship.DefaultBehavior.WaitSeconds)), - ]) - ]); - } - - private ShipPlanRuntime BuildFollowShipPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ShipRuntime targetShip, float radius, float durationSeconds, string summary) - { - return BuildFollowPlan(ship, sourceKind, sourceId, targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary); - } - - private ShipPlanRuntime BuildFollowPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "follow-ship", - summary, - [ - CreateStep("step-follow", "follow-target", summary, - [ - CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds), - ]) - ]); - } - - private ShipPlanRuntime CreateIdlePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "idle", - summary, - [ - CreateStep("step-idle", "hold-position", summary, - [ - CreateSubTask("sub-idle", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 1.5f) - ]) - ]); - } - - private static ShipPlanRuntime CreatePlan( - ShipRuntime ship, - AiPlanSourceKind sourceKind, - string sourceId, - string kind, - string summary, - IReadOnlyList steps) - { - var plan = new ShipPlanRuntime - { - Id = $"plan-{ship.Id}-{Guid.NewGuid():N}", - SourceKind = sourceKind, - SourceId = sourceId, - Kind = kind, - Summary = summary, - }; - plan.Steps.AddRange(steps); - return plan; - } - - private static ShipPlanStepRuntime CreateStep(string id, string kind, string summary, IReadOnlyList subTasks) - { - var step = new ShipPlanStepRuntime - { - Id = id, - Kind = kind, - Summary = summary, - }; - step.SubTasks.AddRange(subTasks); - return step; - } - - private static ShipSubTaskRuntime CreateSubTask( - string id, - string kind, - string summary, - string targetSystemId, - Vector3 targetPosition, - string? targetEntityId, - float threshold, - float amount, - string? itemId = null, - string? moduleId = null, - string? targetNodeId = null) => - new() - { - Id = id, - Kind = kind, - Summary = summary, - TargetSystemId = targetSystemId, - TargetPosition = targetPosition, - TargetEntityId = targetEntityId, - TargetNodeId = targetNodeId, - ItemId = itemId, - ModuleId = moduleId, - Threshold = threshold, - Amount = amount, - }; - - private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipPlanStepRuntime step, ShipSubTaskRuntime subTask, float deltaSeconds) - { - return subTask.Kind switch - { - var kind when string.Equals(kind, ShipTaskKinds.Travel, StringComparison.Ordinal) => UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: true), - var kind when string.Equals(kind, ShipTaskKinds.FollowTarget, StringComparison.Ordinal) => UpdateFollowSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.Dock, StringComparison.Ordinal) => UpdateDockSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.Undock, StringComparison.Ordinal) => UpdateUndockSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.LoadCargo, StringComparison.Ordinal) => UpdateLoadCargoSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.UnloadCargo, StringComparison.Ordinal) => UpdateUnloadCargoSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.TransferCargoToShip, StringComparison.Ordinal) => UpdateTransferCargoToShipSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.MineNode, StringComparison.Ordinal) => UpdateMineSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.SalvageWreck, StringComparison.Ordinal) => UpdateSalvageSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.DeliverConstruction, StringComparison.Ordinal) => UpdateDeliverConstructionSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.BuildConstructionSite, StringComparison.Ordinal) => UpdateBuildConstructionSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.AttackTarget, StringComparison.Ordinal) => UpdateAttackSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.HoldPosition, StringComparison.Ordinal) => UpdateHoldSubTask(ship, subTask, deltaSeconds), - _ => SubTaskOutcome.Failed, - }; - } - - private SubTaskOutcome UpdateHoldSubTask(ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - ship.State = ShipState.HoldingPosition; - ship.TargetPosition = subTask.TargetPosition ?? ship.Position; - ship.Position = ship.Position.MoveToward(ship.TargetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(ship.TargetPosition))); - return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.1f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateFollowSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); - if (targetShip is null) - { - subTask.BlockingReason = "follow-target-missing"; - return SubTaskOutcome.Failed; - } - - var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 16f)); - subTask.TargetSystemId = targetShip.SystemId; - subTask.TargetPosition = desiredPosition; - subTask.BlockingReason = null; - if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f)) - { - return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); - } - - ship.State = ShipState.HoldingPosition; - ship.TargetPosition = desiredPosition; - ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition))); - return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.5f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateTravelSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds, bool completeOnArrival) - { - if (subTask.TargetPosition is null || subTask.TargetSystemId is null) - { - subTask.BlockingReason = "travel-target-missing"; - ship.State = ShipState.Blocked; - return SubTaskOutcome.Failed; - } - - var targetPosition = ResolveCurrentTargetPosition(world, subTask); - var targetCelestial = ResolveTravelTargetCelestial(world, subTask, targetPosition); - ship.TargetPosition = targetPosition; - - if (ship.SystemId != subTask.TargetSystemId) - { - if (!HasShipCapabilities(ship.Definition, "ftl")) - { - subTask.BlockingReason = "ftl-unavailable"; - ship.State = ShipState.Blocked; - return SubTaskOutcome.Failed; - } - - var destinationEntryCelestial = ResolveSystemEntryCelestial(world, subTask.TargetSystemId); - var destinationEntryPosition = destinationEntryCelestial?.Position ?? targetPosition; - return UpdateFtlTransit(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, destinationEntryPosition, destinationEntryCelestial, completeOnArrival, targetPosition); - } - - var currentCelestial = ResolveCurrentCelestial(world, ship); - if (targetCelestial is not null - && currentCelestial is not null - && !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal)) - { - if (!HasShipCapabilities(ship.Definition, "warp")) - { - return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival); - } - - return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival); - } - - if (targetCelestial is not null - && ship.Position.DistanceTo(targetPosition) > WarpEngageDistanceKilometers - && HasShipCapabilities(ship.Definition, "warp")) - { - return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival); - } - - return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival); - } - - private SubTaskOutcome UpdateAttackSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); - var hostileStation = hostileShip is null - ? world.Stations.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) - : null; - if ((hostileShip is not null && hostileShip.FactionId == ship.FactionId) - || (hostileStation is not null && hostileStation.FactionId == ship.FactionId)) - { - subTask.BlockingReason = "friendly-target"; - return SubTaskOutcome.Failed; - } - - if (hostileShip is null && hostileStation is null) - { - return SubTaskOutcome.Completed; - } - - var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId; - var targetPosition = hostileShip?.Position ?? hostileStation!.Position; - var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f; - subTask.TargetSystemId = targetSystemId; - subTask.TargetPosition = targetPosition; - subTask.Threshold = attackRange; - - if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange) - { - return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); - } - - ship.State = ShipState.EngagingTarget; - ship.TargetPosition = targetPosition; - ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f)); - var damage = GetShipDamagePerSecond(ship) * deltaSeconds * GetSkillFactor(ship.Skills.Combat); - subTask.Progress = 1f; - - if (hostileShip is not null) - { - hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage); - return hostileShip.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - hostileStation!.Health = MathF.Max(0f, hostileStation.Health - (damage * 0.6f)); - return hostileStation.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateMineSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var node = ResolveNode(world, subTask.TargetEntityId ?? subTask.TargetNodeId); - if (node is null || !CanExtractNode(ship, node, world)) - { - subTask.BlockingReason = "node-missing"; - ship.State = ShipState.Blocked; - return SubTaskOutcome.Failed; - } - - var targetPosition = subTask.TargetPosition ?? GetResourceHoldPosition(node.Position, ship.Id, 20f); - ship.TargetPosition = targetPosition; - if (ship.Position.DistanceTo(targetPosition) > MathF.Max(subTask.Threshold, 8f)) - { - ship.State = ShipState.MiningApproach; - ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return SubTaskOutcome.Active; - } - - var cargoAmount = GetShipCargoAmount(ship); - if (cargoAmount >= ship.Definition.CargoCapacity - 0.01f) - { - return SubTaskOutcome.Completed; - } - - ship.State = ShipState.Mining; - if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.MiningCycleSeconds)) - { - return SubTaskOutcome.Active; - } - - var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount); - var mined = MathF.Min(balance.MiningRate * GetSkillFactor(ship.Skills.Mining), remainingCapacity); - mined = MathF.Min(mined, node.OreRemaining); - if (mined <= 0.01f) - { - return SubTaskOutcome.Completed; - } - - AddInventory(ship.Inventory, node.ItemId, mined); - node.OreRemaining = MathF.Max(0f, node.OreRemaining - mined); - if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f || node.OreRemaining <= 0.01f) - { - return SubTaskOutcome.Completed; - } - - subTask.ElapsedSeconds = 0f; - return SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateDockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var station = ResolveStation(world, subTask.TargetEntityId); - if (station is null) - { - subTask.BlockingReason = "dock-target-missing"; - ship.State = ShipState.Blocked; - return SubTaskOutcome.Failed; - } - - var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id); - if (padIndex is null) - { - ship.State = ShipState.AwaitingDock; - ship.TargetPosition = GetDockingHoldPosition(station, ship.Id); - if (ship.Position.DistanceTo(ship.TargetPosition) > 4f) - { - ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - } - - subTask.Status = WorkStatus.Blocked; - subTask.BlockingReason = "waiting-for-pad"; - return SubTaskOutcome.Active; - } - - subTask.Status = WorkStatus.Active; - subTask.BlockingReason = null; - ship.AssignedDockingPadIndex = padIndex; - var padPosition = GetDockingPadPosition(station, padIndex.Value); - ship.TargetPosition = padPosition; - if (ship.Position.DistanceTo(padPosition) > 4f) - { - ship.State = ShipState.DockingApproach; - ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return SubTaskOutcome.Active; - } - - ship.State = ShipState.Docking; - if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.DockingDuration)) - { - return SubTaskOutcome.Active; - } - - ship.State = ShipState.Docked; - ship.DockedStationId = station.Id; - station.DockedShipIds.Add(ship.Id); - ship.KnownStationIds.Add(station.Id); - ship.Position = padPosition; - ship.TargetPosition = padPosition; - return SubTaskOutcome.Completed; - } - - private SubTaskOutcome UpdateUndockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - if (ship.DockedStationId is null) - { - return SubTaskOutcome.Completed; - } - - var station = ResolveStation(world, ship.DockedStationId); - if (station is null) - { - ship.DockedStationId = null; - ship.AssignedDockingPadIndex = null; - return SubTaskOutcome.Completed; - } - - var undockTarget = GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, balance.UndockDistance); - ship.TargetPosition = undockTarget; - ship.State = ShipState.Undocking; - if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.UndockingDuration)) - { - ship.Position = GetShipDockedPosition(ship, station); - return SubTaskOutcome.Active; - } - - ship.Position = ship.Position.MoveToward(undockTarget, balance.UndockDistance); - if (ship.Position.DistanceTo(undockTarget) > MathF.Max(subTask.Threshold, 4f)) - { - return SubTaskOutcome.Active; - } - - station.DockedShipIds.Remove(ship.Id); - ReleaseDockingPad(station, ship.Id); - ship.DockedStationId = null; - ship.AssignedDockingPadIndex = null; - return SubTaskOutcome.Completed; - } - - private SubTaskOutcome UpdateLoadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - if (ship.DockedStationId is null) - { - subTask.BlockingReason = "not-docked"; - return SubTaskOutcome.Failed; - } - - var station = ResolveStation(world, ship.DockedStationId); - if (station is null) - { - subTask.BlockingReason = "station-missing"; - return SubTaskOutcome.Failed; - } - - ship.TargetPosition = GetShipDockedPosition(ship, station); - ship.Position = ship.TargetPosition; - ship.State = ShipState.Loading; - var itemId = subTask.ItemId; - if (itemId is null) - { - return SubTaskOutcome.Completed; - } - - var desiredAmount = subTask.Amount > 0f ? subTask.Amount : ship.Definition.CargoCapacity; - var availableCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)); - var transferRate = balance.TransferRate * GetSkillFactor(ship.Skills.Trade); - var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(availableCapacity, GetInventoryAmount(station.Inventory, itemId))); - if (moved > 0.01f) - { - RemoveInventory(station.Inventory, itemId, moved); - AddInventory(ship.Inventory, itemId, moved); - } - - var loadedAmount = GetInventoryAmount(ship.Inventory, itemId); - subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(loadedAmount / desiredAmount, 0f, 1f); - return availableCapacity <= 0.01f || GetInventoryAmount(station.Inventory, itemId) <= 0.01f || loadedAmount >= desiredAmount - 0.01f - ? SubTaskOutcome.Completed - : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateUnloadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - if (ship.DockedStationId is null) - { - subTask.BlockingReason = "not-docked"; - return SubTaskOutcome.Failed; - } - - var station = ResolveStation(world, ship.DockedStationId); - if (station is null) - { - subTask.BlockingReason = "station-missing"; - return SubTaskOutcome.Failed; - } - - ship.TargetPosition = GetShipDockedPosition(ship, station); - ship.Position = ship.TargetPosition; - ship.State = ShipState.Transferring; - var transferRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Mining)); - - if (subTask.ItemId is not null) - { - var moved = MathF.Min(transferRate * deltaSeconds, GetInventoryAmount(ship.Inventory, subTask.ItemId)); - var accepted = TryAddStationInventory(world, station, subTask.ItemId, moved); - RemoveInventory(ship.Inventory, subTask.ItemId, accepted); - subTask.Progress = subTask.Amount <= 0.01f - ? 1f - : Math.Clamp(1f - (GetInventoryAmount(ship.Inventory, subTask.ItemId) / subTask.Amount), 0f, 1f); - return GetInventoryAmount(ship.Inventory, subTask.ItemId) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - foreach (var (itemId, amount) in ship.Inventory.ToList().OrderBy(entry => entry.Key, StringComparer.Ordinal)) - { - var moved = MathF.Min(amount, transferRate * deltaSeconds); - var accepted = TryAddStationInventory(world, station, itemId, moved); - RemoveInventory(ship.Inventory, itemId, accepted); - if (accepted > 0.01f) - { - return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - } - - return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateTransferCargoToShipSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); - if (targetShip is null) - { - subTask.BlockingReason = "target-ship-missing"; - return SubTaskOutcome.Failed; - } - - var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 12f)); - subTask.TargetSystemId = targetShip.SystemId; - subTask.TargetPosition = desiredPosition; - if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f)) - { - return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); - } - - ship.State = ShipState.Transferring; - ship.TargetPosition = desiredPosition; - ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition))); - if (subTask.ItemId is null) - { - return SubTaskOutcome.Completed; - } - - var targetCapacity = MathF.Max(0f, targetShip.Definition.CargoCapacity - GetShipCargoAmount(targetShip)); - if (targetCapacity <= 0.01f) - { - subTask.BlockingReason = "target-cargo-full"; - return SubTaskOutcome.Failed; - } - - var transferRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Navigation)); - var desiredAmount = subTask.Amount > 0f ? subTask.Amount : GetInventoryAmount(ship.Inventory, subTask.ItemId); - var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(targetCapacity, GetInventoryAmount(ship.Inventory, subTask.ItemId))); - if (moved > 0.01f) - { - RemoveInventory(ship.Inventory, subTask.ItemId, moved); - AddInventory(targetShip.Inventory, subTask.ItemId, moved); - } - - var remaining = GetInventoryAmount(ship.Inventory, subTask.ItemId); - subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(1f - (remaining / desiredAmount), 0f, 1f); - return remaining <= 0.01f || GetShipCargoAmount(targetShip) >= targetShip.Definition.CargoCapacity - 0.01f - ? SubTaskOutcome.Completed - : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateSalvageSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.RemainingAmount > 0.01f); - if (wreck is null) - { - return SubTaskOutcome.Completed; - } - - var desiredPosition = subTask.TargetPosition ?? GetFormationPosition(wreck.Position, ship.Id, 8f); - ship.TargetPosition = desiredPosition; - if (ship.SystemId != wreck.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 8f)) - { - subTask.TargetSystemId = wreck.SystemId; - subTask.TargetPosition = desiredPosition; - return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); - } - - ship.State = ShipState.Transferring; - var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)); - if (remainingCapacity <= 0.01f) - { - return SubTaskOutcome.Completed; - } - - if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.4f, balance.MiningCycleSeconds * 0.8f))) - { - return SubTaskOutcome.Active; - } - - var salvageRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Mining, ship.Skills.Trade)); - var recovered = MathF.Min(salvageRate, MathF.Min(remainingCapacity, wreck.RemainingAmount)); - if (recovered > 0.01f) - { - AddInventory(ship.Inventory, wreck.ItemId, recovered); - wreck.RemainingAmount = MathF.Max(0f, wreck.RemainingAmount - recovered); - } - - if (wreck.RemainingAmount <= 0.01f) - { - world.Wrecks.RemoveAll(candidate => candidate.Id == wreck.Id); - } - - subTask.ElapsedSeconds = 0f; - return wreck.RemainingAmount <= 0.01f || GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f - ? SubTaskOutcome.Completed - : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateDeliverConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - var station = site is null ? null : ResolveSupportStation(world, ship, site); - if (site is null || station is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed) - { - subTask.BlockingReason = "construction-target-missing"; - return SubTaskOutcome.Failed; - } - - var supportPosition = ResolveSupportPosition(ship, station, site, world); - if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold))) - { - ship.State = ShipState.LocalFlight; - ship.TargetPosition = supportPosition; - ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return SubTaskOutcome.Active; - } - - ship.TargetPosition = supportPosition; - ship.Position = supportPosition; - ship.State = ShipState.DeliveringConstruction; - var transferRate = balance.TransferRate * GetSkillFactor(ship.Skills.Construction); - foreach (var required in site.RequiredItems.OrderBy(entry => entry.Key, StringComparer.Ordinal)) - { - var delivered = GetInventoryAmount(site.DeliveredItems, required.Key); - var remaining = MathF.Max(0f, required.Value - delivered); - if (remaining <= 0.01f) - { - continue; - } - - var available = MathF.Max(0f, GetInventoryAmount(station.Inventory, required.Key) - GetStationReserveFloor(world, station, required.Key)); - var moved = MathF.Min(remaining, MathF.Min(available, transferRate * deltaSeconds)); - if (moved <= 0.01f) - { - continue; - } - - RemoveInventory(station.Inventory, required.Key, moved); - AddInventory(site.Inventory, required.Key, moved); - AddInventory(site.DeliveredItems, required.Key, moved); - break; - } - - subTask.Progress = site.RequiredItems.Count == 0 - ? 1f - : site.RequiredItems.Sum(required => - required.Value <= 0.01f - ? 1f - : Math.Clamp(GetInventoryAmount(site.DeliveredItems, required.Key) / required.Value, 0f, 1f)) / site.RequiredItems.Count; - return IsConstructionSiteReady(world, site) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateBuildConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - var station = site is null ? null : ResolveSupportStation(world, ship, site); - if (site is null || station is null || site.BlueprintId is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed) - { - subTask.BlockingReason = "construction-site-missing"; - return SubTaskOutcome.Failed; - } - - var supportPosition = ResolveSupportPosition(ship, station, site, world); - if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold))) - { - ship.State = ShipState.LocalFlight; - ship.TargetPosition = supportPosition; - ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return SubTaskOutcome.Active; - } - - if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe)) - { - ship.State = ShipState.WaitingMaterials; - subTask.Status = WorkStatus.Blocked; - subTask.BlockingReason = "waiting-materials"; - return SubTaskOutcome.Active; - } - - subTask.Status = WorkStatus.Active; - subTask.BlockingReason = null; - ship.TargetPosition = supportPosition; - ship.Position = supportPosition; - ship.State = ShipState.Constructing; - site.AssignedConstructorShipIds.Add(ship.Id); - site.Progress += deltaSeconds * GetSkillFactor(ship.Skills.Construction); - subTask.Progress = recipe.Duration <= 0.01f ? 1f : Math.Clamp(site.Progress / recipe.Duration, 0f, 1f); - if (site.Progress < recipe.Duration) - { - return SubTaskOutcome.Active; - } - - if (site.StationId is null) - { - CompleteStationFoundation(world, station, site); - } - else - { - AddStationModule(world, station, site.BlueprintId); - PrepareNextConstructionSiteStep(world, station, site); - } - - site.State = ConstructionSiteStateKinds.Completed; - return SubTaskOutcome.Completed; - } - - private static bool AdvanceTimedSubTask(ShipSubTaskRuntime subTask, float deltaSeconds, float requiredSeconds) - { - subTask.TotalSeconds = requiredSeconds; - subTask.ElapsedSeconds += deltaSeconds; - subTask.Progress = requiredSeconds <= 0.01f ? 1f : Math.Clamp(subTask.ElapsedSeconds / requiredSeconds, 0f, 1f); - if (subTask.ElapsedSeconds < requiredSeconds) - { - return false; - } - - subTask.ElapsedSeconds = 0f; - return true; - } - - private SubTaskOutcome UpdateLocalTravel( - SimulationWorld world, - ShipRuntime ship, - ShipSubTaskRuntime subTask, - float deltaSeconds, - string targetSystemId, - Vector3 targetPosition, - CelestialRuntime? targetCelestial, - bool completeOnArrival) - { - var distance = ship.Position.DistanceTo(targetPosition); - ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace; - ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; - ship.SpatialState.Transit = null; - ship.SpatialState.DestinationNodeId = targetCelestial?.Id; - subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f); - - if (distance <= MathF.Max(subTask.Threshold, balance.ArrivalThreshold)) - { - ship.Position = targetPosition; - ship.TargetPosition = targetPosition; - ship.SystemId = targetSystemId; - ship.SpatialState.CurrentSystemId = targetSystemId; - ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; - ship.State = ShipState.Arriving; - return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - ship.State = ShipState.LocalFlight; - ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateWarpTransit( - SimulationWorld world, - ShipRuntime ship, - ShipSubTaskRuntime subTask, - float deltaSeconds, - Vector3 targetPosition, - CelestialRuntime targetCelestial, - bool completeOnArrival) - { - var transit = ship.SpatialState.Transit; - if (transit is null || transit.Regime != MovementRegimeKind.Warp || transit.DestinationNodeId != targetCelestial.Id) - { - transit = new ShipTransitRuntime - { - Regime = MovementRegimeKind.Warp, - OriginNodeId = ship.SpatialState.CurrentCelestialId, - DestinationNodeId = targetCelestial.Id, - StartedAtUtc = world.GeneratedAtUtc, - }; - ship.SpatialState.Transit = transit; - subTask.ElapsedSeconds = 0f; - } - - ship.SpatialState.SpaceLayer = SpaceLayerKind.SystemSpace; - ship.SpatialState.MovementRegime = MovementRegimeKind.Warp; - ship.SpatialState.CurrentCelestialId = null; - ship.SpatialState.DestinationNodeId = targetCelestial.Id; - - var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); - if (ship.State != ShipState.Warping) - { - ship.State = ShipState.SpoolingWarp; - if (!AdvanceTimedSubTask(subTask, deltaSeconds, spoolDuration)) - { - return SubTaskOutcome.Active; - } - - ship.State = ShipState.Warping; - } - - var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null - ? ship.Position.DistanceTo(targetPosition) - : (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition))); - ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds); - transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance)); - subTask.Progress = transit.Progress; - if (ship.Position.DistanceTo(targetPosition) > 18f) - { - return SubTaskOutcome.Active; - } - - return CompleteTransitArrival(ship, subTask.TargetSystemId ?? ship.SystemId, targetPosition, targetCelestial, completeOnArrival); - } - - private SubTaskOutcome UpdateFtlTransit( - SimulationWorld world, - ShipRuntime ship, - ShipSubTaskRuntime subTask, - float deltaSeconds, - string targetSystemId, - Vector3 entryPosition, - CelestialRuntime? targetCelestial, - bool completeOnArrival, - Vector3 finalTargetPosition) - { - var destinationNodeId = targetCelestial?.Id; - var transit = ship.SpatialState.Transit; - if (transit is null || transit.Regime != MovementRegimeKind.FtlTransit || transit.DestinationNodeId != destinationNodeId) - { - transit = new ShipTransitRuntime - { - Regime = MovementRegimeKind.FtlTransit, - OriginNodeId = ship.SpatialState.CurrentCelestialId, - DestinationNodeId = destinationNodeId, - StartedAtUtc = world.GeneratedAtUtc, - }; - ship.SpatialState.Transit = transit; - subTask.ElapsedSeconds = 0f; - } - - ship.SpatialState.SpaceLayer = SpaceLayerKind.GalaxySpace; - ship.SpatialState.MovementRegime = MovementRegimeKind.FtlTransit; - ship.SpatialState.CurrentCelestialId = null; - ship.SpatialState.DestinationNodeId = destinationNodeId; - - if (ship.State != ShipState.Ftl) - { - ship.State = ShipState.SpoolingFtl; - if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(ship.Definition.SpoolTime, 0.1f))) - { - return SubTaskOutcome.Active; - } - - ship.State = ShipState.Ftl; - } - - var originSystemPosition = ResolveSystemGalaxyPosition(world, ship.SystemId); - var destinationSystemPosition = ResolveSystemGalaxyPosition(world, targetSystemId); - var totalDistance = MathF.Max(0.001f, originSystemPosition.DistanceTo(destinationSystemPosition)); - transit.Progress = MathF.Min(1f, transit.Progress + ((ship.Definition.FtlSpeed * GetSkillFactor(ship.Skills.Navigation)) * deltaSeconds / totalDistance)); - subTask.Progress = transit.Progress; - if (transit.Progress < 0.999f) - { - return SubTaskOutcome.Active; - } - - ship.Position = entryPosition; - ship.TargetPosition = finalTargetPosition; - ship.SystemId = targetSystemId; - ship.SpatialState.CurrentSystemId = targetSystemId; - ship.SpatialState.Transit = null; - ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace; - ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; - ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; - ship.SpatialState.DestinationNodeId = targetCelestial?.Id; - ship.State = ShipState.Arriving; - return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - private static SubTaskOutcome CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial, bool completeOnArrival) - { - ship.Position = targetPosition; - ship.TargetPosition = targetPosition; - ship.SystemId = targetSystemId; - ship.SpatialState.CurrentSystemId = targetSystemId; - ship.SpatialState.Transit = null; - ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace; - ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; - ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; - ship.SpatialState.DestinationNodeId = targetCelestial?.Id; - ship.State = ShipState.Arriving; - return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ShipSubTaskRuntime subTask) - { - if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) - { - var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - if (ship is not null) - { - return ship.Position; - } - - var station = ResolveStation(world, subTask.TargetEntityId); - if (station is not null) - { - return station.Position; - } - - var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - if (celestial is not null) - { - return celestial.Position; - } - - var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - if (wreck is not null) - { - return wreck.Position; - } - } - - return subTask.TargetPosition ?? Vector3.Zero; - } - - private static CelestialRuntime? ResolveTravelTargetCelestial(SimulationWorld world, ShipSubTaskRuntime subTask, Vector3 targetPosition) - { - if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) - { - var station = ResolveStation(world, subTask.TargetEntityId); - if (station?.CelestialId is not null) - { - return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId); - } - - var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - if (site?.CelestialId is not null) - { - return world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); - } - - var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - if (celestial is not null) - { - return celestial; - } - - if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } wreck) - { - return world.Celestials - .Where(candidate => candidate.SystemId == wreck.SystemId) - .OrderBy(candidate => candidate.Position.DistanceTo(wreck.Position)) - .FirstOrDefault(); - } - } - - return world.Celestials - .Where(candidate => subTask.TargetSystemId is null || candidate.SystemId == subTask.TargetSystemId) - .OrderBy(candidate => candidate.Position.DistanceTo(targetPosition)) - .FirstOrDefault(); - } - - private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship) - { - if (ship.SpatialState.CurrentCelestialId is not null) - { - return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId); - } - - return world.Celestials - .Where(candidate => candidate.SystemId == ship.SystemId) - .OrderBy(candidate => candidate.Position.DistanceTo(ship.Position)) - .FirstOrDefault(); - } - - private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) => - world.Celestials.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star); - - private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) => - world.Systems.FirstOrDefault(candidate => candidate.Definition.Id == systemId)?.Position ?? Vector3.Zero; - - private static float GetLocalTravelSpeed(ShipRuntime ship) => - SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed) * GetSkillFactor(ship.Skills.Navigation); - - private static float GetWarpTravelSpeed(ShipRuntime ship) => - SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed) * GetSkillFactor(ship.Skills.Navigation); - - private static float GetSkillFactor(int skillLevel) => - Math.Clamp(1f + ((skillLevel - 3) * 0.08f), 0.75f, 1.4f); - - private static int GetEffectiveSkillLevel( - SimulationWorld world, - ShipRuntime ship, - Func captainSelector, - Func managerSelector) - { - var captainLevel = captainSelector(ship.Skills); - if (ship.CommanderId is null) - { - return captainLevel; - } - - var shipCommander = world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId); - var manager = shipCommander?.ParentCommanderId is null - ? shipCommander - : world.Commanders.FirstOrDefault(candidate => candidate.Id == shipCommander.ParentCommanderId) ?? shipCommander; - return Math.Clamp((captainLevel + (manager is null ? 3 : managerSelector(manager.Skills)) + 1) / 2, 1, 5); - } - - private static int ResolveBehaviorSystemRange(SimulationWorld world, ShipRuntime ship, string behaviorKind, int explicitRange) - { - if (explicitRange > 0) - { - return explicitRange; - } - - var tradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination); - var miningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); - var combatSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Combat, skills => skills.Strategy); - return behaviorKind switch - { - "local-auto-mine" or "local-auto-trade" => 0, - "advanced-auto-mine" => Math.Clamp(1 + ((miningSkill - 1) / 2), 1, 3), - "advanced-auto-trade" => Math.Clamp(1 + ((tradeSkill - 1) / 2), 1, 3), - "expert-auto-mine" => Math.Clamp(2 + ((miningSkill - 1) / 2), 2, Math.Max(world.Systems.Count - 1, 2)), - "fill-shortages" or "find-build-tasks" or "revisit-known-stations" or "supply-fleet" => Math.Clamp(1 + ((tradeSkill + 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)), - "patrol" or "police" or "protect-position" or "protect-ship" or "protect-station" => Math.Clamp(1 + ((combatSkill - 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)), - _ => Math.Max(world.Systems.Count - 1, 0), - }; - } - - private static int GetSystemDistanceTier(SimulationWorld world, string originSystemId, string targetSystemId) - { - if (string.Equals(originSystemId, targetSystemId, StringComparison.Ordinal)) - { - return 0; - } - - var originPosition = ResolveSystemGalaxyPosition(world, originSystemId); - return world.Systems - .OrderBy(system => system.Position.DistanceTo(originPosition)) - .ThenBy(system => system.Definition.Id, StringComparer.Ordinal) - .Select(system => system.Definition.Id) - .TakeWhile(systemId => !string.Equals(systemId, targetSystemId, StringComparison.Ordinal)) - .Count(); - } - - private static bool IsWithinSystemRange(SimulationWorld world, string originSystemId, string targetSystemId, int maxRange) => - maxRange < 0 || GetSystemDistanceTier(world, originSystemId, targetSystemId) <= maxRange; - - private static float GetShipDamagePerSecond(ShipRuntime ship) => - ship.Definition.Class switch - { - "frigate" => FrigateDps, - "destroyer" => DestroyerDps, - "cruiser" => CruiserDps, - "capital" => CapitalDps, - _ => 4f, - }; - - private static MiningOpportunity? SelectMiningOpportunity( - SimulationWorld world, - ShipRuntime ship, - StationRuntime homeStation, - CommanderAssignmentRuntime? assignment, - string behaviorKind) - { - var policy = ResolvePolicy(world, ship.PolicySetId); - var preferredItemId = assignment?.ItemId ?? ship.DefaultBehavior.PreferredItemId; - var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); - var effectiveMiningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); - string? deniedReason = null; - var opportunity = world.Nodes - .Where(node => - { - if (node.OreRemaining <= 0.01f || !CanExtractNode(ship, node, world) || (preferredItemId is not null && !string.Equals(node.ItemId, preferredItemId, StringComparison.Ordinal))) - { - return false; - } - - if (!TryCheckSystemAllowed(world, policy, ship.FactionId, node.SystemId, "military", out var reason)) - { - deniedReason ??= reason; - return false; - } - - return IsWithinSystemRange(world, homeStation.SystemId, node.SystemId, rangeBudget); - }) - .Select(node => - { - var buyer = SelectBestDeliveryStation(world, ship, node.ItemId, homeStation, behaviorKind); - var demandScore = GetFactionDemandScore(world, ship.FactionId, node.ItemId); - var distancePenalty = GetSystemDistanceTier(world, homeStation.SystemId, node.SystemId) * 18f; - var routeRiskPenalty = GeopoliticalSimulationService.GetSystemRouteRisk(world, node.SystemId, ship.FactionId) * 30f; - var score = (node.SystemId == homeStation.SystemId ? 55f : 0f) - + (node.OreRemaining * 0.025f) - + (demandScore * (string.Equals(behaviorKind, "expert-auto-mine", StringComparison.Ordinal) ? 22f : 12f)) - + (effectiveMiningSkill * 10f) - - distancePenalty - - routeRiskPenalty - - node.Position.DistanceTo(ship.Position); - return new MiningOpportunity(node, buyer, score, $"Mine {node.ItemId} for {buyer.Label}"); - }) - .OrderByDescending(candidate => candidate.Score) - .ThenBy(candidate => candidate.Node.Id, StringComparer.Ordinal) - .FirstOrDefault(); - if (opportunity is null && deniedReason is not null) - { - ship.LastAccessFailureReason = deniedReason; - } - - return opportunity; - } - - private static TradeRoutePlan? SelectTradeRoute( - SimulationWorld world, - ShipRuntime ship, - StationRuntime? homeStation, - string behaviorKind, - bool knownStationsOnly) - { - var policy = ResolvePolicy(world, ship.PolicySetId); - var stationsById = world.Stations - .Where(station => station.FactionId == ship.FactionId) - .ToDictionary(station => station.Id, StringComparer.Ordinal); - var originSystemId = homeStation?.SystemId ?? ship.SystemId; - var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); - var effectiveTradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination); - var requireKnownStations = knownStationsOnly || string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal); - string? deniedReason = null; - - var route = world.MarketOrders - .Where(order => - order.FactionId == ship.FactionId && - order.Kind == MarketOrderKinds.Buy && - order.RemainingAmount > 0.01f) - .Select(order => - { - StationRuntime? destination = null; - ConstructionSiteRuntime? destinationSite = null; - if (order.StationId is not null && stationsById.TryGetValue(order.StationId, out var destinationStation)) - { - destination = destinationStation; - } - else if (order.ConstructionSiteId is not null) - { - destinationSite = world.ConstructionSites.FirstOrDefault(site => site.Id == order.ConstructionSiteId); - if (destinationSite is not null) - { - destination = ResolveSupportStation(world, ship, destinationSite); - } - } - - if (destination is null) - { - return null; - } - - if (!TryCheckSystemAllowed(world, policy, ship.FactionId, destination.SystemId, "trade", out var destinationDeniedReason)) - { - deniedReason ??= destinationDeniedReason; - return null; - } - if (!IsWithinSystemRange(world, originSystemId, destination.SystemId, rangeBudget)) - { - return null; - } - if (requireKnownStations - && ship.KnownStationIds.Count > 0 - && !ship.KnownStationIds.Contains(destination.Id) - && (homeStation is null || !string.Equals(destination.Id, homeStation.Id, StringComparison.Ordinal))) - { - return null; - } - if (string.Equals(behaviorKind, "find-build-tasks", StringComparison.Ordinal) && destinationSite is null) - { - return null; - } - if (!string.Equals(behaviorKind, "find-build-tasks", StringComparison.Ordinal) && destinationSite is not null) - { - return null; - } - - var source = stationsById.Values - .Where(station => - { - if (station.Id == destination.Id || GetInventoryAmount(station.Inventory, order.ItemId) <= GetStationReserveFloor(world, station, order.ItemId) + 1f) - { - return false; - } - - if (!TryCheckSystemAllowed(world, policy, ship.FactionId, station.SystemId, "trade", out var sourceDeniedReason)) - { - deniedReason ??= sourceDeniedReason; - return false; - } - - if (!IsWithinSystemRange(world, originSystemId, station.SystemId, rangeBudget)) - { - return false; - } - - return !requireKnownStations - || ship.KnownStationIds.Count == 0 - || ship.KnownStationIds.Contains(station.Id) - || (homeStation is not null && string.Equals(station.Id, homeStation.Id, StringComparison.Ordinal)); - }) - .OrderByDescending(station => GetInventoryAmount(station.Inventory, order.ItemId) - GetStationReserveFloor(world, station, order.ItemId)) - .ThenByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) - .ThenBy(station => station.Id, StringComparer.Ordinal) - .FirstOrDefault(); - if (source is null) - { - return null; - } - - var shortageBias = string.Equals(behaviorKind, "fill-shortages", StringComparison.Ordinal) - ? GetFactionDemandScore(world, ship.FactionId, order.ItemId) * 35f - : 0f; - var buildBias = destinationSite is null ? 0f : 65f; - var revisitBias = string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal) && ship.KnownStationIds.Contains(source.Id) && ship.KnownStationIds.Contains(destination.Id) - ? 28f - : 0f; - var regionalNeedBias = GetRegionalCommodityPressure(world, ship.FactionId, destination.SystemId, order.ItemId) * 18f; - var systemRangePenalty = (GetSystemDistanceTier(world, originSystemId, source.SystemId) + GetSystemDistanceTier(world, originSystemId, destination.SystemId)) * 16f; - var riskPenalty = - (GeopoliticalSimulationService.GetSystemRouteRisk(world, source.SystemId, ship.FactionId) - + GeopoliticalSimulationService.GetSystemRouteRisk(world, destination.SystemId, ship.FactionId)) * 22f; - var distanceScore = source.Position.DistanceTo(ship.Position) + source.Position.DistanceTo(destination.Position); - var score = (order.Valuation * 50f) - + shortageBias - + buildBias - + revisitBias - + regionalNeedBias - + (effectiveTradeSkill * 12f) - - systemRangePenalty - - riskPenalty - - distanceScore; - var summary = destinationSite is null - ? $"{order.ItemId}: {source.Label} -> {destination.Label}" - : $"{order.ItemId}: {source.Label} -> build support {destination.Label}"; - return new TradeRoutePlan(source, destination, order.ItemId, score, summary); - }) - .Where(route => route is not null) - .Cast() - .OrderByDescending(route => route.Score) - .ThenBy(route => route.ItemId, StringComparer.Ordinal) - .ThenBy(route => route.SourceStation.Id, StringComparer.Ordinal) - .FirstOrDefault(); - if (route is null && deniedReason is not null) - { - ship.LastAccessFailureReason = deniedReason; - } - - return route; - } - - private static FleetSupplyPlan? SelectFleetSupplyPlan(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) - { - var assignment = ResolveAssignment(world, ship); - var targetCandidates = world.Ships - .Where(candidate => - candidate.Id != ship.Id && - candidate.FactionId == ship.FactionId && - candidate.Definition.CargoCapacity > 0.01f && - (assignment?.TargetEntityId is null || string.Equals(candidate.Id, assignment.TargetEntityId, StringComparison.Ordinal))) - .OrderByDescending(candidate => candidate.Definition.Kind == "military" ? 1 : 0) - .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) - .ToList(); - if (targetCandidates.Count == 0) - { - return null; - } - - var sourceStations = world.Stations - .Where(station => station.FactionId == ship.FactionId) - .OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) - .ThenBy(station => station.Id, StringComparer.Ordinal) - .ToList(); - foreach (var target in targetCandidates) - { - var itemId = assignment?.ItemId - ?? sourceStations - .SelectMany(station => station.Inventory) - .Where(entry => entry.Value > 2f) - .OrderByDescending(entry => entry.Value) - .ThenBy(entry => entry.Key, StringComparer.Ordinal) - .Select(entry => entry.Key) - .FirstOrDefault(); - if (itemId is null) - { - continue; - } - - var source = sourceStations.FirstOrDefault(station => GetInventoryAmount(station.Inventory, itemId) > 2f); - if (source is null) - { - continue; - } - - var amount = MathF.Min(MathF.Max(10f, ship.Definition.CargoCapacity * 0.5f), GetInventoryAmount(source.Inventory, itemId)); - return new FleetSupplyPlan(source, target, itemId, amount, MathF.Max(16f, ship.DefaultBehavior.Radius), $"Supply {target.Definition.Label} with {itemId}"); - } - - return null; - } - - private static StationRuntime? SelectKnownStationVisit(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) - { - var candidateIds = ship.KnownStationIds.Count == 0 && homeStation is not null - ? [homeStation.Id] - : ship.KnownStationIds.OrderBy(id => id, StringComparer.Ordinal).ToArray(); - return candidateIds - .Select(id => ResolveStation(world, id)) - .Where(station => station is not null && station.FactionId == ship.FactionId) - .Cast() - .OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) - .ThenBy(station => station.SystemId == ship.SystemId ? 0 : 1) - .ThenBy(station => station.Position.DistanceTo(ship.Position)) - .FirstOrDefault(); - } - - private static StationRuntime SelectBestDeliveryStation(SimulationWorld world, ShipRuntime ship, string itemId, StationRuntime homeStation, string behaviorKind) - { - if (!string.Equals(behaviorKind, "expert-auto-mine", StringComparison.Ordinal)) - { - return homeStation; - } - - return world.Stations - .Where(station => station.FactionId == ship.FactionId) - .OrderByDescending(station => GetFactionDemandScore(world, ship.FactionId, itemId) + GetRegionalCommodityPressure(world, ship.FactionId, station.SystemId, itemId) + (station.Id == homeStation.Id ? 5f : 0f)) - .ThenBy(station => station.SystemId == homeStation.SystemId ? 0 : 1) - .ThenBy(station => station.Id, StringComparer.Ordinal) - .FirstOrDefault() - ?? homeStation; - } - - private static float GetFactionDemandScore(SimulationWorld world, string factionId, string itemId) - { - var signal = CommanderPlanningService.FindFactionEconomicAssessment(world, factionId)? - .CommoditySignals - .FirstOrDefault(candidate => candidate.ItemId == itemId); - var regionalBottleneckScore = world.Geopolitics?.EconomyRegions.Bottlenecks - .Where(bottleneck => string.Equals(bottleneck.ItemId, itemId, StringComparison.Ordinal)) - .Join( - world.Geopolitics.EconomyRegions.Regions.Where(region => string.Equals(region.FactionId, factionId, StringComparison.Ordinal)), - bottleneck => bottleneck.RegionId, - region => region.Id, - (bottleneck, _) => bottleneck.Severity) - .DefaultIfEmpty() - .Max() ?? 0f; - if (signal is null) - { - return regionalBottleneckScore * 8f; - } - - return MathF.Max(0f, signal.BuyBacklog + signal.ReservedForConstruction + MathF.Max(0f, -signal.ProjectedNetRatePerSecond * 50f) + (regionalBottleneckScore * 8f)); - } - - private static float GetRegionalCommodityPressure(SimulationWorld world, string factionId, string systemId, string itemId) - { - var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, systemId); - if (region is null) - { - return 0f; - } - - var bottleneck = world.Geopolitics?.EconomyRegions.Bottlenecks - .FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal) - && string.Equals(candidate.ItemId, itemId, StringComparison.Ordinal)); - var assessment = world.Geopolitics?.EconomyRegions.EconomicAssessments - .FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal)); - return (bottleneck?.Severity ?? 0f) + ((assessment?.ConstructionPressure ?? 0f) * 2f); - } - - private static ThreatTargetCandidate? SelectThreatTarget( - SimulationWorld world, - ShipRuntime ship, - string targetSystemId, - Vector3 anchorPosition, - float radius, - string? excludeEntityId = null) - { - var policy = ResolvePolicy(world, ship.PolicySetId); - return world.Ships - .Where(candidate => - candidate.Id != excludeEntityId && - candidate.Health > 0f && - candidate.FactionId != ship.FactionId && - string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) && - candidate.Position.DistanceTo(anchorPosition) <= radius * 1.75f) - .Select(candidate => new ThreatTargetCandidate( - candidate.Id, - candidate.SystemId, - candidate.Position, - 100f - + (candidate.Definition.Kind == "military" ? 30f : 0f) - - candidate.Position.DistanceTo(anchorPosition) - - candidate.Position.DistanceTo(ship.Position) - + (string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase) ? 12f : 0f))) - .Concat(world.Stations - .Where(candidate => - candidate.Id != excludeEntityId && - candidate.FactionId != ship.FactionId && - string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) && - candidate.Position.DistanceTo(anchorPosition) <= radius * 2f) - .Select(candidate => new ThreatTargetCandidate(candidate.Id, candidate.SystemId, candidate.Position, 45f - candidate.Position.DistanceTo(anchorPosition) * 0.2f))) - .OrderByDescending(candidate => candidate.Score) - .ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal) - .FirstOrDefault(); - } - - private static PoliceContactCandidate? SelectPoliceContact(SimulationWorld world, ShipRuntime ship, string systemId, Vector3 anchorPosition, float radius) - { - var policy = ResolvePolicy(world, ship.PolicySetId); - return world.Ships - .Where(candidate => - candidate.Id != ship.Id && - candidate.Health > 0f && - candidate.FactionId != ship.FactionId && - string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal) && - candidate.Position.DistanceTo(anchorPosition) <= radius * 1.5f) - .Select(candidate => - { - var engage = candidate.Definition.Kind == "military" - || string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase); - var score = (engage ? 80f : 40f) - - candidate.Position.DistanceTo(anchorPosition) - - candidate.Position.DistanceTo(ship.Position) - + (candidate.Definition.Kind == "transport" ? 8f : 0f); - return new PoliceContactCandidate(candidate.Id, candidate.SystemId, candidate.Position, engage, score); - }) - .OrderByDescending(candidate => candidate.Score) - .ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal) - .FirstOrDefault(); - } - - private static SalvageOpportunity? SelectSalvageOpportunity(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) - { - if (homeStation is null) - { - return null; - } - - var rangeBudget = ResolveBehaviorSystemRange(world, ship, "auto-salvage", ship.DefaultBehavior.MaxSystemRange > 0 ? ship.DefaultBehavior.MaxSystemRange : 1); - return world.Wrecks - .Where(wreck => - wreck.RemainingAmount > 0.01f && - IsWithinSystemRange(world, homeStation.SystemId, wreck.SystemId, rangeBudget)) - .Select(wreck => new SalvageOpportunity( - wreck, - (wreck.RemainingAmount * 3f) - wreck.Position.DistanceTo(ship.Position) - (GetSystemDistanceTier(world, homeStation.SystemId, wreck.SystemId) * 25f), - $"Salvage {wreck.ItemId} from {wreck.SourceEntityId}")) - .OrderByDescending(candidate => candidate.Score) - .ThenBy(candidate => candidate.Wreck.Id, StringComparer.Ordinal) - .FirstOrDefault(); - } - - private static (string SystemId, Vector3 Position)? ResolveObjectTarget(SimulationWorld world, string? entityId) - { - if (entityId is null) - { - return null; - } - - if (world.Ships.FirstOrDefault(candidate => candidate.Id == entityId) is { } ship) - { - return (ship.SystemId, ship.Position); - } - - if (ResolveStation(world, entityId) is { } station) - { - return (station.SystemId, station.Position); - } - - if (world.Celestials.FirstOrDefault(candidate => candidate.Id == entityId) is { } celestial) - { - return (celestial.SystemId, celestial.Position); - } - - if (world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId) is { } site) - { - var position = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? Vector3.Zero; - return (site.SystemId, position); - } - - if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == entityId) is { } wreck) - { - return (wreck.SystemId, wreck.Position); - } - - return null; - } - - private static Vector3 GetFormationPosition(Vector3 anchorPosition, string seed, float radius) - { - var hash = Math.Abs(seed.Aggregate(17, (acc, c) => (acc * 31) + c)); - var angle = (hash % 360) * (MathF.PI / 180f); - return new Vector3( - anchorPosition.X + (MathF.Cos(angle) * radius), - anchorPosition.Y, - anchorPosition.Z + (MathF.Sin(angle) * radius)); - } - - private static TradeRoutePlan? ResolveTradeRoute(SimulationWorld world, string itemId, string sourceStationId, string destinationStationId) - { - var source = ResolveStation(world, sourceStationId); - var destination = ResolveStation(world, destinationStationId); - return source is null || destination is null ? null : new TradeRoutePlan(source, destination, itemId, 0f, $"{itemId}: {source.Label} -> {destination.Label}"); - } - - private static StationRuntime? ResolveStation(SimulationWorld world, string? stationId) => - stationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == stationId); - - private static ResourceNodeRuntime? ResolveNode(SimulationWorld world, string? nodeId) => - nodeId is null ? null : world.Nodes.FirstOrDefault(candidate => candidate.Id == nodeId); - - private static PolicySetRuntime? ResolvePolicy(SimulationWorld world, string? policySetId) => - policySetId is null ? null : world.Policies.FirstOrDefault(policy => policy.Id == policySetId); - - private static bool IsSystemAllowed( - SimulationWorld world, - PolicySetRuntime? policy, - string factionId, - string systemId, - string accessKind) => - TryCheckSystemAllowed(world, policy, factionId, systemId, accessKind, out _); - - private static bool TryCheckSystemAllowed( - SimulationWorld world, - PolicySetRuntime? policy, - string factionId, - string systemId, - string accessKind, - out string? denialReason) - { - denialReason = null; - if (policy?.BlacklistedSystemIds.Contains(systemId) == true) - { - denialReason = $"blacklisted:{systemId}"; - return false; - } - - var controlState = GeopoliticalSimulationService.GetSystemControlState(world, systemId); - var authorityFactionId = controlState?.ControllerFactionId ?? controlState?.PrimaryClaimantFactionId; - if (authorityFactionId is null || string.Equals(authorityFactionId, factionId, StringComparison.Ordinal)) - { - return true; - } - - var hasAccess = string.Equals(accessKind, "trade", StringComparison.Ordinal) - ? GeopoliticalSimulationService.HasTradeAccess(world, factionId, authorityFactionId) - : GeopoliticalSimulationService.HasMilitaryAccess(world, factionId, authorityFactionId); - if (!hasAccess) - { - denialReason = $"{accessKind}-access-denied:{authorityFactionId}"; - return false; - } - - if (policy?.AvoidHostileSystems != true) - { - return true; - } - - if (GeopoliticalSimulationService.HasHostileRelation(world, factionId, authorityFactionId)) - { - denialReason = $"hostile-authority:{authorityFactionId}"; - return false; - } - - var hostileInfluencer = controlState?.InfluencingFactionIds.FirstOrDefault(candidate => - !string.Equals(candidate, factionId, StringComparison.Ordinal) - && GeopoliticalSimulationService.HasHostileRelation(world, factionId, candidate)); - if (hostileInfluencer is not null) - { - denialReason = $"hostile-influence:{hostileInfluencer}"; - return false; - } - - return true; - } - - private static CommanderAssignmentRuntime? ResolveAssignment(SimulationWorld world, ShipRuntime ship) => - ship.CommanderId is null - ? null - : world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment; - - private static ShipOrderRuntime? GetTopOrder(ShipRuntime ship) => - ship.OrderQueue - .Where(order => order.Status is OrderStatus.Queued or OrderStatus.Active) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .FirstOrDefault(); - - private static ShipPlanStepRuntime? GetCurrentStep(ShipPlanRuntime? plan) => - plan is null || plan.CurrentStepIndex >= plan.Steps.Count ? null : plan.Steps[plan.CurrentStepIndex]; - - private static StationRuntime? ResolveSupportStation(SimulationWorld world, ShipRuntime ship, ConstructionSiteRuntime site) - { - return ResolveStation(world, ResolveAssignment(world, ship)?.HomeStationId ?? ship.DefaultBehavior.HomeStationId) - ?? world.Stations - .Where(station => station.FactionId == ship.FactionId) - .OrderByDescending(station => station.SystemId == site.SystemId ? 1 : 0) - .ThenBy(station => station.Id, StringComparer.Ordinal) - .FirstOrDefault(); - } - - private static Vector3 ResolveSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world) - { - if (ship.DockedStationId is not null) - { - return GetShipDockedPosition(ship, station); - } - - if (site?.StationId is null && site is not null) - { - var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position; - return GetResourceHoldPosition(anchorPosition, ship.Id, 78f); - } - - return GetConstructionHoldPosition(station, ship.Id); - } - - private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) => - ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f); - - private static void TrackHistory(ShipRuntime ship) - { - var plan = ship.ActivePlan; - var step = GetCurrentStep(plan); - var subTask = step is null || step.CurrentSubTaskIndex >= step.SubTasks.Count ? null : step.SubTasks[step.CurrentSubTaskIndex]; - var signature = $"{ship.State.ToContractValue()}|{plan?.Kind ?? "none"}|{step?.Kind ?? "none"}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}"; - if (ship.LastSignature == signature) - { - return; - } - - ship.LastSignature = signature; - ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} plan={plan?.Kind ?? "none"} step={step?.Kind ?? "none"} subTask={subTask?.Kind ?? "none"} cargo={GetShipCargoAmount(ship):0.#}"); - if (ship.History.Count > 24) - { - ship.History.RemoveAt(0); - } - } - - private static void EmitStateEvents(ShipRuntime ship, ShipState previousState, string? previousPlanId, string? previousStepId, ICollection events) - { - var currentPlanId = ship.ActivePlan?.Id; - var currentStepId = GetCurrentStep(ship.ActivePlan)?.Id; - var occurredAtUtc = DateTimeOffset.UtcNow; - if (previousState != ship.State) - { - events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} state {previousState.ToContractValue()} -> {ship.State.ToContractValue()}.", occurredAtUtc)); - } - - if (!string.Equals(previousPlanId, currentPlanId, StringComparison.Ordinal)) - { - events.Add(new SimulationEventRecord("ship", ship.Id, "plan-changed", $"{ship.Definition.Label} switched active plan.", occurredAtUtc)); - } - - if (!string.Equals(previousStepId, currentStepId, StringComparison.Ordinal)) - { - events.Add(new SimulationEventRecord("ship", ship.Id, "step-changed", $"{ship.Definition.Label} advanced plan step.", occurredAtUtc)); - } - } - - private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site) - { - var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); - if (anchor is null || site.BlueprintId is null) - { - site.State = ConstructionSiteStateKinds.Destroyed; - return; - } - - var station = new StationRuntime - { - Id = $"station-{world.Stations.Count + 1}", - SystemId = site.SystemId, - Label = BuildFoundedStationLabel(site.TargetDefinitionId), - Category = "station", - Objective = DetermineFoundationObjective(site.TargetDefinitionId), - Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color, - Position = anchor.Position, - FactionId = site.FactionId, - CelestialId = site.CelestialId, - Health = 600f, - MaxHealth = 600f, - }; - - foreach (var moduleId in GetFoundationModules(world, site.BlueprintId)) - { - AddStationModule(world, station, moduleId); - } - - world.Stations.Add(station); - StationLifecycleService.EnsureStationCommander(world, station); - anchor.OccupyingStructureId = station.Id; - site.StationId = station.Id; - PrepareNextConstructionSiteStep(world, station, site); - } - - private static IReadOnlyList GetFoundationModules(SimulationWorld world, string primaryModuleId) - { - var modules = new List { "module_arg_dock_m_01_lowtech" }; - foreach (var itemId in world.ProductionGraph.OutputsByModuleId.GetValueOrDefault(primaryModuleId, [])) - { - if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) - { - var storageModule = GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoKind); - if (storageModule is not null && !modules.Contains(storageModule, StringComparer.Ordinal)) - { - modules.Add(storageModule); - } - } - } - - if (!modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal)) - { - modules.Add("module_arg_stor_container_m_01"); - } - - if (!string.Equals(primaryModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal)) - { - modules.Add("module_gen_prod_energycells_01"); - } - - modules.Add(primaryModuleId); - return modules.Distinct(StringComparer.Ordinal).ToList(); - } - - private static string DetermineFoundationObjective(string commodityId) => - commodityId switch - { - "energycells" => "power", - "water" => "water", - "refinedmetals" => "refinery", - "hullparts" => "hullparts", - "claytronics" => "claytronics", - "shipyard" => "shipyard", - _ => "general", - }; - - private static string BuildFoundedStationLabel(string commodityId) => - $"{char.ToUpperInvariant(commodityId[0])}{commodityId[1..]} Foundry"; - - private enum SubTaskOutcome - { - Active, - Completed, - Failed, - } - - private sealed record TradeRoutePlan( - StationRuntime SourceStation, - StationRuntime DestinationStation, - string ItemId, - float Score, - string Summary); - - private sealed record MiningOpportunity( - ResourceNodeRuntime Node, - StationRuntime DropOffStation, - float Score, - string Summary); - - private sealed record FleetSupplyPlan( - StationRuntime SourceStation, - ShipRuntime TargetShip, - string ItemId, - float Amount, - float Radius, - string Summary); - - private sealed record ThreatTargetCandidate( - string EntityId, - string SystemId, - Vector3 Position, - float Score); - - private sealed record PoliceContactCandidate( - string EntityId, - string SystemId, - Vector3 Position, - bool Engage, - float Score); - - private sealed record SalvageOpportunity( - WreckRuntime Wreck, - float Score, - string Summary); -} diff --git a/apps/backend/Ships/Simulation/ShipBootstrapPolicy.cs b/apps/backend/Ships/Simulation/ShipBootstrapPolicy.cs deleted file mode 100644 index 523fb84..0000000 --- a/apps/backend/Ships/Simulation/ShipBootstrapPolicy.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace SpaceGame.Api.Ships.Simulation; - -internal static class ShipBootstrapPolicy -{ - internal static ShipSkillProfileRuntime CreateSkills(ShipDefinition definition) - { - return definition.Kind switch - { - "transport" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 4, Mining = 1, Combat = 1, Construction = 1 }, - "construction" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 1, Combat = 1, Construction = 4 }, - "military" => new ShipSkillProfileRuntime { Navigation = 4, Trade = 1, Mining = 1, Combat = 4, Construction = 1 }, - _ when SpaceGame.Api.Universe.Scenario.LoaderSupport.HasCapabilities(definition, "mining") => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 4, Combat = 1, Construction = 1 }, - _ => new ShipSkillProfileRuntime { Navigation = 3, Trade = 2, Mining = 1, Combat = 1, Construction = 1 }, - }; - } -} diff --git a/apps/backend/Simulation/Core/SimulationEngine.cs b/apps/backend/Simulation/Core/SimulationEngine.cs index 3692ba6..aa0cd16 100644 --- a/apps/backend/Simulation/Core/SimulationEngine.cs +++ b/apps/backend/Simulation/Core/SimulationEngine.cs @@ -3,6 +3,7 @@ namespace SpaceGame.Api.Simulation.Core; internal sealed class SimulationEngine { private readonly IBalanceService _balance; + private readonly IPlayerStateStore _playerStateStore; private readonly OrbitalSimulationOptions _orbitalSimulation; private readonly OrbitalStateUpdater _orbitalStateUpdater; private readonly InfrastructureSimulationService _infrastructureSimulation; @@ -14,9 +15,10 @@ internal sealed class SimulationEngine private readonly ShipAiService _shipAi; private readonly SimulationProjectionService _projection; - internal SimulationEngine(OrbitalSimulationOptions orbitalSimulation, IBalanceService balance) + internal SimulationEngine(OrbitalSimulationOptions orbitalSimulation, IBalanceService balance, IPlayerStateStore playerStateStore) { _balance = balance; + _playerStateStore = playerStateStore; _orbitalSimulation = orbitalSimulation; _orbitalStateUpdater = new OrbitalStateUpdater(_orbitalSimulation); _infrastructureSimulation = new InfrastructureSimulationService(); @@ -42,8 +44,8 @@ internal sealed class SimulationEngine _infrastructureSimulation.UpdateClaims(world, events); _infrastructureSimulation.UpdateConstructionSites(world, events); _geopolitics.Update(world, simulationDeltaSeconds, events); - _commanderPlanning.UpdateCommanders(world, simulationDeltaSeconds, events); - _playerFaction.Update(world, simulationDeltaSeconds, events); + _commanderPlanning.UpdateCommanders(world, _playerStateStore, simulationDeltaSeconds, events); + _playerFaction.Update(world, _playerStateStore, simulationDeltaSeconds, events); _stationLifecycle.UpdateStations(world, simulationDeltaSeconds, events); foreach (var ship in world.Ships.ToList()) @@ -76,7 +78,7 @@ internal sealed class SimulationEngine { foreach (var ship in world.Ships.Where(candidate => candidate.Health <= 0f).ToList()) { - CreateWreck(world, "ship", ship.Id, ship.SystemId, ship.Position, ship.Definition.CargoCapacity + (ship.Definition.MaxHealth * 0.08f)); + CreateWreck(world, "ship", ship.Id, ship.SystemId, ship.Position, ship.Definition.GetTotalCargoCapacity() + (ship.Definition.Hull * 0.08f)); world.Ships.Remove(ship); if (ship.DockedStationId is not null && world.Stations.FirstOrDefault(station => station.Id == ship.DockedStationId) is { } dockedStation) { @@ -94,7 +96,7 @@ internal sealed class SimulationEngine commander.IsAlive = false; } - events.Add(new SimulationEventRecord("ship", ship.Id, "destroyed", $"{ship.Definition.Label} was destroyed.", DateTimeOffset.UtcNow)); + events.Add(new SimulationEventRecord("ship", ship.Id, "destroyed", $"{ship.Definition.Name} was destroyed.", DateTimeOffset.UtcNow)); } foreach (var station in world.Stations.Where(candidate => candidate.Health <= 0f).ToList()) diff --git a/apps/backend/Simulation/Core/SimulationProjectionService.cs b/apps/backend/Simulation/Core/SimulationProjectionService.cs index eb8a174..6feb72f 100644 --- a/apps/backend/Simulation/Core/SimulationProjectionService.cs +++ b/apps/backend/Simulation/Core/SimulationProjectionService.cs @@ -32,7 +32,6 @@ internal sealed class SimulationProjectionService BuildPolicyDeltas(world), BuildShipDeltas(world), BuildFactionDeltas(world), - BuildPlayerFactionDelta(world), BuildGeopoliticsDelta(world)); public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence) @@ -177,9 +176,9 @@ internal sealed class SimulationProjectionService policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot( ship.Id, - ship.Label, - ship.Kind, - ship.Class, + ship.Name, + ship.Purpose, + ship.Type, ship.SystemId, ship.LocalPosition, ship.LocalVelocity, @@ -225,7 +224,6 @@ internal sealed class SimulationProjectionService faction.StrategicState, faction.DecisionLog, faction.Commanders)).ToList(), - ToPlayerFactionSnapshot(world.PlayerFaction), ToGeopoliticalStateSnapshot(world.Geopolitics)); } @@ -276,11 +274,6 @@ internal sealed class SimulationProjectionService faction.LastDeltaSignature = BuildFactionSignature(faction, FindFactionCommander(world, faction.Id)); } - if (world.PlayerFaction is not null) - { - world.PlayerFaction.LastDeltaSignature = BuildPlayerFactionSignature(world.PlayerFaction); - } - if (world.Geopolitics is not null) { world.Geopolitics.LastDeltaSignature = BuildGeopoliticalSignature(world.Geopolitics); @@ -450,23 +443,6 @@ internal sealed class SimulationProjectionService return deltas; } - private static PlayerFactionSnapshot? BuildPlayerFactionDelta(SimulationWorld world) - { - if (world.PlayerFaction is null) - { - return null; - } - - var signature = BuildPlayerFactionSignature(world.PlayerFaction); - if (signature == world.PlayerFaction.LastDeltaSignature) - { - return null; - } - - world.PlayerFaction.LastDeltaSignature = signature; - return ToPlayerFactionSnapshot(world.PlayerFaction); - } - private static GeopoliticalStateSnapshot? BuildGeopoliticsDelta(SimulationWorld world) { if (world.Geopolitics is null) @@ -544,11 +520,13 @@ internal sealed class SimulationProjectionService ship.TargetPosition.Z.ToString("0.###"), ship.State.ToContractValue(), string.Join(",", ship.OrderQueue - .OrderByDescending(order => order.Priority) + .OrderByDescending(GetOrderSourcePriority) + .ThenByDescending(order => order.Priority) .ThenBy(order => order.CreatedAtUtc) - .Select(order => $"{order.Id}:{order.Kind}:{order.Status.ToContractValue()}:{order.Priority}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")), + .Select(order => $"{order.Id}:{order.Kind}:{order.SourceKind.ToContractValue()}:{order.SourceId}:{order.Status.ToContractValue()}:{order.Priority}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")), ship.DefaultBehavior.Kind, ship.DefaultBehavior.TargetEntityId ?? "none", + ship.DefaultBehavior.ItemId ?? "none", ship.DefaultBehavior.TargetPosition?.X.ToString("0.###") ?? "none", ship.DefaultBehavior.TargetPosition?.Y.ToString("0.###") ?? "none", ship.DefaultBehavior.TargetPosition?.Z.ToString("0.###") ?? "none", @@ -642,59 +620,6 @@ internal sealed class SimulationProjectionService return $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}|{assignmentSig}|{strategicSig}|{doctrineSig}|{decisionSig}|{theaterSig}|{campaignSig}|{objectiveSig}|{reservationSig}|{productionSig}"; } - private static string BuildPlayerFactionSignature(PlayerFactionRuntime player) - { - var intentSig = $"{player.StrategicIntent.StrategicPosture}:{player.StrategicIntent.EconomicPosture}:{player.StrategicIntent.MilitaryPosture}:{player.StrategicIntent.LogisticsPosture}:{player.StrategicIntent.DesiredReserveRatio:0.###}"; - var registrySig = string.Join("|", - player.AssetRegistry.ShipIds.Count, - player.AssetRegistry.StationIds.Count, - player.AssetRegistry.CommanderIds.Count, - player.AssetRegistry.FleetIds.Count, - player.AssetRegistry.TaskForceIds.Count, - player.AssetRegistry.StationGroupIds.Count, - player.AssetRegistry.EconomicRegionIds.Count, - player.AssetRegistry.FrontIds.Count, - player.AssetRegistry.ReserveIds.Count); - var orgSig = string.Join("|", - player.Fleets.Count, - player.TaskForces.Count, - player.StationGroups.Count, - player.EconomicRegions.Count, - player.Fronts.Count, - player.Reserves.Count, - player.Policies.Count, - player.AutomationPolicies.Count, - player.ReinforcementPolicies.Count, - player.ProductionPrograms.Count, - player.Directives.Count, - player.Assignments.Count, - player.Alerts.Count); - var policySig = string.Join(";", - player.Policies.OrderBy(policy => policy.Id, StringComparer.Ordinal) - .Select(policy => $"{policy.Id}:{policy.ScopeKind}:{policy.ScopeId}:{policy.PolicySetId}:{policy.TradeAccessPolicy}:{policy.DockingAccessPolicy}:{policy.ConstructionAccessPolicy}:{policy.OperationalRangePolicy}:{policy.CombatEngagementPolicy}:{policy.AvoidHostileSystems}:{policy.FleeHullRatio:0.###}:{policy.UpdatedAtUtc.UtcTicks}")); - var automationSig = string.Join(";", - player.AutomationPolicies.OrderBy(policy => policy.Id, StringComparer.Ordinal) - .Select(policy => $"{policy.Id}:{policy.ScopeKind}:{policy.ScopeId}:{policy.Enabled}:{policy.BehaviorKind}:{policy.UseOrders}:{policy.StagingOrderKind}:{policy.MaxSystemRange}:{policy.KnownStationsOnly}:{policy.Radius:0.###}:{policy.WaitSeconds:0.###}:{policy.PreferredItemId}:{policy.UpdatedAtUtc.UtcTicks}")); - var directiveSig = string.Join(";", - player.Directives.OrderBy(directive => directive.Id, StringComparer.Ordinal) - .Select(directive => $"{directive.Id}:{directive.ScopeKind}:{directive.ScopeId}:{directive.Kind}:{directive.BehaviorKind}:{directive.UseOrders}:{directive.StagingOrderKind}:{directive.TargetEntityId}:{directive.TargetSystemId}:{directive.ItemId}:{directive.Priority}:{directive.UpdatedAtUtc.UtcTicks}")); - var assignmentSig = string.Join(";", - player.Assignments.OrderBy(assignment => assignment.Id, StringComparer.Ordinal) - .Select(assignment => $"{assignment.Id}:{assignment.AssetKind}:{assignment.AssetId}:{assignment.FleetId}:{assignment.TaskForceId}:{assignment.StationGroupId}:{assignment.EconomicRegionId}:{assignment.FrontId}:{assignment.ReserveId}:{assignment.DirectiveId}:{assignment.PolicyId}:{assignment.AutomationPolicyId}:{assignment.Role}:{assignment.Status}:{assignment.UpdatedAtUtc.UtcTicks}")); - var decisionSig = string.Join(",", player.DecisionLog.Select(entry => entry.Id)); - var orgDetailSig = string.Join(";", - player.Fleets.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"fleet:{entry.Id}:{entry.FrontId}:{entry.HomeSystemId}:{entry.HomeStationId}:{entry.CommanderId}:{entry.UpdatedAtUtc.UtcTicks}") - .Concat(player.TaskForces.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"task-force:{entry.Id}:{entry.FleetId}:{entry.FrontId}:{entry.CommanderId}:{entry.UpdatedAtUtc.UtcTicks}")) - .Concat(player.StationGroups.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"station-group:{entry.Id}:{entry.EconomicRegionId}:{entry.UpdatedAtUtc.UtcTicks}")) - .Concat(player.EconomicRegions.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"economic-region:{entry.Id}:{entry.SharedEconomicRegionId}:{entry.Role}:{entry.UpdatedAtUtc.UtcTicks}")) - .Concat(player.Fronts.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"front:{entry.Id}:{entry.SharedFrontLineId}:{entry.TargetFactionId}:{entry.Priority:0.###}:{entry.UpdatedAtUtc.UtcTicks}")) - .Concat(player.Reserves.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"reserve:{entry.Id}:{entry.HomeSystemId}:{entry.UpdatedAtUtc.UtcTicks}"))); - var alertSig = string.Join(";", - player.Alerts.OrderBy(alert => alert.Id, StringComparer.Ordinal) - .Select(alert => $"{alert.Id}:{alert.Kind}:{alert.Severity}:{alert.AssetKind}:{alert.AssetId}:{alert.RelatedDirectiveId}:{alert.Status}:{alert.CreatedAtUtc.UtcTicks}")); - return $"{player.SovereignFactionId}|{player.Status}|{intentSig}|{registrySig}|{orgSig}|{policySig}|{automationSig}|{directiveSig}|{assignmentSig}|{decisionSig}|{orgDetailSig}|{alertSig}"; - } - private static string BuildGeopoliticalSignature(GeopoliticalStateRuntime state) { var diplomacySig = string.Join(";", @@ -882,9 +807,9 @@ internal sealed class SimulationProjectionService return new ShipDelta( ship.Id, - ship.Definition.Label, - ship.Definition.Kind, - ship.Definition.Class, + ship.Definition.Name, + ship.Definition.Purpose.ToDataValue(), + ship.Definition.Type.ToDataValue(), ship.SystemId, ToDto(ship.Position), ToDto(ship.Velocity), @@ -906,7 +831,7 @@ internal sealed class SimulationProjectionService ship.DockedStationId, ship.CommanderId, ship.PolicySetId, - ship.Definition.CargoCapacity, + ship.Definition.GetTotalCargoCapacity(), ToShipTravelSpeed(ship).Speed, ToShipTravelSpeed(ship).Unit, @@ -936,11 +861,14 @@ internal sealed class SimulationProjectionService private static IReadOnlyList ToShipOrderSnapshots(ShipRuntime ship) => ship.OrderQueue - .OrderByDescending(order => order.Priority) + .OrderByDescending(GetOrderSourcePriority) + .ThenByDescending(order => order.Priority) .ThenBy(order => order.CreatedAtUtc) .Select(order => new ShipOrderSnapshot( order.Id, order.Kind, + order.SourceKind.ToContractValue(), + order.SourceId, order.Status.ToContractValue(), order.Priority, order.InterruptCurrentPlan, @@ -962,6 +890,14 @@ internal sealed class SimulationProjectionService order.FailureReason)) .ToList(); + private static int GetOrderSourcePriority(ShipOrderRuntime order) => order.SourceKind switch + { + ShipOrderSourceKind.Player => 300, + ShipOrderSourceKind.Commander => 200, + ShipOrderSourceKind.Behavior => 100, + _ => 0, + }; + private static DefaultBehaviorSnapshot ToDefaultBehaviorSnapshot(DefaultBehaviorRuntime behavior) => new( behavior.Kind, @@ -969,7 +905,7 @@ internal sealed class SimulationProjectionService behavior.HomeStationId, behavior.AreaSystemId, behavior.TargetEntityId, - behavior.PreferredItemId, + behavior.ItemId, behavior.PreferredNodeId, behavior.PreferredConstructionSiteId, behavior.PreferredModuleId, @@ -1385,252 +1321,6 @@ internal sealed class SimulationProjectionService entry.OccurredAtUtc)) .ToList(); - private static PlayerFactionSnapshot? ToPlayerFactionSnapshot(PlayerFactionRuntime? player) - { - if (player is null) - { - return null; - } - - return new PlayerFactionSnapshot( - player.Id, - player.Label, - player.SovereignFactionId, - player.Status, - player.CreatedAtUtc, - player.UpdatedAtUtc, - new PlayerAssetRegistrySnapshot( - player.AssetRegistry.ShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.CommanderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.ClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.ConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.PolicySetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.EconomicRegionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList()), - new PlayerStrategicIntentSnapshot( - player.StrategicIntent.StrategicPosture, - player.StrategicIntent.EconomicPosture, - player.StrategicIntent.MilitaryPosture, - player.StrategicIntent.LogisticsPosture, - player.StrategicIntent.DesiredReserveRatio, - player.StrategicIntent.AllowDelegatedCombatAutomation, - player.StrategicIntent.AllowDelegatedEconomicAutomation, - player.StrategicIntent.Notes), - player.Fleets.Select(fleet => new PlayerFleetSnapshot( - fleet.Id, - fleet.Label, - fleet.Status, - fleet.Role, - fleet.CommanderId, - fleet.FrontId, - fleet.HomeSystemId, - fleet.HomeStationId, - fleet.PolicyId, - fleet.AutomationPolicyId, - fleet.ReinforcementPolicyId, - fleet.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - fleet.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - fleet.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - fleet.UpdatedAtUtc)).ToList(), - player.TaskForces.Select(taskForce => new PlayerTaskForceSnapshot( - taskForce.Id, - taskForce.Label, - taskForce.Status, - taskForce.Role, - taskForce.FleetId, - taskForce.CommanderId, - taskForce.FrontId, - taskForce.PolicyId, - taskForce.AutomationPolicyId, - taskForce.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - taskForce.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - taskForce.UpdatedAtUtc)).ToList(), - player.StationGroups.Select(group => new PlayerStationGroupSnapshot( - group.Id, - group.Label, - group.Status, - group.Role, - group.EconomicRegionId, - group.PolicyId, - group.AutomationPolicyId, - group.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - group.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - group.FocusItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - group.UpdatedAtUtc)).ToList(), - player.EconomicRegions.Select(region => new PlayerEconomicRegionSnapshot( - region.Id, - region.Label, - region.Status, - region.Role, - region.SharedEconomicRegionId, - region.PolicyId, - region.AutomationPolicyId, - region.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - region.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - region.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - region.UpdatedAtUtc)).ToList(), - player.Fronts.Select(front => new PlayerFrontSnapshot( - front.Id, - front.Label, - front.Status, - front.Priority, - front.Posture, - front.SharedFrontLineId, - front.TargetFactionId, - front.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - front.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - front.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - front.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - front.UpdatedAtUtc)).ToList(), - player.Reserves.Select(reserve => new PlayerReserveGroupSnapshot( - reserve.Id, - reserve.Label, - reserve.Status, - reserve.ReserveKind, - reserve.HomeSystemId, - reserve.PolicyId, - reserve.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - reserve.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - reserve.UpdatedAtUtc)).ToList(), - player.Policies.Select(policy => new PlayerFactionPolicySnapshot( - policy.Id, - policy.Label, - policy.ScopeKind, - policy.ScopeId, - policy.PolicySetId, - policy.AllowDelegatedCombat, - policy.AllowDelegatedTrade, - policy.ReserveCreditsRatio, - policy.ReserveMilitaryRatio, - policy.TradeAccessPolicy, - policy.DockingAccessPolicy, - policy.ConstructionAccessPolicy, - policy.OperationalRangePolicy, - policy.CombatEngagementPolicy, - policy.AvoidHostileSystems, - policy.FleeHullRatio, - policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - policy.Notes, - policy.UpdatedAtUtc)).ToList(), - player.AutomationPolicies.Select(policy => new PlayerAutomationPolicySnapshot( - policy.Id, - policy.Label, - policy.ScopeKind, - policy.ScopeId, - policy.Enabled, - policy.BehaviorKind, - policy.UseOrders, - policy.StagingOrderKind, - policy.MaxSystemRange, - policy.KnownStationsOnly, - policy.Radius, - policy.WaitSeconds, - policy.PreferredItemId, - policy.Notes, - policy.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(), - policy.UpdatedAtUtc)).ToList(), - player.ReinforcementPolicies.Select(policy => new PlayerReinforcementPolicySnapshot( - policy.Id, - policy.Label, - policy.ScopeKind, - policy.ScopeId, - policy.ShipKind, - policy.DesiredAssetCount, - policy.MinimumReserveCount, - policy.AutoTransferReserves, - policy.AutoQueueProduction, - policy.SourceReserveId, - policy.TargetFrontId, - policy.Notes, - policy.UpdatedAtUtc)).ToList(), - player.ProductionPrograms.Select(program => new PlayerProductionProgramSnapshot( - program.Id, - program.Label, - program.Status, - program.Kind, - program.TargetShipKind, - program.TargetModuleId, - program.TargetItemId, - program.TargetCount, - program.CurrentCount, - program.StationGroupId, - program.ReinforcementPolicyId, - program.Notes, - program.UpdatedAtUtc)).ToList(), - player.Directives.Select(directive => new PlayerDirectiveSnapshot( - directive.Id, - directive.Label, - directive.Status, - directive.Kind, - directive.ScopeKind, - directive.ScopeId, - directive.TargetEntityId, - directive.TargetSystemId, - directive.TargetPosition is null ? null : ToDto(directive.TargetPosition.Value), - directive.HomeSystemId, - directive.HomeStationId, - directive.SourceStationId, - directive.DestinationStationId, - directive.BehaviorKind, - directive.UseOrders, - directive.StagingOrderKind, - directive.ItemId, - directive.PreferredNodeId, - directive.PreferredConstructionSiteId, - directive.PreferredModuleId, - directive.Priority, - directive.Radius, - directive.WaitSeconds, - directive.MaxSystemRange, - directive.KnownStationsOnly, - directive.PatrolPoints.Select(ToDto).ToList(), - directive.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(), - directive.PolicyId, - directive.AutomationPolicyId, - directive.Notes, - directive.CreatedAtUtc, - directive.UpdatedAtUtc)).ToList(), - player.Assignments.Select(assignment => new PlayerAssignmentSnapshot( - assignment.Id, - assignment.AssetKind, - assignment.AssetId, - assignment.FleetId, - assignment.TaskForceId, - assignment.StationGroupId, - assignment.EconomicRegionId, - assignment.FrontId, - assignment.ReserveId, - assignment.DirectiveId, - assignment.PolicyId, - assignment.AutomationPolicyId, - assignment.Role, - assignment.Status, - assignment.UpdatedAtUtc)).ToList(), - player.DecisionLog.Select(entry => new PlayerDecisionLogEntrySnapshot( - entry.Id, - entry.Kind, - entry.Summary, - entry.RelatedEntityKind, - entry.RelatedEntityId, - entry.OccurredAtUtc)).ToList(), - player.Alerts.Select(alert => new PlayerAlertSnapshot( - alert.Id, - alert.Kind, - alert.Severity, - alert.Summary, - alert.AssetKind, - alert.AssetId, - alert.RelatedDirectiveId, - alert.Status, - alert.CreatedAtUtc)).ToList()); - } - private static GeopoliticalStateSnapshot? ToGeopoliticalStateSnapshot(GeopoliticalStateRuntime? state) { if (state is null) diff --git a/apps/backend/SpaceGame.Api.csproj b/apps/backend/SpaceGame.Api.csproj index 134b0f4..bab7aba 100644 --- a/apps/backend/SpaceGame.Api.csproj +++ b/apps/backend/SpaceGame.Api.csproj @@ -9,6 +9,8 @@ + + diff --git a/apps/backend/Stations/Simulation/StationLifecycleService.cs b/apps/backend/Stations/Simulation/StationLifecycleService.cs index b4ca6af..599c3a9 100644 --- a/apps/backend/Stations/Simulation/StationLifecycleService.cs +++ b/apps/backend/Stations/Simulation/StationLifecycleService.cs @@ -1,5 +1,6 @@ using SpaceGame.Api.Shared.Runtime; -using SpaceGame.Api.Ships.Simulation; +using SpaceGame.Api.Ships.AI; +using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; namespace SpaceGame.Api.Stations.Simulation; @@ -81,7 +82,7 @@ internal sealed class StationLifecycleService SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition), DefaultBehavior = CreateSpawnedShipBehavior(definition, station), Skills = ShipBootstrapPolicy.CreateSkills(definition), - Health = definition.MaxHealth, + Health = definition.Hull, }; world.Ships.Add(ship); @@ -91,7 +92,7 @@ internal sealed class StationLifecycleService faction.ShipsBuilt += 1; } - events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Label} launched {definition.Label}.", DateTimeOffset.UtcNow)); + events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Label} launched {definition.Name}.", DateTimeOffset.UtcNow)); return 1f; } @@ -107,21 +108,22 @@ internal sealed class StationLifecycleService private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station) { - if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal)) + if (!IsMilitaryShip(definition)) { return new DefaultBehaviorRuntime { - Kind = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? "advanced-auto-trade" : "idle", + Kind = IsTransportShip(definition) ? AdvancedAutoTrade : HoldPosition, HomeSystemId = station.SystemId, HomeStationId = station.Id, - MaxSystemRange = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? 2 : 0, + AreaSystemId = station.SystemId, + MaxSystemRange = IsTransportShip(definition) ? 2 : 0, }; } var patrolRadius = station.Radius + 90f; return new DefaultBehaviorRuntime { - Kind = "patrol", + Kind = Patrol, HomeSystemId = station.SystemId, HomeStationId = station.Id, AreaSystemId = station.SystemId, diff --git a/apps/backend/Stations/Simulation/StationSimulationService.cs b/apps/backend/Stations/Simulation/StationSimulationService.cs index eb0f35f..9358608 100644 --- a/apps/backend/Stations/Simulation/StationSimulationService.cs +++ b/apps/backend/Stations/Simulation/StationSimulationService.cs @@ -1,4 +1,5 @@ using static SpaceGame.Api.Factions.AI.CommanderPlanningService; +using static SpaceGame.Api.Shared.Runtime.KnownShipTypes; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; using SpaceGame.Api.Shared.Runtime; @@ -7,6 +8,10 @@ namespace SpaceGame.Api.Stations.Simulation; internal sealed class StationSimulationService { internal const int StrategicControlTargetSystems = 5; + private const string MilitaryShipCategory = "military"; + private const string ConstructionShipCategory = "construction"; + private const string TransportShipCategory = "transport"; + private const string MiningShipCategory = "mining"; internal void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station) { @@ -63,7 +68,7 @@ internal sealed class StationSimulationService var superfluidCoolantReserve = role == "superfluidcoolant" ? 120f : 0f; var quantumTubesReserve = role == "quantumtubes" ? 120f : 0f; var shipPartsReserve = HasShipyardCapability(station) - && GetShipProductionPressure(world, station.FactionId, "military") > 0.2f + && GetShipProductionPressure(world, station.FactionId, MilitaryShipCategory) > 0.2f ? 90f : 0f; @@ -118,7 +123,7 @@ internal sealed class StationSimulationService var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics"); var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals"); var shipPartsReserve = HasShipyardCapability(station) - && GetShipProductionPressure(world, station.FactionId, "military") > 0.2f + && GetShipProductionPressure(world, station.FactionId, MilitaryShipCategory) > 0.2f ? 90f : 0f; @@ -255,7 +260,7 @@ internal sealed class StationSimulationService var priority = (float)recipe.Priority; var expansionPressure = GetFactionExpansionPressure(world, station.FactionId); - var fleetPressure = GetShipProductionPressure(world, station.FactionId, "military"); + var fleetPressure = GetShipProductionPressure(world, station.FactionId, MilitaryShipCategory); priority += GetStationRecipePriorityAdjustment(world, station, recipe, expansionPressure, fleetPressure); priority += GetStrategicRecipeBias(world, station, recipe); @@ -266,21 +271,34 @@ internal sealed class StationSimulationService { if (recipe.ShipOutputId is not null && world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)) { - var shipPressure = GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind); - return shipDefinition.Kind switch + var shipPressure = GetShipProductionPressure(world, station.FactionId, GetShipCategory(shipDefinition)); + if (IsMilitaryShip(shipDefinition)) { - "military" => recipe.Id switch + return recipe.Id switch { "frigate-construction" => 320f * shipPressure, "destroyer-construction" => 200f * shipPressure, "cruiser-construction" => 120f * shipPressure, _ => 160f * shipPressure, - }, - "construction" => 260f * shipPressure, - "mining" => 250f * shipPressure, - "transport" => 230f * shipPressure, - _ => 0f, - }; + }; + } + + if (IsConstructionShip(shipDefinition)) + { + return 260f * shipPressure; + } + + if (IsMiningShip(shipDefinition)) + { + return 250f * shipPressure; + } + + if (IsTransportShip(shipDefinition)) + { + return 230f * shipPressure; + } + + return 0f; } var outputItemIds = recipe.Outputs @@ -338,7 +356,7 @@ internal sealed class StationSimulationService if (string.Equals(assignment.Kind, "ship-production-focus", StringComparison.Ordinal) && recipe.ShipOutputId is not null && world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition) - && string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal)) + && IsMilitaryShip(shipDefinition)) { return 260f; } @@ -383,7 +401,7 @@ internal sealed class StationSimulationService return false; } - if (GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind) <= 0.05f) + if (GetShipProductionPressure(world, station.FactionId, GetShipCategory(shipDefinition)) <= 0.05f) { return false; } @@ -708,7 +726,7 @@ internal sealed class StationSimulationService .ToList(); } - private static float GetShipProductionPressure(SimulationWorld world, string factionId, string shipKind) + private static float GetShipProductionPressure(SimulationWorld world, string factionId, string? shipCategory) { var economic = FindFactionEconomicAssessment(world, factionId); var threat = FindFactionThreatAssessment(world, factionId); @@ -717,16 +735,16 @@ internal sealed class StationSimulationService return 0f; } - return shipKind switch + return shipCategory switch { - "military" => threat.EnemyFactionCount > 0 + MilitaryShipCategory => threat.EnemyFactionCount > 0 ? economic.MilitaryShipCount < Math.Max(4, economic.ControlledSystemCount * 2) ? 1f : 0.25f : 0.1f, - "construction" => economic.PrimaryExpansionSiteId is not null + ConstructionShipCategory => economic.PrimaryExpansionSiteId is not null ? economic.ConstructorShipCount < 1 ? 1f : 0.35f : economic.ConstructorShipCount < 1 ? 0.5f : 0f, - "transport" => economic.TransportShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.8f : 0.2f, - _ when shipKind == "mining" || shipKind == "miner" => economic.MinerShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.85f : 0.2f, + TransportShipCategory => economic.TransportShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.8f : 0.2f, + MiningShipCategory => economic.MinerShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.85f : 0.2f, _ => 0.15f, }; } diff --git a/apps/backend/Universe/Api/CreateFactionHandler.cs b/apps/backend/Universe/Api/CreateFactionHandler.cs new file mode 100644 index 0000000..32f9583 --- /dev/null +++ b/apps/backend/Universe/Api/CreateFactionHandler.cs @@ -0,0 +1,25 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Universe.Api; + +public sealed class CreateFactionHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Post("/api/gm/factions"); + Policies(AuthPolicyNames.GmAccess); + } + + public override async Task HandleAsync(CreateFactionCommandRequest request, CancellationToken cancellationToken) + { + try + { + await SendOkAsync(worldService.CreateFaction(request.FactionId), cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } + } +} diff --git a/apps/backend/Universe/Api/GetBalanceHandler.cs b/apps/backend/Universe/Api/GetBalanceHandler.cs index 0f3d674..890283c 100644 --- a/apps/backend/Universe/Api/GetBalanceHandler.cs +++ b/apps/backend/Universe/Api/GetBalanceHandler.cs @@ -8,7 +8,7 @@ public sealed class GetBalanceHandler(IBalanceService balanceService) : Endpoint public override void Configure() { Get("/api/balance"); - AllowAnonymous(); + Policies(AuthPolicyNames.GmAccess); } public override Task HandleAsync(CancellationToken cancellationToken) => diff --git a/apps/backend/Universe/Api/GetTelemetryHandler.cs b/apps/backend/Universe/Api/GetTelemetryHandler.cs index 5aebea8..0ebc525 100644 --- a/apps/backend/Universe/Api/GetTelemetryHandler.cs +++ b/apps/backend/Universe/Api/GetTelemetryHandler.cs @@ -9,7 +9,7 @@ public sealed class GetTelemetryHandler(TelemetryService telemetry, WorldService public override void Configure() { Get("/api/telemetry"); - AllowAnonymous(); + Policies(AuthPolicyNames.GmAccess); } public override Task HandleAsync(CancellationToken cancellationToken) diff --git a/apps/backend/Universe/Api/GetVersionHandler.cs b/apps/backend/Universe/Api/GetVersionHandler.cs new file mode 100644 index 0000000..24c88e4 --- /dev/null +++ b/apps/backend/Universe/Api/GetVersionHandler.cs @@ -0,0 +1,17 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Universe.Api; + +public sealed class GetVersionHandler(AppVersionService appVersionService) : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/api/version"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken cancellationToken) + { + await SendOkAsync(appVersionService.GetSnapshot(), cancellationToken); + } +} diff --git a/apps/backend/Universe/Api/ResetWorldHandler.cs b/apps/backend/Universe/Api/ResetWorldHandler.cs index f5ea4e0..be90405 100644 --- a/apps/backend/Universe/Api/ResetWorldHandler.cs +++ b/apps/backend/Universe/Api/ResetWorldHandler.cs @@ -7,7 +7,7 @@ public sealed class ResetWorldHandler(WorldService worldService) : EndpointWitho public override void Configure() { Post("/api/world/reset"); - AllowAnonymous(); + Policies(AuthPolicyNames.GmAccess); } public override Task HandleAsync(CancellationToken cancellationToken) => diff --git a/apps/backend/Universe/Api/SpawnShipHandler.cs b/apps/backend/Universe/Api/SpawnShipHandler.cs new file mode 100644 index 0000000..006c517 --- /dev/null +++ b/apps/backend/Universe/Api/SpawnShipHandler.cs @@ -0,0 +1,25 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Universe.Api; + +public sealed class SpawnShipHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Post("/api/gm/ships"); + Policies(AuthPolicyNames.GmAccess); + } + + public override async Task HandleAsync(SpawnShipCommandRequest request, CancellationToken cancellationToken) + { + try + { + await SendOkAsync(worldService.SpawnShip(request), cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } + } +} diff --git a/apps/backend/Universe/Api/SpawnStationHandler.cs b/apps/backend/Universe/Api/SpawnStationHandler.cs new file mode 100644 index 0000000..a369589 --- /dev/null +++ b/apps/backend/Universe/Api/SpawnStationHandler.cs @@ -0,0 +1,25 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Universe.Api; + +public sealed class SpawnStationHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Post("/api/gm/stations"); + Policies(AuthPolicyNames.GmAccess); + } + + public override async Task HandleAsync(SpawnStationCommandRequest request, CancellationToken cancellationToken) + { + try + { + await SendOkAsync(worldService.SpawnStation(request), cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } + } +} diff --git a/apps/backend/Universe/Api/UpdateBalanceHandler.cs b/apps/backend/Universe/Api/UpdateBalanceHandler.cs index 05bc4d6..fbb900a 100644 --- a/apps/backend/Universe/Api/UpdateBalanceHandler.cs +++ b/apps/backend/Universe/Api/UpdateBalanceHandler.cs @@ -8,7 +8,7 @@ public sealed class UpdateBalanceHandler(IBalanceService balanceService) : Endpo public override void Configure() { Put("/api/balance"); - AllowAnonymous(); + Policies(AuthPolicyNames.GmAccess); } public override Task HandleAsync(BalanceOptions req, CancellationToken cancellationToken) diff --git a/apps/backend/Universe/Bootstrap/StaticDataProvider.cs b/apps/backend/Universe/Bootstrap/StaticDataProvider.cs index a1a2583..0612730 100644 --- a/apps/backend/Universe/Bootstrap/StaticDataProvider.cs +++ b/apps/backend/Universe/Bootstrap/StaticDataProvider.cs @@ -1,15 +1,22 @@ using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Options; using SpaceGame.Api.Shared.Runtime; +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; namespace SpaceGame.Api.Universe.Bootstrap; public sealed class StaticDataProvider : IStaticDataProvider { + private const string MilitaryShipCategory = "military"; + private const string ConstructionShipCategory = "construction"; + private const string TransportShipCategory = "transport"; + private const string MiningShipCategory = "mining"; private readonly string _dataRoot; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() }, }; public StaticDataProvider(IOptions staticDataOptions) @@ -163,7 +170,7 @@ public sealed class StaticDataProvider : IStaticDataProvider recipes.Add(new RecipeDefinition { Id = $"{ship.Id}-{production.Method}-construction", - Label = $"{ship.Label} Construction", + Label = $"{ship.Name} Construction", FacilityCategory = "shipyard", Duration = production.Time, Priority = InferShipRecipePriority(ship), @@ -224,12 +231,12 @@ public sealed class StaticDataProvider : IStaticDataProvider }; private static int InferShipRecipePriority(ShipDefinition ship) => - ship.Kind switch + GetShipCategory(ship) switch { - "military" => 170, - "construction" => 140, - "transport" => 120, - "mining" => 110, + MilitaryShipCategory => 170, + ConstructionShipCategory => 140, + TransportShipCategory => 120, + MiningShipCategory => 110, _ => 100, }; diff --git a/apps/backend/Universe/Contracts/GmCommands.cs b/apps/backend/Universe/Contracts/GmCommands.cs new file mode 100644 index 0000000..1cee44b --- /dev/null +++ b/apps/backend/Universe/Contracts/GmCommands.cs @@ -0,0 +1,16 @@ +namespace SpaceGame.Api.Universe.Contracts; + +public sealed record CreateFactionCommandRequest( + string FactionId); + +public sealed record SpawnShipCommandRequest( + string FactionId, + string SystemId, + string? ShipId = null, + string? BehaviorKind = null); + +public sealed record SpawnStationCommandRequest( + string FactionId, + string SystemId, + string? Objective = null, + string? Label = null); diff --git a/apps/backend/Universe/Contracts/World.cs b/apps/backend/Universe/Contracts/World.cs index 8e7baf1..9e81b18 100644 --- a/apps/backend/Universe/Contracts/World.cs +++ b/apps/backend/Universe/Contracts/World.cs @@ -18,7 +18,6 @@ public sealed record WorldSnapshot( IReadOnlyList Policies, IReadOnlyList Ships, IReadOnlyList Factions, - PlayerFactionSnapshot? PlayerFaction, GeopoliticalStateSnapshot? Geopolitics); public sealed record WorldDelta( @@ -38,7 +37,6 @@ public sealed record WorldDelta( IReadOnlyList Policies, IReadOnlyList Ships, IReadOnlyList Factions, - PlayerFactionSnapshot? PlayerFaction, GeopoliticalStateSnapshot? Geopolitics, ObserverScope? Scope = null); diff --git a/apps/backend/Universe/Scenario/LoaderSupport.cs b/apps/backend/Universe/Scenario/LoaderSupport.cs index 305ba45..60f1d07 100644 --- a/apps/backend/Universe/Scenario/LoaderSupport.cs +++ b/apps/backend/Universe/Scenario/LoaderSupport.cs @@ -89,9 +89,6 @@ internal static class LoaderSupport internal static bool HasInstalledModules(StationRuntime station, params string[] modules) => modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal))); - internal static bool HasCapabilities(ShipDefinition definition, params string[] capabilities) => - capabilities.All(capability => definition.Capabilities.Contains(capability, StringComparer.Ordinal)); - internal static void AddStationModule(StationRuntime station, IReadOnlyDictionary moduleDefinitions, string moduleId) { if (!moduleDefinitions.TryGetValue(moduleId, out var definition)) diff --git a/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs b/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs index 1ec754c..2474413 100644 --- a/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs +++ b/apps/backend/Universe/Scenario/ScenarioContentBuilder.cs @@ -1,5 +1,7 @@ using SpaceGame.Api.Universe.Bootstrap; -using SpaceGame.Api.Ships.Simulation; +using SpaceGame.Api.Ships.AI; +using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; using static SpaceGame.Api.Universe.Scenario.LoaderSupport; namespace SpaceGame.Api.Universe.Scenario; @@ -194,7 +196,7 @@ public sealed class ScenarioContentBuilder( patrolRoutes, stations), Skills = ShipBootstrapPolicy.CreateSkills(definition), - Health = definition.MaxHealth, + Health = definition.Hull, }); foreach (var (itemId, amount) in formation.StartingInventory) @@ -232,45 +234,45 @@ public sealed class ScenarioContentBuilder( && string.Equals(station.SystemId, systemId, StringComparison.Ordinal)) ?? stations.FirstOrDefault(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)); - if (string.Equals(definition.Kind, "construction", StringComparison.Ordinal) && homeStation is not null) + if (IsConstructionShip(definition) && homeStation is not null) { return new DefaultBehaviorRuntime { - Kind = "construct-station", + Kind = ConstructStation, HomeSystemId = homeStation.SystemId, HomeStationId = homeStation.Id, PreferredConstructionSiteId = null, }; } - if (LoaderSupport.HasCapabilities(definition, "mining") && homeStation is not null) + if (IsMiningShip(definition) && homeStation is not null) { return new DefaultBehaviorRuntime { - Kind = definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine", + Kind = definition.GetTotalCargoCapacity() >= 120f ? ExpertAutoMine : AdvancedAutoMine, HomeSystemId = homeStation.SystemId, HomeStationId = homeStation.Id, AreaSystemId = homeStation.SystemId, - MaxSystemRange = definition.CargoCapacity >= 120f ? 3 : 1, + MaxSystemRange = definition.GetTotalCargoCapacity() >= 120f ? 3 : 1, }; } - if (string.Equals(definition.Kind, "transport", StringComparison.Ordinal)) + if (IsTransportShip(definition)) { return new DefaultBehaviorRuntime { - Kind = "advanced-auto-trade", + Kind = AdvancedAutoTrade, HomeSystemId = homeStation?.SystemId ?? systemId, HomeStationId = homeStation?.Id, MaxSystemRange = 2, }; } - if (string.Equals(definition.Kind, "military", StringComparison.Ordinal) && patrolRoutes.TryGetValue(systemId, out var route)) + if (IsMilitaryShip(definition) && patrolRoutes.TryGetValue(systemId, out var route)) { return new DefaultBehaviorRuntime { - Kind = "patrol", + Kind = Patrol, HomeSystemId = homeStation?.SystemId ?? systemId, HomeStationId = homeStation?.Id, AreaSystemId = systemId, @@ -281,9 +283,10 @@ public sealed class ScenarioContentBuilder( return new DefaultBehaviorRuntime { - Kind = "idle", + Kind = HoldPosition, HomeSystemId = homeStation?.SystemId ?? systemId, HomeStationId = homeStation?.Id, + AreaSystemId = homeStation?.SystemId ?? systemId, }; } } diff --git a/apps/backend/Universe/Scenario/SystemGenerationService.cs b/apps/backend/Universe/Scenario/SystemGenerationService.cs index c217380..8166883 100644 --- a/apps/backend/Universe/Scenario/SystemGenerationService.cs +++ b/apps/backend/Universe/Scenario/SystemGenerationService.cs @@ -520,6 +520,8 @@ public sealed class SystemGenerationService private static float Jitter(int index, int salt, float amplitude) => (Hash01(index, salt) * 2f - 1f) * amplitude; + // Cheap deterministic pseudo-random helper: same (index, salt) pair always maps to the same 0..1 value. + // Generation code uses it instead of a mutable RNG so each procedural choice stays stable for a given seed. private static float Hash01(int index, int salt) { uint value = (uint)(index + 1); diff --git a/apps/backend/Universe/Scenario/WorldRuntimeAssembler.cs b/apps/backend/Universe/Scenario/WorldRuntimeAssembler.cs index 1e5a820..22ea259 100644 --- a/apps/backend/Universe/Scenario/WorldRuntimeAssembler.cs +++ b/apps/backend/Universe/Scenario/WorldRuntimeAssembler.cs @@ -18,9 +18,6 @@ public sealed class WorldRuntimeAssembler( var policies = seedingService.CreatePolicies(factions); var commanders = seedingService.CreateCommanders(factions, content.Stations, content.Ships); var nowUtc = DateTimeOffset.UtcNow; - var playerFaction = worldGenerationOptions.GeneratePlayerFaction - ? seedingService.CreatePlayerFaction(factions, content.Stations, content.Ships, commanders, policies, nowUtc) - : null; var claims = seedingService.CreateClaims(content.Stations, topology.SpatialLayout.Celestials, nowUtc); var world = new SimulationWorld @@ -34,7 +31,6 @@ public sealed class WorldRuntimeAssembler( Stations = content.Stations.ToList(), Ships = content.Ships.ToList(), Factions = factions, - PlayerFaction = playerFaction, Geopolitics = null, Commanders = commanders, Claims = claims, diff --git a/apps/backend/Universe/Scenario/WorldSeedingService.cs b/apps/backend/Universe/Scenario/WorldSeedingService.cs index 1f1c9e2..1ca359a 100644 --- a/apps/backend/Universe/Scenario/WorldSeedingService.cs +++ b/apps/backend/Universe/Scenario/WorldSeedingService.cs @@ -1,4 +1,5 @@ using SpaceGame.Api.Universe.Bootstrap; +using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; using static SpaceGame.Api.Universe.Scenario.LoaderSupport; namespace SpaceGame.Api.Universe.Scenario; @@ -379,7 +380,7 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData) Label = "Core Automation", ScopeKind = "player-faction", ScopeId = player.Id, - BehaviorKind = "idle", + BehaviorKind = Idle, UpdatedAtUtc = nowUtc, }); @@ -395,7 +396,7 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData) return player; } - private FactionRuntime CreateFaction(string factionId) + internal FactionRuntime CreateFaction(string factionId) { if (!staticData.FactionDefinitions.TryGetValue(factionId, out var definition)) { diff --git a/apps/backend/Universe/Simulation/WorldService.cs b/apps/backend/Universe/Simulation/WorldService.cs index 0edfc34..843b96e 100644 --- a/apps/backend/Universe/Simulation/WorldService.cs +++ b/apps/backend/Universe/Simulation/WorldService.cs @@ -1,6 +1,9 @@ using System.Threading.Channels; using Microsoft.Extensions.Options; +using SpaceGame.Api.Universe.Bootstrap; using SpaceGame.Api.Universe.Scenario; +using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; namespace SpaceGame.Api.Universe.Simulation; @@ -11,8 +14,13 @@ public sealed class WorldService private readonly Lock _sync = new(); private readonly OrbitalSimulationSnapshot _orbitalSimulation; private readonly SimulationEngine _engine; + private readonly IPlayerIdentityResolver _playerIdentityResolver; + private readonly IPlayerStateStore _playerStateStore; + private readonly PlayerFactionProjectionService _playerFactionProjection; private readonly ScenarioLoader _scenarioLoader; private readonly WorldBuilder _worldBuilder; + private readonly IStaticDataProvider _staticData; + private readonly WorldSeedingService _worldSeedingService; private readonly PlayerFactionService _playerFaction = new(); private readonly Dictionary _subscribers = []; private readonly Queue _history = []; @@ -24,13 +32,23 @@ public sealed class WorldService public WorldService( ScenarioLoader scenarioLoader, WorldBuilder worldBuilder, + IStaticDataProvider staticData, + WorldSeedingService worldSeedingService, + IPlayerStateStore playerStateStore, + IPlayerIdentityResolver playerIdentityResolver, + PlayerFactionProjectionService playerFactionProjection, IBalanceService balance, IOptions orbitalSimulationOptions) { _orbitalSimulation = new OrbitalSimulationSnapshot(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond); + _playerStateStore = playerStateStore; + _playerIdentityResolver = playerIdentityResolver; + _playerFactionProjection = playerFactionProjection; _scenarioLoader = scenarioLoader; _worldBuilder = worldBuilder; - _engine = new SimulationEngine(orbitalSimulationOptions.Value, balance); + _staticData = staticData; + _worldSeedingService = worldSeedingService; + _engine = new SimulationEngine(orbitalSimulationOptions.Value, balance, playerStateStore); } public void New(WorldGenerationOptions options) @@ -81,7 +99,10 @@ public sealed class WorldService { lock (_sync) { - var ship = _playerFaction.EnqueueDirectShipOrder(_world, shipId, request); + ValidateShipOrderRequestUnsafe(shipId, request); + var ship = CanCurrentActorAccessGm() + ? EnqueueGmShipOrderUnsafe(shipId, request) + : _playerFaction.EnqueueDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, request); if (ship is null) { return null; @@ -95,7 +116,9 @@ public sealed class WorldService { lock (_sync) { - var ship = _playerFaction.RemoveDirectShipOrder(_world, shipId, orderId); + var ship = CanCurrentActorAccessGm() + ? RemoveGmShipOrderUnsafe(shipId, orderId) + : _playerFaction.RemoveDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, orderId); if (ship is null) { return null; @@ -109,7 +132,9 @@ public sealed class WorldService { lock (_sync) { - var ship = _playerFaction.ConfigureDirectShipBehavior(_world, shipId, request); + var ship = CanCurrentActorAccessGm() + ? ConfigureGmShipBehaviorUnsafe(shipId, request) + : _playerFaction.ConfigureDirectShipBehavior(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, request); if (ship is null) { return null; @@ -123,13 +148,15 @@ public sealed class WorldService { lock (_sync) { - if (_world.PlayerFaction is null && _world.Factions.Count == 0) + if (_world.Factions.Count == 0) { return null; } - _playerFaction.EnsureDomain(_world); - return GetPlayerFactionSnapshotUnsafe(); + var playerKey = GetCurrentPlayerKey(); + var player = _playerFaction.TryGetDomain(_playerStateStore, playerKey) + ?? _playerFaction.EnsureDomain(_world, _playerStateStore, playerKey); + return _playerFactionProjection.ToSnapshot(player); } } @@ -137,7 +164,7 @@ public sealed class WorldService { lock (_sync) { - _playerFaction.CreateOrganization(_world, request); + _playerFaction.CreateOrganization(_world, _playerStateStore, GetCurrentPlayerKey(), request); return GetPlayerFactionSnapshotUnsafe(); } } @@ -146,7 +173,7 @@ public sealed class WorldService { lock (_sync) { - _playerFaction.DeleteOrganization(_world, organizationId); + _playerFaction.DeleteOrganization(_world, _playerStateStore, GetCurrentPlayerKey(), organizationId); return GetPlayerFactionSnapshotUnsafe(); } } @@ -155,7 +182,7 @@ public sealed class WorldService { lock (_sync) { - _playerFaction.UpdateOrganizationMembership(_world, organizationId, request); + _playerFaction.UpdateOrganizationMembership(_world, _playerStateStore, GetCurrentPlayerKey(), organizationId, request); return GetPlayerFactionSnapshotUnsafe(); } } @@ -164,7 +191,7 @@ public sealed class WorldService { lock (_sync) { - _playerFaction.UpsertDirective(_world, directiveId, request); + _playerFaction.UpsertDirective(_world, _playerStateStore, GetCurrentPlayerKey(), directiveId, request); return GetPlayerFactionSnapshotUnsafe(); } } @@ -173,7 +200,7 @@ public sealed class WorldService { lock (_sync) { - _playerFaction.DeleteDirective(_world, directiveId); + _playerFaction.DeleteDirective(_world, _playerStateStore, GetCurrentPlayerKey(), directiveId); return GetPlayerFactionSnapshotUnsafe(); } } @@ -182,7 +209,7 @@ public sealed class WorldService { lock (_sync) { - _playerFaction.UpsertPolicy(_world, policyId, request); + _playerFaction.UpsertPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), policyId, request); return GetPlayerFactionSnapshotUnsafe(); } } @@ -191,7 +218,7 @@ public sealed class WorldService { lock (_sync) { - _playerFaction.UpsertAutomationPolicy(_world, automationPolicyId, request); + _playerFaction.UpsertAutomationPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), automationPolicyId, request); return GetPlayerFactionSnapshotUnsafe(); } } @@ -200,7 +227,7 @@ public sealed class WorldService { lock (_sync) { - _playerFaction.UpsertReinforcementPolicy(_world, reinforcementPolicyId, request); + _playerFaction.UpsertReinforcementPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), reinforcementPolicyId, request); return GetPlayerFactionSnapshotUnsafe(); } } @@ -209,7 +236,7 @@ public sealed class WorldService { lock (_sync) { - _playerFaction.UpsertProductionProgram(_world, productionProgramId, request); + _playerFaction.UpsertProductionProgram(_world, _playerStateStore, GetCurrentPlayerKey(), productionProgramId, request); return GetPlayerFactionSnapshotUnsafe(); } } @@ -218,7 +245,7 @@ public sealed class WorldService { lock (_sync) { - _playerFaction.UpsertAssignment(_world, assetId, request); + _playerFaction.UpsertAssignment(_world, _playerStateStore, GetCurrentPlayerKey(), assetId, request); return GetPlayerFactionSnapshotUnsafe(); } } @@ -227,11 +254,118 @@ public sealed class WorldService { lock (_sync) { - _playerFaction.UpdateStrategicIntent(_world, request); + _playerFaction.UpdateStrategicIntent(_world, _playerStateStore, GetCurrentPlayerKey(), request); return GetPlayerFactionSnapshotUnsafe(); } } + public FactionSnapshot CreateFaction(string factionId) + { + lock (_sync) + { + if (_world.Factions.Any(candidate => string.Equals(candidate.Id, factionId, StringComparison.Ordinal))) + { + throw new InvalidOperationException($"Faction '{factionId}' already exists in the current world."); + } + + var faction = _worldSeedingService.CreateFaction(factionId); + _world.Factions.Add(faction); + + var policy = _worldSeedingService.CreatePolicies([faction]).Single(); + _world.Policies.Add(policy); + + var factionCommander = CreateFactionCommander(faction); + _world.Commanders.Add(factionCommander); + faction.CommanderIds.Add(factionCommander.Id); + + new GeopoliticalSimulationService().Update(_world, 0f, []); + PublishSnapshotRefreshUnsafe("create-faction", $"Created faction {factionId}", "faction", factionId); + return _engine.BuildSnapshot(_world, _sequence).Factions.First(candidate => candidate.Id == factionId); + } + } + + public ShipSnapshot SpawnShip(SpawnShipCommandRequest request) + { + lock (_sync) + { + var faction = _world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, request.FactionId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Faction '{request.FactionId}' does not exist in the current world."); + var system = _world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, request.SystemId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"System '{request.SystemId}' does not exist in the current world."); + var definition = ResolveShipDefinition(request, faction.Id); + var shipId = $"ship-{faction.Id}-{definition.Id}-{Guid.NewGuid():N}".ToLowerInvariant(); + var spawnPosition = ResolveSpawnPosition(system.Definition.Id); + var homeStation = _world.Stations.FirstOrDefault(candidate => + string.Equals(candidate.FactionId, faction.Id, StringComparison.Ordinal) + && string.Equals(candidate.SystemId, system.Definition.Id, StringComparison.Ordinal)); + var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, homeStation); + + var ship = new ShipRuntime + { + Id = shipId, + SystemId = system.Definition.Id, + Definition = definition, + FactionId = faction.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(faction, ship); + new GeopoliticalSimulationService().Update(_world, 0f, []); + PublishSnapshotRefreshUnsafe("spawn-ship", $"Spawned ship {ship.Id}", "ship", ship.Id); + return GetShipSnapshotUnsafe(ship.Id) + ?? throw new InvalidOperationException($"Ship '{ship.Id}' could not be projected."); + } + } + + public StationSnapshot SpawnStation(SpawnStationCommandRequest request) + { + lock (_sync) + { + var faction = _world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, request.FactionId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Faction '{request.FactionId}' does not exist in the current world."); + var system = _world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, request.SystemId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"System '{request.SystemId}' does not exist in the current world."); + var objective = StationSimulationService.NormalizeStationObjective(request.Objective); + var label = string.IsNullOrWhiteSpace(request.Label) + ? $"{faction.Label} {ToTitleCaseToken(objective)} {CountFactionStationsInSystem(faction.Id, system.Definition.Id) + 1}" + : request.Label.Trim(); + var stationId = $"station-{faction.Id}-{objective}-{Guid.NewGuid():N}".ToLowerInvariant(); + var position = ResolveStationSpawnPosition(system.Definition.Id); + var station = new StationRuntime + { + Id = stationId, + SystemId = system.Definition.Id, + Label = label, + Color = faction.Color, + Objective = objective, + Position = position, + FactionId = faction.Id, + PolicySetId = faction.DefaultPolicySetId, + Health = 600f, + MaxHealth = 600f, + }; + + foreach (var moduleId in BuildStarterStationModules(faction.Id, objective)) + { + AddStationModule(_world, station, moduleId); + } + + station.PopulationCapacity = GetStationSupportedPopulation(_world.ModuleDefinitions, station); + station.WorkforceRequired = GetStationRequiredWorkforce(_world.ModuleDefinitions, station); + _world.Stations.Add(station); + + new GeopoliticalSimulationService().Update(_world, 0f, []); + PublishSnapshotRefreshUnsafe("spawn-station", $"Spawned station {station.Id}", "station", station.Id); + return _engine.BuildSnapshot(_world, _sequence).Stations.First(candidate => candidate.Id == station.Id); + } + } + public ChannelReader Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken) { var channel = Channel.CreateUnbounded(new UnboundedChannelOptions @@ -318,6 +452,7 @@ public sealed class WorldService private void ReplaceWorldUnsafe(SimulationWorld world, string eventKind, string eventMessage) { _world = world; + _playerStateStore.Clear(); _sequence += 1; _history.Clear(); @@ -339,7 +474,6 @@ public sealed class WorldService [], [], [], - null, null); _history.Enqueue(worldDelta); @@ -349,11 +483,431 @@ public sealed class WorldService } } + private void PublishSnapshotRefreshUnsafe( + string eventKind, + string eventMessage, + string entityKind, + string entityId, + string scopeKind = "universe", + string? scopeEntityId = null) + { + _sequence += 1; + var eventTime = DateTimeOffset.UtcNow; + var worldDelta = new WorldDelta( + _sequence, + _world.TickIntervalMs, + _world.OrbitalTimeSeconds, + _orbitalSimulation, + eventTime, + true, + [new SimulationEventRecord(entityKind, entityId, eventKind, eventMessage, eventTime, "world", scopeKind, scopeEntityId)], + [], + [], + [], + [], + [], + [], + [], + [], + [], + null); + + _history.Enqueue(worldDelta); + while (_history.Count > DeltaHistoryLimit) + { + _history.Dequeue(); + } + + foreach (var subscriber in _subscribers.Values.ToList()) + { + subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(worldDelta, subscriber.Scope)); + } + } + private ShipSnapshot? GetShipSnapshotUnsafe(string shipId) => _engine.BuildSnapshot(_world, _sequence).Ships.FirstOrDefault(ship => ship.Id == shipId); private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() => - _engine.BuildSnapshot(_world, _sequence).PlayerFaction; + _playerFactionProjection.ToSnapshot(_playerFaction.TryGetDomain(_playerStateStore, GetCurrentPlayerKey())); + + private string GetCurrentPlayerKey() => _playerIdentityResolver.GetRequiredPlayerId().ToString("N"); + + private bool CanCurrentActorAccessGm() => _playerIdentityResolver.CanAccessGm(); + + private string GetCurrentActorSourceId() => + _playerIdentityResolver.GetCurrentPlayerId()?.ToString("N") ?? "gm"; + + private void ValidateShipOrderRequestUnsafe(string shipId, ShipOrderCommandRequest request) + { + var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId) + ?? throw new InvalidOperationException($"Ship '{shipId}' was not found."); + + if (!string.Equals(request.Kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal)) + { + return; + } + + if (!IsMiningShip(ship.Definition)) + { + throw new InvalidOperationException($"{ship.Definition.Name} cannot accept Mine Resource because it does not have mining capability."); + } + + if (string.IsNullOrWhiteSpace(request.ItemId)) + { + throw new InvalidOperationException("Mine Resource requires a ware."); + } + + if (!_world.ItemDefinitions.TryGetValue(request.ItemId, out var itemDefinition)) + { + throw new InvalidOperationException($"Mine Resource references unknown ware '{request.ItemId}'."); + } + + if (itemDefinition.CargoKind is null) + { + throw new InvalidOperationException($"Mine Resource ware '{request.ItemId}' is not mineable."); + } + + if (!ship.Definition.SupportsCargoKind(itemDefinition.CargoKind.Value)) + { + throw new InvalidOperationException($"{ship.Definition.Name} cannot mine '{request.ItemId}' because it cannot store '{itemDefinition.CargoKind.Value.ToDataValue()}'."); + } + } + + private ShipRuntime? EnqueueGmShipOrderUnsafe(string shipId, ShipOrderCommandRequest request) + { + var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); + if (ship is null) + { + return null; + } + + if (ship.OrderQueue.Count >= 8) + { + throw new InvalidOperationException("Order queue is full."); + } + + ship.OrderQueue.Add(new ShipOrderRuntime + { + Id = $"order-{ship.Id}-{Guid.NewGuid():N}", + Kind = request.Kind, + SourceKind = ShipOrderSourceKind.Player, + SourceId = GetCurrentActorSourceId(), + Priority = request.Priority, + InterruptCurrentPlan = request.InterruptCurrentPlan, + Label = request.Label, + TargetEntityId = request.TargetEntityId, + TargetSystemId = request.TargetSystemId, + TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z), + SourceStationId = request.SourceStationId, + DestinationStationId = request.DestinationStationId, + ItemId = request.ItemId, + NodeId = request.NodeId, + ConstructionSiteId = request.ConstructionSiteId, + ModuleId = request.ModuleId, + WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f), + Radius = MathF.Max(0f, request.Radius ?? 0f), + MaxSystemRange = request.MaxSystemRange, + KnownStationsOnly = request.KnownStationsOnly ?? false, + }); + + ship.ControlSourceKind = "gm-order"; + ship.ControlSourceId = ship.OrderQueue + .Where(order => order.SourceKind == ShipOrderSourceKind.Player) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => order.Id) + .FirstOrDefault(); + ship.ControlReason = request.Label ?? request.Kind; + ship.NeedsReplan = true; + ship.LastReplanReason = "gm-order-enqueued"; + ship.LastDeltaSignature = string.Empty; + return ship; + } + + private ShipRuntime? RemoveGmShipOrderUnsafe(string shipId, string orderId) + { + var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); + if (ship is null) + { + return null; + } + + ship.OrderQueue.RemoveAll(order => order.Id == orderId); + ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player) + ? "gm-order" + : "gm-manual"; + ship.ControlSourceId = ship.OrderQueue + .Where(order => order.SourceKind == ShipOrderSourceKind.Player) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => order.Id) + .FirstOrDefault(); + ship.ControlReason = ship.OrderQueue + .Where(order => order.SourceKind == ShipOrderSourceKind.Player) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => order.Label ?? order.Kind) + .FirstOrDefault() + ?? "manual-gm-control"; + ship.NeedsReplan = true; + ship.LastReplanReason = "gm-order-removed"; + ship.LastDeltaSignature = string.Empty; + return ship; + } + + private ShipRuntime? ConfigureGmShipBehaviorUnsafe(string shipId, ShipDefaultBehaviorCommandRequest request) + { + var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); + if (ship is null) + { + return null; + } + + ship.DefaultBehavior.Kind = request.Kind; + ship.DefaultBehavior.HomeSystemId = request.HomeSystemId ?? ship.SystemId; + ship.DefaultBehavior.HomeStationId = request.HomeStationId; + ship.DefaultBehavior.AreaSystemId = request.AreaSystemId; + ship.DefaultBehavior.TargetEntityId = request.TargetEntityId; + ship.DefaultBehavior.ItemId = request.ItemId; + ship.DefaultBehavior.PreferredNodeId = request.PreferredNodeId; + ship.DefaultBehavior.PreferredConstructionSiteId = request.PreferredConstructionSiteId; + ship.DefaultBehavior.PreferredModuleId = request.PreferredModuleId; + ship.DefaultBehavior.TargetPosition = request.TargetPosition is null + ? null + : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z); + ship.DefaultBehavior.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? ship.DefaultBehavior.WaitSeconds); + ship.DefaultBehavior.Radius = MathF.Max(0f, request.Radius ?? ship.DefaultBehavior.Radius); + ship.DefaultBehavior.MaxSystemRange = Math.Max(0, request.MaxSystemRange ?? ship.DefaultBehavior.MaxSystemRange); + ship.DefaultBehavior.KnownStationsOnly = request.KnownStationsOnly ?? ship.DefaultBehavior.KnownStationsOnly; + ship.DefaultBehavior.PatrolPoints = + (request.PatrolPoints ?? []) + .Select(point => new Vector3(point.X, point.Y, point.Z)) + .ToList(); + ship.DefaultBehavior.PatrolIndex = 0; + ship.DefaultBehavior.RepeatOrders = + (request.RepeatOrders ?? []) + .Select(template => new ShipOrderTemplateRuntime + { + Kind = template.Kind, + Label = template.Label, + TargetEntityId = template.TargetEntityId, + TargetSystemId = template.TargetSystemId, + TargetPosition = template.TargetPosition is null ? null : new Vector3(template.TargetPosition.X, template.TargetPosition.Y, template.TargetPosition.Z), + SourceStationId = template.SourceStationId, + DestinationStationId = template.DestinationStationId, + ItemId = template.ItemId, + NodeId = template.NodeId, + ConstructionSiteId = template.ConstructionSiteId, + ModuleId = template.ModuleId, + WaitSeconds = template.WaitSeconds ?? 0f, + Radius = template.Radius ?? 0f, + MaxSystemRange = template.MaxSystemRange, + KnownStationsOnly = template.KnownStationsOnly ?? false, + }) + .ToList(); + ship.DefaultBehavior.RepeatIndex = 0; + + ship.ControlSourceKind = "gm-manual"; + ship.ControlSourceId = GetCurrentActorSourceId(); + ship.ControlReason = request.Kind; + ship.NeedsReplan = true; + ship.LastReplanReason = "gm-behavior-updated"; + ship.LastDeltaSignature = string.Empty; + return ship; + } + + private CommanderRuntime CreateFactionCommander(FactionRuntime faction) => new() + { + Id = $"commander-faction-{faction.Id}", + Kind = CommanderKind.Faction, + FactionId = faction.Id, + ControlledEntityId = faction.Id, + PolicySetId = faction.DefaultPolicySetId, + Doctrine = "strategic-control", + }; + + private void EnsureShipCommander(FactionRuntime faction, ShipRuntime ship) + { + var factionCommander = _world.Commanders.FirstOrDefault(candidate => + string.Equals(candidate.Kind, CommanderKind.Faction, StringComparison.Ordinal) + && string.Equals(candidate.FactionId, faction.Id, StringComparison.Ordinal)); + if (factionCommander is null) + { + return; + } + + var commander = new CommanderRuntime + { + Id = $"commander-ship-{ship.Id}", + Kind = CommanderKind.Ship, + FactionId = faction.Id, + ParentCommanderId = factionCommander.Id, + ControlledEntityId = ship.Id, + PolicySetId = factionCommander.PolicySetId, + Doctrine = "ship-control", + Skills = new CommanderSkillProfileRuntime + { + Leadership = Math.Clamp((ship.Skills.Navigation + ship.Skills.Combat + 1) / 2, 2, 5), + Coordination = Math.Clamp((ship.Skills.Trade + ship.Skills.Mining + 1) / 2, 2, 5), + Strategy = Math.Clamp((ship.Skills.Combat + ship.Skills.Construction + 1) / 2, 2, 5), + }, + }; + + ship.CommanderId = commander.Id; + ship.PolicySetId = factionCommander.PolicySetId; + factionCommander.SubordinateCommanderIds.Add(commander.Id); + faction.CommanderIds.Add(commander.Id); + _world.Commanders.Add(commander); + } + + private ShipDefinition ResolveShipDefinition(SpawnShipCommandRequest request, string factionId) + { + if (!string.IsNullOrWhiteSpace(request.ShipId)) + { + return _staticData.ShipDefinitions.TryGetValue(request.ShipId, out var explicitDefinition) + ? explicitDefinition + : throw new InvalidOperationException($"Ship '{request.ShipId}' is not defined in static data."); + } + + return _staticData.ShipDefinitions.Values + .Where(IsMiningShip) + .OrderBy(definition => !definition.Owners.Contains(factionId, StringComparer.Ordinal)) + .ThenBy(definition => !definition.SupportsCargoKind(StorageKind.Solid)) + .ThenBy(definition => definition.Size != "small") + .ThenBy(definition => definition.Id, StringComparer.Ordinal) + .FirstOrDefault() + ?? throw new InvalidOperationException("No mining ship definition is available in static data."); + } + + private Vector3 ResolveSpawnPosition(string systemId) + { + var shipsInSystem = _world.Ships.Count(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal)); + var angle = shipsInSystem * 0.73f; + return new Vector3(60f + (shipsInSystem * 12f), 0f, MathF.Sin(angle) * 34f); + } + + private Vector3 ResolveStationSpawnPosition(string systemId) + { + var stationsInSystem = _world.Stations.Count(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal)); + var angle = stationsInSystem * 0.91f; + var radius = 160f + (stationsInSystem * 42f); + return new Vector3(MathF.Cos(angle) * radius, 0f, MathF.Sin(angle) * radius); + } + + private IReadOnlyList BuildStarterStationModules(string factionId, string objective) + { + var modules = new List(); + + EnsureStationModule(modules, StarterStationLayoutResolver.ResolveDockModuleId(factionId, _staticData.ModuleDefinitions)); + + var powerModuleId = StarterStationLayoutResolver.ResolvePowerModuleId(factionId, _staticData.ModuleDefinitions); + EnsureStationModule(modules, powerModuleId); + + var defaultContainerStorageModuleId = StarterStationLayoutResolver.ResolveRequiredStorageModuleIds( + powerModuleId, + factionId, + _staticData.ModuleDefinitions, + _staticData.ItemDefinitions) + .FirstOrDefault(moduleId => + { + return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition) + && definition is StorageModuleDefinition storageDefinition + && storageDefinition.StorageKind == StorageKind.Container; + }); + + if (defaultContainerStorageModuleId is not null) + { + EnsureStationModule(modules, defaultContainerStorageModuleId); + } + + var objectiveModuleId = StarterStationLayoutResolver.ResolveObjectiveModuleId(objective, factionId, _staticData.ModuleDefinitions); + if (!string.IsNullOrWhiteSpace(objectiveModuleId)) + { + EnsureStationModule(modules, objectiveModuleId); + foreach (var storageModuleId in StarterStationLayoutResolver.ResolveRequiredStorageModuleIds( + objectiveModuleId, + factionId, + _staticData.ModuleDefinitions, + _staticData.ItemDefinitions)) + { + EnsureStationModule(modules, storageModuleId); + } + } + + return modules; + } + + private static void EnsureStationModule(List modules, string moduleId) + { + if (!modules.Contains(moduleId, StringComparer.Ordinal)) + { + modules.Add(moduleId); + } + } + + private int CountFactionStationsInSystem(string factionId, string systemId) => + _world.Stations.Count(candidate => + string.Equals(candidate.FactionId, factionId, StringComparison.Ordinal) + && string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal)); + + private static string ToTitleCaseToken(string value) => + string.Join(" ", + value + .Split(['-', '_', ' '], StringSplitOptions.RemoveEmptyEntries) + .Select(part => part.Length == 0 ? part : char.ToUpperInvariant(part[0]) + part[1..])); + + private static DefaultBehaviorRuntime CreateSpawnBehavior( + SpawnShipCommandRequest request, + ShipDefinition definition, + string systemId, + StationRuntime? homeStation) + { + var requestedBehavior = request.BehaviorKind?.Trim(); + if (!string.IsNullOrWhiteSpace(requestedBehavior)) + { + return new DefaultBehaviorRuntime + { + Kind = requestedBehavior, + HomeSystemId = systemId, + HomeStationId = homeStation?.Id, + AreaSystemId = systemId, + ItemId = string.Equals(requestedBehavior, LocalAutoMine, StringComparison.Ordinal) ? "ore" : null, + }; + } + + if (IsMiningShip(definition) && homeStation is not null) + { + return new DefaultBehaviorRuntime + { + Kind = LocalAutoMine, + HomeSystemId = systemId, + HomeStationId = homeStation.Id, + AreaSystemId = systemId, + }; + } + + if (IsMiningShip(definition)) + { + return new DefaultBehaviorRuntime + { + Kind = LocalAutoMine, + HomeSystemId = systemId, + HomeStationId = null, + AreaSystemId = systemId, + ItemId = "ore", + }; + } + + return new DefaultBehaviorRuntime + { + Kind = HoldPosition, + HomeSystemId = systemId, + HomeStationId = homeStation?.Id, + AreaSystemId = systemId, + WaitSeconds = 4f, + Radius = 24f, + }; + } private static bool HasMeaningfulDelta(WorldDelta delta) => delta.RequiresSnapshotRefresh @@ -367,7 +921,6 @@ public sealed class WorldService || delta.Policies.Count > 0 || delta.Ships.Count > 0 || delta.Factions.Count > 0 - || delta.PlayerFaction is not null || delta.Geopolitics is not null; private void Unsubscribe(Guid subscriberId) @@ -415,7 +968,6 @@ public sealed class WorldService Policies = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Policies : [], Ships = delta.Ships.Where((ship) => systemFilter is null || ship.SystemId == systemFilter).ToList(), Factions = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Factions : [], - PlayerFaction = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.PlayerFaction : null, Geopolitics = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Geopolitics : null, Scope = scope, }; diff --git a/apps/backend/appsettings.Development.json b/apps/backend/appsettings.Development.json index 0cc03e9..f6d99d5 100644 --- a/apps/backend/appsettings.Development.json +++ b/apps/backend/appsettings.Development.json @@ -5,12 +5,6 @@ "Microsoft.AspNetCore": "Warning" } }, - "WorldGeneration": { - "TargetSystemCount": 2, - "UseKnownSystems": true, - "AiControllerFactionCount": 0, - "GeneratePlayerFaction": false - }, "Balance": { "SimulationSpeedMultiplier": 1.5, "YPlane": 4, @@ -24,5 +18,27 @@ }, "OrbitalSimulation": { "SimulatedSecondsPerRealSecond": 0 + }, + "Auth": { + "ConnectionString": "Host=127.0.0.1;Port=5432;Database=spacegame;Username=spacegame;Password=spacegame", + "DevSeedUsers": [ + { + "Email": "gm", + "Password": "gm", + "Roles": [ "gm" ] + }, + { + "Email": "admin", + "Password": "admin", + "Roles": [ "admin", "gm" ] + } + ] + }, + "Jwt": { + "Issuer": "space-game-dev", + "Audience": "space-game-viewer", + "SigningKey": "space-game-development-signing-key-change-me", + "AccessTokenLifetimeMinutes": 30, + "RefreshTokenLifetimeDays": 30 } } diff --git a/apps/backend/appsettings.json b/apps/backend/appsettings.json index 9f83ed7..04ecae4 100644 --- a/apps/backend/appsettings.json +++ b/apps/backend/appsettings.json @@ -8,10 +8,6 @@ "StaticData": { "DataRoot": "../../shared/data/" }, - "WorldGeneration": { - "TargetSystemCount": 160, - "UseKnownSystems": true - }, "Balance": { "SimulationSpeedMultiplier": 1.5, "YPlane": 4, @@ -26,5 +22,15 @@ "OrbitalSimulation": { "SimulatedSecondsPerRealSecond": 0 }, + "Auth": { + "ConnectionString": "Host=127.0.0.1;Port=5432;Database=spacegame;Username=spacegame;Password=spacegame" + }, + "Jwt": { + "Issuer": "space-game", + "Audience": "space-game-viewer", + "SigningKey": "dev-only-change-me-space-game-signing-key", + "AccessTokenLifetimeMinutes": 30, + "RefreshTokenLifetimeDays": 30 + }, "AllowedHosts": "*" } diff --git a/apps/viewer/src/App.vue b/apps/viewer/src/App.vue index 2a9a570..1325025 100644 --- a/apps/viewer/src/App.vue +++ b/apps/viewer/src/App.vue @@ -1,14 +1,21 @@ diff --git a/apps/viewer/src/ViewerAppController.ts b/apps/viewer/src/ViewerAppController.ts index d7b4cd4..cf21e8f 100644 --- a/apps/viewer/src/ViewerAppController.ts +++ b/apps/viewer/src/ViewerAppController.ts @@ -31,6 +31,9 @@ import { LocalLayer } from "./viewerLocalLayer"; import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState"; import { describeSelectable } from "./viewerSelection"; import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection"; +import { useViewerSceneStore } from "./ui/stores/viewerScene"; +import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu"; +import { viewerPinia } from "./ui/stores/pinia"; import type { FactionSnapshot } from "./contracts"; import type { CameraMode, @@ -68,6 +71,8 @@ export class ViewerAppController { readonly hudState: ViewerHudState; readonly selectionStore: ViewerSelectionStore; + private readonly sceneStore = useViewerSceneStore(viewerPinia); + private readonly orderContextMenuStore = useViewerOrderContextMenuStore(viewerPinia); private readonly historyLayerEl: HTMLDivElement; private readonly marqueeEl: HTMLDivElement; private readonly hoverLabelEl: HTMLDivElement; @@ -156,6 +161,8 @@ export class ViewerAppController { this.disposeEventBindings(); this.unsubscribeSelectionStore(); this.stream?.close(); + this.sceneStore.reset(); + this.orderContextMenuStore.close(); this.renderSurface.dispose(); disposeSceneResources(this.universeLayer.scene); disposeSceneResources(this.galaxyLayer.scene); @@ -206,6 +213,7 @@ export class ViewerAppController { } private applySelectedItems(items: Selectable[], source: "viewer" | "ui") { + this.orderContextMenuStore.close(); this.selectedItems = items; if (items.length === 1) { const selection = items[0]; @@ -224,6 +232,7 @@ export class ViewerAppController { kind: Selectable["kind"] | null, entityId: string | null, ) { + this.orderContextMenuStore.close(); const selection = entityIdToSelectable(kind, entityId); this.selectedItems = selection ? [selection] : []; this.navigationController.syncFollowStateFromSelection(); @@ -270,6 +279,9 @@ export class ViewerAppController { this.currentDistance = nextState.currentDistance; this.povLevel = nextState.povLevel; this.orbitPitch = nextState.orbitPitch; + if (this.sceneStore.povLevel !== this.povLevel) { + this.sceneStore.setViewContext(this.activeSystemId ?? null, this.povLevel); + } this.navigationController.updateActiveSystem(); if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) { diff --git a/apps/viewer/src/api.ts b/apps/viewer/src/api.ts index 2a3feda..6606cc9 100644 --- a/apps/viewer/src/api.ts +++ b/apps/viewer/src/api.ts @@ -2,7 +2,12 @@ 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 { 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 type { PlayerAssetAssignmentCommandRequest, PlayerAutomationPolicyCommandRequest, @@ -23,16 +28,54 @@ export interface WorldStreamScope { bubbleId?: string | null; } -async function fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise { - const response = await fetch(input, init); +async function fetchJson(input: RequestInfo | URL, init?: RequestInit, options?: { skipAuth?: boolean; skipRefresh?: boolean }): Promise { + const headers = new Headers(init?.headers); + if (!options?.skipAuth) { + const session = getAuthSession(); + if (session?.accessToken) { + headers.set("Authorization", `Bearer ${session.accessToken}`); + } + } + + const response = await fetch(input, { + ...init, + headers, + }); + if (response.status === 401 && !options?.skipAuth && !options?.skipRefresh) { + const refreshed = await tryRefreshSession(); + if (refreshed) { + return fetchJson(input, init, { skipRefresh: true }); + } + } if (!response.ok) { throw new Error(`${init?.method ?? "GET"} ${typeof input === "string" ? input : input.toString()} failed with ${response.status}`); } return response.json() as Promise; } +async function tryRefreshSession(): Promise { + const session = getAuthSession(); + if (!session?.refreshToken) { + return false; + } + + const response = await fetch("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken: session.refreshToken }), + }); + if (!response.ok) { + clearAuthSession(); + return false; + } + + const nextSession = await response.json() as AuthSessionResponse; + setAuthSession(nextSession); + return true; +} + export async function fetchWorldSnapshot(signal?: AbortSignal) { - return fetchJson("/api/world", { signal }); + return fetchJson("/api/world", { signal }, { skipAuth: true }); } export function openWorldStream( @@ -86,16 +129,80 @@ export async function updateBalance(settings: BalanceSettings) { }); } +export async function createFaction(request: { factionId: string }) { + return fetchJson("/api/gm/factions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); +} + +export async function spawnShip(request: { factionId: string; systemId: string; shipId?: string | null; behaviorKind?: string | null }) { + return fetchJson("/api/gm/ships", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); +} + +export async function spawnStation(request: { factionId: string; systemId: string; objective?: string | null; label?: string | null }) { + return fetchJson("/api/gm/stations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); +} + export async function resetWorld() { return fetchJson("/api/world/reset", { method: "POST", }); } +export async function register(request: { email: string; password: string }) { + const session = await fetchJson("/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 }) { + const session = await fetchJson("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }, { skipAuth: true, skipRefresh: true }); + setAuthSession(session); + return session; +} + +export async function forgotPassword(request: { email: string }) { + return fetchJson("/api/auth/forgot-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }, { skipAuth: true, skipRefresh: true }); +} + +export async function resetPassword(request: { token: string; newPassword: string }) { + await fetchJson("/api/auth/reset-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }, { skipAuth: true, skipRefresh: true }); +} + export async function fetchPlayerFaction(signal?: AbortSignal) { return fetchJson("/api/player-faction", { signal }); } +export async function fetchShipAutomationCatalog(signal?: AbortSignal) { + return fetchJson("/api/ships/catalog", { signal }, { skipAuth: true }); +} + export async function createPlayerOrganization(request: PlayerOrganizationCommandRequest) { return fetchJson("/api/player-faction/organizations", { method: "POST", @@ -182,3 +289,9 @@ export async function updateShipDefaultBehavior(shipId: string, request: ShipDef body: JSON.stringify(request), }); } + +export async function removeShipOrder(shipId: string, orderId: string) { + return fetchJson(`/api/ships/${shipId}/orders/${orderId}`, { + method: "DELETE", + }); +} diff --git a/apps/viewer/src/assets/backdrop1.webp b/apps/viewer/src/assets/backdrop1.webp new file mode 100644 index 0000000..dbb316f Binary files /dev/null and b/apps/viewer/src/assets/backdrop1.webp differ diff --git a/apps/viewer/src/authSession.ts b/apps/viewer/src/authSession.ts new file mode 100644 index 0000000..f7c5f53 --- /dev/null +++ b/apps/viewer/src/authSession.ts @@ -0,0 +1,76 @@ +import type { AuthSessionResponse } from "./contractsAuth"; + +const STORAGE_KEY = "space-game.auth.session"; + +export interface AuthSession { + userId: string; + email: string; + roles: string[]; + accessToken: string; + accessTokenExpiresAtUtc: string; + refreshToken: string; + refreshTokenExpiresAtUtc: string; +} + +let currentSession: AuthSession | null = loadStoredSession(); +const listeners = new Set<(session: AuthSession | null) => void>(); + +export function getAuthSession(): AuthSession | null { + return currentSession; +} + +export function setAuthSession(session: AuthSessionResponse | null) { + currentSession = session ? { ...session } : null; + persistSession(currentSession); + notifyListeners(); +} + +export function clearAuthSession() { + currentSession = null; + persistSession(null); + notifyListeners(); +} + +export function subscribeToAuthSession(listener: (session: AuthSession | null) => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function loadStoredSession(): AuthSession | null { + if (typeof window === "undefined") { + return null; + } + + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as AuthSession; + return parsed?.accessToken && parsed?.refreshToken + ? { ...parsed, roles: Array.isArray(parsed.roles) ? parsed.roles : [] } + : null; + } catch { + return null; + } +} + +function persistSession(session: AuthSession | null) { + if (typeof window === "undefined") { + return; + } + + if (!session) { + window.localStorage.removeItem(STORAGE_KEY); + return; + } + + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); +} + +function notifyListeners() { + for (const listener of listeners) { + listener(currentSession); + } +} diff --git a/apps/viewer/src/components/AuthLandingPage.vue b/apps/viewer/src/components/AuthLandingPage.vue new file mode 100644 index 0000000..48b739e --- /dev/null +++ b/apps/viewer/src/components/AuthLandingPage.vue @@ -0,0 +1,185 @@ + + + diff --git a/apps/viewer/src/components/AuthSessionPanel.vue b/apps/viewer/src/components/AuthSessionPanel.vue new file mode 100644 index 0000000..ecb9f3f --- /dev/null +++ b/apps/viewer/src/components/AuthSessionPanel.vue @@ -0,0 +1,138 @@ + + + diff --git a/apps/viewer/src/components/ViewerEntityBrowserPanel.vue b/apps/viewer/src/components/ViewerEntityBrowserPanel.vue new file mode 100644 index 0000000..feac357 --- /dev/null +++ b/apps/viewer/src/components/ViewerEntityBrowserPanel.vue @@ -0,0 +1,322 @@ + + + diff --git a/apps/viewer/src/components/ViewerEntityInspectorPanel.vue b/apps/viewer/src/components/ViewerEntityInspectorPanel.vue new file mode 100644 index 0000000..901a6c0 --- /dev/null +++ b/apps/viewer/src/components/ViewerEntityInspectorPanel.vue @@ -0,0 +1,594 @@ + + + diff --git a/apps/viewer/src/components/ViewerShipOrderContextMenu.vue b/apps/viewer/src/components/ViewerShipOrderContextMenu.vue new file mode 100644 index 0000000..a67e9c7 --- /dev/null +++ b/apps/viewer/src/components/ViewerShipOrderContextMenu.vue @@ -0,0 +1,320 @@ + + + diff --git a/apps/viewer/src/components/gm/GmOpsWindow.vue b/apps/viewer/src/components/gm/GmOpsWindow.vue index f296221..8762020 100644 --- a/apps/viewer/src/components/gm/GmOpsWindow.vue +++ b/apps/viewer/src/components/gm/GmOpsWindow.vue @@ -1,5 +1,5 @@