Its a start
This commit is contained in:
parent
94de8a3b5b
commit
beb08265ec
12 changed files with 1489 additions and 98 deletions
276
IdleSpectator/ActivityInterestTable.cs
Normal file
276
IdleSpectator/ActivityInterestTable.cs
Normal 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 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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
295
IdleSpectator/ActivityLog.cs
Normal file
295
IdleSpectator/ActivityLog.cs
Normal 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--;
|
||||
}
|
||||
}
|
||||
}
|
||||
266
IdleSpectator/ActivityProse.cs
Normal file
266
IdleSpectator/ActivityProse.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ REGRESSION_SCENARIOS=(
|
|||
discovery
|
||||
world_log
|
||||
chronicle_smoke
|
||||
activity_idle_smoke
|
||||
)
|
||||
|
||||
scenario=""
|
||||
|
|
|
|||
Loading…
Reference in a new issue