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": [ {