Compare commits
3 commits
e4c00b4bbb
...
7dda02bcea
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dda02bcea | |||
| e37cbf7890 | |||
| 3ed85d01d1 |
19 changed files with 3049 additions and 169 deletions
|
|
@ -2013,6 +2013,14 @@ public static class AgentHarness
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "combat_ensemble_escalate":
|
||||||
|
DoCombatEnsembleEscalate(cmd);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "combat_wire_attack_sides":
|
||||||
|
DoCombatWireAttackSides(cmd);
|
||||||
|
break;
|
||||||
|
|
||||||
case "interest_variety_clear":
|
case "interest_variety_clear":
|
||||||
InterestVariety.Clear();
|
InterestVariety.Clear();
|
||||||
_cmdOk++;
|
_cmdOk++;
|
||||||
|
|
@ -3542,7 +3550,19 @@ public static class AgentHarness
|
||||||
}
|
}
|
||||||
|
|
||||||
string label = string.IsNullOrEmpty(cmd.label)
|
string label = string.IsNullOrEmpty(cmd.label)
|
||||||
? EventReason.Fight(follow, related)
|
? EventReason.Combat(new LiveEnsemble
|
||||||
|
{
|
||||||
|
Kind = EnsembleKind.Combat,
|
||||||
|
Scale = EnsembleScale.Pair,
|
||||||
|
Frame = EnsembleFrame.NamedPair,
|
||||||
|
ParticipantCount = related != null ? 2 : 1,
|
||||||
|
Focus = follow,
|
||||||
|
Related = related,
|
||||||
|
SideA = new EnsembleSide { Display = SafeName(follow), Count = 1, Best = follow },
|
||||||
|
SideB = related != null
|
||||||
|
? new EnsembleSide { Display = SafeName(related), Count = 1, Best = related }
|
||||||
|
: null
|
||||||
|
})
|
||||||
: cmd.label.Replace("{a}", SafeName(follow)).Replace("{b}", SafeName(related));
|
: cmd.label.Replace("{a}", SafeName(follow)).Replace("{b}", SafeName(related));
|
||||||
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "combat_focus" : cmd.expect;
|
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "combat_focus" : cmd.expect;
|
||||||
InterestCandidate c = InterestFeeds.RegisterHarness(
|
InterestCandidate c = InterestFeeds.RegisterHarness(
|
||||||
|
|
@ -3562,6 +3582,7 @@ public static class AgentHarness
|
||||||
{
|
{
|
||||||
c.Category = "Combat";
|
c.Category = "Combat";
|
||||||
c.Verb = "fighting";
|
c.Verb = "fighting";
|
||||||
|
c.ClearCombatSticky();
|
||||||
if (related != null)
|
if (related != null)
|
||||||
{
|
{
|
||||||
c.ParticipantCount = 2;
|
c.ParticipantCount = 2;
|
||||||
|
|
@ -3586,6 +3607,349 @@ public static class AgentHarness
|
||||||
detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} related={SafeName(related)} tip='{CameraDirector.LastWatchLabel}'");
|
detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} related={SafeName(related)} tip='{CameraDirector.LastWatchLabel}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Grow the current CombatActive scene into a sided multi-fighter ensemble reason.
|
||||||
|
/// value = participant count (default 6). Spawns extra follow/related species near the focus.
|
||||||
|
/// </summary>
|
||||||
|
private static void DoCombatEnsembleEscalate(HarnessCommand cmd)
|
||||||
|
{
|
||||||
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
||||||
|
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
Emit(cmd, ok: false, detail: "no_combat_scene");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Actor focus = scene.FollowUnit;
|
||||||
|
if (focus == null || !focus.isAlive())
|
||||||
|
{
|
||||||
|
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Actor related = scene.RelatedUnit;
|
||||||
|
if ((focus == null || !focus.isAlive()) && related != null && related.isAlive())
|
||||||
|
{
|
||||||
|
focus = related;
|
||||||
|
related = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focus == null || !focus.isAlive())
|
||||||
|
{
|
||||||
|
// Last resort: nearest living unit so escalate can still seed a pack.
|
||||||
|
focus = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focus == null || !focus.isAlive())
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
Emit(cmd, ok: false, detail: "no_focus");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.ClearCombatSticky();
|
||||||
|
int want = 6;
|
||||||
|
if (!string.IsNullOrEmpty(cmd.value) && !int.TryParse(cmd.value, out want))
|
||||||
|
{
|
||||||
|
want = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
want = Mathf.Clamp(want, 3, 24);
|
||||||
|
string focusSpecies = focus.asset != null ? focus.asset.id : "human";
|
||||||
|
string relatedSpecies = related?.asset != null ? related.asset.id : "wolf";
|
||||||
|
if (string.Equals(relatedSpecies, focusSpecies, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
relatedSpecies = focusSpecies == "human" ? "wolf" : "human";
|
||||||
|
}
|
||||||
|
|
||||||
|
int sideA = want / 2;
|
||||||
|
int sideB = want - sideA;
|
||||||
|
// Already have focus (+ optional related); spawn the rest near the fight.
|
||||||
|
for (int i = 1; i < sideA; i++)
|
||||||
|
{
|
||||||
|
SpawnAssetNear(focusSpecies, focus.current_position, 2f + i * 0.4f);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (related == null || !related.isAlive())
|
||||||
|
{
|
||||||
|
related = SpawnAssetNear(relatedSpecies, focus.current_position, 2.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 1; i < sideB; i++)
|
||||||
|
{
|
||||||
|
SpawnAssetNear(relatedSpecies, focus.current_position, 3f + i * 0.4f);
|
||||||
|
}
|
||||||
|
|
||||||
|
var ensemble = new LiveEnsemble
|
||||||
|
{
|
||||||
|
Kind = EnsembleKind.Combat,
|
||||||
|
Scale = LiveEnsemble.ScaleForCount(want),
|
||||||
|
Frame = EnsembleFrame.SpeciesVsSpecies,
|
||||||
|
ParticipantCount = want,
|
||||||
|
NotableParticipantCount = Mathf.Max(scene.NotableParticipantCount, 0),
|
||||||
|
Focus = focus,
|
||||||
|
Related = related,
|
||||||
|
Anchor = focus.current_position,
|
||||||
|
SideA = new EnsembleSide
|
||||||
|
{
|
||||||
|
Key = focusSpecies,
|
||||||
|
Display = LiveEnsemble.SpeciesDisplay(focusSpecies),
|
||||||
|
Count = sideA,
|
||||||
|
Best = focus
|
||||||
|
},
|
||||||
|
SideB = new EnsembleSide
|
||||||
|
{
|
||||||
|
Key = relatedSpecies,
|
||||||
|
Display = LiveEnsemble.SpeciesDisplay(relatedSpecies),
|
||||||
|
Count = sideB,
|
||||||
|
Best = related
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ensemble.ParticipantIds.Add(EventFeedUtil.SafeId(focus));
|
||||||
|
if (related != null)
|
||||||
|
{
|
||||||
|
ensemble.ParticipantIds.Add(EventFeedUtil.SafeId(related));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok = InterestDirector.HarnessApplyCombatEnsemble(ensemble);
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
_cmdOk++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Emit(
|
||||||
|
cmd,
|
||||||
|
ok,
|
||||||
|
detail: $"n={want} scale={ensemble.Scale} frame={ensemble.Frame} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)} participants={InterestDirector.CurrentCandidate?.ParticipantCount}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort: set attack_target across two species camps near the combat focus
|
||||||
|
/// so <see cref="LiveEnsemble.TryBuildCombat"/> sees a real multi-fighter cluster.
|
||||||
|
/// asset = side A species (default human), value = side B species (default wolf).
|
||||||
|
/// </summary>
|
||||||
|
private static void DoCombatWireAttackSides(HarnessCommand cmd)
|
||||||
|
{
|
||||||
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
||||||
|
Actor anchor = scene?.FollowUnit;
|
||||||
|
if (anchor == null || !anchor.isAlive())
|
||||||
|
{
|
||||||
|
anchor = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchor == null || !anchor.isAlive())
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
Emit(cmd, ok: false, detail: "no_anchor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string sideA = string.IsNullOrEmpty(cmd.asset) ? "human" : cmd.asset.Trim();
|
||||||
|
string sideB = string.IsNullOrEmpty(cmd.value) ? "wolf" : cmd.value.Trim();
|
||||||
|
bool pairOnly = (cmd.expect ?? "").IndexOf("pair", StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|
|| string.Equals(cmd.label, "pair", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (pairOnly)
|
||||||
|
{
|
||||||
|
Actor a = scene?.FollowUnit ?? anchor;
|
||||||
|
Actor b = scene?.RelatedUnit;
|
||||||
|
if (b == null || !b.isAlive())
|
||||||
|
{
|
||||||
|
// Nearest opposite-species unit.
|
||||||
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
||||||
|
{
|
||||||
|
if (actor == null || actor == a || !actor.isAlive() || actor.asset == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor.asset.id.Equals(sideB, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
b = actor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int wiredPair = 0;
|
||||||
|
if (TrySetAttackTarget(a, b))
|
||||||
|
{
|
||||||
|
wiredPair++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TrySetAttackTarget(b, a))
|
||||||
|
{
|
||||||
|
wiredPair++;
|
||||||
|
}
|
||||||
|
|
||||||
|
InterestDirector.HarnessMaintainCombatFocus();
|
||||||
|
bool okPair = wiredPair > 0;
|
||||||
|
if (okPair)
|
||||||
|
{
|
||||||
|
_cmdOk++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Emit(
|
||||||
|
cmd,
|
||||||
|
okPair,
|
||||||
|
detail: $"pair wired={wiredPair} a={SafeName(a)} b={SafeName(b)} tip='{CameraDirector.LastWatchLabel}' focus={FocusLabel()}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var campA = new List<Actor>(8);
|
||||||
|
var campB = new List<Actor>(8);
|
||||||
|
Vector2 pos = anchor.current_position;
|
||||||
|
const float r2 = 16f * 16f;
|
||||||
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
||||||
|
{
|
||||||
|
if (actor == null || !actor.isAlive() || actor.asset == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float dx = actor.current_position.x - pos.x;
|
||||||
|
float dy = actor.current_position.y - pos.y;
|
||||||
|
if (dx * dx + dy * dy > r2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string id = actor.asset.id ?? "";
|
||||||
|
if (id.Equals(sideA, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
campA.Add(actor);
|
||||||
|
}
|
||||||
|
else if (id.Equals(sideB, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
campB.Add(actor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campA.Count == 0 || campB.Count == 0)
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
Emit(cmd, ok: false, detail: $"empty camps a={campA.Count} b={campB.Count} near={SafeName(anchor)}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int wired = 0;
|
||||||
|
for (int i = 0; i < campA.Count; i++)
|
||||||
|
{
|
||||||
|
if (TrySetAttackTarget(campA[i], campB[i % campB.Count]))
|
||||||
|
{
|
||||||
|
wired++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < campB.Count; i++)
|
||||||
|
{
|
||||||
|
if (TrySetAttackTarget(campB[i], campA[i % campA.Count]))
|
||||||
|
{
|
||||||
|
wired++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
InterestDirector.HarnessMaintainCombatFocus();
|
||||||
|
bool ok = wired > 0;
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
_cmdOk++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Emit(
|
||||||
|
cmd,
|
||||||
|
ok,
|
||||||
|
detail: $"wired={wired} a={campA.Count}:{sideA} b={campB.Count}:{sideB} tip='{CameraDirector.LastWatchLabel}' focus={FocusLabel()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TrySetAttackTarget(Actor unit, Actor foe)
|
||||||
|
{
|
||||||
|
if (unit == null || foe == null || !unit.isAlive() || !foe.isAlive() || unit == foe)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mi = typeof(Actor).GetMethod(
|
||||||
|
"setAttackTarget",
|
||||||
|
System.Reflection.BindingFlags.Instance
|
||||||
|
| System.Reflection.BindingFlags.Public
|
||||||
|
| System.Reflection.BindingFlags.NonPublic);
|
||||||
|
if (mi != null)
|
||||||
|
{
|
||||||
|
mi.Invoke(unit, new object[] { foe });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var prop = typeof(Actor).GetProperty(
|
||||||
|
"attack_target",
|
||||||
|
System.Reflection.BindingFlags.Instance
|
||||||
|
| System.Reflection.BindingFlags.Public
|
||||||
|
| System.Reflection.BindingFlags.NonPublic);
|
||||||
|
prop?.SetValue(unit, foe, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return unit.has_attack_target;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Actor SpawnAssetNear(string assetId, Vector3 near, float jitter)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (AssetManager.actor_library?.get(assetId) == null || World.world == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldTile tile = World.world.GetTile(
|
||||||
|
Mathf.RoundToInt(near.x + jitter),
|
||||||
|
Mathf.RoundToInt(near.y));
|
||||||
|
if (tile == null)
|
||||||
|
{
|
||||||
|
tile = World.world.GetTile(
|
||||||
|
Mathf.RoundToInt(near.x),
|
||||||
|
Mathf.RoundToInt(near.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tile == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Actor spawned = World.world.units.spawnNewUnit(
|
||||||
|
assetId, tile, pSpawnSound: false, pMiracleSpawn: true);
|
||||||
|
if (spawned != null && spawned.isAlive())
|
||||||
|
{
|
||||||
|
_lastSpawned = spawned;
|
||||||
|
_lastSpawnedAssetId = assetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return spawned;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void DoSetSetting(HarnessCommand cmd)
|
private static void DoSetSetting(HarnessCommand cmd)
|
||||||
{
|
{
|
||||||
string key = cmd.expect;
|
string key = cmd.expect;
|
||||||
|
|
@ -3711,8 +4075,9 @@ public static class AgentHarness
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
pass = tip.StartsWith(name, StringComparison.OrdinalIgnoreCase)
|
pass = tip.StartsWith(name, StringComparison.OrdinalIgnoreCase)
|
||||||
|| tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0;
|
|| tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0
|
||||||
// Also accept Battle-style where name leads.
|
|| IsEnsembleCombatTip(tip);
|
||||||
|
// Ensemble tips are tier-led (Battle - …); focus still owns the dossier.
|
||||||
detail = $"tip='{tip}' focus='{name}' pass={pass}";
|
detail = $"tip='{tip}' focus='{name}' pass={pass}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3727,6 +4092,43 @@ public static class AgentHarness
|
||||||
detail = $"tip='{tip}' not_contains='{needle}'";
|
detail = $"tip='{tip}' not_contains='{needle}'";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "tip_matches_any":
|
||||||
|
{
|
||||||
|
// value = pipe-separated needles; tip must contain at least one.
|
||||||
|
string raw = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
||||||
|
string tip = CameraDirector.LastWatchLabel ?? "";
|
||||||
|
pass = false;
|
||||||
|
if (!string.IsNullOrEmpty(raw))
|
||||||
|
{
|
||||||
|
string[] parts = raw.Split('|');
|
||||||
|
for (int i = 0; i < parts.Length; i++)
|
||||||
|
{
|
||||||
|
string needle = (parts[i] ?? "").Trim();
|
||||||
|
if (needle.Length > 0
|
||||||
|
&& tip.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
{
|
||||||
|
pass = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detail = $"tip='{tip}' any='{raw}' pass={pass}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "participant_count_min":
|
||||||
|
{
|
||||||
|
int want = 1;
|
||||||
|
if (!int.TryParse(string.IsNullOrEmpty(cmd.value) ? "1" : cmd.value, out want))
|
||||||
|
{
|
||||||
|
want = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int have = InterestDirector.CurrentCandidate?.ParticipantCount ?? 0;
|
||||||
|
pass = have >= want;
|
||||||
|
detail = $"participants={have} want>={want}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "unit_asset":
|
case "unit_asset":
|
||||||
{
|
{
|
||||||
string wantAsset = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
|
string wantAsset = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
|
||||||
|
|
@ -3737,7 +4139,24 @@ public static class AgentHarness
|
||||||
|
|
||||||
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||||
string unitAsset = unit?.asset != null ? unit.asset.id : "";
|
string unitAsset = unit?.asset != null ? unit.asset.id : "";
|
||||||
pass = !string.IsNullOrEmpty(wantAsset) && wantAsset == unitAsset;
|
if (!string.IsNullOrEmpty(wantAsset) && wantAsset.IndexOf('|') >= 0)
|
||||||
|
{
|
||||||
|
string[] opts = wantAsset.Split('|');
|
||||||
|
pass = false;
|
||||||
|
for (int i = 0; i < opts.Length; i++)
|
||||||
|
{
|
||||||
|
if (opts[i].Trim().Equals(unitAsset, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
pass = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pass = !string.IsNullOrEmpty(wantAsset) && wantAsset == unitAsset;
|
||||||
|
}
|
||||||
|
|
||||||
detail = $"unitAsset={unitAsset} want={wantAsset}";
|
detail = $"unitAsset={unitAsset} want={wantAsset}";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -6688,6 +7107,20 @@ public static class AgentHarness
|
||||||
return SafeName(MoveCamera._focus_unit);
|
return SafeName(MoveCamera._focus_unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsEnsembleCombatTip(string tip)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(tip))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| tip.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| tip.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| tip.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| tip.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
private static long ResolveActivitySubjectId()
|
private static long ResolveActivitySubjectId()
|
||||||
{
|
{
|
||||||
// Prefer the camera focus (harness apply/assert target) over watch caption,
|
// Prefer the camera focus (harness apply/assert target) over watch caption,
|
||||||
|
|
|
||||||
|
|
@ -39,48 +39,67 @@ public static class CameraDirector
|
||||||
LastWatchLabel = interest.Label ?? "";
|
LastWatchLabel = interest.Label ?? "";
|
||||||
LastWatchAssetId = interest.AssetId ?? "";
|
LastWatchAssetId = interest.AssetId ?? "";
|
||||||
LogService.LogInfo($"[IdleSpectator] {tip}");
|
LogService.LogInfo($"[IdleSpectator] {tip}");
|
||||||
WatchCaption.SetFromInterest(interest);
|
|
||||||
|
|
||||||
Actor follow = interest.HasFollowUnit
|
|
||||||
? interest.FollowUnit
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Species discovery: only attach a species-matched unit, never a random stranger.
|
|
||||||
if (follow == null && !string.IsNullOrEmpty(interest.AssetId) && interest.AssetId != "live_battle"
|
|
||||||
&& interest.AssetId != "scored_unit" && interest.Label != null && interest.Label.StartsWith("New species:"))
|
|
||||||
{
|
|
||||||
follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
|
|
||||||
}
|
|
||||||
// Location-only civic: pan only. Do not invent a stranger FollowUnit.
|
|
||||||
|
|
||||||
|
Actor follow = ResolveFollowUnit(interest);
|
||||||
if (follow != null && follow.isAlive())
|
if (follow != null && follow.isAlive())
|
||||||
{
|
{
|
||||||
// If this is a species event, refuse mismatched units.
|
// Focus first, then nametag - same order as FocusUnit, so tip/focus/dossier
|
||||||
if (interest.Label != null && interest.Label.StartsWith("New species:")
|
// never diverge across a handoff frame.
|
||||||
&& !string.IsNullOrEmpty(interest.AssetId)
|
|
||||||
&& follow.asset != null && follow.asset.id != interest.AssetId)
|
|
||||||
{
|
|
||||||
Actor matched = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
|
|
||||||
if (matched == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
follow = matched;
|
|
||||||
}
|
|
||||||
|
|
||||||
RetargetFollow(follow);
|
RetargetFollow(follow);
|
||||||
|
WatchCaption.SetFromActor(follow);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No owned unit: pan only if we are not already following someone.
|
// No accepted living follow: tip already logged. Do not bind a rejected FollowUnit
|
||||||
// Never clear an existing focus unit here - that flashes the power bar.
|
// (dead / species mismatch) as the dossier subject.
|
||||||
|
if (!interest.HasFollowUnit)
|
||||||
|
{
|
||||||
|
WatchCaption.SetFromInterest(interest);
|
||||||
|
}
|
||||||
|
|
||||||
if (!MoveCamera.hasFocusUnit())
|
if (!MoveCamera.hasFocusUnit())
|
||||||
{
|
{
|
||||||
PanCamera(interest.Position);
|
PanCamera(interest.Position);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Actor ResolveFollowUnit(InterestEvent interest)
|
||||||
|
{
|
||||||
|
if (interest == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Actor follow = interest.HasFollowUnit ? interest.FollowUnit : null;
|
||||||
|
|
||||||
|
// Species discovery: only attach a species-matched unit, never a random stranger.
|
||||||
|
bool speciesTip = interest.Label != null
|
||||||
|
&& interest.Label.StartsWith("New species:");
|
||||||
|
if (follow == null
|
||||||
|
&& speciesTip
|
||||||
|
&& !string.IsNullOrEmpty(interest.AssetId)
|
||||||
|
&& interest.AssetId != "live_battle"
|
||||||
|
&& interest.AssetId != "scored_unit")
|
||||||
|
{
|
||||||
|
follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (follow == null || !follow.isAlive())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (speciesTip
|
||||||
|
&& !string.IsNullOrEmpty(interest.AssetId)
|
||||||
|
&& follow.asset != null
|
||||||
|
&& follow.asset.id != interest.AssetId)
|
||||||
|
{
|
||||||
|
return WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return follow;
|
||||||
|
}
|
||||||
|
|
||||||
public static string FormatWatchTip(InterestEvent interest)
|
public static string FormatWatchTip(InterestEvent interest)
|
||||||
{
|
{
|
||||||
if (interest == null || string.IsNullOrEmpty(interest.Label))
|
if (interest == null || string.IsNullOrEmpty(interest.Label))
|
||||||
|
|
|
||||||
|
|
@ -30,19 +30,189 @@ public static class EventReason
|
||||||
|
|
||||||
public static string Battle(Actor focus, int fighters)
|
public static string Battle(Actor focus, int fighters)
|
||||||
{
|
{
|
||||||
string name = Name(focus);
|
// Legacy wrapper - prefer Combat(LiveEnsemble) for sided framing.
|
||||||
if (string.IsNullOrEmpty(name))
|
var e = new LiveEnsemble
|
||||||
{
|
{
|
||||||
name = "Someone";
|
Kind = EnsembleKind.Combat,
|
||||||
|
Scale = LiveEnsemble.ScaleForCount(fighters),
|
||||||
|
Frame = fighters <= 2 ? EnsembleFrame.NamedPair : EnsembleFrame.CountOnly,
|
||||||
|
ParticipantCount = Math.Max(1, fighters),
|
||||||
|
Focus = focus
|
||||||
|
};
|
||||||
|
return Combat(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Context-aware combat orange reason from a <see cref="LiveEnsemble"/> snapshot.
|
||||||
|
/// Multi form: <c>Battle - Humans (4) vs Wolves (0)</c> - counts may be 0 while sides stay sticky.
|
||||||
|
/// Never uses raw unit names as mass-side labels. Never prints thin <c>melee</c> fallbacks.
|
||||||
|
/// </summary>
|
||||||
|
public static string Combat(LiveEnsemble ensemble)
|
||||||
|
{
|
||||||
|
if (ensemble == null || !ensemble.HasFocus)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
int n = Math.Max(1, fighters);
|
// True 1v1 only when we do not already have sticky opposing camps.
|
||||||
if (n <= 2)
|
if (ensemble.Scale == EnsembleScale.Pair && !LiveEnsemble.HasOpposingCollectiveSides(ensemble))
|
||||||
{
|
{
|
||||||
return name + " is in a duel";
|
string a = Name(ensemble.Focus);
|
||||||
|
string b = Name(ensemble.Related);
|
||||||
|
if (string.IsNullOrEmpty(a))
|
||||||
|
{
|
||||||
|
a = "Someone";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(b))
|
||||||
|
{
|
||||||
|
// Orphan duel with no foe - blank so director can keep sticky / clear.
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Duel - " + a + " vs " + b;
|
||||||
}
|
}
|
||||||
|
|
||||||
return name + " is in a fight of " + n.ToString(CultureInfo.InvariantCulture);
|
string tier = ScaleTierLabel(
|
||||||
|
ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale);
|
||||||
|
|
||||||
|
if (LiveEnsemble.HasOpposingCollectiveSides(ensemble))
|
||||||
|
{
|
||||||
|
string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame);
|
||||||
|
string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame);
|
||||||
|
if (!string.IsNullOrEmpty(sideA) && !string.IsNullOrEmpty(sideB))
|
||||||
|
{
|
||||||
|
return tier + " - " + sideA + " vs " + sideB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-species pack with no opposing sticky camps.
|
||||||
|
if (ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key))
|
||||||
|
{
|
||||||
|
string pack = FormatCombatSide(ensemble.SideA, EnsembleFrame.SpeciesVsSpecies);
|
||||||
|
if (!string.IsNullOrEmpty(pack))
|
||||||
|
{
|
||||||
|
return tier + " - " + pack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never emit thin "X is fighting" from the multi path - leave blank for director reuse.
|
||||||
|
if (ensemble.Scale > EnsembleScale.Pair || LiveEnsemble.HasOpposingCollectiveSides(ensemble))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Fight(ensemble.Focus, ensemble.Related);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SidesAreSameCollective(string sideA, string sideB)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(sideA) || string.IsNullOrEmpty(sideB))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip counts: "Wolves (6)" → "Wolves"
|
||||||
|
string a = StripSideCount(sideA);
|
||||||
|
string b = StripSideCount(sideB);
|
||||||
|
return a.Equals(b, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripSideCount(string side)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(side))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
int paren = side.LastIndexOf(" (", StringComparison.Ordinal);
|
||||||
|
return paren > 0 ? side.Substring(0, paren).Trim() : side.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ScaleTierLabel(EnsembleScale scale)
|
||||||
|
{
|
||||||
|
switch (scale)
|
||||||
|
{
|
||||||
|
case EnsembleScale.Pair:
|
||||||
|
return "Duel";
|
||||||
|
case EnsembleScale.Skirmish:
|
||||||
|
return "Skirmish";
|
||||||
|
case EnsembleScale.Mass:
|
||||||
|
return "Mass";
|
||||||
|
default:
|
||||||
|
return "Battle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatCombatSide(EnsembleSide side, EnsembleFrame frame)
|
||||||
|
{
|
||||||
|
if (side == null)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mass/skirmish never print unit proper names as a side.
|
||||||
|
if (frame == EnsembleFrame.NamedLeaders || frame == EnsembleFrame.NamedPair)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
string label = side.Display ?? "";
|
||||||
|
if (string.IsNullOrEmpty(label))
|
||||||
|
{
|
||||||
|
label = side.Key ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame == EnsembleFrame.KingdomVsKingdom)
|
||||||
|
{
|
||||||
|
label = LiveEnsemble.KingdomDisplay(string.IsNullOrEmpty(side.Key) ? label : side.Key);
|
||||||
|
}
|
||||||
|
else if (frame == EnsembleFrame.SpeciesVsSpecies)
|
||||||
|
{
|
||||||
|
label = LiveEnsemble.SpeciesDisplay(string.IsNullOrEmpty(side.Key) ? label : side.Key);
|
||||||
|
if (!string.IsNullOrEmpty(side.KingdomDisplay))
|
||||||
|
{
|
||||||
|
string kingdom = LiveEnsemble.KingdomDisplay(side.KingdomDisplay);
|
||||||
|
if (!string.IsNullOrEmpty(kingdom)
|
||||||
|
&& !kingdom.Equals(label, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& kingdom.IndexOf(label, StringComparison.OrdinalIgnoreCase) < 0)
|
||||||
|
{
|
||||||
|
label = "[" + kingdom + "] " + label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// CountOnly / unknown - refuse unit-looking labels.
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(label))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : "");
|
||||||
|
// Allow 0 so sticky camps can show a side briefly empty, then climb again on rejoin.
|
||||||
|
int n = Math.Max(0, side.Count);
|
||||||
|
return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dispatch by ensemble kind (combat first; other kinds plug in later).</summary>
|
||||||
|
public static string Ensemble(LiveEnsemble ensemble)
|
||||||
|
{
|
||||||
|
if (ensemble == null)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (ensemble.Kind)
|
||||||
|
{
|
||||||
|
case EnsembleKind.Combat:
|
||||||
|
return Combat(ensemble);
|
||||||
|
default:
|
||||||
|
return Combat(ensemble);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string SeekingLover(Actor a)
|
public static string SeekingLover(Actor a)
|
||||||
|
|
|
||||||
|
|
@ -353,8 +353,19 @@ public static class InterestFeeds
|
||||||
|
|
||||||
string verb = taskOrBeat ?? "fighting";
|
string verb = taskOrBeat ?? "fighting";
|
||||||
WorldActivityScanner.PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
|
WorldActivityScanner.PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
|
||||||
|
Vector3 pos = follow != null ? follow.current_position : actor.current_position;
|
||||||
|
LiveEnsemble.TryBuildCombat(pos, 10f, out LiveEnsemble ensemble);
|
||||||
|
if (ensemble != null && ensemble.HasFocus)
|
||||||
|
{
|
||||||
|
follow = ensemble.Focus;
|
||||||
|
related = ensemble.Related ?? related;
|
||||||
|
}
|
||||||
|
|
||||||
long followId = SafeId(follow ?? actor);
|
long followId = SafeId(follow ?? actor);
|
||||||
string key = EventCatalog.Combat.Key(followId);
|
string key = EventCatalog.Combat.Key(followId);
|
||||||
|
string label = ensemble != null
|
||||||
|
? EventReason.Combat(ensemble)
|
||||||
|
: EventReason.Fight(follow ?? actor, related);
|
||||||
var candidate = new InterestCandidate
|
var candidate = new InterestCandidate
|
||||||
{
|
{
|
||||||
Key = key,
|
Key = key,
|
||||||
|
|
@ -368,12 +379,14 @@ public static class InterestFeeds
|
||||||
RelatedUnit = related,
|
RelatedUnit = related,
|
||||||
SubjectId = followId,
|
SubjectId = followId,
|
||||||
RelatedId = related != null ? SafeId(related) : 0,
|
RelatedId = related != null ? SafeId(related) : 0,
|
||||||
Label = EventReason.Fight(follow ?? actor, related),
|
Label = label,
|
||||||
AssetId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
|
AssetId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
|
||||||
SpeciesId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
|
SpeciesId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
|
||||||
Verb = verb,
|
Verb = verb,
|
||||||
CityKey = (follow ?? actor).city != null ? ((follow ?? actor).city.name ?? "") : "",
|
CityKey = (follow ?? actor).city != null ? ((follow ?? actor).city.name ?? "") : "",
|
||||||
KingdomKey = (follow ?? actor).kingdom != null ? ((follow ?? actor).kingdom.name ?? "") : "",
|
KingdomKey = (follow ?? actor).kingdom != null ? ((follow ?? actor).kingdom.name ?? "") : "",
|
||||||
|
ParticipantCount = ensemble != null ? Mathf.Max(1, ensemble.ParticipantCount) : (related != null ? 2 : 1),
|
||||||
|
NotableParticipantCount = ensemble != null ? ensemble.NotableParticipantCount : 0,
|
||||||
CreatedAt = Time.unscaledTime,
|
CreatedAt = Time.unscaledTime,
|
||||||
LastSeenAt = Time.unscaledTime,
|
LastSeenAt = Time.unscaledTime,
|
||||||
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl,
|
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl,
|
||||||
|
|
@ -381,15 +394,34 @@ public static class InterestFeeds
|
||||||
MaxWatch = EventCatalog.Combat.MaxWatch,
|
MaxWatch = EventCatalog.Combat.MaxWatch,
|
||||||
Completion = InterestCompletionKind.CombatActive
|
Completion = InterestCompletionKind.CombatActive
|
||||||
};
|
};
|
||||||
candidate.ParticipantIds.Add(followId);
|
if (ensemble != null)
|
||||||
if (related != null)
|
|
||||||
{
|
{
|
||||||
long rid = SafeId(related);
|
WorldActivityScanner.StampParticipantIds(candidate, ensemble);
|
||||||
if (rid != 0)
|
if (ensemble.Scale >= EnsembleScale.Skirmish)
|
||||||
{
|
{
|
||||||
candidate.ParticipantIds.Add(rid);
|
candidate.AssetId = "live_battle";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ensemble.Frame == EnsembleFrame.KingdomVsKingdom
|
||||||
|
&& ensemble.SideA != null
|
||||||
|
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
|
||||||
|
{
|
||||||
|
candidate.KingdomKey = ensemble.SideA.Key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
candidate.ParticipantIds.Add(followId);
|
||||||
|
if (related != null)
|
||||||
|
{
|
||||||
|
long rid = SafeId(related);
|
||||||
|
if (rid != 0)
|
||||||
|
{
|
||||||
|
candidate.ParticipantIds.Add(rid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
InterestScoring.ScoreCheap(candidate);
|
InterestScoring.ScoreCheap(candidate);
|
||||||
EventFeedUtil.RegisterCandidate(candidate);
|
EventFeedUtil.RegisterCandidate(candidate);
|
||||||
}
|
}
|
||||||
|
|
@ -1031,7 +1063,15 @@ public static class InterestFeeds
|
||||||
InterestEvent battle = WorldActivityScanner.FindHottestBattle();
|
InterestEvent battle = WorldActivityScanner.FindHottestBattle();
|
||||||
if (battle != null)
|
if (battle != null)
|
||||||
{
|
{
|
||||||
RegisterDirect(battle);
|
if (string.Equals(battle.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& InterestDirector.TryAbsorbCombatBattle(battle))
|
||||||
|
{
|
||||||
|
// Active combat scene refreshed in place.
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RegisterDirect(battle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ongoing wars from WarManager (complements WorldLog war start/end).
|
// Ongoing wars from WarManager (complements WorldLog war start/end).
|
||||||
|
|
|
||||||
1068
IdleSpectator/Events/LiveEnsemble.cs
Normal file
1068
IdleSpectator/Events/LiveEnsemble.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -53,6 +53,9 @@ internal static class HarnessScenarios
|
||||||
case "combat_focus":
|
case "combat_focus":
|
||||||
case "combat_tip_align":
|
case "combat_tip_align":
|
||||||
return CombatFocusAlign();
|
return CombatFocusAlign();
|
||||||
|
case "combat_stability_live":
|
||||||
|
case "combat_live_proof":
|
||||||
|
return CombatStabilityLive();
|
||||||
case "director_action_priority":
|
case "director_action_priority":
|
||||||
case "action_priority":
|
case "action_priority":
|
||||||
return DirectorActionPriority();
|
return DirectorActionPriority();
|
||||||
|
|
@ -1057,26 +1060,109 @@ internal static class HarnessScenarios
|
||||||
|
|
||||||
// Combat scene: human fighting wolf. Clear follow → related (wolf) must own tip+focus.
|
// 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("cf20", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_fight"),
|
||||||
Step("cf21", "assert", expect: "tip_contains", value: "fighting"),
|
Step("cf21", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
||||||
Step("cf22", "interest_clear_follow", asset: "wolf"),
|
Step("cf22", "interest_clear_follow", asset: "wolf"),
|
||||||
Step("cf23", "combat_maintain_focus"),
|
Step("cf23", "combat_maintain_focus"),
|
||||||
Step("cf24", "assert", expect: "has_focus", value: "true"),
|
Step("cf24", "assert", expect: "has_focus", value: "true"),
|
||||||
Step("cf25", "assert", expect: "tip_matches_focus"),
|
Step("cf25", "assert", expect: "tip_matches_focus"),
|
||||||
|
Step("cf25b", "assert", expect: "dossier_matches_focus"),
|
||||||
Step("cf26", "assert", expect: "unit_asset", asset: "wolf"),
|
Step("cf26", "assert", expect: "unit_asset", asset: "wolf"),
|
||||||
|
|
||||||
// Cold combat must not keep "is fighting".
|
// 1v1 → multi: sided species framing + live sticky counts.
|
||||||
|
Step("cf40", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_ensemble"),
|
||||||
|
Step("cf41", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
||||||
|
Step("cf42", "combat_ensemble_escalate", value: "6"),
|
||||||
|
Step("cf42b", "combat_wire_attack_sides", asset: "human", value: "wolf"),
|
||||||
|
Step("cf43", "assert", expect: "participant_count_min", value: "6"),
|
||||||
|
Step("cf44", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -| vs "),
|
||||||
|
Step("cf45", "assert", expect: "tip_matches_focus"),
|
||||||
|
Step("cf46", "assert", expect: "dossier_matches_focus"),
|
||||||
|
Step("cf47", "assert", expect: "has_focus", value: "true"),
|
||||||
|
|
||||||
|
// Cold combat must not keep fight / ensemble tips.
|
||||||
Step("cf30", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_cold"),
|
Step("cf30", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_cold"),
|
||||||
Step("cf31", "assert", expect: "tip_contains", value: "fighting"),
|
Step("cf31", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
||||||
Step("cf32", "interest_release_force"),
|
Step("cf32", "interest_release_force"),
|
||||||
Step("cf33", "interest_mark_inactive", value: "1"),
|
Step("cf33", "interest_mark_inactive", value: "1"),
|
||||||
Step("cf34", "combat_maintain_focus"),
|
Step("cf34", "combat_maintain_focus"),
|
||||||
Step("cf35", "assert", expect: "tip_not_contains", value: "fighting"),
|
Step("cf35", "assert", expect: "tip_not_contains", value: " vs "),
|
||||||
|
Step("cf35b", "assert", expect: "tip_not_contains", value: "Duel"),
|
||||||
|
Step("cf35c", "assert", expect: "tip_not_contains", value: "fighting"),
|
||||||
|
|
||||||
Step("cf90", "fast_timing", value: "false"),
|
Step("cf90", "fast_timing", value: "false"),
|
||||||
Step("cf99", "snapshot"),
|
Step("cf99", "snapshot"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live pack-fight proof: scale hold vs Duel flap, collective species tip, sleeper cannot steal focus.
|
||||||
|
/// </summary>
|
||||||
|
private static List<HarnessCommand> CombatStabilityLive()
|
||||||
|
{
|
||||||
|
return new List<HarnessCommand>
|
||||||
|
{
|
||||||
|
Step("csl0", "dismiss_windows"),
|
||||||
|
Step("csl1", "wait_world"),
|
||||||
|
Step("csl2", "set_setting", expect: "enabled", value: "true"),
|
||||||
|
Step("csl3", "fast_timing", value: "true"),
|
||||||
|
Step("csl4", "spawn", asset: "human", count: 1),
|
||||||
|
Step("csl5", "spawn", asset: "wolf", count: 1),
|
||||||
|
Step("csl6", "spectator", value: "off"),
|
||||||
|
Step("csl7", "spectator", value: "on"),
|
||||||
|
Step("csl8", "pick_unit", asset: "human"),
|
||||||
|
Step("csl9", "focus", asset: "human"),
|
||||||
|
Step("csl10", "interest_end_session"),
|
||||||
|
|
||||||
|
// Peak at 8 (Mass band). Sticky sides lock now; live counts start at 0 until wired.
|
||||||
|
Step("csl20", "interest_combat_session", asset: "human", value: "wolf", expect: "csl_pack"),
|
||||||
|
Step("csl21", "combat_ensemble_escalate", value: "8"),
|
||||||
|
Step("csl22", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
|
||||||
|
Step("csl22b", "assert", expect: "tip_matches_any", value: "Humans (|Wolves ("),
|
||||||
|
Step("csl24", "combat_wire_attack_sides", asset: "human", value: "wolf", expect: "pair"),
|
||||||
|
|
||||||
|
// Live rebuild: sticky Humans vs Wolves, live counts (may be 1/1 after pair wire).
|
||||||
|
Step("csl30", "combat_maintain_focus"),
|
||||||
|
Step("csl31", "wait", value: "0.4"),
|
||||||
|
Step("csl32", "combat_maintain_focus"),
|
||||||
|
Step("csl33", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
|
||||||
|
Step("csl34", "assert", expect: "tip_not_contains", value: "Duel"),
|
||||||
|
Step("csl35", "assert", expect: "tip_not_contains", value: "melee"),
|
||||||
|
Step("csl36", "assert", expect: "tip_matches_any", value: "Humans (|Wolves ("),
|
||||||
|
Step("csl37", "assert", expect: "tip_matches_any", value: " vs "),
|
||||||
|
|
||||||
|
// Wire both camps → counts climb back up on the same sticky frame.
|
||||||
|
Step("csl40", "combat_wire_attack_sides", asset: "human", value: "wolf"),
|
||||||
|
Step("csl41", "wait", value: "0.5"),
|
||||||
|
Step("csl42", "combat_maintain_focus"),
|
||||||
|
Step("csl42b", "assert", expect: "participant_count_min", value: "6"),
|
||||||
|
Step("csl43", "assert", expect: "tip_matches_any", value: "Humans (|Wolves ("),
|
||||||
|
Step("csl44", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
|
||||||
|
Step("csl45", "assert", expect: "tip_not_contains", value: "Duel"),
|
||||||
|
Step("csl45b", "assert", expect: "tip_not_contains", value: "melee"),
|
||||||
|
Step("csl46", "assert", expect: "unit_asset", value: "human|wolf"),
|
||||||
|
Step("csl47", "assert", expect: "dossier_matches_focus"),
|
||||||
|
|
||||||
|
// Sleeper bystander must not stick the camera.
|
||||||
|
Step("csl50", "spawn", asset: "bear", count: 1),
|
||||||
|
Step("csl51", "focus", asset: "bear"),
|
||||||
|
Step("csl52", "status_apply", value: "sleeping", label: "8"),
|
||||||
|
Step("csl53", "wait", value: "0.35"),
|
||||||
|
Step("csl54", "combat_maintain_focus"),
|
||||||
|
Step("csl55", "wait", value: "0.35"),
|
||||||
|
Step("csl56", "combat_maintain_focus"),
|
||||||
|
Step("csl57", "assert", expect: "unit_asset", value: "human|wolf"),
|
||||||
|
Step("csl58", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -|fighting| vs "),
|
||||||
|
Step("csl59", "assert", expect: "tip_not_contains", value: "Duel -"),
|
||||||
|
Step("csl59b", "assert", expect: "tip_not_contains", value: "melee"),
|
||||||
|
Step("csl60", "assert", expect: "dossier_matches_focus"),
|
||||||
|
Step("csl61", "assert", expect: "has_focus", value: "true"),
|
||||||
|
|
||||||
|
Step("csl90", "fast_timing", value: "false"),
|
||||||
|
Step("csl98", "screenshot", value: "hud-combat-stability-live.png"),
|
||||||
|
Step("csl99", "snapshot"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Status-overlap happiness must not FixedDwell-claim a camera-worthy status the unit lacks.
|
/// Status-overlap happiness must not FixedDwell-claim a camera-worthy status the unit lacks.
|
||||||
/// Hold while status is live; offset end-pings still register briefly without it.
|
/// Hold while status is live; offset end-pings still register briefly without it.
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,21 @@ public sealed class InterestCandidate
|
||||||
public float TotalScore;
|
public float TotalScore;
|
||||||
/// <summary>Live fighters / participants in a cluster (battles, multi-unit events).</summary>
|
/// <summary>Live fighters / participants in a cluster (battles, multi-unit events).</summary>
|
||||||
public int ParticipantCount;
|
public int ParticipantCount;
|
||||||
|
/// <summary>Peak fighters seen this combat scene (scale demotion hysteresis).</summary>
|
||||||
|
public int CombatPeakParticipants;
|
||||||
|
/// <summary>Sticky opposing camps for the orange tip (species/kingdom keys).</summary>
|
||||||
|
public EnsembleFrame CombatSideFrame = EnsembleFrame.CountOnly;
|
||||||
|
public string CombatSideAKey = "";
|
||||||
|
public string CombatSideADisplay = "";
|
||||||
|
public string CombatSideAKingdom = "";
|
||||||
|
public int CombatSideACount;
|
||||||
|
public string CombatSideBKey = "";
|
||||||
|
public string CombatSideBDisplay = "";
|
||||||
|
public string CombatSideBKingdom = "";
|
||||||
|
public int CombatSideBCount;
|
||||||
|
/// <summary>Tracked camp members for sticky scoreboard (kept until combat grace ends).</summary>
|
||||||
|
public readonly List<long> CombatSideAIds = new List<long>(8);
|
||||||
|
public readonly List<long> CombatSideBIds = new List<long>(8);
|
||||||
/// <summary>Notable (king/favorite/leader/high renown) participants in the cluster.</summary>
|
/// <summary>Notable (king/favorite/leader/high renown) participants in the cluster.</summary>
|
||||||
public int NotableParticipantCount;
|
public int NotableParticipantCount;
|
||||||
public Vector3 Position;
|
public Vector3 Position;
|
||||||
|
|
@ -78,6 +93,28 @@ public sealed class InterestCandidate
|
||||||
public bool HasValidPosition =>
|
public bool HasValidPosition =>
|
||||||
HasFollowUnit || (Position != Vector3.zero && !float.IsNaN(Position.x));
|
HasFollowUnit || (Position != Vector3.zero && !float.IsNaN(Position.x));
|
||||||
|
|
||||||
|
public bool HasStickyCombatSides =>
|
||||||
|
!string.IsNullOrEmpty(CombatSideAKey)
|
||||||
|
&& !string.IsNullOrEmpty(CombatSideBKey)
|
||||||
|
&& (CombatSideFrame == EnsembleFrame.SpeciesVsSpecies
|
||||||
|
|| CombatSideFrame == EnsembleFrame.KingdomVsKingdom);
|
||||||
|
|
||||||
|
public void ClearCombatSticky()
|
||||||
|
{
|
||||||
|
CombatPeakParticipants = 0;
|
||||||
|
CombatSideFrame = EnsembleFrame.CountOnly;
|
||||||
|
CombatSideAKey = "";
|
||||||
|
CombatSideADisplay = "";
|
||||||
|
CombatSideAKingdom = "";
|
||||||
|
CombatSideACount = 0;
|
||||||
|
CombatSideBKey = "";
|
||||||
|
CombatSideBDisplay = "";
|
||||||
|
CombatSideBKingdom = "";
|
||||||
|
CombatSideBCount = 0;
|
||||||
|
CombatSideAIds.Clear();
|
||||||
|
CombatSideBIds.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
public InterestEvent ToInterestEvent()
|
public InterestEvent ToInterestEvent()
|
||||||
{
|
{
|
||||||
return new InterestEvent
|
return new InterestEvent
|
||||||
|
|
@ -109,6 +146,16 @@ public sealed class InterestCandidate
|
||||||
VisualConfidence = VisualConfidence,
|
VisualConfidence = VisualConfidence,
|
||||||
TotalScore = TotalScore,
|
TotalScore = TotalScore,
|
||||||
ParticipantCount = ParticipantCount,
|
ParticipantCount = ParticipantCount,
|
||||||
|
CombatPeakParticipants = CombatPeakParticipants,
|
||||||
|
CombatSideFrame = CombatSideFrame,
|
||||||
|
CombatSideAKey = CombatSideAKey,
|
||||||
|
CombatSideADisplay = CombatSideADisplay,
|
||||||
|
CombatSideAKingdom = CombatSideAKingdom,
|
||||||
|
CombatSideACount = CombatSideACount,
|
||||||
|
CombatSideBKey = CombatSideBKey,
|
||||||
|
CombatSideBDisplay = CombatSideBDisplay,
|
||||||
|
CombatSideBKingdom = CombatSideBKingdom,
|
||||||
|
CombatSideBCount = CombatSideBCount,
|
||||||
NotableParticipantCount = NotableParticipantCount,
|
NotableParticipantCount = NotableParticipantCount,
|
||||||
Position = Position,
|
Position = Position,
|
||||||
FollowUnit = FollowUnit,
|
FollowUnit = FollowUnit,
|
||||||
|
|
@ -141,6 +188,16 @@ public sealed class InterestCandidate
|
||||||
copy.ParticipantIds.Add(ParticipantIds[i]);
|
copy.ParticipantIds.Add(ParticipantIds[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < CombatSideAIds.Count; i++)
|
||||||
|
{
|
||||||
|
copy.CombatSideAIds.Add(CombatSideAIds[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < CombatSideBIds.Count; i++)
|
||||||
|
{
|
||||||
|
copy.CombatSideBIds.Add(CombatSideBIds[i]);
|
||||||
|
}
|
||||||
|
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,12 @@ public static class InterestDirector
|
||||||
private static float _enabledAt = -999f;
|
private static float _enabledAt = -999f;
|
||||||
private static float _inactiveSince = -999f;
|
private static float _inactiveSince = -999f;
|
||||||
private static float _lastCombatFocusAt = -999f;
|
private static float _lastCombatFocusAt = -999f;
|
||||||
private const float CombatFocusThrottleSeconds = 0.4f;
|
private const float CombatFocusThrottleSeconds = 1.25f;
|
||||||
|
/// <summary>Require this much extra focus weight before stealing the camera mid-fight.</summary>
|
||||||
|
private const float CombatFocusSwitchMargin = 12f;
|
||||||
|
/// <summary>Hold elevated combat scale this long after fighter count dips.</summary>
|
||||||
|
private const float CombatScaleHoldSeconds = 2.5f;
|
||||||
|
private static float _combatScaleDropSince = -999f;
|
||||||
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(96);
|
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(96);
|
||||||
|
|
||||||
public static string CurrentTierName => CurrentScoreLabel;
|
public static string CurrentTierName => CurrentScoreLabel;
|
||||||
|
|
@ -199,6 +204,11 @@ public static class InterestDirector
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (candidate.Completion == InterestCompletionKind.CombatActive)
|
||||||
|
{
|
||||||
|
candidate.ClearCombatSticky();
|
||||||
|
}
|
||||||
|
|
||||||
EventFeedUtil.RegisterCandidate(candidate);
|
EventFeedUtil.RegisterCandidate(candidate);
|
||||||
SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false);
|
SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -420,10 +430,17 @@ public static class InterestDirector
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No living subject left: do not sit in quiet_grace with an empty camera.
|
// No living subject left: prefer sticky combat roster handoff before ending.
|
||||||
if (!_current.HasFollowUnit
|
if (!_current.HasFollowUnit
|
||||||
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
|
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
|
||||||
{
|
{
|
||||||
|
if (_current.Completion == InterestCompletionKind.CombatActive
|
||||||
|
&& TryPromoteStickyFollow(_current))
|
||||||
|
{
|
||||||
|
MaintainCombatFocus(now, force: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
EndCurrent(now, reason: "follow_lost");
|
EndCurrent(now, reason: "follow_lost");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -475,6 +492,12 @@ public static class InterestDirector
|
||||||
if (!_current.HasFollowUnit
|
if (!_current.HasFollowUnit
|
||||||
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
|
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
|
||||||
{
|
{
|
||||||
|
if (TryPromoteStickyFollow(_current))
|
||||||
|
{
|
||||||
|
MaintainCombatFocus(Time.unscaledTime, force: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
EndCurrent(Time.unscaledTime, reason: "follow_lost");
|
EndCurrent(Time.unscaledTime, reason: "follow_lost");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -506,8 +529,9 @@ public static class InterestDirector
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Combat scenes: keep camera on the highest-scored living participant and rewrite the tip
|
/// Keep camera on the highest-scored combat participant and rewrite tip/counts
|
||||||
/// so the subject always matches focus. Clears fight Labels once combat goes cold.
|
/// from a live <see cref="LiveEnsemble"/> so subject + scale always match focus.
|
||||||
|
/// Clears fight Labels once combat goes cold.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void MaintainCombatFocus(float now, bool force)
|
private static void MaintainCombatFocus(float now, bool force)
|
||||||
{
|
{
|
||||||
|
|
@ -529,21 +553,282 @@ public static class InterestDirector
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastCombatFocusAt = now;
|
_lastCombatFocusAt = now;
|
||||||
if (!WorldActivityScanner.TryPickBestCombatFocus(
|
ApplyCombatEnsembleToCurrent(forceWatch: false);
|
||||||
_current, out Actor best, out Actor foe, out int fighters))
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refresh active combat scene from the world ensemble. Returns false if no focus.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ApplyCombatEnsembleToCurrent(bool forceWatch)
|
||||||
|
{
|
||||||
|
if (_current == null || _current.Completion != InterestCompletionKind.CombatActive)
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool mass = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
Vector3 pos = _current.Position;
|
||||||
|| fighters > 2
|
if (_current.FollowUnit != null && _current.FollowUnit.isAlive())
|
||||||
|| _current.ParticipantCount > 2;
|
{
|
||||||
string label = mass
|
pos = _current.FollowUnit.current_position;
|
||||||
? EventReason.Battle(best, fighters)
|
}
|
||||||
: EventReason.Fight(best, foe);
|
else if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
|
||||||
|
{
|
||||||
|
pos = _current.RelatedUnit.current_position;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool massHint = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| _current.ParticipantCount > 2;
|
||||||
|
float radius = massHint ? 14f : 10f;
|
||||||
|
|
||||||
|
// Death / clear-follow handoff: promote Related before cluster re-pick can steal focus.
|
||||||
|
if (!_current.HasFollowUnit
|
||||||
|
&& _current.RelatedUnit != null
|
||||||
|
&& _current.RelatedUnit.isAlive())
|
||||||
|
{
|
||||||
|
Actor handoff = _current.RelatedUnit;
|
||||||
|
Actor handoffFoe = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (handoff.has_attack_target
|
||||||
|
&& handoff.attack_target != null
|
||||||
|
&& handoff.attack_target.isAlive()
|
||||||
|
&& handoff.attack_target.isActor())
|
||||||
|
{
|
||||||
|
handoffFoe = handoff.attack_target.a;
|
||||||
|
if (handoffFoe != null && !handoffFoe.isAlive())
|
||||||
|
{
|
||||||
|
handoffFoe = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
handoffFoe = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
LiveEnsemble.TryBuildCombat(
|
||||||
|
handoff.current_position,
|
||||||
|
radius,
|
||||||
|
out LiveEnsemble handoffEnsemble,
|
||||||
|
priorParticipantIds: _current.ParticipantIds,
|
||||||
|
newcomerBonus: 8f);
|
||||||
|
if (handoffEnsemble != null)
|
||||||
|
{
|
||||||
|
StabilizeCombatScale(_current, handoffEnsemble, Time.unscaledTime);
|
||||||
|
ApplyEnsembleFields(_current, handoffEnsemble);
|
||||||
|
RefreshStickyCombatSides(
|
||||||
|
_current, handoffEnsemble, handoff.current_position, radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep ownership on the handoff unit; only use ensemble framing when the melee is large.
|
||||||
|
string handoffLabel;
|
||||||
|
if (handoffEnsemble != null
|
||||||
|
&& (handoffEnsemble.ParticipantCount > 2
|
||||||
|
|| HasStickyCombatSides(_current)
|
||||||
|
|| handoffEnsemble.Scale != EnsembleScale.Pair))
|
||||||
|
{
|
||||||
|
handoffEnsemble.Focus = handoff;
|
||||||
|
if (handoffEnsemble.Related == null || handoffEnsemble.Related == handoff)
|
||||||
|
{
|
||||||
|
handoffEnsemble.Related = handoffFoe;
|
||||||
|
}
|
||||||
|
|
||||||
|
handoffLabel = EventReason.Combat(handoffEnsemble);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
handoffLabel = EventReason.Fight(handoff, handoffFoe);
|
||||||
|
_current.ParticipantCount = Mathf.Max(2, _current.ParticipantCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool handoffFollowChanged = _current.FollowUnit != handoff;
|
||||||
|
bool handoffLabelChanged = !string.Equals(_current.Label ?? "", handoffLabel ?? "", StringComparison.Ordinal);
|
||||||
|
_current.FollowUnit = handoff;
|
||||||
|
_current.SubjectId = EventFeedUtil.SafeId(handoff);
|
||||||
|
_current.RelatedUnit = handoffFoe;
|
||||||
|
_current.RelatedId = handoffFoe != null ? EventFeedUtil.SafeId(handoffFoe) : 0;
|
||||||
|
_current.Label = handoffLabel;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_current.Position = handoff.current_position;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// keep
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forceWatch
|
||||||
|
|| handoffFollowChanged
|
||||||
|
|| handoffLabelChanged
|
||||||
|
|| !HasLivingCameraFocus()
|
||||||
|
|| MoveCamera._focus_unit != handoff)
|
||||||
|
{
|
||||||
|
CameraDirector.Watch(_current.ToInterestEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
LiveEnsemble.TryBuildCombat(
|
||||||
|
pos,
|
||||||
|
radius,
|
||||||
|
out LiveEnsemble ensemble,
|
||||||
|
priorParticipantIds: _current.ParticipantIds,
|
||||||
|
newcomerBonus: 8f);
|
||||||
|
|
||||||
|
Actor best;
|
||||||
|
Actor foe;
|
||||||
|
int fighters;
|
||||||
|
string label;
|
||||||
|
if (ensemble != null && ensemble.HasFocus)
|
||||||
|
{
|
||||||
|
StabilizeCombatScale(_current, ensemble, Time.unscaledTime);
|
||||||
|
best = ensemble.Focus;
|
||||||
|
foe = ensemble.Related;
|
||||||
|
fighters = Mathf.Max(0, ensemble.ParticipantCount);
|
||||||
|
ApplyEnsembleFields(_current, ensemble);
|
||||||
|
RefreshStickyCombatSides(_current, ensemble, pos, radius);
|
||||||
|
label = EventReason.Combat(ensemble);
|
||||||
|
}
|
||||||
|
else if (WorldActivityScanner.TryPickBestCombatFocus(
|
||||||
|
_current, out best, out foe, out fighters))
|
||||||
|
{
|
||||||
|
var fallback = new LiveEnsemble
|
||||||
|
{
|
||||||
|
Kind = EnsembleKind.Combat,
|
||||||
|
Scale = LiveEnsemble.ScaleForCount(fighters),
|
||||||
|
Frame = fighters <= 2 ? EnsembleFrame.NamedPair : EnsembleFrame.CountOnly,
|
||||||
|
ParticipantCount = fighters,
|
||||||
|
Focus = best,
|
||||||
|
Related = foe
|
||||||
|
};
|
||||||
|
StabilizeCombatScale(_current, fallback, Time.unscaledTime);
|
||||||
|
ApplyEnsembleFields(_current, fallback);
|
||||||
|
RefreshStickyCombatSides(_current, fallback, pos, radius);
|
||||||
|
label = EventReason.Combat(fallback);
|
||||||
|
_current.ParticipantCount = Math.Max(fighters, _current.CombatSideACount + _current.CombatSideBCount);
|
||||||
|
}
|
||||||
|
else if (HasStickyCombatSides(_current))
|
||||||
|
{
|
||||||
|
// No live fighters briefly - keep framed tip from sticky roster until combat goes cold.
|
||||||
|
if (!_current.HasFollowUnit)
|
||||||
|
{
|
||||||
|
TryPromoteStickyFollow(_current);
|
||||||
|
}
|
||||||
|
|
||||||
|
var held = new LiveEnsemble
|
||||||
|
{
|
||||||
|
Kind = EnsembleKind.Combat,
|
||||||
|
Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)),
|
||||||
|
Focus = _current.FollowUnit,
|
||||||
|
Related = _current.RelatedUnit,
|
||||||
|
ParticipantCount = 0
|
||||||
|
};
|
||||||
|
StabilizeCombatScale(_current, held, Time.unscaledTime);
|
||||||
|
RefreshStickyCombatSides(_current, held, pos, radius);
|
||||||
|
if (held.Focus == null || !held.Focus.isAlive())
|
||||||
|
{
|
||||||
|
held.Focus = _current.RelatedUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((held.Focus == null || !held.Focus.isAlive()) && TryPromoteStickyFollow(_current))
|
||||||
|
{
|
||||||
|
held.Focus = _current.FollowUnit;
|
||||||
|
held.Related = _current.RelatedUnit;
|
||||||
|
RefreshStickyCombatSides(_current, held, pos, radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (held.Focus == null || !held.Focus.isAlive())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
best = held.Focus;
|
||||||
|
foe = held.Related;
|
||||||
|
label = EventReason.Combat(held);
|
||||||
|
fighters = _current.CombatSideACount + _current.CombatSideBCount;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never stick on a sleeper / bystander unless they are on the sticky combat roster.
|
||||||
|
Actor curFollow = _current.FollowUnit;
|
||||||
|
bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow);
|
||||||
|
bool curRostered = IsOnStickyRoster(_current, curFollow);
|
||||||
|
if (!LiveEnsemble.IsCombatParticipant(best))
|
||||||
|
{
|
||||||
|
if (curFighting || curRostered)
|
||||||
|
{
|
||||||
|
best = curFollow;
|
||||||
|
foe = ResolveAttackFoe(best) ?? foe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if ((curFighting || curRostered)
|
||||||
|
&& best != curFollow
|
||||||
|
&& curFollow != null
|
||||||
|
&& curFollow.isAlive())
|
||||||
|
{
|
||||||
|
float curW = LiveEnsemble.FocusWeight(
|
||||||
|
curFollow, _current.ParticipantIds, newcomerBonus: 0f);
|
||||||
|
if (curRostered && !curFighting)
|
||||||
|
{
|
||||||
|
curW += 20f;
|
||||||
|
}
|
||||||
|
|
||||||
|
float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f);
|
||||||
|
if (newW < curW + CombatFocusSwitchMargin)
|
||||||
|
{
|
||||||
|
best = curFollow;
|
||||||
|
if (foe == null || foe == best)
|
||||||
|
{
|
||||||
|
foe = ResolveAttackFoe(best) ?? foe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never replace a sticky scoreboard tip with thin Fight prose.
|
||||||
|
if (HasStickyCombatSides(_current)
|
||||||
|
&& (string.IsNullOrEmpty(label)
|
||||||
|
|| label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||||
|
{
|
||||||
|
var stickyEns = new LiveEnsemble
|
||||||
|
{
|
||||||
|
Kind = EnsembleKind.Combat,
|
||||||
|
Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)),
|
||||||
|
Focus = best,
|
||||||
|
Related = foe,
|
||||||
|
Frame = _current.CombatSideFrame,
|
||||||
|
SideA = new EnsembleSide
|
||||||
|
{
|
||||||
|
Key = _current.CombatSideAKey,
|
||||||
|
Display = _current.CombatSideADisplay,
|
||||||
|
KingdomDisplay = _current.CombatSideAKingdom,
|
||||||
|
Count = _current.CombatSideACount,
|
||||||
|
Best = best
|
||||||
|
},
|
||||||
|
SideB = new EnsembleSide
|
||||||
|
{
|
||||||
|
Key = _current.CombatSideBKey,
|
||||||
|
Display = _current.CombatSideBDisplay,
|
||||||
|
KingdomDisplay = _current.CombatSideBKingdom,
|
||||||
|
Count = _current.CombatSideBCount,
|
||||||
|
Best = foe
|
||||||
|
},
|
||||||
|
ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount
|
||||||
|
};
|
||||||
|
label = EventReason.Combat(stickyEns);
|
||||||
|
}
|
||||||
|
|
||||||
|
string previousLabel = _current.Label ?? "";
|
||||||
|
if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(previousLabel))
|
||||||
|
{
|
||||||
|
label = previousLabel;
|
||||||
|
}
|
||||||
|
|
||||||
bool followChanged = _current.FollowUnit != best;
|
bool followChanged = _current.FollowUnit != best;
|
||||||
bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal);
|
bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal);
|
||||||
_current.FollowUnit = best;
|
_current.FollowUnit = best;
|
||||||
_current.SubjectId = EventFeedUtil.SafeId(best);
|
_current.SubjectId = EventFeedUtil.SafeId(best);
|
||||||
_current.RelatedUnit = foe;
|
_current.RelatedUnit = foe;
|
||||||
|
|
@ -558,13 +843,556 @@ public static class InterestDirector
|
||||||
// keep prior position
|
// keep prior position
|
||||||
}
|
}
|
||||||
|
|
||||||
if (followChanged
|
bool needWatch = forceWatch
|
||||||
|| labelChanged
|
|| followChanged
|
||||||
|| !HasLivingCameraFocus()
|
|| labelChanged
|
||||||
|| MoveCamera._focus_unit != best)
|
|| !HasLivingCameraFocus()
|
||||||
|
|| MoveCamera._focus_unit != best;
|
||||||
|
if (needWatch)
|
||||||
{
|
{
|
||||||
CameraDirector.Watch(_current.ToInterestEvent());
|
CameraDirector.Watch(_current.ToInterestEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOnStickyRoster(InterestCandidate scene, Actor actor)
|
||||||
|
{
|
||||||
|
if (scene == null || actor == null || !actor.isAlive())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long id = EventFeedUtil.SafeId(actor);
|
||||||
|
if (id == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < scene.CombatSideAIds.Count; i++)
|
||||||
|
{
|
||||||
|
if (scene.CombatSideAIds[i] == id)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < scene.CombatSideBIds.Count; i++)
|
||||||
|
{
|
||||||
|
if (scene.CombatSideBIds[i] == id)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasStickyCombatSides(InterestCandidate scene)
|
||||||
|
{
|
||||||
|
return scene != null && scene.HasStickyCombatSides;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the camera subject dies mid sticky battle, promote another living roster member
|
||||||
|
/// instead of ending the scene and falling through to thin "X is fighting" tips.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryPromoteStickyFollow(InterestCandidate scene)
|
||||||
|
{
|
||||||
|
if (scene == null || !HasStickyCombatSides(scene))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Actor pickA = LiveEnsemble.FindBestAliveTracked(scene.CombatSideAIds, preferCombat: true);
|
||||||
|
Actor pickB = LiveEnsemble.FindBestAliveTracked(scene.CombatSideBIds, preferCombat: true);
|
||||||
|
Actor pick = pickA ?? pickB;
|
||||||
|
if (pick == null || !pick.isAlive())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the camp that still has more living members when both exist.
|
||||||
|
if (pickA != null && pickB != null)
|
||||||
|
{
|
||||||
|
int nA = scene.CombatSideACount;
|
||||||
|
int nB = scene.CombatSideBCount;
|
||||||
|
if (nB > nA)
|
||||||
|
{
|
||||||
|
pick = pickB;
|
||||||
|
}
|
||||||
|
else if (nA > nB)
|
||||||
|
{
|
||||||
|
pick = pickA;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Actor other = pick == pickA ? pickB : pickA;
|
||||||
|
scene.FollowUnit = pick;
|
||||||
|
scene.SubjectId = EventFeedUtil.SafeId(pick);
|
||||||
|
scene.RelatedUnit = other;
|
||||||
|
scene.RelatedId = other != null ? EventFeedUtil.SafeId(other) : 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scene.Position = pick.current_position;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// keep
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lock opposing camp keys once, then refresh live counts (may be 0) every maintain pass.
|
||||||
|
/// </summary>
|
||||||
|
private static void RefreshStickyCombatSides(
|
||||||
|
InterestCandidate scene,
|
||||||
|
LiveEnsemble ensemble,
|
||||||
|
Vector3 pos,
|
||||||
|
float radius)
|
||||||
|
{
|
||||||
|
if (scene == null || ensemble == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasStickyCombatSides(scene) && LiveEnsemble.HasOpposingCollectiveSides(ensemble))
|
||||||
|
{
|
||||||
|
// Only lock sticky camps once the live cluster is actually multi-sided.
|
||||||
|
int liveN = Math.Max(ensemble.ParticipantCount,
|
||||||
|
(ensemble.SideA?.Count ?? 0) + (ensemble.SideB?.Count ?? 0));
|
||||||
|
if (liveN <= 2 && ensemble.Scale <= EnsembleScale.Pair)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.CombatSideFrame = ensemble.Frame;
|
||||||
|
scene.CombatSideAKey = ensemble.SideA.Key ?? "";
|
||||||
|
scene.CombatSideADisplay = ensemble.SideA.Display ?? "";
|
||||||
|
scene.CombatSideAKingdom = ensemble.SideA.KingdomDisplay ?? "";
|
||||||
|
scene.CombatSideBKey = ensemble.SideB.Key ?? "";
|
||||||
|
scene.CombatSideBDisplay = ensemble.SideB.Display ?? "";
|
||||||
|
scene.CombatSideBKingdom = ensemble.SideB.KingdomDisplay ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasStickyCombatSides(scene))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed roster from known ensemble participants on first lock / empty lists.
|
||||||
|
SeedStickyRosterFromEnsemble(scene, ensemble);
|
||||||
|
|
||||||
|
LiveEnsemble.RefreshStickyMemberRoster(
|
||||||
|
pos,
|
||||||
|
radius,
|
||||||
|
scene.CombatSideFrame,
|
||||||
|
scene.CombatSideAKey,
|
||||||
|
scene.CombatSideBKey,
|
||||||
|
scene.CombatSideAIds,
|
||||||
|
scene.CombatSideBIds,
|
||||||
|
out int countA,
|
||||||
|
out int countB,
|
||||||
|
out Actor bestA,
|
||||||
|
out Actor bestB);
|
||||||
|
|
||||||
|
scene.CombatSideACount = Math.Max(0, countA);
|
||||||
|
scene.CombatSideBCount = Math.Max(0, countB);
|
||||||
|
int live = scene.CombatSideACount + scene.CombatSideBCount;
|
||||||
|
scene.ParticipantCount = live;
|
||||||
|
if (live > scene.CombatPeakParticipants)
|
||||||
|
{
|
||||||
|
scene.CombatPeakParticipants = live;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensemble.Frame = scene.CombatSideFrame;
|
||||||
|
ensemble.SideA = new EnsembleSide
|
||||||
|
{
|
||||||
|
Key = scene.CombatSideAKey,
|
||||||
|
Display = string.IsNullOrEmpty(scene.CombatSideADisplay)
|
||||||
|
? scene.CombatSideAKey
|
||||||
|
: scene.CombatSideADisplay,
|
||||||
|
KingdomDisplay = scene.CombatSideAKingdom,
|
||||||
|
Count = scene.CombatSideACount,
|
||||||
|
Best = bestA ?? ensemble.Focus
|
||||||
|
};
|
||||||
|
ensemble.SideB = new EnsembleSide
|
||||||
|
{
|
||||||
|
Key = scene.CombatSideBKey,
|
||||||
|
Display = string.IsNullOrEmpty(scene.CombatSideBDisplay)
|
||||||
|
? scene.CombatSideBKey
|
||||||
|
: scene.CombatSideBDisplay,
|
||||||
|
KingdomDisplay = scene.CombatSideBKingdom,
|
||||||
|
Count = scene.CombatSideBCount,
|
||||||
|
Best = bestB ?? ensemble.Related
|
||||||
|
};
|
||||||
|
ensemble.ParticipantCount = live;
|
||||||
|
|
||||||
|
// Sticky multi frame stays multi while the scene is hot - do not collapse to Pair on a dip.
|
||||||
|
if (ensemble.Scale == EnsembleScale.Pair)
|
||||||
|
{
|
||||||
|
ensemble.Scale = LiveEnsemble.ScaleForCount(Math.Max(3, scene.CombatPeakParticipants));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestA != null && (ensemble.Focus == null || !LiveEnsemble.IsCombatParticipant(ensemble.Focus)))
|
||||||
|
{
|
||||||
|
ensemble.Focus = bestA;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestB != null)
|
||||||
|
{
|
||||||
|
ensemble.Related = bestB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SeedStickyRosterFromEnsemble(InterestCandidate scene, LiveEnsemble ensemble)
|
||||||
|
{
|
||||||
|
if (scene == null || ensemble == null || !HasStickyCombatSides(scene))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Enroll(Actor actor)
|
||||||
|
{
|
||||||
|
if (actor == null || !actor.isAlive())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long id = EventFeedUtil.SafeId(actor);
|
||||||
|
if (id == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string key = scene.CombatSideFrame == EnsembleFrame.KingdomVsKingdom
|
||||||
|
? LiveEnsemble.KingdomKeyOf(actor)
|
||||||
|
: LiveEnsemble.SpeciesKeyOf(actor);
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.Equals(scene.CombatSideAKey, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
for (int i = 0; i < scene.CombatSideAIds.Count; i++)
|
||||||
|
{
|
||||||
|
if (scene.CombatSideAIds[i] == id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.CombatSideAIds.Add(id);
|
||||||
|
}
|
||||||
|
else if (key.Equals(scene.CombatSideBKey, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
for (int i = 0; i < scene.CombatSideBIds.Count; i++)
|
||||||
|
{
|
||||||
|
if (scene.CombatSideBIds[i] == id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.CombatSideBIds.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Enroll(ensemble.Focus);
|
||||||
|
Enroll(ensemble.Related);
|
||||||
|
Enroll(ensemble.SideA?.Best);
|
||||||
|
Enroll(ensemble.SideB?.Best);
|
||||||
|
if (ensemble.ParticipantIds != null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < ensemble.ParticipantIds.Count; i++)
|
||||||
|
{
|
||||||
|
long id = ensemble.ParticipantIds[i];
|
||||||
|
if (id == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
||||||
|
{
|
||||||
|
if (actor != null && actor.isAlive() && EventFeedUtil.SafeId(actor) == id)
|
||||||
|
{
|
||||||
|
Enroll(actor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Actor ResolveAttackFoe(Actor actor)
|
||||||
|
{
|
||||||
|
if (actor == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (actor.has_attack_target
|
||||||
|
&& actor.attack_target != null
|
||||||
|
&& actor.attack_target.isAlive()
|
||||||
|
&& actor.attack_target.isActor())
|
||||||
|
{
|
||||||
|
Actor foe = actor.attack_target.a;
|
||||||
|
return foe != null && foe.isAlive() ? foe : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hold elevated combat scale briefly after fighter count dips so Mass does not flap to Duel.
|
||||||
|
/// </summary>
|
||||||
|
private static void StabilizeCombatScale(InterestCandidate scene, LiveEnsemble ensemble, float now)
|
||||||
|
{
|
||||||
|
if (scene == null || ensemble == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int n = Mathf.Max(1, ensemble.ParticipantCount);
|
||||||
|
if (n > scene.CombatPeakParticipants)
|
||||||
|
{
|
||||||
|
scene.CombatPeakParticipants = n;
|
||||||
|
_combatScaleDropSince = -999f;
|
||||||
|
}
|
||||||
|
|
||||||
|
int peak = Math.Max(scene.CombatPeakParticipants, n);
|
||||||
|
EnsembleScale live = LiveEnsemble.ScaleForCount(n);
|
||||||
|
EnsembleScale held = LiveEnsemble.ScaleForCount(peak);
|
||||||
|
if (held > live)
|
||||||
|
{
|
||||||
|
if (_combatScaleDropSince < 0f)
|
||||||
|
{
|
||||||
|
_combatScaleDropSince = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - _combatScaleDropSince < CombatScaleHoldSeconds)
|
||||||
|
{
|
||||||
|
ensemble.Scale = held;
|
||||||
|
// Live rebuild may only see a NamedPair; keep mass framing while peak holds.
|
||||||
|
if (ensemble.Frame == EnsembleFrame.NamedPair)
|
||||||
|
{
|
||||||
|
ensemble.Frame = EnsembleFrame.CountOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hold expired: ratchet peak down to live count.
|
||||||
|
scene.CombatPeakParticipants = n;
|
||||||
|
_combatScaleDropSince = -999f;
|
||||||
|
ensemble.Scale = live;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_combatScaleDropSince = -999f;
|
||||||
|
ensemble.Scale = live;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rewatch tip only when tier or collective sides change - not on (6)↔(5) count flaps.
|
||||||
|
/// </summary>
|
||||||
|
private static bool CombatLabelNeedsRewatch(string oldLabel, string newLabel)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(oldLabel))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(newLabel))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
string a = StripCombatLabelCounts(oldLabel);
|
||||||
|
string b = StripCombatLabelCounts(newLabel);
|
||||||
|
return !a.Equals(b, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripCombatLabelCounts(string label)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(label))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new System.Text.StringBuilder(label.Length);
|
||||||
|
for (int i = 0; i < label.Length; i++)
|
||||||
|
{
|
||||||
|
char c = label[i];
|
||||||
|
if (c == '(')
|
||||||
|
{
|
||||||
|
int close = label.IndexOf(')', i);
|
||||||
|
if (close > i)
|
||||||
|
{
|
||||||
|
i = close;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString().Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyEnsembleFields(InterestCandidate scene, LiveEnsemble ensemble)
|
||||||
|
{
|
||||||
|
if (scene == null || ensemble == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.ParticipantCount = Math.Max(0, ensemble.ParticipantCount);
|
||||||
|
if (scene.ParticipantCount > scene.CombatPeakParticipants)
|
||||||
|
{
|
||||||
|
scene.CombatPeakParticipants = scene.ParticipantCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.NotableParticipantCount = ensemble.NotableParticipantCount;
|
||||||
|
WorldActivityScanner.StampParticipantIds(scene, ensemble);
|
||||||
|
if (ensemble.Scale >= EnsembleScale.Skirmish)
|
||||||
|
{
|
||||||
|
scene.AssetId = "live_battle";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ensemble.Frame == EnsembleFrame.KingdomVsKingdom
|
||||||
|
&& ensemble.SideA != null
|
||||||
|
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
|
||||||
|
{
|
||||||
|
scene.KingdomKey = ensemble.SideA.Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ensemble.Frame == EnsembleFrame.SpeciesVsSpecies
|
||||||
|
&& ensemble.SideA != null
|
||||||
|
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
|
||||||
|
{
|
||||||
|
scene.SpeciesId = ensemble.SideA.Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LiveEnsemble.HasOpposingCollectiveSides(ensemble) && !HasStickyCombatSides(scene))
|
||||||
|
{
|
||||||
|
int liveN = Math.Max(ensemble.ParticipantCount,
|
||||||
|
ensemble.SideA.Count + ensemble.SideB.Count);
|
||||||
|
if (liveN > 2 || ensemble.Scale > EnsembleScale.Pair)
|
||||||
|
{
|
||||||
|
scene.CombatSideFrame = ensemble.Frame;
|
||||||
|
scene.CombatSideAKey = ensemble.SideA.Key ?? "";
|
||||||
|
scene.CombatSideADisplay = ensemble.SideA.Display ?? "";
|
||||||
|
scene.CombatSideAKingdom = ensemble.SideA.KingdomDisplay ?? "";
|
||||||
|
scene.CombatSideACount = Math.Max(0, ensemble.SideA.Count);
|
||||||
|
scene.CombatSideBKey = ensemble.SideB.Key ?? "";
|
||||||
|
scene.CombatSideBDisplay = ensemble.SideB.Display ?? "";
|
||||||
|
scene.CombatSideBKingdom = ensemble.SideB.KingdomDisplay ?? "";
|
||||||
|
scene.CombatSideBCount = Math.Max(0, ensemble.SideB.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If the active combat scene already covers this battle anchor, refresh it in place
|
||||||
|
/// instead of registering a second live_battle candidate.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryAbsorbCombatBattle(InterestEvent battle)
|
||||||
|
{
|
||||||
|
if (battle == null
|
||||||
|
|| _current == null
|
||||||
|
|| _current.Completion != InterestCompletionKind.CombatActive)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector3 anchor = battle.Position;
|
||||||
|
Vector3 cur = _current.Position;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_current.FollowUnit != null && _current.FollowUnit.isAlive())
|
||||||
|
{
|
||||||
|
cur = _current.FollowUnit.current_position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// keep
|
||||||
|
}
|
||||||
|
|
||||||
|
float dx = anchor.x - cur.x;
|
||||||
|
float dy = anchor.y - cur.y;
|
||||||
|
float mergeR = 16f;
|
||||||
|
bool near = dx * dx + dy * dy <= mergeR * mergeR;
|
||||||
|
bool shared = false;
|
||||||
|
if (!near && battle.FollowUnit != null)
|
||||||
|
{
|
||||||
|
long id = EventFeedUtil.SafeId(battle.FollowUnit);
|
||||||
|
shared = id != 0 && _current.ParticipantIds != null && _current.ParticipantIds.Contains(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!near && !shared)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (battle.ParticipantCount > _current.ParticipantCount)
|
||||||
|
{
|
||||||
|
_current.ParticipantCount = battle.ParticipantCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (battle.NotableParticipantCount > _current.NotableParticipantCount)
|
||||||
|
{
|
||||||
|
_current.NotableParticipantCount = battle.NotableParticipantCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyCombatEnsembleToCurrent(forceWatch: true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Harness: apply a synthetic combat ensemble onto the current scene.</summary>
|
||||||
|
public static bool HarnessApplyCombatEnsemble(LiveEnsemble ensemble)
|
||||||
|
{
|
||||||
|
if (_current == null
|
||||||
|
|| ensemble == null
|
||||||
|
|| !ensemble.HasFocus
|
||||||
|
|| _current.Completion != InterestCompletionKind.CombatActive)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyEnsembleFields(_current, ensemble);
|
||||||
|
RefreshStickyCombatSides(
|
||||||
|
_current,
|
||||||
|
ensemble,
|
||||||
|
ensemble.Focus.current_position,
|
||||||
|
14f);
|
||||||
|
_current.FollowUnit = ensemble.Focus;
|
||||||
|
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
|
||||||
|
_current.RelatedUnit = ensemble.Related;
|
||||||
|
_current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
|
||||||
|
_current.Label = EventReason.Combat(ensemble);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_current.Position = ensemble.Focus.current_position;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// keep
|
||||||
|
}
|
||||||
|
|
||||||
|
CameraDirector.Watch(_current.ToInterestEvent());
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ClearStaleCombatLabel(float now)
|
private static void ClearStaleCombatLabel(float now)
|
||||||
|
|
@ -574,13 +1402,23 @@ public static class InterestDirector
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_current.Label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0
|
string label = _current.Label;
|
||||||
&& _current.Label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0)
|
if (label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0
|
||||||
|
&& label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0
|
||||||
|
&& label.IndexOf("duel", StringComparison.OrdinalIgnoreCase) < 0
|
||||||
|
&& label.IndexOf("skirmish", StringComparison.OrdinalIgnoreCase) < 0
|
||||||
|
&& label.IndexOf("clash", StringComparison.OrdinalIgnoreCase) < 0
|
||||||
|
&& label.IndexOf("battle", StringComparison.OrdinalIgnoreCase) < 0
|
||||||
|
&& label.IndexOf("mass -", StringComparison.OrdinalIgnoreCase) < 0
|
||||||
|
&& label.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) < 0
|
||||||
|
&& label.IndexOf("leading a fight", StringComparison.OrdinalIgnoreCase) < 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_current.Label = "";
|
_current.Label = "";
|
||||||
|
_current.ClearCombatSticky();
|
||||||
|
_combatScaleDropSince = -999f;
|
||||||
_lastCombatFocusAt = now;
|
_lastCombatFocusAt = now;
|
||||||
CameraDirector.Watch(_current.ToInterestEvent());
|
CameraDirector.Watch(_current.ToInterestEvent());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,16 @@ public static class InterestRegistry
|
||||||
existing.RelatedId = incoming.RelatedId;
|
existing.RelatedId = incoming.RelatedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (incoming.ParticipantCount > existing.ParticipantCount)
|
||||||
|
{
|
||||||
|
existing.ParticipantCount = incoming.ParticipantCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming.NotableParticipantCount > existing.NotableParticipantCount)
|
||||||
|
{
|
||||||
|
existing.NotableParticipantCount = incoming.NotableParticipantCount;
|
||||||
|
}
|
||||||
|
|
||||||
if (incoming.ForceActive)
|
if (incoming.ForceActive)
|
||||||
{
|
{
|
||||||
existing.ForceActive = true;
|
existing.ForceActive = true;
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ public class ScoringWeights
|
||||||
public float massBaseBonus = 28f;
|
public float massBaseBonus = 28f;
|
||||||
public float massPerExtraFighter = 4f;
|
public float massPerExtraFighter = 4f;
|
||||||
public float massExtraCap = 24f;
|
public float massExtraCap = 24f;
|
||||||
|
/// <summary>Fighter count at which combat ensembles use Mass scale framing.</summary>
|
||||||
|
public int massCrowdThreshold = 8;
|
||||||
public int duelMaxFighters = 2;
|
public int duelMaxFighters = 2;
|
||||||
public float duelNotablePairBonus = 55f;
|
public float duelNotablePairBonus = 55f;
|
||||||
public float duelSingleNotableBonus = 15f;
|
public float duelSingleNotableBonus = 15f;
|
||||||
|
|
|
||||||
|
|
@ -348,12 +348,16 @@ public sealed class UnitDossier
|
||||||
// Ownership: never let the reason name a living stranger (not subject/related).
|
// Ownership: never let the reason name a living stranger (not subject/related).
|
||||||
if (ReasonNamesStranger(beat, scene))
|
if (ReasonNamesStranger(beat, scene))
|
||||||
{
|
{
|
||||||
if (recordDrops)
|
// Tiered combat tips name sides / duelists on purpose.
|
||||||
|
if (!IsEnsembleCombatReason(beat))
|
||||||
{
|
{
|
||||||
InterestDropLog.Record("identity_filter", "stranger:" + beat);
|
if (recordDrops)
|
||||||
}
|
{
|
||||||
|
InterestDropLog.Record("identity_filter", "stranger:" + beat);
|
||||||
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ownership: reject beats that lead with someone else's proper name.
|
// Ownership: reject beats that lead with someone else's proper name.
|
||||||
|
|
@ -472,6 +476,9 @@ public sealed class UnitDossier
|
||||||
case "fighting":
|
case "fighting":
|
||||||
case "war":
|
case "war":
|
||||||
case "battle":
|
case "battle":
|
||||||
|
case "duel":
|
||||||
|
case "skirmish":
|
||||||
|
case "mass":
|
||||||
case "clash":
|
case "clash":
|
||||||
case "grief":
|
case "grief":
|
||||||
case "mourning":
|
case "mourning":
|
||||||
|
|
@ -501,6 +508,20 @@ public sealed class UnitDossier
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsEnsembleCombatReason(string reason)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(reason))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reason.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| reason.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| reason.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| reason.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| reason.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool ReasonNamesStranger(string reason, InterestCandidate scene)
|
private static bool ReasonNamesStranger(string reason, InterestCandidate scene)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null)
|
if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null)
|
||||||
|
|
@ -768,6 +789,11 @@ public sealed class UnitDossier
|
||||||
|| t.IndexOf(" killed", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|| t.IndexOf(" killed", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|| t.IndexOf(" fell", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|| t.IndexOf(" fell", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|| t.IndexOf(" from ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|| t.IndexOf(" from ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|
|| t.IndexOf(" vs ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|
|| t.StartsWith("Duel", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| t.StartsWith("Skirmish", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| t.StartsWith("Battle", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| t.StartsWith("Mass", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|| t.StartsWith("Rebellion:", System.StringComparison.OrdinalIgnoreCase)
|
|| t.StartsWith("Rebellion:", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|| t.StartsWith("War:", System.StringComparison.OrdinalIgnoreCase)
|
|| t.StartsWith("War:", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|| t.IndexOf(':') >= 0;
|
|| t.IndexOf(':') >= 0;
|
||||||
|
|
@ -998,7 +1024,7 @@ public sealed class UnitDossier
|
||||||
sb.Append(" · ");
|
sb.Append(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.Append(d.KingdomName);
|
sb.Append(LiveEnsemble.KingdomDisplay(d.KingdomName));
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrEmpty(d.CityName)
|
else if (!string.IsNullOrEmpty(d.CityName)
|
||||||
&& !string.Equals(d.CityName, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase)
|
&& !string.Equals(d.CityName, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase)
|
||||||
|
|
|
||||||
|
|
@ -561,12 +561,60 @@ public static class WatchCaption
|
||||||
SetVisible(true);
|
SetVisible(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safety net: any focus change that skipped SetFromActor (or rematched follow)
|
||||||
|
// must not leave the nametag on the previous person for a frame.
|
||||||
|
ReconcileDossierToFocus();
|
||||||
RefreshLivePortrait();
|
RefreshLivePortrait();
|
||||||
RefreshLiveTask();
|
RefreshLiveTask();
|
||||||
RefreshOwnedReason();
|
RefreshOwnedReason();
|
||||||
RefreshHistoryIfChanged();
|
RefreshHistoryIfChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// While Idle Spectator is live, dossier nametag must track the camera focus unit.
|
||||||
|
/// Skips pause/fallen pins where the archive subject intentionally differs from focus.
|
||||||
|
/// </summary>
|
||||||
|
private static void ReconcileDossierToFocus()
|
||||||
|
{
|
||||||
|
if (_pinnedWhilePaused || !SpectatorMode.Active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Actor focus = MoveCamera._focus_unit;
|
||||||
|
if (!focus.isAlive())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long focusId = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
focusId = focus.getID();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusId == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_current != null && _current.UnitId == focusId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetFromActor(focus);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool HasStatusBanner()
|
private static bool HasStatusBanner()
|
||||||
{
|
{
|
||||||
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
|
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
|
||||||
|
|
|
||||||
|
|
@ -121,14 +121,24 @@ public static class WorldActivityScanner
|
||||||
}
|
}
|
||||||
|
|
||||||
SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
|
SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
|
||||||
CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out _);
|
|
||||||
fighters = Mathf.Max(1, fighters);
|
|
||||||
|
|
||||||
PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
|
PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
|
||||||
string label = EventReason.Fight(follow, related);
|
LiveEnsemble.TryBuildCombat(actor.current_position, 10f, out LiveEnsemble ensemble);
|
||||||
|
int fighters = ensemble != null ? ensemble.ParticipantCount : 1;
|
||||||
|
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
|
||||||
|
fighters = Mathf.Max(1, fighters);
|
||||||
|
if (ensemble != null && ensemble.HasFocus)
|
||||||
|
{
|
||||||
|
follow = ensemble.Focus;
|
||||||
|
related = ensemble.Related ?? related;
|
||||||
|
}
|
||||||
|
|
||||||
|
string label = ensemble != null
|
||||||
|
? EventReason.Combat(ensemble)
|
||||||
|
: EventReason.Fight(follow, related);
|
||||||
var candidate = new InterestCandidate
|
var candidate = new InterestCandidate
|
||||||
{
|
{
|
||||||
Key = EventCatalog.Combat.Key(id),
|
Key = EventCatalog.Combat.Key(follow != null ? EventFeedUtil.SafeId(follow) : id),
|
||||||
LeadKind = InterestLeadKind.EventLed,
|
LeadKind = InterestLeadKind.EventLed,
|
||||||
Category = "Combat",
|
Category = "Combat",
|
||||||
Source = "scanner",
|
Source = "scanner",
|
||||||
|
|
@ -155,6 +165,21 @@ public static class WorldActivityScanner
|
||||||
MaxWatch = EventCatalog.Combat.MaxWatch,
|
MaxWatch = EventCatalog.Combat.MaxWatch,
|
||||||
Completion = InterestCompletionKind.CombatActive
|
Completion = InterestCompletionKind.CombatActive
|
||||||
};
|
};
|
||||||
|
if (ensemble != null)
|
||||||
|
{
|
||||||
|
StampParticipantIds(candidate, ensemble);
|
||||||
|
if (ensemble.Scale >= EnsembleScale.Skirmish)
|
||||||
|
{
|
||||||
|
candidate.AssetId = "live_battle";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key)
|
||||||
|
&& ensemble.Frame == EnsembleFrame.KingdomVsKingdom)
|
||||||
|
{
|
||||||
|
candidate.KingdomKey = ensemble.SideA.Key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
InterestScoring.ScoreCheap(candidate);
|
InterestScoring.ScoreCheap(candidate);
|
||||||
EventFeedUtil.RegisterCandidate(candidate);
|
EventFeedUtil.RegisterCandidate(candidate);
|
||||||
}
|
}
|
||||||
|
|
@ -230,7 +255,10 @@ public static class WorldActivityScanner
|
||||||
|
|
||||||
int deaths = battle.getDeathsTotal();
|
int deaths = battle.getDeathsTotal();
|
||||||
Vector3 pos = battle.tile.posV3;
|
Vector3 pos = battle.tile.posV3;
|
||||||
CountFightCluster(pos, 14f, out int fighters, out int notables, out Actor follow);
|
LiveEnsemble.TryBuildCombat(pos, 14f, out LiveEnsemble ensemble);
|
||||||
|
int fighters = ensemble != null ? ensemble.ParticipantCount : 0;
|
||||||
|
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
|
||||||
|
Actor follow = ensemble != null ? ensemble.Focus : null;
|
||||||
if (deaths < 1 && fighters < 2)
|
if (deaths < 1 && fighters < 2)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -241,12 +269,16 @@ public static class WorldActivityScanner
|
||||||
if (score > bestScore)
|
if (score > bestScore)
|
||||||
{
|
{
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
string label = EventReason.Battle(follow ?? FindNearestAliveUnit(pos, 14f), fighters);
|
Actor focus = follow ?? FindNearestAliveUnit(pos, 14f);
|
||||||
|
string label = ensemble != null && ensemble.HasFocus
|
||||||
|
? EventReason.Combat(ensemble)
|
||||||
|
: EventReason.Battle(focus, fighters);
|
||||||
best = new InterestEvent
|
best = new InterestEvent
|
||||||
{
|
{
|
||||||
Score = score,
|
Score = score,
|
||||||
Position = pos,
|
Position = pos,
|
||||||
FollowUnit = follow ?? FindNearestAliveUnit(pos, 14f),
|
FollowUnit = focus,
|
||||||
|
RelatedUnit = ensemble != null ? ensemble.Related : null,
|
||||||
Label = label,
|
Label = label,
|
||||||
CreatedAt = Time.unscaledTime,
|
CreatedAt = Time.unscaledTime,
|
||||||
AssetId = "live_battle",
|
AssetId = "live_battle",
|
||||||
|
|
@ -289,7 +321,10 @@ public static class WorldActivityScanner
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out Actor follow);
|
LiveEnsemble.TryBuildCombat(actor.current_position, 10f, out LiveEnsemble ensemble);
|
||||||
|
int fighters = ensemble != null ? ensemble.ParticipantCount : 0;
|
||||||
|
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
|
||||||
|
Actor follow = ensemble != null ? ensemble.Focus : null;
|
||||||
if (fighters < 1)
|
if (fighters < 1)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -302,12 +337,16 @@ public static class WorldActivityScanner
|
||||||
{
|
{
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
Actor focus = follow ?? actor;
|
Actor focus = follow ?? actor;
|
||||||
|
string label = ensemble != null && ensemble.HasFocus
|
||||||
|
? EventReason.Combat(ensemble)
|
||||||
|
: EventReason.Battle(focus, fighters);
|
||||||
best = new InterestEvent
|
best = new InterestEvent
|
||||||
{
|
{
|
||||||
Score = score,
|
Score = score,
|
||||||
Position = actor.current_position,
|
Position = actor.current_position,
|
||||||
FollowUnit = focus,
|
FollowUnit = focus,
|
||||||
Label = EventReason.Battle(focus, fighters),
|
RelatedUnit = ensemble != null ? ensemble.Related : null,
|
||||||
|
Label = label,
|
||||||
CreatedAt = Time.unscaledTime,
|
CreatedAt = Time.unscaledTime,
|
||||||
AssetId = "live_battle",
|
AssetId = "live_battle",
|
||||||
ParticipantCount = fighters,
|
ParticipantCount = fighters,
|
||||||
|
|
@ -370,7 +409,7 @@ public static class WorldActivityScanner
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Highest-scored living participant for an active combat scene.
|
/// Highest-scored living participant for an active combat scene.
|
||||||
/// Considers Follow, Related, and (for mass fights) the nearby fight cluster.
|
/// Considers Follow, Related, and (for mass fights) the nearby fight ensemble.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool TryPickBestCombatFocus(
|
public static bool TryPickBestCombatFocus(
|
||||||
InterestCandidate scene,
|
InterestCandidate scene,
|
||||||
|
|
@ -386,6 +425,35 @@ public static class WorldActivityScanner
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LiveEnsemble ensemble = null;
|
||||||
|
Vector3 pos = scene.Position;
|
||||||
|
if (scene.FollowUnit != null && scene.FollowUnit.isAlive())
|
||||||
|
{
|
||||||
|
pos = scene.FollowUnit.current_position;
|
||||||
|
}
|
||||||
|
else if (scene.RelatedUnit != null && scene.RelatedUnit.isAlive())
|
||||||
|
{
|
||||||
|
pos = scene.RelatedUnit.current_position;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool mass = string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| scene.ParticipantCount > 2;
|
||||||
|
float radius = mass ? 14f : 10f;
|
||||||
|
LiveEnsemble.TryBuildCombat(
|
||||||
|
pos,
|
||||||
|
radius,
|
||||||
|
out ensemble,
|
||||||
|
priorParticipantIds: scene.ParticipantIds,
|
||||||
|
newcomerBonus: 8f);
|
||||||
|
|
||||||
|
if (ensemble != null && ensemble.HasFocus)
|
||||||
|
{
|
||||||
|
best = ensemble.Focus;
|
||||||
|
foe = ensemble.Related;
|
||||||
|
fighters = Mathf.Max(1, ensemble.ParticipantCount);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
fighters = Mathf.Max(1, scene.ParticipantCount);
|
fighters = Mathf.Max(1, scene.ParticipantCount);
|
||||||
float bestWeight = -1f;
|
float bestWeight = -1f;
|
||||||
Actor bestLocal = null;
|
Actor bestLocal = null;
|
||||||
|
|
@ -397,7 +465,7 @@ public static class WorldActivityScanner
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
float w = CombatFocusWeight(unit);
|
float w = LiveEnsemble.FocusWeight(unit, scene.ParticipantIds, 8f);
|
||||||
if (w > bestWeight)
|
if (w > bestWeight)
|
||||||
{
|
{
|
||||||
bestWeight = w;
|
bestWeight = w;
|
||||||
|
|
@ -407,37 +475,13 @@ public static class WorldActivityScanner
|
||||||
|
|
||||||
Consider(scene.FollowUnit);
|
Consider(scene.FollowUnit);
|
||||||
Consider(scene.RelatedUnit);
|
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;
|
best = bestLocal;
|
||||||
if (best == null)
|
if (best == null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foe = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (best.has_attack_target
|
if (best.has_attack_target
|
||||||
|
|
@ -474,6 +518,39 @@ public static class WorldActivityScanner
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Build a combat ensemble snapshot at a world position.</summary>
|
||||||
|
public static bool TryBuildCombatEnsemble(
|
||||||
|
Vector2 pos,
|
||||||
|
float radius,
|
||||||
|
out LiveEnsemble ensemble,
|
||||||
|
InterestCandidate prior = null)
|
||||||
|
{
|
||||||
|
return LiveEnsemble.TryBuildCombat(
|
||||||
|
pos,
|
||||||
|
radius,
|
||||||
|
out ensemble,
|
||||||
|
priorParticipantIds: prior?.ParticipantIds,
|
||||||
|
newcomerBonus: 8f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void StampParticipantIds(InterestCandidate candidate, LiveEnsemble ensemble)
|
||||||
|
{
|
||||||
|
if (candidate == null || ensemble == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.ParticipantIds.Clear();
|
||||||
|
for (int i = 0; i < ensemble.ParticipantIds.Count; i++)
|
||||||
|
{
|
||||||
|
long id = ensemble.ParticipantIds[i];
|
||||||
|
if (id != 0 && !candidate.ParticipantIds.Contains(id))
|
||||||
|
{
|
||||||
|
candidate.ParticipantIds.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static float ScoreFightCluster(int deaths, int fighters, int notables)
|
private static float ScoreFightCluster(int deaths, int fighters, int notables)
|
||||||
{
|
{
|
||||||
ScoringWeights w = InterestScoringConfig.W;
|
ScoringWeights w = InterestScoringConfig.W;
|
||||||
|
|
@ -512,54 +589,14 @@ public static class WorldActivityScanner
|
||||||
fighters = 0;
|
fighters = 0;
|
||||||
notables = 0;
|
notables = 0;
|
||||||
bestFollow = null;
|
bestFollow = null;
|
||||||
float bestChar = -1f;
|
if (!LiveEnsemble.TryBuildCombat(pos, radius, out LiveEnsemble ensemble))
|
||||||
float r2 = radius * radius;
|
|
||||||
foreach (Actor actor in EnumerateAliveUnits())
|
|
||||||
{
|
{
|
||||||
if (actor == null || !actor.isAlive())
|
return;
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
float dx = actor.current_position.x - pos.x;
|
|
||||||
float dy = actor.current_position.y - pos.y;
|
|
||||||
if (dx * dx + dy * dy > r2)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool fighting = false;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
fighting = actor.has_attack_target
|
|
||||||
|| (actor.hasTask() && actor.ai?.task != null && actor.ai.task.in_combat);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
fighting = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fighting)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
fighters++;
|
|
||||||
bool notable = InterestScoring.IsNotable(actor);
|
|
||||||
bool extreme = InterestScoring.IsExtremelyNotable(actor);
|
|
||||||
if (notable)
|
|
||||||
{
|
|
||||||
notables++;
|
|
||||||
}
|
|
||||||
|
|
||||||
SplitActorScore(actor, null, out _, out float charScore);
|
|
||||||
float weight = charScore + (extreme ? 30f : 0f);
|
|
||||||
if (weight > bestChar)
|
|
||||||
{
|
|
||||||
bestChar = weight;
|
|
||||||
bestFollow = actor;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fighters = ensemble.ParticipantCount;
|
||||||
|
notables = ensemble.NotableParticipantCount;
|
||||||
|
bestFollow = ensemble.Focus;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void MarkClusterSeen(Vector2 pos, float radius, HashSet<long> seen)
|
private static void MarkClusterSeen(Vector2 pos, float radius, HashSet<long> seen)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "IdleSpectator",
|
"name": "IdleSpectator",
|
||||||
"author": "dazed",
|
"author": "dazed",
|
||||||
"version": "0.25.51",
|
"version": "0.25.64",
|
||||||
"description": "AFK Idle Spectator (I) + Lore (L). Combat focus + tip alignment.",
|
"description": "AFK Idle Spectator (I) + Lore (L). Sticky combat handoff - no thin fighting fallback.",
|
||||||
"GUID": "com.dazed.idlespectator"
|
"GUID": "com.dazed.idlespectator"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"massBaseBonus": 28,
|
"massBaseBonus": 28,
|
||||||
"massPerExtraFighter": 4,
|
"massPerExtraFighter": 4,
|
||||||
"massExtraCap": 24,
|
"massExtraCap": 24,
|
||||||
|
"massCrowdThreshold": 8,
|
||||||
"duelMaxFighters": 2,
|
"duelMaxFighters": 2,
|
||||||
"duelNotablePairBonus": 55,
|
"duelNotablePairBonus": 55,
|
||||||
"duelSingleNotableBonus": 15,
|
"duelSingleNotableBonus": 15,
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,9 @@ Done (see [camera-presentability-plan.md](camera-presentability-plan.md) and `co
|
||||||
- Combat tip/focus alignment (highest-scored participant; tip subject matches focus;
|
- Combat tip/focus alignment (highest-scored participant; tip subject matches focus;
|
||||||
clears "is fighting" when combat goes cold)
|
clears "is fighting" when combat goes cold)
|
||||||
|
|
||||||
**Polish next:** dossier caption/nametag sync if tip+focus match but the nametag briefly shows someone else.
|
Done (0.25.52): dossier nametag tracks focus on handoff (`CameraDirector.Watch` focus-then-caption;
|
||||||
|
`WatchCaption.ReconcileDossierToFocus`; `combat_focus` asserts `dossier_matches_focus`).
|
||||||
|
|
||||||
|
Done (0.25.64): sticky combat handoff on follow_lost + never collapse sticky tips to "X is fighting".
|
||||||
|
|
||||||
Soak audit helper: `./scripts/soak-audit-player-log.sh [seconds]` (or pass `--since-bytes N`).
|
Soak audit helper: `./scripts/soak-audit-player-log.sh [seconds]` (or pass `--since-bytes N`).
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ See [`camera-presentability-plan.md`](camera-presentability-plan.md).
|
||||||
| Ambient building falls are B-only | live patch + pipelines `id_drop` |
|
| Ambient building falls are B-only | live patch + pipelines `id_drop` |
|
||||||
| Status-overlap onset without live status | `status_overlap_camera` / `status_overlap_absent` |
|
| Status-overlap onset without live status | `status_overlap_camera` / `status_overlap_absent` |
|
||||||
| Combat tip subject matches focus | `combat_focus` / `tip_matches_focus` |
|
| Combat tip subject matches focus | `combat_focus` / `tip_matches_focus` |
|
||||||
|
| Combat dossier nametag matches focus | `combat_focus` / `dossier_matches_focus` |
|
||||||
|
| Combat 1v1 escalates to sided ensemble | `combat_focus` / `combat_ensemble_escalate` + `tip_matches_any` (`Battle -` / ` vs `) |
|
||||||
| Cold combat clears fight tip | `combat_focus` / `tip_not_contains` fighting |
|
| Cold combat clears fight tip | `combat_focus` / `tip_not_contains` fighting |
|
||||||
|
|
||||||
Live idle soak helper (Player.log window):
|
Live idle soak helper (Player.log window):
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,42 @@ Dossier History peek uses `ActivityRelevance` / `ActivityLog.LatestRelevantForSu
|
||||||
|
|
||||||
Drops (ambient, createsInterest=false, identity filter, below margin, …) go to `InterestDropLog`.
|
Drops (ambient, createsInterest=false, identity filter, below margin, …) go to `InterestDropLog`.
|
||||||
|
|
||||||
|
## Live ensembles (escalating multi-actor)
|
||||||
|
|
||||||
|
Combat is the first consumer of `LiveEnsemble` ([`Events/LiveEnsemble.cs`](../IdleSpectator/Events/LiveEnsemble.cs)).
|
||||||
|
The director refreshes counts, sides, focus, and `EventReason.Combat` while `CombatActive` stays sticky.
|
||||||
|
|
||||||
|
| Scale | Fighters | Typical reason |
|
||||||
|
|-------|----------|----------------|
|
||||||
|
| Pair | ≤2 | `Duel - Rosado vs Waaf` |
|
||||||
|
| Skirmish / Battle | 3+ | `Battle - Essiona (10) vs Northreach (3)` or `Battle - humans (10) vs wolves (3)` |
|
||||||
|
| Mass | ≥8 (`massCrowdThreshold`) | `Mass - humans (12) vs wolves (8)` |
|
||||||
|
|
||||||
|
Optional kingdom brackets on species sides: `Battle - [Essiona] humans (10) vs wolves (3)`.
|
||||||
|
Side framing priority: kingdom vs kingdom → species vs species → count-only melee.
|
||||||
|
Never use unit proper names as mass-side labels (no `Battle - Waf (6) vs Eaderbert (1)`).
|
||||||
|
Same-species camps with no opposing sticky keys use a single pack line (`Mass - Wolves (n)`).
|
||||||
|
Sticky opposing camps lock once and enroll member ids while they fight.
|
||||||
|
Counts stay on those tracked members while alive (combat or not) until combat grace clears the scene - so a wiped side can hit `0` while survivors remain counted.
|
||||||
|
New combatants matching a sticky key are enrolled; dead ids are pruned.
|
||||||
|
Orphan pair tips use `{name} is fighting` instead of `Duel - Name` with no foe.
|
||||||
|
Names are humanized (no raw `nomads_human` ids).
|
||||||
|
Scale hold (~2.5s peak hysteresis) keeps Mass/Battle from flapping to Duel on brief count dips.
|
||||||
|
Combat camera hysteresis only applies between living fighters - sleepers/bystanders never stick.
|
||||||
|
|
||||||
|
### Other families (same snapshot later)
|
||||||
|
|
||||||
|
| Kind | Live signal | Side framing |
|
||||||
|
|------|-------------|--------------|
|
||||||
|
| Combat | attack_target / in_combat cluster | kingdom → species → camps |
|
||||||
|
| WarFront | `WarInterestFeed` + nearby battles | kingdom vs kingdom |
|
||||||
|
| StatusOutbreak | same status id in radius | species / city |
|
||||||
|
| FamilyPack | family_group peers | family / species |
|
||||||
|
| PlotCell | plot members / phase | kingdom / plot type |
|
||||||
|
| DiscoveryCluster | pending first-seens near anchor | species |
|
||||||
|
|
||||||
|
Shared pieces: `LiveEnsemble`, side helpers, `EventReason.Ensemble`, maintain/write-back pattern in `InterestDirector`.
|
||||||
|
|
||||||
## EventReason
|
## EventReason
|
||||||
|
|
||||||
[`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) is the sole Label factory for camera candidates.
|
[`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) is the sole Label factory for camera candidates.
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
# ./scripts/soak-audit-player-log.sh # last 120s wall wait, then audit new bytes
|
# ./scripts/soak-audit-player-log.sh # last 120s wall wait, then audit new bytes
|
||||||
# ./scripts/soak-audit-player-log.sh 60 # wait 60s then audit
|
# ./scripts/soak-audit-player-log.sh 60 # wait 60s then audit
|
||||||
# ./scripts/soak-audit-player-log.sh --since-bytes N # audit from byte offset (no wait)
|
# ./scripts/soak-audit-player-log.sh --since-bytes N # audit from byte offset (no wait)
|
||||||
# ./scripts/soak-audit-player-log.sh --no-wait # audit from current EOF backward? no - from mark file
|
# ./scripts/soak-audit-player-log.sh --since-bytes N 60 # wait 60s, then audit from N
|
||||||
|
# ./scripts/soak-audit-player-log.sh --no-wait # audit empty window from current EOF
|
||||||
#
|
#
|
||||||
# Env:
|
# Env:
|
||||||
# PLAYER_LOG - override Player.log path
|
# PLAYER_LOG - override Player.log path
|
||||||
|
|
@ -46,15 +47,18 @@ if [[ ! -f "$LOG" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
MARKED_FROM_EOF=0
|
||||||
if [[ -z "$SINCE_BYTES" ]]; then
|
if [[ -z "$SINCE_BYTES" ]]; then
|
||||||
SINCE_BYTES=$(wc -c < "$LOG" | tr -d ' ')
|
SINCE_BYTES=$(wc -c < "$LOG" | tr -d ' ')
|
||||||
if [[ "$SECONDS_WAIT" -gt 0 ]]; then
|
MARKED_FROM_EOF=1
|
||||||
echo "Marked offset $SINCE_BYTES; waiting ${SECONDS_WAIT}s (leave Idle Spectator running) ..."
|
fi
|
||||||
sleep "$SECONDS_WAIT"
|
|
||||||
else
|
if [[ "$SECONDS_WAIT" -gt 0 ]]; then
|
||||||
echo "No wait and no --since-bytes: auditing from current EOF (empty window)."
|
echo "Marked offset $SINCE_BYTES; waiting ${SECONDS_WAIT}s (leave Idle Spectator running) ..."
|
||||||
echo "Tip: ./scripts/soak-audit-player-log.sh 60"
|
sleep "$SECONDS_WAIT"
|
||||||
fi
|
elif [[ "$MARKED_FROM_EOF" -eq 1 ]]; then
|
||||||
|
echo "No wait and no --since-bytes: auditing from current EOF (empty window)."
|
||||||
|
echo "Tip: ./scripts/soak-audit-player-log.sh 60"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python3 - "$LOG" "$SINCE_BYTES" <<'PY'
|
python3 - "$LOG" "$SINCE_BYTES" <<'PY'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue