Compare commits

...

10 Commits

118 changed files with 8175 additions and 3218 deletions

View File

@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
namespace SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Api.Definitions;
public sealed class ConstructionDefinition
{
@@ -216,6 +216,7 @@ public sealed class ModuleDefinition
[JsonPropertyName("product")]
public List<string> ProductIds
{
get => Products;
set => Products = value ?? [];
}
}
@@ -292,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; }
@@ -306,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

View File

@@ -1,4 +1,4 @@
namespace SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Api.Economy.Contracts;
public sealed record MarketOrderSnapshot(
string Id,

View File

@@ -0,0 +1,30 @@
namespace SpaceGame.Api.Economy.Runtime;
public sealed class MarketOrderRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public string? StationId { get; init; }
public string? ConstructionSiteId { get; init; }
public required string Kind { get; init; }
public required string ItemId { get; init; }
public float Amount { get; init; }
public float RemainingAmount { get; set; }
public float Valuation { get; set; }
public float? ReserveThreshold { get; set; }
public string? PolicySetId { get; set; }
public string State { get; set; } = MarketOrderStateKinds.Open;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class PolicySetRuntime
{
public required string Id { get; init; }
public required string OwnerKind { get; init; }
public required string OwnerId { get; init; }
public string TradeAccessPolicy { get; set; } = "owner-and-allies";
public string DockingAccessPolicy { get; set; } = "owner-and-allies";
public string ConstructionAccessPolicy { get; set; } = "owner-only";
public string OperationalRangePolicy { get; set; } = "unrestricted";
public string LastDeltaSignature { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,403 @@
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Factions.AI;
internal sealed class CommanderPlanningService
{
private const float FactionCommanderReplanInterval = 10f;
private const float ShipCommanderReplanInterval = 5f;
private static readonly GoapPlanner<FactionPlanningState> _factionPlanner = new(s => s.Clone());
private static readonly GoapPlanner<ShipPlanningState> _shipPlanner = new(s => s.Clone());
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(),
];
private static readonly GoapGoal<ShipPlanningState> _shipGoal = new AssignObjectiveGoal();
internal void UpdateCommanders(SimulationEngine engine, SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
// Faction commanders run first so their directives are available to ship commanders in the same tick.
foreach (var commander in world.Commanders)
{
if (!commander.IsAlive || commander.Kind != CommanderKind.Faction)
{
continue;
}
TickCommander(commander, deltaSeconds);
UpdateFactionCommander(engine, world, commander);
}
foreach (var commander in world.Commanders)
{
if (!commander.IsAlive || commander.Kind != CommanderKind.Ship)
{
continue;
}
TickCommander(commander, deltaSeconds);
UpdateShipCommander(engine, world, commander);
}
}
private static void TickCommander(CommanderRuntime commander, float deltaSeconds)
{
if (commander.ReplanTimer > 0f)
{
commander.ReplanTimer = MathF.Max(0f, commander.ReplanTimer - deltaSeconds);
}
}
private void UpdateFactionCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
if (commander.ReplanTimer > 0f && !commander.NeedsReplan)
{
return;
}
commander.ReplanTimer = FactionCommanderReplanInterval;
commander.NeedsReplan = false;
var state = BuildFactionPlanningState(world, commander.FactionId);
var actions = BuildFactionActions(world);
// Clear stale directives — actions will re-assert what is still needed.
commander.ActiveDirectives.Clear();
var rankedGoals = _factionGoals
.Select(g => (goal: g, priority: g.ComputePriority(state, world, commander)))
.Where(x => x.priority > 0f)
.OrderByDescending(x => x.priority)
.ToList();
commander.LastPlanningState = state;
commander.LastGoalPriorities = rankedGoals.Select(x => (x.goal.Name, x.priority)).ToList();
// Execute the first action of each active goal's plan (top 3 to avoid conflicts).
foreach (var (goal, _) in rankedGoals.Take(3))
{
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)
{
if (commander.ReplanTimer > 0f && !commander.NeedsReplan)
{
return;
}
commander.ReplanTimer = ShipCommanderReplanInterval;
commander.NeedsReplan = false;
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
if (ship is null)
{
return;
}
var state = BuildShipPlanningState(world, ship, commander);
var plan = _shipPlanner.Plan(state, _shipGoal, _shipActions);
if (plan?.CurrentAction is { } action)
{
commander.ActiveGoalName = _shipGoal.Name;
commander.ActiveActionName = action.Name;
action.Execute(engine, world, commander);
}
}
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)),
MinerShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "mining", StringComparison.Ordinal)),
TransportShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "transport", StringComparison.Ordinal)),
ConstructorShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "construction", StringComparison.Ordinal)),
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 = 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,
};
}
private static ShipPlanningState BuildShipPlanningState(
SimulationWorld world,
ShipRuntime ship,
CommanderRuntime commander)
{
var factionCommander = world.Commanders.FirstOrDefault(c =>
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,
};
}
private static IReadOnlyList<GoapAction<FactionPlanningState>> BuildFactionActions(SimulationWorld world)
{
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;
}
internal static bool FactionCommanderHasDirective(SimulationWorld world, string factionId, string directive) =>
world.Commanders.FirstOrDefault(c =>
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;
}
}

View File

@@ -0,0 +1,328 @@
namespace SpaceGame.Api.Factions.AI;
// ─── Planning State ────────────────────────────────────────────────────────────
public sealed class FactionPlanningState
{
public int MilitaryShipCount { get; set; }
public int MinerShipCount { get; set; }
public int TransportShipCount { get; set; }
public int ConstructorShipCount { get; set; }
public int ControlledSystemCount { get; set; }
public int TargetSystemCount { get; set; }
public bool HasShipFactory { get; set; }
public int EnemyFactionCount { get; set; }
public int EnemyShipCount { get; set; }
public int EnemyStationCount { get; set; }
public float OreStockpile { get; set; }
public float 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(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";
public override bool IsSatisfied(FactionPlanningState state) =>
state.MilitaryShipCount >= FactionPlanningState.ComputeTargetWarships(state);
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = FactionPlanningState.ComputeTargetWarships(state) - state.MilitaryShipCount;
return deficit <= 0 ? 0f : 50f + (deficit * 10f);
}
}
public sealed class ExterminateRivalGoal : GoapGoal<FactionPlanningState>
{
public override string Name => "exterminate-rival";
public override bool IsSatisfied(FactionPlanningState state) =>
state.EnemyFactionCount <= 0 || (state.EnemyShipCount <= 0 && state.EnemyStationCount <= 0);
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
if (state.EnemyFactionCount <= 0)
{
return 0f;
}
return 140f + (state.EnemyStationCount * 25f) + (state.EnemyShipCount * 6f);
}
}
public sealed class ExpandTerritoryGoal : GoapGoal<FactionPlanningState>
{
public override string Name => "expand-territory";
public override bool IsSatisfied(FactionPlanningState state) =>
state.ControlledSystemCount >= state.TargetSystemCount;
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = state.TargetSystemCount - state.ControlledSystemCount;
return deficit <= 0 ? 0f : 80f + (deficit * 15f);
}
}
public sealed class EnsureMiningCapacityGoal : GoapGoal<FactionPlanningState>
{
private const int MinMiners = 2;
public override string Name => "ensure-mining-capacity";
public override bool IsSatisfied(FactionPlanningState state) => state.MinerShipCount >= MinMiners;
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = MinMiners - state.MinerShipCount;
return deficit <= 0 ? 0f : 70f + (deficit * 12f);
}
}
public sealed class EnsureConstructionCapacityGoal : GoapGoal<FactionPlanningState>
{
private const int MinConstructors = 1;
public override string Name => "ensure-construction-capacity";
public override bool IsSatisfied(FactionPlanningState state) => state.ConstructorShipCount >= MinConstructors;
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = MinConstructors - state.ConstructorShipCount;
return deficit <= 0 ? 0f : 60f + (deficit * 10f);
}
}
// ─── Actions ───────────────────────────────────────────────────────────────────
public sealed class OrderShipProductionAction : GoapAction<FactionPlanningState>
{
private readonly string shipKind;
private readonly string shipId;
public OrderShipProductionAction(string shipKind, string shipId)
{
this.shipKind = shipKind;
this.shipId = shipId;
}
public override string Name => $"order-{shipId}-production";
public override float Cost => 1f;
public override bool CheckPreconditions(FactionPlanningState state) =>
state.HasShipFactory && state.HasWarIndustrySupplyChain;
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
switch (shipKind)
{
case "military": state.MilitaryShipCount++; break;
case "mining": state.MinerShipCount++; break;
case "transport": state.TransportShipCount++; break;
case "construction": state.ConstructorShipCount++; break;
}
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
commander.ActiveDirectives.Add($"produce-{shipKind}-ships");
}
}
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.HasWarIndustrySupplyChain;
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
state.ControlledSystemCount++;
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
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");
}
}

View File

@@ -1,4 +1,4 @@
namespace SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Api.Factions.Contracts;
public sealed record FactionGoapStateSnapshot(
int MilitaryShipCount,
@@ -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);

View File

@@ -0,0 +1,76 @@
namespace SpaceGame.Api.Factions.Runtime;
public sealed class FactionRuntime
{
public required string Id { get; init; }
public required string Label { get; init; }
public required string Color { get; init; }
public float Credits { get; set; }
public float PopulationTotal { get; set; }
public float OreMined { get; set; }
public float GoodsProduced { get; set; }
public int ShipsBuilt { get; set; }
public int ShipsLost { get; set; }
public HashSet<string> CommanderIds { get; } = new(StringComparer.Ordinal);
public string? DefaultPolicySetId { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class CommanderRuntime
{
public required string Id { get; init; }
public required string Kind { get; set; }
public required string FactionId { get; init; }
public string? ParentCommanderId { get; set; }
public string? ControlledEntityId { get; set; }
public string? PolicySetId { get; set; }
public string? Doctrine { get; set; }
public List<string> Goals { get; } = [];
public HashSet<string> ActiveDirectives { get; } = new(StringComparer.Ordinal);
public string? ActiveGoalName { get; set; }
public string? ActiveActionName { get; set; }
public float ReplanTimer { get; set; }
public bool NeedsReplan { get; set; } = true;
public CommanderBehaviorRuntime? ActiveBehavior { get; set; }
public CommanderOrderRuntime? ActiveOrder { get; set; }
public CommanderTaskRuntime? ActiveTask { get; set; }
public HashSet<string> SubordinateCommanderIds { get; } = new(StringComparer.Ordinal);
public bool IsAlive { get; set; } = true;
public FactionPlanningState? LastPlanningState { get; set; }
public IReadOnlyList<(string Name, float Priority)>? LastGoalPriorities { get; set; }
}
public sealed class CommanderBehaviorRuntime
{
public required string Kind { get; set; }
public string? Phase { get; set; }
public string? TargetEntityId { get; set; }
public string? ItemId { get; set; }
public string? NodeId { get; set; }
public string? StationId { get; set; }
public string? ModuleId { get; set; }
public string? AreaSystemId { get; set; }
public int PatrolIndex { get; set; }
}
public sealed class CommanderOrderRuntime
{
public required string Kind { get; init; }
public OrderStatus Status { get; set; } = OrderStatus.Accepted;
public string? TargetEntityId { get; set; }
public string? DestinationNodeId { get; set; }
public required string DestinationSystemId { get; init; }
public required Vector3 DestinationPosition { get; init; }
}
public sealed class CommanderTaskRuntime
{
public required string Kind { get; set; }
public WorkStatus Status { get; set; } = WorkStatus.Pending;
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetNodeId { get; set; }
public Vector3? TargetPosition { get; set; }
public float Threshold { get; set; }
}

View File

@@ -0,0 +1,22 @@
global using SpaceGame.Api.Definitions;
global using SpaceGame.Api.Economy.Contracts;
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;
global using SpaceGame.Api.Ships.AI;
global using SpaceGame.Api.Ships.Contracts;
global using SpaceGame.Api.Ships.Runtime;
global using SpaceGame.Api.Ships.Simulation;
global using SpaceGame.Api.Simulation.Core;
global using SpaceGame.Api.Stations.Contracts;
global using SpaceGame.Api.Stations.Runtime;
global using SpaceGame.Api.Stations.Simulation;
global using SpaceGame.Api.Universe.Contracts;
global using SpaceGame.Api.Universe.Runtime;
global using SpaceGame.Api.Universe.Scenario;
global using SpaceGame.Api.Universe.Simulation;

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

View 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);

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

View 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),
};
}
}

View File

@@ -1,9 +1,7 @@
using SpaceGame.Simulation.Api.Contracts;
using SpaceGame.Simulation.Api.Simulation;
using System.Text.Json;
using FastEndpoints;
using SpaceGame.Api.Universe.Simulation;
var builder = WebApplication.CreateBuilder(args);
var sseJsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
builder.WebHost.UseUrls("http://127.0.0.1:5079");
builder.Services.AddCors((options) =>
@@ -18,61 +16,13 @@ builder.Services.AddCors((options) =>
});
builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration"));
builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
builder.Services.AddFastEndpoints();
builder.Services.AddSingleton<WorldService>();
builder.Services.AddHostedService<SimulationHostedService>();
var app = builder.Build();
app.UseCors();
app.MapGet("/", () => Results.Redirect("/api/world"));
app.MapGet("/api/world", (WorldService worldService) => Results.Ok(worldService.GetSnapshot()));
app.MapGet("/api/world/stream", async (HttpContext httpContext, WorldService worldService, CancellationToken cancellationToken) =>
{
httpContext.Response.Headers.Append("Cache-Control", "no-cache");
httpContext.Response.Headers.Append("Content-Type", "text/event-stream");
var afterSequenceRaw = httpContext.Request.Query["afterSequence"].ToString();
_ = long.TryParse(afterSequenceRaw, out var afterSequence);
var scopeKind = httpContext.Request.Query["scopeKind"].ToString();
if (string.IsNullOrWhiteSpace(scopeKind))
{
scopeKind = httpContext.Request.Query["scope"].ToString();
}
if (string.IsNullOrWhiteSpace(scopeKind))
{
scopeKind = "universe";
}
var systemId = httpContext.Request.Query["systemId"].ToString();
var bubbleId = httpContext.Request.Query["bubbleId"].ToString();
var scope = new ObserverScope(
scopeKind,
string.IsNullOrWhiteSpace(systemId) ? null : systemId,
string.IsNullOrWhiteSpace(bubbleId) ? null : bubbleId);
var stream = worldService.Subscribe(scope, afterSequence, cancellationToken);
await httpContext.Response.WriteAsync(": connected\n\n", cancellationToken);
await httpContext.Response.Body.FlushAsync(cancellationToken);
await foreach (var delta in stream.ReadAllAsync(cancellationToken))
{
var payload = JsonSerializer.Serialize(delta, sseJsonOptions);
await httpContext.Response.WriteAsync($"event: world-delta\ndata: {payload}\n\n", cancellationToken);
await httpContext.Response.Body.FlushAsync(cancellationToken);
}
});
app.MapGet("/api/world/health", (WorldService worldService) => Results.Ok(new
{
ok = true,
sequence = worldService.GetStatus().Sequence,
generatedAtUtc = worldService.GetStatus().GeneratedAtUtc,
}));
app.MapPost("/api/world/reset", (WorldService worldService) =>
{
var snapshot = worldService.Reset();
return Results.Ok(snapshot);
});
app.UseFastEndpoints();
app.Run();

View File

@@ -1,4 +1,5 @@
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Shared.AI;
public abstract class GoapAction<TState>
{

View File

@@ -1,4 +1,4 @@
namespace SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Api.Shared.Contracts;
public sealed record Vector3Dto(float X, float Y, float Z);

View File

@@ -1,4 +1,4 @@
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Shared.Runtime;
public enum SpatialNodeKind
{
@@ -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",

View File

@@ -1,17 +1,15 @@
using SpaceGame.Simulation.Api.Contracts;
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Shared.Runtime;
public sealed partial class SimulationEngine
internal static class SimulationRuntimeSupport
{
private static bool HasShipCapabilities(ShipDefinition definition, params string[] capabilities) =>
internal static bool HasShipCapabilities(ShipDefinition definition, params string[] capabilities) =>
capabilities.All(cap => definition.Capabilities.Contains(cap, StringComparer.Ordinal));
private static int CountStationModules(StationRuntime station, string moduleId) =>
internal static int CountStationModules(StationRuntime station, string moduleId) =>
station.Modules.Count(module => string.Equals(module.ModuleId, moduleId, StringComparison.Ordinal));
private static void AddStationModule(SimulationWorld world, StationRuntime station, string moduleId)
internal static void AddStationModule(SimulationWorld world, StationRuntime station, string moduleId)
{
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var definition))
{
@@ -28,7 +26,7 @@ public sealed partial class SimulationEngine
station.Radius = GetStationRadius(world, station);
}
private static float GetStationRadius(SimulationWorld world, StationRuntime station)
internal static float GetStationRadius(SimulationWorld world, StationRuntime station)
{
var totalArea = station.Modules
.Select(module => world.ModuleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f)
@@ -36,7 +34,7 @@ public sealed partial class SimulationEngine
return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f)));
}
private static float GetStationStorageCapacity(StationRuntime station, string storageClass)
internal static float GetStationStorageCapacity(StationRuntime station, string storageClass)
{
var baseCapacity = storageClass switch
{
@@ -60,13 +58,13 @@ public sealed partial class SimulationEngine
return baseCapacity + moduleCapacity;
}
private static int CountModules(IEnumerable<string> modules, string moduleId) =>
internal static int CountModules(IEnumerable<string> modules, string moduleId) =>
modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal));
private static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
internal static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
private static void AddInventory(IDictionary<string, float> inventory, string itemId, float amount)
internal static void AddInventory(IDictionary<string, float> inventory, string itemId, float amount)
{
if (amount <= 0f)
{
@@ -76,7 +74,7 @@ public sealed partial class SimulationEngine
inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId) + amount;
}
private static float RemoveInventory(IDictionary<string, float> inventory, string itemId, float amount)
internal static float RemoveInventory(IDictionary<string, float> inventory, string itemId, float amount)
{
var current = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId);
var removed = MathF.Min(current, amount);
@@ -93,18 +91,18 @@ public sealed partial class SimulationEngine
return removed;
}
private static bool HasStationModules(StationRuntime station, params string[] modules) =>
internal static bool HasStationModules(StationRuntime station, params string[] modules) =>
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
private static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node, SimulationWorld world) =>
internal static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node, SimulationWorld world) =>
HasShipCapabilities(ship.Definition, "mining")
&& world.ItemDefinitions.TryGetValue(node.ItemId, out var item)
&& string.Equals(item.CargoKind, ship.Definition.CargoKind, StringComparison.Ordinal);
private static bool CanBuildClaimBeacon(ShipRuntime ship) =>
internal static bool CanBuildClaimBeacon(ShipRuntime ship) =>
string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal);
private static float ComputeWorkforceRatio(float population, float workforceRequired)
internal static float ComputeWorkforceRatio(float population, float workforceRequired)
{
if (workforceRequired <= 0.01f)
{
@@ -115,7 +113,7 @@ public sealed partial class SimulationEngine
return 0.1f + (0.9f * staffedRatio);
}
private static string? GetStorageRequirement(string storageClass) =>
internal static string? GetStorageRequirement(string storageClass) =>
storageClass switch
{
"solid" => "module_arg_stor_solid_m_01",
@@ -123,7 +121,7 @@ public sealed partial class SimulationEngine
_ => null,
};
private static float TryAddStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount)
internal static float TryAddStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount)
{
if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
{
@@ -156,15 +154,15 @@ public sealed partial class SimulationEngine
return accepted;
}
private static bool CanStartModuleConstruction(StationRuntime station, ModuleRecipeDefinition recipe) =>
internal static bool CanStartModuleConstruction(StationRuntime station, ModuleRecipeDefinition recipe) =>
recipe.Inputs.All(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f >= input.Amount);
private static ConstructionSiteRuntime? GetConstructionSiteForStation(SimulationWorld world, string stationId) =>
internal static ConstructionSiteRuntime? GetConstructionSiteForStation(SimulationWorld world, string stationId) =>
world.ConstructionSites.FirstOrDefault(site =>
string.Equals(site.StationId, stationId, StringComparison.Ordinal)
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
private static float GetConstructionDeliveredAmount(SimulationWorld world, ConstructionSiteRuntime site, string itemId)
internal static float GetConstructionDeliveredAmount(SimulationWorld world, ConstructionSiteRuntime site, string itemId)
{
if (site.StationId is not null
&& world.Stations.FirstOrDefault(candidate => candidate.Id == site.StationId) is { } station)
@@ -175,6 +173,9 @@ public sealed partial class SimulationEngine
return GetInventoryAmount(site.DeliveredItems, itemId);
}
private static bool IsConstructionSiteReady(SimulationWorld world, ConstructionSiteRuntime site) =>
internal static bool IsConstructionSiteReady(SimulationWorld world, ConstructionSiteRuntime site) =>
site.RequiredItems.All(entry => GetConstructionDeliveredAmount(world, site, entry.Key) + 0.001f >= entry.Value);
internal static float GetShipCargoAmount(ShipRuntime ship) =>
ship.Inventory.Values.Sum();
}

View File

@@ -1,4 +1,4 @@
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Shared.Runtime;
public static class SimulationUnits
{

View File

@@ -1,4 +1,4 @@
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Shared.Runtime;
public readonly record struct Vector3(float X, float Y, float Z)
{

View File

@@ -0,0 +1,11 @@
namespace SpaceGame.Api.Ships.AI;
internal interface IShipBehaviorState
{
string Kind { get; }
void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world);
void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent);
}

View File

@@ -0,0 +1,41 @@
namespace SpaceGame.Api.Ships.AI;
internal sealed class ShipBehaviorStateMachine
{
private readonly IReadOnlyDictionary<string, IShipBehaviorState> states;
private readonly IShipBehaviorState fallbackState;
private ShipBehaviorStateMachine(IReadOnlyDictionary<string, IShipBehaviorState> states, IShipBehaviorState fallbackState)
{
this.states = states;
this.fallbackState = fallbackState;
}
public static ShipBehaviorStateMachine CreateDefault()
{
var idleState = new IdleShipBehaviorState();
var knownStates = new IShipBehaviorState[]
{
idleState,
new PatrolShipBehaviorState(),
new AttackTargetShipBehaviorState(),
new TradeHaulShipBehaviorState(),
new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining"),
new ConstructStationShipBehaviorState(),
};
return new ShipBehaviorStateMachine(
knownStates.ToDictionary(state => state.Kind, StringComparer.Ordinal),
idleState);
}
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
Resolve(ship.DefaultBehavior.Kind).Plan(engine, ship, world);
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) =>
Resolve(ship.DefaultBehavior.Kind).ApplyEvent(engine, ship, world, controllerEvent);
private IShipBehaviorState Resolve(string kind) =>
states.TryGetValue(kind, out var state) ? state : fallbackState;
}

View File

@@ -1,4 +1,5 @@
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Ships.AI;
internal sealed class IdleShipBehaviorState : IShipBehaviorState
{
@@ -92,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;
@@ -125,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

@@ -1,4 +1,5 @@
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Ships.AI;
// ─── Planning State ────────────────────────────────────────────────────────────
@@ -8,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();
@@ -101,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)
{
@@ -128,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

@@ -1,4 +1,4 @@
namespace SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Api.Ships.Contracts;
public sealed record ShipSnapshot(
string Id,

View File

@@ -1,6 +1,5 @@
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Ships.Runtime;
public sealed class ShipRuntime
{
@@ -19,7 +18,7 @@ public sealed class ShipRuntime
public float ActionTimer { get; set; }
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public string DockedStationId { get; set; }
public string? DockedStationId { get; set; }
public int? AssignedDockingPadIndex { get; set; }
public string? CommanderId { get; set; }
public string? PolicySetId { get; set; }
@@ -43,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

@@ -1,9 +1,12 @@
using SpaceGame.Simulation.Api.Data;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Ships.Simulation;
public sealed partial class SimulationEngine
internal sealed class ShipControlService
{
private static readonly ShipBehaviorStateMachine _shipBehaviorStateMachine = ShipBehaviorStateMachine.CreateDefault();
private static CommanderRuntime? GetShipCommander(SimulationWorld world, ShipRuntime ship) =>
ship.CommanderId is null
? null
@@ -15,6 +18,8 @@ public sealed partial class SimulationEngine
{
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;
@@ -58,6 +63,8 @@ public sealed partial class SimulationEngine
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;
@@ -91,7 +98,7 @@ public sealed partial class SimulationEngine
commander.ActiveTask.Threshold = ship.ControllerTask.Threshold;
}
private void RefreshControlLayers(ShipRuntime ship, SimulationWorld world)
internal void RefreshControlLayers(ShipRuntime ship, SimulationWorld world)
{
var commander = GetShipCommander(world, ship);
if (commander is not null)
@@ -114,7 +121,7 @@ public sealed partial class SimulationEngine
}
}
private void PlanControllerTask(ShipRuntime ship, SimulationWorld world)
internal void PlanControllerTask(SimulationEngine engine, ShipRuntime ship, SimulationWorld world)
{
var commander = GetShipCommander(world, ship);
if (ship.Order is not null)
@@ -133,28 +140,197 @@ public sealed partial class SimulationEngine
return;
}
_shipBehaviorStateMachine.Plan(this, ship, world);
_shipBehaviorStateMachine.Plan(engine, ship, world);
SyncCommanderTask(commander, ship.ControllerTask);
}
internal void PlanAttackTarget(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var target = ResolveAttackTarget(ship, world);
if (target is null)
{
behavior.Kind = "idle";
behavior.TargetEntityId = null;
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
behavior.TargetEntityId = target.EntityId;
behavior.AreaSystemId = target.SystemId;
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.AttackTarget,
TargetEntityId = target.EntityId,
TargetSystemId = target.SystemId,
TargetPosition = target.Position,
Threshold = target.AttackRange,
};
}
internal void PlanTransportHaul(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var sourceStation = behavior.StationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId);
var destinationStation = behavior.TargetEntityId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId);
if (sourceStation is null || destinationStation is null || string.IsNullOrWhiteSpace(behavior.ItemId))
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
var carryingCargo = GetShipCargoAmount(ship) > 0.01f;
if (carryingCargo)
{
if (ship.DockedStationId == destinationStation.Id)
{
behavior.Phase = "unload";
}
else if (ship.DockedStationId is not null)
{
behavior.Phase = "undock-from-source";
}
else if (behavior.Phase is not "travel-to-destination" and not "dock-destination" and not "unload")
{
behavior.Phase = "travel-to-destination";
}
}
else
{
if (ship.DockedStationId == sourceStation.Id)
{
var available = GetInventoryAmount(sourceStation.Inventory, behavior.ItemId);
behavior.Phase = available > 0.01f ? "load" : "wait-source";
}
else if (ship.DockedStationId == destinationStation.Id)
{
behavior.Phase = "undock-from-destination";
}
else if (behavior.Phase is not "travel-to-source" and not "dock-source" and not "load")
{
behavior.Phase = "travel-to-source";
}
}
ship.ControllerTask = behavior.Phase switch
{
"travel-to-source" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = sourceStation.Position,
Threshold = sourceStation.Radius + 8f,
ItemId = behavior.ItemId,
},
"dock-source" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = sourceStation.Position,
Threshold = sourceStation.Radius + 4f,
ItemId = behavior.ItemId,
},
"load" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Load,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = sourceStation.Position,
Threshold = 0f,
ItemId = behavior.ItemId,
},
"undock-from-source" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = new Vector3(sourceStation.Position.X + world.Balance.UndockDistance, sourceStation.Position.Y, sourceStation.Position.Z),
Threshold = 8f,
ItemId = behavior.ItemId,
},
"travel-to-destination" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = destinationStation.Position,
Threshold = destinationStation.Radius + 8f,
ItemId = behavior.ItemId,
},
"dock-destination" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = destinationStation.Position,
Threshold = destinationStation.Radius + 4f,
ItemId = behavior.ItemId,
},
"unload" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Unload,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = destinationStation.Position,
Threshold = 0f,
ItemId = behavior.ItemId,
},
"undock-from-destination" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = new Vector3(destinationStation.Position.X + world.Balance.UndockDistance, destinationStation.Position.Y, destinationStation.Position.Z),
Threshold = 8f,
ItemId = behavior.ItemId,
},
_ => CreateIdleTask(world.Balance.ArrivalThreshold),
};
}
internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule)
{
var behavior = ship.DefaultBehavior;
var 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;
}
@@ -250,6 +426,55 @@ public sealed partial class SimulationEngine
}
}
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
@@ -264,7 +489,8 @@ public sealed partial class SimulationEngine
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;
@@ -272,7 +498,18 @@ public sealed partial class SimulationEngine
})
.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) =>
@@ -317,7 +554,9 @@ public sealed partial class SimulationEngine
{
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";
@@ -325,6 +564,13 @@ public sealed partial class SimulationEngine
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)
@@ -344,13 +590,17 @@ public sealed partial class SimulationEngine
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)
{
@@ -387,7 +637,7 @@ public sealed partial class SimulationEngine
{
Kind = ControllerTaskKind.ConstructModule,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
@@ -397,7 +647,7 @@ public sealed partial class SimulationEngine
{
Kind = ControllerTaskKind.DeliverConstruction,
TargetEntityId = site?.Id,
TargetSystemId = station.SystemId,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
@@ -407,7 +657,7 @@ public sealed partial class SimulationEngine
{
Kind = ControllerTaskKind.BuildConstructionSite,
TargetEntityId = site?.Id,
TargetSystemId = station.SystemId,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
@@ -416,8 +666,8 @@ public sealed partial class SimulationEngine
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Idle,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetEntityId = site?.Id ?? station.Id,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 0f,
};
@@ -426,8 +676,8 @@ public sealed partial class SimulationEngine
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetEntityId = site?.Id ?? station.Id,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
@@ -436,7 +686,7 @@ public sealed partial class SimulationEngine
}
}
private void AdvanceControlState(ShipRuntime ship, SimulationWorld world, string controllerEvent)
internal void AdvanceControlState(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
var commander = GetShipCommander(world, ship);
if (ship.Order is not null && controllerEvent == "arrived")
@@ -458,7 +708,7 @@ public sealed partial class SimulationEngine
return;
}
_shipBehaviorStateMachine.ApplyEvent(this, ship, world, controllerEvent);
_shipBehaviorStateMachine.ApplyEvent(engine, ship, world, controllerEvent);
if (commander is not null)
{
SyncShipToCommander(ship, commander);
@@ -469,7 +719,7 @@ public sealed partial class SimulationEngine
}
}
private static void TrackHistory(ShipRuntime ship, string controllerEvent)
internal void TrackHistory(ShipRuntime ship, string controllerEvent)
{
var signature = $"{ship.State.ToContractValue()}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind.ToContractValue()}|{ship.ControllerTask.TargetSystemId}|{ship.ControllerTask.TargetEntityId}|{GetShipCargoAmount(ship):0.0}|{controllerEvent}";
if (signature == ship.LastSignature)
@@ -489,7 +739,38 @@ public sealed partial class SimulationEngine
}
}
private static ControllerTaskRuntime CreateIdleTask(float threshold) =>
internal void EmitShipStateEvents(
ShipRuntime ship,
ShipState previousState,
string previousBehavior,
ControllerTaskKind previousTask,
string controllerEvent,
ICollection<SimulationEventRecord> events)
{
var occurredAtUtc = DateTimeOffset.UtcNow;
if (previousState != ship.State)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState.ToContractValue()} -> {ship.State.ToContractValue()}", occurredAtUtc));
}
if (previousBehavior != ship.DefaultBehavior.Kind)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "behavior-changed", $"{ship.Definition.Label} behavior {previousBehavior} -> {ship.DefaultBehavior.Kind}", occurredAtUtc));
}
if (previousTask != ship.ControllerTask.Kind)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask.ToContractValue()} -> {ship.ControllerTask.Kind.ToContractValue()}", occurredAtUtc));
}
if (controllerEvent != "none")
{
events.Add(new SimulationEventRecord("ship", ship.Id, controllerEvent, $"{ship.Definition.Label} {controllerEvent}", occurredAtUtc));
}
}
internal static ControllerTaskRuntime CreateIdleTask(float threshold) =>
new()
{
Kind = ControllerTaskKind.Idle,
@@ -505,6 +786,7 @@ public sealed partial class SimulationEngine
"unload" => ControllerTaskKind.Unload,
"deliver-construction" => ControllerTaskKind.DeliverConstruction,
"build-construction-site" => ControllerTaskKind.BuildConstructionSite,
"attack-target" => ControllerTaskKind.AttackTarget,
"construct-module" => ControllerTaskKind.ConstructModule,
"undock" => ControllerTaskKind.Undock,
@@ -529,4 +811,62 @@ public sealed partial class SimulationEngine
Threshold = task.Threshold,
};
}
private static Vector3 ResolveConstructionHoldPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world)
{
if (site is null || site.StationId is not null)
{
return GetConstructionHoldPosition(station, ship.Id);
}
var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
var anchorPosition = anchor?.Position ?? station.Position;
return GetResourceHoldPosition(anchorPosition, ship.Id, 78f);
}
private static AttackTargetCandidate? ResolveAttackTarget(ShipRuntime ship, SimulationWorld world)
{
if (!string.IsNullOrWhiteSpace(ship.DefaultBehavior.TargetEntityId))
{
var direct = ResolveAttackTargetCandidate(world, ship.DefaultBehavior.TargetEntityId!);
if (direct is not null && !string.Equals(direct.FactionId, ship.FactionId, StringComparison.Ordinal))
{
return direct;
}
}
var hostileShips = world.Ships
.Where(candidate => candidate.Health > 0f && !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
.Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, 26f))
.ToList();
var hostileStations = world.Stations
.Where(candidate => !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
.Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, candidate.Radius + 18f))
.ToList();
var preferredSystemId = ship.DefaultBehavior.AreaSystemId;
return hostileShips
.Concat(hostileStations)
.OrderBy(candidate => preferredSystemId is null || candidate.SystemId == preferredSystemId ? 0 : 1)
.ThenBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1)
.ThenBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
}
private static AttackTargetCandidate? ResolveAttackTargetCandidate(SimulationWorld world, string entityId)
{
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == entityId && candidate.Health > 0f);
if (ship is not null)
{
return new AttackTargetCandidate(ship.Id, ship.FactionId, ship.SystemId, ship.Position, 26f);
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == entityId);
return station is null
? null
: new AttackTargetCandidate(station.Id, station.FactionId, station.SystemId, station.Position, station.Radius + 18f);
}
private sealed record AttackTargetCandidate(string EntityId, string FactionId, string SystemId, Vector3 Position, float AttackRange);
}

View File

@@ -1,6 +1,9 @@
namespace SpaceGame.Simulation.Api.Simulation;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
public sealed partial class SimulationEngine
namespace SpaceGame.Api.Ships.Simulation;
internal sealed partial class ShipTaskExecutionService
{
private static bool AdvanceTimedAction(ShipRuntime ship, float deltaSeconds, float requiredSeconds)
{
@@ -26,7 +29,7 @@ public sealed partial class SimulationEngine
}
internal static float GetShipCargoAmount(ShipRuntime ship) =>
ship.Inventory.Values.Sum();
SimulationRuntimeSupport.GetShipCargoAmount(ship);
private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
@@ -163,10 +166,12 @@ public sealed partial class SimulationEngine
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))
{
@@ -175,6 +180,12 @@ public sealed partial class SimulationEngine
}
}
if (!transferredAny && GetShipCargoAmount(ship) > 0.01f && HasShipCapabilities(ship.Definition, "mining"))
{
ship.Inventory.Clear();
return "unloaded";
}
return GetShipCargoAmount(ship) <= 0.01f ? "unloaded" : "none";
}
@@ -236,7 +247,7 @@ public sealed partial class SimulationEngine
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;
@@ -293,7 +304,7 @@ public sealed partial class SimulationEngine
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;
@@ -310,6 +321,28 @@ public sealed partial class SimulationEngine
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";
}
@@ -356,7 +389,7 @@ public sealed partial class SimulationEngine
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;
@@ -383,8 +416,16 @@ public sealed partial class SimulationEngine
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";
}
@@ -395,10 +436,21 @@ public sealed partial class SimulationEngine
? 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);
@@ -448,6 +500,90 @@ public sealed partial class SimulationEngine
return "undocked";
}
private static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) =>
internal static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) =>
site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)));
private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site)
{
var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
if (anchor is null || site.BlueprintId is null)
{
site.State = ConstructionSiteStateKinds.Destroyed;
return;
}
var station = new StationRuntime
{
Id = $"station-{world.Stations.Count + 1}",
SystemId = site.SystemId,
Label = BuildFoundedStationLabel(site.TargetDefinitionId),
Category = "station",
Objective = DetermineFoundationObjective(site.TargetDefinitionId),
Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color,
Position = anchor.Position,
FactionId = site.FactionId,
CelestialId = site.CelestialId,
Health = 600f,
MaxHealth = 600f,
};
foreach (var moduleId in GetFoundationModules(world, site.BlueprintId))
{
AddStationModule(world, station, moduleId);
}
world.Stations.Add(station);
StationLifecycleService.EnsureStationCommander(world, station);
anchor.OccupyingStructureId = station.Id;
site.StationId = station.Id;
PrepareNextConstructionSiteStep(world, station, site);
}
private static IReadOnlyList<string> GetFoundationModules(SimulationWorld world, string primaryModuleId)
{
var modules = new List<string> { "module_arg_dock_m_01_lowtech" };
foreach (var itemId in world.ProductionGraph.OutputsByModuleId.GetValueOrDefault(primaryModuleId, []))
{
if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
{
var storageModule = GetStorageRequirement(itemDefinition.CargoKind);
if (storageModule is not null && !modules.Contains(storageModule, StringComparer.Ordinal))
{
modules.Add(storageModule);
}
else if (storageModule is null && !modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal))
{
modules.Add("module_arg_stor_container_m_01");
}
}
}
if (!modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal))
{
modules.Add("module_arg_stor_container_m_01");
}
if (!string.Equals(primaryModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal))
{
modules.Add("module_gen_prod_energycells_01");
}
modules.Add(primaryModuleId);
return modules.Distinct(StringComparer.Ordinal).ToList();
}
private static string DetermineFoundationObjective(string commodityId) =>
commodityId switch
{
"energycells" => "power",
"water" => "water",
"refinedmetals" => "refinery",
"hullparts" => "hullparts",
"claytronics" => "claytronics",
"shipyard" => "shipyard",
_ => "general",
};
private static string BuildFoundedStationLabel(string commodityId) =>
$"{char.ToUpperInvariant(commodityId[0])}{commodityId[1..]} Foundry";
}

View File

@@ -1,8 +1,15 @@
namespace SpaceGame.Simulation.Api.Simulation;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
public sealed partial class SimulationEngine
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);
@@ -14,7 +21,7 @@ public sealed partial class SimulationEngine
world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, systemId, StringComparison.Ordinal))?.Position
?? Vector3.Zero;
private string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
internal string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
return task.Kind switch
@@ -27,6 +34,7 @@ public sealed partial class SimulationEngine
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),
@@ -44,6 +52,11 @@ public sealed partial class SimulationEngine
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;
@@ -54,6 +67,7 @@ public sealed partial class SimulationEngine
// Resolve live position each frame — entities like stations orbit celestials and move every tick
var targetPosition = ResolveCurrentTargetPosition(world, task);
var targetCelestial = ResolveTravelTargetCelestial(world, task, targetPosition);
var distance = ship.Position.DistanceTo(targetPosition);
ship.TargetPosition = targetPosition;
if (ship.SystemId != task.TargetSystemId)
@@ -80,9 +94,76 @@ public sealed partial class SimulationEngine
return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial);
}
if (targetCelestial is not null
&& distance > WarpEngageDistanceKilometers
&& HasShipCapabilities(ship.Definition, "warp"))
{
return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial);
}
return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold);
}
private string UpdateAttackTarget(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
if (string.IsNullOrWhiteSpace(task.TargetEntityId))
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "target-lost";
}
var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId && candidate.Health > 0f);
var hostileStation = hostileShip is null
? world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId)
: null;
if ((hostileShip is not null && string.Equals(hostileShip.FactionId, ship.FactionId, StringComparison.Ordinal))
|| (hostileStation is not null && string.Equals(hostileStation.FactionId, ship.FactionId, StringComparison.Ordinal)))
{
return "target-lost";
}
if (hostileShip is null && hostileStation is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "target-lost";
}
var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId;
var targetPosition = hostileShip?.Position ?? hostileStation!.Position;
var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f;
var attackTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = task.TargetEntityId,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
Threshold = attackRange,
};
if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange)
{
return UpdateTravel(ship, world, deltaSeconds, attackTask);
}
ship.State = ShipState.EngagingTarget;
ship.TargetPosition = targetPosition;
ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f));
var damage = GetShipDamagePerSecond(ship) * deltaSeconds;
if (hostileShip is not null)
{
hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage);
return hostileShip.Health <= 0f ? "target-destroyed" : "none";
}
hostileStation!.Health = MathF.Max(0f, hostileStation.Health - damage * 0.6f);
return hostileStation.Health <= 0f ? "target-destroyed" : "none";
}
private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ControllerTaskRuntime task)
{
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
@@ -298,4 +379,14 @@ public sealed partial class SimulationEngine
ship.State = ShipState.Arriving;
return "none";
}
private static float GetShipDamagePerSecond(ShipRuntime ship) =>
ship.Definition.Class switch
{
"frigate" => FrigateDps,
"destroyer" => DestroyerDps,
"cruiser" => CruiserDps,
"capital" => CapitalDps,
_ => 4f,
};
}

View File

@@ -1,141 +0,0 @@
namespace SpaceGame.Simulation.Api.Simulation;
// ─── Planning State ────────────────────────────────────────────────────────────
public sealed class FactionPlanningState
{
public int MilitaryShipCount { get; set; }
public int MinerShipCount { get; set; }
public int TransportShipCount { get; set; }
public int ConstructorShipCount { get; set; }
public int ControlledSystemCount { get; set; }
public int TargetSystemCount { get; set; }
public bool HasShipFactory { get; set; }
public float OreStockpile { get; set; }
public float RefinedMetalsStockpile { get; set; }
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));
}
}
// ─── Goals ─────────────────────────────────────────────────────────────────────
public sealed class EnsureWarFleetGoal : GoapGoal<FactionPlanningState>
{
public override string Name => "ensure-war-fleet";
public override bool IsSatisfied(FactionPlanningState state) =>
state.MilitaryShipCount >= FactionPlanningState.ComputeTargetWarships(state);
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = FactionPlanningState.ComputeTargetWarships(state) - state.MilitaryShipCount;
return deficit <= 0 ? 0f : 50f + (deficit * 10f);
}
}
public sealed class ExpandTerritoryGoal : GoapGoal<FactionPlanningState>
{
public override string Name => "expand-territory";
public override bool IsSatisfied(FactionPlanningState state) =>
state.ControlledSystemCount >= state.TargetSystemCount;
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = state.TargetSystemCount - state.ControlledSystemCount;
return deficit <= 0 ? 0f : 80f + (deficit * 15f);
}
}
public sealed class EnsureMiningCapacityGoal : GoapGoal<FactionPlanningState>
{
private const int MinMiners = 2;
public override string Name => "ensure-mining-capacity";
public override bool IsSatisfied(FactionPlanningState state) => state.MinerShipCount >= MinMiners;
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = MinMiners - state.MinerShipCount;
return deficit <= 0 ? 0f : 70f + (deficit * 12f);
}
}
public sealed class EnsureConstructionCapacityGoal : GoapGoal<FactionPlanningState>
{
private const int MinConstructors = 1;
public override string Name => "ensure-construction-capacity";
public override bool IsSatisfied(FactionPlanningState state) => state.ConstructorShipCount >= MinConstructors;
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = MinConstructors - state.ConstructorShipCount;
return deficit <= 0 ? 0f : 60f + (deficit * 10f);
}
}
// ─── Actions ───────────────────────────────────────────────────────────────────
public sealed class OrderShipProductionAction : GoapAction<FactionPlanningState>
{
private readonly string shipKind;
private readonly string shipId;
public OrderShipProductionAction(string shipKind, string shipId)
{
this.shipKind = shipKind;
this.shipId = shipId;
}
public override string Name => $"order-{shipId}-production";
public override float Cost => 1f;
public override bool CheckPreconditions(FactionPlanningState state) => state.HasShipFactory;
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
switch (shipKind)
{
case "military": state.MilitaryShipCount++; break;
case "mining": state.MinerShipCount++; break;
case "transport": state.TransportShipCount++; break;
case "construction": state.ConstructorShipCount++; break;
}
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
commander.ActiveDirectives.Add($"produce-{shipKind}-ships");
}
}
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;
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
state.ControlledSystemCount++;
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
commander.ActiveDirectives.Add("expand-territory");
}
}

View File

@@ -1,10 +0,0 @@
namespace SpaceGame.Simulation.Api.Simulation;
internal interface IShipBehaviorState
{
string Kind { get; }
void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world);
void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent);
}

View File

@@ -1,38 +0,0 @@
namespace SpaceGame.Simulation.Api.Simulation;
internal sealed class ShipBehaviorStateMachine
{
private readonly IReadOnlyDictionary<string, IShipBehaviorState> states;
private readonly IShipBehaviorState fallbackState;
private ShipBehaviorStateMachine(IReadOnlyDictionary<string, IShipBehaviorState> states, IShipBehaviorState fallbackState)
{
this.states = states;
this.fallbackState = fallbackState;
}
public static ShipBehaviorStateMachine CreateDefault()
{
var idleState = new IdleShipBehaviorState();
var knownStates = new IShipBehaviorState[]
{
idleState,
new PatrolShipBehaviorState(),
new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining"),
new ConstructStationShipBehaviorState(),
};
return new ShipBehaviorStateMachine(
knownStates.ToDictionary(state => state.Kind, StringComparer.Ordinal),
idleState);
}
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
Resolve(ship.DefaultBehavior.Kind).Plan(engine, ship, world);
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) =>
Resolve(ship.DefaultBehavior.Kind).ApplyEvent(engine, ship, world, controllerEvent);
private IShipBehaviorState Resolve(string kind) =>
states.TryGetValue(kind, out var state) ? state : fallbackState;
}

View File

@@ -0,0 +1,140 @@
namespace SpaceGame.Api.Simulation.Core;
public sealed class SimulationEngine
{
private readonly OrbitalSimulationOptions _orbitalSimulation;
private readonly OrbitalStateUpdater _orbitalStateUpdater;
private readonly InfrastructureSimulationService _infrastructureSimulation;
private readonly CommanderPlanningService _commanderPlanning;
private readonly StationSimulationService _stationSimulation;
private readonly StationLifecycleService _stationLifecycle;
private readonly ShipControlService _shipControl;
private readonly ShipTaskExecutionService _shipTaskExecution;
private readonly SimulationProjectionService _projection;
public SimulationEngine(OrbitalSimulationOptions? orbitalSimulation = null)
{
_orbitalSimulation = orbitalSimulation ?? new OrbitalSimulationOptions();
_orbitalStateUpdater = new OrbitalStateUpdater(_orbitalSimulation);
_infrastructureSimulation = new InfrastructureSimulationService();
_commanderPlanning = new CommanderPlanningService();
_stationSimulation = new StationSimulationService();
_stationLifecycle = new StationLifecycleService(_stationSimulation);
_shipControl = new ShipControlService();
_shipTaskExecution = new ShipTaskExecutionService();
_projection = new SimulationProjectionService(_orbitalSimulation);
}
public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence)
{
var nowUtc = DateTimeOffset.UtcNow;
var events = new List<SimulationEventRecord>();
world.OrbitalTimeSeconds += deltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond;
_orbitalStateUpdater.Update(world);
_infrastructureSimulation.UpdateClaims(world, events);
_infrastructureSimulation.UpdateConstructionSites(world, events);
_commanderPlanning.UpdateCommanders(this, world, deltaSeconds, events);
_stationLifecycle.UpdateStations(world, deltaSeconds, events);
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;
var previousTask = ship.ControllerTask.Kind;
_shipControl.RefreshControlLayers(ship, world);
_shipControl.PlanControllerTask(this, ship, world);
var controllerEvent = _shipTaskExecution.UpdateControllerTask(ship, world, deltaSeconds);
_shipControl.AdvanceControlState(this, ship, world, controllerEvent);
ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds);
_shipControl.TrackHistory(ship, controllerEvent);
_shipControl.EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events);
}
_orbitalStateUpdater.SyncSpatialState(world);
CleanupDestroyedEntities(world, events);
world.GeneratedAtUtc = nowUtc;
return _projection.BuildDelta(world, sequence, events);
}
public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence) =>
_projection.BuildSnapshot(world, sequence);
public void PrimeDeltaBaseline(SimulationWorld world) =>
_projection.PrimeDeltaBaseline(world);
internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule) =>
_shipControl.PlanResourceHarvest(ship, world, resourceItemId, requiredModule);
internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) =>
_shipControl.PlanStationConstruction(ship, world);
internal void PlanAttackTarget(ShipRuntime ship, SimulationWorld world) =>
_shipControl.PlanAttackTarget(ship, world);
internal void PlanTransportHaul(ShipRuntime ship, SimulationWorld world) =>
_shipControl.PlanTransportHaul(ship, world);
internal static float GetShipCargoAmount(ShipRuntime ship) =>
SimulationRuntimeSupport.GetShipCargoAmount(ship);
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));
}
}
}

View File

@@ -1,9 +1,37 @@
using SpaceGame.Simulation.Api.Contracts;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Simulation.Core;
public sealed partial class SimulationEngine
internal sealed class SimulationProjectionService
{
private readonly OrbitalSimulationOptions _orbitalSimulation;
internal SimulationProjectionService(OrbitalSimulationOptions orbitalSimulation)
{
_orbitalSimulation = orbitalSimulation;
}
internal WorldDelta BuildDelta(SimulationWorld world, long sequence, IReadOnlyList<SimulationEventRecord> events) =>
new(
sequence,
world.TickIntervalMs,
world.OrbitalTimeSeconds,
new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond),
world.GeneratedAtUtc,
false,
events,
BuildCelestialDeltas(world),
BuildNodeDeltas(world),
BuildStationDeltas(world),
BuildClaimDeltas(world),
BuildConstructionSiteDeltas(world),
BuildMarketOrderDeltas(world),
BuildPolicyDeltas(world),
BuildShipDeltas(world),
BuildFactionDeltas(world));
public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence)
{
PrimeDeltaBaseline(world);
@@ -73,6 +101,7 @@ public sealed partial class SimulationEngine
station.Id,
station.Label,
station.Category,
station.Objective,
station.SystemId,
station.LocalPosition,
station.CelestialId,
@@ -472,7 +501,7 @@ public sealed partial class SimulationEngine
ship.TrackedActionKey ?? "none",
ship.TrackedActionTotal.ToString("0.###"),
ship.ControllerTask.TargetEntityId is not null && world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is { } site
? GetRemainingConstructionDelivery(world, site).ToString("0.###")
? ShipTaskExecutionService.GetRemainingConstructionDelivery(world, site).ToString("0.###")
: "0",
ship.Health.ToString("0.###"),
ship.ActionTimer.ToString("0.###"));
@@ -516,6 +545,7 @@ public sealed partial class SimulationEngine
station.Id,
station.Label,
station.Category,
station.Objective,
station.SystemId,
ToDto(station.Position),
station.CelestialId,
@@ -542,12 +572,18 @@ public sealed partial class SimulationEngine
{
var recipe = SelectProductionRecipe(world, station, laneKey);
var timer = GetStationProductionTimer(station, laneKey);
var duration = MathF.Max(recipe?.Duration ?? 0.1f, 0.1f);
var progress = Math.Clamp(timer / duration, 0f, 1f);
return recipe is null || timer <= 0.01f
? null
: new StationActionProgressSnapshot(
laneKey,
recipe.Label,
Math.Clamp(timer / MathF.Max(recipe.Duration, 0.1f), 0f, 1f));
progress,
duration * (1f - progress),
duration,
recipe.Inputs.Select(i => new RecipeEntrySnapshot(i.ItemId, i.Amount)).ToList(),
recipe.Outputs.Select(o => new RecipeEntrySnapshot(o.ItemId, o.Amount)).ToList());
})
.Where(snapshot => snapshot is not null)
.Cast<StationActionProgressSnapshot>()
@@ -683,7 +719,7 @@ public sealed partial class SimulationEngine
? null
: world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is not { } site
? null
: CreateShipRemainingActionProgress("Deliver materials", ship.TrackedActionTotal, GetRemainingConstructionDelivery(world, site)),
: CreateShipRemainingActionProgress("Deliver materials", ship.TrackedActionTotal, ShipTaskExecutionService.GetRemainingConstructionDelivery(world, site)),
_ => null,
};
@@ -736,8 +772,16 @@ public sealed partial class SimulationEngine
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)
@@ -776,36 +820,8 @@ public sealed partial class SimulationEngine
state.Transit.ArrivalDueAtUtc,
state.Transit.Progress));
private static void EmitShipStateEvents(
ShipRuntime ship,
ShipState previousState,
string previousBehavior,
ControllerTaskKind previousTask,
string controllerEvent,
ICollection<SimulationEventRecord> events)
{
var occurredAtUtc = DateTimeOffset.UtcNow;
if (previousState != ship.State)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState.ToContractValue()} -> {ship.State.ToContractValue()}", occurredAtUtc));
}
if (previousBehavior != ship.DefaultBehavior.Kind)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "behavior-changed", $"{ship.Definition.Label} behavior {previousBehavior} -> {ship.DefaultBehavior.Kind}", occurredAtUtc));
}
if (previousTask != ship.ControllerTask.Kind)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask.ToContractValue()} -> {ship.ControllerTask.Kind.ToContractValue()}", occurredAtUtc));
}
if (controllerEvent != "none")
{
events.Add(new SimulationEventRecord("ship", ship.Id, controllerEvent, $"{ship.Definition.Label} {controllerEvent}", occurredAtUtc));
}
}
private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z);
private static float NormalizeFiniteFloat(float value) =>
float.IsFinite(value) ? value : -1f;
}

View File

@@ -1,30 +0,0 @@
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class MarketOrderRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public string? StationId { get; init; }
public string? ConstructionSiteId { get; init; }
public required string Kind { get; init; }
public required string ItemId { get; init; }
public float Amount { get; init; }
public float RemainingAmount { get; set; }
public float Valuation { get; set; }
public float? ReserveThreshold { get; set; }
public string? PolicySetId { get; set; }
public string State { get; set; } = MarketOrderStateKinds.Open;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class PolicySetRuntime
{
public required string Id { get; init; }
public required string OwnerKind { get; init; }
public required string OwnerId { get; init; }
public string TradeAccessPolicy { get; set; } = "owner-and-allies";
public string DockingAccessPolicy { get; set; } = "owner-and-allies";
public string ConstructionAccessPolicy { get; set; } = "owner-only";
public string OperationalRangePolicy { get; set; } = "unrestricted";
public string LastDeltaSignature { get; set; } = string.Empty;
}

View File

@@ -1,36 +0,0 @@
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class ClaimRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public required string SystemId { get; init; }
public required string CelestialId { get; init; }
public string? CommanderId { get; set; }
public DateTimeOffset PlacedAtUtc { get; init; }
public DateTimeOffset ActivatesAtUtc { get; set; }
public string State { get; set; } = ClaimStateKinds.Placed;
public float Health { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ConstructionSiteRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public required string SystemId { get; init; }
public required string CelestialId { get; init; }
public required string TargetKind { get; init; }
public required string TargetDefinitionId { get; init; }
public string? BlueprintId { get; set; }
public string? ClaimId { get; set; }
public string? StationId { get; set; }
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> RequiredItems { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> DeliveredItems { get; } = new(StringComparer.Ordinal);
public HashSet<string> AssignedConstructorShipIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> MarketOrderIds { get; } = new(StringComparer.Ordinal);
public float Progress { get; set; }
public string State { get; set; } = ConstructionSiteStateKinds.Planned;
public string LastDeltaSignature { get; set; } = string.Empty;
}

View File

@@ -1,73 +0,0 @@
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class FactionRuntime
{
public required string Id { get; init; }
public required string Label { get; init; }
public required string Color { get; init; }
public float Credits { get; set; }
public float PopulationTotal { get; set; }
public float OreMined { get; set; }
public float GoodsProduced { get; set; }
public int ShipsBuilt { get; set; }
public int ShipsLost { get; set; }
public HashSet<string> CommanderIds { get; } = new(StringComparer.Ordinal);
public string? DefaultPolicySetId { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class CommanderRuntime
{
public required string Id { get; init; }
public required string Kind { get; set; }
public required string FactionId { get; init; }
public string? ParentCommanderId { get; set; }
public string? ControlledEntityId { get; set; }
public string? PolicySetId { get; set; }
public string? Doctrine { get; set; }
public List<string> Goals { get; } = [];
public HashSet<string> ActiveDirectives { get; } = new(StringComparer.Ordinal);
public string? ActiveGoalName { get; set; }
public string? ActiveActionName { get; set; }
public float ReplanTimer { get; set; }
public bool NeedsReplan { get; set; } = true;
public CommanderBehaviorRuntime? ActiveBehavior { get; set; }
public CommanderOrderRuntime? ActiveOrder { get; set; }
public CommanderTaskRuntime? ActiveTask { get; set; }
public HashSet<string> SubordinateCommanderIds { get; } = new(StringComparer.Ordinal);
public bool IsAlive { get; set; } = true;
public FactionPlanningState? LastPlanningState { get; set; }
public IReadOnlyList<(string Name, float Priority)>? LastGoalPriorities { get; set; }
}
public sealed class CommanderBehaviorRuntime
{
public required string Kind { get; set; }
public string? Phase { get; set; }
public string? NodeId { get; set; }
public string? StationId { get; set; }
public string? ModuleId { get; set; }
public string? AreaSystemId { get; set; }
public int PatrolIndex { get; set; }
}
public sealed class CommanderOrderRuntime
{
public required string Kind { get; init; }
public OrderStatus Status { get; set; } = OrderStatus.Accepted;
public string? TargetEntityId { get; set; }
public string? DestinationNodeId { get; set; }
public required string DestinationSystemId { get; init; }
public required Vector3 DestinationPosition { get; init; }
}
public sealed class CommanderTaskRuntime
{
public required string Kind { get; set; }
public WorkStatus Status { get; set; } = WorkStatus.Pending;
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetNodeId { get; set; }
public Vector3? TargetPosition { get; set; }
public float Threshold { get; set; }
}

View File

@@ -1,608 +0,0 @@
using System.Text.Json;
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class ScenarioLoader
{
private const string DefaultFactionId = "sol-dominion";
private const int WorldSeed = 1;
private const float MinimumFactionCredits = 0f;
private const float MinimumRefineryOre = 0f;
private const float MinimumRefineryStock = 0f;
private const float MinimumShipyardStock = 0f;
private const float MinimumSystemSeparation = 3.2f;
private const float LocalSpaceRadius = 10_000f;
private static readonly string[] GeneratedSystemNames =
[
"Aquila Verge",
"Orion Fold",
"Draco Span",
"Lyra Shoal",
"Cygnus March",
"Vela Crossing",
"Carina Wake",
"Phoenix Rest",
"Hydra Loom",
"Cassio Reach",
"Lupus Chain",
"Pavo Line",
"Serpens Rise",
"Cetus Hollow",
"Delphin Crown",
"Volans Drift",
"Ara Bastion",
"Indus Veil",
"Pyxis Trace",
"Lacerta Bloom",
"Columba Shroud",
"Dorado Expanse",
"Reticulum Run",
"Norma Edge",
"Crux Horizon",
"Sagitta Corridor",
"Monoceros Deep",
"Eridan Spur",
"Centauri Shelf",
"Antlia Reach",
"Horologium Gate",
"Telescopium Strand",
];
private static readonly StarProfile[] StarProfiles =
[
new("main-sequence", "#ffd27a", "#ffb14a", 696340f),
new("blue-white", "#9dc6ff", "#66a0ff", 930000f),
new("white-dwarf", "#f1f5ff", "#b8caff", 12000f),
new("brown-dwarf", "#b97d56", "#8a5438", 70000f),
new("neutron-star", "#d9ebff", "#7ab4ff", 18f),
new("binary-main-sequence", "#ffe09f", "#ffbe6b", 780000f),
new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 14000f),
];
private static readonly PlanetProfile[] PlanetProfiles =
[
new("barren", "sphere", "#bca48f", 2800f, 0.22f, 0, false),
new("terrestrial", "sphere", "#58a36c", 6400f, 0.28f, 1, false),
new("oceanic", "sphere", "#4f84c4", 7000f, 0.30f, 2, false),
new("desert", "sphere", "#d4a373", 5200f, 0.26f, 0, false),
new("ice", "sphere", "#c8e4ff", 5800f, 0.32f, 1, false),
new("gas-giant", "oblate", "#d9b06f", 45000f, 1.40f, 8, true),
new("ice-giant", "oblate", "#8fc0d8", 25000f, 1.00f, 5, true),
new("lava", "sphere", "#db6846", 3200f, 0.20f, 0, false),
];
private readonly string _dataRoot;
private readonly WorldGenerationOptions _worldGeneration;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null)
{
_dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data"));
_worldGeneration = worldGeneration ?? new WorldGenerationOptions();
}
public SimulationWorld Load()
{
var authoredSystems = Read<List<SolarSystemDefinition>>("systems.json");
var systems = ExpandSystems(
InjectSpecialSystems(authoredSystems),
_worldGeneration.TargetSystemCount);
var scenario = NormalizeScenarioToAvailableSystems(
Read<ScenarioDefinition>("scenario.json"),
systems.Select((system) => system.Id).ToList());
var modules = NormalizeModules(Read<List<ModuleDefinition>>("modules.json"));
var ships = Read<List<ShipDefinition>>("ships.json");
var items = NormalizeItems(Read<List<ItemDefinition>>("items.json"));
var balance = Read<BalanceDefinition>("balance.json");
var recipes = BuildRecipes(items, ships, modules);
var moduleRecipes = BuildModuleRecipes(modules);
var moduleDefinitions = modules.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var shipDefinitions = ships.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var itemDefinitions = items.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var recipeDefinitions = recipes.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var moduleRecipeDefinitions = moduleRecipes.ToDictionary((definition) => definition.ModuleId, StringComparer.Ordinal);
var systemRuntimes = systems
.Select((definition) => new SystemRuntime
{
Definition = definition,
Position = ToVector(definition.Position),
})
.ToList();
var systemsById = systemRuntimes.ToDictionary((system) => system.Definition.Id, StringComparer.Ordinal);
var systemGraphs = systemRuntimes.ToDictionary(
(system) => system.Definition.Id,
(system) => BuildSystemSpatialGraph(system),
StringComparer.Ordinal);
var celestials = new List<CelestialRuntime>();
var nodes = new List<ResourceNodeRuntime>();
var nodeIdCounter = 0;
foreach (var graph in systemGraphs.Values)
{
celestials.AddRange(graph.Celestials);
}
foreach (var system in systemRuntimes)
{
var systemGraph = systemGraphs[system.Definition.Id];
foreach (var node in system.Definition.ResourceNodes)
{
var anchorCelestial = ResolveResourceNodeAnchor(systemGraph, node);
var resourceNode = new ResourceNodeRuntime
{
Id = $"node-{++nodeIdCounter}",
SystemId = system.Definition.Id,
Position = ComputeResourceNodePosition(anchorCelestial, node, balance.YPlane),
SourceKind = node.SourceKind,
ItemId = node.ItemId,
CelestialId = anchorCelestial?.Id,
OrbitRadius = node.RadiusOffset,
OrbitPhase = node.Angle,
OrbitInclination = DegreesToRadians(node.InclinationDegrees),
OreRemaining = node.OreAmount,
MaxOre = node.OreAmount,
};
nodes.Add(resourceNode);
}
}
var stations = new List<StationRuntime>();
var stationIdCounter = 0;
foreach (var plan in scenario.InitialStations)
{
if (!systemsById.TryGetValue(plan.SystemId, out var system))
{
continue;
}
var placement = ResolveStationPlacement(plan, system, systemGraphs[system.Definition.Id], celestials);
var station = new StationRuntime
{
Id = $"station-{++stationIdCounter}",
SystemId = system.Definition.Id,
Label = plan.Label,
Color = plan.Color,
Position = placement.Position,
FactionId = plan.FactionId ?? DefaultFactionId,
};
station.CelestialId = placement.AnchorCelestial.Id;
stations.Add(station);
placement.AnchorCelestial.OccupyingStructureId = station.Id;
var startingModules = plan.StartingModules.Count > 0
? plan.StartingModules
: ["module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01", "module_arg_stor_liquid_m_01"];
foreach (var moduleId in startingModules)
{
AddStationModule(stations[^1], moduleDefinitions, moduleId);
}
}
foreach (var station in stations)
{
InitializeStationPopulation(station);
station.Inventory["refinedmetals"] = 120f;
if (station.Population > 0f)
{
station.Inventory["water"] = MathF.Max(80f, station.Population * 1.5f);
}
}
var refinery = stations.FirstOrDefault((station) =>
HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01") &&
station.SystemId == scenario.MiningDefaults.RefinerySystemId)
?? stations.FirstOrDefault((station) => HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01"));
var patrolRoutes = scenario.PatrolRoutes
.GroupBy((route) => route.SystemId, StringComparer.Ordinal)
.ToDictionary(
(group) => group.Key,
(group) => group
.SelectMany((route) => route.Points)
.Select((point) => NormalizeScenarioPoint(systemsById[group.Key], point))
.ToList(),
StringComparer.Ordinal);
var shipsRuntime = new List<ShipRuntime>();
var shipIdCounter = 0;
foreach (var formation in scenario.ShipFormations)
{
if (!shipDefinitions.TryGetValue(formation.ShipId, out var definition))
{
continue;
}
for (var index = 0; index < formation.Count; index += 1)
{
var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f);
var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset);
shipsRuntime.Add(new ShipRuntime
{
Id = $"ship-{++shipIdCounter}",
SystemId = formation.SystemId,
Definition = definition,
FactionId = formation.FactionId ?? DefaultFactionId,
Position = position,
TargetPosition = position,
SpatialState = CreateInitialShipSpatialState(formation.SystemId, position, celestials),
DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending },
Health = definition.MaxHealth,
});
}
}
var factions = CreateFactions(stations, shipsRuntime);
BootstrapFactionEconomy(factions, stations);
var policies = CreatePolicies(factions);
var commanders = CreateCommanders(factions, stations, shipsRuntime);
var now = DateTimeOffset.UtcNow;
var claims = CreateClaims(stations, celestials, now);
var (constructionSites, marketOrders) = CreateConstructionSites(stations, claims, moduleRecipeDefinitions);
return new SimulationWorld
{
Label = "Split Viewer / Simulation World",
Seed = WorldSeed,
Balance = balance,
Systems = systemRuntimes,
Celestials = celestials,
Nodes = nodes,
Stations = stations,
Ships = shipsRuntime,
Factions = factions,
Commanders = commanders,
Claims = claims,
ConstructionSites = constructionSites,
MarketOrders = marketOrders,
Policies = policies,
ShipDefinitions = shipDefinitions,
ItemDefinitions = itemDefinitions,
ModuleDefinitions = moduleDefinitions,
ModuleRecipes = moduleRecipeDefinitions,
Recipes = recipeDefinitions,
OrbitalTimeSeconds = WorldSeed * 97d,
GeneratedAtUtc = DateTimeOffset.UtcNow,
};
}
private T Read<T>(string fileName)
{
var path = Path.Combine(_dataRoot, fileName);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, _jsonOptions)
?? throw new InvalidOperationException($"Unable to read {fileName}.");
}
private static ScenarioDefinition NormalizeScenarioToAvailableSystems(
ScenarioDefinition scenario,
IReadOnlyList<string> availableSystemIds)
{
if (availableSystemIds.Count == 0)
{
return scenario;
}
var fallbackSystemId = availableSystemIds.Contains("sol", StringComparer.Ordinal)
? "sol"
: availableSystemIds[0];
string ResolveSystemId(string systemId) =>
availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId;
return new ScenarioDefinition
{
InitialStations = scenario.InitialStations
.Select((station) => new InitialStationDefinition
{
SystemId = ResolveSystemId(station.SystemId),
Label = station.Label,
Color = station.Color,
StartingModules = station.StartingModules.ToList(),
FactionId = station.FactionId,
PlanetIndex = station.PlanetIndex,
LagrangeSide = station.LagrangeSide,
Position = station.Position?.ToArray(),
})
.ToList(),
ShipFormations = scenario.ShipFormations
.Select((formation) => new ShipFormationDefinition
{
ShipId = formation.ShipId,
Count = formation.Count,
Center = formation.Center.ToArray(),
SystemId = ResolveSystemId(formation.SystemId),
FactionId = formation.FactionId,
})
.ToList(),
PatrolRoutes = scenario.PatrolRoutes
.Select((route) => new PatrolRouteDefinition
{
SystemId = ResolveSystemId(route.SystemId),
Points = route.Points.Select((point) => point.ToArray()).ToList(),
})
.ToList(),
MiningDefaults = new MiningDefaultsDefinition
{
NodeSystemId = ResolveSystemId(scenario.MiningDefaults.NodeSystemId),
RefinerySystemId = ResolveSystemId(scenario.MiningDefaults.RefinerySystemId),
},
};
}
private static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
private static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values)
{
var raw = ToVector(values);
var relativeToSystem = new Vector3(
raw.X - system.Position.X,
raw.Y - system.Position.Y,
raw.Z - system.Position.Z);
return relativeToSystem.LengthSquared() < raw.LengthSquared()
? relativeToSystem
: raw;
}
private static bool HasInstalledModules(StationRuntime station, params string[] modules) =>
modules.All((moduleId) => station.Modules.Any((candidate) => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
private static bool HasCapabilities(ShipDefinition definition, params string[] capabilities) =>
capabilities.All((cap) => definition.Capabilities.Contains(cap, StringComparer.Ordinal));
private static void AddStationModule(StationRuntime station, IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, string moduleId)
{
if (!moduleDefinitions.TryGetValue(moduleId, out var definition))
{
return;
}
station.Modules.Add(new StationModuleRuntime
{
Id = $"{station.Id}-module-{station.Modules.Count + 1}",
ModuleId = moduleId,
Health = definition.Hull,
MaxHealth = definition.Hull,
});
station.Radius = GetStationRadius(moduleDefinitions, station);
}
private static float GetStationRadius(IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, StationRuntime station)
{
var totalArea = station.Modules
.Select((module) => moduleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f)
.Sum();
return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f)));
}
private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
private static int CountModules(IEnumerable<string> modules, string moduleId) =>
modules.Count((candidate) => string.Equals(candidate, moduleId, StringComparison.Ordinal));
private static float ComputeWorkforceRatio(float population, float workforceRequired)
{
if (workforceRequired <= 0.01f)
{
return 1f;
}
var staffedRatio = MathF.Min(1f, population / workforceRequired);
return 0.1f + (0.9f * staffedRatio);
}
private static List<ModuleRecipeDefinition> BuildModuleRecipes(IEnumerable<ModuleDefinition> modules) =>
modules
.Where((module) => module.Construction is not null || module.Production.Count > 0)
.Select((module) => new ModuleRecipeDefinition
{
ModuleId = module.Id,
Duration = module.Construction?.ProductionTime ?? module.Production[0].Time,
Inputs = (module.Construction?.Requirements ?? module.Production[0].Wares)
.Select((input) => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
})
.ToList();
private static List<RecipeDefinition> BuildRecipes(IEnumerable<ItemDefinition> items, IEnumerable<ShipDefinition> ships, IReadOnlyCollection<ModuleDefinition> modules)
{
var recipes = new List<RecipeDefinition>();
var preferredProducerByItemId = modules
.Where((module) => module.Products.Count > 0)
.GroupBy((module) => module.Products[0], StringComparer.Ordinal)
.ToDictionary(
(group) => group.Key,
(group) => group.OrderBy((module) => module.Id, StringComparer.Ordinal).First().Id,
StringComparer.Ordinal);
foreach (var item in items)
{
if (item.Production.Count > 0)
{
foreach (var production in item.Production)
{
recipes.Add(new RecipeDefinition
{
Id = $"{item.Id}-{production.Method}-production",
Label = production.Name == "Universal"
? item.Name
: $"{item.Name} ({production.Name})",
FacilityCategory = InferFacilityCategory(item),
Duration = production.Time,
Priority = InferRecipePriority(item),
RequiredModules = InferRequiredModules(item, preferredProducerByItemId),
Inputs = production.Wares
.Select((input) => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
Outputs =
[
new RecipeOutputDefinition
{
ItemId = item.Id,
Amount = production.Amount,
},
],
});
}
continue;
}
if (item.Construction is null)
{
continue;
}
recipes.Add(new RecipeDefinition
{
Id = item.Construction.RecipeId ?? $"{item.Id}-production",
Label = item.Name,
FacilityCategory = item.Construction.FacilityCategory,
Duration = item.Construction.CycleTime,
Priority = item.Construction.Priority,
RequiredModules = item.Construction.RequiredModules.ToList(),
Inputs = item.Construction.Requirements
.Select((input) => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
Outputs =
[
new RecipeOutputDefinition
{
ItemId = item.Id,
Amount = item.Construction.BatchSize,
},
],
});
}
foreach (var ship in ships)
{
if (ship.Construction is null)
{
continue;
}
recipes.Add(new RecipeDefinition
{
Id = ship.Construction.RecipeId ?? $"{ship.Id}-construction",
Label = $"{ship.Label} Construction",
FacilityCategory = ship.Construction.FacilityCategory,
Duration = ship.Construction.CycleTime,
Priority = ship.Construction.Priority,
RequiredModules = ship.Construction.RequiredModules.ToList(),
Inputs = ship.Construction.Requirements
.Select((input) => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
ShipOutputId = ship.Id,
});
}
return recipes;
}
private static string InferFacilityCategory(ItemDefinition item) =>
item.Group switch
{
"agricultural" or "food" or "pharmaceutical" or "water" => "farm",
_ => "station",
};
private static List<string> InferRequiredModules(ItemDefinition item, IReadOnlyDictionary<string, string> preferredProducerByItemId)
{
if (preferredProducerByItemId.TryGetValue(item.Id, out var moduleId))
{
return [moduleId];
}
return [];
}
private static int InferRecipePriority(ItemDefinition item) =>
item.Group switch
{
"energy" => 140,
"water" => 130,
"food" => 120,
"agricultural" => 110,
"refined" => 100,
"hightech" => 90,
"shiptech" => 80,
"pharmaceutical" => 70,
_ => 60,
};
private static List<ItemDefinition> NormalizeItems(List<ItemDefinition> items)
{
foreach (var item in items)
{
if (string.IsNullOrWhiteSpace(item.Type))
{
item.Type = string.IsNullOrWhiteSpace(item.Group) ? "material" : item.Group;
}
}
return items;
}
private static List<ModuleDefinition> NormalizeModules(List<ModuleDefinition> modules)
{
foreach (var module in modules)
{
if (module.Products.Count == 0 && !string.IsNullOrWhiteSpace(module.Product))
{
module.Products = [module.Product];
}
if (string.IsNullOrWhiteSpace(module.ProductionMode))
{
module.ProductionMode = string.Equals(module.Type, "buildmodule", StringComparison.Ordinal)
? "commanded"
: "passive";
}
if (module.WorkforceNeeded <= 0f)
{
module.WorkforceNeeded = module.WorkForce?.Max ?? 0f;
}
}
return modules;
}
private static Vector3 Scale(Vector3 vector, float scale) => new(vector.X * scale, vector.Y * scale, vector.Z * scale);
private static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f);
private static Vector3 NormalizeOrFallback(Vector3 vector, Vector3 fallback)
{
var length = MathF.Sqrt(vector.LengthSquared());
if (length <= 0.0001f)
{
return fallback;
}
return vector.Divide(length);
}
}

View File

@@ -1,187 +0,0 @@
using SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
private const float FactionCommanderReplanInterval = 10f;
private const float ShipCommanderReplanInterval = 5f;
private static readonly GoapPlanner<FactionPlanningState> _factionPlanner = new(s => s.Clone());
private static readonly GoapPlanner<ShipPlanningState> _shipPlanner = new(s => s.Clone());
private static readonly IReadOnlyList<GoapGoal<FactionPlanningState>> _factionGoals =
[
new ExpandTerritoryGoal(),
new EnsureWarFleetGoal(),
new EnsureMiningCapacityGoal(),
new EnsureConstructionCapacityGoal(),
];
private static readonly IReadOnlyList<GoapAction<ShipPlanningState>> _shipActions =
[
new SetMiningObjectiveAction(),
new SetPatrolObjectiveAction(),
new SetConstructionObjectiveAction(),
new SetIdleObjectiveAction(),
];
private static readonly GoapGoal<ShipPlanningState> _shipGoal = new AssignObjectiveGoal();
private void UpdateCommanders(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
// Faction commanders run first so their directives are available to ship commanders in the same tick.
foreach (var commander in world.Commanders)
{
if (!commander.IsAlive || commander.Kind != CommanderKind.Faction)
{
continue;
}
TickCommander(commander, deltaSeconds);
UpdateFactionCommander(world, commander);
}
foreach (var commander in world.Commanders)
{
if (!commander.IsAlive || commander.Kind != CommanderKind.Ship)
{
continue;
}
TickCommander(commander, deltaSeconds);
UpdateShipCommander(world, commander);
}
}
private static void TickCommander(CommanderRuntime commander, float deltaSeconds)
{
if (commander.ReplanTimer > 0f)
{
commander.ReplanTimer = MathF.Max(0f, commander.ReplanTimer - deltaSeconds);
}
}
private void UpdateFactionCommander(SimulationWorld world, CommanderRuntime commander)
{
if (commander.ReplanTimer > 0f && !commander.NeedsReplan)
{
return;
}
commander.ReplanTimer = FactionCommanderReplanInterval;
commander.NeedsReplan = false;
var state = BuildFactionPlanningState(world, commander.FactionId);
var actions = BuildFactionActions(world);
// Clear stale directives — actions will re-assert what is still needed.
commander.ActiveDirectives.Clear();
var rankedGoals = _factionGoals
.Select(g => (goal: g, priority: g.ComputePriority(state, world, commander)))
.Where(x => x.priority > 0f)
.OrderByDescending(x => x.priority)
.ToList();
commander.LastPlanningState = state;
commander.LastGoalPriorities = rankedGoals.Select(x => (x.goal.Name, x.priority)).ToList();
// Execute the first action of each active goal's plan (top 3 to avoid conflicts).
foreach (var (goal, _) in rankedGoals.Take(3))
{
var plan = _factionPlanner.Plan(state, goal, actions);
plan?.CurrentAction?.Execute(this, world, commander);
}
}
private void UpdateShipCommander(SimulationWorld world, CommanderRuntime commander)
{
if (commander.ReplanTimer > 0f && !commander.NeedsReplan)
{
return;
}
commander.ReplanTimer = ShipCommanderReplanInterval;
commander.NeedsReplan = false;
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
if (ship is null)
{
return;
}
var state = BuildShipPlanningState(world, ship, commander);
var plan = _shipPlanner.Plan(state, _shipGoal, _shipActions);
if (plan?.CurrentAction is { } action)
{
commander.ActiveGoalName = _shipGoal.Name;
commander.ActiveActionName = action.Name;
action.Execute(this, world, commander);
}
}
internal FactionPlanningState BuildFactionPlanningState(SimulationWorld world, string factionId)
{
var stations = world.Stations.Where(s => s.FactionId == factionId).ToList();
return new FactionPlanningState
{
MilitaryShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "military", StringComparison.Ordinal)),
MinerShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "mining", StringComparison.Ordinal)),
TransportShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "transport", StringComparison.Ordinal)),
ConstructorShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "construction", StringComparison.Ordinal)),
ControlledSystemCount = GetFactionControlledSystemsCount(world, factionId),
TargetSystemCount = Math.Max(1, Math.Min(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")),
};
}
private static ShipPlanningState BuildShipPlanningState(
SimulationWorld world,
ShipRuntime ship,
CommanderRuntime commander)
{
var factionCommander = world.Commanders.FirstOrDefault(c =>
c.FactionId == commander.FactionId &&
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
return new ShipPlanningState
{
ShipKind = ship.Definition.Kind,
HasMiningCapability = HasShipCapabilities(ship.Definition, "mining"),
FactionWantsOre = true,
FactionWantsExpansion = factionCommander?.ActiveDirectives
.Contains("expand-territory", StringComparer.Ordinal) ?? false,
};
}
private static IReadOnlyList<GoapAction<FactionPlanningState>> BuildFactionActions(SimulationWorld world)
{
var actions = new List<GoapAction<FactionPlanningState>>();
foreach (var (shipId, def) in world.ShipDefinitions)
{
actions.Add(new OrderShipProductionAction(def.Kind, shipId));
}
actions.Add(new ExpandToSystemAction());
return actions;
}
internal static bool FactionCommanderHasDirective(SimulationWorld world, string factionId, string directive) =>
world.Commanders.FirstOrDefault(c =>
c.FactionId == factionId &&
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal))
?.ActiveDirectives.Contains(directive, StringComparer.Ordinal) ?? false;
}

View File

@@ -1,332 +0,0 @@
using SpaceGame.Simulation.Api.Contracts;
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
private static void UpdateClaims(SimulationWorld world, ICollection<SimulationEventRecord> events)
{
foreach (var claim in world.Claims)
{
if (claim.State == ClaimStateKinds.Destroyed || claim.Health <= 0f)
{
if (claim.State != ClaimStateKinds.Destroyed)
{
claim.State = ClaimStateKinds.Destroyed;
events.Add(new SimulationEventRecord("claim", claim.Id, "claim-destroyed", $"Claim {claim.Id} was destroyed.", world.GeneratedAtUtc));
}
foreach (var site in world.ConstructionSites.Where(candidate => candidate.ClaimId == claim.Id))
{
site.State = ConstructionSiteStateKinds.Destroyed;
}
continue;
}
if (claim.State == ClaimStateKinds.Activating && world.GeneratedAtUtc >= claim.ActivatesAtUtc)
{
claim.State = ClaimStateKinds.Active;
events.Add(new SimulationEventRecord("claim", claim.Id, "claim-activated", $"Claim {claim.Id} is now active.", world.GeneratedAtUtc));
}
}
}
private static void UpdateConstructionSites(SimulationWorld world, ICollection<SimulationEventRecord> events)
{
foreach (var site in world.ConstructionSites)
{
if (site.State == ConstructionSiteStateKinds.Destroyed)
{
continue;
}
var claim = site.ClaimId is null
? null
: world.Claims.FirstOrDefault(candidate => candidate.Id == site.ClaimId);
if (claim?.State == ClaimStateKinds.Destroyed)
{
site.State = ConstructionSiteStateKinds.Destroyed;
continue;
}
if (claim?.State == ClaimStateKinds.Active && site.State == ConstructionSiteStateKinds.Planned)
{
site.State = ConstructionSiteStateKinds.Active;
events.Add(new SimulationEventRecord("construction-site", site.Id, "site-active", $"Construction site {site.Id} is active.", world.GeneratedAtUtc));
}
foreach (var orderId in site.MarketOrderIds)
{
var order = world.MarketOrders.FirstOrDefault(candidate => candidate.Id == orderId);
if (order is null || !site.RequiredItems.TryGetValue(order.ItemId, out var required))
{
continue;
}
var remaining = MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, order.ItemId));
order.RemainingAmount = remaining;
order.State = remaining <= 0.01f
? MarketOrderStateKinds.Filled
: remaining < order.Amount
? MarketOrderStateKinds.PartiallyFilled
: MarketOrderStateKinds.Open;
}
}
}
private static bool TryEnsureModuleConstructionStarted(StationRuntime station, ModuleRecipeDefinition recipe, string shipId)
{
if (station.ActiveConstruction is not null)
{
return string.Equals(station.ActiveConstruction.ModuleId, recipe.ModuleId, StringComparison.Ordinal)
&& string.Equals(station.ActiveConstruction.AssignedConstructorShipId, shipId, StringComparison.Ordinal);
}
if (!CanStartModuleConstruction(station, recipe))
{
return false;
}
foreach (var input in recipe.Inputs)
{
RemoveInventory(station.Inventory, input.ItemId, input.Amount);
}
station.ActiveConstruction = new ModuleConstructionRuntime
{
ModuleId = recipe.ModuleId,
RequiredSeconds = recipe.Duration,
AssignedConstructorShipId = shipId,
};
return true;
}
private static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world)
{
// Expand storage before it becomes a bottleneck
const float StorageExpansionThreshold = 0.85f;
var storageExpansionCandidates = 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)
{
continue;
}
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))
{
return moduleId;
}
}
var priorities = 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 void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site)
{
var nextModuleId = GetNextStationModuleToBuild(station, world);
foreach (var orderId in site.MarketOrderIds)
{
var order = world.MarketOrders.FirstOrDefault(candidate => candidate.Id == orderId);
if (order is not null)
{
order.State = MarketOrderStateKinds.Cancelled;
order.RemainingAmount = 0f;
world.MarketOrders.Remove(order);
}
station.MarketOrderIds.Remove(orderId);
}
site.MarketOrderIds.Clear();
site.Inventory.Clear();
site.DeliveredItems.Clear();
site.RequiredItems.Clear();
site.AssignedConstructorShipIds.Clear();
site.Progress = 0f;
if (nextModuleId is null || !world.ModuleRecipes.TryGetValue(nextModuleId, out var recipe))
{
site.State = ConstructionSiteStateKinds.Completed;
site.BlueprintId = null;
return;
}
site.BlueprintId = nextModuleId;
site.State = ConstructionSiteStateKinds.Active;
foreach (var input in recipe.Inputs)
{
site.RequiredItems[input.ItemId] = input.Amount;
site.DeliveredItems[input.ItemId] = 0f;
var orderId = $"market-order-{station.Id}-{nextModuleId}-{input.ItemId}";
site.MarketOrderIds.Add(orderId);
station.MarketOrderIds.Add(orderId);
world.MarketOrders.Add(new MarketOrderRuntime
{
Id = orderId,
FactionId = station.FactionId,
StationId = station.Id,
ConstructionSiteId = site.Id,
Kind = MarketOrderKinds.Buy,
ItemId = input.ItemId,
Amount = input.Amount,
RemainingAmount = input.Amount,
Valuation = 1f,
State = MarketOrderStateKinds.Open,
});
}
}
private static int GetDockingPadCount(StationRuntime station) =>
CountModules(station.InstalledModules, "module_arg_dock_m_01_lowtech") * 2;
private static int? ReserveDockingPad(StationRuntime station, string shipId)
{
if (station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal)) is var existing
&& !string.IsNullOrEmpty(existing.Value))
{
return existing.Key;
}
var padCount = GetDockingPadCount(station);
for (var padIndex = 0; padIndex < padCount; padIndex += 1)
{
if (station.DockingPadAssignments.ContainsKey(padIndex))
{
continue;
}
station.DockingPadAssignments[padIndex] = shipId;
return padIndex;
}
return null;
}
private static void ReleaseDockingPad(StationRuntime station, string shipId)
{
var assignment = station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal));
if (!string.IsNullOrEmpty(assignment.Value))
{
station.DockingPadAssignments.Remove(assignment.Key);
}
}
private static Vector3 GetDockingPadPosition(StationRuntime station, int padIndex)
{
var padCount = Math.Max(1, GetDockingPadCount(station));
var angle = ((MathF.PI * 2f) / padCount) * padIndex;
var radius = station.Radius + 18f;
return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y,
station.Position.Z + (MathF.Sin(angle) * radius));
}
private static Vector3 GetDockingHoldPosition(StationRuntime station, string shipId)
{
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
var angle = (hash % 360) * (MathF.PI / 180f);
var radius = station.Radius + 24f;
return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y,
station.Position.Z + (MathF.Sin(angle) * radius));
}
private static Vector3 GetUndockTargetPosition(StationRuntime station, int? padIndex, float distance)
{
if (padIndex is null)
{
return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z);
}
var pad = GetDockingPadPosition(station, padIndex.Value);
var dx = pad.X - station.Position.X;
var dz = pad.Z - station.Position.Z;
var length = MathF.Sqrt((dx * dx) + (dz * dz));
if (length <= 0.001f)
{
return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z);
}
var scale = distance / length;
return new Vector3(
pad.X + (dx * scale),
station.Position.Y,
pad.Z + (dz * scale));
}
private static Vector3 GetShipDockedPosition(ShipRuntime ship, StationRuntime station) =>
ship.AssignedDockingPadIndex is int padIndex
? GetDockingPadPosition(station, padIndex)
: station.Position;
private static Vector3 GetConstructionHoldPosition(StationRuntime station, string shipId)
{
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
var angle = (hash % 360) * (MathF.PI / 180f);
var radius = station.Radius + 78f;
return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y,
station.Position.Z + (MathF.Sin(angle) * radius));
}
private static Vector3 GetResourceHoldPosition(Vector3 nodePosition, string shipId, float radius)
{
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
var angle = (hash % 360) * (MathF.PI / 180f);
return new Vector3(
nodePosition.X + (MathF.Cos(angle) * radius),
nodePosition.Y,
nodePosition.Z + (MathF.Sin(angle) * radius));
}
}

View File

@@ -1,101 +0,0 @@
using SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
private readonly OrbitalSimulationOptions _orbitalSimulation;
private const float WaterConsumptionPerWorkerPerSecond = 0.004f;
private const float PopulationGrowthPerSecond = 0.012f;
private const float PopulationAttritionPerSecond = 0.018f;
private static readonly ShipBehaviorStateMachine _shipBehaviorStateMachine = ShipBehaviorStateMachine.CreateDefault();
private static readonly IReadOnlyList<WorldUpdateStep> _worldUpdatePipeline =
[
new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateOrbitalState(world)),
new((engine, world, deltaSeconds, nowUtc, events) => UpdateClaims(world, events)),
new((engine, world, deltaSeconds, nowUtc, events) => UpdateConstructionSites(world, events)),
new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateCommanders(world, deltaSeconds, events)),
new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateStations(world, deltaSeconds, events)),
];
private static readonly IReadOnlyList<ShipUpdateStep> _shipUpdatePipeline =
[
new((engine, ship, world, deltaSeconds, events) => engine.RefreshControlLayers(ship, world)),
new((engine, ship, world, deltaSeconds, events) => engine.PlanControllerTask(ship, world)),
];
public SimulationEngine(OrbitalSimulationOptions? orbitalSimulation = null)
{
_orbitalSimulation = orbitalSimulation ?? new OrbitalSimulationOptions();
}
public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence)
{
var events = new List<SimulationEventRecord>();
var nowUtc = DateTimeOffset.UtcNow;
world.OrbitalTimeSeconds += deltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond;
foreach (var step in _worldUpdatePipeline)
{
step.Execute(this, world, deltaSeconds, nowUtc, events);
}
foreach (var ship in world.Ships)
{
var previousPosition = ship.Position;
var previousState = ship.State;
var previousBehavior = ship.DefaultBehavior.Kind;
var previousTask = ship.ControllerTask.Kind;
foreach (var step in _shipUpdatePipeline)
{
step.Execute(this, ship, world, deltaSeconds, events);
}
var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds);
AdvanceControlState(ship, world, controllerEvent);
ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds);
TrackHistory(ship, controllerEvent);
EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events);
}
SyncSpatialState(world);
world.GeneratedAtUtc = nowUtc;
return new WorldDelta(
sequence,
world.TickIntervalMs,
world.OrbitalTimeSeconds,
new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond),
world.GeneratedAtUtc,
false,
events,
BuildCelestialDeltas(world),
BuildNodeDeltas(world),
BuildStationDeltas(world),
BuildClaimDeltas(world),
BuildConstructionSiteDeltas(world),
BuildMarketOrderDeltas(world),
BuildPolicyDeltas(world),
BuildShipDeltas(world),
BuildFactionDeltas(world));
}
private delegate void WorldUpdateStepAction(
SimulationEngine engine,
SimulationWorld world,
float deltaSeconds,
DateTimeOffset nowUtc,
List<SimulationEventRecord> events);
private delegate void ShipUpdateStepAction(
SimulationEngine engine,
ShipRuntime ship,
SimulationWorld world,
float deltaSeconds,
List<SimulationEventRecord> events);
private sealed record WorldUpdateStep(WorldUpdateStepAction Execute);
private sealed record ShipUpdateStep(ShipUpdateStepAction Execute);
}

View File

@@ -6,4 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FastEndpoints" Version="6.*" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
namespace SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Api.Stations.Contracts;
public sealed record InventoryEntry(
string ItemId,
@@ -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,
@@ -55,7 +57,13 @@ public sealed record StationDelta(
public sealed record StationActionProgressSnapshot(
string Lane,
string Label,
float Progress);
float Progress,
float TimeRemainingSeconds,
float CycleSeconds,
IReadOnlyList<RecipeEntrySnapshot> Inputs,
IReadOnlyList<RecipeEntrySnapshot> Outputs);
public sealed record RecipeEntrySnapshot(string ItemId, float Amount);
public sealed record StationStorageUsageSnapshot(
string StorageClass,

View File

@@ -0,0 +1,36 @@
namespace SpaceGame.Api.Stations.Runtime;
public sealed class ClaimRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public required string SystemId { get; init; }
public required string CelestialId { get; init; }
public string? CommanderId { get; set; }
public DateTimeOffset PlacedAtUtc { get; init; }
public DateTimeOffset ActivatesAtUtc { get; set; }
public string State { get; set; } = ClaimStateKinds.Placed;
public float Health { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ConstructionSiteRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public required string SystemId { get; init; }
public required string CelestialId { get; init; }
public required string TargetKind { get; init; }
public required string TargetDefinitionId { get; init; }
public string? BlueprintId { get; set; }
public string? ClaimId { get; set; }
public string? StationId { get; set; }
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> RequiredItems { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> DeliveredItems { get; } = new(StringComparer.Ordinal);
public HashSet<string> AssignedConstructorShipIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> MarketOrderIds { get; } = new(StringComparer.Ordinal);
public float Progress { get; set; }
public string State { get; set; } = ConstructionSiteStateKinds.Planned;
public string LastDeltaSignature { get; set; } = string.Empty;
}

View File

@@ -1,4 +1,4 @@
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Stations.Runtime;
public sealed class StationRuntime
{
@@ -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);

View File

@@ -0,0 +1,892 @@
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Stations.Simulation;
internal sealed class InfrastructureSimulationService
{
internal void UpdateClaims(SimulationWorld world, ICollection<SimulationEventRecord> events)
{
foreach (var claim in world.Claims)
{
if (claim.State == ClaimStateKinds.Destroyed || claim.Health <= 0f)
{
if (claim.State != ClaimStateKinds.Destroyed)
{
claim.State = ClaimStateKinds.Destroyed;
events.Add(new SimulationEventRecord("claim", claim.Id, "claim-destroyed", $"Claim {claim.Id} was destroyed.", world.GeneratedAtUtc));
}
foreach (var site in world.ConstructionSites.Where(candidate => candidate.ClaimId == claim.Id))
{
site.State = ConstructionSiteStateKinds.Destroyed;
}
continue;
}
if (claim.State == ClaimStateKinds.Activating && world.GeneratedAtUtc >= claim.ActivatesAtUtc)
{
claim.State = ClaimStateKinds.Active;
events.Add(new SimulationEventRecord("claim", claim.Id, "claim-activated", $"Claim {claim.Id} is now active.", world.GeneratedAtUtc));
}
}
}
internal void UpdateConstructionSites(SimulationWorld world, ICollection<SimulationEventRecord> events)
{
foreach (var site in world.ConstructionSites)
{
if (site.State == ConstructionSiteStateKinds.Destroyed)
{
continue;
}
var claim = site.ClaimId is null
? null
: world.Claims.FirstOrDefault(candidate => candidate.Id == site.ClaimId);
if (claim?.State == ClaimStateKinds.Destroyed)
{
site.State = ConstructionSiteStateKinds.Destroyed;
continue;
}
if (claim?.State == ClaimStateKinds.Active && site.State == ConstructionSiteStateKinds.Planned)
{
site.State = ConstructionSiteStateKinds.Active;
events.Add(new SimulationEventRecord("construction-site", site.Id, "site-active", $"Construction site {site.Id} is active.", world.GeneratedAtUtc));
}
foreach (var orderId in site.MarketOrderIds)
{
var order = world.MarketOrders.FirstOrDefault(candidate => candidate.Id == orderId);
if (order is null || !site.RequiredItems.TryGetValue(order.ItemId, out var required))
{
continue;
}
var remaining = MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, order.ItemId));
order.RemainingAmount = remaining;
order.State = remaining <= 0.01f
? MarketOrderStateKinds.Filled
: remaining < order.Amount
? MarketOrderStateKinds.PartiallyFilled
: MarketOrderStateKinds.Open;
}
}
}
internal static bool TryEnsureModuleConstructionStarted(StationRuntime station, ModuleRecipeDefinition recipe, string shipId)
{
if (station.ActiveConstruction is not null)
{
return string.Equals(station.ActiveConstruction.ModuleId, recipe.ModuleId, StringComparison.Ordinal)
&& string.Equals(station.ActiveConstruction.AssignedConstructorShipId, shipId, StringComparison.Ordinal);
}
if (!CanStartModuleConstruction(station, recipe))
{
return false;
}
foreach (var input in recipe.Inputs)
{
RemoveInventory(station.Inventory, input.ItemId, input.Amount);
}
station.ActiveConstruction = new ModuleConstructionRuntime
{
ModuleId = recipe.ModuleId,
RequiredSeconds = recipe.Duration,
AssignedConstructorShipId = shipId,
};
return true;
}
internal static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world)
{
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"),
})
{
var capacity = GetStationStorageCapacity(station, storageClass);
if (capacity <= 0.01f)
{
continue;
}
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass)
.Sum(entry => entry.Value);
if (used / capacity >= 0.65f)
{
yield return moduleId;
}
}
}
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);
foreach (var orderId in site.MarketOrderIds)
{
var order = world.MarketOrders.FirstOrDefault(candidate => candidate.Id == orderId);
if (order is not null)
{
order.State = MarketOrderStateKinds.Cancelled;
order.RemainingAmount = 0f;
world.MarketOrders.Remove(order);
}
station.MarketOrderIds.Remove(orderId);
}
site.MarketOrderIds.Clear();
site.Inventory.Clear();
site.DeliveredItems.Clear();
site.RequiredItems.Clear();
site.AssignedConstructorShipIds.Clear();
site.Progress = 0f;
if (nextModuleId is null || !world.ModuleRecipes.TryGetValue(nextModuleId, out var recipe))
{
site.State = ConstructionSiteStateKinds.Completed;
site.BlueprintId = null;
return;
}
site.BlueprintId = nextModuleId;
site.State = ConstructionSiteStateKinds.Active;
foreach (var input in recipe.Inputs)
{
site.RequiredItems[input.ItemId] = input.Amount;
site.DeliveredItems[input.ItemId] = 0f;
var orderId = $"market-order-{station.Id}-{nextModuleId}-{input.ItemId}";
site.MarketOrderIds.Add(orderId);
station.MarketOrderIds.Add(orderId);
world.MarketOrders.Add(new MarketOrderRuntime
{
Id = orderId,
FactionId = station.FactionId,
StationId = station.Id,
ConstructionSiteId = site.Id,
Kind = MarketOrderKinds.Buy,
ItemId = input.ItemId,
Amount = input.Amount,
RemainingAmount = input.Amount,
Valuation = 1f,
State = MarketOrderStateKinds.Open,
});
}
}
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;
internal static int? ReserveDockingPad(StationRuntime station, string shipId)
{
if (station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal)) is var existing
&& !string.IsNullOrEmpty(existing.Value))
{
return existing.Key;
}
var padCount = GetDockingPadCount(station);
for (var padIndex = 0; padIndex < padCount; padIndex += 1)
{
if (station.DockingPadAssignments.ContainsKey(padIndex))
{
continue;
}
station.DockingPadAssignments[padIndex] = shipId;
return padIndex;
}
return null;
}
internal static void ReleaseDockingPad(StationRuntime station, string shipId)
{
var assignment = station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal));
if (!string.IsNullOrEmpty(assignment.Value))
{
station.DockingPadAssignments.Remove(assignment.Key);
}
}
internal static Vector3 GetDockingPadPosition(StationRuntime station, int padIndex)
{
var padCount = Math.Max(1, GetDockingPadCount(station));
var angle = ((MathF.PI * 2f) / padCount) * padIndex;
var radius = station.Radius + 18f;
return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y,
station.Position.Z + (MathF.Sin(angle) * radius));
}
internal static Vector3 GetDockingHoldPosition(StationRuntime station, string shipId)
{
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
var angle = (hash % 360) * (MathF.PI / 180f);
var radius = station.Radius + 24f;
return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y,
station.Position.Z + (MathF.Sin(angle) * radius));
}
internal static Vector3 GetUndockTargetPosition(StationRuntime station, int? padIndex, float distance)
{
if (padIndex is null)
{
return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z);
}
var pad = GetDockingPadPosition(station, padIndex.Value);
var dx = pad.X - station.Position.X;
var dz = pad.Z - station.Position.Z;
var length = MathF.Sqrt((dx * dx) + (dz * dz));
if (length <= 0.001f)
{
return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z);
}
var scale = distance / length;
return new Vector3(
pad.X + (dx * scale),
station.Position.Y,
pad.Z + (dz * scale));
}
internal static Vector3 GetShipDockedPosition(ShipRuntime ship, StationRuntime station) =>
ship.AssignedDockingPadIndex is int padIndex
? GetDockingPadPosition(station, padIndex)
: station.Position;
internal static Vector3 GetConstructionHoldPosition(StationRuntime station, string shipId)
{
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
var angle = (hash % 360) * (MathF.PI / 180f);
var radius = station.Radius + 78f;
return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y,
station.Position.Z + (MathF.Sin(angle) * radius));
}
internal static Vector3 GetResourceHoldPosition(Vector3 nodePosition, string shipId, float radius)
{
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
var angle = (hash % 360) * (MathF.PI / 180f);
return new Vector3(
nodePosition.X + (MathF.Cos(angle) * radius),
nodePosition.Y,
nodePosition.Z + (MathF.Sin(angle) * radius));
}
}

View File

@@ -1,20 +1,28 @@
using SpaceGame.Simulation.Api.Data;
using SpaceGame.Simulation.Api.Contracts;
using static SpaceGame.Api.Ships.Simulation.ShipControlService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Stations.Simulation;
public sealed partial class SimulationEngine
internal sealed class StationLifecycleService
{
private const int StrategicControlTargetSystems = 5;
private const float WaterConsumptionPerWorkerPerSecond = 0.004f;
private const float PopulationGrowthPerSecond = 0.012f;
private const float PopulationAttritionPerSecond = 0.018f;
private readonly StationSimulationService _stationSimulation;
private void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
internal StationLifecycleService(StationSimulationService stationSimulation)
{
_stationSimulation = stationSimulation;
}
internal void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
var factionPopulation = new Dictionary<string, float>(StringComparer.Ordinal);
foreach (var station in world.Stations)
{
UpdateStationPopulation(station, deltaSeconds, events);
ReviewStationMarketOrders(world, station);
RunStationProduction(world, station, deltaSeconds, events);
_stationSimulation.ReviewStationMarketOrders(world, station);
_stationSimulation.RunStationProduction(world, station, deltaSeconds, events);
factionPopulation[station.FactionId] = GetInventoryAmount(factionPopulation, station.FactionId) + station.Population;
}
@@ -54,7 +62,7 @@ public sealed partial class SimulationEngine
station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired);
}
private float CompleteShipRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, ICollection<SimulationEventRecord> events)
internal static float CompleteShipRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, ICollection<SimulationEventRecord> events)
{
if (recipe.ShipOutputId is null || !world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var definition))
{
@@ -77,6 +85,7 @@ public sealed partial class SimulationEngine
};
world.Ships.Add(ship);
EnsureSpawnedShipCommander(world, station, ship);
if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction)
{
faction.ShipsBuilt += 1;
@@ -116,4 +125,86 @@ public sealed partial class SimulationEngine
],
};
}
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);
}
}

View File

@@ -1,11 +1,13 @@
using SpaceGame.Simulation.Api.Data;
using SpaceGame.Simulation.Api.Contracts;
using static SpaceGame.Api.Factions.AI.CommanderPlanningService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Stations.Simulation;
public sealed partial class SimulationEngine
internal sealed class StationSimulationService
{
private void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station)
internal const int StrategicControlTargetSystems = 5;
internal void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station)
{
if (station.CommanderId is null)
{
@@ -13,28 +15,59 @@ public sealed partial class SimulationEngine
}
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);
}
private void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
internal void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId);
foreach (var laneKey in GetStationProductionLanes(world, station))
@@ -60,7 +93,7 @@ public sealed partial class SimulationEngine
if (recipe.ShipOutputId is not null)
{
produced += CompleteShipRecipe(world, station, recipe, events);
produced += StationLifecycleService.CompleteShipRecipe(world, station, recipe, events);
continue;
}
@@ -83,7 +116,7 @@ public sealed partial class SimulationEngine
}
}
private static IEnumerable<string> GetStationProductionLanes(SimulationWorld world, StationRuntime station)
internal static IEnumerable<string> GetStationProductionLanes(SimulationWorld world, StationRuntime station)
{
foreach (var moduleId in station.InstalledModules.Distinct(StringComparer.Ordinal))
{
@@ -101,20 +134,20 @@ public sealed partial class SimulationEngine
}
}
private static float GetStationProductionTimer(StationRuntime station, string laneKey) =>
internal static float GetStationProductionTimer(StationRuntime station, string laneKey) =>
station.ProductionLaneTimers.TryGetValue(laneKey, out var timer) ? timer : 0f;
private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station, string laneKey) =>
internal static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station, string laneKey) =>
world.Recipes.Values
.Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(world, recipe), laneKey, StringComparison.Ordinal))
.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)
@@ -131,14 +164,41 @@ public sealed partial class SimulationEngine
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
var fleetPressure = FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships") ? 1f : 0f;
priority += recipe.Id switch
priority += GetStationRecipePriorityAdjustment(station, recipe, expansionPressure, fleetPressure);
return priority;
}
private static float GetStationRecipePriorityAdjustment(StationRuntime station, RecipeDefinition recipe, float expansionPressure, float fleetPressure)
{
var outputItemIds = recipe.Outputs
.Select(output => output.ItemId)
.ToHashSet(StringComparer.Ordinal);
if (outputItemIds.Contains("hullparts"))
{
"ship-parts-integration" => HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
return HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
? -140f * MathF.Max(expansionPressure, fleetPressure)
: 280f * MathF.Max(expansionPressure, fleetPressure),
"hull-fabrication" => 180f * expansionPressure,
"equipment-assembly" => 170f * expansionPressure,
"gun-assembly" => 160f * expansionPressure,
: 280f * MathF.Max(expansionPressure, fleetPressure);
}
if (outputItemIds.Contains("refinedmetals"))
{
return 180f * expansionPressure;
}
if (outputItemIds.Overlaps(["advancedelectronics", "dronecomponents", "engineparts", "fieldcoils", "missilecomponents", "shieldcomponents", "smartchips"]))
{
return 170f * expansionPressure;
}
if (outputItemIds.Overlaps(["turretcomponents", "weaponcomponents"]))
{
return 160f * expansionPressure;
}
return recipe.Id switch
{
"command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly"
=> 220f * MathF.Max(expansionPressure, fleetPressure),
"frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure),
@@ -149,11 +209,9 @@ public sealed partial class SimulationEngine
=> -120f * expansionPressure,
_ => 0f,
};
return priority;
}
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)
@@ -213,6 +271,71 @@ public sealed partial class SimulationEngine
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);
@@ -240,7 +363,9 @@ public sealed partial class SimulationEngine
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)
@@ -290,7 +415,7 @@ public sealed partial class SimulationEngine
}
}
private static float GetFactionExpansionPressure(SimulationWorld world, string factionId)
internal static float GetFactionExpansionPressure(SimulationWorld world, string factionId)
{
var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count));
var controlledSystems = GetFactionControlledSystemsCount(world, factionId);
@@ -298,11 +423,55 @@ public sealed partial class SimulationEngine
return Math.Clamp(deficit / (float)targetSystems, 0f, 1f);
}
private static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId)
internal static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId)
{
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 =>

View File

@@ -0,0 +1,15 @@
using FastEndpoints;
namespace SpaceGame.Api.Universe.Api;
public sealed class GetWorldHandler(WorldService worldService) : EndpointWithoutRequest
{
public override void Configure()
{
Get("/api/world");
AllowAnonymous();
}
public override Task HandleAsync(CancellationToken cancellationToken) =>
SendOkAsync(worldService.GetSnapshot(), cancellationToken);
}

View File

@@ -0,0 +1,23 @@
using FastEndpoints;
namespace SpaceGame.Api.Universe.Api;
public sealed class GetWorldHealthHandler(WorldService worldService) : EndpointWithoutRequest
{
public override void Configure()
{
Get("/api/world/health");
AllowAnonymous();
}
public override Task HandleAsync(CancellationToken cancellationToken)
{
var status = worldService.GetStatus();
return SendOkAsync(new
{
ok = true,
sequence = status.Sequence,
generatedAtUtc = status.GeneratedAtUtc,
}, cancellationToken);
}
}

View File

@@ -0,0 +1,15 @@
using FastEndpoints;
namespace SpaceGame.Api.Universe.Api;
public sealed class ResetWorldHandler(WorldService worldService) : EndpointWithoutRequest
{
public override void Configure()
{
Post("/api/world/reset");
AllowAnonymous();
}
public override Task HandleAsync(CancellationToken cancellationToken) =>
SendOkAsync(worldService.Reset(), cancellationToken);
}

View File

@@ -0,0 +1,18 @@
using FastEndpoints;
namespace SpaceGame.Api.Universe.Api;
public sealed class RootRedirectHandler : EndpointWithoutRequest
{
public override void Configure()
{
Get("/");
AllowAnonymous();
}
public override Task HandleAsync(CancellationToken cancellationToken)
{
HttpContext.Response.Redirect("/api/world");
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,53 @@
using System.Text.Json;
using FastEndpoints;
namespace SpaceGame.Api.Universe.Api;
public sealed class StreamWorldHandler(WorldService worldService) : EndpointWithoutRequest
{
private static readonly JsonSerializerOptions SseJsonOptions = new(JsonSerializerDefaults.Web);
public override void Configure()
{
Get("/api/world/stream");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken cancellationToken)
{
HttpContext.Response.Headers.Append("Cache-Control", "no-cache");
HttpContext.Response.Headers.Append("Content-Type", "text/event-stream");
var afterSequenceRaw = HttpContext.Request.Query["afterSequence"].ToString();
_ = long.TryParse(afterSequenceRaw, out var afterSequence);
var scopeKind = HttpContext.Request.Query["scopeKind"].ToString();
if (string.IsNullOrWhiteSpace(scopeKind))
{
scopeKind = HttpContext.Request.Query["scope"].ToString();
}
if (string.IsNullOrWhiteSpace(scopeKind))
{
scopeKind = "universe";
}
var systemId = HttpContext.Request.Query["systemId"].ToString();
var bubbleId = HttpContext.Request.Query["bubbleId"].ToString();
var scope = new ObserverScope(
scopeKind,
string.IsNullOrWhiteSpace(systemId) ? null : systemId,
string.IsNullOrWhiteSpace(bubbleId) ? null : bubbleId);
var stream = worldService.Subscribe(scope, afterSequence, cancellationToken);
await HttpContext.Response.WriteAsync(": connected\n\n", cancellationToken);
await HttpContext.Response.Body.FlushAsync(cancellationToken);
await foreach (var delta in stream.ReadAllAsync(cancellationToken))
{
var payload = JsonSerializer.Serialize(delta, SseJsonOptions);
await HttpContext.Response.WriteAsync($"event: world-delta\ndata: {payload}\n\n", cancellationToken);
await HttpContext.Response.Body.FlushAsync(cancellationToken);
}
}
}

View File

@@ -1,4 +1,4 @@
namespace SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Api.Universe.Contracts;
public sealed record StarSnapshot(
string Kind,

View File

@@ -1,4 +1,4 @@
namespace SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Api.Universe.Contracts;
public sealed record WorldSnapshot(
string Label,

View File

@@ -1,6 +1,5 @@
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Universe.Runtime;
public sealed class SimulationWorld
{
@@ -23,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; }

View File

@@ -1,6 +1,5 @@
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Universe.Runtime;
public sealed class SystemRuntime
{

View File

@@ -0,0 +1,305 @@
using System.Text.Json;
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario;
internal sealed class DataCatalogLoader(string dataRoot)
{
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
internal ScenarioCatalog LoadCatalog()
{
var authoredSystems = Read<List<SolarSystemDefinition>>("systems.json");
var scenario = Read<ScenarioDefinition>("scenario.json");
var modules = NormalizeModules(Read<List<ModuleDefinition>>("modules.json"));
var ships = Read<List<ShipDefinition>>("ships.json");
var items = NormalizeItems(Read<List<ItemDefinition>>("items.json"));
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,
scenario,
balance,
modules.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
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),
productionGraph);
}
internal ScenarioDefinition NormalizeScenarioToAvailableSystems(
ScenarioDefinition scenario,
IReadOnlyList<string> availableSystemIds)
{
if (availableSystemIds.Count == 0)
{
return scenario;
}
var fallbackSystemId = availableSystemIds.Contains("sol", StringComparer.Ordinal)
? "sol"
: availableSystemIds[0];
string ResolveSystemId(string systemId) =>
availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId;
return new ScenarioDefinition
{
InitialStations = scenario.InitialStations
.Select(station => new InitialStationDefinition
{
SystemId = ResolveSystemId(station.SystemId),
Label = station.Label,
Color = station.Color,
Objective = station.Objective,
StartingModules = station.StartingModules.ToList(),
FactionId = station.FactionId,
PlanetIndex = station.PlanetIndex,
LagrangeSide = station.LagrangeSide,
Position = station.Position?.ToArray(),
})
.ToList(),
ShipFormations = scenario.ShipFormations
.Select(formation => new ShipFormationDefinition
{
ShipId = formation.ShipId,
Count = formation.Count,
Center = formation.Center.ToArray(),
SystemId = ResolveSystemId(formation.SystemId),
FactionId = formation.FactionId,
StartingInventory = new Dictionary<string, float>(formation.StartingInventory, StringComparer.Ordinal),
})
.ToList(),
PatrolRoutes = scenario.PatrolRoutes
.Select(route => new PatrolRouteDefinition
{
SystemId = ResolveSystemId(route.SystemId),
Points = route.Points.Select(point => point.ToArray()).ToList(),
})
.ToList(),
MiningDefaults = new MiningDefaultsDefinition
{
NodeSystemId = ResolveSystemId(scenario.MiningDefaults.NodeSystemId),
RefinerySystemId = ResolveSystemId(scenario.MiningDefaults.RefinerySystemId),
},
};
}
private T Read<T>(string fileName)
{
var path = Path.Combine(dataRoot, fileName);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, _jsonOptions)
?? throw new InvalidOperationException($"Unable to read {fileName}.");
}
private static List<ModuleRecipeDefinition> BuildModuleRecipes(IEnumerable<ModuleDefinition> modules) =>
modules
.Where(module => module.Construction is not null || module.Production.Count > 0)
.Select(module => new ModuleRecipeDefinition
{
ModuleId = module.Id,
Duration = module.Construction?.ProductionTime ?? module.Production[0].Time,
Inputs = (module.Construction?.Requirements ?? module.Production[0].Wares)
.Select(input => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
})
.ToList();
private static List<RecipeDefinition> BuildRecipes(IEnumerable<ItemDefinition> items, IEnumerable<ShipDefinition> ships, IReadOnlyCollection<ModuleDefinition> modules)
{
var recipes = new List<RecipeDefinition>();
var preferredProducerByItemId = modules
.Where(module => module.Products.Count > 0)
.GroupBy(module => module.Products[0], StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group.OrderBy(module => module.Id, StringComparer.Ordinal).First().Id,
StringComparer.Ordinal);
foreach (var item in items)
{
if (item.Production.Count > 0)
{
foreach (var production in item.Production)
{
recipes.Add(new RecipeDefinition
{
Id = $"{item.Id}-{production.Method}-production",
Label = production.Name == "Universal" ? item.Name : $"{item.Name} ({production.Name})",
FacilityCategory = InferFacilityCategory(item),
Duration = production.Time,
Priority = InferRecipePriority(item),
RequiredModules = InferRequiredModules(item, preferredProducerByItemId),
Inputs = production.Wares
.Select(input => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
Outputs =
[
new RecipeOutputDefinition
{
ItemId = item.Id,
Amount = production.Amount,
},
],
});
}
continue;
}
if (item.Construction is null)
{
continue;
}
recipes.Add(new RecipeDefinition
{
Id = item.Construction.RecipeId ?? $"{item.Id}-production",
Label = item.Name,
FacilityCategory = item.Construction.FacilityCategory,
Duration = item.Construction.CycleTime,
Priority = item.Construction.Priority,
RequiredModules = item.Construction.RequiredModules.ToList(),
Inputs = item.Construction.Requirements
.Select(input => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
Outputs =
[
new RecipeOutputDefinition
{
ItemId = item.Id,
Amount = item.Construction.BatchSize,
},
],
});
}
foreach (var ship in ships)
{
if (ship.Construction is null)
{
continue;
}
recipes.Add(new RecipeDefinition
{
Id = ship.Construction.RecipeId ?? $"{ship.Id}-construction",
Label = $"{ship.Label} Construction",
FacilityCategory = ship.Construction.FacilityCategory,
Duration = ship.Construction.CycleTime,
Priority = ship.Construction.Priority,
RequiredModules = ship.Construction.RequiredModules.ToList(),
Inputs = ship.Construction.Requirements
.Select(input => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
ShipOutputId = ship.Id,
});
}
return recipes;
}
private static string InferFacilityCategory(ItemDefinition item) =>
item.Group switch
{
"agricultural" or "food" or "pharmaceutical" or "water" => "farm",
_ => "station",
};
private static List<string> InferRequiredModules(ItemDefinition item, IReadOnlyDictionary<string, string> preferredProducerByItemId)
{
if (preferredProducerByItemId.TryGetValue(item.Id, out var moduleId))
{
return [moduleId];
}
return [];
}
private static int InferRecipePriority(ItemDefinition item) =>
item.Group switch
{
"energy" => 140,
"water" => 130,
"food" => 120,
"agricultural" => 110,
"refined" => 100,
"hightech" => 90,
"shiptech" => 80,
"pharmaceutical" => 70,
_ => 60,
};
private static List<ItemDefinition> NormalizeItems(List<ItemDefinition> items)
{
foreach (var item in items)
{
if (string.IsNullOrWhiteSpace(item.Type))
{
item.Type = string.IsNullOrWhiteSpace(item.Group) ? "material" : item.Group;
}
}
return items;
}
private static List<ModuleDefinition> NormalizeModules(List<ModuleDefinition> modules)
{
foreach (var module in modules)
{
if (module.Products.Count == 0 && !string.IsNullOrWhiteSpace(module.Product))
{
module.Products = [module.Product];
}
if (string.IsNullOrWhiteSpace(module.ProductionMode))
{
module.ProductionMode = string.Equals(module.Type, "buildmodule", StringComparison.Ordinal)
? "commanded"
: "passive";
}
if (module.WorkforceNeeded <= 0f)
{
module.WorkforceNeeded = module.WorkForce?.Max ?? 0f;
}
}
return modules;
}
}
internal sealed record ScenarioCatalog(
List<SolarSystemDefinition> AuthoredSystems,
ScenarioDefinition Scenario,
BalanceDefinition Balance,
IReadOnlyDictionary<string, ModuleDefinition> ModuleDefinitions,
IReadOnlyDictionary<string, ShipDefinition> ShipDefinitions,
IReadOnlyDictionary<string, ItemDefinition> ItemDefinitions,
IReadOnlyDictionary<string, RecipeDefinition> Recipes,
IReadOnlyDictionary<string, ModuleRecipeDefinition> ModuleRecipes,
ProductionGraph ProductionGraph);

View File

@@ -0,0 +1,171 @@
namespace SpaceGame.Api.Universe.Scenario;
internal static class LoaderSupport
{
internal const string DefaultFactionId = "sol-dominion";
internal const int WorldSeed = 1;
internal const float MinimumFactionCredits = 0f;
internal const float MinimumRefineryOre = 0f;
internal const float MinimumRefineryStock = 0f;
internal const float MinimumShipyardStock = 0f;
internal const float MinimumSystemSeparation = 3.2f;
internal const float LocalSpaceRadius = 10_000f;
internal static readonly string[] GeneratedSystemNames =
[
"Aquila Verge",
"Orion Fold",
"Draco Span",
"Lyra Shoal",
"Cygnus March",
"Vela Crossing",
"Carina Wake",
"Phoenix Rest",
"Hydra Loom",
"Cassio Reach",
"Lupus Chain",
"Pavo Line",
"Serpens Rise",
"Cetus Hollow",
"Delphin Crown",
"Volans Drift",
"Ara Bastion",
"Indus Veil",
"Pyxis Trace",
"Lacerta Bloom",
"Columba Shroud",
"Dorado Expanse",
"Reticulum Run",
"Norma Edge",
"Crux Horizon",
"Sagitta Corridor",
"Monoceros Deep",
"Eridan Spur",
"Centauri Shelf",
"Antlia Reach",
"Horologium Gate",
"Telescopium Strand",
];
internal static readonly StarProfile[] StarProfiles =
[
new("main-sequence", "#ffd27a", "#ffb14a", 696340f),
new("blue-white", "#9dc6ff", "#66a0ff", 930000f),
new("white-dwarf", "#f1f5ff", "#b8caff", 12000f),
new("brown-dwarf", "#b97d56", "#8a5438", 70000f),
new("neutron-star", "#d9ebff", "#7ab4ff", 18f),
new("binary-main-sequence", "#ffe09f", "#ffbe6b", 780000f),
new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 14000f),
];
internal static readonly PlanetProfile[] PlanetProfiles =
[
new("barren", "sphere", "#bca48f", 2800f, 0.22f, 0, false),
new("terrestrial", "sphere", "#58a36c", 6400f, 0.28f, 1, false),
new("oceanic", "sphere", "#4f84c4", 7000f, 0.30f, 2, false),
new("desert", "sphere", "#d4a373", 5200f, 0.26f, 0, false),
new("ice", "sphere", "#c8e4ff", 5800f, 0.32f, 1, false),
new("gas-giant", "oblate", "#d9b06f", 45000f, 1.40f, 8, true),
new("ice-giant", "oblate", "#8fc0d8", 25000f, 1.00f, 5, true),
new("lava", "sphere", "#db6846", 3200f, 0.20f, 0, false),
];
internal static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
internal static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values)
{
var raw = ToVector(values);
var relativeToSystem = new Vector3(
raw.X - system.Position.X,
raw.Y - system.Position.Y,
raw.Z - system.Position.Z);
return relativeToSystem.LengthSquared() < raw.LengthSquared()
? relativeToSystem
: raw;
}
internal static bool HasInstalledModules(StationRuntime station, params string[] modules) =>
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
internal static bool HasCapabilities(ShipDefinition definition, params string[] capabilities) =>
capabilities.All(capability => definition.Capabilities.Contains(capability, StringComparer.Ordinal));
internal static void AddStationModule(StationRuntime station, IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, string moduleId)
{
if (!moduleDefinitions.TryGetValue(moduleId, out var definition))
{
return;
}
station.Modules.Add(new StationModuleRuntime
{
Id = $"{station.Id}-module-{station.Modules.Count + 1}",
ModuleId = moduleId,
Health = definition.Hull,
MaxHealth = definition.Hull,
});
station.Radius = GetStationRadius(moduleDefinitions, station);
}
internal static float GetStationRadius(IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, StationRuntime station)
{
var totalArea = station.Modules
.Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f)
.Sum();
return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f)));
}
internal static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
internal static Vector3 Scale(Vector3 vector, float scale) => new(vector.X * scale, vector.Y * scale, vector.Z * scale);
internal static int CountModules(IEnumerable<string> modules, string moduleId) =>
modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal));
internal static float ComputeWorkforceRatio(float population, float workforceRequired)
{
if (workforceRequired <= 0.01f)
{
return 1f;
}
var staffedRatio = MathF.Min(1f, population / workforceRequired);
return 0.1f + (0.9f * staffedRatio);
}
internal static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
internal static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f);
internal static Vector3 NormalizeOrFallback(Vector3 vector, Vector3 fallback)
{
var length = MathF.Sqrt(vector.LengthSquared());
if (length <= 0.0001f)
{
return fallback;
}
return vector.Divide(length);
}
}
internal sealed record StarProfile(
string Kind,
string StarColor,
string StarGlow,
float BaseSize);
internal sealed record PlanetProfile(
string Type,
string Shape,
string Color,
float BaseSize,
float OrbitGapMin,
int BaseMoonCount,
bool CanHaveRing)
{
public float OrbitGapMax => OrbitGapMin + MathF.Max(0.12f, OrbitGapMin * 0.45f);
}

View File

@@ -0,0 +1,26 @@
namespace SpaceGame.Api.Universe.Scenario;
public sealed class ScenarioLoader
{
private readonly WorldBuilder _worldBuilder;
public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null)
{
var generationOptions = worldGeneration ?? new WorldGenerationOptions();
var dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data"));
var dataLoader = new DataCatalogLoader(dataRoot);
var generationService = new SystemGenerationService();
var spatialBuilder = new SpatialBuilder();
var seedingService = new WorldSeedingService();
_worldBuilder = new WorldBuilder(
generationOptions,
dataLoader,
generationService,
spatialBuilder,
seedingService);
}
public SimulationWorld Load() => _worldBuilder.Build();
}

View File

@@ -1,9 +1,45 @@
using SpaceGame.Simulation.Api.Data;
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Universe.Scenario;
public sealed partial class ScenarioLoader
internal sealed class SpatialBuilder
{
internal ScenarioSpatialLayout BuildLayout(IReadOnlyList<SystemRuntime> systems, BalanceDefinition balance)
{
var systemGraphs = systems.ToDictionary(
system => system.Definition.Id,
BuildSystemSpatialGraph,
StringComparer.Ordinal);
var celestials = systemGraphs.Values.SelectMany(graph => graph.Celestials).ToList();
var nodes = new List<ResourceNodeRuntime>();
var nodeIdCounter = 0;
foreach (var system in systems)
{
var systemGraph = systemGraphs[system.Definition.Id];
foreach (var node in system.Definition.ResourceNodes)
{
var anchorCelestial = ResolveResourceNodeAnchor(systemGraph, node);
nodes.Add(new ResourceNodeRuntime
{
Id = $"node-{++nodeIdCounter}",
SystemId = system.Definition.Id,
Position = ComputeResourceNodePosition(anchorCelestial, node, balance.YPlane),
SourceKind = node.SourceKind,
ItemId = node.ItemId,
CelestialId = anchorCelestial?.Id,
OrbitRadius = node.RadiusOffset,
OrbitPhase = node.Angle,
OrbitInclination = DegreesToRadians(node.InclinationDegrees),
OreRemaining = node.OreAmount,
MaxOre = node.OreAmount,
});
}
}
return new ScenarioSpatialLayout(systemGraphs, celestials, nodes);
}
private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system)
{
var celestials = new List<CelestialRuntime>();
@@ -96,9 +132,7 @@ public sealed partial class ScenarioLoader
return celestial;
}
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(
Vector3 planetPosition,
PlanetDefinition planet)
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, PlanetDefinition planet)
{
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
var tangential = new Vector3(-radial.Z, 0f, radial.X);
@@ -129,7 +163,6 @@ public sealed partial class ScenarioLoader
return MathF.Max(minimumOffset, hillLikeOffset);
}
// The simulation does not track physical masses yet, so use a size/density proxy.
private static float EstimatePlanetMassRatio(PlanetDefinition planet)
{
var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f);
@@ -146,7 +179,7 @@ public sealed partial class ScenarioLoader
return earthMasses / 332_946f;
}
private static StationPlacement ResolveStationPlacement(
internal static StationPlacement ResolveStationPlacement(
InitialStationDefinition plan,
SystemRuntime system,
SystemSpatialGraph graph,
@@ -166,19 +199,19 @@ public sealed partial class ScenarioLoader
{
var targetPosition = NormalizeScenarioPoint(system, plan.Position);
var preferredCelestial = existingCelestials
.Where((c) => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint)
.OrderBy((c) => c.Position.DistanceTo(targetPosition))
.Where(c => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint)
.OrderBy(c => c.Position.DistanceTo(targetPosition))
.FirstOrDefault()
?? existingCelestials
.Where((c) => c.SystemId == system.Definition.Id)
.OrderBy((c) => c.Position.DistanceTo(targetPosition))
.Where(c => c.SystemId == system.Definition.Id)
.OrderBy(c => c.Position.DistanceTo(targetPosition))
.First();
return new StationPlacement(preferredCelestial, preferredCelestial.Position);
}
var fallbackCelestial = graph.Celestials
.FirstOrDefault((c) => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId))
?? graph.Celestials.First((c) => c.Kind == SpatialNodeKind.Planet);
.FirstOrDefault(c => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId))
?? graph.Celestials.First(c => c.Kind == SpatialNodeKind.Planet);
return new StationPlacement(fallbackCelestial, fallbackCelestial.Position);
}
@@ -199,11 +232,11 @@ public sealed partial class ScenarioLoader
if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0)
{
var moonNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}-moon-{moonIndex + 1}";
return graph.Celestials.FirstOrDefault((c) => c.Id == moonNodeId);
return graph.Celestials.FirstOrDefault(c => c.Id == moonNodeId);
}
var planetNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}";
return graph.Celestials.FirstOrDefault((c) => c.Id == planetNodeId);
return graph.Celestials.FirstOrDefault(c => c.Id == planetNodeId);
}
private static Vector3 ComputeResourceNodePosition(CelestialRuntime? anchorCelestial, ResourceNodeDefinition definition, float yPlane)
@@ -226,9 +259,7 @@ public sealed partial class ScenarioLoader
{
var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch);
var orbitRadiusKm = SimulationUnits.AuToKilometers(planet.OrbitRadius);
var x = MathF.Cos(angle) * orbitRadiusKm;
var z = MathF.Sin(angle) * orbitRadiusKm;
return new Vector3(x, 0f, z);
return new Vector3(MathF.Cos(angle) * orbitRadiusKm, 0f, MathF.Sin(angle) * orbitRadiusKm);
}
private static Vector3 ComputeMoonPosition(Vector3 planetPosition, MoonDefinition moon)
@@ -238,11 +269,11 @@ public sealed partial class ScenarioLoader
return Add(planetPosition, local);
}
private static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<CelestialRuntime> celestials)
internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<CelestialRuntime> celestials)
{
var nearestCelestial = celestials
.Where((c) => c.SystemId == systemId)
.OrderBy((c) => c.Position.DistanceTo(position))
.Where(c => c.SystemId == systemId)
.OrderBy(c => c.Position.DistanceTo(position))
.FirstOrDefault();
return new ShipSpatialStateRuntime
@@ -255,13 +286,18 @@ public sealed partial class ScenarioLoader
MovementRegime = MovementRegimeKinds.LocalFlight,
};
}
private sealed record SystemSpatialGraph(
string SystemId,
List<CelestialRuntime> Celestials,
Dictionary<int, Dictionary<string, CelestialRuntime>> LagrangeNodesByPlanetIndex);
private sealed record LagrangePointPlacement(string Designation, Vector3 Position);
private sealed record StationPlacement(CelestialRuntime AnchorCelestial, Vector3 Position);
}
internal sealed record ScenarioSpatialLayout(
IReadOnlyDictionary<string, SystemSpatialGraph> SystemGraphs,
List<CelestialRuntime> Celestials,
List<ResourceNodeRuntime> Nodes);
internal sealed record SystemSpatialGraph(
string SystemId,
List<CelestialRuntime> Celestials,
Dictionary<int, Dictionary<string, CelestialRuntime>> LagrangeNodesByPlanetIndex);
internal sealed record LagrangePointPlacement(string Designation, Vector3 Position);
internal sealed record StationPlacement(CelestialRuntime AnchorCelestial, Vector3 Position);

View File

@@ -1,21 +1,18 @@
using SpaceGame.Simulation.Api.Data;
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Universe.Scenario;
public sealed partial class ScenarioLoader
internal sealed class SystemGenerationService
{
private const string SolSystemId = "sol";
private const string DevelopmentCompanionSystemId = "helios";
private static List<SolarSystemDefinition> InjectSpecialSystems(
IReadOnlyList<SolarSystemDefinition> authoredSystems)
{
return authoredSystems
internal List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems) =>
authoredSystems
.Select(CloneSystemDefinition)
.ToList();
}
private static List<SolarSystemDefinition> ExpandSystems(
internal List<SolarSystemDefinition> ExpandSystems(
IReadOnlyList<SolarSystemDefinition> authoredSystems,
int targetSystemCount)
{
@@ -39,10 +36,10 @@ public sealed partial class ScenarioLoader
}
var existingIds = systems
.Select((system) => system.Id)
.Select(system => system.Id)
.ToHashSet(StringComparer.Ordinal);
var generatedPositions = BuildGalaxyPositions(
authoredSystems.Select((system) => ToVector(system.Position)).ToList(),
authoredSystems.Select(system => ToVector(system.Position)).ToList(),
targetSystemCount - systems.Count);
for (var index = systems.Count; index < targetSystemCount; index += 1)
@@ -61,16 +58,14 @@ public sealed partial class ScenarioLoader
return systems;
}
private static List<SolarSystemDefinition> TrimSystemsToTarget(
IReadOnlyList<SolarSystemDefinition> systems,
int targetSystemCount)
private static List<SolarSystemDefinition> TrimSystemsToTarget(IReadOnlyList<SolarSystemDefinition> systems, int targetSystemCount)
{
var selected = new List<SolarSystemDefinition>(targetSystemCount);
void AddById(string systemId)
{
var system = systems.FirstOrDefault((candidate) => string.Equals(candidate.Id, systemId, StringComparison.Ordinal));
if (system is not null && selected.All((candidate) => !string.Equals(candidate.Id, systemId, StringComparison.Ordinal)))
var system = systems.FirstOrDefault(candidate => string.Equals(candidate.Id, systemId, StringComparison.Ordinal));
if (system is not null && selected.All(candidate => !string.Equals(candidate.Id, systemId, StringComparison.Ordinal)))
{
selected.Add(system);
}
@@ -86,7 +81,7 @@ public sealed partial class ScenarioLoader
break;
}
if (selected.Any((candidate) => string.Equals(candidate.Id, system.Id, StringComparison.Ordinal)))
if (selected.Any(candidate => string.Equals(candidate.Id, system.Id, StringComparison.Ordinal)))
{
continue;
}
@@ -127,9 +122,8 @@ public sealed partial class ScenarioLoader
{
var starProfile = SelectStarProfile(generatedIndex);
var planets = BuildGeneratedPlanets(template, generatedIndex);
var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex)
.Select((node) => new ResourceNodeDefinition
.Select(node => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
Angle = node.Angle,
@@ -185,40 +179,36 @@ public sealed partial class ScenarioLoader
RadiusVariance = definition.AsteroidField.RadiusVariance,
HeightVariance = definition.AsteroidField.HeightVariance,
},
ResourceNodes = definition.ResourceNodes
.Select((node) => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorMoonIndex = node.AnchorMoonIndex,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
})
.ToList(),
Planets = definition.Planets
.Select((planet) => new PlanetDefinition
{
Label = planet.Label,
PlanetType = planet.PlanetType,
Shape = planet.Shape,
Moons = planet.Moons.Select(m => new MoonDefinition { Label = m.Label, Size = m.Size, Color = m.Color, OrbitRadius = m.OrbitRadius, OrbitSpeed = m.OrbitSpeed, OrbitPhaseAtEpoch = m.OrbitPhaseAtEpoch, OrbitInclination = m.OrbitInclination, OrbitLongitudeOfAscendingNode = m.OrbitLongitudeOfAscendingNode }).ToList(),
OrbitRadius = planet.OrbitRadius,
OrbitSpeed = planet.OrbitSpeed,
OrbitEccentricity = planet.OrbitEccentricity,
OrbitInclination = planet.OrbitInclination,
OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode,
OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis,
OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch,
Size = planet.Size,
Color = planet.Color,
Tilt = planet.Tilt,
HasRing = planet.HasRing,
})
.ToList(),
ResourceNodes = definition.ResourceNodes.Select(node => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorMoonIndex = node.AnchorMoonIndex,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
}).ToList(),
Planets = definition.Planets.Select(planet => new PlanetDefinition
{
Label = planet.Label,
PlanetType = planet.PlanetType,
Shape = planet.Shape,
Moons = planet.Moons.Select(moon => new MoonDefinition { Label = moon.Label, Size = moon.Size, Color = moon.Color, OrbitRadius = moon.OrbitRadius, OrbitSpeed = moon.OrbitSpeed, OrbitPhaseAtEpoch = moon.OrbitPhaseAtEpoch, OrbitInclination = moon.OrbitInclination, OrbitLongitudeOfAscendingNode = moon.OrbitLongitudeOfAscendingNode }).ToList(),
OrbitRadius = planet.OrbitRadius,
OrbitSpeed = planet.OrbitSpeed,
OrbitEccentricity = planet.OrbitEccentricity,
OrbitInclination = planet.OrbitInclination,
OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode,
OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis,
OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch,
Size = planet.Size,
Color = planet.Color,
Tilt = planet.Tilt,
HasRing = planet.HasRing,
}).ToList(),
};
}
@@ -230,7 +220,7 @@ public sealed partial class ScenarioLoader
var nodes = new List<ResourceNodeDefinition>();
if (template.ResourceNodes.Count > 0)
{
nodes.AddRange(template.ResourceNodes.Select((node) => new ResourceNodeDefinition
nodes.AddRange(template.ResourceNodes.Select(node => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
Angle = node.Angle,
@@ -259,7 +249,7 @@ public sealed partial class ScenarioLoader
for (var attempt = 0; attempt < 64; attempt += 1)
{
var candidate = ComputeGeneratedSystemPosition(index, attempt);
if (allPositions.All((existing) => existing.DistanceTo(candidate) >= MinimumSystemSeparation))
if (allPositions.All(existing => existing.DistanceTo(candidate) >= MinimumSystemSeparation))
{
accepted = candidate;
break;
@@ -307,7 +297,7 @@ public sealed partial class ScenarioLoader
{
var slug = string.Concat(label
.ToLowerInvariant()
.Select((character) => char.IsLetterOrDigit(character) ? character : '-'))
.Select(character => char.IsLetterOrDigit(character) ? character : '-'))
.Trim('-');
return $"gen-{ordinal}-{slug}";
@@ -359,9 +349,7 @@ public sealed partial class ScenarioLoader
return Math.Clamp((planets.Count / 2) - 1, 0, planets.Count - 1);
}
private static List<PlanetDefinition> BuildGeneratedPlanets(
SolarSystemDefinition template,
int generatedIndex)
private static List<PlanetDefinition> BuildGeneratedPlanets(SolarSystemDefinition template, int generatedIndex)
{
var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f);
var planets = new List<PlanetDefinition>(planetCount);
@@ -495,23 +483,4 @@ public sealed partial class ScenarioLoader
return moons;
}
private sealed record StarProfile(
string Kind,
string StarColor,
string StarGlow,
float BaseSize);
private sealed record PlanetProfile(
string Type,
string Shape,
string Color,
float BaseSize,
float OrbitGapMin,
int BaseMoonCount,
bool CanHaveRing)
{
public float OrbitGapMax => OrbitGapMin + MathF.Max(0.12f, OrbitGapMin * 0.45f);
}
}

View File

@@ -0,0 +1,225 @@
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario;
internal sealed class WorldBuilder(
WorldGenerationOptions worldGeneration,
DataCatalogLoader dataLoader,
SystemGenerationService generationService,
SpatialBuilder spatialBuilder,
WorldSeedingService seedingService)
{
internal SimulationWorld Build()
{
var catalog = dataLoader.LoadCatalog();
var systems = generationService.ExpandSystems(
generationService.InjectSpecialSystems(catalog.AuthoredSystems),
worldGeneration.TargetSystemCount);
var scenario = dataLoader.NormalizeScenarioToAvailableSystems(
catalog.Scenario,
systems.Select(system => system.Id).ToList());
var systemRuntimes = systems
.Select(definition => new SystemRuntime
{
Definition = definition,
Position = ToVector(definition.Position),
})
.ToList();
var systemsById = systemRuntimes.ToDictionary(system => system.Definition.Id, StringComparer.Ordinal);
var spatialLayout = spatialBuilder.BuildLayout(systemRuntimes, catalog.Balance);
var stations = CreateStations(
scenario,
systemsById,
spatialLayout.SystemGraphs,
spatialLayout.Celestials,
catalog.ModuleDefinitions);
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, stations, refinery);
var factions = seedingService.CreateFactions(stations, ships);
seedingService.BootstrapFactionEconomy(factions, stations);
var policies = seedingService.CreatePolicies(factions);
var commanders = seedingService.CreateCommanders(factions, stations, ships);
var nowUtc = DateTimeOffset.UtcNow;
var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc);
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
{
Label = "Split Viewer / Simulation 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 = constructionSites,
MarketOrders = 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 = DateTimeOffset.UtcNow,
};
}
private static List<StationRuntime> CreateStations(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyDictionary<string, SystemSpatialGraph> systemGraphs,
IReadOnlyCollection<CelestialRuntime> celestials,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions)
{
var stations = new List<StationRuntime>();
var stationIdCounter = 0;
foreach (var plan in scenario.InitialStations)
{
if (!systemsById.TryGetValue(plan.SystemId, out var system))
{
continue;
}
var placement = SpatialBuilder.ResolveStationPlacement(plan, system, systemGraphs[system.Definition.Id], celestials);
var station = new StationRuntime
{
Id = $"station-{++stationIdCounter}",
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);
placement.AnchorCelestial.OccupyingStructureId = station.Id;
var startingModules = plan.StartingModules.Count > 0
? plan.StartingModules
: ["module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01", "module_arg_stor_liquid_m_01"];
foreach (var moduleId in startingModules)
{
AddStationModule(station, moduleDefinitions, moduleId);
}
}
return stations;
}
private static Dictionary<string, List<Vector3>> BuildPatrolRoutes(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById)
{
return scenario.PatrolRoutes
.GroupBy(route => route.SystemId, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group
.SelectMany(route => route.Points)
.Select(point => NormalizeScenarioPoint(systemsById[group.Key], point))
.ToList(),
StringComparer.Ordinal);
}
private static List<ShipRuntime> CreateShips(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyCollection<CelestialRuntime> celestials,
BalanceDefinition balance,
IReadOnlyDictionary<string, ShipDefinition> shipDefinitions,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
IReadOnlyCollection<StationRuntime> stations,
StationRuntime? refinery)
{
var ships = new List<ShipRuntime>();
var shipIdCounter = 0;
foreach (var formation in scenario.ShipFormations)
{
if (!shipDefinitions.TryGetValue(formation.ShipId, out var definition))
{
continue;
}
for (var index = 0; index < formation.Count; index += 1)
{
var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f);
var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset);
ships.Add(new ShipRuntime
{
Id = $"ship-{++shipIdCounter}",
SystemId = formation.SystemId,
Definition = definition,
FactionId = formation.FactionId ?? DefaultFactionId,
Position = position,
TargetPosition = position,
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, celestials),
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;
}
}
}
}
return ships;
}
}

View File

@@ -1,19 +1,19 @@
using SpaceGame.Simulation.Api.Data;
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Universe.Scenario;
public sealed partial class ScenarioLoader
internal sealed class WorldSeedingService
{
private static List<FactionRuntime> CreateFactions(
internal List<FactionRuntime> CreateFactions(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ShipRuntime> ships)
{
var factionIds = stations
.Select((station) => station.FactionId)
.Concat(ships.Select((ship) => ship.FactionId))
.Where((factionId) => !string.IsNullOrWhiteSpace(factionId))
.Select(station => station.FactionId)
.Concat(ships.Select(ship => ship.FactionId))
.Where(factionId => !string.IsNullOrWhiteSpace(factionId))
.Distinct(StringComparer.Ordinal)
.OrderBy((factionId) => factionId, StringComparer.Ordinal)
.OrderBy(factionId => factionId, StringComparer.Ordinal)
.ToList();
if (factionIds.Count == 0)
@@ -21,33 +21,10 @@ public sealed partial class ScenarioLoader
factionIds.Add(DefaultFactionId);
}
return factionIds
.Select(CreateFaction)
.ToList();
return factionIds.Select(CreateFaction).ToList();
}
private static FactionRuntime CreateFaction(string factionId)
{
return factionId switch
{
DefaultFactionId => new FactionRuntime
{
Id = factionId,
Label = "Sol Dominion",
Color = "#7ed4ff",
Credits = MinimumFactionCredits,
},
_ => new FactionRuntime
{
Id = factionId,
Label = ToFactionLabel(factionId),
Color = "#c7d2e0",
Credits = MinimumFactionCredits,
},
};
}
private static void BootstrapFactionEconomy(
internal void BootstrapFactionEconomy(
IReadOnlyCollection<FactionRuntime> factions,
IReadOnlyCollection<StationRuntime> stations)
{
@@ -56,11 +33,11 @@ public sealed partial class ScenarioLoader
faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits);
var ownedStations = stations
.Where((station) => station.FactionId == faction.Id)
.Where(station => station.FactionId == faction.Id)
.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)
@@ -70,32 +47,74 @@ public sealed partial class ScenarioLoader
refinery.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(refinery.Inventory, "refinedmetals"), MinimumRefineryStock);
}
if (refineries.All((station) => GetInventoryAmount(station.Inventory, "ore") < MinimumRefineryOre))
if (refineries.All(station => GetInventoryAmount(station.Inventory, "ore") < MinimumRefineryOre))
{
refineries[0].Inventory["ore"] = MinimumRefineryOre;
}
}
foreach (var shipyard in ownedStations.Where((station) => HasInstalledModules(station, "module_gen_build_l_01")))
foreach (var shipyard in ownedStations.Where(station => HasInstalledModules(station, "module_gen_build_l_01")))
{
shipyard.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refinedmetals"), MinimumShipyardStock);
}
}
}
private static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
internal void InitializeStationStockpiles(IReadOnlyCollection<StationRuntime> stations)
{
foreach (var station in stations)
{
InitializeStationPopulation(station);
if (station.InstalledModules.Contains("module_gen_prod_energycells_01", StringComparer.Ordinal))
{
station.Inventory["energycells"] = MathF.Max(GetInventoryAmount(station.Inventory, "energycells"), 240f);
}
private static List<ClaimRuntime> CreateClaims(
if (station.InstalledModules.Contains("module_gen_prod_refinedmetals_01", StringComparer.Ordinal))
{
station.Inventory["ore"] = MathF.Max(GetInventoryAmount(station.Inventory, "ore"), 220f);
}
if (station.InstalledModules.Contains("module_gen_prod_hullparts_01", StringComparer.Ordinal))
{
station.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(station.Inventory, "refinedmetals"), 240f);
station.Inventory["graphene"] = MathF.Max(GetInventoryAmount(station.Inventory, "graphene"), 80f);
}
if (station.InstalledModules.Contains("module_gen_prod_claytronics_01", StringComparer.Ordinal))
{
station.Inventory["antimattercells"] = MathF.Max(GetInventoryAmount(station.Inventory, "antimattercells"), 90f);
station.Inventory["microchips"] = MathF.Max(GetInventoryAmount(station.Inventory, "microchips"), 120f);
station.Inventory["quantumtubes"] = MathF.Max(GetInventoryAmount(station.Inventory, "quantumtubes"), 90f);
}
if (station.Population > 0f)
{
station.Inventory["water"] = MathF.Max(60f, station.Population * 1.5f);
}
}
}
internal StationRuntime? SelectRefineryStation(IReadOnlyCollection<StationRuntime> stations, ScenarioDefinition scenario)
{
return stations.FirstOrDefault(station =>
string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal) &&
station.SystemId == scenario.MiningDefaults.RefinerySystemId)
?? stations.FirstOrDefault(station =>
string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal));
}
internal List<ClaimRuntime> CreateClaims(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<CelestialRuntime> celestials,
DateTimeOffset nowUtc)
{
var stationsByCelestialId = stations
.Where((station) => station.CelestialId is not null)
.ToDictionary((station) => station.CelestialId!, StringComparer.Ordinal);
.Where(station => station.CelestialId is not null)
.ToDictionary(station => station.CelestialId!, StringComparer.Ordinal);
var claims = new List<ClaimRuntime>();
foreach (var celestial in celestials.Where((c) => c.Kind == SpatialNodeKind.LagrangePoint))
foreach (var celestial in celestials.Where(c => c.Kind == SpatialNodeKind.LagrangePoint))
{
if (!stationsByCelestialId.TryGetValue(celestial.Id, out var station))
{
@@ -118,24 +137,22 @@ public sealed partial class ScenarioLoader
return claims;
}
private static (List<ConstructionSiteRuntime> ConstructionSites, List<MarketOrderRuntime> MarketOrders) CreateConstructionSites(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ClaimRuntime> claims,
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
internal (List<ConstructionSiteRuntime> ConstructionSites, List<MarketOrderRuntime> MarketOrders) CreateConstructionSites(
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;
}
@@ -183,43 +200,7 @@ public sealed partial class ScenarioLoader
return (sites, orders);
}
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");
station.PopulationCapacity = 40f + (habitatModules * 220f);
station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f);
station.Population = habitatModules > 0
? MathF.Min(station.PopulationCapacity * 0.65f, station.WorkforceRequired * 1.05f)
: MathF.Min(28f, station.PopulationCapacity);
station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired);
}
private static List<PolicySetRuntime> CreatePolicies(IReadOnlyCollection<FactionRuntime> factions)
internal List<PolicySetRuntime> CreatePolicies(IReadOnlyCollection<FactionRuntime> factions)
{
var policies = new List<PolicySetRuntime>(factions.Count);
foreach (var faction in factions)
@@ -237,14 +218,14 @@ public sealed partial class ScenarioLoader
return policies;
}
private static List<CommanderRuntime> CreateCommanders(
internal List<CommanderRuntime> CreateCommanders(
IReadOnlyCollection<FactionRuntime> factions,
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ShipRuntime> ships)
{
var commanders = new List<CommanderRuntime>();
var factionCommanders = new Dictionary<string, CommanderRuntime>(StringComparer.Ordinal);
var factionsById = factions.ToDictionary((faction) => faction.Id, StringComparer.Ordinal);
var factionsById = factions.ToDictionary(faction => faction.Id, StringComparer.Ordinal);
foreach (var faction in factions)
{
@@ -330,34 +311,43 @@ public sealed partial class ScenarioLoader
return commanders;
}
private static string ToFactionLabel(string factionId)
{
return string.Join(" ",
factionId
.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select((segment) => char.ToUpperInvariant(segment[0]) + segment[1..]));
}
private static DefaultBehaviorRuntime CreateBehavior(
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))
@@ -365,6 +355,7 @@ public sealed partial class ScenarioLoader
return new DefaultBehaviorRuntime
{
Kind = "patrol",
StationId = homeStation?.Id,
PatrolPoints = route,
PatrolIndex = 0,
};
@@ -376,6 +367,53 @@ public sealed partial class ScenarioLoader
};
}
private static FactionRuntime CreateFaction(string factionId)
{
return factionId switch
{
DefaultFactionId => new FactionRuntime
{
Id = factionId,
Label = "Sol Dominion",
Color = "#7ed4ff",
Credits = MinimumFactionCredits,
},
"asterion-league" => new FactionRuntime
{
Id = factionId,
Label = "Asterion League",
Color = "#ff8f70",
Credits = MinimumFactionCredits,
},
_ => new FactionRuntime
{
Id = factionId,
Label = ToFactionLabel(factionId),
Color = "#c7d2e0",
Credits = MinimumFactionCredits,
},
};
}
private static void InitializeStationPopulation(StationRuntime station)
{
var habitatModules = CountModules(station.InstalledModules, "module_arg_hab_m_01");
station.PopulationCapacity = 40f + (habitatModules * 220f);
station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f);
station.Population = habitatModules > 0
? MathF.Min(station.PopulationCapacity * 0.65f, station.WorkforceRequired * 1.05f)
: MathF.Min(28f, station.PopulationCapacity);
station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired);
}
private static string ToFactionLabel(string factionId)
{
return string.Join(" ",
factionId
.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(segment => char.ToUpperInvariant(segment[0]) + segment[1..]));
}
private static DefaultBehaviorRuntime CreateResourceHarvestBehavior(string kind, string areaSystemId, string stationId) => new()
{
Kind = kind,
@@ -388,6 +426,8 @@ public sealed partial class ScenarioLoader
{
Kind = behavior.Kind,
AreaSystemId = behavior.AreaSystemId,
TargetEntityId = behavior.TargetEntityId,
ItemId = behavior.ItemId,
ModuleId = behavior.ModuleId,
NodeId = behavior.NodeId,
Phase = behavior.Phase,

View File

@@ -1,4 +1,4 @@
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Universe.Simulation;
public sealed class OrbitalSimulationOptions
{

View File

@@ -1,9 +1,16 @@
using SpaceGame.Simulation.Api.Data;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Universe.Simulation;
public sealed partial class SimulationEngine
internal sealed class OrbitalStateUpdater
{
private readonly OrbitalSimulationOptions _orbitalSimulation;
internal OrbitalStateUpdater(OrbitalSimulationOptions orbitalSimulation)
{
_orbitalSimulation = orbitalSimulation;
}
private static Vector3 ComputePlanetPosition(PlanetDefinition planet, float timeSeconds)
{
var eccentricity = Math.Clamp(planet.OrbitEccentricity, 0f, 0.85f);
@@ -153,7 +160,7 @@ public sealed partial class SimulationEngine
}
}
private void UpdateOrbitalState(SimulationWorld world)
internal void Update(SimulationWorld world)
{
var worldTimeSeconds = (float)world.OrbitalTimeSeconds;
var celestialsById = world.Celestials.ToDictionary(c => c.Id, StringComparer.Ordinal);
@@ -248,7 +255,7 @@ public sealed partial class SimulationEngine
}
}
private static void SyncSpatialState(SimulationWorld world)
internal void SyncSpatialState(SimulationWorld world)
{
foreach (var ship in world.Ships)
{

View File

@@ -1,4 +1,4 @@
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Universe.Simulation;
public sealed class SimulationHostedService(WorldService worldService) : BackgroundService
{

View File

@@ -1,4 +1,4 @@
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Universe.Simulation;
public sealed class WorldGenerationOptions
{

View File

@@ -1,8 +1,7 @@
using System.Threading.Channels;
using Microsoft.Extensions.Options;
using SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Api.Universe.Simulation;
public sealed class WorldService(
IWebHostEnvironment environment,
@@ -11,7 +10,7 @@ public sealed class WorldService(
{
private const int DeltaHistoryLimit = 256;
private readonly object _sync = new();
private readonly Lock _sync = new();
private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value);
private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value);

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -5,15 +5,21 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json && vite build",
"build": "vue-tsc -p tsconfig.json --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"three": "^0.179.1"
"pinia": "^3.0.3",
"three": "^0.179.1",
"vue": "^3.5.21"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/three": "^0.183.1",
"@vitejs/plugin-vue": "^6.0.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.2",
"vite": "^7.1.3"
"vite": "^7.1.3",
"vue-tsc": "^3.0.7"
}
}

197
apps/viewer/src/App.vue Normal file
View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import { GameViewer } from "./GameViewer";
import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
import ViewerOpsStrip from "./components/ViewerOpsStrip.vue";
import { createViewerHudState } from "./viewerHudState";
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { Selectable } from "./viewerTypes";
const canvasHostEl = ref<HTMLDivElement | null>(null);
const opsStripHostEl = ref<HTMLDivElement | null>(null);
const historyLayerHostEl = ref<HTMLDivElement | null>(null);
const marqueeEl = ref<HTMLDivElement | null>(null);
const hoverLabelEl = ref<HTMLDivElement | null>(null);
const hoverConnectorLineEl = ref<SVGLineElement | null>(null);
const hudState = createViewerHudState();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
let viewer: GameViewer | undefined;
onMounted(async () => {
await nextTick();
if (
!canvasHostEl.value
|| !opsStripHostEl.value
|| !historyLayerHostEl.value
|| !marqueeEl.value
|| !hoverLabelEl.value
|| !hoverConnectorLineEl.value
) {
throw new Error("Viewer HUD mount failed");
}
viewer = new GameViewer(canvasHostEl.value, {
state: hudState,
selectionStore,
opsStripEl: opsStripHostEl.value,
historyLayerEl: historyLayerHostEl.value,
marqueeEl: marqueeEl.value,
hoverLabelEl: hoverLabelEl.value,
hoverConnectorLineEl: hoverConnectorLineEl.value,
});
void viewer.start();
});
onBeforeUnmount(() => {
viewer?.dispose();
});
function onHistoryWindowResize(id: string, width: number, height: number) {
const windowState = hudState.historyWindows.find((entry) => entry.id === id);
if (!windowState) {
return;
}
windowState.width = width;
windowState.height = height;
}
function onOpenHistory(selection: Selectable) {
viewer?.openHistoryWindow(selection);
}
function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactical") {
viewer?.focusSelection(selection, cameraMode);
}
</script>
<template>
<div class="viewer-app">
<div
ref="canvasHostEl"
class="viewer-canvas-host"
/>
<div class="pointer-events-none fixed inset-0">
<div class="absolute left-5 top-5 flex w-[min(360px,calc(100vw-40px))] flex-col gap-4 max-[760px]:right-5 max-[760px]:w-auto">
<CollapsibleHudPanel
v-model:collapsed="hudState.gamePanel.collapsed"
class-name="topbar"
panel-name="game"
title="Game"
:summary="hudState.gamePanel.summary"
:body-text="hudState.gamePanel.bodyText"
/>
<CollapsibleHudPanel
v-model:collapsed="hudState.networkPanel.collapsed"
class-name="network-panel"
panel-name="network"
title="Network"
:summary="hudState.networkPanel.summary"
:body-text="hudState.networkPanel.bodyText"
/>
<CollapsibleHudPanel
v-model:collapsed="hudState.performancePanel.collapsed"
class-name="performance-panel"
panel-name="performance"
title="Performance"
:summary="hudState.performancePanel.summary"
:body-text="hudState.performancePanel.bodyText"
/>
</div>
<div class="absolute right-5 top-5 flex w-[min(380px,calc(100vw-40px))] flex-col gap-4 max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto max-[760px]:overflow-auto">
<HtmlInfoPanel
class-name="system-panel-section"
title="System"
:subtitle="hudState.systemPanel.title"
:body-html="hudState.systemPanel.bodyHtml"
:hidden="hudState.systemPanel.hidden"
subtitle-class="system-title"
body-class="system-body"
/>
<HtmlInfoPanel
class-name="detail-panel-section"
title="Focus"
:subtitle="hudState.detailPanel.title"
:body-html="hudState.detailPanel.bodyHtml"
:hidden="hudState.detailPanel.hidden"
subtitle-class="detail-title"
body-class="detail-body"
/>
<div
class="pointer-events-auto rounded-xl bg-[rgba(255,116,88,0.14)] px-3.5 py-3 text-[#ffd8cf]"
:hidden="hudState.error.hidden"
>
{{ hudState.error.message }}
</div>
<button
v-if="selectedEntityId"
type="button"
class="selection-action-button pointer-events-auto self-end rounded-full border border-white/10 bg-white/5 px-3.5 py-2.5 text-sm text-[color:var(--viewer-text)] transition hover:bg-white/10"
@click="selectionStore.clearSelection('ui')"
>
Clear {{ selectedEntityLabel ?? "Selection" }}
</button>
</div>
<div ref="historyLayerHostEl">
<ViewerHistoryLayer
:windows="hudState.historyWindows"
@resize="onHistoryWindowResize"
/>
</div>
<div ref="opsStripHostEl">
<ViewerOpsStrip
:state="hudState.opsStrip"
@history="onOpenHistory"
@focus="onFocusSelection"
/>
</div>
<div
ref="marqueeEl"
class="marquee-box"
:style="{
display: hudState.marquee.visible ? 'block' : 'none',
left: `${hudState.marquee.x}px`,
top: `${hudState.marquee.y}px`,
width: `${hudState.marquee.width}px`,
height: `${hudState.marquee.height}px`,
}"
/>
<svg
class="hover-connector-svg"
aria-hidden="true"
>
<line
ref="hoverConnectorLineEl"
class="hover-connector-line"
:x1="hudState.hoverLabel.x1"
:y1="hudState.hoverLabel.y1"
:x2="hudState.hoverLabel.x2"
:y2="hudState.hoverLabel.y2"
:hidden="hudState.hoverLabel.connectorHidden"
/>
</svg>
<div
ref="hoverLabelEl"
class="hover-label"
:hidden="hudState.hoverLabel.hidden"
:style="{
left: `${hudState.hoverLabel.x}px`,
top: `${hudState.hoverLabel.y}px`,
}"
>
{{ hudState.hoverLabel.text }}
</div>
</div>
</div>
</template>

View File

@@ -1,13 +1,27 @@
import type { ViewerHudBindings } from "./viewerHudState";
import type { Selectable, CameraMode } from "./viewerTypes";
import { ViewerAppController } from "./ViewerAppController";
export class GameViewer {
private readonly controller: ViewerAppController;
constructor(container: HTMLElement) {
this.controller = new ViewerAppController(container);
constructor(container: HTMLElement, hud: ViewerHudBindings) {
this.controller = new ViewerAppController(container, hud);
}
async start() {
await this.controller.start();
}
focusSelection(selection: Selectable, cameraMode?: CameraMode) {
this.controller.focusSelection(selection, cameraMode);
}
openHistoryWindow(selection: Selectable) {
this.controller.openHistoryWindow(selection);
}
dispose() {
this.controller.dispose();
}
}

View File

@@ -4,7 +4,6 @@ import {
MIN_CAMERA_DISTANCE,
NAV_DISTANCE,
} from "./viewerConstants";
import { createViewerHud } from "./viewerHud";
import { updatePanFromKeyboard } from "./viewerCamera";
import { setShellReticleOpacity } from "./viewerControls";
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
@@ -21,16 +20,21 @@ import { ViewerNavigationController } from "./viewerNavigationController";
import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerPresentationController } from "./viewerPresentationController";
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
import { createViewerRenderer } from "./runtime/rendering/createViewerRenderer";
import { disposeSceneResources } from "./runtime/rendering/disposeThreeResources";
import { ViewerRenderSurface } from "./runtime/rendering/ViewerRenderSurface";
import { toDisplayLocalPosition, getSystemCameraFocus } from "./viewerCamera";
import { UniverseLayer } from "./viewerUniverseLayer";
import { GalaxyLayer } from "./viewerGalaxyLayer";
import { SystemLayer } from "./viewerSystemLayer";
import { LocalLayer } from "./viewerLocalLayer";
import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState";
import { describeSelectable } from "./viewerSelection";
import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { FactionSnapshot } from "./contracts";
import type {
CameraMode,
DragMode,
HistoryWindowState,
NetworkStats,
PerformanceStats,
Selectable,
@@ -41,7 +45,8 @@ import type {
export class ViewerAppController {
private readonly container: HTMLElement;
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
private readonly renderer = createViewerRenderer();
private readonly renderSurface: ViewerRenderSurface;
// ── Three independent rendering layers ───────────────────────────────────
readonly universeLayer = new UniverseLayer();
@@ -61,23 +66,9 @@ export class ViewerAppController {
private readonly cameraOffset = new THREE.Vector3();
private readonly keyState = new Set<string>();
private readonly gamePanelEl: HTMLDivElement;
private readonly statusEl: HTMLDivElement;
private readonly gameSummaryEl: HTMLSpanElement;
private readonly systemPanelEl: HTMLDivElement;
private readonly systemTitleEl: HTMLHeadingElement;
private readonly systemBodyEl: HTMLDivElement;
private readonly detailTitleEl: HTMLHeadingElement;
private readonly detailBodyEl: HTMLDivElement;
readonly hudState: ViewerHudState;
readonly selectionStore: ViewerSelectionStore;
private readonly opsStripEl: HTMLDivElement;
private readonly networkSectionEl: HTMLDivElement;
private readonly networkSummaryEl: HTMLSpanElement;
private readonly networkPanelEl: HTMLDivElement;
private readonly performanceSectionEl: HTMLDivElement;
private readonly performanceSummaryEl: HTMLSpanElement;
private readonly performancePanelEl: HTMLDivElement;
private readonly errorEl: HTMLDivElement;
private readonly historyLayerEl: HTMLDivElement;
private readonly marqueeEl: HTMLDivElement;
private readonly hoverLabelEl: HTMLDivElement;
@@ -111,6 +102,8 @@ export class ViewerAppController {
private readonly followCameraDirection = new THREE.Vector3(0, 0.16, 1);
private readonly followCameraDesiredDirection = new THREE.Vector3(0, 0.16, 1);
private readonly followCameraOffset = new THREE.Vector3();
private followOrbitYaw = 0;
private followOrbitPitch = 0.2;
private readonly historyWindows: HistoryWindowState[] = [];
private historyWindowCounter = 0;
private historyWindowZCounter = 10;
@@ -122,30 +115,14 @@ export class ViewerAppController {
private readonly navigationController: ViewerNavigationController;
private readonly sceneDataController: ViewerSceneDataController;
private readonly presentationController: ViewerPresentationController;
private readonly disposeEventBindings: () => void;
private readonly unsubscribeSelectionStore: () => void;
constructor(container: HTMLElement) {
constructor(container: HTMLElement, hud: ViewerHudBindings) {
this.container = container;
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
const hud = createViewerHud(document);
this.gamePanelEl = hud.gamePanelEl;
this.statusEl = hud.statusEl;
this.gameSummaryEl = hud.gameSummaryEl;
this.networkSectionEl = hud.networkSectionEl;
this.systemPanelEl = hud.systemPanelEl;
this.systemTitleEl = hud.systemTitleEl;
this.systemBodyEl = hud.systemBodyEl;
this.detailTitleEl = hud.detailTitleEl;
this.detailBodyEl = hud.detailBodyEl;
this.hudState = hud.state;
this.selectionStore = hud.selectionStore;
this.opsStripEl = hud.opsStripEl;
this.networkSummaryEl = hud.networkSummaryEl;
this.networkPanelEl = hud.networkPanelEl;
this.performanceSectionEl = hud.performanceSectionEl;
this.performanceSummaryEl = hud.performanceSummaryEl;
this.performancePanelEl = hud.performancePanelEl;
this.errorEl = hud.errorEl;
this.historyLayerEl = hud.historyLayerEl;
this.marqueeEl = hud.marqueeEl;
this.hoverLabelEl = hud.hoverLabelEl;
@@ -158,33 +135,51 @@ export class ViewerAppController {
interactionController: this.interactionController,
} = createViewerControllers(this));
this.presentationController.initializeAmbience();
this.container.append(this.renderer.domElement, hud.root);
this.initializePanelToggles();
wireViewerEvents(this);
this.onResize();
this.unsubscribeSelectionStore = this.selectionStore.$subscribe((_mutation, state) => {
this.syncSelectionFromStore(state.selectedEntityKind, state.selectedEntityId);
});
this.renderSurface = new ViewerRenderSurface({
container: this.container,
renderer: this.renderer,
onFrame: () => this.render(),
onResize: (width, height) => this.onResize(width, height),
});
this.disposeEventBindings = wireViewerEvents(this);
this.updateCamera(0);
}
private initializePanelToggles() {
for (const panel of [this.gamePanelEl, this.networkSectionEl, this.performanceSectionEl]) {
const toggle = panel.querySelector(".panel-toggle");
if (!(toggle instanceof HTMLButtonElement)) {
continue;
}
toggle.addEventListener("click", () => {
const collapsed = panel.classList.toggle("is-collapsed");
toggle.textContent = collapsed ? "+" : "-";
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
toggle.setAttribute("aria-label", `${collapsed ? "Expand" : "Collapse"} ${panel.dataset.panelName ?? "panel"}`);
});
}
async start() {
this.selectionStore.clearSelection();
await this.worldLifecycle.bootstrapWorld();
this.renderSurface.start();
}
async start() {
await this.worldLifecycle.bootstrapWorld();
this.renderer.setAnimationLoop(() => this.render());
dispose() {
this.disposeEventBindings();
this.unsubscribeSelectionStore();
this.stream?.close();
this.renderSurface.dispose();
disposeSceneResources(this.universeLayer.scene);
disposeSceneResources(this.galaxyLayer.scene);
disposeSceneResources(this.systemLayer.scene);
disposeSceneResources(this.localLayer.scene);
}
focusSelection(selection: Selectable, cameraMode?: CameraMode) {
this.applySelectedItems([selection], "ui");
this.navigationController.focusOnSelection(selection);
if (cameraMode) {
this.interactionController.toggleCameraMode(cameraMode);
if (selection.kind === "ship" && cameraMode === "follow") {
this.desiredDistance = 0.00018;
}
}
this.updatePanels();
this.updateGamePanel("Live");
}
openHistoryWindow(selection: Selectable) {
this.interactionController.openHistoryWindow(selection);
}
private refreshStreamScopeIfNeeded() {
@@ -212,6 +207,32 @@ export class ViewerAppController {
this.worldLifecycle.updatePanels();
}
private applySelectedItems(items: Selectable[], source: "viewer" | "ui") {
this.selectedItems = items;
if (items.length === 1) {
const selection = items[0];
this.selectionStore.selectSelection({
id: selectionToEntityId(selection),
kind: selection.kind,
label: describeSelectable(this.world, selection),
}, source);
return;
}
this.selectionStore.clearSelection(source);
}
private syncSelectionFromStore(
kind: Selectable["kind"] | null,
entityId: string | null,
) {
const selection = entityIdToSelectable(kind, entityId);
this.selectedItems = selection ? [selection] : [];
this.navigationController.syncFollowStateFromSelection();
this.updatePanels();
this.updateGamePanel("Live");
}
private render() {
renderFrame({
clock: this.clock,
@@ -322,14 +343,15 @@ export class ViewerAppController {
return resolveFocusedCelestialId(this.world, this.selectedItems);
}
private onResize = () => {
private onResize(width: number, height: number) {
resizeViewer({
renderer: this.renderer,
galaxyLayer: this.galaxyLayer,
systemLayer: this.systemLayer,
localLayer: this.localLayer,
width,
height,
});
};
}
private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
setShellReticleOpacity(sprite, opacity);

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
const collapsed = defineModel<boolean>("collapsed", { required: true });
defineProps<{
title: string;
summary: string;
bodyText: string;
className?: string;
panelName: string;
}>();
</script>
<template>
<section :class="[
'pointer-events-auto rounded-3xl border border-(--viewer-panel-border) bg-(--viewer-panel) p-4 text-(--viewer-text) shadow-[0_18px_54px_rgba(0,0,0,0.35)] backdrop-blur-xl',
'collapsible-panel',
className,
collapsed && 'is-collapsed',
]" :data-panel-name="panelName">
<div class="flex items-center justify-between gap-3">
<h2 class="m-0 text-[0.64rem] leading-none tracking-[0.16em] text-(--viewer-accent) uppercase">
{{ title }}
</h2>
<div class="flex min-w-0 items-center gap-2.5">
<span class="panel-summary hud-mono hidden text-right text-[0.72rem] leading-none text-(--viewer-muted)">
{{ summary }}
</span>
<button type="button"
class="h-7 w-7 cursor-pointer rounded-full border border-white/10 bg-white/5 text-sm text-(--viewer-text) transition hover:bg-white/10"
:aria-expanded="!collapsed" :aria-label="`${collapsed ? 'Expand' : 'Collapse'} ${title} panel`"
@click="collapsed = !collapsed">
{{ collapsed ? "+" : "-" }}
</button>
</div>
</div>
<div :class="`${panelName}-body hud-mono mt-3.5 text-[0.8rem] leading-6 text-(--viewer-muted) whitespace-pre-wrap`">
{{ bodyText }}
</div>
</section>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
defineProps<{
title: string;
subtitle: string;
bodyHtml: string;
hidden?: boolean;
className?: string;
subtitleClass?: string;
bodyClass?: string;
}>();
</script>
<template>
<aside v-if="!hidden" :class="[
'pointer-events-auto rounded-3xl border border-(--viewer-panel-border) bg-(--viewer-panel) p-4 text-(--viewer-text) shadow-[0_18px_54px_rgba(0,0,0,0.35)] backdrop-blur-xl',
'info-panel',
className,
]">
<h2 class="m-0 text-[0.72rem] tracking-[0.16em] text-(--viewer-accent) uppercase">
{{ title }}
</h2>
<h3 :class="subtitleClass">{{ subtitle }}</h3>
<div :class="bodyClass" v-html="bodyHtml" />
</aside>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, useTemplateRef, watch } from "vue";
import type { HistoryWindowState } from "../viewerHudState";
const props = defineProps<{
windows: HistoryWindowState[];
}>();
const emit = defineEmits<{
resize: [id: string, width: number, height: number];
}>();
const windowRefs = useTemplateRef<HTMLElement[]>("historyWindows");
let resizeObserver: ResizeObserver | undefined;
onMounted(() => {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const id = (entry.target as HTMLElement).dataset.historyWindowId;
if (!id) {
continue;
}
emit("resize", id, entry.contentRect.width, entry.contentRect.height);
}
});
for (const element of windowRefs.value ?? []) {
resizeObserver.observe(element);
}
});
watch(
() => props.windows.map((windowState) => `${windowState.id}:${windowState.width}:${windowState.height}`).join("|"),
async () => {
await nextTick();
resizeObserver?.disconnect();
for (const element of windowRefs.value ?? []) {
resizeObserver?.observe(element);
}
},
);
onBeforeUnmount(() => {
resizeObserver?.disconnect();
});
</script>
<template>
<div class="history-layer">
<aside
v-for="windowState in props.windows"
:key="windowState.id"
ref="historyWindows"
class="history-window"
:data-history-window-id="windowState.id"
:style="{
left: `${windowState.x}px`,
top: `${windowState.y}px`,
width: `${windowState.width}px`,
height: `${windowState.height}px`,
zIndex: windowState.zIndex.toString(),
}"
>
<div class="history-window-header">
<h2 class="history-window-title">{{ windowState.title }}</h2>
<div class="history-window-actions">
<button
type="button"
class="history-window-copy"
>
{{ windowState.copyLabel }}
</button>
<button
type="button"
class="history-window-close"
>
Close
</button>
</div>
</div>
<div
class="history-window-body"
v-html="windowState.bodyHtml"
/>
</aside>
</div>
</template>

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import type { OpsStripState } from "../viewerHudState";
import { useViewerSelectionStore } from "../ui/stores/viewerSelection";
import type { Selectable } from "../viewerTypes";
defineProps<{
state: OpsStripState;
}>();
const emit = defineEmits<{
history: [selection: Selectable];
focus: [selection: Selectable, cameraMode?: "follow" | "tactical"];
}>();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
function isSelected(kind: Selectable["kind"], id: string) {
return selectedEntityKind.value === kind && selectedEntityId.value === id;
}
function onStationClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "station", label }, "ui");
}
function onStationDoubleClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "station", label }, "ui");
emit("focus", { kind: "station", id }, "tactical");
}
function onShipClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "ship", label }, "ui");
}
function onShipDoubleClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "ship", label }, "ui");
emit("focus", { kind: "ship", id }, "follow");
}
</script>
<template>
<section class="ops-strip">
<article
v-for="faction in state.factions"
:key="faction.id"
class="ship-card faction-card"
:data-faction-id="faction.id"
>
<div class="ship-card-header">
<h3>{{ faction.label }}</h3>
<span class="ship-card-badge">faction</span>
</div>
<div
v-if="faction.stateLines.length > 0"
class="ship-card-ai"
>
<p class="ship-card-section-title">GOAP State</p>
<p
v-for="line in faction.stateLines"
:key="line"
>
{{ line }}
</p>
</div>
<div
v-if="faction.priorities.length > 0"
class="ship-card-ai"
>
<p class="ship-card-section-title">Priorities</p>
<p
v-for="priority in faction.priorities"
:key="`${faction.id}-${priority.label}`"
class="ship-card-split-line"
>
<span>{{ priority.label }}</span>
<span>{{ priority.value }}</span>
</p>
</div>
</article>
<article
v-for="station in state.stations"
:key="station.id"
:class="['ship-card', 'station-card', isSelected('station', station.id) && 'is-selected']"
:data-station-id="station.id"
@click="onStationClick(station.id, station.label)"
@dblclick="onStationDoubleClick(station.id, station.label)"
>
<div class="ship-card-header">
<h3>{{ station.label }}</h3>
<span class="ship-card-badge">{{ station.badge }}</span>
</div>
<p
v-for="line in station.lines"
:key="`${station.id}-${line}`"
>
{{ line }}
</p>
<div
v-if="station.processes.length > 0"
class="ship-card-ai"
>
<div
v-for="process in station.processes"
:key="`${station.id}-${process.label}`"
class="ship-action-progress"
>
<div class="ship-action-progress-label">
<span>{{ process.label }}</span>
<span>{{ process.valueLabel }}</span>
</div>
<div class="ship-action-progress-track">
<div
class="ship-action-progress-fill"
:style="{ width: `${process.progress}%` }"
/>
</div>
</div>
</div>
</article>
<article
v-for="ship in state.ships"
:key="ship.id"
:class="['ship-card', isSelected('ship', ship.id) && 'is-selected', ship.followed && 'is-followed']"
:data-ship-id="ship.id"
@click="onShipClick(ship.id, ship.label)"
@dblclick="onShipDoubleClick(ship.id, ship.label)"
>
<div class="ship-card-header">
<h3>{{ ship.label }}</h3>
<div class="ship-card-meta">
<span class="ship-card-badge">{{ ship.badge }}</span>
<button
type="button"
class="ship-card-history-button"
:data-history-ship-id="ship.id"
:aria-label="`Open history for ${ship.label}`"
title="Open history"
@click.stop="emit('history', { kind: 'ship', id: ship.id })"
>
&#128340;
</button>
</div>
</div>
<p
v-for="line in ship.locationLines"
:key="`${ship.id}-${line}`"
>
{{ line }}
</p>
<p
v-for="line in ship.lines"
:key="`${ship.id}-${line}`"
>
{{ line }}
</p>
<div
v-if="ship.action"
class="ship-action-progress"
>
<div class="ship-action-progress-label">
<span>{{ ship.action.label }}</span>
<span>{{ ship.action.valueLabel }}</span>
</div>
<div class="ship-action-progress-track">
<div
class="ship-action-progress-fill"
:style="{ width: `${ship.action.progress}%` }"
/>
</div>
</div>
<div class="ship-card-ai">
<p
v-for="line in ship.aiLines"
:key="`${ship.id}-${line}`"
>
{{ line }}
</p>
</div>
</article>
</section>
</template>

View File

@@ -1,9 +1,18 @@
import type { InventoryEntry, Vector3Dto } from "./contractsCommon";
export interface RecipeEntrySnapshot {
itemId: string;
amount: number;
}
export interface StationActionProgressSnapshot {
lane: string;
label: string;
progress: number;
timeRemainingSeconds: number;
cycleSeconds: number;
inputs: RecipeEntrySnapshot[];
outputs: RecipeEntrySnapshot[];
}
export interface StationStorageUsageSnapshot {

8
apps/viewer/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
export default component;
}

View File

@@ -1,5 +1,7 @@
import "./style.css";
import { GameViewer } from "./GameViewer";
import "./styles/index.css";
import { createApp } from "vue";
import App from "./App.vue";
import { viewerPinia } from "./ui/stores/pinia";
const root = document.querySelector<HTMLDivElement>("#app");
@@ -7,5 +9,6 @@ if (!root) {
throw new Error("Missing #app root element");
}
const viewer = new GameViewer(root);
void viewer.start();
createApp(App)
.use(viewerPinia)
.mount(root);

View File

@@ -0,0 +1,52 @@
import * as THREE from "three";
interface ViewerRenderSurfaceOptions {
container: HTMLElement;
renderer: THREE.WebGLRenderer;
onFrame: () => void;
onResize: (width: number, height: number) => void;
}
export class ViewerRenderSurface {
private readonly container: HTMLElement;
readonly renderer: THREE.WebGLRenderer;
private readonly onFrame: () => void;
private readonly onResizeCallback: (width: number, height: number) => void;
private readonly resizeListener = () => this.resize();
constructor(options: ViewerRenderSurfaceOptions) {
this.container = options.container;
this.renderer = options.renderer;
this.onFrame = options.onFrame;
this.onResizeCallback = options.onResize;
this.container.append(this.renderer.domElement);
window.addEventListener("resize", this.resizeListener);
this.resize();
}
get domElement() {
return this.renderer.domElement;
}
start() {
this.renderer.setAnimationLoop(this.onFrame);
}
stop() {
this.renderer.setAnimationLoop(null);
}
resize() {
const width = this.container.clientWidth || window.innerWidth;
const height = this.container.clientHeight || window.innerHeight;
this.renderer.setSize(width, height, false);
this.onResizeCallback(width, height);
}
dispose() {
this.stop();
window.removeEventListener("resize", this.resizeListener);
this.renderer.dispose();
this.renderer.domElement.remove();
}
}

View File

@@ -0,0 +1,9 @@
import * as THREE from "three";
export function createViewerRenderer() {
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(0x040912, 1);
return renderer;
}

View File

@@ -0,0 +1,35 @@
import * as THREE from "three";
function disposeMaterialTextures(material: THREE.Material, disposedTextures: Set<THREE.Texture>) {
for (const value of Object.values(material)) {
if (value instanceof THREE.Texture && !disposedTextures.has(value)) {
disposedTextures.add(value);
value.dispose();
}
}
}
export function disposeSceneResources(root: THREE.Object3D) {
const disposedGeometries = new Set<THREE.BufferGeometry>();
const disposedMaterials = new Set<THREE.Material>();
const disposedTextures = new Set<THREE.Texture>();
root.traverse((object) => {
const mesh = object as THREE.Mesh;
const geometry = mesh.geometry;
if (geometry instanceof THREE.BufferGeometry && !disposedGeometries.has(geometry)) {
disposedGeometries.add(geometry);
geometry.dispose();
}
const material = mesh.material;
const materials = Array.isArray(material) ? material : material ? [material] : [];
for (const candidate of materials) {
if (candidate instanceof THREE.Material && !disposedMaterials.has(candidate)) {
disposedMaterials.add(candidate);
disposeMaterialTextures(candidate, disposedTextures);
candidate.dispose();
}
}
});
}

View File

@@ -0,0 +1,2 @@
@import "tailwindcss";
@import "./viewer.css";

View File

@@ -1,13 +1,12 @@
:root {
color-scheme: dark;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
--bg: #050812;
--panel: rgba(9, 18, 34, 0.78);
--panel-border: rgba(132, 196, 255, 0.18);
--text: #eaf4ff;
--muted: #98adc4;
--accent: #7fd6ff;
--warning: #ffbf69;
--viewer-panel: rgba(9, 18, 34, 0.78);
--viewer-panel-border: rgba(132, 196, 255, 0.18);
--viewer-text: #eaf4ff;
--viewer-muted: #98adc4;
--viewer-accent: #7fd6ff;
--viewer-warning: #ffbf69;
}
* {
@@ -27,39 +26,95 @@ body,
linear-gradient(180deg, #03060d 0%, #060c18 100%);
}
body {
color: var(--viewer-text);
}
canvas {
display: block;
}
.viewer-shell {
position: fixed;
inset: 0;
pointer-events: none;
.viewer-app,
.viewer-canvas-host {
width: 100%;
height: 100%;
}
.left-panel-stack {
position: absolute;
top: 20px;
left: 20px;
width: min(360px, calc(100vw - 40px));
display: flex;
flex-direction: column;
gap: 16px;
.panel-summary,
.hud-mono,
.system-body,
.detail-body,
.ship-card p,
.history,
.history-window-body,
.hover-label {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
}
.right-panel-stack {
position: absolute;
top: 20px;
right: 20px;
width: min(380px, calc(100vw - 40px));
.collapsible-panel.is-collapsed .game-body,
.collapsible-panel.is-collapsed .network-body,
.collapsible-panel.is-collapsed .performance-body {
display: none;
}
.collapsible-panel.is-collapsed .panel-summary {
display: inline-block;
}
.system-title,
.detail-title {
margin: 12px 0 0;
font-size: 1.05rem;
}
.system-body,
.detail-body {
margin-top: 12px;
color: var(--viewer-muted);
line-height: 1.55;
}
.system-body p,
.detail-body p {
margin: 0 0 12px;
}
.detail-progress,
.ship-action-progress {
margin: 0 0 3px;
}
.detail-progress-label,
.ship-action-progress-label {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
color: var(--viewer-muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.72rem;
line-height: 1;
}
.detail-progress-track,
.ship-action-progress-track {
height: 6px;
border-radius: 999px;
overflow: hidden;
background: rgba(127, 214, 255, 0.12);
border: 1px solid rgba(127, 214, 255, 0.14);
}
.detail-progress-fill,
.ship-action-progress-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, rgba(127, 214, 255, 0.72), rgba(255, 191, 105, 0.9));
}
.marquee-box {
position: absolute;
display: none;
border: 1px solid rgba(127, 214, 255, 0.72);
background: rgba(127, 214, 255, 0.14);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
@@ -91,7 +146,6 @@ canvas {
background: rgba(7, 15, 28, 0.88);
border: 1px solid rgba(255, 88, 72, 0.5);
color: #fff2ef;
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.75rem;
line-height: 1.35;
white-space: pre-line;
@@ -102,226 +156,6 @@ canvas {
display: none;
}
.topbar,
.info-panel,
.network-panel,
.performance-panel,
.ops-strip {
backdrop-filter: blur(18px);
background: var(--panel);
border: 1px solid var(--panel-border);
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.35);
}
.topbar {
border-radius: 22px;
padding: 14px 16px;
pointer-events: auto;
}
.eyebrow {
margin: 0 0 6px;
color: var(--accent);
letter-spacing: 0.18em;
font-size: 0.72rem;
text-transform: uppercase;
}
.topbar h1,
.topbar h2,
.info-panel h2,
.info-panel h3,
.ship-card h3 {
margin: 0;
}
.topbar {
display: block;
align-items: start;
}
.topbar h2 {
color: var(--accent);
letter-spacing: 0.16em;
font-size: 0.64rem;
text-transform: uppercase;
line-height: 1;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-heading-meta {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.panel-summary {
display: none;
color: var(--muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.72rem;
line-height: 1;
text-align: right;
white-space: nowrap;
}
.panel-toggle {
border: 1px solid rgba(127, 214, 255, 0.2);
background: rgba(127, 214, 255, 0.08);
color: var(--text);
border-radius: 999px;
width: 28px;
height: 28px;
cursor: pointer;
font: inherit;
}
.panel-toggle:hover {
background: rgba(127, 214, 255, 0.16);
}
.topbar-body {
margin-top: 14px;
color: var(--muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.8rem;
line-height: 1.6;
white-space: pre-wrap;
}
.info-panel {
border-radius: 24px;
padding: 16px;
color: var(--text);
pointer-events: auto;
overflow: auto;
}
.network-panel {
border-radius: 24px;
padding: 14px 16px;
color: var(--text);
pointer-events: auto;
}
.performance-panel {
width: min(360px, calc(100vw - 40px));
border-radius: 24px;
padding: 14px 16px;
color: var(--text);
pointer-events: auto;
}
.info-panel h2 {
color: var(--accent);
letter-spacing: 0.16em;
font-size: 0.72rem;
text-transform: uppercase;
}
.network-panel h2,
.performance-panel h2 {
margin: 0;
color: var(--accent);
letter-spacing: 0.16em;
font-size: 0.64rem;
line-height: 1;
text-transform: uppercase;
}
.network-body,
.performance-body {
margin-top: 14px;
color: var(--muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.8rem;
line-height: 1.6;
white-space: pre-wrap;
}
.collapsible-panel.is-collapsed .topbar-body,
.collapsible-panel.is-collapsed .network-body,
.collapsible-panel.is-collapsed .performance-body {
display: none;
}
.collapsible-panel.is-collapsed .panel-summary {
display: inline-block;
}
.collapsible-panel.is-collapsed {
padding-bottom: 12px;
}
.detail-title {
margin-top: 12px;
font-size: 1.05rem;
}
.system-title {
margin-top: 12px;
font-size: 1.05rem;
}
.system-body,
.detail-body {
margin-top: 12px;
color: var(--muted);
line-height: 1.55;
}
.system-body p,
.detail-body p {
margin: 0 0 12px;
}
.detail-progress,
.ship-action-progress {
margin: 0 0 12px;
}
.detail-progress-label,
.ship-action-progress-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
color: var(--muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.72rem;
line-height: 1;
}
.detail-progress-track,
.ship-action-progress-track {
height: 6px;
border-radius: 999px;
overflow: hidden;
background: rgba(127, 214, 255, 0.12);
border: 1px solid rgba(127, 214, 255, 0.14);
}
.detail-progress-fill,
.ship-action-progress-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, rgba(127, 214, 255, 0.72), rgba(255, 191, 105, 0.9));
}
.history {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.78rem;
line-height: 1.6;
}
.history-window {
position: absolute;
right: auto;
@@ -344,10 +178,6 @@ canvas {
resize: both;
}
.history-window[hidden] {
display: none;
}
.history-window-header {
display: flex;
justify-content: space-between;
@@ -360,41 +190,16 @@ canvas {
.history-window-title {
margin: 0;
color: var(--accent);
color: var(--viewer-accent);
font-size: 0.8rem;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.history-window-close,
.ship-card-history-button {
border: 1px solid rgba(127, 214, 255, 0.22);
border-radius: 999px;
background: rgba(127, 214, 255, 0.08);
color: var(--text);
font: inherit;
cursor: pointer;
}
.history-window-actions {
display: flex;
align-items: center;
gap: 8px;
}
.history-window-close {
padding: 8px 12px;
}
.history-window-copy {
padding: 8px 12px;
}
.history-window-body {
overflow: auto;
padding: 16px;
color: var(--text);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
color: var(--viewer-text);
font-size: 0.78rem;
line-height: 1.6;
white-space: pre-wrap;
@@ -402,34 +207,10 @@ canvas {
cursor: text;
}
.error-strip {
border-radius: 14px;
padding: 12px 14px;
background: rgba(255, 116, 88, 0.14);
color: #ffd8cf;
pointer-events: auto;
}
.right-panel-stack .error-strip {
margin-top: -4px;
}
.system-panel-section[hidden] {
display: none;
}
.detail-panel-section[hidden] {
display: none;
}
.error-strip[hidden] {
display: none;
}
.history-layer {
position: absolute;
inset: 0;
pointer-events: none;
.history-window-actions {
display: flex;
align-items: center;
gap: 8px;
}
.ops-strip {
@@ -439,8 +220,6 @@ canvas {
bottom: 0;
width: 50vw;
min-height: 128px;
border-radius: 0;
padding: 0;
display: flex;
align-items: stretch;
gap: 0;
@@ -452,7 +231,6 @@ canvas {
}
.ship-card {
border-radius: 0;
border-top: 1px solid rgba(127, 214, 255, 0.14);
border-right: 1px solid rgba(127, 214, 255, 0.1);
background: linear-gradient(180deg, rgba(10, 20, 36, 0.96), rgba(6, 12, 22, 0.98));
@@ -462,7 +240,7 @@ canvas {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--text);
color: var(--viewer-text);
cursor: pointer;
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
}
@@ -490,11 +268,26 @@ canvas {
}
.ship-card h3 {
margin: 0;
font-size: 0.82rem;
line-height: 1.15;
letter-spacing: 0.04em;
}
.ship-card p {
margin: 2px 0 0;
color: var(--viewer-muted);
line-height: 1.35;
font-size: 0.72rem;
white-space: pre-line;
}
.ship-card-header + p {
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ship-card-meta {
display: flex;
flex-direction: column;
@@ -506,30 +299,12 @@ canvas {
padding: 3px 8px;
border-radius: 999px;
background: rgba(127, 214, 255, 0.12);
color: var(--accent);
color: var(--viewer-accent);
font-size: 0.64rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.ship-card p {
margin: 2px 0 0;
color: var(--muted);
line-height: 1.35;
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.72rem;
}
.ship-card-header+p {
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ship-action-progress {
margin-top: 2px;
}
.ship-card-ai {
margin-top: 2px;
padding-top: 6px;
@@ -538,11 +313,22 @@ canvas {
.ship-card-section-title {
margin: 0;
color: var(--accent);
color: var(--viewer-accent);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.ship-card-history-button,
.history-window-copy,
.history-window-close {
border: 1px solid rgba(127, 214, 255, 0.22);
border-radius: 999px;
background: rgba(127, 214, 255, 0.08);
color: var(--viewer-text);
font: inherit;
cursor: pointer;
}
.ship-card-history-button {
width: 24px;
height: 24px;
@@ -555,6 +341,11 @@ canvas {
line-height: 1;
}
.history-window-copy,
.history-window-close {
padding: 8px 12px;
}
.faction-card {
border-top-color: rgba(180, 130, 255, 0.3);
cursor: default;
@@ -573,11 +364,14 @@ canvas {
border-color: rgba(127, 255, 180, 0.5);
}
.swatch {
width: 14px;
height: 48px;
border-radius: 999px;
flex: none;
.ship-card-split-line {
display: flex;
justify-content: space-between;
gap: 12px;
}
.selection-action-button {
pointer-events: auto;
}
@media (max-width: 1080px) {
@@ -587,53 +381,8 @@ canvas {
}
@media (max-width: 760px) {
.left-panel-stack {
right: 20px;
width: auto;
}
.right-panel-stack {
left: 20px;
right: 20px;
top: auto;
width: auto;
bottom: 148px;
max-height: 38vh;
overflow: auto;
}
.info-panel {
max-height: none;
overflow: visible;
}
.system-panel-section,
.detail-panel-section,
.error-strip {
width: auto;
}
.network-panel {
width: auto;
}
.performance-panel {
width: auto;
}
.ops-strip {
left: 0;
right: 0;
bottom: 0;
width: 50vw;
width: 100vw;
min-height: 120px;
}
.history-window {
left: 20px;
right: 20px;
width: auto;
max-width: calc(100vw - 40px);
max-height: calc(100vh - 40px);
}
}

View File

@@ -0,0 +1,3 @@
import { createPinia } from "pinia";
export const viewerPinia = createPinia();

View File

@@ -0,0 +1,108 @@
import { defineStore } from "pinia";
import type { Selectable } from "../../viewerTypes";
export type ViewerSelectionSource = "viewer" | "ui" | null;
export interface ViewerSelectionSummary {
id: string;
kind: Selectable["kind"];
label?: string | null;
}
export function selectionToEntityId(selection: Selectable): string {
switch (selection.kind) {
case "planet":
return `${selection.systemId}:${selection.planetIndex}`;
case "moon":
return `${selection.systemId}:${selection.planetIndex}:${selection.moonIndex}`;
default:
return selection.id;
}
}
export function entityIdToSelectable(
kind: Selectable["kind"] | null,
entityId: string | null,
): Selectable | null {
if (!kind || !entityId) {
return null;
}
if (kind === "planet") {
const [systemId, planetIndex] = entityId.split(":");
if (!systemId || planetIndex == null) {
return null;
}
return {
kind,
systemId,
planetIndex: Number(planetIndex),
};
}
if (kind === "moon") {
const [systemId, planetIndex, moonIndex] = entityId.split(":");
if (!systemId || planetIndex == null || moonIndex == null) {
return null;
}
return {
kind,
systemId,
planetIndex: Number(planetIndex),
moonIndex: Number(moonIndex),
};
}
return {
kind,
id: entityId,
} as Selectable;
}
export const useViewerSelectionStore = defineStore("viewerSelection", {
state: () => ({
selectedEntityId: null as string | null,
selectedEntityKind: null as Selectable["kind"] | null,
selectedEntityLabel: null as string | null,
hoveredEntityId: null as string | null,
inspectedEntityId: null as string | null,
selectionSource: null as ViewerSelectionSource,
}),
actions: {
selectEntity(id: string | null, source: ViewerSelectionSource = null) {
this.selectedEntityId = id;
this.selectionSource = source;
if (id == null) {
this.selectedEntityKind = null;
this.selectedEntityLabel = null;
}
},
selectSelection(selection: ViewerSelectionSummary | null, source: ViewerSelectionSource = null) {
if (!selection) {
this.clearSelection(source);
return;
}
this.selectedEntityId = selection.id;
this.selectedEntityKind = selection.kind;
this.selectedEntityLabel = selection.label ?? null;
this.selectionSource = source;
},
clearSelection(source: ViewerSelectionSource = null) {
this.selectedEntityId = null;
this.selectedEntityKind = null;
this.selectedEntityLabel = null;
this.selectionSource = source;
},
hoverEntity(id: string | null) {
this.hoveredEntityId = id;
},
inspectEntity(id: string | null) {
this.inspectedEntityId = id;
},
},
});
export type ViewerSelectionStore = ReturnType<typeof useViewerSelectionStore>;

View File

@@ -9,6 +9,7 @@ export const NAV_DISTANCE: Record<PovLevel, number> = {
// Close-orbit distance when double-clicking a planet (display units).
// 0.005 units = ~333 km from planet center in system space.
export const NAV_DISTANCE_PLANET_ORBIT = 0.005;
export const NAV_DISTANCE_SHIP_HULL = 0.0004;
export const ACTIVE_SYSTEM_DETAIL_SCALE = 10;
export const GALAXY_PARALLAX_FACTOR = 0.025;
@@ -17,8 +18,8 @@ export const PROJECTED_GALAXY_RADIUS = 65000;
export const STAR_RENDER_SCALE = 0.18;
export const PLANET_RENDER_SCALE = 0.95;
export const MOON_RENDER_SCALE = 1.1;
// 0.002 units = ~133 km — allows scrolling into low orbit around planets.
export const MIN_CAMERA_DISTANCE = 0.002;
// 0.00005 units = ~3 km — allows scrolling very close to ships and structures.
export const MIN_CAMERA_DISTANCE = 0.00005;
export const MAX_CAMERA_DISTANCE = 150000;
export interface ZoomBlend {

View File

@@ -57,6 +57,8 @@ export function createViewerControllers(host: any) {
getPovLevel: () => host.povLevel,
getSelectedItems: () => host.selectedItems,
getOrbitYaw: () => host.orbitYaw,
getFollowOrbitYaw: () => host.followOrbitYaw,
getFollowOrbitPitch: () => host.followOrbitPitch,
galaxyAnchor: host.galaxyAnchor,
systemAnchor: host.systemAnchor,
galaxyCamera: host.galaxyLayer.camera,
@@ -77,21 +79,13 @@ export function createViewerControllers(host: any) {
const presentationController = new ViewerPresentationController({
renderer: host.renderer,
hudState: host.hudState,
galaxyScene: host.galaxyLayer.scene,
galaxyCamera: host.galaxyLayer.camera,
systemCamera: host.systemLayer.camera,
galaxyAnchor: host.galaxyAnchor,
systemAnchor: host.systemAnchor,
ambienceGroup: host.universeLayer.ambienceGroup,
gameSummaryEl: host.gameSummaryEl,
networkSummaryEl: host.networkSummaryEl,
performanceSummaryEl: host.performanceSummaryEl,
statusEl: host.statusEl,
networkPanelEl: host.networkPanelEl,
performancePanelEl: host.performancePanelEl,
systemPanelEl: host.systemPanelEl,
systemTitleEl: host.systemTitleEl,
systemBodyEl: host.systemBodyEl,
networkStats: host.networkStats,
performanceStats: host.performanceStats,
getWorld: () => host.world,
@@ -135,10 +129,7 @@ export function createViewerControllers(host: any) {
getCameraTargetShipId: () => host.cameraTargetShipId,
getNetworkStats: () => host.networkStats,
getSystemSummaryVisuals: () => new Map(),
errorEl: host.errorEl,
opsStripEl: host.opsStripEl,
detailTitleEl: host.detailTitleEl,
detailBodyEl: host.detailBodyEl,
hudState: host.hudState,
worldLabel: () => host.world?.label ?? "",
rebuildSystems: (systems) => sceneDataController.rebuildSystems(systems),
syncCelestials: (celestials) => sceneDataController.syncCelestials(celestials),
@@ -164,7 +155,6 @@ export function createViewerControllers(host: any) {
});
const historyController = new ViewerHistoryWindowController({
historyLayerEl: host.historyLayerEl,
historyWindows: host.historyWindows,
getWorld: () => host.world,
getHistoryWindowCounter: () => host.historyWindowCounter,
@@ -198,13 +188,14 @@ export function createViewerControllers(host: any) {
hoverLabelEl: host.hoverLabelEl,
hoverConnectorLineEl: host.hoverConnectorLineEl,
marqueeEl: host.marqueeEl,
hudState: host.hudState,
keyState: host.keyState,
getWorld: () => host.world,
getActiveSystemId: () => host.activeSystemId,
getPovLevel: () => host.povLevel,
getSelectedItems: () => host.selectedItems,
setSelectedItems: (items) => {
host.selectedItems = items;
host.applySelectedItems(items, "viewer");
},
getDragMode: () => host.dragMode,
setDragMode: (mode) => {
@@ -240,8 +231,13 @@ export function createViewerControllers(host: any) {
getFollowCameraFocus: () => host.followCameraFocus,
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
applyOrbitDelta: (delta: THREE.Vector2) => {
host.orbitYaw += delta.x * 0.008;
host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3);
if (host.cameraMode === "follow") {
host.followOrbitYaw += delta.x * 0.008;
host.followOrbitPitch = THREE.MathUtils.clamp(host.followOrbitPitch + delta.y * 0.004, 0.02, 1.45);
} else {
host.orbitYaw += delta.x * 0.008;
host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3);
}
},
syncFollowStateFromSelection: () => navigationController.syncFollowStateFromSelection(),
updatePanels: () => host.updatePanels(),
@@ -261,20 +257,33 @@ export function createViewerControllers(host: any) {
}
export function wireViewerEvents(host: any) {
host.renderer.domElement.addEventListener("pointerdown", host.interactionController.onPointerDown);
host.renderer.domElement.addEventListener("pointermove", host.interactionController.onPointerMove);
host.renderer.domElement.addEventListener("pointerup", host.interactionController.onPointerUp);
host.renderer.domElement.addEventListener("pointerleave", host.interactionController.onPointerUp);
host.renderer.domElement.addEventListener("click", host.interactionController.onClick);
host.renderer.domElement.addEventListener("dblclick", host.interactionController.onDoubleClick);
host.renderer.domElement.addEventListener("wheel", host.interactionController.onWheel, { passive: false });
host.opsStripEl.addEventListener("click", host.interactionController.onOpsStripClick);
host.opsStripEl.addEventListener("dblclick", host.interactionController.onOpsStripDoubleClick);
const canvas = host.renderer.domElement;
canvas.addEventListener("pointerdown", host.interactionController.onPointerDown);
canvas.addEventListener("pointermove", host.interactionController.onPointerMove);
canvas.addEventListener("pointerup", host.interactionController.onPointerUp);
canvas.addEventListener("pointerleave", host.interactionController.onPointerUp);
canvas.addEventListener("click", host.interactionController.onClick);
canvas.addEventListener("dblclick", host.interactionController.onDoubleClick);
canvas.addEventListener("wheel", host.interactionController.onWheel, { passive: false });
host.historyLayerEl.addEventListener("click", host.interactionController.onHistoryLayerClick);
host.historyLayerEl.addEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown);
window.addEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove);
window.addEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp);
window.addEventListener("keydown", host.interactionController.onKeyDown);
window.addEventListener("keyup", host.interactionController.onKeyUp);
window.addEventListener("resize", host.onResize);
return () => {
canvas.removeEventListener("pointerdown", host.interactionController.onPointerDown);
canvas.removeEventListener("pointermove", host.interactionController.onPointerMove);
canvas.removeEventListener("pointerup", host.interactionController.onPointerUp);
canvas.removeEventListener("pointerleave", host.interactionController.onPointerUp);
canvas.removeEventListener("click", host.interactionController.onClick);
canvas.removeEventListener("dblclick", host.interactionController.onDoubleClick);
canvas.removeEventListener("wheel", host.interactionController.onWheel);
host.historyLayerEl.removeEventListener("click", host.interactionController.onHistoryLayerClick);
host.historyLayerEl.removeEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown);
window.removeEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove);
window.removeEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp);
window.removeEventListener("keydown", host.interactionController.onKeyDown);
window.removeEventListener("keyup", host.interactionController.onKeyUp);
};
}

View File

@@ -72,7 +72,7 @@ export function toggleCameraMode(params: {
return {
cameraMode: "follow" as const,
cameraTargetShipId: nextTargetShipId,
desiredDistance: Math.min(desiredDistance, 1800),
desiredDistance: Math.min(desiredDistance, 0.0012),
};
}
@@ -90,6 +90,8 @@ export function updateFollowCamera(params: {
followCameraOffset: THREE.Vector3;
systemAnchor: THREE.Vector3;
delta: number;
followOrbitYaw: number;
followOrbitPitch: number;
getAnimatedShipLocalPosition: (visual: ShipVisual) => THREE.Vector3;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
resolveShipHeading: (visual: ShipVisual, worldPosition: THREE.Vector3) => THREE.Vector3;
@@ -107,6 +109,8 @@ export function updateFollowCamera(params: {
followCameraOffset,
systemAnchor,
delta,
followOrbitYaw,
followOrbitPitch,
getAnimatedShipLocalPosition,
toDisplayLocalPosition,
resolveShipHeading,
@@ -160,14 +164,23 @@ export function updateFollowCamera(params: {
followCameraDirection.lerp(followCameraDesiredDirection, 1 - Math.exp(-delta * 5));
followCameraDirection.normalize();
const distance = THREE.MathUtils.clamp(currentDistance * 0.72, 320, 6800);
const height = THREE.MathUtils.clamp(distance * 0.18, 70, 1100);
const lookAhead = THREE.MathUtils.clamp(distance * 0.9, 220, 2400);
followCameraOffset.copy(followCameraDirection).multiplyScalar(-distance);
const distance = THREE.MathUtils.clamp(currentDistance * 0.72, 0.00018, 0.012);
const height = THREE.MathUtils.clamp(distance * 0.14, 0.00002, 0.0012);
const lookAhead = THREE.MathUtils.clamp(distance * 2.6, 0.0006, 0.028);
// Orbit the camera around the ship using followOrbitYaw/Pitch.
// Base direction is "behind ship" (negate heading). Yaw rotates left/right, pitch elevates.
const baseBack = followCameraDirection.clone().negate();
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), followOrbitYaw);
const orbitBack = baseBack.clone().applyQuaternion(yawQuat);
const cosP = Math.cos(followOrbitPitch), sinP = Math.sin(followOrbitPitch);
followCameraOffset.set(orbitBack.x * cosP, sinP, orbitBack.z * cosP).normalize().multiplyScalar(distance);
followCameraOffset.y += height;
const desiredPosition = shipWorldPosition.clone().add(followCameraOffset);
const desiredFocus = shipWorldPosition.clone().addScaledVector(followCameraDirection, lookAhead);
// Blend look-ahead based on how far off-axis the orbit is (full ahead when behind, ship center when in front)
const lookBlend = Math.max(0, Math.cos(followOrbitYaw));
const desiredFocus = shipWorldPosition.clone().addScaledVector(followCameraDirection, lookAhead * lookBlend);
desiredFocus.y += height * 0.28;
const positionLerp = 1 - Math.exp(-delta * 6);

View File

@@ -1,39 +1,23 @@
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
import type { HistoryWindowState } from "./viewerHudState";
import type { Selectable, WorldState } from "./viewerTypes";
export function createHistoryWindowState(
documentRef: Document,
target: Selectable,
historyWindowsCount: number,
historyWindowCounter: number,
): HistoryWindowState {
const id = `history-${historyWindowCounter}`;
const root = documentRef.createElement("aside");
root.className = "history-window";
root.dataset.historyWindowId = id;
root.innerHTML = `
<div class="history-window-header">
<h2 class="history-window-title">History</h2>
<div class="history-window-actions">
<button type="button" class="history-window-copy">Copy</button>
<button type="button" class="history-window-close">Close</button>
</div>
</div>
<div class="history-window-body">No history selected.</div>
`;
root.style.width = `${Math.min(520, window.innerWidth - 40)}px`;
root.style.height = `${Math.min(360, Math.max(240, window.innerHeight * 0.42))}px`;
root.style.left = `${Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerWidth - 580)))}px`;
root.style.top = `${Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerHeight - 420)))}px`;
return {
id,
id: `history-${historyWindowCounter}`,
target,
root,
titleEl: root.querySelector(".history-window-title") as HTMLHeadingElement,
bodyEl: root.querySelector(".history-window-body") as HTMLDivElement,
copyButtonEl: root.querySelector(".history-window-copy") as HTMLButtonElement,
title: "History",
bodyHtml: "No history selected.",
text: "",
copyLabel: "Copy",
width: Math.min(520, window.innerWidth - 40),
height: Math.min(360, Math.max(240, window.innerHeight * 0.42)),
x: Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerWidth - 580))),
y: Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerHeight - 420))),
zIndex: 1,
};
}
@@ -48,9 +32,9 @@ export function refreshHistoryWindow(
return false;
}
windowState.titleEl.textContent = `${ship.label} History`;
windowState.title = `${ship.label} History`;
windowState.text = ship.history.length > 0 ? ship.history.join("\n") : "No history yet.";
windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
windowState.bodyHtml = windowState.text.replaceAll("\n", "<br>");
return true;
}
@@ -60,9 +44,9 @@ export function refreshHistoryWindow(
return false;
}
windowState.titleEl.textContent = `${station.label} History`;
windowState.title = `${station.label} History`;
windowState.text = renderRecentEvents("station", station.id).replaceAll("<br>", "\n") || "No history yet.";
windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
windowState.bodyHtml = windowState.text.replaceAll("\n", "<br>");
return true;
}

View File

@@ -1,10 +1,10 @@
import * as THREE from "three";
import { createHistoryWindowState, refreshHistoryWindow } from "./viewerHistory";
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
import type { HistoryWindowState } from "./viewerHudState";
import type { Selectable, WorldState } from "./viewerTypes";
export function openHistoryWindow(
historyWindows: HistoryWindowState[],
historyLayerEl: HTMLDivElement,
target: Selectable,
nextCounter: number,
bringToFront: (windowState: HistoryWindowState) => void,
@@ -17,9 +17,8 @@ export function openHistoryWindow(
return nextCounter;
}
const windowState = createHistoryWindowState(document, target, historyWindows.length, nextCounter);
const windowState = createHistoryWindowState(target, historyWindows.length, nextCounter);
historyWindows.push(windowState);
historyLayerEl.append(windowState.root);
bringToFront(windowState);
refreshWindows();
return nextCounter;
@@ -56,8 +55,7 @@ export function destroyHistoryWindow(
};
}
const [removed] = historyWindows.splice(index, 1);
removed.root.remove();
historyWindows.splice(index, 1);
if (historyWindowDragId === id) {
return {
historyWindowDragId: undefined,
@@ -72,7 +70,7 @@ export function destroyHistoryWindow(
}
export function bringHistoryWindowToFront(windowState: HistoryWindowState, nextZIndex: number) {
windowState.root.style.zIndex = `${nextZIndex}`;
windowState.zIndex = nextZIndex;
}
export function beginHistoryWindowDrag(
@@ -91,9 +89,7 @@ export function beginHistoryWindowDrag(
};
}
const bounds = windowState.root.getBoundingClientRect();
historyWindowDragOffset.set(clientX - bounds.left, clientY - bounds.top);
windowState.root.setPointerCapture?.(pointerId);
historyWindowDragOffset.set(clientX - windowState.x, clientY - windowState.y);
return {
historyWindowDragId: windowId,
historyWindowDragPointerId: pointerId,
@@ -118,16 +114,12 @@ export function updateHistoryWindowDrag(
return;
}
const width = windowState.root.offsetWidth;
const height = windowState.root.offsetHeight;
const left = THREE.MathUtils.clamp(clientX - historyWindowDragOffset.x, 20, window.innerWidth - width - 20);
const top = THREE.MathUtils.clamp(clientY - historyWindowDragOffset.y, 20, window.innerHeight - height - 20);
windowState.root.style.left = `${left}px`;
windowState.root.style.top = `${top}px`;
windowState.x = THREE.MathUtils.clamp(clientX - historyWindowDragOffset.x, 20, window.innerWidth - windowState.width - 20);
windowState.y = THREE.MathUtils.clamp(clientY - historyWindowDragOffset.y, 20, window.innerHeight - windowState.height - 20);
}
export function endHistoryWindowDrag(
historyWindows: HistoryWindowState[],
_historyWindows: HistoryWindowState[],
historyWindowDragId: string | undefined,
historyWindowDragPointerId: number | undefined,
pointerId: number,
@@ -139,8 +131,6 @@ export function endHistoryWindowDrag(
};
}
const windowState = historyWindows.find((candidate) => candidate.id === historyWindowDragId);
windowState?.root.releasePointerCapture?.(pointerId);
return {
historyWindowDragId: undefined,
historyWindowDragPointerId: undefined,

View File

@@ -9,10 +9,10 @@ import {
refreshHistoryWindows,
updateHistoryWindowDrag,
} from "./viewerHistoryManager";
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
import type { HistoryWindowState } from "./viewerHudState";
import type { Selectable, WorldState } from "./viewerTypes";
export interface ViewerHistoryWindowContext {
historyLayerEl: HTMLDivElement;
historyWindows: HistoryWindowState[];
getWorld: () => WorldState | undefined;
getHistoryWindowCounter: () => number;
@@ -33,7 +33,6 @@ export class ViewerHistoryWindowController {
openHistoryWindow(target: Selectable) {
const nextCounter = openHistoryWindow(
this.context.historyWindows,
this.context.historyLayerEl,
target,
this.context.getHistoryWindowCounter() + 1,
(windowState) => this.bringHistoryWindowToFront(windowState),
@@ -155,14 +154,14 @@ export class ViewerHistoryWindowController {
try {
await copyTextToClipboard(windowState.text);
windowState.copyButtonEl.textContent = "Copied";
windowState.copyLabel = "Copied";
window.setTimeout(() => {
windowState.copyButtonEl.textContent = "Copy";
windowState.copyLabel = "Copy";
}, 1200);
} catch {
windowState.copyButtonEl.textContent = "Failed";
windowState.copyLabel = "Failed";
window.setTimeout(() => {
windowState.copyButtonEl.textContent = "Copy";
windowState.copyLabel = "Copy";
}, 1200);
}
}

View File

@@ -1,106 +0,0 @@
export interface ViewerHudElements {
root: HTMLDivElement;
gamePanelEl: HTMLDivElement;
statusEl: HTMLDivElement;
gameSummaryEl: HTMLSpanElement;
networkSectionEl: HTMLDivElement;
systemPanelEl: HTMLDivElement;
systemTitleEl: HTMLHeadingElement;
systemBodyEl: HTMLDivElement;
detailTitleEl: HTMLHeadingElement;
detailBodyEl: HTMLDivElement;
opsStripEl: HTMLDivElement;
networkSummaryEl: HTMLSpanElement;
networkPanelEl: HTMLDivElement;
performanceSectionEl: HTMLDivElement;
performanceSummaryEl: HTMLSpanElement;
performancePanelEl: HTMLDivElement;
errorEl: HTMLDivElement;
historyLayerEl: HTMLDivElement;
marqueeEl: HTMLDivElement;
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
}
export function createViewerHud(documentRef: Document): ViewerHudElements {
const root = documentRef.createElement("div");
root.className = "viewer-shell";
root.innerHTML = `
<div class="left-panel-stack">
<header class="topbar collapsible-panel is-collapsed" data-panel-name="game">
<div class="panel-heading">
<h2>Game</h2>
<div class="panel-heading-meta">
<span class="panel-summary game-summary">Bootstrapping</span>
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Game panel">+</button>
</div>
</div>
<div class="topbar-body">Bootstrapping</div>
</header>
<aside class="network-panel collapsible-panel is-collapsed" data-panel-name="network">
<div class="panel-heading">
<h2>Network</h2>
<div class="panel-heading-meta">
<span class="panel-summary network-summary">Waiting</span>
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Network panel">+</button>
</div>
</div>
<div class="network-body">Waiting for snapshot.</div>
</aside>
<aside class="performance-panel collapsible-panel is-collapsed" data-panel-name="performance">
<div class="panel-heading">
<h2>Performance</h2>
<div class="panel-heading-meta">
<span class="panel-summary performance-summary">Waiting</span>
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Performance panel">+</button>
</div>
</div>
<div class="performance-body">Waiting for frame samples.</div>
</aside>
</div>
<div class="right-panel-stack">
<aside class="info-panel system-panel-section">
<h2>System</h2>
<h3 class="system-title">Deep Space</h3>
<div class="system-body">Waiting for the authoritative snapshot.</div>
</aside>
<aside class="info-panel detail-panel-section">
<h2>Focus</h2>
<h3 class="detail-title">Nothing selected</h3>
<div class="detail-body">Waiting for the authoritative snapshot.</div>
</aside>
<div class="error-strip" hidden></div>
</div>
<div class="history-layer"></div>
<section class="ops-strip"></section>
<div class="marquee-box"></div>
<svg class="hover-connector-svg" aria-hidden="true">
<line class="hover-connector-line" x1="0" y1="0" x2="0" y2="0" hidden></line>
</svg>
<div class="hover-label" hidden></div>
`;
return {
root,
gamePanelEl: root.querySelector(".topbar") as HTMLDivElement,
statusEl: root.querySelector(".topbar-body") as HTMLDivElement,
gameSummaryEl: root.querySelector(".game-summary") as HTMLSpanElement,
networkSectionEl: root.querySelector(".network-panel") as HTMLDivElement,
systemPanelEl: root.querySelector(".system-panel-section") as HTMLDivElement,
systemTitleEl: root.querySelector(".system-title") as HTMLHeadingElement,
systemBodyEl: root.querySelector(".system-body") as HTMLDivElement,
detailTitleEl: root.querySelector(".detail-title") as HTMLHeadingElement,
detailBodyEl: root.querySelector(".detail-body") as HTMLDivElement,
opsStripEl: root.querySelector(".ops-strip") as HTMLDivElement,
networkSummaryEl: root.querySelector(".network-summary") as HTMLSpanElement,
networkPanelEl: root.querySelector(".network-body") as HTMLDivElement,
performanceSectionEl: root.querySelector(".performance-panel") as HTMLDivElement,
performanceSummaryEl: root.querySelector(".performance-summary") as HTMLSpanElement,
performancePanelEl: root.querySelector(".performance-body") as HTMLDivElement,
errorEl: root.querySelector(".error-strip") as HTMLDivElement,
historyLayerEl: root.querySelector(".history-layer") as HTMLDivElement,
marqueeEl: root.querySelector(".marquee-box") as HTMLDivElement,
hoverLabelEl: root.querySelector(".hover-label") as HTMLDivElement,
hoverConnectorLineEl: root.querySelector(".hover-connector-line") as unknown as SVGLineElement,
};
}

View File

@@ -0,0 +1,178 @@
import { reactive } from "vue";
import type { ViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { Selectable } from "./viewerTypes";
export interface HudPanelState {
collapsed: boolean;
summary: string;
bodyText: string;
}
export interface HudHtmlPanelState {
hidden: boolean;
title: string;
bodyHtml: string;
}
export interface HudErrorState {
hidden: boolean;
message: string;
}
export interface HudProgressBar {
label: string;
valueLabel: string;
progress: number;
}
export interface OpsFactionCardState {
kind: "faction";
id: string;
label: string;
stateLines: string[];
priorities: { label: string; value: string }[];
}
export interface OpsStationCardState {
kind: "station";
id: string;
label: string;
badge: string;
selected: boolean;
lines: string[];
processes: HudProgressBar[];
}
export interface OpsShipCardState {
kind: "ship";
id: string;
label: string;
badge: string;
selected: boolean;
followed: boolean;
locationLines: string[];
lines: string[];
action?: HudProgressBar;
aiLines: string[];
}
export interface OpsStripState {
factions: OpsFactionCardState[];
stations: OpsStationCardState[];
ships: OpsShipCardState[];
}
export interface HistoryWindowState {
id: string;
target: Selectable;
title: string;
bodyHtml: string;
text: string;
copyLabel: string;
x: number;
y: number;
width: number;
height: number;
zIndex: number;
}
export interface HoverLabelState {
hidden: boolean;
text: string;
x: number;
y: number;
connectorHidden: boolean;
x1: number;
y1: number;
x2: number;
y2: number;
}
export interface MarqueeState {
visible: boolean;
x: number;
y: number;
width: number;
height: number;
}
export interface ViewerHudState {
gamePanel: HudPanelState;
networkPanel: HudPanelState;
performancePanel: HudPanelState;
systemPanel: HudHtmlPanelState;
detailPanel: HudHtmlPanelState;
error: HudErrorState;
opsStrip: OpsStripState;
historyWindows: HistoryWindowState[];
hoverLabel: HoverLabelState;
marquee: MarqueeState;
}
export interface ViewerHudBindings {
state: ViewerHudState;
selectionStore: ViewerSelectionStore;
opsStripEl: HTMLDivElement;
historyLayerEl: HTMLDivElement;
marqueeEl: HTMLDivElement;
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
}
export function createViewerHudState(): ViewerHudState {
return reactive({
gamePanel: {
collapsed: true,
summary: "Bootstrapping",
bodyText: "Bootstrapping",
},
networkPanel: {
collapsed: true,
summary: "Waiting",
bodyText: "Waiting for snapshot.",
},
performancePanel: {
collapsed: true,
summary: "Waiting",
bodyText: "Waiting for frame samples.",
},
systemPanel: {
hidden: false,
title: "Deep Space",
bodyHtml: "Waiting for the authoritative snapshot.",
},
detailPanel: {
hidden: false,
title: "Nothing selected",
bodyHtml: "Waiting for the authoritative snapshot.",
},
error: {
hidden: true,
message: "",
},
opsStrip: {
factions: [],
stations: [],
ships: [],
},
historyWindows: [],
hoverLabel: {
hidden: true,
text: "",
x: 0,
y: 0,
connectorHidden: true,
x1: 0,
y1: 0,
x2: 0,
y2: 0,
},
marquee: {
visible: false,
x: 0,
y: 0,
width: 0,
height: 0,
},
});
}

View File

@@ -2,6 +2,7 @@ import * as THREE from "three";
import { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath";
import type { HoverLabelState, MarqueeState } from "./viewerHudState";
import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes";
export interface HoverPickResult {
@@ -67,6 +68,7 @@ export function pickSelectableHitAtClientPosition(
export function updateHoverLabel(params: {
dragMode?: string;
hoverState: HoverLabelState;
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
hoverPick: HoverPickResult | undefined;
@@ -77,6 +79,7 @@ export function updateHoverLabel(params: {
}) {
const {
dragMode,
hoverState,
hoverLabelEl,
hoverConnectorLineEl,
hoverPick,
@@ -87,6 +90,8 @@ export function updateHoverLabel(params: {
} = params;
if (dragMode || !hoverPick) {
hoverState.hidden = true;
hoverState.connectorHidden = true;
hoverLabelEl.hidden = true;
hoverConnectorLineEl.setAttribute("hidden", "");
return;
@@ -95,6 +100,8 @@ export function updateHoverLabel(params: {
const { selection, object, camera } = hoverPick;
const label = describeHoverLabel(world, selection);
if (!label) {
hoverState.hidden = true;
hoverState.connectorHidden = true;
hoverLabelEl.hidden = true;
hoverConnectorLineEl.setAttribute("hidden", "");
return;
@@ -102,18 +109,27 @@ export function updateHoverLabel(params: {
const distance = formatHoverDistance(camera, object, selection, povLevel, activeSystemId);
hoverState.hidden = false;
hoverState.text = `${label}\n${distance}`;
hoverState.x = point.x + 44;
hoverState.y = point.y - 90;
hoverLabelEl.hidden = false;
hoverLabelEl.textContent = `${label}\n${distance}`;
hoverLabelEl.style.left = `${point.x + 44}px`;
hoverLabelEl.style.top = `${point.y - 90}px`;
hoverLabelEl.textContent = hoverState.text;
hoverLabelEl.style.left = `${hoverState.x}px`;
hoverLabelEl.style.top = `${hoverState.y}px`;
const rect = hoverLabelEl.getBoundingClientRect();
const svgRect = (hoverConnectorLineEl.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
hoverState.connectorHidden = false;
hoverState.x1 = point.x;
hoverState.y1 = point.y;
hoverState.x2 = rect.left - svgRect.left;
hoverState.y2 = rect.top - svgRect.top + rect.height / 2;
hoverConnectorLineEl.removeAttribute("hidden");
hoverConnectorLineEl.setAttribute("x1", String(point.x));
hoverConnectorLineEl.setAttribute("y1", String(point.y));
hoverConnectorLineEl.setAttribute("x2", String(rect.left - svgRect.left));
hoverConnectorLineEl.setAttribute("y2", String(rect.top - svgRect.top + rect.height / 2));
hoverConnectorLineEl.setAttribute("x1", String(hoverState.x1));
hoverConnectorLineEl.setAttribute("y1", String(hoverState.y1));
hoverConnectorLineEl.setAttribute("x2", String(hoverState.x2));
hoverConnectorLineEl.setAttribute("y2", String(hoverState.y2));
}
function formatHoverDistance(
@@ -150,6 +166,7 @@ function formatHoverDistance(
}
export function updateMarqueeBox(
marqueeState: MarqueeState,
marqueeEl: HTMLDivElement,
dragStart: THREE.Vector2,
dragLast: THREE.Vector2,
@@ -158,13 +175,21 @@ export function updateMarqueeBox(
const minY = Math.min(dragStart.y, dragLast.y);
const maxX = Math.max(dragStart.x, dragLast.x);
const maxY = Math.max(dragStart.y, dragLast.y);
marqueeState.visible = true;
marqueeState.x = minX;
marqueeState.y = minY;
marqueeState.width = maxX - minX;
marqueeState.height = maxY - minY;
marqueeEl.style.left = `${minX}px`;
marqueeEl.style.top = `${minY}px`;
marqueeEl.style.width = `${maxX - minX}px`;
marqueeEl.style.height = `${maxY - minY}px`;
}
export function hideMarqueeBox(marqueeEl: HTMLDivElement) {
export function hideMarqueeBox(marqueeState: MarqueeState, marqueeEl: HTMLDivElement) {
marqueeState.visible = false;
marqueeState.width = 0;
marqueeState.height = 0;
marqueeEl.style.display = "none";
marqueeEl.style.width = "0";
marqueeEl.style.height = "0";

View File

@@ -12,8 +12,9 @@ import {
toggleCameraMode,
navigateFromWheel,
} from "./viewerControls";
import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT } from "./viewerConstants";
import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT, NAV_DISTANCE_SHIP_HULL } from "./viewerConstants";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import type { ViewerHudState } from "./viewerHudState";
import type {
CameraMode,
DragMode,
@@ -33,6 +34,7 @@ export interface ViewerInteractionContext {
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
marqueeEl: HTMLDivElement;
hudState: ViewerHudState;
keyState: Set<string>;
getWorld: () => WorldState | undefined;
getActiveSystemId: () => string | undefined;
@@ -109,6 +111,7 @@ export class ViewerInteractionController {
if (!this.context.getMarqueeActive() && dragDistance > 8) {
this.context.setMarqueeActive(true);
this.context.setSuppressClickSelection(true);
this.context.hudState.marquee.visible = true;
this.context.marqueeEl.style.display = "block";
}
@@ -117,7 +120,7 @@ export class ViewerInteractionController {
}
this.context.dragLast.copy(point);
updateMarqueeBox(this.context.marqueeEl, this.context.dragStart, this.context.dragLast);
updateMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl, this.context.dragStart, this.context.dragLast);
};
readonly onPointerUp = (event: PointerEvent) => {
@@ -131,7 +134,7 @@ export class ViewerInteractionController {
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
this.completeMarqueeSelection();
hideMarqueeBox(this.context.marqueeEl);
hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl);
}
this.context.setDragMode(undefined);
@@ -202,6 +205,7 @@ export class ViewerInteractionController {
this.context.syncFollowStateFromSelection();
this.context.focusOnSelection({ kind: "ship", id: shipId });
this.toggleCameraMode("follow");
this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL);
this.context.updatePanels();
this.context.updateGamePanel("Live");
return;
@@ -238,6 +242,16 @@ export class ViewerInteractionController {
this.context.syncFollowStateFromSelection();
if (selection.kind === "planet") {
this.context.setDesiredDistance(NAV_DISTANCE_PLANET_ORBIT);
this.context.updateGamePanel("Live");
return;
}
if (selection.kind === "ship") {
this.toggleCameraMode("follow");
this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL);
this.context.updatePanels();
this.context.updateGamePanel("Live");
return;
}
};
@@ -274,6 +288,7 @@ export class ViewerInteractionController {
updateHoverLabel(event: PointerEvent) {
updateHoverLabel({
dragMode: this.context.getDragMode(),
hoverState: this.context.hudState.hoverLabel,
hoverLabelEl: this.context.hoverLabelEl,
hoverConnectorLineEl: this.context.hoverConnectorLineEl,
hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY),
@@ -288,6 +303,10 @@ export class ViewerInteractionController {
this.context.historyController.refreshHistoryWindows();
}
openHistoryWindow(selection: Selectable) {
this.context.historyController.openHistoryWindow(selection);
}
toggleCameraMode(forceMode?: CameraMode) {
const nextState = toggleCameraMode({
cameraMode: this.context.getCameraMode(),

View File

@@ -39,6 +39,8 @@ export interface ViewerNavigationContext {
getPovLevel: () => PovLevel;
getSelectedItems: () => Selectable[];
getOrbitYaw: () => number;
getFollowOrbitYaw: () => number;
getFollowOrbitPitch: () => number;
galaxyAnchor: THREE.Vector3;
systemAnchor: THREE.Vector3;
galaxyCamera: THREE.PerspectiveCamera;
@@ -126,6 +128,8 @@ export class ViewerNavigationController {
followCameraOffset: this.context.followCameraOffset,
systemAnchor: this.context.systemAnchor,
delta,
followOrbitYaw: this.context.getFollowOrbitYaw(),
followOrbitPitch: this.context.getFollowOrbitPitch(),
getAnimatedShipLocalPosition,
toDisplayLocalPosition: (localPosition) => toDisplayLocalPosition(localPosition),
resolveShipHeading: (visual, worldPosition) => resolveShipHeading(visual, worldPosition, this.context.getOrbitYaw()),

View File

@@ -1,154 +1,131 @@
import type { StationSnapshot } from "./contractsInfrastructure";
import type { FactionSnapshot } from "./contractsFactions";
import { inventoryAmount } from "./viewerMath";
import type {
HudProgressBar,
OpsFactionCardState,
OpsShipCardState,
OpsStationCardState,
OpsStripState,
} from "./viewerHudState";
import { describeShipCurrentAction, describeShipLocation, describeShipObjective, describeShipState } from "./viewerSelection";
import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes";
function renderFactionCard(faction: FactionSnapshot): string {
function buildFactionCard(faction: FactionSnapshot): OpsFactionCardState {
const state = faction.goapState;
const priorities = faction.goapPriorities;
return `
<article class="ship-card faction-card" data-faction-id="${faction.id}">
<div class="ship-card-header">
<h3>${faction.label}</h3>
<span class="ship-card-badge">faction</span>
</div>
${state ? `
<div class="ship-card-ai">
<p class="ship-card-section-title">GOAP State</p>
<p>Military ${state.militaryShipCount} · Miners ${state.minerShipCount}</p>
<p>Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}</p>
<p>Systems ${state.controlledSystemCount} / ${state.targetSystemCount}</p>
<p>Factory ${state.hasShipFactory ? "yes" : "no"} · Ore ${state.oreStockpile.toFixed(0)}</p>
</div>
` : ""}
${priorities && priorities.length > 0 ? `
<div class="ship-card-ai">
<p class="ship-card-section-title">Priorities</p>
${priorities.map(p => `<p>${p.goalName} <span style="float:right">${p.priority.toFixed(0)}</span></p>`).join("")}
</div>
` : ""}
</article>
`;
return {
kind: "faction",
id: faction.id,
label: faction.label,
stateLines: state ? [
`Military ${state.militaryShipCount} · Miners ${state.minerShipCount}`,
`Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}`,
`Systems ${state.controlledSystemCount} / ${state.targetSystemCount}`,
`Factory ${state.hasShipFactory ? "yes" : "no"} · Ore ${state.oreStockpile.toFixed(0)}`,
] : [],
priorities: (faction.goapPriorities ?? []).map((entry) => ({
label: entry.goalName,
value: entry.priority.toFixed(0),
})),
};
}
function renderStationCard(station: StationSnapshot, isSelected: boolean): string {
const cargo = station.inventory.reduce((sum, e) => sum + e.amount, 0);
const processes = station.currentProcesses;
return `
<article class="ship-card station-card${isSelected ? " is-selected" : ""}" data-station-id="${station.id}">
<div class="ship-card-header">
<h3>${station.label}</h3>
<span class="ship-card-badge">${station.category}</span>
</div>
<p>${station.systemId}</p>
<p>Docked ${station.dockedShips} / ${station.dockingPads}</p>
<p>Cargo ${cargo.toFixed(0)} · Pop ${station.population.toFixed(0)}</p>
<p>Modules ${station.installedModules.length}</p>
${processes.length > 0 ? `
<div class="ship-card-ai">
${processes.map(p => `
<div class="ship-action-progress">
<div class="ship-action-progress-label">
<span>${p.label}</span>
<span>${Math.round(p.progress * 100)}%</span>
</div>
<div class="ship-action-progress-track">
<div class="ship-action-progress-fill" style="width: ${(p.progress * 100).toFixed(1)}%"></div>
</div>
</div>
`).join("")}
</div>
` : ""}
</article>
`;
function buildProgressBar(label: string, progress: number): HudProgressBar {
return {
label,
valueLabel: `${Math.round(progress * 100)}%`,
progress: Number((progress * 100).toFixed(1)),
};
}
export function renderOpsStrip(
function buildStationCard(station: StationSnapshot, isSelected: boolean): OpsStationCardState {
const cargo = station.inventory.reduce((sum, entry) => sum + entry.amount, 0);
return {
kind: "station",
id: station.id,
label: station.label,
badge: station.category,
selected: isSelected,
lines: [
station.systemId,
`Docked ${station.dockedShips} / ${station.dockingPads}`,
`Cargo ${cargo.toFixed(0)} · Pop ${station.population.toFixed(0)}`,
`Modules ${station.installedModules.length}`,
],
processes: station.currentProcesses.map((process) => buildProgressBar(process.label, process.progress)),
};
}
function buildShipCard(
world: WorldState,
ship: WorldState["ships"] extends Map<string, infer Ship> ? Ship : never,
isSelected: boolean,
isFollowed: boolean,
): OpsShipCardState {
const cargo = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
const shipLocation = describeShipLocation(world, ship);
const shipState = describeShipState(world, ship);
const shipAction = describeShipCurrentAction(ship);
return {
kind: "ship",
id: ship.id,
label: ship.label,
badge: ship.class,
selected: isSelected,
followed: isFollowed,
locationLines: [shipLocation.system, ...(shipLocation.local ? [shipLocation.local] : [])],
lines: [
`Cargo ${cargo.toFixed(0)}`,
`State ${shipState}`,
],
action: shipAction ? buildProgressBar(shipAction.label, shipAction.progress) : undefined,
aiLines: [
...(ship.commanderObjective ? [`Objective ${describeShipObjective(ship.commanderObjective)}`] : []),
`Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}`,
`Task ${ship.controllerTaskKind}`,
],
};
}
export function buildOpsStripState(
world: WorldState | undefined,
selectedItems: Selectable[],
cameraMode: CameraMode,
cameraTargetShipId?: string,
povLevel?: PovLevel,
activeSystemId?: string,
) {
): OpsStripState {
if (!world) {
return "";
return {
factions: [],
stations: [],
ships: [],
};
}
const isSystemFiltered = povLevel !== "galaxy" && activeSystemId != null;
const factionCards = [...world.factions.values()]
.sort((a, b) => a.label.localeCompare(b.label))
.map(renderFactionCard)
.join("");
const factions = [...world.factions.values()]
.sort((left, right) => left.label.localeCompare(right.label))
.map(buildFactionCard);
const stationCards = [...world.stations.values()]
const stations = [...world.stations.values()]
.filter((station) => !isSystemFiltered || station.systemId === activeSystemId)
.sort((a, b) => a.label.localeCompare(b.label))
.map((station) => {
const isSelected = selectedItems.length === 1
&& selectedItems[0].kind === "station"
&& selectedItems[0].id === station.id;
return renderStationCard(station, isSelected);
})
.join("");
.sort((left, right) => left.label.localeCompare(right.label))
.map((station) => buildStationCard(
station,
selectedItems.length === 1 && selectedItems[0].kind === "station" && selectedItems[0].id === station.id,
));
const ships = [...world.ships.values()]
.filter((ship) => !isSystemFiltered || ship.systemId === activeSystemId)
.sort((a, b) => a.label.localeCompare(b.label));
.sort((left, right) => left.label.localeCompare(right.label))
.map((ship) => buildShipCard(
world,
ship,
selectedItems.length === 1 && selectedItems[0].kind === "ship" && selectedItems[0].id === ship.id,
cameraMode === "follow" && cameraTargetShipId === ship.id,
));
const shipCards = ships
.map((ship) => {
const cargo = ship.inventory.reduce((sum, e) => sum + e.amount, 0);
const shipLocation = describeShipLocation(world, ship);
const shipState = describeShipState(world, ship);
const shipAction = describeShipCurrentAction(ship);
const isSelected = selectedItems.length === 1
&& selectedItems[0].kind === "ship"
&& selectedItems[0].id === ship.id;
const isFollowed = cameraMode === "follow" && cameraTargetShipId === ship.id;
return `
<article class="ship-card${isSelected ? " is-selected" : ""}${isFollowed ? " is-followed" : ""}" data-ship-id="${ship.id}">
<div class="ship-card-header">
<h3>${ship.label}</h3>
<div class="ship-card-meta">
<span class="ship-card-badge">${ship.class}</span>
<button
type="button"
class="ship-card-history-button"
data-history-ship-id="${ship.id}"
aria-label="Open history for ${ship.label}"
title="Open history"
>&#128340;</button>
</div>
</div>
<p>${shipLocation.system}${shipLocation.local ? `<br>${shipLocation.local}` : ""}</p>
<p>Cargo ${cargo.toFixed(0)}</p>
<p>State ${shipState}</p>
${shipAction ? `
<div class="ship-action-progress">
<div class="ship-action-progress-label">
<span>${shipAction.label}</span>
<span>${Math.round(shipAction.progress * 100)}%</span>
</div>
<div class="ship-action-progress-track">
<div class="ship-action-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
</div>
</div>
` : ""}
<div class="ship-card-ai">
${ship.commanderObjective ? `<p>Objective ${describeShipObjective(ship.commanderObjective)}</p>` : ""}
<p>Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}</p>
<p>Task ${ship.controllerTaskKind}</p>
</div>
</article>
`;
})
.join("");
return factionCards + stationCards + shipCards;
return { factions, stations, ships };
}

Some files were not shown because too many files have changed in this diff Show More