Refactor interest management in AgentHarness to replace InterestTier with score-based handling. Update scoring logic across components to prioritize TotalScore for event processing and session control. Revise harness scenarios for improved clarity and accuracy in event representation. Increment version in mod.json to reflect these changes.
This commit is contained in:
parent
8cb19e7388
commit
1d2e830013
20 changed files with 391 additions and 381 deletions
|
|
@ -179,7 +179,7 @@ public static class AgentHarness
|
|||
float seconds = cmd.wait > 0f ? cmd.wait : ParseFloat(cmd.value, 2f);
|
||||
InterestDirector.HarnessAgeCurrent(seconds);
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"aged={seconds:0.##}s tier={InterestDirector.CurrentTierName}");
|
||||
Emit(cmd, ok: true, detail: $"aged={seconds:0.##}s score={InterestDirector.CurrentScoreLabel}");
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -1949,7 +1949,7 @@ public static class AgentHarness
|
|||
ok: true,
|
||||
detail: peek == null
|
||||
? "peek=null"
|
||||
: $"peek={peek.Key} lead={peek.LeadKind} tier={peek.Urgency} label={peek.Label}");
|
||||
: $"peek={peek.Key} lead={peek.LeadKind} score={peek.TotalScore:0.#} label={peek.Label}");
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -2337,7 +2337,8 @@ public static class AgentHarness
|
|||
return;
|
||||
}
|
||||
|
||||
InterestTier tier = ParseTier(cmd.tier, InterestTier.Curiosity);
|
||||
float tierEvt = EventStrengthFromTierHint(cmd.tier, 40f);
|
||||
InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.CharacterLed);
|
||||
string resolvedAsset = follow.asset != null ? follow.asset.id : assetId;
|
||||
string label = string.IsNullOrEmpty(cmd.label)
|
||||
? $"Harness: {resolvedAsset}"
|
||||
|
|
@ -2349,8 +2350,7 @@ public static class AgentHarness
|
|||
|
||||
InterestEvent interest = new InterestEvent
|
||||
{
|
||||
Tier = tier,
|
||||
Score = 50f,
|
||||
Score = tierEvt,
|
||||
Position = follow.current_position,
|
||||
FollowUnit = follow,
|
||||
Label = label,
|
||||
|
|
@ -2391,37 +2391,49 @@ public static class AgentHarness
|
|||
return;
|
||||
}
|
||||
|
||||
float tierEvt = EventStrengthFromTierHint(cmd.tier, 40f);
|
||||
InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.CharacterLed);
|
||||
string tipLabel = string.IsNullOrEmpty(cmd.label)
|
||||
? $"Harness {cmd.tier ?? "scene"}"
|
||||
: cmd.label.Replace("{asset}", follow.asset != null ? follow.asset.id : (assetId ?? ""));
|
||||
|
||||
InterestEvent interest = new InterestEvent
|
||||
{
|
||||
Tier = ParseTier(cmd.tier, InterestTier.Curiosity),
|
||||
Score = 40f,
|
||||
Score = tierEvt,
|
||||
Position = follow.current_position,
|
||||
FollowUnit = follow,
|
||||
Label = string.IsNullOrEmpty(cmd.label)
|
||||
? $"Harness {ParseTier(cmd.tier, InterestTier.Curiosity)}"
|
||||
: cmd.label.Replace("{asset}", follow.asset != null ? follow.asset.id : (assetId ?? "")),
|
||||
Label = tipLabel,
|
||||
CreatedAt = Time.unscaledTime,
|
||||
AssetId = follow.asset != null ? follow.asset.id : assetId
|
||||
};
|
||||
|
||||
InterestTier tier = interest.Tier;
|
||||
bool forceCamera = tierLead == InterestLeadKind.EventLed
|
||||
&& tierEvt >= InterestScoringConfig.W.noticeScoreMin;
|
||||
InterestCandidate registered = InterestFeeds.RegisterHarness(
|
||||
follow,
|
||||
tier,
|
||||
interest.Label,
|
||||
keySuffix: (interest.Label ?? tier.ToString()).Replace(" ", "_"),
|
||||
lead: tier >= InterestTier.Action ? InterestLeadKind.EventLed : InterestLeadKind.CharacterLed,
|
||||
keySuffix: (interest.Label ?? "scene").Replace(" ", "_"),
|
||||
lead: tierLead,
|
||||
completion: InterestCompletionKind.FixedDwell,
|
||||
forceActive: tier >= InterestTier.Action,
|
||||
eventStrength: 40f + (int)tier * 20f,
|
||||
maxWatch: tier >= InterestTier.Epic ? 40f : 25f);
|
||||
forceActive: forceCamera,
|
||||
eventStrength: tierEvt,
|
||||
maxWatch: tierEvt >= 95f ? 40f : 25f);
|
||||
if (registered == null)
|
||||
{
|
||||
InterestCollector.EnqueueDirect(interest);
|
||||
}
|
||||
else if (forceCamera)
|
||||
{
|
||||
// Event-led Action/Epic tips may take the camera; character-led Story/curiosity stay pending.
|
||||
InterestDirector.HarnessForceSession(registered);
|
||||
CameraDirector.Watch(registered.ToInterestEvent());
|
||||
}
|
||||
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"queued tier={tier} label={interest.Label} pending={InterestCollector.PendingCount}");
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail: $"triggered label={tipLabel} evt={tierEvt:0.#} forced={forceCamera}");
|
||||
}
|
||||
|
||||
private static void DoInterestInject(HarnessCommand cmd)
|
||||
|
|
@ -2446,8 +2458,9 @@ public static class AgentHarness
|
|||
return;
|
||||
}
|
||||
|
||||
InterestTier tier = ParseTier(cmd.tier, InterestTier.Action);
|
||||
string label = string.IsNullOrEmpty(cmd.label) ? ("Inject " + tier) : cmd.label;
|
||||
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;
|
||||
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? label.Replace(" ", "_") : cmd.expect;
|
||||
ParseInterestInjectOptions(
|
||||
cmd.value,
|
||||
|
|
@ -2461,13 +2474,10 @@ public static class AgentHarness
|
|||
out int participants,
|
||||
out int notables);
|
||||
|
||||
InterestLeadKind lead = leadOpt
|
||||
?? (tier >= InterestTier.Curiosity && tier < InterestTier.Action
|
||||
? InterestLeadKind.CharacterLed
|
||||
: InterestLeadKind.EventLed);
|
||||
InterestLeadKind lead = leadOpt ?? tierLead;
|
||||
if (eventStrength < 0f)
|
||||
{
|
||||
eventStrength = 50f + (int)tier * 15f;
|
||||
eventStrength = tierEvt;
|
||||
}
|
||||
|
||||
if (charSig < 0f)
|
||||
|
|
@ -2487,7 +2497,6 @@ public static class AgentHarness
|
|||
|
||||
InterestCandidate c = InterestFeeds.RegisterHarness(
|
||||
follow,
|
||||
tier,
|
||||
label,
|
||||
keySuffix,
|
||||
lead,
|
||||
|
|
@ -2533,7 +2542,7 @@ public static class AgentHarness
|
|||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail: $"injected key={c.Key} tier={c.Urgency} lead={c.LeadKind} evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={c.ParticipantCount}/{c.NotableParticipantCount} score={c.TotalScore:0.#} pending={InterestRegistry.PendingCount}");
|
||||
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} score={c.TotalScore:0.#} pending={InterestRegistry.PendingCount}");
|
||||
}
|
||||
|
||||
private static void ParseInterestInjectOptions(
|
||||
|
|
@ -2669,19 +2678,24 @@ public static class AgentHarness
|
|||
return;
|
||||
}
|
||||
|
||||
InterestTier tier = ParseTier(cmd.tier, InterestTier.Action);
|
||||
string label = string.IsNullOrEmpty(cmd.label) ? ("Force " + tier) : cmd.label;
|
||||
float tierEvt = EventStrengthFromTierHint(cmd.tier, 70f);
|
||||
InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.EventLed);
|
||||
string label = string.IsNullOrEmpty(cmd.label) ? ("Force " + (cmd.tier ?? "scene")) : cmd.label;
|
||||
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "force_" + label.Replace(" ", "_") : cmd.expect;
|
||||
InterestCandidate c = InterestFeeds.RegisterHarness(
|
||||
follow,
|
||||
tier,
|
||||
label,
|
||||
keySuffix,
|
||||
lead: InterestLeadKind.EventLed,
|
||||
lead: tierLead,
|
||||
completion: InterestCompletionKind.Manual,
|
||||
forceActive: true,
|
||||
eventStrength: 80f + (int)tier * 10f,
|
||||
eventStrength: tierEvt > 0f ? tierEvt : 70f,
|
||||
maxWatch: 60f);
|
||||
if (c != null && tierLead == InterestLeadKind.CharacterLed)
|
||||
{
|
||||
c.Category = "CharacterVignette";
|
||||
InterestScoring.ScoreCheap(c);
|
||||
}
|
||||
bool ok = InterestDirector.HarnessForceSession(c);
|
||||
if (ok)
|
||||
{
|
||||
|
|
@ -2692,7 +2706,7 @@ public static class AgentHarness
|
|||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok, detail: $"key={InterestDirector.CurrentKey} tier={InterestDirector.CurrentTierName} active={InterestDirector.CurrentIsActive}");
|
||||
Emit(cmd, ok, detail: $"key={InterestDirector.CurrentKey} score={InterestDirector.CurrentScoreLabel} active={InterestDirector.CurrentIsActive}");
|
||||
}
|
||||
|
||||
private static void DoSetSetting(HarnessCommand cmd)
|
||||
|
|
@ -2853,11 +2867,43 @@ public static class AgentHarness
|
|||
}
|
||||
case "current_tier":
|
||||
{
|
||||
string want = (cmd.value ?? cmd.tier ?? "").Trim();
|
||||
string have = InterestDirector.CurrentTierName;
|
||||
pass = !string.IsNullOrEmpty(want)
|
||||
&& string.Equals(have, want, System.StringComparison.OrdinalIgnoreCase);
|
||||
detail = $"tier={have} want={want} label={InterestDirector.CurrentLabel}";
|
||||
// Legacy name: prefer tip/label match; else score band from old tier names.
|
||||
string want = cmd.value ?? "";
|
||||
string label = InterestDirector.CurrentLabel ?? "";
|
||||
string tip = CameraDirector.LastWatchLabel ?? "";
|
||||
float score = InterestDirector.CurrentScore;
|
||||
bool byText = (!string.IsNullOrEmpty(want)
|
||||
&& (label.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| tip.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0));
|
||||
bool byBand = false;
|
||||
string band = want.Trim().ToLowerInvariant();
|
||||
if (band == "ambient")
|
||||
{
|
||||
byBand = InterestScoring.IsFillScore(score);
|
||||
}
|
||||
else if (band == "curiosity")
|
||||
{
|
||||
byBand = score >= 35f && score < InterestScoringConfig.W.noticeScoreMin;
|
||||
}
|
||||
else if (band == "action")
|
||||
{
|
||||
byBand = score >= InterestScoringConfig.W.noticeScoreMin;
|
||||
}
|
||||
else if (band == "story")
|
||||
{
|
||||
byBand = score >= 75f;
|
||||
}
|
||||
else if (band == "epic")
|
||||
{
|
||||
byBand = InterestScoring.IsHotScore(score) || score >= 95f;
|
||||
}
|
||||
else if (band == "none")
|
||||
{
|
||||
byBand = InterestDirector.CurrentCandidate == null;
|
||||
}
|
||||
|
||||
pass = byText || byBand;
|
||||
detail = $"want='{want}' label='{label}' tip='{tip}' score={score:0.#} text={byText} band={byBand}";
|
||||
break;
|
||||
}
|
||||
case "would_accept_curiosity":
|
||||
|
|
@ -2865,7 +2911,7 @@ public static class AgentHarness
|
|||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = InterestDirector.WouldAcceptCuriosity();
|
||||
pass = have == want;
|
||||
detail = $"would_accept={have} want={want} tier={InterestDirector.CurrentTierName}";
|
||||
detail = $"would_accept={have} want={want} score={InterestDirector.CurrentScoreLabel}";
|
||||
break;
|
||||
}
|
||||
case "focus_same":
|
||||
|
|
@ -5146,7 +5192,7 @@ public static class AgentHarness
|
|||
private static void DoSnapshot(HarnessCommand cmd)
|
||||
{
|
||||
string snap =
|
||||
$"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount} tier={InterestDirector.CurrentTierName} discoveryPending={SpeciesDiscovery.PendingCount} caption={WatchCaption.LastHeadline} chronicle={Chronicle.Count}";
|
||||
$"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount} score={InterestDirector.CurrentScoreLabel} discoveryPending={SpeciesDiscovery.PendingCount} caption={WatchCaption.LastHeadline} chronicle={Chronicle.Count}";
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: snap);
|
||||
LogService.LogInfo("[IdleSpectator][SNAP] " + snap);
|
||||
|
|
@ -5219,8 +5265,7 @@ public static class AgentHarness
|
|||
|
||||
InterestEvent interest = new InterestEvent
|
||||
{
|
||||
Tier = ParseTier(cmd.tier, InterestTier.Curiosity),
|
||||
Score = 50f,
|
||||
Score = 50f,
|
||||
Position = near.current_position,
|
||||
FollowUnit = null,
|
||||
Label = label,
|
||||
|
|
@ -5557,21 +5602,53 @@ public static class AgentHarness
|
|||
return actor.asset != null ? actor.asset.id : "unit";
|
||||
}
|
||||
|
||||
private static InterestTier ParseTier(string raw, InterestTier fallback)
|
||||
|
||||
private static float EventStrengthFromTierHint(string raw, float fallback)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (Enum.TryParse(raw, ignoreCase: true, out InterestTier tier))
|
||||
switch (raw.Trim().ToLowerInvariant())
|
||||
{
|
||||
return tier;
|
||||
case "ambient":
|
||||
return 25f;
|
||||
case "curiosity":
|
||||
return 40f;
|
||||
case "action":
|
||||
return 70f;
|
||||
case "story":
|
||||
return 80f;
|
||||
case "epic":
|
||||
return 100f;
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static InterestLeadKind LeadFromTierHint(string raw, InterestLeadKind fallback)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
string s = raw.Trim().ToLowerInvariant();
|
||||
if (s == "ambient" || s == "curiosity" || s == "story")
|
||||
{
|
||||
return InterestLeadKind.CharacterLed;
|
||||
}
|
||||
|
||||
if (s == "action" || s == "epic")
|
||||
{
|
||||
return InterestLeadKind.EventLed;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
|
||||
private static int ParseCountExpect(HarnessCommand cmd, int defaultValue)
|
||||
{
|
||||
// Prefer explicit value= so Step()'s default count=1 does not override want=0.
|
||||
|
|
|
|||
|
|
@ -84,11 +84,10 @@ public static class CameraDirector
|
|||
|
||||
public static string FormatWatchTip(InterestEvent interest)
|
||||
{
|
||||
string tier = interest.Tier.ToString();
|
||||
string reason = string.IsNullOrEmpty(interest.Label)
|
||||
? "something interesting"
|
||||
: interest.Label;
|
||||
return $"Watching [{tier}]: {reason}";
|
||||
return $"Watching: {reason}";
|
||||
}
|
||||
|
||||
public static void ClearFollow()
|
||||
|
|
|
|||
|
|
@ -585,24 +585,20 @@ internal static class HarnessScenarios
|
|||
Step("dt10", "trigger_interest", asset: "sheep", label: "CurioA", tier: "Curiosity"),
|
||||
Step("dt11", "age_current", wait: 2f),
|
||||
Step("dt12", "director_run", wait: 1.2f),
|
||||
Step("dt13", "assert", expect: "current_tier", value: "Curiosity"),
|
||||
Step("dt14", "assert", expect: "tip_contains", value: "CurioA"),
|
||||
Step("dt13", "assert", expect: "tip_contains", value: "CurioA"),
|
||||
|
||||
// Action interrupts curiosity after high-tier window.
|
||||
// Action interrupts curiosity after settle.
|
||||
Step("dt20", "trigger_interest", asset: "auto", label: "ActionA", tier: "Action"),
|
||||
Step("dt21", "age_current", wait: 1.2f),
|
||||
Step("dt22", "director_run", wait: 1.2f),
|
||||
Step("dt23", "assert", expect: "current_tier", value: "Action"),
|
||||
Step("dt24", "assert", expect: "tip_contains", value: "ActionA"),
|
||||
Step("dt23", "assert", expect: "tip_contains", value: "ActionA"),
|
||||
|
||||
// Curiosity must not be accepted while Action holds the camera (no director_run:
|
||||
// ambient rotate would otherwise steal Action after dwell).
|
||||
// Curiosity must not steal the camera while Action holds (no director_run).
|
||||
Step("dt30", "trigger_interest", asset: "auto", label: "CurioBlocked", tier: "Curiosity"),
|
||||
Step("dt31", "assert", expect: "current_tier", value: "Action"),
|
||||
Step("dt32", "assert", expect: "tip_contains", value: "ActionA"),
|
||||
Step("dt33", "assert", expect: "would_accept_curiosity", value: "false"),
|
||||
Step("dt34", "assert", expect: "health"),
|
||||
Step("dt35", "assert", expect: "no_bad"),
|
||||
Step("dt31", "assert", expect: "tip_contains", value: "ActionA"),
|
||||
Step("dt32", "assert", expect: "would_accept_curiosity", value: "false"),
|
||||
Step("dt33", "assert", expect: "health"),
|
||||
Step("dt34", "assert", expect: "no_bad"),
|
||||
|
||||
Step("dt90", "fast_timing", value: "false"),
|
||||
Step("dt99", "snapshot"),
|
||||
|
|
@ -624,19 +620,19 @@ internal static class HarnessScenarios
|
|||
|
||||
// Protected Action: Story waits.
|
||||
Step("dl10", "interest_force_session", asset: "sheep", label: "HoldAction", tier: "Action", expect: "hold_action"),
|
||||
Step("dl11", "assert", expect: "current_tier", value: "Action"),
|
||||
Step("dl11", "assert", expect: "tip_contains", value: "HoldAction"),
|
||||
Step("dl12", "assert", expect: "session_active", value: "true"),
|
||||
Step("dl13", "trigger_interest", asset: "sheep", label: "StoryWait", tier: "Story"),
|
||||
Step("dl14", "age_current", wait: 1.2f),
|
||||
Step("dl15", "director_run", wait: 1.2f),
|
||||
Step("dl16", "assert", expect: "current_tier", value: "Action"),
|
||||
Step("dl16", "assert", expect: "tip_contains", value: "HoldAction"),
|
||||
Step("dl17", "assert", expect: "tip_contains", value: "HoldAction"),
|
||||
|
||||
// Epic preempts Action after settle.
|
||||
Step("dl20", "trigger_interest", asset: "sheep", label: "EpicWin", tier: "Epic"),
|
||||
Step("dl21", "age_current", wait: 1.2f),
|
||||
Step("dl22", "director_run", wait: 1.2f),
|
||||
Step("dl23", "assert", expect: "current_tier", value: "Epic"),
|
||||
Step("dl23", "assert", expect: "tip_contains", value: "EpicWin"),
|
||||
Step("dl24", "assert", expect: "tip_contains", value: "EpicWin"),
|
||||
Step("dl25", "assert", expect: "no_bad"),
|
||||
|
||||
|
|
@ -664,20 +660,20 @@ internal static class HarnessScenarios
|
|||
Step("ih11", "happiness_apply", value: "just_ate"),
|
||||
Step("ih12", "age_current", wait: 1.2f),
|
||||
Step("ih13", "director_run", wait: 1.2f),
|
||||
Step("ih14", "assert", expect: "current_tier", value: "Action"),
|
||||
Step("ih14", "assert", expect: "tip_contains", value: "ActionHold"),
|
||||
Step("ih15", "assert", expect: "tip_contains", value: "ActionHold"),
|
||||
|
||||
// Grief Signal registers into registry (force-note + direct register).
|
||||
Step("ih20", "happiness_force_note", value: "death_child", label: "Ava"),
|
||||
Step("ih21", "assert", expect: "interest_has_key", value: "death_child"),
|
||||
Step("ih22", "assert", expect: "current_tier", value: "Action"),
|
||||
Step("ih22", "assert", expect: "tip_contains", value: "ActionHold"),
|
||||
Step("ih23", "assert", expect: "happiness_drain_seq"),
|
||||
|
||||
// Epic can leave Action; grief may then surface.
|
||||
Step("ih30", "trigger_interest", asset: "auto", label: "EpicClear", tier: "Epic"),
|
||||
Step("ih31", "age_current", wait: 1.2f),
|
||||
Step("ih32", "director_run", wait: 1.2f),
|
||||
Step("ih33", "assert", expect: "current_tier", value: "Epic"),
|
||||
Step("ih33", "assert", expect: "tip_contains", value: "EpicClear"),
|
||||
Step("ih34", "assert", expect: "no_bad"),
|
||||
|
||||
Step("ih90", "fast_timing", value: "false"),
|
||||
|
|
@ -704,16 +700,16 @@ internal static class HarnessScenarios
|
|||
|
||||
// --- Epic preempts Story; Story does not preempt itself past Action rules ---
|
||||
Step("dg10", "interest_force_session", asset: "sheep", label: "HoldStory", tier: "Story", expect: "hold_story"),
|
||||
Step("dg11", "assert", expect: "current_tier", value: "Story"),
|
||||
Step("dg11", "assert", expect: "tip_contains", value: "HoldStory"),
|
||||
Step("dg12", "trigger_interest", asset: "sheep", label: "ActionBlocked", tier: "Action"),
|
||||
Step("dg13", "age_current", wait: 1.2f),
|
||||
Step("dg14", "director_run", wait: 1.2f),
|
||||
Step("dg15", "assert", expect: "current_tier", value: "Story"),
|
||||
Step("dg15", "assert", expect: "tip_contains", value: "HoldStory"),
|
||||
Step("dg16", "assert", expect: "tip_contains", value: "HoldStory"),
|
||||
Step("dg17", "trigger_interest", asset: "sheep", label: "EpicOverStory", tier: "Epic"),
|
||||
Step("dg18", "age_current", wait: 1.2f),
|
||||
Step("dg19", "director_run", wait: 1.2f),
|
||||
Step("dg20", "assert", expect: "current_tier", value: "Epic"),
|
||||
Step("dg20", "assert", expect: "tip_contains", value: "EpicOverStory"),
|
||||
Step("dg21", "assert", expect: "tip_contains", value: "EpicOverStory"),
|
||||
|
||||
// --- Resume after Epic: Action hold interrupted then restored ---
|
||||
|
|
@ -725,13 +721,13 @@ internal static class HarnessScenarios
|
|||
Step("dg35", "trigger_interest", asset: "sheep", label: "EpicInterrupt", tier: "Epic"),
|
||||
Step("dg36", "age_current", wait: 1.2f),
|
||||
Step("dg37", "director_run", wait: 1.2f),
|
||||
Step("dg38", "assert", expect: "current_tier", value: "Epic"),
|
||||
Step("dg38", "assert", expect: "tip_contains", value: "EpicInterrupt"),
|
||||
Step("dg39", "assert", expect: "interrupted_key", value: "hold_resume"),
|
||||
// Max-cap the Epic scene (fast timing caps ~8s) so EndCurrent resumes Action.
|
||||
Step("dg40", "age_current", wait: 9f),
|
||||
Step("dg41", "director_run", wait: 1.2f),
|
||||
Step("dg42", "assert", expect: "tip_contains", value: "HoldResume"),
|
||||
Step("dg43", "assert", expect: "current_tier", value: "Action"),
|
||||
Step("dg43", "assert", expect: "tip_contains", value: "HoldResume"),
|
||||
Step("dg44", "assert", expect: "interrupted_key", value: "none"),
|
||||
|
||||
// --- Max cap ends a forced scene ---
|
||||
|
|
@ -788,7 +784,7 @@ internal static class HarnessScenarios
|
|||
Step("dg112", "interest_feeds_tick"),
|
||||
Step("dg113", "age_current", wait: 1.2f),
|
||||
Step("dg114", "director_run", wait: 1.2f),
|
||||
Step("dg115", "assert", expect: "current_tier", value: "Action"),
|
||||
Step("dg115", "assert", expect: "tip_contains", value: "ActionKeep"),
|
||||
Step("dg116", "assert", expect: "tip_contains", value: "ActionKeep"),
|
||||
Step("dg117", "assert", expect: "interest_no_key", value: "just_ate"),
|
||||
|
||||
|
|
@ -867,9 +863,12 @@ internal static class HarnessScenarios
|
|||
Step("wr44", "screenshot", value: "hud-watch-reason-singleton.png"),
|
||||
Step("wr45", "wait", wait: 0.55f),
|
||||
|
||||
// Ambient catch-all always fills the reason row.
|
||||
Step("wr46", "interest_force_session", asset: "human", label: "Ambient stroll", tier: "Ambient", expect: "ambient_stroll"),
|
||||
Step("wr47", "assert", expect: "dossier_contains", value: "Looking around"),
|
||||
// Character-led fill / ambient → no catch-all reason (row hidden).
|
||||
Step("wr46", "interest_force_session", asset: "human", label: "Ambient stroll", tier: "Ambient", expect: "ambient_stroll",
|
||||
value: "lead=character;evt=20"),
|
||||
Step("wr47", "assert", expect: "dossier_not_contains", value: "Going about their day"),
|
||||
Step("wr47b", "assert", expect: "dossier_not_contains", value: "Curious glance"),
|
||||
Step("wr47c", "assert", expect: "dossier_not_contains", value: "Looking around"),
|
||||
Step("wr48", "assert", expect: "dossier_not_contains", value: "Live ·"),
|
||||
Step("wr49", "wait", wait: 0.35f),
|
||||
|
||||
|
|
@ -945,7 +944,7 @@ internal static class HarnessScenarios
|
|||
Step("ap43", "age_current", wait: 1.2f),
|
||||
Step("ap44", "director_run", wait: 1.2f),
|
||||
Step("ap45", "assert", expect: "tip_contains", value: "RealFight"),
|
||||
Step("ap46", "assert", expect: "current_tier", value: "Action"),
|
||||
Step("ap46", "assert", expect: "tip_contains", value: "RealFight"),
|
||||
|
||||
// Cold-boot focus gaps from welcome/dismiss are unrelated to score order.
|
||||
Step("ap89", "reset_counters"),
|
||||
|
|
@ -1013,13 +1012,13 @@ internal static class HarnessScenarios
|
|||
Step("wl10", "trigger_interest", asset: "auto", label: "Story: kingdom_new", tier: "Story"),
|
||||
Step("wl11", "age_current", wait: 1.2f),
|
||||
Step("wl12", "director_run", wait: 1.2f),
|
||||
Step("wl13", "assert", expect: "current_tier", value: "Story"),
|
||||
Step("wl13", "assert", expect: "tip_contains", value: "kingdom_new"),
|
||||
Step("wl14", "assert", expect: "tip_contains", value: "kingdom_new"),
|
||||
|
||||
Step("wl20", "trigger_interest", asset: "auto", label: "Epic: diplomacy_war_started", tier: "Epic"),
|
||||
Step("wl21", "age_current", wait: 1.2f),
|
||||
Step("wl22", "director_run", wait: 1.2f),
|
||||
Step("wl23", "assert", expect: "current_tier", value: "Epic"),
|
||||
Step("wl23", "assert", expect: "tip_contains", value: "diplomacy_war_started"),
|
||||
Step("wl24", "assert", expect: "tip_contains", value: "diplomacy_war_started"),
|
||||
Step("wl25", "assert", expect: "health"),
|
||||
Step("wl26", "assert", expect: "no_bad"),
|
||||
|
|
|
|||
|
|
@ -28,13 +28,12 @@ public enum InterestCompletionKind
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Durable interest scene candidate. Urgency (<see cref="InterestTier"/>) is preemption class only;
|
||||
/// <see cref="TotalScore"/> ranks within a class.
|
||||
/// Durable interest scene candidate. <see cref="TotalScore"/> is the sole ranking signal;
|
||||
/// typed fields (lead, completion, combat) gate interrupt policy.
|
||||
/// </summary>
|
||||
public sealed class InterestCandidate
|
||||
{
|
||||
public string Key = "";
|
||||
public InterestTier Urgency = InterestTier.Ambient;
|
||||
public InterestLeadKind LeadKind = InterestLeadKind.EventLed;
|
||||
public string Category = "";
|
||||
public string Source = "";
|
||||
|
|
@ -83,13 +82,15 @@ public sealed class InterestCandidate
|
|||
{
|
||||
return new InterestEvent
|
||||
{
|
||||
Tier = Urgency,
|
||||
Score = TotalScore,
|
||||
Position = Position,
|
||||
FollowUnit = FollowUnit,
|
||||
Label = Label,
|
||||
CreatedAt = CreatedAt,
|
||||
AssetId = AssetId
|
||||
AssetId = AssetId,
|
||||
ParticipantCount = ParticipantCount,
|
||||
NotableParticipantCount = NotableParticipantCount,
|
||||
CharacterSignificance = CharacterSignificance
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +99,6 @@ public sealed class InterestCandidate
|
|||
var copy = new InterestCandidate
|
||||
{
|
||||
Key = Key,
|
||||
Urgency = Urgency,
|
||||
LeadKind = LeadKind,
|
||||
Category = Category,
|
||||
Source = Source,
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ public static class InterestCollector
|
|||
|
||||
public static int PendingCount => InterestRegistry.PendingCount;
|
||||
|
||||
public static bool HasPendingAtLeast(InterestTier minTier)
|
||||
public static bool HasPendingScoreAtLeast(float minScore)
|
||||
{
|
||||
return InterestRegistry.HasPendingAtLeast(minTier);
|
||||
return InterestRegistry.HasPendingScoreAtLeast(minScore);
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ using UnityEngine;
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Event-centered scene director: registry intake, protected sessions, Epic-only preemption
|
||||
/// for Action+ scenes, soft 70/30 variety, completion via <see cref="InterestCompletion.IsActive"/>.
|
||||
/// Event-centered scene director: registry intake, protected sessions, score-margin
|
||||
/// preemption with typed combat/vignette rules, soft 70/30 variety, completion via
|
||||
/// <see cref="InterestCompletion.IsActive"/>.
|
||||
/// </summary>
|
||||
public static class InterestDirector
|
||||
{
|
||||
|
|
@ -44,8 +45,13 @@ public static class InterestDirector
|
|||
private static float _inactiveSince = -999f;
|
||||
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(96);
|
||||
|
||||
public static string CurrentTierName =>
|
||||
_current == null ? "none" : _current.Urgency.ToString();
|
||||
public static string CurrentTierName => CurrentScoreLabel;
|
||||
|
||||
public static string CurrentScoreLabel =>
|
||||
_current == null ? "none" : _current.TotalScore.ToString("0.#");
|
||||
|
||||
public static float CurrentScore =>
|
||||
_current == null ? 0f : _current.TotalScore;
|
||||
|
||||
public static string CurrentLabel =>
|
||||
_current == null ? "" : (_current.Label ?? "");
|
||||
|
|
@ -291,7 +297,7 @@ public static class InterestDirector
|
|||
InterestCandidate next = SelectNext(now);
|
||||
if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch))
|
||||
{
|
||||
if (next.Urgency >= InterestTier.Action)
|
||||
if (!InterestScoring.IsFillScore(next.TotalScore))
|
||||
{
|
||||
_lastInterestingAt = now;
|
||||
}
|
||||
|
|
@ -302,8 +308,8 @@ public static class InterestDirector
|
|||
}
|
||||
|
||||
// Queued discovery tips beat ambient fill, but never stall under Action/Story.
|
||||
if (InterestRegistry.HasPendingAtLeast(InterestTier.Curiosity)
|
||||
&& (_current == null || _current.Urgency <= InterestTier.Curiosity))
|
||||
if (InterestRegistry.HasPendingScoreAtLeast(InterestScoringConfig.W.fillScoreMax)
|
||||
&& (_current == null || InterestScoring.IsFillScore(_current.TotalScore)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -448,7 +454,14 @@ public static class InterestDirector
|
|||
float onCurrent = Time.unscaledTime - _currentStartedAt;
|
||||
float sinceSwitch = Time.unscaledTime - _lastSwitchAt;
|
||||
return CanSwitchTo(
|
||||
new InterestCandidate { Urgency = InterestTier.Curiosity, Key = "probe:curiosity" },
|
||||
new InterestCandidate
|
||||
{
|
||||
Key = "probe:curiosity",
|
||||
EventStrength = 40f,
|
||||
LeadKind = InterestLeadKind.CharacterLed,
|
||||
Completion = InterestCompletionKind.CharacterVignette,
|
||||
TotalScore = 45f
|
||||
},
|
||||
onCurrent,
|
||||
sinceSwitch);
|
||||
}
|
||||
|
|
@ -531,14 +544,24 @@ public static class InterestDirector
|
|||
return _minDwell;
|
||||
}
|
||||
|
||||
if (current.MinWatch > 0f)
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
float dwell = InterestScoring.IsHotScore(current.TotalScore)
|
||||
? w.dwellHot
|
||||
: (InterestScoring.IsFillScore(current.TotalScore) ? w.dwellFill : w.dwellNormal);
|
||||
if (_minDwell < MinDwellSeconds * 0.5f)
|
||||
{
|
||||
return current.Urgency <= InterestTier.Curiosity
|
||||
? Mathf.Min(current.MinWatch, _curiosityDwell)
|
||||
: Mathf.Max(current.MinWatch, _minDwell * 0.15f);
|
||||
// Harness fast timing: keep compressed dwells.
|
||||
dwell = InterestScoring.IsFillScore(current.TotalScore) ? _curiosityDwell : _minDwell;
|
||||
}
|
||||
|
||||
return current.Urgency <= InterestTier.Curiosity ? _curiosityDwell : _minDwell;
|
||||
if (current.MinWatch > 0f)
|
||||
{
|
||||
return InterestScoring.IsFillScore(current.TotalScore)
|
||||
? Mathf.Min(current.MinWatch, dwell)
|
||||
: Mathf.Max(current.MinWatch, dwell * 0.15f);
|
||||
}
|
||||
|
||||
return dwell;
|
||||
}
|
||||
|
||||
private static float MaxWatchFor(InterestCandidate current)
|
||||
|
|
@ -603,43 +626,37 @@ public static class InterestDirector
|
|||
|
||||
float now = Time.unscaledTime;
|
||||
bool protectedScene = SessionProtected(now, onCurrent);
|
||||
InterestTier cur = _current.Urgency;
|
||||
InterestTier next = candidate.Urgency;
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
float curScore = _current.TotalScore;
|
||||
float nextScore = candidate.TotalScore;
|
||||
bool nextFill = InterestScoring.IsFillScore(nextScore);
|
||||
bool curFill = InterestScoring.IsFillScore(curScore);
|
||||
|
||||
// Curiosity never interrupts Action+.
|
||||
if (next == InterestTier.Curiosity && cur >= InterestTier.Action)
|
||||
// Fill/curiosity-strength scenes never cut protected non-fill sessions.
|
||||
if (nextFill && !curFill && protectedScene)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (next == InterestTier.Curiosity
|
||||
&& cur == InterestTier.Curiosity
|
||||
&& onCurrent < _curiosityRotate)
|
||||
float fillRotate = _minDwell < MinDwellSeconds * 0.5f ? _curiosityRotate : w.fillRotateSeconds;
|
||||
if (nextFill && curFill && onCurrent < fillRotate)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (protectedScene && cur >= InterestTier.Action)
|
||||
bool typedCutIn = InterestScoring.IsCombatAction(candidate)
|
||||
&& InterestScoring.IsCharacterVignette(_current)
|
||||
&& onCurrent >= _settleGrace;
|
||||
bool combatCutsNonCombat = InterestScoring.IsCombatAction(candidate)
|
||||
&& !InterestScoring.IsCombatAction(_current)
|
||||
&& onCurrent >= _settleGrace
|
||||
&& nextScore >= curScore - w.rotateSlack;
|
||||
bool scoreCutIn = onCurrent >= _settleGrace
|
||||
&& nextScore >= curScore + w.cutInMargin;
|
||||
|
||||
if (protectedScene && !curFill)
|
||||
{
|
||||
// Epic always may cut in after settle.
|
||||
if (next >= InterestTier.Epic && onCurrent >= _settleGrace)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Live combat / battles beat celebrity Story vignettes (actions > characters).
|
||||
if (InterestScoring.IsCombatAction(candidate)
|
||||
&& InterestScoring.IsCharacterVignette(_current)
|
||||
&& onCurrent >= _settleGrace)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Stronger Action combat may cut a weaker non-combat Action after settle.
|
||||
if (next >= InterestTier.Action
|
||||
&& InterestScoring.IsCombatAction(candidate)
|
||||
&& !InterestScoring.IsCombatAction(_current)
|
||||
&& onCurrent >= _settleGrace)
|
||||
if (typedCutIn || combatCutsNonCombat || scoreCutIn)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
|
@ -647,18 +664,20 @@ public static class InterestDirector
|
|||
return false;
|
||||
}
|
||||
|
||||
// Ambient/Curiosity (or unprotected): higher urgency after settle; else dwell+cooldown.
|
||||
if (next > cur && onCurrent >= _settleGrace)
|
||||
// Unprotected / fill current: stronger score after settle, or peer rotate after dwell.
|
||||
if (nextScore > curScore && onCurrent >= _settleGrace)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!protectedScene && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown)
|
||||
{
|
||||
return true;
|
||||
return nextScore >= curScore - w.rotateSlack;
|
||||
}
|
||||
|
||||
return onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown && next >= cur;
|
||||
return onCurrent >= MinDwellFor(_current)
|
||||
&& sinceSwitch >= _switchCooldown
|
||||
&& nextScore >= curScore - w.rotateSlack;
|
||||
}
|
||||
|
||||
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
|
||||
|
|
@ -669,8 +688,8 @@ public static class InterestDirector
|
|||
}
|
||||
|
||||
if (_current != null
|
||||
&& next.Urgency >= InterestTier.Epic
|
||||
&& _current.Urgency < InterestTier.Epic
|
||||
&& InterestScoring.IsHotScore(next.TotalScore)
|
||||
&& !InterestScoring.IsHotScore(_current.TotalScore)
|
||||
&& _current.Resumable)
|
||||
{
|
||||
_interrupted = _current.CloneShallow();
|
||||
|
|
@ -680,7 +699,7 @@ public static class InterestDirector
|
|||
_currentStartedAt = now;
|
||||
_lastSwitchAt = now;
|
||||
_inactiveSince = -999f;
|
||||
if (next.Urgency >= InterestTier.Action)
|
||||
if (!InterestScoring.IsFillScore(next.TotalScore))
|
||||
{
|
||||
_lastInterestingAt = now;
|
||||
}
|
||||
|
|
@ -696,12 +715,12 @@ public static class InterestDirector
|
|||
_lastAmbientAt = now;
|
||||
|
||||
if (!force && _current != null && SessionProtected(now, now - _currentStartedAt)
|
||||
&& _current.Urgency >= InterestTier.Story)
|
||||
&& InterestScoring.IsHotScore(_current.TotalScore))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force && _current != null && _current.Urgency == InterestTier.Curiosity
|
||||
if (!force && _current != null && InterestScoring.IsFillScore(_current.TotalScore)
|
||||
&& now - _currentStartedAt < _ambientRotate)
|
||||
{
|
||||
return;
|
||||
|
|
@ -720,9 +739,9 @@ public static class InterestDirector
|
|||
|
||||
// Harness batches inject their own candidates; never let a live Action/Story
|
||||
// ambient fill steal the camera before trigger_interest / discovery steps.
|
||||
if (AgentHarness.Busy && ambient.Tier >= InterestTier.Action)
|
||||
if (AgentHarness.Busy && ambient.Score >= InterestScoringConfig.W.noticeScoreMin)
|
||||
{
|
||||
ambient.Tier = InterestTier.Ambient;
|
||||
ambient.Score = InterestScoringConfig.W.fillScoreMax * 0.5f;
|
||||
if (string.IsNullOrEmpty(ambient.Label))
|
||||
{
|
||||
ambient.Label = "Ambient";
|
||||
|
|
@ -740,8 +759,8 @@ public static class InterestDirector
|
|||
candidate.Completion = InterestCompletionKind.CharacterVignette;
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
candidate.Urgency = InterestTier.Ambient;
|
||||
candidate.ForceActive = false;
|
||||
candidate.EventStrength = Mathf.Min(candidate.EventStrength, 25f);
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
}
|
||||
|
||||
|
|
@ -750,7 +769,7 @@ public static class InterestDirector
|
|||
&& _current.FollowUnit != null
|
||||
&& candidate.FollowUnit != null
|
||||
&& _current.FollowUnit == candidate.FollowUnit
|
||||
&& candidate.Urgency <= InterestTier.Ambient)
|
||||
&& InterestScoring.IsFillScore(candidate.TotalScore))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ using UnityEngine;
|
|||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight watch payload for camera / tip. Ranking lives on <see cref="Score"/>;
|
||||
/// typed interrupt policy uses the registry candidate, not this DTO.
|
||||
/// </summary>
|
||||
public sealed class InterestEvent
|
||||
{
|
||||
public InterestTier Tier;
|
||||
public float Score;
|
||||
public Vector3 Position;
|
||||
public Actor FollowUnit;
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ public static class InterestFeeds
|
|||
return;
|
||||
}
|
||||
|
||||
if (!WorldLogInterestTable.TryGetTier(message.asset_id, out InterestTier tier))
|
||||
if (!WorldLogInterestTable.TryGetEventStrength(message.asset_id, out float evtSeed))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -113,15 +113,15 @@ public static class InterestFeeds
|
|||
string kingdom = message.special1 ?? "";
|
||||
string key = "worldlog:" + assetId + ":" + kingdom;
|
||||
float boost = PeekCivicBoost(kingdom, assetId);
|
||||
bool politics = evtSeed >= 90f;
|
||||
|
||||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
Urgency = tier,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = tier >= InterestTier.Epic ? "Politics" : "Settlement",
|
||||
Category = politics ? "Politics" : "Settlement",
|
||||
Source = "worldlog",
|
||||
EventStrength = 60f + (int)tier * 20f + boost,
|
||||
EventStrength = evtSeed + boost,
|
||||
VisualConfidence = unit != null ? 0.7f : 0.4f,
|
||||
Position = unit != null ? unit.current_position : position,
|
||||
FollowUnit = unit,
|
||||
|
|
@ -133,8 +133,8 @@ public static class InterestFeeds
|
|||
CreatedAt = Time.unscaledTime,
|
||||
LastSeenAt = Time.unscaledTime,
|
||||
ExpiresAt = Time.unscaledTime + 40f,
|
||||
MinWatch = tier >= InterestTier.Epic ? 8f : 6f,
|
||||
MaxWatch = tier >= InterestTier.Epic ? 35f : 28f,
|
||||
MinWatch = politics ? 8f : 6f,
|
||||
MaxWatch = politics ? 35f : 28f,
|
||||
Completion = InterestCompletionKind.FixedDwell
|
||||
};
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
|
|
@ -152,11 +152,11 @@ public static class InterestFeeds
|
|||
Actor unit = interest.FollowUnit;
|
||||
long subjectId = SafeId(unit);
|
||||
string asset = interest.AssetId ?? "";
|
||||
string key = "direct:" + interest.Tier + ":" + subjectId + ":" + (interest.Label ?? "") + ":" + asset;
|
||||
InterestLeadKind lead = interest.Tier >= InterestTier.Action
|
||||
? InterestLeadKind.EventLed
|
||||
: InterestLeadKind.CharacterLed;
|
||||
InterestCompletionKind completion = interest.Tier >= InterestTier.Action
|
||||
float score = interest.Score;
|
||||
bool hot = score >= InterestScoringConfig.W.noticeScoreMin || asset == "live_battle";
|
||||
string key = "direct:" + subjectId + ":" + (interest.Label ?? "") + ":" + asset;
|
||||
InterestLeadKind lead = hot ? InterestLeadKind.EventLed : InterestLeadKind.CharacterLed;
|
||||
InterestCompletionKind completion = hot
|
||||
? InterestCompletionKind.FixedDwell
|
||||
: InterestCompletionKind.CharacterVignette;
|
||||
|
||||
|
|
@ -170,14 +170,15 @@ public static class InterestFeeds
|
|||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
Urgency = interest.Tier,
|
||||
LeadKind = lead,
|
||||
Category = asset == "live_battle" ? "Combat" : CategoryForTier(interest.Tier, asset),
|
||||
Category = asset == "live_battle"
|
||||
? "Combat"
|
||||
: CategoryForLead(lead, completion, asset),
|
||||
Source = "direct",
|
||||
EventStrength = interest.Score > 0f ? interest.Score : 40f + (int)interest.Tier * 15f,
|
||||
EventStrength = score > 0f ? score : 40f,
|
||||
CharacterSignificance = interest.CharacterSignificance > 0f
|
||||
? interest.CharacterSignificance
|
||||
: (lead == InterestLeadKind.CharacterLed ? interest.Score * 0.5f : 0f),
|
||||
: (lead == InterestLeadKind.CharacterLed ? score * 0.5f : 0f),
|
||||
VisualConfidence = unit != null ? 0.75f : 0.4f,
|
||||
Position = interest.Position,
|
||||
FollowUnit = unit,
|
||||
|
|
@ -191,8 +192,8 @@ public static class InterestFeeds
|
|||
CreatedAt = interest.CreatedAt > 0f ? interest.CreatedAt : Time.unscaledTime,
|
||||
LastSeenAt = Time.unscaledTime,
|
||||
ExpiresAt = Time.unscaledTime + 30f,
|
||||
MinWatch = interest.Tier <= InterestTier.Curiosity ? 1.2f : 1.5f,
|
||||
MaxWatch = interest.Tier >= InterestTier.Epic ? 40f : 25f,
|
||||
MinWatch = InterestScoring.IsFillScore(score) ? 1.2f : 1.5f,
|
||||
MaxWatch = InterestScoring.IsHotScore(score) ? 40f : 25f,
|
||||
Completion = completion
|
||||
};
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
|
|
@ -201,7 +202,6 @@ public static class InterestFeeds
|
|||
|
||||
public static InterestCandidate RegisterHarness(
|
||||
Actor follow,
|
||||
InterestTier urgency,
|
||||
string label,
|
||||
string keySuffix,
|
||||
InterestLeadKind lead,
|
||||
|
|
@ -212,14 +212,15 @@ public static class InterestFeeds
|
|||
{
|
||||
return RegisterHarness(
|
||||
follow,
|
||||
urgency,
|
||||
label,
|
||||
keySuffix,
|
||||
lead,
|
||||
completion,
|
||||
forceActive,
|
||||
eventStrength,
|
||||
characterSignificance: lead == InterestLeadKind.CharacterLed ? eventStrength : 10f,
|
||||
characterSignificance: lead == InterestLeadKind.CharacterLed
|
||||
? UnityEngine.Mathf.Min(40f, eventStrength * 0.4f)
|
||||
: 10f,
|
||||
maxWatch,
|
||||
ttlSeconds: 60f,
|
||||
related: null,
|
||||
|
|
@ -228,7 +229,6 @@ public static class InterestFeeds
|
|||
|
||||
public static InterestCandidate RegisterHarness(
|
||||
Actor follow,
|
||||
InterestTier urgency,
|
||||
string label,
|
||||
string keySuffix,
|
||||
InterestLeadKind lead,
|
||||
|
|
@ -247,14 +247,13 @@ public static class InterestFeeds
|
|||
}
|
||||
|
||||
long id = SafeId(follow);
|
||||
string key = "harness:" + (keySuffix ?? urgency.ToString()) + ":" + id;
|
||||
string key = "harness:" + (keySuffix ?? "scene") + ":" + id;
|
||||
float now = Time.unscaledTime;
|
||||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
Urgency = urgency,
|
||||
LeadKind = lead,
|
||||
Category = CategoryForTier(urgency, ""),
|
||||
Category = CategoryForLead(lead, completion, ""),
|
||||
Source = "harness",
|
||||
EventStrength = eventStrength,
|
||||
CharacterSignificance = characterSignificance,
|
||||
|
|
@ -263,7 +262,7 @@ public static class InterestFeeds
|
|||
FollowUnit = follow,
|
||||
RelatedUnit = related,
|
||||
SubjectId = id,
|
||||
Label = label ?? ("Harness " + urgency),
|
||||
Label = label ?? "Harness",
|
||||
AssetId = follow.asset != null ? follow.asset.id : "harness",
|
||||
SpeciesId = follow.asset != null ? follow.asset.id : "",
|
||||
CreatedAt = now,
|
||||
|
|
@ -305,7 +304,6 @@ public static class InterestFeeds
|
|||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
Urgency = InterestTier.Action,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = combat ? "Combat" : "Work",
|
||||
Source = "activity",
|
||||
|
|
@ -368,7 +366,6 @@ public static class InterestFeeds
|
|||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
Urgency = InterestTier.Action,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = "StatusTransformation",
|
||||
Source = "status",
|
||||
|
|
@ -408,13 +405,9 @@ public static class InterestFeeds
|
|||
long rid = SafeId(related);
|
||||
// Shared key space with happiness canonical merges.
|
||||
string key = "chronicle:" + milestoneKey + ":" + sid + ":" + rid;
|
||||
InterestTier urgency = milestoneKey.Contains("kill") || milestoneKey.Contains("death")
|
||||
? InterestTier.Action
|
||||
: InterestTier.Curiosity;
|
||||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
Urgency = urgency,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = "Relationship",
|
||||
Source = "chronicle",
|
||||
|
|
@ -521,13 +514,11 @@ public static class InterestFeeds
|
|||
string key = "happiness:" + occ.EffectId + ":" + occ.SubjectId + ":" + occ.RelatedId
|
||||
+ ":" + CorrBucket(occ.CorrelationKey);
|
||||
bool grief = occ.EffectId != null && occ.EffectId.StartsWith("death_");
|
||||
InterestTier urgency = InterestScoring.UrgencyForHappiness(occ.EffectId, subject);
|
||||
float strength = InterestScoring.EventStrengthForHappiness(occ.EffectId);
|
||||
|
||||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
Urgency = urgency,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = grief ? "FamilyEmotion" : MapHappinessCategory(cat),
|
||||
Source = "happiness",
|
||||
|
|
@ -599,7 +590,6 @@ public static class InterestFeeds
|
|||
}
|
||||
|
||||
bool grief = effectId.StartsWith("death_");
|
||||
InterestTier urgency = InterestScoring.UrgencyForHappiness(effectId, subject);
|
||||
float strength = InterestScoring.EventStrengthForHappiness(effectId);
|
||||
string key = "happiness:" + effectId + ":" + id + ":0:harness";
|
||||
bool alive = false;
|
||||
|
|
@ -615,7 +605,6 @@ public static class InterestFeeds
|
|||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
Urgency = urgency,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = grief ? "FamilyEmotion" : "LifeChapter",
|
||||
Source = "happiness_harness",
|
||||
|
|
@ -768,34 +757,33 @@ public static class InterestFeeds
|
|||
}
|
||||
}
|
||||
|
||||
private static string CategoryForTier(InterestTier tier, string asset)
|
||||
private static string CategoryForLead(
|
||||
InterestLeadKind lead,
|
||||
InterestCompletionKind completion,
|
||||
string asset)
|
||||
{
|
||||
if (asset == "live_battle")
|
||||
if (asset == "live_battle" || completion == InterestCompletionKind.CombatActive)
|
||||
{
|
||||
return "Combat";
|
||||
}
|
||||
|
||||
if (tier >= InterestTier.Epic)
|
||||
if (completion == InterestCompletionKind.CharacterVignette
|
||||
|| lead == InterestLeadKind.CharacterLed)
|
||||
{
|
||||
return "Politics";
|
||||
return "CharacterVignette";
|
||||
}
|
||||
|
||||
if (tier == InterestTier.Story)
|
||||
if (completion == InterestCompletionKind.HappinessGrief)
|
||||
{
|
||||
return "Settlement";
|
||||
return "FamilyEmotion";
|
||||
}
|
||||
|
||||
if (tier == InterestTier.Action)
|
||||
if (completion == InterestCompletionKind.ActivityActive)
|
||||
{
|
||||
return "Combat";
|
||||
return "Work";
|
||||
}
|
||||
|
||||
if (tier == InterestTier.Curiosity)
|
||||
{
|
||||
return "Discovery";
|
||||
}
|
||||
|
||||
return "CharacterVignette";
|
||||
return "Settlement";
|
||||
}
|
||||
|
||||
private static string CorrBucket(string corr)
|
||||
|
|
|
|||
|
|
@ -91,10 +91,6 @@ public static class InterestRegistry
|
|||
existing.CharacterSignificance, incoming.CharacterSignificance);
|
||||
existing.VisualConfidence = Mathf.Max(existing.VisualConfidence, incoming.VisualConfidence);
|
||||
existing.TotalScore = Mathf.Max(existing.TotalScore, incoming.TotalScore);
|
||||
if (incoming.Urgency > existing.Urgency)
|
||||
{
|
||||
existing.Urgency = incoming.Urgency;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(incoming.Label))
|
||||
{
|
||||
|
|
@ -293,14 +289,14 @@ public static class InterestRegistry
|
|||
}
|
||||
}
|
||||
|
||||
public static bool HasPendingAtLeast(InterestTier minUrgency)
|
||||
public static bool HasPendingScoreAtLeast(float minScore)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
foreach (KeyValuePair<string, InterestCandidate> kv in ByKey)
|
||||
{
|
||||
InterestCandidate c = kv.Value;
|
||||
if (c != null && !c.Selected && c.HasValidPosition && c.Urgency >= minUrgency)
|
||||
if (c != null && !c.Selected && c.HasValidPosition && c.TotalScore >= minScore)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
|
@ -326,22 +322,7 @@ public static class InterestRegistry
|
|||
}
|
||||
}
|
||||
|
||||
Scratch.Sort((a, b) =>
|
||||
{
|
||||
int urg = ((int)b.Urgency).CompareTo((int)a.Urgency);
|
||||
if (urg != 0)
|
||||
{
|
||||
return urg;
|
||||
}
|
||||
|
||||
int score = b.TotalScore.CompareTo(a.TotalScore);
|
||||
if (score != 0)
|
||||
{
|
||||
return score;
|
||||
}
|
||||
|
||||
return b.LastSeenAt.CompareTo(a.LastSeenAt);
|
||||
});
|
||||
Scratch.Sort(InterestScoring.CompareByScore);
|
||||
|
||||
for (int i = MaxCandidates; i < Scratch.Count; i++)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ public sealed class InterestMetadataSnapshot
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Urgency-class mapping and within-class score breakdown.
|
||||
/// Score breakdown and typed scene classifiers (combat / vignette). Ranking is TotalScore only.
|
||||
/// </summary>
|
||||
public static class InterestScoring
|
||||
{
|
||||
|
|
@ -37,31 +37,6 @@ public static class InterestScoring
|
|||
MetaCache.Clear();
|
||||
}
|
||||
|
||||
public static InterestTier UrgencyForHappiness(string effectId, Actor subject)
|
||||
{
|
||||
string id = effectId ?? "";
|
||||
bool notable = IsNotable(subject);
|
||||
|
||||
if (id == "death_child" || id == "death_lover")
|
||||
{
|
||||
return notable ? InterestTier.Story : InterestTier.Action;
|
||||
}
|
||||
|
||||
if (id == "death_best_friend" || id == "death_family_member")
|
||||
{
|
||||
return InterestTier.Action;
|
||||
}
|
||||
|
||||
if (id == "got_saved" || id == "just_born" || id == "just_hatched"
|
||||
|| id == "become_king" || id == "become_city_leader" || id == "become_clan_chief"
|
||||
|| id == "just_found_house" || id == "new_home")
|
||||
{
|
||||
return InterestTier.Action;
|
||||
}
|
||||
|
||||
return InterestTier.Curiosity;
|
||||
}
|
||||
|
||||
public static float EventStrengthForHappiness(string effectId)
|
||||
{
|
||||
string id = effectId ?? "";
|
||||
|
|
@ -92,11 +67,19 @@ public static class InterestScoring
|
|||
}
|
||||
}
|
||||
|
||||
public static InterestTier UrgencyForWorldLog(string assetId)
|
||||
public static float EventStrengthForWorldLog(string assetId)
|
||||
{
|
||||
return WorldLogInterestTable.TryGetTier(assetId, out InterestTier tier)
|
||||
? tier
|
||||
: InterestTier.Ambient;
|
||||
return WorldLogInterestTable.TryGetEventStrength(assetId, out float seed) ? seed : 0f;
|
||||
}
|
||||
|
||||
public static bool IsFillScore(float totalScore)
|
||||
{
|
||||
return totalScore < InterestScoringConfig.W.fillScoreMax;
|
||||
}
|
||||
|
||||
public static bool IsHotScore(float totalScore)
|
||||
{
|
||||
return totalScore >= InterestScoringConfig.W.hotScoreMin;
|
||||
}
|
||||
|
||||
public static void EnrichTopK(List<InterestCandidate> candidates)
|
||||
|
|
@ -106,7 +89,7 @@ public static class InterestScoring
|
|||
return;
|
||||
}
|
||||
|
||||
candidates.Sort(CompareByUrgencyThenScore);
|
||||
candidates.Sort(CompareByScore);
|
||||
int n = Mathf.Min(EnrichTopKLimit, candidates.Count);
|
||||
float now = Time.unscaledTime;
|
||||
for (int i = 0; i < n; i++)
|
||||
|
|
@ -141,19 +124,15 @@ public static class InterestScoring
|
|||
}
|
||||
|
||||
// Action-primary: event strength dominates. Character is a tie-break / small amplifier.
|
||||
// Scale / notables on clusters (battles) add to event strength before this runs.
|
||||
// Knobs: IdleSpectator/scoring-model.json → weights (InterestScoringConfig.W).
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
float charWeight = c.LeadKind == InterestLeadKind.EventLed
|
||||
? w.charWeightEventLed
|
||||
: w.charWeightCharacterLed;
|
||||
float urgencyWeight = 1f + (int)c.Urgency * w.urgencyTierStep;
|
||||
float scaleBonus = 0f;
|
||||
int fighters = c.ParticipantCount;
|
||||
int notables = c.NotableParticipantCount;
|
||||
if (fighters >= w.massFighterThreshold)
|
||||
{
|
||||
// Multi-person melees outrank thin anonymous 1v1s.
|
||||
scaleBonus += w.massBaseBonus
|
||||
+ Mathf.Min(w.massExtraCap, (fighters - w.massFighterThreshold) * w.massPerExtraFighter);
|
||||
}
|
||||
|
|
@ -161,7 +140,6 @@ public static class InterestScoring
|
|||
{
|
||||
if (notables >= 2)
|
||||
{
|
||||
// Extremely important duel (kings/favorites) can beat nameless melees.
|
||||
scaleBonus += w.duelNotablePairBonus;
|
||||
}
|
||||
else if (notables == 1)
|
||||
|
|
@ -179,7 +157,7 @@ public static class InterestScoring
|
|||
scaleBonus += Mathf.Min(w.notableSkirmishCap, notables * w.notableSkirmishPer);
|
||||
}
|
||||
|
||||
c.TotalScore = (c.EventStrength + scaleBonus) * urgencyWeight
|
||||
c.TotalScore = c.EventStrength + scaleBonus
|
||||
+ c.CharacterSignificance * charWeight
|
||||
+ c.VisualConfidence * w.visualMultiplier
|
||||
+ c.Novelty * w.noveltyMultiplier;
|
||||
|
|
@ -285,7 +263,7 @@ public static class InterestScoring
|
|||
}
|
||||
}
|
||||
|
||||
public static int CompareByUrgencyThenScore(InterestCandidate a, InterestCandidate b)
|
||||
public static int CompareByScore(InterestCandidate a, InterestCandidate b)
|
||||
{
|
||||
if (ReferenceEquals(a, b))
|
||||
{
|
||||
|
|
@ -302,12 +280,6 @@ public static class InterestScoring
|
|||
return -1;
|
||||
}
|
||||
|
||||
int urg = ((int)b.Urgency).CompareTo((int)a.Urgency);
|
||||
if (urg != 0)
|
||||
{
|
||||
return urg;
|
||||
}
|
||||
|
||||
int score = b.TotalScore.CompareTo(a.TotalScore);
|
||||
if (score != 0)
|
||||
{
|
||||
|
|
@ -317,6 +289,12 @@ public static class InterestScoring
|
|||
return b.LastSeenAt.CompareTo(a.LastSeenAt);
|
||||
}
|
||||
|
||||
/// <summary>Legacy alias; score-only ranking.</summary>
|
||||
public static int CompareByUrgencyThenScore(InterestCandidate a, InterestCandidate b)
|
||||
{
|
||||
return CompareByScore(a, b);
|
||||
}
|
||||
|
||||
public static bool IsNotable(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
|
|
@ -442,9 +420,9 @@ public static class InterestScoring
|
|||
return true;
|
||||
}
|
||||
|
||||
// Scanner/harness "Story" celebrity strolls are not WorldLog events - actions may cut them.
|
||||
if (c.Urgency == InterestTier.Story
|
||||
&& (c.Source == "scanner" || c.Source == "harness")
|
||||
// Scanner/harness celebrity strolls without a live action beat - combat may cut them.
|
||||
if ((c.Source == "scanner" || c.Source == "harness")
|
||||
&& c.LeadKind == InterestLeadKind.CharacterLed
|
||||
&& string.IsNullOrEmpty(c.HappinessEffectId))
|
||||
{
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -23,10 +23,20 @@ public class ScoringWeights
|
|||
{
|
||||
public float charWeightEventLed = 0.18f;
|
||||
public float charWeightCharacterLed = 0.55f;
|
||||
public float urgencyTierStep = 0.08f;
|
||||
public float visualMultiplier = 12f;
|
||||
public float noveltyMultiplier = 4f;
|
||||
|
||||
// Director policy (score-native; replaces InterestTier preemption).
|
||||
public float cutInMargin = 35f;
|
||||
public float rotateSlack = 12f;
|
||||
public float fillScoreMax = 55f;
|
||||
public float noticeScoreMin = 70f;
|
||||
public float hotScoreMin = 110f;
|
||||
public float dwellFill = 6f;
|
||||
public float dwellNormal = 10f;
|
||||
public float dwellHot = 14f;
|
||||
public float fillRotateSeconds = 10f;
|
||||
|
||||
public int massFighterThreshold = 4;
|
||||
public float massBaseBonus = 28f;
|
||||
public float massPerExtraFighter = 4f;
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Higher value can interrupt lower after interrupt grace.
|
||||
/// </summary>
|
||||
public enum InterestTier
|
||||
{
|
||||
/// <summary>Filler rotation when nothing else is happening.</summary>
|
||||
Ambient = 0,
|
||||
/// <summary>Low priority curiosities (new species, oddities).</summary>
|
||||
Curiosity = 1,
|
||||
/// <summary>Live fights, high-kill creatures, action clusters.</summary>
|
||||
Action = 2,
|
||||
/// <summary>Leadership / politics / favorites.</summary>
|
||||
Story = 3,
|
||||
/// <summary>World-shaping beats (wars, kingdom falls, disasters).</summary>
|
||||
Epic = 4
|
||||
}
|
||||
|
|
@ -174,7 +174,7 @@ public static class InterestVariety
|
|||
InterestCandidate c = pool[i];
|
||||
float penalty = NoveltyPenalty(c, now);
|
||||
// Epic / favorites bypass most novelty.
|
||||
if (c.Urgency >= InterestTier.Epic || InterestScoring.IsNotable(c.FollowUnit))
|
||||
if (InterestScoring.IsHotScore(c.TotalScore) || InterestScoring.IsNotable(c.FollowUnit))
|
||||
{
|
||||
penalty *= 0.15f;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ public sealed class UnitDossier
|
|||
return d;
|
||||
}
|
||||
|
||||
public static UnitDossier FromActor(Actor actor, InterestTier? watchTier = null, string watchLabel = null)
|
||||
public static UnitDossier FromActor(Actor actor, string watchLabel = null)
|
||||
{
|
||||
UnitDossier d = new UnitDossier();
|
||||
if (actor == null || !actor.isAlive())
|
||||
|
|
@ -119,7 +119,6 @@ public sealed class UnitDossier
|
|||
d.Headline = string.IsNullOrEmpty(d.SpeciesId)
|
||||
? d.Name
|
||||
: $"{d.Name} ({d.SpeciesId})";
|
||||
_ = watchTier; // tier badge removed; beat is event-shaped only
|
||||
d.ReasonLine = BuildStoryBeat(d, watchLabel, actor);
|
||||
d.DetailLine = BuildDetailLine(d);
|
||||
d.CaptionText = JoinCaption(d.Headline, d.ReasonLine, d.DetailLine);
|
||||
|
|
@ -214,8 +213,8 @@ public sealed class UnitDossier
|
|||
|
||||
/// <summary>
|
||||
/// Story beat for the dossier reason row: what is happening in this shot.
|
||||
/// No tier badges, no job append, no nametag repeats. Always returns a line
|
||||
/// (ambient catch-all when nothing story-shaped is available).
|
||||
/// No tier badges, no job append, no nametag repeats.
|
||||
/// Fill / ambient scenes with no sharper beat leave the reason empty (row hidden).
|
||||
/// Follow-up (not blocking): expand event/character tags and optional beatPhrases
|
||||
/// in scoring-model.json once playtests show thin beats.
|
||||
/// </summary>
|
||||
|
|
@ -240,10 +239,11 @@ public sealed class UnitDossier
|
|||
return griefOrLife;
|
||||
}
|
||||
|
||||
// Ambient filler: always the catch-all (labels are identity/noise, not story).
|
||||
if (scene != null && scene.Urgency == InterestTier.Ambient)
|
||||
// Ambient filler: no story beat - hide the reason row.
|
||||
if (scene != null && InterestScoring.IsFillScore(scene.TotalScore)
|
||||
&& !InterestScoring.IsCombatAction(scene))
|
||||
{
|
||||
return AmbientBeat(scene);
|
||||
return "";
|
||||
}
|
||||
|
||||
string fromScene = SceneLabelBeat(scene, d);
|
||||
|
|
@ -282,26 +282,7 @@ public sealed class UnitDossier
|
|||
return "New species";
|
||||
}
|
||||
|
||||
return AmbientBeat(scene);
|
||||
}
|
||||
|
||||
/// <summary>Catch-all when the shot has no sharper story beat.</summary>
|
||||
private static string AmbientBeat(InterestCandidate scene)
|
||||
{
|
||||
if (scene != null)
|
||||
{
|
||||
if (scene.Urgency == InterestTier.Curiosity)
|
||||
{
|
||||
return "Curious glance";
|
||||
}
|
||||
|
||||
if (scene.Urgency <= InterestTier.Ambient)
|
||||
{
|
||||
return "Looking around";
|
||||
}
|
||||
}
|
||||
|
||||
return "Looking around";
|
||||
return "";
|
||||
}
|
||||
|
||||
private static InterestCandidate MatchingScene(Actor actor, UnitDossier d)
|
||||
|
|
|
|||
|
|
@ -431,17 +431,17 @@ public static class WatchCaption
|
|||
return;
|
||||
}
|
||||
|
||||
SetFromActor(unit, interest?.Tier, interest?.Label);
|
||||
SetFromActor(unit, interest?.Label);
|
||||
}
|
||||
|
||||
public static void SetFromActor(Actor actor, InterestTier? tier = null, string label = null)
|
||||
public static void SetFromActor(Actor actor, string label = null)
|
||||
{
|
||||
if (!_pinnedWhilePaused)
|
||||
{
|
||||
ClearStatusBanner();
|
||||
}
|
||||
|
||||
UnitDossier dossier = UnitDossier.FromActor(actor, tier, label);
|
||||
UnitDossier dossier = UnitDossier.FromActor(actor, label);
|
||||
_current = dossier;
|
||||
_boundActor = actor;
|
||||
LastHeadline = dossier.Headline ?? "";
|
||||
|
|
|
|||
|
|
@ -88,22 +88,18 @@ public static class WorldActivityScanner
|
|||
{
|
||||
Actor actor = alive[i];
|
||||
ActivityBand band = ActivityInterestTable.Classify(actor);
|
||||
InterestTier tier = InterestTier.Ambient;
|
||||
InterestLeadKind lead = InterestLeadKind.CharacterLed;
|
||||
// Actions outrank characters: combat/hot work are Action events; kings walking stay vignettes.
|
||||
// Actions outrank characters: combat/hot work are events; kings walking stay vignettes.
|
||||
if (band == ActivityBand.Hot || actor.has_attack_target)
|
||||
{
|
||||
tier = InterestTier.Action;
|
||||
lead = InterestLeadKind.EventLed;
|
||||
}
|
||||
else if (band >= ActivityBand.Warm)
|
||||
{
|
||||
tier = InterestTier.Action;
|
||||
lead = InterestLeadKind.EventLed;
|
||||
}
|
||||
else if (IsSpectacle(actor) || IsSingletonSpecies(actor, speciesCounts))
|
||||
{
|
||||
tier = InterestTier.Curiosity;
|
||||
lead = InterestLeadKind.CharacterLed;
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +116,7 @@ public static class WorldActivityScanner
|
|||
|
||||
string key = lead == InterestLeadKind.CharacterLed
|
||||
? "vignette:" + id + ":" + (actor.asset != null ? actor.asset.id : "unit")
|
||||
: "scan:" + tier + ":" + id;
|
||||
: "scan:evt:" + id;
|
||||
|
||||
string label = FormatUnitLabel(actor, speciesCounts);
|
||||
if (actor.has_attack_target)
|
||||
|
|
@ -144,7 +140,6 @@ public static class WorldActivityScanner
|
|||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
Urgency = tier,
|
||||
LeadKind = lead,
|
||||
Category = combat
|
||||
? "Combat"
|
||||
|
|
@ -270,7 +265,6 @@ public static class WorldActivityScanner
|
|||
: $"Skirmish ({fighters} fighting)";
|
||||
best = new InterestEvent
|
||||
{
|
||||
Tier = InterestTier.Action,
|
||||
Score = score,
|
||||
Position = pos,
|
||||
FollowUnit = follow ?? FindNearestAliveUnit(pos, 14f),
|
||||
|
|
@ -330,7 +324,6 @@ public static class WorldActivityScanner
|
|||
bestScore = score;
|
||||
best = new InterestEvent
|
||||
{
|
||||
Tier = InterestTier.Action,
|
||||
Score = score,
|
||||
Position = actor.current_position,
|
||||
FollowUnit = follow ?? actor,
|
||||
|
|
@ -517,7 +510,6 @@ public static class WorldActivityScanner
|
|||
return null;
|
||||
}
|
||||
|
||||
InterestTier tier = InterestTier.Ambient;
|
||||
ActivityBand band = ActivityInterestTable.Classify(best);
|
||||
SplitActorScore(best, speciesCounts, out float actionScore, out float charScore);
|
||||
int fighters = 0;
|
||||
|
|
@ -526,20 +518,6 @@ public static class WorldActivityScanner
|
|||
{
|
||||
CountFightCluster(best.current_position, 10f, out fighters, out notables, out _);
|
||||
fighters = Mathf.Max(1, fighters);
|
||||
tier = InterestTier.Action;
|
||||
}
|
||||
else if (band >= ActivityBand.Warm)
|
||||
{
|
||||
// Warm work is an action beat - not Story celebrity.
|
||||
tier = InterestTier.Action;
|
||||
}
|
||||
else if (IsSpectacle(best) || IsSingletonSpecies(best, speciesCounts))
|
||||
{
|
||||
tier = InterestTier.Curiosity;
|
||||
}
|
||||
else
|
||||
{
|
||||
tier = InterestTier.Ambient;
|
||||
}
|
||||
|
||||
float finalScore = ScoreFightCluster(0, fighters, notables);
|
||||
|
|
@ -555,7 +533,6 @@ public static class WorldActivityScanner
|
|||
|
||||
return new InterestEvent
|
||||
{
|
||||
Tier = tier,
|
||||
Score = finalScore,
|
||||
Position = best.current_position,
|
||||
FollowUnit = best,
|
||||
|
|
@ -874,7 +851,6 @@ public static class WorldActivityScanner
|
|||
|
||||
return new InterestEvent
|
||||
{
|
||||
Tier = InterestTier.Curiosity,
|
||||
Score = 5f,
|
||||
Position = pos,
|
||||
FollowUnit = follow,
|
||||
|
|
@ -1044,7 +1020,6 @@ public static class WorldActivityScanner
|
|||
|
||||
return new InterestEvent
|
||||
{
|
||||
Tier = source.Tier,
|
||||
Score = source.Score,
|
||||
Position = source.Position,
|
||||
FollowUnit = source.FollowUnit,
|
||||
|
|
|
|||
|
|
@ -4,63 +4,63 @@ using UnityEngine;
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized WorldLog asset ids and tier mapping.
|
||||
/// WorldLog asset ids → EventStrength seeds (score-native; no InterestTier).
|
||||
/// </summary>
|
||||
public static class WorldLogInterestTable
|
||||
{
|
||||
private static readonly Dictionary<string, InterestTier> Tiers = new Dictionary<string, InterestTier>
|
||||
private static readonly Dictionary<string, float> EventSeeds = new Dictionary<string, float>
|
||||
{
|
||||
// Epic - world-shaping (wiki: Wars, Kingdoms fall, Disasters, city destroyed)
|
||||
["diplomacy_war_started"] = InterestTier.Epic,
|
||||
["total_war_started"] = InterestTier.Epic,
|
||||
["kingdom_destroyed"] = InterestTier.Epic,
|
||||
["kingdom_shattered"] = InterestTier.Epic,
|
||||
["kingdom_fractured"] = InterestTier.Epic,
|
||||
["city_destroyed"] = InterestTier.Epic,
|
||||
["log_city_revolted"] = InterestTier.Epic,
|
||||
// World-shaping
|
||||
["diplomacy_war_started"] = 100f,
|
||||
["total_war_started"] = 100f,
|
||||
["kingdom_destroyed"] = 100f,
|
||||
["kingdom_shattered"] = 98f,
|
||||
["kingdom_fractured"] = 95f,
|
||||
["city_destroyed"] = 92f,
|
||||
["log_city_revolted"] = 90f,
|
||||
|
||||
// Story - leadership / politics / favorites / founding
|
||||
["kingdom_new"] = InterestTier.Story,
|
||||
["city_new"] = InterestTier.Story,
|
||||
["king_new"] = InterestTier.Story,
|
||||
["king_dead"] = InterestTier.Story,
|
||||
["king_killed"] = InterestTier.Story,
|
||||
["king_fled_capital"] = InterestTier.Story,
|
||||
["king_fled_city"] = InterestTier.Story,
|
||||
["king_left"] = InterestTier.Story,
|
||||
["alliance_new"] = InterestTier.Story,
|
||||
["alliance_dissolved"] = InterestTier.Story,
|
||||
["diplomacy_war_ended"] = InterestTier.Story,
|
||||
["favorite_dead"] = InterestTier.Story,
|
||||
["favorite_killed"] = InterestTier.Story,
|
||||
["kingdom_royal_clan_new"] = InterestTier.Story,
|
||||
["kingdom_royal_clan_changed"] = InterestTier.Story,
|
||||
["kingdom_royal_clan_dead"] = InterestTier.Story,
|
||||
// Leadership / politics / favorites / founding
|
||||
["kingdom_new"] = 75f,
|
||||
["city_new"] = 72f,
|
||||
["king_new"] = 78f,
|
||||
["king_dead"] = 80f,
|
||||
["king_killed"] = 85f,
|
||||
["king_fled_capital"] = 70f,
|
||||
["king_fled_city"] = 68f,
|
||||
["king_left"] = 65f,
|
||||
["alliance_new"] = 70f,
|
||||
["alliance_dissolved"] = 68f,
|
||||
["diplomacy_war_ended"] = 72f,
|
||||
["favorite_dead"] = 82f,
|
||||
["favorite_killed"] = 85f,
|
||||
["kingdom_royal_clan_new"] = 74f,
|
||||
["kingdom_royal_clan_changed"] = 72f,
|
||||
["kingdom_royal_clan_dead"] = 76f,
|
||||
|
||||
// Curiosity - biosphere footnotes
|
||||
["race_dead"] = InterestTier.Curiosity
|
||||
// Biosphere footnotes
|
||||
["race_dead"] = 40f
|
||||
};
|
||||
|
||||
public static bool TryGetTier(string assetId, out InterestTier tier)
|
||||
public static bool TryGetEventStrength(string assetId, out float eventStrength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assetId))
|
||||
{
|
||||
tier = InterestTier.Ambient;
|
||||
eventStrength = 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Tiers.TryGetValue(assetId, out tier))
|
||||
if (EventSeeds.TryGetValue(assetId, out eventStrength))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (assetId.Contains("disaster") || assetId.StartsWith("worldlog_disaster"))
|
||||
{
|
||||
tier = InterestTier.Epic;
|
||||
eventStrength = 95f;
|
||||
return true;
|
||||
}
|
||||
|
||||
tier = InterestTier.Ambient;
|
||||
eventStrength = 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -121,10 +121,12 @@ public static class WorldLogInterestTable
|
|||
{
|
||||
return $"{id}: {a} / {b}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(a))
|
||||
{
|
||||
return $"{id}: {a}";
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,13 @@ public static class WorldLogMessageAddPatch
|
|||
return;
|
||||
}
|
||||
|
||||
if (!WorldLogInterestTable.TryGetTier(pMessage.asset_id, out InterestTier tier))
|
||||
if (!WorldLogInterestTable.TryGetEventStrength(pMessage.asset_id, out float evtSeed))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (tier < InterestTier.Story)
|
||||
// Chronicle notable world events (story-strength and above); skip footnotes.
|
||||
if (evtSeed < 65f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.15.5",
|
||||
"version": "0.16.6",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"modVersion": "0.14.23",
|
||||
"modVersion": "0.16.0",
|
||||
"title": "IdleSpectator interest scoring model",
|
||||
"updated": "2026-07-15",
|
||||
"note": "Edit weights below — InterestScoringConfig loads this file at mod start. Docs sections are human-facing only (ignored by Unity JsonUtility).",
|
||||
|
|
@ -7,10 +7,19 @@
|
|||
"weights": {
|
||||
"charWeightEventLed": 0.18,
|
||||
"charWeightCharacterLed": 0.55,
|
||||
"urgencyTierStep": 0.08,
|
||||
"visualMultiplier": 12,
|
||||
"noveltyMultiplier": 4,
|
||||
|
||||
"cutInMargin": 35,
|
||||
"rotateSlack": 12,
|
||||
"fillScoreMax": 55,
|
||||
"noticeScoreMin": 70,
|
||||
"hotScoreMin": 110,
|
||||
"dwellFill": 6,
|
||||
"dwellNormal": 10,
|
||||
"dwellHot": 14,
|
||||
"fillRotateSeconds": 10,
|
||||
|
||||
"massFighterThreshold": 4,
|
||||
"massBaseBonus": 28,
|
||||
"massPerExtraFighter": 4,
|
||||
|
|
@ -86,39 +95,45 @@
|
|||
"Same action intensity → more important character wins (tie-break).",
|
||||
"Multi-person fights outrank anonymous 1v1s.",
|
||||
"Extremely notable 1v1s (kings/favorites) can beat nameless melees.",
|
||||
"Urgency (tier) is preemption class, not the main score."
|
||||
"TotalScore is the sole ranking signal; typed rules (combat vs vignette) gate interrupts.",
|
||||
"InterestTier was removed - WorldLog seeds EventStrength; director uses cutInMargin / rotateSlack / dwell bands."
|
||||
],
|
||||
"pipeline": [
|
||||
{
|
||||
"id": "feeds",
|
||||
"name": "Feeds → candidates",
|
||||
"detail": "WorldLog, happiness, scanner, battle clusters, harness injects upsert InterestCandidate into InterestRegistry with EventStrength, CharacterSignificance, ParticipantCount, NotableParticipantCount."
|
||||
"detail": "WorldLog, happiness, scanner, battle clusters, harness injects upsert InterestCandidate with EventStrength, CharacterSignificance, ParticipantCount, NotableParticipantCount (no Urgency tier)."
|
||||
},
|
||||
{
|
||||
"id": "variety",
|
||||
"name": "Variety pick",
|
||||
"detail": "InterestVariety.Pick soft-splits EventLed vs CharacterLed (~70/30), applies novelty penalties, RecalcTotal, EnrichTopK, then probabilistic top-3."
|
||||
"detail": "InterestVariety.Pick soft-splits EventLed vs CharacterLed (~70/30), applies novelty penalties, RecalcTotal, EnrichTopK, then probabilistic top-3 by TotalScore."
|
||||
},
|
||||
{
|
||||
"id": "director",
|
||||
"name": "Director switch",
|
||||
"detail": "InterestDirector.CanSwitchTo gates camera changes: settle grace, dwell, Epic cuts, combat-vs-vignette exceptions."
|
||||
"detail": "InterestDirector.CanSwitchTo gates camera changes via score margins (cutInMargin/rotateSlack), settle grace, and typed combat-vs-vignette cut-ins."
|
||||
},
|
||||
{
|
||||
"id": "camera",
|
||||
"name": "Camera + UI",
|
||||
"detail": "CameraDirector.Watch + tip/dossier watch-why from action verbs (Fighting/War/Grief), not identity alone."
|
||||
"detail": "CameraDirector.Watch + dossier story beats from action verbs (Fighting/War/Grief). Fill/ambient with no sharper beat leaves the reason row empty."
|
||||
}
|
||||
],
|
||||
"totalScore": {
|
||||
"file": "InterestScoring.RecalcTotal",
|
||||
"formula": "(EventStrength + scaleBonus) * urgencyWeight + CharacterSignificance * charWeight + VisualConfidence * visualMultiplier + Novelty * noveltyMultiplier",
|
||||
"formula": "EventStrength + scaleBonus + CharacterSignificance * charWeight + VisualConfidence * visualMultiplier + Novelty * noveltyMultiplier",
|
||||
"charWeight": {
|
||||
"EventLed": "weights.charWeightEventLed",
|
||||
"CharacterLed": "weights.charWeightCharacterLed"
|
||||
},
|
||||
"urgencyWeight": "1 + (int)Urgency * weights.urgencyTierStep",
|
||||
"scaleBonus": "See weights.mass* / duel* / notableSkirmish*"
|
||||
"scaleBonus": "See weights.mass* / duel* / notableSkirmish*",
|
||||
"directorPolicy": {
|
||||
"cutInMargin": "weights.cutInMargin",
|
||||
"rotateSlack": "weights.rotateSlack",
|
||||
"fillScoreMax": "weights.fillScoreMax",
|
||||
"dwell": "weights.dwellFill / dwellNormal / dwellHot"
|
||||
}
|
||||
},
|
||||
"workedExamples": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue