Enhance IdleSpectator event handling by introducing new logic for prose regressions and camera demotion criteria. Update event catalogs to adjust presentation tiers for various happiness events, ensuring accurate representation of event ownership. Refine scoring logic in InterestDirector to incorporate sticky scene management and improve camera cut-in conditions. Increment version in mod.json to 0.22.0 to reflect these changes.

This commit is contained in:
DazedAnon 2026-07-16 01:25:42 -05:00
parent 1c00a7980a
commit 2c1f80cdb4
24 changed files with 521 additions and 129 deletions

View file

@ -19,6 +19,7 @@ public sealed class ActivityHappinessAuditResult
public int MissingPolicy;
public int RequiredContextFailures;
public int MissingEventStrength;
public int ProseRegressions;
}
/// <summary>
@ -43,6 +44,7 @@ public static class ActivityHappinessHarness
int missingPolicy = 0;
int requiredContextFailures = 0;
int missingEventStrength = 0;
int proseRegressions = 0;
var tsv = new StringBuilder();
tsv.AppendLine(
"id\tvalue\tpsychopath\tpresentation\tlife\tcontext\toverlap\tevent_strength\tclause\tauthored\ticon\tlocale_leak\tnotes");
@ -195,6 +197,35 @@ public static class ActivityHappinessHarness
// Disk may be unavailable in some harness contexts.
}
// Prose vs call-site regressions (Layer B honesty).
proseRegressions += CountProseRegression(
"just_born",
mustContain: new[] { "appears in the world", "brought into existence" },
mustNotContain: new[] { "born into the world", "first breath" });
proseRegressions += CountProseRegression(
"just_kissed",
mustContain: new[] { "mates", "intimacy" },
mustNotContain: new[] { "kiss" });
proseRegressions += CountProseRegression(
"just_got_out_of_egg",
mustContain: new[] { "hatches from an egg" },
mustNotContain: Array.Empty<string>());
// Camera demotion: ticker crumbs must stay Ambient (Layer B only).
string[] ambientRequired =
{
"just_received_gift", "just_gave_gift", "just_cried", "just_inspired",
"got_robbed", "lost_fight", "just_injured", "just_made_friend"
};
for (int i = 0; i < ambientRequired.Length; i++)
{
HappinessCatalogEntry e = EventCatalog.Happiness.GetOrFallback(ambientRequired[i]);
if (e.Presentation == HappinessPresentationTier.Signal)
{
proseRegressions++;
}
}
bool passed = missingAuthored == 0
&& missingIcon == 0
&& localeLeaks == 0
@ -202,7 +233,8 @@ public static class ActivityHappinessHarness
&& orphanAuthored == 0
&& missingPolicy == 0
&& requiredContextFailures == 0
&& missingEventStrength == 0;
&& missingEventStrength == 0
&& proseRegressions == 0;
return new ActivityHappinessAuditResult
{
@ -216,14 +248,45 @@ public static class ActivityHappinessHarness
MissingPolicy = missingPolicy,
RequiredContextFailures = requiredContextFailures,
MissingEventStrength = missingEventStrength,
ProseRegressions = proseRegressions,
Detail =
$"total={liveIds.Count} missingAuthored={missingAuthored} missingIcon={missingIcon} "
+ $"localeLeaks={localeLeaks} emptyPhrase={emptyPhrase} orphanAuthored={orphanAuthored} "
+ $"missingPolicy={missingPolicy} requiredContextFailures={requiredContextFailures} "
+ $"missingEventStrength={missingEventStrength}"
+ $"missingEventStrength={missingEventStrength} proseRegressions={proseRegressions}"
};
}
private static int CountProseRegression(string id, string[] mustContain, string[] mustNotContain)
{
HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(id);
if (entry == null || entry.IsFallback)
{
return 1;
}
string joined = string.Join(" ", entry.VariantsWithoutRelated ?? Array.Empty<string>())
+ " "
+ string.Join(" ", entry.VariantsWithRelated ?? Array.Empty<string>());
for (int i = 0; mustContain != null && i < mustContain.Length; i++)
{
if (joined.IndexOf(mustContain[i], StringComparison.OrdinalIgnoreCase) < 0)
{
return 1;
}
}
for (int i = 0; mustNotContain != null && i < mustNotContain.Length; i++)
{
if (joined.IndexOf(mustNotContain[i], StringComparison.OrdinalIgnoreCase) >= 0)
{
return 1;
}
}
return 0;
}
private static bool LooksLikeLocaleLeak(string text, string id)
{
if (string.IsNullOrEmpty(text))

View file

@ -184,6 +184,35 @@ public static class ActivityStatusHarness
// Artifact write is best-effort.
}
int cameraDemotionFailures = 0;
string[] bOnlyStatus =
{
"surprised", "confused", "motivated", "inspired", "magnetized",
"starving", "ash_fever", "powerup", "voices_in_my_head", "angry"
};
for (int i = 0; i < bOnlyStatus.Length; i++)
{
StatusInterestEntry e = EventCatalog.Status.GetOrFallback(bOnlyStatus[i]);
if (e.CreatesInterest)
{
cameraDemotionFailures++;
}
}
string[] aStatus =
{
"drowning", "burning", "frozen", "poisoned", "possessed", "cursed",
"pregnant", "crying", "rage", "stunned", "soul_harvested", "tantrum"
};
for (int i = 0; i < aStatus.Length; i++)
{
StatusInterestEntry e = EventCatalog.Status.GetOrFallback(aStatus[i]);
if (!e.CreatesInterest)
{
cameraDemotionFailures++;
}
}
bool passed = liveIds.Count > 20
&& missingAuthored == 0
&& missingIcon == 0
@ -191,7 +220,8 @@ public static class ActivityStatusHarness
&& emptyPhrase == 0
&& orphanAuthored == 0
&& missingInterest == 0
&& orphanInterest == 0;
&& orphanInterest == 0
&& cameraDemotionFailures == 0;
return new ActivityStatusAuditResult
{
@ -207,7 +237,8 @@ public static class ActivityStatusHarness
Detail =
$"total={liveIds.Count} missingAuthored={missingAuthored} missingIcon={missingIcon} "
+ $"localeLeaks={localeLeaks} emptyPhrase={emptyPhrase} orphanAuthored={orphanAuthored} "
+ $"missingInterest={missingInterest} orphanInterest={orphanInterest} passed={passed}"
+ $"missingInterest={missingInterest} orphanInterest={orphanInterest} "
+ $"cameraDemotionFailures={cameraDemotionFailures} passed={passed}"
};
}

View file

@ -5744,8 +5744,14 @@ public static class AgentHarness
private static void DoSnapshot(HarnessCommand cmd)
{
string drops = InterestDropLog.FormatRecent(6);
string snap =
$"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount} score={InterestDirector.CurrentScoreLabel} discoveryPending={SpeciesDiscovery.PendingCount} caption={WatchCaption.LastHeadline} chronicle={Chronicle.Count}";
$"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount} score={InterestDirector.CurrentScoreLabel} discoveryPending={SpeciesDiscovery.PendingCount} caption={WatchCaption.LastHeadline} chronicle={Chronicle.Count} drops={InterestDropLog.Count}";
if (!string.IsNullOrEmpty(drops))
{
snap += " recentDrop=[" + drops + "]";
}
_cmdOk++;
Emit(cmd, ok: true, detail: snap);
LogService.LogInfo("[IdleSpectator][SNAP] " + snap);

View file

@ -67,7 +67,7 @@ public static partial class EventCatalog
{
Id = "got_robbed",
EventStrength = 50f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.RelatedOptional,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
@ -93,7 +93,7 @@ public static partial class EventCatalog
{
Id = "lost_fight",
EventStrength = 55f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
@ -147,7 +147,7 @@ public static partial class EventCatalog
{
Id = "just_received_gift",
EventStrength = 50f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.RelatedOptional,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
@ -160,7 +160,7 @@ public static partial class EventCatalog
{
Id = "just_gave_gift",
EventStrength = 50f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.RelatedOptional,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
@ -510,7 +510,7 @@ public static partial class EventCatalog
{
Id = "just_cried",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Continuation,
@ -566,7 +566,7 @@ public static partial class EventCatalog
// Game id uses British spelling; status asset is magnetized.
Id = "just_magnetised",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Continuation,
@ -580,7 +580,7 @@ public static partial class EventCatalog
{
Id = "just_forced_power",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
@ -607,7 +607,7 @@ public static partial class EventCatalog
{
Id = "strange_urge",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Continuation,
@ -621,7 +621,7 @@ public static partial class EventCatalog
{
Id = "just_had_tantrum",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Continuation,
@ -635,7 +635,7 @@ public static partial class EventCatalog
{
Id = "just_felt_the_divine",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
@ -648,7 +648,7 @@ public static partial class EventCatalog
{
Id = "just_enchanted",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Continuation,
@ -662,7 +662,7 @@ public static partial class EventCatalog
{
Id = "just_inspired",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Continuation,
@ -729,7 +729,7 @@ public static partial class EventCatalog
{
Id = "just_found_house",
EventStrength = 55f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
Overlap = HappinessOverlapPolicy.Independent,
@ -742,7 +742,7 @@ public static partial class EventCatalog
{
Id = "just_lost_house",
EventStrength = 65f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
Overlap = HappinessOverlapPolicy.Independent,
@ -755,7 +755,7 @@ public static partial class EventCatalog
{
Id = "just_made_friend",
EventStrength = 50f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.RelatedOptional,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.CanonicalMilestone,
@ -769,7 +769,7 @@ public static partial class EventCatalog
{
Id = "just_injured",
EventStrength = 65f,
Presentation = HappinessPresentationTier.Signal,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
Overlap = HappinessOverlapPolicy.Independent,

View file

@ -34,23 +34,23 @@ public static partial class EventCatalog
Add("drowning", 50f, "StatusTransformation", true);
Add("stunned", 50f, "StatusTransformation", true);
Add("rage", 62f, "StatusTransformation", true);
Add("angry", 48f, "StatusTransformation", true);
Add("angry", 48f, "StatusAmbient", false);
Add("cursed", 60f, "StatusTransformation", true);
Add("soul_harvested", 75f, "StatusTransformation", true);
Add("voices_in_my_head", 55f, "StatusTransformation", true);
Add("voices_in_my_head", 55f, "StatusAmbient", false);
Add("tantrum", 52f, "StatusTransformation", true);
Add("egg", 40f, "StatusTransformation", false);
Add("magnetized", 55f, "StatusTransformation", true);
Add("magnetized", 55f, "StatusAmbient", false);
Add("invincible", 40f, "StatusTransformation", false);
Add("powerup", 54f, "StatusTransformation", true);
Add("powerup", 54f, "StatusAmbient", false);
Add("enchanted", 52f, "StatusTransformation", true);
Add("ash_fever", 50f, "StatusTransformation", true);
Add("starving", 48f, "StatusTransformation", true);
Add("surprised", 42f, "StatusTransformation", true);
Add("confused", 40f, "StatusTransformation", true);
Add("strange_urge", 45f, "StatusTransformation", true);
Add("inspired", 42f, "Emotion", true);
Add("motivated", 40f, "Emotion", true);
Add("ash_fever", 50f, "StatusAmbient", false);
Add("starving", 48f, "StatusAmbient", false);
Add("surprised", 42f, "StatusAmbient", false);
Add("confused", 40f, "StatusAmbient", false);
Add("strange_urge", 45f, "StatusAmbient", false);
Add("inspired", 42f, "Emotion", false);
Add("motivated", 40f, "Emotion", false);
Add("fell_in_love", 42f, "Relationship", false);
Add("pregnant", 58f, "LifeChapter", true);
Add("pregnant_parthenogenesis", 58f, "LifeChapter", true);

View file

@ -2,8 +2,12 @@ namespace IdleSpectator;
/// <summary>
/// IdleSpectator event inventory (one type, split across partial files).
///
/// Layer B (ticker): Activity / Life prose for live ids - completeness.
/// Layer A (story camera): only CameraWorthy beats register interest and cut the camera.
///
/// Call sites: EventCatalog.Status / .Happiness / .Combat / …
/// Director only ranks; feeds register from these dials.
/// Director only ranks A candidates; it does not author inventory.
///
/// Domain files under Events/Catalogs/:
/// EventCatalog.Status.cs
@ -16,11 +20,32 @@ namespace IdleSpectator;
/// EventCatalog.Relationship.cs
/// EventCatalog.Disaster.cs
/// EventCatalog.WarType.cs
/// EventCatalog.Libraries.cs (Spell, Power, Decision, Item, SubspeciesTrait, Gene, Phenotype, WorldLaw, Biome)
/// EventCatalog.Libraries.cs
/// Combat policy lives in this file.
/// </summary>
public static partial class EventCatalog
{
/// <summary>
/// Layer A gate for happiness: Signal may own the camera; Ambient/Aggregate are B-only.
/// </summary>
public static bool IsCameraWorthy(HappinessPresentationTier presentation) =>
presentation == HappinessPresentationTier.Signal;
/// <summary>Layer A gate for discrete / status / worldlog rows.</summary>
public static bool IsCameraWorthy(bool createsInterest) => createsInterest;
public static bool IsCameraWorthy(DiscreteEventEntry entry) =>
entry != null && entry.CreatesInterest && !entry.IsFallback;
public static bool IsCameraWorthy(StatusInterestEntry entry) =>
entry != null && entry.CreatesInterest && !entry.IsFallback;
public static bool IsCameraWorthy(WorldLogEventEntry entry) =>
entry != null && entry.CreatesInterest && !entry.IsFallback;
public static bool IsCameraWorthy(HappinessCatalogEntry entry) =>
entry != null && IsCameraWorthy(entry.Presentation);
/// <summary>Combat camera policy (activity + scanner share one key / dials).</summary>
public static class Combat
{

View file

@ -13,8 +13,9 @@ public static class BookInterestFeed
}
DiscreteEventEntry entry = EventCatalog.Book.GetOrFallback(bookTypeId);
if (!entry.CreatesInterest)
if (!EventCatalog.IsCameraWorthy(entry))
{
InterestDropLog.Record("createsInterest=false", "book");
return;
}

View file

@ -41,8 +41,15 @@ public static class DeferredInterestFeed
public static void EmitPower(string powerId, Vector3 position, Actor nearUnit)
{
DiscreteEventEntry entry = EventCatalog.Power.GetOrFallback(powerId);
if (!entry.CreatesInterest || AgentHarness.Busy)
if (AgentHarness.Busy)
{
InterestDropLog.Record("busy", "power");
return;
}
if (!EventCatalog.IsCameraWorthy(entry))
{
InterestDropLog.Record("createsInterest=false", "power:" + (powerId ?? ""));
return;
}
@ -110,8 +117,9 @@ public static class DeferredInterestFeed
}
DiscreteEventEntry entry = EventCatalog.WorldLaw.GetOrFallback(lawId);
if (!entry.CreatesInterest)
if (!EventCatalog.IsCameraWorthy(entry))
{
InterestDropLog.Record("createsInterest=false", entry.Id ?? "");
return;
}
@ -135,8 +143,9 @@ public static class DeferredInterestFeed
}
DiscreteEventEntry entry = EventCatalog.Biome.GetOrFallback(biomeId);
if (!entry.CreatesInterest)
if (!EventCatalog.IsCameraWorthy(entry))
{
InterestDropLog.Record("createsInterest=false", entry.Id ?? "");
return;
}
@ -166,8 +175,9 @@ public static class DeferredInterestFeed
}
DiscreteEventEntry entry = lookup(assetId);
if (!entry.CreatesInterest)
if (!EventCatalog.IsCameraWorthy(entry))
{
InterestDropLog.Record("createsInterest=false", entry.Id ?? "");
return;
}

View file

@ -24,6 +24,7 @@ public static class InterestFeeds
HappinessDrain.Clear();
_lastScannerAt = -999f;
CivicBoostUntil.Clear();
InterestDropLog.Clear();
}
public static ulong HappinessCursor => _happinessCursor;
@ -94,8 +95,11 @@ public static class InterestFeeds
}
WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(message.asset_id);
if (!entry.CreatesInterest)
if (!EventCatalog.IsCameraWorthy(entry))
{
InterestDropLog.Record(
"createsInterest=false",
"worldlog:" + (message.asset_id ?? ""));
return;
}
@ -370,7 +374,7 @@ public static class InterestFeeds
}
// Egg timer end is the reliable hatch beat. Happiness just_got_out_of_egg is often
// suppressed (no emotions / still flagged egg), so status loss owns the camera reason.
// suppressed (no emotions), so status loss owns the camera reason.
if (!gained && string.Equals(statusId.Trim(), "egg", StringComparison.OrdinalIgnoreCase))
{
EmitHatch(actor, "status_egg_loss");
@ -379,12 +383,14 @@ public static class InterestFeeds
if (!gained)
{
InterestDropLog.Record("status_loss_ignored", statusId);
return;
}
StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(statusId);
if (!entry.CreatesInterest)
if (!EventCatalog.IsCameraWorthy(entry))
{
InterestDropLog.Record("createsInterest=false", "status:" + statusId);
return;
}
@ -535,20 +541,22 @@ public static class InterestFeeds
private static void IngestHappiness(HappinessOccurrence occ)
{
if (occ == null || !occ.Applied || occ.SuppressedByPsychopath)
if (occ == null)
{
return;
}
// Harness scenarios inject via SourceHook.Harness; ignore live world noise while Busy.
if (!occ.Applied || occ.SuppressedByPsychopath)
{
InterestDropLog.Record(
occ.SuppressedByPsychopath ? "suppressed" : "not_applied",
occ.EffectId ?? "");
return;
}
if (AgentHarness.Busy && occ.SourceHook != HappinessSourceHook.Harness)
{
return;
}
if (occ.Presentation == HappinessPresentationTier.Ambient)
{
// Enrichment only - never owns the camera.
InterestDropLog.Record("busy", occ.EffectId ?? "");
return;
}
@ -578,6 +586,14 @@ public static class InterestFeeds
StampCivicBoost(kingdom, occ.EffectId, 25f + Mathf.Min(40f, occ.TotalAffectedCount));
}
InterestDropLog.Record("aggregate", occ.EffectId ?? "");
return;
}
if (!EventCatalog.IsCameraWorthy(occ.Presentation))
{
// Layer B only - never owns the camera.
InterestDropLog.Record("ambient", occ.EffectId ?? "");
return;
}

View file

@ -13,8 +13,9 @@ public static class MetaInterestFeed
}
DiscreteEventEntry entry = EventCatalog.Era.GetOrFallback(eraId);
if (!entry.CreatesInterest)
if (!EventCatalog.IsCameraWorthy(entry))
{
InterestDropLog.Record("createsInterest=false", "meta");
return;
}

View file

@ -15,8 +15,9 @@ public static class PlotInterestFeed
}
DiscreteEventEntry entry = EventCatalog.Plot.GetOrFallback(plotAssetId);
if (!entry.CreatesInterest)
if (!EventCatalog.IsCameraWorthy(entry))
{
InterestDropLog.Record("createsInterest=false", "plot");
return;
}

View file

@ -13,8 +13,9 @@ public static class RelationshipInterestFeed
}
DiscreteEventEntry entry = EventCatalog.Relationship.GetOrFallback(eventId);
if (!entry.CreatesInterest)
if (!EventCatalog.IsCameraWorthy(entry))
{
InterestDropLog.Record("createsInterest=false", "relationship:" + (eventId ?? ""));
return;
}

View file

@ -60,8 +60,9 @@ public static class WarInterestFeed
{
string warTypeId = ReadWarTypeId(war);
WarTypeInterestEntry entry = EventCatalog.WarType.GetOrFallback(warTypeId);
if (!entry.CreatesInterest)
if (entry == null || !EventCatalog.IsCameraWorthy(entry.CreatesInterest))
{
InterestDropLog.Record("createsInterest=false", "war:" + (warTypeId ?? ""));
return false;
}

View file

@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Text;
using NeoModLoader.services;
namespace IdleSpectator;
/// <summary>
/// Ring buffer of why a beat stayed Layer B or failed to take / keep Layer A camera.
/// </summary>
public static class InterestDropLog
{
public const int Capacity = 48;
private static readonly string[] Buffer = new string[Capacity];
private static int _write;
private static int _count;
private static float _lastPlayerLogAt = -999f;
private const float PlayerLogInterval = 8f;
public static int Count => _count;
public static void Clear()
{
_write = 0;
_count = 0;
for (int i = 0; i < Capacity; i++)
{
Buffer[i] = null;
}
}
public static void Record(string reason, string detail = "")
{
if (string.IsNullOrEmpty(reason))
{
return;
}
string line = string.IsNullOrEmpty(detail)
? reason
: (reason + ": " + detail);
Buffer[_write] = line;
_write = (_write + 1) % Capacity;
if (_count < Capacity)
{
_count++;
}
float now = UnityEngine.Time.unscaledTime;
if (now - _lastPlayerLogAt >= PlayerLogInterval)
{
_lastPlayerLogAt = now;
try
{
LogService.LogInfo("[IdleSpectator][DROP] " + line);
}
catch
{
// ignore log failures
}
}
}
/// <summary>Newest-first snapshot for harness / diagnostics.</summary>
public static List<string> Snapshot(int max = 12)
{
var list = new List<string>(Math.Min(max, _count));
if (_count == 0 || max <= 0)
{
return list;
}
int n = Math.Min(max, _count);
for (int i = 0; i < n; i++)
{
int idx = (_write - 1 - i + Capacity) % Capacity;
if (!string.IsNullOrEmpty(Buffer[idx]))
{
list.Add(Buffer[idx]);
}
}
return list;
}
public static string FormatRecent(int max = 8)
{
List<string> lines = Snapshot(max);
if (lines.Count == 0)
{
return "";
}
var sb = new StringBuilder();
for (int i = 0; i < lines.Count; i++)
{
if (i > 0)
{
sb.Append(" | ");
}
sb.Append(lines[i]);
}
return sb.ToString();
}
}

View file

@ -113,8 +113,9 @@ public static class TraitEventPatches
}
DiscreteEventEntry entry = EventCatalog.Trait.GetOrFallback(traitId);
if (!entry.CreatesInterest)
if (!EventCatalog.IsCameraWorthy(entry))
{
InterestDropLog.Record("createsInterest=false", "trait:" + (traitId ?? ""));
return;
}

View file

@ -183,6 +183,7 @@ public static class HappinessEventRouter
_suppressedSinceClear++;
}
InterestDropLog.Record("no_emotions_or_egg", effectId ?? "");
var occ = BuildOccurrence(
subject,
effectId,

View file

@ -552,12 +552,14 @@ public static class InterestDirector
// Queued too long - miss the beat.
if (now - c.CreatedAt > 5.5f)
{
InterestDropLog.Record("stale", c.HappinessEffectId + " queue");
return true;
}
Actor unit = c.FollowUnit;
if (unit == null || !unit.isAlive())
{
InterestDropLog.Record("stale", c.HappinessEffectId + " dead");
return true;
}
@ -569,6 +571,7 @@ public static class InterestDirector
{
if (!unit.isBaby() && unit.getAge() > 1)
{
InterestDropLog.Record("stale", "just_born grown");
return true;
}
}
@ -876,11 +879,17 @@ public static class InterestDirector
if (_current != null
&& (_current.Completion == InterestCompletionKind.CombatActive
|| _current.Completion == InterestCompletionKind.StatusPhase
|| _current.Completion == InterestCompletionKind.HappinessGrief))
|| _current.Completion == InterestCompletionKind.HappinessGrief
|| IsStickyStoryScene(_current)))
{
float graceCur = _current.TotalScore;
float graceNext = candidate.TotalScore;
return graceNext >= graceCur + InterestScoringConfig.W.cutInMargin
float graceMargin = Mathf.Max(
InterestScoringConfig.W.cutInMargin,
InterestScoringConfig.W.stickyCutInMargin > 0f
? InterestScoringConfig.W.stickyCutInMargin
: 50f);
return graceNext >= graceCur + graceMargin
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
}
@ -899,6 +908,10 @@ public static class InterestDirector
bool nextFill = InterestScoring.IsFillScore(nextScore);
bool curAmbient = IsAmbientShot(_current);
bool curFill = curAmbient || InterestScoring.IsFillScore(curScore);
bool sticky = IsStickyStoryScene(_current);
float cutMargin = sticky
? Mathf.Max(w.cutInMargin, w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f)
: w.cutInMargin;
// Ambient fill: any real event may take the camera immediately (no min dwell).
if (curAmbient && !nextFill && !IsAmbientShot(candidate))
@ -906,12 +919,25 @@ public static class InterestDirector
return true;
}
// Instant score-margin cut - no settle grace, no MinDwell block.
if (nextScore >= curScore + w.cutInMargin)
// Same-arc refresh (combat/hatch/grief keys) may replace without full margin.
if (sticky && IsSameStoryArc(_current, candidate) && nextScore >= curScore - w.rotateSlack)
{
return true;
}
// Instant score-margin cut - no settle grace, no MinDwell block.
if (nextScore >= curScore + cutMargin)
{
return true;
}
if (sticky && nextScore < curScore + cutMargin)
{
InterestDropLog.Record(
"below_margin",
$"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}");
}
// Fill never cuts a protected non-fill session without margin (already failed above).
if (nextFill && !curFill && protectedScene)
{
@ -924,8 +950,8 @@ public static class InterestDirector
return false;
}
// Protected EventLed hold: only margin cut-in (handled above).
if (protectedScene && !curAmbient)
// Protected / sticky EventLed hold: only margin or same-arc (handled above).
if ((protectedScene || sticky) && !curAmbient)
{
return false;
}
@ -941,6 +967,89 @@ public static class InterestDirector
&& nextScore >= curScore - w.rotateSlack;
}
private static bool IsStickyStoryScene(InterestCandidate c)
{
if (c == null)
{
return false;
}
if (c.Completion == InterestCompletionKind.CombatActive
|| c.Completion == InterestCompletionKind.StatusPhase
|| c.Completion == InterestCompletionKind.HappinessGrief)
{
return true;
}
// Hatch FixedDwell is a story beat - hold against weak peers.
if (!string.IsNullOrEmpty(c.HappinessEffectId)
&& (string.Equals(c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.HappinessEffectId, "just_hatched", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
if (!string.IsNullOrEmpty(c.Key)
&& (c.Key.StartsWith("combat:", StringComparison.Ordinal)
|| c.Key.StartsWith("hatch:", StringComparison.Ordinal)))
{
return true;
}
return false;
}
private static bool IsSameStoryArc(InterestCandidate current, InterestCandidate next)
{
if (current == null || next == null)
{
return false;
}
if (!string.IsNullOrEmpty(current.Key)
&& !string.IsNullOrEmpty(next.Key)
&& string.Equals(current.Key, next.Key, StringComparison.Ordinal))
{
return true;
}
// Combat / hatch share key prefixes per subject.
if (!string.IsNullOrEmpty(current.Key) && !string.IsNullOrEmpty(next.Key))
{
if (SameKeyPrefix(current.Key, next.Key, "combat:")
|| SameKeyPrefix(current.Key, next.Key, "hatch:"))
{
return true;
}
}
if (current.Completion == InterestCompletionKind.HappinessGrief
&& next.Completion == InterestCompletionKind.HappinessGrief
&& current.SubjectId != 0
&& current.SubjectId == next.SubjectId)
{
return true;
}
return false;
}
private static bool SameKeyPrefix(string a, string b, string prefix)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix))
{
return false;
}
if (!a.StartsWith(prefix, StringComparison.Ordinal)
|| !b.StartsWith(prefix, StringComparison.Ordinal))
{
return false;
}
return string.Equals(a, b, StringComparison.Ordinal);
}
/// <summary>
/// Still-worth-watching filter: drop stale queued moments; keep live sticky conditions
/// and fresh FixedDwell; during grace, brand-new fires are always eligible.

View file

@ -28,6 +28,8 @@ public class ScoringWeights
// Director policy (score-native; replaces InterestTier preemption).
public float cutInMargin = 35f;
/// <summary>Extra margin required to interrupt sticky A scenes (combat/grief/status/hatch).</summary>
public float stickyCutInMargin = 50f;
public float rotateSlack = 12f;
public float fillScoreMax = 55f;
public float noticeScoreMin = 70f;

View file

@ -284,12 +284,14 @@ public sealed class UnitDossier
// Ownership: never let the reason name a living stranger (not subject/related).
if (ReasonNamesStranger(beat, scene))
{
InterestDropLog.Record("identity_filter", "stranger:" + beat);
return "";
}
// Ownership: reject beats that lead with someone else's proper name.
if (LeadsWithForeignName(beat, scene))
{
InterestDropLog.Record("identity_filter", "foreign:" + beat);
return "";
}
@ -494,9 +496,22 @@ public sealed class UnitDossier
}
}
// EventReason sentences are dossier-ready - keep names; only drop identity crumbs.
// EventReason / Name-verb sentences are dossier-ready.
if (LooksLikeEventSentence(s)
|| (!string.IsNullOrEmpty(d?.Name) && StartsWithSubjectVerbPhrase(s, d.Name)))
{
if (IsWeakFlavorBeat(s, d))
{
InterestDropLog.Record("identity_filter", "weak:" + s);
return "";
}
return CleanBeat(s);
}
if (IsIdentityWhy(s, d) || IsWeakFlavorBeat(s, d))
{
InterestDropLog.Record("identity_filter", s);
return "";
}

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.21.3",
"description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.",
"version": "0.22.0",
"description": "AFK Idle Spectator (I) + Lore (L). Layer B ticker + Layer A story camera.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -11,6 +11,7 @@
"noveltyMultiplier": 4,
"cutInMargin": 35,
"stickyCutInMargin": 50,
"rotateSlack": 12,
"fillScoreMax": 55,
"noticeScoreMin": 70,

View file

@ -1,21 +1,21 @@
# Event audit (post-expand)
# Event audit (A/B layers)
Inventory policy after the full gap expand.
Camera interrupt is score-margin only (`cutInMargin`); no Signal/Ambient ownership gate.
**Layer B (ticker):** Activity / Life coverage for live library ids - honest prose, completeness.
**Layer A (story camera):** only `EventCatalog.IsCameraWorthy` beats register interest and cut the camera.
**Single inventory:** [`EventCatalog`](../IdleSpectator/Events/EventCatalog.cs) (partials under `Events/Catalogs/`).
Nested domains: `Status`, `Happiness`, `WorldLog`, `Trait`, `Combat`, `Spell`, …
Feeds/patches register from those dials; `InterestDirector` only ranks candidates.
Feeds/patches register A from those dials; `InterestDirector` only ranks candidates.
## Dials
| Dial | Role |
|------|------|
| `CreatesInterest` | Whether a live id may register at all |
| `EventStrength` | Base contribution to `TotalScore` (interrupt via +`cutInMargin`) |
| `Completion` | Hold-until-complete kind |
| `MaxWatch` | Hard cap (class defaults in `scoring-model.json`) |
| `EventReason` | Orange dossier sentence |
| Dial | Layer | Role |
|------|-------|------|
| Happiness `Presentation` | A if Signal | Ambient/Aggregate = B-only |
| `CreatesInterest` | A gate | Discrete / status / WorldLog / libraries |
| `EventStrength` | A rank | Base `TotalScore` (interrupt via margin) |
| `Completion` | A hold | Hold-until-complete kind |
| `MaxWatch` | A hold | Hard cap (`scoring-model.json`) |
| `EventReason` / Label | A display | Orange dossier sentence |
## MaxWatch class defaults
@ -27,56 +27,38 @@ Feeds/patches register from those dials; `InterestDirector` only ranks candidate
| Combat | 60 | `maxWatchCombat` |
| Epic world | 50 | `maxWatchEpic` |
Peer rotate / soft cut on EventLed floors at `minCameraDwell` (15s). Score-margin interrupts bypass that floor.
Ambient / Character fill is exempt - events may take the camera immediately.
Peer rotate / soft cut on EventLed floors at `minCameraDwell` (15s).
Score-margin interrupts bypass that floor; sticky A scenes use `stickyCutInMargin`.
Ambient / Character fill is exempt - A events may take the camera immediately.
## Sources (wired)
| Source | Primary owner | Reason factory | Notes |
|--------|---------------|----------------|-------|
| WorldLog | `InterestFeeds.OnWorldLogMessage` | catalog / EventReason | Strength from `EventCatalog.WorldLog` |
| Happiness | `IngestHappiness` | `EventCatalog.Happiness` + EventReason | Prose must match game call sites - see [event-prose-audit.md](event-prose-audit.md) |
| Status | `OnStatusChange` | EventReason.Status | `drowning` CreatesInterest=true, strength ~50; notable via CharacterSignificance |
| Scanner combat | WorldActivityScanner | EventReason.Fight / Battle | Completion CombatActive; dials `EventCatalog.Combat` |
| War | WarEventPatches + WarInterestFeed.Tick | war type labels | |
| Plot | PlotEventPatches + PlotInterestFeed.Tick | `EventCatalog.Plot` | |
| Relationship | RelationshipEventPatches + happiness lovers | EventReason.* | |
| Trait | TraitEventPatches | EventReason.Trait | Spawn window filters routine traits |
| Building | BuildingEventPatches | EventReason.Building* | Eat/damage/falls |
| Boat | BoatEventPatches | EventReason.Boat | |
| Book | BookEventPatches | `EventCatalog.Book` sentences | |
| Era / Meta | MetaEventPatches | EventReason.MetaNew / era labels | |
| Spell | CombatActionLibrary.tryToCastSpell | EventReason.Library | Ambient CreatesInterest=false except spectacle ids |
| Power | PlayerControl.clickedFinal | EventReason.HumanizeId | Spectacle ids create interest |
| Decision | setDecisionCooldown | EventReason.Library | IsCameraWorthy filter |
| Item | ItemManager | EventReason.Library | Legendary CreatesInterest |
| Gene | Subspecies.mutateFrom | EventReason.Library | Ambient CreatesInterest=false |
| Phenotype | generatePhenotypeAndShade | EventReason.Library | |
| World law | WorldLaws.enable / toggle | EventReason.HumanizeId | Location-only |
| Biome | catalog ready | EventReason.HumanizeId | Ambient CreatesInterest=false; emit via power terraform path |
| Subspecies trait | SubspeciesManager biome hooks | EventReason.Library | |
| WorldLog | `InterestFeeds.OnWorldLogMessage` | catalog / EventReason | A if CreatesInterest |
| Happiness | `IngestHappiness` | catalog + EventReason | Signal = A; see [event-prose-audit.md](event-prose-audit.md) |
| Status | `OnStatusChange` | EventReason.Status | Gain A if CreatesInterest; egg **loss** → hatch A |
| Scanner combat | WorldActivityScanner | EventReason.Fight / Battle | `EventCatalog.Combat` |
| War / Plot / Relationship / Trait / Building / Boat / Book / Era | Feeds + patches | EventReason / catalog | A via CreatesInterest |
| Libraries | Deferred feeds | EventReason.Library | Mostly B; spectacle ids A |
| Character fill | InterestDirector.TryCharacterFill | empty Label | Only when no pending EventLed |
## Ambient statuses (intentional off)
## Layer A demotion (this pass)
`festive_spirit`, `laughing`, `singing`, `sleeping`, `handsome_migrant` - CreatesInterest=false.
Happiness Signal → Ambient (B ticker only): gifts, cry, inspired, enchanted, divine, magnetised, strange_urge, tantrum, forced_power, lost_fight, got_robbed, house find/lose, made friend, injured.
## Same-moment ownership
Prefer typed feeds over WorldLog when both fire (relationship / status / combat).
Registry Upsert merges same key and refreshes `LastSeenAt` for sticky combat/status/grief.
Status CreatesInterest → false (Activity remains): surprised, confused, motivated, inspired, magnetized, starving, ash_fever, powerup, voices_in_my_head, angry.
## Interrupt / hold / grace
1. Instant cut when `next.TotalScore >= current + cutInMargin` (no settle; ignores min dwell).
2. Ambient fill: any EventLed may take the camera immediately (no `minCameraDwell`).
3. Else hold while `InterestCompletion.IsActive` and under MaxWatch (moments floor to `minCameraDwell`; combat uses class 60s).
4. Soft peer rotates on EventLed only after `minCameraDwell` (15s).
1. Instant cut when `next.TotalScore >= current + cutInMargin` (sticky: `stickyCutInMargin`).
2. Ambient fill: any A EventLed may take the camera immediately.
3. Else hold while `InterestCompletion.IsActive` and under MaxWatch.
4. Soft peer rotates on EventLed only after `minCameraDwell` (15s), never during protected sticky hold without margin.
5. Quiet grace 6s: still-worth-watching filter, then Character fill with empty reason.
6. Drops recorded in `InterestDropLog`.
## Mutation gap triage
`mutation_discovery` refreshes `.harness/mutation_gaps.tsv`.
Open rows are unexplained Wire candidates only.
False positives (clear/load/UI/FX/getters) and covered-by-primary paths are filtered or listed in `AlreadyWired`.
Library fields are inventory-complete via catalogs / activity / WorldLog (`mutation_libraries.tsv` notes).

View file

@ -39,6 +39,14 @@ Scope: Happiness (all authored), Relationship (all), Status/WorldLog/Plot/etc. s
Grief deaths, `fallen_in_love`, gifts, house find/lose, `just_got_out_of_egg`, `just_became_adult`, WorldLog `king_new` template roles, Status `egg` / `pregnant` / `drowning` prose.
## A/B camera demotion (2026-07-16)
Happiness Signal → Ambient (ticker only): gifts, cry, inspired, enchanted, divine, magnetised, strange_urge, tantrum happiness, forced_power, lost_fight, got_robbed, house find/lose, made friend, injured.
Status CreatesInterest → false (Activity remains): surprised, confused, motivated, inspired, magnetized, starving, ash_fever, powerup, voices_in_my_head, angry.
Gate helper: `EventCatalog.IsCameraWorthy`. Drops: `InterestDropLog`.
## Hatch camera path (fixed)
Hatch was Activity-only for many units:

View file

@ -1,14 +1,23 @@
# Event reasons (camera + orange dossier)
## Contract
## A/B layers
| Layer | What | Gate |
|-------|------|------|
| **B ticker** | Activity / Life / chronicle prose for live ids | Always may write when the game fires |
| **A story camera** | Interest candidates, orange reason, camera cuts | `EventCatalog.IsCameraWorthy` only |
Happiness: `Presentation.Signal` = A; `Ambient` / `Aggregate` = B-only.
Discrete / status / WorldLog: `CreatesInterest` = A gate.
`InterestDirector` only ranks A candidates. It does not author inventory.
Registered **events** own the idle camera.
`EventStrength` / `TotalScore` ranks who wins.
Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only).
Orange dossier reason = the owning events `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
Orange dossier reason = the owning A events `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
Quiet grace / inactive dwell clears the reason even if focus has not switched yet.
Drops (ambient, createsInterest=false, identity filter, below margin, …) go to `InterestDropLog`.
## EventReason
[`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) is the sole Label factory for camera candidates.
@ -19,21 +28,20 @@ Feeds call typed helpers (`Fight`, `BuildingEat`, `SeekingLover`, …) or `Apply
| Path | Role |
|------|------|
| `Events/EventCatalog.cs` (+ `Catalogs/EventCatalog.*.cs`) | **One inventory** - nested domains (`Status`, `Combat`, …) |
| `Events/Feeds/` | Build + register candidates from catalog dials |
| `Events/Patches/` | Harmony hooks → feeds |
| `Events/EventCatalog.cs` (+ `Catalogs/EventCatalog.*.cs`) | **One inventory** - nested domains + `IsCameraWorthy` |
| `Events/Feeds/` | Build + register **A** candidates from catalog dials |
| `Events/Patches/` | Harmony hooks → feeds / Activity |
| `Events/EventReason.cs` | Orange reason sentences |
| `Events/EventFeedUtil.cs` | Shared Register path |
| `Events/InterestDropLog.cs` | Why a beat stayed B or missed A |
| `Events/DiscreteEventEntry.cs` | Shared catalog row shape |
`InterestDirector` only ranks registered candidates for the camera - it does not author event inventory.
Rules:
- Sentence form with names: `{a} is fighting {b}`, `{a} is eating a beehive`, `{a} is seeking a lover`.
- Set `RelatedUnit` when the sentence names a second party.
- Never invent stranger subjects for unit-led events.
- Never use engine jargon that misstates the action (`Consumes a building` → eating/foraging via `BehConsumeTargetBuilding`).
- Never use engine jargon that misstates the action.
## Removed crumbs
@ -44,19 +52,20 @@ Do not author these as orange reasons:
- identity titles (`King of X`, `Leader of X`, `Lone beetle`)
- `Consumes a building` / `Damages a building: {a}` crumb form
## Strength dial (no ownership enum)
## Strength dial (Layer A only)
`InterestOwnership` / `OwnsCamera` were removed.
Every registration is EventLed and may own the camera.
`CreatesInterest` gates whether an id registers; `EventStrength` ranks.
Instant cut-in when `next.TotalScore >= current + cutInMargin` (see `scoring-model.json`).
`CreatesInterest` / Signal gates whether an id registers for the camera; `EventStrength` ranks among A.
Instant cut-in when `next.TotalScore >= current + cutInMargin` (sticky scenes use `stickyCutInMargin`).
See `scoring-model.json`.
## Dossier display
`UnitDossier.EventLabelBeat` keeps the authored sentence.
It only rejects identity/weak crumbs and scrubs living stranger names (`ReasonNamesStranger` / `LeadsWithForeignName`).
`UnitDossier.EventLabelBeat` keeps EventReason / `Name verb …` sentences.
It rejects identity/weak crumbs and scrubs living stranger names (`ReasonNamesStranger` / `LeadsWithForeignName`).
Active EventLed A scenes should not silently blank a valid Label.
## See also
- [`event-audit.md`](event-audit.md) - full source inventory, MaxWatch classes, drowning policy
- [`event-audit.md`](event-audit.md) - sources, MaxWatch, A demotion set
- [`event-prose-audit.md`](event-prose-audit.md) - prose vs game call sites
- [`scoring-model.md`](scoring-model.md) - score weights