Refactor runtime bootstrap and ship control flows
This commit is contained in:
17
apps/backend/Auth/Api/ForgotPasswordHandler.cs
Normal file
17
apps/backend/Auth/Api/ForgotPasswordHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/backend/Auth/Api/LoginHandler.cs
Normal file
25
apps/backend/Auth/Api/LoginHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/backend/Auth/Api/RefreshTokenHandler.cs
Normal file
25
apps/backend/Auth/Api/RefreshTokenHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/backend/Auth/Api/RegisterHandler.cs
Normal file
25
apps/backend/Auth/Api/RegisterHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/backend/Auth/Api/ResetPasswordHandler.cs
Normal file
26
apps/backend/Auth/Api/ResetPasswordHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
apps/backend/Auth/Contracts/AuthContracts.cs
Normal file
42
apps/backend/Auth/Contracts/AuthContracts.cs
Normal 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);
|
||||||
24
apps/backend/Auth/Runtime/AuthRuntimeModels.cs
Normal file
24
apps/backend/Auth/Runtime/AuthRuntimeModels.cs
Normal 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);
|
||||||
14
apps/backend/Auth/Simulation/AuthOptions.cs
Normal file
14
apps/backend/Auth/Simulation/AuthOptions.cs
Normal 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; } = [];
|
||||||
|
}
|
||||||
13
apps/backend/Auth/Simulation/AuthPolicyNames.cs
Normal file
13
apps/backend/Auth/Simulation/AuthPolicyNames.cs
Normal 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";
|
||||||
|
}
|
||||||
41
apps/backend/Auth/Simulation/AuthSchemaInitializer.cs
Normal file
41
apps/backend/Auth/Simulation/AuthSchemaInitializer.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
apps/backend/Auth/Simulation/AuthService.cs
Normal file
120
apps/backend/Auth/Simulation/AuthService.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/backend/Auth/Simulation/DevAuthSeeder.cs
Normal file
33
apps/backend/Auth/Simulation/DevAuthSeeder.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/backend/Auth/Simulation/DevPasswordResetDelivery.cs
Normal file
7
apps/backend/Auth/Simulation/DevPasswordResetDelivery.cs
Normal 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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/backend/Auth/Simulation/IAuthRepository.cs
Normal file
17
apps/backend/Auth/Simulation/IAuthRepository.cs
Normal 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);
|
||||||
|
}
|
||||||
6
apps/backend/Auth/Simulation/IPasswordResetDelivery.cs
Normal file
6
apps/backend/Auth/Simulation/IPasswordResetDelivery.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SpaceGame.Api.Auth.Simulation;
|
||||||
|
|
||||||
|
public interface IPasswordResetDelivery
|
||||||
|
{
|
||||||
|
Task<ForgotPasswordResponse> DeliverAsync(UserAccount user, string resetToken, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
8
apps/backend/Auth/Simulation/IPlayerIdentityResolver.cs
Normal file
8
apps/backend/Auth/Simulation/IPlayerIdentityResolver.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace SpaceGame.Api.Auth.Simulation;
|
||||||
|
|
||||||
|
public interface IPlayerIdentityResolver
|
||||||
|
{
|
||||||
|
Guid? GetCurrentPlayerId();
|
||||||
|
Guid GetRequiredPlayerId();
|
||||||
|
bool CanAccessGm();
|
||||||
|
}
|
||||||
7
apps/backend/Auth/Simulation/ITokenService.cs
Normal file
7
apps/backend/Auth/Simulation/ITokenService.cs
Normal 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();
|
||||||
|
}
|
||||||
10
apps/backend/Auth/Simulation/JwtOptions.cs
Normal file
10
apps/backend/Auth/Simulation/JwtOptions.cs
Normal 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;
|
||||||
|
}
|
||||||
51
apps/backend/Auth/Simulation/JwtTokenService.cs
Normal file
51
apps/backend/Auth/Simulation/JwtTokenService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
apps/backend/Auth/Simulation/LocalPasswordHasher.cs
Normal file
42
apps/backend/Auth/Simulation/LocalPasswordHasher.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
199
apps/backend/Auth/Simulation/PostgresAuthRepository.cs
Normal file
199
apps/backend/Auth/Simulation/PostgresAuthRepository.cs
Normal 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));
|
||||||
|
}
|
||||||
21
apps/backend/Auth/Simulation/RefreshTokenFactory.cs
Normal file
21
apps/backend/Auth/Simulation/RefreshTokenFactory.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using SpaceGame.Api.Shared.Runtime;
|
using SpaceGame.Api.Shared.Runtime;
|
||||||
|
using SpaceGame.Api.Shared.Runtime;
|
||||||
using SpaceGame.Api.Universe.Simulation;
|
using SpaceGame.Api.Universe.Simulation;
|
||||||
|
|
||||||
namespace SpaceGame.Api.Definitions;
|
namespace SpaceGame.Api.Definitions;
|
||||||
@@ -368,6 +369,72 @@ public sealed class PlanetDefinition
|
|||||||
public bool HasRing { get; set; }
|
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 sealed class ShipDefinition
|
||||||
{
|
{
|
||||||
public required string Id { get; set; }
|
public required string Id { get; set; }
|
||||||
@@ -379,9 +446,9 @@ public sealed class ShipDefinition
|
|||||||
public float Hull { get; set; }
|
public float Hull { get; set; }
|
||||||
public Dictionary<string, float> Storage { get; set; } = new(StringComparer.Ordinal);
|
public Dictionary<string, float> Storage { get; set; } = new(StringComparer.Ordinal);
|
||||||
public int People { get; set; }
|
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 Thruster { get; set; } = string.Empty;
|
||||||
public string Type { get; set; } = string.Empty;
|
public ShipType Type { get; set; }
|
||||||
public float Mass { get; set; }
|
public float Mass { get; set; }
|
||||||
public ShipInertiaDefinition? Inertia { get; set; }
|
public ShipInertiaDefinition? Inertia { get; set; }
|
||||||
public ShipDragDefinition? Drag { get; set; }
|
public ShipDragDefinition? Drag { get; set; }
|
||||||
@@ -395,12 +462,6 @@ public sealed class ShipDefinition
|
|||||||
public ItemPriceDefinition? Price { get; set; }
|
public ItemPriceDefinition? Price { get; set; }
|
||||||
public List<ItemProductionDefinition> Production { get; set; } = [];
|
public List<ItemProductionDefinition> Production { get; set; } = [];
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public string Label => Name;
|
|
||||||
[JsonIgnore]
|
|
||||||
public string Kind => InferKind(Purpose);
|
|
||||||
[JsonIgnore]
|
|
||||||
public string Class => Type;
|
|
||||||
[JsonIgnore]
|
|
||||||
public float Speed => InferLocalSpeed(Size);
|
public float Speed => InferLocalSpeed(Size);
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public float WarpSpeed => InferWarpSpeed(Size);
|
public float WarpSpeed => InferWarpSpeed(Size);
|
||||||
@@ -408,53 +469,15 @@ public sealed class ShipDefinition
|
|||||||
public float FtlSpeed => InferFtlSpeed(Size);
|
public float FtlSpeed => InferFtlSpeed(Size);
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public float SpoolTime => InferSpoolTime(Size);
|
public float SpoolTime => InferSpoolTime(Size);
|
||||||
[JsonIgnore]
|
public float GetTotalCargoCapacity() => Cargo.Sum(entry => entry.Max);
|
||||||
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);
|
|
||||||
|
|
||||||
private static string InferKind(string purpose) =>
|
public float GetCargoCapacity(StorageKind kind) =>
|
||||||
purpose switch
|
Cargo
|
||||||
{
|
.Where(entry => entry.Types.Any(type => type.ToNullableStorageKind() == kind))
|
||||||
"build" => "construction",
|
.Sum(entry => entry.Max);
|
||||||
"trade" => "transport",
|
|
||||||
"mine" => "mining",
|
|
||||||
"fight" => "military",
|
|
||||||
"auxiliary" => "military",
|
|
||||||
_ => purpose,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static List<string> InferCapabilities(
|
public bool SupportsCargoKind(StorageKind kind) =>
|
||||||
string purpose,
|
GetCargoCapacity(kind) > 0f;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static float InferWarpSpeed(string size) =>
|
private static float InferWarpSpeed(string size) =>
|
||||||
size switch
|
size switch
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using SpaceGame.Api.Industry.Planning;
|
using SpaceGame.Api.Industry.Planning;
|
||||||
using SpaceGame.Api.Stations.Simulation;
|
using SpaceGame.Api.Stations.Simulation;
|
||||||
|
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||||
|
|
||||||
namespace SpaceGame.Api.Factions.AI;
|
namespace SpaceGame.Api.Factions.AI;
|
||||||
@@ -13,8 +14,12 @@ internal sealed class CommanderPlanningService
|
|||||||
private const int MaxDecisionLogEntries = 40;
|
private const int MaxDecisionLogEntries = 40;
|
||||||
private const int MaxOutcomeEntries = 32;
|
private const int MaxOutcomeEntries = 32;
|
||||||
private const int MaxAiOrdersPerShip = 2;
|
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);
|
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())
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -48,7 +53,7 @@ internal sealed class CommanderPlanningService
|
|||||||
|
|
||||||
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Fleet).ToList())
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -63,7 +68,7 @@ internal sealed class CommanderPlanningService
|
|||||||
|
|
||||||
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Station).ToList())
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -78,7 +83,7 @@ internal sealed class CommanderPlanningService
|
|||||||
|
|
||||||
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Ship).ToList())
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -268,7 +273,7 @@ internal sealed class CommanderPlanningService
|
|||||||
CommanderRuntime factionCommander,
|
CommanderRuntime factionCommander,
|
||||||
IReadOnlyDictionary<string, CommanderRuntime> stationCommanders)
|
IReadOnlyDictionary<string, CommanderRuntime> stationCommanders)
|
||||||
{
|
{
|
||||||
if (string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal))
|
if (IsMilitaryShip(ship.Definition))
|
||||||
{
|
{
|
||||||
return factionCommander;
|
return factionCommander;
|
||||||
}
|
}
|
||||||
@@ -456,8 +461,8 @@ internal sealed class CommanderPlanningService
|
|||||||
ship.Id,
|
ship.Id,
|
||||||
nextAssignment is null ? "assignment-cleared" : "assignment-updated",
|
nextAssignment is null ? "assignment-cleared" : "assignment-updated",
|
||||||
nextAssignment is null
|
nextAssignment is null
|
||||||
? $"{ship.Definition.Label} returned to default behavior."
|
? $"{ship.Definition.Name} returned to default behavior."
|
||||||
: $"{ship.Definition.Label} assigned to {nextAssignment.Kind}.",
|
: $"{ship.Definition.Name} assigned to {nextAssignment.Kind}.",
|
||||||
DateTimeOffset.UtcNow));
|
DateTimeOffset.UtcNow));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -586,10 +591,10 @@ internal sealed class CommanderPlanningService
|
|||||||
var frontCount = Math.Max(1,
|
var frontCount = Math.Max(1,
|
||||||
threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind is "controlled-system" or "contested-system")
|
threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind is "controlled-system" or "contested-system")
|
||||||
+ (expansionProject is null ? 0 : 1));
|
+ (expansionProject is null ? 0 : 1));
|
||||||
var militaryShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "military");
|
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 && HasShipCapabilities(ship.Definition, "mining"));
|
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 && ship.Definition.Kind == "transport");
|
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 && ship.Definition.Kind == "construction");
|
var constructorShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsConstructionShip(ship.Definition));
|
||||||
var hasShipyard = world.Stations.Any(station =>
|
var hasShipyard = world.Stations.Any(station =>
|
||||||
string.Equals(station.FactionId, faction.Id, StringComparison.Ordinal) &&
|
string.Equals(station.FactionId, faction.Id, StringComparison.Ordinal) &&
|
||||||
station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.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}",
|
Id = $"{campaign.Id}-protect-station-{station.Id}",
|
||||||
CampaignId = campaign.Id,
|
CampaignId = campaign.Id,
|
||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "protect-station",
|
Kind = ProtectStation,
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "protect-station",
|
BehaviorKind = ProtectStation,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 8f,
|
Priority = campaign.Priority + 8f,
|
||||||
HomeSystemId = station.SystemId,
|
HomeSystemId = station.SystemId,
|
||||||
@@ -1389,7 +1394,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "patrol-front",
|
Kind = "patrol-front",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "patrol",
|
BehaviorKind = Patrol,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 2f,
|
Priority = campaign.Priority + 2f,
|
||||||
HomeSystemId = campaign.TargetSystemId,
|
HomeSystemId = campaign.TargetSystemId,
|
||||||
@@ -1414,7 +1419,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "police-front",
|
Kind = "police-front",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "police",
|
BehaviorKind = Police,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 1f,
|
Priority = campaign.Priority + 1f,
|
||||||
HomeSystemId = campaign.TargetSystemId,
|
HomeSystemId = campaign.TargetSystemId,
|
||||||
@@ -1454,7 +1459,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "strike-station",
|
Kind = "strike-station",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "attack-target",
|
BehaviorKind = AttackTarget,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 10f,
|
Priority = campaign.Priority + 10f,
|
||||||
TargetSystemId = enemyStation.SystemId,
|
TargetSystemId = enemyStation.SystemId,
|
||||||
@@ -1478,7 +1483,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "hold-front",
|
Kind = "hold-front",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "protect-position",
|
BehaviorKind = ProtectPosition,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 3f,
|
Priority = campaign.Priority + 3f,
|
||||||
TargetSystemId = campaign.TargetSystemId,
|
TargetSystemId = campaign.TargetSystemId,
|
||||||
@@ -1500,7 +1505,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "fleet-sustainment",
|
Kind = "fleet-sustainment",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "supply-fleet",
|
BehaviorKind = SupplyFleet,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 1.5f,
|
Priority = campaign.Priority + 1.5f,
|
||||||
HomeSystemId = campaign.TargetSystemId,
|
HomeSystemId = campaign.TargetSystemId,
|
||||||
@@ -1539,7 +1544,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "construct-site",
|
Kind = "construct-site",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "construct-station",
|
BehaviorKind = ConstructStation,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 8f,
|
Priority = campaign.Priority + 8f,
|
||||||
HomeSystemId = expansionProject.SystemId,
|
HomeSystemId = expansionProject.SystemId,
|
||||||
@@ -1564,7 +1569,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "supply-site",
|
Kind = "supply-site",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "find-build-tasks",
|
BehaviorKind = FindBuildTasks,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 4f,
|
Priority = campaign.Priority + 4f,
|
||||||
HomeSystemId = expansionProject.SystemId,
|
HomeSystemId = expansionProject.SystemId,
|
||||||
@@ -1589,7 +1594,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "guard-site",
|
Kind = "guard-site",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "protect-position",
|
BehaviorKind = ProtectPosition,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 2f,
|
Priority = campaign.Priority + 2f,
|
||||||
TargetSystemId = expansionProject.SystemId,
|
TargetSystemId = expansionProject.SystemId,
|
||||||
@@ -1614,7 +1619,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "mine-expansion-input",
|
Kind = "mine-expansion-input",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "expert-auto-mine",
|
BehaviorKind = ExpertAutoMine,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 1f,
|
Priority = campaign.Priority + 1f,
|
||||||
HomeSystemId = expansionProject.SystemId,
|
HomeSystemId = expansionProject.SystemId,
|
||||||
@@ -1655,7 +1660,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "trade-shortage",
|
Kind = "trade-shortage",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "fill-shortages",
|
BehaviorKind = FillShortages,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 5f,
|
Priority = campaign.Priority + 5f,
|
||||||
HomeSystemId = anchorStation?.SystemId,
|
HomeSystemId = anchorStation?.SystemId,
|
||||||
@@ -1680,7 +1685,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "mine-shortage",
|
Kind = "mine-shortage",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "expert-auto-mine",
|
BehaviorKind = ExpertAutoMine,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 3f,
|
Priority = campaign.Priority + 3f,
|
||||||
HomeSystemId = anchorStation?.SystemId,
|
HomeSystemId = anchorStation?.SystemId,
|
||||||
@@ -1703,7 +1708,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "revisit-stations",
|
Kind = "revisit-stations",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "revisit-known-stations",
|
BehaviorKind = RevisitKnownStations,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 0.5f,
|
Priority = campaign.Priority + 0.5f,
|
||||||
HomeSystemId = anchorStation?.SystemId,
|
HomeSystemId = anchorStation?.SystemId,
|
||||||
@@ -1743,7 +1748,7 @@ internal sealed class CommanderPlanningService
|
|||||||
CampaignId = campaign.Id,
|
CampaignId = campaign.Id,
|
||||||
Kind = "feed-shipyard",
|
Kind = "feed-shipyard",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "fill-shortages",
|
BehaviorKind = FillShortages,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 4f,
|
Priority = campaign.Priority + 4f,
|
||||||
HomeSystemId = shipyard.SystemId,
|
HomeSystemId = shipyard.SystemId,
|
||||||
@@ -1768,7 +1773,7 @@ internal sealed class CommanderPlanningService
|
|||||||
CampaignId = campaign.Id,
|
CampaignId = campaign.Id,
|
||||||
Kind = "mine-bottleneck",
|
Kind = "mine-bottleneck",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "expert-auto-mine",
|
BehaviorKind = ExpertAutoMine,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 2f,
|
Priority = campaign.Priority + 2f,
|
||||||
HomeSystemId = shipyard.SystemId,
|
HomeSystemId = shipyard.SystemId,
|
||||||
@@ -1838,7 +1843,9 @@ internal sealed class CommanderPlanningService
|
|||||||
var reservedCommanderIds = new HashSet<string>(StringComparer.Ordinal);
|
var reservedCommanderIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
var availableMilitaryCommanders = commanders.Count(commander =>
|
var availableMilitaryCommanders = commanders.Count(commander =>
|
||||||
commander.Kind == CommanderKind.Ship &&
|
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;
|
var committedMilitaryCommanders = 0;
|
||||||
|
|
||||||
foreach (var objective in objectives
|
foreach (var objective in objectives
|
||||||
@@ -1921,11 +1928,11 @@ internal sealed class CommanderPlanningService
|
|||||||
|
|
||||||
return objective.BehaviorKind switch
|
return objective.BehaviorKind switch
|
||||||
{
|
{
|
||||||
"construct-station" => string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal),
|
ConstructStation => IsConstructionShip(ship.Definition),
|
||||||
"find-build-tasks" => string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal),
|
FindBuildTasks => IsTransportShip(ship.Definition),
|
||||||
"fill-shortages" or "advanced-auto-trade" or "revisit-known-stations" or "supply-fleet" => string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal),
|
FillShortages or AdvancedAutoTrade or RevisitKnownStations or SupplyFleet => IsTransportShip(ship.Definition),
|
||||||
"local-auto-mine" or "advanced-auto-mine" or "expert-auto-mine" => HasShipCapabilities(ship.Definition, "mining"),
|
LocalAutoMine or AdvancedAutoMine or ExpertAutoMine => IsMiningShip(ship.Definition),
|
||||||
"patrol" or "police" or "protect-position" or "protect-ship" or "protect-station" or "attack-target" => string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal),
|
Patrol or Police or ProtectPosition or ProtectShip or ProtectStation or AttackTarget => IsMilitaryShip(ship.Definition),
|
||||||
_ => true,
|
_ => true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1992,7 +1999,7 @@ internal sealed class CommanderPlanningService
|
|||||||
Kind = "military-fleet",
|
Kind = "military-fleet",
|
||||||
Status = economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount ? "stable" : "active",
|
Status = economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount ? "stable" : "active",
|
||||||
Priority = 80f + (threatAssessment.ThreatSignals.Count * 4f),
|
Priority = 80f + (threatAssessment.ThreatSignals.Count * 4f),
|
||||||
ShipKind = "military",
|
ShipKind = MilitaryShipCategory,
|
||||||
TargetCount = economicAssessment.TargetMilitaryShipCount,
|
TargetCount = economicAssessment.TargetMilitaryShipCount,
|
||||||
CurrentCount = economicAssessment.MilitaryShipCount,
|
CurrentCount = economicAssessment.MilitaryShipCount,
|
||||||
Notes = "Maintain enough military hulls for all active fronts.",
|
Notes = "Maintain enough military hulls for all active fronts.",
|
||||||
@@ -2004,7 +2011,7 @@ internal sealed class CommanderPlanningService
|
|||||||
Kind = "mining-fleet",
|
Kind = "mining-fleet",
|
||||||
Status = economicAssessment.MinerShipCount >= economicAssessment.TargetMinerShipCount ? "stable" : "active",
|
Status = economicAssessment.MinerShipCount >= economicAssessment.TargetMinerShipCount ? "stable" : "active",
|
||||||
Priority = 60f,
|
Priority = 60f,
|
||||||
ShipKind = "mining",
|
ShipKind = MiningShipCategory,
|
||||||
TargetCount = economicAssessment.TargetMinerShipCount,
|
TargetCount = economicAssessment.TargetMinerShipCount,
|
||||||
CurrentCount = economicAssessment.MinerShipCount,
|
CurrentCount = economicAssessment.MinerShipCount,
|
||||||
Notes = "Maintain raw resource extraction capacity.",
|
Notes = "Maintain raw resource extraction capacity.",
|
||||||
@@ -2016,7 +2023,7 @@ internal sealed class CommanderPlanningService
|
|||||||
Kind = "logistics-fleet",
|
Kind = "logistics-fleet",
|
||||||
Status = economicAssessment.TransportShipCount >= economicAssessment.TargetTransportShipCount ? "stable" : "active",
|
Status = economicAssessment.TransportShipCount >= economicAssessment.TargetTransportShipCount ? "stable" : "active",
|
||||||
Priority = 62f,
|
Priority = 62f,
|
||||||
ShipKind = "transport",
|
ShipKind = TransportShipCategory,
|
||||||
TargetCount = economicAssessment.TargetTransportShipCount,
|
TargetCount = economicAssessment.TargetTransportShipCount,
|
||||||
CurrentCount = economicAssessment.TransportShipCount,
|
CurrentCount = economicAssessment.TransportShipCount,
|
||||||
Notes = "Maintain logistics throughput across stations and fronts.",
|
Notes = "Maintain logistics throughput across stations and fronts.",
|
||||||
@@ -2028,7 +2035,7 @@ internal sealed class CommanderPlanningService
|
|||||||
Kind = "construction-fleet",
|
Kind = "construction-fleet",
|
||||||
Status = economicAssessment.ConstructorShipCount >= economicAssessment.TargetConstructorShipCount ? "stable" : "active",
|
Status = economicAssessment.ConstructorShipCount >= economicAssessment.TargetConstructorShipCount ? "stable" : "active",
|
||||||
Priority = expansionProject is null ? 35f : 68f,
|
Priority = expansionProject is null ? 35f : 68f,
|
||||||
ShipKind = "construction",
|
ShipKind = ConstructionShipCategory,
|
||||||
TargetCount = economicAssessment.TargetConstructorShipCount,
|
TargetCount = economicAssessment.TargetConstructorShipCount,
|
||||||
CurrentCount = economicAssessment.ConstructorShipCount,
|
CurrentCount = economicAssessment.ConstructorShipCount,
|
||||||
Notes = "Maintain construction capacity for frontier growth.",
|
Notes = "Maintain construction capacity for frontier growth.",
|
||||||
@@ -2347,10 +2354,10 @@ internal sealed class CommanderPlanningService
|
|||||||
Kind = "fleet-command",
|
Kind = "fleet-command",
|
||||||
BehaviorKind = campaign.Kind switch
|
BehaviorKind = campaign.Kind switch
|
||||||
{
|
{
|
||||||
"offense" => "attack-target",
|
"offense" => AttackTarget,
|
||||||
"defense" => "protect-position",
|
"defense" => ProtectPosition,
|
||||||
"expansion" => "protect-position",
|
"expansion" => ProtectPosition,
|
||||||
_ => "patrol",
|
_ => Patrol,
|
||||||
},
|
},
|
||||||
Status = campaign.Status,
|
Status = campaign.Status,
|
||||||
Priority = campaign.Priority,
|
Priority = campaign.Priority,
|
||||||
@@ -2380,7 +2387,7 @@ internal sealed class CommanderPlanningService
|
|||||||
{
|
{
|
||||||
ObjectiveId = $"objective-station-{station.Id}-ship-production",
|
ObjectiveId = $"objective-station-{station.Id}-ship-production",
|
||||||
Kind = "ship-production-focus",
|
Kind = "ship-production-focus",
|
||||||
BehaviorKind = "fill-shortages",
|
BehaviorKind = FillShortages,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = 55f,
|
Priority = 55f,
|
||||||
HomeSystemId = station.SystemId,
|
HomeSystemId = station.SystemId,
|
||||||
@@ -2399,7 +2406,7 @@ internal sealed class CommanderPlanningService
|
|||||||
{
|
{
|
||||||
ObjectiveId = $"objective-station-{station.Id}-commodity-focus-{bottleneckItem}",
|
ObjectiveId = $"objective-station-{station.Id}-commodity-focus-{bottleneckItem}",
|
||||||
Kind = "commodity-focus",
|
Kind = "commodity-focus",
|
||||||
BehaviorKind = "fill-shortages",
|
BehaviorKind = FillShortages,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = 45f,
|
Priority = 45f,
|
||||||
HomeSystemId = station.SystemId,
|
HomeSystemId = station.SystemId,
|
||||||
@@ -2418,7 +2425,7 @@ internal sealed class CommanderPlanningService
|
|||||||
{
|
{
|
||||||
ObjectiveId = $"objective-station-{station.Id}-expansion-support",
|
ObjectiveId = $"objective-station-{station.Id}-expansion-support",
|
||||||
Kind = "expansion-support",
|
Kind = "expansion-support",
|
||||||
BehaviorKind = "find-build-tasks",
|
BehaviorKind = FindBuildTasks,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = 40f,
|
Priority = 40f,
|
||||||
HomeSystemId = station.SystemId,
|
HomeSystemId = station.SystemId,
|
||||||
@@ -2435,7 +2442,7 @@ internal sealed class CommanderPlanningService
|
|||||||
{
|
{
|
||||||
ObjectiveId = $"objective-station-{station.Id}-oversight",
|
ObjectiveId = $"objective-station-{station.Id}-oversight",
|
||||||
Kind = "station-oversight",
|
Kind = "station-oversight",
|
||||||
BehaviorKind = "fill-shortages",
|
BehaviorKind = FillShortages,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = 30f,
|
Priority = 30f,
|
||||||
HomeSystemId = station.SystemId,
|
HomeSystemId = station.SystemId,
|
||||||
@@ -2460,7 +2467,7 @@ internal sealed class CommanderPlanningService
|
|||||||
faction.StrategicState.Objectives.Any(objective =>
|
faction.StrategicState.Objectives.Any(objective =>
|
||||||
objective.CampaignId == campaign.Id &&
|
objective.CampaignId == campaign.Id &&
|
||||||
objective.CommanderId is not null &&
|
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)
|
.Select(campaign => campaign.Id)
|
||||||
.ToHashSet(StringComparer.Ordinal);
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
@@ -2510,10 +2517,10 @@ internal sealed class CommanderPlanningService
|
|||||||
Kind = "fleet-command",
|
Kind = "fleet-command",
|
||||||
BehaviorKind = campaign.Kind switch
|
BehaviorKind = campaign.Kind switch
|
||||||
{
|
{
|
||||||
"offense" => "attack-target",
|
"offense" => AttackTarget,
|
||||||
"defense" => "protect-position",
|
"defense" => ProtectPosition,
|
||||||
"expansion" => "protect-position",
|
"expansion" => ProtectPosition,
|
||||||
_ => "patrol",
|
_ => Patrol,
|
||||||
},
|
},
|
||||||
Status = campaign.Status,
|
Status = campaign.Status,
|
||||||
Priority = campaign.Priority + 1f,
|
Priority = campaign.Priority + 1f,
|
||||||
@@ -2581,7 +2588,7 @@ internal sealed class CommanderPlanningService
|
|||||||
{
|
{
|
||||||
if (objective?.CampaignId is not null
|
if (objective?.CampaignId is not null
|
||||||
&& fleetCommanders.TryGetValue(objective.CampaignId, out var fleetCommander)
|
&& 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;
|
return fleetCommander.Id;
|
||||||
}
|
}
|
||||||
@@ -2598,25 +2605,39 @@ internal sealed class CommanderPlanningService
|
|||||||
private static DefaultBehaviorRuntime BuildFallbackBehavior(SimulationWorld world, ShipRuntime ship)
|
private static DefaultBehaviorRuntime BuildFallbackBehavior(SimulationWorld world, ShipRuntime ship)
|
||||||
{
|
{
|
||||||
var homeStation = ResolveFallbackHomeStation(world, 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
|
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,
|
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
HomeStationId = homeStation?.Id,
|
HomeStationId = homeStation?.Id,
|
||||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
PreferredItemId = null,
|
ItemId = null,
|
||||||
Radius = 24f,
|
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
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "advanced-auto-trade",
|
Kind = AdvancedAutoTrade,
|
||||||
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
HomeStationId = homeStation?.Id,
|
HomeStationId = homeStation?.Id,
|
||||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
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
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "construct-station",
|
Kind = ConstructStation,
|
||||||
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
HomeStationId = homeStation?.Id,
|
HomeStationId = homeStation?.Id,
|
||||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
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 anchor = homeStation?.Position ?? ship.Position;
|
||||||
var patrolRadius = (homeStation?.Radius ?? 30f) + 90f;
|
var patrolRadius = (homeStation?.Radius ?? 30f) + 90f;
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "patrol",
|
Kind = Patrol,
|
||||||
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
HomeStationId = homeStation?.Id,
|
HomeStationId = homeStation?.Id,
|
||||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
@@ -2660,7 +2681,7 @@ internal sealed class CommanderPlanningService
|
|||||||
|
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "idle",
|
Kind = Idle,
|
||||||
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
HomeStationId = homeStation?.Id,
|
HomeStationId = homeStation?.Id,
|
||||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
@@ -2684,15 +2705,15 @@ internal sealed class CommanderPlanningService
|
|||||||
var areaSystemId = objective.TargetSystemId ?? objective.HomeSystemId ?? fallback.AreaSystemId ?? ship.SystemId;
|
var areaSystemId = objective.TargetSystemId ?? objective.HomeSystemId ?? fallback.AreaSystemId ?? ship.SystemId;
|
||||||
var radius = objective.BehaviorKind switch
|
var radius = objective.BehaviorKind switch
|
||||||
{
|
{
|
||||||
"protect-position" or "protect-station" or "patrol" or "police" => MathF.Max(80f, fallback.Radius),
|
ProtectPosition or ProtectStation or Patrol or Police => MathF.Max(80f, fallback.Radius),
|
||||||
"follow-ship" or "protect-ship" => MathF.Max(18f, fallback.Radius * 0.6f),
|
FollowShip or ProtectShip => MathF.Max(18f, fallback.Radius * 0.6f),
|
||||||
"fill-shortages" or "advanced-auto-trade" or "find-build-tasks" => MathF.Max(20f, fallback.Radius),
|
FillShortages or AdvancedAutoTrade or FindBuildTasks => MathF.Max(20f, fallback.Radius),
|
||||||
_ => fallback.Radius,
|
_ => fallback.Radius,
|
||||||
};
|
};
|
||||||
var maxRange = objective.BehaviorKind switch
|
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),
|
AttackTarget or ProtectPosition or ProtectStation or ProtectShip 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),
|
FillShortages or AdvancedAutoTrade or FindBuildTasks or SupplyFleet => Math.Max(2, fallback.MaxSystemRange),
|
||||||
_ => fallback.MaxSystemRange,
|
_ => fallback.MaxSystemRange,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2703,16 +2724,16 @@ internal sealed class CommanderPlanningService
|
|||||||
HomeStationId = objective.HomeStationId ?? fallback.HomeStationId,
|
HomeStationId = objective.HomeStationId ?? fallback.HomeStationId,
|
||||||
AreaSystemId = areaSystemId,
|
AreaSystemId = areaSystemId,
|
||||||
TargetEntityId = objective.TargetEntityId,
|
TargetEntityId = objective.TargetEntityId,
|
||||||
PreferredItemId = objective.ItemId ?? fallback.PreferredItemId,
|
ItemId = objective.ItemId ?? fallback.ItemId,
|
||||||
PreferredNodeId = fallback.PreferredNodeId,
|
PreferredNodeId = fallback.PreferredNodeId,
|
||||||
PreferredConstructionSiteId = objective.Kind is "construct-site" or "supply-site" ? objective.TargetEntityId : fallback.PreferredConstructionSiteId,
|
PreferredConstructionSiteId = objective.Kind is "construct-site" or "supply-site" ? objective.TargetEntityId : fallback.PreferredConstructionSiteId,
|
||||||
PreferredModuleId = fallback.PreferredModuleId,
|
PreferredModuleId = fallback.PreferredModuleId,
|
||||||
TargetPosition = objective.TargetPosition ?? fallback.TargetPosition,
|
TargetPosition = objective.TargetPosition ?? fallback.TargetPosition,
|
||||||
WaitSeconds = objective.BehaviorKind == "supply-fleet" ? 4f : fallback.WaitSeconds,
|
WaitSeconds = objective.BehaviorKind == SupplyFleet ? 4f : fallback.WaitSeconds,
|
||||||
Radius = radius,
|
Radius = radius,
|
||||||
MaxSystemRange = maxRange,
|
MaxSystemRange = maxRange,
|
||||||
KnownStationsOnly = objective.BehaviorKind == "revisit-known-stations",
|
KnownStationsOnly = objective.BehaviorKind == RevisitKnownStations,
|
||||||
PatrolPoints = objective.BehaviorKind == "patrol"
|
PatrolPoints = objective.BehaviorKind == Patrol
|
||||||
? BuildPatrolPoints(objective.TargetPosition ?? fallback.TargetPosition ?? ship.Position, radius)
|
? BuildPatrolPoints(objective.TargetPosition ?? fallback.TargetPosition ?? ship.Position, radius)
|
||||||
: [],
|
: [],
|
||||||
PatrolIndex = ship.DefaultBehavior.PatrolIndex,
|
PatrolIndex = ship.DefaultBehavior.PatrolIndex,
|
||||||
@@ -2728,7 +2749,7 @@ internal sealed class CommanderPlanningService
|
|||||||
target.HomeStationId = source.HomeStationId;
|
target.HomeStationId = source.HomeStationId;
|
||||||
target.AreaSystemId = source.AreaSystemId;
|
target.AreaSystemId = source.AreaSystemId;
|
||||||
target.TargetEntityId = source.TargetEntityId;
|
target.TargetEntityId = source.TargetEntityId;
|
||||||
target.PreferredItemId = source.PreferredItemId;
|
target.ItemId = source.ItemId;
|
||||||
target.PreferredNodeId = source.PreferredNodeId;
|
target.PreferredNodeId = source.PreferredNodeId;
|
||||||
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
|
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
|
||||||
target.PreferredModuleId = source.PreferredModuleId;
|
target.PreferredModuleId = source.PreferredModuleId;
|
||||||
@@ -2749,7 +2770,7 @@ internal sealed class CommanderPlanningService
|
|||||||
&& string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal)
|
&& string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
|
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.TargetEntityId, right.TargetEntityId, 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.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
|
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
|
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
|
||||||
@@ -2805,13 +2826,15 @@ internal sealed class CommanderPlanningService
|
|||||||
{
|
{
|
||||||
Id = $"ai-order-{objective.Id}",
|
Id = $"ai-order-{objective.Id}",
|
||||||
Kind = objective.StagingOrderKind,
|
Kind = objective.StagingOrderKind,
|
||||||
|
SourceKind = ShipOrderSourceKind.Commander,
|
||||||
|
SourceId = objective.Id,
|
||||||
Priority = 90 + objective.ReinforcementLevel,
|
Priority = 90 + objective.ReinforcementLevel,
|
||||||
InterruptCurrentPlan = true,
|
InterruptCurrentPlan = true,
|
||||||
Label = $"{objective.Kind} staging",
|
Label = $"{objective.Kind} staging",
|
||||||
TargetEntityId = objective.TargetEntityId,
|
TargetEntityId = objective.TargetEntityId,
|
||||||
TargetSystemId = targetSystemId,
|
TargetSystemId = targetSystemId,
|
||||||
TargetPosition = targetPosition,
|
TargetPosition = targetPosition,
|
||||||
DestinationStationId = objective.BehaviorKind == "dock-and-wait" ? objective.TargetEntityId : null,
|
DestinationStationId = objective.BehaviorKind == DockAndWait ? objective.TargetEntityId : null,
|
||||||
ItemId = objective.ItemId,
|
ItemId = objective.ItemId,
|
||||||
WaitSeconds = 0f,
|
WaitSeconds = 0f,
|
||||||
Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f),
|
Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f),
|
||||||
@@ -2885,6 +2908,8 @@ internal sealed class CommanderPlanningService
|
|||||||
private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) =>
|
private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) =>
|
||||||
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
|
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.Kind, right.Kind, 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.Priority == right.Priority
|
||||||
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
|
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
|
||||||
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
|
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
|
||||||
@@ -2920,7 +2945,7 @@ internal sealed class CommanderPlanningService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsCombatObjective(FactionOperationalObjectiveRuntime objective) =>
|
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)
|
private static float EstimateFriendlyAssetValue(SimulationWorld world, string factionId, string systemId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
|
||||||
|
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||||
|
|
||||||
namespace SpaceGame.Api.Geopolitics.Simulation;
|
namespace SpaceGame.Api.Geopolitics.Simulation;
|
||||||
|
|
||||||
internal sealed class GeopoliticalSimulationService
|
internal sealed class GeopoliticalSimulationService
|
||||||
@@ -198,14 +200,24 @@ internal sealed class GeopoliticalSimulationService
|
|||||||
var stationStrength = (stationsByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 50f;
|
var stationStrength = (stationsByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 50f;
|
||||||
var siteStrength = (sitesByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 18f;
|
var siteStrength = (sitesByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 18f;
|
||||||
var shipStrength = shipsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(ship =>
|
var shipStrength = shipsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(ship =>
|
||||||
ship.Definition.Kind switch
|
{
|
||||||
{
|
if (IsMilitaryShip(ship.Definition))
|
||||||
"military" => 9f,
|
{
|
||||||
"construction" => 4f,
|
return 9f;
|
||||||
"transport" => 3f,
|
}
|
||||||
_ when ship.Definition.Kind == "mining" || ship.Definition.Kind == "miner" => 3f,
|
|
||||||
_ => 2f,
|
if (IsConstructionShip(ship.Definition))
|
||||||
}) ?? 0f;
|
{
|
||||||
|
return 4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsTransportShip(ship.Definition) || IsMiningShip(ship.Definition))
|
||||||
|
{
|
||||||
|
return 3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 2f;
|
||||||
|
}) ?? 0f;
|
||||||
var logisticsStrength = MathF.Min(30f, stationStrength * 0.18f) + siteStrength;
|
var logisticsStrength = MathF.Min(30f, stationStrength * 0.18f) + siteStrength;
|
||||||
influences.Add(new TerritoryInfluenceRuntime
|
influences.Add(new TerritoryInfluenceRuntime
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.Definitions;
|
||||||
global using SpaceGame.Api.Economy.Contracts;
|
global using SpaceGame.Api.Economy.Contracts;
|
||||||
global using SpaceGame.Api.Economy.Runtime;
|
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.Shared.Runtime;
|
||||||
global using SpaceGame.Api.Ships.Contracts;
|
global using SpaceGame.Api.Ships.Contracts;
|
||||||
global using SpaceGame.Api.Ships.Runtime;
|
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.Simulation.Core;
|
||||||
global using SpaceGame.Api.Stations.Contracts;
|
global using SpaceGame.Api.Stations.Contracts;
|
||||||
global using SpaceGame.Api.Stations.Runtime;
|
global using SpaceGame.Api.Stations.Runtime;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ public sealed class CreatePlayerOrganizationHandler(WorldService worldService) :
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Post("/api/player-faction/organizations");
|
Post("/api/player-faction/organizations");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerOrganizationCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerOrganizationCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ public sealed class DeletePlayerDirectiveHandler(WorldService worldService) : En
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Delete("/api/player-faction/directives/{directiveId}");
|
Delete("/api/player-faction/directives/{directiveId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(DeletePlayerDirectiveRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(DeletePlayerDirectiveRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ public sealed class DeletePlayerOrganizationHandler(WorldService worldService) :
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Delete("/api/player-faction/organizations/{organizationId}");
|
Delete("/api/player-faction/organizations/{organizationId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(DeletePlayerOrganizationRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(DeletePlayerOrganizationRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ public sealed class GetPlayerFactionHandler(WorldService worldService) : Endpoin
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Get("/api/player-faction");
|
Get("/api/player-faction");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(CancellationToken cancellationToken)
|
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ public sealed class UpdatePlayerOrganizationMembershipHandler(WorldService world
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Put("/api/player-faction/organizations/{organizationId}/membership");
|
Put("/api/player-faction/organizations/{organizationId}/membership");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerOrganizationMembershipCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerOrganizationMembershipCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ public sealed class UpdatePlayerStrategicIntentHandler(WorldService worldService
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Put("/api/player-faction/strategic-intent");
|
Put("/api/player-faction/strategic-intent");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerStrategicIntentCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerStrategicIntentCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ public sealed class UpsertPlayerAssignmentHandler(WorldService worldService) : E
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Put("/api/player-faction/assets/{assetId}/assignment");
|
Put("/api/player-faction/assets/{assetId}/assignment");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerAssetAssignmentCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerAssetAssignmentCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerAutomationPolicyHandler(WorldService worldServic
|
|||||||
{
|
{
|
||||||
Post("/api/player-faction/automation-policies");
|
Post("/api/player-faction/automation-policies");
|
||||||
Put("/api/player-faction/automation-policies/{automationPolicyId}");
|
Put("/api/player-faction/automation-policies/{automationPolicyId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerAutomationPolicyCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerAutomationPolicyCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerDirectiveHandler(WorldService worldService) : En
|
|||||||
{
|
{
|
||||||
Post("/api/player-faction/directives");
|
Post("/api/player-faction/directives");
|
||||||
Put("/api/player-faction/directives/{directiveId}");
|
Put("/api/player-faction/directives/{directiveId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerDirectiveCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerDirectiveCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerPolicyHandler(WorldService worldService) : Endpo
|
|||||||
{
|
{
|
||||||
Post("/api/player-faction/policies");
|
Post("/api/player-faction/policies");
|
||||||
Put("/api/player-faction/policies/{policyId}");
|
Put("/api/player-faction/policies/{policyId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerPolicyCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerPolicyCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerProductionProgramHandler(WorldService worldServi
|
|||||||
{
|
{
|
||||||
Post("/api/player-faction/production-programs");
|
Post("/api/player-faction/production-programs");
|
||||||
Put("/api/player-faction/production-programs/{productionProgramId}");
|
Put("/api/player-faction/production-programs/{productionProgramId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerProductionProgramCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerProductionProgramCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerReinforcementPolicyHandler(WorldService worldSer
|
|||||||
{
|
{
|
||||||
Post("/api/player-faction/reinforcement-policies");
|
Post("/api/player-faction/reinforcement-policies");
|
||||||
Put("/api/player-faction/reinforcement-policies/{reinforcementPolicyId}");
|
Put("/api/player-faction/reinforcement-policies/{reinforcementPolicyId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerReinforcementPolicyCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerReinforcementPolicyCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||||
|
|
||||||
namespace SpaceGame.Api.PlayerFaction.Runtime;
|
namespace SpaceGame.Api.PlayerFaction.Runtime;
|
||||||
|
|
||||||
public sealed class PlayerFactionRuntime
|
public sealed class PlayerFactionRuntime
|
||||||
@@ -180,7 +182,7 @@ public sealed class PlayerAutomationPolicyRuntime
|
|||||||
public string ScopeKind { get; set; } = "player-faction";
|
public string ScopeKind { get; set; } = "player-faction";
|
||||||
public string? ScopeId { get; set; }
|
public string? ScopeId { get; set; }
|
||||||
public bool Enabled { get; set; } = true;
|
public bool Enabled { get; set; } = true;
|
||||||
public string BehaviorKind { get; set; } = "idle";
|
public string BehaviorKind { get; set; } = Idle;
|
||||||
public bool UseOrders { get; set; }
|
public bool UseOrders { get; set; }
|
||||||
public string? StagingOrderKind { get; set; }
|
public string? StagingOrderKind { get; set; }
|
||||||
public int MaxSystemRange { get; set; }
|
public int MaxSystemRange { get; set; }
|
||||||
@@ -242,7 +244,7 @@ public sealed class PlayerDirectiveRuntime
|
|||||||
public string? HomeStationId { get; set; }
|
public string? HomeStationId { get; set; }
|
||||||
public string? SourceStationId { get; set; }
|
public string? SourceStationId { get; set; }
|
||||||
public string? DestinationStationId { 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 bool UseOrders { get; set; }
|
||||||
public string? StagingOrderKind { get; set; }
|
public string? StagingOrderKind { get; set; }
|
||||||
public string? ItemId { get; set; }
|
public string? ItemId { get; set; }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||||
|
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||||
|
|
||||||
namespace SpaceGame.Api.PlayerFaction.Simulation;
|
namespace SpaceGame.Api.PlayerFaction.Simulation;
|
||||||
|
|
||||||
internal sealed class PlayerFactionService
|
internal sealed class PlayerFactionService
|
||||||
@@ -6,58 +9,61 @@ internal sealed class PlayerFactionService
|
|||||||
private const int MaxAlerts = 32;
|
private const int MaxAlerts = 32;
|
||||||
private const string PlayerFactionDomainId = "player-faction";
|
private const string PlayerFactionDomainId = "player-faction";
|
||||||
|
|
||||||
internal static bool IsPlayerFaction(SimulationWorld world, string factionId) =>
|
internal static bool IsPlayerFaction(IPlayerStateStore playerStateStore, string factionId) =>
|
||||||
world.PlayerFaction is not null && string.Equals(world.PlayerFaction.SovereignFactionId, factionId, StringComparison.Ordinal);
|
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 playerStateStore.TryGetPlayerFaction(playerId, out var player) ? player : null;
|
||||||
{
|
}
|
||||||
return world.PlayerFaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
|
||||||
|
{
|
||||||
var sovereignFaction = world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).FirstOrDefault()
|
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.");
|
?? 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,
|
Id = PlayerFactionDomainId,
|
||||||
Label = $"{sovereignFaction.Label} Command",
|
Label = $"{sovereignFaction.Label} Command",
|
||||||
SovereignFactionId = sovereignFaction.Id,
|
SovereignFactionId = sovereignFaction.Id,
|
||||||
CreatedAtUtc = world.GeneratedAtUtc,
|
CreatedAtUtc = world.GeneratedAtUtc,
|
||||||
UpdatedAtUtc = world.GeneratedAtUtc,
|
UpdatedAtUtc = world.GeneratedAtUtc,
|
||||||
};
|
});
|
||||||
|
|
||||||
EnsureBaseStructures(world, world.PlayerFaction);
|
EnsureBaseStructures(world, player);
|
||||||
SyncRegistry(world, world.PlayerFaction);
|
SyncRegistry(world, player);
|
||||||
return world.PlayerFaction;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var player = EnsureDomain(world);
|
foreach (var player in playerStateStore.GetPlayerFactions())
|
||||||
EnsureBaseStructures(world, player);
|
{
|
||||||
SyncRegistry(world, player);
|
EnsureBaseStructures(world, player);
|
||||||
PrunePlayerState(world, player);
|
SyncRegistry(world, player);
|
||||||
RefreshGeopoliticalOrganizationContext(world, player);
|
PrunePlayerState(world, player);
|
||||||
ReconcileOrganizationAssignments(world, player);
|
RefreshGeopoliticalOrganizationContext(world, player);
|
||||||
ReconcileDirectiveScopes(player);
|
ReconcileOrganizationAssignments(world, player);
|
||||||
RefreshProductionPrograms(world, player);
|
ReconcileDirectiveScopes(player);
|
||||||
ApplyStrategicIntegration(world, player);
|
RefreshProductionPrograms(world, player);
|
||||||
ApplyPolicies(world, player);
|
ApplyStrategicIntegration(world, player);
|
||||||
ApplyAssignmentsAndDirectives(world, player, events);
|
ApplyPolicies(world, player);
|
||||||
RefreshAlerts(world, player);
|
ApplyAssignmentsAndDirectives(world, player, events);
|
||||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
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 id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player));
|
||||||
var nowUtc = DateTimeOffset.UtcNow;
|
var nowUtc = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
@@ -172,9 +178,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
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);
|
RemoveOrganization(player, organizationId);
|
||||||
player.Assignments.RemoveAll(assignment =>
|
player.Assignments.RemoveAll(assignment =>
|
||||||
assignment.FleetId == organizationId ||
|
assignment.FleetId == organizationId ||
|
||||||
@@ -190,9 +196,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
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);
|
var kind = ResolveOrganizationKind(player, organizationId);
|
||||||
switch (kind)
|
switch (kind)
|
||||||
{
|
{
|
||||||
@@ -241,9 +247,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
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
|
var directive = directiveId is null
|
||||||
? null
|
? null
|
||||||
: player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal));
|
: player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal));
|
||||||
@@ -318,9 +324,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
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);
|
player.Directives.RemoveAll(directive => directive.Id == directiveId);
|
||||||
foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId))
|
foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId))
|
||||||
{
|
{
|
||||||
@@ -332,9 +338,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
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
|
var policy = policyId is null
|
||||||
? null
|
? null
|
||||||
: player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal));
|
: player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal));
|
||||||
@@ -403,9 +409,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
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
|
var policy = automationPolicyId is null
|
||||||
? null
|
? null
|
||||||
: player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal));
|
: player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal));
|
||||||
@@ -461,9 +467,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
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
|
var policy = reinforcementPolicyId is null
|
||||||
? null
|
? null
|
||||||
: player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal));
|
: player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal));
|
||||||
@@ -495,9 +501,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
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
|
var program = productionProgramId is null
|
||||||
? null
|
? null
|
||||||
: player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal));
|
: player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal));
|
||||||
@@ -527,9 +533,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
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 =>
|
var assignment = player.Assignments.FirstOrDefault(candidate =>
|
||||||
string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) &&
|
string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) &&
|
||||||
string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal));
|
string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal));
|
||||||
@@ -586,9 +592,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
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.StrategicPosture = request.StrategicPosture;
|
||||||
player.StrategicIntent.EconomicPosture = request.EconomicPosture;
|
player.StrategicIntent.EconomicPosture = request.EconomicPosture;
|
||||||
player.StrategicIntent.MilitaryPosture = request.MilitaryPosture;
|
player.StrategicIntent.MilitaryPosture = request.MilitaryPosture;
|
||||||
@@ -602,9 +608,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
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))
|
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -625,6 +631,8 @@ internal sealed class PlayerFactionService
|
|||||||
{
|
{
|
||||||
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
|
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
|
||||||
Kind = request.Kind,
|
Kind = request.Kind,
|
||||||
|
SourceKind = ShipOrderSourceKind.Player,
|
||||||
|
SourceId = playerId,
|
||||||
Priority = request.Priority,
|
Priority = request.Priority,
|
||||||
InterruptCurrentPlan = request.InterruptCurrentPlan,
|
InterruptCurrentPlan = request.InterruptCurrentPlan,
|
||||||
Label = request.Label,
|
Label = request.Label,
|
||||||
@@ -643,11 +651,11 @@ internal sealed class PlayerFactionService
|
|||||||
KnownStationsOnly = request.KnownStationsOnly ?? false,
|
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;
|
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||||
ship.ControlSourceKind = "player-order";
|
ship.ControlSourceKind = "player-order";
|
||||||
ship.ControlSourceId = ship.OrderQueue
|
ship.ControlSourceId = ship.OrderQueue
|
||||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||||
.OrderByDescending(order => order.Priority)
|
.OrderByDescending(order => order.Priority)
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
.ThenBy(order => order.CreatedAtUtc)
|
||||||
.Select(order => order.Id)
|
.Select(order => order.Id)
|
||||||
@@ -659,9 +667,9 @@ internal sealed class PlayerFactionService
|
|||||||
return ship;
|
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))
|
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -676,21 +684,21 @@ internal sealed class PlayerFactionService
|
|||||||
var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId);
|
var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId);
|
||||||
if (removed > 0)
|
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;
|
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-order"
|
||||||
: "player-manual";
|
: "player-manual";
|
||||||
ship.ControlSourceId = ship.OrderQueue
|
ship.ControlSourceId = ship.OrderQueue
|
||||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||||
.OrderByDescending(order => order.Priority)
|
.OrderByDescending(order => order.Priority)
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
.ThenBy(order => order.CreatedAtUtc)
|
||||||
.Select(order => order.Id)
|
.Select(order => order.Id)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
ship.ControlReason = ship.OrderQueue
|
ship.ControlReason = ship.OrderQueue
|
||||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||||
.OrderByDescending(order => order.Priority)
|
.OrderByDescending(order => order.Priority)
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
.ThenBy(order => order.CreatedAtUtc)
|
||||||
.Select(order => order.Label ?? order.Kind)
|
.Select(order => order.Label ?? order.Kind)
|
||||||
@@ -702,9 +710,9 @@ internal sealed class PlayerFactionService
|
|||||||
return ship;
|
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))
|
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -723,7 +731,7 @@ internal sealed class PlayerFactionService
|
|||||||
directive = new PlayerDirectiveRuntime
|
directive = new PlayerDirectiveRuntime
|
||||||
{
|
{
|
||||||
Id = directiveId,
|
Id = directiveId,
|
||||||
Label = $"Direct control {ship.Definition.Label}",
|
Label = $"Direct control {ship.Definition.Name}",
|
||||||
ScopeKind = "ship",
|
ScopeKind = "ship",
|
||||||
ScopeId = shipId,
|
ScopeId = shipId,
|
||||||
Kind = "direct-control",
|
Kind = "direct-control",
|
||||||
@@ -732,7 +740,7 @@ internal sealed class PlayerFactionService
|
|||||||
player.Directives.Add(directive);
|
player.Directives.Add(directive);
|
||||||
}
|
}
|
||||||
|
|
||||||
directive.Label = $"Direct control {ship.Definition.Label}";
|
directive.Label = $"Direct control {ship.Definition.Name}";
|
||||||
directive.Kind = "direct-control";
|
directive.Kind = "direct-control";
|
||||||
directive.ScopeKind = "ship";
|
directive.ScopeKind = "ship";
|
||||||
directive.ScopeId = shipId;
|
directive.ScopeId = shipId;
|
||||||
@@ -746,7 +754,7 @@ internal sealed class PlayerFactionService
|
|||||||
directive.HomeStationId = request.HomeStationId;
|
directive.HomeStationId = request.HomeStationId;
|
||||||
directive.SourceStationId = request.HomeStationId;
|
directive.SourceStationId = request.HomeStationId;
|
||||||
directive.DestinationStationId = null;
|
directive.DestinationStationId = null;
|
||||||
directive.ItemId = request.PreferredItemId;
|
directive.ItemId = request.ItemId;
|
||||||
directive.PreferredNodeId = request.PreferredNodeId;
|
directive.PreferredNodeId = request.PreferredNodeId;
|
||||||
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
|
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
|
||||||
directive.PreferredModuleId = request.PreferredModuleId;
|
directive.PreferredModuleId = request.PreferredModuleId;
|
||||||
@@ -793,7 +801,7 @@ internal sealed class PlayerFactionService
|
|||||||
ship.ControlSourceKind = "player-directive";
|
ship.ControlSourceKind = "player-directive";
|
||||||
ship.ControlSourceId = directive.Id;
|
ship.ControlSourceId = directive.Id;
|
||||||
ship.ControlReason = directive.Label;
|
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;
|
player.UpdatedAtUtc = directive.UpdatedAtUtc;
|
||||||
ship.NeedsReplan = true;
|
ship.NeedsReplan = true;
|
||||||
ship.LastReplanReason = "player-behavior-configured";
|
ship.LastReplanReason = "player-behavior-configured";
|
||||||
@@ -826,7 +834,7 @@ internal sealed class PlayerFactionService
|
|||||||
{
|
{
|
||||||
Id = "player-core-automation",
|
Id = "player-core-automation",
|
||||||
Label = "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);
|
var changed = ApplyDirectiveToShip(commander, ship, directive, automation, assignment);
|
||||||
if (changed && directive is not null)
|
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"
|
? "player-directive"
|
||||||
: automation is not null
|
: automation is not null
|
||||||
? "player-automation"
|
? "player-automation"
|
||||||
: ship.OrderQueue.Any(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
: ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||||
? "player-order"
|
? "player-order"
|
||||||
: "player-manual";
|
: "player-manual";
|
||||||
var desiredControlSourceId = directive?.Id
|
var desiredControlSourceId = directive?.Id
|
||||||
?? automation?.Id
|
?? automation?.Id
|
||||||
?? ship.OrderQueue
|
?? ship.OrderQueue
|
||||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||||
.OrderByDescending(order => order.Priority)
|
.OrderByDescending(order => order.Priority)
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
.ThenBy(order => order.CreatedAtUtc)
|
||||||
.Select(order => order.Id)
|
.Select(order => order.Id)
|
||||||
@@ -1260,7 +1268,7 @@ internal sealed class PlayerFactionService
|
|||||||
var desiredControlReason = directive?.Label
|
var desiredControlReason = directive?.Label
|
||||||
?? automation?.Label
|
?? automation?.Label
|
||||||
?? ship.OrderQueue
|
?? ship.OrderQueue
|
||||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||||
.OrderByDescending(order => order.Priority)
|
.OrderByDescending(order => order.Priority)
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
.ThenBy(order => order.CreatedAtUtc)
|
||||||
.Select(order => order.Label ?? order.Kind)
|
.Select(order => order.Label ?? order.Kind)
|
||||||
@@ -1342,7 +1350,7 @@ internal sealed class PlayerFactionService
|
|||||||
HomeStationId = directive?.HomeStationId ?? ship.DefaultBehavior.HomeStationId,
|
HomeStationId = directive?.HomeStationId ?? ship.DefaultBehavior.HomeStationId,
|
||||||
AreaSystemId = directive?.TargetSystemId ?? directive?.HomeSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId,
|
AreaSystemId = directive?.TargetSystemId ?? directive?.HomeSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId,
|
||||||
TargetEntityId = directive?.TargetEntityId,
|
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,
|
PreferredNodeId = directive?.PreferredNodeId ?? ship.DefaultBehavior.PreferredNodeId,
|
||||||
PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId,
|
PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId,
|
||||||
PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId,
|
PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId,
|
||||||
@@ -1375,6 +1383,8 @@ internal sealed class PlayerFactionService
|
|||||||
{
|
{
|
||||||
Id = aiOrderId!,
|
Id = aiOrderId!,
|
||||||
Kind = directive.StagingOrderKind!,
|
Kind = directive.StagingOrderKind!,
|
||||||
|
SourceKind = ShipOrderSourceKind.Player,
|
||||||
|
SourceId = directive.Id,
|
||||||
Priority = Math.Max(0, directive.Priority),
|
Priority = Math.Max(0, directive.Priority),
|
||||||
InterruptCurrentPlan = true,
|
InterruptCurrentPlan = true,
|
||||||
Label = directive.Label,
|
Label = directive.Label,
|
||||||
@@ -1447,7 +1457,7 @@ internal sealed class PlayerFactionService
|
|||||||
target.HomeStationId = source.HomeStationId;
|
target.HomeStationId = source.HomeStationId;
|
||||||
target.AreaSystemId = source.AreaSystemId;
|
target.AreaSystemId = source.AreaSystemId;
|
||||||
target.TargetEntityId = source.TargetEntityId;
|
target.TargetEntityId = source.TargetEntityId;
|
||||||
target.PreferredItemId = source.PreferredItemId;
|
target.ItemId = source.ItemId;
|
||||||
target.PreferredNodeId = source.PreferredNodeId;
|
target.PreferredNodeId = source.PreferredNodeId;
|
||||||
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
|
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
|
||||||
target.PreferredModuleId = source.PreferredModuleId;
|
target.PreferredModuleId = source.PreferredModuleId;
|
||||||
@@ -1468,7 +1478,7 @@ internal sealed class PlayerFactionService
|
|||||||
&& string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal)
|
&& string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
|
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.TargetEntityId, right.TargetEntityId, 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.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
|
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, 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) =>
|
private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) =>
|
||||||
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
|
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.Kind, right.Kind, 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.Priority == right.Priority
|
||||||
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
|
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
|
||||||
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
|
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
|
||||||
@@ -1716,7 +1728,7 @@ internal sealed class PlayerFactionService
|
|||||||
{
|
{
|
||||||
program.CurrentCount = world.Ships.Count(ship =>
|
program.CurrentCount = world.Ships.Count(ship =>
|
||||||
ship.FactionId == player.SovereignFactionId &&
|
ship.FactionId == player.SovereignFactionId &&
|
||||||
string.Equals(ship.Definition.Kind, program.TargetShipKind, StringComparison.Ordinal));
|
string.Equals(GetShipCategory(ship.Definition), program.TargetShipKind, StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -2113,7 +2125,7 @@ internal sealed class PlayerFactionService
|
|||||||
{
|
{
|
||||||
var available = world.Ships.Count(ship =>
|
var available = world.Ships.Count(ship =>
|
||||||
ship.FactionId == player.SovereignFactionId &&
|
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)
|
if (available < policy.DesiredAssetCount)
|
||||||
{
|
{
|
||||||
player.Alerts.Add(new PlayerAlertRuntime
|
player.Alerts.Add(new PlayerAlertRuntime
|
||||||
|
|||||||
26
apps/backend/PlayerFaction/Simulation/PlayerStateStore.cs
Normal file
26
apps/backend/PlayerFaction/Simulation/PlayerStateStore.cs
Normal 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();
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
|
using System.Text;
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FastEndpoints.Swagger;
|
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.Bootstrap;
|
||||||
using SpaceGame.Api.Universe.Simulation;
|
|
||||||
|
const string StartupScenarioPath = "scenarios/empty.json";
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
const string StartupScenarioPath = "scenarios/empty.json";
|
|
||||||
|
|
||||||
builder.Services.AddCors((options) =>
|
builder.Services.AddCors((options) =>
|
||||||
{
|
{
|
||||||
@@ -46,10 +49,67 @@ builder.Services
|
|||||||
})
|
})
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
builder.Services.Configure<BalanceOptions>(builder.Configuration.GetSection("Balance"));
|
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.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<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<SystemGenerationService>();
|
||||||
builder.Services.AddTransient<SpatialBuilder>();
|
builder.Services.AddTransient<SpatialBuilder>();
|
||||||
builder.Services.AddTransient<WorldSeedingService>();
|
builder.Services.AddTransient<WorldSeedingService>();
|
||||||
@@ -68,9 +128,16 @@ builder.Services.AddFastEndpoints();
|
|||||||
builder.Services.SwaggerDocument();
|
builder.Services.SwaggerDocument();
|
||||||
|
|
||||||
var app = builder.Build();
|
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.UseCors();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
app.UseFastEndpoints();
|
app.UseFastEndpoints();
|
||||||
app.UseSwaggerGen();
|
app.UseSwaggerGen();
|
||||||
|
|
||||||
|
|||||||
7
apps/backend/Shared/Contracts/VersionInfo.cs
Normal file
7
apps/backend/Shared/Contracts/VersionInfo.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SpaceGame.Api.Shared.Contracts;
|
||||||
|
|
||||||
|
public sealed record VersionInfoSnapshot(
|
||||||
|
string Version,
|
||||||
|
string Environment,
|
||||||
|
string? CommitSha,
|
||||||
|
DateTimeOffset StartedAtUtc);
|
||||||
29
apps/backend/Shared/Runtime/AppVersionService.cs
Normal file
29
apps/backend/Shared/Runtime/AppVersionService.cs
Normal 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;
|
||||||
|
}
|
||||||
69
apps/backend/Shared/Runtime/KnownShipTaxonomy.cs
Normal file
69
apps/backend/Shared/Runtime/KnownShipTaxonomy.cs
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
121
apps/backend/Shared/Runtime/ShipAutomationCatalog.cs
Normal file
121
apps/backend/Shared/Runtime/ShipAutomationCatalog.cs
Normal 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."),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -28,6 +28,13 @@ public enum OrderStatus
|
|||||||
Interrupted,
|
Interrupted,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum ShipOrderSourceKind
|
||||||
|
{
|
||||||
|
Player,
|
||||||
|
Behavior,
|
||||||
|
Commander,
|
||||||
|
}
|
||||||
|
|
||||||
public enum AiPlanStatus
|
public enum AiPlanStatus
|
||||||
{
|
{
|
||||||
Planned,
|
Planned,
|
||||||
@@ -166,6 +173,11 @@ public static class ShipOrderKinds
|
|||||||
public const string BuildAtSite = "build-at-site";
|
public const string BuildAtSite = "build-at-site";
|
||||||
public const string AttackTarget = "attack-target";
|
public const string AttackTarget = "attack-target";
|
||||||
public const string HoldPosition = "hold-position";
|
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 RepeatOrders = "repeat-orders";
|
||||||
public const string Flee = "flee";
|
public const string Flee = "flee";
|
||||||
}
|
}
|
||||||
@@ -329,6 +341,14 @@ public static class SimulationEnumMappings
|
|||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
_ => 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
|
public static string ToContractValue(this ShipState state) => state switch
|
||||||
{
|
{
|
||||||
ShipState.Idle => "idle",
|
ShipState.Idle => "idle",
|
||||||
|
|||||||
@@ -3,8 +3,56 @@ namespace SpaceGame.Api.Shared.Runtime;
|
|||||||
|
|
||||||
internal static class SimulationRuntimeSupport
|
internal static class SimulationRuntimeSupport
|
||||||
{
|
{
|
||||||
internal static bool HasShipCapabilities(ShipDefinition definition, params string[] capabilities) =>
|
internal static bool CanWarp(ShipDefinition definition) =>
|
||||||
capabilities.All(cap => definition.Capabilities.Contains(cap, StringComparer.Ordinal));
|
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) =>
|
internal static int CountStationModules(StationRuntime station, ModuleType moduleType) =>
|
||||||
station.Modules.Count(module => module.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)));
|
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
|
||||||
|
|
||||||
internal static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node, SimulationWorld world) =>
|
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)
|
&& world.ItemDefinitions.TryGetValue(node.ItemId, out var item)
|
||||||
&& item.CargoKind is not null
|
&& item.CargoKind is not null
|
||||||
&& item.CargoKind == ship.Definition.CargoKind;
|
&& ship.Definition.SupportsCargoKind(item.CargoKind.Value);
|
||||||
|
|
||||||
internal static bool CanBuildClaimBeacon(ShipRuntime ship) =>
|
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)
|
internal static float ComputeWorkforceRatio(float population, float workforceRequired)
|
||||||
{
|
{
|
||||||
|
|||||||
784
apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs
Normal file
784
apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
54
apps/backend/Ships/AI/ShipAiService.Data.cs
Normal file
54
apps/backend/Ships/AI/ShipAiService.Data.cs
Normal 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);
|
||||||
|
}
|
||||||
770
apps/backend/Ships/AI/ShipAiService.Execution.cs
Normal file
770
apps/backend/Ships/AI/ShipAiService.Execution.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
947
apps/backend/Ships/AI/ShipAiService.Helpers.cs
Normal file
947
apps/backend/Ships/AI/ShipAiService.Helpers.cs
Normal 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";
|
||||||
|
}
|
||||||
319
apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs
Normal file
319
apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
461
apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs
Normal file
461
apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs
Normal 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)
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
216
apps/backend/Ships/AI/ShipAiService.cs
Normal file
216
apps/backend/Ships/AI/ShipAiService.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/backend/Ships/AI/ShipBootstrapPolicy.cs
Normal file
31
apps/backend/Ships/AI/ShipBootstrapPolicy.cs
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ public sealed class EnqueueShipOrderHandler(WorldService worldService) : Endpoin
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Post("/api/ships/{shipId}/orders");
|
Post("/api/ships/{shipId}/orders");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(ShipOrderCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(ShipOrderCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
35
apps/backend/Ships/Api/GetShipAutomationCatalogHandler.cs
Normal file
35
apps/backend/Ships/Api/GetShipAutomationCatalogHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ public sealed class RemoveShipOrderHandler(WorldService worldService) : Endpoint
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Delete("/api/ships/{shipId}/orders/{orderId}");
|
Delete("/api/ships/{shipId}/orders/{orderId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(RemoveShipOrderRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(RemoveShipOrderRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ public sealed class UpdateShipDefaultBehaviorHandler(WorldService worldService)
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Put("/api/ships/{shipId}/default-behavior");
|
Put("/api/ships/{shipId}/default-behavior");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(ShipDefaultBehaviorCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(ShipDefaultBehaviorCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
19
apps/backend/Ships/Contracts/ShipAutomation.cs
Normal file
19
apps/backend/Ships/Contracts/ShipAutomation.cs
Normal 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);
|
||||||
@@ -42,7 +42,7 @@ public sealed record ShipDefaultBehaviorCommandRequest(
|
|||||||
string? HomeStationId,
|
string? HomeStationId,
|
||||||
string? AreaSystemId,
|
string? AreaSystemId,
|
||||||
string? TargetEntityId,
|
string? TargetEntityId,
|
||||||
string? PreferredItemId,
|
string? ItemId,
|
||||||
string? PreferredNodeId,
|
string? PreferredNodeId,
|
||||||
string? PreferredConstructionSiteId,
|
string? PreferredConstructionSiteId,
|
||||||
string? PreferredModuleId,
|
string? PreferredModuleId,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public sealed record ShipSkillProfileSnapshot(
|
|||||||
public sealed record ShipOrderSnapshot(
|
public sealed record ShipOrderSnapshot(
|
||||||
string Id,
|
string Id,
|
||||||
string Kind,
|
string Kind,
|
||||||
|
string SourceKind,
|
||||||
|
string SourceId,
|
||||||
string Status,
|
string Status,
|
||||||
int Priority,
|
int Priority,
|
||||||
bool InterruptCurrentPlan,
|
bool InterruptCurrentPlan,
|
||||||
@@ -53,7 +55,7 @@ public sealed record DefaultBehaviorSnapshot(
|
|||||||
string? HomeStationId,
|
string? HomeStationId,
|
||||||
string? AreaSystemId,
|
string? AreaSystemId,
|
||||||
string? TargetEntityId,
|
string? TargetEntityId,
|
||||||
string? PreferredItemId,
|
string? ItemId,
|
||||||
string? PreferredNodeId,
|
string? PreferredNodeId,
|
||||||
string? PreferredConstructionSiteId,
|
string? PreferredConstructionSiteId,
|
||||||
string? PreferredModuleId,
|
string? PreferredModuleId,
|
||||||
@@ -129,9 +131,9 @@ public sealed record ShipPlanSnapshot(
|
|||||||
|
|
||||||
public sealed record ShipSnapshot(
|
public sealed record ShipSnapshot(
|
||||||
string Id,
|
string Id,
|
||||||
string Label,
|
string Name,
|
||||||
string Kind,
|
string Purpose,
|
||||||
string Class,
|
string Type,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
Vector3Dto LocalPosition,
|
Vector3Dto LocalPosition,
|
||||||
Vector3Dto LocalVelocity,
|
Vector3Dto LocalVelocity,
|
||||||
@@ -164,9 +166,9 @@ public sealed record ShipSnapshot(
|
|||||||
|
|
||||||
public sealed record ShipDelta(
|
public sealed record ShipDelta(
|
||||||
string Id,
|
string Id,
|
||||||
string Label,
|
string Name,
|
||||||
string Kind,
|
string Purpose,
|
||||||
string Class,
|
string Type,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
Vector3Dto LocalPosition,
|
Vector3Dto LocalPosition,
|
||||||
Vector3Dto LocalVelocity,
|
Vector3Dto LocalVelocity,
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ public sealed class ShipOrderRuntime
|
|||||||
{
|
{
|
||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
public required string Kind { 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 OrderStatus Status { get; set; } = OrderStatus.Queued;
|
||||||
public int Priority { get; set; }
|
public int Priority { get; set; }
|
||||||
public bool InterruptCurrentPlan { get; set; } = true;
|
public bool InterruptCurrentPlan { get; set; } = true;
|
||||||
@@ -75,7 +77,7 @@ public sealed class DefaultBehaviorRuntime
|
|||||||
public string? HomeStationId { get; set; }
|
public string? HomeStationId { get; set; }
|
||||||
public string? AreaSystemId { get; set; }
|
public string? AreaSystemId { get; set; }
|
||||||
public string? TargetEntityId { get; set; }
|
public string? TargetEntityId { get; set; }
|
||||||
public string? PreferredItemId { get; set; }
|
public string? ItemId { get; set; }
|
||||||
public string? PreferredNodeId { get; set; }
|
public string? PreferredNodeId { get; set; }
|
||||||
public string? PreferredConstructionSiteId { get; set; }
|
public string? PreferredConstructionSiteId { get; set; }
|
||||||
public string? PreferredModuleId { get; set; }
|
public string? PreferredModuleId { get; set; }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ namespace SpaceGame.Api.Simulation.Core;
|
|||||||
internal sealed class SimulationEngine
|
internal sealed class SimulationEngine
|
||||||
{
|
{
|
||||||
private readonly IBalanceService _balance;
|
private readonly IBalanceService _balance;
|
||||||
|
private readonly IPlayerStateStore _playerStateStore;
|
||||||
private readonly OrbitalSimulationOptions _orbitalSimulation;
|
private readonly OrbitalSimulationOptions _orbitalSimulation;
|
||||||
private readonly OrbitalStateUpdater _orbitalStateUpdater;
|
private readonly OrbitalStateUpdater _orbitalStateUpdater;
|
||||||
private readonly InfrastructureSimulationService _infrastructureSimulation;
|
private readonly InfrastructureSimulationService _infrastructureSimulation;
|
||||||
@@ -14,9 +15,10 @@ internal sealed class SimulationEngine
|
|||||||
private readonly ShipAiService _shipAi;
|
private readonly ShipAiService _shipAi;
|
||||||
private readonly SimulationProjectionService _projection;
|
private readonly SimulationProjectionService _projection;
|
||||||
|
|
||||||
internal SimulationEngine(OrbitalSimulationOptions orbitalSimulation, IBalanceService balance)
|
internal SimulationEngine(OrbitalSimulationOptions orbitalSimulation, IBalanceService balance, IPlayerStateStore playerStateStore)
|
||||||
{
|
{
|
||||||
_balance = balance;
|
_balance = balance;
|
||||||
|
_playerStateStore = playerStateStore;
|
||||||
_orbitalSimulation = orbitalSimulation;
|
_orbitalSimulation = orbitalSimulation;
|
||||||
_orbitalStateUpdater = new OrbitalStateUpdater(_orbitalSimulation);
|
_orbitalStateUpdater = new OrbitalStateUpdater(_orbitalSimulation);
|
||||||
_infrastructureSimulation = new InfrastructureSimulationService();
|
_infrastructureSimulation = new InfrastructureSimulationService();
|
||||||
@@ -42,8 +44,8 @@ internal sealed class SimulationEngine
|
|||||||
_infrastructureSimulation.UpdateClaims(world, events);
|
_infrastructureSimulation.UpdateClaims(world, events);
|
||||||
_infrastructureSimulation.UpdateConstructionSites(world, events);
|
_infrastructureSimulation.UpdateConstructionSites(world, events);
|
||||||
_geopolitics.Update(world, simulationDeltaSeconds, events);
|
_geopolitics.Update(world, simulationDeltaSeconds, events);
|
||||||
_commanderPlanning.UpdateCommanders(world, simulationDeltaSeconds, events);
|
_commanderPlanning.UpdateCommanders(world, _playerStateStore, simulationDeltaSeconds, events);
|
||||||
_playerFaction.Update(world, simulationDeltaSeconds, events);
|
_playerFaction.Update(world, _playerStateStore, simulationDeltaSeconds, events);
|
||||||
_stationLifecycle.UpdateStations(world, simulationDeltaSeconds, events);
|
_stationLifecycle.UpdateStations(world, simulationDeltaSeconds, events);
|
||||||
|
|
||||||
foreach (var ship in world.Ships.ToList())
|
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())
|
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);
|
world.Ships.Remove(ship);
|
||||||
if (ship.DockedStationId is not null && world.Stations.FirstOrDefault(station => station.Id == ship.DockedStationId) is { } dockedStation)
|
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;
|
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())
|
foreach (var station in world.Stations.Where(candidate => candidate.Health <= 0f).ToList())
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ internal sealed class SimulationProjectionService
|
|||||||
BuildPolicyDeltas(world),
|
BuildPolicyDeltas(world),
|
||||||
BuildShipDeltas(world),
|
BuildShipDeltas(world),
|
||||||
BuildFactionDeltas(world),
|
BuildFactionDeltas(world),
|
||||||
BuildPlayerFactionDelta(world),
|
|
||||||
BuildGeopoliticsDelta(world));
|
BuildGeopoliticsDelta(world));
|
||||||
|
|
||||||
public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence)
|
public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence)
|
||||||
@@ -177,9 +176,9 @@ internal sealed class SimulationProjectionService
|
|||||||
policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(),
|
policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(),
|
||||||
world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot(
|
world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot(
|
||||||
ship.Id,
|
ship.Id,
|
||||||
ship.Label,
|
ship.Name,
|
||||||
ship.Kind,
|
ship.Purpose,
|
||||||
ship.Class,
|
ship.Type,
|
||||||
ship.SystemId,
|
ship.SystemId,
|
||||||
ship.LocalPosition,
|
ship.LocalPosition,
|
||||||
ship.LocalVelocity,
|
ship.LocalVelocity,
|
||||||
@@ -225,7 +224,6 @@ internal sealed class SimulationProjectionService
|
|||||||
faction.StrategicState,
|
faction.StrategicState,
|
||||||
faction.DecisionLog,
|
faction.DecisionLog,
|
||||||
faction.Commanders)).ToList(),
|
faction.Commanders)).ToList(),
|
||||||
ToPlayerFactionSnapshot(world.PlayerFaction),
|
|
||||||
ToGeopoliticalStateSnapshot(world.Geopolitics));
|
ToGeopoliticalStateSnapshot(world.Geopolitics));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,11 +274,6 @@ internal sealed class SimulationProjectionService
|
|||||||
faction.LastDeltaSignature = BuildFactionSignature(faction, FindFactionCommander(world, faction.Id));
|
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)
|
if (world.Geopolitics is not null)
|
||||||
{
|
{
|
||||||
world.Geopolitics.LastDeltaSignature = BuildGeopoliticalSignature(world.Geopolitics);
|
world.Geopolitics.LastDeltaSignature = BuildGeopoliticalSignature(world.Geopolitics);
|
||||||
@@ -450,23 +443,6 @@ internal sealed class SimulationProjectionService
|
|||||||
return deltas;
|
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)
|
private static GeopoliticalStateSnapshot? BuildGeopoliticsDelta(SimulationWorld world)
|
||||||
{
|
{
|
||||||
if (world.Geopolitics is null)
|
if (world.Geopolitics is null)
|
||||||
@@ -544,11 +520,13 @@ internal sealed class SimulationProjectionService
|
|||||||
ship.TargetPosition.Z.ToString("0.###"),
|
ship.TargetPosition.Z.ToString("0.###"),
|
||||||
ship.State.ToContractValue(),
|
ship.State.ToContractValue(),
|
||||||
string.Join(",", ship.OrderQueue
|
string.Join(",", ship.OrderQueue
|
||||||
.OrderByDescending(order => order.Priority)
|
.OrderByDescending(GetOrderSourcePriority)
|
||||||
|
.ThenByDescending(order => order.Priority)
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
.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.Kind,
|
||||||
ship.DefaultBehavior.TargetEntityId ?? "none",
|
ship.DefaultBehavior.TargetEntityId ?? "none",
|
||||||
|
ship.DefaultBehavior.ItemId ?? "none",
|
||||||
ship.DefaultBehavior.TargetPosition?.X.ToString("0.###") ?? "none",
|
ship.DefaultBehavior.TargetPosition?.X.ToString("0.###") ?? "none",
|
||||||
ship.DefaultBehavior.TargetPosition?.Y.ToString("0.###") ?? "none",
|
ship.DefaultBehavior.TargetPosition?.Y.ToString("0.###") ?? "none",
|
||||||
ship.DefaultBehavior.TargetPosition?.Z.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}";
|
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)
|
private static string BuildGeopoliticalSignature(GeopoliticalStateRuntime state)
|
||||||
{
|
{
|
||||||
var diplomacySig = string.Join(";",
|
var diplomacySig = string.Join(";",
|
||||||
@@ -882,9 +807,9 @@ internal sealed class SimulationProjectionService
|
|||||||
|
|
||||||
return new ShipDelta(
|
return new ShipDelta(
|
||||||
ship.Id,
|
ship.Id,
|
||||||
ship.Definition.Label,
|
ship.Definition.Name,
|
||||||
ship.Definition.Kind,
|
ship.Definition.Purpose.ToDataValue(),
|
||||||
ship.Definition.Class,
|
ship.Definition.Type.ToDataValue(),
|
||||||
ship.SystemId,
|
ship.SystemId,
|
||||||
ToDto(ship.Position),
|
ToDto(ship.Position),
|
||||||
ToDto(ship.Velocity),
|
ToDto(ship.Velocity),
|
||||||
@@ -906,7 +831,7 @@ internal sealed class SimulationProjectionService
|
|||||||
ship.DockedStationId,
|
ship.DockedStationId,
|
||||||
ship.CommanderId,
|
ship.CommanderId,
|
||||||
ship.PolicySetId,
|
ship.PolicySetId,
|
||||||
ship.Definition.CargoCapacity,
|
ship.Definition.GetTotalCargoCapacity(),
|
||||||
|
|
||||||
ToShipTravelSpeed(ship).Speed,
|
ToShipTravelSpeed(ship).Speed,
|
||||||
ToShipTravelSpeed(ship).Unit,
|
ToShipTravelSpeed(ship).Unit,
|
||||||
@@ -936,11 +861,14 @@ internal sealed class SimulationProjectionService
|
|||||||
|
|
||||||
private static IReadOnlyList<ShipOrderSnapshot> ToShipOrderSnapshots(ShipRuntime ship) =>
|
private static IReadOnlyList<ShipOrderSnapshot> ToShipOrderSnapshots(ShipRuntime ship) =>
|
||||||
ship.OrderQueue
|
ship.OrderQueue
|
||||||
.OrderByDescending(order => order.Priority)
|
.OrderByDescending(GetOrderSourcePriority)
|
||||||
|
.ThenByDescending(order => order.Priority)
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
.ThenBy(order => order.CreatedAtUtc)
|
||||||
.Select(order => new ShipOrderSnapshot(
|
.Select(order => new ShipOrderSnapshot(
|
||||||
order.Id,
|
order.Id,
|
||||||
order.Kind,
|
order.Kind,
|
||||||
|
order.SourceKind.ToContractValue(),
|
||||||
|
order.SourceId,
|
||||||
order.Status.ToContractValue(),
|
order.Status.ToContractValue(),
|
||||||
order.Priority,
|
order.Priority,
|
||||||
order.InterruptCurrentPlan,
|
order.InterruptCurrentPlan,
|
||||||
@@ -962,6 +890,14 @@ internal sealed class SimulationProjectionService
|
|||||||
order.FailureReason))
|
order.FailureReason))
|
||||||
.ToList();
|
.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) =>
|
private static DefaultBehaviorSnapshot ToDefaultBehaviorSnapshot(DefaultBehaviorRuntime behavior) =>
|
||||||
new(
|
new(
|
||||||
behavior.Kind,
|
behavior.Kind,
|
||||||
@@ -969,7 +905,7 @@ internal sealed class SimulationProjectionService
|
|||||||
behavior.HomeStationId,
|
behavior.HomeStationId,
|
||||||
behavior.AreaSystemId,
|
behavior.AreaSystemId,
|
||||||
behavior.TargetEntityId,
|
behavior.TargetEntityId,
|
||||||
behavior.PreferredItemId,
|
behavior.ItemId,
|
||||||
behavior.PreferredNodeId,
|
behavior.PreferredNodeId,
|
||||||
behavior.PreferredConstructionSiteId,
|
behavior.PreferredConstructionSiteId,
|
||||||
behavior.PreferredModuleId,
|
behavior.PreferredModuleId,
|
||||||
@@ -1385,252 +1321,6 @@ internal sealed class SimulationProjectionService
|
|||||||
entry.OccurredAtUtc))
|
entry.OccurredAtUtc))
|
||||||
.ToList();
|
.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)
|
private static GeopoliticalStateSnapshot? ToGeopoliticalStateSnapshot(GeopoliticalStateRuntime? state)
|
||||||
{
|
{
|
||||||
if (state is null)
|
if (state is null)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FastEndpoints" Version="6.*" />
|
<PackageReference Include="FastEndpoints" Version="6.*" />
|
||||||
<PackageReference Include="FastEndpoints.Swagger" 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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using SpaceGame.Api.Shared.Runtime;
|
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;
|
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||||
|
|
||||||
namespace SpaceGame.Api.Stations.Simulation;
|
namespace SpaceGame.Api.Stations.Simulation;
|
||||||
@@ -81,7 +82,7 @@ internal sealed class StationLifecycleService
|
|||||||
SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition),
|
SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition),
|
||||||
DefaultBehavior = CreateSpawnedShipBehavior(definition, station),
|
DefaultBehavior = CreateSpawnedShipBehavior(definition, station),
|
||||||
Skills = ShipBootstrapPolicy.CreateSkills(definition),
|
Skills = ShipBootstrapPolicy.CreateSkills(definition),
|
||||||
Health = definition.MaxHealth,
|
Health = definition.Hull,
|
||||||
};
|
};
|
||||||
|
|
||||||
world.Ships.Add(ship);
|
world.Ships.Add(ship);
|
||||||
@@ -91,7 +92,7 @@ internal sealed class StationLifecycleService
|
|||||||
faction.ShipsBuilt += 1;
|
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;
|
return 1f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,21 +108,22 @@ internal sealed class StationLifecycleService
|
|||||||
|
|
||||||
private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station)
|
private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station)
|
||||||
{
|
{
|
||||||
if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal))
|
if (!IsMilitaryShip(definition))
|
||||||
{
|
{
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? "advanced-auto-trade" : "idle",
|
Kind = IsTransportShip(definition) ? AdvancedAutoTrade : HoldPosition,
|
||||||
HomeSystemId = station.SystemId,
|
HomeSystemId = station.SystemId,
|
||||||
HomeStationId = station.Id,
|
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;
|
var patrolRadius = station.Radius + 90f;
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "patrol",
|
Kind = Patrol,
|
||||||
HomeSystemId = station.SystemId,
|
HomeSystemId = station.SystemId,
|
||||||
HomeStationId = station.Id,
|
HomeStationId = station.Id,
|
||||||
AreaSystemId = station.SystemId,
|
AreaSystemId = station.SystemId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using static SpaceGame.Api.Factions.AI.CommanderPlanningService;
|
using static SpaceGame.Api.Factions.AI.CommanderPlanningService;
|
||||||
|
using static SpaceGame.Api.Shared.Runtime.KnownShipTypes;
|
||||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||||
using SpaceGame.Api.Shared.Runtime;
|
using SpaceGame.Api.Shared.Runtime;
|
||||||
|
|
||||||
@@ -7,6 +8,10 @@ namespace SpaceGame.Api.Stations.Simulation;
|
|||||||
internal sealed class StationSimulationService
|
internal sealed class StationSimulationService
|
||||||
{
|
{
|
||||||
internal const int StrategicControlTargetSystems = 5;
|
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)
|
internal void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station)
|
||||||
{
|
{
|
||||||
@@ -63,7 +68,7 @@ internal sealed class StationSimulationService
|
|||||||
var superfluidCoolantReserve = role == "superfluidcoolant" ? 120f : 0f;
|
var superfluidCoolantReserve = role == "superfluidcoolant" ? 120f : 0f;
|
||||||
var quantumTubesReserve = role == "quantumtubes" ? 120f : 0f;
|
var quantumTubesReserve = role == "quantumtubes" ? 120f : 0f;
|
||||||
var shipPartsReserve = HasShipyardCapability(station)
|
var shipPartsReserve = HasShipyardCapability(station)
|
||||||
&& GetShipProductionPressure(world, station.FactionId, "military") > 0.2f
|
&& GetShipProductionPressure(world, station.FactionId, MilitaryShipCategory) > 0.2f
|
||||||
? 90f
|
? 90f
|
||||||
: 0f;
|
: 0f;
|
||||||
|
|
||||||
@@ -118,7 +123,7 @@ internal sealed class StationSimulationService
|
|||||||
var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics");
|
var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics");
|
||||||
var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals");
|
var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals");
|
||||||
var shipPartsReserve = HasShipyardCapability(station)
|
var shipPartsReserve = HasShipyardCapability(station)
|
||||||
&& GetShipProductionPressure(world, station.FactionId, "military") > 0.2f
|
&& GetShipProductionPressure(world, station.FactionId, MilitaryShipCategory) > 0.2f
|
||||||
? 90f
|
? 90f
|
||||||
: 0f;
|
: 0f;
|
||||||
|
|
||||||
@@ -255,7 +260,7 @@ internal sealed class StationSimulationService
|
|||||||
var priority = (float)recipe.Priority;
|
var priority = (float)recipe.Priority;
|
||||||
|
|
||||||
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
|
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 += GetStationRecipePriorityAdjustment(world, station, recipe, expansionPressure, fleetPressure);
|
||||||
priority += GetStrategicRecipeBias(world, station, recipe);
|
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))
|
if (recipe.ShipOutputId is not null && world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition))
|
||||||
{
|
{
|
||||||
var shipPressure = GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind);
|
var shipPressure = GetShipProductionPressure(world, station.FactionId, GetShipCategory(shipDefinition));
|
||||||
return shipDefinition.Kind switch
|
if (IsMilitaryShip(shipDefinition))
|
||||||
{
|
{
|
||||||
"military" => recipe.Id switch
|
return recipe.Id switch
|
||||||
{
|
{
|
||||||
"frigate-construction" => 320f * shipPressure,
|
"frigate-construction" => 320f * shipPressure,
|
||||||
"destroyer-construction" => 200f * shipPressure,
|
"destroyer-construction" => 200f * shipPressure,
|
||||||
"cruiser-construction" => 120f * shipPressure,
|
"cruiser-construction" => 120f * shipPressure,
|
||||||
_ => 160f * shipPressure,
|
_ => 160f * shipPressure,
|
||||||
},
|
};
|
||||||
"construction" => 260f * shipPressure,
|
}
|
||||||
"mining" => 250f * shipPressure,
|
|
||||||
"transport" => 230f * shipPressure,
|
if (IsConstructionShip(shipDefinition))
|
||||||
_ => 0f,
|
{
|
||||||
};
|
return 260f * shipPressure;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsMiningShip(shipDefinition))
|
||||||
|
{
|
||||||
|
return 250f * shipPressure;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsTransportShip(shipDefinition))
|
||||||
|
{
|
||||||
|
return 230f * shipPressure;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
var outputItemIds = recipe.Outputs
|
var outputItemIds = recipe.Outputs
|
||||||
@@ -338,7 +356,7 @@ internal sealed class StationSimulationService
|
|||||||
if (string.Equals(assignment.Kind, "ship-production-focus", StringComparison.Ordinal)
|
if (string.Equals(assignment.Kind, "ship-production-focus", StringComparison.Ordinal)
|
||||||
&& recipe.ShipOutputId is not null
|
&& recipe.ShipOutputId is not null
|
||||||
&& world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)
|
&& world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)
|
||||||
&& string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal))
|
&& IsMilitaryShip(shipDefinition))
|
||||||
{
|
{
|
||||||
return 260f;
|
return 260f;
|
||||||
}
|
}
|
||||||
@@ -383,7 +401,7 @@ internal sealed class StationSimulationService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind) <= 0.05f)
|
if (GetShipProductionPressure(world, station.FactionId, GetShipCategory(shipDefinition)) <= 0.05f)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -708,7 +726,7 @@ internal sealed class StationSimulationService
|
|||||||
.ToList();
|
.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 economic = FindFactionEconomicAssessment(world, factionId);
|
||||||
var threat = FindFactionThreatAssessment(world, factionId);
|
var threat = FindFactionThreatAssessment(world, factionId);
|
||||||
@@ -717,16 +735,16 @@ internal sealed class StationSimulationService
|
|||||||
return 0f;
|
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
|
? economic.MilitaryShipCount < Math.Max(4, economic.ControlledSystemCount * 2) ? 1f : 0.25f
|
||||||
: 0.1f,
|
: 0.1f,
|
||||||
"construction" => economic.PrimaryExpansionSiteId is not null
|
ConstructionShipCategory => economic.PrimaryExpansionSiteId is not null
|
||||||
? economic.ConstructorShipCount < 1 ? 1f : 0.35f
|
? economic.ConstructorShipCount < 1 ? 1f : 0.35f
|
||||||
: economic.ConstructorShipCount < 1 ? 0.5f : 0f,
|
: economic.ConstructorShipCount < 1 ? 0.5f : 0f,
|
||||||
"transport" => economic.TransportShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.8f : 0.2f,
|
TransportShipCategory => 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,
|
MiningShipCategory => economic.MinerShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.85f : 0.2f,
|
||||||
_ => 0.15f,
|
_ => 0.15f,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
25
apps/backend/Universe/Api/CreateFactionHandler.cs
Normal file
25
apps/backend/Universe/Api/CreateFactionHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ public sealed class GetBalanceHandler(IBalanceService balanceService) : Endpoint
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Get("/api/balance");
|
Get("/api/balance");
|
||||||
AllowAnonymous();
|
Policies(AuthPolicyNames.GmAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public sealed class GetTelemetryHandler(TelemetryService telemetry, WorldService
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Get("/api/telemetry");
|
Get("/api/telemetry");
|
||||||
AllowAnonymous();
|
Policies(AuthPolicyNames.GmAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task HandleAsync(CancellationToken cancellationToken)
|
public override Task HandleAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
17
apps/backend/Universe/Api/GetVersionHandler.cs
Normal file
17
apps/backend/Universe/Api/GetVersionHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ public sealed class ResetWorldHandler(WorldService worldService) : EndpointWitho
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Post("/api/world/reset");
|
Post("/api/world/reset");
|
||||||
AllowAnonymous();
|
Policies(AuthPolicyNames.GmAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
||||||
|
|||||||
25
apps/backend/Universe/Api/SpawnShipHandler.cs
Normal file
25
apps/backend/Universe/Api/SpawnShipHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/backend/Universe/Api/SpawnStationHandler.cs
Normal file
25
apps/backend/Universe/Api/SpawnStationHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ public sealed class UpdateBalanceHandler(IBalanceService balanceService) : Endpo
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Put("/api/balance");
|
Put("/api/balance");
|
||||||
AllowAnonymous();
|
Policies(AuthPolicyNames.GmAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task HandleAsync(BalanceOptions req, CancellationToken cancellationToken)
|
public override Task HandleAsync(BalanceOptions req, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SpaceGame.Api.Shared.Runtime;
|
using SpaceGame.Api.Shared.Runtime;
|
||||||
|
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||||
|
|
||||||
namespace SpaceGame.Api.Universe.Bootstrap;
|
namespace SpaceGame.Api.Universe.Bootstrap;
|
||||||
|
|
||||||
public sealed class StaticDataProvider : IStaticDataProvider
|
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 string _dataRoot;
|
||||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
};
|
};
|
||||||
|
|
||||||
public StaticDataProvider(IOptions<StaticDataOptions> staticDataOptions)
|
public StaticDataProvider(IOptions<StaticDataOptions> staticDataOptions)
|
||||||
@@ -163,7 +170,7 @@ public sealed class StaticDataProvider : IStaticDataProvider
|
|||||||
recipes.Add(new RecipeDefinition
|
recipes.Add(new RecipeDefinition
|
||||||
{
|
{
|
||||||
Id = $"{ship.Id}-{production.Method}-construction",
|
Id = $"{ship.Id}-{production.Method}-construction",
|
||||||
Label = $"{ship.Label} Construction",
|
Label = $"{ship.Name} Construction",
|
||||||
FacilityCategory = "shipyard",
|
FacilityCategory = "shipyard",
|
||||||
Duration = production.Time,
|
Duration = production.Time,
|
||||||
Priority = InferShipRecipePriority(ship),
|
Priority = InferShipRecipePriority(ship),
|
||||||
@@ -224,12 +231,12 @@ public sealed class StaticDataProvider : IStaticDataProvider
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static int InferShipRecipePriority(ShipDefinition ship) =>
|
private static int InferShipRecipePriority(ShipDefinition ship) =>
|
||||||
ship.Kind switch
|
GetShipCategory(ship) switch
|
||||||
{
|
{
|
||||||
"military" => 170,
|
MilitaryShipCategory => 170,
|
||||||
"construction" => 140,
|
ConstructionShipCategory => 140,
|
||||||
"transport" => 120,
|
TransportShipCategory => 120,
|
||||||
"mining" => 110,
|
MiningShipCategory => 110,
|
||||||
_ => 100,
|
_ => 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
16
apps/backend/Universe/Contracts/GmCommands.cs
Normal file
16
apps/backend/Universe/Contracts/GmCommands.cs
Normal 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);
|
||||||
@@ -18,7 +18,6 @@ public sealed record WorldSnapshot(
|
|||||||
IReadOnlyList<PolicySetSnapshot> Policies,
|
IReadOnlyList<PolicySetSnapshot> Policies,
|
||||||
IReadOnlyList<ShipSnapshot> Ships,
|
IReadOnlyList<ShipSnapshot> Ships,
|
||||||
IReadOnlyList<FactionSnapshot> Factions,
|
IReadOnlyList<FactionSnapshot> Factions,
|
||||||
PlayerFactionSnapshot? PlayerFaction,
|
|
||||||
GeopoliticalStateSnapshot? Geopolitics);
|
GeopoliticalStateSnapshot? Geopolitics);
|
||||||
|
|
||||||
public sealed record WorldDelta(
|
public sealed record WorldDelta(
|
||||||
@@ -38,7 +37,6 @@ public sealed record WorldDelta(
|
|||||||
IReadOnlyList<PolicySetDelta> Policies,
|
IReadOnlyList<PolicySetDelta> Policies,
|
||||||
IReadOnlyList<ShipDelta> Ships,
|
IReadOnlyList<ShipDelta> Ships,
|
||||||
IReadOnlyList<FactionDelta> Factions,
|
IReadOnlyList<FactionDelta> Factions,
|
||||||
PlayerFactionSnapshot? PlayerFaction,
|
|
||||||
GeopoliticalStateSnapshot? Geopolitics,
|
GeopoliticalStateSnapshot? Geopolitics,
|
||||||
ObserverScope? Scope = null);
|
ObserverScope? Scope = null);
|
||||||
|
|
||||||
|
|||||||
@@ -89,9 +89,6 @@ internal static class LoaderSupport
|
|||||||
internal static bool HasInstalledModules(StationRuntime station, params string[] modules) =>
|
internal static bool HasInstalledModules(StationRuntime station, params string[] modules) =>
|
||||||
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
|
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)
|
internal static void AddStationModule(StationRuntime station, IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, string moduleId)
|
||||||
{
|
{
|
||||||
if (!moduleDefinitions.TryGetValue(moduleId, out var definition))
|
if (!moduleDefinitions.TryGetValue(moduleId, out var definition))
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using SpaceGame.Api.Universe.Bootstrap;
|
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;
|
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
|
||||||
|
|
||||||
namespace SpaceGame.Api.Universe.Scenario;
|
namespace SpaceGame.Api.Universe.Scenario;
|
||||||
@@ -194,7 +196,7 @@ public sealed class ScenarioContentBuilder(
|
|||||||
patrolRoutes,
|
patrolRoutes,
|
||||||
stations),
|
stations),
|
||||||
Skills = ShipBootstrapPolicy.CreateSkills(definition),
|
Skills = ShipBootstrapPolicy.CreateSkills(definition),
|
||||||
Health = definition.MaxHealth,
|
Health = definition.Hull,
|
||||||
});
|
});
|
||||||
|
|
||||||
foreach (var (itemId, amount) in formation.StartingInventory)
|
foreach (var (itemId, amount) in formation.StartingInventory)
|
||||||
@@ -232,45 +234,45 @@ public sealed class ScenarioContentBuilder(
|
|||||||
&& string.Equals(station.SystemId, systemId, StringComparison.Ordinal))
|
&& string.Equals(station.SystemId, systemId, StringComparison.Ordinal))
|
||||||
?? stations.FirstOrDefault(station => string.Equals(station.FactionId, factionId, 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
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "construct-station",
|
Kind = ConstructStation,
|
||||||
HomeSystemId = homeStation.SystemId,
|
HomeSystemId = homeStation.SystemId,
|
||||||
HomeStationId = homeStation.Id,
|
HomeStationId = homeStation.Id,
|
||||||
PreferredConstructionSiteId = null,
|
PreferredConstructionSiteId = null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (LoaderSupport.HasCapabilities(definition, "mining") && homeStation is not null)
|
if (IsMiningShip(definition) && homeStation is not null)
|
||||||
{
|
{
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine",
|
Kind = definition.GetTotalCargoCapacity() >= 120f ? ExpertAutoMine : AdvancedAutoMine,
|
||||||
HomeSystemId = homeStation.SystemId,
|
HomeSystemId = homeStation.SystemId,
|
||||||
HomeStationId = homeStation.Id,
|
HomeStationId = homeStation.Id,
|
||||||
AreaSystemId = homeStation.SystemId,
|
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
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "advanced-auto-trade",
|
Kind = AdvancedAutoTrade,
|
||||||
HomeSystemId = homeStation?.SystemId ?? systemId,
|
HomeSystemId = homeStation?.SystemId ?? systemId,
|
||||||
HomeStationId = homeStation?.Id,
|
HomeStationId = homeStation?.Id,
|
||||||
MaxSystemRange = 2,
|
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
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "patrol",
|
Kind = Patrol,
|
||||||
HomeSystemId = homeStation?.SystemId ?? systemId,
|
HomeSystemId = homeStation?.SystemId ?? systemId,
|
||||||
HomeStationId = homeStation?.Id,
|
HomeStationId = homeStation?.Id,
|
||||||
AreaSystemId = systemId,
|
AreaSystemId = systemId,
|
||||||
@@ -281,9 +283,10 @@ public sealed class ScenarioContentBuilder(
|
|||||||
|
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "idle",
|
Kind = HoldPosition,
|
||||||
HomeSystemId = homeStation?.SystemId ?? systemId,
|
HomeSystemId = homeStation?.SystemId ?? systemId,
|
||||||
HomeStationId = homeStation?.Id,
|
HomeStationId = homeStation?.Id,
|
||||||
|
AreaSystemId = homeStation?.SystemId ?? systemId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -520,6 +520,8 @@ public sealed class SystemGenerationService
|
|||||||
private static float Jitter(int index, int salt, float amplitude) =>
|
private static float Jitter(int index, int salt, float amplitude) =>
|
||||||
(Hash01(index, salt) * 2f - 1f) * 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)
|
private static float Hash01(int index, int salt)
|
||||||
{
|
{
|
||||||
uint value = (uint)(index + 1);
|
uint value = (uint)(index + 1);
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ public sealed class WorldRuntimeAssembler(
|
|||||||
var policies = seedingService.CreatePolicies(factions);
|
var policies = seedingService.CreatePolicies(factions);
|
||||||
var commanders = seedingService.CreateCommanders(factions, content.Stations, content.Ships);
|
var commanders = seedingService.CreateCommanders(factions, content.Stations, content.Ships);
|
||||||
var nowUtc = DateTimeOffset.UtcNow;
|
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 claims = seedingService.CreateClaims(content.Stations, topology.SpatialLayout.Celestials, nowUtc);
|
||||||
|
|
||||||
var world = new SimulationWorld
|
var world = new SimulationWorld
|
||||||
@@ -34,7 +31,6 @@ public sealed class WorldRuntimeAssembler(
|
|||||||
Stations = content.Stations.ToList(),
|
Stations = content.Stations.ToList(),
|
||||||
Ships = content.Ships.ToList(),
|
Ships = content.Ships.ToList(),
|
||||||
Factions = factions,
|
Factions = factions,
|
||||||
PlayerFaction = playerFaction,
|
|
||||||
Geopolitics = null,
|
Geopolitics = null,
|
||||||
Commanders = commanders,
|
Commanders = commanders,
|
||||||
Claims = claims,
|
Claims = claims,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using SpaceGame.Api.Universe.Bootstrap;
|
using SpaceGame.Api.Universe.Bootstrap;
|
||||||
|
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||||
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
|
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
|
||||||
|
|
||||||
namespace SpaceGame.Api.Universe.Scenario;
|
namespace SpaceGame.Api.Universe.Scenario;
|
||||||
@@ -379,7 +380,7 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData)
|
|||||||
Label = "Core Automation",
|
Label = "Core Automation",
|
||||||
ScopeKind = "player-faction",
|
ScopeKind = "player-faction",
|
||||||
ScopeId = player.Id,
|
ScopeId = player.Id,
|
||||||
BehaviorKind = "idle",
|
BehaviorKind = Idle,
|
||||||
UpdatedAtUtc = nowUtc,
|
UpdatedAtUtc = nowUtc,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -395,7 +396,7 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData)
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
private FactionRuntime CreateFaction(string factionId)
|
internal FactionRuntime CreateFaction(string factionId)
|
||||||
{
|
{
|
||||||
if (!staticData.FactionDefinitions.TryGetValue(factionId, out var definition))
|
if (!staticData.FactionDefinitions.TryGetValue(factionId, out var definition))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using SpaceGame.Api.Universe.Bootstrap;
|
||||||
using SpaceGame.Api.Universe.Scenario;
|
using SpaceGame.Api.Universe.Scenario;
|
||||||
|
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||||
|
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||||
|
|
||||||
namespace SpaceGame.Api.Universe.Simulation;
|
namespace SpaceGame.Api.Universe.Simulation;
|
||||||
|
|
||||||
@@ -11,8 +14,13 @@ public sealed class WorldService
|
|||||||
private readonly Lock _sync = new();
|
private readonly Lock _sync = new();
|
||||||
private readonly OrbitalSimulationSnapshot _orbitalSimulation;
|
private readonly OrbitalSimulationSnapshot _orbitalSimulation;
|
||||||
private readonly SimulationEngine _engine;
|
private readonly SimulationEngine _engine;
|
||||||
|
private readonly IPlayerIdentityResolver _playerIdentityResolver;
|
||||||
|
private readonly IPlayerStateStore _playerStateStore;
|
||||||
|
private readonly PlayerFactionProjectionService _playerFactionProjection;
|
||||||
private readonly ScenarioLoader _scenarioLoader;
|
private readonly ScenarioLoader _scenarioLoader;
|
||||||
private readonly WorldBuilder _worldBuilder;
|
private readonly WorldBuilder _worldBuilder;
|
||||||
|
private readonly IStaticDataProvider _staticData;
|
||||||
|
private readonly WorldSeedingService _worldSeedingService;
|
||||||
private readonly PlayerFactionService _playerFaction = new();
|
private readonly PlayerFactionService _playerFaction = new();
|
||||||
private readonly Dictionary<Guid, SubscriptionState> _subscribers = [];
|
private readonly Dictionary<Guid, SubscriptionState> _subscribers = [];
|
||||||
private readonly Queue<WorldDelta> _history = [];
|
private readonly Queue<WorldDelta> _history = [];
|
||||||
@@ -24,13 +32,23 @@ public sealed class WorldService
|
|||||||
public WorldService(
|
public WorldService(
|
||||||
ScenarioLoader scenarioLoader,
|
ScenarioLoader scenarioLoader,
|
||||||
WorldBuilder worldBuilder,
|
WorldBuilder worldBuilder,
|
||||||
|
IStaticDataProvider staticData,
|
||||||
|
WorldSeedingService worldSeedingService,
|
||||||
|
IPlayerStateStore playerStateStore,
|
||||||
|
IPlayerIdentityResolver playerIdentityResolver,
|
||||||
|
PlayerFactionProjectionService playerFactionProjection,
|
||||||
IBalanceService balance,
|
IBalanceService balance,
|
||||||
IOptions<OrbitalSimulationOptions> orbitalSimulationOptions)
|
IOptions<OrbitalSimulationOptions> orbitalSimulationOptions)
|
||||||
{
|
{
|
||||||
_orbitalSimulation = new OrbitalSimulationSnapshot(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
|
_orbitalSimulation = new OrbitalSimulationSnapshot(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
|
||||||
|
_playerStateStore = playerStateStore;
|
||||||
|
_playerIdentityResolver = playerIdentityResolver;
|
||||||
|
_playerFactionProjection = playerFactionProjection;
|
||||||
_scenarioLoader = scenarioLoader;
|
_scenarioLoader = scenarioLoader;
|
||||||
_worldBuilder = worldBuilder;
|
_worldBuilder = worldBuilder;
|
||||||
_engine = new SimulationEngine(orbitalSimulationOptions.Value, balance);
|
_staticData = staticData;
|
||||||
|
_worldSeedingService = worldSeedingService;
|
||||||
|
_engine = new SimulationEngine(orbitalSimulationOptions.Value, balance, playerStateStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void New(WorldGenerationOptions options)
|
public void New(WorldGenerationOptions options)
|
||||||
@@ -81,7 +99,10 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
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)
|
if (ship is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -95,7 +116,9 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
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)
|
if (ship is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -109,7 +132,9 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
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)
|
if (ship is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -123,13 +148,15 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
if (_world.PlayerFaction is null && _world.Factions.Count == 0)
|
if (_world.Factions.Count == 0)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_playerFaction.EnsureDomain(_world);
|
var playerKey = GetCurrentPlayerKey();
|
||||||
return GetPlayerFactionSnapshotUnsafe();
|
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)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
_playerFaction.CreateOrganization(_world, request);
|
_playerFaction.CreateOrganization(_world, _playerStateStore, GetCurrentPlayerKey(), request);
|
||||||
return GetPlayerFactionSnapshotUnsafe();
|
return GetPlayerFactionSnapshotUnsafe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,7 +173,7 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
_playerFaction.DeleteOrganization(_world, organizationId);
|
_playerFaction.DeleteOrganization(_world, _playerStateStore, GetCurrentPlayerKey(), organizationId);
|
||||||
return GetPlayerFactionSnapshotUnsafe();
|
return GetPlayerFactionSnapshotUnsafe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,7 +182,7 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
_playerFaction.UpdateOrganizationMembership(_world, organizationId, request);
|
_playerFaction.UpdateOrganizationMembership(_world, _playerStateStore, GetCurrentPlayerKey(), organizationId, request);
|
||||||
return GetPlayerFactionSnapshotUnsafe();
|
return GetPlayerFactionSnapshotUnsafe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,7 +191,7 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
_playerFaction.UpsertDirective(_world, directiveId, request);
|
_playerFaction.UpsertDirective(_world, _playerStateStore, GetCurrentPlayerKey(), directiveId, request);
|
||||||
return GetPlayerFactionSnapshotUnsafe();
|
return GetPlayerFactionSnapshotUnsafe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,7 +200,7 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
_playerFaction.DeleteDirective(_world, directiveId);
|
_playerFaction.DeleteDirective(_world, _playerStateStore, GetCurrentPlayerKey(), directiveId);
|
||||||
return GetPlayerFactionSnapshotUnsafe();
|
return GetPlayerFactionSnapshotUnsafe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +209,7 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
_playerFaction.UpsertPolicy(_world, policyId, request);
|
_playerFaction.UpsertPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), policyId, request);
|
||||||
return GetPlayerFactionSnapshotUnsafe();
|
return GetPlayerFactionSnapshotUnsafe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +218,7 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
_playerFaction.UpsertAutomationPolicy(_world, automationPolicyId, request);
|
_playerFaction.UpsertAutomationPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), automationPolicyId, request);
|
||||||
return GetPlayerFactionSnapshotUnsafe();
|
return GetPlayerFactionSnapshotUnsafe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +227,7 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
_playerFaction.UpsertReinforcementPolicy(_world, reinforcementPolicyId, request);
|
_playerFaction.UpsertReinforcementPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), reinforcementPolicyId, request);
|
||||||
return GetPlayerFactionSnapshotUnsafe();
|
return GetPlayerFactionSnapshotUnsafe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,7 +236,7 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
_playerFaction.UpsertProductionProgram(_world, productionProgramId, request);
|
_playerFaction.UpsertProductionProgram(_world, _playerStateStore, GetCurrentPlayerKey(), productionProgramId, request);
|
||||||
return GetPlayerFactionSnapshotUnsafe();
|
return GetPlayerFactionSnapshotUnsafe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,7 +245,7 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
_playerFaction.UpsertAssignment(_world, assetId, request);
|
_playerFaction.UpsertAssignment(_world, _playerStateStore, GetCurrentPlayerKey(), assetId, request);
|
||||||
return GetPlayerFactionSnapshotUnsafe();
|
return GetPlayerFactionSnapshotUnsafe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,11 +254,118 @@ public sealed class WorldService
|
|||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
_playerFaction.UpdateStrategicIntent(_world, request);
|
_playerFaction.UpdateStrategicIntent(_world, _playerStateStore, GetCurrentPlayerKey(), request);
|
||||||
return GetPlayerFactionSnapshotUnsafe();
|
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)
|
public ChannelReader<WorldDelta> Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var channel = Channel.CreateUnbounded<WorldDelta>(new UnboundedChannelOptions
|
var channel = Channel.CreateUnbounded<WorldDelta>(new UnboundedChannelOptions
|
||||||
@@ -318,6 +452,7 @@ public sealed class WorldService
|
|||||||
private void ReplaceWorldUnsafe(SimulationWorld world, string eventKind, string eventMessage)
|
private void ReplaceWorldUnsafe(SimulationWorld world, string eventKind, string eventMessage)
|
||||||
{
|
{
|
||||||
_world = world;
|
_world = world;
|
||||||
|
_playerStateStore.Clear();
|
||||||
_sequence += 1;
|
_sequence += 1;
|
||||||
_history.Clear();
|
_history.Clear();
|
||||||
|
|
||||||
@@ -339,7 +474,6 @@ public sealed class WorldService
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
null,
|
|
||||||
null);
|
null);
|
||||||
|
|
||||||
_history.Enqueue(worldDelta);
|
_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) =>
|
private ShipSnapshot? GetShipSnapshotUnsafe(string shipId) =>
|
||||||
_engine.BuildSnapshot(_world, _sequence).Ships.FirstOrDefault(ship => ship.Id == shipId);
|
_engine.BuildSnapshot(_world, _sequence).Ships.FirstOrDefault(ship => ship.Id == shipId);
|
||||||
|
|
||||||
private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() =>
|
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) =>
|
private static bool HasMeaningfulDelta(WorldDelta delta) =>
|
||||||
delta.RequiresSnapshotRefresh
|
delta.RequiresSnapshotRefresh
|
||||||
@@ -367,7 +921,6 @@ public sealed class WorldService
|
|||||||
|| delta.Policies.Count > 0
|
|| delta.Policies.Count > 0
|
||||||
|| delta.Ships.Count > 0
|
|| delta.Ships.Count > 0
|
||||||
|| delta.Factions.Count > 0
|
|| delta.Factions.Count > 0
|
||||||
|| delta.PlayerFaction is not null
|
|
||||||
|| delta.Geopolitics is not null;
|
|| delta.Geopolitics is not null;
|
||||||
|
|
||||||
private void Unsubscribe(Guid subscriberId)
|
private void Unsubscribe(Guid subscriberId)
|
||||||
@@ -415,7 +968,6 @@ public sealed class WorldService
|
|||||||
Policies = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Policies : [],
|
Policies = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Policies : [],
|
||||||
Ships = delta.Ships.Where((ship) => systemFilter is null || ship.SystemId == systemFilter).ToList(),
|
Ships = delta.Ships.Where((ship) => systemFilter is null || ship.SystemId == systemFilter).ToList(),
|
||||||
Factions = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Factions : [],
|
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,
|
Geopolitics = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Geopolitics : null,
|
||||||
Scope = scope,
|
Scope = scope,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,12 +5,6 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"WorldGeneration": {
|
|
||||||
"TargetSystemCount": 2,
|
|
||||||
"UseKnownSystems": true,
|
|
||||||
"AiControllerFactionCount": 0,
|
|
||||||
"GeneratePlayerFaction": false
|
|
||||||
},
|
|
||||||
"Balance": {
|
"Balance": {
|
||||||
"SimulationSpeedMultiplier": 1.5,
|
"SimulationSpeedMultiplier": 1.5,
|
||||||
"YPlane": 4,
|
"YPlane": 4,
|
||||||
@@ -24,5 +18,27 @@
|
|||||||
},
|
},
|
||||||
"OrbitalSimulation": {
|
"OrbitalSimulation": {
|
||||||
"SimulatedSecondsPerRealSecond": 0
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,6 @@
|
|||||||
"StaticData": {
|
"StaticData": {
|
||||||
"DataRoot": "../../shared/data/"
|
"DataRoot": "../../shared/data/"
|
||||||
},
|
},
|
||||||
"WorldGeneration": {
|
|
||||||
"TargetSystemCount": 160,
|
|
||||||
"UseKnownSystems": true
|
|
||||||
},
|
|
||||||
"Balance": {
|
"Balance": {
|
||||||
"SimulationSpeedMultiplier": 1.5,
|
"SimulationSpeedMultiplier": 1.5,
|
||||||
"YPlane": 4,
|
"YPlane": 4,
|
||||||
@@ -26,5 +22,15 @@
|
|||||||
"OrbitalSimulation": {
|
"OrbitalSimulation": {
|
||||||
"SimulatedSecondsPerRealSecond": 0
|
"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": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import { GameViewer } from "./GameViewer";
|
import { GameViewer } from "./GameViewer";
|
||||||
import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
|
import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
|
||||||
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
|
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
|
||||||
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
|
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
|
||||||
|
import ViewerEntityBrowserPanel from "./components/ViewerEntityBrowserPanel.vue";
|
||||||
|
import ViewerEntityInspectorPanel from "./components/ViewerEntityInspectorPanel.vue";
|
||||||
|
import ViewerShipOrderContextMenu from "./components/ViewerShipOrderContextMenu.vue";
|
||||||
import GmOpsWindow from "./components/gm/GmOpsWindow.vue";
|
import GmOpsWindow from "./components/gm/GmOpsWindow.vue";
|
||||||
import GmTelemetryWindow from "./components/gm/GmTelemetryWindow.vue";
|
import GmTelemetryWindow from "./components/gm/GmTelemetryWindow.vue";
|
||||||
import GmSettingsWindow from "./components/gm/GmSettingsWindow.vue";
|
import GmSettingsWindow from "./components/gm/GmSettingsWindow.vue";
|
||||||
|
import AuthSessionPanel from "./components/AuthSessionPanel.vue";
|
||||||
|
import AuthLandingPage from "./components/AuthLandingPage.vue";
|
||||||
|
import { useShipAutomationCatalogStore } from "./ui/stores/shipAutomationCatalogStore";
|
||||||
import { createViewerHudState } from "./viewerHudState";
|
import { createViewerHudState } from "./viewerHudState";
|
||||||
|
import { useAuthStore } from "./ui/stores/authStore";
|
||||||
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
|
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
|
||||||
import type { Selectable } from "./viewerTypes";
|
import type { Selectable } from "./viewerTypes";
|
||||||
|
|
||||||
@@ -19,8 +26,11 @@ const hoverLabelEl = ref<HTMLDivElement | null>(null);
|
|||||||
const hoverConnectorLineEl = ref<SVGLineElement | null>(null);
|
const hoverConnectorLineEl = ref<SVGLineElement | null>(null);
|
||||||
|
|
||||||
const hudState = createViewerHudState();
|
const hudState = createViewerHudState();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const automationCatalogStore = useShipAutomationCatalogStore();
|
||||||
const selectionStore = useViewerSelectionStore();
|
const selectionStore = useViewerSelectionStore();
|
||||||
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
|
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
|
||||||
|
const { canAccessGm } = storeToRefs(authStore);
|
||||||
let viewer: GameViewer | undefined;
|
let viewer: GameViewer | undefined;
|
||||||
|
|
||||||
const gmOpsOpen = ref(false);
|
const gmOpsOpen = ref(false);
|
||||||
@@ -29,6 +39,47 @@ const gmSettingsOpen = ref(false);
|
|||||||
const gmMenuOpen = ref(false);
|
const gmMenuOpen = ref(false);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
void automationCatalogStore.load();
|
||||||
|
await startViewerIfAuthenticated();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
viewer?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => authStore.isAuthenticated, async (isAuthenticated) => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
await startViewerIfAuthenticated();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer?.dispose();
|
||||||
|
viewer = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onHistoryWindowResize(id: string, width: number, height: number) {
|
||||||
|
const windowState = hudState.historyWindows.find((entry) => entry.id === id);
|
||||||
|
if (!windowState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowState.width = width;
|
||||||
|
windowState.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpenHistory(selection: Selectable) {
|
||||||
|
viewer?.openHistoryWindow(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactical") {
|
||||||
|
viewer?.focusSelection(selection, cameraMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startViewerIfAuthenticated() {
|
||||||
|
if (!authStore.isAuthenticated || viewer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
if (
|
if (
|
||||||
!canvasHostEl.value
|
!canvasHostEl.value
|
||||||
@@ -49,39 +100,19 @@ onMounted(async () => {
|
|||||||
hoverConnectorLineEl: hoverConnectorLineEl.value,
|
hoverConnectorLineEl: hoverConnectorLineEl.value,
|
||||||
});
|
});
|
||||||
void viewer.start();
|
void viewer.start();
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
viewer?.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
function onHistoryWindowResize(id: string, width: number, height: number) {
|
|
||||||
const windowState = hudState.historyWindows.find((entry) => entry.id === id);
|
|
||||||
if (!windowState) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
windowState.width = width;
|
|
||||||
windowState.height = height;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onOpenHistory(selection: Selectable) {
|
|
||||||
viewer?.openHistoryWindow(selection);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactical") {
|
|
||||||
viewer?.focusSelection(selection, cameraMode);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="viewer-app">
|
<AuthLandingPage v-if="!authStore.isAuthenticated" />
|
||||||
|
<div v-else class="viewer-app">
|
||||||
<div
|
<div
|
||||||
ref="canvasHostEl"
|
ref="canvasHostEl"
|
||||||
class="viewer-canvas-host"
|
class="viewer-canvas-host"
|
||||||
/>
|
/>
|
||||||
<div class="pointer-events-none fixed inset-0">
|
<div class="pointer-events-none fixed inset-0">
|
||||||
<div class="absolute left-5 top-5 flex w-[min(360px,calc(100vw-40px))] flex-col gap-4 max-[760px]:right-5 max-[760px]:w-auto">
|
<div class="absolute left-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(360px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:right-5 max-[760px]:bottom-[148px] max-[760px]:w-auto max-[760px]:max-h-[38vh]">
|
||||||
|
<AuthSessionPanel />
|
||||||
<CollapsibleHudPanel
|
<CollapsibleHudPanel
|
||||||
v-model:collapsed="hudState.gamePanel.collapsed"
|
v-model:collapsed="hudState.gamePanel.collapsed"
|
||||||
class-name="topbar"
|
class-name="topbar"
|
||||||
@@ -106,9 +137,13 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
|
|||||||
:summary="hudState.performancePanel.summary"
|
:summary="hudState.performancePanel.summary"
|
||||||
:body-text="hudState.performancePanel.bodyText"
|
:body-text="hudState.performancePanel.bodyText"
|
||||||
/>
|
/>
|
||||||
|
<ViewerEntityBrowserPanel
|
||||||
|
class="min-h-0 flex-1"
|
||||||
|
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute right-5 top-5 flex w-[min(380px,calc(100vw-40px))] flex-col gap-4 max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto max-[760px]:overflow-auto">
|
<div class="absolute right-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(380px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto">
|
||||||
<HtmlInfoPanel
|
<HtmlInfoPanel
|
||||||
class-name="system-panel-section"
|
class-name="system-panel-section"
|
||||||
title="System"
|
title="System"
|
||||||
@@ -118,14 +153,11 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
|
|||||||
subtitle-class="system-title"
|
subtitle-class="system-title"
|
||||||
body-class="system-body"
|
body-class="system-body"
|
||||||
/>
|
/>
|
||||||
<HtmlInfoPanel
|
<ViewerEntityInspectorPanel
|
||||||
class-name="detail-panel-section"
|
class="min-h-0 flex-1"
|
||||||
title="Focus"
|
:fallback-title="hudState.detailPanel.title"
|
||||||
:subtitle="hudState.detailPanel.title"
|
:fallback-html="hudState.detailPanel.bodyHtml"
|
||||||
:body-html="hudState.detailPanel.bodyHtml"
|
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
|
||||||
:hidden="hudState.detailPanel.hidden"
|
|
||||||
subtitle-class="detail-title"
|
|
||||||
body-class="detail-body"
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="pointer-events-auto rounded-xl bg-[rgba(255,116,88,0.14)] px-3.5 py-3 text-[#ffd8cf]"
|
class="pointer-events-auto rounded-xl bg-[rgba(255,116,88,0.14)] px-3.5 py-3 text-[#ffd8cf]"
|
||||||
@@ -150,7 +182,7 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gm-launcher" @mouseleave="gmMenuOpen = false">
|
<div v-if="canAccessGm" class="gm-launcher" @mouseleave="gmMenuOpen = false">
|
||||||
<div v-if="gmMenuOpen" class="gm-launcher-menu">
|
<div v-if="gmMenuOpen" class="gm-launcher-menu">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -239,6 +271,8 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
|
|||||||
>
|
>
|
||||||
{{ hudState.hoverLabel.text }}
|
{{ hudState.hoverLabel.text }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ViewerShipOrderContextMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ import { LocalLayer } from "./viewerLocalLayer";
|
|||||||
import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState";
|
import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState";
|
||||||
import { describeSelectable } from "./viewerSelection";
|
import { describeSelectable } from "./viewerSelection";
|
||||||
import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection";
|
import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection";
|
||||||
|
import { useViewerSceneStore } from "./ui/stores/viewerScene";
|
||||||
|
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
|
||||||
|
import { viewerPinia } from "./ui/stores/pinia";
|
||||||
import type { FactionSnapshot } from "./contracts";
|
import type { FactionSnapshot } from "./contracts";
|
||||||
import type {
|
import type {
|
||||||
CameraMode,
|
CameraMode,
|
||||||
@@ -68,6 +71,8 @@ export class ViewerAppController {
|
|||||||
|
|
||||||
readonly hudState: ViewerHudState;
|
readonly hudState: ViewerHudState;
|
||||||
readonly selectionStore: ViewerSelectionStore;
|
readonly selectionStore: ViewerSelectionStore;
|
||||||
|
private readonly sceneStore = useViewerSceneStore(viewerPinia);
|
||||||
|
private readonly orderContextMenuStore = useViewerOrderContextMenuStore(viewerPinia);
|
||||||
private readonly historyLayerEl: HTMLDivElement;
|
private readonly historyLayerEl: HTMLDivElement;
|
||||||
private readonly marqueeEl: HTMLDivElement;
|
private readonly marqueeEl: HTMLDivElement;
|
||||||
private readonly hoverLabelEl: HTMLDivElement;
|
private readonly hoverLabelEl: HTMLDivElement;
|
||||||
@@ -156,6 +161,8 @@ export class ViewerAppController {
|
|||||||
this.disposeEventBindings();
|
this.disposeEventBindings();
|
||||||
this.unsubscribeSelectionStore();
|
this.unsubscribeSelectionStore();
|
||||||
this.stream?.close();
|
this.stream?.close();
|
||||||
|
this.sceneStore.reset();
|
||||||
|
this.orderContextMenuStore.close();
|
||||||
this.renderSurface.dispose();
|
this.renderSurface.dispose();
|
||||||
disposeSceneResources(this.universeLayer.scene);
|
disposeSceneResources(this.universeLayer.scene);
|
||||||
disposeSceneResources(this.galaxyLayer.scene);
|
disposeSceneResources(this.galaxyLayer.scene);
|
||||||
@@ -206,6 +213,7 @@ export class ViewerAppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applySelectedItems(items: Selectable[], source: "viewer" | "ui") {
|
private applySelectedItems(items: Selectable[], source: "viewer" | "ui") {
|
||||||
|
this.orderContextMenuStore.close();
|
||||||
this.selectedItems = items;
|
this.selectedItems = items;
|
||||||
if (items.length === 1) {
|
if (items.length === 1) {
|
||||||
const selection = items[0];
|
const selection = items[0];
|
||||||
@@ -224,6 +232,7 @@ export class ViewerAppController {
|
|||||||
kind: Selectable["kind"] | null,
|
kind: Selectable["kind"] | null,
|
||||||
entityId: string | null,
|
entityId: string | null,
|
||||||
) {
|
) {
|
||||||
|
this.orderContextMenuStore.close();
|
||||||
const selection = entityIdToSelectable(kind, entityId);
|
const selection = entityIdToSelectable(kind, entityId);
|
||||||
this.selectedItems = selection ? [selection] : [];
|
this.selectedItems = selection ? [selection] : [];
|
||||||
this.navigationController.syncFollowStateFromSelection();
|
this.navigationController.syncFollowStateFromSelection();
|
||||||
@@ -270,6 +279,9 @@ export class ViewerAppController {
|
|||||||
this.currentDistance = nextState.currentDistance;
|
this.currentDistance = nextState.currentDistance;
|
||||||
this.povLevel = nextState.povLevel;
|
this.povLevel = nextState.povLevel;
|
||||||
this.orbitPitch = nextState.orbitPitch;
|
this.orbitPitch = nextState.orbitPitch;
|
||||||
|
if (this.sceneStore.povLevel !== this.povLevel) {
|
||||||
|
this.sceneStore.setViewContext(this.activeSystemId ?? null, this.povLevel);
|
||||||
|
}
|
||||||
this.navigationController.updateActiveSystem();
|
this.navigationController.updateActiveSystem();
|
||||||
|
|
||||||
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
|
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import type { WorldDelta, WorldSnapshot } from "./contracts";
|
|||||||
import type { TelemetrySnapshot } from "./contractsTelemetry";
|
import type { TelemetrySnapshot } from "./contractsTelemetry";
|
||||||
import type { BalanceSettings } from "./contractsBalance";
|
import type { BalanceSettings } from "./contractsBalance";
|
||||||
import type { PlayerFactionSnapshot } from "./contractsPlayerFaction";
|
import type { PlayerFactionSnapshot } from "./contractsPlayerFaction";
|
||||||
|
import type { AuthSessionResponse, ForgotPasswordResponse } from "./contractsAuth";
|
||||||
|
import type { ShipAutomationCatalogSnapshot } from "./contractsShipAutomation";
|
||||||
|
import type { FactionSnapshot } from "./contractsFactions";
|
||||||
import type { ShipSnapshot } from "./contractsShips";
|
import type { ShipSnapshot } from "./contractsShips";
|
||||||
|
import type { StationSnapshot } from "./contractsInfrastructure";
|
||||||
|
import { clearAuthSession, getAuthSession, setAuthSession } from "./authSession";
|
||||||
import type {
|
import type {
|
||||||
PlayerAssetAssignmentCommandRequest,
|
PlayerAssetAssignmentCommandRequest,
|
||||||
PlayerAutomationPolicyCommandRequest,
|
PlayerAutomationPolicyCommandRequest,
|
||||||
@@ -23,16 +28,54 @@ export interface WorldStreamScope {
|
|||||||
bubbleId?: string | null;
|
bubbleId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit, options?: { skipAuth?: boolean; skipRefresh?: boolean }): Promise<T> {
|
||||||
const response = await fetch(input, init);
|
const headers = new Headers(init?.headers);
|
||||||
|
if (!options?.skipAuth) {
|
||||||
|
const session = getAuthSession();
|
||||||
|
if (session?.accessToken) {
|
||||||
|
headers.set("Authorization", `Bearer ${session.accessToken}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(input, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (response.status === 401 && !options?.skipAuth && !options?.skipRefresh) {
|
||||||
|
const refreshed = await tryRefreshSession();
|
||||||
|
if (refreshed) {
|
||||||
|
return fetchJson<T>(input, init, { skipRefresh: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`${init?.method ?? "GET"} ${typeof input === "string" ? input : input.toString()} failed with ${response.status}`);
|
throw new Error(`${init?.method ?? "GET"} ${typeof input === "string" ? input : input.toString()} failed with ${response.status}`);
|
||||||
}
|
}
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function tryRefreshSession(): Promise<boolean> {
|
||||||
|
const session = getAuthSession();
|
||||||
|
if (!session?.refreshToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/auth/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ refreshToken: session.refreshToken }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
clearAuthSession();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSession = await response.json() as AuthSessionResponse;
|
||||||
|
setAuthSession(nextSession);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchWorldSnapshot(signal?: AbortSignal) {
|
export async function fetchWorldSnapshot(signal?: AbortSignal) {
|
||||||
return fetchJson<WorldSnapshot>("/api/world", { signal });
|
return fetchJson<WorldSnapshot>("/api/world", { signal }, { skipAuth: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openWorldStream(
|
export function openWorldStream(
|
||||||
@@ -86,16 +129,80 @@ export async function updateBalance(settings: BalanceSettings) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createFaction(request: { factionId: string }) {
|
||||||
|
return fetchJson<FactionSnapshot>("/api/gm/factions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function spawnShip(request: { factionId: string; systemId: string; shipId?: string | null; behaviorKind?: string | null }) {
|
||||||
|
return fetchJson<ShipSnapshot>("/api/gm/ships", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function spawnStation(request: { factionId: string; systemId: string; objective?: string | null; label?: string | null }) {
|
||||||
|
return fetchJson<StationSnapshot>("/api/gm/stations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function resetWorld() {
|
export async function resetWorld() {
|
||||||
return fetchJson<WorldSnapshot>("/api/world/reset", {
|
return fetchJson<WorldSnapshot>("/api/world/reset", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function register(request: { email: string; password: string }) {
|
||||||
|
const session = await fetchJson<AuthSessionResponse>("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
}, { skipAuth: true, skipRefresh: true });
|
||||||
|
setAuthSession(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(request: { email: string; password: string }) {
|
||||||
|
const session = await fetchJson<AuthSessionResponse>("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
}, { skipAuth: true, skipRefresh: true });
|
||||||
|
setAuthSession(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forgotPassword(request: { email: string }) {
|
||||||
|
return fetchJson<ForgotPasswordResponse>("/api/auth/forgot-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
}, { skipAuth: true, skipRefresh: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPassword(request: { token: string; newPassword: string }) {
|
||||||
|
await fetchJson<void>("/api/auth/reset-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
}, { skipAuth: true, skipRefresh: true });
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPlayerFaction(signal?: AbortSignal) {
|
export async function fetchPlayerFaction(signal?: AbortSignal) {
|
||||||
return fetchJson<PlayerFactionSnapshot>("/api/player-faction", { signal });
|
return fetchJson<PlayerFactionSnapshot>("/api/player-faction", { signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchShipAutomationCatalog(signal?: AbortSignal) {
|
||||||
|
return fetchJson<ShipAutomationCatalogSnapshot>("/api/ships/catalog", { signal }, { skipAuth: true });
|
||||||
|
}
|
||||||
|
|
||||||
export async function createPlayerOrganization(request: PlayerOrganizationCommandRequest) {
|
export async function createPlayerOrganization(request: PlayerOrganizationCommandRequest) {
|
||||||
return fetchJson<PlayerFactionSnapshot>("/api/player-faction/organizations", {
|
return fetchJson<PlayerFactionSnapshot>("/api/player-faction/organizations", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -182,3 +289,9 @@ export async function updateShipDefaultBehavior(shipId: string, request: ShipDef
|
|||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function removeShipOrder(shipId: string, orderId: string) {
|
||||||
|
return fetchJson<ShipSnapshot>(`/api/ships/${shipId}/orders/${orderId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
BIN
apps/viewer/src/assets/backdrop1.webp
Normal file
BIN
apps/viewer/src/assets/backdrop1.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 341 KiB |
76
apps/viewer/src/authSession.ts
Normal file
76
apps/viewer/src/authSession.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import type { AuthSessionResponse } from "./contractsAuth";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "space-game.auth.session";
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
accessToken: string;
|
||||||
|
accessTokenExpiresAtUtc: string;
|
||||||
|
refreshToken: string;
|
||||||
|
refreshTokenExpiresAtUtc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentSession: AuthSession | null = loadStoredSession();
|
||||||
|
const listeners = new Set<(session: AuthSession | null) => void>();
|
||||||
|
|
||||||
|
export function getAuthSession(): AuthSession | null {
|
||||||
|
return currentSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAuthSession(session: AuthSessionResponse | null) {
|
||||||
|
currentSession = session ? { ...session } : null;
|
||||||
|
persistSession(currentSession);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthSession() {
|
||||||
|
currentSession = null;
|
||||||
|
persistSession(null);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeToAuthSession(listener: (session: AuthSession | null) => void) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStoredSession(): AuthSession | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as AuthSession;
|
||||||
|
return parsed?.accessToken && parsed?.refreshToken
|
||||||
|
? { ...parsed, roles: Array.isArray(parsed.roles) ? parsed.roles : [] }
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSession(session: AuthSession | null) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyListeners() {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(currentSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
185
apps/viewer/src/components/AuthLandingPage.vue
Normal file
185
apps/viewer/src/components/AuthLandingPage.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from "vue";
|
||||||
|
import { forgotPassword, login, register, resetPassword } from "../api";
|
||||||
|
import { useAuthStore } from "../ui/stores/authStore";
|
||||||
|
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
|
||||||
|
|
||||||
|
type AuthPane = "login" | "register" | "forgot" | "reset";
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const playerFactionStore = usePlayerFactionStore();
|
||||||
|
|
||||||
|
const pane = ref<AuthPane>("login");
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const infoMessage = ref("");
|
||||||
|
const returnedResetToken = ref("");
|
||||||
|
|
||||||
|
const loginForm = reactive({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerForm = reactive({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const forgotForm = reactive({
|
||||||
|
email: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = reactive({
|
||||||
|
token: "",
|
||||||
|
newPassword: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const paneTitle = computed(() => {
|
||||||
|
switch (pane.value) {
|
||||||
|
case "register":
|
||||||
|
return "Create your pilot account";
|
||||||
|
case "forgot":
|
||||||
|
return "Recover access";
|
||||||
|
case "reset":
|
||||||
|
return "Choose a new password";
|
||||||
|
default:
|
||||||
|
return "Sign in to the command bridge";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitLogin() {
|
||||||
|
await execute(async () => {
|
||||||
|
const session = await login(loginForm);
|
||||||
|
authStore.setSession(session);
|
||||||
|
playerFactionStore.setPlayerFaction(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRegister() {
|
||||||
|
await execute(async () => {
|
||||||
|
const session = await register(registerForm);
|
||||||
|
authStore.setSession(session);
|
||||||
|
playerFactionStore.setPlayerFaction(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForgot() {
|
||||||
|
await execute(async () => {
|
||||||
|
const response = await forgotPassword(forgotForm);
|
||||||
|
returnedResetToken.value = response.resetToken ?? "";
|
||||||
|
if (response.resetToken) {
|
||||||
|
resetForm.token = response.resetToken;
|
||||||
|
resetForm.newPassword = "";
|
||||||
|
infoMessage.value = "Development reset token generated below. Continue directly to reset password.";
|
||||||
|
pane.value = "reset";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
infoMessage.value = "If the account exists, a password reset message has been issued.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReset() {
|
||||||
|
await execute(async () => {
|
||||||
|
await resetPassword(resetForm);
|
||||||
|
returnedResetToken.value = "";
|
||||||
|
infoMessage.value = "Password updated. Sign in with the new password.";
|
||||||
|
pane.value = "login";
|
||||||
|
resetForm.token = "";
|
||||||
|
resetForm.newPassword = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchPane(nextPane: AuthPane) {
|
||||||
|
pane.value = nextPane;
|
||||||
|
errorMessage.value = "";
|
||||||
|
infoMessage.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execute(action: () => Promise<void>) {
|
||||||
|
errorMessage.value = "";
|
||||||
|
infoMessage.value = "";
|
||||||
|
authStore.setBusy(true);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : "Request failed.";
|
||||||
|
} finally {
|
||||||
|
authStore.setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="auth-landing">
|
||||||
|
<div class="auth-landing__backdrop" />
|
||||||
|
|
||||||
|
<div class="auth-landing__hero">
|
||||||
|
<h1>Take command in a persistent universe.</h1>
|
||||||
|
<p>
|
||||||
|
Take your destiny into your own hands. Explore the frontier, forge alliances, expand your reach, and make enemies in a living space sim that keeps moving with or without you.
|
||||||
|
</p>
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-card__tabs">
|
||||||
|
<button type="button" :class="{ 'is-active': pane === 'login' }" @click="switchPane('login')">Login</button>
|
||||||
|
<button type="button" :class="{ 'is-active': pane === 'register' }" @click="switchPane('register')">Register</button>
|
||||||
|
<button type="button" :class="{ 'is-active': pane === 'forgot' }" @click="switchPane('forgot')">Forgot</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{{ paneTitle }}</h2>
|
||||||
|
|
||||||
|
<form v-if="pane === 'login'" class="auth-card__form" @submit.prevent="submitLogin">
|
||||||
|
<input v-model.trim="loginForm.email" type="text" autocomplete="username" placeholder="Email or login">
|
||||||
|
<input v-model="loginForm.password" type="password" autocomplete="current-password" placeholder="Password">
|
||||||
|
<button type="submit" :disabled="authStore.busy">{{ authStore.busy ? "Signing in..." : "Sign in" }}</button>
|
||||||
|
<button type="button" class="auth-card__link" @click="switchPane('forgot')">Forgot password?</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form v-else-if="pane === 'register'" class="auth-card__form" @submit.prevent="submitRegister">
|
||||||
|
<input v-model.trim="registerForm.email" type="email" autocomplete="email" placeholder="Email">
|
||||||
|
<input v-model="registerForm.password" type="password" autocomplete="new-password" placeholder="Password">
|
||||||
|
<button type="submit" :disabled="authStore.busy">{{ authStore.busy ? "Creating..." : "Create account" }}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form v-else-if="pane === 'forgot'" class="auth-card__form" @submit.prevent="submitForgot">
|
||||||
|
<input v-model.trim="forgotForm.email" type="email" autocomplete="email" placeholder="Email">
|
||||||
|
<button type="submit" :disabled="authStore.busy">{{ authStore.busy ? "Submitting..." : "Send reset link" }}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form v-else class="auth-card__form" @submit.prevent="submitReset">
|
||||||
|
<input v-model.trim="resetForm.token" type="text" autocomplete="off" placeholder="Reset token">
|
||||||
|
<input v-model="resetForm.newPassword" type="password" autocomplete="new-password" placeholder="New password">
|
||||||
|
<button type="submit" :disabled="authStore.busy">{{ authStore.busy ? "Updating..." : "Reset password" }}</button>
|
||||||
|
<button type="button" class="auth-card__link" @click="switchPane('login')">Back to login</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-if="returnedResetToken" class="auth-card__token">
|
||||||
|
<div class="auth-card__token-label">Development reset token</div>
|
||||||
|
<code>{{ returnedResetToken }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="infoMessage" class="auth-card__message auth-card__message--info">
|
||||||
|
{{ infoMessage }}
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMessage" class="auth-card__message auth-card__message--error">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-card__footer">
|
||||||
|
<button
|
||||||
|
v-if="pane !== 'reset'"
|
||||||
|
type="button"
|
||||||
|
class="auth-card__link"
|
||||||
|
@click="switchPane('reset')"
|
||||||
|
>
|
||||||
|
Have a reset token?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-landing__notes">
|
||||||
|
<div>Local account auth is active on this dev machine.</div>
|
||||||
|
<div>Google, Microsoft, and other providers can plug into the same identity model later.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
138
apps/viewer/src/components/AuthSessionPanel.vue
Normal file
138
apps/viewer/src/components/AuthSessionPanel.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from "vue";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { login, register } from "../api";
|
||||||
|
import { useAuthStore } from "../ui/stores/authStore";
|
||||||
|
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const playerFactionStore = usePlayerFactionStore();
|
||||||
|
const { session, busy } = storeToRefs(authStore);
|
||||||
|
|
||||||
|
const mode = ref<"login" | "register">("login");
|
||||||
|
const email = ref("");
|
||||||
|
const password = ref("");
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const forgotPasswordOpen = ref(false);
|
||||||
|
const forgotPasswordState = reactive({
|
||||||
|
email: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
errorMessage.value = "";
|
||||||
|
authStore.setBusy(true);
|
||||||
|
try {
|
||||||
|
const snapshot = mode.value === "login"
|
||||||
|
? await login({ email: email.value, password: password.value })
|
||||||
|
: await register({ email: email.value, password: password.value });
|
||||||
|
authStore.setSession(snapshot);
|
||||||
|
playerFactionStore.setPlayerFaction(null);
|
||||||
|
password.value = "";
|
||||||
|
forgotPasswordOpen.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : "Authentication failed.";
|
||||||
|
} finally {
|
||||||
|
authStore.setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
authStore.clearSession();
|
||||||
|
playerFactionStore.setPlayerFaction(null);
|
||||||
|
errorMessage.value = "";
|
||||||
|
password.value = "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pointer-events-auto rounded-2xl border border-white/10 bg-[rgba(11,14,20,0.9)] px-4 py-4 text-[color:var(--viewer-text)] shadow-[0_18px_60px_rgba(0,0,0,0.35)] backdrop-blur">
|
||||||
|
<template v-if="session">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-[0.22em] text-white/45">Identity</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold">{{ session.email }}</div>
|
||||||
|
<div class="mt-1 text-xs text-white/55">Player {{ session.userId.slice(0, 8) }}</div>
|
||||||
|
<div v-if="session.roles.length > 0" class="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="role in session.roles"
|
||||||
|
:key="role"
|
||||||
|
class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] uppercase tracking-[0.16em] text-white/65"
|
||||||
|
>
|
||||||
|
{{ role }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs transition hover:bg-white/10"
|
||||||
|
@click="logout"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-[0.22em] text-white/45">Pilot Access</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold">{{ mode === "login" ? "Sign in" : "Create account" }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs transition hover:bg-white/10"
|
||||||
|
@click="mode = mode === 'login' ? 'register' : 'login'"
|
||||||
|
>
|
||||||
|
{{ mode === "login" ? "Register" : "Login" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="mt-3 flex flex-col gap-2.5" @submit.prevent="submit">
|
||||||
|
<input
|
||||||
|
v-model.trim="email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="Email"
|
||||||
|
class="rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm outline-none transition placeholder:text-white/35 focus:border-white/30"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
:autocomplete="mode === 'login' ? 'current-password' : 'new-password'"
|
||||||
|
placeholder="Password"
|
||||||
|
class="rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm outline-none transition placeholder:text-white/35 focus:border-white/30"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-xl bg-[#ff7458] px-3 py-2 text-sm font-semibold text-black transition hover:bg-[#ff8c74] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="busy"
|
||||||
|
>
|
||||||
|
{{ busy ? "Working..." : mode === "login" ? "Sign in" : "Create account" }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-2 text-xs text-white/55 underline-offset-4 transition hover:text-white/80 hover:underline"
|
||||||
|
@click="forgotPasswordOpen = !forgotPasswordOpen"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="forgotPasswordOpen" class="mt-2 rounded-xl border border-dashed border-white/10 bg-black/15 px-3 py-2 text-xs text-white/60">
|
||||||
|
<div class="font-medium text-white/72">Reset flow not implemented yet.</div>
|
||||||
|
<div class="mt-1">Backend support is still missing for forgot-password and reset-token delivery.</div>
|
||||||
|
<input
|
||||||
|
v-model.trim="forgotPasswordState.email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="Email for future reset flow"
|
||||||
|
class="mt-2 w-full rounded-lg border border-white/10 bg-black/20 px-3 py-2 text-sm outline-none transition placeholder:text-white/35 focus:border-white/30"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="mt-2 rounded-xl border border-[#ff7458]/25 bg-[rgba(255,116,88,0.12)] px-3 py-2 text-xs text-[#ffd8cf]">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user