Good Start
This commit is contained in:
parent
0ba55d7c8c
commit
9bd1c084c7
14 changed files with 1201 additions and 25 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
41
IdleSpectator/CameraDirector.cs
Normal file
41
IdleSpectator/CameraDirector.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
111
IdleSpectator/InterestCollector.cs
Normal file
111
IdleSpectator/InterestCollector.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
211
IdleSpectator/InterestDirector.cs
Normal file
211
IdleSpectator/InterestDirector.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
19
IdleSpectator/InterestEvent.cs
Normal file
19
IdleSpectator/InterestEvent.cs
Normal 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));
|
||||
}
|
||||
18
IdleSpectator/InterestTier.cs
Normal file
18
IdleSpectator/InterestTier.cs
Normal 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
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
94
IdleSpectator/SpectatorMode.cs
Normal file
94
IdleSpectator/SpectatorMode.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
28
IdleSpectator/SubspeciesPatches.cs
Normal file
28
IdleSpectator/SubspeciesPatches.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
493
IdleSpectator/WorldActivityScanner.cs
Normal file
493
IdleSpectator/WorldActivityScanner.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
131
IdleSpectator/WorldLogInterestTable.cs
Normal file
131
IdleSpectator/WorldLogInterestTable.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
IdleSpectator/WorldLogPatches.cs
Normal file
12
IdleSpectator/WorldLogPatches.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
37
README.md
37
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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue