Kinda stable

This commit is contained in:
DazedAnon 2026-07-14 14:17:42 -05:00
parent 9bd1c084c7
commit f76788db69
17 changed files with 813 additions and 54 deletions

4
.gitignore vendored
View file

@ -48,3 +48,7 @@ desktop.ini
.dotnet/
.local/
*.decompiled.cs
# local test arm files
IdleSpectator/.auto-test
IdleSpectator/.force-on

105
IdleSpectator/AutoTest.cs Normal file
View file

@ -0,0 +1,105 @@
using System.IO;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// 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 <c>.auto-test</c>
/// file in the mod folder (deleted when finished).
/// </summary>
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");
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,20 @@
using HarmonyLib;
namespace IdleSpectator;
/// <summary>
/// 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).
/// </summary>
[HarmonyPatch(typeof(CanvasMain), nameof(CanvasMain.isBottomBarShowing))]
internal static class CanvasMainPatches
{
[HarmonyPostfix]
private static void KeepPowerBarHiddenWhileIdleSpectating(ref bool __result)
{
if (SpectatorMode.Active)
{
__result = false;
}
}
}

View file

@ -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;

View file

@ -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)"
}

View file

@ -1,3 +1,4 @@
using System.IO;
using HarmonyLib;
using NeoModLoader.api;
using NeoModLoader.services;
@ -6,13 +7,14 @@ using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Idle Spectator entry: F8 AFK camera director over WorldLog events.
/// Idle Spectator entry: I key AFK camera director over WorldLog events.
/// </summary>
public class ModClass : MonoBehaviour, IMod
public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
{
private ModDeclare _declare;
private GameObject _gameObject;
private Harmony _harmony;
private ModConfig _config;
/// <summary>Absolute path to this mod's folder (parent of mod.json).</summary>
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();
}
}

View file

@ -0,0 +1,74 @@
using System.IO;
using NeoModLoader.api;
using NeoModLoader.constants;
using NeoModLoader.services;
namespace IdleSpectator;
/// <summary>
/// NML ModConfig accessors for Idle Spectator.
/// </summary>
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;
}
/// <summary>NML config callback: IdleSpectator.ModSettings:OnEnabledChanged</summary>
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;
}
}

View file

@ -0,0 +1,121 @@
using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Captures subspecies creation during map gen / before spectator is on,
/// then drains a few as Curiosity once the world is playable.
/// </summary>
public static class SpeciesDiscovery
{
private const float DrainIntervalSeconds = 12f;
private const int MaxBuffered = 24;
private const int MaxDrainPerSession = 8;
private static readonly Queue<PendingSpecies> Pending = new Queue<PendingSpecies>();
private static readonly HashSet<string> SeenAssets = new HashSet<string>();
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})");
}
}

View file

@ -4,11 +4,11 @@ using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// ManualControl vs SpectatorMode toggle (F8).
/// ManualControl vs SpectatorMode toggle (I).
/// </summary>
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
/// </summary>
private static void TryForceOnFile()
{
if (_forceFileChecked || Active)
if (_forceFileChecked || Active || !ModSettings.Enabled)
{
return;
}

161
IdleSpectator/StateProbe.cs Normal file
View file

@ -0,0 +1,161 @@
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Samples focus / spectator / power-bar state and logs only on changes or faults.
/// Tail Player.log for <c>[IdleSpectator][STATE]</c> and <c>[IdleSpectator][BAD]</c>.
/// </summary>
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";
}
}

View file

@ -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<string, float> LastLiveEnqueuedAt = new Dictionary<string, float>();
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);
}
}

View file

@ -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)
/// <summary>
/// Nearest living unit, optionally restricted to a specific actor asset id.
/// </summary>
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<Actor> EnumerateAliveUnits()
{
List<Actor> prepared = World.world?.units?.units_only_alive;

View file

@ -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
}
]
}

View file

@ -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"
}

View file

@ -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

45
scripts/monitor-spectator.sh Executable file
View file

@ -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