Compare commits
10 Commits
f98c47a8a7
...
cd1fe776a5
| Author | SHA1 | Date | |
|---|---|---|---|
| cd1fe776a5 | |||
| 9a5040cf1f | |||
| 07a3142316 | |||
| a281d37fb4 | |||
| 8d2a810f6b | |||
| 5c79946d57 | |||
| 792fc5619b | |||
| 3ca568c05d | |||
| 710addf1f5 | |||
| aa4a6930ba |
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SpaceGame.Simulation.Api.Contracts;
|
||||
namespace SpaceGame.Api.Economy.Contracts;
|
||||
|
||||
public sealed record MarketOrderSnapshot(
|
||||
string Id,
|
||||
30
apps/backend/Economy/Runtime/CommerceRuntimeModels.cs
Normal file
30
apps/backend/Economy/Runtime/CommerceRuntimeModels.cs
Normal 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;
|
||||
}
|
||||
403
apps/backend/Factions/AI/CommanderPlanningService.cs
Normal file
403
apps/backend/Factions/AI/CommanderPlanningService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
328
apps/backend/Factions/AI/FactionController.cs
Normal file
328
apps/backend/Factions/AI/FactionController.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
76
apps/backend/Factions/Runtime/FactionRuntimeModels.cs
Normal file
76
apps/backend/Factions/Runtime/FactionRuntimeModels.cs
Normal 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; }
|
||||
}
|
||||
22
apps/backend/GlobalUsings.cs
Normal file
22
apps/backend/GlobalUsings.cs
Normal 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;
|
||||
228
apps/backend/Industry/Planning/FactionEconomySnapshot.cs
Normal file
228
apps/backend/Industry/Planning/FactionEconomySnapshot.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
namespace SpaceGame.Api.Industry.Planning;
|
||||
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
|
||||
internal sealed class FactionEconomySnapshot
|
||||
{
|
||||
private readonly Dictionary<string, FactionCommoditySnapshot> commodities = new(StringComparer.Ordinal);
|
||||
|
||||
internal IReadOnlyDictionary<string, FactionCommoditySnapshot> Commodities => commodities;
|
||||
|
||||
internal FactionCommoditySnapshot GetCommodity(string itemId)
|
||||
{
|
||||
if (!commodities.TryGetValue(itemId, out var commodity))
|
||||
{
|
||||
commodity = new FactionCommoditySnapshot(itemId);
|
||||
commodities[itemId] = commodity;
|
||||
}
|
||||
|
||||
return commodity;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FactionCommoditySnapshot
|
||||
{
|
||||
internal FactionCommoditySnapshot(string itemId)
|
||||
{
|
||||
ItemId = itemId;
|
||||
}
|
||||
|
||||
internal string ItemId { get; }
|
||||
internal float OnHand { get; set; }
|
||||
internal float ReservedForConstruction { get; set; }
|
||||
internal float BuyBacklog { get; set; }
|
||||
internal float SellBacklog { get; set; }
|
||||
internal float Inbound { get; set; }
|
||||
internal float ProductionRatePerSecond { get; set; }
|
||||
internal float CommittedProductionRatePerSecond { get; set; }
|
||||
internal float ConsumptionRatePerSecond { get; set; }
|
||||
|
||||
internal float AvailableStock => MathF.Max(0f, OnHand + Inbound - ReservedForConstruction);
|
||||
internal float NetRatePerSecond => ProductionRatePerSecond - ConsumptionRatePerSecond;
|
||||
internal float ProjectedProductionRatePerSecond => ProductionRatePerSecond + CommittedProductionRatePerSecond;
|
||||
internal float ProjectedNetRatePerSecond => ProjectedProductionRatePerSecond - ConsumptionRatePerSecond;
|
||||
|
||||
internal float ShortageHorizonSeconds
|
||||
{
|
||||
get
|
||||
{
|
||||
if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f)
|
||||
{
|
||||
return float.PositiveInfinity;
|
||||
}
|
||||
|
||||
if (NetRatePerSecond >= -0.01f)
|
||||
{
|
||||
return float.PositiveInfinity;
|
||||
}
|
||||
|
||||
return AvailableStock / MathF.Max(0.01f, -NetRatePerSecond);
|
||||
}
|
||||
}
|
||||
|
||||
internal float ProjectedShortageHorizonSeconds
|
||||
{
|
||||
get
|
||||
{
|
||||
if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f)
|
||||
{
|
||||
return float.PositiveInfinity;
|
||||
}
|
||||
|
||||
if (ProjectedNetRatePerSecond >= -0.01f)
|
||||
{
|
||||
return float.PositiveInfinity;
|
||||
}
|
||||
|
||||
return AvailableStock / MathF.Max(0.01f, -ProjectedNetRatePerSecond);
|
||||
}
|
||||
}
|
||||
|
||||
internal float PressureScore =>
|
||||
MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound))
|
||||
+ MathF.Max(0f, ConsumptionRatePerSecond - ProductionRatePerSecond) * 120f;
|
||||
|
||||
internal float ProjectedPressureScore =>
|
||||
MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound))
|
||||
+ MathF.Max(0f, ConsumptionRatePerSecond - ProjectedProductionRatePerSecond) * 120f;
|
||||
}
|
||||
|
||||
internal static class FactionEconomyAnalyzer
|
||||
{
|
||||
internal static FactionEconomySnapshot Build(SimulationWorld world, string factionId)
|
||||
{
|
||||
var snapshot = new FactionEconomySnapshot();
|
||||
|
||||
foreach (var station in world.Stations.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)))
|
||||
{
|
||||
foreach (var (itemId, amount) in station.Inventory)
|
||||
{
|
||||
snapshot.GetCommodity(itemId).OnHand += amount;
|
||||
}
|
||||
|
||||
foreach (var laneKey in StationSimulationService.GetStationProductionLanes(world, station))
|
||||
{
|
||||
var recipe = StationSimulationService.SelectProductionRecipe(world, station, laneKey);
|
||||
if (recipe is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var throughput = StationSimulationService.GetStationProductionThroughput(world, station, recipe);
|
||||
var cyclesPerSecond = (station.WorkforceEffectiveRatio * throughput) / MathF.Max(recipe.Duration, 0.01f);
|
||||
if (cyclesPerSecond <= 0.0001f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var input in recipe.Inputs)
|
||||
{
|
||||
snapshot.GetCommodity(input.ItemId).ConsumptionRatePerSecond += input.Amount * cyclesPerSecond;
|
||||
}
|
||||
|
||||
foreach (var output in recipe.Outputs)
|
||||
{
|
||||
snapshot.GetCommodity(output.ItemId).ProductionRatePerSecond += output.Amount * cyclesPerSecond;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var order in world.MarketOrders.Where(order =>
|
||||
string.Equals(order.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& order.State != MarketOrderStateKinds.Cancelled
|
||||
&& order.RemainingAmount > 0.01f))
|
||||
{
|
||||
var commodity = snapshot.GetCommodity(order.ItemId);
|
||||
if (string.Equals(order.Kind, MarketOrderKinds.Buy, StringComparison.Ordinal))
|
||||
{
|
||||
commodity.BuyBacklog += order.RemainingAmount;
|
||||
}
|
||||
else if (string.Equals(order.Kind, MarketOrderKinds.Sell, StringComparison.Ordinal))
|
||||
{
|
||||
commodity.SellBacklog += order.RemainingAmount;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var site in world.ConstructionSites.Where(site =>
|
||||
string.Equals(site.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed))
|
||||
{
|
||||
ApplyCommittedProduction(world, snapshot, site);
|
||||
|
||||
foreach (var required in site.RequiredItems)
|
||||
{
|
||||
var remaining = MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key));
|
||||
if (remaining > 0.01f)
|
||||
{
|
||||
snapshot.GetCommodity(required.Key).ReservedForConstruction += remaining;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static void ApplyCommittedProduction(
|
||||
SimulationWorld world,
|
||||
FactionEconomySnapshot snapshot,
|
||||
ConstructionSiteRuntime site)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(site.BlueprintId)
|
||||
|| !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var recipeOutputs = world.Recipes.Values
|
||||
.Where(candidate => string.Equals(StationSimulationService.GetStationProductionLaneKey(world, candidate), site.BlueprintId, StringComparison.Ordinal))
|
||||
.SelectMany(candidate => candidate.Outputs)
|
||||
.GroupBy(output => output.ItemId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(output => output.Amount), StringComparer.Ordinal);
|
||||
if (recipeOutputs.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var materialFraction = 0f;
|
||||
var materialTerms = 0;
|
||||
foreach (var required in site.RequiredItems)
|
||||
{
|
||||
materialTerms += 1;
|
||||
materialFraction += required.Value <= 0.01f
|
||||
? 1f
|
||||
: Math.Clamp(GetConstructionDeliveredAmount(world, site, required.Key) / required.Value, 0f, 1f);
|
||||
}
|
||||
|
||||
materialFraction = materialTerms == 0 ? 1f : materialFraction / materialTerms;
|
||||
|
||||
var buildFraction = recipe.Duration <= 0.01f
|
||||
? 0f
|
||||
: Math.Clamp(site.Progress / recipe.Duration, 0f, 1f);
|
||||
var readiness = site.State switch
|
||||
{
|
||||
ConstructionSiteStateKinds.Active => 0.3f,
|
||||
ConstructionSiteStateKinds.Planned => 0.15f,
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
readiness += materialFraction * 0.45f;
|
||||
readiness += buildFraction * 0.25f;
|
||||
|
||||
if (site.AssignedConstructorShipIds.Count > 0)
|
||||
{
|
||||
readiness += 0.1f;
|
||||
}
|
||||
|
||||
readiness = Math.Clamp(readiness, 0f, 1f);
|
||||
if (readiness <= 0.01f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cyclesPerSecond = readiness / MathF.Max(recipe.Duration, 0.01f);
|
||||
foreach (var (productItemId, amount) in recipeOutputs)
|
||||
{
|
||||
snapshot.GetCommodity(productItemId).CommittedProductionRatePerSecond += amount * cyclesPerSecond;
|
||||
}
|
||||
}
|
||||
}
|
||||
492
apps/backend/Industry/Planning/FactionIndustryPlanner.cs
Normal file
492
apps/backend/Industry/Planning/FactionIndustryPlanner.cs
Normal file
@@ -0,0 +1,492 @@
|
||||
namespace SpaceGame.Api.Industry.Planning;
|
||||
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
|
||||
internal static class FactionIndustryPlanner
|
||||
{
|
||||
internal static IndustryExpansionProject? AnalyzeCommodityNeed(SimulationWorld world, string factionId, string commodityId)
|
||||
{
|
||||
if (HasActiveExpansionProject(world, factionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bottleneckCommodity = ResolveBottleneckCommodity(world, factionId, commodityId);
|
||||
var moduleId = world.ProductionGraph.GetPrimaryProducerModule(bottleneckCommodity);
|
||||
if (moduleId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetCelestial = SelectFoundationCelestial(world, factionId, bottleneckCommodity);
|
||||
if (targetCelestial is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId);
|
||||
if (supportStation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new IndustryExpansionProject(
|
||||
bottleneckCommodity,
|
||||
moduleId,
|
||||
targetCelestial.SystemId,
|
||||
targetCelestial.Id,
|
||||
supportStation.Id);
|
||||
}
|
||||
|
||||
internal static IndustryExpansionProject? AnalyzeShipyardNeed(SimulationWorld world, string factionId)
|
||||
{
|
||||
if (HasActiveExpansionProject(world, factionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const string shipyardModuleId = "module_gen_build_l_01";
|
||||
if (world.Stations.Any(station =>
|
||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& station.InstalledModules.Contains(shipyardModuleId, StringComparer.Ordinal)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!world.ModuleRecipes.TryGetValue(shipyardModuleId, out var shipyardRecipe))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bottleneckCommodity = shipyardRecipe.Inputs
|
||||
.Select(input => ResolveBottleneckCommodity(world, factionId, input.ItemId))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Select(itemId => new
|
||||
{
|
||||
ItemId = itemId,
|
||||
HasProducer = FactionHasProducerForCommodity(world, factionId, itemId),
|
||||
Pressure = GetCommodityPressure(world, factionId, itemId),
|
||||
Stockpile = GetCommodityStockpile(world, factionId, itemId),
|
||||
})
|
||||
.Where(entry => !entry.HasProducer || entry.Pressure > 0.01f || entry.Stockpile < 120f)
|
||||
.OrderByDescending(entry => !entry.HasProducer ? 1 : 0)
|
||||
.ThenByDescending(entry => entry.Pressure)
|
||||
.ThenBy(entry => entry.Stockpile)
|
||||
.Select(entry => entry.ItemId)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bottleneckCommodity))
|
||||
{
|
||||
return AnalyzeCommodityNeed(world, factionId, bottleneckCommodity);
|
||||
}
|
||||
|
||||
return CreateShipyardFoundationProject(world, factionId);
|
||||
}
|
||||
|
||||
internal static IndustryExpansionProject? CreateShipyardFoundationProject(SimulationWorld world, string factionId)
|
||||
{
|
||||
const string shipyardModuleId = "module_gen_build_l_01";
|
||||
if (HasActiveExpansionProject(world, factionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetCelestial = SelectLogisticsFoundationCelestial(world, factionId);
|
||||
if (targetCelestial is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetCelestial.SystemId);
|
||||
if (supportStation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new IndustryExpansionProject(
|
||||
"shipyard",
|
||||
shipyardModuleId,
|
||||
targetCelestial.SystemId,
|
||||
targetCelestial.Id,
|
||||
supportStation.Id);
|
||||
}
|
||||
|
||||
internal static IndustryExpansionProject? AnalyzeExpansionNeed(SimulationWorld world, string factionId)
|
||||
{
|
||||
if (HasActiveExpansionProject(world, factionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bootstrapCommodity = SelectBootstrapCommodity(world, factionId);
|
||||
if (bootstrapCommodity is not null)
|
||||
{
|
||||
var bootstrapModuleId = world.ProductionGraph.GetPrimaryProducerModule(bootstrapCommodity);
|
||||
if (bootstrapModuleId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bootstrapCelestial = SelectFoundationCelestial(world, factionId, bootstrapCommodity);
|
||||
if (bootstrapCelestial is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapCelestial.SystemId);
|
||||
if (bootstrapSupportStation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new IndustryExpansionProject(
|
||||
bootstrapCommodity,
|
||||
bootstrapModuleId,
|
||||
bootstrapCelestial.SystemId,
|
||||
bootstrapCelestial.Id,
|
||||
bootstrapSupportStation.Id);
|
||||
}
|
||||
|
||||
var commodityId = SelectCommodityToExpand(world, factionId);
|
||||
if (commodityId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var moduleId = world.ProductionGraph.GetPrimaryProducerModule(commodityId);
|
||||
if (moduleId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetCelestial = SelectFoundationCelestial(world, factionId, commodityId);
|
||||
if (targetCelestial is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId);
|
||||
if (supportStation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new IndustryExpansionProject(
|
||||
commodityId,
|
||||
moduleId,
|
||||
targetCelestial.SystemId,
|
||||
targetCelestial.Id,
|
||||
supportStation.Id);
|
||||
}
|
||||
|
||||
internal static IndustryExpansionProject? GetActiveExpansionProject(SimulationWorld world, string factionId)
|
||||
{
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(candidate.TargetKind, "station-foundation", StringComparison.Ordinal)
|
||||
&& candidate.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
|
||||
if (site is null || site.BlueprintId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var supportStationId = world.Stations
|
||||
.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal))
|
||||
.OrderByDescending(station => station.SystemId == site.SystemId ? 1 : 0)
|
||||
.ThenByDescending(station => station.Inventory.Values.Sum())
|
||||
.Select(station => station.Id)
|
||||
.FirstOrDefault();
|
||||
if (supportStationId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new IndustryExpansionProject(
|
||||
site.TargetDefinitionId,
|
||||
site.BlueprintId,
|
||||
site.SystemId,
|
||||
site.CelestialId,
|
||||
supportStationId,
|
||||
site.Id);
|
||||
}
|
||||
|
||||
internal static void EnsureExpansionSite(SimulationWorld world, string factionId, IndustryExpansionProject project)
|
||||
{
|
||||
if (project.SiteId is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var claimId = $"claim-{factionId}-{project.CelestialId}";
|
||||
if (world.Claims.All(candidate => candidate.Id != claimId))
|
||||
{
|
||||
world.Claims.Add(new ClaimRuntime
|
||||
{
|
||||
Id = claimId,
|
||||
FactionId = factionId,
|
||||
SystemId = project.SystemId,
|
||||
CelestialId = project.CelestialId,
|
||||
PlacedAtUtc = nowUtc,
|
||||
ActivatesAtUtc = nowUtc.AddSeconds(8),
|
||||
State = ClaimStateKinds.Activating,
|
||||
Health = 100f,
|
||||
});
|
||||
}
|
||||
|
||||
if (!world.ModuleRecipes.TryGetValue(project.ModuleId, out var recipe))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var siteId = $"site-{factionId}-{project.CelestialId}";
|
||||
if (world.ConstructionSites.Any(candidate => candidate.Id == siteId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var site = new ConstructionSiteRuntime
|
||||
{
|
||||
Id = siteId,
|
||||
FactionId = factionId,
|
||||
SystemId = project.SystemId,
|
||||
CelestialId = project.CelestialId,
|
||||
TargetKind = "station-foundation",
|
||||
TargetDefinitionId = project.CommodityId,
|
||||
BlueprintId = project.ModuleId,
|
||||
ClaimId = claimId,
|
||||
StationId = null,
|
||||
State = ConstructionSiteStateKinds.Planned,
|
||||
};
|
||||
|
||||
foreach (var input in recipe.Inputs)
|
||||
{
|
||||
site.RequiredItems[input.ItemId] = input.Amount;
|
||||
site.DeliveredItems[input.ItemId] = 0f;
|
||||
var orderId = $"market-order-{site.Id}-{input.ItemId}";
|
||||
site.MarketOrderIds.Add(orderId);
|
||||
world.MarketOrders.Add(new MarketOrderRuntime
|
||||
{
|
||||
Id = orderId,
|
||||
FactionId = factionId,
|
||||
StationId = project.SupportStationId,
|
||||
ConstructionSiteId = site.Id,
|
||||
Kind = MarketOrderKinds.Buy,
|
||||
ItemId = input.ItemId,
|
||||
Amount = input.Amount,
|
||||
RemainingAmount = input.Amount,
|
||||
Valuation = 1.1f,
|
||||
State = MarketOrderStateKinds.Open,
|
||||
});
|
||||
}
|
||||
|
||||
if (world.Stations.FirstOrDefault(station => station.Id == project.SupportStationId) is { } supportStation)
|
||||
{
|
||||
foreach (var orderId in site.MarketOrderIds)
|
||||
{
|
||||
supportStation.MarketOrderIds.Add(orderId);
|
||||
}
|
||||
}
|
||||
|
||||
world.ConstructionSites.Add(site);
|
||||
}
|
||||
|
||||
private static string? SelectCommodityToExpand(SimulationWorld world, string factionId)
|
||||
{
|
||||
var demandByItem = world.MarketOrders
|
||||
.Where(order =>
|
||||
string.Equals(order.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(order.Kind, MarketOrderKinds.Buy, StringComparison.Ordinal)
|
||||
&& order.RemainingAmount > 0.01f)
|
||||
.GroupBy(order => order.ItemId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(order => order.RemainingAmount), StringComparer.Ordinal);
|
||||
|
||||
if (CommanderPlanningService.FactionCommanderHasDirective(world, factionId, "produce-military-ships"))
|
||||
{
|
||||
demandByItem["hullparts"] = demandByItem.GetValueOrDefault("hullparts") + 120f;
|
||||
demandByItem["claytronics"] = demandByItem.GetValueOrDefault("claytronics") + 90f;
|
||||
}
|
||||
|
||||
return demandByItem
|
||||
.Select(entry => (ItemId: ResolveBottleneckCommodity(world, factionId, entry.Key), Score: entry.Value))
|
||||
.Where(entry => entry.ItemId is not null)
|
||||
.GroupBy(entry => entry.ItemId!, StringComparer.Ordinal)
|
||||
.Select(group => (ItemId: group.Key, Score: group.Sum(entry => entry.Score)))
|
||||
.OrderByDescending(entry => entry.Score)
|
||||
.Select(entry => entry.ItemId)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static string? SelectBootstrapCommodity(SimulationWorld world, string factionId)
|
||||
{
|
||||
if (!FactionHasProducerForCommodity(world, factionId, "refinedmetals"))
|
||||
{
|
||||
return "refinedmetals";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ResolveBottleneckCommodity(SimulationWorld world, string factionId, string itemId)
|
||||
{
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
return ResolveBottleneckCommodity(world, factionId, itemId, visited);
|
||||
}
|
||||
|
||||
private static string ResolveBottleneckCommodity(SimulationWorld world, string factionId, string itemId, HashSet<string> visited)
|
||||
{
|
||||
if (!visited.Add(itemId))
|
||||
{
|
||||
return itemId;
|
||||
}
|
||||
|
||||
var producers = world.ProductionGraph.GetProcessesForOutput(itemId);
|
||||
if (producers.Count == 0)
|
||||
{
|
||||
return itemId;
|
||||
}
|
||||
|
||||
var hasFactionProducer = producers
|
||||
.SelectMany(process => process.RequiredModuleIds)
|
||||
.Any(moduleId => world.Stations.Any(station =>
|
||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)));
|
||||
if (!hasFactionProducer)
|
||||
{
|
||||
return itemId;
|
||||
}
|
||||
|
||||
var weakestUnproducedInput = world.ProductionGraph.GetImmediateInputs(itemId)
|
||||
.Where(inputId => !FactionHasProducerForCommodity(world, factionId, inputId))
|
||||
.Select(inputId => (ItemId: inputId, Score: GetCommodityPressure(world, factionId, inputId), Stockpile: GetCommodityStockpile(world, factionId, inputId)))
|
||||
.OrderByDescending(entry => entry.Score)
|
||||
.ThenBy(entry => entry.Stockpile)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(weakestUnproducedInput.ItemId)
|
||||
&& (weakestUnproducedInput.Score > 0.01f || weakestUnproducedInput.Stockpile < 120f))
|
||||
{
|
||||
return ResolveBottleneckCommodity(world, factionId, weakestUnproducedInput.ItemId, visited);
|
||||
}
|
||||
|
||||
var weakestInput = world.ProductionGraph.GetImmediateInputs(itemId)
|
||||
.Select(inputId => (ItemId: inputId, Score: GetCommodityPressure(world, factionId, inputId)))
|
||||
.OrderByDescending(entry => entry.Score)
|
||||
.FirstOrDefault();
|
||||
|
||||
return weakestInput.Score > GetCommodityPressure(world, factionId, itemId) * 0.6f
|
||||
? ResolveBottleneckCommodity(world, factionId, weakestInput.ItemId, visited)
|
||||
: itemId;
|
||||
}
|
||||
|
||||
internal static bool FactionHasProducerForCommodity(SimulationWorld world, string factionId, string itemId)
|
||||
=> FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).ProjectedProductionRatePerSecond > 0.01f;
|
||||
|
||||
internal static IReadOnlyCollection<string> ResolveRootResourceItems(SimulationWorld world, string commodityId)
|
||||
{
|
||||
var frontier = new Queue<string>();
|
||||
var resources = new HashSet<string>(StringComparer.Ordinal);
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
frontier.Enqueue(commodityId);
|
||||
|
||||
while (frontier.Count > 0)
|
||||
{
|
||||
var current = frontier.Dequeue();
|
||||
if (!visited.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var inputs = world.ProductionGraph.GetImmediateInputs(current);
|
||||
if (inputs.Count == 0)
|
||||
{
|
||||
resources.Add(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var input in inputs)
|
||||
{
|
||||
frontier.Enqueue(input);
|
||||
}
|
||||
}
|
||||
|
||||
return resources.Count > 0 ? resources : [commodityId];
|
||||
}
|
||||
|
||||
private static bool HasActiveExpansionProject(SimulationWorld world, string factionId) =>
|
||||
world.ConstructionSites.Any(site =>
|
||||
string.Equals(site.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(site.TargetKind, "station-foundation", StringComparison.Ordinal)
|
||||
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
|
||||
|
||||
private static float GetCommodityPressure(SimulationWorld world, string factionId, string itemId)
|
||||
{
|
||||
return FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).ProjectedPressureScore;
|
||||
}
|
||||
|
||||
private static float GetCommodityStockpile(SimulationWorld world, string factionId, string itemId) =>
|
||||
FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).AvailableStock;
|
||||
|
||||
private static CelestialRuntime? SelectFoundationCelestial(SimulationWorld world, string factionId, string commodityId)
|
||||
{
|
||||
var resourceItems = ResolveRootResourceItems(world, commodityId);
|
||||
return world.Celestials
|
||||
.Where(celestial =>
|
||||
celestial.Kind == SpatialNodeKind.LagrangePoint
|
||||
&& celestial.OccupyingStructureId is null
|
||||
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed))
|
||||
.OrderByDescending(celestial => ScoreCelestial(world, factionId, celestial, resourceItems))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static CelestialRuntime? SelectLogisticsFoundationCelestial(SimulationWorld world, string factionId)
|
||||
{
|
||||
return world.Celestials
|
||||
.Where(celestial =>
|
||||
celestial.Kind == SpatialNodeKind.LagrangePoint
|
||||
&& celestial.OccupyingStructureId is null
|
||||
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed))
|
||||
.OrderByDescending(celestial => world.Stations.Count(station =>
|
||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)))
|
||||
.ThenByDescending(celestial => world.Stations
|
||||
.Where(station =>
|
||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal))
|
||||
.Sum(station => station.Inventory.Values.Sum()))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static float ScoreCelestial(SimulationWorld world, string factionId, CelestialRuntime celestial, IReadOnlyCollection<string> resourceItems)
|
||||
{
|
||||
var resourceScore = world.Nodes
|
||||
.Where(node => node.SystemId == celestial.SystemId && resourceItems.Contains(node.ItemId, StringComparer.Ordinal))
|
||||
.Sum(node => node.OreRemaining);
|
||||
var factionPresence = world.Stations.Count(station =>
|
||||
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
|
||||
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal));
|
||||
return resourceScore + (factionPresence * 5_000f);
|
||||
}
|
||||
|
||||
private static StationRuntime? SelectSupportStation(SimulationWorld world, string factionId, string moduleId, string targetSystemId)
|
||||
{
|
||||
var constructionInputs = world.ModuleRecipes.TryGetValue(moduleId, out var recipe)
|
||||
? recipe.Inputs.Select(input => input.ItemId).ToList()
|
||||
: [];
|
||||
|
||||
return world.Stations
|
||||
.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal))
|
||||
.OrderByDescending(station => station.SystemId == targetSystemId ? 1 : 0)
|
||||
.ThenByDescending(station => constructionInputs.Sum(inputId => GetInventoryAmount(station.Inventory, inputId)))
|
||||
.ThenByDescending(station => station.Inventory.Values.Sum())
|
||||
.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record IndustryExpansionProject(
|
||||
string CommodityId,
|
||||
string ModuleId,
|
||||
string SystemId,
|
||||
string CelestialId,
|
||||
string SupportStationId,
|
||||
string? SiteId = null);
|
||||
53
apps/backend/Industry/Planning/ProductionGraph.cs
Normal file
53
apps/backend/Industry/Planning/ProductionGraph.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace SpaceGame.Api.Industry.Planning;
|
||||
|
||||
public sealed class ProductionGraph
|
||||
{
|
||||
public required IReadOnlyDictionary<string, ProductionCommodityNode> Commodities { get; init; }
|
||||
public required IReadOnlyDictionary<string, ProductionProcessNode> Processes { get; init; }
|
||||
public required IReadOnlyDictionary<string, IReadOnlyList<ProductionProcessNode>> ProcessesByOutputId { get; init; }
|
||||
public required IReadOnlyDictionary<string, IReadOnlyList<ProductionProcessNode>> ProcessesByInputId { get; init; }
|
||||
public required IReadOnlyDictionary<string, IReadOnlyList<string>> OutputsByModuleId { get; init; }
|
||||
|
||||
public IReadOnlyList<ProductionProcessNode> GetProcessesForOutput(string itemId) =>
|
||||
ProcessesByOutputId.TryGetValue(itemId, out var processes) ? processes : [];
|
||||
|
||||
public IReadOnlyList<ProductionProcessNode> GetProcessesForInput(string itemId) =>
|
||||
ProcessesByInputId.TryGetValue(itemId, out var processes) ? processes : [];
|
||||
|
||||
public string? GetPrimaryProducerModule(string itemId) =>
|
||||
GetProcessesForOutput(itemId)
|
||||
.SelectMany(process => process.RequiredModuleIds)
|
||||
.FirstOrDefault();
|
||||
|
||||
public string? GetPrimaryOutputForModule(string moduleId) =>
|
||||
OutputsByModuleId.TryGetValue(moduleId, out var outputs)
|
||||
? outputs.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
public IReadOnlyList<string> GetImmediateInputs(string itemId) =>
|
||||
GetProcessesForOutput(itemId)
|
||||
.SelectMany(process => process.Inputs.Keys)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public sealed class ProductionCommodityNode
|
||||
{
|
||||
public required string ItemId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Group { get; init; }
|
||||
public required string CargoKind { get; init; }
|
||||
public List<string> ProducerProcessIds { get; } = [];
|
||||
public List<string> ConsumerProcessIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class ProductionProcessNode
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; init; }
|
||||
public required string FacilityCategory { get; init; }
|
||||
public required IReadOnlyList<string> RequiredModuleIds { get; init; }
|
||||
public required IReadOnlyDictionary<string, float> Inputs { get; init; }
|
||||
public required IReadOnlyDictionary<string, float> Outputs { get; init; }
|
||||
public required bool ProducesShip { get; init; }
|
||||
}
|
||||
105
apps/backend/Industry/Planning/ProductionGraphBuilder.cs
Normal file
105
apps/backend/Industry/Planning/ProductionGraphBuilder.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
namespace SpaceGame.Api.Industry.Planning;
|
||||
|
||||
internal static class ProductionGraphBuilder
|
||||
{
|
||||
internal static ProductionGraph Build(
|
||||
IReadOnlyCollection<ItemDefinition> items,
|
||||
IReadOnlyCollection<RecipeDefinition> recipes,
|
||||
IReadOnlyCollection<ModuleDefinition> modules)
|
||||
{
|
||||
var commodities = items.ToDictionary(
|
||||
item => item.Id,
|
||||
item => new ProductionCommodityNode
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Name = item.Name,
|
||||
Group = item.Group,
|
||||
CargoKind = item.CargoKind,
|
||||
},
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var processes = new Dictionary<string, ProductionProcessNode>(StringComparer.Ordinal);
|
||||
var processesByOutputId = new Dictionary<string, List<ProductionProcessNode>>(StringComparer.Ordinal);
|
||||
var processesByInputId = new Dictionary<string, List<ProductionProcessNode>>(StringComparer.Ordinal);
|
||||
var outputsByModuleId = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var recipe in recipes)
|
||||
{
|
||||
var outputs = recipe.Outputs
|
||||
.GroupBy(output => output.ItemId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(output => output.Amount), StringComparer.Ordinal);
|
||||
var inputs = recipe.Inputs
|
||||
.GroupBy(input => input.ItemId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(input => input.Amount), StringComparer.Ordinal);
|
||||
var process = new ProductionProcessNode
|
||||
{
|
||||
Id = recipe.Id,
|
||||
Label = recipe.Label,
|
||||
FacilityCategory = recipe.FacilityCategory,
|
||||
RequiredModuleIds = recipe.RequiredModules.ToList(),
|
||||
Inputs = inputs,
|
||||
Outputs = outputs,
|
||||
ProducesShip = recipe.ShipOutputId is not null,
|
||||
};
|
||||
|
||||
processes[process.Id] = process;
|
||||
|
||||
foreach (var output in outputs.Keys)
|
||||
{
|
||||
if (!commodities.ContainsKey(output))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
commodities[output].ProducerProcessIds.Add(process.Id);
|
||||
if (!processesByOutputId.TryGetValue(output, out var outputProcesses))
|
||||
{
|
||||
outputProcesses = [];
|
||||
processesByOutputId[output] = outputProcesses;
|
||||
}
|
||||
|
||||
outputProcesses.Add(process);
|
||||
}
|
||||
|
||||
foreach (var input in inputs.Keys)
|
||||
{
|
||||
if (!commodities.ContainsKey(input))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
commodities[input].ConsumerProcessIds.Add(process.Id);
|
||||
if (!processesByInputId.TryGetValue(input, out var inputProcesses))
|
||||
{
|
||||
inputProcesses = [];
|
||||
processesByInputId[input] = inputProcesses;
|
||||
}
|
||||
|
||||
inputProcesses.Add(process);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
if (!outputsByModuleId.TryGetValue(module.Id, out var outputs))
|
||||
{
|
||||
outputs = new HashSet<string>(StringComparer.Ordinal);
|
||||
outputsByModuleId[module.Id] = outputs;
|
||||
}
|
||||
|
||||
foreach (var product in module.Products)
|
||||
{
|
||||
outputs.Add(product);
|
||||
}
|
||||
}
|
||||
|
||||
return new ProductionGraph
|
||||
{
|
||||
Commodities = commodities,
|
||||
Processes = processes,
|
||||
ProcessesByOutputId = processesByOutputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<ProductionProcessNode>)entry.Value, StringComparer.Ordinal),
|
||||
ProcessesByInputId = processesByInputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<ProductionProcessNode>)entry.Value, StringComparer.Ordinal),
|
||||
OutputsByModuleId = outputsByModuleId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<string>)entry.Value.OrderBy(value => value, StringComparer.Ordinal).ToList(), StringComparer.Ordinal),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
namespace SpaceGame.Api.Shared.AI;
|
||||
|
||||
public abstract class GoapAction<TState>
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SpaceGame.Simulation.Api.Contracts;
|
||||
namespace SpaceGame.Api.Shared.Contracts;
|
||||
|
||||
public sealed record Vector3Dto(float X, float Y, float Z);
|
||||
|
||||
@@ -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",
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
namespace SpaceGame.Api.Shared.Runtime;
|
||||
|
||||
public static class SimulationUnits
|
||||
{
|
||||
@@ -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)
|
||||
{
|
||||
11
apps/backend/Ships/AI/IShipBehaviorState.cs
Normal file
11
apps/backend/Ships/AI/IShipBehaviorState.cs
Normal 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);
|
||||
}
|
||||
41
apps/backend/Ships/AI/ShipBehaviorStateMachine.cs
Normal file
41
apps/backend/Ships/AI/ShipBehaviorStateMachine.cs
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SpaceGame.Simulation.Api.Contracts;
|
||||
namespace SpaceGame.Api.Ships.Contracts;
|
||||
|
||||
public sealed record ShipSnapshot(
|
||||
string Id,
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
140
apps/backend/Simulation/Core/SimulationEngine.cs
Normal file
140
apps/backend/Simulation/Core/SimulationEngine.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -6,4 +6,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FastEndpoints" Version="6.*" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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,
|
||||
36
apps/backend/Stations/Runtime/ConstructionRuntimeModels.cs
Normal file
36
apps/backend/Stations/Runtime/ConstructionRuntimeModels.cs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
15
apps/backend/Universe/Api/GetWorldHandler.cs
Normal file
15
apps/backend/Universe/Api/GetWorldHandler.cs
Normal 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);
|
||||
}
|
||||
23
apps/backend/Universe/Api/GetWorldHealthHandler.cs
Normal file
23
apps/backend/Universe/Api/GetWorldHealthHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
15
apps/backend/Universe/Api/ResetWorldHandler.cs
Normal file
15
apps/backend/Universe/Api/ResetWorldHandler.cs
Normal 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);
|
||||
}
|
||||
18
apps/backend/Universe/Api/RootRedirectHandler.cs
Normal file
18
apps/backend/Universe/Api/RootRedirectHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
53
apps/backend/Universe/Api/StreamWorldHandler.cs
Normal file
53
apps/backend/Universe/Api/StreamWorldHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SpaceGame.Simulation.Api.Contracts;
|
||||
namespace SpaceGame.Api.Universe.Contracts;
|
||||
|
||||
public sealed record StarSnapshot(
|
||||
string Kind,
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SpaceGame.Simulation.Api.Contracts;
|
||||
namespace SpaceGame.Api.Universe.Contracts;
|
||||
|
||||
public sealed record WorldSnapshot(
|
||||
string Label,
|
||||
@@ -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; }
|
||||
@@ -1,6 +1,5 @@
|
||||
using SpaceGame.Simulation.Api.Data;
|
||||
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
namespace SpaceGame.Api.Universe.Runtime;
|
||||
|
||||
public sealed class SystemRuntime
|
||||
{
|
||||
305
apps/backend/Universe/Scenario/DataCatalogLoader.cs
Normal file
305
apps/backend/Universe/Scenario/DataCatalogLoader.cs
Normal 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);
|
||||
171
apps/backend/Universe/Scenario/LoaderSupport.cs
Normal file
171
apps/backend/Universe/Scenario/LoaderSupport.cs
Normal 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);
|
||||
}
|
||||
26
apps/backend/Universe/Scenario/ScenarioLoader.cs
Normal file
26
apps/backend/Universe/Scenario/ScenarioLoader.cs
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
225
apps/backend/Universe/Scenario/WorldBuilder.cs
Normal file
225
apps/backend/Universe/Scenario/WorldBuilder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
namespace SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
public sealed class OrbitalSimulationOptions
|
||||
{
|
||||
@@ -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)
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
namespace SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
public sealed class SimulationHostedService(WorldService worldService) : BackgroundService
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
namespace SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
public sealed class WorldGenerationOptions
|
||||
{
|
||||
@@ -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);
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"WorldGeneration": {
|
||||
"TargetSystemCount": 1,
|
||||
"TargetSystemCount": 3,
|
||||
"IncludeSolSystem": true
|
||||
},
|
||||
"OrbitalSimulation": {
|
||||
|
||||
1031
apps/viewer/package-lock.json
generated
1031
apps/viewer/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
197
apps/viewer/src/App.vue
Normal 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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
40
apps/viewer/src/components/CollapsibleHudPanel.vue
Normal file
40
apps/viewer/src/components/CollapsibleHudPanel.vue
Normal 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>
|
||||
25
apps/viewer/src/components/HtmlInfoPanel.vue
Normal file
25
apps/viewer/src/components/HtmlInfoPanel.vue
Normal 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>
|
||||
88
apps/viewer/src/components/ViewerHistoryLayer.vue
Normal file
88
apps/viewer/src/components/ViewerHistoryLayer.vue
Normal 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>
|
||||
184
apps/viewer/src/components/ViewerOpsStrip.vue
Normal file
184
apps/viewer/src/components/ViewerOpsStrip.vue
Normal 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 })"
|
||||
>
|
||||
🕔
|
||||
</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>
|
||||
@@ -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
8
apps/viewer/src/env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
52
apps/viewer/src/runtime/rendering/ViewerRenderSurface.ts
Normal file
52
apps/viewer/src/runtime/rendering/ViewerRenderSurface.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
35
apps/viewer/src/runtime/rendering/disposeThreeResources.ts
Normal file
35
apps/viewer/src/runtime/rendering/disposeThreeResources.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
2
apps/viewer/src/styles/index.css
Normal file
2
apps/viewer/src/styles/index.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@import "./viewer.css";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
3
apps/viewer/src/ui/stores/pinia.ts
Normal file
3
apps/viewer/src/ui/stores/pinia.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
export const viewerPinia = createPinia();
|
||||
108
apps/viewer/src/ui/stores/viewerSelection.ts
Normal file
108
apps/viewer/src/ui/stores/viewerSelection.ts
Normal 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>;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
178
apps/viewer/src/viewerHudState.ts
Normal file
178
apps/viewer/src/viewerHudState.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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"
|
||||
>🕔</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
Reference in New Issue
Block a user