From 2592e947aafeaca99d929813d8b9ab641a83ad68 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 22 Jul 2026 15:03:31 -0500 Subject: [PATCH] feat(saga): make Legacy turning points, not child soup - Cap ParentChild, skip Cast-echo kin, and summarize large lineages - Prefer war/bond/role beats with dated prose over raw "Child X" lines - Gate with saga_legacy_quality (3/3) and document the Legacy contract --- IdleSpectator/AgentHarness.cs | 290 ++++++++++++++++++++ IdleSpectator/HarnessScenarios.cs | 164 +++++++++++ IdleSpectator/Story/LifeSagaMemory.cs | 122 +++++++- IdleSpectator/Story/LifeSagaPresentation.cs | 86 +++++- IdleSpectator/mod.json | 2 +- docs/event-catalog-discovery.md | 4 + docs/life-saga.md | 5 + docs/story-planner.md | 7 + 8 files changed, 672 insertions(+), 8 deletions(-) diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index be93d97..a409227 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -3434,6 +3434,11 @@ public static class AgentHarness { kind = LifeSagaFactKind.HardArcCombat; } + else if (kindRaw.IndexOf("parent", StringComparison.OrdinalIgnoreCase) >= 0 + || kindRaw.IndexOf("child", StringComparison.OrdinalIgnoreCase) >= 0) + { + kind = LifeSagaFactKind.ParentChild; + } Actor other = _happinessPartner; if (other == focus) @@ -3500,6 +3505,27 @@ public static class AgentHarness provenance: "harness"); fact = LifeSagaMemory.StrongestUnresolved(EventFeedUtil.SafeId(focus)); } + else if (kind == LifeSagaFactKind.ParentChild) + { + if (other == null || !other.isAlive() || other == focus) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "parent_child_needs_partner"); + break; + } + + LifeSagaMemory.RecordParentChild(focus, other, provenance: "harness"); + fact = null; + var facts = LifeSagaMemory.FactsFor(EventFeedUtil.SafeId(focus)); + for (int i = 0; i < facts.Count; i++) + { + if (facts[i] != null && facts[i].Kind == LifeSagaFactKind.ParentChild) + { + fact = facts[i]; + break; + } + } + } else { fact = LifeSagaMemory.Record( @@ -9382,6 +9408,270 @@ public static class AgentHarness detail = $"cast='{sample}' any='{any}' pass={pass}"; break; } + case "saga_legacy_contains": + { + long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); + if (id == 0) + { + var board = new System.Collections.Generic.List(LifeSagaRoster.Cap); + LifeSagaRoster.CopySlots(board); + id = board.Count > 0 && board[0] != null ? board[0].UnitId : 0; + } + + LifeSagaViewModel model = LifeSagaPresentation.Build(id); + string any = (cmd.value ?? "").Trim(); + pass = false; + string sample = ""; + if (model != null) + { + for (int i = 0; i < model.Legacy.Count; i++) + { + LifeSagaLegacyBeat b = model.Legacy[i]; + if (b == null || string.IsNullOrEmpty(b.Line)) + { + continue; + } + + if (sample.Length > 0) + { + sample += " | "; + } + + sample += b.Line; + if (!string.IsNullOrEmpty(any) + && b.Line.IndexOf(any, StringComparison.OrdinalIgnoreCase) >= 0) + { + pass = true; + } + } + + if (!pass && !string.IsNullOrEmpty(any)) + { + string[] parts = any.Split('|'); + for (int i = 0; i < parts.Length && !pass; i++) + { + string needle = (parts[i] ?? "").Trim(); + if (needle.Length == 0) + { + continue; + } + + for (int j = 0; j < model.Legacy.Count; j++) + { + LifeSagaLegacyBeat b = model.Legacy[j]; + if (b == null || string.IsNullOrEmpty(b.Line)) + { + continue; + } + + if (b.Line.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) + { + pass = true; + break; + } + } + } + } + } + + detail = $"legacy='{sample}' any='{any}' pass={pass}"; + break; + } + case "saga_legacy_not": + { + long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); + if (id == 0) + { + var board = new System.Collections.Generic.List(LifeSagaRoster.Cap); + LifeSagaRoster.CopySlots(board); + id = board.Count > 0 && board[0] != null ? board[0].UnitId : 0; + } + + LifeSagaViewModel model = LifeSagaPresentation.Build(id); + // Preserve trailing spaces on needles (e.g. "Child " must not match "children"). + string banned = cmd.value ?? ""; + pass = true; + string sample = ""; + string hit = ""; + if (model != null && !string.IsNullOrWhiteSpace(banned)) + { + string[] parts = banned.Split('|'); + for (int i = 0; i < model.Legacy.Count; i++) + { + LifeSagaLegacyBeat b = model.Legacy[i]; + if (b == null || string.IsNullOrEmpty(b.Line)) + { + continue; + } + + if (sample.Length > 0) + { + sample += " | "; + } + + sample += b.Line; + string line = b.Line.Trim(); + int sep = line.IndexOf(" · ", StringComparison.Ordinal); + string beat = sep >= 0 ? line.Substring(sep + 3).Trim() : line; + for (int p = 0; p < parts.Length; p++) + { + string needle = parts[p] ?? ""; + if (string.IsNullOrWhiteSpace(needle)) + { + continue; + } + + // Trim only leading whitespace; trailing spaces are intentional prefixes. + needle = needle.TrimStart(); + bool prefix = needle.Length > 0 && needle[needle.Length - 1] == ' '; + bool matched = prefix + ? beat.StartsWith(needle, StringComparison.OrdinalIgnoreCase) + : beat.IndexOf(needle.Trim(), StringComparison.OrdinalIgnoreCase) >= 0; + if (matched) + { + pass = false; + hit = needle; + break; + } + } + } + } + + detail = $"legacy='{sample}' banned='{banned}' hit='{hit}' pass={pass}"; + break; + } + case "saga_legacy_quality": + { + long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); + if (id == 0) + { + var board = new System.Collections.Generic.List(LifeSagaRoster.Cap); + LifeSagaRoster.CopySlots(board); + id = board.Count > 0 && board[0] != null ? board[0].UnitId : 0; + } + + LifeSagaViewModel model = LifeSagaPresentation.Build(id); + var castIds = new HashSet(); + int parentChildLines = 0; + int namedChildLines = 0; + bool hasRawChildLabel = false; + bool hasFamilySummary = false; + bool hasTurningPoint = false; + string sample = ""; + if (model != null) + { + for (int i = 0; i < model.Cast.Count; i++) + { + if (model.Cast[i] != null && model.Cast[i].UnitId != 0) + { + castIds.Add(model.Cast[i].UnitId); + } + } + + for (int i = 0; i < model.Legacy.Count; i++) + { + LifeSagaLegacyBeat b = model.Legacy[i]; + if (b == null || string.IsNullOrEmpty(b.Line)) + { + continue; + } + + if (sample.Length > 0) + { + sample += " | "; + } + + sample += b.Line; + string line = b.Line.Trim(); + // Strip optional date prefix "Month Year · ". + int sep = line.IndexOf(" · ", StringComparison.Ordinal); + string beat = sep >= 0 ? line.Substring(sep + 3).Trim() : line; + + if (beat.StartsWith("Child ", StringComparison.OrdinalIgnoreCase) + && !beat.StartsWith("Children", StringComparison.OrdinalIgnoreCase)) + { + hasRawChildLabel = true; + } + + if (b.Kind == LifeSagaFactKind.ParentChild + || beat.StartsWith("Had child", StringComparison.OrdinalIgnoreCase) + || beat.StartsWith("Had a child", StringComparison.OrdinalIgnoreCase) + || beat.IndexOf(" children", StringComparison.OrdinalIgnoreCase) >= 0) + { + parentChildLines++; + if (beat.StartsWith("Had child ", StringComparison.OrdinalIgnoreCase)) + { + namedChildLines++; + } + + if (beat.IndexOf(" children", StringComparison.OrdinalIgnoreCase) >= 0 + || string.Equals(beat, "Had a child", StringComparison.OrdinalIgnoreCase)) + { + hasFamilySummary = true; + } + } + + if (b.Kind == LifeSagaFactKind.WarJoin + || b.Kind == LifeSagaFactKind.WarEnd + || b.Kind == LifeSagaFactKind.Kill + || b.Kind == LifeSagaFactKind.RivalEarned + || b.Kind == LifeSagaFactKind.BondFormed + || b.Kind == LifeSagaFactKind.Founding + || b.Kind == LifeSagaFactKind.RoleChange + || b.Kind == LifeSagaFactKind.PlotJoin + || b.Kind == LifeSagaFactKind.HardArcCombat + || b.Kind == LifeSagaFactKind.HardArcWar + || beat.StartsWith("War", StringComparison.OrdinalIgnoreCase) + || beat.StartsWith("Bound", StringComparison.OrdinalIgnoreCase) + || beat.StartsWith("Slew", StringComparison.OrdinalIgnoreCase) + || beat.StartsWith("Founded", StringComparison.OrdinalIgnoreCase) + || beat.StartsWith("Became", StringComparison.OrdinalIgnoreCase)) + { + hasTurningPoint = true; + } + } + } + + int parented = LifeSagaMemory.CountParentedChildren(id); + bool parentCapOk = parentChildLines <= 1; + bool proseOk = !hasRawChildLabel; + bool summaryOk = parented < 3 || hasFamilySummary || namedChildLines == 0; + if (parented >= 3) + { + summaryOk = hasFamilySummary && namedChildLines == 0; + } + + // When memory has a non-family turning point, Legacy must surface at least one. + bool memoryHasTurning = false; + var memFacts = LifeSagaMemory.FactsFor(id); + for (int i = 0; i < memFacts.Count; i++) + { + LifeSagaFact f = memFacts[i]; + if (f == null) + { + continue; + } + + if (f.Kind == LifeSagaFactKind.WarJoin + || f.Kind == LifeSagaFactKind.Kill + || f.Kind == LifeSagaFactKind.BondFormed + || f.Kind == LifeSagaFactKind.Founding + || f.Kind == LifeSagaFactKind.RivalEarned + || f.Kind == LifeSagaFactKind.PlotJoin) + { + memoryHasTurning = true; + break; + } + } + + bool turningOk = !memoryHasTurning || hasTurningPoint; + pass = parentCapOk && proseOk && summaryOk && turningOk; + detail = + $"legacy='{sample}' parented={parented} parentLines={parentChildLines} named={namedChildLines} " + + $"rawChild={hasRawChildLabel} summary={hasFamilySummary} turning={hasTurningPoint} " + + $"memTurning={memoryHasTurning} cast={castIds.Count} pass={pass}"; + break; + } case "saga_memory_life_count": { int want = ParseInt(cmd.value, 1); diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index be61c48..6056f21 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -77,6 +77,12 @@ internal static class HarnessScenarios return StoryRecoverFollowMc(); case "story_park_trim_keeps_mc": return StoryParkTrimKeepsMc(); + case "story_crisis_beats_mc_aftermath": + case "crisis_beats_mc_aftermath": + return StoryCrisisBeatsMcAftermath(); + case "story_pick_opens_stranger_arc": + case "pick_opens_stranger_arc": + return StoryPickOpensStrangerArc(); case "story_aftermath_cast_noise": case "soft_duel_aftermath_noise": return StoryAftermathCastNoise(); @@ -196,6 +202,8 @@ internal static class HarnessScenarios return SagaCastKinNonPackLens(); case "saga_stake_from_role_live": return SagaStakeFromRoleLive(); + case "saga_legacy_quality": + return SagaLegacyQuality(); case "saga_session_reset": return SagaSessionReset(); case "saga_hover_read_pause": @@ -376,6 +384,8 @@ internal static class HarnessScenarios Nested("reg_story_aftermath_mc", "story_aftermath_prefers_mc"), Nested("reg_story_recover_mc", "story_recover_follow_mc"), Nested("reg_story_park_trim_mc", "story_park_trim_keeps_mc"), + Nested("reg_story_crisis_beats_mc", "story_crisis_beats_mc_aftermath"), + Nested("reg_story_pick_stranger", "story_pick_opens_stranger_arc"), Nested("reg_story_aftermath_noise", "story_aftermath_cast_noise"), Nested("reg_story_aftermath_partner_truth", "story_aftermath_partner_truth"), Nested("reg_reason_duel_principal", "reason_duel_principal"), @@ -419,6 +429,7 @@ internal static class HarnessScenarios Nested("reg_saga_plot_cast", "saga_plot_cast_members"), Nested("reg_saga_cast_kin", "saga_cast_kin_non_pack_lens"), Nested("reg_saga_stake_role", "saga_stake_from_role_live"), + Nested("reg_saga_legacy_quality", "saga_legacy_quality"), Nested("reg_saga_session_reset", "saga_session_reset"), Nested("reg_saga_hover_pause", "saga_hover_read_pause"), Nested("reg_saga_diversity", "saga_roster_diversity"), @@ -1896,6 +1907,99 @@ internal static class HarnessScenarios }; } + /// + /// Non-goal gate: crisis ladder still beats Prefer/MC short-arc aftermath. + /// War crisis tip must cut a live MC aftermath; aftermath must not reclaim the camera. + /// + private static List StoryCrisisBeatsMcAftermath() + { + return new List + { + Step("scm0", "dismiss_windows"), + Step("scm1", "wait_world"), + Step("scm2", "set_setting", expect: "enabled", value: "true"), + Step("scm3", "fast_timing", value: "true"), + Step("scm4", "spawn", asset: "human", count: 2), + Step("scm5", "spawn", asset: "wolf", count: 1), + Step("scm6", "spectator", value: "off"), + Step("scm7", "spectator", value: "on"), + Step("scm8", "pick_unit", asset: "human"), + Step("scm9", "focus", asset: "human"), + Step("scm10", "interest_end_session"), + Step("scm11", "interest_story_clear"), + Step("scm12", "interest_saga_clear"), + Step("scm13", "saga_force_admit_focus"), + Step("scm14", "assert", expect: "saga_has_focus"), + Step("scm15", "interest_expire_pending", value: ""), + Step("scm20", "interest_combat_session", asset: "human", value: "wolf", expect: "scm_pack"), + Step("scm21", "combat_isolate_pair", value: "20"), + Step("scm22", "status_apply", asset: "human", value: "invincible"), + Step("scm23", "combat_kill_related"), + Step("scm24", "age_current", wait: 10f), + Step("scm25", "interest_mark_combat_cold"), + Step("scm26", "director_run", wait: 1.5f), + Step("scm27", "assert", expect: "tip_asset", value: "aftermath_"), + Step("scm28", "assert", expect: "story_phase", value: "Aftermath"), + Step("scm29", "assert", expect: "saga_has_focus"), + // Crisis theater: war tip + forced chapter must cut MC aftermath. + Step("scm30", "interest_expire_pending", value: ""), + Step("scm31", "interest_war_session", asset: "human", expect: "scm_war"), + Step("scm32", "director_run", wait: 0.6f), + Step("scm33", "assert", expect: "tip_matches_any", value: "War -"), + Step("scm34", "interest_crisis_begin"), + Step("scm35", "assert", expect: "crisis_active", value: "true"), + Step("scm36", "assert", expect: "crisis_kind", value: "War"), + // Hot non-crisis peer must not reclaim while the war crisis is live. + Step("scm40", "interest_inject", asset: "human", + label: "MCAftermathNoise", tier: "Action", expect: "scm_after", + value: "lead=event;evt=90;char=20;force=false;ttl=60"), + Step("scm41", "director_run", wait: 1.2f), + Step("scm42", "assert", expect: "tip_matches_any", value: "War -"), + Step("scm43", "assert", expect: "tip_not_contains", value: "MCAftermathNoise"), + Step("scm44", "assert", expect: "crisis_active", value: "true"), + Step("scm90", "fast_timing", value: "false"), + Step("scm99", "snapshot"), + }; + } + + /// + /// Non-goal gate: Pick still opens short arcs for strangers (not Prefer/MC-only). + /// Empty roster + duel climax must advance StoryPlanner without saga admit first. + /// + private static List StoryPickOpensStrangerArc() + { + return new List + { + Step("spo0", "dismiss_windows"), + Step("spo1", "wait_world"), + Step("spo2", "set_setting", expect: "enabled", value: "true"), + Step("spo3", "fast_timing", value: "true"), + Step("spo4", "spawn", asset: "human", count: 1), + Step("spo5", "spawn", asset: "wolf", count: 1), + Step("spo6", "spectator", value: "off"), + Step("spo7", "spectator", value: "on"), + Step("spo8", "interest_end_session"), + Step("spo9", "interest_story_clear"), + Step("spo10", "interest_saga_clear"), + Step("spo11", "assert", expect: "saga_roster_count", value: "0"), + Step("spo12", "pick_unit", asset: "human"), + Step("spo13", "focus", asset: "human"), + // Deliberately no saga_force_admit / Prefer - stranger must still open the arc. + Step("spo14", "interest_expire_pending", value: ""), + Step("spo20", "interest_combat_session", asset: "human", value: "wolf", expect: "spo_pack"), + Step("spo21", "combat_isolate_pair", value: "20"), + Step("spo22", "status_apply", asset: "human", value: "invincible"), + Step("spo23", "status_apply", asset: "wolf", value: "invincible"), + Step("spo24", "director_run", wait: 0.8f), + Step("spo25", "assert", expect: "tip_matches_any", value: "Duel -"), + Step("spo26", "assert", expect: "story_phase", value: "Climax"), + // Hard-arc stamp may admit the fighter onto Cap; Prefer must still be empty. + Step("spo27", "assert", expect: "saga_prefer_count", value: "0"), + Step("spo90", "fast_timing", value: "false"), + Step("spo99", "snapshot"), + }; + } + private static List StoryAftermathWar() { return new List @@ -3675,6 +3779,66 @@ internal static class HarnessScenarios }; } + /// + /// Legacy is turning points, not Cast-echo child soup: war + lineage summary, no raw "Child X". + /// + private static List SagaLegacyQuality() + { + return new List + { + Step("slq0", "dismiss_windows"), + Step("slq1", "wait_world"), + Step("slq2", "set_setting", expect: "enabled", value: "true"), + Step("slq3", "fast_timing", value: "true"), + // Parent (MC) + lover from initial spawn; children and foe are spawned fresh below. + Step("slq4", "spawn", asset: "human", count: 2), + Step("slq5", "spectator", value: "off"), + Step("slq6", "spectator", value: "on"), + Step("slq7", "interest_saga_clear"), + Step("slq8", "pick_unit", asset: "human"), + Step("slq9", "focus", asset: "human"), + Step("slq10", "saga_force_admit_focus"), + Step("slq11", "saga_role_rise_focus", value: "become_king"), + // Keep parent as remembered partner so focus_partner always returns to the MC. + Step("slq12", "happiness_remember_partner"), + // Lover (focus other, partner = parent). + Step("slq13", "pick_unit", asset: "human", value: "other"), + Step("slq14", "focus", asset: "human"), + Step("slq15", "happiness_bond_lovers"), + Step("slq16", "focus_partner"), + // Three fresh children: spawn → pick last spawn → setParent1(parent). + Step("slq17", "spawn", asset: "human", count: 1), + Step("slq18", "pick_unit", asset: "human"), + Step("slq19", "relationship_set_parent"), + Step("slq20", "focus_partner"), + Step("slq21", "spawn", asset: "human", count: 1), + Step("slq22", "pick_unit", asset: "human"), + Step("slq23", "relationship_set_parent"), + Step("slq24", "focus_partner"), + Step("slq25", "spawn", asset: "human", count: 1), + Step("slq26", "pick_unit", asset: "human"), + Step("slq27", "relationship_set_parent"), + Step("slq28", "focus_partner"), + // War foe: fresh spawn; harness stamps WarJoin onto remembered parent too. + Step("slq29", "spawn", asset: "human", count: 1), + Step("slq30", "pick_unit", asset: "human"), + Step("slq31", "saga_memory_record_focus", asset: "War", value: "North vs South", + expect: "harness_war_legacy", label: "North"), + // Resolve so Crown/Role stays At stake and War lands in Legacy as a closed beat. + Step("slq31b", "saga_memory_resolve_war", value: "harness_war_legacy"), + Step("slq32", "focus_partner"), + Step("slq33", "saga_select_tab", value: "saga"), + Step("slq34", "assert", expect: "saga_stake_contains", value: "Crown|kingdom|Became king"), + Step("slq35", "assert", expect: "saga_legacy_contains", value: "War"), + Step("slq36", "assert", expect: "saga_legacy_contains", value: "children"), + // saga_legacy_quality already rejects raw "Child Name" prose (and "children" must not trip it). + Step("slq38", "assert", expect: "saga_legacy_quality"), + Step("slq39", "screenshot", value: "saga-legacy-quality"), + Step("slq90", "fast_timing", value: "false"), + Step("slq99", "snapshot"), + }; + } + /// Live roster role rise becomes RoleChange stake; combat is fallback when alone. private static List SagaStakeFromRoleLive() { diff --git a/IdleSpectator/Story/LifeSagaMemory.cs b/IdleSpectator/Story/LifeSagaMemory.cs index a66a4a8..e5936eb 100644 --- a/IdleSpectator/Story/LifeSagaMemory.cs +++ b/IdleSpectator/Story/LifeSagaMemory.cs @@ -1250,7 +1250,8 @@ public static class LifeSagaMemory } var usedBuckets = new HashSet(); - // Prefer diversity across turning-point buckets. + int parentChildKept = 0; + // Prefer diversity across turning-point buckets; never flood Legacy with births. for (int pass = 0; pass < 2 && result.Count < max; pass++) { for (int i = 0; i < life.Facts.Count && result.Count < max; i++) @@ -1272,14 +1273,117 @@ public static class LifeSagaMemory continue; } + if (f.Kind == LifeSagaFactKind.ParentChild) + { + if (parentChildKept >= 1) + { + continue; + } + } + result.Add(f); usedBuckets.Add(bucket); + if (f.Kind == LifeSagaFactKind.ParentChild) + { + parentChildKept++; + } } } return result; } + /// Parent-perspective ParentChild facts for this life (excludes mirror child view). + public static int CountParentedChildren(long unitId) + { + LifeSagaLifeMemory life = Get(unitId); + if (life == null) + { + return 0; + } + + var seen = new HashSet(); + for (int i = 0; i < life.Facts.Count; i++) + { + LifeSagaFact f = life.Facts[i]; + if (f == null || f.Kind != LifeSagaFactKind.ParentChild || !IsParentPerspective(f)) + { + continue; + } + + long childId = f.OtherId; + if (childId == 0) + { + childId = f.Other.Id; + } + + if (childId != 0) + { + seen.Add(childId); + } + else if (!string.IsNullOrEmpty(f.Other.Name)) + { + seen.Add(-(i + 1)); + } + } + + return seen.Count; + } + + /// Legacy display line: optional world date · beat prose. + public static string FormatLegacyLine(LifeSagaFact fact) + { + string line = FormatFactLine(fact); + if (string.IsNullOrEmpty(line)) + { + return ""; + } + + if (!string.IsNullOrEmpty(fact.DateLabel)) + { + return fact.DateLabel + " · " + line; + } + + return line; + } + + public static string FormatFamilySummary(int childCount) + { + if (childCount <= 0) + { + return ""; + } + + if (childCount == 1) + { + return "Had a child"; + } + + return "Had " + childCount + " children"; + } + + public static bool IsParentPerspective(LifeSagaFact fact) + { + if (fact == null) + { + return false; + } + + string key = fact.Key ?? ""; + if (key.EndsWith(":mirror", StringComparison.Ordinal)) + { + return false; + } + + if (key.StartsWith("parent:" + fact.SubjectId + ":", StringComparison.Ordinal)) + { + return true; + } + + // Harness / non-keyed parent records default to parent voice for the subject life. + return !key.Contains(":mirror"); + } + public static List ChaptersFor(long unitId, int max = 3) { var chapters = new List(max); @@ -1290,7 +1394,7 @@ public static class LifeSagaMemory chapters.Add(new LifeSagaChapter { Kind = ToChapterKind(f.Kind), - Line = FormatFactLine(f), + Line = FormatLegacyLine(f), At = f.At }); } @@ -1322,7 +1426,12 @@ public static class LifeSagaMemory case LifeSagaFactKind.FriendFormed: return string.IsNullOrEmpty(other) ? "Made a true friend" : "Befriended " + other; case LifeSagaFactKind.ParentChild: - return string.IsNullOrEmpty(other) ? "A child was born" : "Child " + other; + if (!IsParentPerspective(fact)) + { + return string.IsNullOrEmpty(other) ? "Was born" : "Born to " + other; + } + + return string.IsNullOrEmpty(other) ? "Had a child" : "Had child " + other; case LifeSagaFactKind.Kill: if (fact.McCrossover) { @@ -1827,7 +1936,6 @@ public static class LifeSagaMemory return 0; case LifeSagaFactKind.BondFormed: case LifeSagaFactKind.FriendFormed: - case LifeSagaFactKind.ParentChild: case LifeSagaFactKind.HardArcLove: return 1; case LifeSagaFactKind.RoleChange: @@ -1848,8 +1956,12 @@ public static class LifeSagaMemory case LifeSagaFactKind.Death: case LifeSagaFactKind.HardArcGrief: return 4; - default: + case LifeSagaFactKind.ParentChild: + // Own bucket so births do not block Bond on the diversity pass, + // and so pass-1 fill cannot turn Legacy into child soup. return 5; + default: + return 6; } } diff --git a/IdleSpectator/Story/LifeSagaPresentation.cs b/IdleSpectator/Story/LifeSagaPresentation.cs index 285dd71..544cb5e 100644 --- a/IdleSpectator/Story/LifeSagaPresentation.cs +++ b/IdleSpectator/Story/LifeSagaPresentation.cs @@ -628,7 +628,21 @@ public static class LifeSagaPresentation private static void BuildLegacy(LifeSagaViewModel model, long unitId, LifeSagaFact stake) { - List facts = LifeSagaMemory.SelectLegacy(unitId, 5); + var castIds = new HashSet(); + for (int i = 0; i < model.Cast.Count; i++) + { + if (model.Cast[i] != null && model.Cast[i].UnitId != 0) + { + castIds.Add(model.Cast[i].UnitId); + } + } + + int parentedCount = LifeSagaMemory.CountParentedChildren(unitId); + // Many children: prefer one lineage summary over named birth crumbs. + bool preferFamilySummary = parentedCount >= 3; + bool wroteParentChild = false; + + List facts = LifeSagaMemory.SelectLegacy(unitId, 8); var seenLines = new HashSet(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < facts.Count; i++) { @@ -647,7 +661,21 @@ public static class LifeSagaPresentation continue; } - string line = LifeSagaMemory.FormatFactLine(f); + // Living Cast already owns present relationships - Legacy is turning points. + if (CastOwnsLivingRelation(f, castIds)) + { + continue; + } + + if (f.Kind == LifeSagaFactKind.ParentChild) + { + if (preferFamilySummary || wroteParentChild) + { + continue; + } + } + + string line = LifeSagaMemory.FormatLegacyLine(f); if (string.IsNullOrEmpty(line) || string.Equals(line, model.StakeLine, StringComparison.OrdinalIgnoreCase) || !seenLines.Add(line)) @@ -664,11 +692,65 @@ public static class LifeSagaPresentation DateLabel = f.DateLabel ?? "" }); + if (f.Kind == LifeSagaFactKind.ParentChild) + { + wroteParentChild = true; + } + if (model.Legacy.Count >= 4) { break; } } + + if (!wroteParentChild + && parentedCount >= 2 + && model.Legacy.Count < 4) + { + string summary = LifeSagaMemory.FormatFamilySummary(parentedCount); + if (!string.IsNullOrEmpty(summary) + && !string.Equals(summary, model.StakeLine, StringComparison.OrdinalIgnoreCase) + && seenLines.Add(summary)) + { + model.Legacy.Add(new LifeSagaLegacyBeat + { + Kind = LifeSagaFactKind.ParentChild, + Line = summary, + Truth = "observed", + At = 0f, + DateLabel = "" + }); + } + } + } + + /// + /// Living Cast already presents lover/friend/kin - do not echo those names as Legacy crumbs. + /// Dead / past relations and wars/plots/kills still belong in Legacy. + /// + private static bool CastOwnsLivingRelation(LifeSagaFact fact, HashSet castIds) + { + if (fact == null || castIds == null || castIds.Count == 0) + { + return false; + } + + long otherId = fact.OtherId != 0 ? fact.OtherId : fact.Other.Id; + if (otherId == 0 || !castIds.Contains(otherId)) + { + return false; + } + + switch (fact.Kind) + { + case LifeSagaFactKind.BondFormed: + case LifeSagaFactKind.FriendFormed: + case LifeSagaFactKind.ParentChild: + // Only skip when the cast member is still alive (Cast shows present faces). + return EventFeedUtil.FindAliveById(otherId) != null; + default: + return false; + } } private static string KingdomName(Actor actor) diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 559d139..aa14e38 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.29.74", + "version": "0.29.76", "description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.", "GUID": "com.dazed.idlespectator" } diff --git a/docs/event-catalog-discovery.md b/docs/event-catalog-discovery.md index ed2b530..57fbf6d 100644 --- a/docs/event-catalog-discovery.md +++ b/docs/event-catalog-discovery.md @@ -144,6 +144,10 @@ WorldLog `MakeLabel` prefers `world_log_library` translated names, else humanize Remaining work is playtest-driven Signal A / weak decision-WorldLog prose only where soak or camera-A inventory still shows awkward phrases - not mass new JSON libraries. +Status check (2026-07-22): live `library_asset_labels_ok` / `camera_a_inventory_ok` dumps show +**0** snake_case displays and **0** camera-A rows with raw-id displays. No mass prose pass needed +until a soak/camera tip shows a new awkward class. + Previous gap notes (historical): `EventReason` / discrete `MakeLabel` used to replace `{id}` with the raw asset id. diff --git a/docs/life-saga.md b/docs/life-saga.md index 50836a8..9322180 100644 --- a/docs/life-saga.md +++ b/docs/life-saga.md @@ -76,6 +76,10 @@ Hard-arc admissions also retain the arc kind and chapter context that brought th - Saga body is a story card: lens eyebrow + name + title beside the portrait; stake, Cast, and Legacy wrap full-width under it. - No Evidence stats dump (kills/realm live on Dossier). No Open Lore button (press L). - Body height sizes to content up to a cap; Legacy beats stack as separate lines (no · soup). +- Legacy prefers turning points (war, bond, role, kill, founding) over Cast-echo kin crumbs. +- ParentChild is capped (one named beat, or a lineage summary when there are 3+ children). +- Living Cast lover/friend/kin are not repeated as Legacy relationship lines. +- Legacy lines may prefix the world date (`Month Year · beat`). - Dossier story spine (`Kind · Phase`) is hidden when the orange reason already names the same kind and phase is Climax. - Never show live task/status/trait rows or call a live attack target a rival. @@ -153,6 +157,7 @@ Quiet can refresh briefly when a soft crumb ends (bounded). - `saga_plot_cast_members` - `saga_cast_kin_non_pack_lens` - `saga_stake_from_role_live` +- `saga_legacy_quality` - `saga_session_reset` - `saga_hover_read_pause` - `saga_replace_on_death` diff --git a/docs/story-planner.md b/docs/story-planner.md index 748ce15..9dccc64 100644 --- a/docs/story-planner.md +++ b/docs/story-planner.md @@ -197,6 +197,11 @@ Catalog policy ids live under `event-catalog.json` → `story` | `story_aftermath_combat` | duel cold → aftermath tip | | `story_aftermath_partner_truth` | aftermath never names living lover as fallen | | `story_aftermath_cast_noise` | hatch noise pending still gets aftermath | +| `story_aftermath_prefers_mc` | aftermath follow prefers roster MC | +| `story_recover_follow_mc` | cast recovery prefers living MC | +| `story_park_trim_keeps_mc` | parked trim keeps Prefer/MC arcs over scraps | +| `story_crisis_beats_mc_aftermath` | war crisis cuts MC aftermath; hot peer cannot reclaim | +| `story_pick_opens_stranger_arc` | empty Prefer roster still opens Duel climax (non-goal) | | `reason_duel_principal` | bystander Follow cannot own duel reason | | `reason_life_principal` | Follow hijack cannot keep intimacy Label | | `story_intimacy_cool` | just_kissed cools; peer wins next pick | @@ -226,6 +231,8 @@ Catalog policy ids live under `event-catalog.json` → `story` ./scripts/harness-run.sh --repeat 3 story_aftermath_combat ./scripts/harness-run.sh --repeat 3 story_aftermath_partner_truth ./scripts/harness-run.sh --repeat 3 story_aftermath_cast_noise +./scripts/harness-run.sh --repeat 3 story_crisis_beats_mc_aftermath +./scripts/harness-run.sh --repeat 3 story_pick_opens_stranger_arc ./scripts/harness-run.sh --repeat 3 reason_duel_principal ./scripts/harness-run.sh --repeat 3 story_near_tie ./scripts/harness-run.sh --repeat 3 story_arc_combat