This commit is contained in:
DazedAnon 2026-07-16 21:31:00 -05:00
parent 4491423236
commit c575c57190
7 changed files with 507 additions and 33 deletions

View file

@ -1996,6 +1996,23 @@ public static class AgentHarness
DoInterestForceSession(cmd);
break;
case "interest_combat_session":
DoInterestCombatSession(cmd);
break;
case "combat_maintain_focus":
{
InterestDirector.HarnessMaintainCombatFocus();
string tip = CameraDirector.LastWatchLabel ?? "";
string focus = FocusLabel();
_cmdOk++;
Emit(
cmd,
ok: true,
detail: $"key={InterestDirector.CurrentKey} tip='{tip}' focus={focus} completion={InterestDirector.CurrentCandidate?.Completion}");
break;
}
case "interest_variety_clear":
InterestVariety.Clear();
_cmdOk++;
@ -3475,6 +3492,100 @@ public static class AgentHarness
Emit(cmd, ok, detail: $"key={InterestDirector.CurrentKey} score={InterestDirector.CurrentScoreLabel} active={InterestDirector.CurrentIsActive}");
}
/// <summary>
/// Force a CombatActive session with follow + related so handoff/tip rewrite can be asserted.
/// asset = follow, value = related asset (optional), label overrides Fight prose.
/// </summary>
private static void DoInterestCombatSession(HarnessCommand cmd)
{
if (!WorldReady())
{
_cmdFail++;
Emit(cmd, ok: false, detail: "world_not_ready");
return;
}
Actor follow = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
if (follow == null)
{
follow = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
}
if (follow == null)
{
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
}
Actor related = null;
if (!string.IsNullOrEmpty(cmd.value) && cmd.value != "auto")
{
related = ResolveUnit(cmd.value);
}
if (related == null || related == follow)
{
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{
if (actor != null && actor != follow && actor.isAlive())
{
related = actor;
break;
}
}
}
if (follow == null || !follow.isAlive())
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no follow unit for interest_combat_session");
return;
}
string label = string.IsNullOrEmpty(cmd.label)
? EventReason.Fight(follow, related)
: cmd.label.Replace("{a}", SafeName(follow)).Replace("{b}", SafeName(related));
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "combat_focus" : cmd.expect;
InterestCandidate c = InterestFeeds.RegisterHarness(
follow,
label,
keySuffix,
lead: InterestLeadKind.EventLed,
completion: InterestCompletionKind.CombatActive,
forceActive: true,
eventStrength: 120f,
characterSignificance: 20f,
maxWatch: 60f,
ttlSeconds: 60f,
related: related,
resumable: true);
if (c != null)
{
c.Category = "Combat";
c.Verb = "fighting";
if (related != null)
{
c.ParticipantCount = 2;
}
InterestScoring.ScoreCheap(c);
}
bool ok = InterestDirector.HarnessForceSession(c);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(
cmd,
ok,
detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} related={SafeName(related)} tip='{CameraDirector.LastWatchLabel}'");
}
private static void DoSetSetting(HarnessCommand cmd)
{
string key = cmd.expect;
@ -3581,6 +3692,41 @@ public static class AgentHarness
$"tipAsset={tipAsset} unitAsset={unitAsset} tip={tip}";
break;
}
case "tip_matches_focus":
{
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
string tip = CameraDirector.LastWatchLabel ?? "";
string name = SafeName(unit);
if (string.IsNullOrEmpty(tip))
{
// Cleared fight tip during combat cold is OK.
pass = true;
detail = "tip_empty focus=" + name;
}
else if (string.IsNullOrEmpty(name))
{
pass = false;
detail = "no_focus tip='" + tip + "'";
}
else
{
pass = tip.StartsWith(name, StringComparison.OrdinalIgnoreCase)
|| tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0;
// Also accept Battle-style where name leads.
detail = $"tip='{tip}' focus='{name}' pass={pass}";
}
break;
}
case "tip_not_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
string tip = CameraDirector.LastWatchLabel ?? "";
pass = string.IsNullOrEmpty(needle)
|| tip.IndexOf(needle, StringComparison.OrdinalIgnoreCase) < 0;
detail = $"tip='{tip}' not_contains='{needle}'";
break;
}
case "unit_asset":
{
string wantAsset = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;

View file

@ -338,7 +338,6 @@ public static class InterestFeeds
return;
}
long id = SafeId(actor);
Actor foe = null;
try
{
@ -353,7 +352,9 @@ public static class InterestFeeds
}
string verb = taskOrBeat ?? "fighting";
string key = EventCatalog.Combat.Key(id);
WorldActivityScanner.PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
long followId = SafeId(follow ?? actor);
string key = EventCatalog.Combat.Key(followId);
var candidate = new InterestCandidate
{
Key = key,
@ -362,17 +363,17 @@ public static class InterestFeeds
Source = "activity",
EventStrength = EventCatalog.Combat.ActivityStrength,
VisualConfidence = 0.8f,
Position = actor.current_position,
FollowUnit = actor,
RelatedUnit = foe,
SubjectId = id,
RelatedId = foe != null ? SafeId(foe) : 0,
Label = EventReason.Fight(actor, foe),
AssetId = actor.asset != null ? actor.asset.id : "",
SpeciesId = actor.asset != null ? actor.asset.id : "",
Position = follow != null ? follow.current_position : actor.current_position,
FollowUnit = follow ?? actor,
RelatedUnit = related,
SubjectId = followId,
RelatedId = related != null ? SafeId(related) : 0,
Label = EventReason.Fight(follow ?? actor, related),
AssetId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
SpeciesId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
Verb = verb,
CityKey = actor.city != null ? (actor.city.name ?? "") : "",
KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "",
CityKey = (follow ?? actor).city != null ? ((follow ?? actor).city.name ?? "") : "",
KingdomKey = (follow ?? actor).kingdom != null ? ((follow ?? actor).kingdom.name ?? "") : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl,
@ -380,7 +381,15 @@ public static class InterestFeeds
MaxWatch = EventCatalog.Combat.MaxWatch,
Completion = InterestCompletionKind.CombatActive
};
candidate.ParticipantIds.Add(id);
candidate.ParticipantIds.Add(followId);
if (related != null)
{
long rid = SafeId(related);
if (rid != 0)
{
candidate.ParticipantIds.Add(rid);
}
}
InterestScoring.ScoreCheap(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}

View file

@ -50,6 +50,9 @@ internal static class HarnessScenarios
case "status_overlap_camera":
case "status_overlap":
return StatusOverlapCamera();
case "combat_focus":
case "combat_tip_align":
return CombatFocusAlign();
case "director_action_priority":
case "action_priority":
return DirectorActionPriority();
@ -1029,6 +1032,48 @@ internal static class HarnessScenarios
Step("cp90", "fast_timing", value: "false"),
Step("cp99", "snapshot"),
Nested("cp_status_overlap", "status_overlap_camera"),
Nested("cp_combat_focus", "combat_focus"),
};
}
/// <summary>
/// Combat tip subject must match focus; death handoff rewrites tip; cold combat clears fight tip.
/// </summary>
private static List<HarnessCommand> CombatFocusAlign()
{
return new List<HarnessCommand>
{
Step("cf0", "dismiss_windows"),
Step("cf1", "wait_world"),
Step("cf2", "set_setting", expect: "enabled", value: "true"),
Step("cf3", "fast_timing", value: "true"),
Step("cf4", "spawn", asset: "human", count: 1),
Step("cf5", "spawn", asset: "wolf", count: 1),
Step("cf6", "spectator", value: "off"),
Step("cf7", "spectator", value: "on"),
Step("cf8", "pick_unit", asset: "human"),
Step("cf9", "focus", asset: "human"),
Step("cf10", "interest_end_session"),
// Combat scene: human fighting wolf. Clear follow → related (wolf) must own tip+focus.
Step("cf20", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_fight"),
Step("cf21", "assert", expect: "tip_contains", value: "fighting"),
Step("cf22", "interest_clear_follow", asset: "wolf"),
Step("cf23", "combat_maintain_focus"),
Step("cf24", "assert", expect: "has_focus", value: "true"),
Step("cf25", "assert", expect: "tip_matches_focus"),
Step("cf26", "assert", expect: "unit_asset", asset: "wolf"),
// Cold combat must not keep "is fighting".
Step("cf30", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_cold"),
Step("cf31", "assert", expect: "tip_contains", value: "fighting"),
Step("cf32", "interest_release_force"),
Step("cf33", "interest_mark_inactive", value: "1"),
Step("cf34", "combat_maintain_focus"),
Step("cf35", "assert", expect: "tip_not_contains", value: "fighting"),
Step("cf90", "fast_timing", value: "false"),
Step("cf99", "snapshot"),
};
}

View file

@ -44,6 +44,8 @@ public static class InterestDirector
private static float _lastFeedsAt = -999f;
private static float _enabledAt = -999f;
private static float _inactiveSince = -999f;
private static float _lastCombatFocusAt = -999f;
private const float CombatFocusThrottleSeconds = 0.4f;
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(96);
public static string CurrentTierName => CurrentScoreLabel;
@ -357,6 +359,7 @@ public static class InterestDirector
float sinceSwitch = now - _lastSwitchAt;
UpdateSessionLiveness(now, onCurrent);
MaintainCombatFocus(now, force: false);
InterestCandidate next = SelectNext(now);
if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch))
@ -466,6 +469,18 @@ public static class InterestDirector
return;
}
if (_current.Completion == InterestCompletionKind.CombatActive)
{
MaintainCombatFocus(Time.unscaledTime, force: true);
if (!_current.HasFollowUnit
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
{
EndCurrent(Time.unscaledTime, reason: "follow_lost");
}
return;
}
if (_current.HasFollowUnit)
{
// Living subject still owned - reattach if vanilla cleared focus mid-scene.
@ -490,6 +505,92 @@ public static class InterestDirector
EndCurrent(Time.unscaledTime, reason: "follow_lost");
}
/// <summary>
/// Combat scenes: keep camera on the highest-scored living participant and rewrite the tip
/// so the subject always matches focus. Clears fight Labels once combat goes cold.
/// </summary>
private static void MaintainCombatFocus(float now, bool force)
{
if (_current == null || _current.Completion != InterestCompletionKind.CombatActive)
{
return;
}
bool combatHot = InterestCompletion.IsActive(_current, _currentStartedAt, now);
if (!combatHot)
{
ClearStaleCombatLabel(now);
return;
}
if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds)
{
return;
}
_lastCombatFocusAt = now;
if (!WorldActivityScanner.TryPickBestCombatFocus(
_current, out Actor best, out Actor foe, out int fighters))
{
return;
}
bool mass = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|| fighters > 2
|| _current.ParticipantCount > 2;
string label = mass
? EventReason.Battle(best, fighters)
: EventReason.Fight(best, foe);
bool followChanged = _current.FollowUnit != best;
bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal);
_current.FollowUnit = best;
_current.SubjectId = EventFeedUtil.SafeId(best);
_current.RelatedUnit = foe;
_current.RelatedId = foe != null ? EventFeedUtil.SafeId(foe) : 0;
_current.Label = label;
try
{
_current.Position = best.current_position;
}
catch
{
// keep prior position
}
if (followChanged
|| labelChanged
|| !HasLivingCameraFocus()
|| MoveCamera._focus_unit != best)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
}
private static void ClearStaleCombatLabel(float now)
{
if (_current == null || string.IsNullOrEmpty(_current.Label))
{
return;
}
if (_current.Label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0
&& _current.Label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0)
{
return;
}
_current.Label = "";
_lastCombatFocusAt = now;
CameraDirector.Watch(_current.ToInterestEvent());
}
/// <summary>Harness: force one combat focus maintenance pass.</summary>
public static void HarnessMaintainCombatFocus()
{
MaintainCombatFocus(Time.unscaledTime, force: true);
}
/// <summary>
/// Keep a living focus unit whenever Idle Spectator is on.
/// Covers death mid-combat and quiet_grace windows where vanilla cleared follow.
@ -508,6 +609,15 @@ public static class InterestDirector
if (_current != null)
{
if (_current.Completion == InterestCompletionKind.CombatActive)
{
MaintainCombatFocus(now, force: true);
if (HasLivingCameraFocus())
{
return;
}
}
if (_current.HasFollowUnit)
{
CameraDirector.Watch(_current.ToInterestEvent());
@ -519,12 +629,16 @@ public static class InterestDirector
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
{
_current.FollowUnit = _current.RelatedUnit;
_current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit);
CameraDirector.Watch(_current.ToInterestEvent());
if (HasLivingCameraFocus())
// Non-combat ownership handoff; combat already tried picker above.
if (_current.Completion != InterestCompletionKind.CombatActive)
{
return;
_current.FollowUnit = _current.RelatedUnit;
_current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit);
CameraDirector.Watch(_current.ToInterestEvent());
if (HasLivingCameraFocus())
{
return;
}
}
}

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
@ -123,7 +124,8 @@ public static class WorldActivityScanner
CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out _);
fighters = Mathf.Max(1, fighters);
string label = EventReason.Fight(actor, foe);
PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
string label = EventReason.Fight(follow, related);
var candidate = new InterestCandidate
{
Key = EventCatalog.Combat.Key(id),
@ -133,17 +135,17 @@ public static class WorldActivityScanner
EventStrength = Mathf.Max(actionScore, EventCatalog.Combat.ScannerStrengthFloor),
CharacterSignificance = charScore,
VisualConfidence = 0.7f,
Position = actor.current_position,
FollowUnit = actor,
RelatedUnit = foe,
SubjectId = id,
RelatedId = foe != null ? EventFeedUtil.SafeId(foe) : 0,
Position = follow != null ? follow.current_position : actor.current_position,
FollowUnit = follow,
RelatedUnit = related,
SubjectId = follow != null ? EventFeedUtil.SafeId(follow) : id,
RelatedId = related != null ? EventFeedUtil.SafeId(related) : 0,
Label = label,
Verb = "fighting",
AssetId = actor.asset != null ? actor.asset.id : "scored_unit",
SpeciesId = actor.asset != null ? actor.asset.id : "",
CityKey = actor.city != null ? (actor.city.name ?? "") : "",
KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "",
AssetId = follow?.asset != null ? follow.asset.id : (actor.asset != null ? actor.asset.id : "scored_unit"),
SpeciesId = follow?.asset != null ? follow.asset.id : (actor.asset != null ? actor.asset.id : ""),
CityKey = follow?.city != null ? (follow.city.name ?? "") : (actor.city != null ? (actor.city.name ?? "") : ""),
KingdomKey = follow?.kingdom != null ? (follow.kingdom.name ?? "") : (actor.kingdom != null ? (actor.kingdom.name ?? "") : ""),
ParticipantCount = fighters,
NotableParticipantCount = notables,
CreatedAt = Time.unscaledTime,
@ -318,6 +320,160 @@ public static class WorldActivityScanner
return best;
}
/// <summary>
/// Character weight for picking who to watch in a fight (notable / renown / role).
/// Matches <see cref="CountFightCluster"/> bestFollow ranking.
/// </summary>
public static float CombatFocusWeight(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return -1f;
}
SplitActorScore(actor, null, out _, out float charScore);
float weight = charScore;
if (InterestScoring.IsExtremelyNotable(actor))
{
weight += 30f;
}
return weight;
}
/// <summary>
/// Prefer the higher-scored of two combatants as follow; the other becomes related.
/// </summary>
public static void PreferCombatFollow(Actor a, Actor b, out Actor follow, out Actor related)
{
follow = a;
related = b;
if (a == null || !a.isAlive())
{
follow = b != null && b.isAlive() ? b : null;
related = null;
return;
}
if (b == null || !b.isAlive())
{
related = null;
return;
}
if (CombatFocusWeight(b) > CombatFocusWeight(a))
{
follow = b;
related = a;
}
}
/// <summary>
/// Highest-scored living participant for an active combat scene.
/// Considers Follow, Related, and (for mass fights) the nearby fight cluster.
/// </summary>
public static bool TryPickBestCombatFocus(
InterestCandidate scene,
out Actor best,
out Actor foe,
out int fighters)
{
best = null;
foe = null;
fighters = 1;
if (scene == null)
{
return false;
}
fighters = Mathf.Max(1, scene.ParticipantCount);
float bestWeight = -1f;
Actor bestLocal = null;
void Consider(Actor unit)
{
if (unit == null || !unit.isAlive())
{
return;
}
float w = CombatFocusWeight(unit);
if (w > bestWeight)
{
bestWeight = w;
bestLocal = unit;
}
}
Consider(scene.FollowUnit);
Consider(scene.RelatedUnit);
bool mass = string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|| scene.ParticipantCount > 2;
Vector3 pos = scene.Position;
if (bestLocal != null)
{
pos = bestLocal.current_position;
}
else if (scene.FollowUnit != null)
{
pos = scene.FollowUnit.current_position;
}
else if (scene.RelatedUnit != null)
{
pos = scene.RelatedUnit.current_position;
}
if (mass || bestLocal == null)
{
float radius = mass ? 14f : 10f;
CountFightCluster(pos, radius, out int clusterFighters, out _, out Actor clusterBest);
fighters = Mathf.Max(fighters, clusterFighters);
Consider(clusterBest);
}
best = bestLocal;
if (best == null)
{
return false;
}
try
{
if (best.has_attack_target
&& best.attack_target != null
&& best.attack_target.isAlive()
&& best.attack_target.isActor())
{
foe = best.attack_target.a;
if (foe != null && !foe.isAlive())
{
foe = null;
}
}
}
catch
{
foe = null;
}
if (foe == null)
{
Actor related = scene.RelatedUnit;
Actor follow = scene.FollowUnit;
if (related != null && related.isAlive() && related != best)
{
foe = related;
}
else if (follow != null && follow.isAlive() && follow != best)
{
foe = follow;
}
}
return true;
}
private static float ScoreFightCluster(int deaths, int fighters, int notables)
{
ScoringWeights w = InterestScoringConfig.W;

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.25.50",
"description": "AFK Idle Spectator (I) + Lore (L). Status-overlap camera hold gate.",
"version": "0.25.51",
"description": "AFK Idle Spectator (I) + Lore (L). Combat focus + tip alignment.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -76,7 +76,11 @@ See [world-event-inventory.md](world-event-inventory.md) for live library counts
`mutation_discovery` refreshes `.harness/mutation_gaps.tsv`.
Open rows are unexplained Wire candidates only.
## Idle quality (next)
## Idle quality
Fire accuracy is covered by [event-live-verification-plan.md](event-live-verification-plan.md).
Idle watch quality (presentability gate, ambient fall demotion, focus hold, tip placeholder) is tracked in [camera-presentability-plan.md](camera-presentability-plan.md).
Presentability gate, ambient fall demotion, focus hold, and tip placeholder bans are done
(see [camera-presentability-plan.md](camera-presentability-plan.md)).
**Polish next:** combat tip/focus alignment - camera follows the highest-scored living
participant and rewrites the Watching tip so the subject matches focus; clears "is fighting"
once combat goes cold.