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
This commit is contained in:
DazedAnon 2026-07-22 15:03:31 -05:00
parent ba58a9ff38
commit 2592e947aa
8 changed files with 672 additions and 8 deletions

View file

@ -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<LifeSagaSlot>(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<LifeSagaSlot>(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<LifeSagaSlot>(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<long>();
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);

View file

@ -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
};
}
/// <summary>
/// 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.
/// </summary>
private static List<HarnessCommand> StoryCrisisBeatsMcAftermath()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// 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.
/// </summary>
private static List<HarnessCommand> StoryPickOpensStrangerArc()
{
return new List<HarnessCommand>
{
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<HarnessCommand> StoryAftermathWar()
{
return new List<HarnessCommand>
@ -3675,6 +3779,66 @@ internal static class HarnessScenarios
};
}
/// <summary>
/// Legacy is turning points, not Cast-echo child soup: war + lineage summary, no raw "Child X".
/// </summary>
private static List<HarnessCommand> SagaLegacyQuality()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>Live roster role rise becomes RoleChange stake; combat is fallback when alone.</summary>
private static List<HarnessCommand> SagaStakeFromRoleLive()
{

View file

@ -1250,7 +1250,8 @@ public static class LifeSagaMemory
}
var usedBuckets = new HashSet<int>();
// 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;
}
/// <summary>Parent-perspective ParentChild facts for this life (excludes mirror child view).</summary>
public static int CountParentedChildren(long unitId)
{
LifeSagaLifeMemory life = Get(unitId);
if (life == null)
{
return 0;
}
var seen = new HashSet<long>();
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;
}
/// <summary>Legacy display line: optional world date · beat prose.</summary>
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<LifeSagaChapter> ChaptersFor(long unitId, int max = 3)
{
var chapters = new List<LifeSagaChapter>(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;
}
}

View file

@ -628,7 +628,21 @@ public static class LifeSagaPresentation
private static void BuildLegacy(LifeSagaViewModel model, long unitId, LifeSagaFact stake)
{
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 5);
var castIds = new HashSet<long>();
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<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 8);
var seenLines = new HashSet<string>(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 = ""
});
}
}
}
/// <summary>
/// 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.
/// </summary>
private static bool CastOwnsLivingRelation(LifeSagaFact fact, HashSet<long> 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)

View file

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

View file

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

View file

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

View file

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