Compare commits

..

13 Commits

261 changed files with 189497 additions and 15034 deletions

2
.gitignore vendored
View File

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

29
AGENTS.md Normal file
View File

@@ -0,0 +1,29 @@
# Pair Programming Mode
When working in this repository, act as a pair programming partner by default.
## Collaboration Rules
- Do not broaden scope on your own.
- Before coding, restate the request in your own words.
- Ask clarifying questions when scope, ownership, or design intent is ambiguous.
- Push back on weak assumptions, risky changes, or hidden refactors.
- Prefer discussion first, implementation second.
- Do not refactor adjacent code unless explicitly approved.
- Separate proposed work into:
- required
- optional
- recommended
- After scope is agreed, implement only that scope.
## Ambiguity Rules
- If the request is underspecified, stop and ask instead of assuming.
- If the requested change may interfere with an in-progress refactor, call that out before editing.
- If a request sounds small, keep the first response small and scoped unless asked to expand.
## Working Style
- Treat the user as an active collaborator, not a ticket queue.
- Surface tradeoffs before making structural changes.
- Prefer explicit approval before changing architecture, bootstrapping, dependency wiring, or data flow.

View File

@@ -3,4 +3,8 @@
<Folder Name="/apps/backend/">
<Project Path="apps/backend/SpaceGame.Api.csproj" />
</Folder>
<Folder Name="/tests/" />
<Folder Name="/tests/backend/">
<Project Path="tests/backend/SpaceGame.Api.Tests.csproj" />
</Folder>
</Solution>

View File

@@ -2,7 +2,6 @@ root = true
[*.{cs,csx}]
charset = utf-8
end_of_line = crlf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
@@ -40,7 +39,6 @@ csharp_new_line_before_open_brace = all
[*.{csproj,props,targets,sln,slnx}]
charset = utf-8
end_of_line = crlf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
@@ -48,7 +46,6 @@ indent_size = 2
[*.{json,jsonc}]
charset = utf-8
end_of_line = crlf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space

View File

@@ -0,0 +1,17 @@
using FastEndpoints;
namespace SpaceGame.Api.Auth.Api;
public sealed class ForgotPasswordHandler(AuthService authService) : Endpoint<ForgotPasswordRequest, ForgotPasswordResponse>
{
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);
}
}

View File

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

View File

@@ -0,0 +1,25 @@
using FastEndpoints;
namespace SpaceGame.Api.Auth.Api;
public sealed class LoginHandler(AuthService authService) : Endpoint<LoginRequest, AuthSessionResponse>
{
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);
}
}
}

View File

@@ -0,0 +1,25 @@
using FastEndpoints;
namespace SpaceGame.Api.Auth.Api;
public sealed class RefreshTokenHandler(AuthService authService) : Endpoint<RefreshTokenRequest, AuthSessionResponse>
{
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);
}
}
}

View File

@@ -0,0 +1,25 @@
using FastEndpoints;
namespace SpaceGame.Api.Auth.Api;
public sealed class RegisterHandler(AuthService authService) : Endpoint<RegisterRequest, RegisterResponse>
{
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);
}
}
}

View File

@@ -0,0 +1,26 @@
using FastEndpoints;
namespace SpaceGame.Api.Auth.Api;
public sealed class ResetPasswordHandler(AuthService authService) : Endpoint<ResetPasswordRequest>
{
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);
}
}
}

View File

@@ -0,0 +1,47 @@
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<string> Roles,
string AccessToken,
DateTimeOffset AccessTokenExpiresAtUtc,
string RefreshToken,
DateTimeOffset RefreshTokenExpiresAtUtc);
public sealed record RegisterResponse(
Guid UserId,
string Email,
bool RequiresLogin);
public sealed record ForgotPasswordResponse(
bool Accepted,
string? ResetToken = null);

View File

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

View File

@@ -0,0 +1,24 @@
namespace SpaceGame.Api.Auth.Runtime;
public sealed record UserAccount(
Guid Id,
string Email,
string PasswordHash,
DateTimeOffset CreatedAtUtc,
IReadOnlyList<string> 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);

View File

@@ -0,0 +1,14 @@
namespace SpaceGame.Api.Auth.Simulation;
public sealed class AuthOptions
{
public string ConnectionString { get; set; } = string.Empty;
public List<SeedUserOptions> DevSeedUsers { get; set; } = [];
}
public sealed class SeedUserOptions
{
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public List<string> Roles { get; set; } = [];
}

View File

@@ -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";
}

View File

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

View File

@@ -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<RegisterResponse> 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 new RegisterResponse(user.Id, user.Email, true);
}
public async Task<AuthSessionResponse> 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<AuthSessionResponse> 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<ForgotPasswordResponse> 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<AuthSessionResponse> 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.");
}
}
}

View File

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

View File

@@ -0,0 +1,7 @@
namespace SpaceGame.Api.Auth.Simulation;
public sealed class DevPasswordResetDelivery : IPasswordResetDelivery
{
public Task<ForgotPasswordResponse> DeliverAsync(UserAccount user, string resetToken, CancellationToken cancellationToken) =>
Task.FromResult(new ForgotPasswordResponse(true, resetToken));
}

View File

@@ -0,0 +1,40 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
namespace SpaceGame.Api.Auth.Simulation;
public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpContextAccessor) : IPlayerIdentityResolver
{
public const string EffectivePlayerHeaderName = "X-Act-As-Player-Id";
public Guid? GetCurrentPlayerId()
{
var subject = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? 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 Guid? GetEffectivePlayerId()
{
var currentPlayerId = GetCurrentPlayerId();
if (!CanAccessGm())
{
return currentPlayerId;
}
var requestedIdentity = httpContextAccessor.HttpContext?.Request.Headers[EffectivePlayerHeaderName].FirstOrDefault();
return Guid.TryParse(requestedIdentity, out var effectivePlayerId) ? effectivePlayerId : currentPlayerId;
}
public Guid GetRequiredEffectivePlayerId() =>
GetEffectivePlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required.");
public bool CanAccessGm()
{
var user = httpContextAccessor.HttpContext?.User;
return user?.IsInRole("gm") == true || user?.IsInRole("admin") == true;
}
}

View File

@@ -0,0 +1,18 @@
namespace SpaceGame.Api.Auth.Simulation;
public interface IAuthRepository
{
Task<UserAccount?> FindUserByEmailAsync(string email, CancellationToken cancellationToken);
Task<UserAccount?> FindUserByIdAsync(Guid userId, CancellationToken cancellationToken);
Task<IReadOnlyList<UserAccount>> ListUsersAsync(CancellationToken cancellationToken);
Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
Task<UserAccount> UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken);
Task<RefreshTokenRecord?> 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<PasswordResetTokenRecord?> FindPasswordResetTokenAsync(string tokenHash, CancellationToken cancellationToken);
Task ConsumePasswordResetTokenAsync(Guid passwordResetTokenId, CancellationToken cancellationToken);
Task UpdatePasswordHashAsync(Guid userId, string passwordHash, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,6 @@
namespace SpaceGame.Api.Auth.Simulation;
public interface IPasswordResetDelivery
{
Task<ForgotPasswordResponse> DeliverAsync(UserAccount user, string resetToken, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,10 @@
namespace SpaceGame.Api.Auth.Simulation;
public interface IPlayerIdentityResolver
{
Guid? GetCurrentPlayerId();
Guid GetRequiredPlayerId();
Guid? GetEffectivePlayerId();
Guid GetRequiredEffectivePlayerId();
bool CanAccessGm();
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

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

View File

@@ -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<byte> 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);
}
}

View File

@@ -0,0 +1,216 @@
using Npgsql;
namespace SpaceGame.Api.Auth.Simulation;
public sealed class PostgresAuthRepository(NpgsqlDataSource dataSource) : IAuthRepository
{
public async Task<UserAccount?> 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<UserAccount?> 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<IReadOnlyList<UserAccount>> ListUsersAsync(CancellationToken cancellationToken)
{
await using var command = dataSource.CreateCommand("""
select id, email, password_hash, created_at_utc, roles
from auth_users
order by email asc
""");
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var users = new List<UserAccount>();
while (await reader.ReadAsync(cancellationToken))
{
users.Add(ReadUser(reader));
}
return users;
}
public async Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken)
{
var userId = Guid.NewGuid();
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<UserAccount> UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection<string> 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<RefreshTokenRecord?> 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<DateTimeOffset>(3),
reader.GetFieldValue<DateTimeOffset>(4),
reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(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<PasswordResetTokenRecord?> 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<DateTimeOffset>(3),
reader.GetFieldValue<DateTimeOffset>(4),
reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(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<DateTimeOffset>(3),
reader.GetFieldValue<string[]>(4));
}

View File

@@ -0,0 +1,21 @@
using System.Security.Cryptography;
using System.Text;
namespace SpaceGame.Api.Auth.Simulation;
public sealed class RefreshTokenFactory
{
public string CreateToken()
{
Span<byte> 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);
}
}

View File

@@ -1,6 +1,8 @@
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;
@@ -40,19 +42,6 @@ public sealed class ItemProductionDefinition
public List<ItemEffectDefinition> Effects { get; set; } = [];
}
public sealed class BalanceDefinition
{
public float SimulationSpeedMultiplier { get; set; } = 1f;
public float YPlane { get; set; }
public float ArrivalThreshold { get; set; }
public float MiningRate { get; set; }
public float MiningCycleSeconds { get; set; }
public float TransferRate { get; set; }
public float DockingDuration { get; set; }
public float UndockingDuration { get; set; }
public float UndockDistance { get; set; }
}
public sealed class StarDefinition
{
public string Kind { get; set; } = "main-sequence";
@@ -87,6 +76,39 @@ public sealed class SolarSystemDefinition
public required List<PlanetDefinition> Planets { get; set; }
}
public sealed class RaceDefinition
{
public required string Id { get; set; }
public required string Name { get; set; }
public string Description { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
[JsonIgnore]
public string Label => Name;
}
public sealed class FactionDefinition
{
public required string Id { get; set; }
public int Version { get; set; }
public required string Name { get; set; }
public string Description { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public string? Race { get; set; }
public List<FactionLicenseDefinition> Licenses { get; set; } = [];
[JsonIgnore]
public string Label => Name;
[JsonIgnore]
public string? RaceId => Race;
}
public sealed class FactionLicenseDefinition
{
public required string Type { get; set; }
public string Name { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public float Price { get; set; }
}
public sealed class AsteroidFieldDefinition
{
public int DecorationCount { get; set; }
@@ -114,10 +136,8 @@ public sealed class ItemDefinition
public required string Id { get; set; }
public required string Name { get; set; }
public string Description { get; set; } = string.Empty;
public string Type { get; set; } = "material";
public string CargoKind { get; set; } = string.Empty;
[JsonIgnore]
public StorageKind? CargoStorageKind { get; set; }
public StorageKind? CargoKind { get; set; }
public float Volume { get; set; } = 1f;
public int Version { get; set; }
public string FactoryName { get; set; } = string.Empty;
@@ -130,7 +150,8 @@ public sealed class ItemDefinition
[JsonPropertyName("transport")]
public string Transport
{
set => CargoKind = value;
get => CargoKind?.ToDataValue() ?? string.Empty;
set => CargoKind = value.ToNullableStorageKind();
}
}
@@ -151,12 +172,6 @@ public sealed class RecipeInputDefinition
}
}
public sealed class ModuleConstructionDefinition
{
public required List<RecipeInputDefinition> Requirements { get; set; }
public float ProductionTime { get; set; }
}
public sealed class ModuleDockDefinition
{
public int Capacity { get; set; }
@@ -169,10 +184,14 @@ public sealed class ModuleCargoDefinition
public required string Type { get; set; }
}
public sealed class ModuleWorkForceDefinition
public sealed class ModuleWorkforceDefinition
{
public float Capacity { get; set; }
public float Max { get; set; }
[JsonPropertyName("capacity")]
public float SupportedPopulation { get; set; }
[JsonPropertyName("max")]
public float RequiredWorkforce { get; set; }
public string Race { get; set; } = string.Empty;
}
@@ -184,7 +203,7 @@ public sealed class ModuleMountDefinition
public List<string> Types { get; set; } = [];
}
public sealed class ModuleProductionDefinition
public sealed class ModuleBuildRecipeDefinition
{
public float Time { get; set; }
public float Amount { get; set; }
@@ -207,12 +226,9 @@ public class ModuleDefinition
Description = source.Description;
Type = source.Type;
ModuleType = source.ModuleType;
Product = source.Product;
Products = [.. source.Products];
ProductionMode = source.ProductionMode;
ProductIds = [.. source.ProductIds];
Radius = source.Radius;
Hull = source.Hull;
WorkforceNeeded = source.WorkforceNeeded;
Version = source.Version;
Macro = source.Macro;
MakerRace = source.MakerRace;
@@ -220,12 +236,11 @@ public class ModuleDefinition
Price = source.Price;
Owners = [.. source.Owners];
Cargo = source.Cargo;
WorkForce = source.WorkForce;
SerializedWorkforce = source.SerializedWorkforce;
Docks = [.. source.Docks];
Shields = [.. source.Shields];
Turrets = [.. source.Turrets];
Production = [.. source.Production];
Construction = source.Construction;
BuildRecipes = [.. source.BuildRecipes];
}
public required string Id { get; set; }
@@ -234,13 +249,12 @@ public class ModuleDefinition
public required string Type { get; set; }
[JsonIgnore]
public ModuleType ModuleType { get; set; }
[JsonPropertyName("product")]
public List<string> ProductIds { get; set; } = [];
[JsonIgnore]
public string? Product { get; set; }
public List<string> Products { get; set; } = [];
public string ProductionMode { get; set; } = "passive";
public virtual IReadOnlyList<string> ProductItemIds => [];
public float Radius { get; set; } = 12f;
public float Hull { get; set; } = 100f;
public float WorkforceNeeded { get; set; }
public int Version { get; set; }
public string Macro { get; set; } = string.Empty;
public string MakerRace { get; set; } = string.Empty;
@@ -248,30 +262,58 @@ public class ModuleDefinition
public ItemPriceDefinition? Price { get; set; }
public List<string> Owners { get; set; } = [];
public ModuleCargoDefinition? Cargo { get; set; }
public ModuleWorkForceDefinition? WorkForce { get; set; }
[JsonPropertyName("workForce")]
public ModuleWorkforceDefinition? SerializedWorkforce { get; set; }
public List<ModuleDockDefinition> Docks { get; set; } = [];
public List<ModuleMountDefinition> Shields { get; set; } = [];
public List<ModuleMountDefinition> Turrets { get; set; } = [];
public List<ModuleProductionDefinition> Production { get; set; } = [];
public ModuleConstructionDefinition? Construction { get; set; }
[JsonPropertyName("product")]
public List<string> ProductIds
[JsonPropertyName("production")]
public List<ModuleBuildRecipeDefinition> BuildRecipes { get; set; } = [];
}
public abstract class ProductionLaneModuleDefinition : ModuleDefinition
{
[SetsRequiredMembers]
protected ProductionLaneModuleDefinition(ModuleDefinition source, float requiredWorkforce)
: base(source)
{
RequiredWorkforce = requiredWorkforce;
}
public float RequiredWorkforce { get; init; }
}
public sealed class ProductionModuleDefinition : ProductionLaneModuleDefinition
{
[SetsRequiredMembers]
internal ProductionModuleDefinition(ModuleDefinition source, float requiredWorkforce)
: base(source, requiredWorkforce)
{
ProductItemIds = [.. source.ProductIds];
}
public override IReadOnlyList<string> ProductItemIds { get; } = [];
}
public sealed class BuildModuleDefinition : ProductionLaneModuleDefinition
{
[SetsRequiredMembers]
internal BuildModuleDefinition(ModuleDefinition source, float requiredWorkforce)
: base(source, requiredWorkforce)
{
get => Products;
set => Products = value ?? [];
}
}
public sealed class ProductionModuleDefinition : ModuleDefinition
public sealed class HabitationModuleDefinition : ModuleDefinition
{
[SetsRequiredMembers]
internal ProductionModuleDefinition(ModuleDefinition source)
internal HabitationModuleDefinition(ModuleDefinition source, float supportedPopulation)
: base(source)
{
ProductItemIds = [.. source.Products];
SupportedPopulation = supportedPopulation;
}
public IReadOnlyList<string> ProductItemIds { get; init; } = [];
public float SupportedPopulation { get; init; }
}
public sealed class StorageModuleDefinition : ModuleDefinition
@@ -327,34 +369,202 @@ 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; }
public required string Label { get; set; }
public required string Kind { get; set; }
public required string Class { get; set; }
public float Speed { get; set; }
public float WarpSpeed { get; set; }
public float FtlSpeed { get; set; }
public float SpoolTime { get; set; }
public float CargoCapacity { get; set; }
public string? CargoKind { get; set; }
public int Version { get; set; }
public required string Name { get; set; }
public string Description { get; set; } = string.Empty;
public string Size { get; set; } = string.Empty;
public float ExplosionDamage { get; set; }
public float Hull { get; set; }
public Dictionary<string, float> Storage { get; set; } = new(StringComparer.Ordinal);
public int People { get; set; }
public ShipPurpose Purpose { get; set; }
public string Thruster { get; set; } = string.Empty;
public ShipType Type { get; set; }
public float Mass { get; set; }
public ShipInertiaDefinition? Inertia { get; set; }
public ShipDragDefinition? Drag { get; set; }
public List<ShipMountDefinition> Engines { get; set; } = [];
public List<ShipMountDefinition> Shields { get; set; } = [];
public List<ShipMountDefinition> Weapons { get; set; } = [];
public List<ShipMountDefinition> Turrets { get; set; } = [];
public List<ShipCargoDefinition> Cargo { get; set; } = [];
public List<ModuleDockDefinition> Docks { get; set; } = [];
public List<string> Owners { get; set; } = [];
public ItemPriceDefinition? Price { get; set; }
public List<ItemProductionDefinition> Production { get; set; } = [];
[JsonIgnore]
public StorageKind? CargoStorageKind { get; set; }
public required string Color { get; set; }
public required string HullColor { get; set; }
public float Size { get; set; }
public float MaxHealth { get; set; }
public List<string> Capabilities { get; set; } = [];
public ConstructionDefinition? Construction { get; set; }
public float Speed => InferLocalSpeed(Size);
[JsonIgnore]
public float WarpSpeed => InferWarpSpeed(Size);
[JsonIgnore]
public float FtlSpeed => InferFtlSpeed(Size);
[JsonIgnore]
public float SpoolTime => InferSpoolTime(Size);
public float GetTotalCargoCapacity() => Cargo.Sum(entry => entry.Max);
public float GetCargoCapacity(StorageKind kind) =>
Cargo
.Where(entry => entry.Types.Any(type => type.ToNullableStorageKind() == kind))
.Sum(entry => entry.Max);
public bool SupportsCargoKind(StorageKind kind) =>
GetCargoCapacity(kind) > 0f;
private static float InferWarpSpeed(string size) =>
size switch
{
"extrasmall" => 4.8f,
"small" => 4.2f,
"medium" => 3.4f,
"large" => 2.4f,
"extralarge" => 1.8f,
_ => 3f,
};
private static float InferLocalSpeed(string size) =>
size switch
{
"extrasmall" => 420f,
"small" => 320f,
"medium" => 230f,
"large" => 150f,
"extralarge" => 110f,
_ => 200f,
};
private static float InferFtlSpeed(string size) =>
size switch
{
"extrasmall" => 1f,
"small" => 0.85f,
"medium" => 0.7f,
"large" => 0.55f,
"extralarge" => 0.45f,
_ => 0.6f,
};
private static float InferSpoolTime(string size) =>
size switch
{
"extrasmall" => 0.8f,
"small" => 1f,
"medium" => 1.4f,
"large" => 2f,
"extralarge" => 2.6f,
_ => 1.5f,
};
}
public sealed class ShipInertiaDefinition
{
public float Pitch { get; set; }
public float Yaw { get; set; }
public float Roll { get; set; }
}
public sealed class ShipDragDefinition
{
public float Forward { get; set; }
public float Reverse { get; set; }
public float Horizontal { get; set; }
public float Vertical { get; set; }
public float Pitch { get; set; }
public float Yaw { get; set; }
public float Roll { get; set; }
}
public sealed class ShipMountDefinition
{
public string? Group { get; set; }
public required string Size { get; set; }
public bool Hittable { get; set; }
public List<string> Types { get; set; } = [];
}
public sealed class ShipCargoDefinition
{
public float Max { get; set; }
public List<string> Types { get; set; } = [];
}
public sealed class ScenarioDefinition
{
public required WorldGenerationOptions WorldGeneration { get; set; }
// Temporary QA escape hatch so a scenario can pin an exact topology.
// Do not treat this as the long-term world authoring model.
public List<SolarSystemDefinition>? Systems { get; set; }
public required List<InitialStationDefinition> InitialStations { get; set; }
public required List<ShipFormationDefinition> ShipFormations { get; set; }
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }
public required MiningDefaultsDefinition MiningDefaults { get; set; }
}
public sealed class InitialStationDefinition
@@ -385,9 +595,3 @@ public sealed class PatrolRouteDefinition
public required string SystemId { get; set; }
public required List<float[]> Points { get; set; }
}
public sealed class MiningDefaultsDefinition
{
public required string NodeSystemId { get; set; }
public required string RefinerySystemId { get; set; }
}

View File

@@ -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<SimulationEventRecord> events)
internal void UpdateCommanders(SimulationWorld world, IPlayerStateStore playerStateStore, float deltaSeconds, ICollection<SimulationEventRecord> 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<string, CommanderRuntime> 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));
@@ -1092,14 +1097,14 @@ internal sealed class CommanderPlanningService
{
theaters.Add(new FactionTheaterRuntime
{
Id = $"theater-expansion-{expansionProject.SystemId}-{expansionProject.CelestialId}",
Id = $"theater-expansion-{expansionProject.SystemId}-{expansionProject.AnchorId}",
Kind = "expansion-front",
SystemId = expansionProject.SystemId,
Status = "active",
Priority = 65f + (economicAssessment.HasShipyard ? 0f : 15f),
SupplyRisk = ComputeSystemRisk(world, faction, expansionProject.SystemId),
FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, expansionProject.SystemId),
AnchorEntityId = expansionProject.SiteId ?? expansionProject.CelestialId,
AnchorEntityId = expansionProject.SiteId ?? expansionProject.AnchorId,
AnchorPosition = ResolveExpansionAnchor(world, expansionProject),
UpdatedAtUtc = nowUtc,
});
@@ -1267,7 +1272,7 @@ internal sealed class CommanderPlanningService
],
"expansion" =>
[
new FactionPlanStepRuntime { Id = $"{campaign.Id}-claim", Kind = "secure-claim", Status = "active", Summary = $"Secure {expansionProject?.CelestialId ?? campaign.TargetEntityId} for construction." },
new FactionPlanStepRuntime { Id = $"{campaign.Id}-claim", Kind = "secure-claim", Status = "active", Summary = $"Secure {expansionProject?.AnchorId ?? campaign.TargetEntityId} for construction." },
new FactionPlanStepRuntime { Id = $"{campaign.Id}-supply", Kind = "supply-site", Status = "planned", Summary = "Move construction materials to the site." },
new FactionPlanStepRuntime { Id = $"{campaign.Id}-guard", Kind = "guard-site", Status = "planned", Summary = "Defend the expansion site until operational." },
],
@@ -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<string>(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,
PreferredNodeId = fallback.PreferredNodeId,
ItemId = objective.ItemId ?? fallback.ItemId,
PreferredAnchorId = fallback.PreferredAnchorId,
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,8 +2749,8 @@ internal sealed class CommanderPlanningService
target.HomeStationId = source.HomeStationId;
target.AreaSystemId = source.AreaSystemId;
target.TargetEntityId = source.TargetEntityId;
target.PreferredItemId = source.PreferredItemId;
target.PreferredNodeId = source.PreferredNodeId;
target.ItemId = source.ItemId;
target.PreferredAnchorId = source.PreferredAnchorId;
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
target.PreferredModuleId = source.PreferredModuleId;
target.TargetPosition = source.TargetPosition;
@@ -2749,8 +2770,8 @@ 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.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
&& string.Equals(left.PreferredAnchorId, right.PreferredAnchorId, StringComparison.Ordinal)
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
&& Nullable.Equals(left.TargetPosition, right.TargetPosition)
@@ -2771,7 +2792,7 @@ internal sealed class CommanderPlanningService
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
&& string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal)
&& string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal)
&& string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
&& left.WaitSeconds.Equals(right.WaitSeconds)
@@ -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 == DockAtStation ? objective.TargetEntityId : null,
ItemId = objective.ItemId,
WaitSeconds = 0f,
Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f),
@@ -2840,9 +2863,10 @@ internal sealed class CommanderPlanningService
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId);
if (site?.CelestialId is { } celestialId)
if (site is not null)
{
return world.Celestials.FirstOrDefault(celestial => celestial.Id == celestialId)?.Position;
return world.Anchors.FirstOrDefault(anchor => anchor.Id == site.AnchorId)?.Position
?? Vector3.Zero;
}
return null;
@@ -2850,13 +2874,13 @@ internal sealed class CommanderPlanningService
private static bool ReconcileAiOrders(ShipRuntime ship, ShipOrderRuntime? desiredOrder)
{
var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal) && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))) > 0;
var changed = ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal) && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))) > 0;
if (desiredOrder is null)
{
return changed;
}
var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal));
var existing = ship.OrderQueue.FindById(desiredOrder.Id);
if (existing is not null)
{
if (ShipOrdersEqual(existing, desiredOrder))
@@ -2864,18 +2888,18 @@ internal sealed class CommanderPlanningService
return changed;
}
ship.OrderQueue.Remove(existing);
changed = true;
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
return true;
}
if (ship.OrderQueue.Count >= MaxAiOrdersPerShip)
{
changed |= ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0;
changed |= ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0;
}
if (ship.OrderQueue.Count < 8)
if (ship.OrderQueue.Count < ShipOrderQueue.MaxOrders)
{
ship.OrderQueue.Add(desiredOrder);
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
changed = true;
}
@@ -2885,6 +2909,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)
@@ -2894,7 +2920,7 @@ internal sealed class CommanderPlanningService
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
&& string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal)
&& string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal)
&& string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
&& left.WaitSeconds.Equals(right.WaitSeconds)
@@ -2920,7 +2946,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)
{
@@ -3357,7 +3383,7 @@ internal sealed class CommanderPlanningService
{
"defense-front" => $"Defend {theater.SystemId} from hostile pressure.",
"offense-front" => $"Project force into {theater.SystemId}.",
"expansion-front" => $"Expand into {expansionProject?.CelestialId ?? theater.SystemId}.",
"expansion-front" => $"Expand into {expansionProject?.AnchorId ?? theater.SystemId}.",
"economic-front" => $"Stabilize commodity shortages around {theater.AnchorEntityId ?? theater.SystemId}.",
_ => theater.Kind,
};
@@ -3399,13 +3425,13 @@ internal sealed class CommanderPlanningService
private static Vector3 ResolveExpansionAnchor(SimulationWorld world, IndustryExpansionProject project)
{
if (project.SiteId is not null
&& world.ConstructionSites.FirstOrDefault(site => site.Id == project.SiteId) is { } site
&& world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId) is { } siteCelestial)
&& world.ConstructionSites.FirstOrDefault(site => site.Id == project.SiteId) is { } site)
{
return siteCelestial.Position;
return world.Anchors.FirstOrDefault(candidate => candidate.Id == site.AnchorId)?.Position
?? Vector3.Zero;
}
return world.Celestials.FirstOrDefault(candidate => candidate.Id == project.CelestialId)?.Position
return world.Anchors.FirstOrDefault(candidate => candidate.Id == project.AnchorId)?.Position
?? ResolveSystemAnchor(world, project.SystemId);
}

View File

@@ -88,7 +88,7 @@ public sealed record TerritoryClaimSnapshot(
string? SourceClaimId,
string FactionId,
string SystemId,
string CelestialId,
string AnchorId,
string Status,
string ClaimKind,
float ClaimStrength,

View File

@@ -126,7 +126,7 @@ public sealed class TerritoryClaimRuntime
public string? SourceClaimId { get; set; }
public required string FactionId { get; set; }
public required string SystemId { get; set; }
public required string CelestialId { get; set; }
public required string AnchorId { get; set; }
public string Status { get; set; } = "active";
public string ClaimKind { get; set; } = "infrastructure";
public float ClaimStrength { get; set; }

View File

@@ -1,5 +1,7 @@
using System.Globalization;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Geopolitics.Simulation;
internal sealed class GeopoliticalSimulationService
@@ -159,7 +161,7 @@ internal sealed class GeopoliticalSimulationService
SourceClaimId = claim.Id,
FactionId = claim.FactionId,
SystemId = claim.SystemId,
CelestialId = claim.CelestialId,
AnchorId = claim.AnchorId,
Status = claim.State,
ClaimKind = "infrastructure",
ClaimStrength = claim.State == ClaimStateKinds.Active ? 1f : 0.65f,
@@ -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
{

View File

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

View File

@@ -21,13 +21,13 @@ internal static class FactionIndustryPlanner
return null;
}
var targetCelestial = SelectFoundationCelestial(world, factionId, bottleneckCommodity);
if (targetCelestial is null)
var targetAnchor = SelectFoundationAnchor(world, factionId, bottleneckCommodity);
if (targetAnchor is null)
{
return null;
}
var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId);
var supportStation = SelectSupportStation(world, factionId, moduleId, targetAnchor.SystemId);
if (supportStation is null)
{
return null;
@@ -36,8 +36,8 @@ internal static class FactionIndustryPlanner
return new IndustryExpansionProject(
bottleneckCommodity,
moduleId,
targetCelestial.SystemId,
targetCelestial.Id,
targetAnchor.SystemId,
targetAnchor.Id,
supportStation.Id);
}
@@ -93,13 +93,13 @@ internal static class FactionIndustryPlanner
return null;
}
var targetCelestial = SelectLogisticsFoundationCelestial(world, factionId);
if (targetCelestial is null)
var targetAnchor = SelectLogisticsFoundationAnchor(world, factionId);
if (targetAnchor is null)
{
return null;
}
var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetCelestial.SystemId);
var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetAnchor.SystemId);
if (supportStation is null)
{
return null;
@@ -108,8 +108,8 @@ internal static class FactionIndustryPlanner
return new IndustryExpansionProject(
"shipyard",
shipyardModuleId,
targetCelestial.SystemId,
targetCelestial.Id,
targetAnchor.SystemId,
targetAnchor.Id,
supportStation.Id);
}
@@ -129,13 +129,13 @@ internal static class FactionIndustryPlanner
return null;
}
var bootstrapCelestial = SelectFoundationCelestial(world, factionId, bootstrapCommodity);
if (bootstrapCelestial is null)
var bootstrapAnchor = SelectFoundationAnchor(world, factionId, bootstrapCommodity);
if (bootstrapAnchor is null)
{
return null;
}
var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapCelestial.SystemId);
var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapAnchor.SystemId);
if (bootstrapSupportStation is null)
{
return null;
@@ -144,8 +144,8 @@ internal static class FactionIndustryPlanner
return new IndustryExpansionProject(
bootstrapCommodity,
bootstrapModuleId,
bootstrapCelestial.SystemId,
bootstrapCelestial.Id,
bootstrapAnchor.SystemId,
bootstrapAnchor.Id,
bootstrapSupportStation.Id);
}
@@ -161,13 +161,13 @@ internal static class FactionIndustryPlanner
return null;
}
var targetCelestial = SelectFoundationCelestial(world, factionId, commodityId);
if (targetCelestial is null)
var targetAnchor = SelectFoundationAnchor(world, factionId, commodityId);
if (targetAnchor is null)
{
return null;
}
var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId);
var supportStation = SelectSupportStation(world, factionId, moduleId, targetAnchor.SystemId);
if (supportStation is null)
{
return null;
@@ -176,8 +176,8 @@ internal static class FactionIndustryPlanner
return new IndustryExpansionProject(
commodityId,
moduleId,
targetCelestial.SystemId,
targetCelestial.Id,
targetAnchor.SystemId,
targetAnchor.Id,
supportStation.Id);
}
@@ -207,7 +207,7 @@ internal static class FactionIndustryPlanner
site.TargetDefinitionId,
site.BlueprintId,
site.SystemId,
site.CelestialId,
site.AnchorId,
supportStationId,
site.Id);
}
@@ -225,7 +225,7 @@ internal static class FactionIndustryPlanner
}
var nowUtc = DateTimeOffset.UtcNow;
var claimId = $"claim-{factionId}-{project.CelestialId}";
var claimId = $"claim-{factionId}-{project.AnchorId}";
if (world.Claims.All(candidate => candidate.Id != claimId))
{
world.Claims.Add(new ClaimRuntime
@@ -233,7 +233,7 @@ internal static class FactionIndustryPlanner
Id = claimId,
FactionId = factionId,
SystemId = project.SystemId,
CelestialId = project.CelestialId,
AnchorId = project.AnchorId,
PlacedAtUtc = nowUtc,
ActivatesAtUtc = nowUtc.AddSeconds(8),
State = ClaimStateKinds.Activating,
@@ -246,7 +246,7 @@ internal static class FactionIndustryPlanner
return;
}
var siteId = $"site-{factionId}-{project.CelestialId}";
var siteId = $"site-{factionId}-{project.AnchorId}";
if (world.ConstructionSites.Any(candidate => candidate.Id == siteId))
{
return;
@@ -257,7 +257,7 @@ internal static class FactionIndustryPlanner
Id = siteId,
FactionId = factionId,
SystemId = project.SystemId,
CelestialId = project.CelestialId,
AnchorId = project.AnchorId,
TargetKind = "station-foundation",
TargetDefinitionId = project.CommodityId,
BlueprintId = project.ModuleId,
@@ -450,51 +450,51 @@ internal static class FactionIndustryPlanner
private static float GetTargetLevelSeconds(string itemId) =>
string.Equals(itemId, "water", StringComparison.Ordinal) ? WaterTargetLevelSeconds : CommodityTargetLevelSeconds;
private static CelestialRuntime? SelectFoundationCelestial(SimulationWorld world, string factionId, string commodityId)
private static AnchorRuntime? SelectFoundationAnchor(SimulationWorld world, string factionId, string commodityId)
{
var resourceItems = ResolveRootResourceItems(world, commodityId);
return world.Celestials
.Where(celestial =>
celestial.Kind == SpatialNodeKind.LagrangePoint
&& celestial.OccupyingStructureId is null
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed)
&& IsExpansionSystemEligible(world, factionId, celestial.SystemId))
.OrderByDescending(celestial => ScoreCelestial(world, factionId, celestial, resourceItems))
return world.Anchors
.Where(anchor =>
anchor.Kind == SpatialNodeKind.LagrangePoint
&& anchor.OccupyingStructureId is null
&& world.Claims.All(claim => claim.AnchorId != anchor.Id || claim.State == ClaimStateKinds.Destroyed)
&& IsExpansionSystemEligible(world, factionId, anchor.SystemId))
.OrderByDescending(anchor => ScoreAnchor(world, factionId, anchor, resourceItems))
.FirstOrDefault();
}
private static CelestialRuntime? SelectLogisticsFoundationCelestial(SimulationWorld world, string factionId)
private static AnchorRuntime? SelectLogisticsFoundationAnchor(SimulationWorld world, string factionId)
{
return world.Celestials
.Where(celestial =>
celestial.Kind == SpatialNodeKind.LagrangePoint
&& celestial.OccupyingStructureId is null
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed)
&& IsExpansionSystemEligible(world, factionId, celestial.SystemId))
.OrderByDescending(celestial => world.Stations.Count(station =>
return world.Anchors
.Where(anchor =>
anchor.Kind == SpatialNodeKind.LagrangePoint
&& anchor.OccupyingStructureId is null
&& world.Claims.All(claim => claim.AnchorId != anchor.Id || claim.State == ClaimStateKinds.Destroyed)
&& IsExpansionSystemEligible(world, factionId, anchor.SystemId))
.OrderByDescending(anchor => world.Stations.Count(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)))
.ThenByDescending(celestial => world.Stations
&& string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal)))
.ThenByDescending(anchor => world.Stations
.Where(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal))
&& string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal))
.Sum(station => station.Inventory.Values.Sum()))
.FirstOrDefault();
}
private static float ScoreCelestial(SimulationWorld world, string factionId, CelestialRuntime celestial, IReadOnlyCollection<string> resourceItems)
private static float ScoreAnchor(SimulationWorld world, string factionId, AnchorRuntime anchor, IReadOnlyCollection<string> resourceItems)
{
var resourceScore = world.Nodes
.Where(node => node.SystemId == celestial.SystemId && resourceItems.Contains(node.ItemId, StringComparer.Ordinal))
.Where(node => node.SystemId == anchor.SystemId && resourceItems.Contains(node.ItemId, StringComparer.Ordinal))
.Sum(node => node.OreRemaining);
var factionPresence = world.Stations.Count(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal));
var controlState = GeopoliticalSimulationService.GetSystemControlState(world, celestial.SystemId);
var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, celestial.SystemId);
var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == celestial.SystemId);
&& string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal));
var controlState = GeopoliticalSimulationService.GetSystemControlState(world, anchor.SystemId);
var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, anchor.SystemId);
var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == anchor.SystemId);
var pressure = world.Geopolitics?.Territory.Pressures
.Where(entry => entry.SystemId == celestial.SystemId && entry.FactionId == factionId)
.Where(entry => entry.SystemId == anchor.SystemId && entry.FactionId == factionId)
.OrderByDescending(entry => entry.HostileInfluence)
.ThenBy(entry => entry.Id, StringComparer.Ordinal)
.FirstOrDefault();
@@ -515,7 +515,7 @@ internal static class FactionIndustryPlanner
};
var securityPenalty = ((pressure?.HostileInfluence ?? 0f) * 14f)
+ ((strategicProfile?.TerritorialPressure ?? 0f) * 9f)
+ ((world.Geopolitics is null ? 0f : GeopoliticalSimulationService.GetSystemRouteRisk(world, celestial.SystemId, factionId)) * 250f);
+ ((world.Geopolitics is null ? 0f : GeopoliticalSimulationService.GetSystemRouteRisk(world, anchor.SystemId, factionId)) * 250f);
return resourceScore
+ (factionPresence * 5_000f)
+ controlBias
@@ -585,6 +585,6 @@ internal sealed record IndustryExpansionProject(
string CommodityId,
string ModuleId,
string SystemId,
string CelestialId,
string AnchorId,
string SupportStationId,
string? SiteId = null);

View File

@@ -14,7 +14,7 @@ internal static class ProductionGraphBuilder
ItemId = item.Id,
Name = item.Name,
Group = item.Group,
CargoKind = item.CargoKind,
CargoKind = item.CargoKind?.ToDataValue() ?? string.Empty,
},
StringComparer.Ordinal);
@@ -87,7 +87,7 @@ internal static class ProductionGraphBuilder
outputsByModuleId[module.Id] = outputs;
}
foreach (var product in module.Products)
foreach (var product in module.ProductItemIds)
{
outputs.Add(product);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -194,7 +194,7 @@ public sealed record PlayerDirectiveSnapshot(
bool UseOrders,
string? StagingOrderKind,
string? ItemId,
string? PreferredNodeId,
string? PreferredAnchorId,
string? PreferredConstructionSiteId,
string? PreferredModuleId,
int Priority,
@@ -249,7 +249,10 @@ public sealed record PlayerAlertSnapshot(
public sealed record PlayerFactionSnapshot(
string Id,
string Label,
string? PersonaName,
string? RaceId,
string SovereignFactionId,
bool RequiresOnboarding,
string Status,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc,

View File

@@ -1,5 +1,9 @@
namespace SpaceGame.Api.PlayerFaction.Contracts;
public sealed record CompletePlayerOnboardingRequest(
string Name,
string RaceId);
public sealed record PlayerOrganizationCommandRequest(
string Kind,
string Label,
@@ -41,7 +45,7 @@ public sealed record PlayerDirectiveCommandRequest(
string? SourceStationId,
string? DestinationStationId,
string? ItemId,
string? PreferredNodeId,
string? PreferredAnchorId,
string? PreferredConstructionSiteId,
string? PreferredModuleId,
int Priority,

View File

@@ -1,10 +1,15 @@
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
namespace SpaceGame.Api.PlayerFaction.Runtime;
public sealed class PlayerFactionRuntime
{
public required string Id { get; init; }
public required string Label { get; set; }
public string? PersonaName { get; set; }
public string? RaceId { get; set; }
public required string SovereignFactionId { get; set; }
public bool RequiresOnboarding { get; set; } = true;
public string Status { get; set; } = "active";
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
@@ -180,7 +185,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,11 +247,11 @@ 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; }
public string? PreferredNodeId { get; set; }
public string? PreferredAnchorId { get; set; }
public string? PreferredConstructionSiteId { get; set; }
public string? PreferredModuleId { get; set; }
public int Priority { get; set; } = 50;

View File

@@ -0,0 +1,10 @@
namespace SpaceGame.Api.PlayerFaction.Simulation;
public interface IPlayerStateStore
{
bool TryGetPlayerFaction(string playerId, out PlayerFactionRuntime playerFaction);
PlayerFactionRuntime GetOrAddPlayerFaction(string playerId, Func<PlayerFactionRuntime> factory);
IReadOnlyCollection<PlayerFactionRuntime> GetPlayerFactions();
IReadOnlyDictionary<string, PlayerFactionRuntime> GetPlayerFactionsByPlayerId();
void Clear();
}

View File

@@ -0,0 +1,273 @@
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.PersonaName,
player.RaceId,
player.SovereignFactionId,
player.RequiresOnboarding,
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.PreferredAnchorId,
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.AnchorId,
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);
}

View File

@@ -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,53 +9,111 @@ 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;
}
var sovereignFaction = world.Factions.FirstOrDefault(faction => string.Equals(faction.Id, LoaderSupport.DefaultFactionId, StringComparison.Ordinal))
?? world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).First();
world.PlayerFaction = new PlayerFactionRuntime
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
{
var player = playerStateStore.GetOrAddPlayerFaction(playerId, () => new PlayerFactionRuntime
{
Id = PlayerFactionDomainId,
Label = $"{sovereignFaction.Label} Command",
SovereignFactionId = sovereignFaction.Id,
Label = "Pending Pilot",
SovereignFactionId = string.Empty,
RequiresOnboarding = true,
CreatedAtUtc = world.GeneratedAtUtc,
UpdatedAtUtc = world.GeneratedAtUtc,
};
});
EnsureBaseStructures(world, world.PlayerFaction);
SyncRegistry(world, world.PlayerFaction);
return world.PlayerFaction;
}
internal void Update(SimulationWorld world, float _deltaSeconds, ICollection<SimulationEventRecord> events)
{
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;
return player;
}
internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, PlayerOrganizationCommandRequest request)
internal PlayerFactionRuntime CompleteOnboarding(
SimulationWorld world,
IPlayerStateStore playerStateStore,
string playerId,
CompletePlayerOnboardingRequest request)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
if (!player.RequiresOnboarding)
{
throw new InvalidOperationException("Player onboarding has already been completed.");
}
var personaName = request.Name.Trim();
if (personaName.Length < 2)
{
throw new InvalidOperationException("Player name must contain at least 2 characters.");
}
if (personaName.Length > 48)
{
throw new InvalidOperationException("Player name must contain at most 48 characters.");
}
var ownedFactionId = BuildOwnedFactionId(playerId);
if (world.Factions.Any(faction => string.Equals(faction.Id, ownedFactionId, StringComparison.Ordinal)))
{
throw new InvalidOperationException($"Player faction '{ownedFactionId}' already exists in the current world.");
}
player.Label = personaName;
player.PersonaName = personaName;
player.RaceId = request.RaceId.Trim();
player.SovereignFactionId = ownedFactionId;
player.RequiresOnboarding = false;
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
return player;
}
internal PlayerFactionRuntime EnsureInitializedDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
{
var player = EnsureDomain(world, playerStateStore, playerId);
if (player.RequiresOnboarding || string.IsNullOrWhiteSpace(player.SovereignFactionId))
{
throw new InvalidOperationException("Player onboarding must be completed before issuing gameplay commands.");
}
return player;
}
internal static string BuildOwnedFactionId(string playerId) =>
$"player-{playerId.Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant()}";
internal void Update(SimulationWorld world, IPlayerStateStore playerStateStore, float _deltaSeconds, ICollection<SimulationEventRecord> events)
{
if (playerStateStore.GetPlayerFactions().Count == 0)
{
return;
}
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, IPlayerStateStore playerStateStore, string playerId, PlayerOrganizationCommandRequest request)
{
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player));
var nowUtc = DateTimeOffset.UtcNow;
@@ -167,9 +228,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 = EnsureInitializedDomain(world, playerStateStore, playerId);
RemoveOrganization(player, organizationId);
player.Assignments.RemoveAll(assignment =>
assignment.FleetId == organizationId ||
@@ -185,9 +246,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 = EnsureInitializedDomain(world, playerStateStore, playerId);
var kind = ResolveOrganizationKind(player, organizationId);
switch (kind)
{
@@ -236,9 +297,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 = EnsureInitializedDomain(world, playerStateStore, playerId);
var directive = directiveId is null
? null
: player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal));
@@ -268,7 +329,7 @@ internal sealed class PlayerFactionService
directive.SourceStationId = request.SourceStationId;
directive.DestinationStationId = request.DestinationStationId;
directive.ItemId = request.ItemId;
directive.PreferredNodeId = request.PreferredNodeId;
directive.PreferredAnchorId = request.PreferredAnchorId;
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
directive.PreferredModuleId = request.PreferredModuleId;
directive.Priority = request.Priority;
@@ -294,7 +355,7 @@ internal sealed class PlayerFactionService
SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId,
NodeId = template.NodeId,
AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId,
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
@@ -313,9 +374,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 = EnsureInitializedDomain(world, playerStateStore, playerId);
player.Directives.RemoveAll(directive => directive.Id == directiveId);
foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId))
{
@@ -327,9 +388,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 = EnsureInitializedDomain(world, playerStateStore, playerId);
var policy = policyId is null
? null
: player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal));
@@ -398,9 +459,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 = EnsureInitializedDomain(world, playerStateStore, playerId);
var policy = automationPolicyId is null
? null
: player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal));
@@ -440,7 +501,7 @@ internal sealed class PlayerFactionService
SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId,
NodeId = template.NodeId,
AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId,
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
@@ -456,9 +517,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 = EnsureInitializedDomain(world, playerStateStore, playerId);
var policy = reinforcementPolicyId is null
? null
: player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal));
@@ -490,9 +551,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 = EnsureInitializedDomain(world, playerStateStore, playerId);
var program = productionProgramId is null
? null
: player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal));
@@ -522,9 +583,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 = EnsureInitializedDomain(world, playerStateStore, playerId);
var assignment = player.Assignments.FirstOrDefault(candidate =>
string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) &&
string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal));
@@ -581,9 +642,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 = EnsureInitializedDomain(world, playerStateStore, playerId);
player.StrategicIntent.StrategicPosture = request.StrategicPosture;
player.StrategicIntent.EconomicPosture = request.EconomicPosture;
player.StrategicIntent.MilitaryPosture = request.MilitaryPosture;
@@ -597,9 +658,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 = EnsureInitializedDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
@@ -611,15 +672,12 @@ internal sealed class PlayerFactionService
return null;
}
if (ship.OrderQueue.Count >= 8)
{
throw new InvalidOperationException("Order queue is full.");
}
ship.OrderQueue.Add(new ShipOrderRuntime
ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime
{
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
Kind = request.Kind,
SourceKind = ShipOrderSourceKind.Player,
SourceId = playerId,
Priority = request.Priority,
InterruptCurrentPlan = request.InterruptCurrentPlan,
Label = request.Label,
@@ -629,7 +687,7 @@ internal sealed class PlayerFactionService
SourceStationId = request.SourceStationId,
DestinationStationId = request.DestinationStationId,
ItemId = request.ItemId,
NodeId = request.NodeId,
AnchorId = request.AnchorId,
ConstructionSiteId = request.ConstructionSiteId,
ModuleId = request.ModuleId,
WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f),
@@ -638,15 +696,10 @@ 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))
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
ship.ControlReason = request.Label ?? request.Kind;
ship.NeedsReplan = true;
ship.LastReplanReason = "player-order-enqueued";
@@ -654,9 +707,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 = EnsureInitializedDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
@@ -668,28 +721,18 @@ internal sealed class PlayerFactionService
return null;
}
var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId);
if (removed > 0)
var removed = ship.OrderQueue.RemoveById(orderId);
if (removed)
{
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.HasOrdersFromSource(ShipOrderSourceKind.Player)
? "player-order"
: "player-manual";
ship.ControlSourceId = ship.OrderQueue
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
.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))
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Label ?? order.Kind)
.FirstOrDefault()
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
?? "manual-player-control";
ship.NeedsReplan = true;
ship.LastReplanReason = "player-order-removed";
@@ -697,9 +740,96 @@ internal sealed class PlayerFactionService
return ship;
}
internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, string shipId, ShipDefaultBehaviorCommandRequest request)
internal ShipRuntime? UpdateDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId, ShipOrderUpdateCommandRequest request)
{
var player = EnsureDomain(world);
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
}
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
if (ship is null)
{
return null;
}
var order = ship.OrderQueue.FindById(orderId);
if (order is null || order.SourceKind != ShipOrderSourceKind.Player)
{
return null;
}
order.Priority = request.Priority;
order.InterruptCurrentPlan = request.InterruptCurrentPlan;
order.Label = request.Label;
order.TargetEntityId = request.TargetEntityId;
order.TargetSystemId = request.TargetSystemId;
order.TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z);
order.SourceStationId = request.SourceStationId;
order.DestinationStationId = request.DestinationStationId;
order.ItemId = request.ItemId;
order.AnchorId = request.AnchorId;
order.ConstructionSiteId = request.ConstructionSiteId;
order.ModuleId = request.ModuleId;
order.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f);
order.Radius = MathF.Max(0f, request.Radius ?? 0f);
order.MaxSystemRange = request.MaxSystemRange;
order.KnownStationsOnly = request.KnownStationsOnly ?? false;
order.Status = OrderStatus.Queued;
order.FailureReason = null;
AddDecision(player, "ship-order-updated", $"Updated order {orderId} on {ship.Definition.Name}.", "ship", shipId);
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
? "player-order"
: "player-manual";
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
?? request.Label
?? request.Kind;
ship.NeedsReplan = true;
ship.LastReplanReason = "player-order-updated";
ship.LastDeltaSignature = string.Empty;
return ship;
}
internal ShipRuntime? ReorderDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId, int targetIndex)
{
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
}
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
if (ship is null)
{
return null;
}
if (!ship.OrderQueue.TryMovePlayerOrder(orderId, targetIndex))
{
return ship;
}
AddDecision(player, "ship-order-reordered", $"Reordered order {orderId} on {ship.Definition.Name}.", "ship", shipId);
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
? "player-order"
: "player-manual";
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
?? "manual-player-control";
ship.NeedsReplan = true;
ship.LastReplanReason = "player-order-reordered";
ship.LastDeltaSignature = string.Empty;
return ship;
}
internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipDefaultBehaviorCommandRequest request)
{
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
@@ -718,7 +848,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",
@@ -727,7 +857,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;
@@ -741,8 +871,8 @@ internal sealed class PlayerFactionService
directive.HomeStationId = request.HomeStationId;
directive.SourceStationId = request.HomeStationId;
directive.DestinationStationId = null;
directive.ItemId = request.PreferredItemId;
directive.PreferredNodeId = request.PreferredNodeId;
directive.ItemId = request.ItemId;
directive.PreferredAnchorId = request.PreferredAnchorId;
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
directive.PreferredModuleId = request.PreferredModuleId;
directive.Priority = 100;
@@ -768,7 +898,7 @@ internal sealed class PlayerFactionService
SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId,
NodeId = template.NodeId,
AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId,
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
@@ -788,7 +918,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";
@@ -821,7 +951,7 @@ internal sealed class PlayerFactionService
{
Id = "player-core-automation",
Label = "Core Automation",
BehaviorKind = "idle",
BehaviorKind = Idle,
});
}
@@ -839,6 +969,24 @@ internal sealed class PlayerFactionService
private static void SyncRegistry(SimulationWorld world, PlayerFactionRuntime player)
{
if (string.IsNullOrWhiteSpace(player.SovereignFactionId))
{
SyncSet(player.AssetRegistry.ShipIds, []);
SyncSet(player.AssetRegistry.StationIds, []);
SyncSet(player.AssetRegistry.CommanderIds, []);
SyncSet(player.AssetRegistry.ClaimIds, []);
SyncSet(player.AssetRegistry.ConstructionSiteIds, []);
SyncSet(player.AssetRegistry.PolicySetIds, player.Policies.Where(entry => entry.PolicySetId is not null).Select(entry => entry.PolicySetId!));
SyncSet(player.AssetRegistry.MarketOrderIds, []);
SyncSet(player.AssetRegistry.FleetIds, player.Fleets.Select(fleet => fleet.Id));
SyncSet(player.AssetRegistry.TaskForceIds, player.TaskForces.Select(taskForce => taskForce.Id));
SyncSet(player.AssetRegistry.StationGroupIds, player.StationGroups.Select(group => group.Id));
SyncSet(player.AssetRegistry.EconomicRegionIds, player.EconomicRegions.Select(region => region.Id));
SyncSet(player.AssetRegistry.FrontIds, player.Fronts.Select(front => front.Id));
SyncSet(player.AssetRegistry.ReserveIds, player.Reserves.Select(reserve => reserve.Id));
return;
}
SyncSet(player.AssetRegistry.ShipIds, world.Ships.Where(ship => ship.FactionId == player.SovereignFactionId).Select(ship => ship.Id));
SyncSet(player.AssetRegistry.StationIds, world.Stations.Where(station => station.FactionId == player.SovereignFactionId).Select(station => station.Id));
SyncSet(player.AssetRegistry.CommanderIds, world.Commanders.Where(commander => commander.FactionId == player.SovereignFactionId).Select(commander => commander.Id));
@@ -1030,7 +1178,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));
}
}
@@ -1211,8 +1359,7 @@ internal sealed class PlayerFactionService
return player.AutomationPolicies.FirstOrDefault(policy => policy.Id == automationId);
}
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId)
?? player.AutomationPolicies.FirstOrDefault(policy => policy.Id == "player-core-automation");
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId);
}
private static PlayerFactionPolicyRuntime? ResolvePolicy(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string assetId)
@@ -1241,25 +1388,15 @@ 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.HasOrdersFromSource(ShipOrderSourceKind.Player)
? "player-order"
: "player-manual";
var desiredControlSourceId = directive?.Id
?? automation?.Id
?? ship.OrderQueue
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
?? ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
var desiredControlReason = directive?.Label
?? automation?.Label
?? ship.OrderQueue
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Label ?? order.Kind)
.FirstOrDefault()
?? ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
?? (hasBehaviorSource ? "delegated-player-control" : "manual-player-control");
var assignmentChanged = !AssignmentsEqual(commander.Assignment, desiredAssignment);
@@ -1337,8 +1474,8 @@ 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,
PreferredNodeId = directive?.PreferredNodeId ?? ship.DefaultBehavior.PreferredNodeId,
ItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.ItemId,
PreferredAnchorId = directive?.PreferredAnchorId ?? ship.DefaultBehavior.PreferredAnchorId,
PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId,
PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId,
TargetPosition = directive?.TargetPosition,
@@ -1358,7 +1495,7 @@ internal sealed class PlayerFactionService
private static bool ReconcileDirectiveOrders(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation)
{
var aiOrderId = directive is null ? null : $"player-order-{directive.Id}";
var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("player-order-", StringComparison.Ordinal) && order.Id != aiOrderId) > 0;
var changed = ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("player-order-", StringComparison.Ordinal) && order.Id != aiOrderId) > 0;
var useOrders = directive?.UseOrders ?? automation?.UseOrders ?? false;
if (!useOrders || directive is null || string.IsNullOrWhiteSpace(directive.StagingOrderKind))
@@ -1370,6 +1507,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,
@@ -1379,7 +1518,7 @@ internal sealed class PlayerFactionService
SourceStationId = directive.SourceStationId ?? directive.HomeStationId,
DestinationStationId = directive.DestinationStationId,
ItemId = directive.ItemId,
NodeId = directive.PreferredNodeId,
AnchorId = directive.PreferredAnchorId,
ConstructionSiteId = directive.PreferredConstructionSiteId,
ModuleId = directive.PreferredModuleId,
WaitSeconds = directive.WaitSeconds,
@@ -1388,17 +1527,16 @@ internal sealed class PlayerFactionService
KnownStationsOnly = directive.KnownStationsOnly,
};
var existing = ship.OrderQueue.FirstOrDefault(order => order.Id == aiOrderId);
var existing = ship.OrderQueue.FindById(aiOrderId!);
if (existing is null)
{
ship.OrderQueue.Add(desiredOrder);
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
return true;
}
if (!ShipOrdersEqual(existing, desiredOrder))
{
ship.OrderQueue.Remove(existing);
ship.OrderQueue.Add(desiredOrder);
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
return true;
}
@@ -1442,8 +1580,8 @@ internal sealed class PlayerFactionService
target.HomeStationId = source.HomeStationId;
target.AreaSystemId = source.AreaSystemId;
target.TargetEntityId = source.TargetEntityId;
target.PreferredItemId = source.PreferredItemId;
target.PreferredNodeId = source.PreferredNodeId;
target.ItemId = source.ItemId;
target.PreferredAnchorId = source.PreferredAnchorId;
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
target.PreferredModuleId = source.PreferredModuleId;
target.TargetPosition = source.TargetPosition;
@@ -1463,8 +1601,8 @@ 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.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
&& string.Equals(left.PreferredAnchorId, right.PreferredAnchorId, StringComparison.Ordinal)
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
&& Nullable.Equals(left.TargetPosition, right.TargetPosition)
@@ -1485,7 +1623,7 @@ internal sealed class PlayerFactionService
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
&& string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal)
&& string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal)
&& string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
&& left.WaitSeconds.Equals(right.WaitSeconds)
@@ -1496,6 +1634,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)
@@ -1505,7 +1645,7 @@ internal sealed class PlayerFactionService
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
&& string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal)
&& string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal)
&& string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
&& left.WaitSeconds.Equals(right.WaitSeconds)
@@ -1550,7 +1690,7 @@ internal sealed class PlayerFactionService
SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId,
NodeId = template.NodeId,
AnchorId = template.AnchorId,
ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId,
WaitSeconds = template.WaitSeconds,
@@ -1711,7 +1851,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
{
@@ -2108,7 +2248,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

View File

@@ -0,0 +1,29 @@
namespace SpaceGame.Api.PlayerFaction.Simulation;
public sealed class PlayerStateStore : IPlayerStateStore
{
private readonly Dictionary<string, PlayerFactionRuntime> _playerFactions = new(StringComparer.Ordinal);
public bool TryGetPlayerFaction(string playerId, out PlayerFactionRuntime playerFaction) =>
_playerFactions.TryGetValue(playerId, out playerFaction!);
public PlayerFactionRuntime GetOrAddPlayerFaction(string playerId, Func<PlayerFactionRuntime> factory)
{
if (_playerFactions.TryGetValue(playerId, out var existing))
{
return existing;
}
var created = factory();
_playerFactions[playerId] = created;
return created;
}
public IReadOnlyCollection<PlayerFactionRuntime> GetPlayerFactions() =>
_playerFactions.Values.ToList();
public IReadOnlyDictionary<string, PlayerFactionRuntime> GetPlayerFactionsByPlayerId() =>
new Dictionary<string, PlayerFactionRuntime>(_playerFactions, StringComparer.Ordinal);
public void Clear() => _playerFactions.Clear();
}

View File

@@ -1,6 +1,12 @@
using System.Text;
using FastEndpoints;
using FastEndpoints.Swagger;
using SpaceGame.Api.Universe.Simulation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Npgsql;
using SpaceGame.Api.Universe.Bootstrap;
const string StartupScenarioPath = "scenarios/minimal.json";
var builder = WebApplication.CreateBuilder(args);
@@ -14,17 +20,124 @@ builder.Services.AddCors((options) =>
.AllowAnyOrigin();
});
});
builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration"));
builder.Services
.AddOptions<StaticDataOptions>()
.Bind(builder.Configuration.GetSection("StaticData"))
.Validate(options => !string.IsNullOrWhiteSpace(options.DataRoot), "StaticData:DataRoot must be configured.")
.PostConfigure(options =>
{
if (Path.IsPathRooted(options.DataRoot))
{
options.DataRoot = Path.GetFullPath(options.DataRoot);
return;
}
var candidatePaths = new[]
{
Path.GetFullPath(options.DataRoot),
Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, options.DataRoot)),
Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "..", options.DataRoot)),
};
var resolvedPath = candidatePaths.FirstOrDefault(Directory.Exists);
if (resolvedPath is null)
{
throw new InvalidOperationException($"StaticData:DataRoot '{options.DataRoot}' could not be resolved to an existing directory.");
}
options.DataRoot = resolvedPath;
})
.ValidateOnStart();
builder.Services.Configure<BalanceOptions>(builder.Configuration.GetSection("Balance"));
builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
builder.Services.AddFastEndpoints();
builder.Services.SwaggerDocument();
builder.Services
.AddOptions<AuthOptions>()
.Bind(builder.Configuration.GetSection("Auth"))
.Validate(options => !string.IsNullOrWhiteSpace(options.ConnectionString), "Auth:ConnectionString must be configured.")
.ValidateOnStart();
builder.Services
.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection("Jwt"))
.Validate(options => !string.IsNullOrWhiteSpace(options.SigningKey), "Jwt:SigningKey must be configured.")
.ValidateOnStart();
var jwtOptions = builder.Configuration.GetSection("Jwt").Get<JwtOptions>() ?? 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<IBalanceService, BalanceService>();
builder.Services.AddSingleton<AppVersionService>();
builder.Services.AddSingleton<IPlayerStateStore, PlayerStateStore>();
builder.Services.AddSingleton<PlayerFactionProjectionService>();
builder.Services.AddSingleton<LocalPasswordHasher>();
builder.Services.AddSingleton<RefreshTokenFactory>();
builder.Services.AddSingleton<ITokenService, JwtTokenService>();
builder.Services.AddSingleton<IPasswordResetDelivery, DevPasswordResetDelivery>();
builder.Services.AddSingleton<IPlayerIdentityResolver, HttpContextPlayerIdentityResolver>();
builder.Services.AddSingleton((serviceProvider) =>
{
var authOptions = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<AuthOptions>>();
return new NpgsqlDataSourceBuilder(authOptions.Value.ConnectionString).Build();
});
builder.Services.AddSingleton<IAuthRepository, PostgresAuthRepository>();
builder.Services.AddSingleton<AuthService>();
builder.Services.AddSingleton<AuthSchemaInitializer>();
builder.Services.AddSingleton<DevAuthSeeder>();
builder.Services.AddTransient<SystemGenerationService>();
builder.Services.AddTransient<SpatialBuilder>();
builder.Services.AddTransient<WorldSeedingService>();
builder.Services.AddTransient<ScenarioValidationService>();
builder.Services.AddTransient<ScenarioContentBuilder>();
builder.Services.AddTransient<ScenarioLoader>();
builder.Services.AddTransient<WorldTopologyBuilder>();
builder.Services.AddTransient<WorldRuntimeAssembler>();
builder.Services.AddTransient<WorldBuilder>();
builder.Services.AddSingleton<IStaticDataProvider, StaticDataProvider>();
builder.Services.AddSingleton<WorldService>();
builder.Services.AddSingleton<TelemetryService>();
builder.Services.AddHostedService<SimulationHostedService>();
builder.Services.AddFastEndpoints();
builder.Services.SwaggerDocument();
var app = builder.Build();
await app.Services.GetRequiredService<AuthSchemaInitializer>().EnsureSchemaAsync(CancellationToken.None);
if (builder.Environment.IsDevelopment())
{
await app.Services.GetRequiredService<DevAuthSeeder>().SeedAsync(CancellationToken.None);
app.Services.GetRequiredService<WorldService>().LoadFromScenario(StartupScenarioPath);
}
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseFastEndpoints();
app.UseSwaggerGen();

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("SpaceGame.Api.Tests")]

View File

@@ -0,0 +1,7 @@
namespace SpaceGame.Api.Shared.Contracts;
public sealed record VersionInfoSnapshot(
string Version,
string Environment,
string? CommitSha,
DateTimeOffset StartedAtUtc);

View File

@@ -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<AssemblyInformationalVersionAttribute>()?
.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;
}

View File

@@ -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(),
};
}

View File

@@ -0,0 +1,120 @@
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 DockAtStation = "dock-at-station";
public const string Move = "move";
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<ShipBehaviorDefinition> Behaviors =
[
new(ShipBehaviorKinds.Patrol, "Patrol", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or move 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 move 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 move 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.DockAtStation, "Dock At Station", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
new(ShipBehaviorKinds.Move, "Fly To Position", "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-at-station 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-at-station 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<ShipOrderDefinition> Orders =
[
new(ShipOrderKinds.DockAtStation, "Dock At Station", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
new(ShipOrderKinds.Move, "Fly To", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order completes on arrival."),
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.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."),
];
}

View File

@@ -6,6 +6,7 @@ public enum SpatialNodeKind
Planet,
Moon,
LagrangePoint,
ResourceNode,
}
public enum WorkStatus
@@ -28,17 +29,14 @@ public enum OrderStatus
Interrupted,
}
public enum AiPlanStatus
public enum ShipOrderSourceKind
{
Planned,
Running,
Blocked,
Completed,
Failed,
Interrupted,
Player,
Behavior,
Commander,
}
public enum AiPlanStepStatus
public enum AiPlanStatus
{
Planned,
Running,
@@ -157,8 +155,6 @@ public static class ShipOrderKinds
{
public const string Move = "move";
public const string DockAtStation = "dock-at-station";
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 TradeRoute = "trade-route";
@@ -166,6 +162,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";
}
@@ -265,12 +266,16 @@ public static class SimulationEnumMappings
};
}
public static StorageKind? ToNullableStorageKind(this string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.ToStorageKind();
public static string ToContractValue(this SpatialNodeKind kind) => kind switch
{
SpatialNodeKind.Star => "star",
SpatialNodeKind.Planet => "planet",
SpatialNodeKind.Moon => "moon",
SpatialNodeKind.LagrangePoint => "lagrange-point",
SpatialNodeKind.ResourceNode => "resource-node",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
};
@@ -307,17 +312,6 @@ public static class SimulationEnumMappings
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
public static string ToContractValue(this AiPlanStepStatus status) => status switch
{
AiPlanStepStatus.Planned => "planned",
AiPlanStepStatus.Running => "running",
AiPlanStepStatus.Blocked => "blocked",
AiPlanStepStatus.Completed => "completed",
AiPlanStepStatus.Failed => "failed",
AiPlanStepStatus.Interrupted => "interrupted",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
public static string ToContractValue(this AiPlanSourceKind kind) => kind switch
{
AiPlanSourceKind.Rule => "rule",
@@ -326,6 +320,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",

View File

@@ -3,12 +3,79 @@ 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);
internal static float GetStationSupportedPopulation(
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
StationRuntime station) =>
40f + station.Modules
.Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition) && definition is HabitationModuleDefinition habitation
? habitation.SupportedPopulation
: 0f)
.Sum();
internal static float GetStationRequiredWorkforce(
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
StationRuntime station) =>
MathF.Max(12f, station.Modules
.Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition)
&& definition is ProductionLaneModuleDefinition productionLane
? productionLane.RequiredWorkforce
: 0f)
.Sum());
internal static float GetStationStorageCapacity(SimulationWorld world, StationRuntime station, StorageKind storageKind)
{
SyncStorageModuleLevels(world, station, storageKind);
@@ -46,7 +113,7 @@ internal static class SimulationRuntimeSupport
}
var remaining = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind)
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageKind)
.Sum(entry => entry.Value);
foreach (var (module, definition) in storageModules)
@@ -112,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.CargoStorageKind is not null
&& item.CargoStorageKind == ship.Definition.CargoStorageKind;
&& item.CargoKind is not null
&& 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)
{
@@ -176,7 +243,7 @@ internal static class SimulationRuntimeSupport
return 0f;
}
var storageKind = itemDefinition.CargoStorageKind;
var storageKind = itemDefinition.CargoKind;
if (storageKind is null)
{
return 0f;
@@ -194,7 +261,7 @@ internal static class SimulationRuntimeSupport
}
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind)
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageKind)
.Sum(entry => entry.Value);
var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used));
if (accepted <= 0.01f)

View File

@@ -7,6 +7,22 @@ public static class SimulationUnits
public static float AuToKilometers(float au) => au * KilometersPerAu;
public static float KilometersToMeters(float kilometers) => kilometers * MetersPerKilometer;
public static float MetersToKilometers(float meters) => meters / MetersPerKilometer;
public static Vector3 KilometersToMeters(Vector3 kilometers) =>
new(
KilometersToMeters(kilometers.X),
KilometersToMeters(kilometers.Y),
KilometersToMeters(kilometers.Z));
public static Vector3 MetersToKilometers(Vector3 meters) =>
new(
MetersToKilometers(meters.X),
MetersToKilometers(meters.Y),
MetersToKilometers(meters.Z));
public static float AuPerSecondToKilometersPerSecond(float auPerSecond) =>
auPerSecond * KilometersPerAu;

View File

@@ -0,0 +1,762 @@
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 void SyncBehaviorOrders(SimulationWorld world, ShipRuntime ship)
{
var desiredOrder = BuildManagedBehaviorOrder(world, ship);
ship.OrderQueue.RemoveWhere(order =>
order.SourceKind == ShipOrderSourceKind.Behavior
&& order.Id.StartsWith("behavior-", StringComparison.Ordinal)
&& (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)));
if (desiredOrder is null)
{
return;
}
var existing = ship.OrderQueue.FindById(desiredOrder.Id);
if (existing is null)
{
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
return;
}
if (ManagedOrdersEqual(existing, desiredOrder))
{
return;
}
ship.OrderQueue.AddOrReplaceManagedOrder(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, DockAtStation, 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-at-station",
Kind = ShipOrderKinds.DockAtStation,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = $"Dock at {station.Label}",
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
DestinationStationId = station.Id,
Radius = ship.DefaultBehavior.Radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}
if (string.Equals(behaviorKind, Move, StringComparison.Ordinal))
{
ship.LastAccessFailureReason = null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-move",
Kind = ShipOrderKinds.Move,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = "Fly to position",
TargetSystemId = systemId,
TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position,
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,
AnchorId = opportunity.Node.AnchorId,
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 CreateManagedMoveOrder(
ship,
behaviorKind,
"Protect position",
targetSystemId,
targetPosition,
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 CreateManagedMoveOrder(
ship,
behaviorKind,
$"Guard {station.Label}",
station.SystemId,
GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)),
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 CreateManagedDockAtStationOrder(ship, behaviorKind, visitStation, $"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,
AnchorId = template.AnchorId,
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, ship.DefaultBehavior.PreferredAnchorId);
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}",
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
AnchorId = node.AnchorId,
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.AnchorId, right.AnchorId, 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 CreateManagedMoveOrder(ship, sourceKind, "Patrol waypoint", targetSystemId, targetPosition, MathF.Max(6f, ship.DefaultBehavior.Radius), orderIdSuffix: "patrol-move");
}
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 CreateManagedDockAtStationOrder(ShipRuntime ship, string behaviorKind, StationRuntime station, string label) =>
new()
{
Id = $"behavior-{ship.Id}-{behaviorKind}-dock-at-station",
Kind = ShipOrderKinds.DockAtStation,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = label,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
DestinationStationId = station.Id,
Radius = ship.DefaultBehavior.Radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
private static ShipOrderRuntime CreateManagedMoveOrder(
ShipRuntime ship,
string behaviorKind,
string label,
string targetSystemId,
Vector3 targetPosition,
float radius,
string? orderIdSuffix = null) =>
new()
{
Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}",
Kind = ShipOrderKinds.Move,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = label,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
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,
};
}

View File

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

View File

@@ -0,0 +1,838 @@
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, 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 targetAnchor = ResolveTravelTargetAnchor(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 destinationEntryAnchor = ResolveSystemEntryAnchor(world, subTask.TargetSystemId) ?? targetAnchor;
var destinationEntryPosition = destinationEntryAnchor?.Position ?? targetPosition;
return UpdateFtlTransit(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, destinationEntryPosition, destinationEntryAnchor, completeOnArrival, targetPosition, targetAnchor);
}
var currentAnchor = ResolveCurrentAnchor(world, ship);
if (targetAnchor is not null
&& currentAnchor is not null
&& !string.Equals(currentAnchor.Id, targetAnchor.Id, StringComparison.Ordinal))
{
if (!CanWarp(ship.Definition))
{
return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, currentAnchor, targetAnchor, completeOnArrival);
}
return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, currentAnchor, targetAnchor, completeOnArrival);
}
if (targetAnchor is not null
&& currentAnchor is not null
&& !string.Equals(currentAnchor.Id, targetAnchor.Id, StringComparison.Ordinal)
&& CanWarp(ship.Definition))
{
return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, currentAnchor, targetAnchor, completeOnArrival);
}
return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, currentAnchor, targetAnchor, 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.TargetResourceNodeId ?? subTask.TargetEntityId);
if (node is null || !CanExtractNode(ship, node, world))
{
subTask.BlockingReason = "node-missing";
ship.State = ShipState.Blocked;
return SubTaskOutcome.Failed;
}
var deposit = ResolveResourceDeposit(world, subTask.TargetResourceDepositId);
if (deposit is null || !string.Equals(deposit.NodeId, node.Id, StringComparison.Ordinal) || deposit.OreRemaining <= 0.01f)
{
deposit = SelectMiningDeposit(node, ship.Id);
subTask.TargetResourceDepositId = deposit?.Id;
}
if (deposit is null)
{
SyncNodeOreTotals(node);
return SubTaskOutcome.Completed;
}
var targetPosition = GetResourceHoldPosition(deposit.Position, ship.Id, 20f);
subTask.TargetPosition = targetPosition;
var approachThreshold = MathF.Max(subTask.Threshold, 8f);
var distanceToTarget = ship.Position.DistanceTo(targetPosition);
var distanceToDeposit = ship.Position.DistanceTo(deposit.Position);
var effectivelyAtDeposit = string.Equals(ship.SpatialState.CurrentAnchorId, node.AnchorId, StringComparison.Ordinal)
&& distanceToDeposit <= approachThreshold;
ship.TargetPosition = targetPosition;
if (distanceToTarget > approachThreshold && !effectivelyAtDeposit)
{
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, deposit.OreRemaining);
if (mined <= 0.01f)
{
return SubTaskOutcome.Completed;
}
AddInventory(ship.Inventory, node.ItemId, mined);
deposit.OreRemaining = MathF.Max(0f, deposit.OreRemaining - mined);
SyncNodeOreTotals(node);
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,
AnchorRuntime? currentAnchor,
AnchorRuntime? targetAnchor,
bool completeOnArrival)
{
var distance = ship.Position.DistanceTo(targetPosition);
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
ship.SpatialState.Transit = null;
ship.SpatialState.DestinationAnchorId = targetAnchor?.Id ?? currentAnchor?.Id;
subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f);
var localSystemOffset = SimulationUnits.MetersToKilometers(ship.Position);
ship.SpatialState.SystemPosition = currentAnchor is null
? localSystemOffset
: new Vector3(
currentAnchor.Position.X + localSystemOffset.X,
currentAnchor.Position.Y + localSystemOffset.Y,
currentAnchor.Position.Z + localSystemOffset.Z);
if (distance <= MathF.Max(subTask.Threshold, balance.ArrivalThreshold))
{
ship.Position = targetPosition;
ship.TargetPosition = targetPosition;
ship.SystemId = targetSystemId;
ship.SpatialState.CurrentSystemId = targetSystemId;
ship.SpatialState.CurrentAnchorId = targetAnchor?.Id ?? currentAnchor?.Id;
var arrivalSystemOffset = SimulationUnits.MetersToKilometers(targetPosition);
ship.SpatialState.SystemPosition = targetAnchor is null
? arrivalSystemOffset
: new Vector3(
targetAnchor.Position.X + arrivalSystemOffset.X,
targetAnchor.Position.Y + arrivalSystemOffset.Y,
targetAnchor.Position.Z + arrivalSystemOffset.Z);
ship.State = ShipState.Arriving;
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
}
ship.State = ShipState.LocalFlight;
ship.SpatialState.CurrentAnchorId = currentAnchor?.Id;
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
var movedSystemOffset = SimulationUnits.MetersToKilometers(ship.Position);
ship.SpatialState.SystemPosition = currentAnchor is null
? movedSystemOffset
: new Vector3(
currentAnchor.Position.X + movedSystemOffset.X,
currentAnchor.Position.Y + movedSystemOffset.Y,
currentAnchor.Position.Z + movedSystemOffset.Z);
return SubTaskOutcome.Active;
}
private SubTaskOutcome UpdateWarpTransit(
SimulationWorld world,
ShipRuntime ship,
ShipSubTaskRuntime subTask,
float deltaSeconds,
Vector3 targetPosition,
AnchorRuntime currentAnchor,
AnchorRuntime targetAnchor,
bool completeOnArrival)
{
var transit = ship.SpatialState.Transit;
if (transit is null || transit.Regime != MovementRegimeKind.Warp || transit.DestinationAnchorId != targetAnchor.Id)
{
var originAnchorPosition = currentAnchor.Position;
var destinationAnchorPosition = targetAnchor.Position;
var initialSpoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
var initialTravelDuration = MathF.Max(0.1f, originAnchorPosition.DistanceTo(destinationAnchorPosition) / MathF.Max(GetWarpTravelSpeed(ship), 0.001f));
transit = new ShipTransitRuntime
{
Regime = MovementRegimeKind.Warp,
OriginAnchorId = currentAnchor.Id,
DestinationAnchorId = targetAnchor.Id,
StartedAtUtc = world.GeneratedAtUtc,
ArrivalDueAtUtc = world.GeneratedAtUtc.AddSeconds(initialSpoolDuration + initialTravelDuration),
};
ship.SpatialState.Transit = transit;
subTask.ElapsedSeconds = 0f;
}
ship.SpatialState.SpaceLayer = SpaceLayerKind.SystemSpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.Warp;
ship.SpatialState.CurrentAnchorId = null;
ship.SpatialState.DestinationAnchorId = targetAnchor.Id;
var spoolDurationSeconds = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
var startedAtUtc = transit.StartedAtUtc ?? world.GeneratedAtUtc;
var arrivalDueAtUtc = transit.ArrivalDueAtUtc ?? world.GeneratedAtUtc;
var totalDuration = MathF.Max(0.1f, (float)(arrivalDueAtUtc - startedAtUtc).TotalSeconds);
var elapsedSeconds = Math.Clamp((float)(world.GeneratedAtUtc - startedAtUtc).TotalSeconds, 0f, totalDuration);
var originPosition = ResolveAnchorPosition(world, transit.OriginAnchorId, currentAnchor.Position);
var destinationPosition = ResolveAnchorPosition(world, transit.DestinationAnchorId, targetAnchor.Position);
if (elapsedSeconds < spoolDurationSeconds)
{
ship.State = ShipState.SpoolingWarp;
ship.Position = Vector3.Zero;
ship.TargetPosition = Vector3.Zero;
ship.SpatialState.SystemPosition = originPosition;
transit.Progress = Math.Clamp(elapsedSeconds / totalDuration, 0f, 1f);
subTask.Progress = transit.Progress;
return SubTaskOutcome.Active;
}
ship.State = ShipState.Warping;
var warpTravelDuration = MathF.Max(0.001f, totalDuration - spoolDurationSeconds);
var travelElapsed = Math.Clamp(elapsedSeconds - spoolDurationSeconds, 0f, warpTravelDuration);
var travelProgress = Math.Clamp(travelElapsed / warpTravelDuration, 0f, 1f);
var travelDelta = destinationPosition.Subtract(originPosition);
ship.Position = Vector3.Zero;
ship.TargetPosition = Vector3.Zero;
ship.SpatialState.SystemPosition = new Vector3(
originPosition.X + (travelDelta.X * travelProgress),
originPosition.Y + (travelDelta.Y * travelProgress),
originPosition.Z + (travelDelta.Z * travelProgress));
transit.Progress = Math.Clamp(elapsedSeconds / totalDuration, 0f, 1f);
subTask.Progress = transit.Progress;
if (elapsedSeconds < totalDuration - 0.001f)
{
return SubTaskOutcome.Active;
}
return CompleteTransitArrival(ship, subTask.TargetSystemId ?? ship.SystemId, targetPosition, targetAnchor, completeOnArrival);
}
private SubTaskOutcome UpdateFtlTransit(
SimulationWorld world,
ShipRuntime ship,
ShipSubTaskRuntime subTask,
float deltaSeconds,
string targetSystemId,
Vector3 entryPosition,
AnchorRuntime? entryAnchor,
bool completeOnArrival,
Vector3 finalTargetPosition,
AnchorRuntime? finalTargetAnchor)
{
var destinationAnchorId = entryAnchor?.Id;
var transit = ship.SpatialState.Transit;
if (transit is null || transit.Regime != MovementRegimeKind.FtlTransit || transit.DestinationAnchorId != destinationAnchorId)
{
var initialTravelDuration = MathF.Max(0.1f, ResolveSystemGalaxyPosition(world, ship.SystemId).DistanceTo(ResolveSystemGalaxyPosition(world, targetSystemId)) / MathF.Max(ship.Definition.FtlSpeed * GetSkillFactor(ship.Skills.Navigation), 0.001f));
var initialSpoolDuration = MathF.Max(ship.Definition.SpoolTime, 0.1f);
transit = new ShipTransitRuntime
{
Regime = MovementRegimeKind.FtlTransit,
OriginAnchorId = ship.SpatialState.CurrentAnchorId,
DestinationAnchorId = destinationAnchorId,
StartedAtUtc = world.GeneratedAtUtc,
ArrivalDueAtUtc = world.GeneratedAtUtc.AddSeconds(initialSpoolDuration + initialTravelDuration),
};
ship.SpatialState.Transit = transit;
subTask.ElapsedSeconds = 0f;
}
ship.SpatialState.SpaceLayer = SpaceLayerKind.GalaxySpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.FtlTransit;
ship.SpatialState.CurrentAnchorId = null;
ship.SpatialState.DestinationAnchorId = destinationAnchorId;
var spoolDurationSeconds = MathF.Max(ship.Definition.SpoolTime, 0.1f);
var startedAtUtc = transit.StartedAtUtc ?? world.GeneratedAtUtc;
var arrivalDueAtUtc = transit.ArrivalDueAtUtc ?? world.GeneratedAtUtc;
var totalDuration = MathF.Max(0.1f, (float)(arrivalDueAtUtc - startedAtUtc).TotalSeconds);
var elapsedSeconds = Math.Clamp((float)(world.GeneratedAtUtc - startedAtUtc).TotalSeconds, 0f, totalDuration);
ship.State = elapsedSeconds < spoolDurationSeconds ? ShipState.SpoolingFtl : ShipState.Ftl;
transit.Progress = Math.Clamp(elapsedSeconds / totalDuration, 0f, 1f);
subTask.Progress = transit.Progress;
if (elapsedSeconds < totalDuration - 0.001f)
{
return SubTaskOutcome.Active;
}
ship.Position = Vector3.Zero;
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.CurrentAnchorId = entryAnchor?.Id;
ship.SpatialState.DestinationAnchorId = finalTargetAnchor?.Id ?? entryAnchor?.Id;
ship.SpatialState.SystemPosition = entryPosition;
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, AnchorRuntime? targetAnchor, 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.CurrentAnchorId = targetAnchor?.Id;
ship.SpatialState.DestinationAnchorId = targetAnchor?.Id;
var arrivalSystemOffset = SimulationUnits.MetersToKilometers(targetPosition);
ship.SpatialState.SystemPosition = targetAnchor is null
? arrivalSystemOffset
: new Vector3(
targetAnchor.Position.X + arrivalSystemOffset.X,
targetAnchor.Position.Y + arrivalSystemOffset.Y,
targetAnchor.Position.Z + arrivalSystemOffset.Z);
ship.State = ShipState.Arriving;
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,179 @@
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
namespace SpaceGame.Api.Ships.AI;
public sealed partial class ShipAiService
{
private static bool IsBehaviorBlockingFailure(string behaviorKind, string? failureReason) => failureReason switch
{
"missing-item" => true,
"no-suitable-buyer" => true,
"no-mineable-node" when string.Equals(behaviorKind, ShipBehaviorKinds.LocalAutoMine, StringComparison.Ordinal) => true,
_ => false,
};
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 IReadOnlyList<ShipSubTaskRuntime> BuildTradeSubTasks(ShipRuntime ship, TradeRoutePlan route)
{
return
[
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),
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 IReadOnlyList<ShipSubTaskRuntime> BuildFleetSupplySubTasks(FleetSupplyPlan plan)
{
return
[
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),
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 IReadOnlyList<ShipSubTaskRuntime> BuildConstructionSubTasks(ConstructionSiteRuntime site, StationRuntime supportStation)
{
var targetPosition = supportStation.Position;
return
[
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, supportStation.Position, site.Id, 12f, 0f),
CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f),
];
}
private static IReadOnlyList<ShipSubTaskRuntime> BuildAttackSubTasks(string targetEntityId, string? targetSystemId, string summary)
{
return
[
CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? string.Empty, Vector3.Zero, targetEntityId, 26f, 0f),
];
}
private static IReadOnlyList<ShipSubTaskRuntime> BuildFlyToObjectSubTasks(string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary)
{
return
[
CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f),
];
}
private static IReadOnlyList<ShipSubTaskRuntime> BuildFollowShipSubTasks(ShipRuntime targetShip, float radius, float durationSeconds, string summary) =>
BuildFollowSubTasks(targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary);
private static IReadOnlyList<ShipSubTaskRuntime> BuildFollowSubTasks(string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary)
{
return
[
CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds),
];
}
private static IReadOnlyList<ShipSubTaskRuntime> BuildHoldSubTasks(ShipRuntime ship, ShipOrderRuntime order)
{
return
[
CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f),
];
}
private IReadOnlyList<ShipSubTaskRuntime> BuildMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node, StationRuntime homeStation)
{
var deposit = SelectMiningDeposit(node, ship.Id);
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
return
[
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId),
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id),
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 IReadOnlyList<ShipSubTaskRuntime> BuildLocalMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node)
{
var deposit = SelectMiningDeposit(node, ship.Id);
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
return
[
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId),
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id),
];
}
private IReadOnlyList<ShipSubTaskRuntime> BuildLocalMiningDeliverySubTasks(ShipRuntime ship, StationRuntime buyer, string itemId)
{
var amount = MathF.Max(1f, GetInventoryAmount(ship.Inventory, itemId));
return
[
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),
];
}
private IReadOnlyList<ShipSubTaskRuntime> BuildSalvageSubTasks(ShipRuntime ship, WreckRuntime wreck, StationRuntime homeStation, Vector3 approach)
{
return
[
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),
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 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? targetAnchorId = null,
string? targetResourceNodeId = null,
string? targetResourceDepositId = null) =>
new()
{
Id = id,
Kind = kind,
Summary = summary,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
TargetEntityId = targetEntityId,
TargetAnchorId = targetAnchorId,
TargetResourceNodeId = targetResourceNodeId,
TargetResourceDepositId = targetResourceDepositId,
ItemId = itemId,
ModuleId = moduleId,
Threshold = threshold,
Amount = amount,
};
}

View File

@@ -0,0 +1,328 @@
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 ShipOrderRuntime? BuildEmergencyOrder(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();
return new ShipOrderRuntime
{
Id = $"rule-{ship.Id}-flee",
Kind = ShipOrderKinds.Flee,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = ShipOrderKinds.Flee,
Priority = 1000,
InterruptCurrentPlan = true,
Label = "Emergency retreat",
TargetEntityId = safeStation?.Id,
TargetSystemId = safeStation?.SystemId ?? ship.SystemId,
TargetPosition = safeStation?.Position ?? ship.Position,
DestinationStationId = safeStation?.Id,
Radius = safeStation is null ? 0f : MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f),
};
}
private IReadOnlyList<ShipSubTaskRuntime>? BuildOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
return order.Kind switch
{
var kind when string.Equals(kind, ShipOrderKinds.Flee, StringComparison.Ordinal) => BuildFleeSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMoveSubTasks(ship, order),
var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderSubTasks(world, order),
var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderSubTasks(world, order),
var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.MineLocal, StringComparison.Ordinal) => BuildMineLocalOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliverRun, StringComparison.Ordinal) => BuildMineAndDeliverRunOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.SellMinedCargo, StringComparison.Ordinal) => BuildSellMinedCargoOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.SupplyFleetRun, StringComparison.Ordinal) => BuildSupplyFleetOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.SalvageRun, StringComparison.Ordinal) => BuildAutoSalvageOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderSubTasks(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderSubTasks(order),
var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldSubTasks(ship, order),
_ => null,
};
}
private IReadOnlyList<ShipSubTaskRuntime> BuildFleeSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var safeStation = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId);
if (safeStation is null)
{
return
[
CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f),
];
}
return
[
CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), 0f),
CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f),
];
}
private static IReadOnlyList<ShipSubTaskRuntime> BuildMoveSubTasks(ShipRuntime ship, ShipOrderRuntime order)
{
var targetSystemId = order.TargetSystemId ?? ship.SystemId;
var targetPosition = order.TargetPosition ?? ship.Position;
return
[
CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, MathF.Max(0f, order.Radius), 0f),
];
}
private IReadOnlyList<ShipSubTaskRuntime>? BuildDockOrderSubTasks(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
[
CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(balance.ArrivalThreshold, station.Radius + 12f), 0f),
CreateSubTask("sub-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f),
];
}
private IReadOnlyList<ShipSubTaskRuntime>? BuildTradeOrderSubTasks(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 BuildTradeSubTasks(ship, route);
}
private IReadOnlyList<ShipSubTaskRuntime>? BuildMineOrderSubTasks(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 anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId);
var node = ResolveNode(world, order.TargetEntityId);
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, anchor?.Id);
}
if (node is null)
{
order.FailureReason = "mine-order-node-missing";
return null;
}
return BuildLocalMiningSubTasks(ship, node);
}
private IReadOnlyList<ShipSubTaskRuntime>? BuildMineLocalOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId);
var node = ResolveNode(world, order.TargetEntityId)
?? SelectLocalMiningNode(world, ship, order.TargetSystemId ?? ship.SystemId, order.ItemId ?? ship.DefaultBehavior.ItemId ?? string.Empty, anchor?.Id);
if (node is null)
{
order.FailureReason = "mine-order-incomplete";
return null;
}
return BuildLocalMiningSubTasks(ship, node);
}
private IReadOnlyList<ShipSubTaskRuntime>? BuildMineAndDeliverRunOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId);
var node = ResolveNode(world, order.TargetEntityId)
?? (string.IsNullOrWhiteSpace(order.ItemId)
? null
: SelectLocalMiningNode(world, ship, order.TargetSystemId ?? ship.SystemId, order.ItemId, anchor?.Id));
var buyer = ResolveStation(world, order.DestinationStationId);
if (node is null || buyer is null)
{
order.FailureReason = "mine-and-deliver-order-incomplete";
return null;
}
return BuildMiningSubTasks(ship, node, buyer);
}
private IReadOnlyList<ShipSubTaskRuntime>? BuildSellMinedCargoOrderSubTasks(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 BuildLocalMiningDeliverySubTasks(ship, buyer, order.ItemId);
}
private IReadOnlyList<ShipSubTaskRuntime>? BuildAutoSalvageOrderSubTasks(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 BuildSalvageSubTasks(ship, wreck, homeStation, approach);
}
private IReadOnlyList<ShipSubTaskRuntime>? BuildSupplyFleetOrderSubTasks(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 BuildFleetSupplySubTasks(plan);
}
private IReadOnlyList<ShipSubTaskRuntime>? BuildBuildOrderSubTasks(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 BuildConstructionSubTasks(site, supportStation);
}
private IReadOnlyList<ShipSubTaskRuntime>? BuildAttackOrderSubTasks(ShipOrderRuntime order)
{
var targetId = order.TargetEntityId;
if (targetId is null)
{
order.FailureReason = "attack-target-missing";
return null;
}
return BuildAttackSubTasks(targetId, order.TargetSystemId, order.Label ?? "Attack target");
}
private IReadOnlyList<ShipSubTaskRuntime>? BuildFlyToObjectOrderSubTasks(SimulationWorld world, 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 BuildFlyToObjectSubTasks(objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}");
}
private IReadOnlyList<ShipSubTaskRuntime>? BuildFollowShipOrderSubTasks(SimulationWorld world, 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 BuildFollowShipSubTasks(targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Name}");
}
}

View File

@@ -0,0 +1,220 @@
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<SimulationEventRecord> events)
{
if (ship.ReplanCooldownSeconds > 0f)
{
ship.ReplanCooldownSeconds = MathF.Max(0f, ship.ReplanCooldownSeconds - deltaSeconds);
}
var previousState = ship.State;
var previousOrderId = ship.ActiveOrderId;
var previousTaskId = GetCurrentSubTask(ship)?.Id;
SyncEmergencyOrders(world, ship);
SyncBehaviorOrders(world, ship);
EnsureOrderExecution(world, ship, events);
ExecuteOrder(world, ship, deltaSeconds, events);
TrackHistory(ship);
EmitStateEvents(ship, previousState, previousOrderId, previousTaskId, events);
}
private void EnsureOrderExecution(SimulationWorld world, ShipRuntime ship, ICollection<SimulationEventRecord> events)
{
var currentOrder = ship.OrderQueue.GetCurrentOrder();
if (currentOrder is null)
{
ClearActiveOrder(ship);
ApplyIdleOrBlockedState(world, ship);
return;
}
if (currentOrder.Status == OrderStatus.Queued)
{
currentOrder.Status = OrderStatus.Active;
}
if (!ship.NeedsReplan
&& string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal)
&& ship.ActiveSubTaskIndex < ship.ActiveSubTasks.Count)
{
return;
}
if (ship.ReplanCooldownSeconds > 0f && !string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal))
{
return;
}
var subTasks = BuildOrderSubTasks(world, ship, currentOrder);
if (subTasks is null || subTasks.Count == 0)
{
FailOrder(ship, currentOrder, currentOrder.FailureReason ?? "order-unavailable");
ClearActiveOrder(ship);
ship.NeedsReplan = true;
ship.ReplanCooldownSeconds = 0.1f;
ship.LastReplanReason = currentOrder.FailureReason ?? "order-unavailable";
events.Add(new SimulationEventRecord("ship", ship.Id, "order-failed", $"{ship.Definition.Name} failed {currentOrder.Label ?? currentOrder.Kind}.", DateTimeOffset.UtcNow));
ApplyIdleOrBlockedState(world, ship);
return;
}
BeginOrderExecution(ship, currentOrder, subTasks);
events.Add(new SimulationEventRecord("ship", ship.Id, "order-started", $"{ship.Definition.Name} started {currentOrder.Label ?? currentOrder.Kind}.", DateTimeOffset.UtcNow));
}
private void ExecuteOrder(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
var order = ship.ActiveOrderId is null ? null : ship.OrderQueue.FindById(ship.ActiveOrderId);
if (order is null)
{
ClearActiveOrder(ship);
ApplyIdleOrBlockedState(world, ship);
return;
}
if (ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count)
{
CompleteOrderExecution(ship, order, events);
return;
}
var subTask = ship.ActiveSubTasks[ship.ActiveSubTaskIndex];
if (subTask.Status == WorkStatus.Pending)
{
subTask.Status = WorkStatus.Active;
}
else if (subTask.Status == WorkStatus.Blocked)
{
ship.State = ShipState.Blocked;
ship.TargetPosition = subTask.TargetPosition ?? ship.Position;
return;
}
var outcome = UpdateSubTask(world, ship, subTask, deltaSeconds);
switch (outcome)
{
case SubTaskOutcome.Active:
return;
case SubTaskOutcome.Completed:
subTask.Status = WorkStatus.Completed;
subTask.Progress = 1f;
ship.ActiveSubTaskIndex += 1;
if (ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count)
{
CompleteOrderExecution(ship, order, events);
}
return;
case SubTaskOutcome.Failed:
subTask.Status = WorkStatus.Failed;
FailOrderExecution(ship, order, subTask.BlockingReason ?? "subtask-failed", events);
return;
}
}
private static void BeginOrderExecution(ShipRuntime ship, ShipOrderRuntime order, IReadOnlyList<ShipSubTaskRuntime> subTasks)
{
ship.ActiveOrderId = order.Id;
ship.ActiveSubTaskIndex = 0;
ship.ActiveSubTasks.Clear();
ship.ActiveSubTasks.AddRange(subTasks);
ship.NeedsReplan = false;
ship.ReplanCooldownSeconds = 0f;
ship.LastReplanReason = "order-execution-started";
ship.LastDeltaSignature = string.Empty;
}
private static void ClearActiveOrder(ShipRuntime ship)
{
ship.ActiveOrderId = null;
ship.ActiveSubTaskIndex = 0;
ship.ActiveSubTasks.Clear();
}
private void CompleteOrderExecution(ShipRuntime ship, ShipOrderRuntime order, ICollection<SimulationEventRecord> events)
{
ship.OrderQueue.TryCompleteOrder(order.Id);
if (order.SourceKind == ShipOrderSourceKind.Behavior
&& string.Equals(order.SourceId, RepeatOrders, StringComparison.Ordinal)
&& ship.DefaultBehavior.RepeatOrders.Count > 0)
{
ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count;
}
ClearActiveOrder(ship);
ship.NeedsReplan = true;
ship.ReplanCooldownSeconds = 0.25f;
ship.LastReplanReason = "order-completed";
ship.LastDeltaSignature = string.Empty;
events.Add(new SimulationEventRecord("ship", ship.Id, "order-completed", $"{ship.Definition.Name} completed {order.Label ?? order.Kind}.", DateTimeOffset.UtcNow));
}
private void FailOrderExecution(ShipRuntime ship, ShipOrderRuntime order, string failureReason, ICollection<SimulationEventRecord> events)
{
FailOrder(ship, order, failureReason);
ClearActiveOrder(ship);
ship.NeedsReplan = true;
ship.ReplanCooldownSeconds = 0.5f;
ship.LastReplanReason = failureReason;
ship.LastDeltaSignature = string.Empty;
events.Add(new SimulationEventRecord("ship", ship.Id, "order-failed", $"{ship.Definition.Name} failed {order.Label ?? order.Kind}.", DateTimeOffset.UtcNow));
}
private static void FailOrder(ShipRuntime ship, ShipOrderRuntime order, string failureReason)
{
ship.OrderQueue.TryFailOrder(order.Id, failureReason);
ship.LastDeltaSignature = string.Empty;
}
private static ShipSubTaskRuntime? GetCurrentSubTask(ShipRuntime ship) =>
ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex];
private void ApplyIdleOrBlockedState(SimulationWorld world, ShipRuntime ship)
{
var (behaviorKind, _) = ResolveBehaviorSource(world, ship);
if (IsBehaviorBlockingFailure(behaviorKind, ship.LastAccessFailureReason))
{
ship.State = ShipState.Blocked;
ship.TargetPosition = ship.Position;
return;
}
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
}
private void SyncEmergencyOrders(SimulationWorld world, ShipRuntime ship)
{
var desiredOrder = BuildEmergencyOrder(world, ship);
ship.OrderQueue.RemoveWhere(order =>
order.SourceKind == ShipOrderSourceKind.Behavior
&& string.Equals(order.SourceId, ShipOrderKinds.Flee, StringComparison.Ordinal)
&& (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)));
if (desiredOrder is null)
{
return;
}
ship.OrderQueue.AddOrReplaceManagedOrderAtFront(desiredOrder);
}
}

View File

@@ -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 };
}
}

View File

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

View File

@@ -0,0 +1,35 @@
using FastEndpoints;
namespace SpaceGame.Api.Ships.Api;
public sealed class GetShipAutomationCatalogHandler : EndpointWithoutRequest<ShipAutomationCatalogSnapshot>
{
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);
}
}

View File

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

View File

@@ -0,0 +1,31 @@
using FastEndpoints;
namespace SpaceGame.Api.Ships.Api;
public sealed class ReorderShipOrderHandler(WorldService worldService) : Endpoint<ShipOrderReorderRequest, ShipSnapshot>
{
public override void Configure()
{
Put("/api/ships/{shipId}/orders/{orderId}/position");
}
public override async Task HandleAsync(ShipOrderReorderRequest request, CancellationToken cancellationToken)
{
var shipId = Route<string>("shipId");
var orderId = Route<string>("orderId");
if (string.IsNullOrWhiteSpace(shipId) || string.IsNullOrWhiteSpace(orderId))
{
await SendNotFoundAsync(cancellationToken);
return;
}
var snapshot = worldService.ReorderShipOrder(shipId, orderId, request);
if (snapshot is null)
{
await SendNotFoundAsync(cancellationToken);
return;
}
await SendOkAsync(snapshot, cancellationToken);
}
}

View File

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

View File

@@ -0,0 +1,39 @@
using FastEndpoints;
namespace SpaceGame.Api.Ships.Api;
public sealed class UpdateShipOrderHandler(WorldService worldService) : Endpoint<ShipOrderUpdateCommandRequest, ShipSnapshot>
{
public override void Configure()
{
Put("/api/ships/{shipId}/orders/{orderId}");
}
public override async Task HandleAsync(ShipOrderUpdateCommandRequest request, CancellationToken cancellationToken)
{
var shipId = Route<string>("shipId");
var orderId = Route<string>("orderId");
if (string.IsNullOrWhiteSpace(shipId) || string.IsNullOrWhiteSpace(orderId))
{
await SendNotFoundAsync(cancellationToken);
return;
}
try
{
var snapshot = worldService.UpdateShipOrder(shipId, orderId, request);
if (snapshot is null)
{
await SendNotFoundAsync(cancellationToken);
return;
}
await SendOkAsync(snapshot, cancellationToken);
}
catch (InvalidOperationException ex)
{
AddError(ex.Message);
await SendErrorsAsync(cancellation: cancellationToken);
}
}
}

View File

@@ -0,0 +1,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<ShipBehaviorDefinitionSnapshot> Behaviors,
IReadOnlyList<ShipOrderDefinitionSnapshot> Orders);

View File

@@ -11,7 +11,7 @@ public sealed record ShipOrderCommandRequest(
string? SourceStationId,
string? DestinationStationId,
string? ItemId,
string? NodeId,
string? AnchorId,
string? ConstructionSiteId,
string? ModuleId,
float? WaitSeconds,
@@ -19,6 +19,28 @@ public sealed record ShipOrderCommandRequest(
int? MaxSystemRange,
bool? KnownStationsOnly);
public sealed record ShipOrderUpdateCommandRequest(
string Kind,
int Priority,
bool InterruptCurrentPlan,
string? Label,
string? TargetEntityId,
string? TargetSystemId,
Vector3Dto? TargetPosition,
string? SourceStationId,
string? DestinationStationId,
string? ItemId,
string? AnchorId,
string? ConstructionSiteId,
string? ModuleId,
float? WaitSeconds,
float? Radius,
int? MaxSystemRange,
bool? KnownStationsOnly);
public sealed record ShipOrderReorderRequest(
int TargetIndex);
public sealed record ShipOrderTemplateCommandRequest(
string Kind,
string? Label,
@@ -28,7 +50,7 @@ public sealed record ShipOrderTemplateCommandRequest(
string? SourceStationId,
string? DestinationStationId,
string? ItemId,
string? NodeId,
string? AnchorId,
string? ConstructionSiteId,
string? ModuleId,
float? WaitSeconds,
@@ -42,8 +64,8 @@ public sealed record ShipDefaultBehaviorCommandRequest(
string? HomeStationId,
string? AreaSystemId,
string? TargetEntityId,
string? PreferredItemId,
string? PreferredNodeId,
string? ItemId,
string? PreferredAnchorId,
string? PreferredConstructionSiteId,
string? PreferredModuleId,
Vector3Dto? TargetPosition,

View File

@@ -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,
@@ -21,7 +23,7 @@ public sealed record ShipOrderSnapshot(
string? SourceStationId,
string? DestinationStationId,
string? ItemId,
string? NodeId,
string? AnchorId,
string? ConstructionSiteId,
string? ModuleId,
float WaitSeconds,
@@ -39,7 +41,7 @@ public sealed record ShipOrderTemplateSnapshot(
string? SourceStationId,
string? DestinationStationId,
string? ItemId,
string? NodeId,
string? AnchorId,
string? ConstructionSiteId,
string? ModuleId,
float WaitSeconds,
@@ -53,8 +55,8 @@ public sealed record DefaultBehaviorSnapshot(
string? HomeStationId,
string? AreaSystemId,
string? TargetEntityId,
string? PreferredItemId,
string? PreferredNodeId,
string? ItemId,
string? PreferredAnchorId,
string? PreferredConstructionSiteId,
string? PreferredModuleId,
Vector3Dto? TargetPosition,
@@ -93,7 +95,9 @@ public sealed record ShipSubTaskSnapshot(
string Summary,
string? TargetEntityId,
string? TargetSystemId,
string? TargetNodeId,
string? TargetAnchorId,
string? TargetResourceNodeId,
string? TargetResourceDepositId,
Vector3Dto? TargetPosition,
string? ItemId,
string? ModuleId,
@@ -104,35 +108,13 @@ public sealed record ShipSubTaskSnapshot(
float TotalSeconds,
string? BlockingReason);
public sealed record ShipPlanStepSnapshot(
string Id,
string Kind,
string Status,
string Summary,
string? BlockingReason,
int CurrentSubTaskIndex,
IReadOnlyList<ShipSubTaskSnapshot> SubTasks);
public sealed record ShipPlanSnapshot(
string Id,
string SourceKind,
string SourceId,
string Kind,
string Status,
string Summary,
int CurrentStepIndex,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc,
string? InterruptReason,
string? FailureReason,
IReadOnlyList<ShipPlanStepSnapshot> Steps);
public sealed record ShipSnapshot(
string Id,
string Label,
string Kind,
string Class,
string Name,
string Purpose,
string Type,
string SystemId,
string? AnchorId,
Vector3Dto LocalPosition,
Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition,
@@ -141,19 +123,17 @@ public sealed record ShipSnapshot(
DefaultBehaviorSnapshot DefaultBehavior,
ShipAssignmentSnapshot? Assignment,
ShipSkillProfileSnapshot Skills,
ShipPlanSnapshot? ActivePlan,
string? CurrentStepId,
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
string ControlSourceKind,
string? ControlSourceId,
string? ControlReason,
string? LastReplanReason,
string? LastAccessFailureReason,
string? CelestialId,
string? DockedStationId,
string? CommanderId,
string? PolicySetId,
float CargoCapacity,
IReadOnlyList<string> CargoTypes,
float TravelSpeed,
string TravelSpeedUnit,
IReadOnlyList<InventoryEntry> Inventory,
@@ -164,10 +144,11 @@ public sealed record ShipSnapshot(
public sealed record ShipDelta(
string Id,
string Label,
string Kind,
string Class,
string Name,
string Purpose,
string Type,
string SystemId,
string? AnchorId,
Vector3Dto LocalPosition,
Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition,
@@ -176,19 +157,17 @@ public sealed record ShipDelta(
DefaultBehaviorSnapshot DefaultBehavior,
ShipAssignmentSnapshot? Assignment,
ShipSkillProfileSnapshot Skills,
ShipPlanSnapshot? ActivePlan,
string? CurrentStepId,
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
string ControlSourceKind,
string? ControlSourceId,
string? ControlReason,
string? LastReplanReason,
string? LastAccessFailureReason,
string? CelestialId,
string? DockedStationId,
string? CommanderId,
string? PolicySetId,
float CargoCapacity,
IReadOnlyList<string> CargoTypes,
float TravelSpeed,
string TravelSpeedUnit,
IReadOnlyList<InventoryEntry> Inventory,
@@ -200,17 +179,17 @@ public sealed record ShipDelta(
public sealed record ShipSpatialStateSnapshot(
string SpaceLayer,
string CurrentSystemId,
string? CurrentCelestialId,
string? CurrentAnchorId,
Vector3Dto? LocalPosition,
Vector3Dto? SystemPosition,
string MovementRegime,
string? DestinationNodeId,
string? DestinationAnchorId,
ShipTransitSnapshot? Transit);
public sealed record ShipTransitSnapshot(
string Regime,
string? OriginNodeId,
string? DestinationNodeId,
string? OriginAnchorId,
string? DestinationAnchorId,
DateTimeOffset? StartedAtUtc,
DateTimeOffset? ArrivalDueAtUtc,
float Progress);

View File

@@ -12,8 +12,7 @@ public sealed class ShipRuntime
public Vector3 Velocity { get; set; } = Vector3.Zero;
public ShipState State { get; set; } = ShipState.Idle;
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
public List<ShipOrderRuntime> OrderQueue { get; } = [];
public ShipPlanRuntime? ActivePlan { get; set; }
public ShipOrderQueue OrderQueue { get; } = new();
public required ShipSkillProfileRuntime Skills { get; set; }
public bool NeedsReplan { get; set; } = true;
public float ReplanCooldownSeconds { get; set; }
@@ -30,10 +29,190 @@ public sealed class ShipRuntime
public float Health { get; set; }
public HashSet<string> KnownStationIds { get; } = new(StringComparer.Ordinal);
public List<string> History { get; } = [];
public string? ActiveOrderId { get; set; }
public int ActiveSubTaskIndex { get; set; }
public List<ShipSubTaskRuntime> ActiveSubTasks { get; } = [];
public string LastSignature { get; set; } = string.Empty;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ShipOrderQueue : IReadOnlyList<ShipOrderRuntime>
{
public const int MaxOrders = 8;
private readonly List<ShipOrderRuntime> _orders = [];
public int Count => _orders.Count;
public ShipOrderRuntime this[int index] => _orders[index];
public IEnumerator<ShipOrderRuntime> GetEnumerator() => _orders.GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
public void Enqueue(ShipOrderRuntime order)
{
if (_orders.Count >= MaxOrders)
{
throw new InvalidOperationException("Order queue is full.");
}
_orders.Add(order);
}
public void EnqueuePlayerOrder(ShipOrderRuntime order)
{
if (order.SourceKind != ShipOrderSourceKind.Player)
{
throw new InvalidOperationException("Player segment only accepts player orders.");
}
EnsureCapacityForNewOrder(order.Id);
_orders.Insert(GetManagedInsertionIndex(), order);
}
public void EnqueueManagedOrder(ShipOrderRuntime order)
{
EnsureCapacityForNewOrder(order.Id);
_orders.Add(order);
}
public void AddOrReplaceManagedOrder(ShipOrderRuntime order)
=> AddOrReplaceManagedOrder(order, insertAtFront: false);
public void AddOrReplaceManagedOrderAtFront(ShipOrderRuntime order)
=> AddOrReplaceManagedOrder(order, insertAtFront: true);
private void AddOrReplaceManagedOrder(ShipOrderRuntime order, bool insertAtFront)
{
var existingIndex = _orders.FindIndex(candidate => string.Equals(candidate.Id, order.Id, StringComparison.Ordinal));
if (existingIndex >= 0)
{
_orders[existingIndex] = order;
return;
}
EnsureCapacityForNewOrder(order.Id);
if (insertAtFront)
{
_orders.Insert(GetManagedInsertionIndex(), order);
return;
}
_orders.Add(order);
}
public bool Remove(ShipOrderRuntime order) => RemoveById(order.Id);
public bool RemoveById(string orderId) => _orders.RemoveAll(order => string.Equals(order.Id, orderId, StringComparison.Ordinal)) > 0;
public int RemoveWhere(Predicate<ShipOrderRuntime> predicate) => _orders.RemoveAll(predicate);
public ShipOrderRuntime? FindById(string orderId) => _orders.FirstOrDefault(order => string.Equals(order.Id, orderId, StringComparison.Ordinal));
public ShipOrderRuntime? FindLeadingOrderForSource(ShipOrderSourceKind sourceKind) =>
_orders.FirstOrDefault(order => order.SourceKind == sourceKind);
public string? GetLeadingOrderLabelForSource(ShipOrderSourceKind sourceKind) =>
FindLeadingOrderForSource(sourceKind) is { } order
? order.Label ?? order.Kind
: null;
public bool HasOrdersFromSource(ShipOrderSourceKind sourceKind) => _orders.Any(order => order.SourceKind == sourceKind);
public ShipOrderRuntime? GetCurrentOrder() =>
_orders.FirstOrDefault(order => order.Status is OrderStatus.Queued or OrderStatus.Active);
public bool TryMovePlayerOrder(string orderId, int targetIndex)
{
var currentIndex = _orders.FindIndex(order => string.Equals(order.Id, orderId, StringComparison.Ordinal));
if (currentIndex < 0)
{
return false;
}
var order = _orders[currentIndex];
if (order.SourceKind != ShipOrderSourceKind.Player)
{
return false;
}
var playerOrderIds = _orders
.Select((candidate, index) => (candidate, index))
.Where(entry => entry.candidate.SourceKind == ShipOrderSourceKind.Player)
.Select(entry => entry.index)
.ToList();
if (playerOrderIds.Count <= 1)
{
return true;
}
var clampedPlayerIndex = Math.Clamp(targetIndex, 0, playerOrderIds.Count - 1);
var destinationIndex = playerOrderIds[clampedPlayerIndex];
if (currentIndex == destinationIndex)
{
return true;
}
_orders.RemoveAt(currentIndex);
if (currentIndex < destinationIndex)
{
destinationIndex -= 1;
}
_orders.Insert(destinationIndex, order);
return true;
}
public bool TryCompleteOrder(string orderId) => TryTransitionOrder(orderId, OrderStatus.Completed);
public bool TryFailOrder(string orderId, string? failureReason = null)
{
var order = FindById(orderId);
if (order is null)
{
return false;
}
order.FailureReason = failureReason ?? order.FailureReason;
if (order.SourceKind == ShipOrderSourceKind.Player)
{
order.Status = OrderStatus.Failed;
return true;
}
return TryTransitionOrder(orderId, OrderStatus.Failed);
}
public bool TryTransitionOrder(string orderId, OrderStatus terminalStatus)
{
var order = FindById(orderId);
if (order is null)
{
return false;
}
order.Status = terminalStatus;
return RemoveById(orderId);
}
private int GetManagedInsertionIndex() =>
_orders.TakeWhile(order => order.SourceKind == ShipOrderSourceKind.Player).Count();
private void EnsureCapacityForNewOrder(string orderId)
{
if (FindById(orderId) is not null)
{
return;
}
if (_orders.Count >= MaxOrders)
{
throw new InvalidOperationException("Order queue is full.");
}
}
}
public sealed class ShipSkillProfileRuntime
{
public int Navigation { get; set; }
@@ -47,6 +226,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;
@@ -58,7 +239,7 @@ public sealed class ShipOrderRuntime
public string? SourceStationId { get; set; }
public string? DestinationStationId { get; set; }
public string? ItemId { get; set; }
public string? NodeId { get; set; }
public string? AnchorId { get; set; }
public string? ConstructionSiteId { get; set; }
public string? ModuleId { get; set; }
public float WaitSeconds { get; set; }
@@ -75,8 +256,8 @@ 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? PreferredNodeId { get; set; }
public string? ItemId { get; set; }
public string? PreferredAnchorId { get; set; }
public string? PreferredConstructionSiteId { get; set; }
public string? PreferredModuleId { get; set; }
public Vector3? TargetPosition { get; set; }
@@ -100,7 +281,7 @@ public sealed class ShipOrderTemplateRuntime
public string? SourceStationId { get; set; }
public string? DestinationStationId { get; set; }
public string? ItemId { get; set; }
public string? NodeId { get; set; }
public string? AnchorId { get; set; }
public string? ConstructionSiteId { get; set; }
public string? ModuleId { get; set; }
public float WaitSeconds { get; set; }
@@ -109,33 +290,6 @@ public sealed class ShipOrderTemplateRuntime
public bool KnownStationsOnly { get; set; }
}
public sealed class ShipPlanRuntime
{
public required string Id { get; init; }
public required AiPlanSourceKind SourceKind { get; init; }
public required string SourceId { get; init; }
public required string Kind { get; init; }
public required string Summary { get; set; }
public AiPlanStatus Status { get; set; } = AiPlanStatus.Planned;
public int CurrentStepIndex { get; set; }
public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public string? InterruptReason { get; set; }
public string? FailureReason { get; set; }
public List<ShipPlanStepRuntime> Steps { get; } = [];
}
public sealed class ShipPlanStepRuntime
{
public required string Id { get; init; }
public required string Kind { get; init; }
public required string Summary { get; set; }
public AiPlanStepStatus Status { get; set; } = AiPlanStepStatus.Planned;
public int CurrentSubTaskIndex { get; set; }
public string? BlockingReason { get; set; }
public List<ShipSubTaskRuntime> SubTasks { get; } = [];
}
public sealed class ShipSubTaskRuntime
{
public required string Id { get; init; }
@@ -144,7 +298,9 @@ public sealed class ShipSubTaskRuntime
public WorkStatus Status { get; set; } = WorkStatus.Pending;
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetNodeId { get; set; }
public string? TargetAnchorId { get; set; }
public string? TargetResourceNodeId { get; set; }
public string? TargetResourceDepositId { get; set; }
public Vector3? TargetPosition { get; set; }
public string? ItemId { get; set; }
public string? ModuleId { get; set; }

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
namespace SpaceGame.Api.Simulation.Core;
public sealed class SimulationEngine
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,11 @@ public sealed class SimulationEngine
private readonly ShipAiService _shipAi;
private readonly SimulationProjectionService _projection;
public SimulationEngine(OrbitalSimulationOptions? orbitalSimulation = null)
internal SimulationEngine(OrbitalSimulationOptions orbitalSimulation, IBalanceService balance, IPlayerStateStore playerStateStore)
{
_orbitalSimulation = orbitalSimulation ?? new OrbitalSimulationOptions();
_balance = balance;
_playerStateStore = playerStateStore;
_orbitalSimulation = orbitalSimulation;
_orbitalStateUpdater = new OrbitalStateUpdater(_orbitalSimulation);
_infrastructureSimulation = new InfrastructureSimulationService();
_geopolitics = new GeopoliticalSimulationService();
@@ -24,7 +27,7 @@ public sealed class SimulationEngine
_playerFaction = new PlayerFactionService();
_stationSimulation = new StationSimulationService();
_stationLifecycle = new StationLifecycleService(_stationSimulation);
_shipAi = new ShipAiService();
_shipAi = new ShipAiService(balance);
_projection = new SimulationProjectionService(_orbitalSimulation);
}
@@ -32,7 +35,7 @@ public sealed class SimulationEngine
{
var nowUtc = DateTimeOffset.UtcNow;
var events = new List<SimulationEventRecord>();
var simulationDeltaSeconds = deltaSeconds * MathF.Max(world.Balance.SimulationSpeedMultiplier, 0.01f);
var simulationDeltaSeconds = deltaSeconds * MathF.Max(_balance.SimulationSpeedMultiplier, 0.01f);
world.GeneratedAtUtc = nowUtc;
world.OrbitalTimeSeconds += simulationDeltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond;
@@ -41,8 +44,8 @@ public 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())
@@ -75,7 +78,7 @@ public 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)
{
@@ -93,7 +96,7 @@ public 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())
@@ -101,12 +104,12 @@ public sealed class SimulationEngine
CreateWreck(world, "station", station.Id, station.SystemId, station.Position, station.MaxHealth * 0.12f);
world.Stations.Remove(station);
if (station.CelestialId is not null && world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId) is { } celestial)
if (station.AnchorId is not null && world.Anchors.FirstOrDefault(candidate => candidate.Id == station.AnchorId) is { } anchor)
{
celestial.OccupyingStructureId = null;
anchor.OccupyingStructureId = null;
}
foreach (var claim in world.Claims.Where(candidate => candidate.CelestialId == station.CelestialId))
foreach (var claim in world.Claims.Where(candidate => candidate.AnchorId == station.AnchorId))
{
claim.Health = 0f;
claim.State = ClaimStateKinds.Destroyed;

View File

@@ -24,6 +24,7 @@ internal sealed class SimulationProjectionService
false,
events,
BuildCelestialDeltas(world),
BuildAnchorDeltas(world),
BuildNodeDeltas(world),
BuildStationDeltas(world),
BuildClaimDeltas(world),
@@ -32,7 +33,6 @@ internal sealed class SimulationProjectionService
BuildPolicyDeltas(world),
BuildShipDeltas(world),
BuildFactionDeltas(world),
BuildPlayerFactionDelta(world),
BuildGeopoliticsDelta(world));
public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence)
@@ -88,26 +88,37 @@ internal sealed class SimulationProjectionService
c.Kind,
c.OrbitalAnchor,
c.LocalSpaceRadius,
c.ParentNodeId,
c.ParentAnchorId,
c.OccupyingStructureId,
c.OrbitReferenceId)).ToList(),
world.Anchors.Select(ToAnchorDelta).Select(anchor => new AnchorSnapshot(
anchor.Id,
anchor.SystemId,
anchor.Kind,
anchor.SystemPosition,
anchor.LocalSpaceRadius,
anchor.ParentAnchorId,
anchor.OccupyingStructureId,
anchor.OrbitReferenceId)).ToList(),
world.Nodes.Select(ToNodeDelta).Select(node => new ResourceNodeSnapshot(
node.Id,
node.AnchorId,
node.SystemId,
node.LocalPosition,
node.CelestialId,
node.LocalSpaceRadius,
node.SourceKind,
node.OreRemaining,
node.MaxOre,
node.ItemId)).ToList(),
node.ItemId,
node.Deposits)).ToList(),
world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot(
station.Id,
station.Label,
station.Category,
station.Objective,
station.SystemId,
station.AnchorId,
station.LocalPosition,
station.CelestialId,
station.Color,
station.DockedShips,
station.DockedShipIds,
@@ -128,7 +139,7 @@ internal sealed class SimulationProjectionService
claim.Id,
claim.FactionId,
claim.SystemId,
claim.CelestialId,
claim.AnchorId,
claim.State,
claim.Health,
claim.PlacedAtUtc,
@@ -137,7 +148,7 @@ internal sealed class SimulationProjectionService
site.Id,
site.FactionId,
site.SystemId,
site.CelestialId,
site.AnchorId,
site.TargetKind,
site.TargetDefinitionId,
site.BlueprintId,
@@ -177,10 +188,11 @@ 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.AnchorId,
ship.LocalPosition,
ship.LocalVelocity,
ship.TargetLocalPosition,
@@ -189,19 +201,17 @@ internal sealed class SimulationProjectionService
ship.DefaultBehavior,
ship.Assignment,
ship.Skills,
ship.ActivePlan,
ship.CurrentStepId,
ship.ActiveSubTasks,
ship.ControlSourceKind,
ship.ControlSourceId,
ship.ControlReason,
ship.LastReplanReason,
ship.LastAccessFailureReason,
ship.CelestialId,
ship.DockedStationId,
ship.CommanderId,
ship.PolicySetId,
ship.CargoCapacity,
ship.CargoTypes,
ship.TravelSpeed,
ship.TravelSpeedUnit,
ship.Inventory,
@@ -225,7 +235,6 @@ internal sealed class SimulationProjectionService
faction.StrategicState,
faction.DecisionLog,
faction.Commanders)).ToList(),
ToPlayerFactionSnapshot(world.PlayerFaction),
ToGeopoliticalStateSnapshot(world.Geopolitics));
}
@@ -241,6 +250,11 @@ internal sealed class SimulationProjectionService
celestial.LastDeltaSignature = BuildCelestialSignature(celestial);
}
foreach (var anchor in world.Anchors)
{
anchor.LastDeltaSignature = BuildAnchorSignature(anchor);
}
foreach (var station in world.Stations)
{
station.LastDeltaSignature = BuildStationSignature(world, station);
@@ -276,11 +290,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);
@@ -305,6 +314,24 @@ internal sealed class SimulationProjectionService
return deltas;
}
private static IReadOnlyList<AnchorDelta> BuildAnchorDeltas(SimulationWorld world)
{
var deltas = new List<AnchorDelta>();
foreach (var anchor in world.Anchors)
{
var signature = BuildAnchorSignature(anchor);
if (signature == anchor.LastDeltaSignature)
{
continue;
}
anchor.LastDeltaSignature = signature;
deltas.Add(ToAnchorDelta(anchor));
}
return deltas;
}
private static IReadOnlyList<CelestialDelta> BuildCelestialDeltas(SimulationWorld world)
{
var deltas = new List<CelestialDelta>();
@@ -450,23 +477,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)
@@ -490,17 +500,30 @@ internal sealed class SimulationProjectionService
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
private static string BuildNodeSignature(ResourceNodeRuntime node) =>
$"{node.SystemId}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.CelestialId}|{node.OreRemaining:0.###}";
string.Join("|",
node.SystemId,
node.AnchorId,
$"{node.Position.X:0.###}",
$"{node.Position.Y:0.###}",
$"{node.Position.Z:0.###}",
$"{node.OreRemaining:0.###}",
string.Join(",",
node.Deposits
.OrderBy(deposit => deposit.Id, StringComparer.Ordinal)
.Select(deposit => $"{deposit.Id}:{deposit.Position.X:0.###}:{deposit.Position.Y:0.###}:{deposit.Position.Z:0.###}:{deposit.OreRemaining:0.###}")));
private static string BuildCelestialSignature(CelestialRuntime celestial) =>
$"{celestial.SystemId}|{celestial.Kind.ToContractValue()}|{celestial.Position.X:0.###}|{celestial.Position.Y:0.###}|{celestial.Position.Z:0.###}|{celestial.LocalSpaceRadius:0.###}|{celestial.ParentNodeId}|{celestial.OccupyingStructureId}|{celestial.OrbitReferenceId}";
$"{celestial.SystemId}|{celestial.Kind.ToContractValue()}|{celestial.Position.X:0.###}|{celestial.Position.Y:0.###}|{celestial.Position.Z:0.###}|{celestial.LocalSpaceRadius:0.###}|{celestial.ParentAnchorId}|{celestial.OccupyingStructureId}|{celestial.OrbitReferenceId}";
private static string BuildAnchorSignature(AnchorRuntime anchor) =>
$"{anchor.SystemId}|{anchor.Kind.ToContractValue()}|{anchor.Position.X:0.###}|{anchor.Position.Y:0.###}|{anchor.Position.Z:0.###}|{anchor.LocalSpaceRadius:0.###}|{anchor.ParentAnchorId}|{anchor.OccupyingStructureId}|{anchor.OrbitReferenceId}|{anchor.SourceEntityKind}|{anchor.SourceEntityId}";
private static string BuildStationSignature(SimulationWorld world, StationRuntime station)
{
var processes = ToStationActionProgressSnapshots(world, station);
return string.Join("|",
station.SystemId,
station.CelestialId ?? "none",
station.AnchorId ?? "none",
station.CommanderId ?? "none",
station.PolicySetId ?? "none",
BuildInventorySignature(station.Inventory),
@@ -519,10 +542,10 @@ internal sealed class SimulationProjectionService
}
private static string BuildClaimSignature(ClaimRuntime claim) =>
$"{claim.FactionId}|{claim.SystemId}|{claim.CelestialId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}";
$"{claim.FactionId}|{claim.SystemId}|{claim.AnchorId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}";
private static string BuildConstructionSiteSignature(ConstructionSiteRuntime site) =>
$"{site.FactionId}|{site.SystemId}|{site.CelestialId}|{site.TargetKind}|{site.TargetDefinitionId}|{site.BlueprintId}|{site.ClaimId}|{site.StationId}|{site.State}|{site.Progress:0.###}|{BuildInventorySignature(site.Inventory)}|{BuildInventorySignature(site.RequiredItems)}|{BuildInventorySignature(site.DeliveredItems)}|{string.Join(",", site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal))}";
$"{site.FactionId}|{site.SystemId}|{site.AnchorId}|{site.TargetKind}|{site.TargetDefinitionId}|{site.BlueprintId}|{site.ClaimId}|{site.StationId}|{site.State}|{site.Progress:0.###}|{BuildInventorySignature(site.Inventory)}|{BuildInventorySignature(site.RequiredItems)}|{BuildInventorySignature(site.DeliveredItems)}|{string.Join(",", site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal))}";
private static string BuildMarketOrderSignature(MarketOrderRuntime order) =>
$"{order.FactionId}|{order.StationId}|{order.ConstructionSiteId}|{order.Kind}|{order.ItemId}|{order.Amount:0.###}|{order.RemainingAmount:0.###}|{order.Valuation:0.###}|{order.ReserveThreshold?.ToString("0.###") ?? "none"}|{order.PolicySetId}|{order.State}";
@@ -544,11 +567,10 @@ internal sealed class SimulationProjectionService
ship.TargetPosition.Z.ToString("0.###"),
ship.State.ToContractValue(),
string.Join(",", ship.OrderQueue
.OrderByDescending(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",
@@ -568,23 +590,20 @@ internal sealed class SimulationProjectionService
ship.CommanderId is not null && world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment is { } assignment
? $"{assignment.ObjectiveId}:{assignment.Kind}:{assignment.BehaviorKind}:{assignment.Status}:{assignment.CampaignId}:{assignment.TheaterId}:{assignment.TargetSystemId}:{assignment.TargetEntityId}:{assignment.ItemId}:{assignment.Priority:0.###}:{assignment.UpdatedAtUtc.UtcTicks}"
: "no-assignment",
ship.ActivePlan?.Kind ?? "none",
ship.ActivePlan?.Status.ToContractValue() ?? "none",
ship.ActivePlan?.CurrentStepIndex.ToString(CultureInfo.InvariantCulture) ?? "-1",
string.Join(",",
ToActiveSubTaskSnapshots(ship).Select(subTask =>
$"{subTask.Id}:{subTask.Kind}:{subTask.Status}:{subTask.Progress:0.###}:{subTask.ElapsedSeconds:0.###}:{subTask.BlockingReason ?? "none"}")),
ship.SpatialState.CurrentCelestialId ?? "none",
ship.SpatialState.CurrentAnchorId ?? "none",
ship.DockedStationId ?? "none",
ship.CommanderId ?? "none",
ship.PolicySetId ?? "none",
ship.SpatialState.SpaceLayer.ToContractValue(),
ship.SpatialState.CurrentCelestialId ?? "none",
ship.SpatialState.CurrentAnchorId ?? "none",
ship.SpatialState.MovementRegime.ToContractValue(),
ship.SpatialState.DestinationNodeId ?? "none",
ship.SpatialState.DestinationAnchorId ?? "none",
ship.SpatialState.Transit?.Regime.ToContractValue() ?? "none",
ship.SpatialState.Transit?.OriginNodeId ?? "none",
ship.SpatialState.Transit?.DestinationNodeId ?? "none",
ship.SpatialState.Transit?.OriginAnchorId ?? "none",
ship.SpatialState.Transit?.DestinationAnchorId ?? "none",
ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0",
GetShipCargoAmount(ship).ToString("0.###"),
ship.Skills.Navigation.ToString(CultureInfo.InvariantCulture),
@@ -593,7 +612,9 @@ internal sealed class SimulationProjectionService
ship.Skills.Combat.ToString(CultureInfo.InvariantCulture),
ship.Skills.Construction.ToString(CultureInfo.InvariantCulture),
ship.Health.ToString("0.###"),
GetCurrentShipStep(ship)?.Id ?? "none");
ship.ActiveSubTaskIndex >= 0 && ship.ActiveSubTaskIndex < ship.ActiveSubTasks.Count
? ship.ActiveSubTasks[ship.ActiveSubTaskIndex].Id
: "none");
private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
string.Join(",",
@@ -642,59 +663,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(";",
@@ -728,13 +696,33 @@ internal sealed class SimulationProjectionService
private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new(
node.Id,
node.AnchorId,
node.SystemId,
ToDto(node.Position),
node.CelestialId,
node.LocalSpaceRadius,
node.SourceKind,
node.OreRemaining,
node.MaxOre,
node.ItemId);
node.ItemId,
node.Deposits.Select(ToResourceDepositSnapshot).ToList());
private static ResourceDepositSnapshot ToResourceDepositSnapshot(ResourceDepositRuntime deposit) => new(
deposit.Id,
deposit.NodeId,
deposit.AnchorId,
ToDto(deposit.Position),
deposit.OreRemaining,
deposit.MaxOre);
private static AnchorDelta ToAnchorDelta(AnchorRuntime anchor) => new(
anchor.Id,
anchor.SystemId,
anchor.Kind.ToContractValue(),
ToDto(anchor.Position),
anchor.LocalSpaceRadius,
anchor.ParentAnchorId,
anchor.OccupyingStructureId,
anchor.OrbitReferenceId);
private static CelestialDelta ToCelestialDelta(CelestialRuntime celestial) => new(
celestial.Id,
@@ -742,7 +730,7 @@ internal sealed class SimulationProjectionService
celestial.Kind.ToContractValue(),
ToDto(celestial.Position),
celestial.LocalSpaceRadius,
celestial.ParentNodeId,
celestial.ParentAnchorId,
celestial.OccupyingStructureId,
celestial.OrbitReferenceId);
@@ -752,8 +740,8 @@ internal sealed class SimulationProjectionService
station.Category,
station.Objective,
station.SystemId,
station.AnchorId,
ToDto(station.Position),
station.CelestialId,
station.Color,
station.DockedShipIds.Count,
station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
@@ -801,7 +789,7 @@ internal sealed class SimulationProjectionService
.Select(storageKind => new StationStorageUsageSnapshot(
storageKind.ToDataValue(),
station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind)
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageKind)
.Sum(entry => entry.Value),
GetStationStorageCapacity(world, station, storageKind)))
.Where(snapshot => snapshot.Capacity > 0.01f)
@@ -812,7 +800,7 @@ internal sealed class SimulationProjectionService
claim.Id,
claim.FactionId,
claim.SystemId,
claim.CelestialId,
claim.AnchorId,
claim.State,
claim.Health,
claim.PlacedAtUtc,
@@ -822,7 +810,7 @@ internal sealed class SimulationProjectionService
site.Id,
site.FactionId,
site.SystemId,
site.CelestialId,
site.AnchorId,
site.TargetKind,
site.TargetDefinitionId,
site.BlueprintId,
@@ -882,10 +870,11 @@ 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,
ship.SpatialState.CurrentAnchorId,
ToDto(ship.Position),
ToDto(ship.Velocity),
ToDto(ship.TargetPosition),
@@ -894,19 +883,22 @@ internal sealed class SimulationProjectionService
ToDefaultBehaviorSnapshot(ship.DefaultBehavior),
ToShipAssignmentSnapshot(commander),
new ShipSkillProfileSnapshot(ship.Skills.Navigation, ship.Skills.Trade, ship.Skills.Mining, ship.Skills.Combat, ship.Skills.Construction),
ToShipPlanSnapshot(ship.ActivePlan),
GetCurrentShipStep(ship)?.Id,
ToActiveSubTaskSnapshots(ship),
ship.ControlSourceKind,
ship.ControlSourceId,
ship.ControlReason,
ship.LastReplanReason,
ship.LastAccessFailureReason,
ship.SpatialState.CurrentCelestialId,
ship.DockedStationId,
ship.CommanderId,
ship.PolicySetId,
ship.Definition.CargoCapacity,
ship.Definition.GetTotalCargoCapacity(),
ship.Definition.Cargo
.SelectMany(entry => entry.Types)
.Where(type => !string.IsNullOrWhiteSpace(type))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(type => type, StringComparer.OrdinalIgnoreCase)
.ToList(),
ToShipTravelSpeed(ship).Speed,
ToShipTravelSpeed(ship).Unit,
@@ -923,7 +915,7 @@ internal sealed class SimulationProjectionService
{
MovementRegimeKind.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"),
MovementRegimeKind.Warp => (ship.State == ShipState.Warping ? ship.Definition.WarpSpeed : 0f, "AU/s"),
_ => (MathF.Sqrt(MathF.Max(0f, ship.Velocity.LengthSquared())) * SimulationUnits.MetersPerKilometer, "m/s"),
_ => (MathF.Sqrt(MathF.Max(0f, ship.Velocity.LengthSquared())), "m/s"),
};
}
@@ -936,11 +928,11 @@ internal sealed class SimulationProjectionService
private static IReadOnlyList<ShipOrderSnapshot> ToShipOrderSnapshots(ShipRuntime ship) =>
ship.OrderQueue
.OrderByDescending(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,
@@ -952,7 +944,7 @@ internal sealed class SimulationProjectionService
order.SourceStationId,
order.DestinationStationId,
order.ItemId,
order.NodeId,
order.AnchorId,
order.ConstructionSiteId,
order.ModuleId,
order.WaitSeconds,
@@ -969,8 +961,8 @@ internal sealed class SimulationProjectionService
behavior.HomeStationId,
behavior.AreaSystemId,
behavior.TargetEntityId,
behavior.PreferredItemId,
behavior.PreferredNodeId,
behavior.ItemId,
behavior.PreferredAnchorId,
behavior.PreferredConstructionSiteId,
behavior.PreferredModuleId,
behavior.TargetPosition is null ? null : ToDto(behavior.TargetPosition.Value),
@@ -993,7 +985,7 @@ internal sealed class SimulationProjectionService
template.SourceStationId,
template.DestinationStationId,
template.ItemId,
template.NodeId,
template.AnchorId,
template.ConstructionSiteId,
template.ModuleId,
template.WaitSeconds,
@@ -1028,48 +1020,18 @@ internal sealed class SimulationProjectionService
assignment.UpdatedAtUtc);
}
private static ShipPlanSnapshot? ToShipPlanSnapshot(ShipPlanRuntime? plan)
{
if (plan is null)
{
return null;
}
return new ShipPlanSnapshot(
plan.Id,
plan.SourceKind.ToContractValue(),
plan.SourceId,
plan.Kind,
plan.Status.ToContractValue(),
plan.Summary,
plan.CurrentStepIndex,
plan.CreatedAtUtc,
plan.UpdatedAtUtc,
plan.InterruptReason,
plan.FailureReason,
plan.Steps.Select(ToShipPlanStepSnapshot).ToList());
}
private static ShipPlanStepSnapshot ToShipPlanStepSnapshot(ShipPlanStepRuntime step) =>
new(
step.Id,
step.Kind,
step.Status.ToContractValue(),
step.Summary,
step.BlockingReason,
step.CurrentSubTaskIndex,
step.SubTasks.Select(ToShipSubTaskSnapshot).ToList());
private static ShipSubTaskSnapshot ToShipSubTaskSnapshot(ShipSubTaskRuntime subTask) =>
new(
subTask.Id,
subTask.Kind,
subTask.Status.ToContractValue(),
subTask.Summary,
subTask.TargetEntityId,
subTask.TargetSystemId,
subTask.TargetNodeId,
subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value),
subTask.TargetEntityId,
subTask.TargetSystemId,
subTask.TargetAnchorId,
subTask.TargetResourceNodeId,
subTask.TargetResourceDepositId,
subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value),
subTask.ItemId,
subTask.ModuleId,
subTask.Threshold,
@@ -1081,23 +1043,12 @@ internal sealed class SimulationProjectionService
private static IReadOnlyList<ShipSubTaskSnapshot> ToActiveSubTaskSnapshots(ShipRuntime ship)
{
var step = GetCurrentShipStep(ship);
if (step is null)
{
return [];
}
return step.SubTasks
return ship.ActiveSubTasks
.Where(subTask => subTask.Status is WorkStatus.Pending or WorkStatus.Active or WorkStatus.Blocked)
.Select(ToShipSubTaskSnapshot)
.ToList();
}
private static ShipPlanStepRuntime? GetCurrentShipStep(ShipRuntime ship) =>
ship.ActivePlan is null || ship.ActivePlan.CurrentStepIndex >= ship.ActivePlan.Steps.Count
? null
: ship.ActivePlan.Steps[ship.ActivePlan.CurrentStepIndex];
private static CommanderAssignmentSnapshot ToCommanderAssignmentSnapshot(CommanderRuntime commander)
{
var assignment = commander.Assignment;
@@ -1385,252 +1336,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)
@@ -1718,7 +1423,7 @@ internal sealed class SimulationProjectionService
claim.SourceClaimId,
claim.FactionId,
claim.SystemId,
claim.CelestialId,
claim.AnchorId,
claim.Status,
claim.ClaimKind,
claim.ClaimStrength,
@@ -1874,15 +1579,15 @@ internal sealed class SimulationProjectionService
private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new(
state.SpaceLayer.ToContractValue(),
state.CurrentSystemId,
state.CurrentCelestialId,
state.CurrentAnchorId,
state.LocalPosition is null ? null : ToDto(state.LocalPosition.Value),
state.SystemPosition is null ? null : ToDto(state.SystemPosition.Value),
state.MovementRegime.ToContractValue(),
state.DestinationNodeId,
state.DestinationAnchorId,
state.Transit is null ? null : new ShipTransitSnapshot(
state.Transit.Regime.ToContractValue(),
state.Transit.OriginNodeId,
state.Transit.DestinationNodeId,
state.Transit.OriginAnchorId,
state.Transit.DestinationAnchorId,
state.Transit.StartedAtUtc,
state.Transit.ArrivalDueAtUtc,
state.Transit.Progress));

View File

@@ -9,6 +9,8 @@
<ItemGroup>
<PackageReference Include="FastEndpoints" Version="6.*" />
<PackageReference Include="FastEndpoints.Swagger" Version="6.*" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-preview.7.25380.108" />
<PackageReference Include="Npgsql" Version="9.0.3" />
</ItemGroup>
</Project>

View File

@@ -10,8 +10,8 @@ public sealed record StationSnapshot(
string Category,
string Objective,
string SystemId,
string? AnchorId,
Vector3Dto LocalPosition,
string? CelestialId,
string Color,
int DockedShips,
IReadOnlyList<string> DockedShipIds,
@@ -35,8 +35,8 @@ public sealed record StationDelta(
string Category,
string Objective,
string SystemId,
string? AnchorId,
Vector3Dto LocalPosition,
string? CelestialId,
string Color,
int DockedShips,
IReadOnlyList<string> DockedShipIds,
@@ -74,7 +74,7 @@ public sealed record ClaimSnapshot(
string Id,
string FactionId,
string SystemId,
string CelestialId,
string AnchorId,
string State,
float Health,
DateTimeOffset PlacedAtUtc,
@@ -84,7 +84,7 @@ public sealed record ClaimDelta(
string Id,
string FactionId,
string SystemId,
string CelestialId,
string AnchorId,
string State,
float Health,
DateTimeOffset PlacedAtUtc,
@@ -94,7 +94,7 @@ public sealed record ConstructionSiteSnapshot(
string Id,
string FactionId,
string SystemId,
string CelestialId,
string AnchorId,
string TargetKind,
string TargetDefinitionId,
string? BlueprintId,
@@ -112,7 +112,7 @@ public sealed record ConstructionSiteDelta(
string Id,
string FactionId,
string SystemId,
string CelestialId,
string AnchorId,
string TargetKind,
string TargetDefinitionId,
string? BlueprintId,

View File

@@ -5,7 +5,7 @@ public sealed class ClaimRuntime
public required string Id { get; init; }
public required string FactionId { get; init; }
public required string SystemId { get; init; }
public required string CelestialId { get; init; }
public required string AnchorId { get; init; }
public string? CommanderId { get; set; }
public DateTimeOffset PlacedAtUtc { get; init; }
public DateTimeOffset ActivatesAtUtc { get; set; }
@@ -19,7 +19,7 @@ public sealed class ConstructionSiteRuntime
public required string Id { get; init; }
public required string FactionId { get; init; }
public required string SystemId { get; init; }
public required string CelestialId { get; init; }
public required string AnchorId { get; init; }
public required string TargetKind { get; init; }
public required string TargetDefinitionId { get; init; }
public string? BlueprintId { get; set; }

View File

@@ -7,6 +7,7 @@ public sealed class StationRuntime
{
public required string Id { get; init; }
public required string SystemId { get; init; }
public string? AnchorId { get; set; }
public required string Label { get; set; }
public string Category { get; set; } = "station";
public string Objective { get; set; } = "general";
@@ -14,7 +15,6 @@ public sealed class StationRuntime
public required Vector3 Position { get; set; }
public float Radius { get; set; } = 24f;
public required string FactionId { get; init; }
public string? CelestialId { get; set; }
public string? CommanderId { get; set; }
public string? PolicySetId { get; set; }
public List<StationModuleRuntime> Modules { get; } = [];
@@ -65,6 +65,14 @@ public class StationModuleRuntime
Health = production.Hull,
MaxHealth = production.Hull,
},
BuildModuleDefinition build => new BuildStationModuleRuntime
{
Id = id,
ModuleId = build.Id,
ModuleType = build.ModuleType,
Health = build.Hull,
MaxHealth = build.Hull,
},
_ => new StationModuleRuntime
{
Id = id,
@@ -81,6 +89,10 @@ public sealed class ProductionStationModuleRuntime : StationModuleRuntime
public IReadOnlyList<string> ProductItemIds { get; init; } = [];
}
public sealed class BuildStationModuleRuntime : StationModuleRuntime
{
}
public sealed class StorageStationModuleRuntime : StationModuleRuntime
{
public StorageKind StorageKind { get; init; }

View File

@@ -176,7 +176,7 @@ internal sealed class InfrastructureSimulationService
}
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoStorageKind == storageKind)
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageKind)
.Sum(entry => entry.Value);
if (used / capacity >= 0.65f)
{
@@ -195,7 +195,7 @@ internal sealed class InfrastructureSimulationService
continue;
}
if (GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoStorageKind) is { } storageModuleId)
if (GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoKind) is { } storageModuleId)
{
yield return storageModuleId;
}
@@ -203,14 +203,14 @@ internal sealed class InfrastructureSimulationService
if (world.ModuleDefinitions.TryGetValue(recipe.ModuleId, out var moduleDefinition))
{
foreach (var productItemId in moduleDefinition.Products)
foreach (var productItemId in moduleDefinition.ProductItemIds)
{
if (!world.ItemDefinitions.TryGetValue(productItemId, out var itemDefinition))
{
continue;
}
if (GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoStorageKind) is { } storageModuleId)
if (GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoKind) is { } storageModuleId)
{
yield return storageModuleId;
}
@@ -325,7 +325,7 @@ internal sealed class InfrastructureSimulationService
var capacity = GetStationStorageCapacity(world, station, storageKind);
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoStorageKind == storageKind)
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageKind)
.Sum(entry => entry.Value);
var utilization = capacity <= 0.01f ? 0f : used / capacity;
@@ -621,7 +621,7 @@ internal sealed class InfrastructureSimulationService
}
var score = 0f;
foreach (var productItemId in moduleDefinition.Products)
foreach (var productItemId in moduleDefinition.ProductItemIds)
{
if (!constructionDemandByItem.TryGetValue(productItemId, out var outstandingDemand) || outstandingDemand <= 0.01f)
{
@@ -689,12 +689,12 @@ internal sealed class InfrastructureSimulationService
return recipe.Inputs.Any(input =>
world.ItemDefinitions.TryGetValue(input.ItemId, out var itemDefinition)
&& itemDefinition.CargoStorageKind == storageKind);
&& itemDefinition.CargoKind == storageKind);
}
private static bool CommodityUsesStorageClass(SimulationWorld world, string commodityId, StorageKind storageKind) =>
world.ItemDefinitions.TryGetValue(commodityId, out var itemDefinition)
&& itemDefinition.CargoStorageKind == storageKind;
&& itemDefinition.CargoKind == storageKind;
private static bool CanStationAcceptStationOutputSoon(SimulationWorld world, StationRuntime station, string itemId, float amount)
{
@@ -703,7 +703,7 @@ internal sealed class InfrastructureSimulationService
return false;
}
if (itemDefinition.CargoStorageKind is not { } storageKind)
if (itemDefinition.CargoKind is not { } storageKind)
{
return false;
}
@@ -715,7 +715,7 @@ internal sealed class InfrastructureSimulationService
}
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind)
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageKind)
.Sum(entry => entry.Value);
return used + amount <= capacity * 0.95f;
}

View File

@@ -1,4 +1,6 @@
using SpaceGame.Api.Shared.Runtime;
using SpaceGame.Api.Ships.AI;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Stations.Simulation;
@@ -34,17 +36,16 @@ internal sealed class StationLifecycleService
private void UpdateStationPopulation(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f);
station.WorkforceRequired = GetStationRequiredWorkforce(world.ModuleDefinitions, station);
var requiredWater = station.Population * WaterConsumptionPerWorkerPerSecond * deltaSeconds;
var consumedWater = RemoveInventory(station.Inventory, "water", requiredWater);
var waterSatisfied = requiredWater <= 0.01f || consumedWater + 0.001f >= requiredWater;
var habitatModules = CountStationModules(station, ModuleType.Habitation);
station.PopulationCapacity = 40f + (habitatModules * 220f);
station.PopulationCapacity = GetStationSupportedPopulation(world.ModuleDefinitions, station);
if (waterSatisfied)
{
if (habitatModules > 0 && station.Population < station.PopulationCapacity)
if (station.PopulationCapacity > 40f && station.Population < station.PopulationCapacity)
{
station.Population = MathF.Min(station.PopulationCapacity, station.Population + (PopulationGrowthPerSecond * deltaSeconds));
}
@@ -80,8 +81,8 @@ internal sealed class StationLifecycleService
TargetPosition = spawnPosition,
SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition),
DefaultBehavior = CreateSpawnedShipBehavior(definition, station),
Skills = WorldSeedingService.CreateSkills(definition),
Health = definition.MaxHealth,
Skills = ShipBootstrapPolicy.CreateSkills(definition),
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;
}
@@ -99,7 +100,7 @@ internal sealed class StationLifecycleService
{
CurrentSystemId = station.SystemId,
SpaceLayer = SpaceLayerKind.LocalSpace,
CurrentCelestialId = station.CelestialId,
CurrentAnchorId = station.AnchorId,
LocalPosition = position,
SystemPosition = position,
MovementRegime = MovementRegimeKind.LocalFlight,
@@ -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,

View File

@@ -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;
@@ -216,12 +221,8 @@ internal sealed class StationSimulationService
{
foreach (var moduleId in station.InstalledModules.Distinct(StringComparer.Ordinal))
{
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var def) || string.IsNullOrEmpty(def.ProductionMode))
{
continue;
}
if (string.Equals(def.ProductionMode, "commanded", StringComparison.Ordinal) && station.CommanderId is null)
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var definition)
|| definition is not ProductionLaneModuleDefinition)
{
continue;
}
@@ -241,7 +242,7 @@ internal sealed class StationSimulationService
internal static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) =>
recipe.RequiredModules.FirstOrDefault(moduleId =>
world.ModuleDefinitions.TryGetValue(moduleId, out var def) && !string.IsNullOrEmpty(def.ProductionMode));
world.ModuleDefinitions.TryGetValue(moduleId, out var definition) && definition is ProductionLaneModuleDefinition);
internal static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
{
@@ -259,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);
@@ -270,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
@@ -342,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;
}
@@ -387,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;
}
@@ -408,7 +422,7 @@ internal sealed class StationSimulationService
return false;
}
var storageKind = itemDefinition.CargoStorageKind;
var storageKind = itemDefinition.CargoKind;
if (storageKind is null)
{
return false;
@@ -426,7 +440,7 @@ internal sealed class StationSimulationService
}
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind)
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageKind)
.Sum(entry => entry.Value);
return used + amount <= capacity + 0.001f;
}
@@ -712,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);
@@ -721,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,
};
}

View File

@@ -0,0 +1,25 @@
using FastEndpoints;
namespace SpaceGame.Api.Universe.Api;
public sealed class CreateFactionHandler(WorldService worldService) : Endpoint<CreateFactionCommandRequest, FactionSnapshot>
{
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);
}
}
}

View File

@@ -3,14 +3,14 @@ using SpaceGame.Api.Universe.Simulation;
namespace SpaceGame.Api.Universe.Api;
public sealed class GetBalanceHandler(WorldService worldService) : EndpointWithoutRequest
public sealed class GetBalanceHandler(IBalanceService balanceService) : EndpointWithoutRequest
{
public override void Configure()
{
Get("/api/balance");
AllowAnonymous();
Policies(AuthPolicyNames.GmAccess);
}
public override Task HandleAsync(CancellationToken cancellationToken) =>
SendOkAsync(worldService.GetBalance(), cancellationToken);
SendOkAsync(balanceService.GetCurrent(), cancellationToken);
}

View File

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

View File

@@ -0,0 +1,17 @@
using FastEndpoints;
namespace SpaceGame.Api.Universe.Api;
public sealed class GetVersionHandler(AppVersionService appVersionService) : EndpointWithoutRequest<VersionInfoSnapshot>
{
public override void Configure()
{
Get("/api/version");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken cancellationToken)
{
await SendOkAsync(appVersionService.GetSnapshot(), cancellationToken);
}
}

View File

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

Some files were not shown because too many files have changed in this diff Show More