From af17d9acd184b8079ac83f554dffcff1558f0c07 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 23 Jul 2026 15:32:39 -0500 Subject: [PATCH] feat(identity): enhance job display and narrative probes for identity roles - Introduce IdentityJobDisplayLabel to normalize job labels - Update harness scenarios to reflect identity role changes - Implement probes for live lover recovery and episode continuity - Refactor narrative presentation to improve event relevance and tracking - Expand InterestVariety to manage routine replay cooldowns effectively --- IdleSpectator/ActivityAssetCatalog.cs | 26 +++++ IdleSpectator/AgentHarness.cs | 19 ++- IdleSpectator/Events/StickyScoreboard.cs | 29 ++++- IdleSpectator/HappinessEventRouter.cs | 33 ++++++ IdleSpectator/HarnessScenarios.cs | 3 +- .../InterestDirector.StickyMaintain.cs | 17 ++- IdleSpectator/InterestVariety.cs | 110 ++++++++++++++++++ .../Narrative/EpisodeShotSelector.cs | 1 + IdleSpectator/Narrative/StoryScheduler.cs | 38 +++++- IdleSpectator/Story/CaptionComposer.cs | 29 ++++- IdleSpectator/UnitDossier.cs | 19 ++- IdleSpectator/WatchCaption.cs | 49 +++++++- 12 files changed, 354 insertions(+), 19 deletions(-) diff --git a/IdleSpectator/ActivityAssetCatalog.cs b/IdleSpectator/ActivityAssetCatalog.cs index 2afbd4b..c93c55b 100644 --- a/IdleSpectator/ActivityAssetCatalog.cs +++ b/IdleSpectator/ActivityAssetCatalog.cs @@ -177,6 +177,32 @@ public static class ActivityAssetCatalog return FormatCitizenJobIdLabel(asset.id); } + /// + /// Stable identity role for the dossier nametag. Simulation variants such as + /// miner_deposit describe the current assignment, not a distinct profession. + /// Activity/task prose may still use the full job label. + /// + public static string IdentityJobDisplayLabel(string jobId) + { + if (string.IsNullOrEmpty(jobId)) + { + return ""; + } + + string id = jobId.Trim(); + const string depositSuffix = "_deposit"; + if (id.EndsWith(depositSuffix, StringComparison.OrdinalIgnoreCase) + && id.Length > depositSuffix.Length) + { + id = id.Substring(0, id.Length - depositSuffix.Length); + } + + return JobDisplayLabel(id); + } + + public static string IdentityJobDisplayLabel(CitizenJobAsset asset) => + asset == null ? "" : IdentityJobDisplayLabel(asset.id); + /// /// Title Case + discovered resource-role reorder for a citizen job id. /// Public for harness audits over the full live library. diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 44b6da4..ab98542 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -2941,19 +2941,32 @@ public static class AgentHarness case "narrative_iteration_probe": { bool contextOk = CaptionComposer.HarnessProbeContextPolicy(out string context); + bool spineOk = WatchCaption.HarnessProbeStorySpineRelevance(out string spine); bool combatOk = StickyScoreboard.HarnessProbeReasonStability(out string combat); + bool noveltyOk = InterestVariety.HarnessProbeRoutineReplay(out string novelty); bool participantOk = NarrativePresentationFactory.HarnessProbeNamedParticipantFallback( out string participant); + bool loverOk = + HappinessEventRouter.HarnessProbeLiveLoverRecoveryPolicy(out string lover); + bool identityOk = + UnitDossier.HarnessProbeIdentityJobPresentation(out string identity); + bool episodeOk = + StoryScheduler.HarnessProbeEpisodeContinuity(out string episode); bool proseOk = EventReason.HarnessProbePresentationProse(out string prose); bool persistenceOk = NarrativePersistence.HarnessRoundTrip(out string persistence); - bool ok = contextOk && combatOk && participantOk && proseOk && persistenceOk; + bool ok = contextOk && spineOk && combatOk && noveltyOk + && participantOk && loverOk && identityOk && episodeOk + && proseOk && persistenceOk; if (ok) _cmdOk++; else _cmdFail++; Emit( cmd, ok, - detail: "context={" + context + "} combat={" + combat - + "} participant={" + participant + "} prose={" + prose + detail: "context={" + context + "} spine={" + spine + + "} combat={" + combat + "} novelty={" + novelty + + "} participant={" + participant + "} lover={" + lover + + "} identity={" + identity + "} episode={" + episode + + "} prose={" + prose + "} persistence={" + persistence + "}"); break; } diff --git a/IdleSpectator/Events/StickyScoreboard.cs b/IdleSpectator/Events/StickyScoreboard.cs index e4275df..2e7d60a 100644 --- a/IdleSpectator/Events/StickyScoreboard.cs +++ b/IdleSpectator/Events/StickyScoreboard.cs @@ -296,6 +296,24 @@ namespace IdleSpectator; return proposed; } + /// + /// When structural hysteresis keeps the previous named duel, its existing principals + /// must remain camera principals too. Otherwise the identity changes a frame before + /// the held "A vs B" reason catches up. + /// + public static bool HoldsPresentedDuelPrincipals( + string previous, + string proposed, + string presented, + bool allowImmediate) + { + return !allowImmediate + && !string.IsNullOrEmpty(previous) + && previous.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) + && !string.Equals(previous, proposed ?? "", StringComparison.Ordinal) + && string.Equals(previous, presented ?? "", StringComparison.Ordinal); + } + private static bool IsCombatReason(string label) { string value = (label ?? "").Trim(); @@ -319,14 +337,21 @@ namespace IdleSpectator; sticky.Clear(); string immediate = StabilizeCombatReason(sticky, duel, battle, 20f, true); + bool keepsPrincipals = HoldsPresentedDuelPrincipals( + duel, "Duel - Cato vs Dena", duel, allowImmediate: false); + bool immediateMoves = !HoldsPresentedDuelPrincipals( + duel, battle, battle, allowImmediate: true); bool ok = first == duel && held == duel && committed == battle && headcount == battleTick - && immediate == battle; + && immediate == battle + && keepsPrincipals + && immediateMoves; detail = "first='" + first + "' held='" + held + "' committed='" + committed + "' headcount='" + headcount - + "' immediate='" + immediate + "' pass=" + ok; + + "' immediate='" + immediate + "' principals=" + + keepsPrincipals + "/" + immediateMoves + " pass=" + ok; return ok; } diff --git a/IdleSpectator/HappinessEventRouter.cs b/IdleSpectator/HappinessEventRouter.cs index 923a192..ba3f2d0 100644 --- a/IdleSpectator/HappinessEventRouter.cs +++ b/IdleSpectator/HappinessEventRouter.cs @@ -230,6 +230,14 @@ public static class HappinessEventRouter } } + // Apply-site context can be absent when the status callback arrives outside the + // relationship mutation stack. For explicitly lover-scoped effects, recover only the + // subject's actual current lover; never guess from proximity or names. + if (related == null && ShouldRecoverLiveLover(role, entry.RelationRole)) + { + related = ActorRelation.GetLover(subject); + } + if (role == HappinessRelationRole.None) { role = entry.RelationRole != HappinessRelationRole.None @@ -688,6 +696,31 @@ public static class HappinessEventRouter return true; } + private static bool ShouldRecoverLiveLover( + HappinessRelationRole supplied, + HappinessRelationRole catalog) + { + return supplied == HappinessRelationRole.Lover + || catalog == HappinessRelationRole.Lover; + } + + public static bool HarnessProbeLiveLoverRecoveryPolicy(out string detail) + { + bool supplied = ShouldRecoverLiveLover( + HappinessRelationRole.Lover, HappinessRelationRole.None); + bool catalog = ShouldRecoverLiveLover( + HappinessRelationRole.None, HappinessRelationRole.Lover); + bool griefRejected = !ShouldRecoverLiveLover( + HappinessRelationRole.DeceasedLover, HappinessRelationRole.DeceasedLover); + bool unrelatedRejected = !ShouldRecoverLiveLover( + HappinessRelationRole.None, HappinessRelationRole.ConversationPartner); + bool ok = supplied && catalog && griefRejected && unrelatedRejected; + detail = "supplied=" + supplied + " catalog=" + catalog + + " grief=" + griefRejected + " unrelated=" + unrelatedRejected + + " pass=" + ok; + return ok; + } + /// /// A bridge callback can know the named partner after the first canonical signal was /// recorded. The drain stores occurrence references, so enriching the earlier survivor diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 0e399de..7eb5c4f 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -5646,7 +5646,8 @@ internal static class HarnessScenarios Step("act16k5", "dossier_force_job", value: "gatherer_bushes"), Step("act16k6", "assert", expect: "dossier_contains", value: "Bush Gatherer"), Step("act16k7", "dossier_force_job", value: "miner_deposit"), - Step("act16k8", "assert", expect: "dossier_contains", value: "Miner Deposit"), + Step("act16k8", "assert", expect: "dossier_contains", value: "Human/Miner"), + Step("act16k9", "assert", expect: "caption_not_contains", value: "Deposit"), Step("act16l", "screenshot", value: "hud-dossier-job-only.png"), Step("act16m", "wait", wait: 0.45f), Step("act16n", "dossier_force_job", value: "farmer", label: "Story"), diff --git a/IdleSpectator/InterestDirector.StickyMaintain.cs b/IdleSpectator/InterestDirector.StickyMaintain.cs index d03b46a..635e1ef 100644 --- a/IdleSpectator/InterestDirector.StickyMaintain.cs +++ b/IdleSpectator/InterestDirector.StickyMaintain.cs @@ -570,12 +570,27 @@ public static partial class InterestDirector "Battle", StringComparison.OrdinalIgnoreCase) || (label ?? "").StartsWith( "Mass", StringComparison.OrdinalIgnoreCase)); + string proposedLabel = label ?? ""; + bool allowImmediateReason = staleNamedPair || promoteCollective; label = StickyScoreboard.StabilizeCombatReason( _current.Sticky, previousLabel, label, Time.unscaledTime, - staleNamedPair || promoteCollective); + allowImmediateReason); + + // A held named-duel reason and a newly proposed focus are one editorial transition. + // Keep both old principals until the reason commits, then move identity + reason together. + if (StickyScoreboard.HoldsPresentedDuelPrincipals( + previousLabel, proposedLabel, label, allowImmediateReason) + && curFollow != null + && curFollow.isAlive() + && curRelated != null + && curRelated.isAlive()) + { + best = curFollow; + foe = curRelated; + } bool followChanged = _current.FollowUnit != best; bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal); diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs index ef2d55b..ddb02f9 100644 --- a/IdleSpectator/InterestVariety.cs +++ b/IdleSpectator/InterestVariety.cs @@ -17,6 +17,8 @@ public static class InterestVariety public static float FixedDwellBeatCooldownSeconds => Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds); public static float EditorialReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 24f); + public static float RoutineReplayCooldownSeconds => + Mathf.Max(FixedDwellBeatCooldownSeconds, 120f); private static readonly List RecentMix = new List(32); private static readonly List RecentArcs = new List(32); @@ -176,6 +178,14 @@ public static class InterestVariety Time.unscaledTime + EditorialReplayCooldownSeconds); } + string routineKey = RoutineReplayKey(chosen); + if (!string.IsNullOrEmpty(routineKey)) + { + StampCooldown( + routineKey, + Time.unscaledTime + RoutineReplayCooldownSeconds); + } + // Soft life chapters cool ledger heat so the same villagers do not monopolize. // Soft-life cool no longer demotes saga MCs (CharacterLedger removed). } @@ -241,6 +251,74 @@ public static class InterestVariety return ok; } + /// + /// Routine texture may recur in the simulation, but the editor should not rediscover the + /// same person's sleep/play/laugh/status beat every short cooldown cycle. + /// + public static bool IsRoutineReplayCooled(InterestCandidate c, float now = -1f) + { + string key = RoutineReplayKey(c); + if (string.IsNullOrEmpty(key)) + { + return false; + } + + if (now < 0f) + { + now = Time.unscaledTime; + } + + return CooldownUntil.TryGetValue(key, out float until) && now < until; + } + + public static bool HarnessProbeRoutineReplay(out string detail) + { + Clear(); + var sleeping = new InterestCandidate + { + SubjectId = 41, + StatusId = "sleeping", + AssetId = "sleeping", + Completion = InterestCompletionKind.StatusPhase, + HasNarrativeFunction = true, + NarrativeFunction = NarrativeFunction.CharacterTexture + }; + NoteSelection(sleeping, updateMix: false); + var same = new InterestCandidate + { + SubjectId = 41, + StatusId = "sleeping", + AssetId = "sleeping", + Completion = InterestCompletionKind.StatusPhase, + HasNarrativeFunction = true, + NarrativeFunction = NarrativeFunction.CharacterTexture + }; + var other = new InterestCandidate + { + SubjectId = 42, + StatusId = "sleeping", + AssetId = "sleeping", + Completion = InterestCompletionKind.StatusPhase, + HasNarrativeFunction = true, + NarrativeFunction = NarrativeFunction.CharacterTexture + }; + var love = new InterestCandidate + { + SubjectId = 41, + HappinessEffectId = "fallen_in_love", + HasNarrativeFunction = true, + NarrativeFunction = NarrativeFunction.ThreadOpener + }; + bool sameCooled = IsRoutineReplayCooled(same); + bool otherOpen = !IsRoutineReplayCooled(other); + bool storyOpen = !IsRoutineReplayCooled(love); + bool ok = sameCooled && otherOpen && storyOpen; + detail = "same=" + sameCooled + " other=" + otherOpen + + " story=" + storyOpen + " pass=" + ok; + Clear(); + return ok; + } + /// /// Score to subtract when this candidate matches the last shown variety arc. /// After one duel show, the next duel pays repeatArcPenaltyPer; a lover resets the streak. @@ -717,6 +795,12 @@ public static class InterestVariety continue; } + if (IsRoutineReplayCooled(c, now)) + { + InterestDropLog.Record("routine_replay", c.Label ?? c.Key); + continue; + } + if (c.LeadKind == InterestLeadKind.CharacterLed) { CharPool.Add(c); @@ -947,6 +1031,32 @@ public static class InterestVariety return string.IsNullOrEmpty(key) ? "" : "replay:key:" + key; } + private static string RoutineReplayKey(InterestCandidate c) + { + if (c == null || c.SubjectId == 0) + { + return ""; + } + + NarrativeFunction function = NarrativeFunctionPolicy.Classify(c); + if (function != NarrativeFunction.CharacterTexture + && !IsRestLifeChapter(c) + && !IsSoftStatusFxChapter(c)) + { + return ""; + } + + string family = c.StatusId; + if (string.IsNullOrEmpty(family)) family = c.HappinessEffectId; + if (string.IsNullOrEmpty(family)) family = c.NarrativePresentation?.ActionId; + if (string.IsNullOrEmpty(family)) family = c.AssetId; + if (string.IsNullOrEmpty(family)) family = NormalizeBeatLabel(c.Label); + family = (family ?? "").Trim().ToLowerInvariant(); + return string.IsNullOrEmpty(family) + ? "" + : "routine:" + c.SubjectId + ":" + family; + } + /// /// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class. /// diff --git a/IdleSpectator/Narrative/EpisodeShotSelector.cs b/IdleSpectator/Narrative/EpisodeShotSelector.cs index 7bd474c..d43a766 100644 --- a/IdleSpectator/Narrative/EpisodeShotSelector.cs +++ b/IdleSpectator/Narrative/EpisodeShotSelector.cs @@ -31,6 +31,7 @@ public static class EpisodeShotSelector bool harness = c != null && string.Equals(c.Source, "harness", System.StringComparison.OrdinalIgnoreCase); if (c == null || c.Selected || InterestVariety.IsEditorialReplayCooled(c) + || InterestVariety.IsRoutineReplayCooled(c) || !StoryScheduler.MayUseCombatShot(episode, c) || (requirePresentable && !harness && !EventPresentability.WouldShow(c))) continue; NarrativeFunction function = NarrativeFunctionPolicy.Classify(c); diff --git a/IdleSpectator/Narrative/StoryScheduler.cs b/IdleSpectator/Narrative/StoryScheduler.cs index e65f8b8..97d47bb 100644 --- a/IdleSpectator/Narrative/StoryScheduler.cs +++ b/IdleSpectator/Narrative/StoryScheduler.cs @@ -11,6 +11,8 @@ public static class StoryScheduler private const float EpisodeNoProgressSeconds = 14f; private const float PayoffSettleSeconds = 8f; private const float EpisodeMaxSeconds = 75f; + private const float EarlyEpisodeSwitchMargin = 30f; + private const float EstablishedEpisodeSwitchMargin = 20f; public const int EpisodeCombatShotBudget = 3; private static readonly List OpenThreads = new List(32); private static readonly List Pending = new List(96); @@ -377,6 +379,27 @@ public static class StoryScheduler return ok; } + public static bool HarnessProbeEpisodeContinuity(out string detail) + { + var early = new EpisodePlan { GoalMet = false }; + early.CoveredRoles.Add(EpisodeShotRole.Establish); + var established = new EpisodePlan { GoalMet = false }; + established.CoveredRoles.Add(EpisodeShotRole.Establish); + established.CoveredRoles.Add(EpisodeShotRole.Development); + var complete = new EpisodePlan { GoalMet = true }; + float earlyMargin = ThreadSwitchMargin(early); + float establishedMargin = ThreadSwitchMargin(established); + float completeMargin = ThreadSwitchMargin(complete); + bool ok = earlyMargin > establishedMargin + && establishedMargin == EstablishedEpisodeSwitchMargin + && completeMargin == EstablishedEpisodeSwitchMargin; + detail = "margin=" + earlyMargin.ToString("0") + + "/" + establishedMargin.ToString("0") + + "/" + completeMargin.ToString("0") + + " pass=" + ok; + return ok; + } + public static float StoryPotential(long characterId) { CharacterStoryState state = NarrativeStoryStore.Character(characterId); @@ -419,7 +442,20 @@ public static class StoryScheduler && now - ActiveEpisode.LastProgressAt >= PayoffSettleSeconds; bool stale = ActiveEpisode != null && now - ActiveEpisode.LastProgressAt >= EpisodeNoProgressSeconds; - return settled || stale || bestScore >= activeScore + 20f ? best : active; + float switchMargin = ThreadSwitchMargin(ActiveEpisode); + return settled || stale || bestScore >= activeScore + switchMargin ? best : active; + } + + private static float ThreadSwitchMargin(EpisodePlan episode) + { + if (episode != null + && !episode.GoalMet + && episode.CoveredRoles.Count < 2) + { + return EarlyEpisodeSwitchMargin; + } + + return EstablishedEpisodeSwitchMargin; } private static bool EpisodeStillValid(EpisodePlan episode, NarrativeThread thread, float now) diff --git a/IdleSpectator/Story/CaptionComposer.cs b/IdleSpectator/Story/CaptionComposer.cs index 9e0dfe4..fa3796a 100644 --- a/IdleSpectator/Story/CaptionComposer.cs +++ b/IdleSpectator/Story/CaptionComposer.cs @@ -327,6 +327,9 @@ public static class CaptionComposer } string tipMood = TipMood(beatLine, tip); + // A return to an MC may show a biography reminder only when there is no authored + // orange beat. Reentry must never let an unrelated legacy fact hitch onto a live event. + bool biographyReentry = isReentry && string.IsNullOrEmpty(beatLine); // Walk strongest → weaker so a soft tip can skip a kill/role hitch and still // show an ongoing war/plot/rival stake when one exists. LifeSagaFact stake = PickHitchStake( @@ -335,7 +338,7 @@ public static class CaptionComposer beatLine, tipMood, identityLine ?? "", - isReentry); + biographyReentry); if (stake == null) { return ""; @@ -351,13 +354,13 @@ public static class CaptionComposer string beatLine, string tipMood, string identityLine, - bool isReentry) + bool biographyReentry) { LifeSagaFact best = LifeSagaMemory.StrongestUnresolved(focusId); if (best != null && !BeatCoversStake(beatLine, best) && !ShouldOmitStakeHitch(best, tipMood, identityLine) - && StakeBelongsToBeat(best, tip, tipMood, isReentry)) + && StakeBelongsToBeat(best, tip, tipMood, biographyReentry)) { return best; } @@ -374,7 +377,7 @@ public static class CaptionComposer if (BeatCoversStake(beatLine, f) || ShouldOmitStakeHitch(f, tipMood, identityLine) - || !StakeBelongsToBeat(f, tip, tipMood, isReentry)) + || !StakeBelongsToBeat(f, tip, tipMood, biographyReentry)) { continue; } @@ -401,14 +404,14 @@ public static class CaptionComposer LifeSagaFact stake, InterestCandidate tip, string tipMood, - bool isReentry) + bool biographyReentry) { if (stake == null) { return false; } - if (isReentry) + if (biographyReentry) { return true; } @@ -629,6 +632,11 @@ public static class CaptionComposer Kind = LifeSagaFactKind.RivalEarned, OtherId = 44 }; + var kill = new LifeSagaFact + { + Kind = LifeSagaFactKind.Kill, + OtherId = 55 + }; var texture = new InterestCandidate { HasNarrativeFunction = true, @@ -651,23 +659,32 @@ public static class CaptionComposer NarrativeFunction = NarrativeFunction.Escalation, Completion = InterestCompletionKind.CombatActive }; + var outbreak = new InterestCandidate + { + HasNarrativeFunction = true, + NarrativeFunction = NarrativeFunction.WorldContext, + Completion = InterestCompletionKind.StatusOutbreak + }; bool textureRejectsResume = !StakeBelongsToBeat(founding, texture, "", false); bool childMatchesChild = StakeBelongsToBeat(child, childBeat, "love", false); bool childRejectsBond = !StakeBelongsToBeat(bond, childBeat, "love", false); bool combatMatchesRival = StakeBelongsToBeat(rival, combat, "combat", false); bool reentryRecalls = StakeBelongsToBeat(founding, texture, "", true); + bool outbreakRejectsKill = !StakeBelongsToBeat(kill, outbreak, "", false); bool restRejectsResume = !StakeBelongsToBeat(founding, texture, "rest", false); bool ok = textureRejectsResume && childMatchesChild && childRejectsBond && combatMatchesRival && reentryRecalls + && outbreakRejectsKill && restRejectsResume; detail = "texture=" + textureRejectsResume + " child=" + childMatchesChild + "/" + childRejectsBond + " combat=" + combatMatchesRival + " reentry=" + reentryRecalls + + " outbreak=" + outbreakRejectsKill + " rest=" + restRejectsResume + " pass=" + ok; return ok; diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index fdacfda..68c63c5 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -150,15 +150,15 @@ public sealed class UnitDossier { if (!string.IsNullOrEmpty(HarnessJobLabelOverride)) { - // Same discovery path as live jobs (resource-role families, Title Case id). - return ActivityAssetCatalog.JobDisplayLabel(HarnessJobLabelOverride.Trim()); + // Same discovery path as live jobs, including identity-only assignment collapse. + return ActivityAssetCatalog.IdentityJobDisplayLabel(HarnessJobLabelOverride.Trim()); } try { if (actor?.citizen_job != null) { - string fromAsset = ActivityAssetCatalog.JobDisplayLabel(actor.citizen_job); + string fromAsset = ActivityAssetCatalog.IdentityJobDisplayLabel(actor.citizen_job); if (!string.IsNullOrEmpty(fromAsset)) { return fromAsset; @@ -171,7 +171,18 @@ public sealed class UnitDossier } string jobId = ActivityInterestTable.SafeJobId(actor); - return ActivityAssetCatalog.JobDisplayLabel(jobId); + return ActivityAssetCatalog.IdentityJobDisplayLabel(jobId); + } + + public static bool HarnessProbeIdentityJobPresentation(out string detail) + { + string job = ActivityAssetCatalog.IdentityJobDisplayLabel("miner_deposit"); + string tag = BuildIdentityTag("human", job); + bool ok = job == "Miner" + && tag == "Human/Miner" + && tag.IndexOf("Deposit", StringComparison.OrdinalIgnoreCase) < 0; + detail = "job='" + job + "' tag='" + tag + "' pass=" + ok; + return ok; } /// diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 864fa5c..5bbe842 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -1262,7 +1262,8 @@ public static class WatchCaption return spine; } - if (ReasonLeadsWithToken(reason, kind)) + if (ReasonLeadsWithToken(reason, kind) + || ReasonSemanticallyMatchesStoryKind(reason, kind)) { return ""; } @@ -1276,6 +1277,52 @@ public static class WatchCaption return spine; } + private static bool ReasonSemanticallyMatchesStoryKind(string reason, string kind) + { + string text = (reason ?? "").Trim().ToLowerInvariant(); + string story = (kind ?? "").Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(story)) + { + return false; + } + + switch (story) + { + case "love": + return text.IndexOf("love", StringComparison.Ordinal) >= 0 + || text.IndexOf("smitten", StringComparison.Ordinal) >= 0 + || text.IndexOf("mates with", StringComparison.Ordinal) >= 0 + || text.IndexOf("intimacy", StringComparison.Ordinal) >= 0; + case "grief": + return text.IndexOf("mourn", StringComparison.Ordinal) >= 0 + || text.IndexOf("despair", StringComparison.Ordinal) >= 0 + || text.IndexOf("grief", StringComparison.Ordinal) >= 0; + case "duel": + case "skirmish": + case "battle": + case "mass": + return ReasonLeadsWithCombatScale(reason) + || text.IndexOf(" is fighting", StringComparison.Ordinal) >= 0; + default: + return false; + } + } + + public static bool HarnessProbeStorySpineRelevance(out string detail) + { + string love = FilterRedundantStorySpine("Love · Climax", "Falls in love with Omyahel"); + string duel = FilterRedundantStorySpine("Duel · Climax", "Joon is fighting"); + string pregnancy = FilterRedundantStorySpine("Love · Climax", "Becomes pregnant"); + string aftermath = FilterRedundantStorySpine("Love · Aftermath", "Finds love with Omyahel"); + bool ok = string.IsNullOrEmpty(love) + && string.IsNullOrEmpty(duel) + && pregnancy == "Love · Climax" + && aftermath == "Love · Aftermath"; + detail = "love='" + love + "' duel='" + duel + "' pregnancy='" + pregnancy + + "' aftermath='" + aftermath + "' pass=" + ok; + return ok; + } + private static bool IsCombatSpineKind(string kind) { return string.Equals(kind, "Duel", StringComparison.OrdinalIgnoreCase)