Deepen faction economy and station planning

This commit is contained in:
2026-03-19 23:34:06 -04:00
parent 9a5040cf1f
commit cd1fe776a5
33 changed files with 3170 additions and 175 deletions

View File

@@ -19,6 +19,8 @@ internal sealed class ShipBehaviorStateMachine
{
idleState,
new PatrolShipBehaviorState(),
new AttackTargetShipBehaviorState(),
new TradeHaulShipBehaviorState(),
new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining"),
new ConstructStationShipBehaviorState(),
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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