Its a start

This commit is contained in:
DazedAnon 2026-07-14 23:11:51 -05:00
parent 94de8a3b5b
commit beb08265ec
12 changed files with 1489 additions and 98 deletions

View file

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

View file

@ -0,0 +1,295 @@
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.</summary>
public string DisplayLine = "";
}
/// <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>();
public static int SubjectCount => BySubject.Count;
public static void ClearSession()
{
BySubject.Clear();
LastTaskId.Clear();
LastBeatAt.Clear();
LineIndex.Clear();
}
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);
string target = ActivityInterestTable.TargetLabel(actor);
string loc = "";
try
{
if (actor.hasTask() && actor.ai?.task != null)
{
loc = actor.ai.task.getLocalizedText() ?? "";
}
}
catch
{
// ignore
}
string raw = string.IsNullOrEmpty(loc) ? ("Task: " + taskId) : loc;
if (!string.IsNullOrEmpty(target))
{
raw = raw + " → " + target;
}
int idx = NextIndex(id);
string display = ActivityProse.Format(
ActivityKind.TaskStart, taskId, raw, target, id, idx);
Append(id, new ActivityEntry
{
CreatedAt = Time.unscaledTime,
SubjectId = id,
Kind = ActivityKind.TaskStart,
TaskId = taskId,
JobId = jobId,
TargetLabel = target,
Line = raw,
DisplayLine = display
});
}
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 fact = string.IsNullOrEmpty(rawFact) ? actionKey : rawFact;
int idx = NextIndex(id);
string display = ActivityProse.Format(
ActivityKind.ActionBeat, actionKey, fact, target ?? "", id, idx);
Append(id, new ActivityEntry
{
CreatedAt = now,
SubjectId = id,
Kind = ActivityKind.ActionBeat,
TaskId = string.IsNullOrEmpty(taskId) ? actionKey : taskId,
JobId = jobId,
TargetLabel = target ?? "",
Line = fact,
DisplayLine = display
});
}
/// <summary>Harness: inject activity without a live AI transition.</summary>
public static bool ForceNote(long subjectId, string taskOrAction, string rawLine, bool asBeat = false)
{
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;
string display = ActivityProse.Format(kind, taskOrAction, fact, "", subjectId, idx);
Append(subjectId, new ActivityEntry
{
CreatedAt = Time.unscaledTime,
SubjectId = subjectId,
Kind = kind,
TaskId = taskOrAction,
Line = fact,
DisplayLine = display
});
return true;
}
private static int NextIndex(long id)
{
LineIndex.TryGetValue(id, out int idx);
idx++;
LineIndex[id] = idx;
return idx;
}
private static void Append(long id, ActivityEntry entry)
{
if (!BySubject.TryGetValue(id, out List<ActivityEntry> list))
{
list = new List<ActivityEntry>();
BySubject[id] = list;
PruneSubjectsIfNeeded();
}
list.Add(entry);
while (list.Count > MaxLinesPerSubject)
{
list.RemoveAt(0);
}
}
private static void PruneSubjectsIfNeeded()
{
if (BySubject.Count <= MaxSubjects)
{
return;
}
long focusId = 0;
try
{
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
focusId = MoveCamera._focus_unit.getID();
}
}
catch
{
focusId = 0;
}
var ranked = new List<(long id, float lastAt)>(BySubject.Count);
foreach (KeyValuePair<long, List<ActivityEntry>> kv in BySubject)
{
if (kv.Key == focusId)
{
continue;
}
float last = 0f;
List<ActivityEntry> list = kv.Value;
if (list != null && list.Count > 0 && list[list.Count - 1] != null)
{
last = list[list.Count - 1].CreatedAt;
}
ranked.Add((kv.Key, last));
}
ranked.Sort((a, b) => a.lastAt.CompareTo(b.lastAt));
int need = BySubject.Count - MaxSubjects;
for (int i = 0; i < ranked.Count && need > 0; i++)
{
long id = ranked[i].id;
BySubject.Remove(id);
LastTaskId.Remove(id);
LastBeatAt.Remove(id);
LineIndex.Remove(id);
need--;
}
}
}

View file

@ -0,0 +1,266 @@
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>
/// Fact-preserving multi-variant activity lines (anti-stale dossier feed).
/// </summary>
public static class ActivityProse
{
private static readonly Dictionary<string, string[]> Variants = new Dictionary<string, string[]>
{
["fighting"] = new[]
{
"Locked in combat",
"Trading blows",
"In the fray",
"Clash of steel"
},
["fight"] = new[]
{
"Locked in combat",
"Trading blows",
"In the fray"
},
["attack"] = new[]
{
"Strikes at {target}",
"Presses the attack on {target}",
"Lunges toward {target}"
},
["hunt"] = new[]
{
"Hunts {target}",
"Stalks {target}",
"On the trail of {target}"
},
["eating"] = new[]
{
"Taking a meal",
"Eating",
"Breaking hunger"
},
["eat"] = new[]
{
"Taking a meal",
"Eating",
"Breaking hunger"
},
["sleep"] = new[]
{
"Sleeping",
"Resting",
"Deep in slumber"
},
["random_move"] = new[]
{
"Wandering",
"Roaming aimlessly",
"Drifting along"
},
["pollinate"] = new[]
{
"Dusts the blossom",
"Works the flower",
"Leaves pollen behind",
"Humming over petals"
},
["farm"] = new[]
{
"Tends the fields",
"Works the soil",
"Farming the plot"
},
["harvest"] = new[]
{
"Harvests the crop",
"Gathers the yield",
"Cuts the ripe fields"
},
["mine"] = new[]
{
"Delves the mine",
"Chipping ore",
"Deep in the dig"
},
["build"] = new[]
{
"Building",
"Raising walls",
"At the worksite"
},
["gather"] = new[]
{
"Gathering stores",
"Foraging supplies",
"Collecting goods"
},
["woodcut"] = new[]
{
"Felling timber",
"Cutting wood",
"Axe to the trees"
},
["fire"] = new[]
{
"Fighting the blaze",
"Dousing flames",
"Racing the fire"
},
["fireman"] = new[]
{
"Fighting the blaze",
"Dousing flames",
"Racing the fire"
},
["social"] = new[]
{
"Socializing",
"In conversation",
"Among companions"
},
["lover"] = new[]
{
"Seeking a lover",
"Drawn to {target}",
"Heart on the path"
},
["unload"] = new[]
{
"Unloads goods in town",
"Delivers the haul",
"Empties the pack at home"
},
["store"] = new[]
{
"Stores supplies",
"Stocking the village",
"Puts goods away"
},
["fish"] = new[]
{
"Fishing",
"Casting for fish",
"Working the waters"
},
["trade"] = new[]
{
"Trading",
"Bartering goods",
"On a trade run"
},
["run_away"] = new[]
{
"Fleeing",
"Running for safety",
"In retreat"
},
["make_decision"] = new[]
{
"Considering next steps",
"Weighing a choice",
"Paused in thought"
},
["wait"] = new[]
{
"Waiting",
"Holding still",
"Biding time"
},
["BehPollinate"] = new[]
{
"Dusts the blossom",
"Works the flower",
"Leaves pollen behind"
},
["BehUnloadResources"] = new[]
{
"Unloads goods in town",
"Delivers the haul",
"Empties the pack at home"
},
["BehAttackActorHuntingTarget"] = new[]
{
"Hunts {target}",
"Strikes prey",
"Closes on the quarry"
},
["BehCityActorRemoveFire"] = new[]
{
"Dousing flames",
"Fighting the blaze",
"Beats back the fire"
}
};
public static string Format(
ActivityKind kind,
string key,
string rawFact,
string target,
long subjectId,
int lineIndex)
{
if (string.IsNullOrEmpty(rawFact) && string.IsNullOrEmpty(key))
{
return "";
}
string template = PickTemplate(key, subjectId, lineIndex);
if (string.IsNullOrEmpty(template))
{
return rawFact ?? key ?? "";
}
string t = string.IsNullOrEmpty(target) ? "their mark" : target;
return template.Replace("{target}", t);
}
private static string PickTemplate(string key, long subjectId, int lineIndex)
{
if (string.IsNullOrEmpty(key))
{
return null;
}
string[] bag = null;
if (Variants.TryGetValue(key, out bag) && bag != null && bag.Length > 0)
{
return bag[PickIndex(subjectId, key, lineIndex, bag.Length)];
}
string lower = key.ToLowerInvariant();
foreach (KeyValuePair<string, string[]> kv in Variants)
{
if (lower.IndexOf(kv.Key, System.StringComparison.Ordinal) >= 0
&& kv.Value != null
&& kv.Value.Length > 0)
{
return kv.Value[PickIndex(subjectId, kv.Key, lineIndex, kv.Value.Length)];
}
}
return null;
}
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);
// Extra jitter so back-to-back repeats of the same task shift.
h ^= (int)(UnityEngine.Time.unscaledTime * 10f);
if (h < 0)
{
h = -h;
}
return h % count;
}
}
}

View file

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

View file

@ -352,6 +352,84 @@ public static class AgentHarness
break;
}
case "activity_probe":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
string probe = ActivityInterestTable.DumpFocusProbe(focus);
long id = 0;
try
{
if (focus != null)
{
id = focus.getID();
}
}
catch
{
id = 0;
}
_cmdOk++;
Emit(cmd, ok: true, detail: $"{probe} activity_count={ActivityLog.CountFor(id)}");
break;
}
case "activity_force":
{
long id = WatchCaption.CurrentUnitId;
if (id == 0 && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
try
{
id = MoveCamera._focus_unit.getID();
}
catch
{
id = 0;
}
}
string key = !string.IsNullOrEmpty(cmd.asset)
? cmd.asset
: (!string.IsNullOrEmpty(cmd.value) ? cmd.value : "farm");
string line = !string.IsNullOrEmpty(cmd.label) ? cmd.label : key;
bool asBeat = (cmd.tier ?? "").IndexOf("beat", System.StringComparison.OrdinalIgnoreCase) >= 0;
int n = Math.Max(1, cmd.count);
int okN = 0;
for (int i = 0; i < n; i++)
{
string one = n > 1 ? $"{line} #{i + 1}" : line;
if (ActivityLog.ForceNote(id, key, one, asBeat))
{
okN++;
}
}
WatchCaption.ForceRefreshHistory();
bool ok = okN == n && id != 0;
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail:
$"forced={okN}/{n} id={id} count={ActivityLog.CountFor(id)} shown={WatchCaption.LastActivityShown}");
break;
}
case "activity_clear":
{
ActivityLog.ClearSession();
WatchCaption.ForceRefreshHistory();
_cmdOk++;
Emit(cmd, ok: true, detail: "activity_cleared");
break;
}
case "chronicle_world_force":
{
string label = !string.IsNullOrEmpty(cmd.label)
@ -1680,10 +1758,59 @@ public static class AgentHarness
case "dossier_history_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
string hist = WatchCaption.LastHistoryPreview ?? "";
string hist = WatchCaption.LastHistoryJoined ?? "";
if (string.IsNullOrEmpty(hist))
{
hist = WatchCaption.LastHistoryPreview ?? "";
}
pass = !string.IsNullOrEmpty(needle)
&& hist.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
detail = $"hist='{hist}' needle='{needle}'";
detail = $"hist='{hist}' needle='{needle}' act={WatchCaption.LastActivityShown} life={WatchCaption.LastLifeShown}";
break;
}
case "activity_count":
{
long id = WatchCaption.CurrentUnitId;
int want = ParseCountExpect(cmd, defaultValue: 1);
int have = ActivityLog.CountFor(id);
string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant();
pass = cmp == "exact" || cmp == "==" ? have == want : have >= want;
detail = $"activity_count={have} want={want} id={id} shown={WatchCaption.LastActivityShown}";
break;
}
case "activity_prose_variety":
{
long id = WatchCaption.CurrentUnitId;
IReadOnlyList<ActivityEntry> entries = ActivityLog.LatestForSubject(id, 12);
var distinct = new HashSet<string>(StringComparer.Ordinal);
for (int i = 0; i < entries.Count; i++)
{
if (entries[i] != null && !string.IsNullOrEmpty(entries[i].DisplayLine))
{
distinct.Add(entries[i].DisplayLine);
}
}
int want = ParseCountExpect(cmd, defaultValue: 2);
pass = distinct.Count >= want;
detail = $"distinct={distinct.Count} want={want} total={entries.Count}";
break;
}
case "activity_first_scoring":
{
pass = WorldActivityScanner.HarnessActivityFirstScoringOk(out string scoreDetail);
detail = scoreDetail;
break;
}
case "activity_band_cold_focus":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
ActivityBand band = ActivityInterestTable.Classify(focus);
bool cold = WorldActivityScanner.IsFocusActivityCold(focus);
string want = (cmd.value ?? "cold").Trim().ToLowerInvariant();
pass = want == "cold" ? cold : !cold;
detail = $"band={band} cold={cold} want={want} probe={ActivityInterestTable.DumpFocusProbe(focus)}";
break;
}
case "chronicle_world_latest_contains":
@ -1799,7 +1926,12 @@ public static class AgentHarness
case "dossier_history_shown":
{
int want = ParseCountExpect(cmd, defaultValue: 1);
int have = WatchCaption.LastHistoryShown;
string scope = (cmd.tier ?? "").Trim().ToLowerInvariant();
int have = scope == "life"
? WatchCaption.LastLifeShown
: scope == "activity"
? WatchCaption.LastActivityShown
: WatchCaption.LastHistoryShown;
string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant();
if (cmp == "exact" || cmp == "==")
{
@ -1811,7 +1943,7 @@ public static class AgentHarness
}
detail =
$"shown={have} want={want} scrollable={WatchCaption.LastHistoryScrollable} histFill={WatchCaption.LastHistoryFillsBody}";
$"shown={have} want={want} scope={scope} total={WatchCaption.LastHistoryShown} act={WatchCaption.LastActivityShown} life={WatchCaption.LastLifeShown} scrollable={WatchCaption.LastHistoryScrollable} histFill={WatchCaption.LastHistoryFillsBody}";
break;
}
case "dossier_history_scrollable":
@ -1912,16 +2044,17 @@ public static class AgentHarness
{
long dossierId = WatchCaption.CurrentUnitId;
long loreId = ChronicleHud.DetailUnitId;
int dossierShown = WatchCaption.LastHistoryShown;
int lifeShown = WatchCaption.LastLifeShown;
int loreData = Chronicle.HistoryCountFor(loreId);
int loreUi = ChronicleHud.LastDetailHistoryRows;
bool idsMatch = dossierId != 0 && dossierId == loreId;
bool countsAgree = dossierShown <= 0
// Life lines must agree with Lore; activity-only peek does not imply chronicle rows.
bool countsAgree = lifeShown <= 0
? loreData == 0 && loreUi == 0
: loreData >= dossierShown && loreUi >= Mathf.Min(dossierShown, 24);
: loreData >= lifeShown && loreUi >= Mathf.Min(lifeShown, 24);
pass = Chronicle.HudVisible && idsMatch && countsAgree;
detail =
$"dossierId={dossierId} loreId={loreId} dossierShown={dossierShown} loreData={loreData} loreUi={loreUi} preview={WatchCaption.LastHistoryPreview}";
$"dossierId={dossierId} loreId={loreId} lifeShown={lifeShown} act={WatchCaption.LastActivityShown} loreData={loreData} loreUi={loreUi} preview={WatchCaption.LastHistoryPreview}";
break;
}
case "dossier_pinned":

View file

@ -176,6 +176,7 @@ public static class Chronicle
if (!ReferenceEquals(world, _boundWorld))
{
ClearSession();
ActivityLog.ClearSession();
_boundWorld = world;
LogService.LogInfo("[IdleSpectator] Chronicle cleared for new world session");
}
@ -620,6 +621,7 @@ public static class Chronicle
_trackedFocusId = 0;
_currentAgeId = "";
_currentAgeName = "";
ActivityLog.ClearSession();
BumpRevision();
// Keep _boundWorld so re-enable on same map does not loop-clear.
}

View file

@ -35,6 +35,9 @@ internal static class HarnessScenarios
case "chronicle":
case "chronicle_smoke":
return ChronicleSmoke();
case "activity":
case "activity_idle_smoke":
return ActivityIdleSmoke();
case "chronicle_subject_bench":
case "subject_bench":
return ChronicleSubjectBench();
@ -85,6 +88,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"),
};
@ -415,6 +419,49 @@ internal static class HarnessScenarios
};
}
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("act14", "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: "pollinate", label: "Dusts the blossom", count: 1),
Step("act17", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 4, 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("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("act30", "assert", expect: "caption_layout_ok"),
Step("act31", "assert", expect: "health"),
Step("act32", "assert", expect: "no_bad"),
Step("act99", "snapshot"),
};
}
private static List<HarnessCommand> ChronicleSmoke()
{
return new List<HarnessCommand>
@ -560,7 +607,7 @@ internal static class HarnessScenarios
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"),
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"),

View file

@ -17,6 +17,8 @@ public static class InterestDirector
public const float AmbientRotateSeconds = 20f;
public const float ActionPollSeconds = 0.75f;
public const float InputExitGraceSeconds = 2.5f;
/// <summary>Leave a Cold (idle) focus sooner so interesting work elsewhere can win.</summary>
public const float ColdFocusEscapeSeconds = 6f;
// Production defaults (copied when leaving harness fast timing).
private static float _minDwell = MinDwellSeconds;
@ -28,6 +30,7 @@ public static class InterestDirector
private static float _ambientRotate = AmbientRotateSeconds;
private static float _actionPoll = ActionPollSeconds;
private static float _inputExitGrace = InputExitGraceSeconds;
private static float _coldFocusEscape = ColdFocusEscapeSeconds;
private static InterestEvent _current;
private static float _currentStartedAt = -999f;
@ -63,6 +66,7 @@ public static class InterestDirector
_ambientRotate = 2.5f;
_actionPoll = 0.35f;
_inputExitGrace = 0.6f;
_coldFocusEscape = 1.2f;
}
else
{
@ -75,6 +79,7 @@ public static class InterestDirector
_ambientRotate = AmbientRotateSeconds;
_actionPoll = ActionPollSeconds;
_inputExitGrace = InputExitGraceSeconds;
_coldFocusEscape = ColdFocusEscapeSeconds;
}
}
@ -203,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>

View file

@ -107,9 +107,18 @@ public static class WatchCaption
/// <summary>Harness: newest mini-history line currently shown (if any).</summary>
public static string LastHistoryPreview { get; private set; } = "";
/// <summary>Harness: all visible peek lines joined (activity + life).</summary>
public static string LastHistoryJoined { get; private set; } = "";
/// <summary>Harness: how many history lines are currently visible on the dossier.</summary>
public static int LastHistoryShown { get; private set; }
/// <summary>Harness: activity feed lines currently visible.</summary>
public static int LastActivityShown { get; private set; }
/// <summary>Harness: Chronicle Life lines currently visible in the dossier column.</summary>
public static int LastLifeShown { get; private set; }
/// <summary>Current dossier subject id (0 if none).</summary>
public static long CurrentUnitId => _current != null ? _current.UnitId : 0;
@ -324,7 +333,10 @@ public static class WatchCaption
LastDetail = "";
LastCaptionText = "";
LastHistoryPreview = "";
LastHistoryJoined = "";
LastHistoryShown = 0;
LastActivityShown = 0;
LastLifeShown = 0;
LastHistoryFillsBody = false;
_pinnedWhilePaused = false;
LastTraitsPreview = "";
@ -396,6 +408,7 @@ public static class WatchCaption
LastDetail = "";
LastCaptionText = LastHeadline;
LastHistoryPreview = "";
LastHistoryJoined = "";
_current = null;
_boundActor = null;
EnsureBuilt();
@ -637,6 +650,18 @@ public static class WatchCaption
Front(_historyBtn);
}
/// <summary>Harness: rebuild the dossier peek column after activity injects.</summary>
public static void ForceRefreshHistory()
{
_lastHistoryCount = -1;
if (!_visible || _current == null)
{
return;
}
RefreshHistoryIfChanged();
}
private static void RefreshHistoryIfChanged()
{
if (!_visible || _current == null)
@ -645,7 +670,7 @@ public static class WatchCaption
}
long id = _current.UnitId;
int count = Chronicle.HistoryCountFor(id);
int count = ActivityLog.CountFor(id) + Chronicle.HistoryCountFor(id);
if (id == _lastHistorySubjectId && count == _lastHistoryCount)
{
return;
@ -840,7 +865,10 @@ public static class WatchCaption
else
{
LastHistoryPreview = "";
LastHistoryJoined = "";
LastHistoryShown = 0;
LastActivityShown = 0;
LastLifeShown = 0;
LastHistoryFillsBody = false;
if (_historyCol != null)
{
@ -861,13 +889,28 @@ public static class WatchCaption
private static int FillHistory(long unitId)
{
IReadOnlyList<ChronicleEntry> entries =
Chronicle.LatestForSubject(unitId, HistoryPeekMax);
// Activity first (prominent), then Chronicle Life fills remaining peek slots.
// Always reserve one Life slot when the subject has chronicle history so Life
// does not vanish under a busy activity ring (and chronicle smoke stays valid).
int chronicleCount = Chronicle.HistoryCountFor(unitId);
int actCap = chronicleCount > 0 ? HistoryPeekMax - 1 : HistoryPeekMax;
IReadOnlyList<ActivityEntry> activity =
ActivityLog.LatestForSubject(unitId, actCap);
int lifeNeed = Mathf.Max(0, HistoryPeekMax - (activity?.Count ?? 0));
IReadOnlyList<ChronicleEntry> life =
lifeNeed > 0
? Chronicle.LatestForSubject(unitId, lifeNeed)
: System.Array.Empty<ChronicleEntry>();
_lastHistorySubjectId = unitId;
_lastHistoryCount = Chronicle.HistoryCountFor(unitId);
_lastHistoryCount = ActivityLog.CountFor(unitId) + chronicleCount;
LastHistoryPreview = "";
LastHistoryJoined = "";
LastHistoryShown = 0;
LastActivityShown = 0;
LastLifeShown = 0;
_historyColW = HistoryColMinW;
for (int i = 0; i < _historySlotHeights.Length; i++)
{
@ -879,39 +922,113 @@ public static class WatchCaption
return 0;
}
if (entries == null || entries.Count == 0)
int total = (activity?.Count ?? 0) + (life?.Count ?? 0);
if (total == 0)
{
for (int i = 0; i < _historySlots.Length; i++)
// Soft fallback: show current task as a single activity line when the ring is empty.
string taskFallback = "";
try
{
HistorySlot slot = _historySlots[i];
if (slot?.Root != null)
Actor live = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (live != null && live.isAlive() && live.getID() == unitId && live.hasTask()
&& live.ai?.task != null)
{
slot.Root.SetActive(false);
taskFallback = live.ai.task.getLocalizedText() ?? "";
}
if (slot?.Label != null)
{
slot.Label.text = "";
}
_historySlotHeights[i] = 0f;
}
catch
{
taskFallback = "";
}
_historyCol.SetActive(false);
LastHistoryShown = 0;
LastHistoryFillsBody = false;
LastHistoryPreview = "";
return 0;
if (string.IsNullOrEmpty(taskFallback))
{
for (int i = 0; i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
if (slot?.Root != null)
{
slot.Root.SetActive(false);
}
if (slot?.Label != null)
{
slot.Label.text = "";
}
_historySlotHeights[i] = 0f;
}
_historyCol.SetActive(false);
LastHistoryShown = 0;
LastActivityShown = 0;
LastLifeShown = 0;
LastHistoryFillsBody = false;
LastHistoryPreview = "";
LastHistoryJoined = "";
return 0;
}
activity = new[]
{
new ActivityEntry
{
DisplayLine = taskFallback,
Line = taskFallback,
Kind = ActivityKind.TaskStart
}
};
total = 1;
}
_historyCol.SetActive(true);
// Content-based minimum width; Relayout stretches to fill the panel body.
var lines = new List<(string text, bool isActivity, ChronicleKind? kind)>(HistoryPeekMax);
if (activity != null)
{
for (int i = 0; i < activity.Count && lines.Count < HistoryPeekMax; i++)
{
ActivityEntry e = activity[i];
if (e == null)
{
continue;
}
string t = !string.IsNullOrEmpty(e.DisplayLine) ? e.DisplayLine : e.Line;
if (string.IsNullOrEmpty(t))
{
continue;
}
lines.Add((t, true, null));
}
}
if (life != null)
{
for (int i = 0; i < life.Count && lines.Count < HistoryPeekMax; i++)
{
ChronicleEntry e = life[i];
if (e == null)
{
continue;
}
string t = e.DisplayLineRich ?? e.DisplayLine ?? "";
if (string.IsNullOrEmpty(t))
{
continue;
}
lines.Add((t, false, e.Kind));
}
}
float widestLabel = 0f;
for (int i = 0; i < _historySlots.Length; i++)
for (int i = 0; i < lines.Count && i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
if (slot?.Label == null || i >= entries.Count || entries[i] == null)
if (slot?.Label == null)
{
continue;
}
@ -922,7 +1039,7 @@ public static class WatchCaption
label.horizontalOverflow = HorizontalWrapMode.Overflow;
label.verticalOverflow = VerticalWrapMode.Overflow;
label.alignment = TextAnchor.UpperLeft;
label.text = entries[i].DisplayLineRich ?? "";
label.text = lines[i].text;
Canvas.ForceUpdateCanvases();
try
{
@ -930,7 +1047,7 @@ public static class WatchCaption
}
catch
{
widestLabel = Mathf.Max(widestLabel, (entries[i].DisplayLine ?? "").Length * 6.2f);
widestLabel = Mathf.Max(widestLabel, lines[i].text.Length * 6.2f);
}
}
@ -938,63 +1055,96 @@ public static class WatchCaption
widestLabel + HistoryIcon + 4f,
HistoryColMinW,
HistoryColMaxW);
ApplyHistorySlotContents(entries, _historyColW);
ApplyCombinedSlotContents(lines, _historyColW);
LastHistoryShown = CountActiveHistorySlots();
return LastHistoryShown;
}
private static void ApplyHistorySlotContents(IReadOnlyList<ChronicleEntry> entries, float colW)
private static void ApplyCombinedSlotContents(
List<(string text, bool isActivity, ChronicleKind? kind)> lines,
float colW)
{
float labelW = Mathf.Max(24f, colW - HistoryIcon - 2f);
LastHistoryPreview = "";
LastHistoryJoined = "";
LastActivityShown = 0;
LastLifeShown = 0;
var joined = new List<string>(HistoryPeekMax);
for (int i = 0; i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
if (slot == null || slot.Root == null)
if (slot?.Root == null)
{
continue;
}
if (entries != null && i < entries.Count && entries[i] != null)
{
ChronicleEntry e = entries[i];
slot.Root.SetActive(true);
HudIcons.Apply(slot.Icon, HudIcons.ForChronicleKind(e.Kind));
if (slot.Label != null)
{
slot.Label.horizontalOverflow = HorizontalWrapMode.Wrap;
slot.Label.verticalOverflow = VerticalWrapMode.Overflow;
slot.Label.text = e.DisplayLineRich ?? "";
RectTransform lrt = slot.Label.GetComponent<RectTransform>();
lrt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, labelW);
Canvas.ForceUpdateCanvases();
float needed = HistoryLineMinH;
try
{
needed = Mathf.Max(HistoryLineMinH, slot.Label.preferredHeight + 4f);
}
catch
{
int chars = (e.DisplayLine ?? "").Length;
int perLine = Mathf.Max(12, (int)(labelW / 6.2f));
int lines = Mathf.Max(1, (chars + perLine - 1) / perLine);
needed = HistoryLineMinH * lines;
}
_historySlotHeights[i] = Mathf.Clamp(needed, HistoryLineMinH, HistoryLineMaxH);
}
if (string.IsNullOrEmpty(LastHistoryPreview))
{
LastHistoryPreview = e.DisplayLine ?? "";
}
}
else
if (i >= lines.Count)
{
slot.Root.SetActive(false);
_historySlotHeights[i] = 0f;
continue;
}
var row = lines[i];
slot.Root.SetActive(true);
Sprite icon = row.isActivity
? (HudIcons.FromUiIcon("iconClock")
?? HudIcons.FromUiIcon("iconTask")
?? HudIcons.ForChronicleKind(ChronicleKind.Other))
: HudIcons.ForChronicleKind(row.kind ?? ChronicleKind.Other);
HudIcons.Apply(slot.Icon, icon);
if (slot.Label != null)
{
slot.Label.supportRichText = true;
slot.Label.horizontalOverflow = HorizontalWrapMode.Wrap;
slot.Label.verticalOverflow = VerticalWrapMode.Overflow;
slot.Label.color = row.isActivity
? new Color(0.88f, 0.9f, 0.78f, 1f)
: HistoryTextColor;
slot.Label.text = row.text ?? "";
RectTransform lrt = slot.Label.GetComponent<RectTransform>();
lrt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, labelW);
Canvas.ForceUpdateCanvases();
float needed = HistoryLineMinH;
try
{
needed = Mathf.Max(HistoryLineMinH, slot.Label.preferredHeight + 4f);
}
catch
{
int chars = (row.text ?? "").Length;
int perLine = Mathf.Max(12, (int)(labelW / 6.2f));
int wrapLines = Mathf.Max(1, (chars + perLine - 1) / perLine);
needed = HistoryLineMinH * wrapLines;
}
_historySlotHeights[i] = Mathf.Clamp(needed, HistoryLineMinH, HistoryLineMaxH);
}
else
{
_historySlotHeights[i] = HistoryLineMinH;
}
if (row.isActivity)
{
LastActivityShown++;
}
else
{
LastLifeShown++;
}
string plain = (row.text ?? "").Replace("\n", " ");
joined.Add(plain);
if (string.IsNullOrEmpty(LastHistoryPreview))
{
LastHistoryPreview = plain;
}
}
LastHistoryJoined = string.Join(" | ", joined);
}
private static void RemeasureHistoryForWidth(float colW)

View file

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

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.12.24",
"description": "AFK Idle Spectator (I) + Lore (L). Fallen favorites filter.",
"version": "0.12.25",
"description": "AFK Idle Spectator (I) + Lore (L). Activity feed + activity-first idle scoring.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -34,6 +34,7 @@ REGRESSION_SCENARIOS=(
discovery
world_log
chronicle_smoke
activity_idle_smoke
)
scenario=""