feat: massive AI generation

This commit is contained in:
2026-03-21 02:21:05 -04:00
parent 3b56785f9a
commit 6ccc708ae1
80 changed files with 16929 additions and 5427 deletions

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

@@ -26,5 +26,9 @@ public sealed class PolicySetRuntime
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,96 +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? StatusReason,
string? ExecutionBindingKind,
string? ExecutionBindingTargetId,
string? ExecutionBindingSummary,
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,
@@ -153,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,
@@ -170,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,4 +1,3 @@
namespace SpaceGame.Api.Factions.Runtime;
public sealed class FactionRuntime
@@ -14,6 +13,10 @@ public sealed class FactionRuntime
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;
}
@@ -26,183 +29,296 @@ public sealed class CommanderRuntime
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 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 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 string LastDeltaSignature { get; set; } = string.Empty;
}
public enum FactionObjectiveKind
public sealed class CommanderAssignmentRuntime
{
DestroyFaction,
BootstrapWarIndustry,
BuildShipyard,
BuildAttackFleet,
EnsureCommoditySupply,
EnsureWaterSecurity,
EnsureMiningCapacity,
EnsureConstructionCapacity,
EnsureTransportCapacity,
}
public enum FactionObjectiveState
{
Planned,
Active,
Blocked,
Complete,
Failed,
Cancelled,
}
public enum FactionPlanStepKind
{
EnsureCommodityProduction,
EnsureShipyardSite,
ProduceFleet,
AttackFactionAssets,
EnsureWaterSupply,
EnsureMiningCapacity,
EnsureConstructionCapacity,
EnsureTransportCapacity,
MonitorExpansionProject,
}
public enum FactionPlanStepStatus
{
Planned,
Ready,
Running,
Blocked,
Complete,
Failed,
Cancelled,
}
public enum FactionIssuedTaskKind
{
ExpandIndustry,
ProduceShips,
AttackFactionAssets,
SustainWarIndustry,
}
public enum FactionIssuedTaskState
{
Planned,
Active,
Blocked,
Complete,
Cancelled,
}
public sealed class FactionObjectiveRuntime
{
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 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? ParentObjectiveId { get; set; }
public string? TargetFactionId { get; set; }
public string? HomeSystemId { get; set; }
public string? HomeStationId { 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 string? TargetEntityId { get; set; }
public Vector3? TargetPosition { get; set; }
public string? ItemId { get; set; }
public string? Notes { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class FactionPlanStepRuntime
public sealed class CommanderSkillProfileRuntime
{
public int Leadership { get; set; } = 3;
public int Coordination { get; set; } = 3;
public int Strategy { get; set; } = 3;
}
public sealed class FactionDoctrineRuntime
{
public string StrategicPosture { get; set; } = "balanced";
public string ExpansionPosture { get; set; } = "measured";
public string MilitaryPosture { get; set; } = "defensive";
public string EconomicPosture { get; set; } = "self-sufficient";
public int DesiredControlledSystems { get; set; } = 3;
public int DesiredMilitaryPerFront { get; set; } = 2;
public int DesiredMinersPerSystem { get; set; } = 1;
public int DesiredTransportsPerSystem { get; set; } = 1;
public int DesiredConstructors { get; set; } = 1;
public float ReserveCreditsRatio { get; set; } = 0.2f;
public float ExpansionBudgetRatio { get; set; } = 0.25f;
public float WarBudgetRatio { get; set; } = 0.35f;
public float ReserveMilitaryRatio { get; set; } = 0.2f;
public float OffensiveReadinessThreshold { get; set; } = 0.62f;
public float SupplySecurityBias { get; set; } = 0.55f;
public float FailureAversion { get; set; } = 0.45f;
public int ReinforcementLeadPerFront { get; set; } = 1;
}
public sealed class FactionMemoryRuntime
{
public int LastPlanCycle { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
public int LastObservedShipsBuilt { get; set; }
public int LastObservedShipsLost { get; set; }
public float LastObservedCredits { get; set; }
public HashSet<string> KnownSystemIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> KnownEnemyFactionIds { get; } = new(StringComparer.Ordinal);
public List<FactionSystemMemoryRuntime> SystemMemories { get; } = [];
public List<FactionCommodityMemoryRuntime> CommodityMemories { get; } = [];
public List<FactionOutcomeRecordRuntime> RecentOutcomes { get; } = [];
}
public sealed class FactionSystemMemoryRuntime
{
public required string SystemId { get; init; }
public DateTimeOffset LastSeenAtUtc { get; set; }
public int LastEnemyShipCount { get; set; }
public int LastEnemyStationCount { get; set; }
public bool ControlledByFaction { get; set; }
public string? LastRole { get; set; }
public float FrontierPressure { get; set; }
public float RouteRisk { get; set; }
public float HistoricalShortagePressure { get; set; }
public int OffensiveFailures { get; set; }
public int DefensiveFailures { get; set; }
public int OffensiveSuccesses { get; set; }
public int DefensiveSuccesses { get; set; }
public DateTimeOffset? LastContestedAtUtc { get; set; }
public DateTimeOffset? LastShortageAtUtc { get; set; }
}
public sealed class FactionCommodityMemoryRuntime
{
public required string ItemId { get; init; }
public float HistoricalShortageScore { get; set; }
public float HistoricalSurplusScore { get; set; }
public float LastObservedBacklog { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
public DateTimeOffset? LastCriticalAtUtc { get; set; }
}
public sealed class FactionOutcomeRecordRuntime
{
public required string Id { get; init; }
public required string 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? StatusReason { get; set; }
public string? ExecutionBindingKind { get; set; }
public string? ExecutionBindingTargetId { get; set; }
public string? ExecutionBindingSummary { 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 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 FactionIssuedTaskRuntime
{
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 sealed class FactionBlackboardRuntime
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 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 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 HashSet<string> AvailableShipIds { get; } = new(StringComparer.Ordinal);
}
public sealed class FactionTheaterRuntime
{
public required string Id { get; init; }
public required string Kind { get; set; }
public required string SystemId { get; set; }
public string Status { get; set; } = "active";
public float Priority { get; set; }
public float SupplyRisk { get; set; }
public float FriendlyAssetValue { get; set; }
public string? TargetFactionId { get; set; }
public string? AnchorEntityId { get; set; }
public Vector3? AnchorPosition { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public List<string> CampaignIds { get; } = [];
}
public sealed class FactionCampaignRuntime
{
public required string Id { get; init; }
public required string Kind { get; set; }
public string Status { get; set; } = "planned";
public float Priority { get; set; }
public string? TheaterId { get; set; }
public string? TargetFactionId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetEntityId { get; set; }
public string? CommodityId { get; set; }
public string? SupportStationId { get; set; }
public int CurrentStepIndex { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public string? Summary { get; set; }
public string? PauseReason { get; set; }
public float ContinuationScore { get; set; }
public float SupplyAdequacy { get; set; }
public float ReplacementPressure { get; set; }
public int FailureCount { get; set; }
public int SuccessCount { get; set; }
public string? FleetCommanderId { get; set; }
public bool RequiresReinforcement { get; set; }
public List<FactionPlanStepRuntime> Steps { get; } = [];
public List<string> ObjectiveIds { get; } = [];
}
public sealed class FactionOperationalObjectiveRuntime
{
public required string Id { get; init; }
public required string CampaignId { get; set; }
public string? TheaterId { get; set; }
public required string Kind { get; set; }
public required string DelegationKind { get; set; }
public required string BehaviorKind { get; set; }
public string Status { get; set; } = "planned";
public float Priority { get; set; }
public string? CommanderId { get; set; }
public string? HomeSystemId { get; set; }
public string? HomeStationId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetEntityId { get; set; }
public Vector3? TargetPosition { get; set; }
public string? ItemId { get; set; }
public string? Notes { get; set; }
public int CurrentStepIndex { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public bool UseOrders { get; set; }
public string? StagingOrderKind { get; set; }
public int ReinforcementLevel { get; set; }
public List<FactionPlanStepRuntime> Steps { get; } = [];
public List<string> ReservedAssetIds { get; } = [];
}
public sealed class FactionPlanStepRuntime
{
public required string Id { get; init; }
public required string Kind { get; set; }
public string Status { get; set; } = "planned";
public string? Summary { get; set; }
public string? BlockingReason { get; set; }
}
public sealed class FactionAssetReservationRuntime
{
public required string Id { get; init; }
public required string ObjectiveId { get; set; }
public string? CampaignId { get; set; }
public required string AssetKind { get; set; }
public required string AssetId { get; set; }
public float Priority { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class FactionProductionProgramRuntime
{
public required string Id { get; init; }
public required string Kind { get; set; }
public string Status { get; set; } = "planned";
public float Priority { get; set; }
public string? CampaignId { get; set; }
public string? CommodityId { get; set; }
public string? ModuleId { get; set; }
public string? ShipKind { get; set; }
public string? TargetSystemId { get; set; }
public int TargetCount { get; set; }
public int CurrentCount { get; set; }
public string? Notes { get; set; }
}
public sealed class FactionDecisionLogEntryRuntime
{
public required string Id { get; init; }
public required string Kind { get; set; }
public required string Summary { get; set; }
public string? RelatedEntityId { get; set; }
public int PlanCycle { get; set; }
public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class FactionCommoditySignalRuntime
@@ -228,38 +344,5 @@ public sealed class FactionThreatSignalRuntime
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 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 CelestialId,
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 CelestialId { 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,923 @@
using System.Globalization;
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,
CelestialId = claim.CelestialId,
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 =>
ship.Definition.Kind switch
{
"military" => 9f,
"construction" => 4f,
"transport" => 3f,
_ when ship.Definition.Kind == "mining" || ship.Definition.Kind == "miner" => 3f,
_ => 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

@@ -4,11 +4,15 @@ 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;

View File

@@ -219,6 +219,11 @@ internal static class FactionIndustryPlanner
return;
}
if (!CanEstablishExpansionSite(world, factionId, project))
{
return;
}
var nowUtc = DateTimeOffset.UtcNow;
var claimId = $"claim-{factionId}-{project.CelestialId}";
if (world.Claims.All(candidate => candidate.Id != claimId))
@@ -303,7 +308,8 @@ internal static class FactionIndustryPlanner
.GroupBy(order => order.ItemId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Sum(order => order.RemainingAmount), StringComparer.Ordinal);
if (CommanderPlanningService.FactionCommanderHasIssuedTask(world, factionId, FactionIssuedTaskKind.ProduceShips, "military"))
var threatAssessment = CommanderPlanningService.FindFactionThreatAssessment(world, factionId);
if (threatAssessment is not null && threatAssessment.EnemyFactionCount > 0)
{
demandByItem["hullparts"] = demandByItem.GetValueOrDefault("hullparts") + 120f;
demandByItem["claytronics"] = demandByItem.GetValueOrDefault("claytronics") + 90f;
@@ -451,7 +457,8 @@ internal static class FactionIndustryPlanner
.Where(celestial =>
celestial.Kind == SpatialNodeKind.LagrangePoint
&& celestial.OccupyingStructureId is null
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed))
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed)
&& IsExpansionSystemEligible(world, factionId, celestial.SystemId))
.OrderByDescending(celestial => ScoreCelestial(world, factionId, celestial, resourceItems))
.FirstOrDefault();
}
@@ -462,7 +469,8 @@ internal static class FactionIndustryPlanner
.Where(celestial =>
celestial.Kind == SpatialNodeKind.LagrangePoint
&& celestial.OccupyingStructureId is null
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed))
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed)
&& IsExpansionSystemEligible(world, factionId, celestial.SystemId))
.OrderByDescending(celestial => world.Stations.Count(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)))
@@ -482,7 +490,80 @@ internal static class FactionIndustryPlanner
var factionPresence = world.Stations.Count(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal));
return resourceScore + (factionPresence * 5_000f);
var controlState = GeopoliticalSimulationService.GetSystemControlState(world, celestial.SystemId);
var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, celestial.SystemId);
var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == celestial.SystemId);
var pressure = world.Geopolitics?.Territory.Pressures
.Where(entry => entry.SystemId == celestial.SystemId && entry.FactionId == factionId)
.OrderByDescending(entry => entry.HostileInfluence)
.ThenBy(entry => entry.Id, StringComparer.Ordinal)
.FirstOrDefault();
var controlBias = string.Equals(controlState?.ControllerFactionId, factionId, StringComparison.Ordinal)
? 12_000f
: string.Equals(controlState?.PrimaryClaimantFactionId, factionId, StringComparison.Ordinal)
? 4_000f
: 0f;
var regionBias = region is null
? 0f
: region.Kind switch
{
"core-industry" => 4_500f,
"shipbuilding" => 3_250f,
"trade-hub" => 2_250f,
"corridor" => 1_500f,
_ => 1_000f,
};
var securityPenalty = ((pressure?.HostileInfluence ?? 0f) * 14f)
+ ((strategicProfile?.TerritorialPressure ?? 0f) * 9f)
+ ((world.Geopolitics is null ? 0f : GeopoliticalSimulationService.GetSystemRouteRisk(world, celestial.SystemId, factionId)) * 250f);
return resourceScore
+ (factionPresence * 5_000f)
+ controlBias
+ regionBias
- securityPenalty;
}
private static bool IsExpansionSystemEligible(SimulationWorld world, string factionId, string systemId)
{
var controlState = GeopoliticalSimulationService.GetSystemControlState(world, systemId);
if (controlState is null)
{
return true;
}
var authorityFactionId = controlState.ControllerFactionId ?? controlState.PrimaryClaimantFactionId;
if (authorityFactionId is null || string.Equals(authorityFactionId, factionId, StringComparison.Ordinal))
{
return true;
}
if (!GeopoliticalSimulationService.HasMilitaryAccess(world, factionId, authorityFactionId))
{
return false;
}
return !GeopoliticalSimulationService.HasHostileRelation(world, factionId, authorityFactionId);
}
private static bool CanEstablishExpansionSite(SimulationWorld world, string factionId, IndustryExpansionProject project)
{
if (!IsExpansionSystemEligible(world, factionId, project.SystemId))
{
return false;
}
var controlState = GeopoliticalSimulationService.GetSystemControlState(world, project.SystemId);
if (controlState?.IsContested == true)
{
return false;
}
var pressure = world.Geopolitics?.Territory.Pressures
.Where(entry => entry.SystemId == project.SystemId && entry.FactionId == factionId)
.OrderByDescending(entry => entry.CorridorRisk + entry.HostileInfluence)
.ThenBy(entry => entry.Id, StringComparer.Ordinal)
.FirstOrDefault();
return pressure is null || (pressure.CorridorRisk < 0.8f && pressure.HostileInfluence < 1.2f);
}
private static StationRuntime? SelectSupportStation(SimulationWorld world, string factionId, string moduleId, string targetSystemId)

View File

@@ -0,0 +1,32 @@
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");
AllowAnonymous();
}
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,29 @@
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}");
AllowAnonymous();
}
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,37 @@
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}");
AllowAnonymous();
}
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,24 @@
using FastEndpoints;
namespace SpaceGame.Api.PlayerFaction.Api;
public sealed class GetPlayerFactionHandler(WorldService worldService) : EndpointWithoutRequest<PlayerFactionSnapshot>
{
public override void Configure()
{
Get("/api/player-faction");
AllowAnonymous();
}
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,40 @@
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");
AllowAnonymous();
}
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,24 @@
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");
AllowAnonymous();
}
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,31 @@
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");
AllowAnonymous();
}
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,26 @@
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}");
AllowAnonymous();
}
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,26 @@
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}");
AllowAnonymous();
}
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,26 @@
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}");
AllowAnonymous();
}
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,26 @@
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}");
AllowAnonymous();
}
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,26 @@
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}");
AllowAnonymous();
}
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,271 @@
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? PreferredNodeId,
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 SovereignFactionId,
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,140 @@
namespace SpaceGame.Api.PlayerFaction.Contracts;
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? PreferredNodeId,
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,306 @@
namespace SpaceGame.Api.PlayerFaction.Runtime;
public sealed class PlayerFactionRuntime
{
public required string Id { get; init; }
public required string Label { get; set; }
public required string SovereignFactionId { get; set; }
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? PreferredNodeId { 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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
using FastEndpoints;
using FastEndpoints.Swagger;
using SpaceGame.Api.Universe.Simulation;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://127.0.0.1:5079");
builder.Services.AddCors((options) =>
{
options.AddDefaultPolicy((policy) =>
@@ -17,6 +17,7 @@ builder.Services.AddCors((options) =>
builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration"));
builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
builder.Services.AddFastEndpoints();
builder.Services.SwaggerDocument();
builder.Services.AddSingleton<WorldService>();
builder.Services.AddSingleton<TelemetryService>();
builder.Services.AddHostedService<SimulationHostedService>();
@@ -25,5 +26,6 @@ var app = builder.Build();
app.UseCors();
app.UseFastEndpoints();
app.UseSwaggerGen();
app.Run();

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

@@ -12,14 +12,47 @@ public enum WorkStatus
{
Pending,
Active,
Blocked,
Completed,
Failed,
Interrupted,
}
public enum OrderStatus
{
Queued,
Accepted,
Active,
Completed,
Cancelled,
Failed,
Interrupted,
}
public enum AiPlanStatus
{
Planned,
Running,
Blocked,
Completed,
Failed,
Interrupted,
}
public enum AiPlanStepStatus
{
Planned,
Running,
Blocked,
Completed,
Failed,
Interrupted,
}
public enum AiPlanSourceKind
{
Rule,
Order,
DefaultBehavior,
}
public enum ShipState
@@ -49,22 +82,8 @@ public enum ShipState
Blocked,
Undocking,
EngagingTarget,
}
public enum ControllerTaskKind
{
Idle,
Travel,
Extract,
Dock,
Load,
Unload,
DeliverConstruction,
BuildConstructionSite,
AttackTarget,
ConstructModule,
Undock,
HoldingPosition,
Fleeing,
}
public static class SpaceLayerKinds
@@ -95,37 +114,39 @@ public static class CommanderKind
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 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 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 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 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 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 Move = "move";
public const string DockAtStation = "dock-at-station";
public const string DeliverCargo = "deliver-cargo";
public const string DockAndWait = "dock-and-wait";
public const string FlyAndWait = "fly-and-wait";
public const string FlyToObject = "fly-to-object";
public const string FollowShip = "follow-ship";
public const string TradeRoute = "trade-route";
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 RepeatOrders = "repeat-orders";
public const string Flee = "flee";
}
public static class ClaimStateKinds
@@ -174,18 +195,54 @@ public static class SimulationEnumMappings
{
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.Accepted => "accepted",
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 AiPlanStepStatus status) => status switch
{
AiPlanStepStatus.Planned => "planned",
AiPlanStepStatus.Running => "running",
AiPlanStepStatus.Blocked => "blocked",
AiPlanStepStatus.Completed => "completed",
AiPlanStepStatus.Failed => "failed",
AiPlanStepStatus.Interrupted => "interrupted",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
public static string ToContractValue(this AiPlanSourceKind kind) => kind switch
{
AiPlanSourceKind.Rule => "rule",
AiPlanSourceKind.Order => "order",
AiPlanSourceKind.DefaultBehavior => "default-behavior",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
};
public static string ToContractValue(this ShipState state) => state switch
{
ShipState.Idle => "idle",
@@ -213,23 +270,8 @@ public static class SimulationEnumMappings
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 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",
ControllerTaskKind.ConstructModule => "construct-module",
ControllerTaskKind.Undock => "undock",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
};
}

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

@@ -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", null, "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

@@ -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,39 @@
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");
AllowAnonymous();
}
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,30 @@
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}");
AllowAnonymous();
}
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 UpdateShipDefaultBehaviorHandler(WorldService worldService) : Endpoint<ShipDefaultBehaviorCommandRequest, ShipSnapshot>
{
public override void Configure()
{
Put("/api/ships/{shipId}/default-behavior");
AllowAnonymous();
}
public override async Task HandleAsync(ShipDefaultBehaviorCommandRequest request, CancellationToken cancellationToken)
{
var shipId = Route<string>("shipId");
if (string.IsNullOrWhiteSpace(shipId))
{
await SendNotFoundAsync(cancellationToken);
return;
}
var snapshot = worldService.UpdateShipDefaultBehavior(shipId, request);
if (snapshot is null)
{
await SendNotFoundAsync(cancellationToken);
return;
}
await SendOkAsync(snapshot, cancellationToken);
}
}

View File

@@ -0,0 +1,55 @@
namespace SpaceGame.Api.Ships.Contracts;
public sealed record ShipOrderCommandRequest(
string Kind,
int Priority,
bool InterruptCurrentPlan,
string? Label,
string? TargetEntityId,
string? TargetSystemId,
Vector3Dto? TargetPosition,
string? SourceStationId,
string? DestinationStationId,
string? ItemId,
string? NodeId,
string? ConstructionSiteId,
string? ModuleId,
float? WaitSeconds,
float? Radius,
int? MaxSystemRange,
bool? KnownStationsOnly);
public sealed record ShipOrderTemplateCommandRequest(
string Kind,
string? Label,
string? TargetEntityId,
string? TargetSystemId,
Vector3Dto? TargetPosition,
string? SourceStationId,
string? DestinationStationId,
string? ItemId,
string? NodeId,
string? ConstructionSiteId,
string? ModuleId,
float? WaitSeconds,
float? Radius,
int? MaxSystemRange,
bool? KnownStationsOnly);
public sealed record ShipDefaultBehaviorCommandRequest(
string Kind,
string? HomeSystemId,
string? HomeStationId,
string? AreaSystemId,
string? TargetEntityId,
string? PreferredItemId,
string? PreferredNodeId,
string? PreferredConstructionSiteId,
string? PreferredModuleId,
Vector3Dto? TargetPosition,
float? WaitSeconds,
float? Radius,
int? MaxSystemRange,
bool? KnownStationsOnly,
IReadOnlyList<Vector3Dto>? PatrolPoints,
IReadOnlyList<ShipOrderTemplateCommandRequest>? RepeatOrders);

View File

@@ -1,5 +1,132 @@
namespace SpaceGame.Api.Ships.Contracts;
public sealed record ShipSkillProfileSnapshot(
int Navigation,
int Trade,
int Mining,
int Combat,
int Construction);
public sealed record ShipOrderSnapshot(
string Id,
string Kind,
string Status,
int Priority,
bool InterruptCurrentPlan,
DateTimeOffset CreatedAtUtc,
string? Label,
string? TargetEntityId,
string? TargetSystemId,
Vector3Dto? TargetPosition,
string? SourceStationId,
string? DestinationStationId,
string? ItemId,
string? NodeId,
string? ConstructionSiteId,
string? ModuleId,
float WaitSeconds,
float Radius,
int? MaxSystemRange,
bool KnownStationsOnly,
string? FailureReason);
public sealed record ShipOrderTemplateSnapshot(
string Kind,
string? Label,
string? TargetEntityId,
string? TargetSystemId,
Vector3Dto? TargetPosition,
string? SourceStationId,
string? DestinationStationId,
string? ItemId,
string? NodeId,
string? ConstructionSiteId,
string? ModuleId,
float WaitSeconds,
float Radius,
int? MaxSystemRange,
bool KnownStationsOnly);
public sealed record DefaultBehaviorSnapshot(
string Kind,
string? HomeSystemId,
string? HomeStationId,
string? AreaSystemId,
string? TargetEntityId,
string? PreferredItemId,
string? PreferredNodeId,
string? PreferredConstructionSiteId,
string? PreferredModuleId,
Vector3Dto? TargetPosition,
float WaitSeconds,
float Radius,
int MaxSystemRange,
bool KnownStationsOnly,
IReadOnlyList<Vector3Dto> PatrolPoints,
int PatrolIndex,
IReadOnlyList<ShipOrderTemplateSnapshot> RepeatOrders,
int RepeatIndex);
public sealed record ShipAssignmentSnapshot(
string CommanderId,
string? ParentCommanderId,
string Kind,
string BehaviorKind,
string Status,
string? ObjectiveId,
string? CampaignId,
string? TheaterId,
float Priority,
string? HomeSystemId,
string? HomeStationId,
string? TargetSystemId,
string? TargetEntityId,
Vector3Dto? TargetPosition,
string? ItemId,
string? Notes,
DateTimeOffset? UpdatedAtUtc);
public sealed record ShipSubTaskSnapshot(
string Id,
string Kind,
string Status,
string Summary,
string? TargetEntityId,
string? TargetSystemId,
string? TargetNodeId,
Vector3Dto? TargetPosition,
string? ItemId,
string? ModuleId,
float Threshold,
float Amount,
float Progress,
float ElapsedSeconds,
float TotalSeconds,
string? BlockingReason);
public sealed record ShipPlanStepSnapshot(
string Id,
string Kind,
string Status,
string Summary,
string? BlockingReason,
int CurrentSubTaskIndex,
IReadOnlyList<ShipSubTaskSnapshot> SubTasks);
public sealed record ShipPlanSnapshot(
string Id,
string SourceKind,
string SourceId,
string Kind,
string Status,
string Summary,
int CurrentStepIndex,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc,
string? InterruptReason,
string? FailureReason,
IReadOnlyList<ShipPlanStepSnapshot> Steps);
public sealed record ShipSnapshot(
string Id,
string Label,
@@ -10,24 +137,29 @@ public sealed record ShipSnapshot(
Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition,
string State,
string? OrderKind,
string DefaultBehaviorKind,
string? BehaviorPhase,
string ControllerTaskKind,
string? CommanderObjective,
IReadOnlyList<ShipOrderSnapshot> OrderQueue,
DefaultBehaviorSnapshot DefaultBehavior,
ShipAssignmentSnapshot? Assignment,
ShipSkillProfileSnapshot Skills,
ShipPlanSnapshot? ActivePlan,
string? CurrentStepId,
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
string ControlSourceKind,
string? ControlSourceId,
string? ControlReason,
string? LastReplanReason,
string? LastAccessFailureReason,
string? CelestialId,
string? DockedStationId,
string? CommanderId,
string? PolicySetId,
float CargoCapacity,
float TravelSpeed,
string TravelSpeedUnit,
IReadOnlyList<InventoryEntry> Inventory,
string FactionId,
float Health,
IReadOnlyList<string> History,
ShipActionProgressSnapshot? CurrentAction,
ShipSpatialStateSnapshot SpatialState);
public sealed record ShipDelta(
@@ -40,30 +172,31 @@ public sealed record ShipDelta(
Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition,
string State,
string? OrderKind,
string DefaultBehaviorKind,
string? BehaviorPhase,
string ControllerTaskKind,
string? CommanderObjective,
IReadOnlyList<ShipOrderSnapshot> OrderQueue,
DefaultBehaviorSnapshot DefaultBehavior,
ShipAssignmentSnapshot? Assignment,
ShipSkillProfileSnapshot Skills,
ShipPlanSnapshot? ActivePlan,
string? CurrentStepId,
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
string ControlSourceKind,
string? ControlSourceId,
string? ControlReason,
string? LastReplanReason,
string? LastAccessFailureReason,
string? CelestialId,
string? DockedStationId,
string? CommanderId,
string? PolicySetId,
float CargoCapacity,
float TravelSpeed,
string TravelSpeedUnit,
IReadOnlyList<InventoryEntry> Inventory,
string FactionId,
float Health,
IReadOnlyList<string> History,
ShipActionProgressSnapshot? CurrentAction,
ShipSpatialStateSnapshot SpatialState);
public sealed record ShipActionProgressSnapshot(
string Label,
float Progress);
public sealed record ShipSpatialStateSnapshot(
string SpaceLayer,
string CurrentSystemId,

View File

@@ -1,4 +1,3 @@
namespace SpaceGame.Api.Ships.Runtime;
public sealed class ShipRuntime
@@ -12,56 +11,147 @@ public sealed class ShipRuntime
public required ShipSpatialStateRuntime SpatialState { get; set; }
public Vector3 Velocity { get; set; } = Vector3.Zero;
public ShipState State { get; set; } = ShipState.Idle;
public ShipOrderRuntime? Order { get; set; }
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
public required ControllerTaskRuntime ControllerTask { get; set; }
public float ActionTimer { get; set; }
public List<ShipOrderRuntime> OrderQueue { get; } = [];
public ShipPlanRuntime? ActivePlan { get; set; }
public required ShipSkillProfileRuntime Skills { get; set; }
public bool NeedsReplan { get; set; } = true;
public float ReplanCooldownSeconds { get; set; }
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public string? DockedStationId { get; set; }
public int? AssignedDockingPadIndex { get; set; }
public string? CommanderId { get; set; }
public string? PolicySetId { get; set; }
public string ControlSourceKind { get; set; } = "unassigned";
public string? ControlSourceId { get; set; }
public string? ControlReason { get; set; }
public string? LastReplanReason { get; set; }
public string? LastAccessFailureReason { get; set; }
public float Health { get; set; }
public string? TrackedActionKey { get; set; }
public float TrackedActionTotal { get; set; }
public HashSet<string> KnownStationIds { get; } = new(StringComparer.Ordinal);
public List<string> History { get; } = [];
public string LastSignature { get; set; } = string.Empty;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ShipSkillProfileRuntime
{
public int Navigation { get; set; }
public int Trade { get; set; }
public int Mining { get; set; }
public int Combat { get; set; }
public int Construction { get; set; }
}
public sealed class ShipOrderRuntime
{
public required string Id { get; init; }
public required string Kind { get; init; }
public OrderStatus Status { get; set; } = OrderStatus.Accepted;
public required string DestinationSystemId { get; init; }
public required Vector3 DestinationPosition { get; init; }
public OrderStatus Status { get; set; } = OrderStatus.Queued;
public int Priority { get; set; }
public bool InterruptCurrentPlan { get; set; } = true;
public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public string? Label { get; set; }
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public Vector3? TargetPosition { get; set; }
public string? SourceStationId { get; set; }
public string? DestinationStationId { get; set; }
public string? ItemId { get; set; }
public string? NodeId { get; set; }
public string? ConstructionSiteId { get; set; }
public string? ModuleId { get; set; }
public float WaitSeconds { get; set; }
public float Radius { get; set; }
public int? MaxSystemRange { get; set; }
public bool KnownStationsOnly { get; set; }
public string? FailureReason { get; set; }
}
public sealed class DefaultBehaviorRuntime
{
public required string Kind { get; set; }
public string? HomeSystemId { get; set; }
public string? HomeStationId { get; set; }
public string? AreaSystemId { get; set; }
public string? TargetEntityId { get; set; }
public string? ItemId { get; set; }
public string? StationId { get; set; }
public string? RefineryId { get; set; }
public string? NodeId { get; set; }
public string? ModuleId { get; set; }
public string? Phase { get; set; }
public string? PreferredItemId { get; set; }
public string? PreferredNodeId { get; set; }
public string? PreferredConstructionSiteId { get; set; }
public string? PreferredModuleId { get; set; }
public Vector3? TargetPosition { get; set; }
public float WaitSeconds { get; set; } = 3f;
public float Radius { get; set; } = 24f;
public int MaxSystemRange { get; set; }
public bool KnownStationsOnly { get; set; }
public List<Vector3> PatrolPoints { get; set; } = [];
public int PatrolIndex { get; set; }
public List<ShipOrderTemplateRuntime> RepeatOrders { get; set; } = [];
public int RepeatIndex { get; set; }
}
public sealed class ControllerTaskRuntime
public sealed class ShipOrderTemplateRuntime
{
public required ControllerTaskKind Kind { get; set; }
public required string Kind { get; init; }
public string? Label { get; set; }
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public Vector3? TargetPosition { get; set; }
public string? SourceStationId { get; set; }
public string? DestinationStationId { get; set; }
public string? ItemId { get; set; }
public string? NodeId { get; set; }
public string? ConstructionSiteId { get; set; }
public string? ModuleId { get; set; }
public float WaitSeconds { get; set; }
public float Radius { get; set; }
public int? MaxSystemRange { get; set; }
public bool KnownStationsOnly { get; set; }
}
public sealed class ShipPlanRuntime
{
public required string Id { get; init; }
public required AiPlanSourceKind SourceKind { get; init; }
public required string SourceId { get; init; }
public required string Kind { get; init; }
public required string Summary { get; set; }
public AiPlanStatus Status { get; set; } = AiPlanStatus.Planned;
public int CurrentStepIndex { get; set; }
public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public string? InterruptReason { get; set; }
public string? FailureReason { get; set; }
public List<ShipPlanStepRuntime> Steps { get; } = [];
}
public sealed class ShipPlanStepRuntime
{
public required string Id { get; init; }
public required string Kind { get; init; }
public required string Summary { get; set; }
public AiPlanStepStatus Status { get; set; } = AiPlanStepStatus.Planned;
public int CurrentSubTaskIndex { get; set; }
public string? BlockingReason { get; set; }
public List<ShipSubTaskRuntime> SubTasks { get; } = [];
}
public sealed class ShipSubTaskRuntime
{
public required string Id { get; init; }
public required string Kind { get; init; }
public required string Summary { get; set; }
public WorkStatus Status { get; set; } = WorkStatus.Pending;
public string? CommanderId { get; set; }
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetNodeId { get; set; }
public Vector3? TargetPosition { get; set; }
public float Threshold { get; set; }
public string? ItemId { get; set; }
public string? ModuleId { get; set; }
public float Threshold { get; set; }
public float Amount { get; set; }
public float ElapsedSeconds { get; set; }
public float TotalSeconds { get; set; }
public float Progress { get; set; }
public string? BlockingReason { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,880 +0,0 @@
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Ships.Simulation;
internal sealed class ShipControlService
{
private static readonly ShipBehaviorStateMachine _shipBehaviorStateMachine = ShipBehaviorStateMachine.CreateDefault();
private static CommanderRuntime? GetShipCommander(SimulationWorld world, ShipRuntime ship) =>
ship.CommanderId is null
? null
: world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId && candidate.Kind == CommanderKind.Ship);
private static void SyncCommanderToShip(ShipRuntime ship, CommanderRuntime commander)
{
if (commander.ActiveBehavior is not null)
{
ship.DefaultBehavior.Kind = commander.ActiveBehavior.Kind;
ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior.AreaSystemId;
ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior.TargetEntityId;
ship.DefaultBehavior.ItemId = commander.ActiveBehavior.ItemId;
ship.DefaultBehavior.ModuleId = commander.ActiveBehavior.ModuleId;
ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId;
ship.DefaultBehavior.Phase = commander.ActiveBehavior.Phase;
ship.DefaultBehavior.PatrolIndex = commander.ActiveBehavior.PatrolIndex;
ship.DefaultBehavior.StationId = commander.ActiveBehavior.StationId;
}
if (commander.ActiveOrder is null)
{
ship.Order = null;
}
else
{
ship.Order = new ShipOrderRuntime
{
Kind = commander.ActiveOrder.Kind,
Status = commander.ActiveOrder.Status,
DestinationSystemId = commander.ActiveOrder.DestinationSystemId,
DestinationPosition = commander.ActiveOrder.DestinationPosition,
};
}
if (commander.ActiveTask is not null)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ParseControllerTaskKind(commander.ActiveTask.Kind),
Status = commander.ActiveTask.Status,
CommanderId = commander.Id,
TargetEntityId = commander.ActiveTask.TargetEntityId,
TargetNodeId = commander.ActiveTask.TargetNodeId,
TargetPosition = commander.ActiveTask.TargetPosition,
TargetSystemId = commander.ActiveTask.TargetSystemId,
Threshold = commander.ActiveTask.Threshold,
};
}
}
private static void SyncShipToCommander(ShipRuntime ship, CommanderRuntime commander)
{
commander.ActiveBehavior ??= new CommanderBehaviorRuntime { Kind = ship.DefaultBehavior.Kind };
commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind;
commander.ActiveBehavior.AreaSystemId = ship.DefaultBehavior.AreaSystemId;
commander.ActiveBehavior.TargetEntityId = ship.DefaultBehavior.TargetEntityId;
commander.ActiveBehavior.ItemId = ship.DefaultBehavior.ItemId;
commander.ActiveBehavior.ModuleId = ship.DefaultBehavior.ModuleId;
commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId;
commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase;
commander.ActiveBehavior.PatrolIndex = ship.DefaultBehavior.PatrolIndex;
commander.ActiveBehavior.StationId = ship.DefaultBehavior.StationId;
if (ship.Order is null)
{
commander.ActiveOrder = null;
}
else
{
commander.ActiveOrder ??= new CommanderOrderRuntime
{
Kind = ship.Order.Kind,
DestinationSystemId = ship.Order.DestinationSystemId,
DestinationPosition = ship.Order.DestinationPosition,
};
commander.ActiveOrder.Status = ship.Order.Status;
commander.ActiveOrder.TargetEntityId = ship.ControllerTask.TargetEntityId;
commander.ActiveOrder.DestinationNodeId = ship.ControllerTask.TargetNodeId ?? ship.SpatialState.DestinationNodeId;
}
commander.ActiveTask ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind.ToContractValue() };
commander.ActiveTask.Kind = ship.ControllerTask.Kind.ToContractValue();
commander.ActiveTask.Status = ship.ControllerTask.Status;
commander.ActiveTask.TargetEntityId = ship.ControllerTask.TargetEntityId;
commander.ActiveTask.TargetNodeId = ship.ControllerTask.TargetNodeId;
commander.ActiveTask.TargetPosition = ship.ControllerTask.TargetPosition;
commander.ActiveTask.TargetSystemId = ship.ControllerTask.TargetSystemId;
commander.ActiveTask.Threshold = ship.ControllerTask.Threshold;
}
internal void RefreshControlLayers(ShipRuntime ship, SimulationWorld world)
{
var commander = GetShipCommander(world, ship);
if (commander is not null)
{
SyncCommanderToShip(ship, commander);
}
if (ship.Order is not null && ship.Order.Status == OrderStatus.Queued)
{
ship.Order.Status = OrderStatus.Accepted;
if (commander?.ActiveOrder is not null)
{
commander.ActiveOrder.Status = ship.Order.Status;
}
}
if (commander is not null)
{
SyncShipToCommander(ship, commander);
}
}
internal void PlanControllerTask(SimulationEngine engine, ShipRuntime ship, SimulationWorld world)
{
var commander = GetShipCommander(world, ship);
if (ship.Order is not null)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
Status = WorkStatus.Active,
CommanderId = commander?.Id,
TargetSystemId = ship.Order.DestinationSystemId,
TargetNodeId = ship.SpatialState.DestinationNodeId,
TargetPosition = ship.Order.DestinationPosition,
Threshold = world.Balance.ArrivalThreshold,
};
SyncCommanderTask(commander, ship.ControllerTask);
return;
}
_shipBehaviorStateMachine.Plan(engine, ship, world);
SyncCommanderTask(commander, ship.ControllerTask);
}
internal void PlanAttackTarget(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var target = ResolveAttackTarget(ship, world);
if (target is null)
{
behavior.Kind = "idle";
behavior.TargetEntityId = null;
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
behavior.TargetEntityId = target.EntityId;
behavior.AreaSystemId = target.SystemId;
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.AttackTarget,
TargetEntityId = target.EntityId,
TargetSystemId = target.SystemId,
TargetPosition = target.Position,
Threshold = target.AttackRange,
};
}
internal void PlanTransportHaul(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var sourceStation = behavior.StationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId);
var destinationStation = behavior.TargetEntityId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId);
if (sourceStation is null || destinationStation is null || string.IsNullOrWhiteSpace(behavior.ItemId))
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
var carryingCargo = GetShipCargoAmount(ship) > 0.01f;
if (carryingCargo)
{
if (ship.DockedStationId == destinationStation.Id)
{
behavior.Phase = "unload";
}
else if (ship.DockedStationId is not null)
{
behavior.Phase = "undock-from-source";
}
else if (behavior.Phase is not "travel-to-destination" and not "dock-destination" and not "unload")
{
behavior.Phase = "travel-to-destination";
}
}
else
{
if (ship.DockedStationId == sourceStation.Id)
{
var available = GetInventoryAmount(sourceStation.Inventory, behavior.ItemId);
behavior.Phase = available > 0.01f ? "load" : "wait-source";
}
else if (ship.DockedStationId == destinationStation.Id)
{
behavior.Phase = "undock-from-destination";
}
else if (behavior.Phase is not "travel-to-source" and not "dock-source" and not "load")
{
behavior.Phase = "travel-to-source";
}
}
ship.ControllerTask = behavior.Phase switch
{
"travel-to-source" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = sourceStation.Position,
Threshold = sourceStation.Radius + 8f,
ItemId = behavior.ItemId,
},
"dock-source" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = sourceStation.Position,
Threshold = sourceStation.Radius + 4f,
ItemId = behavior.ItemId,
},
"load" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Load,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = sourceStation.Position,
Threshold = 0f,
ItemId = behavior.ItemId,
},
"undock-from-source" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = new Vector3(sourceStation.Position.X + world.Balance.UndockDistance, sourceStation.Position.Y, sourceStation.Position.Z),
Threshold = 8f,
ItemId = behavior.ItemId,
},
"travel-to-destination" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = destinationStation.Position,
Threshold = destinationStation.Radius + 8f,
ItemId = behavior.ItemId,
},
"dock-destination" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = destinationStation.Position,
Threshold = destinationStation.Radius + 4f,
ItemId = behavior.ItemId,
},
"unload" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Unload,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = destinationStation.Position,
Threshold = 0f,
ItemId = behavior.ItemId,
},
"undock-from-destination" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = new Vector3(destinationStation.Position.X + world.Balance.UndockDistance, destinationStation.Position.Y, destinationStation.Position.Z),
Threshold = 8f,
ItemId = behavior.ItemId,
},
_ => CreateIdleTask(world.Balance.ArrivalThreshold),
};
}
internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string? resourceItemId, string requiredModule)
{
var behavior = ship.DefaultBehavior;
var cargoItemId = ship.Inventory.Keys.FirstOrDefault();
var targetResourceItemId = SelectMiningResourceItem(world, ship, cargoItemId ?? behavior.ItemId ?? resourceItemId);
if (string.IsNullOrWhiteSpace(targetResourceItemId))
{
behavior.Phase = null;
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
if (!string.Equals(behavior.ItemId, targetResourceItemId, StringComparison.Ordinal))
{
behavior.ItemId = targetResourceItemId;
behavior.NodeId = null;
}
var refinery = SelectBestBuyStation(world, ship, targetResourceItemId, behavior.StationId);
behavior.StationId = refinery?.Id;
var node = behavior.NodeId is null
? world.Nodes
.Where(candidate =>
candidate.ItemId == targetResourceItemId &&
candidate.OreRemaining > 0.01f &&
CanShipMineItem(world, ship, candidate.ItemId))
.OrderByDescending(candidate => candidate.SystemId == behavior.AreaSystemId ? 1 : 0)
.ThenByDescending(candidate => candidate.OreRemaining)
.FirstOrDefault()
: world.Nodes.FirstOrDefault(candidate =>
candidate.Id == behavior.NodeId &&
string.Equals(candidate.ItemId, targetResourceItemId, StringComparison.Ordinal) &&
candidate.OreRemaining > 0.01f);
if (node is not null)
{
behavior.AreaSystemId = node.SystemId;
}
if (refinery is null || node is null || !HasShipCapabilities(ship.Definition, requiredModule))
{
if (refinery is null && GetShipCargoAmount(ship) > 0.01f)
{
ship.Inventory.Clear();
}
behavior.Phase = null;
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
behavior.NodeId ??= node.Id;
if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
&& behavior.Phase is "travel-to-node" or "extract")
{
behavior.Phase = "travel-to-station";
}
if (ship.DockedStationId == refinery.Id)
{
if (GetShipCargoAmount(ship) > 0.01f)
{
behavior.Phase = "unload";
}
else if (behavior.Phase is "dock" or "unload")
{
behavior.Phase = "undock";
}
}
else if (behavior.Phase is not "travel-to-station" and not "dock" and not "travel-to-node" and not "extract")
{
behavior.Phase = "travel-to-station";
}
switch (behavior.Phase)
{
case "extract":
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Extract,
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = extractionPosition,
Threshold = 5f,
};
break;
case "travel-to-station":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = refinery.Radius + 8f,
};
break;
case "dock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = refinery.Radius + 4f,
};
break;
case "unload":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Unload,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = 0f,
};
break;
case "undock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z),
Threshold = 8f,
};
break;
default:
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = node.Position,
Threshold = 18f,
};
behavior.Phase = "travel-to-node";
break;
}
}
private static string? SelectMiningResourceItem(SimulationWorld world, ShipRuntime ship, string? fallbackItemId)
{
var candidateItemId = world.MarketOrders
.Where(order =>
string.Equals(order.FactionId, ship.FactionId, StringComparison.Ordinal)
&& order.Kind == MarketOrderKinds.Buy
&& order.ConstructionSiteId is null
&& order.State != MarketOrderStateKinds.Cancelled
&& order.RemainingAmount > 0.01f)
.Select(order => new
{
ItemId = order.ItemId,
Score = order.RemainingAmount * MathF.Max(0.25f, order.Valuation),
})
.Where(entry => CanShipMineItem(world, ship, entry.ItemId))
.Where(entry => world.Nodes.Any(node => string.Equals(node.ItemId, entry.ItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f))
.GroupBy(entry => entry.ItemId, StringComparer.Ordinal)
.Select(group => new
{
ItemId = group.Key,
Score = group.Sum(entry => entry.Score) + (string.Equals(group.Key, ship.DefaultBehavior.ItemId, StringComparison.Ordinal) ? 15f : 0f),
})
.OrderByDescending(entry => entry.Score)
.Select(entry => entry.ItemId)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(candidateItemId))
{
return candidateItemId;
}
if (!string.IsNullOrWhiteSpace(fallbackItemId)
&& CanShipMineItem(world, ship, fallbackItemId)
&& world.Nodes.Any(node => string.Equals(node.ItemId, fallbackItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f))
{
return fallbackItemId;
}
return world.Nodes
.Where(node => node.OreRemaining > 0.01f && CanShipMineItem(world, ship, node.ItemId))
.OrderByDescending(node => node.OreRemaining)
.Select(node => node.ItemId)
.FirstOrDefault() ?? fallbackItemId;
}
private static bool CanShipMineItem(SimulationWorld world, ShipRuntime ship, string itemId) =>
world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)
&& string.Equals(itemDefinition.CargoKind, ship.Definition.CargoKind, StringComparison.Ordinal)
&& HasShipCapabilities(ship.Definition, "mining");
internal static StationRuntime? SelectBestBuyStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId)
{
var preferred = preferredStationId is null
? null
: world.Stations.FirstOrDefault(station => station.Id == preferredStationId);
var bestOrder = world.MarketOrders
.Where(order =>
order.Kind == MarketOrderKinds.Buy &&
order.ConstructionSiteId is null &&
order.State != MarketOrderStateKinds.Cancelled &&
order.ItemId == itemId &&
order.RemainingAmount > 0.01f)
.Select(order => (Order: order, Station: world.Stations.FirstOrDefault(station => station.Id == order.StationId)))
.Where(entry => entry.Station is not null && string.Equals(entry.Station.FactionId, ship.FactionId, StringComparison.Ordinal))
.Where(entry => CanStationReceiveItem(world, entry.Station!, itemId))
.OrderByDescending(entry =>
{
var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f;
return entry.Order.Valuation - distancePenalty;
})
.FirstOrDefault();
return bestOrder.Station ?? (preferred is not null && CanStationReceiveItem(world, preferred, itemId) ? preferred : null);
}
private static bool CanStationReceiveItem(SimulationWorld world, StationRuntime station, string itemId)
{
if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
{
return false;
}
var requiredModule = GetStorageRequirement(itemDefinition.CargoKind);
return requiredModule is null || station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal);
}
private static ControllerTaskRuntime CreateStationSupportTask(SimulationWorld world, ShipRuntime ship, StationRuntime station, string? phase) =>
phase switch
{
"dock" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"load" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Load,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"unload" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Unload,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"undock" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = new Vector3(station.Position.X + world.Balance.UndockDistance, station.Position.Y, station.Position.Z),
Threshold = 8f,
},
_ => CreateIdleTask(world.Balance.ArrivalThreshold),
};
internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId);
var site = !string.IsNullOrWhiteSpace(behavior.TargetEntityId)
? world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId)
: station is null ? null : GetConstructionSiteForStation(world, station.Id);
if (station is null)
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
if (site is null && !string.IsNullOrWhiteSpace(behavior.TargetEntityId))
{
behavior.TargetEntityId = null;
behavior.ModuleId = null;
site = GetConstructionSiteForStation(world, station.Id);
}
var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world);
behavior.ModuleId = moduleId;
if (moduleId is null)
{
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
if (ship.DockedStationId is not null)
{
var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (dockedStation is not null)
{
dockedStation.DockedShipIds.Remove(ship.Id);
ReleaseDockingPad(dockedStation, ship.Id);
}
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.Position = ResolveConstructionHoldPosition(ship, station, site, world);
ship.TargetPosition = ship.Position;
}
var constructionHoldPosition = ResolveConstructionHoldPosition(ship, station, site, world);
var targetSystemId = site?.SystemId ?? station.SystemId;
var targetCelestialId = site?.CelestialId ?? station.CelestialId;
var isAtTargetCelestial = !string.IsNullOrWhiteSpace(targetCelestialId)
&& string.Equals(ship.SpatialState.CurrentCelestialId, targetCelestialId, StringComparison.Ordinal);
var isAtConstructionHold = ship.SystemId == targetSystemId
&& (ship.Position.DistanceTo(constructionHoldPosition) <= 10f || isAtTargetCelestial);
if (isAtConstructionHold)
{
if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(world, site))
{
behavior.Phase = "deliver-to-site";
}
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site))
{
behavior.Phase = "build-site";
}
else if (site is not null)
{
behavior.Phase = "wait-for-materials";
}
else if (CanStartModuleConstruction(station, world.ModuleRecipes[moduleId]))
{
behavior.Phase = "construct-module";
}
else
{
behavior.Phase = "wait-for-materials";
}
}
else if (behavior.Phase != "travel-to-station")
{
behavior.Phase = "travel-to-station";
}
switch (behavior.Phase)
{
case "construct-module":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.ConstructModule,
TargetEntityId = station.Id,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "deliver-to-site":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.DeliverConstruction,
TargetEntityId = site?.Id,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "build-site":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.BuildConstructionSite,
TargetEntityId = site?.Id,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "wait-for-materials":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Idle,
TargetEntityId = site?.Id ?? station.Id,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 0f,
};
break;
default:
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = site?.Id ?? station.Id,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
behavior.Phase = "travel-to-station";
break;
}
}
internal void AdvanceControlState(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
var commander = GetShipCommander(world, ship);
if (ship.Order is not null && controllerEvent == "arrived")
{
ship.Order = null;
ship.ControllerTask.Kind = ControllerTaskKind.Idle;
if (commander is not null)
{
commander.ActiveOrder = null;
commander.ActiveTask = new CommanderTaskRuntime
{
Kind = ShipTaskKinds.Idle,
Status = WorkStatus.Completed,
TargetSystemId = ship.SystemId,
Threshold = 0f,
};
}
return;
}
_shipBehaviorStateMachine.ApplyEvent(engine, ship, world, controllerEvent);
if (commander is not null)
{
SyncShipToCommander(ship, commander);
if (commander.ActiveTask is not null)
{
commander.ActiveTask.Status = controllerEvent == "none" ? WorkStatus.Active : WorkStatus.Completed;
}
}
}
internal void TrackHistory(ShipRuntime ship, string controllerEvent)
{
var signature = $"{ship.State.ToContractValue()}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind.ToContractValue()}|{ship.ControllerTask.TargetSystemId}|{ship.ControllerTask.TargetEntityId}|{GetShipCargoAmount(ship):0.0}|{controllerEvent}";
if (signature == ship.LastSignature)
{
return;
}
ship.LastSignature = signature;
var target = ship.ControllerTask.TargetEntityId
?? ship.ControllerTask.TargetSystemId
?? "none";
var eventSummary = controllerEvent == "none" ? string.Empty : $" event={controllerEvent}";
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind.ToContractValue()} target={target} cargo={GetShipCargoAmount(ship):0.#}{eventSummary}");
if (ship.History.Count > 18)
{
ship.History.RemoveAt(0);
}
}
internal void EmitShipStateEvents(
ShipRuntime ship,
ShipState previousState,
string previousBehavior,
ControllerTaskKind previousTask,
string controllerEvent,
ICollection<SimulationEventRecord> events)
{
var occurredAtUtc = DateTimeOffset.UtcNow;
if (previousState != ship.State)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState.ToContractValue()} -> {ship.State.ToContractValue()}", occurredAtUtc));
}
if (previousBehavior != ship.DefaultBehavior.Kind)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "behavior-changed", $"{ship.Definition.Label} behavior {previousBehavior} -> {ship.DefaultBehavior.Kind}", occurredAtUtc));
}
if (previousTask != ship.ControllerTask.Kind)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask.ToContractValue()} -> {ship.ControllerTask.Kind.ToContractValue()}", occurredAtUtc));
}
if (controllerEvent != "none")
{
events.Add(new SimulationEventRecord("ship", ship.Id, controllerEvent, $"{ship.Definition.Label} {controllerEvent}", occurredAtUtc));
}
}
internal static ControllerTaskRuntime CreateIdleTask(float threshold) =>
new()
{
Kind = ControllerTaskKind.Idle,
Threshold = threshold,
};
private static ControllerTaskKind ParseControllerTaskKind(string kind) => kind switch
{
"travel" => ControllerTaskKind.Travel,
"extract" => ControllerTaskKind.Extract,
"dock" => ControllerTaskKind.Dock,
"load" => ControllerTaskKind.Load,
"unload" => ControllerTaskKind.Unload,
"deliver-construction" => ControllerTaskKind.DeliverConstruction,
"build-construction-site" => ControllerTaskKind.BuildConstructionSite,
"attack-target" => ControllerTaskKind.AttackTarget,
"construct-module" => ControllerTaskKind.ConstructModule,
"undock" => ControllerTaskKind.Undock,
_ => ControllerTaskKind.Idle,
};
private static void SyncCommanderTask(CommanderRuntime? commander, ControllerTaskRuntime task)
{
if (commander is null)
{
return;
}
commander.ActiveTask = new CommanderTaskRuntime
{
Kind = task.Kind.ToContractValue(),
Status = task.Status,
TargetEntityId = task.TargetEntityId,
TargetNodeId = task.TargetNodeId,
TargetPosition = task.TargetPosition,
TargetSystemId = task.TargetSystemId,
Threshold = task.Threshold,
};
}
private static Vector3 ResolveConstructionHoldPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world)
{
if (site is null || site.StationId is not null)
{
return GetConstructionHoldPosition(station, ship.Id);
}
var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
var anchorPosition = anchor?.Position ?? station.Position;
return GetResourceHoldPosition(anchorPosition, ship.Id, 78f);
}
private static AttackTargetCandidate? ResolveAttackTarget(ShipRuntime ship, SimulationWorld world)
{
if (!string.IsNullOrWhiteSpace(ship.DefaultBehavior.TargetEntityId))
{
var direct = ResolveAttackTargetCandidate(world, ship.DefaultBehavior.TargetEntityId!);
if (direct is not null && !string.Equals(direct.FactionId, ship.FactionId, StringComparison.Ordinal))
{
return direct;
}
}
var hostileShips = world.Ships
.Where(candidate => candidate.Health > 0f && !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
.Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, 26f))
.ToList();
var hostileStations = world.Stations
.Where(candidate => !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
.Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, candidate.Radius + 18f))
.ToList();
var preferredSystemId = ship.DefaultBehavior.AreaSystemId;
return hostileShips
.Concat(hostileStations)
.OrderBy(candidate => preferredSystemId is null || candidate.SystemId == preferredSystemId ? 0 : 1)
.ThenBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1)
.ThenBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
}
private static AttackTargetCandidate? ResolveAttackTargetCandidate(SimulationWorld world, string entityId)
{
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == entityId && candidate.Health > 0f);
if (ship is not null)
{
return new AttackTargetCandidate(ship.Id, ship.FactionId, ship.SystemId, ship.Position, 26f);
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == entityId);
return station is null
? null
: new AttackTargetCandidate(station.Id, station.FactionId, station.SystemId, station.Position, station.Radius + 18f);
}
private sealed record AttackTargetCandidate(string EntityId, string FactionId, string SystemId, Vector3 Position, float AttackRange);
}

View File

@@ -1,592 +0,0 @@
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
namespace SpaceGame.Api.Ships.Simulation;
internal sealed partial class ShipTaskExecutionService
{
private static bool AdvanceTimedAction(ShipRuntime ship, float deltaSeconds, float requiredSeconds)
{
ship.ActionTimer += deltaSeconds;
if (ship.ActionTimer < requiredSeconds)
{
return false;
}
ship.ActionTimer = 0f;
return true;
}
private static void BeginTrackedAction(ShipRuntime ship, string actionKey, float total)
{
if (ship.TrackedActionKey == actionKey)
{
return;
}
ship.TrackedActionKey = actionKey;
ship.TrackedActionTotal = MathF.Max(total, 0.01f);
}
internal static float GetShipCargoAmount(ShipRuntime ship) =>
SimulationRuntimeSupport.GetShipCargoAmount(ship);
private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
var node = world.Nodes.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (node is null || task.TargetPosition is null || !CanExtractNode(ship, node, world))
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var cargoAmount = GetShipCargoAmount(ship);
if (cargoAmount >= ship.Definition.CargoCapacity - 0.01f)
{
ship.ActionTimer = 0f;
ship.State = ShipState.CargoFull;
ship.TargetPosition = ship.Position;
return "cargo-full";
}
ship.TargetPosition = task.TargetPosition.Value;
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance > task.Threshold)
{
ship.ActionTimer = 0f;
ship.State = ShipState.MiningApproach;
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
ship.State = ShipState.Mining;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds))
{
return "none";
}
var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount);
var mined = MathF.Min(world.Balance.MiningRate, remainingCapacity);
mined = MathF.Min(mined, node.OreRemaining);
if (mined <= 0.01f)
{
ship.ActionTimer = 0f;
ship.State = node.OreRemaining <= 0.01f ? ShipState.NodeDepleted : ShipState.CargoFull;
ship.TargetPosition = ship.Position;
return node.OreRemaining <= 0.01f ? "node-depleted" : "cargo-full";
}
AddInventory(ship.Inventory, node.ItemId, mined);
node.OreRemaining -= mined;
node.OreRemaining = MathF.Max(0f, node.OreRemaining);
return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none";
}
private string UpdateDock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station is null || task.TargetPosition is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id);
if (padIndex is null)
{
ship.ActionTimer = 0f;
ship.State = ShipState.AwaitingDock;
ship.TargetPosition = GetDockingHoldPosition(station, ship.Id);
var waitDistance = ship.Position.DistanceTo(ship.TargetPosition);
if (waitDistance > 4f)
{
ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
}
return "none";
}
ship.AssignedDockingPadIndex = padIndex;
var padPosition = GetDockingPadPosition(station, padIndex.Value);
ship.TargetPosition = padPosition;
var distance = ship.Position.DistanceTo(padPosition);
if (distance > 4f)
{
ship.ActionTimer = 0f;
ship.State = ShipState.DockingApproach;
ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
ship.State = ShipState.Docking;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration))
{
return "none";
}
ship.State = ShipState.Docked;
ship.DockedStationId = station.Id;
station.DockedShipIds.Add(ship.Id);
ship.Position = padPosition;
ship.TargetPosition = padPosition;
return "docked";
}
private string UpdateUnload(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null)
{
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Transferring;
BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship));
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId);
var transferredAny = false;
foreach (var (itemId, amount) in ship.Inventory.ToList())
{
var moved = MathF.Min(amount, world.Balance.TransferRate * deltaSeconds);
var accepted = TryAddStationInventory(world, station, itemId, moved);
transferredAny |= accepted > 0.01f;
RemoveInventory(ship.Inventory, itemId, accepted);
if (faction is not null && string.Equals(itemId, "ore", StringComparison.Ordinal))
{
faction.OreMined += accepted;
faction.Credits += accepted * 0.4f;
}
}
if (!transferredAny && GetShipCargoAmount(ship) > 0.01f && HasShipCapabilities(ship.Definition, "mining"))
{
ship.Inventory.Clear();
return "unloaded";
}
return GetShipCargoAmount(ship) <= 0.01f ? "unloaded" : "none";
}
private string UpdateLoadCargo(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null)
{
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Loading;
var itemId = ship.ControllerTask.ItemId;
BeginTrackedAction(ship, "loading", MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)));
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, ship.Definition.CargoCapacity - GetShipCargoAmount(ship));
var moved = itemId is null ? 0f : MathF.Min(transfer, GetInventoryAmount(station.Inventory, itemId));
if (itemId is not null && moved > 0.01f)
{
RemoveInventory(station.Inventory, itemId, moved);
AddInventory(ship.Inventory, itemId, moved);
}
return itemId is null
|| GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
|| GetInventoryAmount(station.Inventory, itemId) <= 0.01f
? "loaded"
: "none";
}
private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var station = ResolveShipSupportStation(ship, world);
if (station is null || ship.DefaultBehavior.ModuleId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
if (!world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe))
{
ship.AssignedDockingPadIndex = null;
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station, null, world);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id))
{
ship.ActionTimer = 0f;
ship.State = ShipState.WaitingMaterials;
ship.TargetPosition = supportPosition;
return "none";
}
if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id)
{
ship.State = ShipState.ConstructionBlocked;
ship.TargetPosition = supportPosition;
return "none";
}
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Constructing;
station.ActiveConstruction.ProgressSeconds += deltaSeconds;
if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds)
{
return "none";
}
AddStationModule(world, station, station.ActiveConstruction.ModuleId);
station.ActiveConstruction = null;
return "module-constructed";
}
private string UpdateDeliverConstruction(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var station = ResolveShipSupportStation(ship, world);
if (station is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
if (station is null || site is null || site.State != ConstructionSiteStateKinds.Active)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station, site, world);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.DeliveringConstruction;
BeginTrackedAction(ship, "delivering-construction", GetRemainingConstructionDelivery(world, site));
if (site.StationId is not null)
{
foreach (var required in site.RequiredItems)
{
var delivered = GetInventoryAmount(site.DeliveredItems, required.Key);
var remaining = MathF.Max(0f, required.Value - delivered);
if (remaining <= 0.01f)
{
continue;
}
var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds);
var available = MathF.Max(0f, GetInventoryAmount(station.Inventory, required.Key) - GetStationReserveFloor(world, station, required.Key));
moved = MathF.Min(moved, available);
if (moved <= 0.01f)
{
continue;
}
RemoveInventory(station.Inventory, required.Key, moved);
AddInventory(site.Inventory, required.Key, moved);
AddInventory(site.DeliveredItems, required.Key, moved);
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
foreach (var required in site.RequiredItems)
{
var delivered = GetInventoryAmount(site.DeliveredItems, required.Key);
var remaining = MathF.Max(0f, required.Value - delivered);
if (remaining <= 0.01f)
{
continue;
}
var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds);
var available = MathF.Max(0f, GetInventoryAmount(station.Inventory, required.Key) - GetStationReserveFloor(world, station, required.Key));
moved = MathF.Min(moved, available);
if (moved <= 0.01f)
{
continue;
}
RemoveInventory(station.Inventory, required.Key, moved);
AddInventory(site.Inventory, required.Key, moved);
AddInventory(site.DeliveredItems, required.Key, moved);
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
private string UpdateBuildConstructionSite(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var station = ResolveShipSupportStation(ship, world);
if (station is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
if (station is null || site is null || site.BlueprintId is null || site.State != ConstructionSiteStateKinds.Active)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station, site, world);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
{
ship.State = ShipState.WaitingMaterials;
ship.TargetPosition = supportPosition;
return "none";
}
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Constructing;
site.AssignedConstructorShipIds.Add(ship.Id);
site.Progress += deltaSeconds;
if (site.Progress < recipe.Duration)
{
return "none";
}
if (site.StationId is null)
{
CompleteStationFoundation(world, station, site);
}
else
{
AddStationModule(world, station, site.BlueprintId);
PrepareNextConstructionSiteStep(world, station, site);
}
return "site-constructed";
}
private StationRuntime? ResolveShipSupportStation(ShipRuntime ship, SimulationWorld world) =>
ship.DockedStationId is not null
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId)
: ship.DefaultBehavior.Kind == "construct-station" && ship.DefaultBehavior.StationId is not null
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId)
: null;
private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world)
{
if (ship.DockedStationId is not null)
{
return GetShipDockedPosition(ship, station);
}
if (site?.StationId is null && site is not null)
{
var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position;
return GetResourceHoldPosition(anchorPosition, ship.Id, 78f);
}
return GetConstructionHoldPosition(station, ship.Id);
}
private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) =>
ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f);
private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
if (ship.DockedStationId is null || task.TargetPosition is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
var undockTarget = station is null
? task.TargetPosition.Value
: GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance);
ship.TargetPosition = undockTarget;
ship.State = ShipState.Undocking;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration))
{
if (station is not null)
{
ship.Position = GetShipDockedPosition(ship, station);
}
return "none";
}
ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance);
if (ship.Position.DistanceTo(undockTarget) > task.Threshold)
{
return "none";
}
if (station is not null)
{
station.DockedShipIds.Remove(ship.Id);
ReleaseDockingPad(station, ship.Id);
}
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
return "undocked";
}
internal static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) =>
site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)));
private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site)
{
var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
if (anchor is null || site.BlueprintId is null)
{
site.State = ConstructionSiteStateKinds.Destroyed;
return;
}
var station = new StationRuntime
{
Id = $"station-{world.Stations.Count + 1}",
SystemId = site.SystemId,
Label = BuildFoundedStationLabel(site.TargetDefinitionId),
Category = "station",
Objective = DetermineFoundationObjective(site.TargetDefinitionId),
Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color,
Position = anchor.Position,
FactionId = site.FactionId,
CelestialId = site.CelestialId,
Health = 600f,
MaxHealth = 600f,
};
foreach (var moduleId in GetFoundationModules(world, site.BlueprintId))
{
AddStationModule(world, station, moduleId);
}
world.Stations.Add(station);
StationLifecycleService.EnsureStationCommander(world, station);
anchor.OccupyingStructureId = station.Id;
site.StationId = station.Id;
PrepareNextConstructionSiteStep(world, station, site);
}
private static IReadOnlyList<string> GetFoundationModules(SimulationWorld world, string primaryModuleId)
{
var modules = new List<string> { "module_arg_dock_m_01_lowtech" };
foreach (var itemId in world.ProductionGraph.OutputsByModuleId.GetValueOrDefault(primaryModuleId, []))
{
if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
{
var storageModule = GetStorageRequirement(itemDefinition.CargoKind);
if (storageModule is not null && !modules.Contains(storageModule, StringComparer.Ordinal))
{
modules.Add(storageModule);
}
else if (storageModule is null && !modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal))
{
modules.Add("module_arg_stor_container_m_01");
}
}
}
if (!modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal))
{
modules.Add("module_arg_stor_container_m_01");
}
if (!string.Equals(primaryModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal))
{
modules.Add("module_gen_prod_energycells_01");
}
modules.Add(primaryModuleId);
return modules.Distinct(StringComparer.Ordinal).ToList();
}
private static string DetermineFoundationObjective(string commodityId) =>
commodityId switch
{
"energycells" => "power",
"water" => "water",
"refinedmetals" => "refinery",
"hullparts" => "hullparts",
"claytronics" => "claytronics",
"shipyard" => "shipyard",
_ => "general",
};
private static string BuildFoundedStationLabel(string commodityId) =>
$"{char.ToUpperInvariant(commodityId[0])}{commodityId[1..]} Foundry";
}

View File

@@ -1,392 +0,0 @@
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Ships.Simulation;
internal sealed partial class ShipTaskExecutionService
{
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 static float GetLocalTravelSpeed(ShipRuntime ship) =>
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed);
private static float GetWarpTravelSpeed(ShipRuntime ship) =>
SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed);
private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) =>
world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, systemId, StringComparison.Ordinal))?.Position
?? Vector3.Zero;
internal string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
return task.Kind switch
{
ControllerTaskKind.Idle => UpdateIdle(ship, world, deltaSeconds),
ControllerTaskKind.Travel => UpdateTravel(ship, world, deltaSeconds),
ControllerTaskKind.Extract => UpdateExtract(ship, world, deltaSeconds),
ControllerTaskKind.Dock => UpdateDock(ship, world, deltaSeconds),
ControllerTaskKind.Load => UpdateLoadCargo(ship, world, deltaSeconds),
ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds),
ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds),
ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds),
ControllerTaskKind.AttackTarget => UpdateAttackTarget(ship, world, deltaSeconds),
ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds),
ControllerTaskKind.Undock => UpdateUndock(ship, world, deltaSeconds),
_ => UpdateIdle(ship, world, deltaSeconds),
};
}
private static string UpdateIdle(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
return UpdateTravel(ship, world, deltaSeconds, task);
}
private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds, ControllerTaskRuntime task)
{
if (task.TargetPosition is null || task.TargetSystemId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
// Resolve live position each frame — entities like stations orbit celestials and move every tick
var targetPosition = ResolveCurrentTargetPosition(world, task);
var targetCelestial = ResolveTravelTargetCelestial(world, task, targetPosition);
var distance = ship.Position.DistanceTo(targetPosition);
ship.TargetPosition = targetPosition;
if (ship.SystemId != task.TargetSystemId)
{
if (!HasShipCapabilities(ship.Definition, "ftl"))
{
ship.State = ShipState.Idle;
return "none";
}
var destinationEntryCelestial = ResolveSystemEntryCelestial(world, task.TargetSystemId);
var destinationEntryPosition = destinationEntryCelestial?.Position ?? Vector3.Zero;
return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, destinationEntryPosition, destinationEntryCelestial);
}
var currentCelestial = ResolveCurrentCelestial(world, ship);
if (targetCelestial is not null && currentCelestial is not null && !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal))
{
if (!HasShipCapabilities(ship.Definition, "warp"))
{
return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold);
}
return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial);
}
if (targetCelestial is not null
&& distance > WarpEngageDistanceKilometers
&& HasShipCapabilities(ship.Definition, "warp"))
{
return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial);
}
return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold);
}
private string UpdateAttackTarget(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
if (string.IsNullOrWhiteSpace(task.TargetEntityId))
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "target-lost";
}
var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId && candidate.Health > 0f);
var hostileStation = hostileShip is null
? world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId)
: null;
if ((hostileShip is not null && string.Equals(hostileShip.FactionId, ship.FactionId, StringComparison.Ordinal))
|| (hostileStation is not null && string.Equals(hostileStation.FactionId, ship.FactionId, StringComparison.Ordinal)))
{
return "target-lost";
}
if (hostileShip is null && hostileStation is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "target-lost";
}
var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId;
var targetPosition = hostileShip?.Position ?? hostileStation!.Position;
var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f;
var attackTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = task.TargetEntityId,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
Threshold = attackRange,
};
if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange)
{
return UpdateTravel(ship, world, deltaSeconds, attackTask);
}
ship.State = ShipState.EngagingTarget;
ship.TargetPosition = targetPosition;
ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f));
var damage = GetShipDamagePerSecond(ship) * deltaSeconds;
if (hostileShip is not null)
{
hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage);
return hostileShip.Health <= 0f ? "target-destroyed" : "none";
}
hostileStation!.Health = MathF.Max(0f, hostileStation.Health - damage * 0.6f);
return hostileStation.Health <= 0f ? "target-destroyed" : "none";
}
private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ControllerTaskRuntime task)
{
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
{
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station is not null)
{
return station.Position;
}
var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (celestial is not null)
{
return celestial.Position;
}
}
return task.TargetPosition!.Value;
}
private static CelestialRuntime? ResolveTravelTargetCelestial(SimulationWorld world, ControllerTaskRuntime task, Vector3 targetPosition)
{
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
{
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station?.CelestialId is not null)
{
return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId);
}
var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (celestial is not null)
{
return celestial;
}
}
return world.Celestials
.Where(candidate => task.TargetSystemId is null || candidate.SystemId == task.TargetSystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(targetPosition))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship)
{
if (ship.SpatialState.CurrentCelestialId is not null)
{
return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId);
}
return world.Celestials
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) =>
world.Celestials.FirstOrDefault(candidate =>
candidate.SystemId == systemId &&
candidate.Kind == SpatialNodeKind.Star);
private string UpdateLocalTravel(
ShipRuntime ship,
SimulationWorld world,
float deltaSeconds,
string targetSystemId,
Vector3 targetPosition,
CelestialRuntime? targetCelestial,
float threshold)
{
var distance = ship.Position.DistanceTo(targetPosition);
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.Transit = null;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
if (distance <= threshold)
{
ship.ActionTimer = 0f;
ship.Position = targetPosition;
ship.TargetPosition = ship.Position;
ship.SystemId = targetSystemId;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
return "arrived";
}
ship.ActionTimer = 0f;
ship.State = ShipState.LocalFlight;
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
private string UpdateWarpTransit(ShipRuntime ship, SimulationWorld world, float deltaSeconds, Vector3 targetPosition, CelestialRuntime targetCelestial)
{
var transit = ship.SpatialState.Transit;
if (transit is null || transit.Regime != MovementRegimeKinds.Warp || transit.DestinationNodeId != targetCelestial.Id)
{
transit = new ShipTransitRuntime
{
Regime = MovementRegimeKinds.Warp,
OriginNodeId = ship.SpatialState.CurrentCelestialId,
DestinationNodeId = targetCelestial.Id,
StartedAtUtc = world.GeneratedAtUtc,
};
ship.SpatialState.Transit = transit;
}
ship.SpatialState.SpaceLayer = SpaceLayerKinds.SystemSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.Warp;
ship.SpatialState.CurrentCelestialId = null;
ship.SpatialState.DestinationNodeId = targetCelestial.Id;
var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
if (ship.State != ShipState.Warping)
{
if (ship.State != ShipState.SpoolingWarp)
{
ship.ActionTimer = 0f;
}
ship.State = ShipState.SpoolingWarp;
if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration))
{
return "none";
}
ship.State = ShipState.Warping;
}
var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null
? ship.Position.DistanceTo(targetPosition)
: (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition)));
ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds);
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
return ship.Position.DistanceTo(targetPosition) <= 18f
? CompleteTransitArrival(ship, targetCelestial.SystemId, targetPosition, targetCelestial)
: "none";
}
private string UpdateFtlTransit(ShipRuntime ship, SimulationWorld world, float deltaSeconds, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial)
{
var destinationNodeId = targetCelestial?.Id;
var transit = ship.SpatialState.Transit;
if (transit is null || transit.Regime != MovementRegimeKinds.FtlTransit || transit.DestinationNodeId != destinationNodeId)
{
transit = new ShipTransitRuntime
{
Regime = MovementRegimeKinds.FtlTransit,
OriginNodeId = ship.SpatialState.CurrentCelestialId,
DestinationNodeId = destinationNodeId,
StartedAtUtc = world.GeneratedAtUtc,
};
ship.SpatialState.Transit = transit;
}
ship.SpatialState.SpaceLayer = SpaceLayerKinds.GalaxySpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.FtlTransit;
ship.SpatialState.CurrentCelestialId = null;
ship.SpatialState.DestinationNodeId = destinationNodeId;
if (ship.State != ShipState.Ftl)
{
if (ship.State != ShipState.SpoolingFtl)
{
ship.ActionTimer = 0f;
}
ship.State = ShipState.SpoolingFtl;
if (!AdvanceTimedAction(ship, deltaSeconds, ship.Definition.SpoolTime))
{
return "none";
}
ship.State = ShipState.Ftl;
}
var originSystemPosition = ResolveSystemGalaxyPosition(world, ship.SystemId);
var destinationSystemPosition = ResolveSystemGalaxyPosition(world, targetSystemId);
var totalDistance = MathF.Max(0.001f, originSystemPosition.DistanceTo(destinationSystemPosition));
transit.Progress = MathF.Min(1f, transit.Progress + ((ship.Definition.FtlSpeed * deltaSeconds) / totalDistance));
return transit.Progress >= 0.999f
? CompleteSystemEntryArrival(ship, targetSystemId, targetPosition, targetCelestial)
: "none";
}
private static string CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial)
{
ship.ActionTimer = 0f;
ship.Position = targetPosition;
ship.TargetPosition = targetPosition;
ship.SystemId = targetSystemId;
ship.SpatialState.Transit = null;
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
return "arrived";
}
private static string CompleteSystemEntryArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial)
{
ship.ActionTimer = 0f;
ship.Position = targetPosition;
ship.TargetPosition = targetPosition;
ship.SystemId = targetSystemId;
ship.SpatialState.Transit = null;
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
return "none";
}
private static float GetShipDamagePerSecond(ShipRuntime ship) =>
ship.Definition.Class switch
{
"frigate" => FrigateDps,
"destroyer" => DestroyerDps,
"cruiser" => CruiserDps,
"capital" => CapitalDps,
_ => 4f,
};
}

View File

@@ -6,11 +6,12 @@ public sealed class SimulationEngine
private readonly OrbitalSimulationOptions _orbitalSimulation;
private readonly OrbitalStateUpdater _orbitalStateUpdater;
private readonly InfrastructureSimulationService _infrastructureSimulation;
private readonly GeopoliticalSimulationService _geopolitics;
private readonly CommanderPlanningService _commanderPlanning;
private readonly PlayerFactionService _playerFaction;
private readonly StationSimulationService _stationSimulation;
private readonly StationLifecycleService _stationLifecycle;
private readonly ShipControlService _shipControl;
private readonly ShipTaskExecutionService _shipTaskExecution;
private readonly ShipAiService _shipAi;
private readonly SimulationProjectionService _projection;
public SimulationEngine(OrbitalSimulationOptions? orbitalSimulation = null)
@@ -18,11 +19,12 @@ public sealed class SimulationEngine
_orbitalSimulation = orbitalSimulation ?? new OrbitalSimulationOptions();
_orbitalStateUpdater = new OrbitalStateUpdater(_orbitalSimulation);
_infrastructureSimulation = new InfrastructureSimulationService();
_geopolitics = new GeopoliticalSimulationService();
_commanderPlanning = new CommanderPlanningService();
_playerFaction = new PlayerFactionService();
_stationSimulation = new StationSimulationService();
_stationLifecycle = new StationLifecycleService(_stationSimulation);
_shipControl = new ShipControlService();
_shipTaskExecution = new ShipTaskExecutionService();
_shipAi = new ShipAiService();
_projection = new SimulationProjectionService(_orbitalSimulation);
}
@@ -31,13 +33,16 @@ public sealed class SimulationEngine
var nowUtc = DateTimeOffset.UtcNow;
var events = new List<SimulationEventRecord>();
var simulationDeltaSeconds = deltaSeconds * MathF.Max(world.Balance.SimulationSpeedMultiplier, 0.01f);
world.GeneratedAtUtc = nowUtc;
world.OrbitalTimeSeconds += simulationDeltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond;
_orbitalStateUpdater.Update(world);
_infrastructureSimulation.UpdateClaims(world, events);
_infrastructureSimulation.UpdateConstructionSites(world, events);
_commanderPlanning.UpdateCommanders(this, world, simulationDeltaSeconds, events);
_geopolitics.Update(world, simulationDeltaSeconds, events);
_commanderPlanning.UpdateCommanders(world, simulationDeltaSeconds, events);
_playerFaction.Update(world, simulationDeltaSeconds, events);
_stationLifecycle.UpdateStations(world, simulationDeltaSeconds, events);
foreach (var ship in world.Ships.ToList())
@@ -48,25 +53,12 @@ public sealed class SimulationEngine
}
var previousPosition = ship.Position;
var previousState = ship.State;
var previousBehavior = ship.DefaultBehavior.Kind;
var previousTask = ship.ControllerTask.Kind;
_shipControl.RefreshControlLayers(ship, world);
_shipControl.PlanControllerTask(this, ship, world);
var controllerEvent = _shipTaskExecution.UpdateControllerTask(ship, world, simulationDeltaSeconds);
_shipControl.AdvanceControlState(this, ship, world, controllerEvent);
_shipAi.UpdateShip(world, ship, simulationDeltaSeconds, events);
ship.Velocity = ship.Position.Subtract(previousPosition).Divide(simulationDeltaSeconds);
_shipControl.TrackHistory(ship, controllerEvent);
_shipControl.EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events);
}
_orbitalStateUpdater.SyncSpatialState(world);
CleanupDestroyedEntities(world, events);
world.GeneratedAtUtc = nowUtc;
return _projection.BuildDelta(world, sequence, events);
}
@@ -76,18 +68,6 @@ public sealed class SimulationEngine
public void PrimeDeltaBaseline(SimulationWorld world) =>
_projection.PrimeDeltaBaseline(world);
internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string? resourceItemId, string requiredModule) =>
_shipControl.PlanResourceHarvest(ship, world, resourceItemId, requiredModule);
internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) =>
_shipControl.PlanStationConstruction(ship, world);
internal void PlanAttackTarget(ShipRuntime ship, SimulationWorld world) =>
_shipControl.PlanAttackTarget(ship, world);
internal void PlanTransportHaul(ShipRuntime ship, SimulationWorld world) =>
_shipControl.PlanTransportHaul(ship, world);
internal static float GetShipCargoAmount(ShipRuntime ship) =>
SimulationRuntimeSupport.GetShipCargoAmount(ship);
@@ -95,6 +75,7 @@ public sealed class SimulationEngine
{
foreach (var ship in world.Ships.Where(candidate => candidate.Health <= 0f).ToList())
{
CreateWreck(world, "ship", ship.Id, ship.SystemId, ship.Position, ship.Definition.CargoCapacity + (ship.Definition.MaxHealth * 0.08f));
world.Ships.Remove(ship);
if (ship.DockedStationId is not null && world.Stations.FirstOrDefault(station => station.Id == ship.DockedStationId) is { } dockedStation)
{
@@ -117,6 +98,7 @@ public sealed class SimulationEngine
foreach (var station in world.Stations.Where(candidate => candidate.Health <= 0f).ToList())
{
CreateWreck(world, "station", station.Id, station.SystemId, station.Position, station.MaxHealth * 0.12f);
world.Stations.Remove(station);
if (station.CelestialId is not null && world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId) is { } celestial)
@@ -138,4 +120,29 @@ public sealed class SimulationEngine
events.Add(new SimulationEventRecord("station", station.Id, "destroyed", $"{station.Label} was destroyed.", DateTimeOffset.UtcNow));
}
}
private static void CreateWreck(SimulationWorld world, string sourceKind, string sourceEntityId, string systemId, Vector3 position, float amount)
{
var itemId = world.ItemDefinitions.ContainsKey("scrapmetal")
? "scrapmetal"
: world.ItemDefinitions.ContainsKey("rawscrap")
? "rawscrap"
: world.ItemDefinitions.Keys.OrderBy(id => id, StringComparer.Ordinal).FirstOrDefault();
if (itemId is null || amount <= 0.01f)
{
return;
}
world.Wrecks.Add(new WreckRuntime
{
Id = $"wreck-{sourceKind}-{sourceEntityId}",
SourceKind = sourceKind,
SourceEntityId = sourceEntityId,
SystemId = systemId,
Position = position,
ItemId = itemId,
RemainingAmount = amount,
MaxAmount = amount,
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="FastEndpoints" Version="6.*" />
<PackageReference Include="FastEndpoints.Swagger" Version="6.*" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,3 @@
using static SpaceGame.Api.Ships.Simulation.ShipControlService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Stations.Simulation;
@@ -80,7 +79,7 @@ internal sealed class StationLifecycleService
TargetPosition = spawnPosition,
SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition),
DefaultBehavior = CreateSpawnedShipBehavior(definition, station),
ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold),
Skills = WorldSeedingService.CreateSkills(definition),
Health = definition.MaxHealth,
};
@@ -109,13 +108,22 @@ internal sealed class StationLifecycleService
{
if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal))
{
return new DefaultBehaviorRuntime { Kind = "idle" };
return new DefaultBehaviorRuntime
{
Kind = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? "advanced-auto-trade" : "idle",
HomeSystemId = station.SystemId,
HomeStationId = station.Id,
MaxSystemRange = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? 2 : 0,
};
}
var patrolRadius = station.Radius + 90f;
return new DefaultBehaviorRuntime
{
Kind = "patrol",
HomeSystemId = station.SystemId,
HomeStationId = station.Id,
AreaSystemId = station.SystemId,
PatrolPoints =
[
new Vector3(station.Position.X + patrolRadius, station.Position.Y, station.Position.Z),
@@ -150,7 +158,13 @@ internal sealed class StationLifecycleService
ParentCommanderId = factionCommander.Id,
ControlledEntityId = station.Id,
PolicySetId = factionCommander.PolicySetId,
Doctrine = "station-default",
Doctrine = "station-control",
Skills = new CommanderSkillProfileRuntime
{
Leadership = 3,
Coordination = Math.Clamp(3 + (station.Modules.Count / 8), 3, 5),
Strategy = 3,
},
};
station.CommanderId = commander.Id;
@@ -179,25 +193,12 @@ internal sealed class StationLifecycleService
ParentCommanderId = factionCommander.Id,
ControlledEntityId = ship.Id,
PolicySetId = factionCommander.PolicySetId,
Doctrine = "ship-default",
ActiveBehavior = new CommanderBehaviorRuntime
Doctrine = "ship-control",
Skills = new CommanderSkillProfileRuntime
{
Kind = ship.DefaultBehavior.Kind,
AreaSystemId = ship.DefaultBehavior.AreaSystemId,
TargetEntityId = ship.DefaultBehavior.TargetEntityId,
ItemId = ship.DefaultBehavior.ItemId,
StationId = ship.DefaultBehavior.StationId,
ModuleId = ship.DefaultBehavior.ModuleId,
NodeId = ship.DefaultBehavior.NodeId,
Phase = ship.DefaultBehavior.Phase,
PatrolIndex = ship.DefaultBehavior.PatrolIndex,
},
ActiveTask = new CommanderTaskRuntime
{
Kind = ShipTaskKinds.Idle,
Status = WorkStatus.Pending,
TargetSystemId = ship.SystemId,
Threshold = 0f,
Leadership = Math.Clamp((ship.Skills.Navigation + ship.Skills.Combat + 1) / 2, 2, 5),
Coordination = Math.Clamp((ship.Skills.Trade + ship.Skills.Mining + 1) / 2, 2, 5),
Strategy = Math.Clamp((ship.Skills.Combat + ship.Skills.Construction + 1) / 2, 2, 5),
},
};

View File

@@ -62,7 +62,7 @@ internal sealed class StationSimulationService
var superfluidCoolantReserve = role == "superfluidcoolant" ? 120f : 0f;
var quantumTubesReserve = role == "quantumtubes" ? 120f : 0f;
var shipPartsReserve = HasStationModules(station, "module_gen_build_l_01")
&& FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, "military")
&& GetShipProductionPressure(world, station.FactionId, "military") > 0.2f
? 90f
: 0f;
@@ -104,6 +104,7 @@ internal sealed class StationSimulationService
AddSupplyOrder(desiredOrders, station, "superfluidcoolant", ScaleSupplyTriggerByEconomy(economy, "superfluidcoolant", MathF.Max(superfluidCoolantReserve * 1.35f, superfluidCoolantReserve + 30f)), reserveFloor: superfluidCoolantReserve, valuationBase: ScaleSupplyValuation(economy, "superfluidcoolant", 0.9f));
AddSupplyOrder(desiredOrders, station, "quantumtubes", ScaleSupplyTriggerByEconomy(economy, "quantumtubes", MathF.Max(quantumTubesReserve * 1.35f, quantumTubesReserve + 30f)), reserveFloor: quantumTubesReserve, valuationBase: ScaleSupplyValuation(economy, "quantumtubes", 0.9f));
desiredOrders = ApplyRegionalMarketModifiers(world, station, desiredOrders);
ReconcileStationMarketOrders(world, station, desiredOrders);
}
@@ -116,7 +117,7 @@ internal sealed class StationSimulationService
var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics");
var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals");
var shipPartsReserve = HasStationModules(station, "module_gen_build_l_01")
&& FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, "military")
&& GetShipProductionPressure(world, station.FactionId, "military") > 0.2f
? 90f
: 0f;
@@ -257,8 +258,9 @@ internal sealed class StationSimulationService
var priority = (float)recipe.Priority;
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
var fleetPressure = FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, "military") ? 1f : 0f;
var fleetPressure = GetShipProductionPressure(world, station.FactionId, "military");
priority += GetStationRecipePriorityAdjustment(world, station, recipe, expansionPressure, fleetPressure);
priority += GetStrategicRecipeBias(world, station, recipe);
return priority;
}
@@ -321,6 +323,52 @@ internal sealed class StationSimulationService
};
}
private static float GetStrategicRecipeBias(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
{
var commander = station.CommanderId is null
? null
: world.Commanders.FirstOrDefault(candidate => candidate.Id == station.CommanderId);
var assignment = commander?.Assignment;
if (assignment is null)
{
return 0f;
}
var outputItemIds = recipe.Outputs
.Select(output => output.ItemId)
.ToHashSet(StringComparer.Ordinal);
if (string.Equals(assignment.Kind, "ship-production-focus", StringComparison.Ordinal)
&& recipe.ShipOutputId is not null
&& world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)
&& string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal))
{
return 260f;
}
if (string.Equals(assignment.Kind, "commodity-focus", StringComparison.Ordinal)
&& assignment.ItemId is not null
&& outputItemIds.Contains(assignment.ItemId))
{
return 220f;
}
if (string.Equals(assignment.Kind, "expansion-support", StringComparison.Ordinal)
&& outputItemIds.Overlaps(["energycells", "refinedmetals", "hullparts", "claytronics"]))
{
return 180f;
}
if (string.Equals(assignment.Kind, "station-oversight", StringComparison.Ordinal)
&& assignment.ItemId is not null
&& outputItemIds.Contains(assignment.ItemId))
{
return 90f;
}
return 0f;
}
internal static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
{
var categoryMatch = string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal)
@@ -338,7 +386,7 @@ internal sealed class StationSimulationService
return false;
}
if (!FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, shipDefinition.Kind))
if (GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind) <= 0.05f)
{
return false;
}
@@ -559,12 +607,20 @@ internal sealed class StationSimulationService
var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count));
var controlledSystems = GetFactionControlledSystemsCount(world, factionId);
var deficit = Math.Max(0, targetSystems - controlledSystems);
return Math.Clamp(deficit / (float)targetSystems, 0f, 1f);
var contestedSystems = world.Geopolitics?.Territory.ControlStates.Count(state =>
state.IsContested
&& (string.Equals(state.ControllerFactionId, factionId, StringComparison.Ordinal)
|| string.Equals(state.PrimaryClaimantFactionId, factionId, StringComparison.Ordinal)
|| state.ClaimantFactionIds.Contains(factionId, StringComparer.Ordinal))) ?? 0;
var frontierSystems = world.Geopolitics?.Territory.Zones.Count(zone =>
string.Equals(zone.FactionId, factionId, StringComparison.Ordinal)
&& zone.Kind is "frontier" or "corridor" or "contested") ?? 0;
return Math.Clamp((deficit / (float)targetSystems) + (contestedSystems * 0.12f) + (frontierSystems * 0.04f), 0f, 1f);
}
internal static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId)
{
return world.Systems.Count(system => FactionControlsSystem(world, factionId, system.Definition.Id));
return GeopoliticalSimulationService.GetControlledSystems(world, factionId).Count;
}
private static float ScaleReserveByEconomy(FactionEconomySnapshot economy, string itemId, float baseReserve)
@@ -612,34 +668,66 @@ internal sealed class StationSimulationService
: baseValuation;
}
private static List<DesiredMarketOrder> ApplyRegionalMarketModifiers(SimulationWorld world, StationRuntime station, IReadOnlyCollection<DesiredMarketOrder> desiredOrders)
{
var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, station.FactionId, station.SystemId);
if (region is null)
{
return desiredOrders.ToList();
}
var security = world.Geopolitics?.EconomyRegions.SecurityAssessments.FirstOrDefault(assessment => string.Equals(assessment.RegionId, region.Id, StringComparison.Ordinal));
var economic = world.Geopolitics?.EconomyRegions.EconomicAssessments.FirstOrDefault(assessment => string.Equals(assessment.RegionId, region.Id, StringComparison.Ordinal));
var bottlenecks = world.Geopolitics?.EconomyRegions.Bottlenecks
.Where(bottleneck => string.Equals(bottleneck.RegionId, region.Id, StringComparison.Ordinal))
.ToDictionary(bottleneck => bottleneck.ItemId, StringComparer.Ordinal) ?? new Dictionary<string, RegionalBottleneckRuntime>(StringComparer.Ordinal);
var riskMultiplier = 1f + ((security?.SupplyRisk ?? 0f) * 0.3f) + ((security?.AccessFriction ?? 0f) * 0.2f);
var sustainmentFloor = 1f + MathF.Max(0f, 0.55f - (economic?.SustainmentScore ?? 1f));
return desiredOrders
.Select(order =>
{
bottlenecks.TryGetValue(order.ItemId, out var bottleneck);
var severity = bottleneck?.Severity ?? 0f;
var buyBias = order.Kind == MarketOrderKinds.Buy ? 1f + (severity * 0.08f) : 1f;
var sellBias = order.Kind == MarketOrderKinds.Sell && severity > 0f ? MathF.Max(0.35f, 1f - (severity * 0.07f)) : 1f;
var amount = order.Amount * (order.Kind == MarketOrderKinds.Buy ? riskMultiplier * buyBias * sustainmentFloor : sellBias);
var valuation = order.Valuation * (order.Kind == MarketOrderKinds.Buy
? 1f + (severity * 0.06f) + ((security?.SupplyRisk ?? 0f) * 0.18f)
: 1f + (severity * 0.04f));
float? reserveThreshold = order.ReserveThreshold.HasValue
? order.ReserveThreshold.Value * (1f + ((security?.SupplyRisk ?? 0f) * 0.15f))
: null;
return new DesiredMarketOrder(order.Kind, order.ItemId, amount, valuation, reserveThreshold);
})
.ToList();
}
private static float GetShipProductionPressure(SimulationWorld world, string factionId, string shipKind)
{
var factionCommander = FindFactionCommander(world, factionId);
var task = GetHighestPriorityIssuedTask(factionCommander, FactionIssuedTaskKind.ProduceShips, shipKind);
if (task is null)
var economic = FindFactionEconomicAssessment(world, factionId);
var threat = FindFactionThreatAssessment(world, factionId);
if (economic is null || threat is null)
{
return 0f;
}
return task.State == FactionIssuedTaskState.Blocked ? 0.4f : 1f;
return shipKind switch
{
"military" => threat.EnemyFactionCount > 0
? economic.MilitaryShipCount < Math.Max(4, economic.ControlledSystemCount * 2) ? 1f : 0.25f
: 0.1f,
"construction" => economic.PrimaryExpansionSiteId is not null
? economic.ConstructorShipCount < 1 ? 1f : 0.35f
: economic.ConstructorShipCount < 1 ? 0.5f : 0f,
"transport" => economic.TransportShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.8f : 0.2f,
_ when shipKind == "mining" || shipKind == "miner" => economic.MinerShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.85f : 0.2f,
_ => 0.15f,
};
}
private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)
{
var totalLagrangePoints = world.Celestials.Count(node =>
node.SystemId == systemId &&
node.Kind == SpatialNodeKind.LagrangePoint);
if (totalLagrangePoints == 0)
{
return false;
}
var ownedLocations = world.Claims.Count(claim =>
claim.SystemId == systemId &&
claim.FactionId == factionId &&
claim.State is ClaimStateKinds.Activating or ClaimStateKinds.Active);
return ownedLocations > (totalLagrangePoints / 2f);
}
=> GeopoliticalSimulationService.FactionControlsSystem(world, factionId, systemId);
private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold);
}

View File

@@ -17,7 +17,9 @@ public sealed record WorldSnapshot(
IReadOnlyList<MarketOrderSnapshot> MarketOrders,
IReadOnlyList<PolicySetSnapshot> Policies,
IReadOnlyList<ShipSnapshot> Ships,
IReadOnlyList<FactionSnapshot> Factions);
IReadOnlyList<FactionSnapshot> Factions,
PlayerFactionSnapshot? PlayerFaction,
GeopoliticalStateSnapshot? Geopolitics);
public sealed record WorldDelta(
long Sequence,
@@ -36,6 +38,8 @@ public sealed record WorldDelta(
IReadOnlyList<PolicySetDelta> Policies,
IReadOnlyList<ShipDelta> Ships,
IReadOnlyList<FactionDelta> Factions,
PlayerFactionSnapshot? PlayerFaction,
GeopoliticalStateSnapshot? Geopolitics,
ObserverScope? Scope = null);
public sealed record SimulationEventRecord(

View File

@@ -9,9 +9,12 @@ public sealed class SimulationWorld
public required List<SystemRuntime> Systems { get; init; }
public required List<ResourceNodeRuntime> Nodes { get; init; }
public required List<CelestialRuntime> Celestials { get; init; }
public required List<WreckRuntime> Wrecks { get; init; }
public required List<StationRuntime> Stations { get; init; }
public required List<ShipRuntime> Ships { get; init; }
public required List<FactionRuntime> Factions { get; init; }
public PlayerFactionRuntime? PlayerFaction { get; set; }
public GeopoliticalStateRuntime? Geopolitics { get; set; }
public required List<CommanderRuntime> Commanders { get; init; }
public required List<ClaimRuntime> Claims { get; init; }
public required List<ConstructionSiteRuntime> ConstructionSites { get; init; }

View File

@@ -36,6 +36,18 @@ public sealed class CelestialRuntime
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class WreckRuntime
{
public required string Id { get; init; }
public required string SourceKind { get; init; }
public required string SourceEntityId { get; init; }
public required string SystemId { get; set; }
public required Vector3 Position { get; set; }
public required string ItemId { get; set; }
public float RemainingAmount { get; set; }
public float MaxAmount { get; init; }
}
public sealed class ShipSpatialStateRuntime
{
public string SpaceLayer { get; set; } = SpaceLayerKinds.LocalSpace;

View File

@@ -15,10 +15,16 @@ internal sealed class WorldBuilder(
var systems = generationService.ExpandSystems(
generationService.InjectSpecialSystems(catalog.AuthoredSystems),
worldGeneration.TargetSystemCount);
Console.WriteLine("TEST");
Console.WriteLine(string.Join(',', systems.Select(s => s.Id)));
var scenario = dataLoader.NormalizeScenarioToAvailableSystems(
catalog.Scenario,
systems.Select(system => system.Id).ToList());
Console.WriteLine(string.Join(',', systems.Select(s => s.Id)));
var systemRuntimes = systems
.Select(definition => new SystemRuntime
{
@@ -42,11 +48,29 @@ internal sealed class WorldBuilder(
var patrolRoutes = BuildPatrolRoutes(scenario, systemsById);
var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, stations, refinery);
if (worldGeneration.AiControllerFactionCount < int.MaxValue)
{
var aiFactionIds = stations
.Select(s => s.FactionId)
.Concat(ships.Select(s => s.FactionId))
.Where(id => !string.IsNullOrWhiteSpace(id) && !string.Equals(id, DefaultFactionId, StringComparison.Ordinal))
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.Take(worldGeneration.AiControllerFactionCount)
.ToHashSet(StringComparer.Ordinal);
aiFactionIds.Add(DefaultFactionId);
stations = stations.Where(s => aiFactionIds.Contains(s.FactionId)).ToList();
ships = ships.Where(s => aiFactionIds.Contains(s.FactionId)).ToList();
}
var factions = seedingService.CreateFactions(stations, ships);
seedingService.BootstrapFactionEconomy(factions, stations);
var policies = seedingService.CreatePolicies(factions);
var commanders = seedingService.CreateCommanders(factions, stations, ships);
var nowUtc = DateTimeOffset.UtcNow;
var playerFaction = worldGeneration.GeneratePlayerFaction
? seedingService.CreatePlayerFaction(factions, stations, ships, commanders, policies, nowUtc)
: null;
var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc);
var bootstrapWorld = new SimulationWorld
{
@@ -56,9 +80,11 @@ internal sealed class WorldBuilder(
Systems = systemRuntimes,
Celestials = spatialLayout.Celestials,
Nodes = spatialLayout.Nodes,
Wrecks = [],
Stations = stations,
Ships = ships,
Factions = factions,
PlayerFaction = playerFaction,
Commanders = commanders,
Claims = claims,
ConstructionSites = [],
@@ -75,7 +101,7 @@ internal sealed class WorldBuilder(
};
var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(bootstrapWorld);
return new SimulationWorld
var world = new SimulationWorld
{
Label = "Split Viewer / Simulation World",
Seed = WorldSeed,
@@ -83,9 +109,12 @@ internal sealed class WorldBuilder(
Systems = systemRuntimes,
Celestials = spatialLayout.Celestials,
Nodes = spatialLayout.Nodes,
Wrecks = [],
Stations = stations,
Ships = ships,
Factions = factions,
PlayerFaction = playerFaction,
Geopolitics = null,
Commanders = commanders,
Claims = claims,
ConstructionSites = constructionSites,
@@ -100,6 +129,10 @@ internal sealed class WorldBuilder(
OrbitalTimeSeconds = WorldSeed * 97d,
GeneratedAtUtc = DateTimeOffset.UtcNow,
};
var geopolitics = new GeopoliticalSimulationService();
geopolitics.Update(world, 0f, []);
return world;
}
private static List<StationRuntime> CreateStations(
@@ -291,7 +324,7 @@ internal sealed class WorldBuilder(
patrolRoutes,
stations,
refinery),
ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending },
Skills = WorldSeedingService.CreateSkills(definition),
Health = definition.MaxHealth,
});

View File

@@ -286,16 +286,9 @@ internal sealed class WorldSeedingService
FactionId = faction.Id,
ControlledEntityId = faction.Id,
PolicySetId = faction.DefaultPolicySetId,
Doctrine = "strategic-expansionist",
Doctrine = "strategic-control",
};
commander.Goals.Add("control-all-systems");
commander.Goals.Add("control-five-systems-fast");
commander.Goals.Add("expand-industrial-base");
commander.Goals.Add("grow-war-fleet");
commander.Goals.Add("deter-pirate-harassment");
commander.Goals.Add("contest-rival-expansion");
commanders.Add(commander);
factionCommanders[faction.Id] = commander;
faction.CommanderIds.Add(commander.Id);
@@ -316,7 +309,7 @@ internal sealed class WorldSeedingService
ParentCommanderId = parentCommander.Id,
ControlledEntityId = station.Id,
PolicySetId = parentCommander.PolicySetId,
Doctrine = "station-default",
Doctrine = "station-control",
};
station.CommanderId = commander.Id;
@@ -341,16 +334,9 @@ internal sealed class WorldSeedingService
ParentCommanderId = parentCommander.Id,
ControlledEntityId = ship.Id,
PolicySetId = parentCommander.PolicySetId,
Doctrine = "ship-default",
ActiveBehavior = CopyBehavior(ship.DefaultBehavior),
ActiveTask = CopyTask(ship.ControllerTask, null),
Doctrine = "ship-control",
};
if (ship.Order is not null)
{
commander.ActiveOrder = CopyOrder(ship.Order);
}
ship.CommanderId = commander.Id;
ship.PolicySetId = parentCommander.PolicySetId;
parentCommander.SubordinateCommanderIds.Add(commander.Id);
@@ -361,6 +347,93 @@ internal sealed class WorldSeedingService
return commanders;
}
internal PlayerFactionRuntime CreatePlayerFaction(
IReadOnlyCollection<FactionRuntime> factions,
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ShipRuntime> ships,
IReadOnlyCollection<CommanderRuntime> commanders,
IReadOnlyCollection<PolicySetRuntime> policies,
DateTimeOffset nowUtc)
{
var sovereignFaction = factions.FirstOrDefault(faction => string.Equals(faction.Id, DefaultFactionId, StringComparison.Ordinal))
?? factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).First();
var player = new PlayerFactionRuntime
{
Id = "player-faction",
Label = $"{sovereignFaction.Label} Command",
SovereignFactionId = sovereignFaction.Id,
CreatedAtUtc = nowUtc,
UpdatedAtUtc = nowUtc,
};
foreach (var shipId in ships.Where(ship => ship.FactionId == sovereignFaction.Id).Select(ship => ship.Id))
{
player.AssetRegistry.ShipIds.Add(shipId);
}
foreach (var stationId in stations.Where(station => station.FactionId == sovereignFaction.Id).Select(station => station.Id))
{
player.AssetRegistry.StationIds.Add(stationId);
}
foreach (var commanderId in commanders.Where(commander => commander.FactionId == sovereignFaction.Id).Select(commander => commander.Id))
{
player.AssetRegistry.CommanderIds.Add(commanderId);
}
foreach (var policy in policies.Where(policy => string.Equals(policy.OwnerId, sovereignFaction.Id, StringComparison.Ordinal)))
{
player.AssetRegistry.PolicySetIds.Add(policy.Id);
}
player.Policies.Add(new PlayerFactionPolicyRuntime
{
Id = "player-core-policy",
Label = "Core Empire Policy",
ScopeKind = "player-faction",
ScopeId = player.Id,
PolicySetId = sovereignFaction.DefaultPolicySetId,
TradeAccessPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.TradeAccessPolicy ?? "owner-and-allies",
DockingAccessPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.DockingAccessPolicy ?? "owner-and-allies",
ConstructionAccessPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.ConstructionAccessPolicy ?? "owner-only",
OperationalRangePolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.OperationalRangePolicy ?? "unrestricted",
CombatEngagementPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.CombatEngagementPolicy ?? "defensive",
AvoidHostileSystems = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.AvoidHostileSystems ?? true,
FleeHullRatio = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.FleeHullRatio ?? 0.35f,
UpdatedAtUtc = nowUtc,
});
if (policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId) is { } defaultPolicy)
{
foreach (var systemId in defaultPolicy.BlacklistedSystemIds)
{
player.Policies[0].BlacklistedSystemIds.Add(systemId);
}
}
player.AutomationPolicies.Add(new PlayerAutomationPolicyRuntime
{
Id = "player-core-automation",
Label = "Core Automation",
ScopeKind = "player-faction",
ScopeId = player.Id,
BehaviorKind = "idle",
UpdatedAtUtc = nowUtc,
});
player.Reserves.Add(new PlayerReserveGroupRuntime
{
Id = "player-core-reserve",
Label = "Strategic Reserve",
ReserveKind = "military",
UpdatedAtUtc = nowUtc,
});
player.AssetRegistry.ReserveIds.Add("player-core-reserve");
return player;
}
internal static DefaultBehaviorRuntime CreateBehavior(
ShipDefinition definition,
string systemId,
@@ -381,22 +454,32 @@ internal sealed class WorldSeedingService
return new DefaultBehaviorRuntime
{
Kind = "construct-station",
StationId = homeStation.Id,
Phase = "travel-to-station",
HomeSystemId = homeStation.SystemId,
HomeStationId = homeStation.Id,
PreferredConstructionSiteId = null,
};
}
if (HasCapabilities(definition, "mining") && homeStation is not null)
{
return CreateResourceHarvestBehavior("auto-mine", scenario.MiningDefaults.NodeSystemId, homeStation.Id);
return new DefaultBehaviorRuntime
{
Kind = definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine",
HomeSystemId = homeStation.SystemId,
HomeStationId = homeStation.Id,
AreaSystemId = scenario.MiningDefaults.NodeSystemId,
MaxSystemRange = definition.CargoCapacity >= 120f ? 3 : 1,
};
}
if (string.Equals(definition.Kind, "transport", StringComparison.Ordinal))
{
return new DefaultBehaviorRuntime
{
Kind = "trade-haul",
Phase = "travel-to-source",
Kind = "advanced-auto-trade",
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
MaxSystemRange = 2,
};
}
@@ -405,7 +488,9 @@ internal sealed class WorldSeedingService
return new DefaultBehaviorRuntime
{
Kind = "patrol",
StationId = homeStation?.Id,
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
AreaSystemId = systemId,
PatrolPoints = route,
PatrolIndex = 0,
};
@@ -414,6 +499,20 @@ internal sealed class WorldSeedingService
return new DefaultBehaviorRuntime
{
Kind = "idle",
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
};
}
internal static ShipSkillProfileRuntime CreateSkills(ShipDefinition definition)
{
return definition.Kind switch
{
"transport" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 4, Mining = 1, Combat = 1, Construction = 1 },
"construction" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 1, Combat = 1, Construction = 4 },
"military" => new ShipSkillProfileRuntime { Navigation = 4, Trade = 1, Mining = 1, Combat = 4, Construction = 1 },
_ when HasCapabilities(definition, "mining") => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 4, Combat = 1, Construction = 1 },
_ => new ShipSkillProfileRuntime { Navigation = 3, Trade = 2, Mining = 1, Combat = 1, Construction = 1 },
};
}
@@ -471,43 +570,4 @@ internal sealed class WorldSeedingService
.Select(segment => char.ToUpperInvariant(segment[0]) + segment[1..]));
}
private static DefaultBehaviorRuntime CreateResourceHarvestBehavior(string kind, string areaSystemId, string stationId) => new()
{
Kind = kind,
AreaSystemId = areaSystemId,
StationId = stationId,
Phase = "travel-to-node",
};
private static CommanderBehaviorRuntime CopyBehavior(DefaultBehaviorRuntime behavior) => new()
{
Kind = behavior.Kind,
AreaSystemId = behavior.AreaSystemId,
TargetEntityId = behavior.TargetEntityId,
ItemId = behavior.ItemId,
ModuleId = behavior.ModuleId,
NodeId = behavior.NodeId,
Phase = behavior.Phase,
PatrolIndex = behavior.PatrolIndex,
StationId = behavior.StationId,
};
private static CommanderOrderRuntime CopyOrder(ShipOrderRuntime order) => new()
{
Kind = order.Kind,
Status = order.Status,
DestinationSystemId = order.DestinationSystemId,
DestinationPosition = order.DestinationPosition,
};
private static CommanderTaskRuntime CopyTask(ControllerTaskRuntime task, string? targetNodeId) => new()
{
Kind = task.Kind.ToContractValue(),
Status = task.Status,
TargetEntityId = task.TargetEntityId,
TargetNodeId = targetNodeId ?? task.TargetNodeId,
TargetPosition = task.TargetPosition,
TargetSystemId = task.TargetSystemId,
Threshold = task.Threshold,
};
}

View File

@@ -2,6 +2,7 @@ namespace SpaceGame.Api.Universe.Simulation;
public sealed class WorldGenerationOptions
{
public int TargetSystemCount { get; init; } = 160;
public int TargetSystemCount { get; init; }
public int AiControllerFactionCount { get; init; }
public bool GeneratePlayerFaction { get; init; }
}

View File

@@ -14,6 +14,7 @@ public sealed class WorldService(
private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value);
private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value);
private readonly PlayerFactionService _playerFaction = new();
private readonly Dictionary<Guid, SubscriptionState> _subscribers = [];
private readonly Queue<WorldDelta> _history = [];
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load();
@@ -74,6 +75,156 @@ public sealed class WorldService(
}
}
public ShipSnapshot? EnqueueShipOrder(string shipId, ShipOrderCommandRequest request)
{
lock (_sync)
{
var ship = _playerFaction.EnqueueDirectShipOrder(_world, shipId, request);
if (ship is null)
{
return null;
}
return GetShipSnapshotUnsafe(ship.Id);
}
}
public ShipSnapshot? RemoveShipOrder(string shipId, string orderId)
{
lock (_sync)
{
var ship = _playerFaction.RemoveDirectShipOrder(_world, shipId, orderId);
if (ship is null)
{
return null;
}
return GetShipSnapshotUnsafe(ship.Id);
}
}
public ShipSnapshot? UpdateShipDefaultBehavior(string shipId, ShipDefaultBehaviorCommandRequest request)
{
lock (_sync)
{
var ship = _playerFaction.ConfigureDirectShipBehavior(_world, shipId, request);
if (ship is null)
{
return null;
}
return GetShipSnapshotUnsafe(ship.Id);
}
}
public PlayerFactionSnapshot? GetPlayerFaction()
{
lock (_sync)
{
_playerFaction.EnsureDomain(_world);
return GetPlayerFactionSnapshotUnsafe();
}
}
public PlayerFactionSnapshot? CreatePlayerOrganization(PlayerOrganizationCommandRequest request)
{
lock (_sync)
{
_playerFaction.CreateOrganization(_world, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
public PlayerFactionSnapshot? DeletePlayerOrganization(string organizationId)
{
lock (_sync)
{
_playerFaction.DeleteOrganization(_world, organizationId);
return GetPlayerFactionSnapshotUnsafe();
}
}
public PlayerFactionSnapshot? UpdatePlayerOrganizationMembership(string organizationId, PlayerOrganizationMembershipCommandRequest request)
{
lock (_sync)
{
_playerFaction.UpdateOrganizationMembership(_world, organizationId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
public PlayerFactionSnapshot? UpsertPlayerDirective(string? directiveId, PlayerDirectiveCommandRequest request)
{
lock (_sync)
{
_playerFaction.UpsertDirective(_world, directiveId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
public PlayerFactionSnapshot? DeletePlayerDirective(string directiveId)
{
lock (_sync)
{
_playerFaction.DeleteDirective(_world, directiveId);
return GetPlayerFactionSnapshotUnsafe();
}
}
public PlayerFactionSnapshot? UpsertPlayerPolicy(string? policyId, PlayerPolicyCommandRequest request)
{
lock (_sync)
{
_playerFaction.UpsertPolicy(_world, policyId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
public PlayerFactionSnapshot? UpsertPlayerAutomationPolicy(string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
{
lock (_sync)
{
_playerFaction.UpsertAutomationPolicy(_world, automationPolicyId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
public PlayerFactionSnapshot? UpsertPlayerReinforcementPolicy(string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
{
lock (_sync)
{
_playerFaction.UpsertReinforcementPolicy(_world, reinforcementPolicyId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
public PlayerFactionSnapshot? UpsertPlayerProductionProgram(string? productionProgramId, PlayerProductionProgramCommandRequest request)
{
lock (_sync)
{
_playerFaction.UpsertProductionProgram(_world, productionProgramId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
public PlayerFactionSnapshot? UpsertPlayerAssignment(string assetId, PlayerAssetAssignmentCommandRequest request)
{
lock (_sync)
{
_playerFaction.UpsertAssignment(_world, assetId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
public PlayerFactionSnapshot? UpdatePlayerStrategicIntent(PlayerStrategicIntentCommandRequest request)
{
lock (_sync)
{
_playerFaction.UpdateStrategicIntent(_world, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
public ChannelReader<WorldDelta> Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken)
{
var channel = Channel.CreateUnbounded<WorldDelta>(new UnboundedChannelOptions
@@ -158,7 +309,9 @@ public sealed class WorldService(
[],
[],
[],
[]);
[],
null,
null);
_history.Enqueue(resetDelta);
foreach (var subscriber in _subscribers.Values.ToList())
@@ -203,6 +356,12 @@ public sealed class WorldService(
};
}
private ShipSnapshot? GetShipSnapshotUnsafe(string shipId) =>
_engine.BuildSnapshot(_world, _sequence).Ships.FirstOrDefault(ship => ship.Id == shipId);
private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() =>
_engine.BuildSnapshot(_world, _sequence).PlayerFaction;
private static bool HasMeaningfulDelta(WorldDelta delta) =>
delta.RequiresSnapshotRefresh
|| delta.Events.Count > 0
@@ -214,7 +373,9 @@ public sealed class WorldService(
|| delta.MarketOrders.Count > 0
|| delta.Policies.Count > 0
|| delta.Ships.Count > 0
|| delta.Factions.Count > 0;
|| delta.Factions.Count > 0
|| delta.PlayerFaction is not null
|| delta.Geopolitics is not null;
private void Unsubscribe(Guid subscriberId)
{
@@ -261,6 +422,8 @@ public sealed class WorldService(
Policies = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Policies : [],
Ships = delta.Ships.Where((ship) => systemFilter is null || ship.SystemId == systemFilter).ToList(),
Factions = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Factions : [],
PlayerFaction = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.PlayerFaction : null,
Geopolitics = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Geopolitics : null,
Scope = scope,
};
}

View File

@@ -6,8 +6,10 @@
}
},
"WorldGeneration": {
"TargetSystemCount": 10,
"IncludeSolSystem": true
"TargetSystemCount": 2,
"IncludeSolSystem": true,
"AiControllerFactionCount": 0,
"GeneratePlayerFaction": false
},
"OrbitalSimulation": {
"SimulatedSecondsPerRealSecond": 0