diff --git a/.gitignore b/.gitignore
index af5f487..b165f9e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
node_modules/
dist/
+bin/
+obj/
*.tsbuildinfo
diff --git a/SpaceGame.slnx b/SpaceGame.slnx
new file mode 100644
index 0000000..2f6f43f
--- /dev/null
+++ b/SpaceGame.slnx
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/backend/Contracts/WorldContracts.cs b/apps/backend/Contracts/WorldContracts.cs
new file mode 100644
index 0000000..3ca2a3a
--- /dev/null
+++ b/apps/backend/Contracts/WorldContracts.cs
@@ -0,0 +1,76 @@
+namespace SpaceGame.Simulation.Api.Contracts;
+
+public sealed record WorldSnapshot(
+ string Label,
+ int Seed,
+ DateTimeOffset GeneratedAtUtc,
+ IReadOnlyList Systems,
+ IReadOnlyList Nodes,
+ IReadOnlyList Stations,
+ IReadOnlyList Ships,
+ IReadOnlyList Factions);
+
+public sealed record SystemSnapshot(
+ string Id,
+ string Label,
+ Vector3Dto Position,
+ string StarColor,
+ float StarSize,
+ IReadOnlyList Planets);
+
+public sealed record PlanetSnapshot(
+ string Label,
+ float OrbitRadius,
+ float Size,
+ string Color,
+ bool HasRing);
+
+public sealed record ResourceNodeSnapshot(
+ string Id,
+ string SystemId,
+ Vector3Dto Position,
+ float OreRemaining,
+ float MaxOre,
+ string ItemId);
+
+public sealed record StationSnapshot(
+ string Id,
+ string Label,
+ string Category,
+ string SystemId,
+ Vector3Dto Position,
+ string Color,
+ int DockedShips,
+ float OreStored,
+ float RefinedStock,
+ string FactionId);
+
+public sealed record ShipSnapshot(
+ string Id,
+ string Label,
+ string Role,
+ string ShipClass,
+ string SystemId,
+ Vector3Dto Position,
+ string State,
+ string? OrderKind,
+ string DefaultBehaviorKind,
+ string ControllerTaskKind,
+ float Cargo,
+ float CargoCapacity,
+ string? CargoItemId,
+ string FactionId,
+ float Health,
+ IReadOnlyList History);
+
+public sealed record FactionSnapshot(
+ string Id,
+ string Label,
+ string Color,
+ float Credits,
+ float OreMined,
+ float GoodsProduced,
+ int ShipsBuilt,
+ int ShipsLost);
+
+public sealed record Vector3Dto(float X, float Y, float Z);
diff --git a/apps/backend/Data/WorldDefinitions.cs b/apps/backend/Data/WorldDefinitions.cs
new file mode 100644
index 0000000..35bcd9b
--- /dev/null
+++ b/apps/backend/Data/WorldDefinitions.cs
@@ -0,0 +1,136 @@
+namespace SpaceGame.Simulation.Api.Data;
+
+public sealed class BalanceDefinition
+{
+ public float YPlane { get; set; }
+ public float ArrivalThreshold { get; set; }
+ public float MiningRate { get; set; }
+ public float TransferRate { get; set; }
+ public float DockingDuration { get; set; }
+ public float UndockDistance { get; set; }
+ public EnergyBalanceDefinition Energy { get; set; } = new();
+ public FuelBalanceDefinition Fuel { get; set; } = new();
+}
+
+public sealed class EnergyBalanceDefinition
+{
+ public float IdleDrain { get; set; }
+ public float MoveDrain { get; set; }
+ public float WarpDrain { get; set; }
+ public float ShipRechargeRate { get; set; }
+ public float StationSolarCharge { get; set; }
+}
+
+public sealed class FuelBalanceDefinition
+{
+ public float WarpDrain { get; set; }
+}
+
+public sealed class SolarSystemDefinition
+{
+ public required string Id { get; set; }
+ public required string Label { get; set; }
+ public required float[] Position { get; set; }
+ public required string StarColor { get; set; }
+ public required string StarGlow { get; set; }
+ public float StarSize { get; set; }
+ public float GravityWellRadius { get; set; }
+ public required AsteroidFieldDefinition AsteroidField { get; set; }
+ public required List ResourceNodes { get; set; }
+ public required List Planets { get; set; }
+}
+
+public sealed class AsteroidFieldDefinition
+{
+ public int DecorationCount { get; set; }
+ public float RadiusOffset { get; set; }
+ public float RadiusVariance { get; set; }
+ public float HeightVariance { get; set; }
+}
+
+public sealed class ResourceNodeDefinition
+{
+ public float Angle { get; set; }
+ public float RadiusOffset { get; set; }
+ public float OreAmount { get; set; }
+ public required string ItemId { get; set; }
+ public int ShardCount { get; set; }
+}
+
+public sealed class PlanetDefinition
+{
+ public required string Label { get; set; }
+ public float OrbitRadius { get; set; }
+ public float OrbitSpeed { get; set; }
+ public float Size { get; set; }
+ public required string Color { get; set; }
+ public float Tilt { get; set; }
+ public bool HasRing { get; set; }
+}
+
+public sealed class ShipDefinition
+{
+ public required string Id { get; set; }
+ public required string Label { get; set; }
+ public required string Role { get; set; }
+ public required string ShipClass { get; set; }
+ public float Speed { get; set; }
+ public float FtlSpeed { get; set; }
+ public float SpoolTime { get; set; }
+ public float CargoCapacity { get; set; }
+ public string? CargoKind { get; set; }
+ public string? CargoItemId { get; set; }
+ public required string Color { get; set; }
+ public required string HullColor { get; set; }
+ public float Size { get; set; }
+ public float MaxHealth { get; set; }
+}
+
+public sealed class ConstructibleDefinition
+{
+ public required string Id { get; set; }
+ public required string Label { get; set; }
+ public required string Category { get; set; }
+ public required string Color { get; set; }
+ public float Radius { get; set; }
+ public int DockingCapacity { get; set; }
+}
+
+public sealed class ScenarioDefinition
+{
+ public required List InitialStations { get; set; }
+ public required List ShipFormations { get; set; }
+ public required List PatrolRoutes { get; set; }
+ public required MiningDefaultsDefinition MiningDefaults { get; set; }
+}
+
+public sealed class InitialStationDefinition
+{
+ public required string ConstructibleId { get; set; }
+ public required string SystemId { get; set; }
+ public string? FactionId { get; set; }
+ public int? PlanetIndex { get; set; }
+ public int? LagrangeSide { get; set; }
+ public float[]? Position { get; set; }
+}
+
+public sealed class ShipFormationDefinition
+{
+ public required string ShipId { get; set; }
+ public int Count { get; set; }
+ public required float[] Center { get; set; }
+ public required string SystemId { get; set; }
+ public string? FactionId { get; set; }
+}
+
+public sealed class PatrolRouteDefinition
+{
+ public required string SystemId { get; set; }
+ public required List Points { get; set; }
+}
+
+public sealed class MiningDefaultsDefinition
+{
+ public required string NodeSystemId { get; set; }
+ public required string RefinerySystemId { get; set; }
+}
diff --git a/apps/backend/Program.cs b/apps/backend/Program.cs
new file mode 100644
index 0000000..93466bf
--- /dev/null
+++ b/apps/backend/Program.cs
@@ -0,0 +1,36 @@
+using SpaceGame.Simulation.Api.Simulation;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.WebHost.UseUrls("http://127.0.0.1:5079");
+builder.Services.AddCors((options) =>
+{
+ options.AddDefaultPolicy((policy) =>
+ {
+ policy
+ .AllowAnyHeader()
+ .AllowAnyMethod()
+ .AllowAnyOrigin();
+ });
+});
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
+
+var app = builder.Build();
+
+app.UseCors();
+
+app.MapGet("/", () => Results.Redirect("/api/world"));
+app.MapGet("/api/world", (WorldService worldService) => Results.Ok(worldService.GetSnapshot()));
+app.MapGet("/api/world/health", (WorldService worldService) => Results.Ok(new
+{
+ ok = true,
+ generatedAtUtc = worldService.GetSnapshot().GeneratedAtUtc,
+}));
+app.MapPost("/api/world/reset", (WorldService worldService) =>
+{
+ var snapshot = worldService.Reset();
+ return Results.Ok(snapshot);
+});
+
+app.Run();
diff --git a/apps/backend/Properties/launchSettings.json b/apps/backend/Properties/launchSettings.json
new file mode 100644
index 0000000..c55f6a2
--- /dev/null
+++ b/apps/backend/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:0",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:0;http://localhost:0",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/apps/backend/Simulation/RuntimeModels.cs b/apps/backend/Simulation/RuntimeModels.cs
new file mode 100644
index 0000000..f2868cb
--- /dev/null
+++ b/apps/backend/Simulation/RuntimeModels.cs
@@ -0,0 +1,134 @@
+using SpaceGame.Simulation.Api.Data;
+
+namespace SpaceGame.Simulation.Api.Simulation;
+
+public sealed class SimulationWorld
+{
+ public required string Label { get; init; }
+ public required int Seed { get; init; }
+ public required BalanceDefinition Balance { get; init; }
+ public required List Systems { get; init; }
+ public required List Nodes { get; init; }
+ public required List Stations { get; init; }
+ public required List Ships { get; init; }
+ public required List Factions { get; init; }
+ public required Dictionary ShipDefinitions { get; init; }
+ public DateTimeOffset GeneratedAtUtc { get; set; }
+}
+
+public sealed class SystemRuntime
+{
+ public required SolarSystemDefinition Definition { get; init; }
+ public required Vector3 Position { get; init; }
+}
+
+public sealed class ResourceNodeRuntime
+{
+ public required string Id { get; init; }
+ public required string SystemId { get; init; }
+ public required Vector3 Position { get; init; }
+ public required string ItemId { get; init; }
+ public float OreRemaining { get; set; }
+ public float MaxOre { get; init; }
+}
+
+public sealed class StationRuntime
+{
+ public required string Id { get; init; }
+ public required string SystemId { get; init; }
+ public required ConstructibleDefinition Definition { get; init; }
+ public required Vector3 Position { get; init; }
+ public required string FactionId { get; init; }
+ public float OreStored { get; set; }
+ public float RefinedStock { get; set; }
+ public float ProcessTimer { get; set; }
+ public HashSet DockedShipIds { get; } = [];
+}
+
+public sealed class ShipRuntime
+{
+ public required string Id { get; init; }
+ public required string SystemId { get; set; }
+ public required ShipDefinition Definition { get; init; }
+ public required string FactionId { get; init; }
+ public required Vector3 Position { get; set; }
+ public required Vector3 TargetPosition { get; set; }
+ public string State { get; set; } = "idle";
+ public ShipOrderRuntime? Order { get; set; }
+ public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
+ public required ControllerTaskRuntime ControllerTask { get; set; }
+ public float ActionTimer { get; set; }
+ public float Cargo { get; set; }
+ public string? DockedStationId { get; set; }
+ public float Health { get; set; }
+ public List History { get; } = [];
+ public string LastSignature { get; set; } = string.Empty;
+}
+
+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 OreMined { get; set; }
+ public float GoodsProduced { get; set; }
+ public int ShipsBuilt { get; set; }
+ public int ShipsLost { get; set; }
+}
+
+public sealed class ShipOrderRuntime
+{
+ public required string Kind { get; init; }
+ public string Status { get; set; } = "accepted";
+ public required string DestinationSystemId { get; init; }
+ public required Vector3 DestinationPosition { get; init; }
+}
+
+public sealed class DefaultBehaviorRuntime
+{
+ public required string Kind { get; set; }
+ public string? AreaSystemId { get; set; }
+ public string? RefineryId { get; set; }
+ public string? NodeId { get; set; }
+ public string? Phase { get; set; }
+ public List PatrolPoints { get; set; } = [];
+ public int PatrolIndex { get; set; }
+}
+
+public sealed class ControllerTaskRuntime
+{
+ public required string Kind { get; set; }
+ public string? TargetEntityId { get; set; }
+ public string? TargetSystemId { get; set; }
+ public Vector3? TargetPosition { get; set; }
+ public float Threshold { get; set; }
+}
+
+public readonly record struct Vector3(float X, float Y, float Z)
+{
+ public static Vector3 Zero => new(0f, 0f, 0f);
+
+ public float DistanceTo(Vector3 other)
+ {
+ var dx = X - other.X;
+ var dy = Y - other.Y;
+ var dz = Z - other.Z;
+ return MathF.Sqrt((dx * dx) + (dy * dy) + (dz * dz));
+ }
+
+ public Vector3 MoveToward(Vector3 target, float maxDistance)
+ {
+ var distance = DistanceTo(target);
+ if (distance <= maxDistance || distance <= 0.0001f)
+ {
+ return target;
+ }
+
+ var t = maxDistance / distance;
+ return new Vector3(
+ X + ((target.X - X) * t),
+ Y + ((target.Y - Y) * t),
+ Z + ((target.Z - Z) * t));
+ }
+}
diff --git a/apps/backend/Simulation/ScenarioLoader.cs b/apps/backend/Simulation/ScenarioLoader.cs
new file mode 100644
index 0000000..8c3772f
--- /dev/null
+++ b/apps/backend/Simulation/ScenarioLoader.cs
@@ -0,0 +1,208 @@
+using System.Text.Json;
+using SpaceGame.Simulation.Api.Data;
+
+namespace SpaceGame.Simulation.Api.Simulation;
+
+public sealed class ScenarioLoader
+{
+ private readonly string _dataRoot;
+ private readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ public ScenarioLoader(string contentRootPath)
+ {
+ _dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data"));
+ }
+
+ public SimulationWorld Load()
+ {
+ var systems = Read>("systems.json");
+ var scenario = Read("scenario.json");
+ var ships = Read>("ships.json");
+ var constructibles = Read>("constructibles.json");
+ var balance = Read("balance.json");
+
+ var shipDefinitions = ships.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
+ var constructibleDefinitions = constructibles.ToDictionary((definition) => definition.Id, 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 nodes = new List();
+ var nodeIdCounter = 0;
+ foreach (var system in systemRuntimes)
+ {
+ foreach (var node in system.Definition.ResourceNodes)
+ {
+ nodes.Add(new ResourceNodeRuntime
+ {
+ Id = $"node-{++nodeIdCounter}",
+ SystemId = system.Definition.Id,
+ Position = new Vector3(
+ system.Position.X + (MathF.Cos(node.Angle) * node.RadiusOffset),
+ balance.YPlane,
+ system.Position.Z + (MathF.Sin(node.Angle) * node.RadiusOffset)),
+ ItemId = node.ItemId,
+ OreRemaining = node.OreAmount,
+ MaxOre = node.OreAmount,
+ });
+ }
+ }
+
+ var stations = new List();
+ var stationIdCounter = 0;
+ foreach (var plan in scenario.InitialStations)
+ {
+ if (!constructibleDefinitions.TryGetValue(plan.ConstructibleId, out var definition) || !systemsById.TryGetValue(plan.SystemId, out var system))
+ {
+ continue;
+ }
+
+ stations.Add(new StationRuntime
+ {
+ Id = $"station-{++stationIdCounter}",
+ SystemId = system.Definition.Id,
+ Definition = definition,
+ Position = ResolveStationPosition(system, plan, balance),
+ FactionId = plan.FactionId ?? "sol-dominion",
+ OreStored = definition.Category == "refining" ? 120f : 0f,
+ RefinedStock = definition.Category == "shipyard" ? 180f : 40f,
+ });
+ }
+
+ var refinery = stations.FirstOrDefault((station) =>
+ station.Definition.Category == "refining" && station.SystemId == scenario.MiningDefaults.RefinerySystemId)
+ ?? stations.FirstOrDefault((station) => station.Definition.Category == "refining");
+
+ var patrolRoutes = scenario.PatrolRoutes.ToDictionary(
+ (route) => route.SystemId,
+ (route) => route.Points.Select(ToVector).ToList(),
+ StringComparer.Ordinal);
+
+ var shipsRuntime = new List();
+ 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(ToVector(formation.Center), offset);
+ shipsRuntime.Add(new ShipRuntime
+ {
+ Id = $"ship-{++shipIdCounter}",
+ SystemId = formation.SystemId,
+ Definition = definition,
+ FactionId = formation.FactionId ?? "sol-dominion",
+ Position = position,
+ TargetPosition = position,
+ DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
+ ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold },
+ Health = definition.MaxHealth,
+ });
+ }
+ }
+
+ var factions = new List
+ {
+ new()
+ {
+ Id = "sol-dominion",
+ Label = "Sol Dominion",
+ Color = "#7ed4ff",
+ Credits = 240f,
+ },
+ };
+
+ return new SimulationWorld
+ {
+ Label = "Split Viewer / Simulation World",
+ Seed = 1,
+ Balance = balance,
+ Systems = systemRuntimes,
+ Nodes = nodes,
+ Stations = stations,
+ Ships = shipsRuntime,
+ Factions = factions,
+ ShipDefinitions = shipDefinitions,
+ GeneratedAtUtc = DateTimeOffset.UtcNow,
+ };
+ }
+
+ private T Read(string fileName)
+ {
+ var path = Path.Combine(_dataRoot, fileName);
+ var json = File.ReadAllText(path);
+ return JsonSerializer.Deserialize(json, _jsonOptions)
+ ?? throw new InvalidOperationException($"Unable to read {fileName}.");
+ }
+
+ private static DefaultBehaviorRuntime CreateBehavior(
+ ShipDefinition definition,
+ string systemId,
+ ScenarioDefinition scenario,
+ IReadOnlyDictionary> patrolRoutes,
+ StationRuntime? refinery)
+ {
+ if (definition.Role == "mining" && refinery is not null)
+ {
+ return new DefaultBehaviorRuntime
+ {
+ Kind = "auto-mine",
+ AreaSystemId = scenario.MiningDefaults.NodeSystemId,
+ RefineryId = refinery.Id,
+ Phase = "travel-to-node",
+ };
+ }
+
+ if (definition.Role == "military" && patrolRoutes.TryGetValue(systemId, out var route))
+ {
+ return new DefaultBehaviorRuntime
+ {
+ Kind = "patrol",
+ PatrolPoints = route,
+ PatrolIndex = 0,
+ };
+ }
+
+ return new DefaultBehaviorRuntime
+ {
+ Kind = "idle",
+ };
+ }
+
+ private static Vector3 ResolveStationPosition(SystemRuntime system, InitialStationDefinition plan, BalanceDefinition balance)
+ {
+ if (plan.Position is { Length: 3 })
+ {
+ return ToVector(plan.Position);
+ }
+
+ if (plan.PlanetIndex is int planetIndex && planetIndex >= 0 && planetIndex < system.Definition.Planets.Count)
+ {
+ var planet = system.Definition.Planets[planetIndex];
+ var side = plan.LagrangeSide ?? 1;
+ return new Vector3(
+ system.Position.X + planet.OrbitRadius + (side * 72f),
+ balance.YPlane,
+ system.Position.Z + ((planetIndex + 1) * 42f * side));
+ }
+
+ return new Vector3(system.Position.X + 180f, balance.YPlane, system.Position.Z);
+ }
+
+ private static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
+
+ private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
+}
diff --git a/apps/backend/Simulation/SimulationEngine.cs b/apps/backend/Simulation/SimulationEngine.cs
new file mode 100644
index 0000000..09d643a
--- /dev/null
+++ b/apps/backend/Simulation/SimulationEngine.cs
@@ -0,0 +1,488 @@
+using SpaceGame.Simulation.Api.Contracts;
+
+namespace SpaceGame.Simulation.Api.Simulation;
+
+public sealed class SimulationEngine
+{
+ public void Tick(SimulationWorld world, float deltaSeconds)
+ {
+ UpdateStations(world, deltaSeconds);
+
+ foreach (var ship in world.Ships)
+ {
+ RefreshControlLayers(ship);
+ PlanControllerTask(ship, world);
+ var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds);
+ AdvanceControlState(ship, controllerEvent);
+ TrackHistory(ship);
+ }
+
+ world.GeneratedAtUtc = DateTimeOffset.UtcNow;
+ }
+
+ public WorldSnapshot BuildSnapshot(SimulationWorld world)
+ {
+ return new WorldSnapshot(
+ world.Label,
+ world.Seed,
+ world.GeneratedAtUtc,
+ world.Systems.Select((system) => new SystemSnapshot(
+ system.Definition.Id,
+ system.Definition.Label,
+ ToDto(system.Position),
+ system.Definition.StarColor,
+ system.Definition.StarSize,
+ system.Definition.Planets.Select((planet) => new PlanetSnapshot(
+ planet.Label,
+ planet.OrbitRadius,
+ planet.Size,
+ planet.Color,
+ planet.HasRing)).ToList())).ToList(),
+ world.Nodes.Select((node) => new ResourceNodeSnapshot(
+ node.Id,
+ node.SystemId,
+ ToDto(node.Position),
+ node.OreRemaining,
+ node.MaxOre,
+ node.ItemId)).ToList(),
+ world.Stations.Select((station) => new StationSnapshot(
+ station.Id,
+ station.Definition.Label,
+ station.Definition.Category,
+ station.SystemId,
+ ToDto(station.Position),
+ station.Definition.Color,
+ station.DockedShipIds.Count,
+ station.OreStored,
+ station.RefinedStock,
+ station.FactionId)).ToList(),
+ world.Ships.Select((ship) => new ShipSnapshot(
+ ship.Id,
+ ship.Definition.Label,
+ ship.Definition.Role,
+ ship.Definition.ShipClass,
+ ship.SystemId,
+ ToDto(ship.Position),
+ ship.State,
+ ship.Order?.Kind,
+ ship.DefaultBehavior.Kind,
+ ship.ControllerTask.Kind,
+ ship.Cargo,
+ ship.Definition.CargoCapacity,
+ ship.Definition.CargoItemId,
+ ship.FactionId,
+ ship.Health,
+ ship.History.ToList())).ToList(),
+ world.Factions.Select((faction) => new FactionSnapshot(
+ faction.Id,
+ faction.Label,
+ faction.Color,
+ faction.Credits,
+ faction.OreMined,
+ faction.GoodsProduced,
+ faction.ShipsBuilt,
+ faction.ShipsLost)).ToList());
+ }
+
+ private void UpdateStations(SimulationWorld world, float deltaSeconds)
+ {
+ foreach (var station in world.Stations)
+ {
+ if (station.Definition.Category != "refining" || station.OreStored < 60f)
+ {
+ continue;
+ }
+
+ station.ProcessTimer += deltaSeconds;
+ if (station.ProcessTimer < 8f)
+ {
+ continue;
+ }
+
+ station.ProcessTimer = 0f;
+ station.OreStored -= 60f;
+ station.RefinedStock += 60f;
+ var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == station.FactionId);
+ if (faction is not null)
+ {
+ faction.GoodsProduced += 60f;
+ faction.Credits += 18f;
+ }
+ }
+ }
+
+ private void RefreshControlLayers(ShipRuntime ship)
+ {
+ if (ship.Order is not null && ship.Order.Status == "queued")
+ {
+ ship.Order.Status = "accepted";
+ }
+ }
+
+ private void PlanControllerTask(ShipRuntime ship, SimulationWorld world)
+ {
+ if (ship.Order is not null)
+ {
+ ship.ControllerTask = new ControllerTaskRuntime
+ {
+ Kind = "travel",
+ TargetEntityId = null,
+ TargetSystemId = ship.Order.DestinationSystemId,
+ TargetPosition = ship.Order.DestinationPosition,
+ Threshold = world.Balance.ArrivalThreshold,
+ };
+ return;
+ }
+
+ if (ship.DefaultBehavior.Kind == "auto-mine")
+ {
+ PlanAutoMine(ship, world);
+ return;
+ }
+
+ if (ship.DefaultBehavior.Kind == "patrol" && ship.DefaultBehavior.PatrolPoints.Count > 0)
+ {
+ ship.ControllerTask = new ControllerTaskRuntime
+ {
+ Kind = "travel",
+ TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex],
+ TargetSystemId = ship.SystemId,
+ Threshold = 18f,
+ };
+ return;
+ }
+
+ ship.ControllerTask = new ControllerTaskRuntime
+ {
+ Kind = "idle",
+ Threshold = world.Balance.ArrivalThreshold,
+ };
+ }
+
+ private void PlanAutoMine(ShipRuntime ship, SimulationWorld world)
+ {
+ var behavior = ship.DefaultBehavior;
+ var refinery = world.Stations.FirstOrDefault((station) => station.Id == behavior.RefineryId);
+ var node = behavior.NodeId is null
+ ? world.Nodes
+ .Where((candidate) => candidate.SystemId == behavior.AreaSystemId)
+ .OrderByDescending((candidate) => candidate.OreRemaining)
+ .FirstOrDefault()
+ : world.Nodes.FirstOrDefault((candidate) => candidate.Id == behavior.NodeId);
+
+ if (refinery is null || node is null)
+ {
+ behavior.Kind = "idle";
+ ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold };
+ return;
+ }
+
+ behavior.NodeId ??= node.Id;
+ switch (behavior.Phase)
+ {
+ case "extract":
+ ship.ControllerTask = new ControllerTaskRuntime
+ {
+ Kind = "extract",
+ TargetEntityId = node.Id,
+ TargetSystemId = node.SystemId,
+ TargetPosition = node.Position,
+ Threshold = 14f,
+ };
+ break;
+ case "travel-to-station":
+ ship.ControllerTask = new ControllerTaskRuntime
+ {
+ Kind = "travel",
+ TargetEntityId = refinery.Id,
+ TargetSystemId = refinery.SystemId,
+ TargetPosition = refinery.Position,
+ Threshold = refinery.Definition.Radius + 8f,
+ };
+ break;
+ case "dock":
+ ship.ControllerTask = new ControllerTaskRuntime
+ {
+ Kind = "dock",
+ TargetEntityId = refinery.Id,
+ TargetSystemId = refinery.SystemId,
+ TargetPosition = refinery.Position,
+ Threshold = refinery.Definition.Radius + 4f,
+ };
+ break;
+ case "unload":
+ ship.ControllerTask = new ControllerTaskRuntime
+ {
+ Kind = "unload",
+ TargetEntityId = refinery.Id,
+ TargetSystemId = refinery.SystemId,
+ TargetPosition = refinery.Position,
+ Threshold = 0f,
+ };
+ break;
+ case "undock":
+ ship.ControllerTask = new ControllerTaskRuntime
+ {
+ Kind = "undock",
+ TargetEntityId = refinery.Id,
+ TargetSystemId = refinery.SystemId,
+ TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z),
+ Threshold = 8f,
+ };
+ break;
+ default:
+ ship.ControllerTask = new ControllerTaskRuntime
+ {
+ Kind = "travel",
+ TargetEntityId = node.Id,
+ TargetSystemId = node.SystemId,
+ TargetPosition = node.Position,
+ Threshold = 18f,
+ };
+ behavior.Phase = "travel-to-node";
+ break;
+ }
+ }
+
+ private string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
+ {
+ var task = ship.ControllerTask;
+ switch (task.Kind)
+ {
+ case "idle":
+ ship.State = "idle";
+ return "none";
+ case "travel":
+ return UpdateTravel(ship, world, deltaSeconds);
+ case "extract":
+ return UpdateExtract(ship, world, deltaSeconds);
+ case "dock":
+ return UpdateDock(ship, world, deltaSeconds);
+ case "unload":
+ return UpdateUnload(ship, world, deltaSeconds);
+ case "undock":
+ return UpdateUndock(ship, world, deltaSeconds);
+ default:
+ ship.State = "idle";
+ return "none";
+ }
+ }
+
+ private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
+ {
+ var task = ship.ControllerTask;
+ if (task.TargetPosition is null || task.TargetSystemId is null)
+ {
+ ship.State = "idle";
+ return "none";
+ }
+
+ var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
+ if (distance <= task.Threshold)
+ {
+ ship.Position = task.TargetPosition.Value;
+ ship.TargetPosition = ship.Position;
+ ship.SystemId = task.TargetSystemId;
+ ship.State = "arriving";
+ return "arrived";
+ }
+
+ var speed = ship.Definition.Speed;
+ if (ship.SystemId != task.TargetSystemId)
+ {
+ ship.State = distance > 800f ? "ftl" : "spooling-ftl";
+ speed = ship.Definition.FtlSpeed;
+ }
+ else if (distance > 200f)
+ {
+ ship.State = distance > 500f ? "warping" : "spooling-warp";
+ speed = ship.Definition.Speed * 4.5f;
+ }
+ else
+ {
+ ship.State = "approaching";
+ speed = ship.Definition.Speed;
+ }
+
+ ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, speed * deltaSeconds);
+ ship.TargetPosition = task.TargetPosition.Value;
+ return "none";
+ }
+
+ private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
+ {
+ var task = ship.ControllerTask;
+ var node = world.Nodes.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId);
+ if (node is null || task.TargetPosition is null)
+ {
+ ship.State = "idle";
+ return "none";
+ }
+
+ var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
+ if (distance > task.Threshold)
+ {
+ ship.State = "mining-approach";
+ ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
+ return "none";
+ }
+
+ ship.State = "mining";
+ ship.ActionTimer += deltaSeconds;
+ if (ship.ActionTimer < 1f)
+ {
+ return "none";
+ }
+
+ ship.ActionTimer = 0f;
+ var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - ship.Cargo);
+ mined = MathF.Min(mined, node.OreRemaining);
+ ship.Cargo += mined;
+ node.OreRemaining -= mined;
+ if (node.OreRemaining <= 0f)
+ {
+ node.OreRemaining = node.MaxOre;
+ }
+
+ return ship.Cargo >= ship.Definition.CargoCapacity ? "cargo-full" : "none";
+ }
+
+ private string UpdateDock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
+ {
+ var task = ship.ControllerTask;
+ var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId);
+ if (station is null || task.TargetPosition is null)
+ {
+ ship.State = "idle";
+ return "none";
+ }
+
+ var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
+ if (distance > task.Threshold)
+ {
+ ship.State = "docking-approach";
+ ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
+ return "none";
+ }
+
+ ship.State = "docking";
+ ship.ActionTimer += deltaSeconds;
+ if (ship.ActionTimer < world.Balance.DockingDuration)
+ {
+ return "none";
+ }
+
+ ship.ActionTimer = 0f;
+ ship.State = "docked";
+ ship.DockedStationId = station.Id;
+ station.DockedShipIds.Add(ship.Id);
+ ship.Position = station.Position;
+ return "docked";
+ }
+
+ private string UpdateUnload(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
+ {
+ if (ship.DockedStationId is null)
+ {
+ ship.State = "idle";
+ return "none";
+ }
+
+ var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
+ if (station is null)
+ {
+ ship.DockedStationId = null;
+ ship.State = "idle";
+ return "none";
+ }
+
+ ship.State = "transferring";
+ var moved = MathF.Min(ship.Cargo, world.Balance.TransferRate * deltaSeconds);
+ ship.Cargo -= moved;
+ station.OreStored += moved;
+ var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == ship.FactionId);
+ if (faction is not null)
+ {
+ faction.OreMined += moved;
+ faction.Credits += moved * 0.4f;
+ }
+
+ return ship.Cargo <= 0.01f ? "unloaded" : "none";
+ }
+
+ private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
+ {
+ var task = ship.ControllerTask;
+ if (ship.DockedStationId is null || task.TargetPosition is null)
+ {
+ ship.State = "idle";
+ return "none";
+ }
+
+ var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
+ station?.DockedShipIds.Remove(ship.Id);
+ ship.DockedStationId = null;
+ ship.State = "undocking";
+ ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
+ return ship.Position.DistanceTo(task.TargetPosition.Value) <= task.Threshold ? "undocked" : "none";
+ }
+
+ private void AdvanceControlState(ShipRuntime ship, string controllerEvent)
+ {
+ if (ship.Order is not null && controllerEvent == "arrived")
+ {
+ ship.Order = null;
+ ship.ControllerTask.Kind = "idle";
+ return;
+ }
+
+ if (ship.DefaultBehavior.Kind == "auto-mine")
+ {
+ switch (ship.DefaultBehavior.Phase, controllerEvent)
+ {
+ case ("travel-to-node", "arrived"):
+ ship.DefaultBehavior.Phase = ship.Cargo >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract";
+ break;
+ case ("extract", "cargo-full"):
+ ship.DefaultBehavior.Phase = "travel-to-station";
+ break;
+ case ("travel-to-station", "arrived"):
+ ship.DefaultBehavior.Phase = "dock";
+ break;
+ case ("dock", "docked"):
+ ship.DefaultBehavior.Phase = "unload";
+ break;
+ case ("unload", "unloaded"):
+ ship.DefaultBehavior.Phase = "undock";
+ break;
+ case ("undock", "undocked"):
+ ship.DefaultBehavior.Phase = "travel-to-node";
+ ship.DefaultBehavior.NodeId = null;
+ break;
+ }
+ }
+
+ if (ship.DefaultBehavior.Kind == "patrol" && controllerEvent == "arrived" && ship.DefaultBehavior.PatrolPoints.Count > 0)
+ {
+ ship.DefaultBehavior.PatrolIndex = (ship.DefaultBehavior.PatrolIndex + 1) % ship.DefaultBehavior.PatrolPoints.Count;
+ }
+ }
+
+ private static void TrackHistory(ShipRuntime ship)
+ {
+ var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{ship.Cargo:0.0}";
+ if (signature == ship.LastSignature)
+ {
+ return;
+ }
+
+ ship.LastSignature = signature;
+ ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind} cargo={ship.Cargo:0.#}");
+ if (ship.History.Count > 18)
+ {
+ ship.History.RemoveAt(0);
+ }
+ }
+
+ private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z);
+}
diff --git a/apps/backend/Simulation/SimulationHostedService.cs b/apps/backend/Simulation/SimulationHostedService.cs
new file mode 100644
index 0000000..d5f5b86
--- /dev/null
+++ b/apps/backend/Simulation/SimulationHostedService.cs
@@ -0,0 +1,19 @@
+namespace SpaceGame.Simulation.Api.Simulation;
+
+public sealed class SimulationHostedService(WorldService worldService) : BackgroundService
+{
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(200));
+ try
+ {
+ while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
+ {
+ worldService.Tick(0.2f);
+ }
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ }
+ }
+}
diff --git a/apps/backend/Simulation/WorldService.cs b/apps/backend/Simulation/WorldService.cs
new file mode 100644
index 0000000..f43d2bb
--- /dev/null
+++ b/apps/backend/Simulation/WorldService.cs
@@ -0,0 +1,36 @@
+using SpaceGame.Simulation.Api.Contracts;
+
+namespace SpaceGame.Simulation.Api.Simulation;
+
+public sealed class WorldService(IWebHostEnvironment environment)
+{
+ private readonly object _sync = new();
+ private readonly ScenarioLoader _loader = new(environment.ContentRootPath);
+ private readonly SimulationEngine _engine = new();
+ private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath).Load();
+
+ public WorldSnapshot GetSnapshot()
+ {
+ lock (_sync)
+ {
+ return _engine.BuildSnapshot(_world);
+ }
+ }
+
+ public void Tick(float deltaSeconds)
+ {
+ lock (_sync)
+ {
+ _engine.Tick(_world, deltaSeconds);
+ }
+ }
+
+ public WorldSnapshot Reset()
+ {
+ lock (_sync)
+ {
+ _world = _loader.Load();
+ return _engine.BuildSnapshot(_world);
+ }
+ }
+}
diff --git a/apps/backend/SpaceGame.Simulation.Api.csproj b/apps/backend/SpaceGame.Simulation.Api.csproj
new file mode 100644
index 0000000..a3a34b6
--- /dev/null
+++ b/apps/backend/SpaceGame.Simulation.Api.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/apps/backend/appsettings.Development.json b/apps/backend/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/apps/backend/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/apps/backend/appsettings.json b/apps/backend/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/apps/backend/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/index.html b/apps/viewer/index.html
similarity index 87%
rename from index.html
rename to apps/viewer/index.html
index a5b9aba..f832d08 100644
--- a/index.html
+++ b/apps/viewer/index.html
@@ -3,10 +3,10 @@
- Space Command
-
+ Space Game Viewer
+