using System;
using System.Collections;
using System.Collections.Generic;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
using UnityEngine;
using UnityEngine.UI;
using ProvinceId = System.Int32;

namespace eagle {
    /// <summary>
    /// Plays one-shot animations at province centers when action results arrive.
    /// Dispatches to the appropriate animation coroutine based on action type.
    /// </summary>
    public class ProvinceActionAnimator : MonoBehaviour {
        [Header("References")]
        public ProvinceIDLoader provinceIDLoader;
        public RectTransform mapContainer;

        [Header("Sound")]
        public AudioSource audioSource;
        public ActionResultSound[] actionSounds;

        [Header("Tool Sprites")]
        public Sprite hammerSprite;
        public Sprite sparkSprite;

        [Header("Animation Settings")]
        public float toolScale = 26f;
        public int strikeCount = 3;
        public float strikeDuration = 0.2f;
        public float swingHeight = 39f;
        public float swingArc = 45f;
        public int sparkCount = 3;
        public float sparkSpread = 20f;
        public Color sparkColor = new Color(1f, 0.9f, 0.3f, 1f);
        public float hammerBaseRotation = 90f;
        public bool hammerFlipHorizontal = true;

        [Header("Feast Sprites")]
        public Sprite[] feastSprites;

        [Header("Feast Settings")]
        public int feastItemCount = 6;
        public float feastItemSize = 16f;
        public float feastTossHeight = 52f;
        public float feastSpread = 32f;
        public float feastDuration = 0.8f;

        [Header("Rest Settings")]
        public Sprite moonSprite;
        public Sprite zSprite;
        public int restZCount = 3;
        public float restDuration = 1.0f;
        public float restZSize = 13f;
        public float restMoonSize = 20f;
        public float restDriftHeight = 46f;

        [Header("Train Settings")]
        public Sprite swordSprite;
        public int trainStrikeCount = 2;
        public float trainStrikeDuration = 0.25f;
        public float trainSwingDistance = 32f;

        [Header("Organize Troops Settings")]
        public Sprite chevronSprite;
        public int organizeCount = 4;
        public float organizeDuration = 0.7f;
        public float organizeScatter = 39f;
        public float organizeIconSize = 10f;

        [Header("Arm Troops Settings")]
        public Sprite shieldSprite;
        public float armDuration = 0.5f;
        public float armIconSize = 20f;
        public float armSlamDistance = 26f;

        [Header("Recruit Heroes Settings")]
        public Sprite starSprite;
        public float recruitRiseHeight = 39f;
        public float recruitDuration = 0.8f;
        public float recruitStarSize = 16f;
        public int recruitBurstCount = 5;

        [Header("Trade Settings")]
        public Sprite coinSprite;
        public int tradeItemCount = 5;
        public float tradeItemSize = 10f;
        public float tradeTossHeight = 39f;
        public float tradeSpread = 26f;
        public float tradeDuration = 0.7f;

        [Header("Orders Issued Settings")]
        public Sprite scrollSprite;
        public Sprite sealSprite;
        public float ordersDuration = 0.6f;
        public float ordersScrollSize = 23f;
        public float ordersSealSize = 13f;

        [Header("Sent Supplies Settings")]
        public Sprite crateSprite;
        public float suppliesDuration = 1.5f;
        public float suppliesIconSize = 18f;
        public float suppliesLaunchHeight = 65f;

        [Header("Suppress Beasts Settings")]
        public Sprite pawPrintSprite;
        public float beastStrikeDuration = 0.3f;
        public float beastPawSize = 16f;

        [Header("Divined Settings")]
        public Sprite orbSprite;
        public int divinePulseCount = 3;
        public float divineDuration = 1.0f;
        public float divineOrbSize = 18f;
        public int divineSparkleCount = 6;

        [Header("March Settings")]
        public Sprite bannerSprite;
        public float marchDuration = 0.6f;
        public float marchBannerSize = 18f;
        public float marchSlideDistance = 52f;

        [Header("Swear Brotherhood Settings")]
        public Sprite fistSprite;
        public float brotherhoodDuration = 0.6f;
        public float brotherhoodIconSize = 16f;
        public float brotherhoodSlideDistance = 32f;

        [Header("Control Weather Settings")]
        public Sprite cloudSprite;
        public float weatherDuration = 0.8f;
        public int weatherCloudCount = 4;
        public float weatherCloudSize = 16f;
        public float weatherSpiralRadius = 32f;

        [Header("Alms Settings")]
        public Sprite giftSprite;
        public float almsDuration = 0.7f;
        public float almsIconSize = 18f;
        public float almsDriftHeight = 39f;

        [Header("Hero Gift Settings")]
        public Sprite heroSilhouetteSprite;
        public float heroGiftDuration = 0.6f;
        public float heroGiftIconSize = 18f;
        public float heroGiftSlideDistance = 46f;

        [Header("Apprehend Outlaw Settings")]
        public Sprite shackleSprite;
        public float apprehendDuration = 0.4f;
        public float apprehendIconSize = 23f;
        public float apprehendFinalSize = 10f;

        [Header("Recon Settings")]
        public Sprite spyglassSprite;
        public float reconDuration = 0.7f;
        public float reconIconSize = 18f;
        public float reconScanArc = 30f;

        [Header("Exile Vassal Settings")]
        public Sprite bootSprite;
        public float exileDuration = 0.4f;
        public float exileBootSize = 18f;
        public float exileKickDistance = 46f;

        [Header("Captured Hero Executed Settings")]
        public Sprite severedHeadSprite;
        public float executionDuration = 0.7f;
        public float executionHeadSize = 16f;
        public float executionDropHeight = 32f;
        public float executionRollDistance = 39f;

        [Header("Recruit Fizzle Settings")]
        public float recruitFizzleDuration = 0.8f;

        [Header("Start Epidemic Settings")]
        public Sprite skullSprite;
        public float epidemicDuration = 0.8f;
        public float epidemicSkullSize = 18f;
        public int epidemicMiasmaCount = 6;
        public Color epidemicColor = new Color(0.2f, 0.8f, 0.2f, 1f);

        private Dictionary<ProvinceId, Vector2> _provinceCentroids = new();
        private Dictionary<string, AudioClip> _loadedClips;
        private readonly Queue<ActionResultView> _pendingResults = new();
        private readonly HashSet<GameObject> _activeAnimationObjects = new();

        void Awake() { provinceIDLoader.OnMapLoaded += LoadCentroids; }

        void OnEnable() {
            if (_loadedClips == null) StartCoroutine(LoadSoundEffects());
            if (_pendingResults.Count > 0) StartCoroutine(DrainPendingResults());
        }

        void OnDisable() {
            // Coroutines are killed when the game object deactivates, so destroy
            // any animation objects that were mid-animation to prevent orphaned
            // partly-faded sprites lingering on the map.
            foreach (var go in _activeAnimationObjects) {
                if (go != null) Destroy(go);
            }
            _activeAnimationObjects.Clear();
        }

        private IEnumerator DrainPendingResults() {
            yield return new WaitForSeconds(1f);
            while (_pendingResults.Count > 0) HandleActionResult(_pendingResults.Dequeue());
        }

        private void LoadCentroids() {
            var data = JsonUtility.FromJson<CentroidsData>(provinceIDLoader.CentroidsJsonText);
            if (data?.provinces == null) return;

            foreach (var province in data.provinces) {
                _provinceCentroids[province.id] =
                        new Vector2(province.centroid_x, province.centroid_y);
            }
        }

        public void HandleActionResult(ActionResultView arv) {
            if (!gameObject.activeInHierarchy) {
                _pendingResults.Enqueue(arv);
                return;
            }
            if (arv.Province == null) return;
            var provinceId = arv.Province.Value;

            switch (arv.Type) {
                case ActionResultType.Improve:
                    AnimateImproveAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.Feast: AnimateFeastAtProvince(provinceId, arv.Type); break;
                case ActionResultType.Rest: AnimateRestAtProvince(provinceId, arv.Type); break;
                case ActionResultType.Train: AnimateTrainAtProvince(provinceId, arv.Type); break;
                case ActionResultType.OrganizeTroops:
                    AnimateOrganizeTroopsAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.ArmTroops:
                    AnimateArmTroopsAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.RecruitHeroes:
                    AnimateRecruitHeroesAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.Trade: AnimateTradeAtProvince(provinceId, arv.Type); break;
                case ActionResultType.OrdersIssued:
                    AnimateOrdersIssuedAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.SentSupplies:
                    AnimateSentSuppliesAtProvince(provinceId, arv);
                    break;
                case ActionResultType.SuppressedBeasts:
                case ActionResultType.SuppressBeastsFailed:
                    AnimateSuppressBeastsAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.Divined:
                    AnimateDivinedAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.MarchAction: AnimateMarchAtProvince(provinceId, arv); break;
                case ActionResultType.SwearBrotherhood:
                case ActionResultType.FailedSwearBrotherhood:
                    AnimateSwearBrotherhoodAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.WeatherAltered:
                    AnimateWeatherAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.GiftedProvince:
                    AnimateAlmsAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.GiftedHero:
                    AnimateHeroGiftAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.OutlawApprehended:
                    AnimateApprehendOutlawAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.ReconStarted:
                    AnimateReconAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.VassalExiled:
                    AnimateExileVassalAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.EpidemicStarted:
                    AnimateEpidemicAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.CapturedHeroRecruited:
                    AnimateRecruitHeroesAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.CapturedHeroRecruitmentRefused:
                    AnimateRecruitFizzleAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.CapturedHeroImprisoned:
                    AnimateApprehendOutlawAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.CapturedHeroExiled:
                    AnimateExileVassalAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.CapturedHeroExecuted:
                    AnimateCapturedHeroExecutedAtProvince(provinceId, arv.Type);
                    break;
                case ActionResultType.CapturedHeroReturned:
                    AnimateHeroGiftAtProvince(provinceId, arv.Type);
                    break;
            }
        }

        private IEnumerator LoadSoundEffects() {
            yield return AddressableLoader.LoadWithRetry<AudioClip>("sfx-combat", results => {
                _loadedClips = new Dictionary<string, AudioClip>();
                foreach (var clip in results) { _loadedClips[clip.name] = clip; }
            });
        }

        private void PlaySoundForType(ActionResultType type) {
            foreach (var entry in actionSounds) {
                if (entry.actionType != type) continue;
                audioSource.PlayOneShot(_loadedClips[entry.clipName]);
                return;
            }
        }

        private Vector2 ProvincePosition(Vector2 centroid) {
            var normalizedX = centroid.x / provinceIDLoader.mapWidth;
            var normalizedY = centroid.y / provinceIDLoader.mapHeight;

            var containerSize = mapContainer.rect.size;
            if (containerSize.x < 1f || containerSize.y < 1f) {
                containerSize = new Vector2(provinceIDLoader.mapWidth, provinceIDLoader.mapHeight);
            }

            return new Vector2(normalizedX * containerSize.x, normalizedY * containerSize.y);
        }

        private ProvinceId? GetTargetProvinceId(ActionResultView arv, ProvinceId originId) {
            if (arv.GameStateDiff == null) return null;
            foreach (var cp in arv.GameStateDiff.ChangedProvinces) {
                if (cp.Id != originId) return cp.Id;
            }
            return null;
        }

        private Vector2 DirectionToProvince(Vector2 originCentroid, ProvinceId? targetId) {
            if (targetId == null ||
                !_provinceCentroids.TryGetValue(targetId.Value, out Vector2 targetCentroid))
                return Vector2.right;
            Vector2 dir = ProvincePosition(targetCentroid) - ProvincePosition(originCentroid);
            return dir.normalized;
        }

        private GameObject CreateUIObject(string name) {
            var go = new GameObject(name);
            go.AddComponent<RectTransform>();
            _activeAnimationObjects.Add(go);
            return go;
        }

        private Image SetupImage(GameObject go, Sprite sprite, Color color) {
            var image = go.AddComponent<Image>();
            image.sprite = sprite;
            image.color = color;
            image.raycastTarget = false;
            return image;
        }

        private void SetupRectTransform(GameObject go, Vector2 position) {
            go.transform.SetParent(mapContainer, false);
            var rt = go.GetComponent<RectTransform>();
            rt.anchorMin = Vector2.zero;
            rt.anchorMax = Vector2.zero;
            rt.pivot = new Vector2(0.5f, 0.5f);
            rt.anchoredPosition = position;
        }

        private IEnumerator AnimateToolStrikes(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);

            // Create tool
            var tool = CreateUIObject("ToolStrike");
            SetupRectTransform(tool, worldPos);
            SetupImage(tool, hammerSprite, Color.white);

            var toolRt = tool.GetComponent<RectTransform>();
            toolRt.sizeDelta = new Vector2(toolScale, toolScale);
            if (hammerFlipHorizontal) { toolRt.localScale = new Vector3(-1f, 1f, 1f); }

            // Offset so the hammer head (bottom of rotated image) aligns with
            // the province center. With Image+RectTransform the pivot is at center,
            // so without offset the middle of the hammer sits on the province.
            Vector2 headOffset = new Vector2(0, toolScale * 0.5f);
            Vector2 strikePoint = worldPos + headOffset;
            Vector2 raisePoint = new Vector2(worldPos.x, worldPos.y + swingHeight) + headOffset;

            for (int i = 0; i < strikeCount; i++) {
                // Raise tool
                toolRt.anchoredPosition = raisePoint;
                toolRt.localRotation = Quaternion.Euler(0, 0, hammerBaseRotation - swingArc);

                float elapsed = 0f;
                float downDuration = strikeDuration * 0.4f;

                // Swing down
                while (elapsed < downDuration) {
                    float t = elapsed / downDuration;
                    float easeT = t * t;

                    toolRt.anchoredPosition = Vector2.Lerp(raisePoint, strikePoint, easeT);
                    float angle = Mathf.Lerp(-swingArc, 0f, easeT);
                    toolRt.localRotation = Quaternion.Euler(0, 0, hammerBaseRotation + angle);

                    elapsed += Time.deltaTime;
                    yield return null;
                }

                toolRt.anchoredPosition = strikePoint;
                toolRt.localRotation = Quaternion.Euler(0, 0, hammerBaseRotation);

                // Play impact sound
                PlaySoundForType(actionType);

                // Spawn sparks on impact
                yield return StartCoroutine(SpawnSparks(worldPos));

                // Brief pause at impact
                yield return new WaitForSeconds(strikeDuration * 0.1f);

                // Raise back up
                elapsed = 0f;
                float upDuration = strikeDuration * 0.5f;

                while (elapsed < upDuration) {
                    float t = elapsed / upDuration;
                    float easeT = t * (2f - t);

                    toolRt.anchoredPosition = Vector2.Lerp(strikePoint, raisePoint, easeT);
                    float angle = Mathf.Lerp(0f, -swingArc, easeT);
                    toolRt.localRotation = Quaternion.Euler(0, 0, hammerBaseRotation + angle);

                    elapsed += Time.deltaTime;
                    yield return null;
                }
            }

            if (tool != null) Destroy(tool);
        }

        private IEnumerator SpawnSparks(Vector2 target) {
            if (sparkSprite == null) yield break;

            var sparks = new GameObject[sparkCount];
            var directions = new Vector2[sparkCount];
            var speeds = new float[sparkCount];

            for (int i = 0; i < sparkCount; i++) {
                var spark = CreateUIObject($"Spark_{i}");
                SetupRectTransform(spark, target);
                SetupImage(spark, sparkSprite, sparkColor);

                var rt = spark.GetComponent<RectTransform>();
                rt.sizeDelta = new Vector2(5f, 5f);

                float angle = UnityEngine.Random.Range(0f, Mathf.PI * 2f);
                directions[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
                speeds[i] = sparkSpread * UnityEngine.Random.Range(0.7f, 1.3f);

                sparks[i] = spark;
            }

            float sparkDuration = 0.15f;
            float elapsed = 0f;

            while (elapsed < sparkDuration) {
                float t = elapsed / sparkDuration;

                for (int i = 0; i < sparks.Length; i++) {
                    if (sparks[i] == null) continue;

                    var rt = sparks[i].GetComponent<RectTransform>();
                    Vector2 pos = target + directions[i] * speeds[i] * t;
                    pos.y += t * 10f;
                    rt.anchoredPosition = pos;

                    var image = sparks[i].GetComponent<Image>();
                    if (image != null) {
                        Color c = sparkColor;
                        c.a = 1f - t;
                        image.color = c;
                        float scale = 1f - t * 0.5f;
                        rt.sizeDelta = new Vector2(5f * scale, 5f * scale);
                    }
                }

                elapsed += Time.deltaTime;
                yield return null;
            }

            foreach (var spark in sparks) {
                if (spark != null) Destroy(spark);
            }
        }

        private void AnimateImproveAtProvince(ProvinceId provinceId, ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null) {
                LoadCentroids();
            }
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid)) {
                StartCoroutine(AnimateToolStrikes(centroid, actionType));
            }
        }

        private void AnimateFeastAtProvince(ProvinceId provinceId, ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null) {
                LoadCentroids();
            }
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid)) {
                StartCoroutine(AnimateFeastToss(centroid, actionType));
            }
        }

        private IEnumerator AnimateFeastToss(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);

            PlaySoundForType(actionType);

            float halfDuration = feastDuration * 0.5f;
            float gravity = 2f * feastTossHeight / (halfDuration * halfDuration);
            float baseUpVelocity = gravity * halfDuration;

            var items = new GameObject[feastItemCount];
            var velocities = new Vector2[feastItemCount];
            var spinSpeeds = new float[feastItemCount];

            for (int i = 0; i < feastItemCount; i++) {
                var item = CreateUIObject($"FeastItem_{i}");
                SetupRectTransform(item, worldPos);

                var sprite = feastSprites[UnityEngine.Random.Range(0, feastSprites.Length)];
                SetupImage(item, sprite, Color.white);

                var rt = item.GetComponent<RectTransform>();
                rt.sizeDelta = new Vector2(feastItemSize, feastItemSize);

                float hVel = UnityEngine.Random.Range(-feastSpread, feastSpread);
                float vVel = UnityEngine.Random.Range(baseUpVelocity * 0.7f, baseUpVelocity * 1.3f);
                velocities[i] = new Vector2(hVel, vVel);
                spinSpeeds[i] = UnityEngine.Random.Range(-360f, 360f);

                items[i] = item;
            }
            float elapsed = 0f;

            while (elapsed < feastDuration) {
                float t = elapsed / feastDuration;

                for (int i = 0; i < items.Length; i++) {
                    if (items[i] == null) continue;

                    var rt = items[i].GetComponent<RectTransform>();
                    float x = worldPos.x + velocities[i].x * elapsed;
                    float y = worldPos.y + velocities[i].y * elapsed -
                              0.5f * gravity * elapsed * elapsed;
                    rt.anchoredPosition = new Vector2(x, y);
                    rt.localRotation = Quaternion.Euler(0, 0, spinSpeeds[i] * elapsed);

                    var image = items[i].GetComponent<Image>();
                    Color c = image.color;
                    c.a = 1f - t;
                    image.color = c;
                }

                elapsed += Time.deltaTime;
                yield return null;
            }

            foreach (var item in items) {
                if (item != null) Destroy(item);
            }
        }

        // ── Rest ──

        private void AnimateRestAtProvince(ProvinceId provinceId, ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateRest(centroid, actionType));
        }

        private IEnumerator AnimateRest(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var moon = CreateUIObject("RestMoon");
            SetupRectTransform(moon, worldPos);
            var moonImg = SetupImage(moon, moonSprite, Color.white);
            var moonRt = moon.GetComponent<RectTransform>();
            moonRt.sizeDelta = new Vector2(restMoonSize, restMoonSize);

            var zObjects = new GameObject[restZCount];
            float zSpawnInterval = restDuration * 0.25f / restZCount;
            float elapsed = 0f;
            int spawnedCount = 0;

            while (elapsed < restDuration) {
                float t = elapsed / restDuration;

                if (spawnedCount < restZCount && elapsed >= spawnedCount * zSpawnInterval) {
                    var z = CreateUIObject($"RestZ_{spawnedCount}");
                    float xOff = (spawnedCount - (restZCount - 1) * 0.5f) * restZSize * 0.7f;
                    SetupRectTransform(z, worldPos + new Vector2(xOff, restMoonSize * 0.5f));
                    SetupImage(z, zSprite, new Color(1f, 1f, 1f, 0f));
                    z.GetComponent<RectTransform>().sizeDelta = new Vector2(restZSize, restZSize);
                    zObjects[spawnedCount] = z;
                    spawnedCount++;
                }

                for (int i = 0; i < spawnedCount; i++) {
                    if (zObjects[i] == null) continue;
                    float zAge = elapsed - i * zSpawnInterval;
                    float zLife = restDuration - i * zSpawnInterval;
                    float zt = Mathf.Clamp01(zAge / zLife);

                    var rt = zObjects[i].GetComponent<RectTransform>();
                    float xOff = (i - (restZCount - 1) * 0.5f) * restZSize * 0.7f;
                    float yOff = restMoonSize * 0.5f + zt * restDriftHeight;
                    float xDrift = Mathf.Sin(zAge * 2f + i * 1.5f) * 5f;
                    rt.anchoredPosition = worldPos + new Vector2(xOff + xDrift, yOff);

                    var img = zObjects[i].GetComponent<Image>();
                    Color c = img.color;
                    c.a = zt < 0.1f ? zt / 0.1f : 1f - (zt - 0.1f) / 0.9f;
                    img.color = c;

                    float scale = 1f + zt * 0.3f;
                    rt.sizeDelta = new Vector2(restZSize * scale, restZSize * scale);
                }

                float moonFadeStart = 0.7f;
                if (t > moonFadeStart) {
                    Color mc = moonImg.color;
                    mc.a = 1f - (t - moonFadeStart) / (1f - moonFadeStart);
                    moonImg.color = mc;
                }

                elapsed += Time.deltaTime;
                yield return null;
            }

            foreach (var z in zObjects) {
                if (z != null) Destroy(z);
            }
            if (moon != null) Destroy(moon);
        }

        // ── Train ──

        private void AnimateTrainAtProvince(ProvinceId provinceId, ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateTrain(centroid, actionType));
        }

        private IEnumerator AnimateTrain(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);

            var leftSword = CreateUIObject("TrainSwordLeft");
            SetupRectTransform(leftSword, worldPos + new Vector2(-trainSwingDistance, 0));
            SetupImage(leftSword, swordSprite, Color.white);
            var leftRt = leftSword.GetComponent<RectTransform>();
            leftRt.sizeDelta = new Vector2(toolScale, toolScale);
            leftRt.localScale = new Vector3(-1f, -1f, 1f);

            var rightSword = CreateUIObject("TrainSwordRight");
            SetupRectTransform(rightSword, worldPos + new Vector2(trainSwingDistance, 0));
            SetupImage(rightSword, swordSprite, Color.white);
            var rightRt = rightSword.GetComponent<RectTransform>();
            rightRt.sizeDelta = new Vector2(toolScale, toolScale);
            rightRt.localScale = new Vector3(1f, -1f, 1f);

            Vector2 leftStart = worldPos + new Vector2(-trainSwingDistance, 0);
            Vector2 rightStart = worldPos + new Vector2(trainSwingDistance, 0);

            for (int i = 0; i < trainStrikeCount; i++) {
                leftRt.anchoredPosition = leftStart;
                rightRt.anchoredPosition = rightStart;

                float elapsed = 0f;
                float swingIn = trainStrikeDuration * 0.4f;

                while (elapsed < swingIn) {
                    float t = elapsed / swingIn;
                    float easeT = t * t;
                    leftRt.anchoredPosition = Vector2.Lerp(leftStart, worldPos, easeT);
                    rightRt.anchoredPosition = Vector2.Lerp(rightStart, worldPos, easeT);
                    elapsed += Time.deltaTime;
                    yield return null;
                }

                leftRt.anchoredPosition = worldPos;
                rightRt.anchoredPosition = worldPos;

                PlaySoundForType(actionType);
                yield return StartCoroutine(SpawnSparks(worldPos));
                yield return new WaitForSeconds(trainStrikeDuration * 0.1f);

                elapsed = 0f;
                float swingOut = trainStrikeDuration * 0.5f;

                while (elapsed < swingOut) {
                    float t = elapsed / swingOut;
                    float easeT = t * (2f - t);
                    leftRt.anchoredPosition = Vector2.Lerp(worldPos, leftStart, easeT);
                    rightRt.anchoredPosition = Vector2.Lerp(worldPos, rightStart, easeT);
                    elapsed += Time.deltaTime;
                    yield return null;
                }
            }

            if (leftSword != null) Destroy(leftSword);
            if (rightSword != null) Destroy(rightSword);
        }

        // ── Organize Troops ──

        private void AnimateOrganizeTroopsAtProvince(
                ProvinceId provinceId,
                ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateOrganizeTroops(centroid, actionType));
        }

        private IEnumerator AnimateOrganizeTroops(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var icons = new GameObject[organizeCount];
            var scatterTargets = new Vector2[organizeCount];
            var gridTargets = new Vector2[organizeCount];

            int cols = Mathf.CeilToInt(Mathf.Sqrt(organizeCount));
            for (int i = 0; i < organizeCount; i++) {
                icons[i] = CreateUIObject($"Chevron_{i}");
                SetupRectTransform(icons[i], worldPos);
                SetupImage(icons[i], chevronSprite, Color.white);
                icons[i].GetComponent<RectTransform>().sizeDelta =
                        new Vector2(organizeIconSize, organizeIconSize);

                float angle = UnityEngine.Random.Range(0f, Mathf.PI * 2f);
                float dist = UnityEngine.Random.Range(organizeScatter * 0.5f, organizeScatter);
                scatterTargets[i] =
                        worldPos + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * dist;

                int row = i / cols;
                int col = i % cols;
                float spacing = organizeIconSize * 1.2f;
                float gridW = (cols - 1) * spacing;
                float gridH = ((organizeCount - 1) / cols) * spacing;
                gridTargets[i] =
                        worldPos +
                        new Vector2(col * spacing - gridW * 0.5f, row * spacing - gridH * 0.5f);
            }

            // Scatter outward
            float scatterTime = organizeDuration * 0.35f;
            float elapsed = 0f;
            while (elapsed < scatterTime) {
                float t = elapsed / scatterTime;
                for (int i = 0; i < organizeCount; i++) {
                    icons[i].GetComponent<RectTransform>().anchoredPosition =
                            Vector2.Lerp(worldPos, scatterTargets[i], t * (2f - t));
                }
                elapsed += Time.deltaTime;
                yield return null;
            }

            // Pause
            yield return new WaitForSeconds(organizeDuration * 0.15f);

            // Snap back to grid
            float snapTime = organizeDuration * 0.3f;
            elapsed = 0f;
            while (elapsed < snapTime) {
                float t = elapsed / snapTime;
                float easeT = t * t * t;
                for (int i = 0; i < organizeCount; i++) {
                    icons[i].GetComponent<RectTransform>().anchoredPosition =
                            Vector2.Lerp(scatterTargets[i], gridTargets[i], easeT);
                }
                elapsed += Time.deltaTime;
                yield return null;
            }

            // Flash
            yield return StartCoroutine(SpawnSparks(worldPos));

            // Brief hold then fade
            float fadeTime = organizeDuration * 0.2f;
            elapsed = 0f;
            while (elapsed < fadeTime) {
                float t = elapsed / fadeTime;
                for (int i = 0; i < organizeCount; i++) {
                    var img = icons[i].GetComponent<Image>();
                    Color c = img.color;
                    c.a = 1f - t;
                    img.color = c;
                }
                elapsed += Time.deltaTime;
                yield return null;
            }

            foreach (var icon in icons) {
                if (icon != null) Destroy(icon);
            }
        }

        // ── Arm Troops ──

        private void AnimateArmTroopsAtProvince(
                ProvinceId provinceId,
                ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateArmTroops(centroid, actionType));
        }

        private IEnumerator AnimateArmTroops(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);

            var shield = CreateUIObject("ArmShield");
            SetupRectTransform(shield, worldPos + new Vector2(-armSlamDistance, 0));
            SetupImage(shield, shieldSprite, Color.white);
            shield.GetComponent<RectTransform>().sizeDelta = new Vector2(armIconSize, armIconSize);

            var sword = CreateUIObject("ArmSword");
            SetupRectTransform(sword, worldPos + new Vector2(armSlamDistance, 0));
            SetupImage(sword, swordSprite, Color.white);
            sword.GetComponent<RectTransform>().sizeDelta = new Vector2(armIconSize, armIconSize);

            Vector2 shieldStart = worldPos + new Vector2(-armSlamDistance, 0);
            Vector2 swordStart = worldPos + new Vector2(armSlamDistance, 0);

            // Slam together
            float slamTime = armDuration * 0.4f;
            float elapsed = 0f;
            while (elapsed < slamTime) {
                float t = elapsed / slamTime;
                float easeT = t * t;
                shield.GetComponent<RectTransform>().anchoredPosition =
                        Vector2.Lerp(shieldStart, worldPos, easeT);
                sword.GetComponent<RectTransform>().anchoredPosition =
                        Vector2.Lerp(swordStart, worldPos, easeT);
                elapsed += Time.deltaTime;
                yield return null;
            }

            PlaySoundForType(actionType);
            yield return StartCoroutine(SpawnSparks(worldPos));

            // Fade out
            float fadeTime = armDuration * 0.5f;
            elapsed = 0f;
            while (elapsed < fadeTime) {
                float t = elapsed / fadeTime;
                var shieldImg = shield.GetComponent<Image>();
                var swordImg = sword.GetComponent<Image>();
                Color c = shieldImg.color;
                c.a = 1f - t;
                shieldImg.color = c;
                swordImg.color = c;
                elapsed += Time.deltaTime;
                yield return null;
            }

            if (shield != null) Destroy(shield);
            if (sword != null) Destroy(sword);
        }

        // ── Recruit Heroes ──

        private void AnimateRecruitHeroesAtProvince(
                ProvinceId provinceId,
                ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateRecruitHeroes(centroid, actionType));
        }

        private IEnumerator AnimateRecruitHeroes(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var star = CreateUIObject("RecruitStar");
            SetupRectTransform(star, worldPos);
            var starImg = SetupImage(star, starSprite, Color.white);
            var starRt = star.GetComponent<RectTransform>();
            starRt.sizeDelta = new Vector2(recruitStarSize, recruitStarSize);

            // Rise phase
            float riseTime = recruitDuration * 0.5f;
            float elapsed = 0f;
            Vector2 riseEnd = worldPos + new Vector2(0, recruitRiseHeight);
            while (elapsed < riseTime) {
                float t = elapsed / riseTime;
                starRt.anchoredPosition = Vector2.Lerp(worldPos, riseEnd, t);
                float scale = 1f + t * 0.5f;
                starRt.sizeDelta = new Vector2(recruitStarSize * scale, recruitStarSize * scale);
                Color c = starImg.color;
                c.a = 0.5f + t * 0.5f;
                starImg.color = c;
                elapsed += Time.deltaTime;
                yield return null;
            }

            // Burst into smaller sparkles
            if (star != null) Destroy(star);

            var sparkles = new GameObject[recruitBurstCount];
            var dirs = new Vector2[recruitBurstCount];
            for (int i = 0; i < recruitBurstCount; i++) {
                sparkles[i] = CreateUIObject($"RecruitSparkle_{i}");
                SetupRectTransform(sparkles[i], riseEnd);
                SetupImage(sparkles[i], starSprite, sparkColor);
                sparkles[i].GetComponent<RectTransform>().sizeDelta =
                        new Vector2(recruitStarSize * 0.5f, recruitStarSize * 0.5f);
                float angle = (i / (float)recruitBurstCount) * Mathf.PI * 2f;
                dirs[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
            }

            float burstTime = recruitDuration * 0.5f;
            elapsed = 0f;
            while (elapsed < burstTime) {
                float t = elapsed / burstTime;
                for (int i = 0; i < recruitBurstCount; i++) {
                    if (sparkles[i] == null) continue;
                    var rt = sparkles[i].GetComponent<RectTransform>();
                    rt.anchoredPosition = riseEnd + dirs[i] * sparkSpread * t * 2f;
                    var img = sparkles[i].GetComponent<Image>();
                    Color c = img.color;
                    c.a = 1f - t;
                    img.color = c;
                    float s = (1f - t) * recruitStarSize * 0.5f;
                    rt.sizeDelta = new Vector2(s, s);
                }
                elapsed += Time.deltaTime;
                yield return null;
            }

            foreach (var s in sparkles) {
                if (s != null) Destroy(s);
            }
        }

        // ── Trade ──

        private void AnimateTradeAtProvince(ProvinceId provinceId, ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateTrade(centroid, actionType));
        }

        private IEnumerator AnimateTrade(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            float halfDuration = tradeDuration * 0.5f;
            float gravity = 2f * tradeTossHeight / (halfDuration * halfDuration);
            float baseUpVelocity = gravity * halfDuration;

            var items = new GameObject[tradeItemCount];
            var velocities = new Vector2[tradeItemCount];
            var spinSpeeds = new float[tradeItemCount];

            for (int i = 0; i < tradeItemCount; i++) {
                items[i] = CreateUIObject($"TradeCoin_{i}");
                SetupRectTransform(items[i], worldPos);
                SetupImage(items[i], coinSprite, Color.white);
                items[i].GetComponent<RectTransform>().sizeDelta =
                        new Vector2(tradeItemSize, tradeItemSize);

                float hVel = UnityEngine.Random.Range(-tradeSpread, tradeSpread);
                float vVel = UnityEngine.Random.Range(baseUpVelocity * 0.7f, baseUpVelocity * 1.3f);
                velocities[i] = new Vector2(hVel, vVel);
                spinSpeeds[i] = UnityEngine.Random.Range(-360f, 360f);
            }

            float elapsed = 0f;
            while (elapsed < tradeDuration) {
                float t = elapsed / tradeDuration;
                for (int i = 0; i < items.Length; i++) {
                    if (items[i] == null) continue;
                    var rt = items[i].GetComponent<RectTransform>();
                    float x = worldPos.x + velocities[i].x * elapsed;
                    float y = worldPos.y + velocities[i].y * elapsed -
                              0.5f * gravity * elapsed * elapsed;
                    rt.anchoredPosition = new Vector2(x, y);
                    rt.localRotation = Quaternion.Euler(0, 0, spinSpeeds[i] * elapsed);

                    var image = items[i].GetComponent<Image>();
                    Color c = image.color;
                    c.a = 1f - t;
                    image.color = c;
                }
                elapsed += Time.deltaTime;
                yield return null;
            }

            foreach (var item in items) {
                if (item != null) Destroy(item);
            }
        }

        // ── Orders Issued ──

        private void AnimateOrdersIssuedAtProvince(
                ProvinceId provinceId,
                ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateOrdersIssued(centroid, actionType));
        }

        private IEnumerator AnimateOrdersIssued(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var scroll = CreateUIObject("OrdersScroll");
            SetupRectTransform(scroll, worldPos);
            var scrollImg = SetupImage(scroll, scrollSprite, Color.white);
            var scrollRt = scroll.GetComponent<RectTransform>();
            scrollRt.sizeDelta = Vector2.zero;

            // Unfurl scroll (scale from 0 to full)
            float unfurlTime = ordersDuration * 0.4f;
            float elapsed = 0f;
            while (elapsed < unfurlTime) {
                float t = elapsed / unfurlTime;
                float s = t * ordersScrollSize;
                scrollRt.sizeDelta = new Vector2(s, s);
                elapsed += Time.deltaTime;
                yield return null;
            }
            scrollRt.sizeDelta = new Vector2(ordersScrollSize, ordersScrollSize);

            // Seal stamps down
            var seal = CreateUIObject("OrdersSeal");
            Vector2 sealPos = worldPos + new Vector2(0, ordersScrollSize * 0.2f);
            SetupRectTransform(seal, sealPos + new Vector2(0, ordersScrollSize));
            var sealImg = SetupImage(seal, sealSprite, Color.white);
            var sealRt = seal.GetComponent<RectTransform>();
            sealRt.sizeDelta = new Vector2(ordersSealSize, ordersSealSize);

            float stampTime = ordersDuration * 0.2f;
            elapsed = 0f;
            Vector2 sealStart = sealPos + new Vector2(0, ordersScrollSize);
            while (elapsed < stampTime) {
                float t = elapsed / stampTime;
                sealRt.anchoredPosition = Vector2.Lerp(sealStart, sealPos, t * t);
                elapsed += Time.deltaTime;
                yield return null;
            }
            sealRt.anchoredPosition = sealPos;
            yield return StartCoroutine(SpawnSparks(sealPos));

            // Fade out
            float fadeTime = ordersDuration * 0.3f;
            elapsed = 0f;
            while (elapsed < fadeTime) {
                float t = elapsed / fadeTime;
                Color c = scrollImg.color;
                c.a = 1f - t;
                scrollImg.color = c;
                sealImg.color = c;
                elapsed += Time.deltaTime;
                yield return null;
            }

            if (scroll != null) Destroy(scroll);
            if (seal != null) Destroy(seal);
        }

        // ── Sent Supplies ──

        private void AnimateSentSuppliesAtProvince(ProvinceId provinceId, ActionResultView arv) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid)) {
                Vector2 direction =
                        DirectionToProvince(centroid, GetTargetProvinceId(arv, provinceId));
                StartCoroutine(AnimateSentSupplies(centroid, arv.Type, direction));
            }
        }

        private IEnumerator
        AnimateSentSupplies(Vector2 centroid, ActionResultType actionType, Vector2 direction) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var crate = CreateUIObject("SuppliesCrate");
            SetupRectTransform(crate, worldPos);
            var crateImg = SetupImage(crate, crateSprite, Color.white);
            var crateRt = crate.GetComponent<RectTransform>();
            crateRt.sizeDelta = new Vector2(suppliesIconSize, suppliesIconSize);

            float elapsed = 0f;
            while (elapsed < suppliesDuration) {
                float t = elapsed / suppliesDuration;
                crateRt.anchoredPosition = worldPos + direction * suppliesLaunchHeight * t;

                float scale = 1f - t * 0.6f;
                crateRt.sizeDelta = new Vector2(suppliesIconSize * scale, suppliesIconSize * scale);

                Color c = crateImg.color;
                c.a = 1f - t;
                crateImg.color = c;

                elapsed += Time.deltaTime;
                yield return null;
            }

            if (crate != null) Destroy(crate);
        }

        // ── Suppress Beasts ──

        private void AnimateSuppressBeastsAtProvince(
                ProvinceId provinceId,
                ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateSuppressBeasts(centroid, actionType));
        }

        private IEnumerator AnimateSuppressBeasts(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            bool success = actionType == ActionResultType.SuppressedBeasts;

            // Create paw print at center
            var paw = CreateUIObject("BeastPaw");
            SetupRectTransform(paw, worldPos);
            var pawImg = SetupImage(paw, pawPrintSprite, Color.white);
            var pawRt = paw.GetComponent<RectTransform>();
            pawRt.sizeDelta = new Vector2(beastPawSize, beastPawSize);

            // Sword swings down from above
            var sword = CreateUIObject("BeastSword");
            Vector2 swordStart = worldPos + new Vector2(0, swingHeight);
            SetupRectTransform(sword, swordStart);
            SetupImage(sword, swordSprite, Color.white);
            var swordRt = sword.GetComponent<RectTransform>();
            swordRt.sizeDelta = new Vector2(toolScale, toolScale);

            float swingTime = beastStrikeDuration * 0.5f;
            float elapsed = 0f;
            while (elapsed < swingTime) {
                float t = elapsed / swingTime;
                swordRt.anchoredPosition = Vector2.Lerp(swordStart, worldPos, t * t);
                elapsed += Time.deltaTime;
                yield return null;
            }
            swordRt.anchoredPosition = worldPos;

            PlaySoundForType(actionType);

            if (success) {
                // Paw shatters with sparks
                if (paw != null) Destroy(paw);
                yield return StartCoroutine(SpawnSparks(worldPos));

                // Fade sword
                elapsed = 0f;
                float fadeTime = beastStrikeDuration * 0.5f;
                var swordImg = sword.GetComponent<Image>();
                while (elapsed < fadeTime) {
                    float t = elapsed / fadeTime;
                    Color c = swordImg.color;
                    c.a = 1f - t;
                    swordImg.color = c;
                    elapsed += Time.deltaTime;
                    yield return null;
                }
            } else {
                // Sword bounces back
                elapsed = 0f;
                float bounceTime = beastStrikeDuration * 0.3f;
                while (elapsed < bounceTime) {
                    float t = elapsed / bounceTime;
                    swordRt.anchoredPosition = Vector2.Lerp(worldPos, swordStart, t * (2f - t));
                    elapsed += Time.deltaTime;
                    yield return null;
                }

                // Paw pulses red then fades
                elapsed = 0f;
                float pulseTime = beastStrikeDuration * 0.5f;
                while (elapsed < pulseTime) {
                    float t = elapsed / pulseTime;
                    float pulse = Mathf.Sin(t * Mathf.PI * 2f) * 0.5f + 0.5f;
                    pawImg.color = Color.Lerp(Color.white, Color.red, pulse);
                    pawImg.color = new Color(
                            pawImg.color.r,
                            pawImg.color.g,
                            pawImg.color.b,
                            1f - t * 0.7f);
                    elapsed += Time.deltaTime;
                    yield return null;
                }

                if (paw != null) Destroy(paw);
            }

            if (sword != null) Destroy(sword);
        }

        // ── Divined ──

        private void AnimateDivinedAtProvince(ProvinceId provinceId, ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateDivined(centroid, actionType));
        }

        private IEnumerator AnimateDivined(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var orb = CreateUIObject("DivineOrb");
            SetupRectTransform(orb, worldPos);
            var orbImg = SetupImage(orb, orbSprite, new Color(0.6f, 0.4f, 0.9f, 1f));
            var orbRt = orb.GetComponent<RectTransform>();
            orbRt.sizeDelta = new Vector2(divineOrbSize, divineOrbSize);

            // Pulse phase
            float pulseTime = divineDuration * 0.6f;
            float elapsed = 0f;
            while (elapsed < pulseTime) {
                float t = elapsed / pulseTime;
                float pulse = Mathf.Sin(t * divinePulseCount * Mathf.PI * 2f);
                float scale = 1f + pulse * 0.3f;
                orbRt.sizeDelta = new Vector2(divineOrbSize * scale, divineOrbSize * scale);
                Color c = orbImg.color;
                c.a = 0.7f + pulse * 0.3f;
                orbImg.color = c;
                elapsed += Time.deltaTime;
                yield return null;
            }

            // Emit sparkles
            var sparkles = new GameObject[divineSparkleCount];
            var dirs = new Vector2[divineSparkleCount];
            for (int i = 0; i < divineSparkleCount; i++) {
                sparkles[i] = CreateUIObject($"DivineSparkle_{i}");
                SetupRectTransform(sparkles[i], worldPos);
                SetupImage(sparkles[i], sparkSprite, new Color(0.8f, 0.6f, 1f, 1f));
                sparkles[i].GetComponent<RectTransform>().sizeDelta = new Vector2(4f, 4f);
                float angle = (i / (float)divineSparkleCount) * Mathf.PI * 2f;
                dirs[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
            }

            // Sparkles drift outward while orb fades
            float fadeTime = divineDuration * 0.4f;
            elapsed = 0f;
            while (elapsed < fadeTime) {
                float t = elapsed / fadeTime;
                Color oc = orbImg.color;
                oc.a = 1f - t;
                orbImg.color = oc;

                for (int i = 0; i < divineSparkleCount; i++) {
                    if (sparkles[i] == null) continue;
                    var rt = sparkles[i].GetComponent<RectTransform>();
                    rt.anchoredPosition = worldPos + dirs[i] * sparkSpread * t * 2f;
                    var img = sparkles[i].GetComponent<Image>();
                    Color c = img.color;
                    c.a = 1f - t;
                    img.color = c;
                }
                elapsed += Time.deltaTime;
                yield return null;
            }

            if (orb != null) Destroy(orb);
            foreach (var s in sparkles) {
                if (s != null) Destroy(s);
            }
        }

        // ── March ──

        private void AnimateMarchAtProvince(ProvinceId provinceId, ActionResultView arv) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid)) {
                Vector2 direction =
                        DirectionToProvince(centroid, GetTargetProvinceId(arv, provinceId));
                StartCoroutine(AnimateMarch(centroid, arv.Type, direction));
            }
        }

        private IEnumerator
        AnimateMarch(Vector2 centroid, ActionResultType actionType, Vector2 direction) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var banner = CreateUIObject("MarchBanner");
            SetupRectTransform(banner, worldPos);
            var bannerImg = SetupImage(banner, bannerSprite, Color.white);
            var bannerRt = banner.GetComponent<RectTransform>();
            bannerRt.sizeDelta = new Vector2(marchBannerSize, marchBannerSize);

            // Wave phase (rotation oscillation)
            float waveTime = marchDuration * 0.4f;
            float elapsed = 0f;
            while (elapsed < waveTime) {
                float t = elapsed / waveTime;
                float angle = Mathf.Sin(t * Mathf.PI * 4f) * 15f;
                bannerRt.localRotation = Quaternion.Euler(0, 0, angle);
                elapsed += Time.deltaTime;
                yield return null;
            }

            // Slide toward destination and fade
            float slideTime = marchDuration * 0.6f;
            elapsed = 0f;
            Vector2 slideTarget = worldPos + direction * marchSlideDistance;
            while (elapsed < slideTime) {
                float t = elapsed / slideTime;
                bannerRt.anchoredPosition = Vector2.Lerp(worldPos, slideTarget, t);
                bannerRt.localRotation = Quaternion.Euler(0, 0, 0);
                Color c = bannerImg.color;
                c.a = 1f - t;
                bannerImg.color = c;
                elapsed += Time.deltaTime;
                yield return null;
            }

            if (banner != null) Destroy(banner);
        }

        // ── Swear Brotherhood ──

        private void AnimateSwearBrotherhoodAtProvince(
                ProvinceId provinceId,
                ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateSwearBrotherhood(centroid, actionType));
        }

        private IEnumerator AnimateSwearBrotherhood(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            bool success = actionType == ActionResultType.SwearBrotherhood;

            var leftFist = CreateUIObject("BrotherhoodFistL");
            SetupRectTransform(leftFist, worldPos + new Vector2(-brotherhoodSlideDistance, 0));
            SetupImage(leftFist, fistSprite, Color.white);
            var leftRt = leftFist.GetComponent<RectTransform>();
            leftRt.sizeDelta = new Vector2(brotherhoodIconSize, brotherhoodIconSize);
            leftRt.localScale = new Vector3(-1f, 1f, 1f);

            var rightFist = CreateUIObject("BrotherhoodFistR");
            SetupRectTransform(rightFist, worldPos + new Vector2(brotherhoodSlideDistance, 0));
            SetupImage(rightFist, fistSprite, Color.white);
            var rightRt = rightFist.GetComponent<RectTransform>();
            rightRt.sizeDelta = new Vector2(brotherhoodIconSize, brotherhoodIconSize);

            Vector2 leftStart = worldPos + new Vector2(-brotherhoodSlideDistance, 0);
            Vector2 rightStart = worldPos + new Vector2(brotherhoodSlideDistance, 0);

            // Slide together
            float slideTime = brotherhoodDuration * 0.4f;
            float elapsed = 0f;
            while (elapsed < slideTime) {
                float t = elapsed / slideTime;
                float easeT = t * t;
                leftRt.anchoredPosition = Vector2.Lerp(leftStart, worldPos, easeT);
                rightRt.anchoredPosition = Vector2.Lerp(rightStart, worldPos, easeT);
                elapsed += Time.deltaTime;
                yield return null;
            }

            PlaySoundForType(actionType);

            if (success) {
                // Golden burst
                yield return StartCoroutine(SpawnSparks(worldPos));

                // Fade out together
                float fadeTime = brotherhoodDuration * 0.4f;
                elapsed = 0f;
                while (elapsed < fadeTime) {
                    float t = elapsed / fadeTime;
                    Color c = new Color(1f, 1f, 1f, 1f - t);
                    leftFist.GetComponent<Image>().color = c;
                    rightFist.GetComponent<Image>().color = c;
                    elapsed += Time.deltaTime;
                    yield return null;
                }
            } else {
                // Dark flash and repel
                var leftImg = leftFist.GetComponent<Image>();
                var rightImg = rightFist.GetComponent<Image>();
                leftImg.color = new Color(0.5f, 0.2f, 0.2f, 1f);
                rightImg.color = new Color(0.5f, 0.2f, 0.2f, 1f);
                yield return new WaitForSeconds(0.05f);
                leftImg.color = Color.white;
                rightImg.color = Color.white;

                // Slide apart
                float repelTime = brotherhoodDuration * 0.4f;
                elapsed = 0f;
                while (elapsed < repelTime) {
                    float t = elapsed / repelTime;
                    leftRt.anchoredPosition = Vector2.Lerp(worldPos, leftStart, t * (2f - t));
                    rightRt.anchoredPosition = Vector2.Lerp(worldPos, rightStart, t * (2f - t));
                    Color c = new Color(1f, 1f, 1f, 1f - t);
                    leftImg.color = c;
                    rightImg.color = c;
                    elapsed += Time.deltaTime;
                    yield return null;
                }
            }

            if (leftFist != null) Destroy(leftFist);
            if (rightFist != null) Destroy(rightFist);
        }

        // ── Control Weather ──

        private void AnimateWeatherAtProvince(ProvinceId provinceId, ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateWeather(centroid, actionType));
        }

        private IEnumerator AnimateWeather(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var clouds = new GameObject[weatherCloudCount];
            var baseAngles = new float[weatherCloudCount];
            for (int i = 0; i < weatherCloudCount; i++) {
                clouds[i] = CreateUIObject($"WeatherCloud_{i}");
                SetupRectTransform(clouds[i], worldPos);
                SetupImage(clouds[i], cloudSprite, new Color(0.4f, 0.4f, 0.5f, 1f));
                clouds[i].GetComponent<RectTransform>().sizeDelta =
                        new Vector2(weatherCloudSize, weatherCloudSize);
                baseAngles[i] = (i / (float)weatherCloudCount) * Mathf.PI * 2f;
            }

            // Spiral inward
            float spiralTime = weatherDuration * 0.5f;
            float elapsed = 0f;
            while (elapsed < spiralTime) {
                float t = elapsed / spiralTime;
                float radius = weatherSpiralRadius * (1f - t * 0.7f);
                float spin = t * Mathf.PI * 4f;
                for (int i = 0; i < weatherCloudCount; i++) {
                    float angle = baseAngles[i] + spin;
                    var rt = clouds[i].GetComponent<RectTransform>();
                    rt.anchoredPosition =
                            worldPos +
                            new Vector2(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius);
                }
                elapsed += Time.deltaTime;
                yield return null;
            }

            // Disperse outward
            float disperseTime = weatherDuration * 0.5f;
            elapsed = 0f;
            while (elapsed < disperseTime) {
                float t = elapsed / disperseTime;
                float radius = weatherSpiralRadius * 0.3f + weatherSpiralRadius * 2f * t;
                for (int i = 0; i < weatherCloudCount; i++) {
                    float angle = baseAngles[i] + Mathf.PI * 4f;
                    var rt = clouds[i].GetComponent<RectTransform>();
                    rt.anchoredPosition =
                            worldPos +
                            new Vector2(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius);
                    var img = clouds[i].GetComponent<Image>();
                    Color c = img.color;
                    c.a = 1f - t;
                    img.color = c;
                }
                elapsed += Time.deltaTime;
                yield return null;
            }

            foreach (var cloud in clouds) {
                if (cloud != null) Destroy(cloud);
            }
        }

        // ── Alms (GiftedProvince) ──

        private void AnimateAlmsAtProvince(ProvinceId provinceId, ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateAlms(centroid, actionType));
        }

        private IEnumerator AnimateAlms(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var gift = CreateUIObject("AlmsGift");
            SetupRectTransform(gift, worldPos);
            var giftImg = SetupImage(gift, giftSprite, new Color(1f, 0.9f, 0.5f, 1f));
            var giftRt = gift.GetComponent<RectTransform>();
            giftRt.sizeDelta = new Vector2(almsIconSize, almsIconSize);

            // Rise with pulse
            float riseTime = almsDuration * 0.5f;
            float elapsed = 0f;
            Vector2 riseEnd = worldPos + new Vector2(0, almsDriftHeight * 0.4f);
            while (elapsed < riseTime) {
                float t = elapsed / riseTime;
                giftRt.anchoredPosition = Vector2.Lerp(worldPos, riseEnd, t);
                float pulse = 1f + Mathf.Sin(t * Mathf.PI * 2f) * 0.15f;
                giftRt.sizeDelta = new Vector2(almsIconSize * pulse, almsIconSize * pulse);
                elapsed += Time.deltaTime;
                yield return null;
            }

            // Drift upward and fade
            float driftTime = almsDuration * 0.5f;
            elapsed = 0f;
            Vector2 driftEnd = riseEnd + new Vector2(0, almsDriftHeight * 0.6f);
            while (elapsed < driftTime) {
                float t = elapsed / driftTime;
                giftRt.anchoredPosition = Vector2.Lerp(riseEnd, driftEnd, t);
                Color c = giftImg.color;
                c.a = 1f - t;
                giftImg.color = c;
                elapsed += Time.deltaTime;
                yield return null;
            }

            if (gift != null) Destroy(gift);
        }

        // ── Hero Gift (GiftedHero) ──

        private void AnimateHeroGiftAtProvince(ProvinceId provinceId, ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateHeroGift(centroid, actionType));
        }

        private IEnumerator AnimateHeroGift(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var hero = CreateUIObject("HeroGiftIcon");
            SetupRectTransform(hero, worldPos);
            var heroImg = SetupImage(hero, heroSilhouetteSprite, Color.white);
            var heroRt = hero.GetComponent<RectTransform>();
            heroRt.sizeDelta = new Vector2(heroGiftIconSize, heroGiftIconSize);

            Vector2 slideTarget = worldPos + new Vector2(heroGiftSlideDistance, 0);

            float elapsed = 0f;
            float lastSparkle = 0f;
            float sparkleInterval = heroGiftDuration / 5f;

            while (elapsed < heroGiftDuration) {
                float t = elapsed / heroGiftDuration;
                heroRt.anchoredPosition = Vector2.Lerp(worldPos, slideTarget, t);
                Color c = heroImg.color;
                c.a = 1f - t;
                heroImg.color = c;

                if (elapsed - lastSparkle >= sparkleInterval && sparkSprite != null) {
                    lastSparkle = elapsed;
                    var sparkle = CreateUIObject("HeroTrail");
                    SetupRectTransform(sparkle, heroRt.anchoredPosition);
                    SetupImage(sparkle, sparkSprite, sparkColor);
                    sparkle.GetComponent<RectTransform>().sizeDelta = new Vector2(4f, 4f);
                    StartCoroutine(FadeAndDestroy(sparkle, 0.3f));
                }

                elapsed += Time.deltaTime;
                yield return null;
            }

            if (hero != null) Destroy(hero);
        }

        private IEnumerator FadeAndDestroy(GameObject go, float duration) {
            var img = go.GetComponent<Image>();
            float elapsed = 0f;
            while (elapsed < duration) {
                float t = elapsed / duration;
                if (img != null) {
                    Color c = img.color;
                    c.a = 1f - t;
                    img.color = c;
                }
                elapsed += Time.deltaTime;
                yield return null;
            }
            if (go != null) Destroy(go);
        }

        // ── Apprehend Outlaw ──

        private void AnimateApprehendOutlawAtProvince(
                ProvinceId provinceId,
                ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateApprehendOutlaw(centroid, actionType));
        }

        private IEnumerator AnimateApprehendOutlaw(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var shackle = CreateUIObject("ApprehendShackle");
            SetupRectTransform(shackle, worldPos);
            var shackleImg = SetupImage(shackle, shackleSprite, Color.white);
            var shackleRt = shackle.GetComponent<RectTransform>();
            shackleRt.sizeDelta = new Vector2(apprehendIconSize, apprehendIconSize);

            // Contract from large to small
            float snapTime = apprehendDuration * 0.6f;
            float elapsed = 0f;
            while (elapsed < snapTime) {
                float t = elapsed / snapTime;
                float easeT = t * t * t;
                float size = Mathf.Lerp(apprehendIconSize, apprehendFinalSize, easeT);
                shackleRt.sizeDelta = new Vector2(size, size);
                elapsed += Time.deltaTime;
                yield return null;
            }
            shackleRt.sizeDelta = new Vector2(apprehendFinalSize, apprehendFinalSize);

            // Flash on snap
            yield return StartCoroutine(SpawnSparks(worldPos));

            // Fade out
            float fadeTime = apprehendDuration * 0.4f;
            elapsed = 0f;
            while (elapsed < fadeTime) {
                float t = elapsed / fadeTime;
                Color c = shackleImg.color;
                c.a = 1f - t;
                shackleImg.color = c;
                elapsed += Time.deltaTime;
                yield return null;
            }

            if (shackle != null) Destroy(shackle);
        }

        // ── Recon ──

        private void AnimateReconAtProvince(ProvinceId provinceId, ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateRecon(centroid, actionType));
        }

        private IEnumerator AnimateRecon(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var spyglass = CreateUIObject("ReconSpyglass");
            SetupRectTransform(spyglass, worldPos);
            var spyglassImg = SetupImage(spyglass, spyglassSprite, Color.white);
            var spyglassRt = spyglass.GetComponent<RectTransform>();
            spyglassRt.sizeDelta = new Vector2(reconIconSize, reconIconSize);

            // Scan phase (slow rotation)
            float scanTime = reconDuration * 0.6f;
            float elapsed = 0f;
            while (elapsed < scanTime) {
                float t = elapsed / scanTime;
                float angle = Mathf.Sin(t * Mathf.PI * 2f) * reconScanArc;
                spyglassRt.localRotation = Quaternion.Euler(0, 0, angle);

                // Lens flare sparkle at midpoint
                if (t > 0.45f && t < 0.55f) {
                    float flare = 1f - Mathf.Abs(t - 0.5f) / 0.05f;
                    spyglassImg.color = Color.Lerp(Color.white, new Color(1f, 1f, 0.8f, 1f), flare);
                }

                elapsed += Time.deltaTime;
                yield return null;
            }

            // Fade out
            float fadeTime = reconDuration * 0.4f;
            elapsed = 0f;
            while (elapsed < fadeTime) {
                float t = elapsed / fadeTime;
                Color c = spyglassImg.color;
                c.a = 1f - t;
                spyglassImg.color = c;
                spyglassRt.localRotation = Quaternion.Euler(0, 0, 0);
                elapsed += Time.deltaTime;
                yield return null;
            }

            if (spyglass != null) Destroy(spyglass);
        }

        // ── Exile Vassal ──

        private void AnimateExileVassalAtProvince(
                ProvinceId provinceId,
                ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateExileVassal(centroid, actionType));
        }

        private IEnumerator AnimateExileVassal(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var boot = CreateUIObject("ExileBoot");
            SetupRectTransform(boot, worldPos);
            var bootImg = SetupImage(boot, bootSprite, Color.white);
            var bootRt = boot.GetComponent<RectTransform>();
            bootRt.sizeDelta = new Vector2(exileBootSize, exileBootSize);

            // Dust puff at launch
            if (sparkSprite != null) {
                for (int i = 0; i < 3; i++) {
                    var dust = CreateUIObject($"ExileDust_{i}");
                    SetupRectTransform(dust, worldPos);
                    SetupImage(dust, sparkSprite, new Color(0.7f, 0.6f, 0.5f, 0.8f));
                    dust.GetComponent<RectTransform>().sizeDelta = new Vector2(4f, 4f);
                    StartCoroutine(FadeAndDestroy(dust, 0.3f));
                }
            }

            // Kick arc to one side
            float elapsed = 0f;
            Vector2 kickTarget = worldPos + new Vector2(exileKickDistance, 0);
            while (elapsed < exileDuration) {
                float t = elapsed / exileDuration;
                float x = Mathf.Lerp(worldPos.x, kickTarget.x, t);
                float y = worldPos.y + Mathf.Sin(t * Mathf.PI) * exileKickDistance * 0.4f;
                bootRt.anchoredPosition = new Vector2(x, y);
                bootRt.localRotation = Quaternion.Euler(0, 0, -t * 180f);

                Color c = bootImg.color;
                c.a = 1f - t * 0.8f;
                bootImg.color = c;

                elapsed += Time.deltaTime;
                yield return null;
            }

            if (boot != null) Destroy(boot);
        }

        // ── Start Epidemic ──

        private void AnimateEpidemicAtProvince(ProvinceId provinceId, ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateEpidemic(centroid, actionType));
        }

        private IEnumerator AnimateEpidemic(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var skull = CreateUIObject("EpidemicSkull");
            SetupRectTransform(skull, worldPos);
            var skullImg = SetupImage(skull, skullSprite, new Color(1f, 1f, 1f, 0f));
            var skullRt = skull.GetComponent<RectTransform>();
            skullRt.sizeDelta = new Vector2(epidemicSkullSize, epidemicSkullSize);

            // Spawn miasma particles
            var miasma = new GameObject[epidemicMiasmaCount];
            var miasmaDirections = new Vector2[epidemicMiasmaCount];
            for (int i = 0; i < epidemicMiasmaCount; i++) {
                miasma[i] = CreateUIObject($"Miasma_{i}");
                SetupRectTransform(miasma[i], worldPos);
                SetupImage(
                        miasma[i],
                        sparkSprite,
                        new Color(epidemicColor.r, epidemicColor.g, epidemicColor.b, 0f));
                miasma[i].GetComponent<RectTransform>().sizeDelta = new Vector2(5f, 5f);
                float angle = (i / (float)epidemicMiasmaCount) * Mathf.PI * 2f;
                miasmaDirections[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
            }

            float elapsed = 0f;
            while (elapsed < epidemicDuration) {
                float t = elapsed / epidemicDuration;

                // Skull fades in, pulses green, then fades out
                float skullAlpha;
                if (t < 0.2f) skullAlpha = t / 0.2f;
                else if (t < 0.7f)
                    skullAlpha = 1f;
                else
                    skullAlpha = 1f - (t - 0.7f) / 0.3f;

                float pulse = Mathf.Sin(t * Mathf.PI * 4f) * 0.3f + 0.7f;
                skullImg.color = new Color(
                        Mathf.Lerp(1f, epidemicColor.r, pulse * 0.5f),
                        Mathf.Lerp(1f, epidemicColor.g, pulse * 0.5f),
                        Mathf.Lerp(1f, epidemicColor.b, pulse * 0.5f),
                        skullAlpha);

                // Miasma drifts outward
                for (int i = 0; i < epidemicMiasmaCount; i++) {
                    if (miasma[i] == null) continue;
                    var rt = miasma[i].GetComponent<RectTransform>();
                    float drift = t * sparkSpread * 2f;
                    rt.anchoredPosition = worldPos + miasmaDirections[i] * drift;

                    float miasmaAlpha;
                    if (t < 0.15f) miasmaAlpha = t / 0.15f;
                    else
                        miasmaAlpha = 1f - (t - 0.15f) / 0.85f;

                    var img = miasma[i].GetComponent<Image>();
                    Color c = img.color;
                    c.a = miasmaAlpha * 0.7f;
                    img.color = c;
                }

                elapsed += Time.deltaTime;
                yield return null;
            }

            if (skull != null) Destroy(skull);
            foreach (var m in miasma) {
                if (m != null) Destroy(m);
            }
        }

        // ── Recruit Fizzle (CapturedHeroRecruitmentRefused) ──

        private void AnimateRecruitFizzleAtProvince(
                ProvinceId provinceId,
                ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateRecruitFizzle(centroid, actionType));
        }

        private IEnumerator AnimateRecruitFizzle(Vector2 centroid, ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var star = CreateUIObject("RecruitFizzleStar");
            SetupRectTransform(star, worldPos);
            var starImg = SetupImage(star, starSprite, Color.white);
            var starRt = star.GetComponent<RectTransform>();
            starRt.sizeDelta = new Vector2(recruitStarSize, recruitStarSize);

            // Rise phase (same as successful recruit)
            float riseTime = recruitFizzleDuration * 0.4f;
            float elapsed = 0f;
            Vector2 riseEnd = worldPos + new Vector2(0, recruitRiseHeight);
            while (elapsed < riseTime) {
                float t = elapsed / riseTime;
                starRt.anchoredPosition = Vector2.Lerp(worldPos, riseEnd, t);
                float scale = 1f + t * 0.5f;
                starRt.sizeDelta = new Vector2(recruitStarSize * scale, recruitStarSize * scale);
                Color c = starImg.color;
                c.a = 0.5f + t * 0.5f;
                starImg.color = c;
                elapsed += Time.deltaTime;
                yield return null;
            }

            // Fizzle phase: shrink, turn red, fade out
            float fizzleTime = recruitFizzleDuration * 0.6f;
            elapsed = 0f;
            float peakSize = recruitStarSize * 1.5f;
            while (elapsed < fizzleTime) {
                float t = elapsed / fizzleTime;
                float shrink = Mathf.Lerp(peakSize, 0f, t);
                starRt.sizeDelta = new Vector2(shrink, shrink);
                starImg.color =
                        new Color(1f, Mathf.Lerp(1f, 0.2f, t), Mathf.Lerp(1f, 0.2f, t), 1f - t);
                starRt.anchoredPosition = riseEnd + new Vector2(0, -t * 5f);
                elapsed += Time.deltaTime;
                yield return null;
            }

            if (star != null) Destroy(star);
        }

        // ── Captured Hero Executed ──

        private void AnimateCapturedHeroExecutedAtProvince(
                ProvinceId provinceId,
                ActionResultType actionType) {
            if (_provinceCentroids.Count == 0 && provinceIDLoader.CentroidsJsonText != null)
                LoadCentroids();
            if (_provinceCentroids.TryGetValue(provinceId, out Vector2 centroid))
                StartCoroutine(AnimateCapturedHeroExecuted(centroid, actionType));
        }

        private IEnumerator AnimateCapturedHeroExecuted(
                Vector2 centroid,
                ActionResultType actionType) {
            Vector2 worldPos = ProvincePosition(centroid);
            PlaySoundForType(actionType);

            var head = CreateUIObject("SeveredHead");
            Vector2 dropStart = worldPos + new Vector2(0, executionDropHeight);
            SetupRectTransform(head, dropStart);
            var headImg = SetupImage(head, severedHeadSprite, Color.white);
            var headRt = head.GetComponent<RectTransform>();
            headRt.sizeDelta = new Vector2(executionHeadSize, executionHeadSize);

            // Drop down
            float dropTime = executionDuration * 0.3f;
            float elapsed = 0f;
            while (elapsed < dropTime) {
                float t = elapsed / dropTime;
                headRt.anchoredPosition = Vector2.Lerp(dropStart, worldPos, t * t);
                elapsed += Time.deltaTime;
                yield return null;
            }
            headRt.anchoredPosition = worldPos;

            // Brief blood-red flash
            headImg.color = new Color(0.8f, 0.1f, 0.1f, 1f);
            yield return new WaitForSeconds(0.05f);
            headImg.color = Color.white;

            // Roll to one side with blood trail
            float rollTime = executionDuration * 0.7f;
            elapsed = 0f;
            Vector2 rollTarget = worldPos + new Vector2(executionRollDistance, 0);
            float lastTrail = 0f;
            float trailInterval = rollTime / 8f;

            while (elapsed < rollTime) {
                float t = elapsed / rollTime;
                float x = Mathf.Lerp(worldPos.x, rollTarget.x, t);
                float bounce = Mathf.Abs(Mathf.Sin(t * Mathf.PI * 2f)) * 5f * (1f - t);
                headRt.anchoredPosition = new Vector2(x, worldPos.y + bounce);
                headRt.localRotation = Quaternion.Euler(0, 0, -t * 360f);

                Color c = headImg.color;
                c.a = 1f - t * 0.8f;
                headImg.color = c;

                if (elapsed - lastTrail >= trailInterval && sparkSprite != null) {
                    lastTrail = elapsed;
                    var trail = CreateUIObject("BloodTrail");
                    SetupRectTransform(trail, headRt.anchoredPosition);
                    SetupImage(trail, sparkSprite, new Color(0.6f, 0.05f, 0.05f, 0.7f));
                    trail.GetComponent<RectTransform>().sizeDelta = new Vector2(3f, 3f);
                    StartCoroutine(FadeAndDestroy(trail, 0.4f));
                }

                elapsed += Time.deltaTime;
                yield return null;
            }

            if (head != null) Destroy(head);
        }

        // ── Test Methods ──

        [ContextMenu("Test Improve in Shumal (ID 1)")]
        public void TestImprove() { AnimateImproveAtProvince(1, ActionResultType.Improve); }

        [ContextMenu("Test Feast in Shumal (ID 1)")]
        public void TestFeast() { AnimateFeastAtProvince(1, ActionResultType.Feast); }

        [ContextMenu("Test Rest in Shumal (ID 1)")]
        public void TestRest() { AnimateRestAtProvince(1, ActionResultType.Rest); }

        [ContextMenu("Test Train in Shumal (ID 1)")]
        public void TestTrain() { AnimateTrainAtProvince(1, ActionResultType.Train); }

        [ContextMenu("Test Organize Troops in Shumal (ID 1)")]
        public void TestOrganizeTroops() {
            AnimateOrganizeTroopsAtProvince(1, ActionResultType.OrganizeTroops);
        }

        [ContextMenu("Test Arm Troops in Shumal (ID 1)")]
        public void TestArmTroops() { AnimateArmTroopsAtProvince(1, ActionResultType.ArmTroops); }

        [ContextMenu("Test Recruit Heroes in Shumal (ID 1)")]
        public void TestRecruitHeroes() {
            AnimateRecruitHeroesAtProvince(1, ActionResultType.RecruitHeroes);
        }

        [ContextMenu("Test Trade in Shumal (ID 1)")]
        public void TestTrade() { AnimateTradeAtProvince(1, ActionResultType.Trade); }

        [ContextMenu("Test Orders Issued in Shumal (ID 1)")]
        public void TestOrdersIssued() {
            AnimateOrdersIssuedAtProvince(1, ActionResultType.OrdersIssued);
        }

        [ContextMenu("Test Sent Supplies in Shumal (ID 1)")]
        public void TestSentSupplies() {
            AnimateSentSuppliesAtProvince(
                    1,
                    new ActionResultView { Type = ActionResultType.SentSupplies });
        }

        [ContextMenu("Test Suppress Beasts (Success) in Shumal (ID 1)")]
        public void TestSuppressBeastsSuccess() {
            AnimateSuppressBeastsAtProvince(1, ActionResultType.SuppressedBeasts);
        }

        [ContextMenu("Test Suppress Beasts (Failed) in Shumal (ID 1)")]
        public void TestSuppressBeastsFailed() {
            AnimateSuppressBeastsAtProvince(1, ActionResultType.SuppressBeastsFailed);
        }

        [ContextMenu("Test Divined in Shumal (ID 1)")]
        public void TestDivined() { AnimateDivinedAtProvince(1, ActionResultType.Divined); }

        [ContextMenu("Test March in Shumal (ID 1)")]
        public void TestMarch() {
            AnimateMarchAtProvince(1, new ActionResultView { Type = ActionResultType.MarchAction });
        }

        [ContextMenu("Test Swear Brotherhood (Success) in Shumal (ID 1)")]
        public void TestSwearBrotherhoodSuccess() {
            AnimateSwearBrotherhoodAtProvince(1, ActionResultType.SwearBrotherhood);
        }

        [ContextMenu("Test Swear Brotherhood (Failed) in Shumal (ID 1)")]
        public void TestSwearBrotherhoodFailed() {
            AnimateSwearBrotherhoodAtProvince(1, ActionResultType.FailedSwearBrotherhood);
        }

        [ContextMenu("Test Weather in Shumal (ID 1)")]
        public void TestWeather() { AnimateWeatherAtProvince(1, ActionResultType.WeatherAltered); }

        [ContextMenu("Test Alms in Shumal (ID 1)")]
        public void TestAlms() { AnimateAlmsAtProvince(1, ActionResultType.GiftedProvince); }

        [ContextMenu("Test Hero Gift in Shumal (ID 1)")]
        public void TestHeroGift() { AnimateHeroGiftAtProvince(1, ActionResultType.GiftedHero); }

        [ContextMenu("Test Apprehend Outlaw in Shumal (ID 1)")]
        public void TestApprehendOutlaw() {
            AnimateApprehendOutlawAtProvince(1, ActionResultType.OutlawApprehended);
        }

        [ContextMenu("Test Recon in Shumal (ID 1)")]
        public void TestRecon() { AnimateReconAtProvince(1, ActionResultType.ReconStarted); }

        [ContextMenu("Test Exile Vassal in Shumal (ID 1)")]
        public void TestExileVassal() {
            AnimateExileVassalAtProvince(1, ActionResultType.VassalExiled);
        }

        [ContextMenu("Test Epidemic in Shumal (ID 1)")]
        public void TestEpidemic() {
            AnimateEpidemicAtProvince(1, ActionResultType.EpidemicStarted);
        }

        [ContextMenu("Test Captured Hero Recruited in Shumal (ID 1)")]
        public void TestCapturedHeroRecruited() {
            AnimateRecruitHeroesAtProvince(1, ActionResultType.CapturedHeroRecruited);
        }

        [ContextMenu("Test Captured Hero Recruitment Refused in Shumal (ID 1)")]
        public void TestCapturedHeroRecruitmentRefused() {
            AnimateRecruitFizzleAtProvince(1, ActionResultType.CapturedHeroRecruitmentRefused);
        }

        [ContextMenu("Test Captured Hero Imprisoned in Shumal (ID 1)")]
        public void TestCapturedHeroImprisoned() {
            AnimateApprehendOutlawAtProvince(1, ActionResultType.CapturedHeroImprisoned);
        }

        [ContextMenu("Test Captured Hero Exiled in Shumal (ID 1)")]
        public void TestCapturedHeroExiled() {
            AnimateExileVassalAtProvince(1, ActionResultType.CapturedHeroExiled);
        }

        [ContextMenu("Test Captured Hero Executed in Shumal (ID 1)")]
        public void TestCapturedHeroExecuted() {
            AnimateCapturedHeroExecutedAtProvince(1, ActionResultType.CapturedHeroExecuted);
        }

        [ContextMenu("Test Captured Hero Returned in Shumal (ID 1)")]
        public void TestCapturedHeroReturned() {
            AnimateHeroGiftAtProvince(1, ActionResultType.CapturedHeroReturned);
        }

        void OnDestroy() {
            if (provinceIDLoader != null) { provinceIDLoader.OnMapLoaded -= LoadCentroids; }
        }

        [Serializable]
        private class CentroidsData {
            public ProvinceCentroid[] provinces;
        }

        [Serializable]
        private class ProvinceCentroid {
            public int id;
            public string name;
            public float centroid_x;
            public float centroid_y;
        }
    }

    [Serializable]
    public class ActionResultSound {
        public ActionResultType actionType;
        [Tooltip("Addressable clip name from sfx-combat (e.g. \"repair\")")]
        public string clipName;
    }
}
