Files
space-game/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs

936 lines
46 KiB
C#

using System.Globalization;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Geopolitics.Simulation;
internal sealed class GeopoliticalSimulationService
{
internal void Update(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
var state = EnsureState(world);
state.Cycle += 1;
state.UpdatedAtUtc = world.GeneratedAtUtc;
RebuildRoutes(world, state);
RebuildTerritory(world, state);
RebuildDiplomacy(world, state, events);
RebuildEconomyRegions(world, state);
}
internal static GeopoliticalStateRuntime EnsureState(SimulationWorld world)
{
world.Geopolitics ??= new GeopoliticalStateRuntime();
return world.Geopolitics;
}
internal static DiplomaticRelationRuntime? FindRelation(SimulationWorld world, string factionAId, string factionBId)
{
var state = EnsureState(world);
return state.Diplomacy.Relations.FirstOrDefault(relation => string.Equals(relation.Id, BuildRelationId(factionAId, factionBId), StringComparison.Ordinal));
}
internal static WarStateRuntime? FindWarState(SimulationWorld world, string factionAId, string factionBId) =>
EnsureState(world).Diplomacy.Wars.FirstOrDefault(war => string.Equals(war.RelationId, BuildRelationId(factionAId, factionBId), StringComparison.Ordinal) && war.Status == "active");
internal static TerritoryControlStateRuntime? GetSystemControlState(SimulationWorld world, string systemId) =>
EnsureState(world).Territory.ControlStates.FirstOrDefault(state => string.Equals(state.SystemId, systemId, StringComparison.Ordinal));
internal static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) =>
string.Equals(GetSystemControlState(world, systemId)?.ControllerFactionId, factionId, StringComparison.Ordinal);
internal static IReadOnlyList<string> GetControlledSystems(SimulationWorld world, string factionId) =>
EnsureState(world).Territory.ControlStates
.Where(state => string.Equals(state.ControllerFactionId, factionId, StringComparison.Ordinal))
.OrderBy(state => state.SystemId, StringComparer.Ordinal)
.Select(state => state.SystemId)
.ToList();
internal static float GetSystemRouteRisk(SimulationWorld world, string systemId, string? factionId = null)
{
var pressure = EnsureState(world).Territory.Pressures
.Where(entry => string.Equals(entry.SystemId, systemId, StringComparison.Ordinal)
&& (factionId is null || string.Equals(entry.FactionId, factionId, StringComparison.Ordinal)))
.OrderByDescending(entry => entry.CorridorRisk)
.ThenBy(entry => entry.Id, StringComparer.Ordinal)
.FirstOrDefault();
return pressure?.CorridorRisk
?? EnsureState(world).Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == systemId)?.TerritorialPressure
?? 0f;
}
internal static bool HasHostileRelation(SimulationWorld world, string factionAId, string factionBId)
{
if (string.Equals(factionAId, factionBId, StringComparison.Ordinal))
{
return false;
}
var relation = FindRelation(world, factionAId, factionBId);
return relation is not null && relation.Posture is "hostile" or "war";
}
internal static bool HasTradeAccess(SimulationWorld world, string factionAId, string factionBId)
{
if (string.Equals(factionAId, factionBId, StringComparison.Ordinal))
{
return true;
}
var relation = FindRelation(world, factionAId, factionBId);
return relation?.TradeAccessPolicy is "open" or "allied";
}
internal static bool HasMilitaryAccess(SimulationWorld world, string factionAId, string factionBId)
{
if (string.Equals(factionAId, factionBId, StringComparison.Ordinal))
{
return true;
}
var relation = FindRelation(world, factionAId, factionBId);
return relation?.MilitaryAccessPolicy is "open" or "allied";
}
internal static EconomicRegionRuntime? GetPrimaryEconomicRegion(SimulationWorld world, string factionId, string systemId) =>
EnsureState(world).EconomyRegions.Regions.FirstOrDefault(region =>
string.Equals(region.FactionId, factionId, StringComparison.Ordinal)
&& region.SystemIds.Contains(systemId, StringComparer.Ordinal));
private static void RebuildRoutes(SimulationWorld world, GeopoliticalStateRuntime state)
{
state.Routes.Clear();
if (world.Systems.Count <= 1)
{
return;
}
var systems = world.Systems
.OrderBy(system => system.Definition.Id, StringComparer.Ordinal)
.ToList();
var routeIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var system in systems)
{
foreach (var neighbor in systems
.Where(candidate => candidate.Definition.Id != system.Definition.Id)
.Select(candidate => new
{
candidate.Definition.Id,
Distance = system.Position.DistanceTo(candidate.Position),
})
.OrderBy(candidate => candidate.Distance)
.ThenBy(candidate => candidate.Id, StringComparer.Ordinal)
.Take(Math.Min(3, systems.Count - 1)))
{
var routeId = BuildPairId("route", system.Definition.Id, neighbor.Id);
if (!routeIds.Add(routeId))
{
continue;
}
state.Routes.Add(new SystemRouteLinkRuntime
{
Id = routeId,
SourceSystemId = string.Compare(system.Definition.Id, neighbor.Id, StringComparison.Ordinal) <= 0 ? system.Definition.Id : neighbor.Id,
DestinationSystemId = string.Compare(system.Definition.Id, neighbor.Id, StringComparison.Ordinal) <= 0 ? neighbor.Id : system.Definition.Id,
Distance = neighbor.Distance,
IsPrimaryLane = true,
});
}
}
}
private static void RebuildTerritory(SimulationWorld world, GeopoliticalStateRuntime state)
{
state.Territory.Claims.Clear();
state.Territory.Influences.Clear();
state.Territory.ControlStates.Clear();
state.Territory.StrategicProfiles.Clear();
state.Territory.BorderEdges.Clear();
state.Territory.FrontLines.Clear();
state.Territory.Zones.Clear();
state.Territory.Pressures.Clear();
var nowUtc = world.GeneratedAtUtc;
foreach (var claim in world.Claims.Where(claim => claim.State != ClaimStateKinds.Destroyed))
{
state.Territory.Claims.Add(new TerritoryClaimRuntime
{
Id = $"territory-{claim.Id}",
SourceClaimId = claim.Id,
FactionId = claim.FactionId,
SystemId = claim.SystemId,
CelestialId = claim.CelestialId,
Status = claim.State,
ClaimKind = "infrastructure",
ClaimStrength = claim.State == ClaimStateKinds.Active ? 1f : 0.65f,
UpdatedAtUtc = nowUtc,
});
}
var influencesBySystem = new Dictionary<string, List<TerritoryInfluenceRuntime>>(StringComparer.Ordinal);
foreach (var system in world.Systems)
{
var claimsByFaction = state.Territory.Claims
.Where(claim => claim.SystemId == system.Definition.Id)
.GroupBy(claim => claim.FactionId, StringComparer.Ordinal);
var stationsByFaction = world.Stations
.Where(station => station.SystemId == system.Definition.Id)
.GroupBy(station => station.FactionId, StringComparer.Ordinal);
var shipsByFaction = world.Ships
.Where(ship => ship.SystemId == system.Definition.Id && ship.Health > 0f)
.GroupBy(ship => ship.FactionId, StringComparer.Ordinal);
var sitesByFaction = world.ConstructionSites
.Where(site => site.SystemId == system.Definition.Id && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed)
.GroupBy(site => site.FactionId, StringComparer.Ordinal);
var factionIds = claimsByFaction.Select(group => group.Key)
.Concat(stationsByFaction.Select(group => group.Key))
.Concat(shipsByFaction.Select(group => group.Key))
.Concat(sitesByFaction.Select(group => group.Key))
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToList();
var influences = new List<TerritoryInfluenceRuntime>();
foreach (var factionId in factionIds)
{
var claimStrength = claimsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(claim => claim.ClaimStrength * 40f) ?? 0f;
var stationStrength = (stationsByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 50f;
var siteStrength = (sitesByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 18f;
var shipStrength = shipsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(ship =>
{
if (IsMilitaryShip(ship.Definition))
{
return 9f;
}
if (IsConstructionShip(ship.Definition))
{
return 4f;
}
if (IsTransportShip(ship.Definition) || IsMiningShip(ship.Definition))
{
return 3f;
}
return 2f;
}) ?? 0f;
var logisticsStrength = MathF.Min(30f, stationStrength * 0.18f) + siteStrength;
influences.Add(new TerritoryInfluenceRuntime
{
Id = $"influence-{system.Definition.Id}-{factionId}",
SystemId = system.Definition.Id,
FactionId = factionId,
ClaimStrength = claimStrength,
AssetStrength = stationStrength + shipStrength,
LogisticsStrength = logisticsStrength,
TotalInfluence = claimStrength + stationStrength + shipStrength + logisticsStrength,
UpdatedAtUtc = nowUtc,
});
}
influences.Sort((left, right) =>
{
var total = right.TotalInfluence.CompareTo(left.TotalInfluence);
return total != 0 ? total : string.Compare(left.FactionId, right.FactionId, StringComparison.Ordinal);
});
if (influences.Count > 1)
{
var lead = influences[0].TotalInfluence;
foreach (var influence in influences.Skip(1))
{
influence.IsContesting = influence.TotalInfluence >= (lead * 0.7f);
}
influences[0].IsContesting = influences[1].TotalInfluence >= (lead * 0.7f);
}
influencesBySystem[system.Definition.Id] = influences;
state.Territory.Influences.AddRange(influences);
var top = influences.FirstOrDefault();
var second = influences.Skip(1).FirstOrDefault();
var contested = top is not null && second is not null && second.TotalInfluence >= (top.TotalInfluence * 0.7f);
var controllerFactionId = top is not null && (!contested || top.TotalInfluence >= second!.TotalInfluence + 20f)
? top.FactionId
: null;
var primaryClaimantFactionId = state.Territory.Claims
.Where(claim => claim.SystemId == system.Definition.Id)
.GroupBy(claim => claim.FactionId, StringComparer.Ordinal)
.OrderByDescending(group => group.Sum(claim => claim.ClaimStrength))
.ThenBy(group => group.Key, StringComparer.Ordinal)
.Select(group => group.Key)
.FirstOrDefault();
var strategicValue = EstimateSystemStrategicValue(world, system.Definition.Id);
var controlState = new TerritoryControlStateRuntime
{
SystemId = system.Definition.Id,
ControllerFactionId = controllerFactionId,
PrimaryClaimantFactionId = primaryClaimantFactionId,
ControlKind = contested
? "contested"
: controllerFactionId is not null
? "controlled"
: primaryClaimantFactionId is not null
? "claimed"
: "unclaimed",
IsContested = contested,
ControlScore = top?.TotalInfluence ?? 0f,
StrategicValue = strategicValue,
UpdatedAtUtc = nowUtc,
};
controlState.ClaimantFactionIds.AddRange(state.Territory.Claims
.Where(claim => claim.SystemId == system.Definition.Id)
.Select(claim => claim.FactionId)
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal));
controlState.InfluencingFactionIds.AddRange(influences
.Select(influence => influence.FactionId)
.OrderBy(id => id, StringComparer.Ordinal));
state.Territory.ControlStates.Add(controlState);
}
foreach (var route in state.Routes)
{
var left = state.Territory.ControlStates.First(stateItem => stateItem.SystemId == route.SourceSystemId);
var right = state.Territory.ControlStates.First(stateItem => stateItem.SystemId == route.DestinationSystemId);
var differentControllers = !string.Equals(left.ControllerFactionId, right.ControllerFactionId, StringComparison.Ordinal);
var contested = left.IsContested || right.IsContested || differentControllers;
if (!contested && left.ControllerFactionId is null && right.ControllerFactionId is null)
{
continue;
}
state.Territory.BorderEdges.Add(new BorderEdgeRuntime
{
Id = $"border-{route.Id}",
SourceSystemId = route.SourceSystemId,
DestinationSystemId = route.DestinationSystemId,
SourceFactionId = left.ControllerFactionId ?? left.PrimaryClaimantFactionId,
DestinationFactionId = right.ControllerFactionId ?? right.PrimaryClaimantFactionId,
IsContested = contested,
TensionScore = MathF.Min(1f, MathF.Abs((left.ControlScore - right.ControlScore) / MathF.Max(50f, left.ControlScore + right.ControlScore))),
CorridorImportance = route.Distance <= 0.01f ? 0f : Math.Clamp((left.StrategicValue + right.StrategicValue) / MathF.Max(route.Distance, 1f), 0f, 1f),
UpdatedAtUtc = nowUtc,
});
}
foreach (var control in state.Territory.ControlStates)
{
var adjacentBorders = state.Territory.BorderEdges.Where(edge => edge.SourceSystemId == control.SystemId || edge.DestinationSystemId == control.SystemId).ToList();
var hostileBorderCount = adjacentBorders.Count(edge => edge.IsContested);
var corridorImportance = adjacentBorders.Sum(edge => edge.CorridorImportance);
var zoneKind = control.IsContested
? "contested"
: control.ControllerFactionId is null && control.PrimaryClaimantFactionId is not null
? "buffer"
: control.ControllerFactionId is not null && hostileBorderCount == 0
? "core"
: control.ControllerFactionId is not null && corridorImportance > 1.1f
? "corridor"
: control.ControllerFactionId is not null
? "frontier"
: "unclaimed";
state.Territory.Zones.Add(new TerritoryZoneRuntime
{
Id = $"zone-{control.SystemId}",
SystemId = control.SystemId,
FactionId = control.ControllerFactionId ?? control.PrimaryClaimantFactionId,
Kind = zoneKind,
Status = "active",
Reason = zoneKind == "corridor" ? "high-corridor-importance" : zoneKind == "frontier" ? "hostile-border-contact" : zoneKind,
UpdatedAtUtc = nowUtc,
});
state.Territory.StrategicProfiles.Add(new SectorStrategicProfileRuntime
{
SystemId = control.SystemId,
ControllerFactionId = control.ControllerFactionId,
ZoneKind = zoneKind,
IsContested = control.IsContested,
StrategicValue = control.StrategicValue,
SecurityRating = Math.Clamp(1f - (hostileBorderCount * 0.22f), 0f, 1f),
TerritorialPressure = Math.Clamp(hostileBorderCount * 0.25f, 0f, 1f),
LogisticsValue = Math.Clamp(corridorImportance, 0f, 1f),
UpdatedAtUtc = nowUtc,
});
state.Territory.Pressures.Add(new TerritoryPressureRuntime
{
Id = $"pressure-{control.SystemId}",
SystemId = control.SystemId,
FactionId = control.ControllerFactionId ?? control.PrimaryClaimantFactionId,
Kind = control.IsContested ? "contested-pressure" : "territorial-pressure",
PressureScore = Math.Clamp(hostileBorderCount * 0.28f, 0f, 1f),
SecurityScore = Math.Clamp(1f - (hostileBorderCount * 0.2f), 0f, 1f),
HostileInfluence = influencesBySystem.GetValueOrDefault(control.SystemId)?.Skip(control.ControllerFactionId is null ? 0 : 1).Sum(entry => entry.TotalInfluence) ?? 0f,
CorridorRisk = Math.Clamp(corridorImportance > 0.8f && hostileBorderCount > 0 ? 0.7f : hostileBorderCount * 0.2f, 0f, 1f),
UpdatedAtUtc = nowUtc,
});
}
}
private static void RebuildDiplomacy(SimulationWorld world, GeopoliticalStateRuntime state, ICollection<SimulationEventRecord> events)
{
state.Diplomacy.Relations.Clear();
state.Diplomacy.Treaties.Clear();
state.Diplomacy.BorderTensions.Clear();
state.Diplomacy.Wars.Clear();
var nowUtc = world.GeneratedAtUtc;
var factionPairs = world.Factions
.OrderBy(faction => faction.Id, StringComparer.Ordinal)
.SelectMany((left, index) => world.Factions.Skip(index + 1).Select(right => (left, right)));
foreach (var (leftFaction, rightFaction) in factionPairs)
{
var borderEdges = state.Territory.BorderEdges
.Where(edge =>
(string.Equals(edge.SourceFactionId, leftFaction.Id, StringComparison.Ordinal) && string.Equals(edge.DestinationFactionId, rightFaction.Id, StringComparison.Ordinal))
|| (string.Equals(edge.SourceFactionId, rightFaction.Id, StringComparison.Ordinal) && string.Equals(edge.DestinationFactionId, leftFaction.Id, StringComparison.Ordinal)))
.OrderBy(edge => edge.Id, StringComparer.Ordinal)
.ToList();
var sharedBorderPressure = borderEdges.Sum(edge => edge.TensionScore + (edge.IsContested ? 0.25f : 0f));
var conflictSystems = borderEdges.SelectMany(edge => new[] { edge.SourceSystemId, edge.DestinationSystemId }).Distinct(StringComparer.Ordinal).ToList();
var hostilePresence = world.Ships.Count(ship =>
ship.Health > 0f
&& ((ship.FactionId == leftFaction.Id && conflictSystems.Contains(ship.SystemId, StringComparer.Ordinal))
|| (ship.FactionId == rightFaction.Id && conflictSystems.Contains(ship.SystemId, StringComparer.Ordinal))));
var incidentSeverity = Math.Clamp(sharedBorderPressure + (hostilePresence * 0.03f), 0f, 1.6f);
var relationId = BuildRelationId(leftFaction.Id, rightFaction.Id);
var posture = incidentSeverity switch
{
>= 1.1f => "war",
>= 0.65f => "hostile",
>= 0.3f => "wary",
_ => "neutral",
};
var relation = new DiplomaticRelationRuntime
{
Id = relationId,
FactionAId = leftFaction.Id,
FactionBId = rightFaction.Id,
Status = "active",
Posture = posture,
TrustScore = Math.Clamp(0.7f - incidentSeverity, 0f, 1f),
TensionScore = Math.Clamp(incidentSeverity, 0f, 1f),
GrievanceScore = Math.Clamp(sharedBorderPressure, 0f, 1f),
TradeAccessPolicy = posture is "war" or "hostile" ? "restricted" : "open",
MilitaryAccessPolicy = posture == "neutral" ? "transit" : posture == "wary" ? "restricted" : "denied",
UpdatedAtUtc = nowUtc,
};
if (relation.Posture == "neutral")
{
var treaty = new TreatyRuntime
{
Id = $"treaty-open-trade-{relationId}",
Kind = "trade-understanding",
Status = "active",
TradeAccessPolicy = "open",
MilitaryAccessPolicy = "restricted",
Summary = $"Open civilian trade between {leftFaction.Label} and {rightFaction.Label}.",
CreatedAtUtc = nowUtc,
UpdatedAtUtc = nowUtc,
};
treaty.FactionIds.Add(leftFaction.Id);
treaty.FactionIds.Add(rightFaction.Id);
state.Diplomacy.Treaties.Add(treaty);
relation.ActiveTreatyIds.Add(treaty.Id);
relation.TradeAccessPolicy = "open";
}
state.Diplomacy.Relations.Add(relation);
foreach (var borderEdge in borderEdges)
{
borderEdge.RelationId = relation.Id;
borderEdge.TensionScore = Math.Clamp(borderEdge.TensionScore + (relation.TensionScore * 0.35f), 0f, 1f);
var tension = new BorderTensionRuntime
{
Id = $"tension-{borderEdge.Id}",
RelationId = relation.Id,
BorderEdgeId = borderEdge.Id,
FactionAId = leftFaction.Id,
FactionBId = rightFaction.Id,
Status = relation.Posture is "war" or "hostile" ? "escalating" : "stable",
TensionScore = relation.TensionScore,
IncidentScore = incidentSeverity,
MilitaryPressure = Math.Clamp(hostilePresence * 0.05f, 0f, 1f),
AccessFriction = relation.TradeAccessPolicy == "open" ? 0.15f : 0.75f,
UpdatedAtUtc = nowUtc,
};
tension.SystemIds.Add(borderEdge.SourceSystemId);
tension.SystemIds.Add(borderEdge.DestinationSystemId);
state.Diplomacy.BorderTensions.Add(tension);
if (tension.TensionScore >= 0.35f)
{
var incidentId = $"incident-border-{relationId}-{borderEdge.Id}";
var incident = new DiplomaticIncidentRuntime
{
Id = incidentId,
Kind = borderEdge.IsContested ? "border-clash" : "border-friction",
Status = relation.Posture == "war" ? "escalated" : "active",
SourceFactionId = leftFaction.Id,
TargetFactionId = rightFaction.Id,
SystemId = borderEdge.SourceSystemId,
BorderEdgeId = borderEdge.Id,
Summary = $"{leftFaction.Label} and {rightFaction.Label} are under pressure on {borderEdge.SourceSystemId}/{borderEdge.DestinationSystemId}.",
Severity = tension.TensionScore,
EscalationScore = tension.IncidentScore,
CreatedAtUtc = nowUtc,
LastObservedAtUtc = nowUtc,
};
state.Diplomacy.Incidents.Add(incident);
relation.ActiveIncidentIds.Add(incident.Id);
}
}
if (relation.Posture == "war")
{
var warId = $"war-{relationId}";
var war = new WarStateRuntime
{
Id = warId,
RelationId = relation.Id,
FactionAId = leftFaction.Id,
FactionBId = rightFaction.Id,
Status = "active",
WarGoal = "border-dominance",
EscalationScore = relation.TensionScore,
StartedAtUtc = nowUtc,
UpdatedAtUtc = nowUtc,
};
relation.WarStateId = war.Id;
state.Diplomacy.Wars.Add(war);
}
}
BuildFrontLines(state, nowUtc, events);
}
private static void BuildFrontLines(GeopoliticalStateRuntime state, DateTimeOffset nowUtc, ICollection<SimulationEventRecord> events)
{
foreach (var group in state.Diplomacy.BorderTensions
.Where(tension => tension.TensionScore >= 0.35f)
.GroupBy(tension => BuildPairId("front", tension.FactionAId, tension.FactionBId), StringComparer.Ordinal))
{
var tensions = group.OrderByDescending(tension => tension.TensionScore).ThenBy(tension => tension.Id, StringComparer.Ordinal).ToList();
var front = new FrontLineRuntime
{
Id = group.Key,
Kind = state.Diplomacy.Wars.Any(war => war.RelationId == tensions[0].RelationId && war.Status == "active") ? "war-front" : "border-front",
Status = "active",
AnchorSystemId = tensions.SelectMany(tension => tension.SystemIds).GroupBy(systemId => systemId, StringComparer.Ordinal).OrderByDescending(entry => entry.Count()).ThenBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => entry.Key).FirstOrDefault(),
PressureScore = Math.Clamp(tensions.Sum(tension => tension.TensionScore) / tensions.Count, 0f, 1f),
SupplyRisk = Math.Clamp(tensions.Sum(tension => tension.AccessFriction) / tensions.Count, 0f, 1f),
UpdatedAtUtc = nowUtc,
};
front.FactionIds.Add(tensions[0].FactionAId);
front.FactionIds.Add(tensions[0].FactionBId);
front.SystemIds.AddRange(tensions.SelectMany(tension => tension.SystemIds).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal));
front.BorderEdgeIds.AddRange(tensions.Select(tension => tension.BorderEdgeId).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal));
state.Territory.FrontLines.Add(front);
foreach (var war in state.Diplomacy.Wars.Where(war => string.Equals(war.RelationId, tensions[0].RelationId, StringComparison.Ordinal)))
{
war.ActiveFrontLineIds.Add(front.Id);
}
events.Add(new SimulationEventRecord("front-line", front.Id, "front-updated", $"Front {front.Id} pressure {front.PressureScore.ToString("0.00", CultureInfo.InvariantCulture)}.", nowUtc, "geopolitics"));
}
foreach (var profile in state.Territory.StrategicProfiles)
{
profile.FrontLineId = state.Territory.FrontLines.FirstOrDefault(front => front.SystemIds.Contains(profile.SystemId, StringComparer.Ordinal))?.Id;
}
}
private static void RebuildEconomyRegions(SimulationWorld world, GeopoliticalStateRuntime state)
{
state.EconomyRegions.Regions.Clear();
state.EconomyRegions.SupplyNetworks.Clear();
state.EconomyRegions.Corridors.Clear();
state.EconomyRegions.ProductionProfiles.Clear();
state.EconomyRegions.TradeBalances.Clear();
state.EconomyRegions.Bottlenecks.Clear();
state.EconomyRegions.SecurityAssessments.Clear();
state.EconomyRegions.EconomicAssessments.Clear();
var nowUtc = world.GeneratedAtUtc;
foreach (var faction in world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal))
{
var factionSystems = state.Territory.ControlStates
.Where(control => string.Equals(control.ControllerFactionId ?? control.PrimaryClaimantFactionId, faction.Id, StringComparison.Ordinal))
.Select(control => control.SystemId)
.Distinct(StringComparer.Ordinal)
.OrderBy(systemId => systemId, StringComparer.Ordinal)
.ToList();
if (factionSystems.Count == 0)
{
continue;
}
var connectedComponents = BuildConnectedComponents(factionSystems, state.Routes);
foreach (var component in connectedComponents)
{
var coreSystemId = component
.OrderByDescending(systemId => world.Stations.Count(station => station.FactionId == faction.Id && station.SystemId == systemId))
.ThenBy(systemId => systemId, StringComparer.Ordinal)
.First();
var regionId = $"region-{faction.Id}-{coreSystemId}";
var stations = world.Stations
.Where(station => station.FactionId == faction.Id && component.Contains(station.SystemId, StringComparer.Ordinal))
.OrderBy(station => station.Id, StringComparer.Ordinal)
.ToList();
var economy = BuildRegionalEconomy(world, faction.Id, component);
var regionKind = ResolveRegionKind(stations, economy);
var frontLineIds = state.Territory.FrontLines
.Where(front => front.SystemIds.Any(systemId => component.Contains(systemId, StringComparer.Ordinal)))
.Select(front => front.Id)
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToList();
var region = new EconomicRegionRuntime
{
Id = regionId,
FactionId = faction.Id,
Label = $"{faction.Label} {coreSystemId}",
Kind = regionKind,
Status = "active",
CoreSystemId = coreSystemId,
UpdatedAtUtc = nowUtc,
};
region.SystemIds.AddRange(component.OrderBy(id => id, StringComparer.Ordinal));
region.StationIds.AddRange(stations.Select(station => station.Id));
region.FrontLineIds.AddRange(frontLineIds);
state.EconomyRegions.Regions.Add(region);
var producerItems = economy.Commodities
.Where(entry => entry.Value.ProductionRatePerSecond > 0.01f)
.OrderByDescending(entry => entry.Value.ProductionRatePerSecond)
.ThenBy(entry => entry.Key, StringComparer.Ordinal)
.Take(8)
.Select(entry => entry.Key)
.ToList();
var scarceItems = economy.Commodities
.Where(entry => entry.Value.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low)
.OrderByDescending(entry => CommodityOperationalSignal.ComputeNeedScore(entry.Value, 240f))
.ThenBy(entry => entry.Key, StringComparer.Ordinal)
.Take(8)
.Select(entry => entry.Key)
.ToList();
var supplyNetwork = new SupplyNetworkRuntime
{
Id = $"network-{regionId}",
RegionId = regionId,
ThroughputScore = Math.Clamp(stations.Count * 0.18f, 0f, 1f),
RiskScore = Math.Clamp(frontLineIds.Count * 0.24f, 0f, 1f),
UpdatedAtUtc = nowUtc,
};
supplyNetwork.StationIds.AddRange(stations.Select(station => station.Id));
supplyNetwork.ProducerItemIds.AddRange(producerItems);
supplyNetwork.ConsumerItemIds.AddRange(scarceItems);
supplyNetwork.ConstructionItemIds.AddRange(world.ConstructionSites
.Where(site => site.FactionId == faction.Id && component.Contains(site.SystemId, StringComparer.Ordinal))
.SelectMany(site => site.RequiredItems.Keys)
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal));
state.EconomyRegions.SupplyNetworks.Add(supplyNetwork);
var productionProfile = new RegionalProductionProfileRuntime
{
RegionId = regionId,
PrimaryIndustry = regionKind,
ShipyardCount = stations.Count(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
StationCount = stations.Count,
UpdatedAtUtc = nowUtc,
};
productionProfile.ProducedItemIds.AddRange(producerItems);
productionProfile.ScarceItemIds.AddRange(scarceItems);
state.EconomyRegions.ProductionProfiles.Add(productionProfile);
state.EconomyRegions.TradeBalances.Add(new RegionalTradeBalanceRuntime
{
RegionId = regionId,
ImportsRequiredCount = economy.Commodities.Count(entry => entry.Value.BuyBacklog > 0.01f),
ExportsSurplusCount = economy.Commodities.Count(entry => entry.Value.SellBacklog > 0.01f || entry.Value.Level == CommodityLevelKind.Surplus),
CriticalShortageCount = scarceItems.Count,
NetTradeScore = Math.Clamp((economy.Commodities.Sum(entry => entry.Value.ProjectedNetRatePerSecond) + 5f) / 10f, -1f, 1f),
UpdatedAtUtc = nowUtc,
});
if (scarceItems.FirstOrDefault() is { } bottleneckItemId)
{
state.EconomyRegions.Bottlenecks.Add(new RegionalBottleneckRuntime
{
Id = $"bottleneck-{regionId}-{bottleneckItemId}",
RegionId = regionId,
ItemId = bottleneckItemId,
Cause = "regional-shortage",
Status = "active",
Severity = Math.Clamp(CommodityOperationalSignal.ComputeNeedScore(economy.GetCommodity(bottleneckItemId), 240f), 0f, 10f),
UpdatedAtUtc = nowUtc,
});
}
var supplyRisk = Math.Clamp(frontLineIds.Count * 0.2f, 0f, 1f);
state.EconomyRegions.SecurityAssessments.Add(new RegionalSecurityAssessmentRuntime
{
RegionId = regionId,
SupplyRisk = supplyRisk,
BorderPressure = Math.Clamp(frontLineIds.Count * 0.22f, 0f, 1f),
ActiveWarCount = state.Diplomacy.Wars.Count(war => war.ActiveFrontLineIds.Intersect(frontLineIds, StringComparer.Ordinal).Any()),
HostileRelationCount = state.Diplomacy.Relations.Count(relation => relation.Posture is "hostile" or "war"),
AccessFriction = Math.Clamp(state.Diplomacy.BorderTensions.Where(tension => tension.SystemIds.Any(systemId => component.Contains(systemId, StringComparer.Ordinal))).DefaultIfEmpty().Average(tension => tension?.AccessFriction ?? 0f), 0f, 1f),
UpdatedAtUtc = nowUtc,
});
state.EconomyRegions.EconomicAssessments.Add(new RegionalEconomicAssessmentRuntime
{
RegionId = regionId,
SustainmentScore = Math.Clamp(1f - (scarceItems.Count * 0.12f) - (supplyRisk * 0.35f), 0f, 1f),
ProductionDepth = Math.Clamp(producerItems.Count / 8f, 0f, 1f),
ConstructionPressure = Math.Clamp(world.ConstructionSites.Count(site => site.FactionId == faction.Id && component.Contains(site.SystemId, StringComparer.Ordinal)) * 0.22f, 0f, 1f),
CorridorDependency = Math.Clamp(frontLineIds.Count * 0.18f, 0f, 1f),
UpdatedAtUtc = nowUtc,
});
}
}
BuildCorridors(world, state, nowUtc);
foreach (var profile in state.Territory.StrategicProfiles)
{
profile.EconomicRegionId = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(profile.SystemId, StringComparer.Ordinal))?.Id;
}
}
private static void BuildCorridors(SimulationWorld world, GeopoliticalStateRuntime state, DateTimeOffset nowUtc)
{
foreach (var route in state.Routes)
{
var sourceRegion = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(route.SourceSystemId, StringComparer.Ordinal));
var destinationRegion = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(route.DestinationSystemId, StringComparer.Ordinal));
if (sourceRegion is null && destinationRegion is null)
{
continue;
}
var borderEdge = state.Territory.BorderEdges.FirstOrDefault(edge =>
(edge.SourceSystemId == route.SourceSystemId && edge.DestinationSystemId == route.DestinationSystemId)
|| (edge.SourceSystemId == route.DestinationSystemId && edge.DestinationSystemId == route.SourceSystemId));
var risk = borderEdge?.TensionScore ?? 0f;
var corridor = new LogisticsCorridorRuntime
{
Id = $"corridor-{route.Id}",
FactionId = sourceRegion?.FactionId ?? destinationRegion?.FactionId,
Kind = borderEdge?.IsContested == true ? "frontier-corridor" : "supply-corridor",
Status = borderEdge?.IsContested == true ? "risky" : "active",
RiskScore = Math.Clamp(risk + ((sourceRegion is not null && destinationRegion is not null && sourceRegion.Id != destinationRegion.Id) ? 0.15f : 0f), 0f, 1f),
ThroughputScore = Math.Clamp(((sourceRegion?.StationIds.Count ?? 0) + (destinationRegion?.StationIds.Count ?? 0)) / 10f, 0f, 1f),
AccessState = ResolveCorridorAccessState(world, borderEdge, sourceRegion, destinationRegion),
UpdatedAtUtc = nowUtc,
};
corridor.SystemPathIds.Add(route.SourceSystemId);
corridor.SystemPathIds.Add(route.DestinationSystemId);
if (sourceRegion is not null)
{
corridor.RegionIds.Add(sourceRegion.Id);
}
if (destinationRegion is not null && !corridor.RegionIds.Contains(destinationRegion.Id, StringComparer.Ordinal))
{
corridor.RegionIds.Add(destinationRegion.Id);
}
if (borderEdge is not null)
{
corridor.BorderEdgeIds.Add(borderEdge.Id);
}
state.EconomyRegions.Corridors.Add(corridor);
if (sourceRegion is not null && !sourceRegion.CorridorIds.Contains(corridor.Id, StringComparer.Ordinal))
{
sourceRegion.CorridorIds.Add(corridor.Id);
}
if (destinationRegion is not null && !destinationRegion.CorridorIds.Contains(corridor.Id, StringComparer.Ordinal))
{
destinationRegion.CorridorIds.Add(corridor.Id);
}
}
}
private static string ResolveCorridorAccessState(
SimulationWorld world,
BorderEdgeRuntime? borderEdge,
EconomicRegionRuntime? sourceRegion,
EconomicRegionRuntime? destinationRegion)
{
if (sourceRegion?.FactionId is null || destinationRegion?.FactionId is null)
{
return borderEdge?.IsContested == true ? "restricted" : "open";
}
var relation = FindRelation(world, sourceRegion.FactionId, destinationRegion.FactionId);
if (relation is null)
{
return "restricted";
}
return relation.Posture switch
{
"war" => "denied",
"hostile" => "restricted",
_ => relation.TradeAccessPolicy,
};
}
private static FactionEconomySnapshot BuildRegionalEconomy(SimulationWorld world, string factionId, IReadOnlyCollection<string> systemIds)
{
var snapshot = new FactionEconomySnapshot();
foreach (var station in world.Stations.Where(station => station.FactionId == factionId && systemIds.Contains(station.SystemId, StringComparer.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);
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 => order.FactionId == factionId))
{
var relatedSystemId = world.Stations.FirstOrDefault(station => station.Id == order.StationId)?.SystemId
?? world.ConstructionSites.FirstOrDefault(site => site.Id == order.ConstructionSiteId)?.SystemId;
if (relatedSystemId is null || !systemIds.Contains(relatedSystemId, StringComparer.Ordinal))
{
continue;
}
var commodity = snapshot.GetCommodity(order.ItemId);
if (order.Kind == MarketOrderKinds.Buy)
{
commodity.BuyBacklog += order.RemainingAmount;
}
else if (order.Kind == MarketOrderKinds.Sell)
{
commodity.SellBacklog += order.RemainingAmount;
}
}
foreach (var site in world.ConstructionSites.Where(site => site.FactionId == factionId && systemIds.Contains(site.SystemId, StringComparer.Ordinal)))
{
foreach (var required in site.RequiredItems)
{
var remaining = MathF.Max(0f, required.Value - (site.DeliveredItems.TryGetValue(required.Key, out var delivered) ? delivered : 0f));
if (remaining > 0.01f)
{
snapshot.GetCommodity(required.Key).ReservedForConstruction += remaining;
}
}
}
return snapshot;
}
private static List<List<string>> BuildConnectedComponents(IReadOnlyCollection<string> systems, IReadOnlyCollection<SystemRouteLinkRuntime> routes)
{
var remaining = systems.ToHashSet(StringComparer.Ordinal);
var adjacency = routes
.SelectMany(route => new[]
{
(route.SourceSystemId, route.DestinationSystemId),
(route.DestinationSystemId, route.SourceSystemId),
})
.GroupBy(entry => entry.Item1, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Select(entry => entry.Item2).ToList(), StringComparer.Ordinal);
var components = new List<List<string>>();
while (remaining.Count > 0)
{
var start = remaining.OrderBy(id => id, StringComparer.Ordinal).First();
var frontier = new Queue<string>();
frontier.Enqueue(start);
remaining.Remove(start);
var component = new List<string>();
while (frontier.Count > 0)
{
var current = frontier.Dequeue();
component.Add(current);
foreach (var neighbor in adjacency.GetValueOrDefault(current, []))
{
if (remaining.Remove(neighbor))
{
frontier.Enqueue(neighbor);
}
}
}
components.Add(component);
}
return components;
}
private static string ResolveRegionKind(IReadOnlyCollection<StationRuntime> stations, FactionEconomySnapshot economy)
{
if (stations.Any(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)))
{
return "shipbuilding-region";
}
if (stations.Count(station => StationSimulationService.DetermineStationRole(station) == "refinery") >= 2)
{
return "industrial-core";
}
if (economy.Commodities.Any(entry => entry.Value.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low))
{
return "frontier-sustainment";
}
return stations.Count <= 2 ? "extraction-region" : "balanced-region";
}
private static float EstimateSystemStrategicValue(SimulationWorld world, string systemId)
{
var stationValue = world.Stations.Count(station => station.SystemId == systemId) * 30f;
var constructionValue = world.ConstructionSites.Count(site => site.SystemId == systemId && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed) * 18f;
var nodeValue = world.Nodes.Count(node => node.SystemId == systemId) * 8f;
return stationValue + constructionValue + nodeValue;
}
private static string BuildRelationId(string factionAId, string factionBId) =>
BuildPairId("relation", factionAId, factionBId);
private static string BuildPairId(string prefix, string leftId, string rightId)
{
return string.Compare(leftId, rightId, StringComparison.Ordinal) <= 0
? $"{prefix}-{leftId}-{rightId}"
: $"{prefix}-{rightId}-{leftId}";
}
}