Compare commits
13 Commits
e8fb033a01
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8503855a4c | |||
| 6c92ab50c8 | |||
| d0c6e30304 | |||
| 75568324f5 | |||
| fdcf83ccec | |||
| 74b8bf4116 | |||
| c9a4b474b4 | |||
| 63a9f808bb | |||
| 706e1cda8f | |||
| 0bb72bee35 | |||
| 640e147ea8 | |||
| 04d182e93f | |||
| 3237735b08 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,3 +17,5 @@ pnpm-debug.log*
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.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/">
|
<Folder Name="/apps/backend/">
|
||||||
<Project Path="apps/backend/SpaceGame.Api.csproj" />
|
<Project Path="apps/backend/SpaceGame.Api.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/tests/" />
|
||||||
|
<Folder Name="/tests/backend/">
|
||||||
|
<Project Path="tests/backend/SpaceGame.Api.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ root = true
|
|||||||
|
|
||||||
[*.{cs,csx}]
|
[*.{cs,csx}]
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
end_of_line = crlf
|
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
indent_style = space
|
indent_style = space
|
||||||
@@ -40,7 +39,6 @@ csharp_new_line_before_open_brace = all
|
|||||||
|
|
||||||
[*.{csproj,props,targets,sln,slnx}]
|
[*.{csproj,props,targets,sln,slnx}]
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
end_of_line = crlf
|
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
indent_style = space
|
indent_style = space
|
||||||
@@ -48,7 +46,6 @@ indent_size = 2
|
|||||||
|
|
||||||
[*.{json,jsonc}]
|
[*.{json,jsonc}]
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
end_of_line = crlf
|
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
indent_style = space
|
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.Diagnostics.CodeAnalysis;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using SpaceGame.Api.Shared.Runtime;
|
using SpaceGame.Api.Shared.Runtime;
|
||||||
|
using SpaceGame.Api.Shared.Runtime;
|
||||||
|
using SpaceGame.Api.Universe.Simulation;
|
||||||
|
|
||||||
namespace SpaceGame.Api.Definitions;
|
namespace SpaceGame.Api.Definitions;
|
||||||
|
|
||||||
@@ -40,19 +42,6 @@ public sealed class ItemProductionDefinition
|
|||||||
public List<ItemEffectDefinition> Effects { get; set; } = [];
|
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 sealed class StarDefinition
|
||||||
{
|
{
|
||||||
public string Kind { get; set; } = "main-sequence";
|
public string Kind { get; set; } = "main-sequence";
|
||||||
@@ -87,6 +76,39 @@ public sealed class SolarSystemDefinition
|
|||||||
public required List<PlanetDefinition> Planets { get; set; }
|
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 sealed class AsteroidFieldDefinition
|
||||||
{
|
{
|
||||||
public int DecorationCount { get; set; }
|
public int DecorationCount { get; set; }
|
||||||
@@ -114,10 +136,8 @@ public sealed class ItemDefinition
|
|||||||
public required string Id { get; set; }
|
public required string Id { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
public string Type { get; set; } = "material";
|
|
||||||
public string CargoKind { get; set; } = string.Empty;
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public StorageKind? CargoStorageKind { get; set; }
|
public StorageKind? CargoKind { get; set; }
|
||||||
public float Volume { get; set; } = 1f;
|
public float Volume { get; set; } = 1f;
|
||||||
public int Version { get; set; }
|
public int Version { get; set; }
|
||||||
public string FactoryName { get; set; } = string.Empty;
|
public string FactoryName { get; set; } = string.Empty;
|
||||||
@@ -130,7 +150,8 @@ public sealed class ItemDefinition
|
|||||||
[JsonPropertyName("transport")]
|
[JsonPropertyName("transport")]
|
||||||
public string Transport
|
public string Transport
|
||||||
{
|
{
|
||||||
set => CargoKind = value;
|
get => CargoKind?.ToDataValue() ?? string.Empty;
|
||||||
|
set => CargoKind = value.ToNullableStorageKind();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,12 +172,6 @@ public sealed class RecipeInputDefinition
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ModuleConstructionDefinition
|
|
||||||
{
|
|
||||||
public required List<RecipeInputDefinition> Requirements { get; set; }
|
|
||||||
public float ProductionTime { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ModuleDockDefinition
|
public sealed class ModuleDockDefinition
|
||||||
{
|
{
|
||||||
public int Capacity { get; set; }
|
public int Capacity { get; set; }
|
||||||
@@ -169,10 +184,14 @@ public sealed class ModuleCargoDefinition
|
|||||||
public required string Type { get; set; }
|
public required string Type { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ModuleWorkForceDefinition
|
public sealed class ModuleWorkforceDefinition
|
||||||
{
|
{
|
||||||
public float Capacity { get; set; }
|
[JsonPropertyName("capacity")]
|
||||||
public float Max { get; set; }
|
public float SupportedPopulation { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("max")]
|
||||||
|
public float RequiredWorkforce { get; set; }
|
||||||
|
|
||||||
public string Race { get; set; } = string.Empty;
|
public string Race { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +203,7 @@ public sealed class ModuleMountDefinition
|
|||||||
public List<string> Types { get; set; } = [];
|
public List<string> Types { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ModuleProductionDefinition
|
public sealed class ModuleBuildRecipeDefinition
|
||||||
{
|
{
|
||||||
public float Time { get; set; }
|
public float Time { get; set; }
|
||||||
public float Amount { get; set; }
|
public float Amount { get; set; }
|
||||||
@@ -207,12 +226,9 @@ public class ModuleDefinition
|
|||||||
Description = source.Description;
|
Description = source.Description;
|
||||||
Type = source.Type;
|
Type = source.Type;
|
||||||
ModuleType = source.ModuleType;
|
ModuleType = source.ModuleType;
|
||||||
Product = source.Product;
|
ProductIds = [.. source.ProductIds];
|
||||||
Products = [.. source.Products];
|
|
||||||
ProductionMode = source.ProductionMode;
|
|
||||||
Radius = source.Radius;
|
Radius = source.Radius;
|
||||||
Hull = source.Hull;
|
Hull = source.Hull;
|
||||||
WorkforceNeeded = source.WorkforceNeeded;
|
|
||||||
Version = source.Version;
|
Version = source.Version;
|
||||||
Macro = source.Macro;
|
Macro = source.Macro;
|
||||||
MakerRace = source.MakerRace;
|
MakerRace = source.MakerRace;
|
||||||
@@ -220,12 +236,11 @@ public class ModuleDefinition
|
|||||||
Price = source.Price;
|
Price = source.Price;
|
||||||
Owners = [.. source.Owners];
|
Owners = [.. source.Owners];
|
||||||
Cargo = source.Cargo;
|
Cargo = source.Cargo;
|
||||||
WorkForce = source.WorkForce;
|
SerializedWorkforce = source.SerializedWorkforce;
|
||||||
Docks = [.. source.Docks];
|
Docks = [.. source.Docks];
|
||||||
Shields = [.. source.Shields];
|
Shields = [.. source.Shields];
|
||||||
Turrets = [.. source.Turrets];
|
Turrets = [.. source.Turrets];
|
||||||
Production = [.. source.Production];
|
BuildRecipes = [.. source.BuildRecipes];
|
||||||
Construction = source.Construction;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public required string Id { get; set; }
|
public required string Id { get; set; }
|
||||||
@@ -234,13 +249,12 @@ public class ModuleDefinition
|
|||||||
public required string Type { get; set; }
|
public required string Type { get; set; }
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public ModuleType ModuleType { get; set; }
|
public ModuleType ModuleType { get; set; }
|
||||||
|
[JsonPropertyName("product")]
|
||||||
|
public List<string> ProductIds { get; set; } = [];
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public string? Product { get; set; }
|
public virtual IReadOnlyList<string> ProductItemIds => [];
|
||||||
public List<string> Products { get; set; } = [];
|
|
||||||
public string ProductionMode { get; set; } = "passive";
|
|
||||||
public float Radius { get; set; } = 12f;
|
public float Radius { get; set; } = 12f;
|
||||||
public float Hull { get; set; } = 100f;
|
public float Hull { get; set; } = 100f;
|
||||||
public float WorkforceNeeded { get; set; }
|
|
||||||
public int Version { get; set; }
|
public int Version { get; set; }
|
||||||
public string Macro { get; set; } = string.Empty;
|
public string Macro { get; set; } = string.Empty;
|
||||||
public string MakerRace { get; set; } = string.Empty;
|
public string MakerRace { get; set; } = string.Empty;
|
||||||
@@ -248,30 +262,58 @@ public class ModuleDefinition
|
|||||||
public ItemPriceDefinition? Price { get; set; }
|
public ItemPriceDefinition? Price { get; set; }
|
||||||
public List<string> Owners { get; set; } = [];
|
public List<string> Owners { get; set; } = [];
|
||||||
public ModuleCargoDefinition? Cargo { get; set; }
|
public ModuleCargoDefinition? Cargo { get; set; }
|
||||||
public ModuleWorkForceDefinition? WorkForce { get; set; }
|
[JsonPropertyName("workForce")]
|
||||||
|
public ModuleWorkforceDefinition? SerializedWorkforce { get; set; }
|
||||||
public List<ModuleDockDefinition> Docks { get; set; } = [];
|
public List<ModuleDockDefinition> Docks { get; set; } = [];
|
||||||
public List<ModuleMountDefinition> Shields { get; set; } = [];
|
public List<ModuleMountDefinition> Shields { get; set; } = [];
|
||||||
public List<ModuleMountDefinition> Turrets { get; set; } = [];
|
public List<ModuleMountDefinition> Turrets { get; set; } = [];
|
||||||
public List<ModuleProductionDefinition> Production { get; set; } = [];
|
[JsonPropertyName("production")]
|
||||||
public ModuleConstructionDefinition? Construction { get; set; }
|
public List<ModuleBuildRecipeDefinition> BuildRecipes { get; set; } = [];
|
||||||
[JsonPropertyName("product")]
|
}
|
||||||
public List<string> ProductIds
|
|
||||||
|
public abstract class ProductionLaneModuleDefinition : ModuleDefinition
|
||||||
|
{
|
||||||
|
[SetsRequiredMembers]
|
||||||
|
protected ProductionLaneModuleDefinition(ModuleDefinition source, float requiredWorkforce)
|
||||||
|
: base(source)
|
||||||
|
{
|
||||||
|
RequiredWorkforce = requiredWorkforce;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float RequiredWorkforce { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProductionModuleDefinition : ProductionLaneModuleDefinition
|
||||||
|
{
|
||||||
|
[SetsRequiredMembers]
|
||||||
|
internal ProductionModuleDefinition(ModuleDefinition source, float requiredWorkforce)
|
||||||
|
: base(source, requiredWorkforce)
|
||||||
|
{
|
||||||
|
ProductItemIds = [.. source.ProductIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
public override IReadOnlyList<string> ProductItemIds { get; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BuildModuleDefinition : ProductionLaneModuleDefinition
|
||||||
|
{
|
||||||
|
[SetsRequiredMembers]
|
||||||
|
internal BuildModuleDefinition(ModuleDefinition source, float requiredWorkforce)
|
||||||
|
: base(source, requiredWorkforce)
|
||||||
{
|
{
|
||||||
get => Products;
|
|
||||||
set => Products = value ?? [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ProductionModuleDefinition : ModuleDefinition
|
public sealed class HabitationModuleDefinition : ModuleDefinition
|
||||||
{
|
{
|
||||||
[SetsRequiredMembers]
|
[SetsRequiredMembers]
|
||||||
internal ProductionModuleDefinition(ModuleDefinition source)
|
internal HabitationModuleDefinition(ModuleDefinition source, float supportedPopulation)
|
||||||
: base(source)
|
: base(source)
|
||||||
{
|
{
|
||||||
ProductItemIds = [.. source.Products];
|
SupportedPopulation = supportedPopulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<string> ProductItemIds { get; init; } = [];
|
public float SupportedPopulation { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StorageModuleDefinition : ModuleDefinition
|
public sealed class StorageModuleDefinition : ModuleDefinition
|
||||||
@@ -327,34 +369,202 @@ public sealed class PlanetDefinition
|
|||||||
public bool HasRing { get; set; }
|
public bool HasRing { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum ShipPurpose
|
||||||
|
{
|
||||||
|
[JsonStringEnumMemberName("auxiliary")]
|
||||||
|
Auxiliary,
|
||||||
|
[JsonStringEnumMemberName("mine")]
|
||||||
|
Mine,
|
||||||
|
[JsonStringEnumMemberName("build")]
|
||||||
|
Build,
|
||||||
|
[JsonStringEnumMemberName("fight")]
|
||||||
|
Fight,
|
||||||
|
[JsonStringEnumMemberName("trade")]
|
||||||
|
Trade,
|
||||||
|
[JsonStringEnumMemberName("salvage")]
|
||||||
|
Salvage,
|
||||||
|
[JsonStringEnumMemberName("dismantling")]
|
||||||
|
Dismantling,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ShipType
|
||||||
|
{
|
||||||
|
[JsonStringEnumMemberName("resupplier")]
|
||||||
|
Resupplier,
|
||||||
|
[JsonStringEnumMemberName("miner")]
|
||||||
|
Miner,
|
||||||
|
[JsonStringEnumMemberName("carrier")]
|
||||||
|
Carrier,
|
||||||
|
[JsonStringEnumMemberName("fighter")]
|
||||||
|
Fighter,
|
||||||
|
[JsonStringEnumMemberName("heavyfighter")]
|
||||||
|
HeavyFighter,
|
||||||
|
[JsonStringEnumMemberName("destroyer")]
|
||||||
|
Destroyer,
|
||||||
|
[JsonStringEnumMemberName("largeminer")]
|
||||||
|
LargeMiner,
|
||||||
|
[JsonStringEnumMemberName("freighter")]
|
||||||
|
Freighter,
|
||||||
|
[JsonStringEnumMemberName("bomber")]
|
||||||
|
Bomber,
|
||||||
|
[JsonStringEnumMemberName("scavenger")]
|
||||||
|
Scavenger,
|
||||||
|
[JsonStringEnumMemberName("frigate")]
|
||||||
|
Frigate,
|
||||||
|
[JsonStringEnumMemberName("transporter")]
|
||||||
|
Transporter,
|
||||||
|
[JsonStringEnumMemberName("interceptor")]
|
||||||
|
Interceptor,
|
||||||
|
[JsonStringEnumMemberName("scout")]
|
||||||
|
Scout,
|
||||||
|
[JsonStringEnumMemberName("courier")]
|
||||||
|
Courier,
|
||||||
|
[JsonStringEnumMemberName("builder")]
|
||||||
|
Builder,
|
||||||
|
[JsonStringEnumMemberName("corvette")]
|
||||||
|
Corvette,
|
||||||
|
[JsonStringEnumMemberName("police")]
|
||||||
|
Police,
|
||||||
|
[JsonStringEnumMemberName("battleship")]
|
||||||
|
Battleship,
|
||||||
|
[JsonStringEnumMemberName("gunboat")]
|
||||||
|
Gunboat,
|
||||||
|
[JsonStringEnumMemberName("tug")]
|
||||||
|
Tug,
|
||||||
|
[JsonStringEnumMemberName("compactor")]
|
||||||
|
Compactor,
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class ShipDefinition
|
public sealed class ShipDefinition
|
||||||
{
|
{
|
||||||
public required string Id { get; set; }
|
public required string Id { get; set; }
|
||||||
public required string Label { get; set; }
|
public int Version { get; set; }
|
||||||
public required string Kind { get; set; }
|
public required string Name { get; set; }
|
||||||
public required string Class { get; set; }
|
public string Description { get; set; } = string.Empty;
|
||||||
public float Speed { get; set; }
|
public string Size { get; set; } = string.Empty;
|
||||||
public float WarpSpeed { get; set; }
|
public float ExplosionDamage { get; set; }
|
||||||
public float FtlSpeed { get; set; }
|
public float Hull { get; set; }
|
||||||
public float SpoolTime { get; set; }
|
public Dictionary<string, float> Storage { get; set; } = new(StringComparer.Ordinal);
|
||||||
public float CargoCapacity { get; set; }
|
public int People { get; set; }
|
||||||
public string? CargoKind { 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]
|
[JsonIgnore]
|
||||||
public StorageKind? CargoStorageKind { get; set; }
|
public float Speed => InferLocalSpeed(Size);
|
||||||
public required string Color { get; set; }
|
[JsonIgnore]
|
||||||
public required string HullColor { get; set; }
|
public float WarpSpeed => InferWarpSpeed(Size);
|
||||||
public float Size { get; set; }
|
[JsonIgnore]
|
||||||
public float MaxHealth { get; set; }
|
public float FtlSpeed => InferFtlSpeed(Size);
|
||||||
public List<string> Capabilities { get; set; } = [];
|
[JsonIgnore]
|
||||||
public ConstructionDefinition? Construction { get; set; }
|
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 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<InitialStationDefinition> InitialStations { get; set; }
|
||||||
public required List<ShipFormationDefinition> ShipFormations { get; set; }
|
public required List<ShipFormationDefinition> ShipFormations { get; set; }
|
||||||
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }
|
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }
|
||||||
public required MiningDefaultsDefinition MiningDefaults { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class InitialStationDefinition
|
public sealed class InitialStationDefinition
|
||||||
@@ -385,9 +595,3 @@ public sealed class PatrolRouteDefinition
|
|||||||
public required string SystemId { get; set; }
|
public required string SystemId { get; set; }
|
||||||
public required List<float[]> Points { 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.Industry.Planning;
|
||||||
using SpaceGame.Api.Stations.Simulation;
|
using SpaceGame.Api.Stations.Simulation;
|
||||||
|
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||||
|
|
||||||
namespace SpaceGame.Api.Factions.AI;
|
namespace SpaceGame.Api.Factions.AI;
|
||||||
@@ -13,8 +14,12 @@ internal sealed class CommanderPlanningService
|
|||||||
private const int MaxDecisionLogEntries = 40;
|
private const int MaxDecisionLogEntries = 40;
|
||||||
private const int MaxOutcomeEntries = 32;
|
private const int MaxOutcomeEntries = 32;
|
||||||
private const int MaxAiOrdersPerShip = 2;
|
private const int MaxAiOrdersPerShip = 2;
|
||||||
|
private const string MilitaryShipCategory = "military";
|
||||||
|
private const string MiningShipCategory = "mining";
|
||||||
|
private const string TransportShipCategory = "transport";
|
||||||
|
private const string ConstructionShipCategory = "construction";
|
||||||
|
|
||||||
internal void UpdateCommanders(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
internal void UpdateCommanders(SimulationWorld world, IPlayerStateStore playerStateStore, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||||
{
|
{
|
||||||
EnsureHierarchy(world);
|
EnsureHierarchy(world);
|
||||||
|
|
||||||
@@ -33,7 +38,7 @@ internal sealed class CommanderPlanningService
|
|||||||
|
|
||||||
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Faction).ToList())
|
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Faction).ToList())
|
||||||
{
|
{
|
||||||
if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId))
|
if (PlayerFactionService.IsPlayerFaction(playerStateStore, commander.FactionId))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -48,7 +53,7 @@ internal sealed class CommanderPlanningService
|
|||||||
|
|
||||||
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Fleet).ToList())
|
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Fleet).ToList())
|
||||||
{
|
{
|
||||||
if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId))
|
if (PlayerFactionService.IsPlayerFaction(playerStateStore, commander.FactionId))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -63,7 +68,7 @@ internal sealed class CommanderPlanningService
|
|||||||
|
|
||||||
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Station).ToList())
|
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Station).ToList())
|
||||||
{
|
{
|
||||||
if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId))
|
if (PlayerFactionService.IsPlayerFaction(playerStateStore, commander.FactionId))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -78,7 +83,7 @@ internal sealed class CommanderPlanningService
|
|||||||
|
|
||||||
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Ship).ToList())
|
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Ship).ToList())
|
||||||
{
|
{
|
||||||
if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId))
|
if (PlayerFactionService.IsPlayerFaction(playerStateStore, commander.FactionId))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -268,7 +273,7 @@ internal sealed class CommanderPlanningService
|
|||||||
CommanderRuntime factionCommander,
|
CommanderRuntime factionCommander,
|
||||||
IReadOnlyDictionary<string, CommanderRuntime> stationCommanders)
|
IReadOnlyDictionary<string, CommanderRuntime> stationCommanders)
|
||||||
{
|
{
|
||||||
if (string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal))
|
if (IsMilitaryShip(ship.Definition))
|
||||||
{
|
{
|
||||||
return factionCommander;
|
return factionCommander;
|
||||||
}
|
}
|
||||||
@@ -456,8 +461,8 @@ internal sealed class CommanderPlanningService
|
|||||||
ship.Id,
|
ship.Id,
|
||||||
nextAssignment is null ? "assignment-cleared" : "assignment-updated",
|
nextAssignment is null ? "assignment-cleared" : "assignment-updated",
|
||||||
nextAssignment is null
|
nextAssignment is null
|
||||||
? $"{ship.Definition.Label} returned to default behavior."
|
? $"{ship.Definition.Name} returned to default behavior."
|
||||||
: $"{ship.Definition.Label} assigned to {nextAssignment.Kind}.",
|
: $"{ship.Definition.Name} assigned to {nextAssignment.Kind}.",
|
||||||
DateTimeOffset.UtcNow));
|
DateTimeOffset.UtcNow));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -586,10 +591,10 @@ internal sealed class CommanderPlanningService
|
|||||||
var frontCount = Math.Max(1,
|
var frontCount = Math.Max(1,
|
||||||
threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind is "controlled-system" or "contested-system")
|
threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind is "controlled-system" or "contested-system")
|
||||||
+ (expansionProject is null ? 0 : 1));
|
+ (expansionProject is null ? 0 : 1));
|
||||||
var militaryShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "military");
|
var militaryShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsMilitaryShip(ship.Definition));
|
||||||
var minerShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && HasShipCapabilities(ship.Definition, "mining"));
|
var minerShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsMiningShip(ship.Definition));
|
||||||
var transportShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "transport");
|
var transportShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsTransportShip(ship.Definition));
|
||||||
var constructorShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "construction");
|
var constructorShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsConstructionShip(ship.Definition));
|
||||||
var hasShipyard = world.Stations.Any(station =>
|
var hasShipyard = world.Stations.Any(station =>
|
||||||
string.Equals(station.FactionId, faction.Id, StringComparison.Ordinal) &&
|
string.Equals(station.FactionId, faction.Id, StringComparison.Ordinal) &&
|
||||||
station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal));
|
station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal));
|
||||||
@@ -1092,14 +1097,14 @@ internal sealed class CommanderPlanningService
|
|||||||
{
|
{
|
||||||
theaters.Add(new FactionTheaterRuntime
|
theaters.Add(new FactionTheaterRuntime
|
||||||
{
|
{
|
||||||
Id = $"theater-expansion-{expansionProject.SystemId}-{expansionProject.CelestialId}",
|
Id = $"theater-expansion-{expansionProject.SystemId}-{expansionProject.AnchorId}",
|
||||||
Kind = "expansion-front",
|
Kind = "expansion-front",
|
||||||
SystemId = expansionProject.SystemId,
|
SystemId = expansionProject.SystemId,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = 65f + (economicAssessment.HasShipyard ? 0f : 15f),
|
Priority = 65f + (economicAssessment.HasShipyard ? 0f : 15f),
|
||||||
SupplyRisk = ComputeSystemRisk(world, faction, expansionProject.SystemId),
|
SupplyRisk = ComputeSystemRisk(world, faction, expansionProject.SystemId),
|
||||||
FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, expansionProject.SystemId),
|
FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, expansionProject.SystemId),
|
||||||
AnchorEntityId = expansionProject.SiteId ?? expansionProject.CelestialId,
|
AnchorEntityId = expansionProject.SiteId ?? expansionProject.AnchorId,
|
||||||
AnchorPosition = ResolveExpansionAnchor(world, expansionProject),
|
AnchorPosition = ResolveExpansionAnchor(world, expansionProject),
|
||||||
UpdatedAtUtc = nowUtc,
|
UpdatedAtUtc = nowUtc,
|
||||||
});
|
});
|
||||||
@@ -1267,7 +1272,7 @@ internal sealed class CommanderPlanningService
|
|||||||
],
|
],
|
||||||
"expansion" =>
|
"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}-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." },
|
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}",
|
Id = $"{campaign.Id}-protect-station-{station.Id}",
|
||||||
CampaignId = campaign.Id,
|
CampaignId = campaign.Id,
|
||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "protect-station",
|
Kind = ProtectStation,
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "protect-station",
|
BehaviorKind = ProtectStation,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 8f,
|
Priority = campaign.Priority + 8f,
|
||||||
HomeSystemId = station.SystemId,
|
HomeSystemId = station.SystemId,
|
||||||
@@ -1389,7 +1394,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "patrol-front",
|
Kind = "patrol-front",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "patrol",
|
BehaviorKind = Patrol,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 2f,
|
Priority = campaign.Priority + 2f,
|
||||||
HomeSystemId = campaign.TargetSystemId,
|
HomeSystemId = campaign.TargetSystemId,
|
||||||
@@ -1414,7 +1419,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "police-front",
|
Kind = "police-front",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "police",
|
BehaviorKind = Police,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 1f,
|
Priority = campaign.Priority + 1f,
|
||||||
HomeSystemId = campaign.TargetSystemId,
|
HomeSystemId = campaign.TargetSystemId,
|
||||||
@@ -1454,7 +1459,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "strike-station",
|
Kind = "strike-station",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "attack-target",
|
BehaviorKind = AttackTarget,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 10f,
|
Priority = campaign.Priority + 10f,
|
||||||
TargetSystemId = enemyStation.SystemId,
|
TargetSystemId = enemyStation.SystemId,
|
||||||
@@ -1478,7 +1483,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "hold-front",
|
Kind = "hold-front",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "protect-position",
|
BehaviorKind = ProtectPosition,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 3f,
|
Priority = campaign.Priority + 3f,
|
||||||
TargetSystemId = campaign.TargetSystemId,
|
TargetSystemId = campaign.TargetSystemId,
|
||||||
@@ -1500,7 +1505,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "fleet-sustainment",
|
Kind = "fleet-sustainment",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "supply-fleet",
|
BehaviorKind = SupplyFleet,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 1.5f,
|
Priority = campaign.Priority + 1.5f,
|
||||||
HomeSystemId = campaign.TargetSystemId,
|
HomeSystemId = campaign.TargetSystemId,
|
||||||
@@ -1539,7 +1544,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "construct-site",
|
Kind = "construct-site",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "construct-station",
|
BehaviorKind = ConstructStation,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 8f,
|
Priority = campaign.Priority + 8f,
|
||||||
HomeSystemId = expansionProject.SystemId,
|
HomeSystemId = expansionProject.SystemId,
|
||||||
@@ -1564,7 +1569,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "supply-site",
|
Kind = "supply-site",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "find-build-tasks",
|
BehaviorKind = FindBuildTasks,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 4f,
|
Priority = campaign.Priority + 4f,
|
||||||
HomeSystemId = expansionProject.SystemId,
|
HomeSystemId = expansionProject.SystemId,
|
||||||
@@ -1589,7 +1594,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "guard-site",
|
Kind = "guard-site",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "protect-position",
|
BehaviorKind = ProtectPosition,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 2f,
|
Priority = campaign.Priority + 2f,
|
||||||
TargetSystemId = expansionProject.SystemId,
|
TargetSystemId = expansionProject.SystemId,
|
||||||
@@ -1614,7 +1619,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "mine-expansion-input",
|
Kind = "mine-expansion-input",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "expert-auto-mine",
|
BehaviorKind = ExpertAutoMine,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 1f,
|
Priority = campaign.Priority + 1f,
|
||||||
HomeSystemId = expansionProject.SystemId,
|
HomeSystemId = expansionProject.SystemId,
|
||||||
@@ -1655,7 +1660,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "trade-shortage",
|
Kind = "trade-shortage",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "fill-shortages",
|
BehaviorKind = FillShortages,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 5f,
|
Priority = campaign.Priority + 5f,
|
||||||
HomeSystemId = anchorStation?.SystemId,
|
HomeSystemId = anchorStation?.SystemId,
|
||||||
@@ -1680,7 +1685,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "mine-shortage",
|
Kind = "mine-shortage",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "expert-auto-mine",
|
BehaviorKind = ExpertAutoMine,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 3f,
|
Priority = campaign.Priority + 3f,
|
||||||
HomeSystemId = anchorStation?.SystemId,
|
HomeSystemId = anchorStation?.SystemId,
|
||||||
@@ -1703,7 +1708,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TheaterId = theater?.Id,
|
TheaterId = theater?.Id,
|
||||||
Kind = "revisit-stations",
|
Kind = "revisit-stations",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "revisit-known-stations",
|
BehaviorKind = RevisitKnownStations,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 0.5f,
|
Priority = campaign.Priority + 0.5f,
|
||||||
HomeSystemId = anchorStation?.SystemId,
|
HomeSystemId = anchorStation?.SystemId,
|
||||||
@@ -1743,7 +1748,7 @@ internal sealed class CommanderPlanningService
|
|||||||
CampaignId = campaign.Id,
|
CampaignId = campaign.Id,
|
||||||
Kind = "feed-shipyard",
|
Kind = "feed-shipyard",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "fill-shortages",
|
BehaviorKind = FillShortages,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 4f,
|
Priority = campaign.Priority + 4f,
|
||||||
HomeSystemId = shipyard.SystemId,
|
HomeSystemId = shipyard.SystemId,
|
||||||
@@ -1768,7 +1773,7 @@ internal sealed class CommanderPlanningService
|
|||||||
CampaignId = campaign.Id,
|
CampaignId = campaign.Id,
|
||||||
Kind = "mine-bottleneck",
|
Kind = "mine-bottleneck",
|
||||||
DelegationKind = "ship",
|
DelegationKind = "ship",
|
||||||
BehaviorKind = "expert-auto-mine",
|
BehaviorKind = ExpertAutoMine,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = campaign.Priority + 2f,
|
Priority = campaign.Priority + 2f,
|
||||||
HomeSystemId = shipyard.SystemId,
|
HomeSystemId = shipyard.SystemId,
|
||||||
@@ -1838,7 +1843,9 @@ internal sealed class CommanderPlanningService
|
|||||||
var reservedCommanderIds = new HashSet<string>(StringComparer.Ordinal);
|
var reservedCommanderIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
var availableMilitaryCommanders = commanders.Count(commander =>
|
var availableMilitaryCommanders = commanders.Count(commander =>
|
||||||
commander.Kind == CommanderKind.Ship &&
|
commander.Kind == CommanderKind.Ship &&
|
||||||
world.Ships.FirstOrDefault(ship => ship.Id == commander.ControlledEntityId) is { Definition.Kind: "military", Health: > 0f });
|
world.Ships.FirstOrDefault(ship => ship.Id == commander.ControlledEntityId) is { } commanderShip
|
||||||
|
&& commanderShip.Health > 0f
|
||||||
|
&& IsMilitaryShip(commanderShip.Definition));
|
||||||
var committedMilitaryCommanders = 0;
|
var committedMilitaryCommanders = 0;
|
||||||
|
|
||||||
foreach (var objective in objectives
|
foreach (var objective in objectives
|
||||||
@@ -1921,11 +1928,11 @@ internal sealed class CommanderPlanningService
|
|||||||
|
|
||||||
return objective.BehaviorKind switch
|
return objective.BehaviorKind switch
|
||||||
{
|
{
|
||||||
"construct-station" => string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal),
|
ConstructStation => IsConstructionShip(ship.Definition),
|
||||||
"find-build-tasks" => string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal),
|
FindBuildTasks => IsTransportShip(ship.Definition),
|
||||||
"fill-shortages" or "advanced-auto-trade" or "revisit-known-stations" or "supply-fleet" => string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal),
|
FillShortages or AdvancedAutoTrade or RevisitKnownStations or SupplyFleet => IsTransportShip(ship.Definition),
|
||||||
"local-auto-mine" or "advanced-auto-mine" or "expert-auto-mine" => HasShipCapabilities(ship.Definition, "mining"),
|
LocalAutoMine or AdvancedAutoMine or ExpertAutoMine => IsMiningShip(ship.Definition),
|
||||||
"patrol" or "police" or "protect-position" or "protect-ship" or "protect-station" or "attack-target" => string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal),
|
Patrol or Police or ProtectPosition or ProtectShip or ProtectStation or AttackTarget => IsMilitaryShip(ship.Definition),
|
||||||
_ => true,
|
_ => true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1992,7 +1999,7 @@ internal sealed class CommanderPlanningService
|
|||||||
Kind = "military-fleet",
|
Kind = "military-fleet",
|
||||||
Status = economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount ? "stable" : "active",
|
Status = economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount ? "stable" : "active",
|
||||||
Priority = 80f + (threatAssessment.ThreatSignals.Count * 4f),
|
Priority = 80f + (threatAssessment.ThreatSignals.Count * 4f),
|
||||||
ShipKind = "military",
|
ShipKind = MilitaryShipCategory,
|
||||||
TargetCount = economicAssessment.TargetMilitaryShipCount,
|
TargetCount = economicAssessment.TargetMilitaryShipCount,
|
||||||
CurrentCount = economicAssessment.MilitaryShipCount,
|
CurrentCount = economicAssessment.MilitaryShipCount,
|
||||||
Notes = "Maintain enough military hulls for all active fronts.",
|
Notes = "Maintain enough military hulls for all active fronts.",
|
||||||
@@ -2004,7 +2011,7 @@ internal sealed class CommanderPlanningService
|
|||||||
Kind = "mining-fleet",
|
Kind = "mining-fleet",
|
||||||
Status = economicAssessment.MinerShipCount >= economicAssessment.TargetMinerShipCount ? "stable" : "active",
|
Status = economicAssessment.MinerShipCount >= economicAssessment.TargetMinerShipCount ? "stable" : "active",
|
||||||
Priority = 60f,
|
Priority = 60f,
|
||||||
ShipKind = "mining",
|
ShipKind = MiningShipCategory,
|
||||||
TargetCount = economicAssessment.TargetMinerShipCount,
|
TargetCount = economicAssessment.TargetMinerShipCount,
|
||||||
CurrentCount = economicAssessment.MinerShipCount,
|
CurrentCount = economicAssessment.MinerShipCount,
|
||||||
Notes = "Maintain raw resource extraction capacity.",
|
Notes = "Maintain raw resource extraction capacity.",
|
||||||
@@ -2016,7 +2023,7 @@ internal sealed class CommanderPlanningService
|
|||||||
Kind = "logistics-fleet",
|
Kind = "logistics-fleet",
|
||||||
Status = economicAssessment.TransportShipCount >= economicAssessment.TargetTransportShipCount ? "stable" : "active",
|
Status = economicAssessment.TransportShipCount >= economicAssessment.TargetTransportShipCount ? "stable" : "active",
|
||||||
Priority = 62f,
|
Priority = 62f,
|
||||||
ShipKind = "transport",
|
ShipKind = TransportShipCategory,
|
||||||
TargetCount = economicAssessment.TargetTransportShipCount,
|
TargetCount = economicAssessment.TargetTransportShipCount,
|
||||||
CurrentCount = economicAssessment.TransportShipCount,
|
CurrentCount = economicAssessment.TransportShipCount,
|
||||||
Notes = "Maintain logistics throughput across stations and fronts.",
|
Notes = "Maintain logistics throughput across stations and fronts.",
|
||||||
@@ -2028,7 +2035,7 @@ internal sealed class CommanderPlanningService
|
|||||||
Kind = "construction-fleet",
|
Kind = "construction-fleet",
|
||||||
Status = economicAssessment.ConstructorShipCount >= economicAssessment.TargetConstructorShipCount ? "stable" : "active",
|
Status = economicAssessment.ConstructorShipCount >= economicAssessment.TargetConstructorShipCount ? "stable" : "active",
|
||||||
Priority = expansionProject is null ? 35f : 68f,
|
Priority = expansionProject is null ? 35f : 68f,
|
||||||
ShipKind = "construction",
|
ShipKind = ConstructionShipCategory,
|
||||||
TargetCount = economicAssessment.TargetConstructorShipCount,
|
TargetCount = economicAssessment.TargetConstructorShipCount,
|
||||||
CurrentCount = economicAssessment.ConstructorShipCount,
|
CurrentCount = economicAssessment.ConstructorShipCount,
|
||||||
Notes = "Maintain construction capacity for frontier growth.",
|
Notes = "Maintain construction capacity for frontier growth.",
|
||||||
@@ -2347,10 +2354,10 @@ internal sealed class CommanderPlanningService
|
|||||||
Kind = "fleet-command",
|
Kind = "fleet-command",
|
||||||
BehaviorKind = campaign.Kind switch
|
BehaviorKind = campaign.Kind switch
|
||||||
{
|
{
|
||||||
"offense" => "attack-target",
|
"offense" => AttackTarget,
|
||||||
"defense" => "protect-position",
|
"defense" => ProtectPosition,
|
||||||
"expansion" => "protect-position",
|
"expansion" => ProtectPosition,
|
||||||
_ => "patrol",
|
_ => Patrol,
|
||||||
},
|
},
|
||||||
Status = campaign.Status,
|
Status = campaign.Status,
|
||||||
Priority = campaign.Priority,
|
Priority = campaign.Priority,
|
||||||
@@ -2380,7 +2387,7 @@ internal sealed class CommanderPlanningService
|
|||||||
{
|
{
|
||||||
ObjectiveId = $"objective-station-{station.Id}-ship-production",
|
ObjectiveId = $"objective-station-{station.Id}-ship-production",
|
||||||
Kind = "ship-production-focus",
|
Kind = "ship-production-focus",
|
||||||
BehaviorKind = "fill-shortages",
|
BehaviorKind = FillShortages,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = 55f,
|
Priority = 55f,
|
||||||
HomeSystemId = station.SystemId,
|
HomeSystemId = station.SystemId,
|
||||||
@@ -2399,7 +2406,7 @@ internal sealed class CommanderPlanningService
|
|||||||
{
|
{
|
||||||
ObjectiveId = $"objective-station-{station.Id}-commodity-focus-{bottleneckItem}",
|
ObjectiveId = $"objective-station-{station.Id}-commodity-focus-{bottleneckItem}",
|
||||||
Kind = "commodity-focus",
|
Kind = "commodity-focus",
|
||||||
BehaviorKind = "fill-shortages",
|
BehaviorKind = FillShortages,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = 45f,
|
Priority = 45f,
|
||||||
HomeSystemId = station.SystemId,
|
HomeSystemId = station.SystemId,
|
||||||
@@ -2418,7 +2425,7 @@ internal sealed class CommanderPlanningService
|
|||||||
{
|
{
|
||||||
ObjectiveId = $"objective-station-{station.Id}-expansion-support",
|
ObjectiveId = $"objective-station-{station.Id}-expansion-support",
|
||||||
Kind = "expansion-support",
|
Kind = "expansion-support",
|
||||||
BehaviorKind = "find-build-tasks",
|
BehaviorKind = FindBuildTasks,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = 40f,
|
Priority = 40f,
|
||||||
HomeSystemId = station.SystemId,
|
HomeSystemId = station.SystemId,
|
||||||
@@ -2435,7 +2442,7 @@ internal sealed class CommanderPlanningService
|
|||||||
{
|
{
|
||||||
ObjectiveId = $"objective-station-{station.Id}-oversight",
|
ObjectiveId = $"objective-station-{station.Id}-oversight",
|
||||||
Kind = "station-oversight",
|
Kind = "station-oversight",
|
||||||
BehaviorKind = "fill-shortages",
|
BehaviorKind = FillShortages,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Priority = 30f,
|
Priority = 30f,
|
||||||
HomeSystemId = station.SystemId,
|
HomeSystemId = station.SystemId,
|
||||||
@@ -2460,7 +2467,7 @@ internal sealed class CommanderPlanningService
|
|||||||
faction.StrategicState.Objectives.Any(objective =>
|
faction.StrategicState.Objectives.Any(objective =>
|
||||||
objective.CampaignId == campaign.Id &&
|
objective.CampaignId == campaign.Id &&
|
||||||
objective.CommanderId is not null &&
|
objective.CommanderId is not null &&
|
||||||
(IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, "supply-fleet", StringComparison.Ordinal))))
|
(IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, SupplyFleet, StringComparison.Ordinal))))
|
||||||
.Select(campaign => campaign.Id)
|
.Select(campaign => campaign.Id)
|
||||||
.ToHashSet(StringComparer.Ordinal);
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
@@ -2510,10 +2517,10 @@ internal sealed class CommanderPlanningService
|
|||||||
Kind = "fleet-command",
|
Kind = "fleet-command",
|
||||||
BehaviorKind = campaign.Kind switch
|
BehaviorKind = campaign.Kind switch
|
||||||
{
|
{
|
||||||
"offense" => "attack-target",
|
"offense" => AttackTarget,
|
||||||
"defense" => "protect-position",
|
"defense" => ProtectPosition,
|
||||||
"expansion" => "protect-position",
|
"expansion" => ProtectPosition,
|
||||||
_ => "patrol",
|
_ => Patrol,
|
||||||
},
|
},
|
||||||
Status = campaign.Status,
|
Status = campaign.Status,
|
||||||
Priority = campaign.Priority + 1f,
|
Priority = campaign.Priority + 1f,
|
||||||
@@ -2581,7 +2588,7 @@ internal sealed class CommanderPlanningService
|
|||||||
{
|
{
|
||||||
if (objective?.CampaignId is not null
|
if (objective?.CampaignId is not null
|
||||||
&& fleetCommanders.TryGetValue(objective.CampaignId, out var fleetCommander)
|
&& fleetCommanders.TryGetValue(objective.CampaignId, out var fleetCommander)
|
||||||
&& (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, "supply-fleet", StringComparison.Ordinal)))
|
&& (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, SupplyFleet, StringComparison.Ordinal)))
|
||||||
{
|
{
|
||||||
return fleetCommander.Id;
|
return fleetCommander.Id;
|
||||||
}
|
}
|
||||||
@@ -2598,25 +2605,39 @@ internal sealed class CommanderPlanningService
|
|||||||
private static DefaultBehaviorRuntime BuildFallbackBehavior(SimulationWorld world, ShipRuntime ship)
|
private static DefaultBehaviorRuntime BuildFallbackBehavior(SimulationWorld world, ShipRuntime ship)
|
||||||
{
|
{
|
||||||
var homeStation = ResolveFallbackHomeStation(world, ship);
|
var homeStation = ResolveFallbackHomeStation(world, ship);
|
||||||
if (HasShipCapabilities(ship.Definition, "mining"))
|
if (IsMiningShip(ship.Definition))
|
||||||
{
|
{
|
||||||
|
if (homeStation is null)
|
||||||
|
{
|
||||||
|
return new DefaultBehaviorRuntime
|
||||||
|
{
|
||||||
|
Kind = LocalAutoMine,
|
||||||
|
HomeSystemId = ship.SystemId,
|
||||||
|
HomeStationId = null,
|
||||||
|
AreaSystemId = ship.SystemId,
|
||||||
|
ItemId = "ore",
|
||||||
|
Radius = 24f,
|
||||||
|
MaxSystemRange = 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = ship.Definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine",
|
Kind = ship.Definition.GetTotalCargoCapacity() >= 120f ? ExpertAutoMine : AdvancedAutoMine,
|
||||||
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
HomeStationId = homeStation?.Id,
|
HomeStationId = homeStation?.Id,
|
||||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
PreferredItemId = null,
|
ItemId = null,
|
||||||
Radius = 24f,
|
Radius = 24f,
|
||||||
MaxSystemRange = ship.Definition.CargoCapacity >= 120f ? 3 : 1,
|
MaxSystemRange = ship.Definition.GetTotalCargoCapacity() >= 120f ? 3 : 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal))
|
if (IsTransportShip(ship.Definition))
|
||||||
{
|
{
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "advanced-auto-trade",
|
Kind = AdvancedAutoTrade,
|
||||||
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
HomeStationId = homeStation?.Id,
|
HomeStationId = homeStation?.Id,
|
||||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
@@ -2625,11 +2646,11 @@ internal sealed class CommanderPlanningService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal))
|
if (IsConstructionShip(ship.Definition))
|
||||||
{
|
{
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "construct-station",
|
Kind = ConstructStation,
|
||||||
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
HomeStationId = homeStation?.Id,
|
HomeStationId = homeStation?.Id,
|
||||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
@@ -2638,13 +2659,13 @@ internal sealed class CommanderPlanningService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal))
|
if (IsMilitaryShip(ship.Definition))
|
||||||
{
|
{
|
||||||
var anchor = homeStation?.Position ?? ship.Position;
|
var anchor = homeStation?.Position ?? ship.Position;
|
||||||
var patrolRadius = (homeStation?.Radius ?? 30f) + 90f;
|
var patrolRadius = (homeStation?.Radius ?? 30f) + 90f;
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "patrol",
|
Kind = Patrol,
|
||||||
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
HomeStationId = homeStation?.Id,
|
HomeStationId = homeStation?.Id,
|
||||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
@@ -2660,7 +2681,7 @@ internal sealed class CommanderPlanningService
|
|||||||
|
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "idle",
|
Kind = Idle,
|
||||||
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
HomeStationId = homeStation?.Id,
|
HomeStationId = homeStation?.Id,
|
||||||
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
|
||||||
@@ -2684,15 +2705,15 @@ internal sealed class CommanderPlanningService
|
|||||||
var areaSystemId = objective.TargetSystemId ?? objective.HomeSystemId ?? fallback.AreaSystemId ?? ship.SystemId;
|
var areaSystemId = objective.TargetSystemId ?? objective.HomeSystemId ?? fallback.AreaSystemId ?? ship.SystemId;
|
||||||
var radius = objective.BehaviorKind switch
|
var radius = objective.BehaviorKind switch
|
||||||
{
|
{
|
||||||
"protect-position" or "protect-station" or "patrol" or "police" => MathF.Max(80f, fallback.Radius),
|
ProtectPosition or ProtectStation or Patrol or Police => MathF.Max(80f, fallback.Radius),
|
||||||
"follow-ship" or "protect-ship" => MathF.Max(18f, fallback.Radius * 0.6f),
|
FollowShip or ProtectShip => MathF.Max(18f, fallback.Radius * 0.6f),
|
||||||
"fill-shortages" or "advanced-auto-trade" or "find-build-tasks" => MathF.Max(20f, fallback.Radius),
|
FillShortages or AdvancedAutoTrade or FindBuildTasks => MathF.Max(20f, fallback.Radius),
|
||||||
_ => fallback.Radius,
|
_ => fallback.Radius,
|
||||||
};
|
};
|
||||||
var maxRange = objective.BehaviorKind switch
|
var maxRange = objective.BehaviorKind switch
|
||||||
{
|
{
|
||||||
"attack-target" or "protect-position" or "protect-station" or "protect-ship" or "patrol" or "police" => Math.Max(1, fallback.MaxSystemRange),
|
AttackTarget or ProtectPosition or ProtectStation or ProtectShip or Patrol or Police => Math.Max(1, fallback.MaxSystemRange),
|
||||||
"fill-shortages" or "advanced-auto-trade" or "find-build-tasks" or "supply-fleet" => Math.Max(2, fallback.MaxSystemRange),
|
FillShortages or AdvancedAutoTrade or FindBuildTasks or SupplyFleet => Math.Max(2, fallback.MaxSystemRange),
|
||||||
_ => fallback.MaxSystemRange,
|
_ => fallback.MaxSystemRange,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2703,16 +2724,16 @@ internal sealed class CommanderPlanningService
|
|||||||
HomeStationId = objective.HomeStationId ?? fallback.HomeStationId,
|
HomeStationId = objective.HomeStationId ?? fallback.HomeStationId,
|
||||||
AreaSystemId = areaSystemId,
|
AreaSystemId = areaSystemId,
|
||||||
TargetEntityId = objective.TargetEntityId,
|
TargetEntityId = objective.TargetEntityId,
|
||||||
PreferredItemId = objective.ItemId ?? fallback.PreferredItemId,
|
ItemId = objective.ItemId ?? fallback.ItemId,
|
||||||
PreferredNodeId = fallback.PreferredNodeId,
|
PreferredAnchorId = fallback.PreferredAnchorId,
|
||||||
PreferredConstructionSiteId = objective.Kind is "construct-site" or "supply-site" ? objective.TargetEntityId : fallback.PreferredConstructionSiteId,
|
PreferredConstructionSiteId = objective.Kind is "construct-site" or "supply-site" ? objective.TargetEntityId : fallback.PreferredConstructionSiteId,
|
||||||
PreferredModuleId = fallback.PreferredModuleId,
|
PreferredModuleId = fallback.PreferredModuleId,
|
||||||
TargetPosition = objective.TargetPosition ?? fallback.TargetPosition,
|
TargetPosition = objective.TargetPosition ?? fallback.TargetPosition,
|
||||||
WaitSeconds = objective.BehaviorKind == "supply-fleet" ? 4f : fallback.WaitSeconds,
|
WaitSeconds = objective.BehaviorKind == SupplyFleet ? 4f : fallback.WaitSeconds,
|
||||||
Radius = radius,
|
Radius = radius,
|
||||||
MaxSystemRange = maxRange,
|
MaxSystemRange = maxRange,
|
||||||
KnownStationsOnly = objective.BehaviorKind == "revisit-known-stations",
|
KnownStationsOnly = objective.BehaviorKind == RevisitKnownStations,
|
||||||
PatrolPoints = objective.BehaviorKind == "patrol"
|
PatrolPoints = objective.BehaviorKind == Patrol
|
||||||
? BuildPatrolPoints(objective.TargetPosition ?? fallback.TargetPosition ?? ship.Position, radius)
|
? BuildPatrolPoints(objective.TargetPosition ?? fallback.TargetPosition ?? ship.Position, radius)
|
||||||
: [],
|
: [],
|
||||||
PatrolIndex = ship.DefaultBehavior.PatrolIndex,
|
PatrolIndex = ship.DefaultBehavior.PatrolIndex,
|
||||||
@@ -2728,8 +2749,8 @@ internal sealed class CommanderPlanningService
|
|||||||
target.HomeStationId = source.HomeStationId;
|
target.HomeStationId = source.HomeStationId;
|
||||||
target.AreaSystemId = source.AreaSystemId;
|
target.AreaSystemId = source.AreaSystemId;
|
||||||
target.TargetEntityId = source.TargetEntityId;
|
target.TargetEntityId = source.TargetEntityId;
|
||||||
target.PreferredItemId = source.PreferredItemId;
|
target.ItemId = source.ItemId;
|
||||||
target.PreferredNodeId = source.PreferredNodeId;
|
target.PreferredAnchorId = source.PreferredAnchorId;
|
||||||
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
|
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
|
||||||
target.PreferredModuleId = source.PreferredModuleId;
|
target.PreferredModuleId = source.PreferredModuleId;
|
||||||
target.TargetPosition = source.TargetPosition;
|
target.TargetPosition = source.TargetPosition;
|
||||||
@@ -2749,8 +2770,8 @@ internal sealed class CommanderPlanningService
|
|||||||
&& string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal)
|
&& string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
|
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal)
|
&& string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.PreferredItemId, right.PreferredItemId, StringComparison.Ordinal)
|
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal)
|
&& string.Equals(left.PreferredAnchorId, right.PreferredAnchorId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
|
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
|
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
|
||||||
&& Nullable.Equals(left.TargetPosition, right.TargetPosition)
|
&& 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.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
|
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.ItemId, right.ItemId, 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.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
|
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
|
||||||
&& left.WaitSeconds.Equals(right.WaitSeconds)
|
&& left.WaitSeconds.Equals(right.WaitSeconds)
|
||||||
@@ -2805,13 +2826,15 @@ internal sealed class CommanderPlanningService
|
|||||||
{
|
{
|
||||||
Id = $"ai-order-{objective.Id}",
|
Id = $"ai-order-{objective.Id}",
|
||||||
Kind = objective.StagingOrderKind,
|
Kind = objective.StagingOrderKind,
|
||||||
|
SourceKind = ShipOrderSourceKind.Commander,
|
||||||
|
SourceId = objective.Id,
|
||||||
Priority = 90 + objective.ReinforcementLevel,
|
Priority = 90 + objective.ReinforcementLevel,
|
||||||
InterruptCurrentPlan = true,
|
InterruptCurrentPlan = true,
|
||||||
Label = $"{objective.Kind} staging",
|
Label = $"{objective.Kind} staging",
|
||||||
TargetEntityId = objective.TargetEntityId,
|
TargetEntityId = objective.TargetEntityId,
|
||||||
TargetSystemId = targetSystemId,
|
TargetSystemId = targetSystemId,
|
||||||
TargetPosition = targetPosition,
|
TargetPosition = targetPosition,
|
||||||
DestinationStationId = objective.BehaviorKind == "dock-and-wait" ? objective.TargetEntityId : null,
|
DestinationStationId = objective.BehaviorKind == DockAtStation ? objective.TargetEntityId : null,
|
||||||
ItemId = objective.ItemId,
|
ItemId = objective.ItemId,
|
||||||
WaitSeconds = 0f,
|
WaitSeconds = 0f,
|
||||||
Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f),
|
Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f),
|
||||||
@@ -2840,9 +2863,10 @@ internal sealed class CommanderPlanningService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId);
|
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;
|
return null;
|
||||||
@@ -2850,13 +2874,13 @@ internal sealed class CommanderPlanningService
|
|||||||
|
|
||||||
private static bool ReconcileAiOrders(ShipRuntime ship, ShipOrderRuntime? desiredOrder)
|
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)
|
if (desiredOrder is null)
|
||||||
{
|
{
|
||||||
return changed;
|
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 (existing is not null)
|
||||||
{
|
{
|
||||||
if (ShipOrdersEqual(existing, desiredOrder))
|
if (ShipOrdersEqual(existing, desiredOrder))
|
||||||
@@ -2864,18 +2888,18 @@ internal sealed class CommanderPlanningService
|
|||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
ship.OrderQueue.Remove(existing);
|
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||||
changed = true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ship.OrderQueue.Count >= MaxAiOrdersPerShip)
|
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;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2885,6 +2909,8 @@ internal sealed class CommanderPlanningService
|
|||||||
private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) =>
|
private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) =>
|
||||||
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
|
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.Kind, right.Kind, StringComparison.Ordinal)
|
&& string.Equals(left.Kind, right.Kind, StringComparison.Ordinal)
|
||||||
|
&& left.SourceKind == right.SourceKind
|
||||||
|
&& string.Equals(left.SourceId, right.SourceId, StringComparison.Ordinal)
|
||||||
&& left.Priority == right.Priority
|
&& left.Priority == right.Priority
|
||||||
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
|
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
|
||||||
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
|
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
|
||||||
@@ -2894,7 +2920,7 @@ internal sealed class CommanderPlanningService
|
|||||||
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
|
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
|
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.ItemId, right.ItemId, 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.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
|
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
|
||||||
&& left.WaitSeconds.Equals(right.WaitSeconds)
|
&& left.WaitSeconds.Equals(right.WaitSeconds)
|
||||||
@@ -2920,7 +2946,7 @@ internal sealed class CommanderPlanningService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsCombatObjective(FactionOperationalObjectiveRuntime objective) =>
|
private static bool IsCombatObjective(FactionOperationalObjectiveRuntime objective) =>
|
||||||
objective.BehaviorKind is "attack-target" or "protect-position" or "protect-ship" or "protect-station" or "patrol" or "police";
|
objective.BehaviorKind is AttackTarget or ProtectPosition or ProtectShip or ProtectStation or Patrol or Police;
|
||||||
|
|
||||||
private static float EstimateFriendlyAssetValue(SimulationWorld world, string factionId, string systemId)
|
private static float EstimateFriendlyAssetValue(SimulationWorld world, string factionId, string systemId)
|
||||||
{
|
{
|
||||||
@@ -3357,7 +3383,7 @@ internal sealed class CommanderPlanningService
|
|||||||
{
|
{
|
||||||
"defense-front" => $"Defend {theater.SystemId} from hostile pressure.",
|
"defense-front" => $"Defend {theater.SystemId} from hostile pressure.",
|
||||||
"offense-front" => $"Project force into {theater.SystemId}.",
|
"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}.",
|
"economic-front" => $"Stabilize commodity shortages around {theater.AnchorEntityId ?? theater.SystemId}.",
|
||||||
_ => theater.Kind,
|
_ => theater.Kind,
|
||||||
};
|
};
|
||||||
@@ -3399,13 +3425,13 @@ internal sealed class CommanderPlanningService
|
|||||||
private static Vector3 ResolveExpansionAnchor(SimulationWorld world, IndustryExpansionProject project)
|
private static Vector3 ResolveExpansionAnchor(SimulationWorld world, IndustryExpansionProject project)
|
||||||
{
|
{
|
||||||
if (project.SiteId is not null
|
if (project.SiteId is not null
|
||||||
&& world.ConstructionSites.FirstOrDefault(site => site.Id == project.SiteId) is { } site
|
&& world.ConstructionSites.FirstOrDefault(site => site.Id == project.SiteId) is { } site)
|
||||||
&& world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId) is { } siteCelestial)
|
|
||||||
{
|
{
|
||||||
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);
|
?? ResolveSystemAnchor(world, project.SystemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ public sealed record TerritoryClaimSnapshot(
|
|||||||
string? SourceClaimId,
|
string? SourceClaimId,
|
||||||
string FactionId,
|
string FactionId,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
string CelestialId,
|
string AnchorId,
|
||||||
string Status,
|
string Status,
|
||||||
string ClaimKind,
|
string ClaimKind,
|
||||||
float ClaimStrength,
|
float ClaimStrength,
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ public sealed class TerritoryClaimRuntime
|
|||||||
public string? SourceClaimId { get; set; }
|
public string? SourceClaimId { get; set; }
|
||||||
public required string FactionId { get; set; }
|
public required string FactionId { get; set; }
|
||||||
public required string SystemId { 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 Status { get; set; } = "active";
|
||||||
public string ClaimKind { get; set; } = "infrastructure";
|
public string ClaimKind { get; set; } = "infrastructure";
|
||||||
public float ClaimStrength { get; set; }
|
public float ClaimStrength { get; set; }
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
|
||||||
|
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||||
|
|
||||||
namespace SpaceGame.Api.Geopolitics.Simulation;
|
namespace SpaceGame.Api.Geopolitics.Simulation;
|
||||||
|
|
||||||
internal sealed class GeopoliticalSimulationService
|
internal sealed class GeopoliticalSimulationService
|
||||||
@@ -159,7 +161,7 @@ internal sealed class GeopoliticalSimulationService
|
|||||||
SourceClaimId = claim.Id,
|
SourceClaimId = claim.Id,
|
||||||
FactionId = claim.FactionId,
|
FactionId = claim.FactionId,
|
||||||
SystemId = claim.SystemId,
|
SystemId = claim.SystemId,
|
||||||
CelestialId = claim.CelestialId,
|
AnchorId = claim.AnchorId,
|
||||||
Status = claim.State,
|
Status = claim.State,
|
||||||
ClaimKind = "infrastructure",
|
ClaimKind = "infrastructure",
|
||||||
ClaimStrength = claim.State == ClaimStateKinds.Active ? 1f : 0.65f,
|
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 stationStrength = (stationsByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 50f;
|
||||||
var siteStrength = (sitesByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 18f;
|
var siteStrength = (sitesByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 18f;
|
||||||
var shipStrength = shipsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(ship =>
|
var shipStrength = shipsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(ship =>
|
||||||
ship.Definition.Kind switch
|
{
|
||||||
{
|
if (IsMilitaryShip(ship.Definition))
|
||||||
"military" => 9f,
|
{
|
||||||
"construction" => 4f,
|
return 9f;
|
||||||
"transport" => 3f,
|
}
|
||||||
_ when ship.Definition.Kind == "mining" || ship.Definition.Kind == "miner" => 3f,
|
|
||||||
_ => 2f,
|
if (IsConstructionShip(ship.Definition))
|
||||||
}) ?? 0f;
|
{
|
||||||
|
return 4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsTransportShip(ship.Definition) || IsMiningShip(ship.Definition))
|
||||||
|
{
|
||||||
|
return 3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 2f;
|
||||||
|
}) ?? 0f;
|
||||||
var logisticsStrength = MathF.Min(30f, stationStrength * 0.18f) + siteStrength;
|
var logisticsStrength = MathF.Min(30f, stationStrength * 0.18f) + siteStrength;
|
||||||
influences.Add(new TerritoryInfluenceRuntime
|
influences.Add(new TerritoryInfluenceRuntime
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
global using SpaceGame.Api.Auth.Contracts;
|
||||||
|
global using SpaceGame.Api.Auth.Runtime;
|
||||||
|
global using SpaceGame.Api.Auth.Simulation;
|
||||||
global using SpaceGame.Api.Definitions;
|
global using SpaceGame.Api.Definitions;
|
||||||
global using SpaceGame.Api.Economy.Contracts;
|
global using SpaceGame.Api.Economy.Contracts;
|
||||||
global using SpaceGame.Api.Economy.Runtime;
|
global using SpaceGame.Api.Economy.Runtime;
|
||||||
@@ -15,7 +18,7 @@ global using SpaceGame.Api.Shared.Contracts;
|
|||||||
global using SpaceGame.Api.Shared.Runtime;
|
global using SpaceGame.Api.Shared.Runtime;
|
||||||
global using SpaceGame.Api.Ships.Contracts;
|
global using SpaceGame.Api.Ships.Contracts;
|
||||||
global using SpaceGame.Api.Ships.Runtime;
|
global using SpaceGame.Api.Ships.Runtime;
|
||||||
global using SpaceGame.Api.Ships.Simulation;
|
global using SpaceGame.Api.Ships.AI;
|
||||||
global using SpaceGame.Api.Simulation.Core;
|
global using SpaceGame.Api.Simulation.Core;
|
||||||
global using SpaceGame.Api.Stations.Contracts;
|
global using SpaceGame.Api.Stations.Contracts;
|
||||||
global using SpaceGame.Api.Stations.Runtime;
|
global using SpaceGame.Api.Stations.Runtime;
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ internal static class FactionIndustryPlanner
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetCelestial = SelectFoundationCelestial(world, factionId, bottleneckCommodity);
|
var targetAnchor = SelectFoundationAnchor(world, factionId, bottleneckCommodity);
|
||||||
if (targetCelestial is null)
|
if (targetAnchor is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId);
|
var supportStation = SelectSupportStation(world, factionId, moduleId, targetAnchor.SystemId);
|
||||||
if (supportStation is null)
|
if (supportStation is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -36,8 +36,8 @@ internal static class FactionIndustryPlanner
|
|||||||
return new IndustryExpansionProject(
|
return new IndustryExpansionProject(
|
||||||
bottleneckCommodity,
|
bottleneckCommodity,
|
||||||
moduleId,
|
moduleId,
|
||||||
targetCelestial.SystemId,
|
targetAnchor.SystemId,
|
||||||
targetCelestial.Id,
|
targetAnchor.Id,
|
||||||
supportStation.Id);
|
supportStation.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,13 +93,13 @@ internal static class FactionIndustryPlanner
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetCelestial = SelectLogisticsFoundationCelestial(world, factionId);
|
var targetAnchor = SelectLogisticsFoundationAnchor(world, factionId);
|
||||||
if (targetCelestial is null)
|
if (targetAnchor is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetCelestial.SystemId);
|
var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetAnchor.SystemId);
|
||||||
if (supportStation is null)
|
if (supportStation is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -108,8 +108,8 @@ internal static class FactionIndustryPlanner
|
|||||||
return new IndustryExpansionProject(
|
return new IndustryExpansionProject(
|
||||||
"shipyard",
|
"shipyard",
|
||||||
shipyardModuleId,
|
shipyardModuleId,
|
||||||
targetCelestial.SystemId,
|
targetAnchor.SystemId,
|
||||||
targetCelestial.Id,
|
targetAnchor.Id,
|
||||||
supportStation.Id);
|
supportStation.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,13 +129,13 @@ internal static class FactionIndustryPlanner
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var bootstrapCelestial = SelectFoundationCelestial(world, factionId, bootstrapCommodity);
|
var bootstrapAnchor = SelectFoundationAnchor(world, factionId, bootstrapCommodity);
|
||||||
if (bootstrapCelestial is null)
|
if (bootstrapAnchor is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapCelestial.SystemId);
|
var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapAnchor.SystemId);
|
||||||
if (bootstrapSupportStation is null)
|
if (bootstrapSupportStation is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -144,8 +144,8 @@ internal static class FactionIndustryPlanner
|
|||||||
return new IndustryExpansionProject(
|
return new IndustryExpansionProject(
|
||||||
bootstrapCommodity,
|
bootstrapCommodity,
|
||||||
bootstrapModuleId,
|
bootstrapModuleId,
|
||||||
bootstrapCelestial.SystemId,
|
bootstrapAnchor.SystemId,
|
||||||
bootstrapCelestial.Id,
|
bootstrapAnchor.Id,
|
||||||
bootstrapSupportStation.Id);
|
bootstrapSupportStation.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,13 +161,13 @@ internal static class FactionIndustryPlanner
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetCelestial = SelectFoundationCelestial(world, factionId, commodityId);
|
var targetAnchor = SelectFoundationAnchor(world, factionId, commodityId);
|
||||||
if (targetCelestial is null)
|
if (targetAnchor is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId);
|
var supportStation = SelectSupportStation(world, factionId, moduleId, targetAnchor.SystemId);
|
||||||
if (supportStation is null)
|
if (supportStation is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -176,8 +176,8 @@ internal static class FactionIndustryPlanner
|
|||||||
return new IndustryExpansionProject(
|
return new IndustryExpansionProject(
|
||||||
commodityId,
|
commodityId,
|
||||||
moduleId,
|
moduleId,
|
||||||
targetCelestial.SystemId,
|
targetAnchor.SystemId,
|
||||||
targetCelestial.Id,
|
targetAnchor.Id,
|
||||||
supportStation.Id);
|
supportStation.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ internal static class FactionIndustryPlanner
|
|||||||
site.TargetDefinitionId,
|
site.TargetDefinitionId,
|
||||||
site.BlueprintId,
|
site.BlueprintId,
|
||||||
site.SystemId,
|
site.SystemId,
|
||||||
site.CelestialId,
|
site.AnchorId,
|
||||||
supportStationId,
|
supportStationId,
|
||||||
site.Id);
|
site.Id);
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ internal static class FactionIndustryPlanner
|
|||||||
}
|
}
|
||||||
|
|
||||||
var nowUtc = DateTimeOffset.UtcNow;
|
var nowUtc = DateTimeOffset.UtcNow;
|
||||||
var claimId = $"claim-{factionId}-{project.CelestialId}";
|
var claimId = $"claim-{factionId}-{project.AnchorId}";
|
||||||
if (world.Claims.All(candidate => candidate.Id != claimId))
|
if (world.Claims.All(candidate => candidate.Id != claimId))
|
||||||
{
|
{
|
||||||
world.Claims.Add(new ClaimRuntime
|
world.Claims.Add(new ClaimRuntime
|
||||||
@@ -233,7 +233,7 @@ internal static class FactionIndustryPlanner
|
|||||||
Id = claimId,
|
Id = claimId,
|
||||||
FactionId = factionId,
|
FactionId = factionId,
|
||||||
SystemId = project.SystemId,
|
SystemId = project.SystemId,
|
||||||
CelestialId = project.CelestialId,
|
AnchorId = project.AnchorId,
|
||||||
PlacedAtUtc = nowUtc,
|
PlacedAtUtc = nowUtc,
|
||||||
ActivatesAtUtc = nowUtc.AddSeconds(8),
|
ActivatesAtUtc = nowUtc.AddSeconds(8),
|
||||||
State = ClaimStateKinds.Activating,
|
State = ClaimStateKinds.Activating,
|
||||||
@@ -246,7 +246,7 @@ internal static class FactionIndustryPlanner
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var siteId = $"site-{factionId}-{project.CelestialId}";
|
var siteId = $"site-{factionId}-{project.AnchorId}";
|
||||||
if (world.ConstructionSites.Any(candidate => candidate.Id == siteId))
|
if (world.ConstructionSites.Any(candidate => candidate.Id == siteId))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -257,7 +257,7 @@ internal static class FactionIndustryPlanner
|
|||||||
Id = siteId,
|
Id = siteId,
|
||||||
FactionId = factionId,
|
FactionId = factionId,
|
||||||
SystemId = project.SystemId,
|
SystemId = project.SystemId,
|
||||||
CelestialId = project.CelestialId,
|
AnchorId = project.AnchorId,
|
||||||
TargetKind = "station-foundation",
|
TargetKind = "station-foundation",
|
||||||
TargetDefinitionId = project.CommodityId,
|
TargetDefinitionId = project.CommodityId,
|
||||||
BlueprintId = project.ModuleId,
|
BlueprintId = project.ModuleId,
|
||||||
@@ -450,51 +450,51 @@ internal static class FactionIndustryPlanner
|
|||||||
private static float GetTargetLevelSeconds(string itemId) =>
|
private static float GetTargetLevelSeconds(string itemId) =>
|
||||||
string.Equals(itemId, "water", StringComparison.Ordinal) ? WaterTargetLevelSeconds : CommodityTargetLevelSeconds;
|
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);
|
var resourceItems = ResolveRootResourceItems(world, commodityId);
|
||||||
return world.Celestials
|
return world.Anchors
|
||||||
.Where(celestial =>
|
.Where(anchor =>
|
||||||
celestial.Kind == SpatialNodeKind.LagrangePoint
|
anchor.Kind == SpatialNodeKind.LagrangePoint
|
||||||
&& celestial.OccupyingStructureId is null
|
&& anchor.OccupyingStructureId is null
|
||||||
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed)
|
&& world.Claims.All(claim => claim.AnchorId != anchor.Id || claim.State == ClaimStateKinds.Destroyed)
|
||||||
&& IsExpansionSystemEligible(world, factionId, celestial.SystemId))
|
&& IsExpansionSystemEligible(world, factionId, anchor.SystemId))
|
||||||
.OrderByDescending(celestial => ScoreCelestial(world, factionId, celestial, resourceItems))
|
.OrderByDescending(anchor => ScoreAnchor(world, factionId, anchor, resourceItems))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CelestialRuntime? SelectLogisticsFoundationCelestial(SimulationWorld world, string factionId)
|
private static AnchorRuntime? SelectLogisticsFoundationAnchor(SimulationWorld world, string factionId)
|
||||||
{
|
{
|
||||||
return world.Celestials
|
return world.Anchors
|
||||||
.Where(celestial =>
|
.Where(anchor =>
|
||||||
celestial.Kind == SpatialNodeKind.LagrangePoint
|
anchor.Kind == SpatialNodeKind.LagrangePoint
|
||||||
&& celestial.OccupyingStructureId is null
|
&& anchor.OccupyingStructureId is null
|
||||||
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed)
|
&& world.Claims.All(claim => claim.AnchorId != anchor.Id || claim.State == ClaimStateKinds.Destroyed)
|
||||||
&& IsExpansionSystemEligible(world, factionId, celestial.SystemId))
|
&& IsExpansionSystemEligible(world, factionId, anchor.SystemId))
|
||||||
.OrderByDescending(celestial => world.Stations.Count(station =>
|
.OrderByDescending(anchor => world.Stations.Count(station =>
|
||||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||||
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)))
|
&& string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal)))
|
||||||
.ThenByDescending(celestial => world.Stations
|
.ThenByDescending(anchor => world.Stations
|
||||||
.Where(station =>
|
.Where(station =>
|
||||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
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()))
|
.Sum(station => station.Inventory.Values.Sum()))
|
||||||
.FirstOrDefault();
|
.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
|
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);
|
.Sum(node => node.OreRemaining);
|
||||||
var factionPresence = world.Stations.Count(station =>
|
var factionPresence = world.Stations.Count(station =>
|
||||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||||
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal));
|
&& string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal));
|
||||||
var controlState = GeopoliticalSimulationService.GetSystemControlState(world, celestial.SystemId);
|
var controlState = GeopoliticalSimulationService.GetSystemControlState(world, anchor.SystemId);
|
||||||
var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, celestial.SystemId);
|
var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, anchor.SystemId);
|
||||||
var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == celestial.SystemId);
|
var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == anchor.SystemId);
|
||||||
var pressure = world.Geopolitics?.Territory.Pressures
|
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)
|
.OrderByDescending(entry => entry.HostileInfluence)
|
||||||
.ThenBy(entry => entry.Id, StringComparer.Ordinal)
|
.ThenBy(entry => entry.Id, StringComparer.Ordinal)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
@@ -515,7 +515,7 @@ internal static class FactionIndustryPlanner
|
|||||||
};
|
};
|
||||||
var securityPenalty = ((pressure?.HostileInfluence ?? 0f) * 14f)
|
var securityPenalty = ((pressure?.HostileInfluence ?? 0f) * 14f)
|
||||||
+ ((strategicProfile?.TerritorialPressure ?? 0f) * 9f)
|
+ ((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
|
return resourceScore
|
||||||
+ (factionPresence * 5_000f)
|
+ (factionPresence * 5_000f)
|
||||||
+ controlBias
|
+ controlBias
|
||||||
@@ -585,6 +585,6 @@ internal sealed record IndustryExpansionProject(
|
|||||||
string CommodityId,
|
string CommodityId,
|
||||||
string ModuleId,
|
string ModuleId,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
string CelestialId,
|
string AnchorId,
|
||||||
string SupportStationId,
|
string SupportStationId,
|
||||||
string? SiteId = null);
|
string? SiteId = null);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal static class ProductionGraphBuilder
|
|||||||
ItemId = item.Id,
|
ItemId = item.Id,
|
||||||
Name = item.Name,
|
Name = item.Name,
|
||||||
Group = item.Group,
|
Group = item.Group,
|
||||||
CargoKind = item.CargoKind,
|
CargoKind = item.CargoKind?.ToDataValue() ?? string.Empty,
|
||||||
},
|
},
|
||||||
StringComparer.Ordinal);
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ internal static class ProductionGraphBuilder
|
|||||||
outputsByModuleId[module.Id] = outputs;
|
outputsByModuleId[module.Id] = outputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var product in module.Products)
|
foreach (var product in module.ProductItemIds)
|
||||||
{
|
{
|
||||||
outputs.Add(product);
|
outputs.Add(product);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Post("/api/player-faction/organizations");
|
Post("/api/player-faction/organizations");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerOrganizationCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerOrganizationCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ public sealed class DeletePlayerDirectiveHandler(WorldService worldService) : En
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Delete("/api/player-faction/directives/{directiveId}");
|
Delete("/api/player-faction/directives/{directiveId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(DeletePlayerDirectiveRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(DeletePlayerDirectiveRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ public sealed class DeletePlayerOrganizationHandler(WorldService worldService) :
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Delete("/api/player-faction/organizations/{organizationId}");
|
Delete("/api/player-faction/organizations/{organizationId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(DeletePlayerOrganizationRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(DeletePlayerOrganizationRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ public sealed class GetPlayerFactionHandler(WorldService worldService) : Endpoin
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Get("/api/player-faction");
|
Get("/api/player-faction");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(CancellationToken cancellationToken)
|
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
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()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Put("/api/player-faction/organizations/{organizationId}/membership");
|
Put("/api/player-faction/organizations/{organizationId}/membership");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerOrganizationMembershipCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerOrganizationMembershipCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ public sealed class UpdatePlayerStrategicIntentHandler(WorldService worldService
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Put("/api/player-faction/strategic-intent");
|
Put("/api/player-faction/strategic-intent");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerStrategicIntentCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerStrategicIntentCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ public sealed class UpsertPlayerAssignmentHandler(WorldService worldService) : E
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Put("/api/player-faction/assets/{assetId}/assignment");
|
Put("/api/player-faction/assets/{assetId}/assignment");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerAssetAssignmentCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerAssetAssignmentCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerAutomationPolicyHandler(WorldService worldServic
|
|||||||
{
|
{
|
||||||
Post("/api/player-faction/automation-policies");
|
Post("/api/player-faction/automation-policies");
|
||||||
Put("/api/player-faction/automation-policies/{automationPolicyId}");
|
Put("/api/player-faction/automation-policies/{automationPolicyId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerAutomationPolicyCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerAutomationPolicyCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerDirectiveHandler(WorldService worldService) : En
|
|||||||
{
|
{
|
||||||
Post("/api/player-faction/directives");
|
Post("/api/player-faction/directives");
|
||||||
Put("/api/player-faction/directives/{directiveId}");
|
Put("/api/player-faction/directives/{directiveId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerDirectiveCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerDirectiveCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerPolicyHandler(WorldService worldService) : Endpo
|
|||||||
{
|
{
|
||||||
Post("/api/player-faction/policies");
|
Post("/api/player-faction/policies");
|
||||||
Put("/api/player-faction/policies/{policyId}");
|
Put("/api/player-faction/policies/{policyId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerPolicyCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerPolicyCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerProductionProgramHandler(WorldService worldServi
|
|||||||
{
|
{
|
||||||
Post("/api/player-faction/production-programs");
|
Post("/api/player-faction/production-programs");
|
||||||
Put("/api/player-faction/production-programs/{productionProgramId}");
|
Put("/api/player-faction/production-programs/{productionProgramId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerProductionProgramCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerProductionProgramCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ public sealed class UpsertPlayerReinforcementPolicyHandler(WorldService worldSer
|
|||||||
{
|
{
|
||||||
Post("/api/player-faction/reinforcement-policies");
|
Post("/api/player-faction/reinforcement-policies");
|
||||||
Put("/api/player-faction/reinforcement-policies/{reinforcementPolicyId}");
|
Put("/api/player-faction/reinforcement-policies/{reinforcementPolicyId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PlayerReinforcementPolicyCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(PlayerReinforcementPolicyCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ public sealed record PlayerDirectiveSnapshot(
|
|||||||
bool UseOrders,
|
bool UseOrders,
|
||||||
string? StagingOrderKind,
|
string? StagingOrderKind,
|
||||||
string? ItemId,
|
string? ItemId,
|
||||||
string? PreferredNodeId,
|
string? PreferredAnchorId,
|
||||||
string? PreferredConstructionSiteId,
|
string? PreferredConstructionSiteId,
|
||||||
string? PreferredModuleId,
|
string? PreferredModuleId,
|
||||||
int Priority,
|
int Priority,
|
||||||
@@ -249,7 +249,10 @@ public sealed record PlayerAlertSnapshot(
|
|||||||
public sealed record PlayerFactionSnapshot(
|
public sealed record PlayerFactionSnapshot(
|
||||||
string Id,
|
string Id,
|
||||||
string Label,
|
string Label,
|
||||||
|
string? PersonaName,
|
||||||
|
string? RaceId,
|
||||||
string SovereignFactionId,
|
string SovereignFactionId,
|
||||||
|
bool RequiresOnboarding,
|
||||||
string Status,
|
string Status,
|
||||||
DateTimeOffset CreatedAtUtc,
|
DateTimeOffset CreatedAtUtc,
|
||||||
DateTimeOffset UpdatedAtUtc,
|
DateTimeOffset UpdatedAtUtc,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
namespace SpaceGame.Api.PlayerFaction.Contracts;
|
namespace SpaceGame.Api.PlayerFaction.Contracts;
|
||||||
|
|
||||||
|
public sealed record CompletePlayerOnboardingRequest(
|
||||||
|
string Name,
|
||||||
|
string RaceId);
|
||||||
|
|
||||||
public sealed record PlayerOrganizationCommandRequest(
|
public sealed record PlayerOrganizationCommandRequest(
|
||||||
string Kind,
|
string Kind,
|
||||||
string Label,
|
string Label,
|
||||||
@@ -41,7 +45,7 @@ public sealed record PlayerDirectiveCommandRequest(
|
|||||||
string? SourceStationId,
|
string? SourceStationId,
|
||||||
string? DestinationStationId,
|
string? DestinationStationId,
|
||||||
string? ItemId,
|
string? ItemId,
|
||||||
string? PreferredNodeId,
|
string? PreferredAnchorId,
|
||||||
string? PreferredConstructionSiteId,
|
string? PreferredConstructionSiteId,
|
||||||
string? PreferredModuleId,
|
string? PreferredModuleId,
|
||||||
int Priority,
|
int Priority,
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||||
|
|
||||||
namespace SpaceGame.Api.PlayerFaction.Runtime;
|
namespace SpaceGame.Api.PlayerFaction.Runtime;
|
||||||
|
|
||||||
public sealed class PlayerFactionRuntime
|
public sealed class PlayerFactionRuntime
|
||||||
{
|
{
|
||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
public required string Label { get; set; }
|
public required string Label { get; set; }
|
||||||
|
public string? PersonaName { get; set; }
|
||||||
|
public string? RaceId { get; set; }
|
||||||
public required string SovereignFactionId { get; set; }
|
public required string SovereignFactionId { get; set; }
|
||||||
|
public bool RequiresOnboarding { get; set; } = true;
|
||||||
public string Status { get; set; } = "active";
|
public string Status { get; set; } = "active";
|
||||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset UpdatedAtUtc { 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 ScopeKind { get; set; } = "player-faction";
|
||||||
public string? ScopeId { get; set; }
|
public string? ScopeId { get; set; }
|
||||||
public bool Enabled { get; set; } = true;
|
public bool Enabled { get; set; } = true;
|
||||||
public string BehaviorKind { get; set; } = "idle";
|
public string BehaviorKind { get; set; } = Idle;
|
||||||
public bool UseOrders { get; set; }
|
public bool UseOrders { get; set; }
|
||||||
public string? StagingOrderKind { get; set; }
|
public string? StagingOrderKind { get; set; }
|
||||||
public int MaxSystemRange { get; set; }
|
public int MaxSystemRange { get; set; }
|
||||||
@@ -242,11 +247,11 @@ public sealed class PlayerDirectiveRuntime
|
|||||||
public string? HomeStationId { get; set; }
|
public string? HomeStationId { get; set; }
|
||||||
public string? SourceStationId { get; set; }
|
public string? SourceStationId { get; set; }
|
||||||
public string? DestinationStationId { get; set; }
|
public string? DestinationStationId { get; set; }
|
||||||
public string BehaviorKind { get; set; } = "idle";
|
public string BehaviorKind { get; set; } = Idle;
|
||||||
public bool UseOrders { get; set; }
|
public bool UseOrders { get; set; }
|
||||||
public string? StagingOrderKind { get; set; }
|
public string? StagingOrderKind { get; set; }
|
||||||
public string? ItemId { get; set; }
|
public string? ItemId { get; set; }
|
||||||
public string? PreferredNodeId { get; set; }
|
public string? PreferredAnchorId { get; set; }
|
||||||
public string? PreferredConstructionSiteId { get; set; }
|
public string? PreferredConstructionSiteId { get; set; }
|
||||||
public string? PreferredModuleId { get; set; }
|
public string? PreferredModuleId { get; set; }
|
||||||
public int Priority { get; set; } = 50;
|
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;
|
namespace SpaceGame.Api.PlayerFaction.Simulation;
|
||||||
|
|
||||||
internal sealed class PlayerFactionService
|
internal sealed class PlayerFactionService
|
||||||
@@ -6,53 +9,111 @@ internal sealed class PlayerFactionService
|
|||||||
private const int MaxAlerts = 32;
|
private const int MaxAlerts = 32;
|
||||||
private const string PlayerFactionDomainId = "player-faction";
|
private const string PlayerFactionDomainId = "player-faction";
|
||||||
|
|
||||||
internal static bool IsPlayerFaction(SimulationWorld world, string factionId) =>
|
internal static bool IsPlayerFaction(IPlayerStateStore playerStateStore, string factionId) =>
|
||||||
world.PlayerFaction is not null && string.Equals(world.PlayerFaction.SovereignFactionId, factionId, StringComparison.Ordinal);
|
playerStateStore.GetPlayerFactions().Any(player =>
|
||||||
|
string.Equals(player.SovereignFactionId, factionId, StringComparison.Ordinal));
|
||||||
|
|
||||||
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world)
|
internal PlayerFactionRuntime? TryGetDomain(IPlayerStateStore playerStateStore, string playerId)
|
||||||
{
|
{
|
||||||
if (world.PlayerFaction is not null)
|
return playerStateStore.TryGetPlayerFaction(playerId, out var player) ? player : null;
|
||||||
{
|
}
|
||||||
return world.PlayerFaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sovereignFaction = world.Factions.FirstOrDefault(faction => string.Equals(faction.Id, LoaderSupport.DefaultFactionId, StringComparison.Ordinal))
|
internal PlayerFactionRuntime EnsureDomain(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId)
|
||||||
?? world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).First();
|
{
|
||||||
|
var player = playerStateStore.GetOrAddPlayerFaction(playerId, () => new PlayerFactionRuntime
|
||||||
world.PlayerFaction = new PlayerFactionRuntime
|
|
||||||
{
|
{
|
||||||
Id = PlayerFactionDomainId,
|
Id = PlayerFactionDomainId,
|
||||||
Label = $"{sovereignFaction.Label} Command",
|
Label = "Pending Pilot",
|
||||||
SovereignFactionId = sovereignFaction.Id,
|
SovereignFactionId = string.Empty,
|
||||||
|
RequiresOnboarding = true,
|
||||||
CreatedAtUtc = world.GeneratedAtUtc,
|
CreatedAtUtc = world.GeneratedAtUtc,
|
||||||
UpdatedAtUtc = 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);
|
EnsureBaseStructures(world, player);
|
||||||
SyncRegistry(world, player);
|
SyncRegistry(world, player);
|
||||||
PrunePlayerState(world, player);
|
return player;
|
||||||
RefreshGeopoliticalOrganizationContext(world, player);
|
|
||||||
ReconcileOrganizationAssignments(world, player);
|
|
||||||
ReconcileDirectiveScopes(player);
|
|
||||||
RefreshProductionPrograms(world, player);
|
|
||||||
ApplyStrategicIntegration(world, player);
|
|
||||||
ApplyPolicies(world, player);
|
|
||||||
ApplyAssignmentsAndDirectives(world, player, events);
|
|
||||||
RefreshAlerts(world, player);
|
|
||||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, PlayerOrganizationCommandRequest request)
|
internal PlayerFactionRuntime 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 id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player));
|
||||||
var nowUtc = DateTimeOffset.UtcNow;
|
var nowUtc = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
@@ -167,9 +228,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, string organizationId)
|
internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
RemoveOrganization(player, organizationId);
|
RemoveOrganization(player, organizationId);
|
||||||
player.Assignments.RemoveAll(assignment =>
|
player.Assignments.RemoveAll(assignment =>
|
||||||
assignment.FleetId == organizationId ||
|
assignment.FleetId == organizationId ||
|
||||||
@@ -185,9 +246,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, string organizationId, PlayerOrganizationMembershipCommandRequest request)
|
internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string organizationId, PlayerOrganizationMembershipCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var kind = ResolveOrganizationKind(player, organizationId);
|
var kind = ResolveOrganizationKind(player, organizationId);
|
||||||
switch (kind)
|
switch (kind)
|
||||||
{
|
{
|
||||||
@@ -236,9 +297,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, string? directiveId, PlayerDirectiveCommandRequest request)
|
internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? directiveId, PlayerDirectiveCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var directive = directiveId is null
|
var directive = directiveId is null
|
||||||
? null
|
? null
|
||||||
: player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal));
|
: player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal));
|
||||||
@@ -268,7 +329,7 @@ internal sealed class PlayerFactionService
|
|||||||
directive.SourceStationId = request.SourceStationId;
|
directive.SourceStationId = request.SourceStationId;
|
||||||
directive.DestinationStationId = request.DestinationStationId;
|
directive.DestinationStationId = request.DestinationStationId;
|
||||||
directive.ItemId = request.ItemId;
|
directive.ItemId = request.ItemId;
|
||||||
directive.PreferredNodeId = request.PreferredNodeId;
|
directive.PreferredAnchorId = request.PreferredAnchorId;
|
||||||
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
|
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
|
||||||
directive.PreferredModuleId = request.PreferredModuleId;
|
directive.PreferredModuleId = request.PreferredModuleId;
|
||||||
directive.Priority = request.Priority;
|
directive.Priority = request.Priority;
|
||||||
@@ -294,7 +355,7 @@ internal sealed class PlayerFactionService
|
|||||||
SourceStationId = template.SourceStationId,
|
SourceStationId = template.SourceStationId,
|
||||||
DestinationStationId = template.DestinationStationId,
|
DestinationStationId = template.DestinationStationId,
|
||||||
ItemId = template.ItemId,
|
ItemId = template.ItemId,
|
||||||
NodeId = template.NodeId,
|
AnchorId = template.AnchorId,
|
||||||
ConstructionSiteId = template.ConstructionSiteId,
|
ConstructionSiteId = template.ConstructionSiteId,
|
||||||
ModuleId = template.ModuleId,
|
ModuleId = template.ModuleId,
|
||||||
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
|
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
|
||||||
@@ -313,9 +374,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, string directiveId)
|
internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string directiveId)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
player.Directives.RemoveAll(directive => directive.Id == directiveId);
|
player.Directives.RemoveAll(directive => directive.Id == directiveId);
|
||||||
foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId))
|
foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId))
|
||||||
{
|
{
|
||||||
@@ -327,9 +388,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, string? policyId, PlayerPolicyCommandRequest request)
|
internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? policyId, PlayerPolicyCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var policy = policyId is null
|
var policy = policyId is null
|
||||||
? null
|
? null
|
||||||
: player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal));
|
: player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal));
|
||||||
@@ -398,9 +459,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
|
internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var policy = automationPolicyId is null
|
var policy = automationPolicyId is null
|
||||||
? null
|
? null
|
||||||
: player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal));
|
: player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal));
|
||||||
@@ -440,7 +501,7 @@ internal sealed class PlayerFactionService
|
|||||||
SourceStationId = template.SourceStationId,
|
SourceStationId = template.SourceStationId,
|
||||||
DestinationStationId = template.DestinationStationId,
|
DestinationStationId = template.DestinationStationId,
|
||||||
ItemId = template.ItemId,
|
ItemId = template.ItemId,
|
||||||
NodeId = template.NodeId,
|
AnchorId = template.AnchorId,
|
||||||
ConstructionSiteId = template.ConstructionSiteId,
|
ConstructionSiteId = template.ConstructionSiteId,
|
||||||
ModuleId = template.ModuleId,
|
ModuleId = template.ModuleId,
|
||||||
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
|
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
|
||||||
@@ -456,9 +517,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
|
internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var policy = reinforcementPolicyId is null
|
var policy = reinforcementPolicyId is null
|
||||||
? null
|
? null
|
||||||
: player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal));
|
: player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal));
|
||||||
@@ -490,9 +551,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, string? productionProgramId, PlayerProductionProgramCommandRequest request)
|
internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string? productionProgramId, PlayerProductionProgramCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var program = productionProgramId is null
|
var program = productionProgramId is null
|
||||||
? null
|
? null
|
||||||
: player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal));
|
: player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal));
|
||||||
@@ -522,9 +583,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, string assetId, PlayerAssetAssignmentCommandRequest request)
|
internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string assetId, PlayerAssetAssignmentCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
var assignment = player.Assignments.FirstOrDefault(candidate =>
|
var assignment = player.Assignments.FirstOrDefault(candidate =>
|
||||||
string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) &&
|
string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) &&
|
||||||
string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal));
|
string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal));
|
||||||
@@ -581,9 +642,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, PlayerStrategicIntentCommandRequest request)
|
internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, PlayerStrategicIntentCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
player.StrategicIntent.StrategicPosture = request.StrategicPosture;
|
player.StrategicIntent.StrategicPosture = request.StrategicPosture;
|
||||||
player.StrategicIntent.EconomicPosture = request.EconomicPosture;
|
player.StrategicIntent.EconomicPosture = request.EconomicPosture;
|
||||||
player.StrategicIntent.MilitaryPosture = request.MilitaryPosture;
|
player.StrategicIntent.MilitaryPosture = request.MilitaryPosture;
|
||||||
@@ -597,9 +658,9 @@ internal sealed class PlayerFactionService
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, string shipId, ShipOrderCommandRequest request)
|
internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipOrderCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -611,15 +672,12 @@ internal sealed class PlayerFactionService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ship.OrderQueue.Count >= 8)
|
ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Order queue is full.");
|
|
||||||
}
|
|
||||||
|
|
||||||
ship.OrderQueue.Add(new ShipOrderRuntime
|
|
||||||
{
|
{
|
||||||
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
|
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
|
||||||
Kind = request.Kind,
|
Kind = request.Kind,
|
||||||
|
SourceKind = ShipOrderSourceKind.Player,
|
||||||
|
SourceId = playerId,
|
||||||
Priority = request.Priority,
|
Priority = request.Priority,
|
||||||
InterruptCurrentPlan = request.InterruptCurrentPlan,
|
InterruptCurrentPlan = request.InterruptCurrentPlan,
|
||||||
Label = request.Label,
|
Label = request.Label,
|
||||||
@@ -629,7 +687,7 @@ internal sealed class PlayerFactionService
|
|||||||
SourceStationId = request.SourceStationId,
|
SourceStationId = request.SourceStationId,
|
||||||
DestinationStationId = request.DestinationStationId,
|
DestinationStationId = request.DestinationStationId,
|
||||||
ItemId = request.ItemId,
|
ItemId = request.ItemId,
|
||||||
NodeId = request.NodeId,
|
AnchorId = request.AnchorId,
|
||||||
ConstructionSiteId = request.ConstructionSiteId,
|
ConstructionSiteId = request.ConstructionSiteId,
|
||||||
ModuleId = request.ModuleId,
|
ModuleId = request.ModuleId,
|
||||||
WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f),
|
WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f),
|
||||||
@@ -638,15 +696,10 @@ internal sealed class PlayerFactionService
|
|||||||
KnownStationsOnly = request.KnownStationsOnly ?? false,
|
KnownStationsOnly = request.KnownStationsOnly ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Label}.", "ship", shipId);
|
AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Name}.", "ship", shipId);
|
||||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||||
ship.ControlSourceKind = "player-order";
|
ship.ControlSourceKind = "player-order";
|
||||||
ship.ControlSourceId = ship.OrderQueue
|
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
|
||||||
.OrderByDescending(order => order.Priority)
|
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
|
||||||
.Select(order => order.Id)
|
|
||||||
.FirstOrDefault();
|
|
||||||
ship.ControlReason = request.Label ?? request.Kind;
|
ship.ControlReason = request.Label ?? request.Kind;
|
||||||
ship.NeedsReplan = true;
|
ship.NeedsReplan = true;
|
||||||
ship.LastReplanReason = "player-order-enqueued";
|
ship.LastReplanReason = "player-order-enqueued";
|
||||||
@@ -654,9 +707,9 @@ internal sealed class PlayerFactionService
|
|||||||
return ship;
|
return ship;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, string shipId, string orderId)
|
internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId)
|
||||||
{
|
{
|
||||||
var player = EnsureDomain(world);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -668,28 +721,18 @@ internal sealed class PlayerFactionService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId);
|
var removed = ship.OrderQueue.RemoveById(orderId);
|
||||||
if (removed > 0)
|
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;
|
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-order"
|
||||||
: "player-manual";
|
: "player-manual";
|
||||||
ship.ControlSourceId = ship.OrderQueue
|
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
|
||||||
.OrderByDescending(order => order.Priority)
|
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
|
||||||
.Select(order => order.Id)
|
|
||||||
.FirstOrDefault();
|
|
||||||
ship.ControlReason = ship.OrderQueue
|
|
||||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
|
||||||
.OrderByDescending(order => order.Priority)
|
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
|
||||||
.Select(order => order.Label ?? order.Kind)
|
|
||||||
.FirstOrDefault()
|
|
||||||
?? "manual-player-control";
|
?? "manual-player-control";
|
||||||
ship.NeedsReplan = true;
|
ship.NeedsReplan = true;
|
||||||
ship.LastReplanReason = "player-order-removed";
|
ship.LastReplanReason = "player-order-removed";
|
||||||
@@ -697,9 +740,96 @@ internal sealed class PlayerFactionService
|
|||||||
return ship;
|
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))
|
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -718,7 +848,7 @@ internal sealed class PlayerFactionService
|
|||||||
directive = new PlayerDirectiveRuntime
|
directive = new PlayerDirectiveRuntime
|
||||||
{
|
{
|
||||||
Id = directiveId,
|
Id = directiveId,
|
||||||
Label = $"Direct control {ship.Definition.Label}",
|
Label = $"Direct control {ship.Definition.Name}",
|
||||||
ScopeKind = "ship",
|
ScopeKind = "ship",
|
||||||
ScopeId = shipId,
|
ScopeId = shipId,
|
||||||
Kind = "direct-control",
|
Kind = "direct-control",
|
||||||
@@ -727,7 +857,7 @@ internal sealed class PlayerFactionService
|
|||||||
player.Directives.Add(directive);
|
player.Directives.Add(directive);
|
||||||
}
|
}
|
||||||
|
|
||||||
directive.Label = $"Direct control {ship.Definition.Label}";
|
directive.Label = $"Direct control {ship.Definition.Name}";
|
||||||
directive.Kind = "direct-control";
|
directive.Kind = "direct-control";
|
||||||
directive.ScopeKind = "ship";
|
directive.ScopeKind = "ship";
|
||||||
directive.ScopeId = shipId;
|
directive.ScopeId = shipId;
|
||||||
@@ -741,8 +871,8 @@ internal sealed class PlayerFactionService
|
|||||||
directive.HomeStationId = request.HomeStationId;
|
directive.HomeStationId = request.HomeStationId;
|
||||||
directive.SourceStationId = request.HomeStationId;
|
directive.SourceStationId = request.HomeStationId;
|
||||||
directive.DestinationStationId = null;
|
directive.DestinationStationId = null;
|
||||||
directive.ItemId = request.PreferredItemId;
|
directive.ItemId = request.ItemId;
|
||||||
directive.PreferredNodeId = request.PreferredNodeId;
|
directive.PreferredAnchorId = request.PreferredAnchorId;
|
||||||
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
|
directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
|
||||||
directive.PreferredModuleId = request.PreferredModuleId;
|
directive.PreferredModuleId = request.PreferredModuleId;
|
||||||
directive.Priority = 100;
|
directive.Priority = 100;
|
||||||
@@ -768,7 +898,7 @@ internal sealed class PlayerFactionService
|
|||||||
SourceStationId = template.SourceStationId,
|
SourceStationId = template.SourceStationId,
|
||||||
DestinationStationId = template.DestinationStationId,
|
DestinationStationId = template.DestinationStationId,
|
||||||
ItemId = template.ItemId,
|
ItemId = template.ItemId,
|
||||||
NodeId = template.NodeId,
|
AnchorId = template.AnchorId,
|
||||||
ConstructionSiteId = template.ConstructionSiteId,
|
ConstructionSiteId = template.ConstructionSiteId,
|
||||||
ModuleId = template.ModuleId,
|
ModuleId = template.ModuleId,
|
||||||
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
|
WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f),
|
||||||
@@ -788,7 +918,7 @@ internal sealed class PlayerFactionService
|
|||||||
ship.ControlSourceKind = "player-directive";
|
ship.ControlSourceKind = "player-directive";
|
||||||
ship.ControlSourceId = directive.Id;
|
ship.ControlSourceId = directive.Id;
|
||||||
ship.ControlReason = directive.Label;
|
ship.ControlReason = directive.Label;
|
||||||
AddDecision(player, "ship-behavior-configured", $"Configured {request.Kind} for {ship.Definition.Label}.", "ship", shipId);
|
AddDecision(player, "ship-behavior-configured", $"Configured {request.Kind} for {ship.Definition.Name}.", "ship", shipId);
|
||||||
player.UpdatedAtUtc = directive.UpdatedAtUtc;
|
player.UpdatedAtUtc = directive.UpdatedAtUtc;
|
||||||
ship.NeedsReplan = true;
|
ship.NeedsReplan = true;
|
||||||
ship.LastReplanReason = "player-behavior-configured";
|
ship.LastReplanReason = "player-behavior-configured";
|
||||||
@@ -821,7 +951,7 @@ internal sealed class PlayerFactionService
|
|||||||
{
|
{
|
||||||
Id = "player-core-automation",
|
Id = "player-core-automation",
|
||||||
Label = "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)
|
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.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.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));
|
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);
|
var changed = ApplyDirectiveToShip(commander, ship, directive, automation, assignment);
|
||||||
if (changed && directive is not null)
|
if (changed && directive is not null)
|
||||||
{
|
{
|
||||||
events.Add(new SimulationEventRecord("ship", ship.Id, "player-directive", $"{ship.Definition.Label} aligned to player directive {directive.Label}.", DateTimeOffset.UtcNow, "player", "universe", ship.Id));
|
events.Add(new SimulationEventRecord("ship", ship.Id, "player-directive", $"{ship.Definition.Name} aligned to player directive {directive.Label}.", DateTimeOffset.UtcNow, "player", "universe", ship.Id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1211,8 +1359,7 @@ internal sealed class PlayerFactionService
|
|||||||
return player.AutomationPolicies.FirstOrDefault(policy => policy.Id == automationId);
|
return player.AutomationPolicies.FirstOrDefault(policy => policy.Id == automationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId)
|
return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId);
|
||||||
?? player.AutomationPolicies.FirstOrDefault(policy => policy.Id == "player-core-automation");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PlayerFactionPolicyRuntime? ResolvePolicy(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string 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"
|
? "player-directive"
|
||||||
: automation is not null
|
: automation is not null
|
||||||
? "player-automation"
|
? "player-automation"
|
||||||
: ship.OrderQueue.Any(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
: ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||||
? "player-order"
|
? "player-order"
|
||||||
: "player-manual";
|
: "player-manual";
|
||||||
var desiredControlSourceId = directive?.Id
|
var desiredControlSourceId = directive?.Id
|
||||||
?? automation?.Id
|
?? automation?.Id
|
||||||
?? ship.OrderQueue
|
?? ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
|
||||||
.OrderByDescending(order => order.Priority)
|
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
|
||||||
.Select(order => order.Id)
|
|
||||||
.FirstOrDefault();
|
|
||||||
var desiredControlReason = directive?.Label
|
var desiredControlReason = directive?.Label
|
||||||
?? automation?.Label
|
?? automation?.Label
|
||||||
?? ship.OrderQueue
|
?? ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
|
||||||
.Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal))
|
|
||||||
.OrderByDescending(order => order.Priority)
|
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
|
||||||
.Select(order => order.Label ?? order.Kind)
|
|
||||||
.FirstOrDefault()
|
|
||||||
?? (hasBehaviorSource ? "delegated-player-control" : "manual-player-control");
|
?? (hasBehaviorSource ? "delegated-player-control" : "manual-player-control");
|
||||||
|
|
||||||
var assignmentChanged = !AssignmentsEqual(commander.Assignment, desiredAssignment);
|
var assignmentChanged = !AssignmentsEqual(commander.Assignment, desiredAssignment);
|
||||||
@@ -1337,8 +1474,8 @@ internal sealed class PlayerFactionService
|
|||||||
HomeStationId = directive?.HomeStationId ?? ship.DefaultBehavior.HomeStationId,
|
HomeStationId = directive?.HomeStationId ?? ship.DefaultBehavior.HomeStationId,
|
||||||
AreaSystemId = directive?.TargetSystemId ?? directive?.HomeSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId,
|
AreaSystemId = directive?.TargetSystemId ?? directive?.HomeSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId,
|
||||||
TargetEntityId = directive?.TargetEntityId,
|
TargetEntityId = directive?.TargetEntityId,
|
||||||
PreferredItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.PreferredItemId,
|
ItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.ItemId,
|
||||||
PreferredNodeId = directive?.PreferredNodeId ?? ship.DefaultBehavior.PreferredNodeId,
|
PreferredAnchorId = directive?.PreferredAnchorId ?? ship.DefaultBehavior.PreferredAnchorId,
|
||||||
PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId,
|
PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId,
|
||||||
PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId,
|
PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId,
|
||||||
TargetPosition = directive?.TargetPosition,
|
TargetPosition = directive?.TargetPosition,
|
||||||
@@ -1358,7 +1495,7 @@ internal sealed class PlayerFactionService
|
|||||||
private static bool ReconcileDirectiveOrders(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation)
|
private static bool ReconcileDirectiveOrders(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation)
|
||||||
{
|
{
|
||||||
var aiOrderId = directive is null ? null : $"player-order-{directive.Id}";
|
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;
|
var useOrders = directive?.UseOrders ?? automation?.UseOrders ?? false;
|
||||||
if (!useOrders || directive is null || string.IsNullOrWhiteSpace(directive.StagingOrderKind))
|
if (!useOrders || directive is null || string.IsNullOrWhiteSpace(directive.StagingOrderKind))
|
||||||
@@ -1370,6 +1507,8 @@ internal sealed class PlayerFactionService
|
|||||||
{
|
{
|
||||||
Id = aiOrderId!,
|
Id = aiOrderId!,
|
||||||
Kind = directive.StagingOrderKind!,
|
Kind = directive.StagingOrderKind!,
|
||||||
|
SourceKind = ShipOrderSourceKind.Player,
|
||||||
|
SourceId = directive.Id,
|
||||||
Priority = Math.Max(0, directive.Priority),
|
Priority = Math.Max(0, directive.Priority),
|
||||||
InterruptCurrentPlan = true,
|
InterruptCurrentPlan = true,
|
||||||
Label = directive.Label,
|
Label = directive.Label,
|
||||||
@@ -1379,7 +1518,7 @@ internal sealed class PlayerFactionService
|
|||||||
SourceStationId = directive.SourceStationId ?? directive.HomeStationId,
|
SourceStationId = directive.SourceStationId ?? directive.HomeStationId,
|
||||||
DestinationStationId = directive.DestinationStationId,
|
DestinationStationId = directive.DestinationStationId,
|
||||||
ItemId = directive.ItemId,
|
ItemId = directive.ItemId,
|
||||||
NodeId = directive.PreferredNodeId,
|
AnchorId = directive.PreferredAnchorId,
|
||||||
ConstructionSiteId = directive.PreferredConstructionSiteId,
|
ConstructionSiteId = directive.PreferredConstructionSiteId,
|
||||||
ModuleId = directive.PreferredModuleId,
|
ModuleId = directive.PreferredModuleId,
|
||||||
WaitSeconds = directive.WaitSeconds,
|
WaitSeconds = directive.WaitSeconds,
|
||||||
@@ -1388,17 +1527,16 @@ internal sealed class PlayerFactionService
|
|||||||
KnownStationsOnly = directive.KnownStationsOnly,
|
KnownStationsOnly = directive.KnownStationsOnly,
|
||||||
};
|
};
|
||||||
|
|
||||||
var existing = ship.OrderQueue.FirstOrDefault(order => order.Id == aiOrderId);
|
var existing = ship.OrderQueue.FindById(aiOrderId!);
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
ship.OrderQueue.Add(desiredOrder);
|
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ShipOrdersEqual(existing, desiredOrder))
|
if (!ShipOrdersEqual(existing, desiredOrder))
|
||||||
{
|
{
|
||||||
ship.OrderQueue.Remove(existing);
|
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||||
ship.OrderQueue.Add(desiredOrder);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1442,8 +1580,8 @@ internal sealed class PlayerFactionService
|
|||||||
target.HomeStationId = source.HomeStationId;
|
target.HomeStationId = source.HomeStationId;
|
||||||
target.AreaSystemId = source.AreaSystemId;
|
target.AreaSystemId = source.AreaSystemId;
|
||||||
target.TargetEntityId = source.TargetEntityId;
|
target.TargetEntityId = source.TargetEntityId;
|
||||||
target.PreferredItemId = source.PreferredItemId;
|
target.ItemId = source.ItemId;
|
||||||
target.PreferredNodeId = source.PreferredNodeId;
|
target.PreferredAnchorId = source.PreferredAnchorId;
|
||||||
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
|
target.PreferredConstructionSiteId = source.PreferredConstructionSiteId;
|
||||||
target.PreferredModuleId = source.PreferredModuleId;
|
target.PreferredModuleId = source.PreferredModuleId;
|
||||||
target.TargetPosition = source.TargetPosition;
|
target.TargetPosition = source.TargetPosition;
|
||||||
@@ -1463,8 +1601,8 @@ internal sealed class PlayerFactionService
|
|||||||
&& string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal)
|
&& string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
|
&& string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal)
|
&& string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.PreferredItemId, right.PreferredItemId, StringComparison.Ordinal)
|
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal)
|
&& string.Equals(left.PreferredAnchorId, right.PreferredAnchorId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
|
&& string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
|
&& string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal)
|
||||||
&& Nullable.Equals(left.TargetPosition, right.TargetPosition)
|
&& 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.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
|
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.ItemId, right.ItemId, 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.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
|
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
|
||||||
&& left.WaitSeconds.Equals(right.WaitSeconds)
|
&& left.WaitSeconds.Equals(right.WaitSeconds)
|
||||||
@@ -1496,6 +1634,8 @@ internal sealed class PlayerFactionService
|
|||||||
private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) =>
|
private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) =>
|
||||||
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
|
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.Kind, right.Kind, StringComparison.Ordinal)
|
&& string.Equals(left.Kind, right.Kind, StringComparison.Ordinal)
|
||||||
|
&& left.SourceKind == right.SourceKind
|
||||||
|
&& string.Equals(left.SourceId, right.SourceId, StringComparison.Ordinal)
|
||||||
&& left.Priority == right.Priority
|
&& left.Priority == right.Priority
|
||||||
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
|
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
|
||||||
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
|
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
|
||||||
@@ -1505,7 +1645,7 @@ internal sealed class PlayerFactionService
|
|||||||
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
|
&& string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
|
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.ItemId, right.ItemId, 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.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal)
|
||||||
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
|
&& string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal)
|
||||||
&& left.WaitSeconds.Equals(right.WaitSeconds)
|
&& left.WaitSeconds.Equals(right.WaitSeconds)
|
||||||
@@ -1550,7 +1690,7 @@ internal sealed class PlayerFactionService
|
|||||||
SourceStationId = template.SourceStationId,
|
SourceStationId = template.SourceStationId,
|
||||||
DestinationStationId = template.DestinationStationId,
|
DestinationStationId = template.DestinationStationId,
|
||||||
ItemId = template.ItemId,
|
ItemId = template.ItemId,
|
||||||
NodeId = template.NodeId,
|
AnchorId = template.AnchorId,
|
||||||
ConstructionSiteId = template.ConstructionSiteId,
|
ConstructionSiteId = template.ConstructionSiteId,
|
||||||
ModuleId = template.ModuleId,
|
ModuleId = template.ModuleId,
|
||||||
WaitSeconds = template.WaitSeconds,
|
WaitSeconds = template.WaitSeconds,
|
||||||
@@ -1711,7 +1851,7 @@ internal sealed class PlayerFactionService
|
|||||||
{
|
{
|
||||||
program.CurrentCount = world.Ships.Count(ship =>
|
program.CurrentCount = world.Ships.Count(ship =>
|
||||||
ship.FactionId == player.SovereignFactionId &&
|
ship.FactionId == player.SovereignFactionId &&
|
||||||
string.Equals(ship.Definition.Kind, program.TargetShipKind, StringComparison.Ordinal));
|
string.Equals(GetShipCategory(ship.Definition), program.TargetShipKind, StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -2108,7 +2248,7 @@ internal sealed class PlayerFactionService
|
|||||||
{
|
{
|
||||||
var available = world.Ships.Count(ship =>
|
var available = world.Ships.Count(ship =>
|
||||||
ship.FactionId == player.SovereignFactionId &&
|
ship.FactionId == player.SovereignFactionId &&
|
||||||
string.Equals(ship.Definition.Kind, policy.ShipKind, StringComparison.Ordinal));
|
string.Equals(GetShipCategory(ship.Definition), policy.ShipKind, StringComparison.Ordinal));
|
||||||
if (available < policy.DesiredAssetCount)
|
if (available < policy.DesiredAssetCount)
|
||||||
{
|
{
|
||||||
player.Alerts.Add(new PlayerAlertRuntime
|
player.Alerts.Add(new PlayerAlertRuntime
|
||||||
|
|||||||
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;
|
||||||
using FastEndpoints.Swagger;
|
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);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -14,17 +20,124 @@ builder.Services.AddCors((options) =>
|
|||||||
.AllowAnyOrigin();
|
.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.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
|
||||||
builder.Services.AddFastEndpoints();
|
builder.Services
|
||||||
builder.Services.SwaggerDocument();
|
.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<WorldService>();
|
||||||
builder.Services.AddSingleton<TelemetryService>();
|
builder.Services.AddSingleton<TelemetryService>();
|
||||||
builder.Services.AddHostedService<SimulationHostedService>();
|
builder.Services.AddHostedService<SimulationHostedService>();
|
||||||
|
|
||||||
|
builder.Services.AddFastEndpoints();
|
||||||
|
builder.Services.SwaggerDocument();
|
||||||
|
|
||||||
var app = builder.Build();
|
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.UseCors();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
app.UseFastEndpoints();
|
app.UseFastEndpoints();
|
||||||
app.UseSwaggerGen();
|
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,
|
Planet,
|
||||||
Moon,
|
Moon,
|
||||||
LagrangePoint,
|
LagrangePoint,
|
||||||
|
ResourceNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum WorkStatus
|
public enum WorkStatus
|
||||||
@@ -28,17 +29,14 @@ public enum OrderStatus
|
|||||||
Interrupted,
|
Interrupted,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AiPlanStatus
|
public enum ShipOrderSourceKind
|
||||||
{
|
{
|
||||||
Planned,
|
Player,
|
||||||
Running,
|
Behavior,
|
||||||
Blocked,
|
Commander,
|
||||||
Completed,
|
|
||||||
Failed,
|
|
||||||
Interrupted,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AiPlanStepStatus
|
public enum AiPlanStatus
|
||||||
{
|
{
|
||||||
Planned,
|
Planned,
|
||||||
Running,
|
Running,
|
||||||
@@ -157,8 +155,6 @@ public static class ShipOrderKinds
|
|||||||
{
|
{
|
||||||
public const string Move = "move";
|
public const string Move = "move";
|
||||||
public const string DockAtStation = "dock-at-station";
|
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 FlyToObject = "fly-to-object";
|
||||||
public const string FollowShip = "follow-ship";
|
public const string FollowShip = "follow-ship";
|
||||||
public const string TradeRoute = "trade-route";
|
public const string TradeRoute = "trade-route";
|
||||||
@@ -166,6 +162,11 @@ public static class ShipOrderKinds
|
|||||||
public const string BuildAtSite = "build-at-site";
|
public const string BuildAtSite = "build-at-site";
|
||||||
public const string AttackTarget = "attack-target";
|
public const string AttackTarget = "attack-target";
|
||||||
public const string HoldPosition = "hold-position";
|
public const string HoldPosition = "hold-position";
|
||||||
|
public const string MineLocal = "mine-local";
|
||||||
|
public const string MineAndDeliverRun = "mine-and-deliver-run";
|
||||||
|
public const string SellMinedCargo = "sell-mined-cargo";
|
||||||
|
public const string SupplyFleetRun = "supply-fleet-run";
|
||||||
|
public const string SalvageRun = "salvage-run";
|
||||||
public const string RepeatOrders = "repeat-orders";
|
public const string RepeatOrders = "repeat-orders";
|
||||||
public const string Flee = "flee";
|
public const string Flee = "flee";
|
||||||
}
|
}
|
||||||
@@ -265,12 +266,16 @@ public static class SimulationEnumMappings
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static StorageKind? ToNullableStorageKind(this string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.ToStorageKind();
|
||||||
|
|
||||||
public static string ToContractValue(this SpatialNodeKind kind) => kind switch
|
public static string ToContractValue(this SpatialNodeKind kind) => kind switch
|
||||||
{
|
{
|
||||||
SpatialNodeKind.Star => "star",
|
SpatialNodeKind.Star => "star",
|
||||||
SpatialNodeKind.Planet => "planet",
|
SpatialNodeKind.Planet => "planet",
|
||||||
SpatialNodeKind.Moon => "moon",
|
SpatialNodeKind.Moon => "moon",
|
||||||
SpatialNodeKind.LagrangePoint => "lagrange-point",
|
SpatialNodeKind.LagrangePoint => "lagrange-point",
|
||||||
|
SpatialNodeKind.ResourceNode => "resource-node",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -307,17 +312,6 @@ public static class SimulationEnumMappings
|
|||||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
_ => 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
|
public static string ToContractValue(this AiPlanSourceKind kind) => kind switch
|
||||||
{
|
{
|
||||||
AiPlanSourceKind.Rule => "rule",
|
AiPlanSourceKind.Rule => "rule",
|
||||||
@@ -326,6 +320,14 @@ public static class SimulationEnumMappings
|
|||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public static string ToContractValue(this ShipOrderSourceKind kind) => kind switch
|
||||||
|
{
|
||||||
|
ShipOrderSourceKind.Player => "player",
|
||||||
|
ShipOrderSourceKind.Behavior => "behavior",
|
||||||
|
ShipOrderSourceKind.Commander => "commander",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||||
|
};
|
||||||
|
|
||||||
public static string ToContractValue(this ShipState state) => state switch
|
public static string ToContractValue(this ShipState state) => state switch
|
||||||
{
|
{
|
||||||
ShipState.Idle => "idle",
|
ShipState.Idle => "idle",
|
||||||
|
|||||||
@@ -3,12 +3,79 @@ namespace SpaceGame.Api.Shared.Runtime;
|
|||||||
|
|
||||||
internal static class SimulationRuntimeSupport
|
internal static class SimulationRuntimeSupport
|
||||||
{
|
{
|
||||||
internal static bool HasShipCapabilities(ShipDefinition definition, params string[] capabilities) =>
|
internal static bool CanWarp(ShipDefinition definition) =>
|
||||||
capabilities.All(cap => definition.Capabilities.Contains(cap, StringComparer.Ordinal));
|
definition.Engines.Count > 0;
|
||||||
|
|
||||||
|
internal static bool CanFtl(ShipDefinition definition) =>
|
||||||
|
definition.Engines.Count > 0;
|
||||||
|
|
||||||
|
internal static bool IsMiningShip(ShipDefinition definition) =>
|
||||||
|
definition.Type is ShipType.Miner or ShipType.LargeMiner;
|
||||||
|
|
||||||
|
internal static bool IsTransportShip(ShipDefinition definition) =>
|
||||||
|
definition.Type is ShipType.Freighter or ShipType.Transporter or ShipType.Courier or ShipType.Resupplier;
|
||||||
|
|
||||||
|
internal static bool IsConstructionShip(ShipDefinition definition) =>
|
||||||
|
definition.Type == ShipType.Builder;
|
||||||
|
|
||||||
|
internal static bool IsMilitaryShip(ShipDefinition definition) =>
|
||||||
|
definition.Type is ShipType.Fighter
|
||||||
|
or ShipType.HeavyFighter
|
||||||
|
or ShipType.Destroyer
|
||||||
|
or ShipType.Bomber
|
||||||
|
or ShipType.Frigate
|
||||||
|
or ShipType.Interceptor
|
||||||
|
or ShipType.Corvette
|
||||||
|
or ShipType.Battleship
|
||||||
|
or ShipType.Gunboat;
|
||||||
|
|
||||||
|
internal static string? GetShipCategory(ShipDefinition definition)
|
||||||
|
{
|
||||||
|
if (IsMilitaryShip(definition))
|
||||||
|
{
|
||||||
|
return "military";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsConstructionShip(definition))
|
||||||
|
{
|
||||||
|
return "construction";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsTransportShip(definition))
|
||||||
|
{
|
||||||
|
return "transport";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsMiningShip(definition))
|
||||||
|
{
|
||||||
|
return "mining";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
internal static int CountStationModules(StationRuntime station, ModuleType moduleType) =>
|
internal static int CountStationModules(StationRuntime station, ModuleType moduleType) =>
|
||||||
station.Modules.Count(module => module.ModuleType == moduleType);
|
station.Modules.Count(module => module.ModuleType == moduleType);
|
||||||
|
|
||||||
|
internal static float GetStationSupportedPopulation(
|
||||||
|
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
||||||
|
StationRuntime station) =>
|
||||||
|
40f + station.Modules
|
||||||
|
.Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition) && definition is HabitationModuleDefinition habitation
|
||||||
|
? habitation.SupportedPopulation
|
||||||
|
: 0f)
|
||||||
|
.Sum();
|
||||||
|
|
||||||
|
internal static float GetStationRequiredWorkforce(
|
||||||
|
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
||||||
|
StationRuntime station) =>
|
||||||
|
MathF.Max(12f, station.Modules
|
||||||
|
.Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition)
|
||||||
|
&& definition is ProductionLaneModuleDefinition productionLane
|
||||||
|
? productionLane.RequiredWorkforce
|
||||||
|
: 0f)
|
||||||
|
.Sum());
|
||||||
|
|
||||||
internal static float GetStationStorageCapacity(SimulationWorld world, StationRuntime station, StorageKind storageKind)
|
internal static float GetStationStorageCapacity(SimulationWorld world, StationRuntime station, StorageKind storageKind)
|
||||||
{
|
{
|
||||||
SyncStorageModuleLevels(world, station, storageKind);
|
SyncStorageModuleLevels(world, station, storageKind);
|
||||||
@@ -46,7 +113,7 @@ internal static class SimulationRuntimeSupport
|
|||||||
}
|
}
|
||||||
|
|
||||||
var remaining = station.Inventory
|
var remaining = station.Inventory
|
||||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind)
|
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageKind)
|
||||||
.Sum(entry => entry.Value);
|
.Sum(entry => entry.Value);
|
||||||
|
|
||||||
foreach (var (module, definition) in storageModules)
|
foreach (var (module, definition) in storageModules)
|
||||||
@@ -112,13 +179,13 @@ internal static class SimulationRuntimeSupport
|
|||||||
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
|
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
|
||||||
|
|
||||||
internal static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node, SimulationWorld world) =>
|
internal static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node, SimulationWorld world) =>
|
||||||
HasShipCapabilities(ship.Definition, "mining")
|
IsMiningShip(ship.Definition)
|
||||||
&& world.ItemDefinitions.TryGetValue(node.ItemId, out var item)
|
&& world.ItemDefinitions.TryGetValue(node.ItemId, out var item)
|
||||||
&& item.CargoStorageKind is not null
|
&& item.CargoKind is not null
|
||||||
&& item.CargoStorageKind == ship.Definition.CargoStorageKind;
|
&& ship.Definition.SupportsCargoKind(item.CargoKind.Value);
|
||||||
|
|
||||||
internal static bool CanBuildClaimBeacon(ShipRuntime ship) =>
|
internal static bool CanBuildClaimBeacon(ShipRuntime ship) =>
|
||||||
string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal);
|
IsMilitaryShip(ship.Definition);
|
||||||
|
|
||||||
internal static float ComputeWorkforceRatio(float population, float workforceRequired)
|
internal static float ComputeWorkforceRatio(float population, float workforceRequired)
|
||||||
{
|
{
|
||||||
@@ -176,7 +243,7 @@ internal static class SimulationRuntimeSupport
|
|||||||
return 0f;
|
return 0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
var storageKind = itemDefinition.CargoStorageKind;
|
var storageKind = itemDefinition.CargoKind;
|
||||||
if (storageKind is null)
|
if (storageKind is null)
|
||||||
{
|
{
|
||||||
return 0f;
|
return 0f;
|
||||||
@@ -194,7 +261,7 @@ internal static class SimulationRuntimeSupport
|
|||||||
}
|
}
|
||||||
|
|
||||||
var used = station.Inventory
|
var used = station.Inventory
|
||||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind)
|
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageKind)
|
||||||
.Sum(entry => entry.Value);
|
.Sum(entry => entry.Value);
|
||||||
var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used));
|
var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used));
|
||||||
if (accepted <= 0.01f)
|
if (accepted <= 0.01f)
|
||||||
|
|||||||
@@ -7,6 +7,22 @@ public static class SimulationUnits
|
|||||||
|
|
||||||
public static float AuToKilometers(float au) => au * KilometersPerAu;
|
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) =>
|
public static float AuPerSecondToKilometersPerSecond(float auPerSecond) =>
|
||||||
auPerSecond * KilometersPerAu;
|
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()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Post("/api/ships/{shipId}/orders");
|
Post("/api/ships/{shipId}/orders");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(ShipOrderCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(ShipOrderCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
35
apps/backend/Ships/Api/GetShipAutomationCatalogHandler.cs
Normal file
35
apps/backend/Ships/Api/GetShipAutomationCatalogHandler.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
|
||||||
|
namespace SpaceGame.Api.Ships.Api;
|
||||||
|
|
||||||
|
public sealed class GetShipAutomationCatalogHandler : EndpointWithoutRequest<ShipAutomationCatalogSnapshot>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/api/ships/catalog");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var snapshot = new ShipAutomationCatalogSnapshot(
|
||||||
|
ShipAutomationCatalog.Behaviors
|
||||||
|
.Select(definition => new ShipBehaviorDefinitionSnapshot(
|
||||||
|
definition.Id,
|
||||||
|
definition.Label,
|
||||||
|
definition.Category,
|
||||||
|
definition.SupportStatus.ToString(),
|
||||||
|
definition.Notes))
|
||||||
|
.ToList(),
|
||||||
|
ShipAutomationCatalog.Orders
|
||||||
|
.Select(definition => new ShipOrderDefinitionSnapshot(
|
||||||
|
definition.Id,
|
||||||
|
definition.Label,
|
||||||
|
definition.Category,
|
||||||
|
definition.SupportStatus.ToString(),
|
||||||
|
definition.Notes))
|
||||||
|
.ToList());
|
||||||
|
|
||||||
|
await SendOkAsync(snapshot, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ public sealed class RemoveShipOrderHandler(WorldService worldService) : Endpoint
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Delete("/api/ships/{shipId}/orders/{orderId}");
|
Delete("/api/ships/{shipId}/orders/{orderId}");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(RemoveShipOrderRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(RemoveShipOrderRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
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()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Put("/api/ships/{shipId}/default-behavior");
|
Put("/api/ships/{shipId}/default-behavior");
|
||||||
AllowAnonymous();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(ShipDefaultBehaviorCommandRequest request, CancellationToken cancellationToken)
|
public override async Task HandleAsync(ShipDefaultBehaviorCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
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? SourceStationId,
|
||||||
string? DestinationStationId,
|
string? DestinationStationId,
|
||||||
string? ItemId,
|
string? ItemId,
|
||||||
string? NodeId,
|
string? AnchorId,
|
||||||
string? ConstructionSiteId,
|
string? ConstructionSiteId,
|
||||||
string? ModuleId,
|
string? ModuleId,
|
||||||
float? WaitSeconds,
|
float? WaitSeconds,
|
||||||
@@ -19,6 +19,28 @@ public sealed record ShipOrderCommandRequest(
|
|||||||
int? MaxSystemRange,
|
int? MaxSystemRange,
|
||||||
bool? KnownStationsOnly);
|
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(
|
public sealed record ShipOrderTemplateCommandRequest(
|
||||||
string Kind,
|
string Kind,
|
||||||
string? Label,
|
string? Label,
|
||||||
@@ -28,7 +50,7 @@ public sealed record ShipOrderTemplateCommandRequest(
|
|||||||
string? SourceStationId,
|
string? SourceStationId,
|
||||||
string? DestinationStationId,
|
string? DestinationStationId,
|
||||||
string? ItemId,
|
string? ItemId,
|
||||||
string? NodeId,
|
string? AnchorId,
|
||||||
string? ConstructionSiteId,
|
string? ConstructionSiteId,
|
||||||
string? ModuleId,
|
string? ModuleId,
|
||||||
float? WaitSeconds,
|
float? WaitSeconds,
|
||||||
@@ -42,8 +64,8 @@ public sealed record ShipDefaultBehaviorCommandRequest(
|
|||||||
string? HomeStationId,
|
string? HomeStationId,
|
||||||
string? AreaSystemId,
|
string? AreaSystemId,
|
||||||
string? TargetEntityId,
|
string? TargetEntityId,
|
||||||
string? PreferredItemId,
|
string? ItemId,
|
||||||
string? PreferredNodeId,
|
string? PreferredAnchorId,
|
||||||
string? PreferredConstructionSiteId,
|
string? PreferredConstructionSiteId,
|
||||||
string? PreferredModuleId,
|
string? PreferredModuleId,
|
||||||
Vector3Dto? TargetPosition,
|
Vector3Dto? TargetPosition,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public sealed record ShipSkillProfileSnapshot(
|
|||||||
public sealed record ShipOrderSnapshot(
|
public sealed record ShipOrderSnapshot(
|
||||||
string Id,
|
string Id,
|
||||||
string Kind,
|
string Kind,
|
||||||
|
string SourceKind,
|
||||||
|
string SourceId,
|
||||||
string Status,
|
string Status,
|
||||||
int Priority,
|
int Priority,
|
||||||
bool InterruptCurrentPlan,
|
bool InterruptCurrentPlan,
|
||||||
@@ -21,7 +23,7 @@ public sealed record ShipOrderSnapshot(
|
|||||||
string? SourceStationId,
|
string? SourceStationId,
|
||||||
string? DestinationStationId,
|
string? DestinationStationId,
|
||||||
string? ItemId,
|
string? ItemId,
|
||||||
string? NodeId,
|
string? AnchorId,
|
||||||
string? ConstructionSiteId,
|
string? ConstructionSiteId,
|
||||||
string? ModuleId,
|
string? ModuleId,
|
||||||
float WaitSeconds,
|
float WaitSeconds,
|
||||||
@@ -39,7 +41,7 @@ public sealed record ShipOrderTemplateSnapshot(
|
|||||||
string? SourceStationId,
|
string? SourceStationId,
|
||||||
string? DestinationStationId,
|
string? DestinationStationId,
|
||||||
string? ItemId,
|
string? ItemId,
|
||||||
string? NodeId,
|
string? AnchorId,
|
||||||
string? ConstructionSiteId,
|
string? ConstructionSiteId,
|
||||||
string? ModuleId,
|
string? ModuleId,
|
||||||
float WaitSeconds,
|
float WaitSeconds,
|
||||||
@@ -53,8 +55,8 @@ public sealed record DefaultBehaviorSnapshot(
|
|||||||
string? HomeStationId,
|
string? HomeStationId,
|
||||||
string? AreaSystemId,
|
string? AreaSystemId,
|
||||||
string? TargetEntityId,
|
string? TargetEntityId,
|
||||||
string? PreferredItemId,
|
string? ItemId,
|
||||||
string? PreferredNodeId,
|
string? PreferredAnchorId,
|
||||||
string? PreferredConstructionSiteId,
|
string? PreferredConstructionSiteId,
|
||||||
string? PreferredModuleId,
|
string? PreferredModuleId,
|
||||||
Vector3Dto? TargetPosition,
|
Vector3Dto? TargetPosition,
|
||||||
@@ -93,7 +95,9 @@ public sealed record ShipSubTaskSnapshot(
|
|||||||
string Summary,
|
string Summary,
|
||||||
string? TargetEntityId,
|
string? TargetEntityId,
|
||||||
string? TargetSystemId,
|
string? TargetSystemId,
|
||||||
string? TargetNodeId,
|
string? TargetAnchorId,
|
||||||
|
string? TargetResourceNodeId,
|
||||||
|
string? TargetResourceDepositId,
|
||||||
Vector3Dto? TargetPosition,
|
Vector3Dto? TargetPosition,
|
||||||
string? ItemId,
|
string? ItemId,
|
||||||
string? ModuleId,
|
string? ModuleId,
|
||||||
@@ -104,35 +108,13 @@ public sealed record ShipSubTaskSnapshot(
|
|||||||
float TotalSeconds,
|
float TotalSeconds,
|
||||||
string? BlockingReason);
|
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(
|
public sealed record ShipSnapshot(
|
||||||
string Id,
|
string Id,
|
||||||
string Label,
|
string Name,
|
||||||
string Kind,
|
string Purpose,
|
||||||
string Class,
|
string Type,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
|
string? AnchorId,
|
||||||
Vector3Dto LocalPosition,
|
Vector3Dto LocalPosition,
|
||||||
Vector3Dto LocalVelocity,
|
Vector3Dto LocalVelocity,
|
||||||
Vector3Dto TargetLocalPosition,
|
Vector3Dto TargetLocalPosition,
|
||||||
@@ -141,19 +123,17 @@ public sealed record ShipSnapshot(
|
|||||||
DefaultBehaviorSnapshot DefaultBehavior,
|
DefaultBehaviorSnapshot DefaultBehavior,
|
||||||
ShipAssignmentSnapshot? Assignment,
|
ShipAssignmentSnapshot? Assignment,
|
||||||
ShipSkillProfileSnapshot Skills,
|
ShipSkillProfileSnapshot Skills,
|
||||||
ShipPlanSnapshot? ActivePlan,
|
|
||||||
string? CurrentStepId,
|
|
||||||
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
|
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
|
||||||
string ControlSourceKind,
|
string ControlSourceKind,
|
||||||
string? ControlSourceId,
|
string? ControlSourceId,
|
||||||
string? ControlReason,
|
string? ControlReason,
|
||||||
string? LastReplanReason,
|
string? LastReplanReason,
|
||||||
string? LastAccessFailureReason,
|
string? LastAccessFailureReason,
|
||||||
string? CelestialId,
|
|
||||||
string? DockedStationId,
|
string? DockedStationId,
|
||||||
string? CommanderId,
|
string? CommanderId,
|
||||||
string? PolicySetId,
|
string? PolicySetId,
|
||||||
float CargoCapacity,
|
float CargoCapacity,
|
||||||
|
IReadOnlyList<string> CargoTypes,
|
||||||
float TravelSpeed,
|
float TravelSpeed,
|
||||||
string TravelSpeedUnit,
|
string TravelSpeedUnit,
|
||||||
IReadOnlyList<InventoryEntry> Inventory,
|
IReadOnlyList<InventoryEntry> Inventory,
|
||||||
@@ -164,10 +144,11 @@ public sealed record ShipSnapshot(
|
|||||||
|
|
||||||
public sealed record ShipDelta(
|
public sealed record ShipDelta(
|
||||||
string Id,
|
string Id,
|
||||||
string Label,
|
string Name,
|
||||||
string Kind,
|
string Purpose,
|
||||||
string Class,
|
string Type,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
|
string? AnchorId,
|
||||||
Vector3Dto LocalPosition,
|
Vector3Dto LocalPosition,
|
||||||
Vector3Dto LocalVelocity,
|
Vector3Dto LocalVelocity,
|
||||||
Vector3Dto TargetLocalPosition,
|
Vector3Dto TargetLocalPosition,
|
||||||
@@ -176,19 +157,17 @@ public sealed record ShipDelta(
|
|||||||
DefaultBehaviorSnapshot DefaultBehavior,
|
DefaultBehaviorSnapshot DefaultBehavior,
|
||||||
ShipAssignmentSnapshot? Assignment,
|
ShipAssignmentSnapshot? Assignment,
|
||||||
ShipSkillProfileSnapshot Skills,
|
ShipSkillProfileSnapshot Skills,
|
||||||
ShipPlanSnapshot? ActivePlan,
|
|
||||||
string? CurrentStepId,
|
|
||||||
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
|
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
|
||||||
string ControlSourceKind,
|
string ControlSourceKind,
|
||||||
string? ControlSourceId,
|
string? ControlSourceId,
|
||||||
string? ControlReason,
|
string? ControlReason,
|
||||||
string? LastReplanReason,
|
string? LastReplanReason,
|
||||||
string? LastAccessFailureReason,
|
string? LastAccessFailureReason,
|
||||||
string? CelestialId,
|
|
||||||
string? DockedStationId,
|
string? DockedStationId,
|
||||||
string? CommanderId,
|
string? CommanderId,
|
||||||
string? PolicySetId,
|
string? PolicySetId,
|
||||||
float CargoCapacity,
|
float CargoCapacity,
|
||||||
|
IReadOnlyList<string> CargoTypes,
|
||||||
float TravelSpeed,
|
float TravelSpeed,
|
||||||
string TravelSpeedUnit,
|
string TravelSpeedUnit,
|
||||||
IReadOnlyList<InventoryEntry> Inventory,
|
IReadOnlyList<InventoryEntry> Inventory,
|
||||||
@@ -200,17 +179,17 @@ public sealed record ShipDelta(
|
|||||||
public sealed record ShipSpatialStateSnapshot(
|
public sealed record ShipSpatialStateSnapshot(
|
||||||
string SpaceLayer,
|
string SpaceLayer,
|
||||||
string CurrentSystemId,
|
string CurrentSystemId,
|
||||||
string? CurrentCelestialId,
|
string? CurrentAnchorId,
|
||||||
Vector3Dto? LocalPosition,
|
Vector3Dto? LocalPosition,
|
||||||
Vector3Dto? SystemPosition,
|
Vector3Dto? SystemPosition,
|
||||||
string MovementRegime,
|
string MovementRegime,
|
||||||
string? DestinationNodeId,
|
string? DestinationAnchorId,
|
||||||
ShipTransitSnapshot? Transit);
|
ShipTransitSnapshot? Transit);
|
||||||
|
|
||||||
public sealed record ShipTransitSnapshot(
|
public sealed record ShipTransitSnapshot(
|
||||||
string Regime,
|
string Regime,
|
||||||
string? OriginNodeId,
|
string? OriginAnchorId,
|
||||||
string? DestinationNodeId,
|
string? DestinationAnchorId,
|
||||||
DateTimeOffset? StartedAtUtc,
|
DateTimeOffset? StartedAtUtc,
|
||||||
DateTimeOffset? ArrivalDueAtUtc,
|
DateTimeOffset? ArrivalDueAtUtc,
|
||||||
float Progress);
|
float Progress);
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ public sealed class ShipRuntime
|
|||||||
public Vector3 Velocity { get; set; } = Vector3.Zero;
|
public Vector3 Velocity { get; set; } = Vector3.Zero;
|
||||||
public ShipState State { get; set; } = ShipState.Idle;
|
public ShipState State { get; set; } = ShipState.Idle;
|
||||||
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
|
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
|
||||||
public List<ShipOrderRuntime> OrderQueue { get; } = [];
|
public ShipOrderQueue OrderQueue { get; } = new();
|
||||||
public ShipPlanRuntime? ActivePlan { get; set; }
|
|
||||||
public required ShipSkillProfileRuntime Skills { get; set; }
|
public required ShipSkillProfileRuntime Skills { get; set; }
|
||||||
public bool NeedsReplan { get; set; } = true;
|
public bool NeedsReplan { get; set; } = true;
|
||||||
public float ReplanCooldownSeconds { get; set; }
|
public float ReplanCooldownSeconds { get; set; }
|
||||||
@@ -30,10 +29,190 @@ public sealed class ShipRuntime
|
|||||||
public float Health { get; set; }
|
public float Health { get; set; }
|
||||||
public HashSet<string> KnownStationIds { get; } = new(StringComparer.Ordinal);
|
public HashSet<string> KnownStationIds { get; } = new(StringComparer.Ordinal);
|
||||||
public List<string> History { get; } = [];
|
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 LastSignature { get; set; } = string.Empty;
|
||||||
public string LastDeltaSignature { 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 sealed class ShipSkillProfileRuntime
|
||||||
{
|
{
|
||||||
public int Navigation { get; set; }
|
public int Navigation { get; set; }
|
||||||
@@ -47,6 +226,8 @@ public sealed class ShipOrderRuntime
|
|||||||
{
|
{
|
||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
public required string Kind { get; init; }
|
public required string Kind { get; init; }
|
||||||
|
public required ShipOrderSourceKind SourceKind { get; init; }
|
||||||
|
public required string SourceId { get; init; }
|
||||||
public OrderStatus Status { get; set; } = OrderStatus.Queued;
|
public OrderStatus Status { get; set; } = OrderStatus.Queued;
|
||||||
public int Priority { get; set; }
|
public int Priority { get; set; }
|
||||||
public bool InterruptCurrentPlan { get; set; } = true;
|
public bool InterruptCurrentPlan { get; set; } = true;
|
||||||
@@ -58,7 +239,7 @@ public sealed class ShipOrderRuntime
|
|||||||
public string? SourceStationId { get; set; }
|
public string? SourceStationId { get; set; }
|
||||||
public string? DestinationStationId { get; set; }
|
public string? DestinationStationId { get; set; }
|
||||||
public string? ItemId { get; set; }
|
public string? ItemId { get; set; }
|
||||||
public string? NodeId { get; set; }
|
public string? AnchorId { get; set; }
|
||||||
public string? ConstructionSiteId { get; set; }
|
public string? ConstructionSiteId { get; set; }
|
||||||
public string? ModuleId { get; set; }
|
public string? ModuleId { get; set; }
|
||||||
public float WaitSeconds { get; set; }
|
public float WaitSeconds { get; set; }
|
||||||
@@ -75,8 +256,8 @@ public sealed class DefaultBehaviorRuntime
|
|||||||
public string? HomeStationId { get; set; }
|
public string? HomeStationId { get; set; }
|
||||||
public string? AreaSystemId { get; set; }
|
public string? AreaSystemId { get; set; }
|
||||||
public string? TargetEntityId { get; set; }
|
public string? TargetEntityId { get; set; }
|
||||||
public string? PreferredItemId { get; set; }
|
public string? ItemId { get; set; }
|
||||||
public string? PreferredNodeId { get; set; }
|
public string? PreferredAnchorId { get; set; }
|
||||||
public string? PreferredConstructionSiteId { get; set; }
|
public string? PreferredConstructionSiteId { get; set; }
|
||||||
public string? PreferredModuleId { get; set; }
|
public string? PreferredModuleId { get; set; }
|
||||||
public Vector3? TargetPosition { get; set; }
|
public Vector3? TargetPosition { get; set; }
|
||||||
@@ -100,7 +281,7 @@ public sealed class ShipOrderTemplateRuntime
|
|||||||
public string? SourceStationId { get; set; }
|
public string? SourceStationId { get; set; }
|
||||||
public string? DestinationStationId { get; set; }
|
public string? DestinationStationId { get; set; }
|
||||||
public string? ItemId { get; set; }
|
public string? ItemId { get; set; }
|
||||||
public string? NodeId { get; set; }
|
public string? AnchorId { get; set; }
|
||||||
public string? ConstructionSiteId { get; set; }
|
public string? ConstructionSiteId { get; set; }
|
||||||
public string? ModuleId { get; set; }
|
public string? ModuleId { get; set; }
|
||||||
public float WaitSeconds { get; set; }
|
public float WaitSeconds { get; set; }
|
||||||
@@ -109,33 +290,6 @@ public sealed class ShipOrderTemplateRuntime
|
|||||||
public bool KnownStationsOnly { get; set; }
|
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 sealed class ShipSubTaskRuntime
|
||||||
{
|
{
|
||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
@@ -144,7 +298,9 @@ public sealed class ShipSubTaskRuntime
|
|||||||
public WorkStatus Status { get; set; } = WorkStatus.Pending;
|
public WorkStatus Status { get; set; } = WorkStatus.Pending;
|
||||||
public string? TargetEntityId { get; set; }
|
public string? TargetEntityId { get; set; }
|
||||||
public string? TargetSystemId { 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 Vector3? TargetPosition { get; set; }
|
||||||
public string? ItemId { get; set; }
|
public string? ItemId { get; set; }
|
||||||
public string? ModuleId { 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;
|
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 OrbitalSimulationOptions _orbitalSimulation;
|
||||||
private readonly OrbitalStateUpdater _orbitalStateUpdater;
|
private readonly OrbitalStateUpdater _orbitalStateUpdater;
|
||||||
private readonly InfrastructureSimulationService _infrastructureSimulation;
|
private readonly InfrastructureSimulationService _infrastructureSimulation;
|
||||||
@@ -14,9 +15,11 @@ public sealed class SimulationEngine
|
|||||||
private readonly ShipAiService _shipAi;
|
private readonly ShipAiService _shipAi;
|
||||||
private readonly SimulationProjectionService _projection;
|
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);
|
_orbitalStateUpdater = new OrbitalStateUpdater(_orbitalSimulation);
|
||||||
_infrastructureSimulation = new InfrastructureSimulationService();
|
_infrastructureSimulation = new InfrastructureSimulationService();
|
||||||
_geopolitics = new GeopoliticalSimulationService();
|
_geopolitics = new GeopoliticalSimulationService();
|
||||||
@@ -24,7 +27,7 @@ public sealed class SimulationEngine
|
|||||||
_playerFaction = new PlayerFactionService();
|
_playerFaction = new PlayerFactionService();
|
||||||
_stationSimulation = new StationSimulationService();
|
_stationSimulation = new StationSimulationService();
|
||||||
_stationLifecycle = new StationLifecycleService(_stationSimulation);
|
_stationLifecycle = new StationLifecycleService(_stationSimulation);
|
||||||
_shipAi = new ShipAiService();
|
_shipAi = new ShipAiService(balance);
|
||||||
_projection = new SimulationProjectionService(_orbitalSimulation);
|
_projection = new SimulationProjectionService(_orbitalSimulation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +35,7 @@ public sealed class SimulationEngine
|
|||||||
{
|
{
|
||||||
var nowUtc = DateTimeOffset.UtcNow;
|
var nowUtc = DateTimeOffset.UtcNow;
|
||||||
var events = new List<SimulationEventRecord>();
|
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.GeneratedAtUtc = nowUtc;
|
||||||
|
|
||||||
world.OrbitalTimeSeconds += simulationDeltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond;
|
world.OrbitalTimeSeconds += simulationDeltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond;
|
||||||
@@ -41,8 +44,8 @@ public sealed class SimulationEngine
|
|||||||
_infrastructureSimulation.UpdateClaims(world, events);
|
_infrastructureSimulation.UpdateClaims(world, events);
|
||||||
_infrastructureSimulation.UpdateConstructionSites(world, events);
|
_infrastructureSimulation.UpdateConstructionSites(world, events);
|
||||||
_geopolitics.Update(world, simulationDeltaSeconds, events);
|
_geopolitics.Update(world, simulationDeltaSeconds, events);
|
||||||
_commanderPlanning.UpdateCommanders(world, simulationDeltaSeconds, events);
|
_commanderPlanning.UpdateCommanders(world, _playerStateStore, simulationDeltaSeconds, events);
|
||||||
_playerFaction.Update(world, simulationDeltaSeconds, events);
|
_playerFaction.Update(world, _playerStateStore, simulationDeltaSeconds, events);
|
||||||
_stationLifecycle.UpdateStations(world, simulationDeltaSeconds, events);
|
_stationLifecycle.UpdateStations(world, simulationDeltaSeconds, events);
|
||||||
|
|
||||||
foreach (var ship in world.Ships.ToList())
|
foreach (var ship in world.Ships.ToList())
|
||||||
@@ -75,7 +78,7 @@ public sealed class SimulationEngine
|
|||||||
{
|
{
|
||||||
foreach (var ship in world.Ships.Where(candidate => candidate.Health <= 0f).ToList())
|
foreach (var ship in world.Ships.Where(candidate => candidate.Health <= 0f).ToList())
|
||||||
{
|
{
|
||||||
CreateWreck(world, "ship", ship.Id, ship.SystemId, ship.Position, ship.Definition.CargoCapacity + (ship.Definition.MaxHealth * 0.08f));
|
CreateWreck(world, "ship", ship.Id, ship.SystemId, ship.Position, ship.Definition.GetTotalCargoCapacity() + (ship.Definition.Hull * 0.08f));
|
||||||
world.Ships.Remove(ship);
|
world.Ships.Remove(ship);
|
||||||
if (ship.DockedStationId is not null && world.Stations.FirstOrDefault(station => station.Id == ship.DockedStationId) is { } dockedStation)
|
if (ship.DockedStationId is not null && world.Stations.FirstOrDefault(station => station.Id == ship.DockedStationId) is { } dockedStation)
|
||||||
{
|
{
|
||||||
@@ -93,7 +96,7 @@ public sealed class SimulationEngine
|
|||||||
commander.IsAlive = false;
|
commander.IsAlive = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
events.Add(new SimulationEventRecord("ship", ship.Id, "destroyed", $"{ship.Definition.Label} was destroyed.", DateTimeOffset.UtcNow));
|
events.Add(new SimulationEventRecord("ship", ship.Id, "destroyed", $"{ship.Definition.Name} was destroyed.", DateTimeOffset.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var station in world.Stations.Where(candidate => candidate.Health <= 0f).ToList())
|
foreach (var station in world.Stations.Where(candidate => candidate.Health <= 0f).ToList())
|
||||||
@@ -101,12 +104,12 @@ public sealed class SimulationEngine
|
|||||||
CreateWreck(world, "station", station.Id, station.SystemId, station.Position, station.MaxHealth * 0.12f);
|
CreateWreck(world, "station", station.Id, station.SystemId, station.Position, station.MaxHealth * 0.12f);
|
||||||
world.Stations.Remove(station);
|
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.Health = 0f;
|
||||||
claim.State = ClaimStateKinds.Destroyed;
|
claim.State = ClaimStateKinds.Destroyed;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ internal sealed class SimulationProjectionService
|
|||||||
false,
|
false,
|
||||||
events,
|
events,
|
||||||
BuildCelestialDeltas(world),
|
BuildCelestialDeltas(world),
|
||||||
|
BuildAnchorDeltas(world),
|
||||||
BuildNodeDeltas(world),
|
BuildNodeDeltas(world),
|
||||||
BuildStationDeltas(world),
|
BuildStationDeltas(world),
|
||||||
BuildClaimDeltas(world),
|
BuildClaimDeltas(world),
|
||||||
@@ -32,7 +33,6 @@ internal sealed class SimulationProjectionService
|
|||||||
BuildPolicyDeltas(world),
|
BuildPolicyDeltas(world),
|
||||||
BuildShipDeltas(world),
|
BuildShipDeltas(world),
|
||||||
BuildFactionDeltas(world),
|
BuildFactionDeltas(world),
|
||||||
BuildPlayerFactionDelta(world),
|
|
||||||
BuildGeopoliticsDelta(world));
|
BuildGeopoliticsDelta(world));
|
||||||
|
|
||||||
public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence)
|
public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence)
|
||||||
@@ -88,26 +88,37 @@ internal sealed class SimulationProjectionService
|
|||||||
c.Kind,
|
c.Kind,
|
||||||
c.OrbitalAnchor,
|
c.OrbitalAnchor,
|
||||||
c.LocalSpaceRadius,
|
c.LocalSpaceRadius,
|
||||||
c.ParentNodeId,
|
c.ParentAnchorId,
|
||||||
c.OccupyingStructureId,
|
c.OccupyingStructureId,
|
||||||
c.OrbitReferenceId)).ToList(),
|
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(
|
world.Nodes.Select(ToNodeDelta).Select(node => new ResourceNodeSnapshot(
|
||||||
node.Id,
|
node.Id,
|
||||||
|
node.AnchorId,
|
||||||
node.SystemId,
|
node.SystemId,
|
||||||
node.LocalPosition,
|
node.LocalPosition,
|
||||||
node.CelestialId,
|
node.LocalSpaceRadius,
|
||||||
node.SourceKind,
|
node.SourceKind,
|
||||||
node.OreRemaining,
|
node.OreRemaining,
|
||||||
node.MaxOre,
|
node.MaxOre,
|
||||||
node.ItemId)).ToList(),
|
node.ItemId,
|
||||||
|
node.Deposits)).ToList(),
|
||||||
world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot(
|
world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot(
|
||||||
station.Id,
|
station.Id,
|
||||||
station.Label,
|
station.Label,
|
||||||
station.Category,
|
station.Category,
|
||||||
station.Objective,
|
station.Objective,
|
||||||
station.SystemId,
|
station.SystemId,
|
||||||
|
station.AnchorId,
|
||||||
station.LocalPosition,
|
station.LocalPosition,
|
||||||
station.CelestialId,
|
|
||||||
station.Color,
|
station.Color,
|
||||||
station.DockedShips,
|
station.DockedShips,
|
||||||
station.DockedShipIds,
|
station.DockedShipIds,
|
||||||
@@ -128,7 +139,7 @@ internal sealed class SimulationProjectionService
|
|||||||
claim.Id,
|
claim.Id,
|
||||||
claim.FactionId,
|
claim.FactionId,
|
||||||
claim.SystemId,
|
claim.SystemId,
|
||||||
claim.CelestialId,
|
claim.AnchorId,
|
||||||
claim.State,
|
claim.State,
|
||||||
claim.Health,
|
claim.Health,
|
||||||
claim.PlacedAtUtc,
|
claim.PlacedAtUtc,
|
||||||
@@ -137,7 +148,7 @@ internal sealed class SimulationProjectionService
|
|||||||
site.Id,
|
site.Id,
|
||||||
site.FactionId,
|
site.FactionId,
|
||||||
site.SystemId,
|
site.SystemId,
|
||||||
site.CelestialId,
|
site.AnchorId,
|
||||||
site.TargetKind,
|
site.TargetKind,
|
||||||
site.TargetDefinitionId,
|
site.TargetDefinitionId,
|
||||||
site.BlueprintId,
|
site.BlueprintId,
|
||||||
@@ -177,10 +188,11 @@ internal sealed class SimulationProjectionService
|
|||||||
policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(),
|
policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(),
|
||||||
world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot(
|
world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot(
|
||||||
ship.Id,
|
ship.Id,
|
||||||
ship.Label,
|
ship.Name,
|
||||||
ship.Kind,
|
ship.Purpose,
|
||||||
ship.Class,
|
ship.Type,
|
||||||
ship.SystemId,
|
ship.SystemId,
|
||||||
|
ship.AnchorId,
|
||||||
ship.LocalPosition,
|
ship.LocalPosition,
|
||||||
ship.LocalVelocity,
|
ship.LocalVelocity,
|
||||||
ship.TargetLocalPosition,
|
ship.TargetLocalPosition,
|
||||||
@@ -189,19 +201,17 @@ internal sealed class SimulationProjectionService
|
|||||||
ship.DefaultBehavior,
|
ship.DefaultBehavior,
|
||||||
ship.Assignment,
|
ship.Assignment,
|
||||||
ship.Skills,
|
ship.Skills,
|
||||||
ship.ActivePlan,
|
|
||||||
ship.CurrentStepId,
|
|
||||||
ship.ActiveSubTasks,
|
ship.ActiveSubTasks,
|
||||||
ship.ControlSourceKind,
|
ship.ControlSourceKind,
|
||||||
ship.ControlSourceId,
|
ship.ControlSourceId,
|
||||||
ship.ControlReason,
|
ship.ControlReason,
|
||||||
ship.LastReplanReason,
|
ship.LastReplanReason,
|
||||||
ship.LastAccessFailureReason,
|
ship.LastAccessFailureReason,
|
||||||
ship.CelestialId,
|
|
||||||
ship.DockedStationId,
|
ship.DockedStationId,
|
||||||
ship.CommanderId,
|
ship.CommanderId,
|
||||||
ship.PolicySetId,
|
ship.PolicySetId,
|
||||||
ship.CargoCapacity,
|
ship.CargoCapacity,
|
||||||
|
ship.CargoTypes,
|
||||||
ship.TravelSpeed,
|
ship.TravelSpeed,
|
||||||
ship.TravelSpeedUnit,
|
ship.TravelSpeedUnit,
|
||||||
ship.Inventory,
|
ship.Inventory,
|
||||||
@@ -225,7 +235,6 @@ internal sealed class SimulationProjectionService
|
|||||||
faction.StrategicState,
|
faction.StrategicState,
|
||||||
faction.DecisionLog,
|
faction.DecisionLog,
|
||||||
faction.Commanders)).ToList(),
|
faction.Commanders)).ToList(),
|
||||||
ToPlayerFactionSnapshot(world.PlayerFaction),
|
|
||||||
ToGeopoliticalStateSnapshot(world.Geopolitics));
|
ToGeopoliticalStateSnapshot(world.Geopolitics));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +250,11 @@ internal sealed class SimulationProjectionService
|
|||||||
celestial.LastDeltaSignature = BuildCelestialSignature(celestial);
|
celestial.LastDeltaSignature = BuildCelestialSignature(celestial);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var anchor in world.Anchors)
|
||||||
|
{
|
||||||
|
anchor.LastDeltaSignature = BuildAnchorSignature(anchor);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var station in world.Stations)
|
foreach (var station in world.Stations)
|
||||||
{
|
{
|
||||||
station.LastDeltaSignature = BuildStationSignature(world, station);
|
station.LastDeltaSignature = BuildStationSignature(world, station);
|
||||||
@@ -276,11 +290,6 @@ internal sealed class SimulationProjectionService
|
|||||||
faction.LastDeltaSignature = BuildFactionSignature(faction, FindFactionCommander(world, faction.Id));
|
faction.LastDeltaSignature = BuildFactionSignature(faction, FindFactionCommander(world, faction.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (world.PlayerFaction is not null)
|
|
||||||
{
|
|
||||||
world.PlayerFaction.LastDeltaSignature = BuildPlayerFactionSignature(world.PlayerFaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (world.Geopolitics is not null)
|
if (world.Geopolitics is not null)
|
||||||
{
|
{
|
||||||
world.Geopolitics.LastDeltaSignature = BuildGeopoliticalSignature(world.Geopolitics);
|
world.Geopolitics.LastDeltaSignature = BuildGeopoliticalSignature(world.Geopolitics);
|
||||||
@@ -305,6 +314,24 @@ internal sealed class SimulationProjectionService
|
|||||||
return deltas;
|
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)
|
private static IReadOnlyList<CelestialDelta> BuildCelestialDeltas(SimulationWorld world)
|
||||||
{
|
{
|
||||||
var deltas = new List<CelestialDelta>();
|
var deltas = new List<CelestialDelta>();
|
||||||
@@ -450,23 +477,6 @@ internal sealed class SimulationProjectionService
|
|||||||
return deltas;
|
return deltas;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PlayerFactionSnapshot? BuildPlayerFactionDelta(SimulationWorld world)
|
|
||||||
{
|
|
||||||
if (world.PlayerFaction is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var signature = BuildPlayerFactionSignature(world.PlayerFaction);
|
|
||||||
if (signature == world.PlayerFaction.LastDeltaSignature)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
world.PlayerFaction.LastDeltaSignature = signature;
|
|
||||||
return ToPlayerFactionSnapshot(world.PlayerFaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static GeopoliticalStateSnapshot? BuildGeopoliticsDelta(SimulationWorld world)
|
private static GeopoliticalStateSnapshot? BuildGeopoliticsDelta(SimulationWorld world)
|
||||||
{
|
{
|
||||||
if (world.Geopolitics is null)
|
if (world.Geopolitics is null)
|
||||||
@@ -490,17 +500,30 @@ internal sealed class SimulationProjectionService
|
|||||||
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
|
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
|
||||||
|
|
||||||
private static string BuildNodeSignature(ResourceNodeRuntime node) =>
|
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) =>
|
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)
|
private static string BuildStationSignature(SimulationWorld world, StationRuntime station)
|
||||||
{
|
{
|
||||||
var processes = ToStationActionProgressSnapshots(world, station);
|
var processes = ToStationActionProgressSnapshots(world, station);
|
||||||
return string.Join("|",
|
return string.Join("|",
|
||||||
station.SystemId,
|
station.SystemId,
|
||||||
station.CelestialId ?? "none",
|
station.AnchorId ?? "none",
|
||||||
station.CommanderId ?? "none",
|
station.CommanderId ?? "none",
|
||||||
station.PolicySetId ?? "none",
|
station.PolicySetId ?? "none",
|
||||||
BuildInventorySignature(station.Inventory),
|
BuildInventorySignature(station.Inventory),
|
||||||
@@ -519,10 +542,10 @@ internal sealed class SimulationProjectionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildClaimSignature(ClaimRuntime claim) =>
|
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) =>
|
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) =>
|
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}";
|
$"{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.TargetPosition.Z.ToString("0.###"),
|
||||||
ship.State.ToContractValue(),
|
ship.State.ToContractValue(),
|
||||||
string.Join(",", ship.OrderQueue
|
string.Join(",", ship.OrderQueue
|
||||||
.OrderByDescending(order => order.Priority)
|
.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}")),
|
||||||
.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}")),
|
|
||||||
ship.DefaultBehavior.Kind,
|
ship.DefaultBehavior.Kind,
|
||||||
ship.DefaultBehavior.TargetEntityId ?? "none",
|
ship.DefaultBehavior.TargetEntityId ?? "none",
|
||||||
|
ship.DefaultBehavior.ItemId ?? "none",
|
||||||
ship.DefaultBehavior.TargetPosition?.X.ToString("0.###") ?? "none",
|
ship.DefaultBehavior.TargetPosition?.X.ToString("0.###") ?? "none",
|
||||||
ship.DefaultBehavior.TargetPosition?.Y.ToString("0.###") ?? "none",
|
ship.DefaultBehavior.TargetPosition?.Y.ToString("0.###") ?? "none",
|
||||||
ship.DefaultBehavior.TargetPosition?.Z.ToString("0.###") ?? "none",
|
ship.DefaultBehavior.TargetPosition?.Z.ToString("0.###") ?? "none",
|
||||||
@@ -568,23 +590,20 @@ internal sealed class SimulationProjectionService
|
|||||||
ship.CommanderId is not null && world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment is { } assignment
|
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}"
|
? $"{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",
|
: "no-assignment",
|
||||||
ship.ActivePlan?.Kind ?? "none",
|
|
||||||
ship.ActivePlan?.Status.ToContractValue() ?? "none",
|
|
||||||
ship.ActivePlan?.CurrentStepIndex.ToString(CultureInfo.InvariantCulture) ?? "-1",
|
|
||||||
string.Join(",",
|
string.Join(",",
|
||||||
ToActiveSubTaskSnapshots(ship).Select(subTask =>
|
ToActiveSubTaskSnapshots(ship).Select(subTask =>
|
||||||
$"{subTask.Id}:{subTask.Kind}:{subTask.Status}:{subTask.Progress:0.###}:{subTask.ElapsedSeconds:0.###}:{subTask.BlockingReason ?? "none"}")),
|
$"{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.DockedStationId ?? "none",
|
||||||
ship.CommanderId ?? "none",
|
ship.CommanderId ?? "none",
|
||||||
ship.PolicySetId ?? "none",
|
ship.PolicySetId ?? "none",
|
||||||
ship.SpatialState.SpaceLayer.ToContractValue(),
|
ship.SpatialState.SpaceLayer.ToContractValue(),
|
||||||
ship.SpatialState.CurrentCelestialId ?? "none",
|
ship.SpatialState.CurrentAnchorId ?? "none",
|
||||||
ship.SpatialState.MovementRegime.ToContractValue(),
|
ship.SpatialState.MovementRegime.ToContractValue(),
|
||||||
ship.SpatialState.DestinationNodeId ?? "none",
|
ship.SpatialState.DestinationAnchorId ?? "none",
|
||||||
ship.SpatialState.Transit?.Regime.ToContractValue() ?? "none",
|
ship.SpatialState.Transit?.Regime.ToContractValue() ?? "none",
|
||||||
ship.SpatialState.Transit?.OriginNodeId ?? "none",
|
ship.SpatialState.Transit?.OriginAnchorId ?? "none",
|
||||||
ship.SpatialState.Transit?.DestinationNodeId ?? "none",
|
ship.SpatialState.Transit?.DestinationAnchorId ?? "none",
|
||||||
ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0",
|
ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0",
|
||||||
GetShipCargoAmount(ship).ToString("0.###"),
|
GetShipCargoAmount(ship).ToString("0.###"),
|
||||||
ship.Skills.Navigation.ToString(CultureInfo.InvariantCulture),
|
ship.Skills.Navigation.ToString(CultureInfo.InvariantCulture),
|
||||||
@@ -593,7 +612,9 @@ internal sealed class SimulationProjectionService
|
|||||||
ship.Skills.Combat.ToString(CultureInfo.InvariantCulture),
|
ship.Skills.Combat.ToString(CultureInfo.InvariantCulture),
|
||||||
ship.Skills.Construction.ToString(CultureInfo.InvariantCulture),
|
ship.Skills.Construction.ToString(CultureInfo.InvariantCulture),
|
||||||
ship.Health.ToString("0.###"),
|
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) =>
|
private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
|
||||||
string.Join(",",
|
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}";
|
return $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}|{assignmentSig}|{strategicSig}|{doctrineSig}|{decisionSig}|{theaterSig}|{campaignSig}|{objectiveSig}|{reservationSig}|{productionSig}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildPlayerFactionSignature(PlayerFactionRuntime player)
|
|
||||||
{
|
|
||||||
var intentSig = $"{player.StrategicIntent.StrategicPosture}:{player.StrategicIntent.EconomicPosture}:{player.StrategicIntent.MilitaryPosture}:{player.StrategicIntent.LogisticsPosture}:{player.StrategicIntent.DesiredReserveRatio:0.###}";
|
|
||||||
var registrySig = string.Join("|",
|
|
||||||
player.AssetRegistry.ShipIds.Count,
|
|
||||||
player.AssetRegistry.StationIds.Count,
|
|
||||||
player.AssetRegistry.CommanderIds.Count,
|
|
||||||
player.AssetRegistry.FleetIds.Count,
|
|
||||||
player.AssetRegistry.TaskForceIds.Count,
|
|
||||||
player.AssetRegistry.StationGroupIds.Count,
|
|
||||||
player.AssetRegistry.EconomicRegionIds.Count,
|
|
||||||
player.AssetRegistry.FrontIds.Count,
|
|
||||||
player.AssetRegistry.ReserveIds.Count);
|
|
||||||
var orgSig = string.Join("|",
|
|
||||||
player.Fleets.Count,
|
|
||||||
player.TaskForces.Count,
|
|
||||||
player.StationGroups.Count,
|
|
||||||
player.EconomicRegions.Count,
|
|
||||||
player.Fronts.Count,
|
|
||||||
player.Reserves.Count,
|
|
||||||
player.Policies.Count,
|
|
||||||
player.AutomationPolicies.Count,
|
|
||||||
player.ReinforcementPolicies.Count,
|
|
||||||
player.ProductionPrograms.Count,
|
|
||||||
player.Directives.Count,
|
|
||||||
player.Assignments.Count,
|
|
||||||
player.Alerts.Count);
|
|
||||||
var policySig = string.Join(";",
|
|
||||||
player.Policies.OrderBy(policy => policy.Id, StringComparer.Ordinal)
|
|
||||||
.Select(policy => $"{policy.Id}:{policy.ScopeKind}:{policy.ScopeId}:{policy.PolicySetId}:{policy.TradeAccessPolicy}:{policy.DockingAccessPolicy}:{policy.ConstructionAccessPolicy}:{policy.OperationalRangePolicy}:{policy.CombatEngagementPolicy}:{policy.AvoidHostileSystems}:{policy.FleeHullRatio:0.###}:{policy.UpdatedAtUtc.UtcTicks}"));
|
|
||||||
var automationSig = string.Join(";",
|
|
||||||
player.AutomationPolicies.OrderBy(policy => policy.Id, StringComparer.Ordinal)
|
|
||||||
.Select(policy => $"{policy.Id}:{policy.ScopeKind}:{policy.ScopeId}:{policy.Enabled}:{policy.BehaviorKind}:{policy.UseOrders}:{policy.StagingOrderKind}:{policy.MaxSystemRange}:{policy.KnownStationsOnly}:{policy.Radius:0.###}:{policy.WaitSeconds:0.###}:{policy.PreferredItemId}:{policy.UpdatedAtUtc.UtcTicks}"));
|
|
||||||
var directiveSig = string.Join(";",
|
|
||||||
player.Directives.OrderBy(directive => directive.Id, StringComparer.Ordinal)
|
|
||||||
.Select(directive => $"{directive.Id}:{directive.ScopeKind}:{directive.ScopeId}:{directive.Kind}:{directive.BehaviorKind}:{directive.UseOrders}:{directive.StagingOrderKind}:{directive.TargetEntityId}:{directive.TargetSystemId}:{directive.ItemId}:{directive.Priority}:{directive.UpdatedAtUtc.UtcTicks}"));
|
|
||||||
var assignmentSig = string.Join(";",
|
|
||||||
player.Assignments.OrderBy(assignment => assignment.Id, StringComparer.Ordinal)
|
|
||||||
.Select(assignment => $"{assignment.Id}:{assignment.AssetKind}:{assignment.AssetId}:{assignment.FleetId}:{assignment.TaskForceId}:{assignment.StationGroupId}:{assignment.EconomicRegionId}:{assignment.FrontId}:{assignment.ReserveId}:{assignment.DirectiveId}:{assignment.PolicyId}:{assignment.AutomationPolicyId}:{assignment.Role}:{assignment.Status}:{assignment.UpdatedAtUtc.UtcTicks}"));
|
|
||||||
var decisionSig = string.Join(",", player.DecisionLog.Select(entry => entry.Id));
|
|
||||||
var orgDetailSig = string.Join(";",
|
|
||||||
player.Fleets.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"fleet:{entry.Id}:{entry.FrontId}:{entry.HomeSystemId}:{entry.HomeStationId}:{entry.CommanderId}:{entry.UpdatedAtUtc.UtcTicks}")
|
|
||||||
.Concat(player.TaskForces.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"task-force:{entry.Id}:{entry.FleetId}:{entry.FrontId}:{entry.CommanderId}:{entry.UpdatedAtUtc.UtcTicks}"))
|
|
||||||
.Concat(player.StationGroups.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"station-group:{entry.Id}:{entry.EconomicRegionId}:{entry.UpdatedAtUtc.UtcTicks}"))
|
|
||||||
.Concat(player.EconomicRegions.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"economic-region:{entry.Id}:{entry.SharedEconomicRegionId}:{entry.Role}:{entry.UpdatedAtUtc.UtcTicks}"))
|
|
||||||
.Concat(player.Fronts.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"front:{entry.Id}:{entry.SharedFrontLineId}:{entry.TargetFactionId}:{entry.Priority:0.###}:{entry.UpdatedAtUtc.UtcTicks}"))
|
|
||||||
.Concat(player.Reserves.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"reserve:{entry.Id}:{entry.HomeSystemId}:{entry.UpdatedAtUtc.UtcTicks}")));
|
|
||||||
var alertSig = string.Join(";",
|
|
||||||
player.Alerts.OrderBy(alert => alert.Id, StringComparer.Ordinal)
|
|
||||||
.Select(alert => $"{alert.Id}:{alert.Kind}:{alert.Severity}:{alert.AssetKind}:{alert.AssetId}:{alert.RelatedDirectiveId}:{alert.Status}:{alert.CreatedAtUtc.UtcTicks}"));
|
|
||||||
return $"{player.SovereignFactionId}|{player.Status}|{intentSig}|{registrySig}|{orgSig}|{policySig}|{automationSig}|{directiveSig}|{assignmentSig}|{decisionSig}|{orgDetailSig}|{alertSig}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildGeopoliticalSignature(GeopoliticalStateRuntime state)
|
private static string BuildGeopoliticalSignature(GeopoliticalStateRuntime state)
|
||||||
{
|
{
|
||||||
var diplomacySig = string.Join(";",
|
var diplomacySig = string.Join(";",
|
||||||
@@ -728,13 +696,33 @@ internal sealed class SimulationProjectionService
|
|||||||
|
|
||||||
private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new(
|
private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new(
|
||||||
node.Id,
|
node.Id,
|
||||||
|
node.AnchorId,
|
||||||
node.SystemId,
|
node.SystemId,
|
||||||
ToDto(node.Position),
|
ToDto(node.Position),
|
||||||
node.CelestialId,
|
node.LocalSpaceRadius,
|
||||||
node.SourceKind,
|
node.SourceKind,
|
||||||
node.OreRemaining,
|
node.OreRemaining,
|
||||||
node.MaxOre,
|
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(
|
private static CelestialDelta ToCelestialDelta(CelestialRuntime celestial) => new(
|
||||||
celestial.Id,
|
celestial.Id,
|
||||||
@@ -742,7 +730,7 @@ internal sealed class SimulationProjectionService
|
|||||||
celestial.Kind.ToContractValue(),
|
celestial.Kind.ToContractValue(),
|
||||||
ToDto(celestial.Position),
|
ToDto(celestial.Position),
|
||||||
celestial.LocalSpaceRadius,
|
celestial.LocalSpaceRadius,
|
||||||
celestial.ParentNodeId,
|
celestial.ParentAnchorId,
|
||||||
celestial.OccupyingStructureId,
|
celestial.OccupyingStructureId,
|
||||||
celestial.OrbitReferenceId);
|
celestial.OrbitReferenceId);
|
||||||
|
|
||||||
@@ -752,8 +740,8 @@ internal sealed class SimulationProjectionService
|
|||||||
station.Category,
|
station.Category,
|
||||||
station.Objective,
|
station.Objective,
|
||||||
station.SystemId,
|
station.SystemId,
|
||||||
|
station.AnchorId,
|
||||||
ToDto(station.Position),
|
ToDto(station.Position),
|
||||||
station.CelestialId,
|
|
||||||
station.Color,
|
station.Color,
|
||||||
station.DockedShipIds.Count,
|
station.DockedShipIds.Count,
|
||||||
station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||||
@@ -801,7 +789,7 @@ internal sealed class SimulationProjectionService
|
|||||||
.Select(storageKind => new StationStorageUsageSnapshot(
|
.Select(storageKind => new StationStorageUsageSnapshot(
|
||||||
storageKind.ToDataValue(),
|
storageKind.ToDataValue(),
|
||||||
station.Inventory
|
station.Inventory
|
||||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind)
|
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageKind)
|
||||||
.Sum(entry => entry.Value),
|
.Sum(entry => entry.Value),
|
||||||
GetStationStorageCapacity(world, station, storageKind)))
|
GetStationStorageCapacity(world, station, storageKind)))
|
||||||
.Where(snapshot => snapshot.Capacity > 0.01f)
|
.Where(snapshot => snapshot.Capacity > 0.01f)
|
||||||
@@ -812,7 +800,7 @@ internal sealed class SimulationProjectionService
|
|||||||
claim.Id,
|
claim.Id,
|
||||||
claim.FactionId,
|
claim.FactionId,
|
||||||
claim.SystemId,
|
claim.SystemId,
|
||||||
claim.CelestialId,
|
claim.AnchorId,
|
||||||
claim.State,
|
claim.State,
|
||||||
claim.Health,
|
claim.Health,
|
||||||
claim.PlacedAtUtc,
|
claim.PlacedAtUtc,
|
||||||
@@ -822,7 +810,7 @@ internal sealed class SimulationProjectionService
|
|||||||
site.Id,
|
site.Id,
|
||||||
site.FactionId,
|
site.FactionId,
|
||||||
site.SystemId,
|
site.SystemId,
|
||||||
site.CelestialId,
|
site.AnchorId,
|
||||||
site.TargetKind,
|
site.TargetKind,
|
||||||
site.TargetDefinitionId,
|
site.TargetDefinitionId,
|
||||||
site.BlueprintId,
|
site.BlueprintId,
|
||||||
@@ -882,10 +870,11 @@ internal sealed class SimulationProjectionService
|
|||||||
|
|
||||||
return new ShipDelta(
|
return new ShipDelta(
|
||||||
ship.Id,
|
ship.Id,
|
||||||
ship.Definition.Label,
|
ship.Definition.Name,
|
||||||
ship.Definition.Kind,
|
ship.Definition.Purpose.ToDataValue(),
|
||||||
ship.Definition.Class,
|
ship.Definition.Type.ToDataValue(),
|
||||||
ship.SystemId,
|
ship.SystemId,
|
||||||
|
ship.SpatialState.CurrentAnchorId,
|
||||||
ToDto(ship.Position),
|
ToDto(ship.Position),
|
||||||
ToDto(ship.Velocity),
|
ToDto(ship.Velocity),
|
||||||
ToDto(ship.TargetPosition),
|
ToDto(ship.TargetPosition),
|
||||||
@@ -894,19 +883,22 @@ internal sealed class SimulationProjectionService
|
|||||||
ToDefaultBehaviorSnapshot(ship.DefaultBehavior),
|
ToDefaultBehaviorSnapshot(ship.DefaultBehavior),
|
||||||
ToShipAssignmentSnapshot(commander),
|
ToShipAssignmentSnapshot(commander),
|
||||||
new ShipSkillProfileSnapshot(ship.Skills.Navigation, ship.Skills.Trade, ship.Skills.Mining, ship.Skills.Combat, ship.Skills.Construction),
|
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),
|
ToActiveSubTaskSnapshots(ship),
|
||||||
ship.ControlSourceKind,
|
ship.ControlSourceKind,
|
||||||
ship.ControlSourceId,
|
ship.ControlSourceId,
|
||||||
ship.ControlReason,
|
ship.ControlReason,
|
||||||
ship.LastReplanReason,
|
ship.LastReplanReason,
|
||||||
ship.LastAccessFailureReason,
|
ship.LastAccessFailureReason,
|
||||||
ship.SpatialState.CurrentCelestialId,
|
|
||||||
ship.DockedStationId,
|
ship.DockedStationId,
|
||||||
ship.CommanderId,
|
ship.CommanderId,
|
||||||
ship.PolicySetId,
|
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).Speed,
|
||||||
ToShipTravelSpeed(ship).Unit,
|
ToShipTravelSpeed(ship).Unit,
|
||||||
@@ -923,7 +915,7 @@ internal sealed class SimulationProjectionService
|
|||||||
{
|
{
|
||||||
MovementRegimeKind.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"),
|
MovementRegimeKind.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"),
|
||||||
MovementRegimeKind.Warp => (ship.State == ShipState.Warping ? ship.Definition.WarpSpeed : 0f, "AU/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) =>
|
private static IReadOnlyList<ShipOrderSnapshot> ToShipOrderSnapshots(ShipRuntime ship) =>
|
||||||
ship.OrderQueue
|
ship.OrderQueue
|
||||||
.OrderByDescending(order => order.Priority)
|
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
|
||||||
.Select(order => new ShipOrderSnapshot(
|
.Select(order => new ShipOrderSnapshot(
|
||||||
order.Id,
|
order.Id,
|
||||||
order.Kind,
|
order.Kind,
|
||||||
|
order.SourceKind.ToContractValue(),
|
||||||
|
order.SourceId,
|
||||||
order.Status.ToContractValue(),
|
order.Status.ToContractValue(),
|
||||||
order.Priority,
|
order.Priority,
|
||||||
order.InterruptCurrentPlan,
|
order.InterruptCurrentPlan,
|
||||||
@@ -952,7 +944,7 @@ internal sealed class SimulationProjectionService
|
|||||||
order.SourceStationId,
|
order.SourceStationId,
|
||||||
order.DestinationStationId,
|
order.DestinationStationId,
|
||||||
order.ItemId,
|
order.ItemId,
|
||||||
order.NodeId,
|
order.AnchorId,
|
||||||
order.ConstructionSiteId,
|
order.ConstructionSiteId,
|
||||||
order.ModuleId,
|
order.ModuleId,
|
||||||
order.WaitSeconds,
|
order.WaitSeconds,
|
||||||
@@ -969,8 +961,8 @@ internal sealed class SimulationProjectionService
|
|||||||
behavior.HomeStationId,
|
behavior.HomeStationId,
|
||||||
behavior.AreaSystemId,
|
behavior.AreaSystemId,
|
||||||
behavior.TargetEntityId,
|
behavior.TargetEntityId,
|
||||||
behavior.PreferredItemId,
|
behavior.ItemId,
|
||||||
behavior.PreferredNodeId,
|
behavior.PreferredAnchorId,
|
||||||
behavior.PreferredConstructionSiteId,
|
behavior.PreferredConstructionSiteId,
|
||||||
behavior.PreferredModuleId,
|
behavior.PreferredModuleId,
|
||||||
behavior.TargetPosition is null ? null : ToDto(behavior.TargetPosition.Value),
|
behavior.TargetPosition is null ? null : ToDto(behavior.TargetPosition.Value),
|
||||||
@@ -993,7 +985,7 @@ internal sealed class SimulationProjectionService
|
|||||||
template.SourceStationId,
|
template.SourceStationId,
|
||||||
template.DestinationStationId,
|
template.DestinationStationId,
|
||||||
template.ItemId,
|
template.ItemId,
|
||||||
template.NodeId,
|
template.AnchorId,
|
||||||
template.ConstructionSiteId,
|
template.ConstructionSiteId,
|
||||||
template.ModuleId,
|
template.ModuleId,
|
||||||
template.WaitSeconds,
|
template.WaitSeconds,
|
||||||
@@ -1028,48 +1020,18 @@ internal sealed class SimulationProjectionService
|
|||||||
assignment.UpdatedAtUtc);
|
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) =>
|
private static ShipSubTaskSnapshot ToShipSubTaskSnapshot(ShipSubTaskRuntime subTask) =>
|
||||||
new(
|
new(
|
||||||
subTask.Id,
|
subTask.Id,
|
||||||
subTask.Kind,
|
subTask.Kind,
|
||||||
subTask.Status.ToContractValue(),
|
subTask.Status.ToContractValue(),
|
||||||
subTask.Summary,
|
subTask.Summary,
|
||||||
subTask.TargetEntityId,
|
subTask.TargetEntityId,
|
||||||
subTask.TargetSystemId,
|
subTask.TargetSystemId,
|
||||||
subTask.TargetNodeId,
|
subTask.TargetAnchorId,
|
||||||
subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value),
|
subTask.TargetResourceNodeId,
|
||||||
|
subTask.TargetResourceDepositId,
|
||||||
|
subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value),
|
||||||
subTask.ItemId,
|
subTask.ItemId,
|
||||||
subTask.ModuleId,
|
subTask.ModuleId,
|
||||||
subTask.Threshold,
|
subTask.Threshold,
|
||||||
@@ -1081,23 +1043,12 @@ internal sealed class SimulationProjectionService
|
|||||||
|
|
||||||
private static IReadOnlyList<ShipSubTaskSnapshot> ToActiveSubTaskSnapshots(ShipRuntime ship)
|
private static IReadOnlyList<ShipSubTaskSnapshot> ToActiveSubTaskSnapshots(ShipRuntime ship)
|
||||||
{
|
{
|
||||||
var step = GetCurrentShipStep(ship);
|
return ship.ActiveSubTasks
|
||||||
if (step is null)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return step.SubTasks
|
|
||||||
.Where(subTask => subTask.Status is WorkStatus.Pending or WorkStatus.Active or WorkStatus.Blocked)
|
.Where(subTask => subTask.Status is WorkStatus.Pending or WorkStatus.Active or WorkStatus.Blocked)
|
||||||
.Select(ToShipSubTaskSnapshot)
|
.Select(ToShipSubTaskSnapshot)
|
||||||
.ToList();
|
.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)
|
private static CommanderAssignmentSnapshot ToCommanderAssignmentSnapshot(CommanderRuntime commander)
|
||||||
{
|
{
|
||||||
var assignment = commander.Assignment;
|
var assignment = commander.Assignment;
|
||||||
@@ -1385,252 +1336,6 @@ internal sealed class SimulationProjectionService
|
|||||||
entry.OccurredAtUtc))
|
entry.OccurredAtUtc))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
private static PlayerFactionSnapshot? ToPlayerFactionSnapshot(PlayerFactionRuntime? player)
|
|
||||||
{
|
|
||||||
if (player is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PlayerFactionSnapshot(
|
|
||||||
player.Id,
|
|
||||||
player.Label,
|
|
||||||
player.SovereignFactionId,
|
|
||||||
player.Status,
|
|
||||||
player.CreatedAtUtc,
|
|
||||||
player.UpdatedAtUtc,
|
|
||||||
new PlayerAssetRegistrySnapshot(
|
|
||||||
player.AssetRegistry.ShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
player.AssetRegistry.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
player.AssetRegistry.CommanderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
player.AssetRegistry.ClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
player.AssetRegistry.ConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
player.AssetRegistry.PolicySetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
player.AssetRegistry.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
player.AssetRegistry.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
player.AssetRegistry.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
player.AssetRegistry.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
player.AssetRegistry.EconomicRegionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
player.AssetRegistry.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
player.AssetRegistry.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList()),
|
|
||||||
new PlayerStrategicIntentSnapshot(
|
|
||||||
player.StrategicIntent.StrategicPosture,
|
|
||||||
player.StrategicIntent.EconomicPosture,
|
|
||||||
player.StrategicIntent.MilitaryPosture,
|
|
||||||
player.StrategicIntent.LogisticsPosture,
|
|
||||||
player.StrategicIntent.DesiredReserveRatio,
|
|
||||||
player.StrategicIntent.AllowDelegatedCombatAutomation,
|
|
||||||
player.StrategicIntent.AllowDelegatedEconomicAutomation,
|
|
||||||
player.StrategicIntent.Notes),
|
|
||||||
player.Fleets.Select(fleet => new PlayerFleetSnapshot(
|
|
||||||
fleet.Id,
|
|
||||||
fleet.Label,
|
|
||||||
fleet.Status,
|
|
||||||
fleet.Role,
|
|
||||||
fleet.CommanderId,
|
|
||||||
fleet.FrontId,
|
|
||||||
fleet.HomeSystemId,
|
|
||||||
fleet.HomeStationId,
|
|
||||||
fleet.PolicyId,
|
|
||||||
fleet.AutomationPolicyId,
|
|
||||||
fleet.ReinforcementPolicyId,
|
|
||||||
fleet.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
fleet.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
fleet.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
fleet.UpdatedAtUtc)).ToList(),
|
|
||||||
player.TaskForces.Select(taskForce => new PlayerTaskForceSnapshot(
|
|
||||||
taskForce.Id,
|
|
||||||
taskForce.Label,
|
|
||||||
taskForce.Status,
|
|
||||||
taskForce.Role,
|
|
||||||
taskForce.FleetId,
|
|
||||||
taskForce.CommanderId,
|
|
||||||
taskForce.FrontId,
|
|
||||||
taskForce.PolicyId,
|
|
||||||
taskForce.AutomationPolicyId,
|
|
||||||
taskForce.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
taskForce.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
taskForce.UpdatedAtUtc)).ToList(),
|
|
||||||
player.StationGroups.Select(group => new PlayerStationGroupSnapshot(
|
|
||||||
group.Id,
|
|
||||||
group.Label,
|
|
||||||
group.Status,
|
|
||||||
group.Role,
|
|
||||||
group.EconomicRegionId,
|
|
||||||
group.PolicyId,
|
|
||||||
group.AutomationPolicyId,
|
|
||||||
group.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
group.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
group.FocusItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
group.UpdatedAtUtc)).ToList(),
|
|
||||||
player.EconomicRegions.Select(region => new PlayerEconomicRegionSnapshot(
|
|
||||||
region.Id,
|
|
||||||
region.Label,
|
|
||||||
region.Status,
|
|
||||||
region.Role,
|
|
||||||
region.SharedEconomicRegionId,
|
|
||||||
region.PolicyId,
|
|
||||||
region.AutomationPolicyId,
|
|
||||||
region.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
region.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
region.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
region.UpdatedAtUtc)).ToList(),
|
|
||||||
player.Fronts.Select(front => new PlayerFrontSnapshot(
|
|
||||||
front.Id,
|
|
||||||
front.Label,
|
|
||||||
front.Status,
|
|
||||||
front.Priority,
|
|
||||||
front.Posture,
|
|
||||||
front.SharedFrontLineId,
|
|
||||||
front.TargetFactionId,
|
|
||||||
front.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
front.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
front.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
front.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
front.UpdatedAtUtc)).ToList(),
|
|
||||||
player.Reserves.Select(reserve => new PlayerReserveGroupSnapshot(
|
|
||||||
reserve.Id,
|
|
||||||
reserve.Label,
|
|
||||||
reserve.Status,
|
|
||||||
reserve.ReserveKind,
|
|
||||||
reserve.HomeSystemId,
|
|
||||||
reserve.PolicyId,
|
|
||||||
reserve.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
reserve.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
reserve.UpdatedAtUtc)).ToList(),
|
|
||||||
player.Policies.Select(policy => new PlayerFactionPolicySnapshot(
|
|
||||||
policy.Id,
|
|
||||||
policy.Label,
|
|
||||||
policy.ScopeKind,
|
|
||||||
policy.ScopeId,
|
|
||||||
policy.PolicySetId,
|
|
||||||
policy.AllowDelegatedCombat,
|
|
||||||
policy.AllowDelegatedTrade,
|
|
||||||
policy.ReserveCreditsRatio,
|
|
||||||
policy.ReserveMilitaryRatio,
|
|
||||||
policy.TradeAccessPolicy,
|
|
||||||
policy.DockingAccessPolicy,
|
|
||||||
policy.ConstructionAccessPolicy,
|
|
||||||
policy.OperationalRangePolicy,
|
|
||||||
policy.CombatEngagementPolicy,
|
|
||||||
policy.AvoidHostileSystems,
|
|
||||||
policy.FleeHullRatio,
|
|
||||||
policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
|
||||||
policy.Notes,
|
|
||||||
policy.UpdatedAtUtc)).ToList(),
|
|
||||||
player.AutomationPolicies.Select(policy => new PlayerAutomationPolicySnapshot(
|
|
||||||
policy.Id,
|
|
||||||
policy.Label,
|
|
||||||
policy.ScopeKind,
|
|
||||||
policy.ScopeId,
|
|
||||||
policy.Enabled,
|
|
||||||
policy.BehaviorKind,
|
|
||||||
policy.UseOrders,
|
|
||||||
policy.StagingOrderKind,
|
|
||||||
policy.MaxSystemRange,
|
|
||||||
policy.KnownStationsOnly,
|
|
||||||
policy.Radius,
|
|
||||||
policy.WaitSeconds,
|
|
||||||
policy.PreferredItemId,
|
|
||||||
policy.Notes,
|
|
||||||
policy.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(),
|
|
||||||
policy.UpdatedAtUtc)).ToList(),
|
|
||||||
player.ReinforcementPolicies.Select(policy => new PlayerReinforcementPolicySnapshot(
|
|
||||||
policy.Id,
|
|
||||||
policy.Label,
|
|
||||||
policy.ScopeKind,
|
|
||||||
policy.ScopeId,
|
|
||||||
policy.ShipKind,
|
|
||||||
policy.DesiredAssetCount,
|
|
||||||
policy.MinimumReserveCount,
|
|
||||||
policy.AutoTransferReserves,
|
|
||||||
policy.AutoQueueProduction,
|
|
||||||
policy.SourceReserveId,
|
|
||||||
policy.TargetFrontId,
|
|
||||||
policy.Notes,
|
|
||||||
policy.UpdatedAtUtc)).ToList(),
|
|
||||||
player.ProductionPrograms.Select(program => new PlayerProductionProgramSnapshot(
|
|
||||||
program.Id,
|
|
||||||
program.Label,
|
|
||||||
program.Status,
|
|
||||||
program.Kind,
|
|
||||||
program.TargetShipKind,
|
|
||||||
program.TargetModuleId,
|
|
||||||
program.TargetItemId,
|
|
||||||
program.TargetCount,
|
|
||||||
program.CurrentCount,
|
|
||||||
program.StationGroupId,
|
|
||||||
program.ReinforcementPolicyId,
|
|
||||||
program.Notes,
|
|
||||||
program.UpdatedAtUtc)).ToList(),
|
|
||||||
player.Directives.Select(directive => new PlayerDirectiveSnapshot(
|
|
||||||
directive.Id,
|
|
||||||
directive.Label,
|
|
||||||
directive.Status,
|
|
||||||
directive.Kind,
|
|
||||||
directive.ScopeKind,
|
|
||||||
directive.ScopeId,
|
|
||||||
directive.TargetEntityId,
|
|
||||||
directive.TargetSystemId,
|
|
||||||
directive.TargetPosition is null ? null : ToDto(directive.TargetPosition.Value),
|
|
||||||
directive.HomeSystemId,
|
|
||||||
directive.HomeStationId,
|
|
||||||
directive.SourceStationId,
|
|
||||||
directive.DestinationStationId,
|
|
||||||
directive.BehaviorKind,
|
|
||||||
directive.UseOrders,
|
|
||||||
directive.StagingOrderKind,
|
|
||||||
directive.ItemId,
|
|
||||||
directive.PreferredNodeId,
|
|
||||||
directive.PreferredConstructionSiteId,
|
|
||||||
directive.PreferredModuleId,
|
|
||||||
directive.Priority,
|
|
||||||
directive.Radius,
|
|
||||||
directive.WaitSeconds,
|
|
||||||
directive.MaxSystemRange,
|
|
||||||
directive.KnownStationsOnly,
|
|
||||||
directive.PatrolPoints.Select(ToDto).ToList(),
|
|
||||||
directive.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(),
|
|
||||||
directive.PolicyId,
|
|
||||||
directive.AutomationPolicyId,
|
|
||||||
directive.Notes,
|
|
||||||
directive.CreatedAtUtc,
|
|
||||||
directive.UpdatedAtUtc)).ToList(),
|
|
||||||
player.Assignments.Select(assignment => new PlayerAssignmentSnapshot(
|
|
||||||
assignment.Id,
|
|
||||||
assignment.AssetKind,
|
|
||||||
assignment.AssetId,
|
|
||||||
assignment.FleetId,
|
|
||||||
assignment.TaskForceId,
|
|
||||||
assignment.StationGroupId,
|
|
||||||
assignment.EconomicRegionId,
|
|
||||||
assignment.FrontId,
|
|
||||||
assignment.ReserveId,
|
|
||||||
assignment.DirectiveId,
|
|
||||||
assignment.PolicyId,
|
|
||||||
assignment.AutomationPolicyId,
|
|
||||||
assignment.Role,
|
|
||||||
assignment.Status,
|
|
||||||
assignment.UpdatedAtUtc)).ToList(),
|
|
||||||
player.DecisionLog.Select(entry => new PlayerDecisionLogEntrySnapshot(
|
|
||||||
entry.Id,
|
|
||||||
entry.Kind,
|
|
||||||
entry.Summary,
|
|
||||||
entry.RelatedEntityKind,
|
|
||||||
entry.RelatedEntityId,
|
|
||||||
entry.OccurredAtUtc)).ToList(),
|
|
||||||
player.Alerts.Select(alert => new PlayerAlertSnapshot(
|
|
||||||
alert.Id,
|
|
||||||
alert.Kind,
|
|
||||||
alert.Severity,
|
|
||||||
alert.Summary,
|
|
||||||
alert.AssetKind,
|
|
||||||
alert.AssetId,
|
|
||||||
alert.RelatedDirectiveId,
|
|
||||||
alert.Status,
|
|
||||||
alert.CreatedAtUtc)).ToList());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static GeopoliticalStateSnapshot? ToGeopoliticalStateSnapshot(GeopoliticalStateRuntime? state)
|
private static GeopoliticalStateSnapshot? ToGeopoliticalStateSnapshot(GeopoliticalStateRuntime? state)
|
||||||
{
|
{
|
||||||
if (state is null)
|
if (state is null)
|
||||||
@@ -1718,7 +1423,7 @@ internal sealed class SimulationProjectionService
|
|||||||
claim.SourceClaimId,
|
claim.SourceClaimId,
|
||||||
claim.FactionId,
|
claim.FactionId,
|
||||||
claim.SystemId,
|
claim.SystemId,
|
||||||
claim.CelestialId,
|
claim.AnchorId,
|
||||||
claim.Status,
|
claim.Status,
|
||||||
claim.ClaimKind,
|
claim.ClaimKind,
|
||||||
claim.ClaimStrength,
|
claim.ClaimStrength,
|
||||||
@@ -1874,15 +1579,15 @@ internal sealed class SimulationProjectionService
|
|||||||
private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new(
|
private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new(
|
||||||
state.SpaceLayer.ToContractValue(),
|
state.SpaceLayer.ToContractValue(),
|
||||||
state.CurrentSystemId,
|
state.CurrentSystemId,
|
||||||
state.CurrentCelestialId,
|
state.CurrentAnchorId,
|
||||||
state.LocalPosition is null ? null : ToDto(state.LocalPosition.Value),
|
state.LocalPosition is null ? null : ToDto(state.LocalPosition.Value),
|
||||||
state.SystemPosition is null ? null : ToDto(state.SystemPosition.Value),
|
state.SystemPosition is null ? null : ToDto(state.SystemPosition.Value),
|
||||||
state.MovementRegime.ToContractValue(),
|
state.MovementRegime.ToContractValue(),
|
||||||
state.DestinationNodeId,
|
state.DestinationAnchorId,
|
||||||
state.Transit is null ? null : new ShipTransitSnapshot(
|
state.Transit is null ? null : new ShipTransitSnapshot(
|
||||||
state.Transit.Regime.ToContractValue(),
|
state.Transit.Regime.ToContractValue(),
|
||||||
state.Transit.OriginNodeId,
|
state.Transit.OriginAnchorId,
|
||||||
state.Transit.DestinationNodeId,
|
state.Transit.DestinationAnchorId,
|
||||||
state.Transit.StartedAtUtc,
|
state.Transit.StartedAtUtc,
|
||||||
state.Transit.ArrivalDueAtUtc,
|
state.Transit.ArrivalDueAtUtc,
|
||||||
state.Transit.Progress));
|
state.Transit.Progress));
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FastEndpoints" Version="6.*" />
|
<PackageReference Include="FastEndpoints" Version="6.*" />
|
||||||
<PackageReference Include="FastEndpoints.Swagger" Version="6.*" />
|
<PackageReference Include="FastEndpoints.Swagger" Version="6.*" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-preview.7.25380.108" />
|
||||||
|
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ public sealed record StationSnapshot(
|
|||||||
string Category,
|
string Category,
|
||||||
string Objective,
|
string Objective,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
|
string? AnchorId,
|
||||||
Vector3Dto LocalPosition,
|
Vector3Dto LocalPosition,
|
||||||
string? CelestialId,
|
|
||||||
string Color,
|
string Color,
|
||||||
int DockedShips,
|
int DockedShips,
|
||||||
IReadOnlyList<string> DockedShipIds,
|
IReadOnlyList<string> DockedShipIds,
|
||||||
@@ -35,8 +35,8 @@ public sealed record StationDelta(
|
|||||||
string Category,
|
string Category,
|
||||||
string Objective,
|
string Objective,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
|
string? AnchorId,
|
||||||
Vector3Dto LocalPosition,
|
Vector3Dto LocalPosition,
|
||||||
string? CelestialId,
|
|
||||||
string Color,
|
string Color,
|
||||||
int DockedShips,
|
int DockedShips,
|
||||||
IReadOnlyList<string> DockedShipIds,
|
IReadOnlyList<string> DockedShipIds,
|
||||||
@@ -74,7 +74,7 @@ public sealed record ClaimSnapshot(
|
|||||||
string Id,
|
string Id,
|
||||||
string FactionId,
|
string FactionId,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
string CelestialId,
|
string AnchorId,
|
||||||
string State,
|
string State,
|
||||||
float Health,
|
float Health,
|
||||||
DateTimeOffset PlacedAtUtc,
|
DateTimeOffset PlacedAtUtc,
|
||||||
@@ -84,7 +84,7 @@ public sealed record ClaimDelta(
|
|||||||
string Id,
|
string Id,
|
||||||
string FactionId,
|
string FactionId,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
string CelestialId,
|
string AnchorId,
|
||||||
string State,
|
string State,
|
||||||
float Health,
|
float Health,
|
||||||
DateTimeOffset PlacedAtUtc,
|
DateTimeOffset PlacedAtUtc,
|
||||||
@@ -94,7 +94,7 @@ public sealed record ConstructionSiteSnapshot(
|
|||||||
string Id,
|
string Id,
|
||||||
string FactionId,
|
string FactionId,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
string CelestialId,
|
string AnchorId,
|
||||||
string TargetKind,
|
string TargetKind,
|
||||||
string TargetDefinitionId,
|
string TargetDefinitionId,
|
||||||
string? BlueprintId,
|
string? BlueprintId,
|
||||||
@@ -112,7 +112,7 @@ public sealed record ConstructionSiteDelta(
|
|||||||
string Id,
|
string Id,
|
||||||
string FactionId,
|
string FactionId,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
string CelestialId,
|
string AnchorId,
|
||||||
string TargetKind,
|
string TargetKind,
|
||||||
string TargetDefinitionId,
|
string TargetDefinitionId,
|
||||||
string? BlueprintId,
|
string? BlueprintId,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ public sealed class ClaimRuntime
|
|||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
public required string FactionId { get; init; }
|
public required string FactionId { get; init; }
|
||||||
public required string SystemId { 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 string? CommanderId { get; set; }
|
||||||
public DateTimeOffset PlacedAtUtc { get; init; }
|
public DateTimeOffset PlacedAtUtc { get; init; }
|
||||||
public DateTimeOffset ActivatesAtUtc { get; set; }
|
public DateTimeOffset ActivatesAtUtc { get; set; }
|
||||||
@@ -19,7 +19,7 @@ public sealed class ConstructionSiteRuntime
|
|||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
public required string FactionId { get; init; }
|
public required string FactionId { get; init; }
|
||||||
public required string SystemId { 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 TargetKind { get; init; }
|
||||||
public required string TargetDefinitionId { get; init; }
|
public required string TargetDefinitionId { get; init; }
|
||||||
public string? BlueprintId { get; set; }
|
public string? BlueprintId { get; set; }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public sealed class StationRuntime
|
|||||||
{
|
{
|
||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
public required string SystemId { get; init; }
|
public required string SystemId { get; init; }
|
||||||
|
public string? AnchorId { get; set; }
|
||||||
public required string Label { get; set; }
|
public required string Label { get; set; }
|
||||||
public string Category { get; set; } = "station";
|
public string Category { get; set; } = "station";
|
||||||
public string Objective { get; set; } = "general";
|
public string Objective { get; set; } = "general";
|
||||||
@@ -14,7 +15,6 @@ public sealed class StationRuntime
|
|||||||
public required Vector3 Position { get; set; }
|
public required Vector3 Position { get; set; }
|
||||||
public float Radius { get; set; } = 24f;
|
public float Radius { get; set; } = 24f;
|
||||||
public required string FactionId { get; init; }
|
public required string FactionId { get; init; }
|
||||||
public string? CelestialId { get; set; }
|
|
||||||
public string? CommanderId { get; set; }
|
public string? CommanderId { get; set; }
|
||||||
public string? PolicySetId { get; set; }
|
public string? PolicySetId { get; set; }
|
||||||
public List<StationModuleRuntime> Modules { get; } = [];
|
public List<StationModuleRuntime> Modules { get; } = [];
|
||||||
@@ -65,6 +65,14 @@ public class StationModuleRuntime
|
|||||||
Health = production.Hull,
|
Health = production.Hull,
|
||||||
MaxHealth = production.Hull,
|
MaxHealth = production.Hull,
|
||||||
},
|
},
|
||||||
|
BuildModuleDefinition build => new BuildStationModuleRuntime
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
ModuleId = build.Id,
|
||||||
|
ModuleType = build.ModuleType,
|
||||||
|
Health = build.Hull,
|
||||||
|
MaxHealth = build.Hull,
|
||||||
|
},
|
||||||
_ => new StationModuleRuntime
|
_ => new StationModuleRuntime
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
@@ -81,6 +89,10 @@ public sealed class ProductionStationModuleRuntime : StationModuleRuntime
|
|||||||
public IReadOnlyList<string> ProductItemIds { get; init; } = [];
|
public IReadOnlyList<string> ProductItemIds { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class BuildStationModuleRuntime : StationModuleRuntime
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class StorageStationModuleRuntime : StationModuleRuntime
|
public sealed class StorageStationModuleRuntime : StationModuleRuntime
|
||||||
{
|
{
|
||||||
public StorageKind StorageKind { get; init; }
|
public StorageKind StorageKind { get; init; }
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ internal sealed class InfrastructureSimulationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var used = station.Inventory
|
var used = station.Inventory
|
||||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoStorageKind == storageKind)
|
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageKind)
|
||||||
.Sum(entry => entry.Value);
|
.Sum(entry => entry.Value);
|
||||||
if (used / capacity >= 0.65f)
|
if (used / capacity >= 0.65f)
|
||||||
{
|
{
|
||||||
@@ -195,7 +195,7 @@ internal sealed class InfrastructureSimulationService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoStorageKind) is { } storageModuleId)
|
if (GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoKind) is { } storageModuleId)
|
||||||
{
|
{
|
||||||
yield return storageModuleId;
|
yield return storageModuleId;
|
||||||
}
|
}
|
||||||
@@ -203,14 +203,14 @@ internal sealed class InfrastructureSimulationService
|
|||||||
|
|
||||||
if (world.ModuleDefinitions.TryGetValue(recipe.ModuleId, out var moduleDefinition))
|
if (world.ModuleDefinitions.TryGetValue(recipe.ModuleId, out var moduleDefinition))
|
||||||
{
|
{
|
||||||
foreach (var productItemId in moduleDefinition.Products)
|
foreach (var productItemId in moduleDefinition.ProductItemIds)
|
||||||
{
|
{
|
||||||
if (!world.ItemDefinitions.TryGetValue(productItemId, out var itemDefinition))
|
if (!world.ItemDefinitions.TryGetValue(productItemId, out var itemDefinition))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoStorageKind) is { } storageModuleId)
|
if (GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoKind) is { } storageModuleId)
|
||||||
{
|
{
|
||||||
yield return storageModuleId;
|
yield return storageModuleId;
|
||||||
}
|
}
|
||||||
@@ -325,7 +325,7 @@ internal sealed class InfrastructureSimulationService
|
|||||||
|
|
||||||
var capacity = GetStationStorageCapacity(world, station, storageKind);
|
var capacity = GetStationStorageCapacity(world, station, storageKind);
|
||||||
var used = station.Inventory
|
var used = station.Inventory
|
||||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoStorageKind == storageKind)
|
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageKind)
|
||||||
.Sum(entry => entry.Value);
|
.Sum(entry => entry.Value);
|
||||||
var utilization = capacity <= 0.01f ? 0f : used / capacity;
|
var utilization = capacity <= 0.01f ? 0f : used / capacity;
|
||||||
|
|
||||||
@@ -621,7 +621,7 @@ internal sealed class InfrastructureSimulationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var score = 0f;
|
var score = 0f;
|
||||||
foreach (var productItemId in moduleDefinition.Products)
|
foreach (var productItemId in moduleDefinition.ProductItemIds)
|
||||||
{
|
{
|
||||||
if (!constructionDemandByItem.TryGetValue(productItemId, out var outstandingDemand) || outstandingDemand <= 0.01f)
|
if (!constructionDemandByItem.TryGetValue(productItemId, out var outstandingDemand) || outstandingDemand <= 0.01f)
|
||||||
{
|
{
|
||||||
@@ -689,12 +689,12 @@ internal sealed class InfrastructureSimulationService
|
|||||||
|
|
||||||
return recipe.Inputs.Any(input =>
|
return recipe.Inputs.Any(input =>
|
||||||
world.ItemDefinitions.TryGetValue(input.ItemId, out var itemDefinition)
|
world.ItemDefinitions.TryGetValue(input.ItemId, out var itemDefinition)
|
||||||
&& itemDefinition.CargoStorageKind == storageKind);
|
&& itemDefinition.CargoKind == storageKind);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool CommodityUsesStorageClass(SimulationWorld world, string commodityId, StorageKind storageKind) =>
|
private static bool CommodityUsesStorageClass(SimulationWorld world, string commodityId, StorageKind storageKind) =>
|
||||||
world.ItemDefinitions.TryGetValue(commodityId, out var itemDefinition)
|
world.ItemDefinitions.TryGetValue(commodityId, out var itemDefinition)
|
||||||
&& itemDefinition.CargoStorageKind == storageKind;
|
&& itemDefinition.CargoKind == storageKind;
|
||||||
|
|
||||||
private static bool CanStationAcceptStationOutputSoon(SimulationWorld world, StationRuntime station, string itemId, float amount)
|
private static bool CanStationAcceptStationOutputSoon(SimulationWorld world, StationRuntime station, string itemId, float amount)
|
||||||
{
|
{
|
||||||
@@ -703,7 +703,7 @@ internal sealed class InfrastructureSimulationService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemDefinition.CargoStorageKind is not { } storageKind)
|
if (itemDefinition.CargoKind is not { } storageKind)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -715,7 +715,7 @@ internal sealed class InfrastructureSimulationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var used = station.Inventory
|
var used = station.Inventory
|
||||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind)
|
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageKind)
|
||||||
.Sum(entry => entry.Value);
|
.Sum(entry => entry.Value);
|
||||||
return used + amount <= capacity * 0.95f;
|
return used + amount <= capacity * 0.95f;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using SpaceGame.Api.Shared.Runtime;
|
using SpaceGame.Api.Shared.Runtime;
|
||||||
|
using SpaceGame.Api.Ships.AI;
|
||||||
|
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||||
|
|
||||||
namespace SpaceGame.Api.Stations.Simulation;
|
namespace SpaceGame.Api.Stations.Simulation;
|
||||||
@@ -34,17 +36,16 @@ internal sealed class StationLifecycleService
|
|||||||
|
|
||||||
private void UpdateStationPopulation(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
private void UpdateStationPopulation(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||||
{
|
{
|
||||||
station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f);
|
station.WorkforceRequired = GetStationRequiredWorkforce(world.ModuleDefinitions, station);
|
||||||
|
|
||||||
var requiredWater = station.Population * WaterConsumptionPerWorkerPerSecond * deltaSeconds;
|
var requiredWater = station.Population * WaterConsumptionPerWorkerPerSecond * deltaSeconds;
|
||||||
var consumedWater = RemoveInventory(station.Inventory, "water", requiredWater);
|
var consumedWater = RemoveInventory(station.Inventory, "water", requiredWater);
|
||||||
var waterSatisfied = requiredWater <= 0.01f || consumedWater + 0.001f >= requiredWater;
|
var waterSatisfied = requiredWater <= 0.01f || consumedWater + 0.001f >= requiredWater;
|
||||||
var habitatModules = CountStationModules(station, ModuleType.Habitation);
|
station.PopulationCapacity = GetStationSupportedPopulation(world.ModuleDefinitions, station);
|
||||||
station.PopulationCapacity = 40f + (habitatModules * 220f);
|
|
||||||
|
|
||||||
if (waterSatisfied)
|
if (waterSatisfied)
|
||||||
{
|
{
|
||||||
if (habitatModules > 0 && station.Population < station.PopulationCapacity)
|
if (station.PopulationCapacity > 40f && station.Population < station.PopulationCapacity)
|
||||||
{
|
{
|
||||||
station.Population = MathF.Min(station.PopulationCapacity, station.Population + (PopulationGrowthPerSecond * deltaSeconds));
|
station.Population = MathF.Min(station.PopulationCapacity, station.Population + (PopulationGrowthPerSecond * deltaSeconds));
|
||||||
}
|
}
|
||||||
@@ -80,8 +81,8 @@ internal sealed class StationLifecycleService
|
|||||||
TargetPosition = spawnPosition,
|
TargetPosition = spawnPosition,
|
||||||
SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition),
|
SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition),
|
||||||
DefaultBehavior = CreateSpawnedShipBehavior(definition, station),
|
DefaultBehavior = CreateSpawnedShipBehavior(definition, station),
|
||||||
Skills = WorldSeedingService.CreateSkills(definition),
|
Skills = ShipBootstrapPolicy.CreateSkills(definition),
|
||||||
Health = definition.MaxHealth,
|
Health = definition.Hull,
|
||||||
};
|
};
|
||||||
|
|
||||||
world.Ships.Add(ship);
|
world.Ships.Add(ship);
|
||||||
@@ -91,7 +92,7 @@ internal sealed class StationLifecycleService
|
|||||||
faction.ShipsBuilt += 1;
|
faction.ShipsBuilt += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Label} launched {definition.Label}.", DateTimeOffset.UtcNow));
|
events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Label} launched {definition.Name}.", DateTimeOffset.UtcNow));
|
||||||
return 1f;
|
return 1f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ internal sealed class StationLifecycleService
|
|||||||
{
|
{
|
||||||
CurrentSystemId = station.SystemId,
|
CurrentSystemId = station.SystemId,
|
||||||
SpaceLayer = SpaceLayerKind.LocalSpace,
|
SpaceLayer = SpaceLayerKind.LocalSpace,
|
||||||
CurrentCelestialId = station.CelestialId,
|
CurrentAnchorId = station.AnchorId,
|
||||||
LocalPosition = position,
|
LocalPosition = position,
|
||||||
SystemPosition = position,
|
SystemPosition = position,
|
||||||
MovementRegime = MovementRegimeKind.LocalFlight,
|
MovementRegime = MovementRegimeKind.LocalFlight,
|
||||||
@@ -107,21 +108,22 @@ internal sealed class StationLifecycleService
|
|||||||
|
|
||||||
private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station)
|
private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station)
|
||||||
{
|
{
|
||||||
if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal))
|
if (!IsMilitaryShip(definition))
|
||||||
{
|
{
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? "advanced-auto-trade" : "idle",
|
Kind = IsTransportShip(definition) ? AdvancedAutoTrade : HoldPosition,
|
||||||
HomeSystemId = station.SystemId,
|
HomeSystemId = station.SystemId,
|
||||||
HomeStationId = station.Id,
|
HomeStationId = station.Id,
|
||||||
MaxSystemRange = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? 2 : 0,
|
AreaSystemId = station.SystemId,
|
||||||
|
MaxSystemRange = IsTransportShip(definition) ? 2 : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
var patrolRadius = station.Radius + 90f;
|
var patrolRadius = station.Radius + 90f;
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "patrol",
|
Kind = Patrol,
|
||||||
HomeSystemId = station.SystemId,
|
HomeSystemId = station.SystemId,
|
||||||
HomeStationId = station.Id,
|
HomeStationId = station.Id,
|
||||||
AreaSystemId = station.SystemId,
|
AreaSystemId = station.SystemId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using static SpaceGame.Api.Factions.AI.CommanderPlanningService;
|
using static SpaceGame.Api.Factions.AI.CommanderPlanningService;
|
||||||
|
using static SpaceGame.Api.Shared.Runtime.KnownShipTypes;
|
||||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||||
using SpaceGame.Api.Shared.Runtime;
|
using SpaceGame.Api.Shared.Runtime;
|
||||||
|
|
||||||
@@ -7,6 +8,10 @@ namespace SpaceGame.Api.Stations.Simulation;
|
|||||||
internal sealed class StationSimulationService
|
internal sealed class StationSimulationService
|
||||||
{
|
{
|
||||||
internal const int StrategicControlTargetSystems = 5;
|
internal const int StrategicControlTargetSystems = 5;
|
||||||
|
private const string MilitaryShipCategory = "military";
|
||||||
|
private const string ConstructionShipCategory = "construction";
|
||||||
|
private const string TransportShipCategory = "transport";
|
||||||
|
private const string MiningShipCategory = "mining";
|
||||||
|
|
||||||
internal void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station)
|
internal void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station)
|
||||||
{
|
{
|
||||||
@@ -63,7 +68,7 @@ internal sealed class StationSimulationService
|
|||||||
var superfluidCoolantReserve = role == "superfluidcoolant" ? 120f : 0f;
|
var superfluidCoolantReserve = role == "superfluidcoolant" ? 120f : 0f;
|
||||||
var quantumTubesReserve = role == "quantumtubes" ? 120f : 0f;
|
var quantumTubesReserve = role == "quantumtubes" ? 120f : 0f;
|
||||||
var shipPartsReserve = HasShipyardCapability(station)
|
var shipPartsReserve = HasShipyardCapability(station)
|
||||||
&& GetShipProductionPressure(world, station.FactionId, "military") > 0.2f
|
&& GetShipProductionPressure(world, station.FactionId, MilitaryShipCategory) > 0.2f
|
||||||
? 90f
|
? 90f
|
||||||
: 0f;
|
: 0f;
|
||||||
|
|
||||||
@@ -118,7 +123,7 @@ internal sealed class StationSimulationService
|
|||||||
var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics");
|
var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics");
|
||||||
var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals");
|
var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals");
|
||||||
var shipPartsReserve = HasShipyardCapability(station)
|
var shipPartsReserve = HasShipyardCapability(station)
|
||||||
&& GetShipProductionPressure(world, station.FactionId, "military") > 0.2f
|
&& GetShipProductionPressure(world, station.FactionId, MilitaryShipCategory) > 0.2f
|
||||||
? 90f
|
? 90f
|
||||||
: 0f;
|
: 0f;
|
||||||
|
|
||||||
@@ -216,12 +221,8 @@ internal sealed class StationSimulationService
|
|||||||
{
|
{
|
||||||
foreach (var moduleId in station.InstalledModules.Distinct(StringComparer.Ordinal))
|
foreach (var moduleId in station.InstalledModules.Distinct(StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var def) || string.IsNullOrEmpty(def.ProductionMode))
|
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var definition)
|
||||||
{
|
|| definition is not ProductionLaneModuleDefinition)
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(def.ProductionMode, "commanded", StringComparison.Ordinal) && station.CommanderId is null)
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -241,7 +242,7 @@ internal sealed class StationSimulationService
|
|||||||
|
|
||||||
internal static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) =>
|
internal static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) =>
|
||||||
recipe.RequiredModules.FirstOrDefault(moduleId =>
|
recipe.RequiredModules.FirstOrDefault(moduleId =>
|
||||||
world.ModuleDefinitions.TryGetValue(moduleId, out var def) && !string.IsNullOrEmpty(def.ProductionMode));
|
world.ModuleDefinitions.TryGetValue(moduleId, out var definition) && definition is ProductionLaneModuleDefinition);
|
||||||
|
|
||||||
internal static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
internal static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
||||||
{
|
{
|
||||||
@@ -259,7 +260,7 @@ internal sealed class StationSimulationService
|
|||||||
var priority = (float)recipe.Priority;
|
var priority = (float)recipe.Priority;
|
||||||
|
|
||||||
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
|
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
|
||||||
var fleetPressure = GetShipProductionPressure(world, station.FactionId, "military");
|
var fleetPressure = GetShipProductionPressure(world, station.FactionId, MilitaryShipCategory);
|
||||||
priority += GetStationRecipePriorityAdjustment(world, station, recipe, expansionPressure, fleetPressure);
|
priority += GetStationRecipePriorityAdjustment(world, station, recipe, expansionPressure, fleetPressure);
|
||||||
priority += GetStrategicRecipeBias(world, station, recipe);
|
priority += GetStrategicRecipeBias(world, station, recipe);
|
||||||
|
|
||||||
@@ -270,21 +271,34 @@ internal sealed class StationSimulationService
|
|||||||
{
|
{
|
||||||
if (recipe.ShipOutputId is not null && world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition))
|
if (recipe.ShipOutputId is not null && world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition))
|
||||||
{
|
{
|
||||||
var shipPressure = GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind);
|
var shipPressure = GetShipProductionPressure(world, station.FactionId, GetShipCategory(shipDefinition));
|
||||||
return shipDefinition.Kind switch
|
if (IsMilitaryShip(shipDefinition))
|
||||||
{
|
{
|
||||||
"military" => recipe.Id switch
|
return recipe.Id switch
|
||||||
{
|
{
|
||||||
"frigate-construction" => 320f * shipPressure,
|
"frigate-construction" => 320f * shipPressure,
|
||||||
"destroyer-construction" => 200f * shipPressure,
|
"destroyer-construction" => 200f * shipPressure,
|
||||||
"cruiser-construction" => 120f * shipPressure,
|
"cruiser-construction" => 120f * shipPressure,
|
||||||
_ => 160f * shipPressure,
|
_ => 160f * shipPressure,
|
||||||
},
|
};
|
||||||
"construction" => 260f * shipPressure,
|
}
|
||||||
"mining" => 250f * shipPressure,
|
|
||||||
"transport" => 230f * shipPressure,
|
if (IsConstructionShip(shipDefinition))
|
||||||
_ => 0f,
|
{
|
||||||
};
|
return 260f * shipPressure;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsMiningShip(shipDefinition))
|
||||||
|
{
|
||||||
|
return 250f * shipPressure;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsTransportShip(shipDefinition))
|
||||||
|
{
|
||||||
|
return 230f * shipPressure;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
var outputItemIds = recipe.Outputs
|
var outputItemIds = recipe.Outputs
|
||||||
@@ -342,7 +356,7 @@ internal sealed class StationSimulationService
|
|||||||
if (string.Equals(assignment.Kind, "ship-production-focus", StringComparison.Ordinal)
|
if (string.Equals(assignment.Kind, "ship-production-focus", StringComparison.Ordinal)
|
||||||
&& recipe.ShipOutputId is not null
|
&& recipe.ShipOutputId is not null
|
||||||
&& world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)
|
&& world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)
|
||||||
&& string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal))
|
&& IsMilitaryShip(shipDefinition))
|
||||||
{
|
{
|
||||||
return 260f;
|
return 260f;
|
||||||
}
|
}
|
||||||
@@ -387,7 +401,7 @@ internal sealed class StationSimulationService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind) <= 0.05f)
|
if (GetShipProductionPressure(world, station.FactionId, GetShipCategory(shipDefinition)) <= 0.05f)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -408,7 +422,7 @@ internal sealed class StationSimulationService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var storageKind = itemDefinition.CargoStorageKind;
|
var storageKind = itemDefinition.CargoKind;
|
||||||
if (storageKind is null)
|
if (storageKind is null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -426,7 +440,7 @@ internal sealed class StationSimulationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var used = station.Inventory
|
var used = station.Inventory
|
||||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind)
|
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageKind)
|
||||||
.Sum(entry => entry.Value);
|
.Sum(entry => entry.Value);
|
||||||
return used + amount <= capacity + 0.001f;
|
return used + amount <= capacity + 0.001f;
|
||||||
}
|
}
|
||||||
@@ -712,7 +726,7 @@ internal sealed class StationSimulationService
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float GetShipProductionPressure(SimulationWorld world, string factionId, string shipKind)
|
private static float GetShipProductionPressure(SimulationWorld world, string factionId, string? shipCategory)
|
||||||
{
|
{
|
||||||
var economic = FindFactionEconomicAssessment(world, factionId);
|
var economic = FindFactionEconomicAssessment(world, factionId);
|
||||||
var threat = FindFactionThreatAssessment(world, factionId);
|
var threat = FindFactionThreatAssessment(world, factionId);
|
||||||
@@ -721,16 +735,16 @@ internal sealed class StationSimulationService
|
|||||||
return 0f;
|
return 0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
return shipKind switch
|
return shipCategory switch
|
||||||
{
|
{
|
||||||
"military" => threat.EnemyFactionCount > 0
|
MilitaryShipCategory => threat.EnemyFactionCount > 0
|
||||||
? economic.MilitaryShipCount < Math.Max(4, economic.ControlledSystemCount * 2) ? 1f : 0.25f
|
? economic.MilitaryShipCount < Math.Max(4, economic.ControlledSystemCount * 2) ? 1f : 0.25f
|
||||||
: 0.1f,
|
: 0.1f,
|
||||||
"construction" => economic.PrimaryExpansionSiteId is not null
|
ConstructionShipCategory => economic.PrimaryExpansionSiteId is not null
|
||||||
? economic.ConstructorShipCount < 1 ? 1f : 0.35f
|
? economic.ConstructorShipCount < 1 ? 1f : 0.35f
|
||||||
: economic.ConstructorShipCount < 1 ? 0.5f : 0f,
|
: economic.ConstructorShipCount < 1 ? 0.5f : 0f,
|
||||||
"transport" => economic.TransportShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.8f : 0.2f,
|
TransportShipCategory => economic.TransportShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.8f : 0.2f,
|
||||||
_ when shipKind == "mining" || shipKind == "miner" => economic.MinerShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.85f : 0.2f,
|
MiningShipCategory => economic.MinerShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.85f : 0.2f,
|
||||||
_ => 0.15f,
|
_ => 0.15f,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
25
apps/backend/Universe/Api/CreateFactionHandler.cs
Normal file
25
apps/backend/Universe/Api/CreateFactionHandler.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
|
||||||
|
namespace SpaceGame.Api.Universe.Api;
|
||||||
|
|
||||||
|
public sealed class CreateFactionHandler(WorldService worldService) : Endpoint<CreateFactionCommandRequest, FactionSnapshot>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/gm/factions");
|
||||||
|
Policies(AuthPolicyNames.GmAccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CreateFactionCommandRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SendOkAsync(worldService.CreateFaction(request.FactionId), cancellationToken);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
AddError(ex.Message);
|
||||||
|
await SendErrorsAsync(cancellation: cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,14 @@ using SpaceGame.Api.Universe.Simulation;
|
|||||||
|
|
||||||
namespace SpaceGame.Api.Universe.Api;
|
namespace SpaceGame.Api.Universe.Api;
|
||||||
|
|
||||||
public sealed class GetBalanceHandler(WorldService worldService) : EndpointWithoutRequest
|
public sealed class GetBalanceHandler(IBalanceService balanceService) : EndpointWithoutRequest
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Get("/api/balance");
|
Get("/api/balance");
|
||||||
AllowAnonymous();
|
Policies(AuthPolicyNames.GmAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
||||||
SendOkAsync(worldService.GetBalance(), cancellationToken);
|
SendOkAsync(balanceService.GetCurrent(), cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public sealed class GetTelemetryHandler(TelemetryService telemetry, WorldService
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Get("/api/telemetry");
|
Get("/api/telemetry");
|
||||||
AllowAnonymous();
|
Policies(AuthPolicyNames.GmAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task HandleAsync(CancellationToken cancellationToken)
|
public override Task HandleAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
17
apps/backend/Universe/Api/GetVersionHandler.cs
Normal file
17
apps/backend/Universe/Api/GetVersionHandler.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
|
||||||
|
namespace SpaceGame.Api.Universe.Api;
|
||||||
|
|
||||||
|
public sealed class GetVersionHandler(AppVersionService appVersionService) : EndpointWithoutRequest<VersionInfoSnapshot>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/api/version");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await SendOkAsync(appVersionService.GetSnapshot(), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ public sealed class ResetWorldHandler(WorldService worldService) : EndpointWitho
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Post("/api/world/reset");
|
Post("/api/world/reset");
|
||||||
AllowAnonymous();
|
Policies(AuthPolicyNames.GmAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user