Stable Sticky
This commit is contained in:
parent
7dda02bcea
commit
c5b3c96886
31 changed files with 6129 additions and 470 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
27
.tmp-reflect/DumpFamily.cs
Normal file
27
.tmp-reflect/DumpFamily.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
.tmp-reflect/DumpFamily.csproj
Normal file
14
.tmp-reflect/DumpFamily.csproj
Normal 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
42
.tmp-reflect/DumpPlot.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
8
.tmp-reflect/DumpPlot/DumpPlot.csproj
Normal file
8
.tmp-reflect/DumpPlot/DumpPlot.csproj
Normal 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>
|
||||
113
.tmp-reflect/DumpPlot/Program.cs
Normal file
113
.tmp-reflect/DumpPlot/Program.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
8
.tmp-reflect/FindMethods.csproj
Normal file
8
.tmp-reflect/FindMethods.csproj
Normal 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
22
.tmp-reflect/Program.cs
Normal 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
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
172
IdleSpectator/Events/Feeds/FamilyInterestFeed.cs
Normal file
172
IdleSpectator/Events/Feeds/FamilyInterestFeed.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
229
IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs
Normal file
229
IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
92
IdleSpectator/Events/LiveSceneStickyState.cs
Normal file
92
IdleSpectator/Events/LiveSceneStickyState.cs
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
660
IdleSpectator/Events/StickyScoreboard.cs
Normal file
660
IdleSpectator/Events/StickyScoreboard.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
71
docs/sticky-ensemble-plan.md
Normal file
71
docs/sticky-ensemble-plan.md
Normal 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.
|
||||
Loading…
Reference in a new issue