From 9a059c17ddb56dd2e1fa00e17c06919774f4811e Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Tue, 21 Jul 2026 16:45:12 -0500 Subject: [PATCH] fix(spectator): cool soft life crumbs and keep aftermath victim truth - Soft-cool kill, courtship, intent, construction, status fx, hatch, rest, and worldlog meta - Block cooled tips from resuming after a cut-in - Name only dead theater partners in stands-over aftermath - Add harness gates through 0.28.54 --- IdleSpectator/AgentHarness.cs | 578 ++++++++++- .../Events/Catalogs/EventCatalog.Decision.cs | 21 + IdleSpectator/Events/EventFeedUtil.cs | 34 +- IdleSpectator/Events/EventReason.cs | 49 +- IdleSpectator/Events/Feeds/InterestFeeds.cs | 7 +- IdleSpectator/Events/StickyScoreboard.cs | 27 +- IdleSpectator/HarnessScenarios.cs | 884 ++++++++++++++++ IdleSpectator/InterestDirector.cs | 162 ++- IdleSpectator/InterestScoringConfig.cs | 6 + IdleSpectator/InterestVariety.cs | 978 +++++++++++++++++- IdleSpectator/Story/CharacterLedger.cs | 55 + IdleSpectator/Story/StoryPlanner.cs | 702 +++++++++++-- IdleSpectator/Story/StoryReason.cs | 24 +- IdleSpectator/UnitDossier.cs | 116 ++- IdleSpectator/WatchCaption.cs | 123 ++- IdleSpectator/mod.json | 2 +- IdleSpectator/scoring-model.json | 7 +- docs/event-reason.md | 6 + docs/story-planner.md | 109 +- 19 files changed, 3765 insertions(+), 125 deletions(-) diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 9fdcc52..238011f 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -980,6 +980,53 @@ public static class AgentHarness break; } + case "decision_emit": + { + // Live DeferredInterestFeed path (same as setDecisionCooldown patch). + Actor focus = ResolveUnit( + string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset); + if (focus == null) + { + focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + } + + string decisionId = (cmd.expect ?? cmd.value ?? "sexual_reproduction_try").Trim(); + bool alive = false; + try + { + alive = focus != null && focus.isAlive(); + } + catch + { + alive = false; + } + + if (!alive || string.IsNullOrEmpty(decisionId)) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no unit or decision id"); + break; + } + + bool prevForce = ForceLiveEventFeeds; + ForceLiveEventFeeds = true; + try + { + DeferredInterestFeed.EmitDecision(decisionId, focus); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"emitted decision={decisionId} unit={SafeName(focus)}"); + } + finally + { + ForceLiveEventFeeds = prevForce; + } + + break; + } + case "status_clear_all": { Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; @@ -2714,6 +2761,10 @@ public static class AgentHarness DoCombatWireAttackSides(cmd); break; + case "combat_wipe_sticky_side": + DoCombatWipeStickySide(cmd); + break; + case "combat_swap_attack_target": DoCombatSwapAttackTarget(cmd); break; @@ -2726,12 +2777,22 @@ public static class AgentHarness DoCombatParkBystanderFollow(cmd); break; + case "interest_hijack_follow": + DoInterestHijackFollow(cmd); + break; + case "interest_variety_clear": InterestVariety.Clear(); _cmdOk++; Emit(cmd, ok: true, detail: "variety_cleared"); break; + case "interest_story_clear": + StoryPlanner.Clear(); + _cmdOk++; + Emit(cmd, ok: true, detail: "story_cleared"); + break; + case "interest_variety_note": { // Stamp the current (or pending needle in value) as the last shown variety arc. @@ -2941,6 +3002,31 @@ public static class AgentHarness break; } + case "interest_drop_follow": + { + // Null Follow only - keep SubjectId/Related for story cast recovery tests. + InterestCandidate scene = InterestDirector.CurrentCandidate; + if (scene == null) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_scene"); + break; + } + + long keepSubject = scene.SubjectId != 0 + ? scene.SubjectId + : EventFeedUtil.SafeId(scene.FollowUnit); + scene.FollowUnit = null; + if (scene.SubjectId == 0 && keepSubject != 0) + { + scene.SubjectId = keepSubject; + } + + _cmdOk++; + Emit(cmd, ok: true, detail: $"dropped_follow subjectId={scene.SubjectId} relatedId={scene.RelatedId}"); + break; + } + case "interest_clear_follow": { Actor related = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset); @@ -4157,13 +4243,7 @@ public static class AgentHarness InterestScoring.RecalcTotal(c); } - if (!string.IsNullOrEmpty(cmd.expect) - && (cmd.expect.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0 - || cmd.expect.IndexOf("set_lover", StringComparison.OrdinalIgnoreCase) >= 0)) - { - c.AssetId = "set_lover"; - c.Category = "Relationship"; - } + ApplyHarnessRelationshipAssetHint(c, cmd.expect); if (force) { @@ -4177,6 +4257,163 @@ public static class AgentHarness 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} sides={c.CombatSideACount}/{c.CombatSideBCount} pending={InterestRegistry.PendingCount}"); } + /// + /// Map harness expect tokens onto relationship AssetIds used by StoryPlanner classification. + /// + private static void ApplyHarnessRelationshipAssetHint(InterestCandidate c, string expect) + { + if (c == null || string.IsNullOrEmpty(expect)) + { + return; + } + + if (expect.IndexOf("just_had_child", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("parenthood", StringComparison.OrdinalIgnoreCase) >= 0) + { + c.HappinessEffectId = "just_had_child"; + c.AssetId = "just_had_child"; + c.Category = "LifeChapter"; + c.Completion = InterestCompletionKind.FixedDwell; + return; + } + + if (expect.IndexOf("just_kissed", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("intimacy", StringComparison.OrdinalIgnoreCase) >= 0) + { + c.HappinessEffectId = "just_kissed"; + c.AssetId = "just_kissed"; + c.Category = "Social"; + c.Completion = InterestCompletionKind.FixedDwell; + return; + } + + if (expect.IndexOf("just_found_house", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("found_house", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("finds_a_home", StringComparison.OrdinalIgnoreCase) >= 0) + { + c.HappinessEffectId = "just_found_house"; + c.AssetId = "just_found_house"; + c.Category = "LifeChapter"; + c.Completion = InterestCompletionKind.FixedDwell; + return; + } + + if (expect.IndexOf("milestone_kill", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("just_killed", StringComparison.OrdinalIgnoreCase) >= 0) + { + c.AssetId = "milestone_kill"; + c.HappinessEffectId = "just_killed"; + c.Category = "Relationship"; + c.Source = "chronicle"; + c.Completion = InterestCompletionKind.FixedDwell; + return; + } + + if (expect.IndexOf("fallen_in_love", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("smitten", StringComparison.OrdinalIgnoreCase) >= 0) + { + c.HappinessEffectId = "fallen_in_love"; + c.AssetId = "fallen_in_love"; + c.Category = "Social"; + c.Completion = InterestCompletionKind.FixedDwell; + return; + } + + if (expect.IndexOf("sexual_reproduction", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("decision_intent", StringComparison.OrdinalIgnoreCase) >= 0) + { + c.AssetId = "sexual_reproduction_try"; + c.Source = "decision"; + c.Category = "Politics"; + c.Completion = InterestCompletionKind.FixedDwell; + return; + } + + if (expect.IndexOf("find_lover", StringComparison.OrdinalIgnoreCase) >= 0) + { + // Decision path when tagged; relationship seeking otherwise. + bool asDecision = expect.IndexOf("decision", StringComparison.OrdinalIgnoreCase) >= 0; + c.AssetId = "find_lover"; + c.HappinessEffectId = asDecision ? "" : "find_lover"; + c.Source = asDecision ? "decision" : "relationship"; + c.Category = asDecision ? "Politics" : "Relationship"; + c.Completion = InterestCompletionKind.FixedDwell; + return; + } + + if (expect.IndexOf("building_complete", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("tent_", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("construction", StringComparison.OrdinalIgnoreCase) >= 0) + { + c.AssetId = expect.IndexOf("tent_", StringComparison.OrdinalIgnoreCase) >= 0 + ? "tent_human" + : "building_complete"; + c.Source = "building"; + c.Category = "Settlement"; + c.Completion = InterestCompletionKind.FixedDwell; + return; + } + + if (expect.IndexOf("taking_roots", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("soft_status", StringComparison.OrdinalIgnoreCase) >= 0) + { + c.StatusId = "taking_roots"; + c.AssetId = "taking_roots"; + c.Completion = InterestCompletionKind.StatusPhase; + c.Category = "Status"; + return; + } + + if (expect.IndexOf("just_slept", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("rest_life", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("ends a rest", StringComparison.OrdinalIgnoreCase) >= 0) + { + c.HappinessEffectId = "just_slept"; + c.AssetId = "just_slept"; + c.Source = "happiness"; + c.Completion = InterestCompletionKind.FixedDwell; + c.Category = "Daily"; + return; + } + + if (expect.IndexOf("sleeping", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("falls asleep", StringComparison.OrdinalIgnoreCase) >= 0) + { + c.StatusId = "sleeping"; + c.AssetId = "sleeping"; + c.Source = "status"; + c.Completion = InterestCompletionKind.StatusPhase; + c.Category = "Status"; + return; + } + + if (expect.IndexOf("just_got_out_of_egg", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("hatch", StringComparison.OrdinalIgnoreCase) >= 0) + { + c.HappinessEffectId = "just_got_out_of_egg"; + c.AssetId = "just_got_out_of_egg"; + c.Completion = InterestCompletionKind.FixedDwell; + return; + } + + if (expect.IndexOf("king_dead", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("worldlog_meta", StringComparison.OrdinalIgnoreCase) >= 0) + { + c.AssetId = "king_dead"; + c.Source = "worldlog"; + c.Category = "Politics"; + c.Completion = InterestCompletionKind.FixedDwell; + return; + } + + if (expect.IndexOf("set_lover", StringComparison.OrdinalIgnoreCase) >= 0 + || expect.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0) + { + c.AssetId = "set_lover"; + c.Category = "Relationship"; + } + } + private static Actor FindOtherAliveUnit(Actor exclude, string preferAsset) { if (World.world?.units == null) @@ -4435,6 +4672,8 @@ public static class AgentHarness c.Category = "CharacterVignette"; InterestScoring.ScoreCheap(c); } + + ApplyHarnessRelationshipAssetHint(c, cmd.expect); bool ok = InterestDirector.HarnessForceSession(c); if (ok) { @@ -5828,6 +6067,96 @@ public static class AgentHarness detail: $"n={want} scale={ensemble.Scale} frame={ensemble.Frame} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)} participants={InterestDirector.CurrentCandidate?.ParticipantCount}"); } + /// + /// Point camera Follow at another living unit without rewriting SubjectId / Label. + /// Proves life-tip reason principals reject bystander dossiers under a named tip. + /// asset = optional species for the bystander (default human). + /// + private static void DoInterestHijackFollow(HarnessCommand cmd) + { + InterestCandidate scene = InterestDirector.CurrentCandidate; + if (scene == null) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_scene"); + return; + } + + long subjectId = scene.SubjectId != 0 + ? scene.SubjectId + : EventFeedUtil.SafeId(scene.FollowUnit); + Actor bystander = null; + string wantAsset = string.IsNullOrEmpty(cmd.asset) ? "human" : cmd.asset.Trim(); + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor == null || !actor.isAlive()) + { + continue; + } + + long id = EventFeedUtil.SafeId(actor); + if (id == 0 || id == subjectId || id == scene.RelatedId) + { + continue; + } + + string species = actor.asset != null ? actor.asset.id : ""; + if (!string.IsNullOrEmpty(wantAsset) + && !species.Equals(wantAsset, StringComparison.OrdinalIgnoreCase)) + { + if (bystander == null) + { + bystander = actor; + } + + continue; + } + + bystander = actor; + break; + } + + if (bystander == null) + { + bystander = SpawnAssetNear( + wantAsset, + scene.FollowUnit != null + ? scene.FollowUnit.current_position + : CameraPos(), + 8f); + } + + if (bystander == null || !bystander.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_bystander"); + return; + } + + // Keep SubjectId / Label on the original tip owner; only Follow + camera move. + if (scene.SubjectId == 0) + { + scene.SubjectId = subjectId; + } + + scene.FollowUnit = bystander; + try + { + scene.Position = bystander.current_position; + } + catch + { + // keep + } + + CameraDirector.FocusUnit(bystander); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"hijackFollow={SafeName(bystander)} subjectId={scene.SubjectId} relatedId={scene.RelatedId} tip='{scene.Label}'"); + } + /// /// Spawn a non-fighting same-side unit, park combat Follow on them (rostered bystander), /// while peers keep fighting. Maintain must retarget off the chore unit. @@ -6267,6 +6596,105 @@ public static class AgentHarness } } + /// + /// Harness: zero one sticky combat camp while keeping PeakParticipants high + /// (repro for Mass vs (0) mop-up labels). value = a|b (default b). + /// + private static void DoCombatWipeStickySide(HarnessCommand cmd) + { + InterestCandidate scene = InterestDirector.CurrentCandidate; + if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no sticky opposing combat sides"); + return; + } + + LiveSceneStickyState sticky = scene.Sticky; + string which = (cmd.value ?? "b").Trim().ToLowerInvariant(); + bool wipeB = which != "a" && which != "sidea" && which != "0"; + int peakBefore = sticky.PeakParticipants; + if (wipeB) + { + sticky.SideBIds.Clear(); + sticky.SideBCount = 0; + } + else + { + sticky.SideAIds.Clear(); + sticky.SideACount = 0; + } + + // Keep peak Mass so the bug class (peak hold + empty camp) is exercised. + sticky.PeakParticipants = Math.Max(peakBefore, Math.Max(8, sticky.TotalCount)); + scene.ParticipantCount = sticky.TotalCount; + scene.CombatSideACount = sticky.SideACount; + scene.CombatSideBCount = sticky.SideBCount; + scene.CombatPeakParticipants = sticky.PeakParticipants; + + string reason = StickyScoreboard.FormatReason(scene, scene.FollowUnit, scene.RelatedUnit); + if (!string.IsNullOrEmpty(reason)) + { + scene.Label = reason; + } + + StickyScoreboard.StabilizeScale(sticky, new LiveEnsemble + { + Kind = EnsembleKind.Combat, + Frame = sticky.Frame, + ParticipantCount = Math.Max(1, sticky.TotalCount), + SideA = new EnsembleSide + { + Key = sticky.SideAKey, + Display = sticky.SideADisplay, + Count = sticky.SideACount + }, + SideB = new EnsembleSide + { + Key = sticky.SideBKey, + Display = sticky.SideBDisplay, + Count = sticky.SideBCount + }, + Focus = scene.FollowUnit, + Related = scene.RelatedUnit + }, Time.unscaledTime); + + string label = EventReason.Combat(new LiveEnsemble + { + Kind = EnsembleKind.Combat, + Scale = sticky.HasPresentedScale ? sticky.PresentedScale : LiveEnsemble.ScaleForCount(Math.Max(1, sticky.TotalCount)), + Frame = sticky.Frame, + Focus = scene.FollowUnit, + Related = scene.RelatedUnit, + SideA = new EnsembleSide + { + Key = sticky.SideAKey, + Display = sticky.SideADisplay, + KingdomDisplay = sticky.SideAKingdom, + Count = sticky.SideACount + }, + SideB = new EnsembleSide + { + Key = sticky.SideBKey, + Display = sticky.SideBDisplay, + KingdomDisplay = sticky.SideBKingdom, + Count = sticky.SideBCount + }, + ParticipantCount = sticky.TotalCount + }); + if (!string.IsNullOrEmpty(label)) + { + scene.Label = label; + CameraDirector.Watch(scene.ToInterestEvent()); + } + + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"wiped={(wipeB ? "b" : "a")} sides={sticky.SideACount}/{sticky.SideBCount} peak={sticky.PeakParticipants} tip='{scene.Label}'"); + } + /// Diagnostics: how many combat fighters LiveEnsemble sees near the scene focus. private static void DoCombatProbeFighters(HarnessCommand cmd) { @@ -6574,6 +7002,21 @@ public static class AgentHarness detail = $"tipAsset='{tipAsset}' want='{want}'"; break; } + case "tip_not_partner": + { + // Remembered bystander must not be named as the fallen / sought victim. + // Subject can still be the partner ("Petelor stands over the fallen" is fine). + string partnerName = SafeName(_happinessPartner); + string tip = CameraDirector.LastWatchLabel ?? ""; + bool namedAsOther = !string.IsNullOrEmpty(partnerName) + && (tip.IndexOf("stands over " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0 + || tip.IndexOf("mourns " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0 + || tip.IndexOf("seeks " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0 + || tip.IndexOf("stays beside " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0); + pass = !namedAsOther; + detail = $"tip='{tip}' partner='{partnerName}' namedAsOther={namedAsOther}"; + break; + } case "story_phase": { string want = (cmd.value ?? "").Trim(); @@ -6584,6 +7027,119 @@ public static class AgentHarness detail = $"storyPhase={have} want={want} kind={arc?.Kind} hard={arc?.HardHold}"; break; } + case "story_spine": + { + string want = (cmd.value ?? "").Trim(); + string have = WatchCaption.LastStorySpine ?? ""; + string formatted = StoryPlanner.FormatSpineLabel() ?? ""; + // Prefer live dossier text; fall back to planner format if HUD not painted yet. + if (string.IsNullOrEmpty(have)) + { + have = formatted; + } + + bool wantEmpty = string.IsNullOrEmpty(want) + || string.Equals(want, "empty", StringComparison.OrdinalIgnoreCase) + || string.Equals(want, "none", StringComparison.OrdinalIgnoreCase); + if (wantEmpty) + { + pass = string.IsNullOrEmpty(have); + } + else if (want.IndexOf('|') >= 0) + { + // Alternatives: "Battle · Climax|Mass · Climax|Skirmish · Climax" + pass = false; + string[] alts = want.Split('|'); + for (int i = 0; i < alts.Length; i++) + { + string alt = (alts[i] ?? "").Trim(); + if (!string.IsNullOrEmpty(alt) + && have.IndexOf(alt, StringComparison.OrdinalIgnoreCase) >= 0) + { + pass = true; + break; + } + } + } + else + { + pass = have.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0; + } + + detail = + $"storySpine='{have}' want='{want}' formatted='{formatted}' " + + $"belongs={StoryPlanner.BelongsToActiveStoryBeat(InterestDirector.CurrentCandidate)} " + + $"phase={StoryPlanner.Active?.Phase.ToString() ?? "None"}"; + break; + } + case "story_hold_margin": + { + // value: "0" / "none" → expect no raised hold; "gt0" / "held" → expect hold > 0 + string want = (cmd.value ?? "gt0").Trim(); + InterestCandidate cur = InterestDirector.CurrentCandidate; + InterestCandidate probe = new InterestCandidate + { + Key = "harness:story_hold_probe", + SubjectId = 999999001, + EventStrength = 40f, + LeadKind = InterestLeadKind.EventLed, + Completion = InterestCompletionKind.FixedDwell, + Label = "HoldProbe" + }; + InterestScoring.RecalcTotal(probe); + float margin = StoryPlanner.ArcHoldMargin(cur, probe); + bool wantHeld = string.Equals(want, "gt0", StringComparison.OrdinalIgnoreCase) + || string.Equals(want, "held", StringComparison.OrdinalIgnoreCase) + || string.Equals(want, "true", StringComparison.OrdinalIgnoreCase); + bool wantNone = string.Equals(want, "0", StringComparison.OrdinalIgnoreCase) + || string.Equals(want, "none", StringComparison.OrdinalIgnoreCase) + || string.Equals(want, "false", StringComparison.OrdinalIgnoreCase); + if (wantHeld) + { + pass = margin > 0f; + } + else if (wantNone) + { + pass = margin <= 0f; + } + else if (float.TryParse(want, NumberStyles.Float, CultureInfo.InvariantCulture, out float wantF)) + { + pass = Mathf.Abs(margin - wantF) < 0.5f; + } + else + { + pass = false; + } + + detail = + $"storyHold={margin:0.#} want={want} phase={StoryPlanner.Active?.Phase.ToString() ?? "None"} " + + $"belongs={StoryPlanner.BelongsToActiveStoryBeat(cur)}"; + break; + } + case "beat_cooled": + { + // value = happiness/asset needle; builds a probe on focus unit. + string needle = (cmd.value ?? "just_had_child").Trim(); + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + long id = EventFeedUtil.SafeId(focus); + InterestCandidate probe = new InterestCandidate + { + Key = "harness:beat_cool:" + needle, + SubjectId = id, + FollowUnit = focus, + HappinessEffectId = needle, + AssetId = needle, + LeadKind = InterestLeadKind.EventLed, + Completion = InterestCompletionKind.FixedDwell, + EventStrength = 80f, + Label = needle + }; + // Map decision/building/status needles onto the same chapter fields as injects. + ApplyHarnessRelationshipAssetHint(probe, needle); + pass = InterestVariety.IsBeatCooled(probe); + detail = $"beatCooled={pass} needle='{needle}' focus={SafeName(focus)} id={id}"; + break; + } case "ledger_has_focus": { Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; @@ -7713,6 +8269,7 @@ public static class AgentHarness case "reason_owns_focus": { // Reason must not name a different live unit than the owned active candidate. + // Non-empty reason also requires focus to be a tip principal (duel pair / roster). UnitDossier dossier = WatchCaption.Current; string reason = dossier?.ReasonLine ?? ""; Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; @@ -7733,6 +8290,13 @@ public static class AgentHarness break; } + if (!InterestDirector.UnitIsReasonPrincipal(focus, owned)) + { + pass = false; + detail = $"reason_without_principal reason='{reason}'"; + break; + } + string stranger = FindStrangerNamedInReason(reason, owned); pass = string.IsNullOrEmpty(stranger); detail = pass diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs b/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs index 54f5110..6410e93 100644 --- a/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs +++ b/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs @@ -278,6 +278,27 @@ public static partial class EventCatalog /// True when a decision id should be allowed to own the idle camera. public static bool IsCameraWorthy(string id) => IsInterestingDecision(id); + /// + /// Life-cycle intent decisions: AI cooldown fires before the outcome tip. + /// Outcomes (pregnancy, child, seeking/smitten, lovers) already own the story - + /// these crumbs soft-cool as a class after one show. + /// Derived from live decisions_library token shape, not tip strings. + /// + public static bool IsLifeIntentDecision(string id) + { + if (string.IsNullOrEmpty(id)) + { + return false; + } + + string s = id.Trim().ToLowerInvariant(); + return s.IndexOf("reproduction", StringComparison.Ordinal) >= 0 + || s == "find_lover" + || s.StartsWith("find_lover_", StringComparison.Ordinal) + || s.IndexOf("baby_make", StringComparison.Ordinal) >= 0 + || s.IndexOf("have_child", StringComparison.Ordinal) >= 0; + } + /// Authored action-phrase ids (JSON catalog ∪ builtin fallback). public static IEnumerable AuthoredActionPhraseIds { diff --git a/IdleSpectator/Events/EventFeedUtil.cs b/IdleSpectator/Events/EventFeedUtil.cs index e2fa521..ddd532e 100644 --- a/IdleSpectator/Events/EventFeedUtil.cs +++ b/IdleSpectator/Events/EventFeedUtil.cs @@ -93,6 +93,9 @@ public static class EventFeedUtil Label = label ?? assetId ?? key, AssetId = assetId ?? "", SpeciesId = subject?.asset != null ? subject.asset.id : "", + // StatusPhase publishes the status id as AssetId - stamp StatusId before Upsert + // so NoteSelection / beat cools see the class on first SwitchTo. + StatusId = completion == InterestCompletionKind.StatusPhase ? (assetId ?? "") : "", CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 35f, @@ -185,6 +188,27 @@ public static class EventFeedUtil /// . /// public static Actor FindAliveById(long id) + { + Actor actor = FindUnitById(id); + if (actor == null) + { + return null; + } + + try + { + return actor.isAlive() ? actor : null; + } + catch + { + return null; + } + } + + /// + /// O(1) actor lookup including dead units (naming fallen duel partners, etc.). + /// + public static Actor FindUnitById(long id) { if (id == 0 || World.world?.units == null) { @@ -193,18 +217,12 @@ public static class EventFeedUtil try { - Actor actor = World.world.units.get(id); - if (actor != null && actor.isAlive()) - { - return actor; - } + return World.world.units.get(id); } catch { - // ignore lookup failures + return null; } - - return null; } public static string SafeName(Actor actor) diff --git a/IdleSpectator/Events/EventReason.cs b/IdleSpectator/Events/EventReason.cs index c3bfc78..ba7095b 100644 --- a/IdleSpectator/Events/EventReason.cs +++ b/IdleSpectator/Events/EventReason.cs @@ -172,7 +172,8 @@ public static class EventReason /// /// Context-aware combat orange reason from a snapshot. - /// Multi form: Battle - Humans (4) vs Wolves (0) - counts may be 0 while sides stay sticky. + /// Multi form: Battle - Humans (4) vs Wolves (3). A wiped camp collapses to a + /// living-side pack label at live scale (never vs … (0)). /// Never uses raw unit names as mass-side labels. Never prints thin melee fallbacks. /// public static string Combat(LiveEnsemble ensemble) @@ -201,26 +202,54 @@ public static class EventReason return "Duel - " + a + " vs " + b; } - string tier = ScaleTierLabel( - ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale); - if (LiveEnsemble.HasOpposingCollectiveSides(ensemble)) { - string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame); - string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame); - if (!string.IsNullOrEmpty(sideA) && !string.IsNullOrEmpty(sideB)) + int countA = ensemble.SideA != null ? Math.Max(0, ensemble.SideA.Count) : 0; + int countB = ensemble.SideB != null ? Math.Max(0, ensemble.SideB.Count) : 0; + // Mop-up: a wiped camp must not print "Mass - A (93) vs B (0)". + // Present the living side at live scale (pack chase / remnant). + if (countA <= 0 || countB <= 0) { - return tier + " - " + sideA + " vs " + sideB; + EnsembleSide liveSide = countA > 0 ? ensemble.SideA : ensemble.SideB; + int liveN = Math.Max(countA, countB); + if (liveSide != null && liveN > 0 && !string.IsNullOrEmpty(liveSide.Key)) + { + EnsembleScale mopScale = LiveEnsemble.ScaleForCount(liveN); + string mopTier = ScaleTierLabel( + mopScale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : mopScale); + EnsembleFrame mopFrame = ensemble.Frame == EnsembleFrame.KingdomVsKingdom + ? EnsembleFrame.KingdomVsKingdom + : EnsembleFrame.SpeciesVsSpecies; + string pack = FormatCombatSide(liveSide, mopFrame); + if (!string.IsNullOrEmpty(pack)) + { + return mopTier + " - " + pack; + } + } + } + else + { + string tier = ScaleTierLabel( + ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale); + string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame); + string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame); + if (!string.IsNullOrEmpty(sideA) && !string.IsNullOrEmpty(sideB)) + { + return tier + " - " + sideA + " vs " + sideB; + } } } + string packTier = ScaleTierLabel( + ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale); + // Same-species pack with no opposing sticky camps. if (ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key)) { string pack = FormatCombatSide(ensemble.SideA, EnsembleFrame.SpeciesVsSpecies); if (!string.IsNullOrEmpty(pack)) { - return tier + " - " + pack; + return packTier + " - " + pack; } } @@ -321,7 +350,7 @@ public static class EventReason } label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : ""); - // Allow 0 so sticky camps can show a side briefly empty, then climb again on rejoin. + // Callers must not pair a zero-count side into "vs" framing (see Combat mop-up). int n = Math.Max(0, side.Count); return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")"; } diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs index 1fd1df8..9bf30ed 100644 --- a/IdleSpectator/Events/Feeds/InterestFeeds.cs +++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs @@ -541,7 +541,8 @@ public static class InterestFeeds FollowUnit = actor, SubjectId = id, Label = EventReason.Hatch(actor), - AssetId = SafeAsset(actor), + // Hatch id owns AssetId so cool keys are class-stable (not species). + AssetId = "just_got_out_of_egg", SpeciesId = SafeAsset(actor), HappinessEffectId = "just_got_out_of_egg", CreatedAt = Time.unscaledTime, @@ -649,7 +650,9 @@ public static class InterestFeeds SubjectId = sid, RelatedId = rid, Label = label ?? milestoneKey, - AssetId = subject.asset != null ? subject.asset.id : "", + // Milestone id owns AssetId so kill/lover/friend crumbs cool as a class + // (species id used to land every hunter tip on arc:asset:elf). + AssetId = milestoneKey, SpeciesId = subject.asset != null ? subject.asset.id : "", CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, diff --git a/IdleSpectator/Events/StickyScoreboard.cs b/IdleSpectator/Events/StickyScoreboard.cs index b5d7cfb..3cfb260 100644 --- a/IdleSpectator/Events/StickyScoreboard.cs +++ b/IdleSpectator/Events/StickyScoreboard.cs @@ -158,6 +158,25 @@ namespace IdleSpectator; } EnsembleScale live = LiveEnsemble.ScaleForCount(n); + // Opposing camp wiped: drop Peak Mass hold immediately so tip tier matches mop-up. + bool wipedOpposing = sticky.HasOpposingSides + && sticky.TotalCount > 0 + && (sticky.SideACount <= 0 || sticky.SideBCount <= 0); + if (wipedOpposing) + { + sticky.HasPresentedScale = true; + sticky.PresentedScale = live; + sticky.ScaleChangeSince = -999f; + sticky.ScaleDropSince = -999f; + ensemble.Scale = live; + if (ensemble.Frame == EnsembleFrame.NamedPair && live > EnsembleScale.Pair) + { + ensemble.Frame = EnsembleFrame.CountOnly; + } + + return; + } + if (!sticky.HasPresentedScale) { sticky.HasPresentedScale = true; @@ -458,10 +477,16 @@ namespace IdleSpectator; } LiveSceneStickyState sticky = scene.Sticky; + bool wipedOpposing = sticky.HasOpposingSides + && sticky.TotalCount > 0 + && (sticky.SideACount <= 0 || sticky.SideBCount <= 0); + int scaleN = wipedOpposing + ? Math.Max(1, sticky.TotalCount) + : Math.Max(3, sticky.PeakParticipants); var ensemble = new LiveEnsemble { Kind = sticky.Kind, - Scale = LiveEnsemble.ScaleForCount(Math.Max(3, sticky.PeakParticipants)), + Scale = LiveEnsemble.ScaleForCount(scaleN), Focus = focus, Related = related, Frame = sticky.Frame, diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 6f48e06..5cf2eb8 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -67,8 +67,57 @@ internal static class HarnessScenarios case "story_aftermath_combat": case "story_aftermath": return StoryAftermathCombat(); + case "story_aftermath_cast_noise": + case "soft_duel_aftermath_noise": + return StoryAftermathCastNoise(); + case "reason_duel_principal": + case "story_reason_principal": + return ReasonDuelPrincipal(); + case "reason_life_principal": + case "story_reason_life": + return ReasonLifePrincipal(); + case "story_intimacy_cool": + case "intimacy_cool": + return StoryIntimacyCool(); + case "story_settlement_cool": + case "settlement_cool": + return StorySettlementCool(); + case "story_kill_cool": + case "kill_cool": + return StoryKillCool(); + case "story_courtship_cool": + case "courtship_cool": + case "story_love_cool": + return StoryCourtshipCool(); + case "combat_wiped_side": + case "story_wiped_side": + return CombatWipedSide(); + case "story_decision_intent_cool": + case "decision_intent_cool": + return StoryDecisionIntentCool(); + case "story_construction_cool": + case "construction_cool": + return StoryConstructionCool(); + case "story_status_fx_cool": + case "status_fx_cool": + return StoryStatusFxCool(); + case "story_resume_cooled": + case "resume_cooled": + return StoryResumeCooled(); + case "story_worldlog_meta_cool": + case "worldlog_meta_cool": + return StoryWorldLogMetaCool(); + case "story_rest_cool": + case "rest_cool": + return StoryRestCool(); + case "story_decision_intent_live": + case "decision_intent_live": + return StoryDecisionIntentLive(); case "story_aftermath_war": return StoryAftermathWar(); + case "story_aftermath_partner_truth": + case "aftermath_partner_truth": + return StoryAftermathPartnerTruth(); case "story_near_tie": case "variety_near_tie": return StoryNearTie(); @@ -78,6 +127,10 @@ internal static class HarnessScenarios return StoryArcPreemptResume(); case "story_ledger_revisit": return StoryLedgerRevisit(); + case "story_spine_scoped": + return StorySpineScoped(); + case "story_family_variety": + return StoryFamilyVariety(); case "war_front_sticky": case "war_sticky": return WarFrontSticky(); @@ -237,8 +290,26 @@ internal static class HarnessScenarios Nested("reg_combat_hold", "combat_hold_until_end"), Nested("reg_combat_stability", "combat_stability_live"), Nested("reg_story_aftermath", "story_aftermath_combat"), + Nested("reg_story_aftermath_noise", "story_aftermath_cast_noise"), + Nested("reg_story_aftermath_partner_truth", "story_aftermath_partner_truth"), + Nested("reg_reason_duel_principal", "reason_duel_principal"), + Nested("reg_reason_life_principal", "reason_life_principal"), + Nested("reg_story_intimacy_cool", "story_intimacy_cool"), + Nested("reg_story_settlement_cool", "story_settlement_cool"), + Nested("reg_story_kill_cool", "story_kill_cool"), + Nested("reg_story_courtship_cool", "story_courtship_cool"), + Nested("reg_story_decision_intent_cool", "story_decision_intent_cool"), + Nested("reg_story_construction_cool", "story_construction_cool"), + Nested("reg_story_status_fx_cool", "story_status_fx_cool"), + Nested("reg_story_resume_cooled", "story_resume_cooled"), + Nested("reg_story_worldlog_meta_cool", "story_worldlog_meta_cool"), + Nested("reg_story_rest_cool", "story_rest_cool"), + Nested("reg_story_decision_intent_live", "story_decision_intent_live"), + Nested("reg_combat_wiped_side", "combat_wiped_side"), Nested("reg_story_near_tie", "story_near_tie"), Nested("reg_story_arc", "story_arc_combat"), + Nested("reg_story_spine", "story_spine_scoped"), + Nested("reg_story_family", "story_family_variety"), Nested("reg_story_ledger", "story_ledger_revisit"), Nested("reg_discovery", "discovery"), Nested("reg_worldlog", "world_log"), @@ -1482,11 +1553,21 @@ internal static class HarnessScenarios Step("sa24", "interest_expire_pending", value: ""), Step("sa25", "age_current", wait: 10f), Step("sa26", "interest_mark_combat_cold"), + // Cold climax tip while phase is Aftermath must not show a lying spine. + Step("sa26b", "assert", expect: "tip_matches_any", value: "Duel -"), + Step("sa26c", "assert", expect: "story_phase", value: "Aftermath"), + Step("sa26d", "assert", expect: "story_spine", value: "empty"), Step("sa27", "director_run", wait: 1.5f), Step("sa28", "assert", expect: "tip_matches_any", value: "stands over|mourns|lingers"), Step("sa29", "assert", expect: "tip_asset", value: "aftermath_"), Step("sa30", "assert", expect: "tip_not_contains", value: "Duel -"), Step("sa31", "assert", expect: "story_phase", value: "Aftermath"), + Step("sa31b", "assert", expect: "story_spine", value: "Duel · Aftermath"), + // Drop Follow mid-aftermath - cast recovery must keep the story tip. + Step("sa32", "interest_drop_follow"), + Step("sa33", "director_run", wait: 0.8f), + Step("sa34", "assert", expect: "tip_asset", value: "aftermath_"), + Step("sa35", "assert", expect: "story_phase", value: "Aftermath"), Step("sa90", "fast_timing", value: "false"), Step("sa99", "snapshot"), }; @@ -1518,6 +1599,674 @@ internal static class HarnessScenarios }; } + /// + /// Combat aftermath must not name a living lover/friend as the fallen + /// (soak: "stands over Clemond" after a duel vs someone else). + /// + private static List StoryAftermathPartnerTruth() + { + return new List + { + Step("saf0", "dismiss_windows"), + Step("saf1", "wait_world"), + Step("saf2", "set_setting", expect: "enabled", value: "true"), + Step("saf3", "fast_timing", value: "true"), + // Lover bystander (not in the duel) remembered first; fighter is the next spawn. + Step("saf4", "spawn", asset: "human", count: 1), + Step("saf5", "happiness_remember_partner"), + Step("saf6", "spawn", asset: "human", count: 1), + Step("saf7", "focus", asset: "auto"), + Step("saf8", "happiness_bond_lovers"), + Step("saf9", "spawn", asset: "wolf", count: 1), + Step("saf10", "spectator", value: "off"), + Step("saf11", "spectator", value: "on"), + // Keep the fighter (human focus), not the wolf, as combat Follow. + Step("saf12", "pick_unit", asset: "human"), + Step("saf12b", "focus", asset: "human"), + Step("saf13", "interest_end_session"), + Step("saf14", "interest_story_clear"), + Step("saf20", "interest_combat_session", asset: "human", value: "wolf", expect: "saf_pack"), + Step("saf20b", "combat_isolate_pair", value: "20"), + Step("saf21", "status_apply", asset: "human", value: "invincible"), + Step("saf22", "status_apply", asset: "wolf", value: "invincible"), + Step("saf23", "assert", expect: "tip_matches_any", value: "Duel -"), + Step("saf24", "interest_expire_pending", value: ""), + Step("saf25", "age_current", wait: 10f), + Step("saf26", "interest_mark_combat_cold"), + Step("saf27", "director_run", wait: 1.5f), + // Living theater partner → generic fallen prose; never the lover's name. + Step("saf28", "assert", expect: "tip_contains", value: "stands over the fallen"), + Step("saf29", "assert", expect: "tip_asset", value: "aftermath_"), + Step("saf30", "assert", expect: "tip_not_partner"), + Step("saf90", "fast_timing", value: "false"), + Step("saf99", "snapshot"), + }; + } + + /// + /// Soft-duel cold still injects aftermath when high-strength cast life noise is pending + /// (hatch/eat must not count as natural grief/love peers). + /// + private static List StoryAftermathCastNoise() + { + return new List + { + Step("san0", "dismiss_windows"), + Step("san1", "wait_world"), + Step("san2", "set_setting", expect: "enabled", value: "true"), + Step("san3", "fast_timing", value: "true"), + Step("san4", "spawn", asset: "human", count: 1), + Step("san5", "spawn", asset: "wolf", count: 1), + Step("san6", "spectator", value: "off"), + Step("san7", "spectator", value: "on"), + Step("san8", "pick_unit", asset: "human"), + Step("san9", "focus", asset: "human"), + Step("san10", "interest_end_session"), + Step("san10b", "interest_story_clear"), + Step("san10c", "interest_variety_clear"), + Step("san10d", "interest_expire_pending", value: ""), + Step("san20", "interest_combat_session", asset: "human", value: "wolf", expect: "san_pack"), + Step("san20b", "combat_isolate_pair", value: "20"), + Step("san21", "status_apply", asset: "human", value: "invincible"), + Step("san22", "status_apply", asset: "wolf", value: "invincible"), + Step("san23", "assert", expect: "tip_matches_any", value: "Duel -"), + // High-score cast-life distractor (expect id must NOT contain "hatch" or the + // harness maps it onto just_got_out_of_egg and it can sticky-win the cold). + Step("san24", "interest_inject", asset: "human", label: "{a} munches quietly nearby", + tier: "Action", expect: "san_cast_noise", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("san25", "age_current", wait: 10f), + Step("san26", "interest_mark_combat_cold"), + Step("san27", "director_run", wait: 1.5f), + Step("san28", "assert", expect: "tip_matches_any", value: "stands over|mourns|lingers"), + Step("san29", "assert", expect: "tip_asset", value: "aftermath_"), + Step("san30", "assert", expect: "tip_not_contains", value: "Duel -"), + Step("san31", "assert", expect: "story_phase", value: "Aftermath"), + Step("san90", "fast_timing", value: "false"), + Step("san99", "snapshot"), + }; + } + + /// + /// Life-tip reason principals: Follow hijack onto a bystander must not keep a + /// subject-led intimacy Label on the bystander dossier. + /// + private static List ReasonLifePrincipal() + { + return new List + { + Step("rlp0", "dismiss_windows"), + Step("rlp1", "wait_world"), + Step("rlp2", "set_setting", expect: "enabled", value: "true"), + Step("rlp3", "fast_timing", value: "true"), + Step("rlp4", "spawn", asset: "human", count: 3), + Step("rlp5", "spectator", value: "off"), + Step("rlp6", "spectator", value: "on"), + Step("rlp7", "pick_unit", asset: "human"), + Step("rlp8", "focus", asset: "human"), + Step("rlp9", "interest_end_session"), + Step("rlp20", "interest_force_session", asset: "human", + label: "{a} shares intimacy with a partner", tier: "Action", expect: "just_kissed", + value: "lead=event;evt=62"), + Step("rlp21", "assert", expect: "reason_matches_any", value: "intimacy|mates"), + Step("rlp22", "assert", expect: "reason_owns_focus"), + // Follow drifts to a bystander; SubjectId + Label stay on the couple tip. + Step("rlp30", "interest_hijack_follow", asset: "human"), + Step("rlp31", "wait", value: "0.25"), + Step("rlp32", "assert", expect: "dossier_matches_focus"), + Step("rlp33", "assert", expect: "reason_empty", value: "true"), + Step("rlp34", "assert", expect: "reason_owns_focus"), + Step("rlp35", "assert", expect: "dossier_not_contains", value: "shares intimacy"), + Step("rlp90", "fast_timing", value: "false"), + Step("rlp99", "snapshot"), + }; + } + + /// + /// just_found_house / finds-a-home soft-life cool: settlement crumbs must not + /// keep cutting between combat beats. + /// + private static List StorySettlementCool() + { + return new List + { + Step("ssc0", "dismiss_windows"), + Step("ssc1", "wait_world"), + Step("ssc2", "set_setting", expect: "enabled", value: "true"), + Step("ssc3", "fast_timing", value: "true"), + Step("ssc4", "spawn", asset: "human", count: 2), + Step("ssc5", "spectator", value: "off"), + Step("ssc6", "spectator", value: "on"), + Step("ssc7", "pick_unit", asset: "human"), + Step("ssc8", "focus", asset: "human"), + Step("ssc9", "interest_end_session"), + Step("ssc10", "interest_variety_clear"), + Step("ssc20", "interest_force_session", asset: "human", + label: "{a} finds a home", tier: "Action", expect: "just_found_house", + value: "lead=event;evt=58"), + Step("ssc21", "assert", expect: "tip_contains", value: "finds a home"), + Step("ssc22", "assert", expect: "beat_cooled", value: "just_found_house"), + Step("ssc23", "interest_end_session"), + Step("ssc30", "interest_inject", asset: "human", + label: "{a} finds a home", tier: "Action", expect: "just_found_house", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("ssc31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action", + expect: "ssc_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"), + Step("ssc32", "director_run", wait: 1.2f), + Step("ssc33", "assert", expect: "tip_contains", value: "ClearPeer"), + Step("ssc34", "assert", expect: "tip_not_contains", value: "finds a home"), + Step("ssc35", "assert", expect: "beat_cooled", value: "just_found_house"), + Step("ssc90", "fast_timing", value: "false"), + Step("ssc99", "snapshot"), + }; + } + + /// + /// just_kissed / intimacy soft-life cool: after one show, the same beat cannot + /// immediately cut back in over a stronger peer. + /// + private static List StoryIntimacyCool() + { + return new List + { + Step("sic0", "dismiss_windows"), + Step("sic1", "wait_world"), + Step("sic2", "set_setting", expect: "enabled", value: "true"), + Step("sic3", "fast_timing", value: "true"), + Step("sic4", "spawn", asset: "human", count: 2), + Step("sic5", "spectator", value: "off"), + Step("sic6", "spectator", value: "on"), + Step("sic7", "pick_unit", asset: "human"), + Step("sic8", "focus", asset: "human"), + Step("sic9", "interest_end_session"), + Step("sic10", "interest_variety_clear"), + Step("sic20", "interest_force_session", asset: "human", + label: "{a} shares intimacy with a partner", tier: "Action", expect: "just_kissed", + value: "lead=event;evt=62"), + Step("sic21", "assert", expect: "tip_matches_any", value: "intimacy|mates"), + Step("sic22", "assert", expect: "beat_cooled", value: "just_kissed"), + Step("sic23", "interest_end_session"), + // Same intimacy beat pending again - must stay cooled while a clear peer wins. + Step("sic30", "interest_inject", asset: "human", + label: "{a} shares intimacy with a partner", tier: "Action", expect: "just_kissed", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("sic31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action", + expect: "sic_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"), + Step("sic32", "director_run", wait: 1.2f), + Step("sic33", "assert", expect: "tip_contains", value: "ClearPeer"), + Step("sic34", "assert", expect: "tip_not_contains", value: "intimacy"), + Step("sic35", "assert", expect: "beat_cooled", value: "just_kissed"), + Step("sic90", "fast_timing", value: "false"), + Step("sic99", "snapshot"), + }; + } + + /// + /// Chronicle kill crumbs (Killed X) soft-life cool: after one show, kill beats + /// cannot keep cutting between stronger peers / mass tips. + /// + private static List StoryKillCool() + { + return new List + { + Step("skc0", "dismiss_windows"), + Step("skc1", "wait_world"), + Step("skc2", "set_setting", expect: "enabled", value: "true"), + Step("skc3", "fast_timing", value: "true"), + Step("skc4", "spawn", asset: "human", count: 2), + Step("skc5", "spectator", value: "off"), + Step("skc6", "spectator", value: "on"), + Step("skc7", "pick_unit", asset: "human"), + Step("skc8", "focus", asset: "human"), + Step("skc8b", "status_apply", asset: "human", value: "invincible"), + Step("skc9", "interest_end_session"), + Step("skc10", "interest_variety_clear"), + Step("skc20", "interest_force_session", asset: "human", + label: "Killed Phixzen (fox)", tier: "Action", expect: "milestone_kill", + value: "lead=event;evt=60"), + Step("skc21", "assert", expect: "tip_contains", value: "Killed"), + Step("skc22", "assert", expect: "beat_cooled", value: "milestone_kill"), + Step("skc23", "interest_end_session"), + Step("skc30", "interest_inject", asset: "human", + label: "Killed OtherFox (fox)", tier: "Action", expect: "milestone_kill", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("skc31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action", + expect: "skc_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"), + Step("skc32", "director_run", wait: 1.2f), + Step("skc33", "assert", expect: "tip_contains", value: "ClearPeer"), + Step("skc34", "assert", expect: "tip_not_contains", value: "Killed"), + Step("skc35", "assert", expect: "beat_cooled", value: "milestone_kill"), + Step("skc90", "fast_timing", value: "false"), + Step("skc99", "snapshot"), + }; + } + + /// + /// find_lover / fallen_in_love courtship soft-life cool: seeking stamps the shared + /// love class so smitten/seeking crumbs cannot cut over a clear peer. + /// + private static List StoryCourtshipCool() + { + return new List + { + Step("scc0", "dismiss_windows"), + Step("scc1", "wait_world"), + Step("scc2", "set_setting", expect: "enabled", value: "true"), + Step("scc3", "fast_timing", value: "true"), + Step("scc4", "spawn", asset: "human", count: 3), + Step("scc5", "spectator", value: "off"), + Step("scc6", "spectator", value: "on"), + Step("scc7", "pick_unit", asset: "human"), + Step("scc8", "focus", asset: "human"), + // Dirty worlds often have leftover wolves from combat gates. + Step("scc8b", "status_apply", asset: "human", value: "invincible"), + Step("scc9", "interest_end_session"), + Step("scc10", "interest_variety_clear"), + Step("scc11", "interest_story_clear"), + Step("scc20", "interest_force_session", asset: "human", + label: "{a} is seeking a lover", tier: "Action", expect: "find_lover", + value: "lead=event;evt=62"), + Step("scc21", "assert", expect: "tip_contains", value: "seeking a lover"), + Step("scc22", "assert", expect: "beat_cooled", value: "find_lover"), + // Shared arc:life:love world cool also covers smitten. + Step("scc23", "assert", expect: "beat_cooled", value: "fallen_in_love"), + Step("scc24", "interest_end_session"), + Step("scc25", "interest_story_clear"), + Step("scc30", "interest_inject", asset: "human", + label: "{a} is seeking a lover", tier: "Action", expect: "find_lover", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("scc31", "interest_inject", asset: "human", + label: "{a} is smitten with a partner", tier: "Action", expect: "fallen_in_love", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("scc32", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action", + expect: "scc_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"), + Step("scc33", "director_run", wait: 1.2f), + Step("scc34", "assert", expect: "tip_contains", value: "ClearPeer"), + Step("scc35", "assert", expect: "tip_not_contains", value: "smitten"), + Step("scc36", "assert", expect: "tip_not_contains", value: "seeking a lover"), + Step("scc37", "assert", expect: "beat_cooled", value: "fallen_in_love"), + Step("scc38", "assert", expect: "beat_cooled", value: "find_lover"), + Step("scc90", "fast_timing", value: "false"), + Step("scc99", "snapshot"), + }; + } + + /// + /// Life-intent decisions (reproduce / find lover) soft-cool as a class: + /// one show cools the whole intent family so ClearPeer wins. + /// + private static List StoryDecisionIntentCool() + { + return new List + { + Step("sdc0", "dismiss_windows"), + Step("sdc1", "wait_world"), + Step("sdc2", "set_setting", expect: "enabled", value: "true"), + Step("sdc3", "fast_timing", value: "true"), + Step("sdc4", "spawn", asset: "human", count: 3), + Step("sdc5", "spectator", value: "off"), + Step("sdc6", "spectator", value: "on"), + Step("sdc7", "pick_unit", asset: "human"), + Step("sdc8", "focus", asset: "human"), + Step("sdc8b", "status_apply", asset: "human", value: "invincible"), + Step("sdc9", "interest_end_session"), + Step("sdc10", "interest_variety_clear"), + Step("sdc11", "interest_story_clear"), + Step("sdc20", "interest_force_session", asset: "human", + label: "{a} decides to try to reproduce", tier: "Action", + expect: "sexual_reproduction_try", value: "lead=event;evt=66"), + Step("sdc21", "assert", expect: "tip_contains", value: "try to reproduce"), + Step("sdc22", "assert", expect: "beat_cooled", value: "sexual_reproduction_try"), + // Shared intent class also cools decision find_lover. + Step("sdc23", "assert", expect: "beat_cooled", value: "decision_find_lover"), + Step("sdc24", "interest_end_session"), + Step("sdc25", "interest_story_clear"), + Step("sdc30", "interest_inject", asset: "human", + label: "{a} decides to try to reproduce", tier: "Action", + expect: "sexual_reproduction_try", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("sdc31", "interest_inject", asset: "human", + label: "{a} decides to find a lover", tier: "Action", + expect: "decision_find_lover", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("sdc32", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action", + expect: "sdc_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"), + Step("sdc33", "director_run", wait: 1.2f), + Step("sdc34", "assert", expect: "tip_contains", value: "ClearPeer"), + Step("sdc35", "assert", expect: "tip_not_contains", value: "reproduce"), + Step("sdc36", "assert", expect: "tip_not_contains", value: "find a lover"), + Step("sdc90", "fast_timing", value: "false"), + Step("sdc99", "snapshot"), + }; + } + + /// + /// Ordinary civ construction finishes soft-cool world-wide so tent spam cannot chain. + /// + private static List StoryConstructionCool() + { + return new List + { + Step("sco0", "dismiss_windows"), + Step("sco1", "wait_world"), + Step("sco2", "set_setting", expect: "enabled", value: "true"), + Step("sco3", "fast_timing", value: "true"), + Step("sco4", "spawn", asset: "human", count: 3), + Step("sco5", "spectator", value: "off"), + Step("sco6", "spectator", value: "on"), + Step("sco7", "pick_unit", asset: "human"), + Step("sco8", "focus", asset: "human"), + Step("sco8b", "status_apply", asset: "human", value: "invincible"), + Step("sco9", "interest_end_session"), + Step("sco10", "interest_variety_clear"), + Step("sco11", "interest_story_clear"), + Step("sco20", "interest_force_session", asset: "human", + label: "{a} finishes Tent Human", tier: "Action", expect: "tent_human", + value: "lead=event;evt=72"), + Step("sco21", "assert", expect: "tip_contains", value: "finishes"), + Step("sco22", "assert", expect: "beat_cooled", value: "tent_human"), + Step("sco23", "interest_end_session"), + Step("sco30", "interest_inject", asset: "human", + label: "{a} finishes Tent Human", tier: "Action", expect: "tent_human", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("sco31", "interest_inject", asset: "human", + label: "{a} finishes Tent Dwarf", tier: "Action", expect: "construction", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("sco32", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action", + expect: "sco_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"), + Step("sco33", "director_run", wait: 1.2f), + Step("sco34", "assert", expect: "tip_contains", value: "ClearPeer"), + Step("sco35", "assert", expect: "tip_not_contains", value: "finishes"), + Step("sco90", "fast_timing", value: "false"), + Step("sco99", "snapshot"), + }; + } + + /// + /// Soft-life tip interrupted by a stronger peer must not resume after the peer ends. + /// + private static List StoryResumeCooled() + { + return new List + { + Step("src0", "dismiss_windows"), + Step("src1", "wait_world"), + Step("src2", "set_setting", expect: "enabled", value: "true"), + Step("src3", "fast_timing", value: "true"), + Step("src4", "spawn", asset: "human", count: 3), + Step("src5", "spectator", value: "off"), + Step("src6", "spectator", value: "on"), + Step("src7", "pick_unit", asset: "human"), + Step("src8", "focus", asset: "human"), + Step("src8b", "status_apply", asset: "human", value: "invincible"), + Step("src9", "interest_end_session"), + Step("src10", "interest_variety_clear"), + Step("src11", "interest_story_clear"), + // Soft crumb first (resumable FixedDwell - not ForceActive, so epic can cut in). + Step("src20", "interest_inject", asset: "human", + label: "{a} decides to try to reproduce", tier: "Action", + expect: "sexual_reproduction_try", + value: "lead=event;evt=66;char=5;force=false;ttl=60"), + Step("src21", "director_run", wait: 1.2f), + Step("src22", "assert", expect: "tip_contains", value: "try to reproduce"), + Step("src23", "assert", expect: "beat_cooled", value: "sexual_reproduction_try"), + // Stronger peer cuts in (margin above cutInMargin). + Step("src30", "interest_inject", asset: "human", label: "EpicCutIn", tier: "Epic", + expect: "src_epic", value: "lead=event;evt=150;char=10;force=false;ttl=60"), + Step("src31", "director_run", wait: 1.2f), + Step("src32", "assert", expect: "tip_contains", value: "EpicCutIn"), + Step("src33", "assert", expect: "tip_not_contains", value: "reproduce"), + // Ending the cut-in must not resume the cooled reproduce tip. + Step("src40", "interest_end_session"), + Step("src41", "interest_story_clear"), + Step("src42", "interest_inject", asset: "human", + label: "{a} decides to try to reproduce", tier: "Action", + expect: "sexual_reproduction_try", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("src43", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action", + expect: "src_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"), + Step("src44", "director_run", wait: 1.2f), + Step("src45", "assert", expect: "tip_contains", value: "ClearPeer"), + Step("src46", "assert", expect: "tip_not_contains", value: "reproduce"), + Step("src47", "assert", expect: "beat_cooled", value: "sexual_reproduction_try"), + Step("src90", "fast_timing", value: "false"), + Step("src99", "snapshot"), + }; + } + + /// + /// WorldLog politics meta (king_dead) soft-cools so sticky death crumbs cannot re-monopolize. + /// + private static List StoryWorldLogMetaCool() + { + return new List + { + Step("swm0", "dismiss_windows"), + Step("swm1", "wait_world"), + Step("swm2", "set_setting", expect: "enabled", value: "true"), + Step("swm3", "fast_timing", value: "true"), + Step("swm4", "spawn", asset: "human", count: 3), + Step("swm5", "spectator", value: "off"), + Step("swm6", "spectator", value: "on"), + Step("swm7", "pick_unit", asset: "human"), + Step("swm8", "focus", asset: "human"), + Step("swm8b", "status_apply", asset: "human", value: "invincible"), + Step("swm9", "interest_end_session"), + Step("swm10", "interest_variety_clear"), + Step("swm11", "interest_story_clear"), + Step("swm20", "interest_force_session", asset: "human", + label: "King died: Frortied (Rugen)", tier: "Action", expect: "king_dead", + value: "lead=event;evt=84"), + Step("swm21", "assert", expect: "tip_contains", value: "King died"), + Step("swm22", "assert", expect: "beat_cooled", value: "king_dead"), + Step("swm23", "interest_end_session"), + Step("swm30", "interest_inject", asset: "human", + label: "King died: Frortied (Rugen)", tier: "Action", expect: "king_dead", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("swm31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action", + expect: "swm_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"), + Step("swm32", "director_run", wait: 1.2f), + Step("swm33", "assert", expect: "tip_contains", value: "ClearPeer"), + Step("swm34", "assert", expect: "tip_not_contains", value: "King died"), + Step("swm90", "fast_timing", value: "false"), + Step("swm99", "snapshot"), + }; + } + + /// + /// Soft personal status FX (taking_roots) cool world-wide after one show. + /// + private static List StoryStatusFxCool() + { + return new List + { + Step("sfx0", "dismiss_windows"), + Step("sfx1", "wait_world"), + Step("sfx2", "set_setting", expect: "enabled", value: "true"), + Step("sfx3", "fast_timing", value: "true"), + Step("sfx4", "spawn", asset: "human", count: 3), + Step("sfx5", "spectator", value: "off"), + Step("sfx6", "spectator", value: "on"), + Step("sfx7", "pick_unit", asset: "human"), + Step("sfx8", "focus", asset: "human"), + Step("sfx8b", "status_apply", asset: "human", value: "invincible"), + Step("sfx9", "interest_end_session"), + Step("sfx10", "interest_variety_clear"), + Step("sfx11", "interest_story_clear"), + Step("sfx20", "interest_force_session", asset: "human", + label: "{a} · Starts taking root", tier: "Action", expect: "taking_roots", + value: "lead=event;evt=54"), + Step("sfx21", "assert", expect: "tip_contains", value: "taking root"), + Step("sfx22", "assert", expect: "beat_cooled", value: "taking_roots"), + Step("sfx23", "interest_end_session"), + Step("sfx30", "interest_inject", asset: "human", + label: "{a} · Starts taking root", tier: "Action", expect: "taking_roots", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("sfx31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action", + expect: "sfx_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"), + Step("sfx32", "director_run", wait: 1.2f), + Step("sfx33", "assert", expect: "tip_contains", value: "ClearPeer"), + Step("sfx34", "assert", expect: "tip_not_contains", value: "taking root"), + Step("sfx90", "fast_timing", value: "false"), + Step("sfx99", "snapshot"), + }; + } + + /// + /// Sleep / wake / dream rest cycle soft-cools as one class after one show. + /// + private static List StoryRestCool() + { + return new List + { + Step("srt0", "dismiss_windows"), + Step("srt1", "wait_world"), + Step("srt2", "set_setting", expect: "enabled", value: "true"), + Step("srt3", "fast_timing", value: "true"), + Step("srt4", "spawn", asset: "human", count: 3), + Step("srt5", "spectator", value: "off"), + Step("srt6", "spectator", value: "on"), + Step("srt7", "pick_unit", asset: "human"), + Step("srt8", "focus", asset: "human"), + Step("srt8b", "status_apply", asset: "human", value: "invincible"), + Step("srt9", "interest_end_session"), + Step("srt10", "interest_variety_clear"), + Step("srt11", "interest_story_clear"), + Step("srt20", "interest_force_session", asset: "human", + label: "{a} · Falls asleep", tier: "Action", expect: "sleeping", + value: "lead=event;evt=54"), + Step("srt21", "assert", expect: "tip_contains", value: "Falls asleep"), + Step("srt22", "assert", expect: "beat_cooled", value: "sleeping"), + // Shared rest class also cools wake / dream crumbs. + Step("srt23", "assert", expect: "beat_cooled", value: "just_slept"), + Step("srt24", "interest_end_session"), + Step("srt30", "interest_inject", asset: "human", + label: "{a} · Falls asleep", tier: "Action", expect: "sleeping", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("srt31", "interest_inject", asset: "human", + label: "{a} ends a rest", tier: "Action", expect: "just_slept", + value: "lead=event;evt=88;char=10;force=false;ttl=60"), + Step("srt32", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action", + expect: "srt_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"), + Step("srt33", "director_run", wait: 1.2f), + Step("srt34", "assert", expect: "tip_contains", value: "ClearPeer"), + Step("srt35", "assert", expect: "tip_not_contains", value: "Falls asleep"), + Step("srt36", "assert", expect: "tip_not_contains", value: "ends a rest"), + Step("srt90", "fast_timing", value: "false"), + Step("srt99", "snapshot"), + }; + } + + /// + /// Live DeferredInterestFeed EmitDecision path cools intent class (not only harness inject). + /// + private static List StoryDecisionIntentLive() + { + return new List + { + Step("sdl0", "dismiss_windows"), + Step("sdl1", "wait_world"), + Step("sdl2", "set_setting", expect: "enabled", value: "true"), + Step("sdl3", "fast_timing", value: "true"), + Step("sdl4", "spawn", asset: "human", count: 3), + Step("sdl5", "spectator", value: "off"), + Step("sdl6", "spectator", value: "on"), + Step("sdl7", "pick_unit", asset: "human"), + Step("sdl8", "focus", asset: "human"), + Step("sdl8b", "status_apply", asset: "human", value: "invincible"), + Step("sdl9", "interest_end_session"), + Step("sdl10", "interest_variety_clear"), + Step("sdl11", "interest_story_clear"), + Step("sdl20", "decision_emit", expect: "sexual_reproduction_try"), + Step("sdl21", "director_run", wait: 1.2f), + Step("sdl22", "assert", expect: "tip_contains", value: "try to reproduce"), + Step("sdl23", "assert", expect: "beat_cooled", value: "sexual_reproduction_try"), + Step("sdl24", "assert", expect: "beat_cooled", value: "decision_find_lover"), + Step("sdl25", "interest_end_session"), + Step("sdl26", "interest_story_clear"), + Step("sdl30", "decision_emit", expect: "find_lover"), + Step("sdl31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action", + expect: "sdl_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"), + Step("sdl32", "director_run", wait: 1.2f), + Step("sdl33", "assert", expect: "tip_contains", value: "ClearPeer"), + Step("sdl34", "assert", expect: "tip_not_contains", value: "reproduce"), + Step("sdl35", "assert", expect: "tip_not_contains", value: "find a lover"), + Step("sdl90", "fast_timing", value: "false"), + Step("sdl99", "snapshot"), + }; + } + + /// + /// Peak Mass with one sticky camp wiped to 0 must not print "vs … (0)". + /// + private static List CombatWipedSide() + { + return new List + { + Step("cws0", "dismiss_windows"), + Step("cws1", "wait_world"), + Step("cws2", "set_setting", expect: "enabled", value: "true"), + Step("cws3", "fast_timing", value: "true"), + Step("cws4", "spawn", asset: "human", count: 4), + Step("cws5", "spawn", asset: "wolf", count: 4), + Step("cws6", "spectator", value: "off"), + Step("cws7", "spectator", value: "on"), + Step("cws8", "pick_unit", asset: "human"), + Step("cws9", "focus", asset: "human"), + Step("cws10", "interest_end_session"), + Step("cws20", "interest_combat_session", asset: "human", value: "wolf", expect: "cws_pack"), + Step("cws21", "combat_ensemble_escalate", value: "8"), + Step("cws22", "combat_wire_attack_sides", asset: "human", value: "wolf"), + Step("cws23", "combat_maintain_focus"), + Step("cws24", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"), + Step("cws25", "assert", expect: "tip_matches_any", value: " vs "), + // Wipe sticky B only (do not maintain - live wolves would re-enroll on SideBKey). + Step("cws30", "combat_wipe_sticky_side", value: "b"), + Step("cws31", "assert", expect: "tip_not_contains", value: "(0)"), + Step("cws32", "assert", expect: "tip_not_contains", value: " vs "), + Step("cws33", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"), + Step("cws90", "fast_timing", value: "false"), + Step("cws99", "snapshot"), + }; + } + + /// + /// Pair-locked duel reason only on PairOwner/Partner - parked sticky Follow bystander + /// must not show "Duel - A vs B" on their dossier. + /// + private static List ReasonDuelPrincipal() + { + return new List + { + Step("rdp0", "dismiss_windows"), + Step("rdp1", "wait_world"), + Step("rdp2", "set_setting", expect: "enabled", value: "true"), + Step("rdp3", "fast_timing", value: "true"), + Step("rdp4", "spawn", asset: "human", count: 1), + Step("rdp5", "spawn", asset: "wolf", count: 1), + Step("rdp6", "spectator", value: "off"), + Step("rdp7", "spectator", value: "on"), + Step("rdp8", "pick_unit", asset: "human"), + Step("rdp9", "focus", asset: "human"), + Step("rdp10", "interest_end_session"), + Step("rdp20", "interest_combat_session", asset: "human", value: "wolf", expect: "rdp_duel"), + Step("rdp20b", "combat_isolate_pair", value: "20"), + Step("rdp21", "status_apply", asset: "human", value: "invincible"), + Step("rdp22", "status_apply", asset: "wolf", value: "invincible"), + Step("rdp23", "assert", expect: "tip_matches_any", value: "Duel -"), + Step("rdp24", "assert", expect: "reason_matches_any", value: "Duel -"), + Step("rdp25", "assert", expect: "reason_owns_focus"), + // Park Follow on a rostered sleeper who is not PairOwner/Partner. + Step("rdp30", "combat_park_bystander_follow", asset: "human"), + Step("rdp31", "wait", value: "0.25"), + Step("rdp32", "assert", expect: "dossier_matches_focus"), + Step("rdp33", "assert", expect: "reason_empty", value: "true"), + Step("rdp34", "assert", expect: "reason_owns_focus"), + Step("rdp90", "fast_timing", value: "false"), + Step("rdp99", "snapshot"), + }; + } + /// Large score gap always picks #1; within epsilon may roll among top-3. private static List StoryNearTie() { @@ -1546,6 +2295,86 @@ internal static class HarnessScenarios }; } + /// + /// Spine only while the watch tip is the story beat; find_lover intent is not a Love climax; + /// leaving a climax for an unrelated tip clears the arc. + /// + private static List StorySpineScoped() + { + return new List + { + Step("ss0", "dismiss_windows"), + Step("ss1", "wait_world"), + Step("ss2", "set_setting", expect: "enabled", value: "true"), + Step("ss3", "fast_timing", value: "true"), + Step("ss4", "spawn", asset: "human", count: 1), + Step("ss5", "spawn", asset: "wolf", count: 1), + Step("ss6", "spectator", value: "off"), + Step("ss7", "spectator", value: "on"), + Step("ss8", "pick_unit", asset: "human"), + Step("ss9", "focus", asset: "human"), + Step("ss10", "interest_end_session"), + + // Seeking a lover must stay camera-worthy without opening Love · Climax. + Step("ss20", "interest_force_session", asset: "human", + label: "{a} is seeking a lover", tier: "Action", expect: "find_lover", + value: "lead=event;evt=45"), + Step("ss21", "assert", expect: "tip_contains", value: "seeking a lover"), + Step("ss22", "assert", expect: "story_phase", value: "None"), + Step("ss23", "assert", expect: "story_spine", value: "empty"), + + // Real bond opens Love climax + spine. + Step("ss30", "interest_force_session", asset: "human", + label: "{a} became lovers with a partner", tier: "Action", expect: "set_lover", + value: "lead=event;evt=88"), + Step("ss31", "assert", expect: "story_phase", value: "Climax"), + Step("ss32", "assert", expect: "story_spine", value: "Love · Climax"), + + // Unrelated hatch tip must clear the arc and hide the spine. + Step("ss40", "interest_force_session", asset: "human", + label: "{a} hatches from an egg", tier: "Action", expect: "hatch_leave_love", + value: "lead=event;evt=95"), + Step("ss41", "assert", expect: "tip_contains", value: "hatches from an egg"), + Step("ss42", "assert", expect: "story_phase", value: "None"), + Step("ss43", "assert", expect: "story_spine", value: "empty"), + + // Combat climax spine, then leave for hatch again. + Step("ss50", "interest_end_session"), + Step("ss51", "interest_combat_session", asset: "human", value: "wolf", expect: "ss_pack"), + Step("ss52", "combat_isolate_pair", value: "20"), + Step("ss53", "status_apply", asset: "human", value: "invincible"), + Step("ss54", "status_apply", asset: "wolf", value: "invincible"), + Step("ss55", "assert", expect: "story_phase", value: "Climax"), + Step("ss56", "assert", expect: "story_spine", value: "Duel · Climax"), + // In-place escalate must retarget Kind Mass without a director SwitchTo. + Step("ss57", "combat_ensemble_escalate", value: "6"), + Step("ss57b", "combat_wire_attack_sides", asset: "human", value: "wolf"), + Step("ss57c", "combat_maintain_focus"), + Step("ss57d", "wait", value: "0.35"), + Step("ss57e", "combat_maintain_focus"), + Step("ss58", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"), + // Spine Kind follows tip Label (Battle/Mass/Skirmish), not a collapsed Mass bucket. + Step("ss59", "assert", expect: "story_spine", + value: "Battle · Climax|Mass · Climax|Skirmish · Climax"), + // Shrink back to a named duel - spine Kind must follow the Label, not scale-hold. + Step("ss59b", "combat_isolate_pair", value: "20"), + Step("ss59c", "combat_maintain_focus"), + Step("ss59d", "wait", value: "0.35"), + Step("ss59e", "combat_maintain_focus"), + Step("ss59f", "assert", expect: "tip_matches_any", value: "Duel -"), + Step("ss59g", "assert", expect: "story_spine", value: "Duel · Climax"), + Step("ss60", "interest_force_session", asset: "human", + label: "{a} appears in the world", tier: "Action", expect: "spawn_leave_duel", + value: "lead=event;evt=95"), + Step("ss61", "assert", expect: "tip_contains", value: "appears in the world"), + Step("ss62", "assert", expect: "story_phase", value: "None"), + Step("ss63", "assert", expect: "story_spine", value: "empty"), + + Step("ss90", "fast_timing", value: "false"), + Step("ss99", "snapshot"), + }; + } + /// Combat climax → aftermath ownership; soft ambient must not steal mid-aftermath. private static List StoryArcCombat() { @@ -1568,23 +2397,78 @@ internal static class HarnessScenarios Step("sac22", "status_apply", asset: "wolf", value: "invincible"), Step("sac23", "assert", expect: "tip_matches_any", value: "Duel -"), Step("sac24", "assert", expect: "story_phase", value: "Climax"), + Step("sac24b", "assert", expect: "story_spine", value: "Duel · Climax"), Step("sac25", "interest_expire_pending", value: ""), Step("sac26", "age_current", wait: 10f), Step("sac27", "interest_mark_combat_cold"), Step("sac28", "director_run", wait: 1.5f), Step("sac29", "assert", expect: "tip_asset", value: "aftermath_"), Step("sac30", "assert", expect: "story_phase", value: "Aftermath"), + Step("sac30b", "assert", expect: "story_spine", value: "Duel · Aftermath"), + // Soft anonymous duels do not raise arcHoldMargin (HardHold=false); ownership still holds. // Weak ambient peer must not steal mid-aftermath. Step("sac31", "interest_inject", asset: "human", label: "WeakPeer", tier: "Curiosity", expect: "sac_weak", value: "lead=event;evt=30;char=5;force=false;ttl=60"), Step("sac32", "director_run", wait: 1.0f), Step("sac33", "assert", expect: "tip_asset", value: "aftermath_"), Step("sac34", "assert", expect: "tip_not_contains", value: "WeakPeer"), + // Leaving the story beat for cast life must drop the raised hold wall. + Step("sac40", "interest_force_session", asset: "human", + label: "{a} feels the joy of parenthood", tier: "Action", expect: "just_had_child", + value: "lead=event;evt=76"), + Step("sac41", "assert", expect: "tip_contains", value: "parenthood"), + Step("sac42", "assert", expect: "story_phase", value: "None"), + Step("sac43", "assert", expect: "story_hold_margin", value: "0"), + Step("sac44", "assert", expect: "story_spine", value: "empty"), Step("sac90", "fast_timing", value: "false"), Step("sac99", "snapshot"), }; } + /// + /// Parenthood cools world-wide for a while; a second villager cannot chain the same tip. + /// + private static List StoryFamilyVariety() + { + return new List + { + Step("sf0", "dismiss_windows"), + Step("sf1", "wait_world"), + Step("sf2", "set_setting", expect: "enabled", value: "true"), + Step("sf3", "fast_timing", value: "true"), + Step("sf4", "spawn", asset: "human", count: 2), + Step("sf5", "spectator", value: "off"), + Step("sf6", "spectator", value: "on"), + Step("sf7", "pick_unit", asset: "human"), + Step("sf8", "focus", asset: "human"), + Step("sf9", "interest_end_session"), + Step("sf10", "interest_variety_clear"), + + Step("sf20", "interest_force_session", asset: "human", + label: "{a} feels the joy of parenthood", tier: "Action", expect: "just_had_child", + value: "lead=event;evt=76"), + Step("sf21", "assert", expect: "tip_contains", value: "parenthood"), + Step("sf22", "assert", expect: "beat_cooled", value: "just_had_child"), + Step("sf23", "assert", expect: "variety_arc", value: "life:family"), + + // Second human parenthood must stay beat-cooled; a peer tip may still take the camera. + Step("sf30", "interest_end_session"), + Step("sf31", "interest_expire_pending", value: ""), + Step("sf32", "interest_inject", asset: "human", + label: "{a} feels the joy of parenthood", tier: "Action", expect: "just_had_child_b", + value: "lead=event;evt=90;char=5;force=false;ttl=60;pick=other"), + Step("sf33", "interest_inject", asset: "human", label: "VillagePeer", tier: "Action", + expect: "sf_peer", value: "lead=event;evt=55;char=5;force=true;ttl=60;pick=other"), + Step("sf34", "director_run", wait: 0.8f), + Step("sf35", "assert", expect: "tip_contains", value: "VillagePeer"), + Step("sf36", "assert", expect: "tip_not_contains", value: "parenthood"), + Step("sf37", "assert", expect: "beat_cooled", value: "just_had_child"), + + Step("sf90", "fast_timing", value: "false"), + Step("sf99", "snapshot"), + }; + } + /// Epic preempt during climax; aftermath still available after cold. private static List StoryArcPreemptResume() { diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index ff65fc5..39d6a55 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -121,7 +121,7 @@ public static class InterestDirector /// /// Candidate that may drive the orange dossier reason: owns /// while the director still holds that scene (not quiet_grace). - /// Sticky combat may briefly report !IsActive between swings - keep the reason up. + /// Unit must be a tip principal (not a sticky bystander under a named duel). /// public static InterestCandidate TryGetOwnedReasonCandidate(Actor unit) { @@ -130,37 +130,125 @@ public static class InterestDirector return null; } - long unitId = 0; - try + // Dossier subject asking for the reason must match camera focus when focused. + if (MoveCamera.hasFocusUnit() + && MoveCamera._focus_unit != null + && MoveCamera._focus_unit != unit + && EventFeedUtil.SafeId(MoveCamera._focus_unit) != EventFeedUtil.SafeId(unit)) { - unitId = unit.getID(); - } - catch - { - unitId = 0; + return null; } - if (_current.FollowUnit == unit) + return UnitIsReasonPrincipal(unit, _current) ? _current : null; + } + + /// + /// True when may show 's Label as orange reason. + /// Pair-locked duel tips only name PairOwner/PairPartner - sticky Follow alone is not enough + /// (avoids bystander dossier + "Duel - A vs B" for unrelated fighters). + /// + public static bool UnitIsReasonPrincipal(Actor unit, InterestCandidate scene) + { + if (unit == null || scene == null) { - return _current; + return false; } - if (_current.SubjectId != 0 && unitId != 0 && _current.SubjectId == unitId) + long unitId = EventFeedUtil.SafeId(unit); + if (unitId == 0) { - return _current; + return false; } - if (_current.RelatedUnit == unit) + bool ensemble = scene.Completion == InterestCompletionKind.CombatActive + || scene.Completion == InterestCompletionKind.WarFront + || scene.Completion == InterestCompletionKind.PlotActive + || scene.Completion == InterestCompletionKind.FamilyPack + || scene.Completion == InterestCompletionKind.StatusOutbreak + || InterestScoring.IsCombatAction(scene); + + if (ensemble) { - return _current; + bool massTheater = scene.Sticky != null + && scene.Sticky.HasPresentedScale + && scene.Sticky.PresentedScale >= EnsembleScale.Skirmish; + + if (scene.HasCombatPairLock && !massTheater) + { + // Named-pair duel: only the locked duelists own the reason. + return unitId == scene.PairOwnerId || unitId == scene.PairPartnerId; + } + + if (unitId == scene.SubjectId + || scene.FollowUnit == unit + || unitId == scene.PairOwnerId + || unitId == scene.PairPartnerId + || unitId == scene.TheaterLeadId) + { + return true; + } + + if (StickyRosterContains(scene, unitId)) + { + return true; + } + + // Related alone is not enough for ensemble theaters (crowd / wrong camp). + return false; } - if (_current.RelatedId != 0 && unitId != 0 && _current.RelatedId == unitId) + // Life / discrete tips: Subject + Related are authoritative. + // Follow alone is not enough after a camera handoff left SubjectId on someone else + // (avoids Hillih dossier + "Iksssssa shares intimacy…" stale Label). + if (scene.SubjectId != 0 && unitId == scene.SubjectId) { - return _current; + return true; } - return null; + if (scene.RelatedId != 0 && unitId == scene.RelatedId) + { + return true; + } + + if (scene.RelatedUnit == unit) + { + return true; + } + + if (scene.FollowUnit == unit + && (scene.SubjectId == 0 || unitId == scene.SubjectId)) + { + return true; + } + + return false; + } + + private static bool StickyRosterContains(InterestCandidate scene, long unitId) + { + if (scene?.Sticky == null || unitId == 0) + { + return false; + } + + LiveSceneStickyState sticky = scene.Sticky; + for (int i = 0; i < sticky.SideAIds.Count; i++) + { + if (sticky.SideAIds[i] == unitId) + { + return true; + } + } + + for (int i = 0; i < sticky.SideBIds.Count; i++) + { + if (sticky.SideBIds[i] == unitId) + { + return true; + } + } + + return false; } public static string InterruptedKey => @@ -708,6 +796,13 @@ public static class InterestDirector return; } + // Story aftermath/epilogue: promote another living cast member before ending. + if (StoryPlanner.TryRecoverStoryFollow(_current)) + { + CameraDirector.Watch(_current.ToInterestEvent()); + return; + } + // Ownership-preserving handoff only: related survivor. Never invent a stranger. if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive()) { @@ -849,6 +944,7 @@ public static class InterestDirector CameraDirector.Watch(_current.ToInterestEvent()); } + StoryPlanner.SyncActiveClimax(_current); return true; } @@ -992,6 +1088,7 @@ public static class InterestDirector CameraDirector.Watch(_current.ToInterestEvent()); } + StoryPlanner.SyncActiveClimax(_current); return true; } @@ -1250,6 +1347,8 @@ public static class InterestDirector CameraDirector.Watch(_current.ToInterestEvent()); } + // Framing-only Battle↔Duel still needs story Kind sync (no SwitchTo). + StoryPlanner.SyncActiveClimax(_current); return true; } @@ -3231,6 +3330,15 @@ public static class InterestDirector } } + if (StoryPlanner.TryRecoverStoryFollow(_current)) + { + CameraDirector.Watch(_current.ToInterestEvent()); + if (HasLivingCameraFocus()) + { + return; + } + } + if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive()) { // Non-combat ownership handoff; sticky scenes already tried picker above. @@ -3277,14 +3385,26 @@ public static class InterestDirector StoryPlanner.OnEndCurrent(ended, now); // Resume interrupted Epic-preempted scene if still valid. + // Soft-life / moment beats already NoteSelection-cooled must not cut back in + // after a combat/peer cut (soak: hatch/reproduce resume spam). if (_interrupted != null && _interrupted.HasValidPosition && InterestCompletion.IsActive(_interrupted, now - _interrupted.MinWatch, now)) { InterestCandidate resume = _interrupted; _interrupted = null; - SwitchTo(resume, now, resumableInterrupt: false); - return; + if (InterestVariety.IsBeatCooled(resume, now)) + { + InterestDropLog.Record( + "resume_cooled", + resume.HappinessEffectId ?? resume.AssetId ?? resume.Key ?? "beat"); + InterestRegistry.Remove(resume.Key); + } + else + { + SwitchTo(resume, now, resumableInterrupt: false); + return; + } } // Prefer queued story aftermath/epilogue over ambient fill. @@ -4548,7 +4668,9 @@ public static class InterestDirector if (_current != null && _current.Resumable - && next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin) + && next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin + // Already-shown moment beats are cooled - parking them only enables resume spam. + && !InterestVariety.IsBeatCooled(_current, now)) { _interrupted = _current.CloneShallow(); } diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs index 00bdf80..e6ceae0 100644 --- a/IdleSpectator/InterestScoringConfig.cs +++ b/IdleSpectator/InterestScoringConfig.cs @@ -36,6 +36,12 @@ public class StoryWeights public int ledgerCap = 16; public float ledgerWeight = 8f; public float historyDensityWindowSeconds = 600f; + /// Multiply subject ledger heat after a family life chapter tip is shown. + public float ledgerBleedLifeChapter = 0.4f; + /// Multiply other ledger entries after a family life chapter (village cool). + public float ledgerBleedVillage = 0.82f; + /// Extra FixedDwell beat cool for family chapters (seconds, stacks with base). + public float familyLifeBeatCooldownSeconds = 90f; } /// Serializable weight block — field names must match scoring-model.json "weights". diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs index 19c08d9..6423368 100644 --- a/IdleSpectator/InterestVariety.cs +++ b/IdleSpectator/InterestVariety.cs @@ -146,12 +146,25 @@ public static class InterestVariety // Moment beats: hard suppress the same story on the same subject. if (IsMomentBeat(chosen)) { - float beatUntil = Time.unscaledTime + FixedDwellBeatCooldownSeconds; + float beatSeconds = FixedDwellBeatCooldownSeconds; + if (IsSoftLifeChapter(chosen)) + { + float softCool = InterestScoringConfig.Story.familyLifeBeatCooldownSeconds; + if (softCool > beatSeconds) + { + beatSeconds = softCool; + } + } + + float beatUntil = Time.unscaledTime + beatSeconds; foreach (string beatKey in EnumerateBeatKeys(chosen)) { StampCooldown(beatKey, beatUntil); } } + + // Soft life chapters cool ledger heat so the same villagers do not monopolize. + CharacterLedger.CoolAfterSoftLifeBeat(chosen); } /// @@ -341,7 +354,23 @@ public static class InterestVariety return "arc:life:grief"; } - if (h.IndexOf("lover", StringComparison.Ordinal) >= 0 + if (IsKillLifeHappinessId(h)) + { + return "arc:life:kill"; + } + + if (h == "just_got_out_of_egg") + { + return "arc:life:hatch"; + } + + if (IsIntimacyHappinessId(h)) + { + return "arc:life:intimacy"; + } + + if (IsCourtshipHappinessId(h) + || h.IndexOf("lover", StringComparison.Ordinal) >= 0 || h.IndexOf("love", StringComparison.Ordinal) >= 0 || h.IndexOf("married", StringComparison.Ordinal) >= 0 || h.IndexOf("wedding", StringComparison.Ordinal) >= 0) @@ -349,17 +378,67 @@ public static class InterestVariety return "arc:life:love"; } + if (IsFamilyLifeHappinessId(h)) + { + return "arc:life:family"; + } + + if (IsSettlementHappinessId(h)) + { + return "arc:life:home"; + } + + if (IsRestLifeId(h)) + { + return "arc:life:rest"; + } + return "arc:hap:" + h; } if (!string.IsNullOrEmpty(c.StatusId)) { + if (IsFamilyLifeHappinessId(c.StatusId)) + { + return "arc:life:family"; + } + + if (IsSettlementHappinessId(c.StatusId)) + { + return "arc:life:home"; + } + + if (IsCourtshipHappinessId(c.StatusId)) + { + return "arc:life:love"; + } + + if (IsRestLifeId(c.StatusId)) + { + return "arc:life:rest"; + } + + if (IsSoftStatusFxId(c.StatusId)) + { + return "arc:status:fx"; + } + return "arc:status:" + c.StatusId.Trim().ToLowerInvariant(); } string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); if (!string.IsNullOrEmpty(asset) && !asset.StartsWith("live_", StringComparison.Ordinal)) { + if (IsKillLifeAssetId(asset)) + { + return "arc:life:kill"; + } + + if (IsCourtshipAssetId(asset)) + { + return "arc:life:love"; + } + if (string.Equals(c.Category, "Spectacle", StringComparison.OrdinalIgnoreCase) || asset.StartsWith("cast_", StringComparison.Ordinal) || asset.StartsWith("summon_", StringComparison.Ordinal)) @@ -367,6 +446,27 @@ public static class InterestVariety return "arc:spectacle"; } + if (EventCatalog.Decision.IsLifeIntentDecision(asset) + || IsLifeIntentDecisionChapter(c)) + { + return "arc:decision:intent"; + } + + if (IsRestLifeId(asset) || IsRestLifeChapter(c)) + { + return "arc:life:rest"; + } + + if (IsWorldLogMetaAssetId(asset) || IsWorldLogMetaChapter(c)) + { + return "arc:worldlog:meta"; + } + + if (IsConstructionChapter(c)) + { + return "arc:construction"; + } + if (string.Equals(c.Category, "Politics", StringComparison.OrdinalIgnoreCase) || (c.Label != null && c.Label.IndexOf("decides to", StringComparison.OrdinalIgnoreCase) >= 0)) @@ -377,6 +477,46 @@ public static class InterestVariety return "arc:asset:" + asset; } + if (IsLifeIntentDecisionChapter(c)) + { + return "arc:decision:intent"; + } + + if (IsRestLifeChapter(c)) + { + return "arc:life:rest"; + } + + if (IsWorldLogMetaChapter(c)) + { + return "arc:worldlog:meta"; + } + + if (IsConstructionChapter(c)) + { + return "arc:construction"; + } + + if (IsSoftStatusFxChapter(c)) + { + return "arc:status:fx"; + } + + if (IsHatchLifeChapter(c)) + { + return "arc:life:hatch"; + } + + if (IsKillLifeChapter(c)) + { + return "arc:life:kill"; + } + + if (IsCourtshipLifeChapter(c)) + { + return "arc:life:love"; + } + if (!string.IsNullOrEmpty(c.Category)) { return "arc:cat:" + c.Category.Trim().ToLowerInvariant(); @@ -439,6 +579,14 @@ public static class InterestVariety return false; } + // Synthetic story beats finish the active arc - never hard-skip them for FixedDwell cool. + // Soft novelty / repeat-arc penalties still apply via RecalcTotal. + if (StoryReason.IsStoryAsset(c.AssetId) + || string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + if (now < 0f) { now = Time.unscaledTime; @@ -628,6 +776,586 @@ public static class InterestVariety } } + /// + /// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class. + /// + public static bool IsFamilyLifeChapter(InterestCandidate c) + { + if (c == null) + { + return false; + } + + if (!string.IsNullOrEmpty(c.HappinessEffectId) + && IsFamilyLifeHappinessId(c.HappinessEffectId)) + { + return true; + } + + if (!string.IsNullOrEmpty(c.StatusId) + && IsFamilyLifeHappinessId(c.StatusId)) + { + return true; + } + + string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); + return asset.IndexOf("child", StringComparison.Ordinal) >= 0 + || asset.IndexOf("pregnant", StringComparison.Ordinal) >= 0 + || asset.IndexOf("birth", StringComparison.Ordinal) >= 0 + || asset.IndexOf("baby", StringComparison.Ordinal) >= 0 + || asset.IndexOf("newborn", StringComparison.Ordinal) >= 0; + } + + /// + /// Kiss / mate / intimacy chapter - same soft-life cool class as family (pair + world). + /// + public static bool IsIntimacyLifeChapter(InterestCandidate c) + { + if (c == null) + { + return false; + } + + if (!string.IsNullOrEmpty(c.HappinessEffectId) + && IsIntimacyHappinessId(c.HappinessEffectId)) + { + return true; + } + + if (!string.IsNullOrEmpty(c.StatusId) + && IsIntimacyHappinessId(c.StatusId)) + { + return true; + } + + string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); + if (IsIntimacyHappinessId(asset)) + { + return true; + } + + string label = (c.Label ?? "").Trim().ToLowerInvariant(); + return label.IndexOf("shares intimacy", StringComparison.Ordinal) >= 0 + || label.IndexOf("mates with", StringComparison.Ordinal) >= 0; + } + + /// + /// House-found settlement chapter - repeats easily and steals between combat beats. + /// (House-lost stays camera-worthy without the same soft cool.) + /// + public static bool IsSettlementLifeChapter(InterestCandidate c) + { + if (c == null) + { + return false; + } + + if (!string.IsNullOrEmpty(c.HappinessEffectId) + && IsSettlementHappinessId(c.HappinessEffectId)) + { + return true; + } + + if (!string.IsNullOrEmpty(c.StatusId) + && IsSettlementHappinessId(c.StatusId)) + { + return true; + } + + string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); + if (IsSettlementHappinessId(asset)) + { + return true; + } + + string label = (c.Label ?? "").Trim().ToLowerInvariant(); + return label.IndexOf("finds a home", StringComparison.Ordinal) >= 0 + || label.IndexOf("settles into a house", StringComparison.Ordinal) >= 0; + } + + /// + /// Chronicle / happiness kill crumbs - repeat easily for hunters between combat tips. + /// + public static bool IsKillLifeChapter(InterestCandidate c) + { + if (c == null) + { + return false; + } + + if (!string.IsNullOrEmpty(c.HappinessEffectId) + && IsKillLifeHappinessId(c.HappinessEffectId)) + { + return true; + } + + string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); + if (IsKillLifeAssetId(asset)) + { + return true; + } + + if (!string.IsNullOrEmpty(c.Key) + && c.Key.IndexOf("milestone_kill", StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + + string label = (c.Label ?? "").Trim().ToLowerInvariant(); + return label.StartsWith("killed ", StringComparison.Ordinal); + } + + /// + /// Smitten / seeking-lover courtship crumbs (not bond outcomes like set_lover). + /// + public static bool IsCourtshipLifeChapter(InterestCandidate c) + { + if (c == null) + { + return false; + } + + if (!string.IsNullOrEmpty(c.HappinessEffectId) + && IsCourtshipHappinessId(c.HappinessEffectId)) + { + return true; + } + + if (!string.IsNullOrEmpty(c.StatusId) + && IsCourtshipHappinessId(c.StatusId)) + { + return true; + } + + string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); + if (IsCourtshipAssetId(asset)) + { + return true; + } + + string label = (c.Label ?? "").Trim().ToLowerInvariant(); + return label.IndexOf("is smitten", StringComparison.Ordinal) >= 0 + || label.IndexOf("falls in love", StringComparison.Ordinal) >= 0 + || label.IndexOf("seeking a lover", StringComparison.Ordinal) >= 0 + || label.IndexOf("is seeking a lover", StringComparison.Ordinal) >= 0; + } + + /// + /// Decision intent crumbs (reproduce / find lover) - outcomes own the real story. + /// + public static bool IsLifeIntentDecisionChapter(InterestCandidate c) + { + if (c == null) + { + return false; + } + + string asset = (c.AssetId ?? "").Trim(); + if (EventCatalog.Decision.IsLifeIntentDecision(asset)) + { + return true; + } + + // Live decision feed: Source is authoritative even when AssetId was species-clobbered. + if (string.Equals(c.Source, "decision", StringComparison.OrdinalIgnoreCase) + && (EventCatalog.Decision.IsLifeIntentDecision(asset) + || asset.IndexOf("reproduction", StringComparison.OrdinalIgnoreCase) >= 0 + || asset.IndexOf("find_lover", StringComparison.OrdinalIgnoreCase) >= 0 + || asset.IndexOf("baby_make", StringComparison.OrdinalIgnoreCase) >= 0 + || asset.IndexOf("have_child", StringComparison.OrdinalIgnoreCase) >= 0)) + { + return true; + } + + // Label-only rebuilds still cool when AssetId was lost. + string label = (c.Label ?? "").Trim().ToLowerInvariant(); + return label.IndexOf("decides to try to reproduce", StringComparison.Ordinal) >= 0 + || label.IndexOf("decides to find a lover", StringComparison.Ordinal) >= 0 + || label.IndexOf("decides to reproduce", StringComparison.Ordinal) >= 0; + } + + /// + /// Ordinary civ construction finishes (tents/houses). Spectacle wonders stay hotter. + /// + public static bool IsConstructionChapter(InterestCandidate c) + { + if (c == null) + { + return false; + } + + string asset = c.AssetId ?? ""; + bool buildingSource = string.Equals(c.Source, "building", StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(c.Key) + && c.Key.StartsWith( + "building:complete:", + StringComparison.OrdinalIgnoreCase)); + if (buildingSource) + { + return !LibraryAssetNames.IsSpectacleBuilding(asset, null); + } + + string label = (c.Label ?? "").Trim().ToLowerInvariant(); + if (label.IndexOf(" finishes ", StringComparison.Ordinal) < 0 + && !label.StartsWith("building finished", StringComparison.Ordinal)) + { + return false; + } + + // Spectacle prose uses "wonder". + return label.IndexOf("wonder", StringComparison.Ordinal) < 0; + } + + /// + /// Brief personal status FX (singing, rooting, moods) - not danger/combat statuses. + /// + public static bool IsSoftStatusFxChapter(InterestCandidate c) + { + if (c == null) + { + return false; + } + + string status = !string.IsNullOrEmpty(c.StatusId) ? c.StatusId : c.AssetId; + if (string.IsNullOrEmpty(status)) + { + return false; + } + + if (c.Completion != InterestCompletionKind.StatusPhase + && c.Completion != InterestCompletionKind.FixedDwell + && string.IsNullOrEmpty(c.StatusId)) + { + return false; + } + + return IsSoftStatusFxId(status); + } + + /// Egg hatch crumbs - common and steals between richer beats. + public static bool IsHatchLifeChapter(InterestCandidate c) + { + if (c == null) + { + return false; + } + + if (!string.IsNullOrEmpty(c.HappinessEffectId) + && string.Equals(c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); + if (asset == "just_got_out_of_egg") + { + return true; + } + + string label = (c.Label ?? "").Trim().ToLowerInvariant(); + return label.IndexOf("hatches from an egg", StringComparison.Ordinal) >= 0; + } + + /// + /// WorldLog politics meta crumbs (king death, city destroyed, favorite fell). + /// Sticky and re-fire easily; outcomes stay in Chronicle. + /// + public static bool IsWorldLogMetaChapter(InterestCandidate c) + { + if (c == null) + { + return false; + } + + bool worldLog = string.Equals(c.Source, "worldlog", StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(c.Key) + && c.Key.StartsWith("worldlog:", StringComparison.OrdinalIgnoreCase)); + if (!worldLog && string.IsNullOrEmpty(c.AssetId)) + { + return false; + } + + if (IsWorldLogMetaAssetId(c.AssetId)) + { + return true; + } + + string label = (c.Label ?? "").Trim().ToLowerInvariant(); + return label.StartsWith("king died:", StringComparison.Ordinal) + || label.StartsWith("king killed:", StringComparison.Ordinal) + || label.StartsWith("favorite fell:", StringComparison.Ordinal) + || label.StartsWith("city destroyed:", StringComparison.Ordinal); + } + + /// + /// Family + intimacy + settlement + kill + courtship + intent/construction/status/hatch/meta/rest. + /// + public static bool IsSoftLifeChapter(InterestCandidate c) => + IsFamilyLifeChapter(c) + || IsIntimacyLifeChapter(c) + || IsSettlementLifeChapter(c) + || IsKillLifeChapter(c) + || IsCourtshipLifeChapter(c) + || IsLifeIntentDecisionChapter(c) + || IsWorldLogMetaChapter(c) + || IsConstructionChapter(c) + || IsSoftStatusFxChapter(c) + || IsHatchLifeChapter(c) + || IsRestLifeChapter(c); + + /// + /// Sleep / wake / dream cycle crumbs from live status + happiness inventory shape. + /// One show cools the whole rest class so Falls asleep → ends a rest → dream cannot monopolize. + /// + public static bool IsRestLifeChapter(InterestCandidate c) + { + if (c == null) + { + return false; + } + + if (IsRestLifeId(c.HappinessEffectId) + || IsRestLifeId(c.StatusId) + || IsRestLifeId(c.AssetId)) + { + return true; + } + + string label = (c.Label ?? "").Trim().ToLowerInvariant(); + return label.IndexOf("falls asleep", StringComparison.Ordinal) >= 0 + || label.IndexOf("wakes up", StringComparison.Ordinal) >= 0 + || label.IndexOf("ends a rest", StringComparison.Ordinal) >= 0 + || label.IndexOf("finishes sleeping", StringComparison.Ordinal) >= 0 + || label.IndexOf("dreams pleasantly", StringComparison.Ordinal) >= 0 + || label.IndexOf("good dream", StringComparison.Ordinal) >= 0 + || label.IndexOf("bad dream", StringComparison.Ordinal) >= 0 + || label.IndexOf("nightmare", StringComparison.Ordinal) >= 0 + || label.IndexOf("sleep without a house", StringComparison.Ordinal) >= 0; + } + + /// + /// Rest-cycle ids: sleeping, just_slept, had_*dream, had_nightmare, + /// slept_outside - token shape from live libraries, not tip strings alone. + /// + public static bool IsRestLifeId(string id) + { + if (string.IsNullOrEmpty(id)) + { + return false; + } + + string s = id.Trim().ToLowerInvariant(); + return s.IndexOf("sleep", StringComparison.Ordinal) >= 0 + || s.IndexOf("slept", StringComparison.Ordinal) >= 0 + || s.IndexOf("dream", StringComparison.Ordinal) >= 0 + || s.IndexOf("nightmare", StringComparison.Ordinal) >= 0; + } + + /// + /// Death / destruction WorldLog ids from the live library shape + /// (king_*, favorite_*, city_destroyed, …) - not crowning / founding. + /// + public static bool IsWorldLogMetaAssetId(string assetId) + { + if (string.IsNullOrEmpty(assetId)) + { + return false; + } + + string s = assetId.Trim().ToLowerInvariant(); + if (s.IndexOf("destroyed", StringComparison.Ordinal) >= 0) + { + return true; + } + + bool deathLike = s.IndexOf("dead", StringComparison.Ordinal) >= 0 + || s.IndexOf("killed", StringComparison.Ordinal) >= 0 + || s.IndexOf("_fell", StringComparison.Ordinal) >= 0; + if (!deathLike) + { + return false; + } + + return s.IndexOf("king", StringComparison.Ordinal) >= 0 + || s.IndexOf("leader", StringComparison.Ordinal) >= 0 + || s.IndexOf("favorite", StringComparison.Ordinal) >= 0; + } + + /// + /// Soft personal status FX ids from the live status inventory shape: + /// mood / life-cycle / brief FX. Danger and combat statuses stay hotter. + /// + public static bool IsSoftStatusFxId(string statusId) + { + if (string.IsNullOrEmpty(statusId)) + { + return false; + } + + string s = statusId.Trim().ToLowerInvariant(); + // Danger / combat / possession stay camera-sticky for repeats. + if (s.IndexOf("burn", StringComparison.Ordinal) >= 0 + || s.IndexOf("poison", StringComparison.Ordinal) >= 0 + || s.IndexOf("stun", StringComparison.Ordinal) >= 0 + || s.IndexOf("drown", StringComparison.Ordinal) >= 0 + || s.IndexOf("starv", StringComparison.Ordinal) >= 0 + || s.IndexOf("possess", StringComparison.Ordinal) >= 0 + || s.IndexOf("soul", StringComparison.Ordinal) >= 0 + || s.IndexOf("curse", StringComparison.Ordinal) >= 0 + || s.IndexOf("rage", StringComparison.Ordinal) >= 0 + || s.IndexOf("frozen", StringComparison.Ordinal) >= 0 + || s.IndexOf("shield", StringComparison.Ordinal) >= 0 + || s.IndexOf("invincible", StringComparison.Ordinal) >= 0 + || s.IndexOf("combat", StringComparison.Ordinal) >= 0 + || s == "on_guard" + || s == "angry" + || s == "egg") + { + return false; + } + + // Family / courtship / settlement already have dedicated soft chapters. + if (IsFamilyLifeHappinessId(s) || IsCourtshipHappinessId(s) || IsSettlementHappinessId(s) + || IsIntimacyHappinessId(s)) + { + return false; + } + + // Mood / life-cycle / brief FX tokens (covers unauthored live ids of the same shape). + return s.IndexOf("sing", StringComparison.Ordinal) >= 0 + || s.IndexOf("laugh", StringComparison.Ordinal) >= 0 + || s.IndexOf("swear", StringComparison.Ordinal) >= 0 + || s.IndexOf("cry", StringComparison.Ordinal) >= 0 + || s.IndexOf("root", StringComparison.Ordinal) >= 0 + || s.IndexOf("budding", StringComparison.Ordinal) >= 0 + || s.IndexOf("dream", StringComparison.Ordinal) >= 0 + || s.IndexOf("nightmare", StringComparison.Ordinal) >= 0 + || s.IndexOf("festive", StringComparison.Ordinal) >= 0 + || s.IndexOf("afterglow", StringComparison.Ordinal) >= 0 + || s.IndexOf("surprised", StringComparison.Ordinal) >= 0 + || s.IndexOf("confused", StringComparison.Ordinal) >= 0 + || s.IndexOf("just_ate", StringComparison.Ordinal) >= 0 + || s.IndexOf("caffeine", StringComparison.Ordinal) >= 0 + || s.IndexOf("cough", StringComparison.Ordinal) >= 0 + || s.IndexOf("tantrum", StringComparison.Ordinal) >= 0 + || s.IndexOf("strange_urge", StringComparison.Ordinal) >= 0 + || s.IndexOf("voices", StringComparison.Ordinal) >= 0 + || s.IndexOf("handsome", StringComparison.Ordinal) >= 0 + || s.IndexOf("flick", StringComparison.Ordinal) >= 0 + || s.IndexOf("ash_fever", StringComparison.Ordinal) >= 0 + || s.IndexOf("motivated", StringComparison.Ordinal) >= 0 + || s.IndexOf("inspired", StringComparison.Ordinal) >= 0 + || s.IndexOf("enchant", StringComparison.Ordinal) >= 0 + || s.IndexOf("powerup", StringComparison.Ordinal) >= 0 + || s.IndexOf("slowness", StringComparison.Ordinal) >= 0 + || s.IndexOf("magnet", StringComparison.Ordinal) >= 0 + || s.IndexOf("dodge", StringComparison.Ordinal) >= 0 + || s.IndexOf("dash", StringComparison.Ordinal) >= 0 + || s.IndexOf("silence", StringComparison.Ordinal) >= 0 + || s.IndexOf("boost", StringComparison.Ordinal) >= 0 + || s.IndexOf("recovery_", StringComparison.Ordinal) >= 0; + } + + private static bool IsFamilyLifeHappinessId(string happinessId) + { + if (string.IsNullOrEmpty(happinessId)) + { + return false; + } + + string h = happinessId.Trim().ToLowerInvariant(); + return h == "just_had_child" + || h.IndexOf("pregnant", StringComparison.Ordinal) >= 0 + || h.IndexOf("newborn", StringComparison.Ordinal) >= 0 + || h.IndexOf("birth", StringComparison.Ordinal) >= 0 + || h.IndexOf("had_child", StringComparison.Ordinal) >= 0 + || h.IndexOf("had_baby", StringComparison.Ordinal) >= 0 + || h.IndexOf("parenthood", StringComparison.Ordinal) >= 0; + } + + private static bool IsIntimacyHappinessId(string happinessId) + { + if (string.IsNullOrEmpty(happinessId)) + { + return false; + } + + string h = happinessId.Trim().ToLowerInvariant(); + return h == "just_kissed" + || h.IndexOf("kissed", StringComparison.Ordinal) >= 0 + || h.IndexOf("intimacy", StringComparison.Ordinal) >= 0 + || h == "mates" + || h.StartsWith("mate_", StringComparison.Ordinal); + } + + private static bool IsSettlementHappinessId(string happinessId) + { + if (string.IsNullOrEmpty(happinessId)) + { + return false; + } + + string h = happinessId.Trim().ToLowerInvariant(); + return h == "just_found_house" + || h.IndexOf("found_house", StringComparison.Ordinal) >= 0 + || h.IndexOf("find_house", StringComparison.Ordinal) >= 0 + || h.IndexOf("found_home", StringComparison.Ordinal) >= 0; + } + + private static bool IsKillLifeHappinessId(string happinessId) + { + if (string.IsNullOrEmpty(happinessId)) + { + return false; + } + + string h = happinessId.Trim().ToLowerInvariant(); + return h == "just_killed" + || h == "milestone_kill" + || h.IndexOf("just_killed", StringComparison.Ordinal) >= 0; + } + + private static bool IsKillLifeAssetId(string assetId) + { + if (string.IsNullOrEmpty(assetId)) + { + return false; + } + + string a = assetId.Trim().ToLowerInvariant(); + return a == "milestone_kill" + || a == "just_killed"; + } + + private static bool IsCourtshipHappinessId(string happinessId) + { + if (string.IsNullOrEmpty(happinessId)) + { + return false; + } + + string h = happinessId.Trim().ToLowerInvariant(); + // Bond outcomes (set_lover / milestone_lover) stay hotter; cool intent + smitten only. + return h == "fallen_in_love" + || h == "find_lover" + || h == "fell_in_love" + || h.IndexOf("fallen_in_love", StringComparison.Ordinal) >= 0 + || h.IndexOf("find_lover", StringComparison.Ordinal) >= 0; + } + + private static bool IsCourtshipAssetId(string assetId) + { + if (string.IsNullOrEmpty(assetId)) + { + return false; + } + + string a = assetId.Trim().ToLowerInvariant(); + return a == "fallen_in_love" + || a == "find_lover" + || a == "fell_in_love"; + } + private static IEnumerable EnumerateBeatKeys(InterestCandidate c) { if (c == null) @@ -636,14 +1364,120 @@ public static class InterestVariety } long subject = c.SubjectId; + long related = c.RelatedId != 0 + ? c.RelatedId + : EventFeedUtil.SafeId(c.RelatedUnit); if (!string.IsNullOrEmpty(c.HappinessEffectId)) { yield return "beat:hap:" + c.HappinessEffectId + ":" + subject; + // Family chapters cool the happiness id world-wide so parent A then parent B + // cannot chain the same parenthood tip across the village cast. + if (IsFamilyLifeHappinessId(c.HappinessEffectId)) + { + yield return "beat:hap:" + c.HappinessEffectId + ":world"; + yield return "beat:life:family:" + subject; + } + + // Intimacy: cool the pair and the happiness id world-wide so one couple + // cannot keep cutting back in between combat tips. + if (IsIntimacyHappinessId(c.HappinessEffectId)) + { + yield return "beat:hap:" + c.HappinessEffectId + ":world"; + yield return "beat:life:intimacy:" + subject; + if (related != 0) + { + yield return "beat:life:intimacy:" + related; + yield return "beat:life:intimacy:pair:" + + EventCatalog.Combat.PairKey(subject, related); + } + } + + if (IsSettlementHappinessId(c.HappinessEffectId)) + { + yield return "beat:hap:" + c.HappinessEffectId + ":world"; + yield return "beat:life:home:" + subject; + } + + if (IsKillLifeHappinessId(c.HappinessEffectId)) + { + yield return "beat:hap:" + c.HappinessEffectId + ":world"; + yield return "beat:life:kill:" + subject; + yield return "beat:life:kill:world"; + } + + if (IsCourtshipHappinessId(c.HappinessEffectId)) + { + yield return "beat:hap:" + c.HappinessEffectId + ":world"; + yield return "beat:life:love:" + subject; + yield return "beat:life:love:world"; + if (related != 0) + { + yield return "beat:life:love:" + related; + yield return "beat:life:love:pair:" + + EventCatalog.Combat.PairKey(subject, related); + } + } + + if (string.Equals( + c.HappinessEffectId, + "just_got_out_of_egg", + StringComparison.OrdinalIgnoreCase)) + { + yield return "beat:hap:just_got_out_of_egg:world"; + yield return "beat:life:hatch:" + subject; + yield return "beat:life:hatch:world"; + } + + if (IsRestLifeId(c.HappinessEffectId)) + { + yield return "beat:hap:" + c.HappinessEffectId + ":world"; + yield return "beat:life:rest:" + subject; + yield return "beat:life:rest:world"; + } + + // Happiness-path soft FX (dreams without StatusId bind) still cool as a class. + if (IsSoftStatusFxId(c.HappinessEffectId) && !IsRestLifeId(c.HappinessEffectId)) + { + yield return "beat:hap:" + c.HappinessEffectId + ":world"; + yield return "beat:status:fx:" + subject; + yield return "beat:status:fx:world"; + } } if (!string.IsNullOrEmpty(c.StatusId)) { yield return "beat:status:" + c.StatusId + ":" + subject; + if (IsFamilyLifeHappinessId(c.StatusId)) + { + yield return "beat:status:" + c.StatusId + ":world"; + } + + if (IsIntimacyHappinessId(c.StatusId)) + { + yield return "beat:status:" + c.StatusId + ":world"; + yield return "beat:life:intimacy:" + subject; + } + + if (IsCourtshipHappinessId(c.StatusId)) + { + yield return "beat:status:" + c.StatusId + ":world"; + yield return "beat:life:love:" + subject; + yield return "beat:life:love:world"; + } + + if (IsRestLifeId(c.StatusId)) + { + yield return "beat:status:" + c.StatusId + ":world"; + yield return "beat:life:rest:" + subject; + yield return "beat:life:rest:world"; + } + + if (IsSoftStatusFxId(c.StatusId) && !IsRestLifeId(c.StatusId)) + { + yield return "beat:status:" + c.StatusId + ":world"; + yield return "beat:status:fx:" + subject; + yield return "beat:status:fx:world"; + } } // Discrete catalog asset ids (not species / not sticky live_* frames). @@ -655,12 +1489,147 @@ public static class InterestVariety && !string.Equals(asset, "harness", StringComparison.OrdinalIgnoreCase)) { yield return "beat:asset:" + asset + ":" + subject; + if (IsKillLifeAssetId(asset)) + { + yield return "beat:asset:" + asset + ":world"; + yield return "beat:life:kill:" + subject; + yield return "beat:life:kill:world"; + } + + if (IsCourtshipAssetId(asset)) + { + yield return "beat:asset:" + asset + ":world"; + yield return "beat:life:love:" + subject; + yield return "beat:life:love:world"; + } + + if (EventCatalog.Decision.IsLifeIntentDecision(asset)) + { + yield return "beat:asset:" + asset + ":world"; + yield return "beat:decision:intent:" + subject; + yield return "beat:decision:intent:world"; + } + + if (IsRestLifeId(asset)) + { + yield return "beat:asset:" + asset + ":world"; + yield return "beat:life:rest:" + subject; + yield return "beat:life:rest:world"; + } + + if (IsWorldLogMetaAssetId(asset)) + { + yield return "beat:asset:" + asset + ":world"; + yield return "beat:worldlog:meta:" + subject; + yield return "beat:worldlog:meta:world"; + } + + if (string.Equals(asset, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)) + { + yield return "beat:asset:" + asset + ":world"; + yield return "beat:life:hatch:" + subject; + yield return "beat:life:hatch:world"; + } + } + + // Chapter-level world keys (ungated): survive species AssetId / missing StatusId / label rebuilds. + if (IsLifeIntentDecisionChapter(c)) + { + yield return "beat:decision:intent:" + subject; + yield return "beat:decision:intent:world"; + } + + if (IsRestLifeChapter(c)) + { + yield return "beat:life:rest:" + subject; + yield return "beat:life:rest:world"; + } + + if (IsConstructionChapter(c)) + { + yield return "beat:construction:" + subject; + yield return "beat:construction:world"; + } + + if (IsWorldLogMetaChapter(c) && string.IsNullOrEmpty(asset)) + { + yield return "beat:worldlog:meta:" + subject; + yield return "beat:worldlog:meta:world"; } string labelNorm = NormalizeBeatLabel(c.Label); if (!string.IsNullOrEmpty(labelNorm)) { yield return "beat:label:" + labelNorm + ":" + subject; + // Label-only soft chapters (missing HappinessEffectId) still cool as a class. + if (string.IsNullOrEmpty(c.HappinessEffectId)) + { + if (IsIntimacyLifeChapter(c) + && (labelNorm.IndexOf("intimacy", StringComparison.Ordinal) >= 0 + || labelNorm.IndexOf("mates with", StringComparison.Ordinal) >= 0)) + { + yield return "beat:life:intimacy:" + subject; + yield return "beat:life:intimacy:world"; + if (related != 0) + { + yield return "beat:life:intimacy:pair:" + + EventCatalog.Combat.PairKey(subject, related); + } + } + + if (IsSettlementLifeChapter(c) + && (labelNorm.IndexOf("finds a home", StringComparison.Ordinal) >= 0 + || labelNorm.IndexOf("settles into", StringComparison.Ordinal) >= 0)) + { + yield return "beat:life:home:" + subject; + yield return "beat:life:home:world"; + } + + if (IsKillLifeChapter(c) + && labelNorm.StartsWith("killed ", StringComparison.Ordinal)) + { + yield return "beat:life:kill:" + subject; + yield return "beat:life:kill:world"; + } + + if (IsCourtshipLifeChapter(c) + && (labelNorm.IndexOf("smitten", StringComparison.Ordinal) >= 0 + || labelNorm.IndexOf("falls in love", StringComparison.Ordinal) >= 0 + || labelNorm.IndexOf("seeking a lover", StringComparison.Ordinal) >= 0)) + { + yield return "beat:life:love:" + subject; + yield return "beat:life:love:world"; + if (related != 0) + { + yield return "beat:life:love:pair:" + + EventCatalog.Combat.PairKey(subject, related); + } + } + + if (IsHatchLifeChapter(c) + && labelNorm.IndexOf("hatches", StringComparison.Ordinal) >= 0) + { + yield return "beat:life:hatch:" + subject; + yield return "beat:life:hatch:world"; + } + + if (IsWorldLogMetaChapter(c)) + { + yield return "beat:worldlog:meta:" + subject; + yield return "beat:worldlog:meta:world"; + } + + if (IsRestLifeChapter(c) + && (labelNorm.IndexOf("asleep", StringComparison.Ordinal) >= 0 + || labelNorm.IndexOf("rest", StringComparison.Ordinal) >= 0 + || labelNorm.IndexOf("dream", StringComparison.Ordinal) >= 0 + || labelNorm.IndexOf("nightmare", StringComparison.Ordinal) >= 0 + || labelNorm.IndexOf("sleep", StringComparison.Ordinal) >= 0)) + { + yield return "beat:life:rest:" + subject; + yield return "beat:life:rest:world"; + } + } } } @@ -682,7 +1651,10 @@ public static class InterestVariety || rest.StartsWith("is ", StringComparison.Ordinal) || rest.StartsWith("becomes ", StringComparison.Ordinal) || rest.StartsWith("gains ", StringComparison.Ordinal) - || rest.StartsWith("decides ", StringComparison.Ordinal)) + || rest.StartsWith("decides ", StringComparison.Ordinal) + || rest.StartsWith("shares ", StringComparison.Ordinal) + || rest.StartsWith("mates ", StringComparison.Ordinal) + || rest.StartsWith("mourns ", StringComparison.Ordinal)) { t = rest; } diff --git a/IdleSpectator/Story/CharacterLedger.cs b/IdleSpectator/Story/CharacterLedger.cs index b7ed162..e35a8c6 100644 --- a/IdleSpectator/Story/CharacterLedger.cs +++ b/IdleSpectator/Story/CharacterLedger.cs @@ -151,6 +151,61 @@ public static class CharacterLedger } } + /// + /// Soft life chapters (parenthood, intimacy) should not keep ledger heat hot enough + /// to monopolize the next few picks for the same village cast. + /// + public static void CoolAfterSoftLifeBeat(InterestCandidate scene) + { + if (scene == null || !InterestVariety.IsSoftLifeChapter(scene)) + { + return; + } + + StoryWeights s = InterestScoringConfig.Story; + float subjectMul = s.ledgerBleedLifeChapter > 0f ? s.ledgerBleedLifeChapter : 0.4f; + float villageMul = s.ledgerBleedVillage > 0f ? s.ledgerBleedVillage : 0.82f; + Bleed(scene.SubjectId, subjectMul); + if (scene.RelatedId != 0) + { + Bleed(scene.RelatedId, Mathf.Lerp(subjectMul, 1f, 0.35f)); + } + + // Mild cool across the rest of the ledger so harness-featured villagers fade. + for (int i = 0; i < Entries.Count; i++) + { + long id = Entries[i].UnitId; + if (id == 0 || id == scene.SubjectId || id == scene.RelatedId) + { + continue; + } + + Bleed(id, villageMul); + } + } + + public static void Bleed(long unitId, float multiplyHeat) + { + if (unitId == 0 || multiplyHeat >= 0.999f) + { + return; + } + + multiplyHeat = Mathf.Clamp(multiplyHeat, 0.05f, 1f); + for (int i = 0; i < Entries.Count; i++) + { + if (Entries[i].UnitId != unitId) + { + continue; + } + + LedgerEntry e = Entries[i]; + e.Heat = Mathf.Max(0.15f, e.Heat * multiplyHeat); + Entries[i] = e; + return; + } + } + public static void Enumerate(List into) { if (into == null) diff --git a/IdleSpectator/Story/StoryPlanner.cs b/IdleSpectator/Story/StoryPlanner.cs index 3bd81ca..c7c8124 100644 --- a/IdleSpectator/Story/StoryPlanner.cs +++ b/IdleSpectator/Story/StoryPlanner.cs @@ -13,15 +13,259 @@ public static class StoryPlanner private static StoryArc _active; private static bool _coldHookFired; private static string _coldHookClimaxKey = ""; + private static string _coldHookAnchor = ""; private static readonly List PendingScratch = new List(64); public static StoryArc Active => _active != null && _active.IsActive ? _active : null; + /// + /// Quiet dossier spine: "Duel · Aftermath". + /// Empty when no live arc, or when the current watch tip is not that story beat. + /// + public static string FormatSpineLabel(InterestCandidate watch = null, StoryArc arc = null) + { + watch = watch ?? InterestDirector.CurrentCandidate; + // Sticky combat can reframe Battle↔Duel without SwitchTo - keep Kind honest first. + SyncActiveClimax(watch); + arc = arc ?? Active; + if (arc == null || !arc.IsActive || !BelongsToActiveStoryBeat(watch, arc)) + { + return ""; + } + + string kind = FormatKindLabel(arc.Kind); + // Combat tip prose owns the Kind word (Battle/Skirmish/Mass/Duel) so the spine + // matches the orange tip, not a collapsed CombatMass bucket. + string tipKind = CombatSpineKindFromTip(watch); + if (!string.IsNullOrEmpty(tipKind)) + { + kind = tipKind; + } + + string phase = FormatPhaseLabel(arc.Phase); + if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(phase)) + { + return ""; + } + + return kind + " · " + phase; + } + + /// + /// Spine Kind word from the live tip Label (Duel/Skirmish/Battle/Mass). + /// Empty when the tip is not a framed combat scale tip. + /// + private static string CombatSpineKindFromTip(InterestCandidate watch) + { + if (watch == null) + { + return ""; + } + + string label = watch.Label ?? ""; + if (label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)) + { + return "Duel"; + } + + if (label.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)) + { + return "Skirmish"; + } + + if (label.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)) + { + return "Battle"; + } + + if (label.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)) + { + return "Mass"; + } + + return ""; + } + + /// + /// When the current combat tip changes scale in place (no director SwitchTo), + /// retarget the active climax Kind (Mass ↔ Duel) so the spine matches the tip. + /// + public static void SyncActiveClimax(InterestCandidate current = null) + { + current = current ?? InterestDirector.CurrentCandidate; + if (_active == null || !_active.IsActive || _active.Phase != StoryPhase.Climax || current == null) + { + return; + } + + if (!TryClassifyClimax(current, out StoryArcKind kind, out bool hardHold)) + { + return; + } + + string anchor = AnchorFor(current, kind); + bool sameAnchor = string.Equals(_active.AnchorKey, anchor, StringComparison.Ordinal); + bool sameCombatTheater = IsCombatKind(_active.Kind) + && IsCombatKind(kind) + && SameCombatTheater(_active, current); + if (!sameAnchor && !sameCombatTheater) + { + return; + } + + if (_active.Kind != kind) + { + _active.Kind = kind; + _active.Id = "arc:" + kind + ":" + (_active.AnchorKey ?? anchor); + } + + if (!string.IsNullOrEmpty(anchor) + && (sameAnchor || sameCombatTheater)) + { + _active.AnchorKey = anchor; + } + + if (!string.IsNullOrEmpty(current.Key)) + { + _active.ClimaxKey = current.Key; + } + + _active.HardHold = hardHold || _active.HardHold; + NoteCastFrom(current, _active); + } + + private static bool IsCombatKind(StoryArcKind kind) => + kind == StoryArcKind.CombatDuel || kind == StoryArcKind.CombatMass; + + private static bool SameCombatTheater(StoryArc arc, InterestCandidate c) + { + if (arc == null || c == null) + { + return false; + } + + if (!string.IsNullOrEmpty(arc.ClimaxKey) + && string.Equals(arc.ClimaxKey, c.Key, StringComparison.Ordinal)) + { + return true; + } + + if (c.PairOwnerId != 0 && c.PairPartnerId != 0) + { + string pair = EventCatalog.Combat.PairKey(c.PairOwnerId, c.PairPartnerId); + if (!string.IsNullOrEmpty(arc.AnchorKey) + && string.Equals(arc.AnchorKey, pair, StringComparison.Ordinal)) + { + return true; + } + } + + return arc.ContainsCast(c.SubjectId) && (c.RelatedId == 0 || arc.ContainsCast(c.RelatedId)); + } + + /// + /// True when is the active arc's climax tip or story aftermath/epilogue beat. + /// Cast membership alone is not enough (avoids stale Love/Duel spines on sleep/hatch tips). + /// + public static bool BelongsToActiveStoryBeat(InterestCandidate c, StoryArc arc = null) + { + arc = arc ?? Active; + if (c == null || arc == null || !arc.IsActive) + { + return false; + } + + if (IsStoryAssetCandidate(c)) + { + // Aftermath/epilogue tips only while the arc is in those phases, + // and only for THIS arc (CorrelationKey) - never a stale peer story tip. + if (arc.Phase != StoryPhase.Aftermath && arc.Phase != StoryPhase.Epilogue) + { + return false; + } + + if (string.IsNullOrEmpty(arc.AnchorKey)) + { + return false; + } + + string expect = "story:" + arc.AnchorKey; + return string.Equals(c.CorrelationKey, expect, StringComparison.Ordinal); + } + + // Cold climax tip must not show "Mass · Aftermath" while fleeing after inject. + if (!string.IsNullOrEmpty(arc.ClimaxKey) + && string.Equals(c.Key, arc.ClimaxKey, StringComparison.Ordinal)) + { + return arc.Phase == StoryPhase.Climax; + } + + // Same sticky theater refreshed under the same anchor while still in climax. + // Kind may still be syncing Mass↔Duel - allow either combat kind on the same theater. + if (arc.Phase == StoryPhase.Climax + && TryClassifyClimax(c, out StoryArcKind kind, out _) + && IsCombatKind(arc.Kind) + && IsCombatKind(kind) + && (kind == arc.Kind + || SameCombatTheater(arc, c) + || string.Equals(AnchorFor(c, kind), arc.AnchorKey, StringComparison.Ordinal))) + { + return true; + } + + if (arc.Phase == StoryPhase.Climax + && TryClassifyClimax(c, out kind, out _) + && kind == arc.Kind + && string.Equals(AnchorFor(c, kind), arc.AnchorKey, StringComparison.Ordinal)) + { + return true; + } + + return false; + } + + public static string FormatKindLabel(StoryArcKind kind) + { + switch (kind) + { + case StoryArcKind.CombatDuel: + return "Duel"; + case StoryArcKind.CombatMass: + return "Mass"; + case StoryArcKind.WarFront: + return "War front"; + case StoryArcKind.Plot: + return "Plot"; + case StoryArcKind.Love: + return "Love"; + case StoryArcKind.Grief: + return "Grief"; + default: + return ""; + } + } + + public static string FormatPhaseLabel(StoryPhase phase) + { + switch (phase) + { + case StoryPhase.Climax: + return "Climax"; + case StoryPhase.Aftermath: + return "Aftermath"; + case StoryPhase.Epilogue: + return "Epilogue"; + default: + return ""; + } + } + public static void Clear() { _active = null; _coldHookFired = false; _coldHookClimaxKey = ""; + _coldHookAnchor = ""; CausalHeat.Clear(); CharacterLedger.Clear(); } @@ -33,6 +277,8 @@ public static class StoryPlanner return; } + SyncActiveClimax(InterestDirector.CurrentCandidate); + // Stale arcs without a living cast eventually end. if (_active.Phase == StoryPhase.Aftermath || _active.Phase == StoryPhase.Epilogue) { @@ -81,6 +327,13 @@ public static class StoryPlanner if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold)) { BeginOrRefreshArc(next, kind, hardHold, now); + return; + } + + // Camera left the story for an unrelated tip - do not keep a lying active arc. + if (_active != null && _active.IsActive && !BelongsToActiveStoryBeat(next, _active)) + { + _active.Advance(StoryPhase.Done, now); } } @@ -91,15 +344,11 @@ public static class StoryPlanner return; } - if (!string.IsNullOrEmpty(_active.ClimaxKey) - && string.Equals(ended.Key, _active.ClimaxKey, StringComparison.Ordinal) - && _active.Phase == StoryPhase.Climax) + // follow_lost / max_cap often end a soft duel before quiet-grace cold. + // Match by theater (Mass↔Duel key flip), not exact ClimaxKey only. + if (_active.Phase == StoryPhase.Climax && BelongsToClimaxTheater(ended, _active)) { - // Cold-hook should have advanced; if not, still try aftermath once. - if (!_coldHookFired || !string.Equals(_coldHookClimaxKey, ended.Key, StringComparison.Ordinal)) - { - OnClimaxCold(ended); - } + OnClimaxCold(ended); } if (IsStoryAssetCandidate(ended)) @@ -126,9 +375,26 @@ public static class StoryPlanner } float now = Time.unscaledTime; - // Once per climax key - director may re-enter inactive while min-dwell resets grace. + // Sticky combat can reframe keys in place before cold - keep arc/climax aligned. + if (_active != null && _active.IsActive && _active.Phase == StoryPhase.Climax) + { + SyncActiveClimax(climax); + } + + string climaxKey = climax.Key ?? ""; + string anchor = _active != null && _active.IsActive + ? (_active.AnchorKey ?? "") + : ""; + if (string.IsNullOrEmpty(anchor) && TryClassifyClimax(climax, out StoryArcKind classifyKind, out _)) + { + anchor = AnchorFor(climax, classifyKind); + } + + // Once per climax theater (anchor), not per sticky key refresh. if (_coldHookFired - && string.Equals(_coldHookClimaxKey, climax.Key, StringComparison.Ordinal)) + && (!string.IsNullOrEmpty(_coldHookAnchor) + && string.Equals(_coldHookAnchor, anchor, StringComparison.Ordinal) + || string.Equals(_coldHookClimaxKey, climaxKey, StringComparison.Ordinal))) { return; } @@ -139,8 +405,7 @@ public static class StoryPlanner return; } - if (_active == null || !_active.IsActive - || !string.Equals(_active.ClimaxKey, climax.Key, StringComparison.Ordinal)) + if (_active == null || !_active.IsActive || !BelongsToClimaxTheater(climax, _active)) { if (!TryClassifyClimax(climax, out kind, out hardHold)) { @@ -148,10 +413,22 @@ public static class StoryPlanner } BeginOrRefreshArc(climax, kind, hardHold, now); + anchor = _active != null ? (_active.AnchorKey ?? anchor) : anchor; + } + else if (!string.IsNullOrEmpty(climaxKey)) + { + _active.ClimaxKey = climaxKey; + if (!string.IsNullOrEmpty(_active.AnchorKey)) + { + anchor = _active.AnchorKey; + } } _coldHookFired = true; - _coldHookClimaxKey = climax.Key ?? ""; + _coldHookClimaxKey = !string.IsNullOrEmpty(climaxKey) + ? climaxKey + : (_active != null ? (_active.ClimaxKey ?? "") : ""); + _coldHookAnchor = anchor ?? ""; if (TryClaimNaturalGrief(climax, _active)) { @@ -168,14 +445,50 @@ public static class StoryPlanner if (HasStrongerPendingPeer(climax)) { - // Natural drama already queued (e.g. lover tip in combat_hold) - do not steal. + // Natural grief/love aftermath already queued - do not synthesize a twin. + // End climax ownership so spine/hold cannot stick across the peer tip. + if (_active != null && _active.IsActive && _active.Phase == StoryPhase.Climax) + { + _active.Advance(StoryPhase.Done, now); + } + return; } if (TryInjectAftermath(climax, _active, now)) { _active.Advance(StoryPhase.Aftermath, now); + return; } + + if (_active != null && _active.IsActive && _active.Phase == StoryPhase.Climax) + { + _active.Advance(StoryPhase.Done, now); + } + } + + /// + /// Same climax theater even when sticky combat refreshes the candidate key (Battle↔Duel). + /// + private static bool BelongsToClimaxTheater(InterestCandidate c, StoryArc arc) + { + if (c == null || arc == null || !arc.IsActive) + { + return false; + } + + if (!string.IsNullOrEmpty(arc.ClimaxKey) + && string.Equals(c.Key, arc.ClimaxKey, StringComparison.Ordinal)) + { + return true; + } + + if (BelongsToActiveStoryBeat(c, arc)) + { + return true; + } + + return IsCombatKind(arc.Kind) && SameCombatTheater(arc, c); } public static bool IsContinuationOf(InterestCandidate current, InterestCandidate next) @@ -269,6 +582,14 @@ public static class StoryPlanner return 0f; } + // Hold only while the camera is still on the aftermath/epilogue beat. + // Cast life tips (parenthood, sleep) must not keep the raised wall or the village + // ping-pongs under a fake sticky margin. + if (!BelongsToActiveStoryBeat(current, _active)) + { + return 0f; + } + if (OwnsCandidate(next) || IsContinuationOf(current, next)) { return 0f; @@ -291,6 +612,18 @@ public static class StoryPlanner return false; } + // Injected aftermath/epilogue must win quiet-grace picks over cast life noise + // (hatch/food/sleep), even when those tips have higher raw EventStrength. + if (IsStoryAssetCandidate(a) + && !IsStoryAssetCandidate(b) + && (_active.Phase == StoryPhase.Aftermath + || _active.Phase == StoryPhase.Epilogue + || _active.Phase == StoryPhase.Climax) + && !IsNaturalAftermathPeer(b, InterestDirector.CurrentCandidate)) + { + return true; + } + bool aOwn = OwnsCandidate(a); bool bOwn = OwnsCandidate(b); return aOwn && !bOwn; @@ -332,6 +665,7 @@ public static class StoryPlanner NoteCastFrom(climax, _active); _coldHookFired = false; _coldHookClimaxKey = ""; + _coldHookAnchor = ""; CausalHeat.NoteCast(_active.CastIds); } @@ -392,6 +726,10 @@ public static class StoryPlanner mass = true; } + // Live tip prose wins over sticky scale-hold so spine Kind matches the Label + // (Duel - … must not keep Mass · Climax after a count dip). + CombatTipScaleHint(c, ref mass); + bool notablePair = c.NotableParticipantCount >= 2; if (mass) { @@ -411,6 +749,92 @@ public static class StoryPlanner return false; } + /// + /// Prefer the orange tip's presented scale over sticky hysteresis for arc Kind. + /// + private static void CombatTipScaleHint(InterestCandidate c, ref bool mass) + { + if (c == null) + { + return; + } + + string label = c.Label ?? ""; + if (label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) + || label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0) + { + mass = false; + return; + } + + if (label.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase) + || label.StartsWith("Battle", StringComparison.OrdinalIgnoreCase) + || label.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)) + { + mass = true; + } + } + + /// + /// When a story aftermath/epilogue Follow dies, promote another living cast member + /// so the beat is not dropped as follow_lost. + /// + public static bool TryRecoverStoryFollow(InterestCandidate scene) + { + if (scene == null || !IsStoryAssetCandidate(scene)) + { + return false; + } + + if (scene.HasFollowUnit) + { + return true; + } + + StoryArc arc = Active; + Actor next = null; + if (arc != null && arc.IsActive) + { + for (int i = 0; i < arc.CastIds.Count; i++) + { + Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]); + if (a != null) + { + next = a; + break; + } + } + } + + if (next == null && scene.RelatedUnit != null && scene.RelatedUnit.isAlive()) + { + next = scene.RelatedUnit; + } + + if (next == null && scene.RelatedId != 0) + { + next = EventFeedUtil.FindAliveById(scene.RelatedId); + } + + if (next == null) + { + return false; + } + + scene.FollowUnit = next; + scene.SubjectId = EventFeedUtil.SafeId(next); + try + { + scene.Position = next.current_position; + } + catch + { + // keep + } + + return scene.HasFollowUnit; + } + private static bool IsLoveBeat(InterestCandidate c) { if (c == null) @@ -419,23 +843,37 @@ public static class StoryPlanner } string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); - if (asset == "set_lover" || asset == "find_lover" || asset == AftermathLoveKey()) + // Outcome bonds only. Seeking / find_lover intent is camera-worthy but not a Love climax. + if (asset == "set_lover" + || asset == "fallen_in_love" + || asset == "fell_in_love" + || asset == AftermathLoveKey()) { return true; } + if (asset == "find_lover" || asset == "clear_lover") + { + return false; + } + string hap = (c.HappinessEffectId ?? "").Trim().ToLowerInvariant(); - if (hap.IndexOf("lover", StringComparison.Ordinal) >= 0 - || hap.IndexOf("fallen_in_love", StringComparison.Ordinal) >= 0 + if (hap == "fallen_in_love" + || hap == "fell_in_love" || hap.IndexOf("married", StringComparison.Ordinal) >= 0) { return true; } + // Death-of-lover grief is Grief, not Love. + if (hap.StartsWith("death_", StringComparison.Ordinal)) + { + return false; + } + string cat = (c.Category ?? "").Trim(); return string.Equals(cat, "Relationship", StringComparison.OrdinalIgnoreCase) - && (asset.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0 - || asset.IndexOf("love", StringComparison.OrdinalIgnoreCase) >= 0); + && asset == "set_lover"; } private static string AftermathLoveKey() => StoryReason.AftermathLoveLinger; @@ -584,8 +1022,6 @@ public static class StoryPlanner private static bool HasStrongerPendingPeer(InterestCandidate climax) { - StoryWeights s = InterestScoringConfig.Story; - float floor = s.aftermathStrength - 5f; PendingScratch.Clear(); InterestRegistry.CopyPending(PendingScratch); for (int i = 0; i < PendingScratch.Count; i++) @@ -601,23 +1037,9 @@ public static class StoryPlanner continue; } - if (p.EventStrength < floor && p.TotalScore < floor) - { - continue; - } - - // Prefer natural life/story peers already queued for this cast. - if (climax != null - && (p.SubjectId == climax.SubjectId - || p.SubjectId == climax.RelatedId - || p.RelatedId == climax.SubjectId - || (_active != null && _active.ContainsCast(p.SubjectId)))) - { - return true; - } - - // High-strength parked Action tips (lover injects) also block synthetic aftermath. - if (p.EventStrength >= s.aftermathStrength) + // Only true natural aftermath peers block synthesis - not hatch/food/village + // cast noise that used to starve soft-duel linger tips. + if (IsNaturalAftermathPeer(p, climax)) { return true; } @@ -626,6 +1048,48 @@ public static class StoryPlanner return false; } + /// + /// Grief / love-outcome tips that already finish the climax story for this cast. + /// Ambient cast life (hatch, eat, sleep) must not suppress aftermath. + /// + private static bool IsNaturalAftermathPeer(InterestCandidate p, InterestCandidate climax) + { + if (p == null) + { + return false; + } + + bool grief = p.Completion == InterestCompletionKind.HappinessGrief + || (!string.IsNullOrEmpty(p.HappinessEffectId) + && p.HappinessEffectId.StartsWith("death_", StringComparison.OrdinalIgnoreCase)); + bool loveOutcome = IsLoveBeat(p); + if (!grief && !loveOutcome) + { + return false; + } + + if (climax == null && _active == null) + { + return true; + } + + if (climax != null + && (p.SubjectId == climax.SubjectId + || p.SubjectId == climax.RelatedId + || p.RelatedId == climax.SubjectId + || p.RelatedId == climax.RelatedId + || p.SubjectId == climax.PairOwnerId + || p.SubjectId == climax.PairPartnerId + || p.RelatedId == climax.PairOwnerId + || p.RelatedId == climax.PairPartnerId)) + { + return true; + } + + return _active != null + && (_active.ContainsCast(p.SubjectId) || _active.ContainsCast(p.RelatedId)); + } + private static bool TryInjectAftermath(InterestCandidate climax, StoryArc arc, float now) { if (climax == null || arc == null) @@ -639,7 +1103,7 @@ public static class StoryPlanner return false; } - Actor related = ResolveAftermathRelated(climax, follow); + Actor related = ResolveAftermathRelated(climax, follow, arc.Kind); string assetId = PickAftermathAsset(arc.Kind, related); StoryWeights s = InterestScoringConfig.Story; DiscreteEventEntry catalog = EventCatalog.Story.GetOrFallback(assetId); @@ -647,9 +1111,33 @@ public static class StoryPlanner ? catalog.EventStrength : s.aftermathStrength; string label = StoryReason.AftermathLabel(assetId, follow, related); - string key = "aftermath:" + arc.Kind + ":" + arc.AnchorKey; long followId = EventFeedUtil.SafeId(follow); long relatedId = EventFeedUtil.SafeId(related); + // Camera RelatedUnit must be living; dead theater partners stay as RelatedId for cast/naming. + Actor relatedLive = null; + if (related != null) + { + try + { + if (related.isAlive()) + { + relatedLive = related; + } + } + catch + { + relatedLive = null; + } + } + + if (IsCombatKind(arc.Kind)) + { + // Survivor tip never binds a living bystander as the "fallen" related. + relatedLive = null; + } + // Unique per inject so a prior Selected aftermath cannot block the next cold. + string key = "aftermath:" + arc.Kind + ":" + arc.AnchorKey + ":" + followId + ":" + + Mathf.RoundToInt(now * 1000f); var candidate = new InterestCandidate { @@ -661,7 +1149,7 @@ public static class StoryPlanner Label = label, FollowUnit = follow, SubjectId = followId, - RelatedUnit = related, + RelatedUnit = relatedLive, RelatedId = relatedId, Position = follow.current_position, EventStrength = strength > 0f ? strength : s.aftermathStrength, @@ -684,7 +1172,11 @@ public static class StoryPlanner } arc.NoteCast(followId); - arc.NoteCast(relatedId); + // Theater partner (alive or fallen) only - never social fallbacks. + if (relatedId != 0 && relatedId != followId) + { + arc.NoteCast(relatedId); + } arc.PendingBeats.Enqueue(new StoryBeat { Phase = StoryPhase.Aftermath, @@ -886,7 +1378,14 @@ public static class StoryPlanner return null; } - private static Actor ResolveAftermathRelated(InterestCandidate climax, Actor follow) + /// + /// Theater partner for aftermath naming. Combat never falls back to lover/best friend + /// (those are living bystanders and produce lying "stands over X" tips). + /// + private static Actor ResolveAftermathRelated( + InterestCandidate climax, + Actor follow, + StoryArcKind kind) { if (climax == null) { @@ -894,23 +1393,61 @@ public static class StoryPlanner } long followId = EventFeedUtil.SafeId(follow); + if (kind == StoryArcKind.Love) + { + if (climax.RelatedUnit != null + && climax.RelatedUnit.isAlive() + && EventFeedUtil.SafeId(climax.RelatedUnit) != followId) + { + return climax.RelatedUnit; + } + + if (climax.RelatedId != 0 && climax.RelatedId != followId) + { + Actor bonded = EventFeedUtil.FindAliveById(climax.RelatedId); + if (bonded != null) + { + return bonded; + } + } + + Actor lover = ActorRelation.GetLover(follow); + if (lover != null) + { + return lover; + } + + return ActorRelation.GetBestFriend(follow); + } + + if (kind == StoryArcKind.WarFront || kind == StoryArcKind.Plot) + { + return null; + } + + // Combat / grief / default: climax theater partner only (alive or dead for naming). + return ResolveTheaterPartnerAny(climax, followId); + } + + /// + /// Duel/mass partner from pair lock, RelatedId, or opposing sticky side - including dead units. + /// + private static Actor ResolveTheaterPartnerAny(InterestCandidate climax, long followId) + { + if (climax == null || followId == 0) + { + return null; + } + if (climax.RelatedUnit != null - && climax.RelatedUnit.isAlive() && EventFeedUtil.SafeId(climax.RelatedUnit) != followId) { return climax.RelatedUnit; } - if (climax.FollowUnit != null - && climax.FollowUnit.isAlive() - && EventFeedUtil.SafeId(climax.FollowUnit) != followId) - { - return climax.FollowUnit; - } - if (climax.PairPartnerId != 0 && climax.PairPartnerId != followId) { - Actor p = EventFeedUtil.FindAliveById(climax.PairPartnerId); + Actor p = EventFeedUtil.FindUnitById(climax.PairPartnerId); if (p != null) { return p; @@ -919,20 +1456,67 @@ public static class StoryPlanner if (climax.PairOwnerId != 0 && climax.PairOwnerId != followId) { - Actor p = EventFeedUtil.FindAliveById(climax.PairOwnerId); + Actor p = EventFeedUtil.FindUnitById(climax.PairOwnerId); if (p != null) { return p; } } - Actor lover = ActorRelation.GetLover(follow); - if (lover != null) + if (climax.RelatedId != 0 && climax.RelatedId != followId) { - return lover; + Actor p = EventFeedUtil.FindUnitById(climax.RelatedId); + if (p != null) + { + return p; + } } - return ActorRelation.GetBestFriend(follow); + if (climax.Sticky == null) + { + return null; + } + + bool followOnA = false; + for (int i = 0; i < climax.Sticky.SideAIds.Count; i++) + { + if (climax.Sticky.SideAIds[i] == followId) + { + followOnA = true; + break; + } + } + + var opposite = followOnA ? climax.Sticky.SideBIds : climax.Sticky.SideAIds; + // Prefer a dead opposite (true fallen), else first opposite for lookup. + Actor livingOpposite = null; + for (int i = 0; i < opposite.Count; i++) + { + Actor a = EventFeedUtil.FindUnitById(opposite[i]); + if (a == null || EventFeedUtil.SafeId(a) == followId) + { + continue; + } + + try + { + if (!a.isAlive()) + { + return a; + } + } + catch + { + return a; + } + + if (livingOpposite == null) + { + livingOpposite = a; + } + } + + return livingOpposite; } private static string PickAftermathAsset(StoryArcKind kind, Actor related) @@ -948,9 +1532,7 @@ public static class StoryPlanner case StoryArcKind.Grief: return StoryReason.AftermathMourner; default: - return related != null && !related.isAlive() - ? StoryReason.AftermathSurvivor - : StoryReason.AftermathSurvivor; + return StoryReason.AftermathSurvivor; } } diff --git a/IdleSpectator/Story/StoryReason.cs b/IdleSpectator/Story/StoryReason.cs index 73d8653..054e0a4 100644 --- a/IdleSpectator/Story/StoryReason.cs +++ b/IdleSpectator/Story/StoryReason.cs @@ -51,9 +51,27 @@ public static class StoryReason : name + " seeks " + other + " after the story"; case AftermathSurvivor: default: - return string.IsNullOrEmpty(other) - ? name + " stands over the fallen" - : name + " stands over " + other; + // Only name a dead theater partner. Living lover/friend fallbacks must never + // print as the fallen (soak: "stands over Clemond" after a duel vs someone else). + if (related != null) + { + bool dead = false; + try + { + dead = !related.isAlive(); + } + catch + { + dead = true; + } + + if (dead && !string.IsNullOrEmpty(other)) + { + return name + " stands over " + other; + } + } + + return name + " stands over the fallen"; } } } diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index 7a08884..aa739f3 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -410,6 +410,15 @@ public sealed class UnitDossier return ""; } + // Dossier subject must own the leading proper name on non-ensemble tips. + // Catches Follow/Subject drift where the tip Label still names someone else. + if (!IsEnsembleCombatReason(beat) && ReasonLeadsWithForeignDossierName(beat, actor, d, scene)) + { + InterestDropLog.Record("identity_filter", "dossier_foreign:" + beat); + d.ReasonRelatedId = 0; + return ""; + } + // Gold names inside the orange reason row (same palette as history/activity). string subjectName = d != null ? (d.Name ?? "") : ""; if (string.IsNullOrEmpty(subjectName)) @@ -543,7 +552,18 @@ public sealed class UnitDossier } string subjectName = EventFeedUtil.SafeName(scene.FollowUnit); + if (string.IsNullOrEmpty(subjectName)) + { + Actor subject = EventFeedUtil.FindAliveById(scene.SubjectId); + subjectName = EventFeedUtil.SafeName(subject); + } + string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit); + if (string.IsNullOrEmpty(relatedName) && scene.RelatedId != 0) + { + relatedName = EventFeedUtil.SafeName(EventFeedUtil.FindAliveById(scene.RelatedId)); + } + if (!string.IsNullOrEmpty(subjectName) && first.Equals(FirstToken(subjectName), System.StringComparison.OrdinalIgnoreCase)) { @@ -572,7 +592,101 @@ public sealed class UnitDossier continue; } - if (unit == scene.FollowUnit || unit == scene.RelatedUnit) + long id = EventFeedUtil.SafeId(unit); + if (unit == scene.FollowUnit + || unit == scene.RelatedUnit + || (scene.SubjectId != 0 && id == scene.SubjectId) + || (scene.RelatedId != 0 && id == scene.RelatedId)) + { + continue; + } + + string name = EventFeedUtil.SafeName(unit); + if (string.IsNullOrEmpty(name) || name.Length < 3) + { + continue; + } + + if (first.Equals(FirstToken(name), System.StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + catch + { + // ignore + } + + return false; + } + + /// + /// True when the reason leads with a living unit name that is not the dossier subject + /// (and not the scene's related partner when the dossier subject is that partner). + /// + private static bool ReasonLeadsWithForeignDossierName( + string reason, + Actor dossierActor, + UnitDossier dossier, + InterestCandidate scene) + { + if (string.IsNullOrEmpty(reason) || scene == null) + { + return false; + } + + string first = FirstToken(reason); + if (string.IsNullOrEmpty(first) || IsOwnedBeatKeyword(first)) + { + return false; + } + + string dossierName = dossier != null ? (dossier.Name ?? "") : ""; + if (string.IsNullOrEmpty(dossierName)) + { + dossierName = EventFeedUtil.SafeName(dossierActor); + } + + if (!string.IsNullOrEmpty(dossierName) + && first.Equals(FirstToken(dossierName), System.StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Related partner may show a subject-led sentence ("Iksssssa shares intimacy with Anya") + // only when the dossier subject is that related partner. + long dossierId = dossier != null ? dossier.UnitId : EventFeedUtil.SafeId(dossierActor); + if (dossierId != 0 + && scene.RelatedId != 0 + && dossierId == scene.RelatedId) + { + Actor subject = scene.FollowUnit != null && scene.FollowUnit.isAlive() + ? scene.FollowUnit + : EventFeedUtil.FindAliveById(scene.SubjectId); + string subjectName = EventFeedUtil.SafeName(subject); + if (!string.IsNullOrEmpty(subjectName) + && first.Equals(FirstToken(subjectName), System.StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + if (World.world?.units == null || first.Length < 3) + { + return false; + } + + try + { + foreach (Actor unit in World.world.units) + { + if (unit == null || !unit.isAlive()) + { + continue; + } + + if (unit == dossierActor) { continue; } diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 18efd4d..8365c6b 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -8,7 +8,7 @@ using UnityEngine.UI; namespace IdleSpectator; /// -/// Compact dossier: nametag (species/name/lv/task/sex), avatar + mini History, statuses, traits, reason. +/// Compact dossier: nametag (species/name/lv/task/sex), avatar + mini History, statuses, traits, reason, story spine. /// public static class WatchCaption { @@ -34,6 +34,7 @@ public static class WatchCaption private const float TraitsH = 14f; private const float StatusesH = 14f; private const float ReasonH = 14f; + private const float StorySpineH = 12f; private const float HistoryLineMinH = 13f; private const float HistoryLineMaxH = 100f; private const float Gap = 2f; @@ -51,6 +52,7 @@ public static class WatchCaption private static readonly Color StatValueColor = HudTheme.ValueOrange; private static readonly Color TaskColor = HudTheme.TaskGreen; private static readonly Color ReasonColor = HudTheme.ValueOrange; + private static readonly Color StorySpineColor = HudTheme.Muted; private static readonly Color TraitNameColor = HudTheme.Body; private static readonly Color StatusNameColor = HudTheme.StatusBlue; private static readonly Color HistoryTextColor = HudTheme.Body; @@ -62,6 +64,7 @@ public static class WatchCaption private static Image _sexIcon; private static Text _nameText; private static Text _reasonText; + private static Text _storySpineText; private static long _reasonOtherId; private static Image _levelIcon; @@ -129,6 +132,9 @@ public static class WatchCaption public static string LastCaptionText { get; private set; } = ""; + /// Harness/UI: active story spine ("Duel · Aftermath"), empty when none. + public static string LastStorySpine { get; private set; } = ""; + /// Harness: newest mini-history line currently shown (if any). public static string LastHistoryPreview { get; private set; } = ""; @@ -407,6 +413,7 @@ public static class WatchCaption LastHeadline = ""; LastDetail = ""; LastCaptionText = ""; + LastStorySpine = ""; LastHistoryPreview = ""; LastHistoryJoined = ""; LastHistoryShown = 0; @@ -461,6 +468,9 @@ public static class WatchCaption WireReasonClick(0); } + // Status banners temporarily own the reason row - hide the story spine. + ApplyStorySpine(""); + LastCaptionText = (LastHeadline ?? "Idle Spectator") + " | " + message; // Re-fill from the current subject so Relayout cannot resurrect stale history slots // from a previously focused unit (activeSelf stays true while the column is hidden). @@ -604,6 +614,7 @@ public static class WatchCaption RefreshLiveIdentity(); RefreshLiveStatuses(); RefreshOwnedReason(); + RefreshStorySpine(); RefreshHistoryIfChanged(); return; } @@ -660,6 +671,7 @@ public static class WatchCaption RefreshLiveIdentity(); RefreshLiveStatuses(); RefreshOwnedReason(); + RefreshStorySpine(); RefreshHistoryIfChanged(); } @@ -836,7 +848,11 @@ public static class WatchCaption bool headcountOnly = EventReason.IsHeadcountOnlyChange(prev, next); _current.ReasonLine = next; LastDetail = _current.DetailLine ?? ""; - LastCaptionText = JoinCaptionLines(_current.Headline, next, _current.DetailLine); + LastCaptionText = JoinCaptionLines( + _current.Headline, + next, + _current.DetailLine, + StoryPlanner.FormatSpineLabel()); bool hasReason = !string.IsNullOrEmpty(next); if (_reasonText != null) @@ -847,6 +863,7 @@ public static class WatchCaption // Mass tip headcount ticks must not Relayout the whole dossier every second. if (headcountOnly) { + RefreshStorySpine(); return; } @@ -860,7 +877,61 @@ public static class WatchCaption CountActiveHistorySlots()); } - private static string JoinCaptionLines(string headline, string reason, string detail) + /// + /// Quiet Kind · Phase line under the orange reason while StoryPlanner owns an arc. + /// + private static void RefreshStorySpine() + { + if (!_visible || HasStatusBanner()) + { + return; + } + + string next = SpectatorMode.Active && ModSettings.ShowDossierCaption + ? StoryPlanner.FormatSpineLabel() + : ""; + if (string.Equals(next, LastStorySpine, StringComparison.Ordinal)) + { + return; + } + + ApplyStorySpine(next); + bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf; + bool hasTask = _taskText != null && _taskText.gameObject.activeSelf; + bool hasBody = _boundActor != null || CountActiveHistorySlots() > 0 || _current != null; + Relayout( + hasBody, + CountActiveTraitSlots(), + CountActiveStatusSlots(), + hasTask, + hasReason, + CountActiveHistorySlots()); + } + + private static void ApplyStorySpine(string spine) + { + LastStorySpine = spine ?? ""; + if (_storySpineText == null) + { + return; + } + + bool show = !string.IsNullOrEmpty(LastStorySpine) && !HasStatusBanner(); + _storySpineText.text = show ? LastStorySpine : ""; + _storySpineText.color = StorySpineColor; + _storySpineText.gameObject.SetActive(show); + + if (_current != null) + { + LastCaptionText = JoinCaptionLines( + _current.Headline, + _current.ReasonLine, + _current.DetailLine, + LastStorySpine); + } + } + + private static string JoinCaptionLines(string headline, string reason, string detail, string storySpine = null) { var sb = new System.Text.StringBuilder(); if (!string.IsNullOrEmpty(headline)) @@ -878,6 +949,16 @@ public static class WatchCaption sb.Append(reason); } + if (!string.IsNullOrEmpty(storySpine)) + { + if (sb.Length > 0) + { + sb.Append('\n'); + } + + sb.Append(storySpine); + } + if (!string.IsNullOrEmpty(detail)) { if (sb.Length > 0) @@ -970,7 +1051,11 @@ public static class WatchCaption _current.IdentityTag = tag; _current.Headline = headline; LastHeadline = headline; - LastCaptionText = JoinCaptionLines(headline, _current.ReasonLine, _current.DetailLine); + LastCaptionText = JoinCaptionLines( + headline, + _current.ReasonLine, + _current.DetailLine, + LastStorySpine); if (_nameText != null) { _nameText.text = headline; @@ -1420,6 +1505,11 @@ public static class WatchCaption hasReason && dossier != null ? dossier.ReasonRelatedId : 0); } + string spine = SpectatorMode.Active && ModSettings.ShowDossierCaption && dossier != null + ? StoryPlanner.FormatSpineLabel() + : ""; + ApplyStorySpine(spine); + Relayout(hasBody, traitCount, statusCount, hasTask, hasReason, historyCount); RefreshFavoriteVisual(hasLive ? actor : null); } @@ -2287,6 +2377,15 @@ public static class WatchCaption y += reasonH + Gap; } + bool hasStorySpine = _storySpineText != null + && _storySpineText.gameObject.activeSelf + && !string.IsNullOrEmpty(_storySpineText.text); + if (hasStorySpine) + { + PlaceLine(_storySpineText.GetComponent(), y, StorySpineH, PadX); + y += StorySpineH + Gap; + } + float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap); if (_rootRt != null) { @@ -2925,6 +3024,8 @@ public static class WatchCaption return LineInside(_nameText.GetComponent(), panelHeight) && (_reasonText == null || !_reasonText.gameObject.activeSelf || LineInside(_reasonText.GetComponent(), panelHeight)) + && (_storySpineText == null || !_storySpineText.gameObject.activeSelf + || LineInside(_storySpineText.GetComponent(), panelHeight)) && (_statusesRow == null || !_statusesRow.activeSelf || LineInside(_statusesRow.GetComponent(), panelHeight)) && (_traitsRow == null || !_traitsRow.activeSelf @@ -2997,7 +3098,9 @@ public static class WatchCaption _sexIcon = null; _nameText = null; _reasonText = null; + _storySpineText = null; _reasonOtherId = 0; + LastStorySpine = ""; _levelIcon = null; _levelValue = null; _taskIcon = null; @@ -3144,6 +3247,15 @@ public static class WatchCaption _reasonText.resizeTextForBestFit = false; WireReasonClick(0); + _storySpineText = HudCanvas.MakeText(_root.transform, "StorySpine", "", 7); + _storySpineText.color = StorySpineColor; + _storySpineText.supportRichText = false; + _storySpineText.alignment = TextAnchor.UpperLeft; + _storySpineText.horizontalOverflow = HorizontalWrapMode.Overflow; + _storySpineText.verticalOverflow = VerticalWrapMode.Truncate; + _storySpineText.resizeTextForBestFit = false; + _storySpineText.raycastTarget = false; + _speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize); _sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize); @@ -3173,6 +3285,7 @@ public static class WatchCaption _statusesRow.SetActive(false); _traitsRow.SetActive(false); _reasonText.gameObject.SetActive(false); + _storySpineText.gameObject.SetActive(false); _levelIcon.gameObject.SetActive(false); _levelValue.gameObject.SetActive(false); _taskIcon.gameObject.SetActive(false); @@ -3180,7 +3293,7 @@ public static class WatchCaption Relayout(false, 0, 0, false, false, 0); _root.SetActive(false); _visible = false; - LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag + statuses + traits)"); + LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag + statuses + traits + story spine)"); } private static void BuildHeaderButtons() diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 8d71a2b..48ed5ad 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.28.41", + "version": "0.28.54", "description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.", "GUID": "com.dazed.idlespectator" } diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json index 52a9906..567d808 100644 --- a/IdleSpectator/scoring-model.json +++ b/IdleSpectator/scoring-model.json @@ -1,5 +1,5 @@ { - "modVersion": "0.28.39", + "modVersion": "0.28.49", "title": "IdleSpectator interest scoring model", "updated": "2026-07-18", "note": "Edit weights below - InterestScoringConfig loads this file at mod start. Docs sections are human-facing only (ignored by Unity JsonUtility).", @@ -126,7 +126,10 @@ "epilogueMaxWatch": 14, "ledgerCap": 16, "ledgerWeight": 8, - "historyDensityWindowSeconds": 600 + "historyDensityWindowSeconds": 600, + "ledgerBleedLifeChapter": 0.4, + "ledgerBleedVillage": 0.82, + "familyLifeBeatCooldownSeconds": 90 }, "sources": [ diff --git a/docs/event-reason.md b/docs/event-reason.md index 74b54b3..591f931 100644 --- a/docs/event-reason.md +++ b/docs/event-reason.md @@ -16,6 +16,12 @@ Discrete / status / WorldLog: `CreatesInterest` = A gate. Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only). Orange dossier reason = the owning A event’s `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`). +The focused unit must be a tip principal: +- Life / discrete tips: SubjectId + RelatedId (Follow alone is not enough after a handoff). +- Pair-locked duels: PairOwner/PairPartner only. +- Mass/war/plot/family/outbreak: sticky roster members OK. +Dossier identity also rejects subject-led Labels that name another living unit +(e.g. Hillih must not show “Iksssssa shares intimacy…”). Quiet grace / inactive dwell clears the reason even if focus has not switched yet. ## Activity History peek freshness diff --git a/docs/story-planner.md b/docs/story-planner.md index c59429a..71bdc10 100644 --- a/docs/story-planner.md +++ b/docs/story-planner.md @@ -27,10 +27,85 @@ Planner never calls `MoveCamera`. Phases: `Climax → Aftermath → Epilogue → Done`. -- **Aftermath** injects on climax cold (quiet grace), or claims natural `death_*` grief. +- **Aftermath** injects on climax cold (quiet grace / `follow_lost`), or claims natural `death_*` grief. +- Only grief / love-outcome pending tips block synthesis - cast hatch/food noise does not. +- Injected story beats bypass FixedDwell beat hard-skip and PreferOver cast life tips while the arc is live. +- Soft duel Mass↔Duel key flips still cold-hook once per theater anchor. - **Epilogue** once after aftermath if a living lover/friend resolves. - Soft anonymous duels still get aftermath tips; they do not use raised `arcHoldMargin`. +## Dossier spine + +While the **current watch tip** is the active arc's climax or story aftermath/epilogue beat, +the dossier shows a muted line under the orange reason: `Kind · Phase` +(e.g. `Duel · Aftermath`, `War front · Climax`). + +Hidden when idle is off, the dossier is hidden, a status banner owns the reason row, +or the camera has left the story for an unrelated tip (cast membership alone is not enough). + +Love climax is outcome-only (`set_lover` / fallen-in-love). `find_lover` seeking stays camera-worthy +but does not open a Love arc. Leaving a climax without aftermath ends the arc (`Done`). + +Sticky combat reframes (Battle ↔ Duel) sync climax Kind in place via `SyncActiveClimax` +so the spine cannot say `Mass · Climax` on a Duel tip. + +`arcHoldMargin` applies only while the watch tip **is** the aftermath/epilogue beat. +Cast life tips (parenthood, sleep) no longer keep the raised cut-in wall. + +Family life chapters (`just_had_child`, pregnancy, …) share `arc:life:family`, use a longer +beat cool (including world-wide happiness cool), and bleed ledger heat so the village cast +cannot monopolize the next picks. + +Intimacy chapters (`just_kissed` / shares intimacy) use `arc:life:intimacy` with the same +soft-life cool class (pair + world) so one couple cannot keep cutting between combat tips. + +Settlement finds (`just_found_house` / finds a home) use `arc:life:home` with the same soft cool +(world + subject) so house crumbs cannot keep cutting between combat beats. + +Kill crumbs (`milestone_kill` / `Killed X (species)` / `just_killed`) use `arc:life:kill` with +the same soft cool (subject + world) so hunter kill tips cannot keep cutting between mass beats. + +Courtship crumbs (`fallen_in_love` / smitten / `find_lover` seeking) use `arc:life:love` soft cool +(subject + world + pair). Bond outcomes (`set_lover`) stay hotter. + +Life-intent decisions (`sexual_reproduction_*`, decision `find_lover`, …) share +`arc:decision:intent` soft cool (subject + world). Outcomes (pregnancy, child, seeking) stay hotter. + +Ordinary civ construction finishes (`building:complete` / tents / houses) share `arc:construction` +soft cool. Spectacle wonders stay hotter. + +Soft personal status FX (`taking_roots`, singing, moods, …) share `arc:status:fx` soft cool. +Danger / combat statuses stay hotter. Hatch crumbs use `arc:life:hatch`. + +Sleep / wake / dream cycle (`sleeping`, `just_slept`, `had_*dream`, `had_nightmare`, …) share +`arc:life:rest` soft cool so rest crumbs cannot monopolize between richer beats. + +WorldLog politics meta (`king_dead`, `city_destroyed`, favorite fell, …) share `arc:worldlog:meta` +soft cool so sticky death crumbs cannot re-monopolize. + +Interrupted resume: already-cooled moment beats are not parked / not resumed after a cut-in +(hatch/reproduce cannot bounce back after combat). + +Story spine: aftermath tips must match the active arc `CorrelationKey` (no stale duel aftermath +under a Mass spine). Combat Kind word follows tip Label (`Battle` / `Skirmish` / `Mass` / `Duel`). + +Wiped opposing combat camps collapse to a living-side pack label at live scale +(never `Mass - A (n) vs B (0)`). + +Combat aftermath names only a dead theater partner (`stands over X`), else +`stands over the fallen`. Living lover/friend fallbacks are never the fallen. + +Spine Kind follows the live tip Label for combat (Duel vs Mass/Skirmish/Battle), not sticky +scale-hold alone. Cold climax tips do not show `· Aftermath` - only the aftermath tip does. +Aftermath Follow loss promotes another living cast member before `follow_lost`. + +Harness: `story_spine` / `story_hold_margin` / `story_family_variety` / `story_intimacy_cool` +/ `story_settlement_cool` / `story_kill_cool` / `story_courtship_cool` +/ `story_decision_intent_cool` / `story_decision_intent_live` / `story_construction_cool` +/ `story_status_fx_cool` / `story_rest_cool` / `story_resume_cooled` / `story_worldlog_meta_cool` +/ `combat_wiped_side` / `reason_life_principal` +(`story_arc_combat`, `story_spine_scoped`, `story_aftermath_combat`, `story_family_variety`). + ## Selection discipline - `InterestVariety.Pick` takes `#1` when score gap ≥ `story.nearTieEpsilon` (default 8). @@ -49,6 +124,22 @@ Catalog policy ids live under `event-catalog.json` → `story` | Scenario | Proves | |----------|--------| | `story_aftermath_combat` | duel cold → aftermath tip | +| `story_aftermath_partner_truth` | aftermath never names living lover as fallen | +| `story_aftermath_cast_noise` | hatch noise pending still gets aftermath | +| `reason_duel_principal` | bystander Follow cannot own duel reason | +| `reason_life_principal` | Follow hijack cannot keep intimacy Label | +| `story_intimacy_cool` | just_kissed cools; peer wins next pick | +| `story_settlement_cool` | finds-a-home cools; peer wins next pick | +| `story_kill_cool` | kill crumbs cool; peer wins next pick | +| `story_courtship_cool` | smitten/seeking cool; peer wins next pick | +| `story_decision_intent_cool` | reproduce/find-lover decisions cool as a class | +| `story_decision_intent_live` | live EmitDecision path cools intent class | +| `story_construction_cool` | tent/house finishes cool; peer wins | +| `story_status_fx_cool` | taking_roots FX cool; peer wins | +| `story_rest_cool` | sleep/wake rest cycle cools; peer wins | +| `story_resume_cooled` | cooled soft tip does not resume after cut-in | +| `story_worldlog_meta_cool` | king_dead cools; peer wins next pick | +| `combat_wiped_side` | wiped camp never prints vs (0) | | `story_aftermath_war` | war sticky cold → aftermath | | `story_near_tie` | large gap always picks #1 | | `story_arc_combat` | climax → aftermath ownership | @@ -57,14 +148,28 @@ Catalog policy ids live under `event-catalog.json` → `story` ```bash ./scripts/harness-run.sh --repeat 3 story_aftermath_combat +./scripts/harness-run.sh --repeat 3 story_aftermath_partner_truth +./scripts/harness-run.sh --repeat 3 story_aftermath_cast_noise +./scripts/harness-run.sh --repeat 3 reason_duel_principal ./scripts/harness-run.sh --repeat 3 story_near_tie ./scripts/harness-run.sh --repeat 3 story_arc_combat ./scripts/harness-run.sh --repeat 3 story_ledger_revisit +./scripts/harness-run.sh --repeat 3 story_kill_cool +./scripts/harness-run.sh --repeat 3 story_courtship_cool +./scripts/harness-run.sh --repeat 3 story_decision_intent_cool +./scripts/harness-run.sh --repeat 3 story_decision_intent_live +./scripts/harness-run.sh --repeat 3 story_construction_cool +./scripts/harness-run.sh --repeat 3 story_status_fx_cool +./scripts/harness-run.sh --repeat 3 story_rest_cool +./scripts/harness-run.sh --repeat 3 story_resume_cooled +./scripts/harness-run.sh --repeat 3 story_worldlog_meta_cool +./scripts/harness-run.sh --repeat 3 combat_wiped_side ``` ## Files -- `IdleSpectator/Story/StoryPlanner.cs` - ownership, cold-hook, inject +- `IdleSpectator/Story/StoryPlanner.cs` - ownership, cold-hook, inject, spine labels - `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat - `IdleSpectator/Story/CharacterLedger.cs` - watched lives (cap 16) - `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels +- `IdleSpectator/WatchCaption.cs` - dossier `Kind · Phase` spine row