Stable Sticky

This commit is contained in:
DazedAnon 2026-07-17 01:54:59 -05:00
parent 7dda02bcea
commit c5b3c96886
31 changed files with 6129 additions and 470 deletions

View file

@ -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)

View file

@ -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);
}
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Reference Include="Assembly-CSharp">
<HintPath>/home/dazed/.local/share/Steam/steamapps/common/worldbox/worldbox_Data/Managed/Assembly-CSharp.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>

42
.tmp-reflect/DumpPlot.cs Normal file
View file

@ -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));
}
}
}

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -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<arity;i++) args[i]=DecodeType(ref r);
return gen+"<"+string.Join(",", args)+">";
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);
}
}
}

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

22
.tmp-reflect/Program.cs Normal file
View file

@ -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);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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())

View file

@ -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")

View file

@ -198,7 +198,89 @@ public static class EventReason
return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
}
/// <summary>Dispatch by ensemble kind (combat first; other kinds plug in later).</summary>
/// <summary>
/// War front orange reason: <c>War - Essiona (120) vs Northreach (80)</c>.
/// Kingdom sides only; never unit proper names.
/// </summary>
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;
}
/// <summary>
/// Plot cell orange reason: <c>Plot - Plotters (3) vs Essiona (120)</c>.
/// Side A is a collective plotter camp; Side B is the target kingdom. Never unit names.
/// </summary>
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) + ")";
}
/// <summary>Dispatch by ensemble kind (combat / war / plot; other kinds plug in later).</summary>
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
}
}
/// <summary>
/// Sticky family pack orange reason: <c>Pack - Wolves (5) · gathering</c>.
/// Species-framed collective + why the camera holds; never unit proper names.
/// </summary>
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;
}
/// <summary>
/// Sticky status outbreak orange reason: <c>Outbreak - Cursed (4)</c>.
/// Collective status label + carrier count; never unit proper names.
/// </summary>
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);

View file

@ -0,0 +1,172 @@
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Soft FamilyPack sticky tips. Cold-starts only from real relationship mutations
/// (<see cref="EmitFromActor"/>); <see cref="Tick"/> only refreshes the current pack scene.
/// Join/leave/form one-shot prose stays on <see cref="RelationshipInterestFeed"/>.
/// </summary>
public static class FamilyInterestFeed
{
/// <summary>Warm-social band - below notice milestones so unit events can cut in.</summary>
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
}
}
/// <summary>
/// After a join/form beat, optionally open a soft sticky pack scene for the living family.
/// </summary>
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;
}
}

View file

@ -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);
}

View file

@ -1,10 +1,14 @@
using System;
using System.Collections;
using System.Reflection;
using UnityEngine;
namespace IdleSpectator;
/// <summary>Registers plot interest from Harmony hooks and World.plots polling.</summary>
/// <summary>
/// Registers plot interest from Harmony hooks and World.plots polling.
/// Active plots with a target kingdom use sticky PlotCell tips; begin/end stay FixedDwell.
/// </summary>
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;
}

View file

@ -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)

View file

@ -0,0 +1,229 @@
using System;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// 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.
/// </summary>
public static class StatusOutbreakFeed
{
/// <summary>
/// Affliction / spectacle statuses sampled by <see cref="Tick"/> for cluster outbreaks.
/// Social statuses (fell_in_love, sleeping, …) must also pass <see cref="IsOutbreakWorthy"/>.
/// </summary>
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<string>(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;
}
}
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -8,6 +8,7 @@ namespace IdleSpectator;
/// <summary>
/// Polls live WarManager state and registers ongoing war interest candidates.
/// Active wars use <see cref="LiveEnsemble.TryBuildWar"/> + sticky WarFront tips.
/// </summary>
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;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>
/// 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 <see cref="InterestCandidate"/>; cleared when the scene goes cold.
/// </summary>
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<long> SideAIds = new List<long>(8);
public string SideBKey = "";
public string SideBDisplay = "";
public string SideBKingdom = "";
public int SideBCount;
public readonly List<long> SideBIds = new List<long>(8);
/// <summary>
/// True when sticky identity is locked. Opposing camps need A+B; FamilyPack locks Side A only.
/// </summary>
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]);
}
}
}

View file

@ -0,0 +1,660 @@
using System;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Promote another living roster member onto the candidate when follow is lost.
/// </summary>
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;
}
/// <summary>
/// Hold elevated scale briefly after participant count dips.
/// </summary>
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;
}
/// <summary>
/// Lock opposing camps once (when multi), enroll members, rewrite ensemble sides/counts.
/// </summary>
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<Actor>(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;
}
}
/// <summary>Lock sticky keys from a live opposing collective ensemble (once).</summary>
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;
}
/// <summary>Build orange reason from sticky state; never thin Fight prose.</summary>
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<long> ids, long id)
{
for (int i = 0; i < ids.Count; i++)
{
if (ids[i] == id)
{
return;
}
}
ids.Add(id);
}
}

View file

@ -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
};
}
/// <summary>
/// Sticky WarFront tips stay kingdom-framed across count dips and follow loss.
/// </summary>
private static List<HarnessCommand> WarFrontSticky()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// Sticky PlotCell tips stay plotter-vs-kingdom framed across count dips and follow loss.
/// </summary>
private static List<HarnessCommand> PlotCellSticky()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// Sticky FamilyPack tips stay species-framed across count dips and follow loss.
/// </summary>
private static List<HarnessCommand> FamilyPackSticky()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// Soft FamilyPack must not fortress the camera: Action-tier unit events margin-cut immediately.
/// </summary>
private static List<HarnessCommand> FamilyPackYields()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// Sticky StatusOutbreak tips stay status-framed across count dips and follow loss.
/// </summary>
private static List<HarnessCommand> StatusOutbreakSticky()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// Relationship/emotion statuses must never become Outbreak tips, even when many units share them.
/// </summary>
private static List<HarnessCommand> StatusOutbreakNotLove()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// 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.

View file

@ -24,12 +24,21 @@ public enum InterestCompletionKind
/// <summary>Character vignette until max watch or cold activity.</summary>
CharacterVignette = 5,
/// <summary>Harness-forced session; active until released or max cap.</summary>
Manual = 6
Manual = 6,
/// <summary>Sticky war front while the war / kingdoms remain resolvable.</summary>
WarFront = 7,
/// <summary>Sticky plot cell while the plot / plotters remain resolvable.</summary>
PlotActive = 8,
/// <summary>Sticky family pack while members / alpha remain resolvable.</summary>
FamilyPack = 9,
/// <summary>Sticky status outbreak while clustered carriers remain resolvable.</summary>
StatusOutbreak = 10
}
/// <summary>
/// Durable interest scene candidate. <see cref="TotalScore"/> is the sole ranking signal;
/// typed fields (lead, completion, combat) gate interrupt policy.
/// Multi-actor sticky scoreboard lives on <see cref="Sticky"/>.
/// </summary>
public sealed class InterestCandidate
{
@ -44,21 +53,8 @@ public sealed class InterestCandidate
public float TotalScore;
/// <summary>Live fighters / participants in a cluster (battles, multi-unit events).</summary>
public int ParticipantCount;
/// <summary>Peak fighters seen this combat scene (scale demotion hysteresis).</summary>
public int CombatPeakParticipants;
/// <summary>Sticky opposing camps for the orange tip (species/kingdom keys).</summary>
public EnsembleFrame CombatSideFrame = EnsembleFrame.CountOnly;
public string CombatSideAKey = "";
public string CombatSideADisplay = "";
public string CombatSideAKingdom = "";
public int CombatSideACount;
public string CombatSideBKey = "";
public string CombatSideBDisplay = "";
public string CombatSideBKingdom = "";
public int CombatSideBCount;
/// <summary>Tracked camp members for sticky scoreboard (kept until combat grace ends).</summary>
public readonly List<long> CombatSideAIds = new List<long>(8);
public readonly List<long> CombatSideBIds = new List<long>(8);
/// <summary>Sticky multi-actor scoreboard (combat first; war/plot/family later).</summary>
public readonly LiveSceneStickyState Sticky = new LiveSceneStickyState();
/// <summary>Notable (king/favorite/leader/high renown) participants in the cluster.</summary>
public int NotableParticipantCount;
public Vector3 Position;
@ -88,32 +84,78 @@ public sealed class InterestCandidate
public string ScoreDetail = "";
public readonly List<long> ParticipantIds = new List<long>(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<long> CombatSideAIds => Sticky.SideAIds;
public List<long> 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;
}
}

View file

@ -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<Actor>(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<Actor>(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)

File diff suppressed because it is too large Load diff

View file

@ -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;
}

View file

@ -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;

View file

@ -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"
}

View file

@ -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 |

View file

@ -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

1 domain id strength camera game_call_site verdict fix_action done
3 decisions try_to_steal_money 68 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done
4 decisions banish_unruly_clan_members 76 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done
5 decisions kill_unruly_clan_members 82 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done
6 decisions try_new_plot 78 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate demoted Camera via IsInterestingDecision predicate; action=infinitive Intent-only (no Plot yet); camera via live PlotInterestFeed instead done
7 decisions king_change_kingdom_culture 84 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done
8 decisions king_change_kingdom_language 82 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done
9 decisions king_change_kingdom_religion 84 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done

View file

@ -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.