diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md
index 76db354..8d2cc72 100644
--- a/.cursor/skills/idle-spectator-e2e/SKILL.md
+++ b/.cursor/skills/idle-spectator-e2e/SKILL.md
@@ -125,6 +125,7 @@ A loaded world is still required.
| Name | Purpose |
|------|---------|
+| `watch_reason_clarity` | Orange reason = event sentence (`is fighting`, `is seeking a lover`); no Signal gate; fill has empty reason |
| `critical_smoke` | PR gate: settings gate, idle health, tip match, retarget, manual exit |
| `settings_full` | Disable-while-active, show_watch_reasons |
| `ghost_guard` | Fake new-species tip must not steal focus onto wrong unit |
diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index eee9aa4..374cc99 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -3111,18 +3111,48 @@ public static class AgentHarness
float tierEvt = EventStrengthFromTierHint(cmd.tier, 70f);
InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.EventLed);
+ ParseInterestInjectOptions(
+ cmd.value,
+ out bool forceIgnored,
+ out InterestLeadKind? leadOpt,
+ out float eventStrength,
+ out float charSigIgnored,
+ out float maxWatchIgnored,
+ out float ttlIgnored,
+ out bool resumableIgnored,
+ out int participantsIgnored,
+ out int notablesIgnored);
+ _ = forceIgnored;
+ _ = charSigIgnored;
+ _ = maxWatchIgnored;
+ _ = ttlIgnored;
+ _ = resumableIgnored;
+ _ = participantsIgnored;
+ _ = notablesIgnored;
+ InterestLeadKind lead = leadOpt ?? tierLead;
+ if (eventStrength < 0f)
+ {
+ eventStrength = tierEvt;
+ }
+
string label = string.IsNullOrEmpty(cmd.label) ? ("Force " + (cmd.tier ?? "scene")) : cmd.label;
+ string focusName = SafeName(follow);
+ if (!string.IsNullOrEmpty(focusName))
+ {
+ label = label.Replace("{a}", focusName);
+ }
+
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "force_" + label.Replace(" ", "_") : cmd.expect;
InterestCandidate c = InterestFeeds.RegisterHarness(
follow,
label,
keySuffix,
- lead: tierLead,
+ lead: lead,
completion: InterestCompletionKind.Manual,
forceActive: true,
- eventStrength: tierEvt > 0f ? tierEvt : 70f,
+ eventStrength: eventStrength > 0f ? eventStrength : 70f,
maxWatch: 60f);
- if (c != null && tierLead == InterestLeadKind.CharacterLed)
+ if (c != null && lead == InterestLeadKind.CharacterLed)
{
c.Category = "CharacterVignette";
InterestScoring.ScoreCheap(c);
diff --git a/IdleSpectator/BoatEventPatches.cs b/IdleSpectator/BoatEventPatches.cs
index 788a58a..3c16a6d 100644
--- a/IdleSpectator/BoatEventPatches.cs
+++ b/IdleSpectator/BoatEventPatches.cs
@@ -1,6 +1,5 @@
using ai.behaviours;
using HarmonyLib;
-using UnityEngine;
namespace IdleSpectator;
@@ -17,7 +16,7 @@ public static class BoatEventPatches
return;
}
- EmitBoat("boat_unload", 72f, InterestOwnership.Signal, pActor, "Boat unloads: {a}");
+ EmitBoat("boat_unload", 72f, pActor, EventReason.Boat(pActor, "unload"));
}
[HarmonyPatch(typeof(BehBoatMakeTrade), nameof(BehBoatMakeTrade.execute))]
@@ -29,7 +28,7 @@ public static class BoatEventPatches
return;
}
- EmitBoat("boat_trade", 68f, InterestOwnership.Signal, pActor, "Boat trade: {a}");
+ EmitBoat("boat_trade", 68f, pActor, EventReason.Boat(pActor, "trade"));
}
[HarmonyPatch(typeof(BehBoatTransportDoLoading), nameof(BehBoatTransportDoLoading.execute))]
@@ -41,22 +40,16 @@ public static class BoatEventPatches
return;
}
- EmitBoat("boat_load", 55f, InterestOwnership.Ambient, pActor, "Boat loads: {a}");
+ EmitBoat("boat_load", 55f, pActor, EventReason.Boat(pActor, "load"));
}
- private static void EmitBoat(
- string eventId,
- float strength,
- InterestOwnership owns,
- Actor actor,
- string labelTemplate)
+ private static void EmitBoat(string eventId, float strength, Actor actor, string label)
{
if (AgentHarness.Busy)
{
return;
}
- string label = (labelTemplate ?? "{a}").Replace("{a}", EventFeedUtil.SafeName(actor));
string key = "boat:" + eventId + ":" + EventFeedUtil.SafeId(actor);
EventFeedUtil.Register(
key,
@@ -65,7 +58,7 @@ public static class BoatEventPatches
label,
eventId,
strength,
- owns,
+ InterestOwnership.Signal,
actor.current_position,
actor);
}
diff --git a/IdleSpectator/BookInterestCatalog.cs b/IdleSpectator/BookInterestCatalog.cs
index 5692ffd..589cf93 100644
--- a/IdleSpectator/BookInterestCatalog.cs
+++ b/IdleSpectator/BookInterestCatalog.cs
@@ -11,18 +11,18 @@ public static class BookInterestCatalog
static BookInterestCatalog()
{
- Add("family_story", 52f, "Culture", InterestOwnership.Ambient, "Family story: {a}");
- Add("love_story", 58f, "Culture", InterestOwnership.Ambient, "Love story: {a}");
- Add("friendship_story", 50f, "Culture", InterestOwnership.Ambient, "Friendship story: {a}");
- Add("bad_story_about_king", 62f, "Culture", InterestOwnership.Signal, "Scandalous book: {a}");
- Add("fable", 48f, "Culture", InterestOwnership.Ambient, "Fable: {a}");
- Add("warfare_manual", 60f, "Culture", InterestOwnership.Ambient, "Warfare manual: {a}");
- Add("economy_manual", 48f, "Culture", InterestOwnership.Ambient, "Economy manual: {a}");
- Add("stewardship_manual", 48f, "Culture", InterestOwnership.Ambient, "Stewardship manual: {a}");
- Add("diplomacy_manual", 55f, "Culture", InterestOwnership.Ambient, "Diplomacy manual: {a}");
- Add("mathbook", 45f, "Culture", InterestOwnership.Ambient, "Math book: {a}");
- Add("biology_book", 45f, "Culture", InterestOwnership.Ambient, "Biology book: {a}");
- Add("history_book", 55f, "Culture", InterestOwnership.Ambient, "History book: {a}");
+ Add("family_story", 52f, "Culture", InterestOwnership.Ambient, "{a} writes a family story");
+ Add("love_story", 58f, "Culture", InterestOwnership.Ambient, "{a} writes a love story");
+ Add("friendship_story", 50f, "Culture", InterestOwnership.Ambient, "{a} writes a friendship story");
+ Add("bad_story_about_king", 62f, "Culture", InterestOwnership.Signal, "{a} writes a scandalous book");
+ Add("fable", 48f, "Culture", InterestOwnership.Ambient, "{a} writes a fable");
+ Add("warfare_manual", 60f, "Culture", InterestOwnership.Ambient, "{a} writes a warfare manual");
+ Add("economy_manual", 48f, "Culture", InterestOwnership.Ambient, "{a} writes an economy manual");
+ Add("stewardship_manual", 48f, "Culture", InterestOwnership.Ambient, "{a} writes a stewardship manual");
+ Add("diplomacy_manual", 55f, "Culture", InterestOwnership.Ambient, "{a} writes a diplomacy manual");
+ Add("mathbook", 45f, "Culture", InterestOwnership.Ambient, "{a} writes a math book");
+ Add("biology_book", 45f, "Culture", InterestOwnership.Ambient, "{a} writes a biology book");
+ Add("history_book", 55f, "Culture", InterestOwnership.Ambient, "{a} writes a history book");
}
private static void Add(
@@ -62,7 +62,7 @@ public static class BookInterestCatalog
EventStrength = 45f,
Category = "Culture",
OwnsCamera = InterestOwnership.Ambient,
- LabelTemplate = "Book ({id}): {a}",
+ LabelTemplate = "{a} writes a book",
IsFallback = true
};
}
diff --git a/IdleSpectator/BookInterestFeed.cs b/IdleSpectator/BookInterestFeed.cs
index 20f68b5..dc262af 100644
--- a/IdleSpectator/BookInterestFeed.cs
+++ b/IdleSpectator/BookInterestFeed.cs
@@ -25,14 +25,13 @@ public static class BookInterestFeed
return;
}
- string label = entry.MakeLabel(EventFeedUtil.SafeName(follow), phase ?? "");
+ string label = EventReason.Apply(entry.LabelTemplate, follow, id: entry.Id);
if (!string.IsNullOrEmpty(phase) && phase == "burn")
{
- label = "Book burned: " + EventFeedUtil.SafeName(follow);
+ label = EventFeedUtil.SafeName(follow) + " burns a book";
}
string key = "book:" + entry.Id + ":" + (phase ?? "new") + ":" + EventFeedUtil.SafeId(follow);
- InterestOwnership owns = phase == "burn" ? InterestOwnership.Signal : entry.OwnsCamera;
float strength = phase == "burn" ? Mathf.Max(entry.EventStrength, 70f) : entry.EventStrength;
EventFeedUtil.Register(
key,
@@ -41,7 +40,7 @@ public static class BookInterestFeed
label,
entry.Id,
strength,
- owns,
+ InterestOwnership.Signal,
follow.current_position,
follow);
}
diff --git a/IdleSpectator/BuildingEventPatches.cs b/IdleSpectator/BuildingEventPatches.cs
index de2f999..95dbe52 100644
--- a/IdleSpectator/BuildingEventPatches.cs
+++ b/IdleSpectator/BuildingEventPatches.cs
@@ -4,7 +4,7 @@ using UnityEngine;
namespace IdleSpectator;
-/// Building damage/destroy interest patches from mutation discovery.
+/// Building damage/destroy/eat interest patches from mutation discovery.
[HarmonyPatch]
public static class BuildingEventPatches
{
@@ -17,7 +17,18 @@ public static class BuildingEventPatches
return;
}
- Emit("building_damage", 70f, InterestOwnership.Signal, pActor, "Damages a building: {a}");
+ Building target = null;
+ try
+ {
+ target = pActor.beh_building_target;
+ }
+ catch
+ {
+ target = null;
+ }
+
+ string label = EventReason.BuildingDamage(pActor, target);
+ Emit("building_damage", 70f, pActor, label);
}
[HarmonyPatch(typeof(BehConsumeTargetBuilding), nameof(BehConsumeTargetBuilding.execute))]
@@ -29,7 +40,18 @@ public static class BuildingEventPatches
return;
}
- Emit("building_consume", 78f, InterestOwnership.Signal, pActor, "Consumes a building: {a}");
+ Building food = null;
+ try
+ {
+ food = pActor.beh_building_target;
+ }
+ catch
+ {
+ food = null;
+ }
+
+ string label = EventReason.BuildingEat(pActor, food);
+ Emit("building_consume", 78f, pActor, label);
}
[HarmonyPatch(typeof(Building), "startDestroyBuilding")]
@@ -52,35 +74,35 @@ public static class BuildingEventPatches
}
string buildingName = "";
- object asset = __instance.GetType().GetField("asset")?.GetValue(__instance)
- ?? __instance.GetType().GetProperty("asset")?.GetValue(__instance, null);
- if (asset != null)
+ try
{
- object id = asset.GetType().GetField("id")?.GetValue(asset)
- ?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
- buildingName = id?.ToString() ?? "";
+ if (__instance.asset != null)
+ {
+ buildingName = __instance.asset.id ?? "";
+ }
+ }
+ catch
+ {
+ buildingName = "";
}
Actor near = pos != Vector3.zero
? EventFeedUtil.NearestUnit(pos, 48f)
: null;
+ string label = EventReason.BuildingFalls(buildingName, near);
if (near == null)
{
- // Location-only building fall - no stranger subject.
if (pos == Vector3.zero)
{
return;
}
- string locLabel = string.IsNullOrEmpty(buildingName)
- ? "Building destroyed"
- : "Building falls: " + buildingName;
string locKey = "building:destroy:" + buildingName + ":loc";
EventFeedUtil.Register(
locKey,
"building",
"Spectacle",
- locLabel,
+ label,
string.IsNullOrEmpty(buildingName) ? "building_destroy" : buildingName,
88f,
InterestOwnership.Signal,
@@ -90,9 +112,6 @@ public static class BuildingEventPatches
return;
}
- string label = string.IsNullOrEmpty(buildingName)
- ? "Building destroyed near " + EventFeedUtil.SafeName(near)
- : "Building falls: " + buildingName;
string key = "building:destroy:" + buildingName + ":" + EventFeedUtil.SafeId(near);
EventFeedUtil.Register(
key,
@@ -111,19 +130,13 @@ public static class BuildingEventPatches
}
}
- private static void Emit(
- string eventId,
- float strength,
- InterestOwnership owns,
- Actor actor,
- string labelTemplate)
+ private static void Emit(string eventId, float strength, Actor actor, string label)
{
if (AgentHarness.Busy)
{
return;
}
- string label = (labelTemplate ?? "{a}").Replace("{a}", EventFeedUtil.SafeName(actor));
string key = "building:" + eventId + ":" + EventFeedUtil.SafeId(actor);
EventFeedUtil.Register(
key,
@@ -132,7 +145,7 @@ public static class BuildingEventPatches
label,
eventId,
strength,
- owns,
+ InterestOwnership.Signal,
actor.current_position,
actor);
}
diff --git a/IdleSpectator/DeferredEventPatches.cs b/IdleSpectator/DeferredEventPatches.cs
index 6ee4b34..8f4b9dc 100644
--- a/IdleSpectator/DeferredEventPatches.cs
+++ b/IdleSpectator/DeferredEventPatches.cs
@@ -82,8 +82,8 @@ public static class DeferredEventPatches
}
DiscreteEventEntry entry = DecisionInterestCatalog.GetOrFallback(id);
- // Only emit Signal decisions from this high-frequency hook; Ambient would flood.
- if (entry.OwnsCamera != InterestOwnership.Signal)
+ // High-frequency hook: only emit stronger decisions (strength dial, not Signal gate).
+ if (entry.EventStrength < InterestScoringConfig.W.noticeScoreMin)
{
return;
}
diff --git a/IdleSpectator/DeferredInterestFeed.cs b/IdleSpectator/DeferredInterestFeed.cs
index b4dae4a..c9f7b02 100644
--- a/IdleSpectator/DeferredInterestFeed.cs
+++ b/IdleSpectator/DeferredInterestFeed.cs
@@ -61,10 +61,10 @@ public static class DeferredInterestFeed
"power:" + entry.Id,
"power",
entry.Category,
- entry.MakeLabel("", ""),
+ EventReason.HumanizeId(entry.Id),
entry.Id,
entry.EventStrength,
- entry.OwnsCamera,
+ InterestOwnership.Signal,
position,
follow: null,
locationOnly: true);
@@ -120,10 +120,10 @@ public static class DeferredInterestFeed
"worldlaw:" + entry.Id,
"worldlaw",
entry.Category,
- entry.MakeLabel("", ""),
+ EventReason.HumanizeId(entry.Id),
entry.Id,
entry.EventStrength,
- entry.OwnsCamera,
+ InterestOwnership.Signal,
position,
follow: null,
locationOnly: true);
@@ -146,10 +146,10 @@ public static class DeferredInterestFeed
"biome:" + entry.Id,
"biome",
entry.Category,
- entry.MakeLabel("", ""),
+ EventReason.HumanizeId(entry.Id),
entry.Id,
entry.EventStrength,
- entry.OwnsCamera,
+ InterestOwnership.Signal,
position,
follow: null,
locationOnly: true);
@@ -180,7 +180,12 @@ public static class DeferredInterestFeed
return;
}
- string label = entry.MakeLabel(EventFeedUtil.SafeName(follow), "");
+ string label = EventReason.Library(follow, source, entry.Id);
+ if (follow == null)
+ {
+ label = EventReason.HumanizeId(entry.Id);
+ }
+
string key = source + ":" + entry.Id + ":" + EventFeedUtil.SafeId(follow);
EventFeedUtil.Register(
key,
@@ -189,7 +194,7 @@ public static class DeferredInterestFeed
label,
entry.Id,
entry.EventStrength,
- entry.OwnsCamera,
+ InterestOwnership.Signal,
follow != null ? follow.current_position : Vector3.zero,
follow,
related: related,
diff --git a/IdleSpectator/EventFeedUtil.cs b/IdleSpectator/EventFeedUtil.cs
index 1f338a3..5bed2dc 100644
--- a/IdleSpectator/EventFeedUtil.cs
+++ b/IdleSpectator/EventFeedUtil.cs
@@ -2,18 +2,20 @@ using UnityEngine;
namespace IdleSpectator;
-/// Whether an interest candidate may steal the idle camera.
+///
+/// Legacy catalog tag. Ignored by -
+/// every registered event is EventLed and may own the camera; strength ranks.
+///
public enum InterestOwnership
{
- /// May cut in / own EventLed camera focus.
Signal,
- /// Tracked for fill/enrichment; fill-capped so it does not preempt Signal drama.
Ambient
}
///
/// Sole unit-led publish API for the idle director.
-/// Ownership rule: FollowUnit owns the reason; RelatedUnit required when the label claims a second party;
+/// Every registration is EventLed and may own the camera (score ranks).
+/// FollowUnit owns the reason; RelatedUnit required when the label claims a second party;
/// location-only civic events may omit FollowUnit but never invent a stranger subject.
///
public static class EventFeedUtil
@@ -34,6 +36,8 @@ public static class EventFeedUtil
float maxWatch = 22f,
InterestCompletionKind completion = InterestCompletionKind.FixedDwell)
{
+ // ownership retained for call-site compatibility; camera gate removed.
+ _ = ownership;
if (string.IsNullOrEmpty(key))
{
return null;
@@ -78,25 +82,20 @@ public static class EventFeedUtil
}
}
- bool ambient = ownership == InterestOwnership.Ambient;
float strength = eventStrength;
- if (ambient)
+ if (strength < 8f)
{
- strength = Mathf.Min(strength, InterestScoringConfig.W.fillScoreMax - 1f);
- if (strength < 8f)
- {
- strength = 8f;
- }
+ strength = 8f;
}
var candidate = new InterestCandidate
{
Key = key,
- LeadKind = ambient ? InterestLeadKind.CharacterLed : InterestLeadKind.EventLed,
+ LeadKind = InterestLeadKind.EventLed,
Category = string.IsNullOrEmpty(category) ? "Event" : category,
Source = source ?? "event",
EventStrength = strength,
- CharacterSignificance = ambient ? strength * 0.5f : 0f,
+ CharacterSignificance = 0f,
VisualConfidence = subject != null ? 0.7f : 0.35f,
Position = pos,
FollowUnit = subject,
@@ -108,7 +107,7 @@ public static class EventFeedUtil
SpeciesId = subject?.asset != null ? subject.asset.id : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
- ExpiresAt = Time.unscaledTime + (ambient ? 18f : 35f),
+ ExpiresAt = Time.unscaledTime + 35f,
MinWatch = minWatch,
MaxWatch = maxWatch,
Completion = completion
diff --git a/IdleSpectator/EventReason.cs b/IdleSpectator/EventReason.cs
new file mode 100644
index 0000000..ab6e7aa
--- /dev/null
+++ b/IdleSpectator/EventReason.cs
@@ -0,0 +1,363 @@
+using System;
+using System.Globalization;
+using System.Text;
+
+namespace IdleSpectator;
+
+///
+/// Sole factory for interest Labels shown as the orange dossier reason.
+/// Feeds must call these helpers (or Apply) instead of concatenating crumbs.
+///
+public static class EventReason
+{
+ public static string Fight(Actor a, Actor b)
+ {
+ string name = Name(a);
+ if (string.IsNullOrEmpty(name))
+ {
+ name = "Someone";
+ }
+
+ string foe = Name(b);
+ if (string.IsNullOrEmpty(foe))
+ {
+ return name + " is fighting";
+ }
+
+ return name + " is fighting " + foe;
+ }
+
+ public static string Battle(Actor focus, int fighters)
+ {
+ string name = Name(focus);
+ if (string.IsNullOrEmpty(name))
+ {
+ name = "Someone";
+ }
+
+ int n = Math.Max(1, fighters);
+ if (n <= 2)
+ {
+ return name + " is in a duel";
+ }
+
+ return name + " is in a fight of " + n.ToString(CultureInfo.InvariantCulture);
+ }
+
+ public static string SeekingLover(Actor a)
+ {
+ return NameOrSomeone(a) + " is seeking a lover";
+ }
+
+ public static string Lovers(Actor a, Actor b)
+ {
+ string an = NameOrSomeone(a);
+ string bn = Name(b);
+ if (string.IsNullOrEmpty(bn))
+ {
+ return an + " found a lover";
+ }
+
+ return an + " became lovers with " + bn;
+ }
+
+ public static string LoverParted(Actor a)
+ {
+ return NameOrSomeone(a) + " parted from a lover";
+ }
+
+ public static string FamilyPack(Actor a, Actor peer, string phase)
+ {
+ string an = NameOrSomeone(a);
+ string bn = Name(peer);
+ string p = (phase ?? "").Trim().ToLowerInvariant();
+ switch (p)
+ {
+ case "join":
+ case "family_group_join":
+ return string.IsNullOrEmpty(bn)
+ ? an + " joins a family pack"
+ : an + " joins a family pack with " + bn;
+ case "leave":
+ case "family_group_leave":
+ return string.IsNullOrEmpty(bn)
+ ? an + " leaves a family pack"
+ : an + " leaves a family pack with " + bn;
+ case "form":
+ case "new":
+ case "family_group_new":
+ return string.IsNullOrEmpty(bn)
+ ? an + " forms a family pack"
+ : an + " forms a family pack with " + bn;
+ default:
+ return an + " gathers with kin";
+ }
+ }
+
+ public static string NewChild(Actor a, Actor b)
+ {
+ string an = NameOrSomeone(a);
+ string bn = Name(b);
+ if (string.IsNullOrEmpty(bn))
+ {
+ return an + " has a new child";
+ }
+
+ return an + " and " + bn + " have a new child";
+ }
+
+ public static string BabyBorn(Actor a)
+ {
+ return NameOrSomeone(a) + " welcomes a baby";
+ }
+
+ public static string NewFamily(Actor a)
+ {
+ return NameOrSomeone(a) + " starts a new family";
+ }
+
+ public static string FamilyEnded(Actor a)
+ {
+ return NameOrSomeone(a) + "'s family ends";
+ }
+
+ public static string BuildingEat(Actor a, Building food)
+ {
+ string an = NameOrSomeone(a);
+ string buildingId = BuildingId(food);
+ string buildingType = BuildingType(food);
+
+ if (string.Equals(buildingType, "type_fruits", StringComparison.OrdinalIgnoreCase))
+ {
+ return string.IsNullOrEmpty(buildingId)
+ ? an + " is foraging fruit"
+ : an + " is foraging " + HumanizeId(buildingId);
+ }
+
+ if (!string.IsNullOrEmpty(buildingId))
+ {
+ return an + " is eating a " + HumanizeId(buildingId);
+ }
+
+ return an + " is eating";
+ }
+
+ public static string BuildingDamage(Actor a, Building building)
+ {
+ string an = NameOrSomeone(a);
+ string buildingId = BuildingId(building);
+ if (!string.IsNullOrEmpty(buildingId))
+ {
+ return an + " is damaging a " + HumanizeId(buildingId);
+ }
+
+ return an + " is damaging a building";
+ }
+
+ public static string BuildingFalls(string buildingId, Actor near)
+ {
+ string id = HumanizeId(buildingId);
+ if (near != null && near.isAlive())
+ {
+ string n = Name(near);
+ if (!string.IsNullOrEmpty(n))
+ {
+ return string.IsNullOrEmpty(id)
+ ? "A building falls near " + n
+ : "A " + id + " falls near " + n;
+ }
+ }
+
+ return string.IsNullOrEmpty(id) ? "A building falls" : "A " + id + " falls";
+ }
+
+ public static string Status(Actor a, string phrase)
+ {
+ string an = NameOrSomeone(a);
+ string p = (phrase ?? "").Trim();
+ if (string.IsNullOrEmpty(p))
+ {
+ return an + " changes status";
+ }
+
+ // Prose often already includes a verb phrase ("is burning").
+ if (p.StartsWith("is ", StringComparison.OrdinalIgnoreCase)
+ || p.StartsWith("are ", StringComparison.OrdinalIgnoreCase))
+ {
+ return an + " " + p;
+ }
+
+ return an + " · " + p;
+ }
+
+ public static string Trait(Actor a, string traitId, bool gained)
+ {
+ string an = NameOrSomeone(a);
+ string id = HumanizeId(traitId);
+ if (string.IsNullOrEmpty(id))
+ {
+ id = "a trait";
+ }
+
+ return gained ? an + " gains " + id : an + " loses " + id;
+ }
+
+ public static string Boat(Actor a, string phase)
+ {
+ string an = NameOrSomeone(a);
+ switch ((phase ?? "").Trim().ToLowerInvariant())
+ {
+ case "unload":
+ case "boat_unload":
+ return an + " is unloading passengers";
+ case "load":
+ case "boat_load":
+ return an + " is loading passengers";
+ case "trade":
+ case "boat_trade":
+ return an + " is trading by boat";
+ default:
+ return an + " is on a boat";
+ }
+ }
+
+ public static string MetaNew(Actor a, string kind)
+ {
+ string an = NameOrSomeone(a);
+ string k = HumanizeId(kind);
+ if (string.IsNullOrEmpty(k))
+ {
+ k = "something new";
+ }
+
+ return an + " founds a " + k;
+ }
+
+ public static string Library(Actor a, string kind, string assetId)
+ {
+ string an = NameOrSomeone(a);
+ string id = HumanizeId(assetId);
+ string k = HumanizeId(kind);
+ if (string.IsNullOrEmpty(id))
+ {
+ return an + " triggers " + (string.IsNullOrEmpty(k) ? "an event" : k);
+ }
+
+ switch ((kind ?? "").Trim().ToLowerInvariant())
+ {
+ case "spell":
+ return an + " casts " + id;
+ case "item":
+ return an + " forges " + id;
+ case "decision":
+ return an + " decides " + id;
+ case "gene":
+ return an + " gains gene " + id;
+ case "phenotype":
+ return an + " shows phenotype " + id;
+ case "power":
+ return an + " channels " + id;
+ case "subspecies_trait":
+ return an + " gains " + id;
+ default:
+ return an + " · " + id;
+ }
+ }
+
+ ///
+ /// Apply a sentence template with {a}/{b}/{id}. Prefer typed helpers for new copy.
+ ///
+ public static string Apply(string template, Actor a, Actor b = null, string id = "")
+ {
+ string t = string.IsNullOrEmpty(template) ? "{a}" : template;
+ return t
+ .Replace("{id}", id ?? "")
+ .Replace("{a}", Name(a) ?? "")
+ .Replace("{b}", Name(b) ?? "");
+ }
+
+ public static string HumanizeId(string id)
+ {
+ if (string.IsNullOrEmpty(id))
+ {
+ return "";
+ }
+
+ string raw = id.Trim().Replace('-', '_');
+ if (raw.StartsWith("type_", StringComparison.OrdinalIgnoreCase))
+ {
+ raw = raw.Substring(5);
+ }
+
+ string[] parts = raw.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length == 0)
+ {
+ return raw;
+ }
+
+ var sb = new StringBuilder(raw.Length + 4);
+ for (int i = 0; i < parts.Length; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append(' ');
+ }
+
+ string p = parts[i];
+ if (p.Length == 0)
+ {
+ continue;
+ }
+
+ sb.Append(char.ToUpperInvariant(p[0]));
+ if (p.Length > 1)
+ {
+ sb.Append(p.Substring(1).ToLowerInvariant());
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private static string NameOrSomeone(Actor a)
+ {
+ string n = Name(a);
+ return string.IsNullOrEmpty(n) ? "Someone" : n;
+ }
+
+ private static string Name(Actor a) => EventFeedUtil.SafeName(a);
+
+ private static string BuildingId(Building building)
+ {
+ if (building?.asset == null)
+ {
+ return "";
+ }
+
+ try
+ {
+ return building.asset.id ?? "";
+ }
+ catch
+ {
+ return "";
+ }
+ }
+
+ private static string BuildingType(Building building)
+ {
+ if (building?.asset == null)
+ {
+ return "";
+ }
+
+ try
+ {
+ return building.asset.type ?? "";
+ }
+ catch
+ {
+ return "";
+ }
+ }
+}
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 68e77fd..1d27f25 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -885,12 +885,12 @@ internal static class HarnessScenarios
Step("wr8", "focus", asset: "human"),
Step("wr9", "dossier_clear_job"),
- // Fighting label → story beat "Fighting" (no tier badge, no identity crumb).
- Step("wr10", "interest_force_session", asset: "human", label: "Fighting: Isemward", tier: "Action", expect: "fight_reason"),
- Step("wr11", "assert", expect: "dossier_contains", value: "Fighting"),
+ // EventReason sentence with subject name kept.
+ Step("wr10", "interest_force_session", asset: "human", label: "{a} is fighting Waaaf", tier: "Action", expect: "fight_reason"),
+ Step("wr11", "assert", expect: "dossier_contains", value: "is fighting"),
Step("wr12", "assert", expect: "dossier_not_contains", value: "Action ·"),
Step("wr12b", "assert", expect: "dossier_not_contains", value: "Live ·"),
- Step("wr13", "assert", expect: "tip_contains", value: "Fighting"),
+ Step("wr13", "assert", expect: "tip_contains", value: "is fighting"),
Step("wr14", "screenshot", value: "hud-watch-reason-fighting.png"),
Step("wr15", "wait", wait: 0.55f),
@@ -910,7 +910,7 @@ internal static class HarnessScenarios
Step("wr33", "screenshot", value: "hud-watch-reason-war.png"),
Step("wr34", "wait", wait: 0.55f),
- // Weak singleton crumb → catch-all or Last of its kind (never "Lone beetle" / tier badges).
+ // Weak singleton crumb → never "Lone beetle" / tier badges.
Step("wr40", "interest_force_session", asset: "human", label: "Lone beetle", tier: "Curiosity", expect: "lone_crumb"),
Step("wr41", "assert", expect: "dossier_not_contains", value: "Lone beetle"),
Step("wr42", "assert", expect: "dossier_not_contains", value: "Live ·"),
@@ -934,10 +934,18 @@ internal static class HarnessScenarios
Step("wr62", "assert", expect: "reason_owns_focus"),
Step("wr63", "assert", expect: "dossier_not_contains", value: "Cabo"),
Step("wr64", "interest_force_session", asset: "human",
- label: "Lovers: Alpha + Beta", tier: "Action", expect: "lovers_owned"),
+ label: "{a} became lovers with a partner", tier: "Action", expect: "lovers_owned"),
Step("wr65", "assert", expect: "reason_owns_focus"),
+ Step("wr65b", "assert", expect: "dossier_contains", value: "became lovers"),
Step("wr66", "wait", wait: 0.35f),
+ // Low-strength former-Ambient event still owns camera + shows sentence.
+ Step("wr67", "interest_force_session", asset: "human",
+ label: "{a} is seeking a lover", tier: "Action", expect: "seek_lover_low",
+ value: "lead=event;evt=45"),
+ Step("wr68", "assert", expect: "dossier_contains", value: "is seeking a lover"),
+ Step("wr69", "assert", expect: "reason_owns_focus"),
+
// Label-sourced clash (not inventing Clash from fighter counts).
Step("wr50", "interest_expire_pending", value: ""),
Step("wr51", "interest_inject", asset: "human", label: "Clash · 8 fighting", tier: "Action", expect: "mass_clash",
diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs
index 8b54b98..3eb5b2e 100644
--- a/IdleSpectator/InterestDirector.cs
+++ b/IdleSpectator/InterestDirector.cs
@@ -368,9 +368,8 @@ public static class InterestDirector
sinceSwitch = 0f;
}
- // Queued discovery tips beat ambient fill, but never stall under Action/Story.
- if (InterestRegistry.HasPendingScoreAtLeast(InterestScoringConfig.W.fillScoreMax)
- && (_current == null || InterestScoring.IsFillScore(_current.TotalScore)))
+ // Pending events own the camera - never Character-fill over them.
+ if (InterestRegistry.HasPendingEvent())
{
return;
}
@@ -815,6 +814,8 @@ public static class InterestDirector
candidate.LeadKind = InterestLeadKind.CharacterLed;
candidate.Category = "CharacterVignette";
candidate.Completion = InterestCompletionKind.CharacterVignette;
+ // Fill is not an event - empty orange reason; task chip shows live job.
+ candidate.Label = "";
if (AgentHarness.Busy)
{
candidate.ForceActive = false;
diff --git a/IdleSpectator/InterestFeeds.cs b/IdleSpectator/InterestFeeds.cs
index b0dff22..3a4772d 100644
--- a/IdleSpectator/InterestFeeds.cs
+++ b/IdleSpectator/InterestFeeds.cs
@@ -305,22 +305,42 @@ public static class InterestFeeds
return;
}
+ // Non-combat hot activity is not an event reason - task chip covers live job.
+ if (!actor.has_attack_target)
+ {
+ return;
+ }
+
long id = SafeId(actor);
- string verb = taskOrBeat ?? "activity";
+ Actor foe = null;
+ try
+ {
+ if (actor.attack_target != null && actor.attack_target.isAlive() && actor.attack_target.isActor())
+ {
+ foe = actor.attack_target.a;
+ }
+ }
+ catch
+ {
+ foe = null;
+ }
+
+ string verb = taskOrBeat ?? "fighting";
string key = "activity:" + id + ":" + verb;
- bool combat = actor.has_attack_target;
var candidate = new InterestCandidate
{
Key = key,
LeadKind = InterestLeadKind.EventLed,
- Category = combat ? "Combat" : "Work",
+ Category = "Combat",
Source = "activity",
- EventStrength = combat ? 75f : 50f,
+ EventStrength = 75f,
VisualConfidence = 0.8f,
Position = actor.current_position,
FollowUnit = actor,
+ RelatedUnit = foe,
SubjectId = id,
- Label = combat ? ("Fighting: " + SafeName(actor)) : (SafeName(actor) + " · " + verb),
+ RelatedId = foe != null ? SafeId(foe) : 0,
+ Label = EventReason.Fight(actor, foe),
AssetId = actor.asset != null ? actor.asset.id : "",
SpeciesId = actor.asset != null ? actor.asset.id : "",
Verb = verb,
@@ -331,7 +351,7 @@ public static class InterestFeeds
ExpiresAt = Time.unscaledTime + 20f,
MinWatch = 3f,
MaxWatch = 30f,
- Completion = combat ? InterestCompletionKind.CombatActive : InterestCompletionKind.ActivityActive
+ Completion = InterestCompletionKind.CombatActive
};
candidate.ParticipantIds.Add(id);
InterestScoring.ScoreCheap(candidate);
@@ -378,10 +398,10 @@ public static class InterestFeeds
key,
"status",
string.IsNullOrEmpty(entry.Category) ? "StatusTransformation" : entry.Category,
- SafeName(actor) + " · " + phrase,
+ EventReason.Status(actor, phrase),
statusId,
entry.EventStrength,
- entry.OwnsCamera,
+ InterestOwnership.Signal,
actor.current_position,
actor,
minWatch: 2f,
diff --git a/IdleSpectator/InterestRegistry.cs b/IdleSpectator/InterestRegistry.cs
index 0a74c06..0b500c7 100644
--- a/IdleSpectator/InterestRegistry.cs
+++ b/IdleSpectator/InterestRegistry.cs
@@ -324,6 +324,29 @@ public static class InterestRegistry
}
}
+ /// True when any unselected EventLed candidate is waiting for the camera.
+ public static bool HasPendingEvent()
+ {
+ lock (Gate)
+ {
+ foreach (KeyValuePair kv in ByKey)
+ {
+ InterestCandidate c = kv.Value;
+ if (c == null || c.Selected || !c.HasValidPosition)
+ {
+ continue;
+ }
+
+ if (c.LeadKind == InterestLeadKind.EventLed)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
private static void TrimLocked(float now)
{
if (ByKey.Count <= MaxCandidates)
diff --git a/IdleSpectator/MetaEventPatches.cs b/IdleSpectator/MetaEventPatches.cs
index efedfed..015e38e 100644
--- a/IdleSpectator/MetaEventPatches.cs
+++ b/IdleSpectator/MetaEventPatches.cs
@@ -17,85 +17,66 @@ public static class MetaEventPatches
[HarmonyPostfix]
public static void PostfixNewCulture(Culture __result)
{
- EmitNew("new_culture", "Culture", 68f, InterestOwnership.Signal, __result, "New culture: {a}");
+ EmitNew("new_culture", "Culture", 68f, __result, "culture");
}
[HarmonyPatch(typeof(ReligionManager), nameof(ReligionManager.newReligion))]
[HarmonyPostfix]
public static void PostfixNewReligion(Religion __result)
{
- EmitNew("new_religion", "Culture", 72f, InterestOwnership.Signal, __result, "New religion: {a}");
+ EmitNew("new_religion", "Culture", 72f, __result, "religion");
}
[HarmonyPatch(typeof(LanguageManager), nameof(LanguageManager.newLanguage))]
[HarmonyPostfix]
public static void PostfixNewLanguage(Language __result)
{
- EmitNew("new_language", "Culture", 60f, InterestOwnership.Ambient, __result, "New language: {a}");
+ EmitNew("new_language", "Culture", 60f, __result, "language");
}
[HarmonyPatch(typeof(ClanManager), nameof(ClanManager.newClan))]
[HarmonyPostfix]
public static void PostfixNewClan(Clan __result)
{
- EmitNew("new_clan", "Politics", 70f, InterestOwnership.Signal, __result, "New clan: {a}");
+ EmitNew("new_clan", "Politics", 70f, __result, "clan");
}
[HarmonyPatch(typeof(ArmyManager), nameof(ArmyManager.newArmy))]
[HarmonyPostfix]
public static void PostfixNewArmy(Army __result)
{
- EmitNew("new_army", "Politics", 66f, InterestOwnership.Ambient, __result, "New army: {a}");
+ EmitNew("new_army", "Politics", 66f, __result, "army");
}
[HarmonyPatch(typeof(AllianceManager), nameof(AllianceManager.newAlliance))]
[HarmonyPostfix]
public static void PostfixNewAlliance(Alliance __result)
{
- EmitNew("new_alliance", "Politics", 78f, InterestOwnership.Signal, __result, "New alliance: {a}");
+ EmitNew("new_alliance", "Politics", 78f, __result, "alliance");
}
[HarmonyPatch(typeof(CityManager), nameof(CityManager.newCity))]
[HarmonyPostfix]
public static void PostfixNewCity(City __result)
{
- EmitNew("new_city", "Politics", 74f, InterestOwnership.Signal, __result, "New city: {a}");
+ EmitNew("new_city", "Politics", 74f, __result, "city");
}
private static void EmitNew(
string eventId,
string category,
float strength,
- InterestOwnership owns,
object meta,
- string labelTemplate)
+ string kind)
{
Actor anchor = TryReadActor(meta);
- string name = EventFeedUtil.SafeName(anchor);
- if (string.IsNullOrEmpty(name) && meta != null)
- {
- try
- {
- name = meta.GetType().GetMethod("getName")?.Invoke(meta, null) as string
- ?? meta.GetType().GetProperty("name")?.GetValue(meta, null) as string
- ?? "";
- }
- catch
- {
- name = "";
- }
- }
-
if (anchor == null)
{
- // Meta without a living founder/leader: skip (no stranger subject).
return;
}
- string label = (labelTemplate ?? "{a}")
- .Replace("{a}", name ?? "")
- .Replace("{id}", eventId);
- MetaInterestFeed.EmitMeta(eventId, category, strength, owns, anchor, label);
+ string label = EventReason.MetaNew(anchor, kind);
+ MetaInterestFeed.EmitMeta(eventId, category, strength, InterestOwnership.Signal, anchor, label);
}
private static Actor TryReadActor(object meta)
diff --git a/IdleSpectator/PlotInterestCatalog.cs b/IdleSpectator/PlotInterestCatalog.cs
index 8e9c221..559269e 100644
--- a/IdleSpectator/PlotInterestCatalog.cs
+++ b/IdleSpectator/PlotInterestCatalog.cs
@@ -11,34 +11,34 @@ public static class PlotInterestCatalog
static PlotInterestCatalog()
{
- Add("rebellion", 92f, "Politics", InterestOwnership.Signal, "Rebellion plot: {a}");
- Add("new_war", 94f, "Politics", InterestOwnership.Signal, "War plot: {a}");
- Add("alliance_create", 80f, "Politics", InterestOwnership.Signal, "Alliance plot: {a}");
- Add("alliance_join", 72f, "Politics", InterestOwnership.Signal, "Join alliance: {a}");
- Add("alliance_destroy", 86f, "Politics", InterestOwnership.Signal, "Break alliance: {a}");
- Add("attacker_stop_war", 78f, "Politics", InterestOwnership.Signal, "End war plot: {a}");
- Add("new_book", 55f, "Culture", InterestOwnership.Ambient, "Book plot: {a}");
- Add("new_language", 62f, "Culture", InterestOwnership.Ambient, "Language plot: {a}");
- Add("new_religion", 70f, "Culture", InterestOwnership.Signal, "Religion plot: {a}");
- Add("new_culture", 68f, "Culture", InterestOwnership.Signal, "Culture plot: {a}");
- Add("clan_ascension", 82f, "Politics", InterestOwnership.Signal, "Clan ascension: {a}");
- Add("culture_divide", 75f, "Culture", InterestOwnership.Signal, "Culture divides: {a}");
- Add("religion_schism", 84f, "Culture", InterestOwnership.Signal, "Religion schism: {a}");
- Add("language_divergence", 60f, "Culture", InterestOwnership.Ambient, "Language splits: {a}");
- Add("summon_meteor_rain", 96f, "Spectacle", InterestOwnership.Signal, "Meteor plot: {a}");
- Add("summon_earthquake", 95f, "Spectacle", InterestOwnership.Signal, "Earthquake plot: {a}");
- Add("summon_thunderstorm", 93f, "Spectacle", InterestOwnership.Signal, "Thunderstorm plot: {a}");
- Add("summon_stormfront", 93f, "Spectacle", InterestOwnership.Signal, "Stormfront plot: {a}");
- Add("summon_hellstorm", 97f, "Spectacle", InterestOwnership.Signal, "Hellstorm plot: {a}");
- Add("summon_demons", 96f, "Spectacle", InterestOwnership.Signal, "Summon demons: {a}");
- Add("summon_angles", 96f, "Spectacle", InterestOwnership.Signal, "Summon angels: {a}");
- Add("summon_skeletons", 92f, "Spectacle", InterestOwnership.Signal, "Summon skeletons: {a}");
- Add("summon_living_plants", 90f, "Spectacle", InterestOwnership.Signal, "Summon plants: {a}");
- Add("big_cast_coffee", 58f, "Spectacle", InterestOwnership.Ambient, "Coffee ritual: {a}");
- Add("big_cast_bubble_shield", 64f, "Spectacle", InterestOwnership.Ambient, "Bubble shield: {a}");
- Add("big_cast_madness", 80f, "Spectacle", InterestOwnership.Signal, "Madness cast: {a}");
- Add("big_cast_slowness", 70f, "Spectacle", InterestOwnership.Ambient, "Slowness cast: {a}");
- Add("cause_rebellion", 90f, "Politics", InterestOwnership.Signal, "Cause rebellion: {a}");
+ Add("rebellion", 92f, "Politics", InterestOwnership.Signal, "{a} is plotting a rebellion");
+ Add("new_war", 94f, "Politics", InterestOwnership.Signal, "{a} is plotting a war");
+ Add("alliance_create", 80f, "Politics", InterestOwnership.Signal, "{a} is plotting an alliance");
+ Add("alliance_join", 72f, "Politics", InterestOwnership.Signal, "{a} is joining an alliance");
+ Add("alliance_destroy", 86f, "Politics", InterestOwnership.Signal, "{a} is breaking an alliance");
+ Add("attacker_stop_war", 78f, "Politics", InterestOwnership.Signal, "{a} is ending a war");
+ Add("new_book", 55f, "Culture", InterestOwnership.Ambient, "{a} is writing a book");
+ Add("new_language", 62f, "Culture", InterestOwnership.Ambient, "{a} is forging a language");
+ Add("new_religion", 70f, "Culture", InterestOwnership.Signal, "{a} is founding a religion");
+ Add("new_culture", 68f, "Culture", InterestOwnership.Signal, "{a} is founding a culture");
+ Add("clan_ascension", 82f, "Politics", InterestOwnership.Signal, "{a} is ascending a clan");
+ Add("culture_divide", 75f, "Culture", InterestOwnership.Signal, "{a} splits a culture");
+ Add("religion_schism", 84f, "Culture", InterestOwnership.Signal, "{a} causes a religion schism");
+ Add("language_divergence", 60f, "Culture", InterestOwnership.Ambient, "{a} splits a language");
+ Add("summon_meteor_rain", 96f, "Spectacle", InterestOwnership.Signal, "{a} summons a meteor rain");
+ Add("summon_earthquake", 95f, "Spectacle", InterestOwnership.Signal, "{a} summons an earthquake");
+ Add("summon_thunderstorm", 93f, "Spectacle", InterestOwnership.Signal, "{a} summons a thunderstorm");
+ Add("summon_stormfront", 93f, "Spectacle", InterestOwnership.Signal, "{a} summons a stormfront");
+ Add("summon_hellstorm", 97f, "Spectacle", InterestOwnership.Signal, "{a} summons a hellstorm");
+ Add("summon_demons", 96f, "Spectacle", InterestOwnership.Signal, "{a} summons demons");
+ Add("summon_angles", 96f, "Spectacle", InterestOwnership.Signal, "{a} summons angels");
+ Add("summon_skeletons", 92f, "Spectacle", InterestOwnership.Signal, "{a} summons skeletons");
+ Add("summon_living_plants", 90f, "Spectacle", InterestOwnership.Signal, "{a} summons living plants");
+ Add("big_cast_coffee", 58f, "Spectacle", InterestOwnership.Ambient, "{a} performs a coffee ritual");
+ Add("big_cast_bubble_shield", 64f, "Spectacle", InterestOwnership.Ambient, "{a} casts a bubble shield");
+ Add("big_cast_madness", 80f, "Spectacle", InterestOwnership.Signal, "{a} casts madness");
+ Add("big_cast_slowness", 70f, "Spectacle", InterestOwnership.Ambient, "{a} casts slowness");
+ Add("cause_rebellion", 90f, "Politics", InterestOwnership.Signal, "{a} causes a rebellion");
}
private static void Add(
@@ -78,7 +78,7 @@ public static class PlotInterestCatalog
EventStrength = 55f,
Category = "Plot",
OwnsCamera = InterestOwnership.Ambient,
- LabelTemplate = "Plot ({id}): {a}",
+ LabelTemplate = "{a} · {id}",
IsFallback = true
};
}
diff --git a/IdleSpectator/PlotInterestFeed.cs b/IdleSpectator/PlotInterestFeed.cs
index 1155452..ea964be 100644
--- a/IdleSpectator/PlotInterestFeed.cs
+++ b/IdleSpectator/PlotInterestFeed.cs
@@ -27,8 +27,7 @@ public static class PlotInterestFeed
return;
}
- string name = EventFeedUtil.SafeName(follow);
- string label = entry.MakeLabel(name, phase ?? "");
+ string label = EventReason.Apply(entry.LabelTemplate, follow, id: entry.Id);
string key = "plot:" + entry.Id + ":" + (phase ?? "active") + ":" + EventFeedUtil.SafeId(follow);
EventFeedUtil.Register(
key,
@@ -37,7 +36,7 @@ public static class PlotInterestFeed
label,
entry.Id,
entry.EventStrength,
- entry.OwnsCamera,
+ InterestOwnership.Signal,
follow.current_position,
follow);
}
diff --git a/IdleSpectator/RelationshipEventCatalog.cs b/IdleSpectator/RelationshipEventCatalog.cs
index 5d62762..ccf82d9 100644
--- a/IdleSpectator/RelationshipEventCatalog.cs
+++ b/IdleSpectator/RelationshipEventCatalog.cs
@@ -10,9 +10,14 @@ public sealed class DiscreteEventEntry
public string Category = "Event";
public InterestOwnership OwnsCamera = InterestOwnership.Signal;
public bool CreatesInterest = true;
- public string LabelTemplate = "{id}";
+ public string LabelTemplate = "{a}";
public bool IsFallback;
+ public string MakeLabel(Actor a, Actor b = null)
+ {
+ return EventReason.Apply(LabelTemplate, a, b, Id);
+ }
+
public string MakeLabel(string a, string b)
{
string template = string.IsNullOrEmpty(LabelTemplate) ? "{id}" : LabelTemplate;
@@ -31,31 +36,26 @@ public static class RelationshipEventCatalog
static RelationshipEventCatalog()
{
- Add("set_lover", 72f, "Relationship", InterestOwnership.Signal, "Lovers: {a} + {b}");
- Add("clear_lover", 55f, "Relationship", InterestOwnership.Signal, "Lover parted: {a}");
- Add("add_child", 68f, "Relationship", InterestOwnership.Signal, "New child: {a} + {b}");
- Add("new_family", 70f, "Relationship", InterestOwnership.Signal, "New family: {a}");
- Add("family_removed", 50f, "Relationship", InterestOwnership.Ambient, "Family ended: {a}");
- Add("find_lover", 45f, "Relationship", InterestOwnership.Ambient, "Seeking a lover: {a}");
- Add("family_group_new", 60f, "Relationship", InterestOwnership.Signal, "Family pack forms: {a}");
- Add("family_group_join", 48f, "Relationship", InterestOwnership.Ambient, "Joins family pack: {a}");
- Add("family_group_leave", 48f, "Relationship", InterestOwnership.Ambient, "Leaves family pack: {a}");
- Add("baby_created", 70f, "Relationship", InterestOwnership.Signal, "Baby born: {a}");
+ Add("set_lover", 72f, "Relationship", "{a} became lovers with {b}");
+ Add("clear_lover", 55f, "Relationship", "{a} parted from a lover");
+ Add("add_child", 68f, "Relationship", "{a} and {b} have a new child");
+ Add("new_family", 70f, "Relationship", "{a} starts a new family");
+ Add("family_removed", 50f, "Relationship", "{a}'s family ends");
+ Add("find_lover", 45f, "Relationship", "{a} is seeking a lover");
+ Add("family_group_new", 60f, "Relationship", "{a} forms a family pack");
+ Add("family_group_join", 48f, "Relationship", "{a} joins a family pack");
+ Add("family_group_leave", 48f, "Relationship", "{a} leaves a family pack");
+ Add("baby_created", 70f, "Relationship", "{a} welcomes a baby");
}
- private static void Add(
- string id,
- float strength,
- string category,
- InterestOwnership owns,
- string label)
+ private static void Add(string id, float strength, string category, string label)
{
Entries[id] = new DiscreteEventEntry
{
Id = id,
EventStrength = strength,
Category = category,
- OwnsCamera = owns,
+ OwnsCamera = InterestOwnership.Signal,
CreatesInterest = true,
LabelTemplate = label
};
@@ -79,8 +79,8 @@ public static class RelationshipEventCatalog
Id = key,
EventStrength = 40f,
Category = "Relationship",
- OwnsCamera = InterestOwnership.Ambient,
- LabelTemplate = "{id}: {a}",
+ OwnsCamera = InterestOwnership.Signal,
+ LabelTemplate = "{a} · {id}",
IsFallback = true
};
}
diff --git a/IdleSpectator/RelationshipInterestFeed.cs b/IdleSpectator/RelationshipInterestFeed.cs
index 6760793..a6a2044 100644
--- a/IdleSpectator/RelationshipInterestFeed.cs
+++ b/IdleSpectator/RelationshipInterestFeed.cs
@@ -20,38 +20,36 @@ public static class RelationshipInterestFeed
Actor relatedAlive = related != null && related.isAlive() ? related : null;
- // Pack / join labels claim a second party - resolve live related or drop the dishonest claim.
- bool claimsPack = eventId == "family_group_new"
+ bool claimsPeer = eventId == "family_group_new"
|| eventId == "family_group_join"
|| eventId == "family_group_leave"
|| eventId == "set_lover"
|| eventId == "add_child";
- if (claimsPack && relatedAlive == null)
+ if (claimsPeer && relatedAlive == null)
{
relatedAlive = ActorRelation.ResolvePackRelated(subject);
}
- if (claimsPack && relatedAlive == null)
+ string label = !string.IsNullOrEmpty(labelOverride)
+ ? labelOverride
+ : TypedLabel(eventId, subject, relatedAlive);
+
+ if (claimsPeer && relatedAlive == null)
{
- // Honest Ambient: subject only, no pack/lover claim in the label.
- string soloLabel = SoloLabel(eventId, EventFeedUtil.SafeName(subject));
string soloKey = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(subject) + ":solo";
EventFeedUtil.Register(
soloKey,
"relationship",
entry.Category,
- soloLabel,
+ label,
entry.Id,
Mathf.Min(entry.EventStrength, 40f),
- InterestOwnership.Ambient,
+ InterestOwnership.Signal,
subject.current_position,
subject);
return;
}
- string a = EventFeedUtil.SafeName(subject);
- string b = EventFeedUtil.SafeName(relatedAlive);
- string label = string.IsNullOrEmpty(labelOverride) ? entry.MakeLabel(a, b) : labelOverride;
string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(subject) + ":" + EventFeedUtil.SafeId(relatedAlive);
EventFeedUtil.Register(
key,
@@ -60,7 +58,7 @@ public static class RelationshipInterestFeed
label,
entry.Id,
entry.EventStrength,
- entry.OwnsCamera,
+ InterestOwnership.Signal,
subject.current_position,
subject,
related: relatedAlive);
@@ -82,58 +80,55 @@ public static class RelationshipInterestFeed
if (follow == null)
{
- // No owned member - skip rather than invent a stranger.
return;
}
Actor related = ActorRelation.FirstFamilyPeer(follow);
- string name = "";
- try
- {
- name = family?.GetType().GetMethod("getName")?.Invoke(family, null) as string
- ?? family?.GetType().GetProperty("name")?.GetValue(family, null) as string
- ?? "";
- }
- catch
- {
- // ignore
- }
-
- if (string.IsNullOrEmpty(name))
- {
- name = EventFeedUtil.SafeName(follow);
- }
-
- string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(follow) + ":" + (name ?? "family");
+ string label = TypedLabel(eventId, follow, related);
+ string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(follow);
EventFeedUtil.Register(
key,
"relationship",
entry.Category,
- entry.MakeLabel(name, EventFeedUtil.SafeName(related)),
+ label,
entry.Id,
entry.EventStrength,
- entry.OwnsCamera,
+ InterestOwnership.Signal,
follow.current_position,
follow,
related: related);
}
- private static string SoloLabel(string eventId, string name)
+ private static string TypedLabel(string eventId, Actor subject, Actor related)
{
switch (eventId)
{
- case "family_group_join":
- return "Gathers with kin: " + name;
- case "family_group_new":
- return "Starts a kin group: " + name;
- case "family_group_leave":
- return "Leaves kin: " + name;
case "set_lover":
- return "Seeking a bond: " + name;
+ return EventReason.Lovers(subject, related);
+ case "clear_lover":
+ return EventReason.LoverParted(subject);
+ case "find_lover":
+ return EventReason.SeekingLover(subject);
case "add_child":
- return "Family grows: " + name;
+ return EventReason.NewChild(subject, related);
+ case "baby_created":
+ return EventReason.BabyBorn(subject);
+ case "new_family":
+ return EventReason.NewFamily(subject);
+ case "family_removed":
+ return EventReason.FamilyEnded(subject);
+ case "family_group_new":
+ return EventReason.FamilyPack(subject, related, "form");
+ case "family_group_join":
+ return EventReason.FamilyPack(subject, related, "join");
+ case "family_group_leave":
+ return EventReason.FamilyPack(subject, related, "leave");
default:
- return name;
+ return EventReason.Apply(
+ RelationshipEventCatalog.GetOrFallback(eventId).LabelTemplate,
+ subject,
+ related,
+ eventId);
}
}
@@ -161,7 +156,6 @@ public static class RelationshipInterestFeed
// fall through
}
- // Direct units list on the family meta object.
try
{
object list = family.GetType().GetField("units")?.GetValue(family)
diff --git a/IdleSpectator/TraitEventPatches.cs b/IdleSpectator/TraitEventPatches.cs
index 0877d6e..77d2a93 100644
--- a/IdleSpectator/TraitEventPatches.cs
+++ b/IdleSpectator/TraitEventPatches.cs
@@ -1,5 +1,6 @@
using System;
using HarmonyLib;
+using UnityEngine;
namespace IdleSpectator;
@@ -98,14 +99,14 @@ public static class TraitEventPatches
return;
}
- InterestOwnership owns = entry.OwnsCamera;
- if (!gained && owns == InterestOwnership.Signal)
+ float strength = entry.EventStrength;
+ if (!gained)
{
- owns = InterestOwnership.Ambient;
+ strength = Mathf.Min(strength, 45f);
}
+ string label = EventReason.Trait(actor, entry.Id, gained);
string phase = gained ? "gains" : "loses";
- string label = EventFeedUtil.SafeName(actor) + " " + phase + " " + entry.Id;
string key = "trait:" + entry.Id + ":" + phase + ":" + EventFeedUtil.SafeId(actor);
EventFeedUtil.Register(
key,
@@ -113,8 +114,8 @@ public static class TraitEventPatches
entry.Category,
label,
entry.Id,
- entry.EventStrength,
- owns,
+ strength,
+ InterestOwnership.Signal,
actor.current_position,
actor);
}
diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs
index ce61bba..b223599 100644
--- a/IdleSpectator/UnitDossier.cs
+++ b/IdleSpectator/UnitDossier.cs
@@ -242,8 +242,9 @@ public sealed class UnitDossier
return "";
}
- // Character-fill ambient: hide reason (task chip already shows live activity).
- if (InterestScoring.IsFillScore(scene.TotalScore) && !InterestScoring.IsCombatAction(scene))
+ // Character-fill vignettes: hide reason (task chip shows live activity).
+ if (scene.LeadKind == InterestLeadKind.CharacterLed
+ && !InterestScoring.IsCombatAction(scene))
{
return "";
}
@@ -430,7 +431,7 @@ public sealed class UnitDossier
continue;
}
- if (reason.IndexOf(name, System.StringComparison.OrdinalIgnoreCase) >= 0)
+ if (ContainsWholeName(reason, name))
{
return true;
}
@@ -444,6 +445,37 @@ public sealed class UnitDossier
return false;
}
+ /// True when appears as a whole token in .
+ private static bool ContainsWholeName(string reason, string name)
+ {
+ if (string.IsNullOrEmpty(reason) || string.IsNullOrEmpty(name))
+ {
+ return false;
+ }
+
+ int start = 0;
+ while (start <= reason.Length - name.Length)
+ {
+ int idx = reason.IndexOf(name, start, System.StringComparison.OrdinalIgnoreCase);
+ if (idx < 0)
+ {
+ return false;
+ }
+
+ bool leftOk = idx == 0 || !char.IsLetterOrDigit(reason[idx - 1]);
+ int end = idx + name.Length;
+ bool rightOk = end >= reason.Length || !char.IsLetterOrDigit(reason[end]);
+ if (leftOk && rightOk)
+ {
+ return true;
+ }
+
+ start = idx + 1;
+ }
+
+ return false;
+ }
+
private static string EventLabelBeat(string label, UnitDossier d)
{
if (string.IsNullOrEmpty(label))
@@ -461,44 +493,13 @@ public sealed class UnitDossier
}
}
- // Keep war / fight story text with context (do not strip after first colon).
- if (s.StartsWith("War", System.StringComparison.OrdinalIgnoreCase)
- || s.StartsWith("Fighting", System.StringComparison.OrdinalIgnoreCase)
- || s.StartsWith("Battle", System.StringComparison.OrdinalIgnoreCase)
- || s.StartsWith("Clash", System.StringComparison.OrdinalIgnoreCase)
- || s.StartsWith("Grief", System.StringComparison.OrdinalIgnoreCase)
- || s.StartsWith("Mourn", System.StringComparison.OrdinalIgnoreCase)
- || s.StartsWith("Slain", System.StringComparison.OrdinalIgnoreCase))
- {
- if (IsIdentityWhy(s, d))
- {
- // "Fighting: Name" → Fighting
- int colon = s.IndexOf(':');
- if (colon > 0)
- {
- string head = s.Substring(0, colon).Trim();
- return string.IsNullOrEmpty(head) ? "Fighting" : head;
- }
-
- return "";
- }
-
- return CleanBeat(s);
- }
-
- string shortened = ShortenWatchLabel(s, d);
- if (string.IsNullOrEmpty(shortened) || IsIdentityWhy(shortened, d))
+ // EventReason sentences are dossier-ready - keep names; only drop identity crumbs.
+ if (IsIdentityWhy(s, d) || IsWeakFlavorBeat(s, d))
{
return "";
}
- // Reject weak singleton/species crumbs ("Lone beetle").
- if (IsWeakFlavorBeat(shortened, d))
- {
- return "";
- }
-
- return shortened;
+ return CleanBeat(s);
}
private static bool IsWeakFlavorBeat(string text, UnitDossier d)
@@ -566,6 +567,12 @@ public sealed class UnitDossier
&& t.IndexOf("mourn", System.StringComparison.OrdinalIgnoreCase) < 0
&& t.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) < 0)
{
+ // EventReason sentences lead with the subject name - keep them.
+ if (LooksLikeEventSentence(t))
+ {
+ return false;
+ }
+
// "Isemward (human, 12 kills)" style still useful via kills - keep if kills mentioned.
if (t.IndexOf("kill", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
@@ -578,6 +585,39 @@ public sealed class UnitDossier
return IsRedundantWithHeadline(t, d);
}
+ private static bool LooksLikeEventSentence(string text)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ return false;
+ }
+
+ string t = text;
+ return t.IndexOf(" is ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" became ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" joins ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" leaves ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" forms ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" gains ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" loses ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" writes ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" founds ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" summons ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" begins ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" ends ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" burns ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" welcomes ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" parted ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" decides ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" casts ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" forges ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" eating ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" foraging ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" damaging ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" plotting ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" lovers ", System.StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+
private static bool IsShownTraitName(UnitDossier d, string text)
{
if (d == null || string.IsNullOrEmpty(text))
diff --git a/IdleSpectator/WarInterestFeed.cs b/IdleSpectator/WarInterestFeed.cs
index 487ab37..e5b5cb2 100644
--- a/IdleSpectator/WarInterestFeed.cs
+++ b/IdleSpectator/WarInterestFeed.cs
@@ -95,6 +95,33 @@ public static class WarInterestFeed
{
label = "War ends: " + labelPair;
}
+ else if (!string.IsNullOrEmpty(labelPair) && !label.Contains(labelPair))
+ {
+ label = label + ": " + labelPair;
+ }
+
+ // Prefer sentence form when we have an owned follow unit.
+ if (follow != null)
+ {
+ if (phaseKey == "new")
+ {
+ label = EventFeedUtil.SafeName(follow) + " begins a war";
+ if (!string.IsNullOrEmpty(defenderName))
+ {
+ label = EventFeedUtil.SafeName(follow) + " begins a war against " + defenderName;
+ }
+ }
+ else if (phaseKey == "end")
+ {
+ label = EventFeedUtil.SafeName(follow) + " ends a war";
+ }
+ else
+ {
+ label = string.IsNullOrEmpty(defenderName)
+ ? EventFeedUtil.SafeName(follow) + " is at war"
+ : EventFeedUtil.SafeName(follow) + " is at war with " + defenderName;
+ }
+ }
float strength = entry.EventStrength;
if (phaseKey == "end")
diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs
index de125b6..f54d5e3 100644
--- a/IdleSpectator/WorldActivityScanner.cs
+++ b/IdleSpectator/WorldActivityScanner.cs
@@ -87,23 +87,11 @@ public static class WorldActivityScanner
for (int i = 0; i < n; i++)
{
Actor actor = alive[i];
- ActivityBand band = ActivityInterestTable.Classify(actor);
- InterestLeadKind lead = InterestLeadKind.CharacterLed;
- // Actions outrank characters: combat/hot work are events; kings walking stay vignettes.
- if (band == ActivityBand.Hot || actor.has_attack_target)
+ if (actor == null || !actor.isAlive() || !actor.has_attack_target)
{
- lead = InterestLeadKind.EventLed;
- }
- else if (band >= ActivityBand.Warm)
- {
- lead = InterestLeadKind.EventLed;
- }
- else if (IsSpectacle(actor) || IsSingletonSpecies(actor, speciesCounts))
- {
- lead = InterestLeadKind.CharacterLed;
+ continue;
}
- SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
long id = 0;
try
{
@@ -114,63 +102,56 @@ public static class WorldActivityScanner
continue;
}
- string key = lead == InterestLeadKind.CharacterLed
- ? "vignette:" + id + ":" + (actor.asset != null ? actor.asset.id : "unit")
- : "scan:evt:" + id;
-
- string label = FormatUnitLabel(actor, speciesCounts);
- if (actor.has_attack_target)
+ Actor foe = null;
+ try
{
- label = "Fighting: " + SafeName(actor);
+ if (actor.attack_target != null && actor.attack_target.isAlive() && actor.attack_target.isActor())
+ {
+ foe = actor.attack_target.a;
+ if (foe != null && !foe.isAlive())
+ {
+ foe = null;
+ }
+ }
}
- else if (band == ActivityBand.Hot)
+ catch
{
- label = "In action";
+ foe = null;
}
- bool combat = actor.has_attack_target || band == ActivityBand.Hot;
- int fighters = 1;
- int notables = InterestScoring.IsNotable(actor) ? 1 : 0;
- if (combat)
- {
- CountFightCluster(actor.current_position, 10f, out fighters, out notables, out _);
- fighters = Mathf.Max(1, fighters);
- }
+ SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
+ CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out _);
+ fighters = Mathf.Max(1, fighters);
+ string label = EventReason.Fight(actor, foe);
var candidate = new InterestCandidate
{
- Key = key,
- LeadKind = lead,
- Category = combat
- ? "Combat"
- : (lead == InterestLeadKind.CharacterLed ? "CharacterVignette" : "Work"),
+ Key = "scan:evt:" + id,
+ LeadKind = InterestLeadKind.EventLed,
+ Category = "Combat",
Source = "scanner",
- EventStrength = lead == InterestLeadKind.EventLed
- ? actionScore
- : actionScore * InterestScoringConfig.W.characterLedEventMul,
+ EventStrength = actionScore,
CharacterSignificance = charScore,
- VisualConfidence = band >= ActivityBand.Warm ? 0.7f : 0.4f,
+ VisualConfidence = 0.7f,
Position = actor.current_position,
FollowUnit = actor,
+ RelatedUnit = foe,
SubjectId = id,
+ RelatedId = foe != null ? EventFeedUtil.SafeId(foe) : 0,
Label = label,
- Verb = actor.has_attack_target ? "fighting" : (band == ActivityBand.Hot ? "busy" : ""),
+ Verb = "fighting",
AssetId = actor.asset != null ? actor.asset.id : "scored_unit",
SpeciesId = actor.asset != null ? actor.asset.id : "",
CityKey = actor.city != null ? (actor.city.name ?? "") : "",
KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "",
- ParticipantCount = combat ? fighters : 0,
- NotableParticipantCount = combat ? notables : 0,
+ ParticipantCount = fighters,
+ NotableParticipantCount = notables,
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 18f,
MinWatch = 2f,
MaxWatch = 22f,
- Completion = combat
- ? InterestCompletionKind.CombatActive
- : (lead == InterestLeadKind.EventLed
- ? InterestCompletionKind.ActivityActive
- : InterestCompletionKind.CharacterVignette)
+ Completion = InterestCompletionKind.CombatActive
};
InterestScoring.ScoreCheap(candidate);
EventFeedUtil.RegisterCandidate(candidate);
@@ -258,11 +239,7 @@ public static class WorldActivityScanner
if (score > bestScore)
{
bestScore = score;
- string label = fighters >= 5 || deaths >= 6
- ? $"Battle ({fighters} fighting, {deaths} fallen)"
- : fighters <= 2
- ? $"Duel ({deaths} fallen)"
- : $"Skirmish ({fighters} fighting)";
+ string label = EventReason.Battle(follow ?? FindNearestAliveUnit(pos, 14f), fighters);
best = new InterestEvent
{
Score = score,
@@ -322,12 +299,13 @@ public static class WorldActivityScanner
if (score > bestScore)
{
bestScore = score;
+ Actor focus = follow ?? actor;
best = new InterestEvent
{
Score = score,
Position = actor.current_position,
- FollowUnit = follow ?? actor,
- Label = fighters <= 2 ? "Duel" : $"Fight ({fighters})",
+ FollowUnit = focus,
+ Label = EventReason.Battle(focus, fighters),
CreatedAt = Time.unscaledTime,
AssetId = "live_battle",
ParticipantCount = fighters,
@@ -531,12 +509,31 @@ public static class WorldActivityScanner
finalScore = Mathf.Max(finalScore, actionScore + charScore * rankChar);
}
+ string label = "";
+ if (best.has_attack_target)
+ {
+ Actor foe = null;
+ try
+ {
+ if (best.attack_target != null && best.attack_target.isAlive() && best.attack_target.isActor())
+ {
+ foe = best.attack_target.a;
+ }
+ }
+ catch
+ {
+ foe = null;
+ }
+
+ label = EventReason.Fight(best, foe);
+ }
+
return new InterestEvent
{
Score = finalScore,
Position = best.current_position,
FollowUnit = best,
- Label = FormatUnitLabel(best, speciesCounts),
+ Label = label,
CreatedAt = Time.unscaledTime,
AssetId = best.asset != null ? best.asset.id : "scored_unit",
ParticipantCount = fighters,
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index aab4cf8..d9e022b 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.18.2",
+ "version": "0.19.2",
"description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.",
"GUID": "com.dazed.idlespectator"
}
diff --git a/README.md b/README.md
index 9c5f003..0fb8269 100644
--- a/README.md
+++ b/README.md
@@ -10,21 +10,20 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director.
## What it follows
-Priority (wiki-aligned):
+The camera follows **ranked events** from live feeds (wars, fights, relationships, buildings, statuses, plots, …).
+`EventStrength` decides who wins; quieter events still take the shot when nothing stronger is pending.
+Character fill only runs when the event queue is empty (no orange reason; task chip shows the live job).
-1. **Epic** - wars, kingdom fall/shatter, city destroyed, disasters
-2. **Story** - kings, clans, alliances, favorites, new kingdoms/cities
-3. **Action** - live battles/skirmishes (civ or wild), fighting units
-4. **Curiosity** - new subspecies (low priority; will not interrupt fights)
-5. **Ambient** - highest-scoring alive creature on the map (works on fresh animal-only worlds)
+While watching, a **dossier caption** shows who they are and an orange **event sentence** (why the camera is here), e.g. `Kaszus is fighting Waaaf`.
+See [`docs/event-reason.md`](docs/event-reason.md).
-While watching, a **dossier caption** shows who they are (species, kills, age, roles).
Lingering on someone writes a **Chronicle** line; F9 lists them and click jumps the camera.
## Layout
- `IdleSpectator/` - mod source (`mod.json` + C#), loaded/compiled by NML
- `scripts/verify-nml.sh` - load verification helper
+- `docs/event-reason.md` - events-own-camera + EventReason contract
## Deploy
diff --git a/docs/event-reason.md b/docs/event-reason.md
new file mode 100644
index 0000000..c0d8396
--- /dev/null
+++ b/docs/event-reason.md
@@ -0,0 +1,42 @@
+# Event reasons (camera + orange dossier)
+
+## Contract
+
+Registered **events** own the idle camera.
+`EventStrength` / `TotalScore` ranks who wins.
+Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only).
+
+Orange dossier reason = the owning event’s `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
+Quiet grace / inactive dwell clears the reason even if focus has not switched yet.
+
+## EventReason
+
+[`IdleSpectator/EventReason.cs`](../IdleSpectator/EventReason.cs) is the sole Label factory for camera candidates.
+Feeds call typed helpers (`Fight`, `BuildingEat`, `SeekingLover`, …) or `Apply(template, a, b, id)`.
+
+Rules:
+
+- Sentence form with names: `{a} is fighting {b}`, `{a} is eating a beehive`, `{a} is seeking a lover`.
+- Set `RelatedUnit` when the sentence names a second party.
+- Never invent stranger subjects for unit-led events.
+- Never use engine jargon that misstates the action (`Consumes a building` → eating/foraging via `BehConsumeTargetBuilding`).
+
+## Removed crumbs
+
+Do not author these as orange reasons:
+
+- `In action`
+- bare `Duel` / `Fight (N)` without a named sentence
+- identity titles (`King of X`, `Leader of X`, `Lone beetle`)
+- `Consumes a building` / `Damages a building: {a}` crumb form
+
+## Ownership enum
+
+`InterestOwnership` (Signal/Ambient) is retained on catalogs for compatibility but **ignored** by `EventFeedUtil.Register`.
+Every registration is EventLed and may own the camera.
+Strength is the dial (war ~94 beats seek-lover ~45).
+
+## Dossier display
+
+`UnitDossier.EventLabelBeat` keeps the authored sentence.
+It only rejects identity/weak crumbs and scrubs living stranger names (`ReasonNamesStranger` / `LeadsWithForeignName`).