Deepen faction economy and station planning
This commit is contained in:
@@ -293,6 +293,7 @@ public sealed class InitialStationDefinition
|
||||
public required string SystemId { get; set; }
|
||||
public string Label { get; set; } = "Orbital Station";
|
||||
public string Color { get; set; } = "#8df0d2";
|
||||
public string Objective { get; set; } = "general";
|
||||
public List<string> StartingModules { get; set; } = [];
|
||||
public string? FactionId { get; set; }
|
||||
public int? PlanetIndex { get; set; }
|
||||
@@ -307,6 +308,7 @@ public sealed class ShipFormationDefinition
|
||||
public required float[] Center { get; set; }
|
||||
public required string SystemId { get; set; }
|
||||
public string? FactionId { get; set; }
|
||||
public Dictionary<string, float> StartingInventory { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed class PatrolRouteDefinition
|
||||
|
||||
@@ -12,17 +12,22 @@ internal sealed class CommanderPlanningService
|
||||
|
||||
private static readonly IReadOnlyList<GoapGoal<FactionPlanningState>> _factionGoals =
|
||||
[
|
||||
new ExterminateRivalGoal(),
|
||||
new EnsureWarIndustryGoal(),
|
||||
new ExpandTerritoryGoal(),
|
||||
new EnsureWarFleetGoal(),
|
||||
new EnsureWaterSecurityGoal(),
|
||||
new EnsureMiningCapacityGoal(),
|
||||
new EnsureConstructionCapacityGoal(),
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyList<GoapAction<ShipPlanningState>> _shipActions =
|
||||
[
|
||||
new SetAttackObjectiveAction(),
|
||||
new SetMiningObjectiveAction(),
|
||||
new SetPatrolObjectiveAction(),
|
||||
new SetConstructionObjectiveAction(),
|
||||
new SetTradeObjectiveAction(),
|
||||
new SetIdleObjectiveAction(),
|
||||
];
|
||||
|
||||
@@ -93,6 +98,19 @@ internal sealed class CommanderPlanningService
|
||||
var plan = _factionPlanner.Plan(state, goal, actions);
|
||||
plan?.CurrentAction?.Execute(engine, world, commander);
|
||||
}
|
||||
|
||||
if (FactionIndustryPlanner.GetActiveExpansionProject(world, commander.FactionId) is null)
|
||||
{
|
||||
if (rankedGoals.Any(entry => string.Equals(entry.goal.Name, "ensure-war-industry", StringComparison.Ordinal)))
|
||||
{
|
||||
TryQueueFactionExpansionProject(world, commander, SelectGoalDrivenWarIndustryProject(world, state, commander.FactionId));
|
||||
}
|
||||
else if (rankedGoals.Any(entry => string.Equals(entry.goal.Name, "ensure-water-security", StringComparison.Ordinal)))
|
||||
{
|
||||
TryQueueFactionExpansionProject(world, commander, FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, "water"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void UpdateShipCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
@@ -124,9 +142,20 @@ internal sealed class CommanderPlanningService
|
||||
internal FactionPlanningState BuildFactionPlanningState(SimulationWorld world, string factionId)
|
||||
{
|
||||
var stations = world.Stations.Where(s => s.FactionId == factionId).ToList();
|
||||
var economy = FactionEconomyAnalyzer.Build(world, factionId);
|
||||
var refinedMetals = economy.GetCommodity("refinedmetals");
|
||||
var hullparts = economy.GetCommodity("hullparts");
|
||||
var claytronics = economy.GetCommodity("claytronics");
|
||||
var water = economy.GetCommodity("water");
|
||||
|
||||
return new FactionPlanningState
|
||||
{
|
||||
EnemyFactionCount = world.Factions.Count(f => f.Id != factionId),
|
||||
EnemyShipCount = world.Ships.Count(s =>
|
||||
s.Health > 0f &&
|
||||
!string.Equals(s.FactionId, factionId, StringComparison.Ordinal)),
|
||||
EnemyStationCount = world.Stations.Count(s =>
|
||||
!string.Equals(s.FactionId, factionId, StringComparison.Ordinal)),
|
||||
MilitaryShipCount = world.Ships.Count(s =>
|
||||
s.FactionId == factionId &&
|
||||
string.Equals(s.Definition.Kind, "military", StringComparison.Ordinal)),
|
||||
@@ -142,8 +171,19 @@ internal sealed class CommanderPlanningService
|
||||
ControlledSystemCount = StationSimulationService.GetFactionControlledSystemsCount(world, factionId),
|
||||
TargetSystemCount = Math.Max(1, Math.Min(StationSimulationService.StrategicControlTargetSystems, world.Systems.Count)),
|
||||
HasShipFactory = stations.Any(s => s.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
|
||||
OreStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "ore")),
|
||||
RefinedMetalsStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "refinedmetals")),
|
||||
OreStockpile = economy.GetCommodity("ore").OnHand,
|
||||
RefinedMetalsStockpile = refinedMetals.OnHand,
|
||||
RefinedMetalsProductionRate = refinedMetals.ProjectedProductionRatePerSecond,
|
||||
RefinedMetalsShortageHorizonSeconds = refinedMetals.ProjectedShortageHorizonSeconds,
|
||||
HullpartsStockpile = hullparts.OnHand,
|
||||
HullpartsProductionRate = hullparts.ProjectedProductionRatePerSecond,
|
||||
HullpartsShortageHorizonSeconds = hullparts.ProjectedShortageHorizonSeconds,
|
||||
ClaytronicsStockpile = claytronics.OnHand,
|
||||
ClaytronicsProductionRate = claytronics.ProjectedProductionRatePerSecond,
|
||||
ClaytronicsShortageHorizonSeconds = claytronics.ProjectedShortageHorizonSeconds,
|
||||
WaterStockpile = water.OnHand,
|
||||
WaterProductionRate = water.ProjectedProductionRatePerSecond,
|
||||
WaterShortageHorizonSeconds = water.ProjectedShortageHorizonSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,13 +196,52 @@ internal sealed class CommanderPlanningService
|
||||
c.FactionId == commander.FactionId &&
|
||||
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
|
||||
|
||||
var enemyTarget = SelectEnemyTarget(world, ship);
|
||||
var tradeRoute = SelectTradeRoute(world, ship.FactionId);
|
||||
var expansionProject = FactionIndustryPlanner.GetActiveExpansionProject(world, ship.FactionId);
|
||||
if (commander.ActiveBehavior is not null)
|
||||
{
|
||||
commander.ActiveBehavior.AreaSystemId = enemyTarget?.SystemId;
|
||||
commander.ActiveBehavior.TargetEntityId = enemyTarget?.EntityId;
|
||||
if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal))
|
||||
{
|
||||
commander.ActiveBehavior.ItemId = tradeRoute?.ItemId;
|
||||
commander.ActiveBehavior.StationId = tradeRoute?.SourceStationId;
|
||||
commander.ActiveBehavior.TargetEntityId = tradeRoute?.DestinationStationId;
|
||||
}
|
||||
else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal) && expansionProject is not null)
|
||||
{
|
||||
commander.ActiveBehavior.StationId = expansionProject.SupportStationId;
|
||||
commander.ActiveBehavior.TargetEntityId = expansionProject.SiteId;
|
||||
commander.ActiveBehavior.ModuleId = expansionProject.ModuleId;
|
||||
commander.ActiveBehavior.AreaSystemId = expansionProject.SystemId;
|
||||
}
|
||||
else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal))
|
||||
{
|
||||
commander.ActiveBehavior.TargetEntityId = null;
|
||||
commander.ActiveBehavior.ModuleId = null;
|
||||
commander.ActiveBehavior.AreaSystemId = ship.SystemId;
|
||||
}
|
||||
}
|
||||
|
||||
return new ShipPlanningState
|
||||
{
|
||||
ShipKind = ship.Definition.Kind,
|
||||
HasMiningCapability = HasShipCapabilities(ship.Definition, "mining"),
|
||||
FactionWantsOre = true,
|
||||
FactionWantsCombat = factionCommander?.ActiveDirectives.Contains("attack-rival", StringComparer.Ordinal) ?? false,
|
||||
FactionWantsExpansion = factionCommander?.ActiveDirectives
|
||||
.Contains("expand-territory", StringComparer.Ordinal) ?? false,
|
||||
FactionNeedsShipyard = !(factionCommander?.ActiveDirectives.Contains("bootstrap-war-industry", StringComparer.Ordinal) ?? false)
|
||||
? false
|
||||
: !world.Stations.Any(station =>
|
||||
string.Equals(station.FactionId, ship.FactionId, StringComparison.Ordinal)
|
||||
&& station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
|
||||
TargetEnemySystemId = enemyTarget?.SystemId,
|
||||
TargetEnemyEntityId = enemyTarget?.EntityId,
|
||||
TradeItemId = tradeRoute?.ItemId,
|
||||
TradeSourceStationId = tradeRoute?.SourceStationId,
|
||||
TradeDestinationStationId = tradeRoute?.DestinationStationId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,11 +249,15 @@ internal sealed class CommanderPlanningService
|
||||
{
|
||||
var actions = new List<GoapAction<FactionPlanningState>>();
|
||||
|
||||
actions.Add(new PlanWarIndustryAction());
|
||||
actions.Add(new PlanCommoditySupplyAction("water"));
|
||||
|
||||
foreach (var (shipId, def) in world.ShipDefinitions)
|
||||
{
|
||||
actions.Add(new OrderShipProductionAction(def.Kind, shipId));
|
||||
}
|
||||
|
||||
actions.Add(new LaunchExterminationCampaignAction());
|
||||
actions.Add(new ExpandToSystemAction());
|
||||
return actions;
|
||||
}
|
||||
@@ -184,4 +267,137 @@ internal sealed class CommanderPlanningService
|
||||
c.FactionId == factionId &&
|
||||
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal))
|
||||
?.ActiveDirectives.Contains(directive, StringComparer.Ordinal) ?? false;
|
||||
|
||||
private static void TryQueueFactionExpansionProject(
|
||||
SimulationWorld world,
|
||||
CommanderRuntime commander,
|
||||
IndustryExpansionProject? project)
|
||||
{
|
||||
if (project is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project);
|
||||
commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}");
|
||||
}
|
||||
|
||||
private static IndustryExpansionProject? SelectGoalDrivenWarIndustryProject(
|
||||
SimulationWorld world,
|
||||
FactionPlanningState state,
|
||||
string factionId)
|
||||
{
|
||||
if (!state.HasRefinedMetalsProduction || state.RefinedMetalsShortageHorizonSeconds < 240f)
|
||||
{
|
||||
return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "refinedmetals");
|
||||
}
|
||||
|
||||
if (!state.HasHullpartsProduction || state.HullpartsShortageHorizonSeconds < 240f)
|
||||
{
|
||||
return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "hullparts");
|
||||
}
|
||||
|
||||
if (!state.HasClaytronicsProduction || state.ClaytronicsShortageHorizonSeconds < 240f)
|
||||
{
|
||||
return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "claytronics");
|
||||
}
|
||||
|
||||
if (!state.HasShipFactory)
|
||||
{
|
||||
return FactionIndustryPlanner.CreateShipyardFoundationProject(world, factionId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static (string EntityId, string SystemId)? SelectEnemyTarget(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var hostileShip = world.Ships
|
||||
.Where(candidate =>
|
||||
candidate.Health > 0f &&
|
||||
!string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
|
||||
.OrderBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1)
|
||||
.ThenBy(candidate => candidate.Position.DistanceTo(ship.Position))
|
||||
.Select(candidate => (candidate.Id, candidate.SystemId))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (hostileShip != default)
|
||||
{
|
||||
return hostileShip;
|
||||
}
|
||||
|
||||
var hostileStation = world.Stations
|
||||
.Where(candidate => !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
|
||||
.OrderBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1)
|
||||
.ThenBy(candidate => candidate.Position.DistanceTo(ship.Position))
|
||||
.Select(candidate => (candidate.Id, candidate.SystemId))
|
||||
.FirstOrDefault();
|
||||
|
||||
return hostileStation == default ? null : hostileStation;
|
||||
}
|
||||
|
||||
private static (string ItemId, string SourceStationId, string DestinationStationId)? SelectTradeRoute(SimulationWorld world, string factionId)
|
||||
{
|
||||
var stationsById = world.Stations
|
||||
.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal))
|
||||
.ToDictionary(station => station.Id, StringComparer.Ordinal);
|
||||
|
||||
foreach (var demand in world.MarketOrders
|
||||
.Where(order =>
|
||||
string.Equals(order.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& order.Kind == MarketOrderKinds.Buy
|
||||
&& order.RemainingAmount > 0.01f
|
||||
&& order.StationId is not null)
|
||||
.OrderByDescending(order => order.Valuation))
|
||||
{
|
||||
if (!stationsById.TryGetValue(demand.StationId!, out var destination))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!CanStationAcceptAdditionalItem(world, destination, demand.ItemId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var source = stationsById.Values
|
||||
.Where(station =>
|
||||
station.Id != destination.Id
|
||||
&& GetInventoryAmount(station.Inventory, demand.ItemId) > 1f)
|
||||
.OrderByDescending(station => GetInventoryAmount(station.Inventory, demand.ItemId))
|
||||
.FirstOrDefault();
|
||||
if (source is not null)
|
||||
{
|
||||
return (demand.ItemId, source.Id, destination.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool CanStationAcceptAdditionalItem(SimulationWorld world, StationRuntime station, string itemId)
|
||||
{
|
||||
if (!world.ItemDefinitions.TryGetValue(itemId, out var definition))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var requiredModule = GetStorageRequirement(definition.CargoKind);
|
||||
if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var capacity = GetStationStorageCapacity(station, definition.CargoKind);
|
||||
if (capacity <= 0.01f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var used = station.Inventory
|
||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var item) && string.Equals(item.CargoKind, definition.CargoKind, StringComparison.Ordinal))
|
||||
.Sum(entry => entry.Value);
|
||||
|
||||
return used <= capacity - 1f;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,20 +12,89 @@ public sealed class FactionPlanningState
|
||||
public int ControlledSystemCount { get; set; }
|
||||
public int TargetSystemCount { get; set; }
|
||||
public bool HasShipFactory { get; set; }
|
||||
public int EnemyFactionCount { get; set; }
|
||||
public int EnemyShipCount { get; set; }
|
||||
public int EnemyStationCount { get; set; }
|
||||
public float OreStockpile { get; set; }
|
||||
public float RefinedMetalsStockpile { get; set; }
|
||||
public float RefinedMetalsProductionRate { get; set; }
|
||||
public float RefinedMetalsShortageHorizonSeconds { get; set; }
|
||||
public float HullpartsStockpile { get; set; }
|
||||
public float HullpartsProductionRate { get; set; }
|
||||
public float HullpartsShortageHorizonSeconds { get; set; }
|
||||
public float ClaytronicsStockpile { get; set; }
|
||||
public float ClaytronicsProductionRate { get; set; }
|
||||
public float ClaytronicsShortageHorizonSeconds { get; set; }
|
||||
public float WaterStockpile { get; set; }
|
||||
public float WaterProductionRate { get; set; }
|
||||
public float WaterShortageHorizonSeconds { get; set; }
|
||||
|
||||
public bool HasRefinedMetalsProduction => RefinedMetalsProductionRate > 0.01f;
|
||||
public bool HasHullpartsProduction => HullpartsProductionRate > 0.01f;
|
||||
public bool HasClaytronicsProduction => ClaytronicsProductionRate > 0.01f;
|
||||
public bool HasWaterProduction => WaterProductionRate > 0.01f;
|
||||
|
||||
public bool HasWarIndustrySupplyChain =>
|
||||
HasRefinedMetalsProduction && HasHullpartsProduction && HasClaytronicsProduction;
|
||||
|
||||
public FactionPlanningState Clone() => (FactionPlanningState)MemberwiseClone();
|
||||
|
||||
internal static int ComputeTargetWarships(FactionPlanningState state)
|
||||
{
|
||||
var expansionDeficit = Math.Max(0, state.TargetSystemCount - state.ControlledSystemCount);
|
||||
return Math.Max(2, (state.ControlledSystemCount * 2) + (expansionDeficit * 3));
|
||||
return Math.Max(3, (state.ControlledSystemCount * 2) + (expansionDeficit * 3) + Math.Min(4, state.EnemyFactionCount + state.EnemyStationCount));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Goals ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class EnsureWarIndustryGoal : GoapGoal<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "ensure-war-industry";
|
||||
|
||||
public override bool IsSatisfied(FactionPlanningState state) =>
|
||||
state.EnemyFactionCount <= 0 || (state.HasWarIndustrySupplyChain && state.HasShipFactory);
|
||||
|
||||
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
if (state.EnemyFactionCount <= 0)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var missingStages =
|
||||
(state.HasRefinedMetalsProduction ? 0 : 1) +
|
||||
(state.HasHullpartsProduction ? 0 : 1) +
|
||||
(state.HasClaytronicsProduction ? 0 : 1) +
|
||||
(state.HasShipFactory ? 0 : 1);
|
||||
|
||||
return missingStages <= 0 ? 0f : 125f + (missingStages * 18f);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EnsureWaterSecurityGoal : GoapGoal<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "ensure-water-security";
|
||||
|
||||
public override bool IsSatisfied(FactionPlanningState state) =>
|
||||
state.HasWaterProduction && state.WaterShortageHorizonSeconds >= 300f;
|
||||
|
||||
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
if (state.HasWaterProduction && state.WaterShortageHorizonSeconds >= 300f)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
if (float.IsPositiveInfinity(state.WaterShortageHorizonSeconds))
|
||||
{
|
||||
return state.HasWaterProduction ? 0f : 85f;
|
||||
}
|
||||
|
||||
return 55f + MathF.Max(0f, 300f - state.WaterShortageHorizonSeconds) * 0.2f;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EnsureWarFleetGoal : GoapGoal<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "ensure-war-fleet";
|
||||
@@ -40,6 +109,24 @@ public sealed class EnsureWarFleetGoal : GoapGoal<FactionPlanningState>
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ExterminateRivalGoal : GoapGoal<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "exterminate-rival";
|
||||
|
||||
public override bool IsSatisfied(FactionPlanningState state) =>
|
||||
state.EnemyFactionCount <= 0 || (state.EnemyShipCount <= 0 && state.EnemyStationCount <= 0);
|
||||
|
||||
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
if (state.EnemyFactionCount <= 0)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return 140f + (state.EnemyStationCount * 25f) + (state.EnemyShipCount * 6f);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ExpandTerritoryGoal : GoapGoal<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "expand-territory";
|
||||
@@ -100,7 +187,8 @@ public sealed class OrderShipProductionAction : GoapAction<FactionPlanningState>
|
||||
public override string Name => $"order-{shipId}-production";
|
||||
public override float Cost => 1f;
|
||||
|
||||
public override bool CheckPreconditions(FactionPlanningState state) => state.HasShipFactory;
|
||||
public override bool CheckPreconditions(FactionPlanningState state) =>
|
||||
state.HasShipFactory && state.HasWarIndustrySupplyChain;
|
||||
|
||||
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
|
||||
{
|
||||
@@ -121,13 +209,86 @@ public sealed class OrderShipProductionAction : GoapAction<FactionPlanningState>
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlanWarIndustryAction : GoapAction<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "plan-war-industry";
|
||||
public override float Cost => 2f;
|
||||
|
||||
public override bool CheckPreconditions(FactionPlanningState state) =>
|
||||
state.EnemyFactionCount > 0 && (!state.HasWarIndustrySupplyChain || !state.HasShipFactory);
|
||||
|
||||
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
|
||||
{
|
||||
state.RefinedMetalsProductionRate = MathF.Max(state.RefinedMetalsProductionRate, 1f);
|
||||
state.HullpartsProductionRate = MathF.Max(state.HullpartsProductionRate, 1f);
|
||||
state.ClaytronicsProductionRate = MathF.Max(state.ClaytronicsProductionRate, 1f);
|
||||
state.HasShipFactory = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
commander.ActiveDirectives.Add("bootstrap-war-industry");
|
||||
|
||||
if (FactionIndustryPlanner.AnalyzeShipyardNeed(world, commander.FactionId) is not { } project)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project);
|
||||
commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlanCommoditySupplyAction : GoapAction<FactionPlanningState>
|
||||
{
|
||||
private readonly string commodityId;
|
||||
|
||||
public PlanCommoditySupplyAction(string commodityId)
|
||||
{
|
||||
this.commodityId = commodityId;
|
||||
}
|
||||
|
||||
public override string Name => $"plan-{commodityId}-supply";
|
||||
public override float Cost => 2f;
|
||||
|
||||
public override bool CheckPreconditions(FactionPlanningState state) =>
|
||||
commodityId switch
|
||||
{
|
||||
"water" => !state.HasWaterProduction || state.WaterShortageHorizonSeconds < 300f,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
|
||||
{
|
||||
if (string.Equals(commodityId, "water", StringComparison.Ordinal))
|
||||
{
|
||||
state.WaterProductionRate = MathF.Max(state.WaterProductionRate, 1f);
|
||||
state.WaterShortageHorizonSeconds = MathF.Max(state.WaterShortageHorizonSeconds, 600f);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
if (FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, commodityId) is not { } project)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project);
|
||||
commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ExpandToSystemAction : GoapAction<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "expand-to-system";
|
||||
public override float Cost => 3f;
|
||||
|
||||
public override bool CheckPreconditions(FactionPlanningState state) =>
|
||||
state.ConstructorShipCount > 0 && state.MilitaryShipCount >= 2;
|
||||
state.ConstructorShipCount > 0 && state.MilitaryShipCount >= 2 && state.HasWarIndustrySupplyChain;
|
||||
|
||||
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
|
||||
{
|
||||
@@ -140,3 +301,28 @@ public sealed class ExpandToSystemAction : GoapAction<FactionPlanningState>
|
||||
commander.ActiveDirectives.Add("expand-territory");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LaunchExterminationCampaignAction : GoapAction<FactionPlanningState>
|
||||
{
|
||||
public override string Name => "launch-extermination-campaign";
|
||||
public override float Cost => 1f;
|
||||
|
||||
public override bool CheckPreconditions(FactionPlanningState state) =>
|
||||
state.EnemyFactionCount > 0
|
||||
&& state.HasShipFactory
|
||||
&& state.MilitaryShipCount >= Math.Max(2, FactionPlanningState.ComputeTargetWarships(state) / 2);
|
||||
|
||||
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
|
||||
{
|
||||
state.EnemyShipCount = 0;
|
||||
state.EnemyStationCount = 0;
|
||||
state.EnemyFactionCount = 0;
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
commander.ActiveDirectives.Add("attack-rival");
|
||||
commander.ActiveDirectives.Add("produce-military-ships");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,15 @@ public sealed record FactionGoapStateSnapshot(
|
||||
int TargetSystemCount,
|
||||
bool HasShipFactory,
|
||||
float OreStockpile,
|
||||
float RefinedMetalsStockpile);
|
||||
float RefinedMetalsStockpile,
|
||||
float RefinedMetalsProductionRate,
|
||||
float HullpartsStockpile,
|
||||
float HullpartsProductionRate,
|
||||
float ClaytronicsStockpile,
|
||||
float ClaytronicsProductionRate,
|
||||
float WaterStockpile,
|
||||
float WaterProductionRate,
|
||||
float WaterShortageHorizonSeconds);
|
||||
|
||||
public sealed record FactionGoapPrioritySnapshot(string GoalName, float Priority);
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ public sealed class CommanderBehaviorRuntime
|
||||
{
|
||||
public required string Kind { get; set; }
|
||||
public string? Phase { get; set; }
|
||||
public string? TargetEntityId { get; set; }
|
||||
public string? ItemId { get; set; }
|
||||
public string? NodeId { get; set; }
|
||||
public string? StationId { get; set; }
|
||||
public string? ModuleId { get; set; }
|
||||
|
||||
@@ -4,6 +4,7 @@ global using SpaceGame.Api.Economy.Runtime;
|
||||
global using SpaceGame.Api.Factions.AI;
|
||||
global using SpaceGame.Api.Factions.Contracts;
|
||||
global using SpaceGame.Api.Factions.Runtime;
|
||||
global using SpaceGame.Api.Industry.Planning;
|
||||
global using SpaceGame.Api.Shared.AI;
|
||||
global using SpaceGame.Api.Shared.Contracts;
|
||||
global using SpaceGame.Api.Shared.Runtime;
|
||||
|
||||
228
apps/backend/Industry/Planning/FactionEconomySnapshot.cs
Normal file
228
apps/backend/Industry/Planning/FactionEconomySnapshot.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
namespace SpaceGame.Api.Industry.Planning;
|
||||
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
|
||||
internal sealed class FactionEconomySnapshot
|
||||
{
|
||||
private readonly Dictionary<string, FactionCommoditySnapshot> commodities = new(StringComparer.Ordinal);
|
||||
|
||||
internal IReadOnlyDictionary<string, FactionCommoditySnapshot> Commodities => commodities;
|
||||
|
||||
internal FactionCommoditySnapshot GetCommodity(string itemId)
|
||||
{
|
||||
if (!commodities.TryGetValue(itemId, out var commodity))
|
||||
{
|
||||
commodity = new FactionCommoditySnapshot(itemId);
|
||||
commodities[itemId] = commodity;
|
||||
}
|
||||
|
||||
return commodity;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FactionCommoditySnapshot
|
||||
{
|
||||
internal FactionCommoditySnapshot(string itemId)
|
||||
{
|
||||
ItemId = itemId;
|
||||
}
|
||||
|
||||
internal string ItemId { get; }
|
||||
internal float OnHand { get; set; }
|
||||
internal float ReservedForConstruction { get; set; }
|
||||
internal float BuyBacklog { get; set; }
|
||||
internal float SellBacklog { get; set; }
|
||||
internal float Inbound { get; set; }
|
||||
internal float ProductionRatePerSecond { get; set; }
|
||||
internal float CommittedProductionRatePerSecond { get; set; }
|
||||
internal float ConsumptionRatePerSecond { get; set; }
|
||||
|
||||
internal float AvailableStock => MathF.Max(0f, OnHand + Inbound - ReservedForConstruction);
|
||||
internal float NetRatePerSecond => ProductionRatePerSecond - ConsumptionRatePerSecond;
|
||||
internal float ProjectedProductionRatePerSecond => ProductionRatePerSecond + CommittedProductionRatePerSecond;
|
||||
internal float ProjectedNetRatePerSecond => ProjectedProductionRatePerSecond - ConsumptionRatePerSecond;
|
||||
|
||||
internal float ShortageHorizonSeconds
|
||||
{
|
||||
get
|
||||
{
|
||||
if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f)
|
||||
{
|
||||
return float.PositiveInfinity;
|
||||
}
|
||||
|
||||
if (NetRatePerSecond >= -0.01f)
|
||||
{
|
||||
return float.PositiveInfinity;
|
||||
}
|
||||
|
||||
return AvailableStock / MathF.Max(0.01f, -NetRatePerSecond);
|
||||
}
|
||||
}
|
||||
|
||||
internal float ProjectedShortageHorizonSeconds
|
||||
{
|
||||
get
|
||||
{
|
||||
if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f)
|
||||
{
|
||||
return float.PositiveInfinity;
|
||||
}
|
||||
|
||||
if (ProjectedNetRatePerSecond >= -0.01f)
|
||||
{
|
||||
return float.PositiveInfinity;
|
||||
}
|
||||
|
||||
return AvailableStock / MathF.Max(0.01f, -ProjectedNetRatePerSecond);
|
||||
}
|
||||
}
|
||||
|
||||
internal float PressureScore =>
|
||||
MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound))
|
||||
+ MathF.Max(0f, ConsumptionRatePerSecond - ProductionRatePerSecond) * 120f;
|
||||
|
||||
internal float ProjectedPressureScore =>
|
||||
MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound))
|
||||
+ MathF.Max(0f, ConsumptionRatePerSecond - ProjectedProductionRatePerSecond) * 120f;
|
||||
}
|
||||
|
||||
internal static class FactionEconomyAnalyzer
|
||||
{
|
||||
internal static FactionEconomySnapshot Build(SimulationWorld world, string factionId)
|
||||
{
|
||||
var snapshot = new FactionEconomySnapshot();
|
||||
|
||||
foreach (var station in world.Stations.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)))
|
||||
{
|
||||
foreach (var (itemId, amount) in station.Inventory)
|
||||
{
|
||||
snapshot.GetCommodity(itemId).OnHand += amount;
|
||||
}
|
||||
|
||||
foreach (var laneKey in StationSimulationService.GetStationProductionLanes(world, station))
|
||||
{
|
||||
var recipe = StationSimulationService.SelectProductionRecipe(world, station, laneKey);
|
||||
if (recipe is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var throughput = StationSimulationService.GetStationProductionThroughput(world, station, recipe);
|
||||
var cyclesPerSecond = (station.WorkforceEffectiveRatio * throughput) / MathF.Max(recipe.Duration, 0.01f);
|
||||
if (cyclesPerSecond <= 0.0001f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var input in recipe.Inputs)
|
||||
{
|
||||
snapshot.GetCommodity(input.ItemId).ConsumptionRatePerSecond += input.Amount * cyclesPerSecond;
|
||||
}
|
||||
|
||||
foreach (var output in recipe.Outputs)
|
||||
{
|
||||
snapshot.GetCommodity(output.ItemId).ProductionRatePerSecond += output.Amount * cyclesPerSecond;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var order in world.MarketOrders.Where(order =>
|
||||
string.Equals(order.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& order.State != MarketOrderStateKinds.Cancelled
|
||||
&& order.RemainingAmount > 0.01f))
|
||||
{
|
||||
var commodity = snapshot.GetCommodity(order.ItemId);
|
||||
if (string.Equals(order.Kind, MarketOrderKinds.Buy, StringComparison.Ordinal))
|
||||
{
|
||||
commodity.BuyBacklog += order.RemainingAmount;
|
||||
}
|
||||
else if (string.Equals(order.Kind, MarketOrderKinds.Sell, StringComparison.Ordinal))
|
||||
{
|
||||
commodity.SellBacklog += order.RemainingAmount;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var site in world.ConstructionSites.Where(site =>
|
||||
string.Equals(site.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed))
|
||||
{
|
||||
ApplyCommittedProduction(world, snapshot, site);
|
||||
|
||||
foreach (var required in site.RequiredItems)
|
||||
{
|
||||
var remaining = MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key));
|
||||
if (remaining > 0.01f)
|
||||
{
|
||||
snapshot.GetCommodity(required.Key).ReservedForConstruction += remaining;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static void ApplyCommittedProduction(
|
||||
SimulationWorld world,
|
||||
FactionEconomySnapshot snapshot,
|
||||
ConstructionSiteRuntime site)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(site.BlueprintId)
|
||||
|| !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var recipeOutputs = world.Recipes.Values
|
||||
.Where(candidate => string.Equals(StationSimulationService.GetStationProductionLaneKey(world, candidate), site.BlueprintId, StringComparison.Ordinal))
|
||||
.SelectMany(candidate => candidate.Outputs)
|
||||
.GroupBy(output => output.ItemId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(output => output.Amount), StringComparer.Ordinal);
|
||||
if (recipeOutputs.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var materialFraction = 0f;
|
||||
var materialTerms = 0;
|
||||
foreach (var required in site.RequiredItems)
|
||||
{
|
||||
materialTerms += 1;
|
||||
materialFraction += required.Value <= 0.01f
|
||||
? 1f
|
||||
: Math.Clamp(GetConstructionDeliveredAmount(world, site, required.Key) / required.Value, 0f, 1f);
|
||||
}
|
||||
|
||||
materialFraction = materialTerms == 0 ? 1f : materialFraction / materialTerms;
|
||||
|
||||
var buildFraction = recipe.Duration <= 0.01f
|
||||
? 0f
|
||||
: Math.Clamp(site.Progress / recipe.Duration, 0f, 1f);
|
||||
var readiness = site.State switch
|
||||
{
|
||||
ConstructionSiteStateKinds.Active => 0.3f,
|
||||
ConstructionSiteStateKinds.Planned => 0.15f,
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
readiness += materialFraction * 0.45f;
|
||||
readiness += buildFraction * 0.25f;
|
||||
|
||||
if (site.AssignedConstructorShipIds.Count > 0)
|
||||
{
|
||||
readiness += 0.1f;
|
||||
}
|
||||
|
||||
readiness = Math.Clamp(readiness, 0f, 1f);
|
||||
if (readiness <= 0.01f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cyclesPerSecond = readiness / MathF.Max(recipe.Duration, 0.01f);
|
||||
foreach (var (productItemId, amount) in recipeOutputs)
|
||||
{
|
||||
snapshot.GetCommodity(productItemId).CommittedProductionRatePerSecond += amount * cyclesPerSecond;
|
||||
}
|
||||
}
|
||||
}
|
||||
492
apps/backend/Industry/Planning/FactionIndustryPlanner.cs
Normal file
492
apps/backend/Industry/Planning/FactionIndustryPlanner.cs
Normal file
@@ -0,0 +1,492 @@
|
||||
namespace SpaceGame.Api.Industry.Planning;
|
||||
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
|
||||
internal static class FactionIndustryPlanner
|
||||
{
|
||||
internal static IndustryExpansionProject? AnalyzeCommodityNeed(SimulationWorld world, string factionId, string commodityId)
|
||||
{
|
||||
if (HasActiveExpansionProject(world, factionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bottleneckCommodity = ResolveBottleneckCommodity(world, factionId, commodityId);
|
||||
var moduleId = world.ProductionGraph.GetPrimaryProducerModule(bottleneckCommodity);
|
||||
if (moduleId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetCelestial = SelectFoundationCelestial(world, factionId, bottleneckCommodity);
|
||||
if (targetCelestial is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId);
|
||||
if (supportStation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new IndustryExpansionProject(
|
||||
bottleneckCommodity,
|
||||
moduleId,
|
||||
targetCelestial.SystemId,
|
||||
targetCelestial.Id,
|
||||
supportStation.Id);
|
||||
}
|
||||
|
||||
internal static IndustryExpansionProject? AnalyzeShipyardNeed(SimulationWorld world, string factionId)
|
||||
{
|
||||
if (HasActiveExpansionProject(world, factionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const string shipyardModuleId = "module_gen_build_l_01";
|
||||
if (world.Stations.Any(station =>
|
||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& station.InstalledModules.Contains(shipyardModuleId, StringComparer.Ordinal)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!world.ModuleRecipes.TryGetValue(shipyardModuleId, out var shipyardRecipe))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bottleneckCommodity = shipyardRecipe.Inputs
|
||||
.Select(input => ResolveBottleneckCommodity(world, factionId, input.ItemId))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Select(itemId => new
|
||||
{
|
||||
ItemId = itemId,
|
||||
HasProducer = FactionHasProducerForCommodity(world, factionId, itemId),
|
||||
Pressure = GetCommodityPressure(world, factionId, itemId),
|
||||
Stockpile = GetCommodityStockpile(world, factionId, itemId),
|
||||
})
|
||||
.Where(entry => !entry.HasProducer || entry.Pressure > 0.01f || entry.Stockpile < 120f)
|
||||
.OrderByDescending(entry => !entry.HasProducer ? 1 : 0)
|
||||
.ThenByDescending(entry => entry.Pressure)
|
||||
.ThenBy(entry => entry.Stockpile)
|
||||
.Select(entry => entry.ItemId)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bottleneckCommodity))
|
||||
{
|
||||
return AnalyzeCommodityNeed(world, factionId, bottleneckCommodity);
|
||||
}
|
||||
|
||||
return CreateShipyardFoundationProject(world, factionId);
|
||||
}
|
||||
|
||||
internal static IndustryExpansionProject? CreateShipyardFoundationProject(SimulationWorld world, string factionId)
|
||||
{
|
||||
const string shipyardModuleId = "module_gen_build_l_01";
|
||||
if (HasActiveExpansionProject(world, factionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetCelestial = SelectLogisticsFoundationCelestial(world, factionId);
|
||||
if (targetCelestial is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetCelestial.SystemId);
|
||||
if (supportStation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new IndustryExpansionProject(
|
||||
"shipyard",
|
||||
shipyardModuleId,
|
||||
targetCelestial.SystemId,
|
||||
targetCelestial.Id,
|
||||
supportStation.Id);
|
||||
}
|
||||
|
||||
internal static IndustryExpansionProject? AnalyzeExpansionNeed(SimulationWorld world, string factionId)
|
||||
{
|
||||
if (HasActiveExpansionProject(world, factionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bootstrapCommodity = SelectBootstrapCommodity(world, factionId);
|
||||
if (bootstrapCommodity is not null)
|
||||
{
|
||||
var bootstrapModuleId = world.ProductionGraph.GetPrimaryProducerModule(bootstrapCommodity);
|
||||
if (bootstrapModuleId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bootstrapCelestial = SelectFoundationCelestial(world, factionId, bootstrapCommodity);
|
||||
if (bootstrapCelestial is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapCelestial.SystemId);
|
||||
if (bootstrapSupportStation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new IndustryExpansionProject(
|
||||
bootstrapCommodity,
|
||||
bootstrapModuleId,
|
||||
bootstrapCelestial.SystemId,
|
||||
bootstrapCelestial.Id,
|
||||
bootstrapSupportStation.Id);
|
||||
}
|
||||
|
||||
var commodityId = SelectCommodityToExpand(world, factionId);
|
||||
if (commodityId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var moduleId = world.ProductionGraph.GetPrimaryProducerModule(commodityId);
|
||||
if (moduleId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetCelestial = SelectFoundationCelestial(world, factionId, commodityId);
|
||||
if (targetCelestial is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId);
|
||||
if (supportStation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new IndustryExpansionProject(
|
||||
commodityId,
|
||||
moduleId,
|
||||
targetCelestial.SystemId,
|
||||
targetCelestial.Id,
|
||||
supportStation.Id);
|
||||
}
|
||||
|
||||
internal static IndustryExpansionProject? GetActiveExpansionProject(SimulationWorld world, string factionId)
|
||||
{
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(candidate.TargetKind, "station-foundation", StringComparison.Ordinal)
|
||||
&& candidate.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
|
||||
if (site is null || site.BlueprintId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var supportStationId = world.Stations
|
||||
.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal))
|
||||
.OrderByDescending(station => station.SystemId == site.SystemId ? 1 : 0)
|
||||
.ThenByDescending(station => station.Inventory.Values.Sum())
|
||||
.Select(station => station.Id)
|
||||
.FirstOrDefault();
|
||||
if (supportStationId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new IndustryExpansionProject(
|
||||
site.TargetDefinitionId,
|
||||
site.BlueprintId,
|
||||
site.SystemId,
|
||||
site.CelestialId,
|
||||
supportStationId,
|
||||
site.Id);
|
||||
}
|
||||
|
||||
internal static void EnsureExpansionSite(SimulationWorld world, string factionId, IndustryExpansionProject project)
|
||||
{
|
||||
if (project.SiteId is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var claimId = $"claim-{factionId}-{project.CelestialId}";
|
||||
if (world.Claims.All(candidate => candidate.Id != claimId))
|
||||
{
|
||||
world.Claims.Add(new ClaimRuntime
|
||||
{
|
||||
Id = claimId,
|
||||
FactionId = factionId,
|
||||
SystemId = project.SystemId,
|
||||
CelestialId = project.CelestialId,
|
||||
PlacedAtUtc = nowUtc,
|
||||
ActivatesAtUtc = nowUtc.AddSeconds(8),
|
||||
State = ClaimStateKinds.Activating,
|
||||
Health = 100f,
|
||||
});
|
||||
}
|
||||
|
||||
if (!world.ModuleRecipes.TryGetValue(project.ModuleId, out var recipe))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var siteId = $"site-{factionId}-{project.CelestialId}";
|
||||
if (world.ConstructionSites.Any(candidate => candidate.Id == siteId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var site = new ConstructionSiteRuntime
|
||||
{
|
||||
Id = siteId,
|
||||
FactionId = factionId,
|
||||
SystemId = project.SystemId,
|
||||
CelestialId = project.CelestialId,
|
||||
TargetKind = "station-foundation",
|
||||
TargetDefinitionId = project.CommodityId,
|
||||
BlueprintId = project.ModuleId,
|
||||
ClaimId = claimId,
|
||||
StationId = null,
|
||||
State = ConstructionSiteStateKinds.Planned,
|
||||
};
|
||||
|
||||
foreach (var input in recipe.Inputs)
|
||||
{
|
||||
site.RequiredItems[input.ItemId] = input.Amount;
|
||||
site.DeliveredItems[input.ItemId] = 0f;
|
||||
var orderId = $"market-order-{site.Id}-{input.ItemId}";
|
||||
site.MarketOrderIds.Add(orderId);
|
||||
world.MarketOrders.Add(new MarketOrderRuntime
|
||||
{
|
||||
Id = orderId,
|
||||
FactionId = factionId,
|
||||
StationId = project.SupportStationId,
|
||||
ConstructionSiteId = site.Id,
|
||||
Kind = MarketOrderKinds.Buy,
|
||||
ItemId = input.ItemId,
|
||||
Amount = input.Amount,
|
||||
RemainingAmount = input.Amount,
|
||||
Valuation = 1.1f,
|
||||
State = MarketOrderStateKinds.Open,
|
||||
});
|
||||
}
|
||||
|
||||
if (world.Stations.FirstOrDefault(station => station.Id == project.SupportStationId) is { } supportStation)
|
||||
{
|
||||
foreach (var orderId in site.MarketOrderIds)
|
||||
{
|
||||
supportStation.MarketOrderIds.Add(orderId);
|
||||
}
|
||||
}
|
||||
|
||||
world.ConstructionSites.Add(site);
|
||||
}
|
||||
|
||||
private static string? SelectCommodityToExpand(SimulationWorld world, string factionId)
|
||||
{
|
||||
var demandByItem = world.MarketOrders
|
||||
.Where(order =>
|
||||
string.Equals(order.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(order.Kind, MarketOrderKinds.Buy, StringComparison.Ordinal)
|
||||
&& order.RemainingAmount > 0.01f)
|
||||
.GroupBy(order => order.ItemId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(order => order.RemainingAmount), StringComparer.Ordinal);
|
||||
|
||||
if (CommanderPlanningService.FactionCommanderHasDirective(world, factionId, "produce-military-ships"))
|
||||
{
|
||||
demandByItem["hullparts"] = demandByItem.GetValueOrDefault("hullparts") + 120f;
|
||||
demandByItem["claytronics"] = demandByItem.GetValueOrDefault("claytronics") + 90f;
|
||||
}
|
||||
|
||||
return demandByItem
|
||||
.Select(entry => (ItemId: ResolveBottleneckCommodity(world, factionId, entry.Key), Score: entry.Value))
|
||||
.Where(entry => entry.ItemId is not null)
|
||||
.GroupBy(entry => entry.ItemId!, StringComparer.Ordinal)
|
||||
.Select(group => (ItemId: group.Key, Score: group.Sum(entry => entry.Score)))
|
||||
.OrderByDescending(entry => entry.Score)
|
||||
.Select(entry => entry.ItemId)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static string? SelectBootstrapCommodity(SimulationWorld world, string factionId)
|
||||
{
|
||||
if (!FactionHasProducerForCommodity(world, factionId, "refinedmetals"))
|
||||
{
|
||||
return "refinedmetals";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ResolveBottleneckCommodity(SimulationWorld world, string factionId, string itemId)
|
||||
{
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
return ResolveBottleneckCommodity(world, factionId, itemId, visited);
|
||||
}
|
||||
|
||||
private static string ResolveBottleneckCommodity(SimulationWorld world, string factionId, string itemId, HashSet<string> visited)
|
||||
{
|
||||
if (!visited.Add(itemId))
|
||||
{
|
||||
return itemId;
|
||||
}
|
||||
|
||||
var producers = world.ProductionGraph.GetProcessesForOutput(itemId);
|
||||
if (producers.Count == 0)
|
||||
{
|
||||
return itemId;
|
||||
}
|
||||
|
||||
var hasFactionProducer = producers
|
||||
.SelectMany(process => process.RequiredModuleIds)
|
||||
.Any(moduleId => world.Stations.Any(station =>
|
||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)));
|
||||
if (!hasFactionProducer)
|
||||
{
|
||||
return itemId;
|
||||
}
|
||||
|
||||
var weakestUnproducedInput = world.ProductionGraph.GetImmediateInputs(itemId)
|
||||
.Where(inputId => !FactionHasProducerForCommodity(world, factionId, inputId))
|
||||
.Select(inputId => (ItemId: inputId, Score: GetCommodityPressure(world, factionId, inputId), Stockpile: GetCommodityStockpile(world, factionId, inputId)))
|
||||
.OrderByDescending(entry => entry.Score)
|
||||
.ThenBy(entry => entry.Stockpile)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(weakestUnproducedInput.ItemId)
|
||||
&& (weakestUnproducedInput.Score > 0.01f || weakestUnproducedInput.Stockpile < 120f))
|
||||
{
|
||||
return ResolveBottleneckCommodity(world, factionId, weakestUnproducedInput.ItemId, visited);
|
||||
}
|
||||
|
||||
var weakestInput = world.ProductionGraph.GetImmediateInputs(itemId)
|
||||
.Select(inputId => (ItemId: inputId, Score: GetCommodityPressure(world, factionId, inputId)))
|
||||
.OrderByDescending(entry => entry.Score)
|
||||
.FirstOrDefault();
|
||||
|
||||
return weakestInput.Score > GetCommodityPressure(world, factionId, itemId) * 0.6f
|
||||
? ResolveBottleneckCommodity(world, factionId, weakestInput.ItemId, visited)
|
||||
: itemId;
|
||||
}
|
||||
|
||||
internal static bool FactionHasProducerForCommodity(SimulationWorld world, string factionId, string itemId)
|
||||
=> FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).ProjectedProductionRatePerSecond > 0.01f;
|
||||
|
||||
internal static IReadOnlyCollection<string> ResolveRootResourceItems(SimulationWorld world, string commodityId)
|
||||
{
|
||||
var frontier = new Queue<string>();
|
||||
var resources = new HashSet<string>(StringComparer.Ordinal);
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
frontier.Enqueue(commodityId);
|
||||
|
||||
while (frontier.Count > 0)
|
||||
{
|
||||
var current = frontier.Dequeue();
|
||||
if (!visited.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var inputs = world.ProductionGraph.GetImmediateInputs(current);
|
||||
if (inputs.Count == 0)
|
||||
{
|
||||
resources.Add(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var input in inputs)
|
||||
{
|
||||
frontier.Enqueue(input);
|
||||
}
|
||||
}
|
||||
|
||||
return resources.Count > 0 ? resources : [commodityId];
|
||||
}
|
||||
|
||||
private static bool HasActiveExpansionProject(SimulationWorld world, string factionId) =>
|
||||
world.ConstructionSites.Any(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);
|
||||
|
||||
private static float GetCommodityPressure(SimulationWorld world, string factionId, string itemId)
|
||||
{
|
||||
return FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).ProjectedPressureScore;
|
||||
}
|
||||
|
||||
private static float GetCommodityStockpile(SimulationWorld world, string factionId, string itemId) =>
|
||||
FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).AvailableStock;
|
||||
|
||||
private static CelestialRuntime? SelectFoundationCelestial(SimulationWorld world, string factionId, string commodityId)
|
||||
{
|
||||
var resourceItems = ResolveRootResourceItems(world, commodityId);
|
||||
return world.Celestials
|
||||
.Where(celestial =>
|
||||
celestial.Kind == SpatialNodeKind.LagrangePoint
|
||||
&& celestial.OccupyingStructureId is null
|
||||
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed))
|
||||
.OrderByDescending(celestial => ScoreCelestial(world, factionId, celestial, resourceItems))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static CelestialRuntime? SelectLogisticsFoundationCelestial(SimulationWorld world, string factionId)
|
||||
{
|
||||
return world.Celestials
|
||||
.Where(celestial =>
|
||||
celestial.Kind == SpatialNodeKind.LagrangePoint
|
||||
&& celestial.OccupyingStructureId is null
|
||||
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed))
|
||||
.OrderByDescending(celestial => world.Stations.Count(station =>
|
||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)))
|
||||
.ThenByDescending(celestial => world.Stations
|
||||
.Where(station =>
|
||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal))
|
||||
.Sum(station => station.Inventory.Values.Sum()))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static float ScoreCelestial(SimulationWorld world, string factionId, CelestialRuntime celestial, IReadOnlyCollection<string> resourceItems)
|
||||
{
|
||||
var resourceScore = world.Nodes
|
||||
.Where(node => node.SystemId == celestial.SystemId && resourceItems.Contains(node.ItemId, StringComparer.Ordinal))
|
||||
.Sum(node => node.OreRemaining);
|
||||
var factionPresence = world.Stations.Count(station =>
|
||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal));
|
||||
return resourceScore + (factionPresence * 5_000f);
|
||||
}
|
||||
|
||||
private static StationRuntime? SelectSupportStation(SimulationWorld world, string factionId, string moduleId, string targetSystemId)
|
||||
{
|
||||
var constructionInputs = world.ModuleRecipes.TryGetValue(moduleId, out var recipe)
|
||||
? recipe.Inputs.Select(input => input.ItemId).ToList()
|
||||
: [];
|
||||
|
||||
return world.Stations
|
||||
.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal))
|
||||
.OrderByDescending(station => station.SystemId == targetSystemId ? 1 : 0)
|
||||
.ThenByDescending(station => constructionInputs.Sum(inputId => GetInventoryAmount(station.Inventory, inputId)))
|
||||
.ThenByDescending(station => station.Inventory.Values.Sum())
|
||||
.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record IndustryExpansionProject(
|
||||
string CommodityId,
|
||||
string ModuleId,
|
||||
string SystemId,
|
||||
string CelestialId,
|
||||
string SupportStationId,
|
||||
string? SiteId = null);
|
||||
53
apps/backend/Industry/Planning/ProductionGraph.cs
Normal file
53
apps/backend/Industry/Planning/ProductionGraph.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace SpaceGame.Api.Industry.Planning;
|
||||
|
||||
public sealed class ProductionGraph
|
||||
{
|
||||
public required IReadOnlyDictionary<string, ProductionCommodityNode> Commodities { get; init; }
|
||||
public required IReadOnlyDictionary<string, ProductionProcessNode> Processes { get; init; }
|
||||
public required IReadOnlyDictionary<string, IReadOnlyList<ProductionProcessNode>> ProcessesByOutputId { get; init; }
|
||||
public required IReadOnlyDictionary<string, IReadOnlyList<ProductionProcessNode>> ProcessesByInputId { get; init; }
|
||||
public required IReadOnlyDictionary<string, IReadOnlyList<string>> OutputsByModuleId { get; init; }
|
||||
|
||||
public IReadOnlyList<ProductionProcessNode> GetProcessesForOutput(string itemId) =>
|
||||
ProcessesByOutputId.TryGetValue(itemId, out var processes) ? processes : [];
|
||||
|
||||
public IReadOnlyList<ProductionProcessNode> GetProcessesForInput(string itemId) =>
|
||||
ProcessesByInputId.TryGetValue(itemId, out var processes) ? processes : [];
|
||||
|
||||
public string? GetPrimaryProducerModule(string itemId) =>
|
||||
GetProcessesForOutput(itemId)
|
||||
.SelectMany(process => process.RequiredModuleIds)
|
||||
.FirstOrDefault();
|
||||
|
||||
public string? GetPrimaryOutputForModule(string moduleId) =>
|
||||
OutputsByModuleId.TryGetValue(moduleId, out var outputs)
|
||||
? outputs.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
public IReadOnlyList<string> GetImmediateInputs(string itemId) =>
|
||||
GetProcessesForOutput(itemId)
|
||||
.SelectMany(process => process.Inputs.Keys)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public sealed class ProductionCommodityNode
|
||||
{
|
||||
public required string ItemId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Group { get; init; }
|
||||
public required string CargoKind { get; init; }
|
||||
public List<string> ProducerProcessIds { get; } = [];
|
||||
public List<string> ConsumerProcessIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class ProductionProcessNode
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; init; }
|
||||
public required string FacilityCategory { get; init; }
|
||||
public required IReadOnlyList<string> RequiredModuleIds { get; init; }
|
||||
public required IReadOnlyDictionary<string, float> Inputs { get; init; }
|
||||
public required IReadOnlyDictionary<string, float> Outputs { get; init; }
|
||||
public required bool ProducesShip { get; init; }
|
||||
}
|
||||
105
apps/backend/Industry/Planning/ProductionGraphBuilder.cs
Normal file
105
apps/backend/Industry/Planning/ProductionGraphBuilder.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
namespace SpaceGame.Api.Industry.Planning;
|
||||
|
||||
internal static class ProductionGraphBuilder
|
||||
{
|
||||
internal static ProductionGraph Build(
|
||||
IReadOnlyCollection<ItemDefinition> items,
|
||||
IReadOnlyCollection<RecipeDefinition> recipes,
|
||||
IReadOnlyCollection<ModuleDefinition> modules)
|
||||
{
|
||||
var commodities = items.ToDictionary(
|
||||
item => item.Id,
|
||||
item => new ProductionCommodityNode
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Name = item.Name,
|
||||
Group = item.Group,
|
||||
CargoKind = item.CargoKind,
|
||||
},
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var processes = new Dictionary<string, ProductionProcessNode>(StringComparer.Ordinal);
|
||||
var processesByOutputId = new Dictionary<string, List<ProductionProcessNode>>(StringComparer.Ordinal);
|
||||
var processesByInputId = new Dictionary<string, List<ProductionProcessNode>>(StringComparer.Ordinal);
|
||||
var outputsByModuleId = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var recipe in recipes)
|
||||
{
|
||||
var outputs = recipe.Outputs
|
||||
.GroupBy(output => output.ItemId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(output => output.Amount), StringComparer.Ordinal);
|
||||
var inputs = recipe.Inputs
|
||||
.GroupBy(input => input.ItemId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(input => input.Amount), StringComparer.Ordinal);
|
||||
var process = new ProductionProcessNode
|
||||
{
|
||||
Id = recipe.Id,
|
||||
Label = recipe.Label,
|
||||
FacilityCategory = recipe.FacilityCategory,
|
||||
RequiredModuleIds = recipe.RequiredModules.ToList(),
|
||||
Inputs = inputs,
|
||||
Outputs = outputs,
|
||||
ProducesShip = recipe.ShipOutputId is not null,
|
||||
};
|
||||
|
||||
processes[process.Id] = process;
|
||||
|
||||
foreach (var output in outputs.Keys)
|
||||
{
|
||||
if (!commodities.ContainsKey(output))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
commodities[output].ProducerProcessIds.Add(process.Id);
|
||||
if (!processesByOutputId.TryGetValue(output, out var outputProcesses))
|
||||
{
|
||||
outputProcesses = [];
|
||||
processesByOutputId[output] = outputProcesses;
|
||||
}
|
||||
|
||||
outputProcesses.Add(process);
|
||||
}
|
||||
|
||||
foreach (var input in inputs.Keys)
|
||||
{
|
||||
if (!commodities.ContainsKey(input))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
commodities[input].ConsumerProcessIds.Add(process.Id);
|
||||
if (!processesByInputId.TryGetValue(input, out var inputProcesses))
|
||||
{
|
||||
inputProcesses = [];
|
||||
processesByInputId[input] = inputProcesses;
|
||||
}
|
||||
|
||||
inputProcesses.Add(process);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
if (!outputsByModuleId.TryGetValue(module.Id, out var outputs))
|
||||
{
|
||||
outputs = new HashSet<string>(StringComparer.Ordinal);
|
||||
outputsByModuleId[module.Id] = outputs;
|
||||
}
|
||||
|
||||
foreach (var product in module.Products)
|
||||
{
|
||||
outputs.Add(product);
|
||||
}
|
||||
}
|
||||
|
||||
return new ProductionGraph
|
||||
{
|
||||
Commodities = commodities,
|
||||
Processes = processes,
|
||||
ProcessesByOutputId = processesByOutputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<ProductionProcessNode>)entry.Value, StringComparer.Ordinal),
|
||||
ProcessesByInputId = processesByInputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<ProductionProcessNode>)entry.Value, StringComparer.Ordinal),
|
||||
OutputsByModuleId = outputsByModuleId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<string>)entry.Value.OrderBy(value => value, StringComparer.Ordinal).ToList(), StringComparer.Ordinal),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ public enum ShipState
|
||||
DeliveringConstruction,
|
||||
Blocked,
|
||||
Undocking,
|
||||
EngagingTarget,
|
||||
}
|
||||
|
||||
public enum ControllerTaskKind
|
||||
@@ -60,6 +61,7 @@ public enum ControllerTaskKind
|
||||
Unload,
|
||||
DeliverConstruction,
|
||||
BuildConstructionSite,
|
||||
AttackTarget,
|
||||
|
||||
ConstructModule,
|
||||
Undock,
|
||||
@@ -210,6 +212,7 @@ public static class SimulationEnumMappings
|
||||
ShipState.DeliveringConstruction => "delivering-construction",
|
||||
ShipState.Blocked => "blocked",
|
||||
ShipState.Undocking => "undocking",
|
||||
ShipState.EngagingTarget => "engaging-target",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(state), state, null),
|
||||
};
|
||||
|
||||
@@ -223,6 +226,7 @@ public static class SimulationEnumMappings
|
||||
ControllerTaskKind.Unload => "unload",
|
||||
ControllerTaskKind.DeliverConstruction => "deliver-construction",
|
||||
ControllerTaskKind.BuildConstructionSite => "build-construction-site",
|
||||
ControllerTaskKind.AttackTarget => "attack-target",
|
||||
|
||||
ControllerTaskKind.ConstructModule => "construct-module",
|
||||
ControllerTaskKind.Undock => "undock",
|
||||
|
||||
@@ -19,6 +19,8 @@ internal sealed class ShipBehaviorStateMachine
|
||||
{
|
||||
idleState,
|
||||
new PatrolShipBehaviorState(),
|
||||
new AttackTargetShipBehaviorState(),
|
||||
new TradeHaulShipBehaviorState(),
|
||||
new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining"),
|
||||
new ConstructStationShipBehaviorState(),
|
||||
};
|
||||
|
||||
@@ -93,6 +93,9 @@ internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState
|
||||
case ("dock", "docked"):
|
||||
ship.DefaultBehavior.Phase = "unload";
|
||||
break;
|
||||
case ("unload", "unloaded"):
|
||||
ship.DefaultBehavior.Phase = "undock";
|
||||
break;
|
||||
case ("undock", "undocked"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-node";
|
||||
ship.DefaultBehavior.NodeId = null;
|
||||
@@ -126,3 +129,58 @@ internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AttackTargetShipBehaviorState : IShipBehaviorState
|
||||
{
|
||||
public string Kind => "attack-target";
|
||||
|
||||
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
|
||||
engine.PlanAttackTarget(ship, world);
|
||||
|
||||
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
|
||||
{
|
||||
if (controllerEvent is "target-destroyed" or "target-lost")
|
||||
{
|
||||
ship.DefaultBehavior.TargetEntityId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TradeHaulShipBehaviorState : IShipBehaviorState
|
||||
{
|
||||
public string Kind => "trade-haul";
|
||||
|
||||
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
|
||||
engine.PlanTransportHaul(ship, world);
|
||||
|
||||
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
|
||||
{
|
||||
switch (ship.DefaultBehavior.Phase, controllerEvent)
|
||||
{
|
||||
case ("travel-to-source", "arrived"):
|
||||
ship.DefaultBehavior.Phase = "dock-source";
|
||||
break;
|
||||
case ("dock-source", "docked"):
|
||||
ship.DefaultBehavior.Phase = "load";
|
||||
break;
|
||||
case ("load", "loaded"):
|
||||
ship.DefaultBehavior.Phase = "undock-from-source";
|
||||
break;
|
||||
case ("undock-from-source", "undocked"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-destination";
|
||||
break;
|
||||
case ("travel-to-destination", "arrived"):
|
||||
ship.DefaultBehavior.Phase = "dock-destination";
|
||||
break;
|
||||
case ("dock-destination", "docked"):
|
||||
ship.DefaultBehavior.Phase = "unload";
|
||||
break;
|
||||
case ("unload", "unloaded"):
|
||||
ship.DefaultBehavior.Phase = "undock-from-destination";
|
||||
break;
|
||||
case ("undock-from-destination", "undocked"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-source";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,13 @@ public sealed class ShipPlanningState
|
||||
public bool HasMiningCapability { get; set; }
|
||||
public bool FactionWantsOre { get; set; }
|
||||
public bool FactionWantsExpansion { get; set; }
|
||||
public bool FactionWantsCombat { get; set; }
|
||||
public bool FactionNeedsShipyard { get; set; }
|
||||
public string? TargetEnemySystemId { get; set; }
|
||||
public string? TargetEnemyEntityId { get; set; }
|
||||
public string? TradeItemId { get; set; }
|
||||
public string? TradeSourceStationId { get; set; }
|
||||
public string? TradeDestinationStationId { get; set; }
|
||||
public string? CurrentObjective { get; set; }
|
||||
|
||||
public ShipPlanningState Clone() => (ShipPlanningState)MemberwiseClone();
|
||||
@@ -102,13 +109,45 @@ public sealed class SetPatrolObjectiveAction : GoapAction<ShipPlanningState>
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SetAttackObjectiveAction : GoapAction<ShipPlanningState>
|
||||
{
|
||||
public override string Name => "set-attack-objective";
|
||||
public override float Cost => 1f;
|
||||
|
||||
public override bool CheckPreconditions(ShipPlanningState state) =>
|
||||
string.Equals(state.ShipKind, "military", StringComparison.Ordinal)
|
||||
&& state.FactionWantsCombat
|
||||
&& state.TargetEnemyEntityId is not null;
|
||||
|
||||
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
|
||||
{
|
||||
state.CurrentObjective = "attack-target";
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
|
||||
if (ship is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.DefaultBehavior.Kind = "attack-target";
|
||||
ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior?.AreaSystemId ?? ship.DefaultBehavior.AreaSystemId;
|
||||
ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId;
|
||||
ship.DefaultBehavior.Phase = null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SetConstructionObjectiveAction : GoapAction<ShipPlanningState>
|
||||
{
|
||||
public override string Name => "set-construction-objective";
|
||||
public override float Cost => 1f;
|
||||
|
||||
public override bool CheckPreconditions(ShipPlanningState state) =>
|
||||
string.Equals(state.ShipKind, "construction", StringComparison.Ordinal) && state.FactionWantsExpansion;
|
||||
string.Equals(state.ShipKind, "construction", StringComparison.Ordinal)
|
||||
&& (state.FactionWantsExpansion || state.FactionNeedsShipyard);
|
||||
|
||||
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
|
||||
{
|
||||
@@ -129,6 +168,39 @@ public sealed class SetConstructionObjectiveAction : GoapAction<ShipPlanningStat
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SetTradeObjectiveAction : GoapAction<ShipPlanningState>
|
||||
{
|
||||
public override string Name => "set-trade-objective";
|
||||
public override float Cost => 1f;
|
||||
|
||||
public override bool CheckPreconditions(ShipPlanningState state) =>
|
||||
string.Equals(state.ShipKind, "transport", StringComparison.Ordinal)
|
||||
&& state.TradeItemId is not null
|
||||
&& state.TradeSourceStationId is not null
|
||||
&& state.TradeDestinationStationId is not null;
|
||||
|
||||
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
|
||||
{
|
||||
state.CurrentObjective = "trade-haul";
|
||||
return state;
|
||||
}
|
||||
|
||||
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||
{
|
||||
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
|
||||
if (ship is null || commander.ActiveBehavior is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.DefaultBehavior.Kind = "trade-haul";
|
||||
ship.DefaultBehavior.ItemId = commander.ActiveBehavior.ItemId;
|
||||
ship.DefaultBehavior.StationId = commander.ActiveBehavior.StationId;
|
||||
ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior.TargetEntityId;
|
||||
ship.DefaultBehavior.Phase ??= "travel-to-source";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SetIdleObjectiveAction : GoapAction<ShipPlanningState>
|
||||
{
|
||||
public override string Name => "set-idle-objective";
|
||||
|
||||
@@ -42,6 +42,8 @@ public sealed class DefaultBehaviorRuntime
|
||||
{
|
||||
public required string Kind { get; set; }
|
||||
public string? AreaSystemId { get; set; }
|
||||
public string? TargetEntityId { get; set; }
|
||||
public string? ItemId { get; set; }
|
||||
public string? StationId { get; set; }
|
||||
public string? RefineryId { get; set; }
|
||||
public string? NodeId { get; set; }
|
||||
|
||||
@@ -18,6 +18,8 @@ internal sealed class ShipControlService
|
||||
{
|
||||
ship.DefaultBehavior.Kind = commander.ActiveBehavior.Kind;
|
||||
ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior.AreaSystemId;
|
||||
ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior.TargetEntityId;
|
||||
ship.DefaultBehavior.ItemId = commander.ActiveBehavior.ItemId;
|
||||
ship.DefaultBehavior.ModuleId = commander.ActiveBehavior.ModuleId;
|
||||
ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId;
|
||||
ship.DefaultBehavior.Phase = commander.ActiveBehavior.Phase;
|
||||
@@ -61,6 +63,8 @@ internal sealed class ShipControlService
|
||||
commander.ActiveBehavior ??= new CommanderBehaviorRuntime { Kind = ship.DefaultBehavior.Kind };
|
||||
commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind;
|
||||
commander.ActiveBehavior.AreaSystemId = ship.DefaultBehavior.AreaSystemId;
|
||||
commander.ActiveBehavior.TargetEntityId = ship.DefaultBehavior.TargetEntityId;
|
||||
commander.ActiveBehavior.ItemId = ship.DefaultBehavior.ItemId;
|
||||
commander.ActiveBehavior.ModuleId = ship.DefaultBehavior.ModuleId;
|
||||
commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId;
|
||||
commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase;
|
||||
@@ -140,24 +144,193 @@ internal sealed class ShipControlService
|
||||
SyncCommanderTask(commander, ship.ControllerTask);
|
||||
}
|
||||
|
||||
internal void PlanAttackTarget(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var behavior = ship.DefaultBehavior;
|
||||
var target = ResolveAttackTarget(ship, world);
|
||||
if (target is null)
|
||||
{
|
||||
behavior.Kind = "idle";
|
||||
behavior.TargetEntityId = null;
|
||||
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
|
||||
return;
|
||||
}
|
||||
|
||||
behavior.TargetEntityId = target.EntityId;
|
||||
behavior.AreaSystemId = target.SystemId;
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.AttackTarget,
|
||||
TargetEntityId = target.EntityId,
|
||||
TargetSystemId = target.SystemId,
|
||||
TargetPosition = target.Position,
|
||||
Threshold = target.AttackRange,
|
||||
};
|
||||
}
|
||||
|
||||
internal void PlanTransportHaul(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var behavior = ship.DefaultBehavior;
|
||||
var sourceStation = behavior.StationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId);
|
||||
var destinationStation = behavior.TargetEntityId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId);
|
||||
if (sourceStation is null || destinationStation is null || string.IsNullOrWhiteSpace(behavior.ItemId))
|
||||
{
|
||||
behavior.Kind = "idle";
|
||||
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
|
||||
return;
|
||||
}
|
||||
|
||||
var carryingCargo = GetShipCargoAmount(ship) > 0.01f;
|
||||
if (carryingCargo)
|
||||
{
|
||||
if (ship.DockedStationId == destinationStation.Id)
|
||||
{
|
||||
behavior.Phase = "unload";
|
||||
}
|
||||
else if (ship.DockedStationId is not null)
|
||||
{
|
||||
behavior.Phase = "undock-from-source";
|
||||
}
|
||||
else if (behavior.Phase is not "travel-to-destination" and not "dock-destination" and not "unload")
|
||||
{
|
||||
behavior.Phase = "travel-to-destination";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ship.DockedStationId == sourceStation.Id)
|
||||
{
|
||||
var available = GetInventoryAmount(sourceStation.Inventory, behavior.ItemId);
|
||||
behavior.Phase = available > 0.01f ? "load" : "wait-source";
|
||||
}
|
||||
else if (ship.DockedStationId == destinationStation.Id)
|
||||
{
|
||||
behavior.Phase = "undock-from-destination";
|
||||
}
|
||||
else if (behavior.Phase is not "travel-to-source" and not "dock-source" and not "load")
|
||||
{
|
||||
behavior.Phase = "travel-to-source";
|
||||
}
|
||||
}
|
||||
|
||||
ship.ControllerTask = behavior.Phase switch
|
||||
{
|
||||
"travel-to-source" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetEntityId = sourceStation.Id,
|
||||
TargetSystemId = sourceStation.SystemId,
|
||||
TargetPosition = sourceStation.Position,
|
||||
Threshold = sourceStation.Radius + 8f,
|
||||
ItemId = behavior.ItemId,
|
||||
},
|
||||
"dock-source" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Dock,
|
||||
TargetEntityId = sourceStation.Id,
|
||||
TargetSystemId = sourceStation.SystemId,
|
||||
TargetPosition = sourceStation.Position,
|
||||
Threshold = sourceStation.Radius + 4f,
|
||||
ItemId = behavior.ItemId,
|
||||
},
|
||||
"load" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Load,
|
||||
TargetEntityId = sourceStation.Id,
|
||||
TargetSystemId = sourceStation.SystemId,
|
||||
TargetPosition = sourceStation.Position,
|
||||
Threshold = 0f,
|
||||
ItemId = behavior.ItemId,
|
||||
},
|
||||
"undock-from-source" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Undock,
|
||||
TargetEntityId = sourceStation.Id,
|
||||
TargetSystemId = sourceStation.SystemId,
|
||||
TargetPosition = new Vector3(sourceStation.Position.X + world.Balance.UndockDistance, sourceStation.Position.Y, sourceStation.Position.Z),
|
||||
Threshold = 8f,
|
||||
ItemId = behavior.ItemId,
|
||||
},
|
||||
"travel-to-destination" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetEntityId = destinationStation.Id,
|
||||
TargetSystemId = destinationStation.SystemId,
|
||||
TargetPosition = destinationStation.Position,
|
||||
Threshold = destinationStation.Radius + 8f,
|
||||
ItemId = behavior.ItemId,
|
||||
},
|
||||
"dock-destination" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Dock,
|
||||
TargetEntityId = destinationStation.Id,
|
||||
TargetSystemId = destinationStation.SystemId,
|
||||
TargetPosition = destinationStation.Position,
|
||||
Threshold = destinationStation.Radius + 4f,
|
||||
ItemId = behavior.ItemId,
|
||||
},
|
||||
"unload" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Unload,
|
||||
TargetEntityId = destinationStation.Id,
|
||||
TargetSystemId = destinationStation.SystemId,
|
||||
TargetPosition = destinationStation.Position,
|
||||
Threshold = 0f,
|
||||
ItemId = behavior.ItemId,
|
||||
},
|
||||
"undock-from-destination" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Undock,
|
||||
TargetEntityId = destinationStation.Id,
|
||||
TargetSystemId = destinationStation.SystemId,
|
||||
TargetPosition = new Vector3(destinationStation.Position.X + world.Balance.UndockDistance, destinationStation.Position.Y, destinationStation.Position.Z),
|
||||
Threshold = 8f,
|
||||
ItemId = behavior.ItemId,
|
||||
},
|
||||
_ => CreateIdleTask(world.Balance.ArrivalThreshold),
|
||||
};
|
||||
}
|
||||
|
||||
internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule)
|
||||
{
|
||||
var behavior = ship.DefaultBehavior;
|
||||
var refinery = SelectBestBuyStation(world, ship, resourceItemId, behavior.StationId);
|
||||
var cargoItemId = ship.Inventory.Keys.FirstOrDefault();
|
||||
var targetResourceItemId = SelectMiningResourceItem(world, ship, cargoItemId ?? behavior.ItemId ?? resourceItemId);
|
||||
if (!string.Equals(behavior.ItemId, targetResourceItemId, StringComparison.Ordinal))
|
||||
{
|
||||
behavior.ItemId = targetResourceItemId;
|
||||
behavior.NodeId = null;
|
||||
}
|
||||
|
||||
var refinery = SelectBestBuyStation(world, ship, targetResourceItemId, behavior.StationId);
|
||||
behavior.StationId = refinery?.Id;
|
||||
var node = behavior.NodeId is null
|
||||
? world.Nodes
|
||||
.Where(candidate =>
|
||||
(behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) &&
|
||||
candidate.ItemId == resourceItemId &&
|
||||
candidate.OreRemaining > 0.01f)
|
||||
.OrderByDescending(candidate => candidate.OreRemaining)
|
||||
candidate.ItemId == targetResourceItemId &&
|
||||
candidate.OreRemaining > 0.01f &&
|
||||
CanShipMineItem(world, ship, candidate.ItemId))
|
||||
.OrderByDescending(candidate => candidate.SystemId == behavior.AreaSystemId ? 1 : 0)
|
||||
.ThenByDescending(candidate => candidate.OreRemaining)
|
||||
.FirstOrDefault()
|
||||
: world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && candidate.OreRemaining > 0.01f);
|
||||
: world.Nodes.FirstOrDefault(candidate =>
|
||||
candidate.Id == behavior.NodeId &&
|
||||
string.Equals(candidate.ItemId, targetResourceItemId, StringComparison.Ordinal) &&
|
||||
candidate.OreRemaining > 0.01f);
|
||||
|
||||
if (node is not null)
|
||||
{
|
||||
behavior.AreaSystemId = node.SystemId;
|
||||
}
|
||||
|
||||
if (refinery is null || node is null || !HasShipCapabilities(ship.Definition, requiredModule))
|
||||
{
|
||||
behavior.Kind = "idle";
|
||||
if (refinery is null && GetShipCargoAmount(ship) > 0.01f)
|
||||
{
|
||||
ship.Inventory.Clear();
|
||||
}
|
||||
|
||||
behavior.Phase = null;
|
||||
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
|
||||
return;
|
||||
}
|
||||
@@ -253,6 +426,55 @@ internal sealed class ShipControlService
|
||||
}
|
||||
}
|
||||
|
||||
private static string SelectMiningResourceItem(SimulationWorld world, ShipRuntime ship, string fallbackItemId)
|
||||
{
|
||||
var candidateItemId = world.MarketOrders
|
||||
.Where(order =>
|
||||
string.Equals(order.FactionId, ship.FactionId, StringComparison.Ordinal)
|
||||
&& order.Kind == MarketOrderKinds.Buy
|
||||
&& order.RemainingAmount > 0.01f)
|
||||
.SelectMany(order => FactionIndustryPlanner.ResolveRootResourceItems(world, order.ItemId)
|
||||
.Select(itemId => new
|
||||
{
|
||||
ItemId = itemId,
|
||||
Score = order.RemainingAmount * MathF.Max(0.25f, order.Valuation),
|
||||
}))
|
||||
.Where(entry =>
|
||||
CanShipMineItem(world, ship, entry.ItemId)
|
||||
&& world.Nodes.Any(node => string.Equals(node.ItemId, entry.ItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f))
|
||||
.GroupBy(entry => entry.ItemId, StringComparer.Ordinal)
|
||||
.Select(group => new
|
||||
{
|
||||
ItemId = group.Key,
|
||||
Score = group.Sum(entry => entry.Score) + (string.Equals(group.Key, ship.DefaultBehavior.ItemId, StringComparison.Ordinal) ? 15f : 0f),
|
||||
})
|
||||
.OrderByDescending(entry => entry.Score)
|
||||
.Select(entry => entry.ItemId)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(candidateItemId))
|
||||
{
|
||||
return candidateItemId;
|
||||
}
|
||||
|
||||
if (CanShipMineItem(world, ship, fallbackItemId)
|
||||
&& world.Nodes.Any(node => string.Equals(node.ItemId, fallbackItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f))
|
||||
{
|
||||
return fallbackItemId;
|
||||
}
|
||||
|
||||
return world.Nodes
|
||||
.Where(node => node.OreRemaining > 0.01f && CanShipMineItem(world, ship, node.ItemId))
|
||||
.OrderByDescending(node => node.OreRemaining)
|
||||
.Select(node => node.ItemId)
|
||||
.FirstOrDefault() ?? fallbackItemId;
|
||||
}
|
||||
|
||||
private static bool CanShipMineItem(SimulationWorld world, ShipRuntime ship, string itemId) =>
|
||||
world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)
|
||||
&& string.Equals(itemDefinition.CargoKind, ship.Definition.CargoKind, StringComparison.Ordinal)
|
||||
&& HasShipCapabilities(ship.Definition, "mining");
|
||||
|
||||
internal static StationRuntime? SelectBestBuyStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId)
|
||||
{
|
||||
var preferred = preferredStationId is null
|
||||
@@ -267,7 +489,8 @@ internal sealed class ShipControlService
|
||||
order.ItemId == itemId &&
|
||||
order.RemainingAmount > 0.01f)
|
||||
.Select(order => (Order: order, Station: world.Stations.FirstOrDefault(station => station.Id == order.StationId)))
|
||||
.Where(entry => entry.Station is not null)
|
||||
.Where(entry => entry.Station is not null && string.Equals(entry.Station.FactionId, ship.FactionId, StringComparison.Ordinal))
|
||||
.Where(entry => CanStationReceiveItem(world, entry.Station!, itemId))
|
||||
.OrderByDescending(entry =>
|
||||
{
|
||||
var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f;
|
||||
@@ -275,7 +498,18 @@ internal sealed class ShipControlService
|
||||
})
|
||||
.FirstOrDefault();
|
||||
|
||||
return bestOrder.Station ?? preferred;
|
||||
return bestOrder.Station ?? (preferred is not null && CanStationReceiveItem(world, preferred, itemId) ? preferred : null);
|
||||
}
|
||||
|
||||
private static bool CanStationReceiveItem(SimulationWorld world, StationRuntime station, string itemId)
|
||||
{
|
||||
if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var requiredModule = GetStorageRequirement(itemDefinition.CargoKind);
|
||||
return requiredModule is null || station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static ControllerTaskRuntime CreateStationSupportTask(SimulationWorld world, ShipRuntime ship, StationRuntime station, string? phase) =>
|
||||
@@ -320,7 +554,9 @@ internal sealed class ShipControlService
|
||||
{
|
||||
var behavior = ship.DefaultBehavior;
|
||||
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId);
|
||||
var site = station is null ? null : GetConstructionSiteForStation(world, station.Id);
|
||||
var site = !string.IsNullOrWhiteSpace(behavior.TargetEntityId)
|
||||
? world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId)
|
||||
: station is null ? null : GetConstructionSiteForStation(world, station.Id);
|
||||
if (station is null)
|
||||
{
|
||||
behavior.Kind = "idle";
|
||||
@@ -328,6 +564,13 @@ internal sealed class ShipControlService
|
||||
return;
|
||||
}
|
||||
|
||||
if (site is null && !string.IsNullOrWhiteSpace(behavior.TargetEntityId))
|
||||
{
|
||||
behavior.TargetEntityId = null;
|
||||
behavior.ModuleId = null;
|
||||
site = GetConstructionSiteForStation(world, station.Id);
|
||||
}
|
||||
|
||||
var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world);
|
||||
behavior.ModuleId = moduleId;
|
||||
if (moduleId is null)
|
||||
@@ -347,13 +590,17 @@ internal sealed class ShipControlService
|
||||
|
||||
ship.DockedStationId = null;
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
ship.Position = GetConstructionHoldPosition(station, ship.Id);
|
||||
ship.Position = ResolveConstructionHoldPosition(ship, station, site, world);
|
||||
ship.TargetPosition = ship.Position;
|
||||
}
|
||||
|
||||
var constructionHoldPosition = GetConstructionHoldPosition(station, ship.Id);
|
||||
var isAtConstructionHold = ship.SystemId == station.SystemId
|
||||
&& ship.Position.DistanceTo(constructionHoldPosition) <= 10f;
|
||||
var constructionHoldPosition = ResolveConstructionHoldPosition(ship, station, site, world);
|
||||
var targetSystemId = site?.SystemId ?? station.SystemId;
|
||||
var targetCelestialId = site?.CelestialId ?? station.CelestialId;
|
||||
var isAtTargetCelestial = !string.IsNullOrWhiteSpace(targetCelestialId)
|
||||
&& string.Equals(ship.SpatialState.CurrentCelestialId, targetCelestialId, StringComparison.Ordinal);
|
||||
var isAtConstructionHold = ship.SystemId == targetSystemId
|
||||
&& (ship.Position.DistanceTo(constructionHoldPosition) <= 10f || isAtTargetCelestial);
|
||||
|
||||
if (isAtConstructionHold)
|
||||
{
|
||||
@@ -390,7 +637,7 @@ internal sealed class ShipControlService
|
||||
{
|
||||
Kind = ControllerTaskKind.ConstructModule,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 10f,
|
||||
};
|
||||
@@ -400,7 +647,7 @@ internal sealed class ShipControlService
|
||||
{
|
||||
Kind = ControllerTaskKind.DeliverConstruction,
|
||||
TargetEntityId = site?.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 10f,
|
||||
};
|
||||
@@ -410,7 +657,7 @@ internal sealed class ShipControlService
|
||||
{
|
||||
Kind = ControllerTaskKind.BuildConstructionSite,
|
||||
TargetEntityId = site?.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 10f,
|
||||
};
|
||||
@@ -419,8 +666,8 @@ internal sealed class ShipControlService
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Idle,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetEntityId = site?.Id ?? station.Id,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 0f,
|
||||
};
|
||||
@@ -429,8 +676,8 @@ internal sealed class ShipControlService
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetEntityId = site?.Id ?? station.Id,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 10f,
|
||||
};
|
||||
@@ -539,6 +786,7 @@ internal sealed class ShipControlService
|
||||
"unload" => ControllerTaskKind.Unload,
|
||||
"deliver-construction" => ControllerTaskKind.DeliverConstruction,
|
||||
"build-construction-site" => ControllerTaskKind.BuildConstructionSite,
|
||||
"attack-target" => ControllerTaskKind.AttackTarget,
|
||||
|
||||
"construct-module" => ControllerTaskKind.ConstructModule,
|
||||
"undock" => ControllerTaskKind.Undock,
|
||||
@@ -563,4 +811,62 @@ internal sealed class ShipControlService
|
||||
Threshold = task.Threshold,
|
||||
};
|
||||
}
|
||||
|
||||
private static Vector3 ResolveConstructionHoldPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world)
|
||||
{
|
||||
if (site is null || site.StationId is not null)
|
||||
{
|
||||
return GetConstructionHoldPosition(station, ship.Id);
|
||||
}
|
||||
|
||||
var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
|
||||
var anchorPosition = anchor?.Position ?? station.Position;
|
||||
return GetResourceHoldPosition(anchorPosition, ship.Id, 78f);
|
||||
}
|
||||
|
||||
private static AttackTargetCandidate? ResolveAttackTarget(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ship.DefaultBehavior.TargetEntityId))
|
||||
{
|
||||
var direct = ResolveAttackTargetCandidate(world, ship.DefaultBehavior.TargetEntityId!);
|
||||
if (direct is not null && !string.Equals(direct.FactionId, ship.FactionId, StringComparison.Ordinal))
|
||||
{
|
||||
return direct;
|
||||
}
|
||||
}
|
||||
|
||||
var hostileShips = world.Ships
|
||||
.Where(candidate => candidate.Health > 0f && !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
|
||||
.Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, 26f))
|
||||
.ToList();
|
||||
|
||||
var hostileStations = world.Stations
|
||||
.Where(candidate => !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
|
||||
.Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, candidate.Radius + 18f))
|
||||
.ToList();
|
||||
|
||||
var preferredSystemId = ship.DefaultBehavior.AreaSystemId;
|
||||
return hostileShips
|
||||
.Concat(hostileStations)
|
||||
.OrderBy(candidate => preferredSystemId is null || candidate.SystemId == preferredSystemId ? 0 : 1)
|
||||
.ThenBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1)
|
||||
.ThenBy(candidate => candidate.Position.DistanceTo(ship.Position))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static AttackTargetCandidate? ResolveAttackTargetCandidate(SimulationWorld world, string entityId)
|
||||
{
|
||||
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == entityId && candidate.Health > 0f);
|
||||
if (ship is not null)
|
||||
{
|
||||
return new AttackTargetCandidate(ship.Id, ship.FactionId, ship.SystemId, ship.Position, 26f);
|
||||
}
|
||||
|
||||
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == entityId);
|
||||
return station is null
|
||||
? null
|
||||
: new AttackTargetCandidate(station.Id, station.FactionId, station.SystemId, station.Position, station.Radius + 18f);
|
||||
}
|
||||
|
||||
private sealed record AttackTargetCandidate(string EntityId, string FactionId, string SystemId, Vector3 Position, float AttackRange);
|
||||
}
|
||||
|
||||
@@ -166,10 +166,12 @@ internal sealed partial class ShipTaskExecutionService
|
||||
BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship));
|
||||
|
||||
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId);
|
||||
var transferredAny = false;
|
||||
foreach (var (itemId, amount) in ship.Inventory.ToList())
|
||||
{
|
||||
var moved = MathF.Min(amount, world.Balance.TransferRate * deltaSeconds);
|
||||
var accepted = TryAddStationInventory(world, station, itemId, moved);
|
||||
transferredAny |= accepted > 0.01f;
|
||||
RemoveInventory(ship.Inventory, itemId, accepted);
|
||||
if (faction is not null && string.Equals(itemId, "ore", StringComparison.Ordinal))
|
||||
{
|
||||
@@ -178,6 +180,12 @@ internal sealed partial class ShipTaskExecutionService
|
||||
}
|
||||
}
|
||||
|
||||
if (!transferredAny && GetShipCargoAmount(ship) > 0.01f && HasShipCapabilities(ship.Definition, "mining"))
|
||||
{
|
||||
ship.Inventory.Clear();
|
||||
return "unloaded";
|
||||
}
|
||||
|
||||
return GetShipCargoAmount(ship) <= 0.01f ? "unloaded" : "none";
|
||||
}
|
||||
|
||||
@@ -239,7 +247,7 @@ internal sealed partial class ShipTaskExecutionService
|
||||
return "none";
|
||||
}
|
||||
|
||||
var supportPosition = ResolveShipSupportPosition(ship, station);
|
||||
var supportPosition = ResolveShipSupportPosition(ship, station, null, world);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
||||
{
|
||||
ship.State = ShipState.LocalFlight;
|
||||
@@ -296,7 +304,7 @@ internal sealed partial class ShipTaskExecutionService
|
||||
return "none";
|
||||
}
|
||||
|
||||
var supportPosition = ResolveShipSupportPosition(ship, station);
|
||||
var supportPosition = ResolveShipSupportPosition(ship, station, site, world);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
||||
{
|
||||
ship.State = ShipState.LocalFlight;
|
||||
@@ -313,6 +321,28 @@ internal sealed partial class ShipTaskExecutionService
|
||||
|
||||
if (site.StationId is not null)
|
||||
{
|
||||
foreach (var required in site.RequiredItems)
|
||||
{
|
||||
var delivered = GetInventoryAmount(site.DeliveredItems, required.Key);
|
||||
var remaining = MathF.Max(0f, required.Value - delivered);
|
||||
if (remaining <= 0.01f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds);
|
||||
moved = MathF.Min(moved, GetInventoryAmount(station.Inventory, required.Key));
|
||||
if (moved <= 0.01f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
RemoveInventory(station.Inventory, required.Key, moved);
|
||||
AddInventory(site.Inventory, required.Key, moved);
|
||||
AddInventory(site.DeliveredItems, required.Key, moved);
|
||||
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
|
||||
}
|
||||
|
||||
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
|
||||
}
|
||||
|
||||
@@ -359,7 +389,7 @@ internal sealed partial class ShipTaskExecutionService
|
||||
return "none";
|
||||
}
|
||||
|
||||
var supportPosition = ResolveShipSupportPosition(ship, station);
|
||||
var supportPosition = ResolveShipSupportPosition(ship, station, site, world);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
||||
{
|
||||
ship.State = ShipState.LocalFlight;
|
||||
@@ -386,8 +416,16 @@ internal sealed partial class ShipTaskExecutionService
|
||||
return "none";
|
||||
}
|
||||
|
||||
AddStationModule(world, station, site.BlueprintId);
|
||||
PrepareNextConstructionSiteStep(world, station, site);
|
||||
if (site.StationId is null)
|
||||
{
|
||||
CompleteStationFoundation(world, station, site);
|
||||
}
|
||||
else
|
||||
{
|
||||
AddStationModule(world, station, site.BlueprintId);
|
||||
PrepareNextConstructionSiteStep(world, station, site);
|
||||
}
|
||||
|
||||
return "site-constructed";
|
||||
}
|
||||
|
||||
@@ -398,10 +436,21 @@ internal sealed partial class ShipTaskExecutionService
|
||||
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId)
|
||||
: null;
|
||||
|
||||
private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station) =>
|
||||
ship.DockedStationId is not null
|
||||
? GetShipDockedPosition(ship, station)
|
||||
: GetConstructionHoldPosition(station, ship.Id);
|
||||
private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world)
|
||||
{
|
||||
if (ship.DockedStationId is not null)
|
||||
{
|
||||
return GetShipDockedPosition(ship, station);
|
||||
}
|
||||
|
||||
if (site?.StationId is null && site is not null)
|
||||
{
|
||||
var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position;
|
||||
return GetResourceHoldPosition(anchorPosition, ship.Id, 78f);
|
||||
}
|
||||
|
||||
return GetConstructionHoldPosition(station, ship.Id);
|
||||
}
|
||||
|
||||
private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) =>
|
||||
ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f);
|
||||
@@ -453,4 +502,88 @@ internal sealed partial class ShipTaskExecutionService
|
||||
|
||||
internal static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) =>
|
||||
site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)));
|
||||
|
||||
private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site)
|
||||
{
|
||||
var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
|
||||
if (anchor is null || site.BlueprintId is null)
|
||||
{
|
||||
site.State = ConstructionSiteStateKinds.Destroyed;
|
||||
return;
|
||||
}
|
||||
|
||||
var station = new StationRuntime
|
||||
{
|
||||
Id = $"station-{world.Stations.Count + 1}",
|
||||
SystemId = site.SystemId,
|
||||
Label = BuildFoundedStationLabel(site.TargetDefinitionId),
|
||||
Category = "station",
|
||||
Objective = DetermineFoundationObjective(site.TargetDefinitionId),
|
||||
Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color,
|
||||
Position = anchor.Position,
|
||||
FactionId = site.FactionId,
|
||||
CelestialId = site.CelestialId,
|
||||
Health = 600f,
|
||||
MaxHealth = 600f,
|
||||
};
|
||||
|
||||
foreach (var moduleId in GetFoundationModules(world, site.BlueprintId))
|
||||
{
|
||||
AddStationModule(world, station, moduleId);
|
||||
}
|
||||
|
||||
world.Stations.Add(station);
|
||||
StationLifecycleService.EnsureStationCommander(world, station);
|
||||
anchor.OccupyingStructureId = station.Id;
|
||||
site.StationId = station.Id;
|
||||
PrepareNextConstructionSiteStep(world, station, site);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetFoundationModules(SimulationWorld world, string primaryModuleId)
|
||||
{
|
||||
var modules = new List<string> { "module_arg_dock_m_01_lowtech" };
|
||||
foreach (var itemId in world.ProductionGraph.OutputsByModuleId.GetValueOrDefault(primaryModuleId, []))
|
||||
{
|
||||
if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
|
||||
{
|
||||
var storageModule = GetStorageRequirement(itemDefinition.CargoKind);
|
||||
if (storageModule is not null && !modules.Contains(storageModule, StringComparer.Ordinal))
|
||||
{
|
||||
modules.Add(storageModule);
|
||||
}
|
||||
else if (storageModule is null && !modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal))
|
||||
{
|
||||
modules.Add("module_arg_stor_container_m_01");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal))
|
||||
{
|
||||
modules.Add("module_arg_stor_container_m_01");
|
||||
}
|
||||
|
||||
if (!string.Equals(primaryModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal))
|
||||
{
|
||||
modules.Add("module_gen_prod_energycells_01");
|
||||
}
|
||||
|
||||
modules.Add(primaryModuleId);
|
||||
return modules.Distinct(StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static string DetermineFoundationObjective(string commodityId) =>
|
||||
commodityId switch
|
||||
{
|
||||
"energycells" => "power",
|
||||
"water" => "water",
|
||||
"refinedmetals" => "refinery",
|
||||
"hullparts" => "hullparts",
|
||||
"claytronics" => "claytronics",
|
||||
"shipyard" => "shipyard",
|
||||
_ => "general",
|
||||
};
|
||||
|
||||
private static string BuildFoundedStationLabel(string commodityId) =>
|
||||
$"{char.ToUpperInvariant(commodityId[0])}{commodityId[1..]} Foundry";
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ namespace SpaceGame.Api.Ships.Simulation;
|
||||
internal sealed partial class ShipTaskExecutionService
|
||||
{
|
||||
private const float WarpEngageDistanceKilometers = 250_000f;
|
||||
private const float FrigateDps = 7f;
|
||||
private const float DestroyerDps = 12f;
|
||||
private const float CruiserDps = 18f;
|
||||
private const float CapitalDps = 26f;
|
||||
|
||||
private static float GetLocalTravelSpeed(ShipRuntime ship) =>
|
||||
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed);
|
||||
@@ -30,6 +34,7 @@ internal sealed partial class ShipTaskExecutionService
|
||||
ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.AttackTarget => UpdateAttackTarget(ship, world, deltaSeconds),
|
||||
|
||||
ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Undock => UpdateUndock(ship, world, deltaSeconds),
|
||||
@@ -47,6 +52,11 @@ internal sealed partial class ShipTaskExecutionService
|
||||
private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
var task = ship.ControllerTask;
|
||||
return UpdateTravel(ship, world, deltaSeconds, task);
|
||||
}
|
||||
|
||||
private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds, ControllerTaskRuntime task)
|
||||
{
|
||||
if (task.TargetPosition is null || task.TargetSystemId is null)
|
||||
{
|
||||
ship.State = ShipState.Idle;
|
||||
@@ -94,6 +104,66 @@ internal sealed partial class ShipTaskExecutionService
|
||||
return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold);
|
||||
}
|
||||
|
||||
private string UpdateAttackTarget(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
var task = ship.ControllerTask;
|
||||
if (string.IsNullOrWhiteSpace(task.TargetEntityId))
|
||||
{
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "target-lost";
|
||||
}
|
||||
|
||||
var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId && candidate.Health > 0f);
|
||||
var hostileStation = hostileShip is null
|
||||
? world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId)
|
||||
: null;
|
||||
|
||||
if ((hostileShip is not null && string.Equals(hostileShip.FactionId, ship.FactionId, StringComparison.Ordinal))
|
||||
|| (hostileStation is not null && string.Equals(hostileStation.FactionId, ship.FactionId, StringComparison.Ordinal)))
|
||||
{
|
||||
return "target-lost";
|
||||
}
|
||||
|
||||
if (hostileShip is null && hostileStation is null)
|
||||
{
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "target-lost";
|
||||
}
|
||||
|
||||
var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId;
|
||||
var targetPosition = hostileShip?.Position ?? hostileStation!.Position;
|
||||
var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f;
|
||||
var attackTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetEntityId = task.TargetEntityId,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = targetPosition,
|
||||
Threshold = attackRange,
|
||||
};
|
||||
|
||||
if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange)
|
||||
{
|
||||
return UpdateTravel(ship, world, deltaSeconds, attackTask);
|
||||
}
|
||||
|
||||
ship.State = ShipState.EngagingTarget;
|
||||
ship.TargetPosition = targetPosition;
|
||||
ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f));
|
||||
var damage = GetShipDamagePerSecond(ship) * deltaSeconds;
|
||||
|
||||
if (hostileShip is not null)
|
||||
{
|
||||
hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage);
|
||||
return hostileShip.Health <= 0f ? "target-destroyed" : "none";
|
||||
}
|
||||
|
||||
hostileStation!.Health = MathF.Max(0f, hostileStation.Health - damage * 0.6f);
|
||||
return hostileStation.Health <= 0f ? "target-destroyed" : "none";
|
||||
}
|
||||
|
||||
private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ControllerTaskRuntime task)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
|
||||
@@ -309,4 +379,14 @@ internal sealed partial class ShipTaskExecutionService
|
||||
ship.State = ShipState.Arriving;
|
||||
return "none";
|
||||
}
|
||||
|
||||
private static float GetShipDamagePerSecond(ShipRuntime ship) =>
|
||||
ship.Definition.Class switch
|
||||
{
|
||||
"frigate" => FrigateDps,
|
||||
"destroyer" => DestroyerDps,
|
||||
"cruiser" => CruiserDps,
|
||||
"capital" => CapitalDps,
|
||||
_ => 4f,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,8 +39,13 @@ public sealed class SimulationEngine
|
||||
_commanderPlanning.UpdateCommanders(this, world, deltaSeconds, events);
|
||||
_stationLifecycle.UpdateStations(world, deltaSeconds, events);
|
||||
|
||||
foreach (var ship in world.Ships)
|
||||
foreach (var ship in world.Ships.ToList())
|
||||
{
|
||||
if (ship.Health <= 0f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var previousPosition = ship.Position;
|
||||
var previousState = ship.State;
|
||||
var previousBehavior = ship.DefaultBehavior.Kind;
|
||||
@@ -58,6 +63,7 @@ public sealed class SimulationEngine
|
||||
}
|
||||
|
||||
_orbitalStateUpdater.SyncSpatialState(world);
|
||||
CleanupDestroyedEntities(world, events);
|
||||
world.GeneratedAtUtc = nowUtc;
|
||||
|
||||
return _projection.BuildDelta(world, sequence, events);
|
||||
@@ -75,6 +81,60 @@ public sealed class SimulationEngine
|
||||
internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) =>
|
||||
_shipControl.PlanStationConstruction(ship, world);
|
||||
|
||||
internal void PlanAttackTarget(ShipRuntime ship, SimulationWorld world) =>
|
||||
_shipControl.PlanAttackTarget(ship, world);
|
||||
|
||||
internal void PlanTransportHaul(ShipRuntime ship, SimulationWorld world) =>
|
||||
_shipControl.PlanTransportHaul(ship, world);
|
||||
|
||||
internal static float GetShipCargoAmount(ShipRuntime ship) =>
|
||||
SimulationRuntimeSupport.GetShipCargoAmount(ship);
|
||||
|
||||
private static void CleanupDestroyedEntities(SimulationWorld world, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
foreach (var ship in world.Ships.Where(candidate => candidate.Health <= 0f).ToList())
|
||||
{
|
||||
world.Ships.Remove(ship);
|
||||
if (ship.DockedStationId is not null && world.Stations.FirstOrDefault(station => station.Id == ship.DockedStationId) is { } dockedStation)
|
||||
{
|
||||
dockedStation.DockedShipIds.Remove(ship.Id);
|
||||
dockedStation.DockingPadAssignments.Remove(ship.AssignedDockingPadIndex ?? -1);
|
||||
}
|
||||
|
||||
if (world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId) is { } faction)
|
||||
{
|
||||
faction.ShipsLost += 1;
|
||||
}
|
||||
|
||||
if (ship.CommanderId is not null && world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId) is { } commander)
|
||||
{
|
||||
commander.IsAlive = false;
|
||||
}
|
||||
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "destroyed", $"{ship.Definition.Label} was destroyed.", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
foreach (var station in world.Stations.Where(candidate => candidate.Health <= 0f).ToList())
|
||||
{
|
||||
world.Stations.Remove(station);
|
||||
|
||||
if (station.CelestialId is not null && world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId) is { } celestial)
|
||||
{
|
||||
celestial.OccupyingStructureId = null;
|
||||
}
|
||||
|
||||
foreach (var claim in world.Claims.Where(candidate => candidate.CelestialId == station.CelestialId))
|
||||
{
|
||||
claim.Health = 0f;
|
||||
claim.State = ClaimStateKinds.Destroyed;
|
||||
}
|
||||
|
||||
foreach (var site in world.ConstructionSites.Where(candidate => candidate.StationId == station.Id))
|
||||
{
|
||||
site.State = ConstructionSiteStateKinds.Destroyed;
|
||||
}
|
||||
|
||||
events.Add(new SimulationEventRecord("station", station.Id, "destroyed", $"{station.Label} was destroyed.", DateTimeOffset.UtcNow));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ internal sealed class SimulationProjectionService
|
||||
station.Id,
|
||||
station.Label,
|
||||
station.Category,
|
||||
station.Objective,
|
||||
station.SystemId,
|
||||
station.LocalPosition,
|
||||
station.CelestialId,
|
||||
@@ -544,6 +545,7 @@ internal sealed class SimulationProjectionService
|
||||
station.Id,
|
||||
station.Label,
|
||||
station.Category,
|
||||
station.Objective,
|
||||
station.SystemId,
|
||||
ToDto(station.Position),
|
||||
station.CelestialId,
|
||||
@@ -770,8 +772,16 @@ internal sealed class SimulationProjectionService
|
||||
ps.ControlledSystemCount,
|
||||
ps.TargetSystemCount,
|
||||
ps.HasShipFactory,
|
||||
ps.OreStockpile,
|
||||
ps.RefinedMetalsStockpile);
|
||||
NormalizeFiniteFloat(ps.OreStockpile),
|
||||
NormalizeFiniteFloat(ps.RefinedMetalsStockpile),
|
||||
NormalizeFiniteFloat(ps.RefinedMetalsProductionRate),
|
||||
NormalizeFiniteFloat(ps.HullpartsStockpile),
|
||||
NormalizeFiniteFloat(ps.HullpartsProductionRate),
|
||||
NormalizeFiniteFloat(ps.ClaytronicsStockpile),
|
||||
NormalizeFiniteFloat(ps.ClaytronicsProductionRate),
|
||||
NormalizeFiniteFloat(ps.WaterStockpile),
|
||||
NormalizeFiniteFloat(ps.WaterProductionRate),
|
||||
NormalizeFiniteFloat(ps.WaterShortageHorizonSeconds));
|
||||
}
|
||||
|
||||
if (commander?.LastGoalPriorities is { } prios)
|
||||
@@ -811,4 +821,7 @@ internal sealed class SimulationProjectionService
|
||||
state.Transit.Progress));
|
||||
|
||||
private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z);
|
||||
|
||||
private static float NormalizeFiniteFloat(float value) =>
|
||||
float.IsFinite(value) ? value : -1f;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ public sealed record StationSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string Category,
|
||||
string Objective,
|
||||
string SystemId,
|
||||
Vector3Dto LocalPosition,
|
||||
string? CelestialId,
|
||||
@@ -32,6 +33,7 @@ public sealed record StationDelta(
|
||||
string Id,
|
||||
string Label,
|
||||
string Category,
|
||||
string Objective,
|
||||
string SystemId,
|
||||
Vector3Dto LocalPosition,
|
||||
string? CelestialId,
|
||||
|
||||
@@ -6,6 +6,7 @@ public sealed class StationRuntime
|
||||
public required string SystemId { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public string Category { get; set; } = "station";
|
||||
public string Objective { get; set; } = "general";
|
||||
public string Color { get; set; } = "#8df0d2";
|
||||
public required Vector3 Position { get; set; }
|
||||
public float Radius { get; set; } = 24f;
|
||||
@@ -14,6 +15,8 @@ public sealed class StationRuntime
|
||||
public string? CommanderId { get; set; }
|
||||
public string? PolicySetId { get; set; }
|
||||
public List<StationModuleRuntime> Modules { get; } = [];
|
||||
public float Health { get; set; } = 600f;
|
||||
public float MaxHealth { get; set; } = 600f;
|
||||
public IEnumerable<string> InstalledModules => Modules.Select((module) => module.ModuleId);
|
||||
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, float> ProductionLaneTimers { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
@@ -105,16 +105,66 @@ internal sealed class InfrastructureSimulationService
|
||||
|
||||
internal static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world)
|
||||
{
|
||||
// Expand storage before it becomes a bottleneck
|
||||
const float StorageExpansionThreshold = 0.85f;
|
||||
var storageExpansionCandidates = new[]
|
||||
var economy = FactionEconomyAnalyzer.Build(world, station.FactionId);
|
||||
return GetModuleExpansionCandidates(world, station, economy)
|
||||
.Where(candidate => world.ModuleRecipes.ContainsKey(candidate.ModuleId))
|
||||
.OrderByDescending(candidate => candidate.Score)
|
||||
.Select(candidate => candidate.ModuleId)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ModuleExpansionCandidate> GetModuleExpansionCandidates(
|
||||
SimulationWorld world,
|
||||
StationRuntime station,
|
||||
FactionEconomySnapshot economy)
|
||||
{
|
||||
var role = StationSimulationService.DetermineStationRole(station);
|
||||
var candidates = new Dictionary<string, float>(StringComparer.Ordinal);
|
||||
var constructionDemandByItem = GetOutstandingConstructionDemand(world, station.FactionId);
|
||||
var objectiveCommodity = GetObjectiveCommodityId(role);
|
||||
var objectiveModuleId = GetObjectiveModuleId(world, role, objectiveCommodity);
|
||||
|
||||
if (objectiveModuleId is not null && world.ModuleRecipes.TryGetValue(objectiveModuleId, out var objectiveRecipe))
|
||||
{
|
||||
AddOrRaiseCandidate(candidates, objectiveModuleId, ScoreObjectiveModule(world, station, economy, constructionDemandByItem, objectiveCommodity, objectiveModuleId));
|
||||
|
||||
foreach (var storageModuleId in GetRequiredStorageModules(world, objectiveRecipe))
|
||||
{
|
||||
if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal))
|
||||
{
|
||||
AddOrRaiseCandidate(candidates, storageModuleId, ScoreStorageModule(world, station, storageModuleId, objectiveModuleId, objectiveCommodity, requiredByObjective: true));
|
||||
}
|
||||
}
|
||||
|
||||
if (objectiveCommodity is not null
|
||||
&& world.ProductionGraph.GetImmediateInputs(objectiveCommodity).Contains("energycells", StringComparer.Ordinal))
|
||||
{
|
||||
AddOrRaiseCandidate(candidates, "module_gen_prod_energycells_01", ScoreEnergySupportModule(world, station, economy, constructionDemandByItem));
|
||||
}
|
||||
}
|
||||
|
||||
AddOrRaiseCandidate(candidates, "module_arg_dock_m_01_lowtech", ScoreDockModule(station));
|
||||
AddOrRaiseCandidate(candidates, "module_arg_hab_m_01", ScoreHabitationModule(station, world, economy));
|
||||
|
||||
foreach (var storageModuleId in GetStoragePressureCandidates(world, station))
|
||||
{
|
||||
AddOrRaiseCandidate(candidates, storageModuleId, ScoreStorageModule(world, station, storageModuleId, objectiveModuleId, objectiveCommodity, requiredByObjective: false));
|
||||
}
|
||||
|
||||
return candidates
|
||||
.Where(entry => entry.Value > 0.01f)
|
||||
.Select(entry => new ModuleExpansionCandidate(entry.Key, entry.Value))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetStoragePressureCandidates(SimulationWorld world, StationRuntime station)
|
||||
{
|
||||
foreach (var (storageClass, moduleId) in new[]
|
||||
{
|
||||
("solid", "module_arg_stor_solid_m_01"),
|
||||
("liquid", "module_arg_stor_liquid_m_01"),
|
||||
("container", "module_arg_stor_container_m_01"),
|
||||
};
|
||||
|
||||
foreach (var (storageClass, moduleId) in storageExpansionCandidates)
|
||||
})
|
||||
{
|
||||
var capacity = GetStationStorageCapacity(station, storageClass);
|
||||
if (capacity <= 0.01f)
|
||||
@@ -123,51 +173,552 @@ internal sealed class InfrastructureSimulationService
|
||||
}
|
||||
|
||||
var used = station.Inventory
|
||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass)
|
||||
.Sum(entry => entry.Value);
|
||||
|
||||
if (used / capacity >= StorageExpansionThreshold && world.ModuleRecipes.ContainsKey(moduleId))
|
||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass)
|
||||
.Sum(entry => entry.Value);
|
||||
if (used / capacity >= 0.65f)
|
||||
{
|
||||
return moduleId;
|
||||
yield return moduleId;
|
||||
}
|
||||
}
|
||||
|
||||
var priorities = StationSimulationService.GetFactionExpansionPressure(world, station.FactionId) > 0f
|
||||
? new (string ModuleId, int TargetCount)[]
|
||||
{
|
||||
("module_gen_prod_refinedmetals_01", 1),
|
||||
("module_arg_stor_solid_m_01", 1),
|
||||
("module_arg_stor_container_m_01", 1),
|
||||
("module_gen_prod_hullparts_01", 2),
|
||||
("module_gen_prod_advancedelectronics_01", 1),
|
||||
("module_gen_build_l_01", 1),
|
||||
("module_arg_dock_m_01_lowtech", 2),
|
||||
("module_gen_prod_energycells_01", 2),
|
||||
}
|
||||
: new (string ModuleId, int TargetCount)[]
|
||||
{
|
||||
("module_gen_prod_refinedmetals_01", 1),
|
||||
("module_arg_stor_solid_m_01", 1),
|
||||
("module_arg_stor_container_m_01", 1),
|
||||
("module_gen_prod_hullparts_01", 2),
|
||||
("module_gen_prod_advancedelectronics_01", 1),
|
||||
("module_gen_build_l_01", 1),
|
||||
("module_gen_prod_energycells_01", 2),
|
||||
("module_arg_dock_m_01_lowtech", 2),
|
||||
};
|
||||
|
||||
foreach (var (moduleId, targetCount) in priorities)
|
||||
{
|
||||
if (CountModules(station.InstalledModules, moduleId) < targetCount
|
||||
&& world.ModuleRecipes.ContainsKey(moduleId))
|
||||
{
|
||||
return moduleId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetRequiredStorageModules(SimulationWorld world, ModuleRecipeDefinition recipe)
|
||||
{
|
||||
var itemIds = recipe.Inputs.Select(input => input.ItemId);
|
||||
foreach (var itemId in itemIds)
|
||||
{
|
||||
if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (GetStorageRequirement(itemDefinition.CargoKind) is { } storageModuleId)
|
||||
{
|
||||
yield return storageModuleId;
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return "module_arg_stor_container_m_01";
|
||||
}
|
||||
}
|
||||
|
||||
if (world.ModuleDefinitions.TryGetValue(recipe.ModuleId, out var moduleDefinition))
|
||||
{
|
||||
foreach (var productItemId in moduleDefinition.Products)
|
||||
{
|
||||
if (!world.ItemDefinitions.TryGetValue(productItemId, out var itemDefinition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (GetStorageRequirement(itemDefinition.CargoKind) is { } storageModuleId)
|
||||
{
|
||||
yield return storageModuleId;
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return "module_arg_stor_container_m_01";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetObjectiveCommodityId(string role) =>
|
||||
role switch
|
||||
{
|
||||
"power" => "energycells",
|
||||
"refinery" => "refinedmetals",
|
||||
"water" => "water",
|
||||
"hullparts" => "hullparts",
|
||||
"claytronics" => "claytronics",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? GetObjectiveModuleId(SimulationWorld world, string role, string? objectiveCommodityId) =>
|
||||
role switch
|
||||
{
|
||||
"shipyard" => "module_gen_build_l_01",
|
||||
_ => objectiveCommodityId is null ? null : world.ProductionGraph.GetPrimaryProducerModule(objectiveCommodityId),
|
||||
};
|
||||
|
||||
private static float ScoreObjectiveModule(
|
||||
SimulationWorld world,
|
||||
StationRuntime station,
|
||||
FactionEconomySnapshot economy,
|
||||
IReadOnlyDictionary<string, float> constructionDemandByItem,
|
||||
string? objectiveCommodityId,
|
||||
string objectiveModuleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(objectiveCommodityId))
|
||||
{
|
||||
var hasShipyard = CountModules(station.InstalledModules, objectiveModuleId);
|
||||
return hasShipyard == 0 ? 240f : 0f;
|
||||
}
|
||||
|
||||
var commodity = economy.GetCommodity(objectiveCommodityId);
|
||||
var currentCount = CountModules(station.InstalledModules, objectiveModuleId);
|
||||
var marginalOutputRate = EstimateMarginalOutputRate(world, station, objectiveModuleId, objectiveCommodityId);
|
||||
var constructionImpact = EstimateConstructionBottleneckImpact(world, objectiveModuleId, constructionDemandByItem);
|
||||
var score = 90f + commodity.ProjectedPressureScore + (marginalOutputRate * 900f) + constructionImpact;
|
||||
|
||||
if (currentCount == 0)
|
||||
{
|
||||
score += 80f;
|
||||
}
|
||||
|
||||
if (!float.IsPositiveInfinity(commodity.ProjectedShortageHorizonSeconds))
|
||||
{
|
||||
score += MathF.Max(0f, 300f - commodity.ProjectedShortageHorizonSeconds) * 0.3f;
|
||||
}
|
||||
|
||||
score *= EstimateObjectiveExpansionFeasibility(world, station, economy, objectiveModuleId, objectiveCommodityId);
|
||||
score *= EstimateProducerReadiness(world, station, economy, objectiveModuleId, objectiveCommodityId);
|
||||
score += EstimateImmediateProducerActivationScore(world, station, economy, objectiveModuleId, objectiveCommodityId);
|
||||
return score - (currentCount * 35f);
|
||||
}
|
||||
|
||||
private static float ScoreEnergySupportModule(
|
||||
SimulationWorld world,
|
||||
StationRuntime station,
|
||||
FactionEconomySnapshot economy,
|
||||
IReadOnlyDictionary<string, float> constructionDemandByItem)
|
||||
{
|
||||
var energy = economy.GetCommodity("energycells");
|
||||
var currentCount = CountModules(station.InstalledModules, "module_gen_prod_energycells_01");
|
||||
var constructionImpact = EstimateConstructionBottleneckImpact(world, "module_gen_prod_energycells_01", constructionDemandByItem);
|
||||
var readinessUnlock = EstimateSupportUnlockScore(world, station, economy, "module_gen_prod_energycells_01");
|
||||
var score = 40f + energy.ProjectedPressureScore * 0.5f + constructionImpact + readinessUnlock;
|
||||
|
||||
if (currentCount == 0)
|
||||
{
|
||||
score += 70f;
|
||||
}
|
||||
|
||||
if (!float.IsPositiveInfinity(energy.ProjectedShortageHorizonSeconds))
|
||||
{
|
||||
score += MathF.Max(0f, 240f - energy.ProjectedShortageHorizonSeconds) * 0.2f;
|
||||
}
|
||||
|
||||
return score - (currentCount * 40f);
|
||||
}
|
||||
|
||||
private static float ScoreStorageModule(
|
||||
SimulationWorld world,
|
||||
StationRuntime station,
|
||||
string storageModuleId,
|
||||
string? objectiveModuleId,
|
||||
string? objectiveCommodityId,
|
||||
bool requiredByObjective)
|
||||
{
|
||||
var storageClass = storageModuleId switch
|
||||
{
|
||||
"module_arg_stor_solid_m_01" => "solid",
|
||||
"module_arg_stor_liquid_m_01" => "liquid",
|
||||
_ => "container",
|
||||
};
|
||||
|
||||
var capacity = GetStationStorageCapacity(station, storageClass);
|
||||
var used = station.Inventory
|
||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass)
|
||||
.Sum(entry => entry.Value);
|
||||
var utilization = capacity <= 0.01f ? 0f : used / capacity;
|
||||
|
||||
var score = requiredByObjective ? 140f : 0f;
|
||||
score += MathF.Max(0f, utilization - 0.6f) * 240f;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(objectiveModuleId) && !string.IsNullOrWhiteSpace(objectiveCommodityId))
|
||||
{
|
||||
var objectiveUsesStorage = ModuleNeedsStorageClass(world, objectiveModuleId, storageClass)
|
||||
|| CommodityUsesStorageClass(world, objectiveCommodityId, storageClass);
|
||||
if (objectiveUsesStorage)
|
||||
{
|
||||
score += 35f;
|
||||
score += EstimateSupportUnlockScore(world, station, economy: null, supportModuleId: storageModuleId);
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static float ScoreDockModule(StationRuntime station)
|
||||
{
|
||||
var dockingPads = GetDockingPadCount(station);
|
||||
var dockedShips = station.DockedShipIds.Count;
|
||||
if (dockingPads <= 0)
|
||||
{
|
||||
return 150f;
|
||||
}
|
||||
|
||||
return dockedShips >= dockingPads ? 80f : dockingPads < 4 ? 25f : 0f;
|
||||
}
|
||||
|
||||
private static float ScoreHabitationModule(StationRuntime station)
|
||||
{
|
||||
if (station.WorkforceRequired <= 0.01f)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return station.WorkforceEffectiveRatio < 0.75f
|
||||
? 30f
|
||||
: station.WorkforceEffectiveRatio < 0.95f
|
||||
? 10f
|
||||
: 0f;
|
||||
}
|
||||
|
||||
private static float ScoreHabitationModule(StationRuntime station, SimulationWorld world, FactionEconomySnapshot economy)
|
||||
{
|
||||
return ScoreHabitationModule(station) + EstimateSupportUnlockScore(world, station, economy, "module_arg_hab_m_01");
|
||||
}
|
||||
|
||||
private static void AddOrRaiseCandidate(IDictionary<string, float> candidates, string moduleId, float score)
|
||||
{
|
||||
if (score <= 0.01f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!candidates.TryGetValue(moduleId, out var existing) || score > existing)
|
||||
{
|
||||
candidates[moduleId] = score;
|
||||
}
|
||||
}
|
||||
|
||||
private static float EstimateMarginalOutputRate(
|
||||
SimulationWorld world,
|
||||
StationRuntime station,
|
||||
string moduleId,
|
||||
string commodityId)
|
||||
{
|
||||
var recipe = world.Recipes.Values
|
||||
.Where(recipe =>
|
||||
string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal)
|
||||
&& StationSimulationService.RecipeAppliesToStation(station, recipe))
|
||||
.Where(recipe => recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal)))
|
||||
.OrderByDescending(recipe => recipe.Priority)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (recipe is null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var amount = recipe.Outputs
|
||||
.Where(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal))
|
||||
.Sum(output => output.Amount);
|
||||
return amount * station.WorkforceEffectiveRatio / MathF.Max(recipe.Duration, 0.01f);
|
||||
}
|
||||
|
||||
private static float EstimateObjectiveExpansionFeasibility(
|
||||
SimulationWorld world,
|
||||
StationRuntime station,
|
||||
FactionEconomySnapshot economy,
|
||||
string moduleId,
|
||||
string commodityId)
|
||||
{
|
||||
var recipes = world.Recipes.Values
|
||||
.Where(recipe =>
|
||||
string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal)
|
||||
&& StationSimulationService.RecipeAppliesToStation(station, recipe)
|
||||
&& recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal)))
|
||||
.ToList();
|
||||
if (recipes.Count == 0)
|
||||
{
|
||||
return 1f;
|
||||
}
|
||||
|
||||
var feasibility = 1f;
|
||||
foreach (var recipe in recipes)
|
||||
{
|
||||
foreach (var input in recipe.Inputs)
|
||||
{
|
||||
var inputCommodity = economy.GetCommodity(input.ItemId);
|
||||
if (inputCommodity.AvailableStock <= 0.01f && inputCommodity.ProjectedProductionRatePerSecond <= 0.01f)
|
||||
{
|
||||
feasibility *= 0.65f;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!float.IsPositiveInfinity(inputCommodity.ProjectedShortageHorizonSeconds)
|
||||
&& inputCommodity.ProjectedShortageHorizonSeconds < 180f)
|
||||
{
|
||||
feasibility *= 0.82f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Math.Clamp(feasibility, 0.35f, 1.15f);
|
||||
}
|
||||
|
||||
private static float EstimateProducerReadiness(
|
||||
SimulationWorld world,
|
||||
StationRuntime station,
|
||||
FactionEconomySnapshot economy,
|
||||
string moduleId,
|
||||
string commodityId)
|
||||
{
|
||||
var analysis = AnalyzeProducerLane(world, station, economy, moduleId, commodityId);
|
||||
return analysis.Readiness;
|
||||
}
|
||||
|
||||
private static ProducerLaneAnalysis AnalyzeProducerLane(
|
||||
SimulationWorld world,
|
||||
StationRuntime station,
|
||||
FactionEconomySnapshot economy,
|
||||
string moduleId,
|
||||
string commodityId)
|
||||
{
|
||||
var recipe = world.Recipes.Values
|
||||
.Where(recipe =>
|
||||
string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal)
|
||||
&& StationSimulationService.RecipeAppliesToStation(station, recipe)
|
||||
&& recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal)))
|
||||
.OrderByDescending(recipe => recipe.Priority)
|
||||
.FirstOrDefault();
|
||||
if (recipe is null)
|
||||
{
|
||||
return new ProducerLaneAnalysis(1f, 1f, false, false, false, false);
|
||||
}
|
||||
|
||||
var workforceFactor = station.WorkforceEffectiveRatio < 0.45f
|
||||
? 0.75f
|
||||
: station.WorkforceEffectiveRatio < 0.75f
|
||||
? 0.88f
|
||||
: 1f;
|
||||
var inputFactor = 1f;
|
||||
var missingLocalInputs = false;
|
||||
var missingFactionInputs = false;
|
||||
|
||||
foreach (var input in recipe.Inputs)
|
||||
{
|
||||
var localAmount = GetInventoryAmount(station.Inventory, input.ItemId);
|
||||
var commodity = economy.GetCommodity(input.ItemId);
|
||||
if (localAmount + 0.001f >= input.Amount)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
missingLocalInputs = true;
|
||||
var shortage = input.Amount - localAmount;
|
||||
var availableStockRatio = commodity.AvailableStock <= 0.01f ? 0f : MathF.Min(1f, commodity.AvailableStock / MathF.Max(input.Amount, 0.01f));
|
||||
if (commodity.AvailableStock >= shortage)
|
||||
{
|
||||
inputFactor *= 0.95f + (availableStockRatio * 0.05f);
|
||||
}
|
||||
else if (commodity.ProjectedProductionRatePerSecond > 0.01f)
|
||||
{
|
||||
inputFactor *= 0.82f + (availableStockRatio * 0.08f);
|
||||
}
|
||||
else
|
||||
{
|
||||
inputFactor *= 0.55f + (availableStockRatio * 0.15f);
|
||||
missingFactionInputs = true;
|
||||
}
|
||||
}
|
||||
|
||||
var outputReady = true;
|
||||
foreach (var output in recipe.Outputs)
|
||||
{
|
||||
if (!CanStationAcceptStationOutputSoon(world, station, output.ItemId, output.Amount))
|
||||
{
|
||||
outputReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
var readiness = Math.Clamp(workforceFactor * inputFactor * (outputReady ? 1f : 0.72f), 0.4f, 1.1f);
|
||||
return new ProducerLaneAnalysis(
|
||||
readiness,
|
||||
workforceFactor,
|
||||
missingLocalInputs,
|
||||
missingFactionInputs,
|
||||
!outputReady,
|
||||
outputReady && inputFactor >= 0.9f);
|
||||
}
|
||||
|
||||
private static float EstimateSupportUnlockScore(
|
||||
SimulationWorld world,
|
||||
StationRuntime station,
|
||||
FactionEconomySnapshot? economy,
|
||||
string supportModuleId)
|
||||
{
|
||||
var role = StationSimulationService.DetermineStationRole(station);
|
||||
var objectiveCommodityId = GetObjectiveCommodityId(role);
|
||||
var objectiveModuleId = GetObjectiveModuleId(world, role, objectiveCommodityId);
|
||||
if (string.IsNullOrWhiteSpace(objectiveCommodityId) || string.IsNullOrWhiteSpace(objectiveModuleId))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var analysis = economy is null
|
||||
? new ProducerLaneAnalysis(0.75f, 1f, false, false, false, false)
|
||||
: AnalyzeProducerLane(world, station, economy, objectiveModuleId, objectiveCommodityId);
|
||||
|
||||
var unlockScore = 0f;
|
||||
switch (supportModuleId)
|
||||
{
|
||||
case "module_arg_hab_m_01" when analysis.WorkforceFactor < 0.9f
|
||||
&& !analysis.HasMissingFactionInputs
|
||||
&& !analysis.HasMissingOutputStorage:
|
||||
unlockScore += (1f - analysis.WorkforceFactor) * 150f;
|
||||
break;
|
||||
case "module_gen_prod_energycells_01":
|
||||
if (ObjectiveNeedsEnergy(world, objectiveCommodityId)
|
||||
&& analysis.HasMissingLocalInputs
|
||||
&& (economy?.GetCommodity("energycells").AvailableStock ?? 0f) < 120f)
|
||||
{
|
||||
unlockScore += 90f;
|
||||
}
|
||||
break;
|
||||
case "module_arg_stor_container_m_01":
|
||||
case "module_arg_stor_solid_m_01":
|
||||
case "module_arg_stor_liquid_m_01":
|
||||
var storageClass = supportModuleId switch
|
||||
{
|
||||
"module_arg_stor_solid_m_01" => "solid",
|
||||
"module_arg_stor_liquid_m_01" => "liquid",
|
||||
_ => "container",
|
||||
};
|
||||
if (analysis.HasMissingOutputStorage
|
||||
&& (ModuleNeedsStorageClass(world, objectiveModuleId, storageClass)
|
||||
|| CommodityUsesStorageClass(world, objectiveCommodityId, storageClass)))
|
||||
{
|
||||
unlockScore += 70f;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return unlockScore * MathF.Max(0.4f, 1f - analysis.Readiness);
|
||||
}
|
||||
|
||||
private static float EstimateImmediateProducerActivationScore(
|
||||
SimulationWorld world,
|
||||
StationRuntime station,
|
||||
FactionEconomySnapshot economy,
|
||||
string moduleId,
|
||||
string commodityId)
|
||||
{
|
||||
var analysis = AnalyzeProducerLane(world, station, economy, moduleId, commodityId);
|
||||
if (analysis.CanRunSoon)
|
||||
{
|
||||
return 110f;
|
||||
}
|
||||
|
||||
if (!analysis.HasMissingFactionInputs && !analysis.HasMissingOutputStorage)
|
||||
{
|
||||
return 45f * MathF.Max(0.6f, analysis.WorkforceFactor);
|
||||
}
|
||||
|
||||
return 0f;
|
||||
}
|
||||
|
||||
private static float EstimateConstructionBottleneckImpact(
|
||||
SimulationWorld world,
|
||||
string moduleId,
|
||||
IReadOnlyDictionary<string, float> constructionDemandByItem)
|
||||
{
|
||||
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var moduleDefinition))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var score = 0f;
|
||||
foreach (var productItemId in moduleDefinition.Products)
|
||||
{
|
||||
if (!constructionDemandByItem.TryGetValue(productItemId, out var outstandingDemand) || outstandingDemand <= 0.01f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var outputRate = EstimateModuleOutputRate(world, moduleId, productItemId);
|
||||
if (outputRate <= 0.0001f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
score += MathF.Min(outstandingDemand, outputRate * 900f) * 0.8f;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static float EstimateModuleOutputRate(SimulationWorld world, string moduleId, string itemId)
|
||||
{
|
||||
var recipe = world.Recipes.Values
|
||||
.Where(recipe => string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal))
|
||||
.Where(recipe => recipe.Outputs.Any(output => string.Equals(output.ItemId, itemId, StringComparison.Ordinal)))
|
||||
.OrderByDescending(recipe => recipe.Priority)
|
||||
.FirstOrDefault();
|
||||
if (recipe is null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return recipe.Outputs
|
||||
.Where(output => string.Equals(output.ItemId, itemId, StringComparison.Ordinal))
|
||||
.Sum(output => output.Amount) / MathF.Max(recipe.Duration, 0.01f);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, float> GetOutstandingConstructionDemand(SimulationWorld world, string factionId)
|
||||
{
|
||||
var demand = new Dictionary<string, float>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var site in world.ConstructionSites.Where(site =>
|
||||
string.Equals(site.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed))
|
||||
{
|
||||
foreach (var required in site.RequiredItems)
|
||||
{
|
||||
var remaining = MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key));
|
||||
if (remaining <= 0.01f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
demand[required.Key] = demand.GetValueOrDefault(required.Key) + remaining;
|
||||
}
|
||||
}
|
||||
|
||||
return demand;
|
||||
}
|
||||
|
||||
private static bool ModuleNeedsStorageClass(SimulationWorld world, string moduleId, string storageClass)
|
||||
{
|
||||
if (!world.ModuleRecipes.TryGetValue(moduleId, out var recipe))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return recipe.Inputs.Any(input =>
|
||||
world.ItemDefinitions.TryGetValue(input.ItemId, out var itemDefinition)
|
||||
&& string.Equals(itemDefinition.CargoKind, storageClass, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static bool CommodityUsesStorageClass(SimulationWorld world, string commodityId, string storageClass) =>
|
||||
world.ItemDefinitions.TryGetValue(commodityId, out var itemDefinition)
|
||||
&& string.Equals(itemDefinition.CargoKind, storageClass, StringComparison.Ordinal);
|
||||
|
||||
private static bool CanStationAcceptStationOutputSoon(SimulationWorld world, StationRuntime station, string itemId, float amount)
|
||||
{
|
||||
if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var capacity = GetStationStorageCapacity(station, itemDefinition.CargoKind);
|
||||
if (capacity <= 0.01f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var used = station.Inventory
|
||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && string.Equals(definition.CargoKind, itemDefinition.CargoKind, StringComparison.Ordinal))
|
||||
.Sum(entry => entry.Value);
|
||||
return used + amount <= capacity * 0.95f;
|
||||
}
|
||||
|
||||
private static bool ObjectiveNeedsEnergy(SimulationWorld world, string objectiveCommodityId) =>
|
||||
world.ProductionGraph.GetImmediateInputs(objectiveCommodityId).Contains("energycells", StringComparer.Ordinal);
|
||||
|
||||
internal static void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site)
|
||||
{
|
||||
var nextModuleId = GetNextStationModuleToBuild(station, world);
|
||||
@@ -223,6 +774,16 @@ internal sealed class InfrastructureSimulationService
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ModuleExpansionCandidate(string ModuleId, float Score);
|
||||
|
||||
private sealed record ProducerLaneAnalysis(
|
||||
float Readiness,
|
||||
float WorkforceFactor,
|
||||
bool HasMissingLocalInputs,
|
||||
bool HasMissingFactionInputs,
|
||||
bool HasMissingOutputStorage,
|
||||
bool CanRunSoon);
|
||||
|
||||
internal static int GetDockingPadCount(StationRuntime station) =>
|
||||
CountModules(station.InstalledModules, "module_arg_dock_m_01_lowtech") * 2;
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ internal sealed class StationLifecycleService
|
||||
};
|
||||
|
||||
world.Ships.Add(ship);
|
||||
EnsureSpawnedShipCommander(world, station, ship);
|
||||
if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction)
|
||||
{
|
||||
faction.ShipsBuilt += 1;
|
||||
@@ -124,4 +125,86 @@ internal sealed class StationLifecycleService
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
internal static void EnsureStationCommander(SimulationWorld world, StationRuntime station)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(station.CommanderId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var factionCommander = world.Commanders.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.Kind, CommanderKind.Faction, StringComparison.Ordinal)
|
||||
&& string.Equals(candidate.FactionId, station.FactionId, StringComparison.Ordinal));
|
||||
var faction = world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, station.FactionId, StringComparison.Ordinal));
|
||||
if (factionCommander is null || faction is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var commander = new CommanderRuntime
|
||||
{
|
||||
Id = $"commander-station-{station.Id}",
|
||||
Kind = CommanderKind.Station,
|
||||
FactionId = station.FactionId,
|
||||
ParentCommanderId = factionCommander.Id,
|
||||
ControlledEntityId = station.Id,
|
||||
PolicySetId = factionCommander.PolicySetId,
|
||||
Doctrine = "station-default",
|
||||
};
|
||||
|
||||
station.CommanderId = commander.Id;
|
||||
station.PolicySetId = factionCommander.PolicySetId;
|
||||
factionCommander.SubordinateCommanderIds.Add(commander.Id);
|
||||
faction.CommanderIds.Add(commander.Id);
|
||||
world.Commanders.Add(commander);
|
||||
}
|
||||
|
||||
private static void EnsureSpawnedShipCommander(SimulationWorld world, StationRuntime station, ShipRuntime ship)
|
||||
{
|
||||
var factionCommander = world.Commanders.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.Kind, CommanderKind.Faction, StringComparison.Ordinal)
|
||||
&& string.Equals(candidate.FactionId, station.FactionId, StringComparison.Ordinal));
|
||||
var faction = world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, station.FactionId, StringComparison.Ordinal));
|
||||
if (factionCommander is null || faction is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var commander = new CommanderRuntime
|
||||
{
|
||||
Id = $"commander-ship-{ship.Id}",
|
||||
Kind = CommanderKind.Ship,
|
||||
FactionId = ship.FactionId,
|
||||
ParentCommanderId = factionCommander.Id,
|
||||
ControlledEntityId = ship.Id,
|
||||
PolicySetId = factionCommander.PolicySetId,
|
||||
Doctrine = "ship-default",
|
||||
ActiveBehavior = new CommanderBehaviorRuntime
|
||||
{
|
||||
Kind = ship.DefaultBehavior.Kind,
|
||||
AreaSystemId = ship.DefaultBehavior.AreaSystemId,
|
||||
TargetEntityId = ship.DefaultBehavior.TargetEntityId,
|
||||
ItemId = ship.DefaultBehavior.ItemId,
|
||||
StationId = ship.DefaultBehavior.StationId,
|
||||
ModuleId = ship.DefaultBehavior.ModuleId,
|
||||
NodeId = ship.DefaultBehavior.NodeId,
|
||||
Phase = ship.DefaultBehavior.Phase,
|
||||
PatrolIndex = ship.DefaultBehavior.PatrolIndex,
|
||||
},
|
||||
ActiveTask = new CommanderTaskRuntime
|
||||
{
|
||||
Kind = ShipTaskKinds.Idle,
|
||||
Status = WorkStatus.Pending,
|
||||
TargetSystemId = ship.SystemId,
|
||||
Threshold = 0f,
|
||||
},
|
||||
};
|
||||
|
||||
ship.CommanderId = commander.Id;
|
||||
ship.PolicySetId = factionCommander.PolicySetId;
|
||||
factionCommander.SubordinateCommanderIds.Add(commander.Id);
|
||||
faction.CommanderIds.Add(commander.Id);
|
||||
world.Commanders.Add(commander);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,23 +15,54 @@ internal sealed class StationSimulationService
|
||||
}
|
||||
|
||||
var desiredOrders = new List<DesiredMarketOrder>();
|
||||
var economy = FactionEconomyAnalyzer.Build(world, station.FactionId);
|
||||
var role = DetermineStationRole(station);
|
||||
var site = GetConstructionSiteForStation(world, station.Id);
|
||||
var waterReserve = MathF.Max(30f, station.Population * 3f);
|
||||
var refinedReserve = HasStationModules(station, "module_gen_prod_hullparts_01") ? 140f : 40f;
|
||||
var oreReserve = HasRefineryCapability(station) ? 180f : 0f;
|
||||
var shipPartsReserve = HasStationModules(station, "module_gen_prod_hullparts_01")
|
||||
&& !HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
|
||||
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 iceReserve = role == "water" ? 260f : 0f;
|
||||
var energyReserve = role switch
|
||||
{
|
||||
"power" => 120f,
|
||||
"refinery" => 160f,
|
||||
"hullparts" => 180f,
|
||||
"claytronics" => 220f,
|
||||
"water" => 140f,
|
||||
_ => 60f,
|
||||
} + constructionEnergyReserve;
|
||||
var refinedReserve = role switch
|
||||
{
|
||||
"hullparts" => 220f,
|
||||
"shipyard" => 260f,
|
||||
"refinery" => 80f,
|
||||
_ => 0f,
|
||||
};
|
||||
var oreReserve = role == "refinery" ? 260f : 0f;
|
||||
var hullpartsReserve = MathF.Max(constructionHullpartsReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f);
|
||||
var claytronicsReserve = MathF.Max(constructionClayReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f);
|
||||
var shipPartsReserve = HasStationModules(station, "module_gen_build_l_01")
|
||||
&& FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships")
|
||||
? 90f
|
||||
: 0f;
|
||||
|
||||
AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f);
|
||||
AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f);
|
||||
AddDemandOrder(desiredOrders, station, "refinedmetals", refinedReserve, valuationBase: 1.15f);
|
||||
AddDemandOrder(desiredOrders, station, "hullparts", shipPartsReserve, valuationBase: 1.3f);
|
||||
AddDemandOrder(desiredOrders, station, "water", ScaleReserveByEconomy(economy, "water", waterReserve), valuationBase: ScaleDemandValuation(economy, "water", 1.1f));
|
||||
AddDemandOrder(desiredOrders, station, "energycells", ScaleReserveByEconomy(economy, "energycells", energyReserve), valuationBase: ScaleDemandValuation(economy, "energycells", 1.0f));
|
||||
AddDemandOrder(desiredOrders, station, "ice", ScaleReserveByEconomy(economy, "ice", iceReserve), valuationBase: ScaleDemandValuation(economy, "ice", 1.0f));
|
||||
AddDemandOrder(desiredOrders, station, "ore", ScaleReserveByEconomy(economy, "ore", oreReserve), valuationBase: ScaleDemandValuation(economy, "ore", 1.0f));
|
||||
AddDemandOrder(desiredOrders, station, "refinedmetals", ScaleReserveByEconomy(economy, "refinedmetals", MathF.Max(refinedReserve, constructionRefinedReserve)), valuationBase: ScaleDemandValuation(economy, "refinedmetals", 1.15f));
|
||||
AddDemandOrder(desiredOrders, station, "hullparts", ScaleReserveByEconomy(economy, "hullparts", hullpartsReserve + shipPartsReserve), valuationBase: ScaleDemandValuation(economy, "hullparts", 1.3f));
|
||||
AddDemandOrder(desiredOrders, station, "claytronics", ScaleReserveByEconomy(economy, "claytronics", claytronicsReserve), valuationBase: ScaleDemandValuation(economy, "claytronics", 1.35f));
|
||||
|
||||
AddSupplyOrder(desiredOrders, station, "water", waterReserve * 1.5f, reserveFloor: waterReserve, valuationBase: 0.65f);
|
||||
AddSupplyOrder(desiredOrders, station, "ore", oreReserve * 1.4f, reserveFloor: oreReserve, valuationBase: 0.7f);
|
||||
AddSupplyOrder(desiredOrders, station, "refinedmetals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f);
|
||||
AddSupplyOrder(desiredOrders, station, "water", ScaleSupplyTriggerByEconomy(economy, "water", waterReserve * 1.5f), reserveFloor: waterReserve, valuationBase: ScaleSupplyValuation(economy, "water", 0.65f));
|
||||
AddSupplyOrder(desiredOrders, station, "energycells", ScaleSupplyTriggerByEconomy(economy, "energycells", energyReserve * 1.4f), reserveFloor: energyReserve, valuationBase: ScaleSupplyValuation(economy, "energycells", 0.7f));
|
||||
AddSupplyOrder(desiredOrders, station, "ice", ScaleSupplyTriggerByEconomy(economy, "ice", iceReserve * 1.4f), reserveFloor: iceReserve, valuationBase: ScaleSupplyValuation(economy, "ice", 0.5f));
|
||||
AddSupplyOrder(desiredOrders, station, "ore", ScaleSupplyTriggerByEconomy(economy, "ore", oreReserve * 1.4f), reserveFloor: oreReserve, valuationBase: ScaleSupplyValuation(economy, "ore", 0.7f));
|
||||
AddSupplyOrder(desiredOrders, station, "refinedmetals", ScaleSupplyTriggerByEconomy(economy, "refinedmetals", MathF.Max(refinedReserve, constructionRefinedReserve) * 1.4f), reserveFloor: MathF.Max(refinedReserve, constructionRefinedReserve), valuationBase: ScaleSupplyValuation(economy, "refinedmetals", 0.95f));
|
||||
AddSupplyOrder(desiredOrders, station, "hullparts", ScaleSupplyTriggerByEconomy(economy, "hullparts", MathF.Max(hullpartsReserve * 1.35f, hullpartsReserve + 40f)), reserveFloor: hullpartsReserve, valuationBase: ScaleSupplyValuation(economy, "hullparts", 1.05f));
|
||||
AddSupplyOrder(desiredOrders, station, "claytronics", ScaleSupplyTriggerByEconomy(economy, "claytronics", MathF.Max(claytronicsReserve * 1.35f, claytronicsReserve + 30f)), reserveFloor: claytronicsReserve, valuationBase: ScaleSupplyValuation(economy, "claytronics", 1.1f));
|
||||
|
||||
ReconcileStationMarketOrders(world, station, desiredOrders);
|
||||
}
|
||||
@@ -112,11 +143,11 @@ internal sealed class StationSimulationService
|
||||
.OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe))
|
||||
.FirstOrDefault(recipe => CanRunRecipe(world, station, recipe));
|
||||
|
||||
private static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) =>
|
||||
internal static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) =>
|
||||
recipe.RequiredModules.FirstOrDefault(moduleId =>
|
||||
world.ModuleDefinitions.TryGetValue(moduleId, out var def) && !string.IsNullOrEmpty(def.ProductionMode));
|
||||
|
||||
private static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
||||
internal static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
||||
{
|
||||
var laneModuleId = GetStationProductionLaneKey(world, recipe);
|
||||
if (laneModuleId is null)
|
||||
@@ -180,7 +211,7 @@ internal sealed class StationSimulationService
|
||||
};
|
||||
}
|
||||
|
||||
private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
|
||||
internal static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
|
||||
{
|
||||
var categoryMatch = string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal)
|
||||
|| string.Equals(recipe.FacilityCategory, "farm", StringComparison.Ordinal)
|
||||
@@ -240,6 +271,71 @@ internal sealed class StationSimulationService
|
||||
private static bool HasRefineryCapability(StationRuntime station) =>
|
||||
HasStationModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01");
|
||||
|
||||
internal static string NormalizeStationObjective(string? objective)
|
||||
{
|
||||
return objective?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"power" or "energy" or "energycells" => "power",
|
||||
"water" or "ice-refinery" => "water",
|
||||
"refinery" or "refinedmetals" => "refinery",
|
||||
"hullparts" or "hull" => "hullparts",
|
||||
"claytronics" or "clay" => "claytronics",
|
||||
"shipyard" or "ship-production" => "shipyard",
|
||||
_ => "general",
|
||||
};
|
||||
}
|
||||
|
||||
internal static string DetermineStationRole(StationRuntime station)
|
||||
{
|
||||
var objective = NormalizeStationObjective(station.Objective);
|
||||
if (!string.Equals(objective, "general", StringComparison.Ordinal))
|
||||
{
|
||||
return objective;
|
||||
}
|
||||
|
||||
if (HasStationModules(station, "module_gen_build_l_01"))
|
||||
{
|
||||
return "shipyard";
|
||||
}
|
||||
|
||||
if (HasStationModules(station, "module_gen_prod_water_01"))
|
||||
{
|
||||
return "water";
|
||||
}
|
||||
|
||||
if (HasStationModules(station, "module_gen_prod_claytronics_01"))
|
||||
{
|
||||
return "claytronics";
|
||||
}
|
||||
|
||||
if (HasStationModules(station, "module_gen_prod_hullparts_01"))
|
||||
{
|
||||
return "hullparts";
|
||||
}
|
||||
|
||||
if (HasStationModules(station, "module_gen_prod_refinedmetals_01"))
|
||||
{
|
||||
return "refinery";
|
||||
}
|
||||
|
||||
if (HasStationModules(station, "module_gen_prod_energycells_01"))
|
||||
{
|
||||
return "power";
|
||||
}
|
||||
|
||||
return "general";
|
||||
}
|
||||
|
||||
private static float GetConstructionDemandForItem(SimulationWorld world, ConstructionSiteRuntime? site, string itemId)
|
||||
{
|
||||
if (site is null || !site.RequiredItems.TryGetValue(itemId, out var required))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, itemId));
|
||||
}
|
||||
|
||||
private static void AddDemandOrder(ICollection<DesiredMarketOrder> desiredOrders, StationRuntime station, string itemId, float targetAmount, float valuationBase)
|
||||
{
|
||||
var current = GetInventoryAmount(station.Inventory, itemId);
|
||||
@@ -267,7 +363,9 @@ internal sealed class StationSimulationService
|
||||
return;
|
||||
}
|
||||
|
||||
desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Sell, itemId, surplus, valuationBase, reserveFloor));
|
||||
var surplusRatio = triggerAmount <= 0.01f ? 1f : MathF.Min(1f, surplus / triggerAmount);
|
||||
var liquidationValuation = MathF.Max(0.05f, valuationBase * (1f - (0.85f * surplusRatio)));
|
||||
desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Sell, itemId, surplus, liquidationValuation, reserveFloor));
|
||||
}
|
||||
|
||||
private static void ReconcileStationMarketOrders(SimulationWorld world, StationRuntime station, IReadOnlyCollection<DesiredMarketOrder> desiredOrders)
|
||||
@@ -330,6 +428,50 @@ internal sealed class StationSimulationService
|
||||
return world.Systems.Count(system => FactionControlsSystem(world, factionId, system.Definition.Id));
|
||||
}
|
||||
|
||||
private static float ScaleReserveByEconomy(FactionEconomySnapshot economy, string itemId, float baseReserve)
|
||||
{
|
||||
var commodity = economy.GetCommodity(itemId);
|
||||
if (float.IsPositiveInfinity(commodity.ShortageHorizonSeconds))
|
||||
{
|
||||
return MathF.Max(0f, baseReserve);
|
||||
}
|
||||
|
||||
return commodity.ShortageHorizonSeconds < 180f
|
||||
? baseReserve * 1.5f
|
||||
: commodity.ShortageHorizonSeconds < 360f
|
||||
? baseReserve * 1.2f
|
||||
: baseReserve;
|
||||
}
|
||||
|
||||
private static float ScaleSupplyTriggerByEconomy(FactionEconomySnapshot economy, string itemId, float baseTrigger)
|
||||
{
|
||||
var commodity = economy.GetCommodity(itemId);
|
||||
return commodity.NetRatePerSecond < -0.01f ? baseTrigger * 1.2f : baseTrigger;
|
||||
}
|
||||
|
||||
private static float ScaleDemandValuation(FactionEconomySnapshot economy, string itemId, float baseValuation)
|
||||
{
|
||||
var commodity = economy.GetCommodity(itemId);
|
||||
if (float.IsPositiveInfinity(commodity.ShortageHorizonSeconds))
|
||||
{
|
||||
return commodity.ProductionRatePerSecond > 0.01f ? baseValuation : baseValuation * 1.3f;
|
||||
}
|
||||
|
||||
return commodity.ShortageHorizonSeconds < 180f
|
||||
? baseValuation * 1.5f
|
||||
: commodity.ShortageHorizonSeconds < 360f
|
||||
? baseValuation * 1.25f
|
||||
: baseValuation;
|
||||
}
|
||||
|
||||
private static float ScaleSupplyValuation(FactionEconomySnapshot economy, string itemId, float baseValuation)
|
||||
{
|
||||
var commodity = economy.GetCommodity(itemId);
|
||||
return commodity.NetRatePerSecond > 0.01f && commodity.ShortageHorizonSeconds > 600f
|
||||
? baseValuation * 0.75f
|
||||
: baseValuation;
|
||||
}
|
||||
|
||||
private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)
|
||||
{
|
||||
var totalLagrangePoints = world.Celestials.Count(node =>
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed class SimulationWorld
|
||||
public required Dictionary<string, ModuleDefinition> ModuleDefinitions { get; init; }
|
||||
public required Dictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; init; }
|
||||
public required Dictionary<string, RecipeDefinition> Recipes { get; init; }
|
||||
public required ProductionGraph ProductionGraph { get; init; }
|
||||
public int TickIntervalMs { get; init; } = 200;
|
||||
public double OrbitalTimeSeconds { get; set; }
|
||||
public DateTimeOffset GeneratedAtUtc { get; set; }
|
||||
|
||||
@@ -20,6 +20,7 @@ internal sealed class DataCatalogLoader(string dataRoot)
|
||||
var balance = Read<BalanceDefinition>("balance.json");
|
||||
var recipes = BuildRecipes(items, ships, modules);
|
||||
var moduleRecipes = BuildModuleRecipes(modules);
|
||||
var productionGraph = ProductionGraphBuilder.Build(items, recipes, modules);
|
||||
|
||||
return new ScenarioCatalog(
|
||||
authoredSystems,
|
||||
@@ -29,7 +30,8 @@ internal sealed class DataCatalogLoader(string dataRoot)
|
||||
ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
|
||||
items.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
|
||||
recipes.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
|
||||
moduleRecipes.ToDictionary(definition => definition.ModuleId, StringComparer.Ordinal));
|
||||
moduleRecipes.ToDictionary(definition => definition.ModuleId, StringComparer.Ordinal),
|
||||
productionGraph);
|
||||
}
|
||||
|
||||
internal ScenarioDefinition NormalizeScenarioToAvailableSystems(
|
||||
@@ -56,6 +58,7 @@ internal sealed class DataCatalogLoader(string dataRoot)
|
||||
SystemId = ResolveSystemId(station.SystemId),
|
||||
Label = station.Label,
|
||||
Color = station.Color,
|
||||
Objective = station.Objective,
|
||||
StartingModules = station.StartingModules.ToList(),
|
||||
FactionId = station.FactionId,
|
||||
PlanetIndex = station.PlanetIndex,
|
||||
@@ -71,6 +74,7 @@ internal sealed class DataCatalogLoader(string dataRoot)
|
||||
Center = formation.Center.ToArray(),
|
||||
SystemId = ResolveSystemId(formation.SystemId),
|
||||
FactionId = formation.FactionId,
|
||||
StartingInventory = new Dictionary<string, float>(formation.StartingInventory, StringComparer.Ordinal),
|
||||
})
|
||||
.ToList(),
|
||||
PatrolRoutes = scenario.PatrolRoutes
|
||||
@@ -297,4 +301,5 @@ internal sealed record ScenarioCatalog(
|
||||
IReadOnlyDictionary<string, ShipDefinition> ShipDefinitions,
|
||||
IReadOnlyDictionary<string, ItemDefinition> ItemDefinitions,
|
||||
IReadOnlyDictionary<string, RecipeDefinition> Recipes,
|
||||
IReadOnlyDictionary<string, ModuleRecipeDefinition> ModuleRecipes);
|
||||
IReadOnlyDictionary<string, ModuleRecipeDefinition> ModuleRecipes,
|
||||
ProductionGraph ProductionGraph);
|
||||
|
||||
@@ -39,7 +39,7 @@ internal sealed class WorldBuilder(
|
||||
seedingService.InitializeStationStockpiles(stations);
|
||||
var refinery = seedingService.SelectRefineryStation(stations, scenario);
|
||||
var patrolRoutes = BuildPatrolRoutes(scenario, systemsById);
|
||||
var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, refinery);
|
||||
var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, stations, refinery);
|
||||
|
||||
var factions = seedingService.CreateFactions(stations, ships);
|
||||
seedingService.BootstrapFactionEconomy(factions, stations);
|
||||
@@ -47,7 +47,32 @@ internal sealed class WorldBuilder(
|
||||
var commanders = seedingService.CreateCommanders(factions, stations, ships);
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc);
|
||||
var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(stations, claims, catalog.ModuleRecipes);
|
||||
var bootstrapWorld = new SimulationWorld
|
||||
{
|
||||
Label = "Split Viewer / Bootstrap World",
|
||||
Seed = WorldSeed,
|
||||
Balance = catalog.Balance,
|
||||
Systems = systemRuntimes,
|
||||
Celestials = spatialLayout.Celestials,
|
||||
Nodes = spatialLayout.Nodes,
|
||||
Stations = stations,
|
||||
Ships = ships,
|
||||
Factions = factions,
|
||||
Commanders = commanders,
|
||||
Claims = claims,
|
||||
ConstructionSites = [],
|
||||
MarketOrders = [],
|
||||
Policies = policies,
|
||||
ShipDefinitions = new Dictionary<string, ShipDefinition>(catalog.ShipDefinitions, StringComparer.Ordinal),
|
||||
ItemDefinitions = new Dictionary<string, ItemDefinition>(catalog.ItemDefinitions, StringComparer.Ordinal),
|
||||
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(catalog.ModuleDefinitions, StringComparer.Ordinal),
|
||||
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(catalog.ModuleRecipes, StringComparer.Ordinal),
|
||||
Recipes = new Dictionary<string, RecipeDefinition>(catalog.Recipes, StringComparer.Ordinal),
|
||||
ProductionGraph = catalog.ProductionGraph,
|
||||
OrbitalTimeSeconds = WorldSeed * 97d,
|
||||
GeneratedAtUtc = nowUtc,
|
||||
};
|
||||
var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(bootstrapWorld);
|
||||
|
||||
return new SimulationWorld
|
||||
{
|
||||
@@ -70,6 +95,7 @@ internal sealed class WorldBuilder(
|
||||
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(catalog.ModuleDefinitions, StringComparer.Ordinal),
|
||||
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(catalog.ModuleRecipes, StringComparer.Ordinal),
|
||||
Recipes = new Dictionary<string, RecipeDefinition>(catalog.Recipes, StringComparer.Ordinal),
|
||||
ProductionGraph = catalog.ProductionGraph,
|
||||
OrbitalTimeSeconds = WorldSeed * 97d,
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
@@ -99,9 +125,12 @@ internal sealed class WorldBuilder(
|
||||
SystemId = system.Definition.Id,
|
||||
Label = plan.Label,
|
||||
Color = plan.Color,
|
||||
Objective = StationSimulationService.NormalizeStationObjective(plan.Objective),
|
||||
Position = placement.Position,
|
||||
FactionId = plan.FactionId ?? DefaultFactionId,
|
||||
CelestialId = placement.AnchorCelestial.Id,
|
||||
Health = 600f,
|
||||
MaxHealth = 600f,
|
||||
};
|
||||
|
||||
stations.Add(station);
|
||||
@@ -142,6 +171,7 @@ internal sealed class WorldBuilder(
|
||||
BalanceDefinition balance,
|
||||
IReadOnlyDictionary<string, ShipDefinition> shipDefinitions,
|
||||
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
|
||||
IReadOnlyCollection<StationRuntime> stations,
|
||||
StationRuntime? refinery)
|
||||
{
|
||||
var ships = new List<ShipRuntime>();
|
||||
@@ -168,10 +198,25 @@ internal sealed class WorldBuilder(
|
||||
Position = position,
|
||||
TargetPosition = position,
|
||||
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, celestials),
|
||||
DefaultBehavior = WorldSeedingService.CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
|
||||
DefaultBehavior = WorldSeedingService.CreateBehavior(
|
||||
definition,
|
||||
formation.SystemId,
|
||||
formation.FactionId ?? DefaultFactionId,
|
||||
scenario,
|
||||
patrolRoutes,
|
||||
stations,
|
||||
refinery),
|
||||
ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending },
|
||||
Health = definition.MaxHealth,
|
||||
});
|
||||
|
||||
foreach (var (itemId, amount) in formation.StartingInventory)
|
||||
{
|
||||
if (amount > 0f)
|
||||
{
|
||||
ships[^1].Inventory[itemId] = amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ internal sealed class WorldSeedingService
|
||||
.ToList();
|
||||
|
||||
var refineries = ownedStations
|
||||
.Where(station => HasInstalledModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01"))
|
||||
.Where(station => string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
if (refineries.Count > 0)
|
||||
@@ -65,10 +65,32 @@ internal sealed class WorldSeedingService
|
||||
foreach (var station in stations)
|
||||
{
|
||||
InitializeStationPopulation(station);
|
||||
station.Inventory["refinedmetals"] = 120f;
|
||||
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(80f, station.Population * 1.5f);
|
||||
station.Inventory["water"] = MathF.Max(60f, station.Population * 1.5f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,10 +98,10 @@ internal sealed class WorldSeedingService
|
||||
internal StationRuntime? SelectRefineryStation(IReadOnlyCollection<StationRuntime> stations, ScenarioDefinition scenario)
|
||||
{
|
||||
return stations.FirstOrDefault(station =>
|
||||
HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01") &&
|
||||
string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal) &&
|
||||
station.SystemId == scenario.MiningDefaults.RefinerySystemId)
|
||||
?? stations.FirstOrDefault(station =>
|
||||
HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01"));
|
||||
string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
internal List<ClaimRuntime> CreateClaims(
|
||||
@@ -116,23 +138,21 @@ internal sealed class WorldSeedingService
|
||||
}
|
||||
|
||||
internal (List<ConstructionSiteRuntime> ConstructionSites, List<MarketOrderRuntime> MarketOrders) CreateConstructionSites(
|
||||
IReadOnlyCollection<StationRuntime> stations,
|
||||
IReadOnlyCollection<ClaimRuntime> claims,
|
||||
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
|
||||
SimulationWorld world)
|
||||
{
|
||||
var sites = new List<ConstructionSiteRuntime>();
|
||||
var orders = new List<MarketOrderRuntime>();
|
||||
|
||||
foreach (var station in stations)
|
||||
foreach (var station in world.Stations)
|
||||
{
|
||||
var moduleId = GetNextConstructionSiteModule(station, moduleRecipes);
|
||||
var moduleId = InfrastructureSimulationService.GetNextStationModuleToBuild(station, world);
|
||||
if (moduleId is null || station.CelestialId is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var claim = claims.FirstOrDefault(candidate => candidate.CelestialId == station.CelestialId);
|
||||
if (claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe))
|
||||
var claim = world.Claims.FirstOrDefault(candidate => candidate.CelestialId == station.CelestialId);
|
||||
if (claim is null || !world.ModuleRecipes.TryGetValue(moduleId, out var recipe))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -294,23 +314,40 @@ internal sealed class WorldSeedingService
|
||||
internal static DefaultBehaviorRuntime CreateBehavior(
|
||||
ShipDefinition definition,
|
||||
string systemId,
|
||||
string factionId,
|
||||
ScenarioDefinition scenario,
|
||||
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
|
||||
IReadOnlyCollection<StationRuntime> stations,
|
||||
StationRuntime? refinery)
|
||||
{
|
||||
if (string.Equals(definition.Kind, "construction", StringComparison.Ordinal) && refinery is not null)
|
||||
var homeStation = stations.FirstOrDefault(station =>
|
||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(station.SystemId, systemId, StringComparison.Ordinal))
|
||||
?? stations.FirstOrDefault(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal))
|
||||
?? refinery;
|
||||
|
||||
if (string.Equals(definition.Kind, "construction", StringComparison.Ordinal) && homeStation is not null)
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "construct-station",
|
||||
StationId = refinery.Id,
|
||||
StationId = homeStation.Id,
|
||||
Phase = "travel-to-station",
|
||||
};
|
||||
}
|
||||
|
||||
if (HasCapabilities(definition, "mining") && refinery is not null)
|
||||
if (HasCapabilities(definition, "mining") && homeStation is not null)
|
||||
{
|
||||
return CreateResourceHarvestBehavior("auto-mine", scenario.MiningDefaults.NodeSystemId, refinery.Id);
|
||||
return CreateResourceHarvestBehavior("auto-mine", scenario.MiningDefaults.NodeSystemId, homeStation.Id);
|
||||
}
|
||||
|
||||
if (string.Equals(definition.Kind, "transport", StringComparison.Ordinal))
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "trade-haul",
|
||||
Phase = "travel-to-source",
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(definition.Kind, "military", StringComparison.Ordinal) && patrolRoutes.TryGetValue(systemId, out var route))
|
||||
@@ -318,6 +355,7 @@ internal sealed class WorldSeedingService
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "patrol",
|
||||
StationId = homeStation?.Id,
|
||||
PatrolPoints = route,
|
||||
PatrolIndex = 0,
|
||||
};
|
||||
@@ -340,6 +378,13 @@ internal sealed class WorldSeedingService
|
||||
Color = "#7ed4ff",
|
||||
Credits = MinimumFactionCredits,
|
||||
},
|
||||
"asterion-league" => new FactionRuntime
|
||||
{
|
||||
Id = factionId,
|
||||
Label = "Asterion League",
|
||||
Color = "#ff8f70",
|
||||
Credits = MinimumFactionCredits,
|
||||
},
|
||||
_ => new FactionRuntime
|
||||
{
|
||||
Id = factionId,
|
||||
@@ -350,31 +395,6 @@ internal sealed class WorldSeedingService
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetNextConstructionSiteModule(
|
||||
StationRuntime station,
|
||||
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
|
||||
{
|
||||
foreach (var (moduleId, targetCount) in new (string ModuleId, int TargetCount)[]
|
||||
{
|
||||
("module_gen_prod_refinedmetals_01", 1),
|
||||
("module_arg_stor_container_m_01", 1),
|
||||
("module_gen_prod_hullparts_01", 2),
|
||||
("module_gen_prod_advancedelectronics_01", 1),
|
||||
("module_gen_build_l_01", 1),
|
||||
("module_gen_prod_energycells_01", 2),
|
||||
("module_arg_dock_m_01_lowtech", 2),
|
||||
})
|
||||
{
|
||||
if (CountModules(station.InstalledModules, moduleId) < targetCount
|
||||
&& moduleRecipes.ContainsKey(moduleId))
|
||||
{
|
||||
return moduleId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void InitializeStationPopulation(StationRuntime station)
|
||||
{
|
||||
var habitatModules = CountModules(station.InstalledModules, "module_arg_hab_m_01");
|
||||
@@ -406,6 +426,8 @@ internal sealed class WorldSeedingService
|
||||
{
|
||||
Kind = behavior.Kind,
|
||||
AreaSystemId = behavior.AreaSystemId,
|
||||
TargetEntityId = behavior.TargetEntityId,
|
||||
ItemId = behavior.ItemId,
|
||||
ModuleId = behavior.ModuleId,
|
||||
NodeId = behavior.NodeId,
|
||||
Phase = behavior.Phase,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"WorldGeneration": {
|
||||
"TargetSystemCount": 1,
|
||||
"TargetSystemCount": 3,
|
||||
"IncludeSolSystem": true
|
||||
},
|
||||
"OrbitalSimulation": {
|
||||
|
||||
Reference in New Issue
Block a user