fix(spectator): cool soft life crumbs and keep aftermath victim truth

- Soft-cool kill, courtship, intent, construction, status fx, hatch, rest, and worldlog meta
- Block cooled tips from resuming after a cut-in
- Name only dead theater partners in stands-over aftermath
- Add harness gates through 0.28.54
This commit is contained in:
DazedAnon 2026-07-21 16:45:12 -05:00
parent 7e02356b01
commit 9a059c17dd
19 changed files with 3765 additions and 125 deletions

View file

@ -980,6 +980,53 @@ public static class AgentHarness
break;
}
case "decision_emit":
{
// Live DeferredInterestFeed path (same as setDecisionCooldown patch).
Actor focus = ResolveUnit(
string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
if (focus == null)
{
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
}
string decisionId = (cmd.expect ?? cmd.value ?? "sexual_reproduction_try").Trim();
bool alive = false;
try
{
alive = focus != null && focus.isAlive();
}
catch
{
alive = false;
}
if (!alive || string.IsNullOrEmpty(decisionId))
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no unit or decision id");
break;
}
bool prevForce = ForceLiveEventFeeds;
ForceLiveEventFeeds = true;
try
{
DeferredInterestFeed.EmitDecision(decisionId, focus);
_cmdOk++;
Emit(
cmd,
ok: true,
detail: $"emitted decision={decisionId} unit={SafeName(focus)}");
}
finally
{
ForceLiveEventFeeds = prevForce;
}
break;
}
case "status_clear_all":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
@ -2714,6 +2761,10 @@ public static class AgentHarness
DoCombatWireAttackSides(cmd);
break;
case "combat_wipe_sticky_side":
DoCombatWipeStickySide(cmd);
break;
case "combat_swap_attack_target":
DoCombatSwapAttackTarget(cmd);
break;
@ -2726,12 +2777,22 @@ public static class AgentHarness
DoCombatParkBystanderFollow(cmd);
break;
case "interest_hijack_follow":
DoInterestHijackFollow(cmd);
break;
case "interest_variety_clear":
InterestVariety.Clear();
_cmdOk++;
Emit(cmd, ok: true, detail: "variety_cleared");
break;
case "interest_story_clear":
StoryPlanner.Clear();
_cmdOk++;
Emit(cmd, ok: true, detail: "story_cleared");
break;
case "interest_variety_note":
{
// Stamp the current (or pending needle in value) as the last shown variety arc.
@ -2941,6 +3002,31 @@ public static class AgentHarness
break;
}
case "interest_drop_follow":
{
// Null Follow only - keep SubjectId/Related for story cast recovery tests.
InterestCandidate scene = InterestDirector.CurrentCandidate;
if (scene == null)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no_scene");
break;
}
long keepSubject = scene.SubjectId != 0
? scene.SubjectId
: EventFeedUtil.SafeId(scene.FollowUnit);
scene.FollowUnit = null;
if (scene.SubjectId == 0 && keepSubject != 0)
{
scene.SubjectId = keepSubject;
}
_cmdOk++;
Emit(cmd, ok: true, detail: $"dropped_follow subjectId={scene.SubjectId} relatedId={scene.RelatedId}");
break;
}
case "interest_clear_follow":
{
Actor related = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
@ -4157,13 +4243,7 @@ public static class AgentHarness
InterestScoring.RecalcTotal(c);
}
if (!string.IsNullOrEmpty(cmd.expect)
&& (cmd.expect.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0
|| cmd.expect.IndexOf("set_lover", StringComparison.OrdinalIgnoreCase) >= 0))
{
c.AssetId = "set_lover";
c.Category = "Relationship";
}
ApplyHarnessRelationshipAssetHint(c, cmd.expect);
if (force)
{
@ -4177,6 +4257,163 @@ public static class AgentHarness
detail: $"injected key={c.Key} score={c.TotalScore:0.#} lead={c.LeadKind} evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={c.ParticipantCount}/{c.NotableParticipantCount} sides={c.CombatSideACount}/{c.CombatSideBCount} pending={InterestRegistry.PendingCount}");
}
/// <summary>
/// Map harness expect tokens onto relationship AssetIds used by StoryPlanner classification.
/// </summary>
private static void ApplyHarnessRelationshipAssetHint(InterestCandidate c, string expect)
{
if (c == null || string.IsNullOrEmpty(expect))
{
return;
}
if (expect.IndexOf("just_had_child", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("parenthood", StringComparison.OrdinalIgnoreCase) >= 0)
{
c.HappinessEffectId = "just_had_child";
c.AssetId = "just_had_child";
c.Category = "LifeChapter";
c.Completion = InterestCompletionKind.FixedDwell;
return;
}
if (expect.IndexOf("just_kissed", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("intimacy", StringComparison.OrdinalIgnoreCase) >= 0)
{
c.HappinessEffectId = "just_kissed";
c.AssetId = "just_kissed";
c.Category = "Social";
c.Completion = InterestCompletionKind.FixedDwell;
return;
}
if (expect.IndexOf("just_found_house", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("found_house", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("finds_a_home", StringComparison.OrdinalIgnoreCase) >= 0)
{
c.HappinessEffectId = "just_found_house";
c.AssetId = "just_found_house";
c.Category = "LifeChapter";
c.Completion = InterestCompletionKind.FixedDwell;
return;
}
if (expect.IndexOf("milestone_kill", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("just_killed", StringComparison.OrdinalIgnoreCase) >= 0)
{
c.AssetId = "milestone_kill";
c.HappinessEffectId = "just_killed";
c.Category = "Relationship";
c.Source = "chronicle";
c.Completion = InterestCompletionKind.FixedDwell;
return;
}
if (expect.IndexOf("fallen_in_love", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("smitten", StringComparison.OrdinalIgnoreCase) >= 0)
{
c.HappinessEffectId = "fallen_in_love";
c.AssetId = "fallen_in_love";
c.Category = "Social";
c.Completion = InterestCompletionKind.FixedDwell;
return;
}
if (expect.IndexOf("sexual_reproduction", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("decision_intent", StringComparison.OrdinalIgnoreCase) >= 0)
{
c.AssetId = "sexual_reproduction_try";
c.Source = "decision";
c.Category = "Politics";
c.Completion = InterestCompletionKind.FixedDwell;
return;
}
if (expect.IndexOf("find_lover", StringComparison.OrdinalIgnoreCase) >= 0)
{
// Decision path when tagged; relationship seeking otherwise.
bool asDecision = expect.IndexOf("decision", StringComparison.OrdinalIgnoreCase) >= 0;
c.AssetId = "find_lover";
c.HappinessEffectId = asDecision ? "" : "find_lover";
c.Source = asDecision ? "decision" : "relationship";
c.Category = asDecision ? "Politics" : "Relationship";
c.Completion = InterestCompletionKind.FixedDwell;
return;
}
if (expect.IndexOf("building_complete", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("tent_", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("construction", StringComparison.OrdinalIgnoreCase) >= 0)
{
c.AssetId = expect.IndexOf("tent_", StringComparison.OrdinalIgnoreCase) >= 0
? "tent_human"
: "building_complete";
c.Source = "building";
c.Category = "Settlement";
c.Completion = InterestCompletionKind.FixedDwell;
return;
}
if (expect.IndexOf("taking_roots", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("soft_status", StringComparison.OrdinalIgnoreCase) >= 0)
{
c.StatusId = "taking_roots";
c.AssetId = "taking_roots";
c.Completion = InterestCompletionKind.StatusPhase;
c.Category = "Status";
return;
}
if (expect.IndexOf("just_slept", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("rest_life", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("ends a rest", StringComparison.OrdinalIgnoreCase) >= 0)
{
c.HappinessEffectId = "just_slept";
c.AssetId = "just_slept";
c.Source = "happiness";
c.Completion = InterestCompletionKind.FixedDwell;
c.Category = "Daily";
return;
}
if (expect.IndexOf("sleeping", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("falls asleep", StringComparison.OrdinalIgnoreCase) >= 0)
{
c.StatusId = "sleeping";
c.AssetId = "sleeping";
c.Source = "status";
c.Completion = InterestCompletionKind.StatusPhase;
c.Category = "Status";
return;
}
if (expect.IndexOf("just_got_out_of_egg", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("hatch", StringComparison.OrdinalIgnoreCase) >= 0)
{
c.HappinessEffectId = "just_got_out_of_egg";
c.AssetId = "just_got_out_of_egg";
c.Completion = InterestCompletionKind.FixedDwell;
return;
}
if (expect.IndexOf("king_dead", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("worldlog_meta", StringComparison.OrdinalIgnoreCase) >= 0)
{
c.AssetId = "king_dead";
c.Source = "worldlog";
c.Category = "Politics";
c.Completion = InterestCompletionKind.FixedDwell;
return;
}
if (expect.IndexOf("set_lover", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0)
{
c.AssetId = "set_lover";
c.Category = "Relationship";
}
}
private static Actor FindOtherAliveUnit(Actor exclude, string preferAsset)
{
if (World.world?.units == null)
@ -4435,6 +4672,8 @@ public static class AgentHarness
c.Category = "CharacterVignette";
InterestScoring.ScoreCheap(c);
}
ApplyHarnessRelationshipAssetHint(c, cmd.expect);
bool ok = InterestDirector.HarnessForceSession(c);
if (ok)
{
@ -5828,6 +6067,96 @@ public static class AgentHarness
detail: $"n={want} scale={ensemble.Scale} frame={ensemble.Frame} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)} participants={InterestDirector.CurrentCandidate?.ParticipantCount}");
}
/// <summary>
/// Point camera Follow at another living unit without rewriting SubjectId / Label.
/// Proves life-tip reason principals reject bystander dossiers under a named tip.
/// asset = optional species for the bystander (default human).
/// </summary>
private static void DoInterestHijackFollow(HarnessCommand cmd)
{
InterestCandidate scene = InterestDirector.CurrentCandidate;
if (scene == null)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no_scene");
return;
}
long subjectId = scene.SubjectId != 0
? scene.SubjectId
: EventFeedUtil.SafeId(scene.FollowUnit);
Actor bystander = null;
string wantAsset = string.IsNullOrEmpty(cmd.asset) ? "human" : cmd.asset.Trim();
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{
if (actor == null || !actor.isAlive())
{
continue;
}
long id = EventFeedUtil.SafeId(actor);
if (id == 0 || id == subjectId || id == scene.RelatedId)
{
continue;
}
string species = actor.asset != null ? actor.asset.id : "";
if (!string.IsNullOrEmpty(wantAsset)
&& !species.Equals(wantAsset, StringComparison.OrdinalIgnoreCase))
{
if (bystander == null)
{
bystander = actor;
}
continue;
}
bystander = actor;
break;
}
if (bystander == null)
{
bystander = SpawnAssetNear(
wantAsset,
scene.FollowUnit != null
? scene.FollowUnit.current_position
: CameraPos(),
8f);
}
if (bystander == null || !bystander.isAlive())
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no_bystander");
return;
}
// Keep SubjectId / Label on the original tip owner; only Follow + camera move.
if (scene.SubjectId == 0)
{
scene.SubjectId = subjectId;
}
scene.FollowUnit = bystander;
try
{
scene.Position = bystander.current_position;
}
catch
{
// keep
}
CameraDirector.FocusUnit(bystander);
_cmdOk++;
Emit(
cmd,
ok: true,
detail: $"hijackFollow={SafeName(bystander)} subjectId={scene.SubjectId} relatedId={scene.RelatedId} tip='{scene.Label}'");
}
/// <summary>
/// Spawn a non-fighting same-side unit, park combat Follow on them (rostered bystander),
/// while peers keep fighting. Maintain must retarget off the chore unit.
@ -6267,6 +6596,105 @@ public static class AgentHarness
}
}
/// <summary>
/// Harness: zero one sticky combat camp while keeping PeakParticipants high
/// (repro for Mass vs (0) mop-up labels). value = a|b (default b).
/// </summary>
private static void DoCombatWipeStickySide(HarnessCommand cmd)
{
InterestCandidate scene = InterestDirector.CurrentCandidate;
if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no sticky opposing combat sides");
return;
}
LiveSceneStickyState sticky = scene.Sticky;
string which = (cmd.value ?? "b").Trim().ToLowerInvariant();
bool wipeB = which != "a" && which != "sidea" && which != "0";
int peakBefore = sticky.PeakParticipants;
if (wipeB)
{
sticky.SideBIds.Clear();
sticky.SideBCount = 0;
}
else
{
sticky.SideAIds.Clear();
sticky.SideACount = 0;
}
// Keep peak Mass so the bug class (peak hold + empty camp) is exercised.
sticky.PeakParticipants = Math.Max(peakBefore, Math.Max(8, sticky.TotalCount));
scene.ParticipantCount = sticky.TotalCount;
scene.CombatSideACount = sticky.SideACount;
scene.CombatSideBCount = sticky.SideBCount;
scene.CombatPeakParticipants = sticky.PeakParticipants;
string reason = StickyScoreboard.FormatReason(scene, scene.FollowUnit, scene.RelatedUnit);
if (!string.IsNullOrEmpty(reason))
{
scene.Label = reason;
}
StickyScoreboard.StabilizeScale(sticky, new LiveEnsemble
{
Kind = EnsembleKind.Combat,
Frame = sticky.Frame,
ParticipantCount = Math.Max(1, sticky.TotalCount),
SideA = new EnsembleSide
{
Key = sticky.SideAKey,
Display = sticky.SideADisplay,
Count = sticky.SideACount
},
SideB = new EnsembleSide
{
Key = sticky.SideBKey,
Display = sticky.SideBDisplay,
Count = sticky.SideBCount
},
Focus = scene.FollowUnit,
Related = scene.RelatedUnit
}, Time.unscaledTime);
string label = EventReason.Combat(new LiveEnsemble
{
Kind = EnsembleKind.Combat,
Scale = sticky.HasPresentedScale ? sticky.PresentedScale : LiveEnsemble.ScaleForCount(Math.Max(1, sticky.TotalCount)),
Frame = sticky.Frame,
Focus = scene.FollowUnit,
Related = scene.RelatedUnit,
SideA = new EnsembleSide
{
Key = sticky.SideAKey,
Display = sticky.SideADisplay,
KingdomDisplay = sticky.SideAKingdom,
Count = sticky.SideACount
},
SideB = new EnsembleSide
{
Key = sticky.SideBKey,
Display = sticky.SideBDisplay,
KingdomDisplay = sticky.SideBKingdom,
Count = sticky.SideBCount
},
ParticipantCount = sticky.TotalCount
});
if (!string.IsNullOrEmpty(label))
{
scene.Label = label;
CameraDirector.Watch(scene.ToInterestEvent());
}
_cmdOk++;
Emit(
cmd,
ok: true,
detail: $"wiped={(wipeB ? "b" : "a")} sides={sticky.SideACount}/{sticky.SideBCount} peak={sticky.PeakParticipants} tip='{scene.Label}'");
}
/// <summary>Diagnostics: how many combat fighters LiveEnsemble sees near the scene focus.</summary>
private static void DoCombatProbeFighters(HarnessCommand cmd)
{
@ -6574,6 +7002,21 @@ public static class AgentHarness
detail = $"tipAsset='{tipAsset}' want='{want}'";
break;
}
case "tip_not_partner":
{
// Remembered bystander must not be named as the fallen / sought victim.
// Subject can still be the partner ("Petelor stands over the fallen" is fine).
string partnerName = SafeName(_happinessPartner);
string tip = CameraDirector.LastWatchLabel ?? "";
bool namedAsOther = !string.IsNullOrEmpty(partnerName)
&& (tip.IndexOf("stands over " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0
|| tip.IndexOf("mourns " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0
|| tip.IndexOf("seeks " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0
|| tip.IndexOf("stays beside " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0);
pass = !namedAsOther;
detail = $"tip='{tip}' partner='{partnerName}' namedAsOther={namedAsOther}";
break;
}
case "story_phase":
{
string want = (cmd.value ?? "").Trim();
@ -6584,6 +7027,119 @@ public static class AgentHarness
detail = $"storyPhase={have} want={want} kind={arc?.Kind} hard={arc?.HardHold}";
break;
}
case "story_spine":
{
string want = (cmd.value ?? "").Trim();
string have = WatchCaption.LastStorySpine ?? "";
string formatted = StoryPlanner.FormatSpineLabel() ?? "";
// Prefer live dossier text; fall back to planner format if HUD not painted yet.
if (string.IsNullOrEmpty(have))
{
have = formatted;
}
bool wantEmpty = string.IsNullOrEmpty(want)
|| string.Equals(want, "empty", StringComparison.OrdinalIgnoreCase)
|| string.Equals(want, "none", StringComparison.OrdinalIgnoreCase);
if (wantEmpty)
{
pass = string.IsNullOrEmpty(have);
}
else if (want.IndexOf('|') >= 0)
{
// Alternatives: "Battle · Climax|Mass · Climax|Skirmish · Climax"
pass = false;
string[] alts = want.Split('|');
for (int i = 0; i < alts.Length; i++)
{
string alt = (alts[i] ?? "").Trim();
if (!string.IsNullOrEmpty(alt)
&& have.IndexOf(alt, StringComparison.OrdinalIgnoreCase) >= 0)
{
pass = true;
break;
}
}
}
else
{
pass = have.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0;
}
detail =
$"storySpine='{have}' want='{want}' formatted='{formatted}' "
+ $"belongs={StoryPlanner.BelongsToActiveStoryBeat(InterestDirector.CurrentCandidate)} "
+ $"phase={StoryPlanner.Active?.Phase.ToString() ?? "None"}";
break;
}
case "story_hold_margin":
{
// value: "0" / "none" → expect no raised hold; "gt0" / "held" → expect hold > 0
string want = (cmd.value ?? "gt0").Trim();
InterestCandidate cur = InterestDirector.CurrentCandidate;
InterestCandidate probe = new InterestCandidate
{
Key = "harness:story_hold_probe",
SubjectId = 999999001,
EventStrength = 40f,
LeadKind = InterestLeadKind.EventLed,
Completion = InterestCompletionKind.FixedDwell,
Label = "HoldProbe"
};
InterestScoring.RecalcTotal(probe);
float margin = StoryPlanner.ArcHoldMargin(cur, probe);
bool wantHeld = string.Equals(want, "gt0", StringComparison.OrdinalIgnoreCase)
|| string.Equals(want, "held", StringComparison.OrdinalIgnoreCase)
|| string.Equals(want, "true", StringComparison.OrdinalIgnoreCase);
bool wantNone = string.Equals(want, "0", StringComparison.OrdinalIgnoreCase)
|| string.Equals(want, "none", StringComparison.OrdinalIgnoreCase)
|| string.Equals(want, "false", StringComparison.OrdinalIgnoreCase);
if (wantHeld)
{
pass = margin > 0f;
}
else if (wantNone)
{
pass = margin <= 0f;
}
else if (float.TryParse(want, NumberStyles.Float, CultureInfo.InvariantCulture, out float wantF))
{
pass = Mathf.Abs(margin - wantF) < 0.5f;
}
else
{
pass = false;
}
detail =
$"storyHold={margin:0.#} want={want} phase={StoryPlanner.Active?.Phase.ToString() ?? "None"} "
+ $"belongs={StoryPlanner.BelongsToActiveStoryBeat(cur)}";
break;
}
case "beat_cooled":
{
// value = happiness/asset needle; builds a probe on focus unit.
string needle = (cmd.value ?? "just_had_child").Trim();
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long id = EventFeedUtil.SafeId(focus);
InterestCandidate probe = new InterestCandidate
{
Key = "harness:beat_cool:" + needle,
SubjectId = id,
FollowUnit = focus,
HappinessEffectId = needle,
AssetId = needle,
LeadKind = InterestLeadKind.EventLed,
Completion = InterestCompletionKind.FixedDwell,
EventStrength = 80f,
Label = needle
};
// Map decision/building/status needles onto the same chapter fields as injects.
ApplyHarnessRelationshipAssetHint(probe, needle);
pass = InterestVariety.IsBeatCooled(probe);
detail = $"beatCooled={pass} needle='{needle}' focus={SafeName(focus)} id={id}";
break;
}
case "ledger_has_focus":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
@ -7713,6 +8269,7 @@ public static class AgentHarness
case "reason_owns_focus":
{
// Reason must not name a different live unit than the owned active candidate.
// Non-empty reason also requires focus to be a tip principal (duel pair / roster).
UnitDossier dossier = WatchCaption.Current;
string reason = dossier?.ReasonLine ?? "";
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
@ -7733,6 +8290,13 @@ public static class AgentHarness
break;
}
if (!InterestDirector.UnitIsReasonPrincipal(focus, owned))
{
pass = false;
detail = $"reason_without_principal reason='{reason}'";
break;
}
string stranger = FindStrangerNamedInReason(reason, owned);
pass = string.IsNullOrEmpty(stranger);
detail = pass

View file

@ -278,6 +278,27 @@ public static partial class EventCatalog
/// <summary>True when a decision id should be allowed to own the idle camera.</summary>
public static bool IsCameraWorthy(string id) => IsInterestingDecision(id);
/// <summary>
/// Life-cycle intent decisions: AI cooldown fires before the outcome tip.
/// Outcomes (pregnancy, child, seeking/smitten, lovers) already own the story -
/// these crumbs soft-cool as a class after one show.
/// Derived from live <c>decisions_library</c> token shape, not tip strings.
/// </summary>
public static bool IsLifeIntentDecision(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.Trim().ToLowerInvariant();
return s.IndexOf("reproduction", StringComparison.Ordinal) >= 0
|| s == "find_lover"
|| s.StartsWith("find_lover_", StringComparison.Ordinal)
|| s.IndexOf("baby_make", StringComparison.Ordinal) >= 0
|| s.IndexOf("have_child", StringComparison.Ordinal) >= 0;
}
/// <summary>Authored action-phrase ids (JSON catalog builtin fallback).</summary>
public static IEnumerable<string> AuthoredActionPhraseIds
{

View file

@ -93,6 +93,9 @@ public static class EventFeedUtil
Label = label ?? assetId ?? key,
AssetId = assetId ?? "",
SpeciesId = subject?.asset != null ? subject.asset.id : "",
// StatusPhase publishes the status id as AssetId - stamp StatusId before Upsert
// so NoteSelection / beat cools see the class on first SwitchTo.
StatusId = completion == InterestCompletionKind.StatusPhase ? (assetId ?? "") : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 35f,
@ -185,6 +188,27 @@ public static class EventFeedUtil
/// <see cref="WorldActivityScanner.EnumerateAliveUnitsPublic"/>.
/// </summary>
public static Actor FindAliveById(long id)
{
Actor actor = FindUnitById(id);
if (actor == null)
{
return null;
}
try
{
return actor.isAlive() ? actor : null;
}
catch
{
return null;
}
}
/// <summary>
/// O(1) actor lookup including dead units (naming fallen duel partners, etc.).
/// </summary>
public static Actor FindUnitById(long id)
{
if (id == 0 || World.world?.units == null)
{
@ -193,18 +217,12 @@ public static class EventFeedUtil
try
{
Actor actor = World.world.units.get(id);
if (actor != null && actor.isAlive())
{
return actor;
}
return World.world.units.get(id);
}
catch
{
// ignore lookup failures
return null;
}
return null;
}
public static string SafeName(Actor actor)

View file

@ -172,7 +172,8 @@ public static class EventReason
/// <summary>
/// Context-aware combat orange reason from a <see cref="LiveEnsemble"/> snapshot.
/// Multi form: <c>Battle - Humans (4) vs Wolves (0)</c> - counts may be 0 while sides stay sticky.
/// Multi form: <c>Battle - Humans (4) vs Wolves (3)</c>. A wiped camp collapses to a
/// living-side pack label at live scale (never <c>vs … (0)</c>).
/// Never uses raw unit names as mass-side labels. Never prints thin <c>melee</c> fallbacks.
/// </summary>
public static string Combat(LiveEnsemble ensemble)
@ -201,26 +202,54 @@ public static class EventReason
return "Duel - " + a + " vs " + b;
}
string tier = ScaleTierLabel(
ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale);
if (LiveEnsemble.HasOpposingCollectiveSides(ensemble))
{
string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame);
string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame);
if (!string.IsNullOrEmpty(sideA) && !string.IsNullOrEmpty(sideB))
int countA = ensemble.SideA != null ? Math.Max(0, ensemble.SideA.Count) : 0;
int countB = ensemble.SideB != null ? Math.Max(0, ensemble.SideB.Count) : 0;
// Mop-up: a wiped camp must not print "Mass - A (93) vs B (0)".
// Present the living side at live scale (pack chase / remnant).
if (countA <= 0 || countB <= 0)
{
return tier + " - " + sideA + " vs " + sideB;
EnsembleSide liveSide = countA > 0 ? ensemble.SideA : ensemble.SideB;
int liveN = Math.Max(countA, countB);
if (liveSide != null && liveN > 0 && !string.IsNullOrEmpty(liveSide.Key))
{
EnsembleScale mopScale = LiveEnsemble.ScaleForCount(liveN);
string mopTier = ScaleTierLabel(
mopScale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : mopScale);
EnsembleFrame mopFrame = ensemble.Frame == EnsembleFrame.KingdomVsKingdom
? EnsembleFrame.KingdomVsKingdom
: EnsembleFrame.SpeciesVsSpecies;
string pack = FormatCombatSide(liveSide, mopFrame);
if (!string.IsNullOrEmpty(pack))
{
return mopTier + " - " + pack;
}
}
}
else
{
string tier = ScaleTierLabel(
ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale);
string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame);
string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame);
if (!string.IsNullOrEmpty(sideA) && !string.IsNullOrEmpty(sideB))
{
return tier + " - " + sideA + " vs " + sideB;
}
}
}
string packTier = ScaleTierLabel(
ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale);
// Same-species pack with no opposing sticky camps.
if (ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key))
{
string pack = FormatCombatSide(ensemble.SideA, EnsembleFrame.SpeciesVsSpecies);
if (!string.IsNullOrEmpty(pack))
{
return tier + " - " + pack;
return packTier + " - " + pack;
}
}
@ -321,7 +350,7 @@ public static class EventReason
}
label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : "");
// Allow 0 so sticky camps can show a side briefly empty, then climb again on rejoin.
// Callers must not pair a zero-count side into "vs" framing (see Combat mop-up).
int n = Math.Max(0, side.Count);
return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
}

View file

@ -541,7 +541,8 @@ public static class InterestFeeds
FollowUnit = actor,
SubjectId = id,
Label = EventReason.Hatch(actor),
AssetId = SafeAsset(actor),
// Hatch id owns AssetId so cool keys are class-stable (not species).
AssetId = "just_got_out_of_egg",
SpeciesId = SafeAsset(actor),
HappinessEffectId = "just_got_out_of_egg",
CreatedAt = Time.unscaledTime,
@ -649,7 +650,9 @@ public static class InterestFeeds
SubjectId = sid,
RelatedId = rid,
Label = label ?? milestoneKey,
AssetId = subject.asset != null ? subject.asset.id : "",
// Milestone id owns AssetId so kill/lover/friend crumbs cool as a class
// (species id used to land every hunter tip on arc:asset:elf).
AssetId = milestoneKey,
SpeciesId = subject.asset != null ? subject.asset.id : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,

View file

@ -158,6 +158,25 @@ namespace IdleSpectator;
}
EnsembleScale live = LiveEnsemble.ScaleForCount(n);
// Opposing camp wiped: drop Peak Mass hold immediately so tip tier matches mop-up.
bool wipedOpposing = sticky.HasOpposingSides
&& sticky.TotalCount > 0
&& (sticky.SideACount <= 0 || sticky.SideBCount <= 0);
if (wipedOpposing)
{
sticky.HasPresentedScale = true;
sticky.PresentedScale = live;
sticky.ScaleChangeSince = -999f;
sticky.ScaleDropSince = -999f;
ensemble.Scale = live;
if (ensemble.Frame == EnsembleFrame.NamedPair && live > EnsembleScale.Pair)
{
ensemble.Frame = EnsembleFrame.CountOnly;
}
return;
}
if (!sticky.HasPresentedScale)
{
sticky.HasPresentedScale = true;
@ -458,10 +477,16 @@ namespace IdleSpectator;
}
LiveSceneStickyState sticky = scene.Sticky;
bool wipedOpposing = sticky.HasOpposingSides
&& sticky.TotalCount > 0
&& (sticky.SideACount <= 0 || sticky.SideBCount <= 0);
int scaleN = wipedOpposing
? Math.Max(1, sticky.TotalCount)
: Math.Max(3, sticky.PeakParticipants);
var ensemble = new LiveEnsemble
{
Kind = sticky.Kind,
Scale = LiveEnsemble.ScaleForCount(Math.Max(3, sticky.PeakParticipants)),
Scale = LiveEnsemble.ScaleForCount(scaleN),
Focus = focus,
Related = related,
Frame = sticky.Frame,

View file

@ -67,8 +67,57 @@ internal static class HarnessScenarios
case "story_aftermath_combat":
case "story_aftermath":
return StoryAftermathCombat();
case "story_aftermath_cast_noise":
case "soft_duel_aftermath_noise":
return StoryAftermathCastNoise();
case "reason_duel_principal":
case "story_reason_principal":
return ReasonDuelPrincipal();
case "reason_life_principal":
case "story_reason_life":
return ReasonLifePrincipal();
case "story_intimacy_cool":
case "intimacy_cool":
return StoryIntimacyCool();
case "story_settlement_cool":
case "settlement_cool":
return StorySettlementCool();
case "story_kill_cool":
case "kill_cool":
return StoryKillCool();
case "story_courtship_cool":
case "courtship_cool":
case "story_love_cool":
return StoryCourtshipCool();
case "combat_wiped_side":
case "story_wiped_side":
return CombatWipedSide();
case "story_decision_intent_cool":
case "decision_intent_cool":
return StoryDecisionIntentCool();
case "story_construction_cool":
case "construction_cool":
return StoryConstructionCool();
case "story_status_fx_cool":
case "status_fx_cool":
return StoryStatusFxCool();
case "story_resume_cooled":
case "resume_cooled":
return StoryResumeCooled();
case "story_worldlog_meta_cool":
case "worldlog_meta_cool":
return StoryWorldLogMetaCool();
case "story_rest_cool":
case "rest_cool":
return StoryRestCool();
case "story_decision_intent_live":
case "decision_intent_live":
return StoryDecisionIntentLive();
case "story_aftermath_war":
return StoryAftermathWar();
case "story_aftermath_partner_truth":
case "aftermath_partner_truth":
return StoryAftermathPartnerTruth();
case "story_near_tie":
case "variety_near_tie":
return StoryNearTie();
@ -78,6 +127,10 @@ internal static class HarnessScenarios
return StoryArcPreemptResume();
case "story_ledger_revisit":
return StoryLedgerRevisit();
case "story_spine_scoped":
return StorySpineScoped();
case "story_family_variety":
return StoryFamilyVariety();
case "war_front_sticky":
case "war_sticky":
return WarFrontSticky();
@ -237,8 +290,26 @@ internal static class HarnessScenarios
Nested("reg_combat_hold", "combat_hold_until_end"),
Nested("reg_combat_stability", "combat_stability_live"),
Nested("reg_story_aftermath", "story_aftermath_combat"),
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"),
Nested("reg_reason_life_principal", "reason_life_principal"),
Nested("reg_story_intimacy_cool", "story_intimacy_cool"),
Nested("reg_story_settlement_cool", "story_settlement_cool"),
Nested("reg_story_kill_cool", "story_kill_cool"),
Nested("reg_story_courtship_cool", "story_courtship_cool"),
Nested("reg_story_decision_intent_cool", "story_decision_intent_cool"),
Nested("reg_story_construction_cool", "story_construction_cool"),
Nested("reg_story_status_fx_cool", "story_status_fx_cool"),
Nested("reg_story_resume_cooled", "story_resume_cooled"),
Nested("reg_story_worldlog_meta_cool", "story_worldlog_meta_cool"),
Nested("reg_story_rest_cool", "story_rest_cool"),
Nested("reg_story_decision_intent_live", "story_decision_intent_live"),
Nested("reg_combat_wiped_side", "combat_wiped_side"),
Nested("reg_story_near_tie", "story_near_tie"),
Nested("reg_story_arc", "story_arc_combat"),
Nested("reg_story_spine", "story_spine_scoped"),
Nested("reg_story_family", "story_family_variety"),
Nested("reg_story_ledger", "story_ledger_revisit"),
Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
@ -1482,11 +1553,21 @@ internal static class HarnessScenarios
Step("sa24", "interest_expire_pending", value: ""),
Step("sa25", "age_current", wait: 10f),
Step("sa26", "interest_mark_combat_cold"),
// Cold climax tip while phase is Aftermath must not show a lying spine.
Step("sa26b", "assert", expect: "tip_matches_any", value: "Duel -"),
Step("sa26c", "assert", expect: "story_phase", value: "Aftermath"),
Step("sa26d", "assert", expect: "story_spine", value: "empty"),
Step("sa27", "director_run", wait: 1.5f),
Step("sa28", "assert", expect: "tip_matches_any", value: "stands over|mourns|lingers"),
Step("sa29", "assert", expect: "tip_asset", value: "aftermath_"),
Step("sa30", "assert", expect: "tip_not_contains", value: "Duel -"),
Step("sa31", "assert", expect: "story_phase", value: "Aftermath"),
Step("sa31b", "assert", expect: "story_spine", value: "Duel · Aftermath"),
// Drop Follow mid-aftermath - cast recovery must keep the story tip.
Step("sa32", "interest_drop_follow"),
Step("sa33", "director_run", wait: 0.8f),
Step("sa34", "assert", expect: "tip_asset", value: "aftermath_"),
Step("sa35", "assert", expect: "story_phase", value: "Aftermath"),
Step("sa90", "fast_timing", value: "false"),
Step("sa99", "snapshot"),
};
@ -1518,6 +1599,674 @@ internal static class HarnessScenarios
};
}
/// <summary>
/// Combat aftermath must not name a living lover/friend as the fallen
/// (soak: "stands over Clemond" after a duel vs someone else).
/// </summary>
private static List<HarnessCommand> StoryAftermathPartnerTruth()
{
return new List<HarnessCommand>
{
Step("saf0", "dismiss_windows"),
Step("saf1", "wait_world"),
Step("saf2", "set_setting", expect: "enabled", value: "true"),
Step("saf3", "fast_timing", value: "true"),
// Lover bystander (not in the duel) remembered first; fighter is the next spawn.
Step("saf4", "spawn", asset: "human", count: 1),
Step("saf5", "happiness_remember_partner"),
Step("saf6", "spawn", asset: "human", count: 1),
Step("saf7", "focus", asset: "auto"),
Step("saf8", "happiness_bond_lovers"),
Step("saf9", "spawn", asset: "wolf", count: 1),
Step("saf10", "spectator", value: "off"),
Step("saf11", "spectator", value: "on"),
// Keep the fighter (human focus), not the wolf, as combat Follow.
Step("saf12", "pick_unit", asset: "human"),
Step("saf12b", "focus", asset: "human"),
Step("saf13", "interest_end_session"),
Step("saf14", "interest_story_clear"),
Step("saf20", "interest_combat_session", asset: "human", value: "wolf", expect: "saf_pack"),
Step("saf20b", "combat_isolate_pair", value: "20"),
Step("saf21", "status_apply", asset: "human", value: "invincible"),
Step("saf22", "status_apply", asset: "wolf", value: "invincible"),
Step("saf23", "assert", expect: "tip_matches_any", value: "Duel -"),
Step("saf24", "interest_expire_pending", value: ""),
Step("saf25", "age_current", wait: 10f),
Step("saf26", "interest_mark_combat_cold"),
Step("saf27", "director_run", wait: 1.5f),
// Living theater partner → generic fallen prose; never the lover's name.
Step("saf28", "assert", expect: "tip_contains", value: "stands over the fallen"),
Step("saf29", "assert", expect: "tip_asset", value: "aftermath_"),
Step("saf30", "assert", expect: "tip_not_partner"),
Step("saf90", "fast_timing", value: "false"),
Step("saf99", "snapshot"),
};
}
/// <summary>
/// Soft-duel cold still injects aftermath when high-strength cast life noise is pending
/// (hatch/eat must not count as natural grief/love peers).
/// </summary>
private static List<HarnessCommand> StoryAftermathCastNoise()
{
return new List<HarnessCommand>
{
Step("san0", "dismiss_windows"),
Step("san1", "wait_world"),
Step("san2", "set_setting", expect: "enabled", value: "true"),
Step("san3", "fast_timing", value: "true"),
Step("san4", "spawn", asset: "human", count: 1),
Step("san5", "spawn", asset: "wolf", count: 1),
Step("san6", "spectator", value: "off"),
Step("san7", "spectator", value: "on"),
Step("san8", "pick_unit", asset: "human"),
Step("san9", "focus", asset: "human"),
Step("san10", "interest_end_session"),
Step("san10b", "interest_story_clear"),
Step("san10c", "interest_variety_clear"),
Step("san10d", "interest_expire_pending", value: ""),
Step("san20", "interest_combat_session", asset: "human", value: "wolf", expect: "san_pack"),
Step("san20b", "combat_isolate_pair", value: "20"),
Step("san21", "status_apply", asset: "human", value: "invincible"),
Step("san22", "status_apply", asset: "wolf", value: "invincible"),
Step("san23", "assert", expect: "tip_matches_any", value: "Duel -"),
// High-score cast-life distractor (expect id must NOT contain "hatch" or the
// harness maps it onto just_got_out_of_egg and it can sticky-win the cold).
Step("san24", "interest_inject", asset: "human", label: "{a} munches quietly nearby",
tier: "Action", expect: "san_cast_noise",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("san25", "age_current", wait: 10f),
Step("san26", "interest_mark_combat_cold"),
Step("san27", "director_run", wait: 1.5f),
Step("san28", "assert", expect: "tip_matches_any", value: "stands over|mourns|lingers"),
Step("san29", "assert", expect: "tip_asset", value: "aftermath_"),
Step("san30", "assert", expect: "tip_not_contains", value: "Duel -"),
Step("san31", "assert", expect: "story_phase", value: "Aftermath"),
Step("san90", "fast_timing", value: "false"),
Step("san99", "snapshot"),
};
}
/// <summary>
/// Life-tip reason principals: Follow hijack onto a bystander must not keep a
/// subject-led intimacy Label on the bystander dossier.
/// </summary>
private static List<HarnessCommand> ReasonLifePrincipal()
{
return new List<HarnessCommand>
{
Step("rlp0", "dismiss_windows"),
Step("rlp1", "wait_world"),
Step("rlp2", "set_setting", expect: "enabled", value: "true"),
Step("rlp3", "fast_timing", value: "true"),
Step("rlp4", "spawn", asset: "human", count: 3),
Step("rlp5", "spectator", value: "off"),
Step("rlp6", "spectator", value: "on"),
Step("rlp7", "pick_unit", asset: "human"),
Step("rlp8", "focus", asset: "human"),
Step("rlp9", "interest_end_session"),
Step("rlp20", "interest_force_session", asset: "human",
label: "{a} shares intimacy with a partner", tier: "Action", expect: "just_kissed",
value: "lead=event;evt=62"),
Step("rlp21", "assert", expect: "reason_matches_any", value: "intimacy|mates"),
Step("rlp22", "assert", expect: "reason_owns_focus"),
// Follow drifts to a bystander; SubjectId + Label stay on the couple tip.
Step("rlp30", "interest_hijack_follow", asset: "human"),
Step("rlp31", "wait", value: "0.25"),
Step("rlp32", "assert", expect: "dossier_matches_focus"),
Step("rlp33", "assert", expect: "reason_empty", value: "true"),
Step("rlp34", "assert", expect: "reason_owns_focus"),
Step("rlp35", "assert", expect: "dossier_not_contains", value: "shares intimacy"),
Step("rlp90", "fast_timing", value: "false"),
Step("rlp99", "snapshot"),
};
}
/// <summary>
/// just_found_house / finds-a-home soft-life cool: settlement crumbs must not
/// keep cutting between combat beats.
/// </summary>
private static List<HarnessCommand> StorySettlementCool()
{
return new List<HarnessCommand>
{
Step("ssc0", "dismiss_windows"),
Step("ssc1", "wait_world"),
Step("ssc2", "set_setting", expect: "enabled", value: "true"),
Step("ssc3", "fast_timing", value: "true"),
Step("ssc4", "spawn", asset: "human", count: 2),
Step("ssc5", "spectator", value: "off"),
Step("ssc6", "spectator", value: "on"),
Step("ssc7", "pick_unit", asset: "human"),
Step("ssc8", "focus", asset: "human"),
Step("ssc9", "interest_end_session"),
Step("ssc10", "interest_variety_clear"),
Step("ssc20", "interest_force_session", asset: "human",
label: "{a} finds a home", tier: "Action", expect: "just_found_house",
value: "lead=event;evt=58"),
Step("ssc21", "assert", expect: "tip_contains", value: "finds a home"),
Step("ssc22", "assert", expect: "beat_cooled", value: "just_found_house"),
Step("ssc23", "interest_end_session"),
Step("ssc30", "interest_inject", asset: "human",
label: "{a} finds a home", tier: "Action", expect: "just_found_house",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("ssc31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
expect: "ssc_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
Step("ssc32", "director_run", wait: 1.2f),
Step("ssc33", "assert", expect: "tip_contains", value: "ClearPeer"),
Step("ssc34", "assert", expect: "tip_not_contains", value: "finds a home"),
Step("ssc35", "assert", expect: "beat_cooled", value: "just_found_house"),
Step("ssc90", "fast_timing", value: "false"),
Step("ssc99", "snapshot"),
};
}
/// <summary>
/// just_kissed / intimacy soft-life cool: after one show, the same beat cannot
/// immediately cut back in over a stronger peer.
/// </summary>
private static List<HarnessCommand> StoryIntimacyCool()
{
return new List<HarnessCommand>
{
Step("sic0", "dismiss_windows"),
Step("sic1", "wait_world"),
Step("sic2", "set_setting", expect: "enabled", value: "true"),
Step("sic3", "fast_timing", value: "true"),
Step("sic4", "spawn", asset: "human", count: 2),
Step("sic5", "spectator", value: "off"),
Step("sic6", "spectator", value: "on"),
Step("sic7", "pick_unit", asset: "human"),
Step("sic8", "focus", asset: "human"),
Step("sic9", "interest_end_session"),
Step("sic10", "interest_variety_clear"),
Step("sic20", "interest_force_session", asset: "human",
label: "{a} shares intimacy with a partner", tier: "Action", expect: "just_kissed",
value: "lead=event;evt=62"),
Step("sic21", "assert", expect: "tip_matches_any", value: "intimacy|mates"),
Step("sic22", "assert", expect: "beat_cooled", value: "just_kissed"),
Step("sic23", "interest_end_session"),
// Same intimacy beat pending again - must stay cooled while a clear peer wins.
Step("sic30", "interest_inject", asset: "human",
label: "{a} shares intimacy with a partner", tier: "Action", expect: "just_kissed",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("sic31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
expect: "sic_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
Step("sic32", "director_run", wait: 1.2f),
Step("sic33", "assert", expect: "tip_contains", value: "ClearPeer"),
Step("sic34", "assert", expect: "tip_not_contains", value: "intimacy"),
Step("sic35", "assert", expect: "beat_cooled", value: "just_kissed"),
Step("sic90", "fast_timing", value: "false"),
Step("sic99", "snapshot"),
};
}
/// <summary>
/// Chronicle kill crumbs (Killed X) soft-life cool: after one show, kill beats
/// cannot keep cutting between stronger peers / mass tips.
/// </summary>
private static List<HarnessCommand> StoryKillCool()
{
return new List<HarnessCommand>
{
Step("skc0", "dismiss_windows"),
Step("skc1", "wait_world"),
Step("skc2", "set_setting", expect: "enabled", value: "true"),
Step("skc3", "fast_timing", value: "true"),
Step("skc4", "spawn", asset: "human", count: 2),
Step("skc5", "spectator", value: "off"),
Step("skc6", "spectator", value: "on"),
Step("skc7", "pick_unit", asset: "human"),
Step("skc8", "focus", asset: "human"),
Step("skc8b", "status_apply", asset: "human", value: "invincible"),
Step("skc9", "interest_end_session"),
Step("skc10", "interest_variety_clear"),
Step("skc20", "interest_force_session", asset: "human",
label: "Killed Phixzen (fox)", tier: "Action", expect: "milestone_kill",
value: "lead=event;evt=60"),
Step("skc21", "assert", expect: "tip_contains", value: "Killed"),
Step("skc22", "assert", expect: "beat_cooled", value: "milestone_kill"),
Step("skc23", "interest_end_session"),
Step("skc30", "interest_inject", asset: "human",
label: "Killed OtherFox (fox)", tier: "Action", expect: "milestone_kill",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("skc31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
expect: "skc_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
Step("skc32", "director_run", wait: 1.2f),
Step("skc33", "assert", expect: "tip_contains", value: "ClearPeer"),
Step("skc34", "assert", expect: "tip_not_contains", value: "Killed"),
Step("skc35", "assert", expect: "beat_cooled", value: "milestone_kill"),
Step("skc90", "fast_timing", value: "false"),
Step("skc99", "snapshot"),
};
}
/// <summary>
/// find_lover / fallen_in_love courtship soft-life cool: seeking stamps the shared
/// love class so smitten/seeking crumbs cannot cut over a clear peer.
/// </summary>
private static List<HarnessCommand> StoryCourtshipCool()
{
return new List<HarnessCommand>
{
Step("scc0", "dismiss_windows"),
Step("scc1", "wait_world"),
Step("scc2", "set_setting", expect: "enabled", value: "true"),
Step("scc3", "fast_timing", value: "true"),
Step("scc4", "spawn", asset: "human", count: 3),
Step("scc5", "spectator", value: "off"),
Step("scc6", "spectator", value: "on"),
Step("scc7", "pick_unit", asset: "human"),
Step("scc8", "focus", asset: "human"),
// Dirty worlds often have leftover wolves from combat gates.
Step("scc8b", "status_apply", asset: "human", value: "invincible"),
Step("scc9", "interest_end_session"),
Step("scc10", "interest_variety_clear"),
Step("scc11", "interest_story_clear"),
Step("scc20", "interest_force_session", asset: "human",
label: "{a} is seeking a lover", tier: "Action", expect: "find_lover",
value: "lead=event;evt=62"),
Step("scc21", "assert", expect: "tip_contains", value: "seeking a lover"),
Step("scc22", "assert", expect: "beat_cooled", value: "find_lover"),
// Shared arc:life:love world cool also covers smitten.
Step("scc23", "assert", expect: "beat_cooled", value: "fallen_in_love"),
Step("scc24", "interest_end_session"),
Step("scc25", "interest_story_clear"),
Step("scc30", "interest_inject", asset: "human",
label: "{a} is seeking a lover", tier: "Action", expect: "find_lover",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("scc31", "interest_inject", asset: "human",
label: "{a} is smitten with a partner", tier: "Action", expect: "fallen_in_love",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("scc32", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
expect: "scc_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
Step("scc33", "director_run", wait: 1.2f),
Step("scc34", "assert", expect: "tip_contains", value: "ClearPeer"),
Step("scc35", "assert", expect: "tip_not_contains", value: "smitten"),
Step("scc36", "assert", expect: "tip_not_contains", value: "seeking a lover"),
Step("scc37", "assert", expect: "beat_cooled", value: "fallen_in_love"),
Step("scc38", "assert", expect: "beat_cooled", value: "find_lover"),
Step("scc90", "fast_timing", value: "false"),
Step("scc99", "snapshot"),
};
}
/// <summary>
/// Life-intent decisions (reproduce / find lover) soft-cool as a class:
/// one show cools the whole intent family so ClearPeer wins.
/// </summary>
private static List<HarnessCommand> StoryDecisionIntentCool()
{
return new List<HarnessCommand>
{
Step("sdc0", "dismiss_windows"),
Step("sdc1", "wait_world"),
Step("sdc2", "set_setting", expect: "enabled", value: "true"),
Step("sdc3", "fast_timing", value: "true"),
Step("sdc4", "spawn", asset: "human", count: 3),
Step("sdc5", "spectator", value: "off"),
Step("sdc6", "spectator", value: "on"),
Step("sdc7", "pick_unit", asset: "human"),
Step("sdc8", "focus", asset: "human"),
Step("sdc8b", "status_apply", asset: "human", value: "invincible"),
Step("sdc9", "interest_end_session"),
Step("sdc10", "interest_variety_clear"),
Step("sdc11", "interest_story_clear"),
Step("sdc20", "interest_force_session", asset: "human",
label: "{a} decides to try to reproduce", tier: "Action",
expect: "sexual_reproduction_try", value: "lead=event;evt=66"),
Step("sdc21", "assert", expect: "tip_contains", value: "try to reproduce"),
Step("sdc22", "assert", expect: "beat_cooled", value: "sexual_reproduction_try"),
// Shared intent class also cools decision find_lover.
Step("sdc23", "assert", expect: "beat_cooled", value: "decision_find_lover"),
Step("sdc24", "interest_end_session"),
Step("sdc25", "interest_story_clear"),
Step("sdc30", "interest_inject", asset: "human",
label: "{a} decides to try to reproduce", tier: "Action",
expect: "sexual_reproduction_try",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("sdc31", "interest_inject", asset: "human",
label: "{a} decides to find a lover", tier: "Action",
expect: "decision_find_lover",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("sdc32", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
expect: "sdc_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
Step("sdc33", "director_run", wait: 1.2f),
Step("sdc34", "assert", expect: "tip_contains", value: "ClearPeer"),
Step("sdc35", "assert", expect: "tip_not_contains", value: "reproduce"),
Step("sdc36", "assert", expect: "tip_not_contains", value: "find a lover"),
Step("sdc90", "fast_timing", value: "false"),
Step("sdc99", "snapshot"),
};
}
/// <summary>
/// Ordinary civ construction finishes soft-cool world-wide so tent spam cannot chain.
/// </summary>
private static List<HarnessCommand> StoryConstructionCool()
{
return new List<HarnessCommand>
{
Step("sco0", "dismiss_windows"),
Step("sco1", "wait_world"),
Step("sco2", "set_setting", expect: "enabled", value: "true"),
Step("sco3", "fast_timing", value: "true"),
Step("sco4", "spawn", asset: "human", count: 3),
Step("sco5", "spectator", value: "off"),
Step("sco6", "spectator", value: "on"),
Step("sco7", "pick_unit", asset: "human"),
Step("sco8", "focus", asset: "human"),
Step("sco8b", "status_apply", asset: "human", value: "invincible"),
Step("sco9", "interest_end_session"),
Step("sco10", "interest_variety_clear"),
Step("sco11", "interest_story_clear"),
Step("sco20", "interest_force_session", asset: "human",
label: "{a} finishes Tent Human", tier: "Action", expect: "tent_human",
value: "lead=event;evt=72"),
Step("sco21", "assert", expect: "tip_contains", value: "finishes"),
Step("sco22", "assert", expect: "beat_cooled", value: "tent_human"),
Step("sco23", "interest_end_session"),
Step("sco30", "interest_inject", asset: "human",
label: "{a} finishes Tent Human", tier: "Action", expect: "tent_human",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("sco31", "interest_inject", asset: "human",
label: "{a} finishes Tent Dwarf", tier: "Action", expect: "construction",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("sco32", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
expect: "sco_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
Step("sco33", "director_run", wait: 1.2f),
Step("sco34", "assert", expect: "tip_contains", value: "ClearPeer"),
Step("sco35", "assert", expect: "tip_not_contains", value: "finishes"),
Step("sco90", "fast_timing", value: "false"),
Step("sco99", "snapshot"),
};
}
/// <summary>
/// Soft-life tip interrupted by a stronger peer must not resume after the peer ends.
/// </summary>
private static List<HarnessCommand> StoryResumeCooled()
{
return new List<HarnessCommand>
{
Step("src0", "dismiss_windows"),
Step("src1", "wait_world"),
Step("src2", "set_setting", expect: "enabled", value: "true"),
Step("src3", "fast_timing", value: "true"),
Step("src4", "spawn", asset: "human", count: 3),
Step("src5", "spectator", value: "off"),
Step("src6", "spectator", value: "on"),
Step("src7", "pick_unit", asset: "human"),
Step("src8", "focus", asset: "human"),
Step("src8b", "status_apply", asset: "human", value: "invincible"),
Step("src9", "interest_end_session"),
Step("src10", "interest_variety_clear"),
Step("src11", "interest_story_clear"),
// Soft crumb first (resumable FixedDwell - not ForceActive, so epic can cut in).
Step("src20", "interest_inject", asset: "human",
label: "{a} decides to try to reproduce", tier: "Action",
expect: "sexual_reproduction_try",
value: "lead=event;evt=66;char=5;force=false;ttl=60"),
Step("src21", "director_run", wait: 1.2f),
Step("src22", "assert", expect: "tip_contains", value: "try to reproduce"),
Step("src23", "assert", expect: "beat_cooled", value: "sexual_reproduction_try"),
// Stronger peer cuts in (margin above cutInMargin).
Step("src30", "interest_inject", asset: "human", label: "EpicCutIn", tier: "Epic",
expect: "src_epic", value: "lead=event;evt=150;char=10;force=false;ttl=60"),
Step("src31", "director_run", wait: 1.2f),
Step("src32", "assert", expect: "tip_contains", value: "EpicCutIn"),
Step("src33", "assert", expect: "tip_not_contains", value: "reproduce"),
// Ending the cut-in must not resume the cooled reproduce tip.
Step("src40", "interest_end_session"),
Step("src41", "interest_story_clear"),
Step("src42", "interest_inject", asset: "human",
label: "{a} decides to try to reproduce", tier: "Action",
expect: "sexual_reproduction_try",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("src43", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
expect: "src_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
Step("src44", "director_run", wait: 1.2f),
Step("src45", "assert", expect: "tip_contains", value: "ClearPeer"),
Step("src46", "assert", expect: "tip_not_contains", value: "reproduce"),
Step("src47", "assert", expect: "beat_cooled", value: "sexual_reproduction_try"),
Step("src90", "fast_timing", value: "false"),
Step("src99", "snapshot"),
};
}
/// <summary>
/// WorldLog politics meta (king_dead) soft-cools so sticky death crumbs cannot re-monopolize.
/// </summary>
private static List<HarnessCommand> StoryWorldLogMetaCool()
{
return new List<HarnessCommand>
{
Step("swm0", "dismiss_windows"),
Step("swm1", "wait_world"),
Step("swm2", "set_setting", expect: "enabled", value: "true"),
Step("swm3", "fast_timing", value: "true"),
Step("swm4", "spawn", asset: "human", count: 3),
Step("swm5", "spectator", value: "off"),
Step("swm6", "spectator", value: "on"),
Step("swm7", "pick_unit", asset: "human"),
Step("swm8", "focus", asset: "human"),
Step("swm8b", "status_apply", asset: "human", value: "invincible"),
Step("swm9", "interest_end_session"),
Step("swm10", "interest_variety_clear"),
Step("swm11", "interest_story_clear"),
Step("swm20", "interest_force_session", asset: "human",
label: "King died: Frortied (Rugen)", tier: "Action", expect: "king_dead",
value: "lead=event;evt=84"),
Step("swm21", "assert", expect: "tip_contains", value: "King died"),
Step("swm22", "assert", expect: "beat_cooled", value: "king_dead"),
Step("swm23", "interest_end_session"),
Step("swm30", "interest_inject", asset: "human",
label: "King died: Frortied (Rugen)", tier: "Action", expect: "king_dead",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("swm31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
expect: "swm_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
Step("swm32", "director_run", wait: 1.2f),
Step("swm33", "assert", expect: "tip_contains", value: "ClearPeer"),
Step("swm34", "assert", expect: "tip_not_contains", value: "King died"),
Step("swm90", "fast_timing", value: "false"),
Step("swm99", "snapshot"),
};
}
/// <summary>
/// Soft personal status FX (taking_roots) cool world-wide after one show.
/// </summary>
private static List<HarnessCommand> StoryStatusFxCool()
{
return new List<HarnessCommand>
{
Step("sfx0", "dismiss_windows"),
Step("sfx1", "wait_world"),
Step("sfx2", "set_setting", expect: "enabled", value: "true"),
Step("sfx3", "fast_timing", value: "true"),
Step("sfx4", "spawn", asset: "human", count: 3),
Step("sfx5", "spectator", value: "off"),
Step("sfx6", "spectator", value: "on"),
Step("sfx7", "pick_unit", asset: "human"),
Step("sfx8", "focus", asset: "human"),
Step("sfx8b", "status_apply", asset: "human", value: "invincible"),
Step("sfx9", "interest_end_session"),
Step("sfx10", "interest_variety_clear"),
Step("sfx11", "interest_story_clear"),
Step("sfx20", "interest_force_session", asset: "human",
label: "{a} · Starts taking root", tier: "Action", expect: "taking_roots",
value: "lead=event;evt=54"),
Step("sfx21", "assert", expect: "tip_contains", value: "taking root"),
Step("sfx22", "assert", expect: "beat_cooled", value: "taking_roots"),
Step("sfx23", "interest_end_session"),
Step("sfx30", "interest_inject", asset: "human",
label: "{a} · Starts taking root", tier: "Action", expect: "taking_roots",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("sfx31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
expect: "sfx_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
Step("sfx32", "director_run", wait: 1.2f),
Step("sfx33", "assert", expect: "tip_contains", value: "ClearPeer"),
Step("sfx34", "assert", expect: "tip_not_contains", value: "taking root"),
Step("sfx90", "fast_timing", value: "false"),
Step("sfx99", "snapshot"),
};
}
/// <summary>
/// Sleep / wake / dream rest cycle soft-cools as one class after one show.
/// </summary>
private static List<HarnessCommand> StoryRestCool()
{
return new List<HarnessCommand>
{
Step("srt0", "dismiss_windows"),
Step("srt1", "wait_world"),
Step("srt2", "set_setting", expect: "enabled", value: "true"),
Step("srt3", "fast_timing", value: "true"),
Step("srt4", "spawn", asset: "human", count: 3),
Step("srt5", "spectator", value: "off"),
Step("srt6", "spectator", value: "on"),
Step("srt7", "pick_unit", asset: "human"),
Step("srt8", "focus", asset: "human"),
Step("srt8b", "status_apply", asset: "human", value: "invincible"),
Step("srt9", "interest_end_session"),
Step("srt10", "interest_variety_clear"),
Step("srt11", "interest_story_clear"),
Step("srt20", "interest_force_session", asset: "human",
label: "{a} · Falls asleep", tier: "Action", expect: "sleeping",
value: "lead=event;evt=54"),
Step("srt21", "assert", expect: "tip_contains", value: "Falls asleep"),
Step("srt22", "assert", expect: "beat_cooled", value: "sleeping"),
// Shared rest class also cools wake / dream crumbs.
Step("srt23", "assert", expect: "beat_cooled", value: "just_slept"),
Step("srt24", "interest_end_session"),
Step("srt30", "interest_inject", asset: "human",
label: "{a} · Falls asleep", tier: "Action", expect: "sleeping",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("srt31", "interest_inject", asset: "human",
label: "{a} ends a rest", tier: "Action", expect: "just_slept",
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
Step("srt32", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
expect: "srt_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
Step("srt33", "director_run", wait: 1.2f),
Step("srt34", "assert", expect: "tip_contains", value: "ClearPeer"),
Step("srt35", "assert", expect: "tip_not_contains", value: "Falls asleep"),
Step("srt36", "assert", expect: "tip_not_contains", value: "ends a rest"),
Step("srt90", "fast_timing", value: "false"),
Step("srt99", "snapshot"),
};
}
/// <summary>
/// Live DeferredInterestFeed EmitDecision path cools intent class (not only harness inject).
/// </summary>
private static List<HarnessCommand> StoryDecisionIntentLive()
{
return new List<HarnessCommand>
{
Step("sdl0", "dismiss_windows"),
Step("sdl1", "wait_world"),
Step("sdl2", "set_setting", expect: "enabled", value: "true"),
Step("sdl3", "fast_timing", value: "true"),
Step("sdl4", "spawn", asset: "human", count: 3),
Step("sdl5", "spectator", value: "off"),
Step("sdl6", "spectator", value: "on"),
Step("sdl7", "pick_unit", asset: "human"),
Step("sdl8", "focus", asset: "human"),
Step("sdl8b", "status_apply", asset: "human", value: "invincible"),
Step("sdl9", "interest_end_session"),
Step("sdl10", "interest_variety_clear"),
Step("sdl11", "interest_story_clear"),
Step("sdl20", "decision_emit", expect: "sexual_reproduction_try"),
Step("sdl21", "director_run", wait: 1.2f),
Step("sdl22", "assert", expect: "tip_contains", value: "try to reproduce"),
Step("sdl23", "assert", expect: "beat_cooled", value: "sexual_reproduction_try"),
Step("sdl24", "assert", expect: "beat_cooled", value: "decision_find_lover"),
Step("sdl25", "interest_end_session"),
Step("sdl26", "interest_story_clear"),
Step("sdl30", "decision_emit", expect: "find_lover"),
Step("sdl31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
expect: "sdl_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
Step("sdl32", "director_run", wait: 1.2f),
Step("sdl33", "assert", expect: "tip_contains", value: "ClearPeer"),
Step("sdl34", "assert", expect: "tip_not_contains", value: "reproduce"),
Step("sdl35", "assert", expect: "tip_not_contains", value: "find a lover"),
Step("sdl90", "fast_timing", value: "false"),
Step("sdl99", "snapshot"),
};
}
/// <summary>
/// Peak Mass with one sticky camp wiped to 0 must not print "vs … (0)".
/// </summary>
private static List<HarnessCommand> CombatWipedSide()
{
return new List<HarnessCommand>
{
Step("cws0", "dismiss_windows"),
Step("cws1", "wait_world"),
Step("cws2", "set_setting", expect: "enabled", value: "true"),
Step("cws3", "fast_timing", value: "true"),
Step("cws4", "spawn", asset: "human", count: 4),
Step("cws5", "spawn", asset: "wolf", count: 4),
Step("cws6", "spectator", value: "off"),
Step("cws7", "spectator", value: "on"),
Step("cws8", "pick_unit", asset: "human"),
Step("cws9", "focus", asset: "human"),
Step("cws10", "interest_end_session"),
Step("cws20", "interest_combat_session", asset: "human", value: "wolf", expect: "cws_pack"),
Step("cws21", "combat_ensemble_escalate", value: "8"),
Step("cws22", "combat_wire_attack_sides", asset: "human", value: "wolf"),
Step("cws23", "combat_maintain_focus"),
Step("cws24", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
Step("cws25", "assert", expect: "tip_matches_any", value: " vs "),
// Wipe sticky B only (do not maintain - live wolves would re-enroll on SideBKey).
Step("cws30", "combat_wipe_sticky_side", value: "b"),
Step("cws31", "assert", expect: "tip_not_contains", value: "(0)"),
Step("cws32", "assert", expect: "tip_not_contains", value: " vs "),
Step("cws33", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
Step("cws90", "fast_timing", value: "false"),
Step("cws99", "snapshot"),
};
}
/// <summary>
/// Pair-locked duel reason only on PairOwner/Partner - parked sticky Follow bystander
/// must not show "Duel - A vs B" on their dossier.
/// </summary>
private static List<HarnessCommand> ReasonDuelPrincipal()
{
return new List<HarnessCommand>
{
Step("rdp0", "dismiss_windows"),
Step("rdp1", "wait_world"),
Step("rdp2", "set_setting", expect: "enabled", value: "true"),
Step("rdp3", "fast_timing", value: "true"),
Step("rdp4", "spawn", asset: "human", count: 1),
Step("rdp5", "spawn", asset: "wolf", count: 1),
Step("rdp6", "spectator", value: "off"),
Step("rdp7", "spectator", value: "on"),
Step("rdp8", "pick_unit", asset: "human"),
Step("rdp9", "focus", asset: "human"),
Step("rdp10", "interest_end_session"),
Step("rdp20", "interest_combat_session", asset: "human", value: "wolf", expect: "rdp_duel"),
Step("rdp20b", "combat_isolate_pair", value: "20"),
Step("rdp21", "status_apply", asset: "human", value: "invincible"),
Step("rdp22", "status_apply", asset: "wolf", value: "invincible"),
Step("rdp23", "assert", expect: "tip_matches_any", value: "Duel -"),
Step("rdp24", "assert", expect: "reason_matches_any", value: "Duel -"),
Step("rdp25", "assert", expect: "reason_owns_focus"),
// Park Follow on a rostered sleeper who is not PairOwner/Partner.
Step("rdp30", "combat_park_bystander_follow", asset: "human"),
Step("rdp31", "wait", value: "0.25"),
Step("rdp32", "assert", expect: "dossier_matches_focus"),
Step("rdp33", "assert", expect: "reason_empty", value: "true"),
Step("rdp34", "assert", expect: "reason_owns_focus"),
Step("rdp90", "fast_timing", value: "false"),
Step("rdp99", "snapshot"),
};
}
/// <summary>Large score gap always picks #1; within epsilon may roll among top-3.</summary>
private static List<HarnessCommand> StoryNearTie()
{
@ -1546,6 +2295,86 @@ internal static class HarnessScenarios
};
}
/// <summary>
/// Spine only while the watch tip is the story beat; find_lover intent is not a Love climax;
/// leaving a climax for an unrelated tip clears the arc.
/// </summary>
private static List<HarnessCommand> StorySpineScoped()
{
return new List<HarnessCommand>
{
Step("ss0", "dismiss_windows"),
Step("ss1", "wait_world"),
Step("ss2", "set_setting", expect: "enabled", value: "true"),
Step("ss3", "fast_timing", value: "true"),
Step("ss4", "spawn", asset: "human", count: 1),
Step("ss5", "spawn", asset: "wolf", count: 1),
Step("ss6", "spectator", value: "off"),
Step("ss7", "spectator", value: "on"),
Step("ss8", "pick_unit", asset: "human"),
Step("ss9", "focus", asset: "human"),
Step("ss10", "interest_end_session"),
// Seeking a lover must stay camera-worthy without opening Love · Climax.
Step("ss20", "interest_force_session", asset: "human",
label: "{a} is seeking a lover", tier: "Action", expect: "find_lover",
value: "lead=event;evt=45"),
Step("ss21", "assert", expect: "tip_contains", value: "seeking a lover"),
Step("ss22", "assert", expect: "story_phase", value: "None"),
Step("ss23", "assert", expect: "story_spine", value: "empty"),
// Real bond opens Love climax + spine.
Step("ss30", "interest_force_session", asset: "human",
label: "{a} became lovers with a partner", tier: "Action", expect: "set_lover",
value: "lead=event;evt=88"),
Step("ss31", "assert", expect: "story_phase", value: "Climax"),
Step("ss32", "assert", expect: "story_spine", value: "Love · Climax"),
// Unrelated hatch tip must clear the arc and hide the spine.
Step("ss40", "interest_force_session", asset: "human",
label: "{a} hatches from an egg", tier: "Action", expect: "hatch_leave_love",
value: "lead=event;evt=95"),
Step("ss41", "assert", expect: "tip_contains", value: "hatches from an egg"),
Step("ss42", "assert", expect: "story_phase", value: "None"),
Step("ss43", "assert", expect: "story_spine", value: "empty"),
// Combat climax spine, then leave for hatch again.
Step("ss50", "interest_end_session"),
Step("ss51", "interest_combat_session", asset: "human", value: "wolf", expect: "ss_pack"),
Step("ss52", "combat_isolate_pair", value: "20"),
Step("ss53", "status_apply", asset: "human", value: "invincible"),
Step("ss54", "status_apply", asset: "wolf", value: "invincible"),
Step("ss55", "assert", expect: "story_phase", value: "Climax"),
Step("ss56", "assert", expect: "story_spine", value: "Duel · Climax"),
// In-place escalate must retarget Kind Mass without a director SwitchTo.
Step("ss57", "combat_ensemble_escalate", value: "6"),
Step("ss57b", "combat_wire_attack_sides", asset: "human", value: "wolf"),
Step("ss57c", "combat_maintain_focus"),
Step("ss57d", "wait", value: "0.35"),
Step("ss57e", "combat_maintain_focus"),
Step("ss58", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
// Spine Kind follows tip Label (Battle/Mass/Skirmish), not a collapsed Mass bucket.
Step("ss59", "assert", expect: "story_spine",
value: "Battle · Climax|Mass · Climax|Skirmish · Climax"),
// Shrink back to a named duel - spine Kind must follow the Label, not scale-hold.
Step("ss59b", "combat_isolate_pair", value: "20"),
Step("ss59c", "combat_maintain_focus"),
Step("ss59d", "wait", value: "0.35"),
Step("ss59e", "combat_maintain_focus"),
Step("ss59f", "assert", expect: "tip_matches_any", value: "Duel -"),
Step("ss59g", "assert", expect: "story_spine", value: "Duel · Climax"),
Step("ss60", "interest_force_session", asset: "human",
label: "{a} appears in the world", tier: "Action", expect: "spawn_leave_duel",
value: "lead=event;evt=95"),
Step("ss61", "assert", expect: "tip_contains", value: "appears in the world"),
Step("ss62", "assert", expect: "story_phase", value: "None"),
Step("ss63", "assert", expect: "story_spine", value: "empty"),
Step("ss90", "fast_timing", value: "false"),
Step("ss99", "snapshot"),
};
}
/// <summary>Combat climax → aftermath ownership; soft ambient must not steal mid-aftermath.</summary>
private static List<HarnessCommand> StoryArcCombat()
{
@ -1568,23 +2397,78 @@ internal static class HarnessScenarios
Step("sac22", "status_apply", asset: "wolf", value: "invincible"),
Step("sac23", "assert", expect: "tip_matches_any", value: "Duel -"),
Step("sac24", "assert", expect: "story_phase", value: "Climax"),
Step("sac24b", "assert", expect: "story_spine", value: "Duel · Climax"),
Step("sac25", "interest_expire_pending", value: ""),
Step("sac26", "age_current", wait: 10f),
Step("sac27", "interest_mark_combat_cold"),
Step("sac28", "director_run", wait: 1.5f),
Step("sac29", "assert", expect: "tip_asset", value: "aftermath_"),
Step("sac30", "assert", expect: "story_phase", value: "Aftermath"),
Step("sac30b", "assert", expect: "story_spine", value: "Duel · Aftermath"),
// Soft anonymous duels do not raise arcHoldMargin (HardHold=false); ownership still holds.
// Weak ambient peer must not steal mid-aftermath.
Step("sac31", "interest_inject", asset: "human", label: "WeakPeer", tier: "Curiosity",
expect: "sac_weak", value: "lead=event;evt=30;char=5;force=false;ttl=60"),
Step("sac32", "director_run", wait: 1.0f),
Step("sac33", "assert", expect: "tip_asset", value: "aftermath_"),
Step("sac34", "assert", expect: "tip_not_contains", value: "WeakPeer"),
// Leaving the story beat for cast life must drop the raised hold wall.
Step("sac40", "interest_force_session", asset: "human",
label: "{a} feels the joy of parenthood", tier: "Action", expect: "just_had_child",
value: "lead=event;evt=76"),
Step("sac41", "assert", expect: "tip_contains", value: "parenthood"),
Step("sac42", "assert", expect: "story_phase", value: "None"),
Step("sac43", "assert", expect: "story_hold_margin", value: "0"),
Step("sac44", "assert", expect: "story_spine", value: "empty"),
Step("sac90", "fast_timing", value: "false"),
Step("sac99", "snapshot"),
};
}
/// <summary>
/// Parenthood cools world-wide for a while; a second villager cannot chain the same tip.
/// </summary>
private static List<HarnessCommand> StoryFamilyVariety()
{
return new List<HarnessCommand>
{
Step("sf0", "dismiss_windows"),
Step("sf1", "wait_world"),
Step("sf2", "set_setting", expect: "enabled", value: "true"),
Step("sf3", "fast_timing", value: "true"),
Step("sf4", "spawn", asset: "human", count: 2),
Step("sf5", "spectator", value: "off"),
Step("sf6", "spectator", value: "on"),
Step("sf7", "pick_unit", asset: "human"),
Step("sf8", "focus", asset: "human"),
Step("sf9", "interest_end_session"),
Step("sf10", "interest_variety_clear"),
Step("sf20", "interest_force_session", asset: "human",
label: "{a} feels the joy of parenthood", tier: "Action", expect: "just_had_child",
value: "lead=event;evt=76"),
Step("sf21", "assert", expect: "tip_contains", value: "parenthood"),
Step("sf22", "assert", expect: "beat_cooled", value: "just_had_child"),
Step("sf23", "assert", expect: "variety_arc", value: "life:family"),
// Second human parenthood must stay beat-cooled; a peer tip may still take the camera.
Step("sf30", "interest_end_session"),
Step("sf31", "interest_expire_pending", value: ""),
Step("sf32", "interest_inject", asset: "human",
label: "{a} feels the joy of parenthood", tier: "Action", expect: "just_had_child_b",
value: "lead=event;evt=90;char=5;force=false;ttl=60;pick=other"),
Step("sf33", "interest_inject", asset: "human", label: "VillagePeer", tier: "Action",
expect: "sf_peer", value: "lead=event;evt=55;char=5;force=true;ttl=60;pick=other"),
Step("sf34", "director_run", wait: 0.8f),
Step("sf35", "assert", expect: "tip_contains", value: "VillagePeer"),
Step("sf36", "assert", expect: "tip_not_contains", value: "parenthood"),
Step("sf37", "assert", expect: "beat_cooled", value: "just_had_child"),
Step("sf90", "fast_timing", value: "false"),
Step("sf99", "snapshot"),
};
}
/// <summary>Epic preempt during climax; aftermath still available after cold.</summary>
private static List<HarnessCommand> StoryArcPreemptResume()
{

View file

@ -121,7 +121,7 @@ public static class InterestDirector
/// <summary>
/// Candidate that may drive the orange dossier reason: owns <paramref name="unit"/>
/// while the director still holds that scene (not quiet_grace).
/// Sticky combat may briefly report !IsActive between swings - keep the reason up.
/// Unit must be a tip principal (not a sticky bystander under a named duel).
/// </summary>
public static InterestCandidate TryGetOwnedReasonCandidate(Actor unit)
{
@ -130,37 +130,125 @@ public static class InterestDirector
return null;
}
long unitId = 0;
try
// Dossier subject asking for the reason must match camera focus when focused.
if (MoveCamera.hasFocusUnit()
&& MoveCamera._focus_unit != null
&& MoveCamera._focus_unit != unit
&& EventFeedUtil.SafeId(MoveCamera._focus_unit) != EventFeedUtil.SafeId(unit))
{
unitId = unit.getID();
}
catch
{
unitId = 0;
return null;
}
if (_current.FollowUnit == unit)
return UnitIsReasonPrincipal(unit, _current) ? _current : null;
}
/// <summary>
/// True when <paramref name="unit"/> may show <paramref name="scene"/>'s Label as orange reason.
/// Pair-locked duel tips only name PairOwner/PairPartner - sticky Follow alone is not enough
/// (avoids bystander dossier + "Duel - A vs B" for unrelated fighters).
/// </summary>
public static bool UnitIsReasonPrincipal(Actor unit, InterestCandidate scene)
{
if (unit == null || scene == null)
{
return _current;
return false;
}
if (_current.SubjectId != 0 && unitId != 0 && _current.SubjectId == unitId)
long unitId = EventFeedUtil.SafeId(unit);
if (unitId == 0)
{
return _current;
return false;
}
if (_current.RelatedUnit == unit)
bool ensemble = scene.Completion == InterestCompletionKind.CombatActive
|| scene.Completion == InterestCompletionKind.WarFront
|| scene.Completion == InterestCompletionKind.PlotActive
|| scene.Completion == InterestCompletionKind.FamilyPack
|| scene.Completion == InterestCompletionKind.StatusOutbreak
|| InterestScoring.IsCombatAction(scene);
if (ensemble)
{
return _current;
bool massTheater = scene.Sticky != null
&& scene.Sticky.HasPresentedScale
&& scene.Sticky.PresentedScale >= EnsembleScale.Skirmish;
if (scene.HasCombatPairLock && !massTheater)
{
// Named-pair duel: only the locked duelists own the reason.
return unitId == scene.PairOwnerId || unitId == scene.PairPartnerId;
}
if (unitId == scene.SubjectId
|| scene.FollowUnit == unit
|| unitId == scene.PairOwnerId
|| unitId == scene.PairPartnerId
|| unitId == scene.TheaterLeadId)
{
return true;
}
if (StickyRosterContains(scene, unitId))
{
return true;
}
// Related alone is not enough for ensemble theaters (crowd / wrong camp).
return false;
}
if (_current.RelatedId != 0 && unitId != 0 && _current.RelatedId == unitId)
// Life / discrete tips: Subject + Related are authoritative.
// Follow alone is not enough after a camera handoff left SubjectId on someone else
// (avoids Hillih dossier + "Iksssssa shares intimacy…" stale Label).
if (scene.SubjectId != 0 && unitId == scene.SubjectId)
{
return _current;
return true;
}
return null;
if (scene.RelatedId != 0 && unitId == scene.RelatedId)
{
return true;
}
if (scene.RelatedUnit == unit)
{
return true;
}
if (scene.FollowUnit == unit
&& (scene.SubjectId == 0 || unitId == scene.SubjectId))
{
return true;
}
return false;
}
private static bool StickyRosterContains(InterestCandidate scene, long unitId)
{
if (scene?.Sticky == null || unitId == 0)
{
return false;
}
LiveSceneStickyState sticky = scene.Sticky;
for (int i = 0; i < sticky.SideAIds.Count; i++)
{
if (sticky.SideAIds[i] == unitId)
{
return true;
}
}
for (int i = 0; i < sticky.SideBIds.Count; i++)
{
if (sticky.SideBIds[i] == unitId)
{
return true;
}
}
return false;
}
public static string InterruptedKey =>
@ -708,6 +796,13 @@ public static class InterestDirector
return;
}
// Story aftermath/epilogue: promote another living cast member before ending.
if (StoryPlanner.TryRecoverStoryFollow(_current))
{
CameraDirector.Watch(_current.ToInterestEvent());
return;
}
// Ownership-preserving handoff only: related survivor. Never invent a stranger.
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
{
@ -849,6 +944,7 @@ public static class InterestDirector
CameraDirector.Watch(_current.ToInterestEvent());
}
StoryPlanner.SyncActiveClimax(_current);
return true;
}
@ -992,6 +1088,7 @@ public static class InterestDirector
CameraDirector.Watch(_current.ToInterestEvent());
}
StoryPlanner.SyncActiveClimax(_current);
return true;
}
@ -1250,6 +1347,8 @@ public static class InterestDirector
CameraDirector.Watch(_current.ToInterestEvent());
}
// Framing-only Battle↔Duel still needs story Kind sync (no SwitchTo).
StoryPlanner.SyncActiveClimax(_current);
return true;
}
@ -3231,6 +3330,15 @@ public static class InterestDirector
}
}
if (StoryPlanner.TryRecoverStoryFollow(_current))
{
CameraDirector.Watch(_current.ToInterestEvent());
if (HasLivingCameraFocus())
{
return;
}
}
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
{
// Non-combat ownership handoff; sticky scenes already tried picker above.
@ -3277,14 +3385,26 @@ public static class InterestDirector
StoryPlanner.OnEndCurrent(ended, now);
// Resume interrupted Epic-preempted scene if still valid.
// Soft-life / moment beats already NoteSelection-cooled must not cut back in
// after a combat/peer cut (soak: hatch/reproduce resume spam).
if (_interrupted != null
&& _interrupted.HasValidPosition
&& InterestCompletion.IsActive(_interrupted, now - _interrupted.MinWatch, now))
{
InterestCandidate resume = _interrupted;
_interrupted = null;
SwitchTo(resume, now, resumableInterrupt: false);
return;
if (InterestVariety.IsBeatCooled(resume, now))
{
InterestDropLog.Record(
"resume_cooled",
resume.HappinessEffectId ?? resume.AssetId ?? resume.Key ?? "beat");
InterestRegistry.Remove(resume.Key);
}
else
{
SwitchTo(resume, now, resumableInterrupt: false);
return;
}
}
// Prefer queued story aftermath/epilogue over ambient fill.
@ -4548,7 +4668,9 @@ public static class InterestDirector
if (_current != null
&& _current.Resumable
&& next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin)
&& next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin
// Already-shown moment beats are cooled - parking them only enables resume spam.
&& !InterestVariety.IsBeatCooled(_current, now))
{
_interrupted = _current.CloneShallow();
}

View file

@ -36,6 +36,12 @@ public class StoryWeights
public int ledgerCap = 16;
public float ledgerWeight = 8f;
public float historyDensityWindowSeconds = 600f;
/// <summary>Multiply subject ledger heat after a family life chapter tip is shown.</summary>
public float ledgerBleedLifeChapter = 0.4f;
/// <summary>Multiply other ledger entries after a family life chapter (village cool).</summary>
public float ledgerBleedVillage = 0.82f;
/// <summary>Extra FixedDwell beat cool for family chapters (seconds, stacks with base).</summary>
public float familyLifeBeatCooldownSeconds = 90f;
}
/// <summary>Serializable weight block — field names must match scoring-model.json "weights".</summary>

File diff suppressed because it is too large Load diff

View file

@ -151,6 +151,61 @@ public static class CharacterLedger
}
}
/// <summary>
/// Soft life chapters (parenthood, intimacy) should not keep ledger heat hot enough
/// to monopolize the next few picks for the same village cast.
/// </summary>
public static void CoolAfterSoftLifeBeat(InterestCandidate scene)
{
if (scene == null || !InterestVariety.IsSoftLifeChapter(scene))
{
return;
}
StoryWeights s = InterestScoringConfig.Story;
float subjectMul = s.ledgerBleedLifeChapter > 0f ? s.ledgerBleedLifeChapter : 0.4f;
float villageMul = s.ledgerBleedVillage > 0f ? s.ledgerBleedVillage : 0.82f;
Bleed(scene.SubjectId, subjectMul);
if (scene.RelatedId != 0)
{
Bleed(scene.RelatedId, Mathf.Lerp(subjectMul, 1f, 0.35f));
}
// Mild cool across the rest of the ledger so harness-featured villagers fade.
for (int i = 0; i < Entries.Count; i++)
{
long id = Entries[i].UnitId;
if (id == 0 || id == scene.SubjectId || id == scene.RelatedId)
{
continue;
}
Bleed(id, villageMul);
}
}
public static void Bleed(long unitId, float multiplyHeat)
{
if (unitId == 0 || multiplyHeat >= 0.999f)
{
return;
}
multiplyHeat = Mathf.Clamp(multiplyHeat, 0.05f, 1f);
for (int i = 0; i < Entries.Count; i++)
{
if (Entries[i].UnitId != unitId)
{
continue;
}
LedgerEntry e = Entries[i];
e.Heat = Mathf.Max(0.15f, e.Heat * multiplyHeat);
Entries[i] = e;
return;
}
}
public static void Enumerate(List<long> into)
{
if (into == null)

View file

@ -13,15 +13,259 @@ public static class StoryPlanner
private static StoryArc _active;
private static bool _coldHookFired;
private static string _coldHookClimaxKey = "";
private static string _coldHookAnchor = "";
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(64);
public static StoryArc Active => _active != null && _active.IsActive ? _active : null;
/// <summary>
/// Quiet dossier spine: "Duel · Aftermath".
/// Empty when no live arc, or when the current watch tip is not that story beat.
/// </summary>
public static string FormatSpineLabel(InterestCandidate watch = null, StoryArc arc = null)
{
watch = watch ?? InterestDirector.CurrentCandidate;
// Sticky combat can reframe Battle↔Duel without SwitchTo - keep Kind honest first.
SyncActiveClimax(watch);
arc = arc ?? Active;
if (arc == null || !arc.IsActive || !BelongsToActiveStoryBeat(watch, arc))
{
return "";
}
string kind = FormatKindLabel(arc.Kind);
// Combat tip prose owns the Kind word (Battle/Skirmish/Mass/Duel) so the spine
// matches the orange tip, not a collapsed CombatMass bucket.
string tipKind = CombatSpineKindFromTip(watch);
if (!string.IsNullOrEmpty(tipKind))
{
kind = tipKind;
}
string phase = FormatPhaseLabel(arc.Phase);
if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(phase))
{
return "";
}
return kind + " · " + phase;
}
/// <summary>
/// Spine Kind word from the live tip Label (Duel/Skirmish/Battle/Mass).
/// Empty when the tip is not a framed combat scale tip.
/// </summary>
private static string CombatSpineKindFromTip(InterestCandidate watch)
{
if (watch == null)
{
return "";
}
string label = watch.Label ?? "";
if (label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase))
{
return "Duel";
}
if (label.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase))
{
return "Skirmish";
}
if (label.StartsWith("Battle", StringComparison.OrdinalIgnoreCase))
{
return "Battle";
}
if (label.StartsWith("Mass", StringComparison.OrdinalIgnoreCase))
{
return "Mass";
}
return "";
}
/// <summary>
/// When the current combat tip changes scale in place (no director SwitchTo),
/// retarget the active climax Kind (Mass ↔ Duel) so the spine matches the tip.
/// </summary>
public static void SyncActiveClimax(InterestCandidate current = null)
{
current = current ?? InterestDirector.CurrentCandidate;
if (_active == null || !_active.IsActive || _active.Phase != StoryPhase.Climax || current == null)
{
return;
}
if (!TryClassifyClimax(current, out StoryArcKind kind, out bool hardHold))
{
return;
}
string anchor = AnchorFor(current, kind);
bool sameAnchor = string.Equals(_active.AnchorKey, anchor, StringComparison.Ordinal);
bool sameCombatTheater = IsCombatKind(_active.Kind)
&& IsCombatKind(kind)
&& SameCombatTheater(_active, current);
if (!sameAnchor && !sameCombatTheater)
{
return;
}
if (_active.Kind != kind)
{
_active.Kind = kind;
_active.Id = "arc:" + kind + ":" + (_active.AnchorKey ?? anchor);
}
if (!string.IsNullOrEmpty(anchor)
&& (sameAnchor || sameCombatTheater))
{
_active.AnchorKey = anchor;
}
if (!string.IsNullOrEmpty(current.Key))
{
_active.ClimaxKey = current.Key;
}
_active.HardHold = hardHold || _active.HardHold;
NoteCastFrom(current, _active);
}
private static bool IsCombatKind(StoryArcKind kind) =>
kind == StoryArcKind.CombatDuel || kind == StoryArcKind.CombatMass;
private static bool SameCombatTheater(StoryArc arc, InterestCandidate c)
{
if (arc == null || c == null)
{
return false;
}
if (!string.IsNullOrEmpty(arc.ClimaxKey)
&& string.Equals(arc.ClimaxKey, c.Key, StringComparison.Ordinal))
{
return true;
}
if (c.PairOwnerId != 0 && c.PairPartnerId != 0)
{
string pair = EventCatalog.Combat.PairKey(c.PairOwnerId, c.PairPartnerId);
if (!string.IsNullOrEmpty(arc.AnchorKey)
&& string.Equals(arc.AnchorKey, pair, StringComparison.Ordinal))
{
return true;
}
}
return arc.ContainsCast(c.SubjectId) && (c.RelatedId == 0 || arc.ContainsCast(c.RelatedId));
}
/// <summary>
/// True when <paramref name="c"/> is the active arc's climax tip or story aftermath/epilogue beat.
/// Cast membership alone is not enough (avoids stale Love/Duel spines on sleep/hatch tips).
/// </summary>
public static bool BelongsToActiveStoryBeat(InterestCandidate c, StoryArc arc = null)
{
arc = arc ?? Active;
if (c == null || arc == null || !arc.IsActive)
{
return false;
}
if (IsStoryAssetCandidate(c))
{
// Aftermath/epilogue tips only while the arc is in those phases,
// and only for THIS arc (CorrelationKey) - never a stale peer story tip.
if (arc.Phase != StoryPhase.Aftermath && arc.Phase != StoryPhase.Epilogue)
{
return false;
}
if (string.IsNullOrEmpty(arc.AnchorKey))
{
return false;
}
string expect = "story:" + arc.AnchorKey;
return string.Equals(c.CorrelationKey, expect, StringComparison.Ordinal);
}
// Cold climax tip must not show "Mass · Aftermath" while fleeing after inject.
if (!string.IsNullOrEmpty(arc.ClimaxKey)
&& string.Equals(c.Key, arc.ClimaxKey, StringComparison.Ordinal))
{
return arc.Phase == StoryPhase.Climax;
}
// Same sticky theater refreshed under the same anchor while still in climax.
// Kind may still be syncing Mass↔Duel - allow either combat kind on the same theater.
if (arc.Phase == StoryPhase.Climax
&& TryClassifyClimax(c, out StoryArcKind kind, out _)
&& IsCombatKind(arc.Kind)
&& IsCombatKind(kind)
&& (kind == arc.Kind
|| SameCombatTheater(arc, c)
|| string.Equals(AnchorFor(c, kind), arc.AnchorKey, StringComparison.Ordinal)))
{
return true;
}
if (arc.Phase == StoryPhase.Climax
&& TryClassifyClimax(c, out kind, out _)
&& kind == arc.Kind
&& string.Equals(AnchorFor(c, kind), arc.AnchorKey, StringComparison.Ordinal))
{
return true;
}
return false;
}
public static string FormatKindLabel(StoryArcKind kind)
{
switch (kind)
{
case StoryArcKind.CombatDuel:
return "Duel";
case StoryArcKind.CombatMass:
return "Mass";
case StoryArcKind.WarFront:
return "War front";
case StoryArcKind.Plot:
return "Plot";
case StoryArcKind.Love:
return "Love";
case StoryArcKind.Grief:
return "Grief";
default:
return "";
}
}
public static string FormatPhaseLabel(StoryPhase phase)
{
switch (phase)
{
case StoryPhase.Climax:
return "Climax";
case StoryPhase.Aftermath:
return "Aftermath";
case StoryPhase.Epilogue:
return "Epilogue";
default:
return "";
}
}
public static void Clear()
{
_active = null;
_coldHookFired = false;
_coldHookClimaxKey = "";
_coldHookAnchor = "";
CausalHeat.Clear();
CharacterLedger.Clear();
}
@ -33,6 +277,8 @@ public static class StoryPlanner
return;
}
SyncActiveClimax(InterestDirector.CurrentCandidate);
// Stale arcs without a living cast eventually end.
if (_active.Phase == StoryPhase.Aftermath || _active.Phase == StoryPhase.Epilogue)
{
@ -81,6 +327,13 @@ public static class StoryPlanner
if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold))
{
BeginOrRefreshArc(next, kind, hardHold, now);
return;
}
// Camera left the story for an unrelated tip - do not keep a lying active arc.
if (_active != null && _active.IsActive && !BelongsToActiveStoryBeat(next, _active))
{
_active.Advance(StoryPhase.Done, now);
}
}
@ -91,15 +344,11 @@ public static class StoryPlanner
return;
}
if (!string.IsNullOrEmpty(_active.ClimaxKey)
&& string.Equals(ended.Key, _active.ClimaxKey, StringComparison.Ordinal)
&& _active.Phase == StoryPhase.Climax)
// follow_lost / max_cap often end a soft duel before quiet-grace cold.
// Match by theater (Mass↔Duel key flip), not exact ClimaxKey only.
if (_active.Phase == StoryPhase.Climax && BelongsToClimaxTheater(ended, _active))
{
// Cold-hook should have advanced; if not, still try aftermath once.
if (!_coldHookFired || !string.Equals(_coldHookClimaxKey, ended.Key, StringComparison.Ordinal))
{
OnClimaxCold(ended);
}
OnClimaxCold(ended);
}
if (IsStoryAssetCandidate(ended))
@ -126,9 +375,26 @@ public static class StoryPlanner
}
float now = Time.unscaledTime;
// Once per climax key - director may re-enter inactive while min-dwell resets grace.
// Sticky combat can reframe keys in place before cold - keep arc/climax aligned.
if (_active != null && _active.IsActive && _active.Phase == StoryPhase.Climax)
{
SyncActiveClimax(climax);
}
string climaxKey = climax.Key ?? "";
string anchor = _active != null && _active.IsActive
? (_active.AnchorKey ?? "")
: "";
if (string.IsNullOrEmpty(anchor) && TryClassifyClimax(climax, out StoryArcKind classifyKind, out _))
{
anchor = AnchorFor(climax, classifyKind);
}
// Once per climax theater (anchor), not per sticky key refresh.
if (_coldHookFired
&& string.Equals(_coldHookClimaxKey, climax.Key, StringComparison.Ordinal))
&& (!string.IsNullOrEmpty(_coldHookAnchor)
&& string.Equals(_coldHookAnchor, anchor, StringComparison.Ordinal)
|| string.Equals(_coldHookClimaxKey, climaxKey, StringComparison.Ordinal)))
{
return;
}
@ -139,8 +405,7 @@ public static class StoryPlanner
return;
}
if (_active == null || !_active.IsActive
|| !string.Equals(_active.ClimaxKey, climax.Key, StringComparison.Ordinal))
if (_active == null || !_active.IsActive || !BelongsToClimaxTheater(climax, _active))
{
if (!TryClassifyClimax(climax, out kind, out hardHold))
{
@ -148,10 +413,22 @@ public static class StoryPlanner
}
BeginOrRefreshArc(climax, kind, hardHold, now);
anchor = _active != null ? (_active.AnchorKey ?? anchor) : anchor;
}
else if (!string.IsNullOrEmpty(climaxKey))
{
_active.ClimaxKey = climaxKey;
if (!string.IsNullOrEmpty(_active.AnchorKey))
{
anchor = _active.AnchorKey;
}
}
_coldHookFired = true;
_coldHookClimaxKey = climax.Key ?? "";
_coldHookClimaxKey = !string.IsNullOrEmpty(climaxKey)
? climaxKey
: (_active != null ? (_active.ClimaxKey ?? "") : "");
_coldHookAnchor = anchor ?? "";
if (TryClaimNaturalGrief(climax, _active))
{
@ -168,14 +445,50 @@ public static class StoryPlanner
if (HasStrongerPendingPeer(climax))
{
// Natural drama already queued (e.g. lover tip in combat_hold) - do not steal.
// Natural grief/love aftermath already queued - do not synthesize a twin.
// End climax ownership so spine/hold cannot stick across the peer tip.
if (_active != null && _active.IsActive && _active.Phase == StoryPhase.Climax)
{
_active.Advance(StoryPhase.Done, now);
}
return;
}
if (TryInjectAftermath(climax, _active, now))
{
_active.Advance(StoryPhase.Aftermath, now);
return;
}
if (_active != null && _active.IsActive && _active.Phase == StoryPhase.Climax)
{
_active.Advance(StoryPhase.Done, now);
}
}
/// <summary>
/// Same climax theater even when sticky combat refreshes the candidate key (Battle↔Duel).
/// </summary>
private static bool BelongsToClimaxTheater(InterestCandidate c, StoryArc arc)
{
if (c == null || arc == null || !arc.IsActive)
{
return false;
}
if (!string.IsNullOrEmpty(arc.ClimaxKey)
&& string.Equals(c.Key, arc.ClimaxKey, StringComparison.Ordinal))
{
return true;
}
if (BelongsToActiveStoryBeat(c, arc))
{
return true;
}
return IsCombatKind(arc.Kind) && SameCombatTheater(arc, c);
}
public static bool IsContinuationOf(InterestCandidate current, InterestCandidate next)
@ -269,6 +582,14 @@ public static class StoryPlanner
return 0f;
}
// Hold only while the camera is still on the aftermath/epilogue beat.
// Cast life tips (parenthood, sleep) must not keep the raised wall or the village
// ping-pongs under a fake sticky margin.
if (!BelongsToActiveStoryBeat(current, _active))
{
return 0f;
}
if (OwnsCandidate(next) || IsContinuationOf(current, next))
{
return 0f;
@ -291,6 +612,18 @@ public static class StoryPlanner
return false;
}
// Injected aftermath/epilogue must win quiet-grace picks over cast life noise
// (hatch/food/sleep), even when those tips have higher raw EventStrength.
if (IsStoryAssetCandidate(a)
&& !IsStoryAssetCandidate(b)
&& (_active.Phase == StoryPhase.Aftermath
|| _active.Phase == StoryPhase.Epilogue
|| _active.Phase == StoryPhase.Climax)
&& !IsNaturalAftermathPeer(b, InterestDirector.CurrentCandidate))
{
return true;
}
bool aOwn = OwnsCandidate(a);
bool bOwn = OwnsCandidate(b);
return aOwn && !bOwn;
@ -332,6 +665,7 @@ public static class StoryPlanner
NoteCastFrom(climax, _active);
_coldHookFired = false;
_coldHookClimaxKey = "";
_coldHookAnchor = "";
CausalHeat.NoteCast(_active.CastIds);
}
@ -392,6 +726,10 @@ public static class StoryPlanner
mass = true;
}
// Live tip prose wins over sticky scale-hold so spine Kind matches the Label
// (Duel - … must not keep Mass · Climax after a count dip).
CombatTipScaleHint(c, ref mass);
bool notablePair = c.NotableParticipantCount >= 2;
if (mass)
{
@ -411,6 +749,92 @@ public static class StoryPlanner
return false;
}
/// <summary>
/// Prefer the orange tip's presented scale over sticky hysteresis for arc Kind.
/// </summary>
private static void CombatTipScaleHint(InterestCandidate c, ref bool mass)
{
if (c == null)
{
return;
}
string label = c.Label ?? "";
if (label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|| label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0)
{
mass = false;
return;
}
if (label.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
|| label.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
|| label.StartsWith("Mass", StringComparison.OrdinalIgnoreCase))
{
mass = true;
}
}
/// <summary>
/// When a story aftermath/epilogue Follow dies, promote another living cast member
/// so the beat is not dropped as follow_lost.
/// </summary>
public static bool TryRecoverStoryFollow(InterestCandidate scene)
{
if (scene == null || !IsStoryAssetCandidate(scene))
{
return false;
}
if (scene.HasFollowUnit)
{
return true;
}
StoryArc arc = Active;
Actor next = null;
if (arc != null && arc.IsActive)
{
for (int i = 0; i < arc.CastIds.Count; i++)
{
Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]);
if (a != null)
{
next = a;
break;
}
}
}
if (next == null && scene.RelatedUnit != null && scene.RelatedUnit.isAlive())
{
next = scene.RelatedUnit;
}
if (next == null && scene.RelatedId != 0)
{
next = EventFeedUtil.FindAliveById(scene.RelatedId);
}
if (next == null)
{
return false;
}
scene.FollowUnit = next;
scene.SubjectId = EventFeedUtil.SafeId(next);
try
{
scene.Position = next.current_position;
}
catch
{
// keep
}
return scene.HasFollowUnit;
}
private static bool IsLoveBeat(InterestCandidate c)
{
if (c == null)
@ -419,23 +843,37 @@ public static class StoryPlanner
}
string asset = (c.AssetId ?? "").Trim().ToLowerInvariant();
if (asset == "set_lover" || asset == "find_lover" || asset == AftermathLoveKey())
// Outcome bonds only. Seeking / find_lover intent is camera-worthy but not a Love climax.
if (asset == "set_lover"
|| asset == "fallen_in_love"
|| asset == "fell_in_love"
|| asset == AftermathLoveKey())
{
return true;
}
if (asset == "find_lover" || asset == "clear_lover")
{
return false;
}
string hap = (c.HappinessEffectId ?? "").Trim().ToLowerInvariant();
if (hap.IndexOf("lover", StringComparison.Ordinal) >= 0
|| hap.IndexOf("fallen_in_love", StringComparison.Ordinal) >= 0
if (hap == "fallen_in_love"
|| hap == "fell_in_love"
|| hap.IndexOf("married", StringComparison.Ordinal) >= 0)
{
return true;
}
// Death-of-lover grief is Grief, not Love.
if (hap.StartsWith("death_", StringComparison.Ordinal))
{
return false;
}
string cat = (c.Category ?? "").Trim();
return string.Equals(cat, "Relationship", StringComparison.OrdinalIgnoreCase)
&& (asset.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0
|| asset.IndexOf("love", StringComparison.OrdinalIgnoreCase) >= 0);
&& asset == "set_lover";
}
private static string AftermathLoveKey() => StoryReason.AftermathLoveLinger;
@ -584,8 +1022,6 @@ public static class StoryPlanner
private static bool HasStrongerPendingPeer(InterestCandidate climax)
{
StoryWeights s = InterestScoringConfig.Story;
float floor = s.aftermathStrength - 5f;
PendingScratch.Clear();
InterestRegistry.CopyPending(PendingScratch);
for (int i = 0; i < PendingScratch.Count; i++)
@ -601,23 +1037,9 @@ public static class StoryPlanner
continue;
}
if (p.EventStrength < floor && p.TotalScore < floor)
{
continue;
}
// Prefer natural life/story peers already queued for this cast.
if (climax != null
&& (p.SubjectId == climax.SubjectId
|| p.SubjectId == climax.RelatedId
|| p.RelatedId == climax.SubjectId
|| (_active != null && _active.ContainsCast(p.SubjectId))))
{
return true;
}
// High-strength parked Action tips (lover injects) also block synthetic aftermath.
if (p.EventStrength >= s.aftermathStrength)
// Only true natural aftermath peers block synthesis - not hatch/food/village
// cast noise that used to starve soft-duel linger tips.
if (IsNaturalAftermathPeer(p, climax))
{
return true;
}
@ -626,6 +1048,48 @@ public static class StoryPlanner
return false;
}
/// <summary>
/// Grief / love-outcome tips that already finish the climax story for this cast.
/// Ambient cast life (hatch, eat, sleep) must not suppress aftermath.
/// </summary>
private static bool IsNaturalAftermathPeer(InterestCandidate p, InterestCandidate climax)
{
if (p == null)
{
return false;
}
bool grief = p.Completion == InterestCompletionKind.HappinessGrief
|| (!string.IsNullOrEmpty(p.HappinessEffectId)
&& p.HappinessEffectId.StartsWith("death_", StringComparison.OrdinalIgnoreCase));
bool loveOutcome = IsLoveBeat(p);
if (!grief && !loveOutcome)
{
return false;
}
if (climax == null && _active == null)
{
return true;
}
if (climax != null
&& (p.SubjectId == climax.SubjectId
|| p.SubjectId == climax.RelatedId
|| p.RelatedId == climax.SubjectId
|| p.RelatedId == climax.RelatedId
|| p.SubjectId == climax.PairOwnerId
|| p.SubjectId == climax.PairPartnerId
|| p.RelatedId == climax.PairOwnerId
|| p.RelatedId == climax.PairPartnerId))
{
return true;
}
return _active != null
&& (_active.ContainsCast(p.SubjectId) || _active.ContainsCast(p.RelatedId));
}
private static bool TryInjectAftermath(InterestCandidate climax, StoryArc arc, float now)
{
if (climax == null || arc == null)
@ -639,7 +1103,7 @@ public static class StoryPlanner
return false;
}
Actor related = ResolveAftermathRelated(climax, follow);
Actor related = ResolveAftermathRelated(climax, follow, arc.Kind);
string assetId = PickAftermathAsset(arc.Kind, related);
StoryWeights s = InterestScoringConfig.Story;
DiscreteEventEntry catalog = EventCatalog.Story.GetOrFallback(assetId);
@ -647,9 +1111,33 @@ public static class StoryPlanner
? catalog.EventStrength
: s.aftermathStrength;
string label = StoryReason.AftermathLabel(assetId, follow, related);
string key = "aftermath:" + arc.Kind + ":" + arc.AnchorKey;
long followId = EventFeedUtil.SafeId(follow);
long relatedId = EventFeedUtil.SafeId(related);
// Camera RelatedUnit must be living; dead theater partners stay as RelatedId for cast/naming.
Actor relatedLive = null;
if (related != null)
{
try
{
if (related.isAlive())
{
relatedLive = related;
}
}
catch
{
relatedLive = null;
}
}
if (IsCombatKind(arc.Kind))
{
// Survivor tip never binds a living bystander as the "fallen" related.
relatedLive = null;
}
// Unique per inject so a prior Selected aftermath cannot block the next cold.
string key = "aftermath:" + arc.Kind + ":" + arc.AnchorKey + ":" + followId + ":"
+ Mathf.RoundToInt(now * 1000f);
var candidate = new InterestCandidate
{
@ -661,7 +1149,7 @@ public static class StoryPlanner
Label = label,
FollowUnit = follow,
SubjectId = followId,
RelatedUnit = related,
RelatedUnit = relatedLive,
RelatedId = relatedId,
Position = follow.current_position,
EventStrength = strength > 0f ? strength : s.aftermathStrength,
@ -684,7 +1172,11 @@ public static class StoryPlanner
}
arc.NoteCast(followId);
arc.NoteCast(relatedId);
// Theater partner (alive or fallen) only - never social fallbacks.
if (relatedId != 0 && relatedId != followId)
{
arc.NoteCast(relatedId);
}
arc.PendingBeats.Enqueue(new StoryBeat
{
Phase = StoryPhase.Aftermath,
@ -886,7 +1378,14 @@ public static class StoryPlanner
return null;
}
private static Actor ResolveAftermathRelated(InterestCandidate climax, Actor follow)
/// <summary>
/// Theater partner for aftermath naming. Combat never falls back to lover/best friend
/// (those are living bystanders and produce lying "stands over X" tips).
/// </summary>
private static Actor ResolveAftermathRelated(
InterestCandidate climax,
Actor follow,
StoryArcKind kind)
{
if (climax == null)
{
@ -894,23 +1393,61 @@ public static class StoryPlanner
}
long followId = EventFeedUtil.SafeId(follow);
if (kind == StoryArcKind.Love)
{
if (climax.RelatedUnit != null
&& climax.RelatedUnit.isAlive()
&& EventFeedUtil.SafeId(climax.RelatedUnit) != followId)
{
return climax.RelatedUnit;
}
if (climax.RelatedId != 0 && climax.RelatedId != followId)
{
Actor bonded = EventFeedUtil.FindAliveById(climax.RelatedId);
if (bonded != null)
{
return bonded;
}
}
Actor lover = ActorRelation.GetLover(follow);
if (lover != null)
{
return lover;
}
return ActorRelation.GetBestFriend(follow);
}
if (kind == StoryArcKind.WarFront || kind == StoryArcKind.Plot)
{
return null;
}
// Combat / grief / default: climax theater partner only (alive or dead for naming).
return ResolveTheaterPartnerAny(climax, followId);
}
/// <summary>
/// Duel/mass partner from pair lock, RelatedId, or opposing sticky side - including dead units.
/// </summary>
private static Actor ResolveTheaterPartnerAny(InterestCandidate climax, long followId)
{
if (climax == null || followId == 0)
{
return null;
}
if (climax.RelatedUnit != null
&& climax.RelatedUnit.isAlive()
&& EventFeedUtil.SafeId(climax.RelatedUnit) != followId)
{
return climax.RelatedUnit;
}
if (climax.FollowUnit != null
&& climax.FollowUnit.isAlive()
&& EventFeedUtil.SafeId(climax.FollowUnit) != followId)
{
return climax.FollowUnit;
}
if (climax.PairPartnerId != 0 && climax.PairPartnerId != followId)
{
Actor p = EventFeedUtil.FindAliveById(climax.PairPartnerId);
Actor p = EventFeedUtil.FindUnitById(climax.PairPartnerId);
if (p != null)
{
return p;
@ -919,20 +1456,67 @@ public static class StoryPlanner
if (climax.PairOwnerId != 0 && climax.PairOwnerId != followId)
{
Actor p = EventFeedUtil.FindAliveById(climax.PairOwnerId);
Actor p = EventFeedUtil.FindUnitById(climax.PairOwnerId);
if (p != null)
{
return p;
}
}
Actor lover = ActorRelation.GetLover(follow);
if (lover != null)
if (climax.RelatedId != 0 && climax.RelatedId != followId)
{
return lover;
Actor p = EventFeedUtil.FindUnitById(climax.RelatedId);
if (p != null)
{
return p;
}
}
return ActorRelation.GetBestFriend(follow);
if (climax.Sticky == null)
{
return null;
}
bool followOnA = false;
for (int i = 0; i < climax.Sticky.SideAIds.Count; i++)
{
if (climax.Sticky.SideAIds[i] == followId)
{
followOnA = true;
break;
}
}
var opposite = followOnA ? climax.Sticky.SideBIds : climax.Sticky.SideAIds;
// Prefer a dead opposite (true fallen), else first opposite for lookup.
Actor livingOpposite = null;
for (int i = 0; i < opposite.Count; i++)
{
Actor a = EventFeedUtil.FindUnitById(opposite[i]);
if (a == null || EventFeedUtil.SafeId(a) == followId)
{
continue;
}
try
{
if (!a.isAlive())
{
return a;
}
}
catch
{
return a;
}
if (livingOpposite == null)
{
livingOpposite = a;
}
}
return livingOpposite;
}
private static string PickAftermathAsset(StoryArcKind kind, Actor related)
@ -948,9 +1532,7 @@ public static class StoryPlanner
case StoryArcKind.Grief:
return StoryReason.AftermathMourner;
default:
return related != null && !related.isAlive()
? StoryReason.AftermathSurvivor
: StoryReason.AftermathSurvivor;
return StoryReason.AftermathSurvivor;
}
}

View file

@ -51,9 +51,27 @@ public static class StoryReason
: name + " seeks " + other + " after the story";
case AftermathSurvivor:
default:
return string.IsNullOrEmpty(other)
? name + " stands over the fallen"
: name + " stands over " + other;
// Only name a dead theater partner. Living lover/friend fallbacks must never
// print as the fallen (soak: "stands over Clemond" after a duel vs someone else).
if (related != null)
{
bool dead = false;
try
{
dead = !related.isAlive();
}
catch
{
dead = true;
}
if (dead && !string.IsNullOrEmpty(other))
{
return name + " stands over " + other;
}
}
return name + " stands over the fallen";
}
}
}

View file

@ -410,6 +410,15 @@ public sealed class UnitDossier
return "";
}
// Dossier subject must own the leading proper name on non-ensemble tips.
// Catches Follow/Subject drift where the tip Label still names someone else.
if (!IsEnsembleCombatReason(beat) && ReasonLeadsWithForeignDossierName(beat, actor, d, scene))
{
InterestDropLog.Record("identity_filter", "dossier_foreign:" + beat);
d.ReasonRelatedId = 0;
return "";
}
// Gold names inside the orange reason row (same palette as history/activity).
string subjectName = d != null ? (d.Name ?? "") : "";
if (string.IsNullOrEmpty(subjectName))
@ -543,7 +552,18 @@ public sealed class UnitDossier
}
string subjectName = EventFeedUtil.SafeName(scene.FollowUnit);
if (string.IsNullOrEmpty(subjectName))
{
Actor subject = EventFeedUtil.FindAliveById(scene.SubjectId);
subjectName = EventFeedUtil.SafeName(subject);
}
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
if (string.IsNullOrEmpty(relatedName) && scene.RelatedId != 0)
{
relatedName = EventFeedUtil.SafeName(EventFeedUtil.FindAliveById(scene.RelatedId));
}
if (!string.IsNullOrEmpty(subjectName)
&& first.Equals(FirstToken(subjectName), System.StringComparison.OrdinalIgnoreCase))
{
@ -572,7 +592,101 @@ public sealed class UnitDossier
continue;
}
if (unit == scene.FollowUnit || unit == scene.RelatedUnit)
long id = EventFeedUtil.SafeId(unit);
if (unit == scene.FollowUnit
|| unit == scene.RelatedUnit
|| (scene.SubjectId != 0 && id == scene.SubjectId)
|| (scene.RelatedId != 0 && id == scene.RelatedId))
{
continue;
}
string name = EventFeedUtil.SafeName(unit);
if (string.IsNullOrEmpty(name) || name.Length < 3)
{
continue;
}
if (first.Equals(FirstToken(name), System.StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
catch
{
// ignore
}
return false;
}
/// <summary>
/// True when the reason leads with a living unit name that is not the dossier subject
/// (and not the scene's related partner when the dossier subject is that partner).
/// </summary>
private static bool ReasonLeadsWithForeignDossierName(
string reason,
Actor dossierActor,
UnitDossier dossier,
InterestCandidate scene)
{
if (string.IsNullOrEmpty(reason) || scene == null)
{
return false;
}
string first = FirstToken(reason);
if (string.IsNullOrEmpty(first) || IsOwnedBeatKeyword(first))
{
return false;
}
string dossierName = dossier != null ? (dossier.Name ?? "") : "";
if (string.IsNullOrEmpty(dossierName))
{
dossierName = EventFeedUtil.SafeName(dossierActor);
}
if (!string.IsNullOrEmpty(dossierName)
&& first.Equals(FirstToken(dossierName), System.StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Related partner may show a subject-led sentence ("Iksssssa shares intimacy with Anya")
// only when the dossier subject is that related partner.
long dossierId = dossier != null ? dossier.UnitId : EventFeedUtil.SafeId(dossierActor);
if (dossierId != 0
&& scene.RelatedId != 0
&& dossierId == scene.RelatedId)
{
Actor subject = scene.FollowUnit != null && scene.FollowUnit.isAlive()
? scene.FollowUnit
: EventFeedUtil.FindAliveById(scene.SubjectId);
string subjectName = EventFeedUtil.SafeName(subject);
if (!string.IsNullOrEmpty(subjectName)
&& first.Equals(FirstToken(subjectName), System.StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
if (World.world?.units == null || first.Length < 3)
{
return false;
}
try
{
foreach (Actor unit in World.world.units)
{
if (unit == null || !unit.isAlive())
{
continue;
}
if (unit == dossierActor)
{
continue;
}

View file

@ -8,7 +8,7 @@ using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Compact dossier: nametag (species/name/lv/task/sex), avatar + mini History, statuses, traits, reason.
/// Compact dossier: nametag (species/name/lv/task/sex), avatar + mini History, statuses, traits, reason, story spine.
/// </summary>
public static class WatchCaption
{
@ -34,6 +34,7 @@ public static class WatchCaption
private const float TraitsH = 14f;
private const float StatusesH = 14f;
private const float ReasonH = 14f;
private const float StorySpineH = 12f;
private const float HistoryLineMinH = 13f;
private const float HistoryLineMaxH = 100f;
private const float Gap = 2f;
@ -51,6 +52,7 @@ public static class WatchCaption
private static readonly Color StatValueColor = HudTheme.ValueOrange;
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 TraitNameColor = HudTheme.Body;
private static readonly Color StatusNameColor = HudTheme.StatusBlue;
private static readonly Color HistoryTextColor = HudTheme.Body;
@ -62,6 +64,7 @@ public static class WatchCaption
private static Image _sexIcon;
private static Text _nameText;
private static Text _reasonText;
private static Text _storySpineText;
private static long _reasonOtherId;
private static Image _levelIcon;
@ -129,6 +132,9 @@ public static class WatchCaption
public static string LastCaptionText { get; private set; } = "";
/// <summary>Harness/UI: active story spine ("Duel · Aftermath"), empty when none.</summary>
public static string LastStorySpine { get; private set; } = "";
/// <summary>Harness: newest mini-history line currently shown (if any).</summary>
public static string LastHistoryPreview { get; private set; } = "";
@ -407,6 +413,7 @@ public static class WatchCaption
LastHeadline = "";
LastDetail = "";
LastCaptionText = "";
LastStorySpine = "";
LastHistoryPreview = "";
LastHistoryJoined = "";
LastHistoryShown = 0;
@ -461,6 +468,9 @@ public static class WatchCaption
WireReasonClick(0);
}
// Status banners temporarily own the reason row - hide the story spine.
ApplyStorySpine("");
LastCaptionText = (LastHeadline ?? "Idle Spectator") + " | " + message;
// Re-fill from the current subject so Relayout cannot resurrect stale history slots
// from a previously focused unit (activeSelf stays true while the column is hidden).
@ -604,6 +614,7 @@ public static class WatchCaption
RefreshLiveIdentity();
RefreshLiveStatuses();
RefreshOwnedReason();
RefreshStorySpine();
RefreshHistoryIfChanged();
return;
}
@ -660,6 +671,7 @@ public static class WatchCaption
RefreshLiveIdentity();
RefreshLiveStatuses();
RefreshOwnedReason();
RefreshStorySpine();
RefreshHistoryIfChanged();
}
@ -836,7 +848,11 @@ public static class WatchCaption
bool headcountOnly = EventReason.IsHeadcountOnlyChange(prev, next);
_current.ReasonLine = next;
LastDetail = _current.DetailLine ?? "";
LastCaptionText = JoinCaptionLines(_current.Headline, next, _current.DetailLine);
LastCaptionText = JoinCaptionLines(
_current.Headline,
next,
_current.DetailLine,
StoryPlanner.FormatSpineLabel());
bool hasReason = !string.IsNullOrEmpty(next);
if (_reasonText != null)
@ -847,6 +863,7 @@ public static class WatchCaption
// Mass tip headcount ticks must not Relayout the whole dossier every second.
if (headcountOnly)
{
RefreshStorySpine();
return;
}
@ -860,7 +877,61 @@ public static class WatchCaption
CountActiveHistorySlots());
}
private static string JoinCaptionLines(string headline, string reason, string detail)
/// <summary>
/// Quiet Kind · Phase line under the orange reason while StoryPlanner owns an arc.
/// </summary>
private static void RefreshStorySpine()
{
if (!_visible || HasStatusBanner())
{
return;
}
string next = SpectatorMode.Active && ModSettings.ShowDossierCaption
? StoryPlanner.FormatSpineLabel()
: "";
if (string.Equals(next, LastStorySpine, StringComparison.Ordinal))
{
return;
}
ApplyStorySpine(next);
bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
bool hasBody = _boundActor != null || CountActiveHistorySlots() > 0 || _current != null;
Relayout(
hasBody,
CountActiveTraitSlots(),
CountActiveStatusSlots(),
hasTask,
hasReason,
CountActiveHistorySlots());
}
private static void ApplyStorySpine(string spine)
{
LastStorySpine = spine ?? "";
if (_storySpineText == null)
{
return;
}
bool show = !string.IsNullOrEmpty(LastStorySpine) && !HasStatusBanner();
_storySpineText.text = show ? LastStorySpine : "";
_storySpineText.color = StorySpineColor;
_storySpineText.gameObject.SetActive(show);
if (_current != null)
{
LastCaptionText = JoinCaptionLines(
_current.Headline,
_current.ReasonLine,
_current.DetailLine,
LastStorySpine);
}
}
private static string JoinCaptionLines(string headline, string reason, string detail, string storySpine = null)
{
var sb = new System.Text.StringBuilder();
if (!string.IsNullOrEmpty(headline))
@ -878,6 +949,16 @@ public static class WatchCaption
sb.Append(reason);
}
if (!string.IsNullOrEmpty(storySpine))
{
if (sb.Length > 0)
{
sb.Append('\n');
}
sb.Append(storySpine);
}
if (!string.IsNullOrEmpty(detail))
{
if (sb.Length > 0)
@ -970,7 +1051,11 @@ public static class WatchCaption
_current.IdentityTag = tag;
_current.Headline = headline;
LastHeadline = headline;
LastCaptionText = JoinCaptionLines(headline, _current.ReasonLine, _current.DetailLine);
LastCaptionText = JoinCaptionLines(
headline,
_current.ReasonLine,
_current.DetailLine,
LastStorySpine);
if (_nameText != null)
{
_nameText.text = headline;
@ -1420,6 +1505,11 @@ public static class WatchCaption
hasReason && dossier != null ? dossier.ReasonRelatedId : 0);
}
string spine = SpectatorMode.Active && ModSettings.ShowDossierCaption && dossier != null
? StoryPlanner.FormatSpineLabel()
: "";
ApplyStorySpine(spine);
Relayout(hasBody, traitCount, statusCount, hasTask, hasReason, historyCount);
RefreshFavoriteVisual(hasLive ? actor : null);
}
@ -2287,6 +2377,15 @@ public static class WatchCaption
y += reasonH + Gap;
}
bool hasStorySpine = _storySpineText != null
&& _storySpineText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_storySpineText.text);
if (hasStorySpine)
{
PlaceLine(_storySpineText.GetComponent<RectTransform>(), y, StorySpineH, PadX);
y += StorySpineH + Gap;
}
float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap);
if (_rootRt != null)
{
@ -2925,6 +3024,8 @@ public static class WatchCaption
return LineInside(_nameText.GetComponent<RectTransform>(), panelHeight)
&& (_reasonText == null || !_reasonText.gameObject.activeSelf
|| LineInside(_reasonText.GetComponent<RectTransform>(), panelHeight))
&& (_storySpineText == null || !_storySpineText.gameObject.activeSelf
|| LineInside(_storySpineText.GetComponent<RectTransform>(), panelHeight))
&& (_statusesRow == null || !_statusesRow.activeSelf
|| LineInside(_statusesRow.GetComponent<RectTransform>(), panelHeight))
&& (_traitsRow == null || !_traitsRow.activeSelf
@ -2997,7 +3098,9 @@ public static class WatchCaption
_sexIcon = null;
_nameText = null;
_reasonText = null;
_storySpineText = null;
_reasonOtherId = 0;
LastStorySpine = "";
_levelIcon = null;
_levelValue = null;
_taskIcon = null;
@ -3144,6 +3247,15 @@ public static class WatchCaption
_reasonText.resizeTextForBestFit = false;
WireReasonClick(0);
_storySpineText = HudCanvas.MakeText(_root.transform, "StorySpine", "", 7);
_storySpineText.color = StorySpineColor;
_storySpineText.supportRichText = false;
_storySpineText.alignment = TextAnchor.UpperLeft;
_storySpineText.horizontalOverflow = HorizontalWrapMode.Overflow;
_storySpineText.verticalOverflow = VerticalWrapMode.Truncate;
_storySpineText.resizeTextForBestFit = false;
_storySpineText.raycastTarget = false;
_speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize);
_sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize);
@ -3173,6 +3285,7 @@ public static class WatchCaption
_statusesRow.SetActive(false);
_traitsRow.SetActive(false);
_reasonText.gameObject.SetActive(false);
_storySpineText.gameObject.SetActive(false);
_levelIcon.gameObject.SetActive(false);
_levelValue.gameObject.SetActive(false);
_taskIcon.gameObject.SetActive(false);
@ -3180,7 +3293,7 @@ public static class WatchCaption
Relayout(false, 0, 0, false, false, 0);
_root.SetActive(false);
_visible = false;
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag + statuses + traits)");
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag + statuses + traits + story spine)");
}
private static void BuildHeaderButtons()

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.28.41",
"version": "0.28.54",
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -1,5 +1,5 @@
{
"modVersion": "0.28.39",
"modVersion": "0.28.49",
"title": "IdleSpectator interest scoring model",
"updated": "2026-07-18",
"note": "Edit weights below - InterestScoringConfig loads this file at mod start. Docs sections are human-facing only (ignored by Unity JsonUtility).",
@ -126,7 +126,10 @@
"epilogueMaxWatch": 14,
"ledgerCap": 16,
"ledgerWeight": 8,
"historyDensityWindowSeconds": 600
"historyDensityWindowSeconds": 600,
"ledgerBleedLifeChapter": 0.4,
"ledgerBleedVillage": 0.82,
"familyLifeBeatCooldownSeconds": 90
},
"sources": [

View file

@ -16,6 +16,12 @@ Discrete / status / WorldLog: `CreatesInterest` = A gate.
Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only).
Orange dossier reason = the owning A events `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
The focused unit must be a tip principal:
- Life / discrete tips: SubjectId + RelatedId (Follow alone is not enough after a handoff).
- Pair-locked duels: PairOwner/PairPartner only.
- Mass/war/plot/family/outbreak: sticky roster members OK.
Dossier identity also rejects subject-led Labels that name another living unit
(e.g. Hillih must not show “Iksssssa shares intimacy…”).
Quiet grace / inactive dwell clears the reason even if focus has not switched yet.
## Activity History peek freshness

View file

@ -27,10 +27,85 @@ Planner never calls `MoveCamera`.
Phases: `Climax → Aftermath → Epilogue → Done`.
- **Aftermath** injects on climax cold (quiet grace), or claims natural `death_*` grief.
- **Aftermath** injects on climax cold (quiet grace / `follow_lost`), or claims natural `death_*` grief.
- Only grief / love-outcome pending tips block synthesis - cast hatch/food noise does not.
- Injected story beats bypass FixedDwell beat hard-skip and PreferOver cast life tips while the arc is live.
- Soft duel Mass↔Duel key flips still cold-hook once per theater anchor.
- **Epilogue** once after aftermath if a living lover/friend resolves.
- Soft anonymous duels still get aftermath tips; they do not use raised `arcHoldMargin`.
## Dossier spine
While the **current watch tip** is the active arc's climax or story aftermath/epilogue beat,
the dossier shows a muted line under the orange reason: `Kind · Phase`
(e.g. `Duel · Aftermath`, `War front · Climax`).
Hidden when idle is off, the dossier is hidden, a status banner owns the reason row,
or the camera has left the story for an unrelated tip (cast membership alone is not enough).
Love climax is outcome-only (`set_lover` / fallen-in-love). `find_lover` seeking stays camera-worthy
but does not open a Love arc. Leaving a climax without aftermath ends the arc (`Done`).
Sticky combat reframes (Battle ↔ Duel) sync climax Kind in place via `SyncActiveClimax`
so the spine cannot say `Mass · Climax` on a Duel tip.
`arcHoldMargin` applies only while the watch tip **is** the aftermath/epilogue beat.
Cast life tips (parenthood, sleep) no longer keep the raised cut-in wall.
Family life chapters (`just_had_child`, pregnancy, …) share `arc:life:family`, use a longer
beat cool (including world-wide happiness cool), and bleed ledger heat so the village cast
cannot monopolize the next picks.
Intimacy chapters (`just_kissed` / shares intimacy) use `arc:life:intimacy` with the same
soft-life cool class (pair + world) so one couple cannot keep cutting between combat tips.
Settlement finds (`just_found_house` / finds a home) use `arc:life:home` with the same soft cool
(world + subject) so house crumbs cannot keep cutting between combat beats.
Kill crumbs (`milestone_kill` / `Killed X (species)` / `just_killed`) use `arc:life:kill` with
the same soft cool (subject + world) so hunter kill tips cannot keep cutting between mass beats.
Courtship crumbs (`fallen_in_love` / smitten / `find_lover` seeking) use `arc:life:love` soft cool
(subject + world + pair). Bond outcomes (`set_lover`) stay hotter.
Life-intent decisions (`sexual_reproduction_*`, decision `find_lover`, …) share
`arc:decision:intent` soft cool (subject + world). Outcomes (pregnancy, child, seeking) stay hotter.
Ordinary civ construction finishes (`building:complete` / tents / houses) share `arc:construction`
soft cool. Spectacle wonders stay hotter.
Soft personal status FX (`taking_roots`, singing, moods, …) share `arc:status:fx` soft cool.
Danger / combat statuses stay hotter. Hatch crumbs use `arc:life:hatch`.
Sleep / wake / dream cycle (`sleeping`, `just_slept`, `had_*dream`, `had_nightmare`, …) share
`arc:life:rest` soft cool so rest crumbs cannot monopolize between richer beats.
WorldLog politics meta (`king_dead`, `city_destroyed`, favorite fell, …) share `arc:worldlog:meta`
soft cool so sticky death crumbs cannot re-monopolize.
Interrupted resume: already-cooled moment beats are not parked / not resumed after a cut-in
(hatch/reproduce cannot bounce back after combat).
Story spine: aftermath tips must match the active arc `CorrelationKey` (no stale duel aftermath
under a Mass spine). Combat Kind word follows tip Label (`Battle` / `Skirmish` / `Mass` / `Duel`).
Wiped opposing combat camps collapse to a living-side pack label at live scale
(never `Mass - A (n) vs B (0)`).
Combat aftermath names only a dead theater partner (`stands over X`), else
`stands over the fallen`. Living lover/friend fallbacks are never the fallen.
Spine Kind follows the live tip Label for combat (Duel vs Mass/Skirmish/Battle), not sticky
scale-hold alone. Cold climax tips do not show `· Aftermath` - only the aftermath tip does.
Aftermath Follow loss promotes another living cast member before `follow_lost`.
Harness: `story_spine` / `story_hold_margin` / `story_family_variety` / `story_intimacy_cool`
/ `story_settlement_cool` / `story_kill_cool` / `story_courtship_cool`
/ `story_decision_intent_cool` / `story_decision_intent_live` / `story_construction_cool`
/ `story_status_fx_cool` / `story_rest_cool` / `story_resume_cooled` / `story_worldlog_meta_cool`
/ `combat_wiped_side` / `reason_life_principal`
(`story_arc_combat`, `story_spine_scoped`, `story_aftermath_combat`, `story_family_variety`).
## Selection discipline
- `InterestVariety.Pick` takes `#1` when score gap ≥ `story.nearTieEpsilon` (default 8).
@ -49,6 +124,22 @@ Catalog policy ids live under `event-catalog.json` → `story`
| Scenario | Proves |
|----------|--------|
| `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 |
| `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 |
| `story_settlement_cool` | finds-a-home cools; peer wins next pick |
| `story_kill_cool` | kill crumbs cool; peer wins next pick |
| `story_courtship_cool` | smitten/seeking cool; peer wins next pick |
| `story_decision_intent_cool` | reproduce/find-lover decisions cool as a class |
| `story_decision_intent_live` | live EmitDecision path cools intent class |
| `story_construction_cool` | tent/house finishes cool; peer wins |
| `story_status_fx_cool` | taking_roots FX cool; peer wins |
| `story_rest_cool` | sleep/wake rest cycle cools; peer wins |
| `story_resume_cooled` | cooled soft tip does not resume after cut-in |
| `story_worldlog_meta_cool` | king_dead cools; peer wins next pick |
| `combat_wiped_side` | wiped camp never prints vs (0) |
| `story_aftermath_war` | war sticky cold → aftermath |
| `story_near_tie` | large gap always picks #1 |
| `story_arc_combat` | climax → aftermath ownership |
@ -57,14 +148,28 @@ Catalog policy ids live under `event-catalog.json` → `story`
```bash
./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 reason_duel_principal
./scripts/harness-run.sh --repeat 3 story_near_tie
./scripts/harness-run.sh --repeat 3 story_arc_combat
./scripts/harness-run.sh --repeat 3 story_ledger_revisit
./scripts/harness-run.sh --repeat 3 story_kill_cool
./scripts/harness-run.sh --repeat 3 story_courtship_cool
./scripts/harness-run.sh --repeat 3 story_decision_intent_cool
./scripts/harness-run.sh --repeat 3 story_decision_intent_live
./scripts/harness-run.sh --repeat 3 story_construction_cool
./scripts/harness-run.sh --repeat 3 story_status_fx_cool
./scripts/harness-run.sh --repeat 3 story_rest_cool
./scripts/harness-run.sh --repeat 3 story_resume_cooled
./scripts/harness-run.sh --repeat 3 story_worldlog_meta_cool
./scripts/harness-run.sh --repeat 3 combat_wiped_side
```
## Files
- `IdleSpectator/Story/StoryPlanner.cs` - ownership, cold-hook, inject
- `IdleSpectator/Story/StoryPlanner.cs` - ownership, cold-hook, inject, spine labels
- `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat
- `IdleSpectator/Story/CharacterLedger.cs` - watched lives (cap 16)
- `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels
- `IdleSpectator/WatchCaption.cs` - dossier `Kind · Phase` spine row