Compare commits
10 commits
7b4ff19b01
...
0dfe3ab832
| Author | SHA1 | Date | |
|---|---|---|---|
| 0dfe3ab832 | |||
| 63bf44cae5 | |||
| beb08265ec | |||
| 94de8a3b5b | |||
| 0b23a15618 | |||
| 88cf59a6aa | |||
| 7876321391 | |||
| 94b356c9c8 | |||
| b40f3f8621 | |||
| 0f30f18bcb |
26 changed files with 8400 additions and 469 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
334
IdleSpectator/ActivityInterestTable.cs
Normal file
334
IdleSpectator/ActivityInterestTable.cs
Normal 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 0–100 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;
|
||||
}
|
||||
}
|
||||
449
IdleSpectator/ActivityLog.cs
Normal file
449
IdleSpectator/ActivityLog.cs
Normal 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--;
|
||||
}
|
||||
}
|
||||
}
|
||||
932
IdleSpectator/ActivityProse.cs
Normal file
932
IdleSpectator/ActivityProse.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
97
IdleSpectator/ActorActivityPatches.cs
Normal file
97
IdleSpectator/ActorActivityPatches.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
34
IdleSpectator/CameraZoomPatches.cs
Normal file
34
IdleSpectator/CameraZoomPatches.cs
Normal 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
52
IdleSpectator/ChronicleCharacterSummary.cs
Normal file
52
IdleSpectator/ChronicleCharacterSummary.cs
Normal 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
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
270
IdleSpectator/LoreProse.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue