259 lines
7.5 KiB
C#
259 lines
7.5 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties.
|
|
/// </summary>
|
|
public static class InterestVariety
|
|
{
|
|
public const float EventLedTarget = 0.70f;
|
|
public const int MixWindow = 20;
|
|
public const float HardCooldownSeconds = 12f;
|
|
|
|
private static readonly List<InterestLeadKind> RecentMix = new List<InterestLeadKind>(MixWindow);
|
|
private static readonly Dictionary<string, float> CooldownUntil = new Dictionary<string, float>(64);
|
|
private static readonly System.Random Rng = new System.Random();
|
|
|
|
private static readonly List<InterestCandidate> EventPool = new List<InterestCandidate>(64);
|
|
private static readonly List<InterestCandidate> CharPool = new List<InterestCandidate>(64);
|
|
private static readonly List<InterestCandidate> Ranked = new List<InterestCandidate>(16);
|
|
|
|
/// <summary>Whether the last <see cref="Pick"/> saw both pools non-empty (for mix metering).</summary>
|
|
public static bool LastPickHadBothPools { get; private set; }
|
|
|
|
public static void Clear()
|
|
{
|
|
RecentMix.Clear();
|
|
CooldownUntil.Clear();
|
|
}
|
|
|
|
public static void NoteSelection(InterestCandidate chosen)
|
|
{
|
|
NoteSelection(chosen, updateMix: LastPickHadBothPools);
|
|
}
|
|
|
|
public static void NoteSelection(InterestCandidate chosen, bool updateMix)
|
|
{
|
|
if (chosen == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (updateMix)
|
|
{
|
|
RecentMix.Add(chosen.LeadKind);
|
|
while (RecentMix.Count > MixWindow)
|
|
{
|
|
RecentMix.RemoveAt(0);
|
|
}
|
|
}
|
|
|
|
float until = Time.unscaledTime + (updateMix ? HardCooldownSeconds : HardCooldownSeconds * 0.5f);
|
|
StampCooldown("unit:" + chosen.SubjectId, until);
|
|
if (!string.IsNullOrEmpty(chosen.SpeciesId))
|
|
{
|
|
StampCooldown("species:" + chosen.SpeciesId, until * 0.7f);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(chosen.Category))
|
|
{
|
|
StampCooldown("cat:" + chosen.Category, until * 0.5f);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(chosen.CityKey))
|
|
{
|
|
StampCooldown("city:" + chosen.CityKey, until * 0.6f);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(chosen.KingdomKey))
|
|
{
|
|
StampCooldown("kingdom:" + chosen.KingdomKey, until * 0.6f);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(chosen.RegionKey))
|
|
{
|
|
StampCooldown("region:" + chosen.RegionKey, until * 0.5f);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(chosen.Verb))
|
|
{
|
|
StampCooldown("verb:" + chosen.Verb, until * 0.4f);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pick next candidate. Soft 70/30: prefer event-led when below target and pool non-empty.
|
|
/// Empty event pool → character-led without inventing events (mix meter not updated for empty-side windows).
|
|
/// </summary>
|
|
public static InterestCandidate Pick(List<InterestCandidate> pending, InterestCandidate current)
|
|
{
|
|
if (pending == null || pending.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
EventPool.Clear();
|
|
CharPool.Clear();
|
|
float now = Time.unscaledTime;
|
|
for (int i = 0; i < pending.Count; i++)
|
|
{
|
|
InterestCandidate c = pending[i];
|
|
if (c == null || !c.HasValidPosition)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (current != null && c.Key == current.Key)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (c.LeadKind == InterestLeadKind.CharacterLed)
|
|
{
|
|
CharPool.Add(c);
|
|
}
|
|
else
|
|
{
|
|
EventPool.Add(c);
|
|
}
|
|
}
|
|
|
|
bool bothHadCandidates = EventPool.Count > 0 && CharPool.Count > 0;
|
|
bool preferEvent = EventPool.Count > 0;
|
|
if (bothHadCandidates)
|
|
{
|
|
// Empty mix biases event-led; otherwise prefer event until share reaches target.
|
|
preferEvent = MixSampleCount == 0 || EventShare() < EventLedTarget;
|
|
}
|
|
else if (EventPool.Count == 0)
|
|
{
|
|
preferEvent = false;
|
|
}
|
|
|
|
// Soft mix: empty event pool → character-led without inventing events.
|
|
// Mix meter is updated in NoteSelection only when both pools were non-empty at pick time.
|
|
LastPickHadBothPools = bothHadCandidates;
|
|
|
|
List<InterestCandidate> pool = preferEvent
|
|
? (EventPool.Count > 0 ? EventPool : CharPool)
|
|
: (CharPool.Count > 0 ? CharPool : EventPool);
|
|
|
|
if (pool.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Ranked.Clear();
|
|
for (int i = 0; i < pool.Count; i++)
|
|
{
|
|
InterestCandidate c = pool[i];
|
|
float penalty = NoveltyPenalty(c, now);
|
|
// Epic / favorites bypass most novelty.
|
|
if (c.Urgency >= InterestTier.Epic || InterestScoring.IsNotable(c.FollowUnit))
|
|
{
|
|
penalty *= 0.15f;
|
|
}
|
|
|
|
c.Novelty = Mathf.Clamp01(1f - penalty);
|
|
InterestScoring.RecalcTotal(c);
|
|
Ranked.Add(c);
|
|
}
|
|
|
|
Ranked.Sort(InterestScoring.CompareByUrgencyThenScore);
|
|
InterestScoring.EnrichTopK(Ranked);
|
|
|
|
int topN = Mathf.Min(3, Ranked.Count);
|
|
if (topN <= 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Probabilistic among top-N after penalties.
|
|
int pick = Rng.Next(topN);
|
|
return Ranked[pick];
|
|
}
|
|
|
|
public static float EventShare()
|
|
{
|
|
if (RecentMix.Count == 0)
|
|
{
|
|
return EventLedTarget;
|
|
}
|
|
|
|
int events = 0;
|
|
for (int i = 0; i < RecentMix.Count; i++)
|
|
{
|
|
if (RecentMix[i] == InterestLeadKind.EventLed)
|
|
{
|
|
events++;
|
|
}
|
|
}
|
|
|
|
return events / (float)RecentMix.Count;
|
|
}
|
|
|
|
public static int MixSampleCount => RecentMix.Count;
|
|
|
|
private static float NoveltyPenalty(InterestCandidate c, float now)
|
|
{
|
|
float p = 0f;
|
|
p += CooldownWeight("unit:" + c.SubjectId, now, 0.55f);
|
|
if (!string.IsNullOrEmpty(c.SpeciesId))
|
|
{
|
|
p += CooldownWeight("species:" + c.SpeciesId, now, 0.25f);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.Category))
|
|
{
|
|
p += CooldownWeight("cat:" + c.Category, now, 0.2f);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.CityKey))
|
|
{
|
|
p += CooldownWeight("city:" + c.CityKey, now, 0.2f);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.KingdomKey))
|
|
{
|
|
p += CooldownWeight("kingdom:" + c.KingdomKey, now, 0.2f);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.Verb))
|
|
{
|
|
p += CooldownWeight("verb:" + c.Verb, now, 0.15f);
|
|
}
|
|
|
|
return Mathf.Clamp01(p);
|
|
}
|
|
|
|
private static float CooldownWeight(string key, float now, float weight)
|
|
{
|
|
if (!CooldownUntil.TryGetValue(key, out float until) || now >= until)
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
float remain = until - now;
|
|
float frac = Mathf.Clamp01(remain / HardCooldownSeconds);
|
|
return weight * frac;
|
|
}
|
|
|
|
private static void StampCooldown(string key, float until)
|
|
{
|
|
if (string.IsNullOrEmpty(key))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (CooldownUntil.TryGetValue(key, out float existing))
|
|
{
|
|
CooldownUntil[key] = Mathf.Max(existing, until);
|
|
}
|
|
else
|
|
{
|
|
CooldownUntil[key] = until;
|
|
}
|
|
}
|
|
}
|