Compare commits

...

10 commits

Author SHA1 Message Date
0dfe3ab832 Add detailed activity prose for various actions in ActivityProse class
Expanded the ActivityProse class with new templates for actions such as laughing, singing, and playing. Enhanced the formatting logic to ensure proper sentence structure for activities, including mood and task labels. Updated AgentHarness to utilize the new prose formatting for activity sentences and adjusted test scenarios in HarnessScenarios to validate these changes. Incremented version in mod.json to reflect the update.
2026-07-14 23:48:53 -05:00
63bf44cae5 Enhance ActivityLog and ActivityInterestTable with detailed target resolution and activity tracking. Implemented new methods for resolving actor targets and formatting activity statistics, including colored display options. Updated HUD interactions to reflect these changes and added test scenarios for improved activity logging and display functionality. 2026-07-14 23:40:39 -05:00
beb08265ec Its a start 2026-07-14 23:11:51 -05:00
94de8a3b5b Implement favorites filtering for Fallen characters in Chronicle and HUD. Added functionality to track and manage favorite subjects across death events, enhancing user experience. Updated test scenarios to validate new favorite handling and interactions. 2026-07-14 22:36:06 -05:00
0b23a15618 Enhance Chronicle and HUD functionality with new subject management features. Implemented subject cap adjustments, timed rebuilds for Fallen/Living lists, and added refresh capabilities to the HUD. Updated test scenarios to validate new behaviors and ensure proper subject handling. 2026-07-14 22:29:09 -05:00
88cf59a6aa Implement world memory filtering in AgentHarness to ensure character kills/deaths are excluded from World Memory. Update Chronicle documentation for clarity and remove obsolete legend promotion logic. Adjust test scenarios to validate new world memory behavior. 2026-07-14 22:06:50 -05:00
7876321391 Enhance AgentHarness and Chronicle functionality to support detailed jump tracking. Added LastJumpDetail and LastJumpPosition properties for improved state management during jumps. Updated HUD interactions to reflect jump outcomes and integrated new test scenarios for world event navigation. 2026-07-14 22:03:50 -05:00
94b356c9c8 Enhance Lore panel with Living and Fallen tabs, implementing character death tracking and improved HUD interactions. Added support for death manner classification and updated dossier handling for fallen units. Adjusted test scenarios to validate new features and behaviors. 2026-07-14 21:57:13 -05:00
b40f3f8621 Implement Lore panel functionality, replacing Chronicle with a new tabbed interface for World Memory and Character History. Enhanced HUD interactions, added search and favorites features, and updated key bindings. Adjusted test scenarios to validate new Lore features and behaviors. 2026-07-14 21:19:13 -05:00
0f30f18bcb Refactor Chronicle to World Memory, enhancing HUD and functionality. Updated entry handling, added age chapter support, and improved history display. Adjusted test scenarios and assertions to align with new features. 2026-07-14 20:09:55 -05:00
26 changed files with 8400 additions and 469 deletions

View file

@ -112,6 +112,7 @@ A loaded world is still required.
| `discovery` | Present dedupe + force drain + tip match |
| `world_log` | Story/Epic interest interrupt via collector |
| `chronicle_smoke` | History inject, death-cause samples, World feed isolation, History\|World tabs, HUD jump |
| `chronicle_subject_bench` | Timed Fallen list rebuilds at rising subject caps; recommends a budgeted max |
| `smoke` | Short enable/focus health check |
| `tip_match` | Tip asset must match focused unit |
| `regression` | Shell suite of all gate scenarios above |

View file

@ -47,6 +47,8 @@ Use harness `fast_timing` + `director_run` / `age_current` for scheduling tests.
| Director tests take forever | production dwell/rotate | `fast_timing` (not game 5x) |
| Game never opens / `Cannot run as root user` | Shell sandboxed; Steam rejects root-like env | re-run launch/`harness-run.sh` with Shell `required_permissions: ["all"]` |
| Old mod still loaded after edit | NML caches until restart | kill `.../common/worldbox/worldbox`, bump `mod.json`, relaunch with `all` perms |
| Harness says ready but mod broken | Stale `Harmony patches applied` / missed `error CS` | runner fails boot on IdleSpectator `error CS`; ready requires Harmony in current (truncated) Player.log |
| Harness stuck on `o...` forever | Launch marker written into Player.log then wiped by Unity | do not put boot markers in Player.log; wait for process + Harmony |
## Useful asserts

View file

@ -0,0 +1,334 @@
using UnityEngine;
namespace IdleSpectator;
/// <summary>How interesting a unit's current AI task/job is for idle scoring.</summary>
public enum ActivityBand
{
Cold = 0,
Warm = 1,
Hot = 2
}
/// <summary>
/// Maps WorldBox task / citizen-job ids (and combat flags) to Hot/Warm/Cold interest.
/// Unknown ids default to Warm if they look like work, else Cold.
/// </summary>
public static class ActivityInterestTable
{
public const float WarmScoreFloor = 18f;
public const float HotScoreFloor = 40f;
/// <summary>Activity score 0100 used by idle scoring.</summary>
public static float ScoreActivity(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return 0f;
}
ActivityBand band = Classify(actor);
float score = band switch
{
ActivityBand.Hot => 55f,
ActivityBand.Warm => 28f,
_ => 4f
};
if (actor.has_attack_target)
{
score = Mathf.Max(score, 70f);
}
try
{
if (actor.hasTask() && actor.ai?.task != null && actor.ai.task.in_combat)
{
score = Mathf.Max(score, 72f);
}
if (actor.hasTask() && actor.ai?.task != null && actor.ai.task.is_fireman)
{
score = Mathf.Max(score, 60f);
}
}
catch
{
// ignore
}
return Mathf.Clamp(score, 0f, 100f);
}
public static ActivityBand Classify(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return ActivityBand.Cold;
}
if (actor.has_attack_target)
{
return ActivityBand.Hot;
}
string taskId = SafeTaskId(actor);
string jobId = SafeJobId(actor);
ActivityBand fromTask = ClassifyId(taskId);
ActivityBand fromJob = ClassifyId(jobId);
ActivityBand best = fromTask > fromJob ? fromTask : fromJob;
try
{
if (actor.hasTask() && actor.ai?.task != null)
{
if (actor.ai.task.in_combat || actor.ai.task.is_fireman)
{
return ActivityBand.Hot;
}
}
}
catch
{
// ignore
}
return best;
}
public static ActivityBand ClassifyId(string id)
{
if (string.IsNullOrEmpty(id))
{
return ActivityBand.Cold;
}
string s = id.ToLowerInvariant();
if (ContainsAny(s,
"fight", "attack", "hunt", "fireman", "fire", "war", "kill", "danger",
"unload", "throw_resource", "capture", "siege"))
{
return ActivityBand.Hot;
}
if (ContainsAny(s,
"farm", "harvest", "plant", "fertiliz", "mine", "gather", "woodcut", "chop",
"build", "construct", "road", "smith", "blacksmith", "pollinat", "bee",
"honey", "herb", "bush", "social", "lover", "breed", "fish", "trade",
"clean", "fertiliz", "bucket", "hoe"))
{
return ActivityBand.Warm;
}
if (ContainsAny(s,
"eat", "sleep", "wait", "idle", "random_move", "random_swim", "make_decision",
"run_away", "swim_to", "print_", "godfinger", "end_job"))
{
return ActivityBand.Cold;
}
// Unknown active task: mild warm so we do not ignore novel work.
if (s.Length > 0)
{
return ActivityBand.Warm;
}
return ActivityBand.Cold;
}
public static string SafeTaskId(Actor actor)
{
try
{
if (actor != null && actor.hasTask() && actor.ai?.task != null)
{
return actor.ai.task.id ?? "";
}
}
catch
{
// ignore
}
return "";
}
public static string SafeJobId(Actor actor)
{
try
{
if (actor?.citizen_job != null)
{
return actor.citizen_job.id ?? "";
}
}
catch
{
// ignore
}
return "";
}
public static string DumpFocusProbe(Actor actor)
{
if (actor == null)
{
return "actor=null";
}
string name = "?";
try
{
name = actor.getName() ?? "?";
}
catch
{
// ignore
}
string taskId = SafeTaskId(actor);
string jobId = SafeJobId(actor);
string taskLoc = "";
try
{
if (actor.hasTask() && actor.ai?.task != null)
{
taskLoc = actor.ai.task.getLocalizedText() ?? "";
}
}
catch
{
// ignore
}
string actorTarget = TargetLabel(actor);
ActivityBand band = Classify(actor);
return
$"name={name} task='{taskId}' job='{jobId}' loc='{taskLoc}' target='{actorTarget}' "
+ $"band={band} activity={ScoreActivity(actor):0.0} attack={actor.has_attack_target}";
}
public static string TargetLabel(Actor actor)
{
ResolveTarget(actor, out string name, out _, out string place);
return !string.IsNullOrEmpty(name) ? name : place;
}
/// <summary>
/// Prefer live actor targets (by name). Places/buildings stay as type labels.
/// </summary>
public static void ResolveTarget(
Actor actor,
out string targetName,
out bool targetIsActor,
out string placeLabel)
{
targetName = "";
targetIsActor = false;
placeLabel = "";
if (actor == null)
{
return;
}
try
{
if (actor.has_attack_target && actor.attack_target != null && actor.attack_target.isAlive())
{
if (actor.attack_target.isActor())
{
Actor foe = actor.attack_target.a;
if (foe != null)
{
string n = foe.getName();
if (!string.IsNullOrEmpty(n))
{
targetName = n;
targetIsActor = true;
return;
}
}
}
}
if (actor.beh_actor_target != null && actor.beh_actor_target.isAlive())
{
if (actor.beh_actor_target.isActor())
{
Actor other = actor.beh_actor_target.a;
if (other != null)
{
string n = other.getName();
if (!string.IsNullOrEmpty(n))
{
targetName = n;
targetIsActor = true;
return;
}
}
}
placeLabel = "their mark";
return;
}
if (actor.beh_building_target != null && actor.beh_building_target.isAlive())
{
Building b = actor.beh_building_target;
string type = b.asset != null ? b.asset.type : "";
string id = b.asset != null ? b.asset.id : "";
if (!string.IsNullOrEmpty(type))
{
placeLabel = type.Replace("type_", "");
return;
}
placeLabel = string.IsNullOrEmpty(id) ? "a building" : id;
return;
}
if (actor.beh_tile_target != null)
{
placeLabel = "the wilds";
}
}
catch
{
// ignore
}
}
public static string ActorName(Actor actor)
{
if (actor == null)
{
return "";
}
try
{
string n = actor.getName();
return string.IsNullOrEmpty(n) ? "" : n;
}
catch
{
return "";
}
}
private static bool ContainsAny(string hay, params string[] needles)
{
for (int i = 0; i < needles.Length; i++)
{
if (hay.IndexOf(needles[i], System.StringComparison.Ordinal) >= 0)
{
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,449 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
public enum ActivityKind
{
TaskStart,
ActionBeat
}
public sealed class ActivityEntry
{
public float CreatedAt;
public long SubjectId;
public ActivityKind Kind;
public string TaskId = "";
public string JobId = "";
public string TargetLabel = "";
/// <summary>Stable fact line for asserts.</summary>
public string Line = "";
/// <summary>Varied display prose for HUD (plain text).</summary>
public string DisplayLine = "";
/// <summary>Display prose with colored names (Unity rich text).</summary>
public string DisplayLineRich = "";
}
/// <summary>
/// Ephemeral per-character activity ring (tasks / action beats). Not Chronicle History.
/// </summary>
public static class ActivityLog
{
public const int MaxLinesPerSubject = 32;
public const int MaxSubjects = 4000;
private const float ActionBeatCooldown = 2.5f;
private static readonly Dictionary<long, List<ActivityEntry>> BySubject =
new Dictionary<long, List<ActivityEntry>>();
private static readonly Dictionary<long, string> LastTaskId = new Dictionary<long, string>();
private static readonly Dictionary<long, float> LastBeatAt = new Dictionary<long, float>();
private static readonly Dictionary<long, int> LineIndex = new Dictionary<long, int>();
private static int _notesSinceClear;
private static int _tasksSinceClear;
private static int _beatsSinceClear;
private static float _clearedAt = -1f;
private static readonly Dictionary<string, int> TaskHitsSinceClear =
new Dictionary<string, int>();
public static int SubjectCount => BySubject.Count;
public static int NotesSinceClear => _notesSinceClear;
public static int TasksSinceClear => _tasksSinceClear;
public static int BeatsSinceClear => _beatsSinceClear;
public static void ClearSession()
{
BySubject.Clear();
LastTaskId.Clear();
LastBeatAt.Clear();
LineIndex.Clear();
ResetSampleCounters();
}
/// <summary>Harness: reset rate counters without wiping the ring.</summary>
public static void ResetSampleCounters()
{
_notesSinceClear = 0;
_tasksSinceClear = 0;
_beatsSinceClear = 0;
_clearedAt = Time.unscaledTime;
TaskHitsSinceClear.Clear();
}
public static string FormatSampleStats()
{
float elapsed = _clearedAt < 0f ? 0f : Mathf.Max(0.01f, Time.unscaledTime - _clearedAt);
float perSec = _notesSinceClear / elapsed;
float perMin = perSec * 60f;
int lines = 0;
foreach (var kv in BySubject)
{
lines += kv.Value != null ? kv.Value.Count : 0;
}
string top = "";
if (TaskHitsSinceClear.Count > 0)
{
var ranked = new List<KeyValuePair<string, int>>(TaskHitsSinceClear);
ranked.Sort((a, b) => b.Value.CompareTo(a.Value));
int n = Mathf.Min(8, ranked.Count);
for (int i = 0; i < n; i++)
{
if (i > 0)
{
top += ", ";
}
top += ranked[i].Key + "=" + ranked[i].Value;
}
}
float perSubjectPerMin = BySubject.Count > 0 ? perMin / BySubject.Count : 0f;
return
$"elapsed={elapsed:0.0}s notes={_notesSinceClear} tasks={_tasksSinceClear} beats={_beatsSinceClear} "
+ $"per_sec={perSec:0.00} per_min={perMin:0.0} per_subject_per_min={perSubjectPerMin:0.00} "
+ $"subjects={BySubject.Count} ring_lines={lines} top=[{top}]";
}
public static int CountFor(long subjectId)
{
if (subjectId == 0)
{
return 0;
}
return BySubject.TryGetValue(subjectId, out List<ActivityEntry> list) ? list.Count : 0;
}
public static IReadOnlyList<ActivityEntry> LatestForSubject(long subjectId, int max)
{
if (subjectId == 0 || max <= 0)
{
return System.Array.Empty<ActivityEntry>();
}
if (!BySubject.TryGetValue(subjectId, out List<ActivityEntry> list) || list.Count == 0)
{
return System.Array.Empty<ActivityEntry>();
}
int take = Mathf.Min(max, list.Count);
var result = new ActivityEntry[take];
// Newest first (matches Chronicle.LatestForSubject peek order).
for (int i = 0; i < take; i++)
{
result[i] = list[list.Count - 1 - i];
}
return result;
}
public static void NoteTask(Actor actor, string taskId)
{
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(taskId))
{
return;
}
long id;
try
{
id = actor.getID();
}
catch
{
return;
}
if (id == 0)
{
return;
}
if (LastTaskId.TryGetValue(id, out string prev) && prev == taskId)
{
return;
}
LastTaskId[id] = taskId;
string jobId = ActivityInterestTable.SafeJobId(actor);
ActivityInterestTable.ResolveTarget(
actor, out string targetName, out bool targetIsActor, out string place);
string actorName = ActivityInterestTable.ActorName(actor);
string loc = "";
try
{
if (actor.hasTask() && actor.ai?.task != null)
{
loc = actor.ai.task.getLocalizedText() ?? "";
}
}
catch
{
// ignore
}
string targetLabel = !string.IsNullOrEmpty(targetName) ? targetName : place;
string raw = string.IsNullOrEmpty(loc) ? ("Task: " + taskId) : loc;
if (!string.IsNullOrEmpty(targetLabel))
{
raw = raw + " → " + targetLabel;
}
int idx = NextIndex(id);
ActivityProse.Format(
ActivityKind.TaskStart,
taskId,
actorName,
targetName,
targetIsActor,
place,
raw,
id,
idx,
out string display,
out string displayRich);
RecordSample(taskId, isBeat: false);
Append(id, new ActivityEntry
{
CreatedAt = Time.unscaledTime,
SubjectId = id,
Kind = ActivityKind.TaskStart,
TaskId = taskId,
JobId = jobId,
TargetLabel = targetLabel,
Line = raw,
DisplayLine = display,
DisplayLineRich = displayRich
});
}
/// <summary>
/// Capture safety net: if the live task id differs from the last logged id, note it.
/// </summary>
public static void EnsureCurrentTask(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return;
}
string taskId = ActivityInterestTable.SafeTaskId(actor);
if (string.IsNullOrEmpty(taskId))
{
return;
}
NoteTask(actor, taskId);
}
public static void NoteActionBeat(Actor actor, string actionKey, string rawFact, string target)
{
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(actionKey))
{
return;
}
long id;
try
{
id = actor.getID();
}
catch
{
return;
}
if (id == 0)
{
return;
}
float now = Time.unscaledTime;
if (LastBeatAt.TryGetValue(id, out float last) && now - last < ActionBeatCooldown)
{
return;
}
LastBeatAt[id] = now;
string taskId = ActivityInterestTable.SafeTaskId(actor);
string jobId = ActivityInterestTable.SafeJobId(actor);
string actorName = ActivityInterestTable.ActorName(actor);
ActivityInterestTable.ResolveTarget(
actor, out string resolvedName, out bool targetIsActor, out string place);
if (string.IsNullOrEmpty(resolvedName) && !string.IsNullOrEmpty(target))
{
resolvedName = target;
targetIsActor = true;
}
string fact = string.IsNullOrEmpty(rawFact) ? actionKey : rawFact;
int idx = NextIndex(id);
ActivityProse.Format(
ActivityKind.ActionBeat,
actionKey,
actorName,
resolvedName,
targetIsActor,
place,
fact,
id,
idx,
out string display,
out string displayRich);
RecordSample(actionKey, isBeat: true);
Append(id, new ActivityEntry
{
CreatedAt = now,
SubjectId = id,
Kind = ActivityKind.ActionBeat,
TaskId = string.IsNullOrEmpty(taskId) ? actionKey : taskId,
JobId = jobId,
TargetLabel = !string.IsNullOrEmpty(resolvedName) ? resolvedName : (place ?? ""),
Line = fact,
DisplayLine = display,
DisplayLineRich = displayRich
});
}
/// <summary>Harness: inject activity without a live AI transition.</summary>
public static bool ForceNote(
long subjectId,
string taskOrAction,
string rawLine,
bool asBeat = false,
string actorName = "",
string targetName = "")
{
if (subjectId == 0 || string.IsNullOrEmpty(taskOrAction))
{
return false;
}
int idx = NextIndex(subjectId);
string fact = string.IsNullOrEmpty(rawLine) ? taskOrAction : rawLine;
ActivityKind kind = asBeat ? ActivityKind.ActionBeat : ActivityKind.TaskStart;
ActivityProse.Format(
kind,
taskOrAction,
actorName,
targetName,
!string.IsNullOrEmpty(targetName),
"",
fact,
subjectId,
idx,
out string display,
out string displayRich);
Append(subjectId, new ActivityEntry
{
CreatedAt = Time.unscaledTime,
SubjectId = subjectId,
Kind = kind,
TaskId = taskOrAction,
TargetLabel = targetName ?? "",
Line = fact,
DisplayLine = display,
DisplayLineRich = displayRich
});
return true;
}
private static void RecordSample(string key, bool isBeat)
{
if (_clearedAt < 0f)
{
_clearedAt = Time.unscaledTime;
}
_notesSinceClear++;
if (isBeat)
{
_beatsSinceClear++;
}
else
{
_tasksSinceClear++;
}
string k = string.IsNullOrEmpty(key) ? "?" : key;
TaskHitsSinceClear.TryGetValue(k, out int n);
TaskHitsSinceClear[k] = n + 1;
}
private static int NextIndex(long id)
{
LineIndex.TryGetValue(id, out int idx);
idx++;
LineIndex[id] = idx;
return idx;
}
private static void Append(long id, ActivityEntry entry)
{
if (!BySubject.TryGetValue(id, out List<ActivityEntry> list))
{
list = new List<ActivityEntry>();
BySubject[id] = list;
PruneSubjectsIfNeeded();
}
list.Add(entry);
while (list.Count > MaxLinesPerSubject)
{
list.RemoveAt(0);
}
}
private static void PruneSubjectsIfNeeded()
{
if (BySubject.Count <= MaxSubjects)
{
return;
}
long focusId = 0;
try
{
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
focusId = MoveCamera._focus_unit.getID();
}
}
catch
{
focusId = 0;
}
var ranked = new List<(long id, float lastAt)>(BySubject.Count);
foreach (KeyValuePair<long, List<ActivityEntry>> kv in BySubject)
{
if (kv.Key == focusId)
{
continue;
}
float last = 0f;
List<ActivityEntry> list = kv.Value;
if (list != null && list.Count > 0 && list[list.Count - 1] != null)
{
last = list[list.Count - 1].CreatedAt;
}
ranked.Add((kv.Key, last));
}
ranked.Sort((a, b) => a.lastAt.CompareTo(b.lastAt));
int need = BySubject.Count - MaxSubjects;
for (int i = 0; i < ranked.Count && need > 0; i++)
{
long id = ranked[i].id;
BySubject.Remove(id);
LastTaskId.Remove(id);
LastBeatAt.Remove(id);
LineIndex.Remove(id);
need--;
}
}
}

View file

@ -0,0 +1,932 @@
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>
/// Fact-preserving activity sentences with colored actor/target names.
/// </summary>
public static class ActivityProse
{
public const string NameColorHex = "#F0C14A";
/// <summary>
/// Templates use {actor} and optional {target}. Never invent outcomes.
/// </summary>
private static readonly Dictionary<string, string[]> Variants = new Dictionary<string, string[]>
{
["attack"] = new[]
{
"{actor} strikes at {target}",
"{actor} presses the attack on {target}",
"{actor} lunges toward {target}"
},
["fight"] = new[]
{
"{actor} fights {target}",
"{actor} clashes with {target}",
"{actor} trades blows with {target}"
},
["fighting"] = new[]
{
"{actor} fights {target}",
"{actor} is locked in combat with {target}",
"{actor} clashes with {target}"
},
["hunt"] = new[]
{
"{actor} hunts {target}",
"{actor} stalks {target}",
"{actor} closes on {target}"
},
["BehAttackActorHuntingTarget"] = new[]
{
"{actor} hunts {target}",
"{actor} strikes at {target}",
"{actor} closes on {target}"
},
["heal"] = new[]
{
"{actor} tends to {target}",
"{actor} heals {target}",
"{actor} binds {target}'s wounds"
},
["check_heal"] = new[]
{
"{actor} checks on wounds",
"{actor} looks over injuries",
"{actor} takes stock of pain"
},
["cure"] = new[]
{
"{actor} seeks a cure",
"{actor} works to recover",
"{actor} fights sickness"
},
["eat"] = new[]
{
"{actor} takes a meal",
"{actor} eats",
"{actor} breaks hunger"
},
["eating"] = new[]
{
"{actor} takes a meal",
"{actor} eats",
"{actor} breaks hunger"
},
["sleep"] = new[]
{
"{actor} sleeps",
"{actor} rests",
"{actor} settles in to sleep"
},
["decide_where_to_sleep"] = new[]
{
"{actor} looks for a place to sleep",
"{actor} decides where to rest",
"{actor} seeks shelter for the night"
},
["random_move"] = new[]
{
"{actor} wanders",
"{actor} roams nearby",
"{actor} drifts along"
},
["city_idle_walking"] = new[]
{
"{actor} walks the streets",
"{actor} strolls through town",
"{actor} paces the settlement"
},
["move"] = new[]
{
"{actor} is on the move",
"{actor} travels onward",
"{actor} heads out"
},
["pollinate"] = new[]
{
"{actor} dusts the blossom",
"{actor} works the flower",
"{actor} leaves pollen behind"
},
["BehPollinate"] = new[]
{
"{actor} dusts the blossom",
"{actor} works the flower",
"{actor} leaves pollen behind"
},
["farm"] = new[]
{
"{actor} tends the fields",
"{actor} works the soil",
"{actor} farms the plot"
},
["harvest"] = new[]
{
"{actor} harvests the crop",
"{actor} gathers the yield",
"{actor} cuts the ripe fields"
},
["forag"] = new[]
{
"{actor} forages",
"{actor} gathers wild food",
"{actor} searches for forage"
},
["gather"] = new[]
{
"{actor} gathers stores",
"{actor} collects goods",
"{actor} forages supplies"
},
["mine"] = new[]
{
"{actor} delves the mine",
"{actor} chips at ore",
"{actor} works the dig"
},
["build"] = new[]
{
"{actor} builds",
"{actor} raises walls",
"{actor} works the construction"
},
["woodcut"] = new[]
{
"{actor} fells timber",
"{actor} cuts wood",
"{actor} takes an axe to the trees"
},
["fire"] = new[]
{
"{actor} fights the blaze",
"{actor} douses flames",
"{actor} races the fire"
},
["BehCityActorRemoveFire"] = new[]
{
"{actor} fights the blaze",
"{actor} douses flames",
"{actor} beats back the fire"
},
["social"] = new[]
{
"{actor} talks with {target}",
"{actor} socializes with {target}",
"{actor} converses with {target}"
},
["socialize"] = new[]
{
"{actor} talks with {target}",
"{actor} socializes with {target}",
"{actor} joins company near {target}"
},
["lover"] = new[]
{
"{actor} seeks out {target}",
"{actor} is drawn to {target}",
"{actor} pursues {target}'s affection"
},
["breed"] = new[]
{
"{actor} tries to start a family with {target}",
"{actor} courts {target}",
"{actor} seeks offspring with {target}"
},
["sexual_reproduction"] = new[]
{
"{actor} tries to start a family with {target}",
"{actor} courts {target}",
"{actor} seeks offspring with {target}"
},
["unload"] = new[]
{
"{actor} unloads goods in town",
"{actor} delivers the haul",
"{actor} empties the pack at home"
},
["BehUnloadResources"] = new[]
{
"{actor} unloads goods in town",
"{actor} delivers the haul",
"{actor} empties the pack at home"
},
["job"] = new[]
{
"{actor} seeks work",
"{actor} looks for a job",
"{actor} searches for employment"
},
["find_city_job"] = new[]
{
"{actor} seeks work in town",
"{actor} looks for a city job",
"{actor} searches for employment"
},
["find_house"] = new[]
{
"{actor} looks for a house",
"{actor} seeks a home",
"{actor} searches for shelter"
},
["claim"] = new[]
{
"{actor} claims land",
"{actor} stakes a claim",
"{actor} takes claim of ground"
},
["warrior"] = new[]
{
"{actor} follows the army",
"{actor} marches with the warband",
"{actor} keeps formation"
},
["army"] = new[]
{
"{actor} follows the army",
"{actor} marches with the warband",
"{actor} keeps formation"
},
["wait"] = new[]
{
"{actor} waits",
"{actor} holds still",
"{actor} bides time"
},
["make_decision"] = new[]
{
"{actor} considers next steps",
"{actor} weighs a choice",
"{actor} pauses in thought"
},
["reflection"] = new[]
{
"{actor} reflects",
"{actor} takes a quiet moment",
"{actor} thinks things over"
},
["run_away"] = new[]
{
"{actor} flees",
"{actor} runs for safety",
"{actor} retreats"
},
["trade"] = new[]
{
"{actor} trades",
"{actor} barters goods",
"{actor} is on a trade run"
},
["fish"] = new[]
{
"{actor} fishes",
"{actor} casts for fish",
"{actor} works the waters"
},
["family_group"] = new[]
{
"{actor} joins the herd",
"{actor} seeks the family group",
"{actor} gathers with kin"
},
["generate_loot"] = new[]
{
"{actor} gathers loot",
"{actor} claims spoils",
"{actor} picks through loot"
},
["laughing"] = new[]
{
"{actor} laughs",
"{actor} bursts into laughter",
"{actor} is laughing"
},
["happy_laughing"] = new[]
{
"{actor} laughs with joy",
"{actor} is laughing happily",
"{actor} chuckles aloud"
},
["just_laughed"] = new[]
{
"{actor} just finished laughing",
"{actor} settles after a laugh",
"{actor} catches their breath from laughing"
},
["singing"] = new[]
{
"{actor} sings",
"{actor} lifts a song",
"{actor} is singing"
},
["task_unit_play"] = new[]
{
"{actor} plays",
"{actor} is at play",
"{actor} amuses themselves"
},
["just_played"] = new[]
{
"{actor} just finished playing",
"{actor} wraps up playtime",
"{actor} steps away from play"
},
["child_play_at_one_spot"] = new[]
{
"{actor} plays in place",
"{actor} keeps busy with play",
"{actor} amuses themselves on the spot"
},
["child_random_flips"] = new[]
{
"{actor} flips about",
"{actor} tumbles in play",
"{actor} shows off flips"
},
["child_random_jump"] = new[]
{
"{actor} jumps about",
"{actor} leaps in play",
"{actor} bounces around"
},
["child_follow_parent"] = new[]
{
"{actor} follows a parent",
"{actor} stays close to {target}",
"{actor} trails after kin"
},
["random_fun_move"] = new[]
{
"{actor} frolics about",
"{actor} moves for fun",
"{actor} skips along playfully"
},
["godfinger_random_fun_move"] = new[]
{
"{actor} frolics under divine whim",
"{actor} is nudged into playful motion",
"{actor} moves about for fun"
},
["try_to_read"] = new[]
{
"{actor} tries to read",
"{actor} opens a book",
"{actor} settles in to read"
},
["try_to_poop"] = new[]
{
"{actor} looks for a private moment",
"{actor} tends to nature's call",
"{actor} steps aside briefly"
},
["try_to_launch_fireworks"] = new[]
{
"{actor} tries to launch fireworks",
"{actor} readies a firework",
"{actor} prepares a celebration blast"
}
};
public static string ColorName(string name)
{
if (string.IsNullOrEmpty(name))
{
return "";
}
return "<color=" + NameColorHex + ">" + name + "</color>";
}
public static void Format(
ActivityKind kind,
string key,
string actorName,
string targetName,
bool targetIsActor,
string placeOrFallback,
string rawFact,
long subjectId,
int lineIndex,
out string plain,
out string rich)
{
plain = "";
rich = "";
string actor = string.IsNullOrEmpty(actorName) ? "Someone" : actorName;
string targetPlain = ResolveTargetPlain(targetName, targetIsActor, placeOrFallback);
bool hasTarget = !string.IsNullOrEmpty(targetPlain);
string template = PickTemplate(key, subjectId, lineIndex, hasTarget);
if (string.IsNullOrEmpty(template))
{
template = PatternTemplate(key, hasTarget);
}
if (string.IsNullOrEmpty(template))
{
template = LocalizedTemplate(rawFact, key, hasTarget);
}
if (string.IsNullOrEmpty(template))
{
template = "{actor} " + LowerFirst(HumanizeKey(key));
if (string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(rawFact))
{
template = GerundSentence(rawFact);
}
}
plain = Apply(template, actor, targetPlain);
rich = Apply(
template,
ColorName(actor),
targetIsActor && !string.IsNullOrEmpty(targetName)
? ColorName(targetName)
: targetPlain);
}
/// <summary>Legacy helper used by older call sites / harness.</summary>
public static string Format(
ActivityKind kind,
string key,
string rawFact,
string target,
long subjectId,
int lineIndex)
{
Format(
kind,
key,
actorName: "",
targetName: target,
targetIsActor: !string.IsNullOrEmpty(target),
placeOrFallback: target,
rawFact: rawFact,
subjectId: subjectId,
lineIndex: lineIndex,
out string plain,
out _);
return plain;
}
private static string ResolveTargetPlain(string targetName, bool targetIsActor, string placeOrFallback)
{
if (!string.IsNullOrEmpty(targetName))
{
return targetName;
}
if (!string.IsNullOrEmpty(placeOrFallback))
{
return placeOrFallback;
}
return "";
}
private static string Apply(string template, string actor, string target)
{
string t = template.Replace("{actor}", actor ?? "");
if (string.IsNullOrEmpty(target))
{
// Drop dangling " with {target}" style if target missing - templates should
// prefer no-target variants via PickTemplate, but scrub leftovers.
t = t.Replace(" with {target}", "")
.Replace(" on {target}", "")
.Replace(" toward {target}", "")
.Replace(" at {target}", "")
.Replace(" {target}", "")
.Replace("{target}", "someone");
}
else
{
t = t.Replace("{target}", target);
}
return t.Trim();
}
private static string PatternTemplate(string key, bool hasTarget)
{
if (string.IsNullOrEmpty(key))
{
return null;
}
string k = key.ToLowerInvariant();
if (k.StartsWith("try_to_"))
{
return "{actor} tries to " + HumanizeKey(k.Substring("try_to_".Length));
}
if (k.StartsWith("check_"))
{
return "{actor} checks " + HumanizeKey(k.Substring("check_".Length));
}
if (k.StartsWith("find_"))
{
return "{actor} looks for " + HumanizeKey(k.Substring("find_".Length));
}
if (k.StartsWith("decide_"))
{
return "{actor} decides " + HumanizeKey(k.Substring("decide_".Length));
}
if (k.StartsWith("build_"))
{
return "{actor} builds " + HumanizeKey(k.Substring("build_".Length));
}
if (k.StartsWith("boat_"))
{
return "{actor} " + BoatVerb(k.Substring("boat_".Length));
}
if (k.StartsWith("warrior_"))
{
return WarriorTemplate(k, hasTarget);
}
if (k.StartsWith("socialize_"))
{
return hasTarget
? "{actor} socializes with {target}"
: "{actor} socializes";
}
if (k.StartsWith("sexual_reproduction") || k.StartsWith("asexual_reproduction"))
{
return hasTarget
? "{actor} tries to start a family with {target}"
: "{actor} tries to reproduce";
}
if (k.StartsWith("child_"))
{
return "{actor} " + LowerFirst(HumanizeKey(k.Substring("child_".Length)));
}
if (k.StartsWith("ant_") || k.StartsWith("bee_") || k.StartsWith("ufo_")
|| k.StartsWith("dragon_") || k.StartsWith("worm_"))
{
return "{actor} " + LowerFirst(HumanizeKey(StripCreaturePrefix(k)));
}
if (k.StartsWith("task_unit_"))
{
return LocalizedStyleFromId(k.Substring("task_unit_".Length), hasTarget);
}
if (k.Contains("laugh"))
{
return "{actor} laughs";
}
if (k.Contains("sing"))
{
return "{actor} sings";
}
if (k.Contains("play") && !k.Contains("display"))
{
return hasTarget ? "{actor} plays with {target}" : "{actor} plays";
}
if (k.Contains("wait"))
{
return "{actor} waits";
}
if (k.Contains("sleep"))
{
return "{actor} sleeps";
}
if (k.Contains("idle") || k.Contains("random_move") || k.EndsWith("_move")
|| k.Contains("walking"))
{
return "{actor} wanders";
}
if (k.Contains("fight") || k.Contains("attack") || k.Contains("hunt"))
{
return hasTarget ? "{actor} strikes at {target}" : "{actor} fights";
}
if (k.Contains("heal") || k.Contains("cure"))
{
return hasTarget ? "{actor} tends to {target}" : "{actor} tends to wounds";
}
if (k.Contains("eat") || k.Contains("food"))
{
return "{actor} seeks a meal";
}
if (k.Contains("farm") || k.Contains("harvest") || k.Contains("forag")
|| k.Contains("gather") || k.Contains("chop") || k.Contains("mine"))
{
return "{actor} " + LowerFirst(HumanizeKey(k));
}
return null;
}
private static string WarriorTemplate(string k, bool hasTarget)
{
if (k.Contains("follow"))
{
return hasTarget ? "{actor} follows {target}" : "{actor} follows the army";
}
if (k.Contains("train"))
{
return "{actor} trains for war";
}
if (k.Contains("join"))
{
return "{actor} tries to join the army";
}
if (k.Contains("attack"))
{
return hasTarget ? "{actor} marches on {target}" : "{actor} marches to attack";
}
if (k.Contains("wait") || k.Contains("idle"))
{
return "{actor} holds formation";
}
return "{actor} " + LowerFirst(HumanizeKey(k.Replace("warrior_", "")));
}
private static string BoatVerb(string rest)
{
if (rest.Contains("fish"))
{
return "fishes from the boat";
}
if (rest.Contains("trade"))
{
return "trades by boat";
}
if (rest.Contains("dock") || rest.Contains("return"))
{
return "returns to dock";
}
if (rest.Contains("load"))
{
return "loads the boat";
}
if (rest.Contains("unload"))
{
return "unloads the boat";
}
if (rest.Contains("idle"))
{
return "idles aboard";
}
return LowerFirst(HumanizeKey(rest)) + " aboard";
}
private static string StripCreaturePrefix(string k)
{
int idx = k.IndexOf('_');
return idx > 0 && idx < k.Length - 1 ? k.Substring(idx + 1) : k;
}
private static string LocalizedStyleFromId(string rest, bool hasTarget)
{
if (rest == "play")
{
return "{actor} plays";
}
if (rest == "wait")
{
return "{actor} waits";
}
if (rest == "walk")
{
return "{actor} walks";
}
if (rest.Contains("social"))
{
return hasTarget ? "{actor} socializes with {target}" : "{actor} socializes";
}
return "{actor} " + LowerFirst(HumanizeKey(rest));
}
/// <summary>
/// Turn game localized labels like "Laughing" / "Job Seeking" into real sentences.
/// </summary>
private static string LocalizedTemplate(string rawFact, string key, bool hasTarget)
{
string loc = ExtractLocalizedBody(rawFact, key);
if (string.IsNullOrEmpty(loc))
{
return null;
}
return GerundSentence(loc);
}
private static string ExtractLocalizedBody(string rawFact, string key)
{
if (string.IsNullOrEmpty(rawFact))
{
return null;
}
string body = rawFact;
int arrow = body.IndexOf('→');
if (arrow < 0)
{
arrow = body.IndexOf("->", System.StringComparison.Ordinal);
}
if (arrow >= 0)
{
body = body.Substring(0, arrow).Trim();
}
if (body.StartsWith("Task:", System.StringComparison.OrdinalIgnoreCase))
{
return null;
}
// Ignore if it's just the raw key.
if (!string.IsNullOrEmpty(key)
&& body.Equals(key, System.StringComparison.OrdinalIgnoreCase))
{
return null;
}
return body.Trim();
}
private static string GerundSentence(string loc)
{
if (string.IsNullOrEmpty(loc))
{
return null;
}
string body = loc.Trim();
// "Laughing" / "Job Seeking" / "Holding Still"
if (IsGerundPhrase(body))
{
return "{actor} is " + LowerFirst(body);
}
// Already a short verb phrase
return "{actor} " + LowerFirst(body);
}
private static bool IsGerundPhrase(string body)
{
if (string.IsNullOrEmpty(body))
{
return false;
}
string[] parts = body.Split(new[] { ' ' }, System.StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0 || parts.Length > 4)
{
return false;
}
// Last token ends with -ing (Laughing, Seeking, Holding...)
string last = parts[parts.Length - 1];
return last.Length > 4
&& last.EndsWith("ing", System.StringComparison.OrdinalIgnoreCase);
}
private static string HumanizeKey(string key)
{
if (string.IsNullOrEmpty(key))
{
return "";
}
string s = key.Replace("Beh", "").Replace('_', ' ').Trim();
// Collapse common noise words for readability.
s = s.Replace("task unit ", "").Replace("type ", "");
return s;
}
private static string LowerFirst(string s)
{
if (string.IsNullOrEmpty(s))
{
return s;
}
if (s.Length == 1)
{
return s.ToLowerInvariant();
}
return char.ToLowerInvariant(s[0]) + s.Substring(1);
}
private static string PickTemplate(string key, long subjectId, int lineIndex, bool hasTarget)
{
if (string.IsNullOrEmpty(key))
{
return null;
}
string[] bag = null;
if (Variants.TryGetValue(key, out bag) && bag != null && bag.Length > 0)
{
return PreferTargetAware(bag, hasTarget, subjectId, key, lineIndex);
}
string lower = key.ToLowerInvariant();
string bestKey = null;
string[] bestBag = null;
int bestLen = -1;
foreach (KeyValuePair<string, string[]> kv in Variants)
{
if (kv.Value == null || kv.Value.Length == 0)
{
continue;
}
if (lower.IndexOf(kv.Key, System.StringComparison.Ordinal) >= 0 && kv.Key.Length > bestLen)
{
bestLen = kv.Key.Length;
bestKey = kv.Key;
bestBag = kv.Value;
}
}
if (bestBag == null)
{
return null;
}
return PreferTargetAware(bestBag, hasTarget, subjectId, bestKey, lineIndex);
}
private static string PreferTargetAware(
string[] bag,
bool hasTarget,
long subjectId,
string key,
int lineIndex)
{
var suited = new List<string>(bag.Length);
for (int i = 0; i < bag.Length; i++)
{
string line = bag[i];
bool needsTarget = line.IndexOf("{target}", System.StringComparison.Ordinal) >= 0;
if (hasTarget || !needsTarget)
{
suited.Add(line);
}
}
if (suited.Count == 0)
{
suited.AddRange(bag);
}
return suited[PickIndex(subjectId, key, lineIndex, suited.Count)];
}
private static int PickIndex(long subjectId, string key, int lineIndex, int count)
{
if (count <= 1)
{
return 0;
}
unchecked
{
int h = (int)(subjectId * 397) ^ (key?.GetHashCode() ?? 0) ^ (lineIndex * 31);
h ^= (int)(UnityEngine.Time.unscaledTime * 10f);
if (h < 0)
{
h = -h;
}
return h % count;
}
}
}

View file

@ -0,0 +1,97 @@
using ai.behaviours;
using HarmonyLib;
namespace IdleSpectator;
/// <summary>
/// Feeds <see cref="ActivityLog"/> from task changes and curated behaviour beats.
/// </summary>
[HarmonyPatch]
public static class ActorActivityPatches
{
[HarmonyPatch(typeof(Actor), nameof(Actor.setTask))]
[HarmonyPostfix]
public static void PostfixSetTask(Actor __instance, string pTaskId)
{
if (__instance == null || string.IsNullOrEmpty(pTaskId))
{
return;
}
ActivityLog.NoteTask(__instance, pTaskId);
}
[HarmonyPatch(typeof(BehPollinate), nameof(BehPollinate.execute))]
[HarmonyPostfix]
public static void PostfixPollinate(Actor pActor, BehResult __result)
{
if (pActor == null || __result == BehResult.Stop)
{
return;
}
string target = ActivityInterestTable.TargetLabel(pActor);
if (string.IsNullOrEmpty(target))
{
target = "flower";
}
ActivityLog.NoteActionBeat(pActor, "BehPollinate", "Pollinates " + target, target);
}
[HarmonyPatch(typeof(BehUnloadResources), nameof(BehUnloadResources.execute))]
[HarmonyPrefix]
public static void PrefixUnload(Actor pActor, out bool __state)
{
__state = false;
try
{
__state = pActor != null && pActor.isCarryingResources();
}
catch
{
__state = false;
}
}
[HarmonyPatch(typeof(BehUnloadResources), nameof(BehUnloadResources.execute))]
[HarmonyPostfix]
public static void PostfixUnload(Actor pActor, bool __state)
{
if (pActor == null || !__state)
{
return;
}
ActivityLog.NoteActionBeat(pActor, "BehUnloadResources", "Unloads resources", "town");
}
[HarmonyPatch(typeof(BehAttackActorHuntingTarget), nameof(BehAttackActorHuntingTarget.execute))]
[HarmonyPostfix]
public static void PostfixHuntAttack(Actor pActor, BehResult __result)
{
if (pActor == null)
{
return;
}
string target = ActivityInterestTable.TargetLabel(pActor);
ActivityLog.NoteActionBeat(
pActor,
"BehAttackActorHuntingTarget",
string.IsNullOrEmpty(target) ? "Hunts prey" : "Hunts " + target,
target);
}
[HarmonyPatch(typeof(BehCityActorRemoveFire), nameof(BehCityActorRemoveFire.execute))]
[HarmonyPostfix]
public static void PostfixRemoveFire(Actor pActor, BehResult __result)
{
if (pActor == null)
{
return;
}
ActivityLog.NoteActionBeat(pActor, "BehCityActorRemoveFire", "Fights fire", "blaze");
}
}

View file

@ -27,6 +27,9 @@ public static class ActorChroniclePatches
return;
}
// Capture living dossier before die() clears state - used by Fallen archive.
Chronicle.CaptureFinalDossier(__instance);
BaseSimObject attacker = __instance.attackedBy;
if (attacker != null && !attacker.isRekt() && attacker.isActor() && attacker != __instance)
{

File diff suppressed because it is too large Load diff

View file

@ -8,12 +8,12 @@ public static class CameraDirector
public static string LastWatchLabel { get; private set; } = "";
public static string LastWatchAssetId { get; private set; } = "";
public static void FocusUnit(Actor actor)
public static void FocusUnit(Actor actor, bool updateCaption = true)
{
if (actor != null && actor.isAlive())
{
RetargetFollow(actor);
if (SpectatorMode.Active)
if (updateCaption && SpectatorMode.Active)
{
WatchCaption.SetFromActor(actor);
}
@ -110,6 +110,7 @@ public static class CameraDirector
bool firstFocus = !MoveCamera.hasFocusUnit();
MoveCamera.setFocusUnit(actor);
Chronicle.NoteFocus(actor);
MoveCamera cam = MoveCamera.instance;
if (cam == null)

View file

@ -0,0 +1,34 @@
using HarmonyLib;
namespace IdleSpectator;
/// <summary>
/// WorldBox zooms on mouse wheel even over UI while a unit is focused
/// (<c>!isOverUI || MoveCamera.inSpectatorMode()</c>). Absorb wheel zoom when
/// the cursor is over Lore or the dossier so ScrollRect history can scroll.
/// </summary>
[HarmonyPatch]
internal static class CameraZoomPatches
{
/// <summary>Harness: force the zoom block without needing a real mouse position.</summary>
public static bool ForceAbsorbForHarness;
private static bool Absorb()
{
return ForceAbsorbForHarness || ChronicleHud.ShouldAbsorbScrollZoom();
}
[HarmonyPatch(typeof(MoveCamera), nameof(MoveCamera.zoomInWheel))]
[HarmonyPrefix]
private static bool PrefixZoomInWheel()
{
return !Absorb();
}
[HarmonyPatch(typeof(MoveCamera), nameof(MoveCamera.zoomOutWheel))]
[HarmonyPrefix]
private static bool PrefixZoomOutWheel()
{
return !Absorb();
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,52 @@
namespace IdleSpectator;
/// <summary>One row in the Lore panel Living / Fallen tabs.</summary>
public sealed class ChronicleCharacterSummary
{
public long UnitId;
public string Name = "";
public string SpeciesId = "";
public int HistoryCount;
public bool IsFavorite;
public bool IsAlive;
public bool IsRecent;
public int RecentIndex = -1;
public DeathManner DeathManner;
/// <summary>Newest event time for Fallen sort (unscaledTime).</summary>
public float SortTime;
public string Title
{
get
{
if (string.IsNullOrEmpty(Name))
{
return SpeciesId ?? "creature";
}
if (string.IsNullOrEmpty(SpeciesId))
{
return Name;
}
return Name + " (" + SpeciesId + ")";
}
}
public string DeathLabel =>
!IsAlive ? Chronicle.DeathMannerLabel(DeathManner) : "";
public string Detail
{
get
{
string hist = HistoryCount <= 0 ? "no history" : HistoryCount + " events";
if (!IsAlive && DeathManner != DeathManner.None)
{
return DeathLabel + " · " + hist;
}
return hist;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -21,6 +21,12 @@ public static class DossierAvatar
public static bool UsingVanilla => _usingVanilla;
/// <summary>Live unit portrait is active (not the fallen species fallback).</summary>
public static bool ShowingLiveAvatar => _shownActorId > 0;
/// <summary>Fallen archive species-icon portrait is showing.</summary>
public static bool ShowingSpeciesFallback => _shownActorId == -2;
/// <summary>True if <paramref name="screenPoint"/> is inside the live portrait rect.</summary>
public static bool ContainsScreenPoint(Vector2 screenPoint)
{
@ -113,6 +119,8 @@ public static class DossierAvatar
long id = actor.getID();
if (_usingVanilla && _instance != null && _shownActorId == id && _instance.gameObject.activeSelf)
{
// Always clear species fallback - it can linger above the live portrait.
HideFallback();
try
{
_instance.updateTileSprite();
@ -129,6 +137,8 @@ public static class DossierAvatar
{
try
{
HideFallback();
_instance.gameObject.SetActive(true);
_instance.show_banner_kingdom = false;
_instance.show_banner_clan = false;
if (_instance.kingdomBanner != null)
@ -167,6 +177,54 @@ public static class DossierAvatar
public static void ClearActor()
{
_shownActorId = -1;
HideVanillaInstance();
HideFallback();
}
/// <summary>Species icon portrait when the unit is gone (Fallen archive).</summary>
public static void ShowSpecies(string speciesId)
{
if (_host == null)
{
return;
}
Sprite sprite = HudIcons.FromSpeciesId(speciesId);
HideVanillaInstance();
if (_fallbackSprite == null)
{
BuildFallback(_host.sizeDelta.x > 1f ? _host.sizeDelta.x : 44f);
}
if (_fallbackBg != null)
{
_fallbackBg.gameObject.SetActive(true);
_fallbackBg.sprite = null;
_fallbackBg.color = new Color(0.04f, 0.045f, 0.055f, 0.95f);
_fallbackBg.type = Image.Type.Simple;
}
if (_fallbackSprite != null)
{
_fallbackSprite.gameObject.SetActive(true);
HudIcons.Apply(_fallbackSprite, sprite, preserveAspect: true);
_fallbackSprite.enabled = sprite != null;
if (sprite != null)
{
float w = Mathf.Max(1f, sprite.rect.width);
float h = Mathf.Max(1f, sprite.rect.height);
float max = _host.sizeDelta.x;
float scale = (max * 0.85f) / Mathf.Max(w, h);
_fallbackSprite.rectTransform.sizeDelta = new Vector2(w * scale, h * scale);
}
}
_shownActorId = -2;
}
private static void HideVanillaInstance()
{
if (_usingVanilla && _instance != null)
{
try
@ -178,10 +236,19 @@ public static class DossierAvatar
// ignore
}
}
}
private static void HideFallback()
{
if (_fallbackSprite != null)
{
_fallbackSprite.enabled = false;
_fallbackSprite.gameObject.SetActive(false);
}
if (_fallbackBg != null)
{
_fallbackBg.gameObject.SetActive(false);
}
}
@ -343,8 +410,10 @@ public static class DossierAvatar
if (_fallbackSprite != null)
{
_fallbackSprite.gameObject.SetActive(true);
Sprite live = HudIcons.FromActorLive(actor);
HudIcons.Apply(_fallbackSprite, live, preserveAspect: true);
_fallbackSprite.enabled = live != null;
if (live != null)
{
float w = Mathf.Max(1f, live.rect.width);

View file

@ -35,6 +35,15 @@ internal static class HarnessScenarios
case "chronicle":
case "chronicle_smoke":
return ChronicleSmoke();
case "activity":
case "activity_idle_smoke":
return ActivityIdleSmoke();
case "activity_rate":
case "activity_rate_sample":
return ActivityRateSample();
case "chronicle_subject_bench":
case "subject_bench":
return ChronicleSubjectBench();
case "regression":
case "full":
return Regression();
@ -43,6 +52,28 @@ internal static class HarnessScenarios
}
}
/// <summary>
/// Timed Fallen/Living list rebuilds at rising subject counts.
/// Picks the highest size under a 2ms Fallen avg budget (runtime cap only for that session).
/// </summary>
private static List<HarnessCommand> ChronicleSubjectBench()
{
return new List<HarnessCommand>
{
Step("sb0", "wait_world"),
Step("sb1", "dismiss_windows"),
Step("sb2", "chronicle_clear"),
Step(
"sb3",
"chronicle_subject_bench",
value: "320,640,1280,2560,4096,8192",
count: 3,
expect: "12"),
Step("sb4", "assert", expect: "chronicle_subjects_capped"),
Step("sb5", "snapshot")
};
}
/// <summary>
/// Full regression meta-suite (nested scenarios keep assert counters).
/// Battle / map-gen paths stay out of gate.
@ -60,6 +91,7 @@ internal static class HarnessScenarios
Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
Nested("reg_chronicle", "chronicle_smoke"),
Nested("reg_activity", "activity_idle_smoke"),
Step("reg_end", "fast_timing", value: "false"),
Step("reg_snap", "snapshot"),
};
@ -390,6 +422,80 @@ internal static class HarnessScenarios
};
}
private static List<HarnessCommand> ActivityRateSample()
{
return new List<HarnessCommand>
{
Step("ar0", "wait_world"),
Step("ar1", "activity_sample_reset"),
Step("ar2", "wait", wait: 20f),
Step("ar3", "activity_stats"),
Step("ar4", "snapshot"),
};
}
private static List<HarnessCommand> ActivityIdleSmoke()
{
return new List<HarnessCommand>
{
Step("act0", "dismiss_windows"),
Step("act1", "wait_world"),
Step("act2", "set_setting", expect: "enabled", value: "true"),
Step("act3", "set_setting", expect: "show_dossier_caption", value: "true"),
Step("act4", "set_setting", expect: "chronicle_enabled", value: "true"),
Step("act5", "chronicle_clear"),
Step("act6", "activity_clear"),
Step("act7", "pick_unit", asset: "auto"),
Step("act8", "spectator", value: "off"),
Step("act9", "spectator", value: "on"),
Step("act10", "focus", asset: "auto"),
Step("act11", "wait", wait: 0.35f),
Step("act12", "activity_probe"),
Step("act13", "assert", expect: "activity_first_scoring"),
Step("act13b", "assert", expect: "activity_prose_sentence"),
Step("act14", "activity_force", asset: "laughing", label: "Laughing", count: 1),
Step("act14b", "assert", expect: "activity_log_contains", value: "laugh"),
Step("act14c", "activity_force", asset: "eating", label: "Taking a meal", count: 1),
Step("act15", "activity_force", asset: "farm", label: "Tends the fields", count: 1),
Step("act16", "activity_force", asset: "hunt", label: "Hunts", count: 1, expect: "Barkley"),
Step("act17", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 3, tier: "beat"),
Step("act17b", "activity_force", asset: "zzz_harness_act", label: "Harness pollen trail", count: 1),
Step("act18", "wait", wait: 0.25f),
Step("act19", "assert", expect: "activity_count", value: "4", label: "min"),
Step("act20", "assert", expect: "activity_prose_variety", value: "2"),
Step("act20b", "assert", expect: "activity_names_colored"),
Step("act20c", "assert", expect: "activity_log_contains", value: "Barkley"),
Step("act21", "assert", expect: "dossier_history_shown", value: "1", label: "min", tier: "activity"),
Step("act22", "assert", expect: "dossier_history_contains", value: "Harness pollen"),
Step("act23", "assert", expect: "caption_layout_ok"),
Step("act24", "screenshot", value: "hud-activity_idle_smoke.png"),
Step("act25", "wait", wait: 0.55f),
// Life still peeks when chronicle history exists beside activity.
Step("act26", "chronicle_force", label: "Killed a serpent"),
Step("act27", "wait", wait: 0.2f),
Step("act28", "assert", expect: "dossier_history_contains", value: "serpent"),
Step("act29", "assert", expect: "dossier_history_shown", value: "1", label: "min", tier: "life"),
Step("act29b", "screenshot", value: "hud-activity-life-split.png"),
Step("act29c", "wait", wait: 0.45f),
// Books: Activity / Life tabs.
Step("act40", "lore_open_focus"),
Step("act41", "wait", wait: 0.25f),
Step("act42", "assert", expect: "lore_detail_pane", value: "life"),
Step("act43", "lore_detail_pane", value: "activity"),
Step("act44", "wait", wait: 0.2f),
Step("act45", "assert", expect: "lore_detail_pane", value: "activity"),
Step("act46", "assert", expect: "lore_activity_shown", value: "4", label: "min"),
Step("act47", "screenshot", value: "hud-lore-activity-tab.png"),
Step("act48", "wait", wait: 0.45f),
Step("act49", "lore_detail_pane", value: "life"),
Step("act50", "assert", expect: "lore_history_shown", value: "1", label: "min"),
Step("act51", "assert", expect: "idle", value: "false"),
Step("act52", "assert", expect: "caption_layout_ok"),
Step("act53", "assert", expect: "no_bad"),
Step("act99", "snapshot"),
};
}
private static List<HarnessCommand> ChronicleSmoke()
{
return new List<HarnessCommand>
@ -440,31 +546,250 @@ internal static class HarnessScenarios
Step("ch16f", "assert", expect: "chronicle_latest_contains", value: "Drown"),
Step("ch16g", "chronicle_death_sample", value: "Fire"),
Step("ch16h", "assert", expect: "chronicle_latest_contains", value: "Burned"),
// Character kills/deaths must stay on character history, not World Memory.
Step("ch16i", "assert", expect: "world_memory_world_only"),
// Soft-cap prune: temporarily lower the cap, flood past it, then restore.
Step("ch16j0", "chronicle_set_subject_cap", value: "500"),
Step("ch16j", "chronicle_orphan_flood", count: 580),
Step("ch16k", "assert", expect: "chronicle_subjects_capped"),
Step(
"ch16k2",
"chronicle_set_subject_cap",
value: Chronicle.DefaultMaxHistorySubjects.ToString()),
// World feed is separate from History
// World Memory landmarks (replaces History|World tabs)
Step("ch17a", "chronicle_world_force", label: "War: harness_alpha vs harness_beta"),
Step("ch17b", "assert", expect: "chronicle_count", value: "1", label: "world"),
Step("ch17c", "assert", expect: "chronicle_world_latest_contains", value: "harness_alpha"),
Step("ch17c", "assert", expect: "world_memory_contains", value: "harness_alpha"),
Step("ch17d", "assert", expect: "chronicle_latest_contains", value: "Burned"),
Step("ch17e", "assert", expect: "world_memory_age"),
Step("ch17e2", "assert", expect: "world_memory_world_only"),
Step("ch17e3", "assert", expect: "chronicle_memory_capped"),
Step("ch17e4", "assert", expect: "chronicle_subjects_capped"),
// Selecting a world event pans to its recorded place (even with no unit there).
Step("ch17j", "lore_jump_world"),
Step("ch17k", "wait", wait: 0.2f),
Step("ch17l", "assert", expect: "world_jump_place"),
Step("ch17m", "assert", expect: "focus", value: "false"),
// Restore a living focus for the rest of the scenario.
Step("ch17n", "focus", asset: "auto"),
Step("ch17o", "wait", wait: 0.2f),
Step("ch17p", "assert", expect: "focus", value: "true"),
// Tabs
// World Memory panel + dossier controls
Step("ch18", "chronicle_open"),
Step("ch18a", "screenshot", value: "hud-chronicle_smoke.png"),
Step("ch18a2", "wait", wait: 0.6f),
Step("ch18b", "chronicle_tab", value: "history"),
Step("ch18b", "assert", expect: "world_memory_compact"),
Step("ch18b2", "assert", expect: "hud_no_overlap"),
Step("ch18c", "assert", expect: "caption_layout_ok"),
Step("ch18c2", "assert", expect: "dossier_traits_ok"),
Step("ch18d", "assert", expect: "chronicle_tab", value: "history"),
Step("ch18e", "chronicle_tab", value: "world"),
Step("ch18f", "assert", expect: "chronicle_tab", value: "world"),
Step("ch18g", "chronicle_tab", value: "history"),
Step("ch18h", "wait", wait: 0.25f),
Step("ch18d", "assert", expect: "world_memory_contains", value: "harness_alpha"),
Step("ch18e", "dossier_favorite", value: "true"),
Step("ch18f", "assert", expect: "is_favorite", value: "true"),
Step("ch18g", "dossier_favorite", value: "false"),
Step("ch18h", "assert", expect: "is_favorite", value: "false"),
// Dossier peeks 3 lines; books button opens full history in Lore and pauses idle.
Step("ch18h1", "chronicle_force", label: "Hist", count: 40),
Step("ch18h2", "wait", wait: 0.25f),
Step("ch18k", "assert", expect: "dossier_history_shown", value: "3", label: "exact"),
Step("ch18k3", "assert", expect: "dossier_history_fills_body"),
Step("ch18l", "assert", expect: "caption_layout_ok"),
Step("ch18l1", "assert", expect: "hud_no_overlap"),
Step("ch18l2", "screenshot", value: "hud-dossier-peek.png"),
Step("ch18l3", "wait", wait: 0.45f),
// L shortcut syncs to the focused dossier unit (Follow mode) and locks idle.
Step("ch18m", "lore_open_focus"),
Step("ch18n", "wait", wait: 0.3f),
Step("ch18o", "assert", expect: "idle", value: "false"),
Step("ch18o2", "assert", expect: "lore_tab", value: "living"),
Step("ch18o3", "assert", expect: "lore_detail", value: "true"),
Step("ch18o3b", "assert", expect: "lore_follow", value: "true"),
Step("ch18o4", "assert", expect: "lore_history_shown", value: "40", label: "min"),
Step("ch18o4b", "assert", expect: "dossier_lore_history_match"),
Step("ch18o4c", "assert", expect: "dossier_pinned", value: "true"),
Step("ch18o4d", "assert", expect: "lore_zoom_blocked"),
Step("ch18o5", "screenshot", value: "hud-lore-unit-history.png"),
Step("ch18o6", "wait", wait: 0.55f),
// Banner used to expire at 5s and hide the dossier - pin must outlive that.
Step("ch18o7", "wait", wait: 5.5f),
Step("ch18o8", "assert", expect: "dossier_pinned", value: "true"),
Step("ch18o9", "assert", expect: "idle", value: "false"),
Step("ch18o10", "assert", expect: "dossier_lore_history_match"),
Step("ch18p", "assert", expect: "hud_no_overlap"),
// Resume idle: Follow mode Lore must retarget when dossier focus changes.
Step("ch18f1", "spectator", value: "on"),
Step("ch18f2", "wait", wait: 0.2f),
Step("ch18f3", "spawn", asset: "rabbit", count: 1),
Step("ch18f4", "focus", asset: "auto"),
Step("ch18f5", "wait", wait: 0.35f),
Step("ch18f6", "assert", expect: "lore_follows_focus"),
Step("ch18f7", "assert", expect: "lore_follow", value: "true"),
// Books button still opens Follow mode.
Step("ch18p0", "lore_close"),
Step("ch18p0b", "dossier_history_open"),
Step("ch18p0c", "wait", wait: 0.2f),
Step("ch18p0d", "assert", expect: "dossier_lore_history_match"),
Step("ch18p0e", "assert", expect: "idle", value: "false"),
Step("ch18p0f", "assert", expect: "lore_follow", value: "true"),
// Empty-history subject must not keep a previous unit's peek lines.
Step("ch18p1", "lore_back"),
Step("ch18p2", "spawn", asset: "cow", count: 1),
Step("ch18p3", "focus", asset: "auto"),
Step("ch18p4", "wait", wait: 0.25f),
Step("ch18p5", "dossier_history_open"),
Step("ch18p6", "wait", wait: 0.25f),
Step("ch18p7", "assert", expect: "idle", value: "false"),
Step("ch18p8", "assert", expect: "lore_detail", value: "true"),
Step("ch18p9", "assert", expect: "dossier_history_shown", value: "0", label: "exact", tier: "life"),
Step("ch18p10", "assert", expect: "lore_history_shown", value: "0", label: "exact"),
Step("ch18p11", "assert", expect: "dossier_lore_history_match"),
Step("ch18p12", "screenshot", value: "hud-lore-empty-history.png"),
Step("ch18p13", "wait", wait: 0.55f),
// Fallen (dead) archive: pinned; old age → death place.
Step("ch18d1", "lore_back"),
Step("ch18d1b", "focus", asset: "auto"),
Step("ch18d1c", "wait", wait: 0.2f),
Step(
"ch18d2",
"chronicle_orphan",
label: "Oldbone",
asset: "skeleton",
value: "Fallen",
count: 5,
expect: "Age",
tier: "place"),
Step("ch18d3", "lore_pick_fallen", value: "Oldbone"),
Step("ch18d4", "wait", wait: 0.25f),
Step("ch18d5", "assert", expect: "lore_detail", value: "true"),
Step("ch18d6", "assert", expect: "lore_follow", value: "false"),
Step("ch18d7", "assert", expect: "lore_history_shown", value: "5", label: "exact"),
Step("ch18d7b", "assert", expect: "fallen_focus", value: "place"),
Step("ch18d7c", "assert", expect: "death_manner", value: "old age"),
Step("ch18d7d", "assert", expect: "dossier_contains", value: "Oldbone"),
Step("ch18d7e", "assert", expect: "dossier_unit_matches_lore"),
Step("ch18d7f", "assert", expect: "dossier_species", value: "skeleton"),
Step("ch18d7g", "assert", expect: "dossier_avatar_species"),
Step("ch18d7h", "assert", expect: "fallen_place_no_follow"),
Step("ch18d8", "screenshot", value: "hud-lore-fallen.png"),
Step("ch18d9", "wait", wait: 0.55f),
// Fallen slain → living killer focus.
Step("ch18k1", "lore_back"),
Step("ch18k2", "spawn", asset: "human", count: 1),
Step("ch18k3", "focus", asset: "auto"),
Step("ch18k4", "wait", wait: 0.25f),
Step(
"ch18k5",
"chronicle_orphan",
label: "Slainbone",
asset: "skeleton",
value: "Murder",
count: 3,
expect: "Weapon",
tier: "killer"),
Step("ch18k6", "lore_pick_fallen", value: "Slainbone"),
Step("ch18k7", "wait", wait: 0.25f),
Step("ch18k8", "assert", expect: "lore_detail", value: "true"),
Step("ch18k9", "assert", expect: "lore_follow", value: "false"),
Step("ch18k10", "assert", expect: "fallen_focus", value: "killer"),
Step("ch18k11", "assert", expect: "death_manner", value: "slain"),
Step("ch18k11b", "assert", expect: "dossier_contains", value: "Slainbone"),
Step("ch18k11c", "assert", expect: "dossier_unit_matches_lore"),
Step("ch18k11d", "assert", expect: "dossier_species", value: "skeleton"),
Step("ch18k11e", "assert", expect: "dossier_avatar_species"),
Step("ch18k11f", "assert", expect: "fallen_killer_camera"),
Step("ch18k12", "screenshot", value: "hud-lore-fallen-slain.png"),
Step("ch18k13", "wait", wait: 0.55f),
// Regression: fallen species portrait must not stick onto the next living dossier.
Step("ch18av1", "lore_back"),
Step("ch18av1b", "lore_close"),
Step("ch18av1c", "spectator", value: "on"),
Step("ch18av1d", "wait", wait: 0.2f),
Step("ch18av2", "spawn", asset: "human", count: 1),
Step("ch18av3", "focus", asset: "auto"),
Step("ch18av4", "wait", wait: 0.25f),
Step("ch18av5", "assert", expect: "dossier_matches_focus"),
Step("ch18av6", "assert", expect: "dossier_species", value: "human"),
Step("ch18av7", "assert", expect: "dossier_avatar_live"),
Step("ch18av8", "screenshot", value: "hud-dossier-after-fallen.png"),
Step("ch18av9", "wait", wait: 0.55f),
// Also verify list-pick living after a fallen species portrait.
Step("ch18av10", "lore_pick_fallen", value: "Oldbone"),
Step("ch18av11", "wait", wait: 0.2f),
Step("ch18av12", "assert", expect: "dossier_avatar_species"),
Step("ch18av13", "lore_back"),
Step("ch18av14", "lore_pick", value: "auto"),
Step("ch18av15", "wait", wait: 0.25f),
Step("ch18av16", "assert", expect: "dossier_matches_focus"),
Step("ch18av17", "assert", expect: "dossier_avatar_live"),
Step("ch18av18", "screenshot", value: "hud-dossier-after-fallen-pick.png"),
Step("ch18av19", "wait", wait: 0.55f),
// Character list browse (pinned archive) - Living tab.
Step("ch18q", "lore_tab", value: "living"),
Step("ch18q0", "lore_back"),
Step("ch18q2", "assert", expect: "lore_tab", value: "living"),
Step("ch18q3", "assert", expect: "lore_recent", value: "1", label: "min"),
Step("ch18q4", "lore_pick", value: "auto"),
Step("ch18q5", "wait", wait: 0.25f),
Step("ch18q6", "assert", expect: "idle", value: "false"),
Step("ch18q7", "assert", expect: "lore_detail", value: "true"),
Step("ch18q7b", "assert", expect: "lore_follow", value: "false"),
Step("ch18q7c", "assert", expect: "dossier_unit_matches_lore"),
Step("ch18q7d", "assert", expect: "dossier_matches_focus"),
Step("ch18q7e", "assert", expect: "dossier_avatar_live"),
Step("ch18q7f", "assert", expect: "lore_tab", value: "living"),
Step("ch18q8", "screenshot", value: "hud-lore-characters.png"),
Step("ch18q9", "wait", wait: 0.55f),
// Resume idle: pinned list pick flips back to Follow mode.
Step("ch18q10", "spectator", value: "on"),
Step("ch18q11", "wait", wait: 0.25f),
Step("ch18q11b", "assert", expect: "lore_follow", value: "true"),
Step("ch18q11c", "spawn", asset: "sheep", count: 1),
Step("ch18q11d", "focus", asset: "auto"),
Step("ch18q11e", "wait", wait: 0.35f),
Step("ch18q11f", "assert", expect: "lore_follows_focus"),
// Fallen tab: newest death first (Slainbone after Oldbone).
Step("ch18ft1", "lore_back"),
Step("ch18ft2", "lore_tab", value: "fallen"),
Step("ch18ft3", "wait", wait: 0.2f),
Step("ch18ft4", "assert", expect: "lore_tab", value: "fallen"),
Step("ch18ft5", "assert", expect: "fallen_list_top", value: "Slainbone"),
// Snapshot stays put until Refresh (new deaths do not auto-rebuild).
Step("ch18ft5b", "assert", expect: "lore_list_stale", value: "false"),
Step(
"ch18ft5c",
"chronicle_orphan",
label: "FreshFallen",
asset: "human",
value: "Fresh",
count: 1,
expect: "Age",
tier: "place"),
Step("ch18ft5d", "assert", expect: "lore_list_stale", value: "true"),
Step("ch18ft5e", "assert", expect: "fallen_list_top", value: "Slainbone"),
Step("ch18ft5f", "lore_refresh"),
Step("ch18ft5g", "assert", expect: "lore_list_stale", value: "false"),
Step("ch18ft5h", "assert", expect: "fallen_list_top", value: "FreshFallen"),
// Favorite filter on Fallen (same star control as Living).
Step(
"ch18ft5i",
"chronicle_orphan",
label: "FavFallen",
asset: "human",
value: "Fav",
count: 1,
expect: "Age",
tier: "place+fav"),
Step("ch18ft5j", "lore_favorites", value: "true"),
Step("ch18ft5k", "lore_refresh"),
Step("ch18ft5l", "assert", expect: "fallen_list_top", value: "FavFallen"),
Step("ch18ft5m", "lore_favorites", value: "false"),
Step("ch18ft5n", "lore_refresh"),
Step("ch18ft6", "screenshot", value: "hud-lore-fallen-tab.png"),
Step("ch18ft7", "wait", wait: 0.55f),
Step("ch18q12", "lore_tab", value: "world"),
Step("ch19", "assert", expect: "focus", value: "true"),
Step("ch20", "assert", expect: "health"),
Step("ch21", "chronicle_jump"),
Step("ch22", "wait", wait: 0.25f),
Step("ch23", "assert", expect: "focus", value: "true"),
Step("ch24", "assert", expect: "dossier_contains", value: "auto"),
Step("ch25", "assert", expect: "no_bad"),
Step("ch99", "snapshot"),
};

View file

@ -76,6 +76,29 @@ public static class HudIcons
return FromAsset(actor.asset);
}
public static Sprite FromSpeciesId(string speciesId)
{
if (string.IsNullOrEmpty(speciesId))
{
return FromUiIcon("iconQuestionMark");
}
try
{
ActorAsset asset = AssetManager.actor_library?.get(speciesId);
if (asset != null)
{
return FromAsset(asset);
}
}
catch
{
// fall through
}
return FromUiIcon("iconQuestionMark");
}
/// <summary>
/// Live unit sprite as shown in inspect UI (phenotype/kingdom tint), not the species icon.
/// </summary>
@ -131,11 +154,44 @@ public static class HudIcons
return FromUiIcon("iconFavoriteStar");
case ChronicleKind.World:
return FromUiIcon("iconWar") ?? FromUiIcon("iconBooks");
case ChronicleKind.AgeChapter:
return FromUiIcon("iconAge") ?? FromUiIcon("iconClock");
default:
return FromUiIcon("iconClock");
}
}
public static Sprite ForDeathManner(DeathManner manner)
{
switch (manner)
{
case DeathManner.Slain:
return FromUiIcon("iconKills") ?? FromUiIcon("iconAttack") ?? ForChronicleKind(ChronicleKind.Death);
case DeathManner.OldAge:
return FromUiIcon("iconAge") ?? FromUiIcon("iconClock") ?? ForChronicleKind(ChronicleKind.Death);
case DeathManner.Starvation:
return FromUiIcon("iconHunger") ?? FromUiIcon("iconFood") ?? ForChronicleKind(ChronicleKind.Death);
case DeathManner.Drowned:
return FromUiIcon("iconRain") ?? FromUiIcon("iconWater") ?? ForChronicleKind(ChronicleKind.Death);
case DeathManner.Burned:
return FromUiIcon("iconFire") ?? FromUiIcon("iconLava") ?? ForChronicleKind(ChronicleKind.Death);
case DeathManner.Plague:
return FromUiIcon("iconPlague") ?? FromUiIcon("iconDisease") ?? ForChronicleKind(ChronicleKind.Death);
case DeathManner.Divine:
return FromUiIcon("iconDivineLight") ?? FromUiIcon("iconGodFinger") ?? ForChronicleKind(ChronicleKind.Death);
case DeathManner.Accident:
return FromUiIcon("iconBomb") ?? FromUiIcon("iconExplosion") ?? ForChronicleKind(ChronicleKind.Death);
default:
return ForChronicleKind(ChronicleKind.Death);
}
}
public static Sprite Favorite() =>
FromUiIcon("iconFavorite") ?? FromUiIcon("iconFavoriteStar") ?? FromUiIcon("iconStar");
public static Sprite ExpandHistory() =>
FromUiIcon("iconBooks") ?? FromUiIcon("iconList") ?? FromUiIcon("iconClock");
public static Sprite Age() => FromUiIcon("iconAge");
public static Sprite Level() => FromUiIcon("iconLevels");

View file

@ -17,6 +17,8 @@ public static class InterestDirector
public const float AmbientRotateSeconds = 20f;
public const float ActionPollSeconds = 0.75f;
public const float InputExitGraceSeconds = 2.5f;
/// <summary>Leave a Cold (idle) focus sooner so interesting work elsewhere can win.</summary>
public const float ColdFocusEscapeSeconds = 6f;
// Production defaults (copied when leaving harness fast timing).
private static float _minDwell = MinDwellSeconds;
@ -28,6 +30,7 @@ public static class InterestDirector
private static float _ambientRotate = AmbientRotateSeconds;
private static float _actionPoll = ActionPollSeconds;
private static float _inputExitGrace = InputExitGraceSeconds;
private static float _coldFocusEscape = ColdFocusEscapeSeconds;
private static InterestEvent _current;
private static float _currentStartedAt = -999f;
@ -63,6 +66,7 @@ public static class InterestDirector
_ambientRotate = 2.5f;
_actionPoll = 0.35f;
_inputExitGrace = 0.6f;
_coldFocusEscape = 1.2f;
}
else
{
@ -75,6 +79,7 @@ public static class InterestDirector
_ambientRotate = AmbientRotateSeconds;
_actionPoll = ActionPollSeconds;
_inputExitGrace = InputExitGraceSeconds;
_coldFocusEscape = ColdFocusEscapeSeconds;
}
}
@ -113,6 +118,26 @@ public static class InterestDirector
InterestCollector.Clear();
}
/// <summary>
/// Stop idle auto-follow the same way manual input does (quiet), for Lore history browsing.
/// Prefer <see cref="ChronicleHud.OpenUnitHistory"/> which focuses first then banners.
/// </summary>
public static void PauseForBrowsing(string banner = "Paused (viewing history)")
{
if (SpectatorMode.Active)
{
SpectatorMode.SetActive(false, quiet: true);
}
WatchCaption.PinWhilePaused();
if (!string.IsNullOrEmpty(banner))
{
WatchCaption.ShowStatusBanner(banner);
}
LogService.LogInfo("[IdleSpectator] Spectator paused (viewing history)");
}
public static void Update()
{
if (!SpectatorMode.Active || !Config.game_loaded || World.world == null)
@ -120,6 +145,8 @@ public static class InterestDirector
return;
}
WatchCaption.ClearPausePin();
// During harness batches, freeze unless director_run explicitly unfreezes.
if (AgentHarness.FreezeDirector)
{
@ -181,16 +208,66 @@ public static class InterestDirector
bool quiet = now - _lastInterestingAt >= _quietAmbientAfter;
bool dwellDone = _current == null || onCurrent >= DwellFor(_current);
bool ambientDue = now - _lastAmbientAt >= _ambientRotate;
bool coldEscape = FocusWentCold(onCurrent) && sinceSwitch >= _switchCooldown;
if (_current == null && sinceSwitch >= 0.5f)
{
TryAmbient(force: true);
}
else if (coldEscape)
{
TryAmbient(force: true);
}
else if (quiet && dwellDone && ambientDue && sinceSwitch >= _switchCooldown)
{
TryAmbient(force: false);
}
}
private static bool FocusWentCold(float onCurrent)
{
if (_current == null || onCurrent < _coldFocusEscape)
{
return false;
}
// WorldLog Story/Epic should not be yanked solely for a cold follow unit.
if (_current.Tier >= InterestTier.Story
&& !string.IsNullOrEmpty(_current.AssetId)
&& _current.AssetId != "scored_unit"
&& _current.AssetId != "live_battle")
{
// Ambient-scored kings used AssetId = species id - still allow cold escape for those.
// Only protect true world-log assets (war_*, kingdom_*, etc.).
if (_current.AssetId.IndexOf("war", System.StringComparison.OrdinalIgnoreCase) >= 0
|| _current.AssetId.IndexOf("kingdom", System.StringComparison.OrdinalIgnoreCase) >= 0
|| _current.AssetId.IndexOf("city", System.StringComparison.OrdinalIgnoreCase) >= 0
|| _current.AssetId.IndexOf("disaster", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
return false;
}
}
Actor unit = null;
try
{
if (MoveCamera.hasFocusUnit())
{
unit = MoveCamera._focus_unit;
}
}
catch
{
unit = null;
}
if (unit == null || !unit.isAlive())
{
return true;
}
return WorldActivityScanner.IsFocusActivityCold(unit);
}
/// <summary>
/// True when a drained New species tip would be allowed to take the camera.
/// </summary>
@ -276,6 +353,8 @@ public static class InterestDirector
}
if (!World.world.isOverUI()
&& !WatchCaption.IsPointerOverInteractive()
&& !ChronicleHud.IsPointerOverPanel()
&& (InputHelpers.GetMouseButtonDown(0) || InputHelpers.GetMouseButtonDown(1)))
{
return true;

View file

@ -3,7 +3,7 @@
"enabled": "Enable Idle Spectator (I)",
"show_watch_reasons": "Show brief watch tips",
"show_dossier_caption": "Show creature dossier caption while watching",
"chronicle_enabled": "Enable Chronicle panel (F9) - character History + World events",
"chronicle_enabled": "Enable Lore panel (L) - World Memory + Character History",
"debug_state_probe": "Log focus/power-bar state (for debugging)",
"IdleSpectator Chronicle": "Character Chronicle"
"IdleSpectator Chronicle": "Lore"
}

270
IdleSpectator/LoreProse.cs Normal file
View file

@ -0,0 +1,270 @@
using System.Text.RegularExpressions;
namespace IdleSpectator;
/// <summary>
/// Chronicle-voice templates. Hook = fact (<see cref="ChronicleEntry.Line"/>);
/// sentence = optional poetry (<see cref="ChronicleEntry.LoreLine"/>). Never invents facts.
/// </summary>
public static class LoreProse
{
private static readonly Regex KillPattern =
new Regex(@"^Killed (.+) \(([^)]+)\)$", RegexOptions.CultureInvariant);
private static readonly Regex KilledByPattern =
new Regex(@"^Killed by (.+) \(([^)]+)\)$", RegexOptions.CultureInvariant);
private static readonly Regex EatenByPattern =
new Regex(@"^Eaten by (.+) \(([^)]+)\)$", RegexOptions.CultureInvariant);
private static readonly Regex LoversPattern =
new Regex(@"^Became lovers with (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex FriendPattern =
new Regex(@"^Befriended (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex WarVsPattern =
new Regex(@"^War: (.+) vs (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex TotalWarPattern =
new Regex(@"^Total war: (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex KingdomFellPattern =
new Regex(@"^Kingdom fell: (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex KingdomNewPattern =
new Regex(@"^New kingdom: (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex CityDestroyedPattern =
new Regex(@"^City destroyed: (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex CityNewPattern =
new Regex(@"^New city: (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex NewKingPattern =
new Regex(@"^New king: (.+) \((.+)\)$", RegexOptions.CultureInvariant);
private static readonly Regex KingDiedPattern =
new Regex(@"^King died: (.+) \((.+)\)$", RegexOptions.CultureInvariant);
private static readonly Regex KingKilledPattern =
new Regex(@"^King killed: (.+) \((.+)\)$", RegexOptions.CultureInvariant);
private static readonly Regex AlliancePattern =
new Regex(@"^Alliance: (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex AllianceDissolvedPattern =
new Regex(@"^Alliance dissolved: (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex WarEndedPattern =
new Regex(@"^War ended: (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex RevoltPattern =
new Regex(@"^Revolt: (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex ExtinctPattern =
new Regex(@"^Species extinct: (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex FavoriteFellPattern =
new Regex(@"^Favorite fell: (.+)$", RegexOptions.CultureInvariant);
private static readonly Regex DisasterHint =
new Regex(@"disaster", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
/// <summary>Rewrite a raw history/world fact line into short legendary prose.</summary>
public static string Rewrite(ChronicleKind kind, string rawLine, string assetId = null)
{
if (string.IsNullOrEmpty(rawLine))
{
return rawLine ?? "";
}
// Long free-form harness / player lines stay as written.
if (rawLine.Length > 96)
{
return rawLine;
}
switch (kind)
{
case ChronicleKind.Kill:
return RewriteKill(rawLine);
case ChronicleKind.Death:
return RewriteDeath(rawLine);
case ChronicleKind.Lover:
return RewriteLovers(rawLine);
case ChronicleKind.Friend:
return RewriteFriend(rawLine);
case ChronicleKind.World:
return RewriteWorld(rawLine, assetId);
default:
return rawLine;
}
}
private static string RewriteKill(string raw)
{
Match m = KillPattern.Match(raw);
if (m.Success)
{
return "Slew " + m.Groups[1].Value + " of the " + m.Groups[2].Value + "s";
}
return raw;
}
private static string RewriteDeath(string raw)
{
Match by = KilledByPattern.Match(raw);
if (by.Success)
{
return "Fell at " + by.Groups[1].Value + "'s hand";
}
Match eaten = EatenByPattern.Match(raw);
if (eaten.Success)
{
return "Devoured by " + eaten.Groups[1].Value;
}
switch (raw)
{
case "Died of old age":
return "Passed from old age";
case "Starved to death":
return "Starved in lean seasons";
case "Burned to death":
return "Burned to ash";
case "Burned in lava":
return "Claimed by lava";
case "Drowned":
return "Drowned beneath the waves";
case "Died of poison":
return "Succumbed to poison";
case "Died of plague":
return "Taken by plague";
case "Died of infection":
return "Taken by infection";
case "Died of a tumor":
return "Claimed by a tumor";
case "Dissolved in acid":
return "Dissolved in acid";
case "Died of ash fever":
return "Taken by ash fever";
case "Fell to their death":
return "Fell to their death";
case "Died in an explosion":
return "Perished in an explosion";
case "Struck down by divine power":
return "Struck down by divine power";
case "Was eaten":
return "Was eaten";
case "Killed in combat":
return "Fell in combat";
case "Transformed away":
return "Transformed away";
case "Died":
return "Met their end";
default:
return raw;
}
}
private static string RewriteLovers(string raw)
{
Match m = LoversPattern.Match(raw);
return m.Success ? "Heartbound to " + m.Groups[1].Value : raw;
}
private static string RewriteFriend(string raw)
{
Match m = FriendPattern.Match(raw);
return m.Success ? "Bound in friendship with " + m.Groups[1].Value : raw;
}
private static string RewriteWorld(string raw, string assetId)
{
Match m;
m = WarVsPattern.Match(raw);
if (m.Success)
{
return "War kindled: " + m.Groups[1].Value + " and " + m.Groups[2].Value;
}
m = TotalWarPattern.Match(raw);
if (m.Success)
{
return "Total war engulfed " + m.Groups[1].Value;
}
m = KingdomFellPattern.Match(raw);
if (m.Success)
{
return "Kingdom fell: " + m.Groups[1].Value;
}
m = KingdomNewPattern.Match(raw);
if (m.Success)
{
return "A kingdom rose: " + m.Groups[1].Value;
}
m = CityDestroyedPattern.Match(raw);
if (m.Success)
{
return "City razed: " + m.Groups[1].Value;
}
m = CityNewPattern.Match(raw);
if (m.Success)
{
return "City founded: " + m.Groups[1].Value;
}
m = NewKingPattern.Match(raw);
if (m.Success)
{
return "Crowned: " + m.Groups[1].Value + " of " + m.Groups[2].Value;
}
m = KingDiedPattern.Match(raw);
if (m.Success)
{
return "A king passed: " + m.Groups[1].Value + " of " + m.Groups[2].Value;
}
m = KingKilledPattern.Match(raw);
if (m.Success)
{
return "A king slain: " + m.Groups[1].Value + " of " + m.Groups[2].Value;
}
m = AlliancePattern.Match(raw);
if (m.Success)
{
return "Alliance forged: " + m.Groups[1].Value;
}
m = AllianceDissolvedPattern.Match(raw);
if (m.Success)
{
return "Alliance broken: " + m.Groups[1].Value;
}
m = WarEndedPattern.Match(raw);
if (m.Success)
{
return "War ended: " + m.Groups[1].Value;
}
m = RevoltPattern.Match(raw);
if (m.Success)
{
return "Revolt in " + m.Groups[1].Value;
}
m = ExtinctPattern.Match(raw);
if (m.Success)
{
return "Extinct: " + m.Groups[1].Value;
}
m = FavoriteFellPattern.Match(raw);
if (m.Success)
{
return "A favorite fell: " + m.Groups[1].Value;
}
if (!string.IsNullOrEmpty(assetId) && DisasterHint.IsMatch(assetId))
{
return "Disaster struck: " + raw;
}
if (!string.IsNullOrEmpty(assetId) && assetId.IndexOf("shattered", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
return raw.Replace("Kingdom shattered:", "Kingdom shattered:");
}
return raw;
}
}

View file

@ -57,7 +57,7 @@ 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 for Idle Spectator, F9 for Chronicle");
LogService.LogInfo($"[{pModDecl.Name}]: Press I for Idle Spectator, L for Lore");
Chronicle.EnsureWindow();
}

View file

@ -33,14 +33,16 @@ public static class SpectatorMode
}
Active = active;
if (Active)
{
InterestDirector.OnSpectatorEnabled();
SpeciesDiscovery.OnSpectatorEnabled();
Chronicle.OnSpectatorEnabled();
FocusRelationshipArrows.OnSpectatorEnabled();
LogService.LogInfo("[IdleSpectator] Spectator mode enabled (I or any input to stop; F9 chronicle)");
}
if (Active)
{
WatchCaption.ClearPausePin();
InterestDirector.OnSpectatorEnabled();
SpeciesDiscovery.OnSpectatorEnabled();
Chronicle.OnSpectatorEnabled();
ChronicleHud.OnIdleResumed();
FocusRelationshipArrows.OnSpectatorEnabled();
LogService.LogInfo("[IdleSpectator] Spectator mode enabled (I or any input to stop; L for Lore)");
}
else
{
InterestDirector.OnSpectatorDisabled();

View file

@ -42,6 +42,31 @@ public sealed class UnitDossier
public ActorTrait Trait;
}
/// <summary>
/// Minimal dossier for archive subjects with no living unit (harness orphans / legacy deaths).
/// </summary>
public static UnitDossier FromFallenArchive(
long unitId,
string name,
string speciesId,
DeathManner manner = DeathManner.None)
{
UnitDossier d = new UnitDossier();
d.UnitId = unitId;
d.Name = string.IsNullOrEmpty(name) ? "Nameless" : name;
d.SpeciesId = string.IsNullOrEmpty(speciesId) ? "creature" : speciesId;
d.Headline = string.IsNullOrEmpty(d.SpeciesId)
? d.Name
: $"{d.Name} ({d.SpeciesId})";
string mannerLabel = Chronicle.DeathMannerLabel(manner);
d.ReasonLine = string.IsNullOrEmpty(mannerLabel) || manner == DeathManner.None
? "Fallen"
: char.ToUpperInvariant(mannerLabel[0]) + mannerLabel.Substring(1);
d.DetailLine = "";
d.CaptionText = JoinCaption(d.Headline, d.ReasonLine, d.DetailLine);
return d;
}
public static UnitDossier FromActor(Actor actor, InterestTier? watchTier = null, string watchLabel = null)
{
UnitDossier d = new UnitDossier();

File diff suppressed because it is too large Load diff

View file

@ -183,18 +183,27 @@ public static class WorldActivityScanner
}
InterestTier tier = InterestTier.Ambient;
if (best.isFavorite() || best.isKing() || best.isCityLeader())
{
tier = InterestTier.Story;
}
else if (best.has_attack_target || (best.data != null && best.data.kills >= 10))
ActivityBand band = ActivityInterestTable.Classify(best);
bool important = best.isFavorite() || best.isKing() || best.isCityLeader();
if (band == ActivityBand.Hot || best.has_attack_target
|| (best.data != null && best.data.kills >= 10 && band >= ActivityBand.Warm))
{
tier = InterestTier.Action;
}
else if (band >= ActivityBand.Warm && important)
{
// Important + interesting (not idle king/favorite).
tier = InterestTier.Story;
}
else if (IsSpectacle(best) || IsSingletonSpecies(best, speciesCounts))
{
tier = InterestTier.Curiosity;
}
else
{
tier = InterestTier.Ambient;
}
return new InterestEvent
{
@ -231,6 +240,40 @@ public static class WorldActivityScanner
return speciesCounts;
}
public static bool HarnessActivityFirstScoringOk(out string detail)
{
// Mirror ScoreActor importance gating with synthetic bands.
float Cold(float importance) =>
ComposeScore(4f, ActivityBand.Cold, importance);
float Warm(float importance) =>
ComposeScore(28f, ActivityBand.Warm, importance);
float Hot(float importance) =>
ComposeScore(55f, ActivityBand.Hot, importance);
float coldKingFav = Cold(40f); // favorite + king
float hotPeasant = Hot(0f);
float warmFav = Warm(22f);
float coldPeasant = Cold(0f);
bool ok = hotPeasant > coldKingFav
&& warmFav > coldKingFav
&& hotPeasant > warmFav
&& coldKingFav > coldPeasant;
detail =
$"coldKingFav={coldKingFav:0.0} hotPeasant={hotPeasant:0.0} warmFav={warmFav:0.0} coldPeasant={coldPeasant:0.0}";
return ok;
}
private static float ComposeScore(float activity, ActivityBand band, float importance)
{
if (band >= ActivityBand.Warm || activity >= ActivityInterestTable.WarmScoreFloor)
{
return activity + importance * 0.85f;
}
return activity + importance * 0.08f;
}
public static float ScoreActorPublic(Actor actor, Dictionary<string, int> speciesCounts)
{
return ScoreActor(actor, speciesCounts);
@ -253,48 +296,54 @@ public static class WorldActivityScanner
private static float ScoreActor(Actor actor, Dictionary<string, int> speciesCounts)
{
float score = 0f;
if (actor == null)
{
return 0f;
}
float activity = ActivityInterestTable.ScoreActivity(actor);
ActivityBand band = ActivityInterestTable.Classify(actor);
float importance = 0f;
if (actor.isFavorite())
{
score += 50f;
importance += 22f;
}
if (actor.isKing())
{
score += 40f;
importance += 18f;
}
else if (actor.isCityLeader())
{
score += 35f;
importance += 14f;
}
else if (actor.is_profession_warrior)
{
score += 12f;
importance += 6f;
}
if (actor.has_attack_target)
if (IsSpectacle(actor))
{
score += 30f;
importance += 8f;
}
int kills = actor.data != null ? actor.data.kills : 0;
score += Mathf.Min(25, kills);
string speciesId = actor.asset != null ? actor.asset.id : "unknown";
if (speciesCounts.TryGetValue(speciesId, out int pop) && pop == 1)
if (speciesCounts != null
&& speciesCounts.TryGetValue(speciesId, out int pop)
&& pop == 1)
{
score += 15f;
importance += 10f;
}
if (actor.subspecies != null)
{
// Nascent lineage: few members in subspecies asset unit set if available.
try
{
int subCount = CountSubspeciesMembers(actor.subspecies);
if (subCount > 0 && subCount <= 3)
{
score += 10f;
importance += 6f;
}
}
catch
@ -303,17 +352,37 @@ public static class WorldActivityScanner
}
}
if (IsSpectacle(actor))
importance += Mathf.Min(8f, actor.renown / 120f);
// Importance only amplifies when the unit is actually doing something.
float score;
if (band >= ActivityBand.Warm || activity >= ActivityInterestTable.WarmScoreFloor)
{
score += 8f;
score = activity + importance * 0.85f;
}
else
{
// Cold: tiny identity bump so favorites/kings do not monopolize Ambient.
score = activity + importance * 0.08f;
}
int kills = actor.data != null ? actor.data.kills : 0;
if (band >= ActivityBand.Warm)
{
score += Mathf.Min(12f, kills * 0.35f);
}
score += Mathf.Min(10f, actor.renown / 100f);
// Tiny jitter so equal sheep do not stick forever.
score += (actor.getID() % 7) * 0.01f;
return score;
}
/// <summary>True when the focused unit's current activity is Cold (idle scoring).</summary>
public static bool IsFocusActivityCold(Actor actor)
{
return ActivityInterestTable.Classify(actor) == ActivityBand.Cold
&& (actor == null || !actor.has_attack_target);
}
private static int CountSubspeciesMembers(Subspecies subspecies)
{
int count = 0;

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.9.31",
"description": "AFK Idle Spectator (I) + Chronicle (F9). Dossier top-right; tip on world hover or live sprite.",
"version": "0.12.30",
"description": "AFK Idle Spectator (I) + Lore (L). Full activity prose coverage for actor tasks.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -34,6 +34,7 @@ REGRESSION_SCENARIOS=(
discovery
world_log
chronicle_smoke
activity_idle_smoke
)
scenario=""
@ -82,17 +83,48 @@ worldbox_running() {
return 1
}
mod_compile_failed() {
[[ -f "$LOG" ]] || return 1
# Current Player.log session only (Unity truncates the file on each boot).
rg -q "error CS" "$LOG" 2>/dev/null || return 1
# Only fail when IdleSpectator is implicated, or NML reports a mod compile error near CS lines.
if rg -q "IdleSpectator.*error CS|error CS.*IdleSpectator|WatchCaption\.cs|Chronicle\.cs|LoreProse\.cs|AgentHarness\.cs|ChronicleHud\.cs" "$LOG" 2>/dev/null; then
return 0
fi
# Broader: any error CS in the same boot as an IdleSpectator compile attempt.
if rg -q "Compile Mod IdleSpectator" "$LOG" 2>/dev/null && rg -q "error CS" "$LOG" 2>/dev/null; then
return 0
fi
return 1
}
mod_ready() {
[[ -f "$LOG" ]] || return 1
if tail -n 200 "$LOG" 2>/dev/null | rg -q "error CS"; then
if mod_compile_failed; then
return 2
fi
tail -n 200 "$LOG" 2>/dev/null | rg -q "\[IdleSpectator\]: Harmony patches applied"
rg -q "\[IdleSpectator\]: Harmony patches applied" "$LOG" 2>/dev/null
}
report_compile_errors() {
echo "IdleSpectator compile failed:" >&2
if [[ -f "$LOG" ]]; then
rg -n "error CS|Compile Mod IdleSpectator|Failed to compile|WatchCaption\.cs|AgentHarness\.cs" "$LOG" | tail -n 50 >&2 || true
fi
}
ensure_worldbox() {
if worldbox_running; then
echo "WorldBox already running"
local ready_rc=0
mod_ready || ready_rc=$?
if (( ready_rc == 2 )); then
report_compile_errors
return 1
fi
if (( ready_rc != 0 )); then
echo "WARNING: IdleSpectator harmony ready-line not seen in log (continuing)" >&2
fi
return 0
fi
@ -101,27 +133,37 @@ ensure_worldbox() {
return 1
fi
# Do NOT write a marker into Player.log - Unity truncates that file on boot.
echo "Starting WorldBox (steam app $STEAM_APP_ID) ..."
echo "===== HARNESS LAUNCH $(date -Iseconds) =====" >> "$LOG" 2>/dev/null || true
echo "===== HARNESS LAUNCH $(date -Iseconds) =====" >> /tmp/idle-spectator-harness-launch.log 2>/dev/null || true
nohup steam -applaunch "$STEAM_APP_ID" >/tmp/idle-spectator-harness-launch.log 2>&1 &
disown || true
local deadline=$((SECONDS + BOOT_TIMEOUT_SEC))
local saw_process=0
while (( SECONDS < deadline )); do
if mod_ready; then
echo "WorldBox + IdleSpectator ready"
return 0
fi
if [[ -f "$LOG" ]] && tail -n 200 "$LOG" 2>/dev/null | rg -q "error CS"; then
echo "IdleSpectator compile failed:" >&2
rg -n "error CS" "$LOG" | tail -n 40 >&2 || true
return 1
fi
if worldbox_running; then
saw_process=1
printf '.'
else
printf 'o'
fi
local ready_rc=0
mod_ready || ready_rc=$?
if (( ready_rc == 0 )); then
# Avoid accepting a stale log from a previous boot before the new process exists.
if (( saw_process == 1 )) || worldbox_running; then
echo
echo "WorldBox + IdleSpectator ready"
return 0
fi
fi
if (( ready_rc == 2 )); then
echo
report_compile_errors
return 1
fi
sleep 2
done
echo >&2