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:
parent
ba58a9ff38
commit
2592e947aa
8 changed files with 672 additions and 8 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue