Compare commits
11 Commits
04d182e93f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8503855a4c | |||
| 6c92ab50c8 | |||
| d0c6e30304 | |||
| 75568324f5 | |||
| fdcf83ccec | |||
| 74b8bf4116 | |||
| c9a4b474b4 | |||
| 63a9f808bb | |||
| 706e1cda8f | |||
| 0bb72bee35 | |||
| 640e147ea8 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,3 +17,5 @@ pnpm-debug.log*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
.codex
|
||||
|
||||
29
AGENTS.md
Normal file
29
AGENTS.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Pair Programming Mode
|
||||
|
||||
When working in this repository, act as a pair programming partner by default.
|
||||
|
||||
## Collaboration Rules
|
||||
|
||||
- Do not broaden scope on your own.
|
||||
- Before coding, restate the request in your own words.
|
||||
- Ask clarifying questions when scope, ownership, or design intent is ambiguous.
|
||||
- Push back on weak assumptions, risky changes, or hidden refactors.
|
||||
- Prefer discussion first, implementation second.
|
||||
- Do not refactor adjacent code unless explicitly approved.
|
||||
- Separate proposed work into:
|
||||
- required
|
||||
- optional
|
||||
- recommended
|
||||
- After scope is agreed, implement only that scope.
|
||||
|
||||
## Ambiguity Rules
|
||||
|
||||
- If the request is underspecified, stop and ask instead of assuming.
|
||||
- If the requested change may interfere with an in-progress refactor, call that out before editing.
|
||||
- If a request sounds small, keep the first response small and scoped unless asked to expand.
|
||||
|
||||
## Working Style
|
||||
|
||||
- Treat the user as an active collaborator, not a ticket queue.
|
||||
- Surface tradeoffs before making structural changes.
|
||||
- Prefer explicit approval before changing architecture, bootstrapping, dependency wiring, or data flow.
|
||||
@@ -3,4 +3,8 @@
|
||||
<Folder Name="/apps/backend/">
|
||||
<Project Path="apps/backend/SpaceGame.Api.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/" />
|
||||
<Folder Name="/tests/backend/">
|
||||
<Project Path="tests/backend/SpaceGame.Api.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -2,7 +2,6 @@ root = true
|
||||
|
||||
[*.{cs,csx}]
|
||||
charset = utf-8
|
||||
end_of_line = crlf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
@@ -40,7 +39,6 @@ csharp_new_line_before_open_brace = all
|
||||
|
||||
[*.{csproj,props,targets,sln,slnx}]
|
||||
charset = utf-8
|
||||
end_of_line = crlf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
@@ -48,7 +46,6 @@ indent_size = 2
|
||||
|
||||
[*.{json,jsonc}]
|
||||
charset = utf-8
|
||||
end_of_line = crlf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
22
apps/backend/Auth/Api/GetRacesHandler.cs
Normal file
22
apps/backend/Auth/Api/GetRacesHandler.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using FastEndpoints;
|
||||
using SpaceGame.Api.Universe.Bootstrap;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Api;
|
||||
|
||||
public sealed class GetRacesHandler(IStaticDataProvider staticData) : EndpointWithoutRequest<IReadOnlyList<RaceSnapshot>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/auth/races");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var races = staticData.RaceDefinitions.Values
|
||||
.OrderBy(race => race.Name, StringComparer.Ordinal)
|
||||
.Select(race => new RaceSnapshot(race.Id, race.Name, race.Description, race.Icon))
|
||||
.ToList();
|
||||
await SendOkAsync(races, cancellationToken);
|
||||
}
|
||||
}
|
||||
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, RegisterResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/auth/register");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(RegisterRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendOkAsync(await authService.RegisterAsync(request, cancellationToken), cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(cancellation: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
apps/backend/Auth/Contracts/AuthContracts.cs
Normal file
47
apps/backend/Auth/Contracts/AuthContracts.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
namespace SpaceGame.Api.Auth.Contracts;
|
||||
|
||||
public sealed class RegisterRequest
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class LoginRequest
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class RefreshTokenRequest
|
||||
{
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ForgotPasswordRequest
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ResetPasswordRequest
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record AuthSessionResponse(
|
||||
Guid UserId,
|
||||
string Email,
|
||||
IReadOnlyList<string> Roles,
|
||||
string AccessToken,
|
||||
DateTimeOffset AccessTokenExpiresAtUtc,
|
||||
string RefreshToken,
|
||||
DateTimeOffset RefreshTokenExpiresAtUtc);
|
||||
|
||||
public sealed record RegisterResponse(
|
||||
Guid UserId,
|
||||
string Email,
|
||||
bool RequiresLogin);
|
||||
|
||||
public sealed record ForgotPasswordResponse(
|
||||
bool Accepted,
|
||||
string? ResetToken = null);
|
||||
7
apps/backend/Auth/Contracts/Races.cs
Normal file
7
apps/backend/Auth/Contracts/Races.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace SpaceGame.Api.Auth.Contracts;
|
||||
|
||||
public sealed record RaceSnapshot(
|
||||
string Id,
|
||||
string Name,
|
||||
string Description,
|
||||
string Icon);
|
||||
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<RegisterResponse> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = NormalizeEmail(request.Email);
|
||||
ValidatePassword(request.Password);
|
||||
|
||||
if (await authRepository.FindUserByEmailAsync(email, cancellationToken) is not null)
|
||||
{
|
||||
throw new InvalidOperationException("An account already exists for that email.");
|
||||
}
|
||||
|
||||
var user = await authRepository.CreateUserAsync(email, passwordHasher.HashPassword(request.Password), [], cancellationToken);
|
||||
return new RegisterResponse(user.Id, user.Email, true);
|
||||
}
|
||||
|
||||
public async Task<AuthSessionResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = NormalizeEmail(request.Email);
|
||||
var user = await authRepository.FindUserByEmailAsync(email, cancellationToken)
|
||||
?? throw new InvalidOperationException("Invalid email or password.");
|
||||
if (!passwordHasher.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid email or password.");
|
||||
}
|
||||
|
||||
return await CreateSessionAsync(user, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AuthSessionResponse> RefreshAsync(RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||
{
|
||||
throw new InvalidOperationException("Refresh token is required.");
|
||||
}
|
||||
|
||||
var tokenHash = refreshTokenFactory.HashToken(request.RefreshToken);
|
||||
var record = await authRepository.FindRefreshTokenAsync(tokenHash, cancellationToken)
|
||||
?? throw new InvalidOperationException("Refresh token is invalid.");
|
||||
if (record.RevokedAtUtc is not null || record.ExpiresAtUtc <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
throw new InvalidOperationException("Refresh token is expired.");
|
||||
}
|
||||
|
||||
var user = await authRepository.FindUserByIdAsync(record.UserId, cancellationToken)
|
||||
?? throw new InvalidOperationException("User account was not found.");
|
||||
await authRepository.RevokeRefreshTokenAsync(record.Id, cancellationToken);
|
||||
return await CreateSessionAsync(user, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = NormalizeEmail(request.Email);
|
||||
var user = await authRepository.FindUserByEmailAsync(email, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return new ForgotPasswordResponse(true);
|
||||
}
|
||||
|
||||
var resetToken = refreshTokenFactory.CreateToken();
|
||||
var resetTokenHash = refreshTokenFactory.HashToken(resetToken);
|
||||
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(30);
|
||||
await authRepository.StorePasswordResetTokenAsync(user.Id, resetTokenHash, expiresAtUtc, cancellationToken);
|
||||
return await passwordResetDelivery.DeliverAsync(user, resetToken, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
throw new InvalidOperationException("Reset token is required.");
|
||||
}
|
||||
|
||||
ValidatePassword(request.NewPassword);
|
||||
var tokenHash = refreshTokenFactory.HashToken(request.Token);
|
||||
var record = await authRepository.FindPasswordResetTokenAsync(tokenHash, cancellationToken)
|
||||
?? throw new InvalidOperationException("Reset token is invalid.");
|
||||
if (record.ConsumedAtUtc is not null || record.ExpiresAtUtc <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
throw new InvalidOperationException("Reset token is expired.");
|
||||
}
|
||||
|
||||
await authRepository.UpdatePasswordHashAsync(record.UserId, passwordHasher.HashPassword(request.NewPassword), cancellationToken);
|
||||
await authRepository.ConsumePasswordResetTokenAsync(record.Id, cancellationToken);
|
||||
await authRepository.RevokeAllRefreshTokensAsync(record.UserId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AuthSessionResponse> CreateSessionAsync(UserAccount user, CancellationToken cancellationToken)
|
||||
{
|
||||
var (accessToken, accessExpiresAtUtc) = tokenService.CreateAccessToken(user);
|
||||
var (refreshToken, refreshTokenHash, refreshExpiresAtUtc) = tokenService.CreateRefreshToken();
|
||||
await authRepository.StoreRefreshTokenAsync(user.Id, refreshTokenHash, refreshExpiresAtUtc, cancellationToken);
|
||||
return new AuthSessionResponse(user.Id, user.Email, user.Roles, accessToken, accessExpiresAtUtc, refreshToken, refreshExpiresAtUtc);
|
||||
}
|
||||
|
||||
private static string NormalizeEmail(string email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
throw new InvalidOperationException("Email is required.");
|
||||
}
|
||||
|
||||
return email.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void ValidatePassword(string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
|
||||
{
|
||||
throw new InvalidOperationException("Password must be at least 8 characters.");
|
||||
}
|
||||
}
|
||||
}
|
||||
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,40 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpContextAccessor) : IPlayerIdentityResolver
|
||||
{
|
||||
public const string EffectivePlayerHeaderName = "X-Act-As-Player-Id";
|
||||
|
||||
public Guid? GetCurrentPlayerId()
|
||||
{
|
||||
var subject = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? httpContextAccessor.HttpContext?.User.FindFirstValue("sub");
|
||||
return Guid.TryParse(subject, out var playerId) ? playerId : null;
|
||||
}
|
||||
|
||||
public Guid GetRequiredPlayerId() =>
|
||||
GetCurrentPlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required.");
|
||||
|
||||
public Guid? GetEffectivePlayerId()
|
||||
{
|
||||
var currentPlayerId = GetCurrentPlayerId();
|
||||
if (!CanAccessGm())
|
||||
{
|
||||
return currentPlayerId;
|
||||
}
|
||||
|
||||
var requestedIdentity = httpContextAccessor.HttpContext?.Request.Headers[EffectivePlayerHeaderName].FirstOrDefault();
|
||||
return Guid.TryParse(requestedIdentity, out var effectivePlayerId) ? effectivePlayerId : currentPlayerId;
|
||||
}
|
||||
|
||||
public Guid GetRequiredEffectivePlayerId() =>
|
||||
GetEffectivePlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required.");
|
||||
|
||||
public bool CanAccessGm()
|
||||
{
|
||||
var user = httpContextAccessor.HttpContext?.User;
|
||||
return user?.IsInRole("gm") == true || user?.IsInRole("admin") == true;
|
||||
}
|
||||
}
|
||||
18
apps/backend/Auth/Simulation/IAuthRepository.cs
Normal file
18
apps/backend/Auth/Simulation/IAuthRepository.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public interface IAuthRepository
|
||||
{
|
||||
Task<UserAccount?> FindUserByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
Task<UserAccount?> FindUserByIdAsync(Guid userId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<UserAccount>> ListUsersAsync(CancellationToken cancellationToken);
|
||||
Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
|
||||
Task<UserAccount> UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
|
||||
Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken);
|
||||
Task<RefreshTokenRecord?> FindRefreshTokenAsync(string tokenHash, CancellationToken cancellationToken);
|
||||
Task RevokeRefreshTokenAsync(Guid refreshTokenId, CancellationToken cancellationToken);
|
||||
Task RevokeAllRefreshTokensAsync(Guid userId, CancellationToken cancellationToken);
|
||||
Task StorePasswordResetTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken);
|
||||
Task<PasswordResetTokenRecord?> FindPasswordResetTokenAsync(string tokenHash, CancellationToken cancellationToken);
|
||||
Task ConsumePasswordResetTokenAsync(Guid passwordResetTokenId, CancellationToken cancellationToken);
|
||||
Task UpdatePasswordHashAsync(Guid userId, string passwordHash, CancellationToken cancellationToken);
|
||||
}
|
||||
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);
|
||||
}
|
||||
10
apps/backend/Auth/Simulation/IPlayerIdentityResolver.cs
Normal file
10
apps/backend/Auth/Simulation/IPlayerIdentityResolver.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public interface IPlayerIdentityResolver
|
||||
{
|
||||
Guid? GetCurrentPlayerId();
|
||||
Guid GetRequiredPlayerId();
|
||||
Guid? GetEffectivePlayerId();
|
||||
Guid GetRequiredEffectivePlayerId();
|
||||
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);
|
||||
}
|
||||
}
|
||||
216
apps/backend/Auth/Simulation/PostgresAuthRepository.cs
Normal file
216
apps/backend/Auth/Simulation/PostgresAuthRepository.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
using Npgsql;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public sealed class PostgresAuthRepository(NpgsqlDataSource dataSource) : IAuthRepository
|
||||
{
|
||||
public async Task<UserAccount?> FindUserByEmailAsync(string email, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
select id, email, password_hash, created_at_utc, roles
|
||||
from auth_users
|
||||
where email = $1
|
||||
""");
|
||||
command.Parameters.AddWithValue(email);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<UserAccount?> FindUserByIdAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
select id, email, password_hash, created_at_utc, roles
|
||||
from auth_users
|
||||
where id = $1
|
||||
""");
|
||||
command.Parameters.AddWithValue(userId);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserAccount>> ListUsersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
select id, email, password_hash, created_at_utc, roles
|
||||
from auth_users
|
||||
order by email asc
|
||||
""");
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
var users = new List<UserAccount>();
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
users.Add(ReadUser(reader));
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
public async Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var createdAtUtc = DateTimeOffset.UtcNow;
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
insert into auth_users (id, email, password_hash, created_at_utc, roles)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
""");
|
||||
command.Parameters.AddWithValue(userId);
|
||||
command.Parameters.AddWithValue(email);
|
||||
command.Parameters.AddWithValue(passwordHash);
|
||||
command.Parameters.AddWithValue(createdAtUtc);
|
||||
command.Parameters.AddWithValue(roles.Select(role => role.Trim().ToLowerInvariant()).Where(role => role.Length > 0).Distinct(StringComparer.Ordinal).ToArray());
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
return new UserAccount(userId, email, passwordHash, createdAtUtc, roles.Select(role => role.Trim().ToLowerInvariant()).Where(role => role.Length > 0).Distinct(StringComparer.Ordinal).ToArray());
|
||||
}
|
||||
|
||||
public async Task<UserAccount> UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedRoles = roles.Select(role => role.Trim().ToLowerInvariant()).Where(role => role.Length > 0).Distinct(StringComparer.Ordinal).ToArray();
|
||||
var userId = Guid.NewGuid();
|
||||
var createdAtUtc = DateTimeOffset.UtcNow;
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
insert into auth_users (id, email, password_hash, created_at_utc, roles)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
on conflict (email) do update
|
||||
set password_hash = excluded.password_hash,
|
||||
roles = excluded.roles
|
||||
returning id, email, password_hash, created_at_utc, roles
|
||||
""");
|
||||
command.Parameters.AddWithValue(userId);
|
||||
command.Parameters.AddWithValue(email);
|
||||
command.Parameters.AddWithValue(passwordHash);
|
||||
command.Parameters.AddWithValue(createdAtUtc);
|
||||
command.Parameters.AddWithValue(normalizedRoles);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
await reader.ReadAsync(cancellationToken);
|
||||
return ReadUser(reader);
|
||||
}
|
||||
|
||||
public async Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
insert into auth_refresh_tokens (id, user_id, token_hash, created_at_utc, expires_at_utc, revoked_at_utc)
|
||||
values ($1, $2, $3, $4, $5, null)
|
||||
""");
|
||||
command.Parameters.AddWithValue(Guid.NewGuid());
|
||||
command.Parameters.AddWithValue(userId);
|
||||
command.Parameters.AddWithValue(tokenHash);
|
||||
command.Parameters.AddWithValue(DateTimeOffset.UtcNow);
|
||||
command.Parameters.AddWithValue(expiresAtUtc);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenRecord?> FindRefreshTokenAsync(string tokenHash, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
select id, user_id, token_hash, created_at_utc, expires_at_utc, revoked_at_utc
|
||||
from auth_refresh_tokens
|
||||
where token_hash = $1
|
||||
""");
|
||||
command.Parameters.AddWithValue(tokenHash);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RefreshTokenRecord(
|
||||
reader.GetGuid(0),
|
||||
reader.GetGuid(1),
|
||||
reader.GetString(2),
|
||||
reader.GetFieldValue<DateTimeOffset>(3),
|
||||
reader.GetFieldValue<DateTimeOffset>(4),
|
||||
reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5));
|
||||
}
|
||||
|
||||
public async Task RevokeRefreshTokenAsync(Guid refreshTokenId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
update auth_refresh_tokens
|
||||
set revoked_at_utc = $2
|
||||
where id = $1 and revoked_at_utc is null
|
||||
""");
|
||||
command.Parameters.AddWithValue(refreshTokenId);
|
||||
command.Parameters.AddWithValue(DateTimeOffset.UtcNow);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RevokeAllRefreshTokensAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
update auth_refresh_tokens
|
||||
set revoked_at_utc = $2
|
||||
where user_id = $1 and revoked_at_utc is null
|
||||
""");
|
||||
command.Parameters.AddWithValue(userId);
|
||||
command.Parameters.AddWithValue(DateTimeOffset.UtcNow);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task StorePasswordResetTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
insert into auth_password_reset_tokens (id, user_id, token_hash, created_at_utc, expires_at_utc, consumed_at_utc)
|
||||
values ($1, $2, $3, $4, $5, null)
|
||||
""");
|
||||
command.Parameters.AddWithValue(Guid.NewGuid());
|
||||
command.Parameters.AddWithValue(userId);
|
||||
command.Parameters.AddWithValue(tokenHash);
|
||||
command.Parameters.AddWithValue(DateTimeOffset.UtcNow);
|
||||
command.Parameters.AddWithValue(expiresAtUtc);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PasswordResetTokenRecord?> FindPasswordResetTokenAsync(string tokenHash, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
select id, user_id, token_hash, created_at_utc, expires_at_utc, consumed_at_utc
|
||||
from auth_password_reset_tokens
|
||||
where token_hash = $1
|
||||
""");
|
||||
command.Parameters.AddWithValue(tokenHash);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PasswordResetTokenRecord(
|
||||
reader.GetGuid(0),
|
||||
reader.GetGuid(1),
|
||||
reader.GetString(2),
|
||||
reader.GetFieldValue<DateTimeOffset>(3),
|
||||
reader.GetFieldValue<DateTimeOffset>(4),
|
||||
reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5));
|
||||
}
|
||||
|
||||
public async Task ConsumePasswordResetTokenAsync(Guid passwordResetTokenId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
update auth_password_reset_tokens
|
||||
set consumed_at_utc = $2
|
||||
where id = $1 and consumed_at_utc is null
|
||||
""");
|
||||
command.Parameters.AddWithValue(passwordResetTokenId);
|
||||
command.Parameters.AddWithValue(DateTimeOffset.UtcNow);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdatePasswordHashAsync(Guid userId, string passwordHash, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
update auth_users
|
||||
set password_hash = $2
|
||||
where id = $1
|
||||
""");
|
||||
command.Parameters.AddWithValue(userId);
|
||||
command.Parameters.AddWithValue(passwordHash);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static UserAccount ReadUser(NpgsqlDataReader reader) => new(
|
||||
reader.GetGuid(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetFieldValue<DateTimeOffset>(3),
|
||||
reader.GetFieldValue<string[]>(4));
|
||||
}
|
||||
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,8 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
using SpaceGame.Api.Shared.Runtime;
|
||||
using SpaceGame.Api.Shared.Runtime;
|
||||
using SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
namespace SpaceGame.Api.Definitions;
|
||||
|
||||
@@ -40,19 +42,6 @@ public sealed class ItemProductionDefinition
|
||||
public List<ItemEffectDefinition> Effects { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class BalanceDefinition
|
||||
{
|
||||
public float SimulationSpeedMultiplier { get; set; } = 1f;
|
||||
public float YPlane { get; set; }
|
||||
public float ArrivalThreshold { get; set; }
|
||||
public float MiningRate { get; set; }
|
||||
public float MiningCycleSeconds { get; set; }
|
||||
public float TransferRate { get; set; }
|
||||
public float DockingDuration { get; set; }
|
||||
public float UndockingDuration { get; set; }
|
||||
public float UndockDistance { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StarDefinition
|
||||
{
|
||||
public string Kind { get; set; } = "main-sequence";
|
||||
@@ -87,6 +76,39 @@ public sealed class SolarSystemDefinition
|
||||
public required List<PlanetDefinition> Planets { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RaceDefinition
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
[JsonIgnore]
|
||||
public string Label => Name;
|
||||
}
|
||||
|
||||
public sealed class FactionDefinition
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public int Version { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public string? Race { get; set; }
|
||||
public List<FactionLicenseDefinition> Licenses { get; set; } = [];
|
||||
[JsonIgnore]
|
||||
public string Label => Name;
|
||||
[JsonIgnore]
|
||||
public string? RaceId => Race;
|
||||
}
|
||||
|
||||
public sealed class FactionLicenseDefinition
|
||||
{
|
||||
public required string Type { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public float Price { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AsteroidFieldDefinition
|
||||
{
|
||||
public int DecorationCount { get; set; }
|
||||
@@ -347,39 +369,202 @@ public sealed class PlanetDefinition
|
||||
public bool HasRing { get; set; }
|
||||
}
|
||||
|
||||
public enum ShipPurpose
|
||||
{
|
||||
[JsonStringEnumMemberName("auxiliary")]
|
||||
Auxiliary,
|
||||
[JsonStringEnumMemberName("mine")]
|
||||
Mine,
|
||||
[JsonStringEnumMemberName("build")]
|
||||
Build,
|
||||
[JsonStringEnumMemberName("fight")]
|
||||
Fight,
|
||||
[JsonStringEnumMemberName("trade")]
|
||||
Trade,
|
||||
[JsonStringEnumMemberName("salvage")]
|
||||
Salvage,
|
||||
[JsonStringEnumMemberName("dismantling")]
|
||||
Dismantling,
|
||||
}
|
||||
|
||||
public enum ShipType
|
||||
{
|
||||
[JsonStringEnumMemberName("resupplier")]
|
||||
Resupplier,
|
||||
[JsonStringEnumMemberName("miner")]
|
||||
Miner,
|
||||
[JsonStringEnumMemberName("carrier")]
|
||||
Carrier,
|
||||
[JsonStringEnumMemberName("fighter")]
|
||||
Fighter,
|
||||
[JsonStringEnumMemberName("heavyfighter")]
|
||||
HeavyFighter,
|
||||
[JsonStringEnumMemberName("destroyer")]
|
||||
Destroyer,
|
||||
[JsonStringEnumMemberName("largeminer")]
|
||||
LargeMiner,
|
||||
[JsonStringEnumMemberName("freighter")]
|
||||
Freighter,
|
||||
[JsonStringEnumMemberName("bomber")]
|
||||
Bomber,
|
||||
[JsonStringEnumMemberName("scavenger")]
|
||||
Scavenger,
|
||||
[JsonStringEnumMemberName("frigate")]
|
||||
Frigate,
|
||||
[JsonStringEnumMemberName("transporter")]
|
||||
Transporter,
|
||||
[JsonStringEnumMemberName("interceptor")]
|
||||
Interceptor,
|
||||
[JsonStringEnumMemberName("scout")]
|
||||
Scout,
|
||||
[JsonStringEnumMemberName("courier")]
|
||||
Courier,
|
||||
[JsonStringEnumMemberName("builder")]
|
||||
Builder,
|
||||
[JsonStringEnumMemberName("corvette")]
|
||||
Corvette,
|
||||
[JsonStringEnumMemberName("police")]
|
||||
Police,
|
||||
[JsonStringEnumMemberName("battleship")]
|
||||
Battleship,
|
||||
[JsonStringEnumMemberName("gunboat")]
|
||||
Gunboat,
|
||||
[JsonStringEnumMemberName("tug")]
|
||||
Tug,
|
||||
[JsonStringEnumMemberName("compactor")]
|
||||
Compactor,
|
||||
}
|
||||
|
||||
public sealed class ShipDefinition
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public required string Kind { get; set; }
|
||||
public required string Class { get; set; }
|
||||
public float Speed { get; set; }
|
||||
public float WarpSpeed { get; set; }
|
||||
public float FtlSpeed { get; set; }
|
||||
public float SpoolTime { get; set; }
|
||||
public float CargoCapacity { get; set; }
|
||||
public int Version { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string Size { get; set; } = string.Empty;
|
||||
public float ExplosionDamage { get; set; }
|
||||
public float Hull { get; set; }
|
||||
public Dictionary<string, float> Storage { get; set; } = new(StringComparer.Ordinal);
|
||||
public int People { get; set; }
|
||||
public ShipPurpose Purpose { get; set; }
|
||||
public string Thruster { get; set; } = string.Empty;
|
||||
public ShipType Type { get; set; }
|
||||
public float Mass { get; set; }
|
||||
public ShipInertiaDefinition? Inertia { get; set; }
|
||||
public ShipDragDefinition? Drag { get; set; }
|
||||
public List<ShipMountDefinition> Engines { get; set; } = [];
|
||||
public List<ShipMountDefinition> Shields { get; set; } = [];
|
||||
public List<ShipMountDefinition> Weapons { get; set; } = [];
|
||||
public List<ShipMountDefinition> Turrets { get; set; } = [];
|
||||
public List<ShipCargoDefinition> Cargo { get; set; } = [];
|
||||
public List<ModuleDockDefinition> Docks { get; set; } = [];
|
||||
public List<string> Owners { get; set; } = [];
|
||||
public ItemPriceDefinition? Price { get; set; }
|
||||
public List<ItemProductionDefinition> Production { get; set; } = [];
|
||||
[JsonIgnore]
|
||||
public StorageKind? CargoKind { get; set; }
|
||||
[JsonPropertyName("cargoKind")]
|
||||
public string? SerializedCargoKind
|
||||
{
|
||||
get => CargoKind?.ToDataValue();
|
||||
set => CargoKind = value.ToNullableStorageKind();
|
||||
}
|
||||
public required string Color { get; set; }
|
||||
public required string HullColor { get; set; }
|
||||
public float Size { get; set; }
|
||||
public float MaxHealth { get; set; }
|
||||
public List<string> Capabilities { get; set; } = [];
|
||||
public ConstructionDefinition? Construction { get; set; }
|
||||
public float Speed => InferLocalSpeed(Size);
|
||||
[JsonIgnore]
|
||||
public float WarpSpeed => InferWarpSpeed(Size);
|
||||
[JsonIgnore]
|
||||
public float FtlSpeed => InferFtlSpeed(Size);
|
||||
[JsonIgnore]
|
||||
public float SpoolTime => InferSpoolTime(Size);
|
||||
public float GetTotalCargoCapacity() => Cargo.Sum(entry => entry.Max);
|
||||
|
||||
public float GetCargoCapacity(StorageKind kind) =>
|
||||
Cargo
|
||||
.Where(entry => entry.Types.Any(type => type.ToNullableStorageKind() == kind))
|
||||
.Sum(entry => entry.Max);
|
||||
|
||||
public bool SupportsCargoKind(StorageKind kind) =>
|
||||
GetCargoCapacity(kind) > 0f;
|
||||
|
||||
private static float InferWarpSpeed(string size) =>
|
||||
size switch
|
||||
{
|
||||
"extrasmall" => 4.8f,
|
||||
"small" => 4.2f,
|
||||
"medium" => 3.4f,
|
||||
"large" => 2.4f,
|
||||
"extralarge" => 1.8f,
|
||||
_ => 3f,
|
||||
};
|
||||
|
||||
private static float InferLocalSpeed(string size) =>
|
||||
size switch
|
||||
{
|
||||
"extrasmall" => 420f,
|
||||
"small" => 320f,
|
||||
"medium" => 230f,
|
||||
"large" => 150f,
|
||||
"extralarge" => 110f,
|
||||
_ => 200f,
|
||||
};
|
||||
|
||||
private static float InferFtlSpeed(string size) =>
|
||||
size switch
|
||||
{
|
||||
"extrasmall" => 1f,
|
||||
"small" => 0.85f,
|
||||
"medium" => 0.7f,
|
||||
"large" => 0.55f,
|
||||
"extralarge" => 0.45f,
|
||||
_ => 0.6f,
|
||||
};
|
||||
|
||||
private static float InferSpoolTime(string size) =>
|
||||
size switch
|
||||
{
|
||||
"extrasmall" => 0.8f,
|
||||
"small" => 1f,
|
||||
"medium" => 1.4f,
|
||||
"large" => 2f,
|
||||
"extralarge" => 2.6f,
|
||||
_ => 1.5f,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class ShipInertiaDefinition
|
||||
{
|
||||
public float Pitch { get; set; }
|
||||
public float Yaw { get; set; }
|
||||
public float Roll { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ShipDragDefinition
|
||||
{
|
||||
public float Forward { get; set; }
|
||||
public float Reverse { get; set; }
|
||||
public float Horizontal { get; set; }
|
||||
public float Vertical { get; set; }
|
||||
public float Pitch { get; set; }
|
||||
public float Yaw { get; set; }
|
||||
public float Roll { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ShipMountDefinition
|
||||
{
|
||||
public string? Group { get; set; }
|
||||
public required string Size { get; set; }
|
||||
public bool Hittable { get; set; }
|
||||
public List<string> Types { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ShipCargoDefinition
|
||||
{
|
||||
public float Max { get; set; }
|
||||
public List<string> Types { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ScenarioDefinition
|
||||
{
|
||||
public required WorldGenerationOptions WorldGeneration { get; set; }
|
||||
// Temporary QA escape hatch so a scenario can pin an exact topology.
|
||||
// Do not treat this as the long-term world authoring model.
|
||||
public List<SolarSystemDefinition>? Systems { get; set; }
|
||||
public required List<InitialStationDefinition> InitialStations { get; set; }
|
||||
public required List<ShipFormationDefinition> ShipFormations { get; set; }
|
||||
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }
|
||||
public required MiningDefaultsDefinition MiningDefaults { get; set; }
|
||||
}
|
||||
|
||||
public sealed class InitialStationDefinition
|
||||
@@ -410,9 +595,3 @@ public sealed class PatrolRouteDefinition
|
||||
public required string SystemId { get; set; }
|
||||
public required List<float[]> Points { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MiningDefaultsDefinition
|
||||
{
|
||||
public required string NodeSystemId { get; set; }
|
||||
public required string RefinerySystemId { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using SpaceGame.Api.Industry.Planning;
|
||||
using SpaceGame.Api.Stations.Simulation;
|
||||
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
|
||||
namespace SpaceGame.Api.Factions.AI;
|
||||
@@ -13,8 +14,12 @@ internal sealed class CommanderPlanningService
|
||||
private const int MaxDecisionLogEntries = 40;
|
||||
private const int MaxOutcomeEntries = 32;
|
||||
private const int MaxAiOrdersPerShip = 2;
|
||||
private const string MilitaryShipCategory = "military";
|
||||
private const string MiningShipCategory = "mining";
|
||||
private const string TransportShipCategory = "transport";
|
||||
private const string ConstructionShipCategory = "construction";
|
||||
|
||||
internal void UpdateCommanders(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
internal void UpdateCommanders(SimulationWorld world, IPlayerStateStore playerStateStore, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
EnsureHierarchy(world);
|
||||
|
||||
@@ -33,7 +38,7 @@ internal sealed class CommanderPlanningService
|
||||
|
||||
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Faction).ToList())
|
||||
{
|
||||
if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId))
|
||||
if (PlayerFactionService.IsPlayerFaction(playerStateStore, commander.FactionId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -48,7 +53,7 @@ internal sealed class CommanderPlanningService
|
||||
|
||||
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Fleet).ToList())
|
||||
{
|
||||
if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId))
|
||||
if (PlayerFactionService.IsPlayerFaction(playerStateStore, commander.FactionId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -63,7 +68,7 @@ internal sealed class CommanderPlanningService
|
||||
|
||||
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Station).ToList())
|
||||
{
|
||||
if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId))
|
||||
if (PlayerFactionService.IsPlayerFaction(playerStateStore, commander.FactionId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -78,7 +83,7 @@ internal sealed class CommanderPlanningService
|
||||
|
||||
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Ship).ToList())
|
||||
{
|
||||
if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId))
|
||||
if (PlayerFactionService.IsPlayerFaction(playerStateStore, commander.FactionId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -268,7 +273,7 @@ internal sealed class CommanderPlanningService
|
||||
CommanderRuntime factionCommander,
|
||||
IReadOnlyDictionary<string, CommanderRuntime> stationCommanders)
|
||||
{
|
||||
if (string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal))
|
||||
if (IsMilitaryShip(ship.Definition))
|
||||
{
|
||||
return factionCommander;
|
||||
}
|
||||
@@ -456,8 +461,8 @@ internal sealed class CommanderPlanningService
|
||||
ship.Id,
|
||||
nextAssignment is null ? "assignment-cleared" : "assignment-updated",
|
||||
nextAssignment is null
|
||||
? $"{ship.Definition.Label} returned to default behavior."
|
||||
: $"{ship.Definition.Label} assigned to {nextAssignment.Kind}.",
|
||||
? $"{ship.Definition.Name} returned to default behavior."
|
||||
: $"{ship.Definition.Name} assigned to {nextAssignment.Kind}.",
|
||||
DateTimeOffset.UtcNow));
|
||||
}
|
||||
}
|
||||
@@ -586,10 +591,10 @@ internal sealed class CommanderPlanningService
|
||||
var frontCount = Math.Max(1,
|
||||
threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind is "controlled-system" or "contested-system")
|
||||
+ (expansionProject is null ? 0 : 1));
|
||||
var militaryShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "military");
|
||||
var minerShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && HasShipCapabilities(ship.Definition, "mining"));
|
||||
var transportShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "transport");
|
||||
var constructorShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "construction");
|
||||
var militaryShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsMilitaryShip(ship.Definition));
|
||||
var minerShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsMiningShip(ship.Definition));
|
||||
var transportShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsTransportShip(ship.Definition));
|
||||
var constructorShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsConstructionShip(ship.Definition));
|
||||
var hasShipyard = world.Stations.Any(station =>
|
||||
string.Equals(station.FactionId, faction.Id, StringComparison.Ordinal) &&
|
||||
station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal));
|
||||
@@ -1092,14 +1097,14 @@ internal sealed class CommanderPlanningService
|
||||
{
|
||||
theaters.Add(new FactionTheaterRuntime
|
||||
{
|
||||
Id = $"theater-expansion-{expansionProject.SystemId}-{expansionProject.CelestialId}",
|
||||
Id = $"theater-expansion-{expansionProject.SystemId}-{expansionProject.AnchorId}",
|
||||
Kind = "expansion-front",
|
||||
SystemId = expansionProject.SystemId,
|
||||
Status = "active",
|
||||
Priority = 65f + (economicAssessment.HasShipyard ? 0f : 15f),
|
||||
SupplyRisk = ComputeSystemRisk(world, faction, expansionProject.SystemId),
|
||||
FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, expansionProject.SystemId),
|
||||
AnchorEntityId = expansionProject.SiteId ?? expansionProject.CelestialId,
|
||||
AnchorEntityId = expansionProject.SiteId ?? expansionProject.AnchorId,
|
||||
AnchorPosition = ResolveExpansionAnchor(world, expansionProject),
|
||||
UpdatedAtUtc = nowUtc,
|
||||
});
|
||||
@@ -1267,7 +1272,7 @@ internal sealed class CommanderPlanningService
|
||||
],
|
||||
"expansion" =>
|
||||
[
|
||||
new FactionPlanStepRuntime { Id = $"{campaign.Id}-claim", Kind = "secure-claim", Status = "active", Summary = $"Secure {expansionProject?.CelestialId ?? campaign.TargetEntityId} for construction." },
|
||||
new FactionPlanStepRuntime { Id = $"{campaign.Id}-claim", Kind = "secure-claim", Status = "active", Summary = $"Secure {expansionProject?.AnchorId ?? campaign.TargetEntityId} for construction." },
|
||||
new FactionPlanStepRuntime { Id = $"{campaign.Id}-supply", Kind = "supply-site", Status = "planned", Summary = "Move construction materials to the site." },
|
||||
new FactionPlanStepRuntime { Id = $"{campaign.Id}-guard", Kind = "guard-site", Status = "planned", Summary = "Defend the expansion site until operational." },
|
||||
],
|
||||
@@ -1365,9 +1370,9 @@ internal sealed class CommanderPlanningService
|
||||
Id = $"{campaign.Id}-protect-station-{station.Id}",
|
||||
CampaignId = campaign.Id,
|
||||
TheaterId = theater?.Id,
|
||||
Kind = "protect-station",
|
||||
Kind = ProtectStation,
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "protect-station",
|
||||
BehaviorKind = ProtectStation,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 8f,
|
||||
HomeSystemId = station.SystemId,
|
||||
@@ -1389,7 +1394,7 @@ internal sealed class CommanderPlanningService
|
||||
TheaterId = theater?.Id,
|
||||
Kind = "patrol-front",
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "patrol",
|
||||
BehaviorKind = Patrol,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 2f,
|
||||
HomeSystemId = campaign.TargetSystemId,
|
||||
@@ -1414,7 +1419,7 @@ internal sealed class CommanderPlanningService
|
||||
TheaterId = theater?.Id,
|
||||
Kind = "police-front",
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "police",
|
||||
BehaviorKind = Police,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 1f,
|
||||
HomeSystemId = campaign.TargetSystemId,
|
||||
@@ -1454,7 +1459,7 @@ internal sealed class CommanderPlanningService
|
||||
TheaterId = theater?.Id,
|
||||
Kind = "strike-station",
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "attack-target",
|
||||
BehaviorKind = AttackTarget,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 10f,
|
||||
TargetSystemId = enemyStation.SystemId,
|
||||
@@ -1478,7 +1483,7 @@ internal sealed class CommanderPlanningService
|
||||
TheaterId = theater?.Id,
|
||||
Kind = "hold-front",
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "protect-position",
|
||||
BehaviorKind = ProtectPosition,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 3f,
|
||||
TargetSystemId = campaign.TargetSystemId,
|
||||
@@ -1500,7 +1505,7 @@ internal sealed class CommanderPlanningService
|
||||
TheaterId = theater?.Id,
|
||||
Kind = "fleet-sustainment",
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "supply-fleet",
|
||||
BehaviorKind = SupplyFleet,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 1.5f,
|
||||
HomeSystemId = campaign.TargetSystemId,
|
||||
@@ -1539,7 +1544,7 @@ internal sealed class CommanderPlanningService
|
||||
TheaterId = theater?.Id,
|
||||
Kind = "construct-site",
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "construct-station",
|
||||
BehaviorKind = ConstructStation,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 8f,
|
||||
HomeSystemId = expansionProject.SystemId,
|
||||
@@ -1564,7 +1569,7 @@ internal sealed class CommanderPlanningService
|
||||
TheaterId = theater?.Id,
|
||||
Kind = "supply-site",
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "find-build-tasks",
|
||||
BehaviorKind = FindBuildTasks,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 4f,
|
||||
HomeSystemId = expansionProject.SystemId,
|
||||
@@ -1589,7 +1594,7 @@ internal sealed class CommanderPlanningService
|
||||
TheaterId = theater?.Id,
|
||||
Kind = "guard-site",
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "protect-position",
|
||||
BehaviorKind = ProtectPosition,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 2f,
|
||||
TargetSystemId = expansionProject.SystemId,
|
||||
@@ -1614,7 +1619,7 @@ internal sealed class CommanderPlanningService
|
||||
TheaterId = theater?.Id,
|
||||
Kind = "mine-expansion-input",
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "expert-auto-mine",
|
||||
BehaviorKind = ExpertAutoMine,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 1f,
|
||||
HomeSystemId = expansionProject.SystemId,
|
||||
@@ -1655,7 +1660,7 @@ internal sealed class CommanderPlanningService
|
||||
TheaterId = theater?.Id,
|
||||
Kind = "trade-shortage",
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "fill-shortages",
|
||||
BehaviorKind = FillShortages,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 5f,
|
||||
HomeSystemId = anchorStation?.SystemId,
|
||||
@@ -1680,7 +1685,7 @@ internal sealed class CommanderPlanningService
|
||||
TheaterId = theater?.Id,
|
||||
Kind = "mine-shortage",
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "expert-auto-mine",
|
||||
BehaviorKind = ExpertAutoMine,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 3f,
|
||||
HomeSystemId = anchorStation?.SystemId,
|
||||
@@ -1703,7 +1708,7 @@ internal sealed class CommanderPlanningService
|
||||
TheaterId = theater?.Id,
|
||||
Kind = "revisit-stations",
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "revisit-known-stations",
|
||||
BehaviorKind = RevisitKnownStations,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 0.5f,
|
||||
HomeSystemId = anchorStation?.SystemId,
|
||||
@@ -1743,7 +1748,7 @@ internal sealed class CommanderPlanningService
|
||||
CampaignId = campaign.Id,
|
||||
Kind = "feed-shipyard",
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "fill-shortages",
|
||||
BehaviorKind = FillShortages,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 4f,
|
||||
HomeSystemId = shipyard.SystemId,
|
||||
@@ -1768,7 +1773,7 @@ internal sealed class CommanderPlanningService
|
||||
CampaignId = campaign.Id,
|
||||
Kind = "mine-bottleneck",
|
||||
DelegationKind = "ship",
|
||||
BehaviorKind = "expert-auto-mine",
|
||||
BehaviorKind = ExpertAutoMine,
|
||||
Status = "active",
|
||||
Priority = campaign.Priority + 2f,
|
||||
HomeSystemId = shipyard.SystemId,
|
||||
@@ -1838,7 +1843,9 @@ internal sealed class CommanderPlanningService
|
||||
var reservedCommanderIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var availableMilitaryCommanders = commanders.Count(commander =>
|
||||
commander.Kind == CommanderKind.Ship &&
|
||||
world.Ships.FirstOrDefault(ship => ship.Id == commander.ControlledEntityId) is { Definition.Kind: "military", Health: > 0f });
|
||||
world.Ships.FirstOrDefault(ship => ship.Id == commander.ControlledEntityId) is { } commanderShip
|
||||
&& commanderShip.Health > 0f
|
||||
&& IsMilitaryShip(commanderShip.Definition));
|
||||
var committedMilitaryCommanders = 0;
|
||||
|
||||
foreach (var objective in objectives
|
||||
@@ -1921,11 +1928,11 @@ internal sealed class CommanderPlanningService
|
||||
|
||||
return objective.BehaviorKind switch
|
||||
{
|
||||
"construct-station" => string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal),
|
||||
"find-build-tasks" => string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal),
|
||||
"fill-shortages" or "advanced-auto-trade" or "revisit-known-stations" or "supply-fleet" => string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal),
|
||||
"local-auto-mine" or "advanced-auto-mine" or "expert-auto-mine" => HasShipCapabilities(ship.Definition, "mining"),
|
||||
"patrol" or "police" or "protect-position" or "protect-ship" or "protect-station" or "attack-target" => string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal),
|
||||
ConstructStation => IsConstructionShip(ship.Definition),
|
||||
FindBuildTasks => IsTransportShip(ship.Definition),
|
||||
FillShortages or AdvancedAutoTrade or RevisitKnownStations or SupplyFleet => IsTransportShip(ship.Definition),
|
||||
LocalAutoMine or AdvancedAutoMine or ExpertAutoMine => IsMiningShip(ship.Definition),
|
||||
Patrol or Police or ProtectPosition or ProtectShip or ProtectStation or AttackTarget => IsMilitaryShip(ship.Definition),
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
@@ -1992,7 +1999,7 @@ internal sealed class CommanderPlanningService
|
||||
Kind = "military-fleet",
|
||||
Status = economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount ? "stable" : "active",
|
||||
Priority = 80f + (threatAssessment.ThreatSignals.Count * 4f),
|
||||
ShipKind = "military",
|
||||
ShipKind = MilitaryShipCategory,
|
||||
TargetCount = economicAssessment.TargetMilitaryShipCount,
|
||||
CurrentCount = economicAssessment.MilitaryShipCount,
|
||||
Notes = "Maintain enough military hulls for all active fronts.",
|
||||
@@ -2004,7 +2011,7 @@ internal sealed class CommanderPlanningService
|
||||
Kind = "mining-fleet",
|
||||
Status = economicAssessment.MinerShipCount >= economicAssessment.TargetMinerShipCount ? "stable" : "active",
|
||||
Priority = 60f,
|
||||
ShipKind = "mining",
|
||||
ShipKind = MiningShipCategory,
|
||||
TargetCount = economicAssessment.TargetMinerShipCount,
|
||||
CurrentCount = economicAssessment.MinerShipCount,
|
||||
Notes = "Maintain raw resource extraction capacity.",
|
||||
@@ -2016,7 +2023,7 @@ internal sealed class CommanderPlanningService
|
||||
Kind = "logistics-fleet",
|
||||
Status = economicAssessment.TransportShipCount >= economicAssessment.TargetTransportShipCount ? "stable" : "active",
|
||||
Priority = 62f,
|
||||
ShipKind = "transport",
|
||||
ShipKind = TransportShipCategory,
|
||||
TargetCount = economicAssessment.TargetTransportShipCount,
|
||||
CurrentCount = economicAssessment.TransportShipCount,
|
||||
Notes = "Maintain logistics throughput across stations and fronts.",
|
||||
@@ -2028,7 +2035,7 @@ internal sealed class CommanderPlanningService
|
||||
Kind = "construction-fleet",
|
||||
Status = economicAssessment.ConstructorShipCount >= economicAssessment.TargetConstructorShipCount ? "stable" : "active",
|
||||
Priority = expansionProject is null ? 35f : 68f,
|
||||
ShipKind = "construction",
|
||||
ShipKind = ConstructionShipCategory,
|
||||
TargetCount = economicAssessment.TargetConstructorShipCount,
|
||||
CurrentCount = economicAssessment.ConstructorShipCount,
|
||||
Notes = "Maintain construction capacity for frontier growth.",
|
||||
@@ -2347,10 +2354,10 @@ internal sealed class CommanderPlanningService
|
||||
Kind = "fleet-command",
|
||||
BehaviorKind = campaign.Kind switch
|
||||
{
|
||||
"offense" => "attack-target",
|
||||
"defense" => "protect-position",
|
||||
"expansion" => "protect-position",
|
||||
_ => "patrol",
|
||||
"offense" => AttackTarget,
|
||||
"defense" => ProtectPosition,
|
||||
"expansion" => ProtectPosition,
|
||||
_ => Patrol,
|
||||
},
|
||||
Status = campaign.Status,
|
||||
Priority = campaign.Priority,
|
||||
@@ -2380,7 +2387,7 @@ internal sealed class CommanderPlanningService
|
||||
{
|
||||
ObjectiveId = $"objective-station-{station.Id}-ship-production",
|
||||
Kind = "ship-production-focus",
|
||||
BehaviorKind = "fill-shortages",
|
||||
BehaviorKind = FillShortages,
|
||||
Status = "active",
|
||||
Priority = 55f,
|
||||
HomeSystemId = station.SystemId,
|
||||
@@ -2399,7 +2406,7 @@ internal sealed class CommanderPlanningService
|
||||
{
|
||||
ObjectiveId = $"objective-station-{station.Id}-commodity-focus-{bottleneckItem}",
|
||||
Kind = "commodity-focus",
|
||||
BehaviorKind = "fill-shortages",
|
||||
BehaviorKind = FillShortages,
|
||||
Status = "active",
|
||||
Priority = 45f,
|
||||
HomeSystemId = station.SystemId,
|
||||
@@ -2418,7 +2425,7 @@ internal sealed class CommanderPlanningService
|
||||
{
|
||||
ObjectiveId = $"objective-station-{station.Id}-expansion-support",
|
||||
Kind = "expansion-support",
|
||||
BehaviorKind = "find-build-tasks",
|
||||
BehaviorKind = FindBuildTasks,
|
||||
Status = "active",
|
||||
Priority = 40f,
|
||||
HomeSystemId = station.SystemId,
|
||||
@@ -2435,7 +2442,7 @@ internal sealed class CommanderPlanningService
|
||||
{
|
||||
ObjectiveId = $"objective-station-{station.Id}-oversight",
|
||||
Kind = "station-oversight",
|
||||
BehaviorKind = "fill-shortages",
|
||||
BehaviorKind = FillShortages,
|
||||
Status = "active",
|
||||
Priority = 30f,
|
||||
HomeSystemId = station.SystemId,
|
||||
@@ -2460,7 +2467,7 @@ internal sealed class CommanderPlanningService
|
||||
faction.StrategicState.Objectives.Any(objective =>
|
||||
objective.CampaignId == campaign.Id &&
|
||||
objective.CommanderId is not null &&
|
||||
(IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, "supply-fleet", StringComparison.Ordinal))))
|
||||
(IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, SupplyFleet, StringComparison.Ordinal))))
|
||||
.Select(campaign => campaign.Id)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
@@ -2510,10 +2517,10 @@ internal sealed class CommanderPlanningService
|
||||
Kind = "fleet-command",
|
||||
BehaviorKind = campaign.Kind switch
|
||||
{
|
||||
"offense" => "attack-target",
|
||||
"defense" => "protect-position",
|
||||
"expansion" => "protect-position",
|
||||
_ => "patrol",
|
||||
"offense" => AttackTarget,
|
||||
"defense" => ProtectPosition,
|
||||
"expansion" => ProtectPosition,
|
||||
_ => Patrol,
|
||||
},
|
||||
Status = campaign.Status,
|
||||
Priority = campaign.Priority + 1f,
|
||||
@@ -2581,7 +2588,7 @@ internal sealed class CommanderPlanningService
|
||||
{
|
||||
if (objective?.CampaignId is not null
|
||||
&& fleetCommanders.TryGetValue(objective.CampaignId, out var fleetCommander)
|
||||
&& (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, "supply-fleet", StringComparison.Ordinal)))
|
||||
&& (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, SupplyFleet, StringComparison.Ordinal)))
|
||||
{
|
||||
return fleetCommander.Id;
|
||||
}
|
||||
@@ -2598,25 +2605,39 @@ internal sealed class CommanderPlanningService
|
||||
private static DefaultBehaviorRuntime BuildFallbackBehavior(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var homeStation = ResolveFallbackHomeStation(world, ship);
|
||||
if (HasShipCapabilities(ship.Definition, "mining"))
|
||||
if (IsMiningShip(ship.Definition))
|
||||
{
|
||||
if (homeStation is null)
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = LocalAutoMine,
|
||||
HomeSystemId = ship.SystemId,
|
||||
HomeStationId = null,
|
||||
AreaSystemId = ship.SystemId,
|
||||
ItemId = "ore",
|
||||
Radius = 24f,
|
||||
MaxSystemRange = 0,
|
||||
};
|
||||
}
|
||||
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = ship.Definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine",
|
||||
Kind = ship.Definition.GetTotalCargoCapacity() >= 120f ? ExpertAutoMine : AdvancedAutoMine,
|
||||
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||
HomeStationId = homeStation?.Id,
|
||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||
PreferredItemId = null,
|
||||
ItemId = null,
|
||||
Radius = 24f,
|
||||
MaxSystemRange = ship.Definition.CargoCapacity >= 120f ? 3 : 1,
|
||||
MaxSystemRange = ship.Definition.GetTotalCargoCapacity() >= 120f ? 3 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal))
|
||||
if (IsTransportShip(ship.Definition))
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "advanced-auto-trade",
|
||||
Kind = AdvancedAutoTrade,
|
||||
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||
HomeStationId = homeStation?.Id,
|
||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||
@@ -2625,11 +2646,11 @@ internal sealed class CommanderPlanningService
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal))
|
||||
if (IsConstructionShip(ship.Definition))
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "construct-station",
|
||||
Kind = ConstructStation,
|
||||
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||
HomeStationId = homeStation?.Id,
|
||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||
@@ -2638,13 +2659,13 @@ internal sealed class CommanderPlanningService
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal))
|
||||
if (IsMilitaryShip(ship.Definition))
|
||||
{
|
||||
var anchor = homeStation?.Position ?? ship.Position;
|
||||
var patrolRadius = (homeStation?.Radius ?? 30f) + 90f;
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "patrol",
|
||||
Kind = Patrol,
|
||||
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||
HomeStationId = homeStation?.Id,
|
||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||
@@ -2660,7 +2681,7 @@ internal sealed class CommanderPlanningService
|
||||
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "idle",
|
||||
Kind = Idle,
|
||||
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||
HomeStationId = homeStation?.Id,
|
||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||
@@ -2684,15 +2705,15 @@ internal sealed class CommanderPlanningService
|
||||
var areaSystemId = objective.TargetSystemId ?? objective.HomeSystemId ?? fallback.AreaSystemId ?? ship.SystemId;
|
||||
var radius = objective.BehaviorKind switch
|
||||
{
|
||||
"protect-position" or "protect-station" or "patrol" or "police" => MathF.Max(80f, fallback.Radius),
|
||||
"follow-ship" or "protect-ship" => MathF.Max(18f, fallback.Radius * 0.6f),
|
||||
"fill-shortages" or "advanced-auto-trade" or "find-build-tasks" => MathF.Max(20f, fallback.Radius),
|
||||
ProtectPosition or ProtectStation or Patrol or Police => MathF.Max(80f, fallback.Radius),
|
||||
FollowShip or ProtectShip => MathF.Max(18f, fallback.Radius * 0.6f),
|
||||
FillShortages or AdvancedAutoTrade or FindBuildTasks => MathF.Max(20f, fallback.Radius),
|
||||
_ => fallback.Radius,
|
||||
};
|
||||
var maxRange = objective.BehaviorKind switch
|
||||
{
|
||||
"attack-target" or "protect-position" or "protect-station" or "protect-ship" or "patrol" or "police" => Math.Max(1, fallback.MaxSystemRange),
|
||||
"fill-shortages" or "advanced-auto-trade" or "find-build-tasks" or "supply-fleet" => Math.Max(2, fallback.MaxSystemRange),
|
||||
AttackTarget or ProtectPosition or ProtectStation or ProtectShip or Patrol or Police => Math.Max(1, fallback.MaxSystemRange),
|
||||
FillShortages or AdvancedAutoTrade or FindBuildTasks or SupplyFleet => Math.Max(2, fallback.MaxSystemRange),
|
||||
_ => fallback.MaxSystemRange,
|
||||
};
|
||||
|
||||
@@ -2703,16 +2724,16 @@ internal sealed class CommanderPlanningService
|
||||
HomeStationId = objective.HomeStationId ?? fallback.HomeStationId,
|
||||
AreaSystemId = areaSystemId,
|
||||
TargetEntityId = objective.TargetEntityId,
|
||||
PreferredItemId = objective.ItemId ?? fallback.PreferredItemId,
|
||||
PreferredNodeId = fallback.PreferredNodeId,
|
||||
ItemId = objective.ItemId ?? fallback.ItemId,
|
||||
PreferredAnchorId = fallback.PreferredAnchorId,
|
||||
PreferredConstructionSiteId = objective.Kind is "construct-site" or "supply-site" ? objective.TargetEntityId : fallback.PreferredConstructionSiteId,
|
||||
PreferredModuleId = fallback.PreferredModuleId,
|
||||
TargetPosition = objective.TargetPosition ?? fallback.TargetPosition,
|
||||
WaitSeconds = objective.BehaviorKind == "supply-fleet" ? 4f : fallback.WaitSeconds,
|
||||
WaitSeconds = objective.BehaviorKind == SupplyFleet ? 4f : fallback.WaitSeconds,
|
||||
Radius = radius,
|
||||
MaxSystemRange = maxRange,
|
||||
KnownStationsOnly = objective.BehaviorKind == "revisit-known-stations",
|
||||
PatrolPoints = objective.BehaviorKind == "patrol"
|
||||
KnownStationsOnly = objective.BehaviorKind == RevisitKnownStations,
|
||||
PatrolPoints = objective.BehaviorKind == Patrol
|
||||
? BuildPatrolPoints(objective.TargetPosition ?? fallback.TargetPosition ?? ship.Position, radius)
|
||||
: [],
|
||||
PatrolIndex = ship.DefaultBehavior.PatrolIndex,
|
||||
@@ -2728,8 +2749,8 @@ internal sealed class CommanderPlanningService
|
||||
target.HomeStationId = source.HomeStationId;
|
||||
target.AreaSystemId = source.AreaSystemId;
|
||||
target.TargetEntityId = source.TargetEntityId;
|
||||
target.PreferredItemId = source.PreferredItemId;
|
||||
target.PreferredNodeId = source.PreferredNodeId;
|
||||
target.ItemId = source.ItemId;
|
||||
target.PreferredAnchorId = source.PreferredAnchorId;
|
||||
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
|
||||
target.PreferredModuleId = source.PreferredModuleId;
|
||||
target.TargetPosition = source.TargetPosition;
|
||||
@@ -2749,8 +2770,8 @@ internal sealed class CommanderPlanningService
|
||||
&& string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.PreferredItemId, right.PreferredItemId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.PreferredAnchorId, right.PreferredAnchorId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
|
||||
&& Nullable.Equals(left.TargetPosition, right.TargetPosition)
|
||||
@@ -2771,7 +2792,7 @@ internal sealed class CommanderPlanningService
|
||||
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
|
||||
&& left.WaitSeconds.Equals(right.WaitSeconds)
|
||||
@@ -2805,13 +2826,15 @@ internal sealed class CommanderPlanningService
|
||||
{
|
||||
Id = $"ai-order-{objective.Id}",
|
||||
Kind = objective.StagingOrderKind,
|
||||
SourceKind = ShipOrderSourceKind.Commander,
|
||||
SourceId = objective.Id,
|
||||
Priority = 90 + objective.ReinforcementLevel,
|
||||
InterruptCurrentPlan = true,
|
||||
Label = $"{objective.Kind} staging",
|
||||
TargetEntityId = objective.TargetEntityId,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = targetPosition,
|
||||
DestinationStationId = objective.BehaviorKind == "dock-and-wait" ? objective.TargetEntityId : null,
|
||||
DestinationStationId = objective.BehaviorKind == DockAtStation ? objective.TargetEntityId : null,
|
||||
ItemId = objective.ItemId,
|
||||
WaitSeconds = 0f,
|
||||
Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f),
|
||||
@@ -2840,9 +2863,10 @@ internal sealed class CommanderPlanningService
|
||||
}
|
||||
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId);
|
||||
if (site?.CelestialId is { } celestialId)
|
||||
if (site is not null)
|
||||
{
|
||||
return world.Celestials.FirstOrDefault(celestial => celestial.Id == celestialId)?.Position;
|
||||
return world.Anchors.FirstOrDefault(anchor => anchor.Id == site.AnchorId)?.Position
|
||||
?? Vector3.Zero;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -2850,13 +2874,13 @@ internal sealed class CommanderPlanningService
|
||||
|
||||
private static bool ReconcileAiOrders(ShipRuntime ship, ShipOrderRuntime? desiredOrder)
|
||||
{
|
||||
var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal) && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))) > 0;
|
||||
var changed = ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal) && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))) > 0;
|
||||
if (desiredOrder is null)
|
||||
{
|
||||
return changed;
|
||||
}
|
||||
|
||||
var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal));
|
||||
var existing = ship.OrderQueue.FindById(desiredOrder.Id);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (ShipOrdersEqual(existing, desiredOrder))
|
||||
@@ -2864,18 +2888,18 @@ internal sealed class CommanderPlanningService
|
||||
return changed;
|
||||
}
|
||||
|
||||
ship.OrderQueue.Remove(existing);
|
||||
changed = true;
|
||||
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ship.OrderQueue.Count >= MaxAiOrdersPerShip)
|
||||
{
|
||||
changed |= ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0;
|
||||
changed |= ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0;
|
||||
}
|
||||
|
||||
if (ship.OrderQueue.Count < 8)
|
||||
if (ship.OrderQueue.Count < ShipOrderQueue.MaxOrders)
|
||||
{
|
||||
ship.OrderQueue.Add(desiredOrder);
|
||||
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -2885,6 +2909,8 @@ internal sealed class CommanderPlanningService
|
||||
private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) =>
|
||||
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
|
||||
&& string.Equals(left.Kind, right.Kind, StringComparison.Ordinal)
|
||||
&& left.SourceKind == right.SourceKind
|
||||
&& string.Equals(left.SourceId, right.SourceId, StringComparison.Ordinal)
|
||||
&& left.Priority == right.Priority
|
||||
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
|
||||
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
|
||||
@@ -2894,7 +2920,7 @@ internal sealed class CommanderPlanningService
|
||||
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
|
||||
&& left.WaitSeconds.Equals(right.WaitSeconds)
|
||||
@@ -2920,7 +2946,7 @@ internal sealed class CommanderPlanningService
|
||||
}
|
||||
|
||||
private static bool IsCombatObjective(FactionOperationalObjectiveRuntime objective) =>
|
||||
objective.BehaviorKind is "attack-target" or "protect-position" or "protect-ship" or "protect-station" or "patrol" or "police";
|
||||
objective.BehaviorKind is AttackTarget or ProtectPosition or ProtectShip or ProtectStation or Patrol or Police;
|
||||
|
||||
private static float EstimateFriendlyAssetValue(SimulationWorld world, string factionId, string systemId)
|
||||
{
|
||||
@@ -3357,7 +3383,7 @@ internal sealed class CommanderPlanningService
|
||||
{
|
||||
"defense-front" => $"Defend {theater.SystemId} from hostile pressure.",
|
||||
"offense-front" => $"Project force into {theater.SystemId}.",
|
||||
"expansion-front" => $"Expand into {expansionProject?.CelestialId ?? theater.SystemId}.",
|
||||
"expansion-front" => $"Expand into {expansionProject?.AnchorId ?? theater.SystemId}.",
|
||||
"economic-front" => $"Stabilize commodity shortages around {theater.AnchorEntityId ?? theater.SystemId}.",
|
||||
_ => theater.Kind,
|
||||
};
|
||||
@@ -3399,13 +3425,13 @@ internal sealed class CommanderPlanningService
|
||||
private static Vector3 ResolveExpansionAnchor(SimulationWorld world, IndustryExpansionProject project)
|
||||
{
|
||||
if (project.SiteId is not null
|
||||
&& world.ConstructionSites.FirstOrDefault(site => site.Id == project.SiteId) is { } site
|
||||
&& world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId) is { } siteCelestial)
|
||||
&& world.ConstructionSites.FirstOrDefault(site => site.Id == project.SiteId) is { } site)
|
||||
{
|
||||
return siteCelestial.Position;
|
||||
return world.Anchors.FirstOrDefault(candidate => candidate.Id == site.AnchorId)?.Position
|
||||
?? Vector3.Zero;
|
||||
}
|
||||
|
||||
return world.Celestials.FirstOrDefault(candidate => candidate.Id == project.CelestialId)?.Position
|
||||
return world.Anchors.FirstOrDefault(candidate => candidate.Id == project.AnchorId)?.Position
|
||||
?? ResolveSystemAnchor(world, project.SystemId);
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ public sealed record TerritoryClaimSnapshot(
|
||||
string? SourceClaimId,
|
||||
string FactionId,
|
||||
string SystemId,
|
||||
string CelestialId,
|
||||
string AnchorId,
|
||||
string Status,
|
||||
string ClaimKind,
|
||||
float ClaimStrength,
|
||||
|
||||
@@ -126,7 +126,7 @@ public sealed class TerritoryClaimRuntime
|
||||
public string? SourceClaimId { get; set; }
|
||||
public required string FactionId { get; set; }
|
||||
public required string SystemId { get; set; }
|
||||
public required string CelestialId { get; set; }
|
||||
public required string AnchorId { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string ClaimKind { get; set; } = "infrastructure";
|
||||
public float ClaimStrength { get; set; }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Globalization;
|
||||
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
|
||||
namespace SpaceGame.Api.Geopolitics.Simulation;
|
||||
|
||||
internal sealed class GeopoliticalSimulationService
|
||||
@@ -159,7 +161,7 @@ internal sealed class GeopoliticalSimulationService
|
||||
SourceClaimId = claim.Id,
|
||||
FactionId = claim.FactionId,
|
||||
SystemId = claim.SystemId,
|
||||
CelestialId = claim.CelestialId,
|
||||
AnchorId = claim.AnchorId,
|
||||
Status = claim.State,
|
||||
ClaimKind = "infrastructure",
|
||||
ClaimStrength = claim.State == ClaimStateKinds.Active ? 1f : 0.65f,
|
||||
@@ -198,14 +200,24 @@ internal sealed class GeopoliticalSimulationService
|
||||
var stationStrength = (stationsByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 50f;
|
||||
var siteStrength = (sitesByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 18f;
|
||||
var shipStrength = shipsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(ship =>
|
||||
ship.Definition.Kind switch
|
||||
{
|
||||
"military" => 9f,
|
||||
"construction" => 4f,
|
||||
"transport" => 3f,
|
||||
_ when ship.Definition.Kind == "mining" || ship.Definition.Kind == "miner" => 3f,
|
||||
_ => 2f,
|
||||
}) ?? 0f;
|
||||
{
|
||||
if (IsMilitaryShip(ship.Definition))
|
||||
{
|
||||
return 9f;
|
||||
}
|
||||
|
||||
if (IsConstructionShip(ship.Definition))
|
||||
{
|
||||
return 4f;
|
||||
}
|
||||
|
||||
if (IsTransportShip(ship.Definition) || IsMiningShip(ship.Definition))
|
||||
{
|
||||
return 3f;
|
||||
}
|
||||
|
||||
return 2f;
|
||||
}) ?? 0f;
|
||||
var logisticsStrength = MathF.Min(30f, stationStrength * 0.18f) + siteStrength;
|
||||
influences.Add(new TerritoryInfluenceRuntime
|
||||
{
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
global using SpaceGame.Api.Auth.Contracts;
|
||||
global using SpaceGame.Api.Auth.Runtime;
|
||||
global using SpaceGame.Api.Auth.Simulation;
|
||||
global using SpaceGame.Api.Definitions;
|
||||
global using SpaceGame.Api.Economy.Contracts;
|
||||
global using SpaceGame.Api.Economy.Runtime;
|
||||
@@ -15,7 +18,7 @@ global using SpaceGame.Api.Shared.Contracts;
|
||||
global using SpaceGame.Api.Shared.Runtime;
|
||||
global using SpaceGame.Api.Ships.Contracts;
|
||||
global using SpaceGame.Api.Ships.Runtime;
|
||||
global using SpaceGame.Api.Ships.Simulation;
|
||||
global using SpaceGame.Api.Ships.AI;
|
||||
global using SpaceGame.Api.Simulation.Core;
|
||||
global using SpaceGame.Api.Stations.Contracts;
|
||||
global using SpaceGame.Api.Stations.Runtime;
|
||||
|
||||
@@ -21,13 +21,13 @@ internal static class FactionIndustryPlanner
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetCelestial = SelectFoundationCelestial(world, factionId, bottleneckCommodity);
|
||||
if (targetCelestial is null)
|
||||
var targetAnchor = SelectFoundationAnchor(world, factionId, bottleneckCommodity);
|
||||
if (targetAnchor is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId);
|
||||
var supportStation = SelectSupportStation(world, factionId, moduleId, targetAnchor.SystemId);
|
||||
if (supportStation is null)
|
||||
{
|
||||
return null;
|
||||
@@ -36,8 +36,8 @@ internal static class FactionIndustryPlanner
|
||||
return new IndustryExpansionProject(
|
||||
bottleneckCommodity,
|
||||
moduleId,
|
||||
targetCelestial.SystemId,
|
||||
targetCelestial.Id,
|
||||
targetAnchor.SystemId,
|
||||
targetAnchor.Id,
|
||||
supportStation.Id);
|
||||
}
|
||||
|
||||
@@ -93,13 +93,13 @@ internal static class FactionIndustryPlanner
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetCelestial = SelectLogisticsFoundationCelestial(world, factionId);
|
||||
if (targetCelestial is null)
|
||||
var targetAnchor = SelectLogisticsFoundationAnchor(world, factionId);
|
||||
if (targetAnchor is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetCelestial.SystemId);
|
||||
var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetAnchor.SystemId);
|
||||
if (supportStation is null)
|
||||
{
|
||||
return null;
|
||||
@@ -108,8 +108,8 @@ internal static class FactionIndustryPlanner
|
||||
return new IndustryExpansionProject(
|
||||
"shipyard",
|
||||
shipyardModuleId,
|
||||
targetCelestial.SystemId,
|
||||
targetCelestial.Id,
|
||||
targetAnchor.SystemId,
|
||||
targetAnchor.Id,
|
||||
supportStation.Id);
|
||||
}
|
||||
|
||||
@@ -129,13 +129,13 @@ internal static class FactionIndustryPlanner
|
||||
return null;
|
||||
}
|
||||
|
||||
var bootstrapCelestial = SelectFoundationCelestial(world, factionId, bootstrapCommodity);
|
||||
if (bootstrapCelestial is null)
|
||||
var bootstrapAnchor = SelectFoundationAnchor(world, factionId, bootstrapCommodity);
|
||||
if (bootstrapAnchor is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapCelestial.SystemId);
|
||||
var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapAnchor.SystemId);
|
||||
if (bootstrapSupportStation is null)
|
||||
{
|
||||
return null;
|
||||
@@ -144,8 +144,8 @@ internal static class FactionIndustryPlanner
|
||||
return new IndustryExpansionProject(
|
||||
bootstrapCommodity,
|
||||
bootstrapModuleId,
|
||||
bootstrapCelestial.SystemId,
|
||||
bootstrapCelestial.Id,
|
||||
bootstrapAnchor.SystemId,
|
||||
bootstrapAnchor.Id,
|
||||
bootstrapSupportStation.Id);
|
||||
}
|
||||
|
||||
@@ -161,13 +161,13 @@ internal static class FactionIndustryPlanner
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetCelestial = SelectFoundationCelestial(world, factionId, commodityId);
|
||||
if (targetCelestial is null)
|
||||
var targetAnchor = SelectFoundationAnchor(world, factionId, commodityId);
|
||||
if (targetAnchor is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId);
|
||||
var supportStation = SelectSupportStation(world, factionId, moduleId, targetAnchor.SystemId);
|
||||
if (supportStation is null)
|
||||
{
|
||||
return null;
|
||||
@@ -176,8 +176,8 @@ internal static class FactionIndustryPlanner
|
||||
return new IndustryExpansionProject(
|
||||
commodityId,
|
||||
moduleId,
|
||||
targetCelestial.SystemId,
|
||||
targetCelestial.Id,
|
||||
targetAnchor.SystemId,
|
||||
targetAnchor.Id,
|
||||
supportStation.Id);
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ internal static class FactionIndustryPlanner
|
||||
site.TargetDefinitionId,
|
||||
site.BlueprintId,
|
||||
site.SystemId,
|
||||
site.CelestialId,
|
||||
site.AnchorId,
|
||||
supportStationId,
|
||||
site.Id);
|
||||
}
|
||||
@@ -225,7 +225,7 @@ internal static class FactionIndustryPlanner
|
||||
}
|
||||
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var claimId = $"claim-{factionId}-{project.CelestialId}";
|
||||
var claimId = $"claim-{factionId}-{project.AnchorId}";
|
||||
if (world.Claims.All(candidate => candidate.Id != claimId))
|
||||
{
|
||||
world.Claims.Add(new ClaimRuntime
|
||||
@@ -233,7 +233,7 @@ internal static class FactionIndustryPlanner
|
||||
Id = claimId,
|
||||
FactionId = factionId,
|
||||
SystemId = project.SystemId,
|
||||
CelestialId = project.CelestialId,
|
||||
AnchorId = project.AnchorId,
|
||||
PlacedAtUtc = nowUtc,
|
||||
ActivatesAtUtc = nowUtc.AddSeconds(8),
|
||||
State = ClaimStateKinds.Activating,
|
||||
@@ -246,7 +246,7 @@ internal static class FactionIndustryPlanner
|
||||
return;
|
||||
}
|
||||
|
||||
var siteId = $"site-{factionId}-{project.CelestialId}";
|
||||
var siteId = $"site-{factionId}-{project.AnchorId}";
|
||||
if (world.ConstructionSites.Any(candidate => candidate.Id == siteId))
|
||||
{
|
||||
return;
|
||||
@@ -257,7 +257,7 @@ internal static class FactionIndustryPlanner
|
||||
Id = siteId,
|
||||
FactionId = factionId,
|
||||
SystemId = project.SystemId,
|
||||
CelestialId = project.CelestialId,
|
||||
AnchorId = project.AnchorId,
|
||||
TargetKind = "station-foundation",
|
||||
TargetDefinitionId = project.CommodityId,
|
||||
BlueprintId = project.ModuleId,
|
||||
@@ -450,51 +450,51 @@ internal static class FactionIndustryPlanner
|
||||
private static float GetTargetLevelSeconds(string itemId) =>
|
||||
string.Equals(itemId, "water", StringComparison.Ordinal) ? WaterTargetLevelSeconds : CommodityTargetLevelSeconds;
|
||||
|
||||
private static CelestialRuntime? SelectFoundationCelestial(SimulationWorld world, string factionId, string commodityId)
|
||||
private static AnchorRuntime? SelectFoundationAnchor(SimulationWorld world, string factionId, string commodityId)
|
||||
{
|
||||
var resourceItems = ResolveRootResourceItems(world, commodityId);
|
||||
return world.Celestials
|
||||
.Where(celestial =>
|
||||
celestial.Kind == SpatialNodeKind.LagrangePoint
|
||||
&& celestial.OccupyingStructureId is null
|
||||
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed)
|
||||
&& IsExpansionSystemEligible(world, factionId, celestial.SystemId))
|
||||
.OrderByDescending(celestial => ScoreCelestial(world, factionId, celestial, resourceItems))
|
||||
return world.Anchors
|
||||
.Where(anchor =>
|
||||
anchor.Kind == SpatialNodeKind.LagrangePoint
|
||||
&& anchor.OccupyingStructureId is null
|
||||
&& world.Claims.All(claim => claim.AnchorId != anchor.Id || claim.State == ClaimStateKinds.Destroyed)
|
||||
&& IsExpansionSystemEligible(world, factionId, anchor.SystemId))
|
||||
.OrderByDescending(anchor => ScoreAnchor(world, factionId, anchor, resourceItems))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static CelestialRuntime? SelectLogisticsFoundationCelestial(SimulationWorld world, string factionId)
|
||||
private static AnchorRuntime? SelectLogisticsFoundationAnchor(SimulationWorld world, string factionId)
|
||||
{
|
||||
return world.Celestials
|
||||
.Where(celestial =>
|
||||
celestial.Kind == SpatialNodeKind.LagrangePoint
|
||||
&& celestial.OccupyingStructureId is null
|
||||
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed)
|
||||
&& IsExpansionSystemEligible(world, factionId, celestial.SystemId))
|
||||
.OrderByDescending(celestial => world.Stations.Count(station =>
|
||||
return world.Anchors
|
||||
.Where(anchor =>
|
||||
anchor.Kind == SpatialNodeKind.LagrangePoint
|
||||
&& anchor.OccupyingStructureId is null
|
||||
&& world.Claims.All(claim => claim.AnchorId != anchor.Id || claim.State == ClaimStateKinds.Destroyed)
|
||||
&& IsExpansionSystemEligible(world, factionId, anchor.SystemId))
|
||||
.OrderByDescending(anchor => world.Stations.Count(station =>
|
||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)))
|
||||
.ThenByDescending(celestial => world.Stations
|
||||
&& string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal)))
|
||||
.ThenByDescending(anchor => world.Stations
|
||||
.Where(station =>
|
||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal))
|
||||
&& string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal))
|
||||
.Sum(station => station.Inventory.Values.Sum()))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static float ScoreCelestial(SimulationWorld world, string factionId, CelestialRuntime celestial, IReadOnlyCollection<string> resourceItems)
|
||||
private static float ScoreAnchor(SimulationWorld world, string factionId, AnchorRuntime anchor, IReadOnlyCollection<string> resourceItems)
|
||||
{
|
||||
var resourceScore = world.Nodes
|
||||
.Where(node => node.SystemId == celestial.SystemId && resourceItems.Contains(node.ItemId, StringComparer.Ordinal))
|
||||
.Where(node => node.SystemId == anchor.SystemId && resourceItems.Contains(node.ItemId, StringComparer.Ordinal))
|
||||
.Sum(node => node.OreRemaining);
|
||||
var factionPresence = world.Stations.Count(station =>
|
||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal));
|
||||
var controlState = GeopoliticalSimulationService.GetSystemControlState(world, celestial.SystemId);
|
||||
var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, celestial.SystemId);
|
||||
var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == celestial.SystemId);
|
||||
&& string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal));
|
||||
var controlState = GeopoliticalSimulationService.GetSystemControlState(world, anchor.SystemId);
|
||||
var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, anchor.SystemId);
|
||||
var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == anchor.SystemId);
|
||||
var pressure = world.Geopolitics?.Territory.Pressures
|
||||
.Where(entry => entry.SystemId == celestial.SystemId && entry.FactionId == factionId)
|
||||
.Where(entry => entry.SystemId == anchor.SystemId && entry.FactionId == factionId)
|
||||
.OrderByDescending(entry => entry.HostileInfluence)
|
||||
.ThenBy(entry => entry.Id, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
@@ -515,7 +515,7 @@ internal static class FactionIndustryPlanner
|
||||
};
|
||||
var securityPenalty = ((pressure?.HostileInfluence ?? 0f) * 14f)
|
||||
+ ((strategicProfile?.TerritorialPressure ?? 0f) * 9f)
|
||||
+ ((world.Geopolitics is null ? 0f : GeopoliticalSimulationService.GetSystemRouteRisk(world, celestial.SystemId, factionId)) * 250f);
|
||||
+ ((world.Geopolitics is null ? 0f : GeopoliticalSimulationService.GetSystemRouteRisk(world, anchor.SystemId, factionId)) * 250f);
|
||||
return resourceScore
|
||||
+ (factionPresence * 5_000f)
|
||||
+ controlBias
|
||||
@@ -585,6 +585,6 @@ internal sealed record IndustryExpansionProject(
|
||||
string CommodityId,
|
||||
string ModuleId,
|
||||
string SystemId,
|
||||
string CelestialId,
|
||||
string AnchorId,
|
||||
string SupportStationId,
|
||||
string? SiteId = null);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class CompletePlayerOnboardingHandler(WorldService worldService) : Endpoint<CompletePlayerOnboardingRequest, PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/player-faction/onboarding");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CompletePlayerOnboardingRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = worldService.CompletePlayerOnboarding(request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(cancellation: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ public sealed class CreatePlayerOrganizationHandler(WorldService worldService) :
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/player-faction/organizations");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerOrganizationCommandRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -12,7 +12,6 @@ public sealed class DeletePlayerDirectiveHandler(WorldService worldService) : En
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/player-faction/directives/{directiveId}");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeletePlayerDirectiveRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -12,7 +12,6 @@ public sealed class DeletePlayerOrganizationHandler(WorldService worldService) :
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/player-faction/organizations/{organizationId}");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeletePlayerOrganizationRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -7,7 +7,6 @@ public sealed class GetPlayerFactionHandler(WorldService worldService) : Endpoin
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/player-faction");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||
|
||||
73
apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs
Normal file
73
apps/backend/PlayerFaction/Api/GetPlayerIdentitiesHandler.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using FastEndpoints;
|
||||
using SpaceGame.Api.Auth.Runtime;
|
||||
using SpaceGame.Api.Auth.Simulation;
|
||||
using SpaceGame.Api.PlayerFaction.Simulation;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class GetPlayerIdentitiesHandler(IAuthRepository authRepository, IPlayerStateStore playerStateStore)
|
||||
: EndpointWithoutRequest<IReadOnlyList<PlayerIdentitySummaryResponse>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/player-faction/identities");
|
||||
Policies(AuthPolicyNames.GmAccess);
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var users = await authRepository.ListUsersAsync(cancellationToken);
|
||||
var playerFactionsByPlayerId = playerStateStore.GetPlayerFactionsByPlayerId();
|
||||
|
||||
var responses = new List<PlayerIdentitySummaryResponse>(users.Count + playerFactionsByPlayerId.Count);
|
||||
var seenIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
var userId = user.Id.ToString("N");
|
||||
playerFactionsByPlayerId.TryGetValue(userId, out var playerFaction);
|
||||
responses.Add(new PlayerIdentitySummaryResponse(
|
||||
userId,
|
||||
user.Email,
|
||||
user.Roles,
|
||||
playerFaction is not null,
|
||||
playerFaction?.Id,
|
||||
playerFaction?.Label,
|
||||
playerFaction?.SovereignFactionId));
|
||||
seenIds.Add(userId);
|
||||
}
|
||||
|
||||
foreach (var (playerId, playerFaction) in playerFactionsByPlayerId)
|
||||
{
|
||||
if (!seenIds.Add(playerId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
responses.Add(new PlayerIdentitySummaryResponse(
|
||||
playerId,
|
||||
$"{playerId}@unknown",
|
||||
Array.Empty<string>(),
|
||||
true,
|
||||
playerId,
|
||||
playerFaction.Label,
|
||||
playerFaction.SovereignFactionId));
|
||||
}
|
||||
|
||||
await SendOkAsync(
|
||||
responses
|
||||
.OrderBy(response => response.Email, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(response => response.UserId, StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PlayerIdentitySummaryResponse(
|
||||
string UserId,
|
||||
string Email,
|
||||
IReadOnlyList<string> Roles,
|
||||
bool HasPlayerFaction,
|
||||
string? PlayerFactionId,
|
||||
string? PlayerFactionLabel,
|
||||
string? SovereignFactionId);
|
||||
@@ -7,7 +7,6 @@ public sealed class UpdatePlayerOrganizationMembershipHandler(WorldService world
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/player-faction/organizations/{organizationId}/membership");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerOrganizationMembershipCommandRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -7,7 +7,6 @@ public sealed class UpdatePlayerStrategicIntentHandler(WorldService worldService
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/player-faction/strategic-intent");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerStrategicIntentCommandRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -7,7 +7,6 @@ public sealed class UpsertPlayerAssignmentHandler(WorldService worldService) : E
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/player-faction/assets/{assetId}/assignment");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerAssetAssignmentCommandRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerAutomationPolicyHandler(WorldService worldServic
|
||||
{
|
||||
Post("/api/player-faction/automation-policies");
|
||||
Put("/api/player-faction/automation-policies/{automationPolicyId}");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerAutomationPolicyCommandRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerDirectiveHandler(WorldService worldService) : En
|
||||
{
|
||||
Post("/api/player-faction/directives");
|
||||
Put("/api/player-faction/directives/{directiveId}");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerDirectiveCommandRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerPolicyHandler(WorldService worldService) : Endpo
|
||||
{
|
||||
Post("/api/player-faction/policies");
|
||||
Put("/api/player-faction/policies/{policyId}");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerPolicyCommandRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerProductionProgramHandler(WorldService worldServi
|
||||
{
|
||||
Post("/api/player-faction/production-programs");
|
||||
Put("/api/player-faction/production-programs/{productionProgramId}");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerProductionProgramCommandRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerReinforcementPolicyHandler(WorldService worldSer
|
||||
{
|
||||
Post("/api/player-faction/reinforcement-policies");
|
||||
Put("/api/player-faction/reinforcement-policies/{reinforcementPolicyId}");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerReinforcementPolicyCommandRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -194,7 +194,7 @@ public sealed record PlayerDirectiveSnapshot(
|
||||
bool UseOrders,
|
||||
string? StagingOrderKind,
|
||||
string? ItemId,
|
||||
string? PreferredNodeId,
|
||||
string? PreferredAnchorId,
|
||||
string? PreferredConstructionSiteId,
|
||||
string? PreferredModuleId,
|
||||
int Priority,
|
||||
@@ -249,7 +249,10 @@ public sealed record PlayerAlertSnapshot(
|
||||
public sealed record PlayerFactionSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string? PersonaName,
|
||||
string? RaceId,
|
||||
string SovereignFactionId,
|
||||
bool RequiresOnboarding,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
namespace SpaceGame.Api.PlayerFaction.Contracts;
|
||||
|
||||
public sealed record CompletePlayerOnboardingRequest(
|
||||
string Name,
|
||||
string RaceId);
|
||||
|
||||
public sealed record PlayerOrganizationCommandRequest(
|
||||
string Kind,
|
||||
string Label,
|
||||
@@ -41,7 +45,7 @@ public sealed record PlayerDirectiveCommandRequest(
|
||||
string? SourceStationId,
|
||||
string? DestinationStationId,
|
||||
string? ItemId,
|
||||
string? PreferredNodeId,
|
||||
string? PreferredAnchorId,
|
||||
string? PreferredConstructionSiteId,
|
||||
string? PreferredModuleId,
|
||||
int Priority,
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Runtime;
|
||||
|
||||
public sealed class PlayerFactionRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string? PersonaName { get; set; }
|
||||
public string? RaceId { get; set; }
|
||||
public required string SovereignFactionId { get; set; }
|
||||
public bool RequiresOnboarding { get; set; } = true;
|
||||
public string Status { get; set; } = "active";
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
@@ -180,7 +185,7 @@ public sealed class PlayerAutomationPolicyRuntime
|
||||
public string ScopeKind { get; set; } = "player-faction";
|
||||
public string? ScopeId { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string BehaviorKind { get; set; } = "idle";
|
||||
public string BehaviorKind { get; set; } = Idle;
|
||||
public bool UseOrders { get; set; }
|
||||
public string? StagingOrderKind { get; set; }
|
||||
public int MaxSystemRange { get; set; }
|
||||
@@ -242,11 +247,11 @@ public sealed class PlayerDirectiveRuntime
|
||||
public string? HomeStationId { get; set; }
|
||||
public string? SourceStationId { get; set; }
|
||||
public string? DestinationStationId { get; set; }
|
||||
public string BehaviorKind { get; set; } = "idle";
|
||||
public string BehaviorKind { get; set; } = Idle;
|
||||
public bool UseOrders { get; set; }
|
||||
public string? StagingOrderKind { get; set; }
|
||||
public string? ItemId { get; set; }
|
||||
public string? PreferredNodeId { get; set; }
|
||||
public string? PreferredAnchorId { get; set; }
|
||||
public string? PreferredConstructionSiteId { get; set; }
|
||||
public string? PreferredModuleId { get; set; }
|
||||
public int Priority { get; set; } = 50;
|
||||
|
||||
10
apps/backend/PlayerFaction/Simulation/IPlayerStateStore.cs
Normal file
10
apps/backend/PlayerFaction/Simulation/IPlayerStateStore.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SpaceGame.Api.PlayerFaction.Simulation;
|
||||
|
||||
public interface IPlayerStateStore
|
||||
{
|
||||
bool TryGetPlayerFaction(string playerId, out PlayerFactionRuntime playerFaction);
|
||||
PlayerFactionRuntime GetOrAddPlayerFaction(string playerId, Func<PlayerFactionRuntime> factory);
|
||||
IReadOnlyCollection<PlayerFactionRuntime> GetPlayerFactions();
|
||||
IReadOnlyDictionary<string, PlayerFactionRuntime> GetPlayerFactionsByPlayerId();
|
||||
void Clear();
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
namespace SpaceGame.Api.PlayerFaction.Simulation;
|
||||
|
||||
public sealed class PlayerFactionProjectionService
|
||||
{
|
||||
public PlayerFactionSnapshot? ToSnapshot(PlayerFactionRuntime? player)
|
||||
{
|
||||
if (player is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PlayerFactionSnapshot(
|
||||
player.Id,
|
||||
player.Label,
|
||||
player.PersonaName,
|
||||
player.RaceId,
|
||||
player.SovereignFactionId,
|
||||
player.RequiresOnboarding,
|
||||
player.Status,
|
||||
player.CreatedAtUtc,
|
||||
player.UpdatedAtUtc,
|
||||
new PlayerAssetRegistrySnapshot(
|
||||
player.AssetRegistry.ShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.CommanderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.ClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.ConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.PolicySetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.EconomicRegionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList()),
|
||||
new PlayerStrategicIntentSnapshot(
|
||||
player.StrategicIntent.StrategicPosture,
|
||||
player.StrategicIntent.EconomicPosture,
|
||||
player.StrategicIntent.MilitaryPosture,
|
||||
player.StrategicIntent.LogisticsPosture,
|
||||
player.StrategicIntent.DesiredReserveRatio,
|
||||
player.StrategicIntent.AllowDelegatedCombatAutomation,
|
||||
player.StrategicIntent.AllowDelegatedEconomicAutomation,
|
||||
player.StrategicIntent.Notes),
|
||||
player.Fleets.Select(fleet => new PlayerFleetSnapshot(
|
||||
fleet.Id,
|
||||
fleet.Label,
|
||||
fleet.Status,
|
||||
fleet.Role,
|
||||
fleet.CommanderId,
|
||||
fleet.FrontId,
|
||||
fleet.HomeSystemId,
|
||||
fleet.HomeStationId,
|
||||
fleet.PolicyId,
|
||||
fleet.AutomationPolicyId,
|
||||
fleet.ReinforcementPolicyId,
|
||||
fleet.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
fleet.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
fleet.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
fleet.UpdatedAtUtc)).ToList(),
|
||||
player.TaskForces.Select(taskForce => new PlayerTaskForceSnapshot(
|
||||
taskForce.Id,
|
||||
taskForce.Label,
|
||||
taskForce.Status,
|
||||
taskForce.Role,
|
||||
taskForce.FleetId,
|
||||
taskForce.CommanderId,
|
||||
taskForce.FrontId,
|
||||
taskForce.PolicyId,
|
||||
taskForce.AutomationPolicyId,
|
||||
taskForce.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
taskForce.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
taskForce.UpdatedAtUtc)).ToList(),
|
||||
player.StationGroups.Select(group => new PlayerStationGroupSnapshot(
|
||||
group.Id,
|
||||
group.Label,
|
||||
group.Status,
|
||||
group.Role,
|
||||
group.EconomicRegionId,
|
||||
group.PolicyId,
|
||||
group.AutomationPolicyId,
|
||||
group.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
group.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
group.FocusItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
group.UpdatedAtUtc)).ToList(),
|
||||
player.EconomicRegions.Select(region => new PlayerEconomicRegionSnapshot(
|
||||
region.Id,
|
||||
region.Label,
|
||||
region.Status,
|
||||
region.Role,
|
||||
region.SharedEconomicRegionId,
|
||||
region.PolicyId,
|
||||
region.AutomationPolicyId,
|
||||
region.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
region.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
region.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
region.UpdatedAtUtc)).ToList(),
|
||||
player.Fronts.Select(front => new PlayerFrontSnapshot(
|
||||
front.Id,
|
||||
front.Label,
|
||||
front.Status,
|
||||
front.Priority,
|
||||
front.Posture,
|
||||
front.SharedFrontLineId,
|
||||
front.TargetFactionId,
|
||||
front.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
front.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
front.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
front.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
front.UpdatedAtUtc)).ToList(),
|
||||
player.Reserves.Select(reserve => new PlayerReserveGroupSnapshot(
|
||||
reserve.Id,
|
||||
reserve.Label,
|
||||
reserve.Status,
|
||||
reserve.ReserveKind,
|
||||
reserve.HomeSystemId,
|
||||
reserve.PolicyId,
|
||||
reserve.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
reserve.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
reserve.UpdatedAtUtc)).ToList(),
|
||||
player.Policies.Select(policy => new PlayerFactionPolicySnapshot(
|
||||
policy.Id,
|
||||
policy.Label,
|
||||
policy.ScopeKind,
|
||||
policy.ScopeId,
|
||||
policy.PolicySetId,
|
||||
policy.AllowDelegatedCombat,
|
||||
policy.AllowDelegatedTrade,
|
||||
policy.ReserveCreditsRatio,
|
||||
policy.ReserveMilitaryRatio,
|
||||
policy.TradeAccessPolicy,
|
||||
policy.DockingAccessPolicy,
|
||||
policy.ConstructionAccessPolicy,
|
||||
policy.OperationalRangePolicy,
|
||||
policy.CombatEngagementPolicy,
|
||||
policy.AvoidHostileSystems,
|
||||
policy.FleeHullRatio,
|
||||
policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
policy.Notes,
|
||||
policy.UpdatedAtUtc)).ToList(),
|
||||
player.AutomationPolicies.Select(policy => new PlayerAutomationPolicySnapshot(
|
||||
policy.Id,
|
||||
policy.Label,
|
||||
policy.ScopeKind,
|
||||
policy.ScopeId,
|
||||
policy.Enabled,
|
||||
policy.BehaviorKind,
|
||||
policy.UseOrders,
|
||||
policy.StagingOrderKind,
|
||||
policy.MaxSystemRange,
|
||||
policy.KnownStationsOnly,
|
||||
policy.Radius,
|
||||
policy.WaitSeconds,
|
||||
policy.PreferredItemId,
|
||||
policy.Notes,
|
||||
policy.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(),
|
||||
policy.UpdatedAtUtc)).ToList(),
|
||||
player.ReinforcementPolicies.Select(policy => new PlayerReinforcementPolicySnapshot(
|
||||
policy.Id,
|
||||
policy.Label,
|
||||
policy.ScopeKind,
|
||||
policy.ScopeId,
|
||||
policy.ShipKind,
|
||||
policy.DesiredAssetCount,
|
||||
policy.MinimumReserveCount,
|
||||
policy.AutoTransferReserves,
|
||||
policy.AutoQueueProduction,
|
||||
policy.SourceReserveId,
|
||||
policy.TargetFrontId,
|
||||
policy.Notes,
|
||||
policy.UpdatedAtUtc)).ToList(),
|
||||
player.ProductionPrograms.Select(program => new PlayerProductionProgramSnapshot(
|
||||
program.Id,
|
||||
program.Label,
|
||||
program.Status,
|
||||
program.Kind,
|
||||
program.TargetShipKind,
|
||||
program.TargetModuleId,
|
||||
program.TargetItemId,
|
||||
program.TargetCount,
|
||||
program.CurrentCount,
|
||||
program.StationGroupId,
|
||||
program.ReinforcementPolicyId,
|
||||
program.Notes,
|
||||
program.UpdatedAtUtc)).ToList(),
|
||||
player.Directives.Select(directive => new PlayerDirectiveSnapshot(
|
||||
directive.Id,
|
||||
directive.Label,
|
||||
directive.Status,
|
||||
directive.Kind,
|
||||
directive.ScopeKind,
|
||||
directive.ScopeId,
|
||||
directive.TargetEntityId,
|
||||
directive.TargetSystemId,
|
||||
directive.TargetPosition is null ? null : ToDto(directive.TargetPosition.Value),
|
||||
directive.HomeSystemId,
|
||||
directive.HomeStationId,
|
||||
directive.SourceStationId,
|
||||
directive.DestinationStationId,
|
||||
directive.BehaviorKind,
|
||||
directive.UseOrders,
|
||||
directive.StagingOrderKind,
|
||||
directive.ItemId,
|
||||
directive.PreferredAnchorId,
|
||||
directive.PreferredConstructionSiteId,
|
||||
directive.PreferredModuleId,
|
||||
directive.Priority,
|
||||
directive.Radius,
|
||||
directive.WaitSeconds,
|
||||
directive.MaxSystemRange,
|
||||
directive.KnownStationsOnly,
|
||||
directive.PatrolPoints.Select(ToDto).ToList(),
|
||||
directive.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(),
|
||||
directive.PolicyId,
|
||||
directive.AutomationPolicyId,
|
||||
directive.Notes,
|
||||
directive.CreatedAtUtc,
|
||||
directive.UpdatedAtUtc)).ToList(),
|
||||
player.Assignments.Select(assignment => new PlayerAssignmentSnapshot(
|
||||
assignment.Id,
|
||||
assignment.AssetKind,
|
||||
assignment.AssetId,
|
||||
assignment.FleetId,
|
||||
assignment.TaskForceId,
|
||||
assignment.StationGroupId,
|
||||
assignment.EconomicRegionId,
|
||||
assignment.FrontId,
|
||||
assignment.ReserveId,
|
||||
assignment.DirectiveId,
|
||||
assignment.PolicyId,
|
||||
assignment.AutomationPolicyId,
|
||||
assignment.Role,
|
||||
assignment.Status,
|
||||
assignment.UpdatedAtUtc)).ToList(),
|
||||
player.DecisionLog.Select(entry => new PlayerDecisionLogEntrySnapshot(
|
||||
entry.Id,
|
||||
entry.Kind,
|
||||
entry.Summary,
|
||||
entry.RelatedEntityKind,
|
||||
entry.RelatedEntityId,
|
||||
entry.OccurredAtUtc)).ToList(),
|
||||
player.Alerts.Select(alert => new PlayerAlertSnapshot(
|
||||
alert.Id,
|
||||
alert.Kind,
|
||||
alert.Severity,
|
||||
alert.Summary,
|
||||
alert.AssetKind,
|
||||
alert.AssetId,
|
||||
alert.RelatedDirectiveId,
|
||||
alert.Status,
|
||||
alert.CreatedAtUtc)).ToList());
|
||||
}
|
||||
|
||||
private static ShipOrderTemplateSnapshot ToShipOrderTemplateSnapshot(ShipOrderTemplateRuntime template) =>
|
||||
new(
|
||||
template.Kind,
|
||||
template.Label,
|
||||
template.TargetEntityId,
|
||||
template.TargetSystemId,
|
||||
template.TargetPosition is null ? null : ToDto(template.TargetPosition.Value),
|
||||
template.SourceStationId,
|
||||
template.DestinationStationId,
|
||||
template.ItemId,
|
||||
template.AnchorId,
|
||||
template.ConstructionSiteId,
|
||||
template.ModuleId,
|
||||
template.WaitSeconds,
|
||||
template.Radius,
|
||||
template.MaxSystemRange,
|
||||
template.KnownStationsOnly);
|
||||
|
||||
private static Vector3Dto ToDto(Vector3 vector) => new(vector.X, vector.Y, vector.Z);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Simulation;
|
||||
|
||||
internal sealed class PlayerFactionService
|
||||
@@ -6,53 +9,111 @@ internal sealed class PlayerFactionService
|
||||
private const int MaxAlerts = 32;
|
||||
private const string PlayerFactionDomainId = "player-faction";
|
||||
|
||||
internal static bool IsPlayerFaction(SimulationWorld world, string factionId) =>
|
||||
world.PlayerFaction is not null && string.Equals(world.PlayerFaction.SovereignFactionId, factionId, StringComparison.Ordinal);
|
||||
internal static bool IsPlayerFaction(IPlayerStateStore playerStateStore, string factionId) =>
|
||||
playerStateStore.GetPlayerFactions().Any(player =>
|
||||
string.Equals(player.SovereignFactionId, factionId, StringComparison.Ordinal));
|
||||
|
||||
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world)
|
||||
internal PlayerFactionRuntime? TryGetDomain(IPlayerStateStore playerStateStore, string playerId)
|
||||
{
|
||||
if (world.PlayerFaction is not null)
|
||||
{
|
||||
return world.PlayerFaction;
|
||||
}
|
||||
return playerStateStore.TryGetPlayerFaction(playerId, out var player) ? player : null;
|
||||
}
|
||||
|
||||
var sovereignFaction = world.Factions.FirstOrDefault(faction => string.Equals(faction.Id, LoaderSupport.DefaultFactionId, StringComparison.Ordinal))
|
||||
?? world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).First();
|
||||
|
||||
world.PlayerFaction = new PlayerFactionRuntime
|
||||
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
|
||||
{
|
||||
var player = playerStateStore.GetOrAddPlayerFaction(playerId, () => new PlayerFactionRuntime
|
||||
{
|
||||
Id = PlayerFactionDomainId,
|
||||
Label = $"{sovereignFaction.Label} Command",
|
||||
SovereignFactionId = sovereignFaction.Id,
|
||||
Label = "Pending Pilot",
|
||||
SovereignFactionId = string.Empty,
|
||||
RequiresOnboarding = true,
|
||||
CreatedAtUtc = world.GeneratedAtUtc,
|
||||
UpdatedAtUtc = world.GeneratedAtUtc,
|
||||
};
|
||||
});
|
||||
|
||||
EnsureBaseStructures(world, world.PlayerFaction);
|
||||
SyncRegistry(world, world.PlayerFaction);
|
||||
return world.PlayerFaction;
|
||||
}
|
||||
|
||||
internal void Update(SimulationWorld world, float _deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
EnsureBaseStructures(world, player);
|
||||
SyncRegistry(world, player);
|
||||
PrunePlayerState(world, player);
|
||||
RefreshGeopoliticalOrganizationContext(world, player);
|
||||
ReconcileOrganizationAssignments(world, player);
|
||||
ReconcileDirectiveScopes(player);
|
||||
RefreshProductionPrograms(world, player);
|
||||
ApplyStrategicIntegration(world, player);
|
||||
ApplyPolicies(world, player);
|
||||
ApplyAssignmentsAndDirectives(world, player, events);
|
||||
RefreshAlerts(world, player);
|
||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
return player;
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, PlayerOrganizationCommandRequest request)
|
||||
internal PlayerFactionRuntime CompleteOnboarding(
|
||||
SimulationWorld world,
|
||||
IPlayerStateStore playerStateStore,
|
||||
string playerId,
|
||||
CompletePlayerOnboardingRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
if (!player.RequiresOnboarding)
|
||||
{
|
||||
throw new InvalidOperationException("Player onboarding has already been completed.");
|
||||
}
|
||||
|
||||
var personaName = request.Name.Trim();
|
||||
if (personaName.Length < 2)
|
||||
{
|
||||
throw new InvalidOperationException("Player name must contain at least 2 characters.");
|
||||
}
|
||||
|
||||
if (personaName.Length > 48)
|
||||
{
|
||||
throw new InvalidOperationException("Player name must contain at most 48 characters.");
|
||||
}
|
||||
|
||||
var ownedFactionId = BuildOwnedFactionId(playerId);
|
||||
if (world.Factions.Any(faction => string.Equals(faction.Id, ownedFactionId, StringComparison.Ordinal)))
|
||||
{
|
||||
throw new InvalidOperationException($"Player faction '{ownedFactionId}' already exists in the current world.");
|
||||
}
|
||||
|
||||
player.Label = personaName;
|
||||
player.PersonaName = personaName;
|
||||
player.RaceId = request.RaceId.Trim();
|
||||
player.SovereignFactionId = ownedFactionId;
|
||||
player.RequiresOnboarding = false;
|
||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
return player;
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime EnsureInitializedDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
|
||||
{
|
||||
var player = EnsureDomain(world, playerStateStore, playerId);
|
||||
if (player.RequiresOnboarding || string.IsNullOrWhiteSpace(player.SovereignFactionId))
|
||||
{
|
||||
throw new InvalidOperationException("Player onboarding must be completed before issuing gameplay commands.");
|
||||
}
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
internal static string BuildOwnedFactionId(string playerId) =>
|
||||
$"player-{playerId.Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant()}";
|
||||
|
||||
internal void Update(SimulationWorld world, IPlayerStateStore playerStateStore, float _deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
if (playerStateStore.GetPlayerFactions().Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var player in playerStateStore.GetPlayerFactions())
|
||||
{
|
||||
EnsureBaseStructures(world, player);
|
||||
SyncRegistry(world, player);
|
||||
PrunePlayerState(world, player);
|
||||
RefreshGeopoliticalOrganizationContext(world, player);
|
||||
ReconcileOrganizationAssignments(world, player);
|
||||
ReconcileDirectiveScopes(player);
|
||||
RefreshProductionPrograms(world, player);
|
||||
ApplyStrategicIntegration(world, player);
|
||||
ApplyPolicies(world, player);
|
||||
ApplyAssignmentsAndDirectives(world, player, events);
|
||||
RefreshAlerts(world, player);
|
||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerOrganizationCommandRequest request)
|
||||
{
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player));
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -167,9 +228,9 @@ internal sealed class PlayerFactionService
|
||||
return player;
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, string organizationId)
|
||||
internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
RemoveOrganization(player, organizationId);
|
||||
player.Assignments.RemoveAll(assignment =>
|
||||
assignment.FleetId == organizationId ||
|
||||
@@ -185,9 +246,9 @@ internal sealed class PlayerFactionService
|
||||
return player;
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, string organizationId, PlayerOrganizationMembershipCommandRequest request)
|
||||
internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId, PlayerOrganizationMembershipCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var kind = ResolveOrganizationKind(player, organizationId);
|
||||
switch (kind)
|
||||
{
|
||||
@@ -236,9 +297,9 @@ internal sealed class PlayerFactionService
|
||||
return player;
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, string? directiveId, PlayerDirectiveCommandRequest request)
|
||||
internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? directiveId, PlayerDirectiveCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var directive = directiveId is null
|
||||
? null
|
||||
: player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal));
|
||||
@@ -268,7 +329,7 @@ internal sealed class PlayerFactionService
|
||||
directive.SourceStationId = request.SourceStationId;
|
||||
directive.DestinationStationId = request.DestinationStationId;
|
||||
directive.ItemId = request.ItemId;
|
||||
directive.PreferredNodeId = request.PreferredNodeId;
|
||||
directive.PreferredAnchorId = request.PreferredAnchorId;
|
||||
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
|
||||
directive.PreferredModuleId = request.PreferredModuleId;
|
||||
directive.Priority = request.Priority;
|
||||
@@ -294,7 +355,7 @@ internal sealed class PlayerFactionService
|
||||
SourceStationId = template.SourceStationId,
|
||||
DestinationStationId = template.DestinationStationId,
|
||||
ItemId = template.ItemId,
|
||||
NodeId = template.NodeId,
|
||||
AnchorId = template.AnchorId,
|
||||
ConstructionSiteId = template.ConstructionSiteId,
|
||||
ModuleId = template.ModuleId,
|
||||
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
|
||||
@@ -313,9 +374,9 @@ internal sealed class PlayerFactionService
|
||||
return player;
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, string directiveId)
|
||||
internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string directiveId)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
player.Directives.RemoveAll(directive => directive.Id == directiveId);
|
||||
foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId))
|
||||
{
|
||||
@@ -327,9 +388,9 @@ internal sealed class PlayerFactionService
|
||||
return player;
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, string? policyId, PlayerPolicyCommandRequest request)
|
||||
internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? policyId, PlayerPolicyCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var policy = policyId is null
|
||||
? null
|
||||
: player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal));
|
||||
@@ -398,9 +459,9 @@ internal sealed class PlayerFactionService
|
||||
return player;
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
|
||||
internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var policy = automationPolicyId is null
|
||||
? null
|
||||
: player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal));
|
||||
@@ -440,7 +501,7 @@ internal sealed class PlayerFactionService
|
||||
SourceStationId = template.SourceStationId,
|
||||
DestinationStationId = template.DestinationStationId,
|
||||
ItemId = template.ItemId,
|
||||
NodeId = template.NodeId,
|
||||
AnchorId = template.AnchorId,
|
||||
ConstructionSiteId = template.ConstructionSiteId,
|
||||
ModuleId = template.ModuleId,
|
||||
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
|
||||
@@ -456,9 +517,9 @@ internal sealed class PlayerFactionService
|
||||
return player;
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
|
||||
internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var policy = reinforcementPolicyId is null
|
||||
? null
|
||||
: player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal));
|
||||
@@ -490,9 +551,9 @@ internal sealed class PlayerFactionService
|
||||
return player;
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, string? productionProgramId, PlayerProductionProgramCommandRequest request)
|
||||
internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? productionProgramId, PlayerProductionProgramCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var program = productionProgramId is null
|
||||
? null
|
||||
: player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal));
|
||||
@@ -522,9 +583,9 @@ internal sealed class PlayerFactionService
|
||||
return player;
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, string assetId, PlayerAssetAssignmentCommandRequest request)
|
||||
internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string assetId, PlayerAssetAssignmentCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
var assignment = player.Assignments.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) &&
|
||||
string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal));
|
||||
@@ -581,9 +642,9 @@ internal sealed class PlayerFactionService
|
||||
return player;
|
||||
}
|
||||
|
||||
internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, PlayerStrategicIntentCommandRequest request)
|
||||
internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerStrategicIntentCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
player.StrategicIntent.StrategicPosture = request.StrategicPosture;
|
||||
player.StrategicIntent.EconomicPosture = request.EconomicPosture;
|
||||
player.StrategicIntent.MilitaryPosture = request.MilitaryPosture;
|
||||
@@ -597,9 +658,9 @@ internal sealed class PlayerFactionService
|
||||
return player;
|
||||
}
|
||||
|
||||
internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, string shipId, ShipOrderCommandRequest request)
|
||||
internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipOrderCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||
{
|
||||
return null;
|
||||
@@ -611,15 +672,12 @@ internal sealed class PlayerFactionService
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ship.OrderQueue.Count >= 8)
|
||||
{
|
||||
throw new InvalidOperationException("Order queue is full.");
|
||||
}
|
||||
|
||||
ship.OrderQueue.Add(new ShipOrderRuntime
|
||||
ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime
|
||||
{
|
||||
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
|
||||
Kind = request.Kind,
|
||||
SourceKind = ShipOrderSourceKind.Player,
|
||||
SourceId = playerId,
|
||||
Priority = request.Priority,
|
||||
InterruptCurrentPlan = request.InterruptCurrentPlan,
|
||||
Label = request.Label,
|
||||
@@ -629,7 +687,7 @@ internal sealed class PlayerFactionService
|
||||
SourceStationId = request.SourceStationId,
|
||||
DestinationStationId = request.DestinationStationId,
|
||||
ItemId = request.ItemId,
|
||||
NodeId = request.NodeId,
|
||||
AnchorId = request.AnchorId,
|
||||
ConstructionSiteId = request.ConstructionSiteId,
|
||||
ModuleId = request.ModuleId,
|
||||
WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f),
|
||||
@@ -638,15 +696,10 @@ internal sealed class PlayerFactionService
|
||||
KnownStationsOnly = request.KnownStationsOnly ?? false,
|
||||
});
|
||||
|
||||
AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Label}.", "ship", shipId);
|
||||
AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Name}.", "ship", shipId);
|
||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
ship.ControlSourceKind = "player-order";
|
||||
ship.ControlSourceId = ship.OrderQueue
|
||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => order.Id)
|
||||
.FirstOrDefault();
|
||||
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||
ship.ControlReason = request.Label ?? request.Kind;
|
||||
ship.NeedsReplan = true;
|
||||
ship.LastReplanReason = "player-order-enqueued";
|
||||
@@ -654,9 +707,9 @@ internal sealed class PlayerFactionService
|
||||
return ship;
|
||||
}
|
||||
|
||||
internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, string shipId, string orderId)
|
||||
internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||
{
|
||||
return null;
|
||||
@@ -668,28 +721,18 @@ internal sealed class PlayerFactionService
|
||||
return null;
|
||||
}
|
||||
|
||||
var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId);
|
||||
if (removed > 0)
|
||||
var removed = ship.OrderQueue.RemoveById(orderId);
|
||||
if (removed)
|
||||
{
|
||||
AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Label}.", "ship", shipId);
|
||||
AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Name}.", "ship", shipId);
|
||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
ship.ControlSourceKind = ship.OrderQueue.Any(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
||||
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||
? "player-order"
|
||||
: "player-manual";
|
||||
ship.ControlSourceId = ship.OrderQueue
|
||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => order.Id)
|
||||
.FirstOrDefault();
|
||||
ship.ControlReason = ship.OrderQueue
|
||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => order.Label ?? order.Kind)
|
||||
.FirstOrDefault()
|
||||
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
|
||||
?? "manual-player-control";
|
||||
ship.NeedsReplan = true;
|
||||
ship.LastReplanReason = "player-order-removed";
|
||||
@@ -697,9 +740,96 @@ internal sealed class PlayerFactionService
|
||||
return ship;
|
||||
}
|
||||
|
||||
internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, string shipId, ShipDefaultBehaviorCommandRequest request)
|
||||
internal ShipRuntime? UpdateDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId, ShipOrderUpdateCommandRequest request)
|
||||
{
|
||||
var player = EnsureDomain(world);
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
|
||||
if (ship is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var order = ship.OrderQueue.FindById(orderId);
|
||||
if (order is null || order.SourceKind != ShipOrderSourceKind.Player)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
order.Priority = request.Priority;
|
||||
order.InterruptCurrentPlan = request.InterruptCurrentPlan;
|
||||
order.Label = request.Label;
|
||||
order.TargetEntityId = request.TargetEntityId;
|
||||
order.TargetSystemId = request.TargetSystemId;
|
||||
order.TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z);
|
||||
order.SourceStationId = request.SourceStationId;
|
||||
order.DestinationStationId = request.DestinationStationId;
|
||||
order.ItemId = request.ItemId;
|
||||
order.AnchorId = request.AnchorId;
|
||||
order.ConstructionSiteId = request.ConstructionSiteId;
|
||||
order.ModuleId = request.ModuleId;
|
||||
order.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f);
|
||||
order.Radius = MathF.Max(0f, request.Radius ?? 0f);
|
||||
order.MaxSystemRange = request.MaxSystemRange;
|
||||
order.KnownStationsOnly = request.KnownStationsOnly ?? false;
|
||||
order.Status = OrderStatus.Queued;
|
||||
order.FailureReason = null;
|
||||
|
||||
AddDecision(player, "ship-order-updated", $"Updated order {orderId} on {ship.Definition.Name}.", "ship", shipId);
|
||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||
? "player-order"
|
||||
: "player-manual";
|
||||
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
|
||||
?? request.Label
|
||||
?? request.Kind;
|
||||
ship.NeedsReplan = true;
|
||||
ship.LastReplanReason = "player-order-updated";
|
||||
ship.LastDeltaSignature = string.Empty;
|
||||
return ship;
|
||||
}
|
||||
|
||||
internal ShipRuntime? ReorderDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId, int targetIndex)
|
||||
{
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
|
||||
if (ship is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ship.OrderQueue.TryMovePlayerOrder(orderId, targetIndex))
|
||||
{
|
||||
return ship;
|
||||
}
|
||||
|
||||
AddDecision(player, "ship-order-reordered", $"Reordered order {orderId} on {ship.Definition.Name}.", "ship", shipId);
|
||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||
? "player-order"
|
||||
: "player-manual";
|
||||
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
|
||||
?? "manual-player-control";
|
||||
ship.NeedsReplan = true;
|
||||
ship.LastReplanReason = "player-order-reordered";
|
||||
ship.LastDeltaSignature = string.Empty;
|
||||
return ship;
|
||||
}
|
||||
|
||||
internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipDefaultBehaviorCommandRequest request)
|
||||
{
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||
{
|
||||
return null;
|
||||
@@ -718,7 +848,7 @@ internal sealed class PlayerFactionService
|
||||
directive = new PlayerDirectiveRuntime
|
||||
{
|
||||
Id = directiveId,
|
||||
Label = $"Direct control {ship.Definition.Label}",
|
||||
Label = $"Direct control {ship.Definition.Name}",
|
||||
ScopeKind = "ship",
|
||||
ScopeId = shipId,
|
||||
Kind = "direct-control",
|
||||
@@ -727,7 +857,7 @@ internal sealed class PlayerFactionService
|
||||
player.Directives.Add(directive);
|
||||
}
|
||||
|
||||
directive.Label = $"Direct control {ship.Definition.Label}";
|
||||
directive.Label = $"Direct control {ship.Definition.Name}";
|
||||
directive.Kind = "direct-control";
|
||||
directive.ScopeKind = "ship";
|
||||
directive.ScopeId = shipId;
|
||||
@@ -741,8 +871,8 @@ internal sealed class PlayerFactionService
|
||||
directive.HomeStationId = request.HomeStationId;
|
||||
directive.SourceStationId = request.HomeStationId;
|
||||
directive.DestinationStationId = null;
|
||||
directive.ItemId = request.PreferredItemId;
|
||||
directive.PreferredNodeId = request.PreferredNodeId;
|
||||
directive.ItemId = request.ItemId;
|
||||
directive.PreferredAnchorId = request.PreferredAnchorId;
|
||||
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
|
||||
directive.PreferredModuleId = request.PreferredModuleId;
|
||||
directive.Priority = 100;
|
||||
@@ -768,7 +898,7 @@ internal sealed class PlayerFactionService
|
||||
SourceStationId = template.SourceStationId,
|
||||
DestinationStationId = template.DestinationStationId,
|
||||
ItemId = template.ItemId,
|
||||
NodeId = template.NodeId,
|
||||
AnchorId = template.AnchorId,
|
||||
ConstructionSiteId = template.ConstructionSiteId,
|
||||
ModuleId = template.ModuleId,
|
||||
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
|
||||
@@ -788,7 +918,7 @@ internal sealed class PlayerFactionService
|
||||
ship.ControlSourceKind = "player-directive";
|
||||
ship.ControlSourceId = directive.Id;
|
||||
ship.ControlReason = directive.Label;
|
||||
AddDecision(player, "ship-behavior-configured", $"Configured {request.Kind} for {ship.Definition.Label}.", "ship", shipId);
|
||||
AddDecision(player, "ship-behavior-configured", $"Configured {request.Kind} for {ship.Definition.Name}.", "ship", shipId);
|
||||
player.UpdatedAtUtc = directive.UpdatedAtUtc;
|
||||
ship.NeedsReplan = true;
|
||||
ship.LastReplanReason = "player-behavior-configured";
|
||||
@@ -821,7 +951,7 @@ internal sealed class PlayerFactionService
|
||||
{
|
||||
Id = "player-core-automation",
|
||||
Label = "Core Automation",
|
||||
BehaviorKind = "idle",
|
||||
BehaviorKind = Idle,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -839,6 +969,24 @@ internal sealed class PlayerFactionService
|
||||
|
||||
private static void SyncRegistry(SimulationWorld world, PlayerFactionRuntime player)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(player.SovereignFactionId))
|
||||
{
|
||||
SyncSet(player.AssetRegistry.ShipIds, []);
|
||||
SyncSet(player.AssetRegistry.StationIds, []);
|
||||
SyncSet(player.AssetRegistry.CommanderIds, []);
|
||||
SyncSet(player.AssetRegistry.ClaimIds, []);
|
||||
SyncSet(player.AssetRegistry.ConstructionSiteIds, []);
|
||||
SyncSet(player.AssetRegistry.PolicySetIds, player.Policies.Where(entry => entry.PolicySetId is not null).Select(entry => entry.PolicySetId!));
|
||||
SyncSet(player.AssetRegistry.MarketOrderIds, []);
|
||||
SyncSet(player.AssetRegistry.FleetIds, player.Fleets.Select(fleet => fleet.Id));
|
||||
SyncSet(player.AssetRegistry.TaskForceIds, player.TaskForces.Select(taskForce => taskForce.Id));
|
||||
SyncSet(player.AssetRegistry.StationGroupIds, player.StationGroups.Select(group => group.Id));
|
||||
SyncSet(player.AssetRegistry.EconomicRegionIds, player.EconomicRegions.Select(region => region.Id));
|
||||
SyncSet(player.AssetRegistry.FrontIds, player.Fronts.Select(front => front.Id));
|
||||
SyncSet(player.AssetRegistry.ReserveIds, player.Reserves.Select(reserve => reserve.Id));
|
||||
return;
|
||||
}
|
||||
|
||||
SyncSet(player.AssetRegistry.ShipIds, world.Ships.Where(ship => ship.FactionId == player.SovereignFactionId).Select(ship => ship.Id));
|
||||
SyncSet(player.AssetRegistry.StationIds, world.Stations.Where(station => station.FactionId == player.SovereignFactionId).Select(station => station.Id));
|
||||
SyncSet(player.AssetRegistry.CommanderIds, world.Commanders.Where(commander => commander.FactionId == player.SovereignFactionId).Select(commander => commander.Id));
|
||||
@@ -1030,7 +1178,7 @@ internal sealed class PlayerFactionService
|
||||
var changed = ApplyDirectiveToShip(commander, ship, directive, automation, assignment);
|
||||
if (changed && directive is not null)
|
||||
{
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "player-directive", $"{ship.Definition.Label} aligned to player directive {directive.Label}.", DateTimeOffset.UtcNow, "player", "universe", ship.Id));
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "player-directive", $"{ship.Definition.Name} aligned to player directive {directive.Label}.", DateTimeOffset.UtcNow, "player", "universe", ship.Id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1211,8 +1359,7 @@ internal sealed class PlayerFactionService
|
||||
return player.AutomationPolicies.FirstOrDefault(policy => policy.Id == automationId);
|
||||
}
|
||||
|
||||
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId)
|
||||
?? player.AutomationPolicies.FirstOrDefault(policy => policy.Id == "player-core-automation");
|
||||
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId);
|
||||
}
|
||||
|
||||
private static PlayerFactionPolicyRuntime? ResolvePolicy(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string assetId)
|
||||
@@ -1241,25 +1388,15 @@ internal sealed class PlayerFactionService
|
||||
? "player-directive"
|
||||
: automation is not null
|
||||
? "player-automation"
|
||||
: ship.OrderQueue.Any(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
||||
: ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||
? "player-order"
|
||||
: "player-manual";
|
||||
var desiredControlSourceId = directive?.Id
|
||||
?? automation?.Id
|
||||
?? ship.OrderQueue
|
||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => order.Id)
|
||||
.FirstOrDefault();
|
||||
?? ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||
var desiredControlReason = directive?.Label
|
||||
?? automation?.Label
|
||||
?? ship.OrderQueue
|
||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => order.Label ?? order.Kind)
|
||||
.FirstOrDefault()
|
||||
?? ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
|
||||
?? (hasBehaviorSource ? "delegated-player-control" : "manual-player-control");
|
||||
|
||||
var assignmentChanged = !AssignmentsEqual(commander.Assignment, desiredAssignment);
|
||||
@@ -1337,8 +1474,8 @@ internal sealed class PlayerFactionService
|
||||
HomeStationId = directive?.HomeStationId ?? ship.DefaultBehavior.HomeStationId,
|
||||
AreaSystemId = directive?.TargetSystemId ?? directive?.HomeSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId,
|
||||
TargetEntityId = directive?.TargetEntityId,
|
||||
PreferredItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.PreferredItemId,
|
||||
PreferredNodeId = directive?.PreferredNodeId ?? ship.DefaultBehavior.PreferredNodeId,
|
||||
ItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.ItemId,
|
||||
PreferredAnchorId = directive?.PreferredAnchorId ?? ship.DefaultBehavior.PreferredAnchorId,
|
||||
PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId,
|
||||
PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId,
|
||||
TargetPosition = directive?.TargetPosition,
|
||||
@@ -1358,7 +1495,7 @@ internal sealed class PlayerFactionService
|
||||
private static bool ReconcileDirectiveOrders(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation)
|
||||
{
|
||||
var aiOrderId = directive is null ? null : $"player-order-{directive.Id}";
|
||||
var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("player-order-", StringComparison.Ordinal) && order.Id != aiOrderId) > 0;
|
||||
var changed = ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("player-order-", StringComparison.Ordinal) && order.Id != aiOrderId) > 0;
|
||||
|
||||
var useOrders = directive?.UseOrders ?? automation?.UseOrders ?? false;
|
||||
if (!useOrders || directive is null || string.IsNullOrWhiteSpace(directive.StagingOrderKind))
|
||||
@@ -1370,6 +1507,8 @@ internal sealed class PlayerFactionService
|
||||
{
|
||||
Id = aiOrderId!,
|
||||
Kind = directive.StagingOrderKind!,
|
||||
SourceKind = ShipOrderSourceKind.Player,
|
||||
SourceId = directive.Id,
|
||||
Priority = Math.Max(0, directive.Priority),
|
||||
InterruptCurrentPlan = true,
|
||||
Label = directive.Label,
|
||||
@@ -1379,7 +1518,7 @@ internal sealed class PlayerFactionService
|
||||
SourceStationId = directive.SourceStationId ?? directive.HomeStationId,
|
||||
DestinationStationId = directive.DestinationStationId,
|
||||
ItemId = directive.ItemId,
|
||||
NodeId = directive.PreferredNodeId,
|
||||
AnchorId = directive.PreferredAnchorId,
|
||||
ConstructionSiteId = directive.PreferredConstructionSiteId,
|
||||
ModuleId = directive.PreferredModuleId,
|
||||
WaitSeconds = directive.WaitSeconds,
|
||||
@@ -1388,17 +1527,16 @@ internal sealed class PlayerFactionService
|
||||
KnownStationsOnly = directive.KnownStationsOnly,
|
||||
};
|
||||
|
||||
var existing = ship.OrderQueue.FirstOrDefault(order => order.Id == aiOrderId);
|
||||
var existing = ship.OrderQueue.FindById(aiOrderId!);
|
||||
if (existing is null)
|
||||
{
|
||||
ship.OrderQueue.Add(desiredOrder);
|
||||
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ShipOrdersEqual(existing, desiredOrder))
|
||||
{
|
||||
ship.OrderQueue.Remove(existing);
|
||||
ship.OrderQueue.Add(desiredOrder);
|
||||
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1442,8 +1580,8 @@ internal sealed class PlayerFactionService
|
||||
target.HomeStationId = source.HomeStationId;
|
||||
target.AreaSystemId = source.AreaSystemId;
|
||||
target.TargetEntityId = source.TargetEntityId;
|
||||
target.PreferredItemId = source.PreferredItemId;
|
||||
target.PreferredNodeId = source.PreferredNodeId;
|
||||
target.ItemId = source.ItemId;
|
||||
target.PreferredAnchorId = source.PreferredAnchorId;
|
||||
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
|
||||
target.PreferredModuleId = source.PreferredModuleId;
|
||||
target.TargetPosition = source.TargetPosition;
|
||||
@@ -1463,8 +1601,8 @@ internal sealed class PlayerFactionService
|
||||
&& string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.PreferredItemId, right.PreferredItemId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.PreferredAnchorId, right.PreferredAnchorId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
|
||||
&& Nullable.Equals(left.TargetPosition, right.TargetPosition)
|
||||
@@ -1485,7 +1623,7 @@ internal sealed class PlayerFactionService
|
||||
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
|
||||
&& left.WaitSeconds.Equals(right.WaitSeconds)
|
||||
@@ -1496,6 +1634,8 @@ internal sealed class PlayerFactionService
|
||||
private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) =>
|
||||
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
|
||||
&& string.Equals(left.Kind, right.Kind, StringComparison.Ordinal)
|
||||
&& left.SourceKind == right.SourceKind
|
||||
&& string.Equals(left.SourceId, right.SourceId, StringComparison.Ordinal)
|
||||
&& left.Priority == right.Priority
|
||||
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
|
||||
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
|
||||
@@ -1505,7 +1645,7 @@ internal sealed class PlayerFactionService
|
||||
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
|
||||
&& left.WaitSeconds.Equals(right.WaitSeconds)
|
||||
@@ -1550,7 +1690,7 @@ internal sealed class PlayerFactionService
|
||||
SourceStationId = template.SourceStationId,
|
||||
DestinationStationId = template.DestinationStationId,
|
||||
ItemId = template.ItemId,
|
||||
NodeId = template.NodeId,
|
||||
AnchorId = template.AnchorId,
|
||||
ConstructionSiteId = template.ConstructionSiteId,
|
||||
ModuleId = template.ModuleId,
|
||||
WaitSeconds = template.WaitSeconds,
|
||||
@@ -1711,7 +1851,7 @@ internal sealed class PlayerFactionService
|
||||
{
|
||||
program.CurrentCount = world.Ships.Count(ship =>
|
||||
ship.FactionId == player.SovereignFactionId &&
|
||||
string.Equals(ship.Definition.Kind, program.TargetShipKind, StringComparison.Ordinal));
|
||||
string.Equals(GetShipCategory(ship.Definition), program.TargetShipKind, StringComparison.Ordinal));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2108,7 +2248,7 @@ internal sealed class PlayerFactionService
|
||||
{
|
||||
var available = world.Ships.Count(ship =>
|
||||
ship.FactionId == player.SovereignFactionId &&
|
||||
string.Equals(ship.Definition.Kind, policy.ShipKind, StringComparison.Ordinal));
|
||||
string.Equals(GetShipCategory(ship.Definition), policy.ShipKind, StringComparison.Ordinal));
|
||||
if (available < policy.DesiredAssetCount)
|
||||
{
|
||||
player.Alerts.Add(new PlayerAlertRuntime
|
||||
|
||||
29
apps/backend/PlayerFaction/Simulation/PlayerStateStore.cs
Normal file
29
apps/backend/PlayerFaction/Simulation/PlayerStateStore.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace SpaceGame.Api.PlayerFaction.Simulation;
|
||||
|
||||
public sealed class PlayerStateStore : IPlayerStateStore
|
||||
{
|
||||
private readonly Dictionary<string, PlayerFactionRuntime> _playerFactions = new(StringComparer.Ordinal);
|
||||
|
||||
public bool TryGetPlayerFaction(string playerId, out PlayerFactionRuntime playerFaction) =>
|
||||
_playerFactions.TryGetValue(playerId, out playerFaction!);
|
||||
|
||||
public PlayerFactionRuntime GetOrAddPlayerFaction(string playerId, Func<PlayerFactionRuntime> factory)
|
||||
{
|
||||
if (_playerFactions.TryGetValue(playerId, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = factory();
|
||||
_playerFactions[playerId] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<PlayerFactionRuntime> GetPlayerFactions() =>
|
||||
_playerFactions.Values.ToList();
|
||||
|
||||
public IReadOnlyDictionary<string, PlayerFactionRuntime> GetPlayerFactionsByPlayerId() =>
|
||||
new Dictionary<string, PlayerFactionRuntime>(_playerFactions, StringComparer.Ordinal);
|
||||
|
||||
public void Clear() => _playerFactions.Clear();
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
using System.Text;
|
||||
using FastEndpoints;
|
||||
using FastEndpoints.Swagger;
|
||||
using SpaceGame.Api.Universe.Simulation;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Npgsql;
|
||||
using SpaceGame.Api.Universe.Bootstrap;
|
||||
|
||||
const string StartupScenarioPath = "scenarios/minimal.json";
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -14,17 +20,124 @@ builder.Services.AddCors((options) =>
|
||||
.AllowAnyOrigin();
|
||||
});
|
||||
});
|
||||
builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration"));
|
||||
builder.Services
|
||||
.AddOptions<StaticDataOptions>()
|
||||
.Bind(builder.Configuration.GetSection("StaticData"))
|
||||
.Validate(options => !string.IsNullOrWhiteSpace(options.DataRoot), "StaticData:DataRoot must be configured.")
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
if (Path.IsPathRooted(options.DataRoot))
|
||||
{
|
||||
options.DataRoot = Path.GetFullPath(options.DataRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
var candidatePaths = new[]
|
||||
{
|
||||
Path.GetFullPath(options.DataRoot),
|
||||
Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, options.DataRoot)),
|
||||
Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "..", options.DataRoot)),
|
||||
};
|
||||
|
||||
var resolvedPath = candidatePaths.FirstOrDefault(Directory.Exists);
|
||||
if (resolvedPath is null)
|
||||
{
|
||||
throw new InvalidOperationException($"StaticData:DataRoot '{options.DataRoot}' could not be resolved to an existing directory.");
|
||||
}
|
||||
|
||||
options.DataRoot = resolvedPath;
|
||||
})
|
||||
.ValidateOnStart();
|
||||
builder.Services.Configure<BalanceOptions>(builder.Configuration.GetSection("Balance"));
|
||||
builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
|
||||
builder.Services.AddFastEndpoints();
|
||||
builder.Services.SwaggerDocument();
|
||||
builder.Services
|
||||
.AddOptions<AuthOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Auth"))
|
||||
.Validate(options => !string.IsNullOrWhiteSpace(options.ConnectionString), "Auth:ConnectionString must be configured.")
|
||||
.ValidateOnStart();
|
||||
builder.Services
|
||||
.AddOptions<JwtOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Jwt"))
|
||||
.Validate(options => !string.IsNullOrWhiteSpace(options.SigningKey), "Jwt:SigningKey must be configured.")
|
||||
.ValidateOnStart();
|
||||
|
||||
var jwtOptions = builder.Configuration.GetSection("Jwt").Get<JwtOptions>() ?? new JwtOptions();
|
||||
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey));
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidateLifetime = true,
|
||||
ValidIssuer = jwtOptions.Issuer,
|
||||
ValidAudience = jwtOptions.Audience,
|
||||
IssuerSigningKey = signingKey,
|
||||
ClockSkew = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
});
|
||||
builder.Services
|
||||
.AddAuthorizationBuilder()
|
||||
.AddPolicy(AuthPolicyNames.AdminAccess, policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireRole(AuthRoleNames.Admin);
|
||||
})
|
||||
.AddPolicy(AuthPolicyNames.GmAccess, policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireRole(AuthRoleNames.Gm);
|
||||
});
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
builder.Services.AddSingleton<IBalanceService, BalanceService>();
|
||||
builder.Services.AddSingleton<AppVersionService>();
|
||||
builder.Services.AddSingleton<IPlayerStateStore, PlayerStateStore>();
|
||||
builder.Services.AddSingleton<PlayerFactionProjectionService>();
|
||||
builder.Services.AddSingleton<LocalPasswordHasher>();
|
||||
builder.Services.AddSingleton<RefreshTokenFactory>();
|
||||
builder.Services.AddSingleton<ITokenService, JwtTokenService>();
|
||||
builder.Services.AddSingleton<IPasswordResetDelivery, DevPasswordResetDelivery>();
|
||||
builder.Services.AddSingleton<IPlayerIdentityResolver, HttpContextPlayerIdentityResolver>();
|
||||
builder.Services.AddSingleton((serviceProvider) =>
|
||||
{
|
||||
var authOptions = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<AuthOptions>>();
|
||||
return new NpgsqlDataSourceBuilder(authOptions.Value.ConnectionString).Build();
|
||||
});
|
||||
builder.Services.AddSingleton<IAuthRepository, PostgresAuthRepository>();
|
||||
builder.Services.AddSingleton<AuthService>();
|
||||
builder.Services.AddSingleton<AuthSchemaInitializer>();
|
||||
builder.Services.AddSingleton<DevAuthSeeder>();
|
||||
builder.Services.AddTransient<SystemGenerationService>();
|
||||
builder.Services.AddTransient<SpatialBuilder>();
|
||||
builder.Services.AddTransient<WorldSeedingService>();
|
||||
builder.Services.AddTransient<ScenarioValidationService>();
|
||||
builder.Services.AddTransient<ScenarioContentBuilder>();
|
||||
builder.Services.AddTransient<ScenarioLoader>();
|
||||
builder.Services.AddTransient<WorldTopologyBuilder>();
|
||||
builder.Services.AddTransient<WorldRuntimeAssembler>();
|
||||
builder.Services.AddTransient<WorldBuilder>();
|
||||
builder.Services.AddSingleton<IStaticDataProvider, StaticDataProvider>();
|
||||
builder.Services.AddSingleton<WorldService>();
|
||||
builder.Services.AddSingleton<TelemetryService>();
|
||||
builder.Services.AddHostedService<SimulationHostedService>();
|
||||
|
||||
builder.Services.AddFastEndpoints();
|
||||
builder.Services.SwaggerDocument();
|
||||
|
||||
var app = builder.Build();
|
||||
await app.Services.GetRequiredService<AuthSchemaInitializer>().EnsureSchemaAsync(CancellationToken.None);
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
await app.Services.GetRequiredService<DevAuthSeeder>().SeedAsync(CancellationToken.None);
|
||||
app.Services.GetRequiredService<WorldService>().LoadFromScenario(StartupScenarioPath);
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseFastEndpoints();
|
||||
app.UseSwaggerGen();
|
||||
|
||||
|
||||
3
apps/backend/Properties/AssemblyInfo.cs
Normal file
3
apps/backend/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("SpaceGame.Api.Tests")]
|
||||
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(),
|
||||
};
|
||||
}
|
||||
120
apps/backend/Shared/Runtime/ShipAutomationCatalog.cs
Normal file
120
apps/backend/Shared/Runtime/ShipAutomationCatalog.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
namespace SpaceGame.Api.Shared.Runtime;
|
||||
|
||||
public enum ShipAutomationSupportStatus
|
||||
{
|
||||
Supported,
|
||||
PartiallySupported,
|
||||
NotSupported,
|
||||
InternalOnly,
|
||||
}
|
||||
|
||||
public sealed record ShipBehaviorDefinition(
|
||||
string Id,
|
||||
string Label,
|
||||
string Category,
|
||||
ShipAutomationSupportStatus SupportStatus,
|
||||
string Notes);
|
||||
|
||||
public sealed record ShipOrderDefinition(
|
||||
string Id,
|
||||
string Label,
|
||||
string Category,
|
||||
ShipAutomationSupportStatus SupportStatus,
|
||||
string Notes);
|
||||
|
||||
public static class ShipBehaviorKinds
|
||||
{
|
||||
public const string Patrol = "patrol";
|
||||
public const string Police = "police";
|
||||
public const string ProtectPosition = "protect-position";
|
||||
public const string ProtectShip = "protect-ship";
|
||||
public const string ProtectStation = "protect-station";
|
||||
|
||||
public const string LocalAutoMine = "local-auto-mine";
|
||||
public const string AdvancedAutoMine = "advanced-auto-mine";
|
||||
public const string ExpertAutoMine = "expert-auto-mine";
|
||||
|
||||
public const string DockAtStation = "dock-at-station";
|
||||
public const string Move = "move";
|
||||
public const string FlyToObject = "fly-to-object";
|
||||
public const string FollowShip = "follow-ship";
|
||||
public const string HoldPosition = "hold-position";
|
||||
|
||||
public const string AutoSalvage = "auto-salvage";
|
||||
|
||||
public const string LocalAutoTrade = "local-auto-trade";
|
||||
public const string AdvancedAutoTrade = "advanced-auto-trade";
|
||||
public const string FillShortages = "fill-shortages";
|
||||
public const string FindBuildTasks = "find-build-tasks";
|
||||
public const string RevisitKnownStations = "revisit-known-stations";
|
||||
public const string SupplyFleet = "supply-fleet";
|
||||
|
||||
public const string RepeatOrders = "repeat-orders";
|
||||
|
||||
public const string AttackTarget = "attack-target";
|
||||
public const string ConstructStation = "construct-station";
|
||||
public const string Idle = "idle";
|
||||
}
|
||||
|
||||
public static class ShipAutomationCatalog
|
||||
{
|
||||
public static readonly IReadOnlyList<ShipBehaviorDefinition> Behaviors =
|
||||
[
|
||||
new(ShipBehaviorKinds.Patrol, "Patrol", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or move orders from the active patrol context."),
|
||||
new(ShipBehaviorKinds.Police, "Police", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or follow-ship inspection orders from the active policing context."),
|
||||
new(ShipBehaviorKinds.ProtectPosition, "Protect Position", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or move orders from the defended position context."),
|
||||
new(ShipBehaviorKinds.ProtectShip, "Protect Ship", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or follow-ship escort orders from the guarded ship context."),
|
||||
new(ShipBehaviorKinds.ProtectStation, "Protect Station", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or move guard orders from the defended station context."),
|
||||
|
||||
new(ShipBehaviorKinds.LocalAutoMine, "Local AutoMine", "Mining", ShipAutomationSupportStatus.PartiallySupported, "Queue-backed for solo mining; broader order-generation model still in progress."),
|
||||
new(ShipBehaviorKinds.AdvancedAutoMine, "Advanced AutoMine", "Mining", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal mine-and-deliver run order from the current mining opportunity."),
|
||||
new(ShipBehaviorKinds.ExpertAutoMine, "Expert AutoMine", "Mining", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal mine-and-deliver run order from the current mining opportunity."),
|
||||
|
||||
new(ShipBehaviorKinds.DockAtStation, "Dock At Station", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
|
||||
new(ShipBehaviorKinds.Move, "Fly To Position", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
|
||||
new(ShipBehaviorKinds.FlyToObject, "Fly To Object", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
|
||||
new(ShipBehaviorKinds.FollowShip, "Follow Ship", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
|
||||
new(ShipBehaviorKinds.HoldPosition, "Hold Position", "Navigation", ShipAutomationSupportStatus.Supported, "Default baseline behavior; queue-backed behavior order is active."),
|
||||
|
||||
new(ShipBehaviorKinds.AutoSalvage, "AutoSalvage", "Salvage", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal salvage run order for wreck recovery."),
|
||||
|
||||
new(ShipBehaviorKinds.LocalAutoTrade, "Local AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-at-station orders from the current market context."),
|
||||
new(ShipBehaviorKinds.AdvancedAutoTrade, "Advanced AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route orders from the current market context."),
|
||||
new(ShipBehaviorKinds.FillShortages, "Fill Shortages", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route orders from the current market context."),
|
||||
new(ShipBehaviorKinds.FindBuildTasks, "Find Build Tasks", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing construction-support trade routes from the current market context."),
|
||||
new(ShipBehaviorKinds.RevisitKnownStations, "Revisit Known Stations", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-at-station orders from known-station context."),
|
||||
new(ShipBehaviorKinds.SupplyFleet, "Supply Fleet", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal fleet supply run order."),
|
||||
|
||||
new(ShipBehaviorKinds.RepeatOrders, "Repeat Orders", "Advanced", ShipAutomationSupportStatus.Supported, "Queue-backed behavior generating the current repeat-order template at the bottom of the stack."),
|
||||
|
||||
new(ShipBehaviorKinds.AttackTarget, "Attack Target", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal gameplay behavior used by current combat/control systems, not an X4 exposed default behavior."),
|
||||
new(ShipBehaviorKinds.ConstructStation, "Construct Station", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal gameplay behavior used by construction ships."),
|
||||
new(ShipBehaviorKinds.Idle, "Idle", "Internal", ShipAutomationSupportStatus.InternalOnly, "Legacy fallback/internal placeholder; not intended as an exposed player behavior."),
|
||||
];
|
||||
|
||||
public static readonly IReadOnlyList<ShipOrderDefinition> Orders =
|
||||
[
|
||||
new(ShipOrderKinds.DockAtStation, "Dock At Station", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
|
||||
new(ShipOrderKinds.Move, "Fly To", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order completes on arrival."),
|
||||
new(ShipOrderKinds.FlyToObject, "Fly To Object", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
|
||||
new(ShipOrderKinds.FollowShip, "Follow Ship", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
|
||||
new(ShipOrderKinds.HoldPosition, "Hold Position", "Navigation", ShipAutomationSupportStatus.Supported, "Direct order supported in backend."),
|
||||
|
||||
new(ShipOrderKinds.AttackTarget, "Attack Target", "Combat", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
|
||||
|
||||
new(ShipOrderKinds.MineAndDeliver, "Mine Resource", "Mining", ShipAutomationSupportStatus.Supported, "Direct order mines the requested ware in the requested system until cargo is full."),
|
||||
|
||||
new(ShipOrderKinds.TradeRoute, "Trade Route", "Trade", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
|
||||
|
||||
new(ShipOrderKinds.BuildAtSite, "Build At Site", "Construction", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
|
||||
|
||||
new(ShipOrderKinds.RepeatOrders, "Repeat Orders", "Advanced", ShipAutomationSupportStatus.PartiallySupported, "Represented today as a behavior plus templates, not a normal one-shot direct order."),
|
||||
|
||||
new(ShipOrderKinds.MineLocal, "Mine Local", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for Local AutoMine."),
|
||||
new(ShipOrderKinds.MineAndDeliverRun, "Mine And Deliver Run", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for Advanced/Expert AutoMine."),
|
||||
new(ShipOrderKinds.SellMinedCargo, "Sell Mined Cargo", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for Local AutoMine."),
|
||||
new(ShipOrderKinds.SupplyFleetRun, "Supply Fleet Run", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for Supply Fleet."),
|
||||
new(ShipOrderKinds.SalvageRun, "Salvage Run", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal queue-backed behavior order for AutoSalvage."),
|
||||
new(ShipOrderKinds.Flee, "Flee", "Internal", ShipAutomationSupportStatus.InternalOnly, "Internal emergency order."),
|
||||
];
|
||||
}
|
||||
@@ -6,6 +6,7 @@ public enum SpatialNodeKind
|
||||
Planet,
|
||||
Moon,
|
||||
LagrangePoint,
|
||||
ResourceNode,
|
||||
}
|
||||
|
||||
public enum WorkStatus
|
||||
@@ -28,17 +29,14 @@ public enum OrderStatus
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
public enum AiPlanStatus
|
||||
public enum ShipOrderSourceKind
|
||||
{
|
||||
Planned,
|
||||
Running,
|
||||
Blocked,
|
||||
Completed,
|
||||
Failed,
|
||||
Interrupted,
|
||||
Player,
|
||||
Behavior,
|
||||
Commander,
|
||||
}
|
||||
|
||||
public enum AiPlanStepStatus
|
||||
public enum AiPlanStatus
|
||||
{
|
||||
Planned,
|
||||
Running,
|
||||
@@ -157,8 +155,6 @@ public static class ShipOrderKinds
|
||||
{
|
||||
public const string Move = "move";
|
||||
public const string DockAtStation = "dock-at-station";
|
||||
public const string DockAndWait = "dock-and-wait";
|
||||
public const string FlyAndWait = "fly-and-wait";
|
||||
public const string FlyToObject = "fly-to-object";
|
||||
public const string FollowShip = "follow-ship";
|
||||
public const string TradeRoute = "trade-route";
|
||||
@@ -166,6 +162,11 @@ public static class ShipOrderKinds
|
||||
public const string BuildAtSite = "build-at-site";
|
||||
public const string AttackTarget = "attack-target";
|
||||
public const string HoldPosition = "hold-position";
|
||||
public const string MineLocal = "mine-local";
|
||||
public const string MineAndDeliverRun = "mine-and-deliver-run";
|
||||
public const string SellMinedCargo = "sell-mined-cargo";
|
||||
public const string SupplyFleetRun = "supply-fleet-run";
|
||||
public const string SalvageRun = "salvage-run";
|
||||
public const string RepeatOrders = "repeat-orders";
|
||||
public const string Flee = "flee";
|
||||
}
|
||||
@@ -274,6 +275,7 @@ public static class SimulationEnumMappings
|
||||
SpatialNodeKind.Planet => "planet",
|
||||
SpatialNodeKind.Moon => "moon",
|
||||
SpatialNodeKind.LagrangePoint => "lagrange-point",
|
||||
SpatialNodeKind.ResourceNode => "resource-node",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||
};
|
||||
|
||||
@@ -310,17 +312,6 @@ public static class SimulationEnumMappings
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this AiPlanStepStatus status) => status switch
|
||||
{
|
||||
AiPlanStepStatus.Planned => "planned",
|
||||
AiPlanStepStatus.Running => "running",
|
||||
AiPlanStepStatus.Blocked => "blocked",
|
||||
AiPlanStepStatus.Completed => "completed",
|
||||
AiPlanStepStatus.Failed => "failed",
|
||||
AiPlanStepStatus.Interrupted => "interrupted",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this AiPlanSourceKind kind) => kind switch
|
||||
{
|
||||
AiPlanSourceKind.Rule => "rule",
|
||||
@@ -329,6 +320,14 @@ public static class SimulationEnumMappings
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this ShipOrderSourceKind kind) => kind switch
|
||||
{
|
||||
ShipOrderSourceKind.Player => "player",
|
||||
ShipOrderSourceKind.Behavior => "behavior",
|
||||
ShipOrderSourceKind.Commander => "commander",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this ShipState state) => state switch
|
||||
{
|
||||
ShipState.Idle => "idle",
|
||||
|
||||
@@ -3,8 +3,56 @@ namespace SpaceGame.Api.Shared.Runtime;
|
||||
|
||||
internal static class SimulationRuntimeSupport
|
||||
{
|
||||
internal static bool HasShipCapabilities(ShipDefinition definition, params string[] capabilities) =>
|
||||
capabilities.All(cap => definition.Capabilities.Contains(cap, StringComparer.Ordinal));
|
||||
internal static bool CanWarp(ShipDefinition definition) =>
|
||||
definition.Engines.Count > 0;
|
||||
|
||||
internal static bool CanFtl(ShipDefinition definition) =>
|
||||
definition.Engines.Count > 0;
|
||||
|
||||
internal static bool IsMiningShip(ShipDefinition definition) =>
|
||||
definition.Type is ShipType.Miner or ShipType.LargeMiner;
|
||||
|
||||
internal static bool IsTransportShip(ShipDefinition definition) =>
|
||||
definition.Type is ShipType.Freighter or ShipType.Transporter or ShipType.Courier or ShipType.Resupplier;
|
||||
|
||||
internal static bool IsConstructionShip(ShipDefinition definition) =>
|
||||
definition.Type == ShipType.Builder;
|
||||
|
||||
internal static bool IsMilitaryShip(ShipDefinition definition) =>
|
||||
definition.Type is ShipType.Fighter
|
||||
or ShipType.HeavyFighter
|
||||
or ShipType.Destroyer
|
||||
or ShipType.Bomber
|
||||
or ShipType.Frigate
|
||||
or ShipType.Interceptor
|
||||
or ShipType.Corvette
|
||||
or ShipType.Battleship
|
||||
or ShipType.Gunboat;
|
||||
|
||||
internal static string? GetShipCategory(ShipDefinition definition)
|
||||
{
|
||||
if (IsMilitaryShip(definition))
|
||||
{
|
||||
return "military";
|
||||
}
|
||||
|
||||
if (IsConstructionShip(definition))
|
||||
{
|
||||
return "construction";
|
||||
}
|
||||
|
||||
if (IsTransportShip(definition))
|
||||
{
|
||||
return "transport";
|
||||
}
|
||||
|
||||
if (IsMiningShip(definition))
|
||||
{
|
||||
return "mining";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static int CountStationModules(StationRuntime station, ModuleType moduleType) =>
|
||||
station.Modules.Count(module => module.ModuleType == moduleType);
|
||||
@@ -131,13 +179,13 @@ internal static class SimulationRuntimeSupport
|
||||
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
|
||||
|
||||
internal static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node, SimulationWorld world) =>
|
||||
HasShipCapabilities(ship.Definition, "mining")
|
||||
IsMiningShip(ship.Definition)
|
||||
&& world.ItemDefinitions.TryGetValue(node.ItemId, out var item)
|
||||
&& item.CargoKind is not null
|
||||
&& item.CargoKind == ship.Definition.CargoKind;
|
||||
&& ship.Definition.SupportsCargoKind(item.CargoKind.Value);
|
||||
|
||||
internal static bool CanBuildClaimBeacon(ShipRuntime ship) =>
|
||||
string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal);
|
||||
IsMilitaryShip(ship.Definition);
|
||||
|
||||
internal static float ComputeWorkforceRatio(float population, float workforceRequired)
|
||||
{
|
||||
|
||||
@@ -7,6 +7,22 @@ public static class SimulationUnits
|
||||
|
||||
public static float AuToKilometers(float au) => au * KilometersPerAu;
|
||||
|
||||
public static float KilometersToMeters(float kilometers) => kilometers * MetersPerKilometer;
|
||||
|
||||
public static float MetersToKilometers(float meters) => meters / MetersPerKilometer;
|
||||
|
||||
public static Vector3 KilometersToMeters(Vector3 kilometers) =>
|
||||
new(
|
||||
KilometersToMeters(kilometers.X),
|
||||
KilometersToMeters(kilometers.Y),
|
||||
KilometersToMeters(kilometers.Z));
|
||||
|
||||
public static Vector3 MetersToKilometers(Vector3 meters) =>
|
||||
new(
|
||||
MetersToKilometers(meters.X),
|
||||
MetersToKilometers(meters.Y),
|
||||
MetersToKilometers(meters.Z));
|
||||
|
||||
public static float AuPerSecondToKilometersPerSecond(float auPerSecond) =>
|
||||
auPerSecond * KilometersPerAu;
|
||||
|
||||
|
||||
762
apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs
Normal file
762
apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs
Normal file
@@ -0,0 +1,762 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private void SyncBehaviorOrders(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var desiredOrder = BuildManagedBehaviorOrder(world, ship);
|
||||
ship.OrderQueue.RemoveWhere(order =>
|
||||
order.SourceKind == ShipOrderSourceKind.Behavior
|
||||
&& order.Id.StartsWith("behavior-", StringComparison.Ordinal)
|
||||
&& (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)));
|
||||
|
||||
if (desiredOrder is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = ship.OrderQueue.FindById(desiredOrder.Id);
|
||||
if (existing is null)
|
||||
{
|
||||
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ManagedOrdersEqual(existing, desiredOrder))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||
}
|
||||
|
||||
private ShipOrderRuntime? BuildManagedBehaviorOrder(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var assignment = ResolveAssignment(world, ship);
|
||||
var behaviorKind = assignment?.BehaviorKind ?? ship.DefaultBehavior.Kind;
|
||||
var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
|
||||
|
||||
if (string.Equals(behaviorKind, HoldPosition, StringComparison.Ordinal))
|
||||
{
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-hold-position",
|
||||
Kind = ShipOrderKinds.HoldPosition,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = "Hold position",
|
||||
TargetSystemId = systemId,
|
||||
TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position,
|
||||
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
||||
Radius = ship.DefaultBehavior.Radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, DockAtStation, StringComparison.Ordinal))
|
||||
{
|
||||
var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? ship.DefaultBehavior.HomeStationId);
|
||||
if (station is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "station-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-dock-at-station",
|
||||
Kind = ShipOrderKinds.DockAtStation,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = $"Dock at {station.Label}",
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
DestinationStationId = station.Id,
|
||||
Radius = ship.DefaultBehavior.Radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, Move, StringComparison.Ordinal))
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-move",
|
||||
Kind = ShipOrderKinds.Move,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = "Fly to position",
|
||||
TargetSystemId = systemId,
|
||||
TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position,
|
||||
Radius = ship.DefaultBehavior.Radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, FollowShip, StringComparison.Ordinal))
|
||||
{
|
||||
var targetShip = world.Ships.FirstOrDefault(candidate =>
|
||||
candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId)
|
||||
&& candidate.Health > 0f);
|
||||
if (targetShip is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "target-ship-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-follow-ship",
|
||||
Kind = ShipOrderKinds.FollowShip,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = $"Follow {targetShip.Definition.Name}",
|
||||
TargetEntityId = targetShip.Id,
|
||||
TargetSystemId = targetShip.SystemId,
|
||||
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
||||
Radius = MathF.Max(16f, ship.DefaultBehavior.Radius),
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, FlyToObject, StringComparison.Ordinal))
|
||||
{
|
||||
var targetEntityId = assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId;
|
||||
var target = ResolveObjectTarget(world, targetEntityId);
|
||||
if (target is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "target-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-fly-to-object",
|
||||
Kind = ShipOrderKinds.FlyToObject,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = "Fly to object",
|
||||
TargetEntityId = targetEntityId,
|
||||
TargetSystemId = target.Value.SystemId,
|
||||
TargetPosition = target.Value.Position,
|
||||
WaitSeconds = MathF.Max(1f, ship.DefaultBehavior.WaitSeconds),
|
||||
Radius = MathF.Max(8f, ship.DefaultBehavior.Radius),
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, Patrol, StringComparison.Ordinal))
|
||||
{
|
||||
return BuildManagedPatrolOrder(world, ship, assignment, behaviorKind);
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, AttackTarget, StringComparison.Ordinal))
|
||||
{
|
||||
var targetEntityId = assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId;
|
||||
if (string.IsNullOrWhiteSpace(targetEntityId))
|
||||
{
|
||||
return BuildManagedPatrolOrder(world, ship, assignment, behaviorKind);
|
||||
}
|
||||
|
||||
var target = ResolveObjectTarget(world, targetEntityId);
|
||||
ship.LastAccessFailureReason = target is null ? "target-missing" : null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-attack-target",
|
||||
Kind = ShipOrderKinds.AttackTarget,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = "Attack target",
|
||||
TargetEntityId = targetEntityId,
|
||||
TargetSystemId = target?.SystemId ?? assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId,
|
||||
TargetPosition = target?.Position ?? ship.Position,
|
||||
WaitSeconds = 0f,
|
||||
Radius = 26f,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, ConstructStation, StringComparison.Ordinal))
|
||||
{
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.PreferredConstructionSiteId))
|
||||
?? world.ConstructionSites
|
||||
.Where(candidate => candidate.FactionId == ship.FactionId && candidate.State is ConstructionSiteStateKinds.Active or ConstructionSiteStateKinds.Planned)
|
||||
.OrderBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
if (site is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-construction-site";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ResolveSupportStation(world, ship, site) is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "support-station-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-construct-station",
|
||||
Kind = ShipOrderKinds.BuildAtSite,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = $"Build {site.BlueprintId}",
|
||||
TargetEntityId = site.Id,
|
||||
TargetSystemId = site.SystemId,
|
||||
ConstructionSiteId = site.Id,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, AdvancedAutoMine, StringComparison.Ordinal)
|
||||
|| string.Equals(behaviorKind, ExpertAutoMine, StringComparison.Ordinal))
|
||||
{
|
||||
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
if (homeStation is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-home-station";
|
||||
return null;
|
||||
}
|
||||
|
||||
var opportunity = SelectMiningOpportunity(world, ship, homeStation, assignment, behaviorKind);
|
||||
if (opportunity is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-mineable-node";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{behaviorKind}-mine-and-deliver",
|
||||
Kind = ShipOrderKinds.MineAndDeliverRun,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = opportunity.Summary,
|
||||
TargetEntityId = opportunity.Node.Id,
|
||||
TargetSystemId = opportunity.Node.SystemId,
|
||||
DestinationStationId = opportunity.DropOffStation.Id,
|
||||
ItemId = opportunity.Node.ItemId,
|
||||
AnchorId = opportunity.Node.AnchorId,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, ProtectPosition, StringComparison.Ordinal))
|
||||
{
|
||||
var targetSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
|
||||
var targetPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position;
|
||||
var threat = SelectThreatTarget(world, ship, targetSystemId, targetPosition, MathF.Max(90f, ship.DefaultBehavior.Radius));
|
||||
if (threat is not null)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedAttackOrder(ship, behaviorKind, "Protect position", threat.EntityId, threat.SystemId, threat.Position);
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedMoveOrder(
|
||||
ship,
|
||||
behaviorKind,
|
||||
"Protect position",
|
||||
targetSystemId,
|
||||
targetPosition,
|
||||
MathF.Max(6f, ship.DefaultBehavior.Radius));
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, ProtectShip, StringComparison.Ordinal))
|
||||
{
|
||||
var guardTarget = world.Ships.FirstOrDefault(candidate =>
|
||||
candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId)
|
||||
&& candidate.Health > 0f);
|
||||
if (guardTarget is null)
|
||||
{
|
||||
return BuildManagedPatrolOrder(world, ship, assignment, Patrol);
|
||||
}
|
||||
|
||||
var threat = SelectThreatTarget(
|
||||
world,
|
||||
ship,
|
||||
guardTarget.SystemId,
|
||||
guardTarget.Position,
|
||||
MathF.Max(90f, ship.DefaultBehavior.Radius),
|
||||
excludeEntityId: guardTarget.Id);
|
||||
if (threat is not null)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedAttackOrder(ship, behaviorKind, $"Protect {guardTarget.Definition.Name}", threat.EntityId, threat.SystemId, threat.Position);
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedFollowShipOrder(
|
||||
ship,
|
||||
behaviorKind,
|
||||
$"Escort {guardTarget.Definition.Name}",
|
||||
guardTarget,
|
||||
MathF.Max(18f, ship.DefaultBehavior.Radius * 0.5f),
|
||||
MathF.Max(2f, ship.DefaultBehavior.WaitSeconds));
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, ProtectStation, StringComparison.Ordinal))
|
||||
{
|
||||
var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
if (station is null)
|
||||
{
|
||||
return BuildManagedPatrolOrder(world, ship, assignment, Patrol);
|
||||
}
|
||||
|
||||
var threat = SelectThreatTarget(world, ship, station.SystemId, station.Position, MathF.Max(station.Radius + 80f, ship.DefaultBehavior.Radius));
|
||||
if (threat is not null)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedAttackOrder(ship, behaviorKind, $"Protect {station.Label}", threat.EntityId, threat.SystemId, threat.Position);
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedMoveOrder(
|
||||
ship,
|
||||
behaviorKind,
|
||||
$"Guard {station.Label}",
|
||||
station.SystemId,
|
||||
GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)),
|
||||
MathF.Max(6f, ship.DefaultBehavior.Radius));
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, Police, StringComparison.Ordinal))
|
||||
{
|
||||
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
var policeSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? homeStation?.SystemId ?? ship.SystemId;
|
||||
var areaPosition = homeStation?.Position ?? ship.DefaultBehavior.TargetPosition ?? ship.Position;
|
||||
var contact = SelectPoliceContact(world, ship, policeSystemId, areaPosition, MathF.Max(80f, ship.DefaultBehavior.Radius));
|
||||
if (contact is null)
|
||||
{
|
||||
return BuildManagedPatrolOrder(world, ship, assignment, Patrol);
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return contact.Engage
|
||||
? CreateManagedAttackOrder(ship, behaviorKind, "Police engage", contact.EntityId, contact.SystemId, contact.Position)
|
||||
: CreateManagedFollowTargetOrder(ship, behaviorKind, "Police inspect", contact.EntityId, contact.SystemId, contact.Position, MathF.Max(14f, ship.DefaultBehavior.Radius * 0.5f), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds));
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, LocalAutoTrade, StringComparison.Ordinal)
|
||||
|| string.Equals(behaviorKind, AdvancedAutoTrade, StringComparison.Ordinal)
|
||||
|| string.Equals(behaviorKind, FillShortages, StringComparison.Ordinal)
|
||||
|| string.Equals(behaviorKind, FindBuildTasks, StringComparison.Ordinal)
|
||||
|| string.Equals(behaviorKind, RevisitKnownStations, StringComparison.Ordinal))
|
||||
{
|
||||
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
var route = SelectTradeRoute(world, ship, homeStation, behaviorKind, ship.DefaultBehavior.KnownStationsOnly);
|
||||
if (route is not null)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedTradeRouteOrder(ship, behaviorKind, route);
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, RevisitKnownStations, StringComparison.Ordinal)
|
||||
&& SelectKnownStationVisit(world, ship, homeStation) is { } visitStation)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedDockAtStationOrder(ship, behaviorKind, visitStation, $"Revisit {visitStation.Label}");
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = "no-trade-route";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, SupplyFleet, StringComparison.Ordinal))
|
||||
{
|
||||
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
var plan = SelectFleetSupplyPlan(world, ship, homeStation);
|
||||
if (plan is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-fleet-to-supply";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-supply-fleet",
|
||||
Kind = ShipOrderKinds.SupplyFleetRun,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = plan.Summary,
|
||||
TargetEntityId = plan.TargetShip.Id,
|
||||
TargetSystemId = plan.TargetShip.SystemId,
|
||||
SourceStationId = plan.SourceStation.Id,
|
||||
ItemId = plan.ItemId,
|
||||
Radius = plan.Radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, AutoSalvage, StringComparison.Ordinal))
|
||||
{
|
||||
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
if (homeStation is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-home-station";
|
||||
return null;
|
||||
}
|
||||
|
||||
var salvage = SelectSalvageOpportunity(world, ship, homeStation);
|
||||
if (salvage is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-salvage-target";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-auto-salvage",
|
||||
Kind = ShipOrderKinds.SalvageRun,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = salvage.Summary,
|
||||
TargetEntityId = salvage.Wreck.Id,
|
||||
TargetSystemId = salvage.Wreck.SystemId,
|
||||
TargetPosition = salvage.Wreck.Position,
|
||||
SourceStationId = homeStation.Id,
|
||||
ItemId = salvage.Wreck.ItemId,
|
||||
Radius = MathF.Max(8f, ship.DefaultBehavior.Radius * 0.25f),
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, RepeatOrders, StringComparison.Ordinal))
|
||||
{
|
||||
if (ship.DefaultBehavior.RepeatOrders.Count == 0)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-repeat-orders";
|
||||
return null;
|
||||
}
|
||||
|
||||
var template = ship.DefaultBehavior.RepeatOrders[ship.DefaultBehavior.RepeatIndex % ship.DefaultBehavior.RepeatOrders.Count];
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-repeat-{ship.DefaultBehavior.RepeatIndex}",
|
||||
Kind = template.Kind,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = template.Label,
|
||||
TargetEntityId = template.TargetEntityId,
|
||||
TargetSystemId = template.TargetSystemId,
|
||||
TargetPosition = template.TargetPosition,
|
||||
SourceStationId = template.SourceStationId,
|
||||
DestinationStationId = template.DestinationStationId,
|
||||
ItemId = template.ItemId,
|
||||
AnchorId = template.AnchorId,
|
||||
ConstructionSiteId = template.ConstructionSiteId,
|
||||
ModuleId = template.ModuleId,
|
||||
WaitSeconds = template.WaitSeconds,
|
||||
Radius = template.Radius,
|
||||
MaxSystemRange = template.MaxSystemRange ?? ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = template.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var itemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId;
|
||||
if (string.IsNullOrWhiteSpace(itemId))
|
||||
{
|
||||
ship.LastAccessFailureReason = "missing-item";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (GetShipCargoAmount(ship) >= ship.Definition.GetTotalCargoCapacity() - 0.01f)
|
||||
{
|
||||
var buyer = SelectLocalAutoMineBuyer(world, ship, systemId, itemId);
|
||||
if (buyer is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-suitable-buyer";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-local-auto-mine-sell",
|
||||
Kind = ShipOrderKinds.SellMinedCargo,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = $"Sell {itemId} in {systemId}",
|
||||
TargetEntityId = buyer.Id,
|
||||
TargetSystemId = buyer.SystemId,
|
||||
DestinationStationId = buyer.Id,
|
||||
ItemId = itemId,
|
||||
WaitSeconds = 0f,
|
||||
Radius = 0f,
|
||||
MaxSystemRange = 0,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
var node = SelectLocalMiningNode(world, ship, systemId, itemId, ship.DefaultBehavior.PreferredAnchorId);
|
||||
if (node is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-mineable-node";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-local-auto-mine-mine",
|
||||
Kind = ShipOrderKinds.MineLocal,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = $"Mine {itemId} in {systemId}",
|
||||
TargetEntityId = node.Id,
|
||||
TargetSystemId = node.SystemId,
|
||||
AnchorId = node.AnchorId,
|
||||
ItemId = node.ItemId,
|
||||
WaitSeconds = 0f,
|
||||
Radius = 0f,
|
||||
MaxSystemRange = 0,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ManagedOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) =>
|
||||
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
|
||||
&& string.Equals(left.Kind, right.Kind, StringComparison.Ordinal)
|
||||
&& left.SourceKind == right.SourceKind
|
||||
&& string.Equals(left.SourceId, right.SourceId, StringComparison.Ordinal)
|
||||
&& left.Priority == right.Priority
|
||||
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
|
||||
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
|
||||
&& string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal)
|
||||
&& left.TargetPosition == right.TargetPosition
|
||||
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.AnchorId, right.AnchorId, StringComparison.Ordinal)
|
||||
&& left.WaitSeconds.Equals(right.WaitSeconds)
|
||||
&& left.Radius.Equals(right.Radius)
|
||||
&& left.MaxSystemRange == right.MaxSystemRange
|
||||
&& left.KnownStationsOnly == right.KnownStationsOnly;
|
||||
|
||||
private ShipOrderRuntime BuildManagedPatrolOrder(SimulationWorld world, ShipRuntime ship, CommanderAssignmentRuntime? assignment, string sourceKind)
|
||||
{
|
||||
var patrolSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
|
||||
var protectPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position;
|
||||
var patrolThreat = SelectThreatTarget(world, ship, patrolSystemId, protectPosition, MathF.Max(60f, ship.DefaultBehavior.Radius));
|
||||
if (patrolThreat is not null)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedAttackOrder(ship, sourceKind, "Patrol intercept", patrolThreat.EntityId, patrolThreat.SystemId, patrolThreat.Position, orderIdSuffix: "patrol-attack");
|
||||
}
|
||||
|
||||
Vector3 targetPosition;
|
||||
string targetSystemId;
|
||||
if (ship.DefaultBehavior.PatrolPoints.Count > 0)
|
||||
{
|
||||
var index = ship.DefaultBehavior.PatrolIndex % ship.DefaultBehavior.PatrolPoints.Count;
|
||||
targetPosition = ship.DefaultBehavior.PatrolPoints[index];
|
||||
ship.DefaultBehavior.PatrolIndex = (index + 1) % ship.DefaultBehavior.PatrolPoints.Count;
|
||||
targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
|
||||
}
|
||||
else if (ResolveStation(world, ship.DefaultBehavior.HomeStationId ?? assignment?.HomeStationId) is { } homeStation)
|
||||
{
|
||||
var patrolRadius = homeStation.Radius + 90f;
|
||||
targetPosition = new Vector3(homeStation.Position.X + patrolRadius, homeStation.Position.Y, homeStation.Position.Z);
|
||||
targetSystemId = homeStation.SystemId;
|
||||
}
|
||||
else
|
||||
{
|
||||
targetPosition = ship.Position;
|
||||
targetSystemId = ship.SystemId;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedMoveOrder(ship, sourceKind, "Patrol waypoint", targetSystemId, targetPosition, MathF.Max(6f, ship.DefaultBehavior.Radius), orderIdSuffix: "patrol-move");
|
||||
}
|
||||
|
||||
private static ShipOrderRuntime CreateManagedAttackOrder(
|
||||
ShipRuntime ship,
|
||||
string behaviorKind,
|
||||
string label,
|
||||
string targetEntityId,
|
||||
string targetSystemId,
|
||||
Vector3 targetPosition,
|
||||
string? orderIdSuffix = null) =>
|
||||
new()
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}",
|
||||
Kind = ShipOrderKinds.AttackTarget,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = label,
|
||||
TargetEntityId = targetEntityId,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = targetPosition,
|
||||
WaitSeconds = 0f,
|
||||
Radius = 26f,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
|
||||
private static ShipOrderRuntime CreateManagedTradeRouteOrder(ShipRuntime ship, string behaviorKind, TradeRoutePlan route) =>
|
||||
new()
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{behaviorKind}-trade-route",
|
||||
Kind = ShipOrderKinds.TradeRoute,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = route.Summary,
|
||||
SourceStationId = route.SourceStation.Id,
|
||||
DestinationStationId = route.DestinationStation.Id,
|
||||
ItemId = route.ItemId,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
|
||||
private static ShipOrderRuntime CreateManagedDockAtStationOrder(ShipRuntime ship, string behaviorKind, StationRuntime station, string label) =>
|
||||
new()
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{behaviorKind}-dock-at-station",
|
||||
Kind = ShipOrderKinds.DockAtStation,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = label,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
DestinationStationId = station.Id,
|
||||
Radius = ship.DefaultBehavior.Radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
|
||||
private static ShipOrderRuntime CreateManagedMoveOrder(
|
||||
ShipRuntime ship,
|
||||
string behaviorKind,
|
||||
string label,
|
||||
string targetSystemId,
|
||||
Vector3 targetPosition,
|
||||
float radius,
|
||||
string? orderIdSuffix = null) =>
|
||||
new()
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}",
|
||||
Kind = ShipOrderKinds.Move,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = label,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = targetPosition,
|
||||
Radius = radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
|
||||
private static ShipOrderRuntime CreateManagedFollowShipOrder(
|
||||
ShipRuntime ship,
|
||||
string behaviorKind,
|
||||
string label,
|
||||
ShipRuntime targetShip,
|
||||
float radius,
|
||||
float waitSeconds) =>
|
||||
new()
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{behaviorKind}",
|
||||
Kind = ShipOrderKinds.FollowShip,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = label,
|
||||
TargetEntityId = targetShip.Id,
|
||||
TargetSystemId = targetShip.SystemId,
|
||||
WaitSeconds = waitSeconds,
|
||||
Radius = radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
|
||||
private static ShipOrderRuntime CreateManagedFollowTargetOrder(
|
||||
ShipRuntime ship,
|
||||
string behaviorKind,
|
||||
string label,
|
||||
string targetEntityId,
|
||||
string targetSystemId,
|
||||
Vector3 targetPosition,
|
||||
float radius,
|
||||
float waitSeconds) =>
|
||||
new()
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{behaviorKind}",
|
||||
Kind = ShipOrderKinds.FollowShip,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = label,
|
||||
TargetEntityId = targetEntityId,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = targetPosition,
|
||||
WaitSeconds = waitSeconds,
|
||||
Radius = radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
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);
|
||||
}
|
||||
838
apps/backend/Ships/AI/ShipAiService.Execution.cs
Normal file
838
apps/backend/Ships/AI/ShipAiService.Execution.cs
Normal file
@@ -0,0 +1,838 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
|
||||
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
return subTask.Kind switch
|
||||
{
|
||||
var kind when string.Equals(kind, ShipTaskKinds.Travel, StringComparison.Ordinal) => UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: true),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.FollowTarget, StringComparison.Ordinal) => UpdateFollowSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.Dock, StringComparison.Ordinal) => UpdateDockSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.Undock, StringComparison.Ordinal) => UpdateUndockSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.LoadCargo, StringComparison.Ordinal) => UpdateLoadCargoSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.UnloadCargo, StringComparison.Ordinal) => UpdateUnloadCargoSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.TransferCargoToShip, StringComparison.Ordinal) => UpdateTransferCargoToShipSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.MineNode, StringComparison.Ordinal) => UpdateMineSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.SalvageWreck, StringComparison.Ordinal) => UpdateSalvageSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.DeliverConstruction, StringComparison.Ordinal) => UpdateDeliverConstructionSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.BuildConstructionSite, StringComparison.Ordinal) => UpdateBuildConstructionSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.AttackTarget, StringComparison.Ordinal) => UpdateAttackSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.HoldPosition, StringComparison.Ordinal) => UpdateHoldSubTask(ship, subTask, deltaSeconds),
|
||||
_ => SubTaskOutcome.Failed,
|
||||
};
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateHoldSubTask(ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
ship.State = ShipState.HoldingPosition;
|
||||
ship.TargetPosition = subTask.TargetPosition ?? ship.Position;
|
||||
ship.Position = ship.Position.MoveToward(ship.TargetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(ship.TargetPosition)));
|
||||
return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.1f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateFollowSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f);
|
||||
if (targetShip is null)
|
||||
{
|
||||
subTask.BlockingReason = "follow-target-missing";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 16f));
|
||||
subTask.TargetSystemId = targetShip.SystemId;
|
||||
subTask.TargetPosition = desiredPosition;
|
||||
subTask.BlockingReason = null;
|
||||
if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f))
|
||||
{
|
||||
return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false);
|
||||
}
|
||||
|
||||
ship.State = ShipState.HoldingPosition;
|
||||
ship.TargetPosition = desiredPosition;
|
||||
ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition)));
|
||||
return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.5f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateTravelSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds, bool completeOnArrival)
|
||||
{
|
||||
if (subTask.TargetPosition is null || subTask.TargetSystemId is null)
|
||||
{
|
||||
subTask.BlockingReason = "travel-target-missing";
|
||||
ship.State = ShipState.Blocked;
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var targetPosition = ResolveCurrentTargetPosition(world, subTask);
|
||||
var targetAnchor = ResolveTravelTargetAnchor(world, subTask, targetPosition);
|
||||
ship.TargetPosition = targetPosition;
|
||||
|
||||
if (ship.SystemId != subTask.TargetSystemId)
|
||||
{
|
||||
if (!CanFtl(ship.Definition))
|
||||
{
|
||||
subTask.BlockingReason = "ftl-unavailable";
|
||||
ship.State = ShipState.Blocked;
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var destinationEntryAnchor = ResolveSystemEntryAnchor(world, subTask.TargetSystemId) ?? targetAnchor;
|
||||
var destinationEntryPosition = destinationEntryAnchor?.Position ?? targetPosition;
|
||||
return UpdateFtlTransit(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, destinationEntryPosition, destinationEntryAnchor, completeOnArrival, targetPosition, targetAnchor);
|
||||
}
|
||||
|
||||
var currentAnchor = ResolveCurrentAnchor(world, ship);
|
||||
if (targetAnchor is not null
|
||||
&& currentAnchor is not null
|
||||
&& !string.Equals(currentAnchor.Id, targetAnchor.Id, StringComparison.Ordinal))
|
||||
{
|
||||
if (!CanWarp(ship.Definition))
|
||||
{
|
||||
return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, currentAnchor, targetAnchor, completeOnArrival);
|
||||
}
|
||||
|
||||
return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, currentAnchor, targetAnchor, completeOnArrival);
|
||||
}
|
||||
|
||||
if (targetAnchor is not null
|
||||
&& currentAnchor is not null
|
||||
&& !string.Equals(currentAnchor.Id, targetAnchor.Id, StringComparison.Ordinal)
|
||||
&& CanWarp(ship.Definition))
|
||||
{
|
||||
return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, currentAnchor, targetAnchor, completeOnArrival);
|
||||
}
|
||||
|
||||
return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, currentAnchor, targetAnchor, completeOnArrival);
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateAttackSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f);
|
||||
var hostileStation = hostileShip is null
|
||||
? world.Stations.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId)
|
||||
: null;
|
||||
if ((hostileShip is not null && hostileShip.FactionId == ship.FactionId)
|
||||
|| (hostileStation is not null && hostileStation.FactionId == ship.FactionId))
|
||||
{
|
||||
subTask.BlockingReason = "friendly-target";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
if (hostileShip is null && hostileStation is null)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId;
|
||||
var targetPosition = hostileShip?.Position ?? hostileStation!.Position;
|
||||
var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f;
|
||||
subTask.TargetSystemId = targetSystemId;
|
||||
subTask.TargetPosition = targetPosition;
|
||||
subTask.Threshold = attackRange;
|
||||
|
||||
if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange)
|
||||
{
|
||||
return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false);
|
||||
}
|
||||
|
||||
ship.State = ShipState.EngagingTarget;
|
||||
ship.TargetPosition = targetPosition;
|
||||
ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f));
|
||||
var damage = GetShipDamagePerSecond(ship) * deltaSeconds * GetSkillFactor(ship.Skills.Combat);
|
||||
subTask.Progress = 1f;
|
||||
|
||||
if (hostileShip is not null)
|
||||
{
|
||||
hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage);
|
||||
return hostileShip.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
hostileStation!.Health = MathF.Max(0f, hostileStation.Health - (damage * 0.6f));
|
||||
return hostileStation.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateMineSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var node = ResolveNode(world, subTask.TargetResourceNodeId ?? subTask.TargetEntityId);
|
||||
if (node is null || !CanExtractNode(ship, node, world))
|
||||
{
|
||||
subTask.BlockingReason = "node-missing";
|
||||
ship.State = ShipState.Blocked;
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var deposit = ResolveResourceDeposit(world, subTask.TargetResourceDepositId);
|
||||
if (deposit is null || !string.Equals(deposit.NodeId, node.Id, StringComparison.Ordinal) || deposit.OreRemaining <= 0.01f)
|
||||
{
|
||||
deposit = SelectMiningDeposit(node, ship.Id);
|
||||
subTask.TargetResourceDepositId = deposit?.Id;
|
||||
}
|
||||
|
||||
if (deposit is null)
|
||||
{
|
||||
SyncNodeOreTotals(node);
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
var targetPosition = GetResourceHoldPosition(deposit.Position, ship.Id, 20f);
|
||||
subTask.TargetPosition = targetPosition;
|
||||
var approachThreshold = MathF.Max(subTask.Threshold, 8f);
|
||||
var distanceToTarget = ship.Position.DistanceTo(targetPosition);
|
||||
var distanceToDeposit = ship.Position.DistanceTo(deposit.Position);
|
||||
var effectivelyAtDeposit = string.Equals(ship.SpatialState.CurrentAnchorId, node.AnchorId, StringComparison.Ordinal)
|
||||
&& distanceToDeposit <= approachThreshold;
|
||||
ship.TargetPosition = targetPosition;
|
||||
if (distanceToTarget > approachThreshold && !effectivelyAtDeposit)
|
||||
{
|
||||
ship.State = ShipState.MiningApproach;
|
||||
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
var cargoAmount = GetShipCargoAmount(ship);
|
||||
if (cargoAmount >= ship.Definition.GetTotalCargoCapacity() - 0.01f)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
ship.State = ShipState.Mining;
|
||||
if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.MiningCycleSeconds))
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
var remainingCapacity = MathF.Max(0f, ship.Definition.GetTotalCargoCapacity() - cargoAmount);
|
||||
var mined = MathF.Min(balance.MiningRate * GetSkillFactor(ship.Skills.Mining), remainingCapacity);
|
||||
mined = MathF.Min(mined, deposit.OreRemaining);
|
||||
if (mined <= 0.01f)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
AddInventory(ship.Inventory, node.ItemId, mined);
|
||||
deposit.OreRemaining = MathF.Max(0f, deposit.OreRemaining - mined);
|
||||
SyncNodeOreTotals(node);
|
||||
if (GetShipCargoAmount(ship) >= ship.Definition.GetTotalCargoCapacity() - 0.01f || node.OreRemaining <= 0.01f)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
subTask.ElapsedSeconds = 0f;
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateDockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var station = ResolveStation(world, subTask.TargetEntityId);
|
||||
if (station is null)
|
||||
{
|
||||
subTask.BlockingReason = "dock-target-missing";
|
||||
ship.State = ShipState.Blocked;
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id);
|
||||
if (padIndex is null)
|
||||
{
|
||||
ship.State = ShipState.AwaitingDock;
|
||||
ship.TargetPosition = GetDockingHoldPosition(station, ship.Id);
|
||||
if (ship.Position.DistanceTo(ship.TargetPosition) > 4f)
|
||||
{
|
||||
ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
||||
}
|
||||
|
||||
subTask.Status = WorkStatus.Blocked;
|
||||
subTask.BlockingReason = "waiting-for-pad";
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
subTask.Status = WorkStatus.Active;
|
||||
subTask.BlockingReason = null;
|
||||
ship.AssignedDockingPadIndex = padIndex;
|
||||
var padPosition = GetDockingPadPosition(station, padIndex.Value);
|
||||
ship.TargetPosition = padPosition;
|
||||
if (ship.Position.DistanceTo(padPosition) > 4f)
|
||||
{
|
||||
ship.State = ShipState.DockingApproach;
|
||||
ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
ship.State = ShipState.Docking;
|
||||
if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.DockingDuration))
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
ship.State = ShipState.Docked;
|
||||
ship.DockedStationId = station.Id;
|
||||
station.DockedShipIds.Add(ship.Id);
|
||||
ship.KnownStationIds.Add(station.Id);
|
||||
ship.Position = padPosition;
|
||||
ship.TargetPosition = padPosition;
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateUndockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
var station = ResolveStation(world, ship.DockedStationId);
|
||||
if (station is null)
|
||||
{
|
||||
ship.DockedStationId = null;
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
var undockTarget = GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, balance.UndockDistance);
|
||||
ship.TargetPosition = undockTarget;
|
||||
ship.State = ShipState.Undocking;
|
||||
if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.UndockingDuration))
|
||||
{
|
||||
ship.Position = GetShipDockedPosition(ship, station);
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
ship.Position = ship.Position.MoveToward(undockTarget, balance.UndockDistance);
|
||||
if (ship.Position.DistanceTo(undockTarget) > MathF.Max(subTask.Threshold, 4f))
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
station.DockedShipIds.Remove(ship.Id);
|
||||
ReleaseDockingPad(station, ship.Id);
|
||||
ship.DockedStationId = null;
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateLoadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
{
|
||||
subTask.BlockingReason = "not-docked";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var station = ResolveStation(world, ship.DockedStationId);
|
||||
if (station is null)
|
||||
{
|
||||
subTask.BlockingReason = "station-missing";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.State = ShipState.Loading;
|
||||
var itemId = subTask.ItemId;
|
||||
if (itemId is null)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
var desiredAmount = subTask.Amount > 0f ? subTask.Amount : ship.Definition.GetTotalCargoCapacity();
|
||||
var availableCapacity = MathF.Max(0f, ship.Definition.GetTotalCargoCapacity() - GetShipCargoAmount(ship));
|
||||
var transferRate = balance.TransferRate * GetSkillFactor(ship.Skills.Trade);
|
||||
var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(availableCapacity, GetInventoryAmount(station.Inventory, itemId)));
|
||||
if (moved > 0.01f)
|
||||
{
|
||||
RemoveInventory(station.Inventory, itemId, moved);
|
||||
AddInventory(ship.Inventory, itemId, moved);
|
||||
}
|
||||
|
||||
var loadedAmount = GetInventoryAmount(ship.Inventory, itemId);
|
||||
subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(loadedAmount / desiredAmount, 0f, 1f);
|
||||
return availableCapacity <= 0.01f || GetInventoryAmount(station.Inventory, itemId) <= 0.01f || loadedAmount >= desiredAmount - 0.01f
|
||||
? SubTaskOutcome.Completed
|
||||
: SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateUnloadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
{
|
||||
subTask.BlockingReason = "not-docked";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var station = ResolveStation(world, ship.DockedStationId);
|
||||
if (station is null)
|
||||
{
|
||||
subTask.BlockingReason = "station-missing";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.State = ShipState.Transferring;
|
||||
var transferRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Mining));
|
||||
|
||||
if (subTask.ItemId is not null)
|
||||
{
|
||||
var moved = MathF.Min(transferRate * deltaSeconds, GetInventoryAmount(ship.Inventory, subTask.ItemId));
|
||||
var accepted = TryAddStationInventory(world, station, subTask.ItemId, moved);
|
||||
RemoveInventory(ship.Inventory, subTask.ItemId, accepted);
|
||||
subTask.Progress = subTask.Amount <= 0.01f
|
||||
? 1f
|
||||
: Math.Clamp(1f - (GetInventoryAmount(ship.Inventory, subTask.ItemId) / subTask.Amount), 0f, 1f);
|
||||
return GetInventoryAmount(ship.Inventory, subTask.ItemId) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
foreach (var (itemId, amount) in ship.Inventory.ToList().OrderBy(entry => entry.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var moved = MathF.Min(amount, transferRate * deltaSeconds);
|
||||
var accepted = TryAddStationInventory(world, station, itemId, moved);
|
||||
RemoveInventory(ship.Inventory, itemId, accepted);
|
||||
if (accepted > 0.01f)
|
||||
{
|
||||
return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
}
|
||||
|
||||
return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateTransferCargoToShipSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f);
|
||||
if (targetShip is null)
|
||||
{
|
||||
subTask.BlockingReason = "target-ship-missing";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 12f));
|
||||
subTask.TargetSystemId = targetShip.SystemId;
|
||||
subTask.TargetPosition = desiredPosition;
|
||||
if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f))
|
||||
{
|
||||
return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false);
|
||||
}
|
||||
|
||||
ship.State = ShipState.Transferring;
|
||||
ship.TargetPosition = desiredPosition;
|
||||
ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition)));
|
||||
if (subTask.ItemId is null)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
var targetCapacity = MathF.Max(0f, targetShip.Definition.GetTotalCargoCapacity() - GetShipCargoAmount(targetShip));
|
||||
if (targetCapacity <= 0.01f)
|
||||
{
|
||||
subTask.BlockingReason = "target-cargo-full";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var transferRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Navigation));
|
||||
var desiredAmount = subTask.Amount > 0f ? subTask.Amount : GetInventoryAmount(ship.Inventory, subTask.ItemId);
|
||||
var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(targetCapacity, GetInventoryAmount(ship.Inventory, subTask.ItemId)));
|
||||
if (moved > 0.01f)
|
||||
{
|
||||
RemoveInventory(ship.Inventory, subTask.ItemId, moved);
|
||||
AddInventory(targetShip.Inventory, subTask.ItemId, moved);
|
||||
}
|
||||
|
||||
var remaining = GetInventoryAmount(ship.Inventory, subTask.ItemId);
|
||||
subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(1f - (remaining / desiredAmount), 0f, 1f);
|
||||
return remaining <= 0.01f || GetShipCargoAmount(targetShip) >= targetShip.Definition.GetTotalCargoCapacity() - 0.01f
|
||||
? SubTaskOutcome.Completed
|
||||
: SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateSalvageSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.RemainingAmount > 0.01f);
|
||||
if (wreck is null)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
var desiredPosition = subTask.TargetPosition ?? GetFormationPosition(wreck.Position, ship.Id, 8f);
|
||||
ship.TargetPosition = desiredPosition;
|
||||
if (ship.SystemId != wreck.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 8f))
|
||||
{
|
||||
subTask.TargetSystemId = wreck.SystemId;
|
||||
subTask.TargetPosition = desiredPosition;
|
||||
return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false);
|
||||
}
|
||||
|
||||
ship.State = ShipState.Transferring;
|
||||
var remainingCapacity = MathF.Max(0f, ship.Definition.GetTotalCargoCapacity() - GetShipCargoAmount(ship));
|
||||
if (remainingCapacity <= 0.01f)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.4f, balance.MiningCycleSeconds * 0.8f)))
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
var salvageRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Mining, ship.Skills.Trade));
|
||||
var recovered = MathF.Min(salvageRate, MathF.Min(remainingCapacity, wreck.RemainingAmount));
|
||||
if (recovered > 0.01f)
|
||||
{
|
||||
AddInventory(ship.Inventory, wreck.ItemId, recovered);
|
||||
wreck.RemainingAmount = MathF.Max(0f, wreck.RemainingAmount - recovered);
|
||||
}
|
||||
|
||||
if (wreck.RemainingAmount <= 0.01f)
|
||||
{
|
||||
world.Wrecks.RemoveAll(candidate => candidate.Id == wreck.Id);
|
||||
}
|
||||
|
||||
subTask.ElapsedSeconds = 0f;
|
||||
return wreck.RemainingAmount <= 0.01f || GetShipCargoAmount(ship) >= ship.Definition.GetTotalCargoCapacity() - 0.01f
|
||||
? SubTaskOutcome.Completed
|
||||
: SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateDeliverConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
|
||||
var station = site is null ? null : ResolveSupportStation(world, ship, site);
|
||||
if (site is null || station is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed)
|
||||
{
|
||||
subTask.BlockingReason = "construction-target-missing";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var supportPosition = ResolveSupportPosition(ship, station, site, world);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold)))
|
||||
{
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = supportPosition;
|
||||
ship.State = ShipState.DeliveringConstruction;
|
||||
var transferRate = balance.TransferRate * GetSkillFactor(ship.Skills.Construction);
|
||||
foreach (var required in site.RequiredItems.OrderBy(entry => entry.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var delivered = GetInventoryAmount(site.DeliveredItems, required.Key);
|
||||
var remaining = MathF.Max(0f, required.Value - delivered);
|
||||
if (remaining <= 0.01f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var available = MathF.Max(0f, GetInventoryAmount(station.Inventory, required.Key) - GetStationReserveFloor(world, station, required.Key));
|
||||
var moved = MathF.Min(remaining, MathF.Min(available, transferRate * deltaSeconds));
|
||||
if (moved <= 0.01f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
RemoveInventory(station.Inventory, required.Key, moved);
|
||||
AddInventory(site.Inventory, required.Key, moved);
|
||||
AddInventory(site.DeliveredItems, required.Key, moved);
|
||||
break;
|
||||
}
|
||||
|
||||
subTask.Progress = site.RequiredItems.Count == 0
|
||||
? 1f
|
||||
: site.RequiredItems.Sum(required =>
|
||||
required.Value <= 0.01f
|
||||
? 1f
|
||||
: Math.Clamp(GetInventoryAmount(site.DeliveredItems, required.Key) / required.Value, 0f, 1f)) / site.RequiredItems.Count;
|
||||
return IsConstructionSiteReady(world, site) ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateBuildConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
|
||||
var station = site is null ? null : ResolveSupportStation(world, ship, site);
|
||||
if (site is null || station is null || site.BlueprintId is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed)
|
||||
{
|
||||
subTask.BlockingReason = "construction-site-missing";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var supportPosition = ResolveSupportPosition(ship, station, site, world);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold)))
|
||||
{
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
|
||||
{
|
||||
ship.State = ShipState.WaitingMaterials;
|
||||
subTask.Status = WorkStatus.Blocked;
|
||||
subTask.BlockingReason = "waiting-materials";
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
subTask.Status = WorkStatus.Active;
|
||||
subTask.BlockingReason = null;
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = supportPosition;
|
||||
ship.State = ShipState.Constructing;
|
||||
site.AssignedConstructorShipIds.Add(ship.Id);
|
||||
site.Progress += deltaSeconds * GetSkillFactor(ship.Skills.Construction);
|
||||
subTask.Progress = recipe.Duration <= 0.01f ? 1f : Math.Clamp(site.Progress / recipe.Duration, 0f, 1f);
|
||||
if (site.Progress < recipe.Duration)
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
if (site.StationId is null)
|
||||
{
|
||||
CompleteStationFoundation(world, station, site);
|
||||
}
|
||||
else
|
||||
{
|
||||
AddStationModule(world, station, site.BlueprintId);
|
||||
PrepareNextConstructionSiteStep(world, station, site);
|
||||
}
|
||||
|
||||
site.State = ConstructionSiteStateKinds.Completed;
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
private static bool AdvanceTimedSubTask(ShipSubTaskRuntime subTask, float deltaSeconds, float requiredSeconds)
|
||||
{
|
||||
subTask.TotalSeconds = requiredSeconds;
|
||||
subTask.ElapsedSeconds += deltaSeconds;
|
||||
subTask.Progress = requiredSeconds <= 0.01f ? 1f : Math.Clamp(subTask.ElapsedSeconds / requiredSeconds, 0f, 1f);
|
||||
if (subTask.ElapsedSeconds < requiredSeconds)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
subTask.ElapsedSeconds = 0f;
|
||||
return true;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateLocalTravel(
|
||||
SimulationWorld world,
|
||||
ShipRuntime ship,
|
||||
ShipSubTaskRuntime subTask,
|
||||
float deltaSeconds,
|
||||
string targetSystemId,
|
||||
Vector3 targetPosition,
|
||||
AnchorRuntime? currentAnchor,
|
||||
AnchorRuntime? targetAnchor,
|
||||
bool completeOnArrival)
|
||||
{
|
||||
var distance = ship.Position.DistanceTo(targetPosition);
|
||||
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
|
||||
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
|
||||
ship.SpatialState.Transit = null;
|
||||
ship.SpatialState.DestinationAnchorId = targetAnchor?.Id ?? currentAnchor?.Id;
|
||||
subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f);
|
||||
var localSystemOffset = SimulationUnits.MetersToKilometers(ship.Position);
|
||||
ship.SpatialState.SystemPosition = currentAnchor is null
|
||||
? localSystemOffset
|
||||
: new Vector3(
|
||||
currentAnchor.Position.X + localSystemOffset.X,
|
||||
currentAnchor.Position.Y + localSystemOffset.Y,
|
||||
currentAnchor.Position.Z + localSystemOffset.Z);
|
||||
|
||||
if (distance <= MathF.Max(subTask.Threshold, balance.ArrivalThreshold))
|
||||
{
|
||||
ship.Position = targetPosition;
|
||||
ship.TargetPosition = targetPosition;
|
||||
ship.SystemId = targetSystemId;
|
||||
ship.SpatialState.CurrentSystemId = targetSystemId;
|
||||
ship.SpatialState.CurrentAnchorId = targetAnchor?.Id ?? currentAnchor?.Id;
|
||||
var arrivalSystemOffset = SimulationUnits.MetersToKilometers(targetPosition);
|
||||
ship.SpatialState.SystemPosition = targetAnchor is null
|
||||
? arrivalSystemOffset
|
||||
: new Vector3(
|
||||
targetAnchor.Position.X + arrivalSystemOffset.X,
|
||||
targetAnchor.Position.Y + arrivalSystemOffset.Y,
|
||||
targetAnchor.Position.Z + arrivalSystemOffset.Z);
|
||||
ship.State = ShipState.Arriving;
|
||||
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.SpatialState.CurrentAnchorId = currentAnchor?.Id;
|
||||
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
||||
var movedSystemOffset = SimulationUnits.MetersToKilometers(ship.Position);
|
||||
ship.SpatialState.SystemPosition = currentAnchor is null
|
||||
? movedSystemOffset
|
||||
: new Vector3(
|
||||
currentAnchor.Position.X + movedSystemOffset.X,
|
||||
currentAnchor.Position.Y + movedSystemOffset.Y,
|
||||
currentAnchor.Position.Z + movedSystemOffset.Z);
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateWarpTransit(
|
||||
SimulationWorld world,
|
||||
ShipRuntime ship,
|
||||
ShipSubTaskRuntime subTask,
|
||||
float deltaSeconds,
|
||||
Vector3 targetPosition,
|
||||
AnchorRuntime currentAnchor,
|
||||
AnchorRuntime targetAnchor,
|
||||
bool completeOnArrival)
|
||||
{
|
||||
var transit = ship.SpatialState.Transit;
|
||||
if (transit is null || transit.Regime != MovementRegimeKind.Warp || transit.DestinationAnchorId != targetAnchor.Id)
|
||||
{
|
||||
var originAnchorPosition = currentAnchor.Position;
|
||||
var destinationAnchorPosition = targetAnchor.Position;
|
||||
var initialSpoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
|
||||
var initialTravelDuration = MathF.Max(0.1f, originAnchorPosition.DistanceTo(destinationAnchorPosition) / MathF.Max(GetWarpTravelSpeed(ship), 0.001f));
|
||||
transit = new ShipTransitRuntime
|
||||
{
|
||||
Regime = MovementRegimeKind.Warp,
|
||||
OriginAnchorId = currentAnchor.Id,
|
||||
DestinationAnchorId = targetAnchor.Id,
|
||||
StartedAtUtc = world.GeneratedAtUtc,
|
||||
ArrivalDueAtUtc = world.GeneratedAtUtc.AddSeconds(initialSpoolDuration + initialTravelDuration),
|
||||
};
|
||||
ship.SpatialState.Transit = transit;
|
||||
subTask.ElapsedSeconds = 0f;
|
||||
}
|
||||
|
||||
ship.SpatialState.SpaceLayer = SpaceLayerKind.SystemSpace;
|
||||
ship.SpatialState.MovementRegime = MovementRegimeKind.Warp;
|
||||
ship.SpatialState.CurrentAnchorId = null;
|
||||
ship.SpatialState.DestinationAnchorId = targetAnchor.Id;
|
||||
|
||||
var spoolDurationSeconds = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
|
||||
var startedAtUtc = transit.StartedAtUtc ?? world.GeneratedAtUtc;
|
||||
var arrivalDueAtUtc = transit.ArrivalDueAtUtc ?? world.GeneratedAtUtc;
|
||||
var totalDuration = MathF.Max(0.1f, (float)(arrivalDueAtUtc - startedAtUtc).TotalSeconds);
|
||||
var elapsedSeconds = Math.Clamp((float)(world.GeneratedAtUtc - startedAtUtc).TotalSeconds, 0f, totalDuration);
|
||||
var originPosition = ResolveAnchorPosition(world, transit.OriginAnchorId, currentAnchor.Position);
|
||||
var destinationPosition = ResolveAnchorPosition(world, transit.DestinationAnchorId, targetAnchor.Position);
|
||||
|
||||
if (elapsedSeconds < spoolDurationSeconds)
|
||||
{
|
||||
ship.State = ShipState.SpoolingWarp;
|
||||
ship.Position = Vector3.Zero;
|
||||
ship.TargetPosition = Vector3.Zero;
|
||||
ship.SpatialState.SystemPosition = originPosition;
|
||||
transit.Progress = Math.Clamp(elapsedSeconds / totalDuration, 0f, 1f);
|
||||
subTask.Progress = transit.Progress;
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
ship.State = ShipState.Warping;
|
||||
var warpTravelDuration = MathF.Max(0.001f, totalDuration - spoolDurationSeconds);
|
||||
var travelElapsed = Math.Clamp(elapsedSeconds - spoolDurationSeconds, 0f, warpTravelDuration);
|
||||
var travelProgress = Math.Clamp(travelElapsed / warpTravelDuration, 0f, 1f);
|
||||
var travelDelta = destinationPosition.Subtract(originPosition);
|
||||
ship.Position = Vector3.Zero;
|
||||
ship.TargetPosition = Vector3.Zero;
|
||||
ship.SpatialState.SystemPosition = new Vector3(
|
||||
originPosition.X + (travelDelta.X * travelProgress),
|
||||
originPosition.Y + (travelDelta.Y * travelProgress),
|
||||
originPosition.Z + (travelDelta.Z * travelProgress));
|
||||
transit.Progress = Math.Clamp(elapsedSeconds / totalDuration, 0f, 1f);
|
||||
subTask.Progress = transit.Progress;
|
||||
if (elapsedSeconds < totalDuration - 0.001f)
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
return CompleteTransitArrival(ship, subTask.TargetSystemId ?? ship.SystemId, targetPosition, targetAnchor, completeOnArrival);
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateFtlTransit(
|
||||
SimulationWorld world,
|
||||
ShipRuntime ship,
|
||||
ShipSubTaskRuntime subTask,
|
||||
float deltaSeconds,
|
||||
string targetSystemId,
|
||||
Vector3 entryPosition,
|
||||
AnchorRuntime? entryAnchor,
|
||||
bool completeOnArrival,
|
||||
Vector3 finalTargetPosition,
|
||||
AnchorRuntime? finalTargetAnchor)
|
||||
{
|
||||
var destinationAnchorId = entryAnchor?.Id;
|
||||
var transit = ship.SpatialState.Transit;
|
||||
if (transit is null || transit.Regime != MovementRegimeKind.FtlTransit || transit.DestinationAnchorId != destinationAnchorId)
|
||||
{
|
||||
var initialTravelDuration = MathF.Max(0.1f, ResolveSystemGalaxyPosition(world, ship.SystemId).DistanceTo(ResolveSystemGalaxyPosition(world, targetSystemId)) / MathF.Max(ship.Definition.FtlSpeed * GetSkillFactor(ship.Skills.Navigation), 0.001f));
|
||||
var initialSpoolDuration = MathF.Max(ship.Definition.SpoolTime, 0.1f);
|
||||
transit = new ShipTransitRuntime
|
||||
{
|
||||
Regime = MovementRegimeKind.FtlTransit,
|
||||
OriginAnchorId = ship.SpatialState.CurrentAnchorId,
|
||||
DestinationAnchorId = destinationAnchorId,
|
||||
StartedAtUtc = world.GeneratedAtUtc,
|
||||
ArrivalDueAtUtc = world.GeneratedAtUtc.AddSeconds(initialSpoolDuration + initialTravelDuration),
|
||||
};
|
||||
ship.SpatialState.Transit = transit;
|
||||
subTask.ElapsedSeconds = 0f;
|
||||
}
|
||||
|
||||
ship.SpatialState.SpaceLayer = SpaceLayerKind.GalaxySpace;
|
||||
ship.SpatialState.MovementRegime = MovementRegimeKind.FtlTransit;
|
||||
ship.SpatialState.CurrentAnchorId = null;
|
||||
ship.SpatialState.DestinationAnchorId = destinationAnchorId;
|
||||
|
||||
var spoolDurationSeconds = MathF.Max(ship.Definition.SpoolTime, 0.1f);
|
||||
var startedAtUtc = transit.StartedAtUtc ?? world.GeneratedAtUtc;
|
||||
var arrivalDueAtUtc = transit.ArrivalDueAtUtc ?? world.GeneratedAtUtc;
|
||||
var totalDuration = MathF.Max(0.1f, (float)(arrivalDueAtUtc - startedAtUtc).TotalSeconds);
|
||||
var elapsedSeconds = Math.Clamp((float)(world.GeneratedAtUtc - startedAtUtc).TotalSeconds, 0f, totalDuration);
|
||||
ship.State = elapsedSeconds < spoolDurationSeconds ? ShipState.SpoolingFtl : ShipState.Ftl;
|
||||
transit.Progress = Math.Clamp(elapsedSeconds / totalDuration, 0f, 1f);
|
||||
subTask.Progress = transit.Progress;
|
||||
if (elapsedSeconds < totalDuration - 0.001f)
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
ship.Position = Vector3.Zero;
|
||||
ship.TargetPosition = finalTargetPosition;
|
||||
ship.SystemId = targetSystemId;
|
||||
ship.SpatialState.CurrentSystemId = targetSystemId;
|
||||
ship.SpatialState.Transit = null;
|
||||
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
|
||||
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
|
||||
ship.SpatialState.CurrentAnchorId = entryAnchor?.Id;
|
||||
ship.SpatialState.DestinationAnchorId = finalTargetAnchor?.Id ?? entryAnchor?.Id;
|
||||
ship.SpatialState.SystemPosition = entryPosition;
|
||||
ship.State = ShipState.Arriving;
|
||||
|
||||
// Cross-system travel is only complete once the ship finishes the
|
||||
// destination-system local leg to the actual target.
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private static SubTaskOutcome CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, AnchorRuntime? targetAnchor, bool completeOnArrival)
|
||||
{
|
||||
ship.Position = targetPosition;
|
||||
ship.TargetPosition = targetPosition;
|
||||
ship.SystemId = targetSystemId;
|
||||
ship.SpatialState.CurrentSystemId = targetSystemId;
|
||||
ship.SpatialState.Transit = null;
|
||||
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
|
||||
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
|
||||
ship.SpatialState.CurrentAnchorId = targetAnchor?.Id;
|
||||
ship.SpatialState.DestinationAnchorId = targetAnchor?.Id;
|
||||
var arrivalSystemOffset = SimulationUnits.MetersToKilometers(targetPosition);
|
||||
ship.SpatialState.SystemPosition = targetAnchor is null
|
||||
? arrivalSystemOffset
|
||||
: new Vector3(
|
||||
targetAnchor.Position.X + arrivalSystemOffset.X,
|
||||
targetAnchor.Position.Y + arrivalSystemOffset.Y,
|
||||
targetAnchor.Position.Z + arrivalSystemOffset.Z);
|
||||
ship.State = ShipState.Arriving;
|
||||
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
}
|
||||
1150
apps/backend/Ships/AI/ShipAiService.Helpers.cs
Normal file
1150
apps/backend/Ships/AI/ShipAiService.Helpers.cs
Normal file
File diff suppressed because it is too large
Load Diff
179
apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs
Normal file
179
apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private static bool IsBehaviorBlockingFailure(string behaviorKind, string? failureReason) => failureReason switch
|
||||
{
|
||||
"missing-item" => true,
|
||||
"no-suitable-buyer" => true,
|
||||
"no-mineable-node" when string.Equals(behaviorKind, ShipBehaviorKinds.LocalAutoMine, StringComparison.Ordinal) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var assignment = ResolveAssignment(world, ship);
|
||||
return assignment is null
|
||||
? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind)
|
||||
: (assignment.BehaviorKind, assignment.ObjectiveId);
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildTradeSubTasks(ShipRuntime ship, TradeRoutePlan route)
|
||||
{
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-acquire-travel", ShipTaskKinds.Travel, $"Travel to {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(route.SourceStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-acquire-dock", ShipTaskKinds.Dock, $"Dock at {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-acquire-load", ShipTaskKinds.LoadCargo, $"Load {route.ItemId}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
|
||||
CreateSubTask("sub-acquire-undock", ShipTaskKinds.Undock, $"Undock from {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(4f, 12f), 0f),
|
||||
CreateSubTask("sub-route-travel", ShipTaskKinds.Travel, $"Travel to {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(route.DestinationStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-route-dock", ShipTaskKinds.Dock, $"Dock at {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-route-unload", ShipTaskKinds.UnloadCargo, $"Unload {route.ItemId}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
|
||||
CreateSubTask("sub-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildFleetSupplySubTasks(FleetSupplyPlan plan)
|
||||
{
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-fleet-source-travel", ShipTaskKinds.Travel, $"Travel to {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, MathF.Max(plan.SourceStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-fleet-source-dock", ShipTaskKinds.Dock, $"Dock at {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-fleet-source-load", ShipTaskKinds.LoadCargo, $"Load {plan.ItemId}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 0f, plan.Amount, itemId: plan.ItemId),
|
||||
CreateSubTask("sub-fleet-source-undock", ShipTaskKinds.Undock, $"Undock from {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 12f, 0f),
|
||||
CreateSubTask("sub-fleet-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Name}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, 6f),
|
||||
CreateSubTask("sub-fleet-transfer", ShipTaskKinds.TransferCargoToShip, $"Transfer {plan.ItemId}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, plan.Amount, itemId: plan.ItemId),
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildConstructionSubTasks(ConstructionSiteRuntime site, StationRuntime supportStation)
|
||||
{
|
||||
var targetPosition = supportStation.Position;
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-construction-travel", ShipTaskKinds.Travel, $"Travel to support station {supportStation.Label}", supportStation.SystemId, targetPosition, supportStation.Id, MathF.Max(supportStation.Radius + 18f, 18f), 0f),
|
||||
CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f),
|
||||
CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ShipSubTaskRuntime> BuildAttackSubTasks(string targetEntityId, string? targetSystemId, string summary)
|
||||
{
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? string.Empty, Vector3.Zero, targetEntityId, 26f, 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ShipSubTaskRuntime> BuildFlyToObjectSubTasks(string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary)
|
||||
{
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ShipSubTaskRuntime> BuildFollowShipSubTasks(ShipRuntime targetShip, float radius, float durationSeconds, string summary) =>
|
||||
BuildFollowSubTasks(targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary);
|
||||
|
||||
private static IReadOnlyList<ShipSubTaskRuntime> BuildFollowSubTasks(string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary)
|
||||
{
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds),
|
||||
];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ShipSubTaskRuntime> BuildHoldSubTasks(ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f),
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node, StationRuntime homeStation)
|
||||
{
|
||||
var deposit = SelectMiningDeposit(node, ship.Id);
|
||||
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId),
|
||||
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id),
|
||||
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity()),
|
||||
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildLocalMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node)
|
||||
{
|
||||
var deposit = SelectMiningDeposit(node, ship.Id);
|
||||
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId),
|
||||
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id),
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildLocalMiningDeliverySubTasks(ShipRuntime ship, StationRuntime buyer, string itemId)
|
||||
{
|
||||
var amount = MathF.Max(1f, GetInventoryAmount(ship.Inventory, itemId));
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(buyer.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 4f, 0f),
|
||||
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload {itemId} at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 0f, amount, itemId: itemId),
|
||||
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(4f, 12f), 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildSalvageSubTasks(ShipRuntime ship, WreckRuntime wreck, StationRuntime homeStation, Vector3 approach)
|
||||
{
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {wreck.Id}", wreck.SystemId, approach, wreck.Id, 8f, 0f),
|
||||
CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {wreck.ItemId}", wreck.SystemId, approach, wreck.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
|
||||
CreateSubTask("sub-salvage-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-salvage-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-salvage-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload salvage at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
|
||||
CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private static ShipSubTaskRuntime CreateSubTask(
|
||||
string id,
|
||||
string kind,
|
||||
string summary,
|
||||
string targetSystemId,
|
||||
Vector3 targetPosition,
|
||||
string? targetEntityId,
|
||||
float threshold,
|
||||
float amount,
|
||||
string? itemId = null,
|
||||
string? moduleId = null,
|
||||
string? targetAnchorId = null,
|
||||
string? targetResourceNodeId = null,
|
||||
string? targetResourceDepositId = null) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Kind = kind,
|
||||
Summary = summary,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = targetPosition,
|
||||
TargetEntityId = targetEntityId,
|
||||
TargetAnchorId = targetAnchorId,
|
||||
TargetResourceNodeId = targetResourceNodeId,
|
||||
TargetResourceDepositId = targetResourceDepositId,
|
||||
ItemId = itemId,
|
||||
ModuleId = moduleId,
|
||||
Threshold = threshold,
|
||||
Amount = amount,
|
||||
};
|
||||
}
|
||||
328
apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs
Normal file
328
apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs
Normal file
@@ -0,0 +1,328 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
|
||||
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private ShipOrderRuntime? BuildEmergencyOrder(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var policy = ResolvePolicy(world, ship.PolicySetId);
|
||||
if (policy is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var hullRatio = ship.Definition.Hull <= 0.01f ? 1f : ship.Health / ship.Definition.Hull;
|
||||
if (hullRatio > policy.FleeHullRatio)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var hostileNearby = world.Ships.Any(candidate =>
|
||||
candidate.Health > 0f &&
|
||||
candidate.FactionId != ship.FactionId &&
|
||||
candidate.SystemId == ship.SystemId &&
|
||||
candidate.Position.DistanceTo(ship.Position) <= 200f);
|
||||
if (!hostileNearby)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var safeStation = world.Stations
|
||||
.Where(station => station.FactionId == ship.FactionId)
|
||||
.OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0)
|
||||
.ThenBy(station => station.Position.DistanceTo(ship.Position))
|
||||
.FirstOrDefault();
|
||||
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"rule-{ship.Id}-flee",
|
||||
Kind = ShipOrderKinds.Flee,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = ShipOrderKinds.Flee,
|
||||
Priority = 1000,
|
||||
InterruptCurrentPlan = true,
|
||||
Label = "Emergency retreat",
|
||||
TargetEntityId = safeStation?.Id,
|
||||
TargetSystemId = safeStation?.SystemId ?? ship.SystemId,
|
||||
TargetPosition = safeStation?.Position ?? ship.Position,
|
||||
DestinationStationId = safeStation?.Id,
|
||||
Radius = safeStation is null ? 0f : MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f),
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
return order.Kind switch
|
||||
{
|
||||
var kind when string.Equals(kind, ShipOrderKinds.Flee, StringComparison.Ordinal) => BuildFleeSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMoveSubTasks(ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderSubTasks(world, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderSubTasks(world, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.MineLocal, StringComparison.Ordinal) => BuildMineLocalOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliverRun, StringComparison.Ordinal) => BuildMineAndDeliverRunOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.SellMinedCargo, StringComparison.Ordinal) => BuildSellMinedCargoOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.SupplyFleetRun, StringComparison.Ordinal) => BuildSupplyFleetOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.SalvageRun, StringComparison.Ordinal) => BuildAutoSalvageOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderSubTasks(order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldSubTasks(ship, order),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildFleeSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var safeStation = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId);
|
||||
if (safeStation is null)
|
||||
{
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f),
|
||||
];
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), 0f),
|
||||
CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ShipSubTaskRuntime> BuildMoveSubTasks(ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var targetSystemId = order.TargetSystemId ?? ship.SystemId;
|
||||
var targetPosition = order.TargetPosition ?? ship.Position;
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, MathF.Max(0f, order.Radius), 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildDockOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId);
|
||||
if (station is null)
|
||||
{
|
||||
order.FailureReason = "station-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(balance.ArrivalThreshold, station.Radius + 12f), 0f),
|
||||
CreateSubTask("sub-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildTradeOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
if (order.SourceStationId is null || order.DestinationStationId is null || order.ItemId is null)
|
||||
{
|
||||
order.FailureReason = "trade-order-incomplete";
|
||||
return null;
|
||||
}
|
||||
|
||||
var route = ResolveTradeRoute(world, order.ItemId, order.SourceStationId, order.DestinationStationId);
|
||||
if (route is null)
|
||||
{
|
||||
order.FailureReason = "trade-route-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildTradeSubTasks(ship, route);
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildMineOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var systemId = order.TargetSystemId ?? ship.SystemId;
|
||||
var itemId = order.ItemId;
|
||||
if (string.IsNullOrWhiteSpace(itemId))
|
||||
{
|
||||
order.FailureReason = "mine-order-item-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId);
|
||||
var node = ResolveNode(world, order.TargetEntityId);
|
||||
if (node is not null)
|
||||
{
|
||||
if (!string.Equals(node.SystemId, systemId, StringComparison.Ordinal))
|
||||
{
|
||||
order.FailureReason = "mine-order-node-system-mismatch";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.Equals(node.ItemId, itemId, StringComparison.Ordinal))
|
||||
{
|
||||
order.FailureReason = "mine-order-node-item-mismatch";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
node = SelectLocalMiningNode(world, ship, systemId, itemId, anchor?.Id);
|
||||
}
|
||||
|
||||
if (node is null)
|
||||
{
|
||||
order.FailureReason = "mine-order-node-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildLocalMiningSubTasks(ship, node);
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildMineLocalOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId);
|
||||
var node = ResolveNode(world, order.TargetEntityId)
|
||||
?? SelectLocalMiningNode(world, ship, order.TargetSystemId ?? ship.SystemId, order.ItemId ?? ship.DefaultBehavior.ItemId ?? string.Empty, anchor?.Id);
|
||||
if (node is null)
|
||||
{
|
||||
order.FailureReason = "mine-order-incomplete";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildLocalMiningSubTasks(ship, node);
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildMineAndDeliverRunOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId);
|
||||
var node = ResolveNode(world, order.TargetEntityId)
|
||||
?? (string.IsNullOrWhiteSpace(order.ItemId)
|
||||
? null
|
||||
: SelectLocalMiningNode(world, ship, order.TargetSystemId ?? ship.SystemId, order.ItemId, anchor?.Id));
|
||||
var buyer = ResolveStation(world, order.DestinationStationId);
|
||||
if (node is null || buyer is null)
|
||||
{
|
||||
order.FailureReason = "mine-and-deliver-order-incomplete";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildMiningSubTasks(ship, node, buyer);
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildSellMinedCargoOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var buyer = ResolveStation(world, order.DestinationStationId ?? order.TargetEntityId);
|
||||
if (buyer is null || string.IsNullOrWhiteSpace(order.ItemId))
|
||||
{
|
||||
order.FailureReason = "sell-order-incomplete";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildLocalMiningDeliverySubTasks(ship, buyer, order.ItemId);
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildAutoSalvageOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var homeStation = ResolveStation(world, order.SourceStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.RemainingAmount > 0.01f);
|
||||
if (homeStation is null || wreck is null)
|
||||
{
|
||||
order.FailureReason = "salvage-order-incomplete";
|
||||
return null;
|
||||
}
|
||||
|
||||
var approach = GetFormationPosition(wreck.Position, ship.Id, MathF.Max(8f, order.Radius > 0f ? order.Radius : ship.DefaultBehavior.Radius * 0.25f));
|
||||
return BuildSalvageSubTasks(ship, wreck, homeStation, approach);
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildSupplyFleetOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var sourceStation = ResolveStation(world, order.SourceStationId);
|
||||
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
|
||||
if (sourceStation is null || targetShip is null || string.IsNullOrWhiteSpace(order.ItemId))
|
||||
{
|
||||
order.FailureReason = "supply-fleet-order-incomplete";
|
||||
return null;
|
||||
}
|
||||
|
||||
var amount = MathF.Min(
|
||||
MathF.Max(10f, ship.Definition.GetTotalCargoCapacity() * 0.5f),
|
||||
GetInventoryAmount(sourceStation.Inventory, order.ItemId));
|
||||
if (amount <= 0.01f)
|
||||
{
|
||||
order.FailureReason = "supply-item-unavailable";
|
||||
return null;
|
||||
}
|
||||
|
||||
var plan = new FleetSupplyPlan(
|
||||
sourceStation,
|
||||
targetShip,
|
||||
order.ItemId,
|
||||
amount,
|
||||
MathF.Max(16f, order.Radius),
|
||||
order.Label ?? $"Supply {targetShip.Definition.Name} with {order.ItemId}");
|
||||
return BuildFleetSupplySubTasks(plan);
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildBuildOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (order.ConstructionSiteId ?? order.TargetEntityId));
|
||||
if (site is null)
|
||||
{
|
||||
order.FailureReason = "construction-site-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
var supportStation = ResolveSupportStation(world, ship, site);
|
||||
if (supportStation is null)
|
||||
{
|
||||
order.FailureReason = "support-station-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildConstructionSubTasks(site, supportStation);
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildAttackOrderSubTasks(ShipOrderRuntime order)
|
||||
{
|
||||
var targetId = order.TargetEntityId;
|
||||
if (targetId is null)
|
||||
{
|
||||
order.FailureReason = "attack-target-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildAttackSubTasks(targetId, order.TargetSystemId, order.Label ?? "Attack target");
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildFlyToObjectOrderSubTasks(SimulationWorld world, ShipOrderRuntime order)
|
||||
{
|
||||
var targetEntityId = order.TargetEntityId;
|
||||
if (targetEntityId is null)
|
||||
{
|
||||
order.FailureReason = "target-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
var objectTarget = ResolveObjectTarget(world, targetEntityId);
|
||||
if (objectTarget is null)
|
||||
{
|
||||
order.FailureReason = "target-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildFlyToObjectSubTasks(objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}");
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildFollowShipOrderSubTasks(SimulationWorld world, ShipOrderRuntime order)
|
||||
{
|
||||
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
|
||||
if (targetShip is null)
|
||||
{
|
||||
order.FailureReason = "target-ship-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildFollowShipSubTasks(targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Name}");
|
||||
}
|
||||
}
|
||||
220
apps/backend/Ships/AI/ShipAiService.cs
Normal file
220
apps/backend/Ships/AI/ShipAiService.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private const float WarpEngageDistanceKilometers = 250_000f;
|
||||
private const float FrigateDps = 7f;
|
||||
private const float DestroyerDps = 12f;
|
||||
private const float CruiserDps = 18f;
|
||||
private const float CapitalDps = 26f;
|
||||
|
||||
private readonly IBalanceService balance;
|
||||
|
||||
public ShipAiService(IBalanceService balance)
|
||||
{
|
||||
this.balance = balance;
|
||||
}
|
||||
|
||||
internal void UpdateShip(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
if (ship.ReplanCooldownSeconds > 0f)
|
||||
{
|
||||
ship.ReplanCooldownSeconds = MathF.Max(0f, ship.ReplanCooldownSeconds - deltaSeconds);
|
||||
}
|
||||
|
||||
var previousState = ship.State;
|
||||
var previousOrderId = ship.ActiveOrderId;
|
||||
var previousTaskId = GetCurrentSubTask(ship)?.Id;
|
||||
|
||||
SyncEmergencyOrders(world, ship);
|
||||
SyncBehaviorOrders(world, ship);
|
||||
EnsureOrderExecution(world, ship, events);
|
||||
ExecuteOrder(world, ship, deltaSeconds, events);
|
||||
TrackHistory(ship);
|
||||
EmitStateEvents(ship, previousState, previousOrderId, previousTaskId, events);
|
||||
}
|
||||
|
||||
private void EnsureOrderExecution(SimulationWorld world, ShipRuntime ship, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
var currentOrder = ship.OrderQueue.GetCurrentOrder();
|
||||
if (currentOrder is null)
|
||||
{
|
||||
ClearActiveOrder(ship);
|
||||
ApplyIdleOrBlockedState(world, ship);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentOrder.Status == OrderStatus.Queued)
|
||||
{
|
||||
currentOrder.Status = OrderStatus.Active;
|
||||
}
|
||||
|
||||
if (!ship.NeedsReplan
|
||||
&& string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal)
|
||||
&& ship.ActiveSubTaskIndex < ship.ActiveSubTasks.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ship.ReplanCooldownSeconds > 0f && !string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var subTasks = BuildOrderSubTasks(world, ship, currentOrder);
|
||||
if (subTasks is null || subTasks.Count == 0)
|
||||
{
|
||||
FailOrder(ship, currentOrder, currentOrder.FailureReason ?? "order-unavailable");
|
||||
ClearActiveOrder(ship);
|
||||
ship.NeedsReplan = true;
|
||||
ship.ReplanCooldownSeconds = 0.1f;
|
||||
ship.LastReplanReason = currentOrder.FailureReason ?? "order-unavailable";
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "order-failed", $"{ship.Definition.Name} failed {currentOrder.Label ?? currentOrder.Kind}.", DateTimeOffset.UtcNow));
|
||||
ApplyIdleOrBlockedState(world, ship);
|
||||
return;
|
||||
}
|
||||
|
||||
BeginOrderExecution(ship, currentOrder, subTasks);
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "order-started", $"{ship.Definition.Name} started {currentOrder.Label ?? currentOrder.Kind}.", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private void ExecuteOrder(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
var order = ship.ActiveOrderId is null ? null : ship.OrderQueue.FindById(ship.ActiveOrderId);
|
||||
if (order is null)
|
||||
{
|
||||
ClearActiveOrder(ship);
|
||||
ApplyIdleOrBlockedState(world, ship);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count)
|
||||
{
|
||||
CompleteOrderExecution(ship, order, events);
|
||||
return;
|
||||
}
|
||||
|
||||
var subTask = ship.ActiveSubTasks[ship.ActiveSubTaskIndex];
|
||||
if (subTask.Status == WorkStatus.Pending)
|
||||
{
|
||||
subTask.Status = WorkStatus.Active;
|
||||
}
|
||||
else if (subTask.Status == WorkStatus.Blocked)
|
||||
{
|
||||
ship.State = ShipState.Blocked;
|
||||
ship.TargetPosition = subTask.TargetPosition ?? ship.Position;
|
||||
return;
|
||||
}
|
||||
|
||||
var outcome = UpdateSubTask(world, ship, subTask, deltaSeconds);
|
||||
switch (outcome)
|
||||
{
|
||||
case SubTaskOutcome.Active:
|
||||
return;
|
||||
case SubTaskOutcome.Completed:
|
||||
subTask.Status = WorkStatus.Completed;
|
||||
subTask.Progress = 1f;
|
||||
ship.ActiveSubTaskIndex += 1;
|
||||
if (ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count)
|
||||
{
|
||||
CompleteOrderExecution(ship, order, events);
|
||||
}
|
||||
|
||||
return;
|
||||
case SubTaskOutcome.Failed:
|
||||
subTask.Status = WorkStatus.Failed;
|
||||
FailOrderExecution(ship, order, subTask.BlockingReason ?? "subtask-failed", events);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void BeginOrderExecution(ShipRuntime ship, ShipOrderRuntime order, IReadOnlyList<ShipSubTaskRuntime> subTasks)
|
||||
{
|
||||
ship.ActiveOrderId = order.Id;
|
||||
ship.ActiveSubTaskIndex = 0;
|
||||
ship.ActiveSubTasks.Clear();
|
||||
ship.ActiveSubTasks.AddRange(subTasks);
|
||||
ship.NeedsReplan = false;
|
||||
ship.ReplanCooldownSeconds = 0f;
|
||||
ship.LastReplanReason = "order-execution-started";
|
||||
ship.LastDeltaSignature = string.Empty;
|
||||
}
|
||||
|
||||
private static void ClearActiveOrder(ShipRuntime ship)
|
||||
{
|
||||
ship.ActiveOrderId = null;
|
||||
ship.ActiveSubTaskIndex = 0;
|
||||
ship.ActiveSubTasks.Clear();
|
||||
}
|
||||
|
||||
private void CompleteOrderExecution(ShipRuntime ship, ShipOrderRuntime order, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
ship.OrderQueue.TryCompleteOrder(order.Id);
|
||||
if (order.SourceKind == ShipOrderSourceKind.Behavior
|
||||
&& string.Equals(order.SourceId, RepeatOrders, StringComparison.Ordinal)
|
||||
&& ship.DefaultBehavior.RepeatOrders.Count > 0)
|
||||
{
|
||||
ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count;
|
||||
}
|
||||
|
||||
ClearActiveOrder(ship);
|
||||
ship.NeedsReplan = true;
|
||||
ship.ReplanCooldownSeconds = 0.25f;
|
||||
ship.LastReplanReason = "order-completed";
|
||||
ship.LastDeltaSignature = string.Empty;
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "order-completed", $"{ship.Definition.Name} completed {order.Label ?? order.Kind}.", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private void FailOrderExecution(ShipRuntime ship, ShipOrderRuntime order, string failureReason, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
FailOrder(ship, order, failureReason);
|
||||
ClearActiveOrder(ship);
|
||||
ship.NeedsReplan = true;
|
||||
ship.ReplanCooldownSeconds = 0.5f;
|
||||
ship.LastReplanReason = failureReason;
|
||||
ship.LastDeltaSignature = string.Empty;
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "order-failed", $"{ship.Definition.Name} failed {order.Label ?? order.Kind}.", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private static void FailOrder(ShipRuntime ship, ShipOrderRuntime order, string failureReason)
|
||||
{
|
||||
ship.OrderQueue.TryFailOrder(order.Id, failureReason);
|
||||
ship.LastDeltaSignature = string.Empty;
|
||||
}
|
||||
|
||||
private static ShipSubTaskRuntime? GetCurrentSubTask(ShipRuntime ship) =>
|
||||
ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex];
|
||||
|
||||
private void ApplyIdleOrBlockedState(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var (behaviorKind, _) = ResolveBehaviorSource(world, ship);
|
||||
if (IsBehaviorBlockingFailure(behaviorKind, ship.LastAccessFailureReason))
|
||||
{
|
||||
ship.State = ShipState.Blocked;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return;
|
||||
}
|
||||
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
}
|
||||
|
||||
private void SyncEmergencyOrders(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var desiredOrder = BuildEmergencyOrder(world, ship);
|
||||
ship.OrderQueue.RemoveWhere(order =>
|
||||
order.SourceKind == ShipOrderSourceKind.Behavior
|
||||
&& string.Equals(order.SourceId, ShipOrderKinds.Flee, StringComparison.Ordinal)
|
||||
&& (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)));
|
||||
|
||||
if (desiredOrder is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.OrderQueue.AddOrReplaceManagedOrderAtFront(desiredOrder);
|
||||
}
|
||||
}
|
||||
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()
|
||||
{
|
||||
Post("/api/ships/{shipId}/orders");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
Delete("/api/ships/{shipId}/orders/{orderId}");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(RemoveShipOrderRequest request, CancellationToken cancellationToken)
|
||||
|
||||
31
apps/backend/Ships/Api/ReorderShipOrderHandler.cs
Normal file
31
apps/backend/Ships/Api/ReorderShipOrderHandler.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.Ships.Api;
|
||||
|
||||
public sealed class ReorderShipOrderHandler(WorldService worldService) : Endpoint<ShipOrderReorderRequest, ShipSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/ships/{shipId}/orders/{orderId}/position");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ShipOrderReorderRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var shipId = Route<string>("shipId");
|
||||
var orderId = Route<string>("orderId");
|
||||
if (string.IsNullOrWhiteSpace(shipId) || string.IsNullOrWhiteSpace(orderId))
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = worldService.ReorderShipOrder(shipId, orderId, request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ public sealed class UpdateShipDefaultBehaviorHandler(WorldService worldService)
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/ships/{shipId}/default-behavior");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ShipDefaultBehaviorCommandRequest request, CancellationToken cancellationToken)
|
||||
|
||||
39
apps/backend/Ships/Api/UpdateShipOrderHandler.cs
Normal file
39
apps/backend/Ships/Api/UpdateShipOrderHandler.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.Ships.Api;
|
||||
|
||||
public sealed class UpdateShipOrderHandler(WorldService worldService) : Endpoint<ShipOrderUpdateCommandRequest, ShipSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/ships/{shipId}/orders/{orderId}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ShipOrderUpdateCommandRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var shipId = Route<string>("shipId");
|
||||
var orderId = Route<string>("orderId");
|
||||
if (string.IsNullOrWhiteSpace(shipId) || string.IsNullOrWhiteSpace(orderId))
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = worldService.UpdateShipOrder(shipId, orderId, request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(cancellation: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -11,7 +11,7 @@ public sealed record ShipOrderCommandRequest(
|
||||
string? SourceStationId,
|
||||
string? DestinationStationId,
|
||||
string? ItemId,
|
||||
string? NodeId,
|
||||
string? AnchorId,
|
||||
string? ConstructionSiteId,
|
||||
string? ModuleId,
|
||||
float? WaitSeconds,
|
||||
@@ -19,6 +19,28 @@ public sealed record ShipOrderCommandRequest(
|
||||
int? MaxSystemRange,
|
||||
bool? KnownStationsOnly);
|
||||
|
||||
public sealed record ShipOrderUpdateCommandRequest(
|
||||
string Kind,
|
||||
int Priority,
|
||||
bool InterruptCurrentPlan,
|
||||
string? Label,
|
||||
string? TargetEntityId,
|
||||
string? TargetSystemId,
|
||||
Vector3Dto? TargetPosition,
|
||||
string? SourceStationId,
|
||||
string? DestinationStationId,
|
||||
string? ItemId,
|
||||
string? AnchorId,
|
||||
string? ConstructionSiteId,
|
||||
string? ModuleId,
|
||||
float? WaitSeconds,
|
||||
float? Radius,
|
||||
int? MaxSystemRange,
|
||||
bool? KnownStationsOnly);
|
||||
|
||||
public sealed record ShipOrderReorderRequest(
|
||||
int TargetIndex);
|
||||
|
||||
public sealed record ShipOrderTemplateCommandRequest(
|
||||
string Kind,
|
||||
string? Label,
|
||||
@@ -28,7 +50,7 @@ public sealed record ShipOrderTemplateCommandRequest(
|
||||
string? SourceStationId,
|
||||
string? DestinationStationId,
|
||||
string? ItemId,
|
||||
string? NodeId,
|
||||
string? AnchorId,
|
||||
string? ConstructionSiteId,
|
||||
string? ModuleId,
|
||||
float? WaitSeconds,
|
||||
@@ -42,8 +64,8 @@ public sealed record ShipDefaultBehaviorCommandRequest(
|
||||
string? HomeStationId,
|
||||
string? AreaSystemId,
|
||||
string? TargetEntityId,
|
||||
string? PreferredItemId,
|
||||
string? PreferredNodeId,
|
||||
string? ItemId,
|
||||
string? PreferredAnchorId,
|
||||
string? PreferredConstructionSiteId,
|
||||
string? PreferredModuleId,
|
||||
Vector3Dto? TargetPosition,
|
||||
|
||||
@@ -10,6 +10,8 @@ public sealed record ShipSkillProfileSnapshot(
|
||||
public sealed record ShipOrderSnapshot(
|
||||
string Id,
|
||||
string Kind,
|
||||
string SourceKind,
|
||||
string SourceId,
|
||||
string Status,
|
||||
int Priority,
|
||||
bool InterruptCurrentPlan,
|
||||
@@ -21,7 +23,7 @@ public sealed record ShipOrderSnapshot(
|
||||
string? SourceStationId,
|
||||
string? DestinationStationId,
|
||||
string? ItemId,
|
||||
string? NodeId,
|
||||
string? AnchorId,
|
||||
string? ConstructionSiteId,
|
||||
string? ModuleId,
|
||||
float WaitSeconds,
|
||||
@@ -39,7 +41,7 @@ public sealed record ShipOrderTemplateSnapshot(
|
||||
string? SourceStationId,
|
||||
string? DestinationStationId,
|
||||
string? ItemId,
|
||||
string? NodeId,
|
||||
string? AnchorId,
|
||||
string? ConstructionSiteId,
|
||||
string? ModuleId,
|
||||
float WaitSeconds,
|
||||
@@ -53,8 +55,8 @@ public sealed record DefaultBehaviorSnapshot(
|
||||
string? HomeStationId,
|
||||
string? AreaSystemId,
|
||||
string? TargetEntityId,
|
||||
string? PreferredItemId,
|
||||
string? PreferredNodeId,
|
||||
string? ItemId,
|
||||
string? PreferredAnchorId,
|
||||
string? PreferredConstructionSiteId,
|
||||
string? PreferredModuleId,
|
||||
Vector3Dto? TargetPosition,
|
||||
@@ -93,7 +95,9 @@ public sealed record ShipSubTaskSnapshot(
|
||||
string Summary,
|
||||
string? TargetEntityId,
|
||||
string? TargetSystemId,
|
||||
string? TargetNodeId,
|
||||
string? TargetAnchorId,
|
||||
string? TargetResourceNodeId,
|
||||
string? TargetResourceDepositId,
|
||||
Vector3Dto? TargetPosition,
|
||||
string? ItemId,
|
||||
string? ModuleId,
|
||||
@@ -104,35 +108,13 @@ public sealed record ShipSubTaskSnapshot(
|
||||
float TotalSeconds,
|
||||
string? BlockingReason);
|
||||
|
||||
public sealed record ShipPlanStepSnapshot(
|
||||
string Id,
|
||||
string Kind,
|
||||
string Status,
|
||||
string Summary,
|
||||
string? BlockingReason,
|
||||
int CurrentSubTaskIndex,
|
||||
IReadOnlyList<ShipSubTaskSnapshot> SubTasks);
|
||||
|
||||
public sealed record ShipPlanSnapshot(
|
||||
string Id,
|
||||
string SourceKind,
|
||||
string SourceId,
|
||||
string Kind,
|
||||
string Status,
|
||||
string Summary,
|
||||
int CurrentStepIndex,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string? InterruptReason,
|
||||
string? FailureReason,
|
||||
IReadOnlyList<ShipPlanStepSnapshot> Steps);
|
||||
|
||||
public sealed record ShipSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string Kind,
|
||||
string Class,
|
||||
string Name,
|
||||
string Purpose,
|
||||
string Type,
|
||||
string SystemId,
|
||||
string? AnchorId,
|
||||
Vector3Dto LocalPosition,
|
||||
Vector3Dto LocalVelocity,
|
||||
Vector3Dto TargetLocalPosition,
|
||||
@@ -141,19 +123,17 @@ public sealed record ShipSnapshot(
|
||||
DefaultBehaviorSnapshot DefaultBehavior,
|
||||
ShipAssignmentSnapshot? Assignment,
|
||||
ShipSkillProfileSnapshot Skills,
|
||||
ShipPlanSnapshot? ActivePlan,
|
||||
string? CurrentStepId,
|
||||
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
|
||||
string ControlSourceKind,
|
||||
string? ControlSourceId,
|
||||
string? ControlReason,
|
||||
string? LastReplanReason,
|
||||
string? LastAccessFailureReason,
|
||||
string? CelestialId,
|
||||
string? DockedStationId,
|
||||
string? CommanderId,
|
||||
string? PolicySetId,
|
||||
float CargoCapacity,
|
||||
IReadOnlyList<string> CargoTypes,
|
||||
float TravelSpeed,
|
||||
string TravelSpeedUnit,
|
||||
IReadOnlyList<InventoryEntry> Inventory,
|
||||
@@ -164,10 +144,11 @@ public sealed record ShipSnapshot(
|
||||
|
||||
public sealed record ShipDelta(
|
||||
string Id,
|
||||
string Label,
|
||||
string Kind,
|
||||
string Class,
|
||||
string Name,
|
||||
string Purpose,
|
||||
string Type,
|
||||
string SystemId,
|
||||
string? AnchorId,
|
||||
Vector3Dto LocalPosition,
|
||||
Vector3Dto LocalVelocity,
|
||||
Vector3Dto TargetLocalPosition,
|
||||
@@ -176,19 +157,17 @@ public sealed record ShipDelta(
|
||||
DefaultBehaviorSnapshot DefaultBehavior,
|
||||
ShipAssignmentSnapshot? Assignment,
|
||||
ShipSkillProfileSnapshot Skills,
|
||||
ShipPlanSnapshot? ActivePlan,
|
||||
string? CurrentStepId,
|
||||
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
|
||||
string ControlSourceKind,
|
||||
string? ControlSourceId,
|
||||
string? ControlReason,
|
||||
string? LastReplanReason,
|
||||
string? LastAccessFailureReason,
|
||||
string? CelestialId,
|
||||
string? DockedStationId,
|
||||
string? CommanderId,
|
||||
string? PolicySetId,
|
||||
float CargoCapacity,
|
||||
IReadOnlyList<string> CargoTypes,
|
||||
float TravelSpeed,
|
||||
string TravelSpeedUnit,
|
||||
IReadOnlyList<InventoryEntry> Inventory,
|
||||
@@ -200,17 +179,17 @@ public sealed record ShipDelta(
|
||||
public sealed record ShipSpatialStateSnapshot(
|
||||
string SpaceLayer,
|
||||
string CurrentSystemId,
|
||||
string? CurrentCelestialId,
|
||||
string? CurrentAnchorId,
|
||||
Vector3Dto? LocalPosition,
|
||||
Vector3Dto? SystemPosition,
|
||||
string MovementRegime,
|
||||
string? DestinationNodeId,
|
||||
string? DestinationAnchorId,
|
||||
ShipTransitSnapshot? Transit);
|
||||
|
||||
public sealed record ShipTransitSnapshot(
|
||||
string Regime,
|
||||
string? OriginNodeId,
|
||||
string? DestinationNodeId,
|
||||
string? OriginAnchorId,
|
||||
string? DestinationAnchorId,
|
||||
DateTimeOffset? StartedAtUtc,
|
||||
DateTimeOffset? ArrivalDueAtUtc,
|
||||
float Progress);
|
||||
|
||||
@@ -12,8 +12,7 @@ public sealed class ShipRuntime
|
||||
public Vector3 Velocity { get; set; } = Vector3.Zero;
|
||||
public ShipState State { get; set; } = ShipState.Idle;
|
||||
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
|
||||
public List<ShipOrderRuntime> OrderQueue { get; } = [];
|
||||
public ShipPlanRuntime? ActivePlan { get; set; }
|
||||
public ShipOrderQueue OrderQueue { get; } = new();
|
||||
public required ShipSkillProfileRuntime Skills { get; set; }
|
||||
public bool NeedsReplan { get; set; } = true;
|
||||
public float ReplanCooldownSeconds { get; set; }
|
||||
@@ -30,10 +29,190 @@ public sealed class ShipRuntime
|
||||
public float Health { get; set; }
|
||||
public HashSet<string> KnownStationIds { get; } = new(StringComparer.Ordinal);
|
||||
public List<string> History { get; } = [];
|
||||
public string? ActiveOrderId { get; set; }
|
||||
public int ActiveSubTaskIndex { get; set; }
|
||||
public List<ShipSubTaskRuntime> ActiveSubTasks { get; } = [];
|
||||
public string LastSignature { get; set; } = string.Empty;
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ShipOrderQueue : IReadOnlyList<ShipOrderRuntime>
|
||||
{
|
||||
public const int MaxOrders = 8;
|
||||
|
||||
private readonly List<ShipOrderRuntime> _orders = [];
|
||||
|
||||
public int Count => _orders.Count;
|
||||
|
||||
public ShipOrderRuntime this[int index] => _orders[index];
|
||||
|
||||
public IEnumerator<ShipOrderRuntime> GetEnumerator() => _orders.GetEnumerator();
|
||||
|
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
|
||||
public void Enqueue(ShipOrderRuntime order)
|
||||
{
|
||||
if (_orders.Count >= MaxOrders)
|
||||
{
|
||||
throw new InvalidOperationException("Order queue is full.");
|
||||
}
|
||||
|
||||
_orders.Add(order);
|
||||
}
|
||||
|
||||
public void EnqueuePlayerOrder(ShipOrderRuntime order)
|
||||
{
|
||||
if (order.SourceKind != ShipOrderSourceKind.Player)
|
||||
{
|
||||
throw new InvalidOperationException("Player segment only accepts player orders.");
|
||||
}
|
||||
|
||||
EnsureCapacityForNewOrder(order.Id);
|
||||
_orders.Insert(GetManagedInsertionIndex(), order);
|
||||
}
|
||||
|
||||
public void EnqueueManagedOrder(ShipOrderRuntime order)
|
||||
{
|
||||
EnsureCapacityForNewOrder(order.Id);
|
||||
_orders.Add(order);
|
||||
}
|
||||
|
||||
public void AddOrReplaceManagedOrder(ShipOrderRuntime order)
|
||||
=> AddOrReplaceManagedOrder(order, insertAtFront: false);
|
||||
|
||||
public void AddOrReplaceManagedOrderAtFront(ShipOrderRuntime order)
|
||||
=> AddOrReplaceManagedOrder(order, insertAtFront: true);
|
||||
|
||||
private void AddOrReplaceManagedOrder(ShipOrderRuntime order, bool insertAtFront)
|
||||
{
|
||||
var existingIndex = _orders.FindIndex(candidate => string.Equals(candidate.Id, order.Id, StringComparison.Ordinal));
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
_orders[existingIndex] = order;
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureCapacityForNewOrder(order.Id);
|
||||
if (insertAtFront)
|
||||
{
|
||||
_orders.Insert(GetManagedInsertionIndex(), order);
|
||||
return;
|
||||
}
|
||||
|
||||
_orders.Add(order);
|
||||
}
|
||||
|
||||
public bool Remove(ShipOrderRuntime order) => RemoveById(order.Id);
|
||||
|
||||
public bool RemoveById(string orderId) => _orders.RemoveAll(order => string.Equals(order.Id, orderId, StringComparison.Ordinal)) > 0;
|
||||
|
||||
public int RemoveWhere(Predicate<ShipOrderRuntime> predicate) => _orders.RemoveAll(predicate);
|
||||
|
||||
public ShipOrderRuntime? FindById(string orderId) => _orders.FirstOrDefault(order => string.Equals(order.Id, orderId, StringComparison.Ordinal));
|
||||
|
||||
public ShipOrderRuntime? FindLeadingOrderForSource(ShipOrderSourceKind sourceKind) =>
|
||||
_orders.FirstOrDefault(order => order.SourceKind == sourceKind);
|
||||
|
||||
public string? GetLeadingOrderLabelForSource(ShipOrderSourceKind sourceKind) =>
|
||||
FindLeadingOrderForSource(sourceKind) is { } order
|
||||
? order.Label ?? order.Kind
|
||||
: null;
|
||||
|
||||
public bool HasOrdersFromSource(ShipOrderSourceKind sourceKind) => _orders.Any(order => order.SourceKind == sourceKind);
|
||||
|
||||
public ShipOrderRuntime? GetCurrentOrder() =>
|
||||
_orders.FirstOrDefault(order => order.Status is OrderStatus.Queued or OrderStatus.Active);
|
||||
|
||||
public bool TryMovePlayerOrder(string orderId, int targetIndex)
|
||||
{
|
||||
var currentIndex = _orders.FindIndex(order => string.Equals(order.Id, orderId, StringComparison.Ordinal));
|
||||
if (currentIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var order = _orders[currentIndex];
|
||||
if (order.SourceKind != ShipOrderSourceKind.Player)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var playerOrderIds = _orders
|
||||
.Select((candidate, index) => (candidate, index))
|
||||
.Where(entry => entry.candidate.SourceKind == ShipOrderSourceKind.Player)
|
||||
.Select(entry => entry.index)
|
||||
.ToList();
|
||||
if (playerOrderIds.Count <= 1)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var clampedPlayerIndex = Math.Clamp(targetIndex, 0, playerOrderIds.Count - 1);
|
||||
var destinationIndex = playerOrderIds[clampedPlayerIndex];
|
||||
if (currentIndex == destinationIndex)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_orders.RemoveAt(currentIndex);
|
||||
if (currentIndex < destinationIndex)
|
||||
{
|
||||
destinationIndex -= 1;
|
||||
}
|
||||
|
||||
_orders.Insert(destinationIndex, order);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryCompleteOrder(string orderId) => TryTransitionOrder(orderId, OrderStatus.Completed);
|
||||
|
||||
public bool TryFailOrder(string orderId, string? failureReason = null)
|
||||
{
|
||||
var order = FindById(orderId);
|
||||
if (order is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
order.FailureReason = failureReason ?? order.FailureReason;
|
||||
if (order.SourceKind == ShipOrderSourceKind.Player)
|
||||
{
|
||||
order.Status = OrderStatus.Failed;
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryTransitionOrder(orderId, OrderStatus.Failed);
|
||||
}
|
||||
|
||||
public bool TryTransitionOrder(string orderId, OrderStatus terminalStatus)
|
||||
{
|
||||
var order = FindById(orderId);
|
||||
if (order is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
order.Status = terminalStatus;
|
||||
return RemoveById(orderId);
|
||||
}
|
||||
|
||||
private int GetManagedInsertionIndex() =>
|
||||
_orders.TakeWhile(order => order.SourceKind == ShipOrderSourceKind.Player).Count();
|
||||
|
||||
private void EnsureCapacityForNewOrder(string orderId)
|
||||
{
|
||||
if (FindById(orderId) is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_orders.Count >= MaxOrders)
|
||||
{
|
||||
throw new InvalidOperationException("Order queue is full.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ShipSkillProfileRuntime
|
||||
{
|
||||
public int Navigation { get; set; }
|
||||
@@ -47,6 +226,8 @@ public sealed class ShipOrderRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required ShipOrderSourceKind SourceKind { get; init; }
|
||||
public required string SourceId { get; init; }
|
||||
public OrderStatus Status { get; set; } = OrderStatus.Queued;
|
||||
public int Priority { get; set; }
|
||||
public bool InterruptCurrentPlan { get; set; } = true;
|
||||
@@ -58,7 +239,7 @@ public sealed class ShipOrderRuntime
|
||||
public string? SourceStationId { get; set; }
|
||||
public string? DestinationStationId { get; set; }
|
||||
public string? ItemId { get; set; }
|
||||
public string? NodeId { get; set; }
|
||||
public string? AnchorId { get; set; }
|
||||
public string? ConstructionSiteId { get; set; }
|
||||
public string? ModuleId { get; set; }
|
||||
public float WaitSeconds { get; set; }
|
||||
@@ -75,8 +256,8 @@ public sealed class DefaultBehaviorRuntime
|
||||
public string? HomeStationId { get; set; }
|
||||
public string? AreaSystemId { get; set; }
|
||||
public string? TargetEntityId { get; set; }
|
||||
public string? PreferredItemId { get; set; }
|
||||
public string? PreferredNodeId { get; set; }
|
||||
public string? ItemId { get; set; }
|
||||
public string? PreferredAnchorId { get; set; }
|
||||
public string? PreferredConstructionSiteId { get; set; }
|
||||
public string? PreferredModuleId { get; set; }
|
||||
public Vector3? TargetPosition { get; set; }
|
||||
@@ -100,7 +281,7 @@ public sealed class ShipOrderTemplateRuntime
|
||||
public string? SourceStationId { get; set; }
|
||||
public string? DestinationStationId { get; set; }
|
||||
public string? ItemId { get; set; }
|
||||
public string? NodeId { get; set; }
|
||||
public string? AnchorId { get; set; }
|
||||
public string? ConstructionSiteId { get; set; }
|
||||
public string? ModuleId { get; set; }
|
||||
public float WaitSeconds { get; set; }
|
||||
@@ -109,33 +290,6 @@ public sealed class ShipOrderTemplateRuntime
|
||||
public bool KnownStationsOnly { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ShipPlanRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required AiPlanSourceKind SourceKind { get; init; }
|
||||
public required string SourceId { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string Summary { get; set; }
|
||||
public AiPlanStatus Status { get; set; } = AiPlanStatus.Planned;
|
||||
public int CurrentStepIndex { get; set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public string? InterruptReason { get; set; }
|
||||
public string? FailureReason { get; set; }
|
||||
public List<ShipPlanStepRuntime> Steps { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class ShipPlanStepRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string Summary { get; set; }
|
||||
public AiPlanStepStatus Status { get; set; } = AiPlanStepStatus.Planned;
|
||||
public int CurrentSubTaskIndex { get; set; }
|
||||
public string? BlockingReason { get; set; }
|
||||
public List<ShipSubTaskRuntime> SubTasks { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class ShipSubTaskRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
@@ -144,7 +298,9 @@ public sealed class ShipSubTaskRuntime
|
||||
public WorkStatus Status { get; set; } = WorkStatus.Pending;
|
||||
public string? TargetEntityId { get; set; }
|
||||
public string? TargetSystemId { get; set; }
|
||||
public string? TargetNodeId { get; set; }
|
||||
public string? TargetAnchorId { get; set; }
|
||||
public string? TargetResourceNodeId { get; set; }
|
||||
public string? TargetResourceDepositId { get; set; }
|
||||
public Vector3? TargetPosition { get; set; }
|
||||
public string? ItemId { get; set; }
|
||||
public string? ModuleId { get; set; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
|
||||
namespace SpaceGame.Api.Simulation.Core;
|
||||
|
||||
public sealed class SimulationEngine
|
||||
internal sealed class SimulationEngine
|
||||
{
|
||||
private readonly IBalanceService _balance;
|
||||
private readonly IPlayerStateStore _playerStateStore;
|
||||
private readonly OrbitalSimulationOptions _orbitalSimulation;
|
||||
private readonly OrbitalStateUpdater _orbitalStateUpdater;
|
||||
private readonly InfrastructureSimulationService _infrastructureSimulation;
|
||||
@@ -14,9 +15,11 @@ public sealed class SimulationEngine
|
||||
private readonly ShipAiService _shipAi;
|
||||
private readonly SimulationProjectionService _projection;
|
||||
|
||||
public SimulationEngine(OrbitalSimulationOptions? orbitalSimulation = null)
|
||||
internal SimulationEngine(OrbitalSimulationOptions orbitalSimulation, IBalanceService balance, IPlayerStateStore playerStateStore)
|
||||
{
|
||||
_orbitalSimulation = orbitalSimulation ?? new OrbitalSimulationOptions();
|
||||
_balance = balance;
|
||||
_playerStateStore = playerStateStore;
|
||||
_orbitalSimulation = orbitalSimulation;
|
||||
_orbitalStateUpdater = new OrbitalStateUpdater(_orbitalSimulation);
|
||||
_infrastructureSimulation = new InfrastructureSimulationService();
|
||||
_geopolitics = new GeopoliticalSimulationService();
|
||||
@@ -24,7 +27,7 @@ public sealed class SimulationEngine
|
||||
_playerFaction = new PlayerFactionService();
|
||||
_stationSimulation = new StationSimulationService();
|
||||
_stationLifecycle = new StationLifecycleService(_stationSimulation);
|
||||
_shipAi = new ShipAiService();
|
||||
_shipAi = new ShipAiService(balance);
|
||||
_projection = new SimulationProjectionService(_orbitalSimulation);
|
||||
}
|
||||
|
||||
@@ -32,7 +35,7 @@ public sealed class SimulationEngine
|
||||
{
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var events = new List<SimulationEventRecord>();
|
||||
var simulationDeltaSeconds = deltaSeconds * MathF.Max(world.Balance.SimulationSpeedMultiplier, 0.01f);
|
||||
var simulationDeltaSeconds = deltaSeconds * MathF.Max(_balance.SimulationSpeedMultiplier, 0.01f);
|
||||
world.GeneratedAtUtc = nowUtc;
|
||||
|
||||
world.OrbitalTimeSeconds += simulationDeltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond;
|
||||
@@ -41,8 +44,8 @@ public sealed class SimulationEngine
|
||||
_infrastructureSimulation.UpdateClaims(world, events);
|
||||
_infrastructureSimulation.UpdateConstructionSites(world, events);
|
||||
_geopolitics.Update(world, simulationDeltaSeconds, events);
|
||||
_commanderPlanning.UpdateCommanders(world, simulationDeltaSeconds, events);
|
||||
_playerFaction.Update(world, simulationDeltaSeconds, events);
|
||||
_commanderPlanning.UpdateCommanders(world, _playerStateStore, simulationDeltaSeconds, events);
|
||||
_playerFaction.Update(world, _playerStateStore, simulationDeltaSeconds, events);
|
||||
_stationLifecycle.UpdateStations(world, simulationDeltaSeconds, events);
|
||||
|
||||
foreach (var ship in world.Ships.ToList())
|
||||
@@ -75,7 +78,7 @@ public sealed class SimulationEngine
|
||||
{
|
||||
foreach (var ship in world.Ships.Where(candidate => candidate.Health <= 0f).ToList())
|
||||
{
|
||||
CreateWreck(world, "ship", ship.Id, ship.SystemId, ship.Position, ship.Definition.CargoCapacity + (ship.Definition.MaxHealth * 0.08f));
|
||||
CreateWreck(world, "ship", ship.Id, ship.SystemId, ship.Position, ship.Definition.GetTotalCargoCapacity() + (ship.Definition.Hull * 0.08f));
|
||||
world.Ships.Remove(ship);
|
||||
if (ship.DockedStationId is not null && world.Stations.FirstOrDefault(station => station.Id == ship.DockedStationId) is { } dockedStation)
|
||||
{
|
||||
@@ -93,7 +96,7 @@ public sealed class SimulationEngine
|
||||
commander.IsAlive = false;
|
||||
}
|
||||
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "destroyed", $"{ship.Definition.Label} was destroyed.", DateTimeOffset.UtcNow));
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "destroyed", $"{ship.Definition.Name} was destroyed.", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
foreach (var station in world.Stations.Where(candidate => candidate.Health <= 0f).ToList())
|
||||
@@ -101,12 +104,12 @@ public sealed class SimulationEngine
|
||||
CreateWreck(world, "station", station.Id, station.SystemId, station.Position, station.MaxHealth * 0.12f);
|
||||
world.Stations.Remove(station);
|
||||
|
||||
if (station.CelestialId is not null && world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId) is { } celestial)
|
||||
if (station.AnchorId is not null && world.Anchors.FirstOrDefault(candidate => candidate.Id == station.AnchorId) is { } anchor)
|
||||
{
|
||||
celestial.OccupyingStructureId = null;
|
||||
anchor.OccupyingStructureId = null;
|
||||
}
|
||||
|
||||
foreach (var claim in world.Claims.Where(candidate => candidate.CelestialId == station.CelestialId))
|
||||
foreach (var claim in world.Claims.Where(candidate => candidate.AnchorId == station.AnchorId))
|
||||
{
|
||||
claim.Health = 0f;
|
||||
claim.State = ClaimStateKinds.Destroyed;
|
||||
|
||||
@@ -24,6 +24,7 @@ internal sealed class SimulationProjectionService
|
||||
false,
|
||||
events,
|
||||
BuildCelestialDeltas(world),
|
||||
BuildAnchorDeltas(world),
|
||||
BuildNodeDeltas(world),
|
||||
BuildStationDeltas(world),
|
||||
BuildClaimDeltas(world),
|
||||
@@ -32,7 +33,6 @@ internal sealed class SimulationProjectionService
|
||||
BuildPolicyDeltas(world),
|
||||
BuildShipDeltas(world),
|
||||
BuildFactionDeltas(world),
|
||||
BuildPlayerFactionDelta(world),
|
||||
BuildGeopoliticsDelta(world));
|
||||
|
||||
public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence)
|
||||
@@ -88,26 +88,37 @@ internal sealed class SimulationProjectionService
|
||||
c.Kind,
|
||||
c.OrbitalAnchor,
|
||||
c.LocalSpaceRadius,
|
||||
c.ParentNodeId,
|
||||
c.ParentAnchorId,
|
||||
c.OccupyingStructureId,
|
||||
c.OrbitReferenceId)).ToList(),
|
||||
world.Anchors.Select(ToAnchorDelta).Select(anchor => new AnchorSnapshot(
|
||||
anchor.Id,
|
||||
anchor.SystemId,
|
||||
anchor.Kind,
|
||||
anchor.SystemPosition,
|
||||
anchor.LocalSpaceRadius,
|
||||
anchor.ParentAnchorId,
|
||||
anchor.OccupyingStructureId,
|
||||
anchor.OrbitReferenceId)).ToList(),
|
||||
world.Nodes.Select(ToNodeDelta).Select(node => new ResourceNodeSnapshot(
|
||||
node.Id,
|
||||
node.AnchorId,
|
||||
node.SystemId,
|
||||
node.LocalPosition,
|
||||
node.CelestialId,
|
||||
node.LocalSpaceRadius,
|
||||
node.SourceKind,
|
||||
node.OreRemaining,
|
||||
node.MaxOre,
|
||||
node.ItemId)).ToList(),
|
||||
node.ItemId,
|
||||
node.Deposits)).ToList(),
|
||||
world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot(
|
||||
station.Id,
|
||||
station.Label,
|
||||
station.Category,
|
||||
station.Objective,
|
||||
station.SystemId,
|
||||
station.AnchorId,
|
||||
station.LocalPosition,
|
||||
station.CelestialId,
|
||||
station.Color,
|
||||
station.DockedShips,
|
||||
station.DockedShipIds,
|
||||
@@ -128,7 +139,7 @@ internal sealed class SimulationProjectionService
|
||||
claim.Id,
|
||||
claim.FactionId,
|
||||
claim.SystemId,
|
||||
claim.CelestialId,
|
||||
claim.AnchorId,
|
||||
claim.State,
|
||||
claim.Health,
|
||||
claim.PlacedAtUtc,
|
||||
@@ -137,7 +148,7 @@ internal sealed class SimulationProjectionService
|
||||
site.Id,
|
||||
site.FactionId,
|
||||
site.SystemId,
|
||||
site.CelestialId,
|
||||
site.AnchorId,
|
||||
site.TargetKind,
|
||||
site.TargetDefinitionId,
|
||||
site.BlueprintId,
|
||||
@@ -177,10 +188,11 @@ internal sealed class SimulationProjectionService
|
||||
policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(),
|
||||
world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot(
|
||||
ship.Id,
|
||||
ship.Label,
|
||||
ship.Kind,
|
||||
ship.Class,
|
||||
ship.Name,
|
||||
ship.Purpose,
|
||||
ship.Type,
|
||||
ship.SystemId,
|
||||
ship.AnchorId,
|
||||
ship.LocalPosition,
|
||||
ship.LocalVelocity,
|
||||
ship.TargetLocalPosition,
|
||||
@@ -189,19 +201,17 @@ internal sealed class SimulationProjectionService
|
||||
ship.DefaultBehavior,
|
||||
ship.Assignment,
|
||||
ship.Skills,
|
||||
ship.ActivePlan,
|
||||
ship.CurrentStepId,
|
||||
ship.ActiveSubTasks,
|
||||
ship.ControlSourceKind,
|
||||
ship.ControlSourceId,
|
||||
ship.ControlReason,
|
||||
ship.LastReplanReason,
|
||||
ship.LastAccessFailureReason,
|
||||
ship.CelestialId,
|
||||
ship.DockedStationId,
|
||||
ship.CommanderId,
|
||||
ship.PolicySetId,
|
||||
ship.CargoCapacity,
|
||||
ship.CargoTypes,
|
||||
ship.TravelSpeed,
|
||||
ship.TravelSpeedUnit,
|
||||
ship.Inventory,
|
||||
@@ -225,7 +235,6 @@ internal sealed class SimulationProjectionService
|
||||
faction.StrategicState,
|
||||
faction.DecisionLog,
|
||||
faction.Commanders)).ToList(),
|
||||
ToPlayerFactionSnapshot(world.PlayerFaction),
|
||||
ToGeopoliticalStateSnapshot(world.Geopolitics));
|
||||
}
|
||||
|
||||
@@ -241,6 +250,11 @@ internal sealed class SimulationProjectionService
|
||||
celestial.LastDeltaSignature = BuildCelestialSignature(celestial);
|
||||
}
|
||||
|
||||
foreach (var anchor in world.Anchors)
|
||||
{
|
||||
anchor.LastDeltaSignature = BuildAnchorSignature(anchor);
|
||||
}
|
||||
|
||||
foreach (var station in world.Stations)
|
||||
{
|
||||
station.LastDeltaSignature = BuildStationSignature(world, station);
|
||||
@@ -276,11 +290,6 @@ internal sealed class SimulationProjectionService
|
||||
faction.LastDeltaSignature = BuildFactionSignature(faction, FindFactionCommander(world, faction.Id));
|
||||
}
|
||||
|
||||
if (world.PlayerFaction is not null)
|
||||
{
|
||||
world.PlayerFaction.LastDeltaSignature = BuildPlayerFactionSignature(world.PlayerFaction);
|
||||
}
|
||||
|
||||
if (world.Geopolitics is not null)
|
||||
{
|
||||
world.Geopolitics.LastDeltaSignature = BuildGeopoliticalSignature(world.Geopolitics);
|
||||
@@ -305,6 +314,24 @@ internal sealed class SimulationProjectionService
|
||||
return deltas;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AnchorDelta> BuildAnchorDeltas(SimulationWorld world)
|
||||
{
|
||||
var deltas = new List<AnchorDelta>();
|
||||
foreach (var anchor in world.Anchors)
|
||||
{
|
||||
var signature = BuildAnchorSignature(anchor);
|
||||
if (signature == anchor.LastDeltaSignature)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
anchor.LastDeltaSignature = signature;
|
||||
deltas.Add(ToAnchorDelta(anchor));
|
||||
}
|
||||
|
||||
return deltas;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CelestialDelta> BuildCelestialDeltas(SimulationWorld world)
|
||||
{
|
||||
var deltas = new List<CelestialDelta>();
|
||||
@@ -450,23 +477,6 @@ internal sealed class SimulationProjectionService
|
||||
return deltas;
|
||||
}
|
||||
|
||||
private static PlayerFactionSnapshot? BuildPlayerFactionDelta(SimulationWorld world)
|
||||
{
|
||||
if (world.PlayerFaction is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var signature = BuildPlayerFactionSignature(world.PlayerFaction);
|
||||
if (signature == world.PlayerFaction.LastDeltaSignature)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
world.PlayerFaction.LastDeltaSignature = signature;
|
||||
return ToPlayerFactionSnapshot(world.PlayerFaction);
|
||||
}
|
||||
|
||||
private static GeopoliticalStateSnapshot? BuildGeopoliticsDelta(SimulationWorld world)
|
||||
{
|
||||
if (world.Geopolitics is null)
|
||||
@@ -490,17 +500,30 @@ internal sealed class SimulationProjectionService
|
||||
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
|
||||
|
||||
private static string BuildNodeSignature(ResourceNodeRuntime node) =>
|
||||
$"{node.SystemId}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.CelestialId}|{node.OreRemaining:0.###}";
|
||||
string.Join("|",
|
||||
node.SystemId,
|
||||
node.AnchorId,
|
||||
$"{node.Position.X:0.###}",
|
||||
$"{node.Position.Y:0.###}",
|
||||
$"{node.Position.Z:0.###}",
|
||||
$"{node.OreRemaining:0.###}",
|
||||
string.Join(",",
|
||||
node.Deposits
|
||||
.OrderBy(deposit => deposit.Id, StringComparer.Ordinal)
|
||||
.Select(deposit => $"{deposit.Id}:{deposit.Position.X:0.###}:{deposit.Position.Y:0.###}:{deposit.Position.Z:0.###}:{deposit.OreRemaining:0.###}")));
|
||||
|
||||
private static string BuildCelestialSignature(CelestialRuntime celestial) =>
|
||||
$"{celestial.SystemId}|{celestial.Kind.ToContractValue()}|{celestial.Position.X:0.###}|{celestial.Position.Y:0.###}|{celestial.Position.Z:0.###}|{celestial.LocalSpaceRadius:0.###}|{celestial.ParentNodeId}|{celestial.OccupyingStructureId}|{celestial.OrbitReferenceId}";
|
||||
$"{celestial.SystemId}|{celestial.Kind.ToContractValue()}|{celestial.Position.X:0.###}|{celestial.Position.Y:0.###}|{celestial.Position.Z:0.###}|{celestial.LocalSpaceRadius:0.###}|{celestial.ParentAnchorId}|{celestial.OccupyingStructureId}|{celestial.OrbitReferenceId}";
|
||||
|
||||
private static string BuildAnchorSignature(AnchorRuntime anchor) =>
|
||||
$"{anchor.SystemId}|{anchor.Kind.ToContractValue()}|{anchor.Position.X:0.###}|{anchor.Position.Y:0.###}|{anchor.Position.Z:0.###}|{anchor.LocalSpaceRadius:0.###}|{anchor.ParentAnchorId}|{anchor.OccupyingStructureId}|{anchor.OrbitReferenceId}|{anchor.SourceEntityKind}|{anchor.SourceEntityId}";
|
||||
|
||||
private static string BuildStationSignature(SimulationWorld world, StationRuntime station)
|
||||
{
|
||||
var processes = ToStationActionProgressSnapshots(world, station);
|
||||
return string.Join("|",
|
||||
station.SystemId,
|
||||
station.CelestialId ?? "none",
|
||||
station.AnchorId ?? "none",
|
||||
station.CommanderId ?? "none",
|
||||
station.PolicySetId ?? "none",
|
||||
BuildInventorySignature(station.Inventory),
|
||||
@@ -519,10 +542,10 @@ internal sealed class SimulationProjectionService
|
||||
}
|
||||
|
||||
private static string BuildClaimSignature(ClaimRuntime claim) =>
|
||||
$"{claim.FactionId}|{claim.SystemId}|{claim.CelestialId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}";
|
||||
$"{claim.FactionId}|{claim.SystemId}|{claim.AnchorId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}";
|
||||
|
||||
private static string BuildConstructionSiteSignature(ConstructionSiteRuntime site) =>
|
||||
$"{site.FactionId}|{site.SystemId}|{site.CelestialId}|{site.TargetKind}|{site.TargetDefinitionId}|{site.BlueprintId}|{site.ClaimId}|{site.StationId}|{site.State}|{site.Progress:0.###}|{BuildInventorySignature(site.Inventory)}|{BuildInventorySignature(site.RequiredItems)}|{BuildInventorySignature(site.DeliveredItems)}|{string.Join(",", site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal))}";
|
||||
$"{site.FactionId}|{site.SystemId}|{site.AnchorId}|{site.TargetKind}|{site.TargetDefinitionId}|{site.BlueprintId}|{site.ClaimId}|{site.StationId}|{site.State}|{site.Progress:0.###}|{BuildInventorySignature(site.Inventory)}|{BuildInventorySignature(site.RequiredItems)}|{BuildInventorySignature(site.DeliveredItems)}|{string.Join(",", site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal))}";
|
||||
|
||||
private static string BuildMarketOrderSignature(MarketOrderRuntime order) =>
|
||||
$"{order.FactionId}|{order.StationId}|{order.ConstructionSiteId}|{order.Kind}|{order.ItemId}|{order.Amount:0.###}|{order.RemainingAmount:0.###}|{order.Valuation:0.###}|{order.ReserveThreshold?.ToString("0.###") ?? "none"}|{order.PolicySetId}|{order.State}";
|
||||
@@ -544,11 +567,10 @@ internal sealed class SimulationProjectionService
|
||||
ship.TargetPosition.Z.ToString("0.###"),
|
||||
ship.State.ToContractValue(),
|
||||
string.Join(",", ship.OrderQueue
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => $"{order.Id}:{order.Kind}:{order.Status.ToContractValue()}:{order.Priority}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")),
|
||||
.Select(order => $"{order.Id}:{order.Kind}:{order.SourceKind.ToContractValue()}:{order.SourceId}:{order.Status.ToContractValue()}:{order.Priority}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")),
|
||||
ship.DefaultBehavior.Kind,
|
||||
ship.DefaultBehavior.TargetEntityId ?? "none",
|
||||
ship.DefaultBehavior.ItemId ?? "none",
|
||||
ship.DefaultBehavior.TargetPosition?.X.ToString("0.###") ?? "none",
|
||||
ship.DefaultBehavior.TargetPosition?.Y.ToString("0.###") ?? "none",
|
||||
ship.DefaultBehavior.TargetPosition?.Z.ToString("0.###") ?? "none",
|
||||
@@ -568,23 +590,20 @@ internal sealed class SimulationProjectionService
|
||||
ship.CommanderId is not null && world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment is { } assignment
|
||||
? $"{assignment.ObjectiveId}:{assignment.Kind}:{assignment.BehaviorKind}:{assignment.Status}:{assignment.CampaignId}:{assignment.TheaterId}:{assignment.TargetSystemId}:{assignment.TargetEntityId}:{assignment.ItemId}:{assignment.Priority:0.###}:{assignment.UpdatedAtUtc.UtcTicks}"
|
||||
: "no-assignment",
|
||||
ship.ActivePlan?.Kind ?? "none",
|
||||
ship.ActivePlan?.Status.ToContractValue() ?? "none",
|
||||
ship.ActivePlan?.CurrentStepIndex.ToString(CultureInfo.InvariantCulture) ?? "-1",
|
||||
string.Join(",",
|
||||
ToActiveSubTaskSnapshots(ship).Select(subTask =>
|
||||
$"{subTask.Id}:{subTask.Kind}:{subTask.Status}:{subTask.Progress:0.###}:{subTask.ElapsedSeconds:0.###}:{subTask.BlockingReason ?? "none"}")),
|
||||
ship.SpatialState.CurrentCelestialId ?? "none",
|
||||
ship.SpatialState.CurrentAnchorId ?? "none",
|
||||
ship.DockedStationId ?? "none",
|
||||
ship.CommanderId ?? "none",
|
||||
ship.PolicySetId ?? "none",
|
||||
ship.SpatialState.SpaceLayer.ToContractValue(),
|
||||
ship.SpatialState.CurrentCelestialId ?? "none",
|
||||
ship.SpatialState.CurrentAnchorId ?? "none",
|
||||
ship.SpatialState.MovementRegime.ToContractValue(),
|
||||
ship.SpatialState.DestinationNodeId ?? "none",
|
||||
ship.SpatialState.DestinationAnchorId ?? "none",
|
||||
ship.SpatialState.Transit?.Regime.ToContractValue() ?? "none",
|
||||
ship.SpatialState.Transit?.OriginNodeId ?? "none",
|
||||
ship.SpatialState.Transit?.DestinationNodeId ?? "none",
|
||||
ship.SpatialState.Transit?.OriginAnchorId ?? "none",
|
||||
ship.SpatialState.Transit?.DestinationAnchorId ?? "none",
|
||||
ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0",
|
||||
GetShipCargoAmount(ship).ToString("0.###"),
|
||||
ship.Skills.Navigation.ToString(CultureInfo.InvariantCulture),
|
||||
@@ -593,7 +612,9 @@ internal sealed class SimulationProjectionService
|
||||
ship.Skills.Combat.ToString(CultureInfo.InvariantCulture),
|
||||
ship.Skills.Construction.ToString(CultureInfo.InvariantCulture),
|
||||
ship.Health.ToString("0.###"),
|
||||
GetCurrentShipStep(ship)?.Id ?? "none");
|
||||
ship.ActiveSubTaskIndex >= 0 && ship.ActiveSubTaskIndex < ship.ActiveSubTasks.Count
|
||||
? ship.ActiveSubTasks[ship.ActiveSubTaskIndex].Id
|
||||
: "none");
|
||||
|
||||
private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
|
||||
string.Join(",",
|
||||
@@ -642,59 +663,6 @@ internal sealed class SimulationProjectionService
|
||||
return $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}|{assignmentSig}|{strategicSig}|{doctrineSig}|{decisionSig}|{theaterSig}|{campaignSig}|{objectiveSig}|{reservationSig}|{productionSig}";
|
||||
}
|
||||
|
||||
private static string BuildPlayerFactionSignature(PlayerFactionRuntime player)
|
||||
{
|
||||
var intentSig = $"{player.StrategicIntent.StrategicPosture}:{player.StrategicIntent.EconomicPosture}:{player.StrategicIntent.MilitaryPosture}:{player.StrategicIntent.LogisticsPosture}:{player.StrategicIntent.DesiredReserveRatio:0.###}";
|
||||
var registrySig = string.Join("|",
|
||||
player.AssetRegistry.ShipIds.Count,
|
||||
player.AssetRegistry.StationIds.Count,
|
||||
player.AssetRegistry.CommanderIds.Count,
|
||||
player.AssetRegistry.FleetIds.Count,
|
||||
player.AssetRegistry.TaskForceIds.Count,
|
||||
player.AssetRegistry.StationGroupIds.Count,
|
||||
player.AssetRegistry.EconomicRegionIds.Count,
|
||||
player.AssetRegistry.FrontIds.Count,
|
||||
player.AssetRegistry.ReserveIds.Count);
|
||||
var orgSig = string.Join("|",
|
||||
player.Fleets.Count,
|
||||
player.TaskForces.Count,
|
||||
player.StationGroups.Count,
|
||||
player.EconomicRegions.Count,
|
||||
player.Fronts.Count,
|
||||
player.Reserves.Count,
|
||||
player.Policies.Count,
|
||||
player.AutomationPolicies.Count,
|
||||
player.ReinforcementPolicies.Count,
|
||||
player.ProductionPrograms.Count,
|
||||
player.Directives.Count,
|
||||
player.Assignments.Count,
|
||||
player.Alerts.Count);
|
||||
var policySig = string.Join(";",
|
||||
player.Policies.OrderBy(policy => policy.Id, StringComparer.Ordinal)
|
||||
.Select(policy => $"{policy.Id}:{policy.ScopeKind}:{policy.ScopeId}:{policy.PolicySetId}:{policy.TradeAccessPolicy}:{policy.DockingAccessPolicy}:{policy.ConstructionAccessPolicy}:{policy.OperationalRangePolicy}:{policy.CombatEngagementPolicy}:{policy.AvoidHostileSystems}:{policy.FleeHullRatio:0.###}:{policy.UpdatedAtUtc.UtcTicks}"));
|
||||
var automationSig = string.Join(";",
|
||||
player.AutomationPolicies.OrderBy(policy => policy.Id, StringComparer.Ordinal)
|
||||
.Select(policy => $"{policy.Id}:{policy.ScopeKind}:{policy.ScopeId}:{policy.Enabled}:{policy.BehaviorKind}:{policy.UseOrders}:{policy.StagingOrderKind}:{policy.MaxSystemRange}:{policy.KnownStationsOnly}:{policy.Radius:0.###}:{policy.WaitSeconds:0.###}:{policy.PreferredItemId}:{policy.UpdatedAtUtc.UtcTicks}"));
|
||||
var directiveSig = string.Join(";",
|
||||
player.Directives.OrderBy(directive => directive.Id, StringComparer.Ordinal)
|
||||
.Select(directive => $"{directive.Id}:{directive.ScopeKind}:{directive.ScopeId}:{directive.Kind}:{directive.BehaviorKind}:{directive.UseOrders}:{directive.StagingOrderKind}:{directive.TargetEntityId}:{directive.TargetSystemId}:{directive.ItemId}:{directive.Priority}:{directive.UpdatedAtUtc.UtcTicks}"));
|
||||
var assignmentSig = string.Join(";",
|
||||
player.Assignments.OrderBy(assignment => assignment.Id, StringComparer.Ordinal)
|
||||
.Select(assignment => $"{assignment.Id}:{assignment.AssetKind}:{assignment.AssetId}:{assignment.FleetId}:{assignment.TaskForceId}:{assignment.StationGroupId}:{assignment.EconomicRegionId}:{assignment.FrontId}:{assignment.ReserveId}:{assignment.DirectiveId}:{assignment.PolicyId}:{assignment.AutomationPolicyId}:{assignment.Role}:{assignment.Status}:{assignment.UpdatedAtUtc.UtcTicks}"));
|
||||
var decisionSig = string.Join(",", player.DecisionLog.Select(entry => entry.Id));
|
||||
var orgDetailSig = string.Join(";",
|
||||
player.Fleets.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"fleet:{entry.Id}:{entry.FrontId}:{entry.HomeSystemId}:{entry.HomeStationId}:{entry.CommanderId}:{entry.UpdatedAtUtc.UtcTicks}")
|
||||
.Concat(player.TaskForces.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"task-force:{entry.Id}:{entry.FleetId}:{entry.FrontId}:{entry.CommanderId}:{entry.UpdatedAtUtc.UtcTicks}"))
|
||||
.Concat(player.StationGroups.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"station-group:{entry.Id}:{entry.EconomicRegionId}:{entry.UpdatedAtUtc.UtcTicks}"))
|
||||
.Concat(player.EconomicRegions.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"economic-region:{entry.Id}:{entry.SharedEconomicRegionId}:{entry.Role}:{entry.UpdatedAtUtc.UtcTicks}"))
|
||||
.Concat(player.Fronts.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"front:{entry.Id}:{entry.SharedFrontLineId}:{entry.TargetFactionId}:{entry.Priority:0.###}:{entry.UpdatedAtUtc.UtcTicks}"))
|
||||
.Concat(player.Reserves.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"reserve:{entry.Id}:{entry.HomeSystemId}:{entry.UpdatedAtUtc.UtcTicks}")));
|
||||
var alertSig = string.Join(";",
|
||||
player.Alerts.OrderBy(alert => alert.Id, StringComparer.Ordinal)
|
||||
.Select(alert => $"{alert.Id}:{alert.Kind}:{alert.Severity}:{alert.AssetKind}:{alert.AssetId}:{alert.RelatedDirectiveId}:{alert.Status}:{alert.CreatedAtUtc.UtcTicks}"));
|
||||
return $"{player.SovereignFactionId}|{player.Status}|{intentSig}|{registrySig}|{orgSig}|{policySig}|{automationSig}|{directiveSig}|{assignmentSig}|{decisionSig}|{orgDetailSig}|{alertSig}";
|
||||
}
|
||||
|
||||
private static string BuildGeopoliticalSignature(GeopoliticalStateRuntime state)
|
||||
{
|
||||
var diplomacySig = string.Join(";",
|
||||
@@ -728,13 +696,33 @@ internal sealed class SimulationProjectionService
|
||||
|
||||
private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new(
|
||||
node.Id,
|
||||
node.AnchorId,
|
||||
node.SystemId,
|
||||
ToDto(node.Position),
|
||||
node.CelestialId,
|
||||
node.LocalSpaceRadius,
|
||||
node.SourceKind,
|
||||
node.OreRemaining,
|
||||
node.MaxOre,
|
||||
node.ItemId);
|
||||
node.ItemId,
|
||||
node.Deposits.Select(ToResourceDepositSnapshot).ToList());
|
||||
|
||||
private static ResourceDepositSnapshot ToResourceDepositSnapshot(ResourceDepositRuntime deposit) => new(
|
||||
deposit.Id,
|
||||
deposit.NodeId,
|
||||
deposit.AnchorId,
|
||||
ToDto(deposit.Position),
|
||||
deposit.OreRemaining,
|
||||
deposit.MaxOre);
|
||||
|
||||
private static AnchorDelta ToAnchorDelta(AnchorRuntime anchor) => new(
|
||||
anchor.Id,
|
||||
anchor.SystemId,
|
||||
anchor.Kind.ToContractValue(),
|
||||
ToDto(anchor.Position),
|
||||
anchor.LocalSpaceRadius,
|
||||
anchor.ParentAnchorId,
|
||||
anchor.OccupyingStructureId,
|
||||
anchor.OrbitReferenceId);
|
||||
|
||||
private static CelestialDelta ToCelestialDelta(CelestialRuntime celestial) => new(
|
||||
celestial.Id,
|
||||
@@ -742,7 +730,7 @@ internal sealed class SimulationProjectionService
|
||||
celestial.Kind.ToContractValue(),
|
||||
ToDto(celestial.Position),
|
||||
celestial.LocalSpaceRadius,
|
||||
celestial.ParentNodeId,
|
||||
celestial.ParentAnchorId,
|
||||
celestial.OccupyingStructureId,
|
||||
celestial.OrbitReferenceId);
|
||||
|
||||
@@ -752,8 +740,8 @@ internal sealed class SimulationProjectionService
|
||||
station.Category,
|
||||
station.Objective,
|
||||
station.SystemId,
|
||||
station.AnchorId,
|
||||
ToDto(station.Position),
|
||||
station.CelestialId,
|
||||
station.Color,
|
||||
station.DockedShipIds.Count,
|
||||
station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
@@ -812,7 +800,7 @@ internal sealed class SimulationProjectionService
|
||||
claim.Id,
|
||||
claim.FactionId,
|
||||
claim.SystemId,
|
||||
claim.CelestialId,
|
||||
claim.AnchorId,
|
||||
claim.State,
|
||||
claim.Health,
|
||||
claim.PlacedAtUtc,
|
||||
@@ -822,7 +810,7 @@ internal sealed class SimulationProjectionService
|
||||
site.Id,
|
||||
site.FactionId,
|
||||
site.SystemId,
|
||||
site.CelestialId,
|
||||
site.AnchorId,
|
||||
site.TargetKind,
|
||||
site.TargetDefinitionId,
|
||||
site.BlueprintId,
|
||||
@@ -882,10 +870,11 @@ internal sealed class SimulationProjectionService
|
||||
|
||||
return new ShipDelta(
|
||||
ship.Id,
|
||||
ship.Definition.Label,
|
||||
ship.Definition.Kind,
|
||||
ship.Definition.Class,
|
||||
ship.Definition.Name,
|
||||
ship.Definition.Purpose.ToDataValue(),
|
||||
ship.Definition.Type.ToDataValue(),
|
||||
ship.SystemId,
|
||||
ship.SpatialState.CurrentAnchorId,
|
||||
ToDto(ship.Position),
|
||||
ToDto(ship.Velocity),
|
||||
ToDto(ship.TargetPosition),
|
||||
@@ -894,19 +883,22 @@ internal sealed class SimulationProjectionService
|
||||
ToDefaultBehaviorSnapshot(ship.DefaultBehavior),
|
||||
ToShipAssignmentSnapshot(commander),
|
||||
new ShipSkillProfileSnapshot(ship.Skills.Navigation, ship.Skills.Trade, ship.Skills.Mining, ship.Skills.Combat, ship.Skills.Construction),
|
||||
ToShipPlanSnapshot(ship.ActivePlan),
|
||||
GetCurrentShipStep(ship)?.Id,
|
||||
ToActiveSubTaskSnapshots(ship),
|
||||
ship.ControlSourceKind,
|
||||
ship.ControlSourceId,
|
||||
ship.ControlReason,
|
||||
ship.LastReplanReason,
|
||||
ship.LastAccessFailureReason,
|
||||
ship.SpatialState.CurrentCelestialId,
|
||||
ship.DockedStationId,
|
||||
ship.CommanderId,
|
||||
ship.PolicySetId,
|
||||
ship.Definition.CargoCapacity,
|
||||
ship.Definition.GetTotalCargoCapacity(),
|
||||
ship.Definition.Cargo
|
||||
.SelectMany(entry => entry.Types)
|
||||
.Where(type => !string.IsNullOrWhiteSpace(type))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(type => type, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList(),
|
||||
|
||||
ToShipTravelSpeed(ship).Speed,
|
||||
ToShipTravelSpeed(ship).Unit,
|
||||
@@ -923,7 +915,7 @@ internal sealed class SimulationProjectionService
|
||||
{
|
||||
MovementRegimeKind.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"),
|
||||
MovementRegimeKind.Warp => (ship.State == ShipState.Warping ? ship.Definition.WarpSpeed : 0f, "AU/s"),
|
||||
_ => (MathF.Sqrt(MathF.Max(0f, ship.Velocity.LengthSquared())) * SimulationUnits.MetersPerKilometer, "m/s"),
|
||||
_ => (MathF.Sqrt(MathF.Max(0f, ship.Velocity.LengthSquared())), "m/s"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -936,11 +928,11 @@ internal sealed class SimulationProjectionService
|
||||
|
||||
private static IReadOnlyList<ShipOrderSnapshot> ToShipOrderSnapshots(ShipRuntime ship) =>
|
||||
ship.OrderQueue
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => new ShipOrderSnapshot(
|
||||
order.Id,
|
||||
order.Kind,
|
||||
order.SourceKind.ToContractValue(),
|
||||
order.SourceId,
|
||||
order.Status.ToContractValue(),
|
||||
order.Priority,
|
||||
order.InterruptCurrentPlan,
|
||||
@@ -952,7 +944,7 @@ internal sealed class SimulationProjectionService
|
||||
order.SourceStationId,
|
||||
order.DestinationStationId,
|
||||
order.ItemId,
|
||||
order.NodeId,
|
||||
order.AnchorId,
|
||||
order.ConstructionSiteId,
|
||||
order.ModuleId,
|
||||
order.WaitSeconds,
|
||||
@@ -969,8 +961,8 @@ internal sealed class SimulationProjectionService
|
||||
behavior.HomeStationId,
|
||||
behavior.AreaSystemId,
|
||||
behavior.TargetEntityId,
|
||||
behavior.PreferredItemId,
|
||||
behavior.PreferredNodeId,
|
||||
behavior.ItemId,
|
||||
behavior.PreferredAnchorId,
|
||||
behavior.PreferredConstructionSiteId,
|
||||
behavior.PreferredModuleId,
|
||||
behavior.TargetPosition is null ? null : ToDto(behavior.TargetPosition.Value),
|
||||
@@ -993,7 +985,7 @@ internal sealed class SimulationProjectionService
|
||||
template.SourceStationId,
|
||||
template.DestinationStationId,
|
||||
template.ItemId,
|
||||
template.NodeId,
|
||||
template.AnchorId,
|
||||
template.ConstructionSiteId,
|
||||
template.ModuleId,
|
||||
template.WaitSeconds,
|
||||
@@ -1028,48 +1020,18 @@ internal sealed class SimulationProjectionService
|
||||
assignment.UpdatedAtUtc);
|
||||
}
|
||||
|
||||
private static ShipPlanSnapshot? ToShipPlanSnapshot(ShipPlanRuntime? plan)
|
||||
{
|
||||
if (plan is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ShipPlanSnapshot(
|
||||
plan.Id,
|
||||
plan.SourceKind.ToContractValue(),
|
||||
plan.SourceId,
|
||||
plan.Kind,
|
||||
plan.Status.ToContractValue(),
|
||||
plan.Summary,
|
||||
plan.CurrentStepIndex,
|
||||
plan.CreatedAtUtc,
|
||||
plan.UpdatedAtUtc,
|
||||
plan.InterruptReason,
|
||||
plan.FailureReason,
|
||||
plan.Steps.Select(ToShipPlanStepSnapshot).ToList());
|
||||
}
|
||||
|
||||
private static ShipPlanStepSnapshot ToShipPlanStepSnapshot(ShipPlanStepRuntime step) =>
|
||||
new(
|
||||
step.Id,
|
||||
step.Kind,
|
||||
step.Status.ToContractValue(),
|
||||
step.Summary,
|
||||
step.BlockingReason,
|
||||
step.CurrentSubTaskIndex,
|
||||
step.SubTasks.Select(ToShipSubTaskSnapshot).ToList());
|
||||
|
||||
private static ShipSubTaskSnapshot ToShipSubTaskSnapshot(ShipSubTaskRuntime subTask) =>
|
||||
new(
|
||||
subTask.Id,
|
||||
subTask.Kind,
|
||||
subTask.Status.ToContractValue(),
|
||||
subTask.Summary,
|
||||
subTask.TargetEntityId,
|
||||
subTask.TargetSystemId,
|
||||
subTask.TargetNodeId,
|
||||
subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value),
|
||||
subTask.TargetEntityId,
|
||||
subTask.TargetSystemId,
|
||||
subTask.TargetAnchorId,
|
||||
subTask.TargetResourceNodeId,
|
||||
subTask.TargetResourceDepositId,
|
||||
subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value),
|
||||
subTask.ItemId,
|
||||
subTask.ModuleId,
|
||||
subTask.Threshold,
|
||||
@@ -1081,23 +1043,12 @@ internal sealed class SimulationProjectionService
|
||||
|
||||
private static IReadOnlyList<ShipSubTaskSnapshot> ToActiveSubTaskSnapshots(ShipRuntime ship)
|
||||
{
|
||||
var step = GetCurrentShipStep(ship);
|
||||
if (step is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return step.SubTasks
|
||||
return ship.ActiveSubTasks
|
||||
.Where(subTask => subTask.Status is WorkStatus.Pending or WorkStatus.Active or WorkStatus.Blocked)
|
||||
.Select(ToShipSubTaskSnapshot)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static ShipPlanStepRuntime? GetCurrentShipStep(ShipRuntime ship) =>
|
||||
ship.ActivePlan is null || ship.ActivePlan.CurrentStepIndex >= ship.ActivePlan.Steps.Count
|
||||
? null
|
||||
: ship.ActivePlan.Steps[ship.ActivePlan.CurrentStepIndex];
|
||||
|
||||
private static CommanderAssignmentSnapshot ToCommanderAssignmentSnapshot(CommanderRuntime commander)
|
||||
{
|
||||
var assignment = commander.Assignment;
|
||||
@@ -1385,252 +1336,6 @@ internal sealed class SimulationProjectionService
|
||||
entry.OccurredAtUtc))
|
||||
.ToList();
|
||||
|
||||
private static PlayerFactionSnapshot? ToPlayerFactionSnapshot(PlayerFactionRuntime? player)
|
||||
{
|
||||
if (player is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PlayerFactionSnapshot(
|
||||
player.Id,
|
||||
player.Label,
|
||||
player.SovereignFactionId,
|
||||
player.Status,
|
||||
player.CreatedAtUtc,
|
||||
player.UpdatedAtUtc,
|
||||
new PlayerAssetRegistrySnapshot(
|
||||
player.AssetRegistry.ShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.CommanderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.ClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.ConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.PolicySetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.EconomicRegionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
player.AssetRegistry.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList()),
|
||||
new PlayerStrategicIntentSnapshot(
|
||||
player.StrategicIntent.StrategicPosture,
|
||||
player.StrategicIntent.EconomicPosture,
|
||||
player.StrategicIntent.MilitaryPosture,
|
||||
player.StrategicIntent.LogisticsPosture,
|
||||
player.StrategicIntent.DesiredReserveRatio,
|
||||
player.StrategicIntent.AllowDelegatedCombatAutomation,
|
||||
player.StrategicIntent.AllowDelegatedEconomicAutomation,
|
||||
player.StrategicIntent.Notes),
|
||||
player.Fleets.Select(fleet => new PlayerFleetSnapshot(
|
||||
fleet.Id,
|
||||
fleet.Label,
|
||||
fleet.Status,
|
||||
fleet.Role,
|
||||
fleet.CommanderId,
|
||||
fleet.FrontId,
|
||||
fleet.HomeSystemId,
|
||||
fleet.HomeStationId,
|
||||
fleet.PolicyId,
|
||||
fleet.AutomationPolicyId,
|
||||
fleet.ReinforcementPolicyId,
|
||||
fleet.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
fleet.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
fleet.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
fleet.UpdatedAtUtc)).ToList(),
|
||||
player.TaskForces.Select(taskForce => new PlayerTaskForceSnapshot(
|
||||
taskForce.Id,
|
||||
taskForce.Label,
|
||||
taskForce.Status,
|
||||
taskForce.Role,
|
||||
taskForce.FleetId,
|
||||
taskForce.CommanderId,
|
||||
taskForce.FrontId,
|
||||
taskForce.PolicyId,
|
||||
taskForce.AutomationPolicyId,
|
||||
taskForce.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
taskForce.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
taskForce.UpdatedAtUtc)).ToList(),
|
||||
player.StationGroups.Select(group => new PlayerStationGroupSnapshot(
|
||||
group.Id,
|
||||
group.Label,
|
||||
group.Status,
|
||||
group.Role,
|
||||
group.EconomicRegionId,
|
||||
group.PolicyId,
|
||||
group.AutomationPolicyId,
|
||||
group.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
group.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
group.FocusItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
group.UpdatedAtUtc)).ToList(),
|
||||
player.EconomicRegions.Select(region => new PlayerEconomicRegionSnapshot(
|
||||
region.Id,
|
||||
region.Label,
|
||||
region.Status,
|
||||
region.Role,
|
||||
region.SharedEconomicRegionId,
|
||||
region.PolicyId,
|
||||
region.AutomationPolicyId,
|
||||
region.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
region.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
region.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
region.UpdatedAtUtc)).ToList(),
|
||||
player.Fronts.Select(front => new PlayerFrontSnapshot(
|
||||
front.Id,
|
||||
front.Label,
|
||||
front.Status,
|
||||
front.Priority,
|
||||
front.Posture,
|
||||
front.SharedFrontLineId,
|
||||
front.TargetFactionId,
|
||||
front.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
front.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
front.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
front.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
front.UpdatedAtUtc)).ToList(),
|
||||
player.Reserves.Select(reserve => new PlayerReserveGroupSnapshot(
|
||||
reserve.Id,
|
||||
reserve.Label,
|
||||
reserve.Status,
|
||||
reserve.ReserveKind,
|
||||
reserve.HomeSystemId,
|
||||
reserve.PolicyId,
|
||||
reserve.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
reserve.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
reserve.UpdatedAtUtc)).ToList(),
|
||||
player.Policies.Select(policy => new PlayerFactionPolicySnapshot(
|
||||
policy.Id,
|
||||
policy.Label,
|
||||
policy.ScopeKind,
|
||||
policy.ScopeId,
|
||||
policy.PolicySetId,
|
||||
policy.AllowDelegatedCombat,
|
||||
policy.AllowDelegatedTrade,
|
||||
policy.ReserveCreditsRatio,
|
||||
policy.ReserveMilitaryRatio,
|
||||
policy.TradeAccessPolicy,
|
||||
policy.DockingAccessPolicy,
|
||||
policy.ConstructionAccessPolicy,
|
||||
policy.OperationalRangePolicy,
|
||||
policy.CombatEngagementPolicy,
|
||||
policy.AvoidHostileSystems,
|
||||
policy.FleeHullRatio,
|
||||
policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
policy.Notes,
|
||||
policy.UpdatedAtUtc)).ToList(),
|
||||
player.AutomationPolicies.Select(policy => new PlayerAutomationPolicySnapshot(
|
||||
policy.Id,
|
||||
policy.Label,
|
||||
policy.ScopeKind,
|
||||
policy.ScopeId,
|
||||
policy.Enabled,
|
||||
policy.BehaviorKind,
|
||||
policy.UseOrders,
|
||||
policy.StagingOrderKind,
|
||||
policy.MaxSystemRange,
|
||||
policy.KnownStationsOnly,
|
||||
policy.Radius,
|
||||
policy.WaitSeconds,
|
||||
policy.PreferredItemId,
|
||||
policy.Notes,
|
||||
policy.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(),
|
||||
policy.UpdatedAtUtc)).ToList(),
|
||||
player.ReinforcementPolicies.Select(policy => new PlayerReinforcementPolicySnapshot(
|
||||
policy.Id,
|
||||
policy.Label,
|
||||
policy.ScopeKind,
|
||||
policy.ScopeId,
|
||||
policy.ShipKind,
|
||||
policy.DesiredAssetCount,
|
||||
policy.MinimumReserveCount,
|
||||
policy.AutoTransferReserves,
|
||||
policy.AutoQueueProduction,
|
||||
policy.SourceReserveId,
|
||||
policy.TargetFrontId,
|
||||
policy.Notes,
|
||||
policy.UpdatedAtUtc)).ToList(),
|
||||
player.ProductionPrograms.Select(program => new PlayerProductionProgramSnapshot(
|
||||
program.Id,
|
||||
program.Label,
|
||||
program.Status,
|
||||
program.Kind,
|
||||
program.TargetShipKind,
|
||||
program.TargetModuleId,
|
||||
program.TargetItemId,
|
||||
program.TargetCount,
|
||||
program.CurrentCount,
|
||||
program.StationGroupId,
|
||||
program.ReinforcementPolicyId,
|
||||
program.Notes,
|
||||
program.UpdatedAtUtc)).ToList(),
|
||||
player.Directives.Select(directive => new PlayerDirectiveSnapshot(
|
||||
directive.Id,
|
||||
directive.Label,
|
||||
directive.Status,
|
||||
directive.Kind,
|
||||
directive.ScopeKind,
|
||||
directive.ScopeId,
|
||||
directive.TargetEntityId,
|
||||
directive.TargetSystemId,
|
||||
directive.TargetPosition is null ? null : ToDto(directive.TargetPosition.Value),
|
||||
directive.HomeSystemId,
|
||||
directive.HomeStationId,
|
||||
directive.SourceStationId,
|
||||
directive.DestinationStationId,
|
||||
directive.BehaviorKind,
|
||||
directive.UseOrders,
|
||||
directive.StagingOrderKind,
|
||||
directive.ItemId,
|
||||
directive.PreferredNodeId,
|
||||
directive.PreferredConstructionSiteId,
|
||||
directive.PreferredModuleId,
|
||||
directive.Priority,
|
||||
directive.Radius,
|
||||
directive.WaitSeconds,
|
||||
directive.MaxSystemRange,
|
||||
directive.KnownStationsOnly,
|
||||
directive.PatrolPoints.Select(ToDto).ToList(),
|
||||
directive.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(),
|
||||
directive.PolicyId,
|
||||
directive.AutomationPolicyId,
|
||||
directive.Notes,
|
||||
directive.CreatedAtUtc,
|
||||
directive.UpdatedAtUtc)).ToList(),
|
||||
player.Assignments.Select(assignment => new PlayerAssignmentSnapshot(
|
||||
assignment.Id,
|
||||
assignment.AssetKind,
|
||||
assignment.AssetId,
|
||||
assignment.FleetId,
|
||||
assignment.TaskForceId,
|
||||
assignment.StationGroupId,
|
||||
assignment.EconomicRegionId,
|
||||
assignment.FrontId,
|
||||
assignment.ReserveId,
|
||||
assignment.DirectiveId,
|
||||
assignment.PolicyId,
|
||||
assignment.AutomationPolicyId,
|
||||
assignment.Role,
|
||||
assignment.Status,
|
||||
assignment.UpdatedAtUtc)).ToList(),
|
||||
player.DecisionLog.Select(entry => new PlayerDecisionLogEntrySnapshot(
|
||||
entry.Id,
|
||||
entry.Kind,
|
||||
entry.Summary,
|
||||
entry.RelatedEntityKind,
|
||||
entry.RelatedEntityId,
|
||||
entry.OccurredAtUtc)).ToList(),
|
||||
player.Alerts.Select(alert => new PlayerAlertSnapshot(
|
||||
alert.Id,
|
||||
alert.Kind,
|
||||
alert.Severity,
|
||||
alert.Summary,
|
||||
alert.AssetKind,
|
||||
alert.AssetId,
|
||||
alert.RelatedDirectiveId,
|
||||
alert.Status,
|
||||
alert.CreatedAtUtc)).ToList());
|
||||
}
|
||||
|
||||
private static GeopoliticalStateSnapshot? ToGeopoliticalStateSnapshot(GeopoliticalStateRuntime? state)
|
||||
{
|
||||
if (state is null)
|
||||
@@ -1718,7 +1423,7 @@ internal sealed class SimulationProjectionService
|
||||
claim.SourceClaimId,
|
||||
claim.FactionId,
|
||||
claim.SystemId,
|
||||
claim.CelestialId,
|
||||
claim.AnchorId,
|
||||
claim.Status,
|
||||
claim.ClaimKind,
|
||||
claim.ClaimStrength,
|
||||
@@ -1874,15 +1579,15 @@ internal sealed class SimulationProjectionService
|
||||
private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new(
|
||||
state.SpaceLayer.ToContractValue(),
|
||||
state.CurrentSystemId,
|
||||
state.CurrentCelestialId,
|
||||
state.CurrentAnchorId,
|
||||
state.LocalPosition is null ? null : ToDto(state.LocalPosition.Value),
|
||||
state.SystemPosition is null ? null : ToDto(state.SystemPosition.Value),
|
||||
state.MovementRegime.ToContractValue(),
|
||||
state.DestinationNodeId,
|
||||
state.DestinationAnchorId,
|
||||
state.Transit is null ? null : new ShipTransitSnapshot(
|
||||
state.Transit.Regime.ToContractValue(),
|
||||
state.Transit.OriginNodeId,
|
||||
state.Transit.DestinationNodeId,
|
||||
state.Transit.OriginAnchorId,
|
||||
state.Transit.DestinationAnchorId,
|
||||
state.Transit.StartedAtUtc,
|
||||
state.Transit.ArrivalDueAtUtc,
|
||||
state.Transit.Progress));
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FastEndpoints" Version="6.*" />
|
||||
<PackageReference Include="FastEndpoints.Swagger" Version="6.*" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-preview.7.25380.108" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -10,8 +10,8 @@ public sealed record StationSnapshot(
|
||||
string Category,
|
||||
string Objective,
|
||||
string SystemId,
|
||||
string? AnchorId,
|
||||
Vector3Dto LocalPosition,
|
||||
string? CelestialId,
|
||||
string Color,
|
||||
int DockedShips,
|
||||
IReadOnlyList<string> DockedShipIds,
|
||||
@@ -35,8 +35,8 @@ public sealed record StationDelta(
|
||||
string Category,
|
||||
string Objective,
|
||||
string SystemId,
|
||||
string? AnchorId,
|
||||
Vector3Dto LocalPosition,
|
||||
string? CelestialId,
|
||||
string Color,
|
||||
int DockedShips,
|
||||
IReadOnlyList<string> DockedShipIds,
|
||||
@@ -74,7 +74,7 @@ public sealed record ClaimSnapshot(
|
||||
string Id,
|
||||
string FactionId,
|
||||
string SystemId,
|
||||
string CelestialId,
|
||||
string AnchorId,
|
||||
string State,
|
||||
float Health,
|
||||
DateTimeOffset PlacedAtUtc,
|
||||
@@ -84,7 +84,7 @@ public sealed record ClaimDelta(
|
||||
string Id,
|
||||
string FactionId,
|
||||
string SystemId,
|
||||
string CelestialId,
|
||||
string AnchorId,
|
||||
string State,
|
||||
float Health,
|
||||
DateTimeOffset PlacedAtUtc,
|
||||
@@ -94,7 +94,7 @@ public sealed record ConstructionSiteSnapshot(
|
||||
string Id,
|
||||
string FactionId,
|
||||
string SystemId,
|
||||
string CelestialId,
|
||||
string AnchorId,
|
||||
string TargetKind,
|
||||
string TargetDefinitionId,
|
||||
string? BlueprintId,
|
||||
@@ -112,7 +112,7 @@ public sealed record ConstructionSiteDelta(
|
||||
string Id,
|
||||
string FactionId,
|
||||
string SystemId,
|
||||
string CelestialId,
|
||||
string AnchorId,
|
||||
string TargetKind,
|
||||
string TargetDefinitionId,
|
||||
string? BlueprintId,
|
||||
|
||||
@@ -5,7 +5,7 @@ public sealed class ClaimRuntime
|
||||
public required string Id { get; init; }
|
||||
public required string FactionId { get; init; }
|
||||
public required string SystemId { get; init; }
|
||||
public required string CelestialId { get; init; }
|
||||
public required string AnchorId { get; init; }
|
||||
public string? CommanderId { get; set; }
|
||||
public DateTimeOffset PlacedAtUtc { get; init; }
|
||||
public DateTimeOffset ActivatesAtUtc { get; set; }
|
||||
@@ -19,7 +19,7 @@ public sealed class ConstructionSiteRuntime
|
||||
public required string Id { get; init; }
|
||||
public required string FactionId { get; init; }
|
||||
public required string SystemId { get; init; }
|
||||
public required string CelestialId { get; init; }
|
||||
public required string AnchorId { get; init; }
|
||||
public required string TargetKind { get; init; }
|
||||
public required string TargetDefinitionId { get; init; }
|
||||
public string? BlueprintId { get; set; }
|
||||
|
||||
@@ -7,6 +7,7 @@ public sealed class StationRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string SystemId { get; init; }
|
||||
public string? AnchorId { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public string Category { get; set; } = "station";
|
||||
public string Objective { get; set; } = "general";
|
||||
@@ -14,7 +15,6 @@ public sealed class StationRuntime
|
||||
public required Vector3 Position { get; set; }
|
||||
public float Radius { get; set; } = 24f;
|
||||
public required string FactionId { get; init; }
|
||||
public string? CelestialId { get; set; }
|
||||
public string? CommanderId { get; set; }
|
||||
public string? PolicySetId { get; set; }
|
||||
public List<StationModuleRuntime> Modules { get; } = [];
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using SpaceGame.Api.Shared.Runtime;
|
||||
using SpaceGame.Api.Ships.AI;
|
||||
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
|
||||
namespace SpaceGame.Api.Stations.Simulation;
|
||||
@@ -79,8 +81,8 @@ internal sealed class StationLifecycleService
|
||||
TargetPosition = spawnPosition,
|
||||
SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition),
|
||||
DefaultBehavior = CreateSpawnedShipBehavior(definition, station),
|
||||
Skills = WorldSeedingService.CreateSkills(definition),
|
||||
Health = definition.MaxHealth,
|
||||
Skills = ShipBootstrapPolicy.CreateSkills(definition),
|
||||
Health = definition.Hull,
|
||||
};
|
||||
|
||||
world.Ships.Add(ship);
|
||||
@@ -90,7 +92,7 @@ internal sealed class StationLifecycleService
|
||||
faction.ShipsBuilt += 1;
|
||||
}
|
||||
|
||||
events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Label} launched {definition.Label}.", DateTimeOffset.UtcNow));
|
||||
events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Label} launched {definition.Name}.", DateTimeOffset.UtcNow));
|
||||
return 1f;
|
||||
}
|
||||
|
||||
@@ -98,7 +100,7 @@ internal sealed class StationLifecycleService
|
||||
{
|
||||
CurrentSystemId = station.SystemId,
|
||||
SpaceLayer = SpaceLayerKind.LocalSpace,
|
||||
CurrentCelestialId = station.CelestialId,
|
||||
CurrentAnchorId = station.AnchorId,
|
||||
LocalPosition = position,
|
||||
SystemPosition = position,
|
||||
MovementRegime = MovementRegimeKind.LocalFlight,
|
||||
@@ -106,21 +108,22 @@ internal sealed class StationLifecycleService
|
||||
|
||||
private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station)
|
||||
{
|
||||
if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal))
|
||||
if (!IsMilitaryShip(definition))
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? "advanced-auto-trade" : "idle",
|
||||
Kind = IsTransportShip(definition) ? AdvancedAutoTrade : HoldPosition,
|
||||
HomeSystemId = station.SystemId,
|
||||
HomeStationId = station.Id,
|
||||
MaxSystemRange = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? 2 : 0,
|
||||
AreaSystemId = station.SystemId,
|
||||
MaxSystemRange = IsTransportShip(definition) ? 2 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
var patrolRadius = station.Radius + 90f;
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "patrol",
|
||||
Kind = Patrol,
|
||||
HomeSystemId = station.SystemId,
|
||||
HomeStationId = station.Id,
|
||||
AreaSystemId = station.SystemId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using static SpaceGame.Api.Factions.AI.CommanderPlanningService;
|
||||
using static SpaceGame.Api.Shared.Runtime.KnownShipTypes;
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using SpaceGame.Api.Shared.Runtime;
|
||||
|
||||
@@ -7,6 +8,10 @@ namespace SpaceGame.Api.Stations.Simulation;
|
||||
internal sealed class StationSimulationService
|
||||
{
|
||||
internal const int StrategicControlTargetSystems = 5;
|
||||
private const string MilitaryShipCategory = "military";
|
||||
private const string ConstructionShipCategory = "construction";
|
||||
private const string TransportShipCategory = "transport";
|
||||
private const string MiningShipCategory = "mining";
|
||||
|
||||
internal void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station)
|
||||
{
|
||||
@@ -63,7 +68,7 @@ internal sealed class StationSimulationService
|
||||
var superfluidCoolantReserve = role == "superfluidcoolant" ? 120f : 0f;
|
||||
var quantumTubesReserve = role == "quantumtubes" ? 120f : 0f;
|
||||
var shipPartsReserve = HasShipyardCapability(station)
|
||||
&& GetShipProductionPressure(world, station.FactionId, "military") > 0.2f
|
||||
&& GetShipProductionPressure(world, station.FactionId, MilitaryShipCategory) > 0.2f
|
||||
? 90f
|
||||
: 0f;
|
||||
|
||||
@@ -118,7 +123,7 @@ internal sealed class StationSimulationService
|
||||
var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics");
|
||||
var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals");
|
||||
var shipPartsReserve = HasShipyardCapability(station)
|
||||
&& GetShipProductionPressure(world, station.FactionId, "military") > 0.2f
|
||||
&& GetShipProductionPressure(world, station.FactionId, MilitaryShipCategory) > 0.2f
|
||||
? 90f
|
||||
: 0f;
|
||||
|
||||
@@ -255,7 +260,7 @@ internal sealed class StationSimulationService
|
||||
var priority = (float)recipe.Priority;
|
||||
|
||||
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
|
||||
var fleetPressure = GetShipProductionPressure(world, station.FactionId, "military");
|
||||
var fleetPressure = GetShipProductionPressure(world, station.FactionId, MilitaryShipCategory);
|
||||
priority += GetStationRecipePriorityAdjustment(world, station, recipe, expansionPressure, fleetPressure);
|
||||
priority += GetStrategicRecipeBias(world, station, recipe);
|
||||
|
||||
@@ -266,21 +271,34 @@ internal sealed class StationSimulationService
|
||||
{
|
||||
if (recipe.ShipOutputId is not null && world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition))
|
||||
{
|
||||
var shipPressure = GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind);
|
||||
return shipDefinition.Kind switch
|
||||
var shipPressure = GetShipProductionPressure(world, station.FactionId, GetShipCategory(shipDefinition));
|
||||
if (IsMilitaryShip(shipDefinition))
|
||||
{
|
||||
"military" => recipe.Id switch
|
||||
return recipe.Id switch
|
||||
{
|
||||
"frigate-construction" => 320f * shipPressure,
|
||||
"destroyer-construction" => 200f * shipPressure,
|
||||
"cruiser-construction" => 120f * shipPressure,
|
||||
_ => 160f * shipPressure,
|
||||
},
|
||||
"construction" => 260f * shipPressure,
|
||||
"mining" => 250f * shipPressure,
|
||||
"transport" => 230f * shipPressure,
|
||||
_ => 0f,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
if (IsConstructionShip(shipDefinition))
|
||||
{
|
||||
return 260f * shipPressure;
|
||||
}
|
||||
|
||||
if (IsMiningShip(shipDefinition))
|
||||
{
|
||||
return 250f * shipPressure;
|
||||
}
|
||||
|
||||
if (IsTransportShip(shipDefinition))
|
||||
{
|
||||
return 230f * shipPressure;
|
||||
}
|
||||
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var outputItemIds = recipe.Outputs
|
||||
@@ -338,7 +356,7 @@ internal sealed class StationSimulationService
|
||||
if (string.Equals(assignment.Kind, "ship-production-focus", StringComparison.Ordinal)
|
||||
&& recipe.ShipOutputId is not null
|
||||
&& world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)
|
||||
&& string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal))
|
||||
&& IsMilitaryShip(shipDefinition))
|
||||
{
|
||||
return 260f;
|
||||
}
|
||||
@@ -383,7 +401,7 @@ internal sealed class StationSimulationService
|
||||
return false;
|
||||
}
|
||||
|
||||
if (GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind) <= 0.05f)
|
||||
if (GetShipProductionPressure(world, station.FactionId, GetShipCategory(shipDefinition)) <= 0.05f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -708,7 +726,7 @@ internal sealed class StationSimulationService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static float GetShipProductionPressure(SimulationWorld world, string factionId, string shipKind)
|
||||
private static float GetShipProductionPressure(SimulationWorld world, string factionId, string? shipCategory)
|
||||
{
|
||||
var economic = FindFactionEconomicAssessment(world, factionId);
|
||||
var threat = FindFactionThreatAssessment(world, factionId);
|
||||
@@ -717,16 +735,16 @@ internal sealed class StationSimulationService
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return shipKind switch
|
||||
return shipCategory switch
|
||||
{
|
||||
"military" => threat.EnemyFactionCount > 0
|
||||
MilitaryShipCategory => threat.EnemyFactionCount > 0
|
||||
? economic.MilitaryShipCount < Math.Max(4, economic.ControlledSystemCount * 2) ? 1f : 0.25f
|
||||
: 0.1f,
|
||||
"construction" => economic.PrimaryExpansionSiteId is not null
|
||||
ConstructionShipCategory => economic.PrimaryExpansionSiteId is not null
|
||||
? economic.ConstructorShipCount < 1 ? 1f : 0.35f
|
||||
: economic.ConstructorShipCount < 1 ? 0.5f : 0f,
|
||||
"transport" => economic.TransportShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.8f : 0.2f,
|
||||
_ when shipKind == "mining" || shipKind == "miner" => economic.MinerShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.85f : 0.2f,
|
||||
TransportShipCategory => economic.TransportShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.8f : 0.2f,
|
||||
MiningShipCategory => economic.MinerShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.85f : 0.2f,
|
||||
_ => 0.15f,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,14 @@ using SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
namespace SpaceGame.Api.Universe.Api;
|
||||
|
||||
public sealed class GetBalanceHandler(WorldService worldService) : EndpointWithoutRequest
|
||||
public sealed class GetBalanceHandler(IBalanceService balanceService) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/balance");
|
||||
AllowAnonymous();
|
||||
Policies(AuthPolicyNames.GmAccess);
|
||||
}
|
||||
|
||||
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
||||
SendOkAsync(worldService.GetBalance(), cancellationToken);
|
||||
SendOkAsync(balanceService.GetCurrent(), cancellationToken);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ public sealed class GetTelemetryHandler(TelemetryService telemetry, WorldService
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/telemetry");
|
||||
AllowAnonymous();
|
||||
Policies(AuthPolicyNames.GmAccess);
|
||||
}
|
||||
|
||||
public override Task HandleAsync(CancellationToken cancellationToken)
|
||||
|
||||
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()
|
||||
{
|
||||
Post("/api/world/reset");
|
||||
AllowAnonymous();
|
||||
Policies(AuthPolicyNames.GmAccess);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user