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
This commit is contained in:
DazedAnon 2026-07-23 15:32:39 -05:00
parent fcf2445cd0
commit af17d9acd1
12 changed files with 354 additions and 19 deletions

View file

@ -177,6 +177,32 @@ public static class ActivityAssetCatalog
return FormatCitizenJobIdLabel(asset.id);
}
/// <summary>
/// Stable identity role for the dossier nametag. Simulation variants such as
/// <c>miner_deposit</c> describe the current assignment, not a distinct profession.
/// Activity/task prose may still use the full job label.
/// </summary>
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);
/// <summary>
/// Title Case + discovered resource-role reorder for a citizen job id.
/// Public for harness audits over the full live library.

View file

@ -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;
}

View file

@ -296,6 +296,24 @@ namespace IdleSpectator;
return proposed;
}
/// <summary>
/// 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.
/// </summary>
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;
}

View file

@ -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;
}
/// <summary>
/// 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

View file

@ -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"),

View file

@ -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);

View file

@ -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<InterestLeadKind> RecentMix = new List<InterestLeadKind>(32);
private static readonly List<string> RecentArcs = new List<string>(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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Score to subtract when this candidate matches the last shown variety arc.
/// After one duel show, the next duel pays <c>repeatArcPenaltyPer</c>; 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;
}
/// <summary>
/// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class.
/// </summary>

View file

@ -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);

View file

@ -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<NarrativeThread> OpenThreads = new List<NarrativeThread>(32);
private static readonly List<InterestCandidate> Pending = new List<InterestCandidate>(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)

View file

@ -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;

View file

@ -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;
}
/// <summary>

View file

@ -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)