worldbox-observer-mod/IdleSpectator/Chronicle.cs

1078 lines
29 KiB
C#

using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
public enum ChronicleKind
{
Death,
Kill,
Lover,
Friend,
World,
Other,
/// <summary>Age chapter header row in World Memory (not a clickable landmark).</summary>
AgeChapter
}
public sealed class ChronicleEntry
{
public float CreatedAt;
/// <summary>WorldBox sim time when the event was recorded (<see cref="MapBox.getCurWorldTime"/>).</summary>
public double WorldTime;
/// <summary>In-world month + year, e.g. <c>January 1</c> (no brackets).</summary>
public string DateLabel = "";
public ChronicleKind Kind;
public long SubjectId;
public long OtherId;
public string Name = "";
public string SpeciesId = "";
/// <summary>Raw fact line (asserts / debug).</summary>
public string Line = "";
/// <summary>Chronicle-voice line for HUD; falls back to <see cref="Line"/>.</summary>
public string LoreLine = "";
public Vector3 Position;
public string AssetId = "";
public string AgeId = "";
public string AgeName = "";
/// <summary>True when this row is a curated character legend in World Memory.</summary>
public bool IsLegend;
/// <summary>Back-compat for harness / jump helpers.</summary>
public long UnitId => SubjectId;
public string HudLine => !string.IsNullOrEmpty(LoreLine) ? LoreLine : (Line ?? "");
/// <summary>Plain date-prefixed line for logs / asserts (uses lore when present).</summary>
public string DisplayLine
{
get
{
string body = HudLine;
if (string.IsNullOrEmpty(DateLabel))
{
return body ?? "";
}
if (string.IsNullOrEmpty(body))
{
return "[" + DateLabel + "]";
}
return "[" + DateLabel + "] " + body;
}
}
/// <summary>HUD line with a muted colored date in brackets (Unity rich text).</summary>
public string DisplayLineRich
{
get
{
string body = HudLine;
if (string.IsNullOrEmpty(DateLabel))
{
return body ?? "";
}
const string color = "#A8B4C8";
string stamped = "<color=" + color + ">[" + DateLabel + "]</color>";
if (string.IsNullOrEmpty(body))
{
return stamped;
}
return stamped + " " + body;
}
}
}
/// <summary>
/// Session chronicles: per-character History + World Memory landmarks (age chapters).
/// </summary>
public static class Chronicle
{
public const KeyCode ToggleKey = KeyCode.F9;
public const int MaxHistoryPerUnit = 40;
private const int MaxLandmarks = 80;
private const int MaxLandmarksPerAge = 8;
private static readonly Dictionary<long, List<ChronicleEntry>> Histories =
new Dictionary<long, List<ChronicleEntry>>();
private static readonly List<ChronicleEntry> MemoryLandmarks = new List<ChronicleEntry>();
private static readonly HashSet<string> LoverPairKeys = new HashSet<string>();
private static readonly HashSet<string> FriendPairKeys = new HashSet<string>();
private static readonly HashSet<long> DeathLogged = new HashSet<long>();
private static readonly HashSet<string> LandmarkDedupe = new HashSet<string>();
private static readonly Dictionary<string, int> LandmarksPerAge = new Dictionary<string, int>();
private static bool _hudReady;
private static long _lastHistorySubjectId;
private static object _boundWorld;
private static string _currentAgeId = "";
private static string _currentAgeName = "";
public static void OnSpectatorEnabled()
{
object world = World.world;
if (!ReferenceEquals(world, _boundWorld))
{
ClearSession();
_boundWorld = world;
LogService.LogInfo("[IdleSpectator] Chronicle cleared for new world session");
}
RefreshAgeChapter(force: true);
}
public static int Count => HistoryCountFor(CurrentHistorySubjectId()) + MemoryCount;
/// <summary>World Memory landmark count (replaces the old World feed).</summary>
public static int WorldCount => MemoryCount;
public static int MemoryCount
{
get
{
lock (MemoryLandmarks)
{
return MemoryLandmarks.Count;
}
}
}
public static bool HudVisible => ChronicleHud.Visible;
public static long LastHistorySubjectId => _lastHistorySubjectId;
public static string CurrentAgeId => _currentAgeId;
public static string CurrentAgeName => _currentAgeName;
public static int HistoryCountFor(long subjectId)
{
if (subjectId == 0)
{
return 0;
}
lock (Histories)
{
return Histories.TryGetValue(subjectId, out List<ChronicleEntry> list) ? list.Count : 0;
}
}
public static IReadOnlyList<ChronicleEntry> SnapshotForFocus()
{
return SnapshotForSubject(CurrentHistorySubjectId());
}
public static IReadOnlyList<ChronicleEntry> SnapshotForSubject(long subjectId)
{
if (subjectId == 0)
{
return System.Array.Empty<ChronicleEntry>();
}
lock (Histories)
{
if (!Histories.TryGetValue(subjectId, out List<ChronicleEntry> list))
{
return System.Array.Empty<ChronicleEntry>();
}
return list.ToArray();
}
}
/// <summary>Newest-first slice for compact dossier HUD.</summary>
public static IReadOnlyList<ChronicleEntry> LatestForSubject(long subjectId, int max)
{
if (max <= 0)
{
return System.Array.Empty<ChronicleEntry>();
}
IReadOnlyList<ChronicleEntry> all = SnapshotForSubject(subjectId);
if (all.Count == 0)
{
return all;
}
int take = Mathf.Min(max, all.Count);
ChronicleEntry[] newest = new ChronicleEntry[take];
for (int i = 0; i < take; i++)
{
newest[i] = all[all.Count - 1 - i];
}
return newest;
}
/// <summary>World Memory landmarks oldest→newest (HUD reverses for display).</summary>
public static IReadOnlyList<ChronicleEntry> SnapshotWorld()
{
return SnapshotMemory();
}
public static IReadOnlyList<ChronicleEntry> SnapshotMemory()
{
lock (MemoryLandmarks)
{
return MemoryLandmarks.ToArray();
}
}
/// <summary>Latest history entry for the current/last focus (harness + jump).</summary>
public static ChronicleEntry Latest
{
get
{
IReadOnlyList<ChronicleEntry> list = SnapshotForFocus();
return list.Count == 0 ? null : list[list.Count - 1];
}
}
public static ChronicleEntry LatestWorld
{
get
{
lock (MemoryLandmarks)
{
return MemoryLandmarks.Count == 0 ? null : MemoryLandmarks[MemoryLandmarks.Count - 1];
}
}
}
public static long CurrentHistorySubjectId()
{
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
try
{
long id = MoveCamera._focus_unit.getID();
if (id != 0)
{
_lastHistorySubjectId = id;
return id;
}
}
catch
{
// fall through
}
}
return _lastHistorySubjectId;
}
public static string CurrentHistoryTitle()
{
long id = CurrentHistorySubjectId();
if (id == 0)
{
return "";
}
Actor unit = FindUnitById(id);
if (unit != null)
{
return $"{SafeName(unit)} ({SpeciesOf(unit)})";
}
IReadOnlyList<ChronicleEntry> list = SnapshotForSubject(id);
if (list.Count > 0)
{
ChronicleEntry e = list[list.Count - 1];
if (!string.IsNullOrEmpty(e.Name))
{
return string.IsNullOrEmpty(e.SpeciesId) ? e.Name : $"{e.Name} ({e.SpeciesId})";
}
}
return "Unit #" + id;
}
public static void EnsureWindow()
{
if (!ModSettings.ChronicleEnabled)
{
return;
}
if (_hudReady && ChronicleHud.IsReady)
{
return;
}
try
{
ChronicleHud.EnsureBuilt();
if (ChronicleHud.IsReady)
{
_hudReady = true;
LogService.LogInfo("[IdleSpectator] World Memory HUD ready (F9)");
}
}
catch (System.Exception ex)
{
LogService.LogInfo("[IdleSpectator] World Memory HUD init failed: " + ex.Message);
}
}
public static void ClearSession()
{
lock (Histories)
{
Histories.Clear();
}
lock (MemoryLandmarks)
{
MemoryLandmarks.Clear();
}
LoverPairKeys.Clear();
FriendPairKeys.Clear();
DeathLogged.Clear();
LandmarkDedupe.Clear();
LandmarksPerAge.Clear();
_lastHistorySubjectId = 0;
_currentAgeId = "";
_currentAgeName = "";
// Keep _boundWorld so re-enable on same map does not loop-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()
{
// Prefer a Memory landmark when it can resolve a unit/position; else focus history.
ChronicleEntry memory = LatestWorld;
if (memory != null && memory.Kind != ChronicleKind.AgeChapter && JumpTo(memory))
{
return true;
}
return JumpTo(Latest);
}
public static bool JumpTo(ChronicleEntry entry)
{
if (entry == null || entry.Kind == ChronicleKind.AgeChapter)
{
return false;
}
Actor unit = FindUnitById(entry.SubjectId);
if (unit != null && unit.isAlive())
{
CameraDirector.FocusUnit(unit);
WatchCaption.SetFromActor(unit);
return true;
}
if (entry.OtherId != 0)
{
Actor other = FindUnitById(entry.OtherId);
if (other != null && other.isAlive())
{
CameraDirector.FocusUnit(other);
WatchCaption.SetFromActor(other);
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;
}
RefreshAgeChapter(force: false);
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
try
{
long id = MoveCamera._focus_unit.getID();
if (id != 0)
{
_lastHistorySubjectId = id;
}
}
catch
{
// ignore
}
}
ChronicleHud.UpdateLive();
}
/// <summary>Killer POV - character history only (may promote to Memory if notable).</summary>
public static void NoteKill(Actor killer, Actor victim)
{
if (!ModSettings.ChronicleEnabled || killer == null || victim == null)
{
return;
}
AppendHistory(
ChronicleKind.Kill,
killer,
victim,
$"Killed {SafeName(victim)} ({SpeciesOf(victim)})");
}
/// <summary>Victim POV - cause of death on character history.</summary>
public static void NoteDeath(Actor victim, AttackType attackType, Actor killer)
{
if (!ModSettings.ChronicleEnabled || victim == null)
{
return;
}
long id = 0;
try
{
id = victim.getID();
}
catch
{
return;
}
if (id == 0 || !DeathLogged.Add(id))
{
return;
}
string line = FormatDeathCause(victim, attackType, killer);
AppendHistory(ChronicleKind.Death, victim, killer, line);
}
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;
}
AppendHistory(ChronicleKind.Lover, a, b, $"Became lovers with {SafeName(b)}");
AppendHistory(ChronicleKind.Lover, b, a, $"Became lovers with {SafeName(a)}");
}
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;
}
AppendHistory(ChronicleKind.Friend, a, b, $"Befriended {SafeName(b)}");
AppendHistory(ChronicleKind.Friend, b, a, $"Befriended {SafeName(a)}");
}
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
}
string assetId = message.asset_id ?? "";
AppendLandmark(
ChronicleKind.World,
unit,
null,
label,
LoreProse.Rewrite(ChronicleKind.World, label, assetId),
pos,
assetId,
isLegend: false);
}
/// <summary>Harness: inject a life-event style line on the focused unit's History.</summary>
public static bool ForceNoteCurrentFocus(string prefix = "Killed")
{
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
{
return false;
}
Actor unit = MoveCamera._focus_unit;
string kindPrefix = string.IsNullOrEmpty(prefix) ? "Killed" : prefix;
string line;
if (kindPrefix.Length <= 24 && kindPrefix.IndexOf(' ') < 0)
{
UnitDossier dossier = UnitDossier.FromActor(unit);
line = $"{kindPrefix}: {dossier.Name} ({dossier.SpeciesId}) - life event";
}
else
{
line = kindPrefix;
}
AppendHistory(ChronicleKind.Other, unit, null, line);
return true;
}
/// <summary>Harness: inject a World Memory landmark (does not touch character History).</summary>
public static bool ForceWorldNote(string label)
{
if (string.IsNullOrEmpty(label))
{
return false;
}
AppendLandmark(
ChronicleKind.World,
null,
null,
label,
LoreProse.Rewrite(ChronicleKind.World, label),
Vector3.zero,
"harness",
isLegend: false);
return true;
}
/// <summary>Harness: append a formatted death-cause line for the focused unit.</summary>
public static bool ForceDeathSample(string attackTypeName, Actor killer = null)
{
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
{
return false;
}
if (!System.Enum.TryParse(attackTypeName, ignoreCase: true, out AttackType attackType))
{
return false;
}
Actor victim = MoveCamera._focus_unit;
string line = FormatDeathCause(victim, attackType, killer);
AppendHistory(ChronicleKind.Death, victim, killer, line);
return true;
}
public static string FormatDeathCause(Actor victim, AttackType attackType, Actor killer)
{
string killerLabel = killer != null
? $"{SafeName(killer)} ({SpeciesOf(killer)})"
: "";
switch (attackType)
{
case AttackType.Age:
return "Died of old age";
case AttackType.Starvation:
return "Starved to death";
case AttackType.Fire:
return IsOnLava(victim) ? "Burned in lava" : "Burned to death";
case AttackType.Drowning:
case AttackType.Water:
return "Drowned";
case AttackType.Poison:
return "Died of poison";
case AttackType.Plague:
return "Died of plague";
case AttackType.Infection:
return "Died of infection";
case AttackType.Tumor:
return "Died of a tumor";
case AttackType.Acid:
return "Dissolved in acid";
case AttackType.AshFever:
return "Died of ash fever";
case AttackType.Gravity:
return "Fell to their death";
case AttackType.Explosion:
return "Died in an explosion";
case AttackType.Divine:
return "Struck down by divine power";
case AttackType.Eaten:
return string.IsNullOrEmpty(killerLabel)
? "Was eaten"
: $"Eaten by {killerLabel}";
case AttackType.Weapon:
case AttackType.Other:
case AttackType.Smile:
if (!string.IsNullOrEmpty(killerLabel))
{
return $"Killed by {killerLabel}";
}
return attackType == AttackType.Weapon ? "Killed in combat" : "Died";
case AttackType.Metamorphosis:
return "Transformed away";
case AttackType.None:
return "Died";
default:
return string.IsNullOrEmpty(killerLabel)
? $"Died ({attackType})"
: $"Killed by {killerLabel}";
}
}
private static bool IsOnLava(Actor victim)
{
try
{
WorldTile tile = victim.current_tile;
return tile != null && tile.Type != null && tile.Type.lava;
}
catch
{
return false;
}
}
private static void AppendHistory(ChronicleKind kind, Actor subject, Actor other, string line)
{
if (subject == null || string.IsNullOrEmpty(line))
{
return;
}
long subjectId = subject.getID();
if (subjectId == 0)
{
return;
}
RefreshAgeChapter(force: false);
StampWorldDate(out double worldTime, out string dateLabel);
string lore = LoreProse.Rewrite(kind, line);
ChronicleEntry entry = new ChronicleEntry
{
CreatedAt = Time.unscaledTime,
WorldTime = worldTime,
DateLabel = dateLabel,
Kind = kind,
SubjectId = subjectId,
OtherId = other != null ? other.getID() : 0,
Name = SafeName(subject),
SpeciesId = SpeciesOf(subject),
Line = line,
LoreLine = lore,
Position = subject.current_position,
AgeId = _currentAgeId,
AgeName = _currentAgeName
};
lock (Histories)
{
if (!Histories.TryGetValue(subjectId, out List<ChronicleEntry> list))
{
list = new List<ChronicleEntry>();
Histories[subjectId] = list;
}
list.Add(entry);
while (list.Count > MaxHistoryPerUnit)
{
list.RemoveAt(0);
}
}
LogService.LogInfo($"[IdleSpectator][CHRONICLE][History:{subjectId}] {entry.DisplayLine}");
if (ShouldPromoteLegend(kind, subject, other))
{
AppendLandmark(
kind,
subject,
other,
line,
lore,
subject.current_position,
"legend:" + kind,
isLegend: true);
}
}
private static bool ShouldPromoteLegend(ChronicleKind kind, Actor subject, Actor other)
{
if (kind == ChronicleKind.Other || kind == ChronicleKind.AgeChapter)
{
return false;
}
return IsNotable(subject) || IsNotable(other);
}
private static bool IsNotable(Actor actor)
{
if (actor == null)
{
return false;
}
try
{
return actor.isFavorite() || actor.isKing() || actor.isCityLeader();
}
catch
{
return false;
}
}
private static void AppendLandmark(
ChronicleKind kind,
Actor subject,
Actor other,
string rawLine,
string loreLine,
Vector3 fallbackPosition,
string assetId,
bool isLegend)
{
if (string.IsNullOrEmpty(rawLine) && string.IsNullOrEmpty(loreLine))
{
return;
}
RefreshAgeChapter(force: false);
string ageId = string.IsNullOrEmpty(_currentAgeId) ? "unknown" : _currentAgeId;
string dedupeKey = ageId + "|" + assetId + "|" + rawLine;
if (!LandmarkDedupe.Add(dedupeKey))
{
return;
}
lock (MemoryLandmarks)
{
if (!LandmarksPerAge.TryGetValue(ageId, out int ageCount))
{
ageCount = 0;
}
if (ageCount >= MaxLandmarksPerAge)
{
return;
}
StampWorldDate(out double worldTime, out string dateLabel);
ChronicleEntry entry = new ChronicleEntry
{
CreatedAt = Time.unscaledTime,
WorldTime = worldTime,
DateLabel = dateLabel,
Kind = kind,
SubjectId = subject != null ? subject.getID() : 0,
OtherId = other != null ? other.getID() : 0,
Name = subject != null ? SafeName(subject) : "",
SpeciesId = subject != null ? SpeciesOf(subject) : "",
Line = rawLine ?? "",
LoreLine = string.IsNullOrEmpty(loreLine) ? (rawLine ?? "") : loreLine,
Position = subject != null ? subject.current_position : fallbackPosition,
AssetId = assetId ?? "",
AgeId = ageId,
AgeName = _currentAgeName,
IsLegend = isLegend
};
MemoryLandmarks.Add(entry);
LandmarksPerAge[ageId] = ageCount + 1;
while (MemoryLandmarks.Count > MaxLandmarks)
{
ChronicleEntry removed = MemoryLandmarks[0];
MemoryLandmarks.RemoveAt(0);
if (!string.IsNullOrEmpty(removed.AgeId)
&& LandmarksPerAge.TryGetValue(removed.AgeId, out int c)
&& c > 0)
{
LandmarksPerAge[removed.AgeId] = c - 1;
}
}
LogService.LogInfo("[IdleSpectator][MEMORY] " + entry.DisplayLine);
}
}
private static void RefreshAgeChapter(bool force)
{
string ageId;
string ageName;
if (!TryReadCurrentAge(out ageId, out ageName))
{
return;
}
if (!force && ageId == _currentAgeId)
{
return;
}
bool changed = ageId != _currentAgeId;
_currentAgeId = ageId;
_currentAgeName = ageName;
if (!changed && !force)
{
return;
}
if (!changed)
{
return;
}
// New age chapter: reset per-age cap tracking for the new id (keep old counts).
if (!LandmarksPerAge.ContainsKey(ageId))
{
LandmarksPerAge[ageId] = 0;
}
StampWorldDate(out double worldTime, out string dateLabel);
ChronicleEntry chapter = new ChronicleEntry
{
CreatedAt = Time.unscaledTime,
WorldTime = worldTime,
DateLabel = dateLabel,
Kind = ChronicleKind.AgeChapter,
Line = ageName,
LoreLine = ageName,
AgeId = ageId,
AgeName = ageName,
AssetId = "age:" + ageId
};
lock (MemoryLandmarks)
{
// Avoid duplicate chapter headers for the same age.
for (int i = MemoryLandmarks.Count - 1; i >= 0; i--)
{
ChronicleEntry e = MemoryLandmarks[i];
if (e != null && e.Kind == ChronicleKind.AgeChapter && e.AgeId == ageId)
{
return;
}
}
MemoryLandmarks.Add(chapter);
while (MemoryLandmarks.Count > MaxLandmarks)
{
MemoryLandmarks.RemoveAt(0);
}
}
LogService.LogInfo("[IdleSpectator][MEMORY] Age chapter: " + ageName);
}
private static bool TryReadCurrentAge(out string ageId, out string ageName)
{
ageId = "";
ageName = "";
try
{
if (World.world == null || World.world.era_manager == null)
{
return false;
}
WorldAgeAsset age = World.world.era_manager.getCurrentAge();
if (age == null)
{
return false;
}
ageId = age.id ?? "unknown";
ageName = ageId;
try
{
string locale = age.getLocaleID();
if (!string.IsNullOrEmpty(locale))
{
string translated = LocalizedTextManager.getText(locale);
if (!string.IsNullOrEmpty(translated))
{
ageName = translated;
}
}
}
catch
{
// keep id
}
if (string.IsNullOrEmpty(ageName))
{
ageName = ageId;
}
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Capture current WorldBox calendar as month + year (e.g. <c>January 1</c>).
/// </summary>
private static void StampWorldDate(out double worldTime, out string dateLabel)
{
worldTime = 0;
dateLabel = "";
try
{
if (World.world == null)
{
return;
}
worldTime = World.world.getCurWorldTime();
int[] raw = Date.getRawDate(worldTime);
if (raw == null || raw.Length < 3)
{
dateLabel = "y" + Date.getCurrentYear() + " m" + Date.getCurrentMonth();
return;
}
string monthName = Date.formatMonth(raw[1]);
if (string.IsNullOrEmpty(monthName))
{
monthName = "m" + raw[1];
}
dateLabel = monthName + " " + raw[2];
}
catch
{
dateLabel = "";
}
}
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);
}
}