Compare commits
3 commits
5cdea1930a
...
9a059c17dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a059c17dd | |||
| 7e02356b01 | |||
| 954016f4b6 |
34 changed files with 6131 additions and 160 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
62
IdleSpectator/Events/Catalogs/EventCatalog.Story.cs
Normal file
62
IdleSpectator/Events/Catalogs/EventCatalog.Story.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ public static partial class EventCatalog
|
|||
WorldLog.InvalidateOverlays();
|
||||
Plot.InvalidateOverlays();
|
||||
Relationship.InvalidateOverlays();
|
||||
Story.InvalidateOverlays();
|
||||
Trait.InvalidateOverlays();
|
||||
Book.InvalidateOverlays();
|
||||
Era.InvalidateOverlays();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) + ")";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
134
IdleSpectator/Story/CausalHeat.cs
Normal file
134
IdleSpectator/Story/CausalHeat.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
295
IdleSpectator/Story/CharacterLedger.cs
Normal file
295
IdleSpectator/Story/CharacterLedger.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
IdleSpectator/Story/StoryArc.cs
Normal file
78
IdleSpectator/Story/StoryArc.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
15
IdleSpectator/Story/StoryBeat.cs
Normal file
15
IdleSpectator/Story/StoryBeat.cs
Normal 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;
|
||||
}
|
||||
1542
IdleSpectator/Story/StoryPlanner.cs
Normal file
1542
IdleSpectator/Story/StoryPlanner.cs
Normal file
File diff suppressed because it is too large
Load diff
77
IdleSpectator/Story/StoryReason.cs
Normal file
77
IdleSpectator/Story/StoryReason.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -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 event’s `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
|
||||
|
|
|
|||
|
|
@ -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
175
docs/story-planner.md
Normal 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 30–90s 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue