Compare commits

...

2 Commits

Author SHA1 Message Date
3b56785f9a improvement on gm windows, ai 2026-03-20 12:40:26 -04:00
ff078fe939 Update viewer AI state panels 2026-03-20 02:44:25 -04:00
42 changed files with 2894 additions and 393 deletions

View File

@@ -40,6 +40,7 @@ public sealed class ItemProductionDefinition
public sealed class BalanceDefinition public sealed class BalanceDefinition
{ {
public float SimulationSpeedMultiplier { get; set; } = 1f;
public float YPlane { get; set; } public float YPlane { get; set; }
public float ArrivalThreshold { get; set; } public float ArrivalThreshold { get; set; }
public float MiningRate { get; set; } public float MiningRate { get; set; }
@@ -94,7 +95,8 @@ public sealed class AsteroidFieldDefinition
public sealed class ResourceNodeDefinition 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 Angle { get; set; }
public float RadiusOffset { get; set; } public float RadiusOffset { get; set; }
public float InclinationDegrees { get; set; } public float InclinationDegrees { get; set; }

View File

@@ -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 sealed class FactionObjectiveExecutor
{ {
internal void Execute( internal void Execute(
@@ -466,6 +478,7 @@ internal sealed class FactionObjectiveExecutor
FactionPlanningState state) FactionPlanningState state)
{ {
var blackboard = commander.FactionBlackboard ?? throw new InvalidOperationException("Faction blackboard must exist before objectives are executed."); 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.ActiveGoalName = null;
commander.ActiveActionName = null; commander.ActiveActionName = null;
@@ -480,8 +493,8 @@ internal sealed class FactionObjectiveExecutor
EvaluateObjective(world, commander, objective); EvaluateObjective(world, commander, objective);
foreach (var step in objective.Steps.OrderByDescending(step => step.Priority)) foreach (var step in objective.Steps.OrderByDescending(step => step.Priority))
{ {
EvaluateStep(world, commander, objective, step, blackboard, state); var assessment = EvaluateStep(world, commander, objective, step, blackboard, state, activeProject);
EmitTasks(engine, world, commander, objective, step, blackboard, state, touchedTaskIds, assignedAssetIds); EmitTasks(engine, world, commander, objective, step, assessment, touchedTaskIds, assignedAssetIds);
} }
} }
@@ -531,102 +544,300 @@ internal sealed class FactionObjectiveExecutor
objective.State = FactionObjectiveState.Active; objective.State = FactionObjectiveState.Active;
} }
private static void EvaluateStep( private static StepExecutionAssessment EvaluateStep(
SimulationWorld world, SimulationWorld world,
CommanderRuntime commander, CommanderRuntime commander,
FactionObjectiveRuntime objective, FactionObjectiveRuntime objective,
FactionPlanStepRuntime step, FactionPlanStepRuntime step,
FactionBlackboardRuntime blackboard, FactionBlackboardRuntime blackboard,
FactionPlanningState state) FactionPlanningState state,
IndustryExpansionProject? activeProject)
{ {
step.LastEvaluatedCycle = commander.PlanningCycle; step.LastEvaluatedCycle = commander.PlanningCycle;
step.BlockingReason = null; 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)) if (step.DependencyStepIds.Count > 0 && HasIncompleteDependencies(commander, step))
{ {
step.Status = FactionPlanStepStatus.Blocked; assessment = new StepExecutionAssessment(
step.BlockingReason = "Waiting for prerequisite objective steps to complete."; FactionPlanStepStatus.Blocked,
return; "Blocked on prerequisite objective steps.",
} BlockingReason: "Waiting for prerequisite objective steps to complete.");
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 else
{ {
step.Status = blackboard.HasActiveExpansionProject && string.Equals(blackboard.ActiveExpansionModuleId, step.ModuleId, StringComparison.Ordinal) assessment = step.Kind switch
? 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; FactionPlanStepKind.EnsureCommodityProduction => EvaluateCommodityStep(world, commander, step, blackboard, activeProject),
} FactionPlanStepKind.EnsureShipyardSite => EvaluateShipyardStep(world, commander, step, state, activeProject),
else if (state.MilitaryShipCount < Math.Max(2, blackboard.TargetWarshipCount / 2)) FactionPlanStepKind.ProduceFleet => EvaluateFleetProductionStep(commander, step, blackboard, state),
{ FactionPlanStepKind.AttackFactionAssets => EvaluateAttackStep(commander, step, blackboard, state),
step.Status = FactionPlanStepStatus.Blocked; FactionPlanStepKind.EnsureWaterSupply => EvaluateWaterStep(world, commander, step, blackboard, activeProject),
step.BlockingReason = "Insufficient military strength to commit to a faction attack objective."; FactionPlanStepKind.EnsureMiningCapacity => EvaluateCapacityStep(commander, step, blackboard.HasShipyard, state.MinerShipCount, 2, "mining"),
} FactionPlanStepKind.EnsureConstructionCapacity => EvaluateCapacityStep(commander, step, blackboard.HasShipyard, state.ConstructorShipCount, 1, "construction"),
else FactionPlanStepKind.EnsureTransportCapacity => EvaluateCapacityStep(commander, step, blackboard.HasShipyard, state.TransportShipCount, 1, "transport"),
{ FactionPlanStepKind.MonitorExpansionProject => EvaluateWarIndustryMonitorStep(world, commander, step, blackboard, activeProject),
step.Status = FactionPlanStepStatus.Running; _ => new StepExecutionAssessment(FactionPlanStepStatus.Failed, "Unknown step kind."),
} };
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;
}
} }
private static void EvaluateCommodityStep( ApplyAssessment(step, assessment);
return assessment;
}
private static StepExecutionAssessment EvaluateCommodityStep(
SimulationWorld world,
CommanderRuntime commander,
FactionPlanStepRuntime step, FactionPlanStepRuntime step,
FactionBlackboardRuntime blackboard) FactionBlackboardRuntime blackboard,
IndustryExpansionProject? activeProject)
{ {
if (string.IsNullOrWhiteSpace(step.CommodityId)) if (string.IsNullOrWhiteSpace(step.CommodityId))
{ {
step.Status = FactionPlanStepStatus.Failed; return new StepExecutionAssessment(
step.BlockingReason = "Commodity planning step is missing a target commodity."; FactionPlanStepStatus.Failed,
return; "Commodity step is missing a required commodity.",
BlockingReason: "Commodity planning step is missing a target commodity.");
} }
var completed = IsCommodityOperational(blackboard, step.CommodityId, 240f); if (IsCommodityOperational(blackboard, step.CommodityId, 240f))
step.Status = completed ? FactionPlanStepStatus.Complete : FactionPlanStepStatus.Ready;
if (completed)
{ {
step.ProducedFacts.Add($"commodity-online:{step.CommodityId}"); 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( private static bool IsCommodityOperational(
@@ -653,8 +864,7 @@ internal sealed class FactionObjectiveExecutor
CommanderRuntime commander, CommanderRuntime commander,
FactionObjectiveRuntime objective, FactionObjectiveRuntime objective,
FactionPlanStepRuntime step, FactionPlanStepRuntime step,
FactionBlackboardRuntime blackboard, StepExecutionAssessment assessment,
FactionPlanningState state,
ISet<string> touchedTaskIds, ISet<string> touchedTaskIds,
ISet<string> assignedAssetIds) ISet<string> assignedAssetIds)
{ {
@@ -670,128 +880,12 @@ internal sealed class FactionObjectiveExecutor
switch (step.Kind) switch (step.Kind)
{ {
case FactionPlanStepKind.EnsureCommodityProduction: case FactionPlanStepKind.EnsureCommodityProduction:
if (blackboard.HasActiveExpansionProject) EmitExpansionExecution(world, commander, objective, step, assessment, touchedTaskIds, assignedAssetIds);
{
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);
}
break; break;
case FactionPlanStepKind.EnsureShipyardSite: case FactionPlanStepKind.EnsureShipyardSite:
if (blackboard.HasActiveExpansionProject) EmitExpansionExecution(world, commander, objective, step, assessment, touchedTaskIds, assignedAssetIds);
{
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);
}
break; break;
case FactionPlanStepKind.ProduceFleet: case FactionPlanStepKind.ProduceFleet:
if (!blackboard.HasShipyard)
{
step.Status = FactionPlanStepStatus.Blocked;
step.BlockingReason = "Fleet production requires an online shipyard.";
}
UpsertShipProductionTask( UpsertShipProductionTask(
commander, commander,
objective, objective,
@@ -801,78 +895,30 @@ internal sealed class FactionObjectiveExecutor
blockingReason: step.BlockingReason, blockingReason: step.BlockingReason,
notes: step.Notes ?? "Maintain military ship production until war fleet target is satisfied."); notes: step.Notes ?? "Maintain military ship production until war fleet target is satisfied.");
AssignShipyardAssets(world, commander, objective, step); AssignShipyardAssets(world, commander, objective, step);
PromoteShipProductionStepToRunning(step, "military");
break; break;
case FactionPlanStepKind.AttackFactionAssets: case FactionPlanStepKind.AttackFactionAssets:
UpsertAttackTask(commander, objective, step, touchedTaskIds); UpsertAttackTask(commander, objective, step, touchedTaskIds);
AssignCombatAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); AssignCombatAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds);
PromoteCombatStepToRunning(step);
break; break;
case FactionPlanStepKind.EnsureWaterSupply: case FactionPlanStepKind.EnsureWaterSupply:
if (blackboard.HasActiveExpansionProject) EmitExpansionExecution(world, commander, objective, step, assessment, touchedTaskIds, assignedAssetIds);
{
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);
}
break; break;
case FactionPlanStepKind.EnsureMiningCapacity: case FactionPlanStepKind.EnsureMiningCapacity:
UpsertShipProductionTask(commander, objective, step, touchedTaskIds, "mining", step.BlockingReason, "Maintain mining ship production until logistical capacity is healthy."); UpsertShipProductionTask(commander, objective, step, touchedTaskIds, "mining", step.BlockingReason, "Maintain mining ship production until logistical capacity is healthy.");
AssignShipyardAssets(world, commander, objective, step); AssignShipyardAssets(world, commander, objective, step);
PromoteShipProductionStepToRunning(step, "mining");
break; break;
case FactionPlanStepKind.EnsureConstructionCapacity: case FactionPlanStepKind.EnsureConstructionCapacity:
UpsertShipProductionTask(commander, objective, step, touchedTaskIds, "construction", step.BlockingReason, "Maintain construction ship production until expansion support is healthy."); UpsertShipProductionTask(commander, objective, step, touchedTaskIds, "construction", step.BlockingReason, "Maintain construction ship production until expansion support is healthy.");
AssignShipyardAssets(world, commander, objective, step); AssignShipyardAssets(world, commander, objective, step);
PromoteShipProductionStepToRunning(step, "construction");
break; break;
case FactionPlanStepKind.EnsureTransportCapacity: case FactionPlanStepKind.EnsureTransportCapacity:
UpsertShipProductionTask(commander, objective, step, touchedTaskIds, "transport", step.BlockingReason, "Maintain transport ship production until logistical throughput is healthy."); UpsertShipProductionTask(commander, objective, step, touchedTaskIds, "transport", step.BlockingReason, "Maintain transport ship production until logistical throughput is healthy.");
AssignShipyardAssets(world, commander, objective, step); AssignShipyardAssets(world, commander, objective, step);
PromoteShipProductionStepToRunning(step, "transport");
break; break;
case FactionPlanStepKind.MonitorExpansionProject: case FactionPlanStepKind.MonitorExpansionProject:
UpsertWarIndustryTask(commander, objective, step, touchedTaskIds); 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<string> touchedTaskIds,
ISet<string> 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<string> touchedTaskIds) private static void ReconcileStaleTasks(CommanderRuntime commander, ISet<string> touchedTaskIds)
{ {
foreach (var task in commander.IssuedTasks) foreach (var task in commander.IssuedTasks)
@@ -1056,6 +1207,71 @@ internal sealed class FactionObjectiveExecutor
_ => FactionIssuedTaskState.Cancelled, _ => 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( private static void AssignCombatAssets(
SimulationWorld world, SimulationWorld world,
CommanderRuntime commander, CommanderRuntime commander,

View File

@@ -88,6 +88,10 @@ public sealed record FactionPlanStepSnapshot(
string? ModuleId, string? ModuleId,
string? TargetFactionId, string? TargetFactionId,
string? TargetSiteId, string? TargetSiteId,
string? StatusReason,
string? ExecutionBindingKind,
string? ExecutionBindingTargetId,
string? ExecutionBindingSummary,
string? BlockingReason, string? BlockingReason,
string? Notes, string? Notes,
int LastEvaluatedCycle, int LastEvaluatedCycle,

View File

@@ -144,6 +144,10 @@ public sealed class FactionPlanStepRuntime
public string? ModuleId { get; set; } public string? ModuleId { get; set; }
public string? TargetFactionId { get; set; } public string? TargetFactionId { get; set; }
public string? TargetSiteId { 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? BlockingReason { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int LastEvaluatedCycle { get; set; } public int LastEvaluatedCycle { get; set; }

View File

@@ -7,9 +7,9 @@ internal static class FactionIndustryPlanner
private const float CommodityTargetLevelSeconds = 240f; private const float CommodityTargetLevelSeconds = 240f;
private const float WaterTargetLevelSeconds = 300f; 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; return null;
} }
@@ -41,9 +41,9 @@ internal static class FactionIndustryPlanner
supportStation.Id); 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; return null;
} }
@@ -79,16 +79,16 @@ internal static class FactionIndustryPlanner
if (!string.IsNullOrWhiteSpace(bottleneckCommodity)) 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"; const string shipyardModuleId = "module_gen_build_l_01";
if (HasActiveExpansionProject(world, factionId)) if (!ignoreActiveExpansionProject && HasActiveExpansionProject(world, factionId))
{ {
return null; return null;
} }

View File

@@ -18,6 +18,7 @@ builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSect
builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation")); builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
builder.Services.AddFastEndpoints(); builder.Services.AddFastEndpoints();
builder.Services.AddSingleton<WorldService>(); builder.Services.AddSingleton<WorldService>();
builder.Services.AddSingleton<TelemetryService>();
builder.Services.AddHostedService<SimulationHostedService>(); builder.Services.AddHostedService<SimulationHostedService>();
var app = builder.Build(); var app = builder.Build();

View File

@@ -21,7 +21,7 @@ internal sealed class ShipBehaviorStateMachine
new PatrolShipBehaviorState(), new PatrolShipBehaviorState(),
new AttackTargetShipBehaviorState(), new AttackTargetShipBehaviorState(),
new TradeHaulShipBehaviorState(), new TradeHaulShipBehaviorState(),
new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining"), new ResourceHarvestShipBehaviorState("auto-mine", null, "mining"),
new ConstructStationShipBehaviorState(), new ConstructStationShipBehaviorState(),
}; };

View File

@@ -58,10 +58,10 @@ internal sealed class PatrolShipBehaviorState : IShipBehaviorState
internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState
{ {
private readonly string resourceItemId; private readonly string? resourceItemId;
private readonly string requiredModule; private readonly string requiredModule;
public ResourceHarvestShipBehaviorState(string kind, string resourceItemId, string requiredModule) public ResourceHarvestShipBehaviorState(string kind, string? resourceItemId, string requiredModule)
{ {
Kind = kind; Kind = kind;
this.resourceItemId = resourceItemId; this.resourceItemId = resourceItemId;

View File

@@ -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 behavior = ship.DefaultBehavior;
var cargoItemId = ship.Inventory.Keys.FirstOrDefault(); var cargoItemId = ship.Inventory.Keys.FirstOrDefault();
var targetResourceItemId = SelectMiningResourceItem(world, ship, cargoItemId ?? behavior.ItemId ?? resourceItemId); 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)) if (!string.Equals(behavior.ItemId, targetResourceItemId, StringComparison.Ordinal))
{ {
behavior.ItemId = targetResourceItemId; 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 var candidateItemId = world.MarketOrders
.Where(order => .Where(order =>
string.Equals(order.FactionId, ship.FactionId, StringComparison.Ordinal) string.Equals(order.FactionId, ship.FactionId, StringComparison.Ordinal)
&& order.Kind == MarketOrderKinds.Buy && order.Kind == MarketOrderKinds.Buy
&& order.ConstructionSiteId is null
&& order.State != MarketOrderStateKinds.Cancelled
&& order.RemainingAmount > 0.01f) && order.RemainingAmount > 0.01f)
.SelectMany(order => FactionIndustryPlanner.ResolveRootResourceItems(world, order.ItemId) .Select(order => new
.Select(itemId => new
{ {
ItemId = itemId, ItemId = order.ItemId,
Score = order.RemainingAmount * MathF.Max(0.25f, order.Valuation), Score = order.RemainingAmount * MathF.Max(0.25f, order.Valuation),
})) })
.Where(entry => .Where(entry => CanShipMineItem(world, ship, entry.ItemId))
CanShipMineItem(world, ship, entry.ItemId) .Where(entry => world.Nodes.Any(node => string.Equals(node.ItemId, entry.ItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f))
&& world.Nodes.Any(node => string.Equals(node.ItemId, entry.ItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f))
.GroupBy(entry => entry.ItemId, StringComparer.Ordinal) .GroupBy(entry => entry.ItemId, StringComparer.Ordinal)
.Select(group => new .Select(group => new
{ {
@@ -457,7 +464,8 @@ internal sealed class ShipControlService
return candidateItemId; 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)) && world.Nodes.Any(node => string.Equals(node.ItemId, fallbackItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f))
{ {
return fallbackItemId; return fallbackItemId;

View File

@@ -1,5 +1,6 @@
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
namespace SpaceGame.Api.Ships.Simulation; namespace SpaceGame.Api.Ships.Simulation;
@@ -331,7 +332,8 @@ internal sealed partial class ShipTaskExecutionService
} }
var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds); 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) if (moved <= 0.01f)
{ {
continue; continue;
@@ -356,7 +358,8 @@ internal sealed partial class ShipTaskExecutionService
} }
var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds); 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) if (moved <= 0.01f)
{ {
continue; continue;

View File

@@ -30,14 +30,15 @@ public sealed class SimulationEngine
{ {
var nowUtc = DateTimeOffset.UtcNow; var nowUtc = DateTimeOffset.UtcNow;
var events = new List<SimulationEventRecord>(); var events = new List<SimulationEventRecord>();
var simulationDeltaSeconds = deltaSeconds * MathF.Max(world.Balance.SimulationSpeedMultiplier, 0.01f);
world.OrbitalTimeSeconds += deltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond; world.OrbitalTimeSeconds += simulationDeltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond;
_orbitalStateUpdater.Update(world); _orbitalStateUpdater.Update(world);
_infrastructureSimulation.UpdateClaims(world, events); _infrastructureSimulation.UpdateClaims(world, events);
_infrastructureSimulation.UpdateConstructionSites(world, events); _infrastructureSimulation.UpdateConstructionSites(world, events);
_commanderPlanning.UpdateCommanders(this, world, deltaSeconds, events); _commanderPlanning.UpdateCommanders(this, world, simulationDeltaSeconds, events);
_stationLifecycle.UpdateStations(world, deltaSeconds, events); _stationLifecycle.UpdateStations(world, simulationDeltaSeconds, events);
foreach (var ship in world.Ships.ToList()) foreach (var ship in world.Ships.ToList())
{ {
@@ -54,10 +55,10 @@ public sealed class SimulationEngine
_shipControl.RefreshControlLayers(ship, world); _shipControl.RefreshControlLayers(ship, world);
_shipControl.PlanControllerTask(this, 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); _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.TrackHistory(ship, controllerEvent);
_shipControl.EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events); _shipControl.EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events);
} }
@@ -75,7 +76,7 @@ public sealed class SimulationEngine
public void PrimeDeltaBaseline(SimulationWorld world) => public void PrimeDeltaBaseline(SimulationWorld world) =>
_projection.PrimeDeltaBaseline(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); _shipControl.PlanResourceHarvest(ship, world, resourceItemId, requiredModule);
internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) => internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) =>

View File

@@ -895,6 +895,10 @@ internal sealed class SimulationProjectionService
step.ModuleId, step.ModuleId,
step.TargetFactionId, step.TargetFactionId,
step.TargetSiteId, step.TargetSiteId,
step.StatusReason,
step.ExecutionBindingKind,
step.ExecutionBindingTargetId,
step.ExecutionBindingSummary,
step.BlockingReason, step.BlockingReason,
step.Notes, step.Notes,
step.LastEvaluatedCycle, step.LastEvaluatedCycle,

View File

@@ -232,8 +232,13 @@ internal sealed class InfrastructureSimulationService
"power" => "energycells", "power" => "energycells",
"refinery" => "refinedmetals", "refinery" => "refinedmetals",
"water" => "water", "water" => "water",
"graphene" => "graphene",
"siliconwafers" => "siliconwafers",
"hullparts" => "hullparts", "hullparts" => "hullparts",
"claytronics" => "claytronics", "claytronics" => "claytronics",
"quantumtubes" => "quantumtubes",
"antimattercells" => "antimattercells",
"superfluidcoolant" => "superfluidcoolant",
_ => null, _ => null,
}; };
@@ -724,6 +729,11 @@ internal sealed class InfrastructureSimulationService
private static float GetTargetLevelSeconds(string commodityId) => private static float GetTargetLevelSeconds(string commodityId) =>
string.Equals(commodityId, "energycells", StringComparison.Ordinal) ? EnergyTargetLevelSeconds : string.Equals(commodityId, "energycells", StringComparison.Ordinal) ? EnergyTargetLevelSeconds :
string.Equals(commodityId, "water", StringComparison.Ordinal) ? 300f : 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; CommodityTargetLevelSeconds;
internal static void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site) internal static void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site)

View File

@@ -24,12 +24,25 @@ internal sealed class StationSimulationService
var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics"); var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics");
var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals"); var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals");
var iceReserve = role == "water" ? 260f : 0f; 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 var energyReserve = role switch
{ {
"power" => 120f, "power" => 120f,
"refinery" => 160f, "refinery" => 160f,
"hullparts" => 180f, "hullparts" => 180f,
"claytronics" => 220f, "claytronics" => 220f,
"graphene" => 160f,
"siliconwafers" => 160f,
"antimattercells" => 160f,
"superfluidcoolant" => 160f,
"quantumtubes" => 160f,
"water" => 140f, "water" => 140f,
_ => 60f, _ => 60f,
} + constructionEnergyReserve; } + constructionEnergyReserve;
@@ -43,6 +56,11 @@ internal sealed class StationSimulationService
var oreReserve = role == "refinery" ? 260f : 0f; var oreReserve = role == "refinery" ? 260f : 0f;
var hullpartsReserve = MathF.Max(constructionHullpartsReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 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 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") var shipPartsReserve = HasStationModules(station, "module_gen_build_l_01")
&& FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, "military") && FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, "military")
? 90f ? 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, "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, "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, "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, "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, "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, "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, "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, "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, "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, "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, "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, "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, "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, "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); 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<SimulationEventRecord> events) internal void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
{ {
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId); var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId);
@@ -295,6 +389,11 @@ internal sealed class StationSimulationService
"refinery" or "refinedmetals" => "refinery", "refinery" or "refinedmetals" => "refinery",
"hullparts" or "hull" => "hullparts", "hullparts" or "hull" => "hullparts",
"claytronics" or "clay" => "claytronics", "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", "shipyard" or "ship-production" => "shipyard",
_ => "general", _ => "general",
}; };
@@ -318,6 +417,31 @@ internal sealed class StationSimulationService
return "water"; 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")) if (HasStationModules(station, "module_gen_prod_claytronics_01"))
{ {
return "claytronics"; return "claytronics";

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ public sealed class SimulationWorld
{ {
public required string Label { get; init; } public required string Label { get; init; }
public required int Seed { get; init; } public required int Seed { get; init; }
public required BalanceDefinition Balance { get; init; } public required BalanceDefinition Balance { get; set; }
public required List<SystemRuntime> Systems { get; init; } public required List<SystemRuntime> Systems { get; init; }
public required List<ResourceNodeRuntime> Nodes { get; init; } public required List<ResourceNodeRuntime> Nodes { get; init; }
public required List<CelestialRuntime> Celestials { get; init; } public required List<CelestialRuntime> Celestials { get; init; }

View File

@@ -224,6 +224,23 @@ internal sealed class SpatialBuilder
private static CelestialRuntime? ResolveResourceNodeAnchor(SystemSpatialGraph graph, ResourceNodeDefinition definition) 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) if (definition.AnchorPlanetIndex is not int planetIndex || planetIndex < 0)
{ {
return null; return null;

View File

@@ -9,7 +9,7 @@ internal sealed class SystemGenerationService
internal List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems) => internal List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems) =>
authoredSystems authoredSystems
.Select(CloneSystemDefinition) .Select((system, index) => EnsureStrategicResourceCoverage(CloneSystemDefinition(system), index))
.ToList(); .ToList();
internal List<SolarSystemDefinition> ExpandSystems( internal List<SolarSystemDefinition> ExpandSystems(
@@ -126,6 +126,7 @@ internal sealed class SystemGenerationService
.Select(node => new ResourceNodeDefinition .Select(node => new ResourceNodeDefinition
{ {
SourceKind = node.SourceKind, SourceKind = node.SourceKind,
AnchorReference = node.AnchorReference,
Angle = node.Angle, Angle = node.Angle,
RadiusOffset = node.RadiusOffset, RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees, InclinationDegrees = node.InclinationDegrees,
@@ -137,7 +138,7 @@ internal sealed class SystemGenerationService
}) })
.ToList(); .ToList();
return new SolarSystemDefinition return EnsureStrategicResourceCoverage(new SolarSystemDefinition
{ {
Id = id, Id = id,
Label = label, Label = label,
@@ -161,7 +162,7 @@ internal sealed class SystemGenerationService
}, },
ResourceNodes = resourceNodes, ResourceNodes = resourceNodes,
Planets = planets, Planets = planets,
}; }, generatedIndex + 1024);
} }
private static SolarSystemDefinition CloneSystemDefinition(SolarSystemDefinition definition) private static SolarSystemDefinition CloneSystemDefinition(SolarSystemDefinition definition)
@@ -182,6 +183,7 @@ internal sealed class SystemGenerationService
ResourceNodes = definition.ResourceNodes.Select(node => new ResourceNodeDefinition ResourceNodes = definition.ResourceNodes.Select(node => new ResourceNodeDefinition
{ {
SourceKind = node.SourceKind, SourceKind = node.SourceKind,
AnchorReference = node.AnchorReference,
Angle = node.Angle, Angle = node.Angle,
RadiusOffset = node.RadiusOffset, RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees, InclinationDegrees = node.InclinationDegrees,
@@ -223,6 +225,7 @@ internal sealed class SystemGenerationService
nodes.AddRange(template.ResourceNodes.Select(node => new ResourceNodeDefinition nodes.AddRange(template.ResourceNodes.Select(node => new ResourceNodeDefinition
{ {
SourceKind = node.SourceKind, SourceKind = node.SourceKind,
AnchorReference = node.AnchorReference,
Angle = node.Angle, Angle = node.Angle,
RadiusOffset = node.RadiusOffset, RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees, InclinationDegrees = node.InclinationDegrees,
@@ -234,10 +237,30 @@ internal sealed class SystemGenerationService
})); }));
} }
nodes.AddRange(BuildAsteroidBeltNodes(generatedIndex, planets));
return nodes; 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<Vector3> BuildGalaxyPositions(IReadOnlyCollection<Vector3> occupiedPositions, int count) private static List<Vector3> BuildGalaxyPositions(IReadOnlyCollection<Vector3> occupiedPositions, int count)
{ {
var allPositions = occupiedPositions.ToList(); var allPositions = occupiedPositions.ToList();
@@ -303,25 +326,124 @@ internal sealed class SystemGenerationService
return $"gen-{ordinal}-{slug}"; return $"gen-{ordinal}-{slug}";
} }
private static IEnumerable<ResourceNodeDefinition> BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets) private static ResourceNodeDefinition BuildStrategicResourceNode(
string itemId,
IReadOnlyList<PlanetDefinition> planets,
int seed,
int ordinal)
{ {
var nodeCount = 4 + (generatedIndex % 4); var anchorPlanetIndex = ResolveStrategicResourceAnchorPlanetIndex(itemId, planets);
var oreAmount = 1000f; return new ResourceNodeDefinition
for (var index = 0; index < nodeCount; index += 1)
{ {
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", "ore" => 12000f,
Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f), "silicon" => 10000f,
RadiusOffset = 120000f + Jitter(generatedIndex, 200 + index, 36000f), "ice" => 9000f,
InclinationDegrees = Jitter(generatedIndex, 280 + index, 12f), _ => 8000f,
AnchorPlanetIndex = ResolveAsteroidAnchorPlanetIndex(planets), },
OreAmount = oreAmount, ItemId = itemId,
ItemId = "ore", ShardCount = itemId switch
ShardCount = 6 + (index % 4), {
"ore" or "silicon" or "ice" => 8,
_ => 6,
},
}; };
} }
private static ResourceNodeDefinition SanitizeResourceNode(
ResourceNodeDefinition node,
IReadOnlyList<PlanetDefinition> 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<PlanetDefinition> 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<PlanetDefinition> 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<PlanetDefinition> 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<PlanetDefinition> planets) private static int ResolveAsteroidAnchorPlanetIndex(IReadOnlyList<PlanetDefinition> planets)

View File

@@ -34,7 +34,8 @@ internal sealed class WorldBuilder(
systemsById, systemsById,
spatialLayout.SystemGraphs, spatialLayout.SystemGraphs,
spatialLayout.Celestials, spatialLayout.Celestials,
catalog.ModuleDefinitions); catalog.ModuleDefinitions,
catalog.ItemDefinitions);
seedingService.InitializeStationStockpiles(stations); seedingService.InitializeStationStockpiles(stations);
var refinery = seedingService.SelectRefineryStation(stations, scenario); var refinery = seedingService.SelectRefineryStation(stations, scenario);
@@ -106,7 +107,8 @@ internal sealed class WorldBuilder(
IReadOnlyDictionary<string, SystemRuntime> systemsById, IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyDictionary<string, SystemSpatialGraph> systemGraphs, IReadOnlyDictionary<string, SystemSpatialGraph> systemGraphs,
IReadOnlyCollection<CelestialRuntime> celestials, IReadOnlyCollection<CelestialRuntime> celestials,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions) IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
{ {
var stations = new List<StationRuntime>(); var stations = new List<StationRuntime>();
var stationIdCounter = 0; var stationIdCounter = 0;
@@ -136,9 +138,7 @@ internal sealed class WorldBuilder(
stations.Add(station); stations.Add(station);
placement.AnchorCelestial.OccupyingStructureId = station.Id; placement.AnchorCelestial.OccupyingStructureId = station.Id;
var startingModules = plan.StartingModules.Count > 0 var startingModules = BuildStartingModules(plan, moduleDefinitions, itemDefinitions);
? 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"];
foreach (var moduleId in startingModules) foreach (var moduleId in startingModules)
{ {
@@ -149,6 +149,91 @@ internal sealed class WorldBuilder(
return stations; return stations;
} }
private static IReadOnlyList<string> BuildStartingModules(
InitialStationDefinition plan,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
{
var startingModules = new List<string>(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<string> GetRequiredStartingStorageModules(
string moduleId,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
IReadOnlyDictionary<string, ItemDefinition> 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<string> modules, string moduleId)
{
if (!modules.Contains(moduleId, StringComparer.Ordinal))
{
modules.Add(moduleId);
}
}
private static Dictionary<string, List<Vector3>> BuildPatrolRoutes( private static Dictionary<string, List<Vector3>> BuildPatrolRoutes(
ScenarioDefinition scenario, ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById) IReadOnlyDictionary<string, SystemRuntime> systemsById)

View File

@@ -65,33 +65,6 @@ internal sealed class WorldSeedingService
foreach (var station in stations) foreach (var station in stations)
{ {
InitializeStationPopulation(station); 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) foreach (var station in world.Stations)
{ {
if (HasSatisfiedStarterObjectiveLayout(world, station))
{
continue;
}
var moduleId = InfrastructureSimulationService.GetNextStationModuleToBuild(station, world); var moduleId = InfrastructureSimulationService.GetNextStationModuleToBuild(station, world);
if (moduleId is null || station.CelestialId is null) if (moduleId is null || station.CelestialId is null)
{ {
@@ -200,6 +178,78 @@ internal sealed class WorldSeedingService
return (sites, orders); 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<string> 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<PolicySetRuntime> CreatePolicies(IReadOnlyCollection<FactionRuntime> factions) internal List<PolicySetRuntime> CreatePolicies(IReadOnlyCollection<FactionRuntime> factions)
{ {
var policies = new List<PolicySetRuntime>(factions.Count); var policies = new List<PolicySetRuntime>(factions.Count);
@@ -385,6 +435,13 @@ internal sealed class WorldSeedingService
Color = "#ff8f70", Color = "#ff8f70",
Credits = MinimumFactionCredits, Credits = MinimumFactionCredits,
}, },
"nadir-syndicate" => new FactionRuntime
{
Id = factionId,
Label = "Nadir Syndicate",
Color = "#91e6a8",
Credits = MinimumFactionCredits,
},
_ => new FactionRuntime _ => new FactionRuntime
{ {
Id = factionId, Id = factionId,

View File

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

View File

@@ -18,6 +18,7 @@ public sealed class WorldService(
private readonly Queue<WorldDelta> _history = []; private readonly Queue<WorldDelta> _history = [];
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load(); private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load();
private long _sequence; private long _sequence;
private BalanceDefinition? _balanceOverride;
public WorldSnapshot GetSnapshot() 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<WorldDelta> Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken) public ChannelReader<WorldDelta> Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken)
{ {
var channel = Channel.CreateUnbounded<WorldDelta>(new UnboundedChannelOptions var channel = Channel.CreateUnbounded<WorldDelta>(new UnboundedChannelOptions
@@ -96,6 +135,10 @@ public sealed class WorldService(
lock (_sync) lock (_sync)
{ {
_world = _loader.Load(); _world = _loader.Load();
if (_balanceOverride is not null)
{
ApplyBalance(_world, _balanceOverride);
}
_sequence += 1; _sequence += 1;
_history.Clear(); _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) => private static bool HasMeaningfulDelta(WorldDelta delta) =>
delta.RequiresSnapshotRefresh delta.RequiresSnapshotRefresh
|| delta.Events.Count > 0 || delta.Events.Count > 0

View File

@@ -6,7 +6,7 @@
} }
}, },
"WorldGeneration": { "WorldGeneration": {
"TargetSystemCount": 3, "TargetSystemCount": 10,
"IncludeSolSystem": true "IncludeSolSystem": true
}, },
"OrbitalSimulation": { "OrbitalSimulation": {

View File

@@ -6,6 +6,8 @@ import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue"; import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue"; import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
import GmOpsWindow from "./components/gm/GmOpsWindow.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 { createViewerHudState } from "./viewerHudState";
import { useViewerSelectionStore } from "./ui/stores/viewerSelection"; import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { Selectable } from "./viewerTypes"; import type { Selectable } from "./viewerTypes";
@@ -22,6 +24,9 @@ const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
let viewer: GameViewer | undefined; let viewer: GameViewer | undefined;
const gmOpsOpen = ref(false); const gmOpsOpen = ref(false);
const gmTelemetryOpen = ref(false);
const gmSettingsOpen = ref(false);
const gmMenuOpen = ref(false);
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick();
@@ -145,19 +150,56 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
/> />
</div> </div>
<div class="gm-launcher" @mouseleave="gmMenuOpen = false">
<div v-if="gmMenuOpen" class="gm-launcher-menu">
<button <button
type="button" type="button"
class="gm-console-toggle" class="gm-launcher-item"
@click="gmOpsOpen = !gmOpsOpen" :class="gmOpsOpen ? 'gm-launcher-item--active' : ''"
@click="gmOpsOpen = !gmOpsOpen; gmMenuOpen = false"
> >
{{ gmOpsOpen ? "Close" : "GM Console" }} Entities
</button> </button>
<button
type="button"
class="gm-launcher-item"
:class="gmTelemetryOpen ? 'gm-launcher-item--active' : ''"
@click="gmTelemetryOpen = !gmTelemetryOpen; gmMenuOpen = false"
>
Telemetry
</button>
<button
type="button"
class="gm-launcher-item"
:class="gmSettingsOpen ? 'gm-launcher-item--active' : ''"
@click="gmSettingsOpen = !gmSettingsOpen; gmMenuOpen = false"
>
Settings
</button>
</div>
<button
type="button"
class="gm-launcher-trigger"
:class="gmMenuOpen ? 'gm-launcher-trigger--open' : ''"
@click="gmMenuOpen = !gmMenuOpen"
>
GM
</button>
</div>
<GmOpsWindow <GmOpsWindow
v-if="gmOpsOpen" v-if="gmOpsOpen"
@close="gmOpsOpen = false" @close="gmOpsOpen = false"
@focus="(id, kind) => onFocusSelection({ kind, id }, kind === 'ship' ? 'follow' : 'tactical')" @focus="(id, kind) => onFocusSelection({ kind, id }, kind === 'ship' ? 'follow' : 'tactical')"
/> />
<GmTelemetryWindow
v-if="gmTelemetryOpen"
@close="gmTelemetryOpen = false"
/>
<GmSettingsWindow
v-if="gmSettingsOpen"
@close="gmSettingsOpen = false"
/>
<div <div
ref="marqueeEl" ref="marqueeEl"

View File

@@ -1,4 +1,6 @@
import type { WorldDelta, WorldSnapshot } from "./contracts"; import type { WorldDelta, WorldSnapshot } from "./contracts";
import type { TelemetrySnapshot } from "./contractsTelemetry";
import type { BalanceSettings } from "./contractsBalance";
export interface WorldStreamScope { export interface WorldStreamScope {
scopeKind?: string; scopeKind?: string;
@@ -49,6 +51,34 @@ export function openWorldStream(
return stream; return stream;
} }
export async function fetchTelemetry(signal?: AbortSignal) {
const response = await fetch("/api/telemetry", { signal });
if (!response.ok) {
throw new Error(`Telemetry request failed with ${response.status}`);
}
return response.json() as Promise<TelemetrySnapshot>;
}
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<BalanceSettings>;
}
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<BalanceSettings>;
}
export async function resetWorld() { export async function resetWorld() {
const response = await fetch("/api/world/reset", { const response = await fetch("/api/world/reset", {
method: "POST", method: "POST",

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, h, ref } from "vue";
import { import {
useVueTable, useVueTable,
getCoreRowModel, getCoreRowModel,
@@ -18,6 +18,7 @@ import { useViewerSelectionStore } from "../../ui/stores/viewerSelection";
import type { ShipSnapshot } from "../../contractsShips"; import type { ShipSnapshot } from "../../contractsShips";
import type { StationSnapshot } from "../../contractsInfrastructure"; import type { StationSnapshot } from "../../contractsInfrastructure";
import type { FactionSnapshot } from "../../contractsFactions"; import type { FactionSnapshot } from "../../contractsFactions";
import type { MarketOrderSnapshot } from "../../contractsEconomy";
// ── Column ordering composable ───────────────────────────────────────────── // ── Column ordering composable ─────────────────────────────────────────────
@@ -85,16 +86,134 @@ const factionMap = computed(() =>
new Map(gmStore.factions.map((f) => [f.id, f.label])), new Map(gmStore.factions.map((f) => [f.id, f.label])),
); );
const factionColorMap = computed(() =>
new Map(gmStore.factions.map((f) => [f.id, f.color])),
);
function renderColorCell(color: string | null | undefined) {
const resolved = color && color !== "—" ? color : "#6b7280";
return h("div", { class: "flex items-center justify-center" }, [
h("span", {
class: "inline-block h-3 w-3 rounded-full border border-white/20",
style: { backgroundColor: resolved },
}),
]);
}
function titleCaseToken(value: string | null | undefined) {
if (!value) return "—";
return value
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function compactNumber(value: number | null | undefined, digits = 0) {
if (value == null || Number.isNaN(value)) return "—";
return value.toFixed(digits);
}
function compactRate(value: number | null | undefined) {
if (value == null || Number.isNaN(value)) return "—";
const sign = value > 0.001 ? "+" : "";
return `${sign}${value.toFixed(2)}/s`;
}
function formatCargoAmount(value: number | null | undefined) {
if (value == null || Number.isNaN(value)) return "—";
const rounded = Math.round(value);
if (Math.abs(value - rounded) < 0.005) return String(rounded);
return value.toFixed(2).replace(/\.?0+$/, "");
}
function getLeadObjective(faction: FactionSnapshot) {
return [...(faction.objectives ?? [])]
.sort((left, right) => right.priority - left.priority)
.find((objective) => objective.state !== "Complete" && objective.state !== "Cancelled")
?? faction.objectives?.[0];
}
function getLeadStep(faction: FactionSnapshot) {
const objective = getLeadObjective(faction);
return [...(objective?.steps ?? [])]
.sort((left, right) => right.priority - left.priority)
.find((step) => step.status !== "Complete" && step.status !== "Cancelled")
?? objective?.steps?.[0];
}
function getLeadTask(faction: FactionSnapshot) {
return [...(faction.issuedTasks ?? [])]
.sort((left, right) => right.priority - left.priority)
.find((task) => task.state !== "Complete" && task.state !== "Cancelled")
?? faction.issuedTasks?.[0];
}
function describeCommodityState(faction: FactionSnapshot, itemId: string, shortLabel: string) {
const signal = faction.blackboard?.commoditySignals.find((entry) => entry.itemId === itemId);
if (!signal) return `${shortLabel}`;
return `${shortLabel} ${titleCaseToken(signal.level)} ${compactRate(signal.projectedNetRatePerSecond)}`;
}
function describeFactionStrategicState(faction: FactionSnapshot) {
const objective = getLeadObjective(faction);
if (!objective) return "No objectives";
return `${titleCaseToken(objective.kind)} · ${titleCaseToken(objective.state)}`;
}
function describeFactionLeadStep(faction: FactionSnapshot) {
const step = getLeadStep(faction);
if (!step) return "No steps";
const target = step.commodityId ?? step.moduleId ?? step.targetFactionId ?? step.targetSiteId;
return target
? `${titleCaseToken(step.kind)} · ${titleCaseToken(step.status)} · ${target}`
: `${titleCaseToken(step.kind)} · ${titleCaseToken(step.status)}`;
}
function describeFactionLeadTask(faction: FactionSnapshot) {
const task = getLeadTask(faction);
if (!task) return "No tasks";
const target = task.shipRole ?? task.commodityId ?? task.moduleId ?? task.targetFactionId ?? task.targetSiteId;
return target
? `${titleCaseToken(task.kind)} · ${titleCaseToken(task.state)} · ${target}`
: `${titleCaseToken(task.kind)} · ${titleCaseToken(task.state)}`;
}
function describeFactionPriority(faction: FactionSnapshot) {
const priority = [...(faction.strategicPriorities ?? [])]
.sort((left, right) => right.priority - left.priority)[0];
return priority ? `${titleCaseToken(priority.goalName)} · ${compactNumber(priority.priority, 0)}` : "—";
}
function describeFactionEconomy(faction: FactionSnapshot) {
return [
describeCommodityState(faction, "refinedmetals", "RM"),
describeCommodityState(faction, "hullparts", "HP"),
describeCommodityState(faction, "claytronics", "CL"),
].join(" | ");
}
function describeFactionThreat(faction: FactionSnapshot) {
const blackboard = faction.blackboard;
if (!blackboard) return "—";
return `Enemy ships ${blackboard.enemyShipCount} · stations ${blackboard.enemyStationCount}`;
}
// ── Ships table ──────────────────────────────────────────────────────────── // ── Ships table ────────────────────────────────────────────────────────────
type ShipRow = { type ShipRow = {
id: string; id: string;
label: string; label: string;
class: string; class: string;
factionColor: string;
faction: string; faction: string;
system: string; system: string;
state: string; state: string;
objective: string;
behavior: string; behavior: string;
phase: string;
action: string;
task: string; task: string;
cargo: number; cargo: number;
health: number; health: number;
@@ -105,11 +224,15 @@ const shipRows = computed<ShipRow[]>(() =>
id: s.id, id: s.id,
label: s.label, label: s.label,
class: s.class, class: s.class,
factionColor: factionColorMap.value.get(s.factionId) ?? "—",
faction: factionMap.value.get(s.factionId) ?? s.factionId, faction: factionMap.value.get(s.factionId) ?? s.factionId,
system: s.systemId, system: s.systemId,
state: s.state, state: titleCaseToken(s.state),
behavior: s.defaultBehaviorKind + (s.behaviorPhase ? ` · ${s.behaviorPhase}` : ""), objective: s.commanderObjective ? titleCaseToken(s.commanderObjective) : "",
task: s.controllerTaskKind, behavior: titleCaseToken(s.defaultBehaviorKind),
phase: s.behaviorPhase ? titleCaseToken(s.behaviorPhase) : "—",
action: s.currentAction ? `${s.currentAction.label} ${Math.round(s.currentAction.progress * 100)}%` : "—",
task: titleCaseToken(s.controllerTaskKind),
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0), cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
health: Math.round(s.health), health: Math.round(s.health),
})), })),
@@ -119,18 +242,29 @@ const shipColumnHelper = createColumnHelper<ShipRow>();
const shipColumns = [ const shipColumns = [
shipColumnHelper.accessor("label", { header: "Name" }), shipColumnHelper.accessor("label", { header: "Name" }),
shipColumnHelper.accessor("class", { header: "Class" }), shipColumnHelper.accessor("class", { header: "Class" }),
shipColumnHelper.accessor("factionColor", {
id: "factionColor",
header: "Color",
cell: (info) => renderColorCell(info.getValue()),
}),
shipColumnHelper.accessor("faction", { header: "Faction" }), shipColumnHelper.accessor("faction", { header: "Faction" }),
shipColumnHelper.accessor("system", { header: "System" }), shipColumnHelper.accessor("system", { header: "System" }),
shipColumnHelper.accessor("state", { header: "State" }), shipColumnHelper.accessor("state", { header: "Ship State" }),
shipColumnHelper.accessor("objective", { header: "Commander Objective" }),
shipColumnHelper.accessor("behavior", { header: "Behavior" }), shipColumnHelper.accessor("behavior", { header: "Behavior" }),
shipColumnHelper.accessor("phase", { header: "Phase" }),
shipColumnHelper.accessor("action", { header: "Current Action" }),
shipColumnHelper.accessor("task", { header: "Task" }), shipColumnHelper.accessor("task", { header: "Task" }),
shipColumnHelper.accessor("cargo", { header: "Cargo" }), shipColumnHelper.accessor("cargo", {
header: "Cargo",
cell: (info) => formatCargoAmount(info.getValue()),
}),
shipColumnHelper.accessor("health", { header: "HP" }), shipColumnHelper.accessor("health", { header: "HP" }),
]; ];
const shipFilter = ref(""); const shipFilter = ref("");
const shipSorting = ref<SortingState>([]); const shipSorting = ref<SortingState>([]);
const shipOrder = useColumnOrder(["label", "class", "faction", "system", "state", "behavior", "task", "cargo", "health"]); const shipOrder = useColumnOrder(["label", "class", "factionColor", "faction", "system", "state", "objective", "behavior", "phase", "action", "task", "cargo", "health"]);
const shipTable = useVueTable({ const shipTable = useVueTable({
get data() { return shipRows.value; }, get data() { return shipRows.value; },
@@ -156,24 +290,43 @@ type StationRow = {
id: string; id: string;
label: string; label: string;
category: string; category: string;
objective: string;
factionColor: string;
faction: string; faction: string;
system: string; system: string;
process: string;
workforce: string;
docked: string; docked: string;
orders: number;
orderDetails: MarketOrderSnapshot[];
cargo: number; cargo: number;
population: number;
modules: number; modules: number;
}; };
const marketOrderMap = computed(() =>
new Map(gmStore.marketOrders.map((o) => [o.id, o])),
);
const stationRows = computed<StationRow[]>(() => const stationRows = computed<StationRow[]>(() =>
gmStore.stations.map((s) => ({ gmStore.stations.map((s) => ({
id: s.id, id: s.id,
label: s.label, label: s.label,
category: s.category, category: s.category,
objective: titleCaseToken(s.objective),
factionColor: factionColorMap.value.get(s.factionId) ?? "—",
faction: factionMap.value.get(s.factionId) ?? s.factionId, faction: factionMap.value.get(s.factionId) ?? s.factionId,
system: s.systemId, system: s.systemId,
process: s.currentProcesses.length > 0
? s.currentProcesses.map((process) => `${process.label} ${Math.round(process.progress * 100)}%`).join(" | ")
: "Idle",
workforce: `${Math.round(s.population)} / ${Math.round(s.populationCapacity)} · ${Math.round(s.workforceEffectiveRatio * 100)}%`,
docked: `${s.dockedShips} / ${s.dockingPads}`, docked: `${s.dockedShips} / ${s.dockingPads}`,
orders: s.marketOrderIds.length,
orderDetails: s.marketOrderIds.flatMap((id) => {
const order = marketOrderMap.value.get(id);
return order ? [order] : [];
}),
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0), cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
population: Math.round(s.population),
modules: s.installedModules.length, modules: s.installedModules.length,
})), })),
); );
@@ -182,17 +335,28 @@ const stationColumnHelper = createColumnHelper<StationRow>();
const stationColumns = [ const stationColumns = [
stationColumnHelper.accessor("label", { header: "Name" }), stationColumnHelper.accessor("label", { header: "Name" }),
stationColumnHelper.accessor("category", { header: "Category" }), stationColumnHelper.accessor("category", { header: "Category" }),
stationColumnHelper.accessor("objective", { header: "Objective" }),
stationColumnHelper.accessor("factionColor", {
id: "factionColor",
header: "Color",
cell: (info) => renderColorCell(info.getValue()),
}),
stationColumnHelper.accessor("faction", { header: "Faction" }), stationColumnHelper.accessor("faction", { header: "Faction" }),
stationColumnHelper.accessor("system", { header: "System" }), stationColumnHelper.accessor("system", { header: "System" }),
stationColumnHelper.accessor("process", { header: "Production" }),
stationColumnHelper.accessor("workforce", { header: "Workforce" }),
stationColumnHelper.accessor("docked", { header: "Docked" }), stationColumnHelper.accessor("docked", { header: "Docked" }),
stationColumnHelper.accessor("cargo", { header: "Cargo" }), stationColumnHelper.accessor("orders", { header: "Orders" }),
stationColumnHelper.accessor("population", { header: "Pop" }), stationColumnHelper.accessor("cargo", {
header: "Cargo",
cell: (info) => formatCargoAmount(info.getValue()),
}),
stationColumnHelper.accessor("modules", { header: "Modules" }), stationColumnHelper.accessor("modules", { header: "Modules" }),
]; ];
const stationFilter = ref(""); const stationFilter = ref("");
const stationSorting = ref<SortingState>([]); const stationSorting = ref<SortingState>([]);
const stationOrder = useColumnOrder(["label", "category", "faction", "system", "docked", "cargo", "population", "modules"]); const stationOrder = useColumnOrder(["label", "category", "objective", "factionColor", "faction", "system", "process", "workforce", "docked", "orders", "cargo", "modules"]);
const stationTable = useVueTable({ const stationTable = useVueTable({
get data() { return stationRows.value; }, get data() { return stationRows.value; },
@@ -217,32 +381,43 @@ const stationTable = useVueTable({
type FactionRow = { type FactionRow = {
id: string; id: string;
label: string; label: string;
color: string;
planCycle: number;
priority: string;
strategicState: string;
leadStep: string;
leadTask: string;
warReadiness: string;
economy: string;
threat: string;
fleets: string;
systems: string;
credits: number; credits: number;
population: number; population: number;
military: number;
miners: number;
transport: number;
constructors: number;
systems: string;
ore: number;
shipsBuilt: number; shipsBuilt: number;
shipsLost: number; shipsLost: number;
}; };
const factionRows = computed<FactionRow[]>(() => const factionRows = computed<FactionRow[]>(() =>
gmStore.factions.map((f) => { gmStore.factions.map((f) => {
const gs = f.goapState; const assessment = f.strategicAssessment;
const blackboard = f.blackboard;
return { return {
id: f.id, id: f.id,
label: f.label, label: f.label,
color: f.color,
planCycle: blackboard?.planCycle ?? 0,
priority: describeFactionPriority(f),
strategicState: describeFactionStrategicState(f),
leadStep: describeFactionLeadStep(f),
leadTask: describeFactionLeadTask(f),
warReadiness: `Industry ${blackboard?.hasWarIndustrySupplyChain ? "yes" : "no"} · Shipyard ${blackboard?.hasShipyard ? "yes" : "no"}${blackboard?.hasActiveExpansionProject ? ` · Expanding ${blackboard.activeExpansionCommodityId ?? blackboard.activeExpansionModuleId ?? "site"}` : ""}`,
economy: describeFactionEconomy(f),
threat: describeFactionThreat(f),
fleets: assessment ? `M ${assessment.militaryShipCount}/${blackboard?.targetWarshipCount ?? 0} · Mn ${assessment.minerShipCount} · Tr ${assessment.transportShipCount} · Cn ${assessment.constructorShipCount}` : "—",
systems: assessment ? `${assessment.controlledSystemCount} / ${assessment.targetSystemCount}` : "—",
credits: Math.round(f.credits), credits: Math.round(f.credits),
population: Math.round(f.populationTotal), population: Math.round(f.populationTotal),
military: gs?.militaryShipCount ?? 0,
miners: gs?.minerShipCount ?? 0,
transport: gs?.transportShipCount ?? 0,
constructors: gs?.constructorShipCount ?? 0,
systems: gs ? `${gs.controlledSystemCount} / ${gs.targetSystemCount}` : "—",
ore: gs ? Math.round(gs.oreStockpile) : 0,
shipsBuilt: f.shipsBuilt, shipsBuilt: f.shipsBuilt,
shipsLost: f.shipsLost, shipsLost: f.shipsLost,
}; };
@@ -252,21 +427,29 @@ const factionRows = computed<FactionRow[]>(() =>
const factionColumnHelper = createColumnHelper<FactionRow>(); const factionColumnHelper = createColumnHelper<FactionRow>();
const factionColumns = [ const factionColumns = [
factionColumnHelper.accessor("label", { header: "Faction" }), factionColumnHelper.accessor("label", { header: "Faction" }),
factionColumnHelper.accessor("color", {
header: "Color",
cell: (info) => renderColorCell(info.getValue()),
}),
factionColumnHelper.accessor("planCycle", { header: "Cycle" }),
factionColumnHelper.accessor("priority", { header: "Top Priority" }),
factionColumnHelper.accessor("strategicState", { header: "Objective" }),
factionColumnHelper.accessor("leadStep", { header: "Lead Step" }),
factionColumnHelper.accessor("leadTask", { header: "Issued Task" }),
factionColumnHelper.accessor("warReadiness", { header: "Campaign State" }),
factionColumnHelper.accessor("economy", { header: "Economy" }),
factionColumnHelper.accessor("threat", { header: "Threat" }),
factionColumnHelper.accessor("fleets", { header: "Fleets" }),
factionColumnHelper.accessor("systems", { header: "Systems" }),
factionColumnHelper.accessor("credits", { header: "Credits" }), factionColumnHelper.accessor("credits", { header: "Credits" }),
factionColumnHelper.accessor("population", { header: "Pop" }), factionColumnHelper.accessor("population", { header: "Pop" }),
factionColumnHelper.accessor("military", { header: "Military" }),
factionColumnHelper.accessor("miners", { header: "Miners" }),
factionColumnHelper.accessor("transport", { header: "Transport" }),
factionColumnHelper.accessor("constructors", { header: "Constructors" }),
factionColumnHelper.accessor("systems", { header: "Systems" }),
factionColumnHelper.accessor("ore", { header: "Ore" }),
factionColumnHelper.accessor("shipsBuilt", { header: "Built" }), factionColumnHelper.accessor("shipsBuilt", { header: "Built" }),
factionColumnHelper.accessor("shipsLost", { header: "Lost" }), factionColumnHelper.accessor("shipsLost", { header: "Lost" }),
]; ];
const factionFilter = ref(""); const factionFilter = ref("");
const factionSorting = ref<SortingState>([]); const factionSorting = ref<SortingState>([]);
const factionOrder = useColumnOrder(["label", "credits", "population", "military", "miners", "transport", "constructors", "systems", "ore", "shipsBuilt", "shipsLost"]); const factionOrder = useColumnOrder(["label", "color", "planCycle", "priority", "strategicState", "leadStep", "leadTask", "warReadiness", "economy", "threat", "fleets", "systems", "credits", "population", "shipsBuilt", "shipsLost"]);
const factionTable = useVueTable({ const factionTable = useVueTable({
get data() { return factionRows.value; }, get data() { return factionRows.value; },
@@ -346,11 +529,36 @@ function isShipSelected(id: string) {
function isStationSelected(id: string) { function isStationSelected(id: string) {
return selectedEntityKind.value === "station" && selectedEntityId.value === id; return selectedEntityKind.value === "station" && selectedEntityId.value === id;
} }
// ── Orders tooltip ─────────────────────────────────────────────────────────
const ordersTooltip = ref<{
visible: boolean;
x: number;
y: number;
orders: MarketOrderSnapshot[];
}>({ visible: false, x: 0, y: 0, orders: [] });
function showOrdersTooltip(e: MouseEvent, orders: MarketOrderSnapshot[]) {
if (orders.length === 0) return;
ordersTooltip.value = { visible: true, x: e.clientX, y: e.clientY, orders };
}
function moveOrdersTooltip(e: MouseEvent) {
if (ordersTooltip.value.visible) {
ordersTooltip.value.x = e.clientX;
ordersTooltip.value.y = e.clientY;
}
}
function hideOrdersTooltip() {
ordersTooltip.value.visible = false;
}
</script> </script>
<template> <template>
<GmWindow <GmWindow
title="Ships" title="AI States"
:initial-width="980" :initial-width="980"
:initial-height="560" :initial-height="560"
:initial-x="80" :initial-x="80"
@@ -534,6 +742,15 @@ function isStationSelected(id: string) {
> >
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" /> <FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</span> </span>
<span
v-else-if="cell.column.id === 'orders'"
:class="row.original.orderDetails.length > 0 ? 'gm-orders-trigger' : ''"
@mouseenter="showOrdersTooltip($event, row.original.orderDetails)"
@mousemove="moveOrdersTooltip"
@mouseleave="hideOrdersTooltip"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</span>
<FlexRender <FlexRender
v-else v-else
:render="cell.column.columnDef.cell" :render="cell.column.columnDef.cell"
@@ -614,4 +831,35 @@ function isStationSelected(id: string) {
</div> </div>
</div> </div>
</GmWindow> </GmWindow>
<Teleport to="body">
<div
v-if="ordersTooltip.visible && ordersTooltip.orders.length > 0"
class="gm-orders-tooltip"
:style="{ left: `${ordersTooltip.x + 14}px`, top: `${ordersTooltip.y + 14}px` }"
>
<table class="gm-orders-tooltip-table">
<thead>
<tr>
<th>Item</th>
<th>Kind</th>
<th>Amount</th>
<th>Remaining</th>
<th>Valuation</th>
<th>State</th>
</tr>
</thead>
<tbody>
<tr v-for="order in ordersTooltip.orders" :key="order.id">
<td>{{ order.itemId }}</td>
<td>{{ titleCaseToken(order.kind) }}</td>
<td class="tabular-nums">{{ order.amount }}</td>
<td class="tabular-nums">{{ order.remainingAmount }}</td>
<td class="tabular-nums">{{ order.valuation }}</td>
<td>{{ titleCaseToken(order.state) }}</td>
</tr>
</tbody>
</table>
</div>
</Teleport>
</template> </template>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import GmWindow from "./GmWindow.vue";
import { fetchBalance, updateBalance } from "../../api";
import type { BalanceSettings } from "../../contractsBalance";
const emit = defineEmits<{ close: [] }>();
type FieldMeta = {
key: keyof BalanceSettings;
label: string;
description: string;
step: number;
min: number;
};
const FIELDS: FieldMeta[] = [
{ key: "simulationSpeedMultiplier", label: "Sim Speed Multiplier", description: "Global speed factor applied to every tick", step: 0.1, min: 0.01 },
{ key: "arrivalThreshold", label: "Arrival Threshold", description: "Distance at which a ship is considered arrived", step: 1, min: 0.1 },
{ key: "miningRate", label: "Mining Rate", description: "Units of ore extracted per mining cycle", step: 1, min: 0 },
{ key: "miningCycleSeconds", label: "Mining Cycle (s)", description: "Duration of one mining cycle", step: 0.1, min: 0.1 },
{ key: "transferRate", label: "Transfer Rate", description: "Cargo units transferred per second", step: 1, min: 0 },
{ key: "dockingDuration", label: "Docking Duration (s)", description: "Time for a ship to complete docking", step: 0.1, min: 0.1 },
{ key: "undockingDuration", label: "Undocking Duration (s)", description: "Time for a ship to complete undocking", step: 0.1, min: 0.1 },
{ key: "undockDistance", label: "Undock Distance", description: "Distance traveled when undocking", step: 1, min: 0 },
{ key: "yPlane", label: "Y Plane", description: "Vertical height for spatial placement", step: 0.5, min: 0 },
];
const draft = reactive<BalanceSettings>({
simulationSpeedMultiplier: 1,
yPlane: 0,
arrivalThreshold: 0,
miningRate: 0,
miningCycleSeconds: 0,
transferRate: 0,
dockingDuration: 0,
undockingDuration: 0,
undockDistance: 0,
});
const loadError = ref<string | null>(null);
const saveError = ref<string | null>(null);
const saveStatus = ref<"idle" | "saving" | "saved">("idle");
const loaded = ref(false);
let savedTimer: ReturnType<typeof setTimeout> | null = null;
onMounted(async () => {
try {
const data = await fetchBalance();
Object.assign(draft, data);
loadError.value = null;
loaded.value = true;
} catch {
loadError.value = "Failed to load settings";
}
});
function sanitizeDraft() {
for (const field of FIELDS) {
const value = draft[field.key];
draft[field.key] = Number.isFinite(value) ? Math.max(field.min, value) : field.min;
}
}
async function save() {
sanitizeDraft();
saveStatus.value = "saving";
saveError.value = null;
try {
const saved = await updateBalance({ ...draft });
Object.assign(draft, saved);
saveStatus.value = "saved";
if (savedTimer !== null) clearTimeout(savedTimer);
savedTimer = setTimeout(() => { saveStatus.value = "idle"; }, 2500);
} catch {
saveError.value = "Failed to save settings";
saveStatus.value = "idle";
}
}
</script>
<template>
<GmWindow
title="Settings"
:initial-width="480"
:initial-height="460"
:initial-x="260"
:initial-y="100"
@close="emit('close')"
>
<div class="gm-settings flex h-full flex-col">
<div v-if="loadError" class="gm-settings-error mx-4 mt-3 rounded px-3 py-2 text-xs">
{{ loadError }}
</div>
<div class="gm-settings-body min-h-0 flex-1 overflow-auto px-4 py-3">
<div class="gm-settings-section-title mb-3">Balance</div>
<div class="gm-settings-grid">
<template v-for="field in FIELDS" :key="field.key">
<label :for="`gm-setting-${field.key}`" class="gm-settings-label">
{{ field.label }}
</label>
<div class="gm-settings-field-group">
<input
:id="`gm-setting-${field.key}`"
v-model.number="draft[field.key]"
type="number"
class="gm-settings-input"
:step="field.step"
:min="field.min"
/>
<span class="gm-settings-desc">{{ field.description }}</span>
</div>
</template>
</div>
</div>
<div class="gm-settings-footer flex items-center gap-3 px-4 py-3">
<span v-if="saveError" class="gm-settings-error-inline flex-1 text-xs">{{ saveError }}</span>
<span v-else-if="saveStatus === 'saved'" class="gm-settings-saved flex-1 text-xs">Saved</span>
<span v-else class="flex-1" />
<button
type="button"
class="gm-settings-save-btn"
:disabled="saveStatus === 'saving' || !loaded"
@click="save"
>
{{ saveStatus === 'saving' ? 'Saving…' : 'Apply' }}
</button>
</div>
</div>
</GmWindow>
</template>

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import GmWindow from "./GmWindow.vue";
import { fetchTelemetry } from "../../api";
import type { TelemetrySnapshot } from "../../contractsTelemetry";
const emit = defineEmits<{ close: [] }>();
const data = ref<TelemetrySnapshot | null>(null);
const error = ref<string | null>(null);
const lastUpdatedAt = ref<number | null>(null);
const secondsSinceUpdate = ref(0);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let ageTimer: ReturnType<typeof setInterval> | null = null;
async function poll() {
try {
data.value = await fetchTelemetry();
lastUpdatedAt.value = Date.now();
error.value = null;
} catch {
error.value = "Failed to fetch telemetry";
}
}
onMounted(() => {
void poll();
pollTimer = setInterval(poll, 2000);
ageTimer = setInterval(() => {
secondsSinceUpdate.value = lastUpdatedAt.value
? Math.floor((Date.now() - lastUpdatedAt.value) / 1000)
: 0;
}, 500);
});
onUnmounted(() => {
if (pollTimer !== null) clearInterval(pollTimer);
if (ageTimer !== null) clearInterval(ageTimer);
});
function formatUptime(seconds: number) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function formatNumber(n: number) {
return n.toLocaleString("en-US");
}
function cpuBarWidth(pct: number) {
return `${Math.min(100, Math.max(0, pct))}%`;
}
function cpuBarClass(pct: number) {
if (pct >= 80) return "gm-telemetry-bar--high";
if (pct >= 50) return "gm-telemetry-bar--mid";
return "gm-telemetry-bar--low";
}
</script>
<template>
<GmWindow
title="Server Telemetry"
:initial-width="460"
:initial-height="380"
:initial-x="200"
:initial-y="120"
@close="emit('close')"
>
<div class="gm-telemetry flex h-full flex-col overflow-auto px-4 py-3">
<!-- Error state -->
<div v-if="error" class="gm-telemetry-error mb-3 rounded px-3 py-2 text-xs">
{{ error }}
</div>
<!-- Loading state -->
<div v-else-if="!data" class="flex flex-1 items-center justify-center text-xs opacity-40">
Loading
</div>
<template v-else>
<!-- PROCESS section -->
<div class="gm-telemetry-section mb-4">
<div class="gm-telemetry-section-title mb-2">Process</div>
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
<span class="gm-telemetry-label">CPU</span>
<span class="flex items-center gap-2">
<span class="gm-telemetry-value w-10 text-right">{{ data.process.cpuPercent.toFixed(1) }}%</span>
<span class="gm-telemetry-bar-track flex-1">
<span
class="gm-telemetry-bar"
:class="cpuBarClass(data.process.cpuPercent)"
:style="{ width: cpuBarWidth(data.process.cpuPercent) }"
/>
</span>
<span class="gm-telemetry-dim">/ {{ data.process.processorCount }} cores</span>
</span>
<span class="gm-telemetry-label">Working set</span>
<span class="gm-telemetry-value">{{ data.process.workingSetMb.toFixed(1) }} MB</span>
<span class="gm-telemetry-label">GC memory</span>
<span class="gm-telemetry-value">{{ data.process.gcMemoryMb.toFixed(1) }} MB</span>
<span class="gm-telemetry-label">Threads</span>
<span class="gm-telemetry-value">{{ data.process.threadCount }}</span>
<span class="gm-telemetry-label">Uptime</span>
<span class="gm-telemetry-value">{{ formatUptime(data.process.uptimeSeconds) }}</span>
</div>
</div>
<!-- SIMULATION section -->
<div class="gm-telemetry-section mb-4">
<div class="gm-telemetry-section-title mb-2">Simulation</div>
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
<span class="gm-telemetry-label">Sequence</span>
<span class="gm-telemetry-value font-mono">{{ formatNumber(data.simulation.sequence) }}</span>
<span class="gm-telemetry-label">Connected clients</span>
<span class="gm-telemetry-value">
<span
class="gm-telemetry-clients-dot"
:class="data.simulation.connectedClients > 0 ? 'gm-telemetry-clients-dot--active' : ''"
/>
{{ data.simulation.connectedClients }}
</span>
<span class="gm-telemetry-label">Delta history</span>
<span class="gm-telemetry-value">{{ data.simulation.deltaHistoryCount }} / 256</span>
<span class="gm-telemetry-label">Tick interval</span>
<span class="gm-telemetry-value">{{ data.simulation.tickIntervalMs }} ms</span>
</div>
</div>
<!-- RUNTIME section -->
<div class="gm-telemetry-section mb-4">
<div class="gm-telemetry-section-title mb-2">Runtime</div>
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
<span class="gm-telemetry-label">.NET</span>
<span class="gm-telemetry-value">{{ data.runtime.frameworkDescription }}</span>
<span class="gm-telemetry-label">GC collections</span>
<span class="gm-telemetry-value font-mono">
G0 {{ formatNumber(data.runtime.gcGen0) }} &nbsp;·&nbsp;
G1 {{ formatNumber(data.runtime.gcGen1) }} &nbsp;·&nbsp;
G2 {{ formatNumber(data.runtime.gcGen2) }}
</span>
</div>
</div>
<!-- Footer -->
<div class="mt-auto flex items-center justify-between pt-2 text-[10px] opacity-40">
<span>Updated {{ secondsSinceUpdate }}s ago</span>
<span>Polling every 2s</span>
</div>
</template>
</div>
</GmWindow>
</template>

View File

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

View File

@@ -1,4 +1,4 @@
export interface FactionGoapState { export interface FactionPlanningStateSnapshot {
militaryShipCount: number; militaryShipCount: number;
minerShipCount: number; minerShipCount: number;
transportShipCount: number; transportShipCount: number;
@@ -7,14 +7,145 @@ export interface FactionGoapState {
targetSystemCount: number; targetSystemCount: number;
hasShipFactory: boolean; hasShipFactory: boolean;
oreStockpile: number; oreStockpile: number;
refinedMetalsStockpile: number; refinedMetalsAvailableStock: number;
refinedMetalsUsageRate: number;
refinedMetalsProjectedProductionRate: number;
refinedMetalsProjectedNetRate: number;
refinedMetalsLevelSeconds: number;
refinedMetalsLevel: string;
hullpartsAvailableStock: number;
hullpartsUsageRate: number;
hullpartsProjectedProductionRate: number;
hullpartsProjectedNetRate: number;
hullpartsLevelSeconds: number;
hullpartsLevel: string;
claytronicsAvailableStock: number;
claytronicsUsageRate: number;
claytronicsProjectedProductionRate: number;
claytronicsProjectedNetRate: number;
claytronicsLevelSeconds: number;
claytronicsLevel: string;
waterAvailableStock: number;
waterUsageRate: number;
waterProjectedProductionRate: number;
waterProjectedNetRate: number;
waterLevelSeconds: number;
waterLevel: string;
} }
export interface FactionGoapPriority { export interface FactionStrategicPrioritySnapshot {
goalName: string; goalName: string;
priority: number; priority: number;
} }
export interface FactionCommoditySignalSnapshot {
itemId: string;
availableStock: number;
onHand: number;
productionRatePerSecond: number;
committedProductionRatePerSecond: number;
usageRatePerSecond: number;
netRatePerSecond: number;
projectedNetRatePerSecond: number;
levelSeconds: number;
level: string;
projectedProductionRatePerSecond: number;
buyBacklog: number;
reservedForConstruction: number;
}
export interface FactionThreatSignalSnapshot {
scopeId: string;
scopeKind: string;
enemyShipCount: number;
enemyStationCount: number;
}
export interface FactionBlackboardSnapshot {
planCycle: number;
updatedAtUtc: string;
targetWarshipCount: number;
hasWarIndustrySupplyChain: boolean;
hasShipyard: boolean;
hasActiveExpansionProject: boolean;
activeExpansionCommodityId?: string | null;
activeExpansionModuleId?: string | null;
activeExpansionSiteId?: string | null;
activeExpansionSystemId?: string | null;
enemyFactionCount: number;
enemyShipCount: number;
enemyStationCount: number;
militaryShipCount: number;
minerShipCount: number;
transportShipCount: number;
constructorShipCount: number;
controlledSystemCount: number;
commoditySignals: FactionCommoditySignalSnapshot[];
threatSignals: FactionThreatSignalSnapshot[];
}
export interface FactionPlanStepSnapshot {
id: string;
kind: string;
status: string;
priority: number;
commodityId?: string | null;
moduleId?: string | null;
targetFactionId?: string | null;
targetSiteId?: string | null;
blockingReason?: string | null;
notes?: string | null;
lastEvaluatedCycle: number;
dependencyStepIds: string[];
requiredFacts: string[];
producedFacts: string[];
assignedAssets: string[];
issuedTaskIds: string[];
}
export interface FactionIssuedTaskSnapshot {
id: string;
kind: string;
state: string;
objectiveId: string;
stepId: string;
priority: number;
shipRole?: string | null;
commodityId?: string | null;
moduleId?: string | null;
targetFactionId?: string | null;
targetSystemId?: string | null;
targetSiteId?: string | null;
createdAtCycle: number;
updatedAtCycle: number;
blockingReason?: string | null;
notes?: string | null;
assignedAssets: string[];
}
export interface FactionObjectiveSnapshot {
id: string;
kind: string;
state: string;
priority: number;
parentObjectiveId?: string | null;
targetFactionId?: string | null;
targetSystemId?: string | null;
targetSiteId?: string | null;
targetRegionId?: string | null;
commodityId?: string | null;
moduleId?: string | null;
budgetWeight: number;
slotCost: number;
createdAtCycle: number;
updatedAtCycle: number;
invalidationReason?: string | null;
blockingReason?: string | null;
prerequisiteObjectiveIds: string[];
assignedAssets: string[];
steps: FactionPlanStepSnapshot[];
}
export interface FactionSnapshot { export interface FactionSnapshot {
id: string; id: string;
label: string; label: string;
@@ -26,8 +157,11 @@ export interface FactionSnapshot {
shipsBuilt: number; shipsBuilt: number;
shipsLost: number; shipsLost: number;
defaultPolicySetId?: string | null; defaultPolicySetId?: string | null;
goapState?: FactionGoapState | null; strategicAssessment?: FactionPlanningStateSnapshot | null;
goapPriorities?: FactionGoapPriority[] | null; strategicPriorities?: FactionStrategicPrioritySnapshot[] | null;
blackboard?: FactionBlackboardSnapshot | null;
objectives?: FactionObjectiveSnapshot[] | null;
issuedTasks?: FactionIssuedTaskSnapshot[] | null;
} }
export interface FactionDelta extends FactionSnapshot {} export interface FactionDelta extends FactionSnapshot {}

View File

@@ -25,6 +25,7 @@ export interface StationSnapshot {
id: string; id: string;
label: string; label: string;
category: string; category: string;
objective: string;
systemId: string; systemId: string;
localPosition: Vector3Dto; localPosition: Vector3Dto;
celestialId?: string | null; celestialId?: string | null;

View File

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

View File

@@ -539,28 +539,326 @@ canvas {
box-shadow: inset 2px 0 0 var(--viewer-accent); box-shadow: inset 2px 0 0 var(--viewer-accent);
} }
.gm-console-toggle { .gm-launcher {
position: fixed; position: fixed;
bottom: 20px; bottom: 20px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
pointer-events: auto; pointer-events: auto;
z-index: 100; z-index: 300;
padding: 7px 18px; display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.gm-launcher-trigger {
padding: 7px 24px;
border-radius: 999px; border-radius: 999px;
background: rgba(127, 214, 255, 0.1); background: rgba(127, 214, 255, 0.1);
border: 1px solid rgba(127, 214, 255, 0.24); border: 1px solid rgba(127, 214, 255, 0.24);
color: var(--viewer-accent); color: var(--viewer-accent);
font-family: "IBM Plex Mono", monospace; font-family: "IBM Plex Mono", monospace;
font-size: 0.72rem; font-size: 0.72rem;
letter-spacing: 0.12em; font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase; text-transform: uppercase;
cursor: pointer; cursor: pointer;
transition: background 140ms ease, border-color 140ms ease; transition: background 140ms ease, border-color 140ms ease;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
.gm-console-toggle:hover { .gm-launcher-trigger:hover,
.gm-launcher-trigger--open {
background: rgba(127, 214, 255, 0.18); background: rgba(127, 214, 255, 0.18);
border-color: rgba(127, 214, 255, 0.4); 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;
}

View File

@@ -2,22 +2,26 @@ import { defineStore } from "pinia";
import type { ShipSnapshot } from "../../contractsShips"; import type { ShipSnapshot } from "../../contractsShips";
import type { StationSnapshot } from "../../contractsInfrastructure"; import type { StationSnapshot } from "../../contractsInfrastructure";
import type { FactionSnapshot } from "../../contractsFactions"; import type { FactionSnapshot } from "../../contractsFactions";
import type { MarketOrderSnapshot } from "../../contractsEconomy";
export const useGmStore = defineStore("gm", { export const useGmStore = defineStore("gm", {
state: () => ({ state: () => ({
ships: [] as ShipSnapshot[], ships: [] as ShipSnapshot[],
stations: [] as StationSnapshot[], stations: [] as StationSnapshot[],
factions: [] as FactionSnapshot[], factions: [] as FactionSnapshot[],
marketOrders: [] as MarketOrderSnapshot[],
}), }),
actions: { actions: {
updateWorld( updateWorld(
ships: ShipSnapshot[], ships: ShipSnapshot[],
stations: StationSnapshot[], stations: StationSnapshot[],
factions: FactionSnapshot[], factions: FactionSnapshot[],
marketOrders: MarketOrderSnapshot[],
) { ) {
this.ships = ships; this.ships = ships;
this.stations = stations; this.stations = stations;
this.factions = factions; this.factions = factions;
this.marketOrders = marketOrders;
}, },
}, },
}); });

View File

@@ -11,7 +11,10 @@ import { describeShipCurrentAction, describeShipLocation, describeShipObjective,
import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes"; import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes";
function buildFactionCard(faction: FactionSnapshot): OpsFactionCardState { function buildFactionCard(faction: FactionSnapshot): OpsFactionCardState {
const state = faction.goapState; const state = faction.strategicAssessment;
const blackboard = faction.blackboard;
const leadTask = [...(faction.issuedTasks ?? [])]
.sort((left, right) => right.priority - left.priority)[0];
return { return {
kind: "faction", kind: "faction",
id: faction.id, id: faction.id,
@@ -20,9 +23,10 @@ function buildFactionCard(faction: FactionSnapshot): OpsFactionCardState {
`Military ${state.militaryShipCount} · Miners ${state.minerShipCount}`, `Military ${state.militaryShipCount} · Miners ${state.minerShipCount}`,
`Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}`, `Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}`,
`Systems ${state.controlledSystemCount} / ${state.targetSystemCount}`, `Systems ${state.controlledSystemCount} / ${state.targetSystemCount}`,
`Factory ${state.hasShipFactory ? "yes" : "no"} · Ore ${state.oreStockpile.toFixed(0)}`, `Shipyard ${blackboard?.hasShipyard ? "yes" : "no"} · War industry ${blackboard?.hasWarIndustrySupplyChain ? "yes" : "no"}`,
leadTask ? `Task ${leadTask.kind}${leadTask.shipRole ? ` · ${leadTask.shipRole}` : ""}` : `Ore ${state.oreStockpile.toFixed(0)}`,
] : [], ] : [],
priorities: (faction.goapPriorities ?? []).map((entry) => ({ priorities: (faction.strategicPriorities ?? []).map((entry) => ({
label: entry.goalName, label: entry.goalName,
value: entry.priority.toFixed(0), value: entry.priority.toFixed(0),
})), })),

View File

@@ -11,6 +11,41 @@ import itemsData from "../../../shared/data/items.json";
const moduleNameById = new Map<string, string>( const moduleNameById = new Map<string, string>(
(modulesData as { id: string; name: string }[]).map((m) => [m.id, m.name]), (modulesData as { id: string; name: string }[]).map((m) => [m.id, m.name]),
); );
const moduleProductionById = new Map<string, {
cycleSeconds: number;
inputs: { itemId: string; amount: number }[];
outputs: { itemId: string; amount: number }[];
}>(
(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<string, string>( const itemTransportById = new Map<string, string>(
(itemsData as { id: string; transport: string }[]).map((item) => [item.id, item.transport]), (itemsData as { id: string; transport: string }[]).map((item) => [item.id, item.transport]),
); );
@@ -62,6 +97,23 @@ function renderProgressBar(progress: number): string {
return `<div class="detail-progress"><div class="detail-progress-track"><div class="detail-progress-fill" style="width: ${(progress * 100).toFixed(1)}%"></div></div></div>`; return `<div class="detail-progress"><div class="detail-progress-track"><div class="detail-progress-fill" style="width: ${(progress * 100).toFixed(1)}%"></div></div></div>`;
} }
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( function formatModuleListWithConstruction(
world: WorldState, world: WorldState,
stationId: string, stationId: string,
@@ -91,7 +143,10 @@ function formatModuleListWithConstruction(
renderedProcessCount.set(moduleId, processIndex + 1); renderedProcessCount.set(moduleId, processIndex + 1);
const moduleName = moduleNameById.get(moduleId) ?? moduleId; const moduleName = moduleNameById.get(moduleId) ?? moduleId;
if (!process) { if (!process) {
return moduleName; const tooltip = buildStaticModuleTooltip(world, stationId, moduleId);
return tooltip
? `<div class="detail-progress-label" title="${escapeAttr(tooltip)}"><span>${moduleName}</span><span>idle</span></div>`
: `<div class="detail-progress-label"><span>${moduleName}</span></div>`;
} }
const inputLines = process.inputs.map((e) => ` ${e.itemId}: ${e.amount.toFixed(0)}`).join("\n"); const inputLines = process.inputs.map((e) => ` ${e.itemId}: ${e.amount.toFixed(0)}`).join("\n");

View File

@@ -204,6 +204,7 @@ export class ViewerWorldLifecycle {
[...world.ships.values()], [...world.ships.values()],
[...world.stations.values()], [...world.stations.values()],
[...world.factions.values()], [...world.factions.values()],
[...world.marketOrders.values()],
); );
} }
} }

View File

@@ -1,4 +1,5 @@
{ {
"simulationSpeedMultiplier": 1.5,
"yPlane": 4, "yPlane": 4,
"arrivalThreshold": 16, "arrivalThreshold": 16,
"miningRate": 10, "miningRate": 10,

View File

@@ -15,6 +15,22 @@
"planetIndex": 1, "planetIndex": 1,
"lagrangeSide": -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", "label": "Dominion Hullworks",
"color": "#7ed4ff", "color": "#7ed4ff",
@@ -27,8 +43,8 @@
], ],
"systemId": "helios", "systemId": "helios",
"factionId": "sol-dominion", "factionId": "sol-dominion",
"planetIndex": 2, "planetIndex": 0,
"lagrangeSide": -1 "lagrangeSide": 1
}, },
{ {
"label": "Dominion Clay Grid", "label": "Dominion Clay Grid",
@@ -42,7 +58,102 @@
], ],
"systemId": "helios", "systemId": "helios",
"factionId": "sol-dominion", "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, "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 "lagrangeSide": 1
}, },
{ {
@@ -60,6 +171,22 @@
"planetIndex": 1, "planetIndex": 1,
"lagrangeSide": 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", "label": "League Hullworks",
"color": "#ff8f70", "color": "#ff8f70",
@@ -72,8 +199,8 @@
], ],
"systemId": "sol", "systemId": "sol",
"factionId": "asterion-league", "factionId": "asterion-league",
"planetIndex": 2, "planetIndex": 3,
"lagrangeSide": 1 "lagrangeSide": -1
}, },
{ {
"label": "League Clay Grid", "label": "League Clay Grid",
@@ -87,8 +214,259 @@
], ],
"systemId": "sol", "systemId": "sol",
"factionId": "asterion-league", "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, "planetIndex": 3,
"lagrangeSide": -1 "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": [ "shipFormations": [
@@ -101,7 +479,7 @@
}, },
{ {
"shipId": "miner", "shipId": "miner",
"count": 1, "count": 4,
"center": [ 54, 0, 18 ], "center": [ 54, 0, 18 ],
"systemId": "helios", "systemId": "helios",
"factionId": "sol-dominion" "factionId": "sol-dominion"
@@ -113,6 +491,13 @@
"systemId": "helios", "systemId": "helios",
"factionId": "sol-dominion" "factionId": "sol-dominion"
}, },
{
"shipId": "gas-miner",
"count": 4,
"center": [ 74, 0, 14 ],
"systemId": "helios",
"factionId": "sol-dominion"
},
{ {
"shipId": "constructor", "shipId": "constructor",
"count": 1, "count": 1,
@@ -122,7 +507,7 @@
}, },
{ {
"shipId": "miner", "shipId": "miner",
"count": 1, "count": 4,
"center": [ 56, 0, -12 ], "center": [ 56, 0, -12 ],
"systemId": "sol", "systemId": "sol",
"factionId": "asterion-league" "factionId": "asterion-league"
@@ -133,6 +518,41 @@
"center": [ 68, 0, -18 ], "center": [ 68, 0, -18 ],
"systemId": "sol", "systemId": "sol",
"factionId": "asterion-league" "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": [], "patrolRoutes": [],

View File

@@ -439,5 +439,71 @@
"maxEfficiency": 1, "maxEfficiency": 1,
"priority": 8 "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
}
} }
] ]