Compare commits

...

27 Commits

Author SHA1 Message Date
8503855a4c Refine ship orders and viewer controls 2026-04-09 12:42:52 -04:00
6c92ab50c8 Complete universe model migration 2026-04-07 14:16:59 -04:00
d0c6e30304 Clarify anchor-based mining model 2026-04-07 01:43:13 -04:00
75568324f5 Refine anchor-first universe migration docs 2026-04-07 00:28:38 -04:00
fdcf83ccec Consolidate spatial docs into universe model 2026-04-06 21:29:49 -04:00
74b8bf4116 Add canonical universe model document 2026-04-06 19:11:27 -04:00
c9a4b474b4 Ignore local Codex state and remove worksheet 2026-04-06 17:13:47 -04:00
63a9f808bb Add player onboarding and tactical viewer updates 2026-04-06 17:12:44 -04:00
706e1cda8f Refactor runtime bootstrap and ship control flows 2026-04-03 01:12:26 -04:00
0bb72bee35 Refactor world bootstrap and allow empty startup worlds 2026-03-29 13:22:48 -04:00
640e147ea8 to rename 2026-03-28 11:38:33 -04:00
04d182e93f refactor(backend): align station module production semantics 2026-03-27 16:44:50 -04:00
3237735b08 refactor(backend): simplify cargo kind loading 2026-03-27 15:18:42 -04:00
e8fb033a01 Refactor station modules into typed runtime models 2026-03-27 14:59:15 -04:00
f961ac62b6 chore: convert movement regime kinds to enum 2026-03-27 13:30:10 -04:00
00a1e58184 chore: simplifying world bootstrapping 2026-03-25 12:49:29 -04:00
a5e0037311 chore: add ide files 2026-03-25 03:02:13 -04:00
e87994a2dc chore(editorconfig): prefer var over specific type 2026-03-25 01:45:54 -04:00
e5fa0eb347 chore: normalize line endings 2026-03-24 17:00:53 -04:00
85a055ec91 refactor: replace SpaceLayerKinds with strongly-typed SpaceLayerKind enum
Replaces string-based `SpaceLayerKinds` constants with a strongly-typed `SpaceLayerKind` enum. Updates backend services, runtime models, and projection logic to use the new enum. Adds `ToContractValue` method for compatibility with existing contracts.
2026-03-24 03:03:13 -04:00
766fef1c8f chore: add .editorconfig and consistent formatting for backend projects
Adds an `.editorconfig` file with C# and project-specific conventions. Applies consistent indentation and formatting across backend handlers, runtime models, and AI services.
2026-03-24 02:55:15 -04:00
cfee1306de chore: add project-specific IDE config and ignored files 2026-03-24 02:36:15 -04:00
768d705fb7 fix(sln): wrong project name included 2026-03-23 21:26:02 -04:00
5f41914a59 feat: massive AI generation 2026-03-21 02:22:25 -04:00
6ccc708ae1 feat: massive AI generation 2026-03-21 02:21:05 -04:00
3b56785f9a improvement on gm windows, ai 2026-03-20 12:40:26 -04:00
ff078fe939 Update viewer AI state panels 2026-03-20 02:44:25 -04:00
300 changed files with 209399 additions and 20488 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

2
.gitignore vendored
View File

@@ -17,3 +17,5 @@ pnpm-debug.log*
.env
.env.*
!.env.example
.codex

15
.idea/.idea.SpaceGame/.idea/.gitignore generated vendored Normal file
View 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
View File

@@ -0,0 +1 @@
SpaceGame

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

View 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
View 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
View 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.

View File

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

View 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

View File

@@ -0,0 +1,17 @@
using FastEndpoints;
namespace SpaceGame.Api.Auth.Api;
public sealed class ForgotPasswordHandler(AuthService authService) : Endpoint<ForgotPasswordRequest, ForgotPasswordResponse>
{
public override void Configure()
{
Post("/api/auth/forgot-password");
AllowAnonymous();
}
public override async Task HandleAsync(ForgotPasswordRequest request, CancellationToken cancellationToken)
{
await SendOkAsync(await authService.ForgotPasswordAsync(request, cancellationToken), cancellationToken);
}
}

View File

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

View File

@@ -0,0 +1,25 @@
using FastEndpoints;
namespace SpaceGame.Api.Auth.Api;
public sealed class LoginHandler(AuthService authService) : Endpoint<LoginRequest, AuthSessionResponse>
{
public override void Configure()
{
Post("/api/auth/login");
AllowAnonymous();
}
public override async Task HandleAsync(LoginRequest request, CancellationToken cancellationToken)
{
try
{
await SendOkAsync(await authService.LoginAsync(request, cancellationToken), cancellationToken);
}
catch (InvalidOperationException ex)
{
AddError(ex.Message);
await SendErrorsAsync(cancellation: cancellationToken);
}
}
}

View File

@@ -0,0 +1,25 @@
using FastEndpoints;
namespace SpaceGame.Api.Auth.Api;
public sealed class RefreshTokenHandler(AuthService authService) : Endpoint<RefreshTokenRequest, AuthSessionResponse>
{
public override void Configure()
{
Post("/api/auth/refresh");
AllowAnonymous();
}
public override async Task HandleAsync(RefreshTokenRequest request, CancellationToken cancellationToken)
{
try
{
await SendOkAsync(await authService.RefreshAsync(request, cancellationToken), cancellationToken);
}
catch (InvalidOperationException ex)
{
AddError(ex.Message);
await SendErrorsAsync(cancellation: cancellationToken);
}
}
}

View File

@@ -0,0 +1,25 @@
using FastEndpoints;
namespace SpaceGame.Api.Auth.Api;
public sealed class RegisterHandler(AuthService authService) : Endpoint<RegisterRequest, 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);
}
}
}

View File

@@ -0,0 +1,26 @@
using FastEndpoints;
namespace SpaceGame.Api.Auth.Api;
public sealed class ResetPasswordHandler(AuthService authService) : Endpoint<ResetPasswordRequest>
{
public override void Configure()
{
Post("/api/auth/reset-password");
AllowAnonymous();
}
public override async Task HandleAsync(ResetPasswordRequest request, CancellationToken cancellationToken)
{
try
{
await authService.ResetPasswordAsync(request, cancellationToken);
await SendNoContentAsync(cancellationToken);
}
catch (InvalidOperationException ex)
{
AddError(ex.Message);
await SendErrorsAsync(cancellation: cancellationToken);
}
}
}

View File

@@ -0,0 +1,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);

View File

@@ -0,0 +1,7 @@
namespace SpaceGame.Api.Auth.Contracts;
public sealed record RaceSnapshot(
string Id,
string Name,
string Description,
string Icon);

View File

@@ -0,0 +1,24 @@
namespace SpaceGame.Api.Auth.Runtime;
public sealed record UserAccount(
Guid Id,
string Email,
string PasswordHash,
DateTimeOffset CreatedAtUtc,
IReadOnlyList<string> Roles);
public sealed record RefreshTokenRecord(
Guid Id,
Guid UserId,
string TokenHash,
DateTimeOffset CreatedAtUtc,
DateTimeOffset ExpiresAtUtc,
DateTimeOffset? RevokedAtUtc);
public sealed record PasswordResetTokenRecord(
Guid Id,
Guid UserId,
string TokenHash,
DateTimeOffset CreatedAtUtc,
DateTimeOffset ExpiresAtUtc,
DateTimeOffset? ConsumedAtUtc);

View File

@@ -0,0 +1,14 @@
namespace SpaceGame.Api.Auth.Simulation;
public sealed class AuthOptions
{
public string ConnectionString { get; set; } = string.Empty;
public List<SeedUserOptions> DevSeedUsers { get; set; } = [];
}
public sealed class SeedUserOptions
{
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public List<string> Roles { get; set; } = [];
}

View File

@@ -0,0 +1,13 @@
namespace SpaceGame.Api.Auth.Simulation;
public static class AuthPolicyNames
{
public const string AdminAccess = "AdminAccess";
public const string GmAccess = "GmAccess";
}
public static class AuthRoleNames
{
public const string Gm = "gm";
public const string Admin = "admin";
}

View File

@@ -0,0 +1,41 @@
using Npgsql;
namespace SpaceGame.Api.Auth.Simulation;
public sealed class AuthSchemaInitializer(NpgsqlDataSource dataSource)
{
public async Task EnsureSchemaAsync(CancellationToken cancellationToken)
{
await using var command = dataSource.CreateCommand("""
create table if not exists auth_users (
id uuid primary key,
email text not null unique,
password_hash text not null,
created_at_utc timestamptz not null,
roles text[] not null default '{}'
);
alter table auth_users
add column if not exists roles text[] not null default '{}';
create table if not exists auth_refresh_tokens (
id uuid primary key,
user_id uuid not null references auth_users(id) on delete cascade,
token_hash text not null unique,
created_at_utc timestamptz not null,
expires_at_utc timestamptz not null,
revoked_at_utc timestamptz null
);
create table if not exists auth_password_reset_tokens (
id uuid primary key,
user_id uuid not null references auth_users(id) on delete cascade,
token_hash text not null unique,
created_at_utc timestamptz not null,
expires_at_utc timestamptz not null,
consumed_at_utc timestamptz null
);
""");
await command.ExecuteNonQueryAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,120 @@
namespace SpaceGame.Api.Auth.Simulation;
public sealed class AuthService(
IAuthRepository authRepository,
LocalPasswordHasher passwordHasher,
ITokenService tokenService,
RefreshTokenFactory refreshTokenFactory,
IPasswordResetDelivery passwordResetDelivery)
{
public async Task<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.");
}
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace SpaceGame.Api.Auth.Simulation;
public sealed class DevAuthSeeder(
IHostEnvironment hostEnvironment,
IOptions<AuthOptions> authOptions,
IAuthRepository authRepository,
LocalPasswordHasher passwordHasher)
{
public async Task SeedAsync(CancellationToken cancellationToken)
{
if (!hostEnvironment.IsDevelopment())
{
return;
}
foreach (var seedUser in authOptions.Value.DevSeedUsers)
{
if (string.IsNullOrWhiteSpace(seedUser.Email) || string.IsNullOrWhiteSpace(seedUser.Password))
{
continue;
}
await authRepository.UpsertUserAsync(
seedUser.Email.Trim().ToLowerInvariant(),
passwordHasher.HashPassword(seedUser.Password),
seedUser.Roles,
cancellationToken);
}
}
}

View File

@@ -0,0 +1,7 @@
namespace SpaceGame.Api.Auth.Simulation;
public sealed class DevPasswordResetDelivery : IPasswordResetDelivery
{
public Task<ForgotPasswordResponse> DeliverAsync(UserAccount user, string resetToken, CancellationToken cancellationToken) =>
Task.FromResult(new ForgotPasswordResponse(true, resetToken));
}

View File

@@ -0,0 +1,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;
}
}

View 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);
}

View File

@@ -0,0 +1,6 @@
namespace SpaceGame.Api.Auth.Simulation;
public interface IPasswordResetDelivery
{
Task<ForgotPasswordResponse> DeliverAsync(UserAccount user, string resetToken, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,10 @@
namespace SpaceGame.Api.Auth.Simulation;
public interface IPlayerIdentityResolver
{
Guid? GetCurrentPlayerId();
Guid GetRequiredPlayerId();
Guid? GetEffectivePlayerId();
Guid GetRequiredEffectivePlayerId();
bool CanAccessGm();
}

View File

@@ -0,0 +1,7 @@
namespace SpaceGame.Api.Auth.Simulation;
public interface ITokenService
{
(string Token, DateTimeOffset ExpiresAtUtc) CreateAccessToken(UserAccount user);
(string Token, string TokenHash, DateTimeOffset ExpiresAtUtc) CreateRefreshToken();
}

View File

@@ -0,0 +1,10 @@
namespace SpaceGame.Api.Auth.Simulation;
public sealed class JwtOptions
{
public string Issuer { get; set; } = "space-game";
public string Audience { get; set; } = "space-game-viewer";
public string SigningKey { get; set; } = string.Empty;
public int AccessTokenLifetimeMinutes { get; set; } = 30;
public int RefreshTokenLifetimeDays { get; set; } = 30;
}

View File

@@ -0,0 +1,51 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace SpaceGame.Api.Auth.Simulation;
public sealed class JwtTokenService(
IOptions<JwtOptions> jwtOptions,
RefreshTokenFactory refreshTokenFactory) : ITokenService
{
public (string Token, DateTimeOffset ExpiresAtUtc) CreateAccessToken(UserAccount user)
{
var options = jwtOptions.Value;
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(options.AccessTokenLifetimeMinutes, 5));
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
}.ToList();
foreach (var role in user.Roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
claims.Add(new Claim("role", role));
}
var token = new JwtSecurityToken(
issuer: options.Issuer,
audience: options.Audience,
claims: claims,
notBefore: DateTime.UtcNow,
expires: expiresAtUtc.UtcDateTime,
signingCredentials: credentials);
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAtUtc);
}
public (string Token, string TokenHash, DateTimeOffset ExpiresAtUtc) CreateRefreshToken()
{
var token = refreshTokenFactory.CreateToken();
var tokenHash = refreshTokenFactory.HashToken(token);
var expiresAtUtc = DateTimeOffset.UtcNow.AddDays(Math.Max(jwtOptions.Value.RefreshTokenLifetimeDays, 1));
return (token, tokenHash, expiresAtUtc);
}
}

View File

@@ -0,0 +1,42 @@
using System.Security.Cryptography;
namespace SpaceGame.Api.Auth.Simulation;
public sealed class LocalPasswordHasher
{
private const int SaltSize = 16;
private const int KeySize = 32;
private const int IterationCount = 120_000;
public string HashPassword(string password)
{
ArgumentException.ThrowIfNullOrWhiteSpace(password);
Span<byte> salt = stackalloc byte[SaltSize];
RandomNumberGenerator.Fill(salt);
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, IterationCount, HashAlgorithmName.SHA256, KeySize);
return $"pbkdf2-sha256${IterationCount}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
}
public bool VerifyPassword(string password, string encodedHash)
{
ArgumentException.ThrowIfNullOrWhiteSpace(password);
ArgumentException.ThrowIfNullOrWhiteSpace(encodedHash);
var parts = encodedHash.Split('$', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 4 || !string.Equals(parts[0], "pbkdf2-sha256", StringComparison.Ordinal))
{
return false;
}
if (!int.TryParse(parts[1], out var iterations))
{
return false;
}
var salt = Convert.FromBase64String(parts[2]);
var expected = Convert.FromBase64String(parts[3]);
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
}

View File

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

View File

@@ -0,0 +1,21 @@
using System.Security.Cryptography;
using System.Text;
namespace SpaceGame.Api.Auth.Simulation;
public sealed class RefreshTokenFactory
{
public string CreateToken()
{
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes);
}
public string HashToken(string token)
{
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(token));
return Convert.ToHexString(bytes);
}
}

View File

@@ -1,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; }
}

View File

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

View File

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

View File

@@ -1,224 +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 RefinedMetalsAvailableStock { get; set; }
public float RefinedMetalsUsageRate { get; set; }
public float RefinedMetalsProjectedProductionRate { get; set; }
public float RefinedMetalsProjectedNetRate { get; set; }
public float RefinedMetalsLevelSeconds { get; set; }
public string RefinedMetalsLevel { get; set; } = "unknown";
public float HullpartsAvailableStock { get; set; }
public float HullpartsUsageRate { get; set; }
public float HullpartsProjectedProductionRate { get; set; }
public float HullpartsProjectedNetRate { get; set; }
public float HullpartsLevelSeconds { get; set; }
public string HullpartsLevel { get; set; } = "unknown";
public float ClaytronicsAvailableStock { get; set; }
public float ClaytronicsUsageRate { get; set; }
public float ClaytronicsProjectedProductionRate { get; set; }
public float ClaytronicsProjectedNetRate { get; set; }
public float ClaytronicsLevelSeconds { get; set; }
public string ClaytronicsLevel { get; set; } = "unknown";
public float WaterAvailableStock { get; set; }
public float WaterUsageRate { get; set; }
public float WaterProjectedProductionRate { get; set; }
public float WaterProjectedNetRate { get; set; }
public float WaterLevelSeconds { get; set; }
public string WaterLevel { get; set; } = "unknown";
public bool HasRefinedMetalsProduction => RefinedMetalsProjectedProductionRate > 0.01f;
public bool HasHullpartsProduction => HullpartsProjectedProductionRate > 0.01f;
public bool HasClaytronicsProduction => ClaytronicsProjectedProductionRate > 0.01f;
public bool HasWaterProduction => WaterProjectedProductionRate > 0.01f;
public bool HasWarIndustrySupplyChain =>
IsCommodityOperational(RefinedMetalsProjectedProductionRate, RefinedMetalsProjectedNetRate, RefinedMetalsLevelSeconds, RefinedMetalsLevel, 240f)
&& IsCommodityOperational(HullpartsProjectedProductionRate, HullpartsProjectedNetRate, HullpartsLevelSeconds, HullpartsLevel, 240f)
&& IsCommodityOperational(ClaytronicsProjectedProductionRate, ClaytronicsProjectedNetRate, ClaytronicsLevelSeconds, ClaytronicsLevel, 240f);
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));
}
internal static bool IsCommodityOperational(
float projectedProductionRate,
float projectedNetRate,
float levelSeconds,
string level,
float targetLevelSeconds) =>
projectedProductionRate > 0.01f
&& projectedNetRate >= -0.01f
&& levelSeconds >= targetLevelSeconds
&& (string.Equals(level, "stable", StringComparison.OrdinalIgnoreCase)
|| string.Equals(level, "surplus", StringComparison.OrdinalIgnoreCase));
internal static float ComputeCommodityNeed(
float projectedProductionRate,
float usageRate,
float projectedNetRate,
float levelSeconds,
string level,
float targetLevelSeconds)
{
var levelWeight = level switch
{
"critical" => 140f,
"low" => 80f,
"stable" => 20f,
_ => 0f,
};
var rateDeficit = MathF.Max(0f, usageRate - projectedProductionRate);
var levelDeficit = MathF.Max(0f, targetLevelSeconds - levelSeconds) / MathF.Max(targetLevelSeconds, 1f);
var instability = projectedNetRate < 0f ? MathF.Abs(projectedNetRate) * 80f : 0f;
return levelWeight + (rateDeficit * 140f) + (levelDeficit * 120f) + instability;
}
}
// ─── 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 =
(FactionPlanningState.IsCommodityOperational(state.RefinedMetalsProjectedProductionRate, state.RefinedMetalsProjectedNetRate, state.RefinedMetalsLevelSeconds, state.RefinedMetalsLevel, 240f) ? 0 : 1) +
(FactionPlanningState.IsCommodityOperational(state.HullpartsProjectedProductionRate, state.HullpartsProjectedNetRate, state.HullpartsLevelSeconds, state.HullpartsLevel, 240f) ? 0 : 1) +
(FactionPlanningState.IsCommodityOperational(state.ClaytronicsProjectedProductionRate, state.ClaytronicsProjectedNetRate, state.ClaytronicsLevelSeconds, state.ClaytronicsLevel, 240f) ? 0 : 1) +
(state.HasShipFactory ? 0 : 1);
var supplyNeed =
FactionPlanningState.ComputeCommodityNeed(state.RefinedMetalsProjectedProductionRate, state.RefinedMetalsUsageRate, state.RefinedMetalsProjectedNetRate, state.RefinedMetalsLevelSeconds, state.RefinedMetalsLevel, 240f)
+ FactionPlanningState.ComputeCommodityNeed(state.HullpartsProjectedProductionRate, state.HullpartsUsageRate, state.HullpartsProjectedNetRate, state.HullpartsLevelSeconds, state.HullpartsLevel, 240f)
+ FactionPlanningState.ComputeCommodityNeed(state.ClaytronicsProjectedProductionRate, state.ClaytronicsUsageRate, state.ClaytronicsProjectedNetRate, state.ClaytronicsLevelSeconds, state.ClaytronicsLevel, 240f);
return missingStages <= 0 && supplyNeed <= 0.01f ? 0f : 110f + (missingStages * 22f) + (supplyNeed * 0.18f);
}
}
public sealed class EnsureWaterSecurityGoal : GoapGoal<FactionPlanningState>
{
public override string Name => "ensure-water-security";
public override bool IsSatisfied(FactionPlanningState state) =>
FactionPlanningState.IsCommodityOperational(state.WaterProjectedProductionRate, state.WaterProjectedNetRate, state.WaterLevelSeconds, state.WaterLevel, 300f);
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
if (FactionPlanningState.IsCommodityOperational(state.WaterProjectedProductionRate, state.WaterProjectedNetRate, state.WaterLevelSeconds, state.WaterLevel, 300f))
{
return 0f;
}
return 55f + FactionPlanningState.ComputeCommodityNeed(
state.WaterProjectedProductionRate,
state.WaterUsageRate,
state.WaterProjectedNetRate,
state.WaterLevelSeconds,
state.WaterLevel,
300f) * 0.25f;
}
}
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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +1,76 @@
namespace SpaceGame.Api.Factions.Contracts;
public sealed record FactionPlanningStateSnapshot(
int MilitaryShipCount,
int MinerShipCount,
int TransportShipCount,
int ConstructorShipCount,
int ControlledSystemCount,
int TargetSystemCount,
bool HasShipFactory,
float OreStockpile,
float RefinedMetalsAvailableStock,
float RefinedMetalsUsageRate,
float RefinedMetalsProjectedProductionRate,
float RefinedMetalsProjectedNetRate,
float RefinedMetalsLevelSeconds,
string RefinedMetalsLevel,
float HullpartsAvailableStock,
float HullpartsUsageRate,
float HullpartsProjectedProductionRate,
float HullpartsProjectedNetRate,
float HullpartsLevelSeconds,
string HullpartsLevel,
float ClaytronicsAvailableStock,
float ClaytronicsUsageRate,
float ClaytronicsProjectedProductionRate,
float ClaytronicsProjectedNetRate,
float ClaytronicsLevelSeconds,
string ClaytronicsLevel,
float WaterAvailableStock,
float WaterUsageRate,
float WaterProjectedProductionRate,
float WaterProjectedNetRate,
float WaterLevelSeconds,
string WaterLevel);
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 FactionStrategicPrioritySnapshot(string GoalName, float Priority);
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,
@@ -51,92 +87,185 @@ public sealed record FactionCommoditySignalSnapshot(
float BuyBacklog,
float ReservedForConstruction);
public sealed record FactionThreatSignalSnapshot(
string ScopeId,
string ScopeKind,
int EnemyShipCount,
int EnemyStationCount);
public sealed record FactionBlackboardSnapshot(
public sealed record FactionEconomicAssessmentSnapshot(
int PlanCycle,
DateTimeOffset UpdatedAtUtc,
int TargetWarshipCount,
bool HasWarIndustrySupplyChain,
bool HasShipyard,
bool HasActiveExpansionProject,
string? ActiveExpansionCommodityId,
string? ActiveExpansionModuleId,
string? ActiveExpansionSiteId,
string? ActiveExpansionSystemId,
int EnemyFactionCount,
int EnemyShipCount,
int EnemyStationCount,
int MilitaryShipCount,
int MinerShipCount,
int TransportShipCount,
int ConstructorShipCount,
int ControlledSystemCount,
IReadOnlyList<FactionCommoditySignalSnapshot> CommoditySignals,
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 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,
float Priority,
string? CommodityId,
string? ModuleId,
string? TargetFactionId,
string? TargetSiteId,
string? BlockingReason,
string? Notes,
int LastEvaluatedCycle,
IReadOnlyList<string> DependencyStepIds,
IReadOnlyList<string> RequiredFacts,
IReadOnlyList<string> ProducedFacts,
IReadOnlyList<string> AssignedAssets,
IReadOnlyList<string> IssuedTaskIds);
string? Summary,
string? BlockingReason);
public sealed record FactionIssuedTaskSnapshot(
public sealed record FactionCampaignSnapshot(
string Id,
string Kind,
string State,
string ObjectiveId,
string StepId,
string Status,
float Priority,
string? ShipRole,
string? CommodityId,
string? ModuleId,
string? TheaterId,
string? TargetFactionId,
string? TargetSystemId,
string? TargetSiteId,
int CreatedAtCycle,
int UpdatedAtCycle,
string? BlockingReason,
string? Notes,
IReadOnlyList<string> AssignedAssets);
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 State,
string DelegationKind,
string BehaviorKind,
string Status,
float Priority,
string? ParentObjectiveId,
string? TargetFactionId,
string? CommanderId,
string? HomeSystemId,
string? HomeStationId,
string? TargetSystemId,
string? TargetSiteId,
string? TargetRegionId,
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,
int BudgetWeight,
int SlotCost,
int CreatedAtCycle,
int UpdatedAtCycle,
string? InvalidationReason,
string? BlockingReason,
IReadOnlyList<string> PrerequisiteObjectiveIds,
IReadOnlyList<string> AssignedAssets,
IReadOnlyList<FactionPlanStepSnapshot> Steps);
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,
@@ -149,11 +278,11 @@ public sealed record FactionSnapshot(
int ShipsBuilt,
int ShipsLost,
string? DefaultPolicySetId,
FactionPlanningStateSnapshot? StrategicAssessment,
IReadOnlyList<FactionStrategicPrioritySnapshot>? StrategicPriorities,
FactionBlackboardSnapshot? Blackboard,
IReadOnlyList<FactionObjectiveSnapshot>? Objectives,
IReadOnlyList<FactionIssuedTaskSnapshot>? IssuedTasks);
FactionDoctrineSnapshot Doctrine,
FactionMemorySnapshot Memory,
FactionStrategicStateSnapshot StrategicState,
IReadOnlyList<FactionDecisionLogEntrySnapshot> DecisionLog,
IReadOnlyList<CommanderAssignmentSnapshot> Commanders);
public sealed record FactionDelta(
string Id,
@@ -166,8 +295,8 @@ public sealed record FactionDelta(
int ShipsBuilt,
int ShipsLost,
string? DefaultPolicySetId,
FactionPlanningStateSnapshot? StrategicAssessment,
IReadOnlyList<FactionStrategicPrioritySnapshot>? StrategicPriorities,
FactionBlackboardSnapshot? Blackboard,
IReadOnlyList<FactionObjectiveSnapshot>? Objectives,
IReadOnlyList<FactionIssuedTaskSnapshot>? IssuedTasks);
FactionDoctrineSnapshot Doctrine,
FactionMemorySnapshot Memory,
FactionStrategicStateSnapshot StrategicState,
IReadOnlyList<FactionDecisionLogEntrySnapshot> DecisionLog,
IReadOnlyList<CommanderAssignmentSnapshot> Commanders);

View File

@@ -1,261 +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 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? LastStrategicAssessment { get; set; }
public IReadOnlyList<(string Name, float Priority)>? LastStrategicPriorities { get; set; }
public FactionBlackboardRuntime? FactionBlackboard { get; set; }
public List<FactionObjectiveRuntime> Objectives { get; } = [];
public List<FactionIssuedTaskRuntime> IssuedTasks { get; } = [];
public int PlanningCycle { 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 enum FactionObjectiveKind
public sealed class CommanderAssignmentRuntime
{
DestroyFaction,
BootstrapWarIndustry,
BuildShipyard,
BuildAttackFleet,
EnsureCommoditySupply,
EnsureWaterSecurity,
EnsureMiningCapacity,
EnsureConstructionCapacity,
EnsureTransportCapacity,
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 enum FactionObjectiveState
public sealed class CommanderSkillProfileRuntime
{
Planned,
Active,
Blocked,
Complete,
Failed,
Cancelled,
public int Leadership { get; set; } = 3;
public int Coordination { get; set; } = 3;
public int Strategy { get; set; } = 3;
}
public enum FactionPlanStepKind
public sealed class FactionDoctrineRuntime
{
EnsureCommodityProduction,
EnsureShipyardSite,
ProduceFleet,
AttackFactionAssets,
EnsureWaterSupply,
EnsureMiningCapacity,
EnsureConstructionCapacity,
EnsureTransportCapacity,
MonitorExpansionProject,
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 enum FactionPlanStepStatus
public sealed class FactionMemoryRuntime
{
Planned,
Ready,
Running,
Blocked,
Complete,
Failed,
Cancelled,
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 enum FactionIssuedTaskKind
public sealed class FactionSystemMemoryRuntime
{
ExpandIndustry,
ProduceShips,
AttackFactionAssets,
SustainWarIndustry,
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 enum FactionIssuedTaskState
public sealed class FactionCommodityMemoryRuntime
{
Planned,
Active,
Blocked,
Complete,
Cancelled,
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 FactionObjectiveRuntime
public sealed class FactionOutcomeRecordRuntime
{
public required string Id { get; init; }
public required string MergeKey { get; init; }
public required FactionObjectiveKind Kind { get; init; }
public FactionObjectiveState State { get; set; } = FactionObjectiveState.Planned;
public float Priority { get; set; }
public string? ParentObjectiveId { get; set; }
public string? TargetFactionId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetSiteId { get; set; }
public string? TargetRegionId { get; set; }
public string? CommodityId { get; set; }
public string? ModuleId { get; set; }
public int BudgetWeight { get; set; }
public int SlotCost { get; set; } = 1;
public int CreatedAtCycle { get; init; }
public int UpdatedAtCycle { get; set; }
public string? InvalidationReason { get; set; }
public string? BlockingReason { get; set; }
public HashSet<string> PrerequisiteObjectiveIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> AssignedAssetIds { get; } = new(StringComparer.Ordinal);
public List<FactionPlanStepRuntime> Steps { get; } = [];
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 ObjectiveId { get; init; }
public required FactionPlanStepKind Kind { get; init; }
public FactionPlanStepStatus Status { get; set; } = FactionPlanStepStatus.Planned;
public float Priority { get; set; }
public string? CommodityId { get; set; }
public string? ModuleId { get; set; }
public string? TargetFactionId { get; set; }
public string? TargetSiteId { get; set; }
public string? BlockingReason { get; set; }
public string? Notes { get; set; }
public int LastEvaluatedCycle { get; set; }
public HashSet<string> DependencyStepIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> RequiredFacts { get; } = new(StringComparer.Ordinal);
public HashSet<string> ProducedFacts { get; } = new(StringComparer.Ordinal);
public HashSet<string> AssignedAssetIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> IssuedTaskIds { get; } = new(StringComparer.Ordinal);
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 FactionIssuedTaskRuntime
public sealed class FactionAssetReservationRuntime
{
public required string Id { get; init; }
public required string MergeKey { get; init; }
public required FactionIssuedTaskKind Kind { get; init; }
public required string ObjectiveId { get; init; }
public required string StepId { get; init; }
public FactionIssuedTaskState State { get; set; } = FactionIssuedTaskState.Planned;
public float Priority { get; set; }
public string? ShipRole { get; set; }
public string? CommodityId { get; set; }
public string? ModuleId { get; set; }
public string? TargetFactionId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetSiteId { get; set; }
public int CreatedAtCycle { get; init; }
public int UpdatedAtCycle { get; set; }
public string? BlockingReason { get; set; }
public string? Notes { get; set; }
public HashSet<string> AssignedAssetIds { get; } = new(StringComparer.Ordinal);
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 FactionBlackboardRuntime
public sealed class FactionProductionProgramRuntime
{
public int PlanCycle { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
public int TargetWarshipCount { get; set; }
public bool HasWarIndustrySupplyChain { get; set; }
public bool HasShipyard { get; set; }
public bool HasActiveExpansionProject { get; set; }
public string? ActiveExpansionCommodityId { get; set; }
public string? ActiveExpansionModuleId { get; set; }
public string? ActiveExpansionSiteId { get; set; }
public string? ActiveExpansionSystemId { get; set; }
public int EnemyFactionCount { get; set; }
public int EnemyShipCount { get; set; }
public int EnemyStationCount { 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 List<FactionCommoditySignalRuntime> CommoditySignals { get; } = [];
public List<FactionThreatSignalRuntime> ThreatSignals { get; } = [];
public HashSet<string> AvailableShipIds { get; } = new(StringComparer.Ordinal);
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 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 sealed class CommanderBehaviorRuntime
{
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 sealed class CommanderOrderRuntime
{
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 sealed class CommanderTaskRuntime
{
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 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; }
}

View 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);

View 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;
}

View File

@@ -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}";
}
}

View File

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

View File

@@ -2,53 +2,53 @@ 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
internal static float ComputeNeedScore(FactionCommoditySnapshot commodity, float targetLevelSeconds)
{
CommodityLevelKind.Critical => 140f,
CommodityLevelKind.Low => 80f,
CommodityLevelKind.Stable => 20f,
_ => 0f,
};
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);
return levelWeight
+ (productionDeficit * 140f)
+ (levelDeficit * 120f)
+ backlogPressure;
}
var levelWeight = commodity.Level switch
{
CommodityLevelKind.Critical => 140f,
CommodityLevelKind.Low => 80f,
CommodityLevelKind.Stable => 20f,
_ => 0f,
};
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;
return levelWeight
+ (productionDeficit * 140f)
+ (levelDeficit * 120f)
+ backlogPressure;
}
if (commodity.Level is CommodityLevelKind.Critical)
{
return 0.72f;
}
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;
if (commodity.Level is CommodityLevelKind.Low || commodity.LevelSeconds < targetLevelSeconds)
{
return 0.84f;
}
internal static bool IsStrained(FactionCommoditySnapshot commodity, float targetLevelSeconds) =>
!IsOperational(commodity, targetLevelSeconds)
|| commodity.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low;
return 1f;
}
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;
}
}

View File

@@ -4,202 +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 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 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 OperationalUsageRatePerSecond => MathF.Max(ConsumptionRatePerSecond, BuyBacklog / 180f);
internal float LevelSeconds => AvailableStock <= 0.01f
? 0f
: AvailableStock / MathF.Max(OperationalUsageRatePerSecond, 0.01f);
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);
internal CommodityLevelKind Level =>
LevelSeconds switch
{
<= 60f => CommodityLevelKind.Critical,
<= 180f => CommodityLevelKind.Low,
<= 480f => CommodityLevelKind.Stable,
_ => CommodityLevelKind.Surplus,
};
internal CommodityLevelKind Level =>
LevelSeconds switch
{
<= 60f => CommodityLevelKind.Critical,
<= 180f => CommodityLevelKind.Low,
<= 480f => CommodityLevelKind.Stable,
_ => CommodityLevelKind.Surplus,
};
}
internal enum CommodityLevelKind
{
Critical,
Low,
Stable,
Surplus,
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

View File

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

View File

@@ -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),
};
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View 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);
}
}

View 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);

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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);

View 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);

View 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;
}

View 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();
}

View File

@@ -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);
}

File diff suppressed because it is too large Load Diff

View 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();
}

View File

@@ -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();

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("SpaceGame.Api.Tests")]

View File

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

View File

@@ -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);
}

View File

@@ -0,0 +1,7 @@
namespace SpaceGame.Api.Shared.Contracts;
public sealed record VersionInfoSnapshot(
string Version,
string Environment,
string? CommitSha,
DateTimeOffset StartedAtUtc);

View File

@@ -0,0 +1,29 @@
using System.Reflection;
using Microsoft.Extensions.Hosting;
namespace SpaceGame.Api.Shared.Runtime;
public sealed class AppVersionService
{
private readonly VersionInfoSnapshot _snapshot;
public AppVersionService(IHostEnvironment environment)
{
var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
var informationalVersion = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;
var assemblyVersion = assembly.GetName().Version?.ToString() ?? "0.0.0";
var version = string.IsNullOrWhiteSpace(informationalVersion) ? assemblyVersion : informationalVersion;
var commitSha = Environment.GetEnvironmentVariable("SPACEGAME_COMMIT_SHA")
?? Environment.GetEnvironmentVariable("GIT_COMMIT_SHA");
_snapshot = new VersionInfoSnapshot(
version,
environment.EnvironmentName,
string.IsNullOrWhiteSpace(commitSha) ? null : commitSha,
DateTimeOffset.UtcNow);
}
public VersionInfoSnapshot GetSnapshot() => _snapshot;
}

View File

@@ -0,0 +1,69 @@
namespace SpaceGame.Api.Shared.Runtime;
internal static class KnownShipTypes
{
internal const string Resupplier = "resupplier";
internal const string Miner = "miner";
internal const string Carrier = "carrier";
internal const string Fighter = "fighter";
internal const string HeavyFighter = "heavyfighter";
internal const string Destroyer = "destroyer";
internal const string LargeMiner = "largeminer";
internal const string Freighter = "freighter";
internal const string Bomber = "bomber";
internal const string Scavenger = "scavenger";
internal const string Frigate = "frigate";
internal const string Transporter = "transporter";
internal const string Interceptor = "interceptor";
internal const string Scout = "scout";
internal const string Courier = "courier";
internal const string Builder = "builder";
internal const string Corvette = "corvette";
internal const string Police = "police";
internal const string Battleship = "battleship";
internal const string Gunboat = "gunboat";
internal const string Tug = "tug";
internal const string Compactor = "compactor";
}
internal static class ShipTaxonomyExtensions
{
internal static string ToDataValue(this ShipPurpose purpose) =>
purpose switch
{
ShipPurpose.Auxiliary => "auxiliary",
ShipPurpose.Build => "build",
ShipPurpose.Fight => "fight",
ShipPurpose.Mine => "mine",
ShipPurpose.Trade => "trade",
_ => purpose.ToString(),
};
internal static string ToDataValue(this ShipType type) =>
type switch
{
ShipType.Resupplier => "resupplier",
ShipType.Miner => "miner",
ShipType.Carrier => "carrier",
ShipType.Fighter => "fighter",
ShipType.HeavyFighter => "heavyfighter",
ShipType.Destroyer => "destroyer",
ShipType.LargeMiner => "largeminer",
ShipType.Freighter => "freighter",
ShipType.Bomber => "bomber",
ShipType.Scavenger => "scavenger",
ShipType.Frigate => "frigate",
ShipType.Transporter => "transporter",
ShipType.Interceptor => "interceptor",
ShipType.Scout => "scout",
ShipType.Courier => "courier",
ShipType.Builder => "builder",
ShipType.Corvette => "corvette",
ShipType.Police => "police",
ShipType.Battleship => "battleship",
ShipType.Gunboat => "gunboat",
ShipType.Tug => "tug",
ShipType.Compactor => "compactor",
_ => type.ToString(),
};
}

View File

@@ -0,0 +1,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."),
];
}

View File

@@ -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),
};
}

View File

@@ -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();
}

View File

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

View File

@@ -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);
}

View 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,
};
}

View File

@@ -0,0 +1,54 @@
namespace SpaceGame.Api.Ships.AI;
public sealed partial class ShipAiService
{
private enum SubTaskOutcome
{
Active,
Completed,
Failed,
}
private sealed record TradeRoutePlan(
StationRuntime SourceStation,
StationRuntime DestinationStation,
string ItemId,
float Score,
string Summary);
private sealed record MiningOpportunity(
ResourceNodeRuntime Node,
StationRuntime DropOffStation,
float Score,
string Summary);
private sealed record FleetSupplyPlan(
StationRuntime SourceStation,
ShipRuntime TargetShip,
string ItemId,
float Amount,
float Radius,
string Summary);
private sealed record LocalMiningBuyerCandidate(
StationRuntime Station,
float Score);
private sealed record ThreatTargetCandidate(
string EntityId,
string SystemId,
Vector3 Position,
float Score);
private sealed record PoliceContactCandidate(
string EntityId,
string SystemId,
Vector3 Position,
bool Engage,
float Score);
private sealed record SalvageOpportunity(
WreckRuntime Wreck,
float Score,
string Summary);
}

View File

@@ -0,0 +1,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;
}
}

File diff suppressed because it is too large Load Diff

View 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,
};
}

View 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}");
}
}

View 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);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Ships.AI;
internal static class ShipBootstrapPolicy
{
internal static ShipSkillProfileRuntime CreateSkills(ShipDefinition definition)
{
if (IsTransportShip(definition))
{
return new ShipSkillProfileRuntime { Navigation = 3, Trade = 4, Mining = 1, Combat = 1, Construction = 1 };
}
if (IsConstructionShip(definition))
{
return new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 1, Combat = 1, Construction = 4 };
}
if (IsMilitaryShip(definition))
{
return new ShipSkillProfileRuntime { Navigation = 4, Trade = 1, Mining = 1, Combat = 4, Construction = 1 };
}
if (IsMiningShip(definition))
{
return new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 4, Combat = 1, Construction = 1 };
}
return new ShipSkillProfileRuntime { Navigation = 3, Trade = 2, Mining = 1, Combat = 1, Construction = 1 };
}
}

View File

@@ -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";
}
}

View 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);
}
}
}

View File

@@ -0,0 +1,35 @@
using FastEndpoints;
namespace SpaceGame.Api.Ships.Api;
public sealed class GetShipAutomationCatalogHandler : EndpointWithoutRequest<ShipAutomationCatalogSnapshot>
{
public override void Configure()
{
Get("/api/ships/catalog");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken cancellationToken)
{
var snapshot = new ShipAutomationCatalogSnapshot(
ShipAutomationCatalog.Behaviors
.Select(definition => new ShipBehaviorDefinitionSnapshot(
definition.Id,
definition.Label,
definition.Category,
definition.SupportStatus.ToString(),
definition.Notes))
.ToList(),
ShipAutomationCatalog.Orders
.Select(definition => new ShipOrderDefinitionSnapshot(
definition.Id,
definition.Label,
definition.Category,
definition.SupportStatus.ToString(),
definition.Notes))
.ToList());
await SendOkAsync(snapshot, cancellationToken);
}
}

View File

@@ -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);
}
}

View 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);
}
}

Some files were not shown because too many files have changed in this diff Show More