Split viewer and simulation into separate apps

This commit is contained in:
2026-03-12 17:18:29 -04:00
parent 0a76c60ab1
commit 2fb90162ef
45 changed files with 1982 additions and 6600 deletions

View File

@@ -0,0 +1,76 @@
namespace SpaceGame.Simulation.Api.Contracts;
public sealed record WorldSnapshot(
string Label,
int Seed,
DateTimeOffset GeneratedAtUtc,
IReadOnlyList<SystemSnapshot> Systems,
IReadOnlyList<ResourceNodeSnapshot> Nodes,
IReadOnlyList<StationSnapshot> Stations,
IReadOnlyList<ShipSnapshot> Ships,
IReadOnlyList<FactionSnapshot> Factions);
public sealed record SystemSnapshot(
string Id,
string Label,
Vector3Dto Position,
string StarColor,
float StarSize,
IReadOnlyList<PlanetSnapshot> 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<string> 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);

View File

@@ -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<ResourceNodeDefinition> ResourceNodes { get; set; }
public required List<PlanetDefinition> 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<InitialStationDefinition> InitialStations { get; set; }
public required List<ShipFormationDefinition> ShipFormations { get; set; }
public required List<PatrolRouteDefinition> 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<float[]> Points { get; set; }
}
public sealed class MiningDefaultsDefinition
{
public required string NodeSystemId { get; set; }
public required string RefinerySystemId { get; set; }
}

36
apps/backend/Program.cs Normal file
View File

@@ -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<WorldService>();
builder.Services.AddHostedService<SimulationHostedService>();
var app = builder.Build();
app.UseCors();
app.MapGet("/", () => Results.Redirect("/api/world"));
app.MapGet("/api/world", (WorldService worldService) => Results.Ok(worldService.GetSnapshot()));
app.MapGet("/api/world/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();

View File

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

View File

@@ -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<SystemRuntime> Systems { get; init; }
public required List<ResourceNodeRuntime> Nodes { get; init; }
public required List<StationRuntime> Stations { get; init; }
public required List<ShipRuntime> Ships { get; init; }
public required List<FactionRuntime> Factions { get; init; }
public required Dictionary<string, ShipDefinition> 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<string> 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<string> 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<Vector3> 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));
}
}

View File

@@ -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<List<SolarSystemDefinition>>("systems.json");
var scenario = Read<ScenarioDefinition>("scenario.json");
var ships = Read<List<ShipDefinition>>("ships.json");
var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json");
var balance = Read<BalanceDefinition>("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<ResourceNodeRuntime>();
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<StationRuntime>();
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<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(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<FactionRuntime>
{
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<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 DefaultBehaviorRuntime CreateBehavior(
ShipDefinition definition,
string systemId,
ScenarioDefinition scenario,
IReadOnlyDictionary<string, List<Vector3>> 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);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}