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)