Refactor event handling in IdleSpectator to improve reason clarity and ownership management. Update label templates across various event catalogs to provide more descriptive outputs. Enhance event registration logic to ensure accurate representation of actions and relationships. Increment version in mod.json to 0.19.2 to reflect these changes.

This commit is contained in:
DazedAnon 2026-07-15 23:03:57 -05:00
parent fd2283222c
commit 6c6a7cc286
26 changed files with 865 additions and 330 deletions

View file

@ -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 |

View file

@ -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);

View file

@ -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);
}

View file

@ -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
};
}

View file

@ -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);
}

View file

@ -4,7 +4,7 @@ using UnityEngine;
namespace IdleSpectator;
/// <summary>Building damage/destroy interest patches from mutation discovery.</summary>
/// <summary>Building damage/destroy/eat interest patches from mutation discovery.</summary>
[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);
}

View file

@ -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;
}

View file

@ -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,

View file

@ -2,18 +2,20 @@ using UnityEngine;
namespace IdleSpectator;
/// <summary>Whether an interest candidate may steal the idle camera.</summary>
/// <summary>
/// Legacy catalog tag. Ignored by <see cref="EventFeedUtil.Register"/> -
/// every registered event is EventLed and may own the camera; strength ranks.
/// </summary>
public enum InterestOwnership
{
/// <summary>May cut in / own EventLed camera focus.</summary>
Signal,
/// <summary>Tracked for fill/enrichment; fill-capped so it does not preempt Signal drama.</summary>
Ambient
}
/// <summary>
/// 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.
/// </summary>
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

View file

@ -0,0 +1,363 @@
using System;
using System.Globalization;
using System.Text;
namespace IdleSpectator;
/// <summary>
/// Sole factory for interest Labels shown as the orange dossier reason.
/// Feeds must call these helpers (or Apply) instead of concatenating crumbs.
/// </summary>
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;
}
}
/// <summary>
/// Apply a sentence template with {a}/{b}/{id}. Prefer typed helpers for new copy.
/// </summary>
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 "";
}
}
}

View file

@ -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",

View file

@ -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;

View file

@ -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,

View file

@ -324,6 +324,29 @@ public static class InterestRegistry
}
}
/// <summary>True when any unselected EventLed candidate is waiting for the camera.</summary>
public static bool HasPendingEvent()
{
lock (Gate)
{
foreach (KeyValuePair<string, InterestCandidate> 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)

View file

@ -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)

View file

@ -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
};
}

View file

@ -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);
}

View file

@ -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
};
}

View file

@ -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)

View file

@ -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);
}

View file

@ -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;
}
/// <summary>True when <paramref name="name"/> appears as a whole token in <paramref name="reason"/>.</summary>
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))

View file

@ -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")

View file

@ -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,

View file

@ -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"
}

View file

@ -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

42
docs/event-reason.md Normal file
View file

@ -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 events `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`).