diff --git a/IdleSpectator/ActivityLog.cs b/IdleSpectator/ActivityLog.cs index 4aab270..7c69526 100644 --- a/IdleSpectator/ActivityLog.cs +++ b/IdleSpectator/ActivityLog.cs @@ -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 + } } /// @@ -584,6 +602,15 @@ public static class ActivityLog { // ignore } + + try + { + InterestFeeds.OnStatusChange(actor, statusId.Trim(), gained); + } + catch + { + // ignore + } } /// Harness / forced path for status lines without a live transition. @@ -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; } diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 5d39b83..909be24 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -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(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); diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs index cf19d4b..eeb09a0 100644 --- a/IdleSpectator/Chronicle.cs +++ b/IdleSpectator/Chronicle.cs @@ -1240,6 +1240,15 @@ public static class Chronicle { AppendHistory(ChronicleKind.Kill, killer, victim, line); } + + try + { + InterestFeeds.OnChronicleMilestone(killer, "milestone_kill", victim, line); + } + catch + { + // ignore + } } /// Victim POV - Chronicle Life when enabled; always mirrors to Activity. @@ -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 + } } /// - /// 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. /// public static bool NoteHappinessLife(HappinessOccurrence occ, Actor subject, Actor related = null) diff --git a/IdleSpectator/HappinessEventRouter.cs b/IdleSpectator/HappinessEventRouter.cs index 18da382..8ce1232 100644 --- a/IdleSpectator/HappinessEventRouter.cs +++ b/IdleSpectator/HappinessEventRouter.cs @@ -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 _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; + } + + /// Push listener for spectator interest intake (in addition to ). + public static void Subscribe(Action handler) + { + if (handler == null) + { + return; + } + + _subscribers += handler; + } + + public static void Unsubscribe(Action handler) + { + if (handler == null) + { + return; + } + + _subscribers -= handler; + } + + /// + /// Drain occurrences with Sequence > . + /// Returns the highest sequence drained (pass back next call). Diagnostics may still use . + /// + public static ulong DrainSince(ulong sinceSeq, List 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 handlers = _subscribers; + if (handlers != null) + { + try + { + handlers(occ); + } + catch + { + // Interest consumers must not break happiness logging. + } + } + } + + /// Harness-only: assign sequence and remember an already-built occurrence. + public static void RememberForHarness(HappinessOccurrence occ) + { + if (occ == null) + { + return; + } + + occ.Applied = true; + Remember(occ); } private static bool IsNotable(Actor actor) diff --git a/IdleSpectator/HappinessModels.cs b/IdleSpectator/HappinessModels.cs index 48c1ea6..8d243f1 100644 --- a/IdleSpectator/HappinessModels.cs +++ b/IdleSpectator/HappinessModels.cs @@ -76,6 +76,8 @@ public sealed class HappinessCatalogEntry /// Normalized happiness/emotion occurrence for Activity, Life, and future spectator interest. public sealed class HappinessOccurrence { + /// Monotonic id for . + public ulong Sequence; public string EffectId = ""; public int Amount; public long SubjectId; diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index e55ac3d..93d29dc 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -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 DirectorLifecycle() + { + return new List + { + 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 InterestHappiness() + { + return new List + { + 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 Discovery() { return new List diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs new file mode 100644 index 0000000..40a5bcb --- /dev/null +++ b/IdleSpectator/InterestCandidate.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace IdleSpectator; + +public enum InterestLeadKind +{ + EventLed = 0, + CharacterLed = 1 +} + +public enum InterestCompletionKind +{ + /// One-shot narrative dwell (WorldLog, harness tips). + FixedDwell = 0, + /// Combat / hunt / fire while participants stay live. + CombatActive = 1, + /// Owning AI task / beat still live. + ActivityActive = 2, + /// Grief / family emotion with survivor + optional crying. + HappinessGrief = 3, + /// Status phase still present on subject. + StatusPhase = 4, + /// Character vignette until max watch or cold activity. + CharacterVignette = 5, + /// Harness-forced session; active until released or max cap. + Manual = 6 +} + +/// +/// Durable interest scene candidate. Urgency () is preemption class only; +/// ranks within a class. +/// +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 ParticipantIds = new List(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; + } +} diff --git a/IdleSpectator/InterestCollector.cs b/IdleSpectator/InterestCollector.cs index df77836..b0e3e4d 100644 --- a/IdleSpectator/InterestCollector.cs +++ b/IdleSpectator/InterestCollector.cs @@ -1,138 +1,45 @@ -using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; +/// +/// Compatibility shim: WorldLog / discovery / harness still call here. +/// Selection goes only through (no dual-feed queue). +/// public static class InterestCollector { - private static readonly object Gate = new object(); - private static readonly List Pending = new List(); - 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 + /// Obsolete: director no longer pops from a queue. Kept for compile safety. + public static bool TryGetBest(out InterestEvent best) { - get - { - lock (Gate) - { - return Pending.Count; - } - } + best = null; + return false; } - public static bool HasPendingAtLeast(InterestTier minTier) + /// Obsolete no-op. + 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. } } diff --git a/IdleSpectator/InterestCompletion.cs b/IdleSpectator/InterestCompletion.cs new file mode 100644 index 0000000..cd2bd23 --- /dev/null +++ b/IdleSpectator/InterestCompletion.cs @@ -0,0 +1,168 @@ +using UnityEngine; + +namespace IdleSpectator; + +/// Evaluates whether an interest scene is still "live" for protection. +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; + } + } +} diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 83e74b8..e67d60c 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -1,10 +1,12 @@ +using System.Collections.Generic; using NeoModLoader.services; using UnityEngine; namespace IdleSpectator; /// -/// 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 . /// 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; - /// Leave a Cold (idle) focus sooner so interesting work elsewhere can win. - 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 PendingScratch = new List(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; - /// - /// Harness-only: shorten dwell/rotate so director tests finish in seconds. - /// Game 5x speed does not help - this code uses . - /// 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; } } - /// Harness: pretend the current interest started ago. public static void HarnessAgeCurrent(float seconds) { float age = Mathf.Max(0f, seconds); @@ -92,36 +109,54 @@ public static class InterestDirector _lastSwitchAt = now - age; } - /// Harness: expire input-exit grace immediately. public static void HarnessExpireInputGrace() { _enabledAt = Time.unscaledTime - _inputExitGrace - 0.05f; } + /// Harness: force the given candidate as the active session. + 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(); } - /// - /// Stop idle auto-follow the same way manual input does (quiet), for Lore history browsing. - /// Prefer which focuses first then banners. - /// 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); } /// @@ -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); } - /// - /// Harness: pulse camera-drag (or honorGrace path) and take the real manual-exit path. - /// When is true, input during the post-enable grace does not exit. - /// 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; + } + + /// + /// Protected Action+ scenes: only Epic may preempt after settle grace. + /// Ambient/Curiosity may be replaced by higher urgency after settle. + /// + 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); } } diff --git a/IdleSpectator/InterestFeeds.cs b/IdleSpectator/InterestFeeds.cs new file mode 100644 index 0000000..0d4bd53 --- /dev/null +++ b/IdleSpectator/InterestFeeds.cs @@ -0,0 +1,772 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Converts live streams into candidates. +/// Sole path for WorldLog / happiness / scanner / activity / status / chronicle → registry. +/// +public static class InterestFeeds +{ + private static ulong _happinessCursor; + private static readonly List HappinessDrain = new List(64); + private static float _lastScannerAt = -999f; + private const float ScannerInterval = 0.75f; + + // Civic aggregate boosts keyed by kingdom+effect. + private static readonly Dictionary CivicBoostUntil = new Dictionary(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); + } + + /// Legacy / discovery / harness enqueue path → registry. + 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); + } + + /// Harness / push path: ingest one occurrence into the registry now. + public static void IngestForHarness(HappinessOccurrence occ) + { + IngestHappiness(occ); + if (occ != null && occ.Sequence > _happinessCursor) + { + _happinessCursor = occ.Sequence; + } + } + + /// Direct registry insert for harness happiness signals (skips drain filters). + 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(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(); + foreach (KeyValuePair 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"; + } +} diff --git a/IdleSpectator/InterestRegistry.cs b/IdleSpectator/InterestRegistry.cs new file mode 100644 index 0000000..104a884 --- /dev/null +++ b/IdleSpectator/InterestRegistry.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Bounded merge/expiry store for . Sole intake for the director. +/// +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 ByKey = + new Dictionary(128); + private static readonly List Scratch = new List(128); + + public static int Count + { + get + { + lock (Gate) + { + return ByKey.Count; + } + } + } + + /// Unselected candidates still eligible for scheduling. + public static int PendingCount + { + get + { + lock (Gate) + { + int n = 0; + foreach (KeyValuePair 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 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); + } + } + } + } + + /// Copy live unselected candidates into (cleared first). + public static void CopyPending(List dest) + { + if (dest == null) + { + return; + } + + dest.Clear(); + lock (Gate) + { + foreach (KeyValuePair 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 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 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); + } + } + } +} diff --git a/IdleSpectator/InterestScoring.cs b/IdleSpectator/InterestScoring.cs new file mode 100644 index 0000000..693d18d --- /dev/null +++ b/IdleSpectator/InterestScoring.cs @@ -0,0 +1,349 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace IdleSpectator; + +/// Cached enrichment for top-K finalists only. +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; +} + +/// +/// Urgency-class mapping and within-class score breakdown. +/// +public static class InterestScoring +{ + private static readonly Dictionary MetaCache = + new Dictionary(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 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 fn) + { + try + { + return fn(); + } + catch + { + return false; + } + } + + private static float SafeFloat(System.Func 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; + } + } +} diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs new file mode 100644 index 0000000..d6171a0 --- /dev/null +++ b/IdleSpectator/InterestVariety.cs @@ -0,0 +1,259 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties. +/// +public static class InterestVariety +{ + public const float EventLedTarget = 0.70f; + public const int MixWindow = 20; + public const float HardCooldownSeconds = 12f; + + private static readonly List RecentMix = new List(MixWindow); + private static readonly Dictionary CooldownUntil = new Dictionary(64); + private static readonly System.Random Rng = new System.Random(); + + private static readonly List EventPool = new List(64); + private static readonly List CharPool = new List(64); + private static readonly List Ranked = new List(16); + + /// Whether the last saw both pools non-empty (for mix metering). + 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); + } + } + + /// + /// 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). + /// + public static InterestCandidate Pick(List 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 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; + } + } +} diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index d551f73..2eab86f 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -58,6 +58,100 @@ public static class WorldActivityScanner return Clone(_cachedBattle); } + /// Register up to character-led vignette seeds into the registry. + public static void RegisterTopCharacterCandidates(int max = 4) + { + EnsureScanned(); + Dictionary speciesCounts = CountSpeciesPopulations(); + List alive = new List(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) diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 207b2ca..070ac8f 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -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" }