Compare commits

...

3 commits

Author SHA1 Message Date
9a059c17dd 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
2026-07-21 16:45:12 -05:00
7e02356b01 Audit Tips 2026-07-21 13:38:04 -05:00
954016f4b6 Story Beats 2026-07-21 13:35:07 -05:00
34 changed files with 6131 additions and 160 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.
@ -2902,6 +2963,27 @@ public static class AgentHarness
break;
}
case "interest_mark_sticky_cold":
case "story_mark_climax_cold":
{
InterestCandidate scene = InterestDirector.CurrentCandidate;
if (scene != null)
{
ClearAttackTarget(scene.FollowUnit);
ClearAttackTarget(scene.RelatedUnit);
TryClearCombatTask(scene.FollowUnit);
TryClearCombatTask(scene.RelatedUnit);
}
InterestDirector.HarnessMarkStickySceneCold();
_cmdOk++;
Emit(
cmd,
ok: true,
detail: $"sticky_cold key={InterestDirector.CurrentKey} tip='{CameraDirector.LastWatchLabel}' active={InterestDirector.CurrentIsActive}");
break;
}
case "interest_mark_inactive":
{
float ago = ParseFloat(cmd.value, 1f);
@ -2920,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);
@ -4034,6 +4141,17 @@ public static class AgentHarness
return;
}
// pick=other: use a different living unit (same asset when possible) for ledger/near-tie tests.
if (!string.IsNullOrEmpty(cmd.value)
&& cmd.value.IndexOf("pick=other", StringComparison.OrdinalIgnoreCase) >= 0)
{
Actor other = FindOtherAliveUnit(follow, cmd.asset);
if (other != null)
{
follow = other;
}
}
float tierEvt = EventStrengthFromTierHint(cmd.tier, 70f);
InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.EventLed);
string label = string.IsNullOrEmpty(cmd.label) ? ("Inject " + (cmd.tier ?? "scene")) : cmd.label;
@ -4125,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)
{
@ -4145,6 +4257,208 @@ 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)
{
return null;
}
long excludeId = EventFeedUtil.SafeId(exclude);
string wantAsset = (preferAsset ?? "").Trim();
if (wantAsset == "auto")
{
wantAsset = "";
}
Actor fallback = null;
try
{
foreach (Actor a in World.world.units)
{
if (a == null || !a.isAlive() || EventFeedUtil.SafeId(a) == excludeId)
{
continue;
}
string id = a.asset != null ? a.asset.id : "";
if (!string.IsNullOrEmpty(wantAsset)
&& string.Equals(id, wantAsset, StringComparison.OrdinalIgnoreCase))
{
return a;
}
if (fallback == null)
{
fallback = a;
}
}
}
catch
{
// ignore
}
return fallback;
}
private static void ParseInjectSideCounts(string raw, out int sideA, out int sideB)
{
sideA = 0;
@ -4358,6 +4672,8 @@ public static class AgentHarness
c.Category = "CharacterVignette";
InterestScoring.ScoreCheap(c);
}
ApplyHarnessRelationshipAssetHint(c, cmd.expect);
bool ok = InterestDirector.HarnessForceSession(c);
if (ok)
{
@ -5751,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.
@ -6190,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)
{
@ -6497,6 +7002,152 @@ 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();
StoryArc arc = StoryPlanner.Active;
string have = arc != null ? arc.Phase.ToString() : "None";
pass = !string.IsNullOrEmpty(want)
&& string.Equals(have, want, StringComparison.OrdinalIgnoreCase);
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;
long id = EventFeedUtil.SafeId(focus);
pass = id != 0 && CharacterLedger.IsOnLedger(id);
detail = $"ledger focus={SafeName(focus)} id={id} on={pass} heat={CharacterLedger.Heat(id):0.##}";
break;
}
case "tip_matches_unit":
{
string tipAsset = CameraDirector.LastWatchAssetId ?? "";
@ -7524,16 +8175,35 @@ public static class AgentHarness
}
}
// Spot-check Signal classes still have camera rows after demote.
int geneSignal = 0;
// Genes / clan / language are all B (never own the camera).
int geneCamera = 0;
foreach (string id in EventCatalog.Gene.AuthoredIds)
{
if (EventCatalog.Gene.GetOrFallback(id).CreatesInterest)
{
geneSignal++;
geneCamera++;
}
}
int clanCamera = 0;
foreach (string id in EventCatalog.ClanTrait.AuthoredIds)
{
if (EventCatalog.ClanTrait.GetOrFallback(id).CreatesInterest)
{
clanCamera++;
}
}
int languageCamera = 0;
foreach (string id in EventCatalog.LanguageTrait.AuthoredIds)
{
if (EventCatalog.LanguageTrait.GetOrFallback(id).CreatesInterest)
{
languageCamera++;
}
}
// Spot-check other Signal classes still have camera rows after demote.
int lawSignal = 0;
foreach (string id in EventCatalog.WorldLaw.AuthoredIds)
{
@ -7565,15 +8235,18 @@ public static class AgentHarness
pass = dialBad == 0
&& phenotypeCamera == 0
&& geneCamera == 0
&& clanCamera == 0
&& languageCamera == 0
&& spellFxCamera == 0
&& spellSpectacleMissing == 0
&& geneSignal > 0
&& lawSignal > 0
&& itemSignal > 0
&& signalCamera == spectacleSpells.Length;
detail =
$"dialBad={dialBad} phenotypeCamera={phenotypeCamera} spellFxCamera={spellFxCamera} "
+ $"spellSpectacleMissing={spellSpectacleMissing} geneSignal={geneSignal} "
$"dialBad={dialBad} phenotypeCamera={phenotypeCamera} geneCamera={geneCamera} "
+ $"clanCamera={clanCamera} languageCamera={languageCamera} "
+ $"spellFxCamera={spellFxCamera} spellSpectacleMissing={spellSpectacleMissing} "
+ $"lawSignal={lawSignal} itemSignal={itemSignal} spectacleOk={signalCamera}";
break;
}
@ -7596,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;
@ -7616,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

@ -461,6 +461,80 @@ public static class Chronicle
}
}
/// <summary>
/// Count History rows for <paramref name="subjectId"/> with CreatedAt inside the last
/// <paramref name="windowSeconds"/> (unscaled). Used by Character Ledger density.
/// </summary>
public static int RecentHistoryCount(long subjectId, float windowSeconds)
{
if (subjectId == 0 || windowSeconds <= 0f)
{
return 0;
}
IReadOnlyList<ChronicleEntry> snap = SnapshotForSubject(subjectId);
if (snap.Count == 0)
{
return 0;
}
float cutoff = Time.unscaledTime - windowSeconds;
int n = 0;
for (int i = 0; i < snap.Count; i++)
{
ChronicleEntry e = snap[i];
if (e != null && e.CreatedAt >= cutoff)
{
n++;
}
}
return n;
}
/// <summary>
/// Collect distinct <see cref="ChronicleEntry.OtherId"/> values from subject History.
/// Newest rows first; stops at <paramref name="max"/>.
/// </summary>
public static void EnumerateRelatedIds(long subjectId, List<long> into, int max = 8)
{
if (into == null)
{
return;
}
into.Clear();
if (subjectId == 0 || max <= 0)
{
return;
}
IReadOnlyList<ChronicleEntry> snap = SnapshotForSubject(subjectId);
for (int i = snap.Count - 1; i >= 0 && into.Count < max; i--)
{
ChronicleEntry e = snap[i];
if (e == null || e.OtherId == 0 || e.OtherId == subjectId)
{
continue;
}
bool seen = false;
for (int j = 0; j < into.Count; j++)
{
if (into[j] == e.OtherId)
{
seen = true;
break;
}
}
if (!seen)
{
into.Add(e.OtherId);
}
}
}
/// <summary>Newest-first slice for compact dossier HUD.</summary>
public static IReadOnlyList<ChronicleEntry> LatestForSubject(long subjectId, int max)
{

View file

@ -51,6 +51,8 @@ public static class EventCatalogConfig
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DiscreteEventEntry> Relationship =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DiscreteEventEntry> Story =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DiscreteEventEntry> Traits =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DiscreteEventEntry> Books =
@ -83,6 +85,7 @@ public static class EventCatalogConfig
public static int WorldLogCount => WorldLog.Count;
public static int PlotCount => Plots.Count;
public static int RelationshipCount => Relationship.Count;
public static int StoryCount => Story.Count;
public static int TraitCount => Traits.Count;
public static int BookCount => Books.Count;
public static int EraCount => Eras.Count;
@ -130,7 +133,7 @@ public static class EventCatalogConfig
LogService.LogInfo(
$"[IdleSpectator] event-catalog.json loaded v={_loadedVersion} "
+ $"happiness={Happiness.Count} status={Status.Count} worldLog={WorldLog.Count} "
+ $"plots={Plots.Count} relationship={Relationship.Count} traits={Traits.Count} "
+ $"plots={Plots.Count} relationship={Relationship.Count} story={Story.Count} traits={Traits.Count} "
+ $"books={Books.Count} eras={Eras.Count} disasters={Disasters.Count} "
+ $"warTypes={WarTypes.Count} decisions={Decisions.Count} libraries={Libraries.Count} "
+ $"species={SpeciesBonuses.Count} character={CharacterBonuses.Count}");
@ -153,6 +156,7 @@ public static class EventCatalogConfig
WorldLog.Clear();
Plots.Clear();
Relationship.Clear();
Story.Clear();
Traits.Clear();
Books.Clear();
Eras.Clear();
@ -184,6 +188,7 @@ public static class EventCatalogConfig
if (TryExtractArray(json, "worldLog", out a)) IngestWorldLog(a);
if (TryExtractArray(json, "plots", out a)) IngestDiscrete(a, Plots);
if (TryExtractArray(json, "relationship", out a)) IngestDiscrete(a, Relationship);
if (TryExtractArray(json, "story", out a)) IngestDiscrete(a, Story);
if (TryExtractArray(json, "traits", out a)) IngestDiscrete(a, Traits);
if (TryExtractArray(json, "books", out a)) IngestDiscrete(a, Books);
if (TryExtractArray(json, "eras", out a)) IngestDiscrete(a, Eras);
@ -226,6 +231,7 @@ public static class EventCatalogConfig
public static int FillPlots(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Plots, into);
public static int FillRelationship(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Relationship, into);
public static int FillStory(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Story, into);
public static int FillTraits(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Traits, into);
public static int FillBooks(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Books, into);
public static int FillEras(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Eras, into);

View file

@ -113,6 +113,12 @@ public static partial class EventCatalog
return false;
}
// Soft intent chatter - outcomes already covered by other feeds, or not story-worthy.
if (IsSoftDecisionNoise(s))
{
return false;
}
return HasToken(s, "war")
|| HasToken(s, "king")
|| HasToken(s, "leader")
@ -129,7 +135,6 @@ public static partial class EventCatalog
|| s.Contains("plot")
|| s.Contains("marry")
|| s.Contains("banish")
|| s.Contains("steal")
|| s.Contains("kill_unruly")
|| s.Contains("clan")
|| s.Contains("religion")
@ -137,18 +142,46 @@ public static partial class EventCatalog
|| s.Contains("baby_make")
|| s.Contains("have_child")
|| s.Contains("birth")
|| s.Contains("reproduction")
|| s.Contains("sexual_reproduction")
|| s.Contains("golden_brain")
|| s.Contains("make_skeleton")
|| s.Contains("soul_harvest")
|| s.Contains("possessed")
|| s.Contains("create_hive")
|| s.Contains("fireworks")
|| s.Contains("affect_dreams")
|| s.Contains("family_group")
|| s.Contains("take_city_item")
|| s.Contains("put_out_fire")
|| s.Contains("burn_tumors");
|| s.Contains("create_hive");
}
/// <summary>
/// Soft AI chatter that distracts the story camera. Outcomes (hatch, pack sticky, fires)
/// already have better feeds when they matter.
/// </summary>
private static bool IsSoftDecisionNoise(string s)
{
if (string.IsNullOrEmpty(s))
{
return true;
}
if (s.Contains("family_group")
|| s.Contains("affect_dreams")
|| s.Contains("fireworks")
|| s.Contains("put_out_fire")
|| s.Contains("burn_tumors")
|| s.Contains("asexual_reproduction")
|| s.Contains("take_city_item")
|| s.Contains("try_to_steal")
|| s.Contains("steal_money"))
{
return true;
}
// Army follow/join intent - keep only lead-to-attack.
if (s == "warrior_army_follow_leader"
|| s == "warrior_try_join_army_group")
{
return true;
}
return false;
}
/// <summary>
@ -245,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

@ -245,25 +245,8 @@ public static partial class EventCatalog
}
string s = id.ToLowerInvariant();
// High / mid-spectacle materials from live items library plus named relics.
// Leather/wood/copper/iron stay ambient B.
return s.Contains("legendary")
|| s.Contains("mythic")
|| s.Contains("mythril")
|| s.Contains("adamantine")
|| s.Contains("artifact")
|| s.Contains("relic")
|| s.Contains("divine")
|| s.Contains("demon")
|| s.Contains("dragon")
|| s.Contains("nuke")
|| s.Contains("excalibur")
|| s.Contains("wonder")
|| s.Contains("silver")
|| s.Contains("steel")
|| s.Contains("_bone")
|| s.StartsWith("bone_", StringComparison.Ordinal)
|| s.EndsWith("_bone", StringComparison.Ordinal);
// Only top-tier materials own the camera. Steel/silver/bone gear is soak noise.
return s.Contains("mythril") || s.Contains("adamantine");
}
private static void Ensure()
@ -408,6 +391,7 @@ public static partial class EventCatalog
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
// Gene gain/remove is catalog noise - no Signal predicate, all B (like phenotypes).
LibraryCatalogDefaults d = LibOr("gene", 30f, 70f, "Genetics", "{a} gains gene {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
@ -417,25 +401,9 @@ public static partial class EventCatalog
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
signalPredicate: IsSignalGene,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
private static bool IsSignalGene(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
return s.Contains("warfare")
|| s.Contains("magic")
|| s.Contains("mutation")
|| s.Contains("mutagen")
|| s.Contains("damage");
}
public static IEnumerable<string> AuthoredIds
{
get
@ -548,23 +516,11 @@ public static partial class EventCatalog
}
string s = id.ToLowerInvariant();
return s.Contains("war")
|| s.Contains("death")
|| s.Contains("plague")
|| s.Contains("hunger")
|| s.Contains("rebell")
|| s.Contains("disaster")
|| s.Contains("angry")
|| s.Contains("cursed")
// God-law toggles are mostly UI spam. Keep era-defining / crisis switches only.
return s.Contains("cursed")
|| s.Contains("forever_")
|| s.Contains("tumor")
|| s.Contains("mutant")
|| s.Contains("evolution")
|| s.Contains("old_age")
|| s.Contains("kingdom_expansion")
|| s.Contains("border_steal")
|| s.Contains("civ_army")
|| s.Contains("exploding");
|| s.Contains("disaster")
|| s.Contains("rebell");
}
public static IEnumerable<string> AuthoredIds
@ -673,6 +629,38 @@ public static partial class EventCatalog
|| s.Contains("blaster");
}
/// <summary>
/// Culture camera: hard politics only. Lovers / layouts / craft skins are soak noise.
/// </summary>
private static bool IsDramaticCultureTrait(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
if (s.Contains("lover")
|| s.Contains("city_layout")
|| s.StartsWith("craft_", StringComparison.Ordinal)
|| s.Contains("ethno")
|| s.Contains("reading")
|| s.Contains("statue")
|| s.Contains("tower")
|| s.Contains("gossip"))
{
return false;
}
return s.Contains("conscript")
|| s.Contains("xenophob")
|| s.Contains("expans")
|| s.Contains("join_or_die")
|| s.Contains("happiness_from_war")
|| s.Contains("warriors_ascension")
|| s.Contains("shattered_crown");
}
public static class CultureTrait
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
@ -704,7 +692,7 @@ public static partial class EventCatalog
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
signalPredicate: IsDramaticMetaTrait,
signalPredicate: IsDramaticCultureTrait,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
@ -799,6 +787,7 @@ public static partial class EventCatalog
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
// Clan blood / vein traits are gene-like soak noise - all B.
LibraryCatalogDefaults d = LibOr("clanTrait", 32f, 72f, "Clan", "{a} clan gains {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
@ -808,7 +797,6 @@ public static partial class EventCatalog
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
signalPredicate: IsDramaticMetaTrait,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
@ -851,6 +839,7 @@ public static partial class EventCatalog
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
// Language flavor traits never own the camera.
LibraryCatalogDefaults d = LibOr("languageTrait", 30f, 68f, "Language", "{a} tongue gains {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
@ -860,7 +849,6 @@ public static partial class EventCatalog
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
signalPredicate: IsDramaticMetaTrait,
ambientCreatesInterest: d.AmbientCreatesInterest);
}

View file

@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Story aftermath / epilogue dials from <c>event-catalog.json</c> <c>story</c>.</summary>
public static partial class EventCatalog
{
public static class Story
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
Entries.Clear();
_appliedGeneration = -1;
}
private static void Ensure()
{
if (_appliedGeneration == EventCatalogConfig.Generation)
{
return;
}
_appliedGeneration = EventCatalogConfig.Generation;
EventCatalogConfig.FillStory(Entries);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
{
return entry;
}
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
return new DiscreteEventEntry
{
Id = key,
EventStrength = InterestScoringConfig.Story.aftermathStrength,
Category = "Story",
LabelTemplate = "{a} · {id}",
CreatesInterest = true,
IsFallback = true
};
}
}
}

View file

@ -34,6 +34,7 @@ public static partial class EventCatalog
WorldLog.InvalidateOverlays();
Plot.InvalidateOverlays();
Relationship.InvalidateOverlays();
Story.InvalidateOverlays();
Trait.InvalidateOverlays();
Book.InvalidateOverlays();
Era.InvalidateOverlays();

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,

File diff suppressed because it is too large Load diff

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 =>
@ -298,6 +386,37 @@ public static class InterestDirector
}
_inactiveSince = now;
StoryPlanner.OnClimaxCold(_current);
}
/// <summary>
/// Harness: force WarFront / PlotActive / CombatActive cold so story aftermath can inject.
/// </summary>
public static void HarnessMarkStickySceneCold()
{
if (_current == null)
{
return;
}
float now = Time.unscaledTime;
if (_current.Completion == InterestCompletionKind.CombatActive)
{
HarnessMarkCombatCold();
return;
}
_current.ForceActive = false;
_current.LastSeenAt = now - 60f;
// Break sticky liveness predicates (sides / plotters).
_current.Sticky.Clear();
_current.ParticipantCount = 0;
// Age past MaxWatch so IsActive is false even if a path still resolves.
float past = Mathf.Max(_current.MinWatch, _current.MaxWatch) + 1f;
_currentStartedAt = now - past;
_lastSwitchAt = _currentStartedAt;
_inactiveSince = now;
StoryPlanner.OnClimaxCold(_current);
}
/// <summary>Harness: clear FollowUnit while keeping RelatedUnit for death handoff.</summary>
@ -348,6 +467,7 @@ public static class InterestDirector
InterestVariety.Clear();
InterestScoring.ClearCache();
InterestFeeds.Reset();
StoryPlanner.Clear();
_current = null;
_interrupted = null;
float now = Time.unscaledTime;
@ -420,6 +540,7 @@ public static class InterestDirector
float onCurrent = now - _currentStartedAt;
float sinceSwitch = now - _lastSwitchAt;
StoryPlanner.Tick(now);
UpdateSessionLiveness(now, onCurrent);
MaintainCombatFocus(now, force: false);
MaintainWarFront(now, force: false);
@ -487,6 +608,9 @@ public static class InterestDirector
return;
}
// Climax went cold: inject / claim aftermath (idempotent per climax key).
StoryPlanner.OnClimaxCold(_current);
// No living subject left: prefer sticky combat roster handoff before ending.
if (!_current.HasFollowUnit
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
@ -672,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())
{
@ -813,6 +944,7 @@ public static class InterestDirector
CameraDirector.Watch(_current.ToInterestEvent());
}
StoryPlanner.SyncActiveClimax(_current);
return true;
}
@ -956,6 +1088,7 @@ public static class InterestDirector
CameraDirector.Watch(_current.ToInterestEvent());
}
StoryPlanner.SyncActiveClimax(_current);
return true;
}
@ -1214,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;
}
@ -3195,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.
@ -3231,20 +3375,49 @@ public static class InterestDirector
private static void EndCurrent(float now, string reason)
{
InterestCandidate ended = _current;
if (_current != null)
{
InterestRegistry.Remove(_current.Key);
LogService.LogInfo("[IdleSpectator] Scene end (" + reason + "): " + _current.Label);
}
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);
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.
InterestCandidate storyNext = SelectNext(now);
if (storyNext != null
&& StoryPlanner.OwnsCandidate(storyNext)
&& IsWorthWatchingNow(storyNext, now, selectingDuringGrace: true))
{
_interrupted = null;
_current = null;
_harnessCombatForcedCold = false;
_inactiveSince = -999f;
SwitchTo(storyNext, now, resumableInterrupt: false);
return;
}
@ -3751,6 +3924,13 @@ public static class InterestDirector
bool inGrace = InQuietGrace;
if (inGrace)
{
// Story aftermath / same-arc continuation may cut freely during quiet grace.
if (IsSameStoryArc(_current, candidate)
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
{
return true;
}
// Quiet grace after a sticky hold: only margin-worthy events may cut in.
// Prevents family-join / love status from stealing a fight between swings.
if (_current != null
@ -3852,6 +4032,13 @@ public static class InterestDirector
return true;
}
// StoryPlanner hard-arc hold during aftermath/epilogue against unrelated peers.
float storyHold = StoryPlanner.ArcHoldMargin(_current, candidate);
if (storyHold > 0f)
{
cutMargin = Mathf.Max(cutMargin, storyHold);
}
// Instant score-margin cut - no settle grace, no MinDwell block.
// Soft clusters use the normal cut-in margin (not stickyCutInMargin).
float effectiveMargin = IsSoftStickyCluster(_current) ? w.cutInMargin : cutMargin;
@ -3860,12 +4047,13 @@ public static class InterestDirector
return true;
}
if (sticky && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin)
if ((sticky || storyHold > 0f) && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin)
{
InterestDropLog.Record(
"below_margin",
$"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}"
+ (varietyValve ? " valve" : ""));
+ (varietyValve ? " valve" : "")
+ (storyHold > 0f ? " story" : ""));
}
// Fill never cuts a protected non-fill session without margin (already failed above).
@ -4164,6 +4352,12 @@ public static class InterestDirector
return true;
}
// StoryPlanner aftermath / epilogue of the active climax.
if (StoryPlanner.IsContinuationOf(current, next))
{
return true;
}
return false;
}
@ -4474,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();
}
@ -4510,6 +4706,7 @@ public static class InterestDirector
InterestRegistry.MarkSelected(next.Key);
InterestVariety.NoteSelection(next);
StoryPlanner.OnSwitchTo(next, now);
CameraDirector.Watch(next.ToInterestEvent());
}

View file

@ -159,12 +159,18 @@ public static class InterestScoring
float repeatPenalty = InterestVariety.RepeatArcPenalty(c);
float frequencyAdjust = InterestVariety.FrequencyAdjust(c);
float causalBonus = CausalHeat.ScoreBonus(c);
float ledgerBonus = CharacterLedger.ScoreBonus(c);
float ownershipBonus = StoryPlanner.OwnershipBoost(c);
c.TotalScore = c.EventStrength + scaleBonus
+ c.CharacterSignificance * charWeight
+ c.VisualConfidence * w.visualMultiplier
+ c.Novelty * w.noveltyMultiplier
- repeatPenalty
+ frequencyAdjust;
+ frequencyAdjust
+ causalBonus
+ ledgerBonus
+ ownershipBonus;
string detail =
$"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}";
if (repeatPenalty > 0.05f)
@ -179,6 +185,21 @@ public static class InterestScoring
+ frequencyAdjust.ToString("0.#");
}
if (causalBonus > 0.05f)
{
detail += $" causal=+{causalBonus:0.#}";
}
if (ledgerBonus > 0.05f)
{
detail += $" ledger=+{ledgerBonus:0.#}";
}
if (ownershipBonus > 0.05f)
{
detail += $" arc=+{ownershipBonus:0.#}";
}
c.ScoreDetail = detail;
}

View file

@ -15,6 +15,33 @@ public class ScoringModelDocument
{
public string modVersion;
public ScoringWeights weights;
public StoryWeights story;
}
/// <summary>StoryPlanner knobs from scoring-model.json <c>story</c> block.</summary>
[Serializable]
public class StoryWeights
{
public float nearTieEpsilon = 8f;
public float causalHeatHalfLifeSeconds = 180f;
public float causalHeatWeight = 6f;
public float causalRelatedWeight = 3f;
public float arcOwnershipBoost = 18f;
public float arcHoldMargin = 50f;
public float aftermathStrength = 62f;
public float aftermathMinWatch = 12f;
public float aftermathMaxWatch = 20f;
public float epilogueStrength = 48f;
public float epilogueMaxWatch = 14f;
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>
@ -175,12 +202,15 @@ public static class InterestScoringConfig
public const string FileName = "scoring-model.json";
private static ScoringWeights _w = new ScoringWeights();
private static StoryWeights _story = new StoryWeights();
private static string _loadedPath = "";
private static string _loadedVersion = "";
private static bool _loadedFromFile;
public static ScoringWeights W => _w;
public static StoryWeights Story => _story;
public static bool LoadedFromFile => _loadedFromFile;
public static string LoadedPath => _loadedPath;
@ -190,6 +220,7 @@ public static class InterestScoringConfig
public static void LoadFromModFolder(string modFolder)
{
_w = new ScoringWeights();
_story = new StoryWeights();
_loadedFromFile = false;
_loadedPath = "";
_loadedVersion = "";
@ -210,22 +241,24 @@ public static class InterestScoringConfig
try
{
string json = File.ReadAllText(path);
if (!TryParseDocument(json, out ScoringWeights weights, out string version))
if (!TryParseDocument(json, out ScoringWeights weights, out StoryWeights story, out string version))
{
LogService.LogInfo("[IdleSpectator] scoring-model.json parse failed, using defaults");
return;
}
_w = weights;
_story = story ?? new StoryWeights();
_loadedFromFile = true;
_loadedPath = path;
_loadedVersion = version ?? "";
LogService.LogInfo(
$"[IdleSpectator] scoring-model.json loaded v={_loadedVersion} charEvent={_w.charWeightEventLed:0.##} mass={_w.massBaseBonus:0.#} duelPair={_w.duelNotablePairBonus:0.#}");
$"[IdleSpectator] scoring-model.json loaded v={_loadedVersion} charEvent={_w.charWeightEventLed:0.##} mass={_w.massBaseBonus:0.#} duelPair={_w.duelNotablePairBonus:0.#} storyNearTie={_story.nearTieEpsilon:0.#}");
}
catch (Exception ex)
{
_w = new ScoringWeights();
_story = new StoryWeights();
LogService.LogInfo($"[IdleSpectator] scoring-model.json failed ({ex.Message}), using defaults");
}
}
@ -234,9 +267,14 @@ public static class InterestScoringConfig
/// Unity JsonUtility often returns null nested objects on mixed doc/schema files.
/// Parse the weights object directly (extracted by brace matching).
/// </summary>
internal static bool TryParseDocument(string json, out ScoringWeights weights, out string version)
internal static bool TryParseDocument(
string json,
out ScoringWeights weights,
out StoryWeights story,
out string version)
{
weights = null;
story = new StoryWeights();
version = "";
if (string.IsNullOrEmpty(json))
{
@ -245,6 +283,15 @@ public static class InterestScoringConfig
version = ExtractJsonString(json, "modVersion") ?? "";
if (TryExtractObject(json, "story", out string storyJson))
{
StoryWeights parsedStory = JsonUtility.FromJson<StoryWeights>(storyJson);
if (parsedStory != null)
{
story = parsedStory;
}
}
// Prefer direct FromJson of the weights object — reliable with flat numeric fields.
if (TryExtractObject(json, "weights", out string weightsJson))
{
@ -261,6 +308,11 @@ public static class InterestScoringConfig
if (doc?.weights != null)
{
weights = doc.weights;
if (doc.story != null)
{
story = doc.story;
}
if (!string.IsNullOrEmpty(doc.modVersion))
{
version = doc.modVersion;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,134 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Decaying per-unit heat from featured cast. Soft-spreads to live relations and Chronicle links.
/// </summary>
public static class CausalHeat
{
private struct HeatEntry
{
public float Value;
public float TouchedAt;
}
private static readonly Dictionary<long, HeatEntry> HeatById = new Dictionary<long, HeatEntry>(64);
private static readonly List<long> ScratchIds = new List<long>(16);
private const float FeaturedAmount = 1f;
private const float RelatedSpread = 0.45f;
public static void Clear()
{
HeatById.Clear();
}
public static void NoteFeatured(long unitId, float amount = FeaturedAmount)
{
if (unitId == 0 || amount <= 0f)
{
return;
}
float now = Time.unscaledTime;
AddHeat(unitId, amount, now);
SpreadRelated(unitId, amount * RelatedSpread, now);
}
public static void NoteCast(IList<long> castIds, float amount = FeaturedAmount)
{
if (castIds == null || castIds.Count == 0)
{
return;
}
for (int i = 0; i < castIds.Count; i++)
{
NoteFeatured(castIds[i], amount);
}
}
public static float Get(long unitId)
{
if (unitId == 0 || !HeatById.TryGetValue(unitId, out HeatEntry entry))
{
return 0f;
}
return Decayed(entry, Time.unscaledTime);
}
public static float ScoreBonus(InterestCandidate c)
{
if (c == null)
{
return 0f;
}
StoryWeights s = InterestScoringConfig.Story;
float subject = Get(c.SubjectId);
float related = c.RelatedId != 0 && c.RelatedId != c.SubjectId ? Get(c.RelatedId) : 0f;
return subject * s.causalHeatWeight + related * s.causalRelatedWeight;
}
private static float Decayed(HeatEntry entry, float now)
{
StoryWeights s = InterestScoringConfig.Story;
float halfLife = s.causalHeatHalfLifeSeconds > 1f ? s.causalHeatHalfLifeSeconds : 180f;
float age = Mathf.Max(0f, now - entry.TouchedAt);
return entry.Value * Mathf.Pow(0.5f, age / halfLife);
}
private static void AddHeat(long unitId, float amount, float now)
{
float cur = 0f;
if (HeatById.TryGetValue(unitId, out HeatEntry entry))
{
cur = Decayed(entry, now);
}
HeatById[unitId] = new HeatEntry
{
Value = Mathf.Min(4f, cur + amount),
TouchedAt = now
};
}
private static void SpreadRelated(long unitId, float amount, float now)
{
if (amount < 0.05f)
{
return;
}
Actor actor = EventFeedUtil.FindAliveById(unitId);
if (actor != null)
{
Actor lover = ActorRelation.GetLover(actor);
if (lover != null)
{
AddHeat(EventFeedUtil.SafeId(lover), amount, now);
}
Actor friend = ActorRelation.GetBestFriend(actor);
if (friend != null)
{
AddHeat(EventFeedUtil.SafeId(friend), amount * 0.75f, now);
}
}
ScratchIds.Clear();
Chronicle.EnumerateRelatedIds(unitId, ScratchIds, max: 6);
for (int i = 0; i < ScratchIds.Count; i++)
{
long other = ScratchIds[i];
if (other == 0 || other == unitId)
{
continue;
}
AddHeat(other, amount * 0.5f, now);
}
}
}

View file

@ -0,0 +1,295 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Small cast of watched lives. Heat from favorites, focus, CausalHeat, and Chronicle density.
/// </summary>
public static class CharacterLedger
{
private struct LedgerEntry
{
public long UnitId;
public float Heat;
public float TouchedAt;
}
private static readonly List<LedgerEntry> Entries = new List<LedgerEntry>(20);
private static readonly List<int> ScratchIdx = new List<int>(8);
public static void Clear()
{
Entries.Clear();
}
public static int Count => Entries.Count;
public static bool IsOnLedger(long unitId)
{
if (unitId == 0)
{
return false;
}
for (int i = 0; i < Entries.Count; i++)
{
if (Entries[i].UnitId == unitId)
{
return true;
}
}
return false;
}
public static float Heat(long unitId)
{
if (unitId == 0)
{
return 0f;
}
for (int i = 0; i < Entries.Count; i++)
{
if (Entries[i].UnitId == unitId)
{
return Entries[i].Heat;
}
}
return 0f;
}
public static float ScoreBonus(InterestCandidate c)
{
if (c == null)
{
return 0f;
}
StoryWeights s = InterestScoringConfig.Story;
float h = Heat(c.SubjectId);
if (h <= 0f && c.RelatedId != 0)
{
h = Heat(c.RelatedId) * 0.5f;
}
return h * s.ledgerWeight;
}
public static void Touch(long unitId, float heatAdd = 1f)
{
if (unitId == 0)
{
return;
}
float now = Time.unscaledTime;
for (int i = 0; i < Entries.Count; i++)
{
if (Entries[i].UnitId != unitId)
{
continue;
}
LedgerEntry e = Entries[i];
e.Heat = Mathf.Min(8f, e.Heat + heatAdd);
e.TouchedAt = now;
Entries[i] = e;
return;
}
EnsureCapacityForNew(unitId);
Entries.Add(new LedgerEntry
{
UnitId = unitId,
Heat = Mathf.Clamp(heatAdd, 0.5f, 8f),
TouchedAt = now
});
}
public static void NoteFeatured(InterestCandidate scene)
{
if (scene == null)
{
return;
}
float add = 1f;
if (scene.FollowUnit != null && Chronicle.IsFavoriteSubject(EventFeedUtil.SafeId(scene.FollowUnit)))
{
add += 1.5f;
}
if (InterestScoring.IsNotable(scene.FollowUnit))
{
add += 0.75f;
}
if (scene.SubjectId != 0)
{
Touch(scene.SubjectId, add);
float density = Chronicle.RecentHistoryCount(
scene.SubjectId,
InterestScoringConfig.Story.historyDensityWindowSeconds);
if (density > 0)
{
Touch(scene.SubjectId, Mathf.Min(2f, density * 0.15f));
}
}
if (scene.RelatedId != 0)
{
Touch(scene.RelatedId, add * 0.6f);
}
float causal = CausalHeat.Get(scene.SubjectId);
if (causal > 0.5f)
{
Touch(scene.SubjectId, Mathf.Min(1.5f, causal));
}
}
/// <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)
{
return;
}
into.Clear();
for (int i = 0; i < Entries.Count; i++)
{
into.Add(Entries[i].UnitId);
}
}
public static Actor PreferLedgerUnit(IEnumerable<Actor> candidates)
{
if (candidates == null)
{
return null;
}
Actor best = null;
float bestHeat = -1f;
foreach (Actor a in candidates)
{
if (a == null || !a.isAlive())
{
continue;
}
long id = EventFeedUtil.SafeId(a);
float h = Heat(id);
if (h > bestHeat)
{
bestHeat = h;
best = a;
}
}
return bestHeat > 0f ? best : null;
}
private static void EnsureCapacityForNew(long incomingId)
{
StoryWeights s = InterestScoringConfig.Story;
int cap = s.ledgerCap > 0 ? s.ledgerCap : 16;
if (Entries.Count < cap)
{
return;
}
// Never evict current arc cast.
ScratchIdx.Clear();
int worst = -1;
float worstHeat = float.MaxValue;
for (int i = 0; i < Entries.Count; i++)
{
long id = Entries[i].UnitId;
if (StoryPlanner.Active != null && StoryPlanner.Active.ContainsCast(id))
{
continue;
}
if (id == incomingId)
{
continue;
}
float h = Entries[i].Heat;
if (h < worstHeat)
{
worstHeat = h;
worst = i;
}
}
if (worst >= 0)
{
Entries.RemoveAt(worst);
}
else if (Entries.Count > 0)
{
// All protected - drop oldest non-incoming.
Entries.RemoveAt(0);
}
}
}

View file

@ -0,0 +1,78 @@
using System.Collections.Generic;
namespace IdleSpectator;
public enum StoryArcKind
{
None = 0,
CombatDuel = 1,
CombatMass = 2,
WarFront = 3,
Plot = 4,
Love = 5,
Grief = 6
}
public enum StoryPhase
{
None = 0,
Climax = 1,
Aftermath = 2,
Epilogue = 3,
Done = 4
}
/// <summary>
/// Short multi-beat story ownership: climax sticky/life scene → aftermath → optional epilogue.
/// </summary>
public sealed class StoryArc
{
public string Id = "";
public StoryArcKind Kind = StoryArcKind.None;
public StoryPhase Phase = StoryPhase.None;
public string AnchorKey = "";
public string ClimaxKey = "";
public float StartedAt;
public float PhaseStartedAt;
/// <summary>True when this arc uses hard hold margins (not soft anonymous duels).</summary>
public bool HardHold;
public readonly List<long> CastIds = new List<long>(4);
public readonly Queue<StoryBeat> PendingBeats = new Queue<StoryBeat>(2);
public bool IsActive =>
Phase != StoryPhase.None && Phase != StoryPhase.Done;
public bool ContainsCast(long unitId)
{
if (unitId == 0)
{
return false;
}
for (int i = 0; i < CastIds.Count; i++)
{
if (CastIds[i] == unitId)
{
return true;
}
}
return false;
}
public void NoteCast(long unitId)
{
if (unitId == 0 || ContainsCast(unitId))
{
return;
}
CastIds.Add(unitId);
}
public void Advance(StoryPhase next, float now)
{
Phase = next;
PhaseStartedAt = now;
}
}

View file

@ -0,0 +1,15 @@
namespace IdleSpectator;
/// <summary>Queued aftermath / epilogue beat for an active <see cref="StoryArc"/>.</summary>
public sealed class StoryBeat
{
public StoryPhase Phase = StoryPhase.Aftermath;
public string AssetId = "";
public string CandidateKey = "";
public long FollowId;
public long RelatedId;
public float EventStrength;
public float MinWatch;
public float MaxWatch;
public bool SynthesizeIfMissing = true;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,77 @@
namespace IdleSpectator;
/// <summary>Subject-led orange Labels for synthetic story aftermath / epilogue beats.</summary>
public static class StoryReason
{
public const string AftermathSurvivor = "aftermath_survivor";
public const string AftermathMourner = "aftermath_mourner";
public const string AftermathWarLinger = "aftermath_war_linger";
public const string AftermathPlotFallout = "aftermath_plot_fallout";
public const string AftermathLoveLinger = "aftermath_love_linger";
public const string EpilogueRelated = "epilogue_related";
public static bool IsStoryAsset(string assetId)
{
if (string.IsNullOrEmpty(assetId))
{
return false;
}
return assetId.StartsWith("aftermath_", System.StringComparison.OrdinalIgnoreCase)
|| assetId.StartsWith("epilogue_", System.StringComparison.OrdinalIgnoreCase);
}
public static string AftermathLabel(string assetId, Actor follow, Actor related)
{
string name = EventFeedUtil.SafeName(follow);
if (string.IsNullOrEmpty(name))
{
name = "Someone";
}
string other = EventFeedUtil.SafeName(related);
string id = (assetId ?? "").Trim().ToLowerInvariant();
switch (id)
{
case AftermathMourner:
return string.IsNullOrEmpty(other)
? name + " mourns the fallen"
: name + " mourns " + other;
case AftermathWarLinger:
return name + " lingers on the war front";
case AftermathPlotFallout:
return name + " faces the plot's fallout";
case AftermathLoveLinger:
return string.IsNullOrEmpty(other)
? name + " stays with their love"
: name + " stays beside " + other;
case EpilogueRelated:
return string.IsNullOrEmpty(other)
? name + " carries on after the story"
: name + " seeks " + other + " after the story";
case AftermathSurvivor:
default:
// 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

@ -1020,7 +1020,22 @@ public static class WorldActivityScanner
{
SplitActorScore(actor, speciesCounts, out float action, out float character);
// Action-primary total used for ranking; character is a light tie-break.
return action + character * InterestScoringConfig.W.scannerRankCharWeight;
float score = action + character * InterestScoringConfig.W.scannerRankCharWeight;
// Character Ledger bias for ambient fill - prefer watched lives over strangers.
long id = EventFeedUtil.SafeId(actor);
float ledger = CharacterLedger.Heat(id);
if (ledger > 0f)
{
score += ledger * Mathf.Max(1f, InterestScoringConfig.Story.ledgerWeight * 0.35f);
}
float causal = CausalHeat.Get(id);
if (causal > 0f)
{
score += causal * 2f;
}
return score;
}
/// <summary>

View file

@ -2588,6 +2588,50 @@
"label": "{a} is created as a baby"
}
],
"story": [
{
"id": "aftermath_survivor",
"strength": 62,
"category": "Story",
"camera": true,
"label": "{a} stands over the fallen"
},
{
"id": "aftermath_mourner",
"strength": 62,
"category": "Story",
"camera": true,
"label": "{a} mourns the fallen"
},
{
"id": "aftermath_war_linger",
"strength": 62,
"category": "Story",
"camera": true,
"label": "{a} lingers on the war front"
},
{
"id": "aftermath_plot_fallout",
"strength": 62,
"category": "Story",
"camera": true,
"label": "{a} faces the plot's fallout"
},
{
"id": "aftermath_love_linger",
"strength": 58,
"category": "Story",
"camera": true,
"label": "{a} stays with their love"
},
{
"id": "epilogue_related",
"strength": 48,
"category": "Story",
"camera": true,
"label": "{a} carries on after the story"
}
],
"traits": [
{
"id": "zombie",

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.28.38",
"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.38",
"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).",
@ -112,6 +112,26 @@
"metaCacheTtl": 2
},
"story": {
"nearTieEpsilon": 8,
"causalHeatHalfLifeSeconds": 180,
"causalHeatWeight": 6,
"causalRelatedWeight": 3,
"arcOwnershipBoost": 18,
"arcHoldMargin": 50,
"aftermathStrength": 62,
"aftermathMinWatch": 12,
"aftermathMaxWatch": 20,
"epilogueStrength": 48,
"epilogueMaxWatch": 14,
"ledgerCap": 16,
"ledgerWeight": 8,
"historyDensityWindowSeconds": 600,
"ledgerBleedLifeChapter": 0.4,
"ledgerBleedVillage": 0.82,
"familyLifeBeatCooldownSeconds": 90
},
"sources": [
"IdleSpectator/scoring-model.json",
"IdleSpectator/InterestScoringConfig.cs",
@ -143,7 +163,12 @@
{
"id": "variety",
"name": "Variety pick",
"detail": "InterestVariety.Pick soft-splits EventLed vs CharacterLed (~70/30), applies novelty penalties, RecalcTotal, EnrichTopK, then probabilistic top-3 by TotalScore."
"detail": "InterestVariety.Pick soft-splits EventLed vs CharacterLed (~70/30), applies novelty penalties, RecalcTotal, EnrichTopK, then takes #1 unless near-tie (story.nearTieEpsilon) among top-3. StoryPlanner ownership can prefer aftermath/epilogue beats."
},
{
"id": "story",
"name": "StoryPlanner",
"detail": "Owns short arcs (climax→aftermath→epilogue). Injects aftermath on climax cold, applies causal/ledger heat and arc ownership boosts. Never calls MoveCamera."
},
{
"id": "director",

View file

@ -62,28 +62,30 @@ Seeded via `LiveLibraryInterest.EnsureSeeded` + JSON `libraries.*` dials.
| Domain | Live | Camera A | Dial label template | Notes | Priority |
|--------|-----:|---------:|---------------------|-------|----------|
| decisions | 127 | **33** | `{a} decides to {action}` | Signal tokens + authored phrases; fluff + intent-only genesis (`try_new_plot`, founding/claim) B; dump `.harness/camera-a-inventory.tsv` | P3 variety 0.28.32 |
| decisions | 127 | **16** politics/spectacle | `{a} decides to {action}` | Soft pack/fireworks/asexual/steal demoted B; fluff + intent-genesis B | noise pass 0.28.41 |
| spells | 12 | 6 | `{a} casts {id}` | Signal spectacle only; FX spells B | P3 |
| powers | 339 | **41** | `God power: {id}` | ambient CI=false; widened spectacle + creature powers; UI `_edit` B | P3 expanded 0.28.27 |
| items | 111 | **42** | `{a} gains {id}` | Signal mythril/adamantine + silver/steel/bone; leather/iron B | P3 expanded 0.28.27 |
| genes | 47 | **7** | `{a} gains gene {id}` | warfare/damage/mutagen Signal | P3 expanded 0.28.27 |
| items | 111 | **20** mythril/adamantine | `{a} gains {id}` | steel/silver/bone demoted B | noise pass 0.28.41 |
| genes | 47 | **0** | `{a} gains gene {id}` | all B (gain/remove tips not camera-worthy) | demoted |
| phenotypes | 52 | 0 | `{a} shows {id}` | all B | P3 |
| world_laws | 49 | **17** | `Law changed: {id}` | dramatic + forever_/cursed/army/expansion… | P3 expanded 0.28.27 |
| world_laws | 49 | **7** crisis | `Law changed: {id}` | hunger/army/expansion/UI toggles B | noise pass 0.28.41 |
| subspecies_traits | 204 | **21** | `{a} evolves {id}` | dramatic + mutation/death/blood/horror | P3 expanded 0.28.27 |
| biomes | 29 | 0 | `Biome shifts: {id}` | ambient CI=false | P3 stay B |
| culture_traits | 78 | **27** | `{a} culture gains {id}` | dramatic + conscript/necro/craft weapons… | P3 expanded 0.28.27 |
| culture_traits | 78 | **8** politics | `{a} culture gains {id}` | lovers/layout/craft B; conscript/xenophobe/expansion A | noise pass 0.28.41 |
| religion_traits | 40 | 6 | `{a} faith gains {id}` | dramatic heuristic | P3 |
| clan_traits | 29 | 13 | `{a} clan gains {id}` | dramatic heuristic | P3 |
| language_traits | 26 | 2 | `{a} tongue gains {id}` | dramatic heuristic | P3 |
| clan_traits | 29 | **0** | `{a} clan gains {id}` | all B (blood/vein soak) | noise pass 0.28.41 |
| language_traits | 26 | **0** | `{a} tongue gains {id}` | all B | noise pass 0.28.41 |
**Library+decision Layer A inventory (0.28.32):** live dump total **215 A / 928 B** across 13 domains (`camera_a_inventory_ok`). Decisions **33 A**.
**Library+decision Layer A inventory (0.28.41):** live dump total **125 A / 1018 B** across 13 domains (`camera_a_inventory_ok`). Decisions **16 A**.
### Decision camera classes (0.28.32)
### Decision camera classes (0.28.41)
Allow-token Signal after fluff + intent-genesis filter (not 127 ambient).
Allow-token Signal after fluff + intent-genesis + soft-noise filter (not 127 ambient).
Authored action phrases required for every A id (`domain_event_audit` decision_prose).
Classes: reproduction/pack (`reproduction`, `family_group`), army/war/politics (existing), spectacle/dark (`golden_brain`, `make_skeleton`, `soul_harvest`, `possessed`), civic soft (`put_out_fire`, `burn_tumors`, `fireworks`, `affect_dreams`), hive (`create_hive`).
Classes: sexual reproduction / lover / child, army/war/politics, spectacle/dark (`golden_brain`, `make_skeleton`, `soul_harvest`, `possessed`), hive (`create_hive`).
Stay B (soft noise 0.28.41): `family_group`, `fireworks`, `affect_dreams`, `put_out_fire`, `burn_tumors`, `asexual_reproduction`, steal/take city item, `warrior_army_follow_leader`, `warrior_try_join_army_group`.
Stay B (intent-only; camera on outcome feeds): `try_new_plot*`, `try_to_start_new_civilization*`, `build_civ_city*`, `*city_foundation*`, `claim_land*` → real tips from `PlotInterestFeed` / `MetaEventPatches` (`new_city` 86, `new_kingdom` 92).

View file

@ -9,21 +9,21 @@ traits 116 authored 0 116 wired authored_labels P3
disasters 17 authored 0 via_worldlog wired authored_labels P3 disaster_war_audit PASS
war_types_library 5 authored 0 dials wired authored_labels P3
relationship 11 authored_discrete 0 11 wired authored_labels P3 no AssetManager lib
decisions_library 127 live+dial 0 33 wired action_phrases P3 intent genesis demoted 0.28.32; camera-a-inventory.tsv
decisions_library 127 live+dial 0 16 wired action_phrases P3 soft noise demoted 0.28.41; camera-a-inventory.tsv
spells 12 live+dial 0 6 wired display_names P3 Signal spectacle; 6 FX B
powers 339 live+dial 0 41 wired display_names P3 Signal widened 0.28.27; ambient CI=false
items 111 live+dial 0 42 wired gains_verb P3 Signal mythril/adamantine/silver/steel/bone
genes 47 live+dial 0 7 wired display_names P3 warfare/damage/mutagen Signal
items 111 live+dial 0 20 wired gains_verb P3 Signal mythril/adamantine gear only
genes 47 live+dial 0 0 wired display_names P3 all B; gain/remove not camera
phenotypes 52 live+dial 0 0 wired display_names P3 all B
world_laws_library 49 live+dial 0 17 wired display_names P3 dramatic + forever_/cursed/army…
world_laws_library 49 live+dial 0 7 wired display_names P3 cursed/forever_/disaster/rebell only
subspecies_traits 204 live+dial 0 21 wired display_names P3 dramatic + mutation/death/blood
biome_library 29 live+dial 0 0 wired display_names P3 ambient CI=false
culture_traits 78 live+dial 0 27 wired display_names P3 dramatic widened 0.28.27
culture_traits 78 live+dial 0 8 wired display_names P3 politics-only 0.28.41
religion_traits 40 live+dial 0 6 wired display_names P3
clan_traits 29 live+dial 0 13 wired display_names P3
language_traits 26 live+dial 0 2 wired display_names P3
clan_traits 29 live+dial 0 0 wired display_names P3 all B 0.28.41
language_traits 26 live+dial 0 0 wired display_names P3 all B 0.28.41
buildings 618 patch_only n/a consume_and_civ_complete partial EventReason.BuildingEat_BuildingComplete P3 civ complete/wonder A; destroy demoted B
boats n/a patch_only n/a trade_unload wired EventReason.Boat P3 load skipped
combat n/a csharp_policy n/a pair_keys wired EventReason.Fight P3 soft Duel sticky 0.28.28; hard sticky Skirmish+/notable-pair
camera_a_inventory 1143 harness 0 215 wired camera-a-inventory.tsv P3 A=215 B=928 domains=13; gate camera_a_inventory_ok
camera_a_inventory 1143 harness 0 125 wired camera-a-inventory.tsv P3 A=125 B=1018 domains=13; gate camera_a_inventory_ok
mutation_gaps 0 harness 0 n/a gaps_empty n/a P3 managers=57 libraryAssets=2893

1 library_or_hook live_count catalog_mode missing_ids camera_a wire_status tip_quality priority notes
9 disasters 17 authored 0 via_worldlog wired authored_labels P3 disaster_war_audit PASS
10 war_types_library 5 authored 0 dials wired authored_labels P3
11 relationship 11 authored_discrete 0 11 wired authored_labels P3 no AssetManager lib
12 decisions_library 127 live+dial 0 33 16 wired action_phrases P3 intent genesis demoted 0.28.32; camera-a-inventory.tsv soft noise demoted 0.28.41; camera-a-inventory.tsv
13 spells 12 live+dial 0 6 wired display_names P3 Signal spectacle; 6 FX B
14 powers 339 live+dial 0 41 wired display_names P3 Signal widened 0.28.27; ambient CI=false
15 items 111 live+dial 0 42 20 wired gains_verb P3 Signal mythril/adamantine/silver/steel/bone Signal mythril/adamantine gear only
16 genes 47 live+dial 0 7 0 wired display_names P3 warfare/damage/mutagen Signal all B; gain/remove not camera
17 phenotypes 52 live+dial 0 0 wired display_names P3 all B
18 world_laws_library 49 live+dial 0 17 7 wired display_names P3 dramatic + forever_/cursed/army… cursed/forever_/disaster/rebell only
19 subspecies_traits 204 live+dial 0 21 wired display_names P3 dramatic + mutation/death/blood
20 biome_library 29 live+dial 0 0 wired display_names P3 ambient CI=false
21 culture_traits 78 live+dial 0 27 8 wired display_names P3 dramatic widened 0.28.27 politics-only 0.28.41
22 religion_traits 40 live+dial 0 6 wired display_names P3
23 clan_traits 29 live+dial 0 13 0 wired display_names P3 all B 0.28.41
24 language_traits 26 live+dial 0 2 0 wired display_names P3 all B 0.28.41
25 buildings 618 patch_only n/a consume_and_civ_complete partial EventReason.BuildingEat_BuildingComplete P3 civ complete/wonder A; destroy demoted B
26 boats n/a patch_only n/a trade_unload wired EventReason.Boat P3 load skipped
27 combat n/a csharp_policy n/a pair_keys wired EventReason.Fight P3 soft Duel sticky 0.28.28; hard sticky Skirmish+/notable-pair
28 camera_a_inventory 1143 harness 0 215 125 wired camera-a-inventory.tsv P3 A=215 B=928 domains=13; gate camera_a_inventory_ok A=125 B=1018 domains=13; gate camera_a_inventory_ok
29 mutation_gaps 0 harness 0 n/a gaps_empty n/a P3 managers=57 libraryAssets=2893

View file

@ -7,6 +7,8 @@
| **B ticker** | Activity / Life / chronicle prose for live ids | Always may write when the game fires |
| **A story camera** | Interest candidates, orange reason, camera cuts | `EventCatalog.IsCameraWorthy` only |
Story commitment (climax → aftermath → epilogue) is owned by [`StoryPlanner`](story-planner.md); orange aftermath Labels come from `StoryReason` and must stay subject-led for presentability.
Happiness: `Presentation.Signal` = A; `Ambient` / `Aggregate` = B-only.
Discrete / status / WorldLog: `CreatesInterest` = A gate.
`InterestDirector` only ranks A candidates. It does not author inventory.
@ -14,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

@ -4,6 +4,8 @@
**Per-event inventory (strength + prose):** [`IdleSpectator/event-catalog.json`](../IdleSpectator/event-catalog.json)
**Story commitment (arcs / aftermath / ledger):** [`story-planner.md`](story-planner.md)
- `scoring-model.json` - margins, multipliers, rarity caps, soft-Duel / variety valve knobs
- `event-catalog.json` - every authored event id (`happiness`, `status`, `worldLog`, `plots`, `decisions`, …)
- Decisions use `action` (clause after "decides to …")

175
docs/story-planner.md Normal file
View file

@ -0,0 +1,175 @@
# Story Planner
IdleSpectator commits to short multi-beat stories instead of channel-surfing ranked tips.
Feeds, sticky ensembles, presentability, and `InterestDirector` stay in place.
`StoryPlanner` sits above the director and owns *which story* the next 3090s belongs to.
See also: [scoring-model.md](scoring-model.md), [event-reason.md](event-reason.md).
## Pipeline
```text
Feeds → InterestRegistry → StoryPlanner (boost / inject)
→ InterestVariety.Pick → InterestDirector → CameraDirector
```
Director still owns camera, sticky maintain, and switch margins.
Planner never calls `MoveCamera`.
## Arc model
| Kind | Climax | Hard hold |
|------|--------|-----------|
| CombatDuel | sticky pair | only notable pairs (2+ notables) |
| CombatMass | Skirmish+ | yes |
| WarFront / Plot / Love / Grief | existing sticky / life | yes |
Phases: `Climax → Aftermath → Epilogue → Done`.
- **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).
- Near-ties still roll among top-3.
- Causal heat + Character Ledger heat add into `RecalcTotal`.
## Knobs
`scoring-model.json``story` block (loaded by `InterestScoringConfig.Story`).
Catalog policy ids live under `event-catalog.json``story`
(`aftermath_survivor`, `aftermath_mourner`, `aftermath_war_linger`, …).
## Harness
| 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 |
| `story_arc_preempt_resume` | epic cut then fresh scrap aftermath |
| `story_ledger_revisit` | featured unit wins near-tie |
```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, 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

View file

@ -52,11 +52,11 @@ Only true brief FX, cooldowns, civic Aggregates, and god-tool / biome spam stay
| Relationship | 11 | all interesting discrete |
| Plot / Era / War / Book | 28 / 11 / 5 / 12 | all A |
| Trait | 116 | all CI |
| Decision | 127 live / 37 prose overlays | **37** camera-interesting (0.28.27) |
| Spell / Item / Gene / Phenotype / WorldLaw / SubspeciesTrait | live | Signal-only A: spell 6 / item 42 / gene 7 / phenotype 0 / law 17 / subsp 21 |
| Culture / Religion / Clan / LanguageTrait | live dials | Signal dramatic: 27 / 6 / 13 / 2 |
| Decision | 127 live / prose overlays | **16** camera-interesting (0.28.41 soft demote) |
| Spell / Item / Gene / Phenotype / WorldLaw / SubspeciesTrait | live | Signal-only A: spell 6 / item mythril+adamantine 20 / gene 0 / phenotype 0 / law crisis 7 / subsp 21 |
| Culture / Religion / Clan / LanguageTrait | live dials | Culture politics 8; religion 6; clan 0; language 0 |
| Power / Biome | live | Power Signal **41**; biome 0 (ambient CI=false) |
| Library+decision inventory dump | 1143 rows | **219 A / 924 B** (`.harness/camera-a-inventory.tsv`) |
| Library+decision inventory dump | 1143 rows | **125 A / 1018 B** (`.harness/camera-a-inventory.tsv`) |
| Building / Boat | none | destroy/consume/trade/unload; damage/load skipped |
## Wired hooks (major)