Good Start

This commit is contained in:
DazedAnon 2026-07-14 13:20:49 -05:00
parent 0ba55d7c8c
commit 9bd1c084c7
14 changed files with 1201 additions and 25 deletions

3
.gitignore vendored
View file

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

View file

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

View file

@ -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<InterestEvent> Pending = new List<InterestEvent>();
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();
}
}
}

View file

@ -0,0 +1,211 @@
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Picks the next interesting target using dwell, cooldown, and tier interrupts.
/// </summary>
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);
}
}

View file

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

View file

@ -0,0 +1,18 @@
namespace IdleSpectator;
/// <summary>
/// Higher value can interrupt lower after interrupt grace.
/// </summary>
public enum InterestTier
{
/// <summary>Filler rotation when nothing else is happening.</summary>
Ambient = 0,
/// <summary>Low priority curiosities (new species, oddities).</summary>
Curiosity = 1,
/// <summary>Live fights, high-kill creatures, action clusters.</summary>
Action = 2,
/// <summary>Leadership / politics / favorites.</summary>
Story = 3,
/// <summary>World-shaping beats (wars, kingdom falls, disasters).</summary>
Epic = 4
}

View file

@ -1,17 +1,21 @@
using UnityEngine;
using HarmonyLib;
using NeoModLoader.api;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// 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.
/// </summary>
public class ModClass : MonoBehaviour, IMod
{
private ModDeclare _declare;
private GameObject _gameObject;
private Harmony _harmony;
/// <summary>Absolute path to this mod's folder (parent of mod.json).</summary>
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();
}
}

View file

@ -0,0 +1,94 @@
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// ManualControl vs SpectatorMode toggle (F8).
/// </summary>
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();
}
}
/// <summary>
/// Optional one-shot: place an empty <c>.force-on</c> file in the mod folder to enable Spectator once (deleted after use).
/// </summary>
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);
}
}

View file

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

View file

@ -0,0 +1,493 @@
using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Scores live world activity across civs and wild mobs (wiki-aligned).
/// Throttled to ~2 Hz while Spectator is active.
/// </summary>
public static class WorldActivityScanner
{
private static readonly HashSet<string> SpectacleAssets = new HashSet<string>
{
"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<BattleContainer> 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<string, int> speciesCounts = new Dictionary<string, int>();
List<Actor> alive = new List<Actor>(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<Actor> nearTies = new List<Actor>();
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<string, int> 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<string, int> 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<string, int> 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<Actor> EnumerateAliveUnits()
{
List<Actor> 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
};
}
}

View file

@ -0,0 +1,131 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Centralized WorldLog asset ids and tier mapping.
/// </summary>
public static class WorldLogInterestTable
{
private static readonly Dictionary<string, InterestTier> Tiers = new Dictionary<string, InterestTier>
{
// 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;
}
}
}

View file

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

View file

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

View file

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