feat(spectator): focus camera on a five-character story ensemble

- Prioritize MCs, related cast, meaningful disasters, and exceptional events
- Show MC connections in the dossier when following related characters
- Promote rare creatures and recurring antagonists through bounded scoring
- Reduce roster churn and repetitive love/combat coverage
- Add performance telemetry, soak auditing, documentation, and harness coverage
This commit is contained in:
DazedAnon 2026-07-24 12:54:06 -05:00
parent 4becf1c5dc
commit 188d091166
16 changed files with 2107 additions and 59 deletions

View file

@ -3856,6 +3856,36 @@ public static class AgentHarness
break;
}
case "saga_consider_admit_focus":
{
Actor actor = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long id = EventFeedUtil.SafeId(actor);
bool ok = id != 0 && LifeSagaRoster.ConsiderAdmit(id, heat: 0f);
if (ok) _cmdOk++; else _cmdFail++;
Emit(
cmd,
ok,
detail: $"consider id={id} asset={actor?.asset?.id} roster={LifeSagaRoster.Count} on={LifeSagaRoster.IsMc(id)}");
break;
}
case "saga_consider_antagonist_partner":
{
Actor partner = _happinessPartner;
long id = EventFeedUtil.SafeId(partner);
bool eligible = LifeSagaRoster.TryGetRecurringAntagonist(
partner,
out long anchor,
out LifeSagaFact evidence);
bool ok = eligible && LifeSagaRoster.ConsiderAdmit(id, heat: 0f);
if (ok) _cmdOk++; else _cmdFail++;
Emit(
cmd,
ok,
detail: $"antagonist={id} eligible={eligible} anchor={anchor} evidence={evidence?.Note} on={LifeSagaRoster.IsMc(id)}");
break;
}
case "saga_note_encounters":
{
Actor a = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
@ -4452,6 +4482,38 @@ public static class AgentHarness
break;
}
case "saga_force_admit_six":
{
int admitted = 0;
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{
if (actor == null || !actor.isAlive())
{
continue;
}
if (LifeSagaRoster.HarnessForceAdmit(actor))
{
admitted++;
}
if (admitted >= 6)
{
break;
}
}
bool ok = admitted >= 6 && LifeSagaRoster.Count == LifeSagaRoster.Cap;
if (ok) _cmdOk++; else _cmdFail++;
Emit(
cmd,
ok,
detail: "admitted=" + admitted
+ " roster=" + LifeSagaRoster.Count
+ " cap=" + LifeSagaRoster.Cap);
break;
}
case "hitch_probe":
{
_preserveSagaAfterBatch = true;
@ -6735,10 +6797,18 @@ public static class AgentHarness
return;
}
Actor follow = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
string requestedAsset =
string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset;
Actor focused = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
Actor follow = focused != null
&& focused.isAlive()
&& (requestedAsset == null
|| (focused.asset != null && focused.asset.id == requestedAsset))
? focused
: ResolveUnit(requestedAsset);
if (follow == null)
{
follow = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
follow = focused;
}
if (follow == null)
@ -10026,6 +10096,18 @@ public static class AgentHarness
detail = $"rival focus={SafeName(focus)} have={have} want={want} note={rival?.Note}";
break;
}
case "saga_antagonist_partner":
{
Actor partner = _happinessPartner;
bool want = ParseBool(cmd.value, defaultValue: true);
bool have = LifeSagaRoster.TryGetRecurringAntagonist(
partner,
out long anchor,
out LifeSagaFact evidence);
pass = have == want;
detail = $"antagonist partner={SafeName(partner)} have={have} want={want} anchor={anchor} evidence={evidence?.Note}";
break;
}
case "saga_crossover_focus":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
@ -10309,6 +10391,35 @@ public static class AgentHarness
detail = $"context='{ctx}' pass={pass}";
break;
}
case "caption_connection_present":
{
string connection = WatchCaption.LastConnectionLine ?? "";
pass = !string.IsNullOrEmpty(connection);
detail = "connection='" + connection + "' pass=" + pass;
break;
}
case "caption_connection_anchor":
{
string connection = WatchCaption.LastConnectionLine ?? "";
CameraEligibilityModel eligibility =
CameraEligibility.Classify(InterestDirector.CurrentCandidate);
pass = !string.IsNullOrEmpty(connection)
&& eligibility.AnchorMcId != 0
&& connection == (eligibility.ConnectionLine ?? "");
detail = "connection='" + connection
+ "' anchor=" + eligibility.AnchorMcId
+ " kind=" + eligibility.ConnectionKind
+ " expected='" + eligibility.ConnectionLine
+ "' pass=" + pass;
break;
}
case "camera_exceptional_policy":
{
pass = CameraEligibility.HarnessProbeExceptionalPolicy(out detail);
break;
}
case "caption_context_omits":
{
// Soft tip hitch: Context must not show banned biography fragments.
@ -10855,6 +10966,52 @@ public static class AgentHarness
$"admission={slot?.AdmissionReason.ToString() ?? "missing"} id={slot?.UnitId ?? 0}";
break;
}
case "saga_focus_admission":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
LifeSagaSlot slot = LifeSagaRoster.Get(EventFeedUtil.SafeId(focus));
string any = (cmd.value ?? "").Trim();
string have = slot?.AdmissionReason.ToString() ?? "missing";
pass = slot != null;
if (pass && !string.IsNullOrEmpty(any))
{
pass = false;
string[] parts = any.Split('|');
for (int i = 0; i < parts.Length; i++)
{
if (string.Equals(
have,
(parts[i] ?? "").Trim(),
StringComparison.OrdinalIgnoreCase))
{
pass = true;
break;
}
}
}
detail = $"focus={SafeName(focus)} admission={have} pop={slot?.SpeciesPopulation ?? 0} rare={slot?.IsRareCreature ?? false} special={slot?.IsSpecialCreature ?? false}";
break;
}
case "saga_focus_admission_eligible":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
bool want = ParseBool(cmd.value, defaultValue: true);
bool have = LifeSagaRoster.HarnessIsAdmissionCandidate(focus);
pass = have == want;
detail = $"focus={SafeName(focus)} asset={focus?.asset?.id} eligible={have} want={want}";
break;
}
case "stability_budgets":
{
pass = InterestVariety.HarnessProbeStabilityBudgets(out detail);
break;
}
case "saga_replacement_evidence":
{
pass = LifeSagaRoster.HarnessProbeReplacementEvidence(out detail);
break;
}
case "saga_role_tip":
{
string have = SampleRailTip();
@ -14491,7 +14648,7 @@ public static class AgentHarness
var board = new List<LifeSagaSlot>(LifeSagaRoster.Cap);
LifeSagaRoster.CopySlots(board);
var names = new StringBuilder(160);
var names = new StringBuilder(320);
for (int i = 0; i < board.Count; i++)
{
LifeSagaSlot s = board[i];
@ -14516,7 +14673,24 @@ public static class AgentHarness
n += "^";
}
names.Append(n);
names.Append(n)
.Append("{reason=")
.Append(s.AdmissionReason)
.Append(" pop=")
.Append(s.SpeciesPopulation)
.Append(" rare=")
.Append(s.IsRareCreature)
.Append(" special=")
.Append(s.IsSpecialCreature)
.Append(" antagonist=")
.Append(s.IsRecurringAntagonist)
.Append(" anchor=")
.Append(s.AntagonistAnchorMcId)
.Append(" evidence=")
.Append(s.AntagonistEvidence)
.Append(" score=")
.Append(LifeSagaRoster.EffectiveInterest(s, now).ToString("0.##"))
.Append('}');
}
string tipLabel = tip?.Label ?? CameraDirector.LastWatchLabel ?? "";
@ -14526,7 +14700,8 @@ public static class AgentHarness
+ $"softRank={softRank} touchedMcs={touchedMcs} "
+ $"rival={rival} crossoverFacts={crossovers} facts={facts} lives={LifeSagaMemory.LifeCount} "
+ $"roster={LifeSagaRoster.Count} tip={tipLabel} "
+ $"score={InterestDirector.CurrentScoreLabel} names=[{names}]";
+ $"score={InterestDirector.CurrentScoreLabel} names=[{names}] "
+ $"lastAdmission=[{LifeSagaRoster.LastAdmissionDiagnostic}]";
_cmdOk++;
Emit(cmd, ok: true, detail: detail);

View file

@ -215,6 +215,10 @@ internal static class HarnessScenarios
return SagaCameraPrefersMc();
case "saga_roster_diversity":
return SagaRosterDiversity();
case "saga_roster_cap_five":
return SagaRosterCapFive();
case "saga_connection_dossier":
return SagaConnectionDossier();
case "saga_camera_prefers_mc":
return SagaCameraPrefersMc();
case "saga_replace_on_death":
@ -1456,6 +1460,9 @@ internal static class HarnessScenarios
Step("cf8", "pick_unit", asset: "human"),
Step("cf9", "focus", asset: "human"),
Step("cf10", "interest_end_session"),
// Main-camera policy requires combat to touch an MC or exact MC relation.
Step("cf10a", "interest_saga_clear"),
Step("cf10b", "saga_force_admit_focus"),
// Combat scene: human fighting wolf. Clear follow → related (wolf) must own tip+focus.
// expect suffix no_attack: ownership assert must not race a fast-timing kill mid-wait.
@ -1513,6 +1520,8 @@ internal static class HarnessScenarios
Step("cf39b", "spawn", asset: "human", count: 1),
Step("cf39c", "spawn", asset: "wolf", count: 1),
Step("cf39d", "pick_unit", asset: "human"),
Step("cf39e", "focus", asset: "human"),
Step("cf39f", "saga_force_admit_focus"),
Step("cf40", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_ensemble"),
Step("cf41", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
Step("cf42", "combat_ensemble_escalate", value: "6"),
@ -1537,6 +1546,8 @@ internal static class HarnessScenarios
Step("cf48b", "spawn", asset: "evil_mage", count: 1),
Step("cf48c", "spawn", asset: "human", count: 1),
Step("cf48d", "pick_unit", asset: "evil_mage"),
Step("cf48d2", "focus", asset: "evil_mage"),
Step("cf48d3", "saga_consider_admit_focus"),
Step("cf48e", "interest_combat_session", asset: "evil_mage", value: "human", expect: "cf_mage"),
// Soak shape: 1 spectacle vs a massed civ side (not a tiny 1v8 skirmish).
Step("cf48f", "combat_ensemble_escalate", value: "24"),
@ -1565,6 +1576,8 @@ internal static class HarnessScenarios
Step("cf50b", "spawn", asset: "human", count: 1),
Step("cf50c", "spawn", asset: "wolf", count: 1),
Step("cf50d", "pick_unit", asset: "human"),
Step("cf50e", "focus", asset: "human"),
Step("cf50f", "saga_force_admit_focus"),
Step("cf51", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_natural"),
Step("cf51b", "combat_isolate_pair", value: "20"),
Step("cf52", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
@ -3303,6 +3316,9 @@ internal static class HarnessScenarios
Step("ncc3", "focus", asset: "human"),
Step("ncc4", "narrative_camera_cutover_probe"),
Step("ncc5", "assert", expect: "no_bad"),
Step("ncc6", "assert", expect: "camera_exceptional_policy"),
Step("ncc7", "assert", expect: "stability_budgets"),
Step("ncc8", "assert", expect: "saga_replacement_evidence"),
Step("ncc99", "snapshot")
};
}
@ -3531,6 +3547,49 @@ internal static class HarnessScenarios
};
}
private static List<HarnessCommand> SagaConnectionDossier()
{
return new List<HarnessCommand>
{
Step("scd0", "dismiss_windows"),
Step("scd1", "wait_world"),
Step("scd2", "set_setting", expect: "enabled", value: "true"),
Step("scd3", "fast_timing", value: "true"),
Step("scd4", "spawn", asset: "human", count: 2),
Step("scd5", "spectator", value: "off"),
Step("scd6", "spectator", value: "on"),
Step("scd7", "pick_unit", asset: "human"),
Step("scd8", "focus", asset: "human"),
Step("scd9", "interest_saga_clear"),
Step("scd10", "saga_force_admit_focus"),
Step("scd11", "happiness_remember_partner"),
Step("scd12", "interest_expire_pending", value: ""),
Step("scd13", "interest_inject", asset: "human", label: "{a} faces a turning point",
tier: "Epic", expect: "scd_related",
value: "lead=event;evt=90;char=20;force=false;ttl=60;pick=other;related=partner"),
Step("scd14", "director_run", wait: 1.2f),
Step("scd15", "assert", expect: "caption_connection_present"),
Step("scd16", "assert", expect: "caption_connection_anchor", value: "partner"),
Step("scd17", "screenshot", value: "saga-connection-dossier.png"),
Step("scd90", "fast_timing", value: "false"),
Step("scd99", "snapshot")
};
}
private static List<HarnessCommand> SagaRosterCapFive()
{
return new List<HarnessCommand>
{
Step("src0", "dismiss_windows"),
Step("src1", "wait_world"),
Step("src2", "interest_saga_clear"),
Step("src3", "spawn", asset: "human", count: 6),
Step("src4", "saga_force_admit_six"),
Step("src5", "assert", expect: "saga_roster_count", value: "5"),
Step("src99", "snapshot")
};
}
/// <summary>Prefer toggle soft-biases a near-tie toward the Prefer'd MC.</summary>
private static List<HarnessCommand> SagaPreferSoftBias()
{
@ -3767,6 +3826,7 @@ internal static class HarnessScenarios
Step("srk11", "saga_force_admit_focus"),
Step("srk12", "saga_record_kill_partner"),
Step("srk13", "assert", expect: "saga_rival_focus", value: "false"),
Step("srk14", "assert", expect: "saga_antagonist_partner", value: "false"),
Step("srk99", "snapshot"),
};
}
@ -3804,6 +3864,9 @@ internal static class HarnessScenarios
Step("srr11", "saga_force_admit_focus"),
Step("srr12", "saga_note_encounters", value: "3"),
Step("srr13", "assert", expect: "saga_rival_focus"),
Step("srr14", "assert", expect: "saga_antagonist_partner"),
Step("srr15", "saga_consider_antagonist_partner"),
Step("srr16", "assert", expect: "saga_roster_count", value: "2", label: "min"),
Step("srr99", "snapshot"),
};
}
@ -4464,14 +4527,15 @@ internal static class HarnessScenarios
Step("sar8", "interest_saga_clear"),
Step("sar9", "pick_unit", asset: "dragon"),
Step("sar10", "focus", asset: "dragon"),
// Force admit so role flags refresh from live singleton/pop helpers.
Step("sar11", "saga_force_admit_focus"),
// Real admission path: special/rare identity opens the challenger gate.
Step("sar11", "saga_consider_admit_focus"),
Step("sar12", "assert", expect: "saga_has_focus"),
Step("sar13", "saga_rail_refresh"),
Step("sar14", "assert", expect: "saga_role_tip", value: "Lone|Last of their kind|Dragon"),
Step("sar14", "assert", expect: "saga_focus_admission", value: "Singleton|SpecialCreature|RareCreature"),
// Harness admission proves an ordinary human can occupy the ensemble.
Step("sar20", "pick_unit", asset: "human"),
Step("sar21", "focus", asset: "human"),
Step("sar21b", "assert", expect: "saga_focus_admission_eligible", value: "false"),
Step("sar22", "saga_force_admit_focus"),
Step("sar23", "assert", expect: "saga_has_focus"),
Step("sar24", "assert", expect: "saga_roster_count", value: "2", label: "min"),
@ -4526,14 +4590,14 @@ internal static class HarnessScenarios
Step("srw44", "pick_unit", asset: "human", value: "other"),
Step("srw45", "focus", asset: "human"),
Step("srw46", "saga_force_admit_focus"),
Step("srw50", "assert", expect: "saga_roster_count", value: "10"),
Step("srw50", "assert", expect: "saga_roster_count", value: "5"),
Step("srw51", "saga_weaken_nonprefer"),
// 11th human challenges the full ensemble.
// Another human challenges the full five-character ensemble.
Step("srw60", "pick_unit", asset: "human", value: "other"),
Step("srw61", "focus", asset: "human"),
Step("srw62", "saga_force_admit_focus"),
Step("srw63", "assert", expect: "saga_has_focus"),
Step("srw64", "assert", expect: "saga_roster_count", value: "10"),
Step("srw64", "assert", expect: "saga_roster_count", value: "5"),
// Prefer pin still present; challenger focus itself is not Prefer.
Step("srw70", "assert", expect: "saga_prefer_focus_on", value: "false"),
Step("srw71", "assert", expect: "saga_prefer_count", value: "1", label: "min"),

View file

@ -33,6 +33,12 @@ public static partial class InterestDirector
return false;
}
if (candidate != _current && InterestVariety.IsCombatRunBlocked(candidate))
{
InterestDropLog.Record("combat_run_budget", candidate.Label ?? candidate.Key);
return false;
}
if (candidate != _current
&& !StoryScheduler.MayUseCombatShot(candidate))
{

View file

@ -2056,9 +2056,20 @@ public static partial class InterestDirector
continue;
}
bool harness = string.Equals(c.Source, "harness", StringComparison.OrdinalIgnoreCase);
// Background lives continue to feed narrative admission off-camera. Once an MC
// ensemble exists, routine candidates outside that ensemble/orbit do not enter the
// expensive presentability and camera-selection path.
if (!harness && !CameraEligibility.MayUseCamera(c))
{
InterestDropLog.Record("discovery_only", c.AssetId ?? c.Key);
PendingScratch.RemoveAt(i);
continue;
}
// Drop stale / not-worth-watching / unpresentable shots from the pending set.
// Harness synthetic labels may be unpresentable (dossier blanks); keep them for tip tests.
bool harness = string.Equals(c.Source, "harness", StringComparison.OrdinalIgnoreCase);
bool presentable = harness || EventPresentability.WouldShow(c);
if (!IsWorthWatchingNow(c, now, selectingDuringGrace: InQuietGrace)
|| IsStaleFreshLifeMoment(c, now)
@ -2839,6 +2850,13 @@ public static partial class InterestDirector
InterestRegistry.MarkSelected(next.Key);
InterestVariety.NoteSelection(next);
StoryPlanner.OnSwitchTo(next, now);
CameraEligibilityModel eligibility = CameraEligibility.Classify(next);
LogService.LogInfo(
"[IdleSpectator][CAMERA_ELIGIBILITY] kind=" + eligibility.Kind
+ " subject=" + next.SubjectId
+ " anchor=" + eligibility.AnchorMcId
+ " evidence=" + eligibility.Evidence
+ " connection=" + eligibility.ConnectionLine);
CameraDirector.Watch(next.ToInterestEvent());
}
@ -2870,12 +2888,27 @@ public static partial class InterestDirector
return;
}
InterestEvent ambient = WorldActivityScanner.FindBestLiveTarget();
if (ambient == null)
Actor grounding = LifeSagaRoster.BestLivingMc();
if (grounding == null)
{
return;
}
// Quiet grounding belongs to the current ensemble. Background lives are still scanned
// and scored for admission, but routine activity is not a reason to give them camera time.
var ambient = new InterestEvent
{
Score = InterestScoringConfig.W.fillScoreMax > 0f
? InterestScoringConfig.W.fillScoreMax * 0.5f
: 20f,
Position = grounding.current_position,
FollowUnit = grounding,
Label = "",
CreatedAt = now,
AssetId = grounding.asset != null ? grounding.asset.id : "",
CharacterSignificance = 1f
};
// Character fill never owns an orange reason - empty Label + CharacterLed.
// Force fill score so RegisterDirect does not publish as EventLed.
ambient.Label = "";

View file

@ -45,6 +45,89 @@ public static class InterestVariety
/// <summary>Harness/debug: tips recorded in the rarity window.</summary>
public static int ArcWindowCount => RecentArcs.Count;
public static bool IsCombatRunBlocked(InterestCandidate candidate, int maxRun = 4)
{
if (candidate == null
|| candidate.Completion != InterestCompletionKind.CombatActive
|| RecentArcs.Count < maxRun)
{
return false;
}
NarrativeFunction function = NarrativeFunctionPolicy.Classify(candidate);
string text = ((candidate.AssetId ?? "") + " "
+ (candidate.Verb ?? "") + " "
+ (candidate.Label ?? "")).ToLowerInvariant();
bool decisive = function == NarrativeFunction.TurningPoint
|| function == NarrativeFunction.Resolution
|| function == NarrativeFunction.Consequence
|| candidate.EventStrength >= 95f
|| text.IndexOf("kill", StringComparison.Ordinal) >= 0
|| text.IndexOf("slain", StringComparison.Ordinal) >= 0
|| text.IndexOf("victory", StringComparison.Ordinal) >= 0;
if (decisive)
{
return false;
}
int start = RecentArcs.Count - maxRun;
for (int i = start; i < RecentArcs.Count; i++)
{
if (!IsCombatArc(RecentArcs[i]))
{
return false;
}
}
return true;
}
public static bool HarnessProbeStabilityBudgets(out string detail)
{
Clear();
var texture = new InterestCandidate
{
SubjectId = 991101,
AssetId = "find_lover",
Verb = "decides to find a lover",
Label = "Probe decides to find a lover",
Completion = InterestCompletionKind.ActivityActive
};
NoteSelection(texture, updateMix: false);
bool textureBlocked = IsBeatCooled(texture);
bool loverStillBlockedAtTenMinutes =
IsBeatCooled(texture, Time.unscaledTime + 600f);
bool loverEventuallyReopens =
!IsBeatCooled(texture, Time.unscaledTime + 901f);
Clear();
HarnessPushArc("arc:combat:duel", 4);
var combat = new InterestCandidate
{
SubjectId = 991102,
AssetId = "live_combat",
Label = "Duel - Probe A vs Probe B",
Completion = InterestCompletionKind.CombatActive,
EventStrength = 80f
};
bool combatBlocked = IsCombatRunBlocked(combat);
combat.Label = "Probe A kills Probe B";
combat.Verb = "kills";
bool decisiveAllowed = !IsCombatRunBlocked(combat);
Clear();
detail = "textureBlocked=" + textureBlocked
+ " lover10m=" + loverStillBlockedAtTenMinutes
+ " loverReopens=" + loverEventuallyReopens
+ " combatBlocked=" + combatBlocked
+ " decisiveAllowed=" + decisiveAllowed;
return textureBlocked
&& loverStillBlockedAtTenMinutes
&& loverEventuallyReopens
&& combatBlocked
&& decisiveAllowed;
}
public static void Clear()
{
RecentMix.Clear();
@ -148,6 +231,20 @@ public static class InterestVariety
StampCooldown("verb:" + chosen.Verb, until * 0.4f);
}
string textureFamily = LowInformationTextureFamily(chosen);
if (!string.IsNullOrEmpty(textureFamily) && chosen.SubjectId != 0)
{
float textureCooldown = string.Equals(
textureFamily,
"seeking_love",
StringComparison.Ordinal)
? 900f
: RoutineReplayCooldownSeconds;
StampCooldown(
"texture:" + chosen.SubjectId + ":" + textureFamily,
Time.unscaledTime + textureCooldown);
}
// Moment beats: hard suppress the same story on the same subject.
if (IsMomentBeat(chosen))
{
@ -166,6 +263,7 @@ public static class InterestVariety
{
StampCooldown(beatKey, beatUntil);
}
}
// Sticky scenes are allowed to remain on screen while live, but once the editor
@ -815,7 +913,28 @@ public static class InterestVariety
/// </summary>
public static bool IsBeatCooled(InterestCandidate c, float now = -1f)
{
if (c == null || !IsMomentBeat(c))
if (c == null)
{
return false;
}
if (now < 0f)
{
now = Time.unscaledTime;
}
string textureFamily = LowInformationTextureFamily(c);
if (!string.IsNullOrEmpty(textureFamily)
&& c.SubjectId != 0
&& CooldownUntil.TryGetValue(
"texture:" + c.SubjectId + ":" + textureFamily,
out float textureUntil)
&& now < textureUntil)
{
return true;
}
if (!IsMomentBeat(c))
{
return false;
}
@ -828,11 +947,6 @@ public static class InterestVariety
return false;
}
if (now < 0f)
{
now = Time.unscaledTime;
}
foreach (string beatKey in EnumerateBeatKeys(c))
{
if (CooldownUntil.TryGetValue(beatKey, out float until) && now < until)
@ -844,6 +958,35 @@ public static class InterestVariety
return false;
}
private static string LowInformationTextureFamily(InterestCandidate c)
{
if (c == null)
{
return "";
}
string text = ((c.AssetId ?? "") + " "
+ (c.HappinessEffectId ?? "") + " "
+ (c.StatusId ?? "") + " "
+ (c.Verb ?? "") + " "
+ (c.Label ?? "")).ToLowerInvariant();
if (text.IndexOf("sleep", StringComparison.Ordinal) >= 0
|| text.IndexOf("nap", StringComparison.Ordinal) >= 0)
return "rest";
if (text.IndexOf("laugh", StringComparison.Ordinal) >= 0
|| text.IndexOf("play", StringComparison.Ordinal) >= 0)
return "play";
if (text.IndexOf("caffeine", StringComparison.Ordinal) >= 0
|| text.IndexOf("coffee", StringComparison.Ordinal) >= 0)
return "caffeine";
if (text.IndexOf("find_lover", StringComparison.Ordinal) >= 0
|| text.IndexOf("find a lover", StringComparison.Ordinal) >= 0)
return "seeking_love";
if (text.IndexOf("wander", StringComparison.Ordinal) >= 0)
return "wandering";
return "";
}
/// <summary>
/// Pick next candidate. Soft 70/30: prefer event-led when below target and pool non-empty.
/// Empty event pool → character-led without inventing events (mix meter not updated for empty-side windows).

View file

@ -0,0 +1,483 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
public enum CameraEligibilityKind
{
DiscoveryOnly = 0,
GroundingFill = 1,
ExceptionalBackground = 2,
DisasterPersonalImpact = 3,
McThreadContext = 4,
McCast = 5,
McDirect = 6,
WorldCritical = 7
}
/// <summary>
/// Typed explanation for why a candidate may own the main camera. This is evaluated from
/// structured ids/evidence before selection; presentation consumes the same result.
/// </summary>
public sealed class CameraEligibilityModel
{
public CameraEligibilityKind Kind;
public long AnchorMcId;
public string AnchorMcName = "";
public string ConnectionKind = "";
public string ConnectionLine = "";
public string Evidence = "";
public bool IsMcOrbit =>
Kind == CameraEligibilityKind.McDirect
|| Kind == CameraEligibilityKind.McCast
|| Kind == CameraEligibilityKind.McThreadContext;
public bool MayUseCamera => Kind != CameraEligibilityKind.DiscoveryOnly;
}
public static class CameraEligibility
{
private static readonly List<LifeSagaSlot> Slots = new List<LifeSagaSlot>(LifeSagaRoster.Cap);
public static CameraEligibilityModel Classify(InterestCandidate candidate)
{
var model = new CameraEligibilityModel();
if (candidate == null)
{
return model;
}
long subjectId = candidate.SubjectId != 0
? candidate.SubjectId
: EventFeedUtil.SafeId(candidate.FollowUnit);
if (IsWorldCritical(candidate))
{
model.Kind = CameraEligibilityKind.WorldCritical;
model.Evidence = "world_critical";
if (subjectId != 0 && !LifeSagaRoster.IsMc(subjectId))
{
long criticalAnchor = FirstTouchedMc(candidate);
if (criticalAnchor != 0)
{
StampAnchor(model, criticalAnchor, "event", "world_critical_mc_context");
model.ConnectionLine = EventConnection(candidate, model.AnchorMcName);
}
else if (LifeSagaRoster.IsMcCast(subjectId)
&& TryResolveCastConnection(
subjectId,
out long castAnchor,
out string castKind,
out string castLine))
{
StampAnchor(model, castAnchor, castKind, "world_critical_cast_relation");
model.ConnectionLine = castLine;
}
}
return model;
}
if (LifeSagaRoster.IsMc(subjectId))
{
model.Kind = CameraEligibilityKind.McDirect;
StampAnchor(model, subjectId, "self", "mc_subject");
return model;
}
long touchedMc = FirstTouchedMc(candidate);
if (touchedMc != 0)
{
model.Kind = CameraEligibilityKind.McThreadContext;
StampAnchor(model, touchedMc, "event", "exact_event_participant");
model.ConnectionLine = EventConnection(candidate, model.AnchorMcName);
return model;
}
if (subjectId != 0 && LifeSagaRoster.IsMcCast(subjectId)
&& TryResolveCastConnection(subjectId, out long anchorId, out string kind, out string line))
{
model.Kind = CameraEligibilityKind.McCast;
StampAnchor(model, anchorId, kind, "live_cast_relation");
model.ConnectionLine = line;
return model;
}
EpisodePlan episode = StoryScheduler.ActiveEpisode;
if (episode != null && LifeSagaRoster.IsMc(episode.ProtagonistId))
{
if (TouchesExactEpisodeParticipant(candidate, episode))
{
model.Kind = CameraEligibilityKind.McThreadContext;
StampAnchor(model, episode.ProtagonistId, "story", "active_thread");
model.ConnectionLine = "In " + model.AnchorMcName + "'s story";
return model;
}
}
NarrativeFunction function = NarrativeFunctionPolicy.Classify(candidate);
if (StoryReason.IsStoryAsset(candidate.AssetId)
|| (candidate.Source ?? "").IndexOf("story_planner", StringComparison.OrdinalIgnoreCase) >= 0)
{
model.Kind = CameraEligibilityKind.ExceptionalBackground;
model.Evidence = "confirmed_story_payoff";
return model;
}
if (IsConfirmedDisasterImpact(candidate, function))
{
model.Kind = CameraEligibilityKind.DisasterPersonalImpact;
model.Evidence = "confirmed_disaster_impact";
return model;
}
if (IsExceptionalBackground(candidate, function))
{
model.Kind = CameraEligibilityKind.ExceptionalBackground;
model.Evidence = "exceptional_" + function.ToString().ToLowerInvariant();
return model;
}
if (candidate.LeadKind == InterestLeadKind.CharacterLed
&& subjectId != 0
&& (LifeSagaRoster.IsMc(subjectId) || LifeSagaRoster.IsMcCast(subjectId)))
{
model.Kind = CameraEligibilityKind.GroundingFill;
model.Evidence = "mc_orbit_fill";
}
return model;
}
public static bool MayUseCamera(InterestCandidate candidate)
{
// Before an ensemble has emerged, retain normal world coverage so candidates can
// accumulate evidence and the first five can be discovered.
if (LifeSagaRoster.Count == 0)
{
return true;
}
return Classify(candidate).MayUseCamera;
}
public static string ConnectionLine(InterestCandidate candidate, long focusId)
{
if (candidate == null || focusId == 0 || LifeSagaRoster.IsMc(focusId))
{
return "";
}
CameraEligibilityModel model = Classify(candidate);
return model.AnchorMcId != 0 ? model.ConnectionLine ?? "" : "";
}
private static bool IsWorldCritical(InterestCandidate c)
{
if (c.EventStrength >= 95f) return true;
return c.Completion == InterestCompletionKind.EarthquakeActive
|| c.Completion == InterestCompletionKind.StatusOutbreak
|| (c.Category ?? "").IndexOf("Disaster", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool IsConfirmedDisasterImpact(
InterestCandidate c,
NarrativeFunction function)
{
bool disaster = c.Completion == InterestCompletionKind.EarthquakeActive
|| c.Completion == InterestCompletionKind.StatusOutbreak
|| (c.Category ?? "").IndexOf("Disaster", StringComparison.OrdinalIgnoreCase) >= 0
|| (c.Source ?? "").IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0;
if (!disaster || c.SubjectId == 0)
{
return false;
}
// A personal typed change/outcome is required. WorldContext alone is theater coverage.
return NarrativeFunctionPolicy.ChangesStoryState(function)
&& (c.NarrativePresentation != null
|| !string.IsNullOrEmpty(c.NarrativeDevelopmentId));
}
private static bool IsExceptionalBackground(
InterestCandidate c,
NarrativeFunction function)
{
if (!NarrativeFunctionPolicy.ChangesStoryState(function))
{
return false;
}
bool typed = c.NarrativePresentation != null
|| !string.IsNullOrEmpty(c.NarrativeDevelopmentId);
if (!typed || c.EventStrength < 82f || c.Novelty < 0.35f)
{
return false;
}
string text = ((c.AssetId ?? "") + " "
+ (c.Source ?? "") + " "
+ (c.Verb ?? "") + " "
+ (c.NarrativeDevelopmentId ?? "")).ToLowerInvariant();
bool routineLife = ContainsAny(
text,
"just_born",
"born",
"hatch",
"egg",
"became_adult",
"sleep",
"caffeine",
"find_lover",
"reproduction");
if (routineLife)
{
Actor subject = EventFeedUtil.FindAliveById(c.SubjectId);
return subject != null
&& LifeSagaRoster.IsSpecialCreature(subject)
&& LifeSagaRoster.IsSpeciesSingleton(subject);
}
bool decisive = function == NarrativeFunction.TurningPoint
|| function == NarrativeFunction.Resolution
|| function == NarrativeFunction.Consequence;
if (!decisive)
{
return false;
}
// Exceptional background is an explicit outcome lane, not a numeric escape hatch.
return ContainsAny(
text,
"death",
"died",
"killed",
"destroy",
"lost_home",
"become_king",
"new_king",
"founded",
"war_ended",
"plot_ended",
"disaster",
"earthquake",
"meteor",
"tornado",
"volcano");
}
private static bool TouchesExactEpisodeParticipant(InterestCandidate c, EpisodePlan episode)
{
if (c == null || episode == null)
{
return false;
}
long[] ids =
{
c.SubjectId,
c.RelatedId,
c.PairOwnerId,
c.PairPartnerId,
c.TheaterLeadId,
EventFeedUtil.SafeId(c.FollowUnit)
};
for (int i = 0; i < ids.Length; i++)
{
long id = ids[i];
if (id == 0) continue;
if (id == episode.ProtagonistId) return true;
for (int j = 0; j < episode.EligibleCastIds.Count; j++)
{
if (episode.EligibleCastIds[j] == id) return true;
}
}
return false;
}
private static bool ContainsAny(string value, params string[] needles)
{
for (int i = 0; i < needles.Length; i++)
{
if (value.IndexOf(needles[i], StringComparison.OrdinalIgnoreCase) >= 0) return true;
}
return false;
}
private static long FirstTouchedMc(InterestCandidate c)
{
long[] direct =
{
c.RelatedId,
c.PairOwnerId,
c.PairPartnerId,
c.TheaterLeadId,
EventFeedUtil.SafeId(c.RelatedUnit)
};
for (int i = 0; i < direct.Length; i++)
{
if (LifeSagaRoster.IsMc(direct[i])) return direct[i];
}
// ParticipantIds are accepted only when the MC is exact and the event is a semantic
// state change; broad live clusters do not manufacture connections.
if (NarrativeFunctionPolicy.ChangesStoryState(NarrativeFunctionPolicy.Classify(c)))
{
for (int i = 0; i < c.ParticipantIds.Count; i++)
{
if (LifeSagaRoster.IsMc(c.ParticipantIds[i])) return c.ParticipantIds[i];
}
}
return 0;
}
private static void StampAnchor(
CameraEligibilityModel model,
long anchorId,
string connectionKind,
string evidence)
{
model.AnchorMcId = anchorId;
model.ConnectionKind = connectionKind ?? "";
model.Evidence = evidence ?? "";
LifeSagaSlot slot = LifeSagaRoster.Get(anchorId);
model.AnchorMcName = slot?.DisplayName ?? "";
if (string.IsNullOrEmpty(model.AnchorMcName))
{
Actor actor = EventFeedUtil.FindAliveById(anchorId);
model.AnchorMcName = EventFeedUtil.SafeName(actor);
}
if (string.IsNullOrEmpty(model.AnchorMcName)) model.AnchorMcName = "the main character";
}
private static string EventConnection(InterestCandidate c, string mcName)
{
if (c.Completion == InterestCompletionKind.WarFront)
return "Fights in " + mcName + "'s war";
if (c.Completion == InterestCompletionKind.PlotActive)
return "Caught in " + mcName + "'s plot";
if (!string.IsNullOrEmpty(c.CityKey))
return "Caught in " + mcName + "'s city";
return "In " + mcName + "'s story";
}
private static bool TryResolveCastConnection(
long subjectId,
out long anchorId,
out string kind,
out string line)
{
anchorId = 0;
kind = "";
line = "";
Actor subject = EventFeedUtil.FindAliveById(subjectId);
if (subject == null) return false;
Slots.Clear();
LifeSagaRoster.CopySlots(Slots);
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot slot = Slots[i];
Actor mc = slot != null ? EventFeedUtil.FindAliveById(slot.UnitId) : null;
if (mc == null) continue;
string name = string.IsNullOrEmpty(slot.DisplayName)
? EventFeedUtil.SafeName(mc)
: slot.DisplayName;
if (Same(ActorRelation.GetLover(subject), mc)
|| Same(ActorRelation.GetLover(mc), subject))
return Set(slot.UnitId, "partner", name + "'s partner", out anchorId, out kind, out line);
if (Contains(ActorRelation.EnumerateChildren(mc, 12), subject))
return Set(slot.UnitId, "child", name + "'s child", out anchorId, out kind, out line);
if (Contains(ActorRelation.EnumerateParents(mc, 2), subject))
return Set(slot.UnitId, "parent", name + "'s parent", out anchorId, out kind, out line);
if (Same(ActorRelation.GetBestFriend(subject), mc)
|| Same(ActorRelation.GetBestFriend(mc), subject))
return Set(slot.UnitId, "friend", name + "'s best friend", out anchorId, out kind, out line);
if (Contains(ActorRelation.EnumerateFamilyMembers(mc, 12), subject))
return Set(slot.UnitId, "kin", name + "'s kin", out anchorId, out kind, out line);
if (LifeSagaRoster.TryGetRecurringAntagonist(
subject,
out long rivalAnchor,
out LifeSagaFact rivalEvidence)
&& rivalAnchor == slot.UnitId)
{
string evidence = rivalEvidence?.Note ?? "";
string label = evidence.IndexOf("killed_kin", StringComparison.OrdinalIgnoreCase) >= 0
|| evidence.IndexOf("kin_slain", StringComparison.OrdinalIgnoreCase) >= 0
? "Killed one of " + name + "'s kin"
: "Recurring antagonist of " + name;
return Set(slot.UnitId, "antagonist", label, out anchorId, out kind, out line);
}
if (Same(ActorRelation.GetCombatOpponent(subject), mc)
|| Same(ActorRelation.GetCombatOpponent(mc), subject))
return Set(slot.UnitId, "foe", "Fighting " + name, out anchorId, out kind, out line);
}
return false;
}
private static bool Same(Actor a, Actor b) =>
a != null && b != null && EventFeedUtil.SafeId(a) == EventFeedUtil.SafeId(b);
private static bool Contains(IEnumerable<Actor> actors, Actor target)
{
long id = EventFeedUtil.SafeId(target);
foreach (Actor actor in actors)
{
if (EventFeedUtil.SafeId(actor) == id) return true;
}
return false;
}
private static bool Set(
long id,
string relation,
string text,
out long anchorId,
out string kind,
out string line)
{
anchorId = id;
kind = relation;
line = text;
return true;
}
public static bool HarnessProbeExceptionalPolicy(out string detail)
{
var hatch = new InterestCandidate
{
Key = "harness:hatch",
AssetId = "just_got_out_of_egg",
Verb = "hatches from an egg",
SubjectId = 991001,
EventStrength = 90f,
Novelty = 1f,
HasNarrativeFunction = true,
NarrativeFunction = NarrativeFunction.Consequence,
NarrativeDevelopmentId = "harness_hatch"
};
var death = new InterestCandidate
{
Key = "harness:death",
AssetId = "death",
Verb = "died in battle",
SubjectId = 991002,
EventStrength = 90f,
Novelty = 1f,
HasNarrativeFunction = true,
NarrativeFunction = NarrativeFunction.Consequence,
NarrativeDevelopmentId = "harness_death"
};
CameraEligibilityModel hatchModel = Classify(hatch);
CameraEligibilityModel deathModel = Classify(death);
bool pass = hatchModel.Kind == CameraEligibilityKind.DiscoveryOnly
&& deathModel.Kind == CameraEligibilityKind.ExceptionalBackground;
detail = "hatch=" + hatchModel.Kind + ":" + hatchModel.Evidence
+ " death=" + deathModel.Kind + ":" + deathModel.Evidence;
return pass;
}
}

View file

@ -86,6 +86,7 @@ public static class CaptionComposer
}
model.IdentityLine = BuildIdentityLine(focus, focusId);
model.ConnectionLine = CameraEligibility.ConnectionLine(ownedTip, focusId);
model.ReasonRelatedId = reasonRelatedId;
string beat = presentableBeat ?? "";
@ -136,7 +137,8 @@ public static class CaptionComposer
}
model.Fingerprint =
focusId + "|" + model.IdentityLine + "|" + model.BeatLine + "|" + model.ContextLine
focusId + "|" + model.IdentityLine + "|" + model.ConnectionLine
+ "|" + model.BeatLine + "|" + model.ContextLine
+ "|" + model.ReasonRelatedId + "|" + SagaProse.MemoryRevision;
LastIdentityLine = model.IdentityLine;
@ -842,6 +844,7 @@ public static class CaptionComposer
public sealed class CaptionModel
{
public string IdentityLine = "";
public string ConnectionLine = "";
public string BeatLine = "";
public string ContextLine = "";
public long ReasonRelatedId;

View file

@ -6,12 +6,12 @@ using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Durable dynamic top-10 life-saga cast. Owns rail Prefer, diversity, and soft camera bias.
/// Durable dynamic five-character life-saga cast. Owns rail Prefer, diversity, and soft camera bias.
/// Independent of short-arc <see cref="StoryPlanner"/> Clear.
/// </summary>
public static class LifeSagaRoster
{
public const int Cap = 10;
public const int Cap = 5;
public const int MaxChapters = 3;
public const float ChallengerMargin = 1.5f;
public const float HeatCap = 12f;
@ -55,6 +55,13 @@ public static class LifeSagaRoster
private const int WorldScanBuildsPerFrame = 2;
private const int MaxScanAdmits = Cap * 2;
private const int RareSpeciesMaxPopulation = 5;
private const float SpecialCreatureStandingBonus = 2f;
private const float RareCreatureStandingBonus = 3f;
private const float RecurringAntagonistStandingBonus = 7f;
private const float EstablishedMcSeconds = 120f;
private const float EstablishedMcMarginBonus = 3f;
private const float UndevelopedChallengerMarginBonus = 2f;
/// <summary>Cadence for starting a spread world admission scan.</summary>
private const float WorldScanIntervalSeconds = 3.5f;
@ -64,6 +71,7 @@ public static class LifeSagaRoster
/// <summary>Last timed soft refill cost in ms (hitch probe).</summary>
public static float LastSoftRefillMs { get; private set; }
public static string LastAdmissionDiagnostic { get; private set; } = "";
public static int Count => Slots.Count;
@ -99,6 +107,7 @@ public static class LifeSagaRoster
_scanLimit = 0;
_scanCommitIndex = 0;
_liveScanSource = null;
LastAdmissionDiagnostic = "";
_dirty = true;
}
@ -429,6 +438,43 @@ public static class LifeSagaRoster
return bestPrefer ?? bestMc;
}
/// <summary>
/// Best living current MC for quiet camera grounding. Bounded by the five-slot roster;
/// unlike the world activity scanner this never walks the population.
/// </summary>
public static Actor BestLivingMc()
{
float now = Time.unscaledTime;
float dull = DullSeconds();
Actor best = null;
float bestScore = float.MinValue;
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot slot = Slots[i];
if (slot == null || slot.UnitId == 0)
{
continue;
}
Actor actor = EventFeedUtil.FindAliveById(slot.UnitId);
if (actor == null || !actor.isAlive())
{
continue;
}
float score = EffectiveInterest(slot, now, dull)
+ (slot.Prefer ? 20f : 0f)
+ (slot.GameFavorite ? 8f : 0f);
if (best == null || score > bestScore)
{
best = actor;
bestScore = score;
}
}
return best;
}
public static bool TipTouchesMc(InterestCandidate c)
{
if (c == null)
@ -1032,6 +1078,9 @@ public static class LifeSagaRoster
bool admission = IsAdmissionCandidate(actor);
if (!admission && !emergingStoryAdmit)
{
LastAdmissionDiagnostic =
"id=" + unitId + " result=rejected reason=ineligible asset="
+ (actor.asset?.id ?? "");
return false;
}
@ -1052,6 +1101,15 @@ public static class LifeSagaRoster
}
}
float challengerScore = EffectiveInterest(neu, now);
float weakestScore = float.PositiveInfinity;
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot incumbent = Slots[i];
if (incumbent == null || IsPin(incumbent)) continue;
weakestScore = Mathf.Min(weakestScore, EffectiveInterest(incumbent, now));
}
Scratch.Add(neu);
RankAndCap(Scratch, now, PreviousIds);
Slots.Clear();
@ -1061,7 +1119,23 @@ public static class LifeSagaRoster
}
_dirty = true;
return IsMc(unitId);
bool admitted = IsMc(unitId);
LastAdmissionDiagnostic =
"id=" + unitId
+ " result=" + (admitted ? "admitted" : "rejected")
+ " reason=" + neu.AdmissionReason
+ " score=" + challengerScore.ToString("0.##")
+ " weakest=" + (float.IsPositiveInfinity(weakestScore)
? "none"
: weakestScore.ToString("0.##"))
+ " margin=" + ChallengerMargin.ToString("0.##")
+ " pop=" + neu.SpeciesPopulation
+ " rare=" + neu.IsRareCreature
+ " special=" + neu.IsSpecialCreature
+ " antagonist=" + neu.IsRecurringAntagonist
+ " anchor=" + neu.AntagonistAnchorMcId
+ " evidence=" + neu.AntagonistEvidence;
return admitted;
}
private static void ConsiderEmergingStories(float now)
@ -1327,7 +1401,6 @@ public static class LifeSagaRoster
for (int d = 0; d < displaced.Count; d++)
{
LifeSagaSlot incumbent = displaced[d];
float need = EffectiveInterest(incumbent, now, dull) + ChallengerMargin;
int worstNewcomer = -1;
float worstScore = float.MaxValue;
for (int i = 0; i < kept.Count; i++)
@ -1351,6 +1424,17 @@ public static class LifeSagaRoster
break;
}
LifeSagaSlot newcomer = kept[worstNewcomer];
if (IsEstablishedMc(incumbent, now)
&& IsOrdinaryChallenger(newcomer)
&& !HasMeaningfulNarrativeEvidence(newcomer))
{
kept[worstNewcomer] = incumbent;
continue;
}
float need = EffectiveInterest(incumbent, now, dull)
+ ReplacementMargin(incumbent, newcomer, now);
if (worstScore + 0.001f < need)
{
kept[worstNewcomer] = incumbent;
@ -1380,6 +1464,100 @@ public static class LifeSagaRoster
}
}
private static float ReplacementMargin(
LifeSagaSlot incumbent,
LifeSagaSlot challenger,
float now)
{
float margin = ChallengerMargin;
if (incumbent != null
&& (incumbent.Chapters.Count > 0
|| (incumbent.AdmittedAt > 0f
&& now - incumbent.AdmittedAt >= EstablishedMcSeconds)))
{
margin += EstablishedMcMarginBonus;
}
if (challenger != null
&& challenger.Chapters.Count == 0
&& challenger.Heat < 4f
&& !challenger.IsRecurringAntagonist
&& !challenger.IsSpecialCreature
&& !challenger.IsKing
&& !challenger.IsLeader
&& !challenger.IsClanChief
&& !challenger.IsAlpha)
{
margin += UndevelopedChallengerMarginBonus;
}
return margin;
}
private static bool IsEstablishedMc(LifeSagaSlot slot, float now)
{
return slot != null
&& (slot.Chapters.Count > 0
|| (slot.AdmittedAt > 0f
&& now - slot.AdmittedAt >= EstablishedMcSeconds));
}
private static bool IsOrdinaryChallenger(LifeSagaSlot slot)
{
return slot != null
&& !slot.GameFavorite
&& !slot.Prefer
&& !slot.IsKing
&& !slot.IsLeader
&& !slot.IsClanChief
&& !slot.IsArmyCaptain
&& !slot.IsPlotAuthor
&& !slot.IsFounder
&& !slot.IsSpecialCreature
&& !slot.IsRecurringAntagonist;
}
private static bool HasMeaningfulNarrativeEvidence(LifeSagaSlot slot)
{
if (slot == null)
{
return false;
}
return slot.Chapters.Count > 0
|| slot.Heat >= 4f
|| StoryScheduler.StoryPotential(slot.UnitId) >= 4f;
}
public static bool HarnessProbeReplacementEvidence(out string detail)
{
float now = EstablishedMcSeconds + 2f;
var incumbent = new LifeSagaSlot
{
UnitId = 991201,
AdmittedAt = 1f
};
var ordinary = new LifeSagaSlot
{
UnitId = 991202,
AdmittedAt = now,
Heat = 0f
};
bool ordinaryBlocked = IsEstablishedMc(incumbent, now)
&& IsOrdinaryChallenger(ordinary)
&& !HasMeaningfulNarrativeEvidence(ordinary);
ordinary.Heat = 4f;
bool developedAllowed = HasMeaningfulNarrativeEvidence(ordinary);
ordinary.Heat = 0f;
ordinary.IsSpecialCreature = true;
bool specialAllowed = !IsOrdinaryChallenger(ordinary);
detail = "ordinaryBlocked=" + ordinaryBlocked
+ " developedAllowed=" + developedAllowed
+ " specialAllowed=" + specialAllowed;
return ordinaryBlocked && developedAllowed && specialAllowed;
}
private static int CompareSlots(LifeSagaSlot a, LifeSagaSlot b, float now, float dull)
{
if (a == null && b == null)
@ -1403,6 +1581,12 @@ public static class LifeSagaRoster
return pin;
}
int prefer = (b.Prefer ? 1 : 0).CompareTo(a.Prefer ? 1 : 0);
if (prefer != 0)
{
return prefer;
}
int fav = (b.GameFavorite ? 1 : 0).CompareTo(a.GameFavorite ? 1 : 0);
if (fav != 0)
{
@ -1417,7 +1601,8 @@ public static class LifeSagaRoster
return cmp;
}
return b.TouchedAt.CompareTo(a.TouchedAt);
int touched = b.TouchedAt.CompareTo(a.TouchedAt);
return touched != 0 ? touched : a.UnitId.CompareTo(b.UnitId);
}
public static float EffectiveInterest(LifeSagaSlot s, float now = -1f, float dull = -1f)
@ -1558,6 +1743,16 @@ public static class LifeSagaRoster
// ignore
}
long actorId = EventFeedUtil.SafeId(actor);
if (actorId != 0
&& NarrativePersistence.IsPreferred(
actorId,
EventFeedUtil.SafeName(actor),
actor.asset != null ? actor.asset.id : ""))
{
return true;
}
if (InterestScoring.IsNotable(actor))
{
return true;
@ -1568,7 +1763,9 @@ public static class LifeSagaRoster
|| IsArmyCaptain(actor)
|| IsActivePlotAuthor(actor)
|| IsFounder(actor)
|| IsSpeciesSingleton(actor))
|| IsSpecialCreature(actor)
|| IsRareCreature(actor)
|| TryGetRecurringAntagonist(actor, out _, out _))
{
return true;
}
@ -1576,6 +1773,8 @@ public static class LifeSagaRoster
return false;
}
public static bool HarnessIsAdmissionCandidate(Actor actor) => IsAdmissionCandidate(actor);
/// <summary>
/// Cheap challenger ordering before BuildSlot - prefer favorites / kings / leaders, then roles.
/// </summary>
@ -1668,14 +1867,81 @@ public static class LifeSagaRoster
p += 120f;
}
if (IsActivePlotAuthor(actor) || IsFounder(actor) || IsSpeciesSingleton(actor))
if (IsActivePlotAuthor(actor)
|| IsFounder(actor)
|| (IsSpecialCreature(actor) && IsSpeciesSingleton(actor)))
{
p += 80f;
}
if (IsSpecialCreature(actor))
{
p += 45f;
}
int rarePopulation = GetSpeciesPopulation(actor);
if (IsSpecialCreature(actor)
&& rarePopulation > 1
&& rarePopulation <= RareSpeciesMaxPopulation)
{
p += 25f * (RareSpeciesMaxPopulation + 1 - rarePopulation)
/ RareSpeciesMaxPopulation;
}
if (TryGetRecurringAntagonist(actor, out _, out LifeSagaFact antagonistEvidence))
{
p += 150f + Mathf.Min(50f, antagonistEvidence?.Strength ?? 0f);
}
return p;
}
public static bool TryGetRecurringAntagonist(
Actor actor,
out long anchorMcId,
out LifeSagaFact evidence)
{
anchorMcId = 0;
evidence = null;
long actorId = EventFeedUtil.SafeId(actor);
if (actorId == 0)
{
return false;
}
// Both searches are bounded: at most five MCs and each life-memory fact list is capped.
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot slot = Slots[i];
if (slot == null || slot.UnitId == 0 || slot.UnitId == actorId)
{
continue;
}
if (LifeSagaMemory.TryGetEarnedRival(slot.UnitId, out LifeSagaFact mcRival)
&& mcRival != null
&& mcRival.OtherId == actorId
&& LifeSagaMemory.IsCredibleRival(mcRival))
{
anchorMcId = slot.UnitId;
evidence = mcRival;
return true;
}
}
if (LifeSagaMemory.TryGetEarnedRival(actorId, out LifeSagaFact actorRival)
&& actorRival != null
&& LifeSagaRoster.IsMc(actorRival.OtherId)
&& LifeSagaMemory.IsCredibleRival(actorRival))
{
anchorMcId = actorRival.OtherId;
evidence = actorRival;
return true;
}
return false;
}
public static bool IsPackAlpha(Actor actor)
{
if (actor == null)
@ -1945,44 +2211,80 @@ public static class LifeSagaRoster
public static bool IsSpeciesSingleton(Actor actor)
{
if (actor?.asset == null)
return GetSpeciesPopulation(actor) == 1;
}
public static bool IsRareCreature(Actor actor)
{
if (!IsSpecialCreature(actor))
{
return false;
}
int population = GetSpeciesPopulation(actor);
return population > 0 && population <= RareSpeciesMaxPopulation;
}
public static bool IsSpecialCreature(Actor actor)
{
string id = actor?.asset != null ? actor.asset.id : "";
if (string.IsNullOrEmpty(id))
{
return false;
}
id = id.Trim().ToLowerInvariant();
return id == "dragon"
|| id == "zombie_dragon"
|| id == "druid"
|| id == "necromancer"
|| id == "evil_mage"
|| id == "white_mage";
}
private static int GetSpeciesPopulation(Actor actor)
{
if (actor?.asset == null)
{
return 0;
}
string speciesId = actor.asset.id ?? "";
if (string.IsNullOrEmpty(speciesId))
{
return false;
return 0;
}
if (!SpeciesCountCache.TryGetValue(speciesId, out int population))
if (SpeciesCountCache.TryGetValue(speciesId, out int population))
{
// Roster admission only needs singleton/not-singleton. The actor asset already
// owns its units, so stop at two instead of rebuilding counts for the whole world.
population = 0;
try
return population;
}
// Count only far enough to distinguish singleton/rare from common. The result is
// retained for the current role-cache/world-scan cycle.
population = 0;
try
{
if (actor.asset.units != null)
{
if (actor.asset.units != null)
foreach (Actor sameSpecies in actor.asset.units)
{
foreach (Actor sameSpecies in actor.asset.units)
if (sameSpecies != null
&& sameSpecies.isAlive()
&& ++population > RareSpeciesMaxPopulation)
{
if (sameSpecies != null && sameSpecies.isAlive() && ++population >= 2)
{
break;
}
break;
}
}
}
catch
{
population = 2;
}
SpeciesCountCache[speciesId] = population;
}
catch
{
population = RareSpeciesMaxPopulation + 1;
}
return population == 1;
SpeciesCountCache[speciesId] = population;
return population;
}
private static bool ActorMatchesFounder(object meta, Actor actor)
@ -2158,7 +2460,17 @@ public static class LifeSagaRoster
s.IsArmyCaptain = IsArmyCaptain(actor);
s.IsPlotAuthor = IsActivePlotAuthor(actor);
s.IsFounder = IsFounder(actor);
s.IsSingleton = IsSpeciesSingleton(actor);
s.SpeciesPopulation = GetSpeciesPopulation(actor);
s.IsSingleton = s.SpeciesPopulation == 1;
s.IsSpecialCreature = IsSpecialCreature(actor);
s.IsRareCreature = s.IsSpecialCreature
&& s.SpeciesPopulation > 0
&& s.SpeciesPopulation <= RareSpeciesMaxPopulation;
s.IsRecurringAntagonist = TryGetRecurringAntagonist(
actor,
out s.AntagonistAnchorMcId,
out LifeSagaFact antagonistEvidence);
s.AntagonistEvidence = antagonistEvidence?.Note ?? "";
s.Kills = meta?.Kills ?? 0;
s.Renown = meta?.Renown ?? 0f;
// Standing from flags already resolved above - avoid a second clan/alpha/founder reflection walk.
@ -2227,10 +2539,28 @@ public static class LifeSagaRoster
score += 7f;
}
if (s.IsSingleton)
if (s.IsSingleton && s.IsSpecialCreature)
{
score += 6f;
}
else if (s.IsSingleton)
{
score += 1f;
}
else if (s.IsRareCreature)
{
score += RareCreatureStandingBonus;
}
if (s.IsSpecialCreature)
{
score += SpecialCreatureStandingBonus;
}
if (s.IsRecurringAntagonist)
{
score += RecurringAntagonistStandingBonus;
}
if (s.IsFounder)
{
@ -2374,7 +2704,10 @@ public static class LifeSagaRoster
if (s.IsArmyCaptain) return LifeSagaAdmissionReason.ArmyCaptain;
if (s.IsFounder) return LifeSagaAdmissionReason.Founder;
if (s.IsPlotAuthor) return LifeSagaAdmissionReason.PlotAuthor;
if (s.IsSingleton) return LifeSagaAdmissionReason.Singleton;
if (s.IsSingleton && s.IsSpecialCreature) return LifeSagaAdmissionReason.Singleton;
if (s.IsRecurringAntagonist) return LifeSagaAdmissionReason.RecurringAntagonist;
if (s.IsSpecialCreature) return LifeSagaAdmissionReason.SpecialCreature;
if (s.IsRareCreature) return LifeSagaAdmissionReason.RareCreature;
if (s.Kills > 0) return LifeSagaAdmissionReason.NotableKills;
if (s.Renown > 0f) return LifeSagaAdmissionReason.NotableRenown;
return LifeSagaAdmissionReason.NotableLife;
@ -2419,7 +2752,7 @@ public static class LifeSagaRoster
score += 7f;
}
if (IsSpeciesSingleton(actor))
if (IsSpecialCreature(actor) && IsSpeciesSingleton(actor))
{
score += 6f;
}
@ -2549,6 +2882,12 @@ public sealed class LifeSagaSlot
public bool IsPlotAuthor;
public bool IsFounder;
public bool IsSingleton;
public bool IsRareCreature;
public bool IsSpecialCreature;
public int SpeciesPopulation;
public bool IsRecurringAntagonist;
public long AntagonistAnchorMcId;
public string AntagonistEvidence = "";
public int Kills;
public float Renown;
/// <summary>Live role/standing recomputed each refresh.</summary>
@ -2584,6 +2923,9 @@ public enum LifeSagaAdmissionReason
Founder,
PlotAuthor,
Singleton,
RecurringAntagonist,
RareCreature,
SpecialCreature,
NotableKills,
NotableRenown,
NotableLife,

View file

@ -565,6 +565,12 @@ public static class SagaProse
return Sentence("Leads a plot");
case LifeSagaAdmissionReason.Singleton:
return Sentence("Last of their kind");
case LifeSagaAdmissionReason.RecurringAntagonist:
return Sentence("A recurring threat");
case LifeSagaAdmissionReason.RareCreature:
return Sentence("One of few of their kind");
case LifeSagaAdmissionReason.SpecialCreature:
return Sentence("A rare power in the world");
case LifeSagaAdmissionReason.Founder:
return Sentence("Defends what they founded");
case LifeSagaAdmissionReason.CityLeader:

View file

@ -39,6 +39,7 @@ public static class WatchCaption
private const float TraitsH = 13f;
private const float StatusesH = 13f;
private const float ReasonH = 13f;
private const float ConnectionH = 11f;
private const float StorySpineH = 11f;
private const float HistoryLineMinH = 12f;
private const float HistoryLineMaxH = 100f;
@ -62,6 +63,7 @@ public static class WatchCaption
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 ConnectionColor = HudTheme.Muted;
private static readonly Color TraitNameColor = HudTheme.Body;
private static readonly Color StatusNameColor = HudTheme.StatusBlue;
private static readonly Color HistoryTextColor = HudTheme.Body;
@ -73,6 +75,7 @@ public static class WatchCaption
private static Image _sexIcon;
private static Text _nameText;
private static Text _reasonText;
private static Text _connectionText;
private static Text _storySpineText;
private static long _reasonOtherId;
@ -194,6 +197,7 @@ public static class WatchCaption
/// <summary>Harness: composed context line (spine or stake).</summary>
public static string LastContextLine => CaptionComposer.LastContextLine;
public static string LastConnectionLine { get; private set; } = "";
/// <summary>Harness/UI: active context line under the beat (spine or stake), empty when none.</summary>
public static string LastStorySpine { get; private set; } = "";
@ -509,6 +513,7 @@ public static class WatchCaption
LastDetail = "";
LastCaptionText = "";
LastStorySpine = "";
LastConnectionLine = "";
CaptionComposer.Clear();
LifeSagaViewController.Clear();
LifeSagaPanel.Clear();
@ -1202,6 +1207,7 @@ public static class WatchCaption
{
RefreshSagaBody();
ApplyReasonText("", 0);
ApplyConnection("");
ApplyStorySpine("");
_dossierRowsNeedRestore = true;
}
@ -1240,7 +1246,7 @@ public static class WatchCaption
|| historyCount > 0
|| _current != null);
string layoutKey =
$"{(panelMode ? 1 : 0)}|{LastContextLine}|{traitCount}|{statusCount}|{historyCount}"
$"{(panelMode ? 1 : 0)}|{LastConnectionLine}|{LastContextLine}|{traitCount}|{statusCount}|{historyCount}"
+ $"|{(hasTask ? 1 : 0)}|{(hasReason ? 1 : 0)}|{hasBody}"
+ $"|{LifeSagaPanel.BodyHeight:F0}|{LifeSagaRail.LastShownCount}|{LifeSagaRail.Visible}"
+ $"|{LastBeatLine}|{LastHeadline}";
@ -1274,6 +1280,7 @@ public static class WatchCaption
reasonRelatedId,
spineFiltered,
statusBannerOwnsBeat);
ApplyConnection(model.ConnectionLine ?? "");
if (string.Equals(
model.Fingerprint,
_lastAppliedCaptionFingerprint,
@ -1339,6 +1346,26 @@ public static class WatchCaption
model.ContextLine);
}
private static void ApplyConnection(string connection)
{
string next = connection ?? "";
bool show = !string.IsNullOrEmpty(next)
&& !LifeSagaViewController.IsHoverPreview;
LastConnectionLine = show ? next : "";
if (_connectionText == null)
{
return;
}
if (!string.Equals(_connectionText.text, LastConnectionLine, StringComparison.Ordinal))
{
_connectionText.text = LastConnectionLine;
EllipsisFitLabel(_connectionText, BodyColumnInnerWidth());
}
_connectionText.color = ConnectionColor;
_connectionText.gameObject.SetActive(show);
}
/// <summary>
/// Re-show trait/status/history chrome after hover hid them.
/// Uses the bound dossier snapshot so hover-exit is instant; recomposes caption.
@ -3129,6 +3156,15 @@ public static class WatchCaption
if (!panelMode)
{
bool hasConnection = _connectionText != null
&& _connectionText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_connectionText.text);
if (hasConnection)
{
PlaceLine(_connectionText.GetComponent<RectTransform>(), y, ConnectionH, PadX);
y += ConnectionH + Gap;
}
// Row visibility is owned by Place*/fill - never leave empty rows active at stale
// positions (SetDossierBodyVisible only hides on hover; it must not resurrect them).
if (historyCount <= 0 && _historyCol != null)
@ -4092,6 +4128,7 @@ public static class WatchCaption
if (_root != null && (_nameText == null || _nameText.transform.parent != _root.transform
|| _historyCol == null
|| _statusesRow == null
|| _connectionText == null
|| _favoriteBtn == null
|| _root.transform.Find("HistoryBtn") != null
|| _root.transform.Find("HistoryScroll") != null))
@ -4112,9 +4149,11 @@ public static class WatchCaption
_sexIcon = null;
_nameText = null;
_reasonText = null;
_connectionText = null;
_storySpineText = null;
_reasonOtherId = 0;
LastStorySpine = "";
LastConnectionLine = "";
_levelIcon = null;
_levelValue = null;
_taskIcon = null;
@ -4261,6 +4300,15 @@ public static class WatchCaption
_reasonText.resizeTextForBestFit = false;
WireReasonClick(0);
_connectionText = HudCanvas.MakeText(_root.transform, "Connection", "", 7);
_connectionText.color = ConnectionColor;
_connectionText.supportRichText = false;
_connectionText.alignment = TextAnchor.UpperLeft;
_connectionText.horizontalOverflow = HorizontalWrapMode.Overflow;
_connectionText.verticalOverflow = VerticalWrapMode.Truncate;
_connectionText.resizeTextForBestFit = false;
_connectionText.raycastTarget = false;
_storySpineText = HudCanvas.MakeText(_root.transform, "StorySpine", "", 7);
_storySpineText.color = StorySpineColor;
_storySpineText.supportRichText = false;
@ -4302,6 +4350,7 @@ public static class WatchCaption
_statusesRow.SetActive(false);
_traitsRow.SetActive(false);
_reasonText.gameObject.SetActive(false);
_connectionText.gameObject.SetActive(false);
_storySpineText.gameObject.SetActive(false);
_levelIcon.gameObject.SetActive(false);
_levelValue.gameObject.SetActive(false);

View file

@ -10,7 +10,7 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director.
## What it follows
The camera follows the developing lives and relationships of a small emerging cast. A story
The camera follows the developing lives and relationships of a five-character emerging cast. A story
scheduler chooses developments that advance an active character thread, yields to critical world
events, and returns only when the story has new evidence. `EventStrength` still models urgency and
visual value, but it no longer owns continuity. Bounded character/world fill runs when no episode
@ -21,6 +21,12 @@ sentence** (Beat), and muted Context (story spine or confirmed pressure). Main c
`Name · Title` when they have a real role. The Beat is rendered once from typed event evidence, so
relationship details are not appended a second time.
Routine camera shifts stay with MCs or their confirmed Cast/story context. When a related non-MC is
followed, a muted Connection row explains the MC link. Background lives still accumulate story
potential off-camera and may earn a slot; only typed turning points, consequences, story payoffs,
or critical world/disaster events can give an unrelated background character exceptional screen
time.
Confirmed MC events, threads, consequences, Legacy, and Prefer choices are stored in a versioned
per-world sidecar at the game save boundary. Runtime camera state and episode timing remain
session-only.

View file

@ -0,0 +1,550 @@
# Five-MC ensemble and viewer-recognition plan
## Status
Core implementation complete; extended organic validation pending.
## Product outcome
IdleSpectator follows five recognizable main characters at a time. The five are the complete
visible MC ensemble, not a ten-character roster with a hidden lead tier.
The player should be able to:
1. recognize a returning MC before reading a full dossier;
2. understand what changed since the MC was last shown;
3. notice when two MC stories cross;
4. tell why a newcomer entered and why a former MC left; and
5. recount at least one MC's story after an ordinary spectator session.
Five limits attention; it does not limit durable history. Narrative records, Chronicle history,
Legacy, and saved Prefer choices survive roster churn.
Background lives continue to be observed and scored, but observation does not imply routine camera
time. The camera primarily follows the five and their evidence-backed social/world orbit.
## Product contracts
### Roster
- `LifeSagaRoster.Cap` becomes 5.
- All five slots use the existing standing, heat, story-potential, diversity, and challenger rules.
- A slot means **current MC**. There is no second visible category such as lead, supporting MC, or
bench.
- A newcomer still needs the challenger margin to replace the weakest eligible incumbent.
- Roster order changes only when effective rank changes materially; ordinary heat decay must not
make the rail shuffle continuously.
- Leaving the five removes current MC camera bias but never deletes the character's durable story.
### Prefer and favorite overflow
Five slots cannot promise simultaneous display for an unlimited number of preferred or WorldBox
favorite characters.
- Prefer and WorldBox favorite remain strong, durable priority signals.
- Persist every Prefer choice even while that character is outside the visible five.
- Never clear a preference merely to satisfy Cap.
- When more than five pinned candidates are alive, choose the visible five deterministically:
1. current visible incumbent;
2. active meaningful narrative thread;
3. Prefer over WorldBox-favorite-only;
4. effective story score;
5. most recent confirmed meaningful development;
6. stable unit-id tie-break.
- An off-roster preferred life remains an eligible challenger and can return when its story develops
or a visible slot opens.
- Toggling Prefer on a visible MC remains immediate. Selecting an off-roster character in Chronicle
may preserve its Prefer state, but does not forcibly evict a current MC without passing the same
deterministic admission policy.
### Camera
- The scheduler remains story-first.
- Main camera selection uses this eligibility ladder:
1. world-critical interrupt;
2. meaningful development directly involving a current MC;
3. meaningful development involving confirmed MC Cast or an active MC thread/theater;
4. significant disaster impact on a named, presentable life;
5. exceptional background-character development;
6. MC/MC-Cast grounding footage when no development is ready;
7. location/world context when no suitable character footage exists.
- Prefer > MC > MC Cast remains the soft ordering within an eligible tier.
- Routine background activity is discovery-only. It may update observation, story potential, and
challenger rank, but it does not become character-fill or an ordinary camera cut.
- “Related to an MC” requires structured evidence, not proximity or name matching:
- the MC is the subject, other, pair member, theater lead, or exact event participant;
- the character is confirmed lover, friend, kin, credible foe, war/plot counterpart, or other
current Cast relation;
- the event advances the MC's active correlation, family, city, kingdom, war, plot, or consequence
thread.
- Being near an MC, sharing a species, performing the same task, or appearing in a mass participant
list is not sufficient by itself.
- Prefer state is a strong bias but does not turn routine texture into a meaningful development.
- Exposure debt is recalibrated for five characters so it does not mechanically rotate through all
five when no meaningful footage exists.
- World-critical events retain interruption priority.
- A displaced former MC loses MC bonus immediately but may still be shown for a payoff, consequence,
death, or world-critical event.
### Background challengers
- Background characters accumulate story potential from confirmed developments without needing a
camera cut.
- Ordinary combat, chores, travel, moods, repeated intent, and unchanged status do not qualify as
exceptional background developments.
- An exceptional background development must be both semantically meaningful and rare/high-impact,
for example:
- founding a city or kingdom;
- becoming or losing a major ruler/leader role;
- confirmed birth, death, bond, betrayal/opposition, or decisive survival consequence with high
narrative magnitude;
- a decisive war/plot outcome;
- confirmed severe disaster impact;
- another typed turning point, resolution, or consequence above an explicit background threshold.
- An exceptional background cut is an audition, not instant MC promotion. Admission still uses
accumulated story potential, diversity, standing, and the challenger margin.
- Repeated exceptional cuts for the same non-MC are cooled aggressively. If the life continues to
matter, it should enter the five instead of functioning as an unofficial sixth MC.
- Background camera share is measured and bounded. In a valid developed-world soak, non-critical
cuts whose subject is neither MC nor confirmed MC Cast should be exceptional and individually
explainable from telemetry.
### Disaster significance
Disasters remain world-critical at the event/theater level. A character caught in one may also earn
a meaningful personal development.
- “Caught up in a disaster” requires confirmed impact, not mere distance from the effect:
- injured, afflicted, trapped, displaced, made homeless, or killed by the disaster;
- loses confirmed family, home, city, role, or another durable stake because of it;
- survives a directly observed severe impact or participates in a confirmed rescue/response;
- is an exact affected participant in a typed disaster event whose outcome is presentable.
- Proximity, entering the camera rectangle, or membership in a large nearby-unit snapshot is not
enough.
- For an affected MC or MC Cast member, the personal impact becomes a related escalation,
turning point, resolution, or consequence in that life/thread.
- For an affected background character, severe confirmed impact increases story potential and may
qualify for one exceptional background cut.
- Disaster theater coverage and personal consequence are separate shots. Do not repeat the same
fact once as “disaster spectacle” and again as a personal Beat unless the second shot adds a
confirmed outcome.
- Mass casualty events use bounded representative selection: prefer affected MCs, then MC Cast,
then at most one exceptional background survivor/victim when it adds a distinct human-scale
consequence.
### Performance
The five-MC change must reduce or preserve runtime cost. It must not trade a smaller rail for more
frequent world scans, full-store walks, roster rebuilds, portrait churn, or UI relayout.
- Reuse the existing amortized world scan: walk a bounded unit slice per frame and build only a
bounded challenger slice.
- Cap reduction changes the number of visible slots, not the cadence of admission scans.
- Rank existing slots or bounded challengers in memory; never rescan the world because heat,
presentation, portrait state, or Prefer changes.
- Pin overflow uses cached story/identity indexes and the existing challenger pool. It must not walk
all persisted events or all living units during a normal refill.
- Stable rail order should reduce portrait rebinding and layout work. A no-op refill must produce no
rail rebuild, portrait capture, or dossier relayout.
- Reentry, crossover, admission, and closure presentation must use exact event receipts and indexed
story state rather than latest-similar searches.
- Apply camera eligibility before expensive presentability/prose work where the required structured
ids and narrative function are already available.
- Carry Connection as a small typed model (`AnchorMcId`, relation/context kind, evidence source) and
render it only when the dossier fingerprint changes. Do not query all five MC memories every
frame.
- Disaster impact routing must consume typed affected participants/outcomes from the event receipt;
it must not add a recurring radius scan over the world.
- Diagnostic logging remains rate-limited because the probe itself can create GC/I/O hitches.
- Performance gates are evaluated on a developed world with Idle Spectator active, not only on a
small synthetic map.
### Recognition
Recognition should come from repeated, stable signals rather than extra prose on every cut.
- Keep rail position stable for an incumbent whenever possible.
- Keep each unit's latest unique portrait cached for the duration of roster membership and through a
death/Legacy presentation.
- On a meaningful return after the existing reentry threshold, allow one concise reintroduction:
`Name again · <new development>`.
- Do not use reintroduction wording for rapid A → B → A cuts, unchanged activity, or texture.
- Identity remains `Name · real title`, otherwise `Name (species/job)`.
- Hover remains the place for Cast and Legacy depth; the live caption stays Identity + Beat +
Context.
### MC connection in the camera dossier
When the camera follows a non-MC because of an MC connection, the viewer must see that connection
immediately.
- Add one muted, single-line **Connection** row directly below Identity and above the orange Beat:
```text
Identity Omya (Human)
Connection Waf's daughter
Beat Escapes the burning city
Context Waf's family is scattered
```
- Connection is presentation metadata. It does not become part of the event sentence or replace
story Context.
- Prefer the clearest confirmed relation: `Waf's partner`, `Waf's daughter`, `Waf's father`,
`Waf's best friend`, `Waf's old foe`, `Fights beside Waf`, `Opposes Waf in the war`, or
`Caught in Waf's city`.
- Name one anchor MC. If several MCs are involved, prefer the MC whose active thread admitted the
shot; otherwise use the strongest direct relation. Additional MCs remain visible through involved
rail glyphs.
- Durable social relations outrank temporary theater/context relations.
- Show Connection only when the focused character is not an MC, eligibility is `McCast` or
`McThreadContext`, and an exact anchor MC id plus typed evidence is available.
- Do not show Connection for an unrelated exceptional-background audition. Its Beat must explain
the exceptional event directly.
- Disaster wording distinguishes relationship from geography: use `Waf's daughter` when confirmed,
otherwise factual context such as `Caught in Waf's city`.
- Never derive Connection from display-name parsing, proximity alone, broad participant inventory,
or final prose.
- Clear Connection atomically when focus returns to an MC, world/location shot, unrelated
exceptional character, or Saga hover.
- Saga hover continues to show the hovered MC's Cast/Legacy and never retains the camera subject's
Connection row.
- Long lines remain one row. Ellipsize the MC name before removing the relation label; never wrap.
### Cross-MC moments
- A tip touching two or more of the five lights every involved rail glyph.
- The orange Beat names the confirmed relationship or opposition once.
- Both characters receive crossover heat and durable perspective projections where supported by the
recorded event.
- Cross-MC presentation must not invent “team,” “friend,” “rival,” or shared motive from mere
co-location.
### Entrances, exits, and closure
- Admission records retain the evidence that made the character an MC.
- The first meaningful camera appearance after admission may use Context to communicate that evidence
when it is not already obvious from Identity or Beat.
- Ordinary roster replacement is silent; avoid UI announcements for score churn.
- Death, completed war/plot, lost leadership, and resolved relationship arcs may produce a bounded
payoff or Legacy chapter before/after roster removal when presentable evidence exists.
- No synthetic farewell is created solely because a character fell to rank six.
## Work phases
### Phase 0 — Baseline and instrumentation
1. Record a release baseline with the current ten-character roster:
- authored cuts per MC;
- unique MCs shown;
- longest absence before return;
- number of roster replacements;
- exact rail reorder count;
- cross-MC cuts;
- cuts by subject class: MC, MC Cast, exceptional background, world/location-only;
- reason for every exceptional background cut;
- disaster theater cuts versus confirmed personal-impact cuts;
- combat share and maximum combat run;
- unresolved preferred/favorite overflow;
- hitch count and maximum frame delta;
- maximum director, discovery, and caption slice;
- maximum roster world-scan and soft-refill slice;
- scheduler tick time and candidate count;
- portrait/reconcile time while rail membership is stable and while it changes.
2. Add a compact roster diagnostic containing visible ids, effective scores, pin source, admission
reason, last meaningful development, and replacement reason.
3. Capture the baseline with the existing `hitch_probe`, `narrative_scheduler_health`, and
`soak_probe` paths.
4. Extend `scripts/soak-audit-player-log.sh` to summarize `[HITCH]` detail/summary lines in the
selected byte window. Report active-only spike count, maximum frame delta, and maximum
director/discovery/caption/roster/scheduler slices alongside editorial results.
5. Do not tune scoring or performance thresholds during baseline capture.
Exit: one valid developed-world sample can explain every visible admission and eviction and provides
a reproducible performance baseline from the same log window. It also establishes the current
non-MC camera share and the selection path responsible for it.
### Phase 1 — Reduce the roster safely
1. Change the single roster capacity constant from 10 to 5.
2. Audit loops, buffers, portrait caches, layout calculations, harness fixtures, prose, and
documentation for hard-coded ten-slot assumptions.
3. Keep scratch-buffer capacities independent where they represent challenger pools rather than
visible slots.
4. Implement deterministic pin overflow without deleting persisted preferences.
5. On loading an existing sidecar, rebuild the visible five from live candidates; do not migrate or
rewrite narrative event data merely because the presentation cap changed.
6. Ensure a full five with all visible characters pinned cannot cause a crash, forced arbitrary
removal, or permanent challenger starvation.
7. Preserve the existing `WorldScanUnitsPerFrame`, `WorldScanBuildsPerFrame`, and timed scan cadence
unless profiling demonstrates a measured need to change them.
8. Keep overflow arbitration inside the bounded rank/cap stage. Do not add a second scan or a
per-frame sort of every persisted preferred character.
Exit: old saves load, five glyphs render, all durable preferences remain queryable, and selection is
stable across two rebuilds with identical inputs. A no-op rebuild stays within the baseline soft
refill budget.
### Phase 2 — Stable recognition
1. Preserve incumbent rail order unless an admission, removal, death, or material rank crossing
occurs.
2. Verify portrait identity for same-species MCs and retain the last valid portrait for fallen MCs.
3. Tighten the existing reentry behavior:
- MC only;
- meaningful typed development only;
- minimum absence threshold;
- one use per semantic development.
4. Make the current camera MC and all involved MCs visually distinguishable without relying only on
color.
5. Add a small discoverable tooltip for Prefer that states it is a camera/story bias, not a camera
lock.
6. Bind and relayout the rail only when its ordered unit-id sequence or visible state actually
changes. Refresh a live portrait at the existing bounded cadence; do not refresh five portraits
every frame.
7. Add the one-line Connection slot to the camera dossier between Identity and Beat.
8. Measure maximum authored Connection strings at supported UI scales and keep them to one
ellipsized line.
9. Include Connection identity in the dossier fingerprint so focus changes update atomically
without recomposing unchanged prose every frame.
Exit: a player can scrub the five glyphs without geometry movement or identity ambiguity, and a
returning MC is identifiable without opening Chronicle. Repeated no-change UI frames do not add
caption or portrait slice regressions.
### Phase 3 — Entrances, crossovers, and closure
1. Expose admission evidence to presentation as structured context, not a final authored string.
2. Show it only on the first suitable post-admission appearance and suppress it when Beat or
Identity already communicates the same fact.
3. Verify cross-MC participant inventory uses exact event receipts and lights the correct glyphs.
4. Allow confirmed payoff/consequence shots for a recently displaced or dead MC without restoring
roster membership solely for the shot.
5. Verify Legacy records the lasting consequence once and does not echo current Cast or Identity.
6. Confirm all new presentation lookups are O(1) or bounded by five/current event participants; no
presentation frame may enumerate the full narrative event store.
Exit: admission, crossover, displacement, and death each have an evidence-backed presentation path
with no synthetic emotion or duplicate prose.
### Phase 4 — MC-orbit camera eligibility
1. Add one shared camera-eligibility classifier at or immediately after candidate intake. It returns:
- `McDirect`;
- `McCast`;
- `McThreadContext`;
- `DisasterPersonalImpact`;
- `ExceptionalBackground`;
- `WorldCritical`;
- `GroundingFill`;
- `DiscoveryOnly`.
2. Compute the class from typed participant ids, exact narrative development/thread identity,
durable relation evidence, event outcome, and narrative function. Do not infer it from final
prose.
3. For `McCast` and `McThreadContext`, return a typed connection payload containing the selected
anchor MC and evidence kind. Eligibility and UI consume the same result.
4. Use the class consistently in scheduler proposals, interrupts, ordinary selection, character
fill, switch policy, telemetry, and soak audit.
5. Replace anonymous `TryCharacterFill` behavior with:
- current Prefer/MC;
- living confirmed MC Cast;
- a location/theater establishing shot;
- no character cut when none qualifies.
6. Keep discovery-only candidates in the narrative/admission pipeline while excluding them from the
ordinary camera pool.
7. Define an authored/configurable exceptional-background threshold using narrative function,
magnitude, event strength, novelty, and confirmed outcome. Raw combat score alone cannot pass it.
8. Add a long cooldown and per-window budget for exceptional background cuts.
9. Record why each non-MC/non-Cast character cut passed eligibility and the anchor/evidence used for
every MC-related non-MC cut.
Exit: every routine character cut is MC-orbit footage; every background-character cut has an
explicit exceptional reason; background story potential and roster challenges still advance. Every
MC-related non-MC cut displays the same typed connection that granted camera eligibility.
### Phase 5 — Disaster impact
1. Extend canonical disaster observations with typed affected-character outcome where the game hook
can confirm it.
2. Project confirmed personal impact into the affected life without duplicating the world disaster
record.
3. Attach exact affected ids and outcome evidence to the candidate/event receipt.
4. Route MC/MC-Cast impact into the active life story when related.
5. Route severe background impact into story potential and the exceptional-background gate.
6. Bound representative selection for mass impact and cool repeated theater/personal cards.
7. Add telemetry for:
- affected count;
- confirmed versus proximity-only inventory;
- selected representative class;
- world card followed by distinct personal outcome;
- suppressed duplicate/proximity candidates.
Exit: an MC struck by a disaster produces a coherent personal consequence, a severely affected
background life can emerge, and nearby unaffected units do not receive significance.
### Phase 6 — Tune five-character pacing
Tune only after Phases 15 are observable.
1. Compare five-character results with the Phase 0 baseline.
2. Adjust challenger margin, heat cap/decay, MC weight, Prefer weight, crossover bonus, and exposure
debt only when a measured failure points to that knob.
3. Target:
- at least three of the five receive meaningful developments in a ten-minute developed-world
session when footage exists;
- no forced round-robin texture cuts;
- no more than two unexplained roster replacements per ten minutes;
- no immediate eviction/re-admission loop;
- critical world-event latency remains unchanged;
- combat remains below the existing 35% editorial gate;
- routine background-character cuts are zero;
- exceptional background cuts remain below 10% of authored character cuts unless a
world-critical mass-casualty sequence is active;
- every exceptional background cut has a typed reason and confirmed change/outcome;
- at least one multi-development character thread is intelligible in the cut trace.
4. If the same offices always occupy all five slots, tune diversity/admission rather than adding
slots back.
Exit: the five feel recurring and distinct without narrowing world coverage to five camera targets.
### Phase 7 — Performance gate
1. Run `narrative_scheduler_health` with `hitch_probe` enabled on a developed world.
2. Run a valid ten-minute active soak and audit the exact byte window with the extended soak tool.
3. Compare against Phase 0:
- no regression in maximum director, discovery, or caption slice;
- no increase in scheduler candidate bound;
- no recurring roster slice at or above the probe's 4 ms attribution threshold;
- no new periodic frame-spike pattern at the 3.5-second roster-scan cadence;
- no caption/portrait spike pattern while hovering or scrubbing all five glyphs;
- showing/hiding Connection does not create a new caption relayout spike pattern;
- no unbounded growth in narrative graph counts or compaction time;
- fewer or equal rail rebuilds and portrait binds per minute.
4. Exercise worst cases separately:
- a mature high-population world;
- more than five live preferred/favorite characters;
- five same-species MCs with unique portrait captures;
- rapid crossover events touching several MCs;
- repeated admission challenges that do not cross the margin;
- save/load followed by the first roster rebuild.
5. Treat a threshold failure as a profiling task. Attribute it using the existing director,
discovery, caption, scheduler-part, selector-part, feed-part, roster scan, and soft-refill
timings before changing cadence or budgets.
Exit: the developed-world sample meets the existing hitch thresholds with no regression from the
ten-MC baseline, and no new work scales per frame with world population or durable event count.
### Phase 8 — Viewer-comprehension validation
1. Run deterministic harness coverage.
2. Run a ten-minute organic soak on a developed world.
3. Capture screenshots/video for:
- five full rail slots;
- two same-species MCs;
- active + involved crossover;
- non-MC partner/child/friend/foe with visible Connection;
- non-MC temporary war/disaster context with factual Connection;
- preferred overflow;
- newcomer admission;
- returning MC reintroduction;
- fallen MC Legacy.
4. Review the cut trace without inspecting internal state and write a short account of each MC.
5. The release succeeds only if at least one life can be summarized as a sequence of connected
changes rather than a list of activities.
## Deterministic verification
Add or update these scenarios:
- `saga_roster_cap_five` — six eligible lives produce exactly five visible MCs.
- `saga_cap_shrink_preserves_memory` — former slots retain durable events and Legacy.
- `saga_prefer_overflow_stable` — six or more preferred/favorite lives select the same five on
repeated rebuilds without erasing preferences.
- `saga_preferred_challenger_returns` — an off-roster preferred life can re-enter after meaningful
development.
- `saga_incumbent_order_stable` — heat refresh without material rank crossing does not reorder the
rail.
- `saga_reentry_meaningful_once` — return voice appears once for a new semantic development and not
for texture or rapid cutback.
- `saga_cross_mc_five` — exact participants light Active/Involved glyphs and heat both MCs.
- `saga_displaced_payoff` — a displaced former MC may receive a confirmed consequence shot without
silently becoming an MC again.
- `saga_death_closure_five` — death preserves the final portrait and produces one bounded Legacy
consequence.
- `saga_camera_world_coverage_five` — strangers and world-critical events remain camera-eligible.
- `saga_background_texture_discovery_only` — a non-MC routine event grows no camera eligibility
while remaining observable by admission/story systems.
- `saga_background_exceptional_audition` — a confirmed high-impact turning point receives one
cooled cut without automatically granting MC membership.
- `saga_background_repeats_require_admission` — repeated non-MC developments cannot create an
unofficial sixth MC through camera repetition.
- `saga_fill_prefers_mc_orbit` — fill chooses MC, then confirmed MC Cast, then location/no cut;
never an anonymous routine actor.
- `saga_related_requires_evidence` — proximity, species, and broad participant snapshots do not
classify a character as MC Cast/thread context.
- `saga_connection_matches_eligibility` — every MC-related non-MC cut displays the same anchor MC
and evidence kind that admitted it.
- `saga_connection_relation_priority` — durable partner/kin/friend/foe wording wins over temporary
shared-theater wording.
- `saga_connection_multi_mc_anchor` — the active-thread MC is named while other exact MCs light as
involved.
- `saga_connection_clears_atomically` — the row disappears on MC, unrelated, location, and Saga
hover transitions without showing stale identity for a frame.
- `saga_connection_bounded_layout` — long names remain inside one row at every supported UI scale.
- `saga_exceptional_background_no_fake_connection` — an unrelated exceptional audition shows no
Connection row.
- `saga_disaster_mc_impact` — confirmed disaster harm to an MC becomes a personal consequence after
theater coverage.
- `saga_disaster_background_significance` — severe confirmed impact can audition a background life
and increase story potential.
- `saga_disaster_proximity_not_significance` — nearby unaffected characters receive neither a
personal Beat nor disaster story potential.
- `saga_disaster_representative_bounded` — mass impact selects MC/MC Cast first and at most one
distinct exceptional background representative.
- `saga_five_refill_bounded` — no-op, challenger, and pin-overflow refills remain bounded and do not
start an extra world scan.
- `saga_five_ui_noop_perf` — stable rail frames do not relayout or recapture unchanged portraits.
- `saga_five_perf_health` — hitch probe plus scheduler/roster metrics stay inside the release
thresholds during a directed developed-world sample.
Retain the existing roster diversity, replacement, Prefer, camera-bias, crossover, persistence,
caption, Legacy, scheduler, presentability, scheduler-health, and runtime-compaction regressions.
## Documentation updates at cutover
- Replace “top-10,” “up to 10,” and “ten-slot roster” with the five-MC contract in `README.md`,
`docs/life-saga.md`, and `docs/story-planner.md`.
- Update completed historical plans only with a short supersession note; do not rewrite historical
soak evidence that accurately describes the former ten-character build.
- Resolve `docs/saga-ux-redesign-plan.md` status against the implemented fixed panel and add its
missing named UX scenarios to the release gate.
## Non-goals
- The camera is not restricted to the five.
- Five does not cap Chronicle, graves, durable narrative memory, or the number of lives that may
become MCs over a world's history.
- No lead/supporting sub-tier is added inside the five.
- No fabricated transition, farewell, motive, emotion, or relationship is added to explain roster
scoring.
- No camera exclusivity that hides world-critical events or genuinely exceptional background
turning points.
- No automatic deletion of old Prefer choices.
- This work does not reopen the story-first scheduler cutover or add new event families.
## Definition of done
- Exactly five MCs are visible and camera-biased at once.
- Existing saves and narrative sidecars load without story loss or preference deletion.
- Pin overflow is deterministic and reversible.
- Routine background lives develop off-camera; camera character shifts primarily follow MCs and
confirmed MC Cast/thread context.
- Exceptional background and personal-disaster cuts require typed, telemetry-visible evidence.
- Every MC-related non-MC focus explains its exact MC connection in the camera dossier.
- Normal refill, presentation, and hover work remains bounded and does not introduce a new hitch
pattern.
- The extended soak audit reports performance and editorial health from the same active window.
- Rail order and portraits make the five recognizable.
- Entrances, returns, crossovers, and durable closure are evidence-backed and non-repetitive.
- The camera still covers meaningful strangers and world-scale events.
- Deterministic scenarios and the full existing regression pass.
- A valid organic session produces at least one recountable multi-development character story.

View file

@ -1,6 +1,6 @@
# Life-saga roster
Dynamic top-10 cast of interesting lives (MCs). Owns the dossier rail and soft camera bias.
Dynamic five-character cast of interesting lives (MCs). Owns the dossier rail and camera priority.
Chronicle stays a history book and never drives the camera.
Short combat/love/war arcs stay in [`StoryPlanner`](../IdleSpectator/Story/StoryPlanner.cs) as chapter beats.
Confirmed observations enter through
@ -17,7 +17,7 @@ Beats. [`SagaProse`](../IdleSpectator/Story/SagaProse.cs) owns saga identity, Ca
| Concern | Owner |
|---------|--------|
| Top-10 MCs, diversity, Prefer | `LifeSagaRoster` |
| Five MCs, diversity, Prefer | `LifeSagaRoster` |
| Confirmed event identity and idempotency | `NarrativeEventRecorder` + `NarrativeEventStore` |
| Durable projection/index facade | `LifeSagaMemory` + `NarrativeStoryStore` |
| Versioned per-world persistence | `NarrativePersistence` + `NarrativePersistenceMapper` |
@ -45,9 +45,9 @@ Heat comes from chapter stamps / featured tips and decays with idle time.
Each slot keeps its original admission reason even when its live role later changes.
Hard-arc admissions also retain the arc kind and chapter context that brought the life onto the roster.
## Dynamic top-10
## Dynamic five
- Cap stays 10; order follows Effective = Standing + Heat + pin bonuses.
- Cap is 5; order follows Effective = Standing + Heat + pin bonuses.
- Prefer and WorldBox favorites are hard pins and fully count toward Cap.
- Admission-role units always compete on the periodic scan.
- Ordinary non-notables challenge the Cap through decaying story potential earned from confirmed
@ -78,6 +78,7 @@ Always-on AFK chrome (no Dossier/Saga tabs):
| Row | Source | MC | Nobody |
|-----|--------|----|--------|
| Identity | `CaptionComposer` | `Name · Title` (real roles only; else species/job) | `Name (Species/Job)` |
| Connection | `CameraEligibility` | hidden | MC anchor/relation when this non-MC shot was admitted through MC Cast/thread context |
| Beat | candidate `NarrativePresentationModel` | one complete semantic sentence | one complete semantic sentence |
| Context | muted | spine if live short-arc; else unresolved stake hitch (omits kill/role/founding under love/rest tips; RoleChange that echoes Identity always omitted) | spine only |

View file

@ -0,0 +1,135 @@
# Rare Creatures, Antagonists, and Camera Cleanup
Status: **Implemented; targeted and performance gates pass. Full regression is blocked by the
legacy combat-presentability fixture's nondeterministic pair-to-mass reframe assertions.**
## Goal
Let rare supernatural creatures and evidence-backed recurring antagonists compete for one of the
five MC slots without allowing species identity, incidental combat, or routine background events
to destabilize the ensemble. Keep admission explainable in the dossier/logs and bounded on mature
worlds.
## Phase 1 — Admission diagnostics
- [x] Record visible-slot admission reason, rarity population/tier, and effective score.
- [x] Record antagonist evidence, challenger
score, weakest replaceable MC score, and rejection/replacement reason.
- [x] Include the compact visible-roster explanation in `soak_probe`.
- [x] Include rejected challenger explanations in `soak_probe`.
- [x] Rate-limit diagnostics so observation cannot create log/GC hitches.
Exit: a soak log explains why each visible MC entered and why a notable challenger did not.
## Phase 2 — Rare-creature admission
- [x] Add one authoritative special-creature classifier for dragons, druids, necromancers, evil
mages, and verified equivalent supernatural actor assets.
- [x] Combine a modest special-creature prior with a dynamic population tier: singleton, rare,
uncommon/common.
- [x] Make special/rare creatures admission candidates, but never guaranteed occupants.
- [x] Feed the bounded bonus into challenger priority and roster standing.
- [x] Resolve population once per existing amortized scan/cache cycle, never once per scored actor.
- [x] Expose `RareCreature` and `SpecialCreature` admission reasons.
Exit: a lone dragon or mage enters the challenger pool; a common population receives little or no
rarity benefit and cannot displace a developed MC on identity alone.
## Phase 3 — Recurring-antagonist lane
- [x] Reuse bounded durable-rival evidence keyed by antagonist id and anchor MC id.
- [x] Accept typed hostile encounters, harm to MC cast/city/faction, repeated opposition, major
destruction, and confirmed kills; reject incidental proximity and a single weak fight.
- [x] Make threshold-crossing antagonists admission candidates and emerging-story challengers.
- [x] Combine antagonist and rarity bonuses with the normal replacement margin.
- [x] Remove dead/stale edges through the existing bounded life-memory pruning.
- [x] Expose `RecurringAntagonist` admission reason and exact anchor/evidence.
Exit: O'Blight-like repeated opposition can earn a slot, while one incidental attacker cannot.
## Phase 4 — Exceptional-background policy
- [x] Replace the broad strong-typed-consequence gate with explicit exceptional outcomes.
- [x] Reject unrelated routine birth, hatch, adulthood, sleep, food, reproduction, movement, and
ordinary settlement activity.
- [x] Allow direct MC relevance, first/last special species, major lineage change, disaster
survival/loss, and major political/destructive consequences.
- [x] Log the exact exception rule for every approved background cut.
Exit: routine unrelated background cuts approach zero without hiding important disaster victims or
world-changing events.
## Phase 5 — Relationship/thread accuracy and dossier UI
- [x] Require an exact typed story edge for `McThreadContext`; do not infer it from broad city,
kingdom, cluster, or stale thread proximity.
- [x] Support family, partner, friend, and recurring-opponent evidence; exact event participants
cover typed attacker/victim/rescuer events.
- [x] Show friendly and hostile connection labels in the dossier, including the anchor MC.
- [x] Fall back to exceptional background when no exact MC relationship exists.
Exit: every related non-MC cut has an accurate, visible explanation.
## Phase 6 — Texture pacing
- [x] Track recent low-information texture categories per MC.
- [x] Cool down repeated sleep, laughter, play, caffeine, wandering, and generic lover-search cuts.
- [x] Prefer unresolved developments, relationships, rivalries, and consequences through the
existing story scheduler and variety score.
- [x] Retain occasional quiet re-entry after the category cooldown.
Exit: quiet life remains visible without crowding out story development.
## Phase 7 — Deterministic coverage
- [x] Singleton dragon enters through the real admission path.
- [ ] A larger dragon population receives a smaller bonus.
- [ ] Rare identity alone does not evict a developed MC.
- [x] Repeated conflict creates an antagonist challenger.
- [x] One kill/fight cycle does not create an antagonist.
- [ ] Harm to MC cast creates stronger evidence.
- [ ] Combined rarity/antagonist bonuses respect their cap.
- [x] Routine unrelated hatch is rejected; an explicit singleton-special exception is accepted.
- [ ] Friendly/hostile dossier labels name the correct anchor.
- [ ] Broad or stale faction correlation fails thread attribution.
- [ ] The five-character roster stays stable across repeated scans.
## Phase 8 — Performance and organic soak gate
- [ ] Run the full deterministic regression suite without the legacy combat fixture failure.
- [x] Run scheduler health with `hitch_probe` and an active organic soak audit.
- [x] Compare maximum frame, director, discovery, caption, and scheduler slices
with the pre-change baseline.
- [x] Confirm population work is scan-cached and antagonist work is event-driven/bounded.
- [x] Confirm routine unrelated background share approaches zero, significant antagonists can earn
slots, and roster churn remains controlled.
Exit: editorial acceptance criteria pass with no material performance regression.
## Phase 9 — Long-soak stability tuning
- [x] Remove ordinary-species singleton identity as a standalone admission gate.
- [x] Retain strong singleton standing only for curated special creatures; ordinary singleton
metadata remains available to the dossier but receives only a small standing tie-breaker.
- [x] Protect established MCs with an additional replacement margin after meaningful chapters or
minimum tenure.
- [x] Require undeveloped ordinary challengers to clear an additional replacement margin.
- [x] Apply per-character texture-family cooldowns even when the event is not classified as a
FixedDwell moment beat.
- [x] Extend lover-search to a fifteen-minute per-character cooldown.
- [x] Prevent undeveloped ordinary challengers from displacing established MCs regardless of raw
standing; require a chapter, sufficient story heat, or meaningful scheduler potential.
- [x] Stop a fifth consecutive ordinary combat cut while allowing kills, resolutions,
consequences, and world-critical combat through.
- [ ] Confirm reduced roster churn, no repeated lover-search cuts, and a maximum ordinary combat
run of four in the next long organic soak.
Evidence prompting this phase: the first post-feature long soak successfully promoted evil mage
Roum into a sustained MC story, but produced 26 distinct direct-MC subjects, four repeated
lover-search cuts for Bim Llurr, and a seven-cut combat run. The first stability soak reduced the
normalized churn rate by roughly 37% and capped combat runs at four, but still showed three
lover-search cuts for Yaaahaona and 13 direct MCs in the developed-world portion.
Final deterministic probes confirm the fifteen-minute lover gate and ordinary-challenger evidence
gate. A mature-save instrumented run settled from one 28.5 ms load/warm-up frame to 17.6 ms and
then 16.7 ms windows; scheduler cost was 0.24 ms with 268 developments and 597 character states.

View file

@ -17,7 +17,7 @@ There is **no** rail Commit / hard pin anymore.
- `StoryPlanner.Clear` does **not** wipe the life-saga roster.
- Combat spine Kind comes from sticky/live ensemble scale + `StoryArcKind`, not tip Label prefixes.
Dossier **life-saga rail** is owned by `LifeSagaRoster` / `LifeSagaRail` (up to 10 MCs).
Dossier **life-saga rail** is owned by `LifeSagaRoster` / `LifeSagaRail` (up to 5 MCs).
Click toggles Prefer (soft favorite).
AFK caption is Identity + Beat + Context via `CaptionComposer` (see [life-saga.md](life-saga.md)).
Cast/Legacy depth is rail hover only (`LifeSagaViewController` + `LifeSagaPanel`).

View file

@ -82,6 +82,8 @@ drops = []
bads = []
scenes = []
states = []
hitch_details = []
hitch_summaries = []
first_active_line = -1
last = None
@ -110,6 +112,10 @@ for line_index, ln in enumerate(lines):
first_active_line = line_index
if last is not None and last.get("unit") is None and "unit=" in ln:
last["unit"] = ln.split("unit=", 1)[1].strip()
elif "[HITCH] spike" in ln:
hitch_details.append(ln)
elif "[HITCH] summary" in ln:
hitch_summaries.append(ln)
drop_kinds = Counter()
for d in drops:
@ -206,6 +212,52 @@ print(f"placeholder tips: {placeholder}")
print(f"object falls: {len(veg)}")
print(f"fight tip/focus mismatches: {len(mismatches)}")
print(f"fighting+Sleeping lag: {len(sleep_lag)}")
active_hitches = [h for h in hitch_details if "idle=on" in h]
def metric_max(rows, name):
values = []
pattern = re.compile(rf"\b{re.escape(name)}=([0-9.]+)ms")
for row in rows:
match = pattern.search(row)
if match:
values.append(float(match.group(1)))
return max(values, default=0.0)
summary_spikes = 0
summary_max_dt = 0.0
summary_max_dir = 0.0
summary_max_disc = 0.0
summary_max_cap = 0.0
for row in hitch_summaries:
if "idle=on" not in row:
continue
spike = re.search(r"\bspikes=(\d+)", row)
dt = re.search(r"\bmaxDt=([0-9.]+)ms", row)
direct = re.search(r"\bmaxDir=([0-9.]+)ms", row)
discovery = re.search(r"\bmaxDisc=([0-9.]+)ms", row)
caption = re.search(r"\bmaxCap=([0-9.]+)ms", row)
if spike:
summary_spikes += int(spike.group(1))
if dt:
summary_max_dt = max(summary_max_dt, float(dt.group(1)))
if direct:
summary_max_dir = max(summary_max_dir, float(direct.group(1)))
if discovery:
summary_max_disc = max(summary_max_disc, float(discovery.group(1)))
if caption:
summary_max_cap = max(summary_max_cap, float(caption.group(1)))
print(
"Hitches active: "
f"detail={len(active_hitches)} summary_spikes={summary_spikes} "
f"maxDt={max(summary_max_dt, metric_max(active_hitches, 'dt')):.1f}ms "
f"dir={max(summary_max_dir, metric_max(active_hitches, 'dir')):.1f}ms "
f"disc={max(summary_max_disc, metric_max(active_hitches, 'disc')):.1f}ms "
f"cap={max(summary_max_cap, metric_max(active_hitches, 'cap')):.1f}ms "
f"scan={metric_max(active_hitches, 'scanMs'):.1f}ms "
f"soft={metric_max(active_hitches, 'softMs'):.1f}ms "
f"scheduler={metric_max(active_hitches, 'schedulerMs'):.1f}ms"
)
print()
if buckets:
print("Buckets:")