From 3b56785f9a05a3dbf9ee507775ea0333348dc62e Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Fri, 20 Mar 2026 12:40:26 -0400 Subject: [PATCH] improvement on gm windows, ai --- apps/backend/Definitions/WorldDefinitions.cs | 4 +- .../Factions/AI/FactionObjectivePlanning.cs | 712 ++++++++++++------ apps/backend/Factions/Contracts/Factions.cs | 4 + .../Factions/Runtime/FactionRuntimeModels.cs | 4 + .../Planning/FactionIndustryPlanner.cs | 16 +- apps/backend/Program.cs | 1 + .../Ships/AI/ShipBehaviorStateMachine.cs | 2 +- apps/backend/Ships/AI/ShipBehaviorStates.cs | 4 +- .../Ships/Simulation/ShipControlService.cs | 32 +- .../ShipTaskExecutionService.Actions.cs | 7 +- .../Simulation/Core/SimulationEngine.cs | 13 +- .../Core/SimulationProjectionService.cs | 4 + .../InfrastructureSimulationService.cs | 10 + .../Simulation/StationSimulationService.cs | 124 +++ .../backend/Universe/Api/GetBalanceHandler.cs | 16 + .../Universe/Api/GetTelemetryHandler.cs | 49 ++ .../Universe/Api/UpdateBalanceHandler.cs | 20 + .../Universe/Runtime/SimulationWorld.cs | 2 +- .../Universe/Scenario/SpatialBuilder.cs | 17 + .../Scenario/SystemGenerationService.cs | 160 +++- .../backend/Universe/Scenario/WorldBuilder.cs | 95 ++- .../Universe/Scenario/WorldSeedingService.cs | 111 ++- .../Universe/Simulation/TelemetryService.cs | 44 ++ .../Universe/Simulation/WorldService.cs | 76 ++ apps/backend/appsettings.Development.json | 2 +- apps/viewer/src/App.vue | 56 +- apps/viewer/src/api.ts | 30 + apps/viewer/src/components/gm/GmOpsWindow.vue | 134 +++- .../src/components/gm/GmSettingsWindow.vue | 134 ++++ .../src/components/gm/GmTelemetryWindow.vue | 166 ++++ apps/viewer/src/contractsBalance.ts | 11 + apps/viewer/src/contractsTelemetry.ts | 23 + apps/viewer/src/styles/viewer.css | 308 +++++++- apps/viewer/src/ui/stores/gmStore.ts | 4 + apps/viewer/src/viewerPanels.ts | 57 +- apps/viewer/src/viewerWorldLifecycle.ts | 1 + shared/data/balance.json | 1 + shared/data/scenario.json | 432 ++++++++++- shared/data/ships.json | 66 ++ 39 files changed, 2594 insertions(+), 358 deletions(-) create mode 100644 apps/backend/Universe/Api/GetBalanceHandler.cs create mode 100644 apps/backend/Universe/Api/GetTelemetryHandler.cs create mode 100644 apps/backend/Universe/Api/UpdateBalanceHandler.cs create mode 100644 apps/backend/Universe/Simulation/TelemetryService.cs create mode 100644 apps/viewer/src/components/gm/GmSettingsWindow.vue create mode 100644 apps/viewer/src/components/gm/GmTelemetryWindow.vue create mode 100644 apps/viewer/src/contractsBalance.ts create mode 100644 apps/viewer/src/contractsTelemetry.ts diff --git a/apps/backend/Definitions/WorldDefinitions.cs b/apps/backend/Definitions/WorldDefinitions.cs index dce2e50..f4e163e 100644 --- a/apps/backend/Definitions/WorldDefinitions.cs +++ b/apps/backend/Definitions/WorldDefinitions.cs @@ -40,6 +40,7 @@ public sealed class ItemProductionDefinition public sealed class BalanceDefinition { + public float SimulationSpeedMultiplier { get; set; } = 1f; public float YPlane { get; set; } public float ArrivalThreshold { get; set; } public float MiningRate { get; set; } @@ -94,7 +95,8 @@ public sealed class AsteroidFieldDefinition public sealed class ResourceNodeDefinition { - public string SourceKind { get; set; } = "asteroid-belt"; + public string SourceKind { get; set; } = "local-space"; + public string? AnchorReference { get; set; } public float Angle { get; set; } public float RadiusOffset { get; set; } public float InclinationDegrees { get; set; } diff --git a/apps/backend/Factions/AI/FactionObjectivePlanning.cs b/apps/backend/Factions/AI/FactionObjectivePlanning.cs index 4251181..c196ffd 100644 --- a/apps/backend/Factions/AI/FactionObjectivePlanning.cs +++ b/apps/backend/Factions/AI/FactionObjectivePlanning.cs @@ -457,6 +457,18 @@ internal sealed class ObjectiveStepFactory } } +internal sealed record StepExecutionAssessment( + FactionPlanStepStatus Status, + string StatusReason, + string? BlockingReason = null, + StepExecutionBinding? Binding = null, + IndustryExpansionProject? ExpectedProject = null); + +internal sealed record StepExecutionBinding( + string Kind, + string? TargetId, + string Summary); + internal sealed class FactionObjectiveExecutor { internal void Execute( @@ -466,6 +478,7 @@ internal sealed class FactionObjectiveExecutor FactionPlanningState state) { var blackboard = commander.FactionBlackboard ?? throw new InvalidOperationException("Faction blackboard must exist before objectives are executed."); + var activeProject = FactionIndustryPlanner.GetActiveExpansionProject(world, commander.FactionId); commander.ActiveGoalName = null; commander.ActiveActionName = null; @@ -480,8 +493,8 @@ internal sealed class FactionObjectiveExecutor EvaluateObjective(world, commander, objective); foreach (var step in objective.Steps.OrderByDescending(step => step.Priority)) { - EvaluateStep(world, commander, objective, step, blackboard, state); - EmitTasks(engine, world, commander, objective, step, blackboard, state, touchedTaskIds, assignedAssetIds); + var assessment = EvaluateStep(world, commander, objective, step, blackboard, state, activeProject); + EmitTasks(engine, world, commander, objective, step, assessment, touchedTaskIds, assignedAssetIds); } } @@ -531,102 +544,300 @@ internal sealed class FactionObjectiveExecutor objective.State = FactionObjectiveState.Active; } - private static void EvaluateStep( + private static StepExecutionAssessment EvaluateStep( SimulationWorld world, CommanderRuntime commander, FactionObjectiveRuntime objective, FactionPlanStepRuntime step, FactionBlackboardRuntime blackboard, - FactionPlanningState state) + FactionPlanningState state, + IndustryExpansionProject? activeProject) { step.LastEvaluatedCycle = commander.PlanningCycle; step.BlockingReason = null; + step.StatusReason = null; + step.ExecutionBindingKind = null; + step.ExecutionBindingTargetId = null; + step.ExecutionBindingSummary = null; + StepExecutionAssessment assessment; if (step.DependencyStepIds.Count > 0 && HasIncompleteDependencies(commander, step)) { - step.Status = FactionPlanStepStatus.Blocked; - step.BlockingReason = "Waiting for prerequisite objective steps to complete."; - return; + assessment = new StepExecutionAssessment( + FactionPlanStepStatus.Blocked, + "Blocked on prerequisite objective steps.", + BlockingReason: "Waiting for prerequisite objective steps to complete."); + } + else + { + assessment = step.Kind switch + { + FactionPlanStepKind.EnsureCommodityProduction => EvaluateCommodityStep(world, commander, step, blackboard, activeProject), + FactionPlanStepKind.EnsureShipyardSite => EvaluateShipyardStep(world, commander, step, state, activeProject), + FactionPlanStepKind.ProduceFleet => EvaluateFleetProductionStep(commander, step, blackboard, state), + FactionPlanStepKind.AttackFactionAssets => EvaluateAttackStep(commander, step, blackboard, state), + FactionPlanStepKind.EnsureWaterSupply => EvaluateWaterStep(world, commander, step, blackboard, activeProject), + FactionPlanStepKind.EnsureMiningCapacity => EvaluateCapacityStep(commander, step, blackboard.HasShipyard, state.MinerShipCount, 2, "mining"), + FactionPlanStepKind.EnsureConstructionCapacity => EvaluateCapacityStep(commander, step, blackboard.HasShipyard, state.ConstructorShipCount, 1, "construction"), + FactionPlanStepKind.EnsureTransportCapacity => EvaluateCapacityStep(commander, step, blackboard.HasShipyard, state.TransportShipCount, 1, "transport"), + FactionPlanStepKind.MonitorExpansionProject => EvaluateWarIndustryMonitorStep(world, commander, step, blackboard, activeProject), + _ => new StepExecutionAssessment(FactionPlanStepStatus.Failed, "Unknown step kind."), + }; } - switch (step.Kind) - { - case FactionPlanStepKind.EnsureCommodityProduction: - EvaluateCommodityStep(step, blackboard); - break; - case FactionPlanStepKind.EnsureShipyardSite: - if (state.HasShipFactory) - { - step.Status = FactionPlanStepStatus.Complete; - step.ProducedFacts.Add("shipyard-online"); - } - else - { - step.Status = blackboard.HasActiveExpansionProject && string.Equals(blackboard.ActiveExpansionModuleId, step.ModuleId, StringComparison.Ordinal) - ? FactionPlanStepStatus.Running - : FactionPlanStepStatus.Ready; - } - break; - case FactionPlanStepKind.ProduceFleet: - step.Status = state.MilitaryShipCount >= blackboard.TargetWarshipCount - ? FactionPlanStepStatus.Complete - : FactionPlanStepStatus.Running; - break; - case FactionPlanStepKind.AttackFactionAssets: - if (blackboard.EnemyFactionCount <= 0) - { - step.Status = FactionPlanStepStatus.Complete; - } - else if (state.MilitaryShipCount < Math.Max(2, blackboard.TargetWarshipCount / 2)) - { - step.Status = FactionPlanStepStatus.Blocked; - step.BlockingReason = "Insufficient military strength to commit to a faction attack objective."; - } - else - { - step.Status = FactionPlanStepStatus.Running; - } - break; - case FactionPlanStepKind.EnsureWaterSupply: - step.Status = IsCommodityOperational(blackboard, "water", 300f) - ? FactionPlanStepStatus.Complete - : blackboard.HasActiveExpansionProject && string.Equals(blackboard.ActiveExpansionCommodityId, "water", StringComparison.Ordinal) - ? FactionPlanStepStatus.Running - : FactionPlanStepStatus.Ready; - break; - case FactionPlanStepKind.EnsureMiningCapacity: - step.Status = state.MinerShipCount >= 2 ? FactionPlanStepStatus.Complete : FactionPlanStepStatus.Running; - break; - case FactionPlanStepKind.EnsureConstructionCapacity: - step.Status = state.ConstructorShipCount >= 1 ? FactionPlanStepStatus.Complete : FactionPlanStepStatus.Running; - break; - case FactionPlanStepKind.EnsureTransportCapacity: - step.Status = state.TransportShipCount >= 1 ? FactionPlanStepStatus.Complete : FactionPlanStepStatus.Running; - break; - case FactionPlanStepKind.MonitorExpansionProject: - step.Status = blackboard.HasWarIndustrySupplyChain ? FactionPlanStepStatus.Complete : FactionPlanStepStatus.Running; - break; - } + ApplyAssessment(step, assessment); + return assessment; } - private static void EvaluateCommodityStep( + private static StepExecutionAssessment EvaluateCommodityStep( + SimulationWorld world, + CommanderRuntime commander, FactionPlanStepRuntime step, - FactionBlackboardRuntime blackboard) + FactionBlackboardRuntime blackboard, + IndustryExpansionProject? activeProject) { if (string.IsNullOrWhiteSpace(step.CommodityId)) { - step.Status = FactionPlanStepStatus.Failed; - step.BlockingReason = "Commodity planning step is missing a target commodity."; - return; + return new StepExecutionAssessment( + FactionPlanStepStatus.Failed, + "Commodity step is missing a required commodity.", + BlockingReason: "Commodity planning step is missing a target commodity."); } - var completed = IsCommodityOperational(blackboard, step.CommodityId, 240f); - - step.Status = completed ? FactionPlanStepStatus.Complete : FactionPlanStepStatus.Ready; - if (completed) + if (IsCommodityOperational(blackboard, step.CommodityId, 240f)) { step.ProducedFacts.Add($"commodity-online:{step.CommodityId}"); + return new StepExecutionAssessment( + FactionPlanStepStatus.Complete, + $"Commodity {step.CommodityId} is operational in the faction economy."); } + + var expectedProject = FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, step.CommodityId, ignoreActiveExpansionProject: true); + return EvaluateExpansionRequirement(step, expectedProject, activeProject); + } + + private static StepExecutionAssessment EvaluateShipyardStep( + SimulationWorld world, + CommanderRuntime commander, + FactionPlanStepRuntime step, + FactionPlanningState state, + IndustryExpansionProject? activeProject) + { + if (state.HasShipFactory) + { + step.ProducedFacts.Add("shipyard-online"); + return new StepExecutionAssessment( + FactionPlanStepStatus.Complete, + "Faction already has an online shipyard."); + } + + var expectedProject = FactionIndustryPlanner.CreateShipyardFoundationProject(world, commander.FactionId, ignoreActiveExpansionProject: true); + return EvaluateExpansionRequirement(step, expectedProject, activeProject); + } + + private static StepExecutionAssessment EvaluateWaterStep( + SimulationWorld world, + CommanderRuntime commander, + FactionPlanStepRuntime step, + FactionBlackboardRuntime blackboard, + IndustryExpansionProject? activeProject) + { + if (IsCommodityOperational(blackboard, "water", 300f)) + { + step.ProducedFacts.Add("commodity-online:water"); + return new StepExecutionAssessment( + FactionPlanStepStatus.Complete, + "Water supply is operational."); + } + + var expectedProject = FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, "water", ignoreActiveExpansionProject: true); + return EvaluateExpansionRequirement(step, expectedProject, activeProject); + } + + private static StepExecutionAssessment EvaluateFleetProductionStep( + CommanderRuntime commander, + FactionPlanStepRuntime step, + FactionBlackboardRuntime blackboard, + FactionPlanningState state) + { + if (state.MilitaryShipCount >= blackboard.TargetWarshipCount) + { + return new StepExecutionAssessment( + FactionPlanStepStatus.Complete, + "Target war fleet size has been reached."); + } + + if (!blackboard.HasShipyard) + { + return new StepExecutionAssessment( + FactionPlanStepStatus.Blocked, + "Fleet production requires an online shipyard.", + BlockingReason: "Fleet production requires an online shipyard."); + } + + if (TryFindIssuedTaskBinding(commander, step, out var binding)) + { + return new StepExecutionAssessment( + FactionPlanStepStatus.Running, + "Military fleet production is already bound to an issued ship-production task.", + Binding: binding); + } + + return new StepExecutionAssessment( + FactionPlanStepStatus.Ready, + "Shipyard is available; military fleet production can begin."); + } + + private static StepExecutionAssessment EvaluateAttackStep( + CommanderRuntime commander, + FactionPlanStepRuntime step, + FactionBlackboardRuntime blackboard, + FactionPlanningState state) + { + if (blackboard.EnemyFactionCount <= 0) + { + return new StepExecutionAssessment( + FactionPlanStepStatus.Complete, + "No hostile faction remains to attack."); + } + + if (state.MilitaryShipCount < Math.Max(2, blackboard.TargetWarshipCount / 2)) + { + return new StepExecutionAssessment( + FactionPlanStepStatus.Blocked, + "Insufficient military strength to begin the attack objective.", + BlockingReason: "Insufficient military strength to commit to a faction attack objective."); + } + + if (TryFindIssuedTaskBinding(commander, step, out var binding)) + { + return new StepExecutionAssessment( + FactionPlanStepStatus.Running, + "Attack objective is already bound to a matching combat task.", + Binding: binding); + } + + return new StepExecutionAssessment( + FactionPlanStepStatus.Ready, + "Combat strength is available; attack execution can begin."); + } + + private static StepExecutionAssessment EvaluateCapacityStep( + CommanderRuntime commander, + FactionPlanStepRuntime step, + bool hasShipyard, + int currentCount, + int requiredCount, + string shipRole) + { + if (currentCount >= requiredCount) + { + return new StepExecutionAssessment( + FactionPlanStepStatus.Complete, + $"Faction already meets the required {shipRole} ship capacity."); + } + + if (!hasShipyard) + { + return new StepExecutionAssessment( + FactionPlanStepStatus.Blocked, + $"No shipyard is currently assigned to produce {shipRole} ships.", + BlockingReason: $"Ship capacity expansion for {shipRole} requires an online shipyard."); + } + + if (TryFindIssuedTaskBinding(commander, step, out var binding)) + { + return new StepExecutionAssessment( + FactionPlanStepStatus.Running, + $"{shipRole} ship production is already bound to a matching issued task.", + Binding: binding); + } + + return new StepExecutionAssessment( + FactionPlanStepStatus.Ready, + $"Shipyard capacity is available; {shipRole} ship production can begin."); + } + + private static StepExecutionAssessment EvaluateWarIndustryMonitorStep( + SimulationWorld world, + CommanderRuntime commander, + FactionPlanStepRuntime step, + FactionBlackboardRuntime blackboard, + IndustryExpansionProject? activeProject) + { + if (blackboard.HasWarIndustrySupplyChain) + { + return new StepExecutionAssessment( + FactionPlanStepStatus.Complete, + "War-industry supply chain is operational."); + } + + foreach (var commodityId in new[] { "refinedmetals", "hullparts", "claytronics" }) + { + if (IsCommodityOperational(blackboard, commodityId, 240f)) + { + continue; + } + + var expectedProject = FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, commodityId, ignoreActiveExpansionProject: true); + return EvaluateExpansionRequirement(step, expectedProject, activeProject); + } + + return new StepExecutionAssessment( + FactionPlanStepStatus.Ready, + "War-industry prerequisites are unresolved but no matching active project is bound yet."); + } + + private static StepExecutionAssessment EvaluateExpansionRequirement( + FactionPlanStepRuntime step, + IndustryExpansionProject? expectedProject, + IndustryExpansionProject? activeProject) + { + if (expectedProject is null) + { + return new StepExecutionAssessment( + FactionPlanStepStatus.Blocked, + "Unable to derive a valid expansion plan for the step outcome.", + BlockingReason: BuildMissingPlanReason(step)); + } + + if (activeProject is not null && ProjectsSemanticallyMatch(expectedProject, activeProject)) + { + return new StepExecutionAssessment( + FactionPlanStepStatus.Running, + $"Running on matching active expansion project {DescribeProject(activeProject)}.", + Binding: new StepExecutionBinding( + "expansion-project", + activeProject.SiteId, + $"Matched active project {DescribeProject(activeProject)}."), + ExpectedProject: activeProject); + } + + if (activeProject is not null) + { + return new StepExecutionAssessment( + FactionPlanStepStatus.Blocked, + $"Blocked by unrelated active expansion {DescribeProject(activeProject)}; step requires {DescribeProject(expectedProject)}.", + BlockingReason: $"Active expansion {DescribeProject(activeProject)} does not satisfy required outcome {DescribeProject(expectedProject)}.", + ExpectedProject: expectedProject); + } + + return new StepExecutionAssessment( + FactionPlanStepStatus.Ready, + $"Ready to start required expansion {DescribeProject(expectedProject)}.", + ExpectedProject: expectedProject); + } + + private static void ApplyAssessment( + FactionPlanStepRuntime step, + StepExecutionAssessment assessment) + { + step.Status = assessment.Status; + step.StatusReason = assessment.StatusReason; + step.BlockingReason = assessment.BlockingReason; + step.ExecutionBindingKind = assessment.Binding?.Kind; + step.ExecutionBindingTargetId = assessment.Binding?.TargetId; + step.ExecutionBindingSummary = assessment.Binding?.Summary; } private static bool IsCommodityOperational( @@ -653,8 +864,7 @@ internal sealed class FactionObjectiveExecutor CommanderRuntime commander, FactionObjectiveRuntime objective, FactionPlanStepRuntime step, - FactionBlackboardRuntime blackboard, - FactionPlanningState state, + StepExecutionAssessment assessment, ISet touchedTaskIds, ISet assignedAssetIds) { @@ -670,128 +880,12 @@ internal sealed class FactionObjectiveExecutor switch (step.Kind) { case FactionPlanStepKind.EnsureCommodityProduction: - if (blackboard.HasActiveExpansionProject) - { - UpsertExpansionTask( - commander, - objective, - step, - touchedTaskIds, - commodityId: blackboard.ActiveExpansionCommodityId ?? step.CommodityId, - moduleId: blackboard.ActiveExpansionModuleId, - targetSystemId: blackboard.ActiveExpansionSystemId, - targetSiteId: blackboard.ActiveExpansionSiteId, - blockingReason: step.BlockingReason, - notes: step.Notes ?? "Expansion project already active for faction."); - AssignConstructionAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); - step.Status = FactionPlanStepStatus.Running; - return; - } - - if (step.CommodityId is null) - { - return; - } - - var project = FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, step.CommodityId); - if (project is not null) - { - FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project); - step.TargetSiteId = project.SiteId; - step.Status = FactionPlanStepStatus.Running; - step.Notes = $"Queued expansion project for {project.CommodityId}."; - UpsertExpansionTask( - commander, - objective, - step, - touchedTaskIds, - commodityId: project.CommodityId, - moduleId: project.ModuleId, - targetSystemId: project.SystemId, - targetSiteId: project.SiteId, - blockingReason: null, - notes: step.Notes); - AssignConstructionAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); - } - else - { - step.Status = FactionPlanStepStatus.Blocked; - step.BlockingReason = $"Unable to derive an expansion project for {step.CommodityId}."; - UpsertExpansionTask( - commander, - objective, - step, - touchedTaskIds, - commodityId: step.CommodityId, - moduleId: step.ModuleId, - targetSystemId: null, - targetSiteId: null, - blockingReason: step.BlockingReason, - notes: step.Notes); - } + EmitExpansionExecution(world, commander, objective, step, assessment, touchedTaskIds, assignedAssetIds); break; case FactionPlanStepKind.EnsureShipyardSite: - if (blackboard.HasActiveExpansionProject) - { - UpsertExpansionTask( - commander, - objective, - step, - touchedTaskIds, - commodityId: blackboard.ActiveExpansionCommodityId, - moduleId: blackboard.ActiveExpansionModuleId ?? step.ModuleId, - targetSystemId: blackboard.ActiveExpansionSystemId, - targetSiteId: blackboard.ActiveExpansionSiteId, - blockingReason: step.BlockingReason, - notes: step.Notes ?? "Shipyard support project waiting on current expansion site."); - AssignConstructionAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); - step.Status = FactionPlanStepStatus.Running; - return; - } - - var shipyardProject = FactionIndustryPlanner.CreateShipyardFoundationProject(world, commander.FactionId); - if (shipyardProject is not null) - { - FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, shipyardProject); - step.TargetSiteId = shipyardProject.SiteId; - step.Status = FactionPlanStepStatus.Running; - step.Notes = "Queued shipyard foundation project."; - UpsertExpansionTask( - commander, - objective, - step, - touchedTaskIds, - commodityId: shipyardProject.CommodityId, - moduleId: shipyardProject.ModuleId, - targetSystemId: shipyardProject.SystemId, - targetSiteId: shipyardProject.SiteId, - blockingReason: null, - notes: step.Notes); - AssignConstructionAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); - } - else - { - step.Status = FactionPlanStepStatus.Blocked; - step.BlockingReason = "Unable to identify a viable shipyard foundation project."; - UpsertExpansionTask( - commander, - objective, - step, - touchedTaskIds, - commodityId: step.CommodityId, - moduleId: step.ModuleId, - targetSystemId: null, - targetSiteId: null, - blockingReason: step.BlockingReason, - notes: step.Notes); - } + EmitExpansionExecution(world, commander, objective, step, assessment, touchedTaskIds, assignedAssetIds); break; case FactionPlanStepKind.ProduceFleet: - if (!blackboard.HasShipyard) - { - step.Status = FactionPlanStepStatus.Blocked; - step.BlockingReason = "Fleet production requires an online shipyard."; - } UpsertShipProductionTask( commander, objective, @@ -801,78 +895,30 @@ internal sealed class FactionObjectiveExecutor blockingReason: step.BlockingReason, notes: step.Notes ?? "Maintain military ship production until war fleet target is satisfied."); AssignShipyardAssets(world, commander, objective, step); + PromoteShipProductionStepToRunning(step, "military"); break; case FactionPlanStepKind.AttackFactionAssets: UpsertAttackTask(commander, objective, step, touchedTaskIds); AssignCombatAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); + PromoteCombatStepToRunning(step); break; case FactionPlanStepKind.EnsureWaterSupply: - if (blackboard.HasActiveExpansionProject) - { - UpsertExpansionTask( - commander, - objective, - step, - touchedTaskIds, - commodityId: blackboard.ActiveExpansionCommodityId, - moduleId: blackboard.ActiveExpansionModuleId, - targetSystemId: blackboard.ActiveExpansionSystemId, - targetSiteId: blackboard.ActiveExpansionSiteId, - blockingReason: step.BlockingReason, - notes: step.Notes ?? "Water support project waiting on current expansion site."); - AssignConstructionAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); - step.Status = FactionPlanStepStatus.Running; - return; - } - - var waterProject = FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, "water"); - if (waterProject is not null) - { - FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, waterProject); - step.Status = FactionPlanStepStatus.Running; - step.Notes = "Queued water expansion project."; - step.TargetSiteId = waterProject.SiteId; - UpsertExpansionTask( - commander, - objective, - step, - touchedTaskIds, - commodityId: waterProject.CommodityId, - moduleId: waterProject.ModuleId, - targetSystemId: waterProject.SystemId, - targetSiteId: waterProject.SiteId, - blockingReason: null, - notes: step.Notes); - AssignConstructionAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); - } - else - { - step.Status = FactionPlanStepStatus.Blocked; - step.BlockingReason = "Unable to derive an expansion project for water."; - UpsertExpansionTask( - commander, - objective, - step, - touchedTaskIds, - commodityId: "water", - moduleId: step.ModuleId, - targetSystemId: null, - targetSiteId: null, - blockingReason: step.BlockingReason, - notes: step.Notes); - } + EmitExpansionExecution(world, commander, objective, step, assessment, touchedTaskIds, assignedAssetIds); break; case FactionPlanStepKind.EnsureMiningCapacity: UpsertShipProductionTask(commander, objective, step, touchedTaskIds, "mining", step.BlockingReason, "Maintain mining ship production until logistical capacity is healthy."); AssignShipyardAssets(world, commander, objective, step); + PromoteShipProductionStepToRunning(step, "mining"); break; case FactionPlanStepKind.EnsureConstructionCapacity: UpsertShipProductionTask(commander, objective, step, touchedTaskIds, "construction", step.BlockingReason, "Maintain construction ship production until expansion support is healthy."); AssignShipyardAssets(world, commander, objective, step); + PromoteShipProductionStepToRunning(step, "construction"); break; case FactionPlanStepKind.EnsureTransportCapacity: UpsertShipProductionTask(commander, objective, step, touchedTaskIds, "transport", step.BlockingReason, "Maintain transport ship production until logistical throughput is healthy."); AssignShipyardAssets(world, commander, objective, step); + PromoteShipProductionStepToRunning(step, "transport"); break; case FactionPlanStepKind.MonitorExpansionProject: UpsertWarIndustryTask(commander, objective, step, touchedTaskIds); @@ -880,6 +926,111 @@ internal sealed class FactionObjectiveExecutor } } + private static void EmitExpansionExecution( + SimulationWorld world, + CommanderRuntime commander, + FactionObjectiveRuntime objective, + FactionPlanStepRuntime step, + StepExecutionAssessment assessment, + ISet touchedTaskIds, + ISet assignedAssetIds) + { + var project = assessment.ExpectedProject; + if (project is null) + { + UpsertExpansionTask( + commander, + objective, + step, + touchedTaskIds, + commodityId: step.CommodityId, + moduleId: step.ModuleId, + targetSystemId: null, + targetSiteId: null, + blockingReason: step.BlockingReason, + notes: step.StatusReason); + return; + } + + if (step.Status == FactionPlanStepStatus.Ready) + { + FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project); + project = project with { SiteId = project.SiteId ?? FindMatchingSiteId(world, commander.FactionId, project) }; + step.TargetSiteId = project.SiteId; + step.Status = FactionPlanStepStatus.Running; + step.StatusReason = $"Started required expansion {DescribeProject(project)}."; + step.ExecutionBindingKind = "expansion-project"; + step.ExecutionBindingTargetId = project.SiteId; + step.ExecutionBindingSummary = $"Started site {project.SiteId ?? "pending"} for {DescribeProject(project)}."; + } + + if (step.Status == FactionPlanStepStatus.Running) + { + step.TargetSiteId ??= project.SiteId; + UpsertExpansionTask( + commander, + objective, + step, + touchedTaskIds, + commodityId: project.CommodityId, + moduleId: project.ModuleId, + targetSystemId: project.SystemId, + targetSiteId: project.SiteId, + blockingReason: step.BlockingReason, + notes: step.StatusReason); + AssignConstructionAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); + return; + } + + UpsertExpansionTask( + commander, + objective, + step, + touchedTaskIds, + commodityId: project.CommodityId, + moduleId: project.ModuleId, + targetSystemId: project.SystemId, + targetSiteId: project.SiteId, + blockingReason: step.BlockingReason, + notes: step.StatusReason); + } + + private static void PromoteShipProductionStepToRunning(FactionPlanStepRuntime step, string shipRole) + { + if (step.Status != FactionPlanStepStatus.Ready || step.AssignedAssetIds.Count == 0) + { + return; + } + + step.Status = FactionPlanStepStatus.Running; + step.StatusReason = $"{titleCase(shipRole)} ship production is bound to shipyard assets."; + step.ExecutionBindingKind = "shipyard-production"; + step.ExecutionBindingTargetId = step.AssignedAssetIds.FirstOrDefault(); + step.ExecutionBindingSummary = $"Using shipyard assets {string.Join(", ", step.AssignedAssetIds.OrderBy(id => id, StringComparer.Ordinal))}."; + } + + private static void PromoteCombatStepToRunning(FactionPlanStepRuntime step) + { + if (step.Status != FactionPlanStepStatus.Ready) + { + return; + } + + if (step.AssignedAssetIds.Count <= 0) + { + step.Status = FactionPlanStepStatus.Blocked; + step.BlockingReason = "No combat ships were available for the attack step."; + step.StatusReason = step.BlockingReason; + return; + } + + step.Status = FactionPlanStepStatus.Running; + step.StatusReason = "Attack step is bound to assigned combat ships."; + step.ExecutionBindingKind = "combat-assets"; + step.ExecutionBindingTargetId = step.AssignedAssetIds.FirstOrDefault(); + step.ExecutionBindingSummary = $"Using combat ships {string.Join(", ", step.AssignedAssetIds.OrderBy(id => id, StringComparer.Ordinal))}."; + } + private static void ReconcileStaleTasks(CommanderRuntime commander, ISet touchedTaskIds) { foreach (var task in commander.IssuedTasks) @@ -1056,6 +1207,71 @@ internal sealed class FactionObjectiveExecutor _ => FactionIssuedTaskState.Cancelled, }; + private static bool ProjectsSemanticallyMatch( + IndustryExpansionProject expectedProject, + IndustryExpansionProject activeProject) => + string.Equals(expectedProject.CommodityId, activeProject.CommodityId, StringComparison.Ordinal) + && string.Equals(expectedProject.ModuleId, activeProject.ModuleId, StringComparison.Ordinal) + && string.Equals(expectedProject.SystemId, activeProject.SystemId, StringComparison.Ordinal) + && string.Equals(expectedProject.CelestialId, activeProject.CelestialId, StringComparison.Ordinal); + + private static string DescribeProject(IndustryExpansionProject project) => + $"{project.CommodityId}/{project.ModuleId} @ {project.SystemId}:{project.CelestialId}"; + + private static string BuildMissingPlanReason(FactionPlanStepRuntime step) => + step.Kind switch + { + FactionPlanStepKind.EnsureCommodityProduction => $"Unable to derive an expansion project for required commodity {step.CommodityId}.", + FactionPlanStepKind.EnsureWaterSupply => "Unable to derive an expansion project for water supply.", + FactionPlanStepKind.EnsureShipyardSite => "Unable to identify a viable shipyard foundation project.", + _ => "Unable to derive the required execution plan for this step.", + }; + + private static bool TryFindIssuedTaskBinding( + CommanderRuntime commander, + FactionPlanStepRuntime step, + out StepExecutionBinding binding) + { + var task = commander.IssuedTasks.FirstOrDefault(candidate => + string.Equals(candidate.StepId, step.Id, StringComparison.Ordinal) + && candidate.State == FactionIssuedTaskState.Active); + if (task is null) + { + binding = default!; + return false; + } + + var targetId = task.TargetSiteId + ?? task.TargetFactionId + ?? task.AssignedAssetIds.OrderBy(id => id, StringComparer.Ordinal).FirstOrDefault(); + binding = new StepExecutionBinding( + "issued-task", + targetId, + $"Reusing active issued task {task.Kind} ({task.Id})."); + return true; + } + + private static string? FindMatchingSiteId( + SimulationWorld world, + string factionId, + IndustryExpansionProject project) => + world.ConstructionSites + .Where(site => + string.Equals(site.FactionId, factionId, StringComparison.Ordinal) + && string.Equals(site.TargetKind, "station-foundation", StringComparison.Ordinal) + && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed + && string.Equals(site.TargetDefinitionId, project.CommodityId, StringComparison.Ordinal) + && string.Equals(site.BlueprintId, project.ModuleId, StringComparison.Ordinal) + && string.Equals(site.SystemId, project.SystemId, StringComparison.Ordinal) + && string.Equals(site.CelestialId, project.CelestialId, StringComparison.Ordinal)) + .Select(site => site.Id) + .FirstOrDefault(); + + private static string titleCase(string value) => + string.IsNullOrWhiteSpace(value) + ? value + : char.ToUpperInvariant(value[0]) + value[1..]; + private static void AssignCombatAssets( SimulationWorld world, CommanderRuntime commander, diff --git a/apps/backend/Factions/Contracts/Factions.cs b/apps/backend/Factions/Contracts/Factions.cs index 15188a2..2da5f95 100644 --- a/apps/backend/Factions/Contracts/Factions.cs +++ b/apps/backend/Factions/Contracts/Factions.cs @@ -88,6 +88,10 @@ public sealed record FactionPlanStepSnapshot( string? ModuleId, string? TargetFactionId, string? TargetSiteId, + string? StatusReason, + string? ExecutionBindingKind, + string? ExecutionBindingTargetId, + string? ExecutionBindingSummary, string? BlockingReason, string? Notes, int LastEvaluatedCycle, diff --git a/apps/backend/Factions/Runtime/FactionRuntimeModels.cs b/apps/backend/Factions/Runtime/FactionRuntimeModels.cs index bb16628..a0f9f22 100644 --- a/apps/backend/Factions/Runtime/FactionRuntimeModels.cs +++ b/apps/backend/Factions/Runtime/FactionRuntimeModels.cs @@ -144,6 +144,10 @@ public sealed class FactionPlanStepRuntime 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; } diff --git a/apps/backend/Industry/Planning/FactionIndustryPlanner.cs b/apps/backend/Industry/Planning/FactionIndustryPlanner.cs index b9b51fa..d7c95ce 100644 --- a/apps/backend/Industry/Planning/FactionIndustryPlanner.cs +++ b/apps/backend/Industry/Planning/FactionIndustryPlanner.cs @@ -7,9 +7,9 @@ internal static class FactionIndustryPlanner private const float CommodityTargetLevelSeconds = 240f; private const float WaterTargetLevelSeconds = 300f; - internal static IndustryExpansionProject? AnalyzeCommodityNeed(SimulationWorld world, string factionId, string commodityId) + internal static IndustryExpansionProject? AnalyzeCommodityNeed(SimulationWorld world, string factionId, string commodityId, bool ignoreActiveExpansionProject = false) { - if (HasActiveExpansionProject(world, factionId)) + if (!ignoreActiveExpansionProject && HasActiveExpansionProject(world, factionId)) { return null; } @@ -41,9 +41,9 @@ internal static class FactionIndustryPlanner supportStation.Id); } - internal static IndustryExpansionProject? AnalyzeShipyardNeed(SimulationWorld world, string factionId) + internal static IndustryExpansionProject? AnalyzeShipyardNeed(SimulationWorld world, string factionId, bool ignoreActiveExpansionProject = false) { - if (HasActiveExpansionProject(world, factionId)) + if (!ignoreActiveExpansionProject && HasActiveExpansionProject(world, factionId)) { return null; } @@ -79,16 +79,16 @@ internal static class FactionIndustryPlanner if (!string.IsNullOrWhiteSpace(bottleneckCommodity)) { - return AnalyzeCommodityNeed(world, factionId, bottleneckCommodity); + return AnalyzeCommodityNeed(world, factionId, bottleneckCommodity, ignoreActiveExpansionProject); } - return CreateShipyardFoundationProject(world, factionId); + return CreateShipyardFoundationProject(world, factionId, ignoreActiveExpansionProject); } - internal static IndustryExpansionProject? CreateShipyardFoundationProject(SimulationWorld world, string factionId) + internal static IndustryExpansionProject? CreateShipyardFoundationProject(SimulationWorld world, string factionId, bool ignoreActiveExpansionProject = false) { const string shipyardModuleId = "module_gen_build_l_01"; - if (HasActiveExpansionProject(world, factionId)) + if (!ignoreActiveExpansionProject && HasActiveExpansionProject(world, factionId)) { return null; } diff --git a/apps/backend/Program.cs b/apps/backend/Program.cs index 32ce291..a32abdf 100644 --- a/apps/backend/Program.cs +++ b/apps/backend/Program.cs @@ -18,6 +18,7 @@ builder.Services.Configure(builder.Configuration.GetSect builder.Services.Configure(builder.Configuration.GetSection("OrbitalSimulation")); builder.Services.AddFastEndpoints(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddHostedService(); var app = builder.Build(); diff --git a/apps/backend/Ships/AI/ShipBehaviorStateMachine.cs b/apps/backend/Ships/AI/ShipBehaviorStateMachine.cs index 19dbf8d..a503b65 100644 --- a/apps/backend/Ships/AI/ShipBehaviorStateMachine.cs +++ b/apps/backend/Ships/AI/ShipBehaviorStateMachine.cs @@ -21,7 +21,7 @@ internal sealed class ShipBehaviorStateMachine new PatrolShipBehaviorState(), new AttackTargetShipBehaviorState(), new TradeHaulShipBehaviorState(), - new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining"), + new ResourceHarvestShipBehaviorState("auto-mine", null, "mining"), new ConstructStationShipBehaviorState(), }; diff --git a/apps/backend/Ships/AI/ShipBehaviorStates.cs b/apps/backend/Ships/AI/ShipBehaviorStates.cs index 45c03ef..7536283 100644 --- a/apps/backend/Ships/AI/ShipBehaviorStates.cs +++ b/apps/backend/Ships/AI/ShipBehaviorStates.cs @@ -58,10 +58,10 @@ internal sealed class PatrolShipBehaviorState : IShipBehaviorState internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState { - private readonly string resourceItemId; + private readonly string? resourceItemId; private readonly string requiredModule; - public ResourceHarvestShipBehaviorState(string kind, string resourceItemId, string requiredModule) + public ResourceHarvestShipBehaviorState(string kind, string? resourceItemId, string requiredModule) { Kind = kind; this.resourceItemId = resourceItemId; diff --git a/apps/backend/Ships/Simulation/ShipControlService.cs b/apps/backend/Ships/Simulation/ShipControlService.cs index 551a74e..0484a55 100644 --- a/apps/backend/Ships/Simulation/ShipControlService.cs +++ b/apps/backend/Ships/Simulation/ShipControlService.cs @@ -291,11 +291,18 @@ internal sealed class ShipControlService }; } - internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule) + 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; @@ -426,22 +433,22 @@ internal sealed class ShipControlService } } - private static string SelectMiningResourceItem(SimulationWorld world, ShipRuntime ship, string fallbackItemId) + 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) - .SelectMany(order => FactionIndustryPlanner.ResolveRootResourceItems(world, order.ItemId) - .Select(itemId => new - { - ItemId = itemId, - Score = order.RemainingAmount * MathF.Max(0.25f, order.Valuation), - })) - .Where(entry => - CanShipMineItem(world, ship, entry.ItemId) - && world.Nodes.Any(node => string.Equals(node.ItemId, entry.ItemId, StringComparison.Ordinal) && node.OreRemaining > 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 { @@ -457,7 +464,8 @@ internal sealed class ShipControlService return candidateItemId; } - if (CanShipMineItem(world, ship, fallbackItemId) + 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; diff --git a/apps/backend/Ships/Simulation/ShipTaskExecutionService.Actions.cs b/apps/backend/Ships/Simulation/ShipTaskExecutionService.Actions.cs index 1880559..48233cc 100644 --- a/apps/backend/Ships/Simulation/ShipTaskExecutionService.Actions.cs +++ b/apps/backend/Ships/Simulation/ShipTaskExecutionService.Actions.cs @@ -1,5 +1,6 @@ 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; @@ -331,7 +332,8 @@ internal sealed partial class ShipTaskExecutionService } var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds); - moved = MathF.Min(moved, GetInventoryAmount(station.Inventory, required.Key)); + 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; @@ -356,7 +358,8 @@ internal sealed partial class ShipTaskExecutionService } var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds); - moved = MathF.Min(moved, GetInventoryAmount(station.Inventory, required.Key)); + 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; diff --git a/apps/backend/Simulation/Core/SimulationEngine.cs b/apps/backend/Simulation/Core/SimulationEngine.cs index b074326..110584e 100644 --- a/apps/backend/Simulation/Core/SimulationEngine.cs +++ b/apps/backend/Simulation/Core/SimulationEngine.cs @@ -30,14 +30,15 @@ public sealed class SimulationEngine { var nowUtc = DateTimeOffset.UtcNow; var events = new List(); + var simulationDeltaSeconds = deltaSeconds * MathF.Max(world.Balance.SimulationSpeedMultiplier, 0.01f); - world.OrbitalTimeSeconds += deltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond; + world.OrbitalTimeSeconds += simulationDeltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond; _orbitalStateUpdater.Update(world); _infrastructureSimulation.UpdateClaims(world, events); _infrastructureSimulation.UpdateConstructionSites(world, events); - _commanderPlanning.UpdateCommanders(this, world, deltaSeconds, events); - _stationLifecycle.UpdateStations(world, deltaSeconds, events); + _commanderPlanning.UpdateCommanders(this, world, simulationDeltaSeconds, events); + _stationLifecycle.UpdateStations(world, simulationDeltaSeconds, events); foreach (var ship in world.Ships.ToList()) { @@ -54,10 +55,10 @@ public sealed class SimulationEngine _shipControl.RefreshControlLayers(ship, world); _shipControl.PlanControllerTask(this, ship, world); - var controllerEvent = _shipTaskExecution.UpdateControllerTask(ship, world, deltaSeconds); + var controllerEvent = _shipTaskExecution.UpdateControllerTask(ship, world, simulationDeltaSeconds); _shipControl.AdvanceControlState(this, ship, world, controllerEvent); - ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds); + ship.Velocity = ship.Position.Subtract(previousPosition).Divide(simulationDeltaSeconds); _shipControl.TrackHistory(ship, controllerEvent); _shipControl.EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events); } @@ -75,7 +76,7 @@ public sealed class SimulationEngine public void PrimeDeltaBaseline(SimulationWorld world) => _projection.PrimeDeltaBaseline(world); - internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule) => + internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string? resourceItemId, string requiredModule) => _shipControl.PlanResourceHarvest(ship, world, resourceItemId, requiredModule); internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) => diff --git a/apps/backend/Simulation/Core/SimulationProjectionService.cs b/apps/backend/Simulation/Core/SimulationProjectionService.cs index 19fca17..e92314e 100644 --- a/apps/backend/Simulation/Core/SimulationProjectionService.cs +++ b/apps/backend/Simulation/Core/SimulationProjectionService.cs @@ -895,6 +895,10 @@ internal sealed class SimulationProjectionService step.ModuleId, step.TargetFactionId, step.TargetSiteId, + step.StatusReason, + step.ExecutionBindingKind, + step.ExecutionBindingTargetId, + step.ExecutionBindingSummary, step.BlockingReason, step.Notes, step.LastEvaluatedCycle, diff --git a/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs b/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs index c6105de..b94625f 100644 --- a/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs +++ b/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs @@ -232,8 +232,13 @@ internal sealed class InfrastructureSimulationService "power" => "energycells", "refinery" => "refinedmetals", "water" => "water", + "graphene" => "graphene", + "siliconwafers" => "siliconwafers", "hullparts" => "hullparts", "claytronics" => "claytronics", + "quantumtubes" => "quantumtubes", + "antimattercells" => "antimattercells", + "superfluidcoolant" => "superfluidcoolant", _ => null, }; @@ -724,6 +729,11 @@ internal sealed class InfrastructureSimulationService private static float GetTargetLevelSeconds(string commodityId) => string.Equals(commodityId, "energycells", StringComparison.Ordinal) ? EnergyTargetLevelSeconds : string.Equals(commodityId, "water", StringComparison.Ordinal) ? 300f : + string.Equals(commodityId, "graphene", StringComparison.Ordinal) ? 240f : + string.Equals(commodityId, "siliconwafers", StringComparison.Ordinal) ? 240f : + string.Equals(commodityId, "quantumtubes", StringComparison.Ordinal) ? 240f : + string.Equals(commodityId, "antimattercells", StringComparison.Ordinal) ? 240f : + string.Equals(commodityId, "superfluidcoolant", StringComparison.Ordinal) ? 240f : CommodityTargetLevelSeconds; internal static void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site) diff --git a/apps/backend/Stations/Simulation/StationSimulationService.cs b/apps/backend/Stations/Simulation/StationSimulationService.cs index d40ca7d..557027f 100644 --- a/apps/backend/Stations/Simulation/StationSimulationService.cs +++ b/apps/backend/Stations/Simulation/StationSimulationService.cs @@ -24,12 +24,25 @@ internal sealed class StationSimulationService var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics"); var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals"); var iceReserve = role == "water" ? 260f : 0f; + var methaneReserve = role == "graphene" ? 320f : 0f; + var hydrogenReserve = role == "antimattercells" ? 320f : 0f; + var heliumReserve = role == "superfluidcoolant" ? 320f : 0f; + var siliconReserve = role == "siliconwafers" ? 240f : 0f; + var grapheneInputReserve = role == "quantumtubes" ? 160f : 0f; + var superfluidCoolantInputReserve = role == "quantumtubes" ? 120f : 0f; + var antimatterCellsInputReserve = role == "claytronics" ? 120f : 0f; + var quantumTubesInputReserve = role == "claytronics" ? 120f : 0f; var energyReserve = role switch { "power" => 120f, "refinery" => 160f, "hullparts" => 180f, "claytronics" => 220f, + "graphene" => 160f, + "siliconwafers" => 160f, + "antimattercells" => 160f, + "superfluidcoolant" => 160f, + "quantumtubes" => 160f, "water" => 140f, _ => 60f, } + constructionEnergyReserve; @@ -43,6 +56,11 @@ internal sealed class StationSimulationService var oreReserve = role == "refinery" ? 260f : 0f; var hullpartsReserve = MathF.Max(constructionHullpartsReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f); var claytronicsReserve = MathF.Max(constructionClayReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f); + var grapheneReserve = role == "graphene" ? 120f : 0f; + var siliconWafersReserve = role == "siliconwafers" ? 120f : 0f; + var antimatterCellsReserve = role == "antimattercells" ? 120f : 0f; + 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") ? 90f @@ -51,22 +69,98 @@ internal sealed class StationSimulationService AddDemandOrder(desiredOrders, station, "water", ScaleReserveByEconomy(economy, "water", waterReserve), valuationBase: ScaleDemandValuation(economy, "water", 1.1f)); AddDemandOrder(desiredOrders, station, "energycells", ScaleReserveByEconomy(economy, "energycells", energyReserve), valuationBase: ScaleDemandValuation(economy, "energycells", 1.0f)); AddDemandOrder(desiredOrders, station, "ice", ScaleReserveByEconomy(economy, "ice", iceReserve), valuationBase: ScaleDemandValuation(economy, "ice", 1.0f)); + AddDemandOrder(desiredOrders, station, "methane", ScaleReserveByEconomy(economy, "methane", methaneReserve), valuationBase: ScaleDemandValuation(economy, "methane", 1.0f)); + AddDemandOrder(desiredOrders, station, "hydrogen", ScaleReserveByEconomy(economy, "hydrogen", hydrogenReserve), valuationBase: ScaleDemandValuation(economy, "hydrogen", 1.0f)); + AddDemandOrder(desiredOrders, station, "helium", ScaleReserveByEconomy(economy, "helium", heliumReserve), valuationBase: ScaleDemandValuation(economy, "helium", 1.0f)); AddDemandOrder(desiredOrders, station, "ore", ScaleReserveByEconomy(economy, "ore", oreReserve), valuationBase: ScaleDemandValuation(economy, "ore", 1.0f)); + AddDemandOrder(desiredOrders, station, "silicon", ScaleReserveByEconomy(economy, "silicon", siliconReserve), valuationBase: ScaleDemandValuation(economy, "silicon", 1.0f)); AddDemandOrder(desiredOrders, station, "refinedmetals", ScaleReserveByEconomy(economy, "refinedmetals", MathF.Max(refinedReserve, constructionRefinedReserve)), valuationBase: ScaleDemandValuation(economy, "refinedmetals", 1.15f)); AddDemandOrder(desiredOrders, station, "hullparts", ScaleReserveByEconomy(economy, "hullparts", hullpartsReserve + shipPartsReserve), valuationBase: ScaleDemandValuation(economy, "hullparts", 1.3f)); AddDemandOrder(desiredOrders, station, "claytronics", ScaleReserveByEconomy(economy, "claytronics", claytronicsReserve), valuationBase: ScaleDemandValuation(economy, "claytronics", 1.35f)); + AddDemandOrder(desiredOrders, station, "graphene", ScaleReserveByEconomy(economy, "graphene", grapheneReserve), valuationBase: ScaleDemandValuation(economy, "graphene", 1.05f)); + AddDemandOrder(desiredOrders, station, "siliconwafers", ScaleReserveByEconomy(economy, "siliconwafers", siliconWafersReserve), valuationBase: ScaleDemandValuation(economy, "siliconwafers", 1.05f)); + AddDemandOrder(desiredOrders, station, "antimattercells", ScaleReserveByEconomy(economy, "antimattercells", antimatterCellsReserve), valuationBase: ScaleDemandValuation(economy, "antimattercells", 1.05f)); + AddDemandOrder(desiredOrders, station, "superfluidcoolant", ScaleReserveByEconomy(economy, "superfluidcoolant", superfluidCoolantReserve), valuationBase: ScaleDemandValuation(economy, "superfluidcoolant", 1.05f)); + AddDemandOrder(desiredOrders, station, "graphene", ScaleReserveByEconomy(economy, "graphene", grapheneInputReserve), valuationBase: ScaleDemandValuation(economy, "graphene", 1.1f)); + AddDemandOrder(desiredOrders, station, "superfluidcoolant", ScaleReserveByEconomy(economy, "superfluidcoolant", superfluidCoolantInputReserve), valuationBase: ScaleDemandValuation(economy, "superfluidcoolant", 1.1f)); + AddDemandOrder(desiredOrders, station, "antimattercells", ScaleReserveByEconomy(economy, "antimattercells", antimatterCellsInputReserve), valuationBase: ScaleDemandValuation(economy, "antimattercells", 1.1f)); + AddDemandOrder(desiredOrders, station, "quantumtubes", ScaleReserveByEconomy(economy, "quantumtubes", quantumTubesInputReserve), valuationBase: ScaleDemandValuation(economy, "quantumtubes", 1.1f)); + AddDemandOrder(desiredOrders, station, "quantumtubes", ScaleReserveByEconomy(economy, "quantumtubes", quantumTubesReserve), valuationBase: ScaleDemandValuation(economy, "quantumtubes", 1.05f)); AddSupplyOrder(desiredOrders, station, "water", ScaleSupplyTriggerByEconomy(economy, "water", waterReserve * 1.5f), reserveFloor: waterReserve, valuationBase: ScaleSupplyValuation(economy, "water", 0.65f)); AddSupplyOrder(desiredOrders, station, "energycells", ScaleSupplyTriggerByEconomy(economy, "energycells", energyReserve * 1.4f), reserveFloor: energyReserve, valuationBase: ScaleSupplyValuation(economy, "energycells", 0.7f)); AddSupplyOrder(desiredOrders, station, "ice", ScaleSupplyTriggerByEconomy(economy, "ice", iceReserve * 1.4f), reserveFloor: iceReserve, valuationBase: ScaleSupplyValuation(economy, "ice", 0.5f)); + AddSupplyOrder(desiredOrders, station, "methane", ScaleSupplyTriggerByEconomy(economy, "methane", methaneReserve * 1.4f), reserveFloor: methaneReserve, valuationBase: ScaleSupplyValuation(economy, "methane", 0.7f)); + AddSupplyOrder(desiredOrders, station, "hydrogen", ScaleSupplyTriggerByEconomy(economy, "hydrogen", hydrogenReserve * 1.4f), reserveFloor: hydrogenReserve, valuationBase: ScaleSupplyValuation(economy, "hydrogen", 0.7f)); + AddSupplyOrder(desiredOrders, station, "helium", ScaleSupplyTriggerByEconomy(economy, "helium", heliumReserve * 1.4f), reserveFloor: heliumReserve, valuationBase: ScaleSupplyValuation(economy, "helium", 0.7f)); AddSupplyOrder(desiredOrders, station, "ore", ScaleSupplyTriggerByEconomy(economy, "ore", oreReserve * 1.4f), reserveFloor: oreReserve, valuationBase: ScaleSupplyValuation(economy, "ore", 0.7f)); + AddSupplyOrder(desiredOrders, station, "silicon", ScaleSupplyTriggerByEconomy(economy, "silicon", siliconReserve * 1.4f), reserveFloor: siliconReserve, valuationBase: ScaleSupplyValuation(economy, "silicon", 0.7f)); AddSupplyOrder(desiredOrders, station, "refinedmetals", ScaleSupplyTriggerByEconomy(economy, "refinedmetals", MathF.Max(refinedReserve, constructionRefinedReserve) * 1.4f), reserveFloor: MathF.Max(refinedReserve, constructionRefinedReserve), valuationBase: ScaleSupplyValuation(economy, "refinedmetals", 0.95f)); AddSupplyOrder(desiredOrders, station, "hullparts", ScaleSupplyTriggerByEconomy(economy, "hullparts", MathF.Max(hullpartsReserve * 1.35f, hullpartsReserve + 40f)), reserveFloor: hullpartsReserve, valuationBase: ScaleSupplyValuation(economy, "hullparts", 1.05f)); AddSupplyOrder(desiredOrders, station, "claytronics", ScaleSupplyTriggerByEconomy(economy, "claytronics", MathF.Max(claytronicsReserve * 1.35f, claytronicsReserve + 30f)), reserveFloor: claytronicsReserve, valuationBase: ScaleSupplyValuation(economy, "claytronics", 1.1f)); + AddSupplyOrder(desiredOrders, station, "graphene", ScaleSupplyTriggerByEconomy(economy, "graphene", MathF.Max(grapheneReserve * 1.35f, grapheneReserve + 30f)), reserveFloor: grapheneReserve, valuationBase: ScaleSupplyValuation(economy, "graphene", 0.9f)); + AddSupplyOrder(desiredOrders, station, "siliconwafers", ScaleSupplyTriggerByEconomy(economy, "siliconwafers", MathF.Max(siliconWafersReserve * 1.35f, siliconWafersReserve + 30f)), reserveFloor: siliconWafersReserve, valuationBase: ScaleSupplyValuation(economy, "siliconwafers", 0.9f)); + AddSupplyOrder(desiredOrders, station, "antimattercells", ScaleSupplyTriggerByEconomy(economy, "antimattercells", MathF.Max(antimatterCellsReserve * 1.35f, antimatterCellsReserve + 30f)), reserveFloor: antimatterCellsReserve, valuationBase: ScaleSupplyValuation(economy, "antimattercells", 0.9f)); + 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)); ReconcileStationMarketOrders(world, station, desiredOrders); } + internal static float GetStationReserveFloor(SimulationWorld world, StationRuntime station, string itemId) + { + var role = DetermineStationRole(station); + var site = GetConstructionSiteForStation(world, station.Id); + var constructionEnergyReserve = GetConstructionDemandForItem(world, site, "energycells"); + var constructionHullpartsReserve = GetConstructionDemandForItem(world, site, "hullparts"); + 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") + ? 90f + : 0f; + + return itemId switch + { + "water" => MathF.Max(30f, station.Population * 3f), + "energycells" => role switch + { + "power" => 120f, + "refinery" => 160f, + "hullparts" => 180f, + "claytronics" => 220f, + "graphene" => 160f, + "siliconwafers" => 160f, + "antimattercells" => 160f, + "superfluidcoolant" => 160f, + "quantumtubes" => 160f, + "water" => 140f, + _ => 60f, + } + constructionEnergyReserve, + "ice" => role == "water" ? 260f : 0f, + "methane" => role == "graphene" ? 320f : 0f, + "hydrogen" => role == "antimattercells" ? 320f : 0f, + "helium" => role == "superfluidcoolant" ? 320f : 0f, + "ore" => role == "refinery" ? 260f : 0f, + "silicon" => role == "siliconwafers" ? 240f : 0f, + "refinedmetals" => MathF.Max(role switch + { + "hullparts" => 220f, + "shipyard" => 260f, + "refinery" => 80f, + _ => 0f, + }, constructionRefinedReserve), + "hullparts" => MathF.Max(constructionHullpartsReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f) + shipPartsReserve, + "claytronics" => MathF.Max(constructionClayReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f), + "graphene" => MathF.Max(role == "graphene" ? 120f : 0f, role == "quantumtubes" ? 160f : 0f), + "siliconwafers" => role == "siliconwafers" ? 120f : 0f, + "antimattercells" => MathF.Max(role == "antimattercells" ? 120f : 0f, role == "claytronics" ? 120f : 0f), + "superfluidcoolant" => MathF.Max(role == "superfluidcoolant" ? 120f : 0f, role == "quantumtubes" ? 120f : 0f), + "quantumtubes" => MathF.Max(role == "quantumtubes" ? 120f : 0f, role == "claytronics" ? 120f : 0f), + _ => 0f, + }; + } + internal void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection events) { var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId); @@ -295,6 +389,11 @@ internal sealed class StationSimulationService "refinery" or "refinedmetals" => "refinery", "hullparts" or "hull" => "hullparts", "claytronics" or "clay" => "claytronics", + "graphene" => "graphene", + "siliconwafers" or "silicon-wafers" or "silicon" => "siliconwafers", + "antimattercells" or "antimatter-cells" => "antimattercells", + "superfluidcoolant" or "superfluid-coolant" => "superfluidcoolant", + "quantumtubes" or "quantum-tubes" => "quantumtubes", "shipyard" or "ship-production" => "shipyard", _ => "general", }; @@ -318,6 +417,31 @@ internal sealed class StationSimulationService return "water"; } + if (HasStationModules(station, "module_gen_prod_superfluidcoolant_01")) + { + return "superfluidcoolant"; + } + + if (HasStationModules(station, "module_gen_prod_quantumtubes_01")) + { + return "quantumtubes"; + } + + if (HasStationModules(station, "module_gen_prod_antimattercells_01")) + { + return "antimattercells"; + } + + if (HasStationModules(station, "module_gen_prod_siliconwafers_01")) + { + return "siliconwafers"; + } + + if (HasStationModules(station, "module_gen_prod_graphene_01")) + { + return "graphene"; + } + if (HasStationModules(station, "module_gen_prod_claytronics_01")) { return "claytronics"; diff --git a/apps/backend/Universe/Api/GetBalanceHandler.cs b/apps/backend/Universe/Api/GetBalanceHandler.cs new file mode 100644 index 0000000..90abbe0 --- /dev/null +++ b/apps/backend/Universe/Api/GetBalanceHandler.cs @@ -0,0 +1,16 @@ +using FastEndpoints; +using SpaceGame.Api.Universe.Simulation; + +namespace SpaceGame.Api.Universe.Api; + +public sealed class GetBalanceHandler(WorldService worldService) : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/api/balance"); + AllowAnonymous(); + } + + public override Task HandleAsync(CancellationToken cancellationToken) => + SendOkAsync(worldService.GetBalance(), cancellationToken); +} diff --git a/apps/backend/Universe/Api/GetTelemetryHandler.cs b/apps/backend/Universe/Api/GetTelemetryHandler.cs new file mode 100644 index 0000000..52afc8f --- /dev/null +++ b/apps/backend/Universe/Api/GetTelemetryHandler.cs @@ -0,0 +1,49 @@ +using System.Runtime.InteropServices; +using FastEndpoints; +using SpaceGame.Api.Universe.Simulation; + +namespace SpaceGame.Api.Universe.Api; + +public sealed class GetTelemetryHandler(TelemetryService telemetry, WorldService worldService) : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/api/telemetry"); + AllowAnonymous(); + } + + public override Task HandleAsync(CancellationToken cancellationToken) + { + var status = worldService.GetStatus(); + var connections = worldService.GetConnectionStats(); + var uptime = telemetry.Uptime; + + return SendOkAsync(new + { + process = new + { + uptimeSeconds = uptime.TotalSeconds, + cpuPercent = Math.Round(telemetry.CpuPercent, 1), + workingSetMb = Math.Round(telemetry.WorkingSetBytes / 1_048_576.0, 1), + gcMemoryMb = Math.Round(telemetry.GcMemoryBytes / 1_048_576.0, 1), + threadCount = telemetry.ThreadCount, + processorCount = Environment.ProcessorCount, + }, + simulation = new + { + sequence = status.Sequence, + connectedClients = connections.ConnectedClients, + deltaHistoryCount = connections.DeltaHistoryCount, + tickIntervalMs = 200, + }, + runtime = new + { + frameworkDescription = RuntimeInformation.FrameworkDescription, + osDescription = RuntimeInformation.OSDescription, + gcGen0 = GC.CollectionCount(0), + gcGen1 = GC.CollectionCount(1), + gcGen2 = GC.CollectionCount(2), + }, + }, cancellationToken); + } +} diff --git a/apps/backend/Universe/Api/UpdateBalanceHandler.cs b/apps/backend/Universe/Api/UpdateBalanceHandler.cs new file mode 100644 index 0000000..710be95 --- /dev/null +++ b/apps/backend/Universe/Api/UpdateBalanceHandler.cs @@ -0,0 +1,20 @@ +using FastEndpoints; +using SpaceGame.Api.Definitions; +using SpaceGame.Api.Universe.Simulation; + +namespace SpaceGame.Api.Universe.Api; + +public sealed class UpdateBalanceHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Put("/api/balance"); + AllowAnonymous(); + } + + public override Task HandleAsync(BalanceDefinition req, CancellationToken cancellationToken) + { + var applied = worldService.UpdateBalance(req); + return SendOkAsync(applied, cancellationToken); + } +} diff --git a/apps/backend/Universe/Runtime/SimulationWorld.cs b/apps/backend/Universe/Runtime/SimulationWorld.cs index 8edf39d..572e2c9 100644 --- a/apps/backend/Universe/Runtime/SimulationWorld.cs +++ b/apps/backend/Universe/Runtime/SimulationWorld.cs @@ -5,7 +5,7 @@ public sealed class SimulationWorld { public required string Label { get; init; } public required int Seed { get; init; } - public required BalanceDefinition Balance { get; init; } + public required BalanceDefinition Balance { get; set; } public required List Systems { get; init; } public required List Nodes { get; init; } public required List Celestials { get; init; } diff --git a/apps/backend/Universe/Scenario/SpatialBuilder.cs b/apps/backend/Universe/Scenario/SpatialBuilder.cs index 91d2f40..e760009 100644 --- a/apps/backend/Universe/Scenario/SpatialBuilder.cs +++ b/apps/backend/Universe/Scenario/SpatialBuilder.cs @@ -224,6 +224,23 @@ internal sealed class SpatialBuilder private static CelestialRuntime? ResolveResourceNodeAnchor(SystemSpatialGraph graph, ResourceNodeDefinition definition) { + if (!string.IsNullOrWhiteSpace(definition.AnchorReference)) + { + var anchorId = definition.AnchorReference.ToLowerInvariant() switch + { + var reference when reference.StartsWith("star-", StringComparison.Ordinal) + => $"node-{graph.SystemId}-{reference}", + var reference when reference.StartsWith("planet-", StringComparison.Ordinal) + => $"node-{graph.SystemId}-{reference}", + _ => null, + }; + + if (anchorId is not null) + { + return graph.Celestials.FirstOrDefault(c => string.Equals(c.Id, anchorId, StringComparison.Ordinal)); + } + } + if (definition.AnchorPlanetIndex is not int planetIndex || planetIndex < 0) { return null; diff --git a/apps/backend/Universe/Scenario/SystemGenerationService.cs b/apps/backend/Universe/Scenario/SystemGenerationService.cs index e489377..5dad0e3 100644 --- a/apps/backend/Universe/Scenario/SystemGenerationService.cs +++ b/apps/backend/Universe/Scenario/SystemGenerationService.cs @@ -9,7 +9,7 @@ internal sealed class SystemGenerationService internal List InjectSpecialSystems(IReadOnlyList authoredSystems) => authoredSystems - .Select(CloneSystemDefinition) + .Select((system, index) => EnsureStrategicResourceCoverage(CloneSystemDefinition(system), index)) .ToList(); internal List ExpandSystems( @@ -126,6 +126,7 @@ internal sealed class SystemGenerationService .Select(node => new ResourceNodeDefinition { SourceKind = node.SourceKind, + AnchorReference = node.AnchorReference, Angle = node.Angle, RadiusOffset = node.RadiusOffset, InclinationDegrees = node.InclinationDegrees, @@ -137,7 +138,7 @@ internal sealed class SystemGenerationService }) .ToList(); - return new SolarSystemDefinition + return EnsureStrategicResourceCoverage(new SolarSystemDefinition { Id = id, Label = label, @@ -161,7 +162,7 @@ internal sealed class SystemGenerationService }, ResourceNodes = resourceNodes, Planets = planets, - }; + }, generatedIndex + 1024); } private static SolarSystemDefinition CloneSystemDefinition(SolarSystemDefinition definition) @@ -182,6 +183,7 @@ internal sealed class SystemGenerationService ResourceNodes = definition.ResourceNodes.Select(node => new ResourceNodeDefinition { SourceKind = node.SourceKind, + AnchorReference = node.AnchorReference, Angle = node.Angle, RadiusOffset = node.RadiusOffset, InclinationDegrees = node.InclinationDegrees, @@ -223,6 +225,7 @@ internal sealed class SystemGenerationService nodes.AddRange(template.ResourceNodes.Select(node => new ResourceNodeDefinition { SourceKind = node.SourceKind, + AnchorReference = node.AnchorReference, Angle = node.Angle, RadiusOffset = node.RadiusOffset, InclinationDegrees = node.InclinationDegrees, @@ -234,10 +237,30 @@ internal sealed class SystemGenerationService })); } - nodes.AddRange(BuildAsteroidBeltNodes(generatedIndex, planets)); return nodes; } + private static SolarSystemDefinition EnsureStrategicResourceCoverage(SolarSystemDefinition system, int seed) + { + for (var index = 0; index < system.ResourceNodes.Count; index += 1) + { + system.ResourceNodes[index] = SanitizeResourceNode(system.ResourceNodes[index], system.Planets, seed, index); + } + + var requiredItems = new[] { "ore", "silicon", "ice", "hydrogen", "helium", "methane" }; + foreach (var itemId in requiredItems) + { + if (system.ResourceNodes.Any(node => string.Equals(node.ItemId, itemId, StringComparison.Ordinal))) + { + continue; + } + + system.ResourceNodes.Add(BuildStrategicResourceNode(itemId, system.Planets, seed, system.ResourceNodes.Count)); + } + + return system; + } + private static List BuildGalaxyPositions(IReadOnlyCollection occupiedPositions, int count) { var allPositions = occupiedPositions.ToList(); @@ -303,25 +326,124 @@ internal sealed class SystemGenerationService return $"gen-{ordinal}-{slug}"; } - private static IEnumerable BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList planets) + private static ResourceNodeDefinition BuildStrategicResourceNode( + string itemId, + IReadOnlyList planets, + int seed, + int ordinal) { - var nodeCount = 4 + (generatedIndex % 4); - var oreAmount = 1000f; - - for (var index = 0; index < nodeCount; index += 1) + var anchorPlanetIndex = ResolveStrategicResourceAnchorPlanetIndex(itemId, planets); + return new ResourceNodeDefinition { - yield return new ResourceNodeDefinition + SourceKind = "local-space", + AnchorReference = ResolveStrategicAnchorReference(itemId, planets, ordinal), + Angle = (MathF.PI * 2f * ((ordinal % 7) / 7f)) + Jitter(seed, 400 + ordinal, 0.35f), + RadiusOffset = 150000f + Jitter(seed, 460 + ordinal, 42000f), + InclinationDegrees = Jitter(seed, 520 + ordinal, 10f), + AnchorPlanetIndex = anchorPlanetIndex, + OreAmount = itemId switch { - SourceKind = "asteroid-belt", - Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f), - RadiusOffset = 120000f + Jitter(generatedIndex, 200 + index, 36000f), - InclinationDegrees = Jitter(generatedIndex, 280 + index, 12f), - AnchorPlanetIndex = ResolveAsteroidAnchorPlanetIndex(planets), - OreAmount = oreAmount, - ItemId = "ore", - ShardCount = 6 + (index % 4), - }; + "ore" => 12000f, + "silicon" => 10000f, + "ice" => 9000f, + _ => 8000f, + }, + ItemId = itemId, + ShardCount = itemId switch + { + "ore" or "silicon" or "ice" => 8, + _ => 6, + }, + }; + } + + private static ResourceNodeDefinition SanitizeResourceNode( + ResourceNodeDefinition node, + IReadOnlyList planets, + int seed, + int ordinal) + { + node.SourceKind = "local-space"; + node.AnchorReference ??= ResolveLegacyAnchorReference(node, planets, seed, ordinal); + return node; + } + + private static string ResolveLegacyAnchorReference( + ResourceNodeDefinition node, + IReadOnlyList planets, + int seed, + int ordinal) + { + if (node.AnchorMoonIndex is int moonIndex && node.AnchorPlanetIndex is int planetIndex && planetIndex >= 0) + { + return $"planet-{planetIndex + 1}-moon-{moonIndex + 1}"; } + + if (node.AnchorPlanetIndex is int anchoredPlanetIndex && anchoredPlanetIndex >= 0) + { + return $"planet-{anchoredPlanetIndex + 1}"; + } + + return ResolveStrategicAnchorReference(node.ItemId, planets, ordinal + seed); + } + + private static string ResolveStrategicAnchorReference(string itemId, IReadOnlyList planets, int ordinal) + { + if (itemId is "hydrogen" or "helium" or "methane") + { + var gasGiantIndex = planets + .Select((planet, index) => (planet, index)) + .FirstOrDefault(entry => entry.planet.PlanetType is "gas-giant" or "ice-giant") + .index; + return gasGiantIndex > 0 || (planets.Count > 0 && planets[0].PlanetType is "gas-giant" or "ice-giant") + ? $"planet-{gasGiantIndex + 1}" + : "star-1"; + } + + if (itemId == "ice") + { + var moonAnchor = planets + .Select((planet, index) => (planet, index)) + .FirstOrDefault(entry => entry.planet.Moons.Count > 0 && entry.planet.PlanetType is "ice" or "ice-giant" or "oceanic"); + if (moonAnchor.planet is not null && moonAnchor.planet.Moons.Count > 0) + { + return $"planet-{moonAnchor.index + 1}-moon-1"; + } + } + + var anchorPlanetIndex = ResolveStrategicResourceAnchorPlanetIndex(itemId, planets); + var lagrange = (ordinal % 3) switch + { + 0 => "l1", + 1 => "l4", + _ => "l5", + }; + return $"planet-{anchorPlanetIndex + 1}-{lagrange}"; + } + + private static int ResolveStrategicResourceAnchorPlanetIndex(string itemId, IReadOnlyList planets) + { + if (planets.Count == 0) + { + return 0; + } + + bool MatchesPlanetType(PlanetDefinition planet) => itemId switch + { + "hydrogen" or "helium" or "methane" => planet.PlanetType is "gas-giant" or "ice-giant", + "ice" => planet.PlanetType is "ice" or "ice-giant" or "oceanic", + _ => planet.PlanetType is not "gas-giant" and not "ice-giant", + }; + + for (var index = 0; index < planets.Count; index += 1) + { + if (MatchesPlanetType(planets[index])) + { + return index; + } + } + + return ResolveAsteroidAnchorPlanetIndex(planets); } private static int ResolveAsteroidAnchorPlanetIndex(IReadOnlyList planets) diff --git a/apps/backend/Universe/Scenario/WorldBuilder.cs b/apps/backend/Universe/Scenario/WorldBuilder.cs index 90687e4..a9c4a8a 100644 --- a/apps/backend/Universe/Scenario/WorldBuilder.cs +++ b/apps/backend/Universe/Scenario/WorldBuilder.cs @@ -34,7 +34,8 @@ internal sealed class WorldBuilder( systemsById, spatialLayout.SystemGraphs, spatialLayout.Celestials, - catalog.ModuleDefinitions); + catalog.ModuleDefinitions, + catalog.ItemDefinitions); seedingService.InitializeStationStockpiles(stations); var refinery = seedingService.SelectRefineryStation(stations, scenario); @@ -106,7 +107,8 @@ internal sealed class WorldBuilder( IReadOnlyDictionary systemsById, IReadOnlyDictionary systemGraphs, IReadOnlyCollection celestials, - IReadOnlyDictionary moduleDefinitions) + IReadOnlyDictionary moduleDefinitions, + IReadOnlyDictionary itemDefinitions) { var stations = new List(); var stationIdCounter = 0; @@ -136,9 +138,7 @@ internal sealed class WorldBuilder( stations.Add(station); placement.AnchorCelestial.OccupyingStructureId = station.Id; - var startingModules = plan.StartingModules.Count > 0 - ? plan.StartingModules - : ["module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01", "module_arg_stor_liquid_m_01"]; + var startingModules = BuildStartingModules(plan, moduleDefinitions, itemDefinitions); foreach (var moduleId in startingModules) { @@ -149,6 +149,91 @@ internal sealed class WorldBuilder( return stations; } + private static IReadOnlyList BuildStartingModules( + InitialStationDefinition plan, + IReadOnlyDictionary moduleDefinitions, + IReadOnlyDictionary itemDefinitions) + { + var startingModules = new List(plan.StartingModules.Count > 0 + ? plan.StartingModules + : ["module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_container_m_01"]); + + EnsureStartingModule(startingModules, "module_arg_dock_m_01_lowtech"); + + var objectiveModuleId = GetObjectiveStartingModuleId(plan.Objective); + if (!string.IsNullOrWhiteSpace(objectiveModuleId)) + { + EnsureStartingModule(startingModules, objectiveModuleId); + + if (!string.Equals(objectiveModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal)) + { + EnsureStartingModule(startingModules, "module_gen_prod_energycells_01"); + } + + foreach (var storageModuleId in GetRequiredStartingStorageModules(objectiveModuleId, moduleDefinitions, itemDefinitions)) + { + EnsureStartingModule(startingModules, storageModuleId); + } + } + + return startingModules; + } + + private static string? GetObjectiveStartingModuleId(string? objective) => + StationSimulationService.NormalizeStationObjective(objective) switch + { + "power" => "module_gen_prod_energycells_01", + "refinery" => "module_gen_ref_ore_01", + "graphene" => "module_gen_prod_graphene_01", + "siliconwafers" => "module_gen_prod_siliconwafers_01", + "hullparts" => "module_gen_prod_hullparts_01", + "claytronics" => "module_gen_prod_claytronics_01", + "quantumtubes" => "module_gen_prod_quantumtubes_01", + "antimattercells" => "module_gen_prod_antimattercells_01", + "superfluidcoolant" => "module_gen_prod_superfluidcoolant_01", + "water" => "module_gen_prod_water_01", + _ => null, + }; + + private static IEnumerable GetRequiredStartingStorageModules( + string moduleId, + IReadOnlyDictionary moduleDefinitions, + IReadOnlyDictionary itemDefinitions) + { + if (!moduleDefinitions.TryGetValue(moduleId, out var moduleDefinition)) + { + yield break; + } + + foreach (var wareId in moduleDefinition.Production + .SelectMany(production => production.Wares.Select(ware => ware.ItemId)) + .Concat(moduleDefinition.Products) + .Distinct(StringComparer.Ordinal)) + { + if (!itemDefinitions.TryGetValue(wareId, out var itemDefinition)) + { + continue; + } + + var storageModuleId = itemDefinition.CargoKind switch + { + "solid" => "module_arg_stor_solid_m_01", + "liquid" => "module_arg_stor_liquid_m_01", + _ => "module_arg_stor_container_m_01", + }; + + yield return storageModuleId; + } + } + + private static void EnsureStartingModule(List modules, string moduleId) + { + if (!modules.Contains(moduleId, StringComparer.Ordinal)) + { + modules.Add(moduleId); + } + } + private static Dictionary> BuildPatrolRoutes( ScenarioDefinition scenario, IReadOnlyDictionary systemsById) diff --git a/apps/backend/Universe/Scenario/WorldSeedingService.cs b/apps/backend/Universe/Scenario/WorldSeedingService.cs index 6af6731..2316956 100644 --- a/apps/backend/Universe/Scenario/WorldSeedingService.cs +++ b/apps/backend/Universe/Scenario/WorldSeedingService.cs @@ -65,33 +65,6 @@ internal sealed class WorldSeedingService foreach (var station in stations) { InitializeStationPopulation(station); - if (station.InstalledModules.Contains("module_gen_prod_energycells_01", StringComparer.Ordinal)) - { - station.Inventory["energycells"] = MathF.Max(GetInventoryAmount(station.Inventory, "energycells"), 240f); - } - - if (station.InstalledModules.Contains("module_gen_prod_refinedmetals_01", StringComparer.Ordinal)) - { - station.Inventory["ore"] = MathF.Max(GetInventoryAmount(station.Inventory, "ore"), 220f); - } - - if (station.InstalledModules.Contains("module_gen_prod_hullparts_01", StringComparer.Ordinal)) - { - station.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(station.Inventory, "refinedmetals"), 240f); - station.Inventory["graphene"] = MathF.Max(GetInventoryAmount(station.Inventory, "graphene"), 80f); - } - - if (station.InstalledModules.Contains("module_gen_prod_claytronics_01", StringComparer.Ordinal)) - { - station.Inventory["antimattercells"] = MathF.Max(GetInventoryAmount(station.Inventory, "antimattercells"), 90f); - station.Inventory["microchips"] = MathF.Max(GetInventoryAmount(station.Inventory, "microchips"), 120f); - station.Inventory["quantumtubes"] = MathF.Max(GetInventoryAmount(station.Inventory, "quantumtubes"), 90f); - } - - if (station.Population > 0f) - { - station.Inventory["water"] = MathF.Max(60f, station.Population * 1.5f); - } } } @@ -145,6 +118,11 @@ internal sealed class WorldSeedingService foreach (var station in world.Stations) { + if (HasSatisfiedStarterObjectiveLayout(world, station)) + { + continue; + } + var moduleId = InfrastructureSimulationService.GetNextStationModuleToBuild(station, world); if (moduleId is null || station.CelestialId is null) { @@ -200,6 +178,78 @@ internal sealed class WorldSeedingService return (sites, orders); } + private static bool HasSatisfiedStarterObjectiveLayout(SimulationWorld world, StationRuntime station) + { + var role = StationSimulationService.DetermineStationRole(station); + var objectiveModuleId = role switch + { + "power" => "module_gen_prod_energycells_01", + "refinery" => "module_gen_prod_refinedmetals_01", + "graphene" => "module_gen_prod_graphene_01", + "siliconwafers" => "module_gen_prod_siliconwafers_01", + "hullparts" => "module_gen_prod_hullparts_01", + "claytronics" => "module_gen_prod_claytronics_01", + "quantumtubes" => "module_gen_prod_quantumtubes_01", + "antimattercells" => "module_gen_prod_antimattercells_01", + "superfluidcoolant" => "module_gen_prod_superfluidcoolant_01", + "water" => "module_gen_prod_water_01", + _ => null, + }; + + if (objectiveModuleId is null) + { + return false; + } + + if (!station.InstalledModules.Contains("module_arg_dock_m_01_lowtech", StringComparer.Ordinal) + || !station.InstalledModules.Contains(objectiveModuleId, StringComparer.Ordinal)) + { + return false; + } + + if (!string.Equals(objectiveModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal) + && !station.InstalledModules.Contains("module_gen_prod_energycells_01", StringComparer.Ordinal)) + { + return false; + } + + foreach (var storageModuleId in GetRequiredStorageModulesForInstalledObjective(world, objectiveModuleId)) + { + if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal)) + { + return false; + } + } + + return true; + } + + private static IEnumerable GetRequiredStorageModulesForInstalledObjective(SimulationWorld world, string moduleId) + { + if (!world.ModuleDefinitions.TryGetValue(moduleId, out var moduleDefinition)) + { + yield break; + } + + foreach (var wareId in moduleDefinition.Production + .SelectMany(production => production.Wares.Select(ware => ware.ItemId)) + .Concat(moduleDefinition.Products) + .Distinct(StringComparer.Ordinal)) + { + if (!world.ItemDefinitions.TryGetValue(wareId, out var itemDefinition)) + { + continue; + } + + yield return itemDefinition.CargoKind switch + { + "solid" => "module_arg_stor_solid_m_01", + "liquid" => "module_arg_stor_liquid_m_01", + _ => "module_arg_stor_container_m_01", + }; + } + } + internal List CreatePolicies(IReadOnlyCollection factions) { var policies = new List(factions.Count); @@ -385,6 +435,13 @@ internal sealed class WorldSeedingService Color = "#ff8f70", Credits = MinimumFactionCredits, }, + "nadir-syndicate" => new FactionRuntime + { + Id = factionId, + Label = "Nadir Syndicate", + Color = "#91e6a8", + Credits = MinimumFactionCredits, + }, _ => new FactionRuntime { Id = factionId, diff --git a/apps/backend/Universe/Simulation/TelemetryService.cs b/apps/backend/Universe/Simulation/TelemetryService.cs new file mode 100644 index 0000000..1ed198f --- /dev/null +++ b/apps/backend/Universe/Simulation/TelemetryService.cs @@ -0,0 +1,44 @@ +using System.Diagnostics; + +namespace SpaceGame.Api.Universe.Simulation; + +public sealed class TelemetryService : IDisposable +{ + private readonly Process _process = Process.GetCurrentProcess(); + private readonly Timer _timer; + private double _cpuPercent; + private DateTime _lastSampleTime; + private TimeSpan _lastCpuTime; + + public TelemetryService() + { + _process.Refresh(); + _lastSampleTime = DateTime.UtcNow; + _lastCpuTime = _process.TotalProcessorTime; + _timer = new Timer(Sample, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); + } + + private void Sample(object? _) + { + _process.Refresh(); + var now = DateTime.UtcNow; + var cpu = _process.TotalProcessorTime; + var elapsed = (now - _lastSampleTime).TotalSeconds; + var cpuUsed = (cpu - _lastCpuTime).TotalSeconds; + Volatile.Write(ref _cpuPercent, elapsed > 0 ? cpuUsed / elapsed / Environment.ProcessorCount * 100.0 : 0); + _lastSampleTime = now; + _lastCpuTime = cpu; + } + + public double CpuPercent => Volatile.Read(ref _cpuPercent); + public long WorkingSetBytes => _process.WorkingSet64; + public long GcMemoryBytes => GC.GetTotalMemory(false); + public int ThreadCount => _process.Threads.Count; + public TimeSpan Uptime => DateTime.UtcNow - _process.StartTime.ToUniversalTime(); + + public void Dispose() + { + _timer.Dispose(); + _process.Dispose(); + } +} diff --git a/apps/backend/Universe/Simulation/WorldService.cs b/apps/backend/Universe/Simulation/WorldService.cs index b10ce6c..a09be0f 100644 --- a/apps/backend/Universe/Simulation/WorldService.cs +++ b/apps/backend/Universe/Simulation/WorldService.cs @@ -18,6 +18,7 @@ public sealed class WorldService( private readonly Queue _history = []; private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load(); private long _sequence; + private BalanceDefinition? _balanceOverride; public WorldSnapshot GetSnapshot() { @@ -35,6 +36,44 @@ public sealed class WorldService( } } + public (int ConnectedClients, int DeltaHistoryCount) GetConnectionStats() + { + lock (_sync) + { + return (_subscribers.Count, _history.Count); + } + } + + public BalanceDefinition GetBalance() + { + lock (_sync) + { + var b = _world.Balance; + return new BalanceDefinition + { + SimulationSpeedMultiplier = b.SimulationSpeedMultiplier, + YPlane = b.YPlane, + ArrivalThreshold = b.ArrivalThreshold, + MiningRate = b.MiningRate, + MiningCycleSeconds = b.MiningCycleSeconds, + TransferRate = b.TransferRate, + DockingDuration = b.DockingDuration, + UndockingDuration = b.UndockingDuration, + UndockDistance = b.UndockDistance, + }; + } + } + + public BalanceDefinition UpdateBalance(BalanceDefinition balance) + { + lock (_sync) + { + _balanceOverride = SanitizeBalance(balance); + ApplyBalance(_world, _balanceOverride); + return GetBalance(); + } + } + public ChannelReader Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken) { var channel = Channel.CreateUnbounded(new UnboundedChannelOptions @@ -96,6 +135,10 @@ public sealed class WorldService( lock (_sync) { _world = _loader.Load(); + if (_balanceOverride is not null) + { + ApplyBalance(_world, _balanceOverride); + } _sequence += 1; _history.Clear(); @@ -127,6 +170,39 @@ public sealed class WorldService( } } + private static void ApplyBalance(SimulationWorld world, BalanceDefinition balance) => + world.Balance = new BalanceDefinition + { + SimulationSpeedMultiplier = balance.SimulationSpeedMultiplier, + YPlane = balance.YPlane, + ArrivalThreshold = balance.ArrivalThreshold, + MiningRate = balance.MiningRate, + MiningCycleSeconds = balance.MiningCycleSeconds, + TransferRate = balance.TransferRate, + DockingDuration = balance.DockingDuration, + UndockingDuration = balance.UndockingDuration, + UndockDistance = balance.UndockDistance, + }; + + private static BalanceDefinition SanitizeBalance(BalanceDefinition candidate) + { + static float finiteOr(float value, float fallback) => + float.IsFinite(value) ? value : fallback; + + return new BalanceDefinition + { + SimulationSpeedMultiplier = MathF.Max(0.01f, finiteOr(candidate.SimulationSpeedMultiplier, 1f)), + YPlane = MathF.Max(0f, finiteOr(candidate.YPlane, 0f)), + ArrivalThreshold = MathF.Max(0.1f, finiteOr(candidate.ArrivalThreshold, 16f)), + MiningRate = MathF.Max(0f, finiteOr(candidate.MiningRate, 10f)), + MiningCycleSeconds = MathF.Max(0.1f, finiteOr(candidate.MiningCycleSeconds, 10f)), + TransferRate = MathF.Max(0f, finiteOr(candidate.TransferRate, 56f)), + DockingDuration = MathF.Max(0.1f, finiteOr(candidate.DockingDuration, 1.2f)), + UndockingDuration = MathF.Max(0.1f, finiteOr(candidate.UndockingDuration, 1.2f)), + UndockDistance = MathF.Max(0f, finiteOr(candidate.UndockDistance, 42f)), + }; + } + private static bool HasMeaningfulDelta(WorldDelta delta) => delta.RequiresSnapshotRefresh || delta.Events.Count > 0 diff --git a/apps/backend/appsettings.Development.json b/apps/backend/appsettings.Development.json index cdf8062..572643a 100644 --- a/apps/backend/appsettings.Development.json +++ b/apps/backend/appsettings.Development.json @@ -6,7 +6,7 @@ } }, "WorldGeneration": { - "TargetSystemCount": 3, + "TargetSystemCount": 10, "IncludeSolSystem": true }, "OrbitalSimulation": { diff --git a/apps/viewer/src/App.vue b/apps/viewer/src/App.vue index a6c2742..2a9a570 100644 --- a/apps/viewer/src/App.vue +++ b/apps/viewer/src/App.vue @@ -6,6 +6,8 @@ import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue"; import HtmlInfoPanel from "./components/HtmlInfoPanel.vue"; import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue"; import GmOpsWindow from "./components/gm/GmOpsWindow.vue"; +import GmTelemetryWindow from "./components/gm/GmTelemetryWindow.vue"; +import GmSettingsWindow from "./components/gm/GmSettingsWindow.vue"; import { createViewerHudState } from "./viewerHudState"; import { useViewerSelectionStore } from "./ui/stores/viewerSelection"; import type { Selectable } from "./viewerTypes"; @@ -22,6 +24,9 @@ const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore); let viewer: GameViewer | undefined; const gmOpsOpen = ref(false); +const gmTelemetryOpen = ref(false); +const gmSettingsOpen = ref(false); +const gmMenuOpen = ref(false); onMounted(async () => { await nextTick(); @@ -145,19 +150,56 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic /> - +
+
+ + + +
+ +
+ +
; +} + +export async function fetchBalance(signal?: AbortSignal) { + const response = await fetch("/api/balance", { signal }); + if (!response.ok) { + throw new Error(`Balance request failed with ${response.status}`); + } + return response.json() as Promise; +} + +export async function updateBalance(settings: BalanceSettings) { + const response = await fetch("/api/balance", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }); + if (!response.ok) { + throw new Error(`Balance update failed with ${response.status}`); + } + return response.json() as Promise; +} + export async function resetWorld() { const response = await fetch("/api/world/reset", { method: "POST", diff --git a/apps/viewer/src/components/gm/GmOpsWindow.vue b/apps/viewer/src/components/gm/GmOpsWindow.vue index 923462d..b3dd00a 100644 --- a/apps/viewer/src/components/gm/GmOpsWindow.vue +++ b/apps/viewer/src/components/gm/GmOpsWindow.vue @@ -1,5 +1,5 @@ diff --git a/apps/viewer/src/components/gm/GmSettingsWindow.vue b/apps/viewer/src/components/gm/GmSettingsWindow.vue new file mode 100644 index 0000000..0b5df9e --- /dev/null +++ b/apps/viewer/src/components/gm/GmSettingsWindow.vue @@ -0,0 +1,134 @@ + + + diff --git a/apps/viewer/src/components/gm/GmTelemetryWindow.vue b/apps/viewer/src/components/gm/GmTelemetryWindow.vue new file mode 100644 index 0000000..137cbb3 --- /dev/null +++ b/apps/viewer/src/components/gm/GmTelemetryWindow.vue @@ -0,0 +1,166 @@ + + + diff --git a/apps/viewer/src/contractsBalance.ts b/apps/viewer/src/contractsBalance.ts new file mode 100644 index 0000000..492732e --- /dev/null +++ b/apps/viewer/src/contractsBalance.ts @@ -0,0 +1,11 @@ +export interface BalanceSettings { + simulationSpeedMultiplier: number; + yPlane: number; + arrivalThreshold: number; + miningRate: number; + miningCycleSeconds: number; + transferRate: number; + dockingDuration: number; + undockingDuration: number; + undockDistance: number; +} diff --git a/apps/viewer/src/contractsTelemetry.ts b/apps/viewer/src/contractsTelemetry.ts new file mode 100644 index 0000000..5f2f447 --- /dev/null +++ b/apps/viewer/src/contractsTelemetry.ts @@ -0,0 +1,23 @@ +export interface TelemetrySnapshot { + process: { + uptimeSeconds: number; + cpuPercent: number; + workingSetMb: number; + gcMemoryMb: number; + threadCount: number; + processorCount: number; + }; + simulation: { + sequence: number; + connectedClients: number; + deltaHistoryCount: number; + tickIntervalMs: number; + }; + runtime: { + frameworkDescription: string; + osDescription: string; + gcGen0: number; + gcGen1: number; + gcGen2: number; + }; +} diff --git a/apps/viewer/src/styles/viewer.css b/apps/viewer/src/styles/viewer.css index 7f68e37..0df80b3 100644 --- a/apps/viewer/src/styles/viewer.css +++ b/apps/viewer/src/styles/viewer.css @@ -539,28 +539,326 @@ canvas { box-shadow: inset 2px 0 0 var(--viewer-accent); } -.gm-console-toggle { +.gm-launcher { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); pointer-events: auto; - z-index: 100; - padding: 7px 18px; + z-index: 300; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.gm-launcher-trigger { + padding: 7px 24px; border-radius: 999px; background: rgba(127, 214, 255, 0.1); border: 1px solid rgba(127, 214, 255, 0.24); color: var(--viewer-accent); font-family: "IBM Plex Mono", monospace; font-size: 0.72rem; - letter-spacing: 0.12em; + font-weight: 700; + letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer; transition: background 140ms ease, border-color 140ms ease; backdrop-filter: blur(8px); } -.gm-console-toggle:hover { +.gm-launcher-trigger:hover, +.gm-launcher-trigger--open { background: rgba(127, 214, 255, 0.18); border-color: rgba(127, 214, 255, 0.4); } + +.gm-launcher-menu { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + backdrop-filter: blur(12px); + background: rgba(7, 14, 27, 0.88); + border: 1px solid rgba(132, 196, 255, 0.18); + border-radius: 10px; + padding: 6px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.gm-launcher-item { + padding: 6px 16px; + border-radius: 6px; + background: transparent; + border: 1px solid transparent; + color: var(--viewer-muted); + font-family: "IBM Plex Mono", monospace; + font-size: 0.7rem; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + text-align: left; + transition: background 100ms ease, color 100ms ease, border-color 100ms ease; + white-space: nowrap; +} + +.gm-launcher-item:hover { + background: rgba(127, 214, 255, 0.08); + color: var(--viewer-text); +} + +.gm-launcher-item--active { + background: rgba(127, 214, 255, 0.12); + border-color: rgba(127, 214, 255, 0.22); + color: var(--viewer-accent); +} + +.gm-orders-trigger { + cursor: default; + border-bottom: 1px dashed rgba(127, 214, 255, 0.4); +} + +.gm-orders-tooltip { + position: fixed; + z-index: 9999; + pointer-events: none; + backdrop-filter: blur(12px); + background: rgba(7, 14, 27, 0.95); + border: 1px solid rgba(132, 196, 255, 0.22); + border-radius: 4px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + padding: 6px 0; + font-family: "IBM Plex Mono", monospace; + font-size: 0.68rem; + color: var(--viewer-muted); + max-width: 560px; +} + +.gm-orders-tooltip-table { + border-collapse: collapse; + width: 100%; +} + +.gm-orders-tooltip-table th { + color: var(--viewer-accent); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + font-size: 0.62rem; + padding: 2px 12px 4px; + text-align: left; + border-bottom: 1px solid rgba(132, 196, 255, 0.14); + white-space: nowrap; +} + +.gm-orders-tooltip-table td { + padding: 3px 12px; + white-space: nowrap; +} + +.gm-orders-tooltip-table tr:nth-child(even) td { + background: rgba(127, 214, 255, 0.03); +} + +/* ── GM Telemetry Window ─────────────────────────────────────────────────── */ + +.gm-telemetry { + font-family: "IBM Plex Mono", monospace; + font-size: 0.72rem; + color: var(--viewer-muted); +} + +.gm-telemetry-error { + background: rgba(255, 80, 60, 0.12); + border: 1px solid rgba(255, 80, 60, 0.22); + color: rgba(255, 160, 140, 0.9); +} + +.gm-telemetry-section-title { + color: var(--viewer-accent); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + border-bottom: 1px solid rgba(132, 196, 255, 0.12); + padding-bottom: 4px; +} + +.gm-telemetry-label { + color: var(--viewer-muted); + white-space: nowrap; + opacity: 0.7; +} + +.gm-telemetry-value { + color: var(--viewer-text); + font-size: 0.72rem; +} + +.gm-telemetry-dim { + color: var(--viewer-muted); + font-size: 0.65rem; + opacity: 0.6; +} + +.gm-telemetry-bar-track { + display: block; + height: 4px; + border-radius: 2px; + background: rgba(127, 214, 255, 0.08); + overflow: hidden; + min-width: 60px; +} + +.gm-telemetry-bar { + display: block; + height: 100%; + border-radius: 2px; + transition: width 600ms ease; +} + +.gm-telemetry-bar--low { + background: rgba(127, 214, 255, 0.6); +} + +.gm-telemetry-bar--mid { + background: rgba(255, 191, 105, 0.7); +} + +.gm-telemetry-bar--high { + background: rgba(255, 80, 60, 0.75); +} + +.gm-telemetry-clients-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(127, 214, 255, 0.2); + margin-right: 4px; + vertical-align: middle; + position: relative; + top: -1px; +} + +.gm-telemetry-clients-dot--active { + background: rgba(100, 220, 130, 0.8); + box-shadow: 0 0 4px rgba(100, 220, 130, 0.5); +} + +/* ── GM Settings Window ──────────────────────────────────────────────────── */ + +.gm-settings { + font-family: "IBM Plex Mono", monospace; + font-size: 0.72rem; + color: var(--viewer-muted); +} + +.gm-settings-body { + scrollbar-width: thin; + scrollbar-color: rgba(127, 214, 255, 0.2) transparent; +} + +.gm-settings-section-title { + color: var(--viewer-accent); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + border-bottom: 1px solid rgba(132, 196, 255, 0.12); + padding-bottom: 4px; +} + +.gm-settings-grid { + display: grid; + grid-template-columns: 180px 1fr; + gap: 0; +} + +.gm-settings-label { + display: flex; + align-items: center; + padding: 6px 12px 6px 0; + color: var(--viewer-muted); + font-size: 0.7rem; + border-bottom: 1px solid rgba(132, 196, 255, 0.06); + cursor: default; +} + +.gm-settings-field-group { + display: flex; + flex-direction: column; + justify-content: center; + gap: 1px; + padding: 5px 0; + border-bottom: 1px solid rgba(132, 196, 255, 0.06); +} + +.gm-settings-input { + background: rgba(127, 214, 255, 0.05); + border: 1px solid rgba(132, 196, 255, 0.16); + border-radius: 4px; + color: var(--viewer-text); + font-family: "IBM Plex Mono", monospace; + font-size: 0.72rem; + outline: none; + padding: 3px 8px; + width: 120px; + transition: border-color 120ms ease, background 120ms ease; +} + +.gm-settings-input:focus { + border-color: rgba(127, 214, 255, 0.4); + background: rgba(127, 214, 255, 0.08); +} + +.gm-settings-desc { + color: var(--viewer-muted); + font-size: 0.62rem; + opacity: 0.5; +} + +.gm-settings-footer { + border-top: 1px solid rgba(132, 196, 255, 0.1); + background: rgba(127, 214, 255, 0.02); +} + +.gm-settings-error { + background: rgba(255, 80, 60, 0.12); + border: 1px solid rgba(255, 80, 60, 0.22); + color: rgba(255, 160, 140, 0.9); +} + +.gm-settings-error-inline { + color: rgba(255, 140, 120, 0.9); +} + +.gm-settings-saved { + color: rgba(100, 220, 130, 0.85); +} + +.gm-settings-save-btn { + padding: 5px 18px; + border-radius: 6px; + background: rgba(127, 214, 255, 0.12); + border: 1px solid rgba(127, 214, 255, 0.28); + color: var(--viewer-accent); + font-family: "IBM Plex Mono", monospace; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} + +.gm-settings-save-btn:hover:not(:disabled) { + background: rgba(127, 214, 255, 0.2); + border-color: rgba(127, 214, 255, 0.45); +} + +.gm-settings-save-btn:disabled { + opacity: 0.45; + cursor: default; +} diff --git a/apps/viewer/src/ui/stores/gmStore.ts b/apps/viewer/src/ui/stores/gmStore.ts index e97d3a0..2f20bf6 100644 --- a/apps/viewer/src/ui/stores/gmStore.ts +++ b/apps/viewer/src/ui/stores/gmStore.ts @@ -2,22 +2,26 @@ import { defineStore } from "pinia"; import type { ShipSnapshot } from "../../contractsShips"; import type { StationSnapshot } from "../../contractsInfrastructure"; import type { FactionSnapshot } from "../../contractsFactions"; +import type { MarketOrderSnapshot } from "../../contractsEconomy"; export const useGmStore = defineStore("gm", { state: () => ({ ships: [] as ShipSnapshot[], stations: [] as StationSnapshot[], factions: [] as FactionSnapshot[], + marketOrders: [] as MarketOrderSnapshot[], }), actions: { updateWorld( ships: ShipSnapshot[], stations: StationSnapshot[], factions: FactionSnapshot[], + marketOrders: MarketOrderSnapshot[], ) { this.ships = ships; this.stations = stations; this.factions = factions; + this.marketOrders = marketOrders; }, }, }); diff --git a/apps/viewer/src/viewerPanels.ts b/apps/viewer/src/viewerPanels.ts index d8e0f9c..16b83b6 100644 --- a/apps/viewer/src/viewerPanels.ts +++ b/apps/viewer/src/viewerPanels.ts @@ -11,6 +11,41 @@ import itemsData from "../../../shared/data/items.json"; const moduleNameById = new Map( (modulesData as { id: string; name: string }[]).map((m) => [m.id, m.name]), ); +const moduleProductionById = new Map( + (modulesData as { id: string; product?: string[] }[]) + .flatMap((module) => { + const productItemId = module.product?.[0]; + if (!productItemId) { + return []; + } + + const item = (itemsData as { + id: string; + production?: { time: number; amount: number; method?: string; wares?: { ware?: string; itemId?: string; amount: number }[] }[]; + }[]).find((candidate) => candidate.id === productItemId); + const production = item?.production?.find((recipe) => (recipe.method ?? "default") === "default") + ?? item?.production?.[0]; + if (!production) { + return []; + } + + return [[module.id, { + cycleSeconds: production.time, + inputs: (production.wares ?? []).map((ware) => ({ + itemId: ware.ware ?? ware.itemId ?? "unknown", + amount: ware.amount, + })), + outputs: [{ + itemId: productItemId, + amount: production.amount, + }], + }] as const]; + }), +); const itemTransportById = new Map( (itemsData as { id: string; transport: string }[]).map((item) => [item.id, item.transport]), ); @@ -62,6 +97,23 @@ function renderProgressBar(progress: number): string { return `
`; } +function buildStaticModuleTooltip(world: WorldState, stationId: string, moduleId: string): string | null { + const production = moduleProductionById.get(moduleId); + if (!production) { + return null; + } + + const stationInventory = world.stations.get(stationId)?.inventory ?? []; + const inputLines = production.inputs + .map((entry) => ` ${entry.itemId}: ${entry.amount.toFixed(0)} required / ${inventoryAmount(stationInventory, entry.itemId).toFixed(0)} available`) + .join("\n"); + const outputLines = production.outputs + .map((entry) => ` ${entry.itemId}: ${entry.amount.toFixed(0)}`) + .join("\n"); + + return `Cycle: ${formatDuration(production.cycleSeconds)}\nInputs:\n${inputLines || " none"}\nOutputs:\n${outputLines || " none"}`; +} + function formatModuleListWithConstruction( world: WorldState, stationId: string, @@ -91,7 +143,10 @@ function formatModuleListWithConstruction( renderedProcessCount.set(moduleId, processIndex + 1); const moduleName = moduleNameById.get(moduleId) ?? moduleId; if (!process) { - return moduleName; + const tooltip = buildStaticModuleTooltip(world, stationId, moduleId); + return tooltip + ? `
${moduleName}idle
` + : `
${moduleName}
`; } const inputLines = process.inputs.map((e) => ` ${e.itemId}: ${e.amount.toFixed(0)}`).join("\n"); diff --git a/apps/viewer/src/viewerWorldLifecycle.ts b/apps/viewer/src/viewerWorldLifecycle.ts index f7d0a4f..2f82717 100644 --- a/apps/viewer/src/viewerWorldLifecycle.ts +++ b/apps/viewer/src/viewerWorldLifecycle.ts @@ -204,6 +204,7 @@ export class ViewerWorldLifecycle { [...world.ships.values()], [...world.stations.values()], [...world.factions.values()], + [...world.marketOrders.values()], ); } } diff --git a/shared/data/balance.json b/shared/data/balance.json index 9d635a7..8293647 100644 --- a/shared/data/balance.json +++ b/shared/data/balance.json @@ -1,4 +1,5 @@ { + "simulationSpeedMultiplier": 1.5, "yPlane": 4, "arrivalThreshold": 16, "miningRate": 10, diff --git a/shared/data/scenario.json b/shared/data/scenario.json index 05ce882..71d0b80 100644 --- a/shared/data/scenario.json +++ b/shared/data/scenario.json @@ -15,6 +15,22 @@ "planetIndex": 1, "lagrangeSide": -1 }, + { + "label": "Dominion Refinery", + "color": "#7ed4ff", + "objective": "refinery", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_solid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_refinedmetals_01" + ], + "systemId": "helios", + "factionId": "sol-dominion", + "planetIndex": 2, + "lagrangeSide": -1 + }, { "label": "Dominion Hullworks", "color": "#7ed4ff", @@ -27,8 +43,8 @@ ], "systemId": "helios", "factionId": "sol-dominion", - "planetIndex": 2, - "lagrangeSide": -1 + "planetIndex": 0, + "lagrangeSide": 1 }, { "label": "Dominion Clay Grid", @@ -42,7 +58,102 @@ ], "systemId": "helios", "factionId": "sol-dominion", + "planetIndex": 2, + "lagrangeSide": null + }, + { + "label": "Dominion Quantum Yard", + "color": "#7ed4ff", + "objective": "quantumtubes", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_quantumtubes_01" + ], + "systemId": "helios", + "factionId": "sol-dominion", + "planetIndex": 1, + "lagrangeSide": null + }, + { + "label": "Dominion Graphene Array", + "color": "#7ed4ff", + "objective": "graphene", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_liquid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_graphene_01" + ], + "systemId": "helios", + "factionId": "sol-dominion", "planetIndex": 0, + "lagrangeSide": -1 + }, + { + "label": "Dominion Wafer Foundry", + "color": "#7ed4ff", + "objective": "siliconwafers", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_solid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_siliconwafers_01" + ], + "systemId": "helios", + "factionId": "sol-dominion", + "planetIndex": 1, + "lagrangeSide": 1 + }, + { + "label": "Dominion Antimatter Forge", + "color": "#7ed4ff", + "objective": "antimattercells", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_liquid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_antimattercells_01" + ], + "systemId": "helios", + "factionId": "sol-dominion", + "planetIndex": 2, + "lagrangeSide": 1 + }, + { + "label": "Dominion Coolant Loop", + "color": "#7ed4ff", + "objective": "superfluidcoolant", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_liquid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_superfluidcoolant_01" + ], + "systemId": "helios", + "factionId": "sol-dominion", + "planetIndex": 3, + "lagrangeSide": -1 + }, + { + "label": "Dominion Hydro Plant", + "color": "#7ed4ff", + "objective": "water", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_solid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_water_01" + ], + "systemId": "helios", + "factionId": "sol-dominion", + "planetIndex": 3, "lagrangeSide": 1 }, { @@ -60,6 +171,22 @@ "planetIndex": 1, "lagrangeSide": 1 }, + { + "label": "League Refinery", + "color": "#ff8f70", + "objective": "refinery", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_solid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_refinedmetals_01" + ], + "systemId": "sol", + "factionId": "asterion-league", + "planetIndex": 2, + "lagrangeSide": 1 + }, { "label": "League Hullworks", "color": "#ff8f70", @@ -72,8 +199,8 @@ ], "systemId": "sol", "factionId": "asterion-league", - "planetIndex": 2, - "lagrangeSide": 1 + "planetIndex": 3, + "lagrangeSide": -1 }, { "label": "League Clay Grid", @@ -87,8 +214,259 @@ ], "systemId": "sol", "factionId": "asterion-league", + "planetIndex": 2, + "lagrangeSide": null + }, + { + "label": "League Quantum Yard", + "color": "#ff8f70", + "objective": "quantumtubes", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_quantumtubes_01" + ], + "systemId": "sol", + "factionId": "asterion-league", + "planetIndex": 1, + "lagrangeSide": null + }, + { + "label": "League Graphene Array", + "color": "#ff8f70", + "objective": "graphene", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_liquid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_graphene_01" + ], + "systemId": "sol", + "factionId": "asterion-league", + "planetIndex": 0, + "lagrangeSide": -1 + }, + { + "label": "League Wafer Foundry", + "color": "#ff8f70", + "objective": "siliconwafers", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_solid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_siliconwafers_01" + ], + "systemId": "sol", + "factionId": "asterion-league", + "planetIndex": 0, + "lagrangeSide": 1 + }, + { + "label": "League Antimatter Forge", + "color": "#ff8f70", + "objective": "antimattercells", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_liquid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_antimattercells_01" + ], + "systemId": "sol", + "factionId": "asterion-league", + "planetIndex": 1, + "lagrangeSide": -1 + }, + { + "label": "League Coolant Loop", + "color": "#ff8f70", + "objective": "superfluidcoolant", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_liquid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_superfluidcoolant_01" + ], + "systemId": "sol", + "factionId": "asterion-league", + "planetIndex": 2, + "lagrangeSide": -1 + }, + { + "label": "League Hydro Plant", + "color": "#ff8f70", + "objective": "water", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_solid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_water_01" + ], + "systemId": "sol", + "factionId": "asterion-league", + "planetIndex": 3, + "lagrangeSide": 1 + }, + { + "label": "Syndicate Power Relay", + "color": "#91e6a8", + "objective": "power", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_liquid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01" + ], + "systemId": "perseus", + "factionId": "nadir-syndicate", + "planetIndex": 1, + "lagrangeSide": -1 + }, + { + "label": "Syndicate Refinery", + "color": "#91e6a8", + "objective": "refinery", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_solid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_refinedmetals_01" + ], + "systemId": "perseus", + "factionId": "nadir-syndicate", + "planetIndex": 2, + "lagrangeSide": -1 + }, + { + "label": "Syndicate Hullworks", + "color": "#91e6a8", + "objective": "hullparts", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_hullparts_01" + ], + "systemId": "perseus", + "factionId": "nadir-syndicate", + "planetIndex": 0, + "lagrangeSide": 1 + }, + { + "label": "Syndicate Clay Grid", + "color": "#91e6a8", + "objective": "claytronics", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_claytronics_01" + ], + "systemId": "perseus", + "factionId": "nadir-syndicate", + "planetIndex": 2, + "lagrangeSide": null + }, + { + "label": "Syndicate Quantum Yard", + "color": "#91e6a8", + "objective": "quantumtubes", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_quantumtubes_01" + ], + "systemId": "perseus", + "factionId": "nadir-syndicate", + "planetIndex": 1, + "lagrangeSide": null + }, + { + "label": "Syndicate Graphene Array", + "color": "#91e6a8", + "objective": "graphene", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_liquid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_graphene_01" + ], + "systemId": "perseus", + "factionId": "nadir-syndicate", + "planetIndex": 0, + "lagrangeSide": -1 + }, + { + "label": "Syndicate Wafer Foundry", + "color": "#91e6a8", + "objective": "siliconwafers", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_solid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_siliconwafers_01" + ], + "systemId": "perseus", + "factionId": "nadir-syndicate", + "planetIndex": 1, + "lagrangeSide": 1 + }, + { + "label": "Syndicate Antimatter Forge", + "color": "#91e6a8", + "objective": "antimattercells", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_liquid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_antimattercells_01" + ], + "systemId": "perseus", + "factionId": "nadir-syndicate", + "planetIndex": 2, + "lagrangeSide": 1 + }, + { + "label": "Syndicate Coolant Loop", + "color": "#91e6a8", + "objective": "superfluidcoolant", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_liquid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_superfluidcoolant_01" + ], + "systemId": "perseus", + "factionId": "nadir-syndicate", "planetIndex": 3, "lagrangeSide": -1 + }, + { + "label": "Syndicate Hydro Plant", + "color": "#91e6a8", + "objective": "water", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_solid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_water_01" + ], + "systemId": "perseus", + "factionId": "nadir-syndicate", + "planetIndex": 3, + "lagrangeSide": 1 } ], "shipFormations": [ @@ -101,7 +479,7 @@ }, { "shipId": "miner", - "count": 1, + "count": 4, "center": [ 54, 0, 18 ], "systemId": "helios", "factionId": "sol-dominion" @@ -113,6 +491,13 @@ "systemId": "helios", "factionId": "sol-dominion" }, + { + "shipId": "gas-miner", + "count": 4, + "center": [ 74, 0, 14 ], + "systemId": "helios", + "factionId": "sol-dominion" + }, { "shipId": "constructor", "count": 1, @@ -122,7 +507,7 @@ }, { "shipId": "miner", - "count": 1, + "count": 4, "center": [ 56, 0, -12 ], "systemId": "sol", "factionId": "asterion-league" @@ -133,6 +518,41 @@ "center": [ 68, 0, -18 ], "systemId": "sol", "factionId": "asterion-league" + }, + { + "shipId": "gas-miner", + "count": 4, + "center": [ 76, 0, -22 ], + "systemId": "sol", + "factionId": "asterion-league" + }, + { + "shipId": "constructor", + "count": 1, + "center": [ 44, 0, 20 ], + "systemId": "perseus", + "factionId": "nadir-syndicate" + }, + { + "shipId": "miner", + "count": 4, + "center": [ 58, 0, 24 ], + "systemId": "perseus", + "factionId": "nadir-syndicate" + }, + { + "shipId": "hauler", + "count": 1, + "center": [ 68, 0, 18 ], + "systemId": "perseus", + "factionId": "nadir-syndicate" + }, + { + "shipId": "gas-miner", + "count": 4, + "center": [ 78, 0, 22 ], + "systemId": "perseus", + "factionId": "nadir-syndicate" } ], "patrolRoutes": [], diff --git a/shared/data/ships.json b/shared/data/ships.json index 584431f..dac0c0e 100644 --- a/shared/data/ships.json +++ b/shared/data/ships.json @@ -439,5 +439,71 @@ "maxEfficiency": 1, "priority": 8 } + }, + { + "id": "gas-miner", + "label": "Prospector Gas Miner", + "kind": "mining", + "class": "industrial", + "speed": 75000, + "warpSpeed": 0.15, + "ftlSpeed": 0.5, + "spoolTime": 3.1, + "cargoCapacity": 120, + "cargoKind": "liquid", + "color": "#84e7ff", + "hullColor": "#2b5868", + "size": 6, + "maxHealth": 150, + "capabilities": [ + "warp", + "ftl", + "mining" + ], + "construction": { + "recipeId": "gas-miner-construction", + "facilityCategory": "station", + "requiredModules": [ + "module_gen_build_l_01" + ], + "requirements": [ + { + "itemId": "hullparts", + "amount": 34 + }, + { + "itemId": "advancedelectronics", + "amount": 1 + }, + { + "itemId": "antimatterconverters", + "amount": 1 + }, + { + "itemId": "shieldcomponents", + "amount": 1 + }, + { + "itemId": "engineparts", + "amount": 1 + }, + { + "itemId": "engineparts", + "amount": 1 + }, + { + "itemId": "turretcomponents", + "amount": 1 + }, + { + "itemId": "hullparts", + "amount": 1 + } + ], + "cycleTime": 28, + "productsPerHour": 128.6, + "maxEfficiency": 1, + "priority": 8 + } } ]