Refactor runtime bootstrap and ship control flows

This commit is contained in:
2026-04-03 01:12:26 -04:00
parent 0bb72bee35
commit 706e1cda8f
129 changed files with 9588 additions and 3548 deletions

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,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, AuthSessionResponse>
{
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,42 @@
namespace SpaceGame.Api.Auth.Contracts;
public sealed class RegisterRequest
{
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public sealed class LoginRequest
{
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public sealed class RefreshTokenRequest
{
public string RefreshToken { get; set; } = string.Empty;
}
public sealed class ForgotPasswordRequest
{
public string Email { get; set; } = string.Empty;
}
public sealed class ResetPasswordRequest
{
public string Token { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
}
public sealed record AuthSessionResponse(
Guid UserId,
string Email,
IReadOnlyList<string> Roles,
string AccessToken,
DateTimeOffset AccessTokenExpiresAtUtc,
string RefreshToken,
DateTimeOffset RefreshTokenExpiresAtUtc);
public sealed record ForgotPasswordResponse(
bool Accepted,
string? ResetToken = null);

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<AuthSessionResponse> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
{
var email = NormalizeEmail(request.Email);
ValidatePassword(request.Password);
if (await authRepository.FindUserByEmailAsync(email, cancellationToken) is not null)
{
throw new InvalidOperationException("An account already exists for that email.");
}
var user = await authRepository.CreateUserAsync(email, passwordHasher.HashPassword(request.Password), [], cancellationToken);
return await CreateSessionAsync(user, cancellationToken);
}
public async Task<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,23 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
namespace SpaceGame.Api.Auth.Simulation;
public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpContextAccessor) : IPlayerIdentityResolver
{
public Guid? GetCurrentPlayerId()
{
var subject = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? httpContextAccessor.HttpContext?.User.FindFirstValue("sub");
return Guid.TryParse(subject, out var playerId) ? playerId : null;
}
public Guid GetRequiredPlayerId() =>
GetCurrentPlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required.");
public bool CanAccessGm()
{
var user = httpContextAccessor.HttpContext?.User;
return user?.IsInRole("gm") == true || user?.IsInRole("admin") == true;
}
}

View File

@@ -0,0 +1,17 @@
namespace SpaceGame.Api.Auth.Simulation;
public interface IAuthRepository
{
Task<UserAccount?> FindUserByEmailAsync(string email, CancellationToken cancellationToken);
Task<UserAccount?> FindUserByIdAsync(Guid userId, 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,8 @@
namespace SpaceGame.Api.Auth.Simulation;
public interface IPlayerIdentityResolver
{
Guid? GetCurrentPlayerId();
Guid GetRequiredPlayerId();
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,199 @@
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<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,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using SpaceGame.Api.Shared.Runtime;
using SpaceGame.Api.Shared.Runtime;
using SpaceGame.Api.Universe.Simulation;
namespace SpaceGame.Api.Definitions;
@@ -368,6 +369,72 @@ public sealed class PlanetDefinition
public bool HasRing { get; set; }
}
public enum ShipPurpose
{
[JsonStringEnumMemberName("auxiliary")]
Auxiliary,
[JsonStringEnumMemberName("mine")]
Mine,
[JsonStringEnumMemberName("build")]
Build,
[JsonStringEnumMemberName("fight")]
Fight,
[JsonStringEnumMemberName("trade")]
Trade,
[JsonStringEnumMemberName("salvage")]
Salvage,
[JsonStringEnumMemberName("dismantling")]
Dismantling,
}
public enum ShipType
{
[JsonStringEnumMemberName("resupplier")]
Resupplier,
[JsonStringEnumMemberName("miner")]
Miner,
[JsonStringEnumMemberName("carrier")]
Carrier,
[JsonStringEnumMemberName("fighter")]
Fighter,
[JsonStringEnumMemberName("heavyfighter")]
HeavyFighter,
[JsonStringEnumMemberName("destroyer")]
Destroyer,
[JsonStringEnumMemberName("largeminer")]
LargeMiner,
[JsonStringEnumMemberName("freighter")]
Freighter,
[JsonStringEnumMemberName("bomber")]
Bomber,
[JsonStringEnumMemberName("scavenger")]
Scavenger,
[JsonStringEnumMemberName("frigate")]
Frigate,
[JsonStringEnumMemberName("transporter")]
Transporter,
[JsonStringEnumMemberName("interceptor")]
Interceptor,
[JsonStringEnumMemberName("scout")]
Scout,
[JsonStringEnumMemberName("courier")]
Courier,
[JsonStringEnumMemberName("builder")]
Builder,
[JsonStringEnumMemberName("corvette")]
Corvette,
[JsonStringEnumMemberName("police")]
Police,
[JsonStringEnumMemberName("battleship")]
Battleship,
[JsonStringEnumMemberName("gunboat")]
Gunboat,
[JsonStringEnumMemberName("tug")]
Tug,
[JsonStringEnumMemberName("compactor")]
Compactor,
}
public sealed class ShipDefinition
{
public required string Id { get; set; }
@@ -379,9 +446,9 @@ public sealed class ShipDefinition
public float Hull { get; set; }
public Dictionary<string, float> Storage { get; set; } = new(StringComparer.Ordinal);
public int People { get; set; }
public string Purpose { get; set; } = string.Empty;
public ShipPurpose Purpose { get; set; }
public string Thruster { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public ShipType Type { get; set; }
public float Mass { get; set; }
public ShipInertiaDefinition? Inertia { get; set; }
public ShipDragDefinition? Drag { get; set; }
@@ -395,12 +462,6 @@ public sealed class ShipDefinition
public ItemPriceDefinition? Price { get; set; }
public List<ItemProductionDefinition> Production { get; set; } = [];
[JsonIgnore]
public string Label => Name;
[JsonIgnore]
public string Kind => InferKind(Purpose);
[JsonIgnore]
public string Class => Type;
[JsonIgnore]
public float Speed => InferLocalSpeed(Size);
[JsonIgnore]
public float WarpSpeed => InferWarpSpeed(Size);
@@ -408,53 +469,15 @@ public sealed class ShipDefinition
public float FtlSpeed => InferFtlSpeed(Size);
[JsonIgnore]
public float SpoolTime => InferSpoolTime(Size);
[JsonIgnore]
public float CargoCapacity => Cargo.Sum(entry => entry.Max);
[JsonIgnore]
public StorageKind? CargoKind => Cargo
.SelectMany(entry => entry.Types)
.Select(type => type.ToNullableStorageKind())
.FirstOrDefault(kind => kind is not null);
[JsonIgnore]
public float MaxHealth => Hull;
[JsonIgnore]
public IReadOnlyList<string> Capabilities => InferCapabilities(Purpose, Type, Cargo, Turrets);
public float GetTotalCargoCapacity() => Cargo.Sum(entry => entry.Max);
private static string InferKind(string purpose) =>
purpose switch
{
"build" => "construction",
"trade" => "transport",
"mine" => "mining",
"fight" => "military",
"auxiliary" => "military",
_ => purpose,
};
public float GetCargoCapacity(StorageKind kind) =>
Cargo
.Where(entry => entry.Types.Any(type => type.ToNullableStorageKind() == kind))
.Sum(entry => entry.Max);
private static List<string> InferCapabilities(
string purpose,
string type,
IReadOnlyCollection<ShipCargoDefinition> cargo,
IReadOnlyCollection<ShipMountDefinition> turrets)
{
var capabilities = new List<string> { "warp", "ftl" };
if (string.Equals(purpose, "mine", StringComparison.Ordinal)
|| type.Contains("miner", StringComparison.Ordinal)
|| turrets.Any(turret => turret.Types.Contains("mining", StringComparer.Ordinal)))
{
capabilities.Add("mining");
}
if (cargo.Any(entry => entry.Types.Contains("container", StringComparer.Ordinal)
|| entry.Types.Contains("solid", StringComparer.Ordinal)
|| entry.Types.Contains("liquid", StringComparer.Ordinal)))
{
capabilities.Add("cargo");
}
return capabilities;
}
public bool SupportsCargoKind(StorageKind kind) =>
GetCargoCapacity(kind) > 0f;
private static float InferWarpSpeed(string size) =>
size switch

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));
@@ -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,
ItemId = objective.ItemId ?? fallback.ItemId,
PreferredNodeId = fallback.PreferredNodeId,
PreferredConstructionSiteId = objective.Kind is "construct-site" or "supply-site" ? objective.TargetEntityId : fallback.PreferredConstructionSiteId,
PreferredModuleId = fallback.PreferredModuleId,
TargetPosition = objective.TargetPosition ?? fallback.TargetPosition,
WaitSeconds = objective.BehaviorKind == "supply-fleet" ? 4f : fallback.WaitSeconds,
WaitSeconds = objective.BehaviorKind == SupplyFleet ? 4f : fallback.WaitSeconds,
Radius = radius,
MaxSystemRange = maxRange,
KnownStationsOnly = objective.BehaviorKind == "revisit-known-stations",
PatrolPoints = objective.BehaviorKind == "patrol"
KnownStationsOnly = objective.BehaviorKind == RevisitKnownStations,
PatrolPoints = objective.BehaviorKind == Patrol
? BuildPatrolPoints(objective.TargetPosition ?? fallback.TargetPosition ?? ship.Position, radius)
: [],
PatrolIndex = ship.DefaultBehavior.PatrolIndex,
@@ -2728,7 +2749,7 @@ internal sealed class CommanderPlanningService
target.HomeStationId = source.HomeStationId;
target.AreaSystemId = source.AreaSystemId;
target.TargetEntityId = source.TargetEntityId;
target.PreferredItemId = source.PreferredItemId;
target.ItemId = source.ItemId;
target.PreferredNodeId = source.PreferredNodeId;
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
target.PreferredModuleId = source.PreferredModuleId;
@@ -2749,7 +2770,7 @@ internal sealed class CommanderPlanningService
&& string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal)
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
&& string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal)
&& string.Equals(left.PreferredItemId, right.PreferredItemId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
&& string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal)
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
@@ -2805,13 +2826,15 @@ internal sealed class CommanderPlanningService
{
Id = $"ai-order-{objective.Id}",
Kind = objective.StagingOrderKind,
SourceKind = ShipOrderSourceKind.Commander,
SourceId = objective.Id,
Priority = 90 + objective.ReinforcementLevel,
InterruptCurrentPlan = true,
Label = $"{objective.Kind} staging",
TargetEntityId = objective.TargetEntityId,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
DestinationStationId = objective.BehaviorKind == "dock-and-wait" ? objective.TargetEntityId : null,
DestinationStationId = objective.BehaviorKind == DockAndWait ? objective.TargetEntityId : null,
ItemId = objective.ItemId,
WaitSeconds = 0f,
Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f),
@@ -2885,6 +2908,8 @@ internal sealed class CommanderPlanningService
private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) =>
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
&& string.Equals(left.Kind, right.Kind, StringComparison.Ordinal)
&& left.SourceKind == right.SourceKind
&& string.Equals(left.SourceId, right.SourceId, StringComparison.Ordinal)
&& left.Priority == right.Priority
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
@@ -2920,7 +2945,7 @@ internal sealed class CommanderPlanningService
}
private static bool IsCombatObjective(FactionOperationalObjectiveRuntime objective) =>
objective.BehaviorKind is "attack-target" or "protect-position" or "protect-ship" or "protect-station" or "patrol" or "police";
objective.BehaviorKind is AttackTarget or ProtectPosition or ProtectShip or ProtectStation or Patrol or Police;
private static float EstimateFriendlyAssetValue(SimulationWorld world, string factionId, string systemId)
{

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

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

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

@@ -1,3 +1,5 @@
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
namespace SpaceGame.Api.PlayerFaction.Runtime;
public sealed class PlayerFactionRuntime
@@ -180,7 +182,7 @@ public sealed class PlayerAutomationPolicyRuntime
public string ScopeKind { get; set; } = "player-faction";
public string? ScopeId { get; set; }
public bool Enabled { get; set; } = true;
public string BehaviorKind { get; set; } = "idle";
public string BehaviorKind { get; set; } = Idle;
public bool UseOrders { get; set; }
public string? StagingOrderKind { get; set; }
public int MaxSystemRange { get; set; }
@@ -242,7 +244,7 @@ public sealed class PlayerDirectiveRuntime
public string? HomeStationId { get; set; }
public string? SourceStationId { get; set; }
public string? DestinationStationId { get; set; }
public string BehaviorKind { get; set; } = "idle";
public string BehaviorKind { get; set; } = Idle;
public bool UseOrders { get; set; }
public string? StagingOrderKind { get; set; }
public string? ItemId { get; set; }

View File

@@ -0,0 +1,9 @@
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();
void Clear();
}

View File

@@ -0,0 +1,270 @@
namespace SpaceGame.Api.PlayerFaction.Simulation;
public sealed class PlayerFactionProjectionService
{
public PlayerFactionSnapshot? ToSnapshot(PlayerFactionRuntime? player)
{
if (player is null)
{
return null;
}
return new PlayerFactionSnapshot(
player.Id,
player.Label,
player.SovereignFactionId,
player.Status,
player.CreatedAtUtc,
player.UpdatedAtUtc,
new PlayerAssetRegistrySnapshot(
player.AssetRegistry.ShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.CommanderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.ClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.ConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.PolicySetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.EconomicRegionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList()),
new PlayerStrategicIntentSnapshot(
player.StrategicIntent.StrategicPosture,
player.StrategicIntent.EconomicPosture,
player.StrategicIntent.MilitaryPosture,
player.StrategicIntent.LogisticsPosture,
player.StrategicIntent.DesiredReserveRatio,
player.StrategicIntent.AllowDelegatedCombatAutomation,
player.StrategicIntent.AllowDelegatedEconomicAutomation,
player.StrategicIntent.Notes),
player.Fleets.Select(fleet => new PlayerFleetSnapshot(
fleet.Id,
fleet.Label,
fleet.Status,
fleet.Role,
fleet.CommanderId,
fleet.FrontId,
fleet.HomeSystemId,
fleet.HomeStationId,
fleet.PolicyId,
fleet.AutomationPolicyId,
fleet.ReinforcementPolicyId,
fleet.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
fleet.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
fleet.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
fleet.UpdatedAtUtc)).ToList(),
player.TaskForces.Select(taskForce => new PlayerTaskForceSnapshot(
taskForce.Id,
taskForce.Label,
taskForce.Status,
taskForce.Role,
taskForce.FleetId,
taskForce.CommanderId,
taskForce.FrontId,
taskForce.PolicyId,
taskForce.AutomationPolicyId,
taskForce.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
taskForce.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
taskForce.UpdatedAtUtc)).ToList(),
player.StationGroups.Select(group => new PlayerStationGroupSnapshot(
group.Id,
group.Label,
group.Status,
group.Role,
group.EconomicRegionId,
group.PolicyId,
group.AutomationPolicyId,
group.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
group.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
group.FocusItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
group.UpdatedAtUtc)).ToList(),
player.EconomicRegions.Select(region => new PlayerEconomicRegionSnapshot(
region.Id,
region.Label,
region.Status,
region.Role,
region.SharedEconomicRegionId,
region.PolicyId,
region.AutomationPolicyId,
region.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
region.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
region.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
region.UpdatedAtUtc)).ToList(),
player.Fronts.Select(front => new PlayerFrontSnapshot(
front.Id,
front.Label,
front.Status,
front.Priority,
front.Posture,
front.SharedFrontLineId,
front.TargetFactionId,
front.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
front.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
front.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
front.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
front.UpdatedAtUtc)).ToList(),
player.Reserves.Select(reserve => new PlayerReserveGroupSnapshot(
reserve.Id,
reserve.Label,
reserve.Status,
reserve.ReserveKind,
reserve.HomeSystemId,
reserve.PolicyId,
reserve.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
reserve.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
reserve.UpdatedAtUtc)).ToList(),
player.Policies.Select(policy => new PlayerFactionPolicySnapshot(
policy.Id,
policy.Label,
policy.ScopeKind,
policy.ScopeId,
policy.PolicySetId,
policy.AllowDelegatedCombat,
policy.AllowDelegatedTrade,
policy.ReserveCreditsRatio,
policy.ReserveMilitaryRatio,
policy.TradeAccessPolicy,
policy.DockingAccessPolicy,
policy.ConstructionAccessPolicy,
policy.OperationalRangePolicy,
policy.CombatEngagementPolicy,
policy.AvoidHostileSystems,
policy.FleeHullRatio,
policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
policy.Notes,
policy.UpdatedAtUtc)).ToList(),
player.AutomationPolicies.Select(policy => new PlayerAutomationPolicySnapshot(
policy.Id,
policy.Label,
policy.ScopeKind,
policy.ScopeId,
policy.Enabled,
policy.BehaviorKind,
policy.UseOrders,
policy.StagingOrderKind,
policy.MaxSystemRange,
policy.KnownStationsOnly,
policy.Radius,
policy.WaitSeconds,
policy.PreferredItemId,
policy.Notes,
policy.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(),
policy.UpdatedAtUtc)).ToList(),
player.ReinforcementPolicies.Select(policy => new PlayerReinforcementPolicySnapshot(
policy.Id,
policy.Label,
policy.ScopeKind,
policy.ScopeId,
policy.ShipKind,
policy.DesiredAssetCount,
policy.MinimumReserveCount,
policy.AutoTransferReserves,
policy.AutoQueueProduction,
policy.SourceReserveId,
policy.TargetFrontId,
policy.Notes,
policy.UpdatedAtUtc)).ToList(),
player.ProductionPrograms.Select(program => new PlayerProductionProgramSnapshot(
program.Id,
program.Label,
program.Status,
program.Kind,
program.TargetShipKind,
program.TargetModuleId,
program.TargetItemId,
program.TargetCount,
program.CurrentCount,
program.StationGroupId,
program.ReinforcementPolicyId,
program.Notes,
program.UpdatedAtUtc)).ToList(),
player.Directives.Select(directive => new PlayerDirectiveSnapshot(
directive.Id,
directive.Label,
directive.Status,
directive.Kind,
directive.ScopeKind,
directive.ScopeId,
directive.TargetEntityId,
directive.TargetSystemId,
directive.TargetPosition is null ? null : ToDto(directive.TargetPosition.Value),
directive.HomeSystemId,
directive.HomeStationId,
directive.SourceStationId,
directive.DestinationStationId,
directive.BehaviorKind,
directive.UseOrders,
directive.StagingOrderKind,
directive.ItemId,
directive.PreferredNodeId,
directive.PreferredConstructionSiteId,
directive.PreferredModuleId,
directive.Priority,
directive.Radius,
directive.WaitSeconds,
directive.MaxSystemRange,
directive.KnownStationsOnly,
directive.PatrolPoints.Select(ToDto).ToList(),
directive.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(),
directive.PolicyId,
directive.AutomationPolicyId,
directive.Notes,
directive.CreatedAtUtc,
directive.UpdatedAtUtc)).ToList(),
player.Assignments.Select(assignment => new PlayerAssignmentSnapshot(
assignment.Id,
assignment.AssetKind,
assignment.AssetId,
assignment.FleetId,
assignment.TaskForceId,
assignment.StationGroupId,
assignment.EconomicRegionId,
assignment.FrontId,
assignment.ReserveId,
assignment.DirectiveId,
assignment.PolicyId,
assignment.AutomationPolicyId,
assignment.Role,
assignment.Status,
assignment.UpdatedAtUtc)).ToList(),
player.DecisionLog.Select(entry => new PlayerDecisionLogEntrySnapshot(
entry.Id,
entry.Kind,
entry.Summary,
entry.RelatedEntityKind,
entry.RelatedEntityId,
entry.OccurredAtUtc)).ToList(),
player.Alerts.Select(alert => new PlayerAlertSnapshot(
alert.Id,
alert.Kind,
alert.Severity,
alert.Summary,
alert.AssetKind,
alert.AssetId,
alert.RelatedDirectiveId,
alert.Status,
alert.CreatedAtUtc)).ToList());
}
private static ShipOrderTemplateSnapshot ToShipOrderTemplateSnapshot(ShipOrderTemplateRuntime template) =>
new(
template.Kind,
template.Label,
template.TargetEntityId,
template.TargetSystemId,
template.TargetPosition is null ? null : ToDto(template.TargetPosition.Value),
template.SourceStationId,
template.DestinationStationId,
template.ItemId,
template.NodeId,
template.ConstructionSiteId,
template.ModuleId,
template.WaitSeconds,
template.Radius,
template.MaxSystemRange,
template.KnownStationsOnly);
private static Vector3Dto ToDto(Vector3 vector) => new(vector.X, vector.Y, vector.Z);
}

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,58 +9,61 @@ internal sealed class PlayerFactionService
private const int MaxAlerts = 32;
private const string PlayerFactionDomainId = "player-faction";
internal static bool IsPlayerFaction(SimulationWorld world, string factionId) =>
world.PlayerFaction is not null && string.Equals(world.PlayerFaction.SovereignFactionId, factionId, StringComparison.Ordinal);
internal static bool IsPlayerFaction(IPlayerStateStore playerStateStore, string factionId) =>
playerStateStore.GetPlayerFactions().Any(player =>
string.Equals(player.SovereignFactionId, factionId, StringComparison.Ordinal));
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world)
internal PlayerFactionRuntime? TryGetDomain(IPlayerStateStore playerStateStore, string playerId)
{
if (world.PlayerFaction is not null)
{
return world.PlayerFaction;
}
return playerStateStore.TryGetPlayerFaction(playerId, out var player) ? player : null;
}
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
{
var sovereignFaction = world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).FirstOrDefault()
?? throw new InvalidOperationException("Cannot create a player faction domain without any factions in the world.");
world.PlayerFaction = new PlayerFactionRuntime
var player = playerStateStore.GetOrAddPlayerFaction(playerId, () => new PlayerFactionRuntime
{
Id = PlayerFactionDomainId,
Label = $"{sovereignFaction.Label} Command",
SovereignFactionId = sovereignFaction.Id,
CreatedAtUtc = world.GeneratedAtUtc,
UpdatedAtUtc = world.GeneratedAtUtc,
};
});
EnsureBaseStructures(world, world.PlayerFaction);
SyncRegistry(world, world.PlayerFaction);
return world.PlayerFaction;
EnsureBaseStructures(world, player);
SyncRegistry(world, player);
return player;
}
internal void Update(SimulationWorld world, float _deltaSeconds, ICollection<SimulationEventRecord> events)
internal void Update(SimulationWorld world, IPlayerStateStore playerStateStore, float _deltaSeconds, ICollection<SimulationEventRecord> events)
{
if (world.PlayerFaction is null && world.Factions.Count == 0)
if (playerStateStore.GetPlayerFactions().Count == 0)
{
return;
}
var player = EnsureDomain(world);
EnsureBaseStructures(world, player);
SyncRegistry(world, player);
PrunePlayerState(world, player);
RefreshGeopoliticalOrganizationContext(world, player);
ReconcileOrganizationAssignments(world, player);
ReconcileDirectiveScopes(player);
RefreshProductionPrograms(world, player);
ApplyStrategicIntegration(world, player);
ApplyPolicies(world, player);
ApplyAssignmentsAndDirectives(world, player, events);
RefreshAlerts(world, player);
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
foreach (var player in playerStateStore.GetPlayerFactions())
{
EnsureBaseStructures(world, player);
SyncRegistry(world, player);
PrunePlayerState(world, player);
RefreshGeopoliticalOrganizationContext(world, player);
ReconcileOrganizationAssignments(world, player);
ReconcileDirectiveScopes(player);
RefreshProductionPrograms(world, player);
ApplyStrategicIntegration(world, player);
ApplyPolicies(world, player);
ApplyAssignmentsAndDirectives(world, player, events);
RefreshAlerts(world, player);
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
}
internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, PlayerOrganizationCommandRequest request)
internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerOrganizationCommandRequest request)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player));
var nowUtc = DateTimeOffset.UtcNow;
@@ -172,9 +178,9 @@ internal sealed class PlayerFactionService
return player;
}
internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, string organizationId)
internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
RemoveOrganization(player, organizationId);
player.Assignments.RemoveAll(assignment =>
assignment.FleetId == organizationId ||
@@ -190,9 +196,9 @@ internal sealed class PlayerFactionService
return player;
}
internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, string organizationId, PlayerOrganizationMembershipCommandRequest request)
internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId, PlayerOrganizationMembershipCommandRequest request)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
var kind = ResolveOrganizationKind(player, organizationId);
switch (kind)
{
@@ -241,9 +247,9 @@ internal sealed class PlayerFactionService
return player;
}
internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, string? directiveId, PlayerDirectiveCommandRequest request)
internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? directiveId, PlayerDirectiveCommandRequest request)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
var directive = directiveId is null
? null
: player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal));
@@ -318,9 +324,9 @@ internal sealed class PlayerFactionService
return player;
}
internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, string directiveId)
internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string directiveId)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
player.Directives.RemoveAll(directive => directive.Id == directiveId);
foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId))
{
@@ -332,9 +338,9 @@ internal sealed class PlayerFactionService
return player;
}
internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, string? policyId, PlayerPolicyCommandRequest request)
internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? policyId, PlayerPolicyCommandRequest request)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
var policy = policyId is null
? null
: player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal));
@@ -403,9 +409,9 @@ internal sealed class PlayerFactionService
return player;
}
internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
var policy = automationPolicyId is null
? null
: player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal));
@@ -461,9 +467,9 @@ internal sealed class PlayerFactionService
return player;
}
internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
var policy = reinforcementPolicyId is null
? null
: player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal));
@@ -495,9 +501,9 @@ internal sealed class PlayerFactionService
return player;
}
internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, string? productionProgramId, PlayerProductionProgramCommandRequest request)
internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? productionProgramId, PlayerProductionProgramCommandRequest request)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
var program = productionProgramId is null
? null
: player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal));
@@ -527,9 +533,9 @@ internal sealed class PlayerFactionService
return player;
}
internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, string assetId, PlayerAssetAssignmentCommandRequest request)
internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string assetId, PlayerAssetAssignmentCommandRequest request)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
var assignment = player.Assignments.FirstOrDefault(candidate =>
string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) &&
string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal));
@@ -586,9 +592,9 @@ internal sealed class PlayerFactionService
return player;
}
internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, PlayerStrategicIntentCommandRequest request)
internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerStrategicIntentCommandRequest request)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
player.StrategicIntent.StrategicPosture = request.StrategicPosture;
player.StrategicIntent.EconomicPosture = request.EconomicPosture;
player.StrategicIntent.MilitaryPosture = request.MilitaryPosture;
@@ -602,9 +608,9 @@ internal sealed class PlayerFactionService
return player;
}
internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, string shipId, ShipOrderCommandRequest request)
internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipOrderCommandRequest request)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
@@ -625,6 +631,8 @@ internal sealed class PlayerFactionService
{
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
Kind = request.Kind,
SourceKind = ShipOrderSourceKind.Player,
SourceId = playerId,
Priority = request.Priority,
InterruptCurrentPlan = request.InterruptCurrentPlan,
Label = request.Label,
@@ -643,11 +651,11 @@ internal sealed class PlayerFactionService
KnownStationsOnly = request.KnownStationsOnly ?? false,
});
AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Label}.", "ship", shipId);
AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Name}.", "ship", shipId);
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
ship.ControlSourceKind = "player-order";
ship.ControlSourceId = ship.OrderQueue
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
@@ -659,9 +667,9 @@ internal sealed class PlayerFactionService
return ship;
}
internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, string shipId, string orderId)
internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
@@ -676,21 +684,21 @@ internal sealed class PlayerFactionService
var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId);
if (removed > 0)
{
AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Label}.", "ship", shipId);
AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Name}.", "ship", shipId);
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
ship.ControlSourceKind = ship.OrderQueue.Any(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
? "player-order"
: "player-manual";
ship.ControlSourceId = ship.OrderQueue
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
ship.ControlReason = ship.OrderQueue
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Label ?? order.Kind)
@@ -702,9 +710,9 @@ internal sealed class PlayerFactionService
return ship;
}
internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, string shipId, ShipDefaultBehaviorCommandRequest request)
internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipDefaultBehaviorCommandRequest request)
{
var player = EnsureDomain(world);
var player = EnsureDomain(world, playerStateStore, playerId);
if (!player.AssetRegistry.ShipIds.Contains(shipId))
{
return null;
@@ -723,7 +731,7 @@ internal sealed class PlayerFactionService
directive = new PlayerDirectiveRuntime
{
Id = directiveId,
Label = $"Direct control {ship.Definition.Label}",
Label = $"Direct control {ship.Definition.Name}",
ScopeKind = "ship",
ScopeId = shipId,
Kind = "direct-control",
@@ -732,7 +740,7 @@ internal sealed class PlayerFactionService
player.Directives.Add(directive);
}
directive.Label = $"Direct control {ship.Definition.Label}";
directive.Label = $"Direct control {ship.Definition.Name}";
directive.Kind = "direct-control";
directive.ScopeKind = "ship";
directive.ScopeId = shipId;
@@ -746,7 +754,7 @@ internal sealed class PlayerFactionService
directive.HomeStationId = request.HomeStationId;
directive.SourceStationId = request.HomeStationId;
directive.DestinationStationId = null;
directive.ItemId = request.PreferredItemId;
directive.ItemId = request.ItemId;
directive.PreferredNodeId = request.PreferredNodeId;
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
directive.PreferredModuleId = request.PreferredModuleId;
@@ -793,7 +801,7 @@ internal sealed class PlayerFactionService
ship.ControlSourceKind = "player-directive";
ship.ControlSourceId = directive.Id;
ship.ControlReason = directive.Label;
AddDecision(player, "ship-behavior-configured", $"Configured {request.Kind} for {ship.Definition.Label}.", "ship", shipId);
AddDecision(player, "ship-behavior-configured", $"Configured {request.Kind} for {ship.Definition.Name}.", "ship", shipId);
player.UpdatedAtUtc = directive.UpdatedAtUtc;
ship.NeedsReplan = true;
ship.LastReplanReason = "player-behavior-configured";
@@ -826,7 +834,7 @@ internal sealed class PlayerFactionService
{
Id = "player-core-automation",
Label = "Core Automation",
BehaviorKind = "idle",
BehaviorKind = Idle,
});
}
@@ -1035,7 +1043,7 @@ internal sealed class PlayerFactionService
var changed = ApplyDirectiveToShip(commander, ship, directive, automation, assignment);
if (changed && directive is not null)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "player-directive", $"{ship.Definition.Label} aligned to player directive {directive.Label}.", DateTimeOffset.UtcNow, "player", "universe", ship.Id));
events.Add(new SimulationEventRecord("ship", ship.Id, "player-directive", $"{ship.Definition.Name} aligned to player directive {directive.Label}.", DateTimeOffset.UtcNow, "player", "universe", ship.Id));
}
}
@@ -1246,13 +1254,13 @@ internal sealed class PlayerFactionService
? "player-directive"
: automation is not null
? "player-automation"
: ship.OrderQueue.Any(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
: ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
? "player-order"
: "player-manual";
var desiredControlSourceId = directive?.Id
?? automation?.Id
?? ship.OrderQueue
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
@@ -1260,7 +1268,7 @@ internal sealed class PlayerFactionService
var desiredControlReason = directive?.Label
?? automation?.Label
?? ship.OrderQueue
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Label ?? order.Kind)
@@ -1342,7 +1350,7 @@ internal sealed class PlayerFactionService
HomeStationId = directive?.HomeStationId ?? ship.DefaultBehavior.HomeStationId,
AreaSystemId = directive?.TargetSystemId ?? directive?.HomeSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId,
TargetEntityId = directive?.TargetEntityId,
PreferredItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.PreferredItemId,
ItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.ItemId,
PreferredNodeId = directive?.PreferredNodeId ?? ship.DefaultBehavior.PreferredNodeId,
PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId,
PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId,
@@ -1375,6 +1383,8 @@ internal sealed class PlayerFactionService
{
Id = aiOrderId!,
Kind = directive.StagingOrderKind!,
SourceKind = ShipOrderSourceKind.Player,
SourceId = directive.Id,
Priority = Math.Max(0, directive.Priority),
InterruptCurrentPlan = true,
Label = directive.Label,
@@ -1447,7 +1457,7 @@ internal sealed class PlayerFactionService
target.HomeStationId = source.HomeStationId;
target.AreaSystemId = source.AreaSystemId;
target.TargetEntityId = source.TargetEntityId;
target.PreferredItemId = source.PreferredItemId;
target.ItemId = source.ItemId;
target.PreferredNodeId = source.PreferredNodeId;
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
target.PreferredModuleId = source.PreferredModuleId;
@@ -1468,7 +1478,7 @@ internal sealed class PlayerFactionService
&& string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal)
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
&& string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal)
&& string.Equals(left.PreferredItemId, right.PreferredItemId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
&& string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal)
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
@@ -1501,6 +1511,8 @@ internal sealed class PlayerFactionService
private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) =>
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
&& string.Equals(left.Kind, right.Kind, StringComparison.Ordinal)
&& left.SourceKind == right.SourceKind
&& string.Equals(left.SourceId, right.SourceId, StringComparison.Ordinal)
&& left.Priority == right.Priority
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
@@ -1716,7 +1728,7 @@ internal sealed class PlayerFactionService
{
program.CurrentCount = world.Ships.Count(ship =>
ship.FactionId == player.SovereignFactionId &&
string.Equals(ship.Definition.Kind, program.TargetShipKind, StringComparison.Ordinal));
string.Equals(GetShipCategory(ship.Definition), program.TargetShipKind, StringComparison.Ordinal));
}
else
{
@@ -2113,7 +2125,7 @@ internal sealed class PlayerFactionService
{
var available = world.Ships.Count(ship =>
ship.FactionId == player.SovereignFactionId &&
string.Equals(ship.Definition.Kind, policy.ShipKind, StringComparison.Ordinal));
string.Equals(GetShipCategory(ship.Definition), policy.ShipKind, StringComparison.Ordinal));
if (available < policy.DesiredAssetCount)
{
player.Alerts.Add(new PlayerAlertRuntime

View File

@@ -0,0 +1,26 @@
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 void Clear() => _playerFactions.Clear();
}

View File

@@ -1,11 +1,14 @@
using System.Text;
using FastEndpoints;
using FastEndpoints.Swagger;
using SpaceGame.Api.Universe.Scenario;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Npgsql;
using SpaceGame.Api.Universe.Bootstrap;
using SpaceGame.Api.Universe.Simulation;
const string StartupScenarioPath = "scenarios/empty.json";
var builder = WebApplication.CreateBuilder(args);
const string StartupScenarioPath = "scenarios/empty.json";
builder.Services.AddCors((options) =>
{
@@ -46,10 +49,67 @@ builder.Services
})
.ValidateOnStart();
builder.Services.Configure<BalanceOptions>(builder.Configuration.GetSection("Balance"));
builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration"));
builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
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>();
@@ -68,9 +128,16 @@ builder.Services.AddFastEndpoints();
builder.Services.SwaggerDocument();
var app = builder.Build();
app.Services.GetRequiredService<WorldService>().LoadFromScenario(StartupScenarioPath);
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,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,121 @@
namespace SpaceGame.Api.Shared.Runtime;
public enum ShipAutomationSupportStatus
{
Supported,
PartiallySupported,
NotSupported,
InternalOnly,
}
public sealed record ShipBehaviorDefinition(
string Id,
string Label,
string Category,
ShipAutomationSupportStatus SupportStatus,
string Notes);
public sealed record ShipOrderDefinition(
string Id,
string Label,
string Category,
ShipAutomationSupportStatus SupportStatus,
string Notes);
public static class ShipBehaviorKinds
{
public const string Patrol = "patrol";
public const string Police = "police";
public const string ProtectPosition = "protect-position";
public const string ProtectShip = "protect-ship";
public const string ProtectStation = "protect-station";
public const string LocalAutoMine = "local-auto-mine";
public const string AdvancedAutoMine = "advanced-auto-mine";
public const string ExpertAutoMine = "expert-auto-mine";
public const string DockAndWait = "dock-and-wait";
public const string FlyAndWait = "fly-and-wait";
public const string FlyToObject = "fly-to-object";
public const string FollowShip = "follow-ship";
public const string HoldPosition = "hold-position";
public const string AutoSalvage = "auto-salvage";
public const string LocalAutoTrade = "local-auto-trade";
public const string AdvancedAutoTrade = "advanced-auto-trade";
public const string FillShortages = "fill-shortages";
public const string FindBuildTasks = "find-build-tasks";
public const string RevisitKnownStations = "revisit-known-stations";
public const string SupplyFleet = "supply-fleet";
public const string RepeatOrders = "repeat-orders";
public const string AttackTarget = "attack-target";
public const string ConstructStation = "construct-station";
public const string Idle = "idle";
}
public static class ShipAutomationCatalog
{
public static readonly IReadOnlyList<ShipBehaviorDefinition> Behaviors =
[
new(ShipBehaviorKinds.Patrol, "Patrol", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait orders from the active patrol context."),
new(ShipBehaviorKinds.Police, "Police", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or follow-ship inspection orders from the active policing context."),
new(ShipBehaviorKinds.ProtectPosition, "Protect Position", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait orders from the defended position context."),
new(ShipBehaviorKinds.ProtectShip, "Protect Ship", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or follow-ship escort orders from the guarded ship context."),
new(ShipBehaviorKinds.ProtectStation, "Protect Station", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait guard orders from the defended station context."),
new(ShipBehaviorKinds.LocalAutoMine, "Local AutoMine", "Mining", ShipAutomationSupportStatus.PartiallySupported, "Queue-backed for solo mining; broader order-generation model still in progress."),
new(ShipBehaviorKinds.AdvancedAutoMine, "Advanced AutoMine", "Mining", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal mine-and-deliver run order from the current mining opportunity."),
new(ShipBehaviorKinds.ExpertAutoMine, "Expert AutoMine", "Mining", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal mine-and-deliver run order from the current mining opportunity."),
new(ShipBehaviorKinds.DockAndWait, "Dock And Wait", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
new(ShipBehaviorKinds.FlyAndWait, "Fly And Wait", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
new(ShipBehaviorKinds.FlyToObject, "Fly To Object", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
new(ShipBehaviorKinds.FollowShip, "Follow Ship", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
new(ShipBehaviorKinds.HoldPosition, "Hold Position", "Navigation", ShipAutomationSupportStatus.Supported, "Default baseline behavior; queue-backed behavior order is active."),
new(ShipBehaviorKinds.AutoSalvage, "AutoSalvage", "Salvage", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal salvage run order for wreck recovery."),
new(ShipBehaviorKinds.LocalAutoTrade, "Local AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-and-wait orders from the current market context."),
new(ShipBehaviorKinds.AdvancedAutoTrade, "Advanced AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route orders from the current market context."),
new(ShipBehaviorKinds.FillShortages, "Fill Shortages", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route orders from the current market context."),
new(ShipBehaviorKinds.FindBuildTasks, "Find Build Tasks", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing construction-support trade routes from the current market context."),
new(ShipBehaviorKinds.RevisitKnownStations, "Revisit Known Stations", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-and-wait orders from known-station context."),
new(ShipBehaviorKinds.SupplyFleet, "Supply Fleet", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal fleet supply run order."),
new(ShipBehaviorKinds.RepeatOrders, "Repeat Orders", "Advanced", ShipAutomationSupportStatus.Supported, "Queue-backed behavior generating the current repeat-order template at the bottom of the stack."),
new(ShipBehaviorKinds.AttackTarget, "Attack Target", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal gameplay behavior used by current combat/control systems, not an X4 exposed default behavior."),
new(ShipBehaviorKinds.ConstructStation, "Construct Station", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal gameplay behavior used by construction ships."),
new(ShipBehaviorKinds.Idle, "Idle", "Internal", ShipAutomationSupportStatus.InternalOnly, "Legacy fallback/internal placeholder; not intended as an exposed player behavior."),
];
public static readonly IReadOnlyList<ShipOrderDefinition> Orders =
[
new(ShipOrderKinds.DockAndWait, "Dock And Wait", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
new(ShipOrderKinds.FlyAndWait, "Fly To And Wait", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
new(ShipOrderKinds.FlyToObject, "Fly To Object", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
new(ShipOrderKinds.FollowShip, "Follow Ship", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
new(ShipOrderKinds.HoldPosition, "Hold Position", "Navigation", ShipAutomationSupportStatus.Supported, "Direct order supported in backend."),
new(ShipOrderKinds.Move, "Move", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Low-level direct movement order; viewer may present richer labels such as Fly To And Wait instead."),
new(ShipOrderKinds.AttackTarget, "Attack Target", "Combat", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
new(ShipOrderKinds.MineAndDeliver, "Mine Resource", "Mining", ShipAutomationSupportStatus.Supported, "Direct order mines the requested ware in the requested system until cargo is full."),
new(ShipOrderKinds.TradeRoute, "Trade Route", "Trade", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
new(ShipOrderKinds.BuildAtSite, "Build At Site", "Construction", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
new(ShipOrderKinds.RepeatOrders, "Repeat Orders", "Advanced", ShipAutomationSupportStatus.PartiallySupported, "Represented today as a behavior plus templates, not a normal one-shot direct order."),
new(ShipOrderKinds.MineLocal, "Mine Local", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for Local AutoMine."),
new(ShipOrderKinds.MineAndDeliverRun, "Mine And Deliver Run", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for Advanced/Expert AutoMine."),
new(ShipOrderKinds.SellMinedCargo, "Sell Mined Cargo", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for Local AutoMine."),
new(ShipOrderKinds.SupplyFleetRun, "Supply Fleet Run", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for Supply Fleet."),
new(ShipOrderKinds.SalvageRun, "Salvage Run", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for AutoSalvage."),
new(ShipOrderKinds.Flee, "Flee", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal emergency order."),
];
}

View File

@@ -28,6 +28,13 @@ public enum OrderStatus
Interrupted,
}
public enum ShipOrderSourceKind
{
Player,
Behavior,
Commander,
}
public enum AiPlanStatus
{
Planned,
@@ -166,6 +173,11 @@ public static class ShipOrderKinds
public const string BuildAtSite = "build-at-site";
public const string AttackTarget = "attack-target";
public const string HoldPosition = "hold-position";
public const string MineLocal = "mine-local";
public const string MineAndDeliverRun = "mine-and-deliver-run";
public const string SellMinedCargo = "sell-mined-cargo";
public const string SupplyFleetRun = "supply-fleet-run";
public const string SalvageRun = "salvage-run";
public const string RepeatOrders = "repeat-orders";
public const string Flee = "flee";
}
@@ -329,6 +341,14 @@ public static class SimulationEnumMappings
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
};
public static string ToContractValue(this ShipOrderSourceKind kind) => kind switch
{
ShipOrderSourceKind.Player => "player",
ShipOrderSourceKind.Behavior => "behavior",
ShipOrderSourceKind.Commander => "commander",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
};
public static string ToContractValue(this ShipState state) => state switch
{
ShipState.Idle => "idle",

View File

@@ -3,8 +3,56 @@ namespace SpaceGame.Api.Shared.Runtime;
internal static class SimulationRuntimeSupport
{
internal static bool HasShipCapabilities(ShipDefinition definition, params string[] capabilities) =>
capabilities.All(cap => definition.Capabilities.Contains(cap, StringComparer.Ordinal));
internal static bool CanWarp(ShipDefinition definition) =>
definition.Engines.Count > 0;
internal static bool CanFtl(ShipDefinition definition) =>
definition.Engines.Count > 0;
internal static bool IsMiningShip(ShipDefinition definition) =>
definition.Type is ShipType.Miner or ShipType.LargeMiner;
internal static bool IsTransportShip(ShipDefinition definition) =>
definition.Type is ShipType.Freighter or ShipType.Transporter or ShipType.Courier or ShipType.Resupplier;
internal static bool IsConstructionShip(ShipDefinition definition) =>
definition.Type == ShipType.Builder;
internal static bool IsMilitaryShip(ShipDefinition definition) =>
definition.Type is ShipType.Fighter
or ShipType.HeavyFighter
or ShipType.Destroyer
or ShipType.Bomber
or ShipType.Frigate
or ShipType.Interceptor
or ShipType.Corvette
or ShipType.Battleship
or ShipType.Gunboat;
internal static string? GetShipCategory(ShipDefinition definition)
{
if (IsMilitaryShip(definition))
{
return "military";
}
if (IsConstructionShip(definition))
{
return "construction";
}
if (IsTransportShip(definition))
{
return "transport";
}
if (IsMiningShip(definition))
{
return "mining";
}
return null;
}
internal static int CountStationModules(StationRuntime station, ModuleType moduleType) =>
station.Modules.Count(module => module.ModuleType == moduleType);
@@ -131,13 +179,13 @@ internal static class SimulationRuntimeSupport
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
internal static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node, SimulationWorld world) =>
HasShipCapabilities(ship.Definition, "mining")
IsMiningShip(ship.Definition)
&& world.ItemDefinitions.TryGetValue(node.ItemId, out var item)
&& item.CargoKind is not null
&& item.CargoKind == ship.Definition.CargoKind;
&& ship.Definition.SupportsCargoKind(item.CargoKind.Value);
internal static bool CanBuildClaimBeacon(ShipRuntime ship) =>
string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal);
IsMilitaryShip(ship.Definition);
internal static float ComputeWorkforceRatio(float population, float workforceRequired)
{

View File

@@ -0,0 +1,784 @@
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
namespace SpaceGame.Api.Ships.AI;
public sealed partial class ShipAiService
{
private static ShipOrderRuntime? GetTopOrder(ShipRuntime ship) =>
ship.OrderQueue
.Where(order => order.Status is OrderStatus.Queued or OrderStatus.Active)
.OrderByDescending(GetOrderSourcePriority)
.ThenByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.FirstOrDefault();
private static int GetOrderSourcePriority(ShipOrderRuntime order) => order.SourceKind switch
{
ShipOrderSourceKind.Player => 300,
ShipOrderSourceKind.Commander => 200,
ShipOrderSourceKind.Behavior => 100,
_ => 0,
};
private void SyncBehaviorOrders(SimulationWorld world, ShipRuntime ship)
{
var desiredOrder = BuildManagedBehaviorOrder(world, ship);
ship.OrderQueue.RemoveAll(order =>
order.SourceKind == ShipOrderSourceKind.Behavior
&& (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)));
if (desiredOrder is null)
{
return;
}
var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal));
if (existing is null)
{
ship.OrderQueue.Add(desiredOrder);
return;
}
if (ManagedOrdersEqual(existing, desiredOrder))
{
return;
}
ship.OrderQueue.Remove(existing);
ship.OrderQueue.Add(desiredOrder);
}
private ShipOrderRuntime? BuildManagedBehaviorOrder(SimulationWorld world, ShipRuntime ship)
{
var assignment = ResolveAssignment(world, ship);
var behaviorKind = assignment?.BehaviorKind ?? ship.DefaultBehavior.Kind;
var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
if (string.Equals(behaviorKind, HoldPosition, StringComparison.Ordinal))
{
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-hold-position",
Kind = ShipOrderKinds.HoldPosition,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = "Hold position",
TargetSystemId = systemId,
TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position,
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
Radius = ship.DefaultBehavior.Radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}
if (string.Equals(behaviorKind, DockAndWait, StringComparison.Ordinal))
{
var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? ship.DefaultBehavior.HomeStationId);
if (station is null)
{
ship.LastAccessFailureReason = "station-missing";
return null;
}
ship.LastAccessFailureReason = null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-dock-and-wait",
Kind = ShipOrderKinds.DockAndWait,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = $"Dock and wait at {station.Label}",
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
DestinationStationId = station.Id,
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
Radius = ship.DefaultBehavior.Radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}
if (string.Equals(behaviorKind, FlyAndWait, StringComparison.Ordinal))
{
ship.LastAccessFailureReason = null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-fly-and-wait",
Kind = ShipOrderKinds.FlyAndWait,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = "Fly and wait",
TargetSystemId = systemId,
TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position,
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
Radius = ship.DefaultBehavior.Radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}
if (string.Equals(behaviorKind, FollowShip, StringComparison.Ordinal))
{
var targetShip = world.Ships.FirstOrDefault(candidate =>
candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId)
&& candidate.Health > 0f);
if (targetShip is null)
{
ship.LastAccessFailureReason = "target-ship-missing";
return null;
}
ship.LastAccessFailureReason = null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-follow-ship",
Kind = ShipOrderKinds.FollowShip,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = $"Follow {targetShip.Definition.Name}",
TargetEntityId = targetShip.Id,
TargetSystemId = targetShip.SystemId,
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
Radius = MathF.Max(16f, ship.DefaultBehavior.Radius),
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}
if (string.Equals(behaviorKind, FlyToObject, StringComparison.Ordinal))
{
var targetEntityId = assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId;
var target = ResolveObjectTarget(world, targetEntityId);
if (target is null)
{
ship.LastAccessFailureReason = "target-missing";
return null;
}
ship.LastAccessFailureReason = null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-fly-to-object",
Kind = ShipOrderKinds.FlyToObject,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = "Fly to object",
TargetEntityId = targetEntityId,
TargetSystemId = target.Value.SystemId,
TargetPosition = target.Value.Position,
WaitSeconds = MathF.Max(1f, ship.DefaultBehavior.WaitSeconds),
Radius = MathF.Max(8f, ship.DefaultBehavior.Radius),
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}
if (string.Equals(behaviorKind, Patrol, StringComparison.Ordinal))
{
return BuildManagedPatrolOrder(world, ship, assignment, behaviorKind);
}
if (string.Equals(behaviorKind, AttackTarget, StringComparison.Ordinal))
{
var targetEntityId = assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId;
if (string.IsNullOrWhiteSpace(targetEntityId))
{
return BuildManagedPatrolOrder(world, ship, assignment, behaviorKind);
}
var target = ResolveObjectTarget(world, targetEntityId);
ship.LastAccessFailureReason = target is null ? "target-missing" : null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-attack-target",
Kind = ShipOrderKinds.AttackTarget,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = "Attack target",
TargetEntityId = targetEntityId,
TargetSystemId = target?.SystemId ?? assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId,
TargetPosition = target?.Position ?? ship.Position,
WaitSeconds = 0f,
Radius = 26f,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}
if (string.Equals(behaviorKind, ConstructStation, StringComparison.Ordinal))
{
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.PreferredConstructionSiteId))
?? world.ConstructionSites
.Where(candidate => candidate.FactionId == ship.FactionId && candidate.State is ConstructionSiteStateKinds.Active or ConstructionSiteStateKinds.Planned)
.OrderBy(candidate => candidate.Id, StringComparer.Ordinal)
.FirstOrDefault();
if (site is null)
{
ship.LastAccessFailureReason = "no-construction-site";
return null;
}
if (ResolveSupportStation(world, ship, site) is null)
{
ship.LastAccessFailureReason = "support-station-missing";
return null;
}
ship.LastAccessFailureReason = null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-construct-station",
Kind = ShipOrderKinds.BuildAtSite,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = $"Build {site.BlueprintId}",
TargetEntityId = site.Id,
TargetSystemId = site.SystemId,
ConstructionSiteId = site.Id,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}
if (string.Equals(behaviorKind, AdvancedAutoMine, StringComparison.Ordinal)
|| string.Equals(behaviorKind, ExpertAutoMine, StringComparison.Ordinal))
{
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
if (homeStation is null)
{
ship.LastAccessFailureReason = "no-home-station";
return null;
}
var opportunity = SelectMiningOpportunity(world, ship, homeStation, assignment, behaviorKind);
if (opportunity is null)
{
ship.LastAccessFailureReason = "no-mineable-node";
return null;
}
ship.LastAccessFailureReason = null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-{behaviorKind}-mine-and-deliver",
Kind = ShipOrderKinds.MineAndDeliverRun,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = opportunity.Summary,
TargetEntityId = opportunity.Node.Id,
TargetSystemId = opportunity.Node.SystemId,
DestinationStationId = opportunity.DropOffStation.Id,
ItemId = opportunity.Node.ItemId,
NodeId = opportunity.Node.Id,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}
if (string.Equals(behaviorKind, ProtectPosition, StringComparison.Ordinal))
{
var targetSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
var targetPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position;
var threat = SelectThreatTarget(world, ship, targetSystemId, targetPosition, MathF.Max(90f, ship.DefaultBehavior.Radius));
if (threat is not null)
{
ship.LastAccessFailureReason = null;
return CreateManagedAttackOrder(ship, behaviorKind, "Protect position", threat.EntityId, threat.SystemId, threat.Position);
}
ship.LastAccessFailureReason = null;
return CreateManagedFlyAndWaitOrder(
ship,
behaviorKind,
"Protect position",
targetSystemId,
targetPosition,
MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
MathF.Max(6f, ship.DefaultBehavior.Radius));
}
if (string.Equals(behaviorKind, ProtectShip, StringComparison.Ordinal))
{
var guardTarget = world.Ships.FirstOrDefault(candidate =>
candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId)
&& candidate.Health > 0f);
if (guardTarget is null)
{
return BuildManagedPatrolOrder(world, ship, assignment, Patrol);
}
var threat = SelectThreatTarget(
world,
ship,
guardTarget.SystemId,
guardTarget.Position,
MathF.Max(90f, ship.DefaultBehavior.Radius),
excludeEntityId: guardTarget.Id);
if (threat is not null)
{
ship.LastAccessFailureReason = null;
return CreateManagedAttackOrder(ship, behaviorKind, $"Protect {guardTarget.Definition.Name}", threat.EntityId, threat.SystemId, threat.Position);
}
ship.LastAccessFailureReason = null;
return CreateManagedFollowShipOrder(
ship,
behaviorKind,
$"Escort {guardTarget.Definition.Name}",
guardTarget,
MathF.Max(18f, ship.DefaultBehavior.Radius * 0.5f),
MathF.Max(2f, ship.DefaultBehavior.WaitSeconds));
}
if (string.Equals(behaviorKind, ProtectStation, StringComparison.Ordinal))
{
var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
if (station is null)
{
return BuildManagedPatrolOrder(world, ship, assignment, Patrol);
}
var threat = SelectThreatTarget(world, ship, station.SystemId, station.Position, MathF.Max(station.Radius + 80f, ship.DefaultBehavior.Radius));
if (threat is not null)
{
ship.LastAccessFailureReason = null;
return CreateManagedAttackOrder(ship, behaviorKind, $"Protect {station.Label}", threat.EntityId, threat.SystemId, threat.Position);
}
ship.LastAccessFailureReason = null;
return CreateManagedFlyAndWaitOrder(
ship,
behaviorKind,
$"Guard {station.Label}",
station.SystemId,
GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)),
MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
MathF.Max(6f, ship.DefaultBehavior.Radius));
}
if (string.Equals(behaviorKind, Police, StringComparison.Ordinal))
{
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
var policeSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? homeStation?.SystemId ?? ship.SystemId;
var areaPosition = homeStation?.Position ?? ship.DefaultBehavior.TargetPosition ?? ship.Position;
var contact = SelectPoliceContact(world, ship, policeSystemId, areaPosition, MathF.Max(80f, ship.DefaultBehavior.Radius));
if (contact is null)
{
return BuildManagedPatrolOrder(world, ship, assignment, Patrol);
}
ship.LastAccessFailureReason = null;
return contact.Engage
? CreateManagedAttackOrder(ship, behaviorKind, "Police engage", contact.EntityId, contact.SystemId, contact.Position)
: CreateManagedFollowTargetOrder(ship, behaviorKind, "Police inspect", contact.EntityId, contact.SystemId, contact.Position, MathF.Max(14f, ship.DefaultBehavior.Radius * 0.5f), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds));
}
if (string.Equals(behaviorKind, LocalAutoTrade, StringComparison.Ordinal)
|| string.Equals(behaviorKind, AdvancedAutoTrade, StringComparison.Ordinal)
|| string.Equals(behaviorKind, FillShortages, StringComparison.Ordinal)
|| string.Equals(behaviorKind, FindBuildTasks, StringComparison.Ordinal)
|| string.Equals(behaviorKind, RevisitKnownStations, StringComparison.Ordinal))
{
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
var route = SelectTradeRoute(world, ship, homeStation, behaviorKind, ship.DefaultBehavior.KnownStationsOnly);
if (route is not null)
{
ship.LastAccessFailureReason = null;
return CreateManagedTradeRouteOrder(ship, behaviorKind, route);
}
if (string.Equals(behaviorKind, RevisitKnownStations, StringComparison.Ordinal)
&& SelectKnownStationVisit(world, ship, homeStation) is { } visitStation)
{
ship.LastAccessFailureReason = null;
return CreateManagedDockAndWaitOrder(ship, behaviorKind, visitStation, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Revisit {visitStation.Label}");
}
ship.LastAccessFailureReason = "no-trade-route";
return null;
}
if (string.Equals(behaviorKind, SupplyFleet, StringComparison.Ordinal))
{
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
var plan = SelectFleetSupplyPlan(world, ship, homeStation);
if (plan is null)
{
ship.LastAccessFailureReason = "no-fleet-to-supply";
return null;
}
ship.LastAccessFailureReason = null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-supply-fleet",
Kind = ShipOrderKinds.SupplyFleetRun,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = plan.Summary,
TargetEntityId = plan.TargetShip.Id,
TargetSystemId = plan.TargetShip.SystemId,
SourceStationId = plan.SourceStation.Id,
ItemId = plan.ItemId,
Radius = plan.Radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}
if (string.Equals(behaviorKind, AutoSalvage, StringComparison.Ordinal))
{
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
if (homeStation is null)
{
ship.LastAccessFailureReason = "no-home-station";
return null;
}
var salvage = SelectSalvageOpportunity(world, ship, homeStation);
if (salvage is null)
{
ship.LastAccessFailureReason = "no-salvage-target";
return null;
}
ship.LastAccessFailureReason = null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-auto-salvage",
Kind = ShipOrderKinds.SalvageRun,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = salvage.Summary,
TargetEntityId = salvage.Wreck.Id,
TargetSystemId = salvage.Wreck.SystemId,
TargetPosition = salvage.Wreck.Position,
SourceStationId = homeStation.Id,
ItemId = salvage.Wreck.ItemId,
Radius = MathF.Max(8f, ship.DefaultBehavior.Radius * 0.25f),
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}
if (string.Equals(behaviorKind, RepeatOrders, StringComparison.Ordinal))
{
if (ship.DefaultBehavior.RepeatOrders.Count == 0)
{
ship.LastAccessFailureReason = "no-repeat-orders";
return null;
}
var template = ship.DefaultBehavior.RepeatOrders[ship.DefaultBehavior.RepeatIndex % ship.DefaultBehavior.RepeatOrders.Count];
ship.LastAccessFailureReason = null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-repeat-{ship.DefaultBehavior.RepeatIndex}",
Kind = template.Kind,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = template.Label,
TargetEntityId = template.TargetEntityId,
TargetSystemId = template.TargetSystemId,
TargetPosition = template.TargetPosition,
SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId,
NodeId = template.NodeId,
ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId,
WaitSeconds = template.WaitSeconds,
Radius = template.Radius,
MaxSystemRange = template.MaxSystemRange ?? ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = template.KnownStationsOnly,
};
}
if (!string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal))
{
return null;
}
var itemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId;
if (string.IsNullOrWhiteSpace(itemId))
{
ship.LastAccessFailureReason = "missing-item";
return null;
}
if (GetShipCargoAmount(ship) >= ship.Definition.GetTotalCargoCapacity() - 0.01f)
{
var buyer = SelectLocalAutoMineBuyer(world, ship, systemId, itemId);
if (buyer is null)
{
ship.LastAccessFailureReason = "no-suitable-buyer";
return null;
}
ship.LastAccessFailureReason = null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-local-auto-mine-sell",
Kind = ShipOrderKinds.SellMinedCargo,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = $"Sell {itemId} in {systemId}",
TargetEntityId = buyer.Id,
TargetSystemId = buyer.SystemId,
DestinationStationId = buyer.Id,
ItemId = itemId,
WaitSeconds = 0f,
Radius = 0f,
MaxSystemRange = 0,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}
var node = SelectLocalMiningNode(world, ship, systemId, itemId);
if (node is null)
{
ship.LastAccessFailureReason = "no-mineable-node";
return null;
}
ship.LastAccessFailureReason = null;
return new ShipOrderRuntime
{
Id = $"behavior-{ship.Id}-local-auto-mine-mine",
Kind = ShipOrderKinds.MineLocal,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = $"Mine {itemId} in {systemId}",
TargetSystemId = node.SystemId,
NodeId = node.Id,
ItemId = node.ItemId,
WaitSeconds = 0f,
Radius = 0f,
MaxSystemRange = 0,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}
private static bool ManagedOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) =>
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
&& string.Equals(left.Kind, right.Kind, StringComparison.Ordinal)
&& left.SourceKind == right.SourceKind
&& string.Equals(left.SourceId, right.SourceId, StringComparison.Ordinal)
&& left.Priority == right.Priority
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
&& string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal)
&& string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal)
&& left.TargetPosition == right.TargetPosition
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
&& string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal)
&& left.WaitSeconds.Equals(right.WaitSeconds)
&& left.Radius.Equals(right.Radius)
&& left.MaxSystemRange == right.MaxSystemRange
&& left.KnownStationsOnly == right.KnownStationsOnly;
private ShipOrderRuntime BuildManagedPatrolOrder(SimulationWorld world, ShipRuntime ship, CommanderAssignmentRuntime? assignment, string sourceKind)
{
var patrolSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
var protectPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position;
var patrolThreat = SelectThreatTarget(world, ship, patrolSystemId, protectPosition, MathF.Max(60f, ship.DefaultBehavior.Radius));
if (patrolThreat is not null)
{
ship.LastAccessFailureReason = null;
return CreateManagedAttackOrder(ship, sourceKind, "Patrol intercept", patrolThreat.EntityId, patrolThreat.SystemId, patrolThreat.Position, orderIdSuffix: "patrol-attack");
}
Vector3 targetPosition;
string targetSystemId;
if (ship.DefaultBehavior.PatrolPoints.Count > 0)
{
var index = ship.DefaultBehavior.PatrolIndex % ship.DefaultBehavior.PatrolPoints.Count;
targetPosition = ship.DefaultBehavior.PatrolPoints[index];
ship.DefaultBehavior.PatrolIndex = (index + 1) % ship.DefaultBehavior.PatrolPoints.Count;
targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
}
else if (ResolveStation(world, ship.DefaultBehavior.HomeStationId ?? assignment?.HomeStationId) is { } homeStation)
{
var patrolRadius = homeStation.Radius + 90f;
targetPosition = new Vector3(homeStation.Position.X + patrolRadius, homeStation.Position.Y, homeStation.Position.Z);
targetSystemId = homeStation.SystemId;
}
else
{
targetPosition = ship.Position;
targetSystemId = ship.SystemId;
}
ship.LastAccessFailureReason = null;
return CreateManagedFlyAndWaitOrder(ship, sourceKind, "Patrol waypoint", targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), MathF.Max(6f, ship.DefaultBehavior.Radius), orderIdSuffix: "patrol-fly-and-wait");
}
private static ShipOrderRuntime CreateManagedAttackOrder(
ShipRuntime ship,
string behaviorKind,
string label,
string targetEntityId,
string targetSystemId,
Vector3 targetPosition,
string? orderIdSuffix = null) =>
new()
{
Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}",
Kind = ShipOrderKinds.AttackTarget,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = label,
TargetEntityId = targetEntityId,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
WaitSeconds = 0f,
Radius = 26f,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
private static ShipOrderRuntime CreateManagedTradeRouteOrder(ShipRuntime ship, string behaviorKind, TradeRoutePlan route) =>
new()
{
Id = $"behavior-{ship.Id}-{behaviorKind}-trade-route",
Kind = ShipOrderKinds.TradeRoute,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = route.Summary,
SourceStationId = route.SourceStation.Id,
DestinationStationId = route.DestinationStation.Id,
ItemId = route.ItemId,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
private static ShipOrderRuntime CreateManagedDockAndWaitOrder(ShipRuntime ship, string behaviorKind, StationRuntime station, float waitSeconds, string label) =>
new()
{
Id = $"behavior-{ship.Id}-{behaviorKind}-dock-and-wait",
Kind = ShipOrderKinds.DockAndWait,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = label,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
DestinationStationId = station.Id,
WaitSeconds = waitSeconds,
Radius = ship.DefaultBehavior.Radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
private static ShipOrderRuntime CreateManagedFlyAndWaitOrder(
ShipRuntime ship,
string behaviorKind,
string label,
string targetSystemId,
Vector3 targetPosition,
float waitSeconds,
float radius,
string? orderIdSuffix = null) =>
new()
{
Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}",
Kind = ShipOrderKinds.FlyAndWait,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = label,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
WaitSeconds = waitSeconds,
Radius = radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
private static ShipOrderRuntime CreateManagedFollowShipOrder(
ShipRuntime ship,
string behaviorKind,
string label,
ShipRuntime targetShip,
float radius,
float waitSeconds) =>
new()
{
Id = $"behavior-{ship.Id}-{behaviorKind}",
Kind = ShipOrderKinds.FollowShip,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = label,
TargetEntityId = targetShip.Id,
TargetSystemId = targetShip.SystemId,
WaitSeconds = waitSeconds,
Radius = radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
private static ShipOrderRuntime CreateManagedFollowTargetOrder(
ShipRuntime ship,
string behaviorKind,
string label,
string targetEntityId,
string targetSystemId,
Vector3 targetPosition,
float radius,
float waitSeconds) =>
new()
{
Id = $"behavior-{ship.Id}-{behaviorKind}",
Kind = ShipOrderKinds.FollowShip,
SourceKind = ShipOrderSourceKind.Behavior,
SourceId = behaviorKind,
Priority = 0,
InterruptCurrentPlan = false,
Label = label,
TargetEntityId = targetEntityId,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
WaitSeconds = waitSeconds,
Radius = radius,
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
};
}

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,770 @@
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
namespace SpaceGame.Api.Ships.AI;
public sealed partial class ShipAiService
{
private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipPlanStepRuntime step, ShipSubTaskRuntime subTask, float deltaSeconds)
{
return subTask.Kind switch
{
var kind when string.Equals(kind, ShipTaskKinds.Travel, StringComparison.Ordinal) => UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: true),
var kind when string.Equals(kind, ShipTaskKinds.FollowTarget, StringComparison.Ordinal) => UpdateFollowSubTask(world, ship, subTask, deltaSeconds),
var kind when string.Equals(kind, ShipTaskKinds.Dock, StringComparison.Ordinal) => UpdateDockSubTask(world, ship, subTask, deltaSeconds),
var kind when string.Equals(kind, ShipTaskKinds.Undock, StringComparison.Ordinal) => UpdateUndockSubTask(world, ship, subTask, deltaSeconds),
var kind when string.Equals(kind, ShipTaskKinds.LoadCargo, StringComparison.Ordinal) => UpdateLoadCargoSubTask(world, ship, subTask, deltaSeconds),
var kind when string.Equals(kind, ShipTaskKinds.UnloadCargo, StringComparison.Ordinal) => UpdateUnloadCargoSubTask(world, ship, subTask, deltaSeconds),
var kind when string.Equals(kind, ShipTaskKinds.TransferCargoToShip, StringComparison.Ordinal) => UpdateTransferCargoToShipSubTask(world, ship, subTask, deltaSeconds),
var kind when string.Equals(kind, ShipTaskKinds.MineNode, StringComparison.Ordinal) => UpdateMineSubTask(world, ship, subTask, deltaSeconds),
var kind when string.Equals(kind, ShipTaskKinds.SalvageWreck, StringComparison.Ordinal) => UpdateSalvageSubTask(world, ship, subTask, deltaSeconds),
var kind when string.Equals(kind, ShipTaskKinds.DeliverConstruction, StringComparison.Ordinal) => UpdateDeliverConstructionSubTask(world, ship, subTask, deltaSeconds),
var kind when string.Equals(kind, ShipTaskKinds.BuildConstructionSite, StringComparison.Ordinal) => UpdateBuildConstructionSubTask(world, ship, subTask, deltaSeconds),
var kind when string.Equals(kind, ShipTaskKinds.AttackTarget, StringComparison.Ordinal) => UpdateAttackSubTask(world, ship, subTask, deltaSeconds),
var kind when string.Equals(kind, ShipTaskKinds.HoldPosition, StringComparison.Ordinal) => UpdateHoldSubTask(ship, subTask, deltaSeconds),
_ => SubTaskOutcome.Failed,
};
}
private SubTaskOutcome UpdateHoldSubTask(ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
{
ship.State = ShipState.HoldingPosition;
ship.TargetPosition = subTask.TargetPosition ?? ship.Position;
ship.Position = ship.Position.MoveToward(ship.TargetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(ship.TargetPosition)));
return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.1f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
}
private SubTaskOutcome UpdateFollowSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
{
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f);
if (targetShip is null)
{
subTask.BlockingReason = "follow-target-missing";
return SubTaskOutcome.Failed;
}
var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 16f));
subTask.TargetSystemId = targetShip.SystemId;
subTask.TargetPosition = desiredPosition;
subTask.BlockingReason = null;
if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f))
{
return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false);
}
ship.State = ShipState.HoldingPosition;
ship.TargetPosition = desiredPosition;
ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition)));
return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.5f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
}
private SubTaskOutcome UpdateTravelSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds, bool completeOnArrival)
{
if (subTask.TargetPosition is null || subTask.TargetSystemId is null)
{
subTask.BlockingReason = "travel-target-missing";
ship.State = ShipState.Blocked;
return SubTaskOutcome.Failed;
}
var targetPosition = ResolveCurrentTargetPosition(world, subTask);
var targetCelestial = ResolveTravelTargetCelestial(world, subTask, targetPosition);
ship.TargetPosition = targetPosition;
if (ship.SystemId != subTask.TargetSystemId)
{
if (!CanFtl(ship.Definition))
{
subTask.BlockingReason = "ftl-unavailable";
ship.State = ShipState.Blocked;
return SubTaskOutcome.Failed;
}
var destinationEntryCelestial = ResolveSystemEntryCelestial(world, subTask.TargetSystemId);
var destinationEntryPosition = destinationEntryCelestial?.Position ?? targetPosition;
return UpdateFtlTransit(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, destinationEntryPosition, destinationEntryCelestial, completeOnArrival, targetPosition);
}
var currentCelestial = ResolveCurrentCelestial(world, ship);
if (targetCelestial is not null
&& currentCelestial is not null
&& !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal))
{
if (!CanWarp(ship.Definition))
{
return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival);
}
return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival);
}
if (targetCelestial is not null
&& ship.Position.DistanceTo(targetPosition) > WarpEngageDistanceKilometers
&& CanWarp(ship.Definition))
{
return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival);
}
return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival);
}
private SubTaskOutcome UpdateAttackSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
{
var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f);
var hostileStation = hostileShip is null
? world.Stations.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId)
: null;
if ((hostileShip is not null && hostileShip.FactionId == ship.FactionId)
|| (hostileStation is not null && hostileStation.FactionId == ship.FactionId))
{
subTask.BlockingReason = "friendly-target";
return SubTaskOutcome.Failed;
}
if (hostileShip is null && hostileStation is null)
{
return SubTaskOutcome.Completed;
}
var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId;
var targetPosition = hostileShip?.Position ?? hostileStation!.Position;
var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f;
subTask.TargetSystemId = targetSystemId;
subTask.TargetPosition = targetPosition;
subTask.Threshold = attackRange;
if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange)
{
return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false);
}
ship.State = ShipState.EngagingTarget;
ship.TargetPosition = targetPosition;
ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f));
var damage = GetShipDamagePerSecond(ship) * deltaSeconds * GetSkillFactor(ship.Skills.Combat);
subTask.Progress = 1f;
if (hostileShip is not null)
{
hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage);
return hostileShip.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
}
hostileStation!.Health = MathF.Max(0f, hostileStation.Health - (damage * 0.6f));
return hostileStation.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
}
private SubTaskOutcome UpdateMineSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
{
var node = ResolveNode(world, subTask.TargetEntityId ?? subTask.TargetNodeId);
if (node is null || !CanExtractNode(ship, node, world))
{
subTask.BlockingReason = "node-missing";
ship.State = ShipState.Blocked;
return SubTaskOutcome.Failed;
}
var targetPosition = subTask.TargetPosition ?? GetResourceHoldPosition(node.Position, ship.Id, 20f);
ship.TargetPosition = targetPosition;
if (ship.Position.DistanceTo(targetPosition) > MathF.Max(subTask.Threshold, 8f))
{
ship.State = ShipState.MiningApproach;
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return SubTaskOutcome.Active;
}
var cargoAmount = GetShipCargoAmount(ship);
if (cargoAmount >= ship.Definition.GetTotalCargoCapacity() - 0.01f)
{
return SubTaskOutcome.Completed;
}
ship.State = ShipState.Mining;
if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.MiningCycleSeconds))
{
return SubTaskOutcome.Active;
}
var remainingCapacity = MathF.Max(0f, ship.Definition.GetTotalCargoCapacity() - cargoAmount);
var mined = MathF.Min(balance.MiningRate * GetSkillFactor(ship.Skills.Mining), remainingCapacity);
mined = MathF.Min(mined, node.OreRemaining);
if (mined <= 0.01f)
{
return SubTaskOutcome.Completed;
}
AddInventory(ship.Inventory, node.ItemId, mined);
node.OreRemaining = MathF.Max(0f, node.OreRemaining - mined);
if (GetShipCargoAmount(ship) >= ship.Definition.GetTotalCargoCapacity() - 0.01f || node.OreRemaining <= 0.01f)
{
return SubTaskOutcome.Completed;
}
subTask.ElapsedSeconds = 0f;
return SubTaskOutcome.Active;
}
private SubTaskOutcome UpdateDockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
{
var station = ResolveStation(world, subTask.TargetEntityId);
if (station is null)
{
subTask.BlockingReason = "dock-target-missing";
ship.State = ShipState.Blocked;
return SubTaskOutcome.Failed;
}
var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id);
if (padIndex is null)
{
ship.State = ShipState.AwaitingDock;
ship.TargetPosition = GetDockingHoldPosition(station, ship.Id);
if (ship.Position.DistanceTo(ship.TargetPosition) > 4f)
{
ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
}
subTask.Status = WorkStatus.Blocked;
subTask.BlockingReason = "waiting-for-pad";
return SubTaskOutcome.Active;
}
subTask.Status = WorkStatus.Active;
subTask.BlockingReason = null;
ship.AssignedDockingPadIndex = padIndex;
var padPosition = GetDockingPadPosition(station, padIndex.Value);
ship.TargetPosition = padPosition;
if (ship.Position.DistanceTo(padPosition) > 4f)
{
ship.State = ShipState.DockingApproach;
ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return SubTaskOutcome.Active;
}
ship.State = ShipState.Docking;
if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.DockingDuration))
{
return SubTaskOutcome.Active;
}
ship.State = ShipState.Docked;
ship.DockedStationId = station.Id;
station.DockedShipIds.Add(ship.Id);
ship.KnownStationIds.Add(station.Id);
ship.Position = padPosition;
ship.TargetPosition = padPosition;
return SubTaskOutcome.Completed;
}
private SubTaskOutcome UpdateUndockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
return SubTaskOutcome.Completed;
}
var station = ResolveStation(world, ship.DockedStationId);
if (station is null)
{
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
return SubTaskOutcome.Completed;
}
var undockTarget = GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, balance.UndockDistance);
ship.TargetPosition = undockTarget;
ship.State = ShipState.Undocking;
if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.UndockingDuration))
{
ship.Position = GetShipDockedPosition(ship, station);
return SubTaskOutcome.Active;
}
ship.Position = ship.Position.MoveToward(undockTarget, balance.UndockDistance);
if (ship.Position.DistanceTo(undockTarget) > MathF.Max(subTask.Threshold, 4f))
{
return SubTaskOutcome.Active;
}
station.DockedShipIds.Remove(ship.Id);
ReleaseDockingPad(station, ship.Id);
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
return SubTaskOutcome.Completed;
}
private SubTaskOutcome UpdateLoadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
subTask.BlockingReason = "not-docked";
return SubTaskOutcome.Failed;
}
var station = ResolveStation(world, ship.DockedStationId);
if (station is null)
{
subTask.BlockingReason = "station-missing";
return SubTaskOutcome.Failed;
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.State = ShipState.Loading;
var itemId = subTask.ItemId;
if (itemId is null)
{
return SubTaskOutcome.Completed;
}
var desiredAmount = subTask.Amount > 0f ? subTask.Amount : ship.Definition.GetTotalCargoCapacity();
var availableCapacity = MathF.Max(0f, ship.Definition.GetTotalCargoCapacity() - GetShipCargoAmount(ship));
var transferRate = balance.TransferRate * GetSkillFactor(ship.Skills.Trade);
var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(availableCapacity, GetInventoryAmount(station.Inventory, itemId)));
if (moved > 0.01f)
{
RemoveInventory(station.Inventory, itemId, moved);
AddInventory(ship.Inventory, itemId, moved);
}
var loadedAmount = GetInventoryAmount(ship.Inventory, itemId);
subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(loadedAmount / desiredAmount, 0f, 1f);
return availableCapacity <= 0.01f || GetInventoryAmount(station.Inventory, itemId) <= 0.01f || loadedAmount >= desiredAmount - 0.01f
? SubTaskOutcome.Completed
: SubTaskOutcome.Active;
}
private SubTaskOutcome UpdateUnloadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
subTask.BlockingReason = "not-docked";
return SubTaskOutcome.Failed;
}
var station = ResolveStation(world, ship.DockedStationId);
if (station is null)
{
subTask.BlockingReason = "station-missing";
return SubTaskOutcome.Failed;
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.State = ShipState.Transferring;
var transferRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Mining));
if (subTask.ItemId is not null)
{
var moved = MathF.Min(transferRate * deltaSeconds, GetInventoryAmount(ship.Inventory, subTask.ItemId));
var accepted = TryAddStationInventory(world, station, subTask.ItemId, moved);
RemoveInventory(ship.Inventory, subTask.ItemId, accepted);
subTask.Progress = subTask.Amount <= 0.01f
? 1f
: Math.Clamp(1f - (GetInventoryAmount(ship.Inventory, subTask.ItemId) / subTask.Amount), 0f, 1f);
return GetInventoryAmount(ship.Inventory, subTask.ItemId) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
}
foreach (var (itemId, amount) in ship.Inventory.ToList().OrderBy(entry => entry.Key, StringComparer.Ordinal))
{
var moved = MathF.Min(amount, transferRate * deltaSeconds);
var accepted = TryAddStationInventory(world, station, itemId, moved);
RemoveInventory(ship.Inventory, itemId, accepted);
if (accepted > 0.01f)
{
return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
}
}
return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
}
private SubTaskOutcome UpdateTransferCargoToShipSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
{
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f);
if (targetShip is null)
{
subTask.BlockingReason = "target-ship-missing";
return SubTaskOutcome.Failed;
}
var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 12f));
subTask.TargetSystemId = targetShip.SystemId;
subTask.TargetPosition = desiredPosition;
if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f))
{
return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false);
}
ship.State = ShipState.Transferring;
ship.TargetPosition = desiredPosition;
ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition)));
if (subTask.ItemId is null)
{
return SubTaskOutcome.Completed;
}
var targetCapacity = MathF.Max(0f, targetShip.Definition.GetTotalCargoCapacity() - GetShipCargoAmount(targetShip));
if (targetCapacity <= 0.01f)
{
subTask.BlockingReason = "target-cargo-full";
return SubTaskOutcome.Failed;
}
var transferRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Navigation));
var desiredAmount = subTask.Amount > 0f ? subTask.Amount : GetInventoryAmount(ship.Inventory, subTask.ItemId);
var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(targetCapacity, GetInventoryAmount(ship.Inventory, subTask.ItemId)));
if (moved > 0.01f)
{
RemoveInventory(ship.Inventory, subTask.ItemId, moved);
AddInventory(targetShip.Inventory, subTask.ItemId, moved);
}
var remaining = GetInventoryAmount(ship.Inventory, subTask.ItemId);
subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(1f - (remaining / desiredAmount), 0f, 1f);
return remaining <= 0.01f || GetShipCargoAmount(targetShip) >= targetShip.Definition.GetTotalCargoCapacity() - 0.01f
? SubTaskOutcome.Completed
: SubTaskOutcome.Active;
}
private SubTaskOutcome UpdateSalvageSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
{
var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.RemainingAmount > 0.01f);
if (wreck is null)
{
return SubTaskOutcome.Completed;
}
var desiredPosition = subTask.TargetPosition ?? GetFormationPosition(wreck.Position, ship.Id, 8f);
ship.TargetPosition = desiredPosition;
if (ship.SystemId != wreck.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 8f))
{
subTask.TargetSystemId = wreck.SystemId;
subTask.TargetPosition = desiredPosition;
return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false);
}
ship.State = ShipState.Transferring;
var remainingCapacity = MathF.Max(0f, ship.Definition.GetTotalCargoCapacity() - GetShipCargoAmount(ship));
if (remainingCapacity <= 0.01f)
{
return SubTaskOutcome.Completed;
}
if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.4f, balance.MiningCycleSeconds * 0.8f)))
{
return SubTaskOutcome.Active;
}
var salvageRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Mining, ship.Skills.Trade));
var recovered = MathF.Min(salvageRate, MathF.Min(remainingCapacity, wreck.RemainingAmount));
if (recovered > 0.01f)
{
AddInventory(ship.Inventory, wreck.ItemId, recovered);
wreck.RemainingAmount = MathF.Max(0f, wreck.RemainingAmount - recovered);
}
if (wreck.RemainingAmount <= 0.01f)
{
world.Wrecks.RemoveAll(candidate => candidate.Id == wreck.Id);
}
subTask.ElapsedSeconds = 0f;
return wreck.RemainingAmount <= 0.01f || GetShipCargoAmount(ship) >= ship.Definition.GetTotalCargoCapacity() - 0.01f
? SubTaskOutcome.Completed
: SubTaskOutcome.Active;
}
private SubTaskOutcome UpdateDeliverConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
{
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
var station = site is null ? null : ResolveSupportStation(world, ship, site);
if (site is null || station is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed)
{
subTask.BlockingReason = "construction-target-missing";
return SubTaskOutcome.Failed;
}
var supportPosition = ResolveSupportPosition(ship, station, site, world);
if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold)))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return SubTaskOutcome.Active;
}
ship.TargetPosition = supportPosition;
ship.Position = supportPosition;
ship.State = ShipState.DeliveringConstruction;
var transferRate = balance.TransferRate * GetSkillFactor(ship.Skills.Construction);
foreach (var required in site.RequiredItems.OrderBy(entry => entry.Key, StringComparer.Ordinal))
{
var delivered = GetInventoryAmount(site.DeliveredItems, required.Key);
var remaining = MathF.Max(0f, required.Value - delivered);
if (remaining <= 0.01f)
{
continue;
}
var available = MathF.Max(0f, GetInventoryAmount(station.Inventory, required.Key) - GetStationReserveFloor(world, station, required.Key));
var moved = MathF.Min(remaining, MathF.Min(available, transferRate * deltaSeconds));
if (moved <= 0.01f)
{
continue;
}
RemoveInventory(station.Inventory, required.Key, moved);
AddInventory(site.Inventory, required.Key, moved);
AddInventory(site.DeliveredItems, required.Key, moved);
break;
}
subTask.Progress = site.RequiredItems.Count == 0
? 1f
: site.RequiredItems.Sum(required =>
required.Value <= 0.01f
? 1f
: Math.Clamp(GetInventoryAmount(site.DeliveredItems, required.Key) / required.Value, 0f, 1f)) / site.RequiredItems.Count;
return IsConstructionSiteReady(world, site) ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
}
private SubTaskOutcome UpdateBuildConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
{
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
var station = site is null ? null : ResolveSupportStation(world, ship, site);
if (site is null || station is null || site.BlueprintId is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed)
{
subTask.BlockingReason = "construction-site-missing";
return SubTaskOutcome.Failed;
}
var supportPosition = ResolveSupportPosition(ship, station, site, world);
if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold)))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return SubTaskOutcome.Active;
}
if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
{
ship.State = ShipState.WaitingMaterials;
subTask.Status = WorkStatus.Blocked;
subTask.BlockingReason = "waiting-materials";
return SubTaskOutcome.Active;
}
subTask.Status = WorkStatus.Active;
subTask.BlockingReason = null;
ship.TargetPosition = supportPosition;
ship.Position = supportPosition;
ship.State = ShipState.Constructing;
site.AssignedConstructorShipIds.Add(ship.Id);
site.Progress += deltaSeconds * GetSkillFactor(ship.Skills.Construction);
subTask.Progress = recipe.Duration <= 0.01f ? 1f : Math.Clamp(site.Progress / recipe.Duration, 0f, 1f);
if (site.Progress < recipe.Duration)
{
return SubTaskOutcome.Active;
}
if (site.StationId is null)
{
CompleteStationFoundation(world, station, site);
}
else
{
AddStationModule(world, station, site.BlueprintId);
PrepareNextConstructionSiteStep(world, station, site);
}
site.State = ConstructionSiteStateKinds.Completed;
return SubTaskOutcome.Completed;
}
private static bool AdvanceTimedSubTask(ShipSubTaskRuntime subTask, float deltaSeconds, float requiredSeconds)
{
subTask.TotalSeconds = requiredSeconds;
subTask.ElapsedSeconds += deltaSeconds;
subTask.Progress = requiredSeconds <= 0.01f ? 1f : Math.Clamp(subTask.ElapsedSeconds / requiredSeconds, 0f, 1f);
if (subTask.ElapsedSeconds < requiredSeconds)
{
return false;
}
subTask.ElapsedSeconds = 0f;
return true;
}
private SubTaskOutcome UpdateLocalTravel(
SimulationWorld world,
ShipRuntime ship,
ShipSubTaskRuntime subTask,
float deltaSeconds,
string targetSystemId,
Vector3 targetPosition,
CelestialRuntime? targetCelestial,
bool completeOnArrival)
{
var distance = ship.Position.DistanceTo(targetPosition);
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
ship.SpatialState.Transit = null;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f);
if (distance <= MathF.Max(subTask.Threshold, balance.ArrivalThreshold))
{
ship.Position = targetPosition;
ship.TargetPosition = targetPosition;
ship.SystemId = targetSystemId;
ship.SpatialState.CurrentSystemId = targetSystemId;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
}
ship.State = ShipState.LocalFlight;
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return SubTaskOutcome.Active;
}
private SubTaskOutcome UpdateWarpTransit(
SimulationWorld world,
ShipRuntime ship,
ShipSubTaskRuntime subTask,
float deltaSeconds,
Vector3 targetPosition,
CelestialRuntime targetCelestial,
bool completeOnArrival)
{
var transit = ship.SpatialState.Transit;
if (transit is null || transit.Regime != MovementRegimeKind.Warp || transit.DestinationNodeId != targetCelestial.Id)
{
transit = new ShipTransitRuntime
{
Regime = MovementRegimeKind.Warp,
OriginNodeId = ship.SpatialState.CurrentCelestialId,
DestinationNodeId = targetCelestial.Id,
StartedAtUtc = world.GeneratedAtUtc,
};
ship.SpatialState.Transit = transit;
subTask.ElapsedSeconds = 0f;
}
ship.SpatialState.SpaceLayer = SpaceLayerKind.SystemSpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.Warp;
ship.SpatialState.CurrentCelestialId = null;
ship.SpatialState.DestinationNodeId = targetCelestial.Id;
var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
if (ship.State != ShipState.Warping)
{
ship.State = ShipState.SpoolingWarp;
if (!AdvanceTimedSubTask(subTask, deltaSeconds, spoolDuration))
{
return SubTaskOutcome.Active;
}
ship.State = ShipState.Warping;
}
var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null
? ship.Position.DistanceTo(targetPosition)
: (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition)));
ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds);
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
subTask.Progress = transit.Progress;
if (ship.Position.DistanceTo(targetPosition) > 18f)
{
return SubTaskOutcome.Active;
}
return CompleteTransitArrival(ship, subTask.TargetSystemId ?? ship.SystemId, targetPosition, targetCelestial, completeOnArrival);
}
private SubTaskOutcome UpdateFtlTransit(
SimulationWorld world,
ShipRuntime ship,
ShipSubTaskRuntime subTask,
float deltaSeconds,
string targetSystemId,
Vector3 entryPosition,
CelestialRuntime? targetCelestial,
bool completeOnArrival,
Vector3 finalTargetPosition)
{
var destinationNodeId = targetCelestial?.Id;
var transit = ship.SpatialState.Transit;
if (transit is null || transit.Regime != MovementRegimeKind.FtlTransit || transit.DestinationNodeId != destinationNodeId)
{
transit = new ShipTransitRuntime
{
Regime = MovementRegimeKind.FtlTransit,
OriginNodeId = ship.SpatialState.CurrentCelestialId,
DestinationNodeId = destinationNodeId,
StartedAtUtc = world.GeneratedAtUtc,
};
ship.SpatialState.Transit = transit;
subTask.ElapsedSeconds = 0f;
}
ship.SpatialState.SpaceLayer = SpaceLayerKind.GalaxySpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.FtlTransit;
ship.SpatialState.CurrentCelestialId = null;
ship.SpatialState.DestinationNodeId = destinationNodeId;
if (ship.State != ShipState.Ftl)
{
ship.State = ShipState.SpoolingFtl;
if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(ship.Definition.SpoolTime, 0.1f)))
{
return SubTaskOutcome.Active;
}
ship.State = ShipState.Ftl;
}
var originSystemPosition = ResolveSystemGalaxyPosition(world, ship.SystemId);
var destinationSystemPosition = ResolveSystemGalaxyPosition(world, targetSystemId);
var totalDistance = MathF.Max(0.001f, originSystemPosition.DistanceTo(destinationSystemPosition));
transit.Progress = MathF.Min(1f, transit.Progress + ((ship.Definition.FtlSpeed * GetSkillFactor(ship.Skills.Navigation)) * deltaSeconds / totalDistance));
subTask.Progress = transit.Progress;
if (transit.Progress < 0.999f)
{
return SubTaskOutcome.Active;
}
ship.Position = entryPosition;
ship.TargetPosition = finalTargetPosition;
ship.SystemId = targetSystemId;
ship.SpatialState.CurrentSystemId = targetSystemId;
ship.SpatialState.Transit = null;
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
// Cross-system travel is only complete once the ship finishes the
// destination-system local leg to the actual target.
return SubTaskOutcome.Active;
}
private static SubTaskOutcome CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial, bool completeOnArrival)
{
ship.Position = targetPosition;
ship.TargetPosition = targetPosition;
ship.SystemId = targetSystemId;
ship.SpatialState.CurrentSystemId = targetSystemId;
ship.SpatialState.Transit = null;
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
}
}

View File

@@ -0,0 +1,947 @@
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
namespace SpaceGame.Api.Ships.AI;
public sealed partial class ShipAiService
{
private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ShipSubTaskRuntime subTask)
{
if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId))
{
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
if (ship is not null)
{
return ship.Position;
}
var station = ResolveStation(world, subTask.TargetEntityId);
if (station is not null)
{
return station.Position;
}
var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
if (celestial is not null)
{
return celestial.Position;
}
var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
if (wreck is not null)
{
return wreck.Position;
}
}
return subTask.TargetPosition ?? Vector3.Zero;
}
private static CelestialRuntime? ResolveTravelTargetCelestial(SimulationWorld world, ShipSubTaskRuntime subTask, Vector3 targetPosition)
{
if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId))
{
var station = ResolveStation(world, subTask.TargetEntityId);
if (station?.CelestialId is not null)
{
return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId);
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
if (site?.CelestialId is not null)
{
return world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
}
var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
if (celestial is not null)
{
return celestial;
}
if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } wreck)
{
return world.Celestials
.Where(candidate => candidate.SystemId == wreck.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(wreck.Position))
.FirstOrDefault();
}
}
return world.Celestials
.Where(candidate => subTask.TargetSystemId is null || candidate.SystemId == subTask.TargetSystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(targetPosition))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship)
{
if (ship.SpatialState.CurrentCelestialId is not null)
{
return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId);
}
return world.Celestials
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) =>
world.Celestials.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star);
private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) =>
world.Systems.FirstOrDefault(candidate => candidate.Definition.Id == systemId)?.Position ?? Vector3.Zero;
private static float GetLocalTravelSpeed(ShipRuntime ship) =>
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed) * GetSkillFactor(ship.Skills.Navigation);
private static float GetWarpTravelSpeed(ShipRuntime ship) =>
SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed) * GetSkillFactor(ship.Skills.Navigation);
private static float GetSkillFactor(int skillLevel) =>
Math.Clamp(1f + ((skillLevel - 3) * 0.08f), 0.75f, 1.4f);
private static int GetEffectiveSkillLevel(
SimulationWorld world,
ShipRuntime ship,
Func<ShipSkillProfileRuntime, int> captainSelector,
Func<CommanderSkillProfileRuntime, int> managerSelector)
{
var captainLevel = captainSelector(ship.Skills);
if (ship.CommanderId is null)
{
return captainLevel;
}
var shipCommander = world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId);
var manager = shipCommander?.ParentCommanderId is null
? shipCommander
: world.Commanders.FirstOrDefault(candidate => candidate.Id == shipCommander.ParentCommanderId) ?? shipCommander;
return Math.Clamp((captainLevel + (manager is null ? 3 : managerSelector(manager.Skills)) + 1) / 2, 1, 5);
}
private static int ResolveBehaviorSystemRange(SimulationWorld world, ShipRuntime ship, string behaviorKind, int explicitRange)
{
if (explicitRange > 0)
{
return explicitRange;
}
var tradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination);
var miningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination);
var combatSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Combat, skills => skills.Strategy);
return behaviorKind switch
{
LocalAutoMine or LocalAutoTrade => 0,
AdvancedAutoMine => Math.Clamp(1 + ((miningSkill - 1) / 2), 1, 3),
AdvancedAutoTrade => Math.Clamp(1 + ((tradeSkill - 1) / 2), 1, 3),
ExpertAutoMine => Math.Clamp(2 + ((miningSkill - 1) / 2), 2, Math.Max(world.Systems.Count - 1, 2)),
FillShortages or FindBuildTasks or RevisitKnownStations or SupplyFleet => Math.Clamp(1 + ((tradeSkill + 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)),
Patrol or Police or ProtectPosition or ProtectShip or ProtectStation => Math.Clamp(1 + ((combatSkill - 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)),
_ => Math.Max(world.Systems.Count - 1, 0),
};
}
private static int GetSystemDistanceTier(SimulationWorld world, string originSystemId, string targetSystemId)
{
if (string.Equals(originSystemId, targetSystemId, StringComparison.Ordinal))
{
return 0;
}
var originPosition = ResolveSystemGalaxyPosition(world, originSystemId);
return world.Systems
.OrderBy(system => system.Position.DistanceTo(originPosition))
.ThenBy(system => system.Definition.Id, StringComparer.Ordinal)
.Select(system => system.Definition.Id)
.TakeWhile(systemId => !string.Equals(systemId, targetSystemId, StringComparison.Ordinal))
.Count();
}
private static bool IsWithinSystemRange(SimulationWorld world, string originSystemId, string targetSystemId, int maxRange) =>
maxRange < 0 || GetSystemDistanceTier(world, originSystemId, targetSystemId) <= maxRange;
private static float GetShipDamagePerSecond(ShipRuntime ship) =>
ship.Definition.Type switch
{
ShipType.Frigate => FrigateDps,
ShipType.Destroyer => DestroyerDps,
ShipType.Battleship => CruiserDps,
ShipType.Carrier => CapitalDps,
_ => 4f,
};
private static MiningOpportunity? SelectMiningOpportunity(
SimulationWorld world,
ShipRuntime ship,
StationRuntime homeStation,
CommanderAssignmentRuntime? assignment,
string behaviorKind)
{
var policy = ResolvePolicy(world, ship.PolicySetId);
var preferredItemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId;
var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange);
var effectiveMiningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination);
string? deniedReason = null;
var opportunity = world.Nodes
.Where(node =>
{
if (node.OreRemaining <= 0.01f || !CanExtractNode(ship, node, world) || (preferredItemId is not null && !string.Equals(node.ItemId, preferredItemId, StringComparison.Ordinal)))
{
return false;
}
if (!TryCheckSystemAllowed(world, policy, ship.FactionId, node.SystemId, "military", out var reason))
{
deniedReason ??= reason;
return false;
}
return IsWithinSystemRange(world, homeStation.SystemId, node.SystemId, rangeBudget);
})
.Select(node =>
{
var buyer = SelectBestDeliveryStation(world, ship, node.ItemId, homeStation, behaviorKind);
var demandScore = GetFactionDemandScore(world, ship.FactionId, node.ItemId);
var distancePenalty = GetSystemDistanceTier(world, homeStation.SystemId, node.SystemId) * 18f;
var routeRiskPenalty = GeopoliticalSimulationService.GetSystemRouteRisk(world, node.SystemId, ship.FactionId) * 30f;
var score = (node.SystemId == homeStation.SystemId ? 55f : 0f)
+ (node.OreRemaining * 0.025f)
+ (demandScore * (string.Equals(behaviorKind, ExpertAutoMine, StringComparison.Ordinal) ? 22f : 12f))
+ (effectiveMiningSkill * 10f)
- distancePenalty
- routeRiskPenalty
- node.Position.DistanceTo(ship.Position);
return new MiningOpportunity(node, buyer, score, $"Mine {node.ItemId} for {buyer.Label}");
})
.OrderByDescending(candidate => candidate.Score)
.ThenBy(candidate => candidate.Node.Id, StringComparer.Ordinal)
.FirstOrDefault();
if (opportunity is null && deniedReason is not null)
{
ship.LastAccessFailureReason = deniedReason;
}
return opportunity;
}
private static TradeRoutePlan? SelectTradeRoute(
SimulationWorld world,
ShipRuntime ship,
StationRuntime? homeStation,
string behaviorKind,
bool knownStationsOnly)
{
var policy = ResolvePolicy(world, ship.PolicySetId);
var stationsById = world.Stations
.Where(station => station.FactionId == ship.FactionId)
.ToDictionary(station => station.Id, StringComparer.Ordinal);
var originSystemId = homeStation?.SystemId ?? ship.SystemId;
var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange);
var effectiveTradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination);
var requireKnownStations = knownStationsOnly || string.Equals(behaviorKind, RevisitKnownStations, StringComparison.Ordinal);
string? deniedReason = null;
var route = world.MarketOrders
.Where(order =>
order.FactionId == ship.FactionId &&
order.Kind == MarketOrderKinds.Buy &&
order.RemainingAmount > 0.01f)
.Select(order =>
{
StationRuntime? destination = null;
ConstructionSiteRuntime? destinationSite = null;
if (order.StationId is not null && stationsById.TryGetValue(order.StationId, out var destinationStation))
{
destination = destinationStation;
}
else if (order.ConstructionSiteId is not null)
{
destinationSite = world.ConstructionSites.FirstOrDefault(site => site.Id == order.ConstructionSiteId);
if (destinationSite is not null)
{
destination = ResolveSupportStation(world, ship, destinationSite);
}
}
if (destination is null)
{
return null;
}
if (!TryCheckSystemAllowed(world, policy, ship.FactionId, destination.SystemId, "trade", out var destinationDeniedReason))
{
deniedReason ??= destinationDeniedReason;
return null;
}
if (!IsWithinSystemRange(world, originSystemId, destination.SystemId, rangeBudget))
{
return null;
}
if (requireKnownStations
&& ship.KnownStationIds.Count > 0
&& !ship.KnownStationIds.Contains(destination.Id)
&& (homeStation is null || !string.Equals(destination.Id, homeStation.Id, StringComparison.Ordinal)))
{
return null;
}
if (string.Equals(behaviorKind, FindBuildTasks, StringComparison.Ordinal) && destinationSite is null)
{
return null;
}
if (!string.Equals(behaviorKind, FindBuildTasks, StringComparison.Ordinal) && destinationSite is not null)
{
return null;
}
var source = stationsById.Values
.Where(station =>
{
if (station.Id == destination.Id || GetInventoryAmount(station.Inventory, order.ItemId) <= GetStationReserveFloor(world, station, order.ItemId) + 1f)
{
return false;
}
if (!TryCheckSystemAllowed(world, policy, ship.FactionId, station.SystemId, "trade", out var sourceDeniedReason))
{
deniedReason ??= sourceDeniedReason;
return false;
}
if (!IsWithinSystemRange(world, originSystemId, station.SystemId, rangeBudget))
{
return false;
}
return !requireKnownStations
|| ship.KnownStationIds.Count == 0
|| ship.KnownStationIds.Contains(station.Id)
|| (homeStation is not null && string.Equals(station.Id, homeStation.Id, StringComparison.Ordinal));
})
.OrderByDescending(station => GetInventoryAmount(station.Inventory, order.ItemId) - GetStationReserveFloor(world, station, order.ItemId))
.ThenByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0)
.ThenBy(station => station.Id, StringComparer.Ordinal)
.FirstOrDefault();
if (source is null)
{
return null;
}
var shortageBias = string.Equals(behaviorKind, FillShortages, StringComparison.Ordinal)
? GetFactionDemandScore(world, ship.FactionId, order.ItemId) * 35f
: 0f;
var buildBias = destinationSite is null ? 0f : 65f;
var revisitBias = string.Equals(behaviorKind, RevisitKnownStations, StringComparison.Ordinal) && ship.KnownStationIds.Contains(source.Id) && ship.KnownStationIds.Contains(destination.Id)
? 28f
: 0f;
var regionalNeedBias = GetRegionalCommodityPressure(world, ship.FactionId, destination.SystemId, order.ItemId) * 18f;
var systemRangePenalty = (GetSystemDistanceTier(world, originSystemId, source.SystemId) + GetSystemDistanceTier(world, originSystemId, destination.SystemId)) * 16f;
var riskPenalty =
(GeopoliticalSimulationService.GetSystemRouteRisk(world, source.SystemId, ship.FactionId)
+ GeopoliticalSimulationService.GetSystemRouteRisk(world, destination.SystemId, ship.FactionId)) * 22f;
var distanceScore = source.Position.DistanceTo(ship.Position) + source.Position.DistanceTo(destination.Position);
var score = (order.Valuation * 50f)
+ shortageBias
+ buildBias
+ revisitBias
+ regionalNeedBias
+ (effectiveTradeSkill * 12f)
- systemRangePenalty
- riskPenalty
- distanceScore;
var summary = destinationSite is null
? $"{order.ItemId}: {source.Label} -> {destination.Label}"
: $"{order.ItemId}: {source.Label} -> build support {destination.Label}";
return new TradeRoutePlan(source, destination, order.ItemId, score, summary);
})
.Where(route => route is not null)
.Cast<TradeRoutePlan>()
.OrderByDescending(route => route.Score)
.ThenBy(route => route.ItemId, StringComparer.Ordinal)
.ThenBy(route => route.SourceStation.Id, StringComparer.Ordinal)
.FirstOrDefault();
if (route is null && deniedReason is not null)
{
ship.LastAccessFailureReason = deniedReason;
}
return route;
}
private static FleetSupplyPlan? SelectFleetSupplyPlan(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation)
{
var assignment = ResolveAssignment(world, ship);
var targetCandidates = world.Ships
.Where(candidate =>
candidate.Id != ship.Id &&
candidate.FactionId == ship.FactionId &&
candidate.Definition.GetTotalCargoCapacity() > 0.01f &&
(assignment?.TargetEntityId is null || string.Equals(candidate.Id, assignment.TargetEntityId, StringComparison.Ordinal)))
.OrderByDescending(candidate => IsMilitaryShip(candidate.Definition) ? 1 : 0)
.ThenBy(candidate => candidate.Id, StringComparer.Ordinal)
.ToList();
if (targetCandidates.Count == 0)
{
return null;
}
var sourceStations = world.Stations
.Where(station => station.FactionId == ship.FactionId)
.OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0)
.ThenBy(station => station.Id, StringComparer.Ordinal)
.ToList();
foreach (var target in targetCandidates)
{
var itemId = assignment?.ItemId
?? sourceStations
.SelectMany(station => station.Inventory)
.Where(entry => entry.Value > 2f)
.OrderByDescending(entry => entry.Value)
.ThenBy(entry => entry.Key, StringComparer.Ordinal)
.Select(entry => entry.Key)
.FirstOrDefault();
if (itemId is null)
{
continue;
}
var source = sourceStations.FirstOrDefault(station => GetInventoryAmount(station.Inventory, itemId) > 2f);
if (source is null)
{
continue;
}
var amount = MathF.Min(MathF.Max(10f, ship.Definition.GetTotalCargoCapacity() * 0.5f), GetInventoryAmount(source.Inventory, itemId));
return new FleetSupplyPlan(source, target, itemId, amount, MathF.Max(16f, ship.DefaultBehavior.Radius), $"Supply {target.Definition.Name} with {itemId}");
}
return null;
}
private static StationRuntime? SelectKnownStationVisit(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation)
{
var candidateIds = ship.KnownStationIds.Count == 0 && homeStation is not null
? [homeStation.Id]
: ship.KnownStationIds.OrderBy(id => id, StringComparer.Ordinal).ToArray();
return candidateIds
.Select(id => ResolveStation(world, id))
.Where(station => station is not null && station.FactionId == ship.FactionId)
.Cast<StationRuntime>()
.OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0)
.ThenBy(station => station.SystemId == ship.SystemId ? 0 : 1)
.ThenBy(station => station.Position.DistanceTo(ship.Position))
.FirstOrDefault();
}
private static StationRuntime SelectBestDeliveryStation(SimulationWorld world, ShipRuntime ship, string itemId, StationRuntime homeStation, string behaviorKind)
{
if (!string.Equals(behaviorKind, ExpertAutoMine, StringComparison.Ordinal))
{
return homeStation;
}
return world.Stations
.Where(station => station.FactionId == ship.FactionId)
.OrderByDescending(station => GetFactionDemandScore(world, ship.FactionId, itemId) + GetRegionalCommodityPressure(world, ship.FactionId, station.SystemId, itemId) + (station.Id == homeStation.Id ? 5f : 0f))
.ThenBy(station => station.SystemId == homeStation.SystemId ? 0 : 1)
.ThenBy(station => station.Id, StringComparer.Ordinal)
.FirstOrDefault()
?? homeStation;
}
private static ResourceNodeRuntime? SelectLocalMiningNode(SimulationWorld world, ShipRuntime ship, string systemId, string itemId)
{
var policy = ResolvePolicy(world, ship.PolicySetId);
string? deniedReason = null;
var node = world.Nodes
.Where(candidate =>
{
if (!string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal)
|| !string.Equals(candidate.ItemId, itemId, StringComparison.Ordinal)
|| candidate.OreRemaining <= 0.01f
|| !CanExtractNode(ship, candidate, world))
{
return false;
}
if (!TryCheckSystemAllowed(world, policy, ship.FactionId, candidate.SystemId, "military", out var reason))
{
deniedReason ??= reason;
return false;
}
return true;
})
.OrderByDescending(candidate => candidate.OreRemaining)
.ThenBy(candidate => candidate.Position.DistanceTo(ship.Position))
.ThenBy(candidate => candidate.Id, StringComparer.Ordinal)
.FirstOrDefault();
if (node is null && deniedReason is not null)
{
ship.LastAccessFailureReason = deniedReason;
}
return node;
}
private static StationRuntime? SelectLocalAutoMineBuyer(SimulationWorld world, ShipRuntime ship, string systemId, string itemId)
{
var policy = ResolvePolicy(world, ship.PolicySetId);
var stationsById = world.Stations.ToDictionary(station => station.Id, StringComparer.Ordinal);
string? deniedReason = null;
var buyer = world.MarketOrders
.Where(order =>
order.Kind == MarketOrderKinds.Buy
&& string.Equals(order.ItemId, itemId, StringComparison.Ordinal)
&& order.RemainingAmount > 0.01f)
.Select(order =>
{
StationRuntime? destination = null;
if (order.StationId is not null && stationsById.TryGetValue(order.StationId, out var station))
{
destination = station;
}
else if (order.ConstructionSiteId is not null)
{
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == order.ConstructionSiteId);
if (site is not null)
{
destination = ResolveSupportStation(world, ship, site);
}
}
if (destination is null || !string.Equals(destination.SystemId, systemId, StringComparison.Ordinal))
{
return null;
}
if (!TryCheckSystemAllowed(world, policy, ship.FactionId, destination.SystemId, "trade", out var reason))
{
deniedReason ??= reason;
return null;
}
var score = (order.Valuation * 20f)
+ MathF.Min(order.RemainingAmount, ship.Definition.GetTotalCargoCapacity())
- destination.Position.DistanceTo(ship.Position);
return new LocalMiningBuyerCandidate(destination, score);
})
.Where(candidate => candidate is not null)
.Cast<LocalMiningBuyerCandidate>()
.OrderByDescending(candidate => candidate.Score)
.ThenBy(candidate => candidate.Station.Id, StringComparer.Ordinal)
.Select(candidate => candidate.Station)
.FirstOrDefault();
if (buyer is null && deniedReason is not null)
{
ship.LastAccessFailureReason = deniedReason;
}
return buyer;
}
private static float GetFactionDemandScore(SimulationWorld world, string factionId, string itemId)
{
var signal = CommanderPlanningService.FindFactionEconomicAssessment(world, factionId)?
.CommoditySignals
.FirstOrDefault(candidate => candidate.ItemId == itemId);
var regionalBottleneckScore = world.Geopolitics?.EconomyRegions.Bottlenecks
.Where(bottleneck => string.Equals(bottleneck.ItemId, itemId, StringComparison.Ordinal))
.Join(
world.Geopolitics.EconomyRegions.Regions.Where(region => string.Equals(region.FactionId, factionId, StringComparison.Ordinal)),
bottleneck => bottleneck.RegionId,
region => region.Id,
(bottleneck, _) => bottleneck.Severity)
.DefaultIfEmpty()
.Max() ?? 0f;
if (signal is null)
{
return regionalBottleneckScore * 8f;
}
return MathF.Max(0f, signal.BuyBacklog + signal.ReservedForConstruction + MathF.Max(0f, -signal.ProjectedNetRatePerSecond * 50f) + (regionalBottleneckScore * 8f));
}
private static float GetRegionalCommodityPressure(SimulationWorld world, string factionId, string systemId, string itemId)
{
var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, systemId);
if (region is null)
{
return 0f;
}
var bottleneck = world.Geopolitics?.EconomyRegions.Bottlenecks
.FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal)
&& string.Equals(candidate.ItemId, itemId, StringComparison.Ordinal));
var assessment = world.Geopolitics?.EconomyRegions.EconomicAssessments
.FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal));
return (bottleneck?.Severity ?? 0f) + ((assessment?.ConstructionPressure ?? 0f) * 2f);
}
private static ThreatTargetCandidate? SelectThreatTarget(
SimulationWorld world,
ShipRuntime ship,
string targetSystemId,
Vector3 anchorPosition,
float radius,
string? excludeEntityId = null)
{
var policy = ResolvePolicy(world, ship.PolicySetId);
return world.Ships
.Where(candidate =>
candidate.Id != excludeEntityId &&
candidate.Health > 0f &&
candidate.FactionId != ship.FactionId &&
string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) &&
candidate.Position.DistanceTo(anchorPosition) <= radius * 1.75f)
.Select(candidate => new ThreatTargetCandidate(
candidate.Id,
candidate.SystemId,
candidate.Position,
100f
+ (IsMilitaryShip(candidate.Definition) ? 30f : 0f)
- candidate.Position.DistanceTo(anchorPosition)
- candidate.Position.DistanceTo(ship.Position)
+ (string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase) ? 12f : 0f)))
.Concat(world.Stations
.Where(candidate =>
candidate.Id != excludeEntityId &&
candidate.FactionId != ship.FactionId &&
string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) &&
candidate.Position.DistanceTo(anchorPosition) <= radius * 2f)
.Select(candidate => new ThreatTargetCandidate(candidate.Id, candidate.SystemId, candidate.Position, 45f - candidate.Position.DistanceTo(anchorPosition) * 0.2f)))
.OrderByDescending(candidate => candidate.Score)
.ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal)
.FirstOrDefault();
}
private static PoliceContactCandidate? SelectPoliceContact(SimulationWorld world, ShipRuntime ship, string systemId, Vector3 anchorPosition, float radius)
{
var policy = ResolvePolicy(world, ship.PolicySetId);
return world.Ships
.Where(candidate =>
candidate.Id != ship.Id &&
candidate.Health > 0f &&
candidate.FactionId != ship.FactionId &&
string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal) &&
candidate.Position.DistanceTo(anchorPosition) <= radius * 1.5f)
.Select(candidate =>
{
var engage = IsMilitaryShip(candidate.Definition)
|| string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase);
var score = (engage ? 80f : 40f)
- candidate.Position.DistanceTo(anchorPosition)
- candidate.Position.DistanceTo(ship.Position)
+ (IsTransportShip(candidate.Definition) ? 8f : 0f);
return new PoliceContactCandidate(candidate.Id, candidate.SystemId, candidate.Position, engage, score);
})
.OrderByDescending(candidate => candidate.Score)
.ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal)
.FirstOrDefault();
}
private static SalvageOpportunity? SelectSalvageOpportunity(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation)
{
if (homeStation is null)
{
return null;
}
var rangeBudget = ResolveBehaviorSystemRange(world, ship, AutoSalvage, ship.DefaultBehavior.MaxSystemRange > 0 ? ship.DefaultBehavior.MaxSystemRange : 1);
return world.Wrecks
.Where(wreck =>
wreck.RemainingAmount > 0.01f &&
IsWithinSystemRange(world, homeStation.SystemId, wreck.SystemId, rangeBudget))
.Select(wreck => new SalvageOpportunity(
wreck,
(wreck.RemainingAmount * 3f) - wreck.Position.DistanceTo(ship.Position) - (GetSystemDistanceTier(world, homeStation.SystemId, wreck.SystemId) * 25f),
$"Salvage {wreck.ItemId} from {wreck.SourceEntityId}"))
.OrderByDescending(candidate => candidate.Score)
.ThenBy(candidate => candidate.Wreck.Id, StringComparer.Ordinal)
.FirstOrDefault();
}
private static (string SystemId, Vector3 Position)? ResolveObjectTarget(SimulationWorld world, string? entityId)
{
if (entityId is null)
{
return null;
}
if (world.Ships.FirstOrDefault(candidate => candidate.Id == entityId) is { } ship)
{
return (ship.SystemId, ship.Position);
}
if (ResolveStation(world, entityId) is { } station)
{
return (station.SystemId, station.Position);
}
if (world.Celestials.FirstOrDefault(candidate => candidate.Id == entityId) is { } celestial)
{
return (celestial.SystemId, celestial.Position);
}
if (world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId) is { } site)
{
var position = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? Vector3.Zero;
return (site.SystemId, position);
}
if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == entityId) is { } wreck)
{
return (wreck.SystemId, wreck.Position);
}
return null;
}
private static Vector3 GetFormationPosition(Vector3 anchorPosition, string seed, float radius)
{
var hash = Math.Abs(seed.Aggregate(17, (acc, c) => (acc * 31) + c));
var angle = (hash % 360) * (MathF.PI / 180f);
return new Vector3(
anchorPosition.X + (MathF.Cos(angle) * radius),
anchorPosition.Y,
anchorPosition.Z + (MathF.Sin(angle) * radius));
}
private static TradeRoutePlan? ResolveTradeRoute(SimulationWorld world, string itemId, string sourceStationId, string destinationStationId)
{
var source = ResolveStation(world, sourceStationId);
var destination = ResolveStation(world, destinationStationId);
return source is null || destination is null ? null : new TradeRoutePlan(source, destination, itemId, 0f, $"{itemId}: {source.Label} -> {destination.Label}");
}
private static StationRuntime? ResolveStation(SimulationWorld world, string? stationId) =>
stationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == stationId);
private static ResourceNodeRuntime? ResolveNode(SimulationWorld world, string? nodeId) =>
nodeId is null ? null : world.Nodes.FirstOrDefault(candidate => candidate.Id == nodeId);
private static PolicySetRuntime? ResolvePolicy(SimulationWorld world, string? policySetId) =>
policySetId is null ? null : world.Policies.FirstOrDefault(policy => policy.Id == policySetId);
private static bool IsSystemAllowed(
SimulationWorld world,
PolicySetRuntime? policy,
string factionId,
string systemId,
string accessKind) =>
TryCheckSystemAllowed(world, policy, factionId, systemId, accessKind, out _);
private static bool TryCheckSystemAllowed(
SimulationWorld world,
PolicySetRuntime? policy,
string factionId,
string systemId,
string accessKind,
out string? denialReason)
{
denialReason = null;
if (policy?.BlacklistedSystemIds.Contains(systemId) == true)
{
denialReason = $"blacklisted:{systemId}";
return false;
}
var controlState = GeopoliticalSimulationService.GetSystemControlState(world, systemId);
var authorityFactionId = controlState?.ControllerFactionId ?? controlState?.PrimaryClaimantFactionId;
if (authorityFactionId is null || string.Equals(authorityFactionId, factionId, StringComparison.Ordinal))
{
return true;
}
var hasAccess = string.Equals(accessKind, "trade", StringComparison.Ordinal)
? GeopoliticalSimulationService.HasTradeAccess(world, factionId, authorityFactionId)
: GeopoliticalSimulationService.HasMilitaryAccess(world, factionId, authorityFactionId);
if (!hasAccess)
{
denialReason = $"{accessKind}-access-denied:{authorityFactionId}";
return false;
}
if (policy?.AvoidHostileSystems != true)
{
return true;
}
if (GeopoliticalSimulationService.HasHostileRelation(world, factionId, authorityFactionId))
{
denialReason = $"hostile-authority:{authorityFactionId}";
return false;
}
var hostileInfluencer = controlState?.InfluencingFactionIds.FirstOrDefault(candidate =>
!string.Equals(candidate, factionId, StringComparison.Ordinal)
&& GeopoliticalSimulationService.HasHostileRelation(world, factionId, candidate));
if (hostileInfluencer is not null)
{
denialReason = $"hostile-influence:{hostileInfluencer}";
return false;
}
return true;
}
private static CommanderAssignmentRuntime? ResolveAssignment(SimulationWorld world, ShipRuntime ship) =>
ship.CommanderId is null
? null
: world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment;
private static ShipPlanStepRuntime? GetCurrentStep(ShipPlanRuntime? plan) =>
plan is null || plan.CurrentStepIndex >= plan.Steps.Count ? null : plan.Steps[plan.CurrentStepIndex];
private static StationRuntime? ResolveSupportStation(SimulationWorld world, ShipRuntime ship, ConstructionSiteRuntime site)
{
return ResolveStation(world, ResolveAssignment(world, ship)?.HomeStationId ?? ship.DefaultBehavior.HomeStationId)
?? world.Stations
.Where(station => station.FactionId == ship.FactionId)
.OrderByDescending(station => station.SystemId == site.SystemId ? 1 : 0)
.ThenBy(station => station.Id, StringComparer.Ordinal)
.FirstOrDefault();
}
private static Vector3 ResolveSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world)
{
if (ship.DockedStationId is not null)
{
return GetShipDockedPosition(ship, station);
}
if (site?.StationId is null && site is not null)
{
var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position;
return GetResourceHoldPosition(anchorPosition, ship.Id, 78f);
}
return GetConstructionHoldPosition(station, ship.Id);
}
private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) =>
ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f);
private static void TrackHistory(ShipRuntime ship)
{
var plan = ship.ActivePlan;
var step = GetCurrentStep(plan);
var subTask = step is null || step.CurrentSubTaskIndex >= step.SubTasks.Count ? null : step.SubTasks[step.CurrentSubTaskIndex];
var signature = $"{ship.State.ToContractValue()}|{plan?.Kind ?? "none"}|{step?.Kind ?? "none"}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}";
if (ship.LastSignature == signature)
{
return;
}
ship.LastSignature = signature;
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} plan={plan?.Kind ?? "none"} step={step?.Kind ?? "none"} subTask={subTask?.Kind ?? "none"} cargo={GetShipCargoAmount(ship):0.#}");
if (ship.History.Count > 24)
{
ship.History.RemoveAt(0);
}
}
private static void EmitStateEvents(ShipRuntime ship, ShipState previousState, string? previousPlanId, string? previousStepId, ICollection<SimulationEventRecord> events)
{
var currentPlanId = ship.ActivePlan?.Id;
var currentStepId = GetCurrentStep(ship.ActivePlan)?.Id;
var occurredAtUtc = DateTimeOffset.UtcNow;
if (previousState != ship.State)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Name} state {previousState.ToContractValue()} -> {ship.State.ToContractValue()}.", occurredAtUtc));
}
if (!string.Equals(previousPlanId, currentPlanId, StringComparison.Ordinal))
{
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-changed", $"{ship.Definition.Name} switched active plan.", occurredAtUtc));
}
if (!string.Equals(previousStepId, currentStepId, StringComparison.Ordinal))
{
events.Add(new SimulationEventRecord("ship", ship.Id, "step-changed", $"{ship.Definition.Name} advanced plan step.", occurredAtUtc));
}
}
private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site)
{
var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
if (anchor is null || site.BlueprintId is null)
{
site.State = ConstructionSiteStateKinds.Destroyed;
return;
}
var station = new StationRuntime
{
Id = $"station-{world.Stations.Count + 1}",
SystemId = site.SystemId,
Label = BuildFoundedStationLabel(site.TargetDefinitionId),
Category = "station",
Objective = DetermineFoundationObjective(site.TargetDefinitionId),
Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color,
Position = anchor.Position,
FactionId = site.FactionId,
CelestialId = site.CelestialId,
Health = 600f,
MaxHealth = 600f,
};
foreach (var moduleId in GetFoundationModules(world, site.BlueprintId))
{
AddStationModule(world, station, moduleId);
}
world.Stations.Add(station);
StationLifecycleService.EnsureStationCommander(world, station);
anchor.OccupyingStructureId = station.Id;
site.StationId = station.Id;
PrepareNextConstructionSiteStep(world, station, site);
}
private static IReadOnlyList<string> GetFoundationModules(SimulationWorld world, string primaryModuleId)
{
var modules = new List<string> { "module_arg_dock_m_01_lowtech" };
foreach (var itemId in world.ProductionGraph.OutputsByModuleId.GetValueOrDefault(primaryModuleId, []))
{
if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
{
var storageModule = GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoKind);
if (storageModule is not null && !modules.Contains(storageModule, StringComparer.Ordinal))
{
modules.Add(storageModule);
}
}
}
if (!modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal))
{
modules.Add("module_arg_stor_container_m_01");
}
if (!string.Equals(primaryModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal))
{
modules.Add("module_gen_prod_energycells_01");
}
modules.Add(primaryModuleId);
return modules.Distinct(StringComparer.Ordinal).ToList();
}
private static string DetermineFoundationObjective(string commodityId) =>
commodityId switch
{
"energycells" => "power",
"water" => "water",
"refinedmetals" => "refinery",
"hullparts" => "hullparts",
"claytronics" => "claytronics",
"shipyard" => "shipyard",
_ => "general",
};
private static string BuildFoundedStationLabel(string commodityId) =>
$"{char.ToUpperInvariant(commodityId[0])}{commodityId[1..]} Foundry";
}

View File

@@ -0,0 +1,319 @@
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
namespace SpaceGame.Api.Ships.AI;
public sealed partial class ShipAiService
{
private ShipPlanRuntime BuildBehaviorFallbackPlan(SimulationWorld world, ShipRuntime ship)
{
var (behaviorKind, sourceId) = ResolveBehaviorSource(world, ship);
var failureReason = ship.LastAccessFailureReason;
if (string.Equals(behaviorKind, Idle, StringComparison.Ordinal))
{
return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Idle");
}
if (IsBehaviorBlockingFailure(behaviorKind, failureReason))
{
return CreateBlockedPlan(
ship,
AiPlanSourceKind.DefaultBehavior,
sourceId,
DescribeBehaviorFallbackSummary(world, ship, behaviorKind, failureReason),
failureReason!);
}
return CreateIdlePlan(
ship,
AiPlanSourceKind.DefaultBehavior,
sourceId,
DescribeBehaviorFallbackSummary(world, ship, behaviorKind, failureReason));
}
private static bool IsBehaviorBlockingFailure(string behaviorKind, string? failureReason) => failureReason switch
{
"missing-item" => true,
"no-suitable-buyer" => true,
"no-mineable-node" when string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal) => true,
_ => false,
};
private static string DescribeBehaviorFallbackSummary(SimulationWorld world, ShipRuntime ship, string behaviorKind, string? failureReason)
{
var assignment = ResolveAssignment(world, ship);
var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
var itemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId ?? "resource";
return failureReason switch
{
"missing-item" => "No mining ware configured",
"no-suitable-buyer" => $"No buyer for {itemId} in {systemId}",
"no-mineable-node" when string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal) => $"No {itemId} to mine in {systemId}",
"no-mineable-node" => "No mineable node",
"no-home-station" => "No home station",
"no-trade-route" => "No trade route",
"no-fleet-to-supply" => "No fleet to supply",
"station-missing" => "No station to dock",
"target-ship-missing" => "No ship to follow",
"target-missing" => "No object target",
"no-salvage-target" => "No salvage target",
"no-repeat-orders" => "No repeat orders",
"no-construction-site" => "No construction site",
"support-station-missing" => "No support station",
_ => "Idle",
};
}
private ShipPlanRuntime BuildTradePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, TradeRoutePlan route, string summary)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.TradeRoute,
summary,
[
CreateStep("step-acquire", "acquire-cargo", $"Load {route.ItemId} at {route.SourceStation.Label}",
[
CreateSubTask("sub-acquire-travel", ShipTaskKinds.Travel, $"Travel to {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(route.SourceStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-acquire-dock", ShipTaskKinds.Dock, $"Dock at {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 4f, 0f),
CreateSubTask("sub-acquire-load", ShipTaskKinds.LoadCargo, $"Load {route.ItemId}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
CreateSubTask("sub-acquire-undock", ShipTaskKinds.Undock, $"Undock from {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(4f, 12f), 0f)
]),
CreateStep("step-deliver", "deliver-cargo", $"Deliver {route.ItemId} to {route.DestinationStation.Label}",
[
CreateSubTask("sub-route-travel", ShipTaskKinds.Travel, $"Travel to {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(route.DestinationStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-route-dock", ShipTaskKinds.Dock, $"Dock at {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 4f, 0f),
CreateSubTask("sub-route-unload", ShipTaskKinds.UnloadCargo, $"Unload {route.ItemId}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
CreateSubTask("sub-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f)
])
]);
}
private ShipPlanRuntime BuildFleetSupplyPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, FleetSupplyPlan plan)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
SupplyFleet,
plan.Summary,
[
CreateStep("step-fleet-acquire", "acquire-cargo", $"Load {plan.ItemId} at {plan.SourceStation.Label}",
[
CreateSubTask("sub-fleet-source-travel", ShipTaskKinds.Travel, $"Travel to {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, MathF.Max(plan.SourceStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-fleet-source-dock", ShipTaskKinds.Dock, $"Dock at {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 4f, 0f),
CreateSubTask("sub-fleet-source-load", ShipTaskKinds.LoadCargo, $"Load {plan.ItemId}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 0f, plan.Amount, itemId: plan.ItemId),
CreateSubTask("sub-fleet-source-undock", ShipTaskKinds.Undock, $"Undock from {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 12f, 0f),
]),
CreateStep("step-fleet-deliver", "deliver-fleet", $"Deliver {plan.ItemId} to {plan.TargetShip.Definition.Name}",
[
CreateSubTask("sub-fleet-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Name}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, 6f),
CreateSubTask("sub-fleet-transfer", ShipTaskKinds.TransferCargoToShip, $"Transfer {plan.ItemId}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, plan.Amount, itemId: plan.ItemId),
])
]);
}
private ShipPlanRuntime BuildConstructionPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ConstructionSiteRuntime site, StationRuntime supportStation, string summary)
{
var targetPosition = site.StationId is null ? supportStation.Position : supportStation.Position;
return CreatePlan(
ship,
sourceKind,
sourceId,
"construction-support",
summary,
[
CreateStep("step-construction-deliver", "deliver-materials", $"Deliver materials to {site.Id}",
[
CreateSubTask("sub-construction-travel", ShipTaskKinds.Travel, $"Travel to support station {supportStation.Label}", supportStation.SystemId, targetPosition, supportStation.Id, MathF.Max(supportStation.Radius + 18f, 18f), 0f),
CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f)
]),
CreateStep("step-construction-build", "build-site", $"Build {site.Id}",
[
CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f)
])
]);
}
private ShipPlanRuntime BuildAttackPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string? targetSystemId, string summary)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.AttackTarget,
summary,
[
CreateStep("step-attack", ShipOrderKinds.AttackTarget, summary,
[
CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? ship.SystemId, ship.Position, targetEntityId, 26f, 0f)
])
]);
}
private ShipPlanRuntime BuildDockAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime station, float waitSeconds, string summary)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.DockAndWait,
summary,
[
CreateStep("step-dock-wait-travel", "travel", $"Travel to {station.Label}",
[
CreateSubTask("sub-dock-wait-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(station.Radius + 12f, 12f), 0f),
CreateSubTask("sub-dock-wait-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f),
CreateSubTask("sub-dock-wait-hold", ShipTaskKinds.HoldPosition, $"Wait at {station.Label}", station.SystemId, station.Position, station.Id, 0f, waitSeconds),
])
]);
}
private ShipPlanRuntime BuildFlyAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, float waitSeconds, string summary)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.FlyAndWait,
summary,
[
CreateStep("step-fly-wait", ShipOrderKinds.FlyAndWait, summary,
[
CreateSubTask("sub-fly-wait-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, null, 6f, 0f),
CreateSubTask("sub-fly-wait-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, null, 0f, waitSeconds),
])
]);
}
private ShipPlanRuntime BuildFlyToObjectPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.FlyToObject,
summary,
[
CreateStep("step-fly-object", ShipOrderKinds.FlyToObject, summary,
[
CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f),
CreateSubTask("sub-fly-object-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, targetEntityId, 0f, MathF.Max(1f, ship.DefaultBehavior.WaitSeconds)),
])
]);
}
private ShipPlanRuntime BuildFollowShipPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ShipRuntime targetShip, float radius, float durationSeconds, string summary)
{
return BuildFollowPlan(ship, sourceKind, sourceId, targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary);
}
private ShipPlanRuntime BuildFollowPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.FollowShip,
summary,
[
CreateStep("step-follow", "follow-target", summary,
[
CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds),
])
]);
}
private ShipPlanRuntime CreateIdlePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary)
{
return CreatePlan(
ship,
sourceKind,
sourceId,
Idle,
summary,
[
CreateStep("step-idle", ShipOrderKinds.HoldPosition, summary,
[
CreateSubTask("sub-idle", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 1.5f)
])
]);
}
private ShipPlanRuntime CreateBlockedPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary, string blockingReason)
{
var subTask = CreateSubTask("sub-blocked", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 0f);
subTask.Status = WorkStatus.Blocked;
subTask.BlockingReason = blockingReason;
var step = CreateStep("step-blocked", "blocked", summary, [subTask]);
step.Status = AiPlanStepStatus.Blocked;
step.BlockingReason = blockingReason;
var plan = CreatePlan(ship, sourceKind, sourceId, "blocked", summary, [step]);
plan.Status = AiPlanStatus.Blocked;
plan.FailureReason = blockingReason;
return plan;
}
private static ShipPlanRuntime CreatePlan(
ShipRuntime ship,
AiPlanSourceKind sourceKind,
string sourceId,
string kind,
string summary,
IReadOnlyList<ShipPlanStepRuntime> steps)
{
var plan = new ShipPlanRuntime
{
Id = $"plan-{ship.Id}-{Guid.NewGuid():N}",
SourceKind = sourceKind,
SourceId = sourceId,
Kind = kind,
Summary = summary,
};
plan.Steps.AddRange(steps);
return plan;
}
private static ShipPlanStepRuntime CreateStep(string id, string kind, string summary, IReadOnlyList<ShipSubTaskRuntime> subTasks)
{
var step = new ShipPlanStepRuntime
{
Id = id,
Kind = kind,
Summary = summary,
};
step.SubTasks.AddRange(subTasks);
return step;
}
private static ShipSubTaskRuntime CreateSubTask(
string id,
string kind,
string summary,
string targetSystemId,
Vector3 targetPosition,
string? targetEntityId,
float threshold,
float amount,
string? itemId = null,
string? moduleId = null,
string? targetNodeId = null) =>
new()
{
Id = id,
Kind = kind,
Summary = summary,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
TargetEntityId = targetEntityId,
TargetNodeId = targetNodeId,
ItemId = itemId,
ModuleId = moduleId,
Threshold = threshold,
Amount = amount,
};
}

View File

@@ -0,0 +1,461 @@
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
namespace SpaceGame.Api.Ships.AI;
public sealed partial class ShipAiService
{
private ShipPlanRuntime? BuildEmergencyPlan(SimulationWorld world, ShipRuntime ship)
{
var policy = ResolvePolicy(world, ship.PolicySetId);
if (policy is null)
{
return null;
}
var hullRatio = ship.Definition.Hull <= 0.01f ? 1f : ship.Health / ship.Definition.Hull;
if (hullRatio > policy.FleeHullRatio)
{
return null;
}
var hostileNearby = world.Ships.Any(candidate =>
candidate.Health > 0f &&
candidate.FactionId != ship.FactionId &&
candidate.SystemId == ship.SystemId &&
candidate.Position.DistanceTo(ship.Position) <= 200f);
if (!hostileNearby)
{
return null;
}
var safeStation = world.Stations
.Where(station => station.FactionId == ship.FactionId)
.OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0)
.ThenBy(station => station.Position.DistanceTo(ship.Position))
.FirstOrDefault();
var plan = new ShipPlanRuntime
{
Id = $"plan-{ship.Id}-safety-{Guid.NewGuid():N}",
SourceKind = AiPlanSourceKind.Rule,
SourceId = ShipOrderKinds.Flee,
Kind = "safety-flee",
Summary = "Emergency retreat",
};
if (safeStation is null)
{
plan.Steps.Add(CreateStep("step-flee-hold", ShipOrderKinds.HoldPosition, "Hold position away from hostiles",
[
CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f)
]));
return plan;
}
plan.Steps.Add(CreateStep("step-flee-travel", "travel", "Travel to safe station",
[
CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), 0f)
]));
plan.Steps.Add(CreateStep("step-flee-dock", "dock", "Dock at safe station",
[
CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f)
]));
return plan;
}
private ShipPlanRuntime? BuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
return order.Kind switch
{
var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMovePlan(ship, order),
var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.DockAndWait, StringComparison.Ordinal) => BuildDockAndWaitOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.FlyAndWait, StringComparison.Ordinal) => BuildFlyAndWaitOrderPlan(ship, order),
var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.MineLocal, StringComparison.Ordinal) => BuildMineLocalOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliverRun, StringComparison.Ordinal) => BuildMineAndDeliverRunOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.SellMinedCargo, StringComparison.Ordinal) => BuildSellMinedCargoOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.SupplyFleetRun, StringComparison.Ordinal) => BuildSupplyFleetOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.SalvageRun, StringComparison.Ordinal) => BuildAutoSalvageOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderPlan(world, ship, order),
var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldOrderPlan(ship, order),
_ => null,
};
}
private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship)
{
var assignment = ResolveAssignment(world, ship);
return assignment is null
? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind)
: (assignment.BehaviorKind, assignment.ObjectiveId);
}
private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order)
{
var targetSystemId = order.TargetSystemId ?? ship.SystemId;
var targetPosition = order.TargetPosition ?? ship.Position;
return CreatePlan(
ship,
AiPlanSourceKind.Order,
order.Id,
ShipOrderKinds.Move,
order.Label ?? "Move order",
[
CreateStep("step-move", "travel", order.Label ?? "Travel",
[
CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, 0f, 0f)
])
]);
}
private ShipPlanRuntime? BuildDockOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId);
if (station is null)
{
order.FailureReason = "station-missing";
return null;
}
return CreatePlan(
ship,
AiPlanSourceKind.Order,
order.Id,
"dock-at-station",
order.Label ?? $"Dock at {station.Label}",
[
CreateStep("step-dock-travel", "travel", $"Travel to {station.Label}",
[
CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(balance.ArrivalThreshold, station.Radius + 12f), 0f)
]),
CreateStep("step-dock", "dock", $"Dock at {station.Label}",
[
CreateSubTask("sub-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f)
])
]);
}
private ShipPlanRuntime? BuildTradeOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
if (order.SourceStationId is null || order.DestinationStationId is null || order.ItemId is null)
{
order.FailureReason = "trade-order-incomplete";
return null;
}
var route = ResolveTradeRoute(world, order.ItemId, order.SourceStationId, order.DestinationStationId);
if (route is null)
{
order.FailureReason = "trade-route-missing";
return null;
}
return BuildTradePlan(ship, AiPlanSourceKind.Order, order.Id, route, order.Label ?? route.Summary);
}
private ShipPlanRuntime? BuildMineOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var systemId = order.TargetSystemId ?? ship.SystemId;
var itemId = order.ItemId;
if (string.IsNullOrWhiteSpace(itemId))
{
order.FailureReason = "mine-order-item-missing";
return null;
}
var node = ResolveNode(world, order.NodeId);
if (node is not null)
{
if (!string.Equals(node.SystemId, systemId, StringComparison.Ordinal))
{
order.FailureReason = "mine-order-node-system-mismatch";
return null;
}
if (!string.Equals(node.ItemId, itemId, StringComparison.Ordinal))
{
order.FailureReason = "mine-order-node-item-mismatch";
return null;
}
}
else
{
node = SelectLocalMiningNode(world, ship, systemId, itemId);
}
if (node is null)
{
order.FailureReason = "mine-order-node-missing";
return null;
}
return BuildLocalMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {itemId} in {systemId}");
}
private ShipPlanRuntime? BuildMineLocalOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var node = ResolveNode(world, order.NodeId);
if (node is null)
{
order.FailureReason = "mine-order-incomplete";
return null;
}
return BuildLocalMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {node.ItemId}");
}
private ShipPlanRuntime? BuildMineAndDeliverRunOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var node = ResolveNode(world, order.NodeId);
var buyer = ResolveStation(world, order.DestinationStationId);
if (node is null || buyer is null)
{
order.FailureReason = "mine-and-deliver-order-incomplete";
return null;
}
return BuildMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, buyer, order.Label ?? $"Mine {node.ItemId} for {buyer.Label}");
}
private ShipPlanRuntime? BuildSellMinedCargoOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var buyer = ResolveStation(world, order.DestinationStationId ?? order.TargetEntityId);
if (buyer is null || string.IsNullOrWhiteSpace(order.ItemId))
{
order.FailureReason = "sell-order-incomplete";
return null;
}
return BuildLocalMiningDeliveryPlan(ship, AiPlanSourceKind.Order, order.Id, buyer, order.ItemId, order.Label ?? $"Sell {order.ItemId}");
}
private ShipPlanRuntime? BuildAutoSalvageOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var homeStation = ResolveStation(world, order.SourceStationId ?? ship.DefaultBehavior.HomeStationId);
var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.RemainingAmount > 0.01f);
if (homeStation is null || wreck is null)
{
order.FailureReason = "salvage-order-incomplete";
return null;
}
var approach = GetFormationPosition(wreck.Position, ship.Id, MathF.Max(8f, order.Radius > 0f ? order.Radius : ship.DefaultBehavior.Radius * 0.25f));
return CreatePlan(
ship,
AiPlanSourceKind.Order,
order.Id,
AutoSalvage,
order.Label ?? $"Salvage {wreck.ItemId}",
[
CreateStep("step-salvage-collect", "salvage", $"Salvage {wreck.ItemId}",
[
CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {wreck.Id}", wreck.SystemId, approach, wreck.Id, 8f, 0f),
CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {wreck.ItemId}", wreck.SystemId, approach, wreck.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
]),
CreateStep("step-salvage-deliver", "deliver-salvage", $"Deliver salvage to {homeStation.Label}",
[
CreateSubTask("sub-salvage-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-salvage-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
CreateSubTask("sub-salvage-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload salvage at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f),
])
]);
}
private ShipPlanRuntime? BuildSupplyFleetOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var sourceStation = ResolveStation(world, order.SourceStationId);
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
if (sourceStation is null || targetShip is null || string.IsNullOrWhiteSpace(order.ItemId))
{
order.FailureReason = "supply-fleet-order-incomplete";
return null;
}
var amount = MathF.Min(
MathF.Max(10f, ship.Definition.GetTotalCargoCapacity() * 0.5f),
GetInventoryAmount(sourceStation.Inventory, order.ItemId));
if (amount <= 0.01f)
{
order.FailureReason = "supply-item-unavailable";
return null;
}
var plan = new FleetSupplyPlan(
sourceStation,
targetShip,
order.ItemId,
amount,
MathF.Max(16f, order.Radius),
order.Label ?? $"Supply {targetShip.Definition.Name} with {order.ItemId}");
return BuildFleetSupplyPlan(ship, AiPlanSourceKind.Order, order.Id, plan);
}
private ShipPlanRuntime? BuildBuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (order.ConstructionSiteId ?? order.TargetEntityId));
if (site is null)
{
order.FailureReason = "construction-site-missing";
return null;
}
var supportStation = ResolveSupportStation(world, ship, site);
if (supportStation is null)
{
order.FailureReason = "support-station-missing";
return null;
}
return BuildConstructionPlan(ship, AiPlanSourceKind.Order, order.Id, site, supportStation, order.Label ?? $"Build {site.BlueprintId}");
}
private ShipPlanRuntime? BuildAttackOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var targetId = order.TargetEntityId;
if (targetId is null)
{
order.FailureReason = "attack-target-missing";
return null;
}
return BuildAttackPlan(ship, AiPlanSourceKind.Order, order.Id, targetId, order.TargetSystemId, order.Label ?? "Attack target");
}
private ShipPlanRuntime BuildHoldOrderPlan(ShipRuntime ship, ShipOrderRuntime order)
{
return CreatePlan(
ship,
AiPlanSourceKind.Order,
order.Id,
ShipOrderKinds.HoldPosition,
order.Label ?? "Hold position",
[
CreateStep("step-hold", ShipOrderKinds.HoldPosition, order.Label ?? "Hold position",
[
CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f)
])
]);
}
private ShipPlanRuntime? BuildDockAndWaitOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId);
if (station is null)
{
order.FailureReason = "station-missing";
return null;
}
return BuildDockAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, station, MathF.Max(1f, order.WaitSeconds), order.Label ?? $"Dock and wait at {station.Label}");
}
private ShipPlanRuntime BuildFlyAndWaitOrderPlan(ShipRuntime ship, ShipOrderRuntime order)
{
var systemId = order.TargetSystemId ?? ship.SystemId;
var targetPosition = order.TargetPosition ?? ship.Position;
return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, systemId, targetPosition, MathF.Max(1f, order.WaitSeconds), order.Label ?? "Fly and wait");
}
private ShipPlanRuntime? BuildFlyToObjectOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var targetEntityId = order.TargetEntityId;
if (targetEntityId is null)
{
order.FailureReason = "target-missing";
return null;
}
var objectTarget = ResolveObjectTarget(world, targetEntityId);
if (objectTarget is null)
{
order.FailureReason = "target-missing";
return null;
}
return BuildFlyToObjectPlan(ship, AiPlanSourceKind.Order, order.Id, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}");
}
private ShipPlanRuntime? BuildFollowShipOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
{
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
if (targetShip is null)
{
order.FailureReason = "target-ship-missing";
return null;
}
return BuildFollowShipPlan(ship, AiPlanSourceKind.Order, order.Id, targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Name}");
}
private ShipPlanRuntime BuildMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, StationRuntime homeStation, string summary)
{
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.MineAndDeliver,
summary,
[
CreateStep("step-mine", "mine", $"Mine {node.ItemId}",
[
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} node", node.SystemId, extractionPosition, node.Id, 8f, 0f),
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity())
]),
CreateStep("step-deliver", "deliver", $"Deliver {node.ItemId} to {homeStation.Label}",
[
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity()),
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f)
])
]);
}
private ShipPlanRuntime BuildLocalMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, string summary)
{
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.MineLocal,
summary,
[
CreateStep("step-mine", "mine", $"Mine {node.ItemId}",
[
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} node", node.SystemId, extractionPosition, node.Id, 8f, 0f),
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId)
])
]);
}
private ShipPlanRuntime BuildLocalMiningDeliveryPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime buyer, string itemId, string summary)
{
var amount = MathF.Max(1f, GetInventoryAmount(ship.Inventory, itemId));
return CreatePlan(
ship,
sourceKind,
sourceId,
ShipOrderKinds.SellMinedCargo,
summary,
[
CreateStep("step-deliver", "deliver", $"Deliver {itemId} to {buyer.Label}",
[
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(buyer.Radius + 12f, 12f), 0f),
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 4f, 0f),
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload {itemId} at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 0f, amount, itemId: itemId),
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(4f, 12f), 0f)
])
]);
}
}

View File

@@ -0,0 +1,216 @@
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
namespace SpaceGame.Api.Ships.AI;
public sealed partial class ShipAiService
{
private const float WarpEngageDistanceKilometers = 250_000f;
private const float FrigateDps = 7f;
private const float DestroyerDps = 12f;
private const float CruiserDps = 18f;
private const float CapitalDps = 26f;
private readonly IBalanceService balance;
public ShipAiService(IBalanceService balance)
{
this.balance = balance;
}
internal void UpdateShip(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
if (ship.ReplanCooldownSeconds > 0f)
{
ship.ReplanCooldownSeconds = MathF.Max(0f, ship.ReplanCooldownSeconds - deltaSeconds);
}
var previousState = ship.State;
var previousPlanId = ship.ActivePlan?.Id;
var previousStepId = GetCurrentStep(ship.ActivePlan)?.Id;
EnsurePlan(world, ship, events);
ExecutePlan(world, ship, deltaSeconds, events);
TrackHistory(ship);
EmitStateEvents(ship, previousState, previousPlanId, previousStepId, events);
}
private void EnsurePlan(SimulationWorld world, ShipRuntime ship, ICollection<SimulationEventRecord> events)
{
var emergencyPlan = BuildEmergencyPlan(world, ship);
if (emergencyPlan is not null)
{
ship.LastReplanReason = "rule-safety";
ReplacePlan(ship, emergencyPlan, "rule-safety", events);
return;
}
SyncBehaviorOrders(world, ship);
var topOrder = GetTopOrder(ship);
if (topOrder is not null && topOrder.Status == OrderStatus.Queued)
{
topOrder.Status = OrderStatus.Active;
}
var desiredSourceKind = topOrder is null ? AiPlanSourceKind.DefaultBehavior : AiPlanSourceKind.Order;
var desiredSourceId = topOrder?.Id ?? ResolveBehaviorSource(world, ship).SourceId;
var currentPlan = ship.ActivePlan;
if (currentPlan is not null
&& currentPlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed and not AiPlanStatus.Interrupted
&& currentPlan.SourceKind == desiredSourceKind
&& string.Equals(currentPlan.SourceId, desiredSourceId, StringComparison.Ordinal)
&& !ship.NeedsReplan)
{
return;
}
if (ship.ReplanCooldownSeconds > 0f && currentPlan is null)
{
return;
}
ShipPlanRuntime? nextPlan = desiredSourceKind == AiPlanSourceKind.Order
? BuildOrderPlan(world, ship, topOrder!)
: BuildBehaviorFallbackPlan(world, ship);
if (nextPlan is null)
{
nextPlan = CreateIdlePlan(ship, desiredSourceKind, desiredSourceId, "No viable plan");
}
if (nextPlan.Kind != Idle)
{
ship.LastAccessFailureReason = null;
}
ReplacePlan(ship, nextPlan, "replanned", events);
}
private void ExecutePlan(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
var plan = ship.ActivePlan;
if (plan is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return;
}
if (plan.CurrentStepIndex >= plan.Steps.Count)
{
CompletePlan(ship, plan, events);
return;
}
plan.UpdatedAtUtc = DateTimeOffset.UtcNow;
var step = plan.Steps[plan.CurrentStepIndex];
if (step.Status == AiPlanStepStatus.Planned)
{
step.Status = AiPlanStepStatus.Running;
}
if (step.CurrentSubTaskIndex >= step.SubTasks.Count)
{
CompleteStep(plan, step);
return;
}
var subTask = step.SubTasks[step.CurrentSubTaskIndex];
if (subTask.Status == WorkStatus.Pending)
{
subTask.Status = WorkStatus.Active;
}
else if (subTask.Status == WorkStatus.Blocked)
{
step.Status = AiPlanStepStatus.Blocked;
step.BlockingReason = subTask.BlockingReason;
plan.Status = AiPlanStatus.Blocked;
ship.State = ShipState.Blocked;
ship.TargetPosition = subTask.TargetPosition ?? ship.Position;
return;
}
plan.Status = AiPlanStatus.Running;
var outcome = UpdateSubTask(world, ship, step, subTask, deltaSeconds);
switch (outcome)
{
case SubTaskOutcome.Active:
step.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStepStatus.Blocked : AiPlanStepStatus.Running;
plan.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStatus.Blocked : AiPlanStatus.Running;
return;
case SubTaskOutcome.Completed:
subTask.Status = WorkStatus.Completed;
subTask.Progress = 1f;
step.CurrentSubTaskIndex += 1;
step.BlockingReason = null;
if (step.CurrentSubTaskIndex >= step.SubTasks.Count)
{
CompleteStep(plan, step);
}
return;
case SubTaskOutcome.Failed:
subTask.Status = WorkStatus.Failed;
step.Status = AiPlanStepStatus.Failed;
plan.Status = AiPlanStatus.Failed;
plan.FailureReason = subTask.BlockingReason ?? "subtask-failed";
ship.NeedsReplan = true;
ship.ReplanCooldownSeconds = 0.5f;
ship.LastReplanReason = plan.FailureReason;
return;
}
}
private static void CompleteStep(ShipPlanRuntime plan, ShipPlanStepRuntime step)
{
step.Status = AiPlanStepStatus.Completed;
step.BlockingReason = null;
plan.CurrentStepIndex += 1;
if (plan.CurrentStepIndex >= plan.Steps.Count)
{
plan.Status = AiPlanStatus.Completed;
}
}
private static void CompletePlan(ShipRuntime ship, ShipPlanRuntime plan, ICollection<SimulationEventRecord> events)
{
plan.Status = AiPlanStatus.Completed;
var completedOrder = plan.SourceKind == AiPlanSourceKind.Order
? ship.OrderQueue.FirstOrDefault(order => order.Id == plan.SourceId)
: null;
if (completedOrder is not null)
{
completedOrder.Status = OrderStatus.Completed;
ship.OrderQueue.RemoveAll(order => order.Id == completedOrder.Id);
if (completedOrder.SourceKind == ShipOrderSourceKind.Behavior
&& string.Equals(completedOrder.SourceId, RepeatOrders, StringComparison.Ordinal)
&& ship.DefaultBehavior.RepeatOrders.Count > 0)
{
ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count;
}
}
ship.ActivePlan = null;
ship.NeedsReplan = true;
ship.ReplanCooldownSeconds = 0.25f;
ship.LastReplanReason = "plan-completed";
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-completed", $"{ship.Definition.Name} completed {plan.Kind}.", DateTimeOffset.UtcNow));
}
private void ReplacePlan(ShipRuntime ship, ShipPlanRuntime nextPlan, string reason, ICollection<SimulationEventRecord> events)
{
if (ship.ActivePlan is not null && ship.ActivePlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed)
{
ship.ActivePlan.Status = AiPlanStatus.Interrupted;
ship.ActivePlan.InterruptReason = reason;
}
ship.ActivePlan = nextPlan;
ship.NeedsReplan = false;
ship.ReplanCooldownSeconds = 0f;
ship.LastReplanReason = reason;
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-updated", $"{ship.Definition.Name} planned {nextPlan.Kind}.", DateTimeOffset.UtcNow));
}
}

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

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

@@ -42,7 +42,7 @@ public sealed record ShipDefaultBehaviorCommandRequest(
string? HomeStationId,
string? AreaSystemId,
string? TargetEntityId,
string? PreferredItemId,
string? ItemId,
string? PreferredNodeId,
string? PreferredConstructionSiteId,
string? PreferredModuleId,

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,
@@ -53,7 +55,7 @@ public sealed record DefaultBehaviorSnapshot(
string? HomeStationId,
string? AreaSystemId,
string? TargetEntityId,
string? PreferredItemId,
string? ItemId,
string? PreferredNodeId,
string? PreferredConstructionSiteId,
string? PreferredModuleId,
@@ -129,9 +131,9 @@ public sealed record ShipPlanSnapshot(
public sealed record ShipSnapshot(
string Id,
string Label,
string Kind,
string Class,
string Name,
string Purpose,
string Type,
string SystemId,
Vector3Dto LocalPosition,
Vector3Dto LocalVelocity,
@@ -164,9 +166,9 @@ public sealed record ShipSnapshot(
public sealed record ShipDelta(
string Id,
string Label,
string Kind,
string Class,
string Name,
string Purpose,
string Type,
string SystemId,
Vector3Dto LocalPosition,
Vector3Dto LocalVelocity,

View File

@@ -47,6 +47,8 @@ public sealed class ShipOrderRuntime
{
public required string Id { get; init; }
public required string Kind { get; init; }
public required ShipOrderSourceKind SourceKind { get; init; }
public required string SourceId { get; init; }
public OrderStatus Status { get; set; } = OrderStatus.Queued;
public int Priority { get; set; }
public bool InterruptCurrentPlan { get; set; } = true;
@@ -75,7 +77,7 @@ public sealed class DefaultBehaviorRuntime
public string? HomeStationId { get; set; }
public string? AreaSystemId { get; set; }
public string? TargetEntityId { get; set; }
public string? PreferredItemId { get; set; }
public string? ItemId { get; set; }
public string? PreferredNodeId { get; set; }
public string? PreferredConstructionSiteId { get; set; }
public string? PreferredModuleId { get; set; }

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +0,0 @@
namespace SpaceGame.Api.Ships.Simulation;
internal static class ShipBootstrapPolicy
{
internal static ShipSkillProfileRuntime CreateSkills(ShipDefinition definition)
{
return definition.Kind switch
{
"transport" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 4, Mining = 1, Combat = 1, Construction = 1 },
"construction" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 1, Combat = 1, Construction = 4 },
"military" => new ShipSkillProfileRuntime { Navigation = 4, Trade = 1, Mining = 1, Combat = 4, Construction = 1 },
_ when SpaceGame.Api.Universe.Scenario.LoaderSupport.HasCapabilities(definition, "mining") => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 4, Combat = 1, Construction = 1 },
_ => new ShipSkillProfileRuntime { Navigation = 3, Trade = 2, Mining = 1, Combat = 1, Construction = 1 },
};
}
}

View File

@@ -3,6 +3,7 @@ namespace SpaceGame.Api.Simulation.Core;
internal sealed class SimulationEngine
{
private readonly IBalanceService _balance;
private readonly IPlayerStateStore _playerStateStore;
private readonly OrbitalSimulationOptions _orbitalSimulation;
private readonly OrbitalStateUpdater _orbitalStateUpdater;
private readonly InfrastructureSimulationService _infrastructureSimulation;
@@ -14,9 +15,10 @@ internal sealed class SimulationEngine
private readonly ShipAiService _shipAi;
private readonly SimulationProjectionService _projection;
internal SimulationEngine(OrbitalSimulationOptions orbitalSimulation, IBalanceService balance)
internal SimulationEngine(OrbitalSimulationOptions orbitalSimulation, IBalanceService balance, IPlayerStateStore playerStateStore)
{
_balance = balance;
_playerStateStore = playerStateStore;
_orbitalSimulation = orbitalSimulation;
_orbitalStateUpdater = new OrbitalStateUpdater(_orbitalSimulation);
_infrastructureSimulation = new InfrastructureSimulationService();
@@ -42,8 +44,8 @@ internal sealed class SimulationEngine
_infrastructureSimulation.UpdateClaims(world, events);
_infrastructureSimulation.UpdateConstructionSites(world, events);
_geopolitics.Update(world, simulationDeltaSeconds, events);
_commanderPlanning.UpdateCommanders(world, simulationDeltaSeconds, events);
_playerFaction.Update(world, simulationDeltaSeconds, events);
_commanderPlanning.UpdateCommanders(world, _playerStateStore, simulationDeltaSeconds, events);
_playerFaction.Update(world, _playerStateStore, simulationDeltaSeconds, events);
_stationLifecycle.UpdateStations(world, simulationDeltaSeconds, events);
foreach (var ship in world.Ships.ToList())
@@ -76,7 +78,7 @@ internal sealed class SimulationEngine
{
foreach (var ship in world.Ships.Where(candidate => candidate.Health <= 0f).ToList())
{
CreateWreck(world, "ship", ship.Id, ship.SystemId, ship.Position, ship.Definition.CargoCapacity + (ship.Definition.MaxHealth * 0.08f));
CreateWreck(world, "ship", ship.Id, ship.SystemId, ship.Position, ship.Definition.GetTotalCargoCapacity() + (ship.Definition.Hull * 0.08f));
world.Ships.Remove(ship);
if (ship.DockedStationId is not null && world.Stations.FirstOrDefault(station => station.Id == ship.DockedStationId) is { } dockedStation)
{
@@ -94,7 +96,7 @@ internal sealed class SimulationEngine
commander.IsAlive = false;
}
events.Add(new SimulationEventRecord("ship", ship.Id, "destroyed", $"{ship.Definition.Label} was destroyed.", DateTimeOffset.UtcNow));
events.Add(new SimulationEventRecord("ship", ship.Id, "destroyed", $"{ship.Definition.Name} was destroyed.", DateTimeOffset.UtcNow));
}
foreach (var station in world.Stations.Where(candidate => candidate.Health <= 0f).ToList())

View File

@@ -32,7 +32,6 @@ internal sealed class SimulationProjectionService
BuildPolicyDeltas(world),
BuildShipDeltas(world),
BuildFactionDeltas(world),
BuildPlayerFactionDelta(world),
BuildGeopoliticsDelta(world));
public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence)
@@ -177,9 +176,9 @@ internal sealed class SimulationProjectionService
policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(),
world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot(
ship.Id,
ship.Label,
ship.Kind,
ship.Class,
ship.Name,
ship.Purpose,
ship.Type,
ship.SystemId,
ship.LocalPosition,
ship.LocalVelocity,
@@ -225,7 +224,6 @@ internal sealed class SimulationProjectionService
faction.StrategicState,
faction.DecisionLog,
faction.Commanders)).ToList(),
ToPlayerFactionSnapshot(world.PlayerFaction),
ToGeopoliticalStateSnapshot(world.Geopolitics));
}
@@ -276,11 +274,6 @@ internal sealed class SimulationProjectionService
faction.LastDeltaSignature = BuildFactionSignature(faction, FindFactionCommander(world, faction.Id));
}
if (world.PlayerFaction is not null)
{
world.PlayerFaction.LastDeltaSignature = BuildPlayerFactionSignature(world.PlayerFaction);
}
if (world.Geopolitics is not null)
{
world.Geopolitics.LastDeltaSignature = BuildGeopoliticalSignature(world.Geopolitics);
@@ -450,23 +443,6 @@ internal sealed class SimulationProjectionService
return deltas;
}
private static PlayerFactionSnapshot? BuildPlayerFactionDelta(SimulationWorld world)
{
if (world.PlayerFaction is null)
{
return null;
}
var signature = BuildPlayerFactionSignature(world.PlayerFaction);
if (signature == world.PlayerFaction.LastDeltaSignature)
{
return null;
}
world.PlayerFaction.LastDeltaSignature = signature;
return ToPlayerFactionSnapshot(world.PlayerFaction);
}
private static GeopoliticalStateSnapshot? BuildGeopoliticsDelta(SimulationWorld world)
{
if (world.Geopolitics is null)
@@ -544,11 +520,13 @@ internal sealed class SimulationProjectionService
ship.TargetPosition.Z.ToString("0.###"),
ship.State.ToContractValue(),
string.Join(",", ship.OrderQueue
.OrderByDescending(order => order.Priority)
.OrderByDescending(GetOrderSourcePriority)
.ThenByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => $"{order.Id}:{order.Kind}:{order.Status.ToContractValue()}:{order.Priority}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")),
.Select(order => $"{order.Id}:{order.Kind}:{order.SourceKind.ToContractValue()}:{order.SourceId}:{order.Status.ToContractValue()}:{order.Priority}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")),
ship.DefaultBehavior.Kind,
ship.DefaultBehavior.TargetEntityId ?? "none",
ship.DefaultBehavior.ItemId ?? "none",
ship.DefaultBehavior.TargetPosition?.X.ToString("0.###") ?? "none",
ship.DefaultBehavior.TargetPosition?.Y.ToString("0.###") ?? "none",
ship.DefaultBehavior.TargetPosition?.Z.ToString("0.###") ?? "none",
@@ -642,59 +620,6 @@ internal sealed class SimulationProjectionService
return $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}|{assignmentSig}|{strategicSig}|{doctrineSig}|{decisionSig}|{theaterSig}|{campaignSig}|{objectiveSig}|{reservationSig}|{productionSig}";
}
private static string BuildPlayerFactionSignature(PlayerFactionRuntime player)
{
var intentSig = $"{player.StrategicIntent.StrategicPosture}:{player.StrategicIntent.EconomicPosture}:{player.StrategicIntent.MilitaryPosture}:{player.StrategicIntent.LogisticsPosture}:{player.StrategicIntent.DesiredReserveRatio:0.###}";
var registrySig = string.Join("|",
player.AssetRegistry.ShipIds.Count,
player.AssetRegistry.StationIds.Count,
player.AssetRegistry.CommanderIds.Count,
player.AssetRegistry.FleetIds.Count,
player.AssetRegistry.TaskForceIds.Count,
player.AssetRegistry.StationGroupIds.Count,
player.AssetRegistry.EconomicRegionIds.Count,
player.AssetRegistry.FrontIds.Count,
player.AssetRegistry.ReserveIds.Count);
var orgSig = string.Join("|",
player.Fleets.Count,
player.TaskForces.Count,
player.StationGroups.Count,
player.EconomicRegions.Count,
player.Fronts.Count,
player.Reserves.Count,
player.Policies.Count,
player.AutomationPolicies.Count,
player.ReinforcementPolicies.Count,
player.ProductionPrograms.Count,
player.Directives.Count,
player.Assignments.Count,
player.Alerts.Count);
var policySig = string.Join(";",
player.Policies.OrderBy(policy => policy.Id, StringComparer.Ordinal)
.Select(policy => $"{policy.Id}:{policy.ScopeKind}:{policy.ScopeId}:{policy.PolicySetId}:{policy.TradeAccessPolicy}:{policy.DockingAccessPolicy}:{policy.ConstructionAccessPolicy}:{policy.OperationalRangePolicy}:{policy.CombatEngagementPolicy}:{policy.AvoidHostileSystems}:{policy.FleeHullRatio:0.###}:{policy.UpdatedAtUtc.UtcTicks}"));
var automationSig = string.Join(";",
player.AutomationPolicies.OrderBy(policy => policy.Id, StringComparer.Ordinal)
.Select(policy => $"{policy.Id}:{policy.ScopeKind}:{policy.ScopeId}:{policy.Enabled}:{policy.BehaviorKind}:{policy.UseOrders}:{policy.StagingOrderKind}:{policy.MaxSystemRange}:{policy.KnownStationsOnly}:{policy.Radius:0.###}:{policy.WaitSeconds:0.###}:{policy.PreferredItemId}:{policy.UpdatedAtUtc.UtcTicks}"));
var directiveSig = string.Join(";",
player.Directives.OrderBy(directive => directive.Id, StringComparer.Ordinal)
.Select(directive => $"{directive.Id}:{directive.ScopeKind}:{directive.ScopeId}:{directive.Kind}:{directive.BehaviorKind}:{directive.UseOrders}:{directive.StagingOrderKind}:{directive.TargetEntityId}:{directive.TargetSystemId}:{directive.ItemId}:{directive.Priority}:{directive.UpdatedAtUtc.UtcTicks}"));
var assignmentSig = string.Join(";",
player.Assignments.OrderBy(assignment => assignment.Id, StringComparer.Ordinal)
.Select(assignment => $"{assignment.Id}:{assignment.AssetKind}:{assignment.AssetId}:{assignment.FleetId}:{assignment.TaskForceId}:{assignment.StationGroupId}:{assignment.EconomicRegionId}:{assignment.FrontId}:{assignment.ReserveId}:{assignment.DirectiveId}:{assignment.PolicyId}:{assignment.AutomationPolicyId}:{assignment.Role}:{assignment.Status}:{assignment.UpdatedAtUtc.UtcTicks}"));
var decisionSig = string.Join(",", player.DecisionLog.Select(entry => entry.Id));
var orgDetailSig = string.Join(";",
player.Fleets.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"fleet:{entry.Id}:{entry.FrontId}:{entry.HomeSystemId}:{entry.HomeStationId}:{entry.CommanderId}:{entry.UpdatedAtUtc.UtcTicks}")
.Concat(player.TaskForces.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"task-force:{entry.Id}:{entry.FleetId}:{entry.FrontId}:{entry.CommanderId}:{entry.UpdatedAtUtc.UtcTicks}"))
.Concat(player.StationGroups.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"station-group:{entry.Id}:{entry.EconomicRegionId}:{entry.UpdatedAtUtc.UtcTicks}"))
.Concat(player.EconomicRegions.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"economic-region:{entry.Id}:{entry.SharedEconomicRegionId}:{entry.Role}:{entry.UpdatedAtUtc.UtcTicks}"))
.Concat(player.Fronts.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"front:{entry.Id}:{entry.SharedFrontLineId}:{entry.TargetFactionId}:{entry.Priority:0.###}:{entry.UpdatedAtUtc.UtcTicks}"))
.Concat(player.Reserves.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"reserve:{entry.Id}:{entry.HomeSystemId}:{entry.UpdatedAtUtc.UtcTicks}")));
var alertSig = string.Join(";",
player.Alerts.OrderBy(alert => alert.Id, StringComparer.Ordinal)
.Select(alert => $"{alert.Id}:{alert.Kind}:{alert.Severity}:{alert.AssetKind}:{alert.AssetId}:{alert.RelatedDirectiveId}:{alert.Status}:{alert.CreatedAtUtc.UtcTicks}"));
return $"{player.SovereignFactionId}|{player.Status}|{intentSig}|{registrySig}|{orgSig}|{policySig}|{automationSig}|{directiveSig}|{assignmentSig}|{decisionSig}|{orgDetailSig}|{alertSig}";
}
private static string BuildGeopoliticalSignature(GeopoliticalStateRuntime state)
{
var diplomacySig = string.Join(";",
@@ -882,9 +807,9 @@ internal sealed class SimulationProjectionService
return new ShipDelta(
ship.Id,
ship.Definition.Label,
ship.Definition.Kind,
ship.Definition.Class,
ship.Definition.Name,
ship.Definition.Purpose.ToDataValue(),
ship.Definition.Type.ToDataValue(),
ship.SystemId,
ToDto(ship.Position),
ToDto(ship.Velocity),
@@ -906,7 +831,7 @@ internal sealed class SimulationProjectionService
ship.DockedStationId,
ship.CommanderId,
ship.PolicySetId,
ship.Definition.CargoCapacity,
ship.Definition.GetTotalCargoCapacity(),
ToShipTravelSpeed(ship).Speed,
ToShipTravelSpeed(ship).Unit,
@@ -936,11 +861,14 @@ internal sealed class SimulationProjectionService
private static IReadOnlyList<ShipOrderSnapshot> ToShipOrderSnapshots(ShipRuntime ship) =>
ship.OrderQueue
.OrderByDescending(order => order.Priority)
.OrderByDescending(GetOrderSourcePriority)
.ThenByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => new ShipOrderSnapshot(
order.Id,
order.Kind,
order.SourceKind.ToContractValue(),
order.SourceId,
order.Status.ToContractValue(),
order.Priority,
order.InterruptCurrentPlan,
@@ -962,6 +890,14 @@ internal sealed class SimulationProjectionService
order.FailureReason))
.ToList();
private static int GetOrderSourcePriority(ShipOrderRuntime order) => order.SourceKind switch
{
ShipOrderSourceKind.Player => 300,
ShipOrderSourceKind.Commander => 200,
ShipOrderSourceKind.Behavior => 100,
_ => 0,
};
private static DefaultBehaviorSnapshot ToDefaultBehaviorSnapshot(DefaultBehaviorRuntime behavior) =>
new(
behavior.Kind,
@@ -969,7 +905,7 @@ internal sealed class SimulationProjectionService
behavior.HomeStationId,
behavior.AreaSystemId,
behavior.TargetEntityId,
behavior.PreferredItemId,
behavior.ItemId,
behavior.PreferredNodeId,
behavior.PreferredConstructionSiteId,
behavior.PreferredModuleId,
@@ -1385,252 +1321,6 @@ internal sealed class SimulationProjectionService
entry.OccurredAtUtc))
.ToList();
private static PlayerFactionSnapshot? ToPlayerFactionSnapshot(PlayerFactionRuntime? player)
{
if (player is null)
{
return null;
}
return new PlayerFactionSnapshot(
player.Id,
player.Label,
player.SovereignFactionId,
player.Status,
player.CreatedAtUtc,
player.UpdatedAtUtc,
new PlayerAssetRegistrySnapshot(
player.AssetRegistry.ShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.CommanderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.ClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.ConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.PolicySetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.EconomicRegionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
player.AssetRegistry.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList()),
new PlayerStrategicIntentSnapshot(
player.StrategicIntent.StrategicPosture,
player.StrategicIntent.EconomicPosture,
player.StrategicIntent.MilitaryPosture,
player.StrategicIntent.LogisticsPosture,
player.StrategicIntent.DesiredReserveRatio,
player.StrategicIntent.AllowDelegatedCombatAutomation,
player.StrategicIntent.AllowDelegatedEconomicAutomation,
player.StrategicIntent.Notes),
player.Fleets.Select(fleet => new PlayerFleetSnapshot(
fleet.Id,
fleet.Label,
fleet.Status,
fleet.Role,
fleet.CommanderId,
fleet.FrontId,
fleet.HomeSystemId,
fleet.HomeStationId,
fleet.PolicyId,
fleet.AutomationPolicyId,
fleet.ReinforcementPolicyId,
fleet.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
fleet.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
fleet.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
fleet.UpdatedAtUtc)).ToList(),
player.TaskForces.Select(taskForce => new PlayerTaskForceSnapshot(
taskForce.Id,
taskForce.Label,
taskForce.Status,
taskForce.Role,
taskForce.FleetId,
taskForce.CommanderId,
taskForce.FrontId,
taskForce.PolicyId,
taskForce.AutomationPolicyId,
taskForce.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
taskForce.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
taskForce.UpdatedAtUtc)).ToList(),
player.StationGroups.Select(group => new PlayerStationGroupSnapshot(
group.Id,
group.Label,
group.Status,
group.Role,
group.EconomicRegionId,
group.PolicyId,
group.AutomationPolicyId,
group.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
group.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
group.FocusItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
group.UpdatedAtUtc)).ToList(),
player.EconomicRegions.Select(region => new PlayerEconomicRegionSnapshot(
region.Id,
region.Label,
region.Status,
region.Role,
region.SharedEconomicRegionId,
region.PolicyId,
region.AutomationPolicyId,
region.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
region.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
region.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
region.UpdatedAtUtc)).ToList(),
player.Fronts.Select(front => new PlayerFrontSnapshot(
front.Id,
front.Label,
front.Status,
front.Priority,
front.Posture,
front.SharedFrontLineId,
front.TargetFactionId,
front.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
front.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
front.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
front.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
front.UpdatedAtUtc)).ToList(),
player.Reserves.Select(reserve => new PlayerReserveGroupSnapshot(
reserve.Id,
reserve.Label,
reserve.Status,
reserve.ReserveKind,
reserve.HomeSystemId,
reserve.PolicyId,
reserve.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
reserve.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
reserve.UpdatedAtUtc)).ToList(),
player.Policies.Select(policy => new PlayerFactionPolicySnapshot(
policy.Id,
policy.Label,
policy.ScopeKind,
policy.ScopeId,
policy.PolicySetId,
policy.AllowDelegatedCombat,
policy.AllowDelegatedTrade,
policy.ReserveCreditsRatio,
policy.ReserveMilitaryRatio,
policy.TradeAccessPolicy,
policy.DockingAccessPolicy,
policy.ConstructionAccessPolicy,
policy.OperationalRangePolicy,
policy.CombatEngagementPolicy,
policy.AvoidHostileSystems,
policy.FleeHullRatio,
policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
policy.Notes,
policy.UpdatedAtUtc)).ToList(),
player.AutomationPolicies.Select(policy => new PlayerAutomationPolicySnapshot(
policy.Id,
policy.Label,
policy.ScopeKind,
policy.ScopeId,
policy.Enabled,
policy.BehaviorKind,
policy.UseOrders,
policy.StagingOrderKind,
policy.MaxSystemRange,
policy.KnownStationsOnly,
policy.Radius,
policy.WaitSeconds,
policy.PreferredItemId,
policy.Notes,
policy.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(),
policy.UpdatedAtUtc)).ToList(),
player.ReinforcementPolicies.Select(policy => new PlayerReinforcementPolicySnapshot(
policy.Id,
policy.Label,
policy.ScopeKind,
policy.ScopeId,
policy.ShipKind,
policy.DesiredAssetCount,
policy.MinimumReserveCount,
policy.AutoTransferReserves,
policy.AutoQueueProduction,
policy.SourceReserveId,
policy.TargetFrontId,
policy.Notes,
policy.UpdatedAtUtc)).ToList(),
player.ProductionPrograms.Select(program => new PlayerProductionProgramSnapshot(
program.Id,
program.Label,
program.Status,
program.Kind,
program.TargetShipKind,
program.TargetModuleId,
program.TargetItemId,
program.TargetCount,
program.CurrentCount,
program.StationGroupId,
program.ReinforcementPolicyId,
program.Notes,
program.UpdatedAtUtc)).ToList(),
player.Directives.Select(directive => new PlayerDirectiveSnapshot(
directive.Id,
directive.Label,
directive.Status,
directive.Kind,
directive.ScopeKind,
directive.ScopeId,
directive.TargetEntityId,
directive.TargetSystemId,
directive.TargetPosition is null ? null : ToDto(directive.TargetPosition.Value),
directive.HomeSystemId,
directive.HomeStationId,
directive.SourceStationId,
directive.DestinationStationId,
directive.BehaviorKind,
directive.UseOrders,
directive.StagingOrderKind,
directive.ItemId,
directive.PreferredNodeId,
directive.PreferredConstructionSiteId,
directive.PreferredModuleId,
directive.Priority,
directive.Radius,
directive.WaitSeconds,
directive.MaxSystemRange,
directive.KnownStationsOnly,
directive.PatrolPoints.Select(ToDto).ToList(),
directive.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(),
directive.PolicyId,
directive.AutomationPolicyId,
directive.Notes,
directive.CreatedAtUtc,
directive.UpdatedAtUtc)).ToList(),
player.Assignments.Select(assignment => new PlayerAssignmentSnapshot(
assignment.Id,
assignment.AssetKind,
assignment.AssetId,
assignment.FleetId,
assignment.TaskForceId,
assignment.StationGroupId,
assignment.EconomicRegionId,
assignment.FrontId,
assignment.ReserveId,
assignment.DirectiveId,
assignment.PolicyId,
assignment.AutomationPolicyId,
assignment.Role,
assignment.Status,
assignment.UpdatedAtUtc)).ToList(),
player.DecisionLog.Select(entry => new PlayerDecisionLogEntrySnapshot(
entry.Id,
entry.Kind,
entry.Summary,
entry.RelatedEntityKind,
entry.RelatedEntityId,
entry.OccurredAtUtc)).ToList(),
player.Alerts.Select(alert => new PlayerAlertSnapshot(
alert.Id,
alert.Kind,
alert.Severity,
alert.Summary,
alert.AssetKind,
alert.AssetId,
alert.RelatedDirectiveId,
alert.Status,
alert.CreatedAtUtc)).ToList());
}
private static GeopoliticalStateSnapshot? ToGeopoliticalStateSnapshot(GeopoliticalStateRuntime? state)
{
if (state is null)

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

@@ -1,5 +1,6 @@
using SpaceGame.Api.Shared.Runtime;
using SpaceGame.Api.Ships.Simulation;
using SpaceGame.Api.Ships.AI;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Stations.Simulation;
@@ -81,7 +82,7 @@ internal sealed class StationLifecycleService
SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition),
DefaultBehavior = CreateSpawnedShipBehavior(definition, station),
Skills = ShipBootstrapPolicy.CreateSkills(definition),
Health = definition.MaxHealth,
Health = definition.Hull,
};
world.Ships.Add(ship);
@@ -91,7 +92,7 @@ internal sealed class StationLifecycleService
faction.ShipsBuilt += 1;
}
events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Label} launched {definition.Label}.", DateTimeOffset.UtcNow));
events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Label} launched {definition.Name}.", DateTimeOffset.UtcNow));
return 1f;
}
@@ -107,21 +108,22 @@ internal sealed class StationLifecycleService
private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station)
{
if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal))
if (!IsMilitaryShip(definition))
{
return new DefaultBehaviorRuntime
{
Kind = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? "advanced-auto-trade" : "idle",
Kind = IsTransportShip(definition) ? AdvancedAutoTrade : HoldPosition,
HomeSystemId = station.SystemId,
HomeStationId = station.Id,
MaxSystemRange = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? 2 : 0,
AreaSystemId = station.SystemId,
MaxSystemRange = IsTransportShip(definition) ? 2 : 0,
};
}
var patrolRadius = station.Radius + 90f;
return new DefaultBehaviorRuntime
{
Kind = "patrol",
Kind = Patrol,
HomeSystemId = station.SystemId,
HomeStationId = station.Id,
AreaSystemId = station.SystemId,

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;
@@ -255,7 +260,7 @@ internal sealed class StationSimulationService
var priority = (float)recipe.Priority;
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
var fleetPressure = GetShipProductionPressure(world, station.FactionId, "military");
var fleetPressure = GetShipProductionPressure(world, station.FactionId, MilitaryShipCategory);
priority += GetStationRecipePriorityAdjustment(world, station, recipe, expansionPressure, fleetPressure);
priority += GetStrategicRecipeBias(world, station, recipe);
@@ -266,21 +271,34 @@ internal sealed class StationSimulationService
{
if (recipe.ShipOutputId is not null && world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition))
{
var shipPressure = GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind);
return shipDefinition.Kind switch
var shipPressure = GetShipProductionPressure(world, station.FactionId, GetShipCategory(shipDefinition));
if (IsMilitaryShip(shipDefinition))
{
"military" => recipe.Id switch
return recipe.Id switch
{
"frigate-construction" => 320f * shipPressure,
"destroyer-construction" => 200f * shipPressure,
"cruiser-construction" => 120f * shipPressure,
_ => 160f * shipPressure,
},
"construction" => 260f * shipPressure,
"mining" => 250f * shipPressure,
"transport" => 230f * shipPressure,
_ => 0f,
};
};
}
if (IsConstructionShip(shipDefinition))
{
return 260f * shipPressure;
}
if (IsMiningShip(shipDefinition))
{
return 250f * shipPressure;
}
if (IsTransportShip(shipDefinition))
{
return 230f * shipPressure;
}
return 0f;
}
var outputItemIds = recipe.Outputs
@@ -338,7 +356,7 @@ internal sealed class StationSimulationService
if (string.Equals(assignment.Kind, "ship-production-focus", StringComparison.Ordinal)
&& recipe.ShipOutputId is not null
&& world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)
&& string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal))
&& IsMilitaryShip(shipDefinition))
{
return 260f;
}
@@ -383,7 +401,7 @@ internal sealed class StationSimulationService
return false;
}
if (GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind) <= 0.05f)
if (GetShipProductionPressure(world, station.FactionId, GetShipCategory(shipDefinition)) <= 0.05f)
{
return false;
}
@@ -708,7 +726,7 @@ internal sealed class StationSimulationService
.ToList();
}
private static float GetShipProductionPressure(SimulationWorld world, string factionId, string shipKind)
private static float GetShipProductionPressure(SimulationWorld world, string factionId, string? shipCategory)
{
var economic = FindFactionEconomicAssessment(world, factionId);
var threat = FindFactionThreatAssessment(world, factionId);
@@ -717,16 +735,16 @@ internal sealed class StationSimulationService
return 0f;
}
return shipKind switch
return shipCategory switch
{
"military" => threat.EnemyFactionCount > 0
MilitaryShipCategory => threat.EnemyFactionCount > 0
? economic.MilitaryShipCount < Math.Max(4, economic.ControlledSystemCount * 2) ? 1f : 0.25f
: 0.1f,
"construction" => economic.PrimaryExpansionSiteId is not null
ConstructionShipCategory => economic.PrimaryExpansionSiteId is not null
? economic.ConstructorShipCount < 1 ? 1f : 0.35f
: economic.ConstructorShipCount < 1 ? 0.5f : 0f,
"transport" => economic.TransportShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.8f : 0.2f,
_ when shipKind == "mining" || shipKind == "miner" => economic.MinerShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.85f : 0.2f,
TransportShipCategory => economic.TransportShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.8f : 0.2f,
MiningShipCategory => economic.MinerShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.85f : 0.2f,
_ => 0.15f,
};
}

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

@@ -8,7 +8,7 @@ public sealed class GetBalanceHandler(IBalanceService balanceService) : Endpoint
public override void Configure()
{
Get("/api/balance");
AllowAnonymous();
Policies(AuthPolicyNames.GmAccess);
}
public override Task HandleAsync(CancellationToken cancellationToken) =>

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

View File

@@ -0,0 +1,25 @@
using FastEndpoints;
namespace SpaceGame.Api.Universe.Api;
public sealed class SpawnShipHandler(WorldService worldService) : Endpoint<SpawnShipCommandRequest, ShipSnapshot>
{
public override void Configure()
{
Post("/api/gm/ships");
Policies(AuthPolicyNames.GmAccess);
}
public override async Task HandleAsync(SpawnShipCommandRequest request, CancellationToken cancellationToken)
{
try
{
await SendOkAsync(worldService.SpawnShip(request), cancellationToken);
}
catch (InvalidOperationException ex)
{
AddError(ex.Message);
await SendErrorsAsync(cancellation: cancellationToken);
}
}
}

View File

@@ -0,0 +1,25 @@
using FastEndpoints;
namespace SpaceGame.Api.Universe.Api;
public sealed class SpawnStationHandler(WorldService worldService) : Endpoint<SpawnStationCommandRequest, StationSnapshot>
{
public override void Configure()
{
Post("/api/gm/stations");
Policies(AuthPolicyNames.GmAccess);
}
public override async Task HandleAsync(SpawnStationCommandRequest request, CancellationToken cancellationToken)
{
try
{
await SendOkAsync(worldService.SpawnStation(request), cancellationToken);
}
catch (InvalidOperationException ex)
{
AddError(ex.Message);
await SendErrorsAsync(cancellation: cancellationToken);
}
}
}

View File

@@ -8,7 +8,7 @@ public sealed class UpdateBalanceHandler(IBalanceService balanceService) : Endpo
public override void Configure()
{
Put("/api/balance");
AllowAnonymous();
Policies(AuthPolicyNames.GmAccess);
}
public override Task HandleAsync(BalanceOptions req, CancellationToken cancellationToken)

View File

@@ -1,15 +1,22 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
using SpaceGame.Api.Shared.Runtime;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Universe.Bootstrap;
public sealed class StaticDataProvider : IStaticDataProvider
{
private const string MilitaryShipCategory = "military";
private const string ConstructionShipCategory = "construction";
private const string TransportShipCategory = "transport";
private const string MiningShipCategory = "mining";
private readonly string _dataRoot;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() },
};
public StaticDataProvider(IOptions<StaticDataOptions> staticDataOptions)
@@ -163,7 +170,7 @@ public sealed class StaticDataProvider : IStaticDataProvider
recipes.Add(new RecipeDefinition
{
Id = $"{ship.Id}-{production.Method}-construction",
Label = $"{ship.Label} Construction",
Label = $"{ship.Name} Construction",
FacilityCategory = "shipyard",
Duration = production.Time,
Priority = InferShipRecipePriority(ship),
@@ -224,12 +231,12 @@ public sealed class StaticDataProvider : IStaticDataProvider
};
private static int InferShipRecipePriority(ShipDefinition ship) =>
ship.Kind switch
GetShipCategory(ship) switch
{
"military" => 170,
"construction" => 140,
"transport" => 120,
"mining" => 110,
MilitaryShipCategory => 170,
ConstructionShipCategory => 140,
TransportShipCategory => 120,
MiningShipCategory => 110,
_ => 100,
};

View File

@@ -0,0 +1,16 @@
namespace SpaceGame.Api.Universe.Contracts;
public sealed record CreateFactionCommandRequest(
string FactionId);
public sealed record SpawnShipCommandRequest(
string FactionId,
string SystemId,
string? ShipId = null,
string? BehaviorKind = null);
public sealed record SpawnStationCommandRequest(
string FactionId,
string SystemId,
string? Objective = null,
string? Label = null);

View File

@@ -18,7 +18,6 @@ public sealed record WorldSnapshot(
IReadOnlyList<PolicySetSnapshot> Policies,
IReadOnlyList<ShipSnapshot> Ships,
IReadOnlyList<FactionSnapshot> Factions,
PlayerFactionSnapshot? PlayerFaction,
GeopoliticalStateSnapshot? Geopolitics);
public sealed record WorldDelta(
@@ -38,7 +37,6 @@ public sealed record WorldDelta(
IReadOnlyList<PolicySetDelta> Policies,
IReadOnlyList<ShipDelta> Ships,
IReadOnlyList<FactionDelta> Factions,
PlayerFactionSnapshot? PlayerFaction,
GeopoliticalStateSnapshot? Geopolitics,
ObserverScope? Scope = null);

View File

@@ -89,9 +89,6 @@ internal static class LoaderSupport
internal static bool HasInstalledModules(StationRuntime station, params string[] modules) =>
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
internal static bool HasCapabilities(ShipDefinition definition, params string[] capabilities) =>
capabilities.All(capability => definition.Capabilities.Contains(capability, StringComparer.Ordinal));
internal static void AddStationModule(StationRuntime station, IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, string moduleId)
{
if (!moduleDefinitions.TryGetValue(moduleId, out var definition))

View File

@@ -1,5 +1,7 @@
using SpaceGame.Api.Universe.Bootstrap;
using SpaceGame.Api.Ships.Simulation;
using SpaceGame.Api.Ships.AI;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario;
@@ -194,7 +196,7 @@ public sealed class ScenarioContentBuilder(
patrolRoutes,
stations),
Skills = ShipBootstrapPolicy.CreateSkills(definition),
Health = definition.MaxHealth,
Health = definition.Hull,
});
foreach (var (itemId, amount) in formation.StartingInventory)
@@ -232,45 +234,45 @@ public sealed class ScenarioContentBuilder(
&& string.Equals(station.SystemId, systemId, StringComparison.Ordinal))
?? stations.FirstOrDefault(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal));
if (string.Equals(definition.Kind, "construction", StringComparison.Ordinal) && homeStation is not null)
if (IsConstructionShip(definition) && homeStation is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "construct-station",
Kind = ConstructStation,
HomeSystemId = homeStation.SystemId,
HomeStationId = homeStation.Id,
PreferredConstructionSiteId = null,
};
}
if (LoaderSupport.HasCapabilities(definition, "mining") && homeStation is not null)
if (IsMiningShip(definition) && homeStation is not null)
{
return new DefaultBehaviorRuntime
{
Kind = definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine",
Kind = definition.GetTotalCargoCapacity() >= 120f ? ExpertAutoMine : AdvancedAutoMine,
HomeSystemId = homeStation.SystemId,
HomeStationId = homeStation.Id,
AreaSystemId = homeStation.SystemId,
MaxSystemRange = definition.CargoCapacity >= 120f ? 3 : 1,
MaxSystemRange = definition.GetTotalCargoCapacity() >= 120f ? 3 : 1,
};
}
if (string.Equals(definition.Kind, "transport", StringComparison.Ordinal))
if (IsTransportShip(definition))
{
return new DefaultBehaviorRuntime
{
Kind = "advanced-auto-trade",
Kind = AdvancedAutoTrade,
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
MaxSystemRange = 2,
};
}
if (string.Equals(definition.Kind, "military", StringComparison.Ordinal) && patrolRoutes.TryGetValue(systemId, out var route))
if (IsMilitaryShip(definition) && patrolRoutes.TryGetValue(systemId, out var route))
{
return new DefaultBehaviorRuntime
{
Kind = "patrol",
Kind = Patrol,
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
AreaSystemId = systemId,
@@ -281,9 +283,10 @@ public sealed class ScenarioContentBuilder(
return new DefaultBehaviorRuntime
{
Kind = "idle",
Kind = HoldPosition,
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
AreaSystemId = homeStation?.SystemId ?? systemId,
};
}
}

View File

@@ -520,6 +520,8 @@ public sealed class SystemGenerationService
private static float Jitter(int index, int salt, float amplitude) =>
(Hash01(index, salt) * 2f - 1f) * amplitude;
// Cheap deterministic pseudo-random helper: same (index, salt) pair always maps to the same 0..1 value.
// Generation code uses it instead of a mutable RNG so each procedural choice stays stable for a given seed.
private static float Hash01(int index, int salt)
{
uint value = (uint)(index + 1);

View File

@@ -18,9 +18,6 @@ public sealed class WorldRuntimeAssembler(
var policies = seedingService.CreatePolicies(factions);
var commanders = seedingService.CreateCommanders(factions, content.Stations, content.Ships);
var nowUtc = DateTimeOffset.UtcNow;
var playerFaction = worldGenerationOptions.GeneratePlayerFaction
? seedingService.CreatePlayerFaction(factions, content.Stations, content.Ships, commanders, policies, nowUtc)
: null;
var claims = seedingService.CreateClaims(content.Stations, topology.SpatialLayout.Celestials, nowUtc);
var world = new SimulationWorld
@@ -34,7 +31,6 @@ public sealed class WorldRuntimeAssembler(
Stations = content.Stations.ToList(),
Ships = content.Ships.ToList(),
Factions = factions,
PlayerFaction = playerFaction,
Geopolitics = null,
Commanders = commanders,
Claims = claims,

View File

@@ -1,4 +1,5 @@
using SpaceGame.Api.Universe.Bootstrap;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario;
@@ -379,7 +380,7 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData)
Label = "Core Automation",
ScopeKind = "player-faction",
ScopeId = player.Id,
BehaviorKind = "idle",
BehaviorKind = Idle,
UpdatedAtUtc = nowUtc,
});
@@ -395,7 +396,7 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData)
return player;
}
private FactionRuntime CreateFaction(string factionId)
internal FactionRuntime CreateFaction(string factionId)
{
if (!staticData.FactionDefinitions.TryGetValue(factionId, out var definition))
{

View File

@@ -1,6 +1,9 @@
using System.Threading.Channels;
using Microsoft.Extensions.Options;
using SpaceGame.Api.Universe.Bootstrap;
using SpaceGame.Api.Universe.Scenario;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Universe.Simulation;
@@ -11,8 +14,13 @@ public sealed class WorldService
private readonly Lock _sync = new();
private readonly OrbitalSimulationSnapshot _orbitalSimulation;
private readonly SimulationEngine _engine;
private readonly IPlayerIdentityResolver _playerIdentityResolver;
private readonly IPlayerStateStore _playerStateStore;
private readonly PlayerFactionProjectionService _playerFactionProjection;
private readonly ScenarioLoader _scenarioLoader;
private readonly WorldBuilder _worldBuilder;
private readonly IStaticDataProvider _staticData;
private readonly WorldSeedingService _worldSeedingService;
private readonly PlayerFactionService _playerFaction = new();
private readonly Dictionary<Guid, SubscriptionState> _subscribers = [];
private readonly Queue<WorldDelta> _history = [];
@@ -24,13 +32,23 @@ public sealed class WorldService
public WorldService(
ScenarioLoader scenarioLoader,
WorldBuilder worldBuilder,
IStaticDataProvider staticData,
WorldSeedingService worldSeedingService,
IPlayerStateStore playerStateStore,
IPlayerIdentityResolver playerIdentityResolver,
PlayerFactionProjectionService playerFactionProjection,
IBalanceService balance,
IOptions<OrbitalSimulationOptions> orbitalSimulationOptions)
{
_orbitalSimulation = new OrbitalSimulationSnapshot(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
_playerStateStore = playerStateStore;
_playerIdentityResolver = playerIdentityResolver;
_playerFactionProjection = playerFactionProjection;
_scenarioLoader = scenarioLoader;
_worldBuilder = worldBuilder;
_engine = new SimulationEngine(orbitalSimulationOptions.Value, balance);
_staticData = staticData;
_worldSeedingService = worldSeedingService;
_engine = new SimulationEngine(orbitalSimulationOptions.Value, balance, playerStateStore);
}
public void New(WorldGenerationOptions options)
@@ -81,7 +99,10 @@ public sealed class WorldService
{
lock (_sync)
{
var ship = _playerFaction.EnqueueDirectShipOrder(_world, shipId, request);
ValidateShipOrderRequestUnsafe(shipId, request);
var ship = CanCurrentActorAccessGm()
? EnqueueGmShipOrderUnsafe(shipId, request)
: _playerFaction.EnqueueDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, request);
if (ship is null)
{
return null;
@@ -95,7 +116,9 @@ public sealed class WorldService
{
lock (_sync)
{
var ship = _playerFaction.RemoveDirectShipOrder(_world, shipId, orderId);
var ship = CanCurrentActorAccessGm()
? RemoveGmShipOrderUnsafe(shipId, orderId)
: _playerFaction.RemoveDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, orderId);
if (ship is null)
{
return null;
@@ -109,7 +132,9 @@ public sealed class WorldService
{
lock (_sync)
{
var ship = _playerFaction.ConfigureDirectShipBehavior(_world, shipId, request);
var ship = CanCurrentActorAccessGm()
? ConfigureGmShipBehaviorUnsafe(shipId, request)
: _playerFaction.ConfigureDirectShipBehavior(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, request);
if (ship is null)
{
return null;
@@ -123,13 +148,15 @@ public sealed class WorldService
{
lock (_sync)
{
if (_world.PlayerFaction is null && _world.Factions.Count == 0)
if (_world.Factions.Count == 0)
{
return null;
}
_playerFaction.EnsureDomain(_world);
return GetPlayerFactionSnapshotUnsafe();
var playerKey = GetCurrentPlayerKey();
var player = _playerFaction.TryGetDomain(_playerStateStore, playerKey)
?? _playerFaction.EnsureDomain(_world, _playerStateStore, playerKey);
return _playerFactionProjection.ToSnapshot(player);
}
}
@@ -137,7 +164,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.CreateOrganization(_world, request);
_playerFaction.CreateOrganization(_world, _playerStateStore, GetCurrentPlayerKey(), request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -146,7 +173,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.DeleteOrganization(_world, organizationId);
_playerFaction.DeleteOrganization(_world, _playerStateStore, GetCurrentPlayerKey(), organizationId);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -155,7 +182,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpdateOrganizationMembership(_world, organizationId, request);
_playerFaction.UpdateOrganizationMembership(_world, _playerStateStore, GetCurrentPlayerKey(), organizationId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -164,7 +191,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpsertDirective(_world, directiveId, request);
_playerFaction.UpsertDirective(_world, _playerStateStore, GetCurrentPlayerKey(), directiveId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -173,7 +200,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.DeleteDirective(_world, directiveId);
_playerFaction.DeleteDirective(_world, _playerStateStore, GetCurrentPlayerKey(), directiveId);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -182,7 +209,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpsertPolicy(_world, policyId, request);
_playerFaction.UpsertPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), policyId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -191,7 +218,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpsertAutomationPolicy(_world, automationPolicyId, request);
_playerFaction.UpsertAutomationPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), automationPolicyId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -200,7 +227,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpsertReinforcementPolicy(_world, reinforcementPolicyId, request);
_playerFaction.UpsertReinforcementPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), reinforcementPolicyId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -209,7 +236,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpsertProductionProgram(_world, productionProgramId, request);
_playerFaction.UpsertProductionProgram(_world, _playerStateStore, GetCurrentPlayerKey(), productionProgramId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -218,7 +245,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpsertAssignment(_world, assetId, request);
_playerFaction.UpsertAssignment(_world, _playerStateStore, GetCurrentPlayerKey(), assetId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -227,11 +254,118 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpdateStrategicIntent(_world, request);
_playerFaction.UpdateStrategicIntent(_world, _playerStateStore, GetCurrentPlayerKey(), request);
return GetPlayerFactionSnapshotUnsafe();
}
}
public FactionSnapshot CreateFaction(string factionId)
{
lock (_sync)
{
if (_world.Factions.Any(candidate => string.Equals(candidate.Id, factionId, StringComparison.Ordinal)))
{
throw new InvalidOperationException($"Faction '{factionId}' already exists in the current world.");
}
var faction = _worldSeedingService.CreateFaction(factionId);
_world.Factions.Add(faction);
var policy = _worldSeedingService.CreatePolicies([faction]).Single();
_world.Policies.Add(policy);
var factionCommander = CreateFactionCommander(faction);
_world.Commanders.Add(factionCommander);
faction.CommanderIds.Add(factionCommander.Id);
new GeopoliticalSimulationService().Update(_world, 0f, []);
PublishSnapshotRefreshUnsafe("create-faction", $"Created faction {factionId}", "faction", factionId);
return _engine.BuildSnapshot(_world, _sequence).Factions.First(candidate => candidate.Id == factionId);
}
}
public ShipSnapshot SpawnShip(SpawnShipCommandRequest request)
{
lock (_sync)
{
var faction = _world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, request.FactionId, StringComparison.Ordinal))
?? throw new InvalidOperationException($"Faction '{request.FactionId}' does not exist in the current world.");
var system = _world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, request.SystemId, StringComparison.Ordinal))
?? throw new InvalidOperationException($"System '{request.SystemId}' does not exist in the current world.");
var definition = ResolveShipDefinition(request, faction.Id);
var shipId = $"ship-{faction.Id}-{definition.Id}-{Guid.NewGuid():N}".ToLowerInvariant();
var spawnPosition = ResolveSpawnPosition(system.Definition.Id);
var homeStation = _world.Stations.FirstOrDefault(candidate =>
string.Equals(candidate.FactionId, faction.Id, StringComparison.Ordinal)
&& string.Equals(candidate.SystemId, system.Definition.Id, StringComparison.Ordinal));
var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, homeStation);
var ship = new ShipRuntime
{
Id = shipId,
SystemId = system.Definition.Id,
Definition = definition,
FactionId = faction.Id,
Position = spawnPosition,
TargetPosition = spawnPosition,
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Celestials),
DefaultBehavior = defaultBehavior,
Skills = ShipBootstrapPolicy.CreateSkills(definition),
Health = definition.Hull,
};
_world.Ships.Add(ship);
EnsureShipCommander(faction, ship);
new GeopoliticalSimulationService().Update(_world, 0f, []);
PublishSnapshotRefreshUnsafe("spawn-ship", $"Spawned ship {ship.Id}", "ship", ship.Id);
return GetShipSnapshotUnsafe(ship.Id)
?? throw new InvalidOperationException($"Ship '{ship.Id}' could not be projected.");
}
}
public StationSnapshot SpawnStation(SpawnStationCommandRequest request)
{
lock (_sync)
{
var faction = _world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, request.FactionId, StringComparison.Ordinal))
?? throw new InvalidOperationException($"Faction '{request.FactionId}' does not exist in the current world.");
var system = _world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, request.SystemId, StringComparison.Ordinal))
?? throw new InvalidOperationException($"System '{request.SystemId}' does not exist in the current world.");
var objective = StationSimulationService.NormalizeStationObjective(request.Objective);
var label = string.IsNullOrWhiteSpace(request.Label)
? $"{faction.Label} {ToTitleCaseToken(objective)} {CountFactionStationsInSystem(faction.Id, system.Definition.Id) + 1}"
: request.Label.Trim();
var stationId = $"station-{faction.Id}-{objective}-{Guid.NewGuid():N}".ToLowerInvariant();
var position = ResolveStationSpawnPosition(system.Definition.Id);
var station = new StationRuntime
{
Id = stationId,
SystemId = system.Definition.Id,
Label = label,
Color = faction.Color,
Objective = objective,
Position = position,
FactionId = faction.Id,
PolicySetId = faction.DefaultPolicySetId,
Health = 600f,
MaxHealth = 600f,
};
foreach (var moduleId in BuildStarterStationModules(faction.Id, objective))
{
AddStationModule(_world, station, moduleId);
}
station.PopulationCapacity = GetStationSupportedPopulation(_world.ModuleDefinitions, station);
station.WorkforceRequired = GetStationRequiredWorkforce(_world.ModuleDefinitions, station);
_world.Stations.Add(station);
new GeopoliticalSimulationService().Update(_world, 0f, []);
PublishSnapshotRefreshUnsafe("spawn-station", $"Spawned station {station.Id}", "station", station.Id);
return _engine.BuildSnapshot(_world, _sequence).Stations.First(candidate => candidate.Id == station.Id);
}
}
public ChannelReader<WorldDelta> Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken)
{
var channel = Channel.CreateUnbounded<WorldDelta>(new UnboundedChannelOptions
@@ -318,6 +452,7 @@ public sealed class WorldService
private void ReplaceWorldUnsafe(SimulationWorld world, string eventKind, string eventMessage)
{
_world = world;
_playerStateStore.Clear();
_sequence += 1;
_history.Clear();
@@ -339,7 +474,6 @@ public sealed class WorldService
[],
[],
[],
null,
null);
_history.Enqueue(worldDelta);
@@ -349,11 +483,431 @@ public sealed class WorldService
}
}
private void PublishSnapshotRefreshUnsafe(
string eventKind,
string eventMessage,
string entityKind,
string entityId,
string scopeKind = "universe",
string? scopeEntityId = null)
{
_sequence += 1;
var eventTime = DateTimeOffset.UtcNow;
var worldDelta = new WorldDelta(
_sequence,
_world.TickIntervalMs,
_world.OrbitalTimeSeconds,
_orbitalSimulation,
eventTime,
true,
[new SimulationEventRecord(entityKind, entityId, eventKind, eventMessage, eventTime, "world", scopeKind, scopeEntityId)],
[],
[],
[],
[],
[],
[],
[],
[],
[],
null);
_history.Enqueue(worldDelta);
while (_history.Count > DeltaHistoryLimit)
{
_history.Dequeue();
}
foreach (var subscriber in _subscribers.Values.ToList())
{
subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(worldDelta, subscriber.Scope));
}
}
private ShipSnapshot? GetShipSnapshotUnsafe(string shipId) =>
_engine.BuildSnapshot(_world, _sequence).Ships.FirstOrDefault(ship => ship.Id == shipId);
private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() =>
_engine.BuildSnapshot(_world, _sequence).PlayerFaction;
_playerFactionProjection.ToSnapshot(_playerFaction.TryGetDomain(_playerStateStore, GetCurrentPlayerKey()));
private string GetCurrentPlayerKey() => _playerIdentityResolver.GetRequiredPlayerId().ToString("N");
private bool CanCurrentActorAccessGm() => _playerIdentityResolver.CanAccessGm();
private string GetCurrentActorSourceId() =>
_playerIdentityResolver.GetCurrentPlayerId()?.ToString("N") ?? "gm";
private void ValidateShipOrderRequestUnsafe(string shipId, ShipOrderCommandRequest request)
{
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId)
?? throw new InvalidOperationException($"Ship '{shipId}' was not found.");
if (!string.Equals(request.Kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal))
{
return;
}
if (!IsMiningShip(ship.Definition))
{
throw new InvalidOperationException($"{ship.Definition.Name} cannot accept Mine Resource because it does not have mining capability.");
}
if (string.IsNullOrWhiteSpace(request.ItemId))
{
throw new InvalidOperationException("Mine Resource requires a ware.");
}
if (!_world.ItemDefinitions.TryGetValue(request.ItemId, out var itemDefinition))
{
throw new InvalidOperationException($"Mine Resource references unknown ware '{request.ItemId}'.");
}
if (itemDefinition.CargoKind is null)
{
throw new InvalidOperationException($"Mine Resource ware '{request.ItemId}' is not mineable.");
}
if (!ship.Definition.SupportsCargoKind(itemDefinition.CargoKind.Value))
{
throw new InvalidOperationException($"{ship.Definition.Name} cannot mine '{request.ItemId}' because it cannot store '{itemDefinition.CargoKind.Value.ToDataValue()}'.");
}
}
private ShipRuntime? EnqueueGmShipOrderUnsafe(string shipId, ShipOrderCommandRequest request)
{
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
if (ship is null)
{
return null;
}
if (ship.OrderQueue.Count >= 8)
{
throw new InvalidOperationException("Order queue is full.");
}
ship.OrderQueue.Add(new ShipOrderRuntime
{
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
Kind = request.Kind,
SourceKind = ShipOrderSourceKind.Player,
SourceId = GetCurrentActorSourceId(),
Priority = request.Priority,
InterruptCurrentPlan = request.InterruptCurrentPlan,
Label = request.Label,
TargetEntityId = request.TargetEntityId,
TargetSystemId = request.TargetSystemId,
TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z),
SourceStationId = request.SourceStationId,
DestinationStationId = request.DestinationStationId,
ItemId = request.ItemId,
NodeId = request.NodeId,
ConstructionSiteId = request.ConstructionSiteId,
ModuleId = request.ModuleId,
WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f),
Radius = MathF.Max(0f, request.Radius ?? 0f),
MaxSystemRange = request.MaxSystemRange,
KnownStationsOnly = request.KnownStationsOnly ?? false,
});
ship.ControlSourceKind = "gm-order";
ship.ControlSourceId = ship.OrderQueue
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
ship.ControlReason = request.Label ?? request.Kind;
ship.NeedsReplan = true;
ship.LastReplanReason = "gm-order-enqueued";
ship.LastDeltaSignature = string.Empty;
return ship;
}
private ShipRuntime? RemoveGmShipOrderUnsafe(string shipId, string orderId)
{
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
if (ship is null)
{
return null;
}
ship.OrderQueue.RemoveAll(order => order.Id == orderId);
ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
? "gm-order"
: "gm-manual";
ship.ControlSourceId = ship.OrderQueue
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
ship.ControlReason = ship.OrderQueue
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Label ?? order.Kind)
.FirstOrDefault()
?? "manual-gm-control";
ship.NeedsReplan = true;
ship.LastReplanReason = "gm-order-removed";
ship.LastDeltaSignature = string.Empty;
return ship;
}
private ShipRuntime? ConfigureGmShipBehaviorUnsafe(string shipId, ShipDefaultBehaviorCommandRequest request)
{
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
if (ship is null)
{
return null;
}
ship.DefaultBehavior.Kind = request.Kind;
ship.DefaultBehavior.HomeSystemId = request.HomeSystemId ?? ship.SystemId;
ship.DefaultBehavior.HomeStationId = request.HomeStationId;
ship.DefaultBehavior.AreaSystemId = request.AreaSystemId;
ship.DefaultBehavior.TargetEntityId = request.TargetEntityId;
ship.DefaultBehavior.ItemId = request.ItemId;
ship.DefaultBehavior.PreferredNodeId = request.PreferredNodeId;
ship.DefaultBehavior.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
ship.DefaultBehavior.PreferredModuleId = request.PreferredModuleId;
ship.DefaultBehavior.TargetPosition = request.TargetPosition is null
? null
: new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z);
ship.DefaultBehavior.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? ship.DefaultBehavior.WaitSeconds);
ship.DefaultBehavior.Radius = MathF.Max(0f, request.Radius ?? ship.DefaultBehavior.Radius);
ship.DefaultBehavior.MaxSystemRange = Math.Max(0, request.MaxSystemRange ?? ship.DefaultBehavior.MaxSystemRange);
ship.DefaultBehavior.KnownStationsOnly = request.KnownStationsOnly ?? ship.DefaultBehavior.KnownStationsOnly;
ship.DefaultBehavior.PatrolPoints =
(request.PatrolPoints ?? [])
.Select(point => new Vector3(point.X, point.Y, point.Z))
.ToList();
ship.DefaultBehavior.PatrolIndex = 0;
ship.DefaultBehavior.RepeatOrders =
(request.RepeatOrders ?? [])
.Select(template => new ShipOrderTemplateRuntime
{
Kind = template.Kind,
Label = template.Label,
TargetEntityId = template.TargetEntityId,
TargetSystemId = template.TargetSystemId,
TargetPosition = template.TargetPosition is null ? null : new Vector3(template.TargetPosition.X, template.TargetPosition.Y, template.TargetPosition.Z),
SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId,
NodeId = template.NodeId,
ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId,
WaitSeconds = template.WaitSeconds ?? 0f,
Radius = template.Radius ?? 0f,
MaxSystemRange = template.MaxSystemRange,
KnownStationsOnly = template.KnownStationsOnly ?? false,
})
.ToList();
ship.DefaultBehavior.RepeatIndex = 0;
ship.ControlSourceKind = "gm-manual";
ship.ControlSourceId = GetCurrentActorSourceId();
ship.ControlReason = request.Kind;
ship.NeedsReplan = true;
ship.LastReplanReason = "gm-behavior-updated";
ship.LastDeltaSignature = string.Empty;
return ship;
}
private CommanderRuntime CreateFactionCommander(FactionRuntime faction) => new()
{
Id = $"commander-faction-{faction.Id}",
Kind = CommanderKind.Faction,
FactionId = faction.Id,
ControlledEntityId = faction.Id,
PolicySetId = faction.DefaultPolicySetId,
Doctrine = "strategic-control",
};
private void EnsureShipCommander(FactionRuntime faction, ShipRuntime ship)
{
var factionCommander = _world.Commanders.FirstOrDefault(candidate =>
string.Equals(candidate.Kind, CommanderKind.Faction, StringComparison.Ordinal)
&& string.Equals(candidate.FactionId, faction.Id, StringComparison.Ordinal));
if (factionCommander is null)
{
return;
}
var commander = new CommanderRuntime
{
Id = $"commander-ship-{ship.Id}",
Kind = CommanderKind.Ship,
FactionId = faction.Id,
ParentCommanderId = factionCommander.Id,
ControlledEntityId = ship.Id,
PolicySetId = factionCommander.PolicySetId,
Doctrine = "ship-control",
Skills = new CommanderSkillProfileRuntime
{
Leadership = Math.Clamp((ship.Skills.Navigation + ship.Skills.Combat + 1) / 2, 2, 5),
Coordination = Math.Clamp((ship.Skills.Trade + ship.Skills.Mining + 1) / 2, 2, 5),
Strategy = Math.Clamp((ship.Skills.Combat + ship.Skills.Construction + 1) / 2, 2, 5),
},
};
ship.CommanderId = commander.Id;
ship.PolicySetId = factionCommander.PolicySetId;
factionCommander.SubordinateCommanderIds.Add(commander.Id);
faction.CommanderIds.Add(commander.Id);
_world.Commanders.Add(commander);
}
private ShipDefinition ResolveShipDefinition(SpawnShipCommandRequest request, string factionId)
{
if (!string.IsNullOrWhiteSpace(request.ShipId))
{
return _staticData.ShipDefinitions.TryGetValue(request.ShipId, out var explicitDefinition)
? explicitDefinition
: throw new InvalidOperationException($"Ship '{request.ShipId}' is not defined in static data.");
}
return _staticData.ShipDefinitions.Values
.Where(IsMiningShip)
.OrderBy(definition => !definition.Owners.Contains(factionId, StringComparer.Ordinal))
.ThenBy(definition => !definition.SupportsCargoKind(StorageKind.Solid))
.ThenBy(definition => definition.Size != "small")
.ThenBy(definition => definition.Id, StringComparer.Ordinal)
.FirstOrDefault()
?? throw new InvalidOperationException("No mining ship definition is available in static data.");
}
private Vector3 ResolveSpawnPosition(string systemId)
{
var shipsInSystem = _world.Ships.Count(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal));
var angle = shipsInSystem * 0.73f;
return new Vector3(60f + (shipsInSystem * 12f), 0f, MathF.Sin(angle) * 34f);
}
private Vector3 ResolveStationSpawnPosition(string systemId)
{
var stationsInSystem = _world.Stations.Count(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal));
var angle = stationsInSystem * 0.91f;
var radius = 160f + (stationsInSystem * 42f);
return new Vector3(MathF.Cos(angle) * radius, 0f, MathF.Sin(angle) * radius);
}
private IReadOnlyList<string> BuildStarterStationModules(string factionId, string objective)
{
var modules = new List<string>();
EnsureStationModule(modules, StarterStationLayoutResolver.ResolveDockModuleId(factionId, _staticData.ModuleDefinitions));
var powerModuleId = StarterStationLayoutResolver.ResolvePowerModuleId(factionId, _staticData.ModuleDefinitions);
EnsureStationModule(modules, powerModuleId);
var defaultContainerStorageModuleId = StarterStationLayoutResolver.ResolveRequiredStorageModuleIds(
powerModuleId,
factionId,
_staticData.ModuleDefinitions,
_staticData.ItemDefinitions)
.FirstOrDefault(moduleId =>
{
return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
&& definition is StorageModuleDefinition storageDefinition
&& storageDefinition.StorageKind == StorageKind.Container;
});
if (defaultContainerStorageModuleId is not null)
{
EnsureStationModule(modules, defaultContainerStorageModuleId);
}
var objectiveModuleId = StarterStationLayoutResolver.ResolveObjectiveModuleId(objective, factionId, _staticData.ModuleDefinitions);
if (!string.IsNullOrWhiteSpace(objectiveModuleId))
{
EnsureStationModule(modules, objectiveModuleId);
foreach (var storageModuleId in StarterStationLayoutResolver.ResolveRequiredStorageModuleIds(
objectiveModuleId,
factionId,
_staticData.ModuleDefinitions,
_staticData.ItemDefinitions))
{
EnsureStationModule(modules, storageModuleId);
}
}
return modules;
}
private static void EnsureStationModule(List<string> modules, string moduleId)
{
if (!modules.Contains(moduleId, StringComparer.Ordinal))
{
modules.Add(moduleId);
}
}
private int CountFactionStationsInSystem(string factionId, string systemId) =>
_world.Stations.Count(candidate =>
string.Equals(candidate.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal));
private static string ToTitleCaseToken(string value) =>
string.Join(" ",
value
.Split(['-', '_', ' '], StringSplitOptions.RemoveEmptyEntries)
.Select(part => part.Length == 0 ? part : char.ToUpperInvariant(part[0]) + part[1..]));
private static DefaultBehaviorRuntime CreateSpawnBehavior(
SpawnShipCommandRequest request,
ShipDefinition definition,
string systemId,
StationRuntime? homeStation)
{
var requestedBehavior = request.BehaviorKind?.Trim();
if (!string.IsNullOrWhiteSpace(requestedBehavior))
{
return new DefaultBehaviorRuntime
{
Kind = requestedBehavior,
HomeSystemId = systemId,
HomeStationId = homeStation?.Id,
AreaSystemId = systemId,
ItemId = string.Equals(requestedBehavior, LocalAutoMine, StringComparison.Ordinal) ? "ore" : null,
};
}
if (IsMiningShip(definition) && homeStation is not null)
{
return new DefaultBehaviorRuntime
{
Kind = LocalAutoMine,
HomeSystemId = systemId,
HomeStationId = homeStation.Id,
AreaSystemId = systemId,
};
}
if (IsMiningShip(definition))
{
return new DefaultBehaviorRuntime
{
Kind = LocalAutoMine,
HomeSystemId = systemId,
HomeStationId = null,
AreaSystemId = systemId,
ItemId = "ore",
};
}
return new DefaultBehaviorRuntime
{
Kind = HoldPosition,
HomeSystemId = systemId,
HomeStationId = homeStation?.Id,
AreaSystemId = systemId,
WaitSeconds = 4f,
Radius = 24f,
};
}
private static bool HasMeaningfulDelta(WorldDelta delta) =>
delta.RequiresSnapshotRefresh
@@ -367,7 +921,6 @@ public sealed class WorldService
|| delta.Policies.Count > 0
|| delta.Ships.Count > 0
|| delta.Factions.Count > 0
|| delta.PlayerFaction is not null
|| delta.Geopolitics is not null;
private void Unsubscribe(Guid subscriberId)
@@ -415,7 +968,6 @@ public sealed class WorldService
Policies = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Policies : [],
Ships = delta.Ships.Where((ship) => systemFilter is null || ship.SystemId == systemFilter).ToList(),
Factions = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Factions : [],
PlayerFaction = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.PlayerFaction : null,
Geopolitics = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Geopolitics : null,
Scope = scope,
};

View File

@@ -5,12 +5,6 @@
"Microsoft.AspNetCore": "Warning"
}
},
"WorldGeneration": {
"TargetSystemCount": 2,
"UseKnownSystems": true,
"AiControllerFactionCount": 0,
"GeneratePlayerFaction": false
},
"Balance": {
"SimulationSpeedMultiplier": 1.5,
"YPlane": 4,
@@ -24,5 +18,27 @@
},
"OrbitalSimulation": {
"SimulatedSecondsPerRealSecond": 0
},
"Auth": {
"ConnectionString": "Host=127.0.0.1;Port=5432;Database=spacegame;Username=spacegame;Password=spacegame",
"DevSeedUsers": [
{
"Email": "gm",
"Password": "gm",
"Roles": [ "gm" ]
},
{
"Email": "admin",
"Password": "admin",
"Roles": [ "admin", "gm" ]
}
]
},
"Jwt": {
"Issuer": "space-game-dev",
"Audience": "space-game-viewer",
"SigningKey": "space-game-development-signing-key-change-me",
"AccessTokenLifetimeMinutes": 30,
"RefreshTokenLifetimeDays": 30
}
}

View File

@@ -8,10 +8,6 @@
"StaticData": {
"DataRoot": "../../shared/data/"
},
"WorldGeneration": {
"TargetSystemCount": 160,
"UseKnownSystems": true
},
"Balance": {
"SimulationSpeedMultiplier": 1.5,
"YPlane": 4,
@@ -26,5 +22,15 @@
"OrbitalSimulation": {
"SimulatedSecondsPerRealSecond": 0
},
"Auth": {
"ConnectionString": "Host=127.0.0.1;Port=5432;Database=spacegame;Username=spacegame;Password=spacegame"
},
"Jwt": {
"Issuer": "space-game",
"Audience": "space-game-viewer",
"SigningKey": "dev-only-change-me-space-game-signing-key",
"AccessTokenLifetimeMinutes": 30,
"RefreshTokenLifetimeDays": 30
},
"AllowedHosts": "*"
}