diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 96cb60e..c66222e 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -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(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); diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 07fbe06..aab2c1d 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -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 SagaConnectionDossier() + { + return new List + { + 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 SagaRosterCapFive() + { + return new List + { + 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") + }; + } + /// Prefer toggle soft-biases a near-tie toward the Prefer'd MC. private static List 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"), diff --git a/IdleSpectator/InterestDirector.SwitchPolicy.cs b/IdleSpectator/InterestDirector.SwitchPolicy.cs index 9477f1b..0f41b36 100644 --- a/IdleSpectator/InterestDirector.SwitchPolicy.cs +++ b/IdleSpectator/InterestDirector.SwitchPolicy.cs @@ -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)) { diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index f5c1473..20794fb 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -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 = ""; diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs index 008f206..60b2956 100644 --- a/IdleSpectator/InterestVariety.cs +++ b/IdleSpectator/InterestVariety.cs @@ -45,6 +45,89 @@ public static class InterestVariety /// Harness/debug: tips recorded in the rarity window. 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 /// 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 ""; + } + /// /// 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). diff --git a/IdleSpectator/Narrative/CameraEligibility.cs b/IdleSpectator/Narrative/CameraEligibility.cs new file mode 100644 index 0000000..90fbd12 --- /dev/null +++ b/IdleSpectator/Narrative/CameraEligibility.cs @@ -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 +} + +/// +/// 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. +/// +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 Slots = new List(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 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; + } +} diff --git a/IdleSpectator/Story/CaptionComposer.cs b/IdleSpectator/Story/CaptionComposer.cs index fa3796a..d377faf 100644 --- a/IdleSpectator/Story/CaptionComposer.cs +++ b/IdleSpectator/Story/CaptionComposer.cs @@ -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; diff --git a/IdleSpectator/Story/LifeSagaRoster.cs b/IdleSpectator/Story/LifeSagaRoster.cs index 109382b..535e53a 100644 --- a/IdleSpectator/Story/LifeSagaRoster.cs +++ b/IdleSpectator/Story/LifeSagaRoster.cs @@ -6,12 +6,12 @@ using UnityEngine; namespace IdleSpectator; /// -/// 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 Clear. /// 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; /// Cadence for starting a spread world admission scan. private const float WorldScanIntervalSeconds = 3.5f; @@ -64,6 +71,7 @@ public static class LifeSagaRoster /// Last timed soft refill cost in ms (hitch probe). 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; } + /// + /// Best living current MC for quiet camera grounding. Bounded by the five-slot roster; + /// unlike the world activity scanner this never walks the population. + /// + 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); + /// /// Cheap challenger ordering before BuildSlot - prefer favorites / kings / leaders, then roles. /// @@ -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; /// Live role/standing recomputed each refresh. @@ -2584,6 +2923,9 @@ public enum LifeSagaAdmissionReason Founder, PlotAuthor, Singleton, + RecurringAntagonist, + RareCreature, + SpecialCreature, NotableKills, NotableRenown, NotableLife, diff --git a/IdleSpectator/Story/SagaProse.cs b/IdleSpectator/Story/SagaProse.cs index d461f9f..3d6f4f9 100644 --- a/IdleSpectator/Story/SagaProse.cs +++ b/IdleSpectator/Story/SagaProse.cs @@ -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: diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index ddccee2..000a34c 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -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 /// Harness: composed context line (spine or stake). public static string LastContextLine => CaptionComposer.LastContextLine; + public static string LastConnectionLine { get; private set; } = ""; /// Harness/UI: active context line under the beat (spine or stake), empty when none. 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); + } + /// /// 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(), 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); diff --git a/README.md b/README.md index 6bdd5fd..3163ab7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/five-mc-ensemble-plan.md b/docs/five-mc-ensemble-plan.md new file mode 100644 index 0000000..97828d4 --- /dev/null +++ b/docs/five-mc-ensemble-plan.md @@ -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 · `. +- 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 1–5 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. diff --git a/docs/life-saga.md b/docs/life-saga.md index 0867292..3e1c44f 100644 --- a/docs/life-saga.md +++ b/docs/life-saga.md @@ -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 | diff --git a/docs/rare-creature-antagonist-plan.md b/docs/rare-creature-antagonist-plan.md new file mode 100644 index 0000000..3e5668c --- /dev/null +++ b/docs/rare-creature-antagonist-plan.md @@ -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. diff --git a/docs/story-planner.md b/docs/story-planner.md index a08bfc1..5a6ab7f 100644 --- a/docs/story-planner.md +++ b/docs/story-planner.md @@ -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`). diff --git a/scripts/soak-audit-player-log.sh b/scripts/soak-audit-player-log.sh index 3d8d140..254441e 100755 --- a/scripts/soak-audit-player-log.sh +++ b/scripts/soak-audit-player-log.sh @@ -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:")