diff --git a/1.6/1.6/Assemblies/WulaFallenEmpire.dll b/1.6/1.6/Assemblies/WulaFallenEmpire.dll
index e08503ed..75455d8a 100644
Binary files a/1.6/1.6/Assemblies/WulaFallenEmpire.dll and b/1.6/1.6/Assemblies/WulaFallenEmpire.dll differ
diff --git a/1.6/1.6/Assemblies/WulaFallenEmpire.pdb b/1.6/1.6/Assemblies/WulaFallenEmpire.pdb
index 4dc794eb..57582f75 100644
Binary files a/1.6/1.6/Assemblies/WulaFallenEmpire.pdb and b/1.6/1.6/Assemblies/WulaFallenEmpire.pdb differ
diff --git a/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/WULA_Keyed.xml b/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/WULA_Keyed.xml
index f9ddaf3b..521d8cb6 100644
--- a/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/WULA_Keyed.xml
+++ b/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/WULA_Keyed.xml
@@ -172,4 +172,20 @@
重试中
{FACTION_name}已经在附近投下了一些资源。
+
+
+ P.I.A 轨道监视已启动
+ P.I.A 已经接管了轨道防御系统,正在持续扫描敌对目标。\n\n如果有敌人出现且无误伤风险,AI 将自动进行打击。\n\n剩余时间:{0} 秒
+ P.I.A 轨道监视协议已处于激活状态(剩余 {0} 秒)。请求被忽略。
+ P.I.A 轨道监视协议:已启动,持续 {0} 秒。
+ P.I.A 轨道监视协议:已解除。
+ [P.I.A 轨道监视] 系统运行中。剩余时间:{0} 秒。
+ [P.I.A 轨道监视] 目标 {0} 打击中止:友军误伤风险。
+ [P.I.A 轨道监视] 发现超大型敌群({0} 个目标)!执行联合打击!
+ [P.I.A 轨道监视] 对敌群({0} 个目标)发射光矛。
+ [P.I.A 轨道监视] 对敌群({0} 个目标)发射副炮齐射。
+ [P.I.A 轨道监视] 对敌方({0} 个目标)发射链炮扫射。
+ [P.I.A 轨道监视] 帝国舰队已抵达轨道。
+ [P.I.A 轨道监视] 航道已净空。
+
diff --git a/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs b/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs
index 1cf4ccd7..29d468dd 100644
--- a/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs
+++ b/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs
@@ -340,6 +340,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
_tools.Add(new Tool_SearchThingDef());
_tools.Add(new Tool_SearchPawnKind());
_tools.Add(new Tool_CallPrefabAirdrop());
+ _tools.Add(new Tool_SetOverwatchMode());
// Agent 工具 - 保留画面分析截图能力,移除所有模拟操作工具
if (WulaFallenEmpireMod.settings?.enableVlmFeatures == true)
@@ -494,6 +495,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
"- call_bombardment\n" +
"- modify_goodwill\n" +
"- call_prefab_airdrop\n" +
+ "- set_overwatch_mode\n" +
"If no action is required, output exactly: .\n" +
"Query tools exist but are disabled in this phase (not listed here).\n"
: string.Empty;
@@ -509,7 +511,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
string actionWhitelist = phase == RequestPhase.ActionTools
? "ACTION PHASE VALID TAGS ONLY:\n" +
- ", , , , , \n" +
+ ", , , , , , \n" +
"INVALID EXAMPLES (do NOT use now): , , , \n"
: string.Empty;
@@ -655,7 +657,8 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
toolName == "send_reinforcement" ||
toolName == "call_bombardment" ||
toolName == "modify_goodwill" ||
- toolName == "call_prefab_airdrop";
+ toolName == "call_prefab_airdrop" ||
+ toolName == "set_overwatch_mode";
}
private static bool IsQueryToolName(string toolName)
diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Alerts/Alert_AIOverwatchActive.cs b/Source/WulaFallenEmpire/EventSystem/AI/Alerts/Alert_AIOverwatchActive.cs
new file mode 100644
index 00000000..3ce33e24
--- /dev/null
+++ b/Source/WulaFallenEmpire/EventSystem/AI/Alerts/Alert_AIOverwatchActive.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Collections.Generic;
+using RimWorld;
+using UnityEngine;
+using Verse;
+using WulaFallenEmpire.EventSystem.AI;
+
+namespace WulaFallenEmpire.EventSystem.AI.Alerts
+{
+ public class Alert_AIOverwatchActive : Alert_Critical
+ {
+ public Alert_AIOverwatchActive()
+ {
+ this.defaultLabel = "WULA_AIOverwatch_Label".Translate();
+ this.defaultExplanation = "WULA_AIOverwatch_Desc".Translate(0);
+ }
+
+ public override AlertReport GetReport()
+ {
+ var map = Find.CurrentMap;
+ if (map == null) return AlertReport.Inactive;
+
+ var comp = map.GetComponent();
+ if (comp != null && comp.IsEnabled)
+ {
+ return AlertReport.Active;
+ }
+
+ return AlertReport.Inactive;
+ }
+
+ public override string GetLabel()
+ {
+ var map = Find.CurrentMap;
+ if (map != null)
+ {
+ var comp = map.GetComponent();
+ if (comp != null && comp.IsEnabled)
+ {
+ int secondsLeft = comp.DurationTicks / 60;
+ return "WULA_AIOverwatch_Label".Translate() + $"\n({secondsLeft}s)";
+ }
+ }
+ return "WULA_AIOverwatch_Label".Translate();
+ }
+
+ public override TaggedString GetExplanation()
+ {
+ var map = Find.CurrentMap;
+ if (map != null)
+ {
+ var comp = map.GetComponent();
+ if (comp != null && comp.IsEnabled)
+ {
+ return "WULA_AIOverwatch_Desc".Translate(comp.DurationTicks / 60);
+ }
+ }
+ return base.GetExplanation();
+ }
+ }
+}
diff --git a/Source/WulaFallenEmpire/EventSystem/AI/MapComponent_AIOverwatch.cs b/Source/WulaFallenEmpire/EventSystem/AI/MapComponent_AIOverwatch.cs
new file mode 100644
index 00000000..ed9a77f6
--- /dev/null
+++ b/Source/WulaFallenEmpire/EventSystem/AI/MapComponent_AIOverwatch.cs
@@ -0,0 +1,370 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using RimWorld;
+using UnityEngine;
+using Verse;
+using WulaFallenEmpire.EventSystem.AI.Tools;
+
+namespace WulaFallenEmpire.EventSystem.AI
+{
+ public class MapComponent_AIOverwatch : MapComponent
+ {
+ private bool enabled = false;
+ private int durationTicks = 0;
+ private int tickCounter = 0;
+ private int checkInterval = 180; // Check every 3 seconds (180 ticks)
+
+ // Configurable cooldown to prevent spamming too many simultaneous strikes
+ private int globalCooldownTicks = 0;
+
+ public bool IsEnabled => enabled;
+ public int DurationTicks => durationTicks;
+
+ public MapComponent_AIOverwatch(Map map) : base(map)
+ {
+ }
+
+ public void EnableOverwatch(int durationSeconds)
+ {
+ if (this.enabled)
+ {
+ Messages.Message("WULA_AIOverwatch_AlreadyActive".Translate(this.durationTicks / 60), MessageTypeDefOf.RejectInput);
+ return;
+ }
+
+ // Hard limit: 3 minutes (180 seconds)
+ int clampedDuration = Math.Min(durationSeconds, 180);
+
+ this.enabled = true;
+ this.durationTicks = clampedDuration * 60;
+ this.tickCounter = 0;
+ this.globalCooldownTicks = 0;
+
+ // Call fleet when overwatch starts
+ TryCallFleet();
+
+ Messages.Message("WULA_AIOverwatch_Engaged".Translate(clampedDuration), MessageTypeDefOf.PositiveEvent);
+ }
+
+ public void DisableOverwatch()
+ {
+ this.enabled = false;
+ this.durationTicks = 0;
+
+ // Clear flight path when overwatch ends
+ TryClearFlightPath();
+
+ Messages.Message("WULA_AIOverwatch_Disengaged".Translate(), MessageTypeDefOf.NeutralEvent);
+ }
+
+ private void TryCallFleet()
+ {
+ try
+ {
+ // Find the FlyOver spawner component and trigger it
+ var flyOverDef = DefDatabase.GetNamedSilentFail("WULA_AircraftCarrier");
+ if (flyOverDef != null)
+ {
+ // Use the FlyOver spawning system
+ var flyOver = ThingMaker.MakeThing(flyOverDef) as FlyOver;
+ if (flyOver != null)
+ {
+ // Configure the flyover
+ flyOver.flightSpeed = 0.03f;
+ flyOver.altitude = 20;
+
+ // Spawn at map edge
+ IntVec3 spawnPos = CellFinder.RandomEdgeCell(map);
+ GenSpawn.Spawn(flyOver, spawnPos, map);
+
+ Messages.Message("WULA_AIOverwatch_FleetCalled".Translate(), MessageTypeDefOf.PositiveEvent);
+ WulaLog.Debug("[AI Overwatch] Called fleet: WULA_AircraftCarrier spawned.");
+ }
+ }
+ else
+ {
+ WulaLog.Debug("[AI Overwatch] Could not find WULA_AircraftCarrier ThingDef.");
+ }
+ }
+ catch (Exception ex)
+ {
+ WulaLog.Debug($"[AI Overwatch] Failed to call fleet: {ex.Message}");
+ }
+ }
+
+ private void TryClearFlightPath()
+ {
+ try
+ {
+ // Find all FlyOver entities on the map and destroy them
+ var flyOvers = map.listerThings.AllThings
+ .Where(t => t is FlyOver || t.def.defName.Contains("FlyOver") || t.def.defName.Contains("AircraftCarrier"))
+ .ToList();
+
+ foreach (var flyOver in flyOvers)
+ {
+ if (!flyOver.Destroyed)
+ {
+ flyOver.Destroy(DestroyMode.Vanish);
+ }
+ }
+
+ if (flyOvers.Count > 0)
+ {
+ Messages.Message("WULA_AIOverwatch_FleetCleared".Translate(), MessageTypeDefOf.NeutralEvent);
+ WulaLog.Debug($"[AI Overwatch] Cleared flight path: Destroyed {flyOvers.Count} entities.");
+ }
+ }
+ catch (Exception ex)
+ {
+ WulaLog.Debug($"[AI Overwatch] Failed to clear flight path: {ex.Message}");
+ }
+ }
+
+ public override void MapComponentTick()
+ {
+ base.MapComponentTick();
+ if (!enabled) return;
+
+ durationTicks--;
+ if (durationTicks <= 0)
+ {
+ DisableOverwatch();
+ return;
+ }
+
+ if (globalCooldownTicks > 0)
+ {
+ globalCooldownTicks--;
+ }
+
+ tickCounter++;
+ if (tickCounter >= checkInterval)
+ {
+ tickCounter = 0;
+
+ // Optional: Notify user every 30 seconds (1800 ticks) that system is still active
+ if ((durationTicks % 1800) < checkInterval)
+ {
+ Messages.Message("WULA_AIOverwatch_SystemActive".Translate(durationTicks / 60), MessageTypeDefOf.NeutralEvent);
+ }
+
+ PerformScanAndStrike();
+ }
+ }
+
+ private void PerformScanAndStrike()
+ {
+ // Gather all valid hostile targets
+ List hostiles = map.mapPawns.AllPawnsSpawned
+ .Where(p => !p.Dead && !p.Downed && p.HostileTo(Faction.OfPlayer) && !p.IsPrisoner)
+ .ToList();
+
+ if (hostiles.Count == 0) return;
+
+ // Simple clustering: Group hostiles that are close to each other
+ var clusters = ClusterPawns(hostiles, 12f); // 12 tile radius for a cluster
+
+ // Prioritize larger clusters
+ clusters.Sort((a, b) => b.Count.CompareTo(a.Count)); // Descending order
+
+ // Process clusters
+ _strikesThisScan = 0;
+
+ foreach (var cluster in clusters)
+ {
+ if (globalCooldownTicks > 0) break;
+ ProcessCluster(cluster);
+ }
+ }
+
+ private int _strikesThisScan = 0;
+
+ private void ProcessCluster(List cluster)
+ {
+ if (cluster.Count == 0) return;
+ if (_strikesThisScan >= 3) return; // Self-limit
+
+ // Calculate center of mass
+ float x = 0, z = 0;
+ foreach (var p in cluster)
+ {
+ x += p.Position.x;
+ z += p.Position.z;
+ }
+ IntVec3 center = new IntVec3((int)(x / cluster.Count), 0, (int)(z / cluster.Count));
+
+ if (!center.InBounds(map)) return;
+
+ float angle = Rand.Range(0, 360);
+
+ // NEW Decision Logic:
+ // >= 20: Heavy (Energy Lance + Primary Cannon together)
+ // >= 10: Energy Lance only
+ // >= 3: Cannon Salvo (Medium)
+ // < 3: Minigun (Light)
+
+ if (cluster.Count >= 20)
+ {
+ // Ultra Heavy: Fire BOTH Energy Lance AND Primary Cannon
+ float safetyRadius = 18.9f;
+ if (IsFriendlyFireRisk(center, safetyRadius))
+ {
+ Messages.Message("WULA_AIOverwatch_FriendlyFireAbort".Translate(center.ToString()), new TargetInfo(center, map), MessageTypeDefOf.CautionInput);
+ return;
+ }
+
+ var lanceDef = DefDatabase.GetNamedSilentFail("WULA_Firepower_EnergyLance_Strafe");
+ var cannonDef = DefDatabase.GetNamedSilentFail("WULA_Firepower_Primary_Cannon_Strafe");
+
+ Messages.Message("WULA_AIOverwatch_MassiveCluster".Translate(cluster.Count), new TargetInfo(center, map), MessageTypeDefOf.ThreatBig);
+ WulaLog.Debug($"[AI Overwatch] MASSIVE cluster ({cluster.Count}), executing combined strike at {center}.");
+
+ if (lanceDef != null) FireAbility(lanceDef, center, angle);
+ if (cannonDef != null) FireAbility(cannonDef, center, angle + 45f); // Offset angle for variety
+
+ _strikesThisScan++;
+ return;
+ }
+
+ if (cluster.Count >= 10)
+ {
+ // Heavy: Energy Lance only
+ float safetyRadius = 16.9f;
+ if (IsFriendlyFireRisk(center, safetyRadius))
+ {
+ Messages.Message("WULA_AIOverwatch_FriendlyFireAbort".Translate(center.ToString()), new TargetInfo(center, map), MessageTypeDefOf.CautionInput);
+ return;
+ }
+
+ var lanceDef = DefDatabase.GetNamedSilentFail("WULA_Firepower_EnergyLance_Strafe");
+ if (lanceDef != null)
+ {
+ Messages.Message("WULA_AIOverwatch_EngagingLance".Translate(cluster.Count), new TargetInfo(center, map), MessageTypeDefOf.PositiveEvent);
+ WulaLog.Debug($"[AI Overwatch] Engaging {cluster.Count} hostiles with Energy Lance at {center}.");
+ FireAbility(lanceDef, center, angle);
+ _strikesThisScan++;
+ }
+ return;
+ }
+
+ if (cluster.Count >= 3)
+ {
+ // Medium: Cannon Salvo
+ float safetyRadius = 9.9f;
+ if (IsFriendlyFireRisk(center, safetyRadius))
+ {
+ Messages.Message("WULA_AIOverwatch_FriendlyFireAbort".Translate(center.ToString()), new TargetInfo(center, map), MessageTypeDefOf.CautionInput);
+ return;
+ }
+
+ var cannonDef = DefDatabase.GetNamedSilentFail("WULA_Firepower_Cannon_Salvo");
+ if (cannonDef != null)
+ {
+ Messages.Message("WULA_AIOverwatch_EngagingCannon".Translate(cluster.Count), new TargetInfo(center, map), MessageTypeDefOf.PositiveEvent);
+ WulaLog.Debug($"[AI Overwatch] Engaging {cluster.Count} hostiles with Cannon Salvo at {center}.");
+ FireAbility(cannonDef, center, angle);
+ _strikesThisScan++;
+ }
+ return;
+ }
+
+ // Light: Minigun Strafe
+ {
+ float safetyRadius = 5.9f;
+ if (IsFriendlyFireRisk(center, safetyRadius))
+ {
+ Messages.Message("WULA_AIOverwatch_FriendlyFireAbort".Translate(center.ToString()), new TargetInfo(center, map), MessageTypeDefOf.CautionInput);
+ return;
+ }
+
+ var minigunDef = DefDatabase.GetNamedSilentFail("WULA_Firepower_Minigun_Strafe");
+ if (minigunDef != null)
+ {
+ Messages.Message("WULA_AIOverwatch_EngagingMinigun".Translate(cluster.Count), new TargetInfo(center, map), MessageTypeDefOf.PositiveEvent);
+ WulaLog.Debug($"[AI Overwatch] Engaging {cluster.Count} hostiles with Minigun Strafe at {center}.");
+ FireAbility(minigunDef, center, angle);
+ _strikesThisScan++;
+ }
+ }
+ }
+
+ private void FireAbility(AbilityDef ability, IntVec3 target, float angle)
+ {
+ // Route via BombardmentUtility
+ // We need to check components again to know which method to call
+
+ var circular = ability.comps?.OfType().FirstOrDefault();
+ if (circular != null)
+ {
+ BombardmentUtility.ExecuteCircularBombardment(map, target, ability, circular);
+ return;
+ }
+
+ var bombard = ability.comps?.OfType().FirstOrDefault();
+ if (bombard != null)
+ {
+ BombardmentUtility.ExecuteStrafeBombardmentDirect(map, target, ability, bombard, angle);
+ return;
+ }
+
+ var lance = ability.comps?.OfType().FirstOrDefault();
+ if (lance != null)
+ {
+ BombardmentUtility.ExecuteEnergyLanceDirect(map, target, ability, lance, angle);
+ return;
+ }
+ }
+
+ private bool IsFriendlyFireRisk(IntVec3 center, float radius)
+ {
+ var pawns = map.mapPawns.AllPawnsSpawned;
+ foreach (var p in pawns)
+ {
+ if (p.Faction == Faction.OfPlayer || p.IsPrisonerOfColony)
+ {
+ if (p.Position.InHorDistOf(center, radius)) return true;
+ }
+ }
+ return false;
+ }
+
+ private List> ClusterPawns(List pawns, float radius)
+ {
+ var clusters = new List>();
+ var assigned = new HashSet();
+
+ foreach (var p in pawns)
+ {
+ if (assigned.Contains(p)) continue;
+
+ var newCluster = new List { p };
+ assigned.Add(p);
+
+ // Find neighbors
+ foreach (var neighbor in pawns)
+ {
+ if (assigned.Contains(neighbor)) continue;
+ if (p.Position.InHorDistOf(neighbor.Position, radius))
+ {
+ newCluster.Add(neighbor);
+ assigned.Add(neighbor);
+ }
+ }
+ clusters.Add(newCluster);
+ }
+ return clusters;
+ }
+
+ public override void ExposeData()
+ {
+ base.ExposeData();
+ Scribe_Values.Look(ref enabled, "enabled", false);
+ Scribe_Values.Look(ref durationTicks, "durationTicks", 0);
+ Scribe_Values.Look(ref tickCounter, "tickCounter", 0);
+ Scribe_Values.Look(ref globalCooldownTicks, "globalCooldownTicks", 0);
+ }
+ }
+}
diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Tools/BombardmentUtility.cs b/Source/WulaFallenEmpire/EventSystem/AI/Tools/BombardmentUtility.cs
new file mode 100644
index 00000000..05e467c4
--- /dev/null
+++ b/Source/WulaFallenEmpire/EventSystem/AI/Tools/BombardmentUtility.cs
@@ -0,0 +1,429 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using RimWorld;
+using UnityEngine;
+using Verse;
+using WulaFallenEmpire;
+
+namespace WulaFallenEmpire.EventSystem.AI.Tools
+{
+ public static class BombardmentUtility
+ {
+ public static string ExecuteCircularBombardment(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityCircularBombardment props, Dictionary parsed = null)
+ {
+ if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef.";
+
+ bool filter = true;
+ if (parsed != null && parsed.TryGetValue("filterFriendlyFire", out var ffStr) && bool.TryParse(ffStr, out bool ff)) filter = ff;
+
+ List selectedTargets = SelectTargetCells(map, targetCell, props, filter);
+ if (selectedTargets.Count == 0) return $"Error: No valid target cells near {targetCell}.";
+
+ bool isPaused = Find.TickManager != null && Find.TickManager.Paused;
+ int totalLaunches = ScheduleBombardment(map, selectedTargets, props, spawnImmediately: isPaused);
+
+ return $"Success: Scheduled Circular Bombardment '{def.defName}' at {targetCell}. Launches: {totalLaunches}/{props.maxLaunches}.";
+ }
+
+ public static string ExecuteStrafeBombardment(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityBombardment props, Dictionary parsed = null)
+ {
+ if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef.";
+
+ ParseDirectionInfo(parsed, targetCell, props.bombardmentLength, true, out Vector3 direction, out IntVec3 _);
+
+ var targetCells = CalculateBombardmentAreaCells(map, targetCell, direction, props.bombardmentWidth, props.bombardmentLength);
+
+ if (targetCells.Count == 0) return $"Error: No valid targets found for strafe at {targetCell}.";
+
+ var selectedCells = new List();
+ var missedCells = new List();
+ foreach (var cell in targetCells)
+ {
+ if (Rand.Value <= props.targetSelectionChance) selectedCells.Add(cell);
+ else missedCells.Add(cell);
+ }
+
+ if (selectedCells.Count < props.minTargetCells && missedCells.Count > 0)
+ {
+ int needed = props.minTargetCells - selectedCells.Count;
+ selectedCells.AddRange(missedCells.InRandomOrder().Take(Math.Min(needed, missedCells.Count)));
+ }
+ else if (selectedCells.Count > props.maxTargetCells)
+ {
+ selectedCells = selectedCells.InRandomOrder().Take(props.maxTargetCells).ToList();
+ }
+
+ if (selectedCells.Count == 0) return $"Error: No cells selected for strafe after chance filter.";
+
+ var rows = OrganizeIntoRows(targetCell, direction, selectedCells);
+
+ var delayed = map.GetComponent();
+ if (delayed == null)
+ {
+ delayed = new MapComponent_SkyfallerDelayed(map);
+ map.components.Add(delayed);
+ }
+
+ int now = Find.TickManager.TicksGame;
+ int startTick = now + props.warmupTicks;
+ int totalScheduled = 0;
+
+ foreach (var row in rows)
+ {
+ int rowStartTick = startTick + (row.Key * props.rowDelayTicks);
+ for (int i = 0; i < row.Value.Count; i++)
+ {
+ int hitTick = rowStartTick + (i * props.impactDelayTicks);
+ int delay = hitTick - now;
+
+ if (delay <= 0)
+ {
+ Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(props.skyfallerDef);
+ GenSpawn.Spawn(skyfaller, row.Value[i], map);
+ }
+ else
+ {
+ delayed.ScheduleSkyfaller(props.skyfallerDef, row.Value[i], delay);
+ }
+ totalScheduled++;
+ }
+ }
+
+ return $"Success: Scheduled Strafe Bombardment '{def.defName}' at {targetCell}. Direction: {direction}. Targets: {totalScheduled}.";
+ }
+
+ public static string ExecuteStrafeBombardmentDirect(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityBombardment props, float angle)
+ {
+ // Overload for direct execution with angle (no parsing needed)
+ Vector3 direction = Quaternion.AngleAxis(angle, Vector3.up) * Vector3.forward;
+ // Reuse the main logic by passing a mock dictionary or separating the logic further?
+ // To simplify, let's just copy the core logic or create a private helper that takes explicit args.
+ // Actually, the main method parses direction from 'parsed'.
+ // Let's make a Dictionary to pass to it.
+ var dict = new Dictionary { { "angle", angle.ToString() } };
+ return ExecuteStrafeBombardment(map, targetCell, def, props, dict);
+ }
+
+ public static string ExecuteEnergyLance(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityEnergyLance props, Dictionary parsed = null)
+ {
+ ThingDef lanceDef = props.energyLanceDef ?? DefDatabase.GetNamedSilentFail("EnergyLance");
+ if (lanceDef == null) return $"Error: Could not resolve EnergyLance ThingDef for '{def.defName}'.";
+
+ ParseDirectionInfo(parsed, targetCell, props.moveDistance, props.useFixedDistance, out Vector3 direction, out IntVec3 endPos);
+
+ try
+ {
+ EnergyLance.MakeEnergyLance(
+ lanceDef,
+ targetCell,
+ endPos,
+ map,
+ props.moveDistance,
+ props.useFixedDistance,
+ props.durationTicks,
+ null
+ );
+
+ return $"Success: Triggered Energy Lance '{def.defName}' from {targetCell} towards {endPos}. Type: {lanceDef.defName}.";
+ }
+ catch (Exception ex)
+ {
+ return $"Error: Failed to spawn EnergyLance: {ex.Message}";
+ }
+ }
+
+ public static string ExecuteEnergyLanceDirect(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityEnergyLance props, float angle)
+ {
+ var dict = new Dictionary { { "angle", angle.ToString() } };
+ return ExecuteEnergyLance(map, targetCell, def, props, dict);
+ }
+
+ public static string ExecuteCallSkyfaller(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityCallSkyfaller props)
+ {
+ if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef.";
+
+ var delayed = map.GetComponent();
+ if (delayed == null)
+ {
+ delayed = new MapComponent_SkyfallerDelayed(map);
+ map.components.Add(delayed);
+ }
+
+ int delay = props.delayTicks;
+ if (delay <= 0)
+ {
+ Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(props.skyfallerDef);
+ GenSpawn.Spawn(skyfaller, targetCell, map);
+ return $"Success: Spawned Skyfaller '{def.defName}' immediately at {targetCell}.";
+ }
+ else
+ {
+ delayed.ScheduleSkyfaller(props.skyfallerDef, targetCell, delay);
+ return $"Success: Scheduled Skyfaller '{def.defName}' at {targetCell} in {delay} ticks.";
+ }
+ }
+
+ // --- Helpers ---
+
+ private static void ParseDirectionInfo(Dictionary parsed, IntVec3 startPos, float moveDistance, bool useFixedDistance, out Vector3 direction, out IntVec3 endPos)
+ {
+ direction = Vector3.forward;
+ endPos = startPos;
+
+ if (parsed == null)
+ {
+ // Default North
+ endPos = (startPos.ToVector3() + Vector3.forward * moveDistance).ToIntVec3();
+ return;
+ }
+
+ if (parsed.TryGetValue("angle", out var angleStr) && float.TryParse(angleStr, out float angle))
+ {
+ direction = Quaternion.AngleAxis(angle, Vector3.up) * Vector3.forward;
+ endPos = (startPos.ToVector3() + direction * moveDistance).ToIntVec3();
+ }
+ else if (TryParseDirectionCell(parsed, out IntVec3 dirCell))
+ {
+ direction = (dirCell.ToVector3() - startPos.ToVector3()).normalized;
+ if (direction == Vector3.zero) direction = Vector3.forward;
+
+ if (useFixedDistance)
+ {
+ endPos = (startPos.ToVector3() + direction * moveDistance).ToIntVec3();
+ }
+ else
+ {
+ endPos = dirCell;
+ }
+ }
+ else
+ {
+ // Default North
+ endPos = (startPos.ToVector3() + Vector3.forward * moveDistance).ToIntVec3();
+ }
+ }
+
+ private static bool TryParseDirectionCell(Dictionary parsed, out IntVec3 cell)
+ {
+ cell = IntVec3.Invalid;
+ if (parsed == null) return false;
+
+ if (parsed.TryGetValue("dirX", out var xStr) && parsed.TryGetValue("dirZ", out var zStr) &&
+ int.TryParse(xStr, out int x) && int.TryParse(zStr, out int z))
+ {
+ cell = new IntVec3(x, 0, z);
+ return true;
+ }
+
+ if (parsed.TryGetValue("direction", out var dirStr) && !string.IsNullOrWhiteSpace(dirStr))
+ {
+ var parts = dirStr.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length >= 2 && int.TryParse(parts[0], out int dx) && int.TryParse(parts[1], out int dz))
+ {
+ cell = new IntVec3(dx, 0, dz);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static List SelectTargetCells(Map map, IntVec3 center, CompProperties_AbilityCircularBombardment props, bool filterFriendlyFire)
+ {
+ var candidates = GenRadial.RadialCellsAround(center, props.radius, true)
+ .Where(c => c.InBounds(map))
+ .Where(c => IsValidTargetCell(map, c, center, props, filterFriendlyFire))
+ .ToList();
+
+ if (candidates.Count == 0) return new List();
+
+ var selected = new List();
+ foreach (var cell in candidates.InRandomOrder())
+ {
+ if (Rand.Value <= props.targetSelectionChance)
+ {
+ selected.Add(cell);
+ }
+
+ if (selected.Count >= props.maxTargets) break;
+ }
+
+ if (selected.Count < props.minTargets)
+ {
+ var missedCells = candidates.Except(selected).InRandomOrder().ToList();
+ int needed = props.minTargets - selected.Count;
+ if (needed > 0 && missedCells.Count > 0)
+ {
+ selected.AddRange(missedCells.Take(Math.Min(needed, missedCells.Count)));
+ }
+ }
+ else if (selected.Count > props.maxTargets)
+ {
+ selected = selected.InRandomOrder().Take(props.maxTargets).ToList();
+ }
+
+ return selected;
+ }
+
+ private static bool IsValidTargetCell(Map map, IntVec3 cell, IntVec3 center, CompProperties_AbilityCircularBombardment props, bool filterFriendlyFire)
+ {
+ if (props.minDistanceFromCenter > 0f)
+ {
+ float distance = Vector3.Distance(cell.ToVector3(), center.ToVector3());
+ if (distance < props.minDistanceFromCenter) return false;
+ }
+
+ if (props.avoidBuildings && cell.GetEdifice(map) != null)
+ {
+ return false;
+ }
+
+ if (filterFriendlyFire && props.avoidFriendlyFire)
+ {
+ var things = map.thingGrid.ThingsListAt(cell);
+ if (things != null)
+ {
+ for (int i = 0; i < things.Count; i++)
+ {
+ if (things[i] is Pawn pawn && pawn.Faction == Faction.OfPlayer)
+ {
+ return false;
+ }
+ }
+ }
+ }
+
+ return true;
+ }
+
+ private static int ScheduleBombardment(Map map, List targets, CompProperties_AbilityCircularBombardment props, bool spawnImmediately)
+ {
+ int now = Find.TickManager?.TicksGame ?? 0;
+ int startTick = now + props.warmupTicks;
+ int launchesCompleted = 0;
+ int groupIndex = 0;
+
+ var remainingTargets = new List(targets);
+
+ MapComponent_SkyfallerDelayed delayed = null;
+ if (!spawnImmediately)
+ {
+ delayed = map.GetComponent();
+ if (delayed == null)
+ {
+ delayed = new MapComponent_SkyfallerDelayed(map);
+ map.components.Add(delayed);
+ }
+ }
+
+ while (remainingTargets.Count > 0 && launchesCompleted < props.maxLaunches)
+ {
+ int groupSize = Math.Min(props.simultaneousLaunches, remainingTargets.Count);
+ var groupTargets = remainingTargets.Take(groupSize).ToList();
+ remainingTargets.RemoveRange(0, groupSize);
+
+ if (props.useIndependentIntervals)
+ {
+ for (int i = 0; i < groupTargets.Count && launchesCompleted < props.maxLaunches; i++)
+ {
+ int scheduledTick = startTick + groupIndex * props.launchIntervalTicks + i * props.innerLaunchIntervalTicks;
+ SpawnOrSchedule(map, delayed, props.skyfallerDef, groupTargets[i], spawnImmediately, scheduledTick - now);
+ launchesCompleted++;
+ }
+ groupIndex++;
+ }
+ else
+ {
+ int scheduledTick = startTick + groupIndex * props.launchIntervalTicks;
+ for (int i = 0; i < groupTargets.Count && launchesCompleted < props.maxLaunches; i++)
+ {
+ SpawnOrSchedule(map, delayed, props.skyfallerDef, groupTargets[i], spawnImmediately, scheduledTick - now);
+ launchesCompleted++;
+ }
+ groupIndex++;
+ }
+ }
+
+ return launchesCompleted;
+ }
+
+ private static void SpawnOrSchedule(Map map, MapComponent_SkyfallerDelayed delayed, ThingDef skyfallerDef, IntVec3 cell, bool spawnImmediately, int delayTicks)
+ {
+ if (!cell.IsValid || !cell.InBounds(map)) return;
+
+ if (spawnImmediately || delayTicks <= 0)
+ {
+ Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(skyfallerDef);
+ GenSpawn.Spawn(skyfaller, cell, map);
+ return;
+ }
+
+ delayed?.ScheduleSkyfaller(skyfallerDef, cell, delayTicks);
+ }
+
+ private static List CalculateBombardmentAreaCells(Map map, IntVec3 startCell, Vector3 direction, int width, int length)
+ {
+ var areaCells = new List();
+ Vector3 start = startCell.ToVector3();
+ Vector3 perpendicularDirection = new Vector3(-direction.z, 0, direction.x).normalized;
+
+ float halfWidth = width * 0.5f;
+ float totalLength = length;
+
+ int widthSteps = Math.Max(1, width);
+ int lengthSteps = Math.Max(1, length);
+
+ for (int l = 0; l <= lengthSteps; l++)
+ {
+ float lengthProgress = (float)l / lengthSteps;
+ float lengthOffset = UnityEngine.Mathf.Lerp(0, totalLength, lengthProgress);
+
+ for (int w = 0; w <= widthSteps; w++)
+ {
+ float widthProgress = (float)w / widthSteps;
+ float widthOffset = UnityEngine.Mathf.Lerp(-halfWidth, halfWidth, widthProgress);
+
+ Vector3 cellPos = start + direction * lengthOffset + perpendicularDirection * widthOffset;
+ IntVec3 cell = new IntVec3(
+ UnityEngine.Mathf.RoundToInt(cellPos.x),
+ UnityEngine.Mathf.RoundToInt(cellPos.y),
+ UnityEngine.Mathf.RoundToInt(cellPos.z)
+ );
+
+ if (cell.InBounds(map) && !areaCells.Contains(cell))
+ {
+ areaCells.Add(cell);
+ }
+ }
+ }
+ return areaCells;
+ }
+
+ private static Dictionary> OrganizeIntoRows(IntVec3 startCell, Vector3 direction, List cells)
+ {
+ var rows = new Dictionary>();
+ Vector3 perpendicularDirection = new Vector3(-direction.z, 0, direction.x).normalized;
+
+ foreach (var cell in cells)
+ {
+ Vector3 cellVector = cell.ToVector3() - startCell.ToVector3();
+ float dot = Vector3.Dot(cellVector, direction);
+ int rowIndex = UnityEngine.Mathf.RoundToInt(dot);
+
+ if (!rows.ContainsKey(rowIndex)) rows[rowIndex] = new List();
+ rows[rowIndex].Add(cell);
+ }
+
+ // Sort rows by index (distance from start)
+ var sortedRows = rows.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value);
+
+ // Sort cells within rows by width position
+ foreach (var key in sortedRows.Keys.ToList())
+ {
+ sortedRows[key] = sortedRows[key].OrderBy(c => Vector3.Dot((c.ToVector3() - startCell.ToVector3()), perpendicularDirection)).ToList();
+ }
+
+ return sortedRows;
+ }
+ }
+}
diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_CallBombardment.cs b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_CallBombardment.cs
index 6c62a41a..4beeef98 100644
--- a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_CallBombardment.cs
+++ b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_CallBombardment.cs
@@ -39,59 +39,25 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
// Switch logic based on AbilityDef components
var circular = abilityDef.comps?.OfType().FirstOrDefault();
- if (circular != null) return ExecuteCircularBombardment(map, targetCell, abilityDef, circular, parsed);
+ if (circular != null) return BombardmentUtility.ExecuteCircularBombardment(map, targetCell, abilityDef, circular, parsed);
var bombard = abilityDef.comps?.OfType().FirstOrDefault();
- if (bombard != null) return ExecuteStrafeBombardment(map, targetCell, abilityDef, bombard, parsed);
+ if (bombard != null) return BombardmentUtility.ExecuteStrafeBombardment(map, targetCell, abilityDef, bombard, parsed);
var lance = abilityDef.comps?.OfType().FirstOrDefault();
- if (lance != null) return ExecuteEnergyLance(map, targetCell, abilityDef, lance, parsed);
+ if (lance != null) return BombardmentUtility.ExecuteEnergyLance(map, targetCell, abilityDef, lance, parsed);
var skyfaller = abilityDef.comps?.OfType().FirstOrDefault();
- if (skyfaller != null) return ExecuteCallSkyfaller(map, targetCell, abilityDef, skyfaller);
+ if (skyfaller != null) return BombardmentUtility.ExecuteCallSkyfaller(map, targetCell, abilityDef, skyfaller);
return $"Error: AbilityDef '{abilityDefName}' is not a supported bombardment/support type.";
-
}
catch (Exception ex)
{
return $"Error: {ex.Message}";
}
}
-
- // Shared helper for determining direction and end point
- private void ParseDirectionInfo(Dictionary parsed, IntVec3 startPos, float moveDistance, bool useFixedDistance, out Vector3 direction, out IntVec3 endPos)
- {
- direction = Vector3.forward;
- endPos = startPos;
-
- if (parsed.TryGetValue("angle", out var angleStr) && float.TryParse(angleStr, out float angle))
- {
- direction = Quaternion.AngleAxis(angle, Vector3.up) * Vector3.forward;
- endPos = (startPos.ToVector3() + direction * moveDistance).ToIntVec3();
- }
- else if (TryParseDirectionCell(parsed, out IntVec3 dirCell))
- {
- // direction towards dirCell
- direction = (dirCell.ToVector3() - startPos.ToVector3()).normalized;
- if (direction == Vector3.zero) direction = Vector3.forward;
-
- if (useFixedDistance)
- {
- endPos = (startPos.ToVector3() + direction * moveDistance).ToIntVec3();
- }
- else
- {
- endPos = dirCell;
- }
- }
- else
- {
- // Default North
- endPos = (startPos.ToVector3() + Vector3.forward * moveDistance).ToIntVec3();
- }
- }
-
+
private static bool TryParseTargetCell(Dictionary parsed, out IntVec3 cell)
{
cell = IntVec3.Invalid;
@@ -115,415 +81,5 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
return false;
}
-
- private static List SelectTargetCells(Map map, IntVec3 center, CompProperties_AbilityCircularBombardment props, bool filterFriendlyFire)
- {
- var candidates = GenRadial.RadialCellsAround(center, props.radius, true)
- .Where(c => c.InBounds(map))
- .Where(c => IsValidTargetCell(map, c, center, props, filterFriendlyFire))
- .ToList();
-
- if (candidates.Count == 0) return new List();
-
- var selected = new List();
- foreach (var cell in candidates.InRandomOrder())
- {
- if (Rand.Value <= props.targetSelectionChance)
- {
- selected.Add(cell);
- }
-
- if (selected.Count >= props.maxTargets) break;
- }
-
- if (selected.Count < props.minTargets)
- {
- var missedCells = candidates.Except(selected).InRandomOrder().ToList();
- int needed = props.minTargets - selected.Count;
- if (needed > 0 && missedCells.Count > 0)
- {
- selected.AddRange(missedCells.Take(Math.Min(needed, missedCells.Count)));
- }
- }
- else if (selected.Count > props.maxTargets)
- {
- selected = selected.InRandomOrder().Take(props.maxTargets).ToList();
- }
-
- return selected;
- }
-
- private static bool IsValidTargetCell(Map map, IntVec3 cell, IntVec3 center, CompProperties_AbilityCircularBombardment props, bool filterFriendlyFire)
- {
- if (props.minDistanceFromCenter > 0f)
- {
- float distance = Vector3.Distance(cell.ToVector3(), center.ToVector3());
- if (distance < props.minDistanceFromCenter) return false;
- }
-
- if (props.avoidBuildings && cell.GetEdifice(map) != null)
- {
- return false;
- }
-
- if (filterFriendlyFire && props.avoidFriendlyFire)
- {
- var things = map.thingGrid.ThingsListAt(cell);
- if (things != null)
- {
- for (int i = 0; i < things.Count; i++)
- {
- if (things[i] is Pawn pawn && pawn.Faction == Faction.OfPlayer)
- {
- return false;
- }
- }
- }
- }
-
- return true;
- }
-
- private static int ScheduleBombardment(Map map, List targets, CompProperties_AbilityCircularBombardment props, bool spawnImmediately)
- {
- int now = Find.TickManager?.TicksGame ?? 0;
- int startTick = now + props.warmupTicks;
- int launchesCompleted = 0;
- int groupIndex = 0;
-
- var remainingTargets = new List(targets);
-
- MapComponent_SkyfallerDelayed delayed = null;
- if (!spawnImmediately)
- {
- delayed = map.GetComponent();
- if (delayed == null)
- {
- delayed = new MapComponent_SkyfallerDelayed(map);
- map.components.Add(delayed);
- }
- }
-
- while (remainingTargets.Count > 0 && launchesCompleted < props.maxLaunches)
- {
- int groupSize = Math.Min(props.simultaneousLaunches, remainingTargets.Count);
- var groupTargets = remainingTargets.Take(groupSize).ToList();
- remainingTargets.RemoveRange(0, groupSize);
-
- if (props.useIndependentIntervals)
- {
- for (int i = 0; i < groupTargets.Count && launchesCompleted < props.maxLaunches; i++)
- {
- int scheduledTick = startTick + groupIndex * props.launchIntervalTicks + i * props.innerLaunchIntervalTicks;
- SpawnOrSchedule(map, delayed, props.skyfallerDef, groupTargets[i], spawnImmediately, scheduledTick - now);
- launchesCompleted++;
- }
- groupIndex++;
- }
- else
- {
- int scheduledTick = startTick + groupIndex * props.launchIntervalTicks;
- for (int i = 0; i < groupTargets.Count && launchesCompleted < props.maxLaunches; i++)
- {
- SpawnOrSchedule(map, delayed, props.skyfallerDef, groupTargets[i], spawnImmediately, scheduledTick - now);
- launchesCompleted++;
- }
- groupIndex++;
- }
- }
-
- return launchesCompleted;
- }
-
- private string ExecuteEnergyLance(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityEnergyLance props, Dictionary parsed)
- {
- // Determine EnergyLanceDef
- ThingDef lanceDef = props.energyLanceDef ?? DefDatabase.GetNamedSilentFail("EnergyLance");
- if (lanceDef == null) return $"Error: Could not resolve EnergyLance ThingDef for '{def.defName}'.";
-
- // Determine Start and End positions
- // For AI usage, 'targetCell' is primarily the START position (focus point),
- // but we need a direction to make it move effectively.
-
- IntVec3 startPos = targetCell;
- IntVec3 endPos = targetCell; // Default if no direction
-
- // Determine direction/end position
- Vector3 direction = Vector3.forward;
- if (parsed.TryGetValue("angle", out var angleStr) && float.TryParse(angleStr, out float angle))
- {
- direction = Quaternion.AngleAxis(angle, Vector3.up) * Vector3.forward;
- endPos = (startPos.ToVector3() + direction * props.moveDistance).ToIntVec3();
- }
- else if (TryParseDirectionCell(parsed, out IntVec3 dirCell))
- {
- // If a specific cell is given for direction, that acts as the "target/end" point or direction vector
- direction = (dirCell.ToVector3() - startPos.ToVector3()).normalized;
- if (direction == Vector3.zero) direction = Vector3.forward;
-
- // If using fixed distance, calculate end based on direction and distance
- if (props.useFixedDistance)
- {
- endPos = (startPos.ToVector3() + direction * props.moveDistance).ToIntVec3();
- }
- else
- {
- // Otherwise, move TO the specific cell
- endPos = dirCell;
- }
- }
- else
- {
- // Default direction (North) if none specified, moving props.moveDistance
- endPos = (startPos.ToVector3() + Vector3.forward * props.moveDistance).ToIntVec3();
- }
-
- try
- {
- EnergyLance.MakeEnergyLance(
- lanceDef,
- startPos,
- endPos,
- map,
- props.moveDistance,
- props.useFixedDistance,
- props.durationTicks,
- null // No specific pawn instigator available for AI calls
- );
-
- return $"Success: Triggered Energy Lance '{def.defName}' from {startPos} towards {endPos}. Type: {lanceDef.defName}.";
- }
- catch (Exception ex)
- {
- return $"Error: Failed to spawn EnergyLance: {ex.Message}";
- }
- }
- private string ExecuteCircularBombardment(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityCircularBombardment props, Dictionary parsed)
- {
- if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef.";
-
- bool filter = true;
- if (parsed.TryGetValue("filterFriendlyFire", out var ffStr) && bool.TryParse(ffStr, out bool ff)) filter = ff;
-
- List selectedTargets = SelectTargetCells(map, targetCell, props, filter);
- if (selectedTargets.Count == 0) return $"Error: No valid target cells near {targetCell}.";
-
- bool isPaused = Find.TickManager != null && Find.TickManager.Paused;
- int totalLaunches = ScheduleBombardment(map, selectedTargets, props, spawnImmediately: isPaused);
-
- return $"Success: Scheduled Circular Bombardment '{def.defName}' at {targetCell}. Launches: {totalLaunches}/{props.maxLaunches}.";
- }
-
- private string ExecuteCallSkyfaller(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityCallSkyfaller props)
- {
- if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef.";
-
- var delayed = map.GetComponent();
- if (delayed == null)
- {
- delayed = new MapComponent_SkyfallerDelayed(map);
- map.components.Add(delayed);
- }
-
- // Using the delay from props
- int delay = props.delayTicks;
- if (delay <= 0)
- {
- Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(props.skyfallerDef);
- GenSpawn.Spawn(skyfaller, targetCell, map);
- return $"Success: Spawned Skyfaller '{def.defName}' immediately at {targetCell}.";
- }
- else
- {
- delayed.ScheduleSkyfaller(props.skyfallerDef, targetCell, delay);
- return $"Success: Scheduled Skyfaller '{def.defName}' at {targetCell} in {delay} ticks.";
- }
- }
-
- private string ExecuteStrafeBombardment(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityBombardment props, Dictionary parsed)
- {
- if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef.";
-
- // Determine direction
- // Use shared helper - though Strafe uses width/length, the direction logic is same.
- // Strafe doesn't really have a "moveDistance" in the same way, but it aligns along direction.
- // We use dummy distance for calculation.
- ParseDirectionInfo(parsed, targetCell, props.bombardmentLength, true, out Vector3 direction, out IntVec3 _);
-
- // Calculate target cells based on direction (Simulating CompAbilityEffect_Bombardment logic)
- // We use a simplified version here suitable for AI god-mode instant scheduling.
- // Note: Since we don't have a Comp instance attached to a Pawn, we rely on a static helper or just spawn them.
- // To make it look "progressive" like the real ability, we need a MapComponent or just reuse the SkyfallerDelayed logic.
-
- var targetCells = CalculateBombardmentAreaCells(map, targetCell, direction, props.bombardmentWidth, props.bombardmentLength);
-
- if (targetCells.Count == 0) return $"Error: No valid targets found for strafe at {targetCell}.";
-
- // Filter cells by selection chance
- var selectedCells = new List();
- var missedCells = new List();
- foreach (var cell in targetCells)
- {
- if (Rand.Value <= props.targetSelectionChance) selectedCells.Add(cell);
- else missedCells.Add(cell);
- }
-
- // Apply min/max constraints
- if (selectedCells.Count < props.minTargetCells && missedCells.Count > 0)
- {
- int needed = props.minTargetCells - selectedCells.Count;
- selectedCells.AddRange(missedCells.InRandomOrder().Take(Math.Min(needed, missedCells.Count)));
- }
- else if (selectedCells.Count > props.maxTargetCells)
- {
- selectedCells = selectedCells.InRandomOrder().Take(props.maxTargetCells).ToList();
- }
-
- if (selectedCells.Count == 0) return $"Error: No cells selected for strafe after chance filter.";
-
- // Organize into rows for progressive effect
- var rows = OrganizeIntoRows(targetCell, direction, selectedCells);
-
- // Schedule via MapComponent_SkyfallerDelayed
- var delayed = map.GetComponent();
- if (delayed == null)
- {
- delayed = new MapComponent_SkyfallerDelayed(map);
- map.components.Add(delayed);
- }
-
- int now = Find.TickManager.TicksGame;
- int startTick = now + props.warmupTicks;
- int totalScheduled = 0;
-
- foreach (var row in rows)
- {
- // Each row starts after rowDelayTicks
- int rowStartTick = startTick + (row.Key * props.rowDelayTicks);
-
- for (int i = 0; i < row.Value.Count; i++)
- {
- // Within a row, each cell is hit after impactDelayTicks
- int hitTick = rowStartTick + (i * props.impactDelayTicks);
- int delay = hitTick - now;
-
- if (delay <= 0)
- {
- Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(props.skyfallerDef);
- GenSpawn.Spawn(skyfaller, row.Value[i], map);
- }
- else
- {
- delayed.ScheduleSkyfaller(props.skyfallerDef, row.Value[i], delay);
- }
- totalScheduled++;
- }
- }
-
- return $"Success: Scheduled Strafe Bombardment '{def.defName}' at {targetCell}. Direction: {direction}. Targets: {totalScheduled}.";
- }
-
- private static bool TryParseDirectionCell(Dictionary parsed, out IntVec3 cell)
- {
- cell = IntVec3.Invalid;
- if (parsed.TryGetValue("dirX", out var xStr) && parsed.TryGetValue("dirZ", out var zStr) &&
- int.TryParse(xStr, out int x) && int.TryParse(zStr, out int z))
- {
- cell = new IntVec3(x, 0, z);
- return true;
- }
- // Optional: Support x,z
- if (parsed.TryGetValue("direction", out var dirStr) && !string.IsNullOrWhiteSpace(dirStr))
- {
- var parts = dirStr.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
- if (parts.Length >= 2 && int.TryParse(parts[0], out int dx) && int.TryParse(parts[1], out int dz))
- {
- cell = new IntVec3(dx, 0, dz);
- return true;
- }
- }
- return false;
- }
-
- // Logic adapted from CompAbilityEffect_Bombardment
- private List CalculateBombardmentAreaCells(Map map, IntVec3 startCell, Vector3 direction, int width, int length)
- {
- var areaCells = new List();
- Vector3 start = startCell.ToVector3();
- Vector3 perpendicularDirection = new Vector3(-direction.z, 0, direction.x).normalized;
-
- float halfWidth = width * 0.5f;
- float totalLength = length;
-
- int widthSteps = Math.Max(1, width);
- int lengthSteps = Math.Max(1, length);
-
- for (int l = 0; l <= lengthSteps; l++)
- {
- float lengthProgress = (float)l / lengthSteps;
- float lengthOffset = UnityEngine.Mathf.Lerp(0, totalLength, lengthProgress);
-
- for (int w = 0; w <= widthSteps; w++)
- {
- float widthProgress = (float)w / widthSteps;
- float widthOffset = UnityEngine.Mathf.Lerp(-halfWidth, halfWidth, widthProgress);
-
- Vector3 cellPos = start + direction * lengthOffset + perpendicularDirection * widthOffset;
- IntVec3 cell = new IntVec3(
- UnityEngine.Mathf.RoundToInt(cellPos.x),
- UnityEngine.Mathf.RoundToInt(cellPos.y),
- UnityEngine.Mathf.RoundToInt(cellPos.z)
- );
-
- if (cell.InBounds(map) && !areaCells.Contains(cell))
- {
- areaCells.Add(cell);
- }
- }
- }
- return areaCells;
- }
-
- private Dictionary> OrganizeIntoRows(IntVec3 startCell, Vector3 direction, List cells)
- {
- var rows = new Dictionary>();
- Vector3 perpendicularDirection = new Vector3(-direction.z, 0, direction.x).normalized;
-
- foreach (var cell in cells)
- {
- Vector3 cellVector = cell.ToVector3() - startCell.ToVector3();
- float dot = Vector3.Dot(cellVector, direction);
- int rowIndex = UnityEngine.Mathf.RoundToInt(dot);
-
- if (!rows.ContainsKey(rowIndex)) rows[rowIndex] = new List();
- rows[rowIndex].Add(cell);
- }
-
- // Sort rows by index (distance from start)
- var sortedRows = rows.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value);
-
- // Sort cells within rows by width position
- foreach (var key in sortedRows.Keys.ToList())
- {
- sortedRows[key] = sortedRows[key].OrderBy(c => Vector3.Dot((c.ToVector3() - startCell.ToVector3()), perpendicularDirection)).ToList();
- }
-
- return sortedRows;
- }
-
- private static void SpawnOrSchedule(Map map, MapComponent_SkyfallerDelayed delayed, ThingDef skyfallerDef, IntVec3 cell, bool spawnImmediately, int delayTicks)
- {
- if (!cell.IsValid || !cell.InBounds(map)) return;
-
- if (spawnImmediately || delayTicks <= 0)
- {
- Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(skyfallerDef);
- GenSpawn.Spawn(skyfaller, cell, map);
- return;
- }
-
- delayed?.ScheduleSkyfaller(skyfallerDef, cell, delayTicks);
- }
}
}
-
diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_SetOverwatchMode.cs b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_SetOverwatchMode.cs
new file mode 100644
index 00000000..ea2faeff
--- /dev/null
+++ b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_SetOverwatchMode.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using RimWorld;
+using UnityEngine;
+using Verse;
+
+namespace WulaFallenEmpire.EventSystem.AI.Tools
+{
+ public class Tool_SetOverwatchMode : AITool
+ {
+ public override string Name => "set_overwatch_mode";
+ public override string Description => "Enables or disables the AI Overwatch Combat Protocol. When enabled (enabled=true), the AI will autonomously scan for hostile targets every few seconds and launch appropriate orbital bombardments for a set duration. When disabled (enabled=false), it immediately stops any active overwatch and clears the flight path. Use enabled=false to stop overwatch early if the player requests it.";
+ public override string UsageSchema => "true/falseamount (only needed when enabling)";
+
+ public override string Execute(string args)
+ {
+ var parsed = ParseXmlArgs(args);
+
+ bool enabled = true;
+ if (parsed.TryGetValue("enabled", out var enabledStr) && bool.TryParse(enabledStr, out bool e))
+ {
+ enabled = e;
+ }
+
+ int duration = 60;
+ if (parsed.TryGetValue("durationSeconds", out var dStr) && int.TryParse(dStr, out int d))
+ {
+ duration = d;
+ }
+
+ Map map = Find.CurrentMap;
+ if (map == null) return "Error: No active map.";
+
+ var overwatch = map.GetComponent();
+ if (overwatch == null)
+ {
+ overwatch = new MapComponent_AIOverwatch(map);
+ map.components.Add(overwatch);
+ }
+
+ if (enabled)
+ {
+ overwatch.EnableOverwatch(duration);
+ return $"Success: AI Overwatch Protocol ENABLED for {duration} seconds. Hostiles will be engaged automatically.";
+ }
+ else
+ {
+ overwatch.DisableOverwatch();
+ return "Success: AI Overwatch Protocol DISABLED.";
+ }
+ }
+ }
+}