using System.Globalization; namespace SpaceGame.Api.Geopolitics.Simulation; internal sealed class GeopoliticalSimulationService { internal void Update(SimulationWorld world, float deltaSeconds, ICollection 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 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(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>(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(); 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 => ship.Definition.Kind switch { "military" => 9f, "construction" => 4f, "transport" => 3f, _ when ship.Definition.Kind == "mining" || ship.Definition.Kind == "miner" => 3f, _ => 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 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 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 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> BuildConnectedComponents(IReadOnlyCollection systems, IReadOnlyCollection 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>(); while (remaining.Count > 0) { var start = remaining.OrderBy(id => id, StringComparer.Ordinal).First(); var frontier = new Queue(); frontier.Enqueue(start); remaining.Remove(start); var component = new List(); 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 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}"; } }