Base chronicler

This commit is contained in:
DazedAnon 2026-07-14 15:55:22 -05:00
parent db5f45a00f
commit 43cc098ac9
20 changed files with 1407 additions and 54 deletions

View file

@ -4,7 +4,7 @@ description: >-
Run IdleSpectator WorldBox E2E tests via the in-mod agent harness
(scripts/harness-run.sh, critical_smoke, regression suite, .harness commands).
Use when adding or fixing IdleSpectator features, playtesting the mod,
writing harness scenarios, or verifying spectator/focus/tip/settings behavior.
writing harness scenarios, or verifying spectator/focus/tip/settings/chronicle behavior.
---
# IdleSpectator E2E harness
@ -13,9 +13,20 @@ Always validate IdleSpectator changes with the in-game harness.
Do not rely on OS mouse/keyboard automation (Wayland-flaky).
Do not call `Application.Quit()` (can hang Steam on Linux).
## WorldBox wiki (mechanics source of truth)
When deciding what is “interesting” for Spectator or Chronicle, **read the official wiki** before inventing behavior:
- Hub: https://the-official-worldbox-wiki.fandom.com/wiki/The_Official_Worldbox_Wiki
- Useful pages: [World History](https://the-official-worldbox-wiki.fandom.com/wiki/World_History), [Units](https://the-official-worldbox-wiki.fandom.com/wiki/Units) (lovers/best friends), [Unit Stats](https://the-official-worldbox-wiki.fandom.com/wiki/Unit_Stats), [Kings and Leaders](https://the-official-worldbox-wiki.fandom.com/wiki/Kings_and_Leaders), [Creature Traits](https://the-official-worldbox-wiki.fandom.com/wiki/Creature_Traits), [War](https://the-official-worldbox-wiki.fandom.com/wiki/War), [Disasters](https://the-official-worldbox-wiki.fandom.com/wiki/Disasters), [Ages](https://the-official-worldbox-wiki.fandom.com/wiki/Ages)
Chronicle = **notable life/world events** (kills, lovers, crowning, wars), not “what Idle Spectator is staring at.”
Watch caption = live AFK context for the current focus.
Vanilla `Tooltip` / `TooltipLibrary` (`tooltips/tooltip_actor`, etc.) may be reusable for polish later.
## Mandatory workflow
After any feature or bugfix in `IdleSpectator/`:
After any feature or bug fix in `IdleSpectator/`:
1. Bump `IdleSpectator/mod.json` version (NML recompiles on game load).
2. Extend or add a scenario in `IdleSpectator/HarnessScenarios.cs` when the change is new behavior.
@ -23,7 +34,7 @@ After any feature or bugfix in `IdleSpectator/`:
```bash
./scripts/harness-run.sh --repeat 3 critical_smoke
```
4. After larger changes (director, discovery, settings, input, tips), also run:
4. After larger changes (director, discovery, settings, input, tips, chronicle), also run:
```bash
./scripts/harness-run.sh regression
```
@ -32,19 +43,16 @@ After any feature or bugfix in `IdleSpectator/`:
7. Report: scenario name(s), PASS/FAIL counts, and any new asserts.
For tiny doc-only edits, harness is optional.
For anything that touches camera, spectator, tips, settings, discovery, or input exit, harness is required.
For anything that touches camera, spectator, tips, settings, discovery, chronicle, or input exit, harness is required.
## Runner
```bash
# Default: critical_smoke (starts WorldBox if needed)
./scripts/harness-run.sh
./scripts/harness-run.sh critical_smoke
./scripts/harness-run.sh --repeat 3 critical_smoke
./scripts/harness-run.sh regression # full suite
./scripts/harness-run.sh tip_match
./scripts/harness-run.sh smoke
./scripts/harness-run.sh regression
./scripts/harness-run.sh chronicle_smoke
./scripts/harness-run.sh --no-launch critical_smoke
./scripts/harness-run.sh --cmd '{"id":"1","action":"snapshot"}'
```
@ -63,16 +71,8 @@ Paths:
- Inbox/results: `IdleSpectator/.harness/` (gitignored)
- Log: `~/.config/unity3d/mkarpenko/WorldBox/Player.log`
The runner:
- Starts Steam/WorldBox when not running
- Touches `.harness/reset` + rewinds offset so commands are re-read
- Enables `.harness/auto-dismiss` (closes Welcome Back)
- Waits for `last-result.json` status `PASS` / `FAIL`
- `regression` runs each named scenario sequentially (stop on first fail)
The runner starts Steam/WorldBox when needed, touches `.harness/reset` + rewinds offset, enables auto-dismiss, waits for `last-result.json`.
A loaded world is still required.
`wait_world` + auto-dismiss handle Welcome Back and empty maps.
## Built-in scenarios
@ -86,20 +86,19 @@ A loaded world is still required.
| `director_tiers` | Curiosity accept, Action interrupt, curiosity blocked by Action |
| `discovery` | Present dedupe + force drain + tip match |
| `world_log` | Story/Epic interest interrupt via collector |
| `chronicle_smoke` | Life-event chronicle inject + HUD open/jump (not watch-notes) |
| `smoke` | Short enable/focus health check |
| `tip_match` | Tip asset must match focused unit |
| `regression` | Shell suite of all gate scenarios above |
Out of gate (too flaky / environmental): real battles, map-gen discovery, OS hotkey `I`.
Prefer `pick_unit` (living map unit) over miracle `spawn` in critical paths.
Spawned crabs/sheep often die on bad tiles and flake tests.
Out of gate: real battles, map-gen discovery, OS hotkey `I`.
Prefer `pick_unit` over miracle `spawn` in critical paths.
## Timing note
Director and discovery use `Time.unscaledTime`.
Game speed 5x does **not** shorten harness waits.
Use `fast_timing` in scenarios (and/or `director_run`) instead.
Use `fast_timing` / `director_run` instead.
## Adding coverage for a new fix
@ -107,42 +106,33 @@ Use `fast_timing` in scenarios (and/or `director_run`) instead.
2. Register new scenarios in `HarnessScenarios.Build` and in `REGRESSION_SCENARIOS` inside `scripts/harness-run.sh`.
3. Use `asset=auto` / `label="New species: {asset}"` after `pick_unit`.
4. Keep InterestDirector frozen while `AgentHarness.FreezeDirector` (Busy unless `director_run`).
5. `wait_world` must stay front-of-queue on retry (never enqueue behind later steps).
5. `wait_world` must stay front-of-queue on retry.
## Command cheat sheet
JSONL lines in `.harness/commands.jsonl` (also used by scenarios):
- `wait_world`, `dismiss_windows`, `wait`, `director_run`, `fast_timing`
- `age_current`, `expire_grace`, `remember_focus`
- `spectator` (`value=on|off`, optional `expect=blocked`)
- `pick_unit`, `spawn`, `focus`, `watch`, `watch_ghost`, `trigger_interest`
- `simulate_input` (`expect=grace` to assert grace hold)
- `set_setting` (`expect=enabled|show_watch_reasons`, `value=true|false`)
- `set_setting` (`expect=enabled|show_watch_reasons|show_dossier_caption|chronicle_enabled`)
- `discovery_reset`, `discovery_note`, `discovery_mark`, `discovery_drain`
- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled`, `show_watch_reasons`, `current_tier`, `would_accept_curiosity`, `focus_same`, `presented`, `pending_discovery`, `pending_interest`, `in_grace`
- `snapshot`, `reset_counters` (StateProbe only; `value=all` also clears harness counters), `scenario`
Machine output:
- `[IdleSpectator][HARNESS]` / `[ASSERT]` / `[SNAP]` in Player.log
- `IdleSpectator/.harness/last-result.json`
- `IdleSpectator/.harness/results.jsonl`
- `chronicle_force`, `chronicle_open`, `chronicle_jump`
- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled`, `show_watch_reasons`, `current_tier`, `would_accept_curiosity`, `focus_same`, `presented`, `pending_discovery`, `pending_interest`, `in_grace`, `dossier_contains`, `caption_contains`, `chronicle_count`, `chronicle_latest_contains`
- `snapshot`, `reset_counters`, `scenario`
## Process safety
- Kill only the real game binary (`.../common/worldbox/worldbox`), never `pkill -f` patterns that match the agent shell.
- Prefer `./scripts/harness-run.sh` for launch; avoid casual `Application.Quit`.
- After code changes, restart/relaunch so NML recompiles (version bump helps confirm load).
- Kill only the real game binary (`.../common/worldbox/worldbox`).
- Prefer `./scripts/harness-run.sh`; avoid `Application.Quit`.
- After code changes, restart/relaunch so NML recompiles.
## Definition of done
A change is done only when:
- Relevant harness scenario(s) PASS
- For core spectator/focus/tip/settings work: `critical_smoke` passes **3 times** (`--repeat 3`)
- For larger director/discovery/input/settings work: `regression` PASS
- Failures were fixed by hardening the mod or scenario, not by lowering asserts
- Core work: `critical_smoke` passes **3 times**
- Larger work: `regression` PASS
- Do not lower asserts to greenwash flakes
## More detail

View file

@ -1,5 +1,13 @@
# IdleSpectator harness reference
## WorldBox wiki
Use https://the-official-worldbox-wiki.fandom.com/wiki/The_Official_Worldbox_Wiki when designing Spectator priorities or Chronicle events (Units lovers/friends, World History, kings, traits, war, disasters).
Chronicle records **life/world events**, not spectator watch notes.
Watch caption covers live focus context.
Vanilla tooltips (`Tooltip`, `TooltipLibrary`, `tooltips/tooltip_actor`) are candidates for future UI polish.
## Architecture
```text

View file

@ -0,0 +1,36 @@
using HarmonyLib;
namespace IdleSpectator;
/// <summary>
/// Feeds Chronicle from real creature life events (kills, lovers, best friends).
/// </summary>
[HarmonyPatch]
public static class ActorChroniclePatches
{
[HarmonyPatch(typeof(Actor), "newKillAction")]
[HarmonyPostfix]
public static void PostfixNewKill(Actor __instance, Actor pDeadUnit)
{
Chronicle.NoteKill(__instance, pDeadUnit);
}
[HarmonyPatch(typeof(Actor), nameof(Actor.becomeLoversWith))]
[HarmonyPostfix]
public static void PostfixLovers(Actor __instance, Actor pTarget)
{
Chronicle.NoteLovers(__instance, pTarget);
}
[HarmonyPatch(typeof(Actor), nameof(Actor.setBestFriend))]
[HarmonyPostfix]
public static void PostfixBestFriend(Actor __instance, Actor pActor, bool pNew)
{
if (!pNew)
{
return;
}
Chronicle.NoteBestFriends(__instance, pActor);
}
}

View file

@ -312,6 +312,57 @@ public static class AgentHarness
DoWatchGhost(cmd);
break;
case "chronicle_force":
case "chronicle_note":
{
bool ok = Chronicle.ForceNoteCurrentFocus(
string.IsNullOrEmpty(cmd.label) ? "Slew" : cmd.label);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: $"chronicle_count={Chronicle.Count} caption={WatchCaption.LastHeadline}");
break;
}
case "chronicle_open":
{
Chronicle.ShowHud();
bool open = Chronicle.HudVisible;
if (open)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, open, detail: $"hud={open}");
break;
}
case "chronicle_jump":
{
bool ok = Chronicle.JumpToLatest();
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: $"jumped={ok} focus={MoveCamera.hasFocusUnit()}");
break;
}
case "spawn":
DoSpawn(cmd);
break;
@ -917,6 +968,58 @@ public static class AgentHarness
detail = $"in_grace={have} want={want}";
break;
}
case "dossier_contains":
case "caption_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
if (needle == "auto" || string.IsNullOrEmpty(needle))
{
needle = _lastSpawnedAssetId;
}
string caption = WatchCaption.LastCaptionText ?? "";
UnitDossier dossier = WatchCaption.Current;
bool dossierHit = dossier != null && dossier.ContainsIgnoreCase(needle);
bool captionHit = !string.IsNullOrEmpty(needle)
&& caption.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
pass = dossierHit || captionHit;
detail = $"needle='{needle}' caption='{caption.Replace("\n", " | ")}' dossier={(dossier != null)}";
break;
}
case "chronicle_count":
{
int want = ParseCountExpect(cmd, defaultValue: 1);
int have = Chronicle.Count;
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
if (cmp == "min" || cmp == ">=" || string.IsNullOrEmpty(cmp))
{
pass = have >= want;
detail = $"count={have} min={want}";
}
else
{
pass = have == want;
detail = $"count={have} want={want}";
}
break;
}
case "chronicle_latest_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
if (needle == "auto")
{
needle = _lastSpawnedAssetId;
}
ChronicleEntry latest = Chronicle.Latest;
string line = latest != null ? latest.Line : "";
pass = latest != null
&& !string.IsNullOrEmpty(needle)
&& line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
detail = $"latest='{line}' needle='{needle}'";
break;
}
default:
pass = false;
detail = "unknown expect: " + expect;
@ -942,7 +1045,7 @@ public static class AgentHarness
private static void DoSnapshot(HarnessCommand cmd)
{
string snap =
$"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount} tier={InterestDirector.CurrentTierName} discoveryPending={SpeciesDiscovery.PendingCount}";
$"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount} tier={InterestDirector.CurrentTierName} discoveryPending={SpeciesDiscovery.PendingCount} caption={WatchCaption.LastHeadline} chronicle={Chronicle.Count}";
_cmdOk++;
Emit(cmd, ok: true, detail: snap);
LogService.LogInfo("[IdleSpectator][SNAP] " + snap);

View file

@ -13,6 +13,10 @@ public static class CameraDirector
if (actor != null && actor.isAlive())
{
RetargetFollow(actor);
if (SpectatorMode.Active)
{
WatchCaption.SetFromActor(actor);
}
}
}
@ -31,12 +35,13 @@ public static class CameraDirector
string tip = FormatWatchTip(interest);
LastWatchLabel = interest.Label ?? "";
LastWatchAssetId = interest.AssetId ?? "";
if (ModSettings.ShowWatchReasons)
if (ModSettings.ShowWatchReasons && !ModSettings.ShowDossierCaption)
{
WorldTip.showNowTop(tip, pTranslate: false);
}
LogService.LogInfo($"[IdleSpectator] {tip}");
WatchCaption.SetFromInterest(interest);
Actor follow = interest.HasFollowUnit
? interest.FollowUnit
@ -96,6 +101,11 @@ public static class CameraDirector
MoveCamera.clearFocusUnitOnly();
}
public static void PanTo(Vector3 pos)
{
PanCamera(pos);
}
private static void RetargetFollow(Actor actor)
{
if (MoveCamera.isCameraFollowingUnit(actor))

372
IdleSpectator/Chronicle.cs Normal file
View file

@ -0,0 +1,372 @@
using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
public enum ChronicleKind
{
Kill,
Lover,
Friend,
World,
Other
}
public sealed class ChronicleEntry
{
public float CreatedAt;
public ChronicleKind Kind;
public long UnitId;
public string Name = "";
public string SpeciesId = "";
public string Line = "";
public Vector3 Position;
}
/// <summary>
/// Session ring buffer of notable life/world events (kills, lovers, WorldLog), not spectator watch notes.
/// </summary>
public static class Chronicle
{
public const KeyCode ToggleKey = KeyCode.F9;
private const int MaxEntries = 300;
private static readonly List<ChronicleEntry> Entries = new List<ChronicleEntry>();
private static readonly HashSet<string> LoverPairKeys = new HashSet<string>();
private static readonly HashSet<string> FriendPairKeys = new HashSet<string>();
private static bool _hudReady;
public static int Count
{
get
{
lock (Entries)
{
return Entries.Count;
}
}
}
public static bool HudVisible => ChronicleHud.Visible;
public static IReadOnlyList<ChronicleEntry> Snapshot()
{
lock (Entries)
{
return Entries.ToArray();
}
}
public static ChronicleEntry Latest
{
get
{
lock (Entries)
{
return Entries.Count == 0 ? null : Entries[Entries.Count - 1];
}
}
}
public static void EnsureWindow()
{
if (!ModSettings.ChronicleEnabled)
{
return;
}
if (_hudReady && ChronicleHud.IsReady)
{
return;
}
try
{
ChronicleHud.EnsureBuilt();
if (ChronicleHud.IsReady)
{
_hudReady = true;
LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, life-event side panel)");
}
}
catch (System.Exception ex)
{
LogService.LogInfo("[IdleSpectator] Chronicle HUD init failed: " + ex.Message);
}
}
public static void ClearSession()
{
lock (Entries)
{
Entries.Clear();
}
LoverPairKeys.Clear();
FriendPairKeys.Clear();
}
public static void PollInput()
{
if (!Config.game_loaded || !ModSettings.ChronicleEnabled)
{
return;
}
EnsureWindow();
if (Input.GetKeyDown(ToggleKey))
{
ToggleWindow();
}
}
public static void ToggleWindow()
{
if (!ModSettings.ChronicleEnabled)
{
return;
}
EnsureWindow();
ChronicleHud.Toggle();
}
public static void ShowHud()
{
EnsureWindow();
ChronicleHud.SetVisible(true);
}
public static void HideHud()
{
ChronicleHud.SetVisible(false);
}
public static bool JumpToLatest()
{
return JumpTo(Latest);
}
public static bool JumpTo(ChronicleEntry entry)
{
if (entry == null)
{
return false;
}
Actor unit = FindUnitById(entry.UnitId);
if (unit != null && unit.isAlive())
{
CameraDirector.FocusUnit(unit);
WatchCaption.SetFromActor(unit);
return true;
}
if (entry.Position != Vector3.zero)
{
CameraDirector.PanTo(entry.Position);
return true;
}
return false;
}
public static void Update()
{
if (!ModSettings.ChronicleEnabled || !Config.game_loaded || World.world == null)
{
return;
}
ChronicleHud.UpdateLive();
}
public static void NoteKill(Actor killer, Actor victim)
{
if (!ModSettings.ChronicleEnabled || killer == null || victim == null)
{
return;
}
string killerName = SafeName(killer);
string victimName = SafeName(victim);
string killerSpecies = SpeciesOf(killer);
string victimSpecies = SpeciesOf(victim);
Append(
ChronicleKind.Kill,
killer,
$"Slew: {killerName} ({killerSpecies}) felled {victimName} ({victimSpecies})");
}
public static void NoteLovers(Actor a, Actor b)
{
if (!ModSettings.ChronicleEnabled || a == null || b == null)
{
return;
}
string key = PairKey(a.getID(), b.getID());
if (!LoverPairKeys.Add(key))
{
return;
}
Append(
ChronicleKind.Lover,
a,
$"Lovers: {SafeName(a)} and {SafeName(b)}");
}
public static void NoteBestFriends(Actor a, Actor b)
{
if (!ModSettings.ChronicleEnabled || a == null || b == null)
{
return;
}
string key = PairKey(a.getID(), b.getID());
if (!FriendPairKeys.Add(key))
{
return;
}
Append(
ChronicleKind.Friend,
a,
$"Friends: {SafeName(a)} befriended {SafeName(b)}");
}
public static void NoteWorldLog(WorldLogMessage message, string label)
{
if (!ModSettings.ChronicleEnabled || message == null || string.IsNullOrEmpty(label))
{
return;
}
Actor unit = null;
try
{
if (message.hasFollowLocation())
{
unit = message.unit;
}
}
catch
{
// ignore
}
Vector3 pos = Vector3.zero;
try
{
pos = message.getLocation();
}
catch
{
// ignore
}
Append(
ChronicleKind.World,
unit,
$"World: {label}",
fallbackPosition: pos);
}
/// <summary>Harness: inject a life-event style line for the current focus.</summary>
public static bool ForceNoteCurrentFocus(string prefix = "Slew")
{
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
{
return false;
}
Actor unit = MoveCamera._focus_unit;
UnitDossier dossier = UnitDossier.FromActor(unit);
string kindPrefix = string.IsNullOrEmpty(prefix) ? "Slew" : prefix;
Append(
ChronicleKind.Other,
unit,
$"{kindPrefix}: {dossier.Name} ({dossier.SpeciesId}) - life event");
return true;
}
private static void Append(ChronicleKind kind, Actor unit, string line, Vector3 fallbackPosition = default)
{
if (string.IsNullOrEmpty(line))
{
return;
}
ChronicleEntry entry = new ChronicleEntry
{
CreatedAt = Time.unscaledTime,
Kind = kind,
UnitId = unit != null ? unit.getID() : 0,
Name = unit != null ? SafeName(unit) : "",
SpeciesId = unit != null ? SpeciesOf(unit) : "",
Line = line,
Position = unit != null ? unit.current_position : fallbackPosition
};
lock (Entries)
{
Entries.Add(entry);
while (Entries.Count > MaxEntries)
{
Entries.RemoveAt(0);
}
}
LogService.LogInfo("[IdleSpectator][CHRONICLE] " + line);
}
private static string PairKey(long a, long b)
{
long lo = a < b ? a : b;
long hi = a < b ? b : a;
return lo + ":" + hi;
}
private static Actor FindUnitById(long id)
{
if (id == 0)
{
return null;
}
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{
if (actor != null && actor.getID() == id && actor.isAlive())
{
return actor;
}
}
return null;
}
private static string SpeciesOf(Actor actor)
{
return actor?.asset != null ? actor.asset.id : "creature";
}
private static string SafeName(Actor actor)
{
try
{
string name = actor.getName();
if (!string.IsNullOrEmpty(name))
{
return name;
}
}
catch
{
// ignore
}
return SpeciesOf(actor);
}
}

View file

@ -0,0 +1,341 @@
using System.Collections.Generic;
using NeoModLoader.General;
using NeoModLoader.services;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Non-modal right-side chronicle HUD. Works with focus/spectator - does not open a ScrollWindow.
/// Styled as a compact event feed (inspired by vanilla tip/tooltip denseness, not a modal unit sheet).
/// </summary>
public static class ChronicleHud
{
private const float PanelWidth = 300f;
private const int MaxVisibleRows = 14;
private static GameObject _root;
private static RectTransform _content;
private static Text _header;
private static readonly List<GameObject> Rows = new List<GameObject>();
private static bool _visible;
private static int _lastCount = -1;
public static bool Visible => _visible && _root != null && _root.activeSelf;
public static bool IsReady => _root != null;
public static void EnsureBuilt()
{
if (_root != null)
{
return;
}
if (!Config.game_loaded && CanvasMain.instance == null)
{
return;
}
Canvas canvas = ResolveCanvas();
if (canvas == null)
{
return;
}
_root = new GameObject("IdleSpectatorChronicleHud", typeof(RectTransform), typeof(Image), typeof(CanvasGroup));
_root.transform.SetParent(canvas.transform, false);
RectTransform rt = _root.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(1f, 0.5f);
rt.anchorMax = new Vector2(1f, 0.5f);
rt.pivot = new Vector2(1f, 0.5f);
rt.sizeDelta = new Vector2(PanelWidth, 440f);
rt.anchoredPosition = new Vector2(-10f, 0f);
Image bg = _root.GetComponent<Image>();
bg.color = new Color(0.05f, 0.06f, 0.08f, 0.82f);
bg.raycastTarget = true;
CanvasGroup group = _root.GetComponent<CanvasGroup>();
group.blocksRaycasts = true;
group.interactable = true;
_header = MakeText(_root.transform, "Header", "Chronicle · F9", 12);
RectTransform headerRt = _header.GetComponent<RectTransform>();
headerRt.anchorMin = new Vector2(0f, 1f);
headerRt.anchorMax = new Vector2(1f, 1f);
headerRt.pivot = new Vector2(0.5f, 1f);
headerRt.sizeDelta = new Vector2(-16f, 24f);
headerRt.anchoredPosition = new Vector2(0f, -8f);
_header.fontStyle = FontStyle.Bold;
_header.alignment = TextAnchor.MiddleLeft;
_header.color = new Color(0.92f, 0.9f, 0.82f, 1f);
Text sub = MakeText(_root.transform, "Sub", "Life & world events · click to jump", 9);
RectTransform subRt = sub.GetComponent<RectTransform>();
subRt.anchorMin = new Vector2(0f, 1f);
subRt.anchorMax = new Vector2(1f, 1f);
subRt.pivot = new Vector2(0.5f, 1f);
subRt.sizeDelta = new Vector2(-16f, 16f);
subRt.anchoredPosition = new Vector2(0f, -30f);
sub.alignment = TextAnchor.MiddleLeft;
sub.color = new Color(0.65f, 0.68f, 0.72f, 1f);
GameObject scrollGo = new GameObject("Scroll", typeof(RectTransform), typeof(Image), typeof(ScrollRect), typeof(Mask));
scrollGo.transform.SetParent(_root.transform, false);
RectTransform scrollRt = scrollGo.GetComponent<RectTransform>();
scrollRt.anchorMin = new Vector2(0f, 0f);
scrollRt.anchorMax = new Vector2(1f, 1f);
scrollRt.offsetMin = new Vector2(8f, 10f);
scrollRt.offsetMax = new Vector2(-8f, -50f);
Image scrollBg = scrollGo.GetComponent<Image>();
scrollBg.color = new Color(0f, 0f, 0f, 0.08f);
Mask mask = scrollGo.GetComponent<Mask>();
mask.showMaskGraphic = false;
GameObject contentGo = new GameObject("Content", typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter));
contentGo.transform.SetParent(scrollGo.transform, false);
_content = contentGo.GetComponent<RectTransform>();
_content.anchorMin = new Vector2(0f, 1f);
_content.anchorMax = new Vector2(1f, 1f);
_content.pivot = new Vector2(0.5f, 1f);
_content.anchoredPosition = Vector2.zero;
_content.sizeDelta = new Vector2(0f, 0f);
VerticalLayoutGroup layout = contentGo.GetComponent<VerticalLayoutGroup>();
layout.childAlignment = TextAnchor.UpperCenter;
layout.childControlHeight = false;
layout.childControlWidth = true;
layout.childForceExpandHeight = false;
layout.childForceExpandWidth = true;
layout.spacing = 2f;
layout.padding = new RectOffset(0, 0, 0, 0);
ContentSizeFitter fitter = contentGo.GetComponent<ContentSizeFitter>();
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
ScrollRect scroll = scrollGo.GetComponent<ScrollRect>();
scroll.content = _content;
scroll.horizontal = false;
scroll.vertical = true;
scroll.movementType = ScrollRect.MovementType.Clamped;
scroll.viewport = scrollRt;
_root.SetActive(false);
_visible = false;
LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, non-modal side panel)");
}
public static void SetVisible(bool visible)
{
EnsureBuilt();
if (_root == null)
{
return;
}
_visible = visible;
_root.SetActive(visible);
if (visible)
{
Rebuild(force: true);
}
}
public static void Toggle()
{
SetVisible(!Visible);
}
public static void UpdateLive()
{
if (!Visible)
{
return;
}
if (Chronicle.Count != _lastCount)
{
Rebuild(force: true);
}
}
public static void Rebuild(bool force = false)
{
EnsureBuilt();
if (_content == null || (!force && !Visible))
{
return;
}
ClearRows();
IReadOnlyList<ChronicleEntry> entries = Chronicle.Snapshot();
_lastCount = entries.Count;
if (entries.Count == 0)
{
GameObject empty = BuildStaticRow("Waiting for life events...");
empty.transform.SetParent(_content, false);
Rows.Add(empty);
return;
}
int shown = 0;
for (int i = entries.Count - 1; i >= 0; i--)
{
GameObject row = BuildClickableRow(entries[i], shown);
row.transform.SetParent(_content, false);
Rows.Add(row);
shown++;
if (shown >= MaxVisibleRows)
{
break;
}
}
}
private static void ClearRows()
{
foreach (GameObject row in Rows)
{
if (row != null)
{
Object.Destroy(row);
}
}
Rows.Clear();
}
private static GameObject BuildStaticRow(string text)
{
GameObject go = new GameObject("Empty", typeof(RectTransform), typeof(LayoutElement));
RectTransform rt = go.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(PanelWidth - 24f, 26f);
LayoutElement le = go.GetComponent<LayoutElement>();
le.minHeight = 26f;
le.preferredHeight = 26f;
Text label = MakeText(go.transform, "Text", text, 9);
Stretch(label.GetComponent<RectTransform>(), 4f);
label.alignment = TextAnchor.MiddleLeft;
label.color = new Color(0.7f, 0.72f, 0.75f, 0.95f);
return go;
}
private static GameObject BuildClickableRow(ChronicleEntry entry, int index)
{
GameObject go = new GameObject($"Row_{index}", typeof(RectTransform), typeof(Image), typeof(Button), typeof(LayoutElement));
RectTransform rt = go.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(PanelWidth - 24f, 32f);
LayoutElement le = go.GetComponent<LayoutElement>();
le.minHeight = 32f;
le.preferredHeight = 32f;
Image bg = go.GetComponent<Image>();
bg.color = new Color(0.12f, 0.13f, 0.16f, 0.55f);
Text label = MakeText(go.transform, "Text", Truncate(entry.Line, 58), 9);
Stretch(label.GetComponent<RectTransform>(), 6f);
label.alignment = TextAnchor.MiddleLeft;
label.color = ColorForKind(entry.Kind);
Button button = go.GetComponent<Button>();
ColorBlock colors = button.colors;
colors.normalColor = Color.white;
colors.highlightedColor = new Color(1.15f, 1.15f, 1.2f, 1f);
colors.pressedColor = new Color(0.85f, 0.85f, 0.9f, 1f);
button.colors = colors;
ChronicleEntry captured = entry;
button.onClick.AddListener(new UnityAction(() =>
{
bool ok = Chronicle.JumpTo(captured);
LogService.LogInfo($"[IdleSpectator][CHRONICLE] jump ok={ok} line={captured.Line}");
}));
return go;
}
private static Color ColorForKind(ChronicleKind kind)
{
switch (kind)
{
case ChronicleKind.Kill:
return new Color(0.95f, 0.55f, 0.45f, 1f);
case ChronicleKind.Lover:
return new Color(0.95f, 0.55f, 0.75f, 1f);
case ChronicleKind.Friend:
return new Color(0.55f, 0.85f, 0.95f, 1f);
case ChronicleKind.World:
return new Color(0.95f, 0.85f, 0.45f, 1f);
default:
return new Color(0.88f, 0.88f, 0.9f, 1f);
}
}
private static Canvas ResolveCanvas()
{
if (CanvasMain.instance != null)
{
if (CanvasMain.instance.canvas_ui != null)
{
return CanvasMain.instance.canvas_ui;
}
if (CanvasMain.instance.canvas_tooltip != null)
{
return CanvasMain.instance.canvas_tooltip;
}
}
return Object.FindObjectOfType<Canvas>();
}
private static Text MakeText(Transform parent, string name, string value, int size)
{
Text text = new GameObject(name, typeof(RectTransform), typeof(Text)).GetComponent<Text>();
text.transform.SetParent(parent, false);
try
{
OT.InitializeCommonText(text);
}
catch
{
text.font = Resources.GetBuiltinResource<Font>("Arial.ttf");
}
text.text = value ?? "";
text.fontSize = size;
text.resizeTextForBestFit = true;
text.resizeTextMinSize = 8;
text.resizeTextMaxSize = size;
text.color = Color.white;
text.raycastTarget = false;
text.horizontalOverflow = HorizontalWrapMode.Wrap;
text.verticalOverflow = VerticalWrapMode.Truncate;
return text;
}
private static void Stretch(RectTransform rt, float pad)
{
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = new Vector2(pad, 2f);
rt.offsetMax = new Vector2(-pad, -2f);
}
private static string Truncate(string s, int max)
{
if (string.IsNullOrEmpty(s) || s.Length <= max)
{
return s ?? "";
}
return s.Substring(0, max - 3) + "...";
}
}

View file

@ -32,6 +32,9 @@ internal static class HarnessScenarios
return Discovery();
case "world_log":
return WorldLog();
case "chronicle":
case "chronicle_smoke":
return ChronicleSmoke();
case "regression":
case "full":
return Regression();
@ -56,6 +59,7 @@ internal static class HarnessScenarios
Nested("reg_director", "director_tiers"),
Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
Nested("reg_chronicle", "chronicle_smoke"),
Step("reg_end", "fast_timing", value: "false"),
Step("reg_snap", "snapshot"),
};
@ -384,6 +388,40 @@ internal static class HarnessScenarios
};
}
private static List<HarnessCommand> ChronicleSmoke()
{
return new List<HarnessCommand>
{
Step("ch0", "dismiss_windows"),
Step("ch1", "wait_world"),
Step("ch2", "set_setting", expect: "enabled", value: "true"),
Step("ch3", "set_setting", expect: "show_dossier_caption", value: "true"),
Step("ch4", "set_setting", expect: "chronicle_enabled", value: "true"),
Step("ch5", "pick_unit", asset: "auto"),
Step("ch6", "spectator", value: "off"),
Step("ch7", "spectator", value: "on"),
Step("ch8", "focus", asset: "auto"),
Step("ch9", "wait", wait: 0.35f),
Step("ch10", "assert", expect: "health"),
Step("ch11", "assert", expect: "dossier_contains", value: "auto"),
Step("ch12", "assert", expect: "caption_contains", value: "auto"),
Step("ch13", "chronicle_force", label: "Slew"),
Step("ch14", "assert", expect: "chronicle_count", value: "1", label: "min"),
Step("ch15", "assert", expect: "chronicle_latest_contains", value: "Slew"),
Step("ch15b", "assert", expect: "chronicle_latest_contains", value: "auto"),
Step("ch16", "chronicle_open"),
Step("ch17", "wait", wait: 0.25f),
Step("ch18", "assert", expect: "focus", value: "true"),
Step("ch19", "assert", expect: "health"),
Step("ch20", "chronicle_jump"),
Step("ch21", "wait", wait: 0.25f),
Step("ch22", "assert", expect: "focus", value: "true"),
Step("ch23", "assert", expect: "dossier_contains", value: "auto"),
Step("ch24", "assert", expect: "no_bad"),
Step("ch99", "snapshot"),
};
}
private static HarnessCommand Nested(string id, string scenario)
{
return Step(id, "scenario", value: scenario, expect: "keep");

View file

@ -1,6 +1,9 @@
{
"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)"
"show_watch_reasons": "Show brief watch tips",
"show_dossier_caption": "Show creature dossier caption while watching",
"chronicle_enabled": "Enable Character Chronicle side panel (F9)",
"debug_state_probe": "Log focus/power-bar state (for debugging)",
"IdleSpectator Chronicle": "Character Chronicle"
}

View file

@ -54,18 +54,23 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
_harmony = new Harmony(pModDecl.UID);
_harmony.PatchAll(typeof(ModClass).Assembly);
LogService.LogInfo($"[{pModDecl.Name}]: Harmony patches applied");
LogService.LogInfo($"[{pModDecl.Name}]: Press I to toggle Idle Spectator (disable in mod settings)");
LogService.LogInfo($"[{pModDecl.Name}]: Press I for Idle Spectator, F9 for Chronicle");
Chronicle.EnsureWindow();
}
private void Update()
{
AgentHarness.Update();
SpectatorMode.PollInput();
Chronicle.PollInput();
InterestDirector.Update();
if (!AgentHarness.FreezeDirector)
{
SpeciesDiscovery.Update();
}
WatchCaption.Update();
Chronicle.Update();
StateProbe.Update();
AutoTest.Update();
}

View file

@ -13,6 +13,8 @@ public static class ModSettings
public const string GroupId = "Spectator";
public const string EnabledId = "enabled";
public const string ShowWatchReasonsId = "show_watch_reasons";
public const string ShowDossierCaptionId = "show_dossier_caption";
public const string ChronicleEnabledId = "chronicle_enabled";
public const string DebugStateProbeId = "debug_state_probe";
private static ModConfig _config;
@ -21,6 +23,10 @@ public static class ModSettings
public static bool ShowWatchReasons => GetBool(ShowWatchReasonsId, defaultValue: true);
public static bool ShowDossierCaption => GetBool(ShowDossierCaptionId, defaultValue: true);
public static bool ChronicleEnabled => GetBool(ChronicleEnabledId, defaultValue: true);
public static bool DebugStateProbe => GetBool(DebugStateProbeId, defaultValue: true);
public static ModConfig Load(ModDeclare declare)

View file

@ -36,9 +36,9 @@ public static class SpectatorMode
Active = active;
if (Active)
{
InterestDirector.OnSpectatorEnabled();
SpeciesDiscovery.OnSpectatorEnabled();
WorldTip.showNowTop("Idle Spectator ON (I or any input to stop)", pTranslate: false);
InterestDirector.OnSpectatorEnabled();
SpeciesDiscovery.OnSpectatorEnabled();
WorldTip.showNowTop("Idle Spectator ON (I or any input to stop; F9 chronicle)", pTranslate: false);
LogService.LogInfo("[IdleSpectator] Spectator mode enabled");
}
else
@ -46,6 +46,7 @@ public static class SpectatorMode
InterestDirector.OnSpectatorDisabled();
SpeciesDiscovery.OnSpectatorDisabled();
CameraDirector.ClearFollow();
WatchCaption.Clear();
StateProbe.Reset();
WorldTip.showNowTop("Idle Spectator OFF", pTranslate: false);
LogService.LogInfo("[IdleSpectator] Spectator mode disabled");

View file

@ -0,0 +1,243 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Snapshot of why a living unit is interesting (caption + chronicle source).
/// </summary>
public sealed class UnitDossier
{
public long UnitId;
public string Name = "";
public string SpeciesId = "";
public string Headline = "";
public string DetailLine = "";
public string CaptionText = "";
public float Score;
public int Kills;
public int Age;
public int Renown;
public bool IsFavorite;
public bool IsKing;
public bool IsCityLeader;
public bool IsFighting;
public bool IsSpectacle;
public bool IsLoneSpecies;
public string KingdomName = "";
public string CityName = "";
public readonly List<string> ScoreReasons = new List<string>();
public static UnitDossier FromActor(Actor actor, InterestTier? watchTier = null, string watchLabel = null)
{
UnitDossier d = new UnitDossier();
if (actor == null || !actor.isAlive())
{
d.Headline = "Nobody";
d.DetailLine = "no living unit";
d.CaptionText = d.Headline;
return d;
}
Dictionary<string, int> speciesCounts = WorldActivityScanner.CountSpeciesPopulations();
d.UnitId = actor.getID();
d.Name = SafeName(actor);
d.SpeciesId = actor.asset != null ? actor.asset.id : "creature";
d.Kills = actor.data != null ? actor.data.kills : 0;
d.Age = SafeAge(actor);
d.Renown = actor.renown;
d.IsFavorite = actor.isFavorite();
d.IsKing = actor.isKing();
d.IsCityLeader = actor.isCityLeader();
d.IsFighting = actor.has_attack_target;
d.IsSpectacle = WorldActivityScanner.IsSpectaclePublic(actor);
d.IsLoneSpecies = WorldActivityScanner.IsSingletonSpeciesPublic(actor, speciesCounts);
d.KingdomName = actor.kingdom != null ? actor.kingdom.name : "";
d.CityName = actor.city != null ? actor.city.name : "";
d.Score = WorldActivityScanner.ScoreActorPublic(actor, speciesCounts);
CollectReasons(d, actor, speciesCounts);
string tierPrefix = watchTier.HasValue ? $"Watching [{watchTier.Value}]: " : "";
d.Headline = tierPrefix + (string.IsNullOrEmpty(watchLabel)
? WorldActivityScanner.FormatUnitLabelPublic(actor, speciesCounts)
: watchLabel);
d.DetailLine = BuildDetailLine(d);
d.CaptionText = string.IsNullOrEmpty(d.DetailLine)
? d.Headline
: d.Headline + "\n" + d.DetailLine;
return d;
}
public bool ContainsIgnoreCase(string needle)
{
if (string.IsNullOrEmpty(needle))
{
return false;
}
return (CaptionText ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (Headline ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (DetailLine ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (Name ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (SpeciesId ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
}
private static void CollectReasons(UnitDossier d, Actor actor, Dictionary<string, int> speciesCounts)
{
if (d.IsFavorite)
{
d.ScoreReasons.Add("favorite");
}
if (d.IsKing)
{
d.ScoreReasons.Add("king");
}
else if (d.IsCityLeader)
{
d.ScoreReasons.Add("leader");
}
else if (actor.is_profession_warrior)
{
d.ScoreReasons.Add("warrior");
}
if (d.IsFighting)
{
d.ScoreReasons.Add("fighting");
}
if (d.Kills >= 100)
{
d.ScoreReasons.Add("100+ kills");
}
else if (d.Kills >= 50)
{
d.ScoreReasons.Add("50+ kills");
}
else if (d.Kills >= 10)
{
d.ScoreReasons.Add("10+ kills");
}
else if (d.Kills > 0)
{
d.ScoreReasons.Add($"{d.Kills} kills");
}
if (d.IsLoneSpecies)
{
d.ScoreReasons.Add("lone of kind");
}
if (d.IsSpectacle)
{
d.ScoreReasons.Add("spectacle");
}
if (d.Renown >= 100)
{
d.ScoreReasons.Add($"renown {d.Renown}");
}
// A few trait ids for flavor (keep short).
try
{
if (actor.hasTraits())
{
int n = 0;
foreach (ActorTrait trait in actor.getTraits())
{
if (trait == null || string.IsNullOrEmpty(trait.id))
{
continue;
}
d.ScoreReasons.Add(trait.id);
n++;
if (n >= 2)
{
break;
}
}
}
}
catch
{
// ignore trait access failures on exotic units
}
}
private static string BuildDetailLine(UnitDossier d)
{
StringBuilder sb = new StringBuilder();
sb.Append(d.SpeciesId);
if (d.Age > 0)
{
sb.Append(" · age ").Append(d.Age);
}
if (d.Kills > 0)
{
sb.Append(" · ").Append(d.Kills).Append(" kills");
}
if (!string.IsNullOrEmpty(d.KingdomName))
{
sb.Append(" · ").Append(d.KingdomName);
}
else if (!string.IsNullOrEmpty(d.CityName))
{
sb.Append(" · ").Append(d.CityName);
}
for (int i = 0; i < d.ScoreReasons.Count && i < 4; i++)
{
string reason = d.ScoreReasons[i];
// Skip reasons already implied by headline pieces.
if (reason == "fighting" || reason == "king" || reason == "favorite")
{
continue;
}
if (sb.ToString().IndexOf(reason, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
continue;
}
sb.Append(" · ").Append(reason);
}
return sb.ToString();
}
private static string SafeName(Actor actor)
{
try
{
string name = actor.getName();
if (!string.IsNullOrEmpty(name))
{
return name;
}
}
catch
{
// fall through
}
return actor.asset != null ? actor.asset.id : "unit";
}
private static int SafeAge(Actor actor)
{
try
{
return actor.getAge();
}
catch
{
return 0;
}
}
}

View file

@ -0,0 +1,111 @@
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Persistent "who am I watching" caption while Idle Spectator is active.
/// </summary>
public static class WatchCaption
{
private const float RefreshSeconds = 4.5f;
private static float _lastShownAt = -999f;
private static UnitDossier _current;
public static UnitDossier Current => _current;
public static string LastHeadline { get; private set; } = "";
public static string LastDetail { get; private set; } = "";
public static string LastCaptionText { get; private set; } = "";
public static void Clear()
{
_current = null;
LastHeadline = "";
LastDetail = "";
LastCaptionText = "";
_lastShownAt = -999f;
try
{
WorldTip.hideNow();
}
catch
{
// ignore
}
}
public static void SetFromInterest(InterestEvent interest)
{
Actor unit = interest != null && interest.HasFollowUnit
? interest.FollowUnit
: (MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
if (unit == null || !unit.isAlive())
{
if (interest != null)
{
LastHeadline = CameraDirector.FormatWatchTip(interest);
LastDetail = "";
LastCaptionText = LastHeadline;
_current = null;
ShowNow(LastCaptionText, force: true);
}
return;
}
SetFromActor(unit, interest?.Tier, interest?.Label);
}
public static void SetFromActor(Actor actor, InterestTier? tier = null, string label = null)
{
UnitDossier dossier = UnitDossier.FromActor(actor, tier, label);
_current = dossier;
LastHeadline = dossier.Headline ?? "";
LastDetail = dossier.DetailLine ?? "";
LastCaptionText = dossier.CaptionText ?? "";
ShowNow(LastCaptionText, force: true);
LogService.LogInfo("[IdleSpectator][CAPTION] " + LastCaptionText.Replace("\n", " | "));
}
public static void Update()
{
if (!SpectatorMode.Active || !ModSettings.ShowDossierCaption)
{
return;
}
if (string.IsNullOrEmpty(LastCaptionText))
{
return;
}
if (Time.unscaledTime - _lastShownAt < RefreshSeconds)
{
return;
}
// Refresh so the tip does not fade away during long AFK watches.
ShowNow(LastCaptionText, force: false);
}
private static void ShowNow(string text, bool force)
{
if (!ModSettings.ShowDossierCaption || string.IsNullOrEmpty(text))
{
return;
}
if (!force && Time.unscaledTime - _lastShownAt < 1f)
{
return;
}
WorldTip.showNow(text, pTranslate: false, pPosition: "top", pTime: RefreshSeconds + 2f);
_lastShownAt = Time.unscaledTime;
}
}

View file

@ -204,10 +204,53 @@ public static class WorldActivityScanner
FollowUnit = best,
Label = FormatUnitLabel(best, speciesCounts),
CreatedAt = Time.unscaledTime,
AssetId = "scored_unit"
AssetId = best.asset != null ? best.asset.id : "scored_unit"
};
}
/// <summary>Public wrappers for dossier / harness (same logic as private scorers).</summary>
public static Dictionary<string, int> CountSpeciesPopulations()
{
Dictionary<string, int> speciesCounts = new Dictionary<string, int>();
foreach (Actor actor in EnumerateAliveUnits())
{
if (actor?.asset == null)
{
continue;
}
string id = actor.asset.id;
if (!speciesCounts.ContainsKey(id))
{
speciesCounts[id] = 0;
}
speciesCounts[id]++;
}
return speciesCounts;
}
public static float ScoreActorPublic(Actor actor, Dictionary<string, int> speciesCounts)
{
return ScoreActor(actor, speciesCounts);
}
public static bool IsSpectaclePublic(Actor actor)
{
return IsSpectacle(actor);
}
public static bool IsSingletonSpeciesPublic(Actor actor, Dictionary<string, int> speciesCounts)
{
return IsSingletonSpecies(actor, speciesCounts);
}
public static string FormatUnitLabelPublic(Actor actor, Dictionary<string, int> speciesCounts)
{
return FormatUnitLabel(actor, speciesCounts);
}
private static float ScoreActor(Actor actor, Dictionary<string, int> speciesCounts)
{
float score = 0f;
@ -345,6 +388,12 @@ public static class WorldActivityScanner
return $"King {name} of {kingdom}";
}
if (actor.isCityLeader())
{
string city = actor.city != null ? actor.city.name : "town";
return $"Leader {name} of {city}";
}
if (actor.has_attack_target)
{
return $"Fighting: {name}";
@ -499,6 +548,11 @@ public static class WorldActivityScanner
}
private static IEnumerable<Actor> EnumerateAliveUnits()
{
return EnumerateAliveUnitsPublic();
}
public static IEnumerable<Actor> EnumerateAliveUnitsPublic()
{
List<Actor> prepared = World.world?.units?.units_only_alive;
if (prepared != null && prepared.Count > 0)

View file

@ -8,5 +8,22 @@ public static class WorldLogMessageAddPatch
public static void Postfix(WorldLogMessage pMessage)
{
InterestCollector.OnWorldLogMessage(pMessage);
if (pMessage == null)
{
return;
}
if (!WorldLogInterestTable.TryGetTier(pMessage.asset_id, out InterestTier tier))
{
return;
}
if (tier < InterestTier.Story)
{
return;
}
Chronicle.NoteWorldLog(pMessage, WorldLogInterestTable.MakeLabel(pMessage));
}
}

View file

@ -11,6 +11,16 @@
"Type": "SWITCH",
"BoolVal": true
},
{
"Id": "show_dossier_caption",
"Type": "SWITCH",
"BoolVal": true
},
{
"Id": "chronicle_enabled",
"Type": "SWITCH",
"BoolVal": true
},
{
"Id": "debug_state_probe",
"Type": "SWITCH",

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.6.3",
"description": "AFK Idle Spectator (I) with agent harness. Regression suite: settings, ghost guard, retarget, input grace, director tiers, discovery, world-log.",
"version": "0.8.0",
"description": "AFK Idle Spectator (I) + Chronicle life-event HUD (F9). Dossier captions, kills/lovers/WorldLog chronicle.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -5,6 +5,7 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director.
## Controls
- **I** - toggle Idle Spectator on/off
- **F9** - toggle Character Chronicle side panel (non-modal; works with focus)
- Dragging the camera while Spectator is on turns it off (after a short grace period)
## What it follows
@ -17,6 +18,9 @@ Priority (wiki-aligned):
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)
While watching, a **dossier caption** shows who they are (species, kills, age, roles).
Lingering on someone writes a **Chronicle** line; F9 lists them and click jumps the camera.
## Layout
- `IdleSpectator/` - mod source (`mod.json` + C#), loaded/compiled by NML

View file

@ -33,6 +33,7 @@ REGRESSION_SCENARIOS=(
director_tiers
discovery
world_log
chronicle_smoke
)
scenario=""