2611 lines
74 KiB
C#
2611 lines
74 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
|
|
}
|
|
|
|
/// <summary>How a character died - used for Fallen list labels and icons.</summary>
|
|
public enum DeathManner
|
|
{
|
|
None = 0,
|
|
Slain,
|
|
OldAge,
|
|
Starvation,
|
|
Drowned,
|
|
Burned,
|
|
Plague,
|
|
Accident,
|
|
Divine,
|
|
Other
|
|
}
|
|
|
|
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;
|
|
/// <summary>True when <see cref="Position"/> was recorded from a real world location.</summary>
|
|
public bool HasWorldPosition;
|
|
public string AssetId = "";
|
|
public string AgeId = "";
|
|
public string AgeName = "";
|
|
/// <summary>True when this row is styled as a highlighted World Memory landmark.</summary>
|
|
public bool IsLegend;
|
|
/// <summary>Vanilla <see cref="AttackType"/> name when <see cref="Kind"/> is Death.</summary>
|
|
public string AttackTypeName = "";
|
|
/// <summary>Classified death manner for Fallen UI.</summary>
|
|
public DeathManner DeathManner;
|
|
|
|
/// <summary>Back-compat for harness / jump helpers.</summary>
|
|
public long UnitId => SubjectId;
|
|
|
|
public string HudLine => !string.IsNullOrEmpty(LoreLine) ? LoreLine : (Line ?? "");
|
|
|
|
/// <summary>Resolved death manner (stored, or inferred from line / killer).</summary>
|
|
public DeathManner ResolvedDeathManner
|
|
{
|
|
get
|
|
{
|
|
if (Kind != ChronicleKind.Death)
|
|
{
|
|
return DeathManner.None;
|
|
}
|
|
|
|
if (DeathManner != DeathManner.None)
|
|
{
|
|
return DeathManner;
|
|
}
|
|
|
|
return Chronicle.InferDeathManner(AttackTypeName, OtherId != 0, 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.L;
|
|
public const int MaxHistoryPerUnit = 40;
|
|
/// <summary>Default soft cap on keyed character histories (Living + Fallen archive subjects).</summary>
|
|
public const int DefaultMaxHistorySubjects = 100000;
|
|
/// <summary>Hard safety ceiling for <see cref="SetMaxHistorySubjects"/>.</summary>
|
|
public const int AbsoluteMaxHistorySubjects = 200000;
|
|
/// <summary>Soft cap on keyed character histories. Tunable via harness.</summary>
|
|
public static int MaxHistorySubjects { get; private set; } = DefaultMaxHistorySubjects;
|
|
public const int MaxLandmarks = 80;
|
|
private const int MaxLandmarksPerAge = 8;
|
|
private const int MaxPairKeys = 1500;
|
|
private const int MaxLandmarkDedupe = 400;
|
|
|
|
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<long> FavoriteIds = new HashSet<long>();
|
|
private static readonly Dictionary<long, UnitDossier> FinalDossiers =
|
|
new Dictionary<long, UnitDossier>();
|
|
private static readonly HashSet<string> LandmarkDedupe = new HashSet<string>();
|
|
private static readonly Dictionary<string, int> LandmarksPerAge = new Dictionary<string, int>();
|
|
private static readonly List<long> RecentFocusIds = new List<long>();
|
|
|
|
public const int MaxRecentFocus = 24;
|
|
|
|
/// <summary>Monotonic counter bumped on history/memory writes (cheap HUD dirty check).</summary>
|
|
public static int HistoryRevision { get; private set; }
|
|
|
|
private static bool _hudReady;
|
|
private static long _lastHistorySubjectId;
|
|
private static long _trackedFocusId;
|
|
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();
|
|
ActivityLog.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 int RecentFocusCount
|
|
{
|
|
get
|
|
{
|
|
lock (RecentFocusIds)
|
|
{
|
|
return RecentFocusIds.Count;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>How many distinct characters currently have History rows stored.</summary>
|
|
public static int HistorySubjectCount
|
|
{
|
|
get
|
|
{
|
|
lock (Histories)
|
|
{
|
|
return Histories.Count;
|
|
}
|
|
}
|
|
}
|
|
|
|
public static int FinalDossierCount => FinalDossiers.Count;
|
|
|
|
public static bool IsFavoriteSubject(long unitId)
|
|
{
|
|
return unitId != 0 && FavoriteIds.Contains(unitId);
|
|
}
|
|
|
|
/// <summary>Track favorites across death so Fallen can filter like Living.</summary>
|
|
public static void SetFavoriteSubject(long unitId, bool favorite)
|
|
{
|
|
if (unitId == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool had = FavoriteIds.Contains(unitId);
|
|
if (favorite == had)
|
|
{
|
|
if (FinalDossiers.TryGetValue(unitId, out UnitDossier existing) && existing != null)
|
|
{
|
|
existing.IsFavorite = favorite;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (favorite)
|
|
{
|
|
FavoriteIds.Add(unitId);
|
|
}
|
|
else
|
|
{
|
|
FavoriteIds.Remove(unitId);
|
|
}
|
|
|
|
if (FinalDossiers.TryGetValue(unitId, out UnitDossier dossier) && dossier != null)
|
|
{
|
|
dossier.IsFavorite = favorite;
|
|
}
|
|
|
|
BumpRevision();
|
|
}
|
|
|
|
public static void SyncFavoriteFromActor(Actor actor)
|
|
{
|
|
if (actor == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
long id = 0;
|
|
try
|
|
{
|
|
id = actor.getID();
|
|
}
|
|
catch
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (id == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool fav = false;
|
|
try
|
|
{
|
|
fav = actor.isAlive() && actor.isFavorite();
|
|
}
|
|
catch
|
|
{
|
|
fav = false;
|
|
}
|
|
|
|
SetFavoriteSubject(id, fav);
|
|
}
|
|
|
|
/// <summary>Harness / tuning: change subject cap and prune immediately.</summary>
|
|
public static void SetMaxHistorySubjects(int max)
|
|
{
|
|
MaxHistorySubjects = Mathf.Clamp(max, 40, AbsoluteMaxHistorySubjects);
|
|
lock (Histories)
|
|
{
|
|
PruneHistorySubjectsUnlocked();
|
|
}
|
|
|
|
PruneFinalDossiers();
|
|
BumpRevision();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Harness: fill to <paramref name="subjects"/> and time Fallen/Living list rebuilds.
|
|
/// Returns a single-line report of avg/max ms per size.
|
|
/// </summary>
|
|
public static string BenchSubjectLoad(int subjects, int eventsPerSubject = 3, int iters = 25)
|
|
{
|
|
subjects = Mathf.Clamp(subjects, 40, AbsoluteMaxHistorySubjects);
|
|
eventsPerSubject = Mathf.Clamp(eventsPerSubject, 1, MaxHistoryPerUnit);
|
|
iters = Mathf.Clamp(iters, 5, 200);
|
|
|
|
int previousCap = MaxHistorySubjects;
|
|
ClearSession();
|
|
SetMaxHistorySubjects(subjects);
|
|
FillBenchSubjectsUnlocked(subjects, eventsPerSubject);
|
|
|
|
// Warmup
|
|
ListCharacters(max: 40, scope: CharacterListScope.Fallen);
|
|
ListCharacters(max: 40, scope: CharacterListScope.Living);
|
|
|
|
int have = HistorySubjectCount;
|
|
|
|
double fallenSum = 0;
|
|
double fallenMax = 0;
|
|
double livingSum = 0;
|
|
double livingMax = 0;
|
|
for (int i = 0; i < iters; i++)
|
|
{
|
|
float t0 = Time.realtimeSinceStartup;
|
|
ListCharacters(max: 40, scope: CharacterListScope.Fallen);
|
|
float fallenMs = (Time.realtimeSinceStartup - t0) * 1000f;
|
|
fallenSum += fallenMs;
|
|
if (fallenMs > fallenMax)
|
|
{
|
|
fallenMax = fallenMs;
|
|
}
|
|
|
|
t0 = Time.realtimeSinceStartup;
|
|
ListCharacters(max: 40, scope: CharacterListScope.Living);
|
|
float livingMs = (Time.realtimeSinceStartup - t0) * 1000f;
|
|
livingSum += livingMs;
|
|
if (livingMs > livingMax)
|
|
{
|
|
livingMax = livingMs;
|
|
}
|
|
}
|
|
|
|
float pruneMs;
|
|
{
|
|
float t0 = Time.realtimeSinceStartup;
|
|
SetMaxHistorySubjects(Mathf.Max(40, subjects / 2));
|
|
pruneMs = (Time.realtimeSinceStartup - t0) * 1000f;
|
|
}
|
|
|
|
string report =
|
|
$"n={subjects} events={eventsPerSubject} have={have} "
|
|
+ $"fallenAvgMs={fallenSum / iters:0.00} fallenMaxMs={fallenMax:0.00} "
|
|
+ $"livingAvgMs={livingSum / iters:0.00} livingMaxMs={livingMax:0.00} "
|
|
+ $"pruneMs={pruneMs:0.00}";
|
|
|
|
LogService.LogInfo("[IdleSpectator][BENCH] " + report);
|
|
SetMaxHistorySubjects(previousCap);
|
|
return report;
|
|
}
|
|
|
|
/// <summary>Fast synthetic fill for benches (skips lore rewrite / age refresh).</summary>
|
|
private static void FillBenchSubjectsUnlocked(int subjects, int eventsPerSubject)
|
|
{
|
|
float now = Time.unscaledTime;
|
|
lock (Histories)
|
|
{
|
|
for (int i = 0; i < subjects; i++)
|
|
{
|
|
long id = _orphanSeq--;
|
|
var list = new List<ChronicleEntry>(eventsPerSubject);
|
|
for (int e = 0; e < eventsPerSubject; e++)
|
|
{
|
|
list.Add(new ChronicleEntry
|
|
{
|
|
CreatedAt = now + (i * 0.0001f) + (e * 0.00001f),
|
|
WorldTime = i,
|
|
DateLabel = "Bench",
|
|
Kind = ChronicleKind.Death,
|
|
SubjectId = id,
|
|
Name = "Bench" + i,
|
|
SpeciesId = "human",
|
|
Line = "Bench death",
|
|
LoreLine = "Bench death",
|
|
DeathManner = DeathManner.OldAge,
|
|
AttackTypeName = "Age"
|
|
});
|
|
}
|
|
|
|
Histories[id] = list;
|
|
FinalDossiers[id] = UnitDossier.FromFallenArchive(
|
|
id, "Bench" + i, "human", DeathManner.OldAge);
|
|
}
|
|
|
|
PruneHistorySubjectsUnlocked();
|
|
}
|
|
|
|
PruneFinalDossiers();
|
|
BumpRevision();
|
|
}
|
|
|
|
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 (L)");
|
|
}
|
|
}
|
|
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();
|
|
FavoriteIds.Clear();
|
|
FinalDossiers.Clear();
|
|
LandmarkDedupe.Clear();
|
|
LandmarksPerAge.Clear();
|
|
RecentFocusIds.Clear();
|
|
_lastHistorySubjectId = 0;
|
|
_trackedFocusId = 0;
|
|
_currentAgeId = "";
|
|
_currentAgeName = "";
|
|
ActivityLog.ClearSession();
|
|
BumpRevision();
|
|
// Keep _boundWorld so re-enable on same map does not loop-clear.
|
|
}
|
|
|
|
private static void BumpRevision()
|
|
{
|
|
unchecked
|
|
{
|
|
HistoryRevision++;
|
|
}
|
|
}
|
|
|
|
public static void PollInput()
|
|
{
|
|
if (!Config.game_loaded || !ModSettings.ChronicleEnabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnsureWindow();
|
|
if (ChronicleHud.IsTypingSearch)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Input.GetKeyDown(ToggleKey))
|
|
{
|
|
ToggleWindow();
|
|
}
|
|
}
|
|
|
|
public static void ToggleWindow()
|
|
{
|
|
if (!ModSettings.ChronicleEnabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnsureWindow();
|
|
ChronicleHud.Toggle();
|
|
}
|
|
|
|
public static void ShowHud()
|
|
{
|
|
EnsureWindow();
|
|
// Default into Living only when opening from World with no detail pinned.
|
|
if (ChronicleHud.DetailUnitId == 0 && ChronicleHud.ActiveTab == ChronicleHud.LoreTab.World)
|
|
{
|
|
ChronicleHud.SetTab(ChronicleHud.LoreTab.Living);
|
|
}
|
|
|
|
ChronicleHud.SetVisible(true);
|
|
}
|
|
|
|
public static void HideHud()
|
|
{
|
|
ChronicleHud.SetVisible(false);
|
|
}
|
|
|
|
/// <summary>Harness: how the last JumpTo resolved (unit:id | place | none).</summary>
|
|
public static string LastJumpDetail { get; private set; } = "";
|
|
|
|
/// <summary>Harness: world position used by the last place-style JumpTo.</summary>
|
|
public static Vector3 LastJumpPosition { get; private set; } = Vector3.zero;
|
|
|
|
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)
|
|
{
|
|
LastJumpDetail = "none";
|
|
LastJumpPosition = Vector3.zero;
|
|
if (entry == null || entry.Kind == ChronicleKind.AgeChapter)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// World Memory landmarks: always go to the recorded place when we have one,
|
|
// even if the related unit is gone or has moved on.
|
|
bool memoryPlaceFirst = entry.Kind == ChronicleKind.World || entry.IsLegend;
|
|
if (memoryPlaceFirst && EntryHasWorldPosition(entry))
|
|
{
|
|
CameraDirector.ClearFollow();
|
|
CameraDirector.PanTo(entry.Position);
|
|
LastJumpDetail = "place";
|
|
LastJumpPosition = entry.Position;
|
|
LogService.LogInfo(
|
|
$"[IdleSpectator][MEMORY] jump place pos={entry.Position} line={entry.DisplayLine}");
|
|
return true;
|
|
}
|
|
|
|
Actor unit = FindUnitById(entry.SubjectId);
|
|
if (unit != null && unit.isAlive())
|
|
{
|
|
bool ok = FocusSubject(unit);
|
|
if (ok)
|
|
{
|
|
LastJumpDetail = "unit:" + entry.SubjectId;
|
|
}
|
|
|
|
return ok;
|
|
}
|
|
|
|
if (entry.OtherId != 0)
|
|
{
|
|
Actor other = FindUnitById(entry.OtherId);
|
|
if (other != null && other.isAlive())
|
|
{
|
|
bool ok = FocusSubject(other);
|
|
if (ok)
|
|
{
|
|
LastJumpDetail = "unit:" + entry.OtherId;
|
|
}
|
|
|
|
return ok;
|
|
}
|
|
}
|
|
|
|
if (EntryHasWorldPosition(entry))
|
|
{
|
|
CameraDirector.ClearFollow();
|
|
CameraDirector.PanTo(entry.Position);
|
|
LastJumpDetail = "place";
|
|
LastJumpPosition = entry.Position;
|
|
LogService.LogInfo(
|
|
$"[IdleSpectator][MEMORY] jump place pos={entry.Position} line={entry.DisplayLine}");
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Record a focus for the Character History "recent" list.</summary>
|
|
public static void NoteFocus(Actor actor)
|
|
{
|
|
if (actor == null || !actor.isAlive())
|
|
{
|
|
return;
|
|
}
|
|
|
|
long id;
|
|
try
|
|
{
|
|
id = actor.getID();
|
|
}
|
|
catch
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (id == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
NoteFocusId(id);
|
|
SyncFavoriteFromActor(actor);
|
|
}
|
|
|
|
private static void NoteFocusId(long id)
|
|
{
|
|
if (id == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_lastHistorySubjectId = id;
|
|
lock (RecentFocusIds)
|
|
{
|
|
RecentFocusIds.Remove(id);
|
|
RecentFocusIds.Insert(0, id);
|
|
while (RecentFocusIds.Count > MaxRecentFocus)
|
|
{
|
|
RecentFocusIds.RemoveAt(RecentFocusIds.Count - 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Focus a living unit and show its dossier (enables spectator if needed).</summary>
|
|
public static string TitleForSubject(long subjectId)
|
|
{
|
|
if (subjectId == 0)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
Actor live = FindUnitById(subjectId);
|
|
if (live != null)
|
|
{
|
|
return SafeName(live) + " (" + SpeciesOf(live) + ")";
|
|
}
|
|
|
|
IReadOnlyList<ChronicleEntry> snap = SnapshotForSubject(subjectId);
|
|
if (snap == null || snap.Count == 0)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
ChronicleEntry last = snap[snap.Count - 1];
|
|
if (last == null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
string name = last.Name ?? "";
|
|
string species = last.SpeciesId ?? "";
|
|
if (string.IsNullOrEmpty(name))
|
|
{
|
|
return species;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(species))
|
|
{
|
|
return name;
|
|
}
|
|
|
|
return name + " (" + species + ")";
|
|
}
|
|
|
|
/// <summary>Focus camera + dossier while idle is paused (does not re-enable spectator).</summary>
|
|
public static bool FocusCameraForBrowse(long unitId)
|
|
{
|
|
Actor unit = FindUnitById(unitId);
|
|
if (unit == null || !unit.isAlive())
|
|
{
|
|
NoteFocusId(unitId);
|
|
return false;
|
|
}
|
|
|
|
WatchCaption.PinWhilePaused();
|
|
CameraDirector.FocusUnit(unit);
|
|
WatchCaption.SetFromActor(unit);
|
|
NoteFocus(unit);
|
|
return true;
|
|
}
|
|
|
|
public static bool FocusSubject(long unitId)
|
|
{
|
|
return FocusSubject(FindUnitById(unitId));
|
|
}
|
|
|
|
public static bool FocusSubject(Actor unit)
|
|
{
|
|
if (unit == null || !unit.isAlive())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!SpectatorMode.Active && ModSettings.Enabled)
|
|
{
|
|
SpectatorMode.SetActive(true);
|
|
}
|
|
|
|
CameraDirector.FocusUnit(unit);
|
|
WatchCaption.SetFromActor(unit);
|
|
NoteFocus(unit);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Which cast the Characters browser should show.</summary>
|
|
public enum CharacterListScope
|
|
{
|
|
Living,
|
|
Fallen
|
|
}
|
|
|
|
/// <summary>
|
|
/// Living: favorites, then recently watched, then by event count.
|
|
/// Fallen: newest death / event first.
|
|
/// </summary>
|
|
public static List<ChronicleCharacterSummary> ListCharacters(
|
|
string search = "",
|
|
bool favoritesOnly = false,
|
|
int max = 40,
|
|
CharacterListScope scope = CharacterListScope.Living)
|
|
{
|
|
var byId = new Dictionary<long, ChronicleCharacterSummary>();
|
|
string needle = (search ?? "").Trim().ToLowerInvariant();
|
|
bool wantLiving = scope == CharacterListScope.Living;
|
|
bool wantFallen = scope == CharacterListScope.Fallen;
|
|
|
|
void Upsert(Actor actor, bool recent, int recentIndex)
|
|
{
|
|
if (!wantLiving || actor == null || !actor.isAlive())
|
|
{
|
|
return;
|
|
}
|
|
|
|
long id;
|
|
try
|
|
{
|
|
id = actor.getID();
|
|
}
|
|
catch
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (id == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool fav = false;
|
|
try
|
|
{
|
|
fav = actor.isFavorite();
|
|
}
|
|
catch
|
|
{
|
|
fav = false;
|
|
}
|
|
|
|
if (favoritesOnly && !fav)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (fav)
|
|
{
|
|
FavoriteIds.Add(id);
|
|
}
|
|
else
|
|
{
|
|
FavoriteIds.Remove(id);
|
|
}
|
|
|
|
string name = SafeName(actor);
|
|
string species = SpeciesOf(actor);
|
|
if (!string.IsNullOrEmpty(needle))
|
|
{
|
|
string hay = ((name ?? "") + " " + (species ?? "")).ToLowerInvariant();
|
|
if (hay.IndexOf(needle, System.StringComparison.Ordinal) < 0)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!byId.TryGetValue(id, out ChronicleCharacterSummary row))
|
|
{
|
|
row = new ChronicleCharacterSummary
|
|
{
|
|
UnitId = id,
|
|
Name = name,
|
|
SpeciesId = species,
|
|
HistoryCount = HistoryCountFor(id),
|
|
IsFavorite = fav,
|
|
IsAlive = true,
|
|
SortTime = LatestActivityTime(id)
|
|
};
|
|
byId[id] = row;
|
|
}
|
|
|
|
if (recent)
|
|
{
|
|
row.IsRecent = true;
|
|
if (row.RecentIndex < 0 || recentIndex < row.RecentIndex)
|
|
{
|
|
row.RecentIndex = recentIndex;
|
|
}
|
|
}
|
|
|
|
row.IsFavorite = fav;
|
|
row.HistoryCount = HistoryCountFor(id);
|
|
row.SortTime = LatestActivityTime(id);
|
|
}
|
|
|
|
if (wantLiving)
|
|
{
|
|
List<long> recentCopy;
|
|
lock (RecentFocusIds)
|
|
{
|
|
recentCopy = new List<long>(RecentFocusIds);
|
|
}
|
|
|
|
for (int i = 0; i < recentCopy.Count; i++)
|
|
{
|
|
Upsert(FindUnitById(recentCopy[i]), recent: true, recentIndex: i);
|
|
}
|
|
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor == null || !actor.isAlive())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
bool fav = false;
|
|
try
|
|
{
|
|
fav = actor.isFavorite();
|
|
}
|
|
catch
|
|
{
|
|
fav = false;
|
|
}
|
|
|
|
long id = 0;
|
|
try
|
|
{
|
|
id = actor.getID();
|
|
}
|
|
catch
|
|
{
|
|
continue;
|
|
}
|
|
|
|
bool hasHist = HistoryCountFor(id) > 0;
|
|
if (!fav && !hasHist && !byId.ContainsKey(id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Upsert(actor, recent: false, recentIndex: -1);
|
|
}
|
|
}
|
|
|
|
if (wantFallen)
|
|
{
|
|
// History subjects that may still be named from entries even if not matched above.
|
|
List<long> histIds;
|
|
lock (Histories)
|
|
{
|
|
histIds = new List<long>(Histories.Keys);
|
|
}
|
|
|
|
for (int i = 0; i < histIds.Count; i++)
|
|
{
|
|
long id = histIds[i];
|
|
if (byId.ContainsKey(id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Actor live = FindUnitById(id);
|
|
if (live != null && live.isAlive())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (favoritesOnly)
|
|
{
|
|
if (!IsFavoriteSubject(id)
|
|
&& !(FinalDossiers.TryGetValue(id, out UnitDossier favDossier)
|
|
&& favDossier != null
|
|
&& favDossier.IsFavorite))
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
|
|
IReadOnlyList<ChronicleEntry> snap = SnapshotForSubject(id);
|
|
if (snap == null || snap.Count == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ChronicleEntry last = snap[snap.Count - 1];
|
|
string name = last?.Name ?? "";
|
|
string species = last?.SpeciesId ?? "";
|
|
if (!string.IsNullOrEmpty(needle))
|
|
{
|
|
string hay = (name + " " + species).ToLowerInvariant();
|
|
if (hay.IndexOf(needle, System.StringComparison.Ordinal) < 0)
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
|
|
bool fallenFav = IsFavoriteSubject(id);
|
|
if (!fallenFav
|
|
&& FinalDossiers.TryGetValue(id, out UnitDossier archived)
|
|
&& archived != null)
|
|
{
|
|
fallenFav = archived.IsFavorite;
|
|
}
|
|
|
|
byId[id] = new ChronicleCharacterSummary
|
|
{
|
|
UnitId = id,
|
|
Name = name,
|
|
SpeciesId = species,
|
|
HistoryCount = snap.Count,
|
|
IsFavorite = fallenFav,
|
|
IsAlive = false,
|
|
IsRecent = false,
|
|
DeathManner = LatestDeathMannerFor(id),
|
|
SortTime = LatestActivityTime(id)
|
|
};
|
|
}
|
|
}
|
|
|
|
var list = new List<ChronicleCharacterSummary>(byId.Values);
|
|
list.Sort((a, b) =>
|
|
{
|
|
// Favorites first on both Living and Fallen.
|
|
if (a.IsFavorite != b.IsFavorite)
|
|
{
|
|
return a.IsFavorite ? -1 : 1;
|
|
}
|
|
|
|
if (wantFallen || (!a.IsAlive && !b.IsAlive))
|
|
{
|
|
// Fallen: most recent event first.
|
|
if (a.SortTime != b.SortTime)
|
|
{
|
|
return b.SortTime.CompareTo(a.SortTime);
|
|
}
|
|
|
|
if (a.HistoryCount != b.HistoryCount)
|
|
{
|
|
return b.HistoryCount.CompareTo(a.HistoryCount);
|
|
}
|
|
|
|
return string.Compare(a.Title, b.Title, System.StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// Living: recently watched, event count, name.
|
|
int ra = a.IsRecent && a.RecentIndex >= 0 ? a.RecentIndex : int.MaxValue;
|
|
int rb = b.IsRecent && b.RecentIndex >= 0 ? b.RecentIndex : int.MaxValue;
|
|
if (ra != rb)
|
|
{
|
|
return ra.CompareTo(rb);
|
|
}
|
|
|
|
if (a.HistoryCount != b.HistoryCount)
|
|
{
|
|
return b.HistoryCount.CompareTo(a.HistoryCount);
|
|
}
|
|
|
|
return string.Compare(a.Title, b.Title, System.StringComparison.OrdinalIgnoreCase);
|
|
});
|
|
|
|
if (max > 0 && list.Count > max)
|
|
{
|
|
list.RemoveRange(max, list.Count - max);
|
|
}
|
|
|
|
return list;
|
|
}
|
|
|
|
private static float LatestActivityTime(long subjectId)
|
|
{
|
|
IReadOnlyList<ChronicleEntry> snap = SnapshotForSubject(subjectId);
|
|
float best = 0f;
|
|
for (int i = 0; i < snap.Count; i++)
|
|
{
|
|
ChronicleEntry e = snap[i];
|
|
if (e != null && e.CreatedAt > best)
|
|
{
|
|
best = e.CreatedAt;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
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 && id != _trackedFocusId)
|
|
{
|
|
_trackedFocusId = id;
|
|
NoteFocusId(id);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
ChronicleHud.UpdateLive();
|
|
}
|
|
|
|
/// <summary>Killer POV - Chronicle Life when enabled; always mirrors to Activity.</summary>
|
|
public static void NoteKill(Actor killer, Actor victim)
|
|
{
|
|
if (killer == null || victim == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string victimName = SafeName(victim);
|
|
string line = $"Killed {victimName} ({SpeciesOf(victim)})";
|
|
ActivityLog.NoteMilestone(killer, "milestone_kill", line, victimName);
|
|
if (ModSettings.ChronicleEnabled)
|
|
{
|
|
AppendHistory(ChronicleKind.Kill, killer, victim, line);
|
|
}
|
|
}
|
|
|
|
/// <summary>Victim POV - Chronicle Life when enabled; always mirrors to Activity.</summary>
|
|
public static void NoteDeath(Actor victim, AttackType attackType, Actor killer)
|
|
{
|
|
if (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);
|
|
string killerName = killer != null ? SafeName(killer) : "";
|
|
ActivityLog.NoteMilestone(victim, "milestone_death", line, killerName);
|
|
if (ModSettings.ChronicleEnabled)
|
|
{
|
|
AppendHistory(ChronicleKind.Death, victim, killer, line, attackType);
|
|
}
|
|
}
|
|
|
|
public static void NoteLovers(Actor a, Actor b)
|
|
{
|
|
if (a == null || b == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string key = PairKey(a.getID(), b.getID());
|
|
if (!LoverPairKeys.Add(key))
|
|
{
|
|
return;
|
|
}
|
|
|
|
CapPairKeys(LoverPairKeys);
|
|
|
|
string nameA = SafeName(a);
|
|
string nameB = SafeName(b);
|
|
string lineA = $"Became lovers with {nameB}";
|
|
string lineB = $"Became lovers with {nameA}";
|
|
ActivityLog.NoteMilestone(a, "milestone_lover", lineA, nameB);
|
|
ActivityLog.NoteMilestone(b, "milestone_lover", lineB, nameA);
|
|
if (ModSettings.ChronicleEnabled)
|
|
{
|
|
AppendHistory(ChronicleKind.Lover, a, b, lineA);
|
|
AppendHistory(ChronicleKind.Lover, b, a, lineB);
|
|
}
|
|
}
|
|
|
|
public static void NoteBestFriends(Actor a, Actor b)
|
|
{
|
|
if (a == null || b == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string key = PairKey(a.getID(), b.getID());
|
|
if (!FriendPairKeys.Add(key))
|
|
{
|
|
return;
|
|
}
|
|
|
|
CapPairKeys(FriendPairKeys);
|
|
|
|
string nameA = SafeName(a);
|
|
string nameB = SafeName(b);
|
|
string lineA = $"Befriended {nameB}";
|
|
string lineB = $"Befriended {nameA}";
|
|
ActivityLog.NoteMilestone(a, "milestone_friend", lineA, nameB);
|
|
ActivityLog.NoteMilestone(b, "milestone_friend", lineB, nameA);
|
|
if (ModSettings.ChronicleEnabled)
|
|
{
|
|
AppendHistory(ChronicleKind.Friend, a, b, lineA);
|
|
AppendHistory(ChronicleKind.Friend, b, a, lineB);
|
|
}
|
|
}
|
|
|
|
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: fire a real Life milestone path (Activity mirror + Chronicle when enabled).
|
|
/// kind: kill | death | lover | friend
|
|
/// </summary>
|
|
public static bool ForceLifeMilestoneOnFocus(string kind, string otherName = "")
|
|
{
|
|
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Actor focus = MoveCamera._focus_unit;
|
|
Actor other = FindOtherLivingUnit(focus);
|
|
string k = (kind ?? "").Trim().ToLowerInvariant();
|
|
string label = string.IsNullOrEmpty(otherName)
|
|
? (other != null ? SafeName(other) : "Barkley")
|
|
: otherName;
|
|
|
|
switch (k)
|
|
{
|
|
case "kill":
|
|
if (other != null)
|
|
{
|
|
NoteKill(focus, other);
|
|
return true;
|
|
}
|
|
|
|
ActivityLog.NoteMilestone(
|
|
focus,
|
|
"milestone_kill",
|
|
$"Killed {label} (creature)",
|
|
label);
|
|
if (ModSettings.ChronicleEnabled)
|
|
{
|
|
AppendHistory(ChronicleKind.Kill, focus, null, $"Killed {label} (creature)");
|
|
}
|
|
|
|
return true;
|
|
|
|
case "death":
|
|
NoteDeath(focus, AttackType.Age, other);
|
|
return true;
|
|
|
|
case "lover":
|
|
if (other != null)
|
|
{
|
|
// Allow re-force across scenario steps by clearing this pair first.
|
|
LoverPairKeys.Remove(PairKey(focus.getID(), other.getID()));
|
|
NoteLovers(focus, other);
|
|
return true;
|
|
}
|
|
|
|
ActivityLog.NoteMilestone(
|
|
focus,
|
|
"milestone_lover",
|
|
$"Became lovers with {label}",
|
|
label);
|
|
if (ModSettings.ChronicleEnabled)
|
|
{
|
|
AppendHistory(ChronicleKind.Lover, focus, null, $"Became lovers with {label}");
|
|
}
|
|
|
|
return true;
|
|
|
|
case "friend":
|
|
if (other != null)
|
|
{
|
|
FriendPairKeys.Remove(PairKey(focus.getID(), other.getID()));
|
|
NoteBestFriends(focus, other);
|
|
return true;
|
|
}
|
|
|
|
ActivityLog.NoteMilestone(
|
|
focus,
|
|
"milestone_friend",
|
|
$"Befriended {label}",
|
|
label);
|
|
if (ModSettings.ChronicleEnabled)
|
|
{
|
|
AppendHistory(ChronicleKind.Friend, focus, null, $"Befriended {label}");
|
|
}
|
|
|
|
return true;
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static Actor FindOtherLivingUnit(Actor exclude)
|
|
{
|
|
try
|
|
{
|
|
long excludeId = 0;
|
|
try
|
|
{
|
|
excludeId = exclude != null ? exclude.getID() : 0;
|
|
}
|
|
catch
|
|
{
|
|
excludeId = 0;
|
|
}
|
|
|
|
foreach (Actor unit in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (unit == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (unit.getID() == excludeId)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
return unit;
|
|
}
|
|
catch
|
|
{
|
|
// skip
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static long _orphanSeq = -1000;
|
|
|
|
/// <summary>
|
|
/// Harness: inject history for a subject id with no living unit (Fallen list).
|
|
/// </summary>
|
|
public static long ForceOrphanHistory(
|
|
string name,
|
|
string species,
|
|
int count,
|
|
string labelPrefix = "Fallen",
|
|
AttackType attackType = AttackType.Age,
|
|
long killerId = 0,
|
|
Vector3 deathPos = default,
|
|
bool favorite = false)
|
|
{
|
|
count = Mathf.Clamp(count, 1, MaxHistoryPerUnit);
|
|
long id;
|
|
lock (Histories)
|
|
{
|
|
id = _orphanSeq--;
|
|
}
|
|
|
|
string who = string.IsNullOrEmpty(name) ? "Nameless" : name;
|
|
string sp = string.IsNullOrEmpty(species) ? "creature" : species;
|
|
string prefix = string.IsNullOrEmpty(labelPrefix) ? "Fallen" : labelPrefix;
|
|
bool hasKiller = killerId != 0;
|
|
DeathManner manner = ClassifyDeathManner(attackType, hasKiller);
|
|
string baseCause;
|
|
switch (manner)
|
|
{
|
|
case DeathManner.Slain:
|
|
baseCause = hasKiller ? "Killed by foe" : "Killed in combat";
|
|
break;
|
|
case DeathManner.OldAge:
|
|
baseCause = "Died of old age";
|
|
break;
|
|
case DeathManner.Starvation:
|
|
baseCause = "Starved to death";
|
|
break;
|
|
case DeathManner.Drowned:
|
|
baseCause = "Drowned";
|
|
break;
|
|
case DeathManner.Burned:
|
|
baseCause = "Burned to death";
|
|
break;
|
|
case DeathManner.Plague:
|
|
baseCause = "Died of plague";
|
|
break;
|
|
case DeathManner.Divine:
|
|
baseCause = "Struck down by divine power";
|
|
break;
|
|
case DeathManner.Accident:
|
|
baseCause = "Died in an explosion";
|
|
break;
|
|
default:
|
|
baseCause = "Died";
|
|
break;
|
|
}
|
|
|
|
RefreshAgeChapter(force: false);
|
|
StampWorldDate(out double worldTime, out string dateLabel);
|
|
|
|
lock (Histories)
|
|
{
|
|
if (!Histories.TryGetValue(id, out List<ChronicleEntry> list))
|
|
{
|
|
list = new List<ChronicleEntry>();
|
|
Histories[id] = list;
|
|
}
|
|
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
string line = count > 1 ? $"{baseCause} ({prefix} #{i + 1})" : baseCause;
|
|
string lore = LoreProse.Rewrite(ChronicleKind.Death, line);
|
|
list.Add(new ChronicleEntry
|
|
{
|
|
CreatedAt = Time.unscaledTime,
|
|
WorldTime = worldTime,
|
|
DateLabel = dateLabel,
|
|
Kind = ChronicleKind.Death,
|
|
SubjectId = id,
|
|
OtherId = killerId,
|
|
Name = who,
|
|
SpeciesId = sp,
|
|
Line = line,
|
|
LoreLine = lore,
|
|
Position = deathPos,
|
|
HasWorldPosition = deathPos.sqrMagnitude > 0.0001f,
|
|
AgeId = _currentAgeId,
|
|
AgeName = _currentAgeName,
|
|
AttackTypeName = attackType.ToString(),
|
|
DeathManner = manner
|
|
});
|
|
}
|
|
|
|
while (list.Count > MaxHistoryPerUnit)
|
|
{
|
|
list.RemoveAt(0);
|
|
}
|
|
|
|
PruneHistorySubjectsUnlocked();
|
|
}
|
|
|
|
BumpRevision();
|
|
|
|
UnitDossier archived = UnitDossier.FromFallenArchive(id, who, sp, manner);
|
|
archived.IsFavorite = favorite;
|
|
FinalDossiers[id] = archived;
|
|
if (favorite)
|
|
{
|
|
FavoriteIds.Add(id);
|
|
}
|
|
|
|
PruneFinalDossiers();
|
|
return id;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
Vector3 pos = Vector3.zero;
|
|
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
try
|
|
{
|
|
pos = MoveCamera._focus_unit.current_position;
|
|
}
|
|
catch
|
|
{
|
|
pos = Vector3.zero;
|
|
}
|
|
}
|
|
|
|
if (pos.sqrMagnitude < 0.0001f && MoveCamera.instance != null)
|
|
{
|
|
pos = MoveCamera.instance.transform.position;
|
|
pos.z = 0f;
|
|
}
|
|
|
|
if (pos.sqrMagnitude < 0.0001f)
|
|
{
|
|
pos = new Vector3(48f, 48f, 0f);
|
|
}
|
|
|
|
AppendLandmark(
|
|
ChronicleKind.World,
|
|
null,
|
|
null,
|
|
label,
|
|
LoreProse.Rewrite(ChronicleKind.World, label),
|
|
pos,
|
|
"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, attackType);
|
|
return true;
|
|
}
|
|
|
|
public static DeathManner ClassifyDeathManner(AttackType attackType, bool hasKiller)
|
|
{
|
|
switch (attackType)
|
|
{
|
|
case AttackType.Age:
|
|
return DeathManner.OldAge;
|
|
case AttackType.Starvation:
|
|
return DeathManner.Starvation;
|
|
case AttackType.Drowning:
|
|
case AttackType.Water:
|
|
return DeathManner.Drowned;
|
|
case AttackType.Fire:
|
|
return DeathManner.Burned;
|
|
case AttackType.Plague:
|
|
case AttackType.Infection:
|
|
case AttackType.Poison:
|
|
case AttackType.Tumor:
|
|
case AttackType.AshFever:
|
|
return DeathManner.Plague;
|
|
case AttackType.Divine:
|
|
return DeathManner.Divine;
|
|
case AttackType.Gravity:
|
|
case AttackType.Explosion:
|
|
case AttackType.Acid:
|
|
case AttackType.Metamorphosis:
|
|
return DeathManner.Accident;
|
|
case AttackType.Eaten:
|
|
case AttackType.Weapon:
|
|
case AttackType.Smile:
|
|
case AttackType.Other:
|
|
return hasKiller ? DeathManner.Slain : DeathManner.Other;
|
|
case AttackType.None:
|
|
return hasKiller ? DeathManner.Slain : DeathManner.Other;
|
|
default:
|
|
return hasKiller ? DeathManner.Slain : DeathManner.Other;
|
|
}
|
|
}
|
|
|
|
public static DeathManner InferDeathManner(string attackTypeName, bool hasKiller, string line)
|
|
{
|
|
if (!string.IsNullOrEmpty(attackTypeName)
|
|
&& System.Enum.TryParse(attackTypeName, ignoreCase: true, out AttackType parsed))
|
|
{
|
|
return ClassifyDeathManner(parsed, hasKiller);
|
|
}
|
|
|
|
string raw = line ?? "";
|
|
if (raw.IndexOf("old age", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return DeathManner.OldAge;
|
|
}
|
|
|
|
if (raw.IndexOf("Starved", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return DeathManner.Starvation;
|
|
}
|
|
|
|
if (raw.IndexOf("Drown", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return DeathManner.Drowned;
|
|
}
|
|
|
|
if (raw.IndexOf("Burn", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| raw.IndexOf("lava", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return DeathManner.Burned;
|
|
}
|
|
|
|
if (raw.IndexOf("plague", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| raw.IndexOf("poison", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| raw.IndexOf("infection", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| raw.IndexOf("tumor", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| raw.IndexOf("ash fever", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return DeathManner.Plague;
|
|
}
|
|
|
|
if (raw.IndexOf("divine", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return DeathManner.Divine;
|
|
}
|
|
|
|
if (raw.IndexOf("explosion", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| raw.IndexOf("acid", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| raw.IndexOf("Fell to", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return DeathManner.Accident;
|
|
}
|
|
|
|
if (hasKiller
|
|
|| raw.IndexOf("Killed by", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| raw.IndexOf("Eaten by", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| raw.IndexOf("Was eaten", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return DeathManner.Slain;
|
|
}
|
|
|
|
return DeathManner.Other;
|
|
}
|
|
|
|
public static string DeathMannerLabel(DeathManner manner)
|
|
{
|
|
switch (manner)
|
|
{
|
|
case DeathManner.Slain:
|
|
return "slain";
|
|
case DeathManner.OldAge:
|
|
return "old age";
|
|
case DeathManner.Starvation:
|
|
return "starved";
|
|
case DeathManner.Drowned:
|
|
return "drowned";
|
|
case DeathManner.Burned:
|
|
return "burned";
|
|
case DeathManner.Plague:
|
|
return "plague";
|
|
case DeathManner.Accident:
|
|
return "accident";
|
|
case DeathManner.Divine:
|
|
return "divine";
|
|
case DeathManner.Other:
|
|
return "dead";
|
|
default:
|
|
return "fallen";
|
|
}
|
|
}
|
|
|
|
/// <summary>Newest death event for a subject, if any.</summary>
|
|
public static ChronicleEntry LatestDeathEntry(long subjectId)
|
|
{
|
|
IReadOnlyList<ChronicleEntry> snap = SnapshotForSubject(subjectId);
|
|
for (int i = snap.Count - 1; i >= 0; i--)
|
|
{
|
|
ChronicleEntry e = snap[i];
|
|
if (e != null && e.Kind == ChronicleKind.Death)
|
|
{
|
|
return e;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static DeathManner LatestDeathMannerFor(long subjectId)
|
|
{
|
|
ChronicleEntry death = LatestDeathEntry(subjectId);
|
|
return death != null ? death.ResolvedDeathManner : DeathManner.None;
|
|
}
|
|
|
|
/// <summary>Harness: how the last fallen focus resolved (killer:id | place | none).</summary>
|
|
public static string LastFallenFocusDetail { get; private set; } = "";
|
|
|
|
/// <summary>Harness: world position used for the last place-style fallen focus.</summary>
|
|
public static Vector3 LastFallenFocusPosition { get; private set; } = Vector3.zero;
|
|
|
|
/// <summary>Snapshot living dossier just before die() - for Fallen archive UI.</summary>
|
|
public static void CaptureFinalDossier(Actor victim)
|
|
{
|
|
if (victim == null || !victim.isAlive())
|
|
{
|
|
return;
|
|
}
|
|
|
|
long id = 0;
|
|
try
|
|
{
|
|
id = victim.getID();
|
|
}
|
|
catch
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (id == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
UnitDossier dossier = UnitDossier.FromActor(victim);
|
|
if (dossier == null || dossier.UnitId == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
FinalDossiers[id] = dossier;
|
|
if (dossier.IsFavorite)
|
|
{
|
|
FavoriteIds.Add(id);
|
|
}
|
|
|
|
PruneFinalDossiers();
|
|
}
|
|
|
|
/// <summary>Last living dossier for a fallen subject, or a minimal archive rebuild.</summary>
|
|
public static UnitDossier ResolveFallenDossier(long unitId)
|
|
{
|
|
if (unitId == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (FinalDossiers.TryGetValue(unitId, out UnitDossier cached) && cached != null)
|
|
{
|
|
return cached;
|
|
}
|
|
|
|
ChronicleEntry death = LatestDeathEntry(unitId);
|
|
ChronicleEntry any = death;
|
|
if (any == null)
|
|
{
|
|
IReadOnlyList<ChronicleEntry> snap = SnapshotForSubject(unitId);
|
|
if (snap != null && snap.Count > 0)
|
|
{
|
|
any = snap[snap.Count - 1];
|
|
}
|
|
}
|
|
|
|
if (any == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
DeathManner manner = death != null ? death.ResolvedDeathManner : DeathManner.None;
|
|
UnitDossier rebuilt = UnitDossier.FromFallenArchive(unitId, any.Name, any.SpeciesId, manner);
|
|
FinalDossiers[unitId] = rebuilt;
|
|
return rebuilt;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Camera for a dead subject: living killer if possible, else last death / history position.
|
|
/// Dossier always shows the fallen unit's last living state (not the killer).
|
|
/// </summary>
|
|
public static bool FocusFallenSubject(long unitId)
|
|
{
|
|
LastFallenFocusDetail = "none";
|
|
LastFallenFocusPosition = Vector3.zero;
|
|
if (unitId == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
UnitDossier fallen = ResolveFallenDossier(unitId);
|
|
if (fallen != null)
|
|
{
|
|
WatchCaption.PinWhilePaused();
|
|
WatchCaption.SetFromDossier(fallen);
|
|
}
|
|
|
|
ChronicleEntry death = LatestDeathEntry(unitId);
|
|
if (death != null && death.OtherId != 0)
|
|
{
|
|
Actor killer = FindUnitById(death.OtherId);
|
|
if (killer != null && killer.isAlive())
|
|
{
|
|
CameraDirector.FocusUnit(killer, updateCaption: false);
|
|
LastFallenFocusDetail = "killer:" + death.OtherId;
|
|
LogService.LogInfo(
|
|
$"[IdleSpectator][LORE] fallen focus killer id={death.OtherId} victim={unitId}");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (TryResolveFallenPlace(unitId, death, out Vector3 pos))
|
|
{
|
|
// Drop any prior follow target or the camera eases back away from the death site.
|
|
CameraDirector.ClearFollow();
|
|
CameraDirector.PanTo(pos);
|
|
LastFallenFocusDetail = "place";
|
|
LastFallenFocusPosition = pos;
|
|
LogService.LogInfo($"[IdleSpectator][LORE] fallen focus place victim={unitId} pos={pos}");
|
|
return true;
|
|
}
|
|
|
|
return fallen != null;
|
|
}
|
|
|
|
private static bool EntryHasWorldPosition(ChronicleEntry e)
|
|
{
|
|
if (e == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (e.HasWorldPosition)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Legacy session entries before HasWorldPosition existed.
|
|
return e.Position.sqrMagnitude > 0.0001f;
|
|
}
|
|
|
|
private static bool TryResolveFallenPlace(long unitId, ChronicleEntry death, out Vector3 pos)
|
|
{
|
|
pos = Vector3.zero;
|
|
if (EntryHasWorldPosition(death))
|
|
{
|
|
pos = death.Position;
|
|
return true;
|
|
}
|
|
|
|
IReadOnlyList<ChronicleEntry> snap = SnapshotForSubject(unitId);
|
|
for (int i = snap.Count - 1; i >= 0; i--)
|
|
{
|
|
if (EntryHasWorldPosition(snap[i]))
|
|
{
|
|
pos = snap[i].Position;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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,
|
|
AttackType? attackType = null)
|
|
{
|
|
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,
|
|
HasWorldPosition = true,
|
|
AgeId = _currentAgeId,
|
|
AgeName = _currentAgeName
|
|
};
|
|
|
|
if (kind == ChronicleKind.Death)
|
|
{
|
|
bool hasKiller = other != null;
|
|
if (attackType.HasValue)
|
|
{
|
|
entry.AttackTypeName = attackType.Value.ToString();
|
|
entry.DeathManner = ClassifyDeathManner(attackType.Value, hasKiller);
|
|
}
|
|
else
|
|
{
|
|
entry.DeathManner = InferDeathManner("", hasKiller, line);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
PruneHistorySubjectsUnlocked();
|
|
}
|
|
|
|
_lastHistorySubjectId = subjectId;
|
|
BumpRevision();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drop oldest dormant subjects when over <see cref="MaxHistorySubjects"/>.
|
|
/// Caller must hold the Histories lock.
|
|
/// </summary>
|
|
private static void PruneHistorySubjectsUnlocked()
|
|
{
|
|
if (Histories.Count <= MaxHistorySubjects)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var keep = new HashSet<long>();
|
|
if (_trackedFocusId != 0)
|
|
{
|
|
keep.Add(_trackedFocusId);
|
|
}
|
|
|
|
if (_lastHistorySubjectId != 0)
|
|
{
|
|
keep.Add(_lastHistorySubjectId);
|
|
}
|
|
|
|
lock (RecentFocusIds)
|
|
{
|
|
for (int i = 0; i < RecentFocusIds.Count; i++)
|
|
{
|
|
keep.Add(RecentFocusIds[i]);
|
|
}
|
|
}
|
|
|
|
var ranked = new List<(long id, float lastAt)>(Histories.Count);
|
|
foreach (KeyValuePair<long, List<ChronicleEntry>> kv in Histories)
|
|
{
|
|
if (keep.Contains(kv.Key))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float lastAt = 0f;
|
|
List<ChronicleEntry> list = kv.Value;
|
|
if (list != null && list.Count > 0)
|
|
{
|
|
ChronicleEntry last = list[list.Count - 1];
|
|
if (last != null)
|
|
{
|
|
lastAt = last.CreatedAt;
|
|
}
|
|
}
|
|
|
|
ranked.Add((kv.Key, lastAt));
|
|
}
|
|
|
|
ranked.Sort((a, b) => a.lastAt.CompareTo(b.lastAt));
|
|
|
|
int need = Histories.Count - MaxHistorySubjects;
|
|
for (int i = 0; i < ranked.Count && need > 0; i++)
|
|
{
|
|
long id = ranked[i].id;
|
|
Histories.Remove(id);
|
|
DeathLogged.Remove(id);
|
|
FinalDossiers.Remove(id);
|
|
FavoriteIds.Remove(id);
|
|
need--;
|
|
}
|
|
}
|
|
|
|
private static void PruneFinalDossiers()
|
|
{
|
|
if (FinalDossiers.Count <= MaxHistorySubjects)
|
|
{
|
|
return;
|
|
}
|
|
|
|
HashSet<long> histIds;
|
|
lock (Histories)
|
|
{
|
|
histIds = new HashSet<long>(Histories.Keys);
|
|
}
|
|
|
|
var orphan = new List<long>();
|
|
foreach (long id in FinalDossiers.Keys)
|
|
{
|
|
if (!histIds.Contains(id))
|
|
{
|
|
orphan.Add(id);
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < orphan.Count; i++)
|
|
{
|
|
FinalDossiers.Remove(orphan[i]);
|
|
}
|
|
|
|
if (FinalDossiers.Count <= MaxHistorySubjects)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Still over: drop extras not recently focused.
|
|
var protect = new HashSet<long>();
|
|
if (_trackedFocusId != 0)
|
|
{
|
|
protect.Add(_trackedFocusId);
|
|
}
|
|
|
|
if (_lastHistorySubjectId != 0)
|
|
{
|
|
protect.Add(_lastHistorySubjectId);
|
|
}
|
|
|
|
lock (RecentFocusIds)
|
|
{
|
|
for (int i = 0; i < RecentFocusIds.Count; i++)
|
|
{
|
|
protect.Add(RecentFocusIds[i]);
|
|
}
|
|
}
|
|
|
|
var drop = new List<long>();
|
|
foreach (long id in FinalDossiers.Keys)
|
|
{
|
|
if (protect.Contains(id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
drop.Add(id);
|
|
if (FinalDossiers.Count - drop.Count <= MaxHistorySubjects)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < drop.Count; i++)
|
|
{
|
|
FinalDossiers.Remove(drop[i]);
|
|
}
|
|
}
|
|
|
|
private static void CapPairKeys(HashSet<string> keys)
|
|
{
|
|
if (keys == null || keys.Count <= MaxPairKeys)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Dedupes are best-effort; clearing under pressure avoids unbounded growth.
|
|
keys.Clear();
|
|
}
|
|
|
|
private static void CapLandmarkDedupe()
|
|
{
|
|
if (LandmarkDedupe.Count <= MaxLandmarkDedupe)
|
|
{
|
|
return;
|
|
}
|
|
|
|
LandmarkDedupe.Clear();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
CapLandmarkDedupe();
|
|
|
|
lock (MemoryLandmarks)
|
|
{
|
|
if (!LandmarksPerAge.TryGetValue(ageId, out int ageCount))
|
|
{
|
|
ageCount = 0;
|
|
}
|
|
|
|
if (ageCount >= MaxLandmarksPerAge)
|
|
{
|
|
LandmarkDedupe.Remove(dedupeKey);
|
|
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,
|
|
HasWorldPosition = subject != null || fallbackPosition.sqrMagnitude > 0.0001f,
|
|
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 (removed != null)
|
|
{
|
|
if (!string.IsNullOrEmpty(removed.AgeId)
|
|
&& LandmarksPerAge.TryGetValue(removed.AgeId, out int c)
|
|
&& c > 0)
|
|
{
|
|
LandmarksPerAge[removed.AgeId] = c - 1;
|
|
}
|
|
|
|
string oldKey = (removed.AgeId ?? "") + "|" + (removed.AssetId ?? "") + "|" + (removed.Line ?? "");
|
|
LandmarkDedupe.Remove(oldKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
BumpRevision();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
BumpRevision();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|