From 1d2e8300130c1cb3b0ae7139d40978e07af47552 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 15 Jul 2026 18:26:57 -0500 Subject: [PATCH] Refactor interest management in AgentHarness to replace InterestTier with score-based handling. Update scoring logic across components to prioritize TotalScore for event processing and session control. Revise harness scenarios for improved clarity and accuracy in event representation. Increment version in mod.json to reflect these changes. --- IdleSpectator/AgentHarness.cs | 167 ++++++++++++++++++------- IdleSpectator/CameraDirector.cs | 3 +- IdleSpectator/HarnessScenarios.cs | 59 +++++---- IdleSpectator/InterestCandidate.cs | 12 +- IdleSpectator/InterestCollector.cs | 4 +- IdleSpectator/InterestDirector.cs | 125 ++++++++++-------- IdleSpectator/InterestEvent.cs | 5 +- IdleSpectator/InterestFeeds.cs | 86 ++++++------- IdleSpectator/InterestRegistry.cs | 25 +--- IdleSpectator/InterestScoring.cs | 72 ++++------- IdleSpectator/InterestScoringConfig.cs | 12 +- IdleSpectator/InterestTier.cs | 18 --- IdleSpectator/InterestVariety.cs | 2 +- IdleSpectator/UnitDossier.cs | 35 ++---- IdleSpectator/WatchCaption.cs | 6 +- IdleSpectator/WorldActivityScanner.cs | 29 +---- IdleSpectator/WorldLogInterestTable.cs | 70 ++++++----- IdleSpectator/WorldLogPatches.cs | 5 +- IdleSpectator/mod.json | 2 +- IdleSpectator/scoring-model.json | 35 ++++-- 20 files changed, 391 insertions(+), 381 deletions(-) delete mode 100644 IdleSpectator/InterestTier.cs diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index f988ca5..3d45a63 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -179,7 +179,7 @@ public static class AgentHarness float seconds = cmd.wait > 0f ? cmd.wait : ParseFloat(cmd.value, 2f); InterestDirector.HarnessAgeCurrent(seconds); _cmdOk++; - Emit(cmd, ok: true, detail: $"aged={seconds:0.##}s tier={InterestDirector.CurrentTierName}"); + Emit(cmd, ok: true, detail: $"aged={seconds:0.##}s score={InterestDirector.CurrentScoreLabel}"); break; } @@ -1949,7 +1949,7 @@ public static class AgentHarness ok: true, detail: peek == null ? "peek=null" - : $"peek={peek.Key} lead={peek.LeadKind} tier={peek.Urgency} label={peek.Label}"); + : $"peek={peek.Key} lead={peek.LeadKind} score={peek.TotalScore:0.#} label={peek.Label}"); break; } @@ -2337,7 +2337,8 @@ public static class AgentHarness return; } - InterestTier tier = ParseTier(cmd.tier, InterestTier.Curiosity); + float tierEvt = EventStrengthFromTierHint(cmd.tier, 40f); + InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.CharacterLed); string resolvedAsset = follow.asset != null ? follow.asset.id : assetId; string label = string.IsNullOrEmpty(cmd.label) ? $"Harness: {resolvedAsset}" @@ -2349,8 +2350,7 @@ public static class AgentHarness InterestEvent interest = new InterestEvent { - Tier = tier, - Score = 50f, + Score = tierEvt, Position = follow.current_position, FollowUnit = follow, Label = label, @@ -2391,37 +2391,49 @@ public static class AgentHarness return; } + float tierEvt = EventStrengthFromTierHint(cmd.tier, 40f); + InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.CharacterLed); + string tipLabel = string.IsNullOrEmpty(cmd.label) + ? $"Harness {cmd.tier ?? "scene"}" + : cmd.label.Replace("{asset}", follow.asset != null ? follow.asset.id : (assetId ?? "")); + InterestEvent interest = new InterestEvent { - Tier = ParseTier(cmd.tier, InterestTier.Curiosity), - Score = 40f, + Score = tierEvt, Position = follow.current_position, FollowUnit = follow, - Label = string.IsNullOrEmpty(cmd.label) - ? $"Harness {ParseTier(cmd.tier, InterestTier.Curiosity)}" - : cmd.label.Replace("{asset}", follow.asset != null ? follow.asset.id : (assetId ?? "")), + Label = tipLabel, CreatedAt = Time.unscaledTime, AssetId = follow.asset != null ? follow.asset.id : assetId }; - InterestTier tier = interest.Tier; + bool forceCamera = tierLead == InterestLeadKind.EventLed + && tierEvt >= InterestScoringConfig.W.noticeScoreMin; InterestCandidate registered = InterestFeeds.RegisterHarness( follow, - tier, interest.Label, - keySuffix: (interest.Label ?? tier.ToString()).Replace(" ", "_"), - lead: tier >= InterestTier.Action ? InterestLeadKind.EventLed : InterestLeadKind.CharacterLed, + keySuffix: (interest.Label ?? "scene").Replace(" ", "_"), + lead: tierLead, completion: InterestCompletionKind.FixedDwell, - forceActive: tier >= InterestTier.Action, - eventStrength: 40f + (int)tier * 20f, - maxWatch: tier >= InterestTier.Epic ? 40f : 25f); + forceActive: forceCamera, + eventStrength: tierEvt, + maxWatch: tierEvt >= 95f ? 40f : 25f); if (registered == null) { InterestCollector.EnqueueDirect(interest); } + else if (forceCamera) + { + // Event-led Action/Epic tips may take the camera; character-led Story/curiosity stay pending. + InterestDirector.HarnessForceSession(registered); + CameraDirector.Watch(registered.ToInterestEvent()); + } _cmdOk++; - Emit(cmd, ok: true, detail: $"queued tier={tier} label={interest.Label} pending={InterestCollector.PendingCount}"); + Emit( + cmd, + ok: true, + detail: $"triggered label={tipLabel} evt={tierEvt:0.#} forced={forceCamera}"); } private static void DoInterestInject(HarnessCommand cmd) @@ -2446,8 +2458,9 @@ public static class AgentHarness return; } - InterestTier tier = ParseTier(cmd.tier, InterestTier.Action); - string label = string.IsNullOrEmpty(cmd.label) ? ("Inject " + tier) : cmd.label; + float tierEvt = EventStrengthFromTierHint(cmd.tier, 70f); + InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.EventLed); + string label = string.IsNullOrEmpty(cmd.label) ? ("Inject " + (cmd.tier ?? "scene")) : cmd.label; string keySuffix = string.IsNullOrEmpty(cmd.expect) ? label.Replace(" ", "_") : cmd.expect; ParseInterestInjectOptions( cmd.value, @@ -2461,13 +2474,10 @@ public static class AgentHarness out int participants, out int notables); - InterestLeadKind lead = leadOpt - ?? (tier >= InterestTier.Curiosity && tier < InterestTier.Action - ? InterestLeadKind.CharacterLed - : InterestLeadKind.EventLed); + InterestLeadKind lead = leadOpt ?? tierLead; if (eventStrength < 0f) { - eventStrength = 50f + (int)tier * 15f; + eventStrength = tierEvt; } if (charSig < 0f) @@ -2487,7 +2497,6 @@ public static class AgentHarness InterestCandidate c = InterestFeeds.RegisterHarness( follow, - tier, label, keySuffix, lead, @@ -2533,7 +2542,7 @@ public static class AgentHarness Emit( cmd, ok: true, - detail: $"injected key={c.Key} tier={c.Urgency} lead={c.LeadKind} evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={c.ParticipantCount}/{c.NotableParticipantCount} score={c.TotalScore:0.#} pending={InterestRegistry.PendingCount}"); + detail: $"injected key={c.Key} score={c.TotalScore:0.#} lead={c.LeadKind} evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={c.ParticipantCount}/{c.NotableParticipantCount} score={c.TotalScore:0.#} pending={InterestRegistry.PendingCount}"); } private static void ParseInterestInjectOptions( @@ -2669,19 +2678,24 @@ public static class AgentHarness return; } - InterestTier tier = ParseTier(cmd.tier, InterestTier.Action); - string label = string.IsNullOrEmpty(cmd.label) ? ("Force " + tier) : cmd.label; + float tierEvt = EventStrengthFromTierHint(cmd.tier, 70f); + InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.EventLed); + string label = string.IsNullOrEmpty(cmd.label) ? ("Force " + (cmd.tier ?? "scene")) : cmd.label; string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "force_" + label.Replace(" ", "_") : cmd.expect; InterestCandidate c = InterestFeeds.RegisterHarness( follow, - tier, label, keySuffix, - lead: InterestLeadKind.EventLed, + lead: tierLead, completion: InterestCompletionKind.Manual, forceActive: true, - eventStrength: 80f + (int)tier * 10f, + eventStrength: tierEvt > 0f ? tierEvt : 70f, maxWatch: 60f); + if (c != null && tierLead == InterestLeadKind.CharacterLed) + { + c.Category = "CharacterVignette"; + InterestScoring.ScoreCheap(c); + } bool ok = InterestDirector.HarnessForceSession(c); if (ok) { @@ -2692,7 +2706,7 @@ public static class AgentHarness _cmdFail++; } - Emit(cmd, ok, detail: $"key={InterestDirector.CurrentKey} tier={InterestDirector.CurrentTierName} active={InterestDirector.CurrentIsActive}"); + Emit(cmd, ok, detail: $"key={InterestDirector.CurrentKey} score={InterestDirector.CurrentScoreLabel} active={InterestDirector.CurrentIsActive}"); } private static void DoSetSetting(HarnessCommand cmd) @@ -2853,11 +2867,43 @@ public static class AgentHarness } case "current_tier": { - string want = (cmd.value ?? cmd.tier ?? "").Trim(); - string have = InterestDirector.CurrentTierName; - pass = !string.IsNullOrEmpty(want) - && string.Equals(have, want, System.StringComparison.OrdinalIgnoreCase); - detail = $"tier={have} want={want} label={InterestDirector.CurrentLabel}"; + // Legacy name: prefer tip/label match; else score band from old tier names. + string want = cmd.value ?? ""; + string label = InterestDirector.CurrentLabel ?? ""; + string tip = CameraDirector.LastWatchLabel ?? ""; + float score = InterestDirector.CurrentScore; + bool byText = (!string.IsNullOrEmpty(want) + && (label.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0 + || tip.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0)); + bool byBand = false; + string band = want.Trim().ToLowerInvariant(); + if (band == "ambient") + { + byBand = InterestScoring.IsFillScore(score); + } + else if (band == "curiosity") + { + byBand = score >= 35f && score < InterestScoringConfig.W.noticeScoreMin; + } + else if (band == "action") + { + byBand = score >= InterestScoringConfig.W.noticeScoreMin; + } + else if (band == "story") + { + byBand = score >= 75f; + } + else if (band == "epic") + { + byBand = InterestScoring.IsHotScore(score) || score >= 95f; + } + else if (band == "none") + { + byBand = InterestDirector.CurrentCandidate == null; + } + + pass = byText || byBand; + detail = $"want='{want}' label='{label}' tip='{tip}' score={score:0.#} text={byText} band={byBand}"; break; } case "would_accept_curiosity": @@ -2865,7 +2911,7 @@ public static class AgentHarness bool want = ParseBool(cmd.value, defaultValue: true); bool have = InterestDirector.WouldAcceptCuriosity(); pass = have == want; - detail = $"would_accept={have} want={want} tier={InterestDirector.CurrentTierName}"; + detail = $"would_accept={have} want={want} score={InterestDirector.CurrentScoreLabel}"; break; } case "focus_same": @@ -5146,7 +5192,7 @@ public static class AgentHarness private static void DoSnapshot(HarnessCommand cmd) { string snap = - $"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount} tier={InterestDirector.CurrentTierName} discoveryPending={SpeciesDiscovery.PendingCount} caption={WatchCaption.LastHeadline} chronicle={Chronicle.Count}"; + $"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount} score={InterestDirector.CurrentScoreLabel} discoveryPending={SpeciesDiscovery.PendingCount} caption={WatchCaption.LastHeadline} chronicle={Chronicle.Count}"; _cmdOk++; Emit(cmd, ok: true, detail: snap); LogService.LogInfo("[IdleSpectator][SNAP] " + snap); @@ -5219,8 +5265,7 @@ public static class AgentHarness InterestEvent interest = new InterestEvent { - Tier = ParseTier(cmd.tier, InterestTier.Curiosity), - Score = 50f, + Score = 50f, Position = near.current_position, FollowUnit = null, Label = label, @@ -5557,21 +5602,53 @@ public static class AgentHarness return actor.asset != null ? actor.asset.id : "unit"; } - private static InterestTier ParseTier(string raw, InterestTier fallback) + + private static float EventStrengthFromTierHint(string raw, float fallback) { if (string.IsNullOrEmpty(raw)) { return fallback; } - if (Enum.TryParse(raw, ignoreCase: true, out InterestTier tier)) + switch (raw.Trim().ToLowerInvariant()) { - return tier; + case "ambient": + return 25f; + case "curiosity": + return 40f; + case "action": + return 70f; + case "story": + return 80f; + case "epic": + return 100f; + default: + return fallback; + } + } + + private static InterestLeadKind LeadFromTierHint(string raw, InterestLeadKind fallback) + { + if (string.IsNullOrEmpty(raw)) + { + return fallback; + } + + string s = raw.Trim().ToLowerInvariant(); + if (s == "ambient" || s == "curiosity" || s == "story") + { + return InterestLeadKind.CharacterLed; + } + + if (s == "action" || s == "epic") + { + return InterestLeadKind.EventLed; } return fallback; } + private static int ParseCountExpect(HarnessCommand cmd, int defaultValue) { // Prefer explicit value= so Step()'s default count=1 does not override want=0. diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs index a6475ae..ab998e8 100644 --- a/IdleSpectator/CameraDirector.cs +++ b/IdleSpectator/CameraDirector.cs @@ -84,11 +84,10 @@ public static class CameraDirector public static string FormatWatchTip(InterestEvent interest) { - string tier = interest.Tier.ToString(); string reason = string.IsNullOrEmpty(interest.Label) ? "something interesting" : interest.Label; - return $"Watching [{tier}]: {reason}"; + return $"Watching: {reason}"; } public static void ClearFollow() diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 9af2704..ff6cd9c 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -585,24 +585,20 @@ internal static class HarnessScenarios Step("dt10", "trigger_interest", asset: "sheep", label: "CurioA", tier: "Curiosity"), Step("dt11", "age_current", wait: 2f), Step("dt12", "director_run", wait: 1.2f), - Step("dt13", "assert", expect: "current_tier", value: "Curiosity"), - Step("dt14", "assert", expect: "tip_contains", value: "CurioA"), + Step("dt13", "assert", expect: "tip_contains", value: "CurioA"), - // Action interrupts curiosity after high-tier window. + // Action interrupts curiosity after settle. Step("dt20", "trigger_interest", asset: "auto", label: "ActionA", tier: "Action"), Step("dt21", "age_current", wait: 1.2f), Step("dt22", "director_run", wait: 1.2f), - Step("dt23", "assert", expect: "current_tier", value: "Action"), - Step("dt24", "assert", expect: "tip_contains", value: "ActionA"), + Step("dt23", "assert", expect: "tip_contains", value: "ActionA"), - // Curiosity must not be accepted while Action holds the camera (no director_run: - // ambient rotate would otherwise steal Action after dwell). + // Curiosity must not steal the camera while Action holds (no director_run). Step("dt30", "trigger_interest", asset: "auto", label: "CurioBlocked", tier: "Curiosity"), - Step("dt31", "assert", expect: "current_tier", value: "Action"), - Step("dt32", "assert", expect: "tip_contains", value: "ActionA"), - Step("dt33", "assert", expect: "would_accept_curiosity", value: "false"), - Step("dt34", "assert", expect: "health"), - Step("dt35", "assert", expect: "no_bad"), + Step("dt31", "assert", expect: "tip_contains", value: "ActionA"), + Step("dt32", "assert", expect: "would_accept_curiosity", value: "false"), + Step("dt33", "assert", expect: "health"), + Step("dt34", "assert", expect: "no_bad"), Step("dt90", "fast_timing", value: "false"), Step("dt99", "snapshot"), @@ -624,19 +620,19 @@ internal static class HarnessScenarios // 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("dl11", "assert", expect: "tip_contains", value: "HoldAction"), 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("dl16", "assert", expect: "tip_contains", value: "HoldAction"), 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("dl23", "assert", expect: "tip_contains", value: "EpicWin"), Step("dl24", "assert", expect: "tip_contains", value: "EpicWin"), Step("dl25", "assert", expect: "no_bad"), @@ -664,20 +660,20 @@ internal static class HarnessScenarios 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("ih14", "assert", expect: "tip_contains", value: "ActionHold"), 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("ih22", "assert", expect: "tip_contains", value: "ActionHold"), 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("ih33", "assert", expect: "tip_contains", value: "EpicClear"), Step("ih34", "assert", expect: "no_bad"), Step("ih90", "fast_timing", value: "false"), @@ -704,16 +700,16 @@ internal static class HarnessScenarios // --- Epic preempts Story; Story does not preempt itself past Action rules --- Step("dg10", "interest_force_session", asset: "sheep", label: "HoldStory", tier: "Story", expect: "hold_story"), - Step("dg11", "assert", expect: "current_tier", value: "Story"), + Step("dg11", "assert", expect: "tip_contains", value: "HoldStory"), Step("dg12", "trigger_interest", asset: "sheep", label: "ActionBlocked", tier: "Action"), Step("dg13", "age_current", wait: 1.2f), Step("dg14", "director_run", wait: 1.2f), - Step("dg15", "assert", expect: "current_tier", value: "Story"), + Step("dg15", "assert", expect: "tip_contains", value: "HoldStory"), Step("dg16", "assert", expect: "tip_contains", value: "HoldStory"), Step("dg17", "trigger_interest", asset: "sheep", label: "EpicOverStory", tier: "Epic"), Step("dg18", "age_current", wait: 1.2f), Step("dg19", "director_run", wait: 1.2f), - Step("dg20", "assert", expect: "current_tier", value: "Epic"), + Step("dg20", "assert", expect: "tip_contains", value: "EpicOverStory"), Step("dg21", "assert", expect: "tip_contains", value: "EpicOverStory"), // --- Resume after Epic: Action hold interrupted then restored --- @@ -725,13 +721,13 @@ internal static class HarnessScenarios Step("dg35", "trigger_interest", asset: "sheep", label: "EpicInterrupt", tier: "Epic"), Step("dg36", "age_current", wait: 1.2f), Step("dg37", "director_run", wait: 1.2f), - Step("dg38", "assert", expect: "current_tier", value: "Epic"), + Step("dg38", "assert", expect: "tip_contains", value: "EpicInterrupt"), Step("dg39", "assert", expect: "interrupted_key", value: "hold_resume"), // Max-cap the Epic scene (fast timing caps ~8s) so EndCurrent resumes Action. Step("dg40", "age_current", wait: 9f), Step("dg41", "director_run", wait: 1.2f), Step("dg42", "assert", expect: "tip_contains", value: "HoldResume"), - Step("dg43", "assert", expect: "current_tier", value: "Action"), + Step("dg43", "assert", expect: "tip_contains", value: "HoldResume"), Step("dg44", "assert", expect: "interrupted_key", value: "none"), // --- Max cap ends a forced scene --- @@ -788,7 +784,7 @@ internal static class HarnessScenarios Step("dg112", "interest_feeds_tick"), Step("dg113", "age_current", wait: 1.2f), Step("dg114", "director_run", wait: 1.2f), - Step("dg115", "assert", expect: "current_tier", value: "Action"), + Step("dg115", "assert", expect: "tip_contains", value: "ActionKeep"), Step("dg116", "assert", expect: "tip_contains", value: "ActionKeep"), Step("dg117", "assert", expect: "interest_no_key", value: "just_ate"), @@ -867,9 +863,12 @@ internal static class HarnessScenarios Step("wr44", "screenshot", value: "hud-watch-reason-singleton.png"), Step("wr45", "wait", wait: 0.55f), - // Ambient catch-all always fills the reason row. - Step("wr46", "interest_force_session", asset: "human", label: "Ambient stroll", tier: "Ambient", expect: "ambient_stroll"), - Step("wr47", "assert", expect: "dossier_contains", value: "Looking around"), + // Character-led fill / ambient → no catch-all reason (row hidden). + Step("wr46", "interest_force_session", asset: "human", label: "Ambient stroll", tier: "Ambient", expect: "ambient_stroll", + value: "lead=character;evt=20"), + Step("wr47", "assert", expect: "dossier_not_contains", value: "Going about their day"), + Step("wr47b", "assert", expect: "dossier_not_contains", value: "Curious glance"), + Step("wr47c", "assert", expect: "dossier_not_contains", value: "Looking around"), Step("wr48", "assert", expect: "dossier_not_contains", value: "Live ·"), Step("wr49", "wait", wait: 0.35f), @@ -945,7 +944,7 @@ internal static class HarnessScenarios Step("ap43", "age_current", wait: 1.2f), Step("ap44", "director_run", wait: 1.2f), Step("ap45", "assert", expect: "tip_contains", value: "RealFight"), - Step("ap46", "assert", expect: "current_tier", value: "Action"), + Step("ap46", "assert", expect: "tip_contains", value: "RealFight"), // Cold-boot focus gaps from welcome/dismiss are unrelated to score order. Step("ap89", "reset_counters"), @@ -1013,13 +1012,13 @@ internal static class HarnessScenarios Step("wl10", "trigger_interest", asset: "auto", label: "Story: kingdom_new", tier: "Story"), Step("wl11", "age_current", wait: 1.2f), Step("wl12", "director_run", wait: 1.2f), - Step("wl13", "assert", expect: "current_tier", value: "Story"), + Step("wl13", "assert", expect: "tip_contains", value: "kingdom_new"), Step("wl14", "assert", expect: "tip_contains", value: "kingdom_new"), Step("wl20", "trigger_interest", asset: "auto", label: "Epic: diplomacy_war_started", tier: "Epic"), Step("wl21", "age_current", wait: 1.2f), Step("wl22", "director_run", wait: 1.2f), - Step("wl23", "assert", expect: "current_tier", value: "Epic"), + Step("wl23", "assert", expect: "tip_contains", value: "diplomacy_war_started"), Step("wl24", "assert", expect: "tip_contains", value: "diplomacy_war_started"), Step("wl25", "assert", expect: "health"), Step("wl26", "assert", expect: "no_bad"), diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs index d724ff8..9c7a298 100644 --- a/IdleSpectator/InterestCandidate.cs +++ b/IdleSpectator/InterestCandidate.cs @@ -28,13 +28,12 @@ public enum InterestCompletionKind } /// -/// Durable interest scene candidate. Urgency () is preemption class only; -/// ranks within a class. +/// Durable interest scene candidate. is the sole ranking signal; +/// typed fields (lead, completion, combat) gate interrupt policy. /// public sealed class InterestCandidate { public string Key = ""; - public InterestTier Urgency = InterestTier.Ambient; public InterestLeadKind LeadKind = InterestLeadKind.EventLed; public string Category = ""; public string Source = ""; @@ -83,13 +82,15 @@ public sealed class InterestCandidate { return new InterestEvent { - Tier = Urgency, Score = TotalScore, Position = Position, FollowUnit = FollowUnit, Label = Label, CreatedAt = CreatedAt, - AssetId = AssetId + AssetId = AssetId, + ParticipantCount = ParticipantCount, + NotableParticipantCount = NotableParticipantCount, + CharacterSignificance = CharacterSignificance }; } @@ -98,7 +99,6 @@ public sealed class InterestCandidate var copy = new InterestCandidate { Key = Key, - Urgency = Urgency, LeadKind = LeadKind, Category = Category, Source = Source, diff --git a/IdleSpectator/InterestCollector.cs b/IdleSpectator/InterestCollector.cs index b0e3e4d..35f83da 100644 --- a/IdleSpectator/InterestCollector.cs +++ b/IdleSpectator/InterestCollector.cs @@ -20,9 +20,9 @@ public static class InterestCollector public static int PendingCount => InterestRegistry.PendingCount; - public static bool HasPendingAtLeast(InterestTier minTier) + public static bool HasPendingScoreAtLeast(float minScore) { - return InterestRegistry.HasPendingAtLeast(minTier); + return InterestRegistry.HasPendingScoreAtLeast(minScore); } public static void Clear() diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index df37d22..853999c 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -5,8 +5,9 @@ using UnityEngine; namespace IdleSpectator; /// -/// Event-centered scene director: registry intake, protected sessions, Epic-only preemption -/// for Action+ scenes, soft 70/30 variety, completion via . +/// Event-centered scene director: registry intake, protected sessions, score-margin +/// preemption with typed combat/vignette rules, soft 70/30 variety, completion via +/// . /// public static class InterestDirector { @@ -44,8 +45,13 @@ public static class InterestDirector private static float _inactiveSince = -999f; private static readonly List PendingScratch = new List(96); - public static string CurrentTierName => - _current == null ? "none" : _current.Urgency.ToString(); + public static string CurrentTierName => CurrentScoreLabel; + + public static string CurrentScoreLabel => + _current == null ? "none" : _current.TotalScore.ToString("0.#"); + + public static float CurrentScore => + _current == null ? 0f : _current.TotalScore; public static string CurrentLabel => _current == null ? "" : (_current.Label ?? ""); @@ -291,7 +297,7 @@ public static class InterestDirector InterestCandidate next = SelectNext(now); if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch)) { - if (next.Urgency >= InterestTier.Action) + if (!InterestScoring.IsFillScore(next.TotalScore)) { _lastInterestingAt = now; } @@ -302,8 +308,8 @@ public static class InterestDirector } // Queued discovery tips beat ambient fill, but never stall under Action/Story. - if (InterestRegistry.HasPendingAtLeast(InterestTier.Curiosity) - && (_current == null || _current.Urgency <= InterestTier.Curiosity)) + if (InterestRegistry.HasPendingScoreAtLeast(InterestScoringConfig.W.fillScoreMax) + && (_current == null || InterestScoring.IsFillScore(_current.TotalScore))) { return; } @@ -448,7 +454,14 @@ public static class InterestDirector float onCurrent = Time.unscaledTime - _currentStartedAt; float sinceSwitch = Time.unscaledTime - _lastSwitchAt; return CanSwitchTo( - new InterestCandidate { Urgency = InterestTier.Curiosity, Key = "probe:curiosity" }, + new InterestCandidate + { + Key = "probe:curiosity", + EventStrength = 40f, + LeadKind = InterestLeadKind.CharacterLed, + Completion = InterestCompletionKind.CharacterVignette, + TotalScore = 45f + }, onCurrent, sinceSwitch); } @@ -531,14 +544,24 @@ public static class InterestDirector return _minDwell; } - if (current.MinWatch > 0f) + ScoringWeights w = InterestScoringConfig.W; + float dwell = InterestScoring.IsHotScore(current.TotalScore) + ? w.dwellHot + : (InterestScoring.IsFillScore(current.TotalScore) ? w.dwellFill : w.dwellNormal); + if (_minDwell < MinDwellSeconds * 0.5f) { - return current.Urgency <= InterestTier.Curiosity - ? Mathf.Min(current.MinWatch, _curiosityDwell) - : Mathf.Max(current.MinWatch, _minDwell * 0.15f); + // Harness fast timing: keep compressed dwells. + dwell = InterestScoring.IsFillScore(current.TotalScore) ? _curiosityDwell : _minDwell; } - return current.Urgency <= InterestTier.Curiosity ? _curiosityDwell : _minDwell; + if (current.MinWatch > 0f) + { + return InterestScoring.IsFillScore(current.TotalScore) + ? Mathf.Min(current.MinWatch, dwell) + : Mathf.Max(current.MinWatch, dwell * 0.15f); + } + + return dwell; } private static float MaxWatchFor(InterestCandidate current) @@ -603,43 +626,37 @@ public static class InterestDirector float now = Time.unscaledTime; bool protectedScene = SessionProtected(now, onCurrent); - InterestTier cur = _current.Urgency; - InterestTier next = candidate.Urgency; + ScoringWeights w = InterestScoringConfig.W; + float curScore = _current.TotalScore; + float nextScore = candidate.TotalScore; + bool nextFill = InterestScoring.IsFillScore(nextScore); + bool curFill = InterestScoring.IsFillScore(curScore); - // Curiosity never interrupts Action+. - if (next == InterestTier.Curiosity && cur >= InterestTier.Action) + // Fill/curiosity-strength scenes never cut protected non-fill sessions. + if (nextFill && !curFill && protectedScene) { return false; } - if (next == InterestTier.Curiosity - && cur == InterestTier.Curiosity - && onCurrent < _curiosityRotate) + float fillRotate = _minDwell < MinDwellSeconds * 0.5f ? _curiosityRotate : w.fillRotateSeconds; + if (nextFill && curFill && onCurrent < fillRotate) { return false; } - if (protectedScene && cur >= InterestTier.Action) + bool typedCutIn = InterestScoring.IsCombatAction(candidate) + && InterestScoring.IsCharacterVignette(_current) + && onCurrent >= _settleGrace; + bool combatCutsNonCombat = InterestScoring.IsCombatAction(candidate) + && !InterestScoring.IsCombatAction(_current) + && onCurrent >= _settleGrace + && nextScore >= curScore - w.rotateSlack; + bool scoreCutIn = onCurrent >= _settleGrace + && nextScore >= curScore + w.cutInMargin; + + if (protectedScene && !curFill) { - // Epic always may cut in after settle. - if (next >= InterestTier.Epic && onCurrent >= _settleGrace) - { - return true; - } - - // Live combat / battles beat celebrity Story vignettes (actions > characters). - if (InterestScoring.IsCombatAction(candidate) - && InterestScoring.IsCharacterVignette(_current) - && onCurrent >= _settleGrace) - { - return true; - } - - // Stronger Action combat may cut a weaker non-combat Action after settle. - if (next >= InterestTier.Action - && InterestScoring.IsCombatAction(candidate) - && !InterestScoring.IsCombatAction(_current) - && onCurrent >= _settleGrace) + if (typedCutIn || combatCutsNonCombat || scoreCutIn) { return true; } @@ -647,18 +664,20 @@ public static class InterestDirector return false; } - // Ambient/Curiosity (or unprotected): higher urgency after settle; else dwell+cooldown. - if (next > cur && onCurrent >= _settleGrace) + // Unprotected / fill current: stronger score after settle, or peer rotate after dwell. + if (nextScore > curScore && onCurrent >= _settleGrace) { return true; } if (!protectedScene && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown) { - return true; + return nextScore >= curScore - w.rotateSlack; } - return onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown && next >= cur; + return onCurrent >= MinDwellFor(_current) + && sinceSwitch >= _switchCooldown + && nextScore >= curScore - w.rotateSlack; } private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt) @@ -669,8 +688,8 @@ public static class InterestDirector } if (_current != null - && next.Urgency >= InterestTier.Epic - && _current.Urgency < InterestTier.Epic + && InterestScoring.IsHotScore(next.TotalScore) + && !InterestScoring.IsHotScore(_current.TotalScore) && _current.Resumable) { _interrupted = _current.CloneShallow(); @@ -680,7 +699,7 @@ public static class InterestDirector _currentStartedAt = now; _lastSwitchAt = now; _inactiveSince = -999f; - if (next.Urgency >= InterestTier.Action) + if (!InterestScoring.IsFillScore(next.TotalScore)) { _lastInterestingAt = now; } @@ -696,12 +715,12 @@ public static class InterestDirector _lastAmbientAt = now; if (!force && _current != null && SessionProtected(now, now - _currentStartedAt) - && _current.Urgency >= InterestTier.Story) + && InterestScoring.IsHotScore(_current.TotalScore)) { return; } - if (!force && _current != null && _current.Urgency == InterestTier.Curiosity + if (!force && _current != null && InterestScoring.IsFillScore(_current.TotalScore) && now - _currentStartedAt < _ambientRotate) { return; @@ -720,9 +739,9 @@ public static class InterestDirector // 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) + if (AgentHarness.Busy && ambient.Score >= InterestScoringConfig.W.noticeScoreMin) { - ambient.Tier = InterestTier.Ambient; + ambient.Score = InterestScoringConfig.W.fillScoreMax * 0.5f; if (string.IsNullOrEmpty(ambient.Label)) { ambient.Label = "Ambient"; @@ -740,8 +759,8 @@ public static class InterestDirector candidate.Completion = InterestCompletionKind.CharacterVignette; if (AgentHarness.Busy) { - candidate.Urgency = InterestTier.Ambient; candidate.ForceActive = false; + candidate.EventStrength = Mathf.Min(candidate.EventStrength, 25f); InterestScoring.ScoreCheap(candidate); } @@ -750,7 +769,7 @@ public static class InterestDirector && _current.FollowUnit != null && candidate.FollowUnit != null && _current.FollowUnit == candidate.FollowUnit - && candidate.Urgency <= InterestTier.Ambient) + && InterestScoring.IsFillScore(candidate.TotalScore)) { return; } diff --git a/IdleSpectator/InterestEvent.cs b/IdleSpectator/InterestEvent.cs index 6821ea5..df7ad55 100644 --- a/IdleSpectator/InterestEvent.cs +++ b/IdleSpectator/InterestEvent.cs @@ -2,9 +2,12 @@ using UnityEngine; namespace IdleSpectator; +/// +/// Lightweight watch payload for camera / tip. Ranking lives on ; +/// typed interrupt policy uses the registry candidate, not this DTO. +/// public sealed class InterestEvent { - public InterestTier Tier; public float Score; public Vector3 Position; public Actor FollowUnit; diff --git a/IdleSpectator/InterestFeeds.cs b/IdleSpectator/InterestFeeds.cs index a7e9e30..ca46dc8 100644 --- a/IdleSpectator/InterestFeeds.cs +++ b/IdleSpectator/InterestFeeds.cs @@ -92,7 +92,7 @@ public static class InterestFeeds return; } - if (!WorldLogInterestTable.TryGetTier(message.asset_id, out InterestTier tier)) + if (!WorldLogInterestTable.TryGetEventStrength(message.asset_id, out float evtSeed)) { return; } @@ -113,15 +113,15 @@ public static class InterestFeeds string kingdom = message.special1 ?? ""; string key = "worldlog:" + assetId + ":" + kingdom; float boost = PeekCivicBoost(kingdom, assetId); + bool politics = evtSeed >= 90f; var candidate = new InterestCandidate { Key = key, - Urgency = tier, LeadKind = InterestLeadKind.EventLed, - Category = tier >= InterestTier.Epic ? "Politics" : "Settlement", + Category = politics ? "Politics" : "Settlement", Source = "worldlog", - EventStrength = 60f + (int)tier * 20f + boost, + EventStrength = evtSeed + boost, VisualConfidence = unit != null ? 0.7f : 0.4f, Position = unit != null ? unit.current_position : position, FollowUnit = unit, @@ -133,8 +133,8 @@ public static class InterestFeeds CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 40f, - MinWatch = tier >= InterestTier.Epic ? 8f : 6f, - MaxWatch = tier >= InterestTier.Epic ? 35f : 28f, + MinWatch = politics ? 8f : 6f, + MaxWatch = politics ? 35f : 28f, Completion = InterestCompletionKind.FixedDwell }; InterestScoring.ScoreCheap(candidate); @@ -152,11 +152,11 @@ public static class InterestFeeds 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 + float score = interest.Score; + bool hot = score >= InterestScoringConfig.W.noticeScoreMin || asset == "live_battle"; + string key = "direct:" + subjectId + ":" + (interest.Label ?? "") + ":" + asset; + InterestLeadKind lead = hot ? InterestLeadKind.EventLed : InterestLeadKind.CharacterLed; + InterestCompletionKind completion = hot ? InterestCompletionKind.FixedDwell : InterestCompletionKind.CharacterVignette; @@ -170,14 +170,15 @@ public static class InterestFeeds var candidate = new InterestCandidate { Key = key, - Urgency = interest.Tier, LeadKind = lead, - Category = asset == "live_battle" ? "Combat" : CategoryForTier(interest.Tier, asset), + Category = asset == "live_battle" + ? "Combat" + : CategoryForLead(lead, completion, asset), Source = "direct", - EventStrength = interest.Score > 0f ? interest.Score : 40f + (int)interest.Tier * 15f, + EventStrength = score > 0f ? score : 40f, CharacterSignificance = interest.CharacterSignificance > 0f ? interest.CharacterSignificance - : (lead == InterestLeadKind.CharacterLed ? interest.Score * 0.5f : 0f), + : (lead == InterestLeadKind.CharacterLed ? score * 0.5f : 0f), VisualConfidence = unit != null ? 0.75f : 0.4f, Position = interest.Position, FollowUnit = unit, @@ -191,8 +192,8 @@ public static class InterestFeeds 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, + MinWatch = InterestScoring.IsFillScore(score) ? 1.2f : 1.5f, + MaxWatch = InterestScoring.IsHotScore(score) ? 40f : 25f, Completion = completion }; InterestScoring.ScoreCheap(candidate); @@ -201,7 +202,6 @@ public static class InterestFeeds public static InterestCandidate RegisterHarness( Actor follow, - InterestTier urgency, string label, string keySuffix, InterestLeadKind lead, @@ -212,14 +212,15 @@ public static class InterestFeeds { return RegisterHarness( follow, - urgency, label, keySuffix, lead, completion, forceActive, eventStrength, - characterSignificance: lead == InterestLeadKind.CharacterLed ? eventStrength : 10f, + characterSignificance: lead == InterestLeadKind.CharacterLed + ? UnityEngine.Mathf.Min(40f, eventStrength * 0.4f) + : 10f, maxWatch, ttlSeconds: 60f, related: null, @@ -228,7 +229,6 @@ public static class InterestFeeds public static InterestCandidate RegisterHarness( Actor follow, - InterestTier urgency, string label, string keySuffix, InterestLeadKind lead, @@ -247,14 +247,13 @@ public static class InterestFeeds } long id = SafeId(follow); - string key = "harness:" + (keySuffix ?? urgency.ToString()) + ":" + id; + string key = "harness:" + (keySuffix ?? "scene") + ":" + id; float now = Time.unscaledTime; var candidate = new InterestCandidate { Key = key, - Urgency = urgency, LeadKind = lead, - Category = CategoryForTier(urgency, ""), + Category = CategoryForLead(lead, completion, ""), Source = "harness", EventStrength = eventStrength, CharacterSignificance = characterSignificance, @@ -263,7 +262,7 @@ public static class InterestFeeds FollowUnit = follow, RelatedUnit = related, SubjectId = id, - Label = label ?? ("Harness " + urgency), + Label = label ?? "Harness", AssetId = follow.asset != null ? follow.asset.id : "harness", SpeciesId = follow.asset != null ? follow.asset.id : "", CreatedAt = now, @@ -305,7 +304,6 @@ public static class InterestFeeds var candidate = new InterestCandidate { Key = key, - Urgency = InterestTier.Action, LeadKind = InterestLeadKind.EventLed, Category = combat ? "Combat" : "Work", Source = "activity", @@ -368,7 +366,6 @@ public static class InterestFeeds var candidate = new InterestCandidate { Key = key, - Urgency = InterestTier.Action, LeadKind = InterestLeadKind.EventLed, Category = "StatusTransformation", Source = "status", @@ -408,13 +405,9 @@ public static class InterestFeeds 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", @@ -521,13 +514,11 @@ public static class InterestFeeds 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", @@ -599,7 +590,6 @@ public static class InterestFeeds } 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; @@ -615,7 +605,6 @@ public static class InterestFeeds var candidate = new InterestCandidate { Key = key, - Urgency = urgency, LeadKind = InterestLeadKind.EventLed, Category = grief ? "FamilyEmotion" : "LifeChapter", Source = "happiness_harness", @@ -768,34 +757,33 @@ public static class InterestFeeds } } - private static string CategoryForTier(InterestTier tier, string asset) + private static string CategoryForLead( + InterestLeadKind lead, + InterestCompletionKind completion, + string asset) { - if (asset == "live_battle") + if (asset == "live_battle" || completion == InterestCompletionKind.CombatActive) { return "Combat"; } - if (tier >= InterestTier.Epic) + if (completion == InterestCompletionKind.CharacterVignette + || lead == InterestLeadKind.CharacterLed) { - return "Politics"; + return "CharacterVignette"; } - if (tier == InterestTier.Story) + if (completion == InterestCompletionKind.HappinessGrief) { - return "Settlement"; + return "FamilyEmotion"; } - if (tier == InterestTier.Action) + if (completion == InterestCompletionKind.ActivityActive) { - return "Combat"; + return "Work"; } - if (tier == InterestTier.Curiosity) - { - return "Discovery"; - } - - return "CharacterVignette"; + return "Settlement"; } private static string CorrBucket(string corr) diff --git a/IdleSpectator/InterestRegistry.cs b/IdleSpectator/InterestRegistry.cs index 346a762..5da25be 100644 --- a/IdleSpectator/InterestRegistry.cs +++ b/IdleSpectator/InterestRegistry.cs @@ -91,10 +91,6 @@ public static class InterestRegistry 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)) { @@ -293,14 +289,14 @@ public static class InterestRegistry } } - public static bool HasPendingAtLeast(InterestTier minUrgency) + public static bool HasPendingScoreAtLeast(float minScore) { lock (Gate) { foreach (KeyValuePair kv in ByKey) { InterestCandidate c = kv.Value; - if (c != null && !c.Selected && c.HasValidPosition && c.Urgency >= minUrgency) + if (c != null && !c.Selected && c.HasValidPosition && c.TotalScore >= minScore) { return true; } @@ -326,22 +322,7 @@ public static class InterestRegistry } } - 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); - }); + Scratch.Sort(InterestScoring.CompareByScore); for (int i = MaxCandidates; i < Scratch.Count; i++) { diff --git a/IdleSpectator/InterestScoring.cs b/IdleSpectator/InterestScoring.cs index 1ce3202..9e5cdf4 100644 --- a/IdleSpectator/InterestScoring.cs +++ b/IdleSpectator/InterestScoring.cs @@ -22,7 +22,7 @@ public sealed class InterestMetadataSnapshot } /// -/// Urgency-class mapping and within-class score breakdown. +/// Score breakdown and typed scene classifiers (combat / vignette). Ranking is TotalScore only. /// public static class InterestScoring { @@ -37,31 +37,6 @@ public static class InterestScoring 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 ?? ""; @@ -92,11 +67,19 @@ public static class InterestScoring } } - public static InterestTier UrgencyForWorldLog(string assetId) + public static float EventStrengthForWorldLog(string assetId) { - return WorldLogInterestTable.TryGetTier(assetId, out InterestTier tier) - ? tier - : InterestTier.Ambient; + return WorldLogInterestTable.TryGetEventStrength(assetId, out float seed) ? seed : 0f; + } + + public static bool IsFillScore(float totalScore) + { + return totalScore < InterestScoringConfig.W.fillScoreMax; + } + + public static bool IsHotScore(float totalScore) + { + return totalScore >= InterestScoringConfig.W.hotScoreMin; } public static void EnrichTopK(List candidates) @@ -106,7 +89,7 @@ public static class InterestScoring return; } - candidates.Sort(CompareByUrgencyThenScore); + candidates.Sort(CompareByScore); int n = Mathf.Min(EnrichTopKLimit, candidates.Count); float now = Time.unscaledTime; for (int i = 0; i < n; i++) @@ -141,19 +124,15 @@ public static class InterestScoring } // Action-primary: event strength dominates. Character is a tie-break / small amplifier. - // Scale / notables on clusters (battles) add to event strength before this runs. - // Knobs: IdleSpectator/scoring-model.json → weights (InterestScoringConfig.W). ScoringWeights w = InterestScoringConfig.W; float charWeight = c.LeadKind == InterestLeadKind.EventLed ? w.charWeightEventLed : w.charWeightCharacterLed; - float urgencyWeight = 1f + (int)c.Urgency * w.urgencyTierStep; float scaleBonus = 0f; int fighters = c.ParticipantCount; int notables = c.NotableParticipantCount; if (fighters >= w.massFighterThreshold) { - // Multi-person melees outrank thin anonymous 1v1s. scaleBonus += w.massBaseBonus + Mathf.Min(w.massExtraCap, (fighters - w.massFighterThreshold) * w.massPerExtraFighter); } @@ -161,7 +140,6 @@ public static class InterestScoring { if (notables >= 2) { - // Extremely important duel (kings/favorites) can beat nameless melees. scaleBonus += w.duelNotablePairBonus; } else if (notables == 1) @@ -179,7 +157,7 @@ public static class InterestScoring scaleBonus += Mathf.Min(w.notableSkirmishCap, notables * w.notableSkirmishPer); } - c.TotalScore = (c.EventStrength + scaleBonus) * urgencyWeight + c.TotalScore = c.EventStrength + scaleBonus + c.CharacterSignificance * charWeight + c.VisualConfidence * w.visualMultiplier + c.Novelty * w.noveltyMultiplier; @@ -285,7 +263,7 @@ public static class InterestScoring } } - public static int CompareByUrgencyThenScore(InterestCandidate a, InterestCandidate b) + public static int CompareByScore(InterestCandidate a, InterestCandidate b) { if (ReferenceEquals(a, b)) { @@ -302,12 +280,6 @@ public static class InterestScoring 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) { @@ -317,6 +289,12 @@ public static class InterestScoring return b.LastSeenAt.CompareTo(a.LastSeenAt); } + /// Legacy alias; score-only ranking. + public static int CompareByUrgencyThenScore(InterestCandidate a, InterestCandidate b) + { + return CompareByScore(a, b); + } + public static bool IsNotable(Actor actor) { if (actor == null) @@ -442,9 +420,9 @@ public static class InterestScoring return true; } - // Scanner/harness "Story" celebrity strolls are not WorldLog events - actions may cut them. - if (c.Urgency == InterestTier.Story - && (c.Source == "scanner" || c.Source == "harness") + // Scanner/harness celebrity strolls without a live action beat - combat may cut them. + if ((c.Source == "scanner" || c.Source == "harness") + && c.LeadKind == InterestLeadKind.CharacterLed && string.IsNullOrEmpty(c.HappinessEffectId)) { return true; diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs index 11d13ff..58cead0 100644 --- a/IdleSpectator/InterestScoringConfig.cs +++ b/IdleSpectator/InterestScoringConfig.cs @@ -23,10 +23,20 @@ public class ScoringWeights { public float charWeightEventLed = 0.18f; public float charWeightCharacterLed = 0.55f; - public float urgencyTierStep = 0.08f; public float visualMultiplier = 12f; public float noveltyMultiplier = 4f; + // Director policy (score-native; replaces InterestTier preemption). + public float cutInMargin = 35f; + public float rotateSlack = 12f; + public float fillScoreMax = 55f; + public float noticeScoreMin = 70f; + public float hotScoreMin = 110f; + public float dwellFill = 6f; + public float dwellNormal = 10f; + public float dwellHot = 14f; + public float fillRotateSeconds = 10f; + public int massFighterThreshold = 4; public float massBaseBonus = 28f; public float massPerExtraFighter = 4f; diff --git a/IdleSpectator/InterestTier.cs b/IdleSpectator/InterestTier.cs deleted file mode 100644 index 6966929..0000000 --- a/IdleSpectator/InterestTier.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace IdleSpectator; - -/// -/// Higher value can interrupt lower after interrupt grace. -/// -public enum InterestTier -{ - /// Filler rotation when nothing else is happening. - Ambient = 0, - /// Low priority curiosities (new species, oddities). - Curiosity = 1, - /// Live fights, high-kill creatures, action clusters. - Action = 2, - /// Leadership / politics / favorites. - Story = 3, - /// World-shaping beats (wars, kingdom falls, disasters). - Epic = 4 -} diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs index bd6d376..71c2e14 100644 --- a/IdleSpectator/InterestVariety.cs +++ b/IdleSpectator/InterestVariety.cs @@ -174,7 +174,7 @@ public static class InterestVariety InterestCandidate c = pool[i]; float penalty = NoveltyPenalty(c, now); // Epic / favorites bypass most novelty. - if (c.Urgency >= InterestTier.Epic || InterestScoring.IsNotable(c.FollowUnit)) + if (InterestScoring.IsHotScore(c.TotalScore) || InterestScoring.IsNotable(c.FollowUnit)) { penalty *= 0.15f; } diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index 08bef8d..3499c0b 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -80,7 +80,7 @@ public sealed class UnitDossier return d; } - public static UnitDossier FromActor(Actor actor, InterestTier? watchTier = null, string watchLabel = null) + public static UnitDossier FromActor(Actor actor, string watchLabel = null) { UnitDossier d = new UnitDossier(); if (actor == null || !actor.isAlive()) @@ -119,7 +119,6 @@ public sealed class UnitDossier d.Headline = string.IsNullOrEmpty(d.SpeciesId) ? d.Name : $"{d.Name} ({d.SpeciesId})"; - _ = watchTier; // tier badge removed; beat is event-shaped only d.ReasonLine = BuildStoryBeat(d, watchLabel, actor); d.DetailLine = BuildDetailLine(d); d.CaptionText = JoinCaption(d.Headline, d.ReasonLine, d.DetailLine); @@ -214,8 +213,8 @@ public sealed class UnitDossier /// /// Story beat for the dossier reason row: what is happening in this shot. - /// No tier badges, no job append, no nametag repeats. Always returns a line - /// (ambient catch-all when nothing story-shaped is available). + /// No tier badges, no job append, no nametag repeats. + /// Fill / ambient scenes with no sharper beat leave the reason empty (row hidden). /// Follow-up (not blocking): expand event/character tags and optional beatPhrases /// in scoring-model.json once playtests show thin beats. /// @@ -240,10 +239,11 @@ public sealed class UnitDossier return griefOrLife; } - // Ambient filler: always the catch-all (labels are identity/noise, not story). - if (scene != null && scene.Urgency == InterestTier.Ambient) + // Ambient filler: no story beat - hide the reason row. + if (scene != null && InterestScoring.IsFillScore(scene.TotalScore) + && !InterestScoring.IsCombatAction(scene)) { - return AmbientBeat(scene); + return ""; } string fromScene = SceneLabelBeat(scene, d); @@ -282,26 +282,7 @@ public sealed class UnitDossier return "New species"; } - return AmbientBeat(scene); - } - - /// Catch-all when the shot has no sharper story beat. - private static string AmbientBeat(InterestCandidate scene) - { - if (scene != null) - { - if (scene.Urgency == InterestTier.Curiosity) - { - return "Curious glance"; - } - - if (scene.Urgency <= InterestTier.Ambient) - { - return "Looking around"; - } - } - - return "Looking around"; + return ""; } private static InterestCandidate MatchingScene(Actor actor, UnitDossier d) diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 77c848f..a726ce0 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -431,17 +431,17 @@ public static class WatchCaption return; } - SetFromActor(unit, interest?.Tier, interest?.Label); + SetFromActor(unit, interest?.Label); } - public static void SetFromActor(Actor actor, InterestTier? tier = null, string label = null) + public static void SetFromActor(Actor actor, string label = null) { if (!_pinnedWhilePaused) { ClearStatusBanner(); } - UnitDossier dossier = UnitDossier.FromActor(actor, tier, label); + UnitDossier dossier = UnitDossier.FromActor(actor, label); _current = dossier; _boundActor = actor; LastHeadline = dossier.Headline ?? ""; diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index 7e2ef7a..0dbf357 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -88,22 +88,18 @@ public static class WorldActivityScanner { Actor actor = alive[i]; ActivityBand band = ActivityInterestTable.Classify(actor); - InterestTier tier = InterestTier.Ambient; InterestLeadKind lead = InterestLeadKind.CharacterLed; - // Actions outrank characters: combat/hot work are Action events; kings walking stay vignettes. + // Actions outrank characters: combat/hot work are events; kings walking stay vignettes. if (band == ActivityBand.Hot || actor.has_attack_target) { - tier = InterestTier.Action; lead = InterestLeadKind.EventLed; } else if (band >= ActivityBand.Warm) { - tier = InterestTier.Action; lead = InterestLeadKind.EventLed; } else if (IsSpectacle(actor) || IsSingletonSpecies(actor, speciesCounts)) { - tier = InterestTier.Curiosity; lead = InterestLeadKind.CharacterLed; } @@ -120,7 +116,7 @@ public static class WorldActivityScanner string key = lead == InterestLeadKind.CharacterLed ? "vignette:" + id + ":" + (actor.asset != null ? actor.asset.id : "unit") - : "scan:" + tier + ":" + id; + : "scan:evt:" + id; string label = FormatUnitLabel(actor, speciesCounts); if (actor.has_attack_target) @@ -144,7 +140,6 @@ public static class WorldActivityScanner var candidate = new InterestCandidate { Key = key, - Urgency = tier, LeadKind = lead, Category = combat ? "Combat" @@ -270,7 +265,6 @@ public static class WorldActivityScanner : $"Skirmish ({fighters} fighting)"; best = new InterestEvent { - Tier = InterestTier.Action, Score = score, Position = pos, FollowUnit = follow ?? FindNearestAliveUnit(pos, 14f), @@ -330,7 +324,6 @@ public static class WorldActivityScanner bestScore = score; best = new InterestEvent { - Tier = InterestTier.Action, Score = score, Position = actor.current_position, FollowUnit = follow ?? actor, @@ -517,7 +510,6 @@ public static class WorldActivityScanner return null; } - InterestTier tier = InterestTier.Ambient; ActivityBand band = ActivityInterestTable.Classify(best); SplitActorScore(best, speciesCounts, out float actionScore, out float charScore); int fighters = 0; @@ -526,20 +518,6 @@ public static class WorldActivityScanner { CountFightCluster(best.current_position, 10f, out fighters, out notables, out _); fighters = Mathf.Max(1, fighters); - tier = InterestTier.Action; - } - else if (band >= ActivityBand.Warm) - { - // Warm work is an action beat - not Story celebrity. - tier = InterestTier.Action; - } - else if (IsSpectacle(best) || IsSingletonSpecies(best, speciesCounts)) - { - tier = InterestTier.Curiosity; - } - else - { - tier = InterestTier.Ambient; } float finalScore = ScoreFightCluster(0, fighters, notables); @@ -555,7 +533,6 @@ public static class WorldActivityScanner return new InterestEvent { - Tier = tier, Score = finalScore, Position = best.current_position, FollowUnit = best, @@ -874,7 +851,6 @@ public static class WorldActivityScanner return new InterestEvent { - Tier = InterestTier.Curiosity, Score = 5f, Position = pos, FollowUnit = follow, @@ -1044,7 +1020,6 @@ public static class WorldActivityScanner return new InterestEvent { - Tier = source.Tier, Score = source.Score, Position = source.Position, FollowUnit = source.FollowUnit, diff --git a/IdleSpectator/WorldLogInterestTable.cs b/IdleSpectator/WorldLogInterestTable.cs index 2247b54..e1af40b 100644 --- a/IdleSpectator/WorldLogInterestTable.cs +++ b/IdleSpectator/WorldLogInterestTable.cs @@ -4,63 +4,63 @@ using UnityEngine; namespace IdleSpectator; /// -/// Centralized WorldLog asset ids and tier mapping. +/// WorldLog asset ids → EventStrength seeds (score-native; no InterestTier). /// public static class WorldLogInterestTable { - private static readonly Dictionary Tiers = new Dictionary + private static readonly Dictionary EventSeeds = new Dictionary { - // Epic - world-shaping (wiki: Wars, Kingdoms fall, Disasters, city destroyed) - ["diplomacy_war_started"] = InterestTier.Epic, - ["total_war_started"] = InterestTier.Epic, - ["kingdom_destroyed"] = InterestTier.Epic, - ["kingdom_shattered"] = InterestTier.Epic, - ["kingdom_fractured"] = InterestTier.Epic, - ["city_destroyed"] = InterestTier.Epic, - ["log_city_revolted"] = InterestTier.Epic, + // World-shaping + ["diplomacy_war_started"] = 100f, + ["total_war_started"] = 100f, + ["kingdom_destroyed"] = 100f, + ["kingdom_shattered"] = 98f, + ["kingdom_fractured"] = 95f, + ["city_destroyed"] = 92f, + ["log_city_revolted"] = 90f, - // Story - leadership / politics / favorites / founding - ["kingdom_new"] = InterestTier.Story, - ["city_new"] = InterestTier.Story, - ["king_new"] = InterestTier.Story, - ["king_dead"] = InterestTier.Story, - ["king_killed"] = InterestTier.Story, - ["king_fled_capital"] = InterestTier.Story, - ["king_fled_city"] = InterestTier.Story, - ["king_left"] = InterestTier.Story, - ["alliance_new"] = InterestTier.Story, - ["alliance_dissolved"] = InterestTier.Story, - ["diplomacy_war_ended"] = InterestTier.Story, - ["favorite_dead"] = InterestTier.Story, - ["favorite_killed"] = InterestTier.Story, - ["kingdom_royal_clan_new"] = InterestTier.Story, - ["kingdom_royal_clan_changed"] = InterestTier.Story, - ["kingdom_royal_clan_dead"] = InterestTier.Story, + // Leadership / politics / favorites / founding + ["kingdom_new"] = 75f, + ["city_new"] = 72f, + ["king_new"] = 78f, + ["king_dead"] = 80f, + ["king_killed"] = 85f, + ["king_fled_capital"] = 70f, + ["king_fled_city"] = 68f, + ["king_left"] = 65f, + ["alliance_new"] = 70f, + ["alliance_dissolved"] = 68f, + ["diplomacy_war_ended"] = 72f, + ["favorite_dead"] = 82f, + ["favorite_killed"] = 85f, + ["kingdom_royal_clan_new"] = 74f, + ["kingdom_royal_clan_changed"] = 72f, + ["kingdom_royal_clan_dead"] = 76f, - // Curiosity - biosphere footnotes - ["race_dead"] = InterestTier.Curiosity + // Biosphere footnotes + ["race_dead"] = 40f }; - public static bool TryGetTier(string assetId, out InterestTier tier) + public static bool TryGetEventStrength(string assetId, out float eventStrength) { if (string.IsNullOrEmpty(assetId)) { - tier = InterestTier.Ambient; + eventStrength = 0f; return false; } - if (Tiers.TryGetValue(assetId, out tier)) + if (EventSeeds.TryGetValue(assetId, out eventStrength)) { return true; } if (assetId.Contains("disaster") || assetId.StartsWith("worldlog_disaster")) { - tier = InterestTier.Epic; + eventStrength = 95f; return true; } - tier = InterestTier.Ambient; + eventStrength = 0f; return false; } @@ -121,10 +121,12 @@ public static class WorldLogInterestTable { return $"{id}: {a} / {b}"; } + if (!string.IsNullOrEmpty(a)) { return $"{id}: {a}"; } + return id; } } diff --git a/IdleSpectator/WorldLogPatches.cs b/IdleSpectator/WorldLogPatches.cs index e3e6d65..00d74ee 100644 --- a/IdleSpectator/WorldLogPatches.cs +++ b/IdleSpectator/WorldLogPatches.cs @@ -14,12 +14,13 @@ public static class WorldLogMessageAddPatch return; } - if (!WorldLogInterestTable.TryGetTier(pMessage.asset_id, out InterestTier tier)) + if (!WorldLogInterestTable.TryGetEventStrength(pMessage.asset_id, out float evtSeed)) { return; } - if (tier < InterestTier.Story) + // Chronicle notable world events (story-strength and above); skip footnotes. + if (evtSeed < 65f) { return; } diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index a99c7da..2769992 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.15.5", + "version": "0.16.6", "description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.", "GUID": "com.dazed.idlespectator" } diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json index 8bd4b22..dcfe4e2 100644 --- a/IdleSpectator/scoring-model.json +++ b/IdleSpectator/scoring-model.json @@ -1,5 +1,5 @@ { - "modVersion": "0.14.23", + "modVersion": "0.16.0", "title": "IdleSpectator interest scoring model", "updated": "2026-07-15", "note": "Edit weights below — InterestScoringConfig loads this file at mod start. Docs sections are human-facing only (ignored by Unity JsonUtility).", @@ -7,10 +7,19 @@ "weights": { "charWeightEventLed": 0.18, "charWeightCharacterLed": 0.55, - "urgencyTierStep": 0.08, "visualMultiplier": 12, "noveltyMultiplier": 4, + "cutInMargin": 35, + "rotateSlack": 12, + "fillScoreMax": 55, + "noticeScoreMin": 70, + "hotScoreMin": 110, + "dwellFill": 6, + "dwellNormal": 10, + "dwellHot": 14, + "fillRotateSeconds": 10, + "massFighterThreshold": 4, "massBaseBonus": 28, "massPerExtraFighter": 4, @@ -86,39 +95,45 @@ "Same action intensity → more important character wins (tie-break).", "Multi-person fights outrank anonymous 1v1s.", "Extremely notable 1v1s (kings/favorites) can beat nameless melees.", - "Urgency (tier) is preemption class, not the main score." + "TotalScore is the sole ranking signal; typed rules (combat vs vignette) gate interrupts.", + "InterestTier was removed - WorldLog seeds EventStrength; director uses cutInMargin / rotateSlack / dwell bands." ], "pipeline": [ { "id": "feeds", "name": "Feeds → candidates", - "detail": "WorldLog, happiness, scanner, battle clusters, harness injects upsert InterestCandidate into InterestRegistry with EventStrength, CharacterSignificance, ParticipantCount, NotableParticipantCount." + "detail": "WorldLog, happiness, scanner, battle clusters, harness injects upsert InterestCandidate with EventStrength, CharacterSignificance, ParticipantCount, NotableParticipantCount (no Urgency tier)." }, { "id": "variety", "name": "Variety pick", - "detail": "InterestVariety.Pick soft-splits EventLed vs CharacterLed (~70/30), applies novelty penalties, RecalcTotal, EnrichTopK, then probabilistic top-3." + "detail": "InterestVariety.Pick soft-splits EventLed vs CharacterLed (~70/30), applies novelty penalties, RecalcTotal, EnrichTopK, then probabilistic top-3 by TotalScore." }, { "id": "director", "name": "Director switch", - "detail": "InterestDirector.CanSwitchTo gates camera changes: settle grace, dwell, Epic cuts, combat-vs-vignette exceptions." + "detail": "InterestDirector.CanSwitchTo gates camera changes via score margins (cutInMargin/rotateSlack), settle grace, and typed combat-vs-vignette cut-ins." }, { "id": "camera", "name": "Camera + UI", - "detail": "CameraDirector.Watch + tip/dossier watch-why from action verbs (Fighting/War/Grief), not identity alone." + "detail": "CameraDirector.Watch + dossier story beats from action verbs (Fighting/War/Grief). Fill/ambient with no sharper beat leaves the reason row empty." } ], "totalScore": { "file": "InterestScoring.RecalcTotal", - "formula": "(EventStrength + scaleBonus) * urgencyWeight + CharacterSignificance * charWeight + VisualConfidence * visualMultiplier + Novelty * noveltyMultiplier", + "formula": "EventStrength + scaleBonus + CharacterSignificance * charWeight + VisualConfidence * visualMultiplier + Novelty * noveltyMultiplier", "charWeight": { "EventLed": "weights.charWeightEventLed", "CharacterLed": "weights.charWeightCharacterLed" }, - "urgencyWeight": "1 + (int)Urgency * weights.urgencyTierStep", - "scaleBonus": "See weights.mass* / duel* / notableSkirmish*" + "scaleBonus": "See weights.mass* / duel* / notableSkirmish*", + "directorPolicy": { + "cutInMargin": "weights.cutInMargin", + "rotateSlack": "weights.rotateSlack", + "fillScoreMax": "weights.fillScoreMax", + "dwell": "weights.dwellFill / dwellNormal / dwellHot" + } }, "workedExamples": [ {