diff --git a/.gitignore b/.gitignore
index 1dfd80c..6cffc46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,7 @@ desktop.ini
.dotnet/
.local/
*.decompiled.cs
+
+# local test arm files
+IdleSpectator/.auto-test
+IdleSpectator/.force-on
diff --git a/IdleSpectator/AutoTest.cs b/IdleSpectator/AutoTest.cs
new file mode 100644
index 0000000..a23d525
--- /dev/null
+++ b/IdleSpectator/AutoTest.cs
@@ -0,0 +1,105 @@
+using System.IO;
+using NeoModLoader.services;
+using UnityEngine;
+
+namespace IdleSpectator;
+
+///
+/// One-shot automated session: enable spectator when a world is loaded, run for a while,
+/// log a pass/fail verdict from StateProbe, then quit. Arm with an empty .auto-test
+/// file in the mod folder (deleted when finished).
+///
+public static class AutoTest
+{
+ private const float DurationSeconds = 50f;
+ private static bool _armed;
+ private static bool _running;
+ private static float _startedAt = -1f;
+ private static bool _finished;
+
+ public static void Update()
+ {
+ if (_finished)
+ {
+ return;
+ }
+
+ if (!_armed)
+ {
+ string path = AutoTestPath();
+ if (string.IsNullOrEmpty(path) || !File.Exists(path))
+ {
+ return;
+ }
+
+ _armed = true;
+ LogService.LogInfo("[IdleSpectator][AUTOTEST] armed - waiting for world load");
+ }
+
+ if (!Config.game_loaded || World.world == null || SmoothLoader.isLoading())
+ {
+ return;
+ }
+
+ if (!_running)
+ {
+ _running = true;
+ _startedAt = Time.unscaledTime;
+ StateProbe.ResetCounters();
+ if (!SpectatorMode.Active)
+ {
+ SpectatorMode.SetActive(true);
+ }
+
+ LogService.LogInfo(
+ $"[IdleSpectator][AUTOTEST] started for {DurationSeconds:0}s (idle={SpectatorMode.Active})");
+ return;
+ }
+
+ if (Time.unscaledTime - _startedAt < DurationSeconds)
+ {
+ return;
+ }
+
+ _finished = true;
+ int bad = StateProbe.BadEventCount;
+ int states = StateProbe.StateEventCount;
+ bool focusOk = MoveCamera.hasFocusUnit();
+ bool barHidden = !CanvasMain.isBottomBarShowing();
+ bool pass = bad == 0 && SpectatorMode.Active && focusOk && barHidden;
+ string verdict = pass ? "PASS" : "FAIL";
+ LogService.LogInfo(
+ $"[IdleSpectator][AUTOTEST] {verdict} duration={DurationSeconds:0}s states={states} bad={bad} idle={SpectatorMode.Active} focus={focusOk} powerBar={!barHidden}");
+
+ try
+ {
+ string path = AutoTestPath();
+ if (!string.IsNullOrEmpty(path) && File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ // Do not Application.Quit() - that often freezes the Steam/Unity process on Linux.
+ LogService.LogInfo("[IdleSpectator][AUTOTEST] finished (leaving game running)");
+ if (SpectatorMode.Active)
+ {
+ SpectatorMode.SetActive(false);
+ }
+ }
+
+ private static string AutoTestPath()
+ {
+ string modFolder = ModClass.ModFolder;
+ if (string.IsNullOrEmpty(modFolder))
+ {
+ return null;
+ }
+
+ return Path.Combine(modFolder, ".auto-test");
+ }
+}
diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs
index a7a77da..09f5898 100644
--- a/IdleSpectator/CameraDirector.cs
+++ b/IdleSpectator/CameraDirector.cs
@@ -17,25 +17,109 @@ public static class CameraDirector
return;
}
- string tip = string.IsNullOrEmpty(interest.Label)
- ? "Watching event"
- : "Watching: " + interest.Label;
- WorldTip.showNowTop(tip, pTranslate: false);
+ string tip = FormatWatchTip(interest);
+ if (ModSettings.ShowWatchReasons)
+ {
+ WorldTip.showNowTop(tip, pTranslate: false);
+ }
+
LogService.LogInfo($"[IdleSpectator] {tip}");
- if (interest.HasFollowUnit)
+ Actor follow = interest.HasFollowUnit
+ ? interest.FollowUnit
+ : null;
+
+ // Never attach a random nearby unit to a "new species" tip - that caused ghost focuses
+ // (e.g. tip says crab, camera follows a cat).
+ if (follow == null && !string.IsNullOrEmpty(interest.AssetId) && interest.AssetId != "live_battle"
+ && interest.AssetId != "scored_unit" && interest.Label != null && interest.Label.StartsWith("New species:"))
{
- World.world.locateAndFollow(interest.FollowUnit, null, null);
+ follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
+ }
+ else if (follow == null)
+ {
+ follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f);
+ }
+
+ if (follow != null && follow.isAlive())
+ {
+ // If this is a species event, refuse mismatched units.
+ if (interest.Label != null && interest.Label.StartsWith("New species:")
+ && !string.IsNullOrEmpty(interest.AssetId)
+ && follow.asset != null && follow.asset.id != interest.AssetId)
+ {
+ Actor matched = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
+ if (matched == null)
+ {
+ return;
+ }
+
+ follow = matched;
+ }
+
+ RetargetFollow(follow);
return;
}
- World.world.locatePosition(interest.Position);
+ // No unit available: pan only if we are not already following someone.
+ // Never clear an existing focus unit here - that flashes the power bar.
+ if (!MoveCamera.hasFocusUnit())
+ {
+ PanCamera(interest.Position);
+ }
+ }
+
+ public static string FormatWatchTip(InterestEvent interest)
+ {
+ string tier = interest.Tier.ToString();
+ string reason = string.IsNullOrEmpty(interest.Label)
+ ? "something interesting"
+ : interest.Label;
+ return $"Watching [{tier}]: {reason}";
}
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();
}
+
+ private static void RetargetFollow(Actor actor)
+ {
+ if (MoveCamera.isCameraFollowingUnit(actor))
+ {
+ return;
+ }
+
+ bool firstFocus = !MoveCamera.hasFocusUnit();
+ MoveCamera.setFocusUnit(actor);
+
+ MoveCamera cam = MoveCamera.instance;
+ if (cam == null)
+ {
+ return;
+ }
+
+ // Same as vanilla killer handoff: reset ease so the camera lerps to the new unit.
+ cam._focus_timer = 0f;
+ if (firstFocus)
+ {
+ cam._target_zoom = 15f;
+ cam._focus_zoom = 15f;
+ }
+ }
+
+ private static void PanCamera(Vector3 pos)
+ {
+ MoveCamera cam = MoveCamera.instance;
+ if (cam == null)
+ {
+ World.world.locatePosition(pos);
+ return;
+ }
+
+ pos.z = cam.transform.position.z;
+ cam.transform.position = pos;
+ cam._target_zoom = 15f;
+ cam._focus_zoom = 15f;
+ }
}
diff --git a/IdleSpectator/CanvasMainPatches.cs b/IdleSpectator/CanvasMainPatches.cs
new file mode 100644
index 0000000..9068ff5
--- /dev/null
+++ b/IdleSpectator/CanvasMainPatches.cs
@@ -0,0 +1,20 @@
+using HarmonyLib;
+
+namespace IdleSpectator;
+
+///
+/// Keep the power bar hidden for the whole Idle Spectator session, even if focus
+/// briefly drops between targets (vanilla only hides it while a focus unit is set).
+///
+[HarmonyPatch(typeof(CanvasMain), nameof(CanvasMain.isBottomBarShowing))]
+internal static class CanvasMainPatches
+{
+ [HarmonyPostfix]
+ private static void KeepPowerBarHiddenWhileIdleSpectating(ref bool __result)
+ {
+ if (SpectatorMode.Active)
+ {
+ __result = false;
+ }
+ }
+}
diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs
index b79680e..4ee111a 100644
--- a/IdleSpectator/InterestDirector.cs
+++ b/IdleSpectator/InterestDirector.cs
@@ -23,8 +23,7 @@ public static class InterestDirector
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;
+ private const float InputExitGraceSeconds = 2.5f;
public static void OnSpectatorEnabled()
{
@@ -37,7 +36,6 @@ public static class InterestDirector
_lastAmbientAt = -999f;
_lastActionPollAt = -999f;
_enabledAt = now;
- _prevCameraDrag = MoveCamera.camera_drag_run;
TryAmbient(force: true);
}
@@ -54,16 +52,12 @@ public static class InterestDirector
return;
}
- bool dragging = MoveCamera.camera_drag_run;
- bool pastGrace = Time.unscaledTime - _enabledAt >= DragExitGraceSeconds;
- if (pastGrace && dragging && !_prevCameraDrag)
+ if (Time.unscaledTime - _enabledAt >= InputExitGraceSeconds && PlayerTookManualControl())
{
- _prevCameraDrag = dragging;
SpectatorMode.SetActive(false);
- WorldTip.showNowTop("Idle Spectator paused (camera drag)", pTranslate: false);
+ WorldTip.showNowTop("Idle Spectator paused (manual input)", pTranslate: false);
return;
}
- _prevCameraDrag = dragging;
float now = Time.unscaledTime;
float onCurrent = now - _currentStartedAt;
@@ -116,6 +110,44 @@ public static class InterestDirector
}
}
+ private static bool PlayerTookManualControl()
+ {
+ // Rising-edge style checks only - avoid sticky flags like already_used_power
+ // that can trip when the power bar flashes or a tip appears.
+ if (MoveCamera.camera_drag_run)
+ {
+ return true;
+ }
+
+ if (Mathf.Abs(Input.mouseScrollDelta.y) > 0.01f)
+ {
+ return true;
+ }
+
+ if (HotkeyLibrary.left != null
+ && (HotkeyLibrary.left.isHolding()
+ || HotkeyLibrary.right.isHolding()
+ || HotkeyLibrary.up.isHolding()
+ || HotkeyLibrary.down.isHolding()))
+ {
+ return true;
+ }
+
+ if (HotkeyLibrary.zoom_in != null
+ && (HotkeyLibrary.zoom_in.isHolding() || HotkeyLibrary.zoom_out.isHolding()))
+ {
+ return true;
+ }
+
+ if (!World.world.isOverUI()
+ && (InputHelpers.GetMouseButtonDown(0) || InputHelpers.GetMouseButtonDown(1)))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
private static float DwellFor(InterestEvent current)
{
if (current == null)
@@ -189,6 +221,13 @@ public static class InterestDirector
return;
}
+ // Don't let ambient yank the camera off a curiosity tip after a few seconds.
+ if (!force && _current != null && _current.Tier == InterestTier.Curiosity
+ && now - _currentStartedAt < AmbientRotateSeconds)
+ {
+ return;
+ }
+
if (!force && now - _lastSwitchAt < SwitchCooldownSeconds && _current != null)
{
return;
diff --git a/IdleSpectator/Locales/en.json b/IdleSpectator/Locales/en.json
new file mode 100644
index 0000000..f57ab76
--- /dev/null
+++ b/IdleSpectator/Locales/en.json
@@ -0,0 +1,6 @@
+{
+ "Spectator": "Idle Spectator",
+ "enabled": "Enable Idle Spectator (I)",
+ "show_watch_reasons": "Show why the camera is watching",
+ "debug_state_probe": "Log focus/power-bar state (for debugging)"
+}
diff --git a/IdleSpectator/Main.cs b/IdleSpectator/Main.cs
index ab36a21..031c634 100644
--- a/IdleSpectator/Main.cs
+++ b/IdleSpectator/Main.cs
@@ -1,3 +1,4 @@
+using System.IO;
using HarmonyLib;
using NeoModLoader.api;
using NeoModLoader.services;
@@ -6,13 +7,14 @@ using UnityEngine;
namespace IdleSpectator;
///
-/// Idle Spectator entry: F8 AFK camera director over WorldLog events.
+/// Idle Spectator entry: I key AFK camera director over WorldLog events.
///
-public class ModClass : MonoBehaviour, IMod
+public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
{
private ModDeclare _declare;
private GameObject _gameObject;
private Harmony _harmony;
+ private ModConfig _config;
/// Absolute path to this mod's folder (parent of mod.json).
public static string ModFolder { get; private set; }
@@ -32,21 +34,35 @@ public class ModClass : MonoBehaviour, IMod
return "";
}
+ public ModConfig GetConfig()
+ {
+ return _config;
+ }
+
+ public string GetLocaleFilesDirectory(ModDeclare pModDeclare)
+ {
+ return Path.Combine(pModDeclare.FolderPath, "Locales");
+ }
+
public void OnLoad(ModDeclare pModDecl, GameObject pGameObject)
{
_declare = pModDecl;
_gameObject = pGameObject;
ModFolder = pModDecl.FolderPath;
+ _config = ModSettings.Load(pModDecl);
_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");
+ LogService.LogInfo($"[{pModDecl.Name}]: Press I to toggle Idle Spectator (disable in mod settings)");
}
private void Update()
{
SpectatorMode.PollInput();
InterestDirector.Update();
+ SpeciesDiscovery.Update();
+ StateProbe.Update();
+ AutoTest.Update();
}
}
diff --git a/IdleSpectator/ModSettings.cs b/IdleSpectator/ModSettings.cs
new file mode 100644
index 0000000..f9dbc75
--- /dev/null
+++ b/IdleSpectator/ModSettings.cs
@@ -0,0 +1,74 @@
+using System.IO;
+using NeoModLoader.api;
+using NeoModLoader.constants;
+using NeoModLoader.services;
+
+namespace IdleSpectator;
+
+///
+/// NML ModConfig accessors for Idle Spectator.
+///
+public static class ModSettings
+{
+ public const string GroupId = "Spectator";
+ public const string EnabledId = "enabled";
+ public const string ShowWatchReasonsId = "show_watch_reasons";
+ public const string DebugStateProbeId = "debug_state_probe";
+
+ private static ModConfig _config;
+
+ public static bool Enabled => GetBool(EnabledId, defaultValue: true);
+
+ public static bool ShowWatchReasons => GetBool(ShowWatchReasonsId, defaultValue: true);
+
+ public static bool DebugStateProbe => GetBool(DebugStateProbeId, defaultValue: true);
+
+ public static ModConfig Load(ModDeclare declare)
+ {
+ string persistentPath = Path.Combine(Paths.ModsConfigPath, declare.UID + ".config");
+ ModConfig persistent = new ModConfig(persistentPath, pIsPersistent: true);
+ string defaultsPath = Path.Combine(declare.FolderPath, Paths.ModDefaultConfigFileName);
+ if (File.Exists(defaultsPath))
+ {
+ ModConfig defaults = new ModConfig(defaultsPath);
+ persistent.MergeWith(defaults);
+ }
+
+ _config = persistent;
+ return _config;
+ }
+
+ /// NML config callback: IdleSpectator.ModSettings:OnEnabledChanged
+ public static void OnEnabledChanged(bool enabled)
+ {
+ if (!enabled && SpectatorMode.Active)
+ {
+ SpectatorMode.SetActive(false);
+ LogService.LogInfo("[IdleSpectator] Disabled via settings");
+ }
+ }
+
+ private static bool GetBool(string id, bool defaultValue)
+ {
+ if (_config == null)
+ {
+ return defaultValue;
+ }
+
+ try
+ {
+ ModConfigItem item = _config[GroupId][id];
+ object value = item.GetValue();
+ if (value is bool b)
+ {
+ return b;
+ }
+ }
+ catch
+ {
+ // Fall through to default.
+ }
+
+ return defaultValue;
+ }
+}
diff --git a/IdleSpectator/SpeciesDiscovery.cs b/IdleSpectator/SpeciesDiscovery.cs
new file mode 100644
index 0000000..783c262
--- /dev/null
+++ b/IdleSpectator/SpeciesDiscovery.cs
@@ -0,0 +1,121 @@
+using System.Collections.Generic;
+using NeoModLoader.services;
+using UnityEngine;
+
+namespace IdleSpectator;
+
+///
+/// Captures subspecies creation during map gen / before spectator is on,
+/// then drains a few as Curiosity once the world is playable.
+///
+public static class SpeciesDiscovery
+{
+ private const float DrainIntervalSeconds = 12f;
+ private const int MaxBuffered = 24;
+ private const int MaxDrainPerSession = 8;
+
+ private static readonly Queue Pending = new Queue();
+ private static readonly HashSet SeenAssets = new HashSet();
+ private static float _lastDrainAt = -999f;
+ private static int _drainedThisSession;
+
+ private struct PendingSpecies
+ {
+ public string AssetId;
+ public Vector3 Position;
+ public Subspecies Subspecies;
+ }
+
+ public static void OnSpectatorEnabled()
+ {
+ _drainedThisSession = 0;
+ _lastDrainAt = Time.unscaledTime - DrainIntervalSeconds + 2f;
+ }
+
+ public static void OnSpectatorDisabled()
+ {
+ // Keep buffer so re-enabling can still discover; clear session drain counter only.
+ _drainedThisSession = 0;
+ }
+
+ public static void NoteCreated(ActorAsset asset, WorldTile tile, bool mutation, Subspecies subspecies)
+ {
+ if (mutation || asset == null || string.IsNullOrEmpty(asset.id))
+ {
+ return;
+ }
+
+ if (!SeenAssets.Add(asset.id))
+ {
+ return;
+ }
+
+ if (Pending.Count >= MaxBuffered)
+ {
+ Pending.Dequeue();
+ }
+
+ Vector3 pos = tile != null ? tile.posV3 : Vector3.zero;
+ Pending.Enqueue(new PendingSpecies
+ {
+ AssetId = asset.id,
+ Position = pos,
+ Subspecies = subspecies
+ });
+ }
+
+ public static void Update()
+ {
+ if (!SpectatorMode.Active || !Config.game_loaded || World.world == null)
+ {
+ return;
+ }
+
+ if (SmoothLoader.isLoading())
+ {
+ return;
+ }
+
+ if (_drainedThisSession >= MaxDrainPerSession || Pending.Count == 0)
+ {
+ return;
+ }
+
+ float now = Time.unscaledTime;
+ if (now - _lastDrainAt < DrainIntervalSeconds)
+ {
+ return;
+ }
+
+ PendingSpecies next = Pending.Dequeue();
+ ActorAsset asset = AssetManager.actor_library?.get(next.AssetId);
+ if (asset == null)
+ {
+ return;
+ }
+
+ InterestEvent curiosity = WorldActivityScanner.FromNewSpecies(next.Subspecies, asset, null);
+ if (curiosity == null)
+ {
+ // Unit may not exist yet - put back once, try later.
+ if (Pending.Count < MaxBuffered)
+ {
+ Pending.Enqueue(next);
+ }
+
+ _lastDrainAt = now - DrainIntervalSeconds + 3f;
+ return;
+ }
+
+ // Prefer the recorded spawn tile when the unit search used a weak position.
+ if (next.Position != Vector3.zero && curiosity.Position == Vector3.zero)
+ {
+ curiosity.Position = next.Position;
+ }
+
+ _lastDrainAt = now;
+ _drainedThisSession++;
+ InterestCollector.EnqueueDirect(curiosity);
+ LogService.LogInfo($"[IdleSpectator] Discovery drain: {next.AssetId} ({_drainedThisSession}/{MaxDrainPerSession}, queued={Pending.Count})");
+ }
+}
diff --git a/IdleSpectator/SpectatorMode.cs b/IdleSpectator/SpectatorMode.cs
index c1a3d79..d8fd341 100644
--- a/IdleSpectator/SpectatorMode.cs
+++ b/IdleSpectator/SpectatorMode.cs
@@ -4,11 +4,11 @@ using UnityEngine;
namespace IdleSpectator;
///
-/// ManualControl vs SpectatorMode toggle (F8).
+/// ManualControl vs SpectatorMode toggle (I).
///
public static class SpectatorMode
{
- public const KeyCode ToggleKey = KeyCode.F8;
+ public const KeyCode ToggleKey = KeyCode.I;
public static bool Active { get; private set; }
@@ -21,6 +21,13 @@ public static class SpectatorMode
public static void SetActive(bool active)
{
+ if (active && !ModSettings.Enabled)
+ {
+ WorldTip.showNowTop("Idle Spectator disabled in mod settings", pTranslate: false);
+ LogService.LogInfo("[IdleSpectator] Enable blocked: disabled in settings");
+ return;
+ }
+
if (Active == active)
{
return;
@@ -29,14 +36,17 @@ public static class SpectatorMode
Active = active;
if (Active)
{
- InterestDirector.OnSpectatorEnabled();
- WorldTip.showNowTop("Idle Spectator ON (F8 to stop)", pTranslate: false);
+ InterestDirector.OnSpectatorEnabled();
+ SpeciesDiscovery.OnSpectatorEnabled();
+ WorldTip.showNowTop("Idle Spectator ON (I or any input to stop)", pTranslate: false);
LogService.LogInfo("[IdleSpectator] Spectator mode enabled");
}
else
{
InterestDirector.OnSpectatorDisabled();
+ SpeciesDiscovery.OnSpectatorDisabled();
CameraDirector.ClearFollow();
+ StateProbe.Reset();
WorldTip.showNowTop("Idle Spectator OFF", pTranslate: false);
LogService.LogInfo("[IdleSpectator] Spectator mode disabled");
}
@@ -49,6 +59,16 @@ public static class SpectatorMode
return;
}
+ if (!ModSettings.Enabled)
+ {
+ if (Active)
+ {
+ SetActive(false);
+ }
+
+ return;
+ }
+
TryForceOnFile();
if (Input.GetKeyDown(ToggleKey))
@@ -62,7 +82,7 @@ public static class SpectatorMode
///
private static void TryForceOnFile()
{
- if (_forceFileChecked || Active)
+ if (_forceFileChecked || Active || !ModSettings.Enabled)
{
return;
}
diff --git a/IdleSpectator/StateProbe.cs b/IdleSpectator/StateProbe.cs
new file mode 100644
index 0000000..d3c73a8
--- /dev/null
+++ b/IdleSpectator/StateProbe.cs
@@ -0,0 +1,161 @@
+using NeoModLoader.services;
+using UnityEngine;
+
+namespace IdleSpectator;
+
+///
+/// Samples focus / spectator / power-bar state and logs only on changes or faults.
+/// Tail Player.log for [IdleSpectator][STATE] and [IdleSpectator][BAD].
+///
+public static class StateProbe
+{
+ private static bool _prevIdle;
+ private static bool _prevFocus;
+ private static bool _prevVanillaSpec;
+ private static bool _prevBar;
+ private static string _prevUnit = "";
+ private static float _focusLostSince = -1f;
+ private static float _lastHeartbeatAt = -999f;
+ private static float _lastBadPowerBarAt = -999f;
+ private static float _lastBadGapAt = -999f;
+ private static bool _hadFocusWhileIdle;
+ private const float HeartbeatSeconds = 10f;
+ private const float FocusGapBadSeconds = 0.35f;
+
+ public static int BadEventCount { get; private set; }
+ public static int StateEventCount { get; private set; }
+
+ public static void ResetCounters()
+ {
+ BadEventCount = 0;
+ StateEventCount = 0;
+ _lastBadPowerBarAt = -999f;
+ _lastBadGapAt = -999f;
+ _focusLostSince = -1f;
+ _lastHeartbeatAt = -999f;
+ _hadFocusWhileIdle = false;
+ }
+
+ public static void Update()
+ {
+ if (!ModSettings.DebugStateProbe || !Config.game_loaded || World.world == null)
+ {
+ return;
+ }
+
+ bool idle = SpectatorMode.Active;
+ bool focus = MoveCamera.hasFocusUnit();
+ bool vanillaSpec = MoveCamera.inSpectatorMode();
+ bool bar = CanvasMain.isBottomBarShowing();
+ string unit = FocusUnitLabel();
+
+ bool changed = idle != _prevIdle
+ || focus != _prevFocus
+ || vanillaSpec != _prevVanillaSpec
+ || bar != _prevBar
+ || unit != _prevUnit;
+
+ if (changed)
+ {
+ StateEventCount++;
+ LogService.LogInfo(
+ $"[IdleSpectator][STATE] idle={idle} focus={focus} vanillaSpec={vanillaSpec} powerBar={bar} unit={unit}");
+ }
+
+ if (idle && focus)
+ {
+ _hadFocusWhileIdle = true;
+ }
+
+ if (idle && _prevFocus && !focus)
+ {
+ BadEventCount++;
+ LogService.LogInfo("[IdleSpectator][BAD] focus dropped while Idle Spectator is on");
+ _focusLostSince = Time.unscaledTime;
+ }
+
+ // Only treat prolonged no-focus as bad after we have successfully acquired a target.
+ // Fresh maps often have a short empty window before the first unit exists.
+ if (idle && !focus && _hadFocusWhileIdle)
+ {
+ if (_focusLostSince < 0f)
+ {
+ _focusLostSince = Time.unscaledTime;
+ }
+ else if (Time.unscaledTime - _focusLostSince >= FocusGapBadSeconds
+ && Time.unscaledTime - _lastBadGapAt >= 1f)
+ {
+ _lastBadGapAt = Time.unscaledTime;
+ BadEventCount++;
+ LogService.LogInfo(
+ $"[IdleSpectator][BAD] no focus unit for {Time.unscaledTime - _focusLostSince:0.00}s while idle on (power bar risk)");
+ }
+ }
+ else if (focus)
+ {
+ _focusLostSince = -1f;
+ }
+
+ if (idle && bar && Time.unscaledTime - _lastBadPowerBarAt >= 1f)
+ {
+ _lastBadPowerBarAt = Time.unscaledTime;
+ BadEventCount++;
+ LogService.LogInfo("[IdleSpectator][BAD] power bar showing while Idle Spectator is on");
+ }
+
+ if (idle && Time.unscaledTime - _lastHeartbeatAt >= HeartbeatSeconds)
+ {
+ _lastHeartbeatAt = Time.unscaledTime;
+ StateEventCount++;
+ LogService.LogInfo(
+ $"[IdleSpectator][STATE] heartbeat idle={idle} focus={focus} vanillaSpec={vanillaSpec} powerBar={bar} unit={unit}");
+ }
+
+ _prevIdle = idle;
+ _prevFocus = focus;
+ _prevVanillaSpec = vanillaSpec;
+ _prevBar = bar;
+ _prevUnit = unit;
+ }
+
+ public static void Reset()
+ {
+ _prevIdle = false;
+ _prevFocus = false;
+ _prevVanillaSpec = false;
+ _prevBar = false;
+ _prevUnit = "";
+ _focusLostSince = -1f;
+ _lastHeartbeatAt = -999f;
+ ResetCounters();
+ }
+
+ private static string FocusUnitLabel()
+ {
+ if (!MoveCamera.hasFocusUnit())
+ {
+ return "-";
+ }
+
+ Actor unit = MoveCamera._focus_unit;
+ if (unit == null)
+ {
+ return "?";
+ }
+
+ try
+ {
+ string name = unit.getName();
+ if (!string.IsNullOrEmpty(name))
+ {
+ return name;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return unit.asset != null ? unit.asset.id : "unit";
+ }
+}
diff --git a/IdleSpectator/SubspeciesPatches.cs b/IdleSpectator/SubspeciesPatches.cs
index e20178b..d7dd0e8 100644
--- a/IdleSpectator/SubspeciesPatches.cs
+++ b/IdleSpectator/SubspeciesPatches.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
@@ -6,23 +7,44 @@ namespace IdleSpectator;
[HarmonyPatch(typeof(SubspeciesManager), nameof(SubspeciesManager.newSpecies))]
public static class SubspeciesNewSpeciesPatch
{
+ private static readonly Dictionary LastLiveEnqueuedAt = new Dictionary();
+ private const float PerSpeciesCooldownSeconds = 45f;
+
public static void Postfix(SubspeciesManager __instance, ActorAsset pAsset, WorldTile pTile, bool pMutation, Subspecies __result)
{
- if (!SpectatorMode.Active)
+ // Always remember first-seen assets (including during map gen) for later discovery drain.
+ SpeciesDiscovery.NoteCreated(pAsset, pTile, pMutation, __result);
+
+ if (!SpectatorMode.Active || pMutation)
{
return;
}
- // Avoid flooding the queue during map generation / mass spawn.
+ // Live path: only while the world is playable, not during SmoothLoader gen flood.
if (SmoothLoader.isLoading())
{
return;
}
- InterestEvent curiosity = WorldActivityScanner.FromNewSpecies(__result, pAsset, pTile);
- if (curiosity != null)
+ string assetId = pAsset != null ? pAsset.id : null;
+ if (string.IsNullOrEmpty(assetId))
{
- InterestCollector.EnqueueDirect(curiosity);
+ return;
}
+
+ float now = Time.unscaledTime;
+ if (LastLiveEnqueuedAt.TryGetValue(assetId, out float last) && now - last < PerSpeciesCooldownSeconds)
+ {
+ return;
+ }
+
+ InterestEvent curiosity = WorldActivityScanner.FromNewSpecies(__result, pAsset, pTile);
+ if (curiosity == null)
+ {
+ return;
+ }
+
+ LastLiveEnqueuedAt[assetId] = now;
+ InterestCollector.EnqueueDirect(curiosity);
}
}
diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs
index b5613d8..e84a4db 100644
--- a/IdleSpectator/WorldActivityScanner.cs
+++ b/IdleSpectator/WorldActivityScanner.cs
@@ -371,45 +371,51 @@ public static class WorldActivityScanner
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)
+ if (asset == null)
+ {
+ return null;
+ }
+
+ Vector3 pos = tile != null ? tile.posV3 : Vector3.zero;
+ Actor follow = FindMatchingSpeciesUnit(asset, subspecies, pos, 30f);
+ if (follow != null)
+ {
+ pos = follow.current_position;
+ }
+
+ // No matching unit yet = ghost tip (wrong creature gets focused). Skip until one exists.
+ if (follow == null)
{
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}",
+ Label = $"New species: {asset.id}",
CreatedAt = Time.unscaledTime,
- AssetId = "new_species"
+ AssetId = asset.id
};
}
- private static Actor FindNearestAliveUnit(Vector3 pos, float maxDist)
+ ///
+ /// Nearest living unit, optionally restricted to a specific actor asset id.
+ ///
+ public static Actor FindNearestAliveUnit(Vector3 pos, float maxDist, string requireAssetId = null)
{
Actor best = null;
float bestDist = maxDist * maxDist;
foreach (Actor actor in EnumerateAliveUnits())
{
+ if (requireAssetId != null
+ && (actor.asset == null || actor.asset.id != requireAssetId))
+ {
+ continue;
+ }
+
float dx = actor.current_position.x - pos.x;
float dy = actor.current_position.y - pos.y;
float d2 = dx * dx + dy * dy;
@@ -423,6 +429,22 @@ public static class WorldActivityScanner
return best;
}
+ private static Actor FindMatchingSpeciesUnit(ActorAsset asset, Subspecies subspecies, Vector3 nearPos, float maxDist)
+ {
+ if (asset?.units != null)
+ {
+ foreach (Actor unit in asset.units)
+ {
+ if (unit != null && unit.isAlive() && (subspecies == null || unit.subspecies == subspecies))
+ {
+ return unit;
+ }
+ }
+ }
+
+ return FindNearestAliveUnit(nearPos, maxDist, asset != null ? asset.id : null);
+ }
+
private static IEnumerable EnumerateAliveUnits()
{
List prepared = World.world?.units?.units_only_alive;
diff --git a/IdleSpectator/default_config.json b/IdleSpectator/default_config.json
new file mode 100644
index 0000000..8612a6a
--- /dev/null
+++ b/IdleSpectator/default_config.json
@@ -0,0 +1,20 @@
+{
+ "Spectator": [
+ {
+ "Id": "enabled",
+ "Type": "SWITCH",
+ "BoolVal": true,
+ "Callback": "IdleSpectator.ModSettings:OnEnabledChanged"
+ },
+ {
+ "Id": "show_watch_reasons",
+ "Type": "SWITCH",
+ "BoolVal": true
+ },
+ {
+ "Id": "debug_state_probe",
+ "Type": "SWITCH",
+ "BoolVal": true
+ }
+ ]
+}
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index a4c1d91..5af44ce 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.3.0",
- "description": "AFK Idle Spectator (F8): follows wars, disasters, battles, creatures, and new species from fresh worlds to empires.",
+ "version": "0.3.9",
+ "description": "AFK Idle Spectator (I): 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 19e00af..1b70f0b 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director.
## Controls
-- **F8** - toggle Idle Spectator on/off
+- **I** - toggle Idle Spectator on/off
- Dragging the camera while Spectator is on turns it off (after a short grace period)
## What it follows
diff --git a/scripts/monitor-spectator.sh b/scripts/monitor-spectator.sh
new file mode 100755
index 0000000..fe69d17
--- /dev/null
+++ b/scripts/monitor-spectator.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# Live-monitor Idle Spectator focus / power-bar health from Player.log.
+# Usage: ./scripts/monitor-spectator.sh
+# Restart WorldBox after enabling debug_state_probe, press I, leave it running.
+set -euo pipefail
+
+LOG="${PLAYER_LOG:-$HOME/.config/unity3d/mkarpenko/WorldBox/Player.log}"
+
+if [[ ! -f "$LOG" ]]; then
+ echo "Waiting for Player.log at $LOG"
+ while [[ ! -f "$LOG" ]]; do sleep 1; done
+fi
+
+echo "Monitoring $LOG"
+echo "Looking for [IdleSpectator][STATE] / [IdleSpectator][BAD]"
+echo "Healthy: idle=True focus=True powerBar=False while spectating"
+echo "Bad: [BAD] lines, or powerBar=True while idle=True"
+echo
+
+bad_count=0
+state_count=0
+
+# Follow new lines; also print recent matching history once.
+rg -n "\[IdleSpectator\]\[(STATE|BAD)\]|Spectator mode (enabled|disabled)|Watching \[" "$LOG" | tail -30 || true
+echo "---- live ----"
+
+tail -n 0 -F "$LOG" | while IFS= read -r line; do
+ if [[ "$line" == *"[IdleSpectator][BAD]"* ]]; then
+ bad_count=$((bad_count + 1))
+ printf '\033[1;31m%s\033[0m\n' "$line"
+ printf '\033[1;31m ^^ BAD #%s\033[0m\n' "$bad_count"
+ elif [[ "$line" == *"[IdleSpectator][STATE]"* ]]; then
+ state_count=$((state_count + 1))
+ if [[ "$line" == *"idle=True"* && "$line" == *"powerBar=True"* ]]; then
+ printf '\033[1;31m%s\033[0m\n' "$line"
+ printf '\033[1;31m ^^ power bar visible while idle\033[0m\n'
+ elif [[ "$line" == *"idle=True"* && "$line" == *"focus=False"* ]]; then
+ printf '\033[1;33m%s\033[0m\n' "$line"
+ else
+ printf '\033[1;32m%s\033[0m\n' "$line"
+ fi
+ elif [[ "$line" == *"[IdleSpectator]"* ]]; then
+ echo "$line"
+ fi
+done