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;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace SpaceGame.Simulation.Api.Data;
|
namespace SpaceGame.Api.Definitions;
|
||||||
|
|
||||||
public sealed class ConstructionDefinition
|
public sealed class ConstructionDefinition
|
||||||
{
|
{
|
||||||
@@ -216,6 +216,7 @@ public sealed class ModuleDefinition
|
|||||||
[JsonPropertyName("product")]
|
[JsonPropertyName("product")]
|
||||||
public List<string> ProductIds
|
public List<string> ProductIds
|
||||||
{
|
{
|
||||||
|
get => Products;
|
||||||
set => Products = value ?? [];
|
set => Products = value ?? [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,6 +293,7 @@ public sealed class InitialStationDefinition
|
|||||||
public required string SystemId { get; set; }
|
public required string SystemId { get; set; }
|
||||||
public string Label { get; set; } = "Orbital Station";
|
public string Label { get; set; } = "Orbital Station";
|
||||||
public string Color { get; set; } = "#8df0d2";
|
public string Color { get; set; } = "#8df0d2";
|
||||||
|
public string Objective { get; set; } = "general";
|
||||||
public List<string> StartingModules { get; set; } = [];
|
public List<string> StartingModules { get; set; } = [];
|
||||||
public string? FactionId { get; set; }
|
public string? FactionId { get; set; }
|
||||||
public int? PlanetIndex { get; set; }
|
public int? PlanetIndex { get; set; }
|
||||||
@@ -306,6 +308,7 @@ public sealed class ShipFormationDefinition
|
|||||||
public required float[] Center { get; set; }
|
public required float[] Center { get; set; }
|
||||||
public required string SystemId { get; set; }
|
public required string SystemId { get; set; }
|
||||||
public string? FactionId { get; set; }
|
public string? FactionId { get; set; }
|
||||||
|
public Dictionary<string, float> StartingInventory { get; set; } = new(StringComparer.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class PatrolRouteDefinition
|
public sealed class PatrolRouteDefinition
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace SpaceGame.Simulation.Api.Contracts;
|
namespace SpaceGame.Api.Economy.Contracts;
|
||||||
|
|
||||||
public sealed record MarketOrderSnapshot(
|
public sealed record MarketOrderSnapshot(
|
||||||
string Id,
|
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(
|
public sealed record FactionGoapStateSnapshot(
|
||||||
int MilitaryShipCount,
|
int MilitaryShipCount,
|
||||||
@@ -9,7 +9,15 @@ public sealed record FactionGoapStateSnapshot(
|
|||||||
int TargetSystemCount,
|
int TargetSystemCount,
|
||||||
bool HasShipFactory,
|
bool HasShipFactory,
|
||||||
float OreStockpile,
|
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);
|
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 FastEndpoints;
|
||||||
using SpaceGame.Simulation.Api.Simulation;
|
using SpaceGame.Api.Universe.Simulation;
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
var sseJsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
|
||||||
|
|
||||||
builder.WebHost.UseUrls("http://127.0.0.1:5079");
|
builder.WebHost.UseUrls("http://127.0.0.1:5079");
|
||||||
builder.Services.AddCors((options) =>
|
builder.Services.AddCors((options) =>
|
||||||
@@ -18,61 +16,13 @@ builder.Services.AddCors((options) =>
|
|||||||
});
|
});
|
||||||
builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration"));
|
builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration"));
|
||||||
builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
|
builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
|
||||||
|
builder.Services.AddFastEndpoints();
|
||||||
builder.Services.AddSingleton<WorldService>();
|
builder.Services.AddSingleton<WorldService>();
|
||||||
builder.Services.AddHostedService<SimulationHostedService>();
|
builder.Services.AddHostedService<SimulationHostedService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
|
app.UseFastEndpoints();
|
||||||
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.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
namespace SpaceGame.Simulation.Api.Simulation;
|
|
||||||
|
namespace SpaceGame.Api.Shared.AI;
|
||||||
|
|
||||||
public abstract class GoapAction<TState>
|
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);
|
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
|
public enum SpatialNodeKind
|
||||||
{
|
{
|
||||||
@@ -48,6 +48,7 @@ public enum ShipState
|
|||||||
DeliveringConstruction,
|
DeliveringConstruction,
|
||||||
Blocked,
|
Blocked,
|
||||||
Undocking,
|
Undocking,
|
||||||
|
EngagingTarget,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ControllerTaskKind
|
public enum ControllerTaskKind
|
||||||
@@ -60,6 +61,7 @@ public enum ControllerTaskKind
|
|||||||
Unload,
|
Unload,
|
||||||
DeliverConstruction,
|
DeliverConstruction,
|
||||||
BuildConstructionSite,
|
BuildConstructionSite,
|
||||||
|
AttackTarget,
|
||||||
|
|
||||||
ConstructModule,
|
ConstructModule,
|
||||||
Undock,
|
Undock,
|
||||||
@@ -210,6 +212,7 @@ public static class SimulationEnumMappings
|
|||||||
ShipState.DeliveringConstruction => "delivering-construction",
|
ShipState.DeliveringConstruction => "delivering-construction",
|
||||||
ShipState.Blocked => "blocked",
|
ShipState.Blocked => "blocked",
|
||||||
ShipState.Undocking => "undocking",
|
ShipState.Undocking => "undocking",
|
||||||
|
ShipState.EngagingTarget => "engaging-target",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(state), state, null),
|
_ => throw new ArgumentOutOfRangeException(nameof(state), state, null),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -223,6 +226,7 @@ public static class SimulationEnumMappings
|
|||||||
ControllerTaskKind.Unload => "unload",
|
ControllerTaskKind.Unload => "unload",
|
||||||
ControllerTaskKind.DeliverConstruction => "deliver-construction",
|
ControllerTaskKind.DeliverConstruction => "deliver-construction",
|
||||||
ControllerTaskKind.BuildConstructionSite => "build-construction-site",
|
ControllerTaskKind.BuildConstructionSite => "build-construction-site",
|
||||||
|
ControllerTaskKind.AttackTarget => "attack-target",
|
||||||
|
|
||||||
ControllerTaskKind.ConstructModule => "construct-module",
|
ControllerTaskKind.ConstructModule => "construct-module",
|
||||||
ControllerTaskKind.Undock => "undock",
|
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));
|
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));
|
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))
|
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var definition))
|
||||||
{
|
{
|
||||||
@@ -28,7 +26,7 @@ public sealed partial class SimulationEngine
|
|||||||
station.Radius = GetStationRadius(world, station);
|
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
|
var totalArea = station.Modules
|
||||||
.Select(module => world.ModuleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f)
|
.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)));
|
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
|
var baseCapacity = storageClass switch
|
||||||
{
|
{
|
||||||
@@ -60,13 +58,13 @@ public sealed partial class SimulationEngine
|
|||||||
return baseCapacity + moduleCapacity;
|
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));
|
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;
|
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)
|
if (amount <= 0f)
|
||||||
{
|
{
|
||||||
@@ -76,7 +74,7 @@ public sealed partial class SimulationEngine
|
|||||||
inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId) + amount;
|
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 current = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId);
|
||||||
var removed = MathF.Min(current, amount);
|
var removed = MathF.Min(current, amount);
|
||||||
@@ -93,18 +91,18 @@ public sealed partial class SimulationEngine
|
|||||||
return removed;
|
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)));
|
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")
|
HasShipCapabilities(ship.Definition, "mining")
|
||||||
&& world.ItemDefinitions.TryGetValue(node.ItemId, out var item)
|
&& world.ItemDefinitions.TryGetValue(node.ItemId, out var item)
|
||||||
&& string.Equals(item.CargoKind, ship.Definition.CargoKind, StringComparison.Ordinal);
|
&& 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);
|
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)
|
if (workforceRequired <= 0.01f)
|
||||||
{
|
{
|
||||||
@@ -115,7 +113,7 @@ public sealed partial class SimulationEngine
|
|||||||
return 0.1f + (0.9f * staffedRatio);
|
return 0.1f + (0.9f * staffedRatio);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? GetStorageRequirement(string storageClass) =>
|
internal static string? GetStorageRequirement(string storageClass) =>
|
||||||
storageClass switch
|
storageClass switch
|
||||||
{
|
{
|
||||||
"solid" => "module_arg_stor_solid_m_01",
|
"solid" => "module_arg_stor_solid_m_01",
|
||||||
@@ -123,7 +121,7 @@ public sealed partial class SimulationEngine
|
|||||||
_ => null,
|
_ => 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))
|
if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
|
||||||
{
|
{
|
||||||
@@ -156,15 +154,15 @@ public sealed partial class SimulationEngine
|
|||||||
return accepted;
|
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);
|
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 =>
|
world.ConstructionSites.FirstOrDefault(site =>
|
||||||
string.Equals(site.StationId, stationId, StringComparison.Ordinal)
|
string.Equals(site.StationId, stationId, StringComparison.Ordinal)
|
||||||
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
|
&& 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
|
if (site.StationId is not null
|
||||||
&& world.Stations.FirstOrDefault(candidate => candidate.Id == site.StationId) is { } station)
|
&& world.Stations.FirstOrDefault(candidate => candidate.Id == site.StationId) is { } station)
|
||||||
@@ -175,6 +173,9 @@ public sealed partial class SimulationEngine
|
|||||||
return GetInventoryAmount(site.DeliveredItems, itemId);
|
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);
|
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
|
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)
|
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
|
internal sealed class IdleShipBehaviorState : IShipBehaviorState
|
||||||
{
|
{
|
||||||
@@ -92,6 +93,9 @@ internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState
|
|||||||
case ("dock", "docked"):
|
case ("dock", "docked"):
|
||||||
ship.DefaultBehavior.Phase = "unload";
|
ship.DefaultBehavior.Phase = "unload";
|
||||||
break;
|
break;
|
||||||
|
case ("unload", "unloaded"):
|
||||||
|
ship.DefaultBehavior.Phase = "undock";
|
||||||
|
break;
|
||||||
case ("undock", "undocked"):
|
case ("undock", "undocked"):
|
||||||
ship.DefaultBehavior.Phase = "travel-to-node";
|
ship.DefaultBehavior.Phase = "travel-to-node";
|
||||||
ship.DefaultBehavior.NodeId = null;
|
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 ────────────────────────────────────────────────────────────
|
// ─── Planning State ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -8,6 +9,13 @@ public sealed class ShipPlanningState
|
|||||||
public bool HasMiningCapability { get; set; }
|
public bool HasMiningCapability { get; set; }
|
||||||
public bool FactionWantsOre { get; set; }
|
public bool FactionWantsOre { get; set; }
|
||||||
public bool FactionWantsExpansion { 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 string? CurrentObjective { get; set; }
|
||||||
|
|
||||||
public ShipPlanningState Clone() => (ShipPlanningState)MemberwiseClone();
|
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 sealed class SetConstructionObjectiveAction : GoapAction<ShipPlanningState>
|
||||||
{
|
{
|
||||||
public override string Name => "set-construction-objective";
|
public override string Name => "set-construction-objective";
|
||||||
public override float Cost => 1f;
|
public override float Cost => 1f;
|
||||||
|
|
||||||
public override bool CheckPreconditions(ShipPlanningState state) =>
|
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)
|
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 sealed class SetIdleObjectiveAction : GoapAction<ShipPlanningState>
|
||||||
{
|
{
|
||||||
public override string Name => "set-idle-objective";
|
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(
|
public sealed record ShipSnapshot(
|
||||||
string Id,
|
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
|
public sealed class ShipRuntime
|
||||||
{
|
{
|
||||||
@@ -19,7 +18,7 @@ public sealed class ShipRuntime
|
|||||||
public float ActionTimer { get; set; }
|
public float ActionTimer { get; set; }
|
||||||
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
|
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 int? AssignedDockingPadIndex { get; set; }
|
||||||
public string? CommanderId { get; set; }
|
public string? CommanderId { get; set; }
|
||||||
public string? PolicySetId { get; set; }
|
public string? PolicySetId { get; set; }
|
||||||
@@ -43,6 +42,8 @@ public sealed class DefaultBehaviorRuntime
|
|||||||
{
|
{
|
||||||
public required string Kind { get; set; }
|
public required string Kind { get; set; }
|
||||||
public string? AreaSystemId { get; set; }
|
public string? AreaSystemId { get; set; }
|
||||||
|
public string? TargetEntityId { get; set; }
|
||||||
|
public string? ItemId { get; set; }
|
||||||
public string? StationId { get; set; }
|
public string? StationId { get; set; }
|
||||||
public string? RefineryId { get; set; }
|
public string? RefineryId { get; set; }
|
||||||
public string? NodeId { 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) =>
|
private static CommanderRuntime? GetShipCommander(SimulationWorld world, ShipRuntime ship) =>
|
||||||
ship.CommanderId is null
|
ship.CommanderId is null
|
||||||
? null
|
? null
|
||||||
@@ -15,6 +18,8 @@ public sealed partial class SimulationEngine
|
|||||||
{
|
{
|
||||||
ship.DefaultBehavior.Kind = commander.ActiveBehavior.Kind;
|
ship.DefaultBehavior.Kind = commander.ActiveBehavior.Kind;
|
||||||
ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior.AreaSystemId;
|
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.ModuleId = commander.ActiveBehavior.ModuleId;
|
||||||
ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId;
|
ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId;
|
||||||
ship.DefaultBehavior.Phase = commander.ActiveBehavior.Phase;
|
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 ??= new CommanderBehaviorRuntime { Kind = ship.DefaultBehavior.Kind };
|
||||||
commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind;
|
commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind;
|
||||||
commander.ActiveBehavior.AreaSystemId = ship.DefaultBehavior.AreaSystemId;
|
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.ModuleId = ship.DefaultBehavior.ModuleId;
|
||||||
commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId;
|
commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId;
|
||||||
commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase;
|
commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase;
|
||||||
@@ -91,7 +98,7 @@ public sealed partial class SimulationEngine
|
|||||||
commander.ActiveTask.Threshold = ship.ControllerTask.Threshold;
|
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);
|
var commander = GetShipCommander(world, ship);
|
||||||
if (commander is not null)
|
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);
|
var commander = GetShipCommander(world, ship);
|
||||||
if (ship.Order is not null)
|
if (ship.Order is not null)
|
||||||
@@ -133,28 +140,197 @@ public sealed partial class SimulationEngine
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_shipBehaviorStateMachine.Plan(this, ship, world);
|
_shipBehaviorStateMachine.Plan(engine, ship, world);
|
||||||
SyncCommanderTask(commander, ship.ControllerTask);
|
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)
|
internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule)
|
||||||
{
|
{
|
||||||
var behavior = ship.DefaultBehavior;
|
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;
|
behavior.StationId = refinery?.Id;
|
||||||
var node = behavior.NodeId is null
|
var node = behavior.NodeId is null
|
||||||
? world.Nodes
|
? world.Nodes
|
||||||
.Where(candidate =>
|
.Where(candidate =>
|
||||||
(behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) &&
|
candidate.ItemId == targetResourceItemId &&
|
||||||
candidate.ItemId == resourceItemId &&
|
candidate.OreRemaining > 0.01f &&
|
||||||
candidate.OreRemaining > 0.01f)
|
CanShipMineItem(world, ship, candidate.ItemId))
|
||||||
.OrderByDescending(candidate => candidate.OreRemaining)
|
.OrderByDescending(candidate => candidate.SystemId == behavior.AreaSystemId ? 1 : 0)
|
||||||
|
.ThenByDescending(candidate => candidate.OreRemaining)
|
||||||
.FirstOrDefault()
|
.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))
|
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);
|
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
|
||||||
return;
|
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)
|
internal static StationRuntime? SelectBestBuyStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId)
|
||||||
{
|
{
|
||||||
var preferred = preferredStationId is null
|
var preferred = preferredStationId is null
|
||||||
@@ -264,7 +489,8 @@ public sealed partial class SimulationEngine
|
|||||||
order.ItemId == itemId &&
|
order.ItemId == itemId &&
|
||||||
order.RemainingAmount > 0.01f)
|
order.RemainingAmount > 0.01f)
|
||||||
.Select(order => (Order: order, Station: world.Stations.FirstOrDefault(station => station.Id == order.StationId)))
|
.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 =>
|
.OrderByDescending(entry =>
|
||||||
{
|
{
|
||||||
var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f;
|
var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f;
|
||||||
@@ -272,7 +498,18 @@ public sealed partial class SimulationEngine
|
|||||||
})
|
})
|
||||||
.FirstOrDefault();
|
.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) =>
|
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 behavior = ship.DefaultBehavior;
|
||||||
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId);
|
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)
|
if (station is null)
|
||||||
{
|
{
|
||||||
behavior.Kind = "idle";
|
behavior.Kind = "idle";
|
||||||
@@ -325,6 +564,13 @@ public sealed partial class SimulationEngine
|
|||||||
return;
|
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);
|
var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world);
|
||||||
behavior.ModuleId = moduleId;
|
behavior.ModuleId = moduleId;
|
||||||
if (moduleId is null)
|
if (moduleId is null)
|
||||||
@@ -344,13 +590,17 @@ public sealed partial class SimulationEngine
|
|||||||
|
|
||||||
ship.DockedStationId = null;
|
ship.DockedStationId = null;
|
||||||
ship.AssignedDockingPadIndex = null;
|
ship.AssignedDockingPadIndex = null;
|
||||||
ship.Position = GetConstructionHoldPosition(station, ship.Id);
|
ship.Position = ResolveConstructionHoldPosition(ship, station, site, world);
|
||||||
ship.TargetPosition = ship.Position;
|
ship.TargetPosition = ship.Position;
|
||||||
}
|
}
|
||||||
|
|
||||||
var constructionHoldPosition = GetConstructionHoldPosition(station, ship.Id);
|
var constructionHoldPosition = ResolveConstructionHoldPosition(ship, station, site, world);
|
||||||
var isAtConstructionHold = ship.SystemId == station.SystemId
|
var targetSystemId = site?.SystemId ?? station.SystemId;
|
||||||
&& ship.Position.DistanceTo(constructionHoldPosition) <= 10f;
|
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)
|
if (isAtConstructionHold)
|
||||||
{
|
{
|
||||||
@@ -387,7 +637,7 @@ public sealed partial class SimulationEngine
|
|||||||
{
|
{
|
||||||
Kind = ControllerTaskKind.ConstructModule,
|
Kind = ControllerTaskKind.ConstructModule,
|
||||||
TargetEntityId = station.Id,
|
TargetEntityId = station.Id,
|
||||||
TargetSystemId = station.SystemId,
|
TargetSystemId = targetSystemId,
|
||||||
TargetPosition = constructionHoldPosition,
|
TargetPosition = constructionHoldPosition,
|
||||||
Threshold = 10f,
|
Threshold = 10f,
|
||||||
};
|
};
|
||||||
@@ -397,7 +647,7 @@ public sealed partial class SimulationEngine
|
|||||||
{
|
{
|
||||||
Kind = ControllerTaskKind.DeliverConstruction,
|
Kind = ControllerTaskKind.DeliverConstruction,
|
||||||
TargetEntityId = site?.Id,
|
TargetEntityId = site?.Id,
|
||||||
TargetSystemId = station.SystemId,
|
TargetSystemId = targetSystemId,
|
||||||
TargetPosition = constructionHoldPosition,
|
TargetPosition = constructionHoldPosition,
|
||||||
Threshold = 10f,
|
Threshold = 10f,
|
||||||
};
|
};
|
||||||
@@ -407,7 +657,7 @@ public sealed partial class SimulationEngine
|
|||||||
{
|
{
|
||||||
Kind = ControllerTaskKind.BuildConstructionSite,
|
Kind = ControllerTaskKind.BuildConstructionSite,
|
||||||
TargetEntityId = site?.Id,
|
TargetEntityId = site?.Id,
|
||||||
TargetSystemId = station.SystemId,
|
TargetSystemId = targetSystemId,
|
||||||
TargetPosition = constructionHoldPosition,
|
TargetPosition = constructionHoldPosition,
|
||||||
Threshold = 10f,
|
Threshold = 10f,
|
||||||
};
|
};
|
||||||
@@ -416,8 +666,8 @@ public sealed partial class SimulationEngine
|
|||||||
ship.ControllerTask = new ControllerTaskRuntime
|
ship.ControllerTask = new ControllerTaskRuntime
|
||||||
{
|
{
|
||||||
Kind = ControllerTaskKind.Idle,
|
Kind = ControllerTaskKind.Idle,
|
||||||
TargetEntityId = station.Id,
|
TargetEntityId = site?.Id ?? station.Id,
|
||||||
TargetSystemId = station.SystemId,
|
TargetSystemId = targetSystemId,
|
||||||
TargetPosition = constructionHoldPosition,
|
TargetPosition = constructionHoldPosition,
|
||||||
Threshold = 0f,
|
Threshold = 0f,
|
||||||
};
|
};
|
||||||
@@ -426,8 +676,8 @@ public sealed partial class SimulationEngine
|
|||||||
ship.ControllerTask = new ControllerTaskRuntime
|
ship.ControllerTask = new ControllerTaskRuntime
|
||||||
{
|
{
|
||||||
Kind = ControllerTaskKind.Travel,
|
Kind = ControllerTaskKind.Travel,
|
||||||
TargetEntityId = station.Id,
|
TargetEntityId = site?.Id ?? station.Id,
|
||||||
TargetSystemId = station.SystemId,
|
TargetSystemId = targetSystemId,
|
||||||
TargetPosition = constructionHoldPosition,
|
TargetPosition = constructionHoldPosition,
|
||||||
Threshold = 10f,
|
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);
|
var commander = GetShipCommander(world, ship);
|
||||||
if (ship.Order is not null && controllerEvent == "arrived")
|
if (ship.Order is not null && controllerEvent == "arrived")
|
||||||
@@ -458,7 +708,7 @@ public sealed partial class SimulationEngine
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_shipBehaviorStateMachine.ApplyEvent(this, ship, world, controllerEvent);
|
_shipBehaviorStateMachine.ApplyEvent(engine, ship, world, controllerEvent);
|
||||||
if (commander is not null)
|
if (commander is not null)
|
||||||
{
|
{
|
||||||
SyncShipToCommander(ship, commander);
|
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}";
|
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)
|
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()
|
new()
|
||||||
{
|
{
|
||||||
Kind = ControllerTaskKind.Idle,
|
Kind = ControllerTaskKind.Idle,
|
||||||
@@ -505,6 +786,7 @@ public sealed partial class SimulationEngine
|
|||||||
"unload" => ControllerTaskKind.Unload,
|
"unload" => ControllerTaskKind.Unload,
|
||||||
"deliver-construction" => ControllerTaskKind.DeliverConstruction,
|
"deliver-construction" => ControllerTaskKind.DeliverConstruction,
|
||||||
"build-construction-site" => ControllerTaskKind.BuildConstructionSite,
|
"build-construction-site" => ControllerTaskKind.BuildConstructionSite,
|
||||||
|
"attack-target" => ControllerTaskKind.AttackTarget,
|
||||||
|
|
||||||
"construct-module" => ControllerTaskKind.ConstructModule,
|
"construct-module" => ControllerTaskKind.ConstructModule,
|
||||||
"undock" => ControllerTaskKind.Undock,
|
"undock" => ControllerTaskKind.Undock,
|
||||||
@@ -529,4 +811,62 @@ public sealed partial class SimulationEngine
|
|||||||
Threshold = task.Threshold,
|
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)
|
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) =>
|
internal static float GetShipCargoAmount(ShipRuntime ship) =>
|
||||||
ship.Inventory.Values.Sum();
|
SimulationRuntimeSupport.GetShipCargoAmount(ship);
|
||||||
|
|
||||||
private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||||
{
|
{
|
||||||
@@ -163,10 +166,12 @@ public sealed partial class SimulationEngine
|
|||||||
BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship));
|
BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship));
|
||||||
|
|
||||||
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId);
|
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId);
|
||||||
|
var transferredAny = false;
|
||||||
foreach (var (itemId, amount) in ship.Inventory.ToList())
|
foreach (var (itemId, amount) in ship.Inventory.ToList())
|
||||||
{
|
{
|
||||||
var moved = MathF.Min(amount, world.Balance.TransferRate * deltaSeconds);
|
var moved = MathF.Min(amount, world.Balance.TransferRate * deltaSeconds);
|
||||||
var accepted = TryAddStationInventory(world, station, itemId, moved);
|
var accepted = TryAddStationInventory(world, station, itemId, moved);
|
||||||
|
transferredAny |= accepted > 0.01f;
|
||||||
RemoveInventory(ship.Inventory, itemId, accepted);
|
RemoveInventory(ship.Inventory, itemId, accepted);
|
||||||
if (faction is not null && string.Equals(itemId, "ore", StringComparison.Ordinal))
|
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";
|
return GetShipCargoAmount(ship) <= 0.01f ? "unloaded" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +247,7 @@ public sealed partial class SimulationEngine
|
|||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
var supportPosition = ResolveShipSupportPosition(ship, station);
|
var supportPosition = ResolveShipSupportPosition(ship, station, null, world);
|
||||||
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
||||||
{
|
{
|
||||||
ship.State = ShipState.LocalFlight;
|
ship.State = ShipState.LocalFlight;
|
||||||
@@ -293,7 +304,7 @@ public sealed partial class SimulationEngine
|
|||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
var supportPosition = ResolveShipSupportPosition(ship, station);
|
var supportPosition = ResolveShipSupportPosition(ship, station, site, world);
|
||||||
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
||||||
{
|
{
|
||||||
ship.State = ShipState.LocalFlight;
|
ship.State = ShipState.LocalFlight;
|
||||||
@@ -310,6 +321,28 @@ public sealed partial class SimulationEngine
|
|||||||
|
|
||||||
if (site.StationId is not null)
|
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";
|
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +389,7 @@ public sealed partial class SimulationEngine
|
|||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
var supportPosition = ResolveShipSupportPosition(ship, station);
|
var supportPosition = ResolveShipSupportPosition(ship, station, site, world);
|
||||||
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
||||||
{
|
{
|
||||||
ship.State = ShipState.LocalFlight;
|
ship.State = ShipState.LocalFlight;
|
||||||
@@ -383,8 +416,16 @@ public sealed partial class SimulationEngine
|
|||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (site.StationId is null)
|
||||||
|
{
|
||||||
|
CompleteStationFoundation(world, station, site);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
AddStationModule(world, station, site.BlueprintId);
|
AddStationModule(world, station, site.BlueprintId);
|
||||||
PrepareNextConstructionSiteStep(world, station, site);
|
PrepareNextConstructionSiteStep(world, station, site);
|
||||||
|
}
|
||||||
|
|
||||||
return "site-constructed";
|
return "site-constructed";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,10 +436,21 @@ public sealed partial class SimulationEngine
|
|||||||
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId)
|
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station) =>
|
private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world)
|
||||||
ship.DockedStationId is not null
|
{
|
||||||
? GetShipDockedPosition(ship, station)
|
if (ship.DockedStationId is not null)
|
||||||
: GetConstructionHoldPosition(station, ship.Id);
|
{
|
||||||
|
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) =>
|
private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) =>
|
||||||
ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f);
|
ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f);
|
||||||
@@ -448,6 +500,90 @@ public sealed partial class SimulationEngine
|
|||||||
return "undocked";
|
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)));
|
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 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) =>
|
private static float GetLocalTravelSpeed(ShipRuntime ship) =>
|
||||||
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed);
|
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
|
world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, systemId, StringComparison.Ordinal))?.Position
|
||||||
?? Vector3.Zero;
|
?? Vector3.Zero;
|
||||||
|
|
||||||
private string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
internal string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||||
{
|
{
|
||||||
var task = ship.ControllerTask;
|
var task = ship.ControllerTask;
|
||||||
return task.Kind switch
|
return task.Kind switch
|
||||||
@@ -27,6 +34,7 @@ public sealed partial class SimulationEngine
|
|||||||
ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds),
|
ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds),
|
||||||
ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds),
|
ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds),
|
||||||
ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds),
|
ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds),
|
||||||
|
ControllerTaskKind.AttackTarget => UpdateAttackTarget(ship, world, deltaSeconds),
|
||||||
|
|
||||||
ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds),
|
ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds),
|
||||||
ControllerTaskKind.Undock => UpdateUndock(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)
|
private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||||
{
|
{
|
||||||
var task = ship.ControllerTask;
|
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)
|
if (task.TargetPosition is null || task.TargetSystemId is null)
|
||||||
{
|
{
|
||||||
ship.State = ShipState.Idle;
|
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
|
// Resolve live position each frame — entities like stations orbit celestials and move every tick
|
||||||
var targetPosition = ResolveCurrentTargetPosition(world, task);
|
var targetPosition = ResolveCurrentTargetPosition(world, task);
|
||||||
var targetCelestial = ResolveTravelTargetCelestial(world, task, targetPosition);
|
var targetCelestial = ResolveTravelTargetCelestial(world, task, targetPosition);
|
||||||
|
var distance = ship.Position.DistanceTo(targetPosition);
|
||||||
ship.TargetPosition = targetPosition;
|
ship.TargetPosition = targetPosition;
|
||||||
|
|
||||||
if (ship.SystemId != task.TargetSystemId)
|
if (ship.SystemId != task.TargetSystemId)
|
||||||
@@ -80,9 +94,76 @@ public sealed partial class SimulationEngine
|
|||||||
return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial);
|
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);
|
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)
|
private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ControllerTaskRuntime task)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
|
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
|
||||||
@@ -298,4 +379,14 @@ public sealed partial class SimulationEngine
|
|||||||
ship.State = ShipState.Arriving;
|
ship.State = ShipState.Arriving;
|
||||||
return "none";
|
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)
|
public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence)
|
||||||
{
|
{
|
||||||
PrimeDeltaBaseline(world);
|
PrimeDeltaBaseline(world);
|
||||||
@@ -73,6 +101,7 @@ public sealed partial class SimulationEngine
|
|||||||
station.Id,
|
station.Id,
|
||||||
station.Label,
|
station.Label,
|
||||||
station.Category,
|
station.Category,
|
||||||
|
station.Objective,
|
||||||
station.SystemId,
|
station.SystemId,
|
||||||
station.LocalPosition,
|
station.LocalPosition,
|
||||||
station.CelestialId,
|
station.CelestialId,
|
||||||
@@ -472,7 +501,7 @@ public sealed partial class SimulationEngine
|
|||||||
ship.TrackedActionKey ?? "none",
|
ship.TrackedActionKey ?? "none",
|
||||||
ship.TrackedActionTotal.ToString("0.###"),
|
ship.TrackedActionTotal.ToString("0.###"),
|
||||||
ship.ControllerTask.TargetEntityId is not null && world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is { } site
|
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",
|
: "0",
|
||||||
ship.Health.ToString("0.###"),
|
ship.Health.ToString("0.###"),
|
||||||
ship.ActionTimer.ToString("0.###"));
|
ship.ActionTimer.ToString("0.###"));
|
||||||
@@ -516,6 +545,7 @@ public sealed partial class SimulationEngine
|
|||||||
station.Id,
|
station.Id,
|
||||||
station.Label,
|
station.Label,
|
||||||
station.Category,
|
station.Category,
|
||||||
|
station.Objective,
|
||||||
station.SystemId,
|
station.SystemId,
|
||||||
ToDto(station.Position),
|
ToDto(station.Position),
|
||||||
station.CelestialId,
|
station.CelestialId,
|
||||||
@@ -542,12 +572,18 @@ public sealed partial class SimulationEngine
|
|||||||
{
|
{
|
||||||
var recipe = SelectProductionRecipe(world, station, laneKey);
|
var recipe = SelectProductionRecipe(world, station, laneKey);
|
||||||
var timer = GetStationProductionTimer(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
|
return recipe is null || timer <= 0.01f
|
||||||
? null
|
? null
|
||||||
: new StationActionProgressSnapshot(
|
: new StationActionProgressSnapshot(
|
||||||
laneKey,
|
laneKey,
|
||||||
recipe.Label,
|
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)
|
.Where(snapshot => snapshot is not null)
|
||||||
.Cast<StationActionProgressSnapshot>()
|
.Cast<StationActionProgressSnapshot>()
|
||||||
@@ -683,7 +719,7 @@ public sealed partial class SimulationEngine
|
|||||||
? null
|
? null
|
||||||
: world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is not { } site
|
: world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is not { } site
|
||||||
? null
|
? null
|
||||||
: CreateShipRemainingActionProgress("Deliver materials", ship.TrackedActionTotal, GetRemainingConstructionDelivery(world, site)),
|
: CreateShipRemainingActionProgress("Deliver materials", ship.TrackedActionTotal, ShipTaskExecutionService.GetRemainingConstructionDelivery(world, site)),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -736,8 +772,16 @@ public sealed partial class SimulationEngine
|
|||||||
ps.ControlledSystemCount,
|
ps.ControlledSystemCount,
|
||||||
ps.TargetSystemCount,
|
ps.TargetSystemCount,
|
||||||
ps.HasShipFactory,
|
ps.HasShipFactory,
|
||||||
ps.OreStockpile,
|
NormalizeFiniteFloat(ps.OreStockpile),
|
||||||
ps.RefinedMetalsStockpile);
|
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)
|
if (commander?.LastGoalPriorities is { } prios)
|
||||||
@@ -776,36 +820,8 @@ public sealed partial class SimulationEngine
|
|||||||
state.Transit.ArrivalDueAtUtc,
|
state.Transit.ArrivalDueAtUtc,
|
||||||
state.Transit.Progress));
|
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 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>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FastEndpoints" Version="6.*" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace SpaceGame.Simulation.Api.Contracts;
|
namespace SpaceGame.Api.Stations.Contracts;
|
||||||
|
|
||||||
public sealed record InventoryEntry(
|
public sealed record InventoryEntry(
|
||||||
string ItemId,
|
string ItemId,
|
||||||
@@ -8,6 +8,7 @@ public sealed record StationSnapshot(
|
|||||||
string Id,
|
string Id,
|
||||||
string Label,
|
string Label,
|
||||||
string Category,
|
string Category,
|
||||||
|
string Objective,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
Vector3Dto LocalPosition,
|
Vector3Dto LocalPosition,
|
||||||
string? CelestialId,
|
string? CelestialId,
|
||||||
@@ -32,6 +33,7 @@ public sealed record StationDelta(
|
|||||||
string Id,
|
string Id,
|
||||||
string Label,
|
string Label,
|
||||||
string Category,
|
string Category,
|
||||||
|
string Objective,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
Vector3Dto LocalPosition,
|
Vector3Dto LocalPosition,
|
||||||
string? CelestialId,
|
string? CelestialId,
|
||||||
@@ -55,7 +57,13 @@ public sealed record StationDelta(
|
|||||||
public sealed record StationActionProgressSnapshot(
|
public sealed record StationActionProgressSnapshot(
|
||||||
string Lane,
|
string Lane,
|
||||||
string Label,
|
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(
|
public sealed record StationStorageUsageSnapshot(
|
||||||
string StorageClass,
|
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
|
public sealed class StationRuntime
|
||||||
{
|
{
|
||||||
@@ -6,6 +6,7 @@ public sealed class StationRuntime
|
|||||||
public required string SystemId { get; init; }
|
public required string SystemId { get; init; }
|
||||||
public required string Label { get; set; }
|
public required string Label { get; set; }
|
||||||
public string Category { get; set; } = "station";
|
public string Category { get; set; } = "station";
|
||||||
|
public string Objective { get; set; } = "general";
|
||||||
public string Color { get; set; } = "#8df0d2";
|
public string Color { get; set; } = "#8df0d2";
|
||||||
public required Vector3 Position { get; set; }
|
public required Vector3 Position { get; set; }
|
||||||
public float Radius { get; set; } = 24f;
|
public float Radius { get; set; } = 24f;
|
||||||
@@ -14,6 +15,8 @@ public sealed class StationRuntime
|
|||||||
public string? CommanderId { get; set; }
|
public string? CommanderId { get; set; }
|
||||||
public string? PolicySetId { get; set; }
|
public string? PolicySetId { get; set; }
|
||||||
public List<StationModuleRuntime> Modules { get; } = [];
|
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 IEnumerable<string> InstalledModules => Modules.Select((module) => module.ModuleId);
|
||||||
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
|
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
|
||||||
public Dictionary<string, float> ProductionLaneTimers { 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 static SpaceGame.Api.Ships.Simulation.ShipControlService;
|
||||||
using SpaceGame.Simulation.Api.Contracts;
|
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);
|
var factionPopulation = new Dictionary<string, float>(StringComparer.Ordinal);
|
||||||
foreach (var station in world.Stations)
|
foreach (var station in world.Stations)
|
||||||
{
|
{
|
||||||
UpdateStationPopulation(station, deltaSeconds, events);
|
UpdateStationPopulation(station, deltaSeconds, events);
|
||||||
ReviewStationMarketOrders(world, station);
|
_stationSimulation.ReviewStationMarketOrders(world, station);
|
||||||
RunStationProduction(world, station, deltaSeconds, events);
|
_stationSimulation.RunStationProduction(world, station, deltaSeconds, events);
|
||||||
factionPopulation[station.FactionId] = GetInventoryAmount(factionPopulation, station.FactionId) + station.Population;
|
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);
|
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))
|
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);
|
world.Ships.Add(ship);
|
||||||
|
EnsureSpawnedShipCommander(world, station, ship);
|
||||||
if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction)
|
if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction)
|
||||||
{
|
{
|
||||||
faction.ShipsBuilt += 1;
|
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 static SpaceGame.Api.Factions.AI.CommanderPlanningService;
|
||||||
using SpaceGame.Simulation.Api.Contracts;
|
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)
|
if (station.CommanderId is null)
|
||||||
{
|
{
|
||||||
@@ -13,28 +15,59 @@ public sealed partial class SimulationEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
var desiredOrders = new List<DesiredMarketOrder>();
|
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 waterReserve = MathF.Max(30f, station.Population * 3f);
|
||||||
var refinedReserve = HasStationModules(station, "module_gen_prod_hullparts_01") ? 140f : 40f;
|
var constructionEnergyReserve = GetConstructionDemandForItem(world, site, "energycells");
|
||||||
var oreReserve = HasRefineryCapability(station) ? 180f : 0f;
|
var constructionHullpartsReserve = GetConstructionDemandForItem(world, site, "hullparts");
|
||||||
var shipPartsReserve = HasStationModules(station, "module_gen_prod_hullparts_01")
|
var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics");
|
||||||
&& !HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
|
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")
|
&& FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships")
|
||||||
? 90f
|
? 90f
|
||||||
: 0f;
|
: 0f;
|
||||||
|
|
||||||
AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f);
|
AddDemandOrder(desiredOrders, station, "water", ScaleReserveByEconomy(economy, "water", waterReserve), valuationBase: ScaleDemandValuation(economy, "water", 1.1f));
|
||||||
AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f);
|
AddDemandOrder(desiredOrders, station, "energycells", ScaleReserveByEconomy(economy, "energycells", energyReserve), valuationBase: ScaleDemandValuation(economy, "energycells", 1.0f));
|
||||||
AddDemandOrder(desiredOrders, station, "refinedmetals", refinedReserve, valuationBase: 1.15f);
|
AddDemandOrder(desiredOrders, station, "ice", ScaleReserveByEconomy(economy, "ice", iceReserve), valuationBase: ScaleDemandValuation(economy, "ice", 1.0f));
|
||||||
AddDemandOrder(desiredOrders, station, "hullparts", shipPartsReserve, valuationBase: 1.3f);
|
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, "water", ScaleSupplyTriggerByEconomy(economy, "water", waterReserve * 1.5f), reserveFloor: waterReserve, valuationBase: ScaleSupplyValuation(economy, "water", 0.65f));
|
||||||
AddSupplyOrder(desiredOrders, station, "ore", oreReserve * 1.4f, reserveFloor: oreReserve, valuationBase: 0.7f);
|
AddSupplyOrder(desiredOrders, station, "energycells", ScaleSupplyTriggerByEconomy(economy, "energycells", energyReserve * 1.4f), reserveFloor: energyReserve, valuationBase: ScaleSupplyValuation(economy, "energycells", 0.7f));
|
||||||
AddSupplyOrder(desiredOrders, station, "refinedmetals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f);
|
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);
|
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);
|
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId);
|
||||||
foreach (var laneKey in GetStationProductionLanes(world, station))
|
foreach (var laneKey in GetStationProductionLanes(world, station))
|
||||||
@@ -60,7 +93,7 @@ public sealed partial class SimulationEngine
|
|||||||
|
|
||||||
if (recipe.ShipOutputId is not null)
|
if (recipe.ShipOutputId is not null)
|
||||||
{
|
{
|
||||||
produced += CompleteShipRecipe(world, station, recipe, events);
|
produced += StationLifecycleService.CompleteShipRecipe(world, station, recipe, events);
|
||||||
continue;
|
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))
|
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;
|
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
|
world.Recipes.Values
|
||||||
.Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(world, recipe), laneKey, StringComparison.Ordinal))
|
.Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(world, recipe), laneKey, StringComparison.Ordinal))
|
||||||
.OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe))
|
.OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe))
|
||||||
.FirstOrDefault(recipe => CanRunRecipe(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 =>
|
recipe.RequiredModules.FirstOrDefault(moduleId =>
|
||||||
world.ModuleDefinitions.TryGetValue(moduleId, out var def) && !string.IsNullOrEmpty(def.ProductionMode));
|
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);
|
var laneModuleId = GetStationProductionLaneKey(world, recipe);
|
||||||
if (laneModuleId is null)
|
if (laneModuleId is null)
|
||||||
@@ -131,14 +164,41 @@ public sealed partial class SimulationEngine
|
|||||||
|
|
||||||
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
|
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
|
||||||
var fleetPressure = FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships") ? 1f : 0f;
|
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)
|
||||||
{
|
{
|
||||||
"ship-parts-integration" => HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
|
var outputItemIds = recipe.Outputs
|
||||||
|
.Select(output => output.ItemId)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
if (outputItemIds.Contains("hullparts"))
|
||||||
|
{
|
||||||
|
return HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
|
||||||
? -140f * MathF.Max(expansionPressure, fleetPressure)
|
? -140f * MathF.Max(expansionPressure, fleetPressure)
|
||||||
: 280f * MathF.Max(expansionPressure, fleetPressure),
|
: 280f * MathF.Max(expansionPressure, fleetPressure);
|
||||||
"hull-fabrication" => 180f * expansionPressure,
|
}
|
||||||
"equipment-assembly" => 170f * expansionPressure,
|
|
||||||
"gun-assembly" => 160f * expansionPressure,
|
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"
|
"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),
|
=> 220f * MathF.Max(expansionPressure, fleetPressure),
|
||||||
"frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure),
|
"frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure),
|
||||||
@@ -149,11 +209,9 @@ public sealed partial class SimulationEngine
|
|||||||
=> -120f * expansionPressure,
|
=> -120f * expansionPressure,
|
||||||
_ => 0f,
|
_ => 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)
|
var categoryMatch = string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal)
|
||||||
|| string.Equals(recipe.FacilityCategory, "farm", StringComparison.Ordinal)
|
|| string.Equals(recipe.FacilityCategory, "farm", StringComparison.Ordinal)
|
||||||
@@ -213,6 +271,71 @@ public sealed partial class SimulationEngine
|
|||||||
private static bool HasRefineryCapability(StationRuntime station) =>
|
private static bool HasRefineryCapability(StationRuntime station) =>
|
||||||
HasStationModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01");
|
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)
|
private static void AddDemandOrder(ICollection<DesiredMarketOrder> desiredOrders, StationRuntime station, string itemId, float targetAmount, float valuationBase)
|
||||||
{
|
{
|
||||||
var current = GetInventoryAmount(station.Inventory, itemId);
|
var current = GetInventoryAmount(station.Inventory, itemId);
|
||||||
@@ -240,7 +363,9 @@ public sealed partial class SimulationEngine
|
|||||||
return;
|
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)
|
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 targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count));
|
||||||
var controlledSystems = GetFactionControlledSystemsCount(world, factionId);
|
var controlledSystems = GetFactionControlledSystemsCount(world, factionId);
|
||||||
@@ -298,11 +423,55 @@ public sealed partial class SimulationEngine
|
|||||||
return Math.Clamp(deficit / (float)targetSystems, 0f, 1f);
|
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));
|
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)
|
private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)
|
||||||
{
|
{
|
||||||
var totalLagrangePoints = world.Celestials.Count(node =>
|
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(
|
public sealed record StarSnapshot(
|
||||||
string Kind,
|
string Kind,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace SpaceGame.Simulation.Api.Contracts;
|
namespace SpaceGame.Api.Universe.Contracts;
|
||||||
|
|
||||||
public sealed record WorldSnapshot(
|
public sealed record WorldSnapshot(
|
||||||
string Label,
|
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
|
public sealed class SimulationWorld
|
||||||
{
|
{
|
||||||
@@ -23,6 +22,7 @@ public sealed class SimulationWorld
|
|||||||
public required Dictionary<string, ModuleDefinition> ModuleDefinitions { get; init; }
|
public required Dictionary<string, ModuleDefinition> ModuleDefinitions { get; init; }
|
||||||
public required Dictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; init; }
|
public required Dictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; init; }
|
||||||
public required Dictionary<string, RecipeDefinition> Recipes { get; init; }
|
public required Dictionary<string, RecipeDefinition> Recipes { get; init; }
|
||||||
|
public required ProductionGraph ProductionGraph { get; init; }
|
||||||
public int TickIntervalMs { get; init; } = 200;
|
public int TickIntervalMs { get; init; } = 200;
|
||||||
public double OrbitalTimeSeconds { get; set; }
|
public double OrbitalTimeSeconds { get; set; }
|
||||||
public DateTimeOffset GeneratedAtUtc { 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
|
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)
|
private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system)
|
||||||
{
|
{
|
||||||
var celestials = new List<CelestialRuntime>();
|
var celestials = new List<CelestialRuntime>();
|
||||||
@@ -96,9 +132,7 @@ public sealed partial class ScenarioLoader
|
|||||||
return celestial;
|
return celestial;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(
|
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, PlanetDefinition planet)
|
||||||
Vector3 planetPosition,
|
|
||||||
PlanetDefinition planet)
|
|
||||||
{
|
{
|
||||||
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
|
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
|
||||||
var tangential = new Vector3(-radial.Z, 0f, radial.X);
|
var tangential = new Vector3(-radial.Z, 0f, radial.X);
|
||||||
@@ -129,7 +163,6 @@ public sealed partial class ScenarioLoader
|
|||||||
return MathF.Max(minimumOffset, hillLikeOffset);
|
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)
|
private static float EstimatePlanetMassRatio(PlanetDefinition planet)
|
||||||
{
|
{
|
||||||
var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f);
|
var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f);
|
||||||
@@ -146,7 +179,7 @@ public sealed partial class ScenarioLoader
|
|||||||
return earthMasses / 332_946f;
|
return earthMasses / 332_946f;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static StationPlacement ResolveStationPlacement(
|
internal static StationPlacement ResolveStationPlacement(
|
||||||
InitialStationDefinition plan,
|
InitialStationDefinition plan,
|
||||||
SystemRuntime system,
|
SystemRuntime system,
|
||||||
SystemSpatialGraph graph,
|
SystemSpatialGraph graph,
|
||||||
@@ -166,19 +199,19 @@ public sealed partial class ScenarioLoader
|
|||||||
{
|
{
|
||||||
var targetPosition = NormalizeScenarioPoint(system, plan.Position);
|
var targetPosition = NormalizeScenarioPoint(system, plan.Position);
|
||||||
var preferredCelestial = existingCelestials
|
var preferredCelestial = existingCelestials
|
||||||
.Where((c) => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint)
|
.Where(c => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint)
|
||||||
.OrderBy((c) => c.Position.DistanceTo(targetPosition))
|
.OrderBy(c => c.Position.DistanceTo(targetPosition))
|
||||||
.FirstOrDefault()
|
.FirstOrDefault()
|
||||||
?? existingCelestials
|
?? existingCelestials
|
||||||
.Where((c) => c.SystemId == system.Definition.Id)
|
.Where(c => c.SystemId == system.Definition.Id)
|
||||||
.OrderBy((c) => c.Position.DistanceTo(targetPosition))
|
.OrderBy(c => c.Position.DistanceTo(targetPosition))
|
||||||
.First();
|
.First();
|
||||||
return new StationPlacement(preferredCelestial, preferredCelestial.Position);
|
return new StationPlacement(preferredCelestial, preferredCelestial.Position);
|
||||||
}
|
}
|
||||||
|
|
||||||
var fallbackCelestial = graph.Celestials
|
var fallbackCelestial = graph.Celestials
|
||||||
.FirstOrDefault((c) => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId))
|
.FirstOrDefault(c => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId))
|
||||||
?? graph.Celestials.First((c) => c.Kind == SpatialNodeKind.Planet);
|
?? graph.Celestials.First(c => c.Kind == SpatialNodeKind.Planet);
|
||||||
return new StationPlacement(fallbackCelestial, fallbackCelestial.Position);
|
return new StationPlacement(fallbackCelestial, fallbackCelestial.Position);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,11 +232,11 @@ public sealed partial class ScenarioLoader
|
|||||||
if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0)
|
if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0)
|
||||||
{
|
{
|
||||||
var moonNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}-moon-{moonIndex + 1}";
|
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}";
|
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)
|
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 angle = DegreesToRadians(planet.OrbitPhaseAtEpoch);
|
||||||
var orbitRadiusKm = SimulationUnits.AuToKilometers(planet.OrbitRadius);
|
var orbitRadiusKm = SimulationUnits.AuToKilometers(planet.OrbitRadius);
|
||||||
var x = MathF.Cos(angle) * orbitRadiusKm;
|
return new Vector3(MathF.Cos(angle) * orbitRadiusKm, 0f, MathF.Sin(angle) * orbitRadiusKm);
|
||||||
var z = MathF.Sin(angle) * orbitRadiusKm;
|
|
||||||
return new Vector3(x, 0f, z);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector3 ComputeMoonPosition(Vector3 planetPosition, MoonDefinition moon)
|
private static Vector3 ComputeMoonPosition(Vector3 planetPosition, MoonDefinition moon)
|
||||||
@@ -238,11 +269,11 @@ public sealed partial class ScenarioLoader
|
|||||||
return Add(planetPosition, local);
|
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
|
var nearestCelestial = celestials
|
||||||
.Where((c) => c.SystemId == systemId)
|
.Where(c => c.SystemId == systemId)
|
||||||
.OrderBy((c) => c.Position.DistanceTo(position))
|
.OrderBy(c => c.Position.DistanceTo(position))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
return new ShipSpatialStateRuntime
|
return new ShipSpatialStateRuntime
|
||||||
@@ -255,13 +286,18 @@ public sealed partial class ScenarioLoader
|
|||||||
MovementRegime = MovementRegimeKinds.LocalFlight,
|
MovementRegime = MovementRegimeKinds.LocalFlight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record SystemSpatialGraph(
|
internal sealed record ScenarioSpatialLayout(
|
||||||
|
IReadOnlyDictionary<string, SystemSpatialGraph> SystemGraphs,
|
||||||
|
List<CelestialRuntime> Celestials,
|
||||||
|
List<ResourceNodeRuntime> Nodes);
|
||||||
|
|
||||||
|
internal sealed record SystemSpatialGraph(
|
||||||
string SystemId,
|
string SystemId,
|
||||||
List<CelestialRuntime> Celestials,
|
List<CelestialRuntime> Celestials,
|
||||||
Dictionary<int, Dictionary<string, CelestialRuntime>> LagrangeNodesByPlanetIndex);
|
Dictionary<int, Dictionary<string, CelestialRuntime>> LagrangeNodesByPlanetIndex);
|
||||||
|
|
||||||
private sealed record LagrangePointPlacement(string Designation, Vector3 Position);
|
internal sealed record LagrangePointPlacement(string Designation, Vector3 Position);
|
||||||
|
|
||||||
private sealed record StationPlacement(CelestialRuntime AnchorCelestial, 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 SolSystemId = "sol";
|
||||||
private const string DevelopmentCompanionSystemId = "helios";
|
private const string DevelopmentCompanionSystemId = "helios";
|
||||||
|
|
||||||
private static List<SolarSystemDefinition> InjectSpecialSystems(
|
internal List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems) =>
|
||||||
IReadOnlyList<SolarSystemDefinition> authoredSystems)
|
authoredSystems
|
||||||
{
|
|
||||||
return authoredSystems
|
|
||||||
.Select(CloneSystemDefinition)
|
.Select(CloneSystemDefinition)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
|
||||||
|
|
||||||
private static List<SolarSystemDefinition> ExpandSystems(
|
internal List<SolarSystemDefinition> ExpandSystems(
|
||||||
IReadOnlyList<SolarSystemDefinition> authoredSystems,
|
IReadOnlyList<SolarSystemDefinition> authoredSystems,
|
||||||
int targetSystemCount)
|
int targetSystemCount)
|
||||||
{
|
{
|
||||||
@@ -39,10 +36,10 @@ public sealed partial class ScenarioLoader
|
|||||||
}
|
}
|
||||||
|
|
||||||
var existingIds = systems
|
var existingIds = systems
|
||||||
.Select((system) => system.Id)
|
.Select(system => system.Id)
|
||||||
.ToHashSet(StringComparer.Ordinal);
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
var generatedPositions = BuildGalaxyPositions(
|
var generatedPositions = BuildGalaxyPositions(
|
||||||
authoredSystems.Select((system) => ToVector(system.Position)).ToList(),
|
authoredSystems.Select(system => ToVector(system.Position)).ToList(),
|
||||||
targetSystemCount - systems.Count);
|
targetSystemCount - systems.Count);
|
||||||
|
|
||||||
for (var index = systems.Count; index < targetSystemCount; index += 1)
|
for (var index = systems.Count; index < targetSystemCount; index += 1)
|
||||||
@@ -61,16 +58,14 @@ public sealed partial class ScenarioLoader
|
|||||||
return systems;
|
return systems;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<SolarSystemDefinition> TrimSystemsToTarget(
|
private static List<SolarSystemDefinition> TrimSystemsToTarget(IReadOnlyList<SolarSystemDefinition> systems, int targetSystemCount)
|
||||||
IReadOnlyList<SolarSystemDefinition> systems,
|
|
||||||
int targetSystemCount)
|
|
||||||
{
|
{
|
||||||
var selected = new List<SolarSystemDefinition>(targetSystemCount);
|
var selected = new List<SolarSystemDefinition>(targetSystemCount);
|
||||||
|
|
||||||
void AddById(string systemId)
|
void AddById(string systemId)
|
||||||
{
|
{
|
||||||
var system = systems.FirstOrDefault((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)))
|
if (system is not null && selected.All(candidate => !string.Equals(candidate.Id, systemId, StringComparison.Ordinal)))
|
||||||
{
|
{
|
||||||
selected.Add(system);
|
selected.Add(system);
|
||||||
}
|
}
|
||||||
@@ -86,7 +81,7 @@ public sealed partial class ScenarioLoader
|
|||||||
break;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -127,9 +122,8 @@ public sealed partial class ScenarioLoader
|
|||||||
{
|
{
|
||||||
var starProfile = SelectStarProfile(generatedIndex);
|
var starProfile = SelectStarProfile(generatedIndex);
|
||||||
var planets = BuildGeneratedPlanets(template, generatedIndex);
|
var planets = BuildGeneratedPlanets(template, generatedIndex);
|
||||||
|
|
||||||
var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex)
|
var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex)
|
||||||
.Select((node) => new ResourceNodeDefinition
|
.Select(node => new ResourceNodeDefinition
|
||||||
{
|
{
|
||||||
SourceKind = node.SourceKind,
|
SourceKind = node.SourceKind,
|
||||||
Angle = node.Angle,
|
Angle = node.Angle,
|
||||||
@@ -185,8 +179,7 @@ public sealed partial class ScenarioLoader
|
|||||||
RadiusVariance = definition.AsteroidField.RadiusVariance,
|
RadiusVariance = definition.AsteroidField.RadiusVariance,
|
||||||
HeightVariance = definition.AsteroidField.HeightVariance,
|
HeightVariance = definition.AsteroidField.HeightVariance,
|
||||||
},
|
},
|
||||||
ResourceNodes = definition.ResourceNodes
|
ResourceNodes = definition.ResourceNodes.Select(node => new ResourceNodeDefinition
|
||||||
.Select((node) => new ResourceNodeDefinition
|
|
||||||
{
|
{
|
||||||
SourceKind = node.SourceKind,
|
SourceKind = node.SourceKind,
|
||||||
Angle = node.Angle,
|
Angle = node.Angle,
|
||||||
@@ -197,15 +190,13 @@ public sealed partial class ScenarioLoader
|
|||||||
OreAmount = node.OreAmount,
|
OreAmount = node.OreAmount,
|
||||||
ItemId = node.ItemId,
|
ItemId = node.ItemId,
|
||||||
ShardCount = node.ShardCount,
|
ShardCount = node.ShardCount,
|
||||||
})
|
}).ToList(),
|
||||||
.ToList(),
|
Planets = definition.Planets.Select(planet => new PlanetDefinition
|
||||||
Planets = definition.Planets
|
|
||||||
.Select((planet) => new PlanetDefinition
|
|
||||||
{
|
{
|
||||||
Label = planet.Label,
|
Label = planet.Label,
|
||||||
PlanetType = planet.PlanetType,
|
PlanetType = planet.PlanetType,
|
||||||
Shape = planet.Shape,
|
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(),
|
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,
|
OrbitRadius = planet.OrbitRadius,
|
||||||
OrbitSpeed = planet.OrbitSpeed,
|
OrbitSpeed = planet.OrbitSpeed,
|
||||||
OrbitEccentricity = planet.OrbitEccentricity,
|
OrbitEccentricity = planet.OrbitEccentricity,
|
||||||
@@ -217,8 +208,7 @@ public sealed partial class ScenarioLoader
|
|||||||
Color = planet.Color,
|
Color = planet.Color,
|
||||||
Tilt = planet.Tilt,
|
Tilt = planet.Tilt,
|
||||||
HasRing = planet.HasRing,
|
HasRing = planet.HasRing,
|
||||||
})
|
}).ToList(),
|
||||||
.ToList(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +220,7 @@ public sealed partial class ScenarioLoader
|
|||||||
var nodes = new List<ResourceNodeDefinition>();
|
var nodes = new List<ResourceNodeDefinition>();
|
||||||
if (template.ResourceNodes.Count > 0)
|
if (template.ResourceNodes.Count > 0)
|
||||||
{
|
{
|
||||||
nodes.AddRange(template.ResourceNodes.Select((node) => new ResourceNodeDefinition
|
nodes.AddRange(template.ResourceNodes.Select(node => new ResourceNodeDefinition
|
||||||
{
|
{
|
||||||
SourceKind = node.SourceKind,
|
SourceKind = node.SourceKind,
|
||||||
Angle = node.Angle,
|
Angle = node.Angle,
|
||||||
@@ -259,7 +249,7 @@ public sealed partial class ScenarioLoader
|
|||||||
for (var attempt = 0; attempt < 64; attempt += 1)
|
for (var attempt = 0; attempt < 64; attempt += 1)
|
||||||
{
|
{
|
||||||
var candidate = ComputeGeneratedSystemPosition(index, attempt);
|
var candidate = ComputeGeneratedSystemPosition(index, attempt);
|
||||||
if (allPositions.All((existing) => existing.DistanceTo(candidate) >= MinimumSystemSeparation))
|
if (allPositions.All(existing => existing.DistanceTo(candidate) >= MinimumSystemSeparation))
|
||||||
{
|
{
|
||||||
accepted = candidate;
|
accepted = candidate;
|
||||||
break;
|
break;
|
||||||
@@ -307,7 +297,7 @@ public sealed partial class ScenarioLoader
|
|||||||
{
|
{
|
||||||
var slug = string.Concat(label
|
var slug = string.Concat(label
|
||||||
.ToLowerInvariant()
|
.ToLowerInvariant()
|
||||||
.Select((character) => char.IsLetterOrDigit(character) ? character : '-'))
|
.Select(character => char.IsLetterOrDigit(character) ? character : '-'))
|
||||||
.Trim('-');
|
.Trim('-');
|
||||||
|
|
||||||
return $"gen-{ordinal}-{slug}";
|
return $"gen-{ordinal}-{slug}";
|
||||||
@@ -359,9 +349,7 @@ public sealed partial class ScenarioLoader
|
|||||||
return Math.Clamp((planets.Count / 2) - 1, 0, planets.Count - 1);
|
return Math.Clamp((planets.Count / 2) - 1, 0, planets.Count - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<PlanetDefinition> BuildGeneratedPlanets(
|
private static List<PlanetDefinition> BuildGeneratedPlanets(SolarSystemDefinition template, int generatedIndex)
|
||||||
SolarSystemDefinition template,
|
|
||||||
int generatedIndex)
|
|
||||||
{
|
{
|
||||||
var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f);
|
var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f);
|
||||||
var planets = new List<PlanetDefinition>(planetCount);
|
var planets = new List<PlanetDefinition>(planetCount);
|
||||||
@@ -495,23 +483,4 @@ public sealed partial class ScenarioLoader
|
|||||||
|
|
||||||
return moons;
|
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<StationRuntime> stations,
|
||||||
IReadOnlyCollection<ShipRuntime> ships)
|
IReadOnlyCollection<ShipRuntime> ships)
|
||||||
{
|
{
|
||||||
var factionIds = stations
|
var factionIds = stations
|
||||||
.Select((station) => station.FactionId)
|
.Select(station => station.FactionId)
|
||||||
.Concat(ships.Select((ship) => ship.FactionId))
|
.Concat(ships.Select(ship => ship.FactionId))
|
||||||
.Where((factionId) => !string.IsNullOrWhiteSpace(factionId))
|
.Where(factionId => !string.IsNullOrWhiteSpace(factionId))
|
||||||
.Distinct(StringComparer.Ordinal)
|
.Distinct(StringComparer.Ordinal)
|
||||||
.OrderBy((factionId) => factionId, StringComparer.Ordinal)
|
.OrderBy(factionId => factionId, StringComparer.Ordinal)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (factionIds.Count == 0)
|
if (factionIds.Count == 0)
|
||||||
@@ -21,33 +21,10 @@ public sealed partial class ScenarioLoader
|
|||||||
factionIds.Add(DefaultFactionId);
|
factionIds.Add(DefaultFactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return factionIds
|
return factionIds.Select(CreateFaction).ToList();
|
||||||
.Select(CreateFaction)
|
|
||||||
.ToList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FactionRuntime CreateFaction(string factionId)
|
internal void BootstrapFactionEconomy(
|
||||||
{
|
|
||||||
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(
|
|
||||||
IReadOnlyCollection<FactionRuntime> factions,
|
IReadOnlyCollection<FactionRuntime> factions,
|
||||||
IReadOnlyCollection<StationRuntime> stations)
|
IReadOnlyCollection<StationRuntime> stations)
|
||||||
{
|
{
|
||||||
@@ -56,11 +33,11 @@ public sealed partial class ScenarioLoader
|
|||||||
faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits);
|
faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits);
|
||||||
|
|
||||||
var ownedStations = stations
|
var ownedStations = stations
|
||||||
.Where((station) => station.FactionId == faction.Id)
|
.Where(station => station.FactionId == faction.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var refineries = ownedStations
|
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();
|
.ToList();
|
||||||
|
|
||||||
if (refineries.Count > 0)
|
if (refineries.Count > 0)
|
||||||
@@ -70,32 +47,74 @@ public sealed partial class ScenarioLoader
|
|||||||
refinery.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(refinery.Inventory, "refinedmetals"), MinimumRefineryStock);
|
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;
|
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);
|
shipyard.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refinedmetals"), MinimumShipyardStock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
|
internal void InitializeStationStockpiles(IReadOnlyCollection<StationRuntime> stations)
|
||||||
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
|
{
|
||||||
|
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<StationRuntime> stations,
|
||||||
IReadOnlyCollection<CelestialRuntime> celestials,
|
IReadOnlyCollection<CelestialRuntime> celestials,
|
||||||
DateTimeOffset nowUtc)
|
DateTimeOffset nowUtc)
|
||||||
{
|
{
|
||||||
var stationsByCelestialId = stations
|
var stationsByCelestialId = stations
|
||||||
.Where((station) => station.CelestialId is not null)
|
.Where(station => station.CelestialId is not null)
|
||||||
.ToDictionary((station) => station.CelestialId!, StringComparer.Ordinal);
|
.ToDictionary(station => station.CelestialId!, StringComparer.Ordinal);
|
||||||
var claims = new List<ClaimRuntime>();
|
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))
|
if (!stationsByCelestialId.TryGetValue(celestial.Id, out var station))
|
||||||
{
|
{
|
||||||
@@ -118,24 +137,22 @@ public sealed partial class ScenarioLoader
|
|||||||
return claims;
|
return claims;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (List<ConstructionSiteRuntime> ConstructionSites, List<MarketOrderRuntime> MarketOrders) CreateConstructionSites(
|
internal (List<ConstructionSiteRuntime> ConstructionSites, List<MarketOrderRuntime> MarketOrders) CreateConstructionSites(
|
||||||
IReadOnlyCollection<StationRuntime> stations,
|
SimulationWorld world)
|
||||||
IReadOnlyCollection<ClaimRuntime> claims,
|
|
||||||
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
|
|
||||||
{
|
{
|
||||||
var sites = new List<ConstructionSiteRuntime>();
|
var sites = new List<ConstructionSiteRuntime>();
|
||||||
var orders = new List<MarketOrderRuntime>();
|
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)
|
if (moduleId is null || station.CelestialId is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var claim = claims.FirstOrDefault((candidate) => candidate.CelestialId == station.CelestialId);
|
var claim = world.Claims.FirstOrDefault(candidate => candidate.CelestialId == station.CelestialId);
|
||||||
if (claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe))
|
if (claim is null || !world.ModuleRecipes.TryGetValue(moduleId, out var recipe))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -183,43 +200,7 @@ public sealed partial class ScenarioLoader
|
|||||||
return (sites, orders);
|
return (sites, orders);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? GetNextConstructionSiteModule(
|
internal List<PolicySetRuntime> CreatePolicies(IReadOnlyCollection<FactionRuntime> factions)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
var policies = new List<PolicySetRuntime>(factions.Count);
|
var policies = new List<PolicySetRuntime>(factions.Count);
|
||||||
foreach (var faction in factions)
|
foreach (var faction in factions)
|
||||||
@@ -237,14 +218,14 @@ public sealed partial class ScenarioLoader
|
|||||||
return policies;
|
return policies;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<CommanderRuntime> CreateCommanders(
|
internal List<CommanderRuntime> CreateCommanders(
|
||||||
IReadOnlyCollection<FactionRuntime> factions,
|
IReadOnlyCollection<FactionRuntime> factions,
|
||||||
IReadOnlyCollection<StationRuntime> stations,
|
IReadOnlyCollection<StationRuntime> stations,
|
||||||
IReadOnlyCollection<ShipRuntime> ships)
|
IReadOnlyCollection<ShipRuntime> ships)
|
||||||
{
|
{
|
||||||
var commanders = new List<CommanderRuntime>();
|
var commanders = new List<CommanderRuntime>();
|
||||||
var factionCommanders = new Dictionary<string, CommanderRuntime>(StringComparer.Ordinal);
|
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)
|
foreach (var faction in factions)
|
||||||
{
|
{
|
||||||
@@ -330,34 +311,43 @@ public sealed partial class ScenarioLoader
|
|||||||
return commanders;
|
return commanders;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ToFactionLabel(string factionId)
|
internal static DefaultBehaviorRuntime CreateBehavior(
|
||||||
{
|
|
||||||
return string.Join(" ",
|
|
||||||
factionId
|
|
||||||
.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
||||||
.Select((segment) => char.ToUpperInvariant(segment[0]) + segment[1..]));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DefaultBehaviorRuntime CreateBehavior(
|
|
||||||
ShipDefinition definition,
|
ShipDefinition definition,
|
||||||
string systemId,
|
string systemId,
|
||||||
|
string factionId,
|
||||||
ScenarioDefinition scenario,
|
ScenarioDefinition scenario,
|
||||||
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
|
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
|
||||||
|
IReadOnlyCollection<StationRuntime> stations,
|
||||||
StationRuntime? refinery)
|
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
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "construct-station",
|
Kind = "construct-station",
|
||||||
StationId = refinery.Id,
|
StationId = homeStation.Id,
|
||||||
Phase = "travel-to-station",
|
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))
|
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
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
Kind = "patrol",
|
Kind = "patrol",
|
||||||
|
StationId = homeStation?.Id,
|
||||||
PatrolPoints = route,
|
PatrolPoints = route,
|
||||||
PatrolIndex = 0,
|
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()
|
private static DefaultBehaviorRuntime CreateResourceHarvestBehavior(string kind, string areaSystemId, string stationId) => new()
|
||||||
{
|
{
|
||||||
Kind = kind,
|
Kind = kind,
|
||||||
@@ -388,6 +426,8 @@ public sealed partial class ScenarioLoader
|
|||||||
{
|
{
|
||||||
Kind = behavior.Kind,
|
Kind = behavior.Kind,
|
||||||
AreaSystemId = behavior.AreaSystemId,
|
AreaSystemId = behavior.AreaSystemId,
|
||||||
|
TargetEntityId = behavior.TargetEntityId,
|
||||||
|
ItemId = behavior.ItemId,
|
||||||
ModuleId = behavior.ModuleId,
|
ModuleId = behavior.ModuleId,
|
||||||
NodeId = behavior.NodeId,
|
NodeId = behavior.NodeId,
|
||||||
Phase = behavior.Phase,
|
Phase = behavior.Phase,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace SpaceGame.Simulation.Api.Simulation;
|
namespace SpaceGame.Api.Universe.Simulation;
|
||||||
|
|
||||||
public sealed class OrbitalSimulationOptions
|
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)
|
private static Vector3 ComputePlanetPosition(PlanetDefinition planet, float timeSeconds)
|
||||||
{
|
{
|
||||||
var eccentricity = Math.Clamp(planet.OrbitEccentricity, 0f, 0.85f);
|
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 worldTimeSeconds = (float)world.OrbitalTimeSeconds;
|
||||||
var celestialsById = world.Celestials.ToDictionary(c => c.Id, StringComparer.Ordinal);
|
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)
|
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
|
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
|
public sealed class WorldGenerationOptions
|
||||||
{
|
{
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SpaceGame.Simulation.Api.Contracts;
|
|
||||||
|
|
||||||
namespace SpaceGame.Simulation.Api.Simulation;
|
namespace SpaceGame.Api.Universe.Simulation;
|
||||||
|
|
||||||
public sealed class WorldService(
|
public sealed class WorldService(
|
||||||
IWebHostEnvironment environment,
|
IWebHostEnvironment environment,
|
||||||
@@ -11,7 +10,7 @@ public sealed class WorldService(
|
|||||||
{
|
{
|
||||||
private const int DeltaHistoryLimit = 256;
|
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 OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
|
||||||
private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value);
|
private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value);
|
||||||
private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value);
|
private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value);
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"WorldGeneration": {
|
"WorldGeneration": {
|
||||||
"TargetSystemCount": 1,
|
"TargetSystemCount": 3,
|
||||||
"IncludeSolSystem": true
|
"IncludeSolSystem": true
|
||||||
},
|
},
|
||||||
"OrbitalSimulation": {
|
"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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -p tsconfig.json && vite build",
|
"build": "vue-tsc -p tsconfig.json --noEmit && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"three": "^0.179.1"
|
"pinia": "^3.0.3",
|
||||||
|
"three": "^0.179.1",
|
||||||
|
"vue": "^3.5.21"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/three": "^0.183.1",
|
"@types/three": "^0.183.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.9.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";
|
import { ViewerAppController } from "./ViewerAppController";
|
||||||
|
|
||||||
export class GameViewer {
|
export class GameViewer {
|
||||||
private readonly controller: ViewerAppController;
|
private readonly controller: ViewerAppController;
|
||||||
|
|
||||||
constructor(container: HTMLElement) {
|
constructor(container: HTMLElement, hud: ViewerHudBindings) {
|
||||||
this.controller = new ViewerAppController(container);
|
this.controller = new ViewerAppController(container, hud);
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
await this.controller.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,
|
MIN_CAMERA_DISTANCE,
|
||||||
NAV_DISTANCE,
|
NAV_DISTANCE,
|
||||||
} from "./viewerConstants";
|
} from "./viewerConstants";
|
||||||
import { createViewerHud } from "./viewerHud";
|
|
||||||
import { updatePanFromKeyboard } from "./viewerCamera";
|
import { updatePanFromKeyboard } from "./viewerCamera";
|
||||||
import { setShellReticleOpacity } from "./viewerControls";
|
import { setShellReticleOpacity } from "./viewerControls";
|
||||||
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
|
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
|
||||||
@@ -21,16 +20,21 @@ import { ViewerNavigationController } from "./viewerNavigationController";
|
|||||||
import { ViewerSceneDataController } from "./viewerSceneDataController";
|
import { ViewerSceneDataController } from "./viewerSceneDataController";
|
||||||
import { ViewerPresentationController } from "./viewerPresentationController";
|
import { ViewerPresentationController } from "./viewerPresentationController";
|
||||||
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
|
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 { toDisplayLocalPosition, getSystemCameraFocus } from "./viewerCamera";
|
||||||
import { UniverseLayer } from "./viewerUniverseLayer";
|
import { UniverseLayer } from "./viewerUniverseLayer";
|
||||||
import { GalaxyLayer } from "./viewerGalaxyLayer";
|
import { GalaxyLayer } from "./viewerGalaxyLayer";
|
||||||
import { SystemLayer } from "./viewerSystemLayer";
|
import { SystemLayer } from "./viewerSystemLayer";
|
||||||
import { LocalLayer } from "./viewerLocalLayer";
|
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 { FactionSnapshot } from "./contracts";
|
||||||
import type {
|
import type {
|
||||||
CameraMode,
|
CameraMode,
|
||||||
DragMode,
|
DragMode,
|
||||||
HistoryWindowState,
|
|
||||||
NetworkStats,
|
NetworkStats,
|
||||||
PerformanceStats,
|
PerformanceStats,
|
||||||
Selectable,
|
Selectable,
|
||||||
@@ -41,7 +45,8 @@ import type {
|
|||||||
|
|
||||||
export class ViewerAppController {
|
export class ViewerAppController {
|
||||||
private readonly container: HTMLElement;
|
private readonly container: HTMLElement;
|
||||||
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
|
private readonly renderer = createViewerRenderer();
|
||||||
|
private readonly renderSurface: ViewerRenderSurface;
|
||||||
|
|
||||||
// ── Three independent rendering layers ───────────────────────────────────
|
// ── Three independent rendering layers ───────────────────────────────────
|
||||||
readonly universeLayer = new UniverseLayer();
|
readonly universeLayer = new UniverseLayer();
|
||||||
@@ -61,23 +66,9 @@ export class ViewerAppController {
|
|||||||
private readonly cameraOffset = new THREE.Vector3();
|
private readonly cameraOffset = new THREE.Vector3();
|
||||||
private readonly keyState = new Set<string>();
|
private readonly keyState = new Set<string>();
|
||||||
|
|
||||||
private readonly gamePanelEl: HTMLDivElement;
|
readonly hudState: ViewerHudState;
|
||||||
|
readonly selectionStore: ViewerSelectionStore;
|
||||||
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;
|
|
||||||
private readonly opsStripEl: HTMLDivElement;
|
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 historyLayerEl: HTMLDivElement;
|
||||||
private readonly marqueeEl: HTMLDivElement;
|
private readonly marqueeEl: HTMLDivElement;
|
||||||
private readonly hoverLabelEl: HTMLDivElement;
|
private readonly hoverLabelEl: HTMLDivElement;
|
||||||
@@ -111,6 +102,8 @@ export class ViewerAppController {
|
|||||||
private readonly followCameraDirection = new THREE.Vector3(0, 0.16, 1);
|
private readonly followCameraDirection = new THREE.Vector3(0, 0.16, 1);
|
||||||
private readonly followCameraDesiredDirection = new THREE.Vector3(0, 0.16, 1);
|
private readonly followCameraDesiredDirection = new THREE.Vector3(0, 0.16, 1);
|
||||||
private readonly followCameraOffset = new THREE.Vector3();
|
private readonly followCameraOffset = new THREE.Vector3();
|
||||||
|
private followOrbitYaw = 0;
|
||||||
|
private followOrbitPitch = 0.2;
|
||||||
private readonly historyWindows: HistoryWindowState[] = [];
|
private readonly historyWindows: HistoryWindowState[] = [];
|
||||||
private historyWindowCounter = 0;
|
private historyWindowCounter = 0;
|
||||||
private historyWindowZCounter = 10;
|
private historyWindowZCounter = 10;
|
||||||
@@ -122,30 +115,14 @@ export class ViewerAppController {
|
|||||||
private readonly navigationController: ViewerNavigationController;
|
private readonly navigationController: ViewerNavigationController;
|
||||||
private readonly sceneDataController: ViewerSceneDataController;
|
private readonly sceneDataController: ViewerSceneDataController;
|
||||||
private readonly presentationController: ViewerPresentationController;
|
private readonly presentationController: ViewerPresentationController;
|
||||||
|
private readonly disposeEventBindings: () => void;
|
||||||
|
private readonly unsubscribeSelectionStore: () => void;
|
||||||
|
|
||||||
constructor(container: HTMLElement) {
|
constructor(container: HTMLElement, hud: ViewerHudBindings) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
this.hudState = hud.state;
|
||||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
this.selectionStore = hud.selectionStore;
|
||||||
|
|
||||||
|
|
||||||
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.opsStripEl = hud.opsStripEl;
|
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.historyLayerEl = hud.historyLayerEl;
|
||||||
this.marqueeEl = hud.marqueeEl;
|
this.marqueeEl = hud.marqueeEl;
|
||||||
this.hoverLabelEl = hud.hoverLabelEl;
|
this.hoverLabelEl = hud.hoverLabelEl;
|
||||||
@@ -158,33 +135,51 @@ export class ViewerAppController {
|
|||||||
interactionController: this.interactionController,
|
interactionController: this.interactionController,
|
||||||
} = createViewerControllers(this));
|
} = createViewerControllers(this));
|
||||||
this.presentationController.initializeAmbience();
|
this.presentationController.initializeAmbience();
|
||||||
|
this.unsubscribeSelectionStore = this.selectionStore.$subscribe((_mutation, state) => {
|
||||||
this.container.append(this.renderer.domElement, hud.root);
|
this.syncSelectionFromStore(state.selectedEntityKind, state.selectedEntityId);
|
||||||
this.initializePanelToggles();
|
});
|
||||||
wireViewerEvents(this);
|
this.renderSurface = new ViewerRenderSurface({
|
||||||
this.onResize();
|
container: this.container,
|
||||||
|
renderer: this.renderer,
|
||||||
|
onFrame: () => this.render(),
|
||||||
|
onResize: (width, height) => this.onResize(width, height),
|
||||||
|
});
|
||||||
|
this.disposeEventBindings = wireViewerEvents(this);
|
||||||
this.updateCamera(0);
|
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() {
|
async start() {
|
||||||
|
this.selectionStore.clearSelection();
|
||||||
await this.worldLifecycle.bootstrapWorld();
|
await this.worldLifecycle.bootstrapWorld();
|
||||||
this.renderer.setAnimationLoop(() => this.render());
|
this.renderSurface.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
private refreshStreamScopeIfNeeded() {
|
||||||
@@ -212,6 +207,32 @@ export class ViewerAppController {
|
|||||||
this.worldLifecycle.updatePanels();
|
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() {
|
private render() {
|
||||||
renderFrame({
|
renderFrame({
|
||||||
clock: this.clock,
|
clock: this.clock,
|
||||||
@@ -322,14 +343,15 @@ export class ViewerAppController {
|
|||||||
return resolveFocusedCelestialId(this.world, this.selectedItems);
|
return resolveFocusedCelestialId(this.world, this.selectedItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onResize = () => {
|
private onResize(width: number, height: number) {
|
||||||
resizeViewer({
|
resizeViewer({
|
||||||
renderer: this.renderer,
|
|
||||||
galaxyLayer: this.galaxyLayer,
|
galaxyLayer: this.galaxyLayer,
|
||||||
systemLayer: this.systemLayer,
|
systemLayer: this.systemLayer,
|
||||||
localLayer: this.localLayer,
|
localLayer: this.localLayer,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
|
private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
|
||||||
setShellReticleOpacity(sprite, opacity);
|
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";
|
import type { InventoryEntry, Vector3Dto } from "./contractsCommon";
|
||||||
|
|
||||||
|
export interface RecipeEntrySnapshot {
|
||||||
|
itemId: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StationActionProgressSnapshot {
|
export interface StationActionProgressSnapshot {
|
||||||
lane: string;
|
lane: string;
|
||||||
label: string;
|
label: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
|
timeRemainingSeconds: number;
|
||||||
|
cycleSeconds: number;
|
||||||
|
inputs: RecipeEntrySnapshot[];
|
||||||
|
outputs: RecipeEntrySnapshot[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StationStorageUsageSnapshot {
|
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 "./styles/index.css";
|
||||||
import { GameViewer } from "./GameViewer";
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import { viewerPinia } from "./ui/stores/pinia";
|
||||||
|
|
||||||
const root = document.querySelector<HTMLDivElement>("#app");
|
const root = document.querySelector<HTMLDivElement>("#app");
|
||||||
|
|
||||||
@@ -7,5 +9,6 @@ if (!root) {
|
|||||||
throw new Error("Missing #app root element");
|
throw new Error("Missing #app root element");
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewer = new GameViewer(root);
|
createApp(App)
|
||||||
void viewer.start();
|
.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 {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
--bg: #050812;
|
--viewer-panel: rgba(9, 18, 34, 0.78);
|
||||||
--panel: rgba(9, 18, 34, 0.78);
|
--viewer-panel-border: rgba(132, 196, 255, 0.18);
|
||||||
--panel-border: rgba(132, 196, 255, 0.18);
|
--viewer-text: #eaf4ff;
|
||||||
--text: #eaf4ff;
|
--viewer-muted: #98adc4;
|
||||||
--muted: #98adc4;
|
--viewer-accent: #7fd6ff;
|
||||||
--accent: #7fd6ff;
|
--viewer-warning: #ffbf69;
|
||||||
--warning: #ffbf69;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -27,39 +26,95 @@ body,
|
|||||||
linear-gradient(180deg, #03060d 0%, #060c18 100%);
|
linear-gradient(180deg, #03060d 0%, #060c18 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--viewer-text);
|
||||||
|
}
|
||||||
|
|
||||||
canvas {
|
canvas {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-shell {
|
.viewer-app,
|
||||||
position: fixed;
|
.viewer-canvas-host {
|
||||||
inset: 0;
|
width: 100%;
|
||||||
pointer-events: none;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-panel-stack {
|
.panel-summary,
|
||||||
position: absolute;
|
.hud-mono,
|
||||||
top: 20px;
|
.system-body,
|
||||||
left: 20px;
|
.detail-body,
|
||||||
width: min(360px, calc(100vw - 40px));
|
.ship-card p,
|
||||||
display: flex;
|
.history,
|
||||||
flex-direction: column;
|
.history-window-body,
|
||||||
gap: 16px;
|
.hover-label {
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-panel-stack {
|
.collapsible-panel.is-collapsed .game-body,
|
||||||
position: absolute;
|
.collapsible-panel.is-collapsed .network-body,
|
||||||
top: 20px;
|
.collapsible-panel.is-collapsed .performance-body {
|
||||||
right: 20px;
|
display: none;
|
||||||
width: min(380px, calc(100vw - 40px));
|
}
|
||||||
|
|
||||||
|
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 16px;
|
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 {
|
.marquee-box {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: none;
|
|
||||||
border: 1px solid rgba(127, 214, 255, 0.72);
|
border: 1px solid rgba(127, 214, 255, 0.72);
|
||||||
background: rgba(127, 214, 255, 0.14);
|
background: rgba(127, 214, 255, 0.14);
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
|
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);
|
background: rgba(7, 15, 28, 0.88);
|
||||||
border: 1px solid rgba(255, 88, 72, 0.5);
|
border: 1px solid rgba(255, 88, 72, 0.5);
|
||||||
color: #fff2ef;
|
color: #fff2ef;
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
@@ -102,226 +156,6 @@ canvas {
|
|||||||
display: none;
|
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 {
|
.history-window {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: auto;
|
right: auto;
|
||||||
@@ -344,10 +178,6 @@ canvas {
|
|||||||
resize: both;
|
resize: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-window[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-window-header {
|
.history-window-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -360,41 +190,16 @@ canvas {
|
|||||||
|
|
||||||
.history-window-title {
|
.history-window-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--accent);
|
color: var(--viewer-accent);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
text-transform: uppercase;
|
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 {
|
.history-window-body {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
color: var(--text);
|
color: var(--viewer-text);
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
@@ -402,34 +207,10 @@ canvas {
|
|||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-strip {
|
.history-window-actions {
|
||||||
border-radius: 14px;
|
display: flex;
|
||||||
padding: 12px 14px;
|
align-items: center;
|
||||||
background: rgba(255, 116, 88, 0.14);
|
gap: 8px;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-strip {
|
.ops-strip {
|
||||||
@@ -439,8 +220,6 @@ canvas {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 50vw;
|
width: 50vw;
|
||||||
min-height: 128px;
|
min-height: 128px;
|
||||||
border-radius: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -452,7 +231,6 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ship-card {
|
.ship-card {
|
||||||
border-radius: 0;
|
|
||||||
border-top: 1px solid rgba(127, 214, 255, 0.14);
|
border-top: 1px solid rgba(127, 214, 255, 0.14);
|
||||||
border-right: 1px solid rgba(127, 214, 255, 0.1);
|
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));
|
background: linear-gradient(180deg, rgba(10, 20, 36, 0.96), rgba(6, 12, 22, 0.98));
|
||||||
@@ -462,7 +240,7 @@ canvas {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: var(--text);
|
color: var(--viewer-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
|
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
|
||||||
}
|
}
|
||||||
@@ -490,11 +268,26 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ship-card h3 {
|
.ship-card h3 {
|
||||||
|
margin: 0;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
letter-spacing: 0.04em;
|
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 {
|
.ship-card-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -506,30 +299,12 @@ canvas {
|
|||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(127, 214, 255, 0.12);
|
background: rgba(127, 214, 255, 0.12);
|
||||||
color: var(--accent);
|
color: var(--viewer-accent);
|
||||||
font-size: 0.64rem;
|
font-size: 0.64rem;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
text-transform: uppercase;
|
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 {
|
.ship-card-ai {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
@@ -538,11 +313,22 @@ canvas {
|
|||||||
|
|
||||||
.ship-card-section-title {
|
.ship-card-section-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--accent);
|
color: var(--viewer-accent);
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.14em;
|
||||||
text-transform: uppercase;
|
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 {
|
.ship-card-history-button {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@@ -555,6 +341,11 @@ canvas {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-window-copy,
|
||||||
|
.history-window-close {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.faction-card {
|
.faction-card {
|
||||||
border-top-color: rgba(180, 130, 255, 0.3);
|
border-top-color: rgba(180, 130, 255, 0.3);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -573,11 +364,14 @@ canvas {
|
|||||||
border-color: rgba(127, 255, 180, 0.5);
|
border-color: rgba(127, 255, 180, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.swatch {
|
.ship-card-split-line {
|
||||||
width: 14px;
|
display: flex;
|
||||||
height: 48px;
|
justify-content: space-between;
|
||||||
border-radius: 999px;
|
gap: 12px;
|
||||||
flex: none;
|
}
|
||||||
|
|
||||||
|
.selection-action-button {
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1080px) {
|
@media (max-width: 1080px) {
|
||||||
@@ -587,53 +381,8 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@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 {
|
.ops-strip {
|
||||||
left: 0;
|
width: 100vw;
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 50vw;
|
|
||||||
min-height: 120px;
|
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).
|
// Close-orbit distance when double-clicking a planet (display units).
|
||||||
// 0.005 units = ~333 km from planet center in system space.
|
// 0.005 units = ~333 km from planet center in system space.
|
||||||
export const NAV_DISTANCE_PLANET_ORBIT = 0.005;
|
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 ACTIVE_SYSTEM_DETAIL_SCALE = 10;
|
||||||
export const GALAXY_PARALLAX_FACTOR = 0.025;
|
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 STAR_RENDER_SCALE = 0.18;
|
||||||
export const PLANET_RENDER_SCALE = 0.95;
|
export const PLANET_RENDER_SCALE = 0.95;
|
||||||
export const MOON_RENDER_SCALE = 1.1;
|
export const MOON_RENDER_SCALE = 1.1;
|
||||||
// 0.002 units = ~133 km — allows scrolling into low orbit around planets.
|
// 0.00005 units = ~3 km — allows scrolling very close to ships and structures.
|
||||||
export const MIN_CAMERA_DISTANCE = 0.002;
|
export const MIN_CAMERA_DISTANCE = 0.00005;
|
||||||
export const MAX_CAMERA_DISTANCE = 150000;
|
export const MAX_CAMERA_DISTANCE = 150000;
|
||||||
|
|
||||||
export interface ZoomBlend {
|
export interface ZoomBlend {
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export function createViewerControllers(host: any) {
|
|||||||
getPovLevel: () => host.povLevel,
|
getPovLevel: () => host.povLevel,
|
||||||
getSelectedItems: () => host.selectedItems,
|
getSelectedItems: () => host.selectedItems,
|
||||||
getOrbitYaw: () => host.orbitYaw,
|
getOrbitYaw: () => host.orbitYaw,
|
||||||
|
getFollowOrbitYaw: () => host.followOrbitYaw,
|
||||||
|
getFollowOrbitPitch: () => host.followOrbitPitch,
|
||||||
galaxyAnchor: host.galaxyAnchor,
|
galaxyAnchor: host.galaxyAnchor,
|
||||||
systemAnchor: host.systemAnchor,
|
systemAnchor: host.systemAnchor,
|
||||||
galaxyCamera: host.galaxyLayer.camera,
|
galaxyCamera: host.galaxyLayer.camera,
|
||||||
@@ -77,21 +79,13 @@ export function createViewerControllers(host: any) {
|
|||||||
|
|
||||||
const presentationController = new ViewerPresentationController({
|
const presentationController = new ViewerPresentationController({
|
||||||
renderer: host.renderer,
|
renderer: host.renderer,
|
||||||
|
hudState: host.hudState,
|
||||||
galaxyScene: host.galaxyLayer.scene,
|
galaxyScene: host.galaxyLayer.scene,
|
||||||
galaxyCamera: host.galaxyLayer.camera,
|
galaxyCamera: host.galaxyLayer.camera,
|
||||||
systemCamera: host.systemLayer.camera,
|
systemCamera: host.systemLayer.camera,
|
||||||
galaxyAnchor: host.galaxyAnchor,
|
galaxyAnchor: host.galaxyAnchor,
|
||||||
systemAnchor: host.systemAnchor,
|
systemAnchor: host.systemAnchor,
|
||||||
ambienceGroup: host.universeLayer.ambienceGroup,
|
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,
|
networkStats: host.networkStats,
|
||||||
performanceStats: host.performanceStats,
|
performanceStats: host.performanceStats,
|
||||||
getWorld: () => host.world,
|
getWorld: () => host.world,
|
||||||
@@ -135,10 +129,7 @@ export function createViewerControllers(host: any) {
|
|||||||
getCameraTargetShipId: () => host.cameraTargetShipId,
|
getCameraTargetShipId: () => host.cameraTargetShipId,
|
||||||
getNetworkStats: () => host.networkStats,
|
getNetworkStats: () => host.networkStats,
|
||||||
getSystemSummaryVisuals: () => new Map(),
|
getSystemSummaryVisuals: () => new Map(),
|
||||||
errorEl: host.errorEl,
|
hudState: host.hudState,
|
||||||
opsStripEl: host.opsStripEl,
|
|
||||||
detailTitleEl: host.detailTitleEl,
|
|
||||||
detailBodyEl: host.detailBodyEl,
|
|
||||||
worldLabel: () => host.world?.label ?? "",
|
worldLabel: () => host.world?.label ?? "",
|
||||||
rebuildSystems: (systems) => sceneDataController.rebuildSystems(systems),
|
rebuildSystems: (systems) => sceneDataController.rebuildSystems(systems),
|
||||||
syncCelestials: (celestials) => sceneDataController.syncCelestials(celestials),
|
syncCelestials: (celestials) => sceneDataController.syncCelestials(celestials),
|
||||||
@@ -164,7 +155,6 @@ export function createViewerControllers(host: any) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const historyController = new ViewerHistoryWindowController({
|
const historyController = new ViewerHistoryWindowController({
|
||||||
historyLayerEl: host.historyLayerEl,
|
|
||||||
historyWindows: host.historyWindows,
|
historyWindows: host.historyWindows,
|
||||||
getWorld: () => host.world,
|
getWorld: () => host.world,
|
||||||
getHistoryWindowCounter: () => host.historyWindowCounter,
|
getHistoryWindowCounter: () => host.historyWindowCounter,
|
||||||
@@ -198,13 +188,14 @@ export function createViewerControllers(host: any) {
|
|||||||
hoverLabelEl: host.hoverLabelEl,
|
hoverLabelEl: host.hoverLabelEl,
|
||||||
hoverConnectorLineEl: host.hoverConnectorLineEl,
|
hoverConnectorLineEl: host.hoverConnectorLineEl,
|
||||||
marqueeEl: host.marqueeEl,
|
marqueeEl: host.marqueeEl,
|
||||||
|
hudState: host.hudState,
|
||||||
keyState: host.keyState,
|
keyState: host.keyState,
|
||||||
getWorld: () => host.world,
|
getWorld: () => host.world,
|
||||||
getActiveSystemId: () => host.activeSystemId,
|
getActiveSystemId: () => host.activeSystemId,
|
||||||
getPovLevel: () => host.povLevel,
|
getPovLevel: () => host.povLevel,
|
||||||
getSelectedItems: () => host.selectedItems,
|
getSelectedItems: () => host.selectedItems,
|
||||||
setSelectedItems: (items) => {
|
setSelectedItems: (items) => {
|
||||||
host.selectedItems = items;
|
host.applySelectedItems(items, "viewer");
|
||||||
},
|
},
|
||||||
getDragMode: () => host.dragMode,
|
getDragMode: () => host.dragMode,
|
||||||
setDragMode: (mode) => {
|
setDragMode: (mode) => {
|
||||||
@@ -240,8 +231,13 @@ export function createViewerControllers(host: any) {
|
|||||||
getFollowCameraFocus: () => host.followCameraFocus,
|
getFollowCameraFocus: () => host.followCameraFocus,
|
||||||
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
|
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
|
||||||
applyOrbitDelta: (delta: THREE.Vector2) => {
|
applyOrbitDelta: (delta: THREE.Vector2) => {
|
||||||
|
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.orbitYaw += delta.x * 0.008;
|
||||||
host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3);
|
host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
syncFollowStateFromSelection: () => navigationController.syncFollowStateFromSelection(),
|
syncFollowStateFromSelection: () => navigationController.syncFollowStateFromSelection(),
|
||||||
updatePanels: () => host.updatePanels(),
|
updatePanels: () => host.updatePanels(),
|
||||||
@@ -261,20 +257,33 @@ export function createViewerControllers(host: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function wireViewerEvents(host: any) {
|
export function wireViewerEvents(host: any) {
|
||||||
host.renderer.domElement.addEventListener("pointerdown", host.interactionController.onPointerDown);
|
const canvas = host.renderer.domElement;
|
||||||
host.renderer.domElement.addEventListener("pointermove", host.interactionController.onPointerMove);
|
canvas.addEventListener("pointerdown", host.interactionController.onPointerDown);
|
||||||
host.renderer.domElement.addEventListener("pointerup", host.interactionController.onPointerUp);
|
canvas.addEventListener("pointermove", host.interactionController.onPointerMove);
|
||||||
host.renderer.domElement.addEventListener("pointerleave", host.interactionController.onPointerUp);
|
canvas.addEventListener("pointerup", host.interactionController.onPointerUp);
|
||||||
host.renderer.domElement.addEventListener("click", host.interactionController.onClick);
|
canvas.addEventListener("pointerleave", host.interactionController.onPointerUp);
|
||||||
host.renderer.domElement.addEventListener("dblclick", host.interactionController.onDoubleClick);
|
canvas.addEventListener("click", host.interactionController.onClick);
|
||||||
host.renderer.domElement.addEventListener("wheel", host.interactionController.onWheel, { passive: false });
|
canvas.addEventListener("dblclick", host.interactionController.onDoubleClick);
|
||||||
host.opsStripEl.addEventListener("click", host.interactionController.onOpsStripClick);
|
canvas.addEventListener("wheel", host.interactionController.onWheel, { passive: false });
|
||||||
host.opsStripEl.addEventListener("dblclick", host.interactionController.onOpsStripDoubleClick);
|
|
||||||
host.historyLayerEl.addEventListener("click", host.interactionController.onHistoryLayerClick);
|
host.historyLayerEl.addEventListener("click", host.interactionController.onHistoryLayerClick);
|
||||||
host.historyLayerEl.addEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown);
|
host.historyLayerEl.addEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown);
|
||||||
window.addEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove);
|
window.addEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove);
|
||||||
window.addEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp);
|
window.addEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp);
|
||||||
window.addEventListener("keydown", host.interactionController.onKeyDown);
|
window.addEventListener("keydown", host.interactionController.onKeyDown);
|
||||||
window.addEventListener("keyup", host.interactionController.onKeyUp);
|
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 {
|
return {
|
||||||
cameraMode: "follow" as const,
|
cameraMode: "follow" as const,
|
||||||
cameraTargetShipId: nextTargetShipId,
|
cameraTargetShipId: nextTargetShipId,
|
||||||
desiredDistance: Math.min(desiredDistance, 1800),
|
desiredDistance: Math.min(desiredDistance, 0.0012),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +90,8 @@ export function updateFollowCamera(params: {
|
|||||||
followCameraOffset: THREE.Vector3;
|
followCameraOffset: THREE.Vector3;
|
||||||
systemAnchor: THREE.Vector3;
|
systemAnchor: THREE.Vector3;
|
||||||
delta: number;
|
delta: number;
|
||||||
|
followOrbitYaw: number;
|
||||||
|
followOrbitPitch: number;
|
||||||
getAnimatedShipLocalPosition: (visual: ShipVisual) => THREE.Vector3;
|
getAnimatedShipLocalPosition: (visual: ShipVisual) => THREE.Vector3;
|
||||||
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
|
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
|
||||||
resolveShipHeading: (visual: ShipVisual, worldPosition: THREE.Vector3) => THREE.Vector3;
|
resolveShipHeading: (visual: ShipVisual, worldPosition: THREE.Vector3) => THREE.Vector3;
|
||||||
@@ -107,6 +109,8 @@ export function updateFollowCamera(params: {
|
|||||||
followCameraOffset,
|
followCameraOffset,
|
||||||
systemAnchor,
|
systemAnchor,
|
||||||
delta,
|
delta,
|
||||||
|
followOrbitYaw,
|
||||||
|
followOrbitPitch,
|
||||||
getAnimatedShipLocalPosition,
|
getAnimatedShipLocalPosition,
|
||||||
toDisplayLocalPosition,
|
toDisplayLocalPosition,
|
||||||
resolveShipHeading,
|
resolveShipHeading,
|
||||||
@@ -160,14 +164,23 @@ export function updateFollowCamera(params: {
|
|||||||
followCameraDirection.lerp(followCameraDesiredDirection, 1 - Math.exp(-delta * 5));
|
followCameraDirection.lerp(followCameraDesiredDirection, 1 - Math.exp(-delta * 5));
|
||||||
followCameraDirection.normalize();
|
followCameraDirection.normalize();
|
||||||
|
|
||||||
const distance = THREE.MathUtils.clamp(currentDistance * 0.72, 320, 6800);
|
const distance = THREE.MathUtils.clamp(currentDistance * 0.72, 0.00018, 0.012);
|
||||||
const height = THREE.MathUtils.clamp(distance * 0.18, 70, 1100);
|
const height = THREE.MathUtils.clamp(distance * 0.14, 0.00002, 0.0012);
|
||||||
const lookAhead = THREE.MathUtils.clamp(distance * 0.9, 220, 2400);
|
const lookAhead = THREE.MathUtils.clamp(distance * 2.6, 0.0006, 0.028);
|
||||||
followCameraOffset.copy(followCameraDirection).multiplyScalar(-distance);
|
|
||||||
|
// 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;
|
followCameraOffset.y += height;
|
||||||
|
|
||||||
const desiredPosition = shipWorldPosition.clone().add(followCameraOffset);
|
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;
|
desiredFocus.y += height * 0.28;
|
||||||
|
|
||||||
const positionLerp = 1 - Math.exp(-delta * 6);
|
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(
|
export function createHistoryWindowState(
|
||||||
documentRef: Document,
|
|
||||||
target: Selectable,
|
target: Selectable,
|
||||||
historyWindowsCount: number,
|
historyWindowsCount: number,
|
||||||
historyWindowCounter: number,
|
historyWindowCounter: number,
|
||||||
): HistoryWindowState {
|
): 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 {
|
return {
|
||||||
id,
|
id: `history-${historyWindowCounter}`,
|
||||||
target,
|
target,
|
||||||
root,
|
title: "History",
|
||||||
titleEl: root.querySelector(".history-window-title") as HTMLHeadingElement,
|
bodyHtml: "No history selected.",
|
||||||
bodyEl: root.querySelector(".history-window-body") as HTMLDivElement,
|
|
||||||
copyButtonEl: root.querySelector(".history-window-copy") as HTMLButtonElement,
|
|
||||||
text: "",
|
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;
|
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.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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +44,9 @@ export function refreshHistoryWindow(
|
|||||||
return false;
|
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.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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { createHistoryWindowState, refreshHistoryWindow } from "./viewerHistory";
|
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(
|
export function openHistoryWindow(
|
||||||
historyWindows: HistoryWindowState[],
|
historyWindows: HistoryWindowState[],
|
||||||
historyLayerEl: HTMLDivElement,
|
|
||||||
target: Selectable,
|
target: Selectable,
|
||||||
nextCounter: number,
|
nextCounter: number,
|
||||||
bringToFront: (windowState: HistoryWindowState) => void,
|
bringToFront: (windowState: HistoryWindowState) => void,
|
||||||
@@ -17,9 +17,8 @@ export function openHistoryWindow(
|
|||||||
return nextCounter;
|
return nextCounter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const windowState = createHistoryWindowState(document, target, historyWindows.length, nextCounter);
|
const windowState = createHistoryWindowState(target, historyWindows.length, nextCounter);
|
||||||
historyWindows.push(windowState);
|
historyWindows.push(windowState);
|
||||||
historyLayerEl.append(windowState.root);
|
|
||||||
bringToFront(windowState);
|
bringToFront(windowState);
|
||||||
refreshWindows();
|
refreshWindows();
|
||||||
return nextCounter;
|
return nextCounter;
|
||||||
@@ -56,8 +55,7 @@ export function destroyHistoryWindow(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [removed] = historyWindows.splice(index, 1);
|
historyWindows.splice(index, 1);
|
||||||
removed.root.remove();
|
|
||||||
if (historyWindowDragId === id) {
|
if (historyWindowDragId === id) {
|
||||||
return {
|
return {
|
||||||
historyWindowDragId: undefined,
|
historyWindowDragId: undefined,
|
||||||
@@ -72,7 +70,7 @@ export function destroyHistoryWindow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bringHistoryWindowToFront(windowState: HistoryWindowState, nextZIndex: number) {
|
export function bringHistoryWindowToFront(windowState: HistoryWindowState, nextZIndex: number) {
|
||||||
windowState.root.style.zIndex = `${nextZIndex}`;
|
windowState.zIndex = nextZIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function beginHistoryWindowDrag(
|
export function beginHistoryWindowDrag(
|
||||||
@@ -91,9 +89,7 @@ export function beginHistoryWindowDrag(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const bounds = windowState.root.getBoundingClientRect();
|
historyWindowDragOffset.set(clientX - windowState.x, clientY - windowState.y);
|
||||||
historyWindowDragOffset.set(clientX - bounds.left, clientY - bounds.top);
|
|
||||||
windowState.root.setPointerCapture?.(pointerId);
|
|
||||||
return {
|
return {
|
||||||
historyWindowDragId: windowId,
|
historyWindowDragId: windowId,
|
||||||
historyWindowDragPointerId: pointerId,
|
historyWindowDragPointerId: pointerId,
|
||||||
@@ -118,16 +114,12 @@ export function updateHistoryWindowDrag(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const width = windowState.root.offsetWidth;
|
windowState.x = THREE.MathUtils.clamp(clientX - historyWindowDragOffset.x, 20, window.innerWidth - windowState.width - 20);
|
||||||
const height = windowState.root.offsetHeight;
|
windowState.y = THREE.MathUtils.clamp(clientY - historyWindowDragOffset.y, 20, window.innerHeight - windowState.height - 20);
|
||||||
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`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function endHistoryWindowDrag(
|
export function endHistoryWindowDrag(
|
||||||
historyWindows: HistoryWindowState[],
|
_historyWindows: HistoryWindowState[],
|
||||||
historyWindowDragId: string | undefined,
|
historyWindowDragId: string | undefined,
|
||||||
historyWindowDragPointerId: number | undefined,
|
historyWindowDragPointerId: number | undefined,
|
||||||
pointerId: number,
|
pointerId: number,
|
||||||
@@ -139,8 +131,6 @@ export function endHistoryWindowDrag(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const windowState = historyWindows.find((candidate) => candidate.id === historyWindowDragId);
|
|
||||||
windowState?.root.releasePointerCapture?.(pointerId);
|
|
||||||
return {
|
return {
|
||||||
historyWindowDragId: undefined,
|
historyWindowDragId: undefined,
|
||||||
historyWindowDragPointerId: undefined,
|
historyWindowDragPointerId: undefined,
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import {
|
|||||||
refreshHistoryWindows,
|
refreshHistoryWindows,
|
||||||
updateHistoryWindowDrag,
|
updateHistoryWindowDrag,
|
||||||
} from "./viewerHistoryManager";
|
} from "./viewerHistoryManager";
|
||||||
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
|
import type { HistoryWindowState } from "./viewerHudState";
|
||||||
|
import type { Selectable, WorldState } from "./viewerTypes";
|
||||||
|
|
||||||
export interface ViewerHistoryWindowContext {
|
export interface ViewerHistoryWindowContext {
|
||||||
historyLayerEl: HTMLDivElement;
|
|
||||||
historyWindows: HistoryWindowState[];
|
historyWindows: HistoryWindowState[];
|
||||||
getWorld: () => WorldState | undefined;
|
getWorld: () => WorldState | undefined;
|
||||||
getHistoryWindowCounter: () => number;
|
getHistoryWindowCounter: () => number;
|
||||||
@@ -33,7 +33,6 @@ export class ViewerHistoryWindowController {
|
|||||||
openHistoryWindow(target: Selectable) {
|
openHistoryWindow(target: Selectable) {
|
||||||
const nextCounter = openHistoryWindow(
|
const nextCounter = openHistoryWindow(
|
||||||
this.context.historyWindows,
|
this.context.historyWindows,
|
||||||
this.context.historyLayerEl,
|
|
||||||
target,
|
target,
|
||||||
this.context.getHistoryWindowCounter() + 1,
|
this.context.getHistoryWindowCounter() + 1,
|
||||||
(windowState) => this.bringHistoryWindowToFront(windowState),
|
(windowState) => this.bringHistoryWindowToFront(windowState),
|
||||||
@@ -155,14 +154,14 @@ export class ViewerHistoryWindowController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await copyTextToClipboard(windowState.text);
|
await copyTextToClipboard(windowState.text);
|
||||||
windowState.copyButtonEl.textContent = "Copied";
|
windowState.copyLabel = "Copied";
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
windowState.copyButtonEl.textContent = "Copy";
|
windowState.copyLabel = "Copy";
|
||||||
}, 1200);
|
}, 1200);
|
||||||
} catch {
|
} catch {
|
||||||
windowState.copyButtonEl.textContent = "Failed";
|
windowState.copyLabel = "Failed";
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
windowState.copyButtonEl.textContent = "Copy";
|
windowState.copyLabel = "Copy";
|
||||||
}, 1200);
|
}, 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 { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
|
||||||
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
|
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 { 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";
|
import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes";
|
||||||
|
|
||||||
export interface HoverPickResult {
|
export interface HoverPickResult {
|
||||||
@@ -67,6 +68,7 @@ export function pickSelectableHitAtClientPosition(
|
|||||||
|
|
||||||
export function updateHoverLabel(params: {
|
export function updateHoverLabel(params: {
|
||||||
dragMode?: string;
|
dragMode?: string;
|
||||||
|
hoverState: HoverLabelState;
|
||||||
hoverLabelEl: HTMLDivElement;
|
hoverLabelEl: HTMLDivElement;
|
||||||
hoverConnectorLineEl: SVGLineElement;
|
hoverConnectorLineEl: SVGLineElement;
|
||||||
hoverPick: HoverPickResult | undefined;
|
hoverPick: HoverPickResult | undefined;
|
||||||
@@ -77,6 +79,7 @@ export function updateHoverLabel(params: {
|
|||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
dragMode,
|
dragMode,
|
||||||
|
hoverState,
|
||||||
hoverLabelEl,
|
hoverLabelEl,
|
||||||
hoverConnectorLineEl,
|
hoverConnectorLineEl,
|
||||||
hoverPick,
|
hoverPick,
|
||||||
@@ -87,6 +90,8 @@ export function updateHoverLabel(params: {
|
|||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
if (dragMode || !hoverPick) {
|
if (dragMode || !hoverPick) {
|
||||||
|
hoverState.hidden = true;
|
||||||
|
hoverState.connectorHidden = true;
|
||||||
hoverLabelEl.hidden = true;
|
hoverLabelEl.hidden = true;
|
||||||
hoverConnectorLineEl.setAttribute("hidden", "");
|
hoverConnectorLineEl.setAttribute("hidden", "");
|
||||||
return;
|
return;
|
||||||
@@ -95,6 +100,8 @@ export function updateHoverLabel(params: {
|
|||||||
const { selection, object, camera } = hoverPick;
|
const { selection, object, camera } = hoverPick;
|
||||||
const label = describeHoverLabel(world, selection);
|
const label = describeHoverLabel(world, selection);
|
||||||
if (!label) {
|
if (!label) {
|
||||||
|
hoverState.hidden = true;
|
||||||
|
hoverState.connectorHidden = true;
|
||||||
hoverLabelEl.hidden = true;
|
hoverLabelEl.hidden = true;
|
||||||
hoverConnectorLineEl.setAttribute("hidden", "");
|
hoverConnectorLineEl.setAttribute("hidden", "");
|
||||||
return;
|
return;
|
||||||
@@ -102,18 +109,27 @@ export function updateHoverLabel(params: {
|
|||||||
|
|
||||||
const distance = formatHoverDistance(camera, object, selection, povLevel, activeSystemId);
|
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.hidden = false;
|
||||||
hoverLabelEl.textContent = `${label}\n${distance}`;
|
hoverLabelEl.textContent = hoverState.text;
|
||||||
hoverLabelEl.style.left = `${point.x + 44}px`;
|
hoverLabelEl.style.left = `${hoverState.x}px`;
|
||||||
hoverLabelEl.style.top = `${point.y - 90}px`;
|
hoverLabelEl.style.top = `${hoverState.y}px`;
|
||||||
|
|
||||||
const rect = hoverLabelEl.getBoundingClientRect();
|
const rect = hoverLabelEl.getBoundingClientRect();
|
||||||
const svgRect = (hoverConnectorLineEl.ownerSVGElement as SVGSVGElement).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.removeAttribute("hidden");
|
||||||
hoverConnectorLineEl.setAttribute("x1", String(point.x));
|
hoverConnectorLineEl.setAttribute("x1", String(hoverState.x1));
|
||||||
hoverConnectorLineEl.setAttribute("y1", String(point.y));
|
hoverConnectorLineEl.setAttribute("y1", String(hoverState.y1));
|
||||||
hoverConnectorLineEl.setAttribute("x2", String(rect.left - svgRect.left));
|
hoverConnectorLineEl.setAttribute("x2", String(hoverState.x2));
|
||||||
hoverConnectorLineEl.setAttribute("y2", String(rect.top - svgRect.top + rect.height / 2));
|
hoverConnectorLineEl.setAttribute("y2", String(hoverState.y2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHoverDistance(
|
function formatHoverDistance(
|
||||||
@@ -150,6 +166,7 @@ function formatHoverDistance(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updateMarqueeBox(
|
export function updateMarqueeBox(
|
||||||
|
marqueeState: MarqueeState,
|
||||||
marqueeEl: HTMLDivElement,
|
marqueeEl: HTMLDivElement,
|
||||||
dragStart: THREE.Vector2,
|
dragStart: THREE.Vector2,
|
||||||
dragLast: THREE.Vector2,
|
dragLast: THREE.Vector2,
|
||||||
@@ -158,13 +175,21 @@ export function updateMarqueeBox(
|
|||||||
const minY = Math.min(dragStart.y, dragLast.y);
|
const minY = Math.min(dragStart.y, dragLast.y);
|
||||||
const maxX = Math.max(dragStart.x, dragLast.x);
|
const maxX = Math.max(dragStart.x, dragLast.x);
|
||||||
const maxY = Math.max(dragStart.y, dragLast.y);
|
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.left = `${minX}px`;
|
||||||
marqueeEl.style.top = `${minY}px`;
|
marqueeEl.style.top = `${minY}px`;
|
||||||
marqueeEl.style.width = `${maxX - minX}px`;
|
marqueeEl.style.width = `${maxX - minX}px`;
|
||||||
marqueeEl.style.height = `${maxY - minY}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.display = "none";
|
||||||
marqueeEl.style.width = "0";
|
marqueeEl.style.width = "0";
|
||||||
marqueeEl.style.height = "0";
|
marqueeEl.style.height = "0";
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import {
|
|||||||
toggleCameraMode,
|
toggleCameraMode,
|
||||||
navigateFromWheel,
|
navigateFromWheel,
|
||||||
} from "./viewerControls";
|
} 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 { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
|
||||||
|
import type { ViewerHudState } from "./viewerHudState";
|
||||||
import type {
|
import type {
|
||||||
CameraMode,
|
CameraMode,
|
||||||
DragMode,
|
DragMode,
|
||||||
@@ -33,6 +34,7 @@ export interface ViewerInteractionContext {
|
|||||||
hoverLabelEl: HTMLDivElement;
|
hoverLabelEl: HTMLDivElement;
|
||||||
hoverConnectorLineEl: SVGLineElement;
|
hoverConnectorLineEl: SVGLineElement;
|
||||||
marqueeEl: HTMLDivElement;
|
marqueeEl: HTMLDivElement;
|
||||||
|
hudState: ViewerHudState;
|
||||||
keyState: Set<string>;
|
keyState: Set<string>;
|
||||||
getWorld: () => WorldState | undefined;
|
getWorld: () => WorldState | undefined;
|
||||||
getActiveSystemId: () => string | undefined;
|
getActiveSystemId: () => string | undefined;
|
||||||
@@ -109,6 +111,7 @@ export class ViewerInteractionController {
|
|||||||
if (!this.context.getMarqueeActive() && dragDistance > 8) {
|
if (!this.context.getMarqueeActive() && dragDistance > 8) {
|
||||||
this.context.setMarqueeActive(true);
|
this.context.setMarqueeActive(true);
|
||||||
this.context.setSuppressClickSelection(true);
|
this.context.setSuppressClickSelection(true);
|
||||||
|
this.context.hudState.marquee.visible = true;
|
||||||
this.context.marqueeEl.style.display = "block";
|
this.context.marqueeEl.style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +120,7 @@ export class ViewerInteractionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.context.dragLast.copy(point);
|
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) => {
|
readonly onPointerUp = (event: PointerEvent) => {
|
||||||
@@ -131,7 +134,7 @@ export class ViewerInteractionController {
|
|||||||
|
|
||||||
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
|
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
|
||||||
this.completeMarqueeSelection();
|
this.completeMarqueeSelection();
|
||||||
hideMarqueeBox(this.context.marqueeEl);
|
hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.context.setDragMode(undefined);
|
this.context.setDragMode(undefined);
|
||||||
@@ -202,6 +205,7 @@ export class ViewerInteractionController {
|
|||||||
this.context.syncFollowStateFromSelection();
|
this.context.syncFollowStateFromSelection();
|
||||||
this.context.focusOnSelection({ kind: "ship", id: shipId });
|
this.context.focusOnSelection({ kind: "ship", id: shipId });
|
||||||
this.toggleCameraMode("follow");
|
this.toggleCameraMode("follow");
|
||||||
|
this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL);
|
||||||
this.context.updatePanels();
|
this.context.updatePanels();
|
||||||
this.context.updateGamePanel("Live");
|
this.context.updateGamePanel("Live");
|
||||||
return;
|
return;
|
||||||
@@ -238,6 +242,16 @@ export class ViewerInteractionController {
|
|||||||
this.context.syncFollowStateFromSelection();
|
this.context.syncFollowStateFromSelection();
|
||||||
if (selection.kind === "planet") {
|
if (selection.kind === "planet") {
|
||||||
this.context.setDesiredDistance(NAV_DISTANCE_PLANET_ORBIT);
|
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(event: PointerEvent) {
|
||||||
updateHoverLabel({
|
updateHoverLabel({
|
||||||
dragMode: this.context.getDragMode(),
|
dragMode: this.context.getDragMode(),
|
||||||
|
hoverState: this.context.hudState.hoverLabel,
|
||||||
hoverLabelEl: this.context.hoverLabelEl,
|
hoverLabelEl: this.context.hoverLabelEl,
|
||||||
hoverConnectorLineEl: this.context.hoverConnectorLineEl,
|
hoverConnectorLineEl: this.context.hoverConnectorLineEl,
|
||||||
hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY),
|
hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY),
|
||||||
@@ -288,6 +303,10 @@ export class ViewerInteractionController {
|
|||||||
this.context.historyController.refreshHistoryWindows();
|
this.context.historyController.refreshHistoryWindows();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openHistoryWindow(selection: Selectable) {
|
||||||
|
this.context.historyController.openHistoryWindow(selection);
|
||||||
|
}
|
||||||
|
|
||||||
toggleCameraMode(forceMode?: CameraMode) {
|
toggleCameraMode(forceMode?: CameraMode) {
|
||||||
const nextState = toggleCameraMode({
|
const nextState = toggleCameraMode({
|
||||||
cameraMode: this.context.getCameraMode(),
|
cameraMode: this.context.getCameraMode(),
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface ViewerNavigationContext {
|
|||||||
getPovLevel: () => PovLevel;
|
getPovLevel: () => PovLevel;
|
||||||
getSelectedItems: () => Selectable[];
|
getSelectedItems: () => Selectable[];
|
||||||
getOrbitYaw: () => number;
|
getOrbitYaw: () => number;
|
||||||
|
getFollowOrbitYaw: () => number;
|
||||||
|
getFollowOrbitPitch: () => number;
|
||||||
galaxyAnchor: THREE.Vector3;
|
galaxyAnchor: THREE.Vector3;
|
||||||
systemAnchor: THREE.Vector3;
|
systemAnchor: THREE.Vector3;
|
||||||
galaxyCamera: THREE.PerspectiveCamera;
|
galaxyCamera: THREE.PerspectiveCamera;
|
||||||
@@ -126,6 +128,8 @@ export class ViewerNavigationController {
|
|||||||
followCameraOffset: this.context.followCameraOffset,
|
followCameraOffset: this.context.followCameraOffset,
|
||||||
systemAnchor: this.context.systemAnchor,
|
systemAnchor: this.context.systemAnchor,
|
||||||
delta,
|
delta,
|
||||||
|
followOrbitYaw: this.context.getFollowOrbitYaw(),
|
||||||
|
followOrbitPitch: this.context.getFollowOrbitPitch(),
|
||||||
getAnimatedShipLocalPosition,
|
getAnimatedShipLocalPosition,
|
||||||
toDisplayLocalPosition: (localPosition) => toDisplayLocalPosition(localPosition),
|
toDisplayLocalPosition: (localPosition) => toDisplayLocalPosition(localPosition),
|
||||||
resolveShipHeading: (visual, worldPosition) => resolveShipHeading(visual, worldPosition, this.context.getOrbitYaw()),
|
resolveShipHeading: (visual, worldPosition) => resolveShipHeading(visual, worldPosition, this.context.getOrbitYaw()),
|
||||||
|
|||||||
@@ -1,154 +1,131 @@
|
|||||||
import type { StationSnapshot } from "./contractsInfrastructure";
|
import type { StationSnapshot } from "./contractsInfrastructure";
|
||||||
import type { FactionSnapshot } from "./contractsFactions";
|
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 { describeShipCurrentAction, describeShipLocation, describeShipObjective, describeShipState } from "./viewerSelection";
|
||||||
import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes";
|
import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes";
|
||||||
|
|
||||||
function renderFactionCard(faction: FactionSnapshot): string {
|
function buildFactionCard(faction: FactionSnapshot): OpsFactionCardState {
|
||||||
const state = faction.goapState;
|
const state = faction.goapState;
|
||||||
const priorities = faction.goapPriorities;
|
return {
|
||||||
|
kind: "faction",
|
||||||
return `
|
id: faction.id,
|
||||||
<article class="ship-card faction-card" data-faction-id="${faction.id}">
|
label: faction.label,
|
||||||
<div class="ship-card-header">
|
stateLines: state ? [
|
||||||
<h3>${faction.label}</h3>
|
`Military ${state.militaryShipCount} · Miners ${state.minerShipCount}`,
|
||||||
<span class="ship-card-badge">faction</span>
|
`Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}`,
|
||||||
</div>
|
`Systems ${state.controlledSystemCount} / ${state.targetSystemCount}`,
|
||||||
${state ? `
|
`Factory ${state.hasShipFactory ? "yes" : "no"} · Ore ${state.oreStockpile.toFixed(0)}`,
|
||||||
<div class="ship-card-ai">
|
] : [],
|
||||||
<p class="ship-card-section-title">GOAP State</p>
|
priorities: (faction.goapPriorities ?? []).map((entry) => ({
|
||||||
<p>Military ${state.militaryShipCount} · Miners ${state.minerShipCount}</p>
|
label: entry.goalName,
|
||||||
<p>Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}</p>
|
value: entry.priority.toFixed(0),
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStationCard(station: StationSnapshot, isSelected: boolean): string {
|
function buildProgressBar(label: string, progress: number): HudProgressBar {
|
||||||
const cargo = station.inventory.reduce((sum, e) => sum + e.amount, 0);
|
return {
|
||||||
const processes = station.currentProcesses;
|
label,
|
||||||
|
valueLabel: `${Math.round(progress * 100)}%`,
|
||||||
return `
|
progress: Number((progress * 100).toFixed(1)),
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
world: WorldState | undefined,
|
||||||
selectedItems: Selectable[],
|
selectedItems: Selectable[],
|
||||||
cameraMode: CameraMode,
|
cameraMode: CameraMode,
|
||||||
cameraTargetShipId?: string,
|
cameraTargetShipId?: string,
|
||||||
povLevel?: PovLevel,
|
povLevel?: PovLevel,
|
||||||
activeSystemId?: string,
|
activeSystemId?: string,
|
||||||
) {
|
): OpsStripState {
|
||||||
if (!world) {
|
if (!world) {
|
||||||
return "";
|
return {
|
||||||
|
factions: [],
|
||||||
|
stations: [],
|
||||||
|
ships: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSystemFiltered = povLevel !== "galaxy" && activeSystemId != null;
|
const isSystemFiltered = povLevel !== "galaxy" && activeSystemId != null;
|
||||||
|
|
||||||
const factionCards = [...world.factions.values()]
|
const factions = [...world.factions.values()]
|
||||||
.sort((a, b) => a.label.localeCompare(b.label))
|
.sort((left, right) => left.label.localeCompare(right.label))
|
||||||
.map(renderFactionCard)
|
.map(buildFactionCard);
|
||||||
.join("");
|
|
||||||
|
|
||||||
const stationCards = [...world.stations.values()]
|
const stations = [...world.stations.values()]
|
||||||
.filter((station) => !isSystemFiltered || station.systemId === activeSystemId)
|
.filter((station) => !isSystemFiltered || station.systemId === activeSystemId)
|
||||||
.sort((a, b) => a.label.localeCompare(b.label))
|
.sort((left, right) => left.label.localeCompare(right.label))
|
||||||
.map((station) => {
|
.map((station) => buildStationCard(
|
||||||
const isSelected = selectedItems.length === 1
|
station,
|
||||||
&& selectedItems[0].kind === "station"
|
selectedItems.length === 1 && selectedItems[0].kind === "station" && selectedItems[0].id === station.id,
|
||||||
&& selectedItems[0].id === station.id;
|
));
|
||||||
return renderStationCard(station, isSelected);
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const ships = [...world.ships.values()]
|
const ships = [...world.ships.values()]
|
||||||
.filter((ship) => !isSystemFiltered || ship.systemId === activeSystemId)
|
.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
|
return { factions, stations, 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user