Base chronicler
This commit is contained in:
parent
db5f45a00f
commit
43cc098ac9
20 changed files with 1407 additions and 54 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
36
IdleSpectator/ActorChroniclePatches.cs
Normal file
36
IdleSpectator/ActorChroniclePatches.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
372
IdleSpectator/Chronicle.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
341
IdleSpectator/ChronicleHud.cs
Normal file
341
IdleSpectator/ChronicleHud.cs
Normal 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) + "...";
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
243
IdleSpectator/UnitDossier.cs
Normal file
243
IdleSpectator/UnitDossier.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
111
IdleSpectator/WatchCaption.cs
Normal file
111
IdleSpectator/WatchCaption.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ REGRESSION_SCENARIOS=(
|
|||
director_tiers
|
||||
discovery
|
||||
world_log
|
||||
chronicle_smoke
|
||||
)
|
||||
|
||||
scenario=""
|
||||
|
|
|
|||
Loading…
Reference in a new issue