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