worldbox-observer-mod/IdleSpectator/InterestCandidate.cs
DazedAnon ff4b40d00e feat(narrative): enhance narrative probes and presentation coverage.
- Introduce new narrative probes for episode grammar, ensemble balance, and prose redundancy
- Implement narrative presentation coverage tracking for better event visibility
- Update harness scenarios to include new narrative ingestion and persistence tests
- Refactor event emission to include narrative development IDs and presentations
- Expand InterestCandidate to support narrative presentation models and roles
2026-07-23 14:12:23 -05:00

372 lines
12 KiB
C#

using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
public enum InterestLeadKind
{
EventLed = 0,
CharacterLed = 1
}
public enum InterestCompletionKind
{
/// <summary>One-shot narrative dwell (WorldLog, harness tips).</summary>
FixedDwell = 0,
/// <summary>Combat / hunt / fire while participants stay live.</summary>
CombatActive = 1,
/// <summary>Owning AI task / beat still live.</summary>
ActivityActive = 2,
/// <summary>Grief / family emotion with survivor + optional crying.</summary>
HappinessGrief = 3,
/// <summary>Status phase still present on subject.</summary>
StatusPhase = 4,
/// <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,
/// <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>Sticky while <c>Earthquake.isQuakeActive()</c> (ongoing shake poll).</summary>
EarthquakeActive = 11
}
/// <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
{
public string Key = "";
public InterestLeadKind LeadKind = InterestLeadKind.EventLed;
public string Category = "";
public string Source = "";
public float EventStrength;
public float CharacterSignificance;
public float Novelty = 1f;
public float VisualConfidence = 0.5f;
public float TotalScore;
/// <summary>Editorial role assigned by the narrative catalog at registry intake.</summary>
public NarrativeFunction NarrativeFunction;
public bool HasNarrativeFunction;
public string NarrativeFunctionSource = "";
/// <summary>Exact semantic development represented by this candidate, when durable.</summary>
public string NarrativeDevelopmentId = "";
/// <summary>Complete typed orange-reason input. Final English is rendered at presentation.</summary>
public NarrativePresentationModel NarrativePresentation;
/// <summary>Live fighters / participants in a cluster (battles, multi-unit events).</summary>
public int ParticipantCount;
/// <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;
public Actor FollowUnit;
public Actor RelatedUnit;
public long SubjectId;
public long RelatedId;
/// <summary>
/// Durable 1v1 ownership: first Follow stays camera owner while both pair ids live.
/// Prevents Duel A↔B thrash across maintain ticks and registry upserts.
/// </summary>
public long PairOwnerId;
public long PairPartnerId;
/// <summary>
/// Collective Mass/Battle camera owner: best fighter across both sticky sides.
/// Held across attack-target gaps so maintain ticks cannot thrash the follow.
/// </summary>
public long TheaterLeadId;
/// <summary>Unscaled time the theater lead was last a live combat participant.</summary>
public float TheaterLeadLastCombatAt = -999f;
public string Label = "";
public string AssetId = "";
public string SpeciesId = "";
public string Verb = "";
public string CityKey = "";
public string KingdomKey = "";
public string RegionKey = "";
public string HappinessEffectId = "";
public string StatusId = "";
public string CorrelationKey = "";
public float CreatedAt;
public float LastSeenAt;
public float ExpiresAt;
public float MinWatch = 1.5f;
public float MaxWatch = 45f;
public InterestCompletionKind Completion = InterestCompletionKind.FixedDwell;
public bool Resumable = true;
public bool ForceActive;
public bool Selected;
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 => Sticky.HasOpposingSides;
public void ClearCombatSticky()
{
// Keep TheaterLeadId across sticky rebuilds / tip reframes so Mass focus
// does not hop when camps briefly lose opposing-side identity.
Sticky.Clear();
}
/// <summary>
/// Lock durable 1v1 owner/partner ids (no-op when owner missing).
/// First living partner sticks across attack_target swaps. Replace only when the
/// locked partner is confirmed dead and the owner is no longer fighting
/// (or <paramref name="replacePartner"/>). Lookup misses never unlock the partner.
/// </summary>
public void StampCombatPair(Actor owner, Actor partner, bool replacePartner = false)
{
long oid = EventFeedUtil.SafeId(owner);
if (oid == 0)
{
return;
}
if (PairOwnerId == 0)
{
PairOwnerId = oid;
}
long pid = EventFeedUtil.SafeId(partner);
if (pid == 0 || pid == PairOwnerId)
{
return;
}
if (PairPartnerId == 0 || replacePartner)
{
PairPartnerId = pid;
return;
}
if (PairPartnerId == pid)
{
return;
}
// Prefer FindUnitById: FindAliveById null means dead OR miss - misses must not thrash.
Actor locked = EventFeedUtil.FindUnitById(PairPartnerId);
bool lockedAlive = false;
bool lockedKnown = locked != null;
try
{
lockedAlive = locked != null && locked.isAlive();
}
catch
{
// Destroyed Unity object - treat as miss (keep lock).
lockedKnown = false;
lockedAlive = false;
}
if (lockedAlive)
{
return;
}
if (!lockedKnown)
{
// Unknown / unloaded / destroyed - keep the first partner id.
return;
}
// Confirmed dead. While the owner is still swinging (spectacle 1vN / scrap mop),
// do not chain-adopt the next attack_target into the durable lock.
Actor ownerActor = null;
try
{
ownerActor = owner != null && owner.isAlive()
? owner
: EventFeedUtil.FindAliveById(PairOwnerId);
}
catch
{
ownerActor = EventFeedUtil.FindAliveById(PairOwnerId);
}
if (ownerActor != null && LiveEnsemble.IsCombatParticipant(ownerActor))
{
return;
}
PairPartnerId = pid;
}
public bool HasCombatPairLock => PairOwnerId != 0;
public void StampTheaterLead(Actor lead)
{
long id = EventFeedUtil.SafeId(lead);
if (id == 0)
{
return;
}
// New lead must not inherit the previous unit's attack-gap grace.
if (TheaterLeadId != 0 && TheaterLeadId != id)
{
TheaterLeadLastCombatAt = -999f;
}
TheaterLeadId = id;
}
public void ClearTheaterLead()
{
TheaterLeadId = 0;
TheaterLeadLastCombatAt = -999f;
}
public InterestEvent ToInterestEvent()
{
return new InterestEvent
{
Score = TotalScore,
Position = Position,
FollowUnit = FollowUnit,
RelatedUnit = RelatedUnit,
Label = Label,
CreatedAt = CreatedAt,
AssetId = AssetId,
ParticipantCount = ParticipantCount,
NotableParticipantCount = NotableParticipantCount,
CharacterSignificance = CharacterSignificance
};
}
public InterestCandidate CloneShallow()
{
var copy = new InterestCandidate
{
Key = Key,
LeadKind = LeadKind,
Category = Category,
Source = Source,
EventStrength = EventStrength,
CharacterSignificance = CharacterSignificance,
Novelty = Novelty,
VisualConfidence = VisualConfidence,
TotalScore = TotalScore,
NarrativeFunction = NarrativeFunction,
HasNarrativeFunction = HasNarrativeFunction,
NarrativeFunctionSource = NarrativeFunctionSource,
NarrativeDevelopmentId = NarrativeDevelopmentId,
NarrativePresentation = NarrativePresentation?.Clone(),
ParticipantCount = ParticipantCount,
NotableParticipantCount = NotableParticipantCount,
Position = Position,
FollowUnit = FollowUnit,
RelatedUnit = RelatedUnit,
SubjectId = SubjectId,
RelatedId = RelatedId,
PairOwnerId = PairOwnerId,
PairPartnerId = PairPartnerId,
TheaterLeadId = TheaterLeadId,
TheaterLeadLastCombatAt = TheaterLeadLastCombatAt,
Label = Label,
AssetId = AssetId,
SpeciesId = SpeciesId,
Verb = Verb,
CityKey = CityKey,
KingdomKey = KingdomKey,
RegionKey = RegionKey,
HappinessEffectId = HappinessEffectId,
StatusId = StatusId,
CorrelationKey = CorrelationKey,
CreatedAt = CreatedAt,
LastSeenAt = LastSeenAt,
ExpiresAt = ExpiresAt,
MinWatch = MinWatch,
MaxWatch = MaxWatch,
Completion = Completion,
Resumable = Resumable,
ForceActive = ForceActive,
Selected = Selected,
ScoreDetail = ScoreDetail
};
Sticky.CopyTo(copy.Sticky);
for (int i = 0; i < ParticipantIds.Count; i++)
{
copy.ParticipantIds.Add(ParticipantIds[i]);
}
return copy;
}
}