diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md index 6fea818..44138d0 100644 --- a/.cursor/skills/idle-spectator-e2e/SKILL.md +++ b/.cursor/skills/idle-spectator-e2e/SKILL.md @@ -18,6 +18,8 @@ Do not call `Application.Quit()` (can hang Steam on Linux). **Never start, relaunch, or kill WorldBox / Steam from the Cursor sandbox.** - Always run harness commands and WorldBox process control with **unrestricted / `all` permissions**. +- If the Shell tool still wraps the command in `cursorsandbox` after requesting `all`, retry with Smart Mode approval (`request_smart_mode_approval`) so the user can grant unrestricted execution. Sandboxed `steam -applaunch` will hang or produce a broken game. +- Fail fast: if parent cmdline contains `cursorsandbox`, do not call `worldbox-ctl start/restart` - fix permissions first. - Canonical process control (do not invent ad-hoc kill/launch loops): ```bash @@ -30,6 +32,7 @@ Do not call `Application.Quit()` (can hang Steam on Linux). - `scripts/harness-run.sh` may launch the game when it is not already running (same Steam app id). - Prefer `--no-launch` only when `./scripts/worldbox-ctl.sh status` shows WorldBox is already up. - **Never** `pkill -f` with a `worldbox` path in the agent shell command - it can match and kill the agent process itself. Use `worldbox-ctl.sh stop` (`killall worldbox`). +- Do not add agentd/hooks/LD_PRELOAD shims for this. The working path is: simple `worldbox-ctl.sh` + real unrestricted Shell. ## WorldBox wiki (mechanics source of truth) diff --git a/.tmp-reflect/DumpFamily.cs b/.tmp-reflect/DumpFamily.cs new file mode 100644 index 0000000..895529b --- /dev/null +++ b/.tmp-reflect/DumpFamily.cs @@ -0,0 +1,27 @@ +using System; +using System.Linq; +using System.Reflection; +class Program { + static void Main() { + var a = Assembly.LoadFrom("/home/dazed/.local/share/Steam/steamapps/common/worldbox/worldbox_Data/Managed/Assembly-CSharp.dll"); + foreach (var name in new[]{"Family","FamilyManager","Actor"}) { + var t = a.GetType(name); + Console.WriteLine("==== "+name+" ===="); + if (t==null) { Console.WriteLine("missing"); continue; } + Console.WriteLine("Base="+t.BaseType); + foreach (var m in t.GetMembers(BindingFlags.Instance|BindingFlags.Public|BindingFlags.DeclaredOnly) + .Where(x => x.Name.IndexOf("alpha", StringComparison.OrdinalIgnoreCase)>=0 + || x.Name.IndexOf("unit", StringComparison.OrdinalIgnoreCase)>=0 + || x.Name.IndexOf("family", StringComparison.OrdinalIgnoreCase)>=0 + || x.Name=="name" || x.Name=="getName" || x.Name=="getID" || x.Name=="isAlive") + .OrderBy(x=>x.Name)) + Console.WriteLine(" "+m.MemberType+": "+m); + } + var w = a.GetType("World"); + if (w!=null) { + foreach (var m in w.GetMembers(BindingFlags.Instance|BindingFlags.Public|BindingFlags.Static) + .Where(x => x.Name.IndexOf("famil", StringComparison.OrdinalIgnoreCase)>=0)) + Console.WriteLine("World: "+m); + } + } +} diff --git a/.tmp-reflect/DumpFamily.csproj b/.tmp-reflect/DumpFamily.csproj new file mode 100644 index 0000000..283e138 --- /dev/null +++ b/.tmp-reflect/DumpFamily.csproj @@ -0,0 +1,14 @@ + + + Exe + net8.0 + enable + enable + + + + /home/dazed/.local/share/Steam/steamapps/common/worldbox/worldbox_Data/Managed/Assembly-CSharp.dll + false + + + diff --git a/.tmp-reflect/DumpPlot.cs b/.tmp-reflect/DumpPlot.cs new file mode 100644 index 0000000..1932b33 --- /dev/null +++ b/.tmp-reflect/DumpPlot.cs @@ -0,0 +1,42 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +string path = "/home/dazed/.local/share/Steam/steamapps/common/worldbox/worldbox_Data/Managed/Assembly-CSharp.dll"; +using var fs = File.OpenRead(path); +using var pe = new PEReader(fs); +var md = pe.GetMetadataReader(); + +foreach (var typeName in new[]{"Plot","PlotAsset","PlotManager","PlotState"}) { + Console.WriteLine("== "+typeName+" =="); + foreach (var th in md.TypeDefinitions) { + var t = md.GetTypeDefinition(th); + if (md.GetString(t.Name)!=typeName) continue; + Console.WriteLine(" [fields]"); + foreach (var fh in t.GetFields()) { + var f = md.GetFieldDefinition(fh); + var n = md.GetString(f.Name); + if (n.StartsWith("<")) continue; + Console.WriteLine(" field "+n); + } + Console.WriteLine(" [methods]"); + foreach (var mh in t.GetMethods()) { + var m = md.GetMethodDefinition(mh); + var n = md.GetString(m.Name); + if (n is ".ctor" or ".cctor") continue; + var attrs = m.Attributes; + bool pub = (attrs & MethodAttributes.Public) != 0; + if (!pub && !n.StartsWith("get_") && !n.StartsWith("set_")) continue; + Console.WriteLine(" method "+n+" pub="+pub); + } + Console.WriteLine(" [props via get_]"); + foreach (var mh in t.GetMethods()) { + var m = md.GetMethodDefinition(mh); + var n = md.GetString(m.Name); + if (n.StartsWith("get_")) Console.WriteLine(" "+n.Substring(4)); + } + } +} diff --git a/.tmp-reflect/DumpPlot/DumpPlot.csproj b/.tmp-reflect/DumpPlot/DumpPlot.csproj new file mode 100644 index 0000000..64e34a8 --- /dev/null +++ b/.tmp-reflect/DumpPlot/DumpPlot.csproj @@ -0,0 +1,8 @@ + + + Exe + net8.0 + enable + enable + + diff --git a/.tmp-reflect/DumpPlot/Program.cs b/.tmp-reflect/DumpPlot/Program.cs new file mode 100644 index 0000000..948727f --- /dev/null +++ b/.tmp-reflect/DumpPlot/Program.cs @@ -0,0 +1,113 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +string path = "/home/dazed/.local/share/Steam/steamapps/common/worldbox/worldbox_Data/Managed/Assembly-CSharp.dll"; +using var fs = File.OpenRead(path); +using var pe = new PEReader(fs); +var md = pe.GetMetadataReader(); + +string DecodeType(ref BlobReader r) { + var code = r.ReadSignatureTypeCode(); + switch (code) { + case SignatureTypeCode.GenericTypeInstance: + var gen = DecodeType(ref r); + int arity = r.ReadCompressedInteger(); + var args = new string[arity]; + for (int i=0;i"; + case SignatureTypeCode.SZArray: + return DecodeType(ref r)+"[]"; + case SignatureTypeCode.TypeHandle: + var h = r.ReadTypeHandle(); + return Resolve(h); + case SignatureTypeCode.Object: return "object"; + case SignatureTypeCode.String: return "string"; + case SignatureTypeCode.Int32: return "int"; + case SignatureTypeCode.Boolean: return "bool"; + case SignatureTypeCode.Void: return "void"; + default: + return code.ToString(); + } +} +string Resolve(EntityHandle h) { + if (h.Kind==HandleKind.TypeDefinition) { + var t=md.GetTypeDefinition((TypeDefinitionHandle)h); + var ns=md.GetString(t.Namespace); + var n=md.GetString(t.Name); + return string.IsNullOrEmpty(ns)?n:ns+"."+n; + } + if (h.Kind==HandleKind.TypeReference) { + var t=md.GetTypeReference((TypeReferenceHandle)h); + var ns=md.GetString(t.Namespace); + var n=md.GetString(t.Name); + return string.IsNullOrEmpty(ns)?n:ns+"."+n; + } + return h.Kind.ToString(); +} + +foreach (var typeName in new[]{"Plot","Family","War","Army","Clan"}) { + foreach (var th in md.TypeDefinitions) { + var t=md.GetTypeDefinition(th); + if (md.GetString(t.Name)!=typeName) continue; + if (t.BaseType.Kind==HandleKind.TypeSpecification) { + var ts=md.GetTypeSpecification((TypeSpecificationHandle)t.BaseType); + var br=md.GetBlobReader(ts.Signature); + Console.WriteLine(typeName+" : "+DecodeType(ref br)); + } else { + Console.WriteLine(typeName+" : "+Resolve(t.BaseType)); + } + } +} + +// Dump MetaObject members if found by pattern MetaObject* +Console.WriteLine("\nTypes matching MetaObject*:"); +foreach (var th in md.TypeDefinitions) { + var t=md.GetTypeDefinition(th); + var n=md.GetString(t.Name); + if (n.StartsWith("MetaObject") || n.Contains("MetaObject")) { + Console.WriteLine(" "+n+" arity?"); + // list unit-related + foreach (var fh in t.GetFields()) { + var f=md.GetFieldDefinition(fh); + var fn=md.GetString(f.Name); + if (fn.Contains("unit")||fn.Contains("member")||fn.Contains("list")||fn.Contains("hash")||fn.Contains("actor")) + Console.WriteLine(" field "+fn); + } + foreach (var mh in t.GetMethods()) { + var m=md.GetMethodDefinition(mh); + var mn=md.GetString(m.Name); + if (mn.Contains("unit")||mn.Contains("Unit")||mn.Contains("member")||mn.Contains("Member")||mn=="units" || mn.StartsWith("get_units") || mn.StartsWith("get_")) + if ((m.Attributes & System.Reflection.MethodAttributes.Public)!=0) + Console.WriteLine(" method "+mn); + } + } +} + +// Specifically look for getSupporters signature return - decode method sig for Plot.getSupporters +foreach (var th in md.TypeDefinitions) { + var t=md.GetTypeDefinition(th); + if (md.GetString(t.Name)!="Plot") continue; + foreach (var mh in t.GetMethods()) { + var m=md.GetMethodDefinition(mh); + var n=md.GetString(m.Name); + if (n is "getSupporters" or "getAuthor" or "hasSupporter" or "getMaxSupporters" or "getAsset") { + var br=md.GetBlobReader(m.Signature); + // MethodDefSig: callingconv, paramCount, retType, params... + var hdr=br.ReadSignatureHeader(); + int pc=br.ReadCompressedInteger(); + string ret=DecodeType(ref br); + Console.WriteLine("Plot."+n+" -> "+ret+" (params="+pc+")"); + } + } + // field types for targets + foreach (var fh in t.GetFields()) { + var f=md.GetFieldDefinition(fh); + var n=md.GetString(f.Name); + if (n.StartsWith("target_") || n=="_plot_author" || n=="_plot_asset") { + var br=md.GetBlobReader(f.Signature); + var hdr=br.ReadSignatureHeader(); // Field + string ty=DecodeType(ref br); + Console.WriteLine("Plot."+n+" : "+ty); + } + } +} diff --git a/.tmp-reflect/FindMethods.csproj b/.tmp-reflect/FindMethods.csproj new file mode 100644 index 0000000..64e34a8 --- /dev/null +++ b/.tmp-reflect/FindMethods.csproj @@ -0,0 +1,8 @@ + + + Exe + net8.0 + enable + enable + + diff --git a/.tmp-reflect/Program.cs b/.tmp-reflect/Program.cs new file mode 100644 index 0000000..6a52b73 --- /dev/null +++ b/.tmp-reflect/Program.cs @@ -0,0 +1,22 @@ +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +string path = "/home/dazed/.local/share/Steam/steamapps/common/worldbox/worldbox_Data/Managed/Assembly-CSharp.dll"; +using var fs = File.OpenRead(path); +using var pe = new PEReader(fs); +var md = pe.GetMetadataReader(); +foreach (var typeName in new[]{"FamilyManager","PlotManager","ArmyManager","AllianceManager","ClanManager"}) { + Console.WriteLine("== "+typeName+" =="); + foreach (var th in md.TypeDefinitions) { + var t = md.GetTypeDefinition(th); + if (md.GetString(t.Name)!=typeName) continue; + foreach (var mh in t.GetMethods()) { + var m = md.GetMethodDefinition(mh); + var n = md.GetString(m.Name); + if (n.StartsWith("get_")||n.StartsWith("set_")||n is ".ctor") continue; + var attrs = m.Attributes; + if ((attrs & MethodAttributes.Public) == 0) continue; + Console.WriteLine(" "+n); + } + } +} diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index b4d1c60..fe4380d 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -692,6 +692,58 @@ public static class AgentHarness break; } + case "status_apply_nearby": + { + // value = status id; count = how many nearby living units to afflict (default 6). + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + string statusId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "burning"; + float timer = 30f; + if (!string.IsNullOrEmpty(cmd.label) + && float.TryParse(cmd.label, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsedTimer)) + { + timer = parsedTimer; + } + + int want = Math.Max(1, cmd.count > 0 ? cmd.count : 6); + int applied = 0; + if (focus != null && focus.isAlive() && StatusGameApi.TryAddStatus(focus, statusId, timer)) + { + applied++; + } + + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (applied >= want) + { + break; + } + + if (actor == null || actor == focus || !actor.isAlive()) + { + continue; + } + + if (StatusGameApi.TryAddStatus(actor, statusId, timer)) + { + applied++; + } + } + + bool ok = applied >= Math.Min(2, want); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + WatchCaption.ForceRefreshHistory(); + Emit(cmd, ok, detail: $"status={statusId} applied={applied}/{want}"); + break; + } + case "status_remove": { Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; @@ -2000,6 +2052,22 @@ public static class AgentHarness DoInterestCombatSession(cmd); break; + case "interest_war_session": + DoInterestWarSession(cmd); + break; + + case "interest_plot_session": + DoInterestPlotSession(cmd); + break; + + case "interest_family_session": + DoInterestFamilySession(cmd); + break; + + case "interest_outbreak_session": + DoInterestOutbreakSession(cmd); + break; + case "combat_maintain_focus": { InterestDirector.HarnessMaintainCombatFocus(); @@ -2013,10 +2081,78 @@ public static class AgentHarness break; } + case "war_maintain_focus": + { + InterestDirector.HarnessMaintainWarFront(); + string tip = CameraDirector.LastWatchLabel ?? ""; + string focus = FocusLabel(); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"key={InterestDirector.CurrentKey} tip='{tip}' focus={focus} completion={InterestDirector.CurrentCandidate?.Completion}"); + break; + } + + case "plot_maintain_focus": + { + InterestDirector.HarnessMaintainPlotCell(); + string tip = CameraDirector.LastWatchLabel ?? ""; + string focus = FocusLabel(); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"key={InterestDirector.CurrentKey} tip='{tip}' focus={focus} completion={InterestDirector.CurrentCandidate?.Completion}"); + break; + } + + case "family_maintain_focus": + { + InterestDirector.HarnessMaintainFamilyPack(); + string tip = CameraDirector.LastWatchLabel ?? ""; + string focus = FocusLabel(); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"key={InterestDirector.CurrentKey} tip='{tip}' focus={focus} completion={InterestDirector.CurrentCandidate?.Completion}"); + break; + } + + case "outbreak_maintain_focus": + { + InterestDirector.HarnessMaintainStatusOutbreak(); + string tip = CameraDirector.LastWatchLabel ?? ""; + string focus = FocusLabel(); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"key={InterestDirector.CurrentKey} tip='{tip}' focus={focus} completion={InterestDirector.CurrentCandidate?.Completion}"); + break; + } + case "combat_ensemble_escalate": DoCombatEnsembleEscalate(cmd); break; + case "war_ensemble_apply": + DoWarEnsembleApply(cmd); + break; + + case "plot_ensemble_apply": + DoPlotEnsembleApply(cmd); + break; + + case "family_ensemble_apply": + DoFamilyEnsembleApply(cmd); + break; + + case "outbreak_ensemble_apply": + DoOutbreakEnsembleApply(cmd); + break; + case "combat_wire_attack_sides": DoCombatWireAttackSides(cmd); break; @@ -2679,6 +2815,30 @@ public static class AgentHarness float tierEvt = EventStrengthFromTierHint(cmd.tier, 40f); InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.CharacterLed); + ParseInterestInjectOptions( + cmd.value, + out bool forceIgnored, + out InterestLeadKind? leadOpt, + out float eventStrength, + out float charSigIgnored, + out float maxWatchIgnored, + out float ttlIgnored, + out bool resumableIgnored, + out int participantsIgnored, + out int notablesIgnored); + _ = forceIgnored; + _ = charSigIgnored; + _ = maxWatchIgnored; + _ = ttlIgnored; + _ = resumableIgnored; + _ = participantsIgnored; + _ = notablesIgnored; + InterestLeadKind lead = leadOpt ?? tierLead; + if (eventStrength >= 0f) + { + tierEvt = eventStrength; + } + string tipLabel = string.IsNullOrEmpty(cmd.label) ? $"Harness {cmd.tier ?? "scene"}" : cmd.label.Replace("{asset}", follow.asset != null ? follow.asset.id : (assetId ?? "")); @@ -2693,13 +2853,13 @@ public static class AgentHarness AssetId = follow.asset != null ? follow.asset.id : assetId }; - bool eventLedNotice = tierLead == InterestLeadKind.EventLed + bool eventLedNotice = lead == InterestLeadKind.EventLed && tierEvt >= InterestScoringConfig.W.noticeScoreMin; InterestCandidate registered = InterestFeeds.RegisterHarness( follow, interest.Label, keySuffix: (interest.Label ?? "scene").Replace(" ", "_"), - lead: tierLead, + lead: lead, completion: InterestCompletionKind.FixedDwell, forceActive: false, eventStrength: tierEvt, @@ -3607,6 +3767,1126 @@ public static class AgentHarness detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} related={SafeName(related)} tip='{CameraDirector.LastWatchLabel}'"); } + /// + /// Force a WarFront sticky session with kingdom sides (synthetic keys when units lack civs). + /// + private static void DoInterestWarSession(HarnessCommand cmd) + { + if (!WorldReady()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "world_not_ready"); + return; + } + + Actor follow = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset); + if (follow == null) + { + follow = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + } + + if (follow == null) + { + follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + } + + Actor related = null; + if (!string.IsNullOrEmpty(cmd.value) && cmd.value != "auto") + { + related = ResolveUnit(cmd.value); + } + + if (related == null || related == follow) + { + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor != null && actor != follow && actor.isAlive()) + { + related = actor; + break; + } + } + } + + if (follow == null || !follow.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no follow unit for interest_war_session"); + return; + } + + string keyA = LiveEnsemble.KingdomKeyOf(follow); + string keyB = related != null ? LiveEnsemble.KingdomKeyOf(related) : ""; + if (string.IsNullOrEmpty(keyA)) + { + keyA = "Essiona"; + } + + if (string.IsNullOrEmpty(keyB) || keyB.Equals(keyA, StringComparison.OrdinalIgnoreCase)) + { + keyB = "Northreach"; + } + + var ensemble = new LiveEnsemble + { + Kind = EnsembleKind.WarFront, + Frame = EnsembleFrame.KingdomVsKingdom, + Scale = EnsembleScale.Battle, + ParticipantCount = 20, + Focus = follow, + Related = related, + SideA = new EnsembleSide + { + Key = keyA, + Display = LiveEnsemble.KingdomDisplay(keyA), + KingdomDisplay = LiveEnsemble.KingdomDisplay(keyA), + Count = 12, + Best = follow + }, + SideB = new EnsembleSide + { + Key = keyB, + Display = LiveEnsemble.KingdomDisplay(keyB), + KingdomDisplay = LiveEnsemble.KingdomDisplay(keyB), + Count = 8, + Best = related + } + }; + string label = EventReason.WarFront(ensemble); + string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "war_front" : cmd.expect; + InterestCandidate c = InterestFeeds.RegisterHarness( + follow, + label, + keySuffix, + lead: InterestLeadKind.EventLed, + completion: InterestCompletionKind.WarFront, + forceActive: true, + eventStrength: 120f, + characterSignificance: 20f, + maxWatch: 60f, + ttlSeconds: 60f, + related: related, + resumable: true); + if (c != null) + { + c.Category = "War"; + c.AssetId = "live_war"; + c.ClearCombatSticky(); + StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble); + c.ParticipantCount = ensemble.ParticipantCount; + InterestScoring.ScoreCheap(c); + } + + bool ok = InterestDirector.HarnessForceSession(c); + if (ok && c != null) + { + // ForceSession clears sticky for CombatActive only; re-lock war sides. + StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble); + ok = InterestDirector.HarnessApplyWarEnsemble(ensemble); + } + + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} tip='{CameraDirector.LastWatchLabel}' sides={keyA}/{keyB}"); + } + + /// + /// Rewrite sticky war counts on the current WarFront scene. + /// value = "aCount:bCount" (default 12:8). + /// + private static void DoWarEnsembleApply(HarnessCommand cmd) + { + InterestCandidate scene = InterestDirector.CurrentCandidate; + if (scene == null || scene.Completion != InterestCompletionKind.WarFront) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_war_scene"); + return; + } + + Actor focus = scene.FollowUnit; + if (focus == null || !focus.isAlive()) + { + focus = scene.RelatedUnit; + } + + if (focus == null || !focus.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_focus"); + return; + } + + int countA = 12; + int countB = 8; + string raw = (cmd.value ?? "").Trim(); + int colon = raw.IndexOf(':'); + if (colon > 0) + { + int.TryParse(raw.Substring(0, colon), NumberStyles.Integer, CultureInfo.InvariantCulture, out countA); + int.TryParse(raw.Substring(colon + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out countB); + } + + countA = Math.Max(0, countA); + countB = Math.Max(0, countB); + string keyA = !string.IsNullOrEmpty(scene.CombatSideAKey) ? scene.CombatSideAKey : "Essiona"; + string keyB = !string.IsNullOrEmpty(scene.CombatSideBKey) ? scene.CombatSideBKey : "Northreach"; + var ensemble = new LiveEnsemble + { + Kind = EnsembleKind.WarFront, + Frame = EnsembleFrame.KingdomVsKingdom, + Scale = LiveEnsemble.ScaleForCount(Math.Max(3, countA + countB)), + ParticipantCount = countA + countB, + Focus = focus, + Related = scene.RelatedUnit, + SideA = new EnsembleSide + { + Key = keyA, + Display = LiveEnsemble.KingdomDisplay(keyA), + KingdomDisplay = LiveEnsemble.KingdomDisplay(keyA), + Count = countA, + Best = focus + }, + SideB = new EnsembleSide + { + Key = keyB, + Display = LiveEnsemble.KingdomDisplay(keyB), + KingdomDisplay = LiveEnsemble.KingdomDisplay(keyB), + Count = countB, + Best = scene.RelatedUnit + } + }; + // Keep locked keys; only rewrite counts via apply + refresh. + if (!scene.HasStickyCombatSides) + { + StickyScoreboard.TryLockFromEnsemble(scene.Sticky, ensemble); + } + else + { + scene.Sticky.SideACount = countA; + scene.Sticky.SideBCount = countB; + scene.Sticky.PeakParticipants = Math.Max(scene.Sticky.PeakParticipants, countA + countB); + } + + bool ok = InterestDirector.HarnessApplyWarEnsemble(ensemble); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: $"tip='{CameraDirector.LastWatchLabel}' counts={countA}:{countB} participants={scene.ParticipantCount}"); + } + + /// + /// Force a sticky StatusOutbreak session: Outbreak - Status (n). + /// value = status id (default cursed). Seeds live cursed carriers so maintain recounts stick. + /// + private static void DoInterestOutbreakSession(HarnessCommand cmd) + { + Actor follow = ResolveUnit(cmd.asset); + if (follow == null || !follow.isAlive()) + { + follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + } + + if (follow == null || !follow.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no follow unit for interest_outbreak_session"); + return; + } + + string statusId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "cursed"; + if (!StatusOutbreakFeed.IsOutbreakWorthy(statusId)) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "not_outbreak_worthy status=" + statusId); + return; + } + + var carriers = EnsureStatusCarriers(follow, statusId, want: 4); + if (carriers.Count < 2) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "could not seed outbreak carriers status=" + statusId); + return; + } + + Actor related = carriers.Count > 1 ? carriers[1] : null; + string keyA = "status:" + statusId; + int count = carriers.Count; + var ensemble = new LiveEnsemble + { + Kind = EnsembleKind.StatusOutbreak, + Frame = EnsembleFrame.SpeciesVsSpecies, + Scale = LiveEnsemble.ScaleForCount(Math.Max(2, count)), + ParticipantCount = count, + Focus = follow, + Related = related, + SideA = new EnsembleSide + { + Key = keyA, + Display = LiveEnsemble.StatusDisplayName(statusId), + Count = count, + Best = follow + } + }; + for (int i = 0; i < carriers.Count; i++) + { + long id = EventFeedUtil.SafeId(carriers[i]); + if (id != 0) + { + ensemble.ParticipantIds.Add(id); + } + } + + string label = EventReason.StatusOutbreak(ensemble); + string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "status_outbreak" : cmd.expect; + InterestCandidate c = InterestFeeds.RegisterHarness( + follow, + label, + keySuffix, + lead: InterestLeadKind.EventLed, + completion: InterestCompletionKind.StatusOutbreak, + forceActive: true, + eventStrength: 108f, + characterSignificance: 18f, + maxWatch: 45f, + ttlSeconds: 45f, + related: related, + resumable: true); + if (c != null) + { + c.Category = "StatusTransformation"; + c.AssetId = "live_outbreak"; + c.StatusId = statusId; + c.ClearCombatSticky(); + StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble); + c.ParticipantCount = ensemble.ParticipantCount; + InterestScoring.ScoreCheap(c); + } + + bool ok = InterestDirector.HarnessForceSession(c); + if (ok && c != null) + { + StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble); + ok = InterestDirector.HarnessApplyStatusOutbreakEnsemble(ensemble); + } + + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} tip='{CameraDirector.LastWatchLabel}' status={statusId} carriers={count}"); + } + + /// + /// Rewrite sticky outbreak carrier count from live cursed units. value = count (default 4). + /// + private static void DoOutbreakEnsembleApply(HarnessCommand cmd) + { + InterestCandidate scene = InterestDirector.CurrentCandidate; + if (scene == null || scene.Completion != InterestCompletionKind.StatusOutbreak) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_outbreak_scene"); + return; + } + + Actor focus = scene.FollowUnit; + if (focus == null || !focus.isAlive()) + { + focus = scene.RelatedUnit; + } + + if (focus == null || !focus.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_focus"); + return; + } + + int want = 4; + string raw = (cmd.value ?? "").Trim(); + if (!string.IsNullOrEmpty(raw)) + { + int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out want); + } + + want = Math.Max(0, want); + string statusId = !string.IsNullOrEmpty(scene.StatusId) ? scene.StatusId : "cursed"; + var carriers = EnsureStatusCarriers(focus, statusId, want); + int count = carriers.Count; + Actor related = null; + for (int i = 0; i < carriers.Count; i++) + { + if (carriers[i] != null && carriers[i] != focus && carriers[i].isAlive()) + { + related = carriers[i]; + break; + } + } + + string keyA = !string.IsNullOrEmpty(scene.CombatSideAKey) + ? scene.CombatSideAKey + : "status:" + statusId; + string display = !string.IsNullOrEmpty(scene.Sticky?.SideADisplay) + ? scene.Sticky.SideADisplay + : LiveEnsemble.StatusDisplayName(statusId); + var ensemble = new LiveEnsemble + { + Kind = EnsembleKind.StatusOutbreak, + Frame = EnsembleFrame.SpeciesVsSpecies, + Scale = LiveEnsemble.ScaleForCount(Math.Max(2, count)), + ParticipantCount = count, + Focus = focus, + Related = related ?? scene.RelatedUnit, + SideA = new EnsembleSide + { + Key = keyA, + Display = display, + Count = count, + Best = focus + } + }; + for (int i = 0; i < carriers.Count; i++) + { + long id = EventFeedUtil.SafeId(carriers[i]); + if (id != 0) + { + ensemble.ParticipantIds.Add(id); + } + } + + if (scene.Sticky != null) + { + scene.Sticky.SideAIds.Clear(); + for (int i = 0; i < carriers.Count; i++) + { + long id = EventFeedUtil.SafeId(carriers[i]); + if (id != 0) + { + scene.Sticky.SideAIds.Add(id); + } + } + + scene.Sticky.SideACount = count; + scene.Sticky.SideADisplay = display; + scene.Sticky.PeakParticipants = Math.Max(scene.Sticky.PeakParticipants, count); + } + + if (!scene.HasStickyCombatSides) + { + StickyScoreboard.TryLockFromEnsemble(scene.Sticky, ensemble); + } + + bool ok = InterestDirector.HarnessApplyStatusOutbreakEnsemble(ensemble); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: $"tip='{CameraDirector.LastWatchLabel}' count={count} want={want} participants={scene.ParticipantCount}"); + } + + /// + /// Ensure exactly living units near carry + /// (clears extras so maintain/CollectStatusCarriers cannot inflate). + /// Spawns same-species units when the map is short. + /// + private static List EnsureStatusCarriers(Actor focus, string statusId, int want) + { + var keep = new List(); + if (focus == null || !focus.isAlive() || string.IsNullOrEmpty(statusId) || want <= 0) + { + return keep; + } + + StatusGameApi.TryAddStatus(focus, statusId, overrideTimer: 120f); + if (StatusGameApi.HasStatus(focus, statusId)) + { + keep.Add(focus); + } + + string assetId = focus.asset != null ? focus.asset.id : "human"; + WorldTile tile = null; + try + { + tile = World.world.GetTile( + Mathf.RoundToInt(focus.current_position.x), + Mathf.RoundToInt(focus.current_position.y)); + } + catch + { + tile = TileNearCamera(); + } + + var nearby = new List(); + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor == null || actor == focus || !actor.isAlive()) + { + continue; + } + + nearby.Add(actor); + } + + for (int i = 0; i < nearby.Count && keep.Count < want; i++) + { + Actor actor = nearby[i]; + StatusGameApi.TryAddStatus(actor, statusId, overrideTimer: 120f); + if (StatusGameApi.HasStatus(actor, statusId) && !keep.Contains(actor)) + { + keep.Add(actor); + } + } + + int guard = 0; + while (keep.Count < want && tile != null && guard++ < want + 8) + { + Actor spawned = World.world.units.spawnNewUnit( + assetId, + tile, + pSpawnSound: false, + pMiracleSpawn: true); + if (spawned == null || !spawned.isAlive()) + { + continue; + } + + StatusGameApi.TryAddStatus(spawned, statusId, overrideTimer: 120f); + if (StatusGameApi.HasStatus(spawned, statusId) && !keep.Contains(spawned)) + { + keep.Add(spawned); + } + } + + // Clear the status from everyone else so Refresh cannot re-inflate the tip. + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor == null || !actor.isAlive() || keep.Contains(actor)) + { + continue; + } + + if (StatusGameApi.HasStatus(actor, statusId)) + { + StatusGameApi.TryFinishStatus(actor, statusId); + } + } + + return keep; + } + + /// + /// Ensure living same-species units are enrolled as a pack roster. + /// Spawns when needed so maintain recounts keep the tip count. + /// + private static List EnsurePackMembers(Actor focus, int want) + { + var members = new List(); + if (focus == null || !focus.isAlive() || want <= 0) + { + return members; + } + + members.Add(focus); + string species = LiveEnsemble.SpeciesKeyOf(focus); + string assetId = focus.asset != null ? focus.asset.id : "human"; + WorldTile tile = null; + try + { + tile = World.world.GetTile( + Mathf.RoundToInt(focus.current_position.x), + Mathf.RoundToInt(focus.current_position.y)); + } + catch + { + tile = TileNearCamera(); + } + + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (members.Count >= want) + { + break; + } + + if (actor == null || actor == focus || !actor.isAlive()) + { + continue; + } + + if (!string.IsNullOrEmpty(species) + && !string.Equals(LiveEnsemble.SpeciesKeyOf(actor), species, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + members.Add(actor); + } + + int guard = 0; + while (members.Count < want && tile != null && guard++ < want + 8) + { + Actor spawned = World.world.units.spawnNewUnit( + assetId, + tile, + pSpawnSound: false, + pMiracleSpawn: true); + if (spawned != null && spawned.isAlive() && !members.Contains(spawned)) + { + members.Add(spawned); + } + } + + if (members.Count > want) + { + members.RemoveRange(want, members.Count - want); + } + + return members; + } + + /// + /// Force a sticky FamilyPack session: Pack - Species (n) · gathering. + /// Seeds a live same-species roster so maintain recounts keep the tip count. + /// + private static void DoInterestFamilySession(HarnessCommand cmd) + { + Actor follow = ResolveUnit(cmd.asset); + if (follow == null || !follow.isAlive()) + { + follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + } + + if (follow == null || !follow.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no follow unit for interest_family_session"); + return; + } + + var members = EnsurePackMembers(follow, want: 5); + if (members.Count < 2) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "could not seed pack members"); + return; + } + + Actor related = members.Count > 1 ? members[1] : null; + if (!string.IsNullOrEmpty(cmd.value) && cmd.value != "auto") + { + Actor named = ResolveUnit(cmd.value); + if (named != null && named.isAlive() && named != follow) + { + related = named; + if (!members.Contains(named)) + { + members.Add(named); + } + } + } + + string speciesKey = LiveEnsemble.SpeciesKeyOf(follow); + if (string.IsNullOrEmpty(speciesKey)) + { + speciesKey = "human"; + } + + string packKey = "family:harness"; + int count = members.Count; + var ensemble = new LiveEnsemble + { + Kind = EnsembleKind.FamilyPack, + Frame = EnsembleFrame.SpeciesVsSpecies, + Scale = LiveEnsemble.ScaleForCount(Math.Max(2, count)), + ParticipantCount = count, + Focus = follow, + Related = related, + SideA = new EnsembleSide + { + Key = packKey, + Display = LiveEnsemble.SpeciesDisplay(speciesKey), + Count = count, + Best = follow + } + }; + for (int i = 0; i < members.Count; i++) + { + long id = EventFeedUtil.SafeId(members[i]); + if (id != 0) + { + ensemble.ParticipantIds.Add(id); + } + } + + string label = EventReason.FamilyPack(ensemble); + string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "family_pack" : cmd.expect; + InterestCandidate c = InterestFeeds.RegisterHarness( + follow, + label, + keySuffix, + lead: InterestLeadKind.EventLed, + completion: InterestCompletionKind.FamilyPack, + forceActive: true, + // Match live soft pack band so unit events can still cut in during playtests. + eventStrength: 48f, + characterSignificance: 12f, + maxWatch: 12f, + ttlSeconds: 20f, + related: related, + resumable: true); + if (c != null) + { + c.Category = "Relationship"; + c.AssetId = "live_family"; + c.ClearCombatSticky(); + StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble); + c.ParticipantCount = ensemble.ParticipantCount; + InterestScoring.ScoreCheap(c); + } + + bool ok = InterestDirector.HarnessForceSession(c); + if (ok && c != null) + { + StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble); + ok = InterestDirector.HarnessApplyFamilyEnsemble(ensemble); + } + + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} tip='{CameraDirector.LastWatchLabel}' pack={packKey} members={count}"); + } + + /// + /// Rewrite sticky family pack count from a live same-species roster. + /// value = member count (default 5). + /// + private static void DoFamilyEnsembleApply(HarnessCommand cmd) + { + InterestCandidate scene = InterestDirector.CurrentCandidate; + if (scene == null || scene.Completion != InterestCompletionKind.FamilyPack) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_family_scene"); + return; + } + + Actor focus = scene.FollowUnit; + if (focus == null || !focus.isAlive()) + { + focus = scene.RelatedUnit; + } + + if (focus == null || !focus.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_focus"); + return; + } + + int want = 5; + string raw = (cmd.value ?? "").Trim(); + if (!string.IsNullOrEmpty(raw)) + { + int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out want); + } + + want = Math.Max(0, want); + var members = EnsurePackMembers(focus, want); + int count = members.Count; + Actor related = null; + for (int i = 0; i < members.Count; i++) + { + if (members[i] != null && members[i] != focus && members[i].isAlive()) + { + related = members[i]; + break; + } + } + + // Solo dip: drop related so Refresh/SeedFromEnsemble cannot re-inflate to 2. + if (count <= 1) + { + related = null; + scene.RelatedUnit = null; + scene.RelatedId = 0; + } + + string keyA = !string.IsNullOrEmpty(scene.CombatSideAKey) ? scene.CombatSideAKey : "family:harness"; + string species = LiveEnsemble.SpeciesKeyOf(focus); + string display = !string.IsNullOrEmpty(scene.Sticky?.SideADisplay) + ? scene.Sticky.SideADisplay + : LiveEnsemble.SpeciesDisplay(species); + var ensemble = new LiveEnsemble + { + Kind = EnsembleKind.FamilyPack, + Frame = EnsembleFrame.SpeciesVsSpecies, + Scale = LiveEnsemble.ScaleForCount(Math.Max(2, count)), + ParticipantCount = count, + Focus = focus, + Related = related, + SideA = new EnsembleSide + { + Key = keyA, + Display = display, + Count = count, + Best = focus + } + }; + for (int i = 0; i < members.Count; i++) + { + long id = EventFeedUtil.SafeId(members[i]); + if (id != 0) + { + ensemble.ParticipantIds.Add(id); + } + } + + if (scene.Sticky != null) + { + scene.Sticky.SideAIds.Clear(); + for (int i = 0; i < members.Count; i++) + { + long id = EventFeedUtil.SafeId(members[i]); + if (id != 0) + { + scene.Sticky.SideAIds.Add(id); + } + } + + scene.Sticky.SideACount = count; + scene.Sticky.SideADisplay = display; + scene.Sticky.PeakParticipants = Math.Max(scene.Sticky.PeakParticipants, count); + } + + if (!scene.HasStickyCombatSides) + { + StickyScoreboard.TryLockFromEnsemble(scene.Sticky, ensemble); + } + + bool ok = InterestDirector.HarnessApplyFamilyEnsemble(ensemble); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: $"tip='{CameraDirector.LastWatchLabel}' count={count} want={want} participants={scene.ParticipantCount}"); + } + + /// + /// Force a sticky PlotActive session: Plotters vs a target kingdom key. + /// + private static void DoInterestPlotSession(HarnessCommand cmd) + { + Actor follow = ResolveUnit(cmd.asset); + if (follow == null || !follow.isAlive()) + { + follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + } + + Actor related = null; + if (!string.IsNullOrEmpty(cmd.value) && cmd.value != "auto") + { + related = ResolveUnit(cmd.value); + } + + if (related == null || related == follow) + { + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor != null && actor != follow && actor.isAlive()) + { + related = actor; + break; + } + } + } + + if (follow == null || !follow.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no follow unit for interest_plot_session"); + return; + } + + // Seed a real plotter roster (2+) so PlotCell sticky / completion stay group-shaped. + var plotters = EnsurePackMembers(follow, want: 3); + if (plotters.Count < 2) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "could not seed plotters"); + return; + } + + string keyA = "plot:rebellion"; + string keyB = related != null ? LiveEnsemble.KingdomKeyOf(related) : ""; + if (string.IsNullOrEmpty(keyB) || LiveEnsemble.IsPlotterSideKey(keyB)) + { + keyB = "Essiona"; + } + + int countA = plotters.Count; + var ensemble = new LiveEnsemble + { + Kind = EnsembleKind.PlotCell, + Frame = EnsembleFrame.KingdomVsKingdom, + Scale = EnsembleScale.Skirmish, + ParticipantCount = countA + 12, + Focus = follow, + Related = related, + SideA = new EnsembleSide + { + Key = keyA, + Display = "Plotters", + Count = countA, + Best = follow + }, + SideB = new EnsembleSide + { + Key = keyB, + Display = LiveEnsemble.KingdomDisplay(keyB), + KingdomDisplay = LiveEnsemble.KingdomDisplay(keyB), + Count = 12, + Best = related + } + }; + for (int i = 0; i < plotters.Count; i++) + { + long id = EventFeedUtil.SafeId(plotters[i]); + if (id != 0) + { + ensemble.ParticipantIds.Add(id); + } + } + + string label = EventReason.PlotCell(ensemble); + string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "plot_cell" : cmd.expect; + InterestCandidate c = InterestFeeds.RegisterHarness( + follow, + label, + keySuffix, + lead: InterestLeadKind.EventLed, + completion: InterestCompletionKind.PlotActive, + forceActive: true, + eventStrength: 115f, + characterSignificance: 20f, + maxWatch: 50f, + ttlSeconds: 50f, + related: related, + resumable: true); + if (c != null) + { + c.Category = "Politics"; + c.AssetId = "live_plot"; + c.ClearCombatSticky(); + StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble); + c.ParticipantCount = ensemble.ParticipantCount; + InterestScoring.ScoreCheap(c); + } + + bool ok = InterestDirector.HarnessForceSession(c); + if (ok && c != null) + { + StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble); + ok = InterestDirector.HarnessApplyPlotEnsemble(ensemble); + } + + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} tip='{CameraDirector.LastWatchLabel}' sides={keyA}/{keyB}"); + } + + /// + /// Rewrite sticky plot counts on the current PlotActive scene. + /// value = "aCount:bCount" (default 3:12). + /// + private static void DoPlotEnsembleApply(HarnessCommand cmd) + { + InterestCandidate scene = InterestDirector.CurrentCandidate; + if (scene == null || scene.Completion != InterestCompletionKind.PlotActive) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_plot_scene"); + return; + } + + Actor focus = scene.FollowUnit; + if (focus == null || !focus.isAlive()) + { + focus = scene.RelatedUnit; + } + + if (focus == null || !focus.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_focus"); + return; + } + + int countA = 3; + int countB = 12; + string raw = (cmd.value ?? "").Trim(); + int colon = raw.IndexOf(':'); + if (colon > 0) + { + int.TryParse(raw.Substring(0, colon), NumberStyles.Integer, CultureInfo.InvariantCulture, out countA); + int.TryParse(raw.Substring(colon + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out countB); + } + + countA = Math.Max(0, countA); + countB = Math.Max(0, countB); + // Resize the living plotter roster so maintain recount matches the requested count. + var plotters = EnsurePackMembers(focus, want: Math.Max(1, countA)); + if (countA >= 2 && plotters.Count < 2) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "could not seed plotters for count=" + countA); + return; + } + + if (plotters.Count > countA) + { + plotters.RemoveRange(countA, plotters.Count - countA); + } + + countA = plotters.Count; + string keyA = !string.IsNullOrEmpty(scene.CombatSideAKey) ? scene.CombatSideAKey : "plot:rebellion"; + string keyB = !string.IsNullOrEmpty(scene.CombatSideBKey) ? scene.CombatSideBKey : "Essiona"; + var ensemble = new LiveEnsemble + { + Kind = EnsembleKind.PlotCell, + Frame = EnsembleFrame.KingdomVsKingdom, + Scale = LiveEnsemble.ScaleForCount(Math.Max(3, countA + countB)), + ParticipantCount = countA + countB, + Focus = focus, + Related = scene.RelatedUnit, + SideA = new EnsembleSide + { + Key = keyA, + Display = "Plotters", + Count = countA, + Best = focus + }, + SideB = new EnsembleSide + { + Key = keyB, + Display = LiveEnsemble.KingdomDisplay(keyB), + KingdomDisplay = LiveEnsemble.KingdomDisplay(keyB), + Count = countB, + Best = scene.RelatedUnit + } + }; + for (int i = 0; i < plotters.Count; i++) + { + long id = EventFeedUtil.SafeId(plotters[i]); + if (id != 0) + { + ensemble.ParticipantIds.Add(id); + } + } + + if (scene.Sticky != null) + { + scene.Sticky.SideAIds.Clear(); + for (int i = 0; i < plotters.Count; i++) + { + long id = EventFeedUtil.SafeId(plotters[i]); + if (id != 0) + { + scene.Sticky.SideAIds.Add(id); + } + } + + scene.Sticky.SideACount = countA; + scene.Sticky.SideBCount = countB; + scene.Sticky.SideADisplay = "Plotters"; + scene.Sticky.PeakParticipants = Math.Max(scene.Sticky.PeakParticipants, countA + countB); + } + + if (!scene.HasStickyCombatSides) + { + StickyScoreboard.TryLockFromEnsemble(scene.Sticky, ensemble); + } + + bool ok = InterestDirector.HarnessApplyPlotEnsemble(ensemble); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: $"tip='{CameraDirector.LastWatchLabel}' counts={countA}:{countB} participants={scene.ParticipantCount}"); + } + /// /// 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. @@ -4668,6 +5948,48 @@ public static class AgentHarness detail = $"reason='{reason}' empty={isEmpty} wantEmpty={wantEmpty} active={InterestDirector.CurrentIsActive} quiet={InterestDirector.InQuietGrace}"; break; } + case "reason_matches_any": + { + // value = pipe-separated needles; orange ReasonLine must contain at least one. + string raw = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value; + string reason = WatchCaption.Current?.ReasonLine ?? ""; + 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 + && reason.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) + { + pass = true; + break; + } + } + } + + detail = $"reason='{reason}' any='{raw}' pass={pass}"; + break; + } + case "outbreak_worthy": + { + string statusId = (cmd.value ?? "").Trim(); + bool want = ParseBool(cmd.label, defaultValue: true); + bool have = StatusOutbreakFeed.IsOutbreakWorthy(statusId); + pass = have == want; + detail = $"status={statusId} worthy={have} want={want}"; + break; + } + case "decision_worthy": + { + string decisionId = (cmd.value ?? "").Trim(); + bool want = ParseBool(cmd.label, defaultValue: true); + bool have = EventCatalog.Decision.IsCameraWorthy(decisionId); + pass = have == want; + detail = $"decision={decisionId} worthy={have} want={want}"; + break; + } case "dossier_task_refresh": { // Stale focus snapshot must recover: blank dossier TaskText, then sync from live AI. @@ -7118,6 +8440,10 @@ public static class AgentHarness || tip.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase) || tip.StartsWith("Battle", StringComparison.OrdinalIgnoreCase) || tip.StartsWith("Mass", StringComparison.OrdinalIgnoreCase) + || tip.StartsWith("War -", StringComparison.OrdinalIgnoreCase) + || tip.StartsWith("Plot -", StringComparison.OrdinalIgnoreCase) + || tip.StartsWith("Pack -", StringComparison.OrdinalIgnoreCase) + || tip.StartsWith("Outbreak -", StringComparison.OrdinalIgnoreCase) || tip.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0; } diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs index ba54b9a..b20d2a0 100644 --- a/IdleSpectator/CameraDirector.cs +++ b/IdleSpectator/CameraDirector.cs @@ -1,3 +1,4 @@ +using System; using NeoModLoader.services; using UnityEngine; @@ -34,11 +35,20 @@ public static class CameraDirector } string tip = FormatWatchTip(interest); + string label = interest.Label ?? ""; + string assetId = interest.AssetId ?? ""; + bool tipChanged = !string.Equals(tip, LastFormattedWatchTip, StringComparison.Ordinal) + || !string.Equals(label, LastWatchLabel, StringComparison.Ordinal) + || !string.Equals(assetId, LastWatchAssetId, StringComparison.Ordinal); LastFormattedWatchTip = tip; // LastWatchLabel is tip/harness telemetry only - never dossier reason truth. - LastWatchLabel = interest.Label ?? ""; - LastWatchAssetId = interest.AssetId ?? ""; - LogService.LogInfo($"[IdleSpectator] {tip}"); + LastWatchLabel = label; + LastWatchAssetId = assetId; + // Sticky maintain can call Watch every tick - only log when the tip changes. + if (tipChanged) + { + LogService.LogInfo($"[IdleSpectator] {tip}"); + } Actor follow = ResolveFollowUnit(interest); if (follow != null && follow.isAlive()) diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs b/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs index ff712d6..86ee072 100644 --- a/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs +++ b/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs @@ -89,6 +89,13 @@ public static partial class EventCatalog return false; } + // Intent-only: setDecisionCooldown fires before a Plot exists (and often never does). + // Real plot tips come from PlotInterestFeed / live World.plots. + if (s == "try_new_plot" || s.StartsWith("try_new_plot_", StringComparison.Ordinal)) + { + return false; + } + return HasToken(s, "war") || s.Contains("rebellion") || s.Contains("revolt") diff --git a/IdleSpectator/Events/EventReason.cs b/IdleSpectator/Events/EventReason.cs index b58620f..5acdf9b 100644 --- a/IdleSpectator/Events/EventReason.cs +++ b/IdleSpectator/Events/EventReason.cs @@ -198,7 +198,89 @@ public static class EventReason return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")"; } - /// Dispatch by ensemble kind (combat first; other kinds plug in later). + /// + /// War front orange reason: War - Essiona (120) vs Northreach (80). + /// Kingdom sides only; never unit proper names. + /// + public static string WarFront(LiveEnsemble ensemble) + { + if (ensemble == null || !ensemble.HasFocus) + { + return ""; + } + + if (!LiveEnsemble.HasOpposingCollectiveSides(ensemble)) + { + return ""; + } + + string sideA = FormatCombatSide(ensemble.SideA, EnsembleFrame.KingdomVsKingdom); + string sideB = FormatCombatSide(ensemble.SideB, EnsembleFrame.KingdomVsKingdom); + if (string.IsNullOrEmpty(sideA) || string.IsNullOrEmpty(sideB)) + { + return ""; + } + + return "War - " + sideA + " vs " + sideB; + } + + /// + /// Plot cell orange reason: Plot - Plotters (3) vs Essiona (120). + /// Side A is a collective plotter camp; Side B is the target kingdom. Never unit names. + /// + public static string PlotCell(LiveEnsemble ensemble) + { + if (ensemble == null || !ensemble.HasFocus) + { + return ""; + } + + if (!LiveEnsemble.HasOpposingCollectiveSides(ensemble)) + { + return ""; + } + + // Groups only - never "Plot - Plotters (1) …" / (0). + if (ensemble.SideA == null || ensemble.SideA.Count < 2) + { + return ""; + } + + string sideA = FormatPlotterSide(ensemble.SideA); + string sideB = FormatCombatSide(ensemble.SideB, EnsembleFrame.KingdomVsKingdom); + if (string.IsNullOrEmpty(sideA) || string.IsNullOrEmpty(sideB)) + { + return ""; + } + + return "Plot - " + sideA + " vs " + sideB; + } + + private static string FormatPlotterSide(EnsembleSide side) + { + if (side == null) + { + return ""; + } + + string label = side.Display ?? ""; + if (string.IsNullOrEmpty(label) || LiveEnsemble.IsPlotterSideKey(label)) + { + label = "Plotters"; + } + + // Refuse accidental unit proper names (no spaces-only short names from actors). + if (label.IndexOf(' ') >= 0 && !label.Equals("Plotters", StringComparison.OrdinalIgnoreCase)) + { + label = "Plotters"; + } + + label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : ""); + int n = Math.Max(0, side.Count); + return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")"; + } + + /// Dispatch by ensemble kind (combat / war / plot; other kinds plug in later). public static string Ensemble(LiveEnsemble ensemble) { if (ensemble == null) @@ -208,6 +290,14 @@ public static class EventReason switch (ensemble.Kind) { + case EnsembleKind.WarFront: + return WarFront(ensemble); + case EnsembleKind.PlotCell: + return PlotCell(ensemble); + case EnsembleKind.FamilyPack: + return FamilyPack(ensemble); + case EnsembleKind.StatusOutbreak: + return StatusOutbreak(ensemble); case EnsembleKind.Combat: return Combat(ensemble); default: @@ -275,6 +365,46 @@ public static class EventReason } } + /// + /// Sticky family pack orange reason: Pack - Wolves (5) · gathering. + /// Species-framed collective + why the camera holds; never unit proper names. + /// + public static string FamilyPack(LiveEnsemble ensemble) + { + if (ensemble == null || !ensemble.HasFocus || ensemble.SideA == null) + { + return ""; + } + + if (ensemble.Kind != EnsembleKind.FamilyPack + && !LiveEnsemble.IsFamilyPackSideKey(ensemble.SideA.Key)) + { + return ""; + } + + string label = ensemble.SideA.Display ?? ""; + if (string.IsNullOrEmpty(label) || LiveEnsemble.IsFamilyPackSideKey(label)) + { + label = LiveEnsemble.SpeciesDisplay(LiveEnsemble.SpeciesKeyOf(ensemble.Focus)); + } + + if (string.IsNullOrEmpty(label)) + { + label = "Kin"; + } + + // Refuse accidental unit proper names (spaces usually mean a person name). + if (label.IndexOf(' ') >= 0) + { + string species = LiveEnsemble.SpeciesDisplay(LiveEnsemble.SpeciesKeyOf(ensemble.Focus)); + label = string.IsNullOrEmpty(species) ? "Kin" : species; + } + + label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : ""); + int n = Math.Max(0, ensemble.SideA.Count); + return "Pack - " + label + " (" + n.ToString(CultureInfo.InvariantCulture) + ") · gathering"; + } + public static string NewChild(Actor a, Actor b) { string an = NameOrSomeone(a); @@ -373,6 +503,54 @@ public static class EventReason return an + " · " + p; } + /// + /// Sticky status outbreak orange reason: Outbreak - Cursed (4). + /// Collective status label + carrier count; never unit proper names. + /// + public static string StatusOutbreak(LiveEnsemble ensemble) + { + if (ensemble == null || !ensemble.HasFocus || ensemble.SideA == null) + { + return ""; + } + + if (ensemble.Kind != EnsembleKind.StatusOutbreak + && !LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key)) + { + return ""; + } + + string label = ensemble.SideA.Display ?? ""; + if (string.IsNullOrEmpty(label) || LiveEnsemble.IsStatusOutbreakSideKey(label)) + { + string statusId = LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key) + ? ensemble.SideA.Key.Substring("status:".Length) + : ""; + label = LiveEnsemble.StatusDisplayName(statusId); + } + + if (string.IsNullOrEmpty(label)) + { + label = "Afflicted"; + } + + // Prefer noun labels ("Cursed") over accidental sentence crumbs. + label = label.Trim(); + if (label.StartsWith("Is ", StringComparison.OrdinalIgnoreCase)) + { + label = label.Substring(3).Trim(); + } + + if (string.IsNullOrEmpty(label)) + { + label = "Afflicted"; + } + + label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : ""); + int n = Math.Max(0, ensemble.SideA.Count); + return "Outbreak - " + label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")"; + } + public static string Trait(Actor a, string traitId, bool gained) { string an = NameOrSomeone(a); diff --git a/IdleSpectator/Events/Feeds/FamilyInterestFeed.cs b/IdleSpectator/Events/Feeds/FamilyInterestFeed.cs new file mode 100644 index 0000000..a4e45c1 --- /dev/null +++ b/IdleSpectator/Events/Feeds/FamilyInterestFeed.cs @@ -0,0 +1,172 @@ +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Soft FamilyPack sticky tips. Cold-starts only from real relationship mutations +/// (); only refreshes the current pack scene. +/// Join/leave/form one-shot prose stays on . +/// +public static class FamilyInterestFeed +{ + /// Warm-social band - below notice milestones so unit events can cut in. + private const float PackEventStrength = 44f; + + public static void Tick() + { + if (!AgentHarness.LiveFeedsAllowed) + { + return; + } + + // Do not poll every living family - that hogged the camera. + // Only keep the active pack scene fresh while FamilyPack owns the shot. + InterestCandidate current = InterestDirector.CurrentCandidate; + if (current == null || current.Completion != InterestCompletionKind.FamilyPack) + { + return; + } + + Actor follow = current.FollowUnit; + if (follow == null || !follow.isAlive()) + { + return; + } + + try + { + if (follow.hasFamily() && follow.family != null) + { + TryRegisterFamily(follow.family, softRefresh: true); + } + } + catch + { + // ignore + } + } + + /// + /// After a join/form beat, optionally open a soft sticky pack scene for the living family. + /// + public static void EmitFromActor(Actor subject) + { + if (!AgentHarness.LiveFeedsAllowed || subject == null || !subject.isAlive()) + { + return; + } + + try + { + if (!subject.hasFamily() || subject.family == null) + { + return; + } + + TryRegisterFamily(subject.family, softRefresh: false); + } + catch + { + // ignore + } + } + + private static bool TryRegisterFamily(Family family, bool softRefresh) + { + if (!LiveEnsemble.TryBuildFamily(family, out LiveEnsemble ensemble) || ensemble == null) + { + return false; + } + + // Solo families are not a pack scene - leave to FixedDwell phase tips. + if (ensemble.SideA == null || ensemble.SideA.Count < 2) + { + return false; + } + + Actor follow = ensemble.Focus; + if (follow == null || !follow.isAlive()) + { + return false; + } + + DiscreteEventEntry entry = EventCatalog.Relationship.GetOrFallback("family_group_join"); + if (!EventCatalog.IsCameraWorthy(entry)) + { + InterestDropLog.Record("createsInterest=false", "family_pack"); + return false; + } + + string familyId = ""; + try + { + familyId = family.getID().ToString(); + } + catch + { + familyId = ""; + } + + string key = "family:pack:" + (familyId ?? EventFeedUtil.SafeId(follow).ToString()); + string label = EventReason.FamilyPack(ensemble); + if (string.IsNullOrEmpty(label)) + { + return false; + } + + Vector3 position = Vector3.zero; + try + { + position = follow.current_position; + } + catch + { + position = Vector3.zero; + } + + // Soft cluster: short hold, warm-social strength (never combat-class 82). + float strength = PackEventStrength; + if (entry.EventStrength > 0f && entry.EventStrength < PackEventStrength) + { + strength = Mathf.Clamp(entry.EventStrength, 28f, PackEventStrength); + } + + InterestCandidate registered = EventFeedUtil.Register( + key, + "relationship", + entry.Category, + label, + "live_family", + strength, + position, + follow, + related: ensemble.Related, + minWatch: 4f, + maxWatch: 12f, + completion: InterestCompletionKind.FamilyPack); + if (registered == null) + { + return false; + } + + registered.CorrelationKey = key; + StickyScoreboard.TryLockFromEnsemble(registered.Sticky, ensemble); + StickyScoreboard.Refresh( + registered, + ensemble, + position, + StickyScoreboard.FamilyTheaterRadius); + registered.Label = EventReason.FamilyPack(ensemble); + registered.ParticipantCount = ensemble.ParticipantCount; + registered.AssetId = "live_family"; + // Pack size must not inflate TotalScore via combat mass bonuses. + registered.NotableParticipantCount = 0; + InterestScoring.ScoreCheap(registered); + if (!softRefresh) + { + InterestDropLog.Record("family_pack_open", key); + } + + return true; + } +} diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs index 18aec05..47ba1d6 100644 --- a/IdleSpectator/Events/Feeds/InterestFeeds.cs +++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs @@ -493,6 +493,8 @@ public static class InterestFeeds { registered.StatusId = statusId; registered.LastSeenAt = Time.unscaledTime; + // Cluster of carriers → sticky outbreak tip instead of thin single-unit prose. + StatusOutbreakFeed.TryUpgradeFromEmit(actor, statusId, registered); } } @@ -1080,6 +1082,12 @@ public static class InterestFeeds // Active plots from PlotManager / World.plots. PlotInterestFeed.Tick(); + // Active family packs (2+ members) from World.families. + FamilyInterestFeed.Tick(); + + // Nearby multi-carrier status clusters. + StatusOutbreakFeed.Tick(); + // Character-led vignette seeds (not sole one-best monopoly: register best + a few notables). WorldActivityScanner.RegisterTopCharacterCandidates(max: 4); } diff --git a/IdleSpectator/Events/Feeds/PlotInterestFeed.cs b/IdleSpectator/Events/Feeds/PlotInterestFeed.cs index f6950ae..72b757b 100644 --- a/IdleSpectator/Events/Feeds/PlotInterestFeed.cs +++ b/IdleSpectator/Events/Feeds/PlotInterestFeed.cs @@ -1,10 +1,14 @@ +using System; using System.Collections; using System.Reflection; using UnityEngine; namespace IdleSpectator; -/// Registers plot interest from Harmony hooks and World.plots polling. +/// +/// Registers plot interest from Harmony hooks and World.plots polling. +/// Active plots with a target kingdom use sticky PlotCell tips; begin/end stay FixedDwell. +/// public static class PlotInterestFeed { public static void Emit(string plotAssetId, Actor subject, string phase) @@ -43,14 +47,12 @@ public static class PlotInterestFeed public static void EmitFromPlot(object plot, string phase) { - if (plot == null) + if (plot == null || !AgentHarness.LiveFeedsAllowed) { return; } - string assetId = ReadPlotAssetId(plot); - Actor author = ReadPlotAuthor(plot); - Emit(assetId, author, phase); + TryRegisterPlot(plot, phase ?? "active"); } public static void Tick() @@ -74,11 +76,129 @@ public static class PlotInterestFeed break; } - EmitFromPlot(plot, "active"); - registered++; + if (TryRegisterPlot(plot, "active")) + { + registered++; + } } } + private static bool TryRegisterPlot(object plot, string phase) + { + string assetId = ReadPlotAssetId(plot); + DiscreteEventEntry entry = EventCatalog.Plot.GetOrFallback(assetId); + if (!EventCatalog.IsCameraWorthy(entry)) + { + InterestDropLog.Record("createsInterest=false", "plot:" + (assetId ?? "")); + return false; + } + + string phaseKey = string.IsNullOrEmpty(phase) ? "active" : phase; + LiveEnsemble ensemble = null; + if (plot is Plot typedPlot && phaseKey == "active") + { + LiveEnsemble.TryBuildPlot(typedPlot, out ensemble); + } + + Actor follow = ensemble?.Focus ?? ReadPlotAuthor(plot); + Actor related = ensemble?.Related; + if (follow == null || !follow.isAlive()) + { + return false; + } + + Vector3 position = Vector3.zero; + try + { + position = follow.current_position; + } + catch + { + position = Vector3.zero; + } + + string plotId = ""; + if (plot is Plot pId) + { + try + { + plotId = pId.getID().ToString(); + } + catch + { + plotId = ""; + } + } + + string key = "plot:" + phaseKey + ":" + (plotId ?? "") + ":" + (entry.Id ?? assetId ?? "plot") + + ":" + EventFeedUtil.SafeId(follow); + string label; + InterestCompletionKind completion = InterestCompletionKind.FixedDwell; + float minWatch = 6f; + float maxWatch = 28f; + string registerAsset = entry.Id; + float strength = entry.EventStrength; + + if (ensemble != null && phaseKey == "active") + { + label = EventReason.PlotCell(ensemble); + if (string.IsNullOrEmpty(label)) + { + label = EventReason.Ensemble(ensemble); + } + + completion = InterestCompletionKind.PlotActive; + registerAsset = "live_plot"; + minWatch = 8f; + maxWatch = 40f; + strength = Mathf.Max(strength, 86f); + } + else + { + label = EventReason.Plot(follow, entry.LabelTemplate, phaseKey, entry.Id); + } + + InterestCandidate registered = EventFeedUtil.Register( + key, + "plot", + entry.Category, + label, + registerAsset, + strength, + position, + follow, + related: related, + minWatch: minWatch, + maxWatch: maxWatch, + completion: completion); + if (registered == null) + { + return false; + } + + if (ensemble != null) + { + registered.CorrelationKey = key; + StickyScoreboard.TryLockFromEnsemble(registered.Sticky, ensemble); + StickyScoreboard.Refresh( + registered, + ensemble, + position, + StickyScoreboard.PlotTheaterRadius); + registered.Label = EventReason.PlotCell(ensemble); + if (string.IsNullOrEmpty(registered.Label)) + { + registered.Label = label; + } + + registered.ParticipantCount = ensemble.ParticipantCount; + registered.AssetId = "live_plot"; + InterestScoring.ScoreCheap(registered); + } + + return true; + } + private static IEnumerable ResolvePlotsEnumerable() { try @@ -99,7 +219,7 @@ public static class PlotInterestFeed object manager = world.GetType().GetField("plots_manager")?.GetValue(world) ?? world.GetType().GetProperty("plots_manager")?.GetValue(world, null) ?? typeof(PlotManager); - if (manager is System.Type) + if (manager is Type) { manager = null; } diff --git a/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs b/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs index 8546e4e..f934a62 100644 --- a/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs +++ b/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs @@ -61,6 +61,7 @@ public static class RelationshipInterestFeed subject.current_position, subject, related: relatedAlive); + FamilyInterestFeed.EmitFromActor(subject); return; } @@ -90,6 +91,12 @@ public static class RelationshipInterestFeed subject.current_position, subject, related: relatedAlive); + + // Soft sticky pack scene after real join/form - not from a world family poll. + if (eventId == "family_group_new" || eventId == "family_group_join") + { + FamilyInterestFeed.EmitFromActor(subject); + } } public static void EmitFamily(string eventId, object family, Actor anchor) diff --git a/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs b/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs new file mode 100644 index 0000000..a5161cf --- /dev/null +++ b/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs @@ -0,0 +1,229 @@ +using System; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Upgrades / registers sticky status outbreaks when multiple nearby units share a +/// camera-worthy affliction / transformation status. Stickies require a real group (2+). +/// Relationship / emotion / ambient statuses stay single-unit tips even in crowds. +/// +public static class StatusOutbreakFeed +{ + /// + /// Affliction / spectacle statuses sampled by for cluster outbreaks. + /// Social statuses (fell_in_love, sleeping, …) must also pass . + /// + private static readonly string[] HotStatusIds = + { + "cursed", "poisoned", "burning", "plague", "possessed", "ash_fever", + "frozen", "infected", "sick", "rage", "drowning" + }; + + public static void TryUpgradeFromEmit(Actor subject, string statusId, InterestCandidate registered) + { + if (!AgentHarness.LiveFeedsAllowed + || subject == null + || !subject.isAlive() + || registered == null + || string.IsNullOrEmpty(statusId)) + { + return; + } + + StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(statusId); + if (!IsOutbreakWorthy(statusId, entry)) + { + return; + } + + Vector2 pos; + try + { + Vector3 p = subject.current_position; + pos = new Vector2(p.x, p.y); + } + catch + { + return; + } + + if (!LiveEnsemble.TryBuildStatusOutbreak( + statusId, + pos, + StickyScoreboard.StatusOutbreakRadius, + out LiveEnsemble ensemble, + preferFocus: subject) + || ensemble == null + || ensemble.SideA == null + || ensemble.SideA.Count < 2) + { + return; + } + + ApplySticky(registered, ensemble, statusId, pos); + } + + public static void Tick() + { + if (!AgentHarness.LiveFeedsAllowed) + { + return; + } + + // Sample a few camera-worthy affliction statuses from living units. + int registered = 0; + var seenStatus = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor == null || !actor.isAlive() || registered >= 6) + { + break; + } + + foreach (string statusId in HotStatusIds) + { + if (!LiveEnsemble.HasActorStatus(actor, statusId) || !seenStatus.Add(statusId)) + { + continue; + } + + StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(statusId); + if (!EventCatalog.IsCameraWorthy(entry) || !IsOutbreakWorthy(statusId, entry)) + { + continue; + } + + if (TryRegisterOutbreak(actor, statusId, entry)) + { + registered++; + } + + if (registered >= 6) + { + break; + } + } + } + } + + /// + /// Outbreak tips are for affliction / transformation clusters (2+ nearby). + /// Never "Outbreak - Fell in love" / sleeping / pregnant / emotion crowds. + /// Solo drowning / curse / etc. stay unit StatusPhase tips until a group forms. + /// + public static bool IsOutbreakWorthy(string statusId, StatusInterestEntry entry = null) + { + if (string.IsNullOrEmpty(statusId)) + { + return false; + } + + string id = statusId.Trim(); + StatusInterestEntry e = entry ?? EventCatalog.Status.GetOrFallback(id); + string cat = (e?.Category ?? "").Trim(); + + // Social / life / ambient beats stay unit-led tips even when many share them. + if (cat.Equals("Relationship", StringComparison.OrdinalIgnoreCase) + || cat.Equals("Emotion", StringComparison.OrdinalIgnoreCase) + || cat.Equals("LifeChapter", StringComparison.OrdinalIgnoreCase) + || cat.Equals("StatusAmbient", StringComparison.OrdinalIgnoreCase) + || cat.Equals("Grief", StringComparison.OrdinalIgnoreCase) + || cat.Equals("Social", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (cat.Equals("StatusTransformation", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + for (int i = 0; i < HotStatusIds.Length; i++) + { + if (HotStatusIds[i].Equals(id, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static bool TryRegisterOutbreak(Actor seed, string statusId, StatusInterestEntry entry) + { + Vector2 pos; + try + { + Vector3 p = seed.current_position; + pos = new Vector2(p.x, p.y); + } + catch + { + return false; + } + + if (!LiveEnsemble.TryBuildStatusOutbreak( + statusId, + pos, + StickyScoreboard.StatusOutbreakRadius, + out LiveEnsemble ensemble, + preferFocus: seed) + || ensemble == null + || ensemble.SideA == null + || ensemble.SideA.Count < 2) + { + return false; + } + + string key = "status:outbreak:" + statusId; + string label = EventReason.StatusOutbreak(ensemble); + if (string.IsNullOrEmpty(label)) + { + return false; + } + + InterestCandidate registered = EventFeedUtil.Register( + key, + "status", + string.IsNullOrEmpty(entry.Category) ? "StatusTransformation" : entry.Category, + label, + "live_outbreak", + Mathf.Max(entry.EventStrength, 84f), + seed.current_position, + ensemble.Focus, + related: ensemble.Related, + minWatch: 8f, + maxWatch: InterestScoringConfig.W.maxWatchStatus, + completion: InterestCompletionKind.StatusOutbreak); + if (registered == null) + { + return false; + } + + registered.StatusId = statusId; + ApplySticky(registered, ensemble, statusId, pos); + return true; + } + + private static void ApplySticky( + InterestCandidate registered, + LiveEnsemble ensemble, + string statusId, + Vector2 pos) + { + registered.Completion = InterestCompletionKind.StatusOutbreak; + registered.StatusId = statusId; + registered.AssetId = "live_outbreak"; + registered.CorrelationKey = "status:outbreak:" + statusId; + StickyScoreboard.TryLockFromEnsemble(registered.Sticky, ensemble); + StickyScoreboard.Refresh( + registered, + ensemble, + new Vector3(pos.x, pos.y, 0f), + StickyScoreboard.StatusOutbreakRadius); + registered.Label = EventReason.StatusOutbreak(ensemble); + registered.ParticipantCount = ensemble.ParticipantCount; + InterestScoring.ScoreCheap(registered); + } +} diff --git a/IdleSpectator/Events/Feeds/WarInterestFeed.cs b/IdleSpectator/Events/Feeds/WarInterestFeed.cs index 5409030..bd3924c 100644 --- a/IdleSpectator/Events/Feeds/WarInterestFeed.cs +++ b/IdleSpectator/Events/Feeds/WarInterestFeed.cs @@ -8,6 +8,7 @@ namespace IdleSpectator; /// /// Polls live WarManager state and registers ongoing war interest candidates. +/// Active wars use + sticky WarFront tips. /// public static class WarInterestFeed { @@ -66,10 +67,21 @@ public static class WarInterestFeed return false; } + string phaseKey = string.IsNullOrEmpty(phase) ? "active" : phase; + LiveEnsemble ensemble = null; + if (war is War typedWar && phaseKey == "active") + { + LiveEnsemble.TryBuildWar(typedWar, out ensemble); + } + object attacker = ReadPropOrField(war, "main_attacker") ?? Invoke(war, "get_main_attacker"); object defender = ReadPropOrField(war, "main_defender") ?? Invoke(war, "get_main_defender"); - string attackerName = ReadMetaName(attacker); - string defenderName = ReadMetaName(defender); + string attackerName = ensemble?.SideA != null + ? (ensemble.SideA.Display ?? "") + : ReadMetaName(attacker); + string defenderName = ensemble?.SideB != null + ? (ensemble.SideB.Display ?? "") + : ReadMetaName(defender); string labelPair = string.IsNullOrEmpty(defenderName) ? attackerName : attackerName + " vs " + defenderName; @@ -79,30 +91,61 @@ public static class WarInterestFeed } Vector3 position = Vector3.zero; - Actor follow = null; - if (!TryLocateWar(attacker, defender, out position, out follow)) + Actor follow = ensemble?.Focus; + Actor related = ensemble?.Related; + if (follow == null || !follow.isAlive()) { - return false; + if (!TryLocateWar(attacker, defender, out position, out follow)) + { + return false; + } + } + else + { + try + { + position = follow.current_position; + } + catch + { + position = Vector3.zero; + } } - string phaseKey = string.IsNullOrEmpty(phase) ? "active" : phase; - string key = "war:" + phaseKey + ":" + (ReadString(war, "id") ?? labelPair) + ":" + (warTypeId ?? "normal"); - string label = EventCatalog.WarType.MakeLabel(entry, labelPair); - if (phaseKey == "new") + string warId = ReadString(war, "id"); + if (string.IsNullOrEmpty(warId) && war is War wId) { - label = "War begins: " + labelPair; - } - else if (phaseKey == "end") - { - label = "War ends: " + labelPair; - } - else if (!string.IsNullOrEmpty(labelPair) && !label.Contains(labelPair)) - { - label = label + ": " + labelPair; + try + { + warId = wId.getID().ToString(); + } + catch + { + warId = ""; + } } - // Prefer sentence form when we have an owned follow unit. - if (follow != null) + string key = "war:" + phaseKey + ":" + (warId ?? labelPair) + ":" + (warTypeId ?? "normal"); + string label; + InterestCompletionKind completion = InterestCompletionKind.FixedDwell; + float minWatch = 6f; + float maxWatch = 30f; + string assetId = warTypeId ?? "war"; + + if (ensemble != null && phaseKey == "active") + { + label = EventReason.WarFront(ensemble); + if (string.IsNullOrEmpty(label)) + { + label = EventReason.Ensemble(ensemble); + } + + completion = InterestCompletionKind.WarFront; + assetId = "live_war"; + minWatch = 8f; + maxWatch = 45f; + } + else if (follow != null) { if (phaseKey == "new") { @@ -123,6 +166,22 @@ public static class WarInterestFeed : EventFeedUtil.SafeName(follow) + " is at war with " + defenderName; } } + else + { + label = EventCatalog.WarType.MakeLabel(entry, labelPair); + if (phaseKey == "new") + { + label = "War begins: " + labelPair; + } + else if (phaseKey == "end") + { + label = "War ends: " + labelPair; + } + else if (!string.IsNullOrEmpty(labelPair) && !label.Contains(labelPair)) + { + label = label + ": " + labelPair; + } + } float strength = entry.EventStrength; if (phaseKey == "end") @@ -130,17 +189,49 @@ public static class WarInterestFeed strength = Mathf.Max(60f, strength - 10f); } - EventFeedUtil.Register( + if (ensemble != null) + { + strength = Mathf.Max(strength, 88f); + } + + InterestCandidate registered = EventFeedUtil.Register( key, "war", entry.Category, label, - warTypeId ?? "war", + assetId, strength, position, follow, - minWatch: 6f, - maxWatch: 30f); + related: related, + minWatch: minWatch, + maxWatch: maxWatch, + completion: completion); + if (registered == null) + { + return false; + } + + if (ensemble != null) + { + registered.CorrelationKey = key; + StickyScoreboard.TryLockFromEnsemble(registered.Sticky, ensemble); + StickyScoreboard.Refresh( + registered, + ensemble, + position, + StickyScoreboard.WarTheaterRadius); + registered.Label = EventReason.WarFront(ensemble); + if (string.IsNullOrEmpty(registered.Label)) + { + registered.Label = label; + } + + registered.ParticipantCount = ensemble.ParticipantCount; + registered.AssetId = "live_war"; + InterestScoring.ScoreCheap(registered); + } + return true; } @@ -158,8 +249,6 @@ public static class WarInterestFeed return true; } - // No kingdom unit found - location-only if we have a capital position is handled above. - // Never invent a global random unit as war subject. return false; } @@ -172,6 +261,24 @@ public static class WarInterestFeed return false; } + if (kingdom is Kingdom typed) + { + follow = LiveEnsemble.PreferKingdomLeader(typed); + if (follow != null) + { + try + { + position = follow.current_position; + } + catch + { + // keep + } + + return true; + } + } + try { object capital = ReadPropOrField(kingdom, "capital") ?? Invoke(kingdom, "getCapital"); @@ -337,21 +444,4 @@ public static class WarInterestFeed { return ReadPropOrField(target, name) as string; } - - private static long SafeActorId(Actor actor) - { - if (actor == null) - { - return 0; - } - - try - { - return actor.getID(); - } - catch - { - return 0; - } - } } diff --git a/IdleSpectator/Events/LiveEnsemble.cs b/IdleSpectator/Events/LiveEnsemble.cs index 7ccd4e9..613419c 100644 --- a/IdleSpectator/Events/LiveEnsemble.cs +++ b/IdleSpectator/Events/LiveEnsemble.cs @@ -111,6 +111,952 @@ public sealed class LiveEnsemble return ensemble != null && ensemble.HasFocus; } + /// + /// Build a kingdom-vs-kingdom war front from a live . + /// Counts use kingdom population; focus prefers living kings then any side unit. + /// + public static bool TryBuildWar(War war, out LiveEnsemble ensemble) + { + ensemble = null; + if (war == null) + { + return false; + } + + try + { + if (!war.isAlive()) + { + return false; + } + } + catch + { + // continue - some meta objects omit isAlive + } + + Kingdom attacker; + Kingdom defender; + try + { + attacker = war.main_attacker; + defender = war.main_defender; + } + catch + { + return false; + } + + if (attacker == null || defender == null) + { + return false; + } + + string keyA = KingdomMetaKey(attacker); + string keyB = KingdomMetaKey(defender); + if (string.IsNullOrEmpty(keyA) || string.IsNullOrEmpty(keyB)) + { + return false; + } + + int countA = KingdomUnitCount(attacker); + int countB = KingdomUnitCount(defender); + Actor focus = PreferKingdomLeader(attacker) ?? PreferKingdomLeader(defender); + if (focus == null) + { + return false; + } + + Actor related = null; + string focusKey = KingdomKeyOf(focus); + if (!string.IsNullOrEmpty(focusKey) + && focusKey.Equals(keyA, StringComparison.OrdinalIgnoreCase)) + { + related = PreferKingdomLeader(defender); + } + else + { + related = PreferKingdomLeader(attacker); + } + + if (related == focus) + { + related = null; + } + + ensemble = new LiveEnsemble + { + Kind = EnsembleKind.WarFront, + Frame = EnsembleFrame.KingdomVsKingdom, + Scale = ScaleForCount(Math.Max(3, countA + countB)), + ParticipantCount = Math.Max(0, countA) + Math.Max(0, countB), + Focus = focus, + Related = related, + SideA = new EnsembleSide + { + Key = keyA, + Display = KingdomDisplay(keyA), + KingdomDisplay = KingdomDisplay(keyA), + Count = Math.Max(0, countA), + Best = PreferKingdomLeader(attacker) ?? focus + }, + SideB = new EnsembleSide + { + Key = keyB, + Display = KingdomDisplay(keyB), + KingdomDisplay = KingdomDisplay(keyB), + Count = Math.Max(0, countB), + Best = PreferKingdomLeader(defender) ?? related + } + }; + try + { + ensemble.Anchor = focus.current_position; + } + catch + { + // keep + } + + long idFocus = EventFeedUtil.SafeId(focus); + if (idFocus != 0) + { + ensemble.ParticipantIds.Add(idFocus); + } + + long idRelated = related != null ? EventFeedUtil.SafeId(related) : 0; + if (idRelated != 0) + { + ensemble.ParticipantIds.Add(idRelated); + } + + return ensemble.HasFocus; + } + + /// + /// Build a plotters-vs-target-kingdom cell from a live . + /// Requires an active plot with units and a resolvable target kingdom (or city→kingdom). + /// Side A is a collective plotter camp (never author proper names). + /// + public static bool TryBuildPlot(Plot plot, out LiveEnsemble ensemble) + { + ensemble = null; + if (plot == null) + { + return false; + } + + try + { + if (!plot.isActive()) + { + return false; + } + } + catch + { + // continue - treat as active when isActive is unavailable + } + + string assetId = ""; + try + { + PlotAsset asset = plot.getAsset(); + if (asset != null && !string.IsNullOrEmpty(asset.id)) + { + assetId = asset.id; + } + } + catch + { + assetId = ""; + } + + if (string.IsNullOrEmpty(assetId)) + { + assetId = "plot"; + } + + Kingdom targetKingdom = null; + try + { + targetKingdom = plot.target_kingdom; + } + catch + { + targetKingdom = null; + } + + if (targetKingdom == null) + { + try + { + City city = plot.target_city; + if (city != null) + { + targetKingdom = city.kingdom; + } + } + catch + { + targetKingdom = null; + } + } + + if (targetKingdom == null) + { + try + { + Actor targetActor = plot.target_actor; + if (targetActor != null && targetActor.isAlive()) + { + string tk = KingdomKeyOf(targetActor); + if (!string.IsNullOrEmpty(tk)) + { + targetKingdom = FindKingdomByKey(tk); + } + } + } + catch + { + // ignore + } + } + + string keyB = KingdomMetaKey(targetKingdom); + if (string.IsNullOrEmpty(keyB)) + { + return false; + } + + string keyA = "plot:" + assetId; + var plotters = new List(8); + CollectPlotUnits(plot, plotters); + Actor author = null; + try + { + author = plot.getAuthor(); + } + catch + { + author = null; + } + + if (author != null && author.isAlive() && !plotters.Contains(author)) + { + plotters.Add(author); + } + + // PlotCell sticky is a conspiracy cell - solo authors use unit Plot tips instead. + if (plotters.Count < 2) + { + return false; + } + + Actor focus = author != null && author.isAlive() ? author : plotters[0]; + Actor related = PreferKingdomLeader(targetKingdom); + if (related == focus) + { + related = null; + } + + int countA = plotters.Count; + int countB = KingdomUnitCount(targetKingdom); + ensemble = new LiveEnsemble + { + Kind = EnsembleKind.PlotCell, + Frame = EnsembleFrame.KingdomVsKingdom, + Scale = ScaleForCount(Math.Max(3, countA + Math.Min(countB, 20))), + ParticipantCount = Math.Max(0, countA) + Math.Max(0, countB), + Focus = focus, + Related = related, + SideA = new EnsembleSide + { + Key = keyA, + Display = "Plotters", + KingdomDisplay = "", + Count = Math.Max(0, countA), + Best = focus + }, + SideB = new EnsembleSide + { + Key = keyB, + Display = KingdomDisplay(keyB), + KingdomDisplay = KingdomDisplay(keyB), + Count = Math.Max(0, countB), + Best = related + } + }; + try + { + ensemble.Anchor = focus.current_position; + } + catch + { + // keep + } + + for (int i = 0; i < plotters.Count; i++) + { + long id = EventFeedUtil.SafeId(plotters[i]); + if (id != 0) + { + ensemble.ParticipantIds.Add(id); + } + } + + long idRelated = related != null ? EventFeedUtil.SafeId(related) : 0; + if (idRelated != 0) + { + ensemble.ParticipantIds.Add(idRelated); + } + + return ensemble.HasFocus; + } + + /// + /// Build a species-framed family pack from a live . + /// Requires at least one living member; focus prefers alpha then any member. + /// + public static bool TryBuildFamily(Family family, out LiveEnsemble ensemble) + { + ensemble = null; + if (family == null) + { + return false; + } + + var members = new List(8); + CollectFamilyUnits(family, members); + if (members.Count == 0) + { + return false; + } + + string familyId = ""; + try + { + familyId = family.getID().ToString(); + } + catch + { + familyId = ""; + } + + if (string.IsNullOrEmpty(familyId)) + { + familyId = "pack"; + } + + Actor alpha = null; + try + { + alpha = family.getAlpha(); + if (alpha != null && !alpha.isAlive()) + { + alpha = null; + } + } + catch + { + alpha = null; + } + + Actor focus = alpha ?? members[0]; + string speciesKey = SpeciesKeyOf(focus); + if (string.IsNullOrEmpty(speciesKey)) + { + speciesKey = "unknown"; + } + + Actor related = null; + for (int i = 0; i < members.Count; i++) + { + if (members[i] != null && members[i] != focus && members[i].isAlive()) + { + related = members[i]; + break; + } + } + + int count = members.Count; + ensemble = new LiveEnsemble + { + Kind = EnsembleKind.FamilyPack, + Frame = EnsembleFrame.SpeciesVsSpecies, + Scale = ScaleForCount(Math.Max(2, count)), + ParticipantCount = count, + Focus = focus, + Related = related, + SideA = new EnsembleSide + { + Key = "family:" + familyId, + Display = SpeciesDisplay(speciesKey), + KingdomDisplay = "", + Count = count, + Best = focus + }, + SideB = null + }; + try + { + ensemble.Anchor = focus.current_position; + } + catch + { + // keep + } + + for (int i = 0; i < members.Count; i++) + { + long id = EventFeedUtil.SafeId(members[i]); + if (id != 0) + { + ensemble.ParticipantIds.Add(id); + } + } + + return ensemble.HasFocus; + } + + /// + /// Build a status outbreak cluster: units near sharing . + /// Requires at least 2 carriers for a sticky outbreak scene. + /// + public static bool TryBuildStatusOutbreak( + string statusId, + Vector2 pos, + float radius, + out LiveEnsemble ensemble, + Actor preferFocus = null) + { + ensemble = null; + if (string.IsNullOrEmpty(statusId)) + { + return false; + } + + string id = statusId.Trim(); + var carriers = new List(8); + CollectStatusCarriers(pos, radius, id, carriers); + if (preferFocus != null + && preferFocus.isAlive() + && HasActorStatus(preferFocus, id) + && !carriers.Contains(preferFocus)) + { + carriers.Insert(0, preferFocus); + } + + if (carriers.Count < 2) + { + return false; + } + + Actor focus = preferFocus != null && carriers.Contains(preferFocus) + ? preferFocus + : carriers[0]; + Actor related = null; + for (int i = 0; i < carriers.Count; i++) + { + if (carriers[i] != null && carriers[i] != focus && carriers[i].isAlive()) + { + related = carriers[i]; + break; + } + } + + int count = carriers.Count; + ensemble = new LiveEnsemble + { + Kind = EnsembleKind.StatusOutbreak, + Frame = EnsembleFrame.SpeciesVsSpecies, + Scale = ScaleForCount(Math.Max(2, count)), + ParticipantCount = count, + Focus = focus, + Related = related, + SideA = new EnsembleSide + { + Key = "status:" + id, + Display = StatusDisplayName(id), + KingdomDisplay = "", + Count = count, + Best = focus + }, + SideB = null + }; + try + { + ensemble.Anchor = focus.current_position; + } + catch + { + ensemble.Anchor = new Vector3(pos.x, pos.y, 0f); + } + + for (int i = 0; i < carriers.Count; i++) + { + long unitId = EventFeedUtil.SafeId(carriers[i]); + if (unitId != 0) + { + ensemble.ParticipantIds.Add(unitId); + } + } + + return ensemble.HasFocus; + } + + public static void CollectStatusCarriers( + Vector2 pos, + float radius, + string statusId, + List into) + { + if (into == null || string.IsNullOrEmpty(statusId)) + { + return; + } + + float r2 = radius * radius; + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor == null || !actor.isAlive() || !HasActorStatus(actor, statusId)) + { + continue; + } + + try + { + Vector3 p = actor.current_position; + float dx = p.x - pos.x; + float dy = p.y - pos.y; + if (dx * dx + dy * dy > r2) + { + continue; + } + } + catch + { + continue; + } + + if (!into.Contains(actor)) + { + into.Add(actor); + } + } + } + + public static bool HasActorStatus(Actor actor, string statusId) + { + if (actor == null || string.IsNullOrEmpty(statusId)) + { + return false; + } + + try + { + return actor.hasStatus(statusId.Trim()); + } + catch + { + return false; + } + } + + public static string StatusDisplayName(string statusId) + { + if (string.IsNullOrEmpty(statusId)) + { + return "Afflicted"; + } + + string id = statusId.Trim(); + // Prefer a short title-cased asset id (cursed → Cursed). + string pretty = id.Replace('_', ' ').Trim(); + if (string.IsNullOrEmpty(pretty)) + { + return "Afflicted"; + } + + return char.ToUpperInvariant(pretty[0]) + + (pretty.Length > 1 ? pretty.Substring(1) : ""); + } + + /// Refresh sticky outbreak counts from Side A roster still carrying the status. + public static bool TryApplyStatusOutbreakStickyCounts(LiveSceneStickyState sticky, string statusId) + { + if (sticky == null || !sticky.HasOpposingSides || sticky.Kind != EnsembleKind.StatusOutbreak) + { + return false; + } + + if (string.IsNullOrEmpty(statusId) && IsStatusOutbreakSideKey(sticky.SideAKey)) + { + statusId = sticky.SideAKey.Substring("status:".Length); + } + + int alive = 0; + for (int i = sticky.SideAIds.Count - 1; i >= 0; i--) + { + Actor unit = FindTrackedActor(sticky.SideAIds[i]); + if (unit == null || !unit.isAlive() || !HasActorStatus(unit, statusId)) + { + sticky.SideAIds.RemoveAt(i); + continue; + } + + alive++; + } + + sticky.SideACount = Math.Max(0, alive); + sticky.SideBCount = 0; + sticky.SideBKey = ""; + sticky.SideBDisplay = ""; + if (string.IsNullOrEmpty(sticky.SideADisplay) || IsStatusOutbreakSideKey(sticky.SideADisplay)) + { + sticky.SideADisplay = StatusDisplayName(statusId); + } + + return alive > 0; + } + + /// Collect living members of into . + public static void CollectFamilyUnits(Family family, List into) + { + if (family == null || into == null) + { + return; + } + + try + { + foreach (Actor unit in family.getUnits()) + { + if (unit != null && unit.isAlive() && !into.Contains(unit)) + { + into.Add(unit); + } + } + } + catch + { + try + { + foreach (Actor unit in family.units) + { + if (unit != null && unit.isAlive() && !into.Contains(unit)) + { + into.Add(unit); + } + } + } + catch + { + // ignore + } + } + } + + /// Refresh sticky family pack counts from the Side A roster. + public static bool TryApplyFamilyStickyCounts(LiveSceneStickyState sticky) + { + if (sticky == null || !sticky.HasOpposingSides || sticky.Kind != EnsembleKind.FamilyPack) + { + return false; + } + + int alive = 0; + Actor best = null; + for (int i = sticky.SideAIds.Count - 1; i >= 0; i--) + { + Actor unit = FindTrackedActor(sticky.SideAIds[i]); + if (unit == null || !unit.isAlive()) + { + sticky.SideAIds.RemoveAt(i); + continue; + } + + alive++; + if (best == null) + { + best = unit; + } + + try + { + if (unit.hasFamily() && unit.family != null) + { + Actor alpha = unit.family.getAlpha(); + if (alpha != null && alpha.isAlive() && alpha == unit) + { + best = unit; + } + } + } + catch + { + // keep + } + } + + sticky.SideACount = Math.Max(0, alive); + sticky.SideBCount = 0; + sticky.SideBKey = ""; + sticky.SideBDisplay = ""; + if (string.IsNullOrEmpty(sticky.SideADisplay) || IsFamilyPackSideKey(sticky.SideADisplay)) + { + if (best != null) + { + sticky.SideADisplay = SpeciesDisplay(SpeciesKeyOf(best)); + } + } + + return alive > 0; + } + + /// Collect living members of into . + public static void CollectPlotUnits(Plot plot, List into) + { + if (plot == null || into == null) + { + return; + } + + try + { + foreach (Actor unit in plot.getUnits()) + { + if (unit != null && unit.isAlive() && !into.Contains(unit)) + { + into.Add(unit); + } + } + } + catch + { + try + { + foreach (Actor unit in plot.units) + { + if (unit != null && unit.isAlive() && !into.Contains(unit)) + { + into.Add(unit); + } + } + } + catch + { + // ignore + } + } + } + + /// + /// Refresh sticky plot counts: Side A = alive plotter roster; Side B = target kingdom pop. + /// + public static bool TryApplyPlotStickyCounts(LiveSceneStickyState sticky) + { + if (sticky == null || !sticky.HasOpposingSides || sticky.Kind != EnsembleKind.PlotCell) + { + return false; + } + + int aliveA = 0; + for (int i = sticky.SideAIds.Count - 1; i >= 0; i--) + { + Actor unit = FindTrackedActor(sticky.SideAIds[i]); + if (unit == null || !unit.isAlive()) + { + sticky.SideAIds.RemoveAt(i); + continue; + } + + aliveA++; + } + + sticky.SideACount = Math.Max(0, aliveA); + if (string.IsNullOrEmpty(sticky.SideADisplay)) + { + sticky.SideADisplay = "Plotters"; + } + + Kingdom b = FindKingdomByKey(sticky.SideBKey); + if (b != null) + { + sticky.SideBCount = Math.Max(0, KingdomUnitCount(b)); + sticky.SideBDisplay = KingdomDisplay(sticky.SideBKey); + sticky.SideBKingdom = sticky.SideBDisplay; + } + + return true; + } + + public static Actor FindTrackedActor(long id) + { + if (id == 0) + { + return null; + } + + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor != null && actor.isAlive() && EventFeedUtil.SafeId(actor) == id) + { + return actor; + } + } + + return null; + } + + /// True when Side A is a synthetic plotter camp key. + public static bool IsPlotterSideKey(string key) => + !string.IsNullOrEmpty(key) + && key.StartsWith("plot:", StringComparison.OrdinalIgnoreCase); + + /// Re-read kingdom population onto sticky sides when keys still resolve. + public static bool TryApplyKingdomPopulationCounts(LiveSceneStickyState sticky) + { + if (sticky == null || !sticky.HasOpposingSides + || sticky.Frame != EnsembleFrame.KingdomVsKingdom) + { + return false; + } + + Kingdom a = FindKingdomByKey(sticky.SideAKey); + Kingdom b = FindKingdomByKey(sticky.SideBKey); + if (a == null && b == null) + { + return false; + } + + if (a != null && !IsPlotterSideKey(sticky.SideAKey)) + { + sticky.SideACount = Math.Max(0, KingdomUnitCount(a)); + sticky.SideADisplay = KingdomDisplay(sticky.SideAKey); + } + + if (b != null && !IsPlotterSideKey(sticky.SideBKey)) + { + sticky.SideBCount = Math.Max(0, KingdomUnitCount(b)); + sticky.SideBDisplay = KingdomDisplay(sticky.SideBKey); + } + + return true; + } + + public static string KingdomMetaKey(Kingdom kingdom) + { + if (kingdom == null) + { + return ""; + } + + try + { + string name = kingdom.name ?? ""; + return string.IsNullOrEmpty(name) ? "" : name.Trim(); + } + catch + { + return ""; + } + } + + public static Kingdom FindKingdomByKey(string key) + { + if (string.IsNullOrEmpty(key) || World.world?.kingdoms == null) + { + return null; + } + + try + { + foreach (Kingdom kingdom in World.world.kingdoms) + { + if (kingdom == null) + { + continue; + } + + string k = KingdomMetaKey(kingdom); + if (!string.IsNullOrEmpty(k) + && k.Equals(key, StringComparison.OrdinalIgnoreCase)) + { + return kingdom; + } + } + } + catch + { + // ignore + } + + return null; + } + + public static int KingdomUnitCount(Kingdom kingdom) + { + if (kingdom == null) + { + return 0; + } + + try + { + return Math.Max(0, kingdom.countUnits()); + } + catch + { + try + { + return Math.Max(0, kingdom.getPopulationPeople()); + } + catch + { + return 0; + } + } + } + + public static Actor PreferKingdomLeader(Kingdom kingdom) + { + if (kingdom == null) + { + return null; + } + + try + { + Actor king = kingdom.king; + if (king != null && king.isAlive()) + { + return king; + } + } + catch + { + // ignore + } + + try + { + foreach (Actor unit in kingdom.getUnits()) + { + if (unit != null && unit.isAlive()) + { + return unit; + } + } + } + catch + { + // ignore + } + + return null; + } + public static LiveEnsemble FromCombatFighters( List fighters, Vector2 anchor, @@ -438,9 +1384,9 @@ public sealed class LiveEnsemble } /// - /// Sticky scoreboard roster: add units currently fighting for each camp, then count every - /// tracked member that is still alive (combat or not) until the combat scene grace ends. - /// Dead ids are pruned. New combatants matching a sticky key are enrolled. + /// Sticky scoreboard roster: enroll matching side keys in radius, then count every + /// tracked member that is still alive until the scene grace ends. + /// Combat mode enrolls fighters only; war/plot modes enroll any matching unit. /// public static void RefreshStickyMemberRoster( Vector2 pos, @@ -453,7 +1399,8 @@ public sealed class LiveEnsemble out int countA, out int countB, out Actor bestA, - out Actor bestB) + out Actor bestB, + bool requireCombat = true) { countA = 0; countB = 0; @@ -466,10 +1413,14 @@ public sealed class LiveEnsemble } float r2 = radius * radius; - // Enroll anyone currently fighting for a sticky camp. foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) { - if (actor == null || !actor.isAlive() || !IsCombatParticipant(actor)) + if (actor == null || !actor.isAlive()) + { + continue; + } + + if (requireCombat && !IsCombatParticipant(actor)) { continue; } @@ -505,8 +1456,8 @@ public sealed class LiveEnsemble } } - countA = CountAliveTracked(sideAIds, out bestA, preferCombat: true); - countB = CountAliveTracked(sideBIds, out bestB, preferCombat: true); + countA = CountAliveTracked(sideAIds, out bestA, preferCombat: requireCombat); + countB = CountAliveTracked(sideBIds, out bestB, preferCombat: requireCombat); } public static Actor FindBestAliveTracked(List ids, bool preferCombat = true) @@ -595,7 +1546,19 @@ public sealed class LiveEnsemble public static bool HasOpposingCollectiveSides(LiveEnsemble ensemble) { - if (ensemble?.SideA == null || ensemble.SideB == null) + if (ensemble?.SideA == null) + { + return false; + } + + // Family packs / status outbreaks lock a single collective roster (no opposing B). + if (ensemble.Kind == EnsembleKind.FamilyPack + || ensemble.Kind == EnsembleKind.StatusOutbreak) + { + return !string.IsNullOrEmpty(ensemble.SideA.Key) && ensemble.SideA.Count >= 1; + } + + if (ensemble.SideB == null) { return false; } @@ -614,6 +1577,16 @@ public sealed class LiveEnsemble return !ensemble.SideA.Key.Equals(ensemble.SideB.Key, StringComparison.OrdinalIgnoreCase); } + /// True when Side A is a synthetic family pack key. + public static bool IsFamilyPackSideKey(string key) => + !string.IsNullOrEmpty(key) + && key.StartsWith("family:", StringComparison.OrdinalIgnoreCase); + + /// True when Side A is a synthetic status outbreak key. + public static bool IsStatusOutbreakSideKey(string key) => + !string.IsNullOrEmpty(key) + && key.StartsWith("status:", StringComparison.OrdinalIgnoreCase); + private static void ApplyNamedPair(LiveEnsemble ensemble, List fighters) { ensemble.Frame = EnsembleFrame.NamedPair; diff --git a/IdleSpectator/Events/LiveSceneStickyState.cs b/IdleSpectator/Events/LiveSceneStickyState.cs new file mode 100644 index 0000000..5bc9347 --- /dev/null +++ b/IdleSpectator/Events/LiveSceneStickyState.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; + +namespace IdleSpectator; + +/// +/// Durable sticky scoreboard for a multi-actor live scene (combat first; war/plot/family later). +/// Locks opposing side identity once, tracks member ids, and holds peak scale across dips. +/// Owned by ; cleared when the scene goes cold. +/// +public sealed class LiveSceneStickyState +{ + public EnsembleKind Kind = EnsembleKind.Combat; + public EnsembleFrame Frame = EnsembleFrame.CountOnly; + public int PeakParticipants; + public float ScaleDropSince = -999f; + + public string SideAKey = ""; + public string SideADisplay = ""; + public string SideAKingdom = ""; + public int SideACount; + public readonly List SideAIds = new List(8); + + public string SideBKey = ""; + public string SideBDisplay = ""; + public string SideBKingdom = ""; + public int SideBCount; + public readonly List SideBIds = new List(8); + + /// + /// True when sticky identity is locked. Opposing camps need A+B; FamilyPack locks Side A only. + /// + public bool HasOpposingSides => + !string.IsNullOrEmpty(SideAKey) + && ( + Kind == EnsembleKind.FamilyPack + || Kind == EnsembleKind.StatusOutbreak + || (!string.IsNullOrEmpty(SideBKey) + && (Frame == EnsembleFrame.SpeciesVsSpecies + || Frame == EnsembleFrame.KingdomVsKingdom))); + + public int TotalCount => Math.Max(0, SideACount) + Math.Max(0, SideBCount); + + public void Clear() + { + Kind = EnsembleKind.Combat; + Frame = EnsembleFrame.CountOnly; + PeakParticipants = 0; + ScaleDropSince = -999f; + SideAKey = ""; + SideADisplay = ""; + SideAKingdom = ""; + SideACount = 0; + SideAIds.Clear(); + SideBKey = ""; + SideBDisplay = ""; + SideBKingdom = ""; + SideBCount = 0; + SideBIds.Clear(); + } + + public void CopyTo(LiveSceneStickyState other) + { + if (other == null) + { + return; + } + + other.Clear(); + other.Kind = Kind; + other.Frame = Frame; + other.PeakParticipants = PeakParticipants; + other.ScaleDropSince = ScaleDropSince; + other.SideAKey = SideAKey; + other.SideADisplay = SideADisplay; + other.SideAKingdom = SideAKingdom; + other.SideACount = SideACount; + other.SideBKey = SideBKey; + other.SideBDisplay = SideBDisplay; + other.SideBKingdom = SideBKingdom; + other.SideBCount = SideBCount; + for (int i = 0; i < SideAIds.Count; i++) + { + other.SideAIds.Add(SideAIds[i]); + } + + for (int i = 0; i < SideBIds.Count; i++) + { + other.SideBIds.Add(SideBIds[i]); + } + } +} diff --git a/IdleSpectator/Events/StickyScoreboard.cs b/IdleSpectator/Events/StickyScoreboard.cs new file mode 100644 index 0000000..54f8458 --- /dev/null +++ b/IdleSpectator/Events/StickyScoreboard.cs @@ -0,0 +1,660 @@ +using System; +using UnityEngine; + +namespace IdleSpectator; + + /// + /// Kind-agnostic sticky scoreboard: lock sides, enroll members, refresh alive counts, + /// hold elevated scale, promote follow on subject death. + /// Combat, WarFront, PlotCell, FamilyPack, and StatusOutbreak are consumers. + /// + public static class StickyScoreboard + { + public const float ScaleHoldSeconds = 2.5f; + public const float WarTheaterRadius = 48f; + public const float PlotTheaterRadius = 36f; + public const float FamilyTheaterRadius = 28f; + public const float StatusOutbreakRadius = 32f; + + public static bool HasOpposingSides(InterestCandidate scene) => + scene?.Sticky != null && scene.Sticky.HasOpposingSides; + + public static bool IsOnRoster(InterestCandidate scene, Actor actor) + { + if (scene?.Sticky == null || actor == null || !actor.isAlive()) + { + return false; + } + + long id = EventFeedUtil.SafeId(actor); + if (id == 0) + { + return false; + } + + LiveSceneStickyState sticky = scene.Sticky; + for (int i = 0; i < sticky.SideAIds.Count; i++) + { + if (sticky.SideAIds[i] == id) + { + return true; + } + } + + for (int i = 0; i < sticky.SideBIds.Count; i++) + { + if (sticky.SideBIds[i] == id) + { + return true; + } + } + + return false; + } + + /// + /// Promote another living roster member onto the candidate when follow is lost. + /// + public static bool TryPromoteFollow(InterestCandidate scene) + { + if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides) + { + return false; + } + + LiveSceneStickyState sticky = scene.Sticky; + bool preferCombat = sticky.Kind == EnsembleKind.Combat; + bool plotLike = sticky.Kind == EnsembleKind.PlotCell; + bool familyLike = sticky.Kind == EnsembleKind.FamilyPack; + bool outbreakLike = sticky.Kind == EnsembleKind.StatusOutbreak; + bool singleSide = familyLike || outbreakLike; + Actor pickA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: preferCombat); + Actor pickB = singleSide + ? null + : LiveEnsemble.FindBestAliveTracked(sticky.SideBIds, preferCombat: preferCombat); + if (pickA == null + && sticky.Frame == EnsembleFrame.KingdomVsKingdom + && !LiveEnsemble.IsPlotterSideKey(sticky.SideAKey) + && !LiveEnsemble.IsFamilyPackSideKey(sticky.SideAKey) + && !LiveEnsemble.IsStatusOutbreakSideKey(sticky.SideAKey)) + { + pickA = LiveEnsemble.PreferKingdomLeader(LiveEnsemble.FindKingdomByKey(sticky.SideAKey)); + } + + if (pickB == null + && !singleSide + && sticky.Frame == EnsembleFrame.KingdomVsKingdom) + { + pickB = LiveEnsemble.PreferKingdomLeader(LiveEnsemble.FindKingdomByKey(sticky.SideBKey)); + } + + // Family / plot / outbreak prefer Side A. + Actor pick = (plotLike || singleSide) ? (pickA ?? pickB) : (pickA ?? pickB); + if (pick == null || !pick.isAlive()) + { + return false; + } + + if (!plotLike && !singleSide && pickA != null && pickB != null) + { + if (sticky.SideBCount > sticky.SideACount) + { + pick = pickB; + } + else if (sticky.SideACount > sticky.SideBCount) + { + pick = pickA; + } + } + + Actor other = singleSide + ? null + : (pick == pickA ? pickB : pickA); + if (singleSide) + { + for (int i = 0; i < sticky.SideAIds.Count; i++) + { + Actor peer = LiveEnsemble.FindTrackedActor(sticky.SideAIds[i]); + if (peer != null && peer.isAlive() && peer != pick) + { + other = peer; + break; + } + } + } + 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; + } + + /// + /// Hold elevated scale briefly after participant count dips. + /// + public static void StabilizeScale(LiveSceneStickyState sticky, LiveEnsemble ensemble, float now) + { + if (sticky == null || ensemble == null) + { + return; + } + + int n = Mathf.Max(1, ensemble.ParticipantCount); + if (n > sticky.PeakParticipants) + { + sticky.PeakParticipants = n; + sticky.ScaleDropSince = -999f; + } + + int peak = Math.Max(sticky.PeakParticipants, n); + EnsembleScale live = LiveEnsemble.ScaleForCount(n); + EnsembleScale held = LiveEnsemble.ScaleForCount(peak); + if (held > live) + { + if (sticky.ScaleDropSince < 0f) + { + sticky.ScaleDropSince = now; + } + + if (now - sticky.ScaleDropSince < ScaleHoldSeconds) + { + ensemble.Scale = held; + if (ensemble.Frame == EnsembleFrame.NamedPair) + { + ensemble.Frame = EnsembleFrame.CountOnly; + } + + return; + } + + sticky.PeakParticipants = n; + sticky.ScaleDropSince = -999f; + ensemble.Scale = live; + return; + } + + sticky.ScaleDropSince = -999f; + ensemble.Scale = live; + } + + /// + /// Lock opposing camps once (when multi), enroll members, rewrite ensemble sides/counts. + /// + public static void Refresh( + InterestCandidate scene, + LiveEnsemble ensemble, + Vector3 pos, + float radius) + { + if (scene?.Sticky == null || ensemble == null) + { + return; + } + + LiveSceneStickyState sticky = scene.Sticky; + TryLockFromEnsemble(sticky, ensemble); + if (!sticky.HasOpposingSides) + { + return; + } + + SeedFromEnsemble(sticky, ensemble); + bool warLike = sticky.Kind == EnsembleKind.WarFront || ensemble.Kind == EnsembleKind.WarFront; + bool plotLike = sticky.Kind == EnsembleKind.PlotCell || ensemble.Kind == EnsembleKind.PlotCell; + bool familyLike = sticky.Kind == EnsembleKind.FamilyPack || ensemble.Kind == EnsembleKind.FamilyPack; + bool outbreakLike = sticky.Kind == EnsembleKind.StatusOutbreak + || ensemble.Kind == EnsembleKind.StatusOutbreak; + Actor bestA = null; + Actor bestB = null; + if (outbreakLike) + { + SeedOutbreakRoster(sticky, ensemble); + string statusId = scene.StatusId ?? ""; + if (string.IsNullOrEmpty(statusId) && LiveEnsemble.IsStatusOutbreakSideKey(sticky.SideAKey)) + { + statusId = sticky.SideAKey.Substring("status:".Length); + } + + // Re-scan nearby carriers into the roster, then recount. + Vector2 scanPos = new Vector2(pos.x, pos.y); + var carriers = new System.Collections.Generic.List(8); + LiveEnsemble.CollectStatusCarriers(scanPos, Math.Max(radius, StatusOutbreakRadius), statusId, carriers); + for (int i = 0; i < carriers.Count; i++) + { + long id = EventFeedUtil.SafeId(carriers[i]); + if (id != 0) + { + AddUnique(sticky.SideAIds, id); + } + } + + LiveEnsemble.TryApplyStatusOutbreakStickyCounts(sticky, statusId); + bestA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: false) + ?? ensemble.Focus; + bestB = null; + for (int i = 0; i < sticky.SideAIds.Count; i++) + { + Actor peer = LiveEnsemble.FindTrackedActor(sticky.SideAIds[i]); + if (peer != null && peer.isAlive() && peer != bestA) + { + bestB = peer; + break; + } + } + } + else if (familyLike) + { + SeedFamilyRoster(sticky, ensemble); + LiveEnsemble.TryApplyFamilyStickyCounts(sticky); + bestA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: false) + ?? ensemble.Focus; + bestB = null; + for (int i = 0; i < sticky.SideAIds.Count; i++) + { + Actor peer = LiveEnsemble.FindTrackedActor(sticky.SideAIds[i]); + if (peer != null && peer.isAlive() && peer != bestA) + { + bestB = peer; + break; + } + } + } + else if (plotLike) + { + SeedPlotRoster(sticky, ensemble); + LiveEnsemble.TryApplyPlotStickyCounts(sticky); + bestA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: false) + ?? ensemble.Focus; + bestB = LiveEnsemble.FindBestAliveTracked(sticky.SideBIds, preferCombat: false) + ?? LiveEnsemble.PreferKingdomLeader(LiveEnsemble.FindKingdomByKey(sticky.SideBKey)) + ?? ensemble.Related; + } + else + { + float useRadius = warLike ? Math.Max(radius, WarTheaterRadius) : radius; + LiveEnsemble.RefreshStickyMemberRoster( + pos, + useRadius, + sticky.Frame, + sticky.SideAKey, + sticky.SideBKey, + sticky.SideAIds, + sticky.SideBIds, + out int countA, + out int countB, + out bestA, + out bestB, + requireCombat: !warLike); + + sticky.SideACount = Math.Max(0, countA); + sticky.SideBCount = Math.Max(0, countB); + if (warLike) + { + LiveEnsemble.TryApplyKingdomPopulationCounts(sticky); + } + } + + int live = sticky.TotalCount; + scene.ParticipantCount = live; + if (live > sticky.PeakParticipants) + { + sticky.PeakParticipants = live; + } + + ApplyToEnsemble(sticky, ensemble, bestA, bestB); + + if (ensemble.Scale == EnsembleScale.Pair && sticky.Kind == EnsembleKind.Combat) + { + ensemble.Scale = LiveEnsemble.ScaleForCount(Math.Max(3, sticky.PeakParticipants)); + } + + if (warLike) + { + ensemble.Kind = EnsembleKind.WarFront; + ensemble.Scale = LiveEnsemble.ScaleForCount(Math.Max(3, live)); + } + + if (plotLike) + { + ensemble.Kind = EnsembleKind.PlotCell; + ensemble.Scale = LiveEnsemble.ScaleForCount(Math.Max(3, live)); + } + + if (familyLike) + { + ensemble.Kind = EnsembleKind.FamilyPack; + ensemble.Scale = LiveEnsemble.ScaleForCount(Math.Max(2, sticky.SideACount)); + ensemble.SideB = null; + } + + if (outbreakLike) + { + ensemble.Kind = EnsembleKind.StatusOutbreak; + ensemble.Scale = LiveEnsemble.ScaleForCount(Math.Max(2, sticky.SideACount)); + ensemble.SideB = null; + } + + if (bestA != null + && (ensemble.Focus == null + || (sticky.Kind == EnsembleKind.Combat + && !LiveEnsemble.IsCombatParticipant(ensemble.Focus)))) + { + ensemble.Focus = bestA; + } + + if (bestB != null) + { + ensemble.Related = bestB; + } + } + + /// Lock sticky keys from a live opposing collective ensemble (once). + public static bool TryLockFromEnsemble(LiveSceneStickyState sticky, LiveEnsemble ensemble) + { + if (sticky == null || ensemble == null || sticky.HasOpposingSides) + { + return false; + } + + if (!LiveEnsemble.HasOpposingCollectiveSides(ensemble)) + { + return false; + } + + int liveN = Math.Max( + ensemble.ParticipantCount, + (ensemble.SideA?.Count ?? 0) + (ensemble.SideB?.Count ?? 0)); + // War / plot / family / outbreak lock even at low local fighter counts. + if (ensemble.Kind != EnsembleKind.WarFront + && ensemble.Kind != EnsembleKind.PlotCell + && ensemble.Kind != EnsembleKind.FamilyPack + && ensemble.Kind != EnsembleKind.StatusOutbreak + && liveN <= 2 + && ensemble.Scale <= EnsembleScale.Pair) + { + return false; + } + + sticky.Kind = ensemble.Kind; + sticky.Frame = ensemble.Frame; + sticky.SideAKey = ensemble.SideA.Key ?? ""; + sticky.SideADisplay = ensemble.SideA.Display ?? ""; + sticky.SideAKingdom = ensemble.SideA.KingdomDisplay ?? ""; + sticky.SideACount = Math.Max(0, ensemble.SideA.Count); + if (ensemble.Kind == EnsembleKind.FamilyPack + || ensemble.Kind == EnsembleKind.StatusOutbreak + || ensemble.SideB == null) + { + sticky.SideBKey = ""; + sticky.SideBDisplay = ""; + sticky.SideBKingdom = ""; + sticky.SideBCount = 0; + } + else + { + sticky.SideBKey = ensemble.SideB.Key ?? ""; + sticky.SideBDisplay = ensemble.SideB.Display ?? ""; + sticky.SideBKingdom = ensemble.SideB.KingdomDisplay ?? ""; + sticky.SideBCount = Math.Max(0, ensemble.SideB.Count); + } + + return true; + } + + /// Build orange reason from sticky state; never thin Fight prose. + public static string FormatReason(InterestCandidate scene, Actor focus, Actor related) + { + if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides) + { + return ""; + } + + LiveSceneStickyState sticky = scene.Sticky; + var ensemble = new LiveEnsemble + { + Kind = sticky.Kind, + Scale = LiveEnsemble.ScaleForCount(Math.Max(3, sticky.PeakParticipants)), + Focus = focus, + Related = related, + Frame = sticky.Frame, + SideA = new EnsembleSide + { + Key = sticky.SideAKey, + Display = sticky.SideADisplay, + KingdomDisplay = sticky.SideAKingdom, + Count = sticky.SideACount, + Best = focus + }, + SideB = new EnsembleSide + { + Key = sticky.SideBKey, + Display = sticky.SideBDisplay, + KingdomDisplay = sticky.SideBKingdom, + Count = sticky.SideBCount, + Best = related + }, + ParticipantCount = sticky.TotalCount + }; + return EventReason.Ensemble(ensemble); + } + + private static void ApplyToEnsemble( + LiveSceneStickyState sticky, + LiveEnsemble ensemble, + Actor bestA, + Actor bestB) + { + ensemble.Frame = sticky.Frame; + ensemble.SideA = new EnsembleSide + { + Key = sticky.SideAKey, + Display = string.IsNullOrEmpty(sticky.SideADisplay) ? sticky.SideAKey : sticky.SideADisplay, + KingdomDisplay = sticky.SideAKingdom, + Count = sticky.SideACount, + Best = bestA ?? ensemble.Focus + }; + ensemble.SideB = new EnsembleSide + { + Key = sticky.SideBKey, + Display = string.IsNullOrEmpty(sticky.SideBDisplay) ? sticky.SideBKey : sticky.SideBDisplay, + KingdomDisplay = sticky.SideBKingdom, + Count = sticky.SideBCount, + Best = bestB ?? ensemble.Related + }; + ensemble.ParticipantCount = sticky.TotalCount; + } + + private static void SeedFromEnsemble(LiveSceneStickyState sticky, LiveEnsemble ensemble) + { + if (sticky == null || ensemble == null || !sticky.HasOpposingSides) + { + return; + } + + bool plotLike = sticky.Kind == EnsembleKind.PlotCell || ensemble.Kind == EnsembleKind.PlotCell; + bool familyLike = sticky.Kind == EnsembleKind.FamilyPack || ensemble.Kind == EnsembleKind.FamilyPack; + bool outbreakLike = sticky.Kind == EnsembleKind.StatusOutbreak + || ensemble.Kind == EnsembleKind.StatusOutbreak; + + void Enroll(Actor actor, bool forcePlotter = false) + { + if (actor == null || !actor.isAlive()) + { + return; + } + + long id = EventFeedUtil.SafeId(actor); + if (id == 0) + { + return; + } + + if (familyLike || outbreakLike) + { + AddUnique(sticky.SideAIds, id); + return; + } + + if (plotLike || forcePlotter) + { + string kingdom = LiveEnsemble.KingdomKeyOf(actor); + if (!string.IsNullOrEmpty(kingdom) + && kingdom.Equals(sticky.SideBKey, StringComparison.OrdinalIgnoreCase) + && !forcePlotter) + { + AddUnique(sticky.SideBIds, id); + return; + } + + // Default: living plot participants enroll on Side A. + AddUnique(sticky.SideAIds, id); + return; + } + + string key = sticky.Frame == EnsembleFrame.KingdomVsKingdom + ? LiveEnsemble.KingdomKeyOf(actor) + : LiveEnsemble.SpeciesKeyOf(actor); + if (string.IsNullOrEmpty(key)) + { + return; + } + + if (key.Equals(sticky.SideAKey, StringComparison.OrdinalIgnoreCase)) + { + AddUnique(sticky.SideAIds, id); + } + else if (key.Equals(sticky.SideBKey, StringComparison.OrdinalIgnoreCase)) + { + AddUnique(sticky.SideBIds, id); + } + } + + Enroll(ensemble.Focus, forcePlotter: plotLike || familyLike || outbreakLike); + Enroll(ensemble.Related); + Enroll(ensemble.SideA?.Best, forcePlotter: plotLike || familyLike || outbreakLike); + Enroll(ensemble.SideB?.Best); + if (ensemble.ParticipantIds == null) + { + return; + } + + for (int i = 0; i < ensemble.ParticipantIds.Count; i++) + { + long id = ensemble.ParticipantIds[i]; + if (id == 0) + { + continue; + } + + Actor actor = LiveEnsemble.FindTrackedActor(id); + if (actor != null) + { + // Participant ids from TryBuildPlot are plotters first; related may also appear. + bool forceA = plotLike + && (actor == ensemble.Focus + || actor == ensemble.SideA?.Best + || (ensemble.Related == null || actor != ensemble.Related)); + Enroll(actor, forcePlotter: forceA && actor != ensemble.Related); + } + } + } + + private static void SeedPlotRoster(LiveSceneStickyState sticky, LiveEnsemble ensemble) + { + if (sticky == null || ensemble == null) + { + return; + } + + // Ensure Side A display stays collective. + if (string.IsNullOrEmpty(sticky.SideADisplay) + || LiveEnsemble.IsPlotterSideKey(sticky.SideADisplay)) + { + sticky.SideADisplay = "Plotters"; + } + + if (ensemble.SideA != null && ensemble.SideA.Count > sticky.SideACount) + { + sticky.SideACount = ensemble.SideA.Count; + } + + if (ensemble.SideB != null && ensemble.SideB.Count > 0) + { + sticky.SideBCount = ensemble.SideB.Count; + } + } + + private static void SeedFamilyRoster(LiveSceneStickyState sticky, LiveEnsemble ensemble) + { + if (sticky == null || ensemble == null) + { + return; + } + + if (ensemble.SideA != null) + { + if (!string.IsNullOrEmpty(ensemble.SideA.Display) + && !LiveEnsemble.IsFamilyPackSideKey(ensemble.SideA.Display)) + { + sticky.SideADisplay = ensemble.SideA.Display; + } + + if (ensemble.SideA.Count > sticky.SideACount) + { + sticky.SideACount = ensemble.SideA.Count; + } + } + + sticky.SideBKey = ""; + sticky.SideBDisplay = ""; + sticky.SideBCount = 0; + } + + private static void SeedOutbreakRoster(LiveSceneStickyState sticky, LiveEnsemble ensemble) + { + if (sticky == null || ensemble == null) + { + return; + } + + if (ensemble.SideA != null) + { + if (!string.IsNullOrEmpty(ensemble.SideA.Display) + && !LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Display)) + { + sticky.SideADisplay = ensemble.SideA.Display; + } + + if (ensemble.SideA.Count > sticky.SideACount) + { + sticky.SideACount = ensemble.SideA.Count; + } + } + + sticky.SideBKey = ""; + sticky.SideBDisplay = ""; + sticky.SideBCount = 0; + } + + private static void AddUnique(System.Collections.Generic.List ids, long id) + { + for (int i = 0; i < ids.Count; i++) + { + if (ids[i] == id) + { + return; + } + } + + ids.Add(id); + } +} diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index e8fdc31..cc75799 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -56,6 +56,24 @@ internal static class HarnessScenarios case "combat_stability_live": case "combat_live_proof": return CombatStabilityLive(); + case "war_front_sticky": + case "war_sticky": + return WarFrontSticky(); + case "plot_cell_sticky": + case "plot_sticky": + return PlotCellSticky(); + case "family_pack_sticky": + case "family_sticky": + return FamilyPackSticky(); + case "family_pack_yields": + case "pack_yields": + return FamilyPackYields(); + case "status_outbreak_sticky": + case "outbreak_sticky": + return StatusOutbreakSticky(); + case "status_outbreak_not_love": + case "outbreak_not_love": + return StatusOutbreakNotLove(); case "director_action_priority": case "action_priority": return DirectorActionPriority(); @@ -1163,6 +1181,290 @@ internal static class HarnessScenarios }; } + /// + /// Sticky WarFront tips stay kingdom-framed across count dips and follow loss. + /// + private static List WarFrontSticky() + { + return new List + { + Step("wfs0", "dismiss_windows"), + Step("wfs1", "wait_world"), + Step("wfs2", "set_setting", expect: "enabled", value: "true"), + Step("wfs3", "fast_timing", value: "true"), + Step("wfs4", "spawn", asset: "human", count: 2), + Step("wfs5", "spectator", value: "off"), + Step("wfs6", "spectator", value: "on"), + Step("wfs7", "pick_unit", asset: "human"), + Step("wfs8", "focus", asset: "human"), + Step("wfs9", "interest_end_session"), + + Step("wfs20", "interest_war_session", asset: "human", expect: "wfs_war"), + Step("wfs21", "assert", expect: "tip_matches_any", value: "War -"), + Step("wfs22", "assert", expect: "tip_matches_any", value: " vs "), + Step("wfs23", "assert", expect: "tip_not_contains", value: "fighting"), + Step("wfs24", "assert", expect: "tip_not_contains", value: "Duel"), + + Step("wfs30", "war_ensemble_apply", value: "2:0"), + Step("wfs31", "war_maintain_focus"), + Step("wfs32", "assert", expect: "tip_matches_any", value: "War -"), + Step("wfs33", "assert", expect: "tip_matches_any", value: " vs "), + Step("wfs34", "assert", expect: "tip_not_contains", value: "fighting"), + + Step("wfs40", "interest_clear_follow"), + Step("wfs41", "war_maintain_focus"), + Step("wfs42", "assert", expect: "has_focus", value: "true"), + Step("wfs43", "assert", expect: "tip_matches_any", value: "War -| vs "), + Step("wfs44", "assert", expect: "tip_not_contains", value: " is fighting"), + + Step("wfs50", "war_ensemble_apply", value: "15:11"), + Step("wfs51", "war_maintain_focus"), + Step("wfs52", "assert", expect: "tip_matches_any", value: "War -"), + Step("wfs53", "assert", expect: "dossier_matches_focus"), + + Step("wfs90", "fast_timing", value: "false"), + Step("wfs98", "screenshot", value: "hud-war-front-sticky.png"), + Step("wfs99", "snapshot"), + }; + } + + /// + /// Sticky PlotCell tips stay plotter-vs-kingdom framed across count dips and follow loss. + /// + private static List PlotCellSticky() + { + return new List + { + Step("pcs0", "dismiss_windows"), + Step("pcs1", "wait_world"), + Step("pcs2", "set_setting", expect: "enabled", value: "true"), + Step("pcs3", "fast_timing", value: "true"), + Step("pcs4", "spawn", asset: "human", count: 2), + Step("pcs5", "spectator", value: "off"), + Step("pcs6", "spectator", value: "on"), + Step("pcs7", "pick_unit", asset: "human"), + Step("pcs8", "focus", asset: "human"), + Step("pcs9", "interest_end_session"), + + Step("pcs20", "interest_plot_session", asset: "human", expect: "pcs_plot"), + Step("pcs21", "assert", expect: "tip_matches_any", value: "Plot -"), + Step("pcs22", "assert", expect: "tip_matches_any", value: "Plotters"), + Step("pcs23", "assert", expect: "tip_matches_any", value: " vs "), + Step("pcs24", "assert", expect: "tip_not_contains", value: "fighting"), + Step("pcs25", "assert", expect: "tip_not_contains", value: " is plotting"), + Step("pcs26", "assert", expect: "tip_not_contains", value: "Plotters (1)"), + + // Solo plotter is not a sticky group - tip must clear. + Step("pcs30", "plot_ensemble_apply", value: "1:0"), + Step("pcs31", "plot_maintain_focus"), + Step("pcs32", "assert", expect: "tip_not_contains", value: "Plot -"), + Step("pcs33", "assert", expect: "tip_not_contains", value: "Plotters (1)"), + + // Restore a real cell, then promote-follow still keeps the framed tip. + Step("pcs35", "plot_ensemble_apply", value: "3:12"), + Step("pcs36", "plot_maintain_focus"), + Step("pcs37", "assert", expect: "tip_matches_any", value: "Plot -"), + Step("pcs38", "assert", expect: "tip_matches_any", value: "Plotters (3)"), + + Step("pcs40", "interest_clear_follow"), + Step("pcs41", "plot_maintain_focus"), + Step("pcs42", "assert", expect: "has_focus", value: "true"), + Step("pcs43", "assert", expect: "tip_matches_any", value: "Plot -|Plotters| vs "), + Step("pcs44", "assert", expect: "tip_not_contains", value: " is plotting"), + + Step("pcs50", "plot_ensemble_apply", value: "4:18"), + Step("pcs51", "plot_maintain_focus"), + Step("pcs52", "assert", expect: "tip_matches_any", value: "Plot -"), + Step("pcs53", "assert", expect: "dossier_matches_focus"), + + // Intent-only AI decision must not own the camera - wait for a live Plot instead. + Step("pcs60", "assert", expect: "decision_worthy", value: "try_new_plot", label: "false"), + Step("pcs61", "assert", expect: "decision_worthy", value: "find_lover", label: "true"), + + Step("pcs90", "fast_timing", value: "false"), + Step("pcs98", "screenshot", value: "hud-plot-cell-sticky.png"), + Step("pcs99", "snapshot"), + }; + } + + /// + /// Sticky FamilyPack tips stay species-framed across count dips and follow loss. + /// + private static List FamilyPackSticky() + { + return new List + { + Step("fps0", "dismiss_windows"), + Step("fps1", "wait_world"), + Step("fps2", "set_setting", expect: "enabled", value: "true"), + Step("fps3", "fast_timing", value: "true"), + Step("fps4", "spawn", asset: "human", count: 2), + Step("fps5", "spectator", value: "off"), + Step("fps6", "spectator", value: "on"), + Step("fps7", "pick_unit", asset: "human"), + Step("fps8", "focus", asset: "human"), + Step("fps9", "interest_end_session"), + + Step("fps20", "interest_family_session", asset: "human", expect: "fps_pack"), + Step("fps21", "assert", expect: "tip_matches_any", value: "Pack -"), + Step("fps22", "assert", expect: "tip_matches_any", value: "Humans|Human"), + Step("fps22b", "assert", expect: "tip_matches_any", value: "gathering"), + Step("fps23", "assert", expect: "tip_not_contains", value: "fighting"), + Step("fps24", "assert", expect: "tip_not_contains", value: "joins a family"), + Step("fps25", "assert", expect: "reason_matches_any", value: "Pack -"), + Step("fps26", "assert", expect: "reason_matches_any", value: "gathering"), + + Step("fps30", "family_ensemble_apply", value: "1"), + Step("fps31", "family_maintain_focus"), + Step("fps32", "assert", expect: "tip_matches_any", value: "Pack -"), + Step("fps32b", "assert", expect: "tip_matches_any", value: "(1)"), + Step("fps33", "assert", expect: "tip_not_contains", value: "fighting"), + Step("fps34", "assert", expect: "tip_matches_any", value: "gathering"), + + Step("fps40", "interest_clear_follow"), + Step("fps41", "family_maintain_focus"), + Step("fps42", "assert", expect: "has_focus", value: "true"), + Step("fps43", "assert", expect: "tip_matches_any", value: "Pack -"), + Step("fps44", "assert", expect: "tip_not_contains", value: "joins a family"), + Step("fps45", "assert", expect: "reason_matches_any", value: "gathering"), + + Step("fps50", "family_ensemble_apply", value: "7"), + Step("fps51", "family_maintain_focus"), + Step("fps52", "assert", expect: "tip_matches_any", value: "Pack -"), + Step("fps52b", "assert", expect: "tip_matches_any", value: "(7)"), + Step("fps53", "assert", expect: "tip_matches_any", value: "gathering"), + Step("fps54", "assert", expect: "reason_matches_any", value: "Pack -|gathering"), + // Spawning pack members can briefly drop MoveCamera focus; re-seat before dossier check. + Step("fps54b", "family_maintain_focus"), + Step("fps54c", "assert", expect: "has_focus", value: "true"), + Step("fps55", "assert", expect: "dossier_matches_focus"), + + Step("fps90", "fast_timing", value: "false"), + Step("fps98", "screenshot", value: "hud-family-pack-sticky.png"), + Step("fps99", "snapshot"), + }; + } + + /// + /// Soft FamilyPack must not fortress the camera: Action-tier unit events margin-cut immediately. + /// + private static List FamilyPackYields() + { + return new List + { + Step("fpy0", "dismiss_windows"), + Step("fpy1", "wait_world"), + Step("fpy2", "set_setting", expect: "enabled", value: "true"), + Step("fpy3", "fast_timing", value: "true"), + Step("fpy4", "spawn", asset: "human", count: 2), + Step("fpy5", "spectator", value: "off"), + Step("fpy6", "spectator", value: "on"), + Step("fpy7", "pick_unit", asset: "human"), + Step("fpy8", "focus", asset: "human"), + Step("fpy9", "interest_end_session"), + + Step("fpy20", "interest_family_session", asset: "human", expect: "fpy_pack"), + Step("fpy21", "assert", expect: "tip_matches_any", value: "Pack -"), + Step("fpy22", "assert", expect: "tip_matches_any", value: "gathering"), + + // Action margin-cuts soft pack immediately (no sticky fortress). + // Assert immediately - ambient birth noise can clear a short FixedDwell tip later. + Step("fpy53", "trigger_interest", asset: "human", label: "ActionCutsPack", tier: "Action"), + Step("fpy55", "assert", expect: "tip_contains", value: "ActionCutsPack"), + Step("fpy56", "assert", expect: "tip_not_contains", value: "Pack -"), + Step("fpy57", "assert", expect: "session_key", value: "ActionCutsPack"), + + Step("fpy90", "fast_timing", value: "false"), + Step("fpy99", "snapshot"), + }; + } + + /// + /// Sticky StatusOutbreak tips stay status-framed across count dips and follow loss. + /// + private static List StatusOutbreakSticky() + { + return new List + { + Step("sos0", "dismiss_windows"), + Step("sos1", "wait_world"), + Step("sos2", "set_setting", expect: "enabled", value: "true"), + Step("sos3", "fast_timing", value: "true"), + Step("sos4", "spawn", asset: "human", count: 2), + Step("sos5", "spectator", value: "off"), + Step("sos6", "spectator", value: "on"), + Step("sos7", "pick_unit", asset: "human"), + Step("sos8", "focus", asset: "human"), + Step("sos9", "interest_end_session"), + + Step("sos20", "interest_outbreak_session", asset: "human", value: "cursed", expect: "sos_outbreak"), + Step("sos21", "assert", expect: "tip_matches_any", value: "Outbreak -"), + Step("sos22", "assert", expect: "tip_matches_any", value: "Cursed"), + Step("sos22b", "assert", expect: "tip_matches_any", value: "(4)"), + Step("sos23", "assert", expect: "tip_not_contains", value: "fighting"), + Step("sos24", "assert", expect: "tip_not_contains", value: " is cursed"), + Step("sos25", "assert", expect: "reason_matches_any", value: "Outbreak -|Cursed"), + + Step("sos30", "outbreak_ensemble_apply", value: "1"), + Step("sos31", "outbreak_maintain_focus"), + Step("sos32", "assert", expect: "tip_matches_any", value: "Outbreak -"), + Step("sos32b", "assert", expect: "tip_matches_any", value: "(1)"), + Step("sos33", "assert", expect: "tip_not_contains", value: "fighting"), + + Step("sos40", "interest_clear_follow"), + Step("sos41", "outbreak_maintain_focus"), + Step("sos42", "assert", expect: "has_focus", value: "true"), + Step("sos43", "assert", expect: "tip_matches_any", value: "Outbreak -"), + Step("sos44", "assert", expect: "tip_not_contains", value: " is cursed"), + + Step("sos50", "outbreak_ensemble_apply", value: "6"), + Step("sos51", "outbreak_maintain_focus"), + Step("sos52", "assert", expect: "tip_matches_any", value: "Outbreak -"), + Step("sos52b", "assert", expect: "tip_matches_any", value: "(6)"), + Step("sos53", "assert", expect: "reason_matches_any", value: "Outbreak -|Cursed|(6)"), + Step("sos54", "assert", expect: "dossier_matches_focus"), + + Step("sos90", "fast_timing", value: "false"), + Step("sos98", "screenshot", value: "hud-status-outbreak-sticky.png"), + Step("sos99", "snapshot"), + }; + } + + /// + /// Relationship/emotion statuses must never become Outbreak tips, even when many units share them. + /// + private static List StatusOutbreakNotLove() + { + return new List + { + Step("son0", "dismiss_windows"), + Step("son1", "wait_world"), + Step("son2", "set_setting", expect: "enabled", value: "true"), + Step("son3", "fast_timing", value: "true"), + Step("son4", "spawn", asset: "human", count: 6), + Step("son5", "spectator", value: "off"), + Step("son6", "spectator", value: "on"), + Step("son7", "pick_unit", asset: "human"), + Step("son8", "focus", asset: "human"), + Step("son9", "interest_end_session"), + Step("son10", "force_live_feeds", value: "true"), + + // Cluster of fell_in_love must stay unit/relationship prose - never a love Outbreak. + // Do not ban every "Outbreak -" tip: leftover cursed clusters from other scenarios are fine. + Step("son20", "status_apply_nearby", value: "fell_in_love", label: "30", count: 6), + Step("son21", "wait", value: "0.45"), + Step("son22", "assert", expect: "tip_not_contains", value: "Outbreak - Fell"), + Step("son23", "assert", expect: "tip_not_contains", value: "Outbreak - Love"), + Step("son24", "assert", expect: "interest_no_key", value: "status:outbreak:fell_in_love"), + Step("son25", "assert", expect: "outbreak_worthy", value: "fell_in_love", label: "false"), + Step("son26", "assert", expect: "outbreak_worthy", value: "drowning", label: "true"), + Step("son27", "assert", expect: "outbreak_worthy", value: "cursed", label: "true"), + + Step("son90", "fast_timing", value: "false"), + Step("son99", "snapshot"), + }; + } + /// /// 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. diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs index df21012..a4a49d8 100644 --- a/IdleSpectator/InterestCandidate.cs +++ b/IdleSpectator/InterestCandidate.cs @@ -24,12 +24,21 @@ public enum InterestCompletionKind /// Character vignette until max watch or cold activity. CharacterVignette = 5, /// Harness-forced session; active until released or max cap. - Manual = 6 + Manual = 6, + /// Sticky war front while the war / kingdoms remain resolvable. + WarFront = 7, + /// Sticky plot cell while the plot / plotters remain resolvable. + PlotActive = 8, + /// Sticky family pack while members / alpha remain resolvable. + FamilyPack = 9, + /// Sticky status outbreak while clustered carriers remain resolvable. + StatusOutbreak = 10 } /// /// Durable interest scene candidate. is the sole ranking signal; /// typed fields (lead, completion, combat) gate interrupt policy. +/// Multi-actor sticky scoreboard lives on . /// public sealed class InterestCandidate { @@ -44,21 +53,8 @@ public sealed class InterestCandidate public float TotalScore; /// Live fighters / participants in a cluster (battles, multi-unit events). public int ParticipantCount; - /// Peak fighters seen this combat scene (scale demotion hysteresis). - public int CombatPeakParticipants; - /// Sticky opposing camps for the orange tip (species/kingdom keys). - 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; - /// Tracked camp members for sticky scoreboard (kept until combat grace ends). - public readonly List CombatSideAIds = new List(8); - public readonly List CombatSideBIds = new List(8); + /// Sticky multi-actor scoreboard (combat first; war/plot/family later). + public readonly LiveSceneStickyState Sticky = new LiveSceneStickyState(); /// Notable (king/favorite/leader/high renown) participants in the cluster. public int NotableParticipantCount; public Vector3 Position; @@ -88,32 +84,78 @@ public sealed class InterestCandidate public string ScoreDetail = ""; public readonly List ParticipantIds = new List(4); + // Compat forwarders - existing combat maintain/harness code. + public int CombatPeakParticipants + { + get => Sticky.PeakParticipants; + set => Sticky.PeakParticipants = value; + } + + public EnsembleFrame CombatSideFrame + { + get => Sticky.Frame; + set => Sticky.Frame = value; + } + + public string CombatSideAKey + { + get => Sticky.SideAKey; + set => Sticky.SideAKey = value; + } + + public string CombatSideADisplay + { + get => Sticky.SideADisplay; + set => Sticky.SideADisplay = value; + } + + public string CombatSideAKingdom + { + get => Sticky.SideAKingdom; + set => Sticky.SideAKingdom = value; + } + + public int CombatSideACount + { + get => Sticky.SideACount; + set => Sticky.SideACount = value; + } + + public string CombatSideBKey + { + get => Sticky.SideBKey; + set => Sticky.SideBKey = value; + } + + public string CombatSideBDisplay + { + get => Sticky.SideBDisplay; + set => Sticky.SideBDisplay = value; + } + + public string CombatSideBKingdom + { + get => Sticky.SideBKingdom; + set => Sticky.SideBKingdom = value; + } + + public int CombatSideBCount + { + get => Sticky.SideBCount; + set => Sticky.SideBCount = value; + } + + public List CombatSideAIds => Sticky.SideAIds; + public List CombatSideBIds => Sticky.SideBIds; + public bool HasFollowUnit => FollowUnit != null && FollowUnit.isAlive(); public bool HasValidPosition => HasFollowUnit || (Position != Vector3.zero && !float.IsNaN(Position.x)); - public bool HasStickyCombatSides => - !string.IsNullOrEmpty(CombatSideAKey) - && !string.IsNullOrEmpty(CombatSideBKey) - && (CombatSideFrame == EnsembleFrame.SpeciesVsSpecies - || CombatSideFrame == EnsembleFrame.KingdomVsKingdom); + public bool HasStickyCombatSides => Sticky.HasOpposingSides; - public void ClearCombatSticky() - { - CombatPeakParticipants = 0; - CombatSideFrame = EnsembleFrame.CountOnly; - CombatSideAKey = ""; - CombatSideADisplay = ""; - CombatSideAKingdom = ""; - CombatSideACount = 0; - CombatSideBKey = ""; - CombatSideBDisplay = ""; - CombatSideBKingdom = ""; - CombatSideBCount = 0; - CombatSideAIds.Clear(); - CombatSideBIds.Clear(); - } + public void ClearCombatSticky() => Sticky.Clear(); public InterestEvent ToInterestEvent() { @@ -146,16 +188,6 @@ public sealed class InterestCandidate VisualConfidence = VisualConfidence, TotalScore = TotalScore, ParticipantCount = ParticipantCount, - CombatPeakParticipants = CombatPeakParticipants, - CombatSideFrame = CombatSideFrame, - CombatSideAKey = CombatSideAKey, - CombatSideADisplay = CombatSideADisplay, - CombatSideAKingdom = CombatSideAKingdom, - CombatSideACount = CombatSideACount, - CombatSideBKey = CombatSideBKey, - CombatSideBDisplay = CombatSideBDisplay, - CombatSideBKingdom = CombatSideBKingdom, - CombatSideBCount = CombatSideBCount, NotableParticipantCount = NotableParticipantCount, Position = Position, FollowUnit = FollowUnit, @@ -183,21 +215,12 @@ public sealed class InterestCandidate Selected = Selected, ScoreDetail = ScoreDetail }; + Sticky.CopyTo(copy.Sticky); for (int i = 0; i < ParticipantIds.Count; 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; } } diff --git a/IdleSpectator/InterestCompletion.cs b/IdleSpectator/InterestCompletion.cs index 5f3ef0a..d464ebd 100644 --- a/IdleSpectator/InterestCompletion.cs +++ b/IdleSpectator/InterestCompletion.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; @@ -40,6 +42,18 @@ public static class InterestCompletion case InterestCompletionKind.CombatActive: return CombatStillActive(candidate); + case InterestCompletionKind.WarFront: + return WarFrontStillActive(candidate); + + case InterestCompletionKind.PlotActive: + return PlotStillActive(candidate); + + case InterestCompletionKind.FamilyPack: + return FamilyPackStillActive(candidate); + + case InterestCompletionKind.StatusOutbreak: + return StatusOutbreakStillActive(candidate); + case InterestCompletionKind.ActivityActive: return ActivityStillActive(candidate); @@ -106,6 +120,412 @@ public static class InterestCompletion return now - c.LastSeenAt < combatHysteresisSeconds; } + private static bool WarFrontStillActive(InterestCandidate c) + { + if (c == null) + { + return false; + } + + float now = Time.unscaledTime; + if (!c.HasStickyCombatSides) + { + return false; + } + + // Promote path may briefly clear follow; keep hot while sides resolve. + if (!c.HasFollowUnit) + { + return now - c.LastSeenAt < 8f; + } + + Kingdom a = LiveEnsemble.FindKingdomByKey(c.CombatSideAKey); + Kingdom b = LiveEnsemble.FindKingdomByKey(c.CombatSideBKey); + bool sidesLive = (a != null || b != null); + if (!sidesLive && !string.IsNullOrEmpty(c.CorrelationKey)) + { + sidesLive = TryResolveWarStillActive(c.CorrelationKey); + } + + if (sidesLive) + { + c.LastSeenAt = now; + return true; + } + + return now - c.LastSeenAt < 8f; + } + + private static bool PlotStillActive(InterestCandidate c) + { + if (c == null) + { + return false; + } + + float now = Time.unscaledTime; + if (!c.HasStickyCombatSides) + { + return false; + } + + if (!c.HasFollowUnit) + { + return now - c.LastSeenAt < 8f; + } + + // PlotCell sticky needs a real conspiracy cell (2+ plotters). Solo leftovers end. + int plotters = 0; + LiveSceneStickyState sticky = c.Sticky; + if (sticky != null) + { + for (int i = 0; i < sticky.SideAIds.Count; i++) + { + Actor unit = LiveEnsemble.FindTrackedActor(sticky.SideAIds[i]); + if (unit != null && unit.isAlive()) + { + plotters++; + } + } + } + + if (plotters < 2 && !string.IsNullOrEmpty(c.CorrelationKey)) + { + plotters = Math.Max(plotters, CountLivePlotters(c.CorrelationKey)); + } + + // Harness may pin SideACount above the enrolled roster size. + if (plotters < 2 && sticky != null && sticky.SideACount >= 2 && sticky.SideAIds.Count >= 2) + { + plotters = sticky.SideACount; + } + + if (plotters >= 2) + { + c.LastSeenAt = now; + return true; + } + + return now - c.LastSeenAt < 4f; + } + + private static bool FamilyPackStillActive(InterestCandidate c) + { + if (c == null) + { + return false; + } + + float now = Time.unscaledTime; + if (!c.HasStickyCombatSides) + { + return false; + } + + if (!c.HasFollowUnit) + { + return now - c.LastSeenAt < 8f; + } + + // Pack scenes need a real pack (2+). Solo leftovers end into quiet grace. + int members = CountLivingPackMembers(c); + if (members < 2 && !string.IsNullOrEmpty(c.CorrelationKey)) + { + members = TryResolveFamilyMemberCount(c.CorrelationKey); + } + + if (members >= 2) + { + c.LastSeenAt = now; + return true; + } + + return now - c.LastSeenAt < 4f; + } + + private static int CountLivingPackMembers(InterestCandidate c) + { + LiveSceneStickyState sticky = c?.Sticky; + if (sticky == null) + { + return 0; + } + + int members = 0; + for (int i = 0; i < sticky.SideAIds.Count; i++) + { + Actor unit = LiveEnsemble.FindTrackedActor(sticky.SideAIds[i]); + if (unit != null && unit.isAlive()) + { + members++; + } + } + + return members; + } + + private static int TryResolveFamilyMemberCount(string familyKey) + { + if (string.IsNullOrEmpty(familyKey) || World.world?.families == null) + { + return 0; + } + + try + { + foreach (Family family in World.world.families) + { + if (family == null) + { + continue; + } + + string id = ""; + try + { + id = family.getID().ToString(); + } + catch + { + id = ""; + } + + if (string.IsNullOrEmpty(id) || familyKey.IndexOf(id, StringComparison.Ordinal) < 0) + { + continue; + } + + try + { + return family.countUnits(); + } + catch + { + var members = new System.Collections.Generic.List(4); + LiveEnsemble.CollectFamilyUnits(family, members); + return members.Count; + } + } + } + catch + { + // ignore + } + + return 0; + } + + private static bool TryResolvePlotStillActive(string plotKey) + { + if (string.IsNullOrEmpty(plotKey) || World.world?.plots == null) + { + return false; + } + + try + { + foreach (Plot plot in World.world.plots) + { + if (plot == null) + { + continue; + } + + string id = ""; + try + { + id = plot.getID().ToString(); + } + catch + { + id = ""; + } + + string assetId = ""; + try + { + PlotAsset asset = plot.getAsset(); + if (asset != null) + { + assetId = asset.id ?? ""; + } + } + catch + { + assetId = ""; + } + + bool match = (!string.IsNullOrEmpty(id) && plotKey.IndexOf(id, StringComparison.Ordinal) >= 0) + || (!string.IsNullOrEmpty(assetId) + && plotKey.IndexOf(assetId, StringComparison.OrdinalIgnoreCase) >= 0); + if (!match) + { + continue; + } + + try + { + if (!plot.isActive()) + { + return false; + } + } + catch + { + // treat as active when isActive is unavailable + } + + return CountPlotUnits(plot) >= 2; + } + } + catch + { + // ignore + } + + return false; + } + + private static int CountLivePlotters(string plotKey) + { + if (string.IsNullOrEmpty(plotKey) || World.world?.plots == null) + { + return 0; + } + + try + { + foreach (Plot plot in World.world.plots) + { + if (plot == null) + { + continue; + } + + string id = ""; + try + { + id = plot.getID().ToString(); + } + catch + { + id = ""; + } + + string assetId = ""; + try + { + PlotAsset asset = plot.getAsset(); + if (asset != null) + { + assetId = asset.id ?? ""; + } + } + catch + { + assetId = ""; + } + + bool match = (!string.IsNullOrEmpty(id) && plotKey.IndexOf(id, StringComparison.Ordinal) >= 0) + || (!string.IsNullOrEmpty(assetId) + && plotKey.IndexOf(assetId, StringComparison.OrdinalIgnoreCase) >= 0); + if (!match) + { + continue; + } + + return CountPlotUnits(plot); + } + } + catch + { + // ignore + } + + return 0; + } + + private static int CountPlotUnits(Plot plot) + { + if (plot == null) + { + return 0; + } + + var units = new List(8); + LiveEnsemble.CollectPlotUnits(plot, units); + try + { + Actor author = plot.getAuthor(); + if (author != null && author.isAlive() && !units.Contains(author)) + { + units.Add(author); + } + } + catch + { + // ignore + } + + int n = 0; + for (int i = 0; i < units.Count; i++) + { + if (units[i] != null && units[i].isAlive()) + { + n++; + } + } + + return n; + } + + private static bool TryResolveWarStillActive(string warKey) + { + if (string.IsNullOrEmpty(warKey) || World.world?.wars == null) + { + return false; + } + + try + { + foreach (War war in World.world.wars) + { + if (war == null) + { + continue; + } + + string id = ""; + try + { + id = war.getID().ToString(); + } + catch + { + id = ""; + } + + if (!string.IsNullOrEmpty(id) && warKey.IndexOf(id, StringComparison.Ordinal) >= 0) + { + try + { + return war.isAlive(); + } + catch + { + return true; + } + } + } + } + catch + { + // ignore + } + + return false; + } + private static bool RelatedStillFightingUs(InterestCandidate c, Actor unit) { Actor foe = c.RelatedUnit; @@ -174,6 +594,53 @@ public static class InterestCompletion return HasStatus(unit, c.StatusId); } + private static bool StatusOutbreakStillActive(InterestCandidate c) + { + if (c == null || string.IsNullOrEmpty(c.StatusId)) + { + return false; + } + + float now = Time.unscaledTime; + if (!c.HasStickyCombatSides) + { + return false; + } + + if (!c.HasFollowUnit) + { + return now - c.LastSeenAt < 8f; + } + + // Outbreak stickies need a real cluster (2+). Solo leftovers end into quiet grace. + int carriers = 0; + LiveSceneStickyState sticky = c.Sticky; + if (sticky != null) + { + for (int i = 0; i < sticky.SideAIds.Count; i++) + { + Actor unit = LiveEnsemble.FindTrackedActor(sticky.SideAIds[i]); + if (unit != null && unit.isAlive() && HasStatus(unit, c.StatusId)) + { + carriers++; + } + } + } + + if (carriers < 2 && c.FollowUnit != null && HasStatus(c.FollowUnit, c.StatusId)) + { + carriers = Math.Max(carriers, 1); + } + + if (carriers >= 2) + { + c.LastSeenAt = now; + return true; + } + + return now - c.LastSeenAt < 4f; + } + private static bool VignetteStillActive(InterestCandidate c, float age) { if (age < c.MinWatch) diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 67233b0..2960aa2 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -48,9 +48,6 @@ public static class InterestDirector private const float CombatFocusThrottleSeconds = 1.25f; /// Require this much extra focus weight before stealing the camera mid-fight. private const float CombatFocusSwitchMargin = 12f; - /// Hold elevated combat scale this long after fighter count dips. - private const float CombatScaleHoldSeconds = 2.5f; - private static float _combatScaleDropSince = -999f; private static readonly List PendingScratch = new List(96); public static string CurrentTierName => CurrentScoreLabel; @@ -370,6 +367,10 @@ public static class InterestDirector UpdateSessionLiveness(now, onCurrent); MaintainCombatFocus(now, force: false); + MaintainWarFront(now, force: false); + MaintainPlotCell(now, force: false); + MaintainFamilyPack(now, force: false); + MaintainStatusOutbreak(now, force: false); InterestCandidate next = SelectNext(now); if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch)) @@ -435,12 +436,40 @@ public static class InterestDirector && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) { if (_current.Completion == InterestCompletionKind.CombatActive - && TryPromoteStickyFollow(_current)) + && StickyScoreboard.TryPromoteFollow(_current)) { MaintainCombatFocus(now, force: true); return; } + if (_current.Completion == InterestCompletionKind.WarFront + && StickyScoreboard.TryPromoteFollow(_current)) + { + MaintainWarFront(now, force: true); + return; + } + + if (_current.Completion == InterestCompletionKind.PlotActive + && StickyScoreboard.TryPromoteFollow(_current)) + { + MaintainPlotCell(now, force: true); + return; + } + + if (_current.Completion == InterestCompletionKind.FamilyPack + && StickyScoreboard.TryPromoteFollow(_current)) + { + MaintainFamilyPack(now, force: true); + return; + } + + if (_current.Completion == InterestCompletionKind.StatusOutbreak + && StickyScoreboard.TryPromoteFollow(_current)) + { + MaintainStatusOutbreak(now, force: true); + return; + } + EndCurrent(now, reason: "follow_lost"); return; } @@ -492,7 +521,7 @@ public static class InterestDirector if (!_current.HasFollowUnit && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) { - if (TryPromoteStickyFollow(_current)) + if (StickyScoreboard.TryPromoteFollow(_current)) { MaintainCombatFocus(Time.unscaledTime, force: true); return; @@ -504,6 +533,78 @@ public static class InterestDirector return; } + if (_current.Completion == InterestCompletionKind.WarFront) + { + MaintainWarFront(Time.unscaledTime, force: true); + if (!_current.HasFollowUnit + && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) + { + if (StickyScoreboard.TryPromoteFollow(_current)) + { + MaintainWarFront(Time.unscaledTime, force: true); + return; + } + + EndCurrent(Time.unscaledTime, reason: "follow_lost"); + } + + return; + } + + if (_current.Completion == InterestCompletionKind.PlotActive) + { + MaintainPlotCell(Time.unscaledTime, force: true); + if (!_current.HasFollowUnit + && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) + { + if (StickyScoreboard.TryPromoteFollow(_current)) + { + MaintainPlotCell(Time.unscaledTime, force: true); + return; + } + + EndCurrent(Time.unscaledTime, reason: "follow_lost"); + } + + return; + } + + if (_current.Completion == InterestCompletionKind.FamilyPack) + { + MaintainFamilyPack(Time.unscaledTime, force: true); + if (!_current.HasFollowUnit + && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) + { + if (StickyScoreboard.TryPromoteFollow(_current)) + { + MaintainFamilyPack(Time.unscaledTime, force: true); + return; + } + + EndCurrent(Time.unscaledTime, reason: "follow_lost"); + } + + return; + } + + if (_current.Completion == InterestCompletionKind.StatusOutbreak) + { + MaintainStatusOutbreak(Time.unscaledTime, force: true); + if (!_current.HasFollowUnit + && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) + { + if (StickyScoreboard.TryPromoteFollow(_current)) + { + MaintainStatusOutbreak(Time.unscaledTime, force: true); + return; + } + + EndCurrent(Time.unscaledTime, reason: "follow_lost"); + } + + return; + } + if (_current.HasFollowUnit) { // Living subject still owned - reattach if vanilla cleared focus mid-scene. @@ -614,9 +715,9 @@ public static class InterestDirector newcomerBonus: 8f); if (handoffEnsemble != null) { - StabilizeCombatScale(_current, handoffEnsemble, Time.unscaledTime); + StickyScoreboard.StabilizeScale(_current.Sticky, handoffEnsemble, Time.unscaledTime); ApplyEnsembleFields(_current, handoffEnsemble); - RefreshStickyCombatSides( + StickyScoreboard.Refresh( _current, handoffEnsemble, handoff.current_position, radius); } @@ -624,7 +725,7 @@ public static class InterestDirector string handoffLabel; if (handoffEnsemble != null && (handoffEnsemble.ParticipantCount > 2 - || HasStickyCombatSides(_current) + || StickyScoreboard.HasOpposingSides(_current) || handoffEnsemble.Scale != EnsembleScale.Pair)) { handoffEnsemble.Focus = handoff; @@ -682,12 +783,12 @@ public static class InterestDirector string label; if (ensemble != null && ensemble.HasFocus) { - StabilizeCombatScale(_current, ensemble, Time.unscaledTime); + StickyScoreboard.StabilizeScale(_current.Sticky, ensemble, Time.unscaledTime); best = ensemble.Focus; foe = ensemble.Related; fighters = Mathf.Max(0, ensemble.ParticipantCount); ApplyEnsembleFields(_current, ensemble); - RefreshStickyCombatSides(_current, ensemble, pos, radius); + StickyScoreboard.Refresh(_current, ensemble, pos, radius); label = EventReason.Combat(ensemble); } else if (WorldActivityScanner.TryPickBestCombatFocus( @@ -702,18 +803,18 @@ public static class InterestDirector Focus = best, Related = foe }; - StabilizeCombatScale(_current, fallback, Time.unscaledTime); + StickyScoreboard.StabilizeScale(_current.Sticky, fallback, Time.unscaledTime); ApplyEnsembleFields(_current, fallback); - RefreshStickyCombatSides(_current, fallback, pos, radius); + StickyScoreboard.Refresh(_current, fallback, pos, radius); label = EventReason.Combat(fallback); _current.ParticipantCount = Math.Max(fighters, _current.CombatSideACount + _current.CombatSideBCount); } - else if (HasStickyCombatSides(_current)) + else if (StickyScoreboard.HasOpposingSides(_current)) { // No live fighters briefly - keep framed tip from sticky roster until combat goes cold. if (!_current.HasFollowUnit) { - TryPromoteStickyFollow(_current); + StickyScoreboard.TryPromoteFollow(_current); } var held = new LiveEnsemble @@ -724,18 +825,18 @@ public static class InterestDirector Related = _current.RelatedUnit, ParticipantCount = 0 }; - StabilizeCombatScale(_current, held, Time.unscaledTime); - RefreshStickyCombatSides(_current, held, pos, radius); + StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); + StickyScoreboard.Refresh(_current, held, pos, radius); if (held.Focus == null || !held.Focus.isAlive()) { held.Focus = _current.RelatedUnit; } - if ((held.Focus == null || !held.Focus.isAlive()) && TryPromoteStickyFollow(_current)) + if ((held.Focus == null || !held.Focus.isAlive()) && StickyScoreboard.TryPromoteFollow(_current)) { held.Focus = _current.FollowUnit; held.Related = _current.RelatedUnit; - RefreshStickyCombatSides(_current, held, pos, radius); + StickyScoreboard.Refresh(_current, held, pos, radius); } if (held.Focus == null || !held.Focus.isAlive()) @@ -756,7 +857,7 @@ public static class InterestDirector // 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); + bool curRostered = StickyScoreboard.IsOnRoster(_current, curFollow); if (!LiveEnsemble.IsCombatParticipant(best)) { if (curFighting || curRostered) @@ -856,275 +957,506 @@ public static class InterestDirector 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; - } - /// - /// 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. + /// Refresh sticky war tip (kingdom populations + follow) while WarFront completion is hot. /// - private static bool TryPromoteStickyFollow(InterestCandidate scene) + private static bool MaintainWarFront(float now, bool force) { - if (scene == null || !HasStickyCombatSides(scene)) + if (_current == null || _current.Completion != InterestCompletionKind.WarFront) { 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()) + if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) + { + return _current.HasFollowUnit; + } + + _lastCombatFocusAt = now; + return ApplyWarFrontMaintain(forceWatch: force); + } + + private static bool ApplyWarFrontMaintain(bool forceWatch) + { + if (_current == null || _current.Completion != InterestCompletionKind.WarFront) { return false; } - // Prefer the camp that still has more living members when both exist. - if (pickA != null && pickB != null) + if (!_current.HasFollowUnit) { - int nA = scene.CombatSideACount; - int nB = scene.CombatSideBCount; - if (nB > nA) - { - pick = pickB; - } - else if (nA > nB) - { - pick = pickA; - } + StickyScoreboard.TryPromoteFollow(_current); } - 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; + if (!_current.HasFollowUnit) + { + return false; + } + + Vector3 pos = _current.Position; try { - scene.Position = pick.current_position; + pos = _current.FollowUnit.current_position; } catch { // keep } - return true; + var held = new LiveEnsemble + { + Kind = EnsembleKind.WarFront, + Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)), + Frame = EnsembleFrame.KingdomVsKingdom, + Focus = _current.FollowUnit, + Related = _current.RelatedUnit, + ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount + }; + StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); + StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.WarTheaterRadius); + if (held.Focus != null && held.Focus.isAlive()) + { + _current.FollowUnit = held.Focus; + _current.SubjectId = EventFeedUtil.SafeId(held.Focus); + } + + if (held.Related != null && held.Related.isAlive()) + { + _current.RelatedUnit = held.Related; + _current.RelatedId = EventFeedUtil.SafeId(held.Related); + } + + string label = EventReason.WarFront(held); + if (string.IsNullOrEmpty(label)) + { + label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); + } + + if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) + { + label = _current.Label; + } + + bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; + bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); + _current.Label = label ?? ""; + _current.AssetId = "live_war"; + try + { + if (_current.FollowUnit != null) + { + _current.Position = _current.FollowUnit.current_position; + } + } + catch + { + // keep + } + + bool needWatch = forceWatch + || followChanged + || labelChanged + || !HasLivingCameraFocus() + || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); + if (needWatch && _current.HasFollowUnit) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + + return _current.HasFollowUnit; } /// - /// Lock opposing camp keys once, then refresh live counts (may be 0) every maintain pass. + /// Refresh sticky plot tip (plotter roster + target kingdom) while PlotActive is hot. /// - private static void RefreshStickyCombatSides( - InterestCandidate scene, - LiveEnsemble ensemble, - Vector3 pos, - float radius) + private static bool MaintainPlotCell(float now, bool force) { - if (scene == null || ensemble == null) + if (_current == null || _current.Completion != InterestCompletionKind.PlotActive) { - return; + return false; } - if (!HasStickyCombatSides(scene) && LiveEnsemble.HasOpposingCollectiveSides(ensemble)) + if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) { - // 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 ?? ""; + return _current.HasFollowUnit; } - 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; - } + _lastCombatFocusAt = now; + return ApplyPlotCellMaintain(forceWatch: force); } - private static void SeedStickyRosterFromEnsemble(InterestCandidate scene, LiveEnsemble ensemble) + private static bool ApplyPlotCellMaintain(bool forceWatch) { - if (scene == null || ensemble == null || !HasStickyCombatSides(scene)) + if (_current == null || _current.Completion != InterestCompletionKind.PlotActive) { - return; + return false; } - void Enroll(Actor actor) + if (!_current.HasFollowUnit) { - 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); - } + StickyScoreboard.TryPromoteFollow(_current); } - Enroll(ensemble.Focus); - Enroll(ensemble.Related); - Enroll(ensemble.SideA?.Best); - Enroll(ensemble.SideB?.Best); - if (ensemble.ParticipantIds != null) + if (!_current.HasFollowUnit) { - for (int i = 0; i < ensemble.ParticipantIds.Count; i++) - { - long id = ensemble.ParticipantIds[i]; - if (id == 0) - { - continue; - } + return false; + } - foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) - { - if (actor != null && actor.isAlive() && EventFeedUtil.SafeId(actor) == id) - { - Enroll(actor); - break; - } - } + Vector3 pos = _current.Position; + try + { + pos = _current.FollowUnit.current_position; + } + catch + { + // keep + } + + var held = new LiveEnsemble + { + Kind = EnsembleKind.PlotCell, + Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)), + Frame = EnsembleFrame.KingdomVsKingdom, + Focus = _current.FollowUnit, + Related = _current.RelatedUnit, + ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount, + SideA = new EnsembleSide + { + Key = _current.CombatSideAKey, + Display = "Plotters", + Count = _current.CombatSideACount, + Best = _current.FollowUnit + }, + SideB = new EnsembleSide + { + Key = _current.CombatSideBKey, + Display = LiveEnsemble.KingdomDisplay(_current.CombatSideBKey), + KingdomDisplay = LiveEnsemble.KingdomDisplay(_current.CombatSideBKey), + Count = _current.CombatSideBCount, + Best = _current.RelatedUnit + } + }; + StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); + StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.PlotTheaterRadius); + if (held.Focus != null && held.Focus.isAlive()) + { + _current.FollowUnit = held.Focus; + _current.SubjectId = EventFeedUtil.SafeId(held.Focus); + } + + if (held.Related != null && held.Related.isAlive()) + { + _current.RelatedUnit = held.Related; + _current.RelatedId = EventFeedUtil.SafeId(held.Related); + } + + // Plot stickies are groups only - never "Plot - Plotters (1)" / (0). + int plotters = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0; + if (plotters < 2) + { + _current.Label = ""; + _current.AssetId = "live_plot"; + return _current.HasFollowUnit; + } + + string label = EventReason.PlotCell(held); + if (string.IsNullOrEmpty(label)) + { + label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); + } + + if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) + { + label = _current.Label; + } + + bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; + bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); + _current.Label = label ?? ""; + _current.AssetId = "live_plot"; + try + { + if (_current.FollowUnit != null) + { + _current.Position = _current.FollowUnit.current_position; } } + catch + { + // keep + } + + bool needWatch = forceWatch + || followChanged + || labelChanged + || !HasLivingCameraFocus() + || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); + if (needWatch && _current.HasFollowUnit) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + + return _current.HasFollowUnit; + } + + /// + /// Refresh sticky family pack tip (roster + alpha promote) while FamilyPack is hot. + /// + private static bool MaintainFamilyPack(float now, bool force) + { + if (_current == null || _current.Completion != InterestCompletionKind.FamilyPack) + { + return false; + } + + if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) + { + return _current.HasFollowUnit; + } + + _lastCombatFocusAt = now; + return ApplyFamilyPackMaintain(forceWatch: force); + } + + private static bool ApplyFamilyPackMaintain(bool forceWatch) + { + if (_current == null || _current.Completion != InterestCompletionKind.FamilyPack) + { + return false; + } + + if (!_current.HasFollowUnit) + { + StickyScoreboard.TryPromoteFollow(_current); + } + + if (!_current.HasFollowUnit) + { + return false; + } + + Vector3 pos = _current.Position; + try + { + pos = _current.FollowUnit.current_position; + } + catch + { + // keep + } + + var held = new LiveEnsemble + { + Kind = EnsembleKind.FamilyPack, + Scale = LiveEnsemble.ScaleForCount(Math.Max(2, _current.CombatSideACount)), + Frame = EnsembleFrame.SpeciesVsSpecies, + Focus = _current.FollowUnit, + Related = _current.RelatedUnit, + ParticipantCount = _current.CombatSideACount, + SideA = new EnsembleSide + { + Key = _current.CombatSideAKey, + Display = string.IsNullOrEmpty(_current.Sticky?.SideADisplay) + ? LiveEnsemble.SpeciesDisplay(LiveEnsemble.SpeciesKeyOf(_current.FollowUnit)) + : _current.Sticky.SideADisplay, + Count = _current.CombatSideACount, + Best = _current.FollowUnit + } + }; + StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); + StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.FamilyTheaterRadius); + if (held.Focus != null && held.Focus.isAlive()) + { + _current.FollowUnit = held.Focus; + _current.SubjectId = EventFeedUtil.SafeId(held.Focus); + } + + if (held.Related != null && held.Related.isAlive()) + { + _current.RelatedUnit = held.Related; + _current.RelatedId = EventFeedUtil.SafeId(held.Related); + } + + string label = EventReason.FamilyPack(held); + if (string.IsNullOrEmpty(label)) + { + label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); + } + + if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) + { + label = _current.Label; + } + + bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; + bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); + _current.Label = label ?? ""; + _current.AssetId = "live_family"; + try + { + if (_current.FollowUnit != null) + { + _current.Position = _current.FollowUnit.current_position; + } + } + catch + { + // keep + } + + bool needWatch = forceWatch + || followChanged + || labelChanged + || !HasLivingCameraFocus() + || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); + if (needWatch && _current.HasFollowUnit) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + + return _current.HasFollowUnit; + } + + /// + /// Refresh sticky status outbreak tip while StatusOutbreak completion is hot. + /// + private static bool MaintainStatusOutbreak(float now, bool force) + { + if (_current == null || _current.Completion != InterestCompletionKind.StatusOutbreak) + { + return false; + } + + if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) + { + return _current.HasFollowUnit; + } + + _lastCombatFocusAt = now; + return ApplyStatusOutbreakMaintain(forceWatch: force); + } + + private static bool ApplyStatusOutbreakMaintain(bool forceWatch) + { + if (_current == null || _current.Completion != InterestCompletionKind.StatusOutbreak) + { + return false; + } + + if (!_current.HasFollowUnit) + { + StickyScoreboard.TryPromoteFollow(_current); + } + + if (!_current.HasFollowUnit) + { + return false; + } + + Vector3 pos = _current.Position; + try + { + pos = _current.FollowUnit.current_position; + } + catch + { + // keep + } + + string statusId = _current.StatusId ?? ""; + if (string.IsNullOrEmpty(statusId) + && LiveEnsemble.IsStatusOutbreakSideKey(_current.CombatSideAKey)) + { + statusId = _current.CombatSideAKey.Substring("status:".Length); + _current.StatusId = statusId; + } + + var held = new LiveEnsemble + { + Kind = EnsembleKind.StatusOutbreak, + Scale = LiveEnsemble.ScaleForCount(Math.Max(2, _current.CombatSideACount)), + Frame = EnsembleFrame.SpeciesVsSpecies, + Focus = _current.FollowUnit, + Related = _current.RelatedUnit, + ParticipantCount = _current.CombatSideACount, + SideA = new EnsembleSide + { + Key = string.IsNullOrEmpty(_current.CombatSideAKey) + ? "status:" + statusId + : _current.CombatSideAKey, + Display = string.IsNullOrEmpty(_current.Sticky?.SideADisplay) + ? LiveEnsemble.StatusDisplayName(statusId) + : _current.Sticky.SideADisplay, + Count = _current.CombatSideACount, + Best = _current.FollowUnit + } + }; + StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); + StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.StatusOutbreakRadius); + if (held.Focus != null && held.Focus.isAlive()) + { + _current.FollowUnit = held.Focus; + _current.SubjectId = EventFeedUtil.SafeId(held.Focus); + } + + if (held.Related != null && held.Related.isAlive()) + { + _current.RelatedUnit = held.Related; + _current.RelatedId = EventFeedUtil.SafeId(held.Related); + } + + // Outbreak stickies are groups only - never "Outbreak - X (0)" / solo (1). + int carriers = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0; + if (carriers < 2) + { + _current.Label = ""; + _current.AssetId = "live_outbreak"; + return _current.HasFollowUnit; + } + + string label = EventReason.StatusOutbreak(held); + if (string.IsNullOrEmpty(label)) + { + label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); + } + + if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) + { + label = _current.Label; + } + + bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; + bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); + _current.Label = label ?? ""; + _current.AssetId = "live_outbreak"; + try + { + if (_current.FollowUnit != null) + { + _current.Position = _current.FollowUnit.current_position; + } + } + catch + { + // keep + } + + bool needWatch = forceWatch + || followChanged + || labelChanged + || !HasLivingCameraFocus() + || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); + if (needWatch && _current.HasFollowUnit) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + + return _current.HasFollowUnit; + } + + private static bool HasStickyCombatSides(InterestCandidate scene) + { + return StickyScoreboard.HasOpposingSides(scene); } private static Actor ResolveAttackFoe(Actor actor) @@ -1153,56 +1485,6 @@ public static class InterestDirector return null; } - /// - /// Hold elevated combat scale briefly after fighter count dips so Mass does not flap to Duel. - /// - 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; - } - /// /// Rewatch tip only when tier or collective sides change - not on (6)↔(5) count flaps. /// @@ -1284,23 +1566,7 @@ public static class InterestDirector 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); - } - } + StickyScoreboard.TryLockFromEnsemble(scene.Sticky, ensemble); } /// @@ -1372,7 +1638,7 @@ public static class InterestDirector } ApplyEnsembleFields(_current, ensemble); - RefreshStickyCombatSides( + StickyScoreboard.Refresh( _current, ensemble, ensemble.Focus.current_position, @@ -1418,7 +1684,6 @@ public static class InterestDirector _current.Label = ""; _current.ClearCombatSticky(); - _combatScaleDropSince = -999f; _lastCombatFocusAt = now; CameraDirector.Watch(_current.ToInterestEvent()); } @@ -1429,6 +1694,291 @@ public static class InterestDirector MaintainCombatFocus(Time.unscaledTime, force: true); } + /// Harness: force one war-front maintenance pass. + public static void HarnessMaintainWarFront() + { + MaintainWarFront(Time.unscaledTime, force: true); + } + + /// Harness: force one plot-cell maintenance pass. + public static void HarnessMaintainPlotCell() + { + MaintainPlotCell(Time.unscaledTime, force: true); + } + + /// Harness: force one family-pack maintenance pass. + public static void HarnessMaintainFamilyPack() + { + MaintainFamilyPack(Time.unscaledTime, force: true); + } + + /// Harness: force one status-outbreak maintenance pass. + public static void HarnessMaintainStatusOutbreak() + { + MaintainStatusOutbreak(Time.unscaledTime, force: true); + } + + /// Harness: apply a synthetic war ensemble onto the current WarFront scene. + public static bool HarnessApplyWarEnsemble(LiveEnsemble ensemble) + { + if (_current == null + || ensemble == null + || !ensemble.HasFocus + || _current.Completion != InterestCompletionKind.WarFront) + { + return false; + } + + ensemble.Kind = EnsembleKind.WarFront; + ensemble.Frame = EnsembleFrame.KingdomVsKingdom; + StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble); + StickyScoreboard.Refresh( + _current, + ensemble, + ensemble.Focus.current_position, + StickyScoreboard.WarTheaterRadius); + _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.WarFront(ensemble); + _current.AssetId = "live_war"; + _current.ParticipantCount = ensemble.ParticipantCount; + try + { + _current.Position = ensemble.Focus.current_position; + } + catch + { + // keep + } + + CameraDirector.Watch(_current.ToInterestEvent()); + return true; + } + + /// Harness: apply a synthetic family ensemble onto the current FamilyPack scene. + public static bool HarnessApplyFamilyEnsemble(LiveEnsemble ensemble) + { + if (_current == null + || ensemble == null + || !ensemble.HasFocus + || _current.Completion != InterestCompletionKind.FamilyPack) + { + return false; + } + + ensemble.Kind = EnsembleKind.FamilyPack; + ensemble.Frame = EnsembleFrame.SpeciesVsSpecies; + StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble); + if (ensemble.SideA != null) + { + _current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count); + if (!string.IsNullOrEmpty(ensemble.SideA.Key)) + { + _current.Sticky.SideAKey = ensemble.SideA.Key; + } + + if (!string.IsNullOrEmpty(ensemble.SideA.Display) + && !LiveEnsemble.IsFamilyPackSideKey(ensemble.SideA.Display)) + { + _current.Sticky.SideADisplay = ensemble.SideA.Display; + } + + ensemble.SideA.Count = _current.Sticky.SideACount; + ensemble.SideA.Display = _current.Sticky.SideADisplay; + } + + _current.Sticky.SideBKey = ""; + _current.Sticky.SideBDisplay = ""; + _current.Sticky.SideBCount = 0; + _current.Sticky.PeakParticipants = Math.Max( + _current.Sticky.PeakParticipants, + _current.Sticky.SideACount); + _current.FollowUnit = ensemble.Focus; + _current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus); + _current.RelatedUnit = ensemble.Related; + _current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0; + if (_current.Sticky != null && ensemble.ParticipantIds != null) + { + _current.Sticky.SideAIds.Clear(); + for (int i = 0; i < ensemble.ParticipantIds.Count; i++) + { + long id = ensemble.ParticipantIds[i]; + if (id != 0) + { + _current.Sticky.SideAIds.Add(id); + } + } + + if (ensemble.SideA != null) + { + _current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count); + } + } + + _current.Label = EventReason.FamilyPack(ensemble); + if (string.IsNullOrEmpty(_current.Label)) + { + _current.Label = StickyScoreboard.FormatReason(_current, ensemble.Focus, ensemble.Related); + } + + _current.AssetId = "live_family"; + _current.ParticipantCount = ensemble.ParticipantCount; + try + { + _current.Position = ensemble.Focus.current_position; + } + catch + { + // keep + } + + CameraDirector.Watch(_current.ToInterestEvent()); + return true; + } + + /// Harness: apply a synthetic outbreak ensemble onto the current StatusOutbreak scene. + public static bool HarnessApplyStatusOutbreakEnsemble(LiveEnsemble ensemble) + { + if (_current == null + || ensemble == null + || !ensemble.HasFocus + || _current.Completion != InterestCompletionKind.StatusOutbreak) + { + return false; + } + + ensemble.Kind = EnsembleKind.StatusOutbreak; + ensemble.Frame = EnsembleFrame.SpeciesVsSpecies; + StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble); + if (ensemble.SideA != null) + { + _current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count); + if (!string.IsNullOrEmpty(ensemble.SideA.Key)) + { + _current.Sticky.SideAKey = ensemble.SideA.Key; + if (LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key)) + { + _current.StatusId = ensemble.SideA.Key.Substring("status:".Length); + } + } + + if (!string.IsNullOrEmpty(ensemble.SideA.Display) + && !LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Display)) + { + _current.Sticky.SideADisplay = ensemble.SideA.Display; + } + + ensemble.SideA.Count = _current.Sticky.SideACount; + ensemble.SideA.Display = _current.Sticky.SideADisplay; + } + + _current.Sticky.SideBKey = ""; + _current.Sticky.SideBDisplay = ""; + _current.Sticky.SideBCount = 0; + _current.Sticky.PeakParticipants = Math.Max( + _current.Sticky.PeakParticipants, + _current.Sticky.SideACount); + _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.StatusOutbreak(ensemble); + if (string.IsNullOrEmpty(_current.Label)) + { + _current.Label = StickyScoreboard.FormatReason(_current, ensemble.Focus, ensemble.Related); + } + + _current.AssetId = "live_outbreak"; + _current.ParticipantCount = ensemble.ParticipantCount; + try + { + _current.Position = ensemble.Focus.current_position; + } + catch + { + // keep + } + + CameraDirector.Watch(_current.ToInterestEvent()); + return true; + } + + /// Harness: apply a synthetic plot ensemble onto the current PlotActive scene. + public static bool HarnessApplyPlotEnsemble(LiveEnsemble ensemble) + { + if (_current == null + || ensemble == null + || !ensemble.HasFocus + || _current.Completion != InterestCompletionKind.PlotActive) + { + return false; + } + + ensemble.Kind = EnsembleKind.PlotCell; + ensemble.Frame = EnsembleFrame.KingdomVsKingdom; + StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble); + // Harness drives explicit counts; seed roster then pin counts for tip asserts. + if (ensemble.SideA != null) + { + _current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count); + _current.Sticky.SideADisplay = "Plotters"; + if (!string.IsNullOrEmpty(ensemble.SideA.Key)) + { + _current.Sticky.SideAKey = ensemble.SideA.Key; + } + } + + if (ensemble.SideB != null) + { + _current.Sticky.SideBCount = Math.Max(0, ensemble.SideB.Count); + if (!string.IsNullOrEmpty(ensemble.SideB.Key)) + { + _current.Sticky.SideBKey = ensemble.SideB.Key; + _current.Sticky.SideBDisplay = string.IsNullOrEmpty(ensemble.SideB.Display) + ? LiveEnsemble.KingdomDisplay(ensemble.SideB.Key) + : ensemble.SideB.Display; + _current.Sticky.SideBKingdom = _current.Sticky.SideBDisplay; + } + } + + if (ensemble.SideA != null) + { + ensemble.SideA.Display = "Plotters"; + ensemble.SideA.Count = _current.Sticky.SideACount; + } + + if (ensemble.SideB != null) + { + ensemble.SideB.Count = _current.Sticky.SideBCount; + ensemble.SideB.Display = _current.Sticky.SideBDisplay; + } + + _current.Sticky.PeakParticipants = Math.Max( + _current.Sticky.PeakParticipants, + _current.Sticky.TotalCount); + _current.FollowUnit = ensemble.Focus; + _current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus); + _current.RelatedUnit = ensemble.Related; + _current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0; + // Groups only - leave Label empty below 2 plotters (no unit-plot fallback on sticky). + _current.Label = EventReason.PlotCell(ensemble) ?? ""; + _current.AssetId = "live_plot"; + _current.ParticipantCount = ensemble.ParticipantCount; + try + { + _current.Position = ensemble.Focus.current_position; + } + catch + { + // keep + } + + CameraDirector.Watch(_current.ToInterestEvent()); + return true; + } + /// /// Keep a living focus unit whenever Idle Spectator is on. /// Covers death mid-combat and quiet_grace windows where vanilla cleared follow. @@ -1456,6 +2006,42 @@ public static class InterestDirector } } + if (_current.Completion == InterestCompletionKind.WarFront) + { + MaintainWarFront(now, force: true); + if (HasLivingCameraFocus()) + { + return; + } + } + + if (_current.Completion == InterestCompletionKind.PlotActive) + { + MaintainPlotCell(now, force: true); + if (HasLivingCameraFocus()) + { + return; + } + } + + if (_current.Completion == InterestCompletionKind.FamilyPack) + { + MaintainFamilyPack(now, force: true); + if (HasLivingCameraFocus()) + { + return; + } + } + + if (_current.Completion == InterestCompletionKind.StatusOutbreak) + { + MaintainStatusOutbreak(now, force: true); + if (HasLivingCameraFocus()) + { + return; + } + } + if (_current.HasFollowUnit) { CameraDirector.Watch(_current.ToInterestEvent()); @@ -1467,8 +2053,12 @@ public static class InterestDirector if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive()) { - // Non-combat ownership handoff; combat already tried picker above. - if (_current.Completion != InterestCompletionKind.CombatActive) + // Non-combat ownership handoff; sticky scenes already tried picker above. + if (_current.Completion != InterestCompletionKind.CombatActive + && _current.Completion != InterestCompletionKind.WarFront + && _current.Completion != InterestCompletionKind.PlotActive + && _current.Completion != InterestCompletionKind.FamilyPack + && _current.Completion != InterestCompletionKind.StatusOutbreak) { _current.FollowUnit = _current.RelatedUnit; _current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit); @@ -1796,6 +2386,7 @@ public static class InterestDirector // Sticky event holds are never ambient even if score is temporarily fill-band. if (c.Completion == InterestCompletionKind.CombatActive || c.Completion == InterestCompletionKind.StatusPhase + || c.Completion == InterestCompletionKind.StatusOutbreak || c.Completion == InterestCompletionKind.HappinessGrief || c.Completion == InterestCompletionKind.ActivityActive) { @@ -1817,9 +2408,16 @@ public static class InterestDirector switch (current.Completion) { case InterestCompletionKind.CombatActive: + case InterestCompletionKind.WarFront: + case InterestCompletionKind.PlotActive: classCap = w.maxWatchCombat; break; + case InterestCompletionKind.FamilyPack: + // Soft cluster - moment-class cap so packs cannot outlast unit events. + classCap = w.maxWatchMoment; + break; case InterestCompletionKind.StatusPhase: + case InterestCompletionKind.StatusOutbreak: classCap = w.maxWatchStatus; break; case InterestCompletionKind.HappinessGrief: @@ -1846,12 +2444,20 @@ public static class InterestDirector // Sticky live holds use the class MaxWatch (e.g. combat 60s) - do not let a // short candidate MaxWatch max_cap the fight while it is still active. if (current.Completion == InterestCompletionKind.CombatActive + || current.Completion == InterestCompletionKind.WarFront + || current.Completion == InterestCompletionKind.PlotActive || current.Completion == InterestCompletionKind.StatusPhase + || current.Completion == InterestCompletionKind.StatusOutbreak || current.Completion == InterestCompletionKind.HappinessGrief || current.Completion == InterestCompletionKind.ActivityActive) { cap = Mathf.Max(current.MaxWatch, classCap); } + else if (current.Completion == InterestCompletionKind.FamilyPack) + { + // Soft pack: honor the short candidate MaxWatch (do not stretch to combat caps). + cap = Mathf.Min(current.MaxWatch, classCap); + } else { cap = Mathf.Min(current.MaxWatch, classCap); @@ -1982,6 +2588,17 @@ public static class InterestDirector return true; } + // Soft family pack: yield to discrete unit events after a short hold. + if (IsSoftStickyCluster(_current) + && !IsSoftStickyCluster(candidate) + && !nextFill + && !IsAmbientShot(candidate) + && onCurrent >= SoftClusterYieldSeconds + && nextScore >= curScore - w.rotateSlack) + { + return true; + } + // Same-arc refresh (combat/hatch/grief keys) may replace without full margin. if (sticky && IsSameStoryArc(_current, candidate) && nextScore >= curScore - w.rotateSlack) { @@ -1989,12 +2606,14 @@ public static class InterestDirector } // Instant score-margin cut - no settle grace, no MinDwell block. - if (nextScore >= curScore + cutMargin) + // Soft clusters use the normal cut-in margin (not stickyCutInMargin). + float effectiveMargin = IsSoftStickyCluster(_current) ? w.cutInMargin : cutMargin; + if (nextScore >= curScore + effectiveMargin) { return true; } - if (sticky && nextScore < curScore + cutMargin) + if (sticky && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin) { InterestDropLog.Record( "below_margin", @@ -2030,6 +2649,12 @@ public static class InterestDirector && nextScore >= curScore - w.rotateSlack; } + /// Soft multi-actor holds that must yield to discrete unit events. + private const float SoftClusterYieldSeconds = 4f; + + private static bool IsSoftStickyCluster(InterestCandidate c) => + c != null && c.Completion == InterestCompletionKind.FamilyPack; + private static bool IsStickyStoryScene(InterestCandidate c) { if (c == null) @@ -2037,8 +2662,12 @@ public static class InterestDirector return false; } + // FamilyPack is a soft cluster - not hard sticky (see IsSoftStickyCluster). if (c.Completion == InterestCompletionKind.CombatActive + || c.Completion == InterestCompletionKind.WarFront + || c.Completion == InterestCompletionKind.PlotActive || c.Completion == InterestCompletionKind.StatusPhase + || c.Completion == InterestCompletionKind.StatusOutbreak || c.Completion == InterestCompletionKind.HappinessGrief) { return true; diff --git a/IdleSpectator/InterestScoring.cs b/IdleSpectator/InterestScoring.cs index e496dc5..3ef8e87 100644 --- a/IdleSpectator/InterestScoring.cs +++ b/IdleSpectator/InterestScoring.cs @@ -106,30 +106,39 @@ public static class InterestScoring float scaleBonus = 0f; int fighters = c.ParticipantCount; int notables = c.NotableParticipantCount; - if (fighters >= w.massFighterThreshold) + // Crowd bonuses are for combat/war/plot theaters - not soft pack/outbreak clusters. + bool crowdScale = c.Completion == InterestCompletionKind.CombatActive + || c.Completion == InterestCompletionKind.WarFront + || c.Completion == InterestCompletionKind.PlotActive + || c.AssetId == "live_battle" + || string.Equals(c.Category, "Combat", System.StringComparison.OrdinalIgnoreCase); + if (crowdScale) { - scaleBonus += w.massBaseBonus - + Mathf.Min(w.massExtraCap, (fighters - w.massFighterThreshold) * w.massPerExtraFighter); - } - else if (fighters > 0 && fighters <= w.duelMaxFighters) - { - if (notables >= 2) + if (fighters >= w.massFighterThreshold) { - scaleBonus += w.duelNotablePairBonus; + scaleBonus += w.massBaseBonus + + Mathf.Min(w.massExtraCap, (fighters - w.massFighterThreshold) * w.massPerExtraFighter); } - else if (notables == 1) + else if (fighters > 0 && fighters <= w.duelMaxFighters) { - scaleBonus += w.duelSingleNotableBonus; + if (notables >= 2) + { + scaleBonus += w.duelNotablePairBonus; + } + else if (notables == 1) + { + scaleBonus += w.duelSingleNotableBonus; + } + else + { + scaleBonus += w.duelAnonymousPenalty; + } } - else - { - scaleBonus += w.duelAnonymousPenalty; - } - } - if (notables > 0 && fighters >= w.skirmishMinFighters) - { - scaleBonus += Mathf.Min(w.notableSkirmishCap, notables * w.notableSkirmishPer); + if (notables > 0 && fighters >= w.skirmishMinFighters) + { + scaleBonus += Mathf.Min(w.notableSkirmishCap, notables * w.notableSkirmishPer); + } } c.TotalScore = c.EventStrength + scaleBonus @@ -393,7 +402,9 @@ public static class InterestScoring return true; } - if (c.Completion == InterestCompletionKind.CombatActive) + if (c.Completion == InterestCompletionKind.CombatActive + || c.Completion == InterestCompletionKind.WarFront + || c.Completion == InterestCompletionKind.PlotActive) { return true; } diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index 29821d8..2652955 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -479,6 +479,9 @@ public sealed class UnitDossier case "duel": case "skirmish": case "mass": + case "plot": + case "pack": + case "outbreak": case "clash": case "grief": case "mourning": @@ -498,6 +501,7 @@ public sealed class UnitDossier case "family": case "starts": case "gathers": + case "gathering": case "damages": case "consumes": case "spectacle": @@ -519,6 +523,10 @@ public sealed class UnitDossier || reason.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase) || reason.StartsWith("Battle", StringComparison.OrdinalIgnoreCase) || reason.StartsWith("Mass", StringComparison.OrdinalIgnoreCase) + || reason.StartsWith("War -", StringComparison.OrdinalIgnoreCase) + || reason.StartsWith("Plot -", StringComparison.OrdinalIgnoreCase) + || reason.StartsWith("Pack -", StringComparison.OrdinalIgnoreCase) + || reason.StartsWith("Outbreak -", StringComparison.OrdinalIgnoreCase) || reason.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0; } @@ -794,6 +802,10 @@ public sealed class UnitDossier || t.StartsWith("Skirmish", System.StringComparison.OrdinalIgnoreCase) || t.StartsWith("Battle", System.StringComparison.OrdinalIgnoreCase) || t.StartsWith("Mass", System.StringComparison.OrdinalIgnoreCase) + || t.StartsWith("War -", System.StringComparison.OrdinalIgnoreCase) + || t.StartsWith("Plot -", System.StringComparison.OrdinalIgnoreCase) + || t.StartsWith("Pack -", System.StringComparison.OrdinalIgnoreCase) + || t.StartsWith("Outbreak -", System.StringComparison.OrdinalIgnoreCase) || t.StartsWith("Rebellion:", System.StringComparison.OrdinalIgnoreCase) || t.StartsWith("War:", System.StringComparison.OrdinalIgnoreCase) || t.IndexOf(':') >= 0; diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index e67bf7d..c671879 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.25.64", - "description": "AFK Idle Spectator (I) + Lore (L). Sticky combat handoff - no thin fighting fallback.", + "version": "0.25.82", + "description": "AFK Idle Spectator (I) + Lore (L). PlotCell sticky needs 2+ plotters; Watching log debounced.", "GUID": "com.dazed.idlespectator" } diff --git a/docs/event-reason.md b/docs/event-reason.md index 8207e1c..c618429 100644 --- a/docs/event-reason.md +++ b/docs/event-reason.md @@ -31,7 +31,9 @@ Drops (ambient, createsInterest=false, identity filter, below margin, …) go to ## 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. +Sticky scoreboard state lives on `InterestCandidate.Sticky` (`LiveSceneStickyState`); +`StickyScoreboard` owns lock / enroll / refresh / scale-hold / follow-promote. +The director only decides camera + completion policy and calls those helpers while `CombatActive` stays hot. | Scale | Fighters | Typical reason | |-------|----------|----------------| @@ -48,21 +50,51 @@ Counts stay on those tracked members while alive (combat or not) until combat gr 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. +Scale hold (~2.5s peak hysteresis on `Sticky.ScaleDropSince`) keeps Mass/Battle from flapping to Duel on brief count dips. Combat camera hysteresis only applies between living fighters - sleepers/bystanders never stick. +### Module layout (sticky) + +| Piece | Owns | +|-------|------| +| `LiveEnsemble` | Kind/scale/frame snapshot + combat (and later war/plot) **builders** | +| `LiveSceneStickyState` | Durable side keys, member id rosters, peak/scale-hold | +| `StickyScoreboard` | Lock sides, enroll, refresh counts, promote follow, format reason | +| `InterestDirector` | Camera maintain, completion, when to call scoreboard | +| `EventReason.Ensemble` / `.Combat` / `.WarFront` / `.PlotCell` / `.FamilyPack` / `.StatusOutbreak` | Orange prose from a snapshot | + +Compat: `InterestCandidate.CombatSide*` forwarders still work for harness / older call sites. + +### Discovery: next sticky consumers (game reflection) + +Verified against `Assembly-CSharp` multi-actor containers that already expose rosters / opposing sides: + +| Priority | Kind | Live game surface | Why sticky fits | Fit notes | +|----------|------|-------------------|-----------------|-----------| +| done | **WarFront** | `War.main_attacker` / `main_defender`, kingdom pop | Natural A/B camps | Implemented (`War - A (n) vs B (m)`) | +| done | **PlotCell** | `Plot.units` (2+), `target_kingdom` / city / actor | Plotters vs target kingdom | Implemented (`Plot - Plotters (n) vs A (m)`; solo → unit Plot tip) | +| done | **FamilyPack** | `Family.units`, `getAlpha` / `setAlpha` | Pack identity + alpha handoff | Implemented (`Pack - Species (n) · gathering`) | +| done | **StatusOutbreak** | Scan actors with same `StatusAsset` id in radius | Spatial cluster like combat | Implemented (`Outbreak - Cursed (n)`) | +| 5 | **Army / Clan** | `Army.units` + captain; `Clan.units` + chief | Ready member lists | Usually feed WarFront / siege framing rather than a separate orange scene | +| later | Alliance | `Alliance.kingdoms_hashset`, `getUnits()` | Meta-side aggregation | Prefer war sides unless alliance-vs-alliance tips are wanted | +| later | DiscoveryCluster | pending first-seens near anchor | Soft cluster | Lower stakes; optional | + +Do **not** invent inventory from catalogs: builders must read live `World.world.wars` / `plots` / `families` / unit status like combat already does. + ### 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 | +| Kind | Live signal | Side framing | Status | +|------|-------------|--------------|--------| +| Combat | attack_target / in_combat cluster | kingdom → species → camps | Done | +| WarFront | `War` attackers/defenders + kingdom pop | kingdom vs kingdom (`War - A (n) vs B (m)`) | Done | +| PlotCell | `Plot.units` (2+) + target kingdom | plotters vs kingdom (`Plot - Plotters (n) vs A (m)`) | Done | +| FamilyPack | `Family.units` + alpha | soft species pack (`Pack - Wolves (5) · gathering`); yields to discrete unit events | Done | +| StatusOutbreak | same status id in radius, **2+ carriers** | status carriers (`Outbreak - Cursed (4)`; drowning OK as group) | Done | +| DiscoveryCluster | pending first-seens near anchor | species | Optional | -Shared pieces: `LiveEnsemble`, side helpers, `EventReason.Ensemble`, maintain/write-back pattern in `InterestDirector`. +Roadmap + shared contract: [`sticky-ensemble-plan.md`](sticky-ensemble-plan.md). + +Shared pieces: `LiveEnsemble` builders, `StickyScoreboard`, `EventReason.Ensemble`. ## EventReason @@ -78,6 +110,9 @@ Feeds call typed helpers (`Fight`, `BuildingEat`, `SeekingLover`, …) or `Apply | `Events/Feeds/` | Build + register **A** candidates from catalog dials | | `Events/Patches/` | Harmony hooks → feeds / Activity | | `Events/EventReason.cs` | Orange reason sentences | +| `Events/LiveEnsemble.cs` | Multi-actor snapshot + combat builders | +| `Events/LiveSceneStickyState.cs` | Durable sticky scoreboard on a candidate | +| `Events/StickyScoreboard.cs` | Lock / enroll / refresh / promote / scale-hold | | `Events/EventFeedUtil.cs` | Shared Register path | | `Events/InterestDropLog.cs` | Why a beat stayed B or missed A | | `Events/DiscreteEventEntry.cs` | Shared catalog row shape | diff --git a/docs/event-trigger-audit.tsv b/docs/event-trigger-audit.tsv index 3b65172..56437d5 100644 --- a/docs/event-trigger-audit.tsv +++ b/docs/event-trigger-audit.tsv @@ -3,7 +3,7 @@ decisions find_lover 62 Actor.setDecisionCooldown → DeferredInterestFeed.Emit decisions try_to_steal_money 68 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done decisions banish_unruly_clan_members 76 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done decisions kill_unruly_clan_members 82 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done -decisions try_new_plot 78 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done +decisions try_new_plot 78 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision demoted Intent-only (no Plot yet); camera via live PlotInterestFeed instead done decisions king_change_kingdom_culture 84 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done decisions king_change_kingdom_language 82 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done decisions king_change_kingdom_religion 84 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done diff --git a/docs/sticky-ensemble-plan.md b/docs/sticky-ensemble-plan.md new file mode 100644 index 0000000..9196b7b --- /dev/null +++ b/docs/sticky-ensemble-plan.md @@ -0,0 +1,71 @@ +# Sticky ensemble plan + +Consistent multi-actor camera scenes across combat, war, plot, family, and status. + +Combat is the reference implementation. Every new kind must reuse the same modules and rules - not invent a parallel tip system. + +## Shared modules (do not fork) + +| Piece | Role | +|-------|------| +| `LiveEnsemble` | Snapshot + **kind-specific builders** (`TryBuildCombat`, `TryBuildWar`, …) | +| `LiveSceneStickyState` | Durable side keys, member id rosters, peak / scale-hold on `InterestCandidate.Sticky` | +| `StickyScoreboard` | Lock sides once, enroll, refresh counts, promote follow, stabilize scale | +| `EventReason.Ensemble` | Orange prose dispatch by `EnsembleKind` | +| `InterestDirector` | Camera + completion only; calls scoreboard; never authors side labels | + +Authored catalogs (`event-catalog.json`, war type seeds) are **policy** (strength, whether camera-worthy). Live `War` / `Plot` / `Family` / unit status is **inventory**. + +## Sticky contract (every kind) + +1. **Lock opposing identity once** when the live scene first has clear sides (kingdom / species / plot cell / family). +2. **Enroll member ids** while the scene is hot; prune dead; allow new matching members. +3. **Counts may hit 0** on a wiped side; survivors on the other side stay counted. +4. **Scale / tier hold** (~2.5s) so tips do not flap (Mass↔Battle, War↔thin unit line). +5. **Promote follow** from the sticky roster when the camera subject dies - do not end into thin prose. +6. **Never** use unit proper names as collective side labels. +7. **Never** collapse a sticky multi tip to `{name} is fighting` / bare melee crumbs. +8. Builder reads **live game objects**; director does not hardcode id lists. + +## Kind roadmap + +| Priority | Kind | Live surface | Side frame | Completion | Tip shape | Status | +|----------|------|--------------|------------|------------|-----------|--------| +| 0 | **Combat** | attack_target / in_combat cluster | kingdom → species → camps | `CombatActive` | `Battle - Humans (4) vs Wolves (0)` | Done | +| 1 | **WarFront** | `War.main_attacker` / `main_defender`, kingdom pop | kingdom vs kingdom | `WarFront` | `War - Essiona (120) vs Northreach (80)` | Done | +| 2 | **PlotCell** | `Plot.units`, `target_*`, **2+ plotters** (solo → unit Plot tip) | plotters vs target kingdom/city when useful | `PlotActive` | `Plot - Plotters (3) vs Essiona (120)` | Done | +| 3 | **FamilyPack** | `Family.units`, alpha | family / species pack | `FamilyPack` | `Pack - Wolves (5) · gathering` (soft cluster - yields to unit events) | Done | +| 4 | **StatusOutbreak** | affliction clusters only, **2+ carriers** (not love/emotion/ambient; never tip at 0/1) | status carriers | `StatusOutbreak` | `Outbreak - Cursed (4)` / `Outbreak - Drowning (3)` | Done | +| later | Army / Clan | `Army.units` / `Clan.units` | usually feed WarFront / siege | - | - | Feed only | +| later | Alliance | `Alliance.getUnits()` | meta aggregation | - | - | Prefer war sides | +| later | DiscoveryCluster | pending first-seens | soft cluster | - | - | Optional | + +## Per-kind builder checklist + +When adding a kind: + +1. Add / use `EnsembleKind` value (already stubbed where possible). +2. Implement `LiveEnsemble.TryBuild…` from live world state. +3. Add `EventReason.…` and wire `EventReason.Ensemble` switch (no silent fall-through to Combat unless identical). +4. Set `Sticky.Kind` on lock; enroll rules may differ (combat = fighters; war = kingdom members; plot = plot.units). +5. Choose `InterestCompletionKind` that stays active while the **scene** is live (not only while one unit swings). +6. Director: maintain/handoff path calls `StickyScoreboard.Refresh` / `TryPromoteFollow` / `StabilizeScale`. +7. Feed registers `AssetId` (`live_battle`, `live_war`, …) + completion + presentable Label. +8. Harness scenario: lock sticky → dip counts / lose follow → tip stays framed; no thin fallback. +9. Update this table + `docs/event-reason.md`. +10. Gate: new scenario ×3 + prior combat sticky ×3. + +## WarFront specifics + +- Sides: `main_attacker` vs `main_defender` kingdom names (pretty via `KingdomDisplay`). +- Counts: kingdom `countUnits` / population; sticky roster seeds kings + nearby war units for promote-follow. +- Enroll: matching kingdom key in theater radius (not combat-only). +- City capture / siege: same war sides; do not invent a second sticky identity. +- Begin/end war beats may stay FixedDwell one-shots; **active** wars use `WarFront` completion + sticky scoreboard. + +## Non-goals + +- Hardcoded complete lists of war types / plot ids as existence checks. +- Parallel “war tip” string builders outside `EventReason`. +- Director-owned side display strings. +- Quitting the game from harness / mod code.