From 9bd1c084c7340292735adfc78059c40e80bbf778 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Tue, 14 Jul 2026 13:20:49 -0500 Subject: [PATCH] Good Start --- .gitignore | 3 + IdleSpectator/CameraDirector.cs | 41 ++ IdleSpectator/InterestCollector.cs | 111 ++++++ IdleSpectator/InterestDirector.cs | 211 +++++++++++ IdleSpectator/InterestEvent.cs | 19 + IdleSpectator/InterestTier.cs | 18 + IdleSpectator/Main.cs | 24 +- IdleSpectator/SpectatorMode.cs | 94 +++++ IdleSpectator/SubspeciesPatches.cs | 28 ++ IdleSpectator/WorldActivityScanner.cs | 493 +++++++++++++++++++++++++ IdleSpectator/WorldLogInterestTable.cs | 131 +++++++ IdleSpectator/WorldLogPatches.cs | 12 + IdleSpectator/mod.json | 4 +- README.md | 37 +- 14 files changed, 1201 insertions(+), 25 deletions(-) create mode 100644 IdleSpectator/CameraDirector.cs create mode 100644 IdleSpectator/InterestCollector.cs create mode 100644 IdleSpectator/InterestDirector.cs create mode 100644 IdleSpectator/InterestEvent.cs create mode 100644 IdleSpectator/InterestTier.cs create mode 100644 IdleSpectator/SpectatorMode.cs create mode 100644 IdleSpectator/SubspeciesPatches.cs create mode 100644 IdleSpectator/WorldActivityScanner.cs create mode 100644 IdleSpectator/WorldLogInterestTable.cs create mode 100644 IdleSpectator/WorldLogPatches.cs diff --git a/.gitignore b/.gitignore index b185a43..1dfd80c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Cursor / local agent config (machine-specific; do not publish) .cursor/ +# Local one-shot spectator enable flag +IdleSpectator/.force-on + # Build / IDE bin/ obj/ diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs new file mode 100644 index 0000000..a7a77da --- /dev/null +++ b/IdleSpectator/CameraDirector.cs @@ -0,0 +1,41 @@ +using NeoModLoader.services; +using UnityEngine; + +namespace IdleSpectator; + +public static class CameraDirector +{ + public static void Watch(InterestEvent interest) + { + if (interest == null || !interest.HasValidPosition) + { + return; + } + + if (!Config.game_loaded || World.world == null) + { + return; + } + + string tip = string.IsNullOrEmpty(interest.Label) + ? "Watching event" + : "Watching: " + interest.Label; + WorldTip.showNowTop(tip, pTranslate: false); + LogService.LogInfo($"[IdleSpectator] {tip}"); + + if (interest.HasFollowUnit) + { + World.world.locateAndFollow(interest.FollowUnit, null, null); + return; + } + + World.world.locatePosition(interest.Position); + } + + public static void ClearFollow() + { + // PowerButtonSelector.instance is internal to the game assembly. + // Clearing the focus unit is enough to leave native spectator follow. + MoveCamera.clearFocusUnitOnly(); + } +} diff --git a/IdleSpectator/InterestCollector.cs b/IdleSpectator/InterestCollector.cs new file mode 100644 index 0000000..b0a74eb --- /dev/null +++ b/IdleSpectator/InterestCollector.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace IdleSpectator; + +public static class InterestCollector +{ + private static readonly object Gate = new object(); + private static readonly List Pending = new List(); + + public static void OnWorldLogMessage(WorldLogMessage message) + { + if (message == null) + { + return; + } + + if (!WorldLogInterestTable.TryGetTier(message.asset_id, out InterestTier tier)) + { + return; + } + + Vector3 position = message.getLocation(); + Actor unit = null; + if (message.hasFollowLocation()) + { + unit = message.unit; + } + + if (unit == null && position == Vector3.zero) + { + return; + } + + EnqueueDirect(new InterestEvent + { + Tier = tier, + Score = (float)tier * 100f + Time.unscaledTime * 0.001f, + Position = position, + FollowUnit = unit, + Label = WorldLogInterestTable.MakeLabel(message), + CreatedAt = Time.unscaledTime, + AssetId = message.asset_id + }); + } + + public static void EnqueueDirect(InterestEvent interest) + { + if (interest == null || !interest.HasValidPosition) + { + return; + } + + lock (Gate) + { + Pending.Add(interest); + if (Pending.Count > 64) + { + Pending.RemoveRange(0, Pending.Count - 64); + } + } + } + + public static bool TryGetBest(out InterestEvent best) + { + lock (Gate) + { + best = null; + if (Pending.Count == 0) + { + return false; + } + + int bestIndex = 0; + for (int i = 1; i < Pending.Count; i++) + { + InterestEvent candidate = Pending[i]; + InterestEvent current = Pending[bestIndex]; + if (candidate.Tier > current.Tier + || (candidate.Tier == current.Tier && candidate.CreatedAt > current.CreatedAt)) + { + bestIndex = i; + } + } + + best = Pending[bestIndex]; + return true; + } + } + + public static void Remove(InterestEvent interest) + { + if (interest == null) + { + return; + } + + lock (Gate) + { + Pending.Remove(interest); + } + } + + public static void Clear() + { + lock (Gate) + { + Pending.Clear(); + } + } +} diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs new file mode 100644 index 0000000..b79680e --- /dev/null +++ b/IdleSpectator/InterestDirector.cs @@ -0,0 +1,211 @@ +using NeoModLoader.services; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Picks the next interesting target using dwell, cooldown, and tier interrupts. +/// +public static class InterestDirector +{ + public const float MinDwellSeconds = 10f; + public const float CuriosityDwellSeconds = 6f; + public const float SwitchCooldownSeconds = 5f; + public const float HighTierInterruptAfterSeconds = 3f; + public const float QuietWorldAmbientAfterSeconds = 8f; + public const float AmbientRotateSeconds = 20f; + public const float ActionPollSeconds = 0.75f; + + private static InterestEvent _current; + private static float _currentStartedAt = -999f; + private static float _lastSwitchAt = -999f; + private static float _lastInterestingAt = -999f; + private static float _lastAmbientAt = -999f; + private static float _lastActionPollAt = -999f; + private static float _enabledAt = -999f; + private static bool _prevCameraDrag; + private const float DragExitGraceSeconds = 2.5f; + + public static void OnSpectatorEnabled() + { + InterestCollector.Clear(); + _current = null; + float now = Time.unscaledTime; + _currentStartedAt = now; + _lastSwitchAt = now; + _lastInterestingAt = now; + _lastAmbientAt = -999f; + _lastActionPollAt = -999f; + _enabledAt = now; + _prevCameraDrag = MoveCamera.camera_drag_run; + TryAmbient(force: true); + } + + public static void OnSpectatorDisabled() + { + _current = null; + InterestCollector.Clear(); + } + + public static void Update() + { + if (!SpectatorMode.Active || !Config.game_loaded || World.world == null) + { + return; + } + + bool dragging = MoveCamera.camera_drag_run; + bool pastGrace = Time.unscaledTime - _enabledAt >= DragExitGraceSeconds; + if (pastGrace && dragging && !_prevCameraDrag) + { + _prevCameraDrag = dragging; + SpectatorMode.SetActive(false); + WorldTip.showNowTop("Idle Spectator paused (camera drag)", pTranslate: false); + return; + } + _prevCameraDrag = dragging; + + float now = Time.unscaledTime; + float onCurrent = now - _currentStartedAt; + float sinceSwitch = now - _lastSwitchAt; + + // Periodically surface live battles into the queue (Action tier). + if (now - _lastActionPollAt >= ActionPollSeconds) + { + _lastActionPollAt = now; + InterestEvent battle = WorldActivityScanner.FindHottestBattle(); + if (battle != null) + { + InterestCollector.EnqueueDirect(battle); + } + } + + if (InterestCollector.TryGetBest(out InterestEvent candidate)) + { + if (candidate.HasValidPosition && CanSwitchTo(candidate, onCurrent, sinceSwitch)) + { + InterestCollector.Remove(candidate); + if (candidate.Tier >= InterestTier.Action) + { + _lastInterestingAt = now; + } + SwitchTo(candidate, now); + onCurrent = 0f; + sinceSwitch = 0f; + } + else if (!candidate.HasValidPosition) + { + InterestCollector.Remove(candidate); + } + else if (candidate.Tier >= InterestTier.Action) + { + _lastInterestingAt = now; + } + } + + bool quiet = now - _lastInterestingAt >= QuietWorldAmbientAfterSeconds; + bool dwellDone = _current == null || onCurrent >= DwellFor(_current); + bool ambientDue = now - _lastAmbientAt >= AmbientRotateSeconds; + if (_current == null && sinceSwitch >= 0.5f) + { + TryAmbient(force: true); + } + else if (quiet && dwellDone && ambientDue && sinceSwitch >= SwitchCooldownSeconds) + { + TryAmbient(force: false); + } + } + + private static float DwellFor(InterestEvent current) + { + if (current == null) + { + return MinDwellSeconds; + } + + return current.Tier <= InterestTier.Curiosity ? CuriosityDwellSeconds : MinDwellSeconds; + } + + private static bool CanSwitchTo(InterestEvent candidate, float onCurrent, float sinceSwitch) + { + if (_current == null) + { + return true; + } + + // Curiosity is a footnote: never interrupt Action/Story/Epic, and rarely replace another curiosity. + if (candidate.Tier == InterestTier.Curiosity) + { + if (_current.Tier >= InterestTier.Action) + { + return false; + } + + if (_current.Tier == InterestTier.Curiosity && onCurrent < AmbientRotateSeconds) + { + return false; + } + + if (_current.Tier == InterestTier.Story) + { + return false; + } + } + + if (candidate.Tier > _current.Tier && onCurrent >= HighTierInterruptAfterSeconds) + { + return true; + } + + return onCurrent >= DwellFor(_current) && sinceSwitch >= SwitchCooldownSeconds; + } + + private static void SwitchTo(InterestEvent next, float now) + { + _current = next; + _currentStartedAt = now; + _lastSwitchAt = now; + if (next.Tier >= InterestTier.Action) + { + _lastInterestingAt = now; + } + + CameraDirector.Watch(next); + } + + private static void TryAmbient(bool force) + { + float now = Time.unscaledTime; + _lastAmbientAt = now; + + InterestEvent ambient = WorldActivityScanner.FindBestLiveTarget(); + if (ambient == null) + { + return; + } + + if (!force && _current != null && _current.Tier >= InterestTier.Story) + { + return; + } + + if (!force && now - _lastSwitchAt < SwitchCooldownSeconds && _current != null) + { + return; + } + + // Avoid re-following the exact same unit every ambient tick. + if (!force + && _current != null + && _current.FollowUnit != null + && ambient.FollowUnit != null + && _current.FollowUnit == ambient.FollowUnit + && ambient.Tier <= InterestTier.Ambient) + { + return; + } + + SwitchTo(ambient, now); + LogService.LogInfo("[IdleSpectator] Ambient: " + ambient.Label); + } +} diff --git a/IdleSpectator/InterestEvent.cs b/IdleSpectator/InterestEvent.cs new file mode 100644 index 0000000..cb8ac39 --- /dev/null +++ b/IdleSpectator/InterestEvent.cs @@ -0,0 +1,19 @@ +using UnityEngine; + +namespace IdleSpectator; + +public sealed class InterestEvent +{ + public InterestTier Tier; + public float Score; + public Vector3 Position; + public Actor FollowUnit; + public string Label; + public float CreatedAt; + public string AssetId; + + public bool HasFollowUnit => FollowUnit != null && FollowUnit.isAlive(); + + public bool HasValidPosition => + HasFollowUnit || (Position != Vector3.zero && !float.IsNaN(Position.x)); +} diff --git a/IdleSpectator/InterestTier.cs b/IdleSpectator/InterestTier.cs new file mode 100644 index 0000000..6966929 --- /dev/null +++ b/IdleSpectator/InterestTier.cs @@ -0,0 +1,18 @@ +namespace IdleSpectator; + +/// +/// Higher value can interrupt lower after interrupt grace. +/// +public enum InterestTier +{ + /// Filler rotation when nothing else is happening. + Ambient = 0, + /// Low priority curiosities (new species, oddities). + Curiosity = 1, + /// Live fights, high-kill creatures, action clusters. + Action = 2, + /// Leadership / politics / favorites. + Story = 3, + /// World-shaping beats (wars, kingdom falls, disasters). + Epic = 4 +} diff --git a/IdleSpectator/Main.cs b/IdleSpectator/Main.cs index 0d50df5..ab36a21 100644 --- a/IdleSpectator/Main.cs +++ b/IdleSpectator/Main.cs @@ -1,17 +1,21 @@ -using UnityEngine; +using HarmonyLib; using NeoModLoader.api; using NeoModLoader.services; +using UnityEngine; namespace IdleSpectator; /// -/// Phase 1 entry point: prove NeoModLoader loads this mod. -/// Camera / WorldLog / MoveCamera work belongs in a later phase. +/// Idle Spectator entry: F8 AFK camera director over WorldLog events. /// public class ModClass : MonoBehaviour, IMod { private ModDeclare _declare; private GameObject _gameObject; + private Harmony _harmony; + + /// Absolute path to this mod's folder (parent of mod.json). + public static string ModFolder { get; private set; } public ModDeclare GetDeclaration() { @@ -32,7 +36,17 @@ public class ModClass : MonoBehaviour, IMod { _declare = pModDecl; _gameObject = pGameObject; - // OnLoad -> Awake -> OnEnable -> Start -> Update - LogService.LogInfo($"[{pModDecl.Name}]: Hello World!"); + ModFolder = pModDecl.FolderPath; + + _harmony = new Harmony(pModDecl.UID); + _harmony.PatchAll(typeof(ModClass).Assembly); + LogService.LogInfo($"[{pModDecl.Name}]: Harmony patches applied"); + LogService.LogInfo($"[{pModDecl.Name}]: Press F8 to toggle Idle Spectator"); + } + + private void Update() + { + SpectatorMode.PollInput(); + InterestDirector.Update(); } } diff --git a/IdleSpectator/SpectatorMode.cs b/IdleSpectator/SpectatorMode.cs new file mode 100644 index 0000000..c1a3d79 --- /dev/null +++ b/IdleSpectator/SpectatorMode.cs @@ -0,0 +1,94 @@ +using NeoModLoader.services; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// ManualControl vs SpectatorMode toggle (F8). +/// +public static class SpectatorMode +{ + public const KeyCode ToggleKey = KeyCode.F8; + + public static bool Active { get; private set; } + + private static bool _forceFileChecked; + + public static void Toggle() + { + SetActive(!Active); + } + + public static void SetActive(bool active) + { + if (Active == active) + { + return; + } + + Active = active; + if (Active) + { + InterestDirector.OnSpectatorEnabled(); + WorldTip.showNowTop("Idle Spectator ON (F8 to stop)", pTranslate: false); + LogService.LogInfo("[IdleSpectator] Spectator mode enabled"); + } + else + { + InterestDirector.OnSpectatorDisabled(); + CameraDirector.ClearFollow(); + WorldTip.showNowTop("Idle Spectator OFF", pTranslate: false); + LogService.LogInfo("[IdleSpectator] Spectator mode disabled"); + } + } + + public static void PollInput() + { + if (!Config.game_loaded) + { + return; + } + + TryForceOnFile(); + + if (Input.GetKeyDown(ToggleKey)) + { + Toggle(); + } + } + + /// + /// Optional one-shot: place an empty .force-on file in the mod folder to enable Spectator once (deleted after use). + /// + private static void TryForceOnFile() + { + if (_forceFileChecked || Active) + { + return; + } + + string modFolder = ModClass.ModFolder; + if (string.IsNullOrEmpty(modFolder)) + { + return; + } + + string path = System.IO.Path.Combine(modFolder, ".force-on"); + if (!System.IO.File.Exists(path)) + { + return; + } + + _forceFileChecked = true; + try + { + System.IO.File.Delete(path); + } + catch + { + // Still enable even if delete fails. + } + + SetActive(true); + } +} diff --git a/IdleSpectator/SubspeciesPatches.cs b/IdleSpectator/SubspeciesPatches.cs new file mode 100644 index 0000000..e20178b --- /dev/null +++ b/IdleSpectator/SubspeciesPatches.cs @@ -0,0 +1,28 @@ +using HarmonyLib; +using UnityEngine; + +namespace IdleSpectator; + +[HarmonyPatch(typeof(SubspeciesManager), nameof(SubspeciesManager.newSpecies))] +public static class SubspeciesNewSpeciesPatch +{ + public static void Postfix(SubspeciesManager __instance, ActorAsset pAsset, WorldTile pTile, bool pMutation, Subspecies __result) + { + if (!SpectatorMode.Active) + { + return; + } + + // Avoid flooding the queue during map generation / mass spawn. + if (SmoothLoader.isLoading()) + { + return; + } + + InterestEvent curiosity = WorldActivityScanner.FromNewSpecies(__result, pAsset, pTile); + if (curiosity != null) + { + InterestCollector.EnqueueDirect(curiosity); + } + } +} diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs new file mode 100644 index 0000000..b5613d8 --- /dev/null +++ b/IdleSpectator/WorldActivityScanner.cs @@ -0,0 +1,493 @@ +using System.Collections.Generic; +using NeoModLoader.services; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Scores live world activity across civs and wild mobs (wiki-aligned). +/// Throttled to ~2 Hz while Spectator is active. +/// +public static class WorldActivityScanner +{ + private static readonly HashSet SpectacleAssets = new HashSet + { + "dragon", + "greg", + "demon", + "evil_mage", + "necromancer", + "ufo", + "cold_one", + "walker", + "snowman", + "bandit", + "tumor_monster", + "bioblob", + "assimilator", + "lil_pumpkin", + "fire_elemental", + "skeleton", + "zombie", + "ghost", + "mush" + }; + + private static readonly System.Random Rng = new System.Random(); + private static float _lastScanAt = -999f; + private static InterestEvent _cachedBest; + private static InterestEvent _cachedBattle; + private const float ScanInterval = 0.5f; + private static float _lastEmptyLogAt = -999f; + + public static InterestEvent FindBestLiveTarget() + { + EnsureScanned(); + if (_cachedBest == null && Time.unscaledTime - _lastEmptyLogAt > 15f) + { + _lastEmptyLogAt = Time.unscaledTime; + LogService.LogInfo("[IdleSpectator] No live units to follow yet"); + } + + return Clone(_cachedBest); + } + + public static InterestEvent FindHottestBattle() + { + EnsureScanned(); + return Clone(_cachedBattle); + } + + private static void EnsureScanned() + { + if (Time.unscaledTime - _lastScanAt < ScanInterval) + { + return; + } + + _lastScanAt = Time.unscaledTime; + ScanNow(); + } + + private static void ScanNow() + { + _cachedBattle = BuildHottestBattle(); + InterestEvent bestUnit = BuildBestScoredUnit(); + + // Prefer an active battle when it is meaningfully hotter than quiet unit watching. + if (_cachedBattle != null) + { + _cachedBest = _cachedBattle; + return; + } + + _cachedBest = bestUnit; + } + + private static InterestEvent BuildHottestBattle() + { + HashSet battles = BattleKeeperManager.get(); + if (battles == null || battles.Count == 0) + { + return null; + } + + BattleContainer best = null; + int bestDeaths = 0; + foreach (BattleContainer battle in battles) + { + if (battle?.tile == null) + { + continue; + } + + int deaths = battle.getDeathsTotal(); + if (deaths > bestDeaths) + { + bestDeaths = deaths; + best = battle; + } + } + + if (best == null || bestDeaths < 1) + { + return null; + } + + Vector3 pos = best.tile.posV3; + Actor nearby = FindNearestAliveUnit(pos, 14f); + return new InterestEvent + { + Tier = InterestTier.Action, + Score = 50f + bestDeaths, + Position = pos, + FollowUnit = nearby, + Label = bestDeaths >= 6 ? $"Battle ({bestDeaths} fallen)" : $"Skirmish ({bestDeaths} fallen)", + CreatedAt = Time.unscaledTime, + AssetId = "live_battle" + }; + } + + private static InterestEvent BuildBestScoredUnit() + { + Dictionary speciesCounts = new Dictionary(); + List alive = new List(128); + foreach (Actor actor in EnumerateAliveUnits()) + { + if (actor.asset != null && actor.asset.is_boat) + { + continue; + } + + alive.Add(actor); + string speciesId = actor.asset != null ? actor.asset.id : "unknown"; + speciesCounts.TryGetValue(speciesId, out int count); + speciesCounts[speciesId] = count + 1; + } + + if (alive.Count == 0) + { + return null; + } + + Actor best = null; + float bestScore = float.MinValue; + List nearTies = new List(); + + for (int i = 0; i < alive.Count; i++) + { + Actor actor = alive[i]; + float score = ScoreActor(actor, speciesCounts); + if (score > bestScore + 0.01f) + { + bestScore = score; + best = actor; + nearTies.Clear(); + nearTies.Add(actor); + } + else if (Mathf.Abs(score - bestScore) <= 2f) + { + nearTies.Add(actor); + } + } + + if (nearTies.Count > 1) + { + best = nearTies[Rng.Next(nearTies.Count)]; + bestScore = ScoreActor(best, speciesCounts); + } + + if (best == null) + { + return null; + } + + InterestTier tier = InterestTier.Ambient; + if (best.isFavorite() || best.isKing() || best.isCityLeader()) + { + tier = InterestTier.Story; + } + else if (best.has_attack_target || (best.data != null && best.data.kills >= 10)) + { + tier = InterestTier.Action; + } + else if (IsSpectacle(best) || IsSingletonSpecies(best, speciesCounts)) + { + tier = InterestTier.Curiosity; + } + + return new InterestEvent + { + Tier = tier, + Score = bestScore, + Position = best.current_position, + FollowUnit = best, + Label = FormatUnitLabel(best, speciesCounts), + CreatedAt = Time.unscaledTime, + AssetId = "scored_unit" + }; + } + + private static float ScoreActor(Actor actor, Dictionary speciesCounts) + { + float score = 0f; + if (actor.isFavorite()) + { + score += 50f; + } + + if (actor.isKing()) + { + score += 40f; + } + else if (actor.isCityLeader()) + { + score += 35f; + } + else if (actor.is_profession_warrior) + { + score += 12f; + } + + if (actor.has_attack_target) + { + score += 30f; + } + + int kills = actor.data != null ? actor.data.kills : 0; + score += Mathf.Min(25, kills); + + string speciesId = actor.asset != null ? actor.asset.id : "unknown"; + if (speciesCounts.TryGetValue(speciesId, out int pop) && pop == 1) + { + score += 15f; + } + + if (actor.subspecies != null) + { + // Nascent lineage: few members in subspecies asset unit set if available. + try + { + int subCount = CountSubspeciesMembers(actor.subspecies); + if (subCount > 0 && subCount <= 3) + { + score += 10f; + } + } + catch + { + // ignore + } + } + + if (IsSpectacle(actor)) + { + score += 8f; + } + + score += Mathf.Min(10f, actor.renown / 100f); + // Tiny jitter so equal sheep do not stick forever. + score += (actor.getID() % 7) * 0.01f; + return score; + } + + private static int CountSubspeciesMembers(Subspecies subspecies) + { + int count = 0; + // Prefer actor.asset.units filtered by subspecies when possible. + if (subspecies == null) + { + return 0; + } + + foreach (Actor unit in EnumerateAliveUnits()) + { + if (unit.subspecies == subspecies) + { + count++; + if (count > 8) + { + return count; + } + } + } + + return count; + } + + private static bool IsSpectacle(Actor actor) + { + if (actor?.asset == null) + { + return false; + } + + string id = actor.asset.id; + if (SpectacleAssets.Contains(id)) + { + return true; + } + + // Loose match for variant ids. + foreach (string key in SpectacleAssets) + { + if (id.Contains(key)) + { + return true; + } + } + + return false; + } + + private static bool IsSingletonSpecies(Actor actor, Dictionary speciesCounts) + { + if (actor?.asset == null) + { + return false; + } + + return speciesCounts.TryGetValue(actor.asset.id, out int pop) && pop == 1; + } + + private static string FormatUnitLabel(Actor actor, Dictionary speciesCounts) + { + string name = SafeName(actor); + string species = actor.asset != null ? actor.asset.id : "creature"; + if (actor.isFavorite()) + { + return $"Favorite: {name}"; + } + + if (actor.isKing()) + { + string kingdom = actor.kingdom != null ? actor.kingdom.name : "realm"; + return $"King {name} of {kingdom}"; + } + + if (actor.has_attack_target) + { + return $"Fighting: {name}"; + } + + if (IsSpectacle(actor)) + { + return $"Spectacle: {name} ({species})"; + } + + if (IsSingletonSpecies(actor, speciesCounts)) + { + return $"Lone {species}: {name}"; + } + + int kills = actor.data != null ? actor.data.kills : 0; + if (kills > 0) + { + return $"{name} ({species}, {kills} kills)"; + } + + return $"{name} ({species})"; + } + + public static InterestEvent FromNewSpecies(Subspecies subspecies, ActorAsset asset, WorldTile tile) + { + Vector3 pos = tile != null ? tile.posV3 : Vector3.zero; + Actor follow = null; + if (asset?.units != null) + { + foreach (Actor unit in asset.units) + { + if (unit != null && unit.isAlive() && (subspecies == null || unit.subspecies == subspecies)) + { + follow = unit; + pos = unit.current_position; + break; + } + } + } + + if (follow == null && pos == Vector3.zero) + { + return null; + } + + string name = asset != null ? asset.id : (subspecies != null ? subspecies.name : "species"); + return new InterestEvent + { + Tier = InterestTier.Curiosity, + Score = 5f, + Position = pos, + FollowUnit = follow, + Label = $"New species: {name}", + CreatedAt = Time.unscaledTime, + AssetId = "new_species" + }; + } + + private static Actor FindNearestAliveUnit(Vector3 pos, float maxDist) + { + Actor best = null; + float bestDist = maxDist * maxDist; + foreach (Actor actor in EnumerateAliveUnits()) + { + float dx = actor.current_position.x - pos.x; + float dy = actor.current_position.y - pos.y; + float d2 = dx * dx + dy * dy; + if (d2 < bestDist) + { + bestDist = d2; + best = actor; + } + } + + return best; + } + + private static IEnumerable EnumerateAliveUnits() + { + List prepared = World.world?.units?.units_only_alive; + if (prepared != null && prepared.Count > 0) + { + for (int i = 0; i < prepared.Count; i++) + { + Actor actor = prepared[i]; + if (actor != null && actor.isAlive()) + { + yield return actor; + } + } + + yield break; + } + + if (World.world?.units == null) + { + yield break; + } + + foreach (Actor actor in World.world.units) + { + if (actor != null && actor.isAlive()) + { + yield return actor; + } + } + } + + private static string SafeName(Actor actor) + { + try + { + string name = actor.getName(); + if (!string.IsNullOrEmpty(name)) + { + return name; + } + } + catch + { + // ignore + } + + return actor.asset != null ? actor.asset.id : "creature"; + } + + private static InterestEvent Clone(InterestEvent source) + { + if (source == null) + { + return null; + } + + return new InterestEvent + { + Tier = source.Tier, + Score = source.Score, + Position = source.Position, + FollowUnit = source.FollowUnit, + Label = source.Label, + CreatedAt = Time.unscaledTime, + AssetId = source.AssetId + }; + } +} diff --git a/IdleSpectator/WorldLogInterestTable.cs b/IdleSpectator/WorldLogInterestTable.cs new file mode 100644 index 0000000..2247b54 --- /dev/null +++ b/IdleSpectator/WorldLogInterestTable.cs @@ -0,0 +1,131 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Centralized WorldLog asset ids and tier mapping. +/// +public static class WorldLogInterestTable +{ + private static readonly Dictionary Tiers = new Dictionary + { + // Epic - world-shaping (wiki: Wars, Kingdoms fall, Disasters, city destroyed) + ["diplomacy_war_started"] = InterestTier.Epic, + ["total_war_started"] = InterestTier.Epic, + ["kingdom_destroyed"] = InterestTier.Epic, + ["kingdom_shattered"] = InterestTier.Epic, + ["kingdom_fractured"] = InterestTier.Epic, + ["city_destroyed"] = InterestTier.Epic, + ["log_city_revolted"] = InterestTier.Epic, + + // Story - leadership / politics / favorites / founding + ["kingdom_new"] = InterestTier.Story, + ["city_new"] = InterestTier.Story, + ["king_new"] = InterestTier.Story, + ["king_dead"] = InterestTier.Story, + ["king_killed"] = InterestTier.Story, + ["king_fled_capital"] = InterestTier.Story, + ["king_fled_city"] = InterestTier.Story, + ["king_left"] = InterestTier.Story, + ["alliance_new"] = InterestTier.Story, + ["alliance_dissolved"] = InterestTier.Story, + ["diplomacy_war_ended"] = InterestTier.Story, + ["favorite_dead"] = InterestTier.Story, + ["favorite_killed"] = InterestTier.Story, + ["kingdom_royal_clan_new"] = InterestTier.Story, + ["kingdom_royal_clan_changed"] = InterestTier.Story, + ["kingdom_royal_clan_dead"] = InterestTier.Story, + + // Curiosity - biosphere footnotes + ["race_dead"] = InterestTier.Curiosity + }; + + public static bool TryGetTier(string assetId, out InterestTier tier) + { + if (string.IsNullOrEmpty(assetId)) + { + tier = InterestTier.Ambient; + return false; + } + + if (Tiers.TryGetValue(assetId, out tier)) + { + return true; + } + + if (assetId.Contains("disaster") || assetId.StartsWith("worldlog_disaster")) + { + tier = InterestTier.Epic; + return true; + } + + tier = InterestTier.Ambient; + return false; + } + + public static string MakeLabel(WorldLogMessage message) + { + string id = message.asset_id ?? "event"; + string a = message.special1; + string b = message.special2; + + switch (id) + { + case "diplomacy_war_started": + return $"War: {a} vs {b}"; + case "total_war_started": + return $"Total war: {a}"; + case "kingdom_destroyed": + return $"Kingdom fell: {a}"; + case "kingdom_shattered": + return $"Kingdom shattered: {a}"; + case "kingdom_fractured": + return $"Kingdom fractured: {a}"; + case "city_destroyed": + return $"City destroyed: {a}"; + case "log_city_revolted": + return $"Revolt: {a}"; + case "kingdom_new": + return $"New kingdom: {a}"; + case "city_new": + return $"New city: {a}"; + case "king_new": + return $"New king: {b} ({a})"; + case "king_dead": + return $"King died: {b} ({a})"; + case "king_killed": + return $"King killed: {b} ({a})"; + case "king_fled_capital": + return $"King fled capital: {b}"; + case "king_fled_city": + return $"King fled city: {b}"; + case "alliance_new": + return $"Alliance: {a}"; + case "alliance_dissolved": + return $"Alliance dissolved: {a}"; + case "diplomacy_war_ended": + return $"War ended: {a}"; + case "kingdom_royal_clan_new": + case "kingdom_royal_clan_changed": + return $"Royal clan: {a}"; + case "kingdom_royal_clan_dead": + return $"Royal clan ended: {a}"; + case "race_dead": + return $"Species extinct: {a}"; + case "favorite_dead": + case "favorite_killed": + return $"Favorite fell: {a}"; + default: + if (!string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b)) + { + return $"{id}: {a} / {b}"; + } + if (!string.IsNullOrEmpty(a)) + { + return $"{id}: {a}"; + } + return id; + } + } +} diff --git a/IdleSpectator/WorldLogPatches.cs b/IdleSpectator/WorldLogPatches.cs new file mode 100644 index 0000000..0ea7255 --- /dev/null +++ b/IdleSpectator/WorldLogPatches.cs @@ -0,0 +1,12 @@ +using HarmonyLib; + +namespace IdleSpectator; + +[HarmonyPatch(typeof(WorldLogMessageExtensions), nameof(WorldLogMessageExtensions.add))] +public static class WorldLogMessageAddPatch +{ + public static void Postfix(WorldLogMessage pMessage) + { + InterestCollector.OnWorldLogMessage(pMessage); + } +} diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 65e54fc..a4c1d91 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.1.0", - "description": "Phase 1 Hello World - AFK camera spectator coming later.", + "version": "0.3.0", + "description": "AFK Idle Spectator (F8): follows wars, disasters, battles, creatures, and new species from fresh worlds to empires.", "GUID": "com.dazed.idlespectator" } diff --git a/README.md b/README.md index 54b7803..19e00af 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,32 @@ # IdleSpectator -WorldBox NeoModLoader mod: AFK camera spectator (Phase 1 Hello World is working). +WorldBox NeoModLoader mod: AFK Idle Spectator camera director. + +## Controls + +- **F8** - toggle Idle Spectator on/off +- Dragging the camera while Spectator is on turns it off (after a short grace period) + +## What it follows + +Priority (wiki-aligned): + +1. **Epic** - wars, kingdom fall/shatter, city destroyed, disasters +2. **Story** - kings, clans, alliances, favorites, new kingdoms/cities +3. **Action** - live battles/skirmishes (civ or wild), fighting units +4. **Curiosity** - new subspecies (low priority; will not interrupt fights) +5. **Ambient** - highest-scoring alive creature on the map (works on fresh animal-only worlds) ## Layout -- `IdleSpectator/` - mod source (`mod.json`, `Main.cs`) loaded by NML -- `scripts/verify-nml.sh` - checks that the Hello World line appears in Player.log - -## Game API notes (for later spectator work) - -Useful real types (not placeholders): `MoveCamera`, `WorldLog` / `WorldLogMessage`, `WarManager`, `BattleKeeperManager`, `Actor`, `MapBox` (`locatePosition`, `locateAndFollow`). +- `IdleSpectator/` - mod source (`mod.json` + C#), loaded/compiled by NML +- `scripts/verify-nml.sh` - load verification helper ## Deploy -Symlink the mod into the game Mods folder (already done on this machine): - ```bash ln -sfn "$(pwd)/IdleSpectator" \ "$HOME/.local/share/Steam/steamapps/common/worldbox/Mods/IdleSpectator" ``` -Requires NeoModLoader in `worldbox_Data/StreamingAssets/mods/` and **Experimental Mode** enabled in Settings. - -## Verify - -```bash -./scripts/verify-nml.sh -``` - -Look for: `[NML]: [IdleSpectator]: Hello World!` +Requires NeoModLoader in `worldbox_Data/StreamingAssets/mods/` and **Experimental Mode** enabled.