worldbox-observer-mod/IdleSpectator/Narrative/NarrativeExposureLedger.cs
DazedAnon 2293274d79 feat(event): enhance event handling and narrative presentation probes
- Introduce new probes for framing approval and family coalescing
- Implement exact presentation replay checks to prevent duplicate events
- Refactor InterestVariety to manage exact replay cooldowns effectively
- Update EventPresentability to improve candidate approval logic
- Enhance InterestFeeds to streamline event registration and labeling
2026-07-23 19:43:29 -05:00

213 lines
6.9 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Bounded authored-cut ledger. Camera hold seconds do not count; only meaningful selections do.
/// </summary>
public static class NarrativeExposureLedger
{
private sealed class Exposure
{
public int Cuts;
public float LastCutAt = -999f;
public string LastSemanticFamily = "";
public int SameFamilyRun;
}
private static readonly Dictionary<long, Exposure> Characters =
new Dictionary<long, Exposure>();
private static readonly Dictionary<string, Exposure> Threads =
new Dictionary<string, Exposure>(StringComparer.Ordinal);
private static readonly List<bool> PresentedCuts = new List<bool>(6);
private static int _maxCharacterCuts;
private static int _maxThreadCuts;
private static int _combatRun;
private const int PresentedWindow = 6;
private const int CombatWindowBudget = 2;
private const int CombatRunBudget = 2;
public static void Clear()
{
Characters.Clear();
Threads.Clear();
PresentedCuts.Clear();
_maxCharacterCuts = 0;
_maxThreadCuts = 0;
_combatRun = 0;
}
public static void NoteCut(
long characterId,
string threadId,
string semanticFamily,
float now)
{
if (characterId != 0)
{
Exposure exposure = Get(Characters, characterId);
Update(exposure, semanticFamily, now);
_maxCharacterCuts = Math.Max(_maxCharacterCuts, exposure.Cuts);
}
if (!string.IsNullOrEmpty(threadId))
{
Exposure exposure = Get(Threads, threadId);
Update(exposure, semanticFamily, now);
_maxThreadCuts = Math.Max(_maxThreadCuts, exposure.Cuts);
}
}
public static float CharacterDebt(long characterId)
{
if (characterId == 0) return 0f;
int cuts = Characters.TryGetValue(characterId, out Exposure exposure)
? exposure.Cuts : 0;
return Mathf.Min(12f, Mathf.Max(0, _maxCharacterCuts - cuts) * 1.5f);
}
public static float ThreadDebt(string threadId)
{
if (string.IsNullOrEmpty(threadId)) return 0f;
int cuts = Threads.TryGetValue(threadId, out Exposure exposure)
? exposure.Cuts : 0;
return Mathf.Min(10f, Mathf.Max(0, _maxThreadCuts - cuts) * 1.25f);
}
public static float RepetitionPenalty(long characterId, string semanticFamily)
{
if (characterId == 0 || string.IsNullOrEmpty(semanticFamily)
|| !Characters.TryGetValue(characterId, out Exposure exposure)
|| !string.Equals(
exposure.LastSemanticFamily, semanticFamily, StringComparison.Ordinal))
{
return 0f;
}
return Mathf.Min(24f, exposure.SameFamilyRun * 6f);
}
/// <summary>
/// Two combat cuts may establish and escalate a conflict; another story family must
/// then receive space. Criticality affects which combat cut wins, not whether combat
/// may exceed the global story-first exposure budget.
/// </summary>
public static bool MayPresent(InterestCandidate candidate)
{
if (!IsCombat(candidate))
{
return true;
}
int combat = 0;
for (int i = 0; i < PresentedCuts.Count; i++)
{
if (PresentedCuts[i]) combat++;
}
return _combatRun < CombatRunBudget
&& combat < CombatWindowBudget;
}
public static void NotePresented(InterestCandidate candidate)
{
bool combat = IsCombat(candidate);
PresentedCuts.Add(combat);
while (PresentedCuts.Count > PresentedWindow)
{
PresentedCuts.RemoveAt(0);
}
_combatRun = combat ? _combatRun + 1 : 0;
}
public static bool HarnessProbeBalance(out string detail)
{
Clear();
NoteCut(1, "thread:a", "combat", 1f);
NoteCut(1, "thread:a", "combat", 2f);
NoteCut(1, "thread:a", "combat", 3f);
NoteCut(2, "thread:b", "lineage", 4f);
float underCovered = CharacterDebt(2);
float prolific = CharacterDebt(1);
float repetition = RepetitionPenalty(1, "combat");
float diverse = RepetitionPenalty(1, "lineage");
var combat = new InterestCandidate
{
Completion = InterestCompletionKind.CombatActive,
EventStrength = 80f
};
var criticalCombat = new InterestCandidate
{
Completion = InterestCompletionKind.CombatActive,
EventStrength = 100f
};
var family = new InterestCandidate
{
AssetId = "add_child",
EventStrength = 70f
};
NotePresented(combat);
NotePresented(combat);
bool routineBlocked = !MayPresent(combat);
bool criticalBounded = !MayPresent(criticalCombat);
for (int i = 0; i < 5; i++) NotePresented(family);
bool routineReopened = MayPresent(combat);
bool ok = underCovered > prolific
&& repetition > 0f
&& diverse == 0f
&& routineBlocked
&& criticalBounded
&& routineReopened;
detail = "debt=" + underCovered.ToString("0.0") + "/" + prolific.ToString("0.0")
+ " repetition=" + repetition.ToString("0.0") + "/" + diverse.ToString("0.0")
+ " combat=" + routineBlocked + "/" + criticalBounded
+ "/" + routineReopened
+ " pass=" + ok;
Clear();
return ok;
}
private static Exposure Get<TKey>(Dictionary<TKey, Exposure> map, TKey key)
{
if (!map.TryGetValue(key, out Exposure exposure))
{
exposure = new Exposure();
map[key] = exposure;
}
return exposure;
}
private static void Update(Exposure exposure, string family, float now)
{
exposure.Cuts++;
exposure.LastCutAt = now;
if (!string.IsNullOrEmpty(family)
&& string.Equals(exposure.LastSemanticFamily, family, StringComparison.Ordinal))
{
exposure.SameFamilyRun++;
}
else
{
exposure.LastSemanticFamily = family ?? "";
exposure.SameFamilyRun = 1;
}
}
private static bool IsCombat(InterestCandidate candidate)
{
return candidate != null
&& (candidate.Completion == InterestCompletionKind.CombatActive
|| candidate.Completion == InterestCompletionKind.WarFront
|| string.Equals(
candidate.AssetId,
"live_combat",
StringComparison.OrdinalIgnoreCase)
|| string.Equals(
candidate.AssetId,
"live_battle",
StringComparison.OrdinalIgnoreCase));
}
}