Compare commits
30 Commits
cd1fe776a5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8503855a4c | |||
| 6c92ab50c8 | |||
| d0c6e30304 | |||
| 75568324f5 | |||
| fdcf83ccec | |||
| 74b8bf4116 | |||
| c9a4b474b4 | |||
| 63a9f808bb | |||
| 706e1cda8f | |||
| 0bb72bee35 | |||
| 640e147ea8 | |||
| 04d182e93f | |||
| 3237735b08 | |||
| e8fb033a01 | |||
| f961ac62b6 | |||
| 00a1e58184 | |||
| a5e0037311 | |||
| e87994a2dc | |||
| e5fa0eb347 | |||
| 85a055ec91 | |||
| 766fef1c8f | |||
| cfee1306de | |||
| 768d705fb7 | |||
| 5f41914a59 | |||
| 6ccc708ae1 | |||
| 3b56785f9a | |||
| ff078fe939 | |||
| a2f66b0dca | |||
| f5bf7d8e3f | |||
| 892d069b92 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,3 +17,5 @@ pnpm-debug.log*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
.codex
|
||||
|
||||
15
.idea/.idea.SpaceGame/.idea/.gitignore
generated
vendored
Normal file
15
.idea/.idea.SpaceGame/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Rider ignored files
|
||||
/contentModel.xml
|
||||
/modules.xml
|
||||
/projectSettingsUpdater.xml
|
||||
/.idea.SpaceGame.iml
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
1
.idea/.idea.SpaceGame/.idea/.name
generated
Normal file
1
.idea/.idea.SpaceGame/.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
||||
SpaceGame
|
||||
4
.idea/.idea.SpaceGame/.idea/encodings.xml
generated
Normal file
4
.idea/.idea.SpaceGame/.idea/encodings.xml
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||
</project>
|
||||
8
.idea/.idea.SpaceGame/.idea/indexLayout.xml
generated
Normal file
8
.idea/.idea.SpaceGame/.idea/indexLayout.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="UserContentModel">
|
||||
<attachedFolders />
|
||||
<explicitIncludes />
|
||||
<explicitExcludes />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/.idea.SpaceGame/.idea/vcs.xml
generated
Normal file
6
.idea/.idea.SpaceGame/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
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.
|
||||
@@ -1,6 +1,10 @@
|
||||
<Solution>
|
||||
<Folder Name="/apps/" />
|
||||
<Folder Name="/apps/backend/">
|
||||
<Project Path="apps/backend/SpaceGame.Simulation.Api.csproj" />
|
||||
<Project Path="apps/backend/SpaceGame.Api.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/" />
|
||||
<Folder Name="/tests/backend/">
|
||||
<Project Path="tests/backend/SpaceGame.Api.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
52
apps/backend/.editorconfig
Normal file
52
apps/backend/.editorconfig
Normal file
@@ -0,0 +1,52 @@
|
||||
root = true
|
||||
|
||||
[*.{cs,csx}]
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
# .NET/C# conventions
|
||||
dotnet_sort_system_directives_first = true
|
||||
dotnet_separate_import_directive_groups = false
|
||||
dotnet_style_qualification_for_field = false:suggestion
|
||||
dotnet_style_qualification_for_property = false:suggestion
|
||||
dotnet_style_qualification_for_method = false:suggestion
|
||||
dotnet_style_qualification_for_event = false:suggestion
|
||||
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
|
||||
dotnet_style_predefined_type_for_member_access = true:suggestion
|
||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
|
||||
dotnet_style_object_initializer = true:suggestion
|
||||
dotnet_style_collection_initializer = true:suggestion
|
||||
dotnet_style_coalesce_expression = true:suggestion
|
||||
dotnet_style_null_propagation = true:suggestion
|
||||
dotnet_style_explicit_tuple_names = true:suggestion
|
||||
dotnet_style_prefer_auto_properties = true:suggestion
|
||||
dotnet_style_prefer_collection_expression = true:suggestion
|
||||
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
|
||||
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
|
||||
|
||||
csharp_prefer_braces = true:suggestion
|
||||
|
||||
csharp_style_var_for_built_in_types = true:suggestion
|
||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||
csharp_style_var_elsewhere = true:suggestion
|
||||
csharp_prefer_simple_using_statement = true:suggestion
|
||||
csharp_style_prefer_switch_expression = true:suggestion
|
||||
csharp_style_prefer_primary_constructors = false:silent
|
||||
csharp_new_line_before_open_brace = all
|
||||
|
||||
[*.{csproj,props,targets,sln,slnx}]
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{json,jsonc}]
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
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,324 +1,597 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
using SpaceGame.Api.Shared.Runtime;
|
||||
using SpaceGame.Api.Shared.Runtime;
|
||||
using SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
namespace SpaceGame.Api.Definitions;
|
||||
|
||||
public sealed class ConstructionDefinition
|
||||
{
|
||||
public string? RecipeId { get; set; }
|
||||
public string FacilityCategory { get; set; } = "station";
|
||||
public List<string> RequiredModules { get; set; } = [];
|
||||
public List<RecipeInputDefinition> Requirements { get; set; } = [];
|
||||
public float CycleTime { get; set; }
|
||||
public float BatchSize { get; set; } = 1f;
|
||||
public float ProductsPerHour { get; set; }
|
||||
public float MaxEfficiency { get; set; } = 1f;
|
||||
public int Priority { get; set; }
|
||||
public string? RecipeId { get; set; }
|
||||
public string FacilityCategory { get; set; } = "station";
|
||||
public List<string> RequiredModules { get; set; } = [];
|
||||
public List<RecipeInputDefinition> Requirements { get; set; } = [];
|
||||
public float CycleTime { get; set; }
|
||||
public float BatchSize { get; set; } = 1f;
|
||||
public float ProductsPerHour { get; set; }
|
||||
public float MaxEfficiency { get; set; } = 1f;
|
||||
public int Priority { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ItemPriceDefinition
|
||||
{
|
||||
public float Min { get; set; }
|
||||
public float Max { get; set; }
|
||||
public float Avg { get; set; }
|
||||
public float Min { get; set; }
|
||||
public float Max { get; set; }
|
||||
public float Avg { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ItemEffectDefinition
|
||||
{
|
||||
public required string Type { get; set; }
|
||||
public float Product { get; set; }
|
||||
public required string Type { get; set; }
|
||||
public float Product { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ItemProductionDefinition
|
||||
{
|
||||
public float Time { get; set; }
|
||||
public float Amount { get; set; }
|
||||
public string Method { get; set; } = "default";
|
||||
public string Name { get; set; } = "Universal";
|
||||
public List<RecipeInputDefinition> Wares { get; set; } = [];
|
||||
public List<ItemEffectDefinition> Effects { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class BalanceDefinition
|
||||
{
|
||||
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 float Time { get; set; }
|
||||
public float Amount { get; set; }
|
||||
public string Method { get; set; } = "default";
|
||||
public string Name { get; set; } = "Universal";
|
||||
public List<RecipeInputDefinition> Wares { get; set; } = [];
|
||||
public List<ItemEffectDefinition> Effects { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class StarDefinition
|
||||
{
|
||||
public string Kind { get; set; } = "main-sequence";
|
||||
public required string Color { get; set; }
|
||||
public required string Glow { get; set; }
|
||||
public float Size { get; set; }
|
||||
public float OrbitRadius { get; set; }
|
||||
public float OrbitSpeed { get; set; }
|
||||
public float OrbitPhaseAtEpoch { get; set; }
|
||||
public string Kind { get; set; } = "main-sequence";
|
||||
public required string Color { get; set; }
|
||||
public required string Glow { get; set; }
|
||||
public float Size { get; set; }
|
||||
public float OrbitRadius { get; set; }
|
||||
public float OrbitSpeed { get; set; }
|
||||
public float OrbitPhaseAtEpoch { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MoonDefinition
|
||||
{
|
||||
public required string Label { get; set; }
|
||||
public float Size { get; set; }
|
||||
public required string Color { get; set; }
|
||||
public float OrbitRadius { get; set; }
|
||||
public float OrbitSpeed { get; set; }
|
||||
public float OrbitPhaseAtEpoch { get; set; }
|
||||
public float OrbitInclination { get; set; }
|
||||
public float OrbitLongitudeOfAscendingNode { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public float Size { get; set; }
|
||||
public required string Color { get; set; }
|
||||
public float OrbitRadius { get; set; }
|
||||
public float OrbitSpeed { get; set; }
|
||||
public float OrbitPhaseAtEpoch { get; set; }
|
||||
public float OrbitInclination { get; set; }
|
||||
public float OrbitLongitudeOfAscendingNode { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SolarSystemDefinition
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public required float[] Position { get; set; }
|
||||
public required List<StarDefinition> Stars { get; set; }
|
||||
public required AsteroidFieldDefinition AsteroidField { get; set; }
|
||||
public required List<ResourceNodeDefinition> ResourceNodes { get; set; }
|
||||
public required List<PlanetDefinition> Planets { get; set; }
|
||||
public required string Id { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public required float[] Position { get; set; }
|
||||
public required List<StarDefinition> Stars { get; set; }
|
||||
public required AsteroidFieldDefinition AsteroidField { get; set; }
|
||||
public required List<ResourceNodeDefinition> ResourceNodes { 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 int DecorationCount { get; set; }
|
||||
public float RadiusOffset { get; set; }
|
||||
public float RadiusVariance { get; set; }
|
||||
public float HeightVariance { get; set; }
|
||||
public int DecorationCount { get; set; }
|
||||
public float RadiusOffset { get; set; }
|
||||
public float RadiusVariance { get; set; }
|
||||
public float HeightVariance { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ResourceNodeDefinition
|
||||
{
|
||||
public string SourceKind { get; set; } = "asteroid-belt";
|
||||
public float Angle { get; set; }
|
||||
public float RadiusOffset { get; set; }
|
||||
public float InclinationDegrees { get; set; }
|
||||
public int? AnchorPlanetIndex { get; set; }
|
||||
public int? AnchorMoonIndex { get; set; }
|
||||
public float OreAmount { get; set; }
|
||||
public required string ItemId { get; set; }
|
||||
public int ShardCount { get; set; }
|
||||
public string SourceKind { get; set; } = "local-space";
|
||||
public string? AnchorReference { get; set; }
|
||||
public float Angle { get; set; }
|
||||
public float RadiusOffset { get; set; }
|
||||
public float InclinationDegrees { get; set; }
|
||||
public int? AnchorPlanetIndex { get; set; }
|
||||
public int? AnchorMoonIndex { get; set; }
|
||||
public float OreAmount { get; set; }
|
||||
public required string ItemId { get; set; }
|
||||
public int ShardCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ItemDefinition
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = "material";
|
||||
public string CargoKind { get; set; } = string.Empty;
|
||||
public float Volume { get; set; } = 1f;
|
||||
public int Version { get; set; }
|
||||
public string FactoryName { get; set; } = string.Empty;
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public string Group { get; set; } = string.Empty;
|
||||
public ItemPriceDefinition? Price { get; set; }
|
||||
public List<string> Illegal { get; set; } = [];
|
||||
public List<ItemProductionDefinition> Production { get; set; } = [];
|
||||
public ConstructionDefinition? Construction { get; set; }
|
||||
[JsonPropertyName("transport")]
|
||||
public string Transport
|
||||
{
|
||||
set => CargoKind = value;
|
||||
}
|
||||
public required string Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
[JsonIgnore]
|
||||
public StorageKind? CargoKind { get; set; }
|
||||
public float Volume { get; set; } = 1f;
|
||||
public int Version { get; set; }
|
||||
public string FactoryName { get; set; } = string.Empty;
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public string Group { get; set; } = string.Empty;
|
||||
public ItemPriceDefinition? Price { get; set; }
|
||||
public List<string> Illegal { get; set; } = [];
|
||||
public List<ItemProductionDefinition> Production { get; set; } = [];
|
||||
public ConstructionDefinition? Construction { get; set; }
|
||||
[JsonPropertyName("transport")]
|
||||
public string Transport
|
||||
{
|
||||
get => CargoKind?.ToDataValue() ?? string.Empty;
|
||||
set => CargoKind = value.ToNullableStorageKind();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RecipeOutputDefinition
|
||||
{
|
||||
public required string ItemId { get; set; }
|
||||
public float Amount { get; set; }
|
||||
public required string ItemId { get; set; }
|
||||
public float Amount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RecipeInputDefinition
|
||||
{
|
||||
public string ItemId { get; set; } = string.Empty;
|
||||
public float Amount { get; set; }
|
||||
[JsonPropertyName("ware")]
|
||||
public string Ware
|
||||
{
|
||||
set => ItemId = value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ModuleConstructionDefinition
|
||||
{
|
||||
public required List<RecipeInputDefinition> Requirements { get; set; }
|
||||
public float ProductionTime { get; set; }
|
||||
public string ItemId { get; set; } = string.Empty;
|
||||
public float Amount { get; set; }
|
||||
[JsonPropertyName("ware")]
|
||||
public string Ware
|
||||
{
|
||||
set => ItemId = value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ModuleDockDefinition
|
||||
{
|
||||
public int Capacity { get; set; }
|
||||
public required string Size { get; set; }
|
||||
public int Capacity { get; set; }
|
||||
public required string Size { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ModuleCargoDefinition
|
||||
{
|
||||
public float Max { get; set; }
|
||||
public required string Type { get; set; }
|
||||
public float Max { get; set; }
|
||||
public required string Type { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ModuleWorkForceDefinition
|
||||
public sealed class ModuleWorkforceDefinition
|
||||
{
|
||||
public float Capacity { get; set; }
|
||||
public float Max { get; set; }
|
||||
public string Race { get; set; } = string.Empty;
|
||||
[JsonPropertyName("capacity")]
|
||||
public float SupportedPopulation { get; set; }
|
||||
|
||||
[JsonPropertyName("max")]
|
||||
public float RequiredWorkforce { get; set; }
|
||||
|
||||
public string Race { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ModuleMountDefinition
|
||||
{
|
||||
public required string Group { get; set; }
|
||||
public required string Size { get; set; }
|
||||
public bool Hittable { get; set; }
|
||||
public List<string> Types { get; set; } = [];
|
||||
public required string Group { get; set; }
|
||||
public required string Size { get; set; }
|
||||
public bool Hittable { get; set; }
|
||||
public List<string> Types { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ModuleProductionDefinition
|
||||
public sealed class ModuleBuildRecipeDefinition
|
||||
{
|
||||
public float Time { get; set; }
|
||||
public float Amount { get; set; }
|
||||
public string Method { get; set; } = "default";
|
||||
public string Name { get; set; } = "Universal";
|
||||
public List<RecipeInputDefinition> Wares { get; set; } = [];
|
||||
public float Time { get; set; }
|
||||
public float Amount { get; set; }
|
||||
public string Method { get; set; } = "default";
|
||||
public string Name { get; set; } = "Universal";
|
||||
public List<RecipeInputDefinition> Wares { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ModuleDefinition
|
||||
public class ModuleDefinition
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public required string Type { get; set; }
|
||||
[JsonIgnore]
|
||||
public string? Product { get; set; }
|
||||
public List<string> Products { get; set; } = [];
|
||||
public string ProductionMode { get; set; } = "passive";
|
||||
public float Radius { get; set; } = 12f;
|
||||
public float Hull { get; set; } = 100f;
|
||||
public float WorkforceNeeded { get; set; }
|
||||
public int Version { get; set; }
|
||||
public string Macro { get; set; } = string.Empty;
|
||||
public string MakerRace { get; set; } = string.Empty;
|
||||
public int ExplosionDamage { get; set; }
|
||||
public ItemPriceDefinition? Price { get; set; }
|
||||
public List<string> Owners { get; set; } = [];
|
||||
public ModuleCargoDefinition? Cargo { get; set; }
|
||||
public ModuleWorkForceDefinition? WorkForce { get; set; }
|
||||
public List<ModuleDockDefinition> Docks { get; set; } = [];
|
||||
public List<ModuleMountDefinition> Shields { get; set; } = [];
|
||||
public List<ModuleMountDefinition> Turrets { get; set; } = [];
|
||||
public List<ModuleProductionDefinition> Production { get; set; } = [];
|
||||
public ModuleConstructionDefinition? Construction { get; set; }
|
||||
[JsonPropertyName("product")]
|
||||
public List<string> ProductIds
|
||||
{
|
||||
get => Products;
|
||||
set => Products = value ?? [];
|
||||
}
|
||||
public ModuleDefinition()
|
||||
{
|
||||
}
|
||||
|
||||
[SetsRequiredMembers]
|
||||
protected ModuleDefinition(ModuleDefinition source)
|
||||
{
|
||||
Id = source.Id;
|
||||
Name = source.Name;
|
||||
Description = source.Description;
|
||||
Type = source.Type;
|
||||
ModuleType = source.ModuleType;
|
||||
ProductIds = [.. source.ProductIds];
|
||||
Radius = source.Radius;
|
||||
Hull = source.Hull;
|
||||
Version = source.Version;
|
||||
Macro = source.Macro;
|
||||
MakerRace = source.MakerRace;
|
||||
ExplosionDamage = source.ExplosionDamage;
|
||||
Price = source.Price;
|
||||
Owners = [.. source.Owners];
|
||||
Cargo = source.Cargo;
|
||||
SerializedWorkforce = source.SerializedWorkforce;
|
||||
Docks = [.. source.Docks];
|
||||
Shields = [.. source.Shields];
|
||||
Turrets = [.. source.Turrets];
|
||||
BuildRecipes = [.. source.BuildRecipes];
|
||||
}
|
||||
|
||||
public required string Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public required string Type { get; set; }
|
||||
[JsonIgnore]
|
||||
public ModuleType ModuleType { get; set; }
|
||||
[JsonPropertyName("product")]
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
[JsonIgnore]
|
||||
public virtual IReadOnlyList<string> ProductItemIds => [];
|
||||
public float Radius { get; set; } = 12f;
|
||||
public float Hull { get; set; } = 100f;
|
||||
public int Version { get; set; }
|
||||
public string Macro { get; set; } = string.Empty;
|
||||
public string MakerRace { get; set; } = string.Empty;
|
||||
public int ExplosionDamage { get; set; }
|
||||
public ItemPriceDefinition? Price { get; set; }
|
||||
public List<string> Owners { get; set; } = [];
|
||||
public ModuleCargoDefinition? Cargo { get; set; }
|
||||
[JsonPropertyName("workForce")]
|
||||
public ModuleWorkforceDefinition? SerializedWorkforce { get; set; }
|
||||
public List<ModuleDockDefinition> Docks { get; set; } = [];
|
||||
public List<ModuleMountDefinition> Shields { get; set; } = [];
|
||||
public List<ModuleMountDefinition> Turrets { get; set; } = [];
|
||||
[JsonPropertyName("production")]
|
||||
public List<ModuleBuildRecipeDefinition> BuildRecipes { get; set; } = [];
|
||||
}
|
||||
|
||||
public abstract class ProductionLaneModuleDefinition : ModuleDefinition
|
||||
{
|
||||
[SetsRequiredMembers]
|
||||
protected ProductionLaneModuleDefinition(ModuleDefinition source, float requiredWorkforce)
|
||||
: base(source)
|
||||
{
|
||||
RequiredWorkforce = requiredWorkforce;
|
||||
}
|
||||
|
||||
public float RequiredWorkforce { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ProductionModuleDefinition : ProductionLaneModuleDefinition
|
||||
{
|
||||
[SetsRequiredMembers]
|
||||
internal ProductionModuleDefinition(ModuleDefinition source, float requiredWorkforce)
|
||||
: base(source, requiredWorkforce)
|
||||
{
|
||||
ProductItemIds = [.. source.ProductIds];
|
||||
}
|
||||
|
||||
public override IReadOnlyList<string> ProductItemIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class BuildModuleDefinition : ProductionLaneModuleDefinition
|
||||
{
|
||||
[SetsRequiredMembers]
|
||||
internal BuildModuleDefinition(ModuleDefinition source, float requiredWorkforce)
|
||||
: base(source, requiredWorkforce)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class HabitationModuleDefinition : ModuleDefinition
|
||||
{
|
||||
[SetsRequiredMembers]
|
||||
internal HabitationModuleDefinition(ModuleDefinition source, float supportedPopulation)
|
||||
: base(source)
|
||||
{
|
||||
SupportedPopulation = supportedPopulation;
|
||||
}
|
||||
|
||||
public float SupportedPopulation { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StorageModuleDefinition : ModuleDefinition
|
||||
{
|
||||
[SetsRequiredMembers]
|
||||
internal StorageModuleDefinition(ModuleDefinition source, StorageKind storageKind, float storageCapacity)
|
||||
: base(source)
|
||||
{
|
||||
StorageKind = storageKind;
|
||||
StorageCapacity = storageCapacity;
|
||||
}
|
||||
|
||||
public StorageKind StorageKind { get; init; }
|
||||
public float StorageCapacity { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ModuleRecipeDefinition
|
||||
{
|
||||
public required string ModuleId { get; set; }
|
||||
public float Duration { get; set; }
|
||||
public required List<RecipeInputDefinition> Inputs { get; set; }
|
||||
public required string ModuleId { get; set; }
|
||||
public float Duration { get; set; }
|
||||
public required List<RecipeInputDefinition> Inputs { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RecipeDefinition
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public required string FacilityCategory { get; set; }
|
||||
public float Duration { get; set; }
|
||||
public int Priority { get; set; }
|
||||
public List<string> RequiredModules { get; set; } = [];
|
||||
public List<RecipeInputDefinition> Inputs { get; set; } = [];
|
||||
public List<RecipeOutputDefinition> Outputs { get; set; } = [];
|
||||
public string? ShipOutputId { get; set; }
|
||||
public required string Id { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public required string FacilityCategory { get; set; }
|
||||
public float Duration { get; set; }
|
||||
public int Priority { get; set; }
|
||||
public List<string> RequiredModules { get; set; } = [];
|
||||
public List<RecipeInputDefinition> Inputs { get; set; } = [];
|
||||
public List<RecipeOutputDefinition> Outputs { get; set; } = [];
|
||||
public string? ShipOutputId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlanetDefinition
|
||||
{
|
||||
public required string Label { get; set; }
|
||||
public string PlanetType { get; set; } = "terrestrial";
|
||||
public string Shape { get; set; } = "sphere";
|
||||
public List<MoonDefinition> Moons { get; set; } = [];
|
||||
public float OrbitRadius { get; set; }
|
||||
public float OrbitSpeed { get; set; }
|
||||
public float OrbitEccentricity { get; set; }
|
||||
public float OrbitInclination { get; set; }
|
||||
public float OrbitLongitudeOfAscendingNode { get; set; }
|
||||
public float OrbitArgumentOfPeriapsis { get; set; }
|
||||
public float OrbitPhaseAtEpoch { get; set; }
|
||||
public float Size { get; set; }
|
||||
public required string Color { get; set; }
|
||||
public float Tilt { get; set; }
|
||||
public bool HasRing { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public string PlanetType { get; set; } = "terrestrial";
|
||||
public string Shape { get; set; } = "sphere";
|
||||
public List<MoonDefinition> Moons { get; set; } = [];
|
||||
public float OrbitRadius { get; set; }
|
||||
public float OrbitSpeed { get; set; }
|
||||
public float OrbitEccentricity { get; set; }
|
||||
public float OrbitInclination { get; set; }
|
||||
public float OrbitLongitudeOfAscendingNode { get; set; }
|
||||
public float OrbitArgumentOfPeriapsis { get; set; }
|
||||
public float OrbitPhaseAtEpoch { get; set; }
|
||||
public float Size { get; set; }
|
||||
public required string Color { get; set; }
|
||||
public float Tilt { 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 required string Id { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public required string Kind { get; set; }
|
||||
public required string Class { get; set; }
|
||||
public float Speed { get; set; }
|
||||
public float WarpSpeed { get; set; }
|
||||
public float FtlSpeed { get; set; }
|
||||
public float SpoolTime { get; set; }
|
||||
public float CargoCapacity { get; set; }
|
||||
public string? CargoKind { get; set; }
|
||||
public required string Color { get; set; }
|
||||
public required string HullColor { get; set; }
|
||||
public float Size { get; set; }
|
||||
public float MaxHealth { get; set; }
|
||||
public List<string> Capabilities { get; set; } = [];
|
||||
public ConstructionDefinition? Construction { get; set; }
|
||||
public 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 Size { get; set; } = string.Empty;
|
||||
public float ExplosionDamage { get; set; }
|
||||
public float Hull { get; set; }
|
||||
public Dictionary<string, float> Storage { get; set; } = new(StringComparer.Ordinal);
|
||||
public int People { get; set; }
|
||||
public ShipPurpose Purpose { get; set; }
|
||||
public string Thruster { get; set; } = string.Empty;
|
||||
public ShipType Type { get; set; }
|
||||
public float Mass { get; set; }
|
||||
public ShipInertiaDefinition? Inertia { get; set; }
|
||||
public ShipDragDefinition? Drag { get; set; }
|
||||
public List<ShipMountDefinition> Engines { get; set; } = [];
|
||||
public List<ShipMountDefinition> Shields { get; set; } = [];
|
||||
public List<ShipMountDefinition> Weapons { get; set; } = [];
|
||||
public List<ShipMountDefinition> Turrets { get; set; } = [];
|
||||
public List<ShipCargoDefinition> Cargo { get; set; } = [];
|
||||
public List<ModuleDockDefinition> Docks { get; set; } = [];
|
||||
public List<string> Owners { get; set; } = [];
|
||||
public ItemPriceDefinition? Price { get; set; }
|
||||
public List<ItemProductionDefinition> Production { get; set; } = [];
|
||||
[JsonIgnore]
|
||||
public float Speed => InferLocalSpeed(Size);
|
||||
[JsonIgnore]
|
||||
public float WarpSpeed => InferWarpSpeed(Size);
|
||||
[JsonIgnore]
|
||||
public float FtlSpeed => InferFtlSpeed(Size);
|
||||
[JsonIgnore]
|
||||
public float SpoolTime => InferSpoolTime(Size);
|
||||
public float GetTotalCargoCapacity() => Cargo.Sum(entry => entry.Max);
|
||||
|
||||
public float GetCargoCapacity(StorageKind kind) =>
|
||||
Cargo
|
||||
.Where(entry => entry.Types.Any(type => type.ToNullableStorageKind() == kind))
|
||||
.Sum(entry => entry.Max);
|
||||
|
||||
public bool SupportsCargoKind(StorageKind kind) =>
|
||||
GetCargoCapacity(kind) > 0f;
|
||||
|
||||
private static float InferWarpSpeed(string size) =>
|
||||
size switch
|
||||
{
|
||||
"extrasmall" => 4.8f,
|
||||
"small" => 4.2f,
|
||||
"medium" => 3.4f,
|
||||
"large" => 2.4f,
|
||||
"extralarge" => 1.8f,
|
||||
_ => 3f,
|
||||
};
|
||||
|
||||
private static float InferLocalSpeed(string size) =>
|
||||
size switch
|
||||
{
|
||||
"extrasmall" => 420f,
|
||||
"small" => 320f,
|
||||
"medium" => 230f,
|
||||
"large" => 150f,
|
||||
"extralarge" => 110f,
|
||||
_ => 200f,
|
||||
};
|
||||
|
||||
private static float InferFtlSpeed(string size) =>
|
||||
size switch
|
||||
{
|
||||
"extrasmall" => 1f,
|
||||
"small" => 0.85f,
|
||||
"medium" => 0.7f,
|
||||
"large" => 0.55f,
|
||||
"extralarge" => 0.45f,
|
||||
_ => 0.6f,
|
||||
};
|
||||
|
||||
private static float InferSpoolTime(string size) =>
|
||||
size switch
|
||||
{
|
||||
"extrasmall" => 0.8f,
|
||||
"small" => 1f,
|
||||
"medium" => 1.4f,
|
||||
"large" => 2f,
|
||||
"extralarge" => 2.6f,
|
||||
_ => 1.5f,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class ShipInertiaDefinition
|
||||
{
|
||||
public float Pitch { get; set; }
|
||||
public float Yaw { get; set; }
|
||||
public float Roll { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ShipDragDefinition
|
||||
{
|
||||
public float Forward { get; set; }
|
||||
public float Reverse { get; set; }
|
||||
public float Horizontal { get; set; }
|
||||
public float Vertical { get; set; }
|
||||
public float Pitch { get; set; }
|
||||
public float Yaw { get; set; }
|
||||
public float Roll { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ShipMountDefinition
|
||||
{
|
||||
public string? Group { get; set; }
|
||||
public required string Size { get; set; }
|
||||
public bool Hittable { get; set; }
|
||||
public List<string> Types { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ShipCargoDefinition
|
||||
{
|
||||
public float Max { get; set; }
|
||||
public List<string> Types { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ScenarioDefinition
|
||||
{
|
||||
public required List<InitialStationDefinition> InitialStations { get; set; }
|
||||
public required List<ShipFormationDefinition> ShipFormations { get; set; }
|
||||
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }
|
||||
public required MiningDefaultsDefinition MiningDefaults { get; set; }
|
||||
public required WorldGenerationOptions WorldGeneration { get; set; }
|
||||
// Temporary QA escape hatch so a scenario can pin an exact topology.
|
||||
// Do not treat this as the long-term world authoring model.
|
||||
public List<SolarSystemDefinition>? Systems { get; set; }
|
||||
public required List<InitialStationDefinition> InitialStations { get; set; }
|
||||
public required List<ShipFormationDefinition> ShipFormations { get; set; }
|
||||
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class InitialStationDefinition
|
||||
{
|
||||
public required string SystemId { get; set; }
|
||||
public string Label { get; set; } = "Orbital Station";
|
||||
public string Color { get; set; } = "#8df0d2";
|
||||
public string Objective { get; set; } = "general";
|
||||
public List<string> StartingModules { get; set; } = [];
|
||||
public string? FactionId { get; set; }
|
||||
public int? PlanetIndex { get; set; }
|
||||
public int? LagrangeSide { get; set; }
|
||||
public float[]? Position { get; set; }
|
||||
public required string SystemId { get; set; }
|
||||
public string Label { get; set; } = "Orbital Station";
|
||||
public string Color { get; set; } = "#8df0d2";
|
||||
public string Objective { get; set; } = "general";
|
||||
public List<string> StartingModules { get; set; } = [];
|
||||
public string? FactionId { get; set; }
|
||||
public int? PlanetIndex { get; set; }
|
||||
public int? LagrangeSide { get; set; }
|
||||
public float[]? Position { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ShipFormationDefinition
|
||||
{
|
||||
public required string ShipId { get; set; }
|
||||
public int Count { get; set; }
|
||||
public required float[] Center { get; set; }
|
||||
public required string SystemId { get; set; }
|
||||
public string? FactionId { get; set; }
|
||||
public Dictionary<string, float> StartingInventory { get; set; } = new(StringComparer.Ordinal);
|
||||
public required string ShipId { get; set; }
|
||||
public int Count { get; set; }
|
||||
public required float[] Center { get; set; }
|
||||
public required string SystemId { get; set; }
|
||||
public string? FactionId { get; set; }
|
||||
public Dictionary<string, float> StartingInventory { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed class PatrolRouteDefinition
|
||||
{
|
||||
public required string SystemId { get; set; }
|
||||
public required List<float[]> Points { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MiningDefaultsDefinition
|
||||
{
|
||||
public required string NodeSystemId { get; set; }
|
||||
public required string RefinerySystemId { get; set; }
|
||||
public required string SystemId { get; set; }
|
||||
public required List<float[]> Points { get; set; }
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@ public sealed record PolicySetSnapshot(
|
||||
string TradeAccessPolicy,
|
||||
string DockingAccessPolicy,
|
||||
string ConstructionAccessPolicy,
|
||||
string OperationalRangePolicy);
|
||||
string OperationalRangePolicy,
|
||||
string CombatEngagementPolicy,
|
||||
bool AvoidHostileSystems,
|
||||
float FleeHullRatio,
|
||||
IReadOnlyList<string> BlacklistedSystemIds);
|
||||
|
||||
public sealed record PolicySetDelta(
|
||||
string Id,
|
||||
@@ -44,4 +48,8 @@ public sealed record PolicySetDelta(
|
||||
string TradeAccessPolicy,
|
||||
string DockingAccessPolicy,
|
||||
string ConstructionAccessPolicy,
|
||||
string OperationalRangePolicy);
|
||||
string OperationalRangePolicy,
|
||||
string CombatEngagementPolicy,
|
||||
bool AvoidHostileSystems,
|
||||
float FleeHullRatio,
|
||||
IReadOnlyList<string> BlacklistedSystemIds);
|
||||
|
||||
@@ -2,29 +2,33 @@ namespace SpaceGame.Api.Economy.Runtime;
|
||||
|
||||
public sealed class MarketOrderRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string FactionId { get; init; }
|
||||
public string? StationId { get; init; }
|
||||
public string? ConstructionSiteId { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string ItemId { get; init; }
|
||||
public float Amount { get; init; }
|
||||
public float RemainingAmount { get; set; }
|
||||
public float Valuation { get; set; }
|
||||
public float? ReserveThreshold { get; set; }
|
||||
public string? PolicySetId { get; set; }
|
||||
public string State { get; set; } = MarketOrderStateKinds.Open;
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
public required string Id { get; init; }
|
||||
public required string FactionId { get; init; }
|
||||
public string? StationId { get; init; }
|
||||
public string? ConstructionSiteId { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string ItemId { get; init; }
|
||||
public float Amount { get; init; }
|
||||
public float RemainingAmount { get; set; }
|
||||
public float Valuation { get; set; }
|
||||
public float? ReserveThreshold { get; set; }
|
||||
public string? PolicySetId { get; set; }
|
||||
public string State { get; set; } = MarketOrderStateKinds.Open;
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class PolicySetRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string OwnerKind { get; init; }
|
||||
public required string OwnerId { get; init; }
|
||||
public string TradeAccessPolicy { get; set; } = "owner-and-allies";
|
||||
public string DockingAccessPolicy { get; set; } = "owner-and-allies";
|
||||
public string ConstructionAccessPolicy { get; set; } = "owner-only";
|
||||
public string OperationalRangePolicy { get; set; } = "unrestricted";
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
public required string Id { get; init; }
|
||||
public required string OwnerKind { get; init; }
|
||||
public required string OwnerId { get; init; }
|
||||
public string TradeAccessPolicy { get; set; } = "owner-and-allies";
|
||||
public string DockingAccessPolicy { get; set; } = "owner-and-allies";
|
||||
public string ConstructionAccessPolicy { get; set; } = "owner-only";
|
||||
public string OperationalRangePolicy { get; set; } = "unrestricted";
|
||||
public string CombatEngagementPolicy { get; set; } = "defensive";
|
||||
public bool AvoidHostileSystems { get; set; } = true;
|
||||
public float FleeHullRatio { get; set; } = 0.35f;
|
||||
public HashSet<string> BlacklistedSystemIds { get; } = new(StringComparer.Ordinal);
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,328 +0,0 @@
|
||||
|
||||
namespace SpaceGame.Api.Factions.AI;
|
||||
|
||||
// ─── Planning State ────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class FactionPlanningState
|
||||
{
|
||||
public int MilitaryShipCount { get; set; }
|
||||
public int MinerShipCount { get; set; }
|
||||
public int TransportShipCount { get; set; }
|
||||
public int ConstructorShipCount { get; set; }
|
||||
public int ControlledSystemCount { get; set; }
|
||||
public int TargetSystemCount { get; set; }
|
||||
public bool HasShipFactory { get; set; }
|
||||
public int EnemyFactionCount { get; set; }
|
||||
public int EnemyShipCount { get; set; }
|
||||
public int EnemyStationCount { get; set; }
|
||||
public float OreStockpile { get; set; }
|
||||
public float RefinedMetalsStockpile { get; set; }
|
||||
public float RefinedMetalsProductionRate { get; set; }
|
||||
public float RefinedMetalsShortageHorizonSeconds { get; set; }
|
||||
public float HullpartsStockpile { get; set; }
|
||||
public float HullpartsProductionRate { get; set; }
|
||||
public float HullpartsShortageHorizonSeconds { get; set; }
|
||||
public float ClaytronicsStockpile { get; set; }
|
||||
public float ClaytronicsProductionRate { get; set; }
|
||||
public float ClaytronicsShortageHorizonSeconds { get; set; }
|
||||
public float WaterStockpile { get; set; }
|
||||
public float WaterProductionRate { get; set; }
|
||||
public float WaterShortageHorizonSeconds { get; set; }
|
||||
|
||||
public bool HasRefinedMetalsProduction => RefinedMetalsProductionRate > 0.01f;
|
||||
public bool HasHullpartsProduction => HullpartsProductionRate > 0.01f;
|
||||
public bool HasClaytronicsProduction => ClaytronicsProductionRate > 0.01f;
|
||||
public bool HasWaterProduction => WaterProductionRate > 0.01f;
|
||||
|
||||
public bool HasWarIndustrySupplyChain =>
|
||||
HasRefinedMetalsProduction && HasHullpartsProduction && HasClaytronicsProduction;
|
||||
|
||||
public FactionPlanningState Clone() => (FactionPlanningState)MemberwiseClone();
|
||||
|
||||
internal static int ComputeTargetWarships(FactionPlanningState state)
|
||||
{
|
||||
var expansionDeficit = Math.Max(0, state.TargetSystemCount - state.ControlledSystemCount);
|
||||
return Math.Max(3, (state.ControlledSystemCount * 2) + (expansionDeficit * 3) + Math.Min(4, state.EnemyFactionCount + state.EnemyStationCount));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Goals ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class EnsureWarIndustryGoal : GoapGoal<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "ensure-war-industry";
|
||||
|
||||
public override bool IsSatisfied(FactionPlanningState state) =>
|
||||
state.EnemyFactionCount <= 0 || (state.HasWarIndustrySupplyChain && state.HasShipFactory);
|
||||
|
||||
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
if (state.EnemyFactionCount <= 0)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var missingStages =
|
||||
(state.HasRefinedMetalsProduction ? 0 : 1) +
|
||||
(state.HasHullpartsProduction ? 0 : 1) +
|
||||
(state.HasClaytronicsProduction ? 0 : 1) +
|
||||
(state.HasShipFactory ? 0 : 1);
|
||||
|
||||
return missingStages <= 0 ? 0f : 125f + (missingStages * 18f);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EnsureWaterSecurityGoal : GoapGoal<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "ensure-water-security";
|
||||
|
||||
public override bool IsSatisfied(FactionPlanningState state) =>
|
||||
state.HasWaterProduction && state.WaterShortageHorizonSeconds >= 300f;
|
||||
|
||||
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
if (state.HasWaterProduction && state.WaterShortageHorizonSeconds >= 300f)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
if (float.IsPositiveInfinity(state.WaterShortageHorizonSeconds))
|
||||
{
|
||||
return state.HasWaterProduction ? 0f : 85f;
|
||||
}
|
||||
|
||||
return 55f + MathF.Max(0f, 300f - state.WaterShortageHorizonSeconds) * 0.2f;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EnsureWarFleetGoal : GoapGoal<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "ensure-war-fleet";
|
||||
|
||||
public override bool IsSatisfied(FactionPlanningState state) =>
|
||||
state.MilitaryShipCount >= FactionPlanningState.ComputeTargetWarships(state);
|
||||
|
||||
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
var deficit = FactionPlanningState.ComputeTargetWarships(state) - state.MilitaryShipCount;
|
||||
return deficit <= 0 ? 0f : 50f + (deficit * 10f);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ExterminateRivalGoal : GoapGoal<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "exterminate-rival";
|
||||
|
||||
public override bool IsSatisfied(FactionPlanningState state) =>
|
||||
state.EnemyFactionCount <= 0 || (state.EnemyShipCount <= 0 && state.EnemyStationCount <= 0);
|
||||
|
||||
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
if (state.EnemyFactionCount <= 0)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return 140f + (state.EnemyStationCount * 25f) + (state.EnemyShipCount * 6f);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ExpandTerritoryGoal : GoapGoal<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "expand-territory";
|
||||
|
||||
public override bool IsSatisfied(FactionPlanningState state) =>
|
||||
state.ControlledSystemCount >= state.TargetSystemCount;
|
||||
|
||||
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
var deficit = state.TargetSystemCount - state.ControlledSystemCount;
|
||||
return deficit <= 0 ? 0f : 80f + (deficit * 15f);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EnsureMiningCapacityGoal : GoapGoal<FactionPlanningState>
|
||||
{
|
||||
private const int MinMiners = 2;
|
||||
|
||||
public override string Name => "ensure-mining-capacity";
|
||||
|
||||
public override bool IsSatisfied(FactionPlanningState state) => state.MinerShipCount >= MinMiners;
|
||||
|
||||
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
var deficit = MinMiners - state.MinerShipCount;
|
||||
return deficit <= 0 ? 0f : 70f + (deficit * 12f);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EnsureConstructionCapacityGoal : GoapGoal<FactionPlanningState>
|
||||
{
|
||||
private const int MinConstructors = 1;
|
||||
|
||||
public override string Name => "ensure-construction-capacity";
|
||||
|
||||
public override bool IsSatisfied(FactionPlanningState state) => state.ConstructorShipCount >= MinConstructors;
|
||||
|
||||
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
var deficit = MinConstructors - state.ConstructorShipCount;
|
||||
return deficit <= 0 ? 0f : 60f + (deficit * 10f);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions ───────────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class OrderShipProductionAction : GoapAction<FactionPlanningState>
|
||||
{
|
||||
private readonly string shipKind;
|
||||
private readonly string shipId;
|
||||
|
||||
public OrderShipProductionAction(string shipKind, string shipId)
|
||||
{
|
||||
this.shipKind = shipKind;
|
||||
this.shipId = shipId;
|
||||
}
|
||||
|
||||
public override string Name => $"order-{shipId}-production";
|
||||
public override float Cost => 1f;
|
||||
|
||||
public override bool CheckPreconditions(FactionPlanningState state) =>
|
||||
state.HasShipFactory && state.HasWarIndustrySupplyChain;
|
||||
|
||||
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
|
||||
{
|
||||
switch (shipKind)
|
||||
{
|
||||
case "military": state.MilitaryShipCount++; break;
|
||||
case "mining": state.MinerShipCount++; break;
|
||||
case "transport": state.TransportShipCount++; break;
|
||||
case "construction": state.ConstructorShipCount++; break;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
commander.ActiveDirectives.Add($"produce-{shipKind}-ships");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlanWarIndustryAction : GoapAction<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "plan-war-industry";
|
||||
public override float Cost => 2f;
|
||||
|
||||
public override bool CheckPreconditions(FactionPlanningState state) =>
|
||||
state.EnemyFactionCount > 0 && (!state.HasWarIndustrySupplyChain || !state.HasShipFactory);
|
||||
|
||||
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
|
||||
{
|
||||
state.RefinedMetalsProductionRate = MathF.Max(state.RefinedMetalsProductionRate, 1f);
|
||||
state.HullpartsProductionRate = MathF.Max(state.HullpartsProductionRate, 1f);
|
||||
state.ClaytronicsProductionRate = MathF.Max(state.ClaytronicsProductionRate, 1f);
|
||||
state.HasShipFactory = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
commander.ActiveDirectives.Add("bootstrap-war-industry");
|
||||
|
||||
if (FactionIndustryPlanner.AnalyzeShipyardNeed(world, commander.FactionId) is not { } project)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project);
|
||||
commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlanCommoditySupplyAction : GoapAction<FactionPlanningState>
|
||||
{
|
||||
private readonly string commodityId;
|
||||
|
||||
public PlanCommoditySupplyAction(string commodityId)
|
||||
{
|
||||
this.commodityId = commodityId;
|
||||
}
|
||||
|
||||
public override string Name => $"plan-{commodityId}-supply";
|
||||
public override float Cost => 2f;
|
||||
|
||||
public override bool CheckPreconditions(FactionPlanningState state) =>
|
||||
commodityId switch
|
||||
{
|
||||
"water" => !state.HasWaterProduction || state.WaterShortageHorizonSeconds < 300f,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
|
||||
{
|
||||
if (string.Equals(commodityId, "water", StringComparison.Ordinal))
|
||||
{
|
||||
state.WaterProductionRate = MathF.Max(state.WaterProductionRate, 1f);
|
||||
state.WaterShortageHorizonSeconds = MathF.Max(state.WaterShortageHorizonSeconds, 600f);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
if (FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, commodityId) is not { } project)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project);
|
||||
commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ExpandToSystemAction : GoapAction<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "expand-to-system";
|
||||
public override float Cost => 3f;
|
||||
|
||||
public override bool CheckPreconditions(FactionPlanningState state) =>
|
||||
state.ConstructorShipCount > 0 && state.MilitaryShipCount >= 2 && state.HasWarIndustrySupplyChain;
|
||||
|
||||
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
|
||||
{
|
||||
state.ControlledSystemCount++;
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
commander.ActiveDirectives.Add("expand-territory");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LaunchExterminationCampaignAction : GoapAction<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "launch-extermination-campaign";
|
||||
public override float Cost => 1f;
|
||||
|
||||
public override bool CheckPreconditions(FactionPlanningState state) =>
|
||||
state.EnemyFactionCount > 0
|
||||
&& state.HasShipFactory
|
||||
&& state.MilitaryShipCount >= Math.Max(2, FactionPlanningState.ComputeTargetWarships(state) / 2);
|
||||
|
||||
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
|
||||
{
|
||||
state.EnemyShipCount = 0;
|
||||
state.EnemyStationCount = 0;
|
||||
state.EnemyFactionCount = 0;
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
commander.ActiveDirectives.Add("attack-rival");
|
||||
commander.ActiveDirectives.Add("produce-military-ships");
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,271 @@
|
||||
namespace SpaceGame.Api.Factions.Contracts;
|
||||
|
||||
public sealed record FactionGoapStateSnapshot(
|
||||
public sealed record FactionDoctrineSnapshot(
|
||||
string StrategicPosture,
|
||||
string ExpansionPosture,
|
||||
string MilitaryPosture,
|
||||
string EconomicPosture,
|
||||
int DesiredControlledSystems,
|
||||
int DesiredMilitaryPerFront,
|
||||
int DesiredMinersPerSystem,
|
||||
int DesiredTransportsPerSystem,
|
||||
int DesiredConstructors,
|
||||
float ReserveCreditsRatio,
|
||||
float ExpansionBudgetRatio,
|
||||
float WarBudgetRatio,
|
||||
float ReserveMilitaryRatio,
|
||||
float OffensiveReadinessThreshold,
|
||||
float SupplySecurityBias,
|
||||
float FailureAversion,
|
||||
int ReinforcementLeadPerFront);
|
||||
|
||||
public sealed record FactionSystemMemorySnapshot(
|
||||
string SystemId,
|
||||
DateTimeOffset LastSeenAtUtc,
|
||||
int LastEnemyShipCount,
|
||||
int LastEnemyStationCount,
|
||||
bool ControlledByFaction,
|
||||
string? LastRole,
|
||||
float FrontierPressure,
|
||||
float RouteRisk,
|
||||
float HistoricalShortagePressure,
|
||||
int OffensiveFailures,
|
||||
int DefensiveFailures,
|
||||
int OffensiveSuccesses,
|
||||
int DefensiveSuccesses,
|
||||
DateTimeOffset? LastContestedAtUtc,
|
||||
DateTimeOffset? LastShortageAtUtc);
|
||||
|
||||
public sealed record FactionCommodityMemorySnapshot(
|
||||
string ItemId,
|
||||
float HistoricalShortageScore,
|
||||
float HistoricalSurplusScore,
|
||||
float LastObservedBacklog,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
DateTimeOffset? LastCriticalAtUtc);
|
||||
|
||||
public sealed record FactionOutcomeRecordSnapshot(
|
||||
string Id,
|
||||
string Kind,
|
||||
string Summary,
|
||||
string? RelatedCampaignId,
|
||||
string? RelatedObjectiveId,
|
||||
DateTimeOffset OccurredAtUtc);
|
||||
|
||||
public sealed record FactionMemorySnapshot(
|
||||
int LastPlanCycle,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
int LastObservedShipsBuilt,
|
||||
int LastObservedShipsLost,
|
||||
float LastObservedCredits,
|
||||
IReadOnlyList<string> KnownSystemIds,
|
||||
IReadOnlyList<string> KnownEnemyFactionIds,
|
||||
IReadOnlyList<FactionSystemMemorySnapshot> Systems,
|
||||
IReadOnlyList<FactionCommodityMemorySnapshot> Commodities,
|
||||
IReadOnlyList<FactionOutcomeRecordSnapshot> RecentOutcomes);
|
||||
|
||||
public sealed record FactionBudgetSnapshot(
|
||||
float ReservedCredits,
|
||||
float ExpansionCredits,
|
||||
float WarCredits,
|
||||
int ReservedMilitaryAssets,
|
||||
int ReservedLogisticsAssets,
|
||||
int ReservedConstructionAssets);
|
||||
|
||||
public sealed record FactionCommoditySignalSnapshot(
|
||||
string ItemId,
|
||||
float AvailableStock,
|
||||
float OnHand,
|
||||
float ProductionRatePerSecond,
|
||||
float CommittedProductionRatePerSecond,
|
||||
float UsageRatePerSecond,
|
||||
float NetRatePerSecond,
|
||||
float ProjectedNetRatePerSecond,
|
||||
float LevelSeconds,
|
||||
string Level,
|
||||
float ProjectedProductionRatePerSecond,
|
||||
float BuyBacklog,
|
||||
float ReservedForConstruction);
|
||||
|
||||
public sealed record FactionEconomicAssessmentSnapshot(
|
||||
int PlanCycle,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
int MilitaryShipCount,
|
||||
int MinerShipCount,
|
||||
int TransportShipCount,
|
||||
int ConstructorShipCount,
|
||||
int ControlledSystemCount,
|
||||
int TargetSystemCount,
|
||||
bool HasShipFactory,
|
||||
float OreStockpile,
|
||||
float RefinedMetalsStockpile,
|
||||
float RefinedMetalsProductionRate,
|
||||
float HullpartsStockpile,
|
||||
float HullpartsProductionRate,
|
||||
float ClaytronicsStockpile,
|
||||
float ClaytronicsProductionRate,
|
||||
float WaterStockpile,
|
||||
float WaterProductionRate,
|
||||
float WaterShortageHorizonSeconds);
|
||||
int TargetMilitaryShipCount,
|
||||
int TargetMinerShipCount,
|
||||
int TargetTransportShipCount,
|
||||
int TargetConstructorShipCount,
|
||||
bool HasShipyard,
|
||||
bool HasWarIndustrySupplyChain,
|
||||
string? PrimaryExpansionSiteId,
|
||||
string? PrimaryExpansionSystemId,
|
||||
float ReplacementPressure,
|
||||
float SustainmentScore,
|
||||
float LogisticsSecurityScore,
|
||||
int CriticalShortageCount,
|
||||
string? IndustrialBottleneckItemId,
|
||||
IReadOnlyList<FactionCommoditySignalSnapshot> CommoditySignals);
|
||||
|
||||
public sealed record FactionGoapPrioritySnapshot(string GoalName, float Priority);
|
||||
public sealed record FactionThreatSignalSnapshot(
|
||||
string ScopeId,
|
||||
string ScopeKind,
|
||||
int EnemyShipCount,
|
||||
int EnemyStationCount,
|
||||
string? EnemyFactionId);
|
||||
|
||||
public sealed record FactionThreatAssessmentSnapshot(
|
||||
int PlanCycle,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
int EnemyFactionCount,
|
||||
int EnemyShipCount,
|
||||
int EnemyStationCount,
|
||||
string? PrimaryThreatFactionId,
|
||||
string? PrimaryThreatSystemId,
|
||||
IReadOnlyList<FactionThreatSignalSnapshot> ThreatSignals);
|
||||
|
||||
public sealed record FactionTheaterSnapshot(
|
||||
string Id,
|
||||
string Kind,
|
||||
string SystemId,
|
||||
string Status,
|
||||
float Priority,
|
||||
float SupplyRisk,
|
||||
float FriendlyAssetValue,
|
||||
string? TargetFactionId,
|
||||
string? AnchorEntityId,
|
||||
Vector3Dto? AnchorPosition,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
IReadOnlyList<string> CampaignIds);
|
||||
|
||||
public sealed record FactionPlanStepSnapshot(
|
||||
string Id,
|
||||
string Kind,
|
||||
string Status,
|
||||
string? Summary,
|
||||
string? BlockingReason);
|
||||
|
||||
public sealed record FactionCampaignSnapshot(
|
||||
string Id,
|
||||
string Kind,
|
||||
string Status,
|
||||
float Priority,
|
||||
string? TheaterId,
|
||||
string? TargetFactionId,
|
||||
string? TargetSystemId,
|
||||
string? TargetEntityId,
|
||||
string? CommodityId,
|
||||
string? SupportStationId,
|
||||
int CurrentStepIndex,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string? Summary,
|
||||
string? PauseReason,
|
||||
float ContinuationScore,
|
||||
float SupplyAdequacy,
|
||||
float ReplacementPressure,
|
||||
int FailureCount,
|
||||
int SuccessCount,
|
||||
string? FleetCommanderId,
|
||||
bool RequiresReinforcement,
|
||||
IReadOnlyList<FactionPlanStepSnapshot> Steps,
|
||||
IReadOnlyList<string> ObjectiveIds);
|
||||
|
||||
public sealed record FactionObjectiveSnapshot(
|
||||
string Id,
|
||||
string CampaignId,
|
||||
string? TheaterId,
|
||||
string Kind,
|
||||
string DelegationKind,
|
||||
string BehaviorKind,
|
||||
string Status,
|
||||
float Priority,
|
||||
string? CommanderId,
|
||||
string? HomeSystemId,
|
||||
string? HomeStationId,
|
||||
string? TargetSystemId,
|
||||
string? TargetEntityId,
|
||||
Vector3Dto? TargetPosition,
|
||||
string? ItemId,
|
||||
string? Notes,
|
||||
int CurrentStepIndex,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
bool UseOrders,
|
||||
string? StagingOrderKind,
|
||||
int ReinforcementLevel,
|
||||
IReadOnlyList<FactionPlanStepSnapshot> Steps,
|
||||
IReadOnlyList<string> ReservedAssetIds);
|
||||
|
||||
public sealed record FactionReservationSnapshot(
|
||||
string Id,
|
||||
string ObjectiveId,
|
||||
string? CampaignId,
|
||||
string AssetKind,
|
||||
string AssetId,
|
||||
float Priority,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record FactionProductionProgramSnapshot(
|
||||
string Id,
|
||||
string Kind,
|
||||
string Status,
|
||||
float Priority,
|
||||
string? CampaignId,
|
||||
string? CommodityId,
|
||||
string? ModuleId,
|
||||
string? ShipKind,
|
||||
string? TargetSystemId,
|
||||
int TargetCount,
|
||||
int CurrentCount,
|
||||
string? Notes);
|
||||
|
||||
public sealed record FactionDecisionLogEntrySnapshot(
|
||||
string Id,
|
||||
string Kind,
|
||||
string Summary,
|
||||
string? RelatedEntityId,
|
||||
int PlanCycle,
|
||||
DateTimeOffset OccurredAtUtc);
|
||||
|
||||
public sealed record FactionStrategicStateSnapshot(
|
||||
int PlanCycle,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string Status,
|
||||
FactionBudgetSnapshot Budget,
|
||||
FactionEconomicAssessmentSnapshot EconomicAssessment,
|
||||
FactionThreatAssessmentSnapshot ThreatAssessment,
|
||||
IReadOnlyList<FactionTheaterSnapshot> Theaters,
|
||||
IReadOnlyList<FactionCampaignSnapshot> Campaigns,
|
||||
IReadOnlyList<FactionObjectiveSnapshot> Objectives,
|
||||
IReadOnlyList<FactionReservationSnapshot> Reservations,
|
||||
IReadOnlyList<FactionProductionProgramSnapshot> ProductionPrograms);
|
||||
|
||||
public sealed record CommanderAssignmentSnapshot(
|
||||
string CommanderId,
|
||||
string Kind,
|
||||
string BehaviorKind,
|
||||
string Status,
|
||||
string? ObjectiveId,
|
||||
string? CampaignId,
|
||||
string? TheaterId,
|
||||
string? ParentCommanderId,
|
||||
string? ControlledEntityId,
|
||||
float Priority,
|
||||
string? HomeSystemId,
|
||||
string? HomeStationId,
|
||||
string? TargetSystemId,
|
||||
string? TargetEntityId,
|
||||
Vector3Dto? TargetPosition,
|
||||
string? ItemId,
|
||||
string? Notes,
|
||||
DateTimeOffset? UpdatedAtUtc,
|
||||
IReadOnlyList<string> ActiveObjectiveIds,
|
||||
IReadOnlyList<string> SubordinateCommanderIds);
|
||||
|
||||
public sealed record FactionSnapshot(
|
||||
string Id,
|
||||
@@ -32,8 +278,11 @@ public sealed record FactionSnapshot(
|
||||
int ShipsBuilt,
|
||||
int ShipsLost,
|
||||
string? DefaultPolicySetId,
|
||||
FactionGoapStateSnapshot? GoapState,
|
||||
IReadOnlyList<FactionGoapPrioritySnapshot>? GoapPriorities);
|
||||
FactionDoctrineSnapshot Doctrine,
|
||||
FactionMemorySnapshot Memory,
|
||||
FactionStrategicStateSnapshot StrategicState,
|
||||
IReadOnlyList<FactionDecisionLogEntrySnapshot> DecisionLog,
|
||||
IReadOnlyList<CommanderAssignmentSnapshot> Commanders);
|
||||
|
||||
public sealed record FactionDelta(
|
||||
string Id,
|
||||
@@ -46,5 +295,8 @@ public sealed record FactionDelta(
|
||||
int ShipsBuilt,
|
||||
int ShipsLost,
|
||||
string? DefaultPolicySetId,
|
||||
FactionGoapStateSnapshot? GoapState,
|
||||
IReadOnlyList<FactionGoapPrioritySnapshot>? GoapPriorities);
|
||||
FactionDoctrineSnapshot Doctrine,
|
||||
FactionMemorySnapshot Memory,
|
||||
FactionStrategicStateSnapshot StrategicState,
|
||||
IReadOnlyList<FactionDecisionLogEntrySnapshot> DecisionLog,
|
||||
IReadOnlyList<CommanderAssignmentSnapshot> Commanders);
|
||||
|
||||
@@ -1,76 +1,348 @@
|
||||
|
||||
namespace SpaceGame.Api.Factions.Runtime;
|
||||
|
||||
public sealed class FactionRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; init; }
|
||||
public required string Color { get; init; }
|
||||
public float Credits { get; set; }
|
||||
public float PopulationTotal { get; set; }
|
||||
public float OreMined { get; set; }
|
||||
public float GoodsProduced { get; set; }
|
||||
public int ShipsBuilt { get; set; }
|
||||
public int ShipsLost { get; set; }
|
||||
public HashSet<string> CommanderIds { get; } = new(StringComparer.Ordinal);
|
||||
public string? DefaultPolicySetId { get; set; }
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; init; }
|
||||
public required string Color { get; init; }
|
||||
public float Credits { get; set; }
|
||||
public float PopulationTotal { get; set; }
|
||||
public float OreMined { get; set; }
|
||||
public float GoodsProduced { get; set; }
|
||||
public int ShipsBuilt { get; set; }
|
||||
public int ShipsLost { get; set; }
|
||||
public HashSet<string> CommanderIds { get; } = new(StringComparer.Ordinal);
|
||||
public string? DefaultPolicySetId { get; set; }
|
||||
public FactionDoctrineRuntime Doctrine { get; set; } = new();
|
||||
public FactionMemoryRuntime Memory { get; set; } = new();
|
||||
public FactionStrategicStateRuntime StrategicState { get; set; } = new();
|
||||
public List<FactionDecisionLogEntryRuntime> DecisionLog { get; } = [];
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CommanderRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; set; }
|
||||
public required string FactionId { get; init; }
|
||||
public string? ParentCommanderId { get; set; }
|
||||
public string? ControlledEntityId { get; set; }
|
||||
public string? PolicySetId { get; set; }
|
||||
public string? Doctrine { get; set; }
|
||||
public List<string> Goals { get; } = [];
|
||||
public HashSet<string> ActiveDirectives { get; } = new(StringComparer.Ordinal);
|
||||
public string? ActiveGoalName { get; set; }
|
||||
public string? ActiveActionName { get; set; }
|
||||
public float ReplanTimer { get; set; }
|
||||
public bool NeedsReplan { get; set; } = true;
|
||||
public CommanderBehaviorRuntime? ActiveBehavior { get; set; }
|
||||
public CommanderOrderRuntime? ActiveOrder { get; set; }
|
||||
public CommanderTaskRuntime? ActiveTask { get; set; }
|
||||
public HashSet<string> SubordinateCommanderIds { get; } = new(StringComparer.Ordinal);
|
||||
public bool IsAlive { get; set; } = true;
|
||||
public FactionPlanningState? LastPlanningState { get; set; }
|
||||
public IReadOnlyList<(string Name, float Priority)>? LastGoalPriorities { get; set; }
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; set; }
|
||||
public required string FactionId { get; init; }
|
||||
public string? ParentCommanderId { get; set; }
|
||||
public string? ControlledEntityId { get; set; }
|
||||
public string? PolicySetId { get; set; }
|
||||
public string? Doctrine { get; set; }
|
||||
public float ReplanTimer { get; set; }
|
||||
public bool NeedsReplan { get; set; } = true;
|
||||
public CommanderAssignmentRuntime? Assignment { get; set; }
|
||||
public CommanderSkillProfileRuntime Skills { get; set; } = new();
|
||||
public HashSet<string> SubordinateCommanderIds { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> ActiveObjectiveIds { get; } = new(StringComparer.Ordinal);
|
||||
public bool IsAlive { get; set; } = true;
|
||||
public int PlanningCycle { get; set; }
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CommanderBehaviorRuntime
|
||||
public sealed class CommanderAssignmentRuntime
|
||||
{
|
||||
public required string Kind { get; set; }
|
||||
public string? Phase { get; set; }
|
||||
public string? TargetEntityId { get; set; }
|
||||
public string? ItemId { get; set; }
|
||||
public string? NodeId { get; set; }
|
||||
public string? StationId { get; set; }
|
||||
public string? ModuleId { get; set; }
|
||||
public string? AreaSystemId { get; set; }
|
||||
public int PatrolIndex { get; set; }
|
||||
public required string ObjectiveId { get; set; }
|
||||
public string? CampaignId { get; set; }
|
||||
public string? TheaterId { get; set; }
|
||||
public required string Kind { get; set; }
|
||||
public required string BehaviorKind { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public float Priority { get; set; }
|
||||
public string? HomeSystemId { get; set; }
|
||||
public string? HomeStationId { get; set; }
|
||||
public string? TargetSystemId { get; set; }
|
||||
public string? TargetEntityId { get; set; }
|
||||
public Vector3? TargetPosition { get; set; }
|
||||
public string? ItemId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class CommanderOrderRuntime
|
||||
public sealed class CommanderSkillProfileRuntime
|
||||
{
|
||||
public required string Kind { get; init; }
|
||||
public OrderStatus Status { get; set; } = OrderStatus.Accepted;
|
||||
public string? TargetEntityId { get; set; }
|
||||
public string? DestinationNodeId { get; set; }
|
||||
public required string DestinationSystemId { get; init; }
|
||||
public required Vector3 DestinationPosition { get; init; }
|
||||
public int Leadership { get; set; } = 3;
|
||||
public int Coordination { get; set; } = 3;
|
||||
public int Strategy { get; set; } = 3;
|
||||
}
|
||||
|
||||
public sealed class CommanderTaskRuntime
|
||||
public sealed class FactionDoctrineRuntime
|
||||
{
|
||||
public required string Kind { get; set; }
|
||||
public WorkStatus Status { get; set; } = WorkStatus.Pending;
|
||||
public string? TargetEntityId { get; set; }
|
||||
public string? TargetSystemId { get; set; }
|
||||
public string? TargetNodeId { get; set; }
|
||||
public Vector3? TargetPosition { get; set; }
|
||||
public float Threshold { get; set; }
|
||||
public string StrategicPosture { get; set; } = "balanced";
|
||||
public string ExpansionPosture { get; set; } = "measured";
|
||||
public string MilitaryPosture { get; set; } = "defensive";
|
||||
public string EconomicPosture { get; set; } = "self-sufficient";
|
||||
public int DesiredControlledSystems { get; set; } = 3;
|
||||
public int DesiredMilitaryPerFront { get; set; } = 2;
|
||||
public int DesiredMinersPerSystem { get; set; } = 1;
|
||||
public int DesiredTransportsPerSystem { get; set; } = 1;
|
||||
public int DesiredConstructors { get; set; } = 1;
|
||||
public float ReserveCreditsRatio { get; set; } = 0.2f;
|
||||
public float ExpansionBudgetRatio { get; set; } = 0.25f;
|
||||
public float WarBudgetRatio { get; set; } = 0.35f;
|
||||
public float ReserveMilitaryRatio { get; set; } = 0.2f;
|
||||
public float OffensiveReadinessThreshold { get; set; } = 0.62f;
|
||||
public float SupplySecurityBias { get; set; } = 0.55f;
|
||||
public float FailureAversion { get; set; } = 0.45f;
|
||||
public int ReinforcementLeadPerFront { get; set; } = 1;
|
||||
}
|
||||
|
||||
public sealed class FactionMemoryRuntime
|
||||
{
|
||||
public int LastPlanCycle { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
public int LastObservedShipsBuilt { get; set; }
|
||||
public int LastObservedShipsLost { get; set; }
|
||||
public float LastObservedCredits { get; set; }
|
||||
public HashSet<string> KnownSystemIds { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> KnownEnemyFactionIds { get; } = new(StringComparer.Ordinal);
|
||||
public List<FactionSystemMemoryRuntime> SystemMemories { get; } = [];
|
||||
public List<FactionCommodityMemoryRuntime> CommodityMemories { get; } = [];
|
||||
public List<FactionOutcomeRecordRuntime> RecentOutcomes { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class FactionSystemMemoryRuntime
|
||||
{
|
||||
public required string SystemId { get; init; }
|
||||
public DateTimeOffset LastSeenAtUtc { get; set; }
|
||||
public int LastEnemyShipCount { get; set; }
|
||||
public int LastEnemyStationCount { get; set; }
|
||||
public bool ControlledByFaction { get; set; }
|
||||
public string? LastRole { get; set; }
|
||||
public float FrontierPressure { get; set; }
|
||||
public float RouteRisk { get; set; }
|
||||
public float HistoricalShortagePressure { get; set; }
|
||||
public int OffensiveFailures { get; set; }
|
||||
public int DefensiveFailures { get; set; }
|
||||
public int OffensiveSuccesses { get; set; }
|
||||
public int DefensiveSuccesses { get; set; }
|
||||
public DateTimeOffset? LastContestedAtUtc { get; set; }
|
||||
public DateTimeOffset? LastShortageAtUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FactionCommodityMemoryRuntime
|
||||
{
|
||||
public required string ItemId { get; init; }
|
||||
public float HistoricalShortageScore { get; set; }
|
||||
public float HistoricalSurplusScore { get; set; }
|
||||
public float LastObservedBacklog { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
public DateTimeOffset? LastCriticalAtUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FactionOutcomeRecordRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; set; }
|
||||
public required string Summary { get; set; }
|
||||
public string? RelatedCampaignId { get; set; }
|
||||
public string? RelatedObjectiveId { get; set; }
|
||||
public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class FactionStrategicStateRuntime
|
||||
{
|
||||
public int PlanCycle { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
public string Status { get; set; } = "stable";
|
||||
public FactionBudgetRuntime Budget { get; set; } = new();
|
||||
public FactionEconomicAssessmentRuntime EconomicAssessment { get; set; } = new();
|
||||
public FactionThreatAssessmentRuntime ThreatAssessment { get; set; } = new();
|
||||
public List<FactionTheaterRuntime> Theaters { get; } = [];
|
||||
public List<FactionCampaignRuntime> Campaigns { get; } = [];
|
||||
public List<FactionOperationalObjectiveRuntime> Objectives { get; } = [];
|
||||
public List<FactionAssetReservationRuntime> Reservations { get; } = [];
|
||||
public List<FactionProductionProgramRuntime> ProductionPrograms { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class FactionBudgetRuntime
|
||||
{
|
||||
public float ReservedCredits { get; set; }
|
||||
public float ExpansionCredits { get; set; }
|
||||
public float WarCredits { get; set; }
|
||||
public int ReservedMilitaryAssets { get; set; }
|
||||
public int ReservedLogisticsAssets { get; set; }
|
||||
public int ReservedConstructionAssets { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FactionEconomicAssessmentRuntime
|
||||
{
|
||||
public int PlanCycle { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
public int MilitaryShipCount { get; set; }
|
||||
public int MinerShipCount { get; set; }
|
||||
public int TransportShipCount { get; set; }
|
||||
public int ConstructorShipCount { get; set; }
|
||||
public int ControlledSystemCount { get; set; }
|
||||
public int TargetMilitaryShipCount { get; set; }
|
||||
public int TargetMinerShipCount { get; set; }
|
||||
public int TargetTransportShipCount { get; set; }
|
||||
public int TargetConstructorShipCount { get; set; }
|
||||
public bool HasShipyard { get; set; }
|
||||
public bool HasWarIndustrySupplyChain { get; set; }
|
||||
public string? PrimaryExpansionSiteId { get; set; }
|
||||
public string? PrimaryExpansionSystemId { get; set; }
|
||||
public float ReplacementPressure { get; set; }
|
||||
public float SustainmentScore { get; set; }
|
||||
public float LogisticsSecurityScore { get; set; }
|
||||
public int CriticalShortageCount { get; set; }
|
||||
public string? IndustrialBottleneckItemId { get; set; }
|
||||
public List<FactionCommoditySignalRuntime> CommoditySignals { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class FactionThreatAssessmentRuntime
|
||||
{
|
||||
public int PlanCycle { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
public int EnemyFactionCount { get; set; }
|
||||
public int EnemyShipCount { get; set; }
|
||||
public int EnemyStationCount { get; set; }
|
||||
public string? PrimaryThreatFactionId { get; set; }
|
||||
public string? PrimaryThreatSystemId { get; set; }
|
||||
public List<FactionThreatSignalRuntime> ThreatSignals { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class FactionTheaterRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; set; }
|
||||
public required string SystemId { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public float Priority { get; set; }
|
||||
public float SupplyRisk { get; set; }
|
||||
public float FriendlyAssetValue { get; set; }
|
||||
public string? TargetFactionId { get; set; }
|
||||
public string? AnchorEntityId { get; set; }
|
||||
public Vector3? AnchorPosition { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public List<string> CampaignIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class FactionCampaignRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; set; }
|
||||
public string Status { get; set; } = "planned";
|
||||
public float Priority { get; set; }
|
||||
public string? TheaterId { get; set; }
|
||||
public string? TargetFactionId { get; set; }
|
||||
public string? TargetSystemId { get; set; }
|
||||
public string? TargetEntityId { get; set; }
|
||||
public string? CommodityId { get; set; }
|
||||
public string? SupportStationId { get; set; }
|
||||
public int CurrentStepIndex { get; set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public string? Summary { get; set; }
|
||||
public string? PauseReason { get; set; }
|
||||
public float ContinuationScore { get; set; }
|
||||
public float SupplyAdequacy { get; set; }
|
||||
public float ReplacementPressure { get; set; }
|
||||
public int FailureCount { get; set; }
|
||||
public int SuccessCount { get; set; }
|
||||
public string? FleetCommanderId { get; set; }
|
||||
public bool RequiresReinforcement { get; set; }
|
||||
public List<FactionPlanStepRuntime> Steps { get; } = [];
|
||||
public List<string> ObjectiveIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class FactionOperationalObjectiveRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string CampaignId { get; set; }
|
||||
public string? TheaterId { get; set; }
|
||||
public required string Kind { get; set; }
|
||||
public required string DelegationKind { get; set; }
|
||||
public required string BehaviorKind { get; set; }
|
||||
public string Status { get; set; } = "planned";
|
||||
public float Priority { get; set; }
|
||||
public string? CommanderId { get; set; }
|
||||
public string? HomeSystemId { get; set; }
|
||||
public string? HomeStationId { get; set; }
|
||||
public string? TargetSystemId { get; set; }
|
||||
public string? TargetEntityId { get; set; }
|
||||
public Vector3? TargetPosition { get; set; }
|
||||
public string? ItemId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int CurrentStepIndex { get; set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public bool UseOrders { get; set; }
|
||||
public string? StagingOrderKind { get; set; }
|
||||
public int ReinforcementLevel { get; set; }
|
||||
public List<FactionPlanStepRuntime> Steps { get; } = [];
|
||||
public List<string> ReservedAssetIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class FactionPlanStepRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; set; }
|
||||
public string Status { get; set; } = "planned";
|
||||
public string? Summary { get; set; }
|
||||
public string? BlockingReason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FactionAssetReservationRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ObjectiveId { get; set; }
|
||||
public string? CampaignId { get; set; }
|
||||
public required string AssetKind { get; set; }
|
||||
public required string AssetId { get; set; }
|
||||
public float Priority { get; set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class FactionProductionProgramRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; set; }
|
||||
public string Status { get; set; } = "planned";
|
||||
public float Priority { get; set; }
|
||||
public string? CampaignId { get; set; }
|
||||
public string? CommodityId { get; set; }
|
||||
public string? ModuleId { get; set; }
|
||||
public string? ShipKind { get; set; }
|
||||
public string? TargetSystemId { get; set; }
|
||||
public int TargetCount { get; set; }
|
||||
public int CurrentCount { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FactionDecisionLogEntryRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; set; }
|
||||
public required string Summary { get; set; }
|
||||
public string? RelatedEntityId { get; set; }
|
||||
public int PlanCycle { get; set; }
|
||||
public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class FactionCommoditySignalRuntime
|
||||
{
|
||||
public required string ItemId { get; init; }
|
||||
public float AvailableStock { get; set; }
|
||||
public float OnHand { get; set; }
|
||||
public float ProductionRatePerSecond { get; set; }
|
||||
public float CommittedProductionRatePerSecond { get; set; }
|
||||
public float UsageRatePerSecond { get; set; }
|
||||
public float NetRatePerSecond { get; set; }
|
||||
public float ProjectedNetRatePerSecond { get; set; }
|
||||
public float LevelSeconds { get; set; }
|
||||
public string Level { get; set; } = "unknown";
|
||||
public float ProjectedProductionRatePerSecond { get; set; }
|
||||
public float BuyBacklog { get; set; }
|
||||
public float ReservedForConstruction { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FactionThreatSignalRuntime
|
||||
{
|
||||
public required string ScopeId { get; init; }
|
||||
public required string ScopeKind { get; init; }
|
||||
public int EnemyShipCount { get; set; }
|
||||
public int EnemyStationCount { get; set; }
|
||||
public string? EnemyFactionId { get; set; }
|
||||
}
|
||||
|
||||
283
apps/backend/Geopolitics/Contracts/Geopolitics.cs
Normal file
283
apps/backend/Geopolitics/Contracts/Geopolitics.cs
Normal file
@@ -0,0 +1,283 @@
|
||||
namespace SpaceGame.Api.Geopolitics.Contracts;
|
||||
|
||||
public sealed record SystemRouteLinkSnapshot(
|
||||
string Id,
|
||||
string SourceSystemId,
|
||||
string DestinationSystemId,
|
||||
float Distance,
|
||||
bool IsPrimaryLane);
|
||||
|
||||
public sealed record DiplomaticRelationSnapshot(
|
||||
string Id,
|
||||
string FactionAId,
|
||||
string FactionBId,
|
||||
string Status,
|
||||
string Posture,
|
||||
float TrustScore,
|
||||
float TensionScore,
|
||||
float GrievanceScore,
|
||||
string TradeAccessPolicy,
|
||||
string MilitaryAccessPolicy,
|
||||
string? WarStateId,
|
||||
DateTimeOffset? CeasefireUntilUtc,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
IReadOnlyList<string> ActiveTreatyIds,
|
||||
IReadOnlyList<string> ActiveIncidentIds);
|
||||
|
||||
public sealed record TreatySnapshot(
|
||||
string Id,
|
||||
string Kind,
|
||||
string Status,
|
||||
string TradeAccessPolicy,
|
||||
string MilitaryAccessPolicy,
|
||||
string? Summary,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
IReadOnlyList<string> FactionIds);
|
||||
|
||||
public sealed record DiplomaticIncidentSnapshot(
|
||||
string Id,
|
||||
string Kind,
|
||||
string Status,
|
||||
string SourceFactionId,
|
||||
string TargetFactionId,
|
||||
string? SystemId,
|
||||
string? BorderEdgeId,
|
||||
string Summary,
|
||||
float Severity,
|
||||
float EscalationScore,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset LastObservedAtUtc);
|
||||
|
||||
public sealed record BorderTensionSnapshot(
|
||||
string Id,
|
||||
string RelationId,
|
||||
string BorderEdgeId,
|
||||
string FactionAId,
|
||||
string FactionBId,
|
||||
string Status,
|
||||
float TensionScore,
|
||||
float IncidentScore,
|
||||
float MilitaryPressure,
|
||||
float AccessFriction,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
IReadOnlyList<string> SystemIds);
|
||||
|
||||
public sealed record WarStateSnapshot(
|
||||
string Id,
|
||||
string RelationId,
|
||||
string FactionAId,
|
||||
string FactionBId,
|
||||
string Status,
|
||||
string WarGoal,
|
||||
float EscalationScore,
|
||||
DateTimeOffset StartedAtUtc,
|
||||
DateTimeOffset? CeasefireUntilUtc,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
IReadOnlyList<string> ActiveFrontLineIds);
|
||||
|
||||
public sealed record DiplomaticStateSnapshot(
|
||||
IReadOnlyList<DiplomaticRelationSnapshot> Relations,
|
||||
IReadOnlyList<TreatySnapshot> Treaties,
|
||||
IReadOnlyList<DiplomaticIncidentSnapshot> Incidents,
|
||||
IReadOnlyList<BorderTensionSnapshot> BorderTensions,
|
||||
IReadOnlyList<WarStateSnapshot> Wars);
|
||||
|
||||
public sealed record TerritoryClaimSnapshot(
|
||||
string Id,
|
||||
string? SourceClaimId,
|
||||
string FactionId,
|
||||
string SystemId,
|
||||
string AnchorId,
|
||||
string Status,
|
||||
string ClaimKind,
|
||||
float ClaimStrength,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record TerritoryInfluenceSnapshot(
|
||||
string Id,
|
||||
string SystemId,
|
||||
string FactionId,
|
||||
float ClaimStrength,
|
||||
float AssetStrength,
|
||||
float LogisticsStrength,
|
||||
float TotalInfluence,
|
||||
bool IsContesting,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record TerritoryControlStateSnapshot(
|
||||
string SystemId,
|
||||
string? ControllerFactionId,
|
||||
string? PrimaryClaimantFactionId,
|
||||
string ControlKind,
|
||||
bool IsContested,
|
||||
float ControlScore,
|
||||
float StrategicValue,
|
||||
IReadOnlyList<string> ClaimantFactionIds,
|
||||
IReadOnlyList<string> InfluencingFactionIds,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record SectorStrategicProfileSnapshot(
|
||||
string SystemId,
|
||||
string? ControllerFactionId,
|
||||
string ZoneKind,
|
||||
bool IsContested,
|
||||
float StrategicValue,
|
||||
float SecurityRating,
|
||||
float TerritorialPressure,
|
||||
float LogisticsValue,
|
||||
string? EconomicRegionId,
|
||||
string? FrontLineId,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record BorderEdgeSnapshot(
|
||||
string Id,
|
||||
string SourceSystemId,
|
||||
string DestinationSystemId,
|
||||
string? SourceFactionId,
|
||||
string? DestinationFactionId,
|
||||
bool IsContested,
|
||||
string? RelationId,
|
||||
float TensionScore,
|
||||
float CorridorImportance,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record FrontLineSnapshot(
|
||||
string Id,
|
||||
string Kind,
|
||||
string Status,
|
||||
string? AnchorSystemId,
|
||||
float PressureScore,
|
||||
float SupplyRisk,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
IReadOnlyList<string> FactionIds,
|
||||
IReadOnlyList<string> SystemIds,
|
||||
IReadOnlyList<string> BorderEdgeIds);
|
||||
|
||||
public sealed record TerritoryZoneSnapshot(
|
||||
string Id,
|
||||
string SystemId,
|
||||
string? FactionId,
|
||||
string Kind,
|
||||
string Status,
|
||||
string? Reason,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record TerritoryPressureSnapshot(
|
||||
string Id,
|
||||
string SystemId,
|
||||
string? FactionId,
|
||||
string Kind,
|
||||
float PressureScore,
|
||||
float SecurityScore,
|
||||
float HostileInfluence,
|
||||
float CorridorRisk,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record TerritoryStateSnapshot(
|
||||
IReadOnlyList<TerritoryClaimSnapshot> Claims,
|
||||
IReadOnlyList<TerritoryInfluenceSnapshot> Influences,
|
||||
IReadOnlyList<TerritoryControlStateSnapshot> ControlStates,
|
||||
IReadOnlyList<SectorStrategicProfileSnapshot> StrategicProfiles,
|
||||
IReadOnlyList<BorderEdgeSnapshot> BorderEdges,
|
||||
IReadOnlyList<FrontLineSnapshot> FrontLines,
|
||||
IReadOnlyList<TerritoryZoneSnapshot> Zones,
|
||||
IReadOnlyList<TerritoryPressureSnapshot> Pressures);
|
||||
|
||||
public sealed record EconomicRegionSnapshot(
|
||||
string Id,
|
||||
string? FactionId,
|
||||
string Label,
|
||||
string Kind,
|
||||
string Status,
|
||||
string CoreSystemId,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
IReadOnlyList<string> SystemIds,
|
||||
IReadOnlyList<string> StationIds,
|
||||
IReadOnlyList<string> FrontLineIds,
|
||||
IReadOnlyList<string> CorridorIds);
|
||||
|
||||
public sealed record SupplyNetworkSnapshot(
|
||||
string Id,
|
||||
string RegionId,
|
||||
float ThroughputScore,
|
||||
float RiskScore,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
IReadOnlyList<string> StationIds,
|
||||
IReadOnlyList<string> ProducerItemIds,
|
||||
IReadOnlyList<string> ConsumerItemIds,
|
||||
IReadOnlyList<string> ConstructionItemIds);
|
||||
|
||||
public sealed record LogisticsCorridorSnapshot(
|
||||
string Id,
|
||||
string? FactionId,
|
||||
string Kind,
|
||||
string Status,
|
||||
float RiskScore,
|
||||
float ThroughputScore,
|
||||
string AccessState,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
IReadOnlyList<string> SystemPathIds,
|
||||
IReadOnlyList<string> RegionIds,
|
||||
IReadOnlyList<string> BorderEdgeIds);
|
||||
|
||||
public sealed record RegionalProductionProfileSnapshot(
|
||||
string RegionId,
|
||||
string PrimaryIndustry,
|
||||
int ShipyardCount,
|
||||
int StationCount,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
IReadOnlyList<string> ProducedItemIds,
|
||||
IReadOnlyList<string> ScarceItemIds);
|
||||
|
||||
public sealed record RegionalTradeBalanceSnapshot(
|
||||
string RegionId,
|
||||
int ImportsRequiredCount,
|
||||
int ExportsSurplusCount,
|
||||
int CriticalShortageCount,
|
||||
float NetTradeScore,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record RegionalBottleneckSnapshot(
|
||||
string Id,
|
||||
string RegionId,
|
||||
string ItemId,
|
||||
string Cause,
|
||||
string Status,
|
||||
float Severity,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record RegionalSecurityAssessmentSnapshot(
|
||||
string RegionId,
|
||||
float SupplyRisk,
|
||||
float BorderPressure,
|
||||
int ActiveWarCount,
|
||||
int HostileRelationCount,
|
||||
float AccessFriction,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record RegionalEconomicAssessmentSnapshot(
|
||||
string RegionId,
|
||||
float SustainmentScore,
|
||||
float ProductionDepth,
|
||||
float ConstructionPressure,
|
||||
float CorridorDependency,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record EconomyRegionStateSnapshot(
|
||||
IReadOnlyList<EconomicRegionSnapshot> Regions,
|
||||
IReadOnlyList<SupplyNetworkSnapshot> SupplyNetworks,
|
||||
IReadOnlyList<LogisticsCorridorSnapshot> Corridors,
|
||||
IReadOnlyList<RegionalProductionProfileSnapshot> ProductionProfiles,
|
||||
IReadOnlyList<RegionalTradeBalanceSnapshot> TradeBalances,
|
||||
IReadOnlyList<RegionalBottleneckSnapshot> Bottlenecks,
|
||||
IReadOnlyList<RegionalSecurityAssessmentSnapshot> SecurityAssessments,
|
||||
IReadOnlyList<RegionalEconomicAssessmentSnapshot> EconomicAssessments);
|
||||
|
||||
public sealed record GeopoliticalStateSnapshot(
|
||||
int Cycle,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
IReadOnlyList<SystemRouteLinkSnapshot> Routes,
|
||||
DiplomaticStateSnapshot Diplomacy,
|
||||
TerritoryStateSnapshot Territory,
|
||||
EconomyRegionStateSnapshot EconomyRegions);
|
||||
336
apps/backend/Geopolitics/Runtime/GeopoliticalRuntimeModels.cs
Normal file
336
apps/backend/Geopolitics/Runtime/GeopoliticalRuntimeModels.cs
Normal file
@@ -0,0 +1,336 @@
|
||||
namespace SpaceGame.Api.Geopolitics.Runtime;
|
||||
|
||||
public sealed class GeopoliticalStateRuntime
|
||||
{
|
||||
public int Cycle { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public List<SystemRouteLinkRuntime> Routes { get; } = [];
|
||||
public DiplomaticStateRuntime Diplomacy { get; set; } = new();
|
||||
public TerritoryStateRuntime Territory { get; set; } = new();
|
||||
public EconomyRegionStateRuntime EconomyRegions { get; set; } = new();
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SystemRouteLinkRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string SourceSystemId { get; set; }
|
||||
public required string DestinationSystemId { get; set; }
|
||||
public float Distance { get; set; }
|
||||
public bool IsPrimaryLane { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class DiplomaticStateRuntime
|
||||
{
|
||||
public List<DiplomaticRelationRuntime> Relations { get; } = [];
|
||||
public List<TreatyRuntime> Treaties { get; } = [];
|
||||
public List<DiplomaticIncidentRuntime> Incidents { get; } = [];
|
||||
public List<BorderTensionRuntime> BorderTensions { get; } = [];
|
||||
public List<WarStateRuntime> Wars { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class DiplomaticRelationRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string FactionAId { get; set; }
|
||||
public required string FactionBId { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string Posture { get; set; } = "neutral";
|
||||
public float TrustScore { get; set; }
|
||||
public float TensionScore { get; set; }
|
||||
public float GrievanceScore { get; set; }
|
||||
public string TradeAccessPolicy { get; set; } = "restricted";
|
||||
public string MilitaryAccessPolicy { get; set; } = "restricted";
|
||||
public string? WarStateId { get; set; }
|
||||
public DateTimeOffset? CeasefireUntilUtc { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public List<string> ActiveTreatyIds { get; } = [];
|
||||
public List<string> ActiveIncidentIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class TreatyRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string TradeAccessPolicy { get; set; } = "restricted";
|
||||
public string MilitaryAccessPolicy { get; set; } = "restricted";
|
||||
public string? Summary { get; set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public List<string> FactionIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class DiplomaticIncidentRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public required string SourceFactionId { get; set; }
|
||||
public required string TargetFactionId { get; set; }
|
||||
public string? SystemId { get; set; }
|
||||
public string? BorderEdgeId { get; set; }
|
||||
public required string Summary { get; set; }
|
||||
public float Severity { get; set; }
|
||||
public float EscalationScore { get; set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset LastObservedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class BorderTensionRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string RelationId { get; set; }
|
||||
public required string BorderEdgeId { get; set; }
|
||||
public required string FactionAId { get; set; }
|
||||
public required string FactionBId { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public float TensionScore { get; set; }
|
||||
public float IncidentScore { get; set; }
|
||||
public float MilitaryPressure { get; set; }
|
||||
public float AccessFriction { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public List<string> SystemIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class WarStateRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string RelationId { get; set; }
|
||||
public required string FactionAId { get; set; }
|
||||
public required string FactionBId { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string WarGoal { get; set; } = "territorial-pressure";
|
||||
public float EscalationScore { get; set; }
|
||||
public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? CeasefireUntilUtc { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public List<string> ActiveFrontLineIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class TerritoryStateRuntime
|
||||
{
|
||||
public List<TerritoryClaimRuntime> Claims { get; } = [];
|
||||
public List<TerritoryInfluenceRuntime> Influences { get; } = [];
|
||||
public List<TerritoryControlStateRuntime> ControlStates { get; } = [];
|
||||
public List<SectorStrategicProfileRuntime> StrategicProfiles { get; } = [];
|
||||
public List<BorderEdgeRuntime> BorderEdges { get; } = [];
|
||||
public List<FrontLineRuntime> FrontLines { get; } = [];
|
||||
public List<TerritoryZoneRuntime> Zones { get; } = [];
|
||||
public List<TerritoryPressureRuntime> Pressures { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class TerritoryClaimRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public string? SourceClaimId { get; set; }
|
||||
public required string FactionId { get; set; }
|
||||
public required string SystemId { get; set; }
|
||||
public required string AnchorId { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string ClaimKind { get; set; } = "infrastructure";
|
||||
public float ClaimStrength { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class TerritoryInfluenceRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string SystemId { get; set; }
|
||||
public required string FactionId { get; set; }
|
||||
public float ClaimStrength { get; set; }
|
||||
public float AssetStrength { get; set; }
|
||||
public float LogisticsStrength { get; set; }
|
||||
public float TotalInfluence { get; set; }
|
||||
public bool IsContesting { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class TerritoryControlStateRuntime
|
||||
{
|
||||
public required string SystemId { get; init; }
|
||||
public string? ControllerFactionId { get; set; }
|
||||
public string? PrimaryClaimantFactionId { get; set; }
|
||||
public string ControlKind { get; set; } = "unclaimed";
|
||||
public bool IsContested { get; set; }
|
||||
public float ControlScore { get; set; }
|
||||
public float StrategicValue { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public List<string> ClaimantFactionIds { get; } = [];
|
||||
public List<string> InfluencingFactionIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class SectorStrategicProfileRuntime
|
||||
{
|
||||
public required string SystemId { get; init; }
|
||||
public string? ControllerFactionId { get; set; }
|
||||
public string ZoneKind { get; set; } = "unclaimed";
|
||||
public bool IsContested { get; set; }
|
||||
public float StrategicValue { get; set; }
|
||||
public float SecurityRating { get; set; }
|
||||
public float TerritorialPressure { get; set; }
|
||||
public float LogisticsValue { get; set; }
|
||||
public string? EconomicRegionId { get; set; }
|
||||
public string? FrontLineId { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class BorderEdgeRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string SourceSystemId { get; set; }
|
||||
public required string DestinationSystemId { get; set; }
|
||||
public string? SourceFactionId { get; set; }
|
||||
public string? DestinationFactionId { get; set; }
|
||||
public bool IsContested { get; set; }
|
||||
public string? RelationId { get; set; }
|
||||
public float TensionScore { get; set; }
|
||||
public float CorridorImportance { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class FrontLineRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public string Kind { get; set; } = "border-front";
|
||||
public string Status { get; set; } = "active";
|
||||
public string? AnchorSystemId { get; set; }
|
||||
public float PressureScore { get; set; }
|
||||
public float SupplyRisk { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public List<string> FactionIds { get; } = [];
|
||||
public List<string> SystemIds { get; } = [];
|
||||
public List<string> BorderEdgeIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class TerritoryZoneRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string SystemId { get; set; }
|
||||
public string? FactionId { get; set; }
|
||||
public string Kind { get; set; } = "unclaimed";
|
||||
public string Status { get; set; } = "active";
|
||||
public string? Reason { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class TerritoryPressureRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string SystemId { get; set; }
|
||||
public string? FactionId { get; set; }
|
||||
public string Kind { get; set; } = "border-pressure";
|
||||
public float PressureScore { get; set; }
|
||||
public float SecurityScore { get; set; }
|
||||
public float HostileInfluence { get; set; }
|
||||
public float CorridorRisk { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class EconomyRegionStateRuntime
|
||||
{
|
||||
public List<EconomicRegionRuntime> Regions { get; } = [];
|
||||
public List<SupplyNetworkRuntime> SupplyNetworks { get; } = [];
|
||||
public List<LogisticsCorridorRuntime> Corridors { get; } = [];
|
||||
public List<RegionalProductionProfileRuntime> ProductionProfiles { get; } = [];
|
||||
public List<RegionalTradeBalanceRuntime> TradeBalances { get; } = [];
|
||||
public List<RegionalBottleneckRuntime> Bottlenecks { get; } = [];
|
||||
public List<RegionalSecurityAssessmentRuntime> SecurityAssessments { get; } = [];
|
||||
public List<RegionalEconomicAssessmentRuntime> EconomicAssessments { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class EconomicRegionRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public string? FactionId { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public string Kind { get; set; } = "balanced-region";
|
||||
public string Status { get; set; } = "active";
|
||||
public required string CoreSystemId { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public List<string> SystemIds { get; } = [];
|
||||
public List<string> StationIds { get; } = [];
|
||||
public List<string> FrontLineIds { get; } = [];
|
||||
public List<string> CorridorIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class SupplyNetworkRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string RegionId { get; set; }
|
||||
public float ThroughputScore { get; set; }
|
||||
public float RiskScore { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public List<string> StationIds { get; } = [];
|
||||
public List<string> ProducerItemIds { get; } = [];
|
||||
public List<string> ConsumerItemIds { get; } = [];
|
||||
public List<string> ConstructionItemIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class LogisticsCorridorRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public string? FactionId { get; set; }
|
||||
public string Kind { get; set; } = "supply-corridor";
|
||||
public string Status { get; set; } = "active";
|
||||
public float RiskScore { get; set; }
|
||||
public float ThroughputScore { get; set; }
|
||||
public string AccessState { get; set; } = "restricted";
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public List<string> SystemPathIds { get; } = [];
|
||||
public List<string> RegionIds { get; } = [];
|
||||
public List<string> BorderEdgeIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class RegionalProductionProfileRuntime
|
||||
{
|
||||
public required string RegionId { get; set; }
|
||||
public string PrimaryIndustry { get; set; } = "mixed";
|
||||
public int ShipyardCount { get; set; }
|
||||
public int StationCount { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public List<string> ProducedItemIds { get; } = [];
|
||||
public List<string> ScarceItemIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class RegionalTradeBalanceRuntime
|
||||
{
|
||||
public required string RegionId { get; set; }
|
||||
public int ImportsRequiredCount { get; set; }
|
||||
public int ExportsSurplusCount { get; set; }
|
||||
public int CriticalShortageCount { get; set; }
|
||||
public float NetTradeScore { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class RegionalBottleneckRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string RegionId { get; set; }
|
||||
public required string ItemId { get; set; }
|
||||
public string Cause { get; set; } = "regional-shortage";
|
||||
public string Status { get; set; } = "active";
|
||||
public float Severity { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class RegionalSecurityAssessmentRuntime
|
||||
{
|
||||
public required string RegionId { get; set; }
|
||||
public float SupplyRisk { get; set; }
|
||||
public float BorderPressure { get; set; }
|
||||
public int ActiveWarCount { get; set; }
|
||||
public int HostileRelationCount { get; set; }
|
||||
public float AccessFriction { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class RegionalEconomicAssessmentRuntime
|
||||
{
|
||||
public required string RegionId { get; set; }
|
||||
public float SustainmentScore { get; set; }
|
||||
public float ProductionDepth { get; set; }
|
||||
public float ConstructionPressure { get; set; }
|
||||
public float CorridorDependency { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,935 @@
|
||||
using System.Globalization;
|
||||
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
|
||||
namespace SpaceGame.Api.Geopolitics.Simulation;
|
||||
|
||||
internal sealed class GeopoliticalSimulationService
|
||||
{
|
||||
internal void Update(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
var state = EnsureState(world);
|
||||
state.Cycle += 1;
|
||||
state.UpdatedAtUtc = world.GeneratedAtUtc;
|
||||
|
||||
RebuildRoutes(world, state);
|
||||
RebuildTerritory(world, state);
|
||||
RebuildDiplomacy(world, state, events);
|
||||
RebuildEconomyRegions(world, state);
|
||||
}
|
||||
|
||||
internal static GeopoliticalStateRuntime EnsureState(SimulationWorld world)
|
||||
{
|
||||
world.Geopolitics ??= new GeopoliticalStateRuntime();
|
||||
return world.Geopolitics;
|
||||
}
|
||||
|
||||
internal static DiplomaticRelationRuntime? FindRelation(SimulationWorld world, string factionAId, string factionBId)
|
||||
{
|
||||
var state = EnsureState(world);
|
||||
return state.Diplomacy.Relations.FirstOrDefault(relation => string.Equals(relation.Id, BuildRelationId(factionAId, factionBId), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
internal static WarStateRuntime? FindWarState(SimulationWorld world, string factionAId, string factionBId) =>
|
||||
EnsureState(world).Diplomacy.Wars.FirstOrDefault(war => string.Equals(war.RelationId, BuildRelationId(factionAId, factionBId), StringComparison.Ordinal) && war.Status == "active");
|
||||
|
||||
internal static TerritoryControlStateRuntime? GetSystemControlState(SimulationWorld world, string systemId) =>
|
||||
EnsureState(world).Territory.ControlStates.FirstOrDefault(state => string.Equals(state.SystemId, systemId, StringComparison.Ordinal));
|
||||
|
||||
internal static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) =>
|
||||
string.Equals(GetSystemControlState(world, systemId)?.ControllerFactionId, factionId, StringComparison.Ordinal);
|
||||
|
||||
internal static IReadOnlyList<string> GetControlledSystems(SimulationWorld world, string factionId) =>
|
||||
EnsureState(world).Territory.ControlStates
|
||||
.Where(state => string.Equals(state.ControllerFactionId, factionId, StringComparison.Ordinal))
|
||||
.OrderBy(state => state.SystemId, StringComparer.Ordinal)
|
||||
.Select(state => state.SystemId)
|
||||
.ToList();
|
||||
|
||||
internal static float GetSystemRouteRisk(SimulationWorld world, string systemId, string? factionId = null)
|
||||
{
|
||||
var pressure = EnsureState(world).Territory.Pressures
|
||||
.Where(entry => string.Equals(entry.SystemId, systemId, StringComparison.Ordinal)
|
||||
&& (factionId is null || string.Equals(entry.FactionId, factionId, StringComparison.Ordinal)))
|
||||
.OrderByDescending(entry => entry.CorridorRisk)
|
||||
.ThenBy(entry => entry.Id, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
return pressure?.CorridorRisk
|
||||
?? EnsureState(world).Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == systemId)?.TerritorialPressure
|
||||
?? 0f;
|
||||
}
|
||||
|
||||
internal static bool HasHostileRelation(SimulationWorld world, string factionAId, string factionBId)
|
||||
{
|
||||
if (string.Equals(factionAId, factionBId, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var relation = FindRelation(world, factionAId, factionBId);
|
||||
return relation is not null && relation.Posture is "hostile" or "war";
|
||||
}
|
||||
|
||||
internal static bool HasTradeAccess(SimulationWorld world, string factionAId, string factionBId)
|
||||
{
|
||||
if (string.Equals(factionAId, factionBId, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var relation = FindRelation(world, factionAId, factionBId);
|
||||
return relation?.TradeAccessPolicy is "open" or "allied";
|
||||
}
|
||||
|
||||
internal static bool HasMilitaryAccess(SimulationWorld world, string factionAId, string factionBId)
|
||||
{
|
||||
if (string.Equals(factionAId, factionBId, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var relation = FindRelation(world, factionAId, factionBId);
|
||||
return relation?.MilitaryAccessPolicy is "open" or "allied";
|
||||
}
|
||||
|
||||
internal static EconomicRegionRuntime? GetPrimaryEconomicRegion(SimulationWorld world, string factionId, string systemId) =>
|
||||
EnsureState(world).EconomyRegions.Regions.FirstOrDefault(region =>
|
||||
string.Equals(region.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& region.SystemIds.Contains(systemId, StringComparer.Ordinal));
|
||||
|
||||
private static void RebuildRoutes(SimulationWorld world, GeopoliticalStateRuntime state)
|
||||
{
|
||||
state.Routes.Clear();
|
||||
if (world.Systems.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var systems = world.Systems
|
||||
.OrderBy(system => system.Definition.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var routeIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var system in systems)
|
||||
{
|
||||
foreach (var neighbor in systems
|
||||
.Where(candidate => candidate.Definition.Id != system.Definition.Id)
|
||||
.Select(candidate => new
|
||||
{
|
||||
candidate.Definition.Id,
|
||||
Distance = system.Position.DistanceTo(candidate.Position),
|
||||
})
|
||||
.OrderBy(candidate => candidate.Distance)
|
||||
.ThenBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.Take(Math.Min(3, systems.Count - 1)))
|
||||
{
|
||||
var routeId = BuildPairId("route", system.Definition.Id, neighbor.Id);
|
||||
if (!routeIds.Add(routeId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
state.Routes.Add(new SystemRouteLinkRuntime
|
||||
{
|
||||
Id = routeId,
|
||||
SourceSystemId = string.Compare(system.Definition.Id, neighbor.Id, StringComparison.Ordinal) <= 0 ? system.Definition.Id : neighbor.Id,
|
||||
DestinationSystemId = string.Compare(system.Definition.Id, neighbor.Id, StringComparison.Ordinal) <= 0 ? neighbor.Id : system.Definition.Id,
|
||||
Distance = neighbor.Distance,
|
||||
IsPrimaryLane = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RebuildTerritory(SimulationWorld world, GeopoliticalStateRuntime state)
|
||||
{
|
||||
state.Territory.Claims.Clear();
|
||||
state.Territory.Influences.Clear();
|
||||
state.Territory.ControlStates.Clear();
|
||||
state.Territory.StrategicProfiles.Clear();
|
||||
state.Territory.BorderEdges.Clear();
|
||||
state.Territory.FrontLines.Clear();
|
||||
state.Territory.Zones.Clear();
|
||||
state.Territory.Pressures.Clear();
|
||||
|
||||
var nowUtc = world.GeneratedAtUtc;
|
||||
foreach (var claim in world.Claims.Where(claim => claim.State != ClaimStateKinds.Destroyed))
|
||||
{
|
||||
state.Territory.Claims.Add(new TerritoryClaimRuntime
|
||||
{
|
||||
Id = $"territory-{claim.Id}",
|
||||
SourceClaimId = claim.Id,
|
||||
FactionId = claim.FactionId,
|
||||
SystemId = claim.SystemId,
|
||||
AnchorId = claim.AnchorId,
|
||||
Status = claim.State,
|
||||
ClaimKind = "infrastructure",
|
||||
ClaimStrength = claim.State == ClaimStateKinds.Active ? 1f : 0.65f,
|
||||
UpdatedAtUtc = nowUtc,
|
||||
});
|
||||
}
|
||||
|
||||
var influencesBySystem = new Dictionary<string, List<TerritoryInfluenceRuntime>>(StringComparer.Ordinal);
|
||||
foreach (var system in world.Systems)
|
||||
{
|
||||
var claimsByFaction = state.Territory.Claims
|
||||
.Where(claim => claim.SystemId == system.Definition.Id)
|
||||
.GroupBy(claim => claim.FactionId, StringComparer.Ordinal);
|
||||
var stationsByFaction = world.Stations
|
||||
.Where(station => station.SystemId == system.Definition.Id)
|
||||
.GroupBy(station => station.FactionId, StringComparer.Ordinal);
|
||||
var shipsByFaction = world.Ships
|
||||
.Where(ship => ship.SystemId == system.Definition.Id && ship.Health > 0f)
|
||||
.GroupBy(ship => ship.FactionId, StringComparer.Ordinal);
|
||||
var sitesByFaction = world.ConstructionSites
|
||||
.Where(site => site.SystemId == system.Definition.Id && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed)
|
||||
.GroupBy(site => site.FactionId, StringComparer.Ordinal);
|
||||
|
||||
var factionIds = claimsByFaction.Select(group => group.Key)
|
||||
.Concat(stationsByFaction.Select(group => group.Key))
|
||||
.Concat(shipsByFaction.Select(group => group.Key))
|
||||
.Concat(sitesByFaction.Select(group => group.Key))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var influences = new List<TerritoryInfluenceRuntime>();
|
||||
foreach (var factionId in factionIds)
|
||||
{
|
||||
var claimStrength = claimsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(claim => claim.ClaimStrength * 40f) ?? 0f;
|
||||
var stationStrength = (stationsByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 50f;
|
||||
var siteStrength = (sitesByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 18f;
|
||||
var shipStrength = shipsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(ship =>
|
||||
{
|
||||
if (IsMilitaryShip(ship.Definition))
|
||||
{
|
||||
return 9f;
|
||||
}
|
||||
|
||||
if (IsConstructionShip(ship.Definition))
|
||||
{
|
||||
return 4f;
|
||||
}
|
||||
|
||||
if (IsTransportShip(ship.Definition) || IsMiningShip(ship.Definition))
|
||||
{
|
||||
return 3f;
|
||||
}
|
||||
|
||||
return 2f;
|
||||
}) ?? 0f;
|
||||
var logisticsStrength = MathF.Min(30f, stationStrength * 0.18f) + siteStrength;
|
||||
influences.Add(new TerritoryInfluenceRuntime
|
||||
{
|
||||
Id = $"influence-{system.Definition.Id}-{factionId}",
|
||||
SystemId = system.Definition.Id,
|
||||
FactionId = factionId,
|
||||
ClaimStrength = claimStrength,
|
||||
AssetStrength = stationStrength + shipStrength,
|
||||
LogisticsStrength = logisticsStrength,
|
||||
TotalInfluence = claimStrength + stationStrength + shipStrength + logisticsStrength,
|
||||
UpdatedAtUtc = nowUtc,
|
||||
});
|
||||
}
|
||||
|
||||
influences.Sort((left, right) =>
|
||||
{
|
||||
var total = right.TotalInfluence.CompareTo(left.TotalInfluence);
|
||||
return total != 0 ? total : string.Compare(left.FactionId, right.FactionId, StringComparison.Ordinal);
|
||||
});
|
||||
if (influences.Count > 1)
|
||||
{
|
||||
var lead = influences[0].TotalInfluence;
|
||||
foreach (var influence in influences.Skip(1))
|
||||
{
|
||||
influence.IsContesting = influence.TotalInfluence >= (lead * 0.7f);
|
||||
}
|
||||
|
||||
influences[0].IsContesting = influences[1].TotalInfluence >= (lead * 0.7f);
|
||||
}
|
||||
|
||||
influencesBySystem[system.Definition.Id] = influences;
|
||||
state.Territory.Influences.AddRange(influences);
|
||||
|
||||
var top = influences.FirstOrDefault();
|
||||
var second = influences.Skip(1).FirstOrDefault();
|
||||
var contested = top is not null && second is not null && second.TotalInfluence >= (top.TotalInfluence * 0.7f);
|
||||
var controllerFactionId = top is not null && (!contested || top.TotalInfluence >= second!.TotalInfluence + 20f)
|
||||
? top.FactionId
|
||||
: null;
|
||||
var primaryClaimantFactionId = state.Territory.Claims
|
||||
.Where(claim => claim.SystemId == system.Definition.Id)
|
||||
.GroupBy(claim => claim.FactionId, StringComparer.Ordinal)
|
||||
.OrderByDescending(group => group.Sum(claim => claim.ClaimStrength))
|
||||
.ThenBy(group => group.Key, StringComparer.Ordinal)
|
||||
.Select(group => group.Key)
|
||||
.FirstOrDefault();
|
||||
|
||||
var strategicValue = EstimateSystemStrategicValue(world, system.Definition.Id);
|
||||
var controlState = new TerritoryControlStateRuntime
|
||||
{
|
||||
SystemId = system.Definition.Id,
|
||||
ControllerFactionId = controllerFactionId,
|
||||
PrimaryClaimantFactionId = primaryClaimantFactionId,
|
||||
ControlKind = contested
|
||||
? "contested"
|
||||
: controllerFactionId is not null
|
||||
? "controlled"
|
||||
: primaryClaimantFactionId is not null
|
||||
? "claimed"
|
||||
: "unclaimed",
|
||||
IsContested = contested,
|
||||
ControlScore = top?.TotalInfluence ?? 0f,
|
||||
StrategicValue = strategicValue,
|
||||
UpdatedAtUtc = nowUtc,
|
||||
};
|
||||
controlState.ClaimantFactionIds.AddRange(state.Territory.Claims
|
||||
.Where(claim => claim.SystemId == system.Definition.Id)
|
||||
.Select(claim => claim.FactionId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal));
|
||||
controlState.InfluencingFactionIds.AddRange(influences
|
||||
.Select(influence => influence.FactionId)
|
||||
.OrderBy(id => id, StringComparer.Ordinal));
|
||||
state.Territory.ControlStates.Add(controlState);
|
||||
}
|
||||
|
||||
foreach (var route in state.Routes)
|
||||
{
|
||||
var left = state.Territory.ControlStates.First(stateItem => stateItem.SystemId == route.SourceSystemId);
|
||||
var right = state.Territory.ControlStates.First(stateItem => stateItem.SystemId == route.DestinationSystemId);
|
||||
var differentControllers = !string.Equals(left.ControllerFactionId, right.ControllerFactionId, StringComparison.Ordinal);
|
||||
var contested = left.IsContested || right.IsContested || differentControllers;
|
||||
if (!contested && left.ControllerFactionId is null && right.ControllerFactionId is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
state.Territory.BorderEdges.Add(new BorderEdgeRuntime
|
||||
{
|
||||
Id = $"border-{route.Id}",
|
||||
SourceSystemId = route.SourceSystemId,
|
||||
DestinationSystemId = route.DestinationSystemId,
|
||||
SourceFactionId = left.ControllerFactionId ?? left.PrimaryClaimantFactionId,
|
||||
DestinationFactionId = right.ControllerFactionId ?? right.PrimaryClaimantFactionId,
|
||||
IsContested = contested,
|
||||
TensionScore = MathF.Min(1f, MathF.Abs((left.ControlScore - right.ControlScore) / MathF.Max(50f, left.ControlScore + right.ControlScore))),
|
||||
CorridorImportance = route.Distance <= 0.01f ? 0f : Math.Clamp((left.StrategicValue + right.StrategicValue) / MathF.Max(route.Distance, 1f), 0f, 1f),
|
||||
UpdatedAtUtc = nowUtc,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var control in state.Territory.ControlStates)
|
||||
{
|
||||
var adjacentBorders = state.Territory.BorderEdges.Where(edge => edge.SourceSystemId == control.SystemId || edge.DestinationSystemId == control.SystemId).ToList();
|
||||
var hostileBorderCount = adjacentBorders.Count(edge => edge.IsContested);
|
||||
var corridorImportance = adjacentBorders.Sum(edge => edge.CorridorImportance);
|
||||
var zoneKind = control.IsContested
|
||||
? "contested"
|
||||
: control.ControllerFactionId is null && control.PrimaryClaimantFactionId is not null
|
||||
? "buffer"
|
||||
: control.ControllerFactionId is not null && hostileBorderCount == 0
|
||||
? "core"
|
||||
: control.ControllerFactionId is not null && corridorImportance > 1.1f
|
||||
? "corridor"
|
||||
: control.ControllerFactionId is not null
|
||||
? "frontier"
|
||||
: "unclaimed";
|
||||
state.Territory.Zones.Add(new TerritoryZoneRuntime
|
||||
{
|
||||
Id = $"zone-{control.SystemId}",
|
||||
SystemId = control.SystemId,
|
||||
FactionId = control.ControllerFactionId ?? control.PrimaryClaimantFactionId,
|
||||
Kind = zoneKind,
|
||||
Status = "active",
|
||||
Reason = zoneKind == "corridor" ? "high-corridor-importance" : zoneKind == "frontier" ? "hostile-border-contact" : zoneKind,
|
||||
UpdatedAtUtc = nowUtc,
|
||||
});
|
||||
state.Territory.StrategicProfiles.Add(new SectorStrategicProfileRuntime
|
||||
{
|
||||
SystemId = control.SystemId,
|
||||
ControllerFactionId = control.ControllerFactionId,
|
||||
ZoneKind = zoneKind,
|
||||
IsContested = control.IsContested,
|
||||
StrategicValue = control.StrategicValue,
|
||||
SecurityRating = Math.Clamp(1f - (hostileBorderCount * 0.22f), 0f, 1f),
|
||||
TerritorialPressure = Math.Clamp(hostileBorderCount * 0.25f, 0f, 1f),
|
||||
LogisticsValue = Math.Clamp(corridorImportance, 0f, 1f),
|
||||
UpdatedAtUtc = nowUtc,
|
||||
});
|
||||
state.Territory.Pressures.Add(new TerritoryPressureRuntime
|
||||
{
|
||||
Id = $"pressure-{control.SystemId}",
|
||||
SystemId = control.SystemId,
|
||||
FactionId = control.ControllerFactionId ?? control.PrimaryClaimantFactionId,
|
||||
Kind = control.IsContested ? "contested-pressure" : "territorial-pressure",
|
||||
PressureScore = Math.Clamp(hostileBorderCount * 0.28f, 0f, 1f),
|
||||
SecurityScore = Math.Clamp(1f - (hostileBorderCount * 0.2f), 0f, 1f),
|
||||
HostileInfluence = influencesBySystem.GetValueOrDefault(control.SystemId)?.Skip(control.ControllerFactionId is null ? 0 : 1).Sum(entry => entry.TotalInfluence) ?? 0f,
|
||||
CorridorRisk = Math.Clamp(corridorImportance > 0.8f && hostileBorderCount > 0 ? 0.7f : hostileBorderCount * 0.2f, 0f, 1f),
|
||||
UpdatedAtUtc = nowUtc,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void RebuildDiplomacy(SimulationWorld world, GeopoliticalStateRuntime state, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
state.Diplomacy.Relations.Clear();
|
||||
state.Diplomacy.Treaties.Clear();
|
||||
state.Diplomacy.BorderTensions.Clear();
|
||||
state.Diplomacy.Wars.Clear();
|
||||
|
||||
var nowUtc = world.GeneratedAtUtc;
|
||||
var factionPairs = world.Factions
|
||||
.OrderBy(faction => faction.Id, StringComparer.Ordinal)
|
||||
.SelectMany((left, index) => world.Factions.Skip(index + 1).Select(right => (left, right)));
|
||||
|
||||
foreach (var (leftFaction, rightFaction) in factionPairs)
|
||||
{
|
||||
var borderEdges = state.Territory.BorderEdges
|
||||
.Where(edge =>
|
||||
(string.Equals(edge.SourceFactionId, leftFaction.Id, StringComparison.Ordinal) && string.Equals(edge.DestinationFactionId, rightFaction.Id, StringComparison.Ordinal))
|
||||
|| (string.Equals(edge.SourceFactionId, rightFaction.Id, StringComparison.Ordinal) && string.Equals(edge.DestinationFactionId, leftFaction.Id, StringComparison.Ordinal)))
|
||||
.OrderBy(edge => edge.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var sharedBorderPressure = borderEdges.Sum(edge => edge.TensionScore + (edge.IsContested ? 0.25f : 0f));
|
||||
var conflictSystems = borderEdges.SelectMany(edge => new[] { edge.SourceSystemId, edge.DestinationSystemId }).Distinct(StringComparer.Ordinal).ToList();
|
||||
var hostilePresence = world.Ships.Count(ship =>
|
||||
ship.Health > 0f
|
||||
&& ((ship.FactionId == leftFaction.Id && conflictSystems.Contains(ship.SystemId, StringComparer.Ordinal))
|
||||
|| (ship.FactionId == rightFaction.Id && conflictSystems.Contains(ship.SystemId, StringComparer.Ordinal))));
|
||||
var incidentSeverity = Math.Clamp(sharedBorderPressure + (hostilePresence * 0.03f), 0f, 1.6f);
|
||||
var relationId = BuildRelationId(leftFaction.Id, rightFaction.Id);
|
||||
var posture = incidentSeverity switch
|
||||
{
|
||||
>= 1.1f => "war",
|
||||
>= 0.65f => "hostile",
|
||||
>= 0.3f => "wary",
|
||||
_ => "neutral",
|
||||
};
|
||||
|
||||
var relation = new DiplomaticRelationRuntime
|
||||
{
|
||||
Id = relationId,
|
||||
FactionAId = leftFaction.Id,
|
||||
FactionBId = rightFaction.Id,
|
||||
Status = "active",
|
||||
Posture = posture,
|
||||
TrustScore = Math.Clamp(0.7f - incidentSeverity, 0f, 1f),
|
||||
TensionScore = Math.Clamp(incidentSeverity, 0f, 1f),
|
||||
GrievanceScore = Math.Clamp(sharedBorderPressure, 0f, 1f),
|
||||
TradeAccessPolicy = posture is "war" or "hostile" ? "restricted" : "open",
|
||||
MilitaryAccessPolicy = posture == "neutral" ? "transit" : posture == "wary" ? "restricted" : "denied",
|
||||
UpdatedAtUtc = nowUtc,
|
||||
};
|
||||
|
||||
if (relation.Posture == "neutral")
|
||||
{
|
||||
var treaty = new TreatyRuntime
|
||||
{
|
||||
Id = $"treaty-open-trade-{relationId}",
|
||||
Kind = "trade-understanding",
|
||||
Status = "active",
|
||||
TradeAccessPolicy = "open",
|
||||
MilitaryAccessPolicy = "restricted",
|
||||
Summary = $"Open civilian trade between {leftFaction.Label} and {rightFaction.Label}.",
|
||||
CreatedAtUtc = nowUtc,
|
||||
UpdatedAtUtc = nowUtc,
|
||||
};
|
||||
treaty.FactionIds.Add(leftFaction.Id);
|
||||
treaty.FactionIds.Add(rightFaction.Id);
|
||||
state.Diplomacy.Treaties.Add(treaty);
|
||||
relation.ActiveTreatyIds.Add(treaty.Id);
|
||||
relation.TradeAccessPolicy = "open";
|
||||
}
|
||||
|
||||
state.Diplomacy.Relations.Add(relation);
|
||||
|
||||
foreach (var borderEdge in borderEdges)
|
||||
{
|
||||
borderEdge.RelationId = relation.Id;
|
||||
borderEdge.TensionScore = Math.Clamp(borderEdge.TensionScore + (relation.TensionScore * 0.35f), 0f, 1f);
|
||||
var tension = new BorderTensionRuntime
|
||||
{
|
||||
Id = $"tension-{borderEdge.Id}",
|
||||
RelationId = relation.Id,
|
||||
BorderEdgeId = borderEdge.Id,
|
||||
FactionAId = leftFaction.Id,
|
||||
FactionBId = rightFaction.Id,
|
||||
Status = relation.Posture is "war" or "hostile" ? "escalating" : "stable",
|
||||
TensionScore = relation.TensionScore,
|
||||
IncidentScore = incidentSeverity,
|
||||
MilitaryPressure = Math.Clamp(hostilePresence * 0.05f, 0f, 1f),
|
||||
AccessFriction = relation.TradeAccessPolicy == "open" ? 0.15f : 0.75f,
|
||||
UpdatedAtUtc = nowUtc,
|
||||
};
|
||||
tension.SystemIds.Add(borderEdge.SourceSystemId);
|
||||
tension.SystemIds.Add(borderEdge.DestinationSystemId);
|
||||
state.Diplomacy.BorderTensions.Add(tension);
|
||||
|
||||
if (tension.TensionScore >= 0.35f)
|
||||
{
|
||||
var incidentId = $"incident-border-{relationId}-{borderEdge.Id}";
|
||||
var incident = new DiplomaticIncidentRuntime
|
||||
{
|
||||
Id = incidentId,
|
||||
Kind = borderEdge.IsContested ? "border-clash" : "border-friction",
|
||||
Status = relation.Posture == "war" ? "escalated" : "active",
|
||||
SourceFactionId = leftFaction.Id,
|
||||
TargetFactionId = rightFaction.Id,
|
||||
SystemId = borderEdge.SourceSystemId,
|
||||
BorderEdgeId = borderEdge.Id,
|
||||
Summary = $"{leftFaction.Label} and {rightFaction.Label} are under pressure on {borderEdge.SourceSystemId}/{borderEdge.DestinationSystemId}.",
|
||||
Severity = tension.TensionScore,
|
||||
EscalationScore = tension.IncidentScore,
|
||||
CreatedAtUtc = nowUtc,
|
||||
LastObservedAtUtc = nowUtc,
|
||||
};
|
||||
state.Diplomacy.Incidents.Add(incident);
|
||||
relation.ActiveIncidentIds.Add(incident.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (relation.Posture == "war")
|
||||
{
|
||||
var warId = $"war-{relationId}";
|
||||
var war = new WarStateRuntime
|
||||
{
|
||||
Id = warId,
|
||||
RelationId = relation.Id,
|
||||
FactionAId = leftFaction.Id,
|
||||
FactionBId = rightFaction.Id,
|
||||
Status = "active",
|
||||
WarGoal = "border-dominance",
|
||||
EscalationScore = relation.TensionScore,
|
||||
StartedAtUtc = nowUtc,
|
||||
UpdatedAtUtc = nowUtc,
|
||||
};
|
||||
relation.WarStateId = war.Id;
|
||||
state.Diplomacy.Wars.Add(war);
|
||||
}
|
||||
}
|
||||
|
||||
BuildFrontLines(state, nowUtc, events);
|
||||
}
|
||||
|
||||
private static void BuildFrontLines(GeopoliticalStateRuntime state, DateTimeOffset nowUtc, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
foreach (var group in state.Diplomacy.BorderTensions
|
||||
.Where(tension => tension.TensionScore >= 0.35f)
|
||||
.GroupBy(tension => BuildPairId("front", tension.FactionAId, tension.FactionBId), StringComparer.Ordinal))
|
||||
{
|
||||
var tensions = group.OrderByDescending(tension => tension.TensionScore).ThenBy(tension => tension.Id, StringComparer.Ordinal).ToList();
|
||||
var front = new FrontLineRuntime
|
||||
{
|
||||
Id = group.Key,
|
||||
Kind = state.Diplomacy.Wars.Any(war => war.RelationId == tensions[0].RelationId && war.Status == "active") ? "war-front" : "border-front",
|
||||
Status = "active",
|
||||
AnchorSystemId = tensions.SelectMany(tension => tension.SystemIds).GroupBy(systemId => systemId, StringComparer.Ordinal).OrderByDescending(entry => entry.Count()).ThenBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => entry.Key).FirstOrDefault(),
|
||||
PressureScore = Math.Clamp(tensions.Sum(tension => tension.TensionScore) / tensions.Count, 0f, 1f),
|
||||
SupplyRisk = Math.Clamp(tensions.Sum(tension => tension.AccessFriction) / tensions.Count, 0f, 1f),
|
||||
UpdatedAtUtc = nowUtc,
|
||||
};
|
||||
front.FactionIds.Add(tensions[0].FactionAId);
|
||||
front.FactionIds.Add(tensions[0].FactionBId);
|
||||
front.SystemIds.AddRange(tensions.SelectMany(tension => tension.SystemIds).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal));
|
||||
front.BorderEdgeIds.AddRange(tensions.Select(tension => tension.BorderEdgeId).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal));
|
||||
state.Territory.FrontLines.Add(front);
|
||||
|
||||
foreach (var war in state.Diplomacy.Wars.Where(war => string.Equals(war.RelationId, tensions[0].RelationId, StringComparison.Ordinal)))
|
||||
{
|
||||
war.ActiveFrontLineIds.Add(front.Id);
|
||||
}
|
||||
|
||||
events.Add(new SimulationEventRecord("front-line", front.Id, "front-updated", $"Front {front.Id} pressure {front.PressureScore.ToString("0.00", CultureInfo.InvariantCulture)}.", nowUtc, "geopolitics"));
|
||||
}
|
||||
|
||||
foreach (var profile in state.Territory.StrategicProfiles)
|
||||
{
|
||||
profile.FrontLineId = state.Territory.FrontLines.FirstOrDefault(front => front.SystemIds.Contains(profile.SystemId, StringComparer.Ordinal))?.Id;
|
||||
}
|
||||
}
|
||||
|
||||
private static void RebuildEconomyRegions(SimulationWorld world, GeopoliticalStateRuntime state)
|
||||
{
|
||||
state.EconomyRegions.Regions.Clear();
|
||||
state.EconomyRegions.SupplyNetworks.Clear();
|
||||
state.EconomyRegions.Corridors.Clear();
|
||||
state.EconomyRegions.ProductionProfiles.Clear();
|
||||
state.EconomyRegions.TradeBalances.Clear();
|
||||
state.EconomyRegions.Bottlenecks.Clear();
|
||||
state.EconomyRegions.SecurityAssessments.Clear();
|
||||
state.EconomyRegions.EconomicAssessments.Clear();
|
||||
|
||||
var nowUtc = world.GeneratedAtUtc;
|
||||
foreach (var faction in world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal))
|
||||
{
|
||||
var factionSystems = state.Territory.ControlStates
|
||||
.Where(control => string.Equals(control.ControllerFactionId ?? control.PrimaryClaimantFactionId, faction.Id, StringComparison.Ordinal))
|
||||
.Select(control => control.SystemId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(systemId => systemId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (factionSystems.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var connectedComponents = BuildConnectedComponents(factionSystems, state.Routes);
|
||||
foreach (var component in connectedComponents)
|
||||
{
|
||||
var coreSystemId = component
|
||||
.OrderByDescending(systemId => world.Stations.Count(station => station.FactionId == faction.Id && station.SystemId == systemId))
|
||||
.ThenBy(systemId => systemId, StringComparer.Ordinal)
|
||||
.First();
|
||||
var regionId = $"region-{faction.Id}-{coreSystemId}";
|
||||
var stations = world.Stations
|
||||
.Where(station => station.FactionId == faction.Id && component.Contains(station.SystemId, StringComparer.Ordinal))
|
||||
.OrderBy(station => station.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var economy = BuildRegionalEconomy(world, faction.Id, component);
|
||||
var regionKind = ResolveRegionKind(stations, economy);
|
||||
var frontLineIds = state.Territory.FrontLines
|
||||
.Where(front => front.SystemIds.Any(systemId => component.Contains(systemId, StringComparer.Ordinal)))
|
||||
.Select(front => front.Id)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var region = new EconomicRegionRuntime
|
||||
{
|
||||
Id = regionId,
|
||||
FactionId = faction.Id,
|
||||
Label = $"{faction.Label} {coreSystemId}",
|
||||
Kind = regionKind,
|
||||
Status = "active",
|
||||
CoreSystemId = coreSystemId,
|
||||
UpdatedAtUtc = nowUtc,
|
||||
};
|
||||
region.SystemIds.AddRange(component.OrderBy(id => id, StringComparer.Ordinal));
|
||||
region.StationIds.AddRange(stations.Select(station => station.Id));
|
||||
region.FrontLineIds.AddRange(frontLineIds);
|
||||
state.EconomyRegions.Regions.Add(region);
|
||||
|
||||
var producerItems = economy.Commodities
|
||||
.Where(entry => entry.Value.ProductionRatePerSecond > 0.01f)
|
||||
.OrderByDescending(entry => entry.Value.ProductionRatePerSecond)
|
||||
.ThenBy(entry => entry.Key, StringComparer.Ordinal)
|
||||
.Take(8)
|
||||
.Select(entry => entry.Key)
|
||||
.ToList();
|
||||
var scarceItems = economy.Commodities
|
||||
.Where(entry => entry.Value.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low)
|
||||
.OrderByDescending(entry => CommodityOperationalSignal.ComputeNeedScore(entry.Value, 240f))
|
||||
.ThenBy(entry => entry.Key, StringComparer.Ordinal)
|
||||
.Take(8)
|
||||
.Select(entry => entry.Key)
|
||||
.ToList();
|
||||
|
||||
var supplyNetwork = new SupplyNetworkRuntime
|
||||
{
|
||||
Id = $"network-{regionId}",
|
||||
RegionId = regionId,
|
||||
ThroughputScore = Math.Clamp(stations.Count * 0.18f, 0f, 1f),
|
||||
RiskScore = Math.Clamp(frontLineIds.Count * 0.24f, 0f, 1f),
|
||||
UpdatedAtUtc = nowUtc,
|
||||
};
|
||||
supplyNetwork.StationIds.AddRange(stations.Select(station => station.Id));
|
||||
supplyNetwork.ProducerItemIds.AddRange(producerItems);
|
||||
supplyNetwork.ConsumerItemIds.AddRange(scarceItems);
|
||||
supplyNetwork.ConstructionItemIds.AddRange(world.ConstructionSites
|
||||
.Where(site => site.FactionId == faction.Id && component.Contains(site.SystemId, StringComparer.Ordinal))
|
||||
.SelectMany(site => site.RequiredItems.Keys)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal));
|
||||
state.EconomyRegions.SupplyNetworks.Add(supplyNetwork);
|
||||
|
||||
var productionProfile = new RegionalProductionProfileRuntime
|
||||
{
|
||||
RegionId = regionId,
|
||||
PrimaryIndustry = regionKind,
|
||||
ShipyardCount = stations.Count(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
|
||||
StationCount = stations.Count,
|
||||
UpdatedAtUtc = nowUtc,
|
||||
};
|
||||
productionProfile.ProducedItemIds.AddRange(producerItems);
|
||||
productionProfile.ScarceItemIds.AddRange(scarceItems);
|
||||
state.EconomyRegions.ProductionProfiles.Add(productionProfile);
|
||||
|
||||
state.EconomyRegions.TradeBalances.Add(new RegionalTradeBalanceRuntime
|
||||
{
|
||||
RegionId = regionId,
|
||||
ImportsRequiredCount = economy.Commodities.Count(entry => entry.Value.BuyBacklog > 0.01f),
|
||||
ExportsSurplusCount = economy.Commodities.Count(entry => entry.Value.SellBacklog > 0.01f || entry.Value.Level == CommodityLevelKind.Surplus),
|
||||
CriticalShortageCount = scarceItems.Count,
|
||||
NetTradeScore = Math.Clamp((economy.Commodities.Sum(entry => entry.Value.ProjectedNetRatePerSecond) + 5f) / 10f, -1f, 1f),
|
||||
UpdatedAtUtc = nowUtc,
|
||||
});
|
||||
|
||||
if (scarceItems.FirstOrDefault() is { } bottleneckItemId)
|
||||
{
|
||||
state.EconomyRegions.Bottlenecks.Add(new RegionalBottleneckRuntime
|
||||
{
|
||||
Id = $"bottleneck-{regionId}-{bottleneckItemId}",
|
||||
RegionId = regionId,
|
||||
ItemId = bottleneckItemId,
|
||||
Cause = "regional-shortage",
|
||||
Status = "active",
|
||||
Severity = Math.Clamp(CommodityOperationalSignal.ComputeNeedScore(economy.GetCommodity(bottleneckItemId), 240f), 0f, 10f),
|
||||
UpdatedAtUtc = nowUtc,
|
||||
});
|
||||
}
|
||||
|
||||
var supplyRisk = Math.Clamp(frontLineIds.Count * 0.2f, 0f, 1f);
|
||||
state.EconomyRegions.SecurityAssessments.Add(new RegionalSecurityAssessmentRuntime
|
||||
{
|
||||
RegionId = regionId,
|
||||
SupplyRisk = supplyRisk,
|
||||
BorderPressure = Math.Clamp(frontLineIds.Count * 0.22f, 0f, 1f),
|
||||
ActiveWarCount = state.Diplomacy.Wars.Count(war => war.ActiveFrontLineIds.Intersect(frontLineIds, StringComparer.Ordinal).Any()),
|
||||
HostileRelationCount = state.Diplomacy.Relations.Count(relation => relation.Posture is "hostile" or "war"),
|
||||
AccessFriction = Math.Clamp(state.Diplomacy.BorderTensions.Where(tension => tension.SystemIds.Any(systemId => component.Contains(systemId, StringComparer.Ordinal))).DefaultIfEmpty().Average(tension => tension?.AccessFriction ?? 0f), 0f, 1f),
|
||||
UpdatedAtUtc = nowUtc,
|
||||
});
|
||||
|
||||
state.EconomyRegions.EconomicAssessments.Add(new RegionalEconomicAssessmentRuntime
|
||||
{
|
||||
RegionId = regionId,
|
||||
SustainmentScore = Math.Clamp(1f - (scarceItems.Count * 0.12f) - (supplyRisk * 0.35f), 0f, 1f),
|
||||
ProductionDepth = Math.Clamp(producerItems.Count / 8f, 0f, 1f),
|
||||
ConstructionPressure = Math.Clamp(world.ConstructionSites.Count(site => site.FactionId == faction.Id && component.Contains(site.SystemId, StringComparer.Ordinal)) * 0.22f, 0f, 1f),
|
||||
CorridorDependency = Math.Clamp(frontLineIds.Count * 0.18f, 0f, 1f),
|
||||
UpdatedAtUtc = nowUtc,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
BuildCorridors(world, state, nowUtc);
|
||||
foreach (var profile in state.Territory.StrategicProfiles)
|
||||
{
|
||||
profile.EconomicRegionId = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(profile.SystemId, StringComparer.Ordinal))?.Id;
|
||||
}
|
||||
}
|
||||
|
||||
private static void BuildCorridors(SimulationWorld world, GeopoliticalStateRuntime state, DateTimeOffset nowUtc)
|
||||
{
|
||||
foreach (var route in state.Routes)
|
||||
{
|
||||
var sourceRegion = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(route.SourceSystemId, StringComparer.Ordinal));
|
||||
var destinationRegion = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(route.DestinationSystemId, StringComparer.Ordinal));
|
||||
if (sourceRegion is null && destinationRegion is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var borderEdge = state.Territory.BorderEdges.FirstOrDefault(edge =>
|
||||
(edge.SourceSystemId == route.SourceSystemId && edge.DestinationSystemId == route.DestinationSystemId)
|
||||
|| (edge.SourceSystemId == route.DestinationSystemId && edge.DestinationSystemId == route.SourceSystemId));
|
||||
var risk = borderEdge?.TensionScore ?? 0f;
|
||||
var corridor = new LogisticsCorridorRuntime
|
||||
{
|
||||
Id = $"corridor-{route.Id}",
|
||||
FactionId = sourceRegion?.FactionId ?? destinationRegion?.FactionId,
|
||||
Kind = borderEdge?.IsContested == true ? "frontier-corridor" : "supply-corridor",
|
||||
Status = borderEdge?.IsContested == true ? "risky" : "active",
|
||||
RiskScore = Math.Clamp(risk + ((sourceRegion is not null && destinationRegion is not null && sourceRegion.Id != destinationRegion.Id) ? 0.15f : 0f), 0f, 1f),
|
||||
ThroughputScore = Math.Clamp(((sourceRegion?.StationIds.Count ?? 0) + (destinationRegion?.StationIds.Count ?? 0)) / 10f, 0f, 1f),
|
||||
AccessState = ResolveCorridorAccessState(world, borderEdge, sourceRegion, destinationRegion),
|
||||
UpdatedAtUtc = nowUtc,
|
||||
};
|
||||
corridor.SystemPathIds.Add(route.SourceSystemId);
|
||||
corridor.SystemPathIds.Add(route.DestinationSystemId);
|
||||
if (sourceRegion is not null)
|
||||
{
|
||||
corridor.RegionIds.Add(sourceRegion.Id);
|
||||
}
|
||||
if (destinationRegion is not null && !corridor.RegionIds.Contains(destinationRegion.Id, StringComparer.Ordinal))
|
||||
{
|
||||
corridor.RegionIds.Add(destinationRegion.Id);
|
||||
}
|
||||
if (borderEdge is not null)
|
||||
{
|
||||
corridor.BorderEdgeIds.Add(borderEdge.Id);
|
||||
}
|
||||
|
||||
state.EconomyRegions.Corridors.Add(corridor);
|
||||
if (sourceRegion is not null && !sourceRegion.CorridorIds.Contains(corridor.Id, StringComparer.Ordinal))
|
||||
{
|
||||
sourceRegion.CorridorIds.Add(corridor.Id);
|
||||
}
|
||||
if (destinationRegion is not null && !destinationRegion.CorridorIds.Contains(corridor.Id, StringComparer.Ordinal))
|
||||
{
|
||||
destinationRegion.CorridorIds.Add(corridor.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveCorridorAccessState(
|
||||
SimulationWorld world,
|
||||
BorderEdgeRuntime? borderEdge,
|
||||
EconomicRegionRuntime? sourceRegion,
|
||||
EconomicRegionRuntime? destinationRegion)
|
||||
{
|
||||
if (sourceRegion?.FactionId is null || destinationRegion?.FactionId is null)
|
||||
{
|
||||
return borderEdge?.IsContested == true ? "restricted" : "open";
|
||||
}
|
||||
|
||||
var relation = FindRelation(world, sourceRegion.FactionId, destinationRegion.FactionId);
|
||||
if (relation is null)
|
||||
{
|
||||
return "restricted";
|
||||
}
|
||||
|
||||
return relation.Posture switch
|
||||
{
|
||||
"war" => "denied",
|
||||
"hostile" => "restricted",
|
||||
_ => relation.TradeAccessPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
private static FactionEconomySnapshot BuildRegionalEconomy(SimulationWorld world, string factionId, IReadOnlyCollection<string> systemIds)
|
||||
{
|
||||
var snapshot = new FactionEconomySnapshot();
|
||||
foreach (var station in world.Stations.Where(station => station.FactionId == factionId && systemIds.Contains(station.SystemId, StringComparer.Ordinal)))
|
||||
{
|
||||
foreach (var (itemId, amount) in station.Inventory)
|
||||
{
|
||||
snapshot.GetCommodity(itemId).OnHand += amount;
|
||||
}
|
||||
|
||||
foreach (var laneKey in StationSimulationService.GetStationProductionLanes(world, station))
|
||||
{
|
||||
var recipe = StationSimulationService.SelectProductionRecipe(world, station, laneKey);
|
||||
if (recipe is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var throughput = StationSimulationService.GetStationProductionThroughput(world, station, recipe);
|
||||
var cyclesPerSecond = (station.WorkforceEffectiveRatio * throughput) / MathF.Max(recipe.Duration, 0.01f);
|
||||
foreach (var input in recipe.Inputs)
|
||||
{
|
||||
snapshot.GetCommodity(input.ItemId).ConsumptionRatePerSecond += input.Amount * cyclesPerSecond;
|
||||
}
|
||||
foreach (var output in recipe.Outputs)
|
||||
{
|
||||
snapshot.GetCommodity(output.ItemId).ProductionRatePerSecond += output.Amount * cyclesPerSecond;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var order in world.MarketOrders.Where(order => order.FactionId == factionId))
|
||||
{
|
||||
var relatedSystemId = world.Stations.FirstOrDefault(station => station.Id == order.StationId)?.SystemId
|
||||
?? world.ConstructionSites.FirstOrDefault(site => site.Id == order.ConstructionSiteId)?.SystemId;
|
||||
if (relatedSystemId is null || !systemIds.Contains(relatedSystemId, StringComparer.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var commodity = snapshot.GetCommodity(order.ItemId);
|
||||
if (order.Kind == MarketOrderKinds.Buy)
|
||||
{
|
||||
commodity.BuyBacklog += order.RemainingAmount;
|
||||
}
|
||||
else if (order.Kind == MarketOrderKinds.Sell)
|
||||
{
|
||||
commodity.SellBacklog += order.RemainingAmount;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var site in world.ConstructionSites.Where(site => site.FactionId == factionId && systemIds.Contains(site.SystemId, StringComparer.Ordinal)))
|
||||
{
|
||||
foreach (var required in site.RequiredItems)
|
||||
{
|
||||
var remaining = MathF.Max(0f, required.Value - (site.DeliveredItems.TryGetValue(required.Key, out var delivered) ? delivered : 0f));
|
||||
if (remaining > 0.01f)
|
||||
{
|
||||
snapshot.GetCommodity(required.Key).ReservedForConstruction += remaining;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static List<List<string>> BuildConnectedComponents(IReadOnlyCollection<string> systems, IReadOnlyCollection<SystemRouteLinkRuntime> routes)
|
||||
{
|
||||
var remaining = systems.ToHashSet(StringComparer.Ordinal);
|
||||
var adjacency = routes
|
||||
.SelectMany(route => new[]
|
||||
{
|
||||
(route.SourceSystemId, route.DestinationSystemId),
|
||||
(route.DestinationSystemId, route.SourceSystemId),
|
||||
})
|
||||
.GroupBy(entry => entry.Item1, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Select(entry => entry.Item2).ToList(), StringComparer.Ordinal);
|
||||
var components = new List<List<string>>();
|
||||
|
||||
while (remaining.Count > 0)
|
||||
{
|
||||
var start = remaining.OrderBy(id => id, StringComparer.Ordinal).First();
|
||||
var frontier = new Queue<string>();
|
||||
frontier.Enqueue(start);
|
||||
remaining.Remove(start);
|
||||
var component = new List<string>();
|
||||
|
||||
while (frontier.Count > 0)
|
||||
{
|
||||
var current = frontier.Dequeue();
|
||||
component.Add(current);
|
||||
foreach (var neighbor in adjacency.GetValueOrDefault(current, []))
|
||||
{
|
||||
if (remaining.Remove(neighbor))
|
||||
{
|
||||
frontier.Enqueue(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
components.Add(component);
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
private static string ResolveRegionKind(IReadOnlyCollection<StationRuntime> stations, FactionEconomySnapshot economy)
|
||||
{
|
||||
if (stations.Any(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)))
|
||||
{
|
||||
return "shipbuilding-region";
|
||||
}
|
||||
|
||||
if (stations.Count(station => StationSimulationService.DetermineStationRole(station) == "refinery") >= 2)
|
||||
{
|
||||
return "industrial-core";
|
||||
}
|
||||
|
||||
if (economy.Commodities.Any(entry => entry.Value.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low))
|
||||
{
|
||||
return "frontier-sustainment";
|
||||
}
|
||||
|
||||
return stations.Count <= 2 ? "extraction-region" : "balanced-region";
|
||||
}
|
||||
|
||||
private static float EstimateSystemStrategicValue(SimulationWorld world, string systemId)
|
||||
{
|
||||
var stationValue = world.Stations.Count(station => station.SystemId == systemId) * 30f;
|
||||
var constructionValue = world.ConstructionSites.Count(site => site.SystemId == systemId && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed) * 18f;
|
||||
var nodeValue = world.Nodes.Count(node => node.SystemId == systemId) * 8f;
|
||||
return stationValue + constructionValue + nodeValue;
|
||||
}
|
||||
|
||||
private static string BuildRelationId(string factionAId, string factionBId) =>
|
||||
BuildPairId("relation", factionAId, factionBId);
|
||||
|
||||
private static string BuildPairId(string prefix, string leftId, string rightId)
|
||||
{
|
||||
return string.Compare(leftId, rightId, StringComparison.Ordinal) <= 0
|
||||
? $"{prefix}-{leftId}-{rightId}"
|
||||
: $"{prefix}-{rightId}-{leftId}";
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,24 @@
|
||||
global using SpaceGame.Api.Auth.Contracts;
|
||||
global using SpaceGame.Api.Auth.Runtime;
|
||||
global using SpaceGame.Api.Auth.Simulation;
|
||||
global using SpaceGame.Api.Definitions;
|
||||
global using SpaceGame.Api.Economy.Contracts;
|
||||
global using SpaceGame.Api.Economy.Runtime;
|
||||
global using SpaceGame.Api.Factions.AI;
|
||||
global using SpaceGame.Api.Factions.Contracts;
|
||||
global using SpaceGame.Api.Factions.Runtime;
|
||||
global using SpaceGame.Api.Geopolitics.Contracts;
|
||||
global using SpaceGame.Api.Geopolitics.Runtime;
|
||||
global using SpaceGame.Api.Geopolitics.Simulation;
|
||||
global using SpaceGame.Api.Industry.Planning;
|
||||
global using SpaceGame.Api.Shared.AI;
|
||||
global using SpaceGame.Api.PlayerFaction.Contracts;
|
||||
global using SpaceGame.Api.PlayerFaction.Runtime;
|
||||
global using SpaceGame.Api.PlayerFaction.Simulation;
|
||||
global using SpaceGame.Api.Shared.Contracts;
|
||||
global using SpaceGame.Api.Shared.Runtime;
|
||||
global using SpaceGame.Api.Ships.AI;
|
||||
global using SpaceGame.Api.Ships.Contracts;
|
||||
global using SpaceGame.Api.Ships.Runtime;
|
||||
global using SpaceGame.Api.Ships.Simulation;
|
||||
global using SpaceGame.Api.Ships.AI;
|
||||
global using SpaceGame.Api.Simulation.Core;
|
||||
global using SpaceGame.Api.Stations.Contracts;
|
||||
global using SpaceGame.Api.Stations.Runtime;
|
||||
|
||||
54
apps/backend/Industry/Planning/CommodityOperationalSignal.cs
Normal file
54
apps/backend/Industry/Planning/CommodityOperationalSignal.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
namespace SpaceGame.Api.Industry.Planning;
|
||||
|
||||
internal static class CommodityOperationalSignal
|
||||
{
|
||||
internal static float ComputeNeedScore(FactionCommoditySnapshot commodity, float targetLevelSeconds)
|
||||
{
|
||||
var productionDeficit = MathF.Max(0f, commodity.ConsumptionRatePerSecond - commodity.ProjectedProductionRatePerSecond);
|
||||
var levelDeficit = MathF.Max(0f, targetLevelSeconds - commodity.LevelSeconds) / MathF.Max(targetLevelSeconds, 1f);
|
||||
var backlogPressure = MathF.Max(0f, commodity.BuyBacklog + commodity.ReservedForConstruction - commodity.AvailableStock);
|
||||
|
||||
var levelWeight = commodity.Level switch
|
||||
{
|
||||
CommodityLevelKind.Critical => 140f,
|
||||
CommodityLevelKind.Low => 80f,
|
||||
CommodityLevelKind.Stable => 20f,
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
return levelWeight
|
||||
+ (productionDeficit * 140f)
|
||||
+ (levelDeficit * 120f)
|
||||
+ backlogPressure;
|
||||
}
|
||||
|
||||
internal static bool IsOperational(FactionCommoditySnapshot commodity, float targetLevelSeconds) =>
|
||||
commodity.ProjectedProductionRatePerSecond > 0.01f
|
||||
&& commodity.ProjectedNetRatePerSecond >= -0.01f
|
||||
&& commodity.LevelSeconds >= targetLevelSeconds
|
||||
&& commodity.Level is CommodityLevelKind.Stable or CommodityLevelKind.Surplus;
|
||||
|
||||
internal static bool IsStrained(FactionCommoditySnapshot commodity, float targetLevelSeconds) =>
|
||||
!IsOperational(commodity, targetLevelSeconds)
|
||||
|| commodity.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low;
|
||||
|
||||
internal static float ComputeFeasibilityFactor(FactionCommoditySnapshot commodity, float targetLevelSeconds)
|
||||
{
|
||||
if (commodity.AvailableStock <= 0.01f && commodity.ProjectedProductionRatePerSecond <= 0.01f)
|
||||
{
|
||||
return 0.65f;
|
||||
}
|
||||
|
||||
if (commodity.Level is CommodityLevelKind.Critical)
|
||||
{
|
||||
return 0.72f;
|
||||
}
|
||||
|
||||
if (commodity.Level is CommodityLevelKind.Low || commodity.LevelSeconds < targetLevelSeconds)
|
||||
{
|
||||
return 0.84f;
|
||||
}
|
||||
|
||||
return 1f;
|
||||
}
|
||||
}
|
||||
@@ -4,225 +4,202 @@ using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
|
||||
internal sealed class FactionEconomySnapshot
|
||||
{
|
||||
private readonly Dictionary<string, FactionCommoditySnapshot> commodities = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, FactionCommoditySnapshot> commodities = new(StringComparer.Ordinal);
|
||||
|
||||
internal IReadOnlyDictionary<string, FactionCommoditySnapshot> Commodities => commodities;
|
||||
internal IReadOnlyDictionary<string, FactionCommoditySnapshot> Commodities => commodities;
|
||||
|
||||
internal FactionCommoditySnapshot GetCommodity(string itemId)
|
||||
{
|
||||
if (!commodities.TryGetValue(itemId, out var commodity))
|
||||
internal FactionCommoditySnapshot GetCommodity(string itemId)
|
||||
{
|
||||
commodity = new FactionCommoditySnapshot(itemId);
|
||||
commodities[itemId] = commodity;
|
||||
}
|
||||
if (!commodities.TryGetValue(itemId, out var commodity))
|
||||
{
|
||||
commodity = new FactionCommoditySnapshot(itemId);
|
||||
commodities[itemId] = commodity;
|
||||
}
|
||||
|
||||
return commodity;
|
||||
}
|
||||
return commodity;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FactionCommoditySnapshot
|
||||
{
|
||||
internal FactionCommoditySnapshot(string itemId)
|
||||
{
|
||||
ItemId = itemId;
|
||||
}
|
||||
|
||||
internal string ItemId { get; }
|
||||
internal float OnHand { get; set; }
|
||||
internal float ReservedForConstruction { get; set; }
|
||||
internal float BuyBacklog { get; set; }
|
||||
internal float SellBacklog { get; set; }
|
||||
internal float Inbound { get; set; }
|
||||
internal float ProductionRatePerSecond { get; set; }
|
||||
internal float CommittedProductionRatePerSecond { get; set; }
|
||||
internal float ConsumptionRatePerSecond { get; set; }
|
||||
|
||||
internal float AvailableStock => MathF.Max(0f, OnHand + Inbound - ReservedForConstruction);
|
||||
internal float NetRatePerSecond => ProductionRatePerSecond - ConsumptionRatePerSecond;
|
||||
internal float ProjectedProductionRatePerSecond => ProductionRatePerSecond + CommittedProductionRatePerSecond;
|
||||
internal float ProjectedNetRatePerSecond => ProjectedProductionRatePerSecond - ConsumptionRatePerSecond;
|
||||
|
||||
internal float ShortageHorizonSeconds
|
||||
{
|
||||
get
|
||||
internal FactionCommoditySnapshot(string itemId)
|
||||
{
|
||||
if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f)
|
||||
{
|
||||
return float.PositiveInfinity;
|
||||
}
|
||||
|
||||
if (NetRatePerSecond >= -0.01f)
|
||||
{
|
||||
return float.PositiveInfinity;
|
||||
}
|
||||
|
||||
return AvailableStock / MathF.Max(0.01f, -NetRatePerSecond);
|
||||
ItemId = itemId;
|
||||
}
|
||||
}
|
||||
|
||||
internal float ProjectedShortageHorizonSeconds
|
||||
{
|
||||
get
|
||||
{
|
||||
if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f)
|
||||
{
|
||||
return float.PositiveInfinity;
|
||||
}
|
||||
internal string ItemId { get; }
|
||||
internal float OnHand { get; set; }
|
||||
internal float ReservedForConstruction { get; set; }
|
||||
internal float BuyBacklog { get; set; }
|
||||
internal float SellBacklog { get; set; }
|
||||
internal float Inbound { get; set; }
|
||||
internal float ProductionRatePerSecond { get; set; }
|
||||
internal float CommittedProductionRatePerSecond { get; set; }
|
||||
internal float ConsumptionRatePerSecond { get; set; }
|
||||
|
||||
if (ProjectedNetRatePerSecond >= -0.01f)
|
||||
{
|
||||
return float.PositiveInfinity;
|
||||
}
|
||||
internal float AvailableStock => MathF.Max(0f, OnHand + Inbound - ReservedForConstruction);
|
||||
internal float NetRatePerSecond => ProductionRatePerSecond - ConsumptionRatePerSecond;
|
||||
internal float ProjectedProductionRatePerSecond => ProductionRatePerSecond + CommittedProductionRatePerSecond;
|
||||
internal float ProjectedNetRatePerSecond => ProjectedProductionRatePerSecond - ConsumptionRatePerSecond;
|
||||
internal float OperationalUsageRatePerSecond => MathF.Max(ConsumptionRatePerSecond, BuyBacklog / 180f);
|
||||
internal float LevelSeconds => AvailableStock <= 0.01f
|
||||
? 0f
|
||||
: AvailableStock / MathF.Max(OperationalUsageRatePerSecond, 0.01f);
|
||||
|
||||
return AvailableStock / MathF.Max(0.01f, -ProjectedNetRatePerSecond);
|
||||
}
|
||||
}
|
||||
internal CommodityLevelKind Level =>
|
||||
LevelSeconds switch
|
||||
{
|
||||
<= 60f => CommodityLevelKind.Critical,
|
||||
<= 180f => CommodityLevelKind.Low,
|
||||
<= 480f => CommodityLevelKind.Stable,
|
||||
_ => CommodityLevelKind.Surplus,
|
||||
};
|
||||
}
|
||||
|
||||
internal float PressureScore =>
|
||||
MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound))
|
||||
+ MathF.Max(0f, ConsumptionRatePerSecond - ProductionRatePerSecond) * 120f;
|
||||
|
||||
internal float ProjectedPressureScore =>
|
||||
MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound))
|
||||
+ MathF.Max(0f, ConsumptionRatePerSecond - ProjectedProductionRatePerSecond) * 120f;
|
||||
internal enum CommodityLevelKind
|
||||
{
|
||||
Critical,
|
||||
Low,
|
||||
Stable,
|
||||
Surplus,
|
||||
}
|
||||
|
||||
internal static class FactionEconomyAnalyzer
|
||||
{
|
||||
internal static FactionEconomySnapshot Build(SimulationWorld world, string factionId)
|
||||
{
|
||||
var snapshot = new FactionEconomySnapshot();
|
||||
|
||||
foreach (var station in world.Stations.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)))
|
||||
internal static FactionEconomySnapshot Build(SimulationWorld world, string factionId)
|
||||
{
|
||||
foreach (var (itemId, amount) in station.Inventory)
|
||||
{
|
||||
snapshot.GetCommodity(itemId).OnHand += amount;
|
||||
}
|
||||
var snapshot = new FactionEconomySnapshot();
|
||||
|
||||
foreach (var laneKey in StationSimulationService.GetStationProductionLanes(world, station))
|
||||
{
|
||||
var recipe = StationSimulationService.SelectProductionRecipe(world, station, laneKey);
|
||||
if (recipe is null)
|
||||
foreach (var station in world.Stations.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)))
|
||||
{
|
||||
continue;
|
||||
foreach (var (itemId, amount) in station.Inventory)
|
||||
{
|
||||
snapshot.GetCommodity(itemId).OnHand += amount;
|
||||
}
|
||||
|
||||
foreach (var laneKey in StationSimulationService.GetStationProductionLanes(world, station))
|
||||
{
|
||||
var recipe = StationSimulationService.SelectProductionRecipe(world, station, laneKey);
|
||||
if (recipe is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var throughput = StationSimulationService.GetStationProductionThroughput(world, station, recipe);
|
||||
var cyclesPerSecond = (station.WorkforceEffectiveRatio * throughput) / MathF.Max(recipe.Duration, 0.01f);
|
||||
if (cyclesPerSecond <= 0.0001f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var input in recipe.Inputs)
|
||||
{
|
||||
snapshot.GetCommodity(input.ItemId).ConsumptionRatePerSecond += input.Amount * cyclesPerSecond;
|
||||
}
|
||||
|
||||
foreach (var output in recipe.Outputs)
|
||||
{
|
||||
snapshot.GetCommodity(output.ItemId).ProductionRatePerSecond += output.Amount * cyclesPerSecond;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var throughput = StationSimulationService.GetStationProductionThroughput(world, station, recipe);
|
||||
var cyclesPerSecond = (station.WorkforceEffectiveRatio * throughput) / MathF.Max(recipe.Duration, 0.01f);
|
||||
if (cyclesPerSecond <= 0.0001f)
|
||||
foreach (var order in world.MarketOrders.Where(order =>
|
||||
string.Equals(order.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& order.State != MarketOrderStateKinds.Cancelled
|
||||
&& order.RemainingAmount > 0.01f))
|
||||
{
|
||||
continue;
|
||||
var commodity = snapshot.GetCommodity(order.ItemId);
|
||||
if (string.Equals(order.Kind, MarketOrderKinds.Buy, StringComparison.Ordinal))
|
||||
{
|
||||
commodity.BuyBacklog += order.RemainingAmount;
|
||||
}
|
||||
else if (string.Equals(order.Kind, MarketOrderKinds.Sell, StringComparison.Ordinal))
|
||||
{
|
||||
commodity.SellBacklog += order.RemainingAmount;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var input in recipe.Inputs)
|
||||
foreach (var site in world.ConstructionSites.Where(site =>
|
||||
string.Equals(site.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed))
|
||||
{
|
||||
snapshot.GetCommodity(input.ItemId).ConsumptionRatePerSecond += input.Amount * cyclesPerSecond;
|
||||
ApplyCommittedProduction(world, snapshot, site);
|
||||
|
||||
foreach (var required in site.RequiredItems)
|
||||
{
|
||||
var remaining = MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key));
|
||||
if (remaining > 0.01f)
|
||||
{
|
||||
snapshot.GetCommodity(required.Key).ReservedForConstruction += remaining;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var output in recipe.Outputs)
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static void ApplyCommittedProduction(
|
||||
SimulationWorld world,
|
||||
FactionEconomySnapshot snapshot,
|
||||
ConstructionSiteRuntime site)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(site.BlueprintId)
|
||||
|| !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
|
||||
{
|
||||
snapshot.GetCommodity(output.ItemId).ProductionRatePerSecond += output.Amount * cyclesPerSecond;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var order in world.MarketOrders.Where(order =>
|
||||
string.Equals(order.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& order.State != MarketOrderStateKinds.Cancelled
|
||||
&& order.RemainingAmount > 0.01f))
|
||||
{
|
||||
var commodity = snapshot.GetCommodity(order.ItemId);
|
||||
if (string.Equals(order.Kind, MarketOrderKinds.Buy, StringComparison.Ordinal))
|
||||
{
|
||||
commodity.BuyBacklog += order.RemainingAmount;
|
||||
}
|
||||
else if (string.Equals(order.Kind, MarketOrderKinds.Sell, StringComparison.Ordinal))
|
||||
{
|
||||
commodity.SellBacklog += order.RemainingAmount;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var site in world.ConstructionSites.Where(site =>
|
||||
string.Equals(site.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed))
|
||||
{
|
||||
ApplyCommittedProduction(world, snapshot, site);
|
||||
|
||||
foreach (var required in site.RequiredItems)
|
||||
{
|
||||
var remaining = MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key));
|
||||
if (remaining > 0.01f)
|
||||
var recipeOutputs = world.Recipes.Values
|
||||
.Where(candidate => string.Equals(StationSimulationService.GetStationProductionLaneKey(world, candidate), site.BlueprintId, StringComparison.Ordinal))
|
||||
.SelectMany(candidate => candidate.Outputs)
|
||||
.GroupBy(output => output.ItemId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(output => output.Amount), StringComparer.Ordinal);
|
||||
if (recipeOutputs.Count == 0)
|
||||
{
|
||||
snapshot.GetCommodity(required.Key).ReservedForConstruction += remaining;
|
||||
return;
|
||||
}
|
||||
|
||||
var materialFraction = 0f;
|
||||
var materialTerms = 0;
|
||||
foreach (var required in site.RequiredItems)
|
||||
{
|
||||
materialTerms += 1;
|
||||
materialFraction += required.Value <= 0.01f
|
||||
? 1f
|
||||
: Math.Clamp(GetConstructionDeliveredAmount(world, site, required.Key) / required.Value, 0f, 1f);
|
||||
}
|
||||
|
||||
materialFraction = materialTerms == 0 ? 1f : materialFraction / materialTerms;
|
||||
|
||||
var buildFraction = recipe.Duration <= 0.01f
|
||||
? 0f
|
||||
: Math.Clamp(site.Progress / recipe.Duration, 0f, 1f);
|
||||
var readiness = site.State switch
|
||||
{
|
||||
ConstructionSiteStateKinds.Active => 0.3f,
|
||||
ConstructionSiteStateKinds.Planned => 0.15f,
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
readiness += materialFraction * 0.45f;
|
||||
readiness += buildFraction * 0.25f;
|
||||
|
||||
if (site.AssignedConstructorShipIds.Count > 0)
|
||||
{
|
||||
readiness += 0.1f;
|
||||
}
|
||||
|
||||
readiness = Math.Clamp(readiness, 0f, 1f);
|
||||
if (readiness <= 0.01f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cyclesPerSecond = readiness / MathF.Max(recipe.Duration, 0.01f);
|
||||
foreach (var (productItemId, amount) in recipeOutputs)
|
||||
{
|
||||
snapshot.GetCommodity(productItemId).CommittedProductionRatePerSecond += amount * cyclesPerSecond;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static void ApplyCommittedProduction(
|
||||
SimulationWorld world,
|
||||
FactionEconomySnapshot snapshot,
|
||||
ConstructionSiteRuntime site)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(site.BlueprintId)
|
||||
|| !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var recipeOutputs = world.Recipes.Values
|
||||
.Where(candidate => string.Equals(StationSimulationService.GetStationProductionLaneKey(world, candidate), site.BlueprintId, StringComparison.Ordinal))
|
||||
.SelectMany(candidate => candidate.Outputs)
|
||||
.GroupBy(output => output.ItemId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(output => output.Amount), StringComparer.Ordinal);
|
||||
if (recipeOutputs.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var materialFraction = 0f;
|
||||
var materialTerms = 0;
|
||||
foreach (var required in site.RequiredItems)
|
||||
{
|
||||
materialTerms += 1;
|
||||
materialFraction += required.Value <= 0.01f
|
||||
? 1f
|
||||
: Math.Clamp(GetConstructionDeliveredAmount(world, site, required.Key) / required.Value, 0f, 1f);
|
||||
}
|
||||
|
||||
materialFraction = materialTerms == 0 ? 1f : materialFraction / materialTerms;
|
||||
|
||||
var buildFraction = recipe.Duration <= 0.01f
|
||||
? 0f
|
||||
: Math.Clamp(site.Progress / recipe.Duration, 0f, 1f);
|
||||
var readiness = site.State switch
|
||||
{
|
||||
ConstructionSiteStateKinds.Active => 0.3f,
|
||||
ConstructionSiteStateKinds.Planned => 0.15f,
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
readiness += materialFraction * 0.45f;
|
||||
readiness += buildFraction * 0.25f;
|
||||
|
||||
if (site.AssignedConstructorShipIds.Count > 0)
|
||||
{
|
||||
readiness += 0.1f;
|
||||
}
|
||||
|
||||
readiness = Math.Clamp(readiness, 0f, 1f);
|
||||
if (readiness <= 0.01f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cyclesPerSecond = readiness / MathF.Max(recipe.Duration, 0.01f);
|
||||
foreach (var (productItemId, amount) in recipeOutputs)
|
||||
{
|
||||
snapshot.GetCommodity(productItemId).CommittedProductionRatePerSecond += amount * cyclesPerSecond;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,52 +2,52 @@ namespace SpaceGame.Api.Industry.Planning;
|
||||
|
||||
public sealed class ProductionGraph
|
||||
{
|
||||
public required IReadOnlyDictionary<string, ProductionCommodityNode> Commodities { get; init; }
|
||||
public required IReadOnlyDictionary<string, ProductionProcessNode> Processes { get; init; }
|
||||
public required IReadOnlyDictionary<string, IReadOnlyList<ProductionProcessNode>> ProcessesByOutputId { get; init; }
|
||||
public required IReadOnlyDictionary<string, IReadOnlyList<ProductionProcessNode>> ProcessesByInputId { get; init; }
|
||||
public required IReadOnlyDictionary<string, IReadOnlyList<string>> OutputsByModuleId { get; init; }
|
||||
public required IReadOnlyDictionary<string, ProductionCommodityNode> Commodities { get; init; }
|
||||
public required IReadOnlyDictionary<string, ProductionProcessNode> Processes { get; init; }
|
||||
public required IReadOnlyDictionary<string, IReadOnlyList<ProductionProcessNode>> ProcessesByOutputId { get; init; }
|
||||
public required IReadOnlyDictionary<string, IReadOnlyList<ProductionProcessNode>> ProcessesByInputId { get; init; }
|
||||
public required IReadOnlyDictionary<string, IReadOnlyList<string>> OutputsByModuleId { get; init; }
|
||||
|
||||
public IReadOnlyList<ProductionProcessNode> GetProcessesForOutput(string itemId) =>
|
||||
ProcessesByOutputId.TryGetValue(itemId, out var processes) ? processes : [];
|
||||
public IReadOnlyList<ProductionProcessNode> GetProcessesForOutput(string itemId) =>
|
||||
ProcessesByOutputId.TryGetValue(itemId, out var processes) ? processes : [];
|
||||
|
||||
public IReadOnlyList<ProductionProcessNode> GetProcessesForInput(string itemId) =>
|
||||
ProcessesByInputId.TryGetValue(itemId, out var processes) ? processes : [];
|
||||
public IReadOnlyList<ProductionProcessNode> GetProcessesForInput(string itemId) =>
|
||||
ProcessesByInputId.TryGetValue(itemId, out var processes) ? processes : [];
|
||||
|
||||
public string? GetPrimaryProducerModule(string itemId) =>
|
||||
GetProcessesForOutput(itemId)
|
||||
.SelectMany(process => process.RequiredModuleIds)
|
||||
.FirstOrDefault();
|
||||
public string? GetPrimaryProducerModule(string itemId) =>
|
||||
GetProcessesForOutput(itemId)
|
||||
.SelectMany(process => process.RequiredModuleIds)
|
||||
.FirstOrDefault();
|
||||
|
||||
public string? GetPrimaryOutputForModule(string moduleId) =>
|
||||
OutputsByModuleId.TryGetValue(moduleId, out var outputs)
|
||||
? outputs.FirstOrDefault()
|
||||
: null;
|
||||
public string? GetPrimaryOutputForModule(string moduleId) =>
|
||||
OutputsByModuleId.TryGetValue(moduleId, out var outputs)
|
||||
? outputs.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
public IReadOnlyList<string> GetImmediateInputs(string itemId) =>
|
||||
GetProcessesForOutput(itemId)
|
||||
.SelectMany(process => process.Inputs.Keys)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
public IReadOnlyList<string> GetImmediateInputs(string itemId) =>
|
||||
GetProcessesForOutput(itemId)
|
||||
.SelectMany(process => process.Inputs.Keys)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public sealed class ProductionCommodityNode
|
||||
{
|
||||
public required string ItemId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Group { get; init; }
|
||||
public required string CargoKind { get; init; }
|
||||
public List<string> ProducerProcessIds { get; } = [];
|
||||
public List<string> ConsumerProcessIds { get; } = [];
|
||||
public required string ItemId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Group { get; init; }
|
||||
public required string CargoKind { get; init; }
|
||||
public List<string> ProducerProcessIds { get; } = [];
|
||||
public List<string> ConsumerProcessIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class ProductionProcessNode
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; init; }
|
||||
public required string FacilityCategory { get; init; }
|
||||
public required IReadOnlyList<string> RequiredModuleIds { get; init; }
|
||||
public required IReadOnlyDictionary<string, float> Inputs { get; init; }
|
||||
public required IReadOnlyDictionary<string, float> Outputs { get; init; }
|
||||
public required bool ProducesShip { get; init; }
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; init; }
|
||||
public required string FacilityCategory { get; init; }
|
||||
public required IReadOnlyList<string> RequiredModuleIds { get; init; }
|
||||
public required IReadOnlyDictionary<string, float> Inputs { get; init; }
|
||||
public required IReadOnlyDictionary<string, float> Outputs { get; init; }
|
||||
public required bool ProducesShip { get; init; }
|
||||
}
|
||||
|
||||
@@ -2,104 +2,104 @@ namespace SpaceGame.Api.Industry.Planning;
|
||||
|
||||
internal static class ProductionGraphBuilder
|
||||
{
|
||||
internal static ProductionGraph Build(
|
||||
IReadOnlyCollection<ItemDefinition> items,
|
||||
IReadOnlyCollection<RecipeDefinition> recipes,
|
||||
IReadOnlyCollection<ModuleDefinition> modules)
|
||||
{
|
||||
var commodities = items.ToDictionary(
|
||||
item => item.Id,
|
||||
item => new ProductionCommodityNode
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Name = item.Name,
|
||||
Group = item.Group,
|
||||
CargoKind = item.CargoKind,
|
||||
},
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var processes = new Dictionary<string, ProductionProcessNode>(StringComparer.Ordinal);
|
||||
var processesByOutputId = new Dictionary<string, List<ProductionProcessNode>>(StringComparer.Ordinal);
|
||||
var processesByInputId = new Dictionary<string, List<ProductionProcessNode>>(StringComparer.Ordinal);
|
||||
var outputsByModuleId = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var recipe in recipes)
|
||||
internal static ProductionGraph Build(
|
||||
IReadOnlyCollection<ItemDefinition> items,
|
||||
IReadOnlyCollection<RecipeDefinition> recipes,
|
||||
IReadOnlyCollection<ModuleDefinition> modules)
|
||||
{
|
||||
var outputs = recipe.Outputs
|
||||
.GroupBy(output => output.ItemId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(output => output.Amount), StringComparer.Ordinal);
|
||||
var inputs = recipe.Inputs
|
||||
.GroupBy(input => input.ItemId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(input => input.Amount), StringComparer.Ordinal);
|
||||
var process = new ProductionProcessNode
|
||||
{
|
||||
Id = recipe.Id,
|
||||
Label = recipe.Label,
|
||||
FacilityCategory = recipe.FacilityCategory,
|
||||
RequiredModuleIds = recipe.RequiredModules.ToList(),
|
||||
Inputs = inputs,
|
||||
Outputs = outputs,
|
||||
ProducesShip = recipe.ShipOutputId is not null,
|
||||
};
|
||||
var commodities = items.ToDictionary(
|
||||
item => item.Id,
|
||||
item => new ProductionCommodityNode
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Name = item.Name,
|
||||
Group = item.Group,
|
||||
CargoKind = item.CargoKind?.ToDataValue() ?? string.Empty,
|
||||
},
|
||||
StringComparer.Ordinal);
|
||||
|
||||
processes[process.Id] = process;
|
||||
var processes = new Dictionary<string, ProductionProcessNode>(StringComparer.Ordinal);
|
||||
var processesByOutputId = new Dictionary<string, List<ProductionProcessNode>>(StringComparer.Ordinal);
|
||||
var processesByInputId = new Dictionary<string, List<ProductionProcessNode>>(StringComparer.Ordinal);
|
||||
var outputsByModuleId = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var output in outputs.Keys)
|
||||
{
|
||||
if (!commodities.ContainsKey(output))
|
||||
foreach (var recipe in recipes)
|
||||
{
|
||||
continue;
|
||||
var outputs = recipe.Outputs
|
||||
.GroupBy(output => output.ItemId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(output => output.Amount), StringComparer.Ordinal);
|
||||
var inputs = recipe.Inputs
|
||||
.GroupBy(input => input.ItemId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(input => input.Amount), StringComparer.Ordinal);
|
||||
var process = new ProductionProcessNode
|
||||
{
|
||||
Id = recipe.Id,
|
||||
Label = recipe.Label,
|
||||
FacilityCategory = recipe.FacilityCategory,
|
||||
RequiredModuleIds = recipe.RequiredModules.ToList(),
|
||||
Inputs = inputs,
|
||||
Outputs = outputs,
|
||||
ProducesShip = recipe.ShipOutputId is not null,
|
||||
};
|
||||
|
||||
processes[process.Id] = process;
|
||||
|
||||
foreach (var output in outputs.Keys)
|
||||
{
|
||||
if (!commodities.ContainsKey(output))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
commodities[output].ProducerProcessIds.Add(process.Id);
|
||||
if (!processesByOutputId.TryGetValue(output, out var outputProcesses))
|
||||
{
|
||||
outputProcesses = [];
|
||||
processesByOutputId[output] = outputProcesses;
|
||||
}
|
||||
|
||||
outputProcesses.Add(process);
|
||||
}
|
||||
|
||||
foreach (var input in inputs.Keys)
|
||||
{
|
||||
if (!commodities.ContainsKey(input))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
commodities[input].ConsumerProcessIds.Add(process.Id);
|
||||
if (!processesByInputId.TryGetValue(input, out var inputProcesses))
|
||||
{
|
||||
inputProcesses = [];
|
||||
processesByInputId[input] = inputProcesses;
|
||||
}
|
||||
|
||||
inputProcesses.Add(process);
|
||||
}
|
||||
}
|
||||
|
||||
commodities[output].ProducerProcessIds.Add(process.Id);
|
||||
if (!processesByOutputId.TryGetValue(output, out var outputProcesses))
|
||||
foreach (var module in modules)
|
||||
{
|
||||
outputProcesses = [];
|
||||
processesByOutputId[output] = outputProcesses;
|
||||
if (!outputsByModuleId.TryGetValue(module.Id, out var outputs))
|
||||
{
|
||||
outputs = new HashSet<string>(StringComparer.Ordinal);
|
||||
outputsByModuleId[module.Id] = outputs;
|
||||
}
|
||||
|
||||
foreach (var product in module.ProductItemIds)
|
||||
{
|
||||
outputs.Add(product);
|
||||
}
|
||||
}
|
||||
|
||||
outputProcesses.Add(process);
|
||||
}
|
||||
|
||||
foreach (var input in inputs.Keys)
|
||||
{
|
||||
if (!commodities.ContainsKey(input))
|
||||
return new ProductionGraph
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
commodities[input].ConsumerProcessIds.Add(process.Id);
|
||||
if (!processesByInputId.TryGetValue(input, out var inputProcesses))
|
||||
{
|
||||
inputProcesses = [];
|
||||
processesByInputId[input] = inputProcesses;
|
||||
}
|
||||
|
||||
inputProcesses.Add(process);
|
||||
}
|
||||
Commodities = commodities,
|
||||
Processes = processes,
|
||||
ProcessesByOutputId = processesByOutputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<ProductionProcessNode>)entry.Value, StringComparer.Ordinal),
|
||||
ProcessesByInputId = processesByInputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<ProductionProcessNode>)entry.Value, StringComparer.Ordinal),
|
||||
OutputsByModuleId = outputsByModuleId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<string>)entry.Value.OrderBy(value => value, StringComparer.Ordinal).ToList(), StringComparer.Ordinal),
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
if (!outputsByModuleId.TryGetValue(module.Id, out var outputs))
|
||||
{
|
||||
outputs = new HashSet<string>(StringComparer.Ordinal);
|
||||
outputsByModuleId[module.Id] = outputs;
|
||||
}
|
||||
|
||||
foreach (var product in module.Products)
|
||||
{
|
||||
outputs.Add(product);
|
||||
}
|
||||
}
|
||||
|
||||
return new ProductionGraph
|
||||
{
|
||||
Commodities = commodities,
|
||||
Processes = processes,
|
||||
ProcessesByOutputId = processesByOutputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<ProductionProcessNode>)entry.Value, StringComparer.Ordinal),
|
||||
ProcessesByInputId = processesByInputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<ProductionProcessNode>)entry.Value, StringComparer.Ordinal),
|
||||
OutputsByModuleId = outputsByModuleId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<string>)entry.Value.OrderBy(value => value, StringComparer.Ordinal).ToList(), StringComparer.Ordinal),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class CreatePlayerOrganizationHandler(WorldService worldService) : Endpoint<PlayerOrganizationCommandRequest, PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/player-faction/organizations");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerOrganizationCommandRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = worldService.CreatePlayerOrganization(request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(cancellation: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class DeletePlayerDirectiveRequest
|
||||
{
|
||||
public string DirectiveId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class DeletePlayerDirectiveHandler(WorldService worldService) : Endpoint<DeletePlayerDirectiveRequest, PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/player-faction/directives/{directiveId}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeletePlayerDirectiveRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var snapshot = worldService.DeletePlayerDirective(request.DirectiveId);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class DeletePlayerOrganizationRequest
|
||||
{
|
||||
public string OrganizationId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class DeletePlayerOrganizationHandler(WorldService worldService) : Endpoint<DeletePlayerOrganizationRequest, PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/player-faction/organizations/{organizationId}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeletePlayerOrganizationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = worldService.DeletePlayerOrganization(request.OrganizationId);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(cancellation: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
apps/backend/PlayerFaction/Api/GetPlayerFactionHandler.cs
Normal file
23
apps/backend/PlayerFaction/Api/GetPlayerFactionHandler.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class GetPlayerFactionHandler(WorldService worldService) : EndpointWithoutRequest<PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/player-faction");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var snapshot = worldService.GetPlayerFaction();
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, 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);
|
||||
@@ -0,0 +1,39 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class UpdatePlayerOrganizationMembershipHandler(WorldService worldService) : Endpoint<PlayerOrganizationMembershipCommandRequest, PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/player-faction/organizations/{organizationId}/membership");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerOrganizationMembershipCommandRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var organizationId = Route<string>("organizationId");
|
||||
if (string.IsNullOrWhiteSpace(organizationId))
|
||||
{
|
||||
AddError("organizationId route parameter is required.");
|
||||
await SendErrorsAsync(cancellation: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = worldService.UpdatePlayerOrganizationMembership(organizationId, request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(cancellation: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class UpdatePlayerStrategicIntentHandler(WorldService worldService) : Endpoint<PlayerStrategicIntentCommandRequest, PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/player-faction/strategic-intent");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerStrategicIntentCommandRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var snapshot = worldService.UpdatePlayerStrategicIntent(request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class UpsertPlayerAssignmentHandler(WorldService worldService) : Endpoint<PlayerAssetAssignmentCommandRequest, PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/player-faction/assets/{assetId}/assignment");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerAssetAssignmentCommandRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var assetId = Route<string>("assetId");
|
||||
if (string.IsNullOrWhiteSpace(assetId))
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = worldService.UpsertPlayerAssignment(assetId, request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class UpsertPlayerAutomationPolicyHandler(WorldService worldService) : Endpoint<PlayerAutomationPolicyCommandRequest, PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/player-faction/automation-policies");
|
||||
Put("/api/player-faction/automation-policies/{automationPolicyId}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerAutomationPolicyCommandRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var automationPolicyId = Route<string?>("automationPolicyId");
|
||||
var snapshot = worldService.UpsertPlayerAutomationPolicy(string.IsNullOrWhiteSpace(automationPolicyId) ? null : automationPolicyId, request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class UpsertPlayerDirectiveHandler(WorldService worldService) : Endpoint<PlayerDirectiveCommandRequest, PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/player-faction/directives");
|
||||
Put("/api/player-faction/directives/{directiveId}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerDirectiveCommandRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var directiveId = Route<string?>("directiveId");
|
||||
var snapshot = worldService.UpsertPlayerDirective(string.IsNullOrWhiteSpace(directiveId) ? null : directiveId, request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
}
|
||||
25
apps/backend/PlayerFaction/Api/UpsertPlayerPolicyHandler.cs
Normal file
25
apps/backend/PlayerFaction/Api/UpsertPlayerPolicyHandler.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class UpsertPlayerPolicyHandler(WorldService worldService) : Endpoint<PlayerPolicyCommandRequest, PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/player-faction/policies");
|
||||
Put("/api/player-faction/policies/{policyId}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerPolicyCommandRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var policyId = Route<string?>("policyId");
|
||||
var snapshot = worldService.UpsertPlayerPolicy(string.IsNullOrWhiteSpace(policyId) ? null : policyId, request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class UpsertPlayerProductionProgramHandler(WorldService worldService) : Endpoint<PlayerProductionProgramCommandRequest, PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/player-faction/production-programs");
|
||||
Put("/api/player-faction/production-programs/{productionProgramId}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerProductionProgramCommandRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var productionProgramId = Route<string?>("productionProgramId");
|
||||
var snapshot = worldService.UpsertPlayerProductionProgram(string.IsNullOrWhiteSpace(productionProgramId) ? null : productionProgramId, request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Api;
|
||||
|
||||
public sealed class UpsertPlayerReinforcementPolicyHandler(WorldService worldService) : Endpoint<PlayerReinforcementPolicyCommandRequest, PlayerFactionSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/player-faction/reinforcement-policies");
|
||||
Put("/api/player-faction/reinforcement-policies/{reinforcementPolicyId}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PlayerReinforcementPolicyCommandRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var reinforcementPolicyId = Route<string?>("reinforcementPolicyId");
|
||||
var snapshot = worldService.UpsertPlayerReinforcementPolicy(string.IsNullOrWhiteSpace(reinforcementPolicyId) ? null : reinforcementPolicyId, request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
}
|
||||
274
apps/backend/PlayerFaction/Contracts/PlayerFaction.cs
Normal file
274
apps/backend/PlayerFaction/Contracts/PlayerFaction.cs
Normal file
@@ -0,0 +1,274 @@
|
||||
namespace SpaceGame.Api.PlayerFaction.Contracts;
|
||||
|
||||
public sealed record PlayerAssetRegistrySnapshot(
|
||||
IReadOnlyList<string> ShipIds,
|
||||
IReadOnlyList<string> StationIds,
|
||||
IReadOnlyList<string> CommanderIds,
|
||||
IReadOnlyList<string> ClaimIds,
|
||||
IReadOnlyList<string> ConstructionSiteIds,
|
||||
IReadOnlyList<string> PolicySetIds,
|
||||
IReadOnlyList<string> MarketOrderIds,
|
||||
IReadOnlyList<string> FleetIds,
|
||||
IReadOnlyList<string> TaskForceIds,
|
||||
IReadOnlyList<string> StationGroupIds,
|
||||
IReadOnlyList<string> EconomicRegionIds,
|
||||
IReadOnlyList<string> FrontIds,
|
||||
IReadOnlyList<string> ReserveIds);
|
||||
|
||||
public sealed record PlayerStrategicIntentSnapshot(
|
||||
string StrategicPosture,
|
||||
string EconomicPosture,
|
||||
string MilitaryPosture,
|
||||
string LogisticsPosture,
|
||||
float DesiredReserveRatio,
|
||||
bool AllowDelegatedCombatAutomation,
|
||||
bool AllowDelegatedEconomicAutomation,
|
||||
string? Notes);
|
||||
|
||||
public sealed record PlayerFleetSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string Status,
|
||||
string Role,
|
||||
string? CommanderId,
|
||||
string? FrontId,
|
||||
string? HomeSystemId,
|
||||
string? HomeStationId,
|
||||
string? PolicyId,
|
||||
string? AutomationPolicyId,
|
||||
string? ReinforcementPolicyId,
|
||||
IReadOnlyList<string> AssetIds,
|
||||
IReadOnlyList<string> TaskForceIds,
|
||||
IReadOnlyList<string> DirectiveIds,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record PlayerTaskForceSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string Status,
|
||||
string Role,
|
||||
string? FleetId,
|
||||
string? CommanderId,
|
||||
string? FrontId,
|
||||
string? PolicyId,
|
||||
string? AutomationPolicyId,
|
||||
IReadOnlyList<string> AssetIds,
|
||||
IReadOnlyList<string> DirectiveIds,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record PlayerStationGroupSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string Status,
|
||||
string Role,
|
||||
string? EconomicRegionId,
|
||||
string? PolicyId,
|
||||
string? AutomationPolicyId,
|
||||
IReadOnlyList<string> StationIds,
|
||||
IReadOnlyList<string> DirectiveIds,
|
||||
IReadOnlyList<string> FocusItemIds,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record PlayerEconomicRegionSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string Status,
|
||||
string Role,
|
||||
string? SharedEconomicRegionId,
|
||||
string? PolicyId,
|
||||
string? AutomationPolicyId,
|
||||
IReadOnlyList<string> SystemIds,
|
||||
IReadOnlyList<string> StationGroupIds,
|
||||
IReadOnlyList<string> DirectiveIds,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record PlayerFrontSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string Status,
|
||||
float Priority,
|
||||
string Posture,
|
||||
string? SharedFrontLineId,
|
||||
string? TargetFactionId,
|
||||
IReadOnlyList<string> SystemIds,
|
||||
IReadOnlyList<string> FleetIds,
|
||||
IReadOnlyList<string> ReserveIds,
|
||||
IReadOnlyList<string> DirectiveIds,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record PlayerReserveGroupSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string Status,
|
||||
string ReserveKind,
|
||||
string? HomeSystemId,
|
||||
string? PolicyId,
|
||||
IReadOnlyList<string> AssetIds,
|
||||
IReadOnlyList<string> FrontIds,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record PlayerFactionPolicySnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string ScopeKind,
|
||||
string? ScopeId,
|
||||
string? PolicySetId,
|
||||
bool AllowDelegatedCombat,
|
||||
bool AllowDelegatedTrade,
|
||||
float ReserveCreditsRatio,
|
||||
float ReserveMilitaryRatio,
|
||||
string TradeAccessPolicy,
|
||||
string DockingAccessPolicy,
|
||||
string ConstructionAccessPolicy,
|
||||
string OperationalRangePolicy,
|
||||
string CombatEngagementPolicy,
|
||||
bool AvoidHostileSystems,
|
||||
float FleeHullRatio,
|
||||
IReadOnlyList<string> BlacklistedSystemIds,
|
||||
string? Notes,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record PlayerAutomationPolicySnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string ScopeKind,
|
||||
string? ScopeId,
|
||||
bool Enabled,
|
||||
string BehaviorKind,
|
||||
bool UseOrders,
|
||||
string? StagingOrderKind,
|
||||
int MaxSystemRange,
|
||||
bool KnownStationsOnly,
|
||||
float Radius,
|
||||
float WaitSeconds,
|
||||
string? PreferredItemId,
|
||||
string? Notes,
|
||||
IReadOnlyList<ShipOrderTemplateSnapshot> RepeatOrders,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record PlayerReinforcementPolicySnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string ScopeKind,
|
||||
string? ScopeId,
|
||||
string ShipKind,
|
||||
int DesiredAssetCount,
|
||||
int MinimumReserveCount,
|
||||
bool AutoTransferReserves,
|
||||
bool AutoQueueProduction,
|
||||
string? SourceReserveId,
|
||||
string? TargetFrontId,
|
||||
string? Notes,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record PlayerProductionProgramSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string Status,
|
||||
string Kind,
|
||||
string? TargetShipKind,
|
||||
string? TargetModuleId,
|
||||
string? TargetItemId,
|
||||
int TargetCount,
|
||||
int CurrentCount,
|
||||
string? StationGroupId,
|
||||
string? ReinforcementPolicyId,
|
||||
string? Notes,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record PlayerDirectiveSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string Status,
|
||||
string Kind,
|
||||
string ScopeKind,
|
||||
string ScopeId,
|
||||
string? TargetEntityId,
|
||||
string? TargetSystemId,
|
||||
Vector3Dto? TargetPosition,
|
||||
string? HomeSystemId,
|
||||
string? HomeStationId,
|
||||
string? SourceStationId,
|
||||
string? DestinationStationId,
|
||||
string BehaviorKind,
|
||||
bool UseOrders,
|
||||
string? StagingOrderKind,
|
||||
string? ItemId,
|
||||
string? PreferredAnchorId,
|
||||
string? PreferredConstructionSiteId,
|
||||
string? PreferredModuleId,
|
||||
int Priority,
|
||||
float Radius,
|
||||
float WaitSeconds,
|
||||
int MaxSystemRange,
|
||||
bool KnownStationsOnly,
|
||||
IReadOnlyList<Vector3Dto> PatrolPoints,
|
||||
IReadOnlyList<ShipOrderTemplateSnapshot> RepeatOrders,
|
||||
string? PolicyId,
|
||||
string? AutomationPolicyId,
|
||||
string? Notes,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record PlayerAssignmentSnapshot(
|
||||
string Id,
|
||||
string AssetKind,
|
||||
string AssetId,
|
||||
string? FleetId,
|
||||
string? TaskForceId,
|
||||
string? StationGroupId,
|
||||
string? EconomicRegionId,
|
||||
string? FrontId,
|
||||
string? ReserveId,
|
||||
string? DirectiveId,
|
||||
string? PolicyId,
|
||||
string? AutomationPolicyId,
|
||||
string Role,
|
||||
string Status,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record PlayerDecisionLogEntrySnapshot(
|
||||
string Id,
|
||||
string Kind,
|
||||
string Summary,
|
||||
string? RelatedEntityKind,
|
||||
string? RelatedEntityId,
|
||||
DateTimeOffset OccurredAtUtc);
|
||||
|
||||
public sealed record PlayerAlertSnapshot(
|
||||
string Id,
|
||||
string Kind,
|
||||
string Severity,
|
||||
string Summary,
|
||||
string? AssetKind,
|
||||
string? AssetId,
|
||||
string? RelatedDirectiveId,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAtUtc);
|
||||
|
||||
public sealed record PlayerFactionSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string? PersonaName,
|
||||
string? RaceId,
|
||||
string SovereignFactionId,
|
||||
bool RequiresOnboarding,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
PlayerAssetRegistrySnapshot AssetRegistry,
|
||||
PlayerStrategicIntentSnapshot StrategicIntent,
|
||||
IReadOnlyList<PlayerFleetSnapshot> Fleets,
|
||||
IReadOnlyList<PlayerTaskForceSnapshot> TaskForces,
|
||||
IReadOnlyList<PlayerStationGroupSnapshot> StationGroups,
|
||||
IReadOnlyList<PlayerEconomicRegionSnapshot> EconomicRegions,
|
||||
IReadOnlyList<PlayerFrontSnapshot> Fronts,
|
||||
IReadOnlyList<PlayerReserveGroupSnapshot> Reserves,
|
||||
IReadOnlyList<PlayerFactionPolicySnapshot> Policies,
|
||||
IReadOnlyList<PlayerAutomationPolicySnapshot> AutomationPolicies,
|
||||
IReadOnlyList<PlayerReinforcementPolicySnapshot> ReinforcementPolicies,
|
||||
IReadOnlyList<PlayerProductionProgramSnapshot> ProductionPrograms,
|
||||
IReadOnlyList<PlayerDirectiveSnapshot> Directives,
|
||||
IReadOnlyList<PlayerAssignmentSnapshot> Assignments,
|
||||
IReadOnlyList<PlayerDecisionLogEntrySnapshot> DecisionLog,
|
||||
IReadOnlyList<PlayerAlertSnapshot> Alerts);
|
||||
144
apps/backend/PlayerFaction/Contracts/PlayerFactionCommands.cs
Normal file
144
apps/backend/PlayerFaction/Contracts/PlayerFactionCommands.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
namespace SpaceGame.Api.PlayerFaction.Contracts;
|
||||
|
||||
public sealed record CompletePlayerOnboardingRequest(
|
||||
string Name,
|
||||
string RaceId);
|
||||
|
||||
public sealed record PlayerOrganizationCommandRequest(
|
||||
string Kind,
|
||||
string Label,
|
||||
string? ParentOrganizationId,
|
||||
string? FrontId,
|
||||
string? HomeSystemId,
|
||||
string? HomeStationId,
|
||||
string? PolicyId,
|
||||
string? AutomationPolicyId,
|
||||
string? ReinforcementPolicyId,
|
||||
string? TargetFactionId,
|
||||
float? Priority,
|
||||
string? Role,
|
||||
string? ReserveKind,
|
||||
IReadOnlyList<string>? SystemIds,
|
||||
IReadOnlyList<string>? FocusItemIds,
|
||||
string? Notes);
|
||||
|
||||
public sealed record PlayerOrganizationMembershipCommandRequest(
|
||||
IReadOnlyList<string>? AssetIds,
|
||||
IReadOnlyList<string>? ChildOrganizationIds,
|
||||
IReadOnlyList<string>? SystemIds,
|
||||
IReadOnlyList<string>? FrontIds,
|
||||
bool Replace = false);
|
||||
|
||||
public sealed record PlayerDirectiveCommandRequest(
|
||||
string Label,
|
||||
string Kind,
|
||||
string ScopeKind,
|
||||
string ScopeId,
|
||||
string BehaviorKind,
|
||||
bool UseOrders,
|
||||
string? StagingOrderKind,
|
||||
string? TargetEntityId,
|
||||
string? TargetSystemId,
|
||||
Vector3Dto? TargetPosition,
|
||||
string? HomeSystemId,
|
||||
string? HomeStationId,
|
||||
string? SourceStationId,
|
||||
string? DestinationStationId,
|
||||
string? ItemId,
|
||||
string? PreferredAnchorId,
|
||||
string? PreferredConstructionSiteId,
|
||||
string? PreferredModuleId,
|
||||
int Priority,
|
||||
float? Radius,
|
||||
float? WaitSeconds,
|
||||
int? MaxSystemRange,
|
||||
bool? KnownStationsOnly,
|
||||
IReadOnlyList<Vector3Dto>? PatrolPoints,
|
||||
IReadOnlyList<ShipOrderTemplateCommandRequest>? RepeatOrders,
|
||||
string? PolicyId,
|
||||
string? AutomationPolicyId,
|
||||
string? Notes);
|
||||
|
||||
public sealed record PlayerPolicyCommandRequest(
|
||||
string Label,
|
||||
string ScopeKind,
|
||||
string? ScopeId,
|
||||
string? PolicySetId,
|
||||
bool AllowDelegatedCombat,
|
||||
bool AllowDelegatedTrade,
|
||||
float ReserveCreditsRatio,
|
||||
float ReserveMilitaryRatio,
|
||||
string? Notes,
|
||||
string? TradeAccessPolicy,
|
||||
string? DockingAccessPolicy,
|
||||
string? ConstructionAccessPolicy,
|
||||
string? OperationalRangePolicy,
|
||||
string? CombatEngagementPolicy,
|
||||
bool? AvoidHostileSystems,
|
||||
float? FleeHullRatio,
|
||||
IReadOnlyList<string>? BlacklistedSystemIds);
|
||||
|
||||
public sealed record PlayerAutomationPolicyCommandRequest(
|
||||
string Label,
|
||||
string ScopeKind,
|
||||
string? ScopeId,
|
||||
bool Enabled,
|
||||
string BehaviorKind,
|
||||
bool UseOrders,
|
||||
string? StagingOrderKind,
|
||||
int MaxSystemRange,
|
||||
bool KnownStationsOnly,
|
||||
float Radius,
|
||||
float WaitSeconds,
|
||||
string? PreferredItemId,
|
||||
string? Notes,
|
||||
IReadOnlyList<ShipOrderTemplateCommandRequest>? RepeatOrders);
|
||||
|
||||
public sealed record PlayerReinforcementPolicyCommandRequest(
|
||||
string Label,
|
||||
string ScopeKind,
|
||||
string? ScopeId,
|
||||
string ShipKind,
|
||||
int DesiredAssetCount,
|
||||
int MinimumReserveCount,
|
||||
bool AutoTransferReserves,
|
||||
bool AutoQueueProduction,
|
||||
string? SourceReserveId,
|
||||
string? TargetFrontId,
|
||||
string? Notes);
|
||||
|
||||
public sealed record PlayerProductionProgramCommandRequest(
|
||||
string Label,
|
||||
string Kind,
|
||||
string? TargetShipKind,
|
||||
string? TargetModuleId,
|
||||
string? TargetItemId,
|
||||
int TargetCount,
|
||||
string? StationGroupId,
|
||||
string? ReinforcementPolicyId,
|
||||
string? Notes);
|
||||
|
||||
public sealed record PlayerAssetAssignmentCommandRequest(
|
||||
string AssetKind,
|
||||
string AssetId,
|
||||
string? FleetId,
|
||||
string? TaskForceId,
|
||||
string? StationGroupId,
|
||||
string? EconomicRegionId,
|
||||
string? FrontId,
|
||||
string? ReserveId,
|
||||
string? DirectiveId,
|
||||
string? PolicyId,
|
||||
string? AutomationPolicyId,
|
||||
string Role,
|
||||
bool ClearConflicts = true);
|
||||
|
||||
public sealed record PlayerStrategicIntentCommandRequest(
|
||||
string StrategicPosture,
|
||||
string EconomicPosture,
|
||||
string MilitaryPosture,
|
||||
string LogisticsPosture,
|
||||
float DesiredReserveRatio,
|
||||
bool AllowDelegatedCombatAutomation,
|
||||
bool AllowDelegatedEconomicAutomation,
|
||||
string? Notes);
|
||||
311
apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs
Normal file
311
apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs
Normal file
@@ -0,0 +1,311 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||
|
||||
namespace SpaceGame.Api.PlayerFaction.Runtime;
|
||||
|
||||
public sealed class PlayerFactionRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string? PersonaName { get; set; }
|
||||
public string? RaceId { get; set; }
|
||||
public required string SovereignFactionId { get; set; }
|
||||
public bool RequiresOnboarding { get; set; } = true;
|
||||
public string Status { get; set; } = "active";
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public PlayerAssetRegistryRuntime AssetRegistry { get; set; } = new();
|
||||
public PlayerStrategicIntentRuntime StrategicIntent { get; set; } = new();
|
||||
public List<PlayerFleetRuntime> Fleets { get; } = [];
|
||||
public List<PlayerTaskForceRuntime> TaskForces { get; } = [];
|
||||
public List<PlayerStationGroupRuntime> StationGroups { get; } = [];
|
||||
public List<PlayerEconomicRegionRuntime> EconomicRegions { get; } = [];
|
||||
public List<PlayerFrontRuntime> Fronts { get; } = [];
|
||||
public List<PlayerReserveGroupRuntime> Reserves { get; } = [];
|
||||
public List<PlayerFactionPolicyRuntime> Policies { get; } = [];
|
||||
public List<PlayerAutomationPolicyRuntime> AutomationPolicies { get; } = [];
|
||||
public List<PlayerReinforcementPolicyRuntime> ReinforcementPolicies { get; } = [];
|
||||
public List<PlayerProductionProgramRuntime> ProductionPrograms { get; } = [];
|
||||
public List<PlayerDirectiveRuntime> Directives { get; } = [];
|
||||
public List<PlayerAssignmentRuntime> Assignments { get; } = [];
|
||||
public List<PlayerDecisionLogEntryRuntime> DecisionLog { get; } = [];
|
||||
public List<PlayerAlertRuntime> Alerts { get; } = [];
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class PlayerAssetRegistryRuntime
|
||||
{
|
||||
public HashSet<string> ShipIds { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> StationIds { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> CommanderIds { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> ClaimIds { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> ConstructionSiteIds { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> PolicySetIds { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> MarketOrderIds { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> FleetIds { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> TaskForceIds { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> StationGroupIds { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> EconomicRegionIds { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> FrontIds { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> ReserveIds { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed class PlayerStrategicIntentRuntime
|
||||
{
|
||||
public string StrategicPosture { get; set; } = "balanced";
|
||||
public string EconomicPosture { get; set; } = "delegated";
|
||||
public string MilitaryPosture { get; set; } = "layered-defense";
|
||||
public string LogisticsPosture { get; set; } = "stable";
|
||||
public float DesiredReserveRatio { get; set; } = 0.2f;
|
||||
public bool AllowDelegatedCombatAutomation { get; set; } = true;
|
||||
public bool AllowDelegatedEconomicAutomation { get; set; } = true;
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlayerFleetRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string Role { get; set; } = "general-purpose";
|
||||
public string? CommanderId { get; set; }
|
||||
public string? FrontId { get; set; }
|
||||
public string? HomeSystemId { get; set; }
|
||||
public string? HomeStationId { get; set; }
|
||||
public string? PolicyId { get; set; }
|
||||
public string? AutomationPolicyId { get; set; }
|
||||
public string? ReinforcementPolicyId { get; set; }
|
||||
public List<string> AssetIds { get; } = [];
|
||||
public List<string> TaskForceIds { get; } = [];
|
||||
public List<string> DirectiveIds { get; } = [];
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerTaskForceRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string Role { get; set; } = "task-force";
|
||||
public string? FleetId { get; set; }
|
||||
public string? CommanderId { get; set; }
|
||||
public string? FrontId { get; set; }
|
||||
public string? PolicyId { get; set; }
|
||||
public string? AutomationPolicyId { get; set; }
|
||||
public List<string> AssetIds { get; } = [];
|
||||
public List<string> DirectiveIds { get; } = [];
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerStationGroupRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string Role { get; set; } = "industrial-group";
|
||||
public string? EconomicRegionId { get; set; }
|
||||
public string? PolicyId { get; set; }
|
||||
public string? AutomationPolicyId { get; set; }
|
||||
public List<string> StationIds { get; } = [];
|
||||
public List<string> DirectiveIds { get; } = [];
|
||||
public List<string> FocusItemIds { get; } = [];
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerEconomicRegionRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string Role { get; set; } = "balanced-region";
|
||||
public string? SharedEconomicRegionId { get; set; }
|
||||
public string? PolicyId { get; set; }
|
||||
public string? AutomationPolicyId { get; set; }
|
||||
public List<string> SystemIds { get; } = [];
|
||||
public List<string> StationGroupIds { get; } = [];
|
||||
public List<string> DirectiveIds { get; } = [];
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerFrontRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public float Priority { get; set; } = 50f;
|
||||
public string Posture { get; set; } = "hold";
|
||||
public string? SharedFrontLineId { get; set; }
|
||||
public string? TargetFactionId { get; set; }
|
||||
public List<string> SystemIds { get; } = [];
|
||||
public List<string> FleetIds { get; } = [];
|
||||
public List<string> ReserveIds { get; } = [];
|
||||
public List<string> DirectiveIds { get; } = [];
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerReserveGroupRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string Status { get; set; } = "ready";
|
||||
public string ReserveKind { get; set; } = "military";
|
||||
public string? HomeSystemId { get; set; }
|
||||
public string? PolicyId { get; set; }
|
||||
public List<string> AssetIds { get; } = [];
|
||||
public List<string> FrontIds { get; } = [];
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerFactionPolicyRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string ScopeKind { get; set; } = "player-faction";
|
||||
public string? ScopeId { get; set; }
|
||||
public string? PolicySetId { get; set; }
|
||||
public bool AllowDelegatedCombat { get; set; } = true;
|
||||
public bool AllowDelegatedTrade { get; set; } = true;
|
||||
public float ReserveCreditsRatio { get; set; } = 0.2f;
|
||||
public float ReserveMilitaryRatio { get; set; } = 0.2f;
|
||||
public string TradeAccessPolicy { get; set; } = "owner-and-allies";
|
||||
public string DockingAccessPolicy { get; set; } = "owner-and-allies";
|
||||
public string ConstructionAccessPolicy { get; set; } = "owner-only";
|
||||
public string OperationalRangePolicy { get; set; } = "unrestricted";
|
||||
public string CombatEngagementPolicy { get; set; } = "defensive";
|
||||
public bool AvoidHostileSystems { get; set; } = true;
|
||||
public float FleeHullRatio { get; set; } = 0.35f;
|
||||
public HashSet<string> BlacklistedSystemIds { get; } = new(StringComparer.Ordinal);
|
||||
public string? Notes { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerAutomationPolicyRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string ScopeKind { get; set; } = "player-faction";
|
||||
public string? ScopeId { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string BehaviorKind { get; set; } = Idle;
|
||||
public bool UseOrders { get; set; }
|
||||
public string? StagingOrderKind { get; set; }
|
||||
public int MaxSystemRange { get; set; }
|
||||
public bool KnownStationsOnly { get; set; }
|
||||
public float Radius { get; set; } = 24f;
|
||||
public float WaitSeconds { get; set; } = 3f;
|
||||
public string? PreferredItemId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<ShipOrderTemplateRuntime> RepeatOrders { get; } = [];
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerReinforcementPolicyRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string ScopeKind { get; set; } = "player-faction";
|
||||
public string? ScopeId { get; set; }
|
||||
public string ShipKind { get; set; } = "military";
|
||||
public int DesiredAssetCount { get; set; }
|
||||
public int MinimumReserveCount { get; set; }
|
||||
public bool AutoTransferReserves { get; set; } = true;
|
||||
public bool AutoQueueProduction { get; set; } = true;
|
||||
public string? SourceReserveId { get; set; }
|
||||
public string? TargetFrontId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerProductionProgramRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string Kind { get; set; } = "ship-production";
|
||||
public string? TargetShipKind { get; set; }
|
||||
public string? TargetModuleId { get; set; }
|
||||
public string? TargetItemId { get; set; }
|
||||
public int TargetCount { get; set; }
|
||||
public int CurrentCount { get; set; }
|
||||
public string? StationGroupId { get; set; }
|
||||
public string? ReinforcementPolicyId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerDirectiveRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string Kind { get; set; } = "hold";
|
||||
public string ScopeKind { get; set; } = "asset";
|
||||
public string ScopeId { get; set; } = string.Empty;
|
||||
public string? TargetEntityId { get; set; }
|
||||
public string? TargetSystemId { get; set; }
|
||||
public Vector3? TargetPosition { get; set; }
|
||||
public string? HomeSystemId { get; set; }
|
||||
public string? HomeStationId { get; set; }
|
||||
public string? SourceStationId { get; set; }
|
||||
public string? DestinationStationId { get; set; }
|
||||
public string BehaviorKind { get; set; } = Idle;
|
||||
public bool UseOrders { get; set; }
|
||||
public string? StagingOrderKind { get; set; }
|
||||
public string? ItemId { get; set; }
|
||||
public string? PreferredAnchorId { get; set; }
|
||||
public string? PreferredConstructionSiteId { get; set; }
|
||||
public string? PreferredModuleId { get; set; }
|
||||
public int Priority { get; set; } = 50;
|
||||
public float Radius { get; set; } = 24f;
|
||||
public float WaitSeconds { get; set; } = 3f;
|
||||
public int MaxSystemRange { get; set; }
|
||||
public bool KnownStationsOnly { get; set; }
|
||||
public List<Vector3> PatrolPoints { get; } = [];
|
||||
public List<ShipOrderTemplateRuntime> RepeatOrders { get; } = [];
|
||||
public string? PolicyId { get; set; }
|
||||
public string? AutomationPolicyId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerAssignmentRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string AssetKind { get; set; }
|
||||
public required string AssetId { get; set; }
|
||||
public string? FleetId { get; set; }
|
||||
public string? TaskForceId { get; set; }
|
||||
public string? StationGroupId { get; set; }
|
||||
public string? EconomicRegionId { get; set; }
|
||||
public string? FrontId { get; set; }
|
||||
public string? ReserveId { get; set; }
|
||||
public string? DirectiveId { get; set; }
|
||||
public string? PolicyId { get; set; }
|
||||
public string? AutomationPolicyId { get; set; }
|
||||
public string Role { get; set; } = "line";
|
||||
public string Status { get; set; } = "active";
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerDecisionLogEntryRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; set; }
|
||||
public required string Summary { get; set; }
|
||||
public string? RelatedEntityKind { get; set; }
|
||||
public string? RelatedEntityId { get; set; }
|
||||
public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerAlertRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; set; }
|
||||
public required string Severity { get; set; }
|
||||
public required string Summary { get; set; }
|
||||
public string? AssetKind { get; set; }
|
||||
public string? AssetId { get; set; }
|
||||
public string? RelatedDirectiveId { get; set; }
|
||||
public string Status { get; set; } = "open";
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
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);
|
||||
}
|
||||
2581
apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs
Normal file
2581
apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs
Normal file
File diff suppressed because it is too large
Load Diff
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,28 +1,144 @@
|
||||
using System.Text;
|
||||
using FastEndpoints;
|
||||
using SpaceGame.Api.Universe.Simulation;
|
||||
using FastEndpoints.Swagger;
|
||||
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);
|
||||
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:5079");
|
||||
builder.Services.AddCors((options) =>
|
||||
{
|
||||
options.AddDefaultPolicy((policy) =>
|
||||
{
|
||||
policy
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyOrigin();
|
||||
});
|
||||
options.AddDefaultPolicy((policy) =>
|
||||
{
|
||||
policy
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyOrigin();
|
||||
});
|
||||
});
|
||||
builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration"));
|
||||
builder.Services
|
||||
.AddOptions<StaticDataOptions>()
|
||||
.Bind(builder.Configuration.GetSection("StaticData"))
|
||||
.Validate(options => !string.IsNullOrWhiteSpace(options.DataRoot), "StaticData:DataRoot must be configured.")
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
if (Path.IsPathRooted(options.DataRoot))
|
||||
{
|
||||
options.DataRoot = Path.GetFullPath(options.DataRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
var candidatePaths = new[]
|
||||
{
|
||||
Path.GetFullPath(options.DataRoot),
|
||||
Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, options.DataRoot)),
|
||||
Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "..", options.DataRoot)),
|
||||
};
|
||||
|
||||
var resolvedPath = candidatePaths.FirstOrDefault(Directory.Exists);
|
||||
if (resolvedPath is null)
|
||||
{
|
||||
throw new InvalidOperationException($"StaticData:DataRoot '{options.DataRoot}' could not be resolved to an existing directory.");
|
||||
}
|
||||
|
||||
options.DataRoot = resolvedPath;
|
||||
})
|
||||
.ValidateOnStart();
|
||||
builder.Services.Configure<BalanceOptions>(builder.Configuration.GetSection("Balance"));
|
||||
builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
|
||||
builder.Services.AddFastEndpoints();
|
||||
builder.Services
|
||||
.AddOptions<AuthOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Auth"))
|
||||
.Validate(options => !string.IsNullOrWhiteSpace(options.ConnectionString), "Auth:ConnectionString must be configured.")
|
||||
.ValidateOnStart();
|
||||
builder.Services
|
||||
.AddOptions<JwtOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Jwt"))
|
||||
.Validate(options => !string.IsNullOrWhiteSpace(options.SigningKey), "Jwt:SigningKey must be configured.")
|
||||
.ValidateOnStart();
|
||||
|
||||
var jwtOptions = builder.Configuration.GetSection("Jwt").Get<JwtOptions>() ?? new JwtOptions();
|
||||
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey));
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidateLifetime = true,
|
||||
ValidIssuer = jwtOptions.Issuer,
|
||||
ValidAudience = jwtOptions.Audience,
|
||||
IssuerSigningKey = signingKey,
|
||||
ClockSkew = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
});
|
||||
builder.Services
|
||||
.AddAuthorizationBuilder()
|
||||
.AddPolicy(AuthPolicyNames.AdminAccess, policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireRole(AuthRoleNames.Admin);
|
||||
})
|
||||
.AddPolicy(AuthPolicyNames.GmAccess, policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireRole(AuthRoleNames.Gm);
|
||||
});
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
builder.Services.AddSingleton<IBalanceService, BalanceService>();
|
||||
builder.Services.AddSingleton<AppVersionService>();
|
||||
builder.Services.AddSingleton<IPlayerStateStore, PlayerStateStore>();
|
||||
builder.Services.AddSingleton<PlayerFactionProjectionService>();
|
||||
builder.Services.AddSingleton<LocalPasswordHasher>();
|
||||
builder.Services.AddSingleton<RefreshTokenFactory>();
|
||||
builder.Services.AddSingleton<ITokenService, JwtTokenService>();
|
||||
builder.Services.AddSingleton<IPasswordResetDelivery, DevPasswordResetDelivery>();
|
||||
builder.Services.AddSingleton<IPlayerIdentityResolver, HttpContextPlayerIdentityResolver>();
|
||||
builder.Services.AddSingleton((serviceProvider) =>
|
||||
{
|
||||
var authOptions = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<AuthOptions>>();
|
||||
return new NpgsqlDataSourceBuilder(authOptions.Value.ConnectionString).Build();
|
||||
});
|
||||
builder.Services.AddSingleton<IAuthRepository, PostgresAuthRepository>();
|
||||
builder.Services.AddSingleton<AuthService>();
|
||||
builder.Services.AddSingleton<AuthSchemaInitializer>();
|
||||
builder.Services.AddSingleton<DevAuthSeeder>();
|
||||
builder.Services.AddTransient<SystemGenerationService>();
|
||||
builder.Services.AddTransient<SpatialBuilder>();
|
||||
builder.Services.AddTransient<WorldSeedingService>();
|
||||
builder.Services.AddTransient<ScenarioValidationService>();
|
||||
builder.Services.AddTransient<ScenarioContentBuilder>();
|
||||
builder.Services.AddTransient<ScenarioLoader>();
|
||||
builder.Services.AddTransient<WorldTopologyBuilder>();
|
||||
builder.Services.AddTransient<WorldRuntimeAssembler>();
|
||||
builder.Services.AddTransient<WorldBuilder>();
|
||||
builder.Services.AddSingleton<IStaticDataProvider, StaticDataProvider>();
|
||||
builder.Services.AddSingleton<WorldService>();
|
||||
builder.Services.AddSingleton<TelemetryService>();
|
||||
builder.Services.AddHostedService<SimulationHostedService>();
|
||||
|
||||
builder.Services.AddFastEndpoints();
|
||||
builder.Services.SwaggerDocument();
|
||||
|
||||
var app = builder.Build();
|
||||
await app.Services.GetRequiredService<AuthSchemaInitializer>().EnsureSchemaAsync(CancellationToken.None);
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
await app.Services.GetRequiredService<DevAuthSeeder>().SeedAsync(CancellationToken.None);
|
||||
app.Services.GetRequiredService<WorldService>().LoadFromScenario(StartupScenarioPath);
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseFastEndpoints();
|
||||
app.UseSwaggerGen();
|
||||
|
||||
app.Run();
|
||||
|
||||
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")]
|
||||
@@ -5,16 +5,7 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:0",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:0;http://localhost:0",
|
||||
"applicationUrl": "http://0.0.0.0:5079",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
|
||||
namespace SpaceGame.Api.Shared.AI;
|
||||
|
||||
public abstract class GoapAction<TState>
|
||||
{
|
||||
public abstract string Name { get; }
|
||||
public abstract float Cost { get; }
|
||||
public abstract bool CheckPreconditions(TState state);
|
||||
public abstract TState ApplyEffects(TState state);
|
||||
public abstract void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander);
|
||||
}
|
||||
|
||||
public abstract class GoapGoal<TState>
|
||||
{
|
||||
public abstract string Name { get; }
|
||||
public abstract bool IsSatisfied(TState state);
|
||||
public abstract float ComputePriority(TState state, SimulationWorld world, CommanderRuntime commander);
|
||||
}
|
||||
|
||||
public sealed class GoapPlan<TState>
|
||||
{
|
||||
public static readonly GoapPlan<TState> Empty = new() { Actions = [], TotalCost = 0f };
|
||||
|
||||
public required IReadOnlyList<GoapAction<TState>> Actions { get; init; }
|
||||
public required float TotalCost { get; init; }
|
||||
public int CurrentStep { get; set; }
|
||||
|
||||
public GoapAction<TState>? CurrentAction => CurrentStep < Actions.Count ? Actions[CurrentStep] : null;
|
||||
public bool IsComplete => CurrentStep >= Actions.Count;
|
||||
public void Advance() => CurrentStep++;
|
||||
}
|
||||
|
||||
public sealed class GoapPlanner<TState>
|
||||
{
|
||||
private readonly Func<TState, TState> cloneState;
|
||||
|
||||
public GoapPlanner(Func<TState, TState> cloneState)
|
||||
{
|
||||
this.cloneState = cloneState;
|
||||
}
|
||||
|
||||
public GoapPlan<TState>? Plan(
|
||||
TState initialState,
|
||||
GoapGoal<TState> goal,
|
||||
IReadOnlyList<GoapAction<TState>> availableActions)
|
||||
{
|
||||
if (goal.IsSatisfied(initialState))
|
||||
{
|
||||
return GoapPlan<TState>.Empty;
|
||||
}
|
||||
|
||||
var openSet = new PriorityQueue<PlanNode, float>();
|
||||
openSet.Enqueue(new PlanNode(cloneState(initialState), [], 0f), 0f);
|
||||
|
||||
const int MaxIterations = 256;
|
||||
var iterations = 0;
|
||||
|
||||
while (openSet.Count > 0 && iterations++ < MaxIterations)
|
||||
{
|
||||
var current = openSet.Dequeue();
|
||||
|
||||
if (goal.IsSatisfied(current.State))
|
||||
{
|
||||
return new GoapPlan<TState>
|
||||
{
|
||||
Actions = current.Actions,
|
||||
TotalCost = current.Cost,
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var action in availableActions)
|
||||
{
|
||||
if (!action.CheckPreconditions(current.State))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var newState = action.ApplyEffects(cloneState(current.State));
|
||||
var newCost = current.Cost + action.Cost;
|
||||
var newActions = new List<GoapAction<TState>>(current.Actions) { action };
|
||||
openSet.Enqueue(new PlanNode(newState, newActions, newCost), newCost);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record PlanNode(
|
||||
TState State,
|
||||
IReadOnlyList<GoapAction<TState>> Actions,
|
||||
float Cost);
|
||||
}
|
||||
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."),
|
||||
];
|
||||
}
|
||||
@@ -2,234 +2,379 @@ namespace SpaceGame.Api.Shared.Runtime;
|
||||
|
||||
public enum SpatialNodeKind
|
||||
{
|
||||
Star,
|
||||
Planet,
|
||||
Moon,
|
||||
LagrangePoint,
|
||||
Star,
|
||||
Planet,
|
||||
Moon,
|
||||
LagrangePoint,
|
||||
ResourceNode,
|
||||
}
|
||||
|
||||
public enum WorkStatus
|
||||
{
|
||||
Pending,
|
||||
Active,
|
||||
Completed,
|
||||
Pending,
|
||||
Active,
|
||||
Blocked,
|
||||
Completed,
|
||||
Failed,
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
public enum OrderStatus
|
||||
{
|
||||
Queued,
|
||||
Accepted,
|
||||
Completed,
|
||||
Queued,
|
||||
Active,
|
||||
Completed,
|
||||
Cancelled,
|
||||
Failed,
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
public enum ShipOrderSourceKind
|
||||
{
|
||||
Player,
|
||||
Behavior,
|
||||
Commander,
|
||||
}
|
||||
|
||||
public enum AiPlanStatus
|
||||
{
|
||||
Planned,
|
||||
Running,
|
||||
Blocked,
|
||||
Completed,
|
||||
Failed,
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
public enum AiPlanSourceKind
|
||||
{
|
||||
Rule,
|
||||
Order,
|
||||
DefaultBehavior,
|
||||
}
|
||||
|
||||
public enum ShipState
|
||||
{
|
||||
Idle,
|
||||
Arriving,
|
||||
LocalFlight,
|
||||
SpoolingWarp,
|
||||
Warping,
|
||||
SpoolingFtl,
|
||||
Ftl,
|
||||
CargoFull,
|
||||
MiningApproach,
|
||||
Mining,
|
||||
NodeDepleted,
|
||||
AwaitingDock,
|
||||
DockingApproach,
|
||||
Docking,
|
||||
Docked,
|
||||
Transferring,
|
||||
Loading,
|
||||
Unloading,
|
||||
WaitingMaterials,
|
||||
ConstructionBlocked,
|
||||
Constructing,
|
||||
DeliveringConstruction,
|
||||
Blocked,
|
||||
Undocking,
|
||||
EngagingTarget,
|
||||
Idle,
|
||||
Arriving,
|
||||
LocalFlight,
|
||||
SpoolingWarp,
|
||||
Warping,
|
||||
SpoolingFtl,
|
||||
Ftl,
|
||||
CargoFull,
|
||||
MiningApproach,
|
||||
Mining,
|
||||
NodeDepleted,
|
||||
AwaitingDock,
|
||||
DockingApproach,
|
||||
Docking,
|
||||
Docked,
|
||||
Transferring,
|
||||
Loading,
|
||||
Unloading,
|
||||
WaitingMaterials,
|
||||
ConstructionBlocked,
|
||||
Constructing,
|
||||
DeliveringConstruction,
|
||||
Blocked,
|
||||
Undocking,
|
||||
EngagingTarget,
|
||||
HoldingPosition,
|
||||
Fleeing,
|
||||
}
|
||||
|
||||
public enum ControllerTaskKind
|
||||
public enum SpaceLayerKind
|
||||
{
|
||||
Idle,
|
||||
Travel,
|
||||
Extract,
|
||||
Dock,
|
||||
Load,
|
||||
Unload,
|
||||
DeliverConstruction,
|
||||
BuildConstructionSite,
|
||||
AttackTarget,
|
||||
|
||||
ConstructModule,
|
||||
Undock,
|
||||
UniverseSpace,
|
||||
GalaxySpace,
|
||||
SystemSpace,
|
||||
LocalSpace,
|
||||
}
|
||||
|
||||
public static class SpaceLayerKinds
|
||||
public enum MovementRegimeKind
|
||||
{
|
||||
public const string UniverseSpace = "universe-space";
|
||||
public const string GalaxySpace = "galaxy-space";
|
||||
public const string SystemSpace = "system-space";
|
||||
public const string LocalSpace = "local-space";
|
||||
LocalFlight,
|
||||
Warp,
|
||||
StargateTransit,
|
||||
FtlTransit,
|
||||
}
|
||||
|
||||
public static class MovementRegimeKinds
|
||||
public enum ModuleType
|
||||
{
|
||||
public const string LocalFlight = "local-flight";
|
||||
public const string Warp = "warp";
|
||||
public const string StargateTransit = "stargate-transit";
|
||||
public const string FtlTransit = "ftl-transit";
|
||||
BuildModule,
|
||||
ConnectionModule,
|
||||
DefenceModule,
|
||||
DockArea,
|
||||
Habitation,
|
||||
Pier,
|
||||
ProcessingModule,
|
||||
Production,
|
||||
Storage,
|
||||
}
|
||||
|
||||
public enum StorageKind
|
||||
{
|
||||
Condensate,
|
||||
Container,
|
||||
Liquid,
|
||||
Solid,
|
||||
}
|
||||
|
||||
public static class CommanderKind
|
||||
{
|
||||
public const string Faction = "faction";
|
||||
public const string Station = "station";
|
||||
public const string Ship = "ship";
|
||||
public const string Fleet = "fleet";
|
||||
public const string Sector = "sector";
|
||||
public const string TaskGroup = "task-group";
|
||||
public const string Faction = "faction";
|
||||
public const string Station = "station";
|
||||
public const string Ship = "ship";
|
||||
public const string Fleet = "fleet";
|
||||
public const string Sector = "sector";
|
||||
public const string TaskGroup = "task-group";
|
||||
}
|
||||
|
||||
public static class ShipTaskKinds
|
||||
{
|
||||
public const string Idle = "idle";
|
||||
public const string LocalMove = "local-move";
|
||||
public const string WarpToNode = "warp-to-node";
|
||||
public const string UseStargate = "use-stargate";
|
||||
public const string UseFtl = "use-ftl";
|
||||
public const string Dock = "dock";
|
||||
public const string Undock = "undock";
|
||||
public const string LoadCargo = "load-cargo";
|
||||
public const string UnloadCargo = "unload-cargo";
|
||||
|
||||
public const string MineNode = "mine-node";
|
||||
public const string HarvestGas = "harvest-gas";
|
||||
public const string DeliverToStation = "deliver-to-station";
|
||||
public const string ClaimLagrangePoint = "claim-lagrange-point";
|
||||
public const string BuildConstructionSite = "build-construction-site";
|
||||
public const string EscortTarget = "escort-target";
|
||||
public const string AttackTarget = "attack-target";
|
||||
public const string DefendCelestial = "defend-celestial";
|
||||
public const string Retreat = "retreat";
|
||||
public const string HoldPosition = "hold-position";
|
||||
public const string HoldPosition = "hold-position";
|
||||
public const string Travel = "travel";
|
||||
public const string FollowTarget = "follow-target";
|
||||
public const string MineNode = "mine-node";
|
||||
public const string Dock = "dock";
|
||||
public const string Undock = "undock";
|
||||
public const string LoadCargo = "load-cargo";
|
||||
public const string UnloadCargo = "unload-cargo";
|
||||
public const string TransferCargoToShip = "transfer-cargo-to-ship";
|
||||
public const string SalvageWreck = "salvage-wreck";
|
||||
public const string DeliverConstruction = "deliver-construction";
|
||||
public const string ConstructModule = "construct-module";
|
||||
public const string BuildConstructionSite = "build-construction-site";
|
||||
public const string AttackTarget = "attack-target";
|
||||
public const string Flee = "flee";
|
||||
public const string Wait = "wait";
|
||||
}
|
||||
|
||||
public static class ShipOrderKinds
|
||||
{
|
||||
public const string DirectMove = "direct-move";
|
||||
public const string TravelToNode = "travel-to-node";
|
||||
public const string DockAtStation = "dock-at-station";
|
||||
public const string DeliverCargo = "deliver-cargo";
|
||||
public const string BuildAtSite = "build-at-site";
|
||||
public const string AttackTarget = "attack-target";
|
||||
public const string HoldPosition = "hold-position";
|
||||
public const string Move = "move";
|
||||
public const string DockAtStation = "dock-at-station";
|
||||
public const string FlyToObject = "fly-to-object";
|
||||
public const string FollowShip = "follow-ship";
|
||||
public const string TradeRoute = "trade-route";
|
||||
public const string MineAndDeliver = "mine-and-deliver";
|
||||
public const string BuildAtSite = "build-at-site";
|
||||
public const string AttackTarget = "attack-target";
|
||||
public const string HoldPosition = "hold-position";
|
||||
public const string MineLocal = "mine-local";
|
||||
public const string MineAndDeliverRun = "mine-and-deliver-run";
|
||||
public const string SellMinedCargo = "sell-mined-cargo";
|
||||
public const string SupplyFleetRun = "supply-fleet-run";
|
||||
public const string SalvageRun = "salvage-run";
|
||||
public const string RepeatOrders = "repeat-orders";
|
||||
public const string Flee = "flee";
|
||||
}
|
||||
|
||||
public static class ClaimStateKinds
|
||||
{
|
||||
public const string Placed = "placed";
|
||||
public const string Activating = "activating";
|
||||
public const string Active = "active";
|
||||
public const string Destroyed = "destroyed";
|
||||
public const string Placed = "placed";
|
||||
public const string Activating = "activating";
|
||||
public const string Active = "active";
|
||||
public const string Destroyed = "destroyed";
|
||||
}
|
||||
|
||||
public static class ConstructionSiteStateKinds
|
||||
{
|
||||
public const string Planned = "planned";
|
||||
public const string Active = "active";
|
||||
public const string Paused = "paused";
|
||||
public const string Completed = "completed";
|
||||
public const string Destroyed = "destroyed";
|
||||
public const string Planned = "planned";
|
||||
public const string Active = "active";
|
||||
public const string Paused = "paused";
|
||||
public const string Completed = "completed";
|
||||
public const string Destroyed = "destroyed";
|
||||
}
|
||||
|
||||
public static class MarketOrderKinds
|
||||
{
|
||||
public const string Buy = "buy";
|
||||
public const string Sell = "sell";
|
||||
public const string Buy = "buy";
|
||||
public const string Sell = "sell";
|
||||
}
|
||||
|
||||
public static class MarketOrderStateKinds
|
||||
{
|
||||
public const string Open = "open";
|
||||
public const string PartiallyFilled = "partially-filled";
|
||||
public const string Filled = "filled";
|
||||
public const string Cancelled = "cancelled";
|
||||
public const string Open = "open";
|
||||
public const string PartiallyFilled = "partially-filled";
|
||||
public const string Filled = "filled";
|
||||
public const string Cancelled = "cancelled";
|
||||
}
|
||||
|
||||
public static class SimulationEnumMappings
|
||||
{
|
||||
public static string ToContractValue(this SpatialNodeKind kind) => kind switch
|
||||
{
|
||||
SpatialNodeKind.Star => "star",
|
||||
SpatialNodeKind.Planet => "planet",
|
||||
SpatialNodeKind.Moon => "moon",
|
||||
SpatialNodeKind.LagrangePoint => "lagrange-point",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||
};
|
||||
public static string ToDataValue(this ModuleType moduleType) => moduleType switch
|
||||
{
|
||||
ModuleType.BuildModule => "buildmodule",
|
||||
ModuleType.ConnectionModule => "connectionmodule",
|
||||
ModuleType.DefenceModule => "defencemodule",
|
||||
ModuleType.DockArea => "dockarea",
|
||||
ModuleType.Habitation => "habitation",
|
||||
ModuleType.Pier => "pier",
|
||||
ModuleType.ProcessingModule => "processingmodule",
|
||||
ModuleType.Production => "production",
|
||||
ModuleType.Storage => "storage",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(moduleType), moduleType, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this WorkStatus status) => status switch
|
||||
{
|
||||
WorkStatus.Pending => "pending",
|
||||
WorkStatus.Active => "active",
|
||||
WorkStatus.Completed => "completed",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
||||
};
|
||||
public static ModuleType ToModuleType(this string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "Module type is required.");
|
||||
}
|
||||
|
||||
public static string ToContractValue(this OrderStatus status) => status switch
|
||||
{
|
||||
OrderStatus.Queued => "queued",
|
||||
OrderStatus.Accepted => "accepted",
|
||||
OrderStatus.Completed => "completed",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
||||
};
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"buildmodule" => ModuleType.BuildModule,
|
||||
"connectionmodule" => ModuleType.ConnectionModule,
|
||||
"defencemodule" => ModuleType.DefenceModule,
|
||||
"dockarea" => ModuleType.DockArea,
|
||||
"habitation" => ModuleType.Habitation,
|
||||
"pier" => ModuleType.Pier,
|
||||
"processingmodule" => ModuleType.ProcessingModule,
|
||||
"production" => ModuleType.Production,
|
||||
"storage" => ModuleType.Storage,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unsupported module type."),
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToContractValue(this ShipState state) => state switch
|
||||
{
|
||||
ShipState.Idle => "idle",
|
||||
ShipState.Arriving => "arriving",
|
||||
ShipState.LocalFlight => "local-flight",
|
||||
ShipState.SpoolingWarp => "spooling-warp",
|
||||
ShipState.Warping => "warping",
|
||||
ShipState.SpoolingFtl => "spooling-ftl",
|
||||
ShipState.Ftl => "ftl",
|
||||
ShipState.CargoFull => "cargo-full",
|
||||
ShipState.MiningApproach => "mining-approach",
|
||||
ShipState.Mining => "mining",
|
||||
ShipState.NodeDepleted => "node-depleted",
|
||||
ShipState.AwaitingDock => "awaiting-dock",
|
||||
ShipState.DockingApproach => "docking-approach",
|
||||
ShipState.Docking => "docking",
|
||||
ShipState.Docked => "docked",
|
||||
ShipState.Transferring => "transferring",
|
||||
ShipState.Loading => "loading",
|
||||
ShipState.Unloading => "unloading",
|
||||
ShipState.WaitingMaterials => "waiting-materials",
|
||||
ShipState.ConstructionBlocked => "construction-blocked",
|
||||
ShipState.Constructing => "constructing",
|
||||
ShipState.DeliveringConstruction => "delivering-construction",
|
||||
ShipState.Blocked => "blocked",
|
||||
ShipState.Undocking => "undocking",
|
||||
ShipState.EngagingTarget => "engaging-target",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(state), state, null),
|
||||
};
|
||||
public static string ToDataValue(this StorageKind storageKind) => storageKind switch
|
||||
{
|
||||
StorageKind.Condensate => "condensate",
|
||||
StorageKind.Container => "container",
|
||||
StorageKind.Liquid => "liquid",
|
||||
StorageKind.Solid => "solid",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(storageKind), storageKind, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this ControllerTaskKind kind) => kind switch
|
||||
{
|
||||
ControllerTaskKind.Idle => "idle",
|
||||
ControllerTaskKind.Travel => "travel",
|
||||
ControllerTaskKind.Extract => "extract",
|
||||
ControllerTaskKind.Dock => "dock",
|
||||
ControllerTaskKind.Load => "load",
|
||||
ControllerTaskKind.Unload => "unload",
|
||||
ControllerTaskKind.DeliverConstruction => "deliver-construction",
|
||||
ControllerTaskKind.BuildConstructionSite => "build-construction-site",
|
||||
ControllerTaskKind.AttackTarget => "attack-target",
|
||||
public static StorageKind ToStorageKind(this string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "Storage kind is required.");
|
||||
}
|
||||
|
||||
ControllerTaskKind.ConstructModule => "construct-module",
|
||||
ControllerTaskKind.Undock => "undock",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||
};
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"condensate" => StorageKind.Condensate,
|
||||
"container" => StorageKind.Container,
|
||||
"liquid" => StorageKind.Liquid,
|
||||
"solid" => StorageKind.Solid,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unsupported storage kind."),
|
||||
};
|
||||
}
|
||||
|
||||
public static StorageKind? ToNullableStorageKind(this string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.ToStorageKind();
|
||||
|
||||
public static string ToContractValue(this SpatialNodeKind kind) => kind switch
|
||||
{
|
||||
SpatialNodeKind.Star => "star",
|
||||
SpatialNodeKind.Planet => "planet",
|
||||
SpatialNodeKind.Moon => "moon",
|
||||
SpatialNodeKind.LagrangePoint => "lagrange-point",
|
||||
SpatialNodeKind.ResourceNode => "resource-node",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this WorkStatus status) => status switch
|
||||
{
|
||||
WorkStatus.Pending => "pending",
|
||||
WorkStatus.Active => "active",
|
||||
WorkStatus.Blocked => "blocked",
|
||||
WorkStatus.Completed => "completed",
|
||||
WorkStatus.Failed => "failed",
|
||||
WorkStatus.Interrupted => "interrupted",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this OrderStatus status) => status switch
|
||||
{
|
||||
OrderStatus.Queued => "queued",
|
||||
OrderStatus.Active => "active",
|
||||
OrderStatus.Completed => "completed",
|
||||
OrderStatus.Cancelled => "cancelled",
|
||||
OrderStatus.Failed => "failed",
|
||||
OrderStatus.Interrupted => "interrupted",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this AiPlanStatus status) => status switch
|
||||
{
|
||||
AiPlanStatus.Planned => "planned",
|
||||
AiPlanStatus.Running => "running",
|
||||
AiPlanStatus.Blocked => "blocked",
|
||||
AiPlanStatus.Completed => "completed",
|
||||
AiPlanStatus.Failed => "failed",
|
||||
AiPlanStatus.Interrupted => "interrupted",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this AiPlanSourceKind kind) => kind switch
|
||||
{
|
||||
AiPlanSourceKind.Rule => "rule",
|
||||
AiPlanSourceKind.Order => "order",
|
||||
AiPlanSourceKind.DefaultBehavior => "default-behavior",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this ShipOrderSourceKind kind) => kind switch
|
||||
{
|
||||
ShipOrderSourceKind.Player => "player",
|
||||
ShipOrderSourceKind.Behavior => "behavior",
|
||||
ShipOrderSourceKind.Commander => "commander",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this ShipState state) => state switch
|
||||
{
|
||||
ShipState.Idle => "idle",
|
||||
ShipState.Arriving => "arriving",
|
||||
ShipState.LocalFlight => "local-flight",
|
||||
ShipState.SpoolingWarp => "spooling-warp",
|
||||
ShipState.Warping => "warping",
|
||||
ShipState.SpoolingFtl => "spooling-ftl",
|
||||
ShipState.Ftl => "ftl",
|
||||
ShipState.CargoFull => "cargo-full",
|
||||
ShipState.MiningApproach => "mining-approach",
|
||||
ShipState.Mining => "mining",
|
||||
ShipState.NodeDepleted => "node-depleted",
|
||||
ShipState.AwaitingDock => "awaiting-dock",
|
||||
ShipState.DockingApproach => "docking-approach",
|
||||
ShipState.Docking => "docking",
|
||||
ShipState.Docked => "docked",
|
||||
ShipState.Transferring => "transferring",
|
||||
ShipState.Loading => "loading",
|
||||
ShipState.Unloading => "unloading",
|
||||
ShipState.WaitingMaterials => "waiting-materials",
|
||||
ShipState.ConstructionBlocked => "construction-blocked",
|
||||
ShipState.Constructing => "constructing",
|
||||
ShipState.DeliveringConstruction => "delivering-construction",
|
||||
ShipState.Blocked => "blocked",
|
||||
ShipState.Undocking => "undocking",
|
||||
ShipState.EngagingTarget => "engaging-target",
|
||||
ShipState.HoldingPosition => "holding-position",
|
||||
ShipState.Fleeing => "fleeing",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(state), state, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this SpaceLayerKind kind) => kind switch
|
||||
{
|
||||
SpaceLayerKind.UniverseSpace => "universe-space",
|
||||
SpaceLayerKind.GalaxySpace => "galaxy-space",
|
||||
SpaceLayerKind.SystemSpace => "system-space",
|
||||
SpaceLayerKind.LocalSpace => "local-space",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this MovementRegimeKind kind) => kind switch
|
||||
{
|
||||
MovementRegimeKind.LocalFlight => "local-flight",
|
||||
MovementRegimeKind.Warp => "warp",
|
||||
MovementRegimeKind.StargateTransit => "stargate-transit",
|
||||
MovementRegimeKind.FtlTransit => "ftl-transit",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,179 +3,298 @@ namespace SpaceGame.Api.Shared.Runtime;
|
||||
|
||||
internal static class SimulationRuntimeSupport
|
||||
{
|
||||
internal static bool HasShipCapabilities(ShipDefinition definition, params string[] capabilities) =>
|
||||
capabilities.All(cap => definition.Capabilities.Contains(cap, StringComparer.Ordinal));
|
||||
internal static bool CanWarp(ShipDefinition definition) =>
|
||||
definition.Engines.Count > 0;
|
||||
|
||||
internal static int CountStationModules(StationRuntime station, string moduleId) =>
|
||||
station.Modules.Count(module => string.Equals(module.ModuleId, moduleId, StringComparison.Ordinal));
|
||||
internal static bool CanFtl(ShipDefinition definition) =>
|
||||
definition.Engines.Count > 0;
|
||||
|
||||
internal static void AddStationModule(SimulationWorld world, StationRuntime station, string moduleId)
|
||||
{
|
||||
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var definition))
|
||||
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)
|
||||
{
|
||||
return;
|
||||
if (IsMilitaryShip(definition))
|
||||
{
|
||||
return "military";
|
||||
}
|
||||
|
||||
if (IsConstructionShip(definition))
|
||||
{
|
||||
return "construction";
|
||||
}
|
||||
|
||||
if (IsTransportShip(definition))
|
||||
{
|
||||
return "transport";
|
||||
}
|
||||
|
||||
if (IsMiningShip(definition))
|
||||
{
|
||||
return "mining";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
station.Modules.Add(new StationModuleRuntime
|
||||
internal static int CountStationModules(StationRuntime station, ModuleType moduleType) =>
|
||||
station.Modules.Count(module => module.ModuleType == moduleType);
|
||||
|
||||
internal static float GetStationSupportedPopulation(
|
||||
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
||||
StationRuntime station) =>
|
||||
40f + station.Modules
|
||||
.Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition) && definition is HabitationModuleDefinition habitation
|
||||
? habitation.SupportedPopulation
|
||||
: 0f)
|
||||
.Sum();
|
||||
|
||||
internal static float GetStationRequiredWorkforce(
|
||||
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
||||
StationRuntime station) =>
|
||||
MathF.Max(12f, station.Modules
|
||||
.Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition)
|
||||
&& definition is ProductionLaneModuleDefinition productionLane
|
||||
? productionLane.RequiredWorkforce
|
||||
: 0f)
|
||||
.Sum());
|
||||
|
||||
internal static float GetStationStorageCapacity(SimulationWorld world, StationRuntime station, StorageKind storageKind)
|
||||
{
|
||||
Id = $"{station.Id}-module-{station.Modules.Count + 1}",
|
||||
ModuleId = moduleId,
|
||||
Health = definition.Hull,
|
||||
MaxHealth = definition.Hull,
|
||||
});
|
||||
station.Radius = GetStationRadius(world, station);
|
||||
}
|
||||
|
||||
internal static float GetStationRadius(SimulationWorld world, StationRuntime station)
|
||||
{
|
||||
var totalArea = station.Modules
|
||||
.Select(module => world.ModuleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f)
|
||||
.Sum();
|
||||
return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f)));
|
||||
}
|
||||
|
||||
internal static float GetStationStorageCapacity(StationRuntime station, string storageClass)
|
||||
{
|
||||
var baseCapacity = storageClass switch
|
||||
{
|
||||
"manufactured" => 400f,
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
var bulkBays = CountStationModules(station, "module_arg_stor_solid_m_01");
|
||||
var liquidTanks = CountStationModules(station, "module_arg_stor_liquid_m_01");
|
||||
var containerBays = CountStationModules(station, "module_arg_stor_container_m_01");
|
||||
|
||||
var moduleCapacity = storageClass switch
|
||||
{
|
||||
"solid" => bulkBays * 1000f,
|
||||
"liquid" => liquidTanks * 500f,
|
||||
"container" => containerBays * 800f,
|
||||
"manufactured" => containerBays * 200f,
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
return baseCapacity + moduleCapacity;
|
||||
}
|
||||
|
||||
internal static int CountModules(IEnumerable<string> modules, string moduleId) =>
|
||||
modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal));
|
||||
|
||||
internal static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
|
||||
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
|
||||
|
||||
internal static void AddInventory(IDictionary<string, float> inventory, string itemId, float amount)
|
||||
{
|
||||
if (amount <= 0f)
|
||||
{
|
||||
return;
|
||||
SyncStorageModuleLevels(world, station, storageKind);
|
||||
return GetStorageModules(world, station, storageKind)
|
||||
.Sum(entry => entry.Definition.StorageCapacity);
|
||||
}
|
||||
|
||||
inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId) + amount;
|
||||
}
|
||||
|
||||
internal static float RemoveInventory(IDictionary<string, float> inventory, string itemId, float amount)
|
||||
{
|
||||
var current = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId);
|
||||
var removed = MathF.Min(current, amount);
|
||||
var remaining = current - removed;
|
||||
if (remaining <= 0.001f)
|
||||
internal static bool HasStorageCapacity(SimulationWorld world, StationRuntime station, StorageKind storageKind)
|
||||
{
|
||||
inventory.Remove(itemId);
|
||||
}
|
||||
else
|
||||
{
|
||||
inventory[itemId] = remaining;
|
||||
SyncStorageModuleLevels(world, station, storageKind);
|
||||
return GetStorageModules(world, station, storageKind).Any();
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
private static IEnumerable<(StorageStationModuleRuntime Module, StorageModuleDefinition Definition)> GetStorageModules(
|
||||
SimulationWorld world,
|
||||
StationRuntime station,
|
||||
StorageKind storageKind) =>
|
||||
station.Modules
|
||||
.OfType<StorageStationModuleRuntime>()
|
||||
.Where(module => module.StorageKind == storageKind)
|
||||
.Select(module => world.ModuleDefinitions.TryGetValue(module.ModuleId, out var definition) && definition is StorageModuleDefinition storageDefinition
|
||||
? (Module: module, Definition: storageDefinition)
|
||||
: ((StorageStationModuleRuntime Module, StorageModuleDefinition Definition)?)null)
|
||||
.Where(entry => entry is not null && entry.Value.Definition.StorageKind == storageKind)
|
||||
.Select(entry => entry!.Value);
|
||||
|
||||
internal static bool HasStationModules(StationRuntime station, params string[] modules) =>
|
||||
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
|
||||
|
||||
internal static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node, SimulationWorld world) =>
|
||||
HasShipCapabilities(ship.Definition, "mining")
|
||||
&& world.ItemDefinitions.TryGetValue(node.ItemId, out var item)
|
||||
&& string.Equals(item.CargoKind, ship.Definition.CargoKind, StringComparison.Ordinal);
|
||||
|
||||
internal static bool CanBuildClaimBeacon(ShipRuntime ship) =>
|
||||
string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal);
|
||||
|
||||
internal static float ComputeWorkforceRatio(float population, float workforceRequired)
|
||||
{
|
||||
if (workforceRequired <= 0.01f)
|
||||
private static void SyncStorageModuleLevels(SimulationWorld world, StationRuntime station, StorageKind storageKind)
|
||||
{
|
||||
return 1f;
|
||||
var storageModules = GetStorageModules(world, station, storageKind)
|
||||
.OrderBy(entry => entry.Module.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (storageModules.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var remaining = station.Inventory
|
||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageKind)
|
||||
.Sum(entry => entry.Value);
|
||||
|
||||
foreach (var (module, definition) in storageModules)
|
||||
{
|
||||
module.CurrentLevel = MathF.Min(remaining, definition.StorageCapacity);
|
||||
remaining = MathF.Max(0f, remaining - definition.StorageCapacity);
|
||||
}
|
||||
}
|
||||
|
||||
var staffedRatio = MathF.Min(1f, population / workforceRequired);
|
||||
return 0.1f + (0.9f * staffedRatio);
|
||||
}
|
||||
|
||||
internal static string? GetStorageRequirement(string storageClass) =>
|
||||
storageClass switch
|
||||
{
|
||||
"solid" => "module_arg_stor_solid_m_01",
|
||||
"liquid" => "module_arg_stor_liquid_m_01",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
internal static float TryAddStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount)
|
||||
{
|
||||
if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
|
||||
internal static void AddStationModule(SimulationWorld world, StationRuntime station, string moduleId)
|
||||
{
|
||||
return 0f;
|
||||
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var definition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
station.Modules.Add(StationModuleRuntime.Create($"{station.Id}-module-{station.Modules.Count + 1}", definition));
|
||||
station.Radius = GetStationRadius(world, station);
|
||||
}
|
||||
|
||||
var storageClass = itemDefinition.CargoKind;
|
||||
var requiredModule = GetStorageRequirement(storageClass);
|
||||
if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal))
|
||||
internal static float GetStationRadius(SimulationWorld world, StationRuntime station)
|
||||
{
|
||||
return 0f;
|
||||
var totalArea = station.Modules
|
||||
.Select(module => world.ModuleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f)
|
||||
.Sum();
|
||||
return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f)));
|
||||
}
|
||||
|
||||
var capacity = GetStationStorageCapacity(station, storageClass);
|
||||
if (capacity <= 0.01f)
|
||||
internal static int CountModules(IEnumerable<string> modules, string moduleId) =>
|
||||
modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal));
|
||||
|
||||
internal static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
|
||||
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
|
||||
|
||||
internal static void AddInventory(IDictionary<string, float> inventory, string itemId, float amount)
|
||||
{
|
||||
return 0f;
|
||||
if (amount <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId) + amount;
|
||||
}
|
||||
|
||||
var used = station.Inventory
|
||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageClass)
|
||||
.Sum(entry => entry.Value);
|
||||
var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used));
|
||||
if (accepted <= 0.01f)
|
||||
internal static float RemoveInventory(IDictionary<string, float> inventory, string itemId, float amount)
|
||||
{
|
||||
return 0f;
|
||||
var current = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId);
|
||||
var removed = MathF.Min(current, amount);
|
||||
var remaining = current - removed;
|
||||
if (remaining <= 0.001f)
|
||||
{
|
||||
inventory.Remove(itemId);
|
||||
}
|
||||
else
|
||||
{
|
||||
inventory[itemId] = remaining;
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
AddInventory(station.Inventory, itemId, accepted);
|
||||
return accepted;
|
||||
}
|
||||
internal static bool HasStationModules(StationRuntime station, params string[] modules) =>
|
||||
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
|
||||
|
||||
internal static bool CanStartModuleConstruction(StationRuntime station, ModuleRecipeDefinition recipe) =>
|
||||
recipe.Inputs.All(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f >= input.Amount);
|
||||
internal static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node, SimulationWorld world) =>
|
||||
IsMiningShip(ship.Definition)
|
||||
&& world.ItemDefinitions.TryGetValue(node.ItemId, out var item)
|
||||
&& item.CargoKind is not null
|
||||
&& ship.Definition.SupportsCargoKind(item.CargoKind.Value);
|
||||
|
||||
internal static ConstructionSiteRuntime? GetConstructionSiteForStation(SimulationWorld world, string stationId) =>
|
||||
world.ConstructionSites.FirstOrDefault(site =>
|
||||
string.Equals(site.StationId, stationId, StringComparison.Ordinal)
|
||||
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
|
||||
internal static bool CanBuildClaimBeacon(ShipRuntime ship) =>
|
||||
IsMilitaryShip(ship.Definition);
|
||||
|
||||
internal static float GetConstructionDeliveredAmount(SimulationWorld world, ConstructionSiteRuntime site, string itemId)
|
||||
{
|
||||
if (site.StationId is not null
|
||||
&& world.Stations.FirstOrDefault(candidate => candidate.Id == site.StationId) is { } station)
|
||||
internal static float ComputeWorkforceRatio(float population, float workforceRequired)
|
||||
{
|
||||
return GetInventoryAmount(station.Inventory, itemId);
|
||||
if (workforceRequired <= 0.01f)
|
||||
{
|
||||
return 1f;
|
||||
}
|
||||
|
||||
var staffedRatio = MathF.Min(1f, population / workforceRequired);
|
||||
return 0.1f + (0.9f * staffedRatio);
|
||||
}
|
||||
|
||||
return GetInventoryAmount(site.DeliveredItems, itemId);
|
||||
}
|
||||
internal static string? GetStorageRequirement(
|
||||
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
||||
StorageKind? storageKind)
|
||||
{
|
||||
if (storageKind is not { } requiredStorageKind)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static bool IsConstructionSiteReady(SimulationWorld world, ConstructionSiteRuntime site) =>
|
||||
site.RequiredItems.All(entry => GetConstructionDeliveredAmount(world, site, entry.Key) + 0.001f >= entry.Value);
|
||||
return moduleDefinitions.Values
|
||||
.OfType<StorageModuleDefinition>()
|
||||
.Where(definition => definition.StorageKind == requiredStorageKind)
|
||||
.OrderBy(definition => GetPreferredStorageModuleRank(definition.Id))
|
||||
.ThenBy(definition => definition.Id, StringComparer.Ordinal)
|
||||
.Select(definition => definition.Id)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
internal static float GetShipCargoAmount(ShipRuntime ship) =>
|
||||
ship.Inventory.Values.Sum();
|
||||
private static int GetPreferredStorageModuleRank(string moduleId)
|
||||
{
|
||||
if (moduleId.Contains("_m_", StringComparison.Ordinal))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (moduleId.Contains("_s_", StringComparison.Ordinal))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (moduleId.Contains("_l_", StringComparison.Ordinal))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
internal static float TryAddStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount)
|
||||
{
|
||||
if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var storageKind = itemDefinition.CargoKind;
|
||||
if (storageKind is null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
if (!HasStorageCapacity(world, station, storageKind.Value))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var capacity = GetStationStorageCapacity(world, station, storageKind.Value);
|
||||
if (capacity <= 0.01f)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var used = station.Inventory
|
||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageKind)
|
||||
.Sum(entry => entry.Value);
|
||||
var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used));
|
||||
if (accepted <= 0.01f)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
AddInventory(station.Inventory, itemId, accepted);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
internal static bool CanStartModuleConstruction(StationRuntime station, ModuleRecipeDefinition recipe) =>
|
||||
recipe.Inputs.All(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f >= input.Amount);
|
||||
|
||||
internal static ConstructionSiteRuntime? GetConstructionSiteForStation(SimulationWorld world, string stationId) =>
|
||||
world.ConstructionSites.FirstOrDefault(site =>
|
||||
string.Equals(site.StationId, stationId, StringComparison.Ordinal)
|
||||
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
|
||||
|
||||
internal static float GetConstructionDeliveredAmount(SimulationWorld world, ConstructionSiteRuntime site, string itemId)
|
||||
{
|
||||
if (site.StationId is not null
|
||||
&& world.Stations.FirstOrDefault(candidate => candidate.Id == site.StationId) is { } station)
|
||||
{
|
||||
return GetInventoryAmount(station.Inventory, itemId);
|
||||
}
|
||||
|
||||
return GetInventoryAmount(site.DeliveredItems, itemId);
|
||||
}
|
||||
|
||||
internal static bool IsConstructionSiteReady(SimulationWorld world, ConstructionSiteRuntime site) =>
|
||||
site.RequiredItems.All(entry => GetConstructionDeliveredAmount(world, site, entry.Key) + 0.001f >= entry.Value);
|
||||
|
||||
internal static float GetShipCargoAmount(ShipRuntime ship) =>
|
||||
ship.Inventory.Values.Sum();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,22 @@ public static class SimulationUnits
|
||||
|
||||
public static float AuToKilometers(float au) => au * KilometersPerAu;
|
||||
|
||||
public static float KilometersToMeters(float kilometers) => kilometers * MetersPerKilometer;
|
||||
|
||||
public static float MetersToKilometers(float meters) => meters / MetersPerKilometer;
|
||||
|
||||
public static Vector3 KilometersToMeters(Vector3 kilometers) =>
|
||||
new(
|
||||
KilometersToMeters(kilometers.X),
|
||||
KilometersToMeters(kilometers.Y),
|
||||
KilometersToMeters(kilometers.Z));
|
||||
|
||||
public static Vector3 MetersToKilometers(Vector3 meters) =>
|
||||
new(
|
||||
MetersToKilometers(meters.X),
|
||||
MetersToKilometers(meters.Y),
|
||||
MetersToKilometers(meters.Z));
|
||||
|
||||
public static float AuPerSecondToKilometersPerSecond(float auPerSecond) =>
|
||||
auPerSecond * KilometersPerAu;
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
internal interface IShipBehaviorState
|
||||
{
|
||||
string Kind { get; }
|
||||
|
||||
void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world);
|
||||
|
||||
void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
internal sealed class ShipBehaviorStateMachine
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IShipBehaviorState> states;
|
||||
private readonly IShipBehaviorState fallbackState;
|
||||
|
||||
private ShipBehaviorStateMachine(IReadOnlyDictionary<string, IShipBehaviorState> states, IShipBehaviorState fallbackState)
|
||||
{
|
||||
this.states = states;
|
||||
this.fallbackState = fallbackState;
|
||||
}
|
||||
|
||||
public static ShipBehaviorStateMachine CreateDefault()
|
||||
{
|
||||
var idleState = new IdleShipBehaviorState();
|
||||
var knownStates = new IShipBehaviorState[]
|
||||
{
|
||||
idleState,
|
||||
new PatrolShipBehaviorState(),
|
||||
new AttackTargetShipBehaviorState(),
|
||||
new TradeHaulShipBehaviorState(),
|
||||
new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining"),
|
||||
new ConstructStationShipBehaviorState(),
|
||||
};
|
||||
|
||||
return new ShipBehaviorStateMachine(
|
||||
knownStates.ToDictionary(state => state.Kind, StringComparer.Ordinal),
|
||||
idleState);
|
||||
}
|
||||
|
||||
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
|
||||
Resolve(ship.DefaultBehavior.Kind).Plan(engine, ship, world);
|
||||
|
||||
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) =>
|
||||
Resolve(ship.DefaultBehavior.Kind).ApplyEvent(engine, ship, world, controllerEvent);
|
||||
|
||||
private IShipBehaviorState Resolve(string kind) =>
|
||||
states.TryGetValue(kind, out var state) ? state : fallbackState;
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
internal sealed class IdleShipBehaviorState : IShipBehaviorState
|
||||
{
|
||||
public string Kind => "idle";
|
||||
|
||||
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Idle,
|
||||
Threshold = world.Balance.ArrivalThreshold,
|
||||
Status = WorkStatus.Pending,
|
||||
};
|
||||
}
|
||||
|
||||
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PatrolShipBehaviorState : IShipBehaviorState
|
||||
{
|
||||
public string Kind => "patrol";
|
||||
|
||||
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
if (ship.DefaultBehavior.PatrolPoints.Count == 0)
|
||||
{
|
||||
ship.DefaultBehavior.Kind = "idle";
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Idle,
|
||||
Threshold = world.Balance.ArrivalThreshold,
|
||||
Status = WorkStatus.Pending,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex],
|
||||
TargetSystemId = ship.SystemId,
|
||||
Threshold = 18f,
|
||||
};
|
||||
}
|
||||
|
||||
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
|
||||
{
|
||||
if (controllerEvent == "arrived" && ship.DefaultBehavior.PatrolPoints.Count > 0)
|
||||
{
|
||||
ship.DefaultBehavior.PatrolIndex = (ship.DefaultBehavior.PatrolIndex + 1) % ship.DefaultBehavior.PatrolPoints.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState
|
||||
{
|
||||
private readonly string resourceItemId;
|
||||
private readonly string requiredModule;
|
||||
|
||||
public ResourceHarvestShipBehaviorState(string kind, string resourceItemId, string requiredModule)
|
||||
{
|
||||
Kind = kind;
|
||||
this.resourceItemId = resourceItemId;
|
||||
this.requiredModule = requiredModule;
|
||||
}
|
||||
|
||||
public string Kind { get; }
|
||||
|
||||
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
|
||||
engine.PlanResourceHarvest(ship, world, resourceItemId, requiredModule);
|
||||
|
||||
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
|
||||
{
|
||||
switch (ship.DefaultBehavior.Phase, controllerEvent)
|
||||
{
|
||||
case ("travel-to-node", "arrived"):
|
||||
ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract";
|
||||
break;
|
||||
case ("extract", "cargo-full"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-station";
|
||||
break;
|
||||
case ("extract", "node-depleted"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-node";
|
||||
ship.DefaultBehavior.NodeId = null;
|
||||
break;
|
||||
case ("travel-to-station", "arrived"):
|
||||
ship.DefaultBehavior.Phase = "dock";
|
||||
break;
|
||||
case ("dock", "docked"):
|
||||
ship.DefaultBehavior.Phase = "unload";
|
||||
break;
|
||||
case ("unload", "unloaded"):
|
||||
ship.DefaultBehavior.Phase = "undock";
|
||||
break;
|
||||
case ("undock", "undocked"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-node";
|
||||
ship.DefaultBehavior.NodeId = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState
|
||||
{
|
||||
public string Kind => "construct-station";
|
||||
|
||||
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
|
||||
engine.PlanStationConstruction(ship, world);
|
||||
|
||||
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
|
||||
{
|
||||
switch (ship.DefaultBehavior.Phase, controllerEvent)
|
||||
{
|
||||
case ("travel-to-station", "arrived"):
|
||||
ship.DefaultBehavior.Phase = "deliver-to-site";
|
||||
break;
|
||||
case ("deliver-to-site", "construction-delivered"):
|
||||
ship.DefaultBehavior.Phase = "build-site";
|
||||
break;
|
||||
case ("construct-module", "module-constructed"):
|
||||
case ("build-site", "site-constructed"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-station";
|
||||
ship.DefaultBehavior.ModuleId = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AttackTargetShipBehaviorState : IShipBehaviorState
|
||||
{
|
||||
public string Kind => "attack-target";
|
||||
|
||||
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
|
||||
engine.PlanAttackTarget(ship, world);
|
||||
|
||||
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
|
||||
{
|
||||
if (controllerEvent is "target-destroyed" or "target-lost")
|
||||
{
|
||||
ship.DefaultBehavior.TargetEntityId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TradeHaulShipBehaviorState : IShipBehaviorState
|
||||
{
|
||||
public string Kind => "trade-haul";
|
||||
|
||||
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
|
||||
engine.PlanTransportHaul(ship, world);
|
||||
|
||||
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
|
||||
{
|
||||
switch (ship.DefaultBehavior.Phase, controllerEvent)
|
||||
{
|
||||
case ("travel-to-source", "arrived"):
|
||||
ship.DefaultBehavior.Phase = "dock-source";
|
||||
break;
|
||||
case ("dock-source", "docked"):
|
||||
ship.DefaultBehavior.Phase = "load";
|
||||
break;
|
||||
case ("load", "loaded"):
|
||||
ship.DefaultBehavior.Phase = "undock-from-source";
|
||||
break;
|
||||
case ("undock-from-source", "undocked"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-destination";
|
||||
break;
|
||||
case ("travel-to-destination", "arrived"):
|
||||
ship.DefaultBehavior.Phase = "dock-destination";
|
||||
break;
|
||||
case ("dock-destination", "docked"):
|
||||
ship.DefaultBehavior.Phase = "unload";
|
||||
break;
|
||||
case ("unload", "unloaded"):
|
||||
ship.DefaultBehavior.Phase = "undock-from-destination";
|
||||
break;
|
||||
case ("undock-from-destination", "undocked"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-source";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
// ─── Planning State ────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class ShipPlanningState
|
||||
{
|
||||
public string ShipKind { get; set; } = string.Empty;
|
||||
public bool HasMiningCapability { get; set; }
|
||||
public bool FactionWantsOre { get; set; }
|
||||
public bool FactionWantsExpansion { get; set; }
|
||||
public bool FactionWantsCombat { get; set; }
|
||||
public bool FactionNeedsShipyard { get; set; }
|
||||
public string? TargetEnemySystemId { get; set; }
|
||||
public string? TargetEnemyEntityId { get; set; }
|
||||
public string? TradeItemId { get; set; }
|
||||
public string? TradeSourceStationId { get; set; }
|
||||
public string? TradeDestinationStationId { get; set; }
|
||||
public string? CurrentObjective { get; set; }
|
||||
|
||||
public ShipPlanningState Clone() => (ShipPlanningState)MemberwiseClone();
|
||||
}
|
||||
|
||||
// ─── Goals ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// A ship should always have an assigned objective. The planner picks the best one.
|
||||
public sealed class AssignObjectiveGoal : GoapGoal<ShipPlanningState>
|
||||
{
|
||||
public override string Name => "assign-objective";
|
||||
|
||||
public override bool IsSatisfied(ShipPlanningState state) => state.CurrentObjective is not null;
|
||||
|
||||
public override float ComputePriority(ShipPlanningState state, SimulationWorld world, CommanderRuntime commander) =>
|
||||
100f;
|
||||
}
|
||||
|
||||
// ─── Actions ───────────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class SetMiningObjectiveAction : GoapAction<ShipPlanningState>
|
||||
{
|
||||
public override string Name => "set-mining-objective";
|
||||
public override float Cost => 1f;
|
||||
|
||||
public override bool CheckPreconditions(ShipPlanningState state) =>
|
||||
state.HasMiningCapability && state.FactionWantsOre;
|
||||
|
||||
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
|
||||
{
|
||||
state.CurrentObjective = "auto-mine";
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
|
||||
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "auto-mine", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.DefaultBehavior.Kind = "auto-mine";
|
||||
ship.DefaultBehavior.Phase = null;
|
||||
ship.DefaultBehavior.NodeId = null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SetPatrolObjectiveAction : GoapAction<ShipPlanningState>
|
||||
{
|
||||
public override string Name => "set-patrol-objective";
|
||||
public override float Cost => 2f;
|
||||
|
||||
public override bool CheckPreconditions(ShipPlanningState state) =>
|
||||
string.Equals(state.ShipKind, "military", StringComparison.Ordinal);
|
||||
|
||||
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
|
||||
{
|
||||
state.CurrentObjective = "patrol";
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
|
||||
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "patrol", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ship.DefaultBehavior.PatrolPoints.Count == 0)
|
||||
{
|
||||
var station = world.Stations.FirstOrDefault(s =>
|
||||
s.FactionId == ship.FactionId &&
|
||||
string.Equals(s.SystemId, ship.SystemId, StringComparison.Ordinal));
|
||||
|
||||
if (station is not null)
|
||||
{
|
||||
var radius = station.Radius + 90f;
|
||||
ship.DefaultBehavior.PatrolPoints.AddRange(
|
||||
[
|
||||
new Vector3(station.Position.X + radius, station.Position.Y, station.Position.Z),
|
||||
new Vector3(station.Position.X, station.Position.Y, station.Position.Z + radius),
|
||||
new Vector3(station.Position.X - radius, station.Position.Y, station.Position.Z),
|
||||
new Vector3(station.Position.X, station.Position.Y, station.Position.Z - radius),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
ship.DefaultBehavior.Kind = "patrol";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SetAttackObjectiveAction : GoapAction<ShipPlanningState>
|
||||
{
|
||||
public override string Name => "set-attack-objective";
|
||||
public override float Cost => 1f;
|
||||
|
||||
public override bool CheckPreconditions(ShipPlanningState state) =>
|
||||
string.Equals(state.ShipKind, "military", StringComparison.Ordinal)
|
||||
&& state.FactionWantsCombat
|
||||
&& state.TargetEnemyEntityId is not null;
|
||||
|
||||
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
|
||||
{
|
||||
state.CurrentObjective = "attack-target";
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
|
||||
if (ship is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.DefaultBehavior.Kind = "attack-target";
|
||||
ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior?.AreaSystemId ?? ship.DefaultBehavior.AreaSystemId;
|
||||
ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId;
|
||||
ship.DefaultBehavior.Phase = null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SetConstructionObjectiveAction : GoapAction<ShipPlanningState>
|
||||
{
|
||||
public override string Name => "set-construction-objective";
|
||||
public override float Cost => 1f;
|
||||
|
||||
public override bool CheckPreconditions(ShipPlanningState state) =>
|
||||
string.Equals(state.ShipKind, "construction", StringComparison.Ordinal)
|
||||
&& (state.FactionWantsExpansion || state.FactionNeedsShipyard);
|
||||
|
||||
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
|
||||
{
|
||||
state.CurrentObjective = "construct-station";
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
|
||||
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "construct-station", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.DefaultBehavior.Kind = "construct-station";
|
||||
ship.DefaultBehavior.Phase = null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SetTradeObjectiveAction : GoapAction<ShipPlanningState>
|
||||
{
|
||||
public override string Name => "set-trade-objective";
|
||||
public override float Cost => 1f;
|
||||
|
||||
public override bool CheckPreconditions(ShipPlanningState state) =>
|
||||
string.Equals(state.ShipKind, "transport", StringComparison.Ordinal)
|
||||
&& state.TradeItemId is not null
|
||||
&& state.TradeSourceStationId is not null
|
||||
&& state.TradeDestinationStationId is not null;
|
||||
|
||||
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
|
||||
{
|
||||
state.CurrentObjective = "trade-haul";
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
|
||||
if (ship is null || commander.ActiveBehavior is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.DefaultBehavior.Kind = "trade-haul";
|
||||
ship.DefaultBehavior.ItemId = commander.ActiveBehavior.ItemId;
|
||||
ship.DefaultBehavior.StationId = commander.ActiveBehavior.StationId;
|
||||
ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior.TargetEntityId;
|
||||
ship.DefaultBehavior.Phase ??= "travel-to-source";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SetIdleObjectiveAction : GoapAction<ShipPlanningState>
|
||||
{
|
||||
public override string Name => "set-idle-objective";
|
||||
public override float Cost => 10f;
|
||||
|
||||
public override bool CheckPreconditions(ShipPlanningState state) => true;
|
||||
|
||||
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
|
||||
{
|
||||
state.CurrentObjective = "idle";
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
|
||||
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "idle", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.DefaultBehavior.Kind = "idle";
|
||||
}
|
||||
}
|
||||
38
apps/backend/Ships/Api/EnqueueShipOrderHandler.cs
Normal file
38
apps/backend/Ships/Api/EnqueueShipOrderHandler.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.Ships.Api;
|
||||
|
||||
public sealed class EnqueueShipOrderHandler(WorldService worldService) : Endpoint<ShipOrderCommandRequest, ShipSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/ships/{shipId}/orders");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ShipOrderCommandRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var shipId = Route<string>("shipId");
|
||||
if (string.IsNullOrWhiteSpace(shipId))
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = worldService.EnqueueShipOrder(shipId, request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(cancellation: 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);
|
||||
}
|
||||
}
|
||||
29
apps/backend/Ships/Api/RemoveShipOrderHandler.cs
Normal file
29
apps/backend/Ships/Api/RemoveShipOrderHandler.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.Ships.Api;
|
||||
|
||||
public sealed class RemoveShipOrderRequest
|
||||
{
|
||||
public string ShipId { get; set; } = string.Empty;
|
||||
public string OrderId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class RemoveShipOrderHandler(WorldService worldService) : Endpoint<RemoveShipOrderRequest, ShipSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/ships/{shipId}/orders/{orderId}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(RemoveShipOrderRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var snapshot = worldService.RemoveShipOrder(request.ShipId, request.OrderId);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, 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);
|
||||
}
|
||||
}
|
||||
30
apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs
Normal file
30
apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.Ships.Api;
|
||||
|
||||
public sealed class UpdateShipDefaultBehaviorHandler(WorldService worldService) : Endpoint<ShipDefaultBehaviorCommandRequest, ShipSnapshot>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/ships/{shipId}/default-behavior");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ShipDefaultBehaviorCommandRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var shipId = Route<string>("shipId");
|
||||
if (string.IsNullOrWhiteSpace(shipId))
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = worldService.UpdateShipDefaultBehavior(shipId, request);
|
||||
if (snapshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(snapshot, cancellationToken);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user