Enhance ActivityLog, AgentHarness, and Chronicle with improved interest tracking and event handling. Introduce methods for logging activity milestones, managing happiness occurrences, and integrating interest feeds. Update related functionality to ensure robust event processing and synchronization. Add new harness scenarios for testing interest and happiness features. Increment version in mod.json to reflect these changes.

This commit is contained in:
DazedAnon 2026-07-15 15:48:31 -05:00
parent 892e645872
commit 79e1a93633
16 changed files with 2987 additions and 277 deletions

View file

@ -291,6 +291,24 @@ public static class ActivityLog
DisplayLine = display,
DisplayLineRich = displayRich
});
try
{
ActivityBand band = ActivityInterestTable.Classify(actor);
bool hot = band == ActivityBand.Hot
|| actor.has_attack_target
|| (taskId != null && (taskId.Contains("attack")
|| taskId.Contains("hunt")
|| taskId.Contains("fire")));
if (hot)
{
InterestFeeds.OnActivityNote(actor, taskId, hot: true);
}
}
catch
{
// interest feed must not break activity logging
}
}
/// <summary>
@ -584,6 +602,15 @@ public static class ActivityLog
{
// ignore
}
try
{
InterestFeeds.OnStatusChange(actor, statusId.Trim(), gained);
}
catch
{
// ignore
}
}
/// <summary>Harness / forced path for status lines without a live transition.</summary>
@ -692,11 +719,63 @@ public static class ActivityLog
return false;
}
HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(effectId);
string clause = ActivityHappinessProse.Clause(entry, relatedName, out bool usedRelated, out _);
string rich = usedRelated && !string.IsNullOrEmpty(relatedName)
? clause.Replace(relatedName, ActivityProse.ColorName(relatedName))
: clause;
Actor subject = null;
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{
try
{
if (actor != null && actor.getID() == subjectId)
{
subject = actor;
break;
}
}
catch
{
// continue
}
}
// Prefer the live router path so spectator DrainSince sees the occurrence.
if (subject != null && subject.isAlive())
{
HappinessOccurrence published = HappinessEventRouter.PublishApplied(
subject,
effectId.Trim(),
amount != 0 ? amount : HappinessGameApi.ResolveAmount(effectId),
HappinessSourceHook.Harness,
related: null,
role: HappinessRelationRole.None,
forceRing: true);
if (published != null && !string.IsNullOrEmpty(relatedName))
{
published.RelatedName = relatedName;
HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(effectId);
string clause = ActivityHappinessProse.Clause(entry, relatedName, out bool usedRelated, out _);
published.PlainClause = clause;
published.RichClause = usedRelated
? clause.Replace(relatedName, ActivityProse.ColorName(relatedName))
: clause;
}
if (published != null)
{
InterestFeeds.IngestForHarness(published);
string label = !string.IsNullOrEmpty(published.PlainClause)
? ((published.SubjectName ?? "") + " " + published.PlainClause).Trim()
: "";
InterestFeeds.RegisterHappinessSignal(subject, effectId.Trim(), label, relatedName);
}
return published != null;
}
HappinessCatalogEntry fallbackEntry = HappinessEventCatalog.GetOrFallback(effectId);
string fallbackClause = ActivityHappinessProse.Clause(
fallbackEntry, relatedName, out bool usedRel, out _);
string rich = usedRel && !string.IsNullOrEmpty(relatedName)
? fallbackClause.Replace(relatedName, ActivityProse.ColorName(relatedName))
: fallbackClause;
var occ = new HappinessOccurrence
{
EffectId = effectId.Trim(),
@ -704,14 +783,17 @@ public static class ActivityLog
SubjectId = subjectId,
SubjectName = string.IsNullOrEmpty(actorName) ? "Someone" : actorName,
RelatedName = relatedName ?? "",
PlainClause = clause,
PlainClause = fallbackClause,
RichClause = rich,
FactLine = "Happiness " + effectId.Trim(),
Presentation = entry.Presentation,
Presentation = fallbackEntry.Presentation,
InterestCategory = fallbackEntry.InterestCategory,
Applied = true,
SourceHook = HappinessSourceHook.Harness
};
NoteHappiness(occ);
HappinessEventRouter.RememberForHarness(occ);
InterestFeeds.IngestForHarness(occ);
return true;
}

View file

@ -814,6 +814,53 @@ public static class AgentHarness
}
bool ok = ActivityLog.ForceHappinessNote(id, effectId, actorName, related);
Actor registerUnit = focus;
if (registerUnit == null && InterestDirector.CurrentCandidate != null)
{
registerUnit = InterestDirector.CurrentCandidate.FollowUnit;
}
if (registerUnit == null && id != 0)
{
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{
try
{
if (actor != null && actor.getID() == id)
{
registerUnit = actor;
break;
}
}
catch
{
// continue
}
}
}
if (registerUnit == null && MoveCamera.hasFocusUnit())
{
registerUnit = MoveCamera._focus_unit;
}
if (ok && registerUnit != null)
{
string label = string.IsNullOrEmpty(related)
? (actorName + " · " + effectId)
: (actorName + " mourns " + related);
InterestCandidate registered = InterestFeeds.RegisterHappinessSignal(
registerUnit, effectId, label, related);
if (registered == null)
{
ok = false;
}
}
else if (ok)
{
ok = false;
}
WatchCaption.ForceRefreshHistory();
if (ok)
{
@ -824,7 +871,10 @@ public static class AgentHarness
_cmdFail++;
}
Emit(cmd, ok, detail: $"forced happiness={effectId} related={related} id={id}");
Emit(
cmd,
ok,
detail: $"forced happiness={effectId} related={related} id={id} unit={(registerUnit != null)} pending={InterestRegistry.PendingCount} count={InterestRegistry.Count}");
break;
}
@ -1736,6 +1786,14 @@ public static class AgentHarness
DoTriggerInterest(cmd);
break;
case "interest_inject":
DoInterestInject(cmd);
break;
case "interest_force_session":
DoInterestForceSession(cmd);
break;
case "set_setting":
DoSetSetting(cmd);
break;
@ -2099,9 +2157,121 @@ public static class AgentHarness
AssetId = follow.asset != null ? follow.asset.id : assetId
};
InterestCollector.EnqueueDirect(interest);
InterestTier tier = interest.Tier;
InterestCandidate registered = InterestFeeds.RegisterHarness(
follow,
tier,
interest.Label,
keySuffix: (interest.Label ?? tier.ToString()).Replace(" ", "_"),
lead: tier >= InterestTier.Action ? InterestLeadKind.EventLed : InterestLeadKind.CharacterLed,
completion: InterestCompletionKind.FixedDwell,
forceActive: tier >= InterestTier.Action,
eventStrength: 40f + (int)tier * 20f,
maxWatch: tier >= InterestTier.Epic ? 40f : 25f);
if (registered == null)
{
InterestCollector.EnqueueDirect(interest);
}
_cmdOk++;
Emit(cmd, ok: true, detail: $"queued tier={interest.Tier} label={interest.Label} pending={InterestCollector.PendingCount}");
Emit(cmd, ok: true, detail: $"queued tier={tier} label={interest.Label} pending={InterestCollector.PendingCount}");
}
private static void DoInterestInject(HarnessCommand cmd)
{
if (!WorldReady())
{
_cmdFail++;
Emit(cmd, ok: false, detail: "world_not_ready");
return;
}
Actor follow = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
if (follow == null)
{
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
}
if (follow == null)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no unit for interest_inject");
return;
}
InterestTier tier = ParseTier(cmd.tier, InterestTier.Action);
string label = string.IsNullOrEmpty(cmd.label) ? ("Inject " + tier) : cmd.label;
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? label.Replace(" ", "_") : cmd.expect;
bool force = ParseBool(cmd.value, defaultValue: false);
InterestCandidate c = InterestFeeds.RegisterHarness(
follow,
tier,
label,
keySuffix,
lead: tier >= InterestTier.Curiosity && tier < InterestTier.Action
? InterestLeadKind.CharacterLed
: InterestLeadKind.EventLed,
completion: force ? InterestCompletionKind.Manual : InterestCompletionKind.FixedDwell,
forceActive: force,
eventStrength: 50f + (int)tier * 15f,
maxWatch: 30f);
if (c == null)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "inject_failed");
return;
}
_cmdOk++;
Emit(cmd, ok: true, detail: $"injected key={c.Key} tier={c.Urgency} pending={InterestRegistry.PendingCount}");
}
private static void DoInterestForceSession(HarnessCommand cmd)
{
if (!WorldReady())
{
_cmdFail++;
Emit(cmd, ok: false, detail: "world_not_ready");
return;
}
Actor follow = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
if (follow == null)
{
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
}
if (follow == null)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no unit for interest_force_session");
return;
}
InterestTier tier = ParseTier(cmd.tier, InterestTier.Action);
string label = string.IsNullOrEmpty(cmd.label) ? ("Force " + tier) : cmd.label;
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "force_" + label.Replace(" ", "_") : cmd.expect;
InterestCandidate c = InterestFeeds.RegisterHarness(
follow,
tier,
label,
keySuffix,
lead: InterestLeadKind.EventLed,
completion: InterestCompletionKind.Manual,
forceActive: true,
eventStrength: 80f + (int)tier * 10f,
maxWatch: 60f);
bool ok = InterestDirector.HarnessForceSession(c);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: $"key={InterestDirector.CurrentKey} tier={InterestDirector.CurrentTierName} active={InterestDirector.CurrentIsActive}");
}
private static void DoSetSetting(HarnessCommand cmd)
@ -2331,6 +2501,84 @@ public static class AgentHarness
detail = $"interest_pending={have} want={want}";
break;
}
case "session_key":
case "interest_session_key":
{
string want = (cmd.value ?? cmd.expect ?? "").Trim();
string have = InterestDirector.CurrentKey ?? "";
pass = !string.IsNullOrEmpty(want)
&& have.IndexOf(want, System.StringComparison.OrdinalIgnoreCase) >= 0;
detail = $"key='{have}' contains='{want}'";
break;
}
case "session_active":
case "interest_session_active":
{
bool want = ParseBool(cmd.value, defaultValue: true);
bool have = InterestDirector.CurrentIsActive;
pass = have == want;
detail = $"active={have} want={want} key={InterestDirector.CurrentKey}";
break;
}
case "event_share":
case "interest_event_share":
{
float want = ParseFloat(cmd.value, 0.5f);
float have = InterestVariety.EventShare();
// Soft check: within 0.35 of target when enough samples exist.
pass = InterestVariety.MixSampleCount < 3 || Mathf.Abs(have - want) <= 0.35f;
detail = $"share={have:0.##} want~={want:0.##} samples={InterestVariety.MixSampleCount}";
break;
}
case "happiness_drain_seq":
{
ulong have = HappinessEventRouter.CurrentSequence;
pass = have >= 0;
detail = $"happiness_seq={have}";
break;
}
case "interest_has_key":
{
string want = (cmd.value ?? cmd.expect ?? "").Trim();
bool have = false;
string found = "";
if (!string.IsNullOrEmpty(want))
{
if ((InterestDirector.CurrentKey ?? "").IndexOf(want, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
have = true;
found = InterestDirector.CurrentKey;
}
else
{
var pending = new System.Collections.Generic.List<InterestCandidate>(64);
InterestRegistry.CopyPending(pending);
for (int i = 0; i < pending.Count; i++)
{
InterestCandidate c = pending[i];
if (c?.Key != null
&& c.Key.IndexOf(want, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
have = true;
found = c.Key;
break;
}
if (c?.HappinessEffectId != null
&& c.HappinessEffectId.IndexOf(want, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
have = true;
found = c.Key;
break;
}
}
}
}
pass = have;
detail = $"want='{want}' found='{found}' pending={InterestRegistry.PendingCount}";
break;
}
case "in_grace":
{
bool want = ParseBool(cmd.value, defaultValue: true);

View file

@ -1240,6 +1240,15 @@ public static class Chronicle
{
AppendHistory(ChronicleKind.Kill, killer, victim, line);
}
try
{
InterestFeeds.OnChronicleMilestone(killer, "milestone_kill", victim, line);
}
catch
{
// ignore
}
}
/// <summary>Victim POV - Chronicle Life when enabled; always mirrors to Activity.</summary>
@ -1309,6 +1318,15 @@ public static class Chronicle
AppendHistory(ChronicleKind.Lover, a, b, lineA);
AppendHistory(ChronicleKind.Lover, b, a, lineB);
}
try
{
InterestFeeds.OnChronicleMilestone(a, "milestone_lover", b, lineA);
}
catch
{
// ignore
}
}
public static void NoteBestFriends(Actor a, Actor b)
@ -1346,10 +1364,19 @@ public static class Chronicle
AppendHistory(ChronicleKind.Friend, a, b, lineA);
AppendHistory(ChronicleKind.Friend, b, a, lineB);
}
try
{
InterestFeeds.OnChronicleMilestone(a, "milestone_friend", b, lineA);
}
catch
{
// ignore
}
}
/// <summary>
/// Durable happiness Life projection (birth, grief, roles, housing, book/plot/injury).
/// Durable happiness Life projection (birth, grief, roles, housing, book/plot, injury).
/// Canonical lover/friend/kill milestones stay on their own paths.
/// </summary>
public static bool NoteHappinessLife(HappinessOccurrence occ, Actor subject, Actor related = null)

View file

@ -33,6 +33,8 @@ public static class HappinessEventRouter
private static int _aggregateSummariesSinceClear;
private static string _lastEffectId = "";
private static long _counterSubjectId;
private static ulong _nextSequence = 1;
private static Action<HappinessOccurrence> _subscribers;
private sealed class AggregateBucket
{
@ -51,6 +53,7 @@ public static class HappinessEventRouter
public static int SuppressedSinceClear => _suppressedSinceClear;
public static int AggregateSummariesSinceClear => _aggregateSummariesSinceClear;
public static string LastEffectId => _lastEffectId ?? "";
public static ulong CurrentSequence => _nextSequence > 0 ? _nextSequence - 1 : 0;
public static void ClearSession()
{
@ -66,6 +69,59 @@ public static class HappinessEventRouter
_aggregateSummariesSinceClear = 0;
_lastEffectId = "";
_counterSubjectId = 0;
_nextSequence = 1;
}
/// <summary>Push listener for spectator interest intake (in addition to <see cref="DrainSince"/>).</summary>
public static void Subscribe(Action<HappinessOccurrence> handler)
{
if (handler == null)
{
return;
}
_subscribers += handler;
}
public static void Unsubscribe(Action<HappinessOccurrence> handler)
{
if (handler == null)
{
return;
}
_subscribers -= handler;
}
/// <summary>
/// Drain occurrences with Sequence &gt; <paramref name="sinceSeq"/>.
/// Returns the highest sequence drained (pass back next call). Diagnostics may still use <see cref="Latest"/>.
/// </summary>
public static ulong DrainSince(ulong sinceSeq, List<HappinessOccurrence> destination)
{
if (destination == null)
{
return sinceSeq;
}
destination.Clear();
ulong max = sinceSeq;
for (int i = 0; i < Recent.Count; i++)
{
HappinessOccurrence occ = Recent[i];
if (occ == null || occ.Sequence <= sinceSeq)
{
continue;
}
destination.Add(occ);
if (occ.Sequence > max)
{
max = occ.Sequence;
}
}
return max;
}
public static void ResetCountersForSubject(long subjectId)
@ -609,11 +665,37 @@ public static class HappinessEventRouter
return;
}
occ.Sequence = _nextSequence++;
Recent.Add(occ);
while (Recent.Count > MaxRecentOccurrences)
{
Recent.RemoveAt(0);
}
Action<HappinessOccurrence> handlers = _subscribers;
if (handlers != null)
{
try
{
handlers(occ);
}
catch
{
// Interest consumers must not break happiness logging.
}
}
}
/// <summary>Harness-only: assign sequence and remember an already-built occurrence.</summary>
public static void RememberForHarness(HappinessOccurrence occ)
{
if (occ == null)
{
return;
}
occ.Applied = true;
Remember(occ);
}
private static bool IsNotable(Actor actor)

View file

@ -76,6 +76,8 @@ public sealed class HappinessCatalogEntry
/// <summary>Normalized happiness/emotion occurrence for Activity, Life, and future spectator interest.</summary>
public sealed class HappinessOccurrence
{
/// <summary>Monotonic id for <see cref="HappinessEventRouter.DrainSince"/>.</summary>
public ulong Sequence;
public string EffectId = "";
public int Amount;
public long SubjectId;

View file

@ -28,6 +28,10 @@ internal static class HarnessScenarios
return InputExit();
case "director_tiers":
return DirectorTiers();
case "director_lifecycle":
return DirectorLifecycle();
case "interest_happiness":
return InterestHappiness();
case "discovery":
return Discovery();
case "world_log":
@ -124,6 +128,8 @@ internal static class HarnessScenarios
Nested("reg_retarget", "retarget_stability"),
Nested("reg_input", "input_exit"),
Nested("reg_director", "director_tiers"),
Nested("reg_director_life", "director_lifecycle"),
Nested("reg_interest_happiness", "interest_happiness"),
Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
Nested("reg_chronicle", "chronicle_smoke"),
@ -592,6 +598,82 @@ internal static class HarnessScenarios
};
}
private static List<HarnessCommand> DirectorLifecycle()
{
return new List<HarnessCommand>
{
Step("dl0", "dismiss_windows"),
Step("dl1", "wait_world"),
Step("dl2", "set_setting", expect: "enabled", value: "true"),
Step("dl3", "fast_timing", value: "true"),
Step("dl4", "spawn", asset: "sheep"),
Step("dl5", "spectator", value: "off"),
Step("dl6", "spectator", value: "on"),
Step("dl7", "focus", asset: "sheep"),
// Protected Action: Story waits.
Step("dl10", "interest_force_session", asset: "sheep", label: "HoldAction", tier: "Action", expect: "hold_action"),
Step("dl11", "assert", expect: "current_tier", value: "Action"),
Step("dl12", "assert", expect: "session_active", value: "true"),
Step("dl13", "trigger_interest", asset: "sheep", label: "StoryWait", tier: "Story"),
Step("dl14", "age_current", wait: 1.2f),
Step("dl15", "director_run", wait: 1.2f),
Step("dl16", "assert", expect: "current_tier", value: "Action"),
Step("dl17", "assert", expect: "tip_contains", value: "HoldAction"),
// Epic preempts Action after settle.
Step("dl20", "trigger_interest", asset: "sheep", label: "EpicWin", tier: "Epic"),
Step("dl21", "age_current", wait: 1.2f),
Step("dl22", "director_run", wait: 1.2f),
Step("dl23", "assert", expect: "current_tier", value: "Epic"),
Step("dl24", "assert", expect: "tip_contains", value: "EpicWin"),
Step("dl25", "assert", expect: "no_bad"),
Step("dl90", "fast_timing", value: "false"),
Step("dl99", "snapshot"),
};
}
private static List<HarnessCommand> InterestHappiness()
{
return new List<HarnessCommand>
{
Step("ih0", "dismiss_windows"),
Step("ih1", "wait_world"),
Step("ih2", "set_setting", expect: "enabled", value: "true"),
Step("ih3", "fast_timing", value: "true"),
Step("ih4", "spawn", asset: "human"),
Step("ih5", "spectator", value: "off"),
Step("ih6", "spectator", value: "on"),
Step("ih7", "focus", asset: "auto"),
Step("ih8", "happiness_reset"),
// Force Action hold; ambient happiness must not steal.
Step("ih10", "interest_force_session", asset: "auto", label: "ActionHold", tier: "Action", expect: "action_hold"),
Step("ih11", "happiness_apply", value: "just_ate"),
Step("ih12", "age_current", wait: 1.2f),
Step("ih13", "director_run", wait: 1.2f),
Step("ih14", "assert", expect: "current_tier", value: "Action"),
Step("ih15", "assert", expect: "tip_contains", value: "ActionHold"),
// Grief Signal registers into registry (force-note + direct register).
Step("ih20", "happiness_force_note", value: "death_child", label: "Ava"),
Step("ih21", "assert", expect: "interest_has_key", value: "death_child"),
Step("ih22", "assert", expect: "current_tier", value: "Action"),
Step("ih23", "assert", expect: "happiness_drain_seq"),
// Epic can leave Action; grief may then surface.
Step("ih30", "trigger_interest", asset: "auto", label: "EpicClear", tier: "Epic"),
Step("ih31", "age_current", wait: 1.2f),
Step("ih32", "director_run", wait: 1.2f),
Step("ih33", "assert", expect: "current_tier", value: "Epic"),
Step("ih34", "assert", expect: "no_bad"),
Step("ih90", "fast_timing", value: "false"),
Step("ih99", "snapshot"),
};
}
private static List<HarnessCommand> Discovery()
{
return new List<HarnessCommand>

View file

@ -0,0 +1,139 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
public enum InterestLeadKind
{
EventLed = 0,
CharacterLed = 1
}
public enum InterestCompletionKind
{
/// <summary>One-shot narrative dwell (WorldLog, harness tips).</summary>
FixedDwell = 0,
/// <summary>Combat / hunt / fire while participants stay live.</summary>
CombatActive = 1,
/// <summary>Owning AI task / beat still live.</summary>
ActivityActive = 2,
/// <summary>Grief / family emotion with survivor + optional crying.</summary>
HappinessGrief = 3,
/// <summary>Status phase still present on subject.</summary>
StatusPhase = 4,
/// <summary>Character vignette until max watch or cold activity.</summary>
CharacterVignette = 5,
/// <summary>Harness-forced session; active until released or max cap.</summary>
Manual = 6
}
/// <summary>
/// Durable interest scene candidate. Urgency (<see cref="InterestTier"/>) is preemption class only;
/// <see cref="TotalScore"/> ranks within a class.
/// </summary>
public sealed class InterestCandidate
{
public string Key = "";
public InterestTier Urgency = InterestTier.Ambient;
public InterestLeadKind LeadKind = InterestLeadKind.EventLed;
public string Category = "";
public string Source = "";
public float EventStrength;
public float CharacterSignificance;
public float Novelty = 1f;
public float VisualConfidence = 0.5f;
public float TotalScore;
public Vector3 Position;
public Actor FollowUnit;
public Actor RelatedUnit;
public long SubjectId;
public long RelatedId;
public string Label = "";
public string AssetId = "";
public string SpeciesId = "";
public string Verb = "";
public string CityKey = "";
public string KingdomKey = "";
public string RegionKey = "";
public string HappinessEffectId = "";
public string StatusId = "";
public string CorrelationKey = "";
public float CreatedAt;
public float LastSeenAt;
public float ExpiresAt;
public float MinWatch = 1.5f;
public float MaxWatch = 45f;
public InterestCompletionKind Completion = InterestCompletionKind.FixedDwell;
public bool Resumable = true;
public bool ForceActive;
public bool Selected;
public string ScoreDetail = "";
public readonly List<long> ParticipantIds = new List<long>(4);
public bool HasFollowUnit => FollowUnit != null && FollowUnit.isAlive();
public bool HasValidPosition =>
HasFollowUnit || (Position != Vector3.zero && !float.IsNaN(Position.x));
public InterestEvent ToInterestEvent()
{
return new InterestEvent
{
Tier = Urgency,
Score = TotalScore,
Position = Position,
FollowUnit = FollowUnit,
Label = Label,
CreatedAt = CreatedAt,
AssetId = AssetId
};
}
public InterestCandidate CloneShallow()
{
var copy = new InterestCandidate
{
Key = Key,
Urgency = Urgency,
LeadKind = LeadKind,
Category = Category,
Source = Source,
EventStrength = EventStrength,
CharacterSignificance = CharacterSignificance,
Novelty = Novelty,
VisualConfidence = VisualConfidence,
TotalScore = TotalScore,
Position = Position,
FollowUnit = FollowUnit,
RelatedUnit = RelatedUnit,
SubjectId = SubjectId,
RelatedId = RelatedId,
Label = Label,
AssetId = AssetId,
SpeciesId = SpeciesId,
Verb = Verb,
CityKey = CityKey,
KingdomKey = KingdomKey,
RegionKey = RegionKey,
HappinessEffectId = HappinessEffectId,
StatusId = StatusId,
CorrelationKey = CorrelationKey,
CreatedAt = CreatedAt,
LastSeenAt = LastSeenAt,
ExpiresAt = ExpiresAt,
MinWatch = MinWatch,
MaxWatch = MaxWatch,
Completion = Completion,
Resumable = Resumable,
ForceActive = ForceActive,
Selected = Selected,
ScoreDetail = ScoreDetail
};
for (int i = 0; i < ParticipantIds.Count; i++)
{
copy.ParticipantIds.Add(ParticipantIds[i]);
}
return copy;
}
}

View file

@ -1,138 +1,45 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Compatibility shim: WorldLog / discovery / harness still call here.
/// Selection goes only through <see cref="InterestRegistry"/> (no dual-feed queue).
/// </summary>
public static class InterestCollector
{
private static readonly object Gate = new object();
private static readonly List<InterestEvent> Pending = new List<InterestEvent>();
public static void OnWorldLogMessage(WorldLogMessage message)
{
if (message == null || AgentHarness.Busy)
{
return;
}
if (!WorldLogInterestTable.TryGetTier(message.asset_id, out InterestTier tier))
{
return;
}
Vector3 position = message.getLocation();
Actor unit = null;
if (message.hasFollowLocation())
{
unit = message.unit;
}
if (unit == null && position == Vector3.zero)
{
return;
}
EnqueueDirect(new InterestEvent
{
Tier = tier,
Score = (float)tier * 100f + Time.unscaledTime * 0.001f,
Position = position,
FollowUnit = unit,
Label = WorldLogInterestTable.MakeLabel(message),
CreatedAt = Time.unscaledTime,
AssetId = message.asset_id
});
InterestFeeds.OnWorldLogMessage(message);
}
public static void EnqueueDirect(InterestEvent interest)
{
if (interest == null || !interest.HasValidPosition)
{
return;
}
lock (Gate)
{
Pending.Add(interest);
if (Pending.Count > 64)
{
Pending.RemoveRange(0, Pending.Count - 64);
}
}
InterestFeeds.RegisterDirect(interest);
}
public static bool TryGetBest(out InterestEvent best)
public static int PendingCount => InterestRegistry.PendingCount;
public static bool HasPendingAtLeast(InterestTier minTier)
{
lock (Gate)
{
best = null;
if (Pending.Count == 0)
{
return false;
}
int bestIndex = 0;
for (int i = 1; i < Pending.Count; i++)
{
InterestEvent candidate = Pending[i];
InterestEvent current = Pending[bestIndex];
if (candidate.Tier > current.Tier
|| (candidate.Tier == current.Tier && candidate.CreatedAt > current.CreatedAt))
{
bestIndex = i;
}
}
best = Pending[bestIndex];
return true;
}
}
public static void Remove(InterestEvent interest)
{
if (interest == null)
{
return;
}
lock (Gate)
{
Pending.Remove(interest);
}
return InterestRegistry.HasPendingAtLeast(minTier);
}
public static void Clear()
{
lock (Gate)
{
Pending.Clear();
}
InterestRegistry.Clear();
}
public static int PendingCount
/// <summary>Obsolete: director no longer pops from a queue. Kept for compile safety.</summary>
public static bool TryGetBest(out InterestEvent best)
{
get
{
lock (Gate)
{
return Pending.Count;
}
}
best = null;
return false;
}
public static bool HasPendingAtLeast(InterestTier minTier)
/// <summary>Obsolete no-op.</summary>
public static void Remove(InterestEvent interest)
{
lock (Gate)
{
for (int i = 0; i < Pending.Count; i++)
{
if (Pending[i] != null && Pending[i].Tier >= minTier)
{
return true;
}
}
return false;
}
// Registry marks selected via InterestDirector.
}
}

View file

@ -0,0 +1,168 @@
using UnityEngine;
namespace IdleSpectator;
/// <summary>Evaluates whether an interest scene is still "live" for protection.</summary>
public static class InterestCompletion
{
public static bool IsActive(InterestCandidate candidate, float sessionStartedAt, float now)
{
if (candidate == null)
{
return false;
}
if (candidate.ForceActive)
{
return true;
}
if (!candidate.HasValidPosition)
{
return false;
}
float age = now - sessionStartedAt;
if (age >= candidate.MaxWatch)
{
return false;
}
switch (candidate.Completion)
{
case InterestCompletionKind.Manual:
return age < candidate.MaxWatch;
case InterestCompletionKind.FixedDwell:
return age < Mathf.Max(candidate.MinWatch, candidate.MaxWatch * 0.35f);
case InterestCompletionKind.CombatActive:
return CombatStillActive(candidate);
case InterestCompletionKind.ActivityActive:
return ActivityStillActive(candidate);
case InterestCompletionKind.HappinessGrief:
return GriefStillActive(candidate);
case InterestCompletionKind.StatusPhase:
return StatusStillActive(candidate);
case InterestCompletionKind.CharacterVignette:
return VignetteStillActive(candidate, age);
default:
return age < candidate.MinWatch;
}
}
private static bool CombatStillActive(InterestCandidate c)
{
Actor unit = c.FollowUnit;
if (unit == null || !unit.isAlive())
{
return false;
}
try
{
if (unit.has_attack_target)
{
return true;
}
if (unit.hasTask() && unit.ai?.task != null
&& (unit.ai.task.in_combat || unit.ai.task.is_fireman))
{
return true;
}
}
catch
{
// ignore
}
// Battle asset without a live fighter: keep briefly via position only if battle still hot.
if (c.AssetId == "live_battle")
{
InterestEvent battle = WorldActivityScanner.FindHottestBattle();
return battle != null;
}
return false;
}
private static bool ActivityStillActive(InterestCandidate c)
{
Actor unit = c.FollowUnit;
if (unit == null || !unit.isAlive())
{
return false;
}
ActivityBand band = ActivityInterestTable.Classify(unit);
return band >= ActivityBand.Warm || unit.has_attack_target;
}
private static bool GriefStillActive(InterestCandidate c)
{
Actor survivor = c.FollowUnit;
if (survivor == null || !survivor.isAlive())
{
return false;
}
if (!string.IsNullOrEmpty(c.StatusId) && HasStatus(survivor, c.StatusId))
{
return true;
}
// Short grief window after the happiness signal.
return Time.unscaledTime - c.LastSeenAt < 8f;
}
private static bool StatusStillActive(InterestCandidate c)
{
Actor unit = c.FollowUnit;
if (unit == null || !unit.isAlive() || string.IsNullOrEmpty(c.StatusId))
{
return false;
}
return HasStatus(unit, c.StatusId);
}
private static bool VignetteStillActive(InterestCandidate c, float age)
{
if (age < c.MinWatch)
{
return true;
}
Actor unit = c.FollowUnit;
if (unit == null || !unit.isAlive())
{
return false;
}
// Stay while still warm; cold ends after min watch.
return !WorldActivityScanner.IsFocusActivityCold(unit);
}
private static bool HasStatus(Actor actor, string statusId)
{
if (actor == null || string.IsNullOrEmpty(statusId))
{
return false;
}
try
{
return actor.hasStatus(statusId);
}
catch
{
return false;
}
}
}

View file

@ -1,10 +1,12 @@
using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Picks the next interesting target using dwell, cooldown, and tier interrupts.
/// Event-centered scene director: registry intake, protected sessions, Epic-only preemption
/// for Action+ scenes, soft 70/30 variety, completion via <see cref="InterestCompletion.IsActive"/>.
/// </summary>
public static class InterestDirector
{
@ -17,42 +19,58 @@ public static class InterestDirector
public const float AmbientRotateSeconds = 20f;
public const float ActionPollSeconds = 0.75f;
public const float InputExitGraceSeconds = 2.5f;
/// <summary>Leave a Cold (idle) focus sooner so interesting work elsewhere can win.</summary>
public const float ColdFocusEscapeSeconds = 6f;
public const float QuietGraceSeconds = 1.2f;
public const float CameraSettleGraceSeconds = 3f;
// Production defaults (copied when leaving harness fast timing).
private static float _minDwell = MinDwellSeconds;
private static float _curiosityDwell = CuriosityDwellSeconds;
private static float _curiosityRotate = CuriosityRotateSeconds;
private static float _switchCooldown = SwitchCooldownSeconds;
private static float _highTierInterrupt = HighTierInterruptAfterSeconds;
private static float _settleGrace = CameraSettleGraceSeconds;
private static float _quietAmbientAfter = QuietWorldAmbientAfterSeconds;
private static float _ambientRotate = AmbientRotateSeconds;
private static float _actionPoll = ActionPollSeconds;
private static float _feedsTick = ActionPollSeconds;
private static float _inputExitGrace = InputExitGraceSeconds;
private static float _coldFocusEscape = ColdFocusEscapeSeconds;
private static float _quietGrace = QuietGraceSeconds;
private static InterestEvent _current;
private static InterestCandidate _current;
private static InterestCandidate _interrupted;
private static float _currentStartedAt = -999f;
private static float _lastSwitchAt = -999f;
private static float _lastInterestingAt = -999f;
private static float _lastAmbientAt = -999f;
private static float _lastActionPollAt = -999f;
private static float _lastFeedsAt = -999f;
private static float _enabledAt = -999f;
private static float _inactiveSince = -999f;
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(96);
public static string CurrentTierName =>
_current == null ? "none" : _current.Tier.ToString();
_current == null ? "none" : _current.Urgency.ToString();
public static string CurrentLabel =>
_current == null ? "" : (_current.Label ?? "");
public static string CurrentKey =>
_current == null ? "" : (_current.Key ?? "");
public static bool CurrentIsActive
{
get
{
if (_current == null)
{
return false;
}
return InterestCompletion.IsActive(_current, _currentStartedAt, Time.unscaledTime);
}
}
public static InterestCandidate CurrentCandidate => _current;
public static bool InInputGrace =>
SpectatorMode.Active && Time.unscaledTime - _enabledAt < _inputExitGrace;
/// <summary>
/// Harness-only: shorten dwell/rotate so director tests finish in seconds.
/// Game 5x speed does not help - this code uses <see cref="Time.unscaledTime"/>.
/// </summary>
public static void SetHarnessFastTiming(bool enabled)
{
if (enabled)
@ -61,12 +79,12 @@ public static class InterestDirector
_curiosityDwell = 1.2f;
_curiosityRotate = 1.5f;
_switchCooldown = 0.75f;
_highTierInterrupt = 0.75f;
_settleGrace = 0.75f;
_quietAmbientAfter = 1.5f;
_ambientRotate = 2.5f;
_actionPoll = 0.35f;
_feedsTick = 0.35f;
_inputExitGrace = 0.6f;
_coldFocusEscape = 1.2f;
_quietGrace = 0.35f;
}
else
{
@ -74,16 +92,15 @@ public static class InterestDirector
_curiosityDwell = CuriosityDwellSeconds;
_curiosityRotate = CuriosityRotateSeconds;
_switchCooldown = SwitchCooldownSeconds;
_highTierInterrupt = HighTierInterruptAfterSeconds;
_settleGrace = CameraSettleGraceSeconds;
_quietAmbientAfter = QuietWorldAmbientAfterSeconds;
_ambientRotate = AmbientRotateSeconds;
_actionPoll = ActionPollSeconds;
_feedsTick = ActionPollSeconds;
_inputExitGrace = InputExitGraceSeconds;
_coldFocusEscape = ColdFocusEscapeSeconds;
_quietGrace = QuietGraceSeconds;
}
}
/// <summary>Harness: pretend the current interest started <paramref name="seconds"/> ago.</summary>
public static void HarnessAgeCurrent(float seconds)
{
float age = Mathf.Max(0f, seconds);
@ -92,36 +109,54 @@ public static class InterestDirector
_lastSwitchAt = now - age;
}
/// <summary>Harness: expire input-exit grace immediately.</summary>
public static void HarnessExpireInputGrace()
{
_enabledAt = Time.unscaledTime - _inputExitGrace - 0.05f;
}
/// <summary>Harness: force the given candidate as the active session.</summary>
public static bool HarnessForceSession(InterestCandidate candidate)
{
if (candidate == null || !candidate.HasValidPosition)
{
return false;
}
InterestRegistry.Upsert(candidate);
SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false);
return true;
}
public static void OnSpectatorEnabled()
{
InterestCollector.Clear();
InterestRegistry.Clear();
InterestVariety.Clear();
InterestScoring.ClearCache();
InterestFeeds.Reset();
_current = null;
_interrupted = null;
float now = Time.unscaledTime;
_currentStartedAt = now;
_lastSwitchAt = now;
_lastInterestingAt = now;
_lastAmbientAt = -999f;
_lastActionPollAt = -999f;
_lastFeedsAt = -999f;
_enabledAt = now;
TryAmbient(force: true);
_inactiveSince = -999f;
// Harness scenarios inject their own candidates after enable/focus.
if (!AgentHarness.Busy)
{
TryCharacterFill(force: true);
}
}
public static void OnSpectatorDisabled()
{
_current = null;
InterestCollector.Clear();
_interrupted = null;
InterestRegistry.Clear();
}
/// <summary>
/// Stop idle auto-follow the same way manual input does (quiet), for Lore history browsing.
/// Prefer <see cref="ChronicleHud.OpenUnitHistory"/> which focuses first then banners.
/// </summary>
public static void PauseForBrowsing(string banner = "Paused (viewing history)")
{
if (SpectatorMode.Active)
@ -147,7 +182,6 @@ public static class InterestDirector
WatchCaption.ClearPausePin();
// During harness batches, freeze unless director_run explicitly unfreezes.
if (AgentHarness.FreezeDirector)
{
return;
@ -161,111 +195,163 @@ public static class InterestDirector
}
float now = Time.unscaledTime;
if (now - _lastFeedsAt >= _feedsTick)
{
_lastFeedsAt = now;
InterestFeeds.Tick();
InterestRegistry.ExpireStale(now);
}
float onCurrent = now - _currentStartedAt;
float sinceSwitch = now - _lastSwitchAt;
// Periodically surface live battles into the queue (Action tier).
if (now - _lastActionPollAt >= _actionPoll)
{
_lastActionPollAt = now;
InterestEvent battle = WorldActivityScanner.FindHottestBattle();
if (battle != null)
{
InterestCollector.EnqueueDirect(battle);
}
}
UpdateSessionLiveness(now, onCurrent);
if (InterestCollector.TryGetBest(out InterestEvent candidate))
InterestCandidate next = SelectNext(now);
if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch))
{
if (candidate.HasValidPosition && CanSwitchTo(candidate, onCurrent, sinceSwitch))
{
InterestCollector.Remove(candidate);
if (candidate.Tier >= InterestTier.Action)
{
_lastInterestingAt = now;
}
SwitchTo(candidate, now);
onCurrent = 0f;
sinceSwitch = 0f;
}
else if (!candidate.HasValidPosition)
{
InterestCollector.Remove(candidate);
}
else if (candidate.Tier >= InterestTier.Action)
if (next.Urgency >= InterestTier.Action)
{
_lastInterestingAt = now;
}
SwitchTo(next, now, resumableInterrupt: false);
onCurrent = 0f;
sinceSwitch = 0f;
}
// Queued discovery tips beat ambient, but never stall ambient under Action/Story.
if (InterestCollector.HasPendingAtLeast(InterestTier.Curiosity)
&& (_current == null || _current.Tier <= InterestTier.Curiosity))
// Queued discovery tips beat ambient fill, but never stall under Action/Story.
if (InterestRegistry.HasPendingAtLeast(InterestTier.Curiosity)
&& (_current == null || _current.Urgency <= InterestTier.Curiosity))
{
return;
}
bool quiet = now - _lastInterestingAt >= _quietAmbientAfter;
bool dwellDone = _current == null || onCurrent >= DwellFor(_current);
bool sceneDone = _current == null || !SessionProtected(now, onCurrent);
bool ambientDue = now - _lastAmbientAt >= _ambientRotate;
bool coldEscape = FocusWentCold(onCurrent) && sinceSwitch >= _switchCooldown;
if (_current == null && sinceSwitch >= 0.5f)
{
TryAmbient(force: true);
TryCharacterFill(force: true);
}
else if (coldEscape)
else if (sceneDone && quiet && ambientDue && sinceSwitch >= _switchCooldown)
{
TryAmbient(force: true);
}
else if (quiet && dwellDone && ambientDue && sinceSwitch >= _switchCooldown)
{
TryAmbient(force: false);
TryCharacterFill(force: false);
}
}
private static bool FocusWentCold(float onCurrent)
private static void UpdateSessionLiveness(float now, float onCurrent)
{
if (_current == null || onCurrent < _coldFocusEscape)
if (_current == null)
{
return false;
_inactiveSince = -999f;
return;
}
// WorldLog Story/Epic should not be yanked solely for a cold follow unit.
if (_current.Tier >= InterestTier.Story
&& !string.IsNullOrEmpty(_current.AssetId)
&& _current.AssetId != "scored_unit"
&& _current.AssetId != "live_battle")
// Max cap ends the scene even if still "active".
if (onCurrent >= MaxWatchFor(_current))
{
// Ambient-scored kings used AssetId = species id - still allow cold escape for those.
// Only protect true world-log assets (war_*, kingdom_*, etc.).
if (_current.AssetId.IndexOf("war", System.StringComparison.OrdinalIgnoreCase) >= 0
|| _current.AssetId.IndexOf("kingdom", System.StringComparison.OrdinalIgnoreCase) >= 0
|| _current.AssetId.IndexOf("city", System.StringComparison.OrdinalIgnoreCase) >= 0
|| _current.AssetId.IndexOf("disaster", System.StringComparison.OrdinalIgnoreCase) >= 0)
EndCurrent(now, reason: "max_cap");
return;
}
bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now);
if (active)
{
_inactiveSince = -999f;
TryHandoffFollow();
return;
}
if (_inactiveSince < 0f)
{
_inactiveSince = now;
}
if (now - _inactiveSince >= _quietGrace)
{
EndCurrent(now, reason: "quiet_grace");
}
}
private static void TryHandoffFollow()
{
if (_current == null)
{
return;
}
if (_current.HasFollowUnit)
{
return;
}
// Prefer related (grief survivor is already FollowUnit); try related then participants.
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
{
_current.FollowUnit = _current.RelatedUnit;
CameraDirector.Watch(_current.ToInterestEvent());
return;
}
Actor near = WorldActivityScanner.FindNearestAliveUnit(_current.Position, 18f);
if (near != null)
{
_current.FollowUnit = near;
CameraDirector.Watch(_current.ToInterestEvent());
}
}
private static void EndCurrent(float now, string reason)
{
if (_current != null)
{
InterestRegistry.Remove(_current.Key);
LogService.LogInfo("[IdleSpectator] Scene end (" + reason + "): " + _current.Label);
}
// Resume interrupted Epic-preempted scene if still valid.
if (_interrupted != null
&& _interrupted.HasValidPosition
&& InterestCompletion.IsActive(_interrupted, now - _interrupted.MinWatch, now))
{
InterestCandidate resume = _interrupted;
_interrupted = null;
SwitchTo(resume, now, resumableInterrupt: false);
return;
}
_interrupted = null;
_current = null;
_inactiveSince = -999f;
}
private static InterestCandidate SelectNext(float now)
{
InterestRegistry.CopyPending(PendingScratch);
if (PendingScratch.Count == 0)
{
return null;
}
float onCurrent = now - _currentStartedAt;
float sinceSwitch = now - _lastSwitchAt;
for (int i = PendingScratch.Count - 1; i >= 0; i--)
{
InterestCandidate c = PendingScratch[i];
if (c == null || !CanSwitchTo(c, onCurrent, sinceSwitch))
{
return false;
PendingScratch.RemoveAt(i);
}
}
Actor unit = null;
try
if (PendingScratch.Count == 0)
{
if (MoveCamera.hasFocusUnit())
{
unit = MoveCamera._focus_unit;
}
}
catch
{
unit = null;
return null;
}
if (unit == null || !unit.isAlive())
{
return true;
}
return WorldActivityScanner.IsFocusActivityCold(unit);
InterestScoring.EnrichTopK(PendingScratch);
return InterestVariety.Pick(PendingScratch, _current);
}
/// <summary>
@ -281,15 +367,11 @@ public static class InterestDirector
float onCurrent = Time.unscaledTime - _currentStartedAt;
float sinceSwitch = Time.unscaledTime - _lastSwitchAt;
return CanSwitchTo(
new InterestEvent { Tier = InterestTier.Curiosity },
new InterestCandidate { Urgency = InterestTier.Curiosity, Key = "probe:curiosity" },
onCurrent,
sinceSwitch);
}
/// <summary>
/// Harness: pulse camera-drag (or honorGrace path) and take the real manual-exit path.
/// When <paramref name="honorGrace"/> is true, input during the post-enable grace does not exit.
/// </summary>
public static bool SimulateManualInputExit(bool honorGrace = false)
{
if (!SpectatorMode.Active)
@ -325,8 +407,6 @@ public static class InterestDirector
private static bool PlayerTookManualControl()
{
// Rising-edge style checks only - avoid sticky flags like already_used_power
// that can trip when the power bar flashes or a tip appears.
if (MoveCamera.camera_drag_run)
{
return true;
@ -363,82 +443,167 @@ public static class InterestDirector
return false;
}
private static float DwellFor(InterestEvent current)
private static float MinDwellFor(InterestCandidate current)
{
if (current == null)
{
return MinDwellSeconds;
return _minDwell;
}
return current.Tier <= InterestTier.Curiosity ? _curiosityDwell : _minDwell;
if (current.MinWatch > 0f)
{
return current.Urgency <= InterestTier.Curiosity
? Mathf.Min(current.MinWatch, _curiosityDwell)
: Mathf.Max(current.MinWatch, _minDwell * 0.15f);
}
return current.Urgency <= InterestTier.Curiosity ? _curiosityDwell : _minDwell;
}
private static bool CanSwitchTo(InterestEvent candidate, float onCurrent, float sinceSwitch)
private static float MaxWatchFor(InterestCandidate current)
{
if (current == null)
{
return 45f;
}
float cap = current.MaxWatch > 0f ? current.MaxWatch : 45f;
// Harness fast timing compresses caps.
if (_minDwell < MinDwellSeconds * 0.5f)
{
cap = Mathf.Min(cap, 8f);
}
return cap;
}
/// <summary>
/// Protected Action+ scenes: only Epic may preempt after settle grace.
/// Ambient/Curiosity may be replaced by higher urgency after settle.
/// </summary>
private static bool SessionProtected(float now, float onCurrent)
{
if (_current == null)
{
return false;
}
if (onCurrent >= MaxWatchFor(_current))
{
return false;
}
bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now);
if (!active)
{
if (_inactiveSince >= 0f && now - _inactiveSince >= _quietGrace)
{
return false;
}
// Still in quiet grace - treat as protected briefly.
return _inactiveSince < 0f || now - _inactiveSince < _quietGrace;
}
return true;
}
private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch)
{
if (candidate == null)
{
return false;
}
if (_current == null)
{
return true;
}
// Curiosity is a footnote: never interrupt Action/Story/Epic.
// Curiosity→curiosity uses a shorter rotate so discovery drains can surface.
if (candidate.Tier == InterestTier.Curiosity)
float now = Time.unscaledTime;
bool protectedScene = SessionProtected(now, onCurrent);
InterestTier cur = _current.Urgency;
InterestTier next = candidate.Urgency;
// Curiosity never interrupts Action+.
if (next == InterestTier.Curiosity && cur >= InterestTier.Action)
{
if (_current.Tier >= InterestTier.Action)
{
return false;
}
if (_current.Tier == InterestTier.Curiosity && onCurrent < _curiosityRotate)
{
return false;
}
if (_current.Tier == InterestTier.Story)
{
return false;
}
return false;
}
if (candidate.Tier > _current.Tier && onCurrent >= _highTierInterrupt)
if (next == InterestTier.Curiosity
&& cur == InterestTier.Curiosity
&& onCurrent < _curiosityRotate)
{
return false;
}
if (protectedScene && cur >= InterestTier.Action)
{
// Action/Story protected: only Epic after settle grace.
if (next >= InterestTier.Epic && onCurrent >= _settleGrace)
{
return true;
}
return false;
}
// Ambient/Curiosity (or unprotected): higher urgency after settle; else dwell+cooldown.
if (next > cur && onCurrent >= _settleGrace)
{
return true;
}
return onCurrent >= DwellFor(_current) && sinceSwitch >= _switchCooldown;
if (!protectedScene && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown)
{
return true;
}
return onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown && next >= cur;
}
private static void SwitchTo(InterestEvent next, float now)
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
{
if (next == null)
{
return;
}
if (_current != null
&& next.Urgency >= InterestTier.Epic
&& _current.Urgency < InterestTier.Epic
&& _current.Resumable)
{
_interrupted = _current.CloneShallow();
}
_current = next;
_currentStartedAt = now;
_lastSwitchAt = now;
if (next.Tier >= InterestTier.Action)
_inactiveSince = -999f;
if (next.Urgency >= InterestTier.Action)
{
_lastInterestingAt = now;
}
CameraDirector.Watch(next);
InterestRegistry.MarkSelected(next.Key);
InterestVariety.NoteSelection(next);
CameraDirector.Watch(next.ToInterestEvent());
}
private static void TryAmbient(bool force)
private static void TryCharacterFill(bool force)
{
float now = Time.unscaledTime;
_lastAmbientAt = now;
InterestEvent ambient = WorldActivityScanner.FindBestLiveTarget();
if (ambient == null)
if (!force && _current != null && SessionProtected(now, now - _currentStartedAt)
&& _current.Urgency >= InterestTier.Story)
{
return;
}
if (!force && _current != null && _current.Tier >= InterestTier.Story)
{
return;
}
// Don't let ambient yank the camera off a curiosity tip after a few seconds.
if (!force && _current != null && _current.Tier == InterestTier.Curiosity
if (!force && _current != null && _current.Urgency == InterestTier.Curiosity
&& now - _currentStartedAt < _ambientRotate)
{
return;
@ -449,18 +614,55 @@ public static class InterestDirector
return;
}
// Avoid re-following the exact same unit every ambient tick.
if (!force
&& _current != null
&& _current.FollowUnit != null
&& ambient.FollowUnit != null
&& _current.FollowUnit == ambient.FollowUnit
&& ambient.Tier <= InterestTier.Ambient)
InterestEvent ambient = WorldActivityScanner.FindBestLiveTarget();
if (ambient == null)
{
return;
}
SwitchTo(ambient, now);
LogService.LogInfo("[IdleSpectator] Ambient: " + ambient.Label);
// Harness batches inject their own candidates; never let a live Action/Story
// ambient fill steal the camera before trigger_interest / discovery steps.
if (AgentHarness.Busy && ambient.Tier >= InterestTier.Action)
{
ambient.Tier = InterestTier.Ambient;
if (string.IsNullOrEmpty(ambient.Label))
{
ambient.Label = "Ambient";
}
}
InterestCandidate candidate = InterestFeeds.RegisterDirect(ambient);
if (candidate == null)
{
return;
}
candidate.LeadKind = InterestLeadKind.CharacterLed;
candidate.Category = "CharacterVignette";
candidate.Completion = InterestCompletionKind.CharacterVignette;
if (AgentHarness.Busy)
{
candidate.Urgency = InterestTier.Ambient;
candidate.ForceActive = false;
InterestScoring.ScoreCheap(candidate);
}
if (!force
&& _current != null
&& _current.FollowUnit != null
&& candidate.FollowUnit != null
&& _current.FollowUnit == candidate.FollowUnit
&& candidate.Urgency <= InterestTier.Ambient)
{
return;
}
if (!force && !CanSwitchTo(candidate, now - _currentStartedAt, now - _lastSwitchAt))
{
return;
}
SwitchTo(candidate, now, resumableInterrupt: false);
LogService.LogInfo("[IdleSpectator] Ambient: " + candidate.Label);
}
}

View file

@ -0,0 +1,772 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Converts live streams into <see cref="InterestRegistry"/> candidates.
/// Sole path for WorldLog / happiness / scanner / activity / status / chronicle → registry.
/// </summary>
public static class InterestFeeds
{
private static ulong _happinessCursor;
private static readonly List<HappinessOccurrence> HappinessDrain = new List<HappinessOccurrence>(64);
private static float _lastScannerAt = -999f;
private const float ScannerInterval = 0.75f;
// Civic aggregate boosts keyed by kingdom+effect.
private static readonly Dictionary<string, float> CivicBoostUntil = new Dictionary<string, float>(32);
public static void Reset()
{
_happinessCursor = HappinessEventRouter.CurrentSequence;
HappinessDrain.Clear();
_lastScannerAt = -999f;
CivicBoostUntil.Clear();
}
public static void Tick()
{
DrainHappiness();
TickScanner();
PruneCivicBoosts();
}
public static void OnWorldLogMessage(WorldLogMessage message)
{
if (message == null || AgentHarness.Busy)
{
return;
}
if (!WorldLogInterestTable.TryGetTier(message.asset_id, out InterestTier tier))
{
return;
}
Vector3 position = message.getLocation();
Actor unit = null;
if (message.hasFollowLocation())
{
unit = message.unit;
}
if (unit == null && position == Vector3.zero)
{
return;
}
string assetId = message.asset_id ?? "worldlog";
string kingdom = message.special1 ?? "";
string key = "worldlog:" + assetId + ":" + kingdom;
float boost = PeekCivicBoost(kingdom, assetId);
var candidate = new InterestCandidate
{
Key = key,
Urgency = tier,
LeadKind = InterestLeadKind.EventLed,
Category = tier >= InterestTier.Epic ? "Politics" : "Settlement",
Source = "worldlog",
EventStrength = 60f + (int)tier * 20f + boost,
VisualConfidence = unit != null ? 0.7f : 0.4f,
Position = unit != null ? unit.current_position : position,
FollowUnit = unit,
SubjectId = SafeId(unit),
Label = WorldLogInterestTable.MakeLabel(message),
AssetId = assetId,
KingdomKey = kingdom,
SpeciesId = unit?.asset != null ? unit.asset.id : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 40f,
MinWatch = tier >= InterestTier.Epic ? 8f : 6f,
MaxWatch = tier >= InterestTier.Epic ? 35f : 28f,
Completion = InterestCompletionKind.FixedDwell
};
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
}
/// <summary>Legacy / discovery / harness enqueue path → registry.</summary>
public static InterestCandidate RegisterDirect(InterestEvent interest)
{
if (interest == null || !interest.HasValidPosition)
{
return null;
}
Actor unit = interest.FollowUnit;
long subjectId = SafeId(unit);
string asset = interest.AssetId ?? "";
string key = "direct:" + interest.Tier + ":" + subjectId + ":" + (interest.Label ?? "") + ":" + asset;
InterestLeadKind lead = interest.Tier >= InterestTier.Action
? InterestLeadKind.EventLed
: InterestLeadKind.CharacterLed;
InterestCompletionKind completion = interest.Tier >= InterestTier.Action
? InterestCompletionKind.FixedDwell
: InterestCompletionKind.CharacterVignette;
if (asset == "live_battle")
{
key = "battle:hot:" + Mathf.FloorToInt(interest.Position.x) + ":" + Mathf.FloorToInt(interest.Position.y);
lead = InterestLeadKind.EventLed;
completion = InterestCompletionKind.CombatActive;
}
var candidate = new InterestCandidate
{
Key = key,
Urgency = interest.Tier,
LeadKind = lead,
Category = CategoryForTier(interest.Tier, asset),
Source = "direct",
EventStrength = interest.Score > 0f ? interest.Score : 40f + (int)interest.Tier * 15f,
CharacterSignificance = lead == InterestLeadKind.CharacterLed ? interest.Score : 0f,
VisualConfidence = unit != null ? 0.75f : 0.4f,
Position = interest.Position,
FollowUnit = unit,
SubjectId = subjectId,
Label = interest.Label ?? "",
AssetId = asset,
SpeciesId = unit?.asset != null ? unit.asset.id : asset,
CreatedAt = interest.CreatedAt > 0f ? interest.CreatedAt : Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 30f,
MinWatch = interest.Tier <= InterestTier.Curiosity ? 1.2f : 1.5f,
MaxWatch = interest.Tier >= InterestTier.Epic ? 40f : 25f,
Completion = completion
};
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
}
public static InterestCandidate RegisterHarness(
Actor follow,
InterestTier urgency,
string label,
string keySuffix,
InterestLeadKind lead,
InterestCompletionKind completion,
bool forceActive,
float eventStrength,
float maxWatch)
{
if (follow == null || !follow.isAlive())
{
return null;
}
long id = SafeId(follow);
string key = "harness:" + (keySuffix ?? urgency.ToString()) + ":" + id;
var candidate = new InterestCandidate
{
Key = key,
Urgency = urgency,
LeadKind = lead,
Category = CategoryForTier(urgency, ""),
Source = "harness",
EventStrength = eventStrength,
CharacterSignificance = lead == InterestLeadKind.CharacterLed ? eventStrength : 10f,
VisualConfidence = 0.9f,
Position = follow.current_position,
FollowUnit = follow,
SubjectId = id,
Label = label ?? ("Harness " + urgency),
AssetId = follow.asset != null ? follow.asset.id : "harness",
SpeciesId = follow.asset != null ? follow.asset.id : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 60f,
MinWatch = 1f,
MaxWatch = maxWatch > 0f ? maxWatch : 20f,
Completion = completion,
ForceActive = forceActive
};
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
}
public static void OnActivityNote(Actor actor, string taskOrBeat, bool hot)
{
if (actor == null || !actor.isAlive() || AgentHarness.Busy || !hot)
{
return;
}
long id = SafeId(actor);
string verb = taskOrBeat ?? "activity";
string key = "activity:" + id + ":" + verb;
bool combat = actor.has_attack_target;
var candidate = new InterestCandidate
{
Key = key,
Urgency = InterestTier.Action,
LeadKind = InterestLeadKind.EventLed,
Category = combat ? "Combat" : "Work",
Source = "activity",
EventStrength = combat ? 75f : 50f,
VisualConfidence = 0.8f,
Position = actor.current_position,
FollowUnit = actor,
SubjectId = id,
Label = combat ? ("Fighting: " + SafeName(actor)) : (SafeName(actor) + " · " + verb),
AssetId = actor.asset != null ? actor.asset.id : "",
SpeciesId = actor.asset != null ? actor.asset.id : "",
Verb = verb,
CityKey = actor.city != null ? (actor.city.name ?? "") : "",
KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 20f,
MinWatch = 3f,
MaxWatch = 30f,
Completion = combat ? InterestCompletionKind.CombatActive : InterestCompletionKind.ActivityActive
};
candidate.ParticipantIds.Add(id);
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
}
public static void OnStatusChange(Actor actor, string statusId, bool gained)
{
if (actor == null || !actor.isAlive() || !gained || string.IsNullOrEmpty(statusId))
{
return;
}
if (AgentHarness.Busy)
{
return;
}
// Visible spectacle statuses amplify / extend scenes; crying extends grief.
bool spectacle = statusId == "burning"
|| statusId == "possessed"
|| statusId == "crying"
|| statusId == "frozen"
|| statusId == "shocked";
if (!spectacle)
{
return;
}
long id = SafeId(actor);
if (statusId == "crying")
{
// Extend existing grief candidate rather than spawning a second scene.
string griefPrefix = "happiness:death_";
ExtendMatching(id, griefPrefix, statusId);
return;
}
string key = "status:" + statusId + ":" + id;
var candidate = new InterestCandidate
{
Key = key,
Urgency = InterestTier.Action,
LeadKind = InterestLeadKind.EventLed,
Category = "StatusTransformation",
Source = "status",
EventStrength = 55f,
VisualConfidence = 0.85f,
Position = actor.current_position,
FollowUnit = actor,
SubjectId = id,
StatusId = statusId,
Label = SafeName(actor) + " · " + statusId,
AssetId = actor.asset != null ? actor.asset.id : "",
SpeciesId = actor.asset != null ? actor.asset.id : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 18f,
MinWatch = 2f,
MaxWatch = 22f,
Completion = InterestCompletionKind.StatusPhase
};
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
}
public static void OnChronicleMilestone(Actor subject, string milestoneKey, Actor related, string label)
{
if (subject == null || !subject.isAlive() || string.IsNullOrEmpty(milestoneKey))
{
return;
}
if (AgentHarness.Busy)
{
return;
}
long sid = SafeId(subject);
long rid = SafeId(related);
// Shared key space with happiness canonical merges.
string key = "chronicle:" + milestoneKey + ":" + sid + ":" + rid;
InterestTier urgency = milestoneKey.Contains("kill") || milestoneKey.Contains("death")
? InterestTier.Action
: InterestTier.Curiosity;
var candidate = new InterestCandidate
{
Key = key,
Urgency = urgency,
LeadKind = InterestLeadKind.EventLed,
Category = "Relationship",
Source = "chronicle",
EventStrength = 60f,
VisualConfidence = 0.7f,
Position = subject.current_position,
FollowUnit = subject,
RelatedUnit = related,
SubjectId = sid,
RelatedId = rid,
Label = label ?? milestoneKey,
AssetId = subject.asset != null ? subject.asset.id : "",
SpeciesId = subject.asset != null ? subject.asset.id : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 25f,
MinWatch = 3f,
MaxWatch = 20f,
Completion = InterestCompletionKind.FixedDwell
};
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
}
private static void DrainHappiness()
{
ulong next = HappinessEventRouter.DrainSince(_happinessCursor, HappinessDrain);
if (HappinessDrain.Count == 0)
{
_happinessCursor = next;
return;
}
for (int i = 0; i < HappinessDrain.Count; i++)
{
IngestHappiness(HappinessDrain[i]);
}
_happinessCursor = next;
}
private static void IngestHappiness(HappinessOccurrence occ)
{
if (occ == null || !occ.Applied || occ.SuppressedByPsychopath)
{
return;
}
// Harness scenarios inject via SourceHook.Harness; ignore live world noise while Busy.
if (AgentHarness.Busy && occ.SourceHook != HappinessSourceHook.Harness)
{
return;
}
if (occ.Presentation == HappinessPresentationTier.Ambient)
{
// Enrichment only - never owns the camera.
return;
}
if (occ.Presentation == HappinessPresentationTier.Aggregate)
{
if (!occ.Merged || occ.SourceHook == HappinessSourceHook.AggregateSummary)
{
string kingdom = "";
try
{
// Prefer civ key fragment.
string civ = occ.CivEventKey ?? "";
int pipe = civ.IndexOf('|');
if (pipe >= 0 && pipe + 1 < civ.Length)
{
int pipe2 = civ.IndexOf('|', pipe + 1);
kingdom = pipe2 > pipe
? civ.Substring(pipe + 1, pipe2 - pipe - 1)
: civ.Substring(pipe + 1);
}
}
catch
{
kingdom = "";
}
StampCivicBoost(kingdom, occ.EffectId, 25f + Mathf.Min(40f, occ.TotalAffectedCount));
}
return;
}
// Signal: skip merged duplicates (canonical / continuation already covered).
if (occ.Merged)
{
return;
}
Actor subject = FindUnitById(occ.SubjectId);
if (subject == null || !subject.isAlive())
{
return;
}
Actor related = occ.RelatedId != 0 ? FindUnitById(occ.RelatedId) : null;
string cat = string.IsNullOrEmpty(occ.InterestCategory) ? "Emotion" : occ.InterestCategory;
string key = "happiness:" + occ.EffectId + ":" + occ.SubjectId + ":" + occ.RelatedId
+ ":" + CorrBucket(occ.CorrelationKey);
bool grief = occ.EffectId != null && occ.EffectId.StartsWith("death_");
InterestTier urgency = InterestScoring.UrgencyForHappiness(occ.EffectId, subject);
float strength = InterestScoring.EventStrengthForHappiness(occ.EffectId);
var candidate = new InterestCandidate
{
Key = key,
Urgency = urgency,
LeadKind = InterestLeadKind.EventLed,
Category = grief ? "FamilyEmotion" : MapHappinessCategory(cat),
Source = "happiness",
EventStrength = strength,
VisualConfidence = 0.75f,
Position = occ.Position != Vector3.zero ? occ.Position : subject.current_position,
FollowUnit = subject,
RelatedUnit = related,
SubjectId = occ.SubjectId,
RelatedId = occ.RelatedId,
Label = !string.IsNullOrEmpty(occ.PlainClause)
? (occ.SubjectName + " " + occ.PlainClause)
: (occ.SubjectName + " · " + occ.EffectId),
AssetId = occ.SubjectSpecies,
SpeciesId = occ.SubjectSpecies,
HappinessEffectId = occ.EffectId,
StatusId = grief ? "crying" : "",
CorrelationKey = occ.CorrelationKey,
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 35f,
MinWatch = 4f,
MaxWatch = 28f,
Completion = grief
? InterestCompletionKind.HappinessGrief
: InterestCompletionKind.FixedDwell
};
candidate.ParticipantIds.Add(occ.SubjectId);
if (occ.RelatedId != 0)
{
candidate.ParticipantIds.Add(occ.RelatedId);
}
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
}
/// <summary>Harness / push path: ingest one occurrence into the registry now.</summary>
public static void IngestForHarness(HappinessOccurrence occ)
{
IngestHappiness(occ);
if (occ != null && occ.Sequence > _happinessCursor)
{
_happinessCursor = occ.Sequence;
}
}
/// <summary>Direct registry insert for harness happiness signals (skips drain filters).</summary>
public static InterestCandidate RegisterHappinessSignal(
Actor subject,
string effectId,
string label,
string relatedName = "")
{
if (subject == null || string.IsNullOrEmpty(effectId))
{
return null;
}
long id = SafeId(subject);
Vector3 pos = Vector3.zero;
try
{
pos = subject.current_position;
}
catch
{
pos = Vector3.zero;
}
bool grief = effectId.StartsWith("death_");
InterestTier urgency = InterestScoring.UrgencyForHappiness(effectId, subject);
float strength = InterestScoring.EventStrengthForHappiness(effectId);
string key = "happiness:" + effectId + ":" + id + ":0:harness";
bool alive = false;
try
{
alive = subject.isAlive();
}
catch
{
alive = false;
}
var candidate = new InterestCandidate
{
Key = key,
Urgency = urgency,
LeadKind = InterestLeadKind.EventLed,
Category = grief ? "FamilyEmotion" : "LifeChapter",
Source = "happiness_harness",
EventStrength = strength,
VisualConfidence = 0.9f,
Position = pos != Vector3.zero ? pos : new Vector3(1f, 1f, 0f),
FollowUnit = alive ? subject : null,
SubjectId = id,
Label = !string.IsNullOrEmpty(label)
? label
: (SafeName(subject) + " · " + effectId),
AssetId = subject.asset != null ? subject.asset.id : "",
SpeciesId = subject.asset != null ? subject.asset.id : "",
HappinessEffectId = effectId,
StatusId = grief ? "crying" : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 60f,
MinWatch = 4f,
MaxWatch = 28f,
Completion = grief
? InterestCompletionKind.HappinessGrief
: InterestCompletionKind.FixedDwell
};
if (!string.IsNullOrEmpty(relatedName))
{
candidate.Label = SafeName(subject) + " mourns " + relatedName;
}
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
}
private static void TickScanner()
{
float now = Time.unscaledTime;
if (now - _lastScannerAt < ScannerInterval)
{
return;
}
_lastScannerAt = now;
if (AgentHarness.Busy)
{
return;
}
// Top battle cluster.
InterestEvent battle = WorldActivityScanner.FindHottestBattle();
if (battle != null)
{
RegisterDirect(battle);
}
// Character-led vignette seeds (not sole one-best monopoly: register best + a few notables).
WorldActivityScanner.RegisterTopCharacterCandidates(max: 4);
}
private static void ExtendMatching(long subjectId, string keyPrefix, string statusId)
{
var pending = new List<InterestCandidate>(32);
InterestRegistry.CopyPending(pending);
for (int i = 0; i < pending.Count; i++)
{
InterestCandidate c = pending[i];
if (c == null || c.SubjectId != subjectId)
{
continue;
}
if (c.Key != null && c.Key.StartsWith(keyPrefix))
{
c.StatusId = statusId;
c.LastSeenAt = Time.unscaledTime;
c.ExpiresAt = Time.unscaledTime + 20f;
InterestRegistry.Upsert(c);
return;
}
}
}
private static void StampCivicBoost(string kingdom, string effectId, float amount)
{
string key = (kingdom ?? "") + "|" + (effectId ?? "");
CivicBoostUntil[key] = Time.unscaledTime + 12f;
// Store amount in a parallel way via Expires encoding isn't ideal; keep simple additive peek.
CivicBoostUntil[key + "|amt"] = amount;
}
private static float PeekCivicBoost(string kingdom, string assetId)
{
// Map worldlog war assets to happiness war effects loosely.
string effect = "";
if (assetId != null && assetId.Contains("war"))
{
effect = "just_started_war";
}
else if (assetId != null && (assetId.Contains("city") || assetId.Contains("kingdom")))
{
effect = "conquered_city";
}
string key = (kingdom ?? "") + "|" + effect;
if (!CivicBoostUntil.TryGetValue(key, out float until) || Time.unscaledTime > until)
{
return 0f;
}
return CivicBoostUntil.TryGetValue(key + "|amt", out float amt) ? amt : 15f;
}
private static void PruneCivicBoosts()
{
if (CivicBoostUntil.Count < 64)
{
return;
}
float now = Time.unscaledTime;
var remove = new List<string>();
foreach (KeyValuePair<string, float> kv in CivicBoostUntil)
{
if (!kv.Key.EndsWith("|amt") && now > kv.Value)
{
remove.Add(kv.Key);
remove.Add(kv.Key + "|amt");
}
}
for (int i = 0; i < remove.Count; i++)
{
CivicBoostUntil.Remove(remove[i]);
}
}
private static string MapHappinessCategory(string cat)
{
switch ((cat ?? "").ToLowerInvariant())
{
case "grief":
return "FamilyEmotion";
case "social":
return "Relationship";
case "lifechapter":
return "LifeChapter";
case "civic":
return "Politics";
default:
return "FamilyEmotion";
}
}
private static string CategoryForTier(InterestTier tier, string asset)
{
if (asset == "live_battle")
{
return "Combat";
}
if (tier >= InterestTier.Epic)
{
return "Politics";
}
if (tier == InterestTier.Story)
{
return "Settlement";
}
if (tier == InterestTier.Action)
{
return "Combat";
}
if (tier == InterestTier.Curiosity)
{
return "Discovery";
}
return "CharacterVignette";
}
private static string CorrBucket(string corr)
{
if (string.IsNullOrEmpty(corr))
{
return "0";
}
int hash = corr.GetHashCode();
return (hash & 0xFFFF).ToString("x");
}
private static Actor FindUnitById(long id)
{
if (id == 0)
{
return null;
}
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{
try
{
if (actor != null && actor.getID() == id)
{
return actor;
}
}
catch
{
// continue
}
}
return null;
}
private static long SafeId(Actor actor)
{
if (actor == null)
{
return 0;
}
try
{
return actor.getID();
}
catch
{
return 0;
}
}
private static string SafeName(Actor actor)
{
if (actor == null)
{
return "Someone";
}
try
{
string n = actor.getName();
if (!string.IsNullOrEmpty(n))
{
return n;
}
}
catch
{
// ignore
}
return actor.asset != null ? actor.asset.id : "creature";
}
}

View file

@ -0,0 +1,297 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Bounded merge/expiry store for <see cref="InterestCandidate"/>. Sole intake for the director.
/// </summary>
public static class InterestRegistry
{
public const int MaxCandidates = 96;
public const float DefaultTtlSeconds = 45f;
private static readonly object Gate = new object();
private static readonly Dictionary<string, InterestCandidate> ByKey =
new Dictionary<string, InterestCandidate>(128);
private static readonly List<InterestCandidate> Scratch = new List<InterestCandidate>(128);
public static int Count
{
get
{
lock (Gate)
{
return ByKey.Count;
}
}
}
/// <summary>Unselected candidates still eligible for scheduling.</summary>
public static int PendingCount
{
get
{
lock (Gate)
{
int n = 0;
foreach (KeyValuePair<string, InterestCandidate> kv in ByKey)
{
if (kv.Value != null && !kv.Value.Selected && kv.Value.HasValidPosition)
{
n++;
}
}
return n;
}
}
}
public static void Clear()
{
lock (Gate)
{
ByKey.Clear();
}
}
public static InterestCandidate Upsert(InterestCandidate incoming)
{
if (incoming == null || string.IsNullOrEmpty(incoming.Key) || !incoming.HasValidPosition)
{
return null;
}
float now = Time.unscaledTime;
if (incoming.CreatedAt <= 0f)
{
incoming.CreatedAt = now;
}
if (incoming.LastSeenAt <= 0f)
{
incoming.LastSeenAt = now;
}
if (incoming.ExpiresAt <= 0f)
{
incoming.ExpiresAt = now + DefaultTtlSeconds;
}
lock (Gate)
{
if (ByKey.TryGetValue(incoming.Key, out InterestCandidate existing) && existing != null)
{
existing.LastSeenAt = now;
existing.ExpiresAt = Mathf.Max(existing.ExpiresAt, incoming.ExpiresAt);
existing.EventStrength = Mathf.Max(existing.EventStrength, incoming.EventStrength);
existing.CharacterSignificance = Mathf.Max(
existing.CharacterSignificance, incoming.CharacterSignificance);
existing.VisualConfidence = Mathf.Max(existing.VisualConfidence, incoming.VisualConfidence);
existing.TotalScore = Mathf.Max(existing.TotalScore, incoming.TotalScore);
if (incoming.Urgency > existing.Urgency)
{
existing.Urgency = incoming.Urgency;
}
if (!string.IsNullOrEmpty(incoming.Label))
{
existing.Label = incoming.Label;
}
if (incoming.FollowUnit != null && incoming.FollowUnit.isAlive())
{
existing.FollowUnit = incoming.FollowUnit;
existing.Position = incoming.FollowUnit.current_position;
}
else if (incoming.Position != Vector3.zero)
{
existing.Position = incoming.Position;
}
if (incoming.RelatedUnit != null)
{
existing.RelatedUnit = incoming.RelatedUnit;
existing.RelatedId = incoming.RelatedId;
}
if (incoming.ForceActive)
{
existing.ForceActive = true;
}
existing.Selected = false;
return existing;
}
ByKey[incoming.Key] = incoming;
TrimLocked(now);
return incoming;
}
}
public static bool TryGet(string key, out InterestCandidate candidate)
{
lock (Gate)
{
return ByKey.TryGetValue(key ?? "", out candidate) && candidate != null;
}
}
public static void MarkSelected(string key)
{
if (string.IsNullOrEmpty(key))
{
return;
}
lock (Gate)
{
if (ByKey.TryGetValue(key, out InterestCandidate c) && c != null)
{
c.Selected = true;
}
}
}
public static void MarkUnselected(string key)
{
if (string.IsNullOrEmpty(key))
{
return;
}
lock (Gate)
{
if (ByKey.TryGetValue(key, out InterestCandidate c) && c != null)
{
c.Selected = false;
c.LastSeenAt = Time.unscaledTime;
}
}
}
public static void Remove(string key)
{
if (string.IsNullOrEmpty(key))
{
return;
}
lock (Gate)
{
ByKey.Remove(key);
}
}
public static void ExpireStale(float now)
{
lock (Gate)
{
Scratch.Clear();
foreach (KeyValuePair<string, InterestCandidate> kv in ByKey)
{
InterestCandidate c = kv.Value;
if (c == null || now >= c.ExpiresAt || !c.HasValidPosition)
{
Scratch.Add(c);
}
}
for (int i = 0; i < Scratch.Count; i++)
{
InterestCandidate c = Scratch[i];
if (c != null && !string.IsNullOrEmpty(c.Key))
{
ByKey.Remove(c.Key);
}
}
}
}
/// <summary>Copy live unselected candidates into <paramref name="dest"/> (cleared first).</summary>
public static void CopyPending(List<InterestCandidate> dest)
{
if (dest == null)
{
return;
}
dest.Clear();
lock (Gate)
{
foreach (KeyValuePair<string, InterestCandidate> kv in ByKey)
{
InterestCandidate c = kv.Value;
if (c == null || c.Selected || !c.HasValidPosition)
{
continue;
}
dest.Add(c);
}
}
}
public static bool HasPendingAtLeast(InterestTier minUrgency)
{
lock (Gate)
{
foreach (KeyValuePair<string, InterestCandidate> kv in ByKey)
{
InterestCandidate c = kv.Value;
if (c != null && !c.Selected && c.HasValidPosition && c.Urgency >= minUrgency)
{
return true;
}
}
return false;
}
}
private static void TrimLocked(float now)
{
if (ByKey.Count <= MaxCandidates)
{
return;
}
Scratch.Clear();
foreach (KeyValuePair<string, InterestCandidate> kv in ByKey)
{
if (kv.Value != null)
{
Scratch.Add(kv.Value);
}
}
Scratch.Sort((a, b) =>
{
int urg = ((int)b.Urgency).CompareTo((int)a.Urgency);
if (urg != 0)
{
return urg;
}
int score = b.TotalScore.CompareTo(a.TotalScore);
if (score != 0)
{
return score;
}
return b.LastSeenAt.CompareTo(a.LastSeenAt);
});
for (int i = MaxCandidates; i < Scratch.Count; i++)
{
InterestCandidate drop = Scratch[i];
if (drop != null && !string.IsNullOrEmpty(drop.Key))
{
ByKey.Remove(drop.Key);
}
}
}
}

View file

@ -0,0 +1,349 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>Cached enrichment for top-K finalists only.</summary>
public sealed class InterestMetadataSnapshot
{
public long UnitId;
public float CharacterSignificance;
public bool Favorite;
public bool King;
public bool Leader;
public int Kills;
public int Level;
public float Renown;
public string SpeciesId = "";
public string CityKey = "";
public string KingdomKey = "";
public ActivityBand Band = ActivityBand.Cold;
public float CapturedAt;
}
/// <summary>
/// Urgency-class mapping and within-class score breakdown.
/// </summary>
public static class InterestScoring
{
private static readonly Dictionary<long, InterestMetadataSnapshot> MetaCache =
new Dictionary<long, InterestMetadataSnapshot>(64);
private const float MetaCacheTtl = 2f;
private const int EnrichTopKLimit = 8;
public static void ClearCache()
{
MetaCache.Clear();
}
public static InterestTier UrgencyForHappiness(string effectId, Actor subject)
{
string id = effectId ?? "";
bool notable = IsNotable(subject);
if (id == "death_child" || id == "death_lover")
{
return notable ? InterestTier.Story : InterestTier.Action;
}
if (id == "death_best_friend" || id == "death_family_member")
{
return InterestTier.Action;
}
if (id == "got_saved" || id == "just_born" || id == "just_hatched"
|| id == "become_king" || id == "become_city_leader" || id == "become_clan_chief"
|| id == "just_found_house" || id == "new_home")
{
return InterestTier.Action;
}
return InterestTier.Curiosity;
}
public static float EventStrengthForHappiness(string effectId)
{
string id = effectId ?? "";
switch (id)
{
case "death_child":
return 95f;
case "death_lover":
return 88f;
case "death_best_friend":
return 78f;
case "death_family_member":
return 76f;
case "just_born":
case "just_hatched":
case "had_child":
case "just_had_child":
return 70f;
case "become_king":
case "become_city_leader":
case "become_clan_chief":
return 72f;
case "just_found_house":
case "new_home":
return 55f;
default:
return 40f;
}
}
public static InterestTier UrgencyForWorldLog(string assetId)
{
return WorldLogInterestTable.TryGetTier(assetId, out InterestTier tier)
? tier
: InterestTier.Ambient;
}
public static void EnrichTopK(List<InterestCandidate> candidates)
{
if (candidates == null || candidates.Count == 0)
{
return;
}
candidates.Sort(CompareByUrgencyThenScore);
int n = Mathf.Min(EnrichTopKLimit, candidates.Count);
float now = Time.unscaledTime;
for (int i = 0; i < n; i++)
{
InterestCandidate c = candidates[i];
if (c?.FollowUnit == null)
{
continue;
}
InterestMetadataSnapshot snap = GetOrBuildMeta(c.FollowUnit, now);
ApplyMeta(c, snap);
RecalcTotal(c);
}
}
public static void ScoreCheap(InterestCandidate c)
{
if (c == null)
{
return;
}
RecalcTotal(c);
}
public static void RecalcTotal(InterestCandidate c)
{
if (c == null)
{
return;
}
float urgencyWeight = 1f + (int)c.Urgency * 0.15f;
if (c.LeadKind == InterestLeadKind.EventLed)
{
c.TotalScore = c.EventStrength * urgencyWeight
+ c.CharacterSignificance * 0.35f
+ c.VisualConfidence * 20f
+ c.Novelty * 5f;
c.ScoreDetail =
$"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} vis={c.VisualConfidence:0.##} nov={c.Novelty:0.##}";
}
else
{
c.TotalScore = c.CharacterSignificance * urgencyWeight
+ c.EventStrength * 0.25f
+ c.VisualConfidence * 12f
+ c.Novelty * 5f;
c.ScoreDetail =
$"char={c.CharacterSignificance:0.#} evt={c.EventStrength:0.#} vis={c.VisualConfidence:0.##} nov={c.Novelty:0.##}";
}
}
public static InterestMetadataSnapshot GetOrBuildMeta(Actor actor, float now)
{
if (actor == null)
{
return null;
}
long id = 0;
try
{
id = actor.getID();
}
catch
{
return null;
}
if (MetaCache.TryGetValue(id, out InterestMetadataSnapshot cached)
&& now - cached.CapturedAt < MetaCacheTtl)
{
return cached;
}
var snap = new InterestMetadataSnapshot
{
UnitId = id,
CapturedAt = now,
Favorite = SafeBool(() => actor.isFavorite()),
King = SafeBool(() => actor.isKing()),
Leader = SafeBool(() => actor.isCityLeader()),
Kills = actor.data != null ? actor.data.kills : 0,
Level = SafeLevel(actor),
Renown = SafeFloat(() => actor.renown),
SpeciesId = actor.asset != null ? actor.asset.id : "",
CityKey = actor.city != null ? (actor.city.name ?? "") : "",
KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "",
Band = ActivityInterestTable.Classify(actor)
};
float sig = 0f;
if (snap.Favorite)
{
sig += 22f;
}
if (snap.King)
{
sig += 18f;
}
else if (snap.Leader)
{
sig += 14f;
}
sig += Mathf.Min(8f, snap.Renown / 120f);
sig += Mathf.Min(12f, snap.Kills * 0.35f);
sig += Mathf.Min(10f, snap.Level * 0.4f);
snap.CharacterSignificance = sig;
MetaCache[id] = snap;
return snap;
}
private static void ApplyMeta(InterestCandidate c, InterestMetadataSnapshot snap)
{
if (c == null || snap == null)
{
return;
}
c.CharacterSignificance = Mathf.Max(c.CharacterSignificance, snap.CharacterSignificance);
if (string.IsNullOrEmpty(c.SpeciesId))
{
c.SpeciesId = snap.SpeciesId;
}
if (string.IsNullOrEmpty(c.CityKey))
{
c.CityKey = snap.CityKey;
}
if (string.IsNullOrEmpty(c.KingdomKey))
{
c.KingdomKey = snap.KingdomKey;
}
if (snap.Band >= ActivityBand.Warm)
{
c.VisualConfidence = Mathf.Max(c.VisualConfidence, 0.65f);
}
if (snap.Band == ActivityBand.Hot)
{
c.VisualConfidence = Mathf.Max(c.VisualConfidence, 0.85f);
}
}
public static int CompareByUrgencyThenScore(InterestCandidate a, InterestCandidate b)
{
if (ReferenceEquals(a, b))
{
return 0;
}
if (a == null)
{
return 1;
}
if (b == null)
{
return -1;
}
int urg = ((int)b.Urgency).CompareTo((int)a.Urgency);
if (urg != 0)
{
return urg;
}
int score = b.TotalScore.CompareTo(a.TotalScore);
if (score != 0)
{
return score;
}
return b.LastSeenAt.CompareTo(a.LastSeenAt);
}
public static bool IsNotable(Actor actor)
{
if (actor == null)
{
return false;
}
try
{
if (actor.isFavorite() || actor.isKing() || actor.isCityLeader())
{
return true;
}
}
catch
{
// ignore
}
return false;
}
private static bool SafeBool(System.Func<bool> fn)
{
try
{
return fn();
}
catch
{
return false;
}
}
private static float SafeFloat(System.Func<float> fn)
{
try
{
return fn();
}
catch
{
return 0f;
}
}
private static int SafeLevel(Actor actor)
{
try
{
return actor.data != null ? actor.data.level : 0;
}
catch
{
return 0;
}
}
}

View file

@ -0,0 +1,259 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties.
/// </summary>
public static class InterestVariety
{
public const float EventLedTarget = 0.70f;
public const int MixWindow = 20;
public const float HardCooldownSeconds = 12f;
private static readonly List<InterestLeadKind> RecentMix = new List<InterestLeadKind>(MixWindow);
private static readonly Dictionary<string, float> CooldownUntil = new Dictionary<string, float>(64);
private static readonly System.Random Rng = new System.Random();
private static readonly List<InterestCandidate> EventPool = new List<InterestCandidate>(64);
private static readonly List<InterestCandidate> CharPool = new List<InterestCandidate>(64);
private static readonly List<InterestCandidate> Ranked = new List<InterestCandidate>(16);
/// <summary>Whether the last <see cref="Pick"/> saw both pools non-empty (for mix metering).</summary>
public static bool LastPickHadBothPools { get; private set; }
public static void Clear()
{
RecentMix.Clear();
CooldownUntil.Clear();
}
public static void NoteSelection(InterestCandidate chosen)
{
NoteSelection(chosen, updateMix: LastPickHadBothPools);
}
public static void NoteSelection(InterestCandidate chosen, bool updateMix)
{
if (chosen == null)
{
return;
}
if (updateMix)
{
RecentMix.Add(chosen.LeadKind);
while (RecentMix.Count > MixWindow)
{
RecentMix.RemoveAt(0);
}
}
float until = Time.unscaledTime + (updateMix ? HardCooldownSeconds : HardCooldownSeconds * 0.5f);
StampCooldown("unit:" + chosen.SubjectId, until);
if (!string.IsNullOrEmpty(chosen.SpeciesId))
{
StampCooldown("species:" + chosen.SpeciesId, until * 0.7f);
}
if (!string.IsNullOrEmpty(chosen.Category))
{
StampCooldown("cat:" + chosen.Category, until * 0.5f);
}
if (!string.IsNullOrEmpty(chosen.CityKey))
{
StampCooldown("city:" + chosen.CityKey, until * 0.6f);
}
if (!string.IsNullOrEmpty(chosen.KingdomKey))
{
StampCooldown("kingdom:" + chosen.KingdomKey, until * 0.6f);
}
if (!string.IsNullOrEmpty(chosen.RegionKey))
{
StampCooldown("region:" + chosen.RegionKey, until * 0.5f);
}
if (!string.IsNullOrEmpty(chosen.Verb))
{
StampCooldown("verb:" + chosen.Verb, until * 0.4f);
}
}
/// <summary>
/// Pick next candidate. Soft 70/30: prefer event-led when below target and pool non-empty.
/// Empty event pool → character-led without inventing events (mix meter not updated for empty-side windows).
/// </summary>
public static InterestCandidate Pick(List<InterestCandidate> pending, InterestCandidate current)
{
if (pending == null || pending.Count == 0)
{
return null;
}
EventPool.Clear();
CharPool.Clear();
float now = Time.unscaledTime;
for (int i = 0; i < pending.Count; i++)
{
InterestCandidate c = pending[i];
if (c == null || !c.HasValidPosition)
{
continue;
}
if (current != null && c.Key == current.Key)
{
continue;
}
if (c.LeadKind == InterestLeadKind.CharacterLed)
{
CharPool.Add(c);
}
else
{
EventPool.Add(c);
}
}
bool bothHadCandidates = EventPool.Count > 0 && CharPool.Count > 0;
bool preferEvent = EventPool.Count > 0;
if (bothHadCandidates)
{
// Empty mix biases event-led; otherwise prefer event until share reaches target.
preferEvent = MixSampleCount == 0 || EventShare() < EventLedTarget;
}
else if (EventPool.Count == 0)
{
preferEvent = false;
}
// Soft mix: empty event pool → character-led without inventing events.
// Mix meter is updated in NoteSelection only when both pools were non-empty at pick time.
LastPickHadBothPools = bothHadCandidates;
List<InterestCandidate> pool = preferEvent
? (EventPool.Count > 0 ? EventPool : CharPool)
: (CharPool.Count > 0 ? CharPool : EventPool);
if (pool.Count == 0)
{
return null;
}
Ranked.Clear();
for (int i = 0; i < pool.Count; i++)
{
InterestCandidate c = pool[i];
float penalty = NoveltyPenalty(c, now);
// Epic / favorites bypass most novelty.
if (c.Urgency >= InterestTier.Epic || InterestScoring.IsNotable(c.FollowUnit))
{
penalty *= 0.15f;
}
c.Novelty = Mathf.Clamp01(1f - penalty);
InterestScoring.RecalcTotal(c);
Ranked.Add(c);
}
Ranked.Sort(InterestScoring.CompareByUrgencyThenScore);
InterestScoring.EnrichTopK(Ranked);
int topN = Mathf.Min(3, Ranked.Count);
if (topN <= 0)
{
return null;
}
// Probabilistic among top-N after penalties.
int pick = Rng.Next(topN);
return Ranked[pick];
}
public static float EventShare()
{
if (RecentMix.Count == 0)
{
return EventLedTarget;
}
int events = 0;
for (int i = 0; i < RecentMix.Count; i++)
{
if (RecentMix[i] == InterestLeadKind.EventLed)
{
events++;
}
}
return events / (float)RecentMix.Count;
}
public static int MixSampleCount => RecentMix.Count;
private static float NoveltyPenalty(InterestCandidate c, float now)
{
float p = 0f;
p += CooldownWeight("unit:" + c.SubjectId, now, 0.55f);
if (!string.IsNullOrEmpty(c.SpeciesId))
{
p += CooldownWeight("species:" + c.SpeciesId, now, 0.25f);
}
if (!string.IsNullOrEmpty(c.Category))
{
p += CooldownWeight("cat:" + c.Category, now, 0.2f);
}
if (!string.IsNullOrEmpty(c.CityKey))
{
p += CooldownWeight("city:" + c.CityKey, now, 0.2f);
}
if (!string.IsNullOrEmpty(c.KingdomKey))
{
p += CooldownWeight("kingdom:" + c.KingdomKey, now, 0.2f);
}
if (!string.IsNullOrEmpty(c.Verb))
{
p += CooldownWeight("verb:" + c.Verb, now, 0.15f);
}
return Mathf.Clamp01(p);
}
private static float CooldownWeight(string key, float now, float weight)
{
if (!CooldownUntil.TryGetValue(key, out float until) || now >= until)
{
return 0f;
}
float remain = until - now;
float frac = Mathf.Clamp01(remain / HardCooldownSeconds);
return weight * frac;
}
private static void StampCooldown(string key, float until)
{
if (string.IsNullOrEmpty(key))
{
return;
}
if (CooldownUntil.TryGetValue(key, out float existing))
{
CooldownUntil[key] = Mathf.Max(existing, until);
}
else
{
CooldownUntil[key] = until;
}
}
}

View file

@ -58,6 +58,100 @@ public static class WorldActivityScanner
return Clone(_cachedBattle);
}
/// <summary>Register up to <paramref name="max"/> character-led vignette seeds into the registry.</summary>
public static void RegisterTopCharacterCandidates(int max = 4)
{
EnsureScanned();
Dictionary<string, int> speciesCounts = CountSpeciesPopulations();
List<Actor> alive = new List<Actor>(128);
foreach (Actor actor in EnumerateAliveUnits())
{
if (actor?.asset != null && actor.asset.is_boat)
{
continue;
}
if (actor != null && actor.isAlive())
{
alive.Add(actor);
}
}
if (alive.Count == 0)
{
return;
}
alive.Sort((a, b) => ScoreActor(b, speciesCounts).CompareTo(ScoreActor(a, speciesCounts)));
int n = Mathf.Min(max, alive.Count);
for (int i = 0; i < n; i++)
{
Actor actor = alive[i];
float score = ScoreActor(actor, speciesCounts);
ActivityBand band = ActivityInterestTable.Classify(actor);
InterestTier tier = InterestTier.Ambient;
InterestLeadKind lead = InterestLeadKind.CharacterLed;
if (band == ActivityBand.Hot || actor.has_attack_target)
{
tier = InterestTier.Action;
lead = InterestLeadKind.EventLed;
}
else if (band >= ActivityBand.Warm && (actor.isFavorite() || actor.isKing() || actor.isCityLeader()))
{
tier = InterestTier.Story;
lead = InterestLeadKind.EventLed;
}
else if (IsSpectacle(actor) || IsSingletonSpecies(actor, speciesCounts))
{
tier = InterestTier.Curiosity;
}
long id = 0;
try
{
id = actor.getID();
}
catch
{
continue;
}
string key = lead == InterestLeadKind.CharacterLed
? "vignette:" + id + ":" + (actor.asset != null ? actor.asset.id : "unit")
: "scan:" + tier + ":" + id;
var candidate = new InterestCandidate
{
Key = key,
Urgency = tier,
LeadKind = lead,
Category = lead == InterestLeadKind.CharacterLed ? "CharacterVignette" : "Combat",
Source = "scanner",
EventStrength = lead == InterestLeadKind.EventLed ? score : score * 0.3f,
CharacterSignificance = score,
VisualConfidence = band >= ActivityBand.Warm ? 0.7f : 0.4f,
Position = actor.current_position,
FollowUnit = actor,
SubjectId = id,
Label = FormatUnitLabel(actor, speciesCounts),
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 ?? "") : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 18f,
MinWatch = 2f,
MaxWatch = 22f,
Completion = lead == InterestLeadKind.EventLed
? InterestCompletionKind.CombatActive
: InterestCompletionKind.CharacterVignette
};
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
}
}
private static void EnsureScanned()
{
if (Time.unscaledTime - _lastScanAt < ScanInterval)

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.13.8",
"description": "AFK Idle Spectator (I) + Lore (L). Exhaustive happiness/emotion Activity + Life logging.",
"version": "0.14.14",
"description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.",
"GUID": "com.dazed.idlespectator"
}